Files
route-commerce/CLAUDE.md
T
Nora 49b8e27219 chore(supabase): full purge — remove all Supabase references from codebase
- Delete supabase/ directory (config.toml, push-migrations.js,
  ADMIN_CREATE_STOP_FIX.sql, 137 archived migration files)
- Remove @supabase/ssr and @supabase/supabase-js from package.json
- Strip *.supabase.co from next.config.ts image hostnames
- Strip https://*.supabase.co from vercel.json CSP connect-src
- Remove 'supabase/**' ignore from eslint.config.mjs
- Clean supabase references from src/lib/db.ts, vitest.config.ts,
  db/seeds/2026-tuxedo-tour-stops.sql, src/actions/storefront.ts,
  scripts/import-tuxedo-stops.ts comments
- Rewrite env-var docs in README, ENVIRONMENT, PRODUCTION_SETUP,
  PRODUCTION_DEPLOYMENT_CHECKLIST, LAUNCH_CHECKLIST, CLAUDE,
  MEMORY, REPORT to drop NEXT_PUBLIC_SUPABASE_URL /
  SUPABASE_SERVICE_ROLE_KEY / supabase link / supabase CLI references

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

Final source scan: grep -rln '@supabase\|rest/v1\|supabase\.co'
src/ tests/ db/ scripts/ next.config.ts vercel.json eslint.config.mjs
package.json returns zero. Verification: npm install clean
(11 added, 21 removed, 12 changed), tsc shows same 17 pre-existing
errors (Stripe dahlia API version + fetch preconnect mocks),
vitest 172/175 (3 pre-existing failures in getAdminUser.test.ts),
lint shows same 14 pre-existing errors. Live-tested: dev server
boots in 248ms, public storefronts (/) (/login) (/pricing) (/tuxedo)
(/indian-river-direct) all return 200; /admin/v2?demo=1 reaches 200
after dev_session redirect; storefront renders real brand content.
2026-06-25 17:48:32 -06:00

22 KiB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Canonical Remote

There is exactly one remote — origin — pointing to the self-hosted Gitea repo:

  • URL: git@git.crispygoat.com:tyler/route-commerce.git
  • Default branch: main
  • Deploy: push to origin/main triggers .gitea/workflows/deploy.yml

