Commit graph

359 commits

Author SHA1 Message Date
2a3e89c753 fix(09): ThemeSessionBridge ignores server 'system' to preserve existing client theme
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.
2026-05-10 21:54:04 -04:00
58816bbdfe docs(phase-09): complete phase execution + evolve PROJECT.md 2026-05-10 21:52:09 -04:00
26e581d350 test(09): persist human verification items as UAT 2026-05-10 07:58:24 -04:00
6010d0b012 docs(09-05): complete channels UI + theme bridge plan — ProfileChannelsSection, ThemeSessionBridge, ThemeToggle write-through 2026-05-10 07:50:18 -04:00
586c04ad2a feat(09-05): ThemeSessionBridge + ThemeToggle write-through to /api/me/theme
- 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)
2026-05-10 07:48:57 -04:00
1b7c453c6d feat(09-05): ProfileChannelsSection (Teams + ntfy + QR code) + qrcode.react install
- Create ProfileChannelsSection.tsx with Teams + ntfy sub-sections
- Teams: URL input, inline 400 error (teamsError), save/clear buttons, inline test result
- ntfy: mint-on-first-save (State A → State B), QR code via QRCodeSVG, subscribe link
- ntfy: advanced disclosure with custom topic input + inline 400 error (customTopicError)
- ntfy: test-now and remove buttons
- Install qrcode.react ^4.2.0 (node_modules + package.json + package-lock.json updated)
- Delete ProfileChannelsSectionPlaceholder.tsx (replaced by real component)
- Update app/mobile/profile/page.tsx import to ProfileChannelsSection (not Placeholder)
2026-05-10 07:48:03 -04:00
7238c97a98 docs(09-06): complete admin-surfaces plan — channels owner column, event-keys CRUD, pipeline-executions ROUTE-07 2026-05-10 07:44:19 -04:00
23a8c7c5d9 feat(09-06): NEW /admin/workflow/executions page + pipeline-executions API (ROUTE-07)
- GET /api/admin/pipeline-executions: requireAdmin(), accepts fallbacks_only/pipeline_id/limit params
- Four complete parameterized SQL strings — no alias-in-WHERE bug (HIGH 4 fix)
- JSONB predicate: output_data ? 'user_route_fallback' inlined in EXISTS subquery in WHERE
- has_fallback boolean on every row (true constant in fallbacks-only branches, EXISTS in unfiltered)
- pipeline_id validated against /^\d+$/ before binding; limit capped at 500
- app/admin/workflow/executions/page.tsx: Switch 'Show only fallbacks', pipeline Select filter, per-row fallback badge, links to pipeline detail page
- Locked URL /admin/workflow/executions honored — fresh page over pipeline-engine tables only
2026-05-10 07:43:05 -04:00
5a9ec0f5df docs(09-04): complete mobile profile UI plan
- 6 new files: page shell, 3 section cards, skeleton, channels placeholder
- MoreDrawer Account section routes to /mobile/profile
- TSC: exit 0
2026-05-10 07:42:19 -04:00
7f4ffa0fb6 feat(09-06): add /admin/workflow/event-keys CRUD page and API routes
- 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()
2026-05-10 07:41:27 -04:00
577e236eb5 feat(09-04): ProfileThemeSection + ProfileNotificationMatrix
- ProfileThemeSection: 3-option radio rows (Light/Dark/System), immediate setTheme + PUT /api/me/theme, rollback on error
- ProfileNotificationMatrix: skeleton loading, empty state, single/multi-column switch grid, 400ms debounced PUT per cell
2026-05-10 07:40:53 -04:00
5aef2559dd feat(09-04): ProfileTimezoneSection (Combobox + live current time)
- 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
2026-05-10 07:40:48 -04:00
da13caf9cb feat(09-04): page shell, drawer wiring, skeleton helper, channels placeholder
- app/mobile/profile/page.tsx: server-component shell gated by requireAuth() + redirect('/auth/sign-in')
- ProfileSectionSkeleton.tsx: generic 3-row pulsing skeleton Card
- ProfileChannelsSectionPlaceholder.tsx: stub Channels card (Plan 05 swaps real component)
- MoreDrawer.tsx Account section: identity row wrapped in Link, new Profile & preferences row above Sign-out
2026-05-10 07:40:40 -04:00
47cab788fc feat(09-06): owner column + role-scoped reads on notification channels
- 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
2026-05-10 07:39:53 -04:00
1bce661648 chore: merge 09-02 worktree commits (Wave 2) 2026-05-10 07:36:15 -04:00
17a189e564 docs(09-02): complete per-user API surface plan
- Theme GET/PUT, channels CRUD + test, notification-subscriptions matrix
- All routes requireAuth(), session.user.id only write target
- updated_at (not updatedAt) confirmed
- tsc exits 0
2026-05-10 07:31:58 -04:00
55a80a07ad feat(09-02): GET + PUT /api/me/notification-subscriptions (matrix endpoint)
- 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)
2026-05-10 07:30:51 -04:00
486f27eb77 docs(09-03): complete notify per-user routing plan
- 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
2026-05-10 07:30:36 -04:00
c35b968522 feat(09-02): personal channels service + /api/me/channels routes
- 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)
2026-05-10 07:30:05 -04:00
fd19a5d997 test(09-03): add vitest unit test for muted user route behavior
- 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
2026-05-10 07:29:20 -04:00
86acc06b16 feat(09-03): rewrite executeNotify with route_to_user branch and fallback semantics
- Extract dispatchToGlobalChannel helper (backward-compat path unchanged)
- Add dispatchUserRoute: field lookup, resolver dispatch, email→user_id
  resolution, mute check, personal channel lookup, send with fallback
