- AdminSidebar now renders BrandSelector when brands are available
- Stops page uses getActiveBrandId() instead of adminUser.brand_id
directly, so platform admins see all stops and brand selection
via the sidebar picker works correctly
- BrandSelector already existed and was wired up in the layout,
just never rendered in the sidebar
The page was importing from @/lib/supabase which is a mock client that
returns empty results. Replaced with pool.query() against the real
Postgres stops table.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The seed step on every deploy was inserting duplicate stops each run.
The cleanup script (scripts/cleanup-duplicate-stops.ts) can be used to
dedupe existing rows when needed — run manually via:
DATABASE_URL="..." npx tsx scripts/cleanup-duplicate-stops.ts
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
psql with Neon pooler times out on large batch RPCs. The tsx script
replaces -pooler. with direct compute endpoint and uses pg Pool with
proper batching + progress output.
Run db/seeds/2026-tuxedo-tour-stops.sql via psql after migrations in
the deploy step. Uses admin_create_locations_batch + admin_create_stops_batch
RPCs (migration 0003) to insert 40 locations + 269 stops for the Tuxedo Corn
2026 tour. Wrapped in BEGIN/COMMIT with DELETE-first semantics — idempotent.
- Fetch dashboard stats server-side in admin/page.tsx to eliminate
client-side useEffect waterfall and the '—' placeholder flash
- Pass pre-fetched stats as props to DashboardClient
- Lazy-load UpgradePlanModal via next/dynamic (only loads when needed)
- Redesign stats cards with compact layout, smaller icons, Fraunces serif values
- Improve Quick Actions + Usage row: side-by-side on desktop, stacked mobile
- Clean up Recent Orders as a proper list with icon/name/time/amount/badge
- Add staggered fade-up entrance animations (0/60/120/180/240ms delays)
- Consolidate loading.tsx skeleton to match actual dashboard structure
- Mobile-responsive: 2-col stats on mobile, stacked usage, collapsible header
- LandingPageWrapper: Use dynamic year for copyright
- HeroSection: Add float-slow/float-slow-delayed keyframes, add prefers-reduced-motion media query
- TuxedoVideoHero: Add reduced-motion JS guard and CSS media query for accessibility
The inline node -e '...' blocks inside the YAML run: block had shell
quoting conflicts (nested parens, single-quote escapes) that caused
act/local tooling to fail with 'syntax error near unexpected token ('.
Split into two dedicated scripts:
- scripts/preflight-check.js — neon_auth schema check + 0001_init.sql tracking repair
- scripts/postflight-check.js — admin_users table verification after migrations
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "Run migrations" job (and thus the whole deploy) was failing on every push after the first successful bootstrap with:
✗ 0001_init.sql failed: relation "admin_users" already exists
Root causes:
- 0001_init.sql used plain CREATE TABLE/INDEX/TRIGGER despite the header comment claiming "CREATE TABLE IF NOT EXISTS".
- The _migrations tracking row for 0001_init.sql was never recorded on the prod DATABASE_URL (tracking logic landed after initial apply, or apply happened outside the runner).
Fix (3 layers of defense):
* db/migrations/0001_init.sql: every CREATE TABLE now uses IF NOT EXISTS (63 tables), CREATE INDEX uses IF NOT EXISTS (49), all CREATE TRIGGER wrapped in safe DO $$ IF NOT EXISTS (pg_trigger join check) THEN ... END IF; $$, removed the file's own BEGIN/COMMIT so the runner's transaction is authoritative. RLS drop+create and CREATE OR REPLACE FUNCTION were already safe.
* scripts/migrate.js: added ensureTracked() right after loading the applied set. For 0001/0002, if the core objects (admin_users table, password_hash column) already exist in information_schema but the tracking row is absent, INSERT the filename (ON CONFLICT DO NOTHING) and log "repaired tracking". Then the normal skip path handles it.
* .gitea/workflows/deploy.yml: added an inline node -e pre-repair (same admin_users existence check → force _migrations row) immediately before `npm run migrate:one`. This protects even on an older checkout of the runner. The neon_auth preflight and post-apply "admin_users must exist" hard gate are untouched.
* Minor: cleaned 0002_admin_password.sql tx wrapper for consistency; updated MEMORY.md with the incident + resolution.
Result: deploys (and any server-side `node scripts/migrate.js` recovery runs) on an already-initialized prod DB will repair/skip 0001 cleanly, pass the verification query, and continue to build/deploy. The scp of scripts/ + db/migrations/ in the Deploy step now carries the fixed versions.
See the plan note in deploy.yml and MEMORY.md for background.
- getAdminUser now properly supports platform_admin with 0 brand links and loads full brand_ids
- createAdminUser action now inserts into admin_user_brands join table
- Admin layout surfaces the signed-in email on Access Denied
- AdminAccessDenied links to /login instead of dead-end /admin
- Main dashboard uses direct pool query instead of dead supabase shim
- Improved provision-admin.ts script for prod bootstrap (loads .env.production too)
The membership query in getAdminUser() was selecting role from
adminUserBrands.adminUserId, which is wrong - admin_user_brands has no
role column. Role is stored in admin_users. Also added try/catch
around withPlatformAdmin to prevent DB errors from throwing.
- 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.
Previously, any non-empty AUTH_GOOGLE_ID + AUTH_GOOGLE_SECRET would
trigger the Google button. That broke dev/CI setups where the env
vars are placeholder strings (e.g. 'dummy-google-client-id') — the
button would render and immediately 401 from Google with 'invalid_client'.
Real Google OAuth client IDs always end in '.apps.googleusercontent.com'.
Gate hasGoogle on that suffix so the login page falls back to the
credentials form (or the 'not configured' message) when the values
aren't real.
The seed script previously imported hashPassword from src/lib/passwords.ts,
which has 'import "server-only"' at the top. That guard throws when the
file is loaded outside a Next.js server runtime (which is what 'tsx db/seed.ts'
is — plain Node). Inline the same scrypt format (matches verifyPassword)
so the seed runs from a CLI.
Gitea deploy failed with:
Module not found: Can't resolve '@stripe/stripe-js'
Module not found: Can't resolve '@stripe/react-stripe-js'
These were imported by src/components/storefront/StripeExpressCheckout.tsx
(transitive: src/lib/stripe-client.ts → src/app/checkout/CheckoutClient.tsx)
but never declared in package.json. Add them so the Gitea build can resolve
the imports.
- playwright.config.ts: add testMatch pattern so playwright only picks up
*.spec.ts (skipping tests/unit/*.test.ts which are vitest's domain).
Before this, 'npx playwright test' would fail trying to run vitest tests.
- .gitignore: exclude test-results/ and playwright-report/ (generated by
'npx playwright test', not source files).
Final wave of the Full Supabase migration. All 114 files with Supabase
REST calls have been converted to Drizzle ORM + raw pg.Pool queries.
Verification:
- npx tsc --noEmit: clean (0 errors)
- npm run test: 22/22 pass
- npm run build: succeeded
Zero @supabase imports or rest/v1 references remain in src/.
- analytics.ts: rewrite getReportsSummary, getRevenueChart, getSalesByProduct,
getContactGrowth, getRecentOrders, getConversionFunnel against pool + new
orders/customers schema. Drops retired columns (subtotal, pickup_complete)
and re-implements the SQL by hand.
- import-orders.ts: bulk import via withTx using orders + orderItems + customers
Drizzle tables, computes total_cents from current product prices.
- import-products.ts: rewrite to use withTenant(brandId) and Drizzle products
table.
- products/create-product.ts, update-product.ts, upload-image.ts: switch to
withTenant + Drizzle; image_url moves to product_images table.
- reports.ts: rewrite against pool + new orders schema.
- route-trace/lots.ts: stub functions (route-trace feature retired from SaaS
rebuild — harvest_lots table not in db/schema). Uses discriminated union
return types so consumer narrowing works in both branches.
- settings/features.ts: switch to withTenant + Drizzle brandSettings.
- shipping.ts: switch to pool + Drizzle orders/orderItems.
- api/v1/referrals/route.ts: fix typecheck (referred_user_id undefined → 'anonymous').
Typecheck: clean. Tests: 22/22 pass. Build: succeeds.