Do not add GitHub remotes. There is no origin on github.com and no separate "dev" repo. If you see github.com/dzinesco/* URLs in .git/config, that is stale configuration from a previous fork and should be removed (git remote remove).

Project Overview

Route Commerce is a multi-tenant B2B e-commerce platform for fresh produce wholesale distribution. Brands sell to customers who pick up at scheduled stops or receive shipments. The platform includes admin dashboards for order management, stop/route scheduling, product catalogs, payment processing (Stripe + Square), and a communications module ("Harvest Reach") for email/SMS campaigns.

Tech stack: Next.js 16 (App Router) · Postgres (direct) · Neon Auth (Better Auth) · Stripe · Square · Resend (email) · Tailwind CSS v4

Direction: Auth is handled by Neon Auth (Better Auth). The admin_users table links to Neon Auth users by email. New DB code must connect to Postgres directly via the shared pg Pool from src/lib/db.ts. No Supabase JS client, REST gateway, or *.supabase.co calls anywhere in the codebase.


Commands

npm run dev          # Start dev server (runs fix-agents.js first to patch Next.js App Router agent issues)
npm run build        # Production build
npm run lint          # ESLint
npm run migrate:one   # Push a migration (pass digits only, e.g. `npm run migrate:one 83` runs 083_*.sql)
npx tsc --noEmit     # TypeScript check (no emit)
npx playwright test  # Run E2E tests (Playwright)

The migrate script is scripts/migrate.js. It reads DATABASE_URL (or DATABASE_ADMIN_URL) from .env.local via dotenv and applies any db/migrations/*.sql files not already recorded in _migrations. pg is already in devDependencies. If a migration fails with "cannot change return type", the function signature changed — drop and recreate it first.

Historical migration work is documented in MEMORY.md (Supabase login + link process, updates to push-migrations.js for the modern CLI, specific SQL patches made to 091/145/148/200/201 so they would apply cleanly, and which migrations were pushed). Cat MEMORY.md for details.

E2E tests live in tests/ and run via Playwright. Specs include tests/smoke.spec.ts and tests/login/login-flow.spec.ts. Note: playwright.config.ts defaults baseURL to production (https://route-commerce-platform.vercel.app); override with PLAYWRIGHT_URL=http://localhost:3000 for local runs, or pass --config with a local config.


Architecture

Authentication & Authorization

Neon Auth (Better Auth) is the active auth system. Config lives in src/lib/auth.ts, with the route handler at src/app/api/auth/[...nextauth]/route.ts. Neon Auth manages users in the neon_auth.user table; our admin_users table links to Neon Auth users by email.

Auth flow:

  1. User signs in via /api/auth/sign-in → Neon Auth validates credentials → session cookie set
  2. getAdminUser() in src/lib/admin-permissions.ts reads the Neon Auth session and looks up admin_users by email
  3. Middleware (src/middleware.ts) guards /admin/* routes at the edge level

Key files:

  • src/lib/auth.ts — Neon Auth configuration (getSession, signIn, signOut, resetPassword, requestPasswordReset)
  • src/auth.config.ts — Edge-safe config (baseUrl, cookieSecret)
  • src/middleware.ts — Edge-level route protection
  • src/app/api/auth/sign-in/route.ts — Email/password sign-in API
  • src/app/api/auth/forgot-password/route.ts — Password reset request API
  • src/app/api/auth/reset-password/route.ts — Password reset confirmation API
  • src/actions/auth-actions.ts — Server actions (signOutAction)
  • src/actions/admin/password.ts — Admin password update (setUserPassword)

Environment variables:

  • NEON_AUTH_BASE_URL — from neonctl neon-auth status
  • NEON_AUTH_COOKIE_SECRET — min 32-char secret: openssl rand -base64 32
  • NEXT_PUBLIC_SITE_URL — site URL for redirects and Origin header

Single source of truth for the current admin user: getAdminUser() in src/lib/admin-permissions.ts. It reads the Neon Auth session via getSession() from @/lib/auth, then looks up admin_users by email. Never import admin-permissions.ts into Client Components — use server actions instead.

The AdminUser type lives in src/lib/admin-permissions-types.ts and is shared across server/client boundary.

Auth API routes

Route Method Purpose
/api/auth/sign-in POST Email/password sign-in
/api/auth/forgot-password POST Request password reset email
/api/auth/reset-password POST Set new password (after reset link clicked)
/api/auth/[...nextauth] GET/POST Neon Auth handler (sign-out, OAuth callbacks, etc.)

Server Actions Pattern

All database writes go through server actions in src/actions/. These:

  1. Call getAdminUser() to verify auth
  2. Check role/permission flags (can_manage_orders, etc.)
  3. Call Postgres RPCs via the pg driver
  4. Return typed results ({ success: true, ... } | { success: false, error: string })

Server actions are "use server" files that export async functions. Client components import and call them directly.

Database (Postgres, direct)

The app connects to Postgres directly — no Supabase platform, JS client, or REST gateway. Server actions use the pg driver to call SECURITY DEFINER PL/pgSQL functions; Drizzle (on top of the same pool) handles typed reads and the per-request app.current_brand_id GUC. Storage of files (product images, etc.) is moving to an S3-compatible object store; until that's wired up, image references can stay as URLs.

Connection

  • DATABASE_URL in .env.local (and hosting dashboard) is the only required DB env var.
  • A single shared pg Pool is exported from src/lib/db.ts as pool (proxy) and getPool() (lazy). Server actions and API routes import it and call pool.query(...) against RPC names. The Drizzle client lives at db/client.ts and shares the same pool.
  • No NEXT_PUBLIC_SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY / @supabase/* imports — none exist anywhere in the codebase.

First production deploy / new prod DB bootstrap (critical for admin access)

The admin_users + admin_user_brands tables (and the rest of the schema) come only from db/migrations/0001_init.sql.

If the prod DATABASE_URL has never had the migrations applied, getAdminUser() will fail with "relation "admin_users" does not exist", the layout will show "Access Denied", and even a signed-in Neon Auth user will be blocked.

Correct bootstrap sequence (do this from a machine with the full source tree before the first push that exercises /admin):

  1. Ensure the Gitea secret DATABASE_URL points at the real prod Neon Postgres (the one with neon_auth.user already present).

  2. Sign in once at the live prod URL (/login) with the email you want as the first platform_admin. This creates the row in neon_auth.user.

  3. From your laptop (or any box with the checkout):

    # Paste the real prod connection string (get it from Gitea secrets or the target's .env.production)
    DATABASE_URL="postgresql://...prod-full-string..." node scripts/migrate.js
    
    # Then provision (the script will link to the first brand it finds)
    DATABASE_URL="postgresql://...prod-full-string..." \
      npx tsx scripts/provision-admin.ts you@real.com platform_admin
    
  4. Push to main. The deploy workflow now has a hard gate (see .gitea/workflows/deploy.yml "Run migrations" + verification query for admin_users) and ships the migrate runner + SQL files, so future deploys and server-side recovery are protected.

See the full root-cause + plan: docs/superpowers/plans/2026-06-prod-db-schema-migration-reliability.md

The old || echo masking around npm run migrate:one has been removed; a missing critical table will now fail the CI job with a clear message.

SECURITY DEFINER RPCs + Brand Scoping

The app uses PostgreSQL SECURITY DEFINER functions for all data access. These run with the function owner's privileges and bypass any future RLS. This means:

  • Brand isolation must be enforced at the application layer (in server actions), not in database policies
  • Every RPC that touches brand-scoped data accepts a p_brand_id UUID parameter and filters by it
  • The pattern in server actions: effectiveBrandId = brandId ?? adminUser.brand_id ?? null — use explicitly passed brandId when available (from page server components), falling back to the admin's assigned brand

Critical consequence: When getAdminUser() returns brand_id: null (e.g., dev_session platform_admin or certain dev auth states), p_brand_id passed to RPCs will be null. The RPCs handle this by returning all brands' data for platform_admin scope.

Brand ID Threading

Pages under /admin/ are server components that resolve brandId from getAdminUser() and pass it as a prop to client components. Always thread brandId through the entire component tree — never use hardcoded BRAND_ID constants in client components that accept a brandId prop. The pattern:

// admin/orders/page.tsx (server component)
const adminUser = await getAdminUser();
const effectiveBrandId = adminUser.brand_id ?? undefined; // platform_admin gets all brands
return <OrdersPanel brandId={effectiveBrandId} />;

// OrdersPanel.tsx (client component) — use brandId prop, not constants

Public Storefront Architecture

Brand storefronts (/tuxedo/*, /indian-river-direct/*) are isolated from the platform shell. The root layout.tsx detects brand routes via usePathname() and suppresses SiteHeader/SiteFooter for those paths. Brand pages use StorefrontHeader/StorefrontFooter directly.

Brand settings (hero tagline, about headline, feature toggles, etc.) are stored in brand_settings and fetched publicly via getBrandSettingsPublic(brandSlug) server action (no auth required). The get_brand_settings_by_slug RPC merges brand_settings + wholesale_settings.wholesale_enabled in one call.

The StorefrontHeader and StorefrontFooter components accept:

StorefrontHeader: { brandName, brandSlug, logoUrl?, showWholesaleLink?, isAdmin? }
StorefrontFooter: { brandName, brandSlug, logoUrl?, customFooterText?, contactEmail?, contactPhone?, isAdmin? }

Feature Flags + Add-ons

Add-on features are managed via the brand_features table and src/lib/feature-flags.ts. The ADDON_CATALOG defines all available add-ons. Check isFeatureEnabled(brandId, key) in server components. Toggle via toggleBrandFeature(brandId, key, enabled) in src/actions/settings/features.ts. The /admin/settings/apps page provides a UI for enabling/disabling add-ons per brand.

Billing + Plan Tiers

Each brand has a plan_tier in the brands table: starter | farm | enterprise. Plan limits (max_users, max_stops_monthly, max_products) are also stored on the brand. Usage is computed live from actual table counts via get_brand_plan_info RPC.

Tier Price Includes
Starter $49/mo Products, Stops (10/mo), Orders, Basic Pickup, 1 user, 25 products
Farm $149/mo Everything in Starter + Wholesale Portal, Harvest Reach, unlimited stops/products, 5 users, priority support
Enterprise Custom Everything in Farm + AI Intelligence Pack, SMS Campaigns, Square Sync, Water Log, unlimited users, unlimited brands, custom development, dedicated SLA

Add-ons are available à la carte on any plan: wholesale_portal ($99/mo), harvest_reach ($79/mo), ai_tools ($59/mo), water_log ($39/mo), square_sync ($39/mo), sms_campaigns ($29/mo).

Billing page: /admin/settings/billing — shows plan tier, usage stats, enabled add-ons, Stripe Customer Portal link (if stripe_customer_id set), and platform invoice history.

Stripe Billing Integration

Real Stripe subscription management is integrated via:

  • src/actions/billing/stripe-checkout.tscreateStripeCheckoutSession, createPlanUpgradeCheckout, createAddonCheckoutSession, cancelAddonSubscription
  • src/app/api/stripe/webhook/route.ts — handles checkout.session.completed, customer.subscription.updated/deleted, invoice.payment_succeeded/failed

When a subscription checkout completes, the webhook automatically:

  1. Saves stripe_subscription_id, stripe_subscription_status, and stripe_current_period_end to the brand via set_brand_subscription RPC
  2. Enables the corresponding feature flag via set_brand_feature for add-ons
  3. Updates plan tier via update_brand_plan_tier for plan changes

Required Stripe Credentials

Environment variables (.env.local for local, hosting dashboard for production):

Variable Format Where to get
STRIPE_SECRET_KEY sk_live_... or sk_test_... Stripe Dashboard → Developers → API Keys → Secret key
STRIPE_PUBLISHABLE_KEY pk_live_... or pk_test_... Same page — used for Stripe.js in client components
STRIPE_WEBHOOK_SECRET whsec_... Stripe Dashboard → Developers → Webhooks → select endpoint → Signing secret

Stripe Price IDs (create products/prices in Stripe Dashboard first):

Variable Product to create in Stripe
STRIPE_PRICE_STARTER Recurring $49/mo — Starter plan
STRIPE_PRICE_FARM Recurring $149/mo — Farm plan
STRIPE_PRICE_ENTERPRISE Recurring $399/mo — Enterprise plan
STRIPE_PRICE_HARVEST_REACH Recurring $79/mo — Harvest Reach add-on
STRIPE_PRICE_WHOLESALE_PORTAL Recurring $99/mo — Wholesale Portal add-on
STRIPE_PRICE_WATER_LOG Recurring $39/mo — Water Log add-on
STRIPE_PRICE_AI_TOOLS Recurring $59/mo — AI Intelligence add-on
STRIPE_PRICE_SQUARE_SYNC Recurring $39/mo — Square Sync add-on
STRIPE_PRICE_SMS_CAMPAIGNS Recurring $29/mo — SMS Campaigns add-on

For annual pricing, create separate annual prices in Stripe (e.g., $441/yr for Starter = $49/mo * 12 * 0.75) and use different price IDs. Pass annual=true to createStripeCheckoutSession to select the annual price key.

Webhook setup (Stripe Dashboard → Developers → Webhooks → Add endpoint):

  • URL: https://yourdomain.com/api/stripe/webhook
  • Events to listen for: checkout.session.completed, customer.subscription.updated, customer.subscription.deleted, invoice.payment_succeeded, invoice.payment_failed

UI Payments settings (/admin/settings/payments):

  • The stripe_customer_id on the brand record is set when the brand connects Stripe via the Payments settings UI (OAuth flow or manual key entry). Once set, billing checkout sessions can be created for that brand.

Adding a New Brand

  1. Create brand record in brands table:

    INSERT INTO brands (id, name, slug, plan_tier, max_users, max_stops_monthly, max_products)
    VALUES (gen_random_uuid(), 'Sunrise Farms', 'sunrise-farms', 'starter', 2, 20, 50);
    
  2. Initialize wholesale_settings (required for portal):

    INSERT INTO wholesale_settings (brand_id, require_approval, wholesale_enabled)
    VALUES ('<brand-uuid>', true, true);
    
  3. Add-ons are always opt-in — enable specific features via brand_features table or /admin/settings/apps UI.

  4. Create public storefront — duplicate /app/tuxedo//app/sunrise-farms/, update brand constants and slug.

  5. Set Stripe customer ID (for billing portal) — set stripe_customer_id column on the brand via Payments settings or API once Stripe is connected.

Multi-Tenant Data Model

  • brands — each brand has its own products, stops, wholesale settings, communication contacts
  • orders — belong to a brand (via brand_id), may belong to a stop (via stop_id) or be shipping-only
  • order_items — each item has fulfillment: 'pickup' | 'ship'
  • stops — belong to a brand, have address, datetime, status
  • customers (communication_contacts) — belong to a brand, track email/sms opt-in separately

Communications Module ("Harvest Reach")

The communications system (/admin/communications) uses a separate set of tables that have no row-level policies — they rely entirely on the SECURITY DEFINER RPCs + application-layer brand scoping. Key tables: communication_campaigns, communication_templates, communication_contacts, communication_message_logs. Scoping is enforced by RPC + app layer.

send_campaign / send_stop_blast RPCs insert into communication_message_logs but do NOT populate event_id. The Resend webhook (src/app/api/resend/webhook/route.ts) must therefore look up logs by customer_email + subject + created_at (7-day window), not by event_id.

Scheduled automations (declared in vercel.json):

  • POST /api/email-automation/abandoned-cart — every 6h, fires abandoned-cart sequence emails
  • POST /api/email-automation/welcome-sequence — every 6h, fires welcome onboarding sequence
  • POST /api/cron/send-scheduled — daily 09:00, sends scheduled campaigns
  • POST /api/wholesale/notifications/{send,dispatch,pickup-reminder} — wholesale lifecycle
  • POST /api/square/process-queue — every 2 min, drains Square sync queue

These endpoints are also reachable via curl for manual triggering; the email-automation routes accept Authorization: Bearer $CRON_SECRET.

Payments

  • Stripe — primary payment processor; src/actions/payments.ts and src/app/api/stripe/ handle checkout, webhooks, refunds
  • Square — optional sync for inventory/products; src/actions/square-*.ts and src/app/api/square/
  • Wholesale deposits use a separate wholesale_deposits table with payment_method: 'stripe' | 'square'

Water Log

Separate from orders/stops — tracks irrigation/water usage per brand. src/actions/water-log/ and src/app/admin/water-log/. Uses its own water_logs table.


Key Conventions

  • All DB access goes through a shared pg Pool (see Database section). Server actions call SECURITY DEFINER RPCs via pool.query('SELECT * FROM fn_name($1, $2)', [...]). Do not introduce @supabase/* imports, REST fetches, or *.supabase.co calls anywhere in the codebase.
  • gen_random_uuid() used in migrations for primary keys
  • Migrations use CREATE OR REPLACE FUNCTION for idempotency — never DROP then CREATE
  • Status enums stored as TEXT — no PostgreSQL ENUM type
  • Timestamps use TIMESTAMPTZ for timezone awareness
  • Address fields on orders table are minimal (customer_address TEXT) — full structured address (postal_code, state, country) may not exist; check before assuming FedEx-ready address format
  • Migration files: numbered sequentially (e.g., 083_*.sql), never reuse numbers
  • All display dates use formatDate() from src/lib/format-date.ts (MM/DD/YYYY US locale) — never use raw toLocaleDateString() in components

Important File Locations

Concern Location
Neon Auth configuration src/lib/auth.ts, src/auth.config.ts
Auth API routes src/app/api/auth/sign-in/route.ts, src/app/api/auth/forgot-password/route.ts, src/app/api/auth/reset-password/route.ts, src/app/api/auth/[...nextauth]/route.ts
Admin auth + permissions src/lib/admin-permissions.ts, src/lib/admin-permissions-types.ts
Middleware (route protection) src/middleware.ts
Server actions src/actions/*.ts (one file per domain; also grouped into src/actions/{admin,ai,billing,communications,harvest-reach,integrations,orders,products,settings,shipping,stops,water-log,platform,route-trace,time-tracking,email-automation}/)
Admin pages src/app/admin/[module]/page.tsx
Admin client components src/components/admin/*.tsx
Migrations db/migrations/
Postgres pool / driver src/lib/db.ts (TBD)
Email templates src/lib/email-templates.ts
Date formatting src/lib/format-date.ts
Feature flags src/lib/feature-flags.ts
Square sync settings UI src/app/admin/settings/square-sync/
Wholesale portal src/app/wholesale/, src/actions/wholesale*.ts
Billing + plan actions src/actions/billing/stripe-portal.ts
Add-on settings UI src/app/admin/settings/apps/
Billing page src/app/admin/settings/billing/
Brand storefront header/footer src/components/storefront/StorefrontHeader.tsx, src/components/storefront/StorefrontFooter.tsx
Paginated stops (public) src/components/storefront/PaginatedStops.tsx

Gotchas

  • Dev mode brand_id: null: getAdminUser() returns brand_id: null for platform_admin dev sessions. Always pass explicit brandId to server action functions that accept it — don't rely on adminUser.brand_id alone.
  • Communications = no RLS: The communications tables (campaigns, templates, contacts, message_logs) have no row-level policies. All brand scoping must be enforced in server actions.
  • Webhook event_id: log_communication_messages never populates event_id, so the Resend webhook uses customer_email + subject lookup instead.
  • Mixed fulfillment orders: An order can have both pickup and ship items. get_shipping_orders RPC returns orders with at least one fulfillment = 'ship' item.
  • SMS opt-in defaults: communication_contacts.sms_opt_in defaults to FALSE (opt-out by default). email_opt_in defaults to TRUE. Always check sms_opt_in specifically for SMS sends, not email_opt_in.
  • Neon Auth session cookie: The session cookie is managed by Neon Auth. Do not manually set or clear it — use signOut() from @/lib/auth instead.
  • Password reset: The forgot-password API returns success even if the email doesn't exist (to prevent email enumeration). Check server logs to verify reset emails were sent.