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.
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.
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)
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.
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.
The Start Docker stack and Deploy steps referenced docker-compose.yml at
the repo root, but the file moved to deploy/ as part of the deploy.sh
refactor. The cp commands failed with 'cannot stat docker-compose.yml'
and the deploy step aborted.
Updated both cp paths to deploy/docker-compose.yml. The cd $APP_DIR and
docker compose -f $APP_DIR/docker-compose.yml references are unchanged
because they point at the deployed copy in APP_DIR, not the repo.
There is exactly one remote (origin = git@git.crispygoat.com:tyler/route-commerce.git).
No GitHub remotes. Pushing to origin/main triggers .gitea/workflows/deploy.yml.
Two errors were aborting the Gitea build:
1. DYNAMIC_SERVER_USAGE on /admin/settings/square-sync (and any admin page):
getAdminUser() reads cookies() via next/headers. The admin layout tried
to prerender statically, so the first child page that hit cookies()
aborted the build. Added 'export const dynamic = "force-dynamic"' to
src/app/admin/layout.tsx so the whole admin tree opts out of static
prerender.
2. Prerender ECONNREFUSED on /indian-river-direct/stops and the sitemap:
getPublicStopsForBrand / getActiveStopsForSitemap / getBrandSettingsPublic
fetch NEXT_PUBLIC_SUPABASE_URL at build time. The Gitea runner sets the
Supabase env vars (so the existing env-var guard passes) but the URL
is unreachable, so fetch throws ECONNREFUSED and the prerender aborts.
Wrapped each fetch in try/catch returning [] / {success: false} so the
prerender completes; runtime behavior is unchanged when the fetch
succeeds.
Also added force-dynamic to the square-sync page itself as belt-and-braces
in case the layout change doesn't propagate.
The /indian-river-direct/stops page and sitemap prerender at build time
and call getPublicStopsForBrand / getActiveStopsForSitemap / getBrandSettingsPublic.
Those actions fetch NEXT_PUBLIC_SUPABASE_URL via Supabase REST. During
the GitHub/Gitea build, the Supabase secret is unset (or the value is
".supabase.co" which doesn't resolve), so the fetch errors with
ECONNREFUSED and the build aborts.
Return [] / not-configured when the env vars are missing so the prerender
can complete. Runtime behavior is unchanged when the vars are set.
- Delete .gitea/workflows/build.yml (typecheck/lint only; caused confusion)
- Rewrite .gitea/workflows/deploy.yml as a thin wrapper that calls
./deploy/deploy.sh, matching the design in deploy/GITEA_SETUP.md
(Option B). The 14512-byte inline deploy is removed; deploy.sh is the
source of truth for the deploy mechanism.
- Fix runs-on to [self-hosted, ubuntu-latest] (matches the actual labels
registered on crispygoat-host-runner; the previous [.., linux, ..] was
unmatchable, which is why runs were stuck in the queue)
Ports the deploy pipeline from the Gitea main fork (commit 7ddb06d's deploy
toolkit) into the Auth.js v5 / NextAuth tree:
- .gitea/workflows/deploy.yml: inline deploy that brings up the Docker
stack, applies migrations, builds Next.js, and runs the app under PM2.
Swapped Better Auth env vars (BETTER_AUTH_SECRET/URL, NEXT_PUBLIC_BETTER_AUTH_URL)
for Auth.js v5 names (AUTH_SECRET/URL, NEXT_PUBLIC_AUTH_URL). Dropped
NEXT_PUBLIC_SUPABASE_URL/ANON_KEY (Supabase removal in progress). Added
GOOGLE_CLIENT_ID/SECRET + ALLOW_DEV_LOGIN for the Auth.js Google provider
and dev credentials path. Switched runs-on from 'ubuntu-latest' to the
self-hosted runner labels matching build.yml.
- deploy/: idempotent deploy toolkit (deploy.sh, docker-compose.yml,
Dockerfile.nextjs, nginx.conf.template, .env.production.example, healthcheck.sh,
Makefile, deploy/.gitignore). No auth/Supabase dependencies — pure infra.
- deploy/.env.production.example: renamed NEXTAUTH_SECRET/NEXTAUTH_URL
(v4) to AUTH_SECRET/AUTH_URL (v5) and added the v5-specific vars
(NEXT_PUBLIC_AUTH_URL, GOOGLE_*, ALLOW_DEV_LOGIN).
Build pipeline is now end-to-end:
build.yml → typecheck + lint + build (uses [self-hosted, linux, ubuntu-latest])
deploy.yml → start docker stack + migrations + build + PM2 restart
Storage / admin code ports (MinIO via @/lib/storage, Supabase removal,
admin-permissions rewrite) are tracked separately — they require porting
the storage and admin code first; the deploy pipeline itself is ready
to run against the Auth.js world.
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
Runs typecheck, lint, and build on every push to main and on pull requests.
Targets the self-hosted Gitea Actions runner (crispygoat-host-runner)
using labels [self-hosted, linux, ubuntu-latest].
Uses the local dev stack endpoints (PostgREST on :3001, MinIO on :9000,
Postgres on :5432) for env vars so no secrets need to be wired in.
Validates the full local development stack:
- Postgres connectivity
- PostgREST API on :3001
- MinIO object storage on :9000
- Next.js dev server on :4000
- Brand isolation via SECURITY DEFINER RPCs
- Auth via dev_session cookie (3 roles)
- 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)
Replace the basic add/edit product modal with an editorial two-column
layout featuring Fraunces display serif, IBM Plex Mono section labels,
a custom drop zone, visual type cards, and pill toggles. Add a brand
selector visible only to platform admins to fix the 'Brand ID is
required' / RLS error when creating products without an assigned brand.
- Add ProductFormModal with two-column layout (image hero + form)
- Add Fraunces / IBM Plex Mono / Inter Tight via next/font/google
- Add atelier-* design system to globals.css
- Wire brand picker through page server component -> client
- Convert ProductsClient image handling to callback shape
- Move all submit/validation/upload logic into the new modal
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.
- Add @stripe/stripe-js + @stripe/react-stripe-js
- New src/lib/stripe-client.ts: cached loadStripe helper
- New src/actions/billing/retail-payment-intent.ts: server action
that creates a PaymentIntent with automatic_payment_methods
- New src/components/storefront/StripeExpressCheckout.tsx: embedded
ExpressCheckoutElement (Apple Pay, Google Pay, Link, PayPal) +
PaymentElement (card) + hosted-checkout fallback
- /checkout form is now controlled; StripeExpressCheckout reads
name/email/stop from form state and confirms in-page via
stripe.confirmPayment
- /checkout/success handles both ?session_id= and ?payment_intent=
so embedded + hosted flows both land on the same order-creation
page using the pending_checkout sessionStorage payload
- Export StopInfo from CartContext and select 'time' on the stops
fetch so the local Stop shape matches the context's StopInfo
- QuickCartSheet express buttons are visual shortcuts only;
/checkout auto-renders the real Apple Pay / Google Pay buttons
Requires NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY for the embedded path;
if missing the embedded section falls back to the hosted
Stripe Checkout button. npx tsc --noEmit passes.
Replace the table-only stops view with a three-tab 'Harvest Dispatch'
almanac dashboard.
- Calendar tab: month grid with stops as status-colored pins, day-detail
side panel, prev/next/today nav, weekend tint, sunrise gradient on
the current day.
- Locations tab: cards grouped by city+state with per-location stats
(stops/active/upcoming), date range, next-stop ribbon, and inline
expansion to list every stop at that location. Top of the tab shows
a six-card almanac stats strip (Locations/Cities/Stops/Active/Upcoming/
Drafts).
- List tab: condensed read-only list of all stops in date order.
Typography: load Fraunces (display), Manrope (body), and Fragment Mono
(mono) via next/font/google, wired into the Tailwind theme as
--font-display, --font-sans, --font-mono. Add typescript to devDeps
for tsc --noEmit in this environment.
Visual signature: cream parchment cards, forest green + clay accents,
Roman-numeral stat cells, paper-grain noise, binder-tab nav, route
number watermarks on location cards, harvest-pin SVG markers.
Verified with tsc --noEmit, eslint on the new files, and a 200 from
GET /admin/stops in the dev server.
- New route /tuxedo/products/sweet-corn-box with editorial Fraunces/JetBrains
Mono typography, paper-grain hero, tilted 'Peak Season 2026' stamp, and
sticky buy-box (desktop) + sticky mobile bottom bar with 48px+ tap targets
- QuickCartSheet slide-up drawer: mobile bottom sheet (spring + drag-to-close)
vs desktop right drawer, with Apple Pay / Google Pay / Shop Pay express row
and one-page 'Checkout' CTA — enables the user's specified 2-3 click flow
- CartContext.buyNow() — single-item cart replace + clear stop for 1-tap
'Quick Buy' that pushes directly to /checkout
- 4-feature check list, 100% Sweetness Guarantee, FAQ accordion, dark final
CTA strip, and proper SEO/Open Graph meta
StopDetailModal was hardcoding callerUid={stop.id} when rendering
StopProductAssignment, so assign_product_to_stop received the stop's
own UUID as p_caller_uid. The RPC's admin_users.user_id lookup
returned no row, so every assign attempt failed with
'Not recognized as admin'.
The admin's user_id is already known inside the getStopDetails
server action (it gates on getAdminUser()), so surface it on the
response and read it in the modal. The sibling /admin/stops/[id]
page already uses the same source (adminUser.user_id) — this brings
the modal in line with it.
Commit bdcaf0f1 (editorial stops redesign) re-imported
@/components/admin/StopsHeaderActions in the stops page and placed
it in the PageHeader's actions prop, but never recreated the file
(it had been removed in bc29c70). The build fails with 'Module not
found' for StopsHeaderActions.
Restore the file from its previous content (pre-bc29c70). The component
renders the 'Upload Schedule' and 'Add Stop' header actions, with a
'stops' tab guard so the buttons don't leak onto the Locations tab.
Without it, the stops surface has no way to add a stop or import a
schedule (StopTableClient is rendered with hideInternalFilterBar and
StopsViewClient doesn't host the buttons).
StopProductAssignment
- remove() now uses functional setState — fixes a stale-closure bug
where 'Remove all' only cleared the first product and rapid single
removals could collide
- assign() no longer re-fetches the full assigned list on every click;
it now does an optimistic insert using the product already in
allProducts, keyed by the row id returned from the RPC. Eliminates
a network round-trip + brief UI flicker per click
- clearAll() snapshots the ids at click time and stops on error so a
failed remove doesn't silently continue
- 'Press / to search' hint: moved `ha-modal-footer-hint` to the
parent span (it was on the kbd), so the kbd styles from
`.ha-modal-footer-hint kbd` actually apply; removed the redundant
inline style and the duplicated className
StopsHeaderActions (new)
- Small client component that owns the Add Stop / Upload Schedule
modals and renders the two header buttons. Wires into the
PageHeader actions slot so the actions stay accessible from the
StopsViewClient (where the StopTableClient filter bar is hidden
by the shared search/status filter)
Per path:
- Edit: trust editingProduct.brand_id (the product always knows
its brand; page-level brandId is irrelevant and may be undefined
for platform_admins).
- Create: require the page-level brandId prop (no product to pull
from).
Replaces the previous single 'brandId ?? editingProduct?.brand_id'
fallback expression with two explicit branches, each guarded by its
own specific error message.
Reimagine the admin stops surface under a 'Harvest Almanac' editorial
direction. Establishes the type system (Fraunces serif display, Geist
sans, JetBrains Mono numerics) loaded via next/font, and a compact
modal pattern used across the new components.
Stop modal (AddStopModal)
- Compact 2-col grid: City|State, Date|Time, Address|ZIP|Cutoff
- Status replaced with slim segmented control (Draft / Publish Now)
- Tighter inputs (36px), single-line footer with Esc hint
- Auto-focus on City field, reactive submit label
GlassModal
- New 'compact' prop: tighter padding, eyebrow text, no accent bar,
capped max-height for dense forms
Stop product assignment (StopProductAssignment)
- Reimagine the <select> dropdown as an editorial card-grid picker
- Big Fraunces numeral + small-caps eyebrow showing live count
- Click-to-toggle assign/remove; green check for assigned, red X on
hover-to-remove intent
- Search + filter chips (All / Available / On this stop) with
mono counters
- '/' keyboard shortcut focuses search
- Summary footer with 'Remove all' action
Stops page
- New StopsViewClient wrapper with shared search/status filter and
Calendar/Table view toggle (default = Calendar)
- New StopsCalendarClient: month grid almanac with editorial header,
color-coded event chips, today highlight, prev/next/Today nav
- Event popover with auto-edge-detection positioning, full stop
details, Publish/View Route/Edit actions
- Day-route drawer: slides in from right with route spine (numbered
markers + connecting hairline), 3-card summary (stops/live/brands),
per-stop Publish/Edit/Duplicate actions
- StopsTableClient refactored to accept hideInternalFilterBar prop
so the wrapper can own shared filtering
Design tokens
- New utility classes in admin-design-system.css: ha-display,
ha-eyebrow, ha-field, ha-segment, ha-picker, ha-product-card,
ha-calendar, ha-event-popover, ha-drawer, ha-route-stop, etc.
- All uses CSS variables (--font-fraunces, --font-geist,
--font-jetbrains-mono) so the type system cascades
When a platform_admin (no brand_id assigned) opens the Edit Product
modal, the page-level brandId prop is undefined and handleSubmit was
erroring with 'Brand ID is required'. The product record itself carries
brand_id, so use it as a fallback before bailing out.
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
Clicking a stop (or the Edit link) in /admin/stops now opens a modal
with Details/Products/Message tabs instead of navigating to
/admin/stops/[id].
- Add getStopDetails server action (returns stop, brand, candidate
products, assigned products, brand list in one round trip).
- Add StopDetailModal component with tabbed panels that reuse the
existing StopEditForm, StopProductAssignment, and
MessageCustomersSection.
- Add an optional onSaved callback to StopEditForm so the modal can
refetch + router.refresh() after a save.
- StopTableClient now opens the modal on row click; the /admin/stops/[id]
route is kept in place for direct links/bookmarks.
Adds a design document for supporting admins who manage 2+ specific brands
(e.g., franchise / multi-brand tenant use cases).
Current model: admin_users.brand_id is a single UUID | null, and the
effectiveBrandId = brandId ?? adminUser.brand_id ?? null pattern silently
does the wrong thing for multi-brand admins. There is no central
validation that an admin is acting in a brand they have access to.
Proposed: admin_user_brands junction table (m:n), kept admin_users.brand_id
for backwards compat, a new multi_brand_admin role, a cookie-based active
brand, a centralized brand-scope helper, and a BrandSelector dropdown in
the admin header. ~30 server actions and ~10 page server components get
a mechanical one-line swap to use the new helper.
Follow-up migration 220_drop_legacy_brand_id.sql will drop the legacy
column after we verify nothing reads it. Out of scope for this spec.
See docs/superpowers/specs/2026-06-04-multi-brand-admin-design.md
Tier 1 — Layout consistency:
* Move Stops tab action buttons (Upload Schedule, Add Stop) into the
filter bar, matching the Locations tab. One muscle memory for
'where the Add button is'. Removes the page-level StopsHeaderActions
component.
* Make the card a single cohesive unit. Tabs sit INSIDE the card on
top with a border-b, content fills the rest. No more 'rounded-b-2xl
rounded-t-none border-t-0' shape.
* New StopsLocationsTabs: pill-style tabs with icons + count badges +
sublabel ('269 · 7 cities'). Instant orientation. URL-driven via
Link so refresh + deep-linking work.
Tier 2 — Easier to scan:
* New StatsStrip under the page header: '269 stops · 7 cities · 4
states · 4 active · 5 draft · 0 inactive' (different stats for the
Locations tab). Context before the table loads.
* Stops table collapses 8 columns → 6: drop Brand, merge City+State
into 'Where', merge Date+Time into 'When' (date + weekday on one
line, time on the next). 30% more breathing room per row.
* Replace font-mono for dates/times with tabular-nums — same column
alignment, type family stays consistent.
* Click anywhere on a Stops row to edit. Redundant 'Edit' link is
gone. Kebab menu still handles Publish/Duplicate/Delete.
* Match Locations' empty state (icon + title + subtitle) on the Stops
side.
Polish:
* Consistent status pills (Active = emerald, Inactive = stone, Draft
= amber) in both tabs.
* Tab-switch fade via framer-motion AnimatePresence — subtle y/opacity
transition between Stops and Locations content.
* 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.
- Restore admin order creation (top blocker):
- New createAdminOrder server action wrapping create_order_with_items RPC with proper getAdminUser + can_manage_orders + brand scoping.
- AdminOrdersPanel now supports ?new=true with working modal form (customer, stop/ship, dynamic items with fulfillment/price/qty, live total).
- Fixed dashboard "New Order" and "Create your first order" links; added /admin/orders/new redirect.
- Success flow: toast + redirect to list.
- Product edit reliability and admin flows hardened.
- Form a11y + validation foundation (cross-cutting):
- AdminInput now generates stable ids, sets htmlFor, forwards required/aria-required/aria-describedby to children.
- All admin forms benefit (labels programmatic, required semantics real).
- Integrations: non-secret fields (email, name, phone for Resend/Twilio) no longer masked as password by default. Only isSecret fields use type=password + toggle.
- Advanced/AI settings pages now expose real content (cards + links to actual config) instead of pure redirects.
- Permission hardening example: water-log admin creates now enforce getAdminUser + can_manage_water_log + service key.
- Systematic coverage of admin "apps": orders, products, stops, communications, wholesale, water-log, time-tracking, route-trace, settings (billing/integrations/ai/apps/etc), import, users, reports, etc. via exploration + targeted fixes. Empty states, toasts, scoping, quick actions improved.
- Updated MEMORY.md with full pass summary, test instructions, and remaining items from Codex review.
Non-destructive focus during pass; used dev auth for verification. Core blockers from review now addressed for testing.
See session plan.md for detailed execution guide.
- 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`
WaterFieldClient.tsx:
- Wrap entry-form section in <form onSubmit={handleSubmitEntry}>
(handler was defined but never bound to a form or button — submit
button did nothing). Submit button already had type="submit".
- Remove unused selectedRole state + its two setSelectedRole calls
(state was set at role-selection but never read).
wholesale/login/page.tsx:
- Remove unused 'name' prop from BrandLogo (was passed in but
never referenced inside the component).
Fixes CRI-17.
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.