Verified via psql that the user table has quoted camelCase columns from
Better Auth ("updatedAt", "createdAt", "emailVerified"). The original
route comment claimed app/api/settings/profile as precedent — that route
is ALSO broken with the same bug; only app/api/me/timezone got it right.
Aligning theme route with the timezone precedent.
Migration 084 backfilled theme='system' for all existing users. The bridge
was then calling setTheme('system') on every signed-in client, clobbering
existing localStorage values (e.g. user previously chose 'dark', got reset
to OS preference). Treat server 'system' as 'no explicit opinion' — only
enforce explicit 'light'/'dark' from the server.
Tradeoff: explicit 'system' selection on one device won't propagate to a
device that has 'light'/'dark' cached. Acceptable — users can re-select.
- Create ThemeSessionBridge.tsx: useEffect compares session.user.theme to
next-themes value; calls setTheme(serverTheme) on mismatch; validates
against 3-string allowlist ('light'|'dark'|'system'); renders null
- Mount <ThemeSessionBridge /> as first child of <AuthProvider> in app/layout.tsx
- Modify ThemeToggle: writeTheme() calls setTheme() then fire-and-forget
PUT /api/me/theme; silent catch for network errors (best-effort desktop UX)
- GET/POST /api/admin/notify-event-keys: list ordered by sort_order/key, create with key regex validation (^[a-z][a-z0-9_]*$/i), 409 on conflict
- PUT/DELETE /api/admin/notify-event-keys/[key]: update via COALESCE, hard delete with 404 guard
- app/admin/workflow/event-keys/page.tsx: list with inline edit, Switch for is_active toggle, + New event key form, sonner toasts
- All routes gated by requireAdmin()
- Combobox from Popover + Command with Intl.supportedValuesOf + EXTRA_ALLOWED_TIMEZONES
- debounced 400ms PUT /api/me/timezone on selection
- live clock via setInterval 60s re-tick
- toast.success/error; inline destructive error on save failure
- GET /api/notification-channels: requireAuth(), admin sees all rows with owner_email JOIN, non-admin sees global-only
- GET accepts ?owner=global|personal|all filter parameter
- POST /api/notification-channels: requireAdmin(); preserves all four channel_type values (teams/telegram/ntfy/webhook); adds owner_user_id column
- [id] routes: requireAuth() + per-row authorization (isAdmin || isOwner); global rows require admin
- Admin channels page: Owner badge (Global vs Personal: email), Show filter select, disclaimer text for personal channels
- GET returns { eventKeys, channelTypes, matrix } where matrix defaults to
true when no row exists (D-15 opt-out model)
- PUT UPSERTs single row via composite PK ON CONFLICT
- Validates: event_key (non-empty, <=128 chars), channel_type via
isPersonalChannelType, enabled as typeof boolean
- Write target always session.user.id (T-09-02-01, T-09-02-03)
- Type additions: RouteToUser, ResolvedRecipient, NotifyResolver,
UserRouteFallback, UserRouteFallbackReason in lib/types/pipeline.ts
- New resolver registry: notify-resolvers.ts with 3 v1 resolvers
- executeNotify rewrite with backward-compat path + user-route decision tree
- Behavioral vitest guarantee: muted user does not fall back to global channel
- lib/services/personal-channels.ts: isValidTeamsWebhookUrl, isValidNtfyTopic,
mintNtfyTopic, sendChannelTest, TEST_MESSAGE_BODY, isPersonalChannelType,
PERSONAL_CHANNEL_TYPES
- GET /api/me/channels: returns user's personal channels (owner_user_id scoped)
- PUT /api/me/channels/[type]: WITH-CTE UPSERT + best-effort test send
- DELETE /api/me/channels/[type]: removes user's channel, 404 if missing
- POST /api/me/channels/[type]/test: re-sends test to existing channel
- SSRF mitigation via Teams URL hostname allowlist (T-09-02-06)
- Race window closed by partial unique index from Plan 01 (T-09-02-10)
- Asserts muted user (enabled=false in user_event_subscriptions) returns
success:true, notified:false, skipped_reason='user_muted'
- Asserts no notification_channels SQL is issued on the mute path
- Asserts no outbound fetch() is called (no personal or global send)
- Asserts user_route_fallback is absent from output (mute != fallback)
- Uses _INTERNALS test seam to call dispatchUserRoute directly
- Behavioral guarantee for D-12/ROUTE-05 enforced by CI, not just grep
- ALLOWED_THEMES allowlist for light/dark/system
- GET returns { theme, source: 'user'|'default' }
- PUT validates against allowlist, writes session.user.id only
- Uses updated_at (snake_case) — matches migration 012 schema
- No userId from body (T-09-02-01 mitigation)
- migrations/084_add_user_theme.sql: ALTER TABLE user ADD COLUMN theme TEXT NOT NULL DEFAULT 'system'
- Defensive backfill UPDATE for in-flight NULL rows on managed Postgres
- COMMENT ON COLUMN documents allowed values (light|dark|system)
- lib/auth.ts: adds theme additionalField with defaultValue 'system' after timezone
- session.user.theme now exposed via Better Auth same as session.user.timezone
- Typography: remove text-base (16px) reference for Card titles; map
them to the declared 20px Heading role (text-xl) to keep scale at
exactly 4 sizes. Add explicit note that section header labels use
the 12px Label role, not a fifth size.
- Copywriting: add explicit "Save custom topic" row to Channels table
for the ntfy advanced-disclosure save button; also propagate the
label into the layout block and the save-model table so executor
has no ambiguity.
- Visuals (optional flag): declare page focal point — H1 is entry
anchor, Channels Card is conversion-weight section.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The window scrolls (not <main>) on this layout, and the document content
height isn't fully laid out by the first rAF after rows render — so
window.scrollTo gets clamped to maxScroll, leaving the user near top.
Retry up to 30 frames (~500ms) until the actual scroll position matches
the target within 4px.
The mobile shell's <main> is overflow-y-auto, so Next.js's built-in
scrollRestoration (window-only) doesn't restore the list's inner scroll
when returning from /mobile/engagement/[userId]. Persist the scroll
position to sessionStorage on scroll (rAF-throttled) and restore once
after the first users page loads.
Restoration is gated to the first load only, so changing period/sort
doesn't yank the viewport — and uses a single sessionStorage key, so
returning to the list later still lands where you were.
Postgres returns NUMERIC columns as strings via pg, so calling
.toFixed(1) on time_entries.hours_worked from /api/engagement/user/[userId]
threw at runtime. The period-level hours in the same response are already
parseFloat'd; the recentEntries and matchedEntries arrays pass rows through
verbatim, so wrap with Number() at render.
- New EngagementProfileBreakdown: Time/Communication/Meetings subsections, after-hours
and Zoom conditional rows, py-2 per UI-SPEC override
- New EngagementRecentEntries: collapsible list up to 10, Billable badge, Set<string>
expand state, empty-state copy
- New EngagementRecentMeetings: collapsible list up to 10, matched entries + attendees
in expanded view, (no subject) fallback, Set<string> expand state
- Page updated: 3 new component imports + breakdown/entries/meetings mounted in order
- No dangerouslySetInnerHTML; D-01/D-22 guard rails untouched
- New app/mobile/engagement/[userId]/page.tsx with fetch + error states + retryNonce
- New EngagementProfileSkeleton with header/metric/breakdown/list skeletons
- 404 renders 'User not found' + back link; 500 renders sonner toast + Retry
- D-04 comment: relies on App Router default scrollRestoration
- D-01/D-22 guard rails: EngagementUserRow.tsx and data endpoint untouched
- Proxies Microsoft Graph user photo bytes to authenticated mobile clients
- requireAuth() is first call — unauthenticated requests get 401 before Graph
- 503 when MSGRAPH_* env not configured (isMsgraphConfigured gate, D-26)
- 400 for malformed userId (path traversal denylist, permissive per VARCHAR(255))
- 404 neutral response when user has no photo (no userId oracle)
- 200 with Cache-Control: private, max-age=3600 on success (D-25)
- 502 neutral response on Graph upstream errors (no token/user leakage)
- New public method fetches binary photo from Graph /users/{id}/photo/$value
- Returns { bytes, contentType } on 200, null on 404 (no photo)
- Throws on other non-2xx for upstream caller to map to 502/503
- Reuses getToken() OAuth2 cache; no retry (best-effort per D-26)
- Existing methods (getToken, fetchJson, getUsers, etc.) untouched