- Add fallbackToGlobal: annotates output with user_route_fallback reason
- Mute path (enabled=false) returns success:true/notified:false, no fallback
- Default channel-type order when omitted: ntfy then teams (ROUTE-06)
- Export _INTERNALS test seam following link-discovery.ts precedent
- All five fallback reasons: no_channel, send_failed, user_not_found,
  no_field_value, resolver_unknown (ROUTE-03..06)
2026-05-10 07:28:21 -04:00
dc5dc913bd feat(09-02): GET + PUT /api/me/theme
- 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)
2026-05-10 07:28:03 -04:00
d27462f713 feat(09-03): add RouteToUser types and resolver registry
- Add RouteToUser, ResolvedRecipient, NotifyResolver, UserRouteFallback,
  UserRouteFallbackReason types to lib/types/pipeline.ts (ROUTE-01)
- Create lib/services/pipeline-steps/notify-resolvers.ts with three v1
  resolvers: direct_email, pulse_user_id, autotask_resource_email (ROUTE-02)
- Resolver registry as Map<string, NotifyResolver> with registerResolver()
  and resolveRecipient() dispatcher with try/catch error handling
2026-05-10 07:27:00 -04:00
485053c639 docs(09-01): complete schema and types foundation plan
- SUMMARY.md for plan 09-01: theme column, personal channels, event-key and subscription tables
2026-05-10 07:24:23 -04:00
b4f8ccac8b feat(09-01): add owner_user_id to notification_channels + event-key and subscription tables
- migrations/085: ALTER TABLE notification_channels ADD COLUMN owner_user_id TEXT REFERENCES user(id) ON DELETE CASCADE
- migrations/085: partial unique index notification_channels_owner_user_id_channel_type_uniq WHERE owner_user_id IS NOT NULL (UPSERT race defense)
- migrations/086: CREATE TABLE notify_event_keys (key PK, display_label, description, sort_order, is_active) with seed row
- migrations/086: CREATE TABLE user_event_subscriptions composite PK (user_id, event_key, channel_type) opt-out model
- lib/types/pipeline.ts: NotificationChannel gains owner_user_id: string | null
- lib/types/pipeline.ts: exports NotifyEventKey and UserEventSubscription interfaces
2026-05-10 07:23:24 -04:00
4fc4a3d3b9 feat(09-01): add theme column to user table + Better Auth additionalField
- 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
2026-05-10 07:22:26 -04:00
e5031e5613 docs(09): create phase plan 2026-05-09 22:54:02 -04:00
480c115296 docs(09): UI design contract 2026-05-09 22:09:45 -04:00
31ff615752 docs(09): fix two blocking UI-SPEC issues found by checker
- 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>
2026-05-09 22:08:01 -04:00
d6a9eaca8f docs(09): UI design contract 2026-05-09 22:03:29 -04:00
d6a6c7139b docs(09): formalize phase 9 requirements after discuss-phase 2026-05-09 16:54:23 -04:00
039033ddff docs(state): record phase 9 context session 2026-05-09 15:06:44 -04:00
e383e986d0 docs(09): capture phase context 2026-05-09 15:06:40 -04:00
84352b3d61 docs(phase-08): evolve PROJECT.md after phase completion 2026-05-08 09:58:54 -04:00
a5e43fddbf docs(phase-08): complete phase execution 2026-05-08 09:58:32 -04:00
25f03cd9d1 test(08): persist human verification items as UAT 2026-05-08 09:47:25 -04:00
7fcb156cfc docs(08-02): SUMMARY — mobile engagement profile page complete (SC#2 partial) 2026-05-07 23:06:44 -04:00
435051ddc8 fix(08-02): retry scroll restoration across frames until layout finalizes
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.
2026-05-07 23:01:22 -04:00
8834db981d fix(08-02): scroll restoration — save/restore both window and <main> scrollTop, defer to rAF after rows render 2026-05-07 22:23:48 -04:00
6bdc937861 fix(08-02): preserve engagement list scroll across profile navigation (D-04)
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.
2026-05-07 22:10:43 -04:00
81079ad89f fix(08-02): coerce Postgres numeric hours_worked to Number before toFixed
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.
2026-05-07 22:02:52 -04:00
b3ad92cd86 docs: plant seed — Wulf Standards Alignment Engine 2026-05-07 21:10:24 -04:00
0be0c1f7f8 feat(08-02): activity breakdown + recent entries + meetings + page wiring (Task 2)
- 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
2026-05-07 20:50:56 -04:00
df78ab8fa5 feat(08-02): identity header + 2x2 metric grid + page wiring (Task 1b)
- New EngagementProfileHeader: avatar (photo/initials fallback), name, jobTitle,
  department, mailto link, last-active relative/absolute label
- New EngagementProfileMetricGrid: 2x2 grid of Hours/Billable/Days/Meetings cards
- Page updated: imports Header+MetricGrid, placeholder div removed, real components mounted
- D-01/D-22 guard rails: EngagementUserRow.tsx and data endpoint untouched
2026-05-07 20:48:24 -04:00
3247c92486 feat(08-02): page shell + skeleton + period/fetch wiring (Task 1a)
- 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
2026-05-07 20:46:28 -04:00
b1a6e6a3c3 docs(08-01): complete photo proxy plan — SUMMARY
- MsGraphClient.getUserPhotoBytes() added (3f6b135)
- /api/mobile/engagement/user/[userId]/photo route added (4978780)
- Type-check and build both pass
- All threat mitigations verified (T-08-01 through T-08-08)
2026-05-07 20:42:04 -04:00
4978780962 feat(08-01): add /api/mobile/engagement/user/[userId]/photo proxy route
- 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)
2026-05-07 20:41:07 -04:00
3f6b13572e feat(08-01): add getUserPhotoBytes() to MsGraphClient
- 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
2026-05-07 20:39:42 -04:00
3f35e1e785 docs(08): plan Phase 8 — engagement user profile (2 plans) 2026-05-07 19:45:34 -04:00
69251d7a0d docs(08): UI design contract 2026-05-07 17:50:19 -04:00