diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index a557581..b18144d 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -188,7 +188,12 @@ Decimal phases appear between their surrounding integers in numeric order.
3. Each user can configure one Teams webhook URL and one Pulse-minted ntfy topic; both are test-sent on save and admins have full read+edit access via `/admin/workflow/channels` (CHAN-01..07)
4. The Notifications section renders a per-event × per-channel matrix sourced from `notify_event_keys`; defaults to enabled (opt-out model); writes via `/api/me/notification-subscriptions` (SUB-01..04)
5. `lib/services/pipeline-steps/notify.ts` honors an optional `route_to_user` block on each notify step — resolving the user via a registered resolver, checking the subscription matrix, sending via the personal channel, and falling back to the step's `channel_id` on no-channel/send-failure (recorded as `user_route_fallback`) but skipping silently when the user has the toggle muted (ROUTE-01..07)
-**Plans**: TBD
+**Plans**: 5 plans
+- [ ] 09-01-PLAN.md — Schema foundation: theme column, owner_user_id, notify_event_keys, user_event_subscriptions (THEME-01, THEME-05, CHAN-01, SUB-01, SUB-02)
+- [ ] 09-02-PLAN.md — /api/me/* endpoints: theme, channels (Teams + ntfy), notification-subscriptions matrix (THEME-02, CHAN-02..05, CHAN-07, SUB-04)
+- [ ] 09-03-PLAN.md — notify.ts route_to_user branch + resolver registry + fallback semantics (ROUTE-01..06)
+- [ ] 09-04-PLAN.md — /mobile/profile UI: 4 Cards + theme bridge + drawer link + ThemeToggle write-through (PROF-01..04, TZ-CHOOSER-01..02, THEME-03, THEME-04, SUB-03)
+- [ ] 09-05-PLAN.md — Admin surfaces: channels Owner column + filter, event-keys CRUD page, pipeline executions fallback filter (CHAN-06, ROUTE-07)
**UI hint**: yes
## Progress
@@ -207,7 +212,7 @@ Phases execute in numeric order. Phase 2 unblocks Phases 3–7 (any order, paral
| 7. Engagement Overview | 0/3 | Not started | - |
| 7.1. User Timezone Fix | 0/5 | Not started | - |
| 8. Engagement User Profile | 0/2 | Not started | - |
-| 9. User Profile & Preferences | 0/TBD | Not started | - |
+| 9. User Profile & Preferences | 0/5 | Not started | - |
---
*Roadmap created: 2026-05-03*
diff --git a/.planning/phases/09-user-profile-preferences-new/09-01-PLAN.md b/.planning/phases/09-user-profile-preferences-new/09-01-PLAN.md
new file mode 100644
index 0000000..707157b
--- /dev/null
+++ b/.planning/phases/09-user-profile-preferences-new/09-01-PLAN.md
@@ -0,0 +1,388 @@
+---
+phase: 09-user-profile-preferences-new
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - migrations/084_add_user_theme.sql
+ - migrations/085_personal_notification_channels.sql
+ - migrations/086_notify_event_keys_and_subscriptions.sql
+ - lib/auth.ts
+ - lib/types/pipeline.ts
+autonomous: true
+requirements: [THEME-01, THEME-05, CHAN-01, SUB-01, SUB-02]
+
+must_haves:
+ truths:
+ - "user table has theme TEXT column with default 'system' and existing rows backfilled to 'system'"
+ - "session.user.theme is exposed via Better Auth additionalFields the same way session.user.timezone is"
+ - "notification_channels has owner_user_id TEXT REFERENCES user(id) ON DELETE CASCADE column; existing rows have owner_user_id IS NULL (global)"
+ - "notify_event_keys table exists with admin-CRUD-able rows (key, display_label, description, sort_order, is_active)"
+ - "user_event_subscriptions table exists with composite PK (user_id, event_key, channel_type)"
+ artifacts:
+ - path: "migrations/084_add_user_theme.sql"
+ provides: "theme column on user with backfill"
+ contains: 'ALTER TABLE "user"'
+ - path: "migrations/085_personal_notification_channels.sql"
+ provides: "owner_user_id column on notification_channels"
+ contains: "ALTER TABLE notification_channels"
+ - path: "migrations/086_notify_event_keys_and_subscriptions.sql"
+ provides: "two new tables for event taxonomy and per-user subscriptions"
+ contains: "CREATE TABLE IF NOT EXISTS notify_event_keys"
+ - path: "lib/auth.ts"
+ provides: "theme additionalField"
+ contains: "theme:"
+ - path: "lib/types/pipeline.ts"
+ provides: "NotificationChannel.owner_user_id, NotifyEventKey, UserEventSubscription"
+ contains: "owner_user_id"
+ key_links:
+ - from: "migrations/084_add_user_theme.sql"
+ to: "user table"
+ via: "ALTER TABLE ADD COLUMN theme TEXT NOT NULL DEFAULT 'system'"
+ pattern: "DEFAULT 'system'"
+ - from: "lib/auth.ts additionalFields"
+ to: "session.user.theme"
+ via: "Better Auth field exposure"
+ pattern: "theme:.*type:.*string"
+---
+
+
+Land the foundational schema and types for Phase 9. This plan adds:
+1. `theme` column on `"user"` (Better Auth) + Better Auth `additionalFields` exposure (THEME-01, THEME-05)
+2. `owner_user_id` column on `notification_channels` (CHAN-01)
+3. New tables `notify_event_keys` and `user_event_subscriptions` (SUB-01, SUB-02)
+4. TypeScript type updates in `lib/types/pipeline.ts` so downstream code compiles against the new shapes
+
+No API or UI in this plan — just schema, type contracts, and the Better Auth field wiring so `session.user.theme` becomes available the same way `session.user.timezone` is today (Phase 7.1 precedent).
+
+Purpose: Every subsequent plan reads or writes one of these tables/columns, or imports one of these types. Putting all schema in Wave 1 unblocks Wave 2 (APIs) and Wave 3 (notify.ts + UI) to run in parallel.
+
+Output: Three new migrations, an extended `lib/auth.ts`, and an updated `lib/types/pipeline.ts`.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/phases/09-user-profile-preferences-new/09-CONTEXT.md
+@.planning/REQUIREMENTS.md
+@CLAUDE.md
+@migrations/083_add_user_timezone.sql
+@migrations/033_create_pipeline_engine_tables.sql
+@migrations/012_create_auth_tables.sql
+@lib/auth.ts
+@lib/types/pipeline.ts
+
+
+
+
+From lib/types/pipeline.ts (current — needs `owner_user_id`):
+```typescript
+export type ChannelType = 'teams' | 'telegram' | 'ntfy' | 'webhook';
+
+export interface NotificationChannel {
+ id: number;
+ name: string;
+ channel_type: ChannelType;
+ config: Record;
+ is_active: boolean;
+ created_at: Date;
+ updated_at: Date;
+}
+```
+
+From lib/auth.ts (current additionalFields):
+```typescript
+user: {
+ additionalFields: {
+ role: { type: "string", defaultValue: "user" },
+ requires_setup: { type: "boolean", defaultValue: false },
+ timezone: { type: "string", defaultValue: process.env.DEFAULT_TIMEZONE || "UTC" },
+ },
+},
+```
+
+From migrations/083_add_user_timezone.sql (precedent — mirror for theme):
+```sql
+ALTER TABLE "user"
+ ADD COLUMN IF NOT EXISTS timezone TEXT NOT NULL DEFAULT 'UTC';
+UPDATE "user" SET timezone = 'UTC' WHERE timezone IS NULL;
+COMMENT ON COLUMN "user".timezone IS '...';
+```
+
+
+
+
+
+
+ Task 1: Add theme column to user + Better Auth additionalField
+ migrations/084_add_user_theme.sql, lib/auth.ts
+
+
+ - migrations/083_add_user_timezone.sql (mirror this exact structure)
+ - migrations/012_create_auth_tables.sql (confirm "user" table is the Better Auth table and it uses snake_case-style quoted identifier)
+ - lib/auth.ts (current additionalFields block)
+ - .planning/phases/09-user-profile-preferences-new/09-CONTEXT.md (D-18, D-19 — column shape and default)
+
+
+
+ Step A — Create `migrations/084_add_user_theme.sql` with exactly:
+
+ ```sql
+ -- =============================================================================
+ -- Per-user theme preference (Phase 9 — THEME-01, THEME-05)
+ -- =============================================================================
+ -- Adds a `theme` column to the Better Auth "user" table so light/dark/system
+ -- preference persists server-side and applies on sign-in across devices.
+ --
+ -- Allowed values: 'light' | 'dark' | 'system' (validated in API layer).
+ -- Default 'system' means next-themes detects OS preference at render time —
+ -- zero behavior change for existing signed-in users.
+ -- =============================================================================
+
+ ALTER TABLE "user"
+ ADD COLUMN IF NOT EXISTS theme TEXT NOT NULL DEFAULT 'system';
+
+ -- Defensive backfill (DEFAULT covers new inserts, but on managed Postgres a
+ -- column added with DEFAULT may briefly show NULL in flight on some replicas).
+ UPDATE "user" SET theme = 'system' WHERE theme IS NULL;
+
+ COMMENT ON COLUMN "user".theme IS
+ 'User theme preference: light | dark | system. Applied app-wide via next-themes; server is canonical.';
+ ```
+
+ Step B — In `lib/auth.ts` add a `theme` entry to `user.additionalFields` immediately after the existing `timezone` entry:
+
+ ```typescript
+ timezone: {
+ type: "string",
+ defaultValue: process.env.DEFAULT_TIMEZONE || "UTC",
+ },
+ theme: {
+ type: "string",
+ defaultValue: "system",
+ },
+ ```
+
+ Do NOT change anything else in `lib/auth.ts`. The new field becomes available as `session.user.theme` on the next session refresh — same mechanism that exposed `session.user.timezone` in Phase 7.1.
+
+
+
+ grep -q "ADD COLUMN IF NOT EXISTS theme TEXT NOT NULL DEFAULT 'system'" migrations/084_add_user_theme.sql && grep -q 'theme:' lib/auth.ts && grep -q "defaultValue: \"system\"" lib/auth.ts && npx tsc --noEmit --pretty 2>&1 | grep -E "(auth.ts|error)" | head
+
+
+
+ - `migrations/084_add_user_theme.sql` exists
+ - File contains the exact string `ALTER TABLE "user"` (with double quotes around `user`)
+ - File contains `ADD COLUMN IF NOT EXISTS theme TEXT NOT NULL DEFAULT 'system'`
+ - File contains `UPDATE "user" SET theme = 'system' WHERE theme IS NULL`
+ - File contains a `COMMENT ON COLUMN "user".theme IS` line
+ - `lib/auth.ts` `additionalFields` block contains a `theme:` key with `defaultValue: "system"` and `type: "string"`
+ - `lib/auth.ts` still contains the existing `role`, `requires_setup`, and `timezone` keys (no entries removed)
+ - `npx tsc --noEmit --pretty` reports no new errors in `lib/auth.ts`
+
+
+
+ Migration file written with the exact ALTER + backfill + COMMENT structure of 083. `lib/auth.ts` exposes `theme` as a fourth additionalField. TypeScript compiles cleanly.
+
+
+
+
+ Task 2: Add owner_user_id to notification_channels + create event-keys / subscriptions tables
+ migrations/085_personal_notification_channels.sql, migrations/086_notify_event_keys_and_subscriptions.sql, lib/types/pipeline.ts
+
+
+ - migrations/033_create_pipeline_engine_tables.sql (current notification_channels schema)
+ - lib/types/pipeline.ts (current NotificationChannel interface — must extend, not replace)
+ - .planning/phases/09-user-profile-preferences-new/09-CONTEXT.md (D-03 owner_user_id, D-13 event keys, D-14 subscriptions, D-15 default-enabled)
+
+
+
+ Step A — Create `migrations/085_personal_notification_channels.sql`:
+
+ ```sql
+ -- =============================================================================
+ -- Personal notification channels (Phase 9 — CHAN-01)
+ -- =============================================================================
+ -- Adds owner_user_id to notification_channels so a row can be either:
+ -- - Global (owner_user_id IS NULL) — existing rows, admin-managed
+ -- - Personal (owner_user_id = "user".id) — created via /api/me/channels
+ --
+ -- Cascade delete: removing a Better Auth user removes their personal channels.
+ -- Multi-channel-per-type-per-user is forbidden by API-layer UPSERT logic
+ -- keyed by (owner_user_id, channel_type) — D-02 / CHAN-02. We deliberately
+ -- do NOT add a partial unique index so existing global rows (NULL owner) and
+ -- multiple users can coexist.
+ -- =============================================================================
+
+ ALTER TABLE notification_channels
+ ADD COLUMN IF NOT EXISTS owner_user_id TEXT
+ REFERENCES "user"(id) ON DELETE CASCADE;
+
+ CREATE INDEX IF NOT EXISTS idx_notification_channels_owner
+ ON notification_channels(owner_user_id, channel_type)
+ WHERE owner_user_id IS NOT NULL;
+
+ COMMENT ON COLUMN notification_channels.owner_user_id IS
+ 'Personal channel owner. NULL = global channel (admin-managed). NOT NULL = personal channel reachable only by the owner and admins.';
+ ```
+
+ Step B — Create `migrations/086_notify_event_keys_and_subscriptions.sql`:
+
+ ```sql
+ -- =============================================================================
+ -- Notify event taxonomy + per-user subscriptions (Phase 9 — SUB-01, SUB-02)
+ -- =============================================================================
+ -- notify_event_keys: humanization layer for the event keys that appear in
+ -- pipeline_steps.config->'route_to_user'->>'event_key'. Admin-managed at
+ -- /admin/workflow/event-keys. NOT a gate — keys not in this table are
+ -- still routable; they just render with the raw key as their label.
+ --
+ -- user_event_subscriptions: opt-out matrix per (user, event_key, channel_type).
+ -- Row absence = enabled (D-15 default-enabled / opt-out).
+ -- =============================================================================
+
+ CREATE TABLE IF NOT EXISTS notify_event_keys (
+ key TEXT PRIMARY KEY,
+ display_label TEXT NOT NULL,
+ description TEXT,
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ is_active BOOLEAN NOT NULL DEFAULT true,
+ created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMP NOT NULL DEFAULT NOW()
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_notify_event_keys_active
+ ON notify_event_keys(is_active, sort_order, key);
+
+ CREATE TABLE IF NOT EXISTS user_event_subscriptions (
+ user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
+ event_key TEXT NOT NULL,
+ channel_type VARCHAR(20) NOT NULL,
+ enabled BOOLEAN NOT NULL DEFAULT true,
+ updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
+ PRIMARY KEY (user_id, event_key, channel_type)
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_user_event_subs_user
+ ON user_event_subscriptions(user_id);
+
+ COMMENT ON TABLE notify_event_keys IS
+ 'Humanization layer for event keys used in notify-step route_to_user blocks. Admins curate display_label/description/sort. Not a gate — unknown keys are still routable.';
+ COMMENT ON TABLE user_event_subscriptions IS
+ 'Opt-out matrix. Row absence = enabled. Composite PK enforces one row per (user, event_key, channel_type).';
+
+ -- Seed an example row so the admin UI is not empty on first run.
+ INSERT INTO notify_event_keys (key, display_label, description, sort_order, is_active)
+ VALUES ('ticket_assigned_to_me', 'Ticket assigned to me', 'Fires when an Autotask ticket is assigned to your resource.', 10, true)
+ ON CONFLICT (key) DO NOTHING;
+ ```
+
+ Step C — In `lib/types/pipeline.ts`:
+
+ 1. Add `owner_user_id: string | null;` to the `NotificationChannel` interface, immediately after the `is_active` line.
+ 2. Append two new exported interfaces at the bottom of the file (after `NotificationChannelInput`):
+
+ ```typescript
+ // ============================================================================
+ // Phase 9 — Event taxonomy + user subscriptions
+ // ============================================================================
+
+ export interface NotifyEventKey {
+ key: string;
+ display_label: string;
+ description: string | null;
+ sort_order: number;
+ is_active: boolean;
+ created_at: Date;
+ updated_at: Date;
+ }
+
+ export interface UserEventSubscription {
+ user_id: string;
+ event_key: string;
+ channel_type: ChannelType;
+ enabled: boolean;
+ updated_at: Date;
+ }
+ ```
+
+ Do NOT touch any other type in this file. Do NOT export a barrel index.
+
+
+
+ grep -q "ADD COLUMN IF NOT EXISTS owner_user_id" migrations/085_personal_notification_channels.sql && grep -q "CREATE TABLE IF NOT EXISTS notify_event_keys" migrations/086_notify_event_keys_and_subscriptions.sql && grep -q "CREATE TABLE IF NOT EXISTS user_event_subscriptions" migrations/086_notify_event_keys_and_subscriptions.sql && grep -q "owner_user_id: string | null" lib/types/pipeline.ts && grep -q "export interface NotifyEventKey" lib/types/pipeline.ts && grep -q "export interface UserEventSubscription" lib/types/pipeline.ts && npx tsc --noEmit --pretty 2>&1 | head
+
+
+
+ - `migrations/085_personal_notification_channels.sql` exists and contains `ALTER TABLE notification_channels`
+ - File contains `ADD COLUMN IF NOT EXISTS owner_user_id TEXT` and `REFERENCES "user"(id) ON DELETE CASCADE`
+ - File contains `CREATE INDEX IF NOT EXISTS idx_notification_channels_owner`
+ - `migrations/086_notify_event_keys_and_subscriptions.sql` exists
+ - File contains `CREATE TABLE IF NOT EXISTS notify_event_keys` with `key TEXT PRIMARY KEY`
+ - File contains `CREATE TABLE IF NOT EXISTS user_event_subscriptions` with `PRIMARY KEY (user_id, event_key, channel_type)`
+ - File contains `INSERT INTO notify_event_keys` ... `ON CONFLICT (key) DO NOTHING` (the seed row)
+ - `lib/types/pipeline.ts` `NotificationChannel` interface contains the line `owner_user_id: string | null;`
+ - `lib/types/pipeline.ts` exports `NotifyEventKey` interface with fields `key`, `display_label`, `description`, `sort_order`, `is_active`, `created_at`, `updated_at`
+ - `lib/types/pipeline.ts` exports `UserEventSubscription` interface with composite-PK fields and `enabled: boolean`
+ - `npx tsc --noEmit --pretty` reports no new errors
+
+
+
+ Two new migrations land at the end of the migration sequence (head moves 083 → 086). `lib/types/pipeline.ts` types match the new schema. TypeScript compiles cleanly.
+
+
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| Browser → Postgres (via Next API) | All changes here go through schema only; no untrusted input is processed in this plan |
+| Future API routes → user table | New columns on `"user"` will be writable only via authenticated `/api/me/*` routes (next plan) |
+
+## STRIDE Threat Register (ASVS L1)
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-09-01-01 | Tampering | `migrations/084_add_user_theme.sql` | mitigate | `theme TEXT NOT NULL DEFAULT 'system'` enforces the column is always populated; downstream API layer (Plan 02) validates against three-string allowlist before write — schema cannot be bypassed by the application layer |
+| T-09-01-02 | Information Disclosure | `notification_channels.owner_user_id` | mitigate | Cascade delete on user removal ensures personal channels (which contain webhook URLs / ntfy topics — secrets) cannot outlive their owner; queries in Plan 02 filter by `owner_user_id = session.user.id OR owner_user_id IS NULL`. Storage at rest stays plaintext (matches existing `notification_channels.config` for global rows — same precedent) |
+| T-09-01-03 | Elevation of Privilege | `user_event_subscriptions` PK | mitigate | Composite primary key `(user_id, event_key, channel_type)` makes mass-assignment impossible: no surrogate ID, no nullable user_id. API writes (Plan 02) target `session.user.id` only |
+| T-09-01-04 | Denial of Service | `notify_event_keys` | accept | Admin-only CRUD (Plan 05); table is small (one row per event key, expected < 50 rows). No rate-limiting needed — admin role is already trusted |
+| T-09-01-05 | Spoofing | `lib/auth.ts theme additionalField` | mitigate | Better Auth signs the session cookie; `session.user.theme` is read from server-side session lookup (cookieCache 5 min). Client cannot tamper with the value — they can only request a write via authenticated `PUT /api/me/theme` (Plan 02) which validates against allowlist |
+
+No `high` severity threats. ASVS L1 satisfied: V8.3.4 (sensitive data lifecycle — cascade delete), V4.2.1 (mass-assignment prevention via composite PK), V5.1.3 (input validation deferred to Plan 02 API).
+
+
+
+
+- All three migrations land at sequential numbers 084, 085, 086 (head was 083).
+- `lib/auth.ts` `additionalFields` has exactly four entries: `role`, `requires_setup`, `timezone`, `theme`.
+- `lib/types/pipeline.ts` compiles and exports the two new interfaces.
+- No production code reads from the new tables yet — that lands in Plan 02.
+- Migration files use `IF NOT EXISTS` everywhere (idempotent re-runs).
+- Backfill semantics match Phase 7.1 precedent (`UPDATE ... WHERE col IS NULL`).
+
+
+
+1. `git status` shows three new files in `migrations/` plus modifications to `lib/auth.ts` and `lib/types/pipeline.ts`.
+2. `npx tsc --noEmit --pretty` exits 0.
+3. `grep -c "additionalFields" lib/auth.ts` returns 1; the block contains a line matching `theme:` with `defaultValue: "system"`.
+4. The three migrations are syntactically valid SQL and use `IF NOT EXISTS` on every CREATE / ADD COLUMN.
+5. Plan 02 (Wave 2) and Plan 03 (Wave 2) can both import the new types without further schema changes.
+
+
+
diff --git a/.planning/phases/09-user-profile-preferences-new/09-02-PLAN.md b/.planning/phases/09-user-profile-preferences-new/09-02-PLAN.md
new file mode 100644
index 0000000..6c4aadb
--- /dev/null
+++ b/.planning/phases/09-user-profile-preferences-new/09-02-PLAN.md
@@ -0,0 +1,574 @@
+---
+phase: 09-user-profile-preferences-new
+plan: 02
+type: execute
+wave: 2
+depends_on: [09-01]
+files_modified:
+ - 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
+autonomous: true
+requirements: [THEME-02, CHAN-02, CHAN-03, CHAN-04, CHAN-05, CHAN-07, SUB-04]
+
+must_haves:
+ truths:
+ - "Authenticated GET /api/me/theme returns { theme, source } and PUT /api/me/theme accepts { theme: 'light'|'dark'|'system' } and writes session.user.id only"
+ - "Authenticated GET /api/me/channels returns the calling user's Teams + ntfy personal channels"
+ - "PUT /api/me/channels/teams accepts { webhook_url } validated as https://*.webhook.office.com OR https://*.logic.azure.com, UPSERTs keyed by (owner_user_id, channel_type='teams'), and issues a best-effort test send returning the test result inline"
+ - "PUT /api/me/channels/ntfy mints a UUID-prefixed topic on first save (or accepts a custom topic when explicitly supplied), UPSERTs keyed by (owner_user_id, channel_type='ntfy'), and issues a best-effort test send"
+ - "DELETE /api/me/channels/{type} removes only the calling user's row of that channel type"
+ - "POST /api/me/channels/{type}/test issues a test send to the calling user's existing channel row"
+ - "GET /api/me/notification-subscriptions returns { event_keys, channels, matrix } where matrix[event_key][channel_type] = enabled boolean (defaults to true when no row exists)"
+ - "PUT /api/me/notification-subscriptions accepts { event_key, channel_type, enabled } and UPSERTs a single row keyed by session.user.id"
+ artifacts:
+ - path: "app/api/me/theme/route.ts"
+ provides: "GET + PUT theme endpoints"
+ exports: ["GET", "PUT"]
+ - path: "app/api/me/channels/route.ts"
+ provides: "GET aggregate of user's personal channels"
+ exports: ["GET"]
+ - path: "app/api/me/channels/[type]/route.ts"
+ provides: "PUT (upsert) and DELETE per channel type"
+ exports: ["PUT", "DELETE"]
+ - path: "app/api/me/channels/[type]/test/route.ts"
+ provides: "POST: re-send test message to existing channel"
+ exports: ["POST"]
+ - path: "app/api/me/notification-subscriptions/route.ts"
+ provides: "GET full matrix + PUT single row"
+ exports: ["GET", "PUT"]
+ - path: "lib/services/personal-channels.ts"
+ provides: "shared validation + test-send helpers; sendChannelTest(channel)"
+ exports: ["isValidTeamsWebhookUrl", "mintNtfyTopic", "sendChannelTest", "TEST_MESSAGE_BODY"]
+ key_links:
+ - from: "PUT /api/me/channels/teams"
+ to: "notification_channels"
+ via: "INSERT ... ON CONFLICT (owner_user_id, channel_type) DO UPDATE — guarded by application-level pre-check, see action"
+ pattern: "owner_user_id"
+ - from: "PUT /api/me/channels/ntfy"
+ to: "lib/services/personal-channels.ts mintNtfyTopic"
+ via: "topic generated server-side using crypto.randomUUID()"
+ pattern: "pulse-"
+ - from: "GET /api/me/notification-subscriptions"
+ to: "notify_event_keys + user_event_subscriptions"
+ via: "LEFT JOIN with default-enabled fallback"
+ pattern: "LEFT JOIN user_event_subscriptions"
+---
+
+
+Build the per-user API surface for Phase 9. All routes mirror the conventions already
+established by `/api/me/timezone`: `requireAuth()`, write target is always
+`session.user.id`, manual snake_case → camelCase transform, no Zod, explicit validation.
+
+Three route families:
+1. **`/api/me/theme`** (THEME-02) — GET returns `{ theme, source }`, PUT validates against allowlist
+2. **`/api/me/channels` family** (CHAN-02..05, CHAN-07) — GET aggregate, PUT/DELETE per type, POST :test
+3. **`/api/me/notification-subscriptions`** (SUB-04) — GET full matrix, PUT a single row
+
+Plus `lib/services/personal-channels.ts` to share Teams URL validation, ntfy topic
+minting, and the test-send helper across the routes (and reuse in Plan 03 / Plan 05).
+
+Purpose: This plan does NOT touch UI, drawer, theme bridge, notify.ts, or admin
+surfaces. Those land in Plans 03/04/05. Once these endpoints exist, the mobile profile
+page (Plan 04) becomes a pure rendering exercise.
+
+Output: 5 new route files + 1 new service helper. No DB migrations (those landed in
+Plan 01).
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/phases/09-user-profile-preferences-new/09-CONTEXT.md
+@.planning/REQUIREMENTS.md
+@CLAUDE.md
+@app/api/me/timezone/route.ts
+@app/api/notification-channels/route.ts
+@app/api/notification-channels/[id]/test/route.ts
+@lib/services/pipeline-steps/notify.ts
+@lib/types/pipeline.ts
+
+
+
+
+From lib/auth-utils.ts:
+```typescript
+export async function requireAuth(): Promise<{ session: Session | null; error: NextResponse | null }>;
+// On error: caller should return error directly. On success, session.user.id is the write target.
+```
+
+From lib/services/postgres-client.ts:
+```typescript
+postgresClient.query(sql: string, params: any[]): Promise<{ rows: T[]; rowCount: number }>;
+postgresClient.transaction(fn: (client) => Promise): Promise;
+```
+
+From lib/types/pipeline.ts (already updated in Plan 01):
+```typescript
+export type ChannelType = 'teams' | 'telegram' | 'ntfy' | 'webhook';
+export interface NotificationChannel {
+ id: number;
+ name: string;
+ channel_type: ChannelType;
+ config: Record;
+ is_active: boolean;
+ owner_user_id: string | null;
+ created_at: Date;
+ updated_at: Date;
+}
+export interface NotifyEventKey { key: string; display_label: string; description: string | null; sort_order: number; is_active: boolean; created_at: Date; updated_at: Date; }
+export interface UserEventSubscription { user_id: string; event_key: string; channel_type: ChannelType; enabled: boolean; updated_at: Date; }
+```
+
+Reference shape for /api/me/timezone (mirror this convention exactly):
+- requireAuth() guard at top
+- GET returns `{ value, source: 'user' | 'default' }`
+- PUT validates input shape, then validates value against allowlist, then UPDATEs WHERE id = session.user.id
+- All errors caught, console.error + 500 NextResponse.json({ error, message }, { status })
+
+
+
+
+
+
+ Task 1: GET + PUT /api/me/theme
+ app/api/me/theme/route.ts
+
+
+ - app/api/me/timezone/route.ts (mirror exactly)
+ - lib/auth-utils.ts (requireAuth signature)
+ - .planning/phases/09-user-profile-preferences-new/09-CONTEXT.md (D-18, D-19 — theme allowlist + default 'system')
+
+
+
+ Create `app/api/me/theme/route.ts` with:
+
+ 1. Top-of-file comment block matching the `/api/me/timezone` style (THEME-02 reference, "writes session.user.id only", validation note).
+ 2. Constant: `const ALLOWED_THEMES = new Set(['light', 'dark', 'system'] as const);`
+ 3. Helper: `function isValidTheme(t: unknown): t is 'light' | 'dark' | 'system'` — returns true iff `typeof t === 'string'` and `ALLOWED_THEMES.has(t)`.
+ 4. `export async function GET(): Promise`:
+ - `const { session, error } = await requireAuth(); if (error) return error;`
+ - Query: `SELECT theme FROM "user" WHERE id = $1` with `session!.user.id`
+ - Compute `source`: `'user'` when `stored && stored !== 'system'`, else `'default'` (matches the `/api/me/timezone` semantic where `source = 'default'` when value equals fallback).
+ - Return `NextResponse.json({ theme: stored ?? 'system', source })`
+ - Wrap in try/catch; on error `console.error('GET /api/me/theme failed:', e)` and return 500.
+ 5. `export async function PUT(request: NextRequest): Promise`:
+ - requireAuth guard
+ - Parse `body = await request.json()` inside try/catch — on parse failure return 400 `{ error: 'Invalid JSON', message: 'Request body must be JSON' }`
+ - Extract `candidate = body.theme`
+ - If `!isValidTheme(candidate)` return 400 `{ error: 'Invalid theme', message: "theme must be one of: light, dark, system" }`
+ - `UPDATE "user" SET theme = $1, "updatedAt" = NOW() WHERE id = $2 RETURNING theme` with `[candidate, session!.user.id]`
+ - If `rowCount === 0` return 404 `{ error: 'User not found', message: 'No user row matched the session' }`
+ - Return `NextResponse.json({ theme: result.rows[0].theme })`
+ - try/catch with `console.error('PUT /api/me/theme failed:', e)` + 500
+
+
+
+ test -f app/api/me/theme/route.ts && grep -q "export async function GET" app/api/me/theme/route.ts && grep -q "export async function PUT" app/api/me/theme/route.ts && grep -q "ALLOWED_THEMES" app/api/me/theme/route.ts && grep -q "requireAuth" app/api/me/theme/route.ts && npx tsc --noEmit --pretty 2>&1 | grep "theme/route.ts" | head
+
+
+
+ - `app/api/me/theme/route.ts` exists
+ - Contains `import { requireAuth } from '@/lib/auth-utils';`
+ - Contains `import { postgresClient } from '@/lib/services/postgres-client';`
+ - Contains `export async function GET(`
+ - Contains `export async function PUT(`
+ - Contains the literal string `'light'`, `'dark'`, and `'system'` (the three allowed values)
+ - Contains an SQL string starting with `UPDATE "user" SET theme = $1` and ending in `WHERE id = $2 RETURNING theme`
+ - Contains `session!.user.id` (the write target, never from body)
+ - Does NOT contain a `userId` extraction from `body` or `searchParams`
+ - `npx tsc --noEmit --pretty` reports no errors in this file
+
+
+
+ Theme GET/PUT route lives at `/api/me/theme`, gated by `requireAuth()`, writes only session.user.id, validates against the three-string allowlist. Mirrors `/api/me/timezone` shape line-for-line modulo the validation function.
+
+
+
+
+ Task 2: Personal channels service + /api/me/channels routes
+ 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
+
+
+ - lib/services/pipeline-steps/notify.ts (existing send shapes for teams + ntfy — reuse the same fetch contracts)
+ - app/api/notification-channels/[id]/test/route.ts (existing global-channel test send pattern)
+ - app/api/me/timezone/route.ts (per-user route conventions)
+ - .planning/phases/09-user-profile-preferences-new/09-CONTEXT.md (D-02 singular per-type, D-03 owner_user_id, D-04 mint UUID-prefixed ntfy, D-05 Teams URL validation, D-06 best-effort test on save)
+ - .planning/phases/09-user-profile-preferences-new/09-UI-SPEC.md ("Test message body" copy: "Pulse channel verified — you can ignore this message.")
+ - lib/types/pipeline.ts (NotificationChannel, ChannelType — already updated in Plan 01)
+
+
+
+ Step A — Create `lib/services/personal-channels.ts` with:
+
+ ```typescript
+ import { randomUUID } from 'crypto';
+ import { NotificationChannel, ChannelType } from '@/lib/types/pipeline';
+
+ /** Test-send copy used by every personal-channel save and POST :test. */
+ export const TEST_MESSAGE_BODY =
+ 'Pulse channel verified — you can ignore this message.';
+
+ /** Allowed Teams webhook hosts (CHAN-04 / D-05). */
+ const TEAMS_HOST_PATTERNS = [
+ /^[a-z0-9-]+\.webhook\.office\.com$/i,
+ /^[a-z0-9-]+\.logic\.azure\.com$/i,
+ /^[a-z0-9-]+\.[a-z]+\.logic\.azure\.com$/i, // regional subdomains, e.g. prod-12.eastus.logic.azure.com
+ ];
+
+ export function isValidTeamsWebhookUrl(input: unknown): input is string {
+ if (typeof input !== 'string' || input.length === 0 || input.length > 2048) return false;
+ try {
+ const u = new URL(input);
+ if (u.protocol !== 'https:') return false;
+ return TEAMS_HOST_PATTERNS.some((re) => re.test(u.hostname));
+ } catch {
+ return false;
+ }
+ }
+
+ /** Mint a Pulse-namespaced ntfy topic (CHAN-03 / D-04). */
+ export function mintNtfyTopic(): string {
+ // 8-hex prefix is enough collision-resistance for ntfy public tier.
+ const id = randomUUID().replace(/-/g, '').slice(0, 8);
+ return `pulse-${id}`;
+ }
+
+ export type ChannelTestResult =
+ | { ok: true }
+ | { ok: false; status?: number; error: string };
+
+ /**
+ * Issue a single best-effort test send to a personal channel. Mirrors the
+ * existing app/api/notification-channels/[id]/test/route.ts flow but with the
+ * Phase 9 test-message body.
+ */
+ export async function sendChannelTest(channel: NotificationChannel): Promise {
+ try {
+ switch (channel.channel_type) {
+ case 'teams': {
+ const url = channel.config.webhook_url;
+ if (!url) return { ok: false, error: 'Teams channel missing webhook_url' };
+ const card = {
+ type: 'message',
+ attachments: [{
+ contentType: 'application/vnd.microsoft.card.adaptive',
+ content: {
+ type: 'AdaptiveCard',
+ $schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
+ version: '1.4',
+ body: [
+ { type: 'TextBlock', text: 'Pulse channel verified', weight: 'bolder', size: 'medium' },
+ { type: 'TextBlock', text: TEST_MESSAGE_BODY, wrap: true },
+ ],
+ },
+ }],
+ };
+ const resp = await fetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(card),
+ });
+ if (!resp.ok) return { ok: false, status: resp.status, error: (await resp.text()).slice(0, 200) };
+ return { ok: true };
+ }
+ case 'ntfy': {
+ const serverUrl = channel.config.server_url || 'https://ntfy.sh';
+ const topic = channel.config.topic;
+ if (!topic) return { ok: false, error: 'ntfy channel missing topic' };
+ const headers: Record = {
+ 'Content-Type': 'text/plain',
+ 'Title': 'Pulse channel verified',
+ };
+ if (channel.config.auth_token) headers['Authorization'] = `Bearer ${channel.config.auth_token}`;
+ const resp = await fetch(`${serverUrl}/${topic}`, {
+ method: 'POST',
+ headers,
+ body: TEST_MESSAGE_BODY,
+ });
+ if (!resp.ok) return { ok: false, status: resp.status, error: (await resp.text()).slice(0, 200) };
+ return { ok: true };
+ }
+ default:
+ return { ok: false, error: `Unsupported personal channel type: ${channel.channel_type}` };
+ }
+ } catch (e) {
+ return { ok: false, error: e instanceof Error ? e.message : 'unknown error' };
+ }
+ }
+
+ /** ChannelType values that personal channels may take in Phase 9 (D-02). */
+ export const PERSONAL_CHANNEL_TYPES: ChannelType[] = ['teams', 'ntfy'];
+
+ export function isPersonalChannelType(t: unknown): t is 'teams' | 'ntfy' {
+ return t === 'teams' || t === 'ntfy';
+ }
+ ```
+
+ Step B — Create `app/api/me/channels/route.ts`:
+
+ ```typescript
+ // GET /api/me/channels
+ // Returns the calling user's personal channels (Teams + ntfy), camelCase.
+ // Auth: requireAuth(). Reads only owner_user_id = session.user.id.
+
+ import { NextResponse } from 'next/server';
+ import { requireAuth } from '@/lib/auth-utils';
+ import { postgresClient } from '@/lib/services/postgres-client';
+ import { NotificationChannel } from '@/lib/types/pipeline';
+
+ export async function GET(): Promise {
+ const { session, error } = await requireAuth();
+ if (error) return error;
+
+ try {
+ const result = await postgresClient.query(
+ `SELECT id, name, channel_type, config, is_active, owner_user_id, created_at, updated_at
+ FROM notification_channels
+ WHERE owner_user_id = $1
+ ORDER BY channel_type ASC`,
+ [session!.user.id],
+ );
+ // camelCase transform
+ const channels = result.rows.map((r) => ({
+ id: r.id,
+ name: r.name,
+ channelType: r.channel_type,
+ config: r.config,
+ isActive: r.is_active,
+ ownerUserId: r.owner_user_id,
+ createdAt: r.created_at,
+ updatedAt: r.updated_at,
+ }));
+ return NextResponse.json({ channels });
+ } catch (e) {
+ console.error('GET /api/me/channels failed:', e);
+ return NextResponse.json(
+ { error: 'Failed to read channels', message: e instanceof Error ? e.message : 'unknown' },
+ { status: 500 },
+ );
+ }
+ }
+ ```
+
+ Step C — Create `app/api/me/channels/[type]/route.ts` with PUT and DELETE:
+
+ PUT semantics:
+ - `requireAuth()` guard.
+ - `const { type } = await params; if (!isPersonalChannelType(type)) return 400 'Unsupported channel type'`
+ - Parse JSON body
+ - For `type === 'teams'`:
+ - Extract `webhook_url = body.webhook_url`
+ - If `!isValidTeamsWebhookUrl(webhook_url)` return 400 with message `"webhook_url must be https://*.webhook.office.com or https://*.logic.azure.com"`
+ - Build `config = { webhook_url }`
+ - Build `name = "Personal Teams (" + session!.user.email + ")"`
+ - For `type === 'ntfy'`:
+ - `customTopic = typeof body.topic === 'string' ? body.topic : null`
+ - If `customTopic` provided: validate format (must match `/^[A-Za-z0-9_-]{6,64}$/`); reject with 400 otherwise.
+ - If no customTopic: `topic = mintNtfyTopic()`
+ - `config = { server_url: 'https://ntfy.sh', topic }`
+ - `name = "Personal ntfy (" + session!.user.email + ")"`
+ - **UPSERT** in a single SQL statement, scoped to the calling user (D-02 / CHAN-02 — API-layer enforcement, not partial unique index):
+ ```sql
+ WITH existing AS (
+ SELECT id FROM notification_channels
+ WHERE owner_user_id = $1 AND channel_type = $2
+ LIMIT 1
+ ),
+ updated AS (
+ UPDATE notification_channels
+ SET name = $3, config = $4, is_active = true, updated_at = NOW()
+ WHERE id = (SELECT id FROM existing)
+ RETURNING *
+ ),
+ inserted AS (
+ INSERT INTO notification_channels (name, channel_type, config, is_active, owner_user_id)
+ SELECT $3, $2, $4, true, $1
+ WHERE NOT EXISTS (SELECT 1 FROM existing)
+ RETURNING *
+ )
+ SELECT * FROM updated UNION ALL SELECT * FROM inserted;
+ ```
+ Bind params: `[session!.user.id, type, name, JSON.stringify(config)]`.
+ - After upsert, fire `await sendChannelTest(row)` (best-effort — do NOT bubble errors).
+ - Return `NextResponse.json({ channel: , test: })`.
+
+ DELETE semantics:
+ - `requireAuth()`, validate `type`
+ - `DELETE FROM notification_channels WHERE owner_user_id = $1 AND channel_type = $2 RETURNING id` with `[session!.user.id, type]`
+ - 404 if no rows; 200 `{ deleted: true, channelType: type }` otherwise.
+
+ Step D — Create `app/api/me/channels/[type]/test/route.ts`:
+ - `export async function POST(_req: NextRequest, { params }: { params: Promise<{ type: string }> })`
+ - `requireAuth()`, validate type
+ - `SELECT * FROM notification_channels WHERE owner_user_id = $1 AND channel_type = $2`
+ - 404 if missing
+ - `const result = await sendChannelTest(channel)`
+ - Return `NextResponse.json({ test: result })` (200 even on test failure — mirrors save semantics in Step C)
+
+
+
+ test -f lib/services/personal-channels.ts && test -f app/api/me/channels/route.ts && test -f app/api/me/channels/[type]/route.ts && test -f app/api/me/channels/[type]/test/route.ts && grep -q "isValidTeamsWebhookUrl" lib/services/personal-channels.ts && grep -q "mintNtfyTopic" lib/services/personal-channels.ts && grep -q "TEST_MESSAGE_BODY" lib/services/personal-channels.ts && grep -q "Pulse channel verified — you can ignore this message" lib/services/personal-channels.ts && grep -q "owner_user_id = \$1" "app/api/me/channels/[type]/route.ts" && npx tsc --noEmit --pretty 2>&1 | head -20
+
+
+
+ - `lib/services/personal-channels.ts` exports `isValidTeamsWebhookUrl`, `mintNtfyTopic`, `sendChannelTest`, `TEST_MESSAGE_BODY`, `isPersonalChannelType`, `PERSONAL_CHANNEL_TYPES`
+ - `TEST_MESSAGE_BODY` value is exactly `'Pulse channel verified — you can ignore this message.'`
+ - `mintNtfyTopic()` returns a string starting with `pulse-` and containing 8 hex chars (no UUID dashes)
+ - `isValidTeamsWebhookUrl` returns `false` for `'http://foo.webhook.office.com/x'`, `'https://evil.com'`, `''`; returns `true` for `'https://yourorg.webhook.office.com/abc'` and `'https://prod-12.eastus.logic.azure.com/foo'`
+ - `app/api/me/channels/route.ts` `GET` queries `WHERE owner_user_id = $1` with `session!.user.id`
+ - `app/api/me/channels/[type]/route.ts` `PUT` calls `isValidTeamsWebhookUrl` for the `'teams'` branch and `mintNtfyTopic()` for the `'ntfy'` branch when no custom topic is supplied
+ - `app/api/me/channels/[type]/route.ts` `PUT` UPSERT SQL includes the literal substring `WHERE owner_user_id = $1 AND channel_type = $2` (the lookup arm of the WITH-CTE)
+ - `app/api/me/channels/[type]/route.ts` `DELETE` SQL is exactly `DELETE FROM notification_channels WHERE owner_user_id = $1 AND channel_type = $2 RETURNING id`
+ - `app/api/me/channels/[type]/test/route.ts` calls `sendChannelTest(channel)` and returns `{ test: result }`
+ - No route accepts a `userId`, `user_id`, or `owner_user_id` from request body or query — all writes scope to `session!.user.id`
+ - `npx tsc --noEmit --pretty` reports no errors in any of these files
+
+
+
+ The complete `/api/me/channels` family exists, gated by requireAuth, with API-layer UPSERT semantics enforcing one-channel-per-type per user (D-02). Test sends are best-effort and never block save success. The shared service file is reusable from the admin extension in Plan 05.
+
+
+
+
+ Task 3: GET + PUT /api/me/notification-subscriptions (matrix endpoint)
+ app/api/me/notification-subscriptions/route.ts
+
+
+ - app/api/me/timezone/route.ts (per-user route conventions)
+ - lib/types/pipeline.ts (UserEventSubscription, NotifyEventKey, ChannelType — Plan 01)
+ - .planning/phases/09-user-profile-preferences-new/09-CONTEXT.md (D-14 matrix shape, D-15 default-enabled, SUB-04)
+ - lib/services/personal-channels.ts (isPersonalChannelType — Task 2 of this plan)
+
+
+
+ Create `app/api/me/notification-subscriptions/route.ts`.
+
+ **GET shape:**
+
+ ```jsonc
+ {
+ "eventKeys": [
+ { "key": "ticket_assigned_to_me", "displayLabel": "Ticket assigned to me", "description": "...", "sortOrder": 10 }
+ ],
+ "channelTypes": ["teams", "ntfy"], // only types the user has personal channels configured for
+ "matrix": {
+ "ticket_assigned_to_me": { "teams": true, "ntfy": false }
+ }
+ }
+ ```
+
+ Implementation:
+ 1. `requireAuth()` guard.
+ 2. Three queries (one transaction NOT required — read-only):
+ a. `SELECT key, display_label, description, sort_order FROM notify_event_keys WHERE is_active = true ORDER BY sort_order ASC, key ASC`
+ b. `SELECT channel_type FROM notification_channels WHERE owner_user_id = $1 AND is_active = true ORDER BY channel_type ASC` with `[session!.user.id]`
+ c. `SELECT event_key, channel_type, enabled FROM user_event_subscriptions WHERE user_id = $1` with `[session!.user.id]`
+ 3. Build `channelTypes = result_b.rows.map(r => r.channel_type)`.
+ 4. Build `matrix: Record>`:
+ - For every active event key × every configured channelType: default to `true` (D-15).
+ - Override with stored rows from query (c): `matrix[event_key][channel_type] = enabled`.
+ 5. camelCase transform of eventKeys (`key`, `displayLabel`, `description`, `sortOrder`).
+ 6. Return JSON.
+
+ **PUT shape:**
+ - Body: `{ event_key: string, channel_type: 'teams' | 'ntfy', enabled: boolean }`
+ - Validation:
+ - `event_key` must be a non-empty string ≤ 128 chars
+ - `channel_type` must pass `isPersonalChannelType` (imported from `lib/services/personal-channels`)
+ - `enabled` must be `boolean` (use `typeof === 'boolean'`)
+ - UPSERT:
+ ```sql
+ INSERT INTO user_event_subscriptions (user_id, event_key, channel_type, enabled, updated_at)
+ VALUES ($1, $2, $3, $4, NOW())
+ ON CONFLICT (user_id, event_key, channel_type) DO UPDATE
+ SET enabled = EXCLUDED.enabled, updated_at = NOW()
+ RETURNING user_id, event_key, channel_type, enabled, updated_at;
+ ```
+ Bind: `[session!.user.id, event_key, channel_type, enabled]`.
+ - Return `NextResponse.json({ subscription: { eventKey, channelType, enabled } })`.
+ - Standard try/catch + `console.error('PUT /api/me/notification-subscriptions failed:', e)` + 500 fallthrough.
+
+
+
+ test -f app/api/me/notification-subscriptions/route.ts && grep -q "export async function GET" app/api/me/notification-subscriptions/route.ts && grep -q "export async function PUT" app/api/me/notification-subscriptions/route.ts && grep -q "user_event_subscriptions" app/api/me/notification-subscriptions/route.ts && grep -q "ON CONFLICT (user_id, event_key, channel_type)" app/api/me/notification-subscriptions/route.ts && grep -q "isPersonalChannelType" app/api/me/notification-subscriptions/route.ts && npx tsc --noEmit --pretty 2>&1 | grep -E "(notification-subscriptions|error)" | head
+
+
+
+ - `app/api/me/notification-subscriptions/route.ts` exists and exports `GET` + `PUT`
+ - GET issues at minimum the SELECT queries against `notify_event_keys` (filtered `is_active = true`), `notification_channels` (filtered `owner_user_id = session.user.id`), and `user_event_subscriptions` (filtered `user_id = session.user.id`)
+ - GET response JSON contains a top-level `matrix` object with event_key → channel_type → boolean shape
+ - GET defaults missing matrix cells to `true` (verifiable: search code for the literal value `true` in the matrix-building loop)
+ - PUT contains `ON CONFLICT (user_id, event_key, channel_type) DO UPDATE`
+ - PUT writes `user_id = session!.user.id` (verified: route does NOT extract user_id from body or params)
+ - PUT validates `channel_type` via `isPersonalChannelType` (imported from `@/lib/services/personal-channels`)
+ - PUT rejects `enabled` that is not a boolean with HTTP 400
+ - `npx tsc --noEmit --pretty` reports no errors in this file
+
+
+
+ Subscription matrix endpoint exists with the canonical default-enabled fallback (D-15), singular row writes via composite-PK UPSERT. UI in Plan 04 reads the GET shape and writes single rows on Switch toggle.
+
+
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| Browser → /api/me/* | Untrusted JSON crosses here; auth via signed Better Auth cookie |
+| Server → external webhooks (Teams) | User-supplied URL is dispatched to Teams hosts |
+| Server → ntfy.sh | Pulse-minted topic is published to public ntfy.sh |
+
+## STRIDE Threat Register (ASVS L1)
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-09-02-01 | Spoofing | All `/api/me/*` routes | mitigate | Every route calls `requireAuth()` first; write target is always `session!.user.id` (never from body/query). PUT body extraction explicitly drops any `userId`/`user_id` field |
+| T-09-02-02 | Tampering | `PUT /api/me/theme` | mitigate | `ALLOWED_THEMES` allowlist (`'light' \| 'dark' \| 'system'`); rejected with HTTP 400 before any DB write |
+| T-09-02-03 | Tampering | `PUT /api/me/notification-subscriptions` | mitigate | `event_key` length-bounded; `channel_type` passes `isPersonalChannelType` allowlist; `enabled` `typeof === 'boolean'`. Composite PK on user_id+event_key+channel_type prevents duplicate insertion attempts |
+| T-09-02-04 | Information Disclosure | `GET /api/me/channels` | mitigate | SQL hardcodes `WHERE owner_user_id = session!.user.id` — a user cannot read another user's webhook URL or ntfy topic. Admin path lives in a separate route (Plan 05) |
+| T-09-02-05 | Information Disclosure / Logging | Test-send error responses | mitigate | `sendChannelTest` returns `{ status, error }` capped at 200 chars; no full webhook URL is echoed back. `console.error` logs do NOT include `channel.config` (only the channel id and channel_type) — see Action Step C/D |
+| T-09-02-06 | SSRF | `PUT /api/me/channels/teams` | mitigate | `isValidTeamsWebhookUrl` enforces `https://` AND hostname matching `*.webhook.office.com` or `*.logic.azure.com`. Cannot point at internal hosts (`localhost`, `127.0.0.1`, `10.x.x.x`) because hostname regex requires the public Teams hosts |
+| T-09-02-07 | Spoofing / Topic Squatting | `mintNtfyTopic` | mitigate | Server-side `crypto.randomUUID()` slice (8 hex chars = 32 bits — sufficient unguessability for ntfy public tier; collision probability negligible at expected user count). Custom topic override is permitted but format-restricted to `^[A-Za-z0-9_-]{6,64}$` to prevent injection |
+| T-09-02-08 | Repudiation | UPSERT semantics | accept | `notification_channels.updated_at` and `user_event_subscriptions.updated_at` are bumped on every write — sufficient audit for v1. No separate audit log table this phase |
+| T-09-02-09 | Denial of Service | Test-send on save | accept | Single best-effort fetch per save; bounded by Node.js fetch default timeout. Save persists even if test fails (D-06). User-driven action so no DoS amplification |
+
+No `high` severity unmitigated. ASVS L1 satisfied: V4.1.5 (URL allowlist for outbound), V5.1.3 (input validation), V8.1.6 (auth context bound to write target), V12.6.1 (SSRF mitigation via hostname pattern).
+
+
+
+
+- All five route files exist and export the expected handlers.
+- All routes call `requireAuth()` and never extract user_id from body/query.
+- `lib/services/personal-channels.ts` exports the four named symbols.
+- `npx tsc --noEmit --pretty` exits 0.
+- Manual smoke (post-deploy): `curl -X PUT /api/me/theme -d '{"theme":"hacker"}'` returns 400.
+- Manual smoke: `curl -X PUT /api/me/channels/teams -d '{"webhook_url":"http://evil.com"}'` returns 400 (protocol + host mismatch).
+- Manual smoke: `curl -X PUT /api/me/channels/ntfy -d '{}'` returns 200 with a `pulse-XXXXXXXX`-shaped topic.
+
+
+
+1. Five route files written with the correct handler exports.
+2. `lib/services/personal-channels.ts` provides Teams URL validation, ntfy minting, and the test-send helper.
+3. The Phase 9 frontend (Plan 04) and the notify.ts route_to_user implementation (Plan 03) can both consume `notification_channels` rows shaped by Plan 01 + this plan without further DB or API changes.
+4. `npx tsc --noEmit --pretty` exits 0.
+
+
+
diff --git a/.planning/phases/09-user-profile-preferences-new/09-03-PLAN.md b/.planning/phases/09-user-profile-preferences-new/09-03-PLAN.md
new file mode 100644
index 0000000..0a33173
--- /dev/null
+++ b/.planning/phases/09-user-profile-preferences-new/09-03-PLAN.md
@@ -0,0 +1,516 @@
+---
+phase: 09-user-profile-preferences-new
+plan: 03
+type: execute
+wave: 2
+depends_on: [09-01]
+files_modified:
+ - lib/types/pipeline.ts
+ - lib/services/pipeline-steps/notify-resolvers.ts
+ - lib/services/pipeline-steps/notify.ts
+autonomous: true
+requirements: [ROUTE-01, ROUTE-02, ROUTE-03, ROUTE-04, ROUTE-05, ROUTE-06]
+
+must_haves:
+ truths:
+ - "When a notify step config has no route_to_user block, behavior is byte-identical to today (backward compatible)"
+ - "When route_to_user is present, notify.ts resolves a Pulse user from PipelineContext, checks the user_event_subscriptions matrix, and dispatches via the user's personal channel before falling back"
+ - "When the user has the (event_key, channel_type) toggle DISABLED, notify.ts records skipped_reason='user_muted' and does NOT fall back to the global channel_id"
+ - "When the user route can't deliver (no personal channel of the requested type, or HTTP send returns non-2xx, or user not found), notify.ts falls back to the step's channel_id and records output.user_route_fallback = { reason, user_id?, channel_type, error? }"
+ - "When route_to_user.channel_type is omitted, notify.ts attempts ntfy first, then teams, then global fallback (D-10)"
+ - "Three resolvers ship in v1: autotask_resource_email, direct_email, pulse_user_id — registered in a Map so adding a resolver is a one-file change"
+ artifacts:
+ - path: "lib/types/pipeline.ts"
+ provides: "RouteToUser, NotifyResolver, ResolvedRecipient, UserRouteFallback types"
+ contains: "interface RouteToUser"
+ - path: "lib/services/pipeline-steps/notify-resolvers.ts"
+ provides: "Resolver registry: autotask_resource_email, direct_email, pulse_user_id"
+ exports: ["resolveRecipient", "registerResolver", "RESOLVERS"]
+ - path: "lib/services/pipeline-steps/notify.ts"
+ provides: "Updated executeNotify with route_to_user branch + fallback semantics"
+ exports: []
+ key_links:
+ - from: "lib/services/pipeline-steps/notify.ts"
+ to: "user_event_subscriptions"
+ via: "SELECT enabled FROM user_event_subscriptions WHERE user_id, event_key, channel_type"
+ pattern: "user_event_subscriptions"
+ - from: "lib/services/pipeline-steps/notify.ts"
+ to: "notification_channels personal lookup"
+ via: "SELECT WHERE owner_user_id = $1 AND channel_type = $2 AND is_active = true"
+ pattern: "owner_user_id ="
+ - from: "lib/services/pipeline-steps/notify.ts"
+ to: "lib/services/pipeline-steps/notify-resolvers.ts"
+ via: "import { resolveRecipient }"
+ pattern: "resolveRecipient"
+---
+
+
+Extend the notify pipeline step with optional per-user routing while keeping every
+existing pipeline byte-compatible. This plan:
+
+1. Adds the `route_to_user` shape to `lib/types/pipeline.ts` (ROUTE-01).
+2. Creates `lib/services/pipeline-steps/notify-resolvers.ts` with three v1 resolvers and
+ a `Map` registry (ROUTE-02).
+3. Rewrites `executeNotify` in `lib/services/pipeline-steps/notify.ts` to short-circuit
+ into a per-user branch when `route_to_user` is present, with the exact decision
+ tree from D-08 / D-10 / D-11 / D-12 (ROUTE-03..06).
+
+Out of scope:
+- Admin filter UI for `user_route_fallback` events (Plan 05, ROUTE-07)
+- Profile UI matrix that drives subscriptions (Plan 04, SUB-03)
+- Personal channel CRUD endpoints (Plan 02, CHAN-*)
+
+Output: One new file (`notify-resolvers.ts`), one rewritten file (`notify.ts`), and
+type additions in `lib/types/pipeline.ts`.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/phases/09-user-profile-preferences-new/09-CONTEXT.md
+@.planning/REQUIREMENTS.md
+@CLAUDE.md
+@lib/services/pipeline-steps/notify.ts
+@lib/types/pipeline.ts
+@lib/services/pipeline-engine.ts
+
+
+
+
+Current executeNotify signature (must keep this signature):
+```typescript
+async function executeNotify(
+ step: PipelineStep,
+ _context: PipelineContext,
+ _executionId: number,
+): Promise;
+```
+
+Existing helpers in notify.ts (preserve as-is, do NOT inline or remove):
+```typescript
+async function sendTeams(channel, config, message): Promise;
+async function sendTelegram(channel, message): Promise;
+async function sendNtfy(channel, config, message): Promise;
+async function sendWebhook(channel, config, message): Promise;
+```
+
+Each helper returns `{ success: true, output: { notified: true, channel: 'teams'|'ntfy'|... } }`
+on 2xx and `{ success: false, error: string }` on non-2xx — keep that contract.
+
+From lib/types/pipeline.ts (already extended in Plan 01):
+```typescript
+export interface NotificationChannel {
+ id: number; name: string; channel_type: ChannelType;
+ config: Record; is_active: boolean;
+ owner_user_id: string | null;
+ created_at: Date; updated_at: Date;
+}
+export interface PipelineContext { [key: string]: any; }
+export interface StepExecutorResult { success: boolean; output?: Record; error?: string; waiting?: boolean; }
+```
+
+Resource table (used by autotask_resource_email resolver):
+```sql
+-- resources(id INTEGER PRIMARY KEY, email TEXT, ...)
+SELECT email FROM resources WHERE id = $1
+```
+
+Better Auth user lookup (case-insensitive email match — emails come from Autotask):
+```sql
+SELECT id FROM "user" WHERE LOWER(email) = LOWER($1)
+```
+
+
+
+
+
+
+ Task 1: Add route_to_user types and resolver registry
+ lib/types/pipeline.ts, lib/services/pipeline-steps/notify-resolvers.ts
+
+
+ - lib/types/pipeline.ts (current shape, post Plan 01 — types must extend, not replace)
+ - lib/services/pipeline-steps/notify.ts (current channel-only logic — must remain compatible)
+ - .planning/phases/09-user-profile-preferences-new/09-CONTEXT.md (D-08 route_to_user shape, D-09 v1 resolvers)
+
+
+
+ Step A — Append the following block to `lib/types/pipeline.ts` (after the `UserEventSubscription` interface added in Plan 01). Do not modify any existing type.
+
+ ```typescript
+ // ============================================================================
+ // Phase 9 — notify step per-user routing (ROUTE-01..06)
+ // ============================================================================
+
+ /** Resolver name; resolvers live in lib/services/pipeline-steps/notify-resolvers.ts. */
+ export type ResolverName = 'autotask_resource_email' | 'direct_email' | 'pulse_user_id' | string;
+
+ /** Optional block on a `notify` step's config. When present, the step attempts
+ * delivery via the resolved Pulse user's personal channel before falling back
+ * to the step's `channel_id`. */
+ export interface RouteToUser {
+ /** PipelineContext key whose value holds the entity (e.g. 'ticket'). */
+ source: string;
+ /** Field name within context[source] (e.g. 'assignedResourceID'). */
+ field: string;
+ /** Resolver name. Looks up the user from the field value. */
+ resolve: ResolverName;
+ /** Event key checked against user_event_subscriptions for muting (D-12). */
+ event_key: string;
+ /** Optional preferred channel type (D-10). When omitted: ntfy then teams then fallback. */
+ channel_type?: 'teams' | 'ntfy';
+ }
+
+ /** Output of a resolver. null = no recipient (notify.ts falls through to global). */
+ export type ResolvedRecipient =
+ | { user_id: string }
+ | { email: string }
+ | null;
+
+ /** A resolver fn. Pure async; reads from postgres if it needs to. */
+ export type NotifyResolver = (fieldValue: unknown) => Promise;
+
+ /** Reasons recorded in execution_step.output_data when fallback occurs (D-11). */
+ export type UserRouteFallbackReason =
+ | 'no_channel'
+ | 'send_failed'
+ | 'user_not_found'
+ | 'no_field_value'
+ | 'resolver_unknown';
+
+ export interface UserRouteFallback {
+ reason: UserRouteFallbackReason;
+ user_id?: string;
+ channel_type: string;
+ error?: string;
+ }
+ ```
+
+ Step B — Create `lib/services/pipeline-steps/notify-resolvers.ts` with:
+
+ - Imports: `postgresClient` from `../postgres-client`, types from `../../types/pipeline`.
+ - Three resolver functions implementing the `NotifyResolver` signature:
+ - `directEmail`: returns `{ email: value }` only when `typeof value === 'string'` AND matches `/^[^\s@]+@[^\s@]+\.[^\s@]+$/`. Otherwise `null`.
+ - `pulseUserId`: queries `SELECT id FROM "user" WHERE id = $1` with the value (when string + non-empty); returns `{ user_id }` or `null`.
+ - `autotaskResourceEmail`: coerces value to integer (accepts number directly OR string matching `/^\d+$/`); queries `SELECT email FROM resources WHERE id = $1`; returns `{ email }` when row.email is non-empty, else `null`.
+ - `export const RESOLVERS: Map` initialized with all three: keys `'direct_email'`, `'pulse_user_id'`, `'autotask_resource_email'`.
+ - `export function registerResolver(name: ResolverName, fn: NotifyResolver): void` that calls `RESOLVERS.set(name, fn)`.
+ - `export async function resolveRecipient(name: ResolverName, fieldValue: unknown): Promise<{ recipient: ResolvedRecipient; resolverFound: boolean }>`:
+ - Look up `RESOLVERS.get(name)`. Missing → return `{ recipient: null, resolverFound: false }`.
+ - Try the resolver in a try/catch — on throw, return `{ recipient: null, resolverFound: true }` (the resolver was found but failed).
+
+ Side-effect-free: file imports postgresClient (lazy-init on first query) and exports a Map. NO worker startup. Safe to import from notify.ts.
+
+ The file must follow the established CLAUDE.md conventions: kebab-case filename, camelCase identifiers, no Zod, no ORM, manual SQL via `postgresClient.query(...)`.
+
+
+
+ grep -q "interface RouteToUser" lib/types/pipeline.ts && grep -q "type ResolvedRecipient" lib/types/pipeline.ts && grep -q "interface UserRouteFallback" lib/types/pipeline.ts && test -f lib/services/pipeline-steps/notify-resolvers.ts && grep -q "export const RESOLVERS" lib/services/pipeline-steps/notify-resolvers.ts && grep -q "autotask_resource_email" lib/services/pipeline-steps/notify-resolvers.ts && grep -q "direct_email" lib/services/pipeline-steps/notify-resolvers.ts && grep -q "pulse_user_id" lib/services/pipeline-steps/notify-resolvers.ts && npx tsc --noEmit --pretty 2>&1 | head -20
+
+
+
+ - `lib/types/pipeline.ts` contains `export interface RouteToUser {`
+ - `lib/types/pipeline.ts` contains the `event_key` field within `RouteToUser`
+ - `lib/types/pipeline.ts` contains `export type ResolvedRecipient`
+ - `lib/types/pipeline.ts` contains `export interface UserRouteFallback {`
+ - `lib/types/pipeline.ts` contains the literal type union `'no_channel' | 'send_failed' | 'user_not_found' | 'no_field_value' | 'resolver_unknown'` (each value present)
+ - `lib/services/pipeline-steps/notify-resolvers.ts` exists
+ - File contains `export const RESOLVERS`
+ - File contains a `'direct_email'` Map key, a `'pulse_user_id'` Map key, and an `'autotask_resource_email'` Map key
+ - File contains `export function registerResolver`
+ - File contains `export async function resolveRecipient`
+ - `autotask_resource_email` resolver SQL is exactly `SELECT email FROM resources WHERE id = $1`
+ - `pulse_user_id` resolver SQL is exactly `SELECT id FROM "user" WHERE id = $1`
+ - File does NOT import or call any worker side-effect (no `setInterval`, `setTimeout` at module scope, no `start()` function calls at top level)
+ - `npx tsc --noEmit --pretty` reports no errors in either file
+
+
+
+ Types added to `lib/types/pipeline.ts`. Resolver registry file exists with three v1 resolvers, a registration function, and the resolver dispatcher. Plan Task 2 can now import `resolveRecipient` from this file.
+
+
+
+
+ Task 2: Rewrite executeNotify with route_to_user branch and fallback semantics
+ lib/services/pipeline-steps/notify.ts
+
+
+ - lib/services/pipeline-steps/notify.ts (current implementation — preserve sendTeams/sendTelegram/sendNtfy/sendWebhook helpers verbatim)
+ - lib/services/pipeline-steps/notify-resolvers.ts (just created in Task 1 — the source of `resolveRecipient`)
+ - lib/types/pipeline.ts (RouteToUser, UserRouteFallback added in Task 1)
+ - .planning/phases/09-user-profile-preferences-new/09-CONTEXT.md (D-08 shape, D-10 ntfy-then-teams default, D-11 fallback rules, D-12 mute = skip silently)
+ - lib/services/pipeline-engine.ts lines 380-410 (how output_data is persisted on the execution_step row — your output object becomes that row's output_data)
+
+
+
+ Rewrite `lib/services/pipeline-steps/notify.ts` so:
+
+ 1. Imports add: `import { resolveRecipient } from './notify-resolvers';` and types `RouteToUser, UserRouteFallback, ResolvedRecipient` from `../../types/pipeline`.
+
+ 2. Keep the `sendTeams`, `sendTelegram`, `sendNtfy`, `sendWebhook` helpers UNCHANGED at the bottom of the file.
+
+ 3. Replace `executeNotify` with the following decision tree. Use `_context` and `_executionId` (rename to `context` and `executionId` since they're now used).
+
+ **Top of executeNotify:**
+
+ ```typescript
+ async function executeNotify(
+ step: PipelineStep,
+ context: PipelineContext,
+ _executionId: number,
+ ): Promise {
+ const channelId = Number(step.config.channel_id);
+ if (!channelId || isNaN(channelId)) {
+ return { success: false, error: 'Missing or invalid channel_id' };
+ }
+
+ const route = (step.config.route_to_user as RouteToUser | undefined) ?? null;
+ const message = step.config.message || '';
+
+ // BACKWARD COMPATIBLE PATH: no route_to_user → original behavior verbatim.
+ if (!route) {
+ return await dispatchToGlobalChannel(channelId, step.config, message);
+ }
+
+ // USER-ROUTE PATH (ROUTE-01..06)
+ return await dispatchUserRoute({ step, context, message, channelId, route });
+ }
+ ```
+
+ 4. Extract the existing global dispatch into a helper called `dispatchToGlobalChannel(channelId, config, message)` that runs the EXACT SQL + switch the current `executeNotify` runs (load the channel by id, dispatch by channel_type, return). This is a pure refactor — the SQL `SELECT * FROM notification_channels WHERE id = $1 AND is_active = true` and the switch on `channel.channel_type` move from `executeNotify` into this helper, returning the same StepExecutorResult.
+
+ 5. Implement `dispatchUserRoute(args: { step, context, message, channelId, route })`:
+
+ **Step 5a — Read field value from context:**
+ ```typescript
+ const sourceObj = context[route.source];
+ const fieldValue = sourceObj?.[route.field];
+ if (fieldValue === undefined || fieldValue === null || fieldValue === '') {
+ // No field value to resolve — fall back to global with reason
+ return await fallbackToGlobal({
+ channelId, config: step.config, message,
+ fallback: { reason: 'no_field_value', channel_type: route.channel_type ?? 'auto' },
+ });
+ }
+ ```
+
+ **Step 5b — Resolve recipient:**
+ ```typescript
+ const { recipient, resolverFound } = await resolveRecipient(route.resolve, fieldValue);
+ if (!resolverFound) {
+ return await fallbackToGlobal({
+ channelId, config: step.config, message,
+ fallback: { reason: 'resolver_unknown', channel_type: route.channel_type ?? 'auto', error: `Unknown resolver: ${route.resolve}` },
+ });
+ }
+ if (!recipient) {
+ return await fallbackToGlobal({
+ channelId, config: step.config, message,
+ fallback: { reason: 'user_not_found', channel_type: route.channel_type ?? 'auto' },
+ });
+ }
+ ```
+
+ **Step 5c — Resolve to Pulse user.id:**
+ - If `recipient` has `user_id`, use it directly.
+ - Else (has `email`): `SELECT id FROM "user" WHERE LOWER(email) = LOWER($1)` using `recipient.email`.
+ - If no user row → fallback with reason `'user_not_found'`.
+
+ **Step 5d — Determine channel-type attempt order (ROUTE-06):**
+ ```typescript
+ const attemptOrder: Array<'ntfy' | 'teams'> =
+ route.channel_type ? [route.channel_type] : ['ntfy', 'teams'];
+ ```
+
+ **Step 5e — For each attempted type, check mute then send:**
+ ```typescript
+ for (const channelType of attemptOrder) {
+ // ROUTE-05 mute check — query user_event_subscriptions
+ const subRes = await postgresClient.query<{ enabled: boolean }>(
+ `SELECT enabled FROM user_event_subscriptions
+ WHERE user_id = $1 AND event_key = $2 AND channel_type = $3`,
+ [userId, route.event_key, channelType],
+ );
+ // Default-enabled (D-15): row absent = enabled = true
+ const enabled = subRes.rows.length === 0 ? true : subRes.rows[0].enabled;
+ if (!enabled) {
+ // ROUTE-05: skip silently, NO fallback
+ return {
+ success: true,
+ output: {
+ notified: false,
+ skipped_reason: 'user_muted',
+ user_id: userId,
+ event_key: route.event_key,
+ channel_type: channelType,
+ },
+ };
+ }
+
+ // Look up the user's personal channel of this type
+ const chanRes = await postgresClient.query(
+ `SELECT * FROM notification_channels
+ WHERE owner_user_id = $1 AND channel_type = $2 AND is_active = true
+ LIMIT 1`,
+ [userId, channelType],
+ );
+ if (chanRes.rows.length === 0) {
+ // No personal channel of this type — try the next type in attemptOrder.
+ // If this was the last type attempted, the loop falls through to global fallback.
+ if (channelType === attemptOrder[attemptOrder.length - 1]) {
+ return await fallbackToGlobal({
+ channelId, config: step.config, message,
+ fallback: { reason: 'no_channel', user_id: userId, channel_type: channelType },
+ });
+ }
+ continue;
+ }
+
+ // Dispatch to the personal channel using existing helpers
+ const dispatchResult =
+ channelType === 'teams' ? await sendTeams(chanRes.rows[0], step.config, message) :
+ await sendNtfy(chanRes.rows[0], step.config, message);
+
+ if (dispatchResult.success) {
+ return {
+ success: true,
+ output: {
+ ...(dispatchResult.output ?? {}),
+ user_route: { user_id: userId, channel_type: channelType, event_key: route.event_key },
+ },
+ };
+ }
+
+ // Send failed — try next type if any, else fall back
+ if (channelType === attemptOrder[attemptOrder.length - 1]) {
+ return await fallbackToGlobal({
+ channelId, config: step.config, message,
+ fallback: { reason: 'send_failed', user_id: userId, channel_type: channelType, error: dispatchResult.error },
+ });
+ }
+ }
+
+ // Defensive: should never reach here because the loop always returns or continues
+ return await fallbackToGlobal({
+ channelId, config: step.config, message,
+ fallback: { reason: 'no_channel', user_id: userId, channel_type: 'auto' },
+ });
+ ```
+
+ **Step 5f — Implement `fallbackToGlobal(args)`:**
+
+ ```typescript
+ async function fallbackToGlobal(args: {
+ channelId: number;
+ config: Record;
+ message: string;
+ fallback: UserRouteFallback;
+ }): Promise {
+ const result = await dispatchToGlobalChannel(args.channelId, args.config, args.message);
+ // Annotate output with the fallback reason regardless of global success/failure
+ if (result.success) {
+ return {
+ success: true,
+ output: {
+ ...(result.output ?? {}),
+ user_route_fallback: args.fallback,
+ },
+ };
+ }
+ // Global also failed — return error with fallback context attached
+ return {
+ success: false,
+ error: result.error,
+ output: { user_route_fallback: args.fallback },
+ };
+ }
+ ```
+
+ 6. Keep `registerStepExecutor('notify', executeNotify);` at the bottom (unchanged).
+
+ **Critical correctness notes:**
+ - The mute path (`enabled === false`) returns `success: true` but `notified: false` and DOES NOT call any send function and DOES NOT call `fallbackToGlobal`. This satisfies ROUTE-05 / D-12 ("muting must actually mute").
+ - The no-personal-channel and send-failure paths DO call `fallbackToGlobal` (ROUTE-04 / D-11).
+ - The output object is what `pipeline-engine.ts updateStepLog` JSON-serializes into `pipeline_execution_steps.output_data` — admins can later filter on `output_data->>'user_route_fallback' IS NOT NULL` for ROUTE-07.
+
+
+
+ grep -q "route_to_user" lib/services/pipeline-steps/notify.ts && grep -q "resolveRecipient" lib/services/pipeline-steps/notify.ts && grep -q "user_event_subscriptions" lib/services/pipeline-steps/notify.ts && grep -q "owner_user_id = " lib/services/pipeline-steps/notify.ts && grep -q "user_route_fallback" lib/services/pipeline-steps/notify.ts && grep -q "skipped_reason" lib/services/pipeline-steps/notify.ts && grep -q "user_muted" lib/services/pipeline-steps/notify.ts && grep -q "dispatchToGlobalChannel\|dispatchUserRoute\|fallbackToGlobal" lib/services/pipeline-steps/notify.ts && grep -q "registerStepExecutor('notify', executeNotify)" lib/services/pipeline-steps/notify.ts && npx tsc --noEmit --pretty 2>&1 | head -20
+
+
+
+ - `lib/services/pipeline-steps/notify.ts` still calls `registerStepExecutor('notify', executeNotify)` at the bottom (no behavior break)
+ - File still defines all four send helpers: `sendTeams`, `sendTelegram`, `sendNtfy`, `sendWebhook` (preserved verbatim)
+ - File contains `import { resolveRecipient } from './notify-resolvers';`
+ - File contains the literal substring `route_to_user` (config key)
+ - File contains a SELECT statement reading `user_event_subscriptions` with the predicate `WHERE user_id = $1 AND event_key = $2 AND channel_type = $3`
+ - File contains a SELECT statement reading `notification_channels` with the predicate `WHERE owner_user_id = $1 AND channel_type = $2 AND is_active = true`
+ - File contains the literal string `'user_muted'` (mute disposition)
+ - File contains the literal string `user_route_fallback` (fallback output key)
+ - File contains the literal strings `'no_channel'`, `'send_failed'`, `'user_not_found'`, `'no_field_value'`, `'resolver_unknown'` (all five fallback reasons)
+ - File contains the literal `['ntfy', 'teams']` (default channel-type order — ROUTE-06)
+ - In the mute branch (`enabled === false`), the function returns `success: true` and the code path does NOT call `dispatchToGlobalChannel` or `fallbackToGlobal` (verified by grep showing the mute return has no fallback call between `if (!enabled)` and the next `}`)
+ - When `step.config.route_to_user` is undefined/null, `executeNotify` calls only `dispatchToGlobalChannel` — no resolver, no subscription query (backward-compat path)
+ - `npx tsc --noEmit --pretty` reports no errors in this file
+ - `npm test -- --run lib/services/pipeline-steps` does not crash on import (resolver registry side-effect-safe)
+
+
+
+ `notify.ts` honors the `route_to_user` block per the D-08..D-12 decision tree. Pipelines without `route_to_user` continue to work exactly as today. Fallback events are recorded in `output.user_route_fallback` for the admin filter in Plan 05.
+
+
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| Pipeline runtime → resolved user | Field value from PipelineContext crosses into a Postgres lookup |
+| Pipeline runtime → personal channel send | Personal webhook URL / ntfy topic is dispatched to external host |
+
+## STRIDE Threat Register (ASVS L1)
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-09-03-01 | Information Disclosure | `dispatchUserRoute` per-user resolver | mitigate | Resolver only returns the SINGLE recipient resolved from the field value. notify.ts never broadcasts to multiple users in a single step. The `LIMIT 1` on the personal channel SELECT enforces single-recipient semantics |
+| T-09-03-02 | Tampering / Bypass | mute → fallback path | mitigate | The `if (!enabled)` branch in Task 2 Step 5e returns directly with `success: true` and DOES NOT call `fallbackToGlobal`. Acceptance criteria verifies via grep that no fallback call exists in the mute branch — preserves user opt-out (D-12 / ROUTE-05) |
+| T-09-03-03 | Spoofing / Identity | `pulse_user_id` resolver | mitigate | Validates the field value as a non-empty string then performs an existence check (`SELECT id FROM "user" WHERE id = $1`). A malicious context payload claiming to be another user fails the existence check OR routes only to that user's own channel — no cross-user delivery possible |
+| T-09-03-04 | Information Disclosure | resolver registry side-effects | mitigate | `notify-resolvers.ts` imports only `postgresClient` and types. No worker side effects (sync-scheduler, analyzer, RMM). Verified by grep that no module-scope `setInterval`/`start()` exists |
+| T-09-03-05 | Denial of Service | resolver throws inside notify.ts | mitigate | `resolveRecipient` wraps the resolver call in try/catch and returns `{ recipient: null, resolverFound: true }` on throw. notify.ts treats this as `user_not_found` and falls back to global — no crash, no stuck pipeline |
+| T-09-03-06 | Repudiation | fallback event recording | mitigate | Every fallback path writes a `user_route_fallback: { reason, user_id?, channel_type, error? }` object onto `output`, persisted by `pipeline-engine.ts updateStepLog` to `pipeline_execution_steps.output_data`. Plan 05 surfaces this in `/admin/workflow/executions` for review |
+| T-09-03-07 | Information Disclosure | error string in `user_route_fallback.error` | mitigate | `dispatchResult.error` from existing `sendTeams`/`sendNtfy` is already truncated to 200 chars (verified by reading current notify.ts). No webhook URL is echoed |
+
+No `high` severity unmitigated. ASVS L1 satisfied: V8.1.6 (auth context bound to recipient), V12.6.1 (no SSRF amplification — personal channels validated at write-time in Plan 02), V13.1.1 (auditing of routing decisions via output_data).
+
+
+
+
+- `npx tsc --noEmit --pretty` exits 0 across the three modified files.
+- A pipeline with no `route_to_user` produces the same SQL trace and same StepExecutorResult shape it produces today (backward compat).
+- A pipeline with `route_to_user.event_key` set, where the user has the toggle off, produces output `{ skipped_reason: 'user_muted', ... }` and never queries `notification_channels` for the personal channel.
+- A pipeline with `route_to_user` set but no personal channel of the requested type produces output containing `user_route_fallback.reason === 'no_channel'`.
+- A pipeline with `route_to_user.channel_type` omitted attempts ntfy first, then teams, before falling back.
+
+
+
+1. Three files modified: `lib/types/pipeline.ts`, `lib/services/pipeline-steps/notify-resolvers.ts` (new), `lib/services/pipeline-steps/notify.ts` (rewritten).
+2. `npx tsc --noEmit --pretty` exits 0.
+3. All existing tests under `lib/services/` continue to pass: `npm test`.
+4. Backward compat: a step with `step.config.channel_id = 7` and no `route_to_user` produces identical behavior to pre-Phase-9.
+5. Plans 04 (UI) and 05 (admin filter) can rely on the `output.user_route_fallback` shape stabilized here.
+
+
+
diff --git a/.planning/phases/09-user-profile-preferences-new/09-04-PLAN.md b/.planning/phases/09-user-profile-preferences-new/09-04-PLAN.md
new file mode 100644
index 0000000..fb594d0
--- /dev/null
+++ b/.planning/phases/09-user-profile-preferences-new/09-04-PLAN.md
@@ -0,0 +1,637 @@
+---
+phase: 09-user-profile-preferences-new
+plan: 04
+type: execute
+wave: 3
+depends_on: [09-02]
+files_modified:
+ - app/mobile/profile/page.tsx
+ - components/mobile/profile/ProfileTimezoneSection.tsx
+ - components/mobile/profile/ProfileThemeSection.tsx
+ - components/mobile/profile/ProfileNotificationMatrix.tsx
+ - components/mobile/profile/ProfileChannelsSection.tsx
+ - components/mobile/profile/ProfileSectionSkeleton.tsx
+ - components/mobile/profile/ThemeSessionBridge.tsx
+ - components/mobile/MoreDrawer.tsx
+ - components/theme-toggle.tsx
+ - app/layout.tsx
+ - package.json
+autonomous: true
+requirements: [PROF-01, PROF-02, PROF-03, PROF-04, TZ-CHOOSER-01, TZ-CHOOSER-02, THEME-03, THEME-04, SUB-03]
+
+must_haves:
+ truths:
+ - "Tapping 'Profile & preferences' in the More drawer Account section navigates to /mobile/profile"
+ - "/mobile/profile is gated by requireAuth() server-side and renders four shadcn Cards in order: Timezone, Theme, Notifications, Channels"
+ - "Timezone Card uses a shadcn Combobox (Command + Popover) populated from Intl.supportedValuesOf('timeZone') plus EXTRA_ALLOWED_TIMEZONES; selecting a value debounces 400ms then PUTs /api/me/timezone and shows a sonner success/error toast"
+ - "Timezone Card shows a read-only 'Your current time: {time} in {zone}' line below the picker, formatted via useUserTimezone() and rendered with the .num utility class"
+ - "Theme Card renders three radio rows (Light/Dark/System) with Sun/Moon/Monitor icons; selecting a row immediately calls setTheme() (next-themes) and PUTs /api/me/theme"
+ - "Notifications Card renders a per-event-key × per-channel-type matrix from GET /api/me/notification-subscriptions, defaulting missing cells to enabled=true; toggle saves are debounced 400ms via PUT and revert on error"
+ - "Channels Card has a Teams sub-section (URL Input + Save/Clear buttons) and an ntfy sub-section that mints on first save then displays subscribe link + QR code; both show test-send result inline"
+ - "On session load and after sign-in, ThemeSessionBridge compares session.user.theme to next-themes useTheme() and calls setTheme(session.user.theme) when they differ"
+ - "ThemeToggle (desktop) writes through to PUT /api/me/theme on every setTheme call"
+ - "MoreDrawer Account section's user-identity row becomes a Link to /mobile/profile, with a 'Profile & preferences' label, sitting above the Sign-out destructive action"
+ artifacts:
+ - path: "app/mobile/profile/page.tsx"
+ provides: "/mobile/profile page (server-component shell + client orchestration)"
+ contains: "requireAuth"
+ - path: "components/mobile/profile/ProfileTimezoneSection.tsx"
+ provides: "Timezone Card (Combobox + current time)"
+ - path: "components/mobile/profile/ProfileThemeSection.tsx"
+ provides: "Theme Card (3-option radio rows + write-through)"
+ - path: "components/mobile/profile/ProfileNotificationMatrix.tsx"
+ provides: "Notifications matrix Card with debounced PUT per cell"
+ - path: "components/mobile/profile/ProfileChannelsSection.tsx"
+ provides: "Channels Card (Teams + ntfy sub-sections, QR, test result inline)"
+ - path: "components/mobile/profile/ProfileSectionSkeleton.tsx"
+ provides: "Generic 3-row pulsing Skeleton for any section"
+ - path: "components/mobile/profile/ThemeSessionBridge.tsx"
+ provides: "Client effect comparing session.user.theme to next-themes; calls setTheme on mismatch"
+ - path: "components/mobile/MoreDrawer.tsx"
+ provides: "Account section with Profile & preferences link"
+ contains: "Profile & preferences"
+ - path: "components/theme-toggle.tsx"
+ provides: "Session-aware ThemeToggle that writes through to /api/me/theme"
+ contains: "/api/me/theme"
+ key_links:
+ - from: "app/mobile/profile/page.tsx"
+ to: "/api/me/timezone, /api/me/theme, /api/me/channels, /api/me/notification-subscriptions"
+ via: "client fetch from each section"
+ pattern: "fetch.*api/me"
+ - from: "ThemeSessionBridge"
+ to: "next-themes useTheme"
+ via: "setTheme(session.user.theme) on mismatch"
+ pattern: "setTheme"
+ - from: "ThemeToggle"
+ to: "/api/me/theme PUT"
+ via: "fetch on setTheme callback"
+ pattern: "method:.*PUT"
+ - from: "MoreDrawer.tsx Account section"
+ to: "/mobile/profile"
+ via: "Link href in identity row + new 'Profile & preferences' row"
+ pattern: "/mobile/profile"
+---
+
+
+Build the entire Phase 9 mobile profile UI surface plus the theme-bridge plumbing that
+makes the server's `theme` value canonical app-wide. This plan has 5 tasks because the
+work spans multiple loosely-coupled subsystems (one task per section, one task for
+theme bridging, one task for drawer wiring). Despite the count, each task is small
+(15–30 min) thanks to the UI-SPEC contract.
+
+Output: 7 new files in `components/mobile/profile/`, 1 new page, 3 modified files.
+
+Per UI-SPEC contract `.planning/phases/09-user-profile-preferences-new/09-UI-SPEC.md`:
+- Page H1: "Profile & Preferences" (text-xl font-semibold)
+- Section gap: `space-y-4`
+- Each Card: shadcn Card with CardHeader (`px-4 pt-4 pb-0`) + CardContent (`px-4 py-4`)
+- Page wrapper: `` (inherits `max-w-lg mx-auto` from layout)
+- All interactive rows ≥ 44px height (`min-h-[44px]` or `py-3`)
+- Color tokens only — never hex / raw palette
+- Numeric values use `.num` utility (IBM Plex Mono)
+- Inline errors `text-xs text-destructive`; toasts via sonner
+
+Adding `qrcode.react` (~10KB gzip) is the only new dependency. UI-SPEC notes this is
+acceptable; reviewed for "no network-call side effects at render time" (it's pure
+client-side SVG generation).
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/phases/09-user-profile-preferences-new/09-CONTEXT.md
+@.planning/phases/09-user-profile-preferences-new/09-UI-SPEC.md
+@.planning/REQUIREMENTS.md
+@CLAUDE.md
+@components/mobile/MoreDrawer.tsx
+@components/theme-toggle.tsx
+@components/theme-provider.tsx
+@app/layout.tsx
+@app/api/me/timezone/route.ts
+@lib/hooks/use-user-timezone.ts
+@components/ui/card.tsx
+@components/ui/command.tsx
+@components/ui/popover.tsx
+@components/ui/switch.tsx
+@components/mobile/EngagementProfileMetricGrid.tsx
+
+
+
+
+GET /api/me/timezone → { timezone: string, source: 'user' | 'default' }
+PUT /api/me/timezone → body { timezone: string } → { timezone: string }
+
+GET /api/me/theme → { theme: 'light' | 'dark' | 'system', source: 'user' | 'default' }
+PUT /api/me/theme → body { theme: 'light'|'dark'|'system' } → { theme }
+
+GET /api/me/channels → { channels: Array<{ id, name, channelType, config, isActive, ownerUserId, createdAt, updatedAt }> }
+PUT /api/me/channels/teams → body { webhook_url } → { channel, test: { ok, status?, error? } }
+PUT /api/me/channels/ntfy → body { topic? } → { channel, test }
+DELETE /api/me/channels/{type} → { deleted: true, channelType }
+POST /api/me/channels/{type}/test → { test: ChannelTestResult }
+
+GET /api/me/notification-subscriptions →
+ {
+ eventKeys: Array<{ key, displayLabel, description, sortOrder }>,
+ channelTypes: Array<'teams' | 'ntfy'>,
+ matrix: Record>
+ }
+PUT /api/me/notification-subscriptions → body { event_key, channel_type, enabled } → { subscription }
+
+useUserTimezone() (existing hook): () => string // IANA timezone
+
+EXTRA_ALLOWED_TIMEZONES from app/api/me/timezone/route.ts: ['UTC', 'Etc/UTC', 'GMT', 'Etc/GMT']
+
+
+
+
+
+
+ Task 1: Page shell, MoreDrawer wiring, and skeleton helper
+ app/mobile/profile/page.tsx, components/mobile/profile/ProfileSectionSkeleton.tsx, components/mobile/MoreDrawer.tsx
+
+
+ - .planning/phases/09-user-profile-preferences-new/09-UI-SPEC.md (Page Layout Contract section, MoreDrawer drawer link addition)
+ - components/mobile/MoreDrawer.tsx (current Account section structure)
+ - app/mobile/layout.tsx (page wrapper inherits max-w-lg)
+ - lib/auth-utils.ts (requireAuth signature for server component)
+ - components/mobile/EngagementProfileSkeleton.tsx (Skeleton precedent if it exists; otherwise mirror Card + 3 Skeleton rows)
+
+
+
+ Step A — Create `components/mobile/profile/ProfileSectionSkeleton.tsx` (client component):
+
+ - Exports default `ProfileSectionSkeleton`
+ - Renders a shadcn `Card` with `CardHeader` containing one `Skeleton` (h-5 w-32) and `CardContent` (`px-4 py-4`) containing 3 stacked `Skeleton` rows (each `h-10 w-full`, `space-y-3` between them)
+ - Uses `Skeleton` from `@/components/ui/skeleton`
+
+ Step B — Create `app/mobile/profile/page.tsx` as a Next.js server component shell:
+
+ ```tsx
+ // /mobile/profile (PROF-01) — gated by requireAuth(), server-rendered shell
+ // hosts the four client sections.
+
+ import { redirect } from 'next/navigation';
+ import { requireAuth } from '@/lib/auth-utils';
+ import { ProfileTimezoneSection } from '@/components/mobile/profile/ProfileTimezoneSection';
+ import { ProfileThemeSection } from '@/components/mobile/profile/ProfileThemeSection';
+ import { ProfileNotificationMatrix } from '@/components/mobile/profile/ProfileNotificationMatrix';
+ import { ProfileChannelsSection } from '@/components/mobile/profile/ProfileChannelsSection';
+
+ export default async function MobileProfilePage() {
+ const { session, error } = await requireAuth();
+ if (error) {
+ // requireAuth returns a NextResponse 401 on miss; for a page route we redirect to sign-in
+ redirect('/auth/sign-in');
+ }
+ return (
+
+
Profile & Preferences
+
+
+
+
+
+
+
+ );
+ }
+ ```
+
+ Note: `requireAuth()` returns a NextResponse on miss — for a Page route we cannot return a Response directly; redirect to `/auth/sign-in` instead. The `session` variable is unused in the shell (each child component reads `useSession()` for what it needs) — that is intentional; the auth check is the gate.
+
+ Step C — Modify `components/mobile/MoreDrawer.tsx` Account section:
+
+ - Add `import { Settings as SettingsIcon } from 'lucide-react';` (alongside existing icons; if `Settings` is already imported keep it).
+ - Replace the existing Account section's `
` block so the inner structure becomes (in this exact order):
+
+ 1. The current user-identity row, BUT wrapped in a `...inner identity content...`. Preserve the existing avatar circle, name, and email markup verbatim — only the wrapping element changes from a non-clickable `div` to a `Link` (PROF-04: "the existing user-identity row becomes the tappable link").
+
+ 2. A new row immediately below: `Profile & preferences`.
+
+ 3. The existing Sign-out button (unchanged).
+
+ - Do NOT remove or restructure any other drawer section (Mobile sections / Full site stay exactly as today).
+
+
+
+ test -f app/mobile/profile/page.tsx && test -f components/mobile/profile/ProfileSectionSkeleton.tsx && grep -q "ProfileTimezoneSection" app/mobile/profile/page.tsx && grep -q "ProfileThemeSection" app/mobile/profile/page.tsx && grep -q "ProfileNotificationMatrix" app/mobile/profile/page.tsx && grep -q "ProfileChannelsSection" app/mobile/profile/page.tsx && grep -q "requireAuth" app/mobile/profile/page.tsx && grep -q '/mobile/profile' components/mobile/MoreDrawer.tsx && grep -q "Profile & preferences\|Profile & preferences" components/mobile/MoreDrawer.tsx && npx tsc --noEmit --pretty 2>&1 | head
+
+
+
+ - `app/mobile/profile/page.tsx` exists, calls `requireAuth()`, redirects to `/auth/sign-in` on miss
+ - File renders the four section components in this exact order: ProfileTimezoneSection, ProfileThemeSection, ProfileNotificationMatrix, ProfileChannelsSection
+ - File contains `
Profile & Preferences
` (UI-SPEC heading copy)
+ - Page wrapper is `` and inner div is `
` (UI-SPEC layout)
+ - `components/mobile/profile/ProfileSectionSkeleton.tsx` exports default; uses Card + Skeleton primitives; renders 3 skeleton rows
+ - `components/mobile/MoreDrawer.tsx` Account section contains a `Link` with `href="/mobile/profile"` wrapping the identity row
+ - `components/mobile/MoreDrawer.tsx` Account section contains a new row with the literal text `Profile & preferences` (lowercase 'p' for 'preferences' per UI-SPEC copy table)
+ - `components/mobile/MoreDrawer.tsx` still contains the Sign-out button using `text-destructive` and `signOut()` (existing destructive action preserved)
+ - `npx tsc --noEmit --pretty` reports no errors
+
+
+
+ Page shell exists, drawer routes to it, skeleton helper available for each section's loading state.
+
+
+
+
+ Task 2: ProfileTimezoneSection (Combobox + current time)
+ components/mobile/profile/ProfileTimezoneSection.tsx
+
+
+ - .planning/phases/09-user-profile-preferences-new/09-UI-SPEC.md ("Section: Timezone Card", Copywriting Contract → Timezone)
+ - app/api/me/timezone/route.ts (EXTRA_ALLOWED_TIMEZONES list, response shape)
+ - lib/hooks/use-user-timezone.ts (existing hook to consume)
+ - components/ui/command.tsx (cmdk Combobox primitives)
+ - components/ui/popover.tsx (Popover primitives)
+ - app/styles/brand.css (verify .num utility exists; if not, the executor uses `font-mono` Tailwind class instead — document the choice in the SUMMARY)
+
+
+
+ Create `components/mobile/profile/ProfileTimezoneSection.tsx` as a `'use client'` component.
+
+ Layout per UI-SPEC:
+ - shadcn Card; CardHeader with CardTitle "Timezone" (text-xl font-semibold)
+ - CardContent (px-4 py-4):
+ - A `Combobox` built from `Popover` + `Command` (cmdk via shadcn).
+ - Trigger button: full width, displays the currently selected zone (e.g., "America/Chicago"), shows `` icon (lucide).
+ - Popover content: `No matches.` rendering one `` per timezone.
+ - The list combines `Intl.supportedValuesOf('timeZone')` (call once via `useMemo`) with the four `EXTRA_ALLOWED_TIMEZONES` (`UTC`, `Etc/UTC`, `GMT`, `Etc/GMT`), de-duplicated; sorted ASC.
+ - Each CommandItem `value={zone}` and renders the zone string. The selected zone shows a `` (lucide) icon at the right edge.
+ - Below the trigger: a paragraph "Your current time: {formattedTime} in {selectedZone}". The {formattedTime} span has class `font-mono tabular-nums` (use this if the codebase has no `.num` utility) and is computed via `new Intl.DateTimeFormat('en-US', { dateStyle: 'short', timeStyle: 'short', timeZone: selectedZone }).format(new Date())`. The line itself uses `text-xs text-muted-foreground`.
+
+ Behavior:
+ - On mount: `fetch('/api/me/timezone')` → set `selectedZone = data.timezone`. Render `ProfileSectionSkeleton` while loading.
+ - On select: optimistically nothing — set a `pending` boolean to true, debounce 400ms via `setTimeout`, then `fetch('/api/me/timezone', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ timezone: zone }) })`.
+ - On 200: `setSelectedZone(data.timezone)`, `toast.success('Timezone updated')` (sonner, copy from UI-SPEC).
+ - On non-2xx: keep prior selection, render inline `text-xs text-destructive` "Couldn't save. Try again." below the picker, `toast.error('Failed to update timezone')`.
+ - The "current time" line re-renders whenever `selectedZone` changes; also use `useEffect` with `setInterval(() => forceUpdate(), 60000)` to re-tick the displayed minute (clean up on unmount).
+
+ Imports from project:
+ - `Card, CardHeader, CardTitle, CardContent` from `@/components/ui/card`
+ - `Command, CommandEmpty, CommandInput, CommandItem, CommandList` from `@/components/ui/command`
+ - `Popover, PopoverContent, PopoverTrigger` from `@/components/ui/popover`
+ - `Button` from `@/components/ui/button`
+ - `ChevronsUpDown, Check` from `lucide-react`
+ - `toast` from `sonner`
+ - `ProfileSectionSkeleton` from `./ProfileSectionSkeleton`
+
+ Do NOT use `useUserTimezone()` here — this Section IS the source of truth that writes the value; reading via the hook would create a stale-loop. The Section calls `/api/me/timezone` directly to read and write.
+
+
+
+ test -f components/mobile/profile/ProfileTimezoneSection.tsx && grep -q "Intl.supportedValuesOf" components/mobile/profile/ProfileTimezoneSection.tsx && grep -q "EXTRA_ALLOWED_TIMEZONES\|'UTC'" components/mobile/profile/ProfileTimezoneSection.tsx && grep -q "/api/me/timezone" components/mobile/profile/ProfileTimezoneSection.tsx && grep -q "method: 'PUT'\|method: \"PUT\"" components/mobile/profile/ProfileTimezoneSection.tsx && grep -q "Search timezones" components/mobile/profile/ProfileTimezoneSection.tsx && grep -q "Your current time" components/mobile/profile/ProfileTimezoneSection.tsx && grep -q "toast.success" components/mobile/profile/ProfileTimezoneSection.tsx && npx tsc --noEmit --pretty 2>&1 | head
+
+
+
+ - File starts with `'use client';`
+ - File imports Card primitives from `@/components/ui/card`
+ - File imports Command primitives from `@/components/ui/command`
+ - File imports Popover primitives from `@/components/ui/popover`
+ - File imports `toast` from `sonner`
+ - File contains `Intl.supportedValuesOf('timeZone')`
+ - File contains all four EXTRA_ALLOWED_TIMEZONES strings: `'UTC'`, `'Etc/UTC'`, `'GMT'`, `'Etc/GMT'`
+ - File contains the literal placeholder `Search timezones…`
+ - File contains the literal label `Your current time:`
+ - File contains a fetch to `/api/me/timezone` with `method: 'PUT'` (or `"PUT"`)
+ - File debounces save with `setTimeout` ≥ 350ms (search for `400` or `setTimeout`)
+ - On success calls `toast.success('Timezone updated')`
+ - On error calls `toast.error('Failed to update timezone')` AND renders inline `text-xs text-destructive` with copy `Couldn't save. Try again.`
+ - `npx tsc --noEmit --pretty` reports no errors
+
+
+
+ Timezone section renders, persists, and shows live current-time. Saves are debounced 400ms; errors don't optimistically update.
+
+
+
+
+ Task 3: ProfileThemeSection + ProfileNotificationMatrix
+ components/mobile/profile/ProfileThemeSection.tsx, components/mobile/profile/ProfileNotificationMatrix.tsx
+
+
+ - .planning/phases/09-user-profile-preferences-new/09-UI-SPEC.md ("Section: Theme Card", "Section: Notifications Card", Copywriting Contract)
+ - app/api/me/theme/route.ts (Plan 02) — request/response shape
+ - app/api/me/notification-subscriptions/route.ts (Plan 02) — matrix shape
+ - components/ui/switch.tsx
+ - components/theme-toggle.tsx (existing setTheme pattern)
+ - components/mobile/profile/ProfileSectionSkeleton.tsx (Task 1 — loading state)
+
+
+
+ File 1 — `components/mobile/profile/ProfileThemeSection.tsx` (`'use client'`):
+
+ - Card with CardTitle "Theme"
+ - CardContent renders three rows (NOT a Switch — a 3-position radio group), each `min-h-[44px] flex items-center gap-3 px-2 py-3 rounded-md cursor-pointer hover:bg-accent`. Use `role="radiogroup"` on the wrapping div and `role="radio" aria-checked={selected === value}` on each row.
+ - Row contents: `` (Sun for Light, Moon for Dark, Monitor for System) + label span (text-sm) + a `` indicator at the right when active.
+ - Active row uses `text-primary` on the icon and label.
+ - On mount: read `useTheme()` from `next-themes` for current selection (initial render). Then `fetch('/api/me/theme')` and if `data.theme !== currentTheme` call `setTheme(data.theme)` (server canonical).
+ - On row click for value V:
+ - Call `setTheme(V)` immediately (optimistic local render, per UI-SPEC interaction contract: theme is the only optimistic case).
+ - In parallel, `fetch('/api/me/theme', { method: 'PUT', body: JSON.stringify({ theme: V }) })`.
+ - On 200: `toast.success('Theme updated')`
+ - On error: rollback `setTheme(previous)`, `toast.error('Failed to update theme')`.
+
+ File 2 — `components/mobile/profile/ProfileNotificationMatrix.tsx` (`'use client'`):
+
+ - Card with CardTitle "Notifications" and CardDescription "Choose which events trigger a personal notification." (UI-SPEC copy).
+ - State: `loading`, `error`, `data: { eventKeys, channelTypes, matrix } | null`.
+ - On mount: fetch `/api/me/notification-subscriptions`. While loading, render ``.
+ - Empty state — when `data.channelTypes.length === 0`: render `
Configure a Teams or ntfy channel below to enable personal notifications.
` (no skeleton, no matrix).
+ - Loaded state:
+ - When `channelTypes.length === 1`: render simple list of rows. Each row: `
{eventKey.displayLabel}
`.
+ - When `channelTypes.length > 1`: render a header row: `
`. Then per event-key row using the same grid: label cell + one Switch per channel type column.
+ - Switch checked state = `data.matrix[eventKey.key]?.[channelType] ?? true` (default-enabled per D-15).
+ - On Switch toggle for (event_key, channel_type, newValue):
+ - Optimistically set local state.
+ - Debounce 400ms (`setTimeout` per cell — track `pendingTimer` in a ref keyed by cell), then `fetch('/api/me/notification-subscriptions', { method: 'PUT', body: JSON.stringify({ event_key, channel_type, enabled: newValue }) })`.
+ - On 200: `toast.success('Preference saved')`.
+ - On error: revert local state, `toast.error("Couldn't save preference")`.
+
+
+
+ test -f components/mobile/profile/ProfileThemeSection.tsx && test -f components/mobile/profile/ProfileNotificationMatrix.tsx && grep -q "useTheme\|next-themes" components/mobile/profile/ProfileThemeSection.tsx && grep -q "/api/me/theme" components/mobile/profile/ProfileThemeSection.tsx && grep -q "setTheme" components/mobile/profile/ProfileThemeSection.tsx && grep -q "role=\"radiogroup\"\|role={'radiogroup'}" components/mobile/profile/ProfileThemeSection.tsx && grep -q "/api/me/notification-subscriptions" components/mobile/profile/ProfileNotificationMatrix.tsx && grep -q "Switch" components/mobile/profile/ProfileNotificationMatrix.tsx && grep -q "Configure a Teams or ntfy channel below" components/mobile/profile/ProfileNotificationMatrix.tsx && grep -q "Choose which events trigger" components/mobile/profile/ProfileNotificationMatrix.tsx && npx tsc --noEmit --pretty 2>&1 | head
+
+
+
+ - `ProfileThemeSection.tsx` starts with `'use client';` and imports `useTheme` from `next-themes`
+ - File contains three `setTheme` call sites for `'light'`, `'dark'`, `'system'`
+ - File contains `role="radiogroup"` (or `role={'radiogroup'}`) on the wrapping element
+ - File contains the literal labels `Light`, `Dark`, and `System`
+ - File contains a fetch to `/api/me/theme` with `method: 'PUT'`
+ - File contains `toast.success('Theme updated')`
+ - File contains `toast.error('Failed to update theme')`
+ - File rolls back the optimistic `setTheme` on error (search for `setTheme(prev` or equivalent)
+ - `ProfileNotificationMatrix.tsx` starts with `'use client';`
+ - File fetches `/api/me/notification-subscriptions`
+ - File renders `` during loading
+ - File contains the empty-state copy `Configure a Teams or ntfy channel below to enable personal notifications.`
+ - File contains the description copy `Choose which events trigger a personal notification.`
+ - File defaults missing matrix cells to `true` (search for `?? true`)
+ - File debounces saves with `setTimeout` (≥ 350ms)
+ - File contains `toast.success('Preference saved')` and `toast.error("Couldn't save preference")`
+ - `npx tsc --noEmit --pretty` reports no errors
+
+
+
+ Theme section commits server-side and rolls back on error. Notification matrix renders with default-enabled fallback, debounced PUT per cell.
+
+
+
+
+ Task 4: ProfileChannelsSection (Teams + ntfy with QR code)
+ components/mobile/profile/ProfileChannelsSection.tsx, package.json
+
+
+ - .planning/phases/09-user-profile-preferences-new/09-UI-SPEC.md ("Section: Channels Card" — full sub-section spec, Copywriting Contract → Channels)
+ - app/api/me/channels/route.ts and [type]/route.ts and [type]/test/route.ts (Plan 02 endpoints)
+ - lib/services/personal-channels.ts (TEST_MESSAGE_BODY constant — for awareness; not imported into the client)
+ - .planning/phases/09-user-profile-preferences-new/09-CONTEXT.md (D-04 ntfy mint-on-first-save, D-06 best-effort test)
+
+
+
+ Step A — Add `qrcode.react` to `package.json` dependencies. Use the latest stable major (`^4.0.0` or current). Run `npm install qrcode.react` semantically by adding the entry; the executor MAY also run the actual install command if the harness allows it. Otherwise, just edit `package.json` and document in SUMMARY that `npm install` is required.
+
+ Step B — Create `components/mobile/profile/ProfileChannelsSection.tsx` (`'use client'`):
+
+ - Card with CardTitle "Personal Channels" and CardDescription "Receive notifications directly on your devices." (UI-SPEC).
+ - State: `loading`, `channels: { teams?: Channel; ntfy?: Channel }`, `teamsInput: string`, `teamsTest: TestResult | null`, `ntfyTest: TestResult | null`, `showAdvanced: boolean`, `customTopicInput: string`.
+ - On mount: `fetch('/api/me/channels')`, then split `data.channels` into `teams` and `ntfy` by `channelType`.
+ - Layout: two sub-sections separated by ``.
+
+ **Teams sub-section:**
+ - Label "Microsoft Teams webhook URL"
+ - ``
+ - Below input: if `teamsTest` is set, render an inline test result row (UI-SPEC pattern):
+ - Success: `
Channel verified
`
+ - Failure: `
Test failed — {test.status ?? test.error ?? 'unknown'}
`
+ - Buttons row: ` ` (both `min-h-[44px]`).
+ - Save: `fetch('/api/me/channels/teams', { method: 'PUT', body: JSON.stringify({ webhook_url: teamsInput }) })`. On 200: `toast.success('Channel saved')`, set `teamsTest = data.test`, set `channels.teams = data.channel`. On 4xx (e.g., invalid host): show inline `text-xs text-destructive` with the response's `error`/`message` AND `toast.error('Failed to save channel')`.
+ - Clear: `fetch('/api/me/channels/teams', { method: 'DELETE' })`. Clear local state; `toast.success('Channel removed')`.
+
+ **ntfy sub-section:**
+ - State A — `channels.ntfy === undefined`:
+ - Label "Mobile push (ntfy)"
+ - Description "Pulse will generate a private topic for you." (text-sm text-muted-foreground)
+ - Button `` (min-h-[44px])
+ - `enableNtfy()` calls `fetch('/api/me/channels/ntfy', { method: 'PUT', body: JSON.stringify({}) })`; on 200 set `channels.ntfy = data.channel`, set `ntfyTest = data.test`, `toast.success('Channel saved')`.
+ - State B — `channels.ntfy` exists:
+ - Label "Mobile push (ntfy)"
+ - Subscribe link: `{"https://ntfy.sh/" + topic}`
+ - QR code: `` (from `qrcode.react`). Wrap in `
` for accessibility.
+ - Helper line: `
Scan with the ntfy app to subscribe.
`
+ - Test result inline (same pattern as Teams).
+ - Disclosure: `Edit advanced` — inside the details, render Label "Custom ntfy topic", Input bound to `customTopicInput`, Button "Save custom topic" calling `PUT /api/me/channels/ntfy` with body `{ topic: customTopicInput }`.
+ - Bottom buttons row: `` (calls `POST /api/me/channels/ntfy/test`) `` (calls `DELETE /api/me/channels/ntfy`).
+
+ All buttons use shadcn `Button` from `@/components/ui/button`. Test result icons from `lucide-react` (`CheckCircle`, `XCircle`).
+
+
+
+ test -f components/mobile/profile/ProfileChannelsSection.tsx && grep -q "qrcode.react" package.json && grep -q "/api/me/channels" components/mobile/profile/ProfileChannelsSection.tsx && grep -q "Microsoft Teams webhook URL" components/mobile/profile/ProfileChannelsSection.tsx && grep -q "Mobile push (ntfy)" components/mobile/profile/ProfileChannelsSection.tsx && grep -q "Enable mobile push" components/mobile/profile/ProfileChannelsSection.tsx && grep -q "QRCodeSVG\|qrcode.react" components/mobile/profile/ProfileChannelsSection.tsx && grep -q "Scan with the ntfy app" components/mobile/profile/ProfileChannelsSection.tsx && grep -q "Save Teams URL" components/mobile/profile/ProfileChannelsSection.tsx && grep -q "Edit advanced" components/mobile/profile/ProfileChannelsSection.tsx && grep -q "method: 'DELETE'\|method: \"DELETE\"" components/mobile/profile/ProfileChannelsSection.tsx && npx tsc --noEmit --pretty 2>&1 | head -20
+
+
+
+ - `package.json` includes `"qrcode.react"` in dependencies
+ - `components/mobile/profile/ProfileChannelsSection.tsx` starts with `'use client';`
+ - File imports the QR component from `qrcode.react` (`QRCodeSVG` or default)
+ - File contains the exact label `Microsoft Teams webhook URL`
+ - File contains the placeholder `https://yourorg.webhook.office.com/`
+ - File contains the literal `Mobile push (ntfy)`
+ - File contains the literal `Enable mobile push`
+ - File contains the literal `Pulse will generate a private topic for you.`
+ - File contains the literal `Scan with the ntfy app to subscribe.`
+ - File contains the literal `Save Teams URL`
+ - File contains the literal `Clear` (Teams clear button)
+ - File contains the literal `Edit advanced` (disclosure label)
+ - File contains the literal `Test now`
+ - File contains the literal `Remove`
+ - File contains the literal `Channel verified` (success copy)
+ - File contains the literal `Test failed`
+ - File contains the literal toast string `'Channel saved'`
+ - File contains the literal toast string `'Channel removed'`
+ - File makes fetch calls to `/api/me/channels`, `/api/me/channels/teams`, `/api/me/channels/ntfy`, `/api/me/channels/ntfy/test`
+ - File DELETE call uses `method: 'DELETE'` (or `"DELETE"`)
+ - QR code rendered inside an element with `aria-label` referencing the topic (accessibility)
+ - `npx tsc --noEmit --pretty` reports no errors after `npm install` adds qrcode.react
+
+
+
+ Channels section renders Teams + ntfy with the full UI-SPEC interaction model (mint, QR, test result inline, advanced override, remove). qrcode.react added to dependencies.
+
+
+
+
+ Task 5: Theme session bridge + ThemeToggle write-through
+ components/mobile/profile/ThemeSessionBridge.tsx, components/theme-toggle.tsx, app/layout.tsx
+
+
+ - components/theme-toggle.tsx (current setTheme handlers — three DropdownMenuItems)
+ - components/theme-provider.tsx
+ - app/layout.tsx (where ThemeProvider mounts; the bridge sits inside it)
+ - .planning/phases/09-user-profile-preferences-new/09-CONTEXT.md (D-16, D-17, D-20)
+ - .planning/REQUIREMENTS.md (THEME-03, THEME-04)
+
+
+
+ Step A — Create `components/mobile/profile/ThemeSessionBridge.tsx`:
+
+ ```tsx
+ 'use client';
+
+ /**
+ * ThemeSessionBridge (THEME-03 / D-16, D-17).
+ *
+ * On session load and after sign-in, compares session.user.theme to the
+ * next-themes useTheme() value and calls setTheme(session.user.theme) if
+ * different. Server is canonical; this is the bridge that enforces it.
+ *
+ * Renders nothing.
+ */
+
+ import { useEffect } from 'react';
+ import { useTheme } from 'next-themes';
+ import { useSession } from '@/lib/auth-client';
+
+ type SessionUserWithTheme = { theme?: 'light' | 'dark' | 'system' | string };
+
+ export function ThemeSessionBridge() {
+ const { data: session } = useSession();
+ const { theme, setTheme } = useTheme();
+
+ useEffect(() => {
+ if (!session?.user) return;
+ const serverTheme = (session.user as SessionUserWithTheme).theme;
+ if (
+ serverTheme === 'light' ||
+ serverTheme === 'dark' ||
+ serverTheme === 'system'
+ ) {
+ if (serverTheme !== theme) {
+ setTheme(serverTheme);
+ }
+ }
+ }, [session?.user, theme, setTheme]);
+
+ return null;
+ }
+ ```
+
+ Step B — Mount `` inside `app/layout.tsx`:
+
+ - Find the existing `` block.
+ - Inside the `` children, immediately AFTER `` opens (so it has access to `useSession()`), add ``. The exact placement: as the FIRST child of ``, before the existing `
`.
+ - Add the import at the top: `import { ThemeSessionBridge } from '@/components/mobile/profile/ThemeSessionBridge';`
+ - Do NOT change any other layout.tsx content.
+
+ Step C — Modify `components/theme-toggle.tsx` (THEME-04):
+
+ - Rename or wrap the bare `setTheme` calls so each `DropdownMenuItem onClick` invokes a helper:
+ ```tsx
+ const writeTheme = (next: 'light' | 'dark' | 'system') => {
+ setTheme(next);
+ // Fire-and-forget server write. No await — the session bridge re-syncs on next session refresh anyway.
+ fetch('/api/me/theme', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ theme: next }),
+ }).catch(() => {
+ // Silent fail on network error — the desktop affordance is best-effort.
+ // The mobile profile Theme section is the explicit-error UX.
+ });
+ };
+ ```
+ - Replace the three `setTheme('light')`, `setTheme('dark')`, `setTheme('system')` calls with `writeTheme('light')`, `writeTheme('dark')`, `writeTheme('system')`.
+ - Do NOT remove the `useTheme` import or the existing UI structure.
+ - The `theme` value read from `useTheme()` stays in scope for the existing icon-rotation logic (no behavior change for the visual toggle).
+
+
+
+ test -f components/mobile/profile/ThemeSessionBridge.tsx && grep -q "useSession\|useTheme" components/mobile/profile/ThemeSessionBridge.tsx && grep -q "session.user" components/mobile/profile/ThemeSessionBridge.tsx && grep -q "setTheme" components/mobile/profile/ThemeSessionBridge.tsx && grep -q "ThemeSessionBridge" app/layout.tsx && grep -q "/api/me/theme" components/theme-toggle.tsx && grep -q "method: 'PUT'\|method: \"PUT\"" components/theme-toggle.tsx && npx tsc --noEmit --pretty 2>&1 | head
+
+
+
+ - `components/mobile/profile/ThemeSessionBridge.tsx` exists, starts with `'use client';`
+ - File imports both `useTheme` from `next-themes` and `useSession` from `@/lib/auth-client`
+ - File contains `setTheme(serverTheme)` inside a `useEffect`
+ - File guards `serverTheme` against the three-string allowlist before calling `setTheme`
+ - File returns `null` (renders nothing)
+ - `app/layout.tsx` imports `ThemeSessionBridge` from `@/components/mobile/profile/ThemeSessionBridge`
+ - `app/layout.tsx` mounts `` inside `` (so `useSession()` works)
+ - `components/theme-toggle.tsx` contains the literal `/api/me/theme` reference
+ - `components/theme-toggle.tsx` issues a fetch with `method: 'PUT'` (or `"PUT"`)
+ - `components/theme-toggle.tsx` still renders the three `DropdownMenuItem` rows (Light, Dark, System) — visual structure unchanged
+ - `components/theme-toggle.tsx` still imports `useTheme` from `next-themes` (not removed)
+ - `npx tsc --noEmit --pretty` reports no errors
+
+
+
+ Server theme is canonical: ThemeSessionBridge reconciles next-themes from `session.user.theme` on session load. ThemeToggle writes through to the server on every selection.
+
+
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| Browser → /api/me/* | Already mitigated by Plan 02; this plan only consumes those endpoints |
+| Browser DOM → ntfy.sh subscribe link | User's own minted topic; rendered as anchor + QR — no third-party script |
+
+## STRIDE Threat Register (ASVS L1)
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-09-04-01 | Information Disclosure | QR code rendering | mitigate | `qrcode.react` is a pure client-side SVG library; no network calls at render time. The QR encodes only the user's own ntfy subscribe URL — no secrets |
+| T-09-04-02 | Tampering | Optimistic theme update rollback | mitigate | `ProfileThemeSection` rolls back `setTheme(previous)` on PUT error; the `ThemeSessionBridge` re-syncs from server on next session refresh as the canonical source |
+| T-09-04-03 | Spoofing | `ThemeSessionBridge` | mitigate | Reads `session.user.theme` from `useSession()` (Better Auth signed cookie). Validates against the three-string allowlist before calling `setTheme` — drops any unexpected value silently |
+| T-09-04-04 | Information Disclosure | Inline test-result error display | mitigate | The error string from `/api/me/channels` is already truncated to 200 chars (Plan 02 / `lib/services/personal-channels.ts`). UI renders it `text-xs` and only on the user's own channel form — never on another user's data |
+| T-09-04-05 | XSS | Subscribe link `target="_blank"` | mitigate | Anchor uses `rel="noopener noreferrer"`. Topic value flows from server-minted UUID-prefixed string (Plan 02), not user input |
+| T-09-04-06 | DoS / Bundle bloat | `qrcode.react` dependency | accept | ~10 KB gzip; loaded only on `/mobile/profile`. Reviewed for "no network-call side effects at render time" per UI-SPEC Registry Safety section |
+| T-09-04-07 | CSRF | Fetch calls from client | mitigate | Better Auth uses signed httpOnly session cookies; same-origin fetch is implicitly authenticated. No third-party origin can post on behalf of the user without the cookie |
+
+No `high` severity unmitigated. ASVS L1 satisfied: V14.4.5 (anti-clickjacking via SameSite cookie), V11.1.5 (XSS via React auto-escaping + rel=noopener).
+
+
+
+
+- `npm install` (or equivalent) installs `qrcode.react` cleanly.
+- `npx tsc --noEmit --pretty` exits 0.
+- `npm run build` (turbopack) builds without errors.
+- Manual smoke (post-deploy):
+ 1. Visit `/mobile/profile` → 4 Cards render in order.
+ 2. Tap a timezone → toast → reload → still selected.
+ 3. Tap "Dark" → page switches theme, toast → reload → still dark.
+ 4. Configure Teams URL → test send works → save → toast.
+ 5. Enable mobile push → topic minted → QR visible → subscribe link works.
+ 6. Open the More drawer → Account section shows the identity row as a link AND a "Profile & preferences" row above Sign-out.
+ 7. Open desktop ThemeToggle → switch theme → reload mobile profile → matches.
+
+
+
+1. All 7 new files exist under `components/mobile/profile/` and `app/mobile/profile/`.
+2. `MoreDrawer.tsx` Account section routes to `/mobile/profile`.
+3. `ThemeSessionBridge` is mounted in `app/layout.tsx` and renders nothing.
+4. `ThemeToggle` writes through to `/api/me/theme` on every selection.
+5. `package.json` declares `qrcode.react`.
+6. `npx tsc --noEmit --pretty` exits 0.
+7. The four section Cards each match the UI-SPEC contract (header, content padding, copywriting strings, save model).
+
+
+
diff --git a/.planning/phases/09-user-profile-preferences-new/09-05-PLAN.md b/.planning/phases/09-user-profile-preferences-new/09-05-PLAN.md
new file mode 100644
index 0000000..dd727cb
--- /dev/null
+++ b/.planning/phases/09-user-profile-preferences-new/09-05-PLAN.md
@@ -0,0 +1,393 @@
+---
+phase: 09-user-profile-preferences-new
+plan: 05
+type: execute
+wave: 3
+depends_on: [09-01, 09-02, 09-03]
+files_modified:
+ - app/admin/workflow/channels/page.tsx
+ - app/api/notification-channels/route.ts
+ - app/api/notification-channels/[id]/route.ts
+ - app/admin/workflow/event-keys/page.tsx
+ - app/api/admin/notify-event-keys/route.ts
+ - app/api/admin/notify-event-keys/[key]/route.ts
+ - app/admin/workflow/pipelines/[id]/page.tsx
+ - app/api/pipelines/[id]/executions/route.ts
+autonomous: true
+requirements: [CHAN-06, ROUTE-07]
+
+must_haves:
+ truths:
+ - "Admins (role admin or super-admin) can read AND edit any user's personal notification channel via /admin/workflow/channels — including viewing the webhook_url / topic and toggling is_active and triggering test sends"
+ - "/admin/workflow/channels has an Owner column that displays 'Global' for owner_user_id IS NULL or the owner's email otherwise, plus a filter widget that toggles between 'All', 'Global only', and 'Personal only'"
+ - "/admin/workflow/event-keys exists as a real CRUD page for admins (list, create, edit, soft-toggle is_active, change sort_order, delete)"
+ - "/admin/workflow/pipelines/[id] (recent executions panel) gains a 'Show only fallbacks' filter that filters to executions where any execution_step has output_data->'user_route_fallback' set"
+ - "All admin routes use requireAdmin() — non-admin users get 403"
+ - "Non-admin users can never read another user's personal channel via /api/notification-channels (the legacy endpoint hides personal rows for non-admins)"
+ artifacts:
+ - path: "app/admin/workflow/channels/page.tsx"
+ provides: "Channels list with Owner column + filter; admin edit of personal channels"
+ - path: "app/api/notification-channels/route.ts"
+ provides: "Updated GET to require auth, scope visibility by role; POST stays admin-only"
+ - path: "app/api/notification-channels/[id]/route.ts"
+ provides: "Updated PUT/DELETE/GET to authorize per-row by ownership and role"
+ - path: "app/admin/workflow/event-keys/page.tsx"
+ provides: "New admin CRUD page for notify_event_keys"
+ - path: "app/api/admin/notify-event-keys/route.ts"
+ provides: "GET list / POST create event keys (admin-only)"
+ - path: "app/api/admin/notify-event-keys/[key]/route.ts"
+ provides: "PUT update / DELETE one event key by primary key"
+ - path: "app/admin/workflow/pipelines/[id]/page.tsx"
+ provides: "Pipeline detail page with 'Show only fallbacks' filter on recent executions"
+ - path: "app/api/pipelines/[id]/executions/route.ts"
+ provides: "Updated executions endpoint to support fallbacks_only=1 filter"
+ key_links:
+ - from: "/admin/workflow/channels"
+ to: "notification_channels with owner_user_id JOIN user.email"
+ via: "LEFT JOIN \"user\" ON owner_user_id = user.id; renders Owner column"
+ pattern: "owner_user_id"
+ - from: "/admin/workflow/event-keys"
+ to: "notify_event_keys"
+ via: "POST/PUT/DELETE via /api/admin/notify-event-keys"
+ pattern: "notify_event_keys"
+ - from: "/admin/workflow/pipelines/[id]"
+ to: "pipeline_execution_steps.output_data->'user_route_fallback'"
+ via: "fallbacks_only=1 filter on /api/pipelines/[id]/executions"
+ pattern: "user_route_fallback"
+---
+
+
+Land the admin surfaces required by Phase 9:
+
+1. **CHAN-06: Admin full-edit of personal channels.** `/admin/workflow/channels` gains
+ an Owner column + filter. Admins can read/edit any user's personal Teams webhook URL
+ or ntfy topic, toggle is_active, and trigger test sends. Non-admins still cannot
+ see other users' personal channels via the legacy `/api/notification-channels` API.
+
+2. **SUB-01 admin CRUD: `/admin/workflow/event-keys`.** A new small admin page
+ (D-13) for managing the `notify_event_keys` lookup. Backed by two new API routes.
+
+3. **ROUTE-07: Admin filter for `user_route_fallback` events.** Add a "Show only
+ fallbacks" filter to the recent-executions panel on
+ `/admin/workflow/pipelines/[id]`. The requirement text says
+ `/admin/workflow/executions` but that page is the legacy workflow engine. The
+ pipeline-engine executions surface today is the per-pipeline detail page —
+ that is where the filter goes (documented as a deliberate redirection in the
+ task action below).
+
+Out of scope:
+- Backfilling existing notification_channels rows to set owner_user_id (they stay
+ global / NULL).
+- Notifying users when their personal channel changes (deferred per CONTEXT
+ "channel-rotation flow" deferred idea).
+
+Output: 3 modified existing files, 5 new files (2 new pages + 3 new API routes —
+including one extra API route for the event-keys collection vs by-key).
+
+This plan stays small per task by keeping API route handlers minimal (no new
+business logic — just CRUD + auth-gating).
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/phases/09-user-profile-preferences-new/09-CONTEXT.md
+@.planning/REQUIREMENTS.md
+@CLAUDE.md
+@app/admin/workflow/channels/page.tsx
+@app/api/notification-channels/route.ts
+@app/api/notification-channels/[id]/route.ts
+@app/admin/workflow/pipelines/[id]/page.tsx
+@app/api/pipelines/[id]/executions/route.ts
+@lib/auth-utils.ts
+@lib/services/personal-channels.ts
+
+
+
+
+notification_channels (post-Plan-01): id, name, channel_type, config, is_active, owner_user_id (NULL=global), created_at, updated_at
+
+notify_event_keys: key (PK), display_label, description, sort_order, is_active, created_at, updated_at
+
+pipeline_execution_steps.output_data is JSONB. After Plan 03, on user-route fallback the value contains:
+ output_data->'user_route_fallback' = { reason: string, user_id?: string, channel_type: string, error?: string }
+
+
+requireAuth() / requireAdmin() / requirePermission(resource, action) from lib/auth-utils.ts
+
+
+- Reads from `/api/notification-channels` (no auth today — gap to close in Task 1)
+- Writes via `/api/notification-channels`, `/api/notification-channels/[id]`, `/api/notification-channels/[id]/test`
+- Uses Card + Switch + DataTable-less list rendering
+
+
+GET /api/pipelines/[id]/executions?limit=N → returns { data: PipelineExecution[] }
+After this plan: GET /api/pipelines/[id]/executions?limit=N&fallbacks_only=1 → filters via JSONB predicate
+
+
+
+
+
+
+ Task 1: Owner column + role-scoped reads on /admin/workflow/channels
+ app/api/notification-channels/route.ts, app/api/notification-channels/[id]/route.ts, app/admin/workflow/channels/page.tsx
+
+
+ - app/api/notification-channels/route.ts (current GET / POST — no auth today)
+ - app/api/notification-channels/[id]/route.ts (current GET / PUT / DELETE — no auth today)
+ - app/admin/workflow/channels/page.tsx (current rendering structure to extend)
+ - lib/auth-utils.ts (requireAuth, requireAdmin)
+ - .planning/phases/09-user-profile-preferences-new/09-CONTEXT.md (D-07 admin full edit, CHAN-06)
+
+
+
+ Step A — `app/api/notification-channels/route.ts` (GET + POST):
+
+ Make this route auth-aware AND role-scoped. The existing implementation has no auth gate (security gap). Replace handlers with:
+
+ GET:
+ - Call `requireAuth()`. On error return error.
+ - If `(session.user.role === 'admin' || session.user.role === 'super-admin')`:
+ - Run a JOIN that returns owner email for personal channels:
+ ```sql
+ SELECT nc.*, u.email AS owner_email
+ FROM notification_channels nc
+ LEFT JOIN "user" u ON u.id = nc.owner_user_id
+ ORDER BY nc.owner_user_id NULLS FIRST, nc.name
+ ```
+ - Optional `?owner=global|personal|all` filter (default `all`):
+ - `global` → `WHERE owner_user_id IS NULL`
+ - `personal` → `WHERE owner_user_id IS NOT NULL`
+ - Else (non-admin):
+ - Return only global rows (`WHERE owner_user_id IS NULL`) — non-admins see admin-managed channels for selection in pipelines but NOT other users' personal channels.
+ - Response shape: `{ data: rows, total: rows.length }` (preserve existing shape).
+
+ POST:
+ - Call `requireAdmin()`. On error return error.
+ - Existing validation logic preserved verbatim (validates `channel_type` against `['teams','telegram','ntfy','webhook']`).
+ - INSERT statement adds `owner_user_id` to the column list, accepting `body.owner_user_id ?? null`. Default behavior: rows created via this admin endpoint are GLOBAL (owner_user_id NULL).
+
+ Step B — `app/api/notification-channels/[id]/route.ts` (GET / PUT / DELETE):
+
+ Each handler:
+ - `requireAuth()` first.
+ - Read the row (`SELECT * FROM notification_channels WHERE id = $1`).
+ - Compute `isOwner = row.owner_user_id === session.user.id`.
+ - Compute `isAdmin = session.user.role === 'admin' || session.user.role === 'super-admin'`.
+ - For GET / PUT / DELETE:
+ - If row is global (`owner_user_id IS NULL`) → require `isAdmin` else 403.
+ - If row is personal → require `isOwner OR isAdmin` else 403 (CHAN-06: admins have full read+edit).
+ - Existing UPDATE / DELETE logic preserved verbatim under the gate.
+
+ Step C — `app/admin/workflow/channels/page.tsx`:
+
+ - Page top: add a filter Select (using `@/components/ui/select`) labeled "Show:" with options "All" (default), "Global only", "Personal only". State `ownerFilter: 'all' | 'global' | 'personal'`. Reload list when it changes by passing `?owner=...` to `/api/notification-channels`.
+ - Add an "Owner" badge to each channel row card. Render BEFORE the existing channel-type Badge:
+ - When `channel.owner_user_id == null`: `Global`
+ - Else: `Personal: {channel.owner_email ?? channel.owner_user_id}` (the `owner_email` field comes from the JOIN added in Step A).
+ - The existing edit/delete/test buttons stay on every row regardless of ownership — this is the admin surface (D-07: full edit).
+ - Add a small disclaimer above the channel list when `ownerFilter !== 'global'`: `
Personal channels contain user-supplied webhook URLs — handle with care.
` (operational hygiene).
+
+
+
+ grep -q "requireAuth\|requireAdmin" app/api/notification-channels/route.ts && grep -q "owner_user_id" app/api/notification-channels/route.ts && grep -q "requireAuth" "app/api/notification-channels/[id]/route.ts" && grep -q "owner_user_id" "app/api/notification-channels/[id]/route.ts" && grep -q "owner_email\|owner_user_id" app/admin/workflow/channels/page.tsx && grep -q "Global only\|owner=global\|Personal only" app/admin/workflow/channels/page.tsx && npx tsc --noEmit --pretty 2>&1 | head
+
+
+
+ - `app/api/notification-channels/route.ts` GET handler calls `requireAuth()` (it does NOT today)
+ - GET handler returns only global rows (`WHERE owner_user_id IS NULL`) for non-admin sessions
+ - GET handler for admin sessions runs `LEFT JOIN "user" u ON u.id = nc.owner_user_id` and selects `u.email AS owner_email`
+ - GET handler accepts an `owner` query parameter with values `global` / `personal` / `all`
+ - POST handler calls `requireAdmin()` (it does NOT today)
+ - `app/api/notification-channels/[id]/route.ts` PUT and DELETE handlers each call `requireAuth()` and authorize via `(isAdmin || isOwner)` predicate against `row.owner_user_id`
+ - `app/admin/workflow/channels/page.tsx` renders an Owner badge on each channel row that displays `Global` for null owner or `Personal: {email}` otherwise
+ - `app/admin/workflow/channels/page.tsx` has a Select filter with the three values (`all`, `global`, `personal`)
+ - Selecting the filter updates the fetch URL with `?owner=`
+ - `npx tsc --noEmit --pretty` reports no errors
+
+
+
+ Admin Channels page handles personal-vs-global rows; admins see full data and can edit any row; non-admins (e.g., a pipeline operator without admin role) only see global rows.
+
+
+
+
+ Task 2: /admin/workflow/event-keys CRUD page + API + pipeline executions fallback filter
+ app/admin/workflow/event-keys/page.tsx, app/api/admin/notify-event-keys/route.ts, app/api/admin/notify-event-keys/[key]/route.ts, app/admin/workflow/pipelines/[id]/page.tsx, app/api/pipelines/[id]/executions/route.ts
+
+
+ - app/admin/workflow/channels/page.tsx (admin page conventions: Card + ArrowLeft back link + container layout)
+ - app/admin/workflow/pipelines/[id]/page.tsx (current recent_executions rendering — add the filter near the top of that section)
+ - app/api/pipelines/[id]/executions/route.ts (current SELECT — extend with the JSONB filter)
+ - lib/auth-utils.ts (requireAdmin signature)
+ - lib/types/pipeline.ts (NotifyEventKey type from Plan 01)
+ - .planning/phases/09-user-profile-preferences-new/09-CONTEXT.md (D-13 lookup table — admin manages, not a gate)
+
+
+
+ Step A — `app/api/admin/notify-event-keys/route.ts`:
+
+ GET:
+ - `requireAdmin()` guard.
+ - `SELECT key, display_label, description, sort_order, is_active, created_at, updated_at FROM notify_event_keys ORDER BY sort_order ASC, key ASC`
+ - Return `{ data: rows.map(camelCase) }`.
+
+ POST:
+ - `requireAdmin()` guard.
+ - Body: `{ key, display_label, description?, sort_order?, is_active? }`
+ - Validate `key` is non-empty string ≤ 128 chars matching `/^[a-z][a-z0-9_]*$/i` (event key naming).
+ - Validate `display_label` is non-empty string ≤ 200.
+ - INSERT and return the new row. Use `ON CONFLICT (key) DO NOTHING` then re-SELECT — return 409 if key already existed.
+
+ Step B — `app/api/admin/notify-event-keys/[key]/route.ts`:
+
+ PUT:
+ - `requireAdmin()` guard.
+ - `params.key` is the row to update.
+ - Body fields: `display_label?`, `description?`, `sort_order?`, `is_active?`.
+ - `UPDATE notify_event_keys SET display_label = COALESCE($1, display_label), description = COALESCE($2, description), sort_order = COALESCE($3, sort_order), is_active = COALESCE($4, is_active), updated_at = NOW() WHERE key = $5 RETURNING *`
+ - 404 if no row.
+
+ DELETE:
+ - `requireAdmin()` guard.
+ - `DELETE FROM notify_event_keys WHERE key = $1 RETURNING key`
+ - 404 if no row. Return `{ deleted: true, key }`.
+
+ Step C — `app/admin/workflow/event-keys/page.tsx`:
+
+ - `'use client';` page modeled on `/admin/workflow/channels/page.tsx`.
+ - Header: ArrowLeft back link to `/admin/workflow`, page title "Event Keys".
+ - Body: a list of Cards or rows displaying each event key with its `display_label`, `description`, `sort_order`, `is_active` Switch, and Edit / Delete buttons.
+ - "+ New event key" button opens an inline form (Input for `key`, Input for `display_label`, Input for `description`, NumericInput for `sort_order`).
+ - All writes via `/api/admin/notify-event-keys/...` endpoints.
+ - Render error toast via `sonner` on non-2xx; success toast on save/delete.
+
+ Step D — `app/api/pipelines/[id]/executions/route.ts`:
+
+ Extend the GET handler to accept `?fallbacks_only=1`. When set, the SELECT becomes:
+
+ ```sql
+ SELECT pe.*
+ FROM pipeline_executions pe
+ WHERE pe.pipeline_id = $1
+ AND EXISTS (
+ SELECT 1 FROM pipeline_execution_steps pes
+ WHERE pes.execution_id = pe.id
+ AND pes.output_data ? 'user_route_fallback'
+ )
+ ORDER BY pe.created_at DESC
+ LIMIT $2
+ ```
+
+ Without the param, behavior is unchanged. Add `requireAuth()` to the handler if it's not already present (defense in depth — admin page already gates the surface but the API should not be unauthenticated).
+
+ Step E — `app/admin/workflow/pipelines/[id]/page.tsx`:
+
+ - Locate the recent_executions panel (currently rendered from `data.recent_executions`).
+ - Add a small filter chip / toggle ABOVE the executions list: a Switch labeled "Show only fallbacks" (use `@/components/ui/switch` and a `
+
+
+ test -f app/admin/workflow/event-keys/page.tsx && test -f app/api/admin/notify-event-keys/route.ts && test -f "app/api/admin/notify-event-keys/[key]/route.ts" && grep -q "requireAdmin" app/api/admin/notify-event-keys/route.ts && grep -q "requireAdmin" "app/api/admin/notify-event-keys/[key]/route.ts" && grep -q "notify_event_keys" app/api/admin/notify-event-keys/route.ts && grep -q "fallbacks_only\|has_fallback\|user_route_fallback" "app/api/pipelines/[id]/executions/route.ts" && grep -q "Show only fallbacks\|fallbacks_only=1" "app/admin/workflow/pipelines/[id]/page.tsx" && grep -q "has_fallback\|fallback" "app/admin/workflow/pipelines/[id]/page.tsx" && npx tsc --noEmit --pretty 2>&1 | head
+
+
+
+ - `app/admin/workflow/event-keys/page.tsx` exists, starts with `'use client';`
+ - File renders a list of event keys and has a "+ New event key" affordance
+ - File issues writes to `/api/admin/notify-event-keys` (POST, PUT, DELETE on the by-key sub-route)
+ - `app/api/admin/notify-event-keys/route.ts` exports GET and POST; both call `requireAdmin()`
+ - GET runs `SELECT ... FROM notify_event_keys ORDER BY sort_order ASC, key ASC`
+ - POST validates `key` matches `/^[a-z][a-z0-9_]*$/i`
+ - `app/api/admin/notify-event-keys/[key]/route.ts` exports PUT and DELETE; both call `requireAdmin()`
+ - PUT uses `UPDATE notify_event_keys SET ... WHERE key = $5` (or equivalent param index)
+ - `app/api/pipelines/[id]/executions/route.ts` SELECT contains `output_data ? 'user_route_fallback'` (the JSONB containment predicate)
+ - GET handler accepts `fallbacks_only` query parameter
+ - GET response per-row contains a `has_fallback` boolean
+ - `app/admin/workflow/pipelines/[id]/page.tsx` contains a Switch labeled `Show only fallbacks`
+ - File appends `fallbacks_only=1` to the executions fetch URL when the toggle is ON
+ - File renders a `fallback` (or equivalent) on rows where `has_fallback === true`
+ - `npx tsc --noEmit --pretty` reports no errors in any modified file
+
+
+
+ Admins have a real CRUD page for the event-key lookup (D-13), and the pipeline-detail recent-executions panel surfaces user_route_fallback events with a one-click filter (ROUTE-07).
+
+
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| Browser → /admin/* | Admin role required; non-admins must get 403 |
+| Browser → /api/notification-channels | Previously unauthenticated — closing the gap |
+| Browser → /api/admin/notify-event-keys | New surface; admin-only |
+| Browser → /api/pipelines/[id]/executions | New filter param; auth required |
+
+## STRIDE Threat Register (ASVS L1)
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-09-05-01 | Information Disclosure | `/api/notification-channels` GET | mitigate | Adds `requireAuth()` (was unauthenticated). Non-admin sessions are scoped to global rows only via `WHERE owner_user_id IS NULL`. Admins see all rows. Closes a pre-existing gap |
+| T-09-05-02 | Elevation of Privilege | `/api/notification-channels/[id]` PUT/DELETE | mitigate | Per-row authorization: `isAdmin || isOwner`. A non-admin can edit only their own personal channel; the legacy admin path for global channels still requires admin |
+| T-09-05-03 | Information Disclosure | Admin Owner-column rendering | accept | Webhook URLs and ntfy topics ARE shown in the admin UI — D-07 explicitly accepts this trade-off (admins can read user secrets) to enable onboarding/offboarding fixes. Mitigation: small inline disclaimer above the personal-channel list |
+| T-09-05-04 | Tampering | `/api/admin/notify-event-keys` POST | mitigate | `requireAdmin()` guard + `key` regex `^[a-z][a-z0-9_]*$/i` + length limits. `ON CONFLICT (key) DO NOTHING` prevents accidental overwrite of an existing key — UI must use PUT for updates |
+| T-09-05-05 | Elevation of Privilege | `/admin/workflow/event-keys` page | mitigate | Page issues all writes through the gated API routes; even if a non-admin reaches the page URL directly, the API returns 403. Page does not embed admin-only secrets in the rendered HTML beyond what the API would return |
+| T-09-05-06 | SQL Injection | executions endpoint `fallbacks_only` filter | mitigate | The `fallbacks_only` query param is converted to a boolean (`searchParams.get('fallbacks_only') === '1'`) and used to choose which parameterized SQL string to execute — never concatenated into the SQL. Pipeline `id` and limit remain parameterized via `$1` / `$2` |
+| T-09-05-07 | Information Disclosure / Logging | per-row `has_fallback` flag | accept | The `EXISTS()` subquery is admin-only context (page is `/admin/workflow/pipelines/[id]`). Reveals only that a fallback occurred, not the personal user_id (which is in the step output_data and only loaded on row click) |
+
+No `high` severity unmitigated. ASVS L1 satisfied: V4.1.1 (per-resource authorization), V4.2.2 (data minimization for non-admins), V5.1.3 (input validation on the event key), V12.1.1 (parameterized SQL).
+
+
+
+
+- All five admin/API files compile under `npx tsc --noEmit --pretty`.
+- Hitting `/api/notification-channels` without a session returns 401.
+- Hitting `/api/notification-channels` as a non-admin user returns only global rows.
+- Hitting `/api/notification-channels/[id]` PUT for someone else's personal row as a non-admin returns 403.
+- Hitting `/api/admin/notify-event-keys` as a non-admin returns 403.
+- The recent-executions panel renders a `fallback` badge on rows where `has_fallback === true`, and toggling "Show only fallbacks" filters to those rows.
+
+
+
+1. `/admin/workflow/channels` shows Owner column + filter; admins can edit any personal row; non-admins see only global rows.
+2. `/admin/workflow/event-keys` exists as a real CRUD page; reachable from `/admin/workflow` (the executor can either add a link there or document the URL in the SUMMARY for a future small wire-up).
+3. `/admin/workflow/pipelines/[id]` has a "Show only fallbacks" toggle and renders a fallback badge per row.
+4. All admin API routes are gated by `requireAdmin()`; the legacy notification-channels API now uses `requireAuth()` + per-row authorization.
+5. `npx tsc --noEmit --pretty` exits 0.
+
+
+