Two bugs were silently breaking every Smartsheet sync since launch:
1. The /api/water-log/smartsheet-sync route was never registered in
vercel.json's crons array. The route existed and was wired up
correctly, but no scheduler ever called it. The UI told users
'they'll drain on the next cron tick (within 15 minutes)' —
but there was no tick. Queue rows accumulated indefinitely.
Fix: register the route with schedule '*/15 * * * *'.
2. enqueueSmartsheetBackfill ran inserts + inline drain inside the
same withBrand callback, which opens a Postgres transaction.
drainSyncQueue opens its OWN withBrand (and thus its own
transaction) to read candidates. Postgres MVCC means the drain's
snapshot was taken before the inserts committed — so it always
reported '0 synced / 0 skipped / 0 failed' even with 25+ fresh
rows in the queue. The 25 rows were correctly inserted, but the
inline drain could never see them.
Fix: split the backfill into three explicit phases, each in its
own transaction:
- Phase 1: validate + query + insert queue rows (commits)
- Phase 2: inline drain in a FRESH transaction (sees phase 1)
- Phase 3: audit log (logAuditEvent opens its own withBrand)
The phase 1 callback returns a discriminated union so config-
missing / sync-disabled / empty-result paths still short-circuit
cleanly without ever touching phase 2.
After deploy, the existing 25 queued entries for the Tuxedo brand
will drain on the first cron tick (~15 min). To drain immediately
without waiting:
curl -H 'Authorization: Bearer $SMARTSHEET_CRON_SECRET' \
-X POST http://crispygoat.com:3100/api/water-log/smartsheet-sync
- New .gitea/workflows/smartsheet-cron.yml: hits POST /api/water-log/smartsheet-sync
every 15min with SMARTSHEET_CRON_SECRET bearer. Replaces the no-op
vercel.json cron entry (ignored on self-hosted Gitea).
- deploy.yml now writes SMARTSHEET_TOKEN_ENC_KEY and SMARTSHEET_CRON_SECRET
to the runtime .env on every deploy so we don't silently lose them.
- Dropped the unused cron entry from vercel.json.
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).
- Delete supabase/ directory (config.toml, push-migrations.js,
ADMIN_CREATE_STOP_FIX.sql, 137 archived migration files)
- Remove @supabase/ssr and @supabase/supabase-js from package.json
- Strip *.supabase.co from next.config.ts image hostnames
- Strip https://*.supabase.co from vercel.json CSP connect-src
- Remove 'supabase/**' ignore from eslint.config.mjs
- Clean supabase references from src/lib/db.ts, vitest.config.ts,
db/seeds/2026-tuxedo-tour-stops.sql, src/actions/storefront.ts,
scripts/import-tuxedo-stops.ts comments
- Rewrite env-var docs in README, ENVIRONMENT, PRODUCTION_SETUP,
PRODUCTION_DEPLOYMENT_CHECKLIST, LAUNCH_CHECKLIST, CLAUDE,
MEMORY, REPORT to drop NEXT_PUBLIC_SUPABASE_URL /
SUPABASE_SERVICE_ROLE_KEY / supabase link / supabase CLI references
The canonical migration runner is now scripts/migrate.js (uses pg
directly via DATABASE_URL). Migrations live in db/migrations/. The
Supabase CLI is no longer in the codebase. The only remaining
'@supabase/*' in the dep tree is @supabase/auth-js as a transitive
of @neondatabase/auth (Neon Auth / Better Auth).
Final source scan: grep -rln '@supabase\|rest/v1\|supabase\.co'
src/ tests/ db/ scripts/ next.config.ts vercel.json eslint.config.mjs
package.json returns zero. Verification: npm install clean
(11 added, 21 removed, 12 changed), tsc shows same 17 pre-existing
errors (Stripe dahlia API version + fetch preconnect mocks),
vitest 172/175 (3 pre-existing failures in getAdminUser.test.ts),
lint shows same 14 pre-existing errors. Live-tested: dev server
boots in 248ms, public storefronts (/) (/login) (/pricing) (/tuxedo)
(/indian-river-direct) all return 200; /admin/v2?demo=1 reaches 200
after dev_session redirect; storefront renders real brand content.