- Add MinIO/S3-compatible storage client (src/lib/storage.ts) with uploadObject,
deleteObject, presigned URL helpers, and BUCKETS constant
- Wire product images, brand logos, and water log photos to MinIO via the
new storage client
- Migrate forgot-password to Neon Auth (remove Supabase /auth/v1/recover call)
- Migrate send-scheduled cron to direct Postgres + Resend (remove Supabase Edge
Function proxy)
- Add logoUrl to email types (OrderReceipt, Welcome, PasswordReset) and pass
brand_settings.logo_url from all call sites
- Update email templates to use dynamic logoUrl instead of hardcoded Supabase
bucket URLs
- Remove hardcoded Supabase URLs from TuxedoVideoHero, TuxedoAboutPage,
TimeTrackingFieldClient; use brand_settings props + local public/ fallback
- Download brand logos (3) and tuxedo-hero.mp4 (36MB) from Supabase bucket to
public/ for local development
- Add MinIO env vars to .env.example (endpoint, access key, secret, buckets)
- Fix TimeTrackingFieldClient to destructure logoUrl and brandAccent props
- Fix admin/users.ts logoUrl type (null → undefined for optional string)
- Remove stale sb- cookie from wholesale-auth
- Migrate tuxedo/about page to remove supabase import and use pool query for
wholesale_settings 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
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.
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.
Wire up NextAuth v5 with @auth/pg-adapter, JWT sessions (edge-friendly),
and a dev Credentials provider for local testing without Google OAuth.
Stack
- next-auth@5.0.0-beta.31, @auth/pg-adapter@1.11.2, @types/pg
- Google OAuth provider via GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET
(falls back to AUTH_GOOGLE_ID / AUTH_GOOGLE_SECRET)
- Postgres adapter wired to a single pg.Pool in src/lib/db.ts style —
reads DATABASE_URL with SUPABASE_DB_URL / POSTGRES_URL fallbacks
- JWT session strategy (edge-safe) so the proxy can verify sessions
without a DB round-trip
Files
- src/auth.config.ts edge-safe config (Google + authorized cb)
- src/lib/auth.ts server config (adapter + dev Credentials)
- src/proxy.ts Next.js 16 proxy (was middleware.ts)
- src/app/api/auth/[...nextauth]/route.ts
catch-all handler
- src/app/protected-example/page.tsx
demo page that renders auth() session
- src/actions/auth-signin.ts
signInWithGoogle, signInWithDev,
signOutAction server actions
- src/app/login/LoginClient.tsx
added "Sign in with Google" + dev form
- supabase/migrations/204_authjs_tables.sql
users / accounts / sessions /
verification_token schema (UUID-keyed)
- .env.example AUTH_SECRET, AUTH_URL, GOOGLE_CLIENT_*,
DATABASE_URL, ALLOW_DEV_LOGIN
Removed
- src/middleware.ts deleted; Next.js 16 only runs one proxy
(the new src/proxy.ts is canonical)
Routes
- /login, /admin, /admin/*, /protected-example
proxy matcher
- /api/auth/{providers,csrf,signin/<provider>,callback/<provider>,
session,signout}
standard Auth.js endpoints
Local dev
- npm run dev (now runs on port 4000)
- push migration 204 then visit /login
- dev signin works with any non-empty username/password
(hidden when ALLOW_DEV_LOGIN=false)
- Google signin requires real GOOGLE_CLIENT_ID + redirect URI
http://localhost:4000/api/auth/callback/google
Verified
- tsc --noEmit clean
- /admin, /admin/orders, /protected-example → 307 to /login
when unauthenticated
- /api/auth/session returns user after signin
- /protected-example renders session info
- /api/auth/providers returns google + dev-login
Docs
- CLAUDE.md and MEMORY.md updated to reflect the Supabase → Postgres
+ Auth.js v5 pivot
Gradual migration in progress
- src/lib/admin-permissions.ts still uses dev_session / rc_auth_uid;
the admin shell will show 'Access Denied' for Auth.js-only
sessions until each page is flipped over
- @supabase/* packages remain in package.json for the same reason
- production deployment (AUTH_URL=https://, __Secure- cookies) is
out of scope for this pass
- Add available_from and available_until fields to ProductFormValues type
- Add date picker inputs for seasonal availability in ProductFormModal
- Wire availability dates through ProductsClient to modal initial values
- Create migration 149 for database columns (already applied)
The stops.date column was created as TIMESTAMPTZ but is used as a
calendar date throughout the app. Supabase returned it as a
'YYYY-MM-DD HH:MM:SS+TZ' string, which made the existing
'new Date(s.date + "T00:00:00")' parsing in the client produce
Invalid Date objects — leaving the Stops & Routes calendar empty.
Changing the column to DATE makes Supabase return a clean 'YYYY-MM-DD'
string that the existing local-time parsing handles correctly.
- ALTER stops.date TYPE DATE USING date::DATE
- Recreate get_public_stops_for_brand with date DATE in RETURNS TABLE
(DROP + CREATE required because CREATE OR REPLACE refuses to change
the OUT-parameter row type — PostgreSQL error 42P13)
The time (TEXT 'HH:MM') and cutoff_time (TIMESTAMPTZ) columns are
unchanged — they legitimately carry time-of-day information.
Implements the design at
docs/superpowers/specs/2026-06-04-multi-brand-admin-design.md.
Adds:
- admin_user_brands junction table (m:n admin<->brand) via migration 207
- New role 'multi_brand_admin' (auto-set when an admin has 2+ brands)
- 'active_brand_id' cookie that persists the admin's currently-selected
brand across navigations; switchable via the new BrandSelector dropdown
in the sidebar
- Centralised brand resolution in src/lib/brand-scope.ts:
- getActiveBrandId (URL > cookie > legacy brand_id > first of brand_ids)
- assertBrandAccess (defence-in-depth for cases where the brandId
comes from a URL form or RPC return)
- ~30 server actions and ~10 page server components migrated to use
getActiveBrandId instead of the silent brandId ?? adminUser.brand_id
pattern that allowed cross-brand access bugs
- BrandSelector client component with proper a11y (aria-haspopup,
aria-expanded, role=listbox, outside-click and escape-to-close)
Migration 207 also adds RLS so admins can read their own junction rows
(needed for the dropdown to populate) and SECURITY DEFINER RPCs
add/remove_admin_user_brand that auto-promote/demote between
brand_admin and multi_brand_admin.
Notes:
- Migration number is 207 not 204 — 204-206 were taken in this worktree
by the concurrent locations work.
- The legacy admin_users.brand_id column is preserved for backwards
compat; a follow-up migration 220_* will drop it.
- Dev sessions (dev_session cookie, NEXT_PUBLIC_USE_MOCK_DATA) get
brand_ids: []; the documented limitation is that dev store_employee
will see <AdminAccessDenied /> if no real brands exist.
- Pages that hardcode a Tuxedo brand UUID as a fallback
(adminUser.brand_id ?? '64294306-...') are NOT migrated in this PR —
they still work for single-brand admins and are out of scope.
Co-authored-by: implementer subagent (cancelled mid-run), Grok orchestrator
* inspect_xlsx.py — one-off xlsx dumper used to discover the Stop
Directory sheet structure (14 lines, kept for reference when the
tour schedule xlsx is updated next season).
* scripts/seed_tuxedo_tour.py — Python+openpyxl seed script that
parsed the 3-week Tuxedo Corn 2026 tour schedule and inserted
269 stops via admin_create_stops_batch. Also linked each stop to
its Stop Directory venue (address/phone/contact) and self-published
by flipping status='draft' -> 'active'.
* supabase/migrations/202_delete_stop_orders_guard_fix.sql — fixes a
400 error on the admin Stop Table delete button. The delete_stop
RPC referenced o.deleted_at on the orders table, but that column
doesn't exist, so the SELECT raised 'column does not exist' and
PostgREST surfaced it as HTTP 400. The fix drops the redundant
o.deleted_at IS NULL clause (pickup_complete = false is sufficient
on its own); re-add it here if/when orders grows a deleted_at
column.
The previous backfill grouped by (name, address) only, which collapsed
Tractor Supply in Brighton, CO and Tractor Supply in Canon City, CO
into a single location whenever they shared the same address (or both
had a NULL address). Same problem for Ace Hardware, Murdoch's,
Big Tool Box, and JAX Farm & Ranch.
This migration re-dedupes by (name, city, address), re-links all 269
Tuxedo stops to the new per-city venues, and soft-deletes the 26
over-grouped original locations.
Result: 41 active locations (was 26). 269/269 stops re-linked.
Slug regex also fixed — previous [a-z0-9] stripped uppercase letters;
now uses [[:alnum:]].
* New 'locations' table for reusable venues (Tractor Supply, Boot Barn,
etc.) — each stop now links via stops.location_id while keeping the
denormalized location text for backwards compat.
* 6 SECURITY DEFINER RPCs: admin_create_location, admin_create_locations_batch,
admin_update_location, admin_delete_location, admin_attach_location_to_stop,
get_locations_for_brand. Plus admin_list_locations for the admin tab.
* Backfill: 26 unique venues extracted from the 269 Tuxedo stops, all linked.
* Stops & Routes page now has tabs (Stops | Locations) via ?tab= query param.
Locations tab has full CRUD UI: search, status filter, pagination, edit,
delete, add-venue modal.
* StopsHeaderActions now tab-aware — Upload Schedule / Add Stop only show on
Stops tab; Locations tab has its own Add Venue button inline.
- Update supabase/push-migrations.js:
- Detect modern Supabase CLI link state (supabase/.temp/project-ref)
- Prefer `supabase db query --linked --file` (works in this env where direct postgres connections fail with ENOTFOUND/network unreachable)
- Updated docs/comments for `supabase login` + `supabase link --project-ref wnzkhezyhnfzhkhiflrp` workflow
- Add MEMORY.md capturing the Supabase login/link session, tooling changes, migration patches applied, and gotchas
- Patch migrations for current remote schema (column drift, syntax, idempotency, pre-existing tables):
- 091_brand_plan_tier.sql: fix extra paren in get_brand_plan_info
- 145_create_product_images_bucket.sql: allowed_mime_types + DROP POLICY IF EXISTS + safer bucket insert
- 148_public_stops_rpc.sql: quote reserved "time" column in RETURNS TABLE + SELECT
- 200_production_features.sql: add ALTER TABLE for user_activity_logs (originated in 036) so policies/indexes validate
- 201_seed_data.sql: trim demo seeds with outdated column lists (products/stops/etc.); keep only compatible brands insert
- Include 202_fix_admin_create_stop.sql and related stop creation updates (src/actions/stops/create-stop.ts, 147_admin_create_stop_rpcs.sql, ADMIN_CREATE_STOP_FIX.sql)
- Update CLAUDE.md with pointer to MEMORY.md for recent migration work
Applied via the new flow (after supabase login + link): 084, 091, 142–148, 200–202 etc.
Refs: Supabase CLI now linked; use `node supabase/push-migrations.js <prefix>` or `npm run migrate:one NNN`
Server-side / caching refactor (Grok):
- New RPC get_public_stops_for_brand (migration 148) for public storefront stops
- New server action getPublicStopsForBrand with revalidate=300 + tags
- Add revalidateTag invalidation to createStopsBatch + publishStop
- Convert /tuxedo/stops and /indian-river-direct/stops to Server Components
- Extract TuxedoStopsList + IndianRiverStopsList as client islands (GSAP only)
- Removes supabase-js from browser bundle on those routes
- Both pages now statically prerendered (5m ISR)
Parallel agent changes also staged:
- AI provider model list refresh (claude-sonnet-4-5, etc.)
- ESLint directive patches for react-hooks/set-state-in-effect
- Admin + storefront + checkout + cart updates
- New admin_create_stop_rpcs migration (147)
- Misc fixes across ~90 files
Build verified: typecheck clean, lint clean on new files, production build succeeds.