The legacy src/lib/supabase.ts shim returned { data: null } for every
query, so 15+ pages were silently rendering empty state — including the
two customer-facing storefronts (tuxedo, indian-river-direct) and
several v1 admin pages. Replace every caller with canonical access:
- New src/actions/storefront.ts server-action module: brand lookup,
public stops, active products, stop-by-slug, wholesale settings,
portal config. Uses the shared pg Pool and SECURITY DEFINER RPCs.
- src/actions/brand-settings.ts: getBrandSettingsPublic inlined the
brands + brand_settings LEFT JOIN wholesale_settings query (the
RPC it called did not exist; try/catch was masking the failure).
- src/actions/ai/preferences.ts: switched get/saveAIPreferences to
pool.query.
- src/actions/square-sync-ui.ts: new getSquareQueueCount action for
SquareSyncWidget (replaces shim count).
- src/app/api/{tuxedo,indian-river-direct}/schedule-pdf/route.ts:
use pool.query.
- next.config.ts: redirects /admin/{products/:id,reports,taxes,
settings/{shipping,integrations,billing}} → their v2 / settings
equivalents, then deleted those pages.
- Deleted src/lib/supabase.ts (no remaining imports).
Tests: 174/175 (unchanged from baseline; pre-existing getAdminUser
mock issue is tracked in Step 5).
- List page: new PageHeader with 'OPERATIONS' eyebrow + 'Add product' CTA
- New/Edit pages: PageHeader with eyebrow, icon, status badge (edit)
- ProductsClient: EmptyState for zero products, AdminBadge for status,
Fragment Mono on stats, token-based colors throughout
- ProductTableBody: AdminBadge tones for status/taxable, token-based
hover/selected states, Fragment Mono on price column
- NewProductForm / ProductEditForm / ProductFormModal: surrounding
chrome on tokens; save bars use .ha-btn-primary / .ha-btn-ghost
- .atelier-* body preserved as editorial treatment
- 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
The products list page at /admin/products still queried the legacy Supabase
mock client, which returns an empty array. Combined with the SaaS schema
renaming brands → tenants, the platform admin's brand picker for new
products was silently failing (getBrands SELECTed from a non-existent
'brands' table), making 'Add Product to catalog' look like a no-op.
- src/app/admin/products/page.tsx: replace supabase.from('products') with
a Drizzle query using withPlatformAdmin (cross-tenant) or
withTenant(brandId, ...) (scoped). Map new columns back to the legacy
Product shape the existing UI consumes:
* price_cents (integer) → price (dollars)
* tenant_id → brand_id
* product_images LEFT JOIN for first image per product
* default type='pickup', is_taxable=false (columns not in SaaS schema)
- src/actions/admin/users.ts (getBrands): query tenants table instead of
the legacy brands table.
Verified locally against the seeded dev DB: 8 products across 2 tenants
now render in the catalog, and the brand picker populates with both
tenants for platform admins.
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
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
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.