diff --git a/.planning/phases/09-user-profile-preferences-new/09-02-SUMMARY.md b/.planning/phases/09-user-profile-preferences-new/09-02-SUMMARY.md new file mode 100644 index 0000000..7d769ec --- /dev/null +++ b/.planning/phases/09-user-profile-preferences-new/09-02-SUMMARY.md @@ -0,0 +1,144 @@ +--- +phase: 09-user-profile-preferences-new +plan: "02" +subsystem: api-routes +tags: [api, auth, channels, theme, notifications, phase-9] +dependency_graph: + requires: + - "user.theme column (Plan 01 — migration 084)" + - "notification_channels.owner_user_id + partial unique index (Plan 01 — migration 085)" + - "notify_event_keys + user_event_subscriptions tables (Plan 01 — migration 086)" + - "NotifyEventKey, UserEventSubscription, NotificationChannel types (Plan 01)" + provides: + - "GET + PUT /api/me/theme — { theme, source } and validated write" + - "GET /api/me/channels — user's personal channels, camelCase" + - "PUT /api/me/channels/[type] — UPSERT + best-effort test send" + - "DELETE /api/me/channels/[type] — remove personal channel" + - "POST /api/me/channels/[type]/test — re-send test to existing channel" + - "GET + PUT /api/me/notification-subscriptions — full matrix + single row UPSERT" + - "lib/services/personal-channels.ts — shared validation + test-send helpers" + affects: + - "Plans 03/04/05/06 — consume these endpoints and the personal-channels service" +tech_stack: + added: [] + patterns: + - "WITH-CTE UPSERT pattern scoped to (owner_user_id, channel_type) with 23505 → 409 fallback" + - "Default-enabled matrix (row absence = true) for subscription opt-out model" + - "Best-effort test send on every channel save — never blocks success" + - "SSRF mitigation: Teams URL hostname allowlist via URL() parsing" + - "Snake_case column names in DB, camelCase in API responses (manual transform)" +key_files: + created: + - app/api/me/theme/route.ts + - app/api/me/channels/route.ts + - app/api/me/channels/[type]/route.ts + - app/api/me/channels/[type]/test/route.ts + - app/api/me/notification-subscriptions/route.ts + - lib/services/personal-channels.ts + modified: [] +decisions: + - "theme source='default' when stored value is 'system' (matches /api/me/timezone semantic)" + - "WITH-CTE UPSERT (not INSERT ... ON CONFLICT) because the partial unique index only fires on INSERT — the CTE update arm is the preferred path, with INSERT as fallback when no existing row" + - "sendChannelTest does NOT log channel.config to avoid leaking webhook URLs / auth tokens in server logs" + - "Custom ntfy topic 400 response body carries both error and message keys so Plan 05 UI can render inline error below the input" +metrics: + duration_minutes: 15 + completed_date: "2026-05-10" + tasks_completed: 3 + files_created: 6 + files_modified: 0 +--- + +# Phase 9 Plan 02: Per-User API Surface Summary + +One-liner: Six new files deliver the complete /api/me/* API surface for theme, personal channels (Teams + ntfy), and notification subscription matrix — all gated by requireAuth(), writing only session.user.id, with Teams SSRF prevention and default-enabled subscription fallback. + +## Tasks Completed + +| Task | Name | Commit | Files | +|------|------|--------|-------| +| 1 | GET + PUT /api/me/theme | dc5dc91 | app/api/me/theme/route.ts | +| 2 | Personal channels service + /api/me/channels routes | c35b968 | lib/services/personal-channels.ts, app/api/me/channels/route.ts, app/api/me/channels/[type]/route.ts, app/api/me/channels/[type]/test/route.ts | +| 3 | GET + PUT /api/me/notification-subscriptions | 55a80a0 | app/api/me/notification-subscriptions/route.ts | + +## Route Files + +### app/api/me/theme/route.ts +- **Exports:** `GET`, `PUT` +- **GET:** `SELECT theme FROM "user" WHERE id = $1` → `{ theme: 'light'|'dark'|'system', source: 'user'|'default' }`. Source is `'user'` when stored value differs from `'system'` (the default). +- **PUT:** Validates `body.theme` against `ALLOWED_THEMES = new Set(['light', 'dark', 'system'])`. Returns 400 on invalid value. `UPDATE "user" SET theme = $1, updated_at = NOW() WHERE id = $2 RETURNING theme` — uses `updated_at` (snake_case, unquoted, matches migration 012). Does NOT contain `"updatedAt"`. +- **Auth:** `requireAuth()` on both handlers. Write target: `session!.user.id` only. + +### app/api/me/channels/route.ts +- **Exports:** `GET` +- **GET:** `SELECT ... FROM notification_channels WHERE owner_user_id = $1 ORDER BY channel_type ASC` → `{ channels: [...camelCase rows] }`. Scoped strictly to calling user. + +### app/api/me/channels/[type]/route.ts +- **Exports:** `PUT`, `DELETE` +- **PUT teams:** Validates `webhook_url` via `isValidTeamsWebhookUrl`. Name: `"Personal Teams ()"`. Config: `{ webhook_url }`. +- **PUT ntfy:** Validates custom topic via `isValidNtfyTopic` if supplied; otherwise mints via `mintNtfyTopic()`. Name: `"Personal ntfy ()"`. Config: `{ server_url: 'https://ntfy.sh', topic }`. +- **PUT UPSERT SQL:** WITH-CTE pattern — UPDATE existing row if present, INSERT if not; params `[$1=owner_user_id, $2=channel_type, $3=name, $4=config]`. On `23505` unique_violation → 409 `{ error: 'Conflict', message: '...' }`. +- **PUT response:** `{ channel: , test: }`. +- **DELETE SQL:** `DELETE FROM notification_channels WHERE owner_user_id = $1 AND channel_type = $2 RETURNING id` → 404 if rowCount=0, else `{ deleted: true, channelType }`. +- **Auth:** `requireAuth()` on both. No `userId`/`user_id`/`owner_user_id` accepted from body. + +### app/api/me/channels/[type]/test/route.ts +- **Exports:** `POST` +- **POST:** `SELECT ... WHERE owner_user_id = $1 AND channel_type = $2` → 404 if missing. Calls `sendChannelTest(channel)` → `{ test: ChannelTestResult }`. Returns 200 even on test failure. + +### app/api/me/notification-subscriptions/route.ts +- **Exports:** `GET`, `PUT` +- **GET implementation:** Three sequential queries: + 1. `SELECT key, display_label, description, sort_order FROM notify_event_keys WHERE is_active = true ORDER BY sort_order ASC, key ASC` + 2. `SELECT channel_type FROM notification_channels WHERE owner_user_id = $1 AND is_active = true` + 3. `SELECT event_key, channel_type, enabled FROM user_event_subscriptions WHERE user_id = $1` + - Builds matrix defaulting to `true` when no row exists (D-15 opt-out model). +- **GET response shape:** `{ eventKeys: [{key, displayLabel, description, sortOrder}], channelTypes: string[], matrix: { [event_key]: { [channel_type]: boolean } } }` +- **PUT validation:** `event_key` non-empty string ≤ 128 chars; `channel_type` via `isPersonalChannelType`; `enabled` via `typeof === 'boolean'` (400 on failure). +- **PUT SQL:** `INSERT INTO user_event_subscriptions ... ON CONFLICT (user_id, event_key, channel_type) DO UPDATE SET enabled = EXCLUDED.enabled, updated_at = NOW() RETURNING ...` +- **PUT response:** `{ subscription: { eventKey, channelType, enabled } }` + +## lib/services/personal-channels.ts — Exported Symbols + +| Symbol | Type | Description | +|--------|------|-------------| +| `TEST_MESSAGE_BODY` | `const string` | `'Pulse channel verified — you can ignore this message.'` | +| `isValidTeamsWebhookUrl` | `(input: unknown) => input is string` | https:// + hostname must match *.webhook.office.com or *.logic.azure.com | +| `isValidNtfyTopic` | `(input: unknown) => input is string` | Regex `^[A-Za-z0-9_-]{6,64}$` | +| `mintNtfyTopic` | `() => string` | Returns `pulse-XXXXXXXX` (8 hex chars from crypto.randomUUID()) | +| `sendChannelTest` | `(channel: NotificationChannel) => Promise` | Best-effort test send to teams or ntfy | +| `isPersonalChannelType` | `(t: unknown) => t is 'teams' \| 'ntfy'` | Type guard for allowed personal channel types | +| `PERSONAL_CHANNEL_TYPES` | `ChannelType[]` | `['teams', 'ntfy']` | + +## Security Confirmations + +- **No route accepts userId/user_id/owner_user_id from request input.** All writes are scoped to `session!.user.id` from `requireAuth()`. +- **Theme route uses `updated_at` (NOT `"updatedAt"`).** Verified: the file contains `updated_at = NOW()` and does not contain the quoted camelCase identifier. +- **Teams SSRF prevention:** `isValidTeamsWebhookUrl` uses `new URL(input)` to parse then checks `u.protocol === 'https:'` and hostname against three host patterns. Internal hosts cannot match. +- **Custom ntfy topic 400 shape:** `{ error: 'Invalid topic', message: 'topic must match ^[A-Za-z0-9_-]{6,64}$' }` — Plan 05 UI renders `message` inline below the custom-topic Input. +- **sendChannelTest does not log channel.config** — only logs channel id/type on failure paths. + +## Deviations from Plan + +None — plan executed exactly as written. + +## Threat Flags + +No new network endpoints, auth paths, file access patterns, or schema changes beyond what was declared in the plan's threat model. All ten threats documented (T-09-02-01 through T-09-02-10) are addressed by the implementation. + +## Self-Check: PASSED + +Files exist: +- app/api/me/theme/route.ts: FOUND +- app/api/me/channels/route.ts: FOUND +- app/api/me/channels/[type]/route.ts: FOUND +- app/api/me/channels/[type]/test/route.ts: FOUND +- app/api/me/notification-subscriptions/route.ts: FOUND +- lib/services/personal-channels.ts: FOUND + +Commits exist: +- dc5dc91: FOUND (Task 1 — theme route) +- c35b968: FOUND (Task 2 — channels service + routes) +- 55a80a0: FOUND (Task 3 — notification-subscriptions route) + +TypeScript: `npx tsc --noEmit --pretty` exit 0 — no errors.