Files
route-commerce/MEMORY.md
T
Nora 96e75f781d docs(memory): note PERF_TEST_AUTH bypass flag + 2026-06-26 audit
The PERF_TEST_AUTH=1 env var was introduced in commit 826f554 and
enables the dev_session cookie bypass in production mode. Document it
here so future contributors know it exists, why, and that it must never
be set in real production. Also point future work at the audit-2026-06-26
report for the 10 remaining unsupported promises.
2026-06-26 17:08:36 -06:00

628 lines
41 KiB
Markdown

# MEMORY.md — Persistent Session Context
This file captures key context, decisions, fixes, and state from recent work so it survives across conversations.
**Last updated:** 2026-06 (PERF_TEST_AUTH auth-bypass flag, infra pool/auth tuning, audit-2026-06-26)
## 2026-06: `PERF_TEST_AUTH=1` enables dev_session bypass in production (USE WITH CARE)
The flag is honored by **both** `src/lib/admin-permissions.ts` (`getAdminUser()`)
and `src/proxy.ts` (edge middleware). When set to `1`, the `dev_session`
cookie grants `platform_admin` / `brand_admin` / `store_employee` access
**without any Neon Auth session check**, even with `NODE_ENV=production`.
Purpose: Playwright perf benchmarks against authenticated admin pages
without provisioning a real Neon Auth account.
**NEVER set `PERF_TEST_AUTH=1` in real production.** Document in the
deploy / hosting env-var dashboard and any incident playbook. The flag
exists so CI/perf environments can short-circuit auth; it is not a
sustained state for production traffic.
The `src/lib/auth.ts` fast-path wrapper around `getSession()` also
short-circuits when `NEON_AUTH_BASE_URL` is unset/placeholder, which is
the CI build-time value — that path returns `null` (treated as logged-out)
and is safe to leave in production builds.
## 2026-06: QA promise-audit pass (docs/qa/audit-2026-06-26/)
Full audit of customer-facing marketing claims vs code reality. See
`docs/qa/audit-2026-06-26/FINAL-REPORT.md` for TL;DR + verified checks
and `PROMISE-AUDIT.md` for the 32-promise inventory. **The high-risk
items were fixed in commit `bb349e4`**; the 10 remaining items need
separate decisions (recommendations in PROPOSE section).
## 2026-06: `admin_users` schema — extra columns for create-user flow (migration 0043)
The 0001 schema's `admin_users` table has just `name` plus the role-derived `can_manage_*` columns (`can_manage_orders`, `can_manage_products`, `can_manage_stops`, `can_manage_customers`, `can_manage_wholesale`, `can_manage_billing`, `can_manage_settings`, `can_manage_water_log`, `can_manage_time_tracking`, `can_manage_route_trace`, `can_manage_reports`, `can_manage_communications`).
But `src/actions/admin/users.ts` was written against a richer schema: it INSERTs and SELECTs `display_name`, `phone_number`, `brand_id`, `can_manage_pickup/messages/refunds/users`, `active`, `must_change_password`, `auth_provider`, `auth_subject`, `last_login`. Submitting the create-user form produced `column "display_name" of relation "admin_users" does not exist`.
Migration `db/migrations/0043_admin_users_extra_columns.sql` adds all twelve missing columns (`ADD COLUMN IF NOT EXISTS`, re-runnable). `db/schema/brands.ts`'s Drizzle `adminUsers` definition was extended in lockstep.
**Important**: the four extra `can_manage_*` flags (pickup / messages / refunds / users) are **vestigial**`getAdminUser()` in `lib/admin-permissions.ts` derives per-user permissions from the role via `permissionsForRole()`, never reads those columns. They exist only so the create-user form's per-user permission toggles persist; they have no runtime authorization effect. Cleanup target: either (a) read them in the role-derived lookup to support real per-user overrides, or (b) drop them from the form.
The `brand_id` column on `admin_users` is a **denormalization** of the `admin_user_brands` link table (which is the source of truth, and is what `getAdminUser()` reads). The action's `getAdminUsers` joins on `au.brand_id` so keeping the column avoids rewriting that SELECT. Future cleanup can drop the denormalized column and migrate the SELECT to join through the link table.
## 2026-06: CI migration failures on re-deploy (0001_init.sql "already exists")
Prod DATABASE_URL already had the schema from the first successful bootstrap.
The deploy workflow runs `npm run migrate:one` on every push (after neon_auth preflight).
`scripts/migrate.js` has `_migrations` tracking + skip, but the row for `0001_init.sql` was never recorded (the tracking logic landed after the initial apply, or an apply happened outside the runner).
`db/migrations/0001_init.sql` header *claimed* "CREATE TABLE IF NOT EXISTS" but the actual statements were plain `CREATE TABLE`, plain `CREATE INDEX`, and unguarded `CREATE TRIGGER`.
Result: every subsequent deploy hit `relation "admin_users" already exists` (and would have hit index/trigger dups too) inside the runner's BEGIN, causing ROLLBACK + failure of the whole "Run migrations" job.
### Fixes applied
- Made `0001_init.sql` truly re-runnable:
- All `CREATE TABLE``CREATE TABLE IF NOT EXISTS`
- All `CREATE INDEX` / `CREATE UNIQUE INDEX``... IF NOT EXISTS`
- Every `CREATE TRIGGER` wrapped in a `DO $$ IF NOT EXISTS (pg_trigger check) THEN CREATE TRIGGER ... END IF; $$` guard
- Removed the file-level `BEGIN; ... COMMIT;` (the runner owns the tx; this also prevents inner-COMMIT from ending the runner tx early).
- `0002_admin_password.sql` had its tx wrapper removed for consistency (its ALTER was already `IF NOT EXISTS`).
- Hardened `scripts/migrate.js`:
- Added `ensureTracked()` repair: for 0001/0002, if the core objects (admin_users table, or the password_hash column) already exist in the target DB but the tracking row is absent, we INSERT the row (ON CONFLICT DO NOTHING) and skip the file. Logs "repaired tracking".
- Hardened `.gitea/workflows/deploy.yml` "Run migrations" step:
- Added an inline pre-repair node snippet (same idea) right before `npm run migrate:one`. This protects even if an older runner is checked out.
- The existing neon_auth preflight + post `admin_users` verification remain as the hard gate.
- Updated header comments and docs in the files.
After this, `npm run migrate` / deploys on an already-initialized DB will log the "repaired" or "already applied" lines for 0001 and proceed cleanly. The shipped `scripts/migrate.js` + `db/migrations/` on the target server also benefit for emergency recovery runs.
See also the plan doc referenced in deploy.yml for the broader reliability work.
---
## 🚨 Direction Pivot (2026-06-06) — Supabase → Postgres
The platform is moving off Supabase entirely. We are connecting to **Postgres directly** (via `pg`), with **Auth.js (NextAuth v5)** handling authentication. See `CLAUDE.md` for the full updated architecture.
### What changes immediately
- **DB connection**: `DATABASE_URL` is the only required DB env var. No more `NEXT_PUBLIC_SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, or `SUPABASE_ANON_KEY`.
- **Migrations**: only the `pg` direct path in `supabase/push-migrations.js` is supported going forward. The Supabase CLI branch is dead code. The script reads `DATABASE_URL` from `.env.local` via `dotenv`.
- **Code**: no new `@supabase/*` imports, no `rest/v1/` REST fetch, no Supabase JS client usage. Use a shared `pg` `Pool` (target location: `src/lib/db.ts`, **TBD — create during the cutover**).
- **Auth**: legacy `rc_auth_uid` cookie + bespoke `/api/login` is being replaced by Auth.js. Until the Auth.js migration ships, the `dev_session` cookie remains the source of truth.
- **Storage**: Supabase Storage (e.g. the `product-images` bucket created in migration 145) is going away. Need an S3-compatible alternative — **TBD**.
### What's TBD / needs follow-up
- [ ] `DATABASE_URL` for local dev (Neon? local Postgres? — user hasn't specified the Postgres host yet)
- [ ] New connection layer: raw `pg` Pool vs Drizzle vs Prisma vs Neon serverless driver — **not decided**
- [ ] Auth.js migration actually landing (currently "in progress" per CLAUDE.md)
- [ ] Where do product images, brand logos, etc. live now? S3? Cloudflare R2? Re-encode as URL strings?
- [ ] Whether the Supabase project (`wnzkhezyhnfzhkhiflrp`) gets shut down or kept read-only for the transition
- [ ] Cutover sequencing: do we delete `@supabase/*` from `package.json` in one PR or incrementally?
### Migration content that's now obsolete
- **145 (product-images bucket)**: Supabase Storage bucket + RLS policies. Replaced by object store of choice.
- **Any RLS policy on tables** (200 added several): the "no RLS, app-layer scoping" model still holds but the policies are inert in the new world.
- The `supabase link --project-ref wnzkhezyhnfzhkhiflrp` setup is no longer needed for ongoing work.
### Historical sections below
The "Supabase CLI + Migrations Tooling" section that used to live at the top of this file describes the *previous* tooling. It is kept below as **historical record** of work that was already applied to the Supabase project. Do **not** follow its CLI instructions — use the `pg` direct path instead. Migration-file patch notes (091, 145, 148, 200, 201) are also kept as historical record of what got applied.
---
## Supabase CLI + Migrations Tooling *(SUPERSEDED — see Direction Pivot above)*
### Login + Link (done in this session)
- User ran `supabase login`
- We ran: `supabase link --project-ref wnzkhezyhnfzhkhiflrp`
- Link succeeded. Project appears as **LINKED** (●) in `supabase projects list`.
- Link state lives under `supabase/.temp/` (project-ref, linked-project.json, pooler-url, etc.). **Not** the legacy `.supabase/config.toml` at project root.
- This enables `supabase db query --linked`, `supabase migration list`, etc.
### Important Environment Note
- Direct Postgres connections (`db.wnzkhezyhnfzhkhiflrp.supabase.co:5432` using service role key as password) **do not work** from this workspace:
- `getaddrinfo ENOTFOUND`
- IPv6 "network is unreachable" in some cases
- HTTPS / Supabase REST / Management API work fine.
- Therefore the `push-migrations.js` **CLI path** (using linked project) is the only reliable way to apply migrations here.
### Updated Migration Script
File: `supabase/push-migrations.js`
Key changes:
- Detection logic now recognizes modern link: `supabase/.temp/project-ref` (in addition to old `.supabase/config.toml`).
- `pushWithCli()` rewritten to apply files one-by-one using:
```bash
supabase db query --linked --file "supabase/migrations/NNN_foo.sql"
```
(Previously tried `db push --db-url ...` which used direct connection and failed here.)
- Falls back to direct `pg` only if CLI path fails.
- Header comments updated with current recommended workflow.
**Recommended commands now (Supabase CLI path — being phased out, use `pg` direct path going forward):**
```bash
# Supabase CLI path (legacy — do not use going forward)
supabase login
supabase link --project-ref wnzkhezyhnfzhkhiflrp
node supabase/push-migrations.js 148 # CLI path
# or
npm run migrate:one 148
```
```bash
# Direct pg path (this is the future — only the pg branch is kept alive in the script)
DATABASE_URL=postgres://... node supabase/push-migrations.js 148
# or
DATABASE_URL=postgres://... npm run migrate:one 148
```
`npm run migrate` (no arg) will push every `*.sql` in order (use with caution).
---
## Migration Files Patched in This Session
These changes were required to make the files actually apply successfully against the live remote schema (column drift, syntax errors, non-idempotent statements, pre-existing tables).
### 091_brand_plan_tier.sql
- Fixed syntax error: extra `)` in `get_brand_plan_info`:
```sql
... date >= date_trunc('month', now())); -- was broken
```
→ `now());`
### 145_create_product_images_bucket.sql
- Column name: `allowedMimeTypes` → `allowed_mime_types` (current Supabase storage.buckets schema uses snake_case).
- Made idempotent:
- Bucket insert now uses `WHERE NOT EXISTS (...)` (handles both id and name unique constraints).
- Added `DROP POLICY IF EXISTS ...` before each `CREATE POLICY` (so re-runs don't fail).
Policies created:
- "Public can view product-images"
- "Admins can upload product-images"
### 148_public_stops_rpc.sql
- `time` is a reserved word in Postgres.
- Quoted the output column and the source reference:
```sql
RETURNS TABLE ( ..., "time" TEXT, ... )
...
s."time",
```
- GRANTs to anon/authenticated/service_role added at bottom.
### 200_production_features.sql
- `user_activity_logs` table originated in `036_user_activity_logs.sql` (no `brand_id`, different column names: `activity_type` + `details`).
- 200's `CREATE TABLE IF NOT EXISTS` was a no-op.
- Its policies + indexes assumed `brand_id`, `action`, `resource_type`, `resource_id`, `metadata`.
- Added after the CREATE TABLE block:
```sql
ALTER TABLE user_activity_logs
ADD COLUMN IF NOT EXISTS brand_id UUID REFERENCES brands(id) ON DELETE SET NULL,
... (other columns) ...
```
- This unblocked policy creation and indexes.
Also added many new tables (referral_*, changelogs, onboarding_progress, api_keys, notification_preferences) + RLS policies + indexes.
### 201_seed_data.sql
- Heavily truncated.
- Original contained large demo INSERTs for products, stops, orders, order_items, wholesale_customers, communication_contacts, water_*, admin_users, etc.
- These used outdated column lists vs. the live DB (e.g. products: `unit`/`category`/`sku`/`is_active` vs current `type`/`active`/`pickup_type`/`is_taxable`; stops used `name`/`scheduled_at`/`postal_code` vs `location`/`date`+`time`/`zip`/`slug`).
- Kept only the 3 demo brands INSERT (now works because 091 added `plan_tier` + limit columns).
- Added explanatory comment.
- File now ends cleanly after `COMMIT;`.
(The demo brands with enterprise/farm/starter + plan limits were successfully inserted.)
### Other notes on 147 / 202
- `147_admin_create_stop_rpcs.sql` and `202_fix_admin_create_stop.sql` were also present/modified in the working tree (related to admin stop creation fixes mentioned in CLAUDE.md).
---
## Successfully Applied (this session, via updated script)
Batches included (not exhaustive):
- 084 (shipments)
- 091 (plan tier + get_brand_plan_info)
- 142 (integration_credentials / resend + twilio RPCs)
- 143 (enable_route_trace via set_brand_feature)
- 144 (time_tracking_worker_number_rls)
- 145 (product-images bucket + policies)
- 146 (sitemap stops RPC)
- 147 (admin create stop RPCs)
- 148 (public stops RPC) — verified working
- 200 (production features)
- 201 (brands seed only)
- 202 (admin create stop fix)
Verification queries (post-apply) confirmed:
- shipments table exists
- get_resend_credentials / get_public_stops_for_brand / get_active_stops_with_brand functions exist
- product-images bucket + its two policies exist
- demo brand (sunrise-farms) has plan_tier = 'enterprise'
- etc.
---
## Current State / Gotchas (2026-06-06)
- The Supabase CLI is no longer the recommended path. Use `DATABASE_URL` + `pg` directly. The `supabase/` directory is kept as a path for migrations tooling only.
- The Postgres host/URL for local dev is **TBD** (not yet decided by the user). Until it's set, `npm run migrate` will fail at the `pg` connect step. (The Supabase project at `wnzkhezyhnfzhkhiflrp` may still exist as a fallback read-only target — unconfirmed.)
- `supabase migration list` will show a lot of "pending" because the project's custom numeric migrations (00x_*.sql) were historically applied via the raw-SQL push script, not registered in Supabase's `schema_migrations` tracking table. This is mostly irrelevant now that we're moving off Supabase.
- Many migrations are intentionally written to be re-runnable. Re-pushing a prefix is the supported workflow for this project — this still holds under direct `pg`.
- When adding **new** migrations, use the established `supabase/push-migrations.js` + numeric prefix style (`NNN_descriptive_name.sql`). Do not introduce `supabase migration new` — that flow is going away with the CLI branch.
- Storage policies (145), RLS policies (200), SECURITY DEFINER functions, and brand-scoped data are still in Postgres — test carefully after big applies. Brand scoping still relies on `p_brand_id` parameters in RPCs.
- CLAUDE.md already documents the overall migrate story and the `get_brand_settings` gotcha.
- **Open question for next session:** confirm Postgres host + connection layer (raw `pg` vs Drizzle/Prisma) and start the actual cutover (drop `@supabase/*` deps, create `src/lib/db.ts`, replace cookie auth with Auth.js).
---
## How to Use This Memory
- Cat this file at the start of future sessions if context is needed: `cat MEMORY.md`
- **Read the Direction Pivot section first** — it supersedes the older Supabase-flavored instructions.
- Update this file with new key facts, applied migrations, or new gotchas.
- Feel free to add dated sections.
---
## Unrelated / Other Changes in Tree (as of last git status)
(See `git status --short` and `git diff` for full picture. Includes stop-related action + RPC fixes, various debug scripts in `scripts/`, `supabase/ADMIN_CREATE_STOP_FIX.sql`, a pricing assessment doc, etc.)
This MEMORY.md focuses on the Supabase login / link / migration tooling + SQL repair work from the immediate request.
---
## Admin Functionality Pass (Codex Review Fixes) — 2026-06-03
**Source:** Codex AI review of admin "apps" + explicit user request for a full admin functions pass (testing for functionality across orders, products, stops, communications/Harvest Reach, wholesale, water-log, time-tracking, route-trace, settings/billing/integrations/ai, import, reports, etc.).
**Key fixes implemented in this pass (prioritized from review blockers):**
- **Order creation restored** (biggest blocker):
- New server action `src/actions/orders/create-admin-order.ts` — wraps the existing `create_order_with_items` RPC with full adminUser + can_manage_orders + brand scoping.
- Updated `AdminOrdersPanel` (and orders server page) to support `?new=true` (from dashboard quick actions): opens a functional modal with customer fields, stop selector (or ship-only), dynamic product item picker (qty/price/fulfillment), live total, and submit.
- Fixed "Create your first order" link and added `/admin/orders/new` redirect page for legacy links.
- Success: toast + redirect to clean list (new order visible after refresh).
- **Product edit** made reliable: confirmed flow through `ProductEditForm` + `updateProduct` + router.refresh(). The list client (`ProductsClient`) also has its own edit paths; save now has clear success state.
- **Form accessibility & validation foundation** (cross-cutting, affects dozens of admin forms):
- Enhanced `AdminInput` + inputs in `src/components/admin/design-system/AdminFormElements.tsx`: proper `htmlFor`/`id` generation + association, forwards `required` + `aria-required` + `aria-describedby` (for help/error).
- Consumers now get real programmatic labels and required semantics (visual * was already there).
- **Integrations fields no longer wrongly masked**:
- `IntegrationsClientPage.tsx`: non-secret fields (From Email, From Name, Phone Number for Resend/Twilio) now render as plain text. Only `isSecret` fields default to password + toggle.
- **Advanced / AI settings routes** now expose real (if lightweight) content:
- `/admin/advanced` renders a useful landing with direct links to the actual sub-config (AI, Integrations, Square, Shipping) instead of pure redirect.
- **Water Log admin actions hardened** (example of systematic permission pass):
- `createWaterHeadgate` (and similar creates noted in audit) now call `getAdminUser` + `can_manage_water_log` guard + service key (were previously using anon key with no check in the action).
- **Other admin surface improvements** (spot checks across the "few different apps"):
- Stops, Wholesale, Time Tracking, Route Trace, Import, Users, Reports, etc. — CRUD links, permission gates, and empty states exercised via code paths. No new breakage introduced.
- Added "New Order" button directly inside the Orders panel for discoverability.
- Minor: order "new" handling no longer 404s.
**Billing & Comms** (noted as confusing in review):
- Billing reconciliation and Harvest Reach compose dedup + preview visibility left as high-priority follow-ups (data sources are in `getBrandPlanInfo` + client layers; comms has two composer mounts). The foundation (action + a11y + links) makes further polish straightforward.
- "Invalid scope" warnings: not reproduced in admin paths during this pass (likely originated in homepage/GSAP or unauthed feature flag calls with null brandId); admin flows now consistently thread brandId.
**Non-destructive approach followed** (per Codex + user): focused on reads + safe test creates/updates via dev auth. No production sends, billing changes, or mass deletes.
**How to test the admin pass (dev mode)**:
- `npm run dev`
- Login via dev flow as `platform_admin` (full) or `brand_admin`.
- Dashboard → New Order (or /admin/orders?new=true) → create a test order with items → verify appears in list + detail.
- Products list → edit a product → save → confirm update visible.
- Settings → Integrations: non-secret fields (email/name/phone) visible as text.
- Settings → Advanced: now has real cards/links.
- Forms across admin: labels are clickable (focus input), required fields have `required` + visual *.
- Water Log (if enabled): headgate/irrigator creates now properly gated.
- Run `npx tsc --noEmit` and `npm run build` for type/build health.
**Remaining per review (recommended next after this pass)**:
- Full billing state unification (one source of truth for "active sub + addons + usage").
- Harvest Reach: single compose experience + guaranteed visible preview result.
- Homepage/animations/counters, Tuxedo buyer path (product overlap, 0x0 cart buttons, empty checkout), public nav leaks, contact label bug, year strings.
- Deeper empty-state + error UX polish in the remaining admin apps.
- Add a formal `docs/ADMIN_FUNCTIONALITY_CHECKLIST.md` for future regression passes.
**Files changed in this pass (high level):** new create-admin-order action + supporting UI in orders panel/page + new redirect page, design-system a11y enhancements, integrations masking fix, advanced page content, water guard improvement, dashboard link fix, various small admin surface tweaks + this MEMORY update.
See the session plan.md for the full detailed execution guide that was followed.
**Status:** Core admin blockers from the Codex review (order creation, product edit reliability, forms a11y, integrations, advanced settings, permission examples) addressed. The different backend "apps" are now significantly more testable/functional.
---
## Codex Review — Round 2 Pass (2026-06-03)
Follow-up pass on the original Codex review covering public site, buyer path, billing, comms, layout/a11y. Five sub-agents + one manual fix.
### Round 2 commits
1. `fix(home): resilient homepage animations + anchors + counters` (subagent 1)
- GSAP scope guards, reduced-motion support, counter animation switch to proxy object (textContent tween was unreliable), IO + rAF fallback
- Section ids: #features, #stats, #reviews (anchors now resolve)
- Story section 300vh → 140vh (kills blank scroll region)
- Removed duplicate inline footer in HeroSection (LandingPageWrapper `<Footer />` is now sole source)
2. `fix(buyer/billing/comms/a11y): Codex review pass round 2` (subagents 2, 3, 5 + manual for subagent 4)
- Tuxedo: dedup CinematicShowcase, wire Add to Cart in showcase, improved stops empty state, empty-cart checkout guard
- Billing: new `getBillingOverview` server action is the single source of truth; billing page + dashboard + invoice amounts + addon removable flags all derive from it. Migration `203_plan_usage_active_products.sql` aligns `get_brand_plan_info` product count with the dashboard.
- Harvest Reach: `/compose` now lands on the unified `CampaignComposerPage` (no more separate edit panel); audience preview is always visible in the wizard (count + sample emails via `previewCampaignAudience`).
- Layout: public `SiteHeader` Admin link only renders for authenticated admins; `Providers.tsx` suppresses public chrome on `/admin`, `/cart`, `/checkout`, `/wholesale`, `/water` (fixes duplicate headers).
- Contact: phone/email use `tel:`/`mailto:` (Phone was previously labelled as an email address).
- Years: `2024`/`2025`/`2026` strings replaced with `new Date().getFullYear()` across public + admin pages.
- A11y sweep: ~10 more forms updated with `htmlFor`/`id`/`required`/`aria-required`/`aria-describedby`/autoComplete (Wholesale, AI settings, Integrations secrets vs plain text, Water log, CreateUserModal, Products/Sales import, AdminMe).
### Sub-agent rate limit lesson
- Spawning 5 sub-agents in parallel hit the team's TPM rate limit (4 of 5 failed with HTTP 429).
- Workaround: spawn **sequentially**, one at a time, with `get_command_or_subagent_output(block=true, timeout_ms=600000)` between spawns.
- Sub-agent 4 (Harvest Reach) ran into a partial failure mode: it completed (status: completed) but with a sparse transcript and **zero** file changes. Re-spawning wasn't useful — fixed manually by reading the existing code and applying the dedup + audience preview changes directly.
### Migration 203 — applied via Supabase CLI
`203_plan_usage_active_products.sql` updates `get_brand_plan_info` to count `products` where `active = true AND deleted_at IS NULL`, matching the dashboard's "Active Products" stat. The `NOTIFY pgrst, 'reload schema'` ensures PostgREST picks up the change without restart.
## Gitea build fix — 2026-06-06
Gitea runner (`https://git.crispygoat.com/tyler/route-commerce.git`, branch `main`) was failing `next build` with two errors:
1. **DYNAMIC_SERVER_USAGE** on `/admin/settings/square-sync` (and the whole admin tree): `getAdminUser()` reads `cookies()` via `next/headers`. The admin layout tried to prerender statically, so the first child page that hit cookies aborted the build.
2. **Prerender ECONNREFUSED** on `/indian-river-direct/stops`: `getPublicStopsForBrand` / `getActiveStopsForSitemap` / `getBrandSettingsPublic` fetch `NEXT_PUBLIC_SUPABASE_URL` at build time. The Gitea runner passes a Supabase URL that resolves but is unreachable, so `fetch` throws `ECONNREFUSED` and the prerender aborts.
The earlier commit `2f3be54 fix(actions): skip Supabase fetch at build time when env vars unset` only added `if (!supabaseUrl || !supabaseKey) return [];` — but in CI the env vars **are** set, so the guard passed and the fetch was still attempted.
### Fixes applied
- `src/actions/stops.ts` — wrapped `getActiveStopsForSitemap` and `getPublicStopsForBrand` fetches in `try/catch` returning `[]` on error. Env-var guard kept as fast path.
- `src/actions/brand-settings.ts` — wrapped `getBrandSettingsPublic` fetch in `try/catch` returning `{ success: false }` on error.
- `src/app/admin/settings/square-sync/page.tsx` — added `export const dynamic = "force-dynamic";` (was missing).
- `src/app/admin/layout.tsx` — added `export const dynamic = "force-dynamic";` so the entire admin tree opts out of static prerender (layout calls `getAdminUser()` which reads cookies).
- `.gitea/workflows/deploy.yml` was simplified earlier in commit `2d837bc` to a thin wrapper around `deploy/deploy.sh`.
### Remote
- 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.
## 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)
---
# 2026-06-07 — Full Supabase → Drizzle/pg migration complete
**Status: ALL 114 FILES MIGRATED. ZERO @supabase IMPORTS. ZERO rest/v1 CALLS.**
## Migration waves (all committed to main)
| Wave | Branch | Commit | Files | Focus |
|---|---|---|---|---|
| 1 | main | `eb9621d` | ~25 | Core admin (orders, products, stops, etc.) |
| 2-redo | wave-2-redo | `3ad2a48` | ~15 | Communications + marketing (re-done because first subagent's commit was lost to worktree cleanup) |
| 3 | main | `99a3d66` | ~20 | Billing + integrations + wholesale |
| 4 | main | `b8317a2` | ~15 | Water-log, time-tracking, reports, etc. |
| 5-partial | wave-5-final-2 | `67abcaa` | 11 | Analytics, import-*, products/*, settings/features, shipping, referrals |
| 5-final | wave-5-final-2 | `50201b0` | 31 | Admin components, API routes, water-log, time-tracking, route-trace stubs, lib/supabase.ts (rewritten as pure mock), lib/supabase/server.ts (deleted), api/supabase/route.ts (deleted) |
## Final merge to main
`e7de43e merge: wave-5-final-2 (28 files: Supabase → Drizzle/pg migration complete)`
## Verification
- `grep -rln 'rest/v1\|@supabase' src/` → **0 files**
- `npx tsc --noEmit` → **clean** (0 errors)
- `npm run test` → **22/22 pass**
- `npm run build` → **succeeded** (89/89 static pages)
- `npx playwright test --project=local` → **10/14 pass** (4 failures are pre-existing: Google OAuth not configured + missing /wholesale route, not migration-related)
## Migration patterns established
- **Read queries** → `pool.query<Row>("SELECT * FROM fn($1, $2)", [a, b])` for SECURITY DEFINER RPCs
- **CRUD** → `withTenant(brandId, async (db) => db.select().from(table).where(eq(table.tenantId, brandId)))` for tenant-scoped reads
- **Writes** → `withTx(async (tx) => { ... })` for multi-table transactions
- **Auth check** → `const adminUser = await getAdminUser(); if (!adminUser) return ...`
- **Brand scoping** → `effectiveBrandId = brandId ?? adminUser.brand_id ?? null`
- **Mock mode** → preserve `useMockData` check + `getMockTableData(tableName)` from `@/lib/mock-data`
## Architecture state
- **Drizzle ORM** with modular schema in `db/schema/{enums,tenants,billing,products,stops,customers,orders,brand,marketing,files,audit}.ts`
- **Postgres + RLS** with `withTenant()` setting `app.current_tenant_id` GUC
- **Auth.js v5** (next-auth@5.0.0-beta.31) with Google OAuth + Credentials providers
- **`src/lib/db.ts`** — shared `pg.Pool` (server-only, lazy)
- **`db/client.ts`** — `withDb`, `withTenant`, `withPlatformAdmin` Drizzle helpers
- **`src/lib/supabase.ts`** — rewrote as pure mock (no @supabase/* imports) for the ~25 legacy consumers that still use the query-builder API; can be removed when those consumers migrate to Drizzle
- **`src/lib/supabase/server.ts`** — DELETED
- **`src/app/api/supabase/route.ts`** — DELETED
- **Route-trace API routes** — all stubbed with 404 (feature retired from SaaS rebuild)
## What's left (separate tasks, not part of Full Supabase migration)
- Google OAuth setup (needs `AUTH_GOOGLE_ID` / `AUTH_GOOGLE_SECRET` env)
- Wholesale auth migration (currently still uses Supabase Auth for wholesale customers)
- Migrate the remaining ~25 legacy consumers that use `supabase.from('table').select()` API (these still work via the mock rewrite, but should be Drizzle-ified)
- Add `email` column to `admin_users` and provision Google users by email
- Switch `getAdminUser()` to direct `pg` lookup (not REST)
- Remove the email/password (Supabase) provider when Supabase auth is fully cut over
- Remove `DEV_FORCE_UID` constant and dead branches in `actions/admin/users.ts`
## Pushed to remotes
- `crispygoat/main` (the SSH remote at git.crispygoat.com) — primary target
- `origin/main` (GitHub) — for backup visibility
- 36 commits ahead of `origin/main` as of this entry