Mirrors the water-log Smartsheet pattern (migration 0093 +
src/services/smartsheet-sync.ts). End-to-end wiring is in place;
the brand fills in sheet ID + token + column mapping whenever
ready.
DB:
- 0095_time_tracking_smartsheet_sync.sql — config + sync_queue +
sync_log tables with brand-scoped RLS. Unique constraint on
(brand_id, log_id) keeps the queue idempotent against retries.
Code:
- db/schema/time-tracking.ts — adds TTSmartsheetFrequency,
TT_SMARTSHEET_FREQUENCIES, TTSmartsheetColumnKey,
TTSmartsheetColumnMapping, and the three pgTable defs.
- src/services/time-tracking-smartsheet-sync.ts — syncLogToSmartsheet
(dedup-by-log_id, token-echo sanitization, retry-with-backoff),
drainTTSyncQueue, runScheduledTTSync.
- src/actions/time-tracking/smartsheet.ts — full admin CRUD
(getTTSmartsheetConfig, saveTTSmartsheetConfig,
testTTSmartsheetConnection, getTTSmartsheetRecent,
getTTSmartsheetQueueSummary, disableTTSmartsheet).
- src/actions/time-tracking/field.ts — clockOutWorker calls the new
enqueueClockOutForSync helper in its OWN transaction (so a queue-
insert failure can never poison the clock-out commit; reviewer
caught this in cycle 2's same-style bug pattern).
- src/components/admin/time-tracking/TimeTrackingSmartsheetCard.tsx —
slim, focused card: sheet ID + token + frequency + enable +
Test Connection → column-mapping dropdowns + Recent Activity table.
- src/app/admin/water-log/settings/page.tsx — §06 section renders
the new card as a sibling of the existing water-log smartsheet
card.
Permission gate uses can_manage_settings + platform_admin (not
can_manage_water_log) — feature is a brand-level integration
setting, not water-log-specific.
Out of scope / deferred:
- /api/time-tracking/smartsheet-sync cron route. The trigger is
optional: realtime mode syncs inline; 15-min/hourly modes need the
cron. Add in a follow-up cycle when the cron infrastructure is
revisited.
- drag-and-drop column-mapping UX (current card uses plain
dropdowns).
- backfill flow (no equivalent of the water-log "backfill past N
days" feature yet).
- can_manage_time_tracking permission flag (uses
can_manage_settings as the proxy; revisit once the admin_users
schema gets a time-tracking-specific permission column).
Replaces the in-progress stub (RPC + brandId params) with Drizzle + the
shared SECURITY DEFINER wrappers in db/client.
- field.ts: cookie session, scrypt PIN, clock-in/out, getOpenClockIn,
getWorkerPayPeriodHours. Cookie payload now 7 fields; parseSessionCookie
re-resolves name/role/lang/brand_id from DB on every read so a stale or
tampered cookie cannot impersonate a different brand.
- index.ts: admin CRUD for workers / tasks / settings / logs / notifications
+ getWorkerPeriodTotals. All admin actions drop the brandId param; the
helpers pull it from the admin session via getAdminUser().
- notifications.ts: overtime check inserts with status='pending' (not
optimistic 'sent') since email/SMS dispatch is out of scope until the
notification_targets columns land in a follow-up migration.
- export/route: cast fix for the changed TimeLog shape.
DB:
- 0094 partial unique index on time_tracking_logs (worker_id)
WHERE clock_out IS NULL — enforces one open clock-in per worker
against concurrent / retried requests (READ COMMITTED cannot).
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).
The Drizzle schema in db/schema/water-log.ts declares
`water_admin_settings.pin_hash TEXT` for the hashed admin PIN, but
the table was originally created in 0090_water_log_completion.sql
WITHOUT that column. The earlier 0004 migration was a no-op against
prod (table already existed from 0090) and so did not add it either.
Effect: every Drizzle `select()` / `insert()` against the table
throws 'column pin_hash does not exist', which:
- /api/water-admin-auth catches and returns 500 'Server error'
- /admin/water-log/settings never finishes loading (the action's
promise rejects inside withBrand, so LOAD_DONE is never
dispatched, and the page sits on the Loading… state)
Migration adds the column with IF NOT EXISTS so it's safe to re-run
and won't error on a brand whose row already has a hash.
The water-log admin actions (saveWaterAdminSettings, verifyWaterAdminPin,
regenerateAdminPin) and /api/water-admin-auth route were all wired up
against the Drizzle schema in db/schema/water-log.ts, but the three
underlying tables never had a migration applied:
- water_admin_settings
- water_admin_sessions
- water_audit_log
Hitting any of these surfaces threw 'relation does not exist' from pg,
which /api/water-admin-auth surfaced as a 500 'Server error' to the
field. Adding /admin/water-log/settings → save also failed silently
with a server error.
This migration creates the three tables with columns matching the
Drizzle schema (PKs, defaults, FKs to brands + admin_users) and wires
up RLS + tenant_isolation policies in the same shape as 0001_init.sql.
The deploy workflow (scripts/migrate.js) will pick this file up
automatically on the next push to main.
Two related bugs caused the 'New Template' modal on
/admin/communications to fail with a database error:
1. The `email_templates` table was defined in `db/schema/marketing.ts`
(Drizzle) but never migrated, so the modal's `upsertTemplate`
server action errored with 'relation "email_templates" does not
exist'. Adds migration 0092 to create the table (and `campaigns`)
to match the Drizzle schema. Uses CREATE TABLE IF NOT EXISTS,
gen_random_uuid() PKs, and the standard set_updated_at trigger
pattern from 0001_init.sql.
2. `/admin/communications/page.tsx` resolved `effectiveBrandId`
with a hard-coded UUID ('64294306-...') for sessions without a
brand (e.g., dev platform_admin). That UUID isn't in the local
dev DB (brands are '11111111-...' and '22222222-...'), so the
modal would FK-violate on insert with 'violates foreign key
constraint email_templates_brand_id_fkey'. Replaced with a
`listBrandsForAdmin()` lookup that picks the first accessible
brand.
3. `listBrandsForAdmin()` (`src/actions/brands.ts`) was SELECTing
`logo_url` from `brands`, but that column doesn't exist (the
brands table only has id/name/slug/plan_tier/limits/stripe_*/...).
The query threw 'column "logo_url" does not exist', got caught
by the silent try/catch, returned [], which made #2 above render
the page with brandId="" — and any submit would then error with
'invalid input syntax for type uuid: ""'. Removed logo_url
from the SELECT; BrandListItem.logo_url is always null until a
future migration adds the column.
E2E verified: opening /admin/communications → Templates tab → New
Template → fill name → Create Template inserts a row in
email_templates with the correct brand_id.
The 0002_admin_password.sql migration referenced a 'users' table that
no longer exists — auth moved from Auth.js Credentials to Neon Auth
(Better Auth), which owns its own neon_auth.user schema.
The migration was being recorded in _migrations to skip, but a fresh DB
or new environment would fail with 'relation users does not exist'.
scripts/migrate.js already had an ensureTracked() repair path that
auto-marks 0002 applied when the users.password_hash column exists,
so legacy DBs still work. For fresh DBs, this rewrite replaces the
broken ALTER with a no-op SELECT 1, preserving the 0002 slot so
0003_*.sql onward numbering is unaffected.
Also add .gitignore entries for local-only QA artifacts:
- db/migrations/0000_qa_*.sql (Neon Auth stub for local audit)
- db/seeds/2026-qa-*.sql (production-scale audit seed)
- docs/qa/ (local audit plan/inventory/bugs)
These files should never apply to production (the migration stub
would conflict with real neon_auth.user; the seed is 1000s of rows).
Tracking progress in /tmp/refactor-routecomm.md.
The create-user action in src/actions/admin/users.ts was written
against a richer admin_users schema than the one that landed in
0001_init.sql. The 0001 schema has just `name` plus the role-derived
can_manage_<X> columns; the action also references display_name,
phone_number, brand_id, can_manage_pickup/messages/refunds/users,
active, must_change_password, auth_provider, auth_subject, and
last_login. Result: 'column "display_name" of relation
"admin_users" does not exist' on any create-user submit.
Migration 0043 adds the missing columns with sensible defaults
(ADD COLUMN IF NOT EXISTS, so re-runnable). The four extra
can_manage_* flags are vestigial — getAdminUser() in
lib/admin-permissions.ts derives per-user permissions from the role
via permissionsForRole() and never reads them — but they exist so
the create-user form's per-user toggles persist, and the migration
header documents the cleanup target.
The db/schema/brands.ts Drizzle adminUsers definition is extended
in lockstep so the inferred TS types stay in sync with the table.
brand_id is left as a denormalized column (the link table
admin_user_brands is the source of truth and is what
getAdminUser() reads); getAdminUsers() in the action joins on
au.brand_id so keeping the column avoids rewriting the SELECT.
Gitea CI runs scripts/migrate.js before the build, so this lands
on prod automatically on the next push.
The /admin/command-center route was platform_admin-only, used a heavy
custom theme (Major Mono / JetBrains / Inter Tight + ~700 lines of
schematic CSS), called three RPCs, and overlapped with the per-brand
dashboards reachable at /admin, /admin/orders, /admin/stops, and
/admin/reports. The page is being removed; it didn't justify the
build complexity, font payload, or schema surface area.
Changes:
- Drop page, dashboard component, CSS, and server actions
- Remove the platform_admin nav entry from header + sidebar
- Remove the cmd+K palette entry
- Add migration 0042 to drop the 3 RPCs, founder_pain_log table,
and founder_pain_log_platform view it owned
- Decrement route count in docs/pricing-assessment.md (88 -> 87)
Verification: tsc clean, eslint clean, npm run build clean, command-
center absent from the route list.
Migration 0040 was written against an assumed schema and referenced
columns that don't exist in the prod orders table:
- orders.created_at → use orders.placed_at
- orders.subtotal → use orders.total_cents / 100
- stops.date regex → column is already DATE, not TEXT, drop the
~ '^\d{4}-...$' check
Migration 0041 fixes both broken functions in place via
CREATE OR REPLACE FUNCTION.
The action layer at src/actions/platform/command-center.ts used
SELECT fn() AS "fn" for all platform RPCs, but two of the three
return TABLE(...) (setof) — the correct syntax is SELECT * FROM fn().
The platformRPC<T> helper now takes a kind: 'scalar' | 'setof' flag
and emits the right SQL.
Verified on prod:
METRICS: { orders_today:0, active_brands:1, active_routes:538, ... }
BRAND_HEALTH: 1 row (Tuxedo Corn), 538 active_stops, 0 failed, healthy schema
ACTIVITY: 0 rows (operational_events has no brand_id in payload, no error)
The /admin/command-center page calls 3 RPCs and a table that only existed in
the archived Supabase migrations (126/127). The active db/migrations/ directory
had no replacement, so prod was missing these objects and the page threw
'Connection Lost' on every load (fetchAll() Promise.all rejects on missing RPCs).
Un-archives the original SQL into a single new migration (0040_command_center.sql)
with idempotent CREATE OR NOT EXISTS / CREATE OR REPLACE so it's safe to apply
to DBs that may already have some of the objects.
- 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
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.
- 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
- Migration 0002 adds nullable password_hash to users (idempotent)
- src/lib/passwords.ts: encode/verify with self-describing format
(algo$N$salt$hash) so we can migrate to a stronger KDF later
- Schema adds passwordHash column; OAuth-only users leave it null