Per-brand Smartsheet integration that pushes new water_log_entries to a
configured sheet. Config UI lives at /admin/water-log/settings.
Database (0093_water_smartsheet_sync.sql):
- water_smartsheet_config: one row per brand; AES-256-GCM encrypted API
token (BYTEA), sheet id, column mapping JSONB, sync_frequency
(realtime | every_15_minutes | hourly), enable flag, last-sync
metadata. RLS via existing current_brand_id() pattern.
- water_smartsheet_sync_queue: one row per entry awaiting sync;
tracks status / attempts / exponential backoff. UNIQUE(brand_id,
entry_id) for idempotent enqueue.
- water_smartsheet_sync_log: append-only audit of every sync attempt
(success or failure); backs the Recent Activity panel in the UI.
Server:
- src/lib/crypto.ts: AES-256-GCM encrypt/decrypt + token mask helper.
Reads SMARTSHEET_TOKEN_ENC_KEY. Throws on missing/wrong-size key.
- src/lib/smartsheet.ts: typed REST wrapper (no SDK; the official
smartsheet npm has Node 22 compat issues). getSheetMeta, addRow,
findRowByColumn + typed SmartsheetApiError.
- src/services/smartsheet-sync.ts: syncEntryToSmartsheet (one entry),
drainSyncQueue (batch with backoff), runScheduledSync (cron entry).
Sequential processing stays well under Smartsheet's 300 req/min.
Max 5 attempts, then row stays 'failed' permanently.
- src/actions/water-log/smartsheet.ts: 6 server actions. Token never
returned in plaintext — UI gets maskedToken only. Permission check
matches the rest of the water-log module (can_manage_water_log).
- src/app/api/water-log/smartsheet-sync/route.ts: POST cron route,
bearer auth via SMARTSHEET_CRON_SECRET (falls back to CRON_SECRET).
Optional {brandId} body for manual retry.
- vercel.json: added */15 * * * * cron entry. Single entry covers
both 15-min and hourly frequencies (route filters per-brand).
UI:
- src/components/admin/water-log/SmartsheetIntegrationCard.tsx:
self-contained client component with useReducer. Sections: enable
toggle, sheet URL/ID + API token + Test Connection, sync frequency
radio group, column mapping dropdowns (populated after Test),
Recent Activity panel.
- src/app/admin/water-log/settings/page.tsx: mounted the card as new
'§ 05 — Integrations' section after the existing admin-PIN settings.
Card takes brandId as a prop (CLAUDE.md 'Brand ID Threading').
Hooks:
- src/actions/water-log/field.ts::submitWaterEntry: calls
triggerSyncForEntry(brandId, entryId) in a fire-and-forget void
after the entry insert. Sync failures NEVER bubble to the field
worker.
Env vars (.env.example):
- SMARTSHEET_TOKEN_ENC_KEY (REQUIRED, 32 random bytes base64)
- SMARTSHEET_CRON_SECRET (optional bearer)
- SMARTSHEET_SYNC_TIMEOUT_MS (optional, default 8000)
Deploy:
1. openssl rand -base64 32 → SMARTSHEET_TOKEN_ENC_KEY in .env.local
AND Vercel dashboard
2. npm run migrate:one 93 (pushes 0093_water_smartsheet_sync.sql)
3. Push triggers .gitea/workflows/deploy.yml
Rollback: remove cron entry, DROP the 3 tables, revert field.ts +
settings page hooks, git revert the merge commit. See MEMORY.md
for full rollback story + sharp edges (no key rotation, slug URLs).
45 KiB
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.sqltruly re-runnable:- All
CREATE TABLE→CREATE TABLE IF NOT EXISTS - All
CREATE INDEX/CREATE UNIQUE INDEX→... IF NOT EXISTS - Every
CREATE TRIGGERwrapped in aDO $$ 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).
- All
0002_admin_password.sqlhad its tx wrapper removed for consistency (its ALTER was alreadyIF 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".
- Added
- 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_usersverification remain as the hard gate.
- Added an inline pre-repair node snippet (same idea) right before
- 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_URLis the only required DB env var. No moreNEXT_PUBLIC_SUPABASE_URL,SUPABASE_SERVICE_ROLE_KEY, orSUPABASE_ANON_KEY. - Migrations: only the
pgdirect path insupabase/push-migrations.jsis supported going forward. The Supabase CLI branch is dead code. The script readsDATABASE_URLfrom.env.localviadotenv. - Code: no new
@supabase/*imports, norest/v1/REST fetch, no Supabase JS client usage. Use a sharedpgPool(target location:src/lib/db.ts, TBD — create during the cutover). - Auth: legacy
rc_auth_uidcookie + bespoke/api/loginis being replaced by Auth.js. Until the Auth.js migration ships, thedev_sessioncookie remains the source of truth. - Storage: Supabase Storage (e.g. the
product-imagesbucket created in migration 145) is going away. Need an S3-compatible alternative — TBD.
What's TBD / needs follow-up
DATABASE_URLfor local dev (Neon? local Postgres? — user hasn't specified the Postgres host yet)- New connection layer: raw
pgPool 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/*frompackage.jsonin 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 wnzkhezyhnfzhkhiflrpsetup 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.tomlat project root. - This enables
supabase db query --linked,supabase migration list, etc.
Important Environment Note
- Direct Postgres connections (
db.wnzkhezyhnfzhkhiflrp.supabase.co:5432using 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.jsCLI 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:(Previously triedsupabase db query --linked --file "supabase/migrations/NNN_foo.sql"db push --db-url ...which used direct connection and failed here.)- Falls back to direct
pgonly 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):
# 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
# 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
)inget_brand_plan_info:→... date >= date_trunc('month', now())); -- was brokennow());
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 eachCREATE POLICY(so re-runs don't fail).
- Bucket insert now uses
Policies created:
- "Public can view product-images"
- "Admins can upload product-images"
148_public_stops_rpc.sql
timeis a reserved word in Postgres.- Quoted the output column and the source reference:
RETURNS TABLE ( ..., "time" TEXT, ... ) ... s."time", - GRANTs to anon/authenticated/service_role added at bottom.
200_production_features.sql
user_activity_logstable originated in036_user_activity_logs.sql(nobrand_id, different column names:activity_type+details).- 200's
CREATE TABLE IF NOT EXISTSwas a no-op. - Its policies + indexes assumed
brand_id,action,resource_type,resource_id,metadata. - Added after the CREATE TABLE block:
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_activevs currenttype/active/pickup_type/is_taxable; stops usedname/scheduled_at/postal_codevslocation/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.sqland202_fix_admin_create_stop.sqlwere 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+pgdirectly. Thesupabase/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 migratewill fail at thepgconnect step. (The Supabase project atwnzkhezyhnfzhkhiflrpmay still exist as a fallback read-only target — unconfirmed.) supabase migration listwill 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'sschema_migrationstracking 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 introducesupabase 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_idparameters in RPCs. - CLAUDE.md already documents the overall migrate story and the
get_brand_settingsgotcha. - Open question for next session: confirm Postgres host + connection layer (raw
pgvs Drizzle/Prisma) and start the actual cutover (drop@supabase/*deps, createsrc/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 existingcreate_order_with_itemsRPC 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/newredirect page for legacy links. - Success: toast + redirect to clean list (new order visible after refresh).
- New server action
- 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 insrc/components/admin/design-system/AdminFormElements.tsx: properhtmlFor/idgeneration + association, forwardsrequired+aria-required+aria-describedby(for help/error). - Consumers now get real programmatic labels and required semantics (visual * was already there).
- Enhanced
- 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. OnlyisSecretfields default to password + toggle.
- Advanced / AI settings routes now expose real (if lightweight) content:
/admin/advancedrenders 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 callgetAdminUser+can_manage_water_logguard + 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) orbrand_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 --noEmitandnpm run buildfor 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.mdfor 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
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)
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
getBillingOverviewserver action is the single source of truth; billing page + dashboard + invoice amounts + addon removable flags all derive from it. Migration203_plan_usage_active_products.sqlalignsget_brand_plan_infoproduct count with the dashboard. - Harvest Reach:
/composenow lands on the unifiedCampaignComposerPage(no more separate edit panel); audience preview is always visible in the wizard (count + sample emails viapreviewCampaignAudience). - Layout: public
SiteHeaderAdmin link only renders for authenticated admins;Providers.tsxsuppresses 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/2026strings replaced withnew 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:
- DYNAMIC_SERVER_USAGE on
/admin/settings/square-sync(and the whole admin tree):getAdminUser()readscookies()vianext/headers. The admin layout tried to prerender statically, so the first child page that hit cookies aborted the build. - Prerender ECONNREFUSED on
/indian-river-direct/stops:getPublicStopsForBrand/getActiveStopsForSitemap/getBrandSettingsPublicfetchNEXT_PUBLIC_SUPABASE_URLat build time. The Gitea runner passes a Supabase URL that resolves but is unreachable, sofetchthrowsECONNREFUSEDand 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— wrappedgetActiveStopsForSitemapandgetPublicStopsForBrandfetches intry/catchreturning[]on error. Env-var guard kept as fast path.src/actions/brand-settings.ts— wrappedgetBrandSettingsPublicfetch intry/catchreturning{ success: false }on error.src/app/admin/settings/square-sync/page.tsx— addedexport const dynamic = "force-dynamic";(was missing).src/app/admin/layout.tsx— addedexport const dynamic = "force-dynamic";so the entire admin tree opts out of static prerender (layout callsgetAdminUser()which reads cookies)..gitea/workflows/deploy.ymlwas simplified earlier in commit2d837bcto a thin wrapper arounddeploy/deploy.sh.
Remote
- The crispygoat repo (
git@git.crispygoat.com:tyler/route-commerce.git) and the GitHuboriginrepo are separate forks —tyler/mainis the self-hosted Auth.js + Postgres branch,origin/mainis the Supabase branch. Don't merge them; they share no deploy workflow. - Push targets
tyler/mainto trigger the Gitea build.
Build green — 2026-06-06
Push 32396af to origin/main triggered a successful Gitea deploy. Fixes that landed:
force-dynamiconsrc/app/admin/layout.tsx+src/app/admin/settings/square-sync/page.tsx- try/catch around Supabase REST fetches in
src/actions/stops.tsandsrc/actions/brand-settings.ts .gitea/workflows/deploy.ymlpaths updated todeploy/docker-compose.yml
Production prep — next steps
- Verify the stack is actually running. SSH to the deploy host,
docker compose -p prod-app psin$APP_DIR(/home/tyler/route-commerce). All services should behealthy. - 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. - Test app → PostgREST. Hit any public page that reads from PostgREST (e.g.
/indian-river-direct/stopsafter the revalidate window). If it returns stops, the chain works. - 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.
- Supabase → direct Postgres migration. The codebase still imports
@supabase/ssrand@supabase/supabase-jsinsrc/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: replacesupabase.from(...)calls withfetchtoNEXT_PUBLIC_API_URL/rest/v1/...or directpgqueries, then drop the@supabase/*deps. - Auth.js hardening.
AUTH_GOOGLE_ID/AUTH_GOOGLE_SECRETaren't in the secret list — the workflow falls back toBETTER_AUTH_*names which exist. Set the canonicalAUTH_*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 theauth()wrapper). Gates/admin/*and/login:- If
dev_session,rc_auth_uid, orrc_uidcookie is present →NextResponse.next(). - If no auth cookie, on
/admin/*, andALLOW_DEV_LOGIN !== "false"(on by default) → setdev_session=platform_admincookie andNextResponse.next(). Invisible auto-login. - If no auth and dev disabled → redirect to
/login. - If authenticated and on
/login→ redirect to/admin.
- If
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). DemoModecomponent 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— removedsignInWithDev. KeptsignInWithGoogleandsignOutAction.- Deleted
src/app/dev-login/page.tsxandsrc/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 setsdev_sessioncookie →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) — sharedpg.Poolsingleton. The single connection pool for the whole app. Extracted fromsrc/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. ThesignInevent now calls the newupsert_admin_user_for_authjsRPC 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.ymldoescat 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 TypeScriptAdminUsertype but not in any tracked migration (was likely dashboard-added).- SECURITY DEFINER RPC
upsert_admin_user_for_authjs(p_user_id UUID)that inserts aplatform_adminrow with allcan_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 betweendev_sessionandrc_auth_uid. Usesauth()from@/lib/authto decrypt the JWT cookie server-side, thengetAdminUserFromPool()queriesadmin_users+admin_user_brandsvia the shared pool. The legacyrc_auth_uidpath is unchanged (deferred — it still hits the dummy Supabase URL in prod). -
src/middleware.ts— recognizesauthjs.session-tokenand__Secure-authjs.session-tokencookies 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:
- Dev/demo: visit
/admin→ middleware auto-issuesdev_sessioncookie →getAdminUser()returns platform_admin. (No DB call.) - Production: click "Sign in with Google" → Auth.js OAuth →
signInevent fires →upsert_admin_user_for_authjscreates theadmin_usersrow → redirect to/admin→getAdminUser()reads JWT, queries pool viaauth.js.user.id, returns platform_admin.
What's still broken (out of scope for this push):
- Legacy
rc_auth_uidpath ingetAdminUser()still fetches from${NEXT_PUBLIC_SUPABASE_URL}/rest/v1/...which is a dummyhttp://localhost:54321in prod. Any pre-existing user with arc_auth_uidcookie 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
signInevent RPC call will fail silently ifDATABASE_URLis 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:
-
PGRST_DB_URInot 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). -
docker-compose.ymlhad a deadnextjsservice withenv_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 upvalidates the whole compose file and bailed.The
nextjsservice 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_initreferenced services that don't exist in the compose file. Postgres runs on the host (the migrations step usespsql -h 127.0.0.1), not in docker. Changed to justpostgrest. -
The
pg_isreadycheck wasdocker compose exec -T db pg_isready. Sincedbis a host service, changed toPGPASSWORD=... 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_URLfrom.env.production - MinIO: not yet wired up (the
MINIO_ROOT_USER/PASSWORDenv vars are written to.envbut no service consumes them yet — add aminioservice 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 filesnpx tsc --noEmit→ clean (0 errors)npm run test→ 22/22 passnpm 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
useMockDatacheck +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()settingapp.current_tenant_idGUC - Auth.js v5 (next-auth@5.0.0-beta.31) with Google OAuth + Credentials providers
src/lib/db.ts— sharedpg.Pool(server-only, lazy)db/client.ts—withDb,withTenant,withPlatformAdminDrizzle helperssrc/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 Drizzlesrc/lib/supabase/server.ts— DELETEDsrc/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_SECRETenv) - 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
emailcolumn toadmin_usersand provision Google users by email - Switch
getAdminUser()to directpglookup (not REST) - Remove the email/password (Supabase) provider when Supabase auth is fully cut over
- Remove
DEV_FORCE_UIDconstant and dead branches inactions/admin/users.ts
Pushed to remotes
crispygoat/main(the SSH remote at git.crispygoat.com) — primary targetorigin/main(GitHub) — for backup visibility- 36 commits ahead of
origin/mainas of this entry
2026-07: Smartsheet sync added to Water Log module
Per-brand Smartsheet integration that pushes new water_log_entries
rows to a configured sheet. Config UI lives in
/admin/water-log/settings as a self-contained card.
Key files added:
db/migrations/0093_water_smartsheet_sync.sql— 3 tables:water_smartsheet_config(one row per brand; encrypted token + sheet ID + column mapping + frequency + enable flag + last-sync metadata),water_smartsheet_sync_queue(one row per entry awaiting sync; status / attempts / backoff),water_smartsheet_sync_log(append-only Recent Activity feed for the UI). RLS via the existingcurrent_brand_id()/is_platform_admin()pattern.db/schema/water-log.ts— appended the three tables + types (SmartsheetFrequency,SmartsheetColumnKey,SmartsheetColumnMapping). Uses a small custombyteaDrizzle type sincedrizzle-orm/pg-coredoesn't export one in this version.src/lib/crypto.ts— AES-256-GCM helpers (encryptToken,decryptToken,maskToken). ReadsSMARTSHEET_TOKEN_ENC_KEY. Throws on missing/wrong-size key — no insecure default.src/lib/smartsheet.ts— REST API wrapper (no SDK —smartsheetnpm has Node 22 compat issues). ExposesextractSheetId,getSheetMeta,addRow,findRowByColumn, and a typedSmartsheetApiErrorclass.src/services/smartsheet-sync.ts— pure sync engine:syncEntryToSmartsheet,drainSyncQueue,runScheduledSync. Sequential per-row processing to stay well under Smartsheet's 300 req/min cap. Exponential backoff capped at 60 min, max 5 attempts before the row staysfailedpermanently.src/actions/water-log/smartsheet.ts—"use server"actions:getSmartsheetConfig,saveSmartsheetConfig,deleteSmartsheetConfig,testSmartsheetConnection,triggerSyncForEntry,listSmartsheetSyncLog. Token never returned in plaintext — UI getsmaskedTokenonly.src/components/admin/water-log/SmartsheetIntegrationCard.tsx— client component usinguseReducer. Sections: Enable toggle, Sheet URL/ID + API token + Test Connection, sync frequency radio group, column mapping dropdowns (populated after Test), Recent Activity panel.src/app/api/water-log/smartsheet-sync/route.ts— POST cron route. Bearer-auth viaSMARTSHEET_CRON_SECRET(falls back toCRON_SECRET). Optional{brandId}body to drain one brand.vercel.json— added*/15 * * * *cron entry pointing at the route above. Single entry covers both 15-min and hourly frequencies (route filters by per-brandsync_frequency).
Hooks into existing code:
src/actions/water-log/field.ts::submitWaterEntry— callstriggerSyncForEntry(brandId, entryId)in a fire-and-forget void after the entry insert. Sync failures NEVER bubble to the field worker.src/app/admin/water-log/settings/page.tsx— mounted the new card as a new "§ 05 — Integrations" section after the existing admin- PIN settings. Card takesbrandIdas a prop (CLAUDE.md "Brand ID Threading").
Env vars (.env.example):
SMARTSHEET_TOKEN_ENC_KEY— REQUIRED, 32 random bytes base64. Generate:openssl rand -base64 32.SMARTSHEET_CRON_SECRET— optional bearer for the cron route.SMARTSHEET_SYNC_TIMEOUT_MS— optional, default 8000.
Migration push (Vercel deploy + manual):
npm run migrate:one 93 # pushes 0093_water_smartsheet_sync.sql
Then provision the encryption key in Vercel dashboard:
SMARTSHEET_TOKEN_ENC_KEY (paste the openssl rand -base64 32 output).
Rollback story:
- Remove the cron entry from
vercel.json. - SQL rollback:
DROP TABLE water_smartsheet_sync_log;DROP TABLE water_smartsheet_sync_queue;DROP TABLE water_smartsheet_config; - Revert the changes to
field.tsandsettings/page.tsx. git revertthe merge commit.
Known sharp edges:
- No key rotation support — changing
SMARTSHEET_TOKEN_ENC_KEYmakes existing tokens unreadable; admins must re-enter. Document if/when Phase 2 adds akey_versioncolumn. - Share-URL Smartsheet sheets often have opaque slugs (not numeric);
if "Test Connection" 404s with a slug URL, ask the brand admin for
the numeric sheet ID from
File → Propertiesin Smartsheet. findRowByColumnis O(n) over up to 2,500 rows; fine for typical water-log sheets but switches to search-API dependency if any brand ever exceeds ~10k entries/year.