Commit graph

349 commits

Author SHA1 Message Date
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
80bb9495fc docs(08): fix UI-SPEC checker failures — typography and spacing
- Collapse 6 font sizes to 4 (12, 14, 20, 24px): promote display name
  from text-lg (18px) to text-xl (20px); promote avatar initials and
  period chip text from text-[10px] to text-xs (12px)
- Remove non-standard "Additional fixed sizes" block; contract now
  declares exactly 4 canonical sizes
- Drop font-medium (500); breakdown subsection headers move to
  font-semibold (600) — two weights only: 400 + 600
- Replace py-1.5 (6px) with py-2 (8px) throughout breakdown rows;
  note D-16 override with rationale; clarify min-h-[44px] as WCAG
  floor only (not a spacing/padding value)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 17:48:10 -04:00
725fe731ca docs(08): UI design contract 2026-05-07 17:44:42 -04:00
f0d06ad06f docs(state): record phase 8 context session 2026-05-07 17:39:08 -04:00
1459b86a9d docs(08): capture phase context 2026-05-07 17:39:08 -04:00
a394517603 docs(phase-07.1): complete phase + evolve PROJECT.md after user approval 2026-05-07 17:26:04 -04:00
26fba8170a test(07.1): mark BUG-7.1-B resolved by 660d039 2026-05-07 17:21:44 -04:00
660d039b80 fix(07.1-02): allowlist UTC, Etc/UTC, GMT in IANA validator 2026-05-07 17:21:26 -04:00
f55b937af9 test(07.1): UAT results — 5 pass, 2 skipped, 2 bugs found (1 fixed) 2026-05-07 16:46:53 -04:00
d31fd48cad fix(07.1-02): use updatedAt camelCase in user timezone UPDATE 2026-05-07 16:45:52 -04:00
91ccf6560f docs(07.1): add phase verification report 2026-05-07 09:15:59 -04:00