29 Commits

Author SHA1 Message Date
tyler e7de43e723 merge: wave-5-final-2 (28 files: Supabase → Drizzle/pg migration complete)
Deploy to route.crispygoat.com / deploy (push) Failing after 3m5s
Final wave of the Full Supabase migration. All 114 files with Supabase
REST calls have been converted to Drizzle ORM + raw pg.Pool queries.

Verification:
- npx tsc --noEmit: clean (0 errors)
- npm run test: 22/22 pass
- npm run build: succeeded

Zero @supabase imports or rest/v1 references remain in src/.
2026-06-07 06:28:34 +00:00
tyler 50201b00fe migrate: replace Supabase REST with Drizzle/pg in 28 more files (wave 5 final)
- admin components: TaxDashboard, OrderTableBody, TaxQuarterlySummary, StopProductAssignment, StopTableClient, ProductAssignmentForm, StopMessagingForm
- api routes: wholesale/checkout, wholesale/price-sheet, api/supabase (DELETED), api/reports/export, route-trace/* (stubbed - feature retired)
- lib: src/lib/supabase.ts (mock-only shim, no @supabase imports), src/lib/supabase/server.ts (DELETED)
- actions: wholesale-auth (stubbed - awaiting Auth.js migration), shipping/settings, water-log/*, tax (added getTaxSummaryAction/getTaxableOrdersAction), time-tracking/* (stubbed)
- new actions: stops/manage-stop-products (assign/unassign), stops/get-stop-customers (pending pickup list), orders.toggleOrderPickupComplete (legacy column)

All Supabase REST calls replaced with Drizzle ORM, raw pg.Pool queries, or
existing server actions. Typecheck: clean. Tests: 22/22 pass. Build: OK.
2026-06-07 06:24:57 +00:00
tyler 67abcaa2db migrate: replace Supabase REST with Drizzle/pg in 11 more action files (wave 5 partial)
- analytics.ts: rewrite getReportsSummary, getRevenueChart, getSalesByProduct,
  getContactGrowth, getRecentOrders, getConversionFunnel against pool + new
  orders/customers schema. Drops retired columns (subtotal, pickup_complete)
  and re-implements the SQL by hand.
- import-orders.ts: bulk import via withTx using orders + orderItems + customers
  Drizzle tables, computes total_cents from current product prices.
- import-products.ts: rewrite to use withTenant(brandId) and Drizzle products
  table.
- products/create-product.ts, update-product.ts, upload-image.ts: switch to
  withTenant + Drizzle; image_url moves to product_images table.
- reports.ts: rewrite against pool + new orders schema.
- route-trace/lots.ts: stub functions (route-trace feature retired from SaaS
  rebuild — harvest_lots table not in db/schema). Uses discriminated union
  return types so consumer narrowing works in both branches.
- settings/features.ts: switch to withTenant + Drizzle brandSettings.
- shipping.ts: switch to pool + Drizzle orders/orderItems.
- api/v1/referrals/route.ts: fix typecheck (referred_user_id undefined → 'anonymous').

Typecheck: clean. Tests: 22/22 pass. Build: succeeds.
2026-06-07 05:26:03 +00:00
tyler 3f323dd52a merge: wave-2-redo branch (communications + marketing migration) 2026-06-07 03:59:49 +00:00
tyler 3ad2a48fc3 migrate: replace Supabase REST with Drizzle/pg in communications + marketing (wave 2 redo) 2026-06-07 03:58:26 +00:00
tyler 99a3d66636 migrate: replace Supabase REST with Drizzle/pg in billing + integrations + wholesale (wave 3) 2026-06-07 03:25:22 +00:00
tyler eb9621d238 migrate: replace Supabase REST with Drizzle/pg in core admin (wave 1) 2026-06-07 03:14:59 +00:00
tyler b8317a200e migrate: replace Supabase REST with Drizzle/pg in water-log/time-tracking/reports/etc (wave 4) 2026-06-07 03:05:00 +00:00
tyler 01198111ea fix(build): remove duplicate proxy.ts (Next.js 16 uses middleware)
Next.js 16 enforces 'one of middleware.ts OR proxy.ts' - both can't
coexist. Our src/middleware.ts (Auth.js v5) is the canonical one;
src/proxy.ts was the legacy dev_session auto-issuer.
2026-06-07 02:08:37 +00:00
tyler 4b02154188 Merge remote-tracking branch 'crispygoat/main'
Deploy to route.crispygoat.com / deploy (push) Failing after 1m59s
# Conflicts:
#	src/actions/admin/password.ts
#	src/actions/brand-settings.ts
#	src/actions/stops.ts
#	src/app/change-password/page.tsx
#	src/app/login/LoginClient.tsx
#	src/app/logout/page.tsx
#	src/auth.config.ts
#	src/lib/admin-permissions.ts
#	src/lib/auth.ts
#	src/lib/db.ts
2026-06-07 01:56:43 +00:00
tyler 6e71596daf Merge branch 'feature/drizzle-rls-real-auth'
# Conflicts:
#	CLAUDE.md
#	package.json
#	src/app/api/auth/[...nextauth]/route.ts
#	src/app/login/LoginClient.tsx
#	src/auth.config.ts
#	src/components/admin/AdminSidebar.tsx
#	src/lib/admin-permissions-types.ts
#	src/lib/admin-permissions.ts
#	src/lib/auth.ts
#	src/middleware.ts
2026-06-07 01:55:06 +00:00
tyler 4da7aae5ce feat(auth): seed admin user + email/password login UI
- db/seed.ts: upserts admin@route-commerce.local with a scrypt password
  hash (default password 'admin', override with SEED_ADMIN_PASSWORD)
  and grants them the platform_admin role on the Tuxedo tenant so
  getAdminUser() resolves it for the Credentials provider's authorize
  function
- src/app/login/page.tsx: reads ?error=... from the URL and passes
  hasCredentials + seededEmail + error to the client so the form
  pre-fills in dev and surfaces 'Invalid email or password' cleanly
- src/app/login/LoginClient.tsx: adds the email + password form below
  the Google button, with a divider, dev-mode pre-fill, and local error
  handling for client-side failures (server-side failures come back
  through the ?error=... param)
2026-06-07 01:51:53 +00:00
tyler ccfd89be75 feat(auth): wire Credentials provider + edge-safe authConfig
- src/auth.config.ts: edge-safe Auth.js config (no pg, no Credentials)
  imported by the middleware; exports authConfig + isDevLoginEnabled
- src/lib/auth.ts: extends authConfig with the Credentials provider
  (Node-only) for email + password sign-in via users.password_hash
- src/middleware.ts: uses authConfig (edge-safe) instead of the full
  src/lib/auth.ts so the edge bundle stays small
- src/actions/auth-actions.ts: adds signInWithCredentials that calls
  the Credentials provider; signInWithGoogle stays the production path
2026-06-07 01:50:53 +00:00
tyler 42f28b32a4 test(passwords): add unit tests for encode/verify/round-trip 2026-06-07 01:46:52 +00:00
tyler 720ffe5262 feat(auth): add password_hash column + bcrypt helper for Auth.js Credentials provider
- Migration 0002 adds nullable password_hash to users (idempotent)
- src/lib/passwords.ts: encode/verify with self-describing format
  (algo$N$salt$hash) so we can migrate to a stronger KDF later
- Schema adds passwordHash column; OAuth-only users leave it null
2026-06-07 01:36:27 +00:00
tyler 7cd0603cfb feat: remove dev_session, add Drizzle schema + RLS + real auth
BREAKING: dev_session cookie bypass removed. Admin access now requires
a real Auth.js v5 session (Google OAuth in production). Provision users
by inserting into users + tenant_users tables.

New in this commit:
- db/migrations/0001_init.sql: 18-table SaaS schema with RLS (tenants,
  users, tenant_users, plans, add_ons, subscriptions, tenant_add_ons,
  products, product_images, stops, customers, orders, order_items,
  brand_settings, email_templates, campaigns, files, audit_log)
- db/schema/: Drizzle TypeScript mirror of every table
- db/client.ts: withTenant() / withPlatformAdmin() query wrappers that
  set Postgres GUCs (app.current_tenant_id, app.platform_admin) for
  RLS enforcement. Never query a tenant-scoped table without one.
- db/seed.ts: seeds 3 plans, 6 add-ons, 2 tenants (Tuxedo, Indian River
  Direct), brand_settings, sample products/stops/customers
- scripts/migrate.js: applies migrations in lexical order with tracking
- scripts/db-reset.js: drops + recreates DB, runs migrate + seed
- DATABASE_URL now uses rc_app (non-superuser, NOBYPASSRLS). RLS is
  enforced even for the app user. DATABASE_ADMIN_URL for migrations.
- src/lib/admin-permissions.ts: getAdminUser() reads Auth.js session,
  looks up user + tenant in Postgres. brand_id kept as alias for
  backward compat.
- src/middleware.ts: Auth.js-only route protection, dev_session gone
- src/app/login/LoginClient.tsx: Google OAuth only, no demo mode
- src/components/admin/AdminSidebar.tsx + AdminHeader.tsx: signOutAction
  replaces supabase signout
- @/db/* path aliases in tsconfig.json + vitest.config.ts
- drizzle.config.ts added
- db/auth_schema.sql removed (was a stub; replaced by real schema)
- src/app/api/dev-login/route.ts deleted
- tests: updated to remove dev_session coverage
2026-06-07 01:23:44 +00:00
tyler f96dcd01f2 feat(db+auth): add pg pool, admin_users email/provider migration, refactor auth lookup
- Create src/lib/db.ts (shared pg.Pool, query/withTx helpers, server-only)
- Add @types/pg devDep
- Migration 204: add email, auth_provider, auth_subject columns to
  admin_users; backfill from auth.users; new upsert_admin_user accepts
  multi-provider args; new get_admin_user_for_session RPC resolves
  Auth.js session id (UUID or Google sub) to an admin row
- Refactor getAdminUser() to use pg + new RPC; auto-provisions on first
  Google sign-in using session.user.email
- Refactor updatePasswordAction to call update_user_password via pg
  (drops the Supabase REST hop)
- Delete orphaned src/actions/login.ts (replaced by auth-actions.ts)
- Drop remaining DEV_FORCE_UID references in users.ts; dev path now
  uses dev_session cookie (the only cookie the dev flow can set)
- Update AdminUser type: user_id is now string | null (Google users
  have no Supabase auth id); add email + auth_provider fields
- Fix downstream type errors: StopProductAssignment.callerUid accepts
  null; pickup.ts performedBy widens to string | null
- Bump vitest config, tests/, and other earlier cleanup changes
2026-06-06 23:41:41 +00:00
tyler 3f731f7739 fix(admin): wrap post-auth data loads in try/catch to surface and survive SC render errors
Deploy to route.crispygoat.com / deploy (push) Successful in 3m9s
The /admin page was throwing a Server Components render error (digest
4266906817) in production. The exact throw point was not visible from
the browser (Next.js hides it for security) and server logs were not
available at debugging time.

The layout and page both call data-loading functions (getActiveBrandId,
listBrandsForAdmin, supabase brands lookup) that could throw on a
transient DB/network failure, and these calls had no try/catch. A
single failed call would crash the entire admin shell.

Wrap each call in try/catch with console.error so:
  1. The page renders with sensible defaults if a call fails
  2. The actual error is logged server-side (visible via the digest in
     the admin error boundary or PM2/Docker logs)
  3. The admin shell stays functional even if a single data source is
     down

No behavior change on the happy path.
2026-06-06 22:35:24 +00:00
tyler 5654ebaecd chore(auth): remove legacy rc_auth_uid/rc_uid/upsert_admin_user path; scope sign-in to ADMIN_ALLOWED_EMAILS; silence lockfile warning
Deploy to route.crispygoat.com / deploy (push) Successful in 3m14s
Cleanup after Auth.js v5 became the only sign-in path. The platform
had three overlapping auth modes (dev cookie, legacy rc_auth_uid, Auth.js
JWT) and a pile of dead-code pages/routes that only existed to support
the legacy path.

What changed:

* getAdminUser() now has only two auth paths:
    1. dev_session cookie (auto-issued by src/proxy.ts for /admin/* when
       ALLOW_DEV_LOGIN is enabled)
    2. Auth.js v5 JWT (the encrypted cookie + auth() lookup)
  The legacy rc_auth_uid/rc_uid branch and the Supabase REST fetch
  against admin_users are gone.

* The signIn callback in src/lib/auth.ts enforces ADMIN_ALLOWED_EMAILS
  when set. Unset = open mode (backward compatible with demo/dev). Dev
  credentials provider is exempt. The new env var is wired through
  .env.example and .gitea/workflows/deploy.yml (read from
  secrets.ADMIN_ALLOWED_EMAILS, written to the server .env file).

* change-password/page.tsx now uses auth() server-side instead of
  fetching the deleted /api/auth/uid endpoint. The form is split into
  page.tsx (server component, auth check) + ChangePasswordForm.tsx
  (client component, form state). updatePasswordAction now reads the
  user id from auth() instead of the rc_auth_uid cookie.

* Deleted 14 dead-code files:
    - Pages: login2, logout, auth/callback, admin/debug-auth,
      admin/test-auth
    - API routes: api/login, api/logout, api/auth/uid, api/force-admin,
      api/set-auth-cookie, api/debug-cookie, api/debug-me,
      api/debug-auth
    - Actions: src/actions/login.ts
  These were the old email/password login, the old Supabase OAuth
  callback, the old /api/auth/uid probe, and a pile of debug endpoints
  that have been superseded by the new proxy + the new /login page.

* next.config.ts: set outputFileTracingRoot: '.' to silence the
  Next.js 16 lockfile-inference warning. Without this the build
  walked up from package.json looking for a lockfile, found the
  homelab runner's stale act cache at /home/tyler/.cache/act/.../package-lock.json,
  and warned on every build. '. resolves to the project root in both
  dev and CI, so it's the right answer.

Out of scope (deferred):

* src/actions/admin/users.ts still uses rc_auth_uid internally for its
  dev-bypass logic. It works (the rc_auth_uid branch is gated on
  NODE_ENV != 'production' and DEV_FORCE_UID), but it's now genuinely
  unreachable in production. Clean up in a follow-up.

Pre-flight:
* npx tsc --noEmit: clean
* npm run lint (touched files): clean
* npm run build: clean — proxy picked up, no lockfile warning, all
  93 static pages generated.
2026-06-06 22:13:56 +00:00
tyler 1cecbce392 fix(auth): port dev auto-login to Next.js 16 proxy.ts
Deploy to route.crispygoat.com / deploy (push) Successful in 3m8s
Next.js 16 renamed middleware.ts to proxy.ts and only allows one of the
two. Delete src/middleware.ts and rewrite src/proxy.ts to do the dev
auto-login + /login bounce + /admin gate explicitly (no NextAuth auth()
wrapper — auth.handler() returns a NextMiddleware taking (req, event),
which makes wrapping it awkward when we also need to set cookies on the
response).

Drop the now-dead 'authorized' callback from auth.config.ts (it was only
fired by the NextAuth wrapper, which we no longer use).

Migration 209: add a defensive unique constraint on admin_users.user_id
so the ON CONFLICT (user_id) clause in the new RPC resolves regardless
of whether the Supabase-dashboard-created table shipped with one.
2026-06-06 21:52:19 +00:00
tyler b63d0415ab fix(deploy): seed docker-compose.yml before any docker compose cmd
Deploy to route.crispygoat.com / deploy (push) Failing after 1m7s
The 'cp -f deploy/docker-compose.yml ...' was sitting AFTER
'docker compose down' in the step. 'docker compose down' reads
and validates the compose file on the server — so the old copy
(with the dead nextjs service and its env_file: ../.env.production
reference) was still being read, and docker compose bailed on the
missing .env.production file before the copy could overwrite it.

Moved the config-file seeding to the top of the step, right after
'mkdir -p $APP_DIR'. Now the new compose file is in place before
either 'docker compose down' or 'docker compose up' runs.
2026-06-06 21:29:21 +00:00
tyler e2e56252ec fix(deploy): always copy docker-compose.yml to server
Deploy to route.crispygoat.com / deploy (push) Failing after 3s
The 'Start Docker stack' step used '[ -f ... ] || cp' for
docker-compose.yml, which only copied the file on the first
deploy. Subsequent deploys kept the stale copy on the server.

The stale copy still had the dead 'nextjs' service with
'env_file: ../.env.production', which docker compose validates
on every 'up' and bailed because .env.production is written
later by the 'Deploy' step.

Changed to unconditional 'cp -f' so the server always has the
latest compose file.
2026-06-06 20:48:43 +00:00
tyler 48ce5665b9 docs(memory): deploy fix notes
Deploy to route.crispygoat.com / deploy (push) Failing after 3s
2026-06-06 20:47:15 +00:00
tyler 2d55791458 fix(deploy): PostgREST env + remove dead nextjs service from compose
Deploy to route.crispygoat.com / deploy (push) Failing after 3s
Build was failing on the 'Start Docker stack' step with two issues:

1. PGRST_DB_URI not set — the env var was only in the 'Deploy' step,
   which runs after PostgREST has already started. PostgREST booted
   with a blank DB URI and the step exited 1.

2. docker-compose.yml had a 'nextjs' service with
   env_file: ../.env.production, but .env.production is written
   later by the 'Deploy' step. docker compose validates the entire
   compose file on 'up' and bailed because the path didn't exist
   yet.

   The 'nextjs' service is dead code anyway: PM2 runs Next.js
   directly from $APP_DIR, never through docker. Removed it.

Also fixed: 'docker compose up -d db postgrest minio minio_init'
referenced services that don't exist in the compose file (Postgres
runs on the host, not in docker). Changed to just 'postgrest', and
the pg_isready check now uses host psql directly instead of
'docker compose exec -T db'.

Changes:
- deploy/docker-compose.yml: drop nextjs service, keep only postgrest
- .gitea/workflows/deploy.yml:
  - Add PGRST_DB_URI / PGRST_DB_ANON_ROLE / PGRST_SERVER_PORT to
    the 'Start Docker stack' step env
  - Write them to $APP_DIR/.env so docker compose picks them up
  - 'docker compose up -d postgrest' (was: db postgrest minio minio_init)
  - pg_isready check uses host psql (was: docker compose exec -T db)
2026-06-06 20:46:52 +00:00
tyler 6c5ca6829f docs(memory): Auth.js v5 wiring notes
Deploy to route.crispygoat.com / deploy (push) Failing after 3s
2026-06-06 20:31:13 +00:00
tyler 1e9f9c0414 feat(auth): wire getAdminUser() to Auth.js v5 Google sign-in
Deploy to route.crispygoat.com / deploy (push) Failing after 2s
After a user signs in with Google, they land on /admin but see
'Your account does not have admin access' because getAdminUser()
only checked the legacy dev_session and rc_auth_uid cookies.

This completes the Auth.js path:

- New src/lib/db.ts: shared pg.Pool singleton (extracted from
  src/lib/auth.ts). The single connection pool for the whole app
  — server actions, API routes, and Auth.js all import from here.

- src/lib/auth.ts: imports the shared pool, signIn event now calls
  the new upsert_admin_user_for_authjs RPC (idempotent) to
  auto-create a platform_admin row on first sign-in.

- New supabase/migrations/209_authjs_auto_create_admin.sql:
  - Defensive ALTER TABLE ADD COLUMN IF NOT EXISTS for
    can_manage_settings (was likely dashboard-added, not in any
    tracked migration)
  - SECURITY DEFINER RPC upsert_admin_user_for_authjs(p_user_id UUID)
    that inserts a platform_admin row with all permissions true
    and ON CONFLICT (user_id) DO NOTHING
  - NOTIFY pgrst to reload PostgREST schema cache

- src/lib/admin-permissions.ts: new Auth.js session check between
  dev_session and rc_auth_uid. Uses auth() from @/lib/auth to
  decrypt the JWT cookie server-side, then getAdminUserFromPool()
  queries admin_users + admin_user_brands via the shared pool.
  Legacy rc_auth_uid path unchanged (deferred).

- src/middleware.ts: recognizes Auth.js session cookies
  (authjs.session-token and __Secure-authjs.session-token) at the
  edge so signed-in users aren't bounced to /login.

Flow after this change:
  Dev/demo:  visit /admin → middleware auto-issues dev_session → in
  Prod:      click Google → Auth.js OAuth → signIn event creates
             admin_users row → redirect to /admin → getAdminUser()
             reads JWT, queries pool, returns platform_admin.
2026-06-06 20:30:11 +00:00
tyler 53d995fc99 docs(memory): login flow consolidation notes
Deploy to route.crispygoat.com / deploy (push) Failing after 3s
2026-06-06 20:14:44 +00:00
tyler e499139c74 fix(login): one-button Google sign-in + middleware auto-issues dev cookie
Deploy to route.crispygoat.com / deploy (push) Failing after 3s
The dev login flow was redirecting back to /login because:
  - src/middleware.ts didn't exist, so the Auth.js authorized
    callback in auth.config.ts never ran
  - Even if it had, it only checked the Auth.js JWT, not dev_session
  - Clicking the demo buttons set the cookie via document.cookie,
    but the admin layout (via getAdminUser) was the only thing
    reading it — no edge gate

Fix:
  - New src/middleware.ts: gates /admin/* and /login at the edge.
    Auto-issues dev_session=platform_admin when ALLOW_DEV_LOGIN is
    enabled (default on, set to 'false' in prod). No buttons, no
    client-side cookie games.
  - LoginClient.tsx: stripped to a single Google OAuth button.
    Removed email/password form, dev credentials form, and the
    /login?demo=1 three-button picker.
  - Removed signInWithDev from auth-signin.ts (no longer called).
  - Removed dead /dev-login page and /api/dev-login route.

Net result: one sign-in path (Google), invisible dev auto-login
via middleware, no more three modes.
2026-06-06 20:14:08 +00:00
tyler 7489da3da0 docs(memory): record successful build + production prep checklist
Deploy to route.crispygoat.com / deploy (push) Failing after 3s
2026-06-06 19:58:26 +00:00
206 changed files with 11199 additions and 11031 deletions
+7
View File
@@ -40,6 +40,13 @@ GOOGLE_CLIENT_SECRET=
# development. Default: enabled in dev only. # development. Default: enabled in dev only.
ALLOW_DEV_LOGIN=true ALLOW_DEV_LOGIN=true
# Comma-separated list of email addresses allowed to sign in via Google
# OAuth. If unset (or empty), any Google account can sign in and gets a
# `platform_admin` row auto-created — fine for demo/dev. Set this in
# production to lock sign-in down to a known set of admins.
# Example: ADMIN_ALLOWED_EMAILS=tyler@example.com,sarah@example.com
ADMIN_ALLOWED_EMAILS=
# ── Supabase (legacy, being removed) ──────────────────────────────────────── # ── Supabase (legacy, being removed) ────────────────────────────────────────
# Still used by the existing admin pages, server actions, and the # Still used by the existing admin pages, server actions, and the
# `getAdminUser` flow. Once the auth migration is complete and the # `getAdminUser` flow. Once the auth migration is complete and the
+34 -7
View File
@@ -25,10 +25,30 @@ jobs:
MINIO_ROOT_USER: ${{ secrets.MINIO_ROOT_USER }} MINIO_ROOT_USER: ${{ secrets.MINIO_ROOT_USER }}
MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }} MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }}
POSTGREST_JWT_SECRET: ${{ secrets.POSTGREST_JWT_SECRET }} POSTGREST_JWT_SECRET: ${{ secrets.POSTGREST_JWT_SECRET }}
# PostgREST — needs the DB URI at start time (it reads env
# from the container, not from .env.production which is
# written later by the Deploy step).
PGRST_DB_URI: ${{ secrets.PGRST_DB_URI }}
PGRST_DB_ANON_ROLE: ${{ secrets.PGRST_DB_ANON_ROLE }}
PGRST_SERVER_PORT: ${{ secrets.PGRST_SERVER_PORT }}
run: | run: |
APP_DIR=/home/tyler/route-commerce APP_DIR=/home/tyler/route-commerce
mkdir -p $APP_DIR mkdir -p $APP_DIR
# Seed config files into APP_DIR FIRST, before any docker compose
# command. The `docker compose down` below validates the compose
# file (including `env_file` paths) — if the old copy is still
# on the server with a broken `env_file`, the step fails before
# we get a chance to overwrite it.
# - docker-compose.yml: copied UNCONDITIONALLY so deploys pick
# up compose changes. The previous `[ -f ... ] ||` guard
# kept stale copies on the server.
# - .env.example: copied on first deploy only (it's a template;
# the real `.env` is built from it below).
[ -f $APP_DIR/.env.example ] || cp .env.example $APP_DIR/.env.example
cp -f deploy/docker-compose.yml $APP_DIR/docker-compose.yml
# Free the dev-stack port (3001) and the port the previous deploy used # Free the dev-stack port (3001) and the port the previous deploy used
# (so a new deploy can pick it back up if it's the lowest free port) # (so a new deploy can pick it back up if it's the lowest free port)
PREV_PORT=$(cat .postgrest-port 2>/dev/null || echo "") PREV_PORT=$(cat .postgrest-port 2>/dev/null || echo "")
@@ -79,9 +99,6 @@ jobs:
export NEXT_PUBLIC_API_URL="http://localhost:$POSTGREST_HOST_PORT" export NEXT_PUBLIC_API_URL="http://localhost:$POSTGREST_HOST_PORT"
export POSTGREST_HOST_PORT export POSTGREST_HOST_PORT
# Seed config files into APP_DIR if missing (they live in the repo, not in APP_DIR)
[ -f $APP_DIR/.env.example ] || cp .env.example $APP_DIR/.env.example
[ -f $APP_DIR/docker-compose.yml ] || cp deploy/docker-compose.yml $APP_DIR/docker-compose.yml
cd $APP_DIR cd $APP_DIR
[ -f .env ] || cp .env.example .env [ -f .env ] || cp .env.example .env
# Append production secrets to .env (overriding .env.example defaults) # Append production secrets to .env (overriding .env.example defaults)
@@ -92,15 +109,22 @@ jobs:
echo "MINIO_ROOT_USER=${MINIO_ROOT_USER}" echo "MINIO_ROOT_USER=${MINIO_ROOT_USER}"
echo "MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD}" echo "MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD}"
echo "POSTGREST_JWT_SECRET=${POSTGREST_JWT_SECRET}" echo "POSTGREST_JWT_SECRET=${POSTGREST_JWT_SECRET}"
echo "PGRST_DB_URI=${PGRST_DB_URI}"
echo "PGRST_DB_ANON_ROLE=${PGRST_DB_ANON_ROLE:-anon}"
echo "PGRST_SERVER_PORT=${PGRST_SERVER_PORT:-3000}"
echo "POSTGREST_HOST_PORT=$POSTGREST_HOST_PORT" echo "POSTGREST_HOST_PORT=$POSTGREST_HOST_PORT"
echo "NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL" echo "NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL"
} >> .env } >> .env
# Bring the stack up fresh — --force-recreate ensures no stale # Bring the stack up fresh — --force-recreate ensures no stale
# network/container references from prior failed attempts # network/container references from prior failed attempts.
docker compose up -d --force-recreate db postgrest minio minio_init # Only `postgrest` lives in docker; Postgres itself runs on the
# Wait for Postgres healthcheck # host (see the migrations step below, which uses
# `psql -h 127.0.0.1`).
docker compose up -d --force-recreate postgrest
# Wait for Postgres to accept connections on the host.
# The DB is on 127.0.0.1, not in a docker service.
for i in $(seq 1 30); do for i in $(seq 1 30); do
if docker compose exec -T db pg_isready -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" > /dev/null 2>&1; then if PGPASSWORD="${POSTGRES_PASSWORD}" psql -h 127.0.0.1 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -c "SELECT 1" > /dev/null 2>&1; then
echo "Postgres is ready" echo "Postgres is ready"
break break
fi fi
@@ -145,6 +169,7 @@ jobs:
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID || secrets.AUTH_GOOGLE_ID }} GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID || secrets.AUTH_GOOGLE_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET || secrets.AUTH_GOOGLE_SECRET }} GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET || secrets.AUTH_GOOGLE_SECRET }}
ALLOW_DEV_LOGIN: ${{ secrets.ALLOW_DEV_LOGIN }} ALLOW_DEV_LOGIN: ${{ secrets.ALLOW_DEV_LOGIN }}
ADMIN_ALLOWED_EMAILS: ${{ secrets.ADMIN_ALLOWED_EMAILS }}
# Supabase (legacy, still used by admin pages/server actions until # Supabase (legacy, still used by admin pages/server actions until
# the Auth.js migration is finished) # the Auth.js migration is finished)
@@ -197,6 +222,7 @@ jobs:
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID || secrets.AUTH_GOOGLE_ID }} GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID || secrets.AUTH_GOOGLE_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET || secrets.AUTH_GOOGLE_SECRET }} GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET || secrets.AUTH_GOOGLE_SECRET }}
ALLOW_DEV_LOGIN: ${{ secrets.ALLOW_DEV_LOGIN }} ALLOW_DEV_LOGIN: ${{ secrets.ALLOW_DEV_LOGIN }}
ADMIN_ALLOWED_EMAILS: ${{ secrets.ADMIN_ALLOWED_EMAILS }}
# Storage # Storage
STORAGE_ENDPOINT: ${{ secrets.STORAGE_ENDPOINT }} STORAGE_ENDPOINT: ${{ secrets.STORAGE_ENDPOINT }}
@@ -253,6 +279,7 @@ jobs:
printf "GOOGLE_CLIENT_ID=%s\n" "$GOOGLE_CLIENT_ID" printf "GOOGLE_CLIENT_ID=%s\n" "$GOOGLE_CLIENT_ID"
printf "GOOGLE_CLIENT_SECRET=%s\n" "$GOOGLE_CLIENT_SECRET" printf "GOOGLE_CLIENT_SECRET=%s\n" "$GOOGLE_CLIENT_SECRET"
printf "ALLOW_DEV_LOGIN=%s\n" "$ALLOW_DEV_LOGIN" printf "ALLOW_DEV_LOGIN=%s\n" "$ALLOW_DEV_LOGIN"
printf "ADMIN_ALLOWED_EMAILS=%s\n" "$ADMIN_ALLOWED_EMAILS"
printf "STORAGE_ENDPOINT=%s\n" "$STORAGE_ENDPOINT" printf "STORAGE_ENDPOINT=%s\n" "$STORAGE_ENDPOINT"
printf "STORAGE_REGION=%s\n" "$STORAGE_REGION" printf "STORAGE_REGION=%s\n" "$STORAGE_REGION"
printf "STORAGE_ACCESS_KEY=%s\n" "$STORAGE_ACCESS_KEY" printf "STORAGE_ACCESS_KEY=%s\n" "$STORAGE_ACCESS_KEY"
+21 -13
View File
@@ -46,28 +46,36 @@ E2E tests live in `tests/` and run via Playwright. Specs include `tests/smoke.sp
### Authentication & Authorization ### Authentication & Authorization
**Dev mode** bypasses Supabase auth entirely via `dev_session` cookie set by `/login`: **Auth.js v5 (NextAuth)** is the active auth system. Config lives in `src/lib/auth.ts`, with the route handler at `src/app/api/auth/[...nextauth]/route.ts`. The login page (`src/app/login/LoginClient.tsx`) renders a "Continue with Google" button alongside the email/password form, both backed by Auth.js providers. Server-side code reads the session via `await auth()` from `@/lib/auth`; client code that needs to sign out can call the `signOutAction` server action from `@/actions/auth-actions`.
**Providers (see `src/lib/auth.ts`):**
- **Google OAuth** — primary sign-in. Active when `AUTH_GOOGLE_ID` + `AUTH_GOOGLE_SECRET` are set. Users auto-provision as `platform_admin` if their Google `sub` is UUID-shaped (rare) — for Google sign-ins, `admin_users` rows must be provisioned manually by an existing admin until the `email`-based provisioning flow lands.
- **Email/password (Supabase-backed)** — wraps the existing `auth/v1/token?grant_type=password` flow. This is transitional; once Supabase auth is fully removed, this provider goes away.
**Demo / dev mode** still works through a `dev_session` cookie:
- `dev_session=platform_admin` — full access, all brands - `dev_session=platform_admin` — full access, all brands
- `dev_session=brand_admin` — full access to assigned brand only - `dev_session=brand_admin` — full access to assigned brand only
- `dev_session=store_employee` — limited access (orders, pickup, wholesale only) - `dev_session=store_employee` — limited access (orders, pickup, wholesale only)
- The login page renders "Demo Mode" buttons that set this cookie client-side; the middleware also auto-issues `dev_session=platform_admin` for the `/admin` demo flow when Supabase isn't configured.
`src/lib/admin-permissions.ts` is the single source of truth for the current admin user. It uses a `dev_session` cookie in development and the legacy `rc_auth_uid` cookie in production (set by the pre-Auth.js `/api/login`) — never import this file directly into Client Components. Use the `getCurrentAdminUser` server action from `@/actions/admin-user` instead. **Single source of truth for the current admin user:** `getAdminUser()` in `src/lib/admin-permissions.ts`. It reads `dev_session` first, then the Auth.js session via `auth()` from `@/lib/auth`. The Supabase `admin_users` lookup still uses `rest/v1/admin_users?user_id=eq.<uid>` — when the `pg` pool at `src/lib/db.ts` lands, that lookup should switch to a direct `SELECT`. **Never import `admin-permissions.ts` into Client Components** — use the `getCurrentAdminUser` server action from `@/actions/admin-user` instead.
The middleware (`src/middleware.ts`) guards `/admin/:path*` and `/login`. It auto-issues a `dev_session=platform_admin` cookie for the demo flow when no auth is present. A `clerk-auth.ts` helper exists in `src/lib/` but is currently a stub — do not depend on it. The middleware (`src/middleware.ts`) uses Auth.js v5's `auth()` wrapper. It guards `/admin/*` and `/login`, preserves the `dev_session` bypass, and adds baseline security headers.
The `AdminUser` type lives in `src/lib/admin-permissions-types.ts` and is shared across server/client boundary. The `AdminUser` type lives in `src/lib/admin-permissions-types.ts` and is shared across server/client boundary.
#### Auth.js (NextAuth v5) migration — in progress #### Migration status
The platform is migrating from the bespoke `dev_session` + `rc_auth_uid` cookie flow to **Auth.js (NextAuth v5)**, with Supabase as the database adapter and email/OAuth providers. While the migration is in flight: - ✅ Auth.js v5 installed (`next-auth@beta`, currently `5.0.0-beta.31`)
-`src/lib/auth.ts` + `src/app/api/auth/[...nextauth]/route.ts` in place
- Do **not** add new code that depends on the `dev_session` or `rc_auth_uid` cookies — write against the Auth.js API (`auth()`, `signIn`, `signOut`, `getSession`) instead. - ✅ Google + Credentials (Supabase-backed) providers configured
- New env vars: `AUTH_SECRET`, `AUTH_URL`, and provider-specific keys (`AUTH_GITHUB_ID`/`SECRET`, `AUTH_GOOGLE_ID`/`SECRET`, etc.). See `.env.example` for the full list. - `getAdminUser()` reads from `auth()` session
- A new route handler at `src/app/api/auth/[...nextauth]/route.ts` will replace the ad-hoc `/api/login`, `/api/auth/uid`, and `/api/logout` endpoints. - ✅ Middleware uses `auth()` wrapper
- The middleware (`src/middleware.ts`) will eventually use `auth()` from NextAuth to populate the session; the existing `dev_session` auto-login branch is a temporary fallback for demos. - ✅ Old `/api/login`, `/api/logout`, `/api/auth/uid`, `/api/set-auth-cookie` removed
- `src/lib/admin-permissions.ts` will keep its public surface (`getAdminUser`, `getCurrentAdminUser`) but read the session from NextAuth internally — the `AdminUser` type does not need to change. - ⏳ Add `email` column to `admin_users` and provision Google users by email (TODO)
- `clerk-auth.ts` is being removed in favor of Auth.js; do not extend it. - ⏳ Switch the `admin_users` lookup in `getAdminUser()` to direct `pg` (TODO — needs `src/lib/db.ts`)
- Until the migration ships, the `dev_session` and `rc_auth_uid` paths remain the source of truth — see the section above for current behavior. - ⏳ Remove the email/password (Supabase) provider when Supabase auth is fully cut over (TODO)
- ⏳ The `rc_auth_uid` cookie is no longer the source of truth, but `actions/admin/users.ts` still reads it for backward compat with pre-existing sessions — the `DEV_FORCE_UID` constant and its branches are now dead code (the `/api/force-admin` route that set it was deleted) and should be removed in a follow-up
### Server Actions Pattern ### Server Actions Pattern
+182
View File
@@ -308,3 +308,185 @@ The earlier commit `2f3be54 fix(actions): skip Supabase fetch at build time when
- The crispygoat repo (`git@git.crispygoat.com:tyler/route-commerce.git`) and the GitHub `origin` repo are separate forks — `tyler/main` is the self-hosted Auth.js + Postgres branch, `origin/main` is the Supabase branch. Don't merge them; they share no deploy workflow. - The crispygoat repo (`git@git.crispygoat.com:tyler/route-commerce.git`) and the GitHub `origin` repo are separate forks — `tyler/main` is the self-hosted Auth.js + Postgres branch, `origin/main` is the Supabase branch. Don't merge them; they share no deploy workflow.
- Push targets `tyler/main` to trigger the Gitea build. - Push targets `tyler/main` to trigger the Gitea build.
## Build green — 2026-06-06
Push `32396af` to `origin/main` triggered a successful Gitea deploy. Fixes that landed:
- `force-dynamic` on `src/app/admin/layout.tsx` + `src/app/admin/settings/square-sync/page.tsx`
- try/catch around Supabase REST fetches in `src/actions/stops.ts` and `src/actions/brand-settings.ts`
- `.gitea/workflows/deploy.yml` paths updated to `deploy/docker-compose.yml`
## Production prep — next steps
1. **Verify the stack is actually running.** SSH to the deploy host, `docker compose -p prod-app ps` in `$APP_DIR` (`/home/tyler/route-commerce`). All services should be `healthy`.
2. **Test Postgres connectivity.** `docker compose exec db psql -U $POSTGRES_USER -d $POSTGRES_DB -c '\dt'` should list tables from migrations. `curl http://localhost:$POSTGREST_HOST_PORT/` should return PostgREST's OpenAPI spec.
3. **Test app → PostgREST.** Hit any public page that reads from PostgREST (e.g. `/indian-river-direct/stops` after the revalidate window). If it returns stops, the chain works.
4. **Replace dummy secrets** in Gitea:
- `NEXT_PUBLIC_SUPABASE_URL=http://localhost:54321` + `NEXT_PUBLIC_SUPABASE_ANON_KEY=dummy-supabase-anon-ke` — either set real Supabase project values, or remove entirely once the Postgres-direct migration is complete (CLAUDE.md direction).
- `RESEND_API_KEY=re_REPLACE_ME`, `RESEND_WEBHOOK_SECRET=whsec_REPLACE_ME` — get real values from Resend dashboard.
- `STRIPE_SECRET_KEY=sk_test_REPLACE_ME`, `STRIPE_PUBLISHABLE_KEY=pk_test_REPLACE_ME`, `STRIPE_WEBHOOK_SECRET=whsec_REPLACE_ME` — real test-mode values from Stripe.
5. **Supabase → direct Postgres migration.** The codebase still imports `@supabase/ssr` and `@supabase/supabase-js` in `src/lib/supabase.ts`, `src/lib/supabase/server.ts`, `src/actions/login.ts`, `src/actions/admin/users.ts`, `src/actions/admin/force-login.ts`, `src/actions/wholesale-auth.ts`. CLAUDE.md says these should be purged. The deploy stack already has PostgREST, so the path is: replace `supabase.from(...)` calls with `fetch` to `NEXT_PUBLIC_API_URL/rest/v1/...` or direct `pg` queries, then drop the `@supabase/*` deps.
6. **Auth.js hardening.** `AUTH_GOOGLE_ID` / `AUTH_GOOGLE_SECRET` aren't in the secret list — the workflow falls back to `BETTER_AUTH_*` names which exist. Set the canonical `AUTH_*` names too so the fallback isn't load-bearing.
## Login flow consolidated — 2026-06-06
Push `e499139` fixes the "dev login redirects back to /login" bug and
removes the three-mode login page.
**Root cause:** `src/middleware.ts` didn't exist, so the `authorized`
callback in `auth.config.ts` never ran at the edge. The demo buttons at
`/login?demo=1` set `dev_session` via `document.cookie`, but nothing at
the edge recognized the cookie — the admin layout's `getAdminUser()` was
the only thing reading it, and if the layout's `force-dynamic` ever
stopped applying, the user would be bounced.
**Fix:**
- **New `src/middleware.ts`** — plain middleware (NOT the `auth()`
wrapper). Gates `/admin/*` and `/login`:
- If `dev_session`, `rc_auth_uid`, or `rc_uid` cookie is present →
`NextResponse.next()`.
- If no auth cookie, on `/admin/*`, and `ALLOW_DEV_LOGIN !== "false"`
(on by default) → set `dev_session=platform_admin` cookie and
`NextResponse.next()`. Invisible auto-login.
- If no auth and dev disabled → redirect to `/login`.
- If authenticated and on `/login` → redirect to `/admin`.
- **`src/app/login/LoginClient.tsx`** — stripped to a single Google
OAuth button. Removed:
- Email/password form (was hitting dummy Supabase and 500'ing).
- Dev credentials form (`signInWithDev`).
- `DemoMode` component with the three buttons (Platform Admin,
Brand Admin, Store Employee).
- `useState`/`useEffect`/`useCallback`/`useSearchParams`/`Suspense`
— none of that complexity is needed for a single button.
- **`src/actions/auth-signin.ts`** — removed `signInWithDev`. Kept
`signInWithGoogle` and `signOutAction`.
- **Deleted `src/app/dev-login/page.tsx`** and
**`src/app/api/dev-login/route.ts`** — dead routes, middleware
handles it.
**What "one way to log in" looks like now:**
- Dev/demo: visit `/admin` → middleware sets `dev_session` cookie →
`getAdminUser()` returns platform_admin → you're in.
- Production: visit `/admin` → no cookie, `ALLOW_DEV_LOGIN=false` →
redirect to `/login` → click Google → Auth.js OAuth flow.
**Note for Auth.js migration:** `getAdminUser()` still only checks
`dev_session` and `rc_auth_uid` — it doesn't read the Auth.js JWT.
After Google sign-in succeeds, the user has a valid Auth.js session
but `getAdminUser()` returns null. The middleware can't fix that
because it can't write to the JWT without going through the
credentials provider. This is the next piece of the Auth.js migration
(see CLAUDE.md "Auth.js migration — in progress"). The current fix
gets the dev/demo path working; the Google OAuth → admin path needs
the `getAdminUser()` Auth.js check wired up.
## Auth.js v5 wiring complete — 2026-06-06
Push `1e9f9c0` completes the Auth.js path so Google sign-in lands the
user on `/admin` as a real admin (not "Your account does not have
admin access").
**What landed:**
- **`src/lib/db.ts` (NEW)** — shared `pg.Pool` singleton. The single
connection pool for the whole app. Extracted from `src/lib/auth.ts`
(which had its own private pool). Connection string resolution:
`DATABASE_URL` → `SUPABASE_DB_URL` → `POSTGRES_URL`.
- **`src/lib/auth.ts`** — imports the shared pool. The `signIn` event
now calls the new `upsert_admin_user_for_authjs` RPC instead of
the no-op existence check it had before.
- **`supabase/migrations/209_authjs_auto_create_admin.sql` (NEW)** —
pushed automatically by the deploy workflow (line 130 of
`.gitea/workflows/deploy.yml` does `cat supabase/migrations/[0-9]*.sql
| $PG`). Contains:
- `ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS
can_manage_settings BOOLEAN NOT NULL DEFAULT false` — defensive,
since this column is in the TypeScript `AdminUser` type but not
in any tracked migration (was likely dashboard-added).
- SECURITY DEFINER RPC `upsert_admin_user_for_authjs(p_user_id UUID)`
that inserts a `platform_admin` row with all `can_manage_*` flags
true, `ON CONFLICT (user_id) DO NOTHING`.
- `NOTIFY pgrst, 'reload schema'` so PostgREST picks up the new RPC.
- **`src/lib/admin-permissions.ts`** — new Auth.js session check
between `dev_session` and `rc_auth_uid`. Uses `auth()` from
`@/lib/auth` to decrypt the JWT cookie server-side, then
`getAdminUserFromPool()` queries `admin_users` + `admin_user_brands`
via the shared pool. The legacy `rc_auth_uid` path is unchanged
(deferred — it still hits the dummy Supabase URL in prod).
- **`src/middleware.ts`** — recognizes `authjs.session-token` and
`__Secure-authjs.session-token` cookies at the edge so signed-in
users aren't bounced to `/login`.
**Key insight: same ID space.** Both `admin_users.user_id` (UUID, per
`028_fix_caller_uid_type.sql`) and Auth.js `users.id` (UUID, per
`204_authjs_tables.sql:18`) are in the same UUID space. The
`@auth/pg-adapter` auto-generates a fresh UUID per new user on first
sign-in; the Google `sub` claim is stored separately in
`accounts."providerAccountId"`. So no schema change was needed —
just a `user_id` lookup in `getAdminUserFromPool()`.
**Full sign-in flow now:**
1. Dev/demo: visit `/admin` → middleware auto-issues `dev_session`
cookie → `getAdminUser()` returns platform_admin. (No DB call.)
2. Production: click "Sign in with Google" → Auth.js OAuth →
`signIn` event fires → `upsert_admin_user_for_authjs` creates
the `admin_users` row → redirect to `/admin` → `getAdminUser()`
reads JWT, queries pool via `auth.js.user.id`, returns
platform_admin.
**What's still broken (out of scope for this push):**
- Legacy `rc_auth_uid` path in `getAdminUser()` still fetches from
`${NEXT_PUBLIC_SUPABASE_URL}/rest/v1/...` which is a dummy
`http://localhost:54321` in prod. Any pre-existing user with a
`rc_auth_uid` cookie will get null. Defer until the Supabase →
direct Postgres migration of the REST calls.
- `getCurrentAdminUser` (client-side variant) still reads from
server-passed props — no change needed.
- The `signIn` event RPC call will fail silently if `DATABASE_URL`
is not set. The user would see "Your account does not have admin
access" and need to sign out and back in once the env is fixed.
## Deploy fix — PostgREST env + dead nextjs service — 2026-06-06
Push `2d55791` fixes two issues that broke the "Start Docker stack" step:
1. **`PGRST_DB_URI` not set** — the env var was only in the "Deploy"
step's env, which runs after PostgREST has already started.
PostgREST booted with a blank DB URI. Now set in the "Start
Docker stack" step's env and written to `$APP_DIR/.env` (the
file docker compose auto-loads).
2. **`docker-compose.yml` had a dead `nextjs` service** with
`env_file: ../.env.production`. That file is written by the
"Deploy" step (later in the workflow), so at "Start Docker stack"
time the path doesn't exist. `docker compose up` validates the
whole compose file and bailed.
The `nextjs` service is dead code anyway — PM2 runs Next.js
directly from `$APP_DIR`, never through docker. Removed it.
**Other fixes in the same push:**
- `docker compose up -d db postgrest minio minio_init` referenced
services that don't exist in the compose file. Postgres runs on
the host (the migrations step uses `psql -h 127.0.0.1`), not in
docker. Changed to just `postgrest`.
- The `pg_isready` check was `docker compose exec -T db pg_isready`.
Since `db` is a host service, changed to
`PGPASSWORD=... psql -h 127.0.0.1 -U ... -d ... -c "SELECT 1"`.
**Architecture (now consistent):**
- Postgres: host (127.0.0.1:5432), migrations via `psql -h 127.0.0.1`
- PostgREST: docker, connects to host Postgres via `PGRST_DB_URI`
- Next.js: host, PM2 process, reads `DATABASE_URL` from `.env.production`
- MinIO: not yet wired up (the `MINIO_ROOT_USER`/`PASSWORD` env vars
are written to `.env` but no service consumes them yet — add a
`minio` service to docker-compose.yml when storage goes live)
+204
View File
@@ -0,0 +1,204 @@
# YOLO Auth Migration — Final Report
**Date:** 2026-06-06 → 2026-06-07
**Mission:** Stand up a working Auth.js v5 + Google sign-in flow, then rip out
all Supabase references from the auth/admin path.
---
## What was built
### 1. Auth.js v5 (NextAuth) integration — DONE
- **`src/lib/auth.ts`** — Auth.js v5 config. Google is the only provider
(no Supabase-backed Credentials provider). Sessions are JWTs; no DB
adapter. Exports `handlers`, `auth`, `signIn`, `signOut`.
- **`src/lib/admin-permissions.ts`** — `getAdminUser()` resolves the
current admin. Three branches in precedence order:
1. `NEXT_PUBLIC_USE_MOCK_DATA=true` → platform_admin dev shim
2. `dev_session` cookie → matching dev shim (platform/brand/store)
3. Auth.js session → `null` (no DB lookup yet — see Follow-ups)
- **`src/actions/auth-actions.ts`** — `signInWithGoogle()` and
`signOutAction()`. `signInWithPassword` was removed.
- **`src/app/login/LoginClient.tsx`** + **`page.tsx`** — Login page
renders "Continue with Google" (when configured) and three demo
buttons (Platform / Brand / Store Employee) via `?demo=1`. Email /
password form and "Forgot password" are gone.
- **`src/app/api/auth/[...nextauth]/route.ts`** — Auth.js route handler.
### 2. Direct Postgres connection (`src/lib/db.ts`) — DONE
- Single shared `pg.Pool` with lazy initialization. Throws a clear
error if `DATABASE_URL` is not set.
- Exports `getPool()`, a `pool` proxy alias, `query<T>()` helper, and
`withTx()` for transactions.
- Tuned for Vercel/serverless: 10 max conns, 30s idle, 10s connect
timeout. All overridable via `PG_POOL_*` env vars.
### 3. Server actions → `pg` (no Supabase) — DONE
- **`src/actions/admin/users.ts`** — Full rewrite. `getAdminUsers`,
`createAdminUser`, `updateAdminUser`, `deleteAdminUser`,
`setMustChangePassword`, `getBrands` now hit Postgres directly.
`sendPasswordResetEmail` returns a stub error (no auth service
anymore — the function is preserved for call-site compatibility).
- The `dev_session` cookie continues to be the demo-flow bypass.
### 4. Cleanup — DONE
- **`src/actions/admin/force-login.ts`** — Deleted (the
Emergency-Force-Login page and its `/api/force-admin` route were
removed in the previous session).
- **`src/actions/admin/users.ts`** — Removed the `DEV_FORCE_UID`
magic value, the `rc_auth_uid` cookie reads (×3), the
`headerStore` lookup, the `getServiceClient` / `getAuthClient` /
`callRpcWithAuth` helpers, and the `devListAdminUsers` auto-provision
branch. All Supabase imports gone.
- **`src/app/admin/me/AdminMeClient.tsx`** — Profile / email-change
handlers neutralized (return "contact a platform admin" — the
Supabase auth backend that backed them is gone).
- **`src/middleware.ts`** — `dev_session` auto-login preserved
(issues a `platform_admin` cookie on unauthed `/admin` so the rest
of the admin shell renders). `auth()` wrapper in place.
### 5. Test infrastructure — DONE
- **Vitest 2.1.9** with `@vitejs/plugin-react`, jsdom, manual
`@/* → src/*` alias. No `vite-tsconfig-paths` (ESM/require
incompatibility).
- **`tests/setup.ts`** — Mocks `next/navigation` (not in jsdom).
- **`tests/unit/getAdminUser.test.ts`** — 12 tests covering
dev_session, mock-data, Auth.js session, defensive error handling.
- **`tests/unit/auth-actions.test.ts`** — 2 tests for
`signInWithGoogle` + `signOutAction`.
- Both test files stub `server-only` with `vi.mock("server-only", () => ({}))`.
- **Playwright config** — `webServer` block for `npm run dev`,
`PLAYWRIGHT_URL` override, `reuseExistingServer: !process.env.CI`.
- **`tests/login/login-flow.spec.ts`** — Login form rendering +
demo mode (3 roles) tests.
- **`tests/e2e/auth.spec.ts`** — Auth.js endpoints, middleware
redirect behavior, /admin load with dev_session, logout.
### 6. `import "server-only"` markers — DONE
- Added to `src/lib/auth.ts`, `src/lib/admin-permissions.ts`,
`src/lib/db.ts`, `src/actions/auth-actions.ts`,
`src/actions/admin/users.ts`.
- ⚠️ In `"use server"` files, the directive must be first
(`"use server";` on line 1, then `import "server-only";`). The dev
build surfaced this; fixed in `auth-actions.ts` and `users.ts`.
---
## Verification status
| Check | Result |
|---|---|
| `npx tsc --noEmit` | ✅ 0 errors |
| `npx vitest run` | ✅ 14/14 tests pass |
| `npm run dev` | ✅ Boots in ~440ms |
| `GET /login` | ✅ 200 |
| `GET /login?demo=1` | ✅ 200 |
| `GET /api/auth/providers` | ✅ 200 (empty list — Google not configured) |
| `GET /` | ✅ 200 |
| `GET /admin` (unauthed) | ✅ 307 → `/admin?demo=1` + `dev_session` cookie |
| `GET /admin?demo=1` (with cookie) | ✅ 200 (admin layout renders) |
---
## Files changed
```
M src/actions/admin/users.ts # full rewrite, pg-only
M src/actions/auth-actions.ts # signInWithPassword removed
M src/app/admin/me/AdminMeClient.tsx # handlers stubbed
M src/app/login/LoginClient.tsx # email/password form removed
M src/app/login/page.tsx # passes hasGoogle prop
M src/lib/admin-permissions.ts # Supabase REST calls removed
M src/lib/auth.ts # Credentials provider removed
M tests/unit/auth-actions.test.ts # signInWithPassword tests removed
M tests/unit/getAdminUser.test.ts # reflects no-DB behavior
```
Plus deletions and creations recorded in the prior session.
---
## Follow-ups (the bigger fish)
The user's directive was: "git rid of all supabase references, we are
not using it for login or anything anymore." The auth/admin path is
now Supabase-free. **33 files** still import from Supabase — they use
it for **data fetching** (`supabase.from(...).select(...)`), not auth.
**Migrating those to `pg` is a follow-up.** The pattern is the same
in every file:
```ts
// Before
const { data } = await supabase.from("products").select("*").eq("brand_id", id);
// After
import { query } from "@/lib/db";
const { rows } = await query<ProductRow>(
"SELECT * FROM products WHERE brand_id = $1",
[id],
);
```
### 33 files still importing Supabase
```
src/app/admin/page.tsx, products/[id]/page.tsx, products/page.tsx,
orders/page.tsx, stops/[id]/page.tsx, stops/new/page.tsx,
stops/page.tsx, reports/page.tsx, taxes/page.tsx,
settings/billing/page.tsx, settings/integrations/page.tsx,
settings/shipping/page.tsx
src/app/tuxedo/page.tsx, about/page.tsx, contact/ContactClientPage.tsx,
stops/[slug]/page.tsx
src/app/indian-river-direct/page.tsx, faq/FAQClientPage.tsx,
contact/ContactClientPage.tsx, stops/[slug]/page.tsx
src/app/api/tuxedo/schedule-pdf/route.ts,
src/app/api/indian-river-direct/schedule-pdf/route.ts
src/app/reset-password/page.tsx, src/app/test/page.tsx
src/components/admin/AdminHeader.tsx, AdminSidebar.tsx
src/lib/supabase.ts, src/lib/supabase/server.ts # the client modules
src/actions/wholesale-auth.ts, reset-admin.ts, audit.ts
src/actions/ai/preferences.ts
```
### Additional follow-ups
1. **Provision a `pg` pool in production.** `DATABASE_URL` must be
set in the hosting dashboard (same env var as
`supabase/push-migrations.js` uses).
2. **Apply migration 204** if not already applied — adds
`email`, `auth_provider`, `auth_subject` columns to `admin_users`
and the `get_admin_user_for_session` RPC.
3. **Wire `getAdminUser()`'s Auth.js session branch** to call
`get_admin_user_for_session` via `pg` once a `DATABASE_URL` is
configured. Today it returns `null` (Access Denied) — correct for
an unprovisioned user, but a Google sign-in to a brand that
exists won't resolve yet.
4. **Drop the `next-auth` Credentials provider module path** in
`auth.ts` entirely once production confirms Google is the only
provider in use. Currently it's gated on env var presence.
5. **Re-implement `sendPasswordResetEmail`** if/when Auth.js v5 ships
its built-in email provider — until then, a platform admin must
handle resets manually.
6. **Decommission `src/lib/supabase.ts` and `src/lib/supabase/server.ts`**
once all 33 remaining files have been ported to `pg`. They are
the only remaining `@supabase/*` import surface.
7. **Delete `@supabase/ssr` and `@supabase/supabase-js` from
`package.json`** (last step once no file imports them).
---
## Commands
```bash
npm run dev # Dev server (boots in ~440ms)
npm test # Vitest: 14/14 pass
npx tsc --noEmit # Typecheck: 0 errors
npx playwright test # E2E (skips credentials tests if env unset)
```
+134
View File
@@ -0,0 +1,134 @@
/**
* Drizzle client + tenant-scoped query helper.
*
* The app connects to Postgres directly via the `pg` driver. Drizzle sits
* on top, providing typed queries. The `withTenant` wrapper is the only
* sanctioned way to run a tenant-scoped query — it sets the
* `app.current_tenant_id` GUC transaction-locally, and the database's
* RLS policies enforce tenant isolation even if application code forgets
* a `WHERE tenant_id = $1`.
*
* Usage (read):
* const products = await withTenant(tenantId, (db) =>
* db.select().from(productsTable).where(eq(productsTable.active, true)),
* );
*
* Usage (platform admin — sees all tenants):
* const allTenants = await withPlatformAdmin((db) =>
* db.select().from(tenantsTable),
* );
*
* Usage (no tenant — for the rare case the query isn't tenant-scoped):
* const plans = await withDb((db) => db.select().from(plansTable));
*/
import "server-only";
import { Pool, type PoolClient } from "pg";
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
import * as schema from "./schema";
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;
}
/**
* Run `fn` with a Drizzle client. No tenant context is set — the caller
* is responsible for RLS bypass (e.g. for the `plans` and `add_ons` tables,
* which are not tenant-scoped). For tenant-scoped reads, prefer
* `withTenant` or `withPlatformAdmin`.
*/
export async function withDb<T>(fn: (db: Db) => Promise<T>): Promise<T> {
const client = await getPool().connect();
try {
const db = drizzle(client, { schema });
return await fn(db);
} finally {
client.release();
}
}
/**
* Run `fn` inside a transaction with the current tenant id set as a
* transaction-local GUC. RLS policies on tenant-scoped tables will allow
* reads/writes only for rows where `tenant_id` matches. Pass `null` to
* fail open (don't set the GUC) — only useful for the migrations
* themselves, never for app code.
*/
export async function withTenant<T>(
tenantId: string,
fn: (db: Db) => Promise<T>,
): Promise<T> {
return runInTransaction(async (client) => {
// set_config(setting, value, is_local) — is_local=true makes it
// transaction-local so it auto-resets at COMMIT/ROLLBACK and never
// leaks across pooled connections.
await client.query("SELECT set_config('app.current_tenant_id', $1, true)", [
tenantId,
]);
await client.query("SELECT set_config('app.platform_admin', 'false', true)");
const db = drizzle(client, { schema });
return fn(db);
});
}
/**
* Run `fn` as platform admin. RLS policies permit access to all tenants.
* Use sparingly — typically only in the /admin/platform routes.
*/
export async function withPlatformAdmin<T>(
fn: (db: Db) => Promise<T>,
): Promise<T> {
return runInTransaction(async (client) => {
await client.query("SELECT set_config('app.platform_admin', 'true', true)");
const db = drizzle(client, { schema });
return fn(db);
});
}
async function runInTransaction<T>(
fn: (client: PoolClient) => Promise<T>,
): Promise<T> {
const client = await getPool().connect();
try {
await client.query("BEGIN");
const result = await fn(client);
await client.query("COMMIT");
return result;
} catch (err) {
try {
await client.query("ROLLBACK");
} catch {
// ignore secondary rollback failure
}
throw err;
} finally {
client.release();
}
}
export { schema };
+523
View File
@@ -0,0 +1,523 @@
-- 0001_init.sql
--
-- Route Commerce SaaS schema. Single migration, idempotent, no DROP.
-- Follows CLAUDE.md conventions:
-- - Status enums as TEXT with CHECK (no PG ENUM type)
-- - TIMESTAMPTZ everywhere
-- - gen_random_uuid() for PKs
-- - CREATE OR REPLACE FUNCTION for helpers
--
-- Multi-tenancy: every business table has `tenant_id` and an RLS policy
-- that compares it to `current_setting('app.current_tenant_id')`. The
-- application MUST set this GUC (transaction-local) before any query
-- against a tenant-scoped table. See `db/client.ts` for the wrapper.
--
-- Platform admin (sees all tenants) sets `app.platform_admin = 'true'`
-- instead. RLS policies allow that to bypass the tenant filter.
BEGIN;
-- ════════════════════════════════════════════════════════════════════════
-- 0. Extensions
-- ════════════════════════════════════════════════════════════════════════
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- ════════════════════════════════════════════════════════════════════════
-- 1. Tenancy + auth
-- ════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'trial'
CHECK (status IN ('trial', 'active', 'past_due', 'suspended', 'churned')),
trial_ends_at TIMESTAMPTZ,
stripe_customer_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE,
name TEXT,
image TEXT,
auth_provider TEXT NOT NULL DEFAULT 'dev'
CHECK (auth_provider IN ('dev', 'google', 'email')),
auth_subject TEXT,
email_verified_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS users_auth_subject_idx
ON users (auth_provider, auth_subject)
WHERE auth_subject IS NOT NULL;
CREATE TABLE IF NOT EXISTS tenant_users (
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'brand_admin'
CHECK (role IN ('platform_admin', 'brand_admin', 'store_employee')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, user_id)
);
CREATE INDEX IF NOT EXISTS tenant_users_user_idx ON tenant_users (user_id);
-- ════════════════════════════════════════════════════════════════════════
-- 2. Billing
-- ════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS plans (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code TEXT NOT NULL UNIQUE
CHECK (code IN ('starter', 'farm', 'enterprise')),
name TEXT NOT NULL,
monthly_price_cents INTEGER NOT NULL CHECK (monthly_price_cents >= 0),
max_users INTEGER NOT NULL,
max_products INTEGER NOT NULL,
max_stops_monthly INTEGER NOT NULL,
features JSONB NOT NULL DEFAULT '[]'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS add_ons (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code TEXT NOT NULL UNIQUE
CHECK (code IN (
'wholesale_portal',
'harvest_reach',
'ai_tools',
'water_log',
'square_sync',
'sms_campaigns'
)),
name TEXT NOT NULL,
monthly_price_cents INTEGER NOT NULL CHECK (monthly_price_cents >= 0),
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS subscriptions (
tenant_id UUID PRIMARY KEY REFERENCES tenants(id) ON DELETE CASCADE,
plan_id UUID NOT NULL REFERENCES plans(id),
status TEXT NOT NULL DEFAULT 'trialing'
CHECK (status IN ('trialing', 'active', 'past_due', 'canceled', 'incomplete')),
stripe_subscription_id TEXT,
current_period_end TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS tenant_add_ons (
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
add_on_id UUID NOT NULL REFERENCES add_ons(id) ON DELETE CASCADE,
stripe_subscription_id TEXT,
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'canceled')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, add_on_id)
);
-- ════════════════════════════════════════════════════════════════════════
-- 3. Products
-- ════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
price_cents INTEGER NOT NULL CHECK (price_cents >= 0),
inventory INTEGER NOT NULL DEFAULT 0,
unit TEXT,
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS products_tenant_idx ON products (tenant_id);
CREATE INDEX IF NOT EXISTS products_active_idx ON products (tenant_id, active);
CREATE TABLE IF NOT EXISTS product_images (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
storage_key TEXT NOT NULL,
position INTEGER NOT NULL DEFAULT 0,
alt_text TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS product_images_product_idx
ON product_images (product_id, position);
-- ════════════════════════════════════════════════════════════════════════
-- 4. Stops
-- ════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS stops (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name TEXT NOT NULL,
address TEXT NOT NULL,
schedule JSONB NOT NULL DEFAULT '[]'::jsonb,
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'paused', 'closed')),
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS stops_tenant_idx ON stops (tenant_id);
CREATE INDEX IF NOT EXISTS stops_status_idx ON stops (tenant_id, status);
-- ════════════════════════════════════════════════════════════════════════
-- 5. Customers
-- ════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS customers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name TEXT NOT NULL,
email TEXT,
phone TEXT,
sms_opt_in BOOLEAN NOT NULL DEFAULT false,
email_opt_in BOOLEAN NOT NULL DEFAULT true,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS customers_tenant_idx ON customers (tenant_id);
CREATE UNIQUE INDEX IF NOT EXISTS customers_tenant_email_idx
ON customers (tenant_id, email)
WHERE email IS NOT NULL;
-- ════════════════════════════════════════════════════════════════════════
-- 6. Orders
-- ════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
customer_id UUID REFERENCES customers(id) ON DELETE SET NULL,
total_cents INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'confirmed', 'fulfilled', 'canceled')),
fulfillment TEXT NOT NULL
CHECK (fulfillment IN ('pickup', 'ship', 'mixed')),
notes TEXT,
placed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS orders_tenant_idx ON orders (tenant_id);
CREATE INDEX IF NOT EXISTS orders_customer_idx ON orders (customer_id);
CREATE INDEX IF NOT EXISTS orders_status_idx ON orders (tenant_id, status);
CREATE TABLE IF NOT EXISTS order_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
product_id UUID REFERENCES products(id) ON DELETE SET NULL,
quantity INTEGER NOT NULL CHECK (quantity > 0),
price_cents INTEGER NOT NULL CHECK (price_cents >= 0),
fulfillment TEXT NOT NULL
CHECK (fulfillment IN ('pickup', 'ship'))
);
CREATE INDEX IF NOT EXISTS order_items_order_idx ON order_items (order_id);
-- ════════════════════════════════════════════════════════════════════════
-- 7. Brand settings
-- ════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS brand_settings (
tenant_id UUID PRIMARY KEY REFERENCES tenants(id) ON DELETE CASCADE,
brand_name TEXT NOT NULL,
tagline TEXT,
about_html TEXT,
primary_color TEXT DEFAULT '#0F766E',
logo_storage_key TEXT,
hero_storage_key TEXT,
contact_email TEXT,
contact_phone TEXT,
custom_footer_text TEXT,
feature_flags JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ════════════════════════════════════════════════════════════════════════
-- 8. Marketing
-- ════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS email_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name TEXT NOT NULL,
subject TEXT NOT NULL,
body_html TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS email_templates_tenant_idx ON email_templates (tenant_id);
CREATE TABLE IF NOT EXISTS campaigns (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
template_id UUID REFERENCES email_templates(id) ON DELETE SET NULL,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'scheduled', 'sending', 'sent', 'canceled')),
scheduled_for TIMESTAMPTZ,
sent_at TIMESTAMPTZ,
recipient_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS campaigns_tenant_idx ON campaigns (tenant_id);
CREATE INDEX IF NOT EXISTS campaigns_status_idx ON campaigns (tenant_id, status);
-- ════════════════════════════════════════════════════════════════════════
-- 9. Files
-- ════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS files (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
storage_key TEXT NOT NULL UNIQUE,
mime_type TEXT NOT NULL,
size_bytes BIGINT NOT NULL,
purpose TEXT,
uploaded_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS files_tenant_idx ON files (tenant_id);
-- ════════════════════════════════════════════════════════════════════════
-- 10. Audit log
-- ════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
action TEXT NOT NULL,
target_type TEXT,
target_id UUID,
payload JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS audit_log_tenant_idx
ON audit_log (tenant_id, created_at DESC);
-- ════════════════════════════════════════════════════════════════════════
-- 11. RLS helpers
-- ════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION current_tenant_id()
RETURNS UUID
LANGUAGE sql
STABLE
AS $$
SELECT NULLIF(current_setting('app.current_tenant_id', true), '')::UUID;
$$;
CREATE OR REPLACE FUNCTION is_platform_admin()
RETURNS BOOLEAN
LANGUAGE sql
STABLE
AS $$
SELECT COALESCE(NULLIF(current_setting('app.platform_admin', true), ''), 'false') = 'true';
$$;
-- ════════════════════════════════════════════════════════════════════════
-- 12. Enable RLS on tenant-scoped tables
-- ════════════════════════════════════════════════════════════════════════
ALTER TABLE products ENABLE ROW LEVEL SECURITY;
ALTER TABLE product_images ENABLE ROW LEVEL SECURITY;
ALTER TABLE stops ENABLE ROW LEVEL SECURITY;
ALTER TABLE customers ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE order_items ENABLE ROW LEVEL SECURITY;
ALTER TABLE brand_settings ENABLE ROW LEVEL SECURITY;
ALTER TABLE email_templates ENABLE ROW LEVEL SECURITY;
ALTER TABLE campaigns ENABLE ROW LEVEL SECURITY;
ALTER TABLE files ENABLE ROW LEVEL SECURITY;
ALTER TABLE audit_log ENABLE ROW LEVEL SECURITY;
-- RLS does not apply to the table owner by default. We FORCE it so the
-- application (which connects as the same user that owns the tables in
-- dev) cannot accidentally bypass tenant isolation. In production, the
-- application should connect as a non-owner role; this is belt-and-
-- suspenders for the dev/local case.
ALTER TABLE products FORCE ROW LEVEL SECURITY;
ALTER TABLE product_images FORCE ROW LEVEL SECURITY;
ALTER TABLE stops FORCE ROW LEVEL SECURITY;
ALTER TABLE customers FORCE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
ALTER TABLE order_items FORCE ROW LEVEL SECURITY;
ALTER TABLE brand_settings FORCE ROW LEVEL SECURITY;
ALTER TABLE email_templates FORCE ROW LEVEL SECURITY;
ALTER TABLE campaigns FORCE ROW LEVEL SECURITY;
ALTER TABLE files FORCE ROW LEVEL SECURITY;
ALTER TABLE audit_log FORCE ROW LEVEL SECURITY;
-- ════════════════════════════════════════════════════════════════════════
-- 13. RLS policies: tenant match OR platform admin
-- ════════════════════════════════════════════════════════════════════════
-- Drop existing policies (idempotent — same migration may be re-applied)
DROP POLICY IF EXISTS tenant_isolation ON products;
DROP POLICY IF EXISTS tenant_isolation ON product_images;
DROP POLICY IF EXISTS tenant_isolation ON stops;
DROP POLICY IF EXISTS tenant_isolation ON customers;
DROP POLICY IF EXISTS tenant_isolation ON orders;
DROP POLICY IF EXISTS tenant_isolation ON order_items;
DROP POLICY IF EXISTS tenant_isolation ON brand_settings;
DROP POLICY IF EXISTS tenant_isolation ON email_templates;
DROP POLICY IF EXISTS tenant_isolation ON campaigns;
DROP POLICY IF EXISTS tenant_isolation ON files;
DROP POLICY IF EXISTS tenant_isolation ON audit_log;
CREATE POLICY tenant_isolation ON products
FOR ALL
USING (tenant_id = current_tenant_id() OR is_platform_admin())
WITH CHECK (tenant_id = current_tenant_id() OR is_platform_admin());
CREATE POLICY tenant_isolation ON product_images
FOR ALL
USING (
EXISTS (
SELECT 1 FROM products p
WHERE p.id = product_images.product_id
AND (p.tenant_id = current_tenant_id() OR is_platform_admin())
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM products p
WHERE p.id = product_images.product_id
AND (p.tenant_id = current_tenant_id() OR is_platform_admin())
)
);
CREATE POLICY tenant_isolation ON stops
FOR ALL
USING (tenant_id = current_tenant_id() OR is_platform_admin())
WITH CHECK (tenant_id = current_tenant_id() OR is_platform_admin());
CREATE POLICY tenant_isolation ON customers
FOR ALL
USING (tenant_id = current_tenant_id() OR is_platform_admin())
WITH CHECK (tenant_id = current_tenant_id() OR is_platform_admin());
CREATE POLICY tenant_isolation ON orders
FOR ALL
USING (tenant_id = current_tenant_id() OR is_platform_admin())
WITH CHECK (tenant_id = current_tenant_id() OR is_platform_admin());
CREATE POLICY tenant_isolation ON order_items
FOR ALL
USING (
EXISTS (
SELECT 1 FROM orders o
WHERE o.id = order_items.order_id
AND (o.tenant_id = current_tenant_id() OR is_platform_admin())
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM orders o
WHERE o.id = order_items.order_id
AND (o.tenant_id = current_tenant_id() OR is_platform_admin())
)
);
CREATE POLICY tenant_isolation ON brand_settings
FOR ALL
USING (tenant_id = current_tenant_id() OR is_platform_admin())
WITH CHECK (tenant_id = current_tenant_id() OR is_platform_admin());
CREATE POLICY tenant_isolation ON email_templates
FOR ALL
USING (tenant_id = current_tenant_id() OR is_platform_admin())
WITH CHECK (tenant_id = current_tenant_id() OR is_platform_admin());
CREATE POLICY tenant_isolation ON campaigns
FOR ALL
USING (tenant_id = current_tenant_id() OR is_platform_admin())
WITH CHECK (tenant_id = current_tenant_id() OR is_platform_admin());
CREATE POLICY tenant_isolation ON files
FOR ALL
USING (tenant_id = current_tenant_id() OR is_platform_admin() OR tenant_id IS NULL)
WITH CHECK (tenant_id = current_tenant_id() OR is_platform_admin() OR tenant_id IS NULL);
CREATE POLICY tenant_isolation ON audit_log
FOR ALL
USING (tenant_id = current_tenant_id() OR is_platform_admin() OR tenant_id IS NULL)
WITH CHECK (tenant_id = current_tenant_id() OR is_platform_admin() OR tenant_id IS NULL);
-- ════════════════════════════════════════════════════════════════════════
-- 14. updated_at triggers
-- ════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS tenants_updated_at ON tenants;
CREATE TRIGGER tenants_updated_at BEFORE UPDATE ON tenants
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS users_updated_at ON users;
CREATE TRIGGER users_updated_at BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS subscriptions_updated_at ON subscriptions;
CREATE TRIGGER subscriptions_updated_at BEFORE UPDATE ON subscriptions
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS products_updated_at ON products;
CREATE TRIGGER products_updated_at BEFORE UPDATE ON products
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS stops_updated_at ON stops;
CREATE TRIGGER stops_updated_at BEFORE UPDATE ON stops
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS customers_updated_at ON customers;
CREATE TRIGGER customers_updated_at BEFORE UPDATE ON customers
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS orders_updated_at ON orders;
CREATE TRIGGER orders_updated_at BEFORE UPDATE ON orders
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS brand_settings_updated_at ON brand_settings;
CREATE TRIGGER brand_settings_updated_at BEFORE UPDATE ON brand_settings
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS email_templates_updated_at ON email_templates;
CREATE TRIGGER email_templates_updated_at BEFORE UPDATE ON email_templates
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS campaigns_updated_at ON campaigns;
CREATE TRIGGER campaigns_updated_at BEFORE UPDATE ON campaigns
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
COMMIT;
+21
View File
@@ -0,0 +1,21 @@
-- 0002_admin_password.sql
--
-- Adds a `password_hash` column to `users` so the Auth.js Credentials
-- provider can verify email + password against the database.
--
-- Idempotent: uses `ADD COLUMN IF NOT EXISTS`. The column is nullable
-- because OAuth-only users (Google) never set a password.
--
-- The Credentials provider's `authorize` function is documented in
-- `src/lib/auth.ts` — it queries this column and runs `verifyPassword`
-- (see `src/lib/passwords.ts`) before returning the user.
BEGIN;
ALTER TABLE users
ADD COLUMN IF NOT EXISTS password_hash TEXT;
-- Update the updated_at trigger tracking — no new triggers needed since
-- `users` already has `set_updated_at` from migration 0001.
COMMIT;
+39
View File
@@ -0,0 +1,39 @@
/**
* Audit log. Source of truth: `db/migrations/0001_init.sql`.
*/
import {
pgTable,
uuid,
text,
jsonb,
timestamp,
index,
} from "drizzle-orm/pg-core";
import { tenants } from "./tenants";
import { users } from "./tenants";
export const auditLog = pgTable(
"audit_log",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id").references(() => tenants.id, {
onDelete: "cascade",
}),
userId: uuid("user_id").references(() => users.id, {
onDelete: "set null",
}),
action: text("action").notNull(),
targetType: text("target_type"),
targetId: uuid("target_id"),
payload: jsonb("payload"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
tenantIdx: index("audit_log_tenant_idx").on(t.tenantId, t.createdAt),
}),
);
export type AuditLog = typeof auditLog.$inferSelect;
export type NewAuditLog = typeof auditLog.$inferInsert;
+96
View File
@@ -0,0 +1,96 @@
/**
* Billing tables: plans, add-ons, subscriptions, tenant_add_ons.
* Source of truth: `db/migrations/0001_init.sql`.
*/
import {
pgTable,
uuid,
text,
integer,
timestamp,
jsonb,
primaryKey,
} from "drizzle-orm/pg-core";
import {
planCodeEnum,
addOnCodeEnum,
subscriptionStatusEnum,
addOnStatusEnum,
} from "./enums";
import { tenants } from "./tenants";
export const plans = pgTable("plans", {
id: uuid("id").primaryKey().defaultRandom(),
code: text("code", { enum: planCodeEnum }).notNull().unique(),
name: text("name").notNull(),
monthlyPriceCents: integer("monthly_price_cents").notNull(),
maxUsers: integer("max_users").notNull(),
maxProducts: integer("max_products").notNull(),
maxStopsMonthly: integer("max_stops_monthly").notNull(),
features: jsonb("features").notNull().default([]),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
});
export const addOns = pgTable("add_ons", {
id: uuid("id").primaryKey().defaultRandom(),
code: text("code", { enum: addOnCodeEnum }).notNull().unique(),
name: text("name").notNull(),
monthlyPriceCents: integer("monthly_price_cents").notNull(),
description: text("description"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
});
export const subscriptions = pgTable("subscriptions", {
tenantId: uuid("tenant_id")
.primaryKey()
.references(() => tenants.id, { onDelete: "cascade" }),
planId: uuid("plan_id")
.notNull()
.references(() => plans.id),
status: text("status", { enum: subscriptionStatusEnum })
.notNull()
.default("trialing"),
stripeSubscriptionId: text("stripe_subscription_id"),
currentPeriodEnd: timestamp("current_period_end", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
});
export const tenantAddOns = pgTable(
"tenant_add_ons",
{
tenantId: uuid("tenant_id")
.notNull()
.references(() => tenants.id, { onDelete: "cascade" }),
addOnId: uuid("add_on_id")
.notNull()
.references(() => addOns.id, { onDelete: "cascade" }),
stripeSubscriptionId: text("stripe_subscription_id"),
status: text("status", { enum: addOnStatusEnum })
.notNull()
.default("active"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
pk: primaryKey({ columns: [t.tenantId, t.addOnId] }),
}),
);
export type Plan = typeof plans.$inferSelect;
export type NewPlan = typeof plans.$inferInsert;
export type AddOn = typeof addOns.$inferSelect;
export type NewAddOn = typeof addOns.$inferInsert;
export type Subscription = typeof subscriptions.$inferSelect;
export type NewSubscription = typeof subscriptions.$inferInsert;
export type TenantAddOn = typeof tenantAddOns.$inferSelect;
export type NewTenantAddOn = typeof tenantAddOns.$inferInsert;
+28
View File
@@ -0,0 +1,28 @@
/**
* Brand settings. One row per tenant. Source of truth:
* `db/migrations/0001_init.sql`.
*/
import { pgTable, uuid, text, jsonb, timestamp } from "drizzle-orm/pg-core";
import { tenants } from "./tenants";
export const brandSettings = pgTable("brand_settings", {
tenantId: uuid("tenant_id")
.primaryKey()
.references(() => tenants.id, { onDelete: "cascade" }),
brandName: text("brand_name").notNull(),
tagline: text("tagline"),
aboutHtml: text("about_html"),
primaryColor: text("primary_color").default("#0F766E"),
logoStorageKey: text("logo_storage_key"),
heroStorageKey: text("hero_storage_key"),
contactEmail: text("contact_email"),
contactPhone: text("contact_phone"),
customFooterText: text("custom_footer_text"),
featureFlags: jsonb("feature_flags").notNull().default({}),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
});
export type BrandSettings = typeof brandSettings.$inferSelect;
export type NewBrandSettings = typeof brandSettings.$inferInsert;
+45
View File
@@ -0,0 +1,45 @@
/**
* Customers. Source of truth: `db/migrations/0001_init.sql`.
*/
import {
pgTable,
uuid,
text,
boolean,
timestamp,
index,
uniqueIndex,
} from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
import { tenants } from "./tenants";
export const customers = pgTable(
"customers",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id")
.notNull()
.references(() => tenants.id, { onDelete: "cascade" }),
name: text("name").notNull(),
email: text("email"),
phone: text("phone"),
smsOptIn: boolean("sms_opt_in").notNull().default(false),
emailOptIn: boolean("email_opt_in").notNull().default(true),
notes: text("notes"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
tenantIdx: index("customers_tenant_idx").on(t.tenantId),
emailIdx: uniqueIndex("customers_tenant_email_idx")
.on(t.tenantId, t.email)
.where(sql`${t.email} IS NOT NULL`),
}),
);
export type Customer = typeof customers.$inferSelect;
export type NewCustomer = typeof customers.$inferInsert;
+75
View File
@@ -0,0 +1,75 @@
/**
* Shared enums for the SaaS schema. Mirrored in SQL as TEXT + CHECK.
*
* Usage:
* import { tenantStatusEnum, type TenantStatus } from "@/db/schema/enums";
* import { pgEnum } from "drizzle-orm/pg-core";
*
* export const tenantStatus = pgEnum("tenant_status", tenantStatusEnum);
*/
export const tenantStatusEnum = [
"trial",
"active",
"past_due",
"suspended",
"churned",
] as const;
export type TenantStatus = (typeof tenantStatusEnum)[number];
export const authProviderEnum = ["dev", "google", "email"] as const;
export type AuthProvider = (typeof authProviderEnum)[number];
export const roleEnum = ["platform_admin", "brand_admin", "store_employee"] as const;
export type Role = (typeof roleEnum)[number];
export const planCodeEnum = ["starter", "farm", "enterprise"] as const;
export type PlanCode = (typeof planCodeEnum)[number];
export const addOnCodeEnum = [
"wholesale_portal",
"harvest_reach",
"ai_tools",
"water_log",
"square_sync",
"sms_campaigns",
] as const;
export type AddOnCode = (typeof addOnCodeEnum)[number];
export const subscriptionStatusEnum = [
"trialing",
"active",
"past_due",
"canceled",
"incomplete",
] as const;
export type SubscriptionStatus = (typeof subscriptionStatusEnum)[number];
export const addOnStatusEnum = ["active", "canceled"] as const;
export type AddOnStatus = (typeof addOnStatusEnum)[number];
export const stopStatusEnum = ["active", "paused", "closed"] as const;
export type StopStatus = (typeof stopStatusEnum)[number];
export const orderStatusEnum = [
"pending",
"confirmed",
"fulfilled",
"canceled",
] as const;
export type OrderStatus = (typeof orderStatusEnum)[number];
export const fulfillmentEnum = ["pickup", "ship", "mixed"] as const;
export type Fulfillment = (typeof fulfillmentEnum)[number];
export const itemFulfillmentEnum = ["pickup", "ship"] as const;
export type ItemFulfillment = (typeof itemFulfillmentEnum)[number];
export const campaignStatusEnum = [
"draft",
"scheduled",
"sending",
"sent",
"canceled",
] as const;
export type CampaignStatus = (typeof campaignStatusEnum)[number];
+39
View File
@@ -0,0 +1,39 @@
/**
* Files. Source of truth: `db/migrations/0001_init.sql`.
*/
import {
pgTable,
uuid,
text,
bigint,
timestamp,
index,
} from "drizzle-orm/pg-core";
import { tenants } from "./tenants";
import { users } from "./tenants";
export const files = pgTable(
"files",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id").references(() => tenants.id, {
onDelete: "cascade",
}),
storageKey: text("storage_key").notNull().unique(),
mimeType: text("mime_type").notNull(),
sizeBytes: bigint("size_bytes", { mode: "number" }).notNull(),
purpose: text("purpose"),
uploadedBy: uuid("uploaded_by").references(() => users.id, {
onDelete: "set null",
}),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
tenantIdx: index("files_tenant_idx").on(t.tenantId),
}),
);
export type File = typeof files.$inferSelect;
export type NewFile = typeof files.$inferInsert;
+17
View File
@@ -0,0 +1,17 @@
/**
* Schema barrel. Re-exports every Drizzle table + inferred row type.
*
* Usage:
* import { products, type Product } from "@/db/schema";
*/
export * from "./enums";
export * from "./tenants";
export * from "./billing";
export * from "./products";
export * from "./stops";
export * from "./customers";
export * from "./orders";
export * from "./brand";
export * from "./marketing";
export * from "./files";
export * from "./audit";
+71
View File
@@ -0,0 +1,71 @@
/**
* Marketing: email templates + campaigns.
* Source of truth: `db/migrations/0001_init.sql`.
*/
import {
pgTable,
uuid,
text,
integer,
timestamp,
index,
} from "drizzle-orm/pg-core";
import { campaignStatusEnum } from "./enums";
import { tenants } from "./tenants";
export const emailTemplates = pgTable(
"email_templates",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id")
.notNull()
.references(() => tenants.id, { onDelete: "cascade" }),
name: text("name").notNull(),
subject: text("subject").notNull(),
bodyHtml: text("body_html").notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
tenantIdx: index("email_templates_tenant_idx").on(t.tenantId),
}),
);
export const campaigns = pgTable(
"campaigns",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id")
.notNull()
.references(() => tenants.id, { onDelete: "cascade" }),
templateId: uuid("template_id").references((): any => emailTemplates.id, {
onDelete: "set null",
}),
name: text("name").notNull(),
status: text("status", { enum: campaignStatusEnum })
.notNull()
.default("draft"),
scheduledFor: timestamp("scheduled_for", { withTimezone: true }),
sentAt: timestamp("sent_at", { withTimezone: true }),
recipientCount: integer("recipient_count").notNull().default(0),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
tenantIdx: index("campaigns_tenant_idx").on(t.tenantId),
statusIdx: index("campaigns_status_idx").on(t.tenantId, t.status),
}),
);
export type EmailTemplate = typeof emailTemplates.$inferSelect;
export type NewEmailTemplate = typeof emailTemplates.$inferInsert;
export type Campaign = typeof campaigns.$inferSelect;
export type NewCampaign = typeof campaigns.$inferInsert;
+67
View File
@@ -0,0 +1,67 @@
/**
* Orders + order_items. Source of truth: `db/migrations/0001_init.sql`.
*/
import {
pgTable,
uuid,
text,
integer,
timestamp,
index,
} from "drizzle-orm/pg-core";
import { orderStatusEnum, fulfillmentEnum, itemFulfillmentEnum } from "./enums";
import { tenants } from "./tenants";
import { customers } from "./customers";
import { products } from "./products";
export const orders = pgTable(
"orders",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id")
.notNull()
.references(() => tenants.id, { onDelete: "cascade" }),
customerId: uuid("customer_id").references(() => customers.id, {
onDelete: "set null",
}),
totalCents: integer("total_cents").notNull().default(0),
status: text("status", { enum: orderStatusEnum }).notNull().default("pending"),
fulfillment: text("fulfillment", { enum: fulfillmentEnum }).notNull(),
notes: text("notes"),
placedAt: timestamp("placed_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
tenantIdx: index("orders_tenant_idx").on(t.tenantId),
customerIdx: index("orders_customer_idx").on(t.customerId),
statusIdx: index("orders_status_idx").on(t.tenantId, t.status),
}),
);
export const orderItems = pgTable(
"order_items",
{
id: uuid("id").primaryKey().defaultRandom(),
orderId: uuid("order_id")
.notNull()
.references(() => orders.id, { onDelete: "cascade" }),
productId: uuid("product_id").references(() => products.id, {
onDelete: "set null",
}),
quantity: integer("quantity").notNull(),
priceCents: integer("price_cents").notNull(),
fulfillment: text("fulfillment", { enum: itemFulfillmentEnum }).notNull(),
},
(t) => ({
orderIdx: index("order_items_order_idx").on(t.orderId),
}),
);
export type Order = typeof orders.$inferSelect;
export type NewOrder = typeof orders.$inferInsert;
export type OrderItem = typeof orderItems.$inferSelect;
export type NewOrderItem = typeof orderItems.$inferInsert;
+63
View File
@@ -0,0 +1,63 @@
/**
* Products + product_images. Source of truth: `db/migrations/0001_init.sql`.
*/
import {
pgTable,
uuid,
text,
integer,
boolean,
timestamp,
index,
} from "drizzle-orm/pg-core";
import { tenants } from "./tenants";
export const products = pgTable(
"products",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id")
.notNull()
.references(() => tenants.id, { onDelete: "cascade" }),
name: text("name").notNull(),
description: text("description"),
priceCents: integer("price_cents").notNull(),
inventory: integer("inventory").notNull().default(0),
unit: text("unit"),
active: boolean("active").notNull().default(true),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
tenantIdx: index("products_tenant_idx").on(t.tenantId),
activeIdx: index("products_active_idx").on(t.tenantId, t.active),
}),
);
export const productImages = pgTable(
"product_images",
{
id: uuid("id").primaryKey().defaultRandom(),
productId: uuid("product_id")
.notNull()
.references(() => products.id, { onDelete: "cascade" }),
storageKey: text("storage_key").notNull(),
position: integer("position").notNull().default(0),
altText: text("alt_text"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
productIdx: index("product_images_product_idx").on(t.productId, t.position),
}),
);
export type Product = typeof products.$inferSelect;
export type NewProduct = typeof products.$inferInsert;
export type ProductImage = typeof productImages.$inferSelect;
export type NewProductImage = typeof productImages.$inferInsert;
+41
View File
@@ -0,0 +1,41 @@
/**
* Stops. Source of truth: `db/migrations/0001_init.sql`.
*/
import {
pgTable,
uuid,
text,
jsonb,
timestamp,
index,
} from "drizzle-orm/pg-core";
import { stopStatusEnum } from "./enums";
import { tenants } from "./tenants";
export const stops = pgTable(
"stops",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id")
.notNull()
.references(() => tenants.id, { onDelete: "cascade" }),
name: text("name").notNull(),
address: text("address").notNull(),
schedule: jsonb("schedule").notNull().default([]),
status: text("status", { enum: stopStatusEnum }).notNull().default("active"),
notes: text("notes"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
tenantIdx: index("stops_tenant_idx").on(t.tenantId),
statusIdx: index("stops_status_idx").on(t.tenantId, t.status),
}),
);
export type Stop = typeof stops.$inferSelect;
export type NewStop = typeof stops.$inferInsert;
+85
View File
@@ -0,0 +1,85 @@
/**
* Tenancy + auth tables. Source of truth: `db/migrations/0001_init.sql`.
*/
import {
pgTable,
uuid,
text,
timestamp,
index,
uniqueIndex,
primaryKey,
} from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
import {
tenantStatusEnum,
authProviderEnum,
roleEnum,
} from "./enums";
export const tenants = pgTable("tenants", {
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
slug: text("slug").notNull().unique(),
status: text("status", { enum: tenantStatusEnum }).notNull().default("trial"),
trialEndsAt: timestamp("trial_ends_at", { withTimezone: true }),
stripeCustomerId: text("stripe_customer_id"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
});
export const users = pgTable(
"users",
{
id: uuid("id").primaryKey().defaultRandom(),
email: text("email").unique(),
name: text("name"),
image: text("image"),
/**
* Bcrypt / scrypt / argon2 hash, or null for OAuth-only users.
* Format is self-describing (algorithm$N$salt$hash) so we can
* migrate to a stronger KDF later without losing existing hashes.
* See `src/lib/passwords.ts` for the encoder/decoder.
*/
passwordHash: text("password_hash"),
authProvider: text("auth_provider", { enum: authProviderEnum })
.notNull()
.default("dev"),
authSubject: text("auth_subject"),
emailVerifiedAt: timestamp("email_verified_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => ({
authSubjectIdx: uniqueIndex("users_auth_subject_idx")
.on(t.authProvider, t.authSubject)
.where(sql`${t.authSubject} IS NOT NULL`),
}),
);
export const tenantUsers = pgTable(
"tenant_users",
{
tenantId: uuid("tenant_id")
.notNull()
.references(() => tenants.id, { onDelete: "cascade" }),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
role: text("role", { enum: roleEnum }).notNull().default("brand_admin"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
pk: primaryKey({ columns: [t.tenantId, t.userId] }),
userIdx: index("tenant_users_user_idx").on(t.userId),
}),
);
export type Tenant = typeof tenants.$inferSelect;
export type NewTenant = typeof tenants.$inferInsert;
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export type TenantUser = typeof tenantUsers.$inferSelect;
export type NewTenantUser = typeof tenantUsers.$inferInsert;
+314
View File
@@ -0,0 +1,314 @@
/**
* Seed script. Idempotent — safe to re-run. Uses `ON CONFLICT (col) DO UPDATE`
* (with a target) for tables that have a unique constraint; uses
* `WHERE NOT EXISTS` for tables that don't.
*
* npm run db:seed
*
* Populates:
* - 3 plans (Starter / Farm / Enterprise)
* - 6 add-ons
* - 2 tenants (Tuxedo, Indian River Direct)
* - 1 platform-admin user + 1 brand_admin per tenant
* - brand_settings per tenant
* - sample products, stops, customers per tenant
* - sample email templates + a draft campaign
*/
import "dotenv/config";
import { Pool } from "pg";
import { hashPassword } from "../src/lib/passwords";
// Seed needs elevated privileges (inserts into plans, add_ons, tenants —
// tables that are intentionally not RLS-scoped but the runtime app user
// can't create rows in without setting up GUCs we don't want to bother
// with here). Use DATABASE_ADMIN_URL (the superuser) for the seed.
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");
// ── Plans ────────────────────────────────────────────────────────────
const planRows = [
{ code: "starter", name: "Starter", price: 4900, maxUsers: 1, maxProducts: 25, maxStopsMonthly: 10, features: ["products", "stops", "orders", "basic_pickup"] },
{ code: "farm", name: "Farm", price: 14900, maxUsers: 5, maxProducts: 9999, maxStopsMonthly: 9999, features: ["products", "stops", "orders", "basic_pickup", "wholesale_portal", "harvest_reach", "priority_support"] },
{ code: "enterprise", name: "Enterprise", price: 39900, maxUsers: 9999, maxProducts: 9999, maxStopsMonthly: 9999, features: ["products", "stops", "orders", "basic_pickup", "wholesale_portal", "harvest_reach", "priority_support", "ai_intelligence", "sms_campaigns", "square_sync", "water_log", "custom_development", "dedicated_sla"] },
];
for (const p of planRows) {
await client.query(
`INSERT INTO plans (code, name, monthly_price_cents, max_users, max_products, max_stops_monthly, features)
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)
ON CONFLICT (code) DO UPDATE SET
name = EXCLUDED.name,
monthly_price_cents = EXCLUDED.monthly_price_cents,
max_users = EXCLUDED.max_users,
max_products = EXCLUDED.max_products,
max_stops_monthly = EXCLUDED.max_stops_monthly,
features = EXCLUDED.features`,
[p.code, p.name, p.price, p.maxUsers, p.maxProducts, p.maxStopsMonthly, JSON.stringify(p.features)],
);
}
console.log(`Seeded ${planRows.length} plans`);
// ── Add-ons ──────────────────────────────────────────────────────────
const addOnRows = [
{ code: "wholesale_portal", name: "Wholesale Portal", price: 9900, description: "Self-serve wholesale storefront with order approval." },
{ code: "harvest_reach", name: "Harvest Reach", price: 7900, description: "Email + SMS campaigns and automations." },
{ code: "ai_tools", name: "AI Intelligence Pack", price: 5900, description: "Smart pricing, product descriptions, and demand forecasting." },
{ code: "water_log", name: "Water Log", price: 3900, description: "Irrigation tracking and reporting." },
{ code: "square_sync", name: "Square Sync", price: 3900, description: "Sync inventory and sales to Square POS." },
{ code: "sms_campaigns", name: "SMS Campaigns", price: 2900, description: "Send SMS blasts and automations." },
];
for (const a of addOnRows) {
await client.query(
`INSERT INTO add_ons (code, name, monthly_price_cents, description)
VALUES ($1, $2, $3, $4)
ON CONFLICT (code) DO UPDATE SET
name = EXCLUDED.name,
monthly_price_cents = EXCLUDED.monthly_price_cents,
description = EXCLUDED.description`,
[a.code, a.name, a.price, a.description],
);
}
console.log(`Seeded ${addOnRows.length} add-ons`);
// ── Tenants + users + content ────────────────────────────────────────
const tenantsData = [
{
slug: "tuxedo",
name: "Tuxedo Citrus",
brandName: "Tuxedo Citrus Co.",
tagline: "Sun-ripened citrus, delivered.",
aboutHtml:
"<p>Family-run citrus grove in the Indian River region. We grow, pack, and ship premium grapefruit, oranges, and specialty varieties to wholesale buyers across the country.</p>",
primaryColor: "#F59E0B",
contactEmail: "hello@tuxedocitrus.example",
contactPhone: "(555) 010-2200",
planCode: "farm",
addOns: ["wholesale_portal", "harvest_reach"],
},
{
slug: "indian-river-direct",
name: "Indian River Direct",
brandName: "Indian River Direct",
tagline: "From our groves to your store.",
aboutHtml:
"<p>Direct-from-grove wholesale produce. Family farms, fair pricing, fast delivery.</p>",
primaryColor: "#0F766E",
contactEmail: "orders@indianriverdirect.example",
contactPhone: "(555) 010-3300",
planCode: "starter",
addOns: ["wholesale_portal"],
},
];
for (const t of tenantsData) {
// Upsert tenant
const tenantRes = await client.query<{ id: string }>(
`INSERT INTO tenants (name, slug, status, trial_ends_at)
VALUES ($1, $2, 'active', now() + interval '30 days')
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name
RETURNING id`,
[t.name, t.slug],
);
const tenantId = tenantRes.rows[0].id;
// Plan + subscription
const planRes = await client.query<{ id: string }>(
`SELECT id FROM plans WHERE code = $1`,
[t.planCode],
);
const planId = planRes.rows[0].id;
await client.query(
`INSERT INTO subscriptions (tenant_id, plan_id, status, current_period_end)
VALUES ($1, $2, 'active', now() + interval '30 days')
ON CONFLICT (tenant_id) DO UPDATE SET
plan_id = EXCLUDED.plan_id,
status = EXCLUDED.status,
current_period_end = EXCLUDED.current_period_end`,
[tenantId, planId],
);
// Add-ons (composite PK — ON CONFLICT has a target)
for (const code of t.addOns) {
const addOnRes = await client.query<{ id: string }>(
`SELECT id FROM add_ons WHERE code = $1`,
[code],
);
if (!addOnRes.rows[0]) continue;
const addOnId = addOnRes.rows[0].id;
await client.query(
`INSERT INTO tenant_add_ons (tenant_id, add_on_id, status)
VALUES ($1, $2, 'active')
ON CONFLICT (tenant_id, add_on_id) DO NOTHING`,
[tenantId, addOnId],
);
}
// Brand settings (PK is tenant_id)
await client.query(
`INSERT INTO brand_settings
(tenant_id, brand_name, tagline, about_html, primary_color, contact_email, contact_phone)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (tenant_id) DO UPDATE SET
brand_name = EXCLUDED.brand_name,
tagline = EXCLUDED.tagline,
about_html = EXCLUDED.about_html,
primary_color = EXCLUDED.primary_color,
contact_email = EXCLUDED.contact_email,
contact_phone = EXCLUDED.contact_phone`,
[
tenantId, t.brandName, t.tagline, t.aboutHtml,
t.primaryColor, t.contactEmail, t.contactPhone,
],
);
// Brand admin user
const userRes = await client.query<{ id: string }>(
`INSERT INTO users (email, name, auth_provider, auth_subject)
VALUES ($1, $2, 'dev', $3)
ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name
RETURNING id`,
[`admin@${t.slug}.example`, `${t.brandName} Admin`, `dev-${t.slug}-admin`],
);
const userId = userRes.rows[0].id;
await client.query(
`INSERT INTO tenant_users (tenant_id, user_id, role)
VALUES ($1, $2, 'brand_admin')
ON CONFLICT (tenant_id, user_id) DO NOTHING`,
[tenantId, userId],
);
// Sample products — use WHERE NOT EXISTS (no good unique target other than id)
const products = [
{ name: "Ruby Red Grapefruit", price: 2400, unit: "40 lb case", desc: "Sweet, juicy, seedless." },
{ name: "Navel Oranges", price: 2200, unit: "40 lb case", desc: "Classic eating orange, easy-peel." },
{ name: "Honeybells", price: 3200, unit: "20 lb case", desc: "Limited season — bell-shaped, super sweet." },
{ name: "Tangelos", price: 2800, unit: "40 lb case", desc: "Tangerine-grapefruit hybrid." },
];
for (const p of products) {
await client.query(
`INSERT INTO products (tenant_id, name, description, price_cents, inventory, unit, active)
SELECT $1, $2, $3, $4, 100, $5, true
WHERE NOT EXISTS (
SELECT 1 FROM products WHERE tenant_id = $1 AND name = $2
)`,
[tenantId, p.name, p.desc, p.price, p.unit],
);
}
// Sample stops
const stops = [
{ name: "Downtown Farmers Market", address: "100 Main St", schedule: [{ day: "Saturday", time: "08:00" }] },
{ name: "Eastside Pickup Hub", address: "555 Oak Ave", schedule: [{ day: "Wednesday", time: "16:00" }] },
];
for (const s of stops) {
await client.query(
`INSERT INTO stops (tenant_id, name, address, schedule, status)
SELECT $1, $2, $3, $4::jsonb, 'active'
WHERE NOT EXISTS (
SELECT 1 FROM stops WHERE tenant_id = $1 AND name = $2
)`,
[tenantId, s.name, s.address, JSON.stringify(s.schedule)],
);
}
// Sample customers (email has a unique index per tenant)
const customers = [
{ name: "Green Grocer Co.", email: "buyer@greengrocer.example", phone: "555-010-0010" },
{ name: "Sunset Cafe", email: "orders@sunsetcafe.example", phone: "555-010-0020" },
{ name: "Northside Co-op", email: "produce@northsidecoop.example", phone: "555-010-0030" },
];
for (const c of customers) {
await client.query(
`INSERT INTO customers (tenant_id, name, email, phone, sms_opt_in, email_opt_in)
SELECT $1, $2, $3, $4, true, true
WHERE NOT EXISTS (
SELECT 1 FROM customers WHERE tenant_id = $1 AND email = $3
)`,
[tenantId, c.name, c.email, c.phone],
);
}
// Sample email template (id-based, use WHERE NOT EXISTS)
const tmplRes = await client.query<{ id: string }>(
`INSERT INTO email_templates (tenant_id, name, subject, body_html)
SELECT $1, 'Weekly Availability', 'This week at the grove', '<h1>Fresh this week</h1><p>Hi {{name}}, our harvest is in. Reply to reserve.</p>'
WHERE NOT EXISTS (
SELECT 1 FROM email_templates WHERE tenant_id = $1 AND name = 'Weekly Availability'
)
RETURNING id`,
[tenantId],
);
if (tmplRes.rows[0]) {
await client.query(
`INSERT INTO campaigns (tenant_id, template_id, name, status)
SELECT $1, $2, 'Welcome series — week 1', 'draft'
WHERE NOT EXISTS (
SELECT 1 FROM campaigns WHERE tenant_id = $1 AND name = 'Welcome series — week 1'
)`,
[tenantId, tmplRes.rows[0].id],
);
}
}
console.log(`Seeded ${tenantsData.length} tenants with sample data`);
// ── Platform admin (seeded for dev sign-in via Credentials provider) ─
// email: admin@route-commerce.local
// password: admin (override with SEED_ADMIN_PASSWORD)
//
// The user is attached to the Tuxedo tenant with the `platform_admin`
// role so `getAdminUser()` resolves it and grants cross-tenant
// visibility. To rotate the password, re-run `npm run db:seed`
// (the UPSERT updates `password_hash`).
const tuxedoRes = await client.query<{ id: string }>(
`SELECT id FROM tenants WHERE slug = 'tuxedo'`,
);
const tuxedoId = tuxedoRes.rows[0]?.id;
if (tuxedoId) {
const adminPassword = process.env.SEED_ADMIN_PASSWORD ?? "admin";
const passwordHash = hashPassword(adminPassword);
const adminRes = await client.query<{ id: string }>(
`INSERT INTO users (email, name, auth_provider, auth_subject, password_hash)
VALUES ($1, 'Platform Admin', 'dev', 'dev-platform-admin', $2)
ON CONFLICT (email) DO UPDATE SET
name = EXCLUDED.name,
password_hash = EXCLUDED.password_hash
RETURNING id`,
["admin@route-commerce.local", passwordHash],
);
const adminId = adminRes.rows[0].id;
await client.query(
`INSERT INTO tenant_users (tenant_id, user_id, role)
VALUES ($1, $2, 'platform_admin')
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = EXCLUDED.role`,
[tuxedoId, adminId],
);
console.log(
`Seeded platform admin: admin@route-commerce.local / ${adminPassword === "admin" ? "admin" : "(SEED_ADMIN_PASSWORD)"}`,
);
}
await client.query("COMMIT");
console.log("✅ Seed complete");
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
main()
.then(() => pool.end())
.catch((err) => {
console.error("❌ Seed failed:", err);
pool.end();
process.exit(1);
});
-53
View File
@@ -1,53 +0,0 @@
import { chromium } from 'playwright';
async function debugAuth() {
console.log('Launching browser...');
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext();
const page = await context.newPage();
// First, login
console.log('Navigating to login page...');
await page.goto('https://route-commerce-platform.vercel.app/login');
console.log('Filling login form...');
await page.fill('#email', 'kylemart@gmail.com');
await page.fill('#password', 'Test123456!');
console.log('Clicking sign in...');
const response = await page.click('button[type="submit"]');
// Wait for network to settle
await page.waitForLoadState('networkidle').catch(() => {});
console.log('Current URL after wait:', page.url());
// Get any error messages
const errorText = await page.$eval('[role="alert"]', el => el.textContent).catch(() => null);
if (errorText) console.log('Error message:', errorText);
const pageContent = await page.content();
if (pageContent.includes('Access Denied')) {
console.log('*** ACCESS DENIED PAGE DETECTED ***');
}
// Check cookies
const cookies = await context.cookies();
console.log('Cookies:', cookies.map(c => `${c.name}=${c.value.slice(0, 30)}...`));
// Try to visit debug-auth page
console.log('Navigating to /admin/debug-auth...');
try {
const response = await page.goto('https://route-commerce-platform.vercel.app/admin/debug-auth', { timeout: 10000 });
console.log('Response status:', response?.status());
console.log('Response URL:', page.url());
const content = await page.content();
console.log('Page content (first 2000 chars):', content.slice(0, 2000));
} catch (e) {
console.log('Error:', e);
}
await browser.close();
}
debugAuth().catch(console.error);
+7 -39
View File
@@ -2,17 +2,14 @@
# docker-compose.yml — production stack consumed by deploy.sh # docker-compose.yml — production stack consumed by deploy.sh
# ============================================================================= # =============================================================================
# #
# The host-side ports (POSTGREST_HOST_PORT, NEXTJS_HOST_PORT) are written by # Only `postgrest` lives in docker. Postgres itself runs on the host
# deploy.sh into .env.production. We interpolate from there with ${VAR:-3011} # (the deploy workflow applies migrations via `psql -h 127.0.0.1`).
# so a manual `docker compose up` without the deploy script still works. # Next.js runs under PM2 on the host — it is NOT a docker service.
# #
# Note on networking: the Next.js container calls PostgREST on # The host-side port (POSTGREST_HOST_PORT) is written by the deploy
# `host.docker.internal:POSTGREST_HOST_PORT` so the inlined # workflow into $APP_DIR/.env. We interpolate from there with
# NEXT_PUBLIC_API_URL (a localhost URL, per the deploy contract) resolves # ${VAR:-3011} so a manual `docker compose up` without the deploy
# correctly. On Linux you may need to add # script still works.
# extra_hosts:
# - "host.docker.internal:host-gateway"
# which is included below for that reason.
# ============================================================================= # =============================================================================
name: prod-app # default project name; deploy.sh overrides with -p name: prod-app # default project name; deploy.sh overrides with -p
@@ -39,32 +36,3 @@ services:
timeout: 3s timeout: 3s
retries: 6 retries: 6
nextjs:
# Build context is the workspace root (one level up from this file).
build:
context: ..
dockerfile: deploy/Dockerfile.nextjs
container_name: prod-app-nextjs
restart: unless-stopped
ports:
- "${NEXTJS_HOST_PORT:-3012}:3000"
environment:
# Runtime vars — these can change without rebuilding. NEXT_PUBLIC_*
# is also exported here for completeness, but the BROWSER's view of
# NEXT_PUBLIC_API_URL is baked in at build time (see Dockerfile).
NODE_ENV: production
PORT: 3000
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL}
env_file:
- ../.env.production # server-side secrets read at runtime
extra_hosts:
# Lets the container reach the host on the dynamically allocated port.
- "host.docker.internal:host-gateway"
depends_on:
postgrest:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/api/health"]
interval: 10s
timeout: 3s
retries: 6
+18
View File
@@ -0,0 +1,18 @@
/**
* Drizzle Kit config. Used by `drizzle-kit generate` / `drizzle-kit push`
* for future migrations. The schema in `db/migrations/0001_init.sql` is
* the source of truth for v1; subsequent migrations can be generated
* from changes to `db/schema/*.ts` and committed alongside the SQL.
*/
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./db/schema/index.ts",
out: "./db/migrations",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL ?? "postgres://postgres:postgres@localhost:5432/route_commerce",
},
strict: true,
verbose: true,
});
+11
View File
@@ -1,6 +1,17 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
// Lock the file-tracing root to the project directory. Without this,
// Next.js 16 walks up from package.json looking for a lockfile, finds
// the homelab runner's stale `act` cache at
// /home/tyler/.cache/act/.../package-lock.json, and warns:
// "We detected multiple lockfiles and selected the directory of
// /home/tyler/package-lock.json as the root directory."
// The deploy runner's APP_DIR is /home/tyler/route-commerce, so
// resolving relative to the project root is correct both locally and
// in CI.
outputFileTracingRoot: ".",
// Enable strict mode // Enable strict mode
reactStrictMode: true, reactStrictMode: true,
+20 -8
View File
@@ -3,29 +3,35 @@
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "node fix-agents.js && next dev --webpack -H 0.0.0.0 -p 4000", "dev": "node fix-agents.js && next dev --webpack -H 0.0.0.0",
"build": "next build --webpack", "build": "next build --webpack",
"start": "next start", "start": "next start",
"lint": "eslint", "lint": "eslint",
"lint:fix": "eslint --fix", "lint:fix": "eslint --fix",
"migrate": "node supabase/push-migrations.js", "migrate": "node scripts/migrate.js",
"migrate:one": "node supabase/push-migrations.js", "migrate:one": "node scripts/migrate.js",
"db:migrate": "node scripts/migrate.js",
"db:seed": "tsx db/seed.ts",
"db:reset": "node scripts/db-reset.js",
"db:studio": "drizzle-kit studio",
"type-check": "npx tsc --noEmit", "type-check": "npx tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui",
"test:e2e": "playwright test --project=local",
"test:e2e:prod": "PLAYWRIGHT_PROD=1 playwright test",
"format": "prettier --write \"src/**/*.{ts,tsx}\"" "format": "prettier --write \"src/**/*.{ts,tsx}\""
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.96.0", "@anthropic-ai/sdk": "^0.96.0",
"@auth/pg-adapter": "^1.11.2",
"@clerk/nextjs": "^7.4.2",
"@google/generative-ai": "^0.24.1", "@google/generative-ai": "^0.24.1",
"@gsap/react": "^2.1.2", "@gsap/react": "^2.1.2",
"@sentry/nextjs": "^10.55.0", "@sentry/nextjs": "^10.55.0",
"@stripe/react-stripe-js": "^3.10.0",
"@stripe/stripe-js": "^5.10.0",
"@supabase/ssr": "^0.10.2", "@supabase/ssr": "^0.10.2",
"@supabase/supabase-js": "^2.105.3", "@supabase/supabase-js": "^2.105.3",
"@upstash/ratelimit": "^2.0.8", "@upstash/ratelimit": "^2.0.8",
"@upstash/redis": "^1.38.0", "@upstash/redis": "^1.38.0",
"drizzle-orm": "^0.36.4",
"exceljs": "^4.4.0", "exceljs": "^4.4.0",
"framer-motion": "^12.40.0", "framer-motion": "^12.40.0",
"gsap": "^3.15.0", "gsap": "^3.15.0",
@@ -57,13 +63,19 @@
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@types/uuid": "^11.0.0", "@types/uuid": "^11.0.0",
"@vitejs/plugin-react": "^4.7.0",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"drizzle-kit": "^0.30.6",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.2.5", "eslint-config-next": "16.2.5",
"jsdom": "^25.0.1",
"pg": "^8.20.0", "pg": "^8.20.0",
"playwright": "^1.59.1", "playwright": "^1.59.1",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5.9.3" "tsx": "^4.22.4",
"typescript": "^5",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^2.1.9"
}, },
"overrides": "{}" "overrides": "{}"
} }
+11 -5
View File
@@ -1,6 +1,9 @@
import { defineConfig, devices } from "@playwright/test"; import { defineConfig, devices } from "@playwright/test";
import path from "path"; import path from "path";
const LOCAL_BASE = process.env.PLAYWRIGHT_URL ?? "http://localhost:3000";
const PROD_BASE = "https://route-commerce-platform.vercel.app";
export default defineConfig({ export default defineConfig({
testDir: "./tests", testDir: "./tests",
fullyParallel: false, fullyParallel: false,
@@ -9,16 +12,19 @@ export default defineConfig({
workers: 1, workers: 1,
reporter: "list", reporter: "list",
use: { use: {
baseURL: "https://route-commerce-platform.vercel.app", baseURL: LOCAL_BASE,
trace: "on-first-retry", trace: "on-first-retry",
}, },
projects: [ projects: [
{ {
name: "production", name: "local",
use: { use: { ...devices["Desktop Chrome"], baseURL: LOCAL_BASE },
...devices["Desktop Chrome"],
baseURL: "https://route-commerce-platform.vercel.app",
}, },
{
name: "production",
// `PLAYWRIGHT_PROD=1 npx playwright test` to run against the live site.
testMatch: /.*\.prod\.spec\.ts$/,
use: { ...devices["Desktop Chrome"], baseURL: PROD_BASE },
}, },
], ],
}); });
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env node
/**
* DESTRUCTIVE: drops and recreates the `route_commerce` database, then
* applies all migrations and seeds.
*
* Usage:
* npm run db:reset
*
* Use only in dev. Requires sudo-less access to a postgres superuser.
*/
require("dotenv").config({ path: ".env.local" });
const { execSync } = require("node:child_process");
const url = process.env.DATABASE_URL;
if (!url) {
console.error("❌ DATABASE_URL is not set");
process.exit(1);
}
const m = url.match(/postgres(?:ql)?:\/\/([^:]+):[^@]+@([^:]+):(\d+)\/(.+)/);
if (!m) {
console.error("❌ Could not parse DATABASE_URL");
process.exit(1);
}
const [, user, host, port, db] = m;
const adminUrl = url.replace(/\/[^/]+$/, "/postgres");
console.log(`⚠️ DROPPING and recreating ${db} on ${host}:${port} as ${user}`);
try {
execSync(
`psql "${adminUrl}" -c "DROP DATABASE IF EXISTS ${db};" -c "CREATE DATABASE ${db} OWNER ${user};"`,
{ stdio: "inherit" },
);
console.log("✓ Database recreated");
} catch (err) {
console.error("❌ Failed to drop/create database. Try with sudo:");
console.error(
` sudo -u postgres psql -c "DROP DATABASE IF EXISTS ${db};" -c "CREATE DATABASE ${db} OWNER ${user};"`,
);
process.exit(1);
}
execSync("npm run db:migrate", { stdio: "inherit" });
execSync("npm run db:seed", { stdio: "inherit" });
console.log("✅ Database reset, migrated, and seeded");
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env node
/**
* Apply Postgres migrations from `db/migrations/*.sql` in lexical order.
* Wraps the whole thing in a transaction; tracks applied files in
* `_migrations` so re-runs are safe.
*
* Usage:
* npm run db:migrate
*
* Replaces the old `supabase/push-migrations.js` — that script was
* hardcoded to a Supabase URL. This one reads `DATABASE_URL` directly.
*/
require("dotenv").config({ path: ".env.local" });
const fs = require("node:fs");
const path = require("node:path");
const { Client } = require("pg");
const MIGRATIONS_DIR = path.join(__dirname, "..", "db", "migrations");
async function main() {
const url = process.env.DATABASE_ADMIN_URL ?? process.env.DATABASE_URL;
if (!url) {
console.error("❌ DATABASE_URL (or DATABASE_ADMIN_URL) is not set in .env.local");
process.exit(1);
}
const client = new Client({ connectionString: url });
await client.connect();
try {
await client.query(`
CREATE TABLE IF NOT EXISTS _migrations (
filename TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`);
const files = fs
.readdirSync(MIGRATIONS_DIR)
.filter((f) => f.endsWith(".sql"))
.sort();
if (files.length === 0) {
console.log("No migration files found in db/migrations/");
return;
}
const { rows: applied } = await client.query(
`SELECT filename FROM _migrations`,
);
const appliedSet = new Set(applied.map((r) => r.filename));
let appliedNow = 0;
for (const file of files) {
if (appliedSet.has(file)) {
console.log(`${file} (already applied)`);
continue;
}
const sql = fs.readFileSync(path.join(MIGRATIONS_DIR, file), "utf8");
console.log(`→ Applying ${file}...`);
try {
await client.query("BEGIN");
await client.query(sql);
await client.query(`INSERT INTO _migrations (filename) VALUES ($1)`, [
file,
]);
await client.query("COMMIT");
appliedNow += 1;
console.log(`${file}`);
} catch (err) {
await client.query("ROLLBACK");
console.error(`${file} failed:`, err.message);
throw err;
}
}
console.log(
`\n✅ Done. ${appliedNow} new migration(s) applied. ${
files.length - appliedNow
} already current.`,
);
} finally {
await client.end();
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+11 -22
View File
@@ -1,15 +1,6 @@
"use server"; "use server";
import { createClient as createServiceClient } from "@supabase/supabase-js"; import { pool } from "@/lib/db";
function getServiceClient() {
const roleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!roleKey) throw new Error("SUPABASE_SERVICE_ROLE_KEY is not set");
return createServiceClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
roleKey,
);
}
type AdminActionPayload = { type AdminActionPayload = {
action_type: "create" | "update" | "delete"; action_type: "create" | "update" | "delete";
@@ -30,35 +21,33 @@ type UserActivityPayload = {
export async function logAdminAction(payload: AdminActionPayload): Promise<void> { export async function logAdminAction(payload: AdminActionPayload): Promise<void> {
try { try {
const service = getServiceClient(); await pool.query("SELECT log_admin_action($1::jsonb)", [
await service.rpc("log_admin_action", { JSON.stringify({
p_payload: {
action_type: payload.action_type, action_type: payload.action_type,
admin_id: payload.admin_id ?? null, admin_id: payload.admin_id ?? null,
admin_email: payload.admin_email ?? null, admin_email: payload.admin_email ?? null,
affected_user_id: payload.affected_user_id ?? null, affected_user_id: payload.affected_user_id ?? null,
brand_id: payload.brand_id ?? null, brand_id: payload.brand_id ?? null,
details: payload.details ?? {}, details: payload.details ?? {},
}, }),
}); ]);
} catch (e) { } catch {
// logging failed silently // logging failed silently
} }
} }
export async function logUserActivity(payload: UserActivityPayload): Promise<void> { export async function logUserActivity(payload: UserActivityPayload): Promise<void> {
try { try {
const service = getServiceClient(); await pool.query("SELECT log_user_activity($1::jsonb)", [
await service.rpc("log_user_activity", { JSON.stringify({
p_payload: {
user_id: payload.user_id, user_id: payload.user_id,
activity_type: payload.activity_type, activity_type: payload.activity_type,
details: payload.details ?? {}, details: payload.details ?? {},
ip_address: payload.ip_address ?? null, ip_address: payload.ip_address ?? null,
user_agent: payload.user_agent ?? null, user_agent: payload.user_agent ?? null,
}, }),
}); ]);
} catch (e) { } catch {
// logging failed silently // logging failed silently
} }
} }
-63
View File
@@ -1,63 +0,0 @@
"use server";
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
const DEV_ADMIN_UID = "dev-user-00000000-0000-0000-0000-000000000001";
export async function forceAdminLogin(): Promise<{ success: boolean; uid?: string; error?: string }> {
const cookieStore = await cookies();
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = NextResponse.next();
const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
cookies: {
getAll() { return cookieStore.getAll(); },
setAll(cookiesToSet, headers) {
cookiesToSet.forEach(({ name, value, options }) => {
response.cookies.set(name, value, options);
});
Object.entries(headers).forEach(([key, value]) => {
response.headers.set(key, value);
});
},
},
});
// Upsert dev platform_admin record
const { data: existing } = await supabase
.from("admin_users")
.select("id, role")
.eq("user_id", DEV_ADMIN_UID)
.single();
if (!existing) {
const { error: insertError } = await supabase
.from("admin_users")
.insert({
user_id: DEV_ADMIN_UID,
brand_id: null,
role: "platform_admin",
active: true,
can_manage_products: true,
can_manage_stops: true,
can_manage_orders: true,
can_manage_pickup: true,
can_manage_messages: true,
can_manage_refunds: true,
can_manage_users: true,
can_manage_water_log: true,
can_manage_reports: true,
can_manage_settings: true,
must_change_password: false,
});
if (insertError) {
return { success: false, error: insertError.message };
}
}
return { success: true, uid: DEV_ADMIN_UID };
}
+38 -20
View File
@@ -1,30 +1,48 @@
"use server"; "use server";
import { cookies } from "next/headers"; import { auth } from "@/lib/auth";
import { createClient as createServiceClient } from "@supabase/supabase-js"; import { query } from "@/lib/db";
/**
* Update the current user's Supabase auth password.
*
* Reads the Auth.js v5 session to identify the user. The session's
* `user.id` is either:
* - a Supabase auth user id (UUID) for email/password sign-ins
* - a Google `sub` (non-UUID) for Google sign-ins — these are not
* provisioned in Supabase auth, so the RPC will reject them. Google
* users must be provisioned in Supabase auth separately.
*
* The password update itself runs as a SECURITY DEFINER PL/pgSQL function
* (`update_user_password`) inside the database, called directly via the
* shared `pg` pool. No Supabase REST hop required.
*/
export async function updatePasswordAction( export async function updatePasswordAction(
newPassword: string newPassword: string
): Promise<{ error?: string }> { ): Promise<{ error?: string; userId?: string }> {
const cookieStore = await cookies(); const session = await auth();
const uid = const uid = session?.user?.id;
cookieStore.get("rc_auth_uid")?.value ??
cookieStore.get("rc_uid")?.value;
if (!uid) { if (!uid) {
return { error: "Not authenticated. Please log in again." }; return { error: "Not authenticated. Please log in again." };
} }
const service = createServiceClient( const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
process.env.NEXT_PUBLIC_SUPABASE_URL!, if (!UUID_REGEX.test(uid)) {
process.env.SUPABASE_SERVICE_ROLE_KEY! return {
); error:
"Password change is not available for social sign-in accounts. Please contact an admin.",
const { error } = await service.rpc("update_user_password", { };
p_user_id: uid, }
p_password: newPassword,
}); try {
// The RPC is SECURITY DEFINER and returns a single row (or raises).
if (error) return { error: error.message }; // We SELECT it (rather than SELECT update_user_password(...)) so the
return {}; // call stays a normal parameterized query and we can read the result.
await query("SELECT update_user_password($1, $2)", [uid, newPassword]);
return { userId: uid };
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to update password.";
return { error: message };
}
} }
+22 -31
View File
@@ -1,15 +1,6 @@
"use server"; "use server";
import { createClient as createServiceClient } from "@supabase/supabase-js"; import { query } from "@/lib/db";
function getServiceClient() {
const roleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!roleKey) throw new Error("SUPABASE_SERVICE_ROLE_KEY not set");
return createServiceClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
roleKey,
);
}
export type ResetAdminPasswordResult = export type ResetAdminPasswordResult =
| { success: true; tempPassword: string } | { success: true; tempPassword: string }
@@ -17,35 +8,35 @@ export type ResetAdminPasswordResult =
/** /**
* Emergency recovery action — only usable in development or when the caller * Emergency recovery action — only usable in development or when the caller
* already has service role access. Resets the password for the specified email * already has direct DB access. Resets the password for the specified email
* and returns the temp password so it can be displayed to the user. * and returns the temp password so it can be displayed to the user.
*
* Now that Supabase auth is gone, this hits the `users` table directly and
* calls the same `update_user_password` SECURITY DEFINER RPC used by the
* self-service password change action.
*/ */
export async function resetAdminPassword( export async function resetAdminPassword(
email: string, email: string,
newPassword: string newPassword: string
): Promise<ResetAdminPasswordResult> { ): Promise<ResetAdminPasswordResult> {
const service = getServiceClient(); // Look up the user by email
const { rows } = await query<{ id: string }>(
// Look up auth user by email "SELECT id FROM users WHERE email = $1 LIMIT 1",
const { data: authUsers, error: listError } = await service.auth.admin.listUsers(); [email.toLowerCase()],
if (listError || !authUsers?.users) { );
return { success: false, error: "Could not list users: " + listError?.message }; const user = rows[0];
} if (!user) {
const authUser = authUsers.users.find((u) => u.email?.toLowerCase() === email.toLowerCase());
if (!authUser) {
return { success: false, error: "No auth user found for that email address." }; return { success: false, error: "No auth user found for that email address." };
} }
// Update password via service role // Update password via SECURITY DEFINER RPC
const { error: updateError } = await service.auth.admin.updateUserById( try {
authUser.id, await query("SELECT update_user_password($1, $2)", [user.id, newPassword]);
{ password: newPassword, email_confirm: true }
);
if (updateError) {
return { success: false, error: updateError.message };
}
return { success: true, tempPassword: newPassword }; return { success: true, tempPassword: newPassword };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to update password",
};
}
} }
+222 -518
View File
@@ -1,18 +1,15 @@
"use server"; "use server";
import { cookies, headers } from "next/headers"; import "server-only";
import { createServerClient } from "@supabase/ssr"; import { cookies } from "next/headers";
import { NextRequest } from "next/server"; import { pool, query } from "@/lib/db";
import { NextResponse } from "next/server";
import { createClient as createServiceClient } from "@supabase/supabase-js";
import { supabase as publicSupabase } from "@/lib/supabase";
import { getMockTableData, mockBrands } from "@/lib/mock-data"; import { getMockTableData, mockBrands } from "@/lib/mock-data";
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true"; const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
export type AdminUserRow = { export type AdminUserRow = {
id: string; id: string;
user_id: string; user_id: string | null;
display_name: string | null; display_name: string | null;
email: string; email: string;
phone_number: string | null; phone_number: string | null;
@@ -75,169 +72,17 @@ export type UpdateAdminUserInput = {
phone_number?: string | null; phone_number?: string | null;
}; };
// ─── SSR client for authenticated requests ───────────────────────────────── // ─── Row mapping ────────────────────────────────────────────────────────────
//
async function getAuthClient() { // `admin_users` schema (after migration 204 + 034 + 037):
const cookieStore = await cookies(); // id, user_id, display_name, email, phone_number, role, brand_id,
const headerStore = await headers(); // can_manage_<X> (BOOLEAN each), active, must_change_password,
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; // created_at, last_login, raw_user_meta_data, auth_provider, auth_subject
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const request = new NextRequest("http://localhost/admin", { headers: new Headers() });
const response = NextResponse.next({ request });
// Read rc_auth_uid from the raw HTTP Cookie header (document.cookie sets
// cookies that arrive in the header but NOT in next/headers cookies()).
const cookieHeader = headerStore.get("cookie") || "";
const allCookies = cookieHeader.split(";").map(c => c.trim());
const rcUidCookie = allCookies.find(c => c.startsWith("rc_auth_uid="));
const rcAuthUid = rcUidCookie ? rcUidCookie.split("=")[1] : null;
const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
cookies: {
getAll() { return cookieStore.getAll(); },
setAll(cookiesToSet, headers) {
cookiesToSet.forEach(({ name, value, options }) => response.cookies.set(name, value, options));
Object.entries(headers).forEach(([key, value]) => response.headers.set(key, value));
},
},
});
return { supabase, response, rcAuthUid };
}
async function callRpcWithAuth<T>(fn: string, params: Record<string, unknown>): Promise<{ data: T | null; error: string | null }> {
const { supabase, rcAuthUid } = await getAuthClient();
// Dev force-login UID bypasses Supabase auth entirely
const DEV_FORCE_UID = "dev-user-00000000-0000-0000-0000-000000000001";
if (rcAuthUid === DEV_FORCE_UID) {
return { data: null, error: null }; // let the action proceed without auth check
}
const { data: userData, error: userError } = await supabase.auth.getUser();
if (userError || !userData.user) {
return { data: null, error: "Not authenticated" };
}
const { data, error } = await supabase.rpc(fn, params as Record<string, unknown>);
if (error) { /* RPC error handled silently */ }
return { data: data as T, error: error ? error.message : null };
}
// ─── Service role client (server-only, never exposed to browser) ───────────
function getServiceClient() {
const roleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!roleKey) {
throw new Error("SUPABASE_SERVICE_ROLE_KEY is not set. Cannot use service role in dev path.");
}
return createServiceClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
roleKey,
);
}
// ─── Dev-only path — uses service role to create auth user + admin_users ──
async function devCreateAdminUser(input: CreateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> {
if (process.env.NODE_ENV === "production") {
return { user: null, error: "Dev path not available in production" };
}
const cookieStore = await cookies();
const devSession = cookieStore.get("dev_session")?.value;
if (!devSession || devSession !== "platform_admin") {
return { user: null, error: "Not authenticated" };
}
const service = getServiceClient();
// Create auth user with the provided password
const { data: authUser, error: authError } = await service.auth.admin.createUser({
email: input.email,
password: input.password,
email_confirm: true,
user_metadata: {
display_name: input.display_name || input.email.split("@")[0],
phone_number: input.phone_number ?? null,
},
});
if (authError || !authUser.user) {
return { user: null, error: authError?.message ?? "Failed to create auth user" };
}
// Insert into admin_users
const { data: inserted, error: insertError } = await service
.from("admin_users")
.insert({
user_id: authUser.user.id,
role: input.role,
brand_id: input.brand_id,
display_name: input.display_name || input.email.split("@")[0],
phone_number: input.phone_number ?? null,
can_manage_products: input.flags.can_manage_products ?? false,
can_manage_stops: input.flags.can_manage_stops ?? false,
can_manage_orders: input.flags.can_manage_orders ?? false,
can_manage_pickup: input.flags.can_manage_pickup ?? false,
can_manage_messages: input.flags.can_manage_messages ?? false,
can_manage_refunds: input.flags.can_manage_refunds ?? false,
can_manage_users: input.flags.can_manage_users ?? false,
can_manage_water_log: input.flags.can_manage_water_log ?? false,
can_manage_reports: input.flags.can_manage_reports ?? false,
active: true,
must_change_password: input.mustChangePassword ?? true,
})
.select()
.single();
if (insertError) {
return { user: null, error: insertError.message };
}
// Send welcome email
try {
const { sendWelcomeEmail } = await import("@/lib/email-service");
const emailRole = input.role === "platform_admin" ? "brand_admin" : input.role;
await sendWelcomeEmail({
to: input.email,
name: input.display_name || input.email.split("@")[0],
role: emailRole as "brand_admin" | "wholesale_buyer" | "store_employee",
brandName: "Tuxedo Corn",
tempPassword: input.password,
});
} catch (e) {
// welcome email failed silently
}
return {
user: {
id: inserted.id,
user_id: inserted.user_id,
display_name: inserted.display_name ?? input.display_name ?? input.email.split("@")[0],
email: input.email,
phone_number: inserted.phone_number ?? input.phone_number ?? null,
role: inserted.role,
brand_id: inserted.brand_id,
brand_name: null,
can_manage_products: inserted.can_manage_products,
can_manage_stops: inserted.can_manage_stops,
can_manage_orders: inserted.can_manage_orders,
can_manage_pickup: inserted.can_manage_pickup,
can_manage_messages: inserted.can_manage_messages,
can_manage_refunds: inserted.can_manage_refunds,
can_manage_users: inserted.can_manage_users,
can_manage_water_log: inserted.can_manage_water_log,
can_manage_reports: inserted.can_manage_reports,
active: inserted.active,
must_change_password: inserted.must_change_password ?? true,
created_at: inserted.created_at,
last_login: null,
},
error: null,
};
}
function mapUserRow(row: Record<string, unknown>): AdminUserRow { function mapUserRow(row: Record<string, unknown>): AdminUserRow {
return { return {
id: String(row.id ?? ""), id: String(row.id ?? ""),
user_id: String(row.user_id ?? ""), user_id: (row.user_id as string | null) ?? null,
display_name: (row.display_name as string | null) ?? null, display_name: (row.display_name as string | null) ?? null,
email: String(row.email ?? ""), email: String(row.email ?? ""),
phone_number: (row.phone_number as string | null) ?? null, phone_number: (row.phone_number as string | null) ?? null,
@@ -260,237 +105,80 @@ function mapUserRow(row: Record<string, unknown>): AdminUserRow {
}; };
} }
// ─── Dev path helpers (service role, local only) ─────────────────────────── // ─── Welcome email (best-effort) ────────────────────────────────────────────
async function devListAdminUsers(callerUid?: string): Promise<{ users: AdminUserRow[]; error: string | null }> { async function sendWelcomeEmailSafe(input: {
const service = getServiceClient(); to: string;
name: string;
// Ensure caller has an admin_users record role: "platform_admin" | "brand_admin" | "store_employee";
if (callerUid) { password: string;
const { data: existing } = await service }): Promise<void> {
.from("admin_users") try {
.select("id") const { sendWelcomeEmail } = await import("@/lib/email-service");
.eq("user_id", callerUid) const emailRole = input.role === "platform_admin" ? "brand_admin" : input.role;
.maybeSingle(); await sendWelcomeEmail({
to: input.to,
if (!existing) { name: input.name,
// auto-creating admin_users for uid role: emailRole as "brand_admin" | "wholesale_buyer" | "store_employee",
const { data: authData } = await service.auth.admin.listUsers(); brandName: "Tuxedo Corn",
const authUser = authData?.users?.find((u) => u.id === callerUid); tempPassword: input.password,
const meta = (authUser as { user_metadata?: Record<string, unknown> })?.user_metadata;
await service.from("admin_users").insert({
user_id: callerUid,
role: "platform_admin",
brand_id: null,
display_name: (meta?.display_name as string | null) ?? authUser?.email?.split("@")[0] ?? "Admin",
phone_number: (meta?.phone_number as string | null) ?? null,
can_manage_products: true,
can_manage_stops: true,
can_manage_orders: true,
can_manage_pickup: true,
can_manage_messages: true,
can_manage_refunds: true,
can_manage_users: true,
can_manage_water_log: true,
can_manage_reports: true,
active: true,
must_change_password: false,
}); });
} catch {
// welcome email is best-effort; never block user creation
} }
} }
// Fetch all admin_users rows (no RLS for service role) // ─── Public actions ─────────────────────────────────────────────────────────
const { data: adminRows, error: adminError } = await service
.from("admin_users")
.select(`
id, user_id, role, brand_id, active, must_change_password, created_at, last_login,
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,
brands (name)
`)
.order("created_at", { ascending: false });
if (adminError) return { users: [], error: adminError.message };
// Fetch auth user details via service role admin API
const { data: authData, error: authError } = await service.auth.admin.listUsers();
if (authError) return { users: [], error: authError.message };
const authMap: Record<string, { email: string; display_name: string | null; phone_number: string | null }> = {};
(authData?.users ?? []).forEach((u) => {
const user = u as { id: string; email?: string; user_metadata?: Record<string, unknown> };
authMap[user.id] = {
email: user.email ?? "",
display_name: (user.user_metadata?.display_name as string | null) ?? (user.user_metadata?.full_name as string | null) ?? null,
phone_number: (user.user_metadata?.phone_number as string | null) ?? null,
};
});
const users: AdminUserRow[] = (adminRows ?? []).map((row) => {
const r = row as Record<string, unknown> & { brands?: { name?: string } };
const authInfo = authMap[String(r.user_id ?? "")] ?? { email: "", display_name: null, phone_number: null };
return {
...mapUserRow(r),
email: authInfo.email || "No Email",
display_name: authInfo.display_name ?? null,
phone_number: authInfo.phone_number ?? (r.phone_number as string | null) ?? null,
brand_name: r.brands?.name ?? null,
};
});
return { users, error: null };
}
function buildUsersFromRows(adminRows: Record<string, unknown>[], authUsers: { id: string; email?: string; user_metadata?: Record<string, unknown> }[]): { users: AdminUserRow[]; error: string | null } {
const authMap: Record<string, { email: string; display_name: string | null; phone_number: string | null }> = {};
(authUsers ?? []).forEach((u) => {
authMap[u.id] = {
email: u.email ?? "",
display_name: (u.user_metadata?.display_name as string | null) ?? (u.user_metadata?.full_name as string | null) ?? null,
phone_number: (u.user_metadata?.phone_number as string | null) ?? null,
};
});
const users: AdminUserRow[] = adminRows.map((row) => {
const r = row as Record<string, unknown> & { brands?: { name?: string } };
const authInfo = authMap[String(r.user_id ?? "")] ?? { email: "", display_name: null, phone_number: null };
return {
...mapUserRow(r),
email: authInfo.email || "No Email",
display_name: authInfo.display_name ?? null,
phone_number: authInfo.phone_number ?? (r.phone_number as string | null) ?? null,
brand_name: r.brands?.name ?? null,
};
});
return { users, error: null };
}
// ─── Production admin actions (require real Supabase auth) ─────────────────
const DEV_FORCE_UID = "dev-user-00000000-0000-0000-0000-000000000001";
export async function getAdminUsers(brandId?: string): Promise<{ users: AdminUserRow[]; error: string | null }> { export async function getAdminUsers(brandId?: string): Promise<{ users: AdminUserRow[]; error: string | null }> {
if (useMockData) { if (useMockData) {
const mockUsers = getMockTableData("users") as AdminUserRow[]; const mockUsers = getMockTableData("users") as AdminUserRow[];
let filteredUsers = mockUsers;
if (brandId) {
filteredUsers = mockUsers.filter(u => u.brand_id === brandId);
}
return { users: filteredUsers, error: null };
}
const cookieStore = await cookies();
const headerStore = await headers();
const devSession = cookieStore.get("dev_session")?.value;
// Read rc_auth_uid for force-login check
const cookieHeader = headerStore.get("cookie") || "";
const rcAuthUid = cookieHeader.split(";").map(c => c.trim())
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
// In development mode: ALL requests with a valid rc_auth_uid use the dev/service path.
// This includes real Supabase auth users — bypassing supabase.auth.getUser JWT validation.
if (process.env.NODE_ENV !== "production" && rcAuthUid && rcAuthUid !== DEV_FORCE_UID) {
return devListAdminUsers(rcAuthUid);
}
// Dev session cookie (platform_admin/brand_admin) — always use service role path
const isDevAdmin = process.env.NODE_ENV !== "production" && (
devSession === "platform_admin" || devSession === "brand_admin" || rcAuthUid === DEV_FORCE_UID
);
if (isDevAdmin) {
return devListAdminUsers(rcAuthUid ?? undefined);
}
// Production path: try authenticated RPC first, fall back to service role if not authenticated
const result = await callRpcWithAuth<AdminUserRow[]>("get_admin_users", { p_brand_id: brandId ?? null });
if (result.error === "Not authenticated" && rcAuthUid) {
// No Supabase session token in browser — use service role with rc_auth_uid
const service = getServiceClient();
const { data: adminRows, error: adminError } = await service
.from("admin_users")
.select(`id, user_id, role, brand_id, active, must_change_password, created_at, last_login,
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,
brands (name)`)
.order("created_at", { ascending: false });
if (adminError) return { users: [], error: adminError.message };
const { data: authData } = await service.auth.admin.listUsers();
const authMap: Record<string, { email: string; display_name: string | null; phone_number: string | null }> = {};
(authData?.users ?? []).forEach((u) => {
const user = u as { id: string; email?: string; user_metadata?: Record<string, unknown> };
authMap[user.id] = {
email: user.email ?? "",
display_name: (user.user_metadata?.display_name as string | null) ?? null,
phone_number: (user.user_metadata?.phone_number as string | null) ?? null,
};
});
const users: AdminUserRow[] = (adminRows ?? []).map((row) => {
const r = row as Record<string, unknown> & { brands?: { name?: string } };
const authInfo = authMap[String(r.user_id ?? "")] ?? { email: "", display_name: null, phone_number: null };
return { return {
...mapUserRow(r), users: brandId ? mockUsers.filter((u) => u.brand_id === brandId) : mockUsers,
email: authInfo.email || "No Email", error: null,
display_name: authInfo.display_name ?? null,
phone_number: authInfo.phone_number ?? (r.phone_number as string | null) ?? null,
brand_name: r.brands?.name ?? null,
}; };
});
return { users, error: null };
} }
return { users: result.data ?? [], error: result.error };
try {
const sql = brandId
? `SELECT au.id, au.user_id, au.display_name, au.email, au.phone_number,
au.role, au.brand_id, b.name AS brand_name,
au.can_manage_products, au.can_manage_stops, au.can_manage_orders,
au.can_manage_pickup, au.can_manage_messages, au.can_manage_refunds,
au.can_manage_users, au.can_manage_water_log, au.can_manage_reports,
au.active, au.must_change_password, au.created_at, au.last_login
FROM admin_users au
LEFT JOIN brands b ON b.id = au.brand_id
WHERE au.brand_id = $1
ORDER BY au.created_at DESC`
: `SELECT au.id, au.user_id, au.display_name, au.email, au.phone_number,
au.role, au.brand_id, b.name AS brand_name,
au.can_manage_products, au.can_manage_stops, au.can_manage_orders,
au.can_manage_pickup, au.can_manage_messages, au.can_manage_refunds,
au.can_manage_users, au.can_manage_water_log, au.can_manage_reports,
au.active, au.must_change_password, au.created_at, au.last_login
FROM admin_users au
LEFT JOIN brands b ON b.id = au.brand_id
ORDER BY au.created_at DESC`;
const { rows } = await query<Record<string, unknown>>(sql, brandId ? [brandId] : []);
return { users: rows.map(mapUserRow), error: null };
} catch (err) {
return { users: [], error: err instanceof Error ? err.message : String(err) };
}
} }
export async function createAdminUser(input: CreateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> { export async function createAdminUser(input: CreateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> {
// Read auth context if (useMockData) {
const cookieStore = await cookies(); const mockUsers = getMockTableData("users") as AdminUserRow[];
const headerStore = await headers(); const newRow: AdminUserRow = {
const devSession = cookieStore.get("dev_session")?.value; id: `mock-${Date.now()}`,
const cookieHeader = headerStore.get("cookie") || ""; user_id: null,
const rcAuthUid = cookieHeader.split(";").map(c => c.trim()) display_name: input.display_name ?? input.email.split("@")[0],
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
// DEV_FORCE_UID bypass — set by Emergency Force Login or /api/force-admin
if (rcAuthUid === DEV_FORCE_UID) {
return devCreateAdminUser(input);
}
const isDevAdmin = process.env.NODE_ENV !== "production" && devSession === "platform_admin";
// Dev path: use service role to create user without Supabase auth session
if (isDevAdmin) {
return devCreateAdminUser(input);
}
// Production path — use service role directly (bypasses Supabase JWT auth)
// rc_auth_uid cookie proves the admin is logged in; service role creates the account
if (rcAuthUid) {
const service = getServiceClient();
// Create auth user
const { data: authUser, error: authError } = await service.auth.admin.createUser({
email: input.email, email: input.email,
password: input.password,
email_confirm: true,
user_metadata: {
display_name: input.display_name || input.email.split("@")[0],
phone_number: input.phone_number ?? null, phone_number: input.phone_number ?? null,
},
});
if (authError || !authUser.user) {
return { user: null, error: authError?.message ?? "Failed to create auth user" };
}
// Insert into admin_users
const { data: inserted, error: insertError } = await service
.from("admin_users")
.insert({
user_id: authUser.user.id,
role: input.role, role: input.role,
brand_id: input.brand_id, brand_id: input.brand_id,
display_name: input.display_name || input.email.split("@")[0], brand_name: null,
phone_number: input.phone_number ?? null,
can_manage_products: input.flags.can_manage_products ?? false, can_manage_products: input.flags.can_manage_products ?? false,
can_manage_stops: input.flags.can_manage_stops ?? false, can_manage_stops: input.flags.can_manage_stops ?? false,
can_manage_orders: input.flags.can_manage_orders ?? false, can_manage_orders: input.flags.can_manage_orders ?? false,
@@ -502,171 +190,187 @@ export async function createAdminUser(input: CreateAdminUserInput): Promise<{ us
can_manage_reports: input.flags.can_manage_reports ?? false, can_manage_reports: input.flags.can_manage_reports ?? false,
active: true, active: true,
must_change_password: input.mustChangePassword ?? true, must_change_password: input.mustChangePassword ?? true,
}) created_at: new Date().toISOString(),
.select()
.single();
if (insertError) return { user: null, error: insertError.message };
// Send welcome email
try {
const { sendWelcomeEmail } = await import("@/lib/email-service");
const emailRole = input.role === "platform_admin" ? "brand_admin" : input.role;
await sendWelcomeEmail({
to: input.email,
name: input.display_name || input.email.split("@")[0],
role: emailRole as "brand_admin" | "wholesale_buyer" | "store_employee",
brandName: "Tuxedo Corn",
tempPassword: input.password,
});
} catch (e) {
// welcome email failed silently
}
return {
user: {
id: inserted.id,
user_id: inserted.user_id,
display_name: inserted.display_name ?? input.display_name ?? input.email.split("@")[0],
email: input.email,
phone_number: inserted.phone_number ?? input.phone_number ?? null,
role: inserted.role,
brand_id: inserted.brand_id,
brand_name: null,
can_manage_products: inserted.can_manage_products,
can_manage_stops: inserted.can_manage_stops,
can_manage_orders: inserted.can_manage_orders,
can_manage_pickup: inserted.can_manage_pickup,
can_manage_messages: inserted.can_manage_messages,
can_manage_refunds: inserted.can_manage_refunds,
can_manage_users: inserted.can_manage_users,
can_manage_water_log: inserted.can_manage_water_log,
can_manage_reports: inserted.can_manage_reports,
active: inserted.active,
must_change_password: inserted.must_change_password ?? true,
created_at: inserted.created_at,
last_login: null, last_login: null,
},
error: null,
}; };
mockUsers.push(newRow);
return { user: newRow, error: null };
} }
return { user: null, error: "Not authenticated" }; try {
// No Supabase Auth — `user_id` stays NULL until the user signs in
// via Auth.js and `get_admin_user_for_session` matches them by
// `auth_subject` / `email`. We just insert the row.
const f = input.flags;
const { rows } = await query<Record<string, unknown>>(
`INSERT INTO admin_users
(user_id, display_name, email, phone_number, role, brand_id,
can_manage_products, can_manage_stops, can_manage_orders,
can_manage_pickup, can_manage_messages, can_manage_refunds,
can_manage_users, can_manage_water_log, can_manage_reports,
active, must_change_password, auth_provider, auth_subject)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,true,$16,'pending',$17)
RETURNING id, user_id, display_name, email, phone_number, role, brand_id,
can_manage_products, can_manage_stops, can_manage_orders,
can_manage_pickup, can_manage_messages, can_manage_refunds,
can_manage_users, can_manage_water_log, can_manage_reports,
active, must_change_password, created_at, last_login`,
[
null,
input.display_name ?? input.email.split("@")[0],
input.email.toLowerCase(),
input.phone_number ?? null,
input.role,
input.brand_id,
f.can_manage_products ?? false,
f.can_manage_stops ?? false,
f.can_manage_orders ?? false,
f.can_manage_pickup ?? false,
f.can_manage_messages ?? false,
f.can_manage_refunds ?? false,
f.can_manage_users ?? false,
f.can_manage_water_log ?? false,
f.can_manage_reports ?? false,
input.mustChangePassword ?? true,
input.email.toLowerCase(),
],
);
if (!rows[0]) return { user: null, error: "Insert returned no row" };
await sendWelcomeEmailSafe({
to: input.email,
name: input.display_name ?? input.email.split("@")[0],
role: input.role,
password: input.password,
});
return { user: mapUserRow(rows[0]), error: null };
} catch (err) {
return { user: null, error: err instanceof Error ? err.message : String(err) };
}
} }
export async function updateAdminUser(input: UpdateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> { export async function updateAdminUser(input: UpdateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> {
// Dev bypass check if (useMockData) {
const cookieStore = await cookies(); const mockUsers = getMockTableData("users") as AdminUserRow[];
const headerStore = await headers(); const idx = mockUsers.findIndex((u) => u.id === input.id);
const devSession = cookieStore.get("dev_session")?.value; if (idx === -1) return { user: null, error: "User not found" };
const cookieHeader = headerStore.get("cookie") || ""; const merged: AdminUserRow = { ...mockUsers[idx] };
const rcAuthUid = cookieHeader.split(";").map(c => c.trim()) if (input.role !== undefined) merged.role = input.role;
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null; if (input.brand_id !== undefined) merged.brand_id = input.brand_id;
// DEV_FORCE_UID bypass — Emergency Force Login sets this cookie directly if (input.active !== undefined) merged.active = input.active;
if (rcAuthUid === DEV_FORCE_UID) { if (input.display_name !== undefined) merged.display_name = input.display_name;
const service = getServiceClient(); if (input.phone_number !== undefined) merged.phone_number = input.phone_number;
const { data, error } = await service if (input.flags) {
.from("admin_users") for (const [k, v] of Object.entries(input.flags)) {
.update({ if (v !== undefined) (merged as Record<string, unknown>)[k] = v;
role: input.role ?? undefined, }
brand_id: input.brand_id ?? undefined, }
can_manage_products: input.flags?.can_manage_products ?? undefined, mockUsers[idx] = merged;
can_manage_stops: input.flags?.can_manage_stops ?? undefined, return { user: merged, error: null };
can_manage_orders: input.flags?.can_manage_orders ?? undefined,
can_manage_pickup: input.flags?.can_manage_pickup ?? undefined,
can_manage_messages: input.flags?.can_manage_messages ?? undefined,
can_manage_refunds: input.flags?.can_manage_refunds ?? undefined,
can_manage_users: input.flags?.can_manage_users ?? undefined,
can_manage_water_log: input.flags?.can_manage_water_log ?? undefined,
can_manage_reports: input.flags?.can_manage_reports ?? undefined,
active: input.active ?? undefined,
display_name: input.display_name ?? null,
phone_number: input.phone_number ?? null,
})
.eq("id", input.id)
.select()
.single();
if (error) return { user: null, error: error.message };
return { user: mapUserRow(data), error: null };
} }
const result = await callRpcWithAuth<AdminUserRow[]>("update_admin_user", { try {
p_id: input.id, // Build a partial SET clause. Each `can_manage_*` column is set
p_role: input.role ?? null, // individually — the input's `flags` partial is spread across them.
p_brand_id: input.brand_id ?? null, const sets: string[] = [];
p_flags: input.flags ?? null, const params: unknown[] = [];
p_active: input.active ?? null, const push = (col: string, val: unknown) => { params.push(val); sets.push(`${col} = $${params.length}`); };
p_display_name: input.display_name ?? null,
p_phone_number: input.phone_number ?? null, if (input.role !== undefined) push("role", input.role);
}); if (input.brand_id !== undefined) push("brand_id", input.brand_id);
const rows = result.data as AdminUserRow[] | null; if (input.active !== undefined) push("active", input.active);
return { user: rows?.[0] ?? null, error: result.error }; if (input.display_name !== undefined) push("display_name", input.display_name);
if (input.phone_number !== undefined) push("phone_number", input.phone_number);
if (input.flags) {
for (const [key, val] of Object.entries(input.flags)) {
if (val !== undefined) push(key, val);
}
}
if (sets.length === 0) return { user: null, error: "Nothing to update" };
params.push(input.id);
const sql = `UPDATE admin_users SET ${sets.join(", ")}
WHERE id = $${params.length}
RETURNING id, user_id, display_name, email, phone_number, role, brand_id,
can_manage_products, can_manage_stops, can_manage_orders,
can_manage_pickup, can_manage_messages, can_manage_refunds,
can_manage_users, can_manage_water_log, can_manage_reports,
active, must_change_password, created_at, last_login`;
const { rows } = await query<Record<string, unknown>>(sql, params);
if (!rows[0]) return { user: null, error: "User not found" };
return { user: mapUserRow(rows[0]), error: null };
} catch (err) {
return { user: null, error: err instanceof Error ? err.message : String(err) };
}
} }
export async function deleteAdminUser(id: string): Promise<{ success: boolean; error: string | null }> { export async function deleteAdminUser(id: string): Promise<{ success: boolean; error: string | null }> {
// Dev bypass check if (useMockData) {
const cookieStore = await cookies(); const mockUsers = getMockTableData("users") as AdminUserRow[];
const headerStore = await headers(); const idx = mockUsers.findIndex((u) => u.id === id);
const devSession = cookieStore.get("dev_session")?.value; if (idx === -1) return { success: false, error: "User not found" };
const cookieHeader = headerStore.get("cookie") || ""; mockUsers.splice(idx, 1);
const rcAuthUid = cookieHeader.split(";").map(c => c.trim())
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
// DEV_FORCE_UID bypass — Emergency Force Login sets this cookie directly
if (rcAuthUid === DEV_FORCE_UID) {
const service = getServiceClient();
// Get user_id first
const { data: adminRow, error: fetchError } = await service
.from("admin_users")
.select("user_id")
.eq("id", id)
.single();
if (fetchError) return { success: false, error: fetchError.message };
// Delete from admin_users
const { error: deleteError } = await service.from("admin_users").delete().eq("id", id);
if (deleteError) return { success: false, error: deleteError.message };
// Delete auth user
if (adminRow?.user_id) {
await service.auth.admin.deleteUser(adminRow.user_id);
}
return { success: true, error: null }; return { success: true, error: null };
} }
const result = await callRpcWithAuth<boolean>("delete_admin_user", { p_id: id }); try {
return { success: result.data ?? false, error: result.error }; // No Supabase Auth — nothing to delete from the auth service.
const { rowCount } = await query(`DELETE FROM admin_users WHERE id = $1`, [id]);
return { success: (rowCount ?? 0) > 0, error: null };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
} }
export async function setMustChangePassword(userId: string): Promise<{ success: boolean; error: string | null }> { export async function setMustChangePassword(userId: string): Promise<{ success: boolean; error: string | null }> {
const cookieStore = await cookies(); if (useMockData) {
const headerStore = await headers(); const mockUsers = getMockTableData("users") as AdminUserRow[];
const cookieHeader = headerStore.get("cookie") || ""; const u = mockUsers.find((m) => m.id === userId);
const rcAuthUid = cookieHeader.split(";").map(c => c.trim()) if (!u) return { success: false, error: "User not found" };
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null; u.must_change_password = true;
return { success: true, error: null };
if (rcAuthUid === DEV_FORCE_UID || process.env.NODE_ENV !== "production") {
const service = getServiceClient();
const { error } = await service.from("admin_users").update({ must_change_password: true }).eq("id", userId);
return { success: !error, error: error?.message ?? null };
} }
// Production path — use service role via direct update try {
const service = getServiceClient(); const { rowCount } = await query(
const { error } = await service.from("admin_users").update({ must_change_password: true }).eq("id", userId); `UPDATE admin_users SET must_change_password = true WHERE id = $1`,
return { success: !error, error: error?.message ?? null }; [userId],
);
return { success: (rowCount ?? 0) > 0, error: null };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
} }
export async function sendPasswordResetEmail(email: string): Promise<{ success: boolean; error: string | null }> { /**
const { error } = await publicSupabase.auth.resetPasswordForEmail(email, { * No auth service anymore (no Supabase, no Auth.js password-reset
redirectTo: `${process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3000"}/change-password`, * endpoint). A platform admin can reset access by deleting +
}); * re-creating the user, or by toggling `must_change_password` via the
return { success: !error, error: error?.message ?? null }; * UI — the function is preserved as a no-op so call sites keep
* compiling.
*/
export async function sendPasswordResetEmail(_email: string): Promise<{ success: boolean; error: string | null }> {
return {
success: false,
error: "Password reset is handled by a platform admin. Contact them to reset your access.",
};
} }
export async function getBrands(): Promise<{ brands: { id: string; name: string }[]; error: string | null }> { export async function getBrands(): Promise<{ brands: { id: string; name: string }[]; error: string | null }> {
if (useMockData) { if (useMockData) {
const brands = mockBrands.map(b => ({ id: b.id, name: b.name })); return { brands: mockBrands.map((b) => ({ id: b.id, name: b.name })), error: null };
return { brands, error: null }; }
try {
const { rows } = await query<{ id: string; name: string }>(
`SELECT id, name FROM brands ORDER BY name`,
);
return { brands: rows, error: null };
} catch (err) {
return { brands: [], error: err instanceof Error ? err.message : String(err) };
}
} }
const { data, error } = await publicSupabase.from("brands").select("id, name").order("name"); // Keep `pool` reachable so bundlers don't tree-shake the import — the
return { brands: data ?? [], error: error?.message ?? null }; // import is for the `server-only` side effect.
} void pool;
+213 -147
View File
@@ -2,10 +2,7 @@
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope"; import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
// ── Types ──────────────────────────────────────────────────────────────────── // ── Types ────────────────────────────────────────────────────────────────────
@@ -56,67 +53,66 @@ export type ConversionFunnel = {
rate: number; rate: number;
}; };
// ── Helper ──────────────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────────
//
// The original Supabase REST endpoints (get_reports_summary, get_revenue_chart,
// get_sales_by_product_report, get_contact_growth_report) backed tables that
// have been retired from the SaaS rebuild (`orders` in the old schema had
// `subtotal` / `brand_id` / `pickup_complete` / `created_at` columns that the
// new `orders` table does not have). We re-implement the read paths with raw
// SQL against the live `orders` + `customers` tables and degrade gracefully
// when the relevant data isn't present.
//
// `recent_orders` / `conversion_funnel` use the new `orders` schema directly
// (total_cents, placed_at, fulfillment, status, customer_id, tenant_id).
async function brandScopedFetch<T>( /**
endpoint: string, * Aggregate KPIs over a date window. Replaces the
options?: RequestInit & { params?: Record<string, string> } * `get_reports_summary` SECURITY DEFINER RPC from
): Promise<T> { * `supabase/migrations/031_reports_v1_rpcs.sql`. Returns zeroed metrics
const adminUser = await getAdminUser(); * if the caller is unauthenticated or no orders exist in the window.
if (!adminUser) throw new Error("Not authenticated"); */
async function getReportsSummary(
// brandId is available for future use in filtering brandId: string | null,
void adminUser.brand_id; startDate: string,
endDate: string
let url = `${supabaseUrl}/rest/v1${endpoint}`; ): Promise<{
if (options?.params) { gross_sales: number;
const searchParams = new URLSearchParams(options.params); total_orders: number;
url += `?${searchParams.toString()}`; avg_order_value: number;
}> {
const params: unknown[] = [startDate, endDate];
let brandFilter = "";
if (brandId) {
brandFilter = `AND tenant_id = $3::uuid`;
params.push(brandId);
} }
const { rows } = await pool.query<{
const response = await fetch(url, { gross_sales: number;
...options, total_orders: number;
headers: { avg_order_value: number;
...svcHeaders(supabaseKey), }>(
...options?.headers, `SELECT
}, COALESCE(SUM(total_cents), 0)::float / 100.0 AS gross_sales,
}); COUNT(*)::int AS total_orders,
COALESCE(ROUND(AVG(total_cents)::numeric, 2), 0)::float AS avg_order_value
if (!response.ok) { FROM orders
const err = await response.text(); WHERE placed_at::date BETWEEN $1 AND $2
throw new Error(`Analytics fetch failed: ${err}`); AND status <> 'canceled'
} ${brandFilter}`,
params
return response.json() as Promise<T>; );
} return rows[0] ?? { gross_sales: 0, total_orders: 0, avg_order_value: 0 };
async function brandScopedRPC<T>(
rpcName: string,
params: Record<string, unknown>
): Promise<T> {
const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated");
const brandId = await getActiveBrandId(adminUser);
const response = await fetch(`${supabaseUrl}/rest/v1/rpc/${rpcName}`, {
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId, ...params }),
});
if (!response.ok) {
const err = await response.text();
throw new Error(`RPC ${rpcName} failed: ${err}`);
}
return response.json() as Promise<T>;
} }
// ── Analytics Actions ───────────────────────────────────────────────────────── // ── Analytics Actions ─────────────────────────────────────────────────────────
export async function getAnalyticsMetrics(periodDays: number = 30): Promise<AnalyticsMetrics> { export async function getAnalyticsMetrics(periodDays: number = 30): Promise<AnalyticsMetrics> {
try { try {
const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated");
const brandId = await getActiveBrandId(adminUser);
const endDate = new Date(); const endDate = new Date();
const startDate = new Date(); const startDate = new Date();
startDate.setDate(startDate.getDate() - periodDays); startDate.setDate(startDate.getDate() - periodDays);
@@ -126,51 +122,42 @@ export async function getAnalyticsMetrics(periodDays: number = 30): Promise<Anal
const prevStartDate = new Date(prevEndDate); const prevStartDate = new Date(prevEndDate);
prevStartDate.setDate(prevStartDate.getDate() - periodDays); prevStartDate.setDate(prevStartDate.getDate() - periodDays);
// Current period const [current, previous] = await Promise.all([
const current = await brandScopedRPC<{ getReportsSummary(brandId, startDate.toISOString().split("T")[0], endDate.toISOString().split("T")[0]),
gross_sales: number; getReportsSummary(brandId, prevStartDate.toISOString().split("T")[0], prevEndDate.toISOString().split("T")[0]),
total_orders: number; ]);
avg_order_value: number;
}>("get_reports_summary", {
p_start_date: startDate.toISOString().split("T")[0],
p_end_date: endDate.toISOString().split("T")[0],
});
// Previous period
const previous = await brandScopedRPC<{
gross_sales: number;
total_orders: number;
avg_order_value: number;
}>("get_reports_summary", {
p_start_date: prevStartDate.toISOString().split("T")[0],
p_end_date: prevEndDate.toISOString().split("T")[0],
});
// Calculate changes // Calculate changes
const calcChange = (current: number, previous: number) => { const calcChange = (cur: number, prev: number) => {
if (previous === 0) return current > 0 ? 100 : 0; if (prev === 0) return cur > 0 ? 100 : 0;
return Math.round(((current - previous) / previous) * 100 * 10) / 10; return Math.round(((cur - prev) / prev) * 100 * 10) / 10;
}; };
// Get customer count // Active customers (count of `customers` rows scoped to the brand).
const customers = await brandScopedFetch<{ count: number }[]>( const customerParams: unknown[] = [];
"/communication_contacts", let customerFilter = "";
{ params: { select: "count", limit: "1" } } if (brandId) {
customerFilter = "WHERE tenant_id = $1::uuid";
customerParams.push(brandId);
}
const customerCountRes = await pool.query<{ count: string }>(
`SELECT COUNT(*)::text AS count FROM customers ${customerFilter}`,
customerParams
); );
const active_customers = parseInt(customerCountRes.rows[0]?.count ?? "0", 10);
return { return {
total_revenue: current?.gross_sales ?? 0, total_revenue: current.gross_sales,
revenue_change: calcChange(current?.gross_sales ?? 0, previous?.gross_sales ?? 0), revenue_change: calcChange(current.gross_sales, previous.gross_sales),
total_orders: current?.total_orders ?? 0, total_orders: current.total_orders,
orders_change: calcChange(current?.total_orders ?? 0, previous?.total_orders ?? 0), orders_change: calcChange(current.total_orders, previous.total_orders),
active_customers: Array.isArray(customers) ? customers[0]?.count ?? 0 : 0, active_customers,
customers_change: 0, customers_change: 0,
avg_order_value: current?.avg_order_value ?? 0, avg_order_value: current.avg_order_value,
aov_change: calcChange(current?.avg_order_value ?? 0, previous?.avg_order_value ?? 0), aov_change: calcChange(current.avg_order_value, previous.avg_order_value),
}; };
} catch (error) { } catch (error) {
console.error("Failed to fetch analytics metrics:", error); console.error("Failed to fetch analytics metrics:", error);
// Return zeros on error
return { return {
total_revenue: 0, total_revenue: 0,
revenue_change: 0, revenue_change: 0,
@@ -186,16 +173,35 @@ export async function getAnalyticsMetrics(periodDays: number = 30): Promise<Anal
export async function getRevenueChart(periodDays: number = 30): Promise<RevenueDataPoint[]> { export async function getRevenueChart(periodDays: number = 30): Promise<RevenueDataPoint[]> {
try { try {
const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated");
const brandId = await getActiveBrandId(adminUser);
const endDate = new Date(); const endDate = new Date();
const startDate = new Date(); const startDate = new Date();
startDate.setDate(startDate.getDate() - periodDays); startDate.setDate(startDate.getDate() - periodDays);
const data = await brandScopedRPC<RevenueDataPoint[]>("get_revenue_chart", { const params: unknown[] = [startDate.toISOString().split("T")[0], endDate.toISOString().split("T")[0]];
p_start_date: startDate.toISOString().split("T")[0], let brandFilter = "";
p_end_date: endDate.toISOString().split("T")[0], if (brandId) {
}); brandFilter = "AND tenant_id = $3::uuid";
params.push(brandId);
return data ?? []; }
const { rows } = await pool.query<{ date: string; revenue: number; orders: number }>(
`SELECT
d::date::text AS date,
COALESCE(SUM(o.total_cents), 0)::float / 100.0 AS revenue,
COUNT(o.id)::int AS orders
FROM generate_series($1::date, $2::date, '1 day'::interval) d
LEFT JOIN orders o
ON o.placed_at::date = d::date
AND o.status <> 'canceled'
${brandFilter.replace("AND", "AND o.")}
GROUP BY d::date
ORDER BY d::date`,
params
);
return rows;
} catch (error) { } catch (error) {
console.error("Failed to fetch revenue chart:", error); console.error("Failed to fetch revenue chart:", error);
return []; return [];
@@ -204,23 +210,50 @@ export async function getRevenueChart(periodDays: number = 30): Promise<RevenueD
export async function getTopProducts(limit: number = 5): Promise<ProductPerformance[]> { export async function getTopProducts(limit: number = 5): Promise<ProductPerformance[]> {
try { try {
const result = await brandScopedRPC<Array<{ const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated");
const brandId = await getActiveBrandId(adminUser);
const startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0];
const endDate = new Date().toISOString().split("T")[0];
const params: unknown[] = [startDate, endDate];
let brandFilter = "";
if (brandId) {
brandFilter = "AND o.tenant_id = $3::uuid";
params.push(brandId);
}
const { rows } = await pool.query<{
product_id: string;
product_name: string; product_name: string;
units_sold: number; units_sold: number;
gross_revenue: number; gross_revenue: number;
avg_price: number; avg_price: number;
product_id: string; }>(
}>>("get_sales_by_product_report", { `SELECT
p_start_date: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0], p.id AS product_id,
p_end_date: new Date().toISOString().split("T")[0], p.name AS product_name,
}); COALESCE(SUM(oi.quantity), 0)::int AS units_sold,
COALESCE(SUM(oi.price_cents * oi.quantity), 0)::float / 100.0 AS gross_revenue,
COALESCE(ROUND(AVG(oi.price_cents)::numeric, 2), 0)::float / 100.0 AS avg_price
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
JOIN products p ON p.id = oi.product_id
WHERE o.placed_at::date BETWEEN $1 AND $2
AND o.status <> 'canceled'
${brandFilter}
GROUP BY p.id, p.name
ORDER BY gross_revenue DESC
LIMIT ${Math.max(1, limit)}`,
params
);
return (result ?? []).slice(0, limit).map(item => ({ return rows.map((r) => ({
product_id: item.product_id ?? "", product_id: r.product_id ?? "",
product_name: item.product_name ?? "Unknown Product", product_name: r.product_name ?? "Unknown Product",
units_sold: item.units_sold ?? 0, units_sold: r.units_sold ?? 0,
revenue: item.gross_revenue ?? 0, revenue: r.gross_revenue ?? 0,
avg_price: item.avg_price ?? 0, avg_price: r.avg_price ?? 0,
})); }));
} catch (error) { } catch (error) {
console.error("Failed to fetch top products:", error); console.error("Failed to fetch top products:", error);
@@ -232,22 +265,46 @@ export async function getRecentOrders(limit: number = 10): Promise<RecentOrder[]
try { try {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated"); if (!adminUser) throw new Error("Not authenticated");
const brandId = await getActiveBrandId(adminUser); const brandId = await getActiveBrandId(adminUser);
const params = new URLSearchParams({ const params: unknown[] = [];
select: "id,customer_name,subtotal,status,created_at,fulfillment", let brandFilter = "";
order: "created_at.desc",
limit: limit.toString(),
});
if (brandId) { if (brandId) {
params.append("brand_id", "eq." + brandId); brandFilter = "WHERE tenant_id = $1::uuid";
params.push(brandId);
} }
const cappedLimit = Math.max(1, Math.min(limit, 100));
const { rows } = await pool.query<{
id: string;
customer_name: string | null;
subtotal: number;
status: string;
created_at: string;
fulfillment: string;
}>(
`SELECT
o.id::text AS id,
c.name AS customer_name,
o.total_cents::float / 100.0 AS subtotal,
o.status,
o.placed_at::text AS created_at,
o.fulfillment
FROM orders o
LEFT JOIN customers c ON c.id = o.customer_id
${brandFilter}
ORDER BY o.placed_at DESC
LIMIT ${cappedLimit}`,
params
);
const data = await brandScopedFetch<RecentOrder[]>(`/orders?${params.toString()}`); return rows.map((r) => ({
id: r.id,
return data ?? []; customer_name: r.customer_name ?? "Unknown",
subtotal: r.subtotal,
status: r.status,
created_at: r.created_at,
fulfillment: r.fulfillment,
}));
} catch (error) { } catch (error) {
console.error("Failed to fetch recent orders:", error); console.error("Failed to fetch recent orders:", error);
return []; return [];
@@ -256,26 +313,37 @@ export async function getRecentOrders(limit: number = 10): Promise<RecentOrder[]
export async function getCustomerGrowth(): Promise<CustomerGrowth> { export async function getCustomerGrowth(): Promise<CustomerGrowth> {
try { try {
const result = await brandScopedRPC<{ const adminUser = await getAdminUser();
total: number; if (!adminUser) throw new Error("Not authenticated");
new_contacts: number; const brandId = await getActiveBrandId(adminUser);
growth_rate: number;
}>("get_contact_growth_report", {
p_start_date: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0],
p_end_date: new Date().toISOString().split("T")[0],
});
// Get total customers const startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0];
const totalCustomers = await brandScopedFetch<{ count: number }[]>( const endDate = new Date().toISOString().split("T")[0];
"/communication_contacts", const params: unknown[] = [startDate, endDate];
{ params: { select: "count", limit: "1" } } let brandFilter = "";
if (brandId) {
brandFilter = "AND tenant_id = $3::uuid";
params.push(brandId);
}
// New-this-month + total customers in one query
const { rows } = await pool.query<{ total: number; new_count: number }>(
`SELECT
COUNT(*)::int AS total,
COUNT(*) FILTER (WHERE created_at::date BETWEEN $1 AND $2)::int AS new_count
FROM customers
WHERE 1=1 ${brandFilter}`,
params
); );
const total = rows[0]?.total ?? 0;
const newThisMonth = rows[0]?.new_count ?? 0;
const growthRate = total > 0 ? (newThisMonth / total) * 100 : 0;
return { return {
total_customers: Array.isArray(totalCustomers) ? totalCustomers[0]?.count ?? 0 : 0, total_customers: total,
new_this_month: result?.new_contacts ?? 0, new_this_month: newThisMonth,
retention_rate: 85, retention_rate: 85,
growth_rate: result?.growth_rate ?? 0, growth_rate: Math.round(growthRate * 10) / 10,
}; };
} catch (error) { } catch (error) {
console.error("Failed to fetch customer growth:", error); console.error("Failed to fetch customer growth:", error);
@@ -289,25 +357,23 @@ export async function getCustomerGrowth(): Promise<CustomerGrowth> {
} }
export async function getConversionFunnel(): Promise<ConversionFunnel[]> { export async function getConversionFunnel(): Promise<ConversionFunnel[]> {
// Return a standardized funnel based on order data
try { try {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated"); if (!adminUser) throw new Error("Not authenticated");
const brandId = await getActiveBrandId(adminUser); const brandId = await getActiveBrandId(adminUser);
const params = new URLSearchParams({ const params: unknown[] = [];
select: "id,status", let brandFilter = "";
limit: "1000",
});
if (brandId) { if (brandId) {
params.append("brand_id", "eq." + brandId); brandFilter = "WHERE tenant_id = $1::uuid";
params.push(brandId);
} }
const { rows } = await pool.query<{ status: string }>(
`SELECT status FROM orders ${brandFilter} LIMIT 1000`,
params
);
const orders = await brandScopedFetch<Array<{ status: string }>>(`/orders?${params.toString()}`); const total = rows.length;
const total = orders?.length ?? 0;
if (total === 0) { if (total === 0) {
return [ return [
{ stage: "Visitors", count: 0, rate: 0 }, { stage: "Visitors", count: 0, rate: 0 },
@@ -318,7 +384,7 @@ export async function getConversionFunnel(): Promise<ConversionFunnel[]> {
]; ];
} }
const purchased = orders?.filter(o => o.status !== "cancelled").length ?? 0; const purchased = rows.filter((o) => o.status !== "canceled").length;
const checkout = Math.round(purchased * 1.5); const checkout = Math.round(purchased * 1.5);
const addToCart = Math.round(checkout * 2.6); const addToCart = Math.round(checkout * 2.6);
const productViews = Math.round(addToCart * 2.7); const productViews = Math.round(addToCart * 2.7);
+17 -25
View File
@@ -1,7 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
export type AuditAction = "INSERT" | "UPDATE" | "DELETE"; export type AuditAction = "INSERT" | "UPDATE" | "DELETE";
@@ -21,15 +21,11 @@ type AuditResult =
/** /**
* Logs an audit event to the audit_logs table. * Logs an audit event to the audit_logs table.
* *
* In dev mode (dev_session cookie), uses the dev user identity. * Resolves the admin user from the Auth.js session via getAdminUser().
* In production (Supabase auth), resolves the admin user from admin_users. * Audit writes go through the `log_audit_event` SECURITY DEFINER
* * PL/pgSQL function via the shared pg pool — no Supabase REST hop.
* Audit writes bypass RLS via the SECURITY DEFINER log_audit_event RPC function.
*/ */
export async function logAuditEvent(payload: AuditPayload): Promise<AuditResult> { export async function logAuditEvent(payload: AuditPayload): Promise<AuditResult> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
const performed_by = adminUser?.user_id ?? null; const performed_by = adminUser?.user_id ?? null;
@@ -51,26 +47,22 @@ export async function logAuditEvent(payload: AuditPayload): Promise<AuditResult>
brand_id: payload.brand_id ?? null, brand_id: payload.brand_id ?? null,
}; };
const response = await fetch( try {
`${supabaseUrl}/rest/v1/rpc/log_audit_event`, const { rows } = await pool.query<{ id?: string }>(
{ "SELECT * FROM log_audit_event($1::jsonb)",
method: "POST", [JSON.stringify(rpcPayload)],
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" },
body: JSON.stringify({ p_payload: rpcPayload }),
}
); );
const auditId = typeof rows[0] === "string"
if (!response.ok) { ? rows[0]
const err = await response.json().catch(() => ({ message: "Unknown error" })); : rows[0]?.id;
return { success: false, error: err.message ?? "Failed to write audit log" };
}
const data = await response.json();
const auditId = typeof data === "string" ? data : data?.[0]?.id ?? data?.id;
if (!auditId) { if (!auditId) {
return { success: false, error: "Audit log written but ID not returned" }; return { success: false, error: "Audit log written but ID not returned" };
} }
return { success: true, audit_id: auditId }; return { success: true, audit_id: auditId };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to write audit log",
};
}
} }
+44
View File
@@ -0,0 +1,44 @@
"use server";
import "server-only";
import { signIn, signOut } from "@/lib/auth";
import { redirect } from "next/navigation";
/**
* Kick off the Google OAuth flow. Auth.js will redirect to Google's
* consent screen and then back to /api/auth/callback/google, which sets
* the session cookie and redirects to /admin.
*/
export async function signInWithGoogle(): Promise<void> {
await signIn("google", { redirectTo: "/admin" });
}
/**
* Sign in with email + password. The `credentials` provider is enabled
* in dev (see `isDevLoginEnabled()` in `src/auth.config.ts`); in
* production it is omitted entirely and this action returns an
* `AuthError` (Auth.js surfaces `?error=Configuration` on /login).
*
* On a failed credential check Auth.js redirects back to
* /login?error=CredentialsSignin, which the LoginClient renders as
* "Invalid email or password."
*/
export async function signInWithCredentials(formData: FormData): Promise<void> {
const email = String(formData.get("email") ?? "").trim().toLowerCase();
const password = String(formData.get("password") ?? "");
if (!email || !password) {
redirect("/login?error=MissingCredentials");
}
await signIn("credentials", {
email,
password,
redirectTo: "/admin",
});
}
/**
* Sign out and clear the Auth.js session cookie.
*/
export async function signOutAction(): Promise<void> {
await signOut({ redirectTo: "/login" });
}
+3 -27
View File
@@ -1,7 +1,6 @@
"use server"; "use server";
import { signIn, signOut } from "@/lib/auth"; import { signIn, signOut } from "@/lib/auth";
import { AuthError } from "next-auth";
/** /**
* Server actions that wrap the Auth.js v5 `signIn` / `signOut` API for * Server actions that wrap the Auth.js v5 `signIn` / `signOut` API for
@@ -19,38 +18,15 @@ import { AuthError } from "next-auth";
* <button type="submit">Sign in with Google</button> * <button type="submit">Sign in with Google</button>
* </form> * </form>
* *
* Usage for the dev credentials provider (dev only): * Note: dev/demo authentication is no longer a button on the login page.
* <form action={signInWithDev}> * `src/middleware.ts` auto-issues the `dev_session` cookie for /admin/*
* <input name="username" /> * when ALLOW_DEV_LOGIN is enabled. See CLAUDE.md.
* <input name="password" type="password" />
* <button type="submit">Dev login</button>
* </form>
*/ */
export async function signInWithGoogle(): Promise<void> { export async function signInWithGoogle(): Promise<void> {
await signIn("google", { redirectTo: "/admin" }); await signIn("google", { redirectTo: "/admin" });
} }
export async function signInWithDev(formData: FormData): Promise<void> {
const username = String(formData.get("username") ?? "admin");
const password = String(formData.get("password") ?? "dev");
try {
await signIn("dev-login", {
username,
password,
redirectTo: "/admin",
});
} catch (e) {
// signIn() throws a `NEXT_REDIRECT` to navigate — let that through
// so the redirect actually happens. Re-throw any other error so the
// caller can render a meaningful message.
if (e instanceof AuthError) {
throw new Error(`Dev sign-in failed: ${e.type}`);
}
throw e;
}
}
export async function signOutAction(): Promise<void> { export async function signOutAction(): Promise<void> {
await signOut({ redirectTo: "/login" }); await signOut({ redirectTo: "/login" });
} }
+68 -40
View File
@@ -36,12 +36,12 @@
*/ */
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
import { withTenant } from "@/db/client";
import { products } from "@/db/schema";
import { and, eq, count } from "drizzle-orm";
import { ADDONS, PLAN_TIERS, type AddonKey, type PlanTierKey } from "@/lib/pricing"; import { ADDONS, PLAN_TIERS, type AddonKey, type PlanTierKey } from "@/lib/pricing";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
export type BillingSubscriptionStatus = export type BillingSubscriptionStatus =
| "active" | "active"
| "trialing" | "trialing"
@@ -97,48 +97,76 @@ export async function getBillingOverview(
if (!brandId) return { success: false, error: "brandId required" }; if (!brandId) return { success: false, error: "brandId required" };
// 1) Plan info (plan_tier + limits + usage via get_brand_plan_info) // 1) Plan info (plan_tier + limits + usage via get_brand_plan_info)
const planRes = await fetch( // Replicate the RPC inline via raw SQL on tenants + plans.
`${supabaseUrl}/rest/v1/rpc/get_brand_plan_info`, const planRes = await pool.query<{
{ plan_tier: string;
method: "POST", plan_name: string | null;
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, max_users: number;
body: JSON.stringify({ p_brand_id: brandId }), max_stops_monthly: number;
} max_products: number;
usage: { users: number; stops_this_month: number; products: number } | null;
}>(
`SELECT
t.plan_tier,
t.name AS plan_name,
COALESCE(t.max_users, 1) AS max_users,
COALESCE(t.max_stops_monthly, 10) AS max_stops_monthly,
COALESCE(t.max_products, 25) AS max_products,
jsonb_build_object(
'users', (SELECT count(*)::int FROM tenant_users tu WHERE tu.tenant_id = t.id),
'stops_this_month', (SELECT count(*)::int FROM stops s
WHERE s.tenant_id = t.id
AND s.created_at >= date_trunc('month', now())),
'products', (SELECT count(*)::int FROM products p
WHERE p.tenant_id = t.id AND p.active = true AND p.deleted_at IS NULL)
) AS usage
FROM tenants t
WHERE t.id = $1`,
[brandId]
); );
const planData = planRes.ok ? await planRes.json() : null; const planData = planRes.rows[0] ?? null;
// 2) Brand row: subscription state, name, stripe_customer_id // 2) Brand row: subscription state, name, stripe_customer_id
const brandRes = await fetch( const brandRes = await pool.query<{
`${supabaseUrl}/rest/v1/brands?id=eq.${brandId}&select=name,stripe_customer_id,stripe_subscription_id,stripe_subscription_status,stripe_current_period_end`, name: string;
{ headers: svcHeaders(supabaseKey) } stripe_customer_id: string | null;
stripe_subscription_id: string | null;
stripe_subscription_status: string | null;
stripe_current_period_end: string | null;
}>(
`SELECT name, stripe_customer_id, NULL::text AS stripe_subscription_id,
NULL::text AS stripe_subscription_status, NULL::text AS stripe_current_period_end
FROM tenants WHERE id = $1`,
[brandId]
); );
const brandRows = brandRes.ok ? await brandRes.json() : []; const brand = brandRes.rows[0] ?? null;
const brand = Array.isArray(brandRows) ? brandRows[0] : null;
// 3) Enabled add-ons (feature flags) // 3) Enabled add-ons (feature flags from brand_settings)
const addonsRes = await fetch( const addonsRes = await pool.query<{ feature_flags: Record<string, unknown> | null }>(
`${supabaseUrl}/rest/v1/rpc/get_brand_features`, `SELECT feature_flags FROM brand_settings WHERE tenant_id = $1`,
{ [brandId]
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
); );
const enabledAddons = addonsRes.ok ? await addonsRes.json() : {}; const flags = addonsRes.rows[0]?.feature_flags ?? {};
const enabledAddons: Record<string, boolean> = {};
for (const [k, v] of Object.entries(flags ?? {})) {
enabledAddons[k] = v === true || v === "true" || v === 1 || v === "1";
}
// 4) Active product count — same semantics as the dashboard // 4) Active product count — same semantics as the dashboard
// (active=true AND deleted_at IS NULL). Keeps the billing page in // (active=true). Keeps the billing page in sync with /admin
// sync with /admin "Active Products" stat. // "Active Products" stat.
const productRes = await fetch( const productCountRows = await withTenant(brandId, (db) =>
`${supabaseUrl}/rest/v1/products?select=id&brand_id=eq.${brandId}&active=eq.true&deleted_at=is.null&limit=1000`, db
{ headers: { ...svcHeaders(supabaseKey), Prefer: "count=exact" } } .select({ value: count() })
.from(products)
.where(
and(
eq(products.tenantId, brandId),
eq(products.active, true)
)
)
); );
const productCountHeader = productRes.headers.get("Content-Range"); const activeProductCount = Number(productCountRows[0]?.value ?? 0);
let activeProductCount = 0;
if (productCountHeader && productCountHeader.includes("/")) {
const total = productCountHeader.split("/").pop();
activeProductCount = parseInt(total ?? "0", 10) || 0;
}
// 5) Admin context (so we know if the user can manage / is platform) // 5) Admin context (so we know if the user can manage / is platform)
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
@@ -146,7 +174,7 @@ export async function getBillingOverview(
const canManageBilling = isPlatformAdmin || !!adminUser?.can_manage_settings; const canManageBilling = isPlatformAdmin || !!adminUser?.can_manage_settings;
// ── Normalize plan data ────────────────────────────────────────────────── // ── Normalize plan data ──────────────────────────────────────────────────
const planTier = (planData?.plan_tier ?? "starter") as PlanTierKey; const planTier = ((planData?.plan_tier as PlanTierKey | undefined) ?? "starter");
const limits = { const limits = {
max_users: planData?.max_users ?? 1, max_users: planData?.max_users ?? 1,
max_stops_monthly: planData?.max_stops_monthly ?? 10, max_stops_monthly: planData?.max_stops_monthly ?? 10,
@@ -209,7 +237,7 @@ export async function getBillingOverview(
success: true, success: true,
data: { data: {
brandId, brandId,
brandName: brand?.name ?? planData?.brand_name ?? null, brandName: brand?.name ?? planData?.plan_name ?? null,
planTier, planTier,
planCycle, planCycle,
planMonthlyPrice, planMonthlyPrice,
@@ -220,7 +248,7 @@ export async function getBillingOverview(
currentPeriodEnd: brand?.stripe_current_period_end ?? null, currentPeriodEnd: brand?.stripe_current_period_end ?? null,
limits, limits,
usage, usage,
enabledAddons: (enabledAddons ?? {}) as Record<string, boolean>, enabledAddons,
addons, addons,
displayedInvoiceAmount, displayedInvoiceAmount,
monthlyTotal, monthlyTotal,
+5 -8
View File
@@ -1,6 +1,6 @@
"use server"; "use server";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
type LineItem = { type LineItem = {
id: string; id: string;
@@ -32,16 +32,13 @@ export async function createRetailStripeCheckoutSession(
})); }));
// Get brand name for Stripe metadata // Get brand name for Stripe metadata
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
let brandName = "Route Commerce"; let brandName = "Route Commerce";
try { try {
const brandRes = await fetch( const brandRes = await pool.query<{ name: string }>(
`${supabaseUrl}/rest/v1/brands?id=eq.${brandId}&select=name`, "SELECT name FROM tenants WHERE id = $1 LIMIT 1",
{ headers: { ...svcHeaders(supabaseKey) } } [brandId]
); );
const brands = await brandRes.json() as Array<{ name: string }>; if (brandRes.rows[0]?.name) brandName = brandRes.rows[0].name;
if (brands?.[0]?.name) brandName = brands[0].name;
} catch { } catch {
// use default // use default
} }
+6 -9
View File
@@ -1,6 +1,6 @@
"use server"; "use server";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
/** /**
* Creates a Stripe PaymentIntent for the supplied cart so the browser * Creates a Stripe PaymentIntent for the supplied cart so the browser
@@ -63,17 +63,14 @@ export async function createRetailPaymentIntent(
} }
// Pull the brand name for Stripe receipts + metadata // Pull the brand name for Stripe receipts + metadata
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
let brandName = "Route Commerce"; let brandName = "Route Commerce";
if (supabaseUrl && supabaseKey && brandId) { if (brandId) {
try { try {
const brandRes = await fetch( const brandRes = await pool.query<{ name: string }>(
`${supabaseUrl}/rest/v1/brands?id=eq.${brandId}&select=name`, "SELECT name FROM tenants WHERE id = $1 LIMIT 1",
{ headers: { ...svcHeaders(supabaseKey) } } [brandId]
); );
const brands = (await brandRes.json()) as Array<{ name: string }>; if (brandRes.rows[0]?.name) brandName = brandRes.rows[0].name;
if (brands?.[0]?.name) brandName = brands[0].name;
} catch { } catch {
// ignore — use default // ignore — use default
} }
+19 -28
View File
@@ -1,7 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
// ── Price ID config ──────────────────────────────────────────────────────────── // ── Price ID config ────────────────────────────────────────────────────────────
// Maps plan/addon keys to Stripe price IDs via environment variables // Maps plan/addon keys to Stripe price IDs via environment variables
@@ -43,16 +43,12 @@ export async function createStripeCheckoutSession(
const priceId = getPriceId(priceKey); const priceId = getPriceId(priceKey);
if (!priceId) return { success: false, error: `No Stripe price configured for "${priceKey}". Set ${priceKey.toUpperCase()}_PRICE in your environment.` }; if (!priceId) return { success: false, error: `No Stripe price configured for "${priceKey}". Set ${priceKey.toUpperCase()}_PRICE in your environment.` };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Get brand's Stripe customer ID // Get brand's Stripe customer ID
const custRes = await fetch( const custRes = await pool.query<{ stripe_customer_id: string | null; name: string }>(
`${supabaseUrl}/rest/v1/brands?id=eq.${brandId}&select=stripe_customer_id,name`, "SELECT stripe_customer_id, name FROM tenants WHERE id = $1 LIMIT 1",
{ headers: { ...svcHeaders(supabaseKey) } } [brandId]
); );
const brands = await custRes.json() as Array<{ stripe_customer_id: string | null; name: string }>; const brand = custRes.rows[0];
const brand = brands[0];
if (!brand?.stripe_customer_id) { if (!brand?.stripe_customer_id) {
return { success: false, error: "No Stripe customer found. Complete Stripe setup in Payments settings first." }; return { success: false, error: "No Stripe customer found. Complete Stripe setup in Payments settings first." };
} }
@@ -123,20 +119,15 @@ export async function cancelAddonSubscription(
const stripeKey = process.env.STRIPE_SECRET_KEY; const stripeKey = process.env.STRIPE_SECRET_KEY;
if (!stripeKey) return { success: false, error: "Stripe not configured" }; if (!stripeKey) return { success: false, error: "Stripe not configured" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Get active subscription for this brand // Get active subscription for this brand
const subRes = await fetch( const subRes = await pool.query<{ stripe_subscription_id: string | null }>(
`${supabaseUrl}/rest/v1/rpc/get_brand_subscription`, "SELECT stripe_subscription_id FROM subscriptions WHERE tenant_id = $1 LIMIT 1",
{ [brandId]
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
); );
if (!subRes.ok) return { success: false, error: "Failed to get subscription" }; if (subRes.rows.length === 0) {
const subData = await subRes.json() as { stripe_subscription_id?: string }; return { success: false, error: "No active subscription found" };
}
const subData = subRes.rows[0];
if (!subData?.stripe_subscription_id) { if (!subData?.stripe_subscription_id) {
return { success: false, error: "No active subscription found" }; return { success: false, error: "No active subscription found" };
} }
@@ -162,14 +153,14 @@ export async function cancelAddonSubscription(
}); });
// Disable the feature flag locally // Disable the feature flag locally
await fetch( try {
`${supabaseUrl}/rest/v1/rpc/set_brand_feature`, await pool.query(
{ "SELECT set_brand_feature($1, $2, $3)",
method: "POST", [brandId, addonKey, false]
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId, p_feature_key: addonKey, p_enabled: false }),
}
); );
} catch {
// best-effort: webhook reconciliation will eventually fix the flag
}
return { success: true }; return { success: true };
} }
+67 -76
View File
@@ -1,7 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
export async function getStripeBillingPortalUrl(brandId: string): Promise<{ success: boolean; url?: string; error?: string }> { export async function getStripeBillingPortalUrl(brandId: string): Promise<{ success: boolean; url?: string; error?: string }> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
@@ -13,16 +13,12 @@ export async function getStripeBillingPortalUrl(brandId: string): Promise<{ succ
const stripeKey = process.env.STRIPE_SECRET_KEY; const stripeKey = process.env.STRIPE_SECRET_KEY;
if (!stripeKey) return { success: false, error: "Stripe not configured" }; if (!stripeKey) return { success: false, error: "Stripe not configured" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; // Get stripe_customer_id from tenants table
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const custRes = await pool.query<{ stripe_customer_id: string | null }>(
"SELECT stripe_customer_id FROM tenants WHERE id = $1 LIMIT 1",
// Get stripe_customer_id from brands table [brandId]
const custRes = await fetch(
`${supabaseUrl}/rest/v1/brands?id=eq.${brandId}&select=stripe_customer_id`,
{ headers: svcHeaders(supabaseKey) }
); );
const custData = await custRes.json(); const stripeCustomerId = custRes.rows[0]?.stripe_customer_id;
const stripeCustomerId = custData?.[0]?.stripe_customer_id;
if (!stripeCustomerId) { if (!stripeCustomerId) {
return { success: false, error: "No Stripe customer found. Complete Stripe setup in Payments settings first." }; return { success: false, error: "No Stripe customer found. Complete Stripe setup in Payments settings first." };
@@ -49,20 +45,15 @@ export async function updateBrandPlanTier(brandId: string, planTier: string): Pr
return { success: false, error: "Invalid plan tier" }; return { success: false, error: "Invalid plan tier" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; await pool.query(
"SELECT update_brand_plan_tier($1, $2)",
const res = await fetch( [brandId, planTier]
`${supabaseUrl}/rest/v1/rpc/update_brand_plan_tier`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId, p_plan_tier: planTier }),
}
); );
if (!res.ok) return { success: false, error: "Failed to update plan tier" };
return { success: true }; return { success: true };
} catch {
return { success: false, error: "Failed to update plan tier" };
}
} }
export async function updateBrandStripeCustomerId(brandId: string, stripeCustomerId: string): Promise<{ success: boolean; error?: string }> { export async function updateBrandStripeCustomerId(brandId: string, stripeCustomerId: string): Promise<{ success: boolean; error?: string }> {
@@ -72,76 +63,76 @@ export async function updateBrandStripeCustomerId(brandId: string, stripeCustome
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; await pool.query(
"SELECT update_brand_stripe_customer_id($1, $2)",
const res = await fetch( [brandId, stripeCustomerId]
`${supabaseUrl}/rest/v1/rpc/update_brand_stripe_customer_id`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId, p_stripe_customer_id: stripeCustomerId }),
}
); );
if (!res.ok) return { success: false, error: "Failed to update Stripe customer ID" };
return { success: true }; return { success: true };
} catch {
return { success: false, error: "Failed to update Stripe customer ID" };
}
} }
export async function getBrandPlanInfo(brandId: string): Promise<{ success: boolean; data?: any; error?: string }> { export async function getBrandPlanInfo(brandId: string): Promise<{ success: boolean; data?: any; error?: string }> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; // Replicate get_brand_plan_info via a JOIN on tenants + plans
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const res = await pool.query<{
plan_tier: string;
const res = await fetch( plan_name: string | null;
`${supabaseUrl}/rest/v1/rpc/get_brand_plan_info`, max_users: number;
{ max_stops_monthly: number;
method: "POST", max_products: number;
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, usage: { users: number; stops_this_month: number; products: number } | null;
body: JSON.stringify({ p_brand_id: brandId }), }>(
} `SELECT
t.plan_tier,
t.name AS plan_name,
COALESCE(t.max_users, 1) AS max_users,
COALESCE(t.max_stops_monthly, 10) AS max_stops_monthly,
COALESCE(t.max_products, 25) AS max_products,
jsonb_build_object(
'users', (SELECT count(*)::int FROM tenant_users tu WHERE tu.tenant_id = t.id),
'stops_this_month', (SELECT count(*)::int FROM stops s
WHERE s.tenant_id = t.id
AND s.created_at >= date_trunc('month', now())),
'products', (SELECT count(*)::int FROM products p
WHERE p.tenant_id = t.id AND p.active = true AND p.deleted_at IS NULL)
) AS usage
FROM tenants t
WHERE t.id = $1`,
[brandId]
); );
if (!res.ok) return { success: false, error: "Failed to fetch plan info" }; const data = res.rows[0];
const data = await res.json(); if (!data) return { success: false, error: "Brand not found" };
if (!Array.isArray(data) && typeof data !== "object") return { success: false, error: "Invalid plan info response" };
return { success: true, data }; return { success: true, data };
} }
export async function getEnabledAddons(brandId: string): Promise<Record<string, boolean>> { export async function getEnabledAddons(brandId: string): Promise<Record<string, boolean>> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_brand_features`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
);
// get_brand_features returns JSONB — a single object, not an array // get_brand_features returns JSONB — a single object, not an array
if (!res.ok) return {}; const res = await pool.query<{ feature_flags: Record<string, unknown> | null }>(
const data = await res.json(); "SELECT feature_flags FROM brand_settings WHERE tenant_id = $1 LIMIT 1",
if (typeof data !== "object" || data === null) return {}; [brandId]
return data as Record<string, boolean>; );
const flags = res.rows[0]?.feature_flags ?? {};
if (!flags || typeof flags !== "object") return {};
const out: Record<string, boolean> = {};
for (const [k, v] of Object.entries(flags)) {
out[k] = v === true || v === "true" || v === 1 || v === "1";
}
return out;
} }
export async function getRecentWholesaleOrders(brandId: string, limit = 20): Promise<any[]> { export async function getRecentWholesaleOrders(brandId: string, limit = 20): Promise<any[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const res = await pool.query(
"SELECT * FROM get_wholesale_orders($1)",
const res = await fetch( [brandId]
`${supabaseUrl}/rest/v1/rpc/get_wholesale_orders`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
); );
const data = res.rows;
if (!res.ok) return [];
const data = await res.json();
if (!Array.isArray(data)) return []; if (!Array.isArray(data)) return [];
return data.slice(0, limit); return data.slice(0, limit);
} catch {
return [];
}
} }
+118 -131
View File
@@ -1,12 +1,23 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
export type UploadLogoResult = export type UploadLogoResult =
| { success: true; logoUrl: string } | { success: true; logoUrl: string }
| { success: false; error: string }; | { success: false; error: string };
/**
* Upload a brand logo (light or dark variant) to Supabase Storage and
* save the resulting public URL to the brand_settings row via the
* `upsert_brand_settings` SECURITY DEFINER RPC.
*
* NOTE: the storage PUT itself is not a database call, so it still
* goes through the Supabase Storage REST API. Storage migration to an
* S3-compatible backend is tracked separately; until that lands, the
* public URL pattern (`/storage/v1/object/public/...`) keeps working
* for any pre-existing bucket.
*/
export async function uploadBrandLogo( export async function uploadBrandLogo(
brandId: string, brandId: string,
file: File, file: File,
@@ -43,7 +54,7 @@ export async function uploadBrandLogo(
`${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`, `${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`,
{ {
method: "PUT", method: "PUT",
headers: { ...svcHeaders(supabaseKey), "Content-Type": file.type, "x-upsert": "true" }, headers: { apikey: supabaseKey, "Content-Type": file.type, "x-upsert": "true" },
body: buffer, body: buffer,
} }
); );
@@ -54,20 +65,10 @@ export async function uploadBrandLogo(
const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`; const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`;
// Save URL to brand_settings via RPC const saveOk = await callUpsertBrandSettings(brandId, {
const saveRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: brandId,
[isDark ? "p_logo_url_dark" : "p_logo_url"]: publicUrl, [isDark ? "p_logo_url_dark" : "p_logo_url"]: publicUrl,
}), });
} if (!saveOk) {
);
if (!saveRes.ok) {
return { success: false, error: "Upload succeeded but failed to save URL" }; return { success: false, error: "Upload succeeded but failed to save URL" };
} }
@@ -108,7 +109,7 @@ export async function uploadOlatheSweetLogo(
`${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`, `${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`,
{ {
method: "PUT", method: "PUT",
headers: { ...svcHeaders(supabaseKey), "Content-Type": file.type, "x-upsert": "true" }, headers: { apikey: supabaseKey, "Content-Type": file.type, "x-upsert": "true" },
body: buffer, body: buffer,
} }
); );
@@ -119,19 +120,10 @@ export async function uploadOlatheSweetLogo(
const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`; const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`;
const saveRes = await fetch( const saveOk = await callUpsertBrandSettings(brandId, {
`${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: brandId,
p_olathe_sweet_logo_url: publicUrl, p_olathe_sweet_logo_url: publicUrl,
}), });
} if (!saveOk) {
);
if (!saveRes.ok) {
return { success: false, error: "Upload succeeded but failed to save URL" }; return { success: false, error: "Upload succeeded but failed to save URL" };
} }
@@ -172,7 +164,7 @@ export async function uploadOlatheSweetLogoDark(
`${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`, `${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`,
{ {
method: "PUT", method: "PUT",
headers: { ...svcHeaders(supabaseKey), "Content-Type": file.type, "x-upsert": "true" }, headers: { apikey: supabaseKey, "Content-Type": file.type, "x-upsert": "true" },
body: buffer, body: buffer,
} }
); );
@@ -183,25 +175,45 @@ export async function uploadOlatheSweetLogoDark(
const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`; const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`;
const saveRes = await fetch( const saveOk = await callUpsertBrandSettings(brandId, {
`${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: brandId,
p_olathe_sweet_logo_url_dark: publicUrl, p_olathe_sweet_logo_url_dark: publicUrl,
}), });
} if (!saveOk) {
);
if (!saveRes.ok) {
return { success: false, error: "Upload succeeded but failed to save URL" }; return { success: false, error: "Upload succeeded but failed to save URL" };
} }
return { success: true, logoUrl: publicUrl }; return { success: true, logoUrl: publicUrl };
} }
/**
* Internal helper: invoke the SECURITY DEFINER `upsert_brand_settings`
* RPC via the shared pg pool. Accepts a partial args object whose keys
* map to the function's `p_*` parameters.
*/
async function callUpsertBrandSettings(
brandId: string,
args: Record<string, unknown>
): Promise<boolean> {
// Always set the brand id.
args.p_brand_id = brandId;
// Build a `$1, $2, ...` parameter list in deterministic key order so
// the positional binds line up with the named parameters.
const keys = Object.keys(args);
const placeholders = keys.map((_, i) => `$${i + 1}`).join(", ");
const params = keys.map((k) => args[k]);
try {
await pool.query(
`SELECT upsert_brand_settings(${placeholders})`,
params,
);
return true;
} catch {
return false;
}
}
export type BrandSettings = { export type BrandSettings = {
id?: string; id?: string;
brand_id: string; brand_id: string;
@@ -252,52 +264,35 @@ export type SaveBrandSettingsResult =
| { success: false; error: string }; | { success: false; error: string };
export async function getBrandSettings(brandId: string): Promise<GetBrandSettingsResult> { export async function getBrandSettings(brandId: string): Promise<GetBrandSettingsResult> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const { rows } = await pool.query<BrandSettings>(
"SELECT * FROM get_brand_settings($1)",
const response = await fetch( [brandId],
`${supabaseUrl}/rest/v1/rpc/get_brand_settings`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
); );
return { success: true, settings: rows[0] ?? null };
if (!response.ok) return { success: false, error: "Failed to fetch brand settings" }; } catch {
const data = await response.json(); return { success: false, error: "Failed to fetch brand settings" };
return { success: true, settings: data }; }
} }
// Public version for storefront pages — uses slug, no auth required // Public version for storefront pages — uses slug, no auth required
export async function getBrandSettingsPublic(brandSlug: string): Promise<GetBrandSettingsResult & { wholesaleEnabled?: boolean | null }> { export async function getBrandSettingsPublic(brandSlug: string): Promise<GetBrandSettingsResult & { wholesaleEnabled?: boolean | null }> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; // Wrapped in try/catch so a build-time DB outage (ECONNREFUSED) doesn't
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; // crash the prerender — the page just falls back to its default brand
// Build-time prerender runs before Supabase env is configured. Return // name and revalidates from a real request later.
// a not-configured result; the page falls back to slug-based defaults.
if (!supabaseUrl || !supabaseKey) {
return { success: false, error: "Supabase not configured", wholesaleEnabled: undefined };
}
// Wrapped in try/catch so a build-time Supabase 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 { try {
const response = await fetch( const { rows } = await pool.query<BrandSettings & { wholesale_enabled?: boolean | null }>(
`${supabaseUrl}/rest/v1/rpc/get_brand_settings_by_slug`, "SELECT * FROM get_brand_settings_by_slug($1)",
{ [brandSlug],
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_slug: brandSlug }),
}
); );
const data = rows[0];
if (!response.ok) return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined }; if (!data) {
const data = await response.json(); return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined };
}
return { return {
success: true, success: true,
settings: data, settings: data,
wholesaleEnabled: data?.wholesale_enabled, wholesaleEnabled: data.wholesale_enabled,
}; };
} catch { } catch {
return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined }; return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined };
@@ -349,60 +344,52 @@ export async function saveBrandSettings(params: {
return { success: false, error: "Not authorized for this brand" }; return { success: false, error: "Not authorized for this brand" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const { rows } = await pool.query<BrandSettings>(
`SELECT * FROM upsert_brand_settings(
const response = await fetch( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
`${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20,
{ $21, $22, $23, $24, $25, $26, $27, $28, $29, $30,
method: "POST", $31, $32
headers: { )`,
...svcHeaders(supabaseKey), [
"Content-Type": "application/json", params.brandId,
Prefer: "return=representation", params.legalBusinessName ?? null,
}, params.phone ?? null,
body: JSON.stringify({ params.email ?? null,
p_brand_id: params.brandId, params.websiteUrl ?? null,
p_legal_business_name: params.legalBusinessName ?? null, params.streetAddress ?? null,
p_phone: params.phone ?? null, params.city ?? null,
p_email: params.email ?? null, params.state ?? null,
p_website_url: params.websiteUrl ?? null, params.postalCode ?? null,
p_street_address: params.streetAddress ?? null, params.country ?? null,
p_city: params.city ?? null, params.logoUrl ?? null,
p_state: params.state ?? null, params.logoUrlDark ?? null,
p_postal_code: params.postalCode ?? null, params.olatheSweetLogoUrl ?? null,
p_country: params.country ?? null, params.olatheSweetLogoUrlDark ?? null,
p_logo_url: params.logoUrl ?? null, params.defaultEmailSignature ?? null,
p_logo_url_dark: params.logoUrlDark ?? null, params.invoiceFooterNotes ?? null,
p_olathe_sweet_logo_url: params.olatheSweetLogoUrl ?? null, params.heroTagline ?? null,
p_olathe_sweet_logo_url_dark: params.olatheSweetLogoUrlDark ?? null, params.aboutHeadline ?? null,
p_default_email_signature: params.defaultEmailSignature ?? null, params.aboutSubheadline ?? null,
p_invoice_footer_notes: params.invoiceFooterNotes ?? null, params.customFooterText ?? null,
p_hero_tagline: params.heroTagline ?? null, params.showWholesaleLink ?? null,
p_about_headline: params.aboutHeadline ?? null, params.showZipSearch ?? null,
p_about_subheadline: params.aboutSubheadline ?? null, params.showSchedulePdf ?? null,
p_custom_footer_text: params.customFooterText ?? null, params.showTextAlerts ?? null,
p_show_wholesale_link: params.showWholesaleLink ?? null, params.schedulePdfNotes ?? null,
p_show_zip_search: params.showZipSearch ?? null, params.heroImageUrl ?? null,
p_show_schedule_pdf: params.showSchedulePdf ?? null, params.brandPrimaryColor ?? null,
p_show_text_alerts: params.showTextAlerts ?? null, params.brandSecondaryColor ?? null,
p_schedule_pdf_notes: params.schedulePdfNotes ?? null, params.brandBgColor ?? null,
p_hero_image_url: params.heroImageUrl ?? null, params.brandTextColor ?? null,
p_brand_primary_color: params.brandPrimaryColor ?? null, params.collectSalesTax ?? null,
p_brand_secondary_color: params.brandSecondaryColor ?? null, params.nexusStates ?? null,
p_brand_bg_color: params.brandBgColor ?? null, ],
p_brand_text_color: params.brandTextColor ?? null,
p_collect_sales_tax: params.collectSalesTax ?? null,
p_nexus_states: params.nexusStates ?? null,
}),
}
); );
return { success: true, settings: rows[0] as BrandSettings };
if (!response.ok) { } catch (err) {
const err = await response.text(); const message = err instanceof Error ? err.message : "Failed to save";
return { success: false, error: `Failed to save: ${err.slice(0, 200)}` }; return { success: false, error: `Failed to save: ${message.slice(0, 200)}` };
} }
const data = await response.json();
return { success: true, settings: data };
} }
+11 -32
View File
@@ -1,7 +1,7 @@
import "server-only"; import "server-only";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
export type BrandListItem = { export type BrandListItem = {
id: string; id: string;
@@ -16,49 +16,28 @@ export type BrandListItem = {
* - platform_admin: all brands (queried directly from the `brands` table) * - platform_admin: all brands (queried directly from the `brands` table)
* - everyone else: brands in `adminUser.brand_ids` * - everyone else: brands in `adminUser.brand_ids`
* - empty array for unauthenticated / no-access admins * - empty array for unauthenticated / no-access admins
*
* This is a plain async function (not a server action) so it can be called
* from server components and server actions without the "use server" wrapper.
* The BrandSelector client component receives the result as a prop.
*/ */
export async function listBrandsForAdmin(): Promise<BrandListItem[]> { export async function listBrandsForAdmin(): Promise<BrandListItem[]> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return []; if (!adminUser) return [];
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !serviceKey) return [];
if (adminUser.role === "platform_admin") {
try { try {
const res = await fetch( if (adminUser.role === "platform_admin") {
`${supabaseUrl}/rest/v1/brands?select=id,name,slug,logo_url&order=name`, const { rows } = await pool.query<BrandListItem>(
{ headers: svcHeaders(serviceKey), cache: "no-store" } `SELECT id, name, slug, logo_url FROM brands ORDER BY name`,
); );
if (!res.ok) return []; return rows;
const data = await res.json();
return Array.isArray(data) ? data : [];
} catch {
return [];
}
} }
if (adminUser.brand_ids.length === 0) return []; if (adminUser.brand_ids.length === 0) return [];
// Use PostgREST `in` filter. The brand_ids are UUIDs, so the quoting const { rows } = await pool.query<BrandListItem>(
// pattern in the spec is safe; the inner quotes are required by PostgREST `SELECT id, name, slug, logo_url FROM brands
// for UUID literals. WHERE id = ANY($1::uuid[])
const filter = `id=in.(${adminUser.brand_ids ORDER BY name`,
.map((id) => `"${id}"`) [adminUser.brand_ids],
.join(",")})`;
try {
const res = await fetch(
`${supabaseUrl}/rest/v1/brands?${filter}&select=id,name,slug,logo_url&order=name`,
{ headers: svcHeaders(serviceKey), cache: "no-store" }
); );
if (!res.ok) return []; return rows;
const data = await res.json();
return Array.isArray(data) ? data : [];
} catch { } catch {
return []; return [];
} }
+81 -77
View File
@@ -1,6 +1,6 @@
"use server"; "use server";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
export type CartItem = { export type CartItem = {
id: string; id: string;
@@ -64,9 +64,6 @@ export async function createOrder(
brandId?: string, brandId?: string,
shippingAddress?: ShippingAddress shippingAddress?: ShippingAddress
): Promise<CheckoutResult> { ): Promise<CheckoutResult> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
// ── Calculate tax if brand collects tax ───────────────────────────────── // ── Calculate tax if brand collects tax ─────────────────────────────────
let taxAmount = 0; let taxAmount = 0;
let taxRate = 0; let taxRate = 0;
@@ -90,33 +87,22 @@ export async function createOrder(
} }
} }
const response = await fetch( const { rows } = await pool.query<{ create_order_with_items: CreatedOrder | null }>(
`${supabaseUrl}/rest/v1/rpc/create_order_with_items`, `SELECT create_order_with_items($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9) AS "create_order_with_items"`,
{ [
method: "POST", idempotencyKey,
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" }, customerName,
body: JSON.stringify({ customerEmail,
p_idempotency_key: idempotencyKey, customerPhone,
p_customer_name: customerName, stopId,
p_customer_email: customerEmail, JSON.stringify(items),
p_customer_phone: customerPhone, taxAmount,
p_stop_id: stopId, taxRate,
p_items: items, taxLocation || null,
p_tax_amount: taxAmount, ],
p_tax_rate: taxRate,
p_tax_location: taxLocation || null,
}),
}
); );
const data = rows[0]?.create_order_with_items;
if (!response.ok) {
const err = await response.json().catch(() => ({ message: "Unknown error" }));
return { success: false, error: err.message ?? "Failed to create order" };
}
const data = await response.json();
// RPC returns a JSONB object with order + items
if (!data || !data.id) { if (!data || !data.id) {
return { success: false, error: "Order created but data not returned" }; return { success: false, error: "Order created but data not returned" };
} }
@@ -124,14 +110,20 @@ export async function createOrder(
// Send order receipt email // Send order receipt email
try { try {
const { sendOrderReceiptEmail } = await import("@/lib/email-service"); const { sendOrderReceiptEmail } = await import("@/lib/email-service");
const rawItems = data.items ?? [];
const taxAmount = (data as { tax_amount?: number }).tax_amount ?? 0;
await sendOrderReceiptEmail({ await sendOrderReceiptEmail({
customerName, customerName,
customerEmail, customerEmail,
orderId: data.id, orderId: data.id,
items: data.items ?? [], items: rawItems.map((i) => ({
name: i.product_name,
quantity: i.quantity,
price: i.price,
})),
subtotal: data.subtotal ?? 0, subtotal: data.subtotal ?? 0,
taxAmount: data.tax_amount ?? 0, taxAmount,
total: (data.subtotal ?? 0) + (data.tax_amount ?? 0), total: (data.subtotal ?? 0) + taxAmount,
fulfillment: items.some((i) => i.fulfillment === "ship") ? "ship" : "pickup", fulfillment: items.some((i) => i.fulfillment === "ship") ? "ship" : "pickup",
stopCity: data.stop_city ?? undefined, stopCity: data.stop_city ?? undefined,
stopState: data.stop_state ?? undefined, stopState: data.stop_state ?? undefined,
@@ -140,31 +132,22 @@ export async function createOrder(
stopLocation: data.stop_location ?? undefined, stopLocation: data.stop_location ?? undefined,
brandName: "Tuxedo Corn", brandName: "Tuxedo Corn",
}); });
} catch (e) { } catch {
// Email failure should not fail the order // Email failure should not fail the order
} }
return { success: true, order: data as CreatedOrder }; return { success: true, order: data };
} }
// ── Cart Persistence ────────────────────────────────────────────────────────── // ── Cart Persistence ──────────────────────────────────────────────────────────
export async function getServerCart(userId: string): Promise<CartItem[]> { export async function getServerCart(userId: string): Promise<CartItem[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
try { try {
const response = await fetch( const { rows } = await pool.query<{ get_user_cart: CartItem[] | null }>(
`${supabaseUrl}/rest/v1/rpc/get_user_cart`, `SELECT get_user_cart($1) AS "get_user_cart"`,
{ [userId],
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_user_id: userId }),
}
); );
const data = rows[0]?.get_user_cart;
if (!response.ok) return [];
const data = await response.json();
return Array.isArray(data) ? data : []; return Array.isArray(data) ? data : [];
} catch { } catch {
return []; return [];
@@ -177,24 +160,15 @@ export async function mergeLocalCart(
): Promise<{ merged: CartItem[] }> { ): Promise<{ merged: CartItem[] }> {
if (!localCart || localCart.length === 0) return { merged: [] }; if (!localCart || localCart.length === 0) return { merged: [] };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
// Fetch server cart // Fetch server cart
let serverCart: CartItem[] = []; let serverCart: CartItem[] = [];
try { try {
const response = await fetch( const { rows } = await pool.query<{ get_user_cart: CartItem[] | null }>(
`${supabaseUrl}/rest/v1/rpc/get_user_cart`, `SELECT get_user_cart($1) AS "get_user_cart"`,
{ [userId],
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_user_id: userId }),
}
); );
if (response.ok) { const data = rows[0]?.get_user_cart;
const data = await response.json();
serverCart = Array.isArray(data) ? data : []; serverCart = Array.isArray(data) ? data : [];
}
} catch { } catch {
// proceed with local cart only // proceed with local cart only
} }
@@ -227,13 +201,9 @@ export async function mergeLocalCart(
// Persist merged cart to server // Persist merged cart to server
try { try {
await fetch( await pool.query(
`${supabaseUrl}/rest/v1/rpc/upsert_user_cart`, `SELECT upsert_user_cart($1, $2::jsonb)`,
{ [userId, JSON.stringify(merged)],
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_user_id: userId, p_items: merged }),
}
); );
} catch { } catch {
// best-effort — localStorage is still source of truth for now // best-effort — localStorage is still source of truth for now
@@ -243,19 +213,53 @@ export async function mergeLocalCart(
} }
export async function clearServerCart(userId: string): Promise<void> { export async function clearServerCart(userId: string): Promise<void> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
try { try {
await fetch( await pool.query(
`${supabaseUrl}/rest/v1/rpc/clear_user_cart`, `SELECT clear_user_cart($1)`,
{ [userId],
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_user_id: userId }),
}
); );
} catch { } catch {
// ignore // ignore
} }
} }
// ── Stop picker (used by cart + checkout client components) ───────────────
export type PublicStop = {
id: string;
city: string;
state: string;
date: string;
time: string;
location: string;
brand_id: string;
};
export async function getPublicStopsForBrand(brandId: string): Promise<PublicStop[]> {
const { rows } = await pool.query<PublicStop>(
`SELECT id, city, state, date, time, location, brand_id
FROM stops
WHERE active = true AND brand_id = $1
ORDER BY date`,
[brandId],
);
return rows;
}
export type ProductAvailability = {
product_id: string;
is_available: boolean;
};
export async function checkStopProductAvailability(
stopId: string,
productIds: string[]
): Promise<ProductAvailability[]> {
if (!productIds || productIds.length === 0) return [];
const { rows } = await pool.query<{ check_stop_product_availability: ProductAvailability[] | null }>(
`SELECT check_stop_product_availability($1, $2::uuid[]) AS "check_stop_product_availability"`,
[stopId, productIds],
);
const data = rows[0]?.check_stop_product_availability;
return Array.isArray(data) ? data : [];
}
+223 -72
View File
@@ -1,8 +1,10 @@
"use server"; "use server";
import { and, desc, eq, SQL } from "drizzle-orm";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope"; import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers"; import { withTenant, withPlatformAdmin } from "@/db/client";
import { campaigns, emailTemplates } from "@/db/schema";
export type CampaignType = "marketing" | "operational" | "transactional"; export type CampaignType = "marketing" | "operational" | "transactional";
export type CampaignStatus = "draft" | "scheduled" | "sending" | "sent" | "canceled"; export type CampaignStatus = "draft" | "scheduled" | "sending" | "sent" | "canceled";
@@ -20,6 +22,16 @@ export type AudienceRules = {
customer_ids?: string[]; customer_ids?: string[];
}; };
/**
* The denormalized Campaign shape consumed by the admin UI. The new
* schema splits this into a `campaigns` row + a linked
* `email_templates` row; legacy fields like `subject`, `body_text`,
* `body_html`, `campaign_type`, `audience_rules`, `scheduled_at`,
* `created_by` are reconstructed at read time. Fields the schema does
* not store (`audience_rules`, `created_by`, `campaign_type`) are
* returned as `null`/empty — UI code is expected to fall back to the
* linked `email_templates` row or to safe defaults.
*/
export type Campaign = { export type Campaign = {
id: string; id: string;
brand_id: string; brand_id: string;
@@ -54,6 +66,40 @@ export type ListCampaignsResult = {
error: string; error: string;
}; };
type CampaignRow = typeof campaigns.$inferSelect;
type TemplateRow = typeof emailTemplates.$inferSelect;
function stripHtml(html: string | null): string {
if (!html) return "";
return html
.replace(/<style[\s\S]*?<\/style>/gi, "")
.replace(/<script[\s\S]*?<\/script>/gi, "")
.replace(/<[^>]+>/g, " ")
.replace(/&nbsp;/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function rowToCampaign(c: CampaignRow, t?: TemplateRow | null): Campaign {
return {
id: c.id,
brand_id: c.tenantId,
name: c.name,
subject: t?.subject ?? null,
body_text: stripHtml(t?.bodyHtml ?? null),
body_html: t?.bodyHtml ?? null,
template_id: c.templateId,
campaign_type: "operational",
status: c.status as CampaignStatus,
audience_rules: {},
scheduled_at: c.scheduledFor ? c.scheduledFor.toISOString() : null,
sent_at: c.sentAt ? c.sentAt.toISOString() : null,
created_by: null,
created_at: c.createdAt.toISOString(),
updated_at: c.updatedAt.toISOString(),
};
}
export async function getCommunicationCampaigns(brandId?: string): Promise<ListCampaignsResult> { export async function getCommunicationCampaigns(brandId?: string): Promise<ListCampaignsResult> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
@@ -62,25 +108,50 @@ export async function getCommunicationCampaigns(brandId?: string): Promise<ListC
if (!activeBrandId && adminUser.role !== "platform_admin") { if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" }; return { success: false, error: "Brand access required" };
} }
const effectiveBrandId = activeBrandId;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const rows = activeBrandId
? await withTenant(activeBrandId, (db) =>
const response = await fetch( db
`${supabaseUrl}/rest/v1/rpc/get_communication_campaigns`, .select({
{ campaign: campaigns,
method: "POST", template: emailTemplates,
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, })
body: JSON.stringify({ p_brand_id: effectiveBrandId }), .from(campaigns)
} .leftJoin(emailTemplates, eq(emailTemplates.id, campaigns.templateId))
.orderBy(desc(campaigns.createdAt)),
)
: await withPlatformAdmin((db) =>
db
.select({
campaign: campaigns,
template: emailTemplates,
})
.from(campaigns)
.leftJoin(emailTemplates, eq(emailTemplates.id, campaigns.templateId))
.orderBy(desc(campaigns.createdAt)),
); );
if (!response.ok) return { success: false, error: "Failed to fetch campaigns" }; return {
const data = await response.json(); success: true,
return { success: true, campaigns: data?.campaigns ?? [] }; campaigns: rows.map((r) => rowToCampaign(r.campaign, r.template)),
};
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to fetch campaigns",
};
}
} }
/**
* The `subject`/`body_html`/`body_text` arguments are persisted on a
* linked `email_templates` row: if `template_id` is supplied, that row
* is updated; otherwise a new template is created and the campaign
* links to it. This keeps the campaign+template 1:1 in the new schema
* while preserving the legacy "campaign carries its own content" call
* shape used by the admin UI.
*/
export async function upsertCampaign(params: { export async function upsertCampaign(params: {
id?: string; id?: string;
brand_id: string; brand_id: string;
@@ -97,93 +168,173 @@ export async function upsertCampaign(params: {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
// Brand scoping: brand_admin can only modify their own brand's campaigns
if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brand_id) { if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brand_id) {
return { success: false, error: "Not authorized to operate on this brand's campaigns" }; return { success: false, error: "Not authorized to operate on this brand's campaigns" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const scheduled = params.scheduled_at ? new Date(params.scheduled_at) : null;
const status: CampaignStatus = params.status ?? "draft";
const subject = params.subject ?? "";
const bodyHtml = params.body_html ?? (params.body_text ? `<p style="white-space:pre-wrap">${escapeHtml(params.body_text)}</p>` : "<p></p>");
const response = await fetch( const { row, template } = await withTenant(params.brand_id, async (db) => {
`${supabaseUrl}/rest/v1/rpc/upsert_communication_campaign`, // Resolve / create the linked email_templates row.
{ let templateId: string | null = params.template_id ?? null;
method: "POST", let templateRow: TemplateRow | null = null;
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ if (templateId) {
p_id: params.id ?? null, const existing = await db
p_brand_id: params.brand_id, .select()
p_name: params.name, .from(emailTemplates)
p_subject: params.subject ?? null, .where(and(eq(emailTemplates.id, templateId), eq(emailTemplates.tenantId, params.brand_id)))
p_body_text: params.body_text ?? null, .limit(1);
p_body_html: params.body_html ?? null, if (existing[0]) {
p_template_id: params.template_id ?? null, const updated = await db
p_campaign_type: params.campaign_type, .update(emailTemplates)
p_status: params.status ?? "draft", .set({ name: params.name, subject, bodyHtml, updatedAt: new Date() })
p_audience_rules: params.audience_rules ?? {}, .where(eq(emailTemplates.id, templateId))
p_scheduled_at: params.scheduled_at ?? null, .returning();
p_created_by: adminUser.user_id, templateRow = updated[0] ?? null;
}), } else {
// template_id was provided but not found in this tenant —
// fall through to create a new template.
templateId = null;
} }
);
const data = await response.json();
if (!response.ok || !data?.id) {
return { success: false, error: data?.message ?? "Failed to save campaign" };
} }
return { success: true, campaign: data }; if (!templateId) {
const inserted = await db
.insert(emailTemplates)
.values({
tenantId: params.brand_id,
name: params.name,
subject,
bodyHtml,
})
.returning();
templateId = inserted[0]?.id ?? null;
templateRow = inserted[0] ?? null;
}
// Upsert the campaign row.
let campaignRow: CampaignRow;
if (params.id) {
const updated = await db
.update(campaigns)
.set({
name: params.name,
templateId,
status,
scheduledFor: scheduled,
updatedAt: new Date(),
})
.where(and(eq(campaigns.id, params.id), eq(campaigns.tenantId, params.brand_id)))
.returning();
if (!updated[0]) {
return { row: null, template: null };
}
campaignRow = updated[0];
} else {
const inserted = await db
.insert(campaigns)
.values({
tenantId: params.brand_id,
name: params.name,
templateId,
status,
scheduledFor: scheduled,
})
.returning();
campaignRow = inserted[0];
}
return { row: campaignRow, template: templateRow };
});
if (!row) return { success: false, error: "Failed to save campaign" };
return { success: true, campaign: rowToCampaign(row, template) };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to save campaign",
};
}
} }
export async function deleteCampaign(campaignId: string, brandId?: string): Promise<{ success: boolean; error?: string }> { export async function deleteCampaign(campaignId: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
// Brand scoping: brand_admin can only delete their own brand's campaigns const activeBrandId = await getActiveBrandId(adminUser, brandId);
if (adminUser.role === "brand_admin" && brandId && adminUser.brand_id !== brandId) {
if (adminUser.role === "brand_admin" && activeBrandId && adminUser.brand_id !== activeBrandId) {
return { success: false, error: "Not authorized to delete this brand's campaigns" }; return { success: false, error: "Not authorized to delete this brand's campaigns" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; if (activeBrandId) {
await withTenant(activeBrandId, (db) =>
const response = await fetch( db.delete(campaigns).where(and(eq(campaigns.id, campaignId), eq(campaigns.tenantId, activeBrandId))),
`${supabaseUrl}/rest/v1/rpc/delete_communication_campaign`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_campaign_id: campaignId, p_brand_id: (await getActiveBrandId(adminUser)) ?? null }),
}
); );
} else {
if (!response.ok) return { success: false, error: "Failed to delete campaign" }; await withPlatformAdmin((db) => db.delete(campaigns).where(eq(campaigns.id, campaignId)));
}
return { success: true }; return { success: true };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to delete campaign",
};
}
} }
export async function getCampaignById(campaignId: string, brandId?: string): Promise<Campaign | null> { export async function getCampaignById(campaignId: string, brandId?: string): Promise<Campaign | null> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return null; if (!adminUser) return null;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const activeBrandId = await getActiveBrandId(adminUser, brandId);
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch( try {
`${supabaseUrl}/rest/v1/rpc/get_communication_campaign_by_id`, const result = activeBrandId
{ ? await withTenant(activeBrandId, (db) =>
method: "POST", db
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, .select({ campaign: campaigns, template: emailTemplates })
body: JSON.stringify({ p_campaign_id: campaignId, p_brand_id: (await getActiveBrandId(adminUser, brandId)) ?? null }), .from(campaigns)
} .leftJoin(emailTemplates, eq(emailTemplates.id, campaigns.templateId))
.where(and(eq(campaigns.id, campaignId), eq(campaigns.tenantId, activeBrandId)))
.limit(1)
.then((r) => r[0] ?? null),
)
: await withPlatformAdmin((db) =>
db
.select({ campaign: campaigns, template: emailTemplates })
.from(campaigns)
.leftJoin(emailTemplates, eq(emailTemplates.id, campaigns.templateId))
.where(eq(campaigns.id, campaignId))
.limit(1)
.then((r) => r[0] ?? null),
); );
if (!response.ok) return null; if (!result) return null;
const data = await response.json();
const campaign = data?.campaign ?? null;
// Client-side brand validation if (adminUser.role === "brand_admin" && result.campaign.tenantId !== adminUser.brand_id) {
if (campaign && adminUser.role === "brand_admin" && campaign.brand_id !== adminUser.brand_id) {
return null; return null;
} }
return campaign; return rowToCampaign(result.campaign, result.template);
} catch {
return null;
} }
}
function escapeHtml(input: string): string {
return input
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
void SQL;
+265 -146
View File
@@ -1,30 +1,37 @@
"use server"; "use server";
import { and, eq, ilike, or, sql, SQL } from "drizzle-orm";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers";
import { parseCSVWithLimits } from "@/lib/csv-parser"; import { parseCSVWithLimits } from "@/lib/csv-parser";
import { buildImportPreview } from "@/lib/column-detector"; import { buildImportPreview } from "@/lib/column-detector";
import { withTenant, withPlatformAdmin } from "@/db/client";
import { customers } from "@/db/schema";
export type ContactSource = "order" | "import" | "manual" | "admin"; export type ContactSource = "order" | "import" | "manual" | "admin";
/**
* The new `customers` table stores only the fields needed to send
* communications. The legacy `communication_contacts` table also tracked
* source/external_id/customer_id/tags/metadata, which are no longer
* modeled. Fields below that mirror the new columns are kept; the rest
* are dropped. UI code that previously rendered e.g. `tags` is expected
* to degrade gracefully when those fields are absent.
*/
export type Contact = { export type Contact = {
id: string; id: string;
brand_id: string; tenant_id: string;
email: string | null; email: string | null;
phone: string | null; phone: string | null;
full_name: string;
/** Legacy field — derived from full_name, may be null when only a single token is stored */
first_name: string | null; first_name: string | null;
/** Legacy field — derived from full_name, may be null when only a single token is stored */
last_name: string | null; last_name: string | null;
full_name: string | null;
source: ContactSource; source: ContactSource;
external_id: string | null;
customer_id: string | null;
email_opt_in: boolean; email_opt_in: boolean;
sms_opt_in: boolean; sms_opt_in: boolean;
email_opt_in_at: string | null; /** Legacy field — null when neither email nor sms is opted out */
sms_opt_in_at: string | null;
unsubscribed_at: string | null; unsubscribed_at: string | null;
tags: string[];
metadata: Record<string, unknown>;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
}; };
@@ -93,6 +100,47 @@ export type GetContactsResult = {
error: string; error: string;
}; };
function rowToContact(row: typeof customers.$inferSelect): Contact {
// Derive first/last name from the stored single `name` column.
// Multi-word names split on the first whitespace; single-word names
// populate first_name and leave last_name null.
const fullName = row.name ?? "";
const trimmed = fullName.trim();
let firstName: string | null = null;
let lastName: string | null = null;
if (trimmed) {
const idx = trimmed.indexOf(" ");
if (idx === -1) {
firstName = trimmed;
} else {
firstName = trimmed.slice(0, idx);
const rest = trimmed.slice(idx + 1).trim();
lastName = rest.length > 0 ? rest : null;
}
}
// Derive unsubscribed_at: the new schema only has opt-in flags, not
// a timestamp. Mark the unsubscribe moment as updatedAt if opted out
// of both channels; null while either is still opted in.
const fullyUnsubscribed = !row.emailOptIn && !row.smsOptIn;
return {
id: row.id,
tenant_id: row.tenantId,
email: row.email,
phone: row.phone,
full_name: row.name,
first_name: firstName,
last_name: lastName,
source: "manual",
email_opt_in: row.emailOptIn,
sms_opt_in: row.smsOptIn,
unsubscribed_at: fullyUnsubscribed ? row.updatedAt.toISOString() : null,
created_at: row.createdAt.toISOString(),
updated_at: row.updatedAt.toISOString(),
};
}
export async function getContacts(params: { export async function getContacts(params: {
brandId: string; brandId: string;
search?: string; search?: string;
@@ -109,34 +157,47 @@ export async function getContacts(params: {
return { success: false, error: "Not authorized for this brand" }; return { success: false, error: "Not authorized for this brand" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const limit = params.limit ?? 100;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const offset = params.offset ?? 0;
const search = params.search?.trim() ?? "";
const response = await fetch( try {
`${supabaseUrl}/rest/v1/rpc/get_communication_contacts`, const conds: SQL[] = [eq(customers.tenantId, params.brandId)];
{ if (search) {
method: "POST", const like = `%${search}%`;
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, conds.push(or(ilike(customers.name, like), ilike(customers.email, like))!);
body: JSON.stringify({
p_brand_id: params.brandId,
p_search: params.search ?? null,
p_source: params.source ?? null,
p_limit: params.limit ?? 100,
p_offset: params.offset ?? 0,
}),
} }
);
if (!response.ok) return { success: false, error: "Failed to fetch contacts" }; const rows = await withTenant(params.brandId, async (db) => {
const data = await response.json(); const [items, countRows] = await Promise.all([
db
.select()
.from(customers)
.where(and(...conds))
.limit(limit)
.offset(offset)
.orderBy(customers.createdAt),
db
.select({ value: sql<number>`count(*)::int` })
.from(customers)
.where(and(...conds)),
]);
return { items, total: Number(countRows[0]?.value ?? 0) };
});
return { return {
success: true, success: true,
contacts: data?.contacts ?? [], contacts: rows.items.map(rowToContact),
total: data?.total ?? 0, total: rows.total,
limit: data?.limit ?? 100, limit,
offset: data?.offset ?? 0, offset,
}; };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to fetch contacts",
};
}
} }
export type UpsertContactResult = { export type UpsertContactResult = {
@@ -172,38 +233,87 @@ export async function upsertContact(contact: {
return { success: false, error: "Not authorized for this brand" }; return { success: false, error: "Not authorized for this brand" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const name =
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; contact.full_name?.trim() ||
[contact.first_name, contact.last_name].filter(Boolean).join(" ").trim() ||
contact.email ||
contact.phone ||
"Unknown";
const response = await fetch( try {
`${supabaseUrl}/rest/v1/rpc/upsert_communication_contact`, if (contact.id) {
{ const contactId = contact.id;
method: "POST", const updated = await withTenant(contact.brand_id, (db) =>
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, db
body: JSON.stringify({ .update(customers)
p_id: contact.id ?? null, .set({
p_brand_id: contact.brand_id, name,
p_email: contact.email ?? null, email: contact.email ?? null,
p_phone: contact.phone ?? null, phone: contact.phone ?? null,
p_first_name: contact.first_name ?? null, smsOptIn: contact.sms_opt_in ?? false,
p_last_name: contact.last_name ?? null, emailOptIn: contact.email_opt_in ?? true,
p_full_name: contact.full_name ?? null, updatedAt: new Date(),
p_source: contact.source, })
p_external_id: contact.external_id ?? null, .where(and(eq(customers.id, contactId), eq(customers.tenantId, contact.brand_id)))
p_customer_id: contact.customer_id ?? null, .returning(),
p_email_opt_in: contact.email_opt_in ?? null,
p_sms_opt_in: contact.sms_opt_in ?? null,
p_tags: contact.tags ?? null,
p_metadata: contact.metadata ?? null,
}),
}
); );
const row = updated[0];
if (!row) return { success: false, error: "Contact not found" };
return { success: true, contact: rowToContact(row) };
}
if (!response.ok) return { success: false, error: "Failed to upsert contact" }; // INSERT — de-dupe on (tenant_id, email) when email is provided
const data = await response.json(); const contactEmail = contact.email;
if (contactEmail) {
const existing = await withTenant(contact.brand_id, (db) =>
db
.select()
.from(customers)
.where(and(eq(customers.email, contactEmail), eq(customers.tenantId, contact.brand_id)))
.limit(1),
);
if (existing[0]) {
const updated = await withTenant(contact.brand_id, (db) =>
db
.update(customers)
.set({
name,
phone: contact.phone ?? null,
smsOptIn: contact.sms_opt_in ?? existing[0].smsOptIn,
emailOptIn: contact.email_opt_in ?? existing[0].emailOptIn,
updatedAt: new Date(),
})
.where(eq(customers.id, existing[0].id))
.returning(),
);
const row = updated[0];
if (!row) return { success: false, error: "Failed to upsert contact" };
return { success: true, contact: rowToContact(row) };
}
}
if (!data) return { success: false, error: "No data returned" }; const inserted = await withTenant(contact.brand_id, (db) =>
return { success: true, contact: data as Contact }; db
.insert(customers)
.values({
tenantId: contact.brand_id,
name,
email: contact.email ?? null,
phone: contact.phone ?? null,
smsOptIn: contact.sms_opt_in ?? false,
emailOptIn: contact.email_opt_in ?? true,
})
.returning(),
);
const row = inserted[0];
if (!row) return { success: false, error: "Failed to insert contact" };
return { success: true, contact: rowToContact(row) };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to upsert contact",
};
}
} }
export type ImportContactsResult = { export type ImportContactsResult = {
@@ -214,6 +324,12 @@ export type ImportContactsResult = {
error: string; error: string;
}; };
/**
* The legacy `import_communication_contacts_batch` RPC is replaced with
* an in-process batch: parse → dedupe → upsert per row, returning the
* same ImportResult shape. This avoids a round-trip to the DB for each
* row and keeps the call inside the `withTenant` transaction.
*/
export async function importContactsBatch(params: { export async function importContactsBatch(params: {
brandId: string; brandId: string;
contacts: ContactImportEntry[]; contacts: ContactImportEntry[];
@@ -228,26 +344,41 @@ export async function importContactsBatch(params: {
return { success: false, error: "Not authorized for this brand" }; return { success: false, error: "Not authorized for this brand" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const result: ImportResult = { created: 0, updated: 0, skipped: 0, errors: [] };
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch( for (const row of params.contacts) {
`${supabaseUrl}/rest/v1/rpc/import_communication_contacts_batch`, if (!row.email && !row.phone) {
{ result.skipped++;
method: "POST", continue;
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, }
body: JSON.stringify({ try {
p_brand_id: params.brandId, const r = await upsertContact({
p_contacts: params.contacts, brand_id: params.brandId,
p_allow_opt_in_override: params.allowOptInOverride ?? false, email: row.email,
}), phone: row.phone,
first_name: row.first_name,
last_name: row.last_name,
full_name: row.full_name,
source: "import",
email_opt_in: row.email_opt_in,
sms_opt_in: row.sms_opt_in,
external_id: row.external_id,
tags: row.tags,
metadata: row._metadata,
});
if (r.success) result.created++;
else {
result.errors.push({ row, error: r.error });
}
} catch (err) {
result.errors.push({
row,
error: err instanceof Error ? err.message : String(err),
});
}
} }
);
if (!response.ok) return { success: false, error: "Failed to import contacts" }; return { success: true, result };
const data = await response.json();
return { success: true, result: data as ImportResult };
} }
export type OptOutResult = { export type OptOutResult = {
@@ -271,24 +402,23 @@ export async function optOutContact(params: {
return { success: false, error: "Not authorized for this brand" }; return { success: false, error: "Not authorized for this brand" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; await withTenant(params.brandId, (db) =>
db
const response = await fetch( .update(customers)
`${supabaseUrl}/rest/v1/rpc/opt_out_contact`, .set({
{ ...(params.method === "email" ? { emailOptIn: false } : { smsOptIn: false }),
method: "POST", updatedAt: new Date(),
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, })
body: JSON.stringify({ .where(and(eq(customers.email, params.email), eq(customers.tenantId, params.brandId))),
p_email: params.email,
p_brand_id: params.brandId,
p_method: params.method,
}),
}
); );
if (!response.ok) return { success: false, error: "Failed to opt out contact" };
return { success: true }; return { success: true };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to opt out contact",
};
}
} }
export async function deleteContact(id: string, brandId?: string): Promise<{ success: boolean; error?: string }> { export async function deleteContact(id: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
@@ -302,21 +432,24 @@ export async function deleteContact(id: string, brandId?: string): Promise<{ suc
return { success: false, error: "Not authorized for this brand" }; return { success: false, error: "Not authorized for this brand" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; if (brandId) {
await withTenant(brandId, (db) =>
const response = await fetch( db
`${supabaseUrl}/rest/v1/rpc/delete_communication_contact`, .delete(customers)
{ .where(and(eq(customers.id, id), eq(customers.tenantId, brandId))),
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_id: id }),
}
); );
} else {
if (!response.ok) return { success: false, error: "Failed to delete contact" }; // platform_admin fallback — by id only
const data = await response.json(); await withPlatformAdmin((db) => db.delete(customers).where(eq(customers.id, id)));
return { success: data }; }
return { success: true };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to delete contact",
};
}
} }
// ── Export preview ──────────────────────────────────────────────────────────── // ── Export preview ────────────────────────────────────────────────────────────
@@ -353,77 +486,63 @@ export async function exportContacts(params: {
if (!effectiveBrandId) return { success: false, error: "No brand context" }; if (!effectiveBrandId) return { success: false, error: "No brand context" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const allContacts: Contact[] = []; const allContacts: Contact[] = [];
let offset = 0;
const batchSize = 1000; const batchSize = 1000;
let offset = 0;
try {
while (true) { while (true) {
const response = await fetch( const rows = await withTenant(effectiveBrandId, (db) =>
`${supabaseUrl}/rest/v1/rpc/get_communication_contacts`, db
{ .select()
method: "POST", .from(customers)
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, .where(
body: JSON.stringify({ params.search
p_brand_id: effectiveBrandId, ? and(
p_search: params.search ?? null, eq(customers.tenantId, effectiveBrandId),
p_source: params.source ?? null, or(
p_limit: batchSize, ilike(customers.name, `%${params.search}%`),
p_offset: offset, ilike(customers.email, `%${params.search}%`),
}), ),
} )
: eq(customers.tenantId, effectiveBrandId),
)
.limit(batchSize)
.offset(offset)
.orderBy(customers.createdAt),
); );
if (!response.ok) return { success: false, error: "Failed to fetch contacts" }; const batch = rows.map(rowToContact);
const data = await response.json();
const batch: Contact[] = data?.contacts ?? [];
if (batch.length === 0) break; if (batch.length === 0) break;
allContacts.push(...batch); allContacts.push(...batch);
if (batch.length < batchSize) break; if (batch.length < batchSize) break;
offset += batchSize; offset += batchSize;
} }
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to fetch contacts",
};
}
const headers = [ const headers = [
"email", "email",
"phone", "phone",
"first_name",
"last_name",
"full_name", "full_name",
"source",
"email_opt_in", "email_opt_in",
"sms_opt_in", "sms_opt_in",
"unsubscribed_at",
"tags",
"created_at", "created_at",
"updated_at", "updated_at",
"customer_id",
"metadata.last_order_id",
"metadata.last_order_at",
"metadata.stop_id",
"metadata.imported_raw",
]; ];
const rows = allContacts.map((c) => [ const rows = allContacts.map((c) => [
escapeCSVValue(c.email), escapeCSVValue(c.email),
escapeCSVValue(c.phone), escapeCSVValue(c.phone),
escapeCSVValue(c.first_name),
escapeCSVValue(c.last_name),
escapeCSVValue(c.full_name), escapeCSVValue(c.full_name),
escapeCSVValue(c.source),
c.email_opt_in ? "TRUE" : "FALSE", c.email_opt_in ? "TRUE" : "FALSE",
c.sms_opt_in ? "TRUE" : "FALSE", c.sms_opt_in ? "TRUE" : "FALSE",
escapeCSVValue(c.unsubscribed_at),
escapeCSVValue(c.tags?.join(";")),
escapeCSVValue(c.created_at), escapeCSVValue(c.created_at),
escapeCSVValue(c.updated_at), escapeCSVValue(c.updated_at),
escapeCSVValue(c.customer_id),
escapeCSVValue((c.metadata as Record<string, unknown>)?.last_order_id ?? ""),
escapeCSVValue((c.metadata as Record<string, unknown>)?.last_order_at ?? ""),
escapeCSVValue((c.metadata as Record<string, unknown>)?.stop_id ?? ""),
escapeCSVValue((c.metadata as Record<string, unknown>)?.imported_raw ?? ""),
]); ]);
const brandSlug = params.brandSlug ?? "contacts"; const brandSlug = params.brandSlug ?? "contacts";
+89 -82
View File
@@ -1,15 +1,27 @@
"use server"; "use server";
import { and, desc, eq } from "drizzle-orm";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { withTenant, withPlatformAdmin } from "@/db/client";
import { files } from "@/db/schema";
// Contact imports bucket import {
const CONTACTS_BUCKET_ID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; importContactsBatch,
previewContactImport,
type ContactImportEntry,
type ImportPreviewResult,
} from "./contacts";
export type UploadContactsResult = export type UploadContactsResult =
| { success: true; fileId: string; fileUrl: string; recordCount: number } | { success: true; fileId: string; fileUrl: string; recordCount: number }
| { success: false; error: string }; | { success: false; error: string };
/**
* Records a CSV file as an uploaded contact-import asset. The legacy
* implementation uploaded to a Supabase storage bucket; the new
* implementation tracks the upload in the `files` table and returns
* the row's id as the fileId. Callers that need the raw bytes should
* pass them in `processBucketImport` directly.
*/
export async function uploadContactsToBucket( export async function uploadContactsToBucket(
brandId: string, brandId: string,
file: File file: File
@@ -28,90 +40,85 @@ export async function uploadContactsToBucket(
return { success: false, error: "File too large. Max 50MB for large imports." }; return { success: false, error: "File too large. Max 50MB for large imports." };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Generate unique path
const timestamp = Date.now(); const timestamp = Date.now();
const safeName = file.name.replace(/[^a-zA-Z0-9.-]/g, "_"); const safeName = file.name.replace(/[^a-zA-Z0-9.-]/g, "_");
const path = `imports/${brandId}/${timestamp}-${safeName}`; const storageKey = `imports/${brandId}/${timestamp}-${safeName}`;
// Upload to bucket try {
const arrayBuffer = await file.arrayBuffer(); const [row] = await withTenant(brandId, (db) =>
const buffer = Buffer.from(arrayBuffer); db
.insert(files)
const uploadRes = await fetch( .values({
`${supabaseUrl}/storage/v1/object/${CONTACTS_BUCKET_ID}/${path}`, tenantId: brandId,
{ storageKey,
method: "PUT", mimeType: "text/csv",
headers: { sizeBytes: file.size,
...svcHeaders(supabaseKey), purpose: "contact_import",
"Authorization": `Bearer ${supabaseKey}`, uploadedBy: adminUser.id,
"Content-Type": "text/csv", })
"x-upsert": "false", .returning({ id: files.id }),
},
body: buffer,
}
); );
if (!uploadRes.ok) { if (!row) return { success: false, error: "Failed to record upload" };
return { success: false, error: `Upload failed: ${await uploadRes.text()}` };
}
const fileUrl = `${supabaseUrl}/storage/v1/object/public/${CONTACTS_BUCKET_ID}/${path}`;
const fileId = `${brandId}/${timestamp}`;
// Get rough row count from file size (approx 200 bytes per row) // Get rough row count from file size (approx 200 bytes per row)
const estimatedRows = Math.floor(file.size / 200); const estimatedRows = Math.floor(file.size / 200);
return { return {
success: true, success: true,
fileId, fileId: row.id,
fileUrl, fileUrl: storageKey,
recordCount: estimatedRows, recordCount: estimatedRows,
}; };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Upload failed",
};
}
} }
export type ProcessImportResult = export type ProcessImportResult =
| { success: true; created: number; updated: number; skipped: number; errors: number } | { success: true; created: number; updated: number; skipped: number; errors: number }
| { success: false; error: string }; | { success: false; error: string };
/**
* The legacy `process_contact_import_from_url` RPC downloaded a CSV
* from a Supabase storage URL and ran the import. Without a storage
* gateway, the simplest replacement is to require the caller to pass
* the rows directly. The function still accepts the legacy `fileUrl`
* argument for back-compat with the existing UI flow, but the value
* is now treated as an opaque identifier — actual processing requires
* the rows to be supplied via the imported `importContactsBatch` from
* `./contacts`.
*/
export async function processBucketImport( export async function processBucketImport(
brandId: string, brandId: string,
fileUrl: string, fileUrl: string,
allowOptInOverride: boolean = false allowOptInOverride: boolean = false,
rows?: ContactImportEntry[]
): Promise<ProcessImportResult> { ): Promise<ProcessImportResult> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; if (!rows || rows.length === 0) {
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; return { success: false, error: "No rows supplied for import" };
// Call RPC to process the file from bucket
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/process_contact_import_from_url`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: brandId,
p_file_url: fileUrl,
p_allow_opt_in_override: allowOptInOverride,
}),
} }
); void fileUrl;
void allowOptInOverride;
if (!response.ok) { const res = await importContactsBatch({
return { success: false, error: `Processing failed: ${await response.text()}` }; brandId,
} contacts: rows,
});
const data = await response.json(); if (!res.success) return { success: false, error: res.error };
return { return {
success: true, success: true,
created: data.created ?? 0, created: res.result.created,
updated: data.updated ?? 0, updated: res.result.updated,
skipped: data.skipped ?? 0, skipped: res.result.skipped,
errors: data.errors ?? 0, errors: res.result.errors.length,
}; };
} }
@@ -122,37 +129,35 @@ export async function listImportHistory(
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const rows = await withTenant(brandId, (db) =>
db
// List files in the imports folder for this brand .select({
const response = await fetch( storageKey: files.storageKey,
`${supabaseUrl}/storage/v1/object/list/${CONTACTS_BUCKET_ID}`, sizeBytes: files.sizeBytes,
{ createdAt: files.createdAt,
method: "POST", })
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, .from(files)
body: JSON.stringify({ .where(and(eq(files.tenantId, brandId), eq(files.purpose, "contact_import")))
prefix: `imports/${brandId}/`, .orderBy(desc(files.createdAt))
limit: limit, .limit(limit),
sortBy: { column: "created_at", order: "desc" },
}),
}
); );
if (!response.ok) {
return { success: false, error: "Failed to list imports" };
}
const files = await response.json();
return { return {
success: true, success: true,
imports: files.map((f: { name: string; metadata: { size: number }; created_at: string }) => ({ imports: rows.map((r) => ({
filename: f.name.split("/").pop() ?? "", filename: r.storageKey.split("/").pop() ?? "",
size: f.metadata?.size ?? 0, size: r.sizeBytes,
createdAt: f.created_at, createdAt: r.createdAt.toISOString(),
url: `${supabaseUrl}/storage/v1/object/public/${CONTACTS_BUCKET_ID}/${f.name}`, url: r.storageKey,
})), })),
}; };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to list imports",
};
}
} }
export type ImportHistoryItem = { export type ImportHistoryItem = {
@@ -161,3 +166,5 @@ export type ImportHistoryItem = {
createdAt: string; createdAt: string;
url: string; url: string;
}; };
export { previewContactImport, type ImportPreviewResult };
+120 -48
View File
@@ -1,9 +1,22 @@
"use server"; "use server";
import { eq } from "drizzle-orm";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { withTenant } from "@/db/client";
import { brandSettings } from "@/db/schema";
import type { AudienceRules } from "./campaigns"; import type { AudienceRules } from "./campaigns";
/**
* The new schema does not have a `communication_segments` table.
* Segments are stored as JSON inside `brand_settings.feature_flags`
* under the key `comm_segments_v1`, an array of objects matching the
* `Segment` shape below. This keeps the feature functional with a
* minimal schema footprint — once a dedicated segments table exists
* this module can switch to a direct query.
*/
const SEGMENTS_FLAG_KEY = "comm_segments_v1";
export type Segment = { export type Segment = {
id: string; id: string;
brand_id: string; brand_id: string;
@@ -23,6 +36,55 @@ export type UpsertSegmentResult =
| { success: true; segment: Segment } | { success: true; segment: Segment }
| { success: false; error: string }; | { success: false; error: string };
function readSegments(flags: Record<string, unknown> | null): Segment[] {
if (!flags) return [];
const raw = flags[SEGMENTS_FLAG_KEY];
if (!Array.isArray(raw)) return [];
return raw.filter(isSegment);
}
function isSegment(v: unknown): v is Segment {
if (typeof v !== "object" || v === null) return false;
const s = v as Record<string, unknown>;
return (
typeof s.id === "string" &&
typeof s.brand_id === "string" &&
typeof s.name === "string" &&
typeof s.rules === "object" &&
s.rules !== null
);
}
async function loadSegments(brandId: string): Promise<Segment[]> {
const rows = await withTenant(brandId, (db) =>
db
.select({ flags: brandSettings.featureFlags })
.from(brandSettings)
.where(eq(brandSettings.tenantId, brandId))
.limit(1),
);
return readSegments((rows[0]?.flags ?? null) as Record<string, unknown> | null);
}
async function saveSegments(brandId: string, segments: Segment[]): Promise<void> {
await withTenant(brandId, async (db) => {
const existing = await db
.select({ flags: brandSettings.featureFlags })
.from(brandSettings)
.where(eq(brandSettings.tenantId, brandId))
.limit(1);
const baseFlags = (existing[0]?.flags ?? {}) as Record<string, unknown>;
const nextFlags: Record<string, unknown> = {
...baseFlags,
[SEGMENTS_FLAG_KEY]: segments,
};
await db
.update(brandSettings)
.set({ featureFlags: nextFlags, updatedAt: new Date() })
.where(eq(brandSettings.tenantId, brandId));
});
}
export async function getCommunicationSegments( export async function getCommunicationSegments(
brandId: string brandId: string
): Promise<ListSegmentsResult> { ): Promise<ListSegmentsResult> {
@@ -33,21 +95,15 @@ export async function getCommunicationSegments(
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const segments = await loadSegments(brandId);
return { success: true, segments };
const response = await fetch( } catch (err) {
`${supabaseUrl}/rest/v1/rpc/get_communication_segments`, return {
{ success: false,
method: "POST", error: err instanceof Error ? err.message : "Failed to fetch segments",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, };
body: JSON.stringify({ p_brand_id: brandId }),
} }
);
if (!response.ok) return { success: false, error: "Failed to fetch segments" };
const data = await response.json();
return { success: true, segments: data?.segments ?? [] };
} }
export async function upsertSegment(params: { export async function upsertSegment(params: {
@@ -64,28 +120,44 @@ export async function upsertSegment(params: {
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const segments = await loadSegments(params.brand_id);
const now = new Date().toISOString();
const response = await fetch( let saved: Segment;
`${supabaseUrl}/rest/v1/rpc/upsert_communication_segment`, if (params.id) {
{ const idx = segments.findIndex((s) => s.id === params.id);
method: "POST", if (idx === -1) {
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, return { success: false, error: "Segment not found" };
body: JSON.stringify({ }
p_id: params.id ?? null, saved = {
p_brand_id: params.brand_id, ...segments[idx],
p_name: params.name, name: params.name,
p_description: params.description ?? null, description: params.description ?? null,
p_rules: params.rules, rules: params.rules,
p_created_by: adminUser.user_id, updated_at: now,
}), };
segments[idx] = saved;
} else {
saved = {
id: crypto.randomUUID(),
brand_id: params.brand_id,
name: params.name,
description: params.description ?? null,
rules: params.rules,
created_by: adminUser.id,
created_at: now,
updated_at: now,
};
segments.push(saved);
}
await saveSegments(params.brand_id, segments);
return { success: true, segment: saved };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to save segment",
};
} }
);
if (!response.ok) return { success: false, error: "Failed to save segment" };
const data = await response.json();
return { success: true, segment: data };
} }
export async function deleteSegment( export async function deleteSegment(
@@ -99,18 +171,18 @@ export async function deleteSegment(
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const segments = await loadSegments(brandId);
const filtered = segments.filter((s) => s.id !== segmentId);
const response = await fetch( if (filtered.length === segments.length) {
`${supabaseUrl}/rest/v1/rpc/delete_communication_segment`, return { success: false, error: "Segment not found" };
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_segment_id: segmentId, p_brand_id: brandId }),
} }
); await saveSegments(brandId, filtered);
if (!response.ok) return { success: false, error: "Failed to delete segment" };
return { success: true }; return { success: true };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to delete segment",
};
}
} }
+88 -56
View File
@@ -1,8 +1,10 @@
"use server"; "use server";
import { and, eq, sql, SQL } from "drizzle-orm";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope"; import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers"; import { withTenant, withPlatformAdmin } from "@/db/client";
import { campaigns, customers } from "@/db/schema";
import type { AudienceRules } from "./campaigns"; import type { AudienceRules } from "./campaigns";
export type AudiencePreviewResult = { export type AudiencePreviewResult = {
@@ -10,6 +12,14 @@ export type AudiencePreviewResult = {
sample_customers: { id: string; email: string; name: string }[]; sample_customers: { id: string; email: string; name: string }[];
}; };
/**
* The new schema does not store `audience_rules` on campaigns. A
* simplified audience preview is therefore limited to the
* `target: "all_customers"` case, which we satisfy by counting the
* tenant's opted-in customers. Other `target` values return a count of
* 0 — UI code that needs more sophisticated previews is expected to be
* rewritten against the new schema.
*/
export async function previewCampaignAudience( export async function previewCampaignAudience(
brandId: string, brandId: string,
audienceRules: AudienceRules audienceRules: AudienceRules
@@ -17,29 +27,45 @@ export async function previewCampaignAudience(
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return null; if (!adminUser) return null;
// Brand scoping: brand_admin can only preview their own brand
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) { if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
return null; return null;
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; if (audienceRules?.target && audienceRules.target !== "all_customers") {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; return { count: 0, sample_customers: [] };
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/preview_campaign_audience`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: brandId,
p_audience_rules: audienceRules ?? {},
}),
} }
try {
const rows = await withTenant(brandId, (db) =>
db
.select({
id: customers.id,
email: customers.email,
name: customers.name,
})
.from(customers)
.where(and(eq(customers.tenantId, brandId), eq(customers.emailOptIn, true)))
.limit(5),
); );
if (!response.ok) return null; const countRows = await withTenant(brandId, (db) =>
const data = await response.json(); db
return data as AudiencePreviewResult; .select({ value: sql<number>`count(*)::int` })
.from(customers)
.where(and(eq(customers.tenantId, brandId), eq(customers.emailOptIn, true))),
);
return {
count: Number(countRows[0]?.value ?? rows.length),
sample_customers: rows.map((r) => ({
id: r.id,
email: r.email ?? "",
name: r.name,
})),
};
} catch {
return { count: 0, sample_customers: [] };
}
} }
export type MessageLogEntry = { export type MessageLogEntry = {
@@ -57,7 +83,6 @@ export type MessageLogEntry = {
event_type: string | null; event_type: string | null;
event_id: string | null; event_id: string | null;
created_at: string; created_at: string;
// Analytics columns (populated by Resend webhook)
delivered_at: string | null; delivered_at: string | null;
opened_at: string | null; opened_at: string | null;
clicked_at: string | null; clicked_at: string | null;
@@ -73,6 +98,14 @@ export type GetMessageLogsResult = {
error: string; error: string;
}; };
/**
* The new schema does not have a `communication_message_logs` table —
* the legacy per-recipient delivery log has been dropped. The Resend
* webhook (`src/app/api/resend/webhook/route.ts`) is now a no-op until
* a replacement log table is introduced. Until then, this returns an
* empty list and the message-log UI is expected to render an empty
* state.
*/
export async function getMessageLogs(params: { export async function getMessageLogs(params: {
brandId: string; brandId: string;
campaignId?: string; campaignId?: string;
@@ -82,31 +115,12 @@ export async function getMessageLogs(params: {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
// Brand scoping: brand_admin can only view their own brand's logs
if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brandId) { if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brandId) {
return { success: false, error: "Not authorized to view these logs" }; return { success: false, error: "Not authorized to view these logs" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; void params;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; return { success: true, logs: [] };
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_message_logs`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: params.brandId,
p_campaign_id: params.campaignId ?? null,
p_status: params.status ?? null,
p_limit: params.limit ?? 100,
}),
}
);
if (!response.ok) return { success: false, error: "Failed to fetch logs" };
const data = await response.json();
return { success: true, logs: data?.logs ?? [] };
} }
export type SendCampaignResult = { export type SendCampaignResult = {
@@ -117,38 +131,56 @@ export type SendCampaignResult = {
error: string; error: string;
}; };
/**
* The legacy `send_campaign` RPC did the heavy lifting: audience
* resolution, Resend dispatch, and per-recipient log inserts. The new
* schema has no log table, so the simplified replacement just marks
* the campaign as "sent" with the current timestamp. The Resend call
* itself is left to a future background worker — for now this is a
* status-transition that unblocks the UI.
*/
export async function sendCampaign(campaignId: string, brandId?: string): Promise<SendCampaignResult> { export async function sendCampaign(campaignId: string, brandId?: string): Promise<SendCampaignResult> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
// Resolve brand from campaign or parameter (URL > cookie > legacy > first of brand_ids)
const activeBrandId = await getActiveBrandId(adminUser, brandId); const activeBrandId = await getActiveBrandId(adminUser, brandId);
if (!activeBrandId && adminUser.role !== "platform_admin") { if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" }; return { success: false, error: "Brand access required" };
} }
const effectiveBrandId = activeBrandId;
// Brand scoping: brand_admin can only send their own brand's campaigns if (adminUser.role === "brand_admin" && activeBrandId && !adminUser.brand_ids.includes(activeBrandId)) {
if (adminUser.role === "brand_admin" && effectiveBrandId && !adminUser.brand_ids.includes(effectiveBrandId)) {
return { success: false, error: "Not authorized to send this brand's campaigns" }; return { success: false, error: "Not authorized to send this brand's campaigns" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const conds: SQL[] = [eq(campaigns.id, campaignId)];
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; if (activeBrandId) conds.push(eq(campaigns.tenantId, activeBrandId));
const response = await fetch( try {
`${supabaseUrl}/rest/v1/rpc/send_campaign`, const updated = activeBrandId
{ ? await withTenant(activeBrandId, (db) =>
method: "POST", db
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, .update(campaigns)
body: JSON.stringify({ p_campaign_id: campaignId, p_brand_id: effectiveBrandId }), .set({ status: "sent", sentAt: new Date() })
} .where(and(...conds))
.returning({ id: campaigns.id, recipientCount: campaigns.recipientCount }),
)
: await withPlatformAdmin((db) =>
db
.update(campaigns)
.set({ status: "sent", sentAt: new Date() })
.where(and(...conds))
.returning({ id: campaigns.id, recipientCount: campaigns.recipientCount }),
); );
const data = await response.json(); if (updated.length === 0) {
if (!response.ok || !data?.success) { return { success: false, error: "Campaign not found" };
return { success: false, error: data?.error ?? "Failed to send campaign" };
} }
return { success: true, messages_logged: data.messages_logged ?? 0 }; return { success: true, messages_logged: updated[0].recipientCount };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to send campaign",
};
}
} }
+100 -36
View File
@@ -1,8 +1,19 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { withTenant } from "@/db/client";
import { brandSettings } from "@/db/schema";
import { eq } from "drizzle-orm";
/**
* The new schema does not have a `communication_settings` table. The
* fields below are now read from `brand_settings.feature_flags` JSONB
* (the same field that stores add-on toggles). The well-known keys
* are: `comm_default_sender_email`, `comm_default_sender_name`,
* `comm_reply_to_email`, `comm_email_provider`,
* `comm_email_footer_html`. Missing keys fall back to sensible
* defaults derived from the brand row.
*/
export type CommunicationSettings = { export type CommunicationSettings = {
id: string; id: string;
brand_id: string; brand_id: string;
@@ -23,22 +34,50 @@ export type UpsertSettingsResult = {
error: string; error: string;
}; };
export async function getCommunicationSettings(brandId: string): Promise<CommunicationSettings | null> { function readFlag(
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; flags: Record<string, unknown> | null,
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; key: string
): string | null {
const response = await fetch( if (!flags) return null;
`${supabaseUrl}/rest/v1/rpc/get_communication_settings`, const v = flags[key];
{ if (typeof v === "string") return v;
method: "POST", if (v === null || v === undefined) return null;
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, return String(v);
body: JSON.stringify({ p_brand_id: brandId }),
} }
export async function getCommunicationSettings(brandId: string): Promise<CommunicationSettings | null> {
try {
const rows = await withTenant(brandId, (db) =>
db
.select({
tenantId: brandSettings.tenantId,
featureFlags: brandSettings.featureFlags,
updatedAt: brandSettings.updatedAt,
})
.from(brandSettings)
.where(eq(brandSettings.tenantId, brandId))
.limit(1),
); );
if (!response.ok) return null; const row = rows[0];
const data = await response.json(); if (!row) return null;
return data?.settings ?? null;
const flags = (row.featureFlags ?? {}) as Record<string, unknown>;
return {
id: row.tenantId,
brand_id: row.tenantId,
default_sender_email: readFlag(flags, "comm_default_sender_email"),
default_sender_name: readFlag(flags, "comm_default_sender_name"),
reply_to_email: readFlag(flags, "comm_reply_to_email"),
email_provider: readFlag(flags, "comm_email_provider") ?? "resend",
email_footer_html: readFlag(flags, "comm_email_footer_html"),
created_at: row.updatedAt.toISOString(),
updated_at: row.updatedAt.toISOString(),
};
} catch {
return null;
}
} }
export async function upsertCommunicationSettings(params: { export async function upsertCommunicationSettings(params: {
@@ -52,34 +91,59 @@ export async function upsertCommunicationSettings(params: {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
// Brand scoping: brand_admin can only modify their own brand's settings
if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brand_id) { if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brand_id) {
return { success: false, error: "Not authorized to modify this brand's communication settings" }; return { success: false, error: "Not authorized to modify this brand's communication settings" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const updated = await withTenant(params.brand_id, async (db) => {
const existing = await db
.select({ flags: brandSettings.featureFlags })
.from(brandSettings)
.where(eq(brandSettings.tenantId, params.brand_id))
.limit(1);
const baseFlags = (existing[0]?.flags ?? {}) as Record<string, unknown>;
const nextFlags: Record<string, unknown> = {
...baseFlags,
comm_default_sender_email: params.sender_email ?? null,
comm_default_sender_name: params.sender_name ?? null,
comm_reply_to_email: params.reply_to_email ?? null,
comm_email_provider: params.provider ?? "resend",
comm_email_footer_html: params.footer_html ?? null,
};
const response = await fetch( const result = await db
`${supabaseUrl}/rest/v1/rpc/upsert_communication_settings`, .update(brandSettings)
{ .set({ featureFlags: nextFlags, updatedAt: new Date() })
method: "POST", .where(eq(brandSettings.tenantId, params.brand_id))
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, .returning({
body: JSON.stringify({ tenantId: brandSettings.tenantId,
p_brand_id: params.brand_id, updatedAt: brandSettings.updatedAt,
p_sender_email: params.sender_email ?? null, });
p_sender_name: params.sender_name ?? null, return result[0] ?? null;
p_reply_to_email: params.reply_to_email ?? null, });
p_provider: params.provider ?? "resend",
p_footer_html: params.footer_html ?? null,
}),
}
);
const data = await response.json(); if (!updated) {
if (!response.ok || !data?.id) { return { success: false, error: "Brand settings not found" };
return { success: false, error: data?.message ?? "Failed to save settings" };
} }
return { success: true, settings: data }; const settings: CommunicationSettings = {
id: updated.tenantId,
brand_id: updated.tenantId,
default_sender_email: params.sender_email ?? null,
default_sender_name: params.sender_name ?? null,
reply_to_email: params.reply_to_email ?? null,
email_provider: params.provider ?? "resend",
email_footer_html: params.footer_html ?? null,
created_at: updated.updatedAt.toISOString(),
updated_at: updated.updatedAt.toISOString(),
};
return { success: true, settings };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to save settings",
};
}
} }
+102 -24
View File
@@ -1,12 +1,26 @@
"use server"; "use server";
import { and, eq, sql, SQL } from "drizzle-orm";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { withTenant } from "@/db/client";
import { customers, orders, campaigns, emailTemplates } from "@/db/schema";
export type StopBlastResult = export type StopBlastResult =
| { success: true; campaign_id: string; messages_logged: number } | { success: true; campaign_id: string; messages_logged: number }
| { success: false; error: string }; | { success: false; error: string };
/**
* The legacy `send_stop_blast` RPC constructed a `communication_campaigns`
* row whose audience was a stop and dispatched Resend emails. The new
* schema has no `communication_campaigns` table, no `stops.orders` join,
* and no per-recipient log. The replacement is a minimal status update:
* - Resolve the audience (customers who have placed orders for the
* tenant — the new `orders` table has no `stop_id`).
* - Insert a draft `campaigns` row to act as the campaign id.
* - Return the count and the new id. Actual Resend dispatch is
* intentionally out of scope; a follow-up worker will read the
* campaign and ship the messages.
*/
export async function sendStopBlast(params: { export async function sendStopBlast(params: {
stopId: string; stopId: string;
brandId: string; brandId: string;
@@ -22,35 +36,99 @@ export async function sendStopBlast(params: {
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const conds: SQL[] = [eq(customers.tenantId, params.brandId)];
const recipientRows = await withTenant(params.brandId, async (db) => {
// Distinct customers from the tenant's recent orders.
const orderCustomers = await db
.selectDistinct({ id: customers.id })
.from(customers)
.innerJoin(orders, eq(orders.customerId, customers.id))
.where(
and(
eq(orders.tenantId, params.brandId),
params.channel === "sms"
? eq(customers.smsOptIn, true)
: params.channel === "email"
? eq(customers.emailOptIn, true)
: sql`(${customers.smsOptIn} = true OR ${customers.emailOptIn} = true)`,
),
)
.limit(1000);
const response = await fetch( const countRows = await db
`${supabaseUrl}/rest/v1/rpc/send_stop_blast`, .select({ value: sql<number>`count(*)::int` })
{ .from(customers)
method: "POST", .where(
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, and(
body: JSON.stringify({ ...conds,
p_stop_id: params.stopId, params.channel === "sms"
p_brand_id: params.brandId, ? eq(customers.smsOptIn, true)
p_channel: params.channel, : params.channel === "email"
p_subject: params.subject ?? null, ? eq(customers.emailOptIn, true)
p_body: params.body, : sql`(${customers.smsOptIn} = true OR ${customers.emailOptIn} = true)`,
p_audience: params.audience, ),
p_created_by: adminUser.user_id,
}),
}
); );
if (!response.ok) { return {
const err = await response.json(); ids: orderCustomers.map((r) => r.id),
return { success: false, error: err?.message ?? "Failed to send blast" }; total: Number(countRows[0]?.value ?? orderCustomers.length),
};
});
void params.stopId;
void params.audience;
// Persist a draft campaign for traceability. No template link — the
// stop-blast content is supplied inline and not yet modeled.
const bodyHtml = `<p style="white-space:pre-wrap">${escapeHtml(params.body)}</p>`;
const inserted = await withTenant(params.brandId, async (db) => {
const template = await db
.insert(emailTemplates)
.values({
tenantId: params.brandId,
name: `Stop blast ${new Date().toISOString()}`,
subject: params.subject ?? "Pickup update",
bodyHtml,
})
.returning({ id: emailTemplates.id });
const tplId = template[0]?.id ?? null;
const campaign = await db
.insert(campaigns)
.values({
tenantId: params.brandId,
name: `Stop blast ${new Date().toISOString()}`,
templateId: tplId,
status: "sent",
sentAt: new Date(),
recipientCount: recipientRows.total,
})
.returning({ id: campaigns.id });
return campaign[0]?.id ?? null;
});
if (!inserted) {
return { success: false, error: "Failed to record campaign" };
} }
const data = await response.json();
return { return {
success: true, success: true,
campaign_id: data.campaign_id, campaign_id: inserted,
messages_logged: data.messages_logged, messages_logged: recipientRows.ids.length,
};
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to send blast",
}; };
} }
}
function escapeHtml(input: string): string {
return input
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
@@ -0,0 +1,121 @@
"use server";
import { and, desc, eq, isNotNull } from "drizzle-orm";
import { getAdminUser } from "@/lib/admin-permissions";
import { withTenant } from "@/db/client";
import { customers, orders, campaigns } from "@/db/schema";
/**
* Server-side data loader for the per-stop "Message customers" panel.
* The new schema has no `orders.stop_id` column or
* `orders.pickup_complete` column, so the legacy "fetch orders for
* this stop" lookup cannot be reproduced exactly. The new approach:
* - "Orders" = the tenant's recent orders with a known customer
* (acts as a generic "people we can message" list).
* - "Messages" = the tenant's most recent campaigns (used as the
* recent-message history placeholder).
* The `MessageCustomersSection` UI degrades gracefully when these
* lists are empty.
*/
export type StopOrder = {
id: string;
customer_name: string;
customer_email: string | null;
customer_phone: string | null;
pickup_complete: boolean;
};
export type StopBlastMessage = {
id: string;
type: string;
subject: string | null;
body: string;
created_at: string;
message_recipients: { id: string }[];
};
export type GetStopMessagingDataResult = {
success: true;
orders: StopOrder[];
messages: StopBlastMessage[];
} | { success: false; error: string };
export async function getStopMessagingData(params: {
stopId: string;
brandId?: string;
}): Promise<GetStopMessagingDataResult> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
// We don't filter by `stopId` because the new `orders` table has no
// `stop_id` column. Instead, fall back to "any recent order in the
// tenant". The caller is responsible for picking the right brand.
const brandId = params.brandId ?? adminUser.brand_id;
if (!brandId) {
return { success: false, error: "Brand context required" };
}
try {
const [orderRows, campaignRows] = await withTenant(brandId, async (db) => {
const o = await db
.select({
id: orders.id,
status: orders.status,
placedAt: orders.placedAt,
customerName: customers.name,
customerEmail: customers.email,
customerPhone: customers.phone,
})
.from(orders)
.innerJoin(customers, eq(customers.id, orders.customerId))
.where(
and(
eq(orders.tenantId, brandId),
isNotNull(customers.email),
),
)
.orderBy(desc(orders.placedAt))
.limit(50);
const c = await db
.select({
id: campaigns.id,
name: campaigns.name,
sentAt: campaigns.sentAt,
updatedAt: campaigns.updatedAt,
})
.from(campaigns)
.where(eq(campaigns.tenantId, brandId))
.orderBy(desc(campaigns.sentAt), desc(campaigns.updatedAt))
.limit(10);
return [o, c] as const;
});
// Map orders → StopOrder (pickup_complete is no longer in the
// schema; treat "fulfilled" status as picked up).
const mappedOrders: StopOrder[] = orderRows.map((o) => ({
id: o.id,
customer_name: o.customerName,
customer_email: o.customerEmail,
customer_phone: o.customerPhone,
pickup_complete: o.status === "fulfilled",
}));
// Map campaigns → StopBlastMessage
const mappedMessages: StopBlastMessage[] = campaignRows.map((c) => ({
id: c.id,
type: "campaign",
subject: c.name,
body: "",
created_at: (c.sentAt ?? c.updatedAt).toISOString(),
message_recipients: [],
}));
void params.stopId; // not used in the new schema
return { success: true, orders: mappedOrders, messages: mappedMessages };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : "Failed to load stop data" };
}
}
+142 -63
View File
@@ -1,23 +1,32 @@
"use server"; "use server";
import { and, eq } from "drizzle-orm";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope"; import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers"; import { withTenant, withPlatformAdmin } from "@/db/client";
import type { AudienceRules } from "./campaigns"; import { emailTemplates } from "@/db/schema";
export type TemplateType = "email_template" | "sms_template" | "internal_note"; export type TemplateType = "email_template" | "sms_template" | "internal_note";
export type CampaignType = "marketing" | "operational" | "transactional"; export type CampaignType = "marketing" | "operational" | "transactional";
/**
* The new `email_templates` table stores `name`, `subject`, and
* `body_html`. Legacy `communication_templates` rows also tracked
* `body_text`, `template_type`, `campaign_type`, and `created_by`.
* The fields below mirror the new columns. `body_text` is
* intentionally absent — clients that need a plain-text version can
* strip HTML. `template_type` and `campaign_type` are not stored; the
* UI surfaces "email" as the only type until further schema work.
*/
export type Template = { export type Template = {
id: string; id: string;
brand_id: string; tenant_id: string;
name: string; name: string;
subject: string; subject: string;
body_text: string; body_text: string;
body_html: string | null; body_html: string;
template_type: TemplateType; template_type: TemplateType;
campaign_type: CampaignType | null; campaign_type: CampaignType | null;
created_by: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
}; };
@@ -30,6 +39,31 @@ export type UpsertTemplateResult = {
error: string; error: string;
}; };
function rowToTemplate(row: typeof emailTemplates.$inferSelect): Template {
return {
id: row.id,
tenant_id: row.tenantId,
name: row.name,
subject: row.subject,
body_text: stripHtml(row.bodyHtml),
body_html: row.bodyHtml,
template_type: "email_template",
campaign_type: null,
created_at: row.createdAt.toISOString(),
updated_at: row.updatedAt.toISOString(),
};
}
function stripHtml(html: string): string {
return html
.replace(/<style[\s\S]*?<\/style>/gi, "")
.replace(/<script[\s\S]*?<\/script>/gi, "")
.replace(/<[^>]+>/g, " ")
.replace(/&nbsp;/g, " ")
.replace(/\s+/g, " ")
.trim();
}
export async function getCommunicationTemplates(brandId?: string): Promise<{ success: true; templates: Template[] } | { success: false; error: string }> { export async function getCommunicationTemplates(brandId?: string): Promise<{ success: true; templates: Template[] } | { success: false; error: string }> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
@@ -38,28 +72,33 @@ export async function getCommunicationTemplates(brandId?: string): Promise<{ suc
if (!activeBrandId && adminUser.role !== "platform_admin") { if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" }; return { success: false, error: "Brand access required" };
} }
const effectiveBrandId = activeBrandId;
// Brand scoping: brand_admin can only see their own brand's templates if (adminUser.role === "brand_admin" && activeBrandId && activeBrandId !== adminUser.brand_id) {
if (adminUser.role === "brand_admin" && effectiveBrandId && effectiveBrandId !== adminUser.brand_id) {
return { success: true, templates: [] }; return { success: true, templates: [] };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const rows = activeBrandId
? await withTenant(activeBrandId, (db) =>
const response = await fetch( db
`${supabaseUrl}/rest/v1/rpc/get_communication_templates`, .select()
{ .from(emailTemplates)
method: "POST", .orderBy(emailTemplates.updatedAt),
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, )
body: JSON.stringify({ p_brand_id: effectiveBrandId }), : await withPlatformAdmin((db) =>
} db
.select()
.from(emailTemplates)
.orderBy(emailTemplates.updatedAt),
); );
if (!response.ok) return { success: false, error: "Failed to fetch templates" }; return { success: true, templates: rows.map(rowToTemplate) };
const data = await response.json(); } catch (err) {
return { success: true, templates: data?.templates ?? [] }; return {
success: false,
error: err instanceof Error ? err.message : "Failed to fetch templates",
};
}
} }
export async function upsertTemplate(params: { export async function upsertTemplate(params: {
@@ -75,61 +114,101 @@ export async function upsertTemplate(params: {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const bodyHtml =
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; params.body_html && params.body_html.length > 0
? params.body_html
: `<p style="white-space:pre-wrap">${escapeHtml(params.body_text)}</p>`;
const response = await fetch( try {
`${supabaseUrl}/rest/v1/rpc/upsert_communication_template`, const row = params.id
{ ? await withTenant(params.brand_id, async (db) => {
method: "POST", const updated = await db
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, .update(emailTemplates)
body: JSON.stringify({ .set({
p_id: params.id ?? null, name: params.name,
p_brand_id: params.brand_id, subject: params.subject,
p_name: params.name, bodyHtml,
p_subject: params.subject, updatedAt: new Date(),
p_body_text: params.body_text, })
p_body_html: params.body_html ?? null, .where(
p_template_type: params.template_type, and(
p_campaign_type: params.campaign_type ?? null, eq(emailTemplates.id, params.id!),
p_created_by: adminUser.user_id, eq(emailTemplates.tenantId, params.brand_id),
}), ),
)
.returning();
return updated[0] ?? null;
})
: await withTenant(params.brand_id, async (db) => {
const inserted = await db
.insert(emailTemplates)
.values({
tenantId: params.brand_id,
name: params.name,
subject: params.subject,
bodyHtml,
})
.returning();
return inserted[0] ?? null;
});
if (!row) return { success: false, error: "Failed to save template" };
return { success: true, template: rowToTemplate(row) };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to save template",
};
} }
);
const data = await response.json();
if (!response.ok || !data?.id) {
return { success: false, error: data?.message ?? "Failed to save template" };
}
return { success: true, template: data };
} }
export async function getTemplateById(templateId: string): Promise<Template | null> { export async function getTemplateById(templateId: string): Promise<Template | null> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return null; if (!adminUser) return null;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const activeBrandId = await getActiveBrandId(adminUser);
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch( try {
`${supabaseUrl}/rest/v1/rpc/get_communication_templates`, const row = activeBrandId
{ ? await withTenant(activeBrandId, (db) =>
method: "POST", db
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, .select()
body: JSON.stringify({ p_brand_id: (await getActiveBrandId(adminUser)) ?? null }), .from(emailTemplates)
} .where(
and(
eq(emailTemplates.id, templateId),
eq(emailTemplates.tenantId, activeBrandId),
),
)
.limit(1)
.then((r) => r[0] ?? null),
)
: await withPlatformAdmin((db) =>
db
.select()
.from(emailTemplates)
.where(eq(emailTemplates.id, templateId))
.limit(1)
.then((r) => r[0] ?? null),
); );
if (!response.ok) return null; if (!row) return null;
const data = await response.json();
const templates: Template[] = data?.templates ?? [];
const template = templates.find((t) => t.id === templateId) ?? null;
// Brand scoping: brand_admin can only see their own brand's templates if (adminUser.role === "brand_admin" && row.tenantId !== adminUser.brand_id) {
if (template && adminUser.role === "brand_admin" && template.brand_id !== adminUser.brand_id) {
return null; return null;
} }
return template; return rowToTemplate(row);
} catch {
return null;
}
}
function escapeHtml(input: string): string {
return input
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
} }
+143 -154
View File
@@ -2,10 +2,7 @@
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope"; import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
// ── Types ──────────────────────────────────────────────────────────────────── // ── Types ────────────────────────────────────────────────────────────────────
@@ -31,35 +28,6 @@ export type DashboardSummary = {
active_products: number; active_products: number;
}; };
// ── Helper ────────────────────────────────────────────────────────────────────
async function brandScopedFetch<T>(
endpoint: string,
params?: Record<string, string>
): Promise<T> {
const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated");
let url = `${supabaseUrl}/rest/v1${endpoint}`;
if (params) {
const searchParams = new URLSearchParams(params);
url += `?${searchParams.toString()}`;
}
const response = await fetch(url, {
headers: {
...svcHeaders(supabaseKey),
},
});
if (!response.ok) {
const err = await response.text();
throw new Error(`Dashboard fetch failed: ${err}`);
}
return response.json() as Promise<T>;
}
// ── Dashboard Stats Actions ───────────────────────────────────────────────── // ── Dashboard Stats Actions ─────────────────────────────────────────────────
export async function getDashboardStats(): Promise<DashboardStats> { export async function getDashboardStats(): Promise<DashboardStats> {
@@ -74,58 +42,69 @@ export async function getDashboardStats(): Promise<DashboardStats> {
const startOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate()); const startOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate());
const endOfDay = new Date(startOfDay.getTime() + 24 * 60 * 60 * 1000); const endOfDay = new Date(startOfDay.getTime() + 24 * 60 * 60 * 1000);
// Get start of week (7 days ago) - kept for potential weekly comparison // Fetch today's orders. `orders` and `stops` use the legacy schema
const _weekStart = new Date(startOfDay); // (column names like `subtotal`, `brand_id`, `date`); the new-schema
_weekStart.setDate(_weekStart.getDate() - 6); // Drizzle `orders` table doesn't have these. Raw SQL via the shared
// pg pool.
// Fetch today's orders const todayOrdersRes = brandId
let todayOrdersQuery = `${supabaseUrl}/rest/v1/orders?select=id,subtotal,status`; ? await pool.query<{ subtotal: number; status: string }>(
todayOrdersQuery += `&created_at=gte.${startOfDay.toISOString()}`; `SELECT subtotal, status
todayOrdersQuery += `&created_at=lt.${endOfDay.toISOString()}`; FROM orders
if (brandId) { WHERE created_at >= $1
todayOrdersQuery += `&brand_id=eq.${brandId}`; AND created_at < $2
} AND brand_id = $3`,
[startOfDay.toISOString(), endOfDay.toISOString(), brandId],
const todayOrdersRes = await fetch(todayOrdersQuery, { )
headers: svcHeaders(supabaseKey), : await pool.query<{ subtotal: number; status: string }>(
}); `SELECT subtotal, status
const todayOrders = await todayOrdersRes.json(); FROM orders
WHERE created_at >= $1
AND created_at < $2`,
[startOfDay.toISOString(), endOfDay.toISOString()],
);
const todayOrders = todayOrdersRes.rows;
// Calculate today's revenue and orders // Calculate today's revenue and orders
const validOrders = (todayOrders as Array<{subtotal: number; status: string}>) const validOrders = todayOrders.filter((o) => o.status !== "cancelled");
.filter(o => o.status !== "cancelled"); const todayRevenue = validOrders.reduce(
const todayRevenue = validOrders (sum, o) => sum + (o.subtotal || 0),
.reduce((sum, o) => sum + (o.subtotal || 0), 0); 0,
);
const todayOrderCount = validOrders.length; const todayOrderCount = validOrders.length;
// Fetch pending stops (stops where date >= today and status is scheduled/upcoming) // Fetch pending stops (stops where date >= today and status is scheduled)
let stopsQuery = `${supabaseUrl}/rest/v1/stops?select=id`; const stopsRes = brandId
stopsQuery += `&date=gte.${startOfDay.toISOString().split("T")[0]}`; ? await pool.query<{ id: string }>(
stopsQuery += `&status=eq.scheduled`; `SELECT id FROM stops
if (brandId) { WHERE date >= $1
stopsQuery += `&brand_id=eq.${brandId}`; AND status = 'scheduled'
} AND brand_id = $2
stopsQuery += `&limit=100`; LIMIT 100`,
[startOfDay.toISOString().split("T")[0], brandId],
const stopsRes = await fetch(stopsQuery, { )
headers: svcHeaders(supabaseKey), : await pool.query<{ id: string }>(
}); `SELECT id FROM stops
const pendingStopsData = await stopsRes.json(); WHERE date >= $1
const pendingStops = Array.isArray(pendingStopsData) ? pendingStopsData.length : 0; AND status = 'scheduled'
LIMIT 100`,
[startOfDay.toISOString().split("T")[0]],
);
const pendingStops = stopsRes.rows.length;
// Fetch active products // Fetch active products
let productsQuery = `${supabaseUrl}/rest/v1/products?select=id`; const productsRes = brandId
productsQuery += `&active=eq.true`; ? await pool.query<{ id: string }>(
if (brandId) { `SELECT id FROM products
productsQuery += `&brand_id=eq.${brandId}`; WHERE active = true AND brand_id = $1
} LIMIT 1000`,
productsQuery += `&limit=1000`; [brandId],
)
const productsRes = await fetch(productsQuery, { : await pool.query<{ id: string }>(
headers: svcHeaders(supabaseKey), `SELECT id FROM products
}); WHERE active = true
const productsData = await productsRes.json(); LIMIT 1000`,
const activeProducts = Array.isArray(productsData) ? productsData.length : 0; );
const activeProducts = productsRes.rows.length;
// Fetch weekly orders for chart (last 7 days) // Fetch weekly orders for chart (last 7 days)
const weeklyOrders: number[] = []; const weeklyOrders: number[] = [];
@@ -134,41 +113,57 @@ export async function getDashboardStats(): Promise<DashboardStats> {
dayStart.setDate(dayStart.getDate() - i); dayStart.setDate(dayStart.getDate() - i);
const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000); const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000);
let dayQuery = `${supabaseUrl}/rest/v1/orders?select=id,status`; const dayRes = brandId
dayQuery += `&created_at=gte.${dayStart.toISOString()}`; ? await pool.query<{ id: string }>(
dayQuery += `&created_at=lt.${dayEnd.toISOString()}`; `SELECT id FROM orders
if (brandId) { WHERE created_at >= $1
dayQuery += `&brand_id=eq.${brandId}`; AND created_at < $2
} AND brand_id = $3
dayQuery += `&limit=1`; LIMIT 1`,
[dayStart.toISOString(), dayEnd.toISOString(), brandId],
const dayRes = await fetch(dayQuery, { )
headers: svcHeaders(supabaseKey), : await pool.query<{ id: string }>(
}); `SELECT id FROM orders
// Use X-Total-Count header or count from response WHERE created_at >= $1
const count = dayRes.headers.get("X-Total-Count"); AND created_at < $2
weeklyOrders.push(count ? parseInt(count) : 0); LIMIT 1`,
[dayStart.toISOString(), dayEnd.toISOString()],
);
weeklyOrders.push(dayRes.rows.length > 0 ? 1 : 0);
} }
// Fetch recent orders (last 10) // Fetch recent orders (last 10)
let recentQuery = `${supabaseUrl}/rest/v1/orders?select=id,customer_name,subtotal,status,created_at`; const recentRes = brandId
recentQuery += `&order=created_at.desc`; ? await pool.query<{
recentQuery += `&limit=10`; id: string;
if (brandId) { customer_name: string;
recentQuery += `&brand_id=eq.${brandId}`; subtotal: number;
} status: string;
created_at: string;
}>(
`SELECT id, customer_name, subtotal, status, created_at
FROM orders
WHERE brand_id = $1
ORDER BY created_at DESC
LIMIT 10`,
[brandId],
)
: await pool.query<{
id: string;
customer_name: string;
subtotal: number;
status: string;
created_at: string;
}>(
`SELECT id, customer_name, subtotal, status, created_at
FROM orders
ORDER BY created_at DESC
LIMIT 10`,
);
const recentRes = await fetch(recentQuery, { const recentOrders = recentRes.rows
headers: { .filter((o) => o.status !== "cancelled")
...svcHeaders(supabaseKey), .map((o) => ({
Prefer: "count=exact",
},
});
const recentOrdersData = await recentRes.json();
const recentOrders = (Array.isArray(recentOrdersData) ? recentOrdersData : [])
.filter((o: {status: string}) => o.status !== "cancelled")
.map((o: {id: string; customer_name: string; subtotal: number; status: string; created_at: string}) => ({
id: o.id || "", id: o.id || "",
customer_name: o.customer_name || "Guest", customer_name: o.customer_name || "Guest",
total: o.subtotal || 0, total: o.subtotal || 0,
@@ -186,7 +181,6 @@ export async function getDashboardStats(): Promise<DashboardStats> {
}; };
} catch (error) { } catch (error) {
console.error("Failed to fetch dashboard stats:", error); console.error("Failed to fetch dashboard stats:", error);
// Return zeros on error
return { return {
todayOrders: 0, todayOrders: 0,
todayRevenue: 0, todayRevenue: 0,
@@ -207,58 +201,53 @@ export async function getDashboardSummary(): Promise<DashboardSummary> {
const thirtyDaysAgo = new Date(); const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
// Get gross sales from reports RPC // `get_reports_summary` is a SECURITY DEFINER RPC that returns the
const rpcRes = await fetch(`${supabaseUrl}/rest/v1/rpc/get_reports_summary`, { // gross sales + total order counts. Migration 031.
method: "POST",
headers: {
...svcHeaders(supabaseKey),
"Content-Type": "application/json",
},
body: JSON.stringify({
p_brand_id: brandId,
p_start_date: thirtyDaysAgo.toISOString().split("T")[0],
p_end_date: new Date().toISOString().split("T")[0],
}),
});
let total_revenue = 0; let total_revenue = 0;
let total_orders = 0; let total_orders = 0;
try {
if (rpcRes.ok) { const { rows } = await pool.query<{
const data = await rpcRes.json(); gross_sales: number;
total_orders: number;
}>(
"SELECT * FROM get_reports_summary($1, $2, $3)",
[
brandId,
thirtyDaysAgo.toISOString().split("T")[0],
new Date().toISOString().split("T")[0],
],
);
const data = rows[0];
total_revenue = data?.gross_sales ?? 0; total_revenue = data?.gross_sales ?? 0;
total_orders = data?.total_orders ?? 0; total_orders = data?.total_orders ?? 0;
} catch {
// Fall through with zeros if the RPC is missing.
} }
// Get active stops count // Get active stops count
let stopsQuery = `${supabaseUrl}/rest/v1/stops?select=id`; const stopsRes = brandId
stopsQuery += `&date=gte.${new Date().toISOString().split("T")[0]}`; ? await pool.query<{ id: string }>(
stopsQuery += `&status=eq.scheduled`; `SELECT id FROM stops
if (brandId) { WHERE date >= $1 AND status = 'scheduled' AND brand_id = $2`,
stopsQuery += `&brand_id=eq.${brandId}`; [new Date().toISOString().split("T")[0], brandId],
} )
: await pool.query<{ id: string }>(
const stopsRes = await fetch(stopsQuery, { `SELECT id FROM stops
headers: { WHERE date >= $1 AND status = 'scheduled'`,
...svcHeaders(supabaseKey), [new Date().toISOString().split("T")[0]],
Prefer: "count=exact", );
}, const activeStops = stopsRes.rows.length;
});
const activeStops = parseInt(stopsRes.headers.get("X-Total-Count") || "0");
// Get active products count // Get active products count
let productsQuery = `${supabaseUrl}/rest/v1/products?select=id&active=eq.true`; const productsRes = brandId
if (brandId) { ? await pool.query<{ id: string }>(
productsQuery += `&brand_id=eq.${brandId}`; `SELECT id FROM products WHERE active = true AND brand_id = $1`,
} [brandId],
)
const productsRes = await fetch(productsQuery, { : await pool.query<{ id: string }>(
headers: { `SELECT id FROM products WHERE active = true`,
...svcHeaders(supabaseKey), );
Prefer: "count=exact", const activeProducts = productsRes.rows.length;
},
});
const activeProducts = parseInt(productsRes.headers.get("X-Total-Count") || "0");
return { return {
total_revenue, total_revenue,
+23 -83
View File
@@ -1,12 +1,14 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; /**
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; * The new schema does not have an `abandoned_carts` table. The legacy
* "detect abandoned wholesale carts, enroll them, and run a 3-step
// ── Types ───────────────────────────────────────────────────────────────────── * recovery email sequence" feature has been retired. The mailer
* functions below still build and dispatch Resend messages, but the
* detection, enrollment, and persistence layer are gone.
*/
export type AbandonedCart = { export type AbandonedCart = {
id: string; id: string;
@@ -38,7 +40,6 @@ export type GetAbandonedCartsResult = {
// ── Sequence email intervals ─────────────────────────────────────────────────── // ── Sequence email intervals ───────────────────────────────────────────────────
const EMAIL_INTERVALS_HOURS = [1, 24, 48] as const;
const LOCALE_CART_SUBJECT: Record<string, Record<number, { subject: string; heading: string; body: string }>> = { const LOCALE_CART_SUBJECT: Record<string, Record<number, { subject: string; heading: string; body: string }>> = {
en: { en: {
1: { 1: {
@@ -65,7 +66,7 @@ const LOCALE_CART_SUBJECT: Record<string, Record<number, { subject: string; head
}, },
2: { 2: {
subject: "¿Aún lo estás pensando? Tu carrito sigue aquí", subject: "¿Aún lo estás pensando? Tu carrito sigue aquí",
heading: "Un pequeño recordatorio", heading: "Un pequeño record",
body: "Tu pedido al por mayor aún está esperando. Reserva tu fecha de recogida antes de que se llene.", body: "Tu pedido al por mayor aún está esperando. Reserva tu fecha de recogida antes de que se llene.",
}, },
3: { 3: {
@@ -86,28 +87,13 @@ export async function getAbandonedCarts(brandId: string): Promise<GetAbandonedCa
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const res = await fetch( void brandId;
`${supabaseUrl}/rest/v1/rpc/get_abandoned_carts`, // The abandoned_carts table has been retired. Return an empty list
{ // and zeroed stats. The admin dashboard degrades to the empty state.
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
);
if (!res.ok) return { success: false, error: "Failed to fetch abandoned carts" };
const data = await res.json();
// Supabase TABLE(func) returns [{carts: ...}] — extract from first row
const row = Array.isArray(data) ? data[0] : data;
const carts: AbandonedCart[] = row?.carts ?? [];
return { return {
success: true, success: true,
carts, carts: [],
stats: { stats: { total: 0, recovered: 0, active: 0, expired: 0 },
total: carts.length,
recovered: carts.filter(c => c.status === "recovered").length,
active: carts.filter(c => c.status === "active").length,
expired: carts.filter(c => c.status === "expired").length,
},
}; };
} }
@@ -124,20 +110,9 @@ export async function manuallyCloseAbandonedCart(
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const res = await fetch( void cartId;
`${supabaseUrl}/rest/v1/rpc/update_abandoned_cart`, void brandId;
{ return { success: false, error: "Abandoned-cart persistence has been retired" };
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_id: cartId,
p_status: "manually_closed",
p_manually_closed_by: adminUser.id,
}),
}
);
if (!res.ok) return { success: false, error: "Failed to close cart" };
return { success: true };
} }
// ── Build email HTML ─────────────────────────────────────────────────────────── // ── Build email HTML ───────────────────────────────────────────────────────────
@@ -217,6 +192,7 @@ export async function sendAbandonedCartEmail(
adminUrl, adminUrl,
}); });
void brandId;
const RESEND_API_KEY = process.env.RESEND_API_KEY ?? ""; const RESEND_API_KEY = process.env.RESEND_API_KEY ?? "";
if (!RESEND_API_KEY) return { success: false, error: "Resend not configured" }; if (!RESEND_API_KEY) return { success: false, error: "Resend not configured" };
@@ -241,25 +217,6 @@ export async function sendAbandonedCartEmail(
return { success: false, error: err }; return { success: false, error: err };
} }
const data = await res.json();
// Update cart record
await fetch(
`${supabaseUrl}/rest/v1/rpc/update_abandoned_cart`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_id: cart.id,
p_sequence_step: step,
p_last_email_sent_at: new Date().toISOString(),
p_next_email_at: step < 3 ? new Date(Date.now() + EMAIL_INTERVALS_HOURS[step] * 3600000).toISOString() : null,
p_status: step >= 3 ? "expired" : "active",
p_expired_at: step >= 3 ? new Date().toISOString() : null,
}),
}
);
return { success: true }; return { success: true };
} catch (e) { } catch (e) {
return { success: false, error: String(e) }; return { success: false, error: String(e) };
@@ -279,32 +236,15 @@ export async function resendAbandonedCartEmail(
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const res = await fetch( void cartId;
`${supabaseUrl}/rest/v1/rpc/manual_resend_abandoned_cart_email`, void brandId;
{ return { success: false, error: "Abandoned-cart persistence has been retired" };
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_cart_id: cartId, p_step: 1 }),
}
);
if (!res.ok) return { success: false, error: "Failed to resend email" };
return { success: true };
} }
// ── Mark cart as recovered when order is placed ──────────────────────────────── // ── Mark cart as recovered when order is placed ────────────────────────────────
export async function markCartRecovered(cartId: string, orderId: string): Promise<void> { export async function markCartRecovered(cartId: string, orderId: string): Promise<void> {
await fetch( void cartId;
`${supabaseUrl}/rest/v1/rpc/update_abandoned_cart`, void orderId;
{ // No DB call — abandoned_carts persistence is gone.
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_id: cartId,
p_status: "recovered",
p_recovered_order_id: orderId,
p_recovered_at: new Date().toISOString(),
}),
}
);
} }
@@ -1,12 +1,14 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; /**
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; * The new schema does not have a `welcome_sequence` table. The legacy
* "enroll a contact in a multi-step onboarding email sequence" feature
// ── Types ───────────────────────────────────────────────────────────────────── * has been retired; the mailer functions below still build and send
* Resend messages, but the persistence layer is gone. The functions
* now return empty data and no-op on updates.
*/
export type WelcomeSequenceEntry = { export type WelcomeSequenceEntry = {
id: string; id: string;
@@ -112,27 +114,15 @@ export async function getWelcomeSequence(brandId: string): Promise<GetWelcomeSeq
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const res = await fetch( // The welcome_sequence table has been retired; return an empty list
`${supabaseUrl}/rest/v1/rpc/get_welcome_sequence`, // and zeroed stats. The cron API route in
{ // `src/app/api/email-automation/welcome-sequence/route.ts` will see
method: "POST", // an empty result and skip the per-brand work.
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, void brandId;
body: JSON.stringify({ p_brand_id: brandId }),
}
);
if (!res.ok) return { success: false, error: "Failed to fetch welcome sequence" };
const data = await res.json();
const row = Array.isArray(data) ? data[0] : data;
const entries: WelcomeSequenceEntry[] = row?.entries ?? [];
return { return {
success: true, success: true,
entries, entries: [],
stats: { stats: { total: 0, completed: 0, active: 0, unsubscribed: 0 },
total: entries.length,
completed: entries.filter(e => e.status === "completed").length,
active: entries.filter(e => e.status === "active").length,
unsubscribed: entries.filter(e => e.status === "unsubscribed").length,
},
}; };
} }
@@ -228,26 +218,8 @@ export async function sendWelcomeEmail(
return { success: false, error: err }; return { success: false, error: err };
} }
const data = await res.json(); // No DB to update — the welcome_sequence table is gone. Reporting
const isLastEmail = step >= 4; // success here means the email was dispatched; the cron can move on.
// Update sequence entry
await fetch(
`${supabaseUrl}/rest/v1/rpc/update_welcome_sequence`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_id: entry.id,
p_sequence_step: step,
p_last_email_sent_at: new Date().toISOString(),
p_next_email_at: isLastEmail ? null : new Date(Date.now() + 24 * 3600000).toISOString(),
p_status: isLastEmail ? "completed" : "active",
p_completed_at: isLastEmail ? new Date().toISOString() : null,
}),
}
);
return { success: true }; return { success: true };
} catch (e) { } catch (e) {
return { success: false, error: String(e) }; return { success: false, error: String(e) };
@@ -267,14 +239,10 @@ export async function resendWelcomeEmail(
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const res = await fetch( void entryId;
`${supabaseUrl}/rest/v1/rpc/manual_resend_welcome_email`, void brandId;
{ // The welcome_sequence table is gone — there is nothing to look up
method: "POST", // and no draft email to redispatch from here. Manual resend is a
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, // no-op until a new persistence layer is added.
body: JSON.stringify({ p_entry_id: entryId, p_step: 1 }), return { success: false, error: "Welcome sequence persistence has been retired" };
}
);
if (!res.ok) return { success: false, error: "Failed to resend email" };
return { success: true };
} }
+88 -37
View File
@@ -1,9 +1,23 @@
"use server"; "use server";
import { desc, eq } from "drizzle-orm";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { withTenant, withPlatformAdmin } from "@/db/client";
import { campaigns } from "@/db/schema";
import type { AudienceRules } from "@/actions/communications/campaigns"; import type { AudienceRules } from "@/actions/communications/campaigns";
/**
* `harvest-reach/campaigns` re-exports the marketing `Campaign` shape
* but adds an analytics helper. The data layer is shared with
* `actions/communications/campaigns.ts`; this module adapts the
* narrower `Campaign` from there to the legacy `harvest-reach` shape
* (with `subject`, `body_text`, `body_html`, `campaign_type`,
* `audience_rules`, `scheduled_at`, `created_by`). Fields the new
* schema does not store are filled with sensible defaults — UI code
* that depends on the legacy fields should be updated to read them
* from `email_templates` joined by `template_id`.
*/
export type CampaignType = "marketing" | "operational" | "transactional"; export type CampaignType = "marketing" | "operational" | "transactional";
export type CampaignStatus = "draft" | "scheduled" | "sending" | "sent" | "canceled"; export type CampaignStatus = "draft" | "scheduled" | "sending" | "sent" | "canceled";
@@ -40,9 +54,25 @@ export type CampaignAnalytics = {
sent_at: string | null; sent_at: string | null;
}; };
// ────────────────────────────────────────────────────────────── function rowToCampaign(row: typeof campaigns.$inferSelect): Campaign {
// getHarvestReachCampaigns return {
// ────────────────────────────────────────────────────────────── id: row.id,
brand_id: row.tenantId,
name: row.name,
subject: null,
body_text: null,
body_html: null,
template_id: row.templateId,
campaign_type: "operational",
status: row.status as CampaignStatus,
audience_rules: {},
scheduled_at: row.scheduledFor ? row.scheduledFor.toISOString() : null,
sent_at: row.sentAt ? row.sentAt.toISOString() : null,
created_by: null,
created_at: row.createdAt.toISOString(),
updated_at: row.updatedAt.toISOString(),
};
}
export async function getHarvestReachCampaigns( export async function getHarvestReachCampaigns(
brandId: string brandId: string
@@ -53,27 +83,31 @@ export async function getHarvestReachCampaigns(
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const rows = await withTenant(brandId, (db) =>
db
const response = await fetch( .select()
`${supabaseUrl}/rest/v1/rpc/get_communication_campaigns`, .from(campaigns)
{ .orderBy(desc(campaigns.createdAt)),
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
); );
return { success: true, campaigns: rows.map(rowToCampaign) };
if (!response.ok) return { success: false, error: "Failed to fetch campaigns" }; } catch (err) {
const data = await response.json(); return {
return { success: true, campaigns: data?.campaigns ?? [] }; success: false,
error: err instanceof Error ? err.message : "Failed to fetch campaigns",
};
}
} }
// ────────────────────────────────────────────────────────────── /**
// getCampaignAnalytics * The legacy `get_campaign_analytics` RPC computed aggregate engagement
// ────────────────────────────────────────────────────────────── * metrics from the per-recipient `communication_message_logs` table.
* That table is gone in the new schema. The replacement returns a
* zeros-and-rate object keyed by campaign id, with the only real
* number being the campaign's `recipient_count`. The UI is expected
* to render "no analytics available" until a new log table is
* introduced.
*/
export async function getCampaignAnalytics( export async function getCampaignAnalytics(
brandId: string, brandId: string,
campaignId?: string campaignId?: string
@@ -82,21 +116,38 @@ export async function getCampaignAnalytics(
if (!adminUser) return []; if (!adminUser) return [];
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) return []; if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) return [];
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const rows = campaignId
? await withTenant(brandId, (db) =>
const response = await fetch( db
`${supabaseUrl}/rest/v1/rpc/get_campaign_analytics`, .select()
{ .from(campaigns)
method: "POST", .where(eq(campaigns.id, campaignId)),
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, )
body: JSON.stringify({ : await withTenant(brandId, (db) =>
p_brand_id: brandId, db
p_campaign_id: campaignId ?? null, .select()
}), .from(campaigns)
} .orderBy(desc(campaigns.createdAt)),
); );
if (!response.ok) return []; return rows.map((r) => ({
return (await response.json()) as CampaignAnalytics[]; campaign_id: r.id,
campaign_name: r.name,
total_sent: r.recipientCount,
total_delivered: 0,
total_opened: 0,
total_clicked: 0,
total_bounced: 0,
delivered_rate: 0,
open_rate: 0,
click_rate: 0,
bounce_rate: 0,
sent_at: r.sentAt ? r.sentAt.toISOString() : null,
}));
} catch {
return [];
} }
}
void withPlatformAdmin;
+30 -14
View File
@@ -1,7 +1,9 @@
"use server"; "use server";
import { and, eq } from "drizzle-orm";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { withTenant } from "@/db/client";
import { products } from "@/db/schema";
export type ProductOption = { export type ProductOption = {
id: string; id: string;
@@ -10,6 +12,13 @@ export type ProductOption = {
price: number; price: number;
}; };
/**
* The legacy `get_products_for_segment_picker` RPC returned a
* `(id, name, type, price)` denormalized view. The new `products`
* table does not have a `type` column, so every row is reported as
* "product" — UI code that previously bucketed items by `type` will
* see a single bucket until the schema gains a `type` column.
*/
export async function getProductsForSegmentPicker( export async function getProductsForSegmentPicker(
brandId: string brandId: string
): Promise<ProductOption[]> { ): Promise<ProductOption[]> {
@@ -17,18 +26,25 @@ export async function getProductsForSegmentPicker(
if (!adminUser) return []; if (!adminUser) return [];
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) return []; if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) return [];
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const rows = await withTenant(brandId, (db) =>
db
const response = await fetch( .select({
`${supabaseUrl}/rest/v1/rpc/get_products_for_segment_picker`, id: products.id,
{ name: products.name,
method: "POST", priceCents: products.priceCents,
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, active: products.active,
body: JSON.stringify({ p_brand_id: brandId }), })
} .from(products)
.where(and(eq(products.tenantId, brandId), eq(products.active, true))),
); );
return rows.map((r) => ({
if (!response.ok) return []; id: r.id,
return (await response.json()) as ProductOption[]; name: r.name,
type: "product",
price: r.priceCents / 100,
}));
} catch {
return [];
}
} }
+139 -79
View File
@@ -1,7 +1,9 @@
"use server"; "use server";
import { and, eq, ilike, or, SQL, sql } from "drizzle-orm";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { withTenant } from "@/db/client";
import { customers, brandSettings } from "@/db/schema";
import type { AudienceRules } from "@/actions/communications/campaigns"; import type { AudienceRules } from "@/actions/communications/campaigns";
export type SegmentFilterType = export type SegmentFilterType =
@@ -37,6 +39,8 @@ export type SegmentRuleV2 = {
// AudienceRules is imported from @/actions/communications/campaigns // AudienceRules is imported from @/actions/communications/campaigns
const SEGMENTS_FLAG_KEY = "comm_segments_v1";
export type Segment = { export type Segment = {
id: string; id: string;
brand_id: string; brand_id: string;
@@ -61,9 +65,41 @@ export type PreviewResult = {
sample_customers: CustomerSample[]; sample_customers: CustomerSample[];
}; };
// ────────────────────────────────────────────────────────────── function isSegmentList(v: unknown): v is Segment[] {
// getHarvestReachSegments return Array.isArray(v) && v.every((s) => s && typeof s === "object" && "rules" in s);
// ────────────────────────────────────────────────────────────── }
async function loadSegments(brandId: string): Promise<Segment[]> {
const rows = await withTenant(brandId, (db) =>
db
.select({ flags: brandSettings.featureFlags })
.from(brandSettings)
.where(eq(brandSettings.tenantId, brandId))
.limit(1),
);
const raw = (rows[0]?.flags ?? {}) as Record<string, unknown>;
const list = raw[SEGMENTS_FLAG_KEY];
return isSegmentList(list) ? list : [];
}
async function saveSegments(brandId: string, segments: Segment[]): Promise<void> {
await withTenant(brandId, async (db) => {
const existing = await db
.select({ flags: brandSettings.featureFlags })
.from(brandSettings)
.where(eq(brandSettings.tenantId, brandId))
.limit(1);
const baseFlags = (existing[0]?.flags ?? {}) as Record<string, unknown>;
const nextFlags: Record<string, unknown> = {
...baseFlags,
[SEGMENTS_FLAG_KEY]: segments,
};
await db
.update(brandSettings)
.set({ featureFlags: nextFlags, updatedAt: new Date() })
.where(eq(brandSettings.tenantId, brandId));
});
}
export async function getHarvestReachSegments( export async function getHarvestReachSegments(
brandId: string brandId: string
@@ -75,27 +111,17 @@ export async function getHarvestReachSegments(
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const segments = await loadSegments(brandId);
return { success: true, segments };
const response = await fetch( } catch (err) {
`${supabaseUrl}/rest/v1/rpc/get_communication_segments`, return {
{ success: false,
method: "POST", error: err instanceof Error ? err.message : "Failed to fetch segments",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, };
body: JSON.stringify({ p_brand_id: brandId }),
} }
);
if (!response.ok) return { success: false, error: "Failed to fetch segments" };
const data = await response.json();
return { success: true, segments: data?.segments ?? [] };
} }
// ──────────────────────────────────────────────────────────────
// upsertHarvestReachSegment
// ──────────────────────────────────────────────────────────────
export async function upsertHarvestReachSegment(params: { export async function upsertHarvestReachSegment(params: {
id?: string; id?: string;
brand_id: string; brand_id: string;
@@ -110,33 +136,43 @@ export async function upsertHarvestReachSegment(params: {
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const segments = await loadSegments(params.brand_id);
const now = new Date().toISOString();
const response = await fetch( let saved: Segment;
`${supabaseUrl}/rest/v1/rpc/upsert_communication_segment`, if (params.id) {
{ const idx = segments.findIndex((s) => s.id === params.id);
method: "POST", if (idx === -1) return { success: false, error: "Segment not found" };
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, saved = {
body: JSON.stringify({ ...segments[idx],
p_id: params.id ?? null, name: params.name,
p_brand_id: params.brand_id, description: params.description ?? null,
p_name: params.name, rules: params.rules,
p_description: params.description ?? null, updated_at: now,
p_rules: params.rules, };
p_created_by: adminUser.user_id, segments[idx] = saved;
}), } else {
saved = {
id: crypto.randomUUID(),
brand_id: params.brand_id,
name: params.name,
description: params.description ?? null,
rules: params.rules,
created_by: adminUser.id,
created_at: now,
updated_at: now,
};
segments.push(saved);
}
await saveSegments(params.brand_id, segments);
return { success: true, segment: saved };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to save segment",
};
} }
);
if (!response.ok) return { success: false, error: "Failed to save segment" };
const data = await response.json();
return { success: true, segment: data };
} }
// ──────────────────────────────────────────────────────────────
// deleteHarvestReachSegment
// ──────────────────────────────────────────────────────────────
export async function deleteHarvestReachSegment( export async function deleteHarvestReachSegment(
segmentId: string, segmentId: string,
@@ -149,26 +185,30 @@ export async function deleteHarvestReachSegment(
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const segments = await loadSegments(brandId);
const filtered = segments.filter((s) => s.id !== segmentId);
const response = await fetch( if (filtered.length === segments.length) {
`${supabaseUrl}/rest/v1/rpc/delete_communication_segment`, return { success: false, error: "Segment not found" };
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_segment_id: segmentId, p_brand_id: brandId }),
} }
); await saveSegments(brandId, filtered);
if (!response.ok) return { success: false, error: "Failed to delete segment" };
return { success: true }; return { success: true };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to delete segment",
};
}
} }
// ────────────────────────────────────────────────────────────── /**
// previewSegmentWithCustomers * The legacy `preview_campaign_audience` RPC evaluated a (combinator +
// ────────────────────────────────────────────────────────────── * filter[]) rule tree against a join of customers, orders, products,
* etc. The new schema has only `customers` and `orders`; the new
* preview therefore counts the tenant's opted-in customers. The
* `SegmentRuleV2` shape is preserved for round-tripping but no longer
* influences the count.
*/
export async function previewSegmentWithCustomers( export async function previewSegmentWithCustomers(
brandId: string, brandId: string,
rules: SegmentRuleV2 rules: SegmentRuleV2
@@ -179,23 +219,43 @@ export async function previewSegmentWithCustomers(
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) { if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
return null; return null;
} }
void rules;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const conds: SQL[] = [eq(customers.tenantId, brandId), eq(customers.emailOptIn, true)];
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch( try {
`${supabaseUrl}/rest/v1/rpc/preview_campaign_audience`, const [samples, counts] = await withTenant(brandId, async (db) => {
{ const sample = await db
method: "POST", .select({
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, id: customers.id,
body: JSON.stringify({ email: customers.email,
p_brand_id: brandId, name: customers.name,
p_audience_rules: rules, phone: customers.phone,
}), })
.from(customers)
.where(and(...conds))
.limit(5);
const c = await db
.select({ value: sql<number>`count(*)::int` })
.from(customers)
.where(and(...conds));
return [sample, c] as const;
});
return {
count: Number(counts[0]?.value ?? 0),
sample_customers: samples.map((s) => ({
id: s.id,
email: s.email ?? "",
name: s.name,
tags: [],
phone: s.phone,
})),
};
} catch {
return { count: 0, sample_customers: [] };
} }
);
if (!response.ok) return null;
const data = await response.json();
return data as PreviewResult;
} }
void or;
void ilike;
+68 -16
View File
@@ -1,7 +1,9 @@
"use server"; "use server";
import { and, eq } from "drizzle-orm";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { withTenant } from "@/db/client";
import { stops } from "@/db/schema";
export type StopOption = { export type StopOption = {
id: string; id: string;
@@ -15,6 +17,31 @@ export type StopOption = {
is_past: boolean; is_past: boolean;
}; };
/**
* The legacy `get_stops_for_segment_picker` RPC returned a denormalized
* `(id, city, state, date, time, location, zip, ...)` row. The new
* `stops` table only stores `name`, `address`, and a JSONB `schedule`
* array — the structured city/state/zip columns are gone. The
* replacement returns one option per stop, deriving display fields
* from `address` and the schedule. `city`, `state`, `zip`, `date`,
* `time` are best-effort extracted from the address string; missing
* values fall back to "—".
*/
function parseAddress(address: string): { city: string; state: string; zip: string } {
// Best-effort: "123 Main St, City, ST 12345"
const parts = address.split(",").map((s) => s.trim());
const stateZip = parts[parts.length - 1] ?? "";
const stateZipMatch = stateZip.match(/^([A-Z]{2})\s+(\d{5}(?:-\d{4})?)$/);
if (stateZipMatch) {
return {
city: parts.length >= 2 ? parts[parts.length - 2] : "",
state: stateZipMatch[1],
zip: stateZipMatch[2],
};
}
return { city: parts[1] ?? "", state: "", zip: "" };
}
export async function getStopsForSegmentPicker( export async function getStopsForSegmentPicker(
brandId: string, brandId: string,
stopId?: string stopId?: string
@@ -23,21 +50,46 @@ export async function getStopsForSegmentPicker(
if (!adminUser) return []; if (!adminUser) return [];
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) return []; if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) return [];
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const rows = await withTenant(brandId, (db) =>
db
const response = await fetch( .select({
`${supabaseUrl}/rest/v1/rpc/get_stops_for_segment_picker`, id: stops.id,
{ name: stops.name,
method: "POST", address: stops.address,
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, schedule: stops.schedule,
body: JSON.stringify({ })
p_brand_id: brandId, .from(stops)
p_stop_id: stopId ?? null, .where(
}), stopId
} ? and(eq(stops.tenantId, brandId), eq(stops.id, stopId))
: eq(stops.tenantId, brandId),
),
); );
if (!response.ok) return []; const now = Date.now();
return (await response.json()) as StopOption[]; return rows.map((r) => {
const { city, state, zip } = parseAddress(r.address);
const sched = Array.isArray(r.schedule) ? r.schedule : [];
const first = sched[0] as { date?: string; time?: string } | undefined;
const dateStr = first?.date ?? "";
const timeStr = first?.time ?? "";
const ts = dateStr ? new Date(dateStr).getTime() : NaN;
const isUpcoming = Number.isFinite(ts) ? ts >= now : false;
const isPast = Number.isFinite(ts) ? ts < now : false;
return {
id: r.id,
city,
state,
date: dateStr,
time: timeStr,
location: r.name,
zip,
is_upcoming: isUpcoming,
is_past: isPast,
};
});
} catch {
return [];
}
} }
+84 -29
View File
@@ -1,15 +1,26 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { withTx, pool } from "@/lib/db";
import { orders, orderItems, customers } from "@/db/schema";
import { eq } from "drizzle-orm";
export type ImportOrdersResult = export type ImportOrdersResult =
| { success: true; imported: number; errors: { row: number; error: string }[] } | { success: true; imported: number; errors: { row: number; error: string }[] }
| { success: false; error: string }; | { success: false; error: string };
/**
* 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`,
* `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
* insert the `orders` + `order_items` rows.
*/
export async function importOrdersBatch( export async function importOrdersBatch(
brandId: string, brandId: string,
orders: Array<{ ordersToImport: Array<{
customer_name: string; customer_name: string;
customer_email: string; customer_email: string;
customer_phone: string; customer_phone: string;
@@ -25,38 +36,82 @@ export async function importOrdersBatch(
return { success: false, error: "Not authorized for this brand" }; return { success: false, error: "Not authorized for this brand" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const results = { imported: 0, errors: [] as { row: number; error: string }[] }; const results = { imported: 0, errors: [] as { row: number; error: string }[] };
for (const order of orders) { for (let i = 0; i < ordersToImport.length; i++) {
const idempotencyKey = crypto.randomUUID(); const order = ordersToImport[i];
try {
const response = await fetch( // Compute total_cents server-side from current product prices.
`${supabaseUrl}/rest/v1/rpc/create_order_with_items`, const productIds = order.items.map((it) => it.product_id);
{ if (productIds.length === 0) {
method: "POST", results.errors.push({ row: i, error: "Order has no items" });
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, continue;
body: JSON.stringify({
p_idempotency_key: idempotencyKey,
p_customer_name: order.customer_name,
p_customer_email: order.customer_email,
p_customer_phone: order.customer_phone,
p_stop_id: order.stop_id,
p_items: order.items.map((it) => ({
id: it.product_id,
quantity: it.quantity,
fulfillment: it.fulfillment,
})),
}),
} }
);
if (!response.ok) { // Fetch product prices for the brand.
results.errors.push({ row: 0, error: `Failed to import order for ${order.customer_name}` }); const productRes = await pool.query<{ id: string; price_cents: number }>(
} else { `SELECT id, price_cents FROM products WHERE tenant_id = $1 AND id = ANY($2::uuid[])`,
[brandId, productIds]
);
const priceMap = new Map(productRes.rows.map((p) => [p.id, p.price_cents]));
let totalCents = 0;
for (const it of order.items) {
const unit = priceMap.get(it.product_id);
if (typeof unit !== "number") {
results.errors.push({ row: i, error: `Unknown product: ${it.product_id}` });
totalCents = -1;
break;
}
totalCents += unit * it.quantity;
}
if (totalCents < 0) continue;
// Determine fulfillment: pickup | ship | mixed based on items.
const fulfillments = new Set(order.items.map((it) => it.fulfillment));
const fulfillment: "pickup" | "ship" | "mixed" =
fulfillments.size > 1
? "mixed"
: (Array.from(fulfillments)[0] as "pickup" | "ship" | "mixed") ?? "pickup";
// Insert in a single transaction: customers + orders + order_items.
await withTx(async (client) => {
// Upsert customer by (tenant_id, email).
const customerRes = await client.query<{ id: string }>(
`INSERT INTO customers (tenant_id, name, email, phone, email_opt_in)
VALUES ($1, $2, $3, $4, true)
ON CONFLICT (tenant_id, email) WHERE email IS NOT NULL
DO UPDATE SET name = EXCLUDED.name, phone = EXCLUDED.phone, updated_at = now()
RETURNING id`,
[brandId, order.customer_name, order.customer_email || null, order.customer_phone || null]
);
const customerId = customerRes.rows[0]?.id ?? null;
const orderRes = await client.query<{ id: string }>(
`INSERT INTO orders (tenant_id, customer_id, total_cents, status, fulfillment)
VALUES ($1, $2, $3, 'pending', $4)
RETURNING id`,
[brandId, customerId, totalCents, fulfillment]
);
const orderId = orderRes.rows[0]?.id;
if (!orderId) throw new Error("Order insert returned no id");
for (const it of order.items) {
const unit = priceMap.get(it.product_id) ?? 0;
await client.query(
`INSERT INTO order_items (order_id, product_id, quantity, price_cents, fulfillment)
VALUES ($1, $2, $3, $4, $5)`,
[orderId, it.product_id, it.quantity, unit, it.fulfillment === "shipping" ? "ship" : it.fulfillment]
);
}
});
results.imported++; results.imported++;
} catch (err) {
results.errors.push({
row: i,
error: `Failed to import order for ${order.customer_name}: ${err instanceof Error ? err.message : String(err)}`,
});
} }
} }
+37 -14
View File
@@ -1,15 +1,24 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { withTenant } from "@/db/client";
import { products } from "@/db/schema";
export type ImportProductsResult = export type ImportProductsResult =
| { success: true; created: number; updated: number; errors: { product: string; error: string }[] } | { success: true; created: number; updated: number; errors: { product: string; error: string }[] }
| { success: false; error: string }; | { success: false; error: string };
/**
* Bulk-import products. Replaces the legacy `bulk_upsert_products` SECURITY
* DEFINER RPC. The new `products` schema drops the legacy `type`, `is_taxable`,
* `pickup_type`, and `image_url` columns; we keep `name`, `description`,
* `price_cents`, and `active`. Without an id we always INSERT (no upsert
* key for matching — the caller can run an update path separately if
* deduplication is needed).
*/
export async function importProductsBatch( export async function importProductsBatch(
brandId: string, brandId: string,
products: Array<{ productsToImport: Array<{
name: string; name: string;
description: string; description: string;
price: number; price: number;
@@ -26,19 +35,33 @@ export async function importProductsBatch(
return { success: false, error: "Not authorized for this brand" }; return { success: false, error: "Not authorized for this brand" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; let created = 0;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const errors: { product: string; error: string }[] = [];
const response = await fetch( for (const p of productsToImport) {
`${supabaseUrl}/rest/v1/rpc/bulk_upsert_products`, const priceCents = Math.round(Number(p.price) * 100);
{ if (!Number.isFinite(priceCents) || priceCents < 0) {
method: "POST", errors.push({ product: p.name, error: "Invalid price" });
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, continue;
body: JSON.stringify({ p_brand_id: brandId, p_products: products }),
} }
try {
await withTenant(brandId, (db) =>
db.insert(products).values({
tenantId: brandId,
name: p.name,
description: p.description ?? null,
priceCents,
active: p.active,
})
); );
created++;
if (!response.ok) return { success: false, error: "Import failed" }; } catch (err) {
const data = await response.json(); errors.push({
return { success: true, created: data.created, updated: data.updated, errors: data.errors ?? [] }; product: p.name,
error: err instanceof Error ? err.message : String(err),
});
}
}
return { success: true, created, updated: 0, errors };
} }
+51 -93
View File
@@ -1,7 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
import { DEFAULT_MODELS, type AIProvider as AIProviderType } from "@/lib/ai-provider-models"; import { DEFAULT_MODELS, type AIProvider as AIProviderType } from "@/lib/ai-provider-models";
// ── Types ──────────────────────────────────────────────────────────────────────── // ── Types ────────────────────────────────────────────────────────────────────────
@@ -33,33 +33,31 @@ const MINIMAX_DEFAULT_BASE_URL = "https://api.minimax.io/v1";
// ── Get AI provider settings ───────────────────────────────────────────────────── // ── Get AI provider settings ─────────────────────────────────────────────────────
export async function getAIProviderSettings(brandId: string): Promise<AIProviderSettings> { export async function getAIProviderSettings(brandId: string): Promise<AIProviderSettings> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; const res = await pool.query<{
provider: string | null;
const response = await fetch( api_key: string | null;
`${supabaseUrl}/rest/v1/rpc/get_ai_provider_settings`, org_id: string | null;
{ model: string | null;
method: "POST", custom_endpoint: string | null;
headers: { }>(
...svcHeaders(supabaseKey!), "SELECT * FROM get_ai_provider_settings($1)",
"Content-Type": "application/json", [brandId]
},
body: JSON.stringify({ p_brand_id: brandId }),
}
); );
const data = res.rows[0];
if (!response.ok) { if (!data) {
return { provider: "openai", apiKey: "", model: "gpt-4o-mini" }; return { provider: "openai", apiKey: "", model: "gpt-4o-mini" };
} }
const data = await response.json();
return { return {
provider: (data.provider as AIProvider) ?? "openai", provider: (data.provider as AIProvider) ?? "openai",
apiKey: data.api_key ?? "", apiKey: data.api_key ?? "",
orgId: data.org_id, orgId: data.org_id ?? undefined,
model: data.model ?? "gpt-4o-mini", model: data.model ?? "gpt-4o-mini",
customEndpoint: data.custom_endpoint, customEndpoint: data.custom_endpoint ?? undefined,
}; };
} catch {
return { provider: "openai", apiKey: "", model: "gpt-4o-mini" };
}
} }
// ── Save AI provider settings ─────────────────────────────────────────────────── // ── Save AI provider settings ───────────────────────────────────────────────────
@@ -75,9 +73,6 @@ export async function setAIProviderSettings(
const current = await getAIProviderSettings(brandId); const current = await getAIProviderSettings(brandId);
const merged = { ...current, ...settings }; const merged = { ...current, ...settings };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
const payload = { const payload = {
provider: merged.provider, provider: merged.provider,
api_key: merged.apiKey, api_key: merged.apiKey,
@@ -86,24 +81,16 @@ export async function setAIProviderSettings(
custom_endpoint: merged.customEndpoint ?? null, custom_endpoint: merged.customEndpoint ?? null,
}; };
const response = await fetch( try {
`${supabaseUrl}/rest/v1/rpc/set_ai_provider_settings`, await pool.query(
{ "SELECT set_ai_provider_settings($1, $2::jsonb)",
method: "POST", [brandId, JSON.stringify(payload)]
headers: {
...svcHeaders(supabaseKey!),
"Content-Type": "application/json",
},
body: JSON.stringify({ p_brand_id: brandId, p_settings: payload }),
}
); );
const data = await response.json();
if (!response.ok || !data) {
return { success: false, error: data?.message ?? "Failed to save" };
}
return { success: true }; return { success: true };
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to save";
return { success: false, error: msg };
}
} }
// ── Dynamic AI client factory ──────────────────────────────────────────────────── // ── Dynamic AI client factory ────────────────────────────────────────────────────
@@ -206,24 +193,15 @@ export async function getAIClient(brandId: string): Promise<{
// ── Custom integrations ───────────────────────────────────────────────────────── // ── Custom integrations ─────────────────────────────────────────────────────────
export async function getCustomIntegrations(brandId: string): Promise<CustomIntegration[]> { export async function getCustomIntegrations(brandId: string): Promise<CustomIntegration[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; const res = await pool.query<CustomIntegration>(
"SELECT * FROM get_custom_integrations($1)",
const response = await fetch( [brandId]
`${supabaseUrl}/rest/v1/rpc/get_custom_integrations`,
{
method: "POST",
headers: {
...svcHeaders(supabaseKey!),
"Content-Type": "application/json",
},
body: JSON.stringify({ p_brand_id: brandId }),
}
); );
return res.rows ?? [];
if (!response.ok) return []; } catch {
const data = await response.json(); return [];
return data ?? []; }
} }
export async function upsertCustomIntegration( export async function upsertCustomIntegration(
@@ -234,25 +212,16 @@ export async function upsertCustomIntegration(
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (adminUser.brand_id && adminUser.brand_id !== brandId) return { success: false, error: "Not authorized" }; if (adminUser.brand_id && adminUser.brand_id !== brandId) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; const res = await pool.query<CustomIntegration>(
"SELECT * FROM upsert_custom_integration($1, $2::jsonb)",
const response = await fetch( [brandId, JSON.stringify(integration)]
`${supabaseUrl}/rest/v1/rpc/upsert_custom_integration`,
{
method: "POST",
headers: {
...svcHeaders(supabaseKey!),
"Content-Type": "application/json",
},
body: JSON.stringify({ p_brand_id: brandId, p_integration: integration }),
}
); );
return { success: true, integrations: res.rows };
const data = await response.json(); } catch (err) {
if (!response.ok) return { success: false, error: data?.message ?? "Failed to save" }; const msg = err instanceof Error ? err.message : "Failed to save";
return { success: false, error: msg };
return { success: true, integrations: data }; }
} }
export async function deleteCustomIntegration( export async function deleteCustomIntegration(
@@ -263,25 +232,14 @@ export async function deleteCustomIntegration(
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (adminUser.brand_id && adminUser.brand_id !== brandId) return { success: false, error: "Not authorized" }; if (adminUser.brand_id && adminUser.brand_id !== brandId) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; await pool.query(
"SELECT delete_custom_integration($1, $2)",
const response = await fetch( [brandId, integrationId]
`${supabaseUrl}/rest/v1/rpc/delete_custom_integration`,
{
method: "POST",
headers: {
...svcHeaders(supabaseKey!),
"Content-Type": "application/json",
},
body: JSON.stringify({ p_brand_id: brandId, p_integration_id: integrationId }),
}
); );
if (!response.ok) {
const data = await response.json();
return { success: false, error: data?.message ?? "Failed to delete" };
}
return { success: true }; return { success: true };
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to delete";
return { success: false, error: msg };
}
} }
+45 -73
View File
@@ -1,7 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
// ── Types ───────────────────────────────────────────────────────────────────── // ── Types ─────────────────────────────────────────────────────────────────────
@@ -24,28 +24,25 @@ export type SaveCredentialsResult =
// ── Resend Credentials ───────────────────────────────────────────────────────── // ── Resend Credentials ─────────────────────────────────────────────────────────
export async function getResendCredentials(brandId: string): Promise<ResendCredentials> { export async function getResendCredentials(brandId: string): Promise<ResendCredentials> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const res = await pool.query<{
api_key: string | null;
const response = await fetch( from_email: string | null;
`${supabaseUrl}/rest/v1/rpc/get_resend_credentials`, from_name: string | null;
{ }>(
method: "POST", "SELECT * FROM get_resend_credentials($1)",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, [brandId]
body: JSON.stringify({ p_brand_id: brandId }),
}
); );
const data = res.rows[0];
if (!response.ok) { if (!data) return { api_key: null, from_email: null, from_name: null };
return {
api_key: data.api_key ?? null,
from_email: data.from_email ?? null,
from_name: data.from_name ?? null,
};
} catch {
return { api_key: null, from_email: null, from_name: null }; return { api_key: null, from_email: null, from_name: null };
} }
const data = await response.json();
return {
api_key: data?.api_key ?? null,
from_email: data?.from_email ?? null,
from_name: data?.from_name ?? null,
};
} }
export async function saveResendCredentials( export async function saveResendCredentials(
@@ -66,9 +63,6 @@ export async function saveResendCredentials(
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Get current credentials to merge // Get current credentials to merge
const current = await getResendCredentials(brandId); const current = await getResendCredentials(brandId);
const merged = { const merged = {
@@ -77,50 +71,39 @@ export async function saveResendCredentials(
from_name: credentials.from_name !== undefined ? credentials.from_name : current.from_name, from_name: credentials.from_name !== undefined ? credentials.from_name : current.from_name,
}; };
const response = await fetch( try {
`${supabaseUrl}/rest/v1/rpc/set_resend_credentials`, await pool.query(
{ "SELECT set_resend_credentials($1, $2::jsonb)",
method: "POST", [brandId, JSON.stringify(merged)]
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: brandId,
p_credentials: merged,
}),
}
); );
return { success: true };
if (!response.ok) { } catch {
return { success: false, error: "Failed to save Resend credentials" }; return { success: false, error: "Failed to save Resend credentials" };
} }
return { success: true };
} }
// ── Twilio Credentials ───────────────────────────────────────────────────────── // ── Twilio Credentials ─────────────────────────────────────────────────────────
export async function getTwilioCredentials(brandId: string): Promise<TwilioCredentials> { export async function getTwilioCredentials(brandId: string): Promise<TwilioCredentials> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const res = await pool.query<{
account_sid: string | null;
const response = await fetch( auth_token: string | null;
`${supabaseUrl}/rest/v1/rpc/get_twilio_credentials`, phone_number: string | null;
{ }>(
method: "POST", "SELECT * FROM get_twilio_credentials($1)",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, [brandId]
body: JSON.stringify({ p_brand_id: brandId }),
}
); );
const data = res.rows[0];
if (!response.ok) { if (!data) return { account_sid: null, auth_token: null, phone_number: null };
return {
account_sid: data.account_sid ?? null,
auth_token: data.auth_token ?? null,
phone_number: data.phone_number ?? null,
};
} catch {
return { account_sid: null, auth_token: null, phone_number: null }; return { account_sid: null, auth_token: null, phone_number: null };
} }
const data = await response.json();
return {
account_sid: data?.account_sid ?? null,
auth_token: data?.auth_token ?? null,
phone_number: data?.phone_number ?? null,
};
} }
export async function saveTwilioCredentials( export async function saveTwilioCredentials(
@@ -141,9 +124,6 @@ export async function saveTwilioCredentials(
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Get current credentials to merge // Get current credentials to merge
const current = await getTwilioCredentials(brandId); const current = await getTwilioCredentials(brandId);
const merged = { const merged = {
@@ -152,23 +132,15 @@ export async function saveTwilioCredentials(
phone_number: credentials.phone_number !== undefined ? credentials.phone_number : current.phone_number, phone_number: credentials.phone_number !== undefined ? credentials.phone_number : current.phone_number,
}; };
const response = await fetch( try {
`${supabaseUrl}/rest/v1/rpc/set_twilio_credentials`, await pool.query(
{ "SELECT set_twilio_credentials($1, $2::jsonb)",
method: "POST", [brandId, JSON.stringify(merged)]
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: brandId,
p_credentials: merged,
}),
}
); );
return { success: true };
if (!response.ok) { } catch {
return { success: false, error: "Failed to save Twilio credentials" }; return { success: false, error: "Failed to save Twilio credentials" };
} }
return { success: true };
} }
// ── Test Connection Functions ───────────────────────────────────────────────── // ── Test Connection Functions ─────────────────────────────────────────────────
+97 -128
View File
@@ -3,7 +3,7 @@
import { revalidateTag } from "next/cache"; import { revalidateTag } from "next/cache";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope"; import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
export type LocationInput = { export type LocationInput = {
name: string; name: string;
@@ -56,39 +56,37 @@ export async function createLocation(
const effectiveBrandId = activeBrandId; const effectiveBrandId = activeBrandId;
if (!effectiveBrandId) return { success: false, error: "No brand selected" }; if (!effectiveBrandId) return { success: false, error: "No brand selected" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const { rows } = await pool.query<{ id: string; slug: string }>(
`SELECT * FROM admin_create_location(
const res = await fetch( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10
`${supabaseUrl}/rest/v1/rpc/admin_create_location`, )`,
{ [
method: "POST", effectiveBrandId,
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, input.name,
body: JSON.stringify({ input.address ?? null,
p_brand_id: effectiveBrandId, input.city ?? null,
p_name: input.name, input.state ?? null,
p_address: input.address ?? null, input.zip ?? null,
p_city: input.city ?? null, input.phone ?? null,
p_state: input.state ?? null, input.contact_name ?? null,
p_zip: input.zip ?? null, input.contact_email ?? null,
p_phone: input.phone ?? null, input.notes ?? null,
p_contact_name: input.contact_name ?? null, ],
p_contact_email: input.contact_email ?? null,
p_notes: input.notes ?? null,
p_active: input.active ?? true,
}),
}
); );
const data = rows[0];
if (!res.ok) { if (!data) {
const err = await res.json().catch(() => ({ message: "Request failed" })); return { success: false, error: "Insert failed" };
return { success: false, error: (err as { message?: string }).message ?? "Insert failed" };
} }
const data = await res.json();
revalidateTag("locations", "default"); revalidateTag("locations", "default");
revalidateTag(`brand:${effectiveBrandId}:locations`, "default"); revalidateTag(`brand:${effectiveBrandId}:locations`, "default");
return { success: true, id: data.id, slug: data.slug }; return { success: true, id: data.id, slug: data.slug };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Insert failed",
};
}
} }
// ── Create (batch) ─────────────────────────────────────────────────────────── // ── Create (batch) ───────────────────────────────────────────────────────────
@@ -108,24 +106,21 @@ export async function createLocationsBatch(
const effectiveBrandId = activeBrandId; const effectiveBrandId = activeBrandId;
if (!effectiveBrandId) return { success: false, created: 0, error: "No brand selected" }; if (!effectiveBrandId) return { success: false, created: 0, error: "No brand selected" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; let inserted: { id?: string }[] = [];
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; try {
const { rows } = await pool.query<{ id?: string }>(
const res = await fetch( "SELECT * FROM admin_create_locations_batch($1, $2::jsonb)",
`${supabaseUrl}/rest/v1/rpc/admin_create_locations_batch`, [effectiveBrandId, JSON.stringify(locations)],
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: effectiveBrandId, p_locations: locations }),
}
); );
inserted = rows;
if (!res.ok) { } catch (err) {
const err = await res.json().catch(() => ({ message: "Request failed" })); return {
return { success: false, created: 0, error: (err as { message?: string }).message ?? "Insert failed" }; success: false,
created: 0,
error: err instanceof Error ? err.message : "Insert failed",
};
} }
const inserted = await res.json();
revalidateTag("locations", "default"); revalidateTag("locations", "default");
revalidateTag(`brand:${effectiveBrandId}:locations`, "default"); revalidateTag(`brand:${effectiveBrandId}:locations`, "default");
return { return {
@@ -144,25 +139,23 @@ export async function updateLocation(
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; let data: { success?: boolean; error?: string } = {};
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; try {
const { rows } = await pool.query<{ success?: boolean; error?: string }>(
const res = await fetch( "SELECT * FROM admin_update_location($1, $2, $3::jsonb)",
`${supabaseUrl}/rest/v1/rpc/admin_update_location`, [locationId, brandId, JSON.stringify(updates)],
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_location_id: locationId, p_brand_id: brandId, p_updates: updates }),
}
); );
data = rows[0] ?? {};
if (!res.ok) { } catch (err) {
const err = await res.json().catch(() => ({ message: "Request failed" })); return {
return { success: false, error: (err as { message?: string }).message ?? "Update failed" }; success: false,
error: err instanceof Error ? err.message : "Update failed",
};
} }
const data = await res.json(); if (!data.success) {
if (!data.success) return { success: false, error: data.error ?? "Update failed" }; return { success: false, error: data.error ?? "Update failed" };
}
revalidateTag("locations", "default"); revalidateTag("locations", "default");
revalidateTag(`brand:${brandId}:locations`, "default"); revalidateTag(`brand:${brandId}:locations`, "default");
@@ -178,25 +171,23 @@ export async function deleteLocation(
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; let data: { success?: boolean; error?: string } = {};
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; try {
const { rows } = await pool.query<{ success?: boolean; error?: string }>(
const res = await fetch( "SELECT * FROM admin_delete_location($1, $2)",
`${supabaseUrl}/rest/v1/rpc/admin_delete_location`, [locationId, brandId],
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_location_id: locationId, p_brand_id: brandId }),
}
); );
data = rows[0] ?? {};
if (!res.ok) { } catch (err) {
const err = await res.json().catch(() => ({ message: "Request failed" })); return {
return { success: false, error: (err as { message?: string }).message ?? "Delete failed" }; success: false,
error: err instanceof Error ? err.message : "Delete failed",
};
} }
const data = await res.json(); if (!data.success) {
if (!data.success) return { success: false, error: data.error ?? "Delete failed" }; return { success: false, error: data.error ?? "Delete failed" };
}
revalidateTag("locations", "default"); revalidateTag("locations", "default");
revalidateTag(`brand:${brandId}:locations`, "default"); revalidateTag(`brand:${brandId}:locations`, "default");
@@ -210,27 +201,18 @@ export async function adminListLocations(
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return []; if (!adminUser) return [];
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
// Application-layer brand scoping: platform_admin (brand_id: null) gets all
// brands; everyone else gets only their own brand.
const activeBrandId = await getActiveBrandId(adminUser, brandId); const activeBrandId = await getActiveBrandId(adminUser, brandId);
const effectiveBrandId = activeBrandId; const effectiveBrandId = activeBrandId;
const res = await fetch( try {
`${supabaseUrl}/rest/v1/rpc/admin_list_locations`, const { rows } = await pool.query<LocationWithCount>(
{ "SELECT * FROM admin_list_locations($1)",
method: "POST", [effectiveBrandId],
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: effectiveBrandId }),
next: { revalidate: 60, tags: ["locations", `brand:${effectiveBrandId ?? "all"}:locations`] },
}
); );
return Array.isArray(rows) ? rows : [];
if (!res.ok) return []; } catch {
const data = await res.json(); return [];
return Array.isArray(data) ? (data as LocationWithCount[]) : []; }
} }
// ── Read (public, by brand slug) ───────────────────────────────────────────── // ── Read (public, by brand slug) ─────────────────────────────────────────────
@@ -244,22 +226,15 @@ export async function getPublicLocationsForBrand(
): Promise<PublicLocation[]> { ): Promise<PublicLocation[]> {
if (!brandSlug) return []; if (!brandSlug) return [];
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const { rows } = await pool.query<PublicLocation>(
"SELECT * FROM get_locations_for_brand($1)",
const res = await fetch( [brandSlug],
`${supabaseUrl}/rest/v1/rpc/get_locations_for_brand`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_slug: brandSlug }),
next: { revalidate: 300, tags: ["locations", `brand:${brandSlug}:locations`] },
}
); );
return Array.isArray(rows) ? rows : [];
if (!res.ok) return []; } catch {
const data = await res.json(); return [];
return Array.isArray(data) ? (data as PublicLocation[]) : []; }
} }
// ── Attach a stop to a location ────────────────────────────────────────────── // ── Attach a stop to a location ──────────────────────────────────────────────
@@ -272,29 +247,23 @@ export async function attachStopToLocation(
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; let data: { success?: boolean; error?: string } = {};
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; try {
const { rows } = await pool.query<{ success?: boolean; error?: string }>(
const res = await fetch( "SELECT * FROM admin_attach_location_to_stop($1, $2, $3)",
`${supabaseUrl}/rest/v1/rpc/admin_attach_location_to_stop`, [stopId, locationId, brandId],
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_stop_id: stopId,
p_location_id: locationId,
p_brand_id: brandId,
}),
}
); );
data = rows[0] ?? {};
if (!res.ok) { } catch (err) {
const err = await res.json().catch(() => ({ message: "Request failed" })); return {
return { success: false, error: (err as { message?: string }).message ?? "Attach failed" }; success: false,
error: err instanceof Error ? err.message : "Attach failed",
};
} }
const data = await res.json(); if (!data.success) {
if (!data.success) return { success: false, error: data.error ?? "Attach failed" }; return { success: false, error: data.error ?? "Attach failed" };
}
revalidateTag("stops", "default"); revalidateTag("stops", "default");
revalidateTag("locations", "default"); revalidateTag("locations", "default");
-56
View File
@@ -1,56 +0,0 @@
"use server";
import { cookies } from "next/headers";
import { createServerClient } from "@supabase/ssr";
export type LoginWithPasswordResult =
| { success: true; redirect: true }
| { success: false; error: string };
export async function loginWithPassword(
email: string,
password: string
): Promise<LoginWithPasswordResult> {
const cookieStore = await cookies();
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
return { success: false, error: "Server misconfiguration." };
}
const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => {
cookieStore.set(name, value, options);
});
},
},
});
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error || !data.user) {
return { success: false, error: error?.message || "Invalid credentials" };
}
// Set the rc_auth_uid cookie that getAdminUser() reads
const isProd = process.env.NODE_ENV === "production";
cookieStore.set("rc_auth_uid", data.user.id, {
path: "/",
maxAge: 60 * 60 * 24 * 30,
httpOnly: true,
sameSite: "lax",
secure: isProd,
});
return { success: true, redirect: true };
}
+59 -34
View File
@@ -1,7 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
import { mockOrders, mockStops } from "@/lib/mock-data"; import { mockOrders, mockStops } from "@/lib/mock-data";
type AdminOrder = { type AdminOrder = {
@@ -149,29 +149,29 @@ export async function getAdminOrders(): Promise<AdminOrdersResponse> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
const brandId = adminUser?.brand_id ?? null; const brandId = adminUser?.brand_id ?? null;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; // The legacy `get_admin_orders` RPC is a SECURITY DEFINER function that
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; // returns orders + stops joined with order_items. Call it directly via
// the shared pg pool so we don't go through Supabase REST.
const response = await fetch( try {
`${supabaseUrl}/rest/v1/rpc/get_admin_orders`, const { rows } = await pool.query<AdminOrdersResult>(
{ "SELECT * FROM get_admin_orders($1)",
method: "POST", [brandId],
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" },
body: JSON.stringify({ p_brand_id: brandId }),
}
); );
const data = rows[0] ?? { orders: [], stops: [] };
if (!response.ok) {
return { success: false, orders: [], stops: [], error: `HTTP ${response.status}: ${response.statusText}` };
}
const data = await response.json();
return { return {
success: true, success: true,
orders: data?.orders ?? [], orders: data.orders ?? [],
stops: data?.stops ?? [], stops: data.stops ?? [],
error: null, error: null,
}; };
} catch (err) {
return {
success: false,
orders: [],
stops: [],
error: err instanceof Error ? err.message : "Failed to fetch orders",
};
}
} }
export async function getAdminStops(): Promise<AdminStop[]> { export async function getAdminStops(): Promise<AdminStop[]> {
@@ -216,22 +216,47 @@ export async function getAdminOrderDetail(orderId: string): Promise<AdminOrderDe
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
const brandId = adminUser?.brand_id ?? null; const brandId = adminUser?.brand_id ?? null;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const { rows } = await pool.query<AdminOrderDetail>(
"SELECT * FROM get_admin_order_detail($1, $2)",
const response = await fetch( [orderId, brandId],
`${supabaseUrl}/rest/v1/rpc/get_admin_order_detail`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_order_id: orderId, p_brand_id: brandId }),
}
); );
return rows[0] ?? null;
if (!response.ok) { } catch {
return null; return null;
} }
}
const data = await response.json();
return data; /**
* Toggle the `pickup_complete` flag on the legacy `orders` table.
* TODO(migration): the SaaS rebuild's `orders` table (db/schema/orders.ts)
* doesn't carry a `pickup_complete` column. This action writes to the
* legacy column so the OrderTableBody client component keeps working.
* When the pickup flow is rebuilt against the SaaS schema, this should
* be replaced by a Drizzle update on a `pickup_events` table.
*/
export async function toggleOrderPickupComplete(params: {
orderId: string;
pickupComplete: boolean;
}): Promise<{ success: true } | { success: false; 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(
`UPDATE orders
SET pickup_complete = $2,
pickup_completed_at = CASE WHEN $2 THEN NOW() ELSE NULL END,
pickup_completed_by = CASE WHEN $2 THEN $3::uuid ELSE NULL END
WHERE id = $1`,
[params.orderId, params.pickupComplete, adminUser.user_id],
);
return { success: true };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to update pickup status",
};
}
} }
+26 -37
View File
@@ -2,7 +2,7 @@
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope"; import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
export type AdminCreateOrderItem = { export type AdminCreateOrderItem = {
@@ -58,9 +58,6 @@ export async function createAdminOrder(
return { success: false, error: "At least one item is required" }; return { success: false, error: "At least one item is required" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Build items for RPC (match checkout shape) // Build items for RPC (match checkout shape)
const rpcItems = input.items.map((i) => ({ const rpcItems = input.items.map((i) => ({
product_id: i.product_id, product_id: i.product_id,
@@ -77,33 +74,23 @@ export async function createAdminOrder(
const taxLocation = null; const taxLocation = null;
try { try {
const response = await fetch( const { rows } = await pool.query<{ id: string }>(
`${supabaseUrl}/rest/v1/rpc/create_order_with_items`, `SELECT * FROM create_order_with_items(
{ $1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9
method: "POST", )`,
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" }, [
body: JSON.stringify({ idempotencyKey,
p_idempotency_key: idempotencyKey, input.customer_name.trim(),
p_customer_name: input.customer_name.trim(), input.customer_email?.trim() || null,
p_customer_email: input.customer_email?.trim() || null, input.customer_phone?.trim() || null,
p_customer_phone: input.customer_phone?.trim() || null, input.stop_id || null,
p_stop_id: input.stop_id || null, JSON.stringify(rpcItems),
p_items: rpcItems, taxAmount,
p_tax_amount: taxAmount, taxRate,
p_tax_rate: taxRate, taxLocation,
p_tax_location: taxLocation, ],
// The RPC may also accept brand scoping internally via stop or we can extend later.
}),
}
); );
const data = rows[0];
if (!response.ok) {
const errText = await response.text().catch(() => "Unknown error");
return { success: false, error: `Failed to create order: ${errText}` };
}
const data = await response.json();
if (!data || !data.id) { if (!data || !data.id) {
return { success: false, error: "Order created but no ID returned" }; return { success: false, error: "Order created but no ID returned" };
} }
@@ -112,18 +99,20 @@ export async function createAdminOrder(
if (input.internal_notes?.trim()) { if (input.internal_notes?.trim()) {
// Best-effort; don't fail the whole create if this secondary update fails. // Best-effort; don't fail the whole create if this secondary update fails.
try { try {
await fetch(`${supabaseUrl}/rest/v1/orders?id=eq.${data.id}`, { await pool.query(
method: "PATCH", "UPDATE orders SET internal_notes = $1 WHERE id = $2",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, [input.internal_notes.trim(), data.id],
body: JSON.stringify({ internal_notes: input.internal_notes.trim() }), );
});
} catch { } catch {
// ignore // ignore
} }
} }
return { success: true, orderId: data.id, order: data }; return { success: true, orderId: data.id, order: data };
} catch (err: any) { } catch (err) {
return { success: false, error: err?.message ?? "Unexpected error creating order" }; return {
success: false,
error: err instanceof Error ? err.message : "Unexpected error creating order",
};
} }
} }
+26 -24
View File
@@ -1,7 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
import { logAuditEvent } from "@/actions/audit"; import { logAuditEvent } from "@/actions/audit";
export type CreateRefundResult = export type CreateRefundResult =
@@ -22,37 +22,39 @@ export async function createRefund(
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; let inserted: { id: string } | null = null;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; try {
const { rows } = await pool.query<{ id: string }>(
const res = await fetch(`${supabaseUrl}/rest/v1/refunds`, { `INSERT INTO refunds (order_id, amount, reason, processor, processor_refund_id, status)
method: "POST", VALUES ($1, $2, $3, $4, $5, 'pending')
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" }, RETURNING id`,
body: JSON.stringify({ [
order_id: orderId, orderId,
amount: data.amount, data.amount,
reason: data.reason ?? null, data.reason ?? null,
processor: data.processor ?? null, data.processor ?? null,
processor_refund_id: data.processor_refund_id ?? null, data.processor_refund_id ?? null,
status: "pending", ],
}), );
}); inserted = rows[0] ?? null;
} catch (err) {
if (!res.ok) { return {
const err = await res.text(); success: false,
return { success: false, error: `Failed: ${err}` }; error: err instanceof Error ? err.message : "Failed",
};
}
if (!inserted) {
return { success: false, error: "Insert returned no row" };
} }
const inserted = await res.json();
logAuditEvent({ logAuditEvent({
table_name: "refunds", table_name: "refunds",
record_id: inserted[0]?.id ?? "", record_id: inserted.id,
action: "INSERT", action: "INSERT",
old_data: {}, old_data: {},
new_data: { order_id: orderId, amount: data.amount }, new_data: { order_id: orderId, amount: data.amount },
brand_id: brandId, brand_id: brandId,
}); });
return { success: true, id: inserted[0]?.id ?? "" }; return { success: true, id: inserted.id };
} }
+73 -52
View File
@@ -1,7 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
import { logAuditEvent } from "@/actions/audit"; import { logAuditEvent } from "@/actions/audit";
export type UpdateOrderResult = export type UpdateOrderResult =
@@ -36,33 +36,51 @@ export async function updateOrder(
return { success: false, error: "Not authorized for this brand" }; return { success: false, error: "Not authorized for this brand" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; // Build a partial SET clause. Each set column is added in the order
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; // the caller passed it; we don't care about column ordering.
const sets: string[] = [];
const params: unknown[] = [];
const push = (col: string, val: unknown) => {
params.push(val);
sets.push(`${col} = $${params.length}`);
};
const patchData: Record<string, unknown> = {}; if (data.customer_name !== undefined) push("customer_name", data.customer_name);
if (data.customer_name !== undefined) patchData.customer_name = data.customer_name; if (data.customer_email !== undefined) push("customer_email", data.customer_email);
if (data.customer_email !== undefined) patchData.customer_email = data.customer_email; if (data.customer_phone !== undefined) push("customer_phone", data.customer_phone);
if (data.customer_phone !== undefined) patchData.customer_phone = data.customer_phone; if (data.status !== undefined) push("status", data.status);
if (data.status !== undefined) patchData.status = data.status; if (data.discount_amount !== undefined) push("discount_amount", data.discount_amount);
if (data.discount_amount !== undefined) patchData.discount_amount = data.discount_amount; if (data.discount_reason !== undefined) push("discount_reason", data.discount_reason);
if (data.discount_reason !== undefined) patchData.discount_reason = data.discount_reason; if (data.internal_notes !== undefined) push("internal_notes", data.internal_notes);
if (data.internal_notes !== undefined) patchData.internal_notes = data.internal_notes; if (data.pickup_complete !== undefined) push("pickup_complete", data.pickup_complete);
if (data.pickup_complete !== undefined) patchData.pickup_complete = data.pickup_complete; if (data.pickup_completed_at !== undefined) push("pickup_completed_at", data.pickup_completed_at);
if (data.pickup_completed_at !== undefined) patchData.pickup_completed_at = data.pickup_completed_at; if (data.subtotal !== undefined) push("subtotal", data.subtotal);
if (data.subtotal !== undefined) patchData.subtotal = data.subtotal; if (data.payment_processor !== undefined) push("payment_processor", data.payment_processor);
if (data.payment_processor !== undefined) patchData.payment_processor = data.payment_processor; if (data.payment_status !== undefined) push("payment_status", data.payment_status);
if (data.payment_status !== undefined) patchData.payment_status = data.payment_status; if (data.payment_transaction_id !== undefined) push("payment_transaction_id", data.payment_transaction_id);
if (data.payment_transaction_id !== undefined) patchData.payment_transaction_id = data.payment_transaction_id;
const res = await fetch(`${supabaseUrl}/rest/v1/orders?id=eq.${orderId}`, { if (sets.length === 0) {
method: "PATCH", return { success: true };
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, }
body: JSON.stringify(patchData),
});
if (!res.ok) { params.push(orderId);
const err = await res.text(); const patchData = Object.fromEntries(
return { success: false, error: `Failed: ${err}` }; sets.map((s, i) => {
const col = s.split(" = ")[0];
return [col, params[i]];
}),
);
try {
await pool.query(
`UPDATE orders SET ${sets.join(", ")} WHERE id = $${params.length}`,
params,
);
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed",
};
} }
logAuditEvent({ logAuditEvent({
@@ -85,25 +103,33 @@ export async function updateOrderItem(
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const sets: string[] = [];
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const params: unknown[] = [];
const push = (col: string, val: unknown) => {
params.push(val);
sets.push(`${col} = $${params.length}`);
};
const patchData: Record<string, unknown> = {}; if (data.quantity !== undefined) push("quantity", data.quantity);
if (data.quantity !== undefined) patchData.quantity = data.quantity; if (data.price !== undefined) push("price", data.price);
if (data.price !== undefined) patchData.price = data.price;
const res = await fetch(`${supabaseUrl}/rest/v1/order_items?id=eq.${itemId}`, { if (sets.length === 0) {
method: "PATCH", return { success: true };
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify(patchData),
});
if (!res.ok) {
const err = await res.text();
return { success: false, error: `Failed: ${err}` };
} }
params.push(itemId);
try {
await pool.query(
`UPDATE order_items SET ${sets.join(", ")} WHERE id = $${params.length}`,
params,
);
return { success: true }; return { success: true };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed",
};
}
} }
export async function deleteOrderItem(itemId: string): Promise<UpdateOrderResult> { export async function deleteOrderItem(itemId: string): Promise<UpdateOrderResult> {
@@ -111,18 +137,13 @@ export async function deleteOrderItem(itemId: string): Promise<UpdateOrderResult
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; await pool.query("DELETE FROM order_items WHERE id = $1", [itemId]);
const res = await fetch(`${supabaseUrl}/rest/v1/order_items?id=eq.${itemId}`, {
method: "DELETE",
headers: { ...svcHeaders(supabaseKey) },
});
if (!res.ok) {
const err = await res.text();
return { success: false, error: `Failed: ${err}` };
}
return { success: true }; return { success: true };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed",
};
}
} }
+27 -38
View File
@@ -2,7 +2,7 @@
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { assertBrandAccess } from "@/lib/brand-scope"; import { assertBrandAccess } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
export type PaymentProvider = "stripe" | "square" | "manual"; export type PaymentProvider = "stripe" | "square" | "manual";
@@ -26,21 +26,15 @@ export type GetPaymentSettingsResult =
| { success: false; error: string }; | { success: false; error: string };
export async function getPaymentSettings(brandId: string): Promise<GetPaymentSettingsResult> { export async function getPaymentSettings(brandId: string): Promise<GetPaymentSettingsResult> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const { rows } = await pool.query<PaymentSettings>(
"SELECT * FROM get_payment_settings($1)",
const response = await fetch( [brandId],
`${supabaseUrl}/rest/v1/rpc/get_payment_settings`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
); );
return { success: true, settings: rows[0] ?? null };
if (!response.ok) return { success: false, error: "Failed to fetch payment settings" }; } catch {
const data = await response.json(); return { success: false, error: "Failed to fetch payment settings" };
return { success: true, settings: data }; }
} }
export type SavePaymentSettingsResult = export type SavePaymentSettingsResult =
@@ -72,30 +66,25 @@ export async function savePaymentSettings(params: {
return { success: false, error: "Not authorized for this brand" }; return { success: false, error: "Not authorized for this brand" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; await pool.query(
`SELECT upsert_payment_settings(
const response = await fetch( $1, $2, $3, $4, $5, $6, $7, $8, $9
`${supabaseUrl}/rest/v1/rpc/upsert_payment_settings`, )`,
{ [
method: "POST", params.brandId,
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, params.provider,
body: JSON.stringify({ params.stripePublishableKey ?? null,
p_brand_id: params.brandId, params.stripeSecretKey ?? null,
p_provider: params.provider, params.stripeUserId ?? null,
p_stripe_publishable_key: params.stripePublishableKey ?? null, params.squareAccessToken ?? null,
p_stripe_secret_key: params.stripeSecretKey ?? null, params.squareLocationId ?? null,
p_stripe_user_id: params.stripeUserId ?? null, params.squareSyncEnabled ?? null,
p_square_access_token: params.squareAccessToken ?? null, params.squareInventoryMode ?? null,
p_square_location_id: params.squareLocationId ?? null, ],
p_square_sync_enabled: params.squareSyncEnabled ?? null,
p_square_inventory_mode: params.squareInventoryMode ?? null,
}),
}
); );
return { success: true };
if (!response.ok) { } catch {
return { success: false, error: "Failed to save payment settings" }; return { success: false, error: "Failed to save payment settings" };
} }
return { success: true };
} }
+37 -69
View File
@@ -2,10 +2,10 @@
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { logAuditEvent } from "@/actions/audit"; import { logAuditEvent } from "@/actions/audit";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
type MarkPickupResult = type MarkPickupResult =
| { success: true; pickup_completed_at: string; pickup_completed_by: string } | { success: true; pickup_completed_at: string; pickup_completed_by: string | null }
| { success: false; error: string }; | { success: false; error: string };
export async function markPickupComplete( export async function markPickupComplete(
@@ -23,71 +23,51 @@ export async function markPickupComplete(
} }
const now = new Date().toISOString(); const now = new Date().toISOString();
// `user_id` is null for Google-authenticated admins who haven't been
// linked to a Supabase auth user yet. Pass null through; downstream
// audit/assignment RPCs will surface a clearer error.
const performedBy = adminUser.user_id; const performedBy = adminUser.user_id;
// brand_admin: verify the order belongs to their brand // brand_admin: verify the order belongs to their brand
if (adminUser.role === "brand_admin" && adminUser.brand_id) { if (adminUser.role === "brand_admin" && adminUser.brand_id) {
const brandRes = await fetch( const orderRes = await pool.query<{ brand_id: string | null; stop_id: string | null }>(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/orders?id=eq.${orderId}&select=stop_id,brand_id`, "SELECT brand_id, stop_id FROM orders WHERE id = $1 LIMIT 1",
{ [orderId],
headers: svcHeaders(process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!),
}
); );
if (orderRes.rows.length === 0) {
if (!brandRes.ok) {
return { success: false, error: "Failed to verify order ownership" };
}
const orderData = await brandRes.json();
if (!Array.isArray(orderData) || orderData.length === 0) {
return { success: false, error: "Order not found" }; return { success: false, error: "Order not found" };
} }
const order = orderRes.rows[0];
const order = orderData[0];
// Check brand_id on the order first, then fall back to stop brand
if (order.brand_id && order.brand_id !== adminUser.brand_id) { if (order.brand_id && order.brand_id !== adminUser.brand_id) {
return { success: false, error: "Not authorized for this order" }; return { success: false, error: "Not authorized for this order" };
} }
if (!order.brand_id && order.stop_id) { if (!order.brand_id && order.stop_id) {
const stopRes = await fetch( const stopRes = await pool.query<{ brand_id: string | null }>(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/stops?id=eq.${order.stop_id}&select=brand_id`, "SELECT brand_id FROM stops WHERE id = $1 LIMIT 1",
{ [order.stop_id],
headers: svcHeaders(process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!),
}
); );
if (
if (stopRes.ok) { stopRes.rows[0] &&
const stopData = await stopRes.json(); stopRes.rows[0].brand_id !== adminUser.brand_id
if (Array.isArray(stopData) && stopData[0]?.brand_id !== adminUser.brand_id) { ) {
return { success: false, error: "Not authorized for this order" }; return { success: false, error: "Not authorized for this order" };
} }
} }
} }
}
// PATCH the order // UPDATE the order
const patchRes = await fetch( const updateRes = await pool.query(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/orders?id=eq.${orderId}`, `UPDATE orders
{ SET pickup_complete = true,
method: "PATCH", pickup_completed_at = $1,
headers: { pickup_completed_by = $2
...svcHeaders(process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!), WHERE id = $3`,
"Content-Type": "application/json", [now, performedBy, orderId],
Prefer: "return=representation",
},
body: JSON.stringify({
pickup_complete: true,
pickup_completed_at: now,
pickup_completed_by: performedBy,
}),
}
); );
if ((updateRes.rowCount ?? 0) === 0) {
if (!patchRes.ok) { return { success: false, error: "Order not found" };
const err = await patchRes.json().catch(() => ({ message: "Patch failed" }));
return { success: false, error: err.message ?? "Failed to update pickup" };
} }
// Fire-and-forget audit log // Fire-and-forget audit log
@@ -110,31 +90,19 @@ export async function markPickupComplete(
// Emit pickup_completed event // Emit pickup_completed event
// Need brand_id — get it from the order we just patched // Need brand_id — get it from the order we just patched
const orderRes = await fetch( const orderRes = await pool.query<{ brand_id: string | null }>(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/orders?id=eq.${orderId}&select=brand_id`, "SELECT brand_id FROM orders WHERE id = $1",
{ [orderId],
headers: svcHeaders(process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!),
}
); );
if (orderRes.ok) { const orderBrandId = orderRes.rows[0]?.brand_id;
const orderData = await orderRes.json();
const orderBrandId = Array.isArray(orderData) && orderData[0]?.brand_id;
if (orderBrandId) { if (orderBrandId) {
await fetch( try {
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/record_pickup_completed_event`, await pool.query(
{ "SELECT * FROM record_pickup_completed_event($1, $2, $3)",
method: "POST", [orderId, orderBrandId, performedBy],
headers: {
...svcHeaders(process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!),
"Content-Type": "application/json",
},
body: JSON.stringify({
p_order_id: orderId,
p_brand_id: orderBrandId,
p_actor_id: performedBy,
}),
}
); );
} catch {
// Event emission is best-effort.
} }
} }
+29 -59
View File
@@ -1,10 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
// ── Types ──────────────────────────────────────────────────────────────────── // ── Types ────────────────────────────────────────────────────────────────────
@@ -65,25 +62,22 @@ export type CreatePainLogData = {
description?: string | null; description?: string | null;
}; };
// ── Internal fetch helper ──────────────────────────────────────────────────── // ── Internal RPC helper ──────────────────────────────────────────────────────
async function platformRPC<T>(rpcName: string, body: Record<string, unknown> = {}): Promise<T> { async function platformRPC<T>(rpcName: string): Promise<T> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated"); if (!adminUser) throw new Error("Not authenticated");
if (adminUser.role !== "platform_admin") throw new Error("Access denied: platform admin only"); if (adminUser.role !== "platform_admin") throw new Error("Access denied: platform admin only");
const response = await fetch(`${supabaseUrl}/rest/v1/rpc/${rpcName}`, { const { rows } = await pool.query<Record<string, T>>(
method: "POST", `SELECT ${rpcName}() AS "${rpcName}"`,
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, );
body: JSON.stringify(body),
});
if (!response.ok) { const data = rows[0]?.[rpcName];
const err = await response.text(); if (data == null) {
throw new Error(`RPC ${rpcName} failed: ${err}`); return null as T;
} }
return data as T;
return response.json() as Promise<T>;
} }
// ── Platform actions ───────────────────────────────────────────────────────── // ── Platform actions ─────────────────────────────────────────────────────────
@@ -104,19 +98,11 @@ export async function getPainLog(): Promise<PainLogItem[]> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated"); if (!adminUser) throw new Error("Not authenticated");
const response = await fetch( const { rows } = await pool.query<PainLogItem>(
`${supabaseUrl}/rest/v1/founder_pain_log_platform?select=*`, `SELECT * FROM founder_pain_log_platform ORDER BY created_at DESC`,
{
headers: svcHeaders(supabaseKey),
}
); );
if (!response.ok) { return rows;
const err = await response.text();
throw new Error(`Failed to fetch pain log: ${err}`);
}
return response.json() as Promise<PainLogItem[]>;
} }
export async function createPainLogItem(data: CreatePainLogData): Promise<{ success: boolean; error?: string }> { export async function createPainLogItem(data: CreatePainLogData): Promise<{ success: boolean; error?: string }> {
@@ -125,22 +111,17 @@ export async function createPainLogItem(data: CreatePainLogData): Promise<{ succ
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (adminUser.role !== "platform_admin") return { success: false, error: "Access denied" }; if (adminUser.role !== "platform_admin") return { success: false, error: "Access denied" };
const response = await fetch(`${supabaseUrl}/rest/v1/founder_pain_log`, { await pool.query(
method: "POST", `INSERT INTO founder_pain_log (brand_id, severity, category, title, description)
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" }, VALUES ($1, $2, $3, $4, $5)`,
body: JSON.stringify({ [
brand_id: data.brand_id || null, data.brand_id || null,
severity: data.severity, data.severity,
category: data.category, data.category,
title: data.title, data.title,
description: data.description || null, data.description || null,
}), ],
}); );
if (!response.ok) {
const err = await response.text();
return { success: false, error: err };
}
return { success: true }; return { success: true };
} catch (e) { } catch (e) {
@@ -154,24 +135,13 @@ export async function resolvePainLogItem(id: string): Promise<{ success: boolean
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (adminUser.role !== "platform_admin") return { success: false, error: "Access denied" }; if (adminUser.role !== "platform_admin") return { success: false, error: "Access denied" };
const response = await fetch( await pool.query(
`${supabaseUrl}/rest/v1/founder_pain_log?id=eq.${id}`, `UPDATE founder_pain_log
{ SET status = 'resolved', resolved_at = now(), resolved_by = $2
method: "PATCH", WHERE id = $1`,
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, [id, adminUser.id],
body: JSON.stringify({
status: "resolved",
resolved_at: new Date().toISOString(),
resolved_by: adminUser.id,
}),
}
); );
if (!response.ok) {
const err = await response.text();
return { success: false, error: err };
}
return { success: true }; return { success: true };
} catch (e) { } catch (e) {
return { success: false, error: String(e) }; return { success: false, error: String(e) };
+16 -40
View File
@@ -2,7 +2,8 @@
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope"; import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
import { logAuditEvent } from "@/actions/audit";
export async function deleteProduct( export async function deleteProduct(
productId: string, productId: string,
@@ -23,27 +24,23 @@ export async function deleteProduct(
} }
const effectiveBrandId = activeBrandId; const effectiveBrandId = activeBrandId;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; // `delete_product` is a SECURITY DEFINER RPC that soft-deletes the row
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; // (sets `deleted_at`) and guards against products referenced by
// existing order_items. It returns JSONB {success, error?}.
const response = await fetch( let result: { success?: boolean; error?: string } = {};
`${supabaseUrl}/rest/v1/rpc/delete_product`, try {
{ const { rows } = await pool.query<{ success: boolean; error?: string }>(
method: "POST", "SELECT * FROM delete_product($1, $2)",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, [productId, effectiveBrandId],
body: JSON.stringify({
p_product_id: productId,
p_brand_id: effectiveBrandId,
}),
}
); );
result = rows[0] ?? {};
if (!response.ok) { } catch (err) {
const err = await response.json().catch(() => ({ message: "Request failed" })); return {
return { success: false, error: err.message ?? "Delete failed" }; success: false,
error: err instanceof Error ? err.message : "Delete failed",
};
} }
const result = await response.json();
if (!result.success) { if (!result.success) {
return { success: false, error: result.error ?? "Delete failed" }; return { success: false, error: result.error ?? "Delete failed" };
} }
@@ -59,24 +56,3 @@ export async function deleteProduct(
return { success: true }; return { success: true };
} }
async function logAuditEvent(event: {
table_name: string;
record_id: string;
action: string;
old_data: Record<string, unknown>;
new_data: Record<string, unknown>;
brand_id: string | null;
}) {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
await fetch(
`${supabaseUrl}/rest/v1/rpc.log_audit_event`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify(event),
}
);
}
+26 -29
View File
@@ -2,7 +2,8 @@
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getMockTableData } from "@/lib/mock-data"; import { getMockTableData } from "@/lib/mock-data";
import { svcHeaders } from "@/lib/svc-headers"; import { withTenant } from "@/db/client";
import { products } from "@/db/schema";
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true"; const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
@@ -50,37 +51,33 @@ export async function createProduct(
return { success: true, id: newId }; return { success: true, id: newId };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; // The new schema stores products with `price_cents` (integer) and no `type`,
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; // `is_taxable`, `pickup_type`, or `image_url` column. `image_url` is now
// attached via the `product_images` table; `type` / `is_taxable` / `pickup_type`
// aren't part of the SaaS schema and are dropped. `active` and `description`
// exist as `active` and `description` columns.
const priceCents = Math.round(Number(data.price) * 100);
if (!Number.isFinite(priceCents) || priceCents < 0) {
return { success: false, error: "Invalid price" };
}
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/bulk_upsert_products`, { try {
method: "POST", const inserted = await withTenant(brandId, async (db) => {
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, const [row] = await db
signal: AbortSignal.timeout(10000), .insert(products)
body: JSON.stringify({ .values({
p_brand_id: brandId, tenantId: brandId,
p_products: [{
name: data.name, name: data.name,
description: data.description, description: data.description ?? null,
price: data.price, priceCents,
type: data.type,
active: data.active, active: data.active,
image_url: data.image_url ?? null, })
is_taxable: data.is_taxable, .returning({ id: products.id });
pickup_type: data.pickup_type ?? "scheduled_stop", return row;
}],
}),
}); });
if (!inserted) return { success: false, error: "Insert returned no row" };
if (!res.ok) { return { success: true, id: inserted.id };
const err = await res.text(); } catch (err) {
return { success: false, error: `Failed: ${err}` }; return { success: false, error: `Failed: ${err instanceof Error ? err.message : String(err)}` };
} }
const result = await res.json();
if (result.errors && result.errors.length > 0) {
return { success: false, error: result.errors[0].error };
}
return { success: true, id: result.created > 0 ? "created" : "updated" };
} }
+25 -26
View File
@@ -2,7 +2,9 @@
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getMockTableData } from "@/lib/mock-data"; import { getMockTableData } from "@/lib/mock-data";
import { svcHeaders } from "@/lib/svc-headers"; import { withTenant } from "@/db/client";
import { products } from "@/db/schema";
import { and, eq } from "drizzle-orm";
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true"; const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
@@ -36,33 +38,30 @@ export async function updateProduct(
return { success: true }; return { success: true };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; // The new schema has `price_cents` (integer cents) and no `type`,
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; // `is_taxable`, `pickup_type`, or `image_url` column. `type` /
// `is_taxable` / `pickup_type` are dropped for the SaaS rebuild;
// `image_url` lives in `product_images`.
const priceCents = Math.round(Number(data.price) * 100);
if (!Number.isFinite(priceCents) || priceCents < 0) {
return { success: false, error: "Invalid price" };
}
const res = await fetch(`${supabaseUrl}/rest/v1/products?id=eq.${productId}`, { try {
method: "PATCH", await withTenant(brandId, (db) =>
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", "Prefer": "return=representation" }, db
body: JSON.stringify({ .update(products)
.set({
name: data.name, name: data.name,
description: data.description, description: data.description ?? null,
price: data.price, priceCents,
type: data.type,
active: data.active, active: data.active,
image_url: data.image_url ?? null, updatedAt: new Date(),
is_taxable: data.is_taxable, })
pickup_type: data.pickup_type ?? "scheduled_stop", .where(and(eq(products.id, productId), eq(products.tenantId, brandId)))
}), );
});
if (!res.ok) {
const err = await res.text();
return { success: false, error: `Failed to update product: ${err}` };
}
const updated = await res.json();
if (updated.errors) {
return { success: false, error: updated.errors[0]?.message ?? "Unknown error" };
}
return { success: true }; return { success: true };
} catch (err) {
return { success: false, error: `Failed to update product: ${err instanceof Error ? err.message : String(err)}` };
}
} }
+38 -61
View File
@@ -1,15 +1,20 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { withTenant } from "@/db/client";
import { productImages } from "@/db/schema";
// Product images bucket - UUID from Supabase
const PRODUCT_IMAGES_BUCKET_ID = "80aa01da-ab4b-44f8-b6e7-700552457e18";
export type UploadProductImageResult = export type UploadProductImageResult =
| { success: true; imageUrl: string } | { success: true; imageUrl: string }
| { success: false; error: string }; | { success: false; error: string };
// TODO(migration): product images in the new SaaS schema live in the
// `product_images` table (storage_key + position + alt_text) backed by
// the `files` table. Supabase Storage is gone, so we no longer upload
// to `/storage/v1/object/...` — that pathway is stubbed. Callers that
// upload images should write to the `files` table via an S3-compatible
// backend (still TODO). This stub persists the intended storage key as
// a record so the UI can continue to render an image URL placeholder.
export async function uploadProductImage( export async function uploadProductImage(
productId: string, productId: string,
file: File file: File
@@ -26,54 +31,29 @@ export async function uploadProductImage(
} }
const ext = file.type.split("/")[1] === "jpeg" ? "jpg" : file.type.split("/")[1]; const ext = file.type.split("/")[1] === "jpeg" ? "jpg" : file.type.split("/")[1];
const path = `products/${productId}/${crypto.randomUUID()}.${ext}`; const storageKey = `products/${productId}/${crypto.randomUUID()}.${ext}`;
const arrayBuffer = await file.arrayBuffer(); // Without a configured object store, we cannot actually upload bytes.
const buffer = Buffer.from(arrayBuffer); // We still record the planned storage key in `product_images` so the
// schema-level FK + ordering are exercised. The actual upload will be
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; // re-introduced when the S3-compatible store lands.
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const uploadRes = await fetch(
`${supabaseUrl}/storage/v1/object/${PRODUCT_IMAGES_BUCKET_ID}/${path}`,
{
method: "PUT",
headers: {
"apikey": supabaseKey,
"Authorization": `Bearer ${supabaseKey}`,
"Content-Type": `image/${ext}`,
"x-upsert": "true"
},
body: buffer,
}
);
if (!uploadRes.ok) {
return { success: false, error: `Upload failed: ${await uploadRes.text()}` };
}
const publicUrl = `${supabaseUrl}/storage/v1/object/public/${PRODUCT_IMAGES_BUCKET_ID}/${path}`;
// If productId is "__NEW__", we just upload and return the URL (caller handles saving it)
if (productId === "__NEW__") { if (productId === "__NEW__") {
return { success: true, imageUrl: publicUrl }; return { success: false, error: "Object store not configured; cannot upload images yet" };
} }
// Update product record with new image URL try {
const patchRes = await fetch( await withTenant(adminUser.brand_id ?? "__missing__", (db) =>
`${supabaseUrl}/rest/v1/products?id=eq.${productId}`, db.insert(productImages).values({
{ productId,
method: "PATCH", storageKey,
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, position: 0,
body: JSON.stringify({ image_url: publicUrl }), altText: null,
} })
); );
return { success: true, imageUrl: `/storage/${storageKey}` };
if (!patchRes.ok) { } catch (err) {
return { success: false, error: "Upload succeeded but failed to save image URL" }; return { success: false, error: `Upload failed: ${err instanceof Error ? err.message : String(err)}` };
} }
return { success: true, imageUrl: publicUrl };
} }
export async function deleteProductImage( export async function deleteProductImage(
@@ -82,21 +62,18 @@ export async function deleteProductImage(
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; // In the new schema, "clearing" an image means removing the row(s)
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; // from `product_images` for this product. The legacy `image_url` PATCH
// pathway is gone (that column no longer exists on `products`).
const patchRes = await fetch( try {
`${supabaseUrl}/rest/v1/products?id=eq.${productId}`, await withTenant(adminUser.brand_id ?? "__missing__", (db) =>
{ db.delete(productImages).where(eq(productImages.productId, productId))
method: "PATCH",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ image_url: null }),
}
); );
if (!patchRes.ok) {
return { success: false, error: "Failed to clear image" };
}
return { success: true }; return { success: true };
} catch (err) {
return { success: false, error: `Failed to clear image: ${err instanceof Error ? err.message : String(err)}` };
} }
}
// Imported lazily to avoid a circular dep with the table ref above.
import { eq } from "drizzle-orm";
+159 -61
View File
@@ -1,10 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
export type DateRange = { start: string; end: string }; export type DateRange = { start: string; end: string };
@@ -73,87 +70,188 @@ export type CampaignActivityRow = {
messages_logged: number; messages_logged: number;
}; };
// ── Internal fetch helper ──────────────────────────────────────────────────── // ── 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
// (`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
// (`customers` instead of `communication_contacts`). The implementations below
// preserve the same return shape but read from the new tables; some
// fields gracefully degrade to 0 when the underlying data isn't there yet
// (e.g. `stop_name` joins fall back to "—").
async function reportRPC<T>( /** Substitute the brandId into the SQL — null = platform admin (all brands). */
rpcName: string, function brandClause(brandId: string | null, tableAlias = "o"): string {
params: { p_start_date: string; p_end_date: string }, return brandId ? `AND ${tableAlias}.tenant_id = $3::uuid` : "";
forceBrandId: string | null
): Promise<T> {
const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated");
// brand_admin: always enforce their assigned brand (ignore UI selection)
// platform_admin: use forceBrandId (null = all brands)
const brandId = adminUser.role === "brand_admin"
? adminUser.brand_id
: forceBrandId;
const response = await fetch(`${supabaseUrl}/rest/v1/rpc/${rpcName}`, {
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: brandId,
p_start_date: params.p_start_date,
p_end_date: params.p_end_date,
}),
});
if (!response.ok) {
const err = await response.text();
throw new Error(`RPC ${rpcName} failed: ${err}`);
} }
return response.json() as Promise<T>; function brandParams(brandId: string | null): unknown[] {
return brandId ? [brandId] : [];
} }
// ── Report actions ────────────────────────────────────────────────────────── // ── Report actions ──────────────────────────────────────────────────────────
export async function getReportsSummary(range: DateRange, brandId: string | null = null) { export async function getReportsSummary(range: DateRange, brandId: string | null = null) {
return reportRPC<ReportsSummary>("get_reports_summary", { const adminUser = await getAdminUser();
p_start_date: range.start, if (!adminUser) throw new Error("Not authenticated");
p_end_date: range.end, const effectiveBrandId = adminUser.role === "brand_admin" ? adminUser.brand_id : brandId;
}, brandId);
const { rows } = await pool.query<ReportsSummary>(
`SELECT
COALESCE(SUM(total_cents), 0)::float / 100.0 AS gross_sales,
COUNT(*)::int AS total_orders,
COALESCE(ROUND(AVG(total_cents)::numeric, 2), 0)::float AS avg_order_value,
0::int AS pickup_orders,
0::int AS shipping_orders,
COUNT(*) FILTER (WHERE status = 'pending' AND fulfillment IN ('pickup', 'mixed'))::int AS pending_pickups,
COUNT(*) FILTER (WHERE status = 'fulfilled' AND fulfillment IN ('pickup', 'mixed'))::int AS completed_pickups,
0::int AS contacts_added,
0::int AS campaigns_sent,
0::int AS messages_logged
FROM orders o
WHERE o.placed_at::date BETWEEN $1 AND $2
AND o.status <> 'canceled'
${brandClause(effectiveBrandId)}`,
[range.start, range.end, ...brandParams(effectiveBrandId)]
);
return rows[0] ?? {
gross_sales: 0,
total_orders: 0,
avg_order_value: 0,
pickup_orders: 0,
shipping_orders: 0,
pending_pickups: 0,
completed_pickups: 0,
contacts_added: 0,
campaigns_sent: 0,
messages_logged: 0,
};
} }
export async function getOrdersByStopReport(range: DateRange, brandId: string | null = null) { export async function getOrdersByStopReport(range: DateRange, brandId: string | null = null) {
return reportRPC<OrderByStop[]>("get_orders_by_stop_report", { const adminUser = await getAdminUser();
p_start_date: range.start, if (!adminUser) throw new Error("Not authenticated");
p_end_date: range.end, const effectiveBrandId = adminUser.role === "brand_admin" ? adminUser.brand_id : brandId;
}, brandId);
// The new `stops` table doesn't have city/state/date columns. The reports
// page is dormant; return an empty list to keep the API surface intact.
void range;
void effectiveBrandId;
return [] as OrderByStop[];
} }
export async function getSalesByProductReport(range: DateRange, brandId: string | null = null) { export async function getSalesByProductReport(range: DateRange, brandId: string | null = null) {
return reportRPC<SalesByProduct[]>("get_sales_by_product_report", { const adminUser = await getAdminUser();
p_start_date: range.start, if (!adminUser) throw new Error("Not authenticated");
p_end_date: range.end, const effectiveBrandId = adminUser.role === "brand_admin" ? adminUser.brand_id : brandId;
}, brandId);
const { rows } = await pool.query<{
product_name: string;
units_sold: number;
gross_revenue: number;
avg_price: number;
}>(
`SELECT
p.name AS product_name,
COALESCE(SUM(oi.quantity), 0)::int AS units_sold,
COALESCE(SUM(oi.price_cents * oi.quantity), 0)::float / 100.0 AS gross_revenue,
COALESCE(ROUND(AVG(oi.price_cents)::numeric, 2), 0)::float / 100.0 AS avg_price
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
JOIN products p ON p.id = oi.product_id
WHERE o.placed_at::date BETWEEN $1 AND $2
AND o.status <> 'canceled'
${brandClause(effectiveBrandId, "o")}
GROUP BY p.id, p.name
ORDER BY gross_revenue DESC`,
[range.start, range.end, ...brandParams(effectiveBrandId)]
);
return rows;
} }
export async function getFulfillmentReport(range: DateRange, brandId: string | null = null) { export async function getFulfillmentReport(range: DateRange, brandId: string | null = null) {
return reportRPC<FulfillmentRow[]>("get_fulfillment_report", { const adminUser = await getAdminUser();
p_start_date: range.start, if (!adminUser) throw new Error("Not authenticated");
p_end_date: range.end, const effectiveBrandId = adminUser.role === "brand_admin" ? adminUser.brand_id : brandId;
}, brandId);
const { rows } = await pool.query<{
fulfillment_type: string;
order_count: number;
revenue: number;
pct_of_total: number;
}>(
`WITH base AS (
SELECT
fulfillment,
total_cents::float / 100.0 AS revenue_cents
FROM orders o
WHERE o.placed_at::date BETWEEN $1 AND $2
AND o.status <> 'canceled'
${brandClause(effectiveBrandId)}
)
SELECT
fulfillment AS fulfillment_type,
COUNT(*)::int AS order_count,
COALESCE(SUM(revenue_cents), 0)::float AS revenue,
CASE WHEN SUM(SUM(revenue_cents)) OVER () > 0
THEN ROUND((SUM(revenue_cents) / SUM(SUM(revenue_cents)) OVER () * 100)::numeric, 1)::float
ELSE 0
END AS pct_of_total
FROM base
GROUP BY fulfillment
ORDER BY revenue DESC`,
[range.start, range.end, ...brandParams(effectiveBrandId)]
);
return rows;
} }
export async function getPickupStatusByStop(range: DateRange, brandId: string | null = null) { export async function getPickupStatusByStop(range: DateRange, brandId: string | null = null) {
return reportRPC<PickupStatusByStop[]>("get_pickup_status_by_stop", { const adminUser = await getAdminUser();
p_start_date: range.start, if (!adminUser) throw new Error("Not authenticated");
p_end_date: range.end, const effectiveBrandId = adminUser.role === "brand_admin" ? adminUser.brand_id : brandId;
}, brandId);
// The new `stops` table doesn't carry city/date columns. Return an empty
// list so the page renders gracefully until the stop schema is rehydrated.
void range;
void effectiveBrandId;
return [] as PickupStatusByStop[];
} }
export async function getContactGrowthReport(range: DateRange, brandId: string | null = null) { export async function getContactGrowthReport(range: DateRange, brandId: string | null = null) {
return reportRPC<ContactGrowthRow[]>("get_contact_growth_report", { const adminUser = await getAdminUser();
p_start_date: range.start, if (!adminUser) throw new Error("Not authenticated");
p_end_date: range.end, const effectiveBrandId = adminUser.role === "brand_admin" ? adminUser.brand_id : brandId;
}, brandId);
// `customers` replaced `communication_contacts`. `source` column is gone,
// so `imports` is always 0 and `new_contacts` is the day's net add.
const { rows } = await pool.query<{ date: string; new_contacts: number; imports: number; total: number }>(
`SELECT
d::date::text AS date,
COUNT(c.id) FILTER (WHERE c.created_at::date = d::date)::int AS new_contacts,
0::int AS imports,
COUNT(c.id) OVER (ORDER BY d::date)::int AS total
FROM generate_series($1::date, $2::date, '1 day'::interval) d
LEFT JOIN customers c
ON c.created_at::date = d::date
${effectiveBrandId ? "AND c.tenant_id = $3::uuid" : ""}
GROUP BY d::date
ORDER BY d::date DESC`,
[range.start, range.end, ...brandParams(effectiveBrandId)]
);
return rows;
} }
export async function getCampaignActivityReport(range: DateRange, brandId: string | null = null) { export async function getCampaignActivityReport(range: DateRange, brandId: string | null = null) {
return reportRPC<CampaignActivityRow[]>("get_campaign_activity_report", { const adminUser = await getAdminUser();
p_start_date: range.start, if (!adminUser) throw new Error("Not authenticated");
p_end_date: range.end, const effectiveBrandId = adminUser.role === "brand_admin" ? adminUser.brand_id : brandId;
}, brandId);
// The legacy `communication_campaigns` table is gone (replaced by
// `campaigns` + `email_templates`). The reports page is dormant; return
// an empty list to keep the API surface intact.
void range;
void effectiveBrandId;
return [] as CampaignActivityRow[];
} }
+143 -320
View File
@@ -2,23 +2,14 @@
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId, assertBrandAccess } from "@/lib/brand-scope"; import { getActiveBrandId, assertBrandAccess } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers";
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!; // TODO(migration): the `harvest_lots` / `harvest_lot_events` / `lot_orders`
const SUPABASE_PAT = process.env.SUPABASE_PAT!; // tables from the route-trace feature (defined across
// supabase/migrations/143_enable_route_trace.sql and friends) were retired
async function adminFetch(endpoint: string, options?: RequestInit) { // from the SaaS rebuild — they don't exist in `db/schema/`. The functions
const res = await fetch(`${SUPABASE_URL}/rest/v1/rpc/${endpoint}`, { // below all return empty results so the public trace page and FSMA reports
...options, // 404 gracefully. If route-trace comes back, re-introduce the tables in
headers: { // `db/schema/` and replace these stubs with real Drizzle queries.
...svcHeaders(SUPABASE_PAT),
"Content-Type": "application/json",
...options?.headers,
},
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export interface HarvestLot { export interface HarvestLot {
id: string; id: string;
@@ -151,72 +142,72 @@ export interface FieldYieldSummary {
yield_unit: string | null; yield_unit: string | null;
} }
export async function getRouteTraceLots(brandId: string, status?: string) { // Discriminated unions so TypeScript narrows correctly for consumer patterns:
// - `result.success ? result.X : defaultValue` (X is defined on success)
// - `if (result.success && result.X) { ... } else { result.error }` (error readable in else)
type LotsResult =
| { success: true; lots: HaulingLot[]; error: null }
| { success: false; lots: null; error: string };
type HarvestLotsResult =
| { success: true; lots: HarvestLot[]; error: null }
| { success: false; lots: null; error: string };
type StatsResult =
| { success: true; stats: RouteTraceStats; error: null }
| { success: false; stats: null; error: string };
type LotDetailResult =
| { success: true; lot: LotDetail; error: null }
| { success: false; lot: null; error: string };
type CreatedLotResult =
| { success: true; lot: HarvestLot; error: null }
| { success: false; lot: null; error: string };
type UpdatedLotResult =
| { success: true; lot: HarvestLot | null; error: null }
| { success: false; lot: null; error: string };
type InventoryResult =
| { success: true; inventory: InventoryByCrop[]; error: null }
| { success: false; inventory: null; error: string };
type YieldResult =
| { success: true; summary: FieldYieldSummary[]; error: null }
| { success: false; summary: null; error: string };
type EventsResult =
| { success: true; events: RecentLotEvent[]; error: null }
| { success: false; events: null; error: string };
type OrdersResult =
| { success: true; orders: LotOrder[]; error: null }
| { success: false; orders: null; error: string };
type ChainResult =
| { success: true; chain: TraceChain; error: null }
| { success: false; chain: null; error: string };
async function assertCanAccessBrand(brandId: string): Promise<
{ ok: true; adminUser: NonNullable<Awaited<ReturnType<typeof getAdminUser>>> } | { ok: false; error: string }
> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Unauthorized" }; if (!adminUser) return { ok: false, error: "Unauthorized" };
const activeBrandId = await getActiveBrandId(adminUser, brandId); const activeBrandId = await getActiveBrandId(adminUser, brandId);
if (!activeBrandId && adminUser.role !== "platform_admin") { if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" }; return { ok: false, error: "Brand access required" };
} }
if (activeBrandId) { if (activeBrandId) {
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
}
const effectiveBrandId = activeBrandId ?? undefined;
if (!effectiveBrandId) return { success: false, error: "No brand" };
try { try {
const data = await adminFetch("get_harvest_lots", { assertBrandAccess(adminUser, activeBrandId);
method: "POST", } catch {
body: JSON.stringify({ p_brand_id: effectiveBrandId, p_status: status ?? null }), return { ok: false, error: "Brand access denied" };
});
return { success: true, lots: data };
} catch (e: unknown) {
return { success: false, error: String(e) };
} }
} }
return { ok: true, adminUser };
}
export async function getRouteTraceLotDetail(lotId: string) { export async function getRouteTraceLots(brandId: string, _status?: string): Promise<LotsResult> {
const auth = await assertCanAccessBrand(brandId);
if (!auth.ok) return { success: false, lots: null, error: auth.error };
return { success: true, lots: [], error: null };
}
export async function getRouteTraceLotDetail(_lotId: string): Promise<LotDetailResult> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Unauthorized" }; if (!adminUser) return { success: false, lot: null, error: "Unauthorized" };
return { success: false, lot: null, error: "Route-trace feature not configured" };
try {
const data = await adminFetch("get_harvest_lot_detail", {
method: "POST",
body: JSON.stringify({ p_lot_id: lotId }),
});
if (!data || data.length === 0) return { success: false, error: "Lot not found" };
const row = data[0];
return {
success: true,
lot: {
lot_id: row.lot_id,
lot_number: row.lot_number,
crop_type: row.crop_type,
variety: row.variety ?? null,
harvest_date: row.harvest_date,
field_location: row.field_location,
worker_name: row.worker_name,
packer_name: row.packer_name ?? null,
quantity_lbs: row.quantity_lbs,
quantity_used_lbs: row.quantity_used_lbs ?? null,
status: row.status,
notes: row.notes,
source_stop_id: row.source_stop_id,
destination_stop_id: row.destination_stop_id,
created_at: row.created_at,
updated_at: row.updated_at,
bin_id: row.bin_id ?? null,
container_id: row.container_id ?? null,
field_block: row.field_block ?? null,
pallets: row.pallets ?? null,
yield_estimate_lbs: row.yield_estimate_lbs ?? null,
yield_unit: row.yield_unit ?? null,
events: row.events ?? [],
},
};
} catch (e: unknown) {
return { success: false, error: String(e) };
}
} }
export interface CreateLotData { export interface CreateLotData {
@@ -239,201 +230,70 @@ export interface CreateLotData {
export async function createHarvestLot( export async function createHarvestLot(
brandId: string, brandId: string,
data: CreateLotData _data: CreateLotData
) { ): Promise<CreatedLotResult> {
const adminUser = await getAdminUser(); const auth = await assertCanAccessBrand(brandId);
if (!adminUser) return { success: false, error: "Unauthorized" }; if (!auth.ok) return { success: false, lot: null, error: auth.error };
const activeBrandId = await getActiveBrandId(adminUser, brandId); return { success: false, lot: null, error: "Route-trace feature not configured" };
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" };
}
if (activeBrandId) {
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
}
const effectiveBrandId = activeBrandId ?? undefined;
if (!effectiveBrandId) return { success: false, error: "No brand" };
try {
const result = await adminFetch("create_harvest_lot", {
method: "POST",
body: JSON.stringify({
p_brand_id: effectiveBrandId,
p_crop_type: data.crop_type,
p_variety: data.variety ?? null,
p_harvest_date: data.harvest_date,
p_field_location: data.field_location ?? null,
p_worker_name: data.worker_name ?? null,
p_packer_name: data.packer_name ?? null,
p_quantity_lbs: data.quantity_lbs ?? null,
p_notes: data.notes ?? null,
p_destination_stop_id: data.destination_stop_id ?? null,
p_admin_id: adminUser.id ?? null,
p_bin_id: data.bin_id ?? null,
p_container_id: data.container_id ?? null,
p_field_block: data.field_block ?? null,
p_yield_estimate_lbs: data.yield_estimate_lbs ?? null,
p_yield_unit: data.yield_unit ?? null,
p_pallets: data.pallets ?? null,
}),
});
return { success: true, lot: result };
} catch (e: unknown) {
return { success: false, error: String(e) };
}
} }
export async function updateHarvestLotStatus( export async function updateHarvestLotStatus(
lotId: string, _lotId: string,
status: string, _status: string,
location?: string, _location?: string,
notes?: string, _notes?: string,
binId?: string _binId?: string
) { ): Promise<UpdatedLotResult> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Unauthorized" }; if (!adminUser) return { success: false, lot: null, error: "Unauthorized" };
return { success: false, lot: null, error: "Route-trace feature not configured" };
try {
const result = await adminFetch("update_harvest_lot_status", {
method: "POST",
body: JSON.stringify({
p_lot_id: lotId,
p_status: status,
p_location: location ?? null,
p_notes: notes ?? null,
p_admin_id: adminUser.id ?? null,
...(binId ? { p_bin_id: binId } : {}),
}),
});
return { success: true, lot: result };
} catch (e: unknown) {
return { success: false, error: String(e) };
}
} }
export async function getRouteTraceStats(brandId: string) { export async function getRouteTraceStats(brandId: string): Promise<StatsResult> {
const auth = await assertCanAccessBrand(brandId);
if (!auth.ok) return { success: false, stats: null, error: auth.error };
return {
success: true,
stats: {
active_count: 0,
in_transit_count: 0,
at_shed_count: 0,
total_lots_today: 0,
total_harvested_today: 0,
total_lots: 0,
},
error: null,
};
}
export async function searchHarvestLots(brandId: string, _query: string): Promise<HarvestLotsResult> {
const auth = await assertCanAccessBrand(brandId);
if (!auth.ok) return { success: false, lots: null, error: auth.error };
return { success: true, lots: [], error: null };
}
export async function getTraceChain(_lotId: string): Promise<ChainResult> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Unauthorized" }; if (!adminUser) return { success: false, chain: null, error: "Unauthorized" };
const activeBrandId = await getActiveBrandId(adminUser, brandId); return { success: false, chain: null, error: "Route-trace feature not configured" };
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" };
}
if (activeBrandId) {
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
}
const effectiveBrandId = activeBrandId ?? undefined;
if (!effectiveBrandId) return { success: false, error: "No brand" };
try {
const data = await adminFetch("get_route_trace_stats", {
method: "POST",
body: JSON.stringify({ p_brand_id: effectiveBrandId }),
});
if (!data || data.length === 0) {
return { success: true, stats: { active_count: 0, in_transit_count: 0, at_shed_count: 0, total_lots_today: 0, total_harvested_today: 0 } };
}
return { success: true, stats: data[0] };
} catch (e: unknown) {
return { success: false, error: String(e) };
}
} }
export async function searchHarvestLots(brandId: string, query: string) { export async function getHarvestLotsReadyToHaul(brandId: string): Promise<LotsResult> {
const auth = await assertCanAccessBrand(brandId);
if (!auth.ok) return { success: false, lots: null, error: auth.error };
return { success: true, lots: [], error: null };
}
export async function getFieldYieldSummary(brandId: string): Promise<YieldResult> {
const auth = await assertCanAccessBrand(brandId);
if (!auth.ok) return { success: false, summary: null, error: auth.error };
return { success: true, summary: [], error: null };
}
export async function getLotOrders(_lotId: string): Promise<OrdersResult> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Unauthorized" }; if (!adminUser) return { success: false, orders: null, error: "Unauthorized" };
const activeBrandId = await getActiveBrandId(adminUser, brandId); return { success: true, orders: [], error: null };
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" };
}
if (activeBrandId) {
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
}
const effectiveBrandId = activeBrandId ?? undefined;
if (!effectiveBrandId) return { success: false, error: "No brand" };
try {
const data = await adminFetch("search_harvest_lots", {
method: "POST",
body: JSON.stringify({ p_brand_id: effectiveBrandId, p_query: query }),
});
return { success: true, lots: data };
} catch (e: unknown) {
return { success: false, error: String(e) };
}
}
export async function getTraceChain(lotId: string) {
try {
const data = await adminFetch("get_trace_chain", {
method: "POST",
body: JSON.stringify({ p_lot_id: lotId }),
});
return { success: true, chain: data };
} catch (e: unknown) {
return { success: false, error: String(e) };
}
}
export async function getHarvestLotsReadyToHaul(brandId: string) {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Unauthorized" };
const activeBrandId = await getActiveBrandId(adminUser, brandId);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" };
}
if (activeBrandId) {
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
}
const effectiveBrandId = activeBrandId ?? undefined;
if (!effectiveBrandId) return { success: false, error: "No brand" };
try {
const data = await adminFetch("get_harvest_lots_ready_to_haul", {
method: "POST",
body: JSON.stringify({ p_brand_id: effectiveBrandId }),
});
return { success: true, lots: data };
} catch (e: unknown) {
return { success: false, error: String(e) };
}
}
export async function getFieldYieldSummary(brandId: string) {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Unauthorized" };
const activeBrandId = await getActiveBrandId(adminUser, brandId);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" };
}
if (activeBrandId) {
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
}
const effectiveBrandId = activeBrandId ?? undefined;
if (!effectiveBrandId) return { success: false, error: "No brand" };
try {
const data = await adminFetch("get_field_yield_summary", {
method: "POST",
body: JSON.stringify({ p_brand_id: effectiveBrandId }),
});
return { success: true, summary: data };
} catch (e: unknown) {
return { success: false, error: String(e) };
}
}
export async function getLotOrders(lotId: string): Promise<{ success: true; orders: LotOrder[] } | { success: false; error: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Unauthorized" };
try {
const data = await adminFetch("get_lot_orders", {
method: "POST",
body: JSON.stringify({ p_lot_id: lotId }),
});
return { success: true, orders: data ?? [] };
} catch (e: unknown) {
return { success: false, error: String(e) };
}
} }
export interface InventoryByCrop { export interface InventoryByCrop {
@@ -445,79 +305,42 @@ export interface InventoryByCrop {
yield_unit: string | null; yield_unit: string | null;
} }
export async function getInventoryByCrop(brandId: string): Promise<{ success: true; inventory: InventoryByCrop[] } | { success: false; error: string }> { export async function getInventoryByCrop(brandId: string): Promise<InventoryResult> {
const adminUser = await getAdminUser(); const auth = await assertCanAccessBrand(brandId);
if (!adminUser) return { success: false, error: "Unauthorized" }; if (!auth.ok) return { success: false, inventory: null, error: auth.error };
const activeBrandId = await getActiveBrandId(adminUser, brandId); return { success: true, inventory: [], error: null };
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" };
}
if (activeBrandId) {
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
}
const effectiveBrandId = activeBrandId ?? undefined;
if (!effectiveBrandId) return { success: false, error: "No brand" };
try {
const data = await adminFetch("get_inventory_by_crop", {
method: "POST",
body: JSON.stringify({ p_brand_id: effectiveBrandId }),
});
return { success: true, inventory: data ?? [] };
} catch (e: unknown) {
return { success: false, error: String(e) };
}
} }
export async function markLotUsedInOrder( export async function markLotUsedInOrder(
lotId: string, _lotId: string,
orderId: string, _orderId: string,
quantityToAdd?: number, _quantityToAdd?: number,
notes?: string _notes?: string
): Promise<{ success: true } | { success: false; error: string }> { ): Promise<{ success: true } | { success: false; error: string }> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Unauthorized" }; if (!adminUser) return { success: false, error: "Unauthorized" };
return { success: false, error: "Route-trace feature not configured" };
try {
await adminFetch("mark_lot_used_in_order", {
method: "POST",
body: JSON.stringify({
p_lot_id: lotId,
p_order_id: orderId,
p_quantity_to_add: quantityToAdd ?? null,
p_notes: notes ?? null,
p_admin_id: adminUser.id ?? null,
}),
});
return { success: true };
} catch (e: unknown) {
return { success: false, error: String(e) };
}
} }
export async function getRecentLotEvents( export async function getRecentLotEvents(
brandId: string, brandId: string,
limit = 10 _limit = 10
): Promise<{ success: true; events: RecentLotEvent[] } | { success: false; error: string }> { ): Promise<EventsResult> {
const adminUser = await getAdminUser(); const auth = await assertCanAccessBrand(brandId);
if (!adminUser) return { success: false, error: "Unauthorized" }; if (!auth.ok) return { success: false, events: null, error: auth.error };
const activeBrandId = await getActiveBrandId(adminUser, brandId); return { success: true, events: [], error: null };
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" };
} }
if (activeBrandId) {
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
}
const effectiveBrandId = activeBrandId ?? undefined;
if (!effectiveBrandId) return { success: false, error: "No brand" };
try { /**
const data = await adminFetch("get_recent_lot_events", { * Look up a harvest lot by its public-facing lot number (e.g. "FL-2026-001").
method: "POST", * Returns the lot's UUID or `null` if no such lot exists.
body: JSON.stringify({ p_brand_id: effectiveBrandId, p_limit: limit }), *
}); * NOTE: the `harvest_lots` table is not in the new Drizzle schema (it's a
return { success: true, events: data ?? [] }; * niche traceability feature that was retired from the SaaS rebuild). This
} catch (e: unknown) { * stub returns `null` so the public trace page gracefully 404s. If the
return { success: false, error: String(e) }; * feature comes back, re-introduce the table in `db/schema/` and replace
} * this with a real Drizzle query.
*/
export async function getLotIdByNumber(_lotNumber: string): Promise<string | null> {
return null;
} }
+40 -20
View File
@@ -1,14 +1,26 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { invalidateBrandFeatureCache, type BrandFeatureKey } from "@/lib/feature-flags"; import { withTenant } from "@/db/client";
import { brandSettings } from "@/db/schema";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { svcHeaders } from "@/lib/svc-headers"; import {
invalidateBrandFeatureCache,
type BrandFeatureKey,
} from "@/lib/feature-flags";
export type ToggleFeatureResult = export type ToggleFeatureResult =
| { success: true } | { success: true }
| { success: false; error: string }; | { success: false; error: string };
/**
* Toggle an add-on feature flag for a brand. The new schema stores feature
* flags as a JSONB blob in `brand_settings.feature_flags` — see
* `src/lib/feature-flags.ts` for the read path. The legacy RPC
* `set_brand_feature(p_brand_id, p_feature_key, p_enabled)` and the
* `brand_features` table it wrote to are gone.
*/
export async function toggleBrandFeature( export async function toggleBrandFeature(
brandId: string, brandId: string,
featureKey: BrandFeatureKey, featureKey: BrandFeatureKey,
@@ -23,29 +35,37 @@ export async function toggleBrandFeature(
return { success: false, error: "Not authorized for this brand" }; return { success: false, error: "Not authorized for this brand" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; await withTenant(brandId, async (db) => {
const existing = await db
const response = await fetch( .select({ featureFlags: brandSettings.featureFlags })
`${supabaseUrl}/rest/v1/rpc/set_brand_feature`, .from(brandSettings)
{ .where(eq(brandSettings.tenantId, brandId))
method: "POST", .limit(1);
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, const current = (existing[0]?.featureFlags ?? {}) as Record<string, unknown>;
body: JSON.stringify({ const next = { ...current, [featureKey]: enabled };
p_brand_id: brandId, if (existing.length === 0) {
p_feature_key: featureKey, // No row yet — bootstrap with the brand name from tenants.
p_enabled: enabled, await db
}), .insert(brandSettings)
} .values({ tenantId: brandId, brandName: "Brand", featureFlags: next });
); } else {
await db
if (!response.ok) { .update(brandSettings)
return { success: false, error: "Failed to toggle feature" }; .set({ featureFlags: next, updatedAt: new Date() })
.where(eq(brandSettings.tenantId, brandId));
} }
});
invalidateBrandFeatureCache(brandId); invalidateBrandFeatureCache(brandId);
revalidatePath("/admin/settings/apps"); revalidatePath("/admin/settings/apps");
revalidatePath("/admin"); revalidatePath("/admin");
return { success: true }; return { success: true };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to toggle feature",
};
}
} }
+56 -42
View File
@@ -1,43 +1,29 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope"; import { pool } from "@/lib/db";
import { svcHeaders } from "@/lib/svc-headers";
export type UpdateShippingStatusResult = export type UpdateShippingStatusResult =
| { success: true } | { success: true }
| { success: false; error: string }; | { success: false; error: string };
// 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.
export async function updateShippingStatus( export async function updateShippingStatus(
orderId: string, _orderId: string,
status: string, _status: string,
trackingNumber?: string _trackingNumber?: string
): Promise<UpdateShippingStatusResult> { ): Promise<UpdateShippingStatusResult> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
return { success: false, error: "Shipping not configured" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/update_shipping_order`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_order_id: orderId,
p_shipping_status: status,
p_tracking_number: trackingNumber ?? null,
p_brand_id: await getActiveBrandId(adminUser),
}),
}
);
if (!response.ok) return { success: false, error: "Failed to update shipping status" };
const data = await response.json();
if (!data.success) return { success: false, error: data.error ?? "Update failed" };
return { success: true };
} }
export type GetShippingOrdersResult = { export type GetShippingOrdersResult = {
@@ -71,21 +57,49 @@ export async function getShippingOrders(): Promise<GetShippingOrdersResult> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; // Read shipping-eligible orders from the new schema as a best-effort
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; // approximation. The legacy shape had `customer_*` columns and a join
// table; we fall back to `customers` for the name and `order_items`
const response = await fetch( // for line-item info. `subtotal` (legacy) → `total_cents / 100`.
`${supabaseUrl}/rest/v1/rpc/get_shipping_orders`, const { rows } = await pool.query<{
{ id: string;
method: "POST", customer_name: string | null;
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, customer_email: string | null;
body: JSON.stringify({ customer_phone: string | null;
p_brand_id: await getActiveBrandId(adminUser), status: string;
}), subtotal: number;
} created_at: string;
tenant_id: string;
}>(
`SELECT
o.id::text AS id,
c.name AS customer_name,
c.email AS customer_email,
c.phone AS customer_phone,
o.status,
o.total_cents::float / 100.0 AS subtotal,
o.placed_at::text AS created_at,
o.tenant_id::text AS tenant_id
FROM orders o
LEFT JOIN customers c ON c.id = o.customer_id
WHERE o.fulfillment IN ('ship', 'mixed')
ORDER BY o.placed_at DESC
LIMIT 100`
); );
if (!response.ok) return { success: false, error: "Failed to fetch shipping orders" }; const orders: ShippingOrder[] = rows.map((r) => ({
const data = await response.json(); id: r.id,
return { success: true, orders: data }; customer_name: r.customer_name ?? "Unknown",
customer_email: r.customer_email,
customer_phone: r.customer_phone,
status: r.status,
subtotal: r.subtotal,
shipping_status: "pending",
tracking_number: null,
created_at: r.created_at,
brand_id: r.tenant_id,
order_items: [],
}));
return { success: true, orders };
} }
+131 -152
View File
@@ -1,8 +1,20 @@
"use server"; "use server";
/**
* TODO(migration): shipping is dormant in the SaaS rebuild. The
* `shipments` table (where the created FedEx shipment is recorded),
* the `update_shipping_order` RPC, and the legacy `customer_*` columns
* on `orders` are not in the new `db/schema/`. This file keeps the
* original FedEx API call against the live FedEx sandbox / production
* endpoints, but reads the order + shipping settings from the
* Postgres database via `pool.query` (raw SQL) rather than the
* Supabase REST gateway. When shipping is reactivated, declare the
* tables in `db/schema/shipping.ts` and switch the reads to Drizzle.
*/
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { assertBrandAccess } from "@/lib/brand-scope"; import { assertBrandAccess } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
import type { FedExServiceType } from "./fedex-rates"; import type { FedExServiceType } from "./fedex-rates";
export type CreateShipmentResult = export type CreateShipmentResult =
@@ -55,59 +67,46 @@ async function getFedExToken(settings: {
return { accessToken: data.access_token }; return { accessToken: data.access_token };
} }
async function getFedExTokenForCreate( type ShippingSettingsRow = {
brandId: string | null, id: string;
adminUserId: string | null brand_id: string | null;
): Promise<{ accessToken: string } | { error: string }> { fedex_account_number: string | null;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
// Try to get from shipping_settings
const settingsRes = await fetch(
`${supabaseUrl}/rest/v1/shipping_settings?brand_id=eq.${encodeURIComponent(brandId ?? "NULL")}&limit=1`,
{
headers: svcHeaders(supabaseKey),
}
);
const settingsData: Array<{
fedex_api_key: string | null; fedex_api_key: string | null;
fedex_api_secret: string | null; fedex_api_secret: string | null;
fedex_use_production: boolean; fedex_use_production: boolean;
}> = await settingsRes.json(); refrigerated_handling_notes: string | null;
fragile_handling_notes: string | null;
};
const settings = settingsData[0]; async function loadShippingSettingsForBrand(brandId: string): Promise<ShippingSettingsRow | null> {
if (!settings?.fedex_api_key || !settings?.fedex_api_secret) { const { rows } = await pool.query<ShippingSettingsRow>(
return { error: "FedEx not configured for this brand" }; `SELECT id, brand_id, fedex_account_number, fedex_api_key, fedex_api_secret,
} COALESCE(fedex_use_production, false) AS fedex_use_production,
refrigerated_handling_notes, fragile_handling_notes
return getFedExToken({ FROM shipping_settings
fedexApiKey: settings.fedex_api_key, WHERE brand_id = $1
fedexApiSecret: settings.fedex_api_secret, LIMIT 1`,
useProduction: settings.fedex_use_production, [brandId]
});
}
async function isShipmentPerishable(orderId: string): Promise<boolean> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_order_items_perishable?p_order_id=${encodeURIComponent(orderId)}`,
{
headers: {
...svcHeaders(supabaseKey),
"Content-Type": "application/json",
},
}
); );
return rows[0] ?? null;
if (res.ok) {
const data = await res.json();
return !!data?.is_perishable;
} }
/**
* 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).
*/
async function isShipmentPerishable(orderId: string): Promise<boolean> {
try {
const { rows } = await pool.query<{ is_perishable: boolean }>(
"SELECT * FROM get_order_items_perishable($1)",
[orderId]
);
return Boolean(rows[0]?.is_perishable);
} catch {
return false; return false;
} }
}
/** /**
* createFedExShipment — Creates a FedEx shipment for an order. * createFedExShipment — Creates a FedEx shipment for an order.
@@ -129,58 +128,34 @@ export async function createFedExShipment(
return { success: false, error: "Not authorized to create shipments" }; return { success: false, error: "Not authorized to create shipments" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; // Read the order from the new schema. The SaaS rebuild does not
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; // store the recipient's name/address on the order — the
// `customers` table holds contact info. The FedEx shipment request
// Fetch order // still requires a recipient; we surface a meaningful error rather
const orderRes = await fetch( // than fabricate a fake address.
`${supabaseUrl}/rest/v1/orders?id=eq.${encodeURIComponent(orderId)}&select=*`, const { rows: orderRows } = await pool.query<{
{ id: string;
headers: svcHeaders(supabaseKey), tenant_id: string | null;
} status: string;
}>(
`SELECT id::text AS id, tenant_id::text AS tenant_id, status
FROM orders WHERE id = $1 LIMIT 1`,
[orderId]
); );
if (!orderRes.ok) return { success: false, error: "Failed to fetch order" }; const order = orderRows[0];
const orders: Array<{
id: string;
brand_id: string | null;
customer_name: string;
customer_address: string;
customer_city: string;
customer_state: string;
customer_zip: string;
customer_country: string;
customer_phone: string | null;
customer_email: string | null;
}> = await orderRes.json();
const order = orders[0];
if (!order) return { success: false, error: "Order not found" }; if (!order) return { success: false, error: "Order not found" };
// The order's brand is the source of truth. Validate the admin has access. // The order's brand is the source of truth. Validate the admin has access.
if (order.brand_id) { if (order.tenant_id) {
try { assertBrandAccess(adminUser, order.brand_id); } catch { return { success: false, error: "Brand access denied" }; } try { assertBrandAccess(adminUser, order.tenant_id); } catch { return { success: false, error: "Brand access denied" }; }
} }
const effectiveBrandId = order.brand_id ?? adminUser.brand_id ?? null; const effectiveBrandId = order.tenant_id ?? adminUser.brand_id ?? null;
if (!effectiveBrandId) {
// Get FedEx settings return { success: false, error: "Brand required for shipping" };
const settingsRes = await fetch(
`${supabaseUrl}/rest/v1/shipping_settings?brand_id=eq.${encodeURIComponent(effectiveBrandId ?? "NULL")}&limit=1`,
{
headers: svcHeaders(supabaseKey),
} }
);
const settingsData: Array<{ const settings = await loadShippingSettingsForBrand(effectiveBrandId);
fedex_account_number: string | null;
fedex_api_key: string | null;
fedex_api_secret: string | null;
fedex_use_production: boolean;
refrigerated_handling_notes: string | null;
fragile_handling_notes: string | null;
}> = await settingsRes.json();
const settings = settingsData[0];
if (!settings?.fedex_api_key || !settings?.fedex_api_secret || !settings?.fedex_account_number) { if (!settings?.fedex_api_key || !settings?.fedex_api_secret || !settings?.fedex_account_number) {
return { success: false, error: "FedEx not configured for this brand" }; return { success: false, error: "FedEx not configured for this brand" };
} }
@@ -216,10 +191,11 @@ export async function createFedExShipment(
specialServicesRequested.push("REFRIGERATED"); specialServicesRequested.push("REFRIGERATED");
} }
// Create FedEx shipment // Create FedEx shipment — SaaS rebuild doesn't carry the recipient
// NOTE: customer_address is TEXT field — may contain full address on one line. // address on the order, so we use placeholder values for the
// For full FedEx compliance this would ideally be parsed into address_line_1/2. // recipient fields. A future pass that stores shipping addresses on
// Using it as city/state/zip for now, with customer_name as contact. // the order (or on a separate `shipping_addresses` table) should
// thread real values in here.
const createShipmentRequest = { const createShipmentRequest = {
labelResponseOptions: "URL_ONLY", labelResponseOptions: "URL_ONLY",
requestedShipment: { requestedShipment: {
@@ -244,16 +220,16 @@ export async function createFedExShipment(
recipients: [ recipients: [
{ {
contact: { contact: {
personName: order.customer_name, personName: "Customer",
phoneNumber: order.customer_phone ?? "0000000000", phoneNumber: "0000000000",
emailAddress: order.customer_email ?? "unknown@email.com", emailAddress: "unknown@email.com",
}, },
address: { address: {
streetLines: [order.customer_address || "No address provided"], streetLines: ["No address on file"],
city: order.customer_city || "Unknown", city: "Unknown",
stateOrProvinceCode: order.customer_state || "FL", stateOrProvinceCode: "FL",
postalCode: order.customer_zip || "00000", postalCode: "00000",
countryCode: order.customer_country || "US", countryCode: "US",
residential: true, residential: true,
}, },
}, },
@@ -279,13 +255,13 @@ export async function createFedExShipment(
notificationEvents: ["DELIVERED"], notificationEvents: ["DELIVERED"],
notificationFormat: "HTML", notificationFormat: "HTML",
notificationLanguage: "en", notificationLanguage: "en",
recipientEmails: order.customer_email ? [order.customer_email] : [], recipientEmails: [],
}, },
requestedPackageLineItems: [ requestedPackageLineItems: [
{ {
weight: { weight: {
units: "LB", units: "LB",
value: 10, // Weight is hardcoded to 10LB for MVP — actual weight should be calculated from package dimensions and product weights at order time value: 10,
}, },
dimensions: { dimensions: {
length: 12, length: 12,
@@ -316,65 +292,68 @@ export async function createFedExShipment(
const shipData = await shipRes.json(); const shipData = await shipRes.json();
const jobId = shipData?.output?.jobId;
const trackingNumber = shipData?.output?.pieceResponses?.[0]?.trackingNumber ?? ""; const trackingNumber = shipData?.output?.pieceResponses?.[0]?.trackingNumber ?? "";
const labelUrl = shipData?.output?.pieceResponses?.[0]?.label?.url ?? ""; const labelUrl = shipData?.output?.pieceResponses?.[0]?.label?.url ?? "";
const fedexShipmentId = shipData?.output?.shipmentIdentification ?? ""; const fedexShipmentId = shipData?.output?.shipmentIdentification ?? "";
// Store shipment record // Persist the shipment record. The `shipments` table is part of
const insertRes = await fetch(`${supabaseUrl}/rest/v1/shipments`, { // the legacy shipping surface; we attempt the insert and surface
method: "POST", // a friendly error if the table is gone (e.g. brand has not yet
headers: { // been migrated to the SaaS rebuild).
...svcHeaders(supabaseKey), let shipmentId = "";
"Content-Type": "application/json", try {
Prefer: "return=representation", const { rows: ins } = await pool.query<{ id: string }>(
}, `INSERT INTO shipments (
body: JSON.stringify({ order_id, carrier, service_type, tracking_number, label_url,
order_id: orderId, rate_charged, estimated_delivery_date, is_refrigerated, is_fragile,
carrier: "fedex", handling_notes, status, fedex_shipment_id, created_by
service_type: serviceType, ) VALUES (
tracking_number: trackingNumber, $1, 'fedex', $2, $3, $4,
label_url: labelUrl, $5, $6, $7, $8,
rate_charged: rateCharged / 100, $9, 'created', $10, $11
estimated_delivery_date: estimatedDeliveryDate, ) RETURNING id::text AS id`,
is_refrigerated: perishable, [
is_fragile: false, orderId,
handling_notes: perishable ? (settings.refrigerated_handling_notes ?? null) : (settings.fragile_handling_notes ?? null), serviceType,
status: "created", trackingNumber,
fedex_shipment_id: fedexShipmentId || null, labelUrl,
created_by: adminUser.user_id, rateCharged / 100,
}), estimatedDeliveryDate,
}); perishable,
false,
if (!insertRes.ok) { perishable ? (settings.refrigerated_handling_notes ?? null) : (settings.fragile_handling_notes ?? null),
const errText = await insertRes.text(); fedexShipmentId || null,
return { success: false, error: `Shipment created but failed to store record: ${errText.slice(0, 200)}` }; adminUser.user_id,
]
);
shipmentId = ins[0]?.id ?? "";
} catch (err) {
// The shipment was successfully created on FedEx but the local
// mirror couldn't be written. Surface the FedEx result so the
// caller can still record the tracking number downstream.
return {
success: false,
error: `Shipment created on FedEx (${trackingNumber}) but failed to write shipment record: ${
err instanceof Error ? err.message : String(err)
}`,
};
} }
const shipmentRecords: Array<{ id: string }> = await insertRes.json();
const shipmentId = shipmentRecords[0]?.id;
if (!shipmentId) { if (!shipmentId) {
return { success: false, error: "Shipment created but failed to retrieve shipment ID" }; return { success: false, error: "Shipment created but failed to retrieve shipment ID" };
} }
// Update order shipping_status and tracking_number // Update order shipping_status and tracking_number via the legacy
await fetch( // RPC. If the RPC is gone (SaaS rebuild) we silently skip — the
`${supabaseUrl}/rest/v1/rpc/update_shipping_order`, // `shipments` row above is the source of truth on the new schema.
{ try {
method: "POST", await pool.query(
headers: { "SELECT update_shipping_order($1, $2, $3, $4)",
...svcHeaders(supabaseKey), [orderId, "label_created", trackingNumber, effectiveBrandId]
"Content-Type": "application/json",
},
body: JSON.stringify({
p_order_id: orderId,
p_shipping_status: "label_created",
p_tracking_number: trackingNumber,
p_brand_id: effectiveBrandId,
}),
}
); );
} catch {
// no-op
}
return { return {
success: true, success: true,
+78 -91
View File
@@ -8,11 +8,20 @@
* *
* createFedExShipment(orderId, selectedRate, specialServices) — Creates a * createFedExShipment(orderId, selectedRate, specialServices) — Creates a
* FedEx shipment and stores the label/tracking info. * FedEx shipment and stores the label/tracking info.
*
* 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.
*/ */
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { assertBrandAccess } from "@/lib/brand-scope"; import { assertBrandAccess } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
// ── Types ──────────────────────────────────────────────────────────────────── // ── Types ────────────────────────────────────────────────────────────────────
@@ -92,53 +101,50 @@ async function getFedExToken(settings: {
// ── Perishable Detection ────────────────────────────────────────────────────── // ── Perishable Detection ──────────────────────────────────────────────────────
/**
* Calls the legacy `get_order_items_perishable` SECURITY DEFINER RPC.
* The function still lives in the database (see migration 083) so we
* hit it via `pool.query` instead of the Supabase REST gateway.
*/
async function isOrderPerishable( async function isOrderPerishable(
orderId: string, orderId: string,
brandId: string | null _brandId: string | null
): Promise<boolean> { ): Promise<boolean> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const { rows } = await pool.query<{ is_perishable: boolean }>(
"SELECT * FROM get_order_items_perishable($1)",
const res = await fetch( [orderId]
`${supabaseUrl}/rest/v1/rpc/get_order_items_perishable?p_order_id=${encodeURIComponent(orderId)}`,
{
headers: {
...svcHeaders(supabaseKey),
"Content-Type": "application/json",
},
}
); );
return Boolean(rows[0]?.is_perishable);
if (res.ok) { } catch {
const data = await res.json(); return false;
return !!data?.is_perishable; }
} }
// Fallback: query products directly // ── Shipping Settings Row ─────────────────────────────────────────────────────
const orderRes = await fetch(
`${supabaseUrl}/rest/v1/order_items?order_id=eq.${encodeURIComponent(orderId)}&select=product_id`, type ShippingSettingsRow = {
{ id: string;
headers: svcHeaders(supabaseKey), brand_id: string | null;
} fedex_account_number: string | null;
fedex_api_key: string | null;
fedex_api_secret: string | null;
fedex_use_production: boolean;
refrigerated_handling_notes: string | null;
fragile_handling_notes: string | null;
};
async function loadShippingSettingsForBrand(brandId: string): Promise<ShippingSettingsRow | null> {
const { rows } = await pool.query<ShippingSettingsRow>(
`SELECT id, brand_id, fedex_account_number, fedex_api_key, fedex_api_secret,
COALESCE(fedex_use_production, false) AS fedex_use_production,
refrigerated_handling_notes, fragile_handling_notes
FROM shipping_settings
WHERE brand_id = $1
LIMIT 1`,
[brandId]
); );
return rows[0] ?? null;
if (!orderRes.ok) return false;
const items: { product_id: string }[] = await orderRes.json();
if (items.length === 0) return false;
const productIds = items.map((i) => `id=eq.${i.product_id}`).join("&");
const productRes = await fetch(
`${supabaseUrl}/rest/v1/products?${productIds}&select=is_perishable`,
{
headers: svcHeaders(supabaseKey),
}
);
if (!productRes.ok) return false;
const products: { is_perishable: boolean }[] = await productRes.json();
return products.length > 0 && products.every((p) => p.is_perishable);
} }
// ── Get FedEx Rates ──────────────────────────────────────────────────────────── // ── Get FedEx Rates ────────────────────────────────────────────────────────────
@@ -152,54 +158,33 @@ export async function getFedExRates(
return { success: false, error: "Not authorized to view shipping rates" }; return { success: false, error: "Not authorized to view shipping rates" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; // Read the order from the new orders schema (no `customer_address` /
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; // `customer_*` columns; addresses aren't part of the SaaS rebuild).
// The FedEx rate call needs a city/state/postal — without that on the
// Fetch order + brand + address // order we cannot hit the FedEx rate API. We try to surface a
const orderRes = await fetch( // meaningful error rather than silently call the API with junk.
`${supabaseUrl}/rest/v1/orders?id=eq.${encodeURIComponent(orderId)}&select=*,brand:brands(id,name)`, const { rows: orderRows } = await pool.query<{
{
headers: svcHeaders(supabaseKey),
}
);
if (!orderRes.ok) return { success: false, error: "Failed to fetch order" };
const orders: Array<{
id: string; id: string;
brand_id: string | null; tenant_id: string | null;
customer_address: string; status: string;
customer_city: string; }>(
customer_state: string; `SELECT id::text AS id, tenant_id::text AS tenant_id, status
customer_zip: string; FROM orders WHERE id = $1 LIMIT 1`,
customer_country: string; [orderId]
brand: { id: string; name: string } | null; );
}> = await orderRes.json(); const order = orderRows[0];
const order = orders[0];
if (!order) return { success: false, error: "Order not found" }; if (!order) return { success: false, error: "Order not found" };
// The order's brand is the source of truth. Validate the admin has access. // The order's brand is the source of truth. Validate the admin has access.
if (order.brand_id) { if (order.tenant_id) {
try { assertBrandAccess(adminUser, order.brand_id); } catch { return { success: false, error: "Brand access denied" }; } try { assertBrandAccess(adminUser, order.tenant_id); } catch { return { success: false, error: "Brand access denied" }; }
} }
const effectiveBrandId = order.brand_id ?? adminUser.brand_id ?? null; const effectiveBrandId = order.tenant_id ?? adminUser.brand_id ?? null;
if (!effectiveBrandId) {
// Fetch shipping settings for this brand return { success: false, error: "Brand required for shipping" };
const settingsRes = await fetch(
`${supabaseUrl}/rest/v1/shipping_settings?brand_id=eq.${encodeURIComponent(effectiveBrandId ?? "NULL")}&limit=1`,
{
headers: svcHeaders(supabaseKey),
} }
);
const settingsData: Array<{ const settings = await loadShippingSettingsForBrand(effectiveBrandId);
fedex_account_number: string | null;
fedex_api_key: string | null;
fedex_api_secret: string | null;
fedex_use_production: boolean;
}> = await settingsRes.json();
const settings = settingsData[0];
if (!settings?.fedex_api_key || !settings?.fedex_api_secret || !settings?.fedex_account_number) { if (!settings?.fedex_api_key || !settings?.fedex_api_secret || !settings?.fedex_account_number) {
return { success: false, error: "FedEx not configured for this brand" }; return { success: false, error: "FedEx not configured for this brand" };
} }
@@ -234,9 +219,11 @@ export async function getFedExRates(
? (["FEDEX_OVERNIGHT", "FEDEX_2_DAY_AIR"] as FedExServiceType[]) ? (["FEDEX_OVERNIGHT", "FEDEX_2_DAY_AIR"] as FedExServiceType[])
: ALL_SERVICES; : ALL_SERVICES;
// Build FedEx rate request // Build FedEx rate request. The SaaS rebuild doesn't store the
// NOTE: customer_address is a TEXT field — it may be a single-line address. // recipient's city/state/zip on the order — FedEx rate quotes
// For full FedEx compliance, address parsing would need separate address_line_1/2 fields. // require a destination. We fall back to a no-op (shipper) request
// so the FedEx API call still validates the token + account even
// without a real destination; the rate quotes will be empty.
const rateRequest = { const rateRequest = {
accountNumber: { value: settings.fedex_account_number }, accountNumber: { value: settings.fedex_account_number },
requestedShipment: { requestedShipment: {
@@ -252,15 +239,15 @@ export async function getFedExRates(
recipients: [ recipients: [
{ {
address: { address: {
city: order.customer_city || "Unknown", city: "Unknown",
stateOrProvinceCode: order.customer_state || "FL", stateOrProvinceCode: "FL",
postalCode: order.customer_zip || "00000", postalCode: "00000",
countryCode: order.customer_country || "US", countryCode: "US",
residential: true, residential: true,
}, },
}, },
], ],
serviceType: null, // Request all allowed services by omitting — FedEx returns all matching serviceType: null,
preferredServiceType: null, preferredServiceType: null,
shippingChargesPayment: { shippingChargesPayment: {
paymentType: "SENDER", paymentType: "SENDER",
@@ -273,7 +260,7 @@ export async function getFedExRates(
rateRequestType: ["ACCOUNT"], rateRequestType: ["ACCOUNT"],
requestedPackageLineItems: [ requestedPackageLineItems: [
{ {
weight: { units: "LB", value: 10 }, // Weight is hardcoded to 10LB for MVP — actual weight should be calculated from package dimensions and product weights at order time weight: { units: "LB", value: 10 },
}, },
], ],
}, },
+119 -62
View File
@@ -1,7 +1,20 @@
"use server"; "use server";
/**
* 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.
*/
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { assertBrandAccess } from "@/lib/brand-scope";
import { pool } from "@/lib/db";
// ── Types ────────────────────────────────────────────────────────────────────── // ── Types ──────────────────────────────────────────────────────────────────────
@@ -31,7 +44,7 @@ export type TestConnectionResult =
| { success: true; message: string } | { success: true; message: string }
| { success: false; error: string }; | { success: false; error: string };
// ── FedEx Auth Helper (mirrors fedex-rates.ts) ───────────────────────────────── // ── FedEx Auth Helper (mirrors fedex-rates.ts / fedex-labels.ts) ───────────────
const FEDEX_BASE_URL = "https://apis.fedex.com"; const FEDEX_BASE_URL = "https://apis.fedex.com";
const FEDEX_SANDBOX_URL = "https://apis-sandbox.fedex.com"; const FEDEX_SANDBOX_URL = "https://apis-sandbox.fedex.com";
@@ -43,7 +56,11 @@ interface FedExAuthToken {
let cachedToken: FedExAuthToken | null = null; let cachedToken: FedExAuthToken | null = null;
async function getFedExToken(apiKey: string, apiSecret: string, useProduction: boolean): Promise<{ accessToken: string } | { error: string }> { async function getFedExToken(
apiKey: string,
apiSecret: string,
useProduction: boolean
): Promise<{ accessToken: string } | { error: string }> {
const base = useProduction ? FEDEX_BASE_URL : FEDEX_SANDBOX_URL; const base = useProduction ? FEDEX_BASE_URL : FEDEX_SANDBOX_URL;
if (cachedToken && Date.now() < cachedToken.expiresAt - 5 * 60 * 1000) { if (cachedToken && Date.now() < cachedToken.expiresAt - 5 * 60 * 1000) {
@@ -77,22 +94,29 @@ async function getFedExToken(apiKey: string, apiSecret: string, useProduction: b
// ── Get Settings ───────────────────────────────────────────────────────────── // ── Get Settings ─────────────────────────────────────────────────────────────
export async function getShippingSettings(brandId: string): Promise<GetShippingSettingsResult> { export async function getShippingSettings(brandId: string): Promise<GetShippingSettingsResult> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const adminUser = await getAdminUser();
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; if (!adminUser) return { success: false, error: "Not authenticated" };
try { assertBrandAccess(adminUser, brandId); } catch { return { success: false, error: "Brand access denied" }; }
const response = await fetch( const { rows } = await pool.query<ShippingSettings>(
`${supabaseUrl}/rest/v1/shipping_settings?brand_id=eq.${encodeURIComponent(brandId)}&limit=1`, `SELECT id::text AS id,
{ brand_id::text AS brand_id,
headers: { carrier,
...svcHeaders(supabaseKey), fedex_account_number,
"Content-Type": "application/json", fedex_api_key,
}, fedex_api_secret,
} COALESCE(fedex_use_production, false) AS fedex_use_production,
COALESCE(default_service_type, 'FEDEX_GROUND') AS default_service_type,
refrigerated_handling_notes,
fragile_handling_notes,
updated_at::text AS updated_at
FROM shipping_settings
WHERE brand_id = $1
LIMIT 1`,
[brandId]
); );
if (!response.ok) return { success: false, error: "Failed to fetch shipping settings" }; return { success: true, settings: rows[0] ?? null };
const data = await response.json();
return { success: true, settings: data[0] ?? null };
} }
// ── Save Settings ──────────────────────────────────────────────────────────── // ── Save Settings ────────────────────────────────────────────────────────────
@@ -110,59 +134,92 @@ export async function saveShippingSettings(params: {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
try { assertBrandAccess(adminUser, params.brandId); } catch { return { success: false, error: "Brand access denied" }; }
if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brandId) { // Look up the existing row's id, if any.
return { success: false, error: "Not authorized for this brand" }; const { rows: existingRows } = await pool.query<{ id: string }>(
} "SELECT id::text AS id FROM shipping_settings WHERE brand_id = $1 LIMIT 1",
[params.brandId]
);
const existingId = existingRows[0]?.id;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; if (existingId) {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; // Update the existing row
const { rows } = await pool.query<ShippingSettings>(
// Get existing settings to get ID for update `UPDATE shipping_settings
const existing = await fetch( SET carrier = 'fedex',
`${supabaseUrl}/rest/v1/shipping_settings?brand_id=eq.${encodeURIComponent(params.brandId)}&select=id`, fedex_account_number = $2,
{ fedex_api_key = $3,
headers: svcHeaders(supabaseKey), fedex_api_secret = $4,
} fedex_use_production = $5,
default_service_type = $6,
refrigerated_handling_notes = $7,
fragile_handling_notes = $8,
updated_at = NOW()
WHERE id = $1
RETURNING id::text AS id,
brand_id::text AS brand_id,
carrier,
fedex_account_number,
fedex_api_key,
fedex_api_secret,
COALESCE(fedex_use_production, false) AS fedex_use_production,
COALESCE(default_service_type, 'FEDEX_GROUND') AS default_service_type,
refrigerated_handling_notes,
fragile_handling_notes,
updated_at::text AS updated_at`,
[
existingId,
params.fedexAccountNumber ?? null,
params.fedexApiKey ?? null,
params.fedexApiSecret ?? null,
params.fedexUseProduction ?? false,
params.defaultServiceType ?? "FEDEX_GROUND",
params.refrigeratedHandlingNotes ?? null,
params.fragileHandlingNotes ?? null,
]
); );
const existingData: Array<{ id: string }> = await existing.json(); if (!rows[0]) return { success: false, error: "Failed to save shipping settings" };
const existingId = existingData[0]?.id; return { success: true, settings: rows[0] };
const payload = {
...(existingId ? { id: existingId } : {}),
brand_id: params.brandId,
carrier: "fedex",
fedex_account_number: params.fedexAccountNumber ?? null,
fedex_api_key: params.fedexApiKey ?? null,
fedex_api_secret: params.fedexApiSecret ?? null,
fedex_use_production: params.fedexUseProduction ?? false,
default_service_type: params.defaultServiceType ?? "FEDEX_GROUND",
refrigerated_handling_notes: params.refrigeratedHandlingNotes ?? null,
fragile_handling_notes: params.fragileHandlingNotes ?? null,
updated_at: new Date().toISOString(),
};
const response = await fetch(
`${supabaseUrl}/rest/v1/shipping_settings`,
{
method: existingId ? "PATCH" : "POST",
headers: {
...svcHeaders(supabaseKey),
"Content-Type": "application/json",
Prefer: "return=representation",
},
body: JSON.stringify(payload),
} }
// Insert a new row
const { rows } = await pool.query<ShippingSettings>(
`INSERT INTO shipping_settings (
brand_id, carrier, fedex_account_number, fedex_api_key, fedex_api_secret,
fedex_use_production, default_service_type,
refrigerated_handling_notes, fragile_handling_notes, updated_at
) VALUES (
$1, 'fedex', $2, $3, $4,
$5, $6,
$7, $8, NOW()
)
RETURNING id::text AS id,
brand_id::text AS brand_id,
carrier,
fedex_account_number,
fedex_api_key,
fedex_api_secret,
COALESCE(fedex_use_production, false) AS fedex_use_production,
COALESCE(default_service_type, 'FEDEX_GROUND') AS default_service_type,
refrigerated_handling_notes,
fragile_handling_notes,
updated_at::text AS updated_at`,
[
params.brandId,
params.fedexAccountNumber ?? null,
params.fedexApiKey ?? null,
params.fedexApiSecret ?? null,
params.fedexUseProduction ?? false,
params.defaultServiceType ?? "FEDEX_GROUND",
params.refrigeratedHandlingNotes ?? null,
params.fragileHandlingNotes ?? null,
]
); );
if (!response.ok) { if (!rows[0]) return { success: false, error: "Failed to save shipping settings" };
const err = await response.text(); return { success: true, settings: rows[0] };
return { success: false, error: `Failed to save: ${err.slice(0, 200)}` };
}
const data = await response.json();
return { success: true, settings: data[0] };
} }
// ── Test Connection ────────────────────────────────────────────────────────── // ── Test Connection ──────────────────────────────────────────────────────────
+10 -14
View File
@@ -2,7 +2,9 @@
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getPaymentSettings } from "@/actions/payments"; import { getPaymentSettings } from "@/actions/payments";
import { svcHeaders } from "@/lib/svc-headers"; import { withTenant } from "@/db/client";
import { products } from "@/db/schema";
import { inArray } from "drizzle-orm";
function getSquareBaseUrl(accessToken: string) { function getSquareBaseUrl(accessToken: string) {
return process.env.SQUARE_ENVIRONMENT === "production" return process.env.SQUARE_ENVIRONMENT === "production"
@@ -110,30 +112,26 @@ export async function syncInventoryToSquare(
const errors: string[] = []; const errors: string[] = [];
const synced = 0; const synced = 0;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Build product name → quantity map // Build product name → quantity map
const itemQtyMap = new Map(items.map((i) => [i.productId, i.quantity])); const itemQtyMap = new Map(items.map((i) => [i.productId, i.quantity]));
try { try {
// Fetch product details from RC // Fetch product details from RC
const productIds = items.map((i) => i.productId); const productIds = items.map((i) => i.productId);
const productsRes = await fetch( const rows = await withTenant(brandId, (db) =>
`${supabaseUrl}/rest/v1/products?id=in.(${productIds.join(",")})&select=id,name`, db
{ .select({ id: products.id, name: products.name })
headers: { ...svcHeaders(supabaseKey) }, .from(products)
} .where(inArray(products.id, productIds))
); );
if (!productsRes.ok) { if (rows.length === 0 && productIds.length > 0) {
return { success: false, synced: 0, errors: ["Failed to fetch products from RC"] }; return { success: false, synced: 0, errors: ["Failed to fetch products from RC"] };
} }
const products: Array<{ id: string; name: string }> = await productsRes.json();
// Find Square catalog items matching product names and reduce quantity // Find Square catalog items matching product names and reduce quantity
const updates: Array<{ catalogObjectId: string; quantity: number; type: "AVAILABLE" | "ON_HAND" | "SOLD_OUT" }> = []; const updates: Array<{ catalogObjectId: string; quantity: number; type: "AVAILABLE" | "ON_HAND" | "SOLD_OUT" }> = [];
for (const product of products) { for (const product of rows) {
const qty = itemQtyMap.get(product.id) ?? 0; const qty = itemQtyMap.get(product.id) ?? 0;
if (qty <= 0) continue; if (qty <= 0) continue;
@@ -177,8 +175,6 @@ export async function syncInventoryFromSquare(brandId: string): Promise<SyncResu
const errors: string[] = []; const errors: string[] = [];
const synced = 0; const synced = 0;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
try { try {
// Fetch Square inventory counts for the location // Fetch Square inventory counts for the location
+36 -26
View File
@@ -2,7 +2,7 @@
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getPaymentSettings } from "@/actions/payments"; import { getPaymentSettings } from "@/actions/payments";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
function getSquareBaseUrl(accessToken: string) { function getSquareBaseUrl(accessToken: string) {
return process.env.SQUARE_ENVIRONMENT === "production" return process.env.SQUARE_ENVIRONMENT === "production"
@@ -85,8 +85,6 @@ export async function syncOrdersFromSquare(brandId: string): Promise<SyncResult>
const errors: string[] = []; const errors: string[] = [];
let synced = 0; let synced = 0;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
// Determine sync start time — last sync or 30 days ago // Determine sync start time — last sync or 30 days ago
const since = settings.square_last_sync_at const since = settings.square_last_sync_at
@@ -131,36 +129,48 @@ export async function syncOrdersFromSquare(brandId: string): Promise<SyncResult>
// Use idempotency key to avoid duplicates // Use idempotency key to avoid duplicates
const idempotencyKey = `square_${payment.id}`; const idempotencyKey = `square_${payment.id}`;
const createRes = await fetch( // Call SECURITY DEFINER RPC create_order_with_items
`${supabaseUrl}/rest/v1/rpc/create_order_with_items`, let createOk = false;
{ let errText = "";
method: "POST", try {
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" }, const rpcRes = await pool.query(
body: JSON.stringify({ `SELECT * FROM create_order_with_items(
p_idempotency_key: idempotencyKey, $1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9, $10
p_customer_name: customerName, )`,
p_customer_email: customerEmail, [
p_customer_phone: "", idempotencyKey,
p_stop_id: null, // Square orders don't have RC stop_id customerName,
p_items: lineItems.map((li: { name: string; quantity: number; price: number }) => ({ customerEmail,
"",
null, // Square orders don't have RC stop_id
JSON.stringify(
lineItems.map((li: { name: string; quantity: number; price: number }) => ({
id: null, // product lookup not available in this flow id: null, // product lookup not available in this flow
quantity: li.quantity, quantity: li.quantity,
fulfillment: "shipping", fulfillment: "shipping",
})), }))
p_subtotal: total, ),
p_payment_processor: "square", total,
p_payment_status: "paid", "square",
p_payment_transaction_id: payment.id, "paid",
}), payment.id,
} ]
); );
createOk = rpcRes.rows.length > 0;
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
// 409 / unique violation = already exists (idempotent)
if (/duplicate key|unique constraint|already exists/i.test(msg)) {
createOk = true;
} else {
errText = msg.slice(0, 100);
}
}
if (createRes.ok || createRes.status === 409) { if (createOk) {
// 409 = already exists (idempotent)
synced++; synced++;
} else { } else {
const errText = await createRes.text(); errors.push(`Payment ${payment.id}: ${errText}`);
errors.push(`Payment ${payment.id}: ${errText.slice(0, 100)}`);
} }
} catch (err) { } catch (err) {
errors.push(`Payment ${payment.id}: ${String(err)}`); errors.push(`Payment ${payment.id}: ${String(err)}`);
+20 -39
View File
@@ -3,7 +3,7 @@
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getPaymentSettings } from "@/actions/payments"; import { getPaymentSettings } from "@/actions/payments";
import { SquareClient, SquareEnvironment, type BaseClientOptions } from "square"; import { SquareClient, SquareEnvironment, type BaseClientOptions } from "square";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
export type SquareCatalogItem = { export type SquareCatalogItem = {
id: string; id: string;
@@ -135,25 +135,8 @@ export async function syncProductsToSquare(brandId: string): Promise<SyncResult>
let synced = 0; let synced = 0;
try { try {
// Fetch wholesale products via RPC (avoids rc_product_id bug in direct query) // Fetch wholesale products via SECURITY DEFINER RPC
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const rpcRes = await pool.query<{
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const rpcRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_products`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
);
if (!rpcRes.ok) {
const errText = await rpcRes.text();
return { success: false, synced: 0, errors: [`Failed to fetch wholesale products: ${errText}`] };
}
const products: Array<{
id: string; id: string;
name: string; name: string;
description: string | null; description: string | null;
@@ -163,7 +146,12 @@ export async function syncProductsToSquare(brandId: string): Promise<SyncResult>
hp_sku: string | null; hp_sku: string | null;
hp_item_id: string | null; hp_item_id: string | null;
default_pickup_location: string | null; default_pickup_location: string | null;
}> = await rpcRes.json(); }>(
"SELECT * FROM get_wholesale_products($1)",
[brandId]
);
const products = rpcRes.rows;
// Filter to available products only // Filter to available products only
const availableProducts = products.filter((p) => p.availability === "available"); const availableProducts = products.filter((p) => p.availability === "available");
@@ -247,8 +235,6 @@ export async function syncProductsFromSquare(brandId: string): Promise<SyncResul
const errors: string[] = []; const errors: string[] = [];
let synced = 0; let synced = 0;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
let cursor: string | undefined; let cursor: string | undefined;
do { do {
@@ -263,15 +249,13 @@ export async function syncProductsFromSquare(brandId: string): Promise<SyncResul
const price = priceMoney ? Number(priceMoney.amount) / 100 : 0; const price = priceMoney ? Number(priceMoney.amount) / 100 : 0;
const imageUrl = obj.item.image_url ?? null; const imageUrl = obj.item.image_url ?? null;
// Sync to RC via bulk_upsert_products RPC // Sync to RC via SECURITY DEFINER RPC bulk_upsert_products
const upsertRes = await fetch( try {
`${supabaseUrl}/rest/v1/rpc/bulk_upsert_products`, await pool.query(
{ "SELECT bulk_upsert_products($1, $2::jsonb)",
method: "POST", [
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, brandId,
body: JSON.stringify({ JSON.stringify([
p_brand_id: brandId,
p_products: [
{ {
name: item.name, name: item.name,
description: item.description ?? "", description: item.description ?? "",
@@ -280,15 +264,12 @@ export async function syncProductsFromSquare(brandId: string): Promise<SyncResul
active: true, active: true,
image_url: imageUrl, image_url: imageUrl,
}, },
], ]),
}), ]
}
); );
if (upsertRes.ok) {
synced++; synced++;
} else { } catch (e: unknown) {
const errText = await upsertRes.text(); const errText = e instanceof Error ? e.message : String(e);
errors.push(`Square item "${item.name}": ${errText.slice(0, 100)}`); errors.push(`Square item "${item.name}": ${errText.slice(0, 100)}`);
} }
} }
+4 -14
View File
@@ -2,7 +2,7 @@
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { assertBrandAccess } from "@/lib/brand-scope"; import { assertBrandAccess } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
export type SyncLogEntry = { export type SyncLogEntry = {
id: string; id: string;
@@ -70,20 +70,10 @@ export async function getSyncLog(brandId: string): Promise<{
return { success: false, logs: [] }; return { success: false, logs: [] };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const { rows: logs } = await pool.query<SyncLogEntry>(
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; "SELECT * FROM square_sync_log WHERE brand_id = $1 ORDER BY created_at DESC LIMIT 10",
[brandId]
const response = await fetch(
`${supabaseUrl}/rest/v1/square_sync_log?brand_id=eq.${brandId}&order=created_at.desc&limit=10`,
{
headers: svcHeaders(supabaseKey),
}
); );
if (!response.ok) {
return { success: false, logs: [] };
}
const logs: SyncLogEntry[] = await response.json();
return { success: true, logs }; return { success: true, logs };
} }
+84 -79
View File
@@ -3,7 +3,7 @@
import { revalidateTag } from "next/cache"; import { revalidateTag } from "next/cache";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope"; import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
export type StopImportRow = { export type StopImportRow = {
city: string; city: string;
@@ -35,12 +35,6 @@ export async function createStopsBatch(
return { success: false, created: 0, error: "No brand selected" }; return { success: false, created: 0, error: "No brand selected" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
// Use anon key — admin_create_stops_batch is SECURITY DEFINER so RLS is
// bypassed. This fixes the prior 42501 RLS violation that came from doing
// direct REST inserts against the RLS-blocked `stops` table.
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const rows = stops.map((s) => ({ const rows = stops.map((s) => ({
city: s.city, city: s.city,
state: s.state, state: s.state,
@@ -53,22 +47,30 @@ export async function createStopsBatch(
active: false, active: false,
})); }));
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/admin_create_stops_batch`, { // `admin_create_stops_batch` is SECURITY DEFINER — bypasses RLS, so we
method: "POST", // can call it as the app's DB role.
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, let inserted: { id?: string }[] = [];
body: JSON.stringify({ p_brand_id: effectiveBrandId, p_stops: rows }), try {
}); const { rows: rpcRows } = await pool.query<{ id?: string }>(
"SELECT * FROM admin_create_stops_batch($1, $2::jsonb)",
if (!res.ok) { [effectiveBrandId, JSON.stringify(rows)],
const err = await res.json().catch(() => ({ message: "Request failed" })); );
return { success: false, created: 0, error: (err as { message?: string }).message ?? "Insert failed" }; inserted = rpcRows;
} catch (err) {
return {
success: false,
created: 0,
error: err instanceof Error ? err.message : "Insert failed",
};
} }
revalidateTag("stops", "default"); revalidateTag("stops", "default");
revalidateTag(`brand:${effectiveBrandId}:stops`, "default"); revalidateTag(`brand:${effectiveBrandId}:stops`, "default");
const inserted = await res.json(); return {
return { success: true, created: Array.isArray(inserted) ? inserted.length : stops.length }; success: true,
created: Array.isArray(inserted) ? inserted.length : stops.length,
};
} }
export async function publishStop( export async function publishStop(
@@ -79,18 +81,22 @@ export async function publishStop(
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; // Direct update via raw SQL — the stops table lives in the legacy
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; // schema (city/state/date/active), so Drizzle's new-schema stops
// table doesn't have the columns we'd need to set.
const res = await fetch(`${supabaseUrl}/rest/v1/stops?id=eq.${stopId}`, { try {
method: "PATCH", const { rowCount } = await pool.query(
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, "UPDATE stops SET status = 'active', active = true WHERE id = $1",
body: JSON.stringify({ status: "active", active: true }), [stopId],
}); );
if (!rowCount) {
if (!res.ok) { return { success: false, error: "Stop not found" };
const err = await res.json().catch(() => ({ message: "Patch failed" })); }
return { success: false, error: (err as { message?: string }).message ?? "Publish failed" }; } catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Publish failed",
};
} }
revalidateTag("stops", "default"); revalidateTag("stops", "default");
@@ -99,6 +105,41 @@ export async function publishStop(
return { success: true }; return { success: true };
} }
/**
* Soft-delete a stop via the `delete_stop` SECURITY DEFINER RPC. Guards
* against deleting a stop that has open (non-pickup) orders.
*/
export async function deleteStop(
stopId: string,
brandId: string
): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
let data: { success?: boolean; error?: string } = {};
try {
const { rows } = await pool.query<{ success?: boolean; error?: string }>(
"SELECT * FROM delete_stop($1, $2)",
[stopId, brandId],
);
data = rows[0] ?? {};
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Delete failed",
};
}
if (!data.success) {
return { success: false, error: data.error ?? "Delete failed" };
}
revalidateTag("stops", "default");
revalidateTag(`brand:${brandId}:stops`, "default");
return { success: true };
}
/** /**
* Fetch active stops for sitemap generation. * Fetch active stops for sitemap generation.
* This is a public function that doesn't require authentication. * This is a public function that doesn't require authentication.
@@ -110,28 +151,13 @@ export type StopForSitemap = {
}; };
export async function getActiveStopsForSitemap(): Promise<StopForSitemap[]> { export async function getActiveStopsForSitemap(): Promise<StopForSitemap[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; // Wrapped in try/catch so a build-time outage (ECONNREFUSED) doesn't
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; // crash the prerender — the sitemap just renders without stop URLs.
// Build-time prerender runs before Supabase env is configured. Returning
// an empty list lets the sitemap render with only the static URLs.
if (!supabaseUrl || !supabaseKey) return [];
// Get all active stops with their brand slug. Wrapped in try/catch so a
// build-time outage (ECONNREFUSED) doesn't crash the prerender — the
// sitemap just renders without stop URLs.
try { try {
const response = await fetch( const { rows } = await pool.query<StopForSitemap>(
`${supabaseUrl}/rest/v1/rpc/get_active_stops_with_brand`, "SELECT * FROM get_active_stops_with_brand()",
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
}
); );
return Array.isArray(rows) ? rows : [];
if (!response.ok) return [];
const stops = await response.json();
return Array.isArray(stops) ? stops : [];
} catch { } catch {
return []; return [];
} }
@@ -140,9 +166,7 @@ export async function getActiveStopsForSitemap(): Promise<StopForSitemap[]> {
/** /**
* Fetch active stops for a brand by slug. * Fetch active stops for a brand by slug.
* Public action used by the storefront stop pages (e.g. /tuxedo/stops, * Public action used by the storefront stop pages (e.g. /tuxedo/stops,
* /indian-river-direct/stops) — replaces the previous client-side * /indian-river-direct/stops).
* `supabase.from("stops")` query so supabase-js no longer ships to
* the browser for those routes.
* *
* Cached at the edge for 5 minutes; invalidated by `revalidateTag('stops')` * Cached at the edge for 5 minutes; invalidated by `revalidateTag('stops')`
* from any stop mutation (see createStopsBatch, publishStop, etc.). * from any stop mutation (see createStopsBatch, publishStop, etc.).
@@ -164,34 +188,15 @@ export async function getPublicStopsForBrand(
): Promise<PublicStop[]> { ): Promise<PublicStop[]> {
if (!brandSlug) return []; if (!brandSlug) return [];
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; // Wrapped in try/catch so a build-time DB outage (ECONNREFUSED) doesn't
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; // crash the prerender — the page just renders with no stops and
// Build-time prerender runs before Supabase env is configured. Returning // revalidates from a real request once the cache is warm.
// an empty list lets the page render with zero stops; at runtime the
// fetch path is unchanged.
if (!supabaseUrl || !supabaseKey) return [];
// Wrapped in try/catch so a build-time Supabase outage (ECONNREFUSED)
// doesn't crash the prerender — the page just renders with no stops
// and revalidates from a real request once the cache is warm.
try { try {
const response = await fetch( const { rows } = await pool.query<PublicStop>(
`${supabaseUrl}/rest/v1/rpc/get_public_stops_for_brand`, "SELECT * FROM get_public_stops_for_brand($1)",
{ [brandSlug],
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_slug: brandSlug }),
next: {
revalidate: 300,
tags: ["stops", `brand:${brandSlug}:stops`],
},
}
); );
return Array.isArray(rows) ? rows : [];
if (!response.ok) return [];
const stops = await response.json();
return Array.isArray(stops) ? (stops as PublicStop[]) : [];
} catch { } catch {
return []; return [];
} }
+32 -76
View File
@@ -2,7 +2,7 @@
import { revalidateTag } from "next/cache"; import { revalidateTag } from "next/cache";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
import { getMockTableData } from "@/lib/mock-data"; import { getMockTableData } from "@/lib/mock-data";
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true"; const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
@@ -38,61 +38,39 @@ export async function createStop(
return { success: true, id: `mock-stop-${Date.now()}` }; return { success: true, id: `mock-stop-${Date.now()}` };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; // `admin_create_stop` is a SECURITY DEFINER RPC that bypasses the
// Preferred path: anon key + SECURITY DEFINER RPC (bypasses RLS, works even if // block_stops_mutations RLS policy. It returns either {success,
// service role key is absent at runtime). See: // stop_id} or {success:false, error}. Migration 202.
// supabase/migrations/202_fix_admin_create_stop.sql let rpcResult: { success?: boolean; error?: string; stop_id?: string; id?: string } = {};
// scripts/apply-admin-create-stop.js (run this locally with the keys you have) try {
// supabase/ADMIN_CREATE_STOP_FIX.sql (exact prompt SQL for SQL editor) const { rows } = await pool.query<{ success?: boolean; error?: string; stop_id?: string; id?: string }>(
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; "SELECT * FROM admin_create_stop($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
[
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/admin_create_stop`, { data.active ?? false,
method: "POST", data.address || null,
headers: { ...svcHeaders(anonKey), "Content-Type": "application/json" }, brandId,
body: JSON.stringify({ data.city,
p_active: data.active ?? false, data.cutoff_time || null,
p_address: data.address || null, data.date,
p_brand_id: brandId, data.location,
p_city: data.city, data.state,
p_cutoff_time: data.cutoff_time || null, data.time,
p_date: data.date, data.zip || null,
p_location: data.location, ],
p_state: data.state, );
p_time: data.time, rpcResult = rows[0] ?? {};
p_zip: data.zip || null, } catch (err) {
}), return {
}); success: false,
error: err instanceof Error ? err.message : "Failed to create stop",
let usedFallback = false; };
if (!res.ok) {
const errText = await res.text();
const lower = errText.toLowerCase();
const looksLikeMissingFn =
lower.includes("pgrst202") ||
lower.includes("admin_create_stop") ||
lower.includes("could not find the function") ||
lower.includes("function not found");
if (looksLikeMissingFn) {
usedFallback = true;
} else {
return { success: false, error: `Failed: ${errText}` };
} }
} else {
const inserted = await res.json().catch(() => ({} as any)); if (rpcResult.success === false) {
if (inserted && inserted.success === false) { return { success: false, error: rpcResult.error ?? "Failed to create stop" };
// Our RPC returns structured errors as 200 + {success:false}
const errMsg = inserted.error || "Failed to create stop";
const lower = errMsg.toLowerCase();
if (lower.includes("function") || lower.includes("not found") || lower.includes("pgrst")) {
usedFallback = true;
} else {
return { success: false, error: errMsg };
} }
} else {
// Happy path from RPC (either old {id,slug} or new {success, stop_id}) const stopId = rpcResult.stop_id || rpcResult.id || "";
const stopId = inserted?.stop_id || inserted?.id || "";
const effectiveBrandId = brandId || adminUser.brand_id; const effectiveBrandId = brandId || adminUser.brand_id;
if (effectiveBrandId) { if (effectiveBrandId) {
revalidateTag("stops", "default"); revalidateTag("stops", "default");
@@ -100,25 +78,3 @@ export async function createStop(
} }
return { success: true, id: stopId }; return { success: true, id: stopId };
} }
}
if (usedFallback) {
// We cannot bypass the block_stops_mutations RLS policy via REST even with the service key.
// The only reliable path is the SECURITY DEFINER RPC created by migration 202.
// Tell the user exactly how to install it using only the keys they already have.
return {
success: false,
error:
"The admin_create_stop database function is missing (this is why Add New Stop fails with function not found).\n\n" +
"Fix using only the keys in your .env.local (no Supabase dashboard needed):\n" +
" 1. On a machine that can reach your database (normal laptop usually works):\n" +
" node scripts/apply-admin-create-stop.js\n" +
" 2. Or: npm run migrate:one 202\n" +
" 3. Or apply supabase/migrations/202_fix_admin_create_stop.sql via psql / Supabase CLI with --db-url.\n\n" +
"After it succeeds, restart your dev server and Add New Stop will work.",
};
}
// Should not reach here
return { success: false, error: "Unexpected state creating stop" };
}
+68
View File
@@ -0,0 +1,68 @@
"use server";
/**
* List the customers with pending pickups for a given stop.
*
* TODO(migration): the SaaS rebuild's `orders` table
* (db/schema/orders.ts) doesn't carry a `stop_id` column, so this
* helper queries the legacy `orders` table directly via `pool.query`
* for the customer-name / email / phone. When the new schema grows a
* `stop_id` reference, switch this read to a Drizzle query.
*/
import { getAdminUser } from "@/lib/admin-permissions";
import { assertBrandAccess } from "@/lib/brand-scope";
import { pool } from "@/lib/db";
export type StopCustomer = {
id: string;
customer_name: string;
customer_email: string | null;
customer_phone: string | null;
pickup_complete: boolean;
};
export type GetStopPendingCustomersResult =
| { success: true; customers: StopCustomer[] }
| { success: false; error: string };
export async function getStopPendingCustomers(
stopId: string
): Promise<GetStopPendingCustomersResult> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
// Resolve the stop's brand so we can scope-check.
const { rows: stopRows } = await pool.query<{ brand_id: string | null }>(
"SELECT brand_id::text AS brand_id FROM stops WHERE id = $1 LIMIT 1",
[stopId]
);
const brandId = stopRows[0]?.brand_id ?? null;
if (brandId) {
try { assertBrandAccess(adminUser, brandId); } catch {
return { success: false, error: "Brand access denied" };
}
}
try {
const { rows } = await pool.query<StopCustomer>(
`SELECT id::text AS id,
COALESCE(customer_name, '') AS customer_name,
customer_email,
customer_phone,
COALESCE(pickup_complete, false) AS pickup_complete
FROM orders
WHERE stop_id = $1
AND COALESCE(pickup_complete, false) = false
ORDER BY created_at DESC`,
[stopId]
);
return { success: true, customers: rows };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to load customers",
};
}
}
+39 -45
View File
@@ -1,7 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { createClient } from "@supabase/supabase-js"; import { pool } from "@/lib/db";
export type StopDetail = { export type StopDetail = {
id: string; id: string;
@@ -50,39 +50,32 @@ export async function getStopDetails(stopId: string): Promise<StopDetailsResult>
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true"; const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
// Use a fresh server-side client (cookie-less) so RLS doesn't block reads if (useMockData) {
// for platform_admin dev sessions. The auth check above has already gated return { success: false, error: "Stop not found" };
// access.
const server = useMockData
? null
: createClient(supabaseUrl, supabaseKey, {
auth: { persistSession: false, autoRefreshToken: false },
});
// 1. Stop + brand
let stop: StopDetail | null = null;
let stopErr: string | null = null;
if (server) {
const { data, error } = await server
.from("stops")
.select("*, brands(name, slug)")
.eq("id", stopId)
.single();
if (error) stopErr = error.message;
else stop = (data ?? null) as StopDetail | null;
} else {
// Mock fallback — empty
stopErr = "Stop not found";
} }
if (!stop) { // 1. Stop + brand — legacy schema, raw SQL (the Drizzle stops table
return { success: false, error: stopErr ?? "Stop not found" }; // maps to the new schema which doesn't have city/state/date/etc).
const stopRes = await pool.query<StopDetail & { brand_name: string; brand_slug: string }>(
`SELECT s.*, b.name AS brand_name, b.slug AS brand_slug
FROM stops s
LEFT JOIN brands b ON b.id = s.brand_id
WHERE s.id = $1
LIMIT 1`,
[stopId],
);
const stopRow = stopRes.rows[0];
if (!stopRow) {
return { success: false, error: "Stop not found" };
} }
const stop: StopDetail = {
...stopRow,
brands: stopRow.brand_name
? { name: stopRow.brand_name, slug: stopRow.brand_slug }
: null,
};
// Brand-scope check for brand_admin // Brand-scope check for brand_admin
if (adminUser.brand_id && stop.brand_id !== adminUser.brand_id) { if (adminUser.brand_id && stop.brand_id !== adminUser.brand_id) {
@@ -90,26 +83,27 @@ export async function getStopDetails(stopId: string): Promise<StopDetailsResult>
} }
// 2. Candidate products for this brand // 2. Candidate products for this brand
const { data: allProducts } = server const { rows: allProducts } = await pool.query<{ id: string; name: string; type: string; price: number }>(
? await server `SELECT id, name, type, price
.from("products") FROM products
.select("id, name, type, price") WHERE brand_id = $1 AND active = true`,
.eq("brand_id", stop.brand_id) [stop.brand_id],
.eq("active", true) );
: { data: [] as { id: string; name: string; type: string; price: number }[] };
// 3. Assigned products (joined with product info) // 3. Assigned products (joined with product info)
const { data: productStops } = server const { rows: productStops } = await pool.query<AssignedProduct>(
? await server `SELECT ps.id, ps.product_id,
.from("product_stops") json_build_object('id', p.id, 'name', p.name, 'type', p.type, 'price', p.price) AS products
.select("id, product_id, products(id, name, type, price)") FROM product_stops ps
.eq("stop_id", stopId) LEFT JOIN products p ON p.id = ps.product_id
: { data: [] as AssignedProduct[] }; WHERE ps.stop_id = $1`,
[stopId],
);
// 4. Brands for the brand switcher // 4. Brands for the brand switcher
const { data: brands } = server const { rows: brands } = await pool.query<{ id: string; name: string; slug: string }>(
? await server.from("brands").select("id, name, slug") `SELECT id, name, slug FROM brands ORDER BY name`,
: { data: [] as { id: string; name: string; slug: string }[] }; );
return { return {
success: true, success: true,
+76
View File
@@ -0,0 +1,76 @@
"use server";
/**
* 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.
*/
import { getAdminUser } from "@/lib/admin-permissions";
import { pool } from "@/lib/db";
export type AssignProductResult =
| { success: true; id: string }
| { success: false; error: string };
export type UnassignProductResult =
| { success: true }
| { success: false; error: string };
export async function assignProductToStop(params: {
stopId: string;
productId: string;
}): Promise<AssignProductResult> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_products && !adminUser.can_manage_orders) {
return { success: false, error: "Not authorized" };
}
try {
const { rows } = await pool.query<{ id: string; success?: boolean; error?: string }>(
"SELECT * FROM assign_product_to_stop($1::uuid, $2::uuid, $3::text)",
[params.stopId, params.productId, adminUser.user_id]
);
const data = rows[0];
if (!data?.id) {
return { success: false, error: data?.error ?? "Failed to assign product to stop" };
}
return { success: true, id: data.id };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to assign product to stop",
};
}
}
export async function unassignProductFromStop(params: {
stopId: string;
productId: string;
}): Promise<UnassignProductResult> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_products && !adminUser.can_manage_orders) {
return { success: false, error: "Not authorized" };
}
try {
await pool.query(
"SELECT * FROM unassign_product_from_stop($1::uuid, $2::uuid, $3::text)",
[params.stopId, params.productId, adminUser.user_id]
);
return { success: true };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to unassign product from stop",
};
}
}
+42 -23
View File
@@ -1,7 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
import { logAuditEvent } from "@/actions/audit"; import { logAuditEvent } from "@/actions/audit";
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true"; const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
@@ -37,32 +37,51 @@ export async function updateStop(
return { success: true }; return { success: true };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const slug = `${data.city.toLowerCase().replace(/\s+/g, "-")}-${data.date}`; const slug = `${data.city.toLowerCase().replace(/\s+/g, "-")}-${data.date}`;
const res = await fetch(`${supabaseUrl}/rest/v1/stops?id=eq.${stopId}`, { // Direct UPDATE on the legacy stops table — the new-schema Drizzle
method: "PATCH", // stops table doesn't have the columns we need to write.
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, const { rowCount, error } = await pool
body: JSON.stringify({ .query(
city: data.city, `UPDATE stops SET
state: data.state, city = $1,
location: data.location, state = $2,
date: data.date, location = $3,
time: data.time, date = $4,
time = $5,
slug = $6,
active = $7,
brand_id = $8,
address = $9,
zip = $10,
cutoff_time = $11
WHERE id = $12`,
[
data.city,
data.state,
data.location,
data.date,
data.time,
slug, slug,
active: data.active, data.active,
brand_id: brandId, brandId,
address: data.address ?? null, data.address ?? null,
zip: data.zip ?? null, data.zip ?? null,
cutoff_time: data.cutoff_time ?? null, data.cutoff_time ?? null,
}), stopId,
}); ],
)
.then((r) => ({ rowCount: r.rowCount ?? 0, error: null }))
.catch((e: unknown) => ({
rowCount: 0,
error: e instanceof Error ? e : new Error(String(e)),
}));
if (!res.ok) { if (error || !rowCount) {
const err = await res.text(); return {
return { success: false, error: `Failed: ${err}` }; success: false,
error: error ? error.message : "Stop not found",
};
} }
logAuditEvent({ logAuditEvent({
+21 -51
View File
@@ -1,7 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
import Stripe from "stripe"; import Stripe from "stripe";
/** /**
@@ -19,25 +19,13 @@ export async function getStripeConnectStatus(brandId: string): Promise<{
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { is_connected: false, error: "Not authenticated" }; if (!adminUser) return { is_connected: false, error: "Not authenticated" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; // Get brand's payment settings via SECURITY DEFINER RPC
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const { rows } = await pool.query<{ stripe_user_id: string | null }>(
"SELECT * FROM get_brand_payment_settings($1)",
// Get brand's payment settings [brandId]
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_brand_payment_settings`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
); );
if (!res.ok) { const stripeUserId = rows[0]?.stripe_user_id ?? null;
return { is_connected: false, error: "Failed to fetch payment settings" };
}
const data = await res.json();
const stripeUserId = data?.stripe_user_id;
if (!stripeUserId) { if (!stripeUserId) {
return { is_connected: false }; return { is_connected: false };
@@ -183,28 +171,17 @@ export async function saveStripeConnectAccount(brandId: string, accountId: strin
success: boolean; success: boolean;
error?: string; error?: string;
}> { }> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; // Save to payment_settings via SECURITY DEFINER RPC
await pool.query(
// Save to payment_settings via RPC "SELECT set_stripe_connect_account($1, $2)",
const res = await fetch( [brandId, accountId]
`${supabaseUrl}/rest/v1/rpc/set_stripe_connect_account`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: brandId,
p_stripe_user_id: accountId,
}),
}
); );
return { success: true };
if (!res.ok) { } catch (e: unknown) {
const error = await res.text(); const error = e instanceof Error ? e.message : String(e);
return { success: false, error: `Failed to save: ${error}` }; return { success: false, error: `Failed to save: ${error}` };
} }
return { success: true };
} }
/** /**
@@ -220,23 +197,16 @@ export async function disconnectStripeConnect(brandId: string): Promise<{
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; // SECURITY DEFINER RPC disconnects the Stripe Connect account
await pool.query(
const res = await fetch( "SELECT disconnect_stripe_connect($1)",
`${supabaseUrl}/rest/v1/rpc/disconnect_stripe_connect`, [brandId]
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
); );
return { success: true };
if (!res.ok) { } catch {
return { success: false, error: "Failed to disconnect Stripe account" }; return { success: false, error: "Failed to disconnect Stripe account" };
} }
return { success: true };
} }
/** /**
+166 -16
View File
@@ -1,7 +1,10 @@
"use server"; "use server";
import Stripe from "stripe"; import Stripe from "stripe";
import { svcHeaders } from "@/lib/svc-headers"; import { eq } from "drizzle-orm";
import { withTenant } from "@/db/client";
import { brandSettings } from "@/db/schema";
import { pool } from "@/lib/db";
export type TaxCalculationResult = { export type TaxCalculationResult = {
taxAmount: number; taxAmount: number;
@@ -9,6 +12,13 @@ export type TaxCalculationResult = {
taxLocation: string; taxLocation: string;
}; };
/**
* Tax settings are stored on `brand_settings.feature_flags` (jsonb).
* The SaaS rebuild uses the same keys the legacy `get_brand_settings`
* RPC exposed: `collect_sales_tax` (boolean) and `nexus_states`
* (string[] of US state codes). Reading them out of the JSON column
* avoids a per-brand settings table for a few toggle fields.
*/
type BrandTaxSettings = { type BrandTaxSettings = {
collect_sales_tax: boolean | null; collect_sales_tax: boolean | null;
nexus_states: string[] | null; nexus_states: string[] | null;
@@ -101,23 +111,163 @@ export async function calculateOrderTax(params: {
} }
} }
/**
* Read tax-related feature flags for a brand. The two flags
* (`collect_sales_tax`, `nexus_states`) used to live in a dedicated
* `get_brand_settings` SECURITY DEFINER RPC; in the SaaS rebuild they
* live in `brand_settings.feature_flags` (jsonb). Tolerant of missing
* / malformed JSON by falling back to `null`.
*/
async function getBrandTaxSettings(brandId: string): Promise<BrandTaxSettings | null> { async function getBrandTaxSettings(brandId: string): Promise<BrandTaxSettings | null> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const rows = await withTenant(brandId, async (db) =>
db
const response = await fetch( .select({ featureFlags: brandSettings.featureFlags })
`${supabaseUrl}/rest/v1/rpc/get_brand_settings`, .from(brandSettings)
{ .where(eq(brandSettings.tenantId, brandId))
method: "POST", .limit(1),
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
); );
const flags = rows[0]?.featureFlags as
if (!response.ok) return null; | Partial<{
const data = await response.json(); collect_sales_tax: boolean;
nexus_states: string[];
}>
| null
| undefined;
if (!flags) return null;
return { return {
collect_sales_tax: data?.collect_sales_tax ?? null, collect_sales_tax:
nexus_states: data?.nexus_states ?? null, typeof flags.collect_sales_tax === "boolean"
? flags.collect_sales_tax
: null,
nexus_states: Array.isArray(flags.nexus_states)
? flags.nexus_states.filter((s): s is string => typeof s === "string")
: null,
};
} catch {
return null;
}
}
// ── Tax Dashboard read-side actions ───────────────────────────────────────────
//
// 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.
export type TaxByStateRow = {
state: string;
total_tax: number;
gross_sales: number;
order_count: number;
};
export type TaxSummaryData = {
total_tax_collected: number;
total_gross_sales: number;
order_count: number;
tax_by_state: TaxByStateRow[];
};
export type TaxOrderRow = {
order_id: string;
date: string;
customer_name: string;
city: string;
state: string;
taxable_amount: number;
tax_amount: number;
tax_rate: number;
tax_location: string;
};
export type GetTaxSummaryResult =
| { success: true; data: TaxSummaryData }
| { success: false; error: string };
export type GetTaxableOrdersResult =
| { success: true; data: TaxOrderRow[] }
| { success: false; error: string };
export async function getTaxSummaryAction(params: {
brandId: string;
startDate: string;
endDate: string;
}): Promise<GetTaxSummaryResult> {
if (!params.brandId) return { success: false, error: "Brand required" };
try {
const { rows } = await pool.query<{
total_tax_collected: number | string;
total_gross_sales: number | string;
order_count: number | string;
tax_by_state: TaxByStateRow[] | null;
}>(
"SELECT * FROM get_tax_summary($1::uuid, $2::date, $3::date)",
[params.brandId, params.startDate, params.endDate]
);
const row = rows[0];
if (!row) return { success: false, error: "No data" };
return {
success: true,
data: {
total_tax_collected: Number(row.total_tax_collected ?? 0),
total_gross_sales: Number(row.total_gross_sales ?? 0),
order_count: Number(row.order_count ?? 0),
tax_by_state: Array.isArray(row.tax_by_state) ? row.tax_by_state : [],
},
};
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to load tax summary",
}; };
} }
}
export async function getTaxableOrdersAction(params: {
brandId: string;
startDate: string;
endDate: string;
}): Promise<GetTaxableOrdersResult> {
if (!params.brandId) return { success: false, error: "Brand required" };
try {
const { rows } = await pool.query<{
order_id: string;
date: string;
customer_name: string | null;
city: string | null;
state: string | null;
taxable_amount: number | string;
tax_amount: number | string;
tax_rate: number | string;
tax_location: string | null;
}>(
"SELECT * FROM get_taxable_orders($1::uuid, $2::date, $3::date)",
[params.brandId, params.startDate, params.endDate]
);
return {
success: true,
data: rows.map((r) => ({
order_id: String(r.order_id ?? ""),
date: String(r.date ?? ""),
customer_name: String(r.customer_name ?? ""),
city: String(r.city ?? ""),
state: String(r.state ?? ""),
taxable_amount: Number(r.taxable_amount ?? 0),
tax_amount: Number(r.tax_amount ?? 0),
tax_rate: Number(r.tax_rate ?? 0),
tax_location: String(r.tax_location ?? ""),
})),
};
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to load taxable orders",
};
}
}
+48 -171
View File
@@ -2,14 +2,18 @@
import { cookies } from "next/headers"; import { cookies } from "next/headers";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; // TODO(migration): the time-tracking feature was built on Supabase RPCs
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; // (verify_time_tracking_pin, clock_in_worker, clock_out_worker,
// get_open_clock_in, get_time_tracking_tasks,
function rpcBody(body: Record<string, unknown>) { // get_worker_pay_period_hours, get_time_tracking_settings,
return JSON.stringify(body); // get_water_user_by_id, get_water_admin_session, submit_water_entry,
} // trigger_water_alert) plus tables that don't exist in the SaaS rebuild
// schema (`time_workers`, `time_tasks`, `time_logs`, `water_*`). The
// ── Types ───────────────────────────────────────────────────────────────────── // functions below are stubs that preserve the original signature so
// the field UI degrades gracefully (no PIN login, no entry submit) —
// exactly the same pattern as `actions/route-trace/lots.ts`. To bring
// time tracking back, add the tables to `db/schema/` and re-implement
// these functions against Drizzle.
export type TimeTrackingSession = { export type TimeTrackingSession = {
worker_id: string; worker_id: string;
@@ -49,119 +53,41 @@ function parseSessionCookie(cookie: string): TimeTrackingSession | null {
// ── Verify PIN ───────────────────────────────────────────────────────────────── // ── Verify PIN ─────────────────────────────────────────────────────────────────
export async function verifyTimeTrackingPin( export async function verifyTimeTrackingPin(
brandId: string, _brandId: string,
pin: string _pin: string
): Promise<{ success: boolean; session?: TimeTrackingSession; error?: string }> { ): Promise<{ success: boolean; session?: TimeTrackingSession; error?: string }> {
const res = await fetch( // RPC no longer exists; surface a friendly error so the field UI can
`${supabaseUrl}/rest/v1/rpc/verify_time_tracking_pin`, // disable the PIN pad.
{ return { success: false, error: "Time tracking is not configured" };
method: "POST",
headers: {
"Content-Type": "application/json",
apikey: supabaseKey,
Authorization: `Bearer ${supabaseKey}`,
},
body: rpcBody({ p_brand_id: brandId, p_pin: pin }),
}
);
const data = await res.json();
if (!res.ok || !data?.success) {
return { success: false, error: data?.error ?? "Invalid PIN" };
}
const session: TimeTrackingSession = {
worker_id: data.worker_id,
name: data.name,
role: data.role,
lang: data.lang,
session_id: data.session_id,
brand_id: data.brand_id,
expires_at: data.expires_at,
};
// Set HTTP-only cookie
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE, sessionCookie(session), {
httpOnly: true,
sameSite: "lax",
maxAge: COOKIE_MAX_AGE,
path: "/",
});
return { success: true, session };
} }
// ── Clock In ─────────────────────────────────────────────────────────────────── // ── Clock In ───────────────────────────────────────────────────────────────────
export async function clockInWorker( export async function clockInWorker(
brandId: string, _brandId: string,
taskId?: string, _taskId?: string,
taskName = "General Labor" _taskName = "General Labor"
): Promise<{ success: boolean; log_id?: string; clock_in?: string; error?: string }> { ): Promise<{ success: boolean; log_id?: string; clock_in?: string; error?: string }> {
const session = await getTimeTrackingSession(); const session = await getTimeTrackingSession();
if (!session) return { success: false, error: "Not logged in" }; if (!session) return { success: false, error: "Not logged in" };
return { success: false, error: "Time tracking is not configured" };
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/clock_in_worker`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
apikey: supabaseKey,
Authorization: `Bearer ${supabaseKey}`,
},
body: rpcBody({
p_brand_id: brandId,
p_worker_id: session.worker_id,
p_task_id: taskId ?? null,
p_task_name: taskName,
}),
}
);
const data = await res.json();
if (!res.ok || !data?.success) return { success: false, error: data?.error ?? "Failed" };
return { success: true, log_id: data.log_id, clock_in: data.clock_in };
} }
// ── Clock Out ────────────────────────────────────────────────────────────────── // ── Clock Out ──────────────────────────────────────────────────────────────────
export async function clockOutWorker( export async function clockOutWorker(
lunchMinutes = 0, _lunchMinutes = 0,
notes?: string _notes?: string
): Promise<{ success: boolean; log_id?: string; clock_out?: string; total_minutes?: number; error?: string }> { ): Promise<{ success: boolean; log_id?: string; clock_out?: string; total_minutes?: number; error?: string }> {
const session = await getTimeTrackingSession(); const session = await getTimeTrackingSession();
if (!session) return { success: false, error: "Not logged in" }; if (!session) return { success: false, error: "Not logged in" };
return { success: false, error: "Time tracking is not configured" };
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/clock_out_worker`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
apikey: supabaseKey,
Authorization: `Bearer ${supabaseKey}`,
},
body: rpcBody({
p_worker_id: session.worker_id,
p_lunch_minutes: lunchMinutes,
p_notes: notes ?? null,
}),
}
);
const data = await res.json();
if (!res.ok || !data?.success) return { success: false, error: data?.error ?? "Failed" };
return {
success: true,
log_id: data.log_id,
clock_out: data.clock_out,
total_minutes: data.total_minutes,
};
} }
// ── Get Open Clock In ────────────────────────────────────────────────────────── // ── Get Open Clock In ──────────────────────────────────────────────────────────
export async function getOpenClockIn( export async function getOpenClockIn(
brandId: string _brandId: string
): Promise<{ ): Promise<{
success: boolean; success: boolean;
open?: boolean; open?: boolean;
@@ -173,29 +99,7 @@ export async function getOpenClockIn(
}> { }> {
const session = await getTimeTrackingSession(); const session = await getTimeTrackingSession();
if (!session) return { success: false, error: "Not logged in" }; if (!session) return { success: false, error: "Not logged in" };
return { success: true, open: false };
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_open_clock_in`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
apikey: supabaseKey,
Authorization: `Bearer ${supabaseKey}`,
},
body: rpcBody({ p_worker_id: session.worker_id }),
}
);
const data = await res.json();
if (!res.ok || !data?.success) return { success: false, error: data?.error ?? "Failed" };
return {
success: true,
open: data.open,
log_id: data.log_id,
task_name: data.task_name,
clock_in: data.clock_in,
elapsed_minutes: data.elapsed_minutes,
};
} }
// ── Logout ───────────────────────────────────────────────────────────────────── // ── Logout ─────────────────────────────────────────────────────────────────────
@@ -226,24 +130,10 @@ export type TimeTaskField = {
}; };
export async function getTimeTrackingTasksField( export async function getTimeTrackingTasksField(
brandId: string, _brandId: string,
activeOnly = true _activeOnly = true
): Promise<TimeTaskField[]> { ): Promise<TimeTaskField[]> {
const res = await fetch( return [];
`${supabaseUrl}/rest/v1/rpc/get_time_tracking_tasks`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
apikey: supabaseKey,
Authorization: `Bearer ${supabaseKey}`,
},
body: rpcBody({ p_brand_id: brandId, p_active_only: activeOnly }),
}
);
if (!res.ok) return [];
const data = await res.json();
return data?.tasks ?? [];
} }
// ── Pay Period Hours ─────────────────────────────────────────────────────────── // ── Pay Period Hours ───────────────────────────────────────────────────────────
@@ -264,39 +154,26 @@ export type PayPeriodHours = {
weekly_threshold: number; weekly_threshold: number;
}; };
const EMPTY_PAY_PERIOD: PayPeriodHours = {
success: false,
total_minutes: 0,
total_hours: 0,
daily_minutes: 0,
daily_hours: 0,
weekly_minutes: 0,
weekly_hours: 0,
daily_overtime: false,
weekly_overtime: false,
period_start: "",
period_end: "",
daily_threshold: 12,
weekly_threshold: 56,
};
export async function getWorkerPayPeriodHours( export async function getWorkerPayPeriodHours(
brandId: string _brandId: string
): Promise<PayPeriodHours> { ): Promise<PayPeriodHours> {
const session = await getTimeTrackingSession(); const session = await getTimeTrackingSession();
if (!session) return { success: false, total_minutes: 0, total_hours: 0, daily_minutes: 0, daily_hours: 0, weekly_minutes: 0, weekly_hours: 0, daily_overtime: false, weekly_overtime: false, period_start: "", period_end: "", daily_threshold: 12, weekly_threshold: 56 }; if (!session) return { ...EMPTY_PAY_PERIOD };
return { ...EMPTY_PAY_PERIOD };
const [hoursRes, settingsRes] = await Promise.all([
fetch(
`${supabaseUrl}/rest/v1/rpc/get_worker_pay_period_hours`,
{
method: "POST",
headers: { "Content-Type": "application/json", apikey: supabaseKey, Authorization: `Bearer ${supabaseKey}` },
body: rpcBody({ p_brand_id: brandId, p_worker_id: session.worker_id }),
}
),
fetch(
`${supabaseUrl}/rest/v1/rpc/get_time_tracking_settings`,
{
method: "POST",
headers: { "Content-Type": "application/json", apikey: supabaseKey, Authorization: `Bearer ${supabaseKey}` },
body: rpcBody({ p_brand_id: brandId }),
}
),
]);
const data = await hoursRes.json();
const settings = settingsRes.ok ? (await settingsRes.json()) : null;
if (!hoursRes.ok) return { success: false, total_minutes: 0, total_hours: 0, daily_minutes: 0, daily_hours: 0, weekly_minutes: 0, weekly_hours: 0, daily_overtime: false, weekly_overtime: false, period_start: "", period_end: "", daily_threshold: 12, weekly_threshold: 56 };
return {
...data,
daily_threshold: settings ? Number(settings.daily_overtime_threshold) : 12,
weekly_threshold: settings ? Number(settings.weekly_overtime_threshold) : 56,
};
} }
+70 -275
View File
@@ -1,19 +1,27 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers";
import { mockWorkers, mockTasks, mockTimeEntries } from "@/lib/mock-data"; import { mockWorkers, mockTasks, mockTimeEntries } from "@/lib/mock-data";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; // TODO(migration): the time-tracking admin RPCs (get_time_tracking_workers,
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; // create_time_worker, reset_time_worker_pin, update_time_worker,
// delete_time_worker, create_time_task, update_time_task,
// delete_time_task, get_worker_time_logs, update_worker_time_log,
// delete_worker_time_log, get_time_tracking_summary,
// get_time_tracking_settings, update_time_tracking_settings,
// get_time_tracking_notification_log, check_and_notify_overtime) and
// the underlying tables (`time_workers`, `time_tasks`, `time_logs`,
// `time_tracking_settings`, `time_tracking_notification_log`) were
// not carried over into the SaaS rebuild's `db/schema/`. The actions
// below preserve the original signatures and return mock data when
// `NEXT_PUBLIC_USE_MOCK_DATA === "true"`, but the real RPC paths now
// return empty/empty-list results. To bring time tracking back, add
// the tables to `db/schema/` and re-implement against Drizzle. See
// `actions/route-trace/lots.ts` for the same pattern.
// Mock mode flag - only enabled when explicitly set // Mock mode flag - only enabled when explicitly set
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true"; const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
function rpcBody(body: Record<string, unknown>) {
return JSON.stringify(body);
}
// ── Types ───────────────────────────────────────────────────────────────────── // ── Types ─────────────────────────────────────────────────────────────────────
export type TimeWorker = { export type TimeWorker = {
@@ -76,107 +84,43 @@ export async function getTimeTrackingWorkers(brandId: string): Promise<TimeWorke
worker_number: null, worker_number: null,
})); }));
} }
const res = await fetch( // Time tracking tables not in SaaS rebuild — return empty list.
`${supabaseUrl}/rest/v1/rpc/get_time_tracking_workers`, return [];
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: rpcBody({ p_brand_id: brandId }),
}
);
if (!res.ok) return [];
const data = await res.json();
return (data?.workers ?? []).map((w: Record<string, unknown>) => ({
id: w.id as string,
brand_id: w.brand_id as string,
name: w.name as string,
role: w.role as string,
lang: w.lang as string,
pin: w.pin as string,
active: w.active as boolean,
last_used_at: w.last_used_at as string | null,
created_at: w.created_at as string,
worker_number: w.worker_number as number | null,
}));
} }
export async function createTimeWorker( export async function createTimeWorker(
brandId: string, _brandId: string,
name: string, _name: string,
role = "worker", _role = "worker",
lang = "en" _lang = "en"
): Promise<{ success: boolean; worker?: TimeWorker; pin?: string; error?: string }> { ): Promise<{ success: boolean; worker?: TimeWorker; pin?: string; error?: string }> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/create_time_worker`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: rpcBody({ p_brand_id: brandId, p_name: name, p_role: role, p_lang: lang }),
}
);
const data = await res.json();
if (!res.ok || !data?.success) return { success: false, error: data?.error ?? "Failed" };
return { success: true, worker: data.worker, pin: data.pin };
} }
export async function resetTimeWorkerPin(workerId: string): Promise<{ success: boolean; pin?: string; error?: string }> { export async function resetTimeWorkerPin(_workerId: string): Promise<{ success: boolean; pin?: string; error?: string }> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/reset_time_worker_pin`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: rpcBody({ p_worker_id: workerId }),
}
);
const data = await res.json();
if (!res.ok || !data?.success) return { success: false, error: data?.error ?? "Failed" };
return { success: true, pin: data.pin };
} }
export async function updateTimeWorker( export async function updateTimeWorker(
workerId: string, _workerId: string,
name: string, _name: string,
role: string, _role: string,
lang: string, _lang: string,
active: boolean _active: boolean
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/update_time_worker`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: rpcBody({ p_worker_id: workerId, p_name: name, p_role: role, p_lang: lang, p_active: active }),
}
);
const data = await res.json();
if (!res.ok || !data?.success) return { success: false, error: data?.error ?? "Failed" };
return { success: true };
} }
export async function deleteTimeWorker(workerId: string): Promise<{ success: boolean; error?: string }> { export async function deleteTimeWorker(_workerId: string): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/delete_time_worker`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: rpcBody({ p_worker_id: workerId }),
}
);
const data = await res.json();
if (!res.ok || !data?.success) return { success: false, error: data?.error ?? "Failed" };
return { success: true };
} }
// ── Tasks ──────────────────────────────────────────────────────────────────── // ── Tasks ────────────────────────────────────────────────────────────────────
@@ -192,88 +136,45 @@ export async function getTimeTrackingTasks(brandId: string, activeOnly = false):
sort_order: t.sort_order, sort_order: t.sort_order,
})); }));
} }
const res = await fetch( return [];
`${supabaseUrl}/rest/v1/rpc/get_time_tracking_tasks`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: rpcBody({ p_brand_id: brandId, p_active_only: activeOnly }),
}
);
if (!res.ok) return [];
const data = await res.json();
return data?.tasks ?? [];
} }
export async function createTimeTask( export async function createTimeTask(
brandId: string, _brandId: string,
name: string, _name: string,
nameEs: string | null = null, _nameEs: string | null = null,
unit = "hours", _unit = "hours",
sortOrder = 0 _sortOrder = 0
): Promise<{ success: boolean; id?: string; error?: string }> { ): Promise<{ success: boolean; id?: string; error?: string }> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/create_time_task`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: rpcBody({ p_brand_id: brandId, p_name: name, p_name_es: nameEs, p_unit: unit, p_sort_order: sortOrder }),
}
);
const data = await res.json();
if (!res.ok || !data?.success) return { success: false, error: data?.error ?? "Failed" };
return { success: true, id: data.id };
} }
export async function updateTimeTask( export async function updateTimeTask(
taskId: string, _taskId: string,
name: string, _name: string,
nameEs: string, _nameEs: string,
unit: string, _unit: string,
active: boolean, _active: boolean,
sortOrder: number _sortOrder: number
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/update_time_task`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: rpcBody({ p_task_id: taskId, p_name: name, p_name_es: nameEs, p_unit: unit, p_active: active, p_sort_order: sortOrder }),
}
);
const data = await res.json();
if (!res.ok || !data?.success) return { success: false, error: data?.error ?? "Failed" };
return { success: true };
} }
export async function deleteTimeTask(taskId: string): Promise<{ success: boolean; error?: string }> { export async function deleteTimeTask(_taskId: string): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/delete_time_task`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: rpcBody({ p_task_id: taskId }),
}
);
const data = await res.json();
if (!res.ok || !data?.success) return { success: false, error: data?.error ?? "Failed" };
return { success: true };
} }
// ── Time Logs ───────────────────────────────────────────────────────────────── // ── Time Logs ─────────────────────────────────────────────────────────────────
export async function getWorkerTimeLogs( export async function getWorkerTimeLogs(
brandId: string, brandId: string,
options: { _options: {
workerId?: string; workerId?: string;
taskId?: string; taskId?: string;
start?: string; start?: string;
@@ -285,7 +186,7 @@ export async function getWorkerTimeLogs(
if (useMockData) { if (useMockData) {
// Filter by worker, task, date range // Filter by worker, task, date range
let entries = mockTimeEntries.filter(e => e.brand_id === brandId); let entries = mockTimeEntries.filter(e => e.brand_id === brandId);
if (options.workerId) entries = entries.filter(e => e.worker_id === options.workerId); if (_options.workerId) entries = entries.filter(e => e.worker_id === _options.workerId);
return entries.map(e => { return entries.map(e => {
const worker = mockWorkers.find(w => w.id === e.worker_id); const worker = mockWorkers.find(w => w.id === e.worker_id);
@@ -306,79 +207,32 @@ export async function getWorkerTimeLogs(
}; };
}); });
} }
const res = await fetch( return [];
`${supabaseUrl}/rest/v1/rpc/get_worker_time_logs`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: rpcBody({
p_brand_id: brandId,
p_worker_id: options.workerId ?? null,
p_task_id: options.taskId ?? null,
p_start: options.start ?? null,
p_end: options.end ?? null,
p_limit: options.limit ?? 200,
p_offset: options.offset ?? 0,
}),
}
);
if (!res.ok) return [];
const data = await res.json();
return data?.logs ?? [];
} }
export async function updateWorkerTimeLog( export async function updateWorkerTimeLog(
logId: string, _logId: string,
taskName: string, _taskName: string,
clockIn: string, _clockIn: string,
clockOut: string | null, _clockOut: string | null,
lunchMinutes: number, _lunchMinutes: number,
notes: string | null _notes: string | null
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/update_worker_time_log`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: rpcBody({
p_log_id: logId,
p_task_name: taskName,
p_clock_in: clockIn,
p_clock_out: clockOut,
p_lunch_minutes: lunchMinutes,
p_notes: notes,
}),
}
);
const data = await res.json();
if (!res.ok || !data?.success) return { success: false, error: data?.error ?? "Failed" };
return { success: true };
} }
export async function deleteWorkerTimeLog(logId: string): Promise<{ success: boolean; error?: string }> { export async function deleteWorkerTimeLog(_logId: string): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/delete_worker_time_log`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: rpcBody({ p_log_id: logId }),
}
);
const data = await res.json();
if (!res.ok || !data?.success) return { success: false, error: data?.error ?? "Failed" };
return { success: true };
} }
export async function getTimeTrackingSummary( export async function getTimeTrackingSummary(
brandId: string, brandId: string,
start: string, _start: string,
end: string _end: string
): Promise<TimeSummary> { ): Promise<TimeSummary> {
if (useMockData) { if (useMockData) {
const entries = mockTimeEntries.filter(e => e.brand_id === brandId); const entries = mockTimeEntries.filter(e => e.brand_id === brandId);
@@ -413,16 +267,7 @@ export async function getTimeTrackingSummary(
}, },
}; };
} }
const res = await fetch( return { by_worker: [], by_task: [], totals: { entry_count: 0, total_hours: 0, open_count: 0 } };
`${supabaseUrl}/rest/v1/rpc/get_time_tracking_summary`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: rpcBody({ p_brand_id: brandId, p_start: start, p_end: end }),
}
);
if (!res.ok) return { by_worker: [], by_task: [], totals: { entry_count: 0, total_hours: 0, open_count: 0 } };
return res.json();
} }
// ── Time Tracking Settings ───────────────────────────────────────────────────── // ── Time Tracking Settings ─────────────────────────────────────────────────────
@@ -467,22 +312,13 @@ export async function getTimeTrackingSettings(brandId: string): Promise<TimeTrac
brand_name: "Tuxedo Corn", brand_name: "Tuxedo Corn",
}; };
} }
const res = await fetch( // Real RPC not in SaaS rebuild.
`${supabaseUrl}/rest/v1/rpc/get_time_tracking_settings`, return null;
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: rpcBody({ p_brand_id: brandId }),
}
);
if (!res.ok) return null;
const data = await res.json();
return data;
} }
export async function updateTimeTrackingSettings( export async function updateTimeTrackingSettings(
brandId: string, _brandId: string,
settings: { _settings: {
pay_period_start_day: number; pay_period_start_day: number;
pay_period_length_days: number; pay_period_length_days: number;
daily_overtime_threshold: number; daily_overtime_threshold: number;
@@ -501,34 +337,7 @@ export async function updateTimeTrackingSettings(
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/update_time_tracking_settings`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: rpcBody({
p_brand_id: brandId,
p_pay_period_start_day: settings.pay_period_start_day,
p_pay_period_length_days: settings.pay_period_length_days,
p_daily_threshold: settings.daily_overtime_threshold,
p_weekly_threshold: settings.weekly_overtime_threshold,
p_overtime_multiplier: settings.overtime_multiplier,
p_overtime_notifications: settings.overtime_notifications,
p_notification_emails: settings.notification_emails ?? null,
p_notification_sms_numbers: settings.notification_sms_numbers ?? null,
p_enable_daily_alerts: settings.enable_daily_alerts ?? null,
p_enable_weekly_alerts: settings.enable_weekly_alerts ?? null,
p_daily_alert_threshold: settings.daily_alert_threshold ?? null,
p_weekly_alert_threshold: settings.weekly_alert_threshold ?? null,
p_send_end_of_period_summary: settings.send_end_of_period_summary ?? null,
p_brand_name: settings.brand_name ?? null,
}),
}
);
const data = await res.json();
if (!res.ok || !data?.success) return { success: false, error: data?.error ?? "Failed" };
return { success: true };
} }
// ── Notification Log ───────────────────────────────────────────────────────── // ── Notification Log ─────────────────────────────────────────────────────────
@@ -549,22 +358,8 @@ export type NotificationLogEntry = {
}; };
export async function getTimeTrackingNotificationLog( export async function getTimeTrackingNotificationLog(
brandId: string, _brandId: string,
limit = 100 _limit = 100
): Promise<NotificationLogEntry[]> { ): Promise<NotificationLogEntry[]> {
if (useMockData) {
// Return empty log in mock mode
return []; return [];
} }
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_time_tracking_notification_log`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: rpcBody({ p_brand_id: brandId, p_limit: limit }),
}
);
if (!res.ok) return [];
const data = await res.json();
return data ?? [];
}

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