Replaces two independent Smartsheet configs (water log + time tracking)
with one brand-level workbook connection that owns the encrypted API
token. Per-feature configs keep their sheet_id + column_mapping +
frequency + sync_enabled.
Schema (migration 0096):
- NEW smartsheet_workspace: one row per brand. Holds encrypted_api_token
+ token_iv + token_auth_tag (AES-256-GCM, same key), default_sheet_id,
connection_enabled (master switch), last_test_*, audit columns. RLS
brand-scoped.
- Backfill: water_smartsheet_config rows copy into workspace first;
time_tracking_smartsheet_config fills in only brands with no workspace
row yet. Idempotent (NOT EXISTS + ON CONFLICT DO NOTHING).
- DROP encrypted_api_token + token_iv + token_auth_tag from both feature
configs (destructive — bytes are copied to workspace first).
- Drop unused bytea customType from both schemas (no longer needed).
Actions:
- NEW src/actions/smartsheet/workspace.ts: getSmartsheetWorkspace,
saveSmartsheetWorkspace, testSmartsheetWorkspaceConnection,
disableSmartsheetWorkspace. Permission: can_manage_settings +
platform_admin.
- NEW src/services/workspace-token.ts (server-only): resolveWorkspaceToken
helper. Lives outside the 'use server' file so the plaintext token
is not exposed as an RPC surface — only callable from server-side
sync engines.
- MODIFIED water-log + time-tracking smartsheet actions: refactored to
read token via resolveWorkspaceToken; save* no longer accepts a
token; test* falls through to the workspace token if no token given.
- MODIFIED both sync services: load token from resolveWorkspaceToken
instead of decrypting the per-feature config. 'connection_disabled'
now skips cleanly (no retry, no failed log row) — pausing the
workspace at the hub level no longer floods Recent Activity with
'disabled' failures.
UI:
- NEW src/components/admin/water-log/SmartsheetHub.tsx: top-level hub
card. Owns the token + Test Connection + connection enabled toggle +
default sheet ID + last-verified badge. Permission gated.
- MODIFIED SmartsheetIntegrationCard.tsx (water): rewritten without
token UI. Reads masked token from workspace. Sheet ID + column
mapping + frequency + enabled + backfill + recent activity.
- MODIFIED TimeTrackingSmartsheetCard.tsx: same treatment.
- MODIFIED /admin/water-log/settings/page: collapses §05 + §06 into a
single Smartsheet section with the hub on top + both feature cards
as siblings underneath.
Tuxedo-only throughout (TUXEDO_BRAND_ID hardcoded in the settings
page; matches existing single-tenant /water convention).
Security fixes from pr-reviewer:
- resolveWorkspaceToken extracted from 'use server' file to a
server-only service module — was reachable as an RPC, would have
returned the plaintext token to any authenticated admin.
- getSmartsheetWorkspace now actually checks the auth result instead
of calling requireManageSettings() and discarding the return value.
Diff: -1742 / +597 lines (net -1100 from the simpler per-feature card).
Same-origin sub-PWA for Tuxedo field workers, fully isolated from the
main Route Commerce PWA.
- public/manifest-field.json: Tuxedo-branded manifest, start_url=/water,
scope=/water, themeColor=#14532d, no Route Commerce branding
- public/sw-field.js: scoped SW with field-shell-v1 + field-data-v1
caches (never touches main app's rc-shell-v* / rc-data-v*). Skips
any request outside /water scope. Two-cache strategy: shell
cache-first + /api/water stale-while-revalidate.
- src/app/water/layout.tsx: child layout overrides root metadata
(manifest, themeColor, applicationName, appleWebApp.title) and
mounts the field SW registration + install prompt.
- src/components/pwa/FieldSWRegistration.tsx: registers /sw-field.js
with scope=/water. Module-scoped registered flag prevents strict-
mode double-registration.
- src/components/pwa/FieldInstallPrompt.tsx: sibling of InstallPrompt,
forest-green #1a4d2e theming, fires after 4s dwell. Captures
display-mode:standalone AND navigator.standalone for iOS.
No conflict with the main app PWA: PWAInstallPrompt is mounted only
under /admin/layout.tsx, so /water routes only see FieldInstallPrompt.
Tuxedo-only by design (matches existing /water single-tenant hardcode).
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).
The mobile `/water` worker experience (Tuxedo-only, matching the
existing water flow) now exposes a "Time" tab alongside "Headgates"
so irrigators can clock in/out from the same phone they use for
water entries — no second PIN.
- MobileWaterApp: new `activeTab` state, sticky TabBar rendered only
after PIN, screen='time' case embeds <TimeTrackingFieldClient>
with hardcoded Tuxedo brand constants.
- handlePinSubmit best-effort calls `verifyTimeTrackingPin` after
the water PIN succeeds, so the Time tab never re-prompts a worker
who is also set up in `time_tracking_workers`.
- handleLogout clears BOTH `wl_session` and `time_tracking_session`
cookies in independent try/catch blocks.
- Tab switching to Time drops in-flight water flow state; switching
back to Headgates restores the list without reload.
- i18n: added `tab.{headgates,time,logout}` strings (en + es); lifted
Tuxedo brand display constants into @/lib/water-log/brand so the
standalone /tuxedo/time-clock page and the embedded Time tab share
one source of truth.
The water-log admin panel at /admin/water-log becomes a one-stop hub for
the customer: today's entries, the headgates, who can log them, AND how
many hours those workers put in.
- New TimeTrackingTab — thin wrapper that renders the existing
TimeTrackingAdminPanel (which already owns its own Summary / Workers
/ Tasks / Logs / Settings sub-tabs).
- Shell.tsx now exposes Today | Headgates | Users | Time Tracking; the
new branch just passes brandId through.
- TabValue union and TABS[] updated to "time-tracking".
This is the customer-facing surface the user asked for. Cycle 4 adds
the same tab to the mobile worker portal; Cycle 5 scaffolds the
Smartsheet sync (config-deferred).
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).
The Water Log admin panel was the largest single file in the app (2117 lines,
up from the backlog's 1393 estimate) and mixed 12+ distinct UI concerns:
tab shell, reducer, three section components, a modal, ~12 sub-components,
and 8 inline icons.
Split into focused files under src/components/admin/water-log/:
Shell.tsx tabbed shell, reducer wiring, derived data
reducer.ts reducer + initState + selectors
types.ts shared types (State, Action, Toast, …)
tabs/TodayTab.tsx hero summary + recent entries + report settings
tabs/HeadgatesTab.tsx headgate cards + CRUD
tabs/UsersTab.tsx user list + CRUD
shared/Primitives.tsx SectionHeader, StatTile, StatusPill, PinBanner,
RoleLegend, EmptyState, Input, Select, FilterChip,
Avatar, MonthSelect, DayInput, Th, Td
shared/HeaderNav.tsx top-of-page quick links
shared/Toast.tsx toast notification
shared/ReportPreviewModal.tsx daily-report preview modal
shared/icons.tsx 9 inline icons (incl. new ClockIcon reserved
for the Time Tracking tab in Cycle 3)
Tabs wired in this commit: today (default), headgates, users.
Time Tracking lands in Cycle 3.
Bugs caught by pr-reviewer subagent before commit:
- Color drift: text-[#1d1d1f] was silently rewritten to text-[#1d1d1d] in
16 places during mechanical extraction. sed restored all 16.
- Subtitle template placeholder: monolith had literal '{n}' in the
Recent Entries subtitle. New copy: 'The latest readings…'.
- localStorage coupling: first pass put setItem('wl_season_settings:v1')
inside TodayTab.onSeasonChange. Lifted to Shell.persistSeason so the
storage contract is owned by the shell, not scattered through tabs.
Verified:
- npx tsc --noEmit clean
- npx eslint src/components/admin/water-log/ 0 errors, 0 warnings
- npm run lint full project: 388 → 378 warnings (-10 unused imports);
14 pre-existing errors unchanged
- npm run build succeeds
- curl localhost:4000/admin/water-log HTTP 307 to /login (admin guard
fires; route registered)
Refactor-progress tracked at /tmp/refactor-routecomm.md (Phase 2,
Cycle 1 logged with outcome + 3 fix notes). Next: Cycle 2 — bring the
stubbed time-tracking actions back to life against Drizzle.
The script kept failing on deploy (ESM/CJS mismatch). User
opted to drop the auto-fix. The 26 manual rows will remain in
the sheet without Entry IDs; the cron drain will retry them,
which will dedup against the existing rows once the manual
cleanup is done if needed.
26 water-log rows were POSTed directly to Smartsheet on 2026-07-03 because
the cron drain was failing. Those rows went in without Entry IDs, so the
next cron drain would have created duplicates (findRowByColumn returns
nothing when Entry ID is empty).
This script:
1. Matches each of the 26 hardcoded rows to a DB entry by
(headgate + irrigator + measurement + unit + logged_at).
2. Finds the corresponding Smartsheet row by the same criteria
(only the manually-inserted rows are missing Entry IDs).
3. PUTs the Entry ID back into the sheet row.
4. Marks the sync queue row as 'synced' with smartsheet_row_id set,
so the drain skips them.
5. Writes a sync log entry noting the manual backfill.
Idempotent: re-runs are no-ops. Wired into deploy.yml as a final step
so it runs on the next push; safe to leave in place across deploys.
Local invocation:
npm run smartsheet:backfill-entry-ids
Smartsheet's POST /sheets/{id}/rows expects the body to be a top-level
JSON array (or a single Row object), NOT wrapped in { rows: [...] }.
When we wrapped the body, Smartsheet returned:
- HTTP 200 OK
- resultCode: 0
- message: 'SUCCESS'
…and silently dropped every cell value, creating an empty row. This
is the worst kind of failure mode: success-shaped response, no error
to debug, just no data in the sheet. (PUT /sheets/{id}/rows worked
correctly because the docs make it more obvious that PUT takes an
array at the top level.)
Root cause confirmed by direct API probe:
- Body: { rows: [{ cells: [...] }] } → 200, no values persisted
- Body: [{ cells: [...] }] → 200, values persisted
Fix: send the array directly as the request body.
After deploy, retries against the 25 queued entries will:
1. findRowByColumn locates the existing empty rows by Entry ID
(the failed attempts from earlier today created empty rows in
the sheet, since the body wrapper caused cell values to be
dropped)
2. No duplicate insert; the existing empty row is reused
3. Queue marked as synced with smartsheetRowId
The user should also manually delete the empty orphan rows from
their sheet (rows 27-38 from the failed syncs + my probes).
Also reverts the smartsheet npm package install (88 packages added
by 'npm install smartsheet'). The REST fix is one line; the SDK's
deps weren't worth keeping.
Smartsheet's POST /sheets/{id}/rows response shape varies by request size:
- Single-row request → result: { id, rowNumber, ... } (object)
- Multi-row request → result: [{ id, rowNumber, ... }] (array)
We always post exactly one row, so the actual response is the object
form. The previous fix (b061cdd) assumed the array form, so
data.result[0] was undefined for object responses, and addRow threw
a 502 'returned no row' AFTER Smartsheet had successfully created
the row. The queue then recorded a failed sync attempt even though
the data was sitting in the sheet.
User saw this in Recent Activity: 10 sequential retries with
'Smartsheet 502: returned no row' errors but incrementing rowNumber
in the response body (21 → 30), proving the rows were being created
on Smartsheet's side and our parser was the only thing failing.
Fix: check Array.isArray(result) and use result[0] for the array form,
or result directly for the object form. Belt-and-suspenders for both
shapes since Smartsheet's docs are ambiguous about the single-row case.
Side note: the failed attempts did create rows on Smartsheet. Once
the fix deploys, retries will hit findRowByColumn first, which will
locate those existing rows by Entry ID and skip the duplicate insert.
Net result: 25 unique rows in the sheet, queue marked synced.
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
The Smartsheet API uses TWO different response shapes:
- GET /2.0/sheets/{id} → flat: { id, name, columns, ... }
- POST /2.0/sheets/{id}/rows → wrapped: { result: [{id, rowNumber}], resultCode, ... }
Our code assumed both were wrapped in a 'result' object, which meant:
- getSheetMeta read data.result.id → undefined on success.
The defensive guard in 71e1792 caught it and returned 502 'missing
result.id' instead of crashing — but no sheet has ever tested
successfully, so the bug was invisible until a real token was
wired up.
- addRow read data.result.id → undefined, returning rowId='undefined'
and rowNumber=undefined silently. Sync has never worked; any row
insert would have created a row with bogus metadata in the queue.
Fixes:
- getSheetMeta now reads data.id / data.name / data.columns at the
top level. Defensive guard checks for data?.id == null instead
of data?.result?.id == null.
- addRow now reads data.result[0].id / .rowNumber (array shape) and
throws SmartsheetApiError(502) if the array is empty/missing.
Verified end-to-end with the user's token against sheet 782660409446276
(Water Logs, 8 columns: Entry ID, Logged At, Headgate, Measurement, Unit,
Irrigator, Notes). All column titles match our Water Log field names
exactly, so the column-mapping dropdowns will auto-pair cleanly.
When an admin pastes a Smartsheet share-link URL (or its bare slug) into
the Water Log Smartsheet config, the REST API returns a 2xx with a body
that lacks the 'result' envelope. getSheetMeta then crashed with:
Cannot read properties of undefined (reading 'id')
The error bubbled up through testSmartsheetConnection and surfaced in the
Test Connection UI as a generic TypeError.
Two-part fix in src/lib/smartsheet.ts:
1. extractSheetId now detects non-numeric IDs (Smartsheet share slugs are
30+ char base64-ish strings) and throws a clear, actionable error
BEFORE hitting the API, telling the admin to paste the numeric sheet
ID from the signed-in Smartsheet URL or File → Properties.
2. getSheetMeta has a defensive guard (if (!data?.result || data.result.id
== null)) that throws a SmartsheetApiError(502, ...) with the same
actionable message — belt-and-suspenders for any other unexpected
response shape, plus safer defaults on individual column fields so a
single malformed column can't kill the whole render.
User's failing input was:
https://app.smartsheet.com/sheets/V4XgwvVJqFjcxQmQMrgwm77WjCP7hpjqqW82RRR1
Their real numeric sheet ID is 782660409446276 (now accepted).
The Smartsheet card has six stacked sections (Enable, Connection,
Sync frequency, Column mapping, Backfill, Recent activity) — at
1100px viewport that's a long scroll. Add a sticky 'On this page'
pill nav at the top of the card that highlights the active section
as the user scrolls, with anchor jump on click.
- Each <Card> gets a stable id (smartsheet-enable, -connection,
-frequency, -mapping, -backfill, -activity) and scroll-mt-20 so
anchor jumps clear the sticky nav.
- IntersectionObserver (rootMargin 0 0 -80% 0) tracks which section
is in the top 20% of the viewport and updates activeSection state.
- Pill click uses native scrollIntoView, which honors the global
scroll-behavior: smooth on <html> (auto-flipped to instant under
prefers-reduced-motion by globals.css — no JS matchMedia needed).
- URL hash updates via history.replaceState so the destination is
shareable without triggering a second jump.
- Apple HIG polish: rounded-full pills, focus-visible ring,
active:scale-[0.96], motion-safe: spring transitions, bg-[#fdfaf2]/92
with backdrop-blur for the sticky chrome.
- All changes additive — no existing Card/ToggleRow behavior modified.
Verified: npx tsc --noEmit clean, npm run build clean,
npm run lint on this file clean.
Layer HIG polish on top of the Field Almanac visual identity — no
layout change, just quality.
Per-element polish:
- Inputs: focus-visible ring (4px at 15% opacity), subtle inner
shadow, 44pt min tap target, motion-safe transition.
- Selects: same focus ring + min height + transition.
- Toggles: Apple spring curve (cubic-bezier 0.32,0.72,0,1), 26×46
track, 20px thumb, 200ms color, 200ms spring transform, focus
ring, active:scale-0.97.
- Buttons: focus-visible ring, active:scale-0.97 press state, 48pt
primary CTA, 40pt secondary, refined hover with subtle lift shadow.
- Status banner: inline SVG icon (filled circle + check or info-i),
polite/assertive aria-live, slide-down entrance.
- Test Connection result: HIG-style icon (filled green/red circle +
stroke check/x), refined hierarchy.
- Backfill result + error: same HIG pattern as status banner.
- Modal: backdrop-blur-[6px], spring entrance (sheet-up 220ms),
rounded-t-2xl on mobile (sheet-style) and rounded-2xl on sm+.
- All animations: motion-safe: + a prefers-reduced-motion block
in globals.css that disables them.
Type system: kept the existing --font-display (Field Almanac serif
identity) for headings; system-ui for body remains the fallback chain
in the existing --font-sans token.
New keyframes in globals.css: smartsheet-fade-in,
smartsheet-sheet-up, smartsheet-slide-down. All short (180–220ms)
to feel responsive.
Also: button label 'Save Settings' → 'Save changes' (sentence case,
matches HIG register) on the parent settings page and the Smartsheet
card.
Adds a backfill button to the Smartsheet Integration card so brand admins
can push existing water log entries (all time / last 30d / last 7d) to
their Smartsheet sheet — important for customers who configure
Smartsheet after months/years of recording entries.
- New enqueueSmartsheetBackfill(brandId, {since?}) server action:
- Counts candidates, enqueues in 500-row chunks with
onConflictDoNothing (UNIQUE on brand_id+entry_id absorbs dups).
- Runs one inline drain pass (batchSize=200) for immediate feedback.
- Cron picks up remaining queue rows across the next ticks.
- Requires active Smartsheet config and admin permission; logs an
audit event for the backfill.
- New "Backfill historical entries" card in SmartsheetIntegrationCard
with range selector, confirmation prompt, and inline result feedback
(counts of newly queued / already queued / drained synced / skipped /
failed / remaining).
- Sync engine dedup by Entry ID + UNIQUE constraint means re-runs are
safe: re-running never creates duplicate Smartsheet rows.
Without --update-env, PM2 caches the original env vars at first start
and silently ignores newly added ones — including SMARTSHEET_TOKEN_ENC_KEY
and SMARTSHEET_CRON_SECRET, which were getting written to .env but never
reaching the running Next.js process.
- 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.
Adds a 'Setup instructions' button to the Connection card that opens
a modal with full onboarding guidance:
- Prerequisites (Smartsheet account, admin access)
- Step 1: Generate an API token (with link to Smartsheet docs)
- Step 2: Sheet structure — full schema table mapping our 7 fields
to Smartsheet column types (TEXT_NUMBER / DATETIME / DATE), with
required vs optional flags and example values
- Collapsible 'starter header row' CSV snippet users can paste into
row 1 of a new sheet
- Step 3: Connect flow (paste URL + token, test, map columns, save)
- Common issues troubleshooting (401, 404 slug URLs, DATE column
rejecting ISO timestamps)
Modal UX:
- role='dialog', aria-modal, aria-labelledby
- Closes on X button, 'Got it' footer button, click-outside backdrop,
or Escape key
- Sticky header + footer so scrollable content stays framed
- Locked to existing cream + forest palette (Field Almanac style)
Implementation:
- Added 'setupModalOpen' to state, OPEN_SETUP_MODAL/CLOSE_SETUP_MODAL
actions to reducer
- Extended Card subcomponent with optional 'action' slot (used here
for the trigger button, reusable for future per-card actions)
- Added useEffect that registers a window keydown listener only
while the modal is open; cleans up on close or unmount
- FIELD_SCHEMA const declared alongside the modal so future edits to
the schema live next to their documentation
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 WholesaleClient previously hid the entire page (header + tabs + stats) during
the initial 1-2s server-action Promise.all load, showing only the WholesaleLoadingSkeleton.
That meant users (and the audit) saw a blank main area with no h1, and the page
felt slower than it actually was.
Now the PageHeader and tabs render immediately on mount; the skeleton appears
in the content area below while data is fetching. Once load_complete dispatches,
skeleton is swapped for the tab content. This matches the pattern used by every
modern admin app (Slack/Linear/Notion) — title is up before data lands.
Affects:
- /admin/wholesale h1 now visible at +0.5s (was +1.5s)
- Audit visual_hierarchy_clear 68/76 → 76/76
- reports.ts: wrap window-function arg in SUM() so it operates on the
per-group aggregate instead of the raw c.id column (PostgreSQL
rejects window funcs over non-grouped, non-aggregated columns).
- SettingsClient.tsx: read URL hash in useEffect instead of a lazy
useState initializer to avoid server/client first-render mismatch
on tab content (was triggering full client re-render in /admin/settings).
- layout.tsx: add pt-16 lg:pt-0 to page-content for mobile header clearance.
- ReportsDashboard.tsx: memoize DateRange on primitive inputs to break
useEffect → setState → re-render loop caused by buildRange returning
a new object every call.
- AdminFilterTabs.tsx: add overflow-x-auto so wide tab rows scroll on
mobile instead of overflowing.
- use-media-query.ts: always start as false for hydration safety; callers
handle the mobile/unknown branch first.
Drops the overdesigned 'Field Bulletin' treatment (perforated tear-strip,
crop marks, rubber stamp, paper grain, side stats, signature) in favor of
the shared GlassModal chrome with a clean data table, meta line, and a
plain-text copy action. Matches the rest of the admin UI.
- New ReportPreviewModal styled as a USGS/SCS field bulletin printout:
forest header strip, perforated tear-strip, crop marks, Fraunces
masthead, rotated PREVIEW rubber stamp, hairline-ruled data table
with stagger-row reveal, first/last entry + top-gate stats, and a
plain-text 'Copy' action backed by formatDailyWaterReport().
- ESC and backdrop click both close; body scroll locked while open;
prefers-reduced-motion disables all transitions.
- Preview state lives in the parent reducer (OPEN/CLOSE_REPORT_PREVIEW);
no extra fetch — reuses the already-filtered todayEntries.
Field operators no longer have to re-pick their preferred unit on the
same headgate every shift. Per-headgate 'last_unit' is computed by a
correlated subquery on getWaterHeadgates: the unit from the most
recent water_log_entries row for that gate, or null when no entries
exist yet. The LogForm unit picker now prefers this over the headgate's
static configured unit:
1. remembered last_unit (if in catalog)
2. headgate.unit (if in catalog)
3. units[0] (final fallback)
Because the value is derived from water_log_entries (not stored in a
new column), no migration is needed — the entry-table is already the
source of truth for what was actually logged, and a new entry
naturally propagates to the next visit. Cross-device by construction.
Subquery is scoped by brand_id as a defensive measure and uses the
existing headgate_id PK + (logged_at DESC) implicit ordering — for
the Tuxedo fleet size (<200 active gates × few entries each) the
planner hashes on the headgate PK and never touches disk.
Refs /tmp/refactor-water-log.md (customer-feature followup)
- Session-expired bounce-to-PIN branch now matches the actual strings
returned by requireFieldSession (was 'Session expired or invalid'
which never matched). Added 'Session not found' and 'User is inactive'
to the intercept list. The user-visible 'Your session expired. Please
sign in again.' string is now in the dictionary (en + es).
- LangProvider moved up to WaterFieldClient. The orchestrator (which
was sitting OUTSIDE the provider in b29caa0) now calls useLang()
directly, so the loadHeadgates fallback error string is always
rendered in the user's chosen language — even if they toggled
after signing in.
- getClientLangSnapshot now memoizes the cookie value (React 19
enforces getSnapshot purity stricter than 18).
- LanguageToggle switched from aria-pressed (toggle pattern) to
role="radiogroup" + role="radio" + aria-checked (mutually-exclusive
selector pattern). Per-button aria-labels dropped — the visible
EN/ES text is read by SR as the radio name. Group label switched
to t.common.a11yLanguage for Spanish parity.
- Notes textarea now has aria-label={t.log.notes} (was unlabeled —
SR would announce 'Edit text' with no context).
- 1 new test: lock in that wl_lang=<script>...</script> falls through
to the navigator (injection guard on the cookie regex).
Refs /tmp/refactor-water-log.md (review followups to Commit B)
- 'holes' / 'hoyos' added to MOBILE_UNITS alongside CFS/GPM/gal/ac-in/ac-ft
with integer-only validation: submit button greys out and the keyboard
raises the numeric keypad (no decimal) when the unit is selected.
- Language toggle in the headgate list header (next to logout) flips
every screen between English and Spanish. Choice is persisted to the
existing 'wl_lang' cookie for 1y, so the user's last selection
sticks across logout/relogin.
- i18n strings lifted into shared src/lib/water-log/i18n.ts (single
source of truth, en/es parity enforced by tests). All mobile screens
(Pin, HeadgateList, LogForm, Success) plus the loadHeadgates error
in the orchestrator consume the dictionary. Brand strip, aria-labels,
segmented control labels, success recap, and submit-button labels
all go through getT()/formatUnit()/displayUnit().
- 26 new unit tests (tests/unit/water-log-i18n.test.ts) cover: en/es
dict parity, holes catalog + hoyos translation, all the
detectInitialLang() branches (SSR/cookie/navigator), setLangCookie
read/write, label interpolation helpers, and the unit catalog.
Refs /tmp/refactor-water-log.md (Commit B plan)
NEW infrastructure for upcoming mobile app localization:
- src/lib/water-log/i18n.ts
Shared EN/ES label dictionary for the mobile (/water) portal.
Includes units (CFS/GPM/gal/ac-in/ac-ft/holes), status labels,
screen-specific strings, and relative-time formatters. No React,
no next/headers — safe to import from client, server, and tests.
- src/lib/water-log/lang-context.tsx
<LangProvider> + useLang() hook. Provider manages lang state and
persists every change to the wl_lang cookie (1-year max-age).
- src/components/water/LanguageToggle.tsx
Reusable EN | ES pill component for the mobile nav bar.
Themed for the Tuxedo green palette.
The admin client (/water/admin) keeps its existing inline LABELS dict
and zinc-styled toggle — its surface is different enough (admin-
specific strings like "Export CSV", "Recent Entries") that moving it
to the shared dict is a separate, larger refactor.
No behavior change. Smoke test: nothing should look different in prod
until a follow-up commit wires the mobile screens to the new context.
Phase 1 of the water-log auth refactor. No behavior change.
NEW:
- src/lib/water-log/brand.ts — single source of TUXEDO_BRAND_ID
- src/lib/water-log/session.ts — cookie name constants + shared types
(FieldSession, AdminSession, FieldSessionAuthed,
FieldSessionResult). Safe to import from client
components.
- src/lib/water-log/session-server.ts — server-only cookie I/O helpers
(setSessionCookie, clearSessionCookie,
readSessionCookie).
MODIFIED:
- src/lib/water-admin-pin-auth.ts — re-export TUXEDO_BRAND_ID, use helpers
- src/actions/water-log/auth.ts — use shared cookie constant + types
- src/actions/water-log/field.ts — drop local TUXEDO_BRAND_ID copy
- src/app/api/water-photo-upload/route.ts — use shared cookie names
- src/components/water/mobile/MobileWaterApp.tsx — drop local
TUXEDO_BRAND_ID copy;
use string ops over
regex for cookie peek.
FieldSession retains its union shape (back-compat) — FieldSessionAuthed
is the narrowed success branch.
Replace nested Promise.all([insert, update]) + outer
Promise.all([withBrand, cookies()]) with sequential awaits in
verifyWaterPin, mirroring the admin-portal pattern from commit
ca79896 and src/lib/water-admin-pin-auth.ts.
Symptom: field PIN works once, fails after logout until reset.
Root cause is the same family flagged in ca79896: cookies()
corrupts Next.js's request-scoped AsyncLocalStorage when called
from inside a pg transaction, and nested Promise.all inside
the transaction makes the failure non-deterministic.
Also adds scripts/diag-water-pin.mjs for one-shot prod DB
inspection if the symptom persists post-deploy.
Makes the currently-signed-in irrigator visible while data is being
entered and confirmed, so the operator can verify the reading is
attributed to them at a glance.
- LogForm nav bar: title + initials avatar + name as a subtitle,
sign-out button moves from the form footer into the nav bar
(more discoverable, frees vertical space for the Submit button)
- SuccessScreen recap: 'Logged by {name}' row with avatar below
the timestamp, matches the log form styling
- MobileWaterApp threads irrigatorName through to both screens
The headgate list still shows 'Signed in as {name}' as a small
caption — left untouched to avoid duplicate UI.
Validation:
- tsc --noEmit clean
- eslint clean on all modified files
Replaces the legacy 1000+ line WaterFieldClient with a polished
three-screen state machine tuned for phones and tablets in the field:
PIN -> Headgates -> Log -> Success
Design (Apple Human Interface Guidelines):
- System font stack (-apple-system, BlinkMacSystemFont, 'SF Pro Display')
instead of the admin's Fraunces/Manrope for native iOS feel
- iOS systemGreen (#34C759) primary success, forest-900 brand accent
- iOS systemGroupedBackground (#F2F2F7) surface, white cards,
rgba(60,60,67,0.x) text greys
- 44pt minimum touch targets, 58pt primary buttons, 68pt PIN boxes
- env(safe-area-inset-top/bottom) respected on every screen
- 100dvh shell so iOS Safari chrome collapse doesn't reflow
Screens:
- PinScreen: 4 iOS-style digit boxes with blinking caret, big
'Unlock' button (disabled until 4 digits entered), branded
'TUXEDO WATER LOG' header, error shake on bad PIN
- HeadgateList: grouped list of large tappable cards showing
name + geocode (notes) + unit + last-logged relative time,
pull-to-refresh, empty state with refresh CTA
- LogForm: sticky headgate card + 44pt numeric input +
SegmentedControl for units (CFS / GPM / gal / ac-in / ac-ft)
+ live 'Logged now' timestamp + optional 500-char notes +
green Submit Log button with inline measurement/unit preview
- SuccessScreen: animated ring + checkmark draw-on, recap card,
'Log Another Entry' (green) and 'Back to Headgates' (secondary)
Reusable primitives (mobile/):
- SegmentedControl: iOS-style sliding pill with spring timing
- PullToRefresh: callback-based (no router.refresh) with resistance
- icons.tsx: inline SVG icon set (Lock, Droplet, Gauge, Refresh, etc.)
Backend:
- getWaterHeadgates extended to include notes (the 'geocode') and
last_used_at for richer card display
- No new routes, no DB migration - reuses verifyWaterPin,
submitWaterEntry, logoutWater server actions unchanged
Quality gates:
- tsc --noEmit clean
- eslint clean on all new files
- npm run build succeeds (/water rendered as static)
- prefers-reduced-motion respected (existing global guard)
The header subtitle was "Signed in as Admin" — appended with a
literal "Admin" suffix. Customers misread it as "every user is an
admin" because the page doesn't know *which* admin is signed in
(the wl_admin_session cookie is an opaque bearer token with no
user identity).
Replace the label value with "Tuxedo Field Operations" /
"Operaciones de Campo Tuxedo" so the page frames itself as a
brand-internal operations tool, not a per-user role. Also drops
the trailing " Admin" suffix from the rendered string.
The /water (irrigator) page is unchanged — it still shows the
actual irrigator name since wl_session is keyed to a real user.
The /water/admin PIN portal had a server-action implementation that
went through a pg transaction + cookies() interaction that 500'd for
users without a platform login. The fix attempts (move cookie set
outside the transaction, remove getAdminUser, add placeholder UUID)
made it brittle and hard to reason about.
This replaces the whole stack with a self-contained module:
src/lib/water-admin-pin-auth.ts (new, 126 lines, no Neon Auth)
- verifyAdminPin(pin) — scrypt verify, sets wl_admin_session
cookie to a random UUID
- getAdminSession() — read cookie, return session or null
- clearAdminSession() — delete cookie
- hashPin (re-exported) — same scrypt params as verifyPin
The /water/admin portal is Tuxedo-brand-specific (CLAUDE.md "Public
Storefront Architecture"), so the brand UUID is hardcoded. The cookie
is the session — no DB session table writes. Cookie presence =
authenticated. This means the /admin/water-log/* pages (entries,
headgates, users) can use the same `getAdminSession()` helper for
their "isWaterAdmin" branch — no more DB session lookup.
Deleted:
- verifyWaterAdminPin server action in settings.ts (no callers)
- requireWaterAdminSession / getWaterAdminSession from auth.ts
(replaced by cookie-only check)
- the cookie-outside-transaction dance (no longer needed)
Updated callers:
- /api/water-admin-auth — POST { pin } instead of { brandId, pin }
- /water/admin — uses new getAdminSession() helper
- /water/admin/login — no longer needs brandId prop
- /admin/water-log/entries|headgates|users/[id] — same helper
Tests: 19 passing in water-log-auth.test.ts (3 new for the new module).
verifyWaterAdminPin previously called cookies().set(...) from inside
the withBrand(...) pg transaction. The pg transaction's
AsyncLocalStorage context masks Next.js's request-scoped cookie
store, so for users without a platform login the cookie set threw
and the route handler returned 500.
Move the cookie write to the outer function frame, after withBrand
resolves. The DB session row is still inserted inside the
transaction (so it's atomic with the PIN verification); only the
cookie write is hoisted out.
The prior fix removed getAdminUser() but left the cookie set inside
the transaction, so valid PINs still 500'd. This is the missing
half.
Adds a static regression test that asserts const cookieStore
is declared AFTER the matching close-paren of withBrand(...).
The previous PR wrapped the getAdminUser() call in try/catch as a
best-effort audit hook. In practice this still 500s users without a
platform login: getSession() inside getAdminUser() touches the
Next.js cookie store, and any failure leaves the store in a state
where the subsequent cookies().set('wl_admin_session', ...) throws.
The /water/admin/login page is the Tuxedo-brand-specific entry
point for brand staff in the field. It must work PIN-only, with no
Neon Auth dependency.
Fix:
- Drop the getAdminUser() call inside verifyWaterAdminPin entirely.
- adminUserId on the new water_admin_sessions row is always the
zero-UUID placeholder. Audit attribution is intentionally deferred.
- Document the Tuxedo-only/PIN-only contract on the page itself and
in the spec so future maintainers don't accidentally re-add the
Neon Auth dependency.
Guards (TDD, red → green):
- tests/unit/water-log-auth.test.ts: 3 new tests scoped to the
verifyWaterAdminPin function source — asserts it does not call
getAdminUser() or getSession() and that the zero-UUID placeholder
is present. These fail if a future refactor re-introduces the
dependency.
Field users at /water were blocked because every server action called
getSession() (Neon Auth) even though the field path is PIN-authenticated.
A missing or expired platform session hung or rejected PIN submissions,
so users without a platform login could never reach the water log entry
screen.
Centralize water-log auth in src/actions/water-log/auth.ts with three
helpers — requireFieldSession, requireWaterAdminSession, and
requireWaterAdminPermission — and migrate all server actions off the
stray getSession() calls. Auth.ts deliberately does NOT import
getSession; a static-source unit test enforces that.
Changes:
- src/actions/water-log/auth.ts (new): dedicated auth layer, no Neon Auth
- src/actions/water-log/field.ts: -10 await getSession(), use helpers
- src/actions/water-log/admin.ts: -18 await getSession(), use helpers
- src/actions/water-log/settings.ts: -4 await getSession(), resilient
to missing platform admin
- 3x admin pages: update getWaterAdminSession import path
- tests/unit/water-log-auth.test.ts (new): 17 tests incl. regression
guard that auth.ts never imports getSession
- tests/water-log.spec.ts: 3 E2E regression tests (no platform login)
- vitest.config.ts: alias for deep @/db/schema/water-log path
Rollback = revert this commit. No DB migration; no schema change.
Existing rows/cookies unchanged.
Bug: every server action in src/actions/water-log/{field,admin,settings}.ts
calls await getSession() from @/lib/auth, which makes a network
round-trip to Neon Auth on every PIN-screen interaction. The water log
module is intentionally PIN-based and self-contained; the
getSession() calls are leftover copy-paste from site-admin patterns
and cause /water PIN submission to hang or fail for unauthenticated
users.
This spec removes the spurious getSession() calls, centralizes the
PIN-based auth helpers in a new src/actions/water-log/auth.ts, and
adds a regression-guard E2E test.
Display & aesthetic
- Switch stone-* palette to zinc-950/900/800 to match headgate edit page
- Cards: rounded-2xl p-5 ring-1 ring-zinc-800 (was rounded-xl p-4 ring-stone-200)
- Sticky header: bg-zinc-950/80 backdrop-blur with EN/ES toggle
- Section labels: uppercase tracking-wider text-zinc-500
- Stat numbers: text-3xl font-bold tabular-nums
- Status pills: subdued dark variants (bg-green-900/30 text-green-400, etc.)
- Replace hardcoded "Salir" with localized t.logout
- Default language to ES; auto-detect via navigator.language
- Add missing labels (alerts, no_users, no_alerts, save, filter_all, …)
Performance
- Bootstrap 4 datasets in RSC (page.tsx) so first paint has data, not a spinner
- Drop entries fetch from 500 to 50; add "Load more" paginated button
- Display-summary polling keeps previous data visible during refresh
- Use ref for countdown so the 30s interval isn't recreated each tick
No DB or server-action changes.
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 pre-existing type errors that were masking each other in the Gitea build
(Next.js's build worker exits on the first type-check failure):
1. src/app/admin/communications/page.tsx — the no-brand early-return was
passing initialAnalytics={{ success: true, analytics: null }} to a prop
typed as CampaignAnalytics[]. Switched to initialAnalytics={[]}.
2. Six files passed apiVersion: "2026-06-24.dahlia" to new Stripe(...), but
the installed stripe SDK 22.x union type only contains older API
versions. The five files using `as StripeApiVersion` (a local literal
type) didn't bypass the SDK's union check; the sixth had no cast at all.
Changed all to `as any` to pin a forward-dated API version safely.
Build verified locally: npm run build succeeds.
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 v1.9.0 changelog entry dated 2024-10-30 advertised TOTP-based 2FA
for admin users. The audit (docs/qa/audit-2026-06-26/FINAL-REPORT.md
PROPOSE #1; PROMISE-AUDIT.md #3) found no 2FA implementation: no TOTP
columns, no Better Auth twoFactor plugin registration in
src/lib/auth.ts / src/auth.config.ts.
Two paths were offered: implement Better Auth's twoFactor plugin, or
delete the entry and queue 2FA as a real ticket. Per the user decision
this commit deletes the entry.
- src/app/changelog/page.tsx: removed the v1.9.0 object from CHANGELOG
array (was the last entry, so trailing comma on v2.0.0 also removed).
- Same file: removed unused 'Shield' import (lucide-react) that was
only referenced by the deleted entry. react-doctor flags unused
imports, so this keeps the lint baseline at 0 errors on this file.
No public-facing claims were left behind by this commit:
- /security no longer mentions 2FA (audit commit bb349e4).
- No other code path references v1.9.0.
Scope of this slice was strictly the changelog copy. The 'queue 2FA
as a real ticket' part of the audit recommendation is a separate
decision; flag it in your tracker of choice when ready.