docs(09): create phase plan
This commit is contained in:
parent
480c115296
commit
e5031e5613
6 changed files with 2515 additions and 2 deletions
|
|
@ -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*
|
||||
|
|
|
|||
388
.planning/phases/09-user-profile-preferences-new/09-01-PLAN.md
Normal file
388
.planning/phases/09-user-profile-preferences-new/09-01-PLAN.md
Normal file
|
|
@ -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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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`.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.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
|
||||
|
||||
<interfaces>
|
||||
<!-- Existing types that this plan extends. -->
|
||||
|
||||
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<string, any>;
|
||||
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 '...';
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="false">
|
||||
<name>Task 1: Add theme column to user + Better Auth additionalField</name>
|
||||
<files>migrations/084_add_user_theme.sql, lib/auth.ts</files>
|
||||
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
</verify>
|
||||
|
||||
<acceptance_criteria>
|
||||
- `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`
|
||||
</acceptance_criteria>
|
||||
|
||||
<done>
|
||||
Migration file written with the exact ALTER + backfill + COMMENT structure of 083. `lib/auth.ts` exposes `theme` as a fourth additionalField. TypeScript compiles cleanly.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="false">
|
||||
<name>Task 2: Add owner_user_id to notification_channels + create event-keys / subscriptions tables</name>
|
||||
<files>migrations/085_personal_notification_channels.sql, migrations/086_notify_event_keys_and_subscriptions.sql, lib/types/pipeline.ts</files>
|
||||
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
</verify>
|
||||
|
||||
<acceptance_criteria>
|
||||
- `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
|
||||
</acceptance_criteria>
|
||||
|
||||
<done>
|
||||
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.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
|
||||
## 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).
|
||||
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- 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`).
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
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.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/09-user-profile-preferences-new/09-01-SUMMARY.md` documenting:
|
||||
- Three migration filenames and what each adds
|
||||
- The exact `theme` additionalField definition added to `lib/auth.ts`
|
||||
- The two new exported interfaces in `lib/types/pipeline.ts`
|
||||
- Confirmation that `session.user.theme` is now exposed via Better Auth (verified by checking the additionalFields block)
|
||||
- Any deviations from the plan (expected: none)
|
||||
</output>
|
||||
574
.planning/phases/09-user-profile-preferences-new/09-02-PLAN.md
Normal file
574
.planning/phases/09-user-profile-preferences-new/09-02-PLAN.md
Normal file
|
|
@ -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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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).
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.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
|
||||
|
||||
<interfaces>
|
||||
<!-- Shapes the executor needs without exploring the codebase. -->
|
||||
|
||||
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<T>(sql: string, params: any[]): Promise<{ rows: T[]; rowCount: number }>;
|
||||
postgresClient.transaction(fn: (client) => Promise<R>): Promise<R>;
|
||||
```
|
||||
|
||||
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<string, any>;
|
||||
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 })
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="false">
|
||||
<name>Task 1: GET + PUT /api/me/theme</name>
|
||||
<files>app/api/me/theme/route.ts</files>
|
||||
|
||||
<read_first>
|
||||
- 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')
|
||||
</read_first>
|
||||
|
||||
<action>
|
||||
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<NextResponse>`:
|
||||
- `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<NextResponse>`:
|
||||
- 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
|
||||
</action>
|
||||
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
</verify>
|
||||
|
||||
<acceptance_criteria>
|
||||
- `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
|
||||
</acceptance_criteria>
|
||||
|
||||
<done>
|
||||
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.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="false">
|
||||
<name>Task 2: Personal channels service + /api/me/channels routes</name>
|
||||
<files>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</files>
|
||||
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
|
||||
<action>
|
||||
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<ChannelTestResult> {
|
||||
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<string, string> = {
|
||||
'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<NextResponse> {
|
||||
const { session, error } = await requireAuth();
|
||||
if (error) return error;
|
||||
|
||||
try {
|
||||
const result = await postgresClient.query<NotificationChannel>(
|
||||
`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: <camelCase row>, test: <ChannelTestResult> })`.
|
||||
|
||||
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)
|
||||
</action>
|
||||
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
</verify>
|
||||
|
||||
<acceptance_criteria>
|
||||
- `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
|
||||
</acceptance_criteria>
|
||||
|
||||
<done>
|
||||
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.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="false">
|
||||
<name>Task 3: GET + PUT /api/me/notification-subscriptions (matrix endpoint)</name>
|
||||
<files>app/api/me/notification-subscriptions/route.ts</files>
|
||||
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
|
||||
<action>
|
||||
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<string, Record<string, boolean>>`:
|
||||
- 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.
|
||||
</action>
|
||||
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
</verify>
|
||||
|
||||
<acceptance_criteria>
|
||||
- `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
|
||||
</acceptance_criteria>
|
||||
|
||||
<done>
|
||||
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.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
|
||||
## 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).
|
||||
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- 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.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
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.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/09-user-profile-preferences-new/09-02-SUMMARY.md` documenting:
|
||||
- Each route file: path, exported methods, validation rules
|
||||
- The five symbols exported from `lib/services/personal-channels.ts`
|
||||
- The matrix shape returned by `GET /api/me/notification-subscriptions`
|
||||
- Confirmation that no route accepts user_id from request input
|
||||
- Any deviations from the plan
|
||||
</output>
|
||||
516
.planning/phases/09-user-profile-preferences-new/09-03-PLAN.md
Normal file
516
.planning/phases/09-user-profile-preferences-new/09-03-PLAN.md
Normal file
|
|
@ -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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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<string, Resolver>` 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`.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.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
|
||||
|
||||
<interfaces>
|
||||
<!-- The current notify.ts contract — preserve everything not touched here. -->
|
||||
|
||||
Current executeNotify signature (must keep this signature):
|
||||
```typescript
|
||||
async function executeNotify(
|
||||
step: PipelineStep,
|
||||
_context: PipelineContext,
|
||||
_executionId: number,
|
||||
): Promise<StepExecutorResult>;
|
||||
```
|
||||
|
||||
Existing helpers in notify.ts (preserve as-is, do NOT inline or remove):
|
||||
```typescript
|
||||
async function sendTeams(channel, config, message): Promise<StepExecutorResult>;
|
||||
async function sendTelegram(channel, message): Promise<StepExecutorResult>;
|
||||
async function sendNtfy(channel, config, message): Promise<StepExecutorResult>;
|
||||
async function sendWebhook(channel, config, message): Promise<StepExecutorResult>;
|
||||
```
|
||||
|
||||
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<string, any>; 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<string, any>; 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)
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="false">
|
||||
<name>Task 1: Add route_to_user types and resolver registry</name>
|
||||
<files>lib/types/pipeline.ts, lib/services/pipeline-steps/notify-resolvers.ts</files>
|
||||
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
|
||||
<action>
|
||||
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<ResolvedRecipient>;
|
||||
|
||||
/** 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<string, NotifyResolver>` 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<T>(...)`.
|
||||
</action>
|
||||
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
</verify>
|
||||
|
||||
<acceptance_criteria>
|
||||
- `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
|
||||
</acceptance_criteria>
|
||||
|
||||
<done>
|
||||
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.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="false">
|
||||
<name>Task 2: Rewrite executeNotify with route_to_user branch and fallback semantics</name>
|
||||
<files>lib/services/pipeline-steps/notify.ts</files>
|
||||
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
|
||||
<action>
|
||||
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<StepExecutorResult> {
|
||||
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<NotificationChannel>(
|
||||
`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<string, any>;
|
||||
message: string;
|
||||
fallback: UserRouteFallback;
|
||||
}): Promise<StepExecutorResult> {
|
||||
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.
|
||||
</action>
|
||||
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
</verify>
|
||||
|
||||
<acceptance_criteria>
|
||||
- `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)
|
||||
</acceptance_criteria>
|
||||
|
||||
<done>
|
||||
`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.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
|
||||
## 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).
|
||||
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `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.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
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.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/09-user-profile-preferences-new/09-03-SUMMARY.md` documenting:
|
||||
- Type additions to `lib/types/pipeline.ts` (RouteToUser, ResolvedRecipient, NotifyResolver, UserRouteFallback, UserRouteFallbackReason)
|
||||
- The three v1 resolvers in `notify-resolvers.ts` and how to add a fourth
|
||||
- The decision tree implemented in `executeNotify`: backward-compat path → user-route path → mute → personal-send → fallback
|
||||
- Confirmation: backward compat verified (a step without `route_to_user` runs the legacy path verbatim)
|
||||
- The `output` shapes: `user_route` (success), `skipped_reason: 'user_muted'` (mute), `user_route_fallback` (fallback)
|
||||
</output>
|
||||
637
.planning/phases/09-user-profile-preferences-new/09-04-PLAN.md
Normal file
637
.planning/phases/09-user-profile-preferences-new/09-04-PLAN.md
Normal file
|
|
@ -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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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: `<main className="px-4 pb-safe">` (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).
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.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
|
||||
|
||||
<interfaces>
|
||||
<!-- Endpoint shapes (from Plan 02) the executor consumes here. -->
|
||||
|
||||
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<string, Record<string, boolean>>
|
||||
}
|
||||
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']
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="false">
|
||||
<name>Task 1: Page shell, MoreDrawer wiring, and skeleton helper</name>
|
||||
<files>app/mobile/profile/page.tsx, components/mobile/profile/ProfileSectionSkeleton.tsx, components/mobile/MoreDrawer.tsx</files>
|
||||
|
||||
<read_first>
|
||||
- .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)
|
||||
</read_first>
|
||||
|
||||
<action>
|
||||
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 (
|
||||
<main className="px-4 pb-safe">
|
||||
<h1 className="text-xl font-semibold pt-4 pb-2">Profile & Preferences</h1>
|
||||
<div className="space-y-4">
|
||||
<ProfileTimezoneSection />
|
||||
<ProfileThemeSection />
|
||||
<ProfileNotificationMatrix />
|
||||
<ProfileChannelsSection />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
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 `<div className="rounded-2xl border overflow-hidden">` block so the inner structure becomes (in this exact order):
|
||||
|
||||
1. The current user-identity row, BUT wrapped in a `<SheetClose asChild><Link href="/mobile/profile" className="flex items-center gap-3 px-4 py-3 hover:bg-accent transition-colors border-b">...inner identity content...</Link></SheetClose>`. 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: `<SheetClose asChild><Link href="/mobile/profile" className="flex items-center gap-3 px-4 py-3 hover:bg-accent transition-colors border-b"><SettingsIcon className="w-4 h-4 text-muted-foreground shrink-0" /><span className="text-sm flex-1">Profile & preferences</span></Link></SheetClose>`.
|
||||
|
||||
3. The existing Sign-out button (unchanged).
|
||||
|
||||
- Do NOT remove or restructure any other drawer section (Mobile sections / Full site stay exactly as today).
|
||||
</action>
|
||||
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
</verify>
|
||||
|
||||
<acceptance_criteria>
|
||||
- `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 `<h1 className="text-xl font-semibold pt-4 pb-2">Profile & Preferences</h1>` (UI-SPEC heading copy)
|
||||
- Page wrapper is `<main className="px-4 pb-safe">` and inner div is `<div className="space-y-4">` (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
|
||||
</acceptance_criteria>
|
||||
|
||||
<done>
|
||||
Page shell exists, drawer routes to it, skeleton helper available for each section's loading state.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="false">
|
||||
<name>Task 2: ProfileTimezoneSection (Combobox + current time)</name>
|
||||
<files>components/mobile/profile/ProfileTimezoneSection.tsx</files>
|
||||
|
||||
<read_first>
|
||||
- .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)
|
||||
</read_first>
|
||||
|
||||
<action>
|
||||
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 `<ChevronsUpDown />` icon (lucide).
|
||||
- Popover content: `<Command><CommandInput placeholder="Search timezones…" /><CommandEmpty>No matches.</CommandEmpty><CommandList>` rendering one `<CommandItem>` 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 `<Check />` (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.
|
||||
</action>
|
||||
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
</verify>
|
||||
|
||||
<acceptance_criteria>
|
||||
- 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
|
||||
</acceptance_criteria>
|
||||
|
||||
<done>
|
||||
Timezone section renders, persists, and shows live current-time. Saves are debounced 400ms; errors don't optimistically update.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="false">
|
||||
<name>Task 3: ProfileThemeSection + ProfileNotificationMatrix</name>
|
||||
<files>components/mobile/profile/ProfileThemeSection.tsx, components/mobile/profile/ProfileNotificationMatrix.tsx</files>
|
||||
|
||||
<read_first>
|
||||
- .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)
|
||||
</read_first>
|
||||
|
||||
<action>
|
||||
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: `<Icon className="w-4 h-4" />` (Sun for Light, Moon for Dark, Monitor for System) + label span (text-sm) + a `<Check />` 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 `<ProfileSectionSkeleton />`.
|
||||
- Empty state — when `data.channelTypes.length === 0`: render `<p className="text-sm text-muted-foreground">Configure a Teams or ntfy channel below to enable personal notifications.</p>` (no skeleton, no matrix).
|
||||
- Loaded state:
|
||||
- When `channelTypes.length === 1`: render simple list of rows. Each row: `<div className="flex items-center justify-between min-h-[44px] py-3 gap-3"><span className="text-sm flex-1">{eventKey.displayLabel}</span><Switch ... /></div>`.
|
||||
- When `channelTypes.length > 1`: render a header row: `<div className="grid" style={{ gridTemplateColumns: 'minmax(0,1fr) ' + 'repeat(' + channelTypes.length + ', 4rem)' }}><span /> ... per-type column header (text-xs text-center capitalize) </div>`. 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")`.
|
||||
</action>
|
||||
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
</verify>
|
||||
|
||||
<acceptance_criteria>
|
||||
- `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 `<ProfileSectionSkeleton />` 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
|
||||
</acceptance_criteria>
|
||||
|
||||
<done>
|
||||
Theme section commits server-side and rolls back on error. Notification matrix renders with default-enabled fallback, debounced PUT per cell.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="false">
|
||||
<name>Task 4: ProfileChannelsSection (Teams + ntfy with QR code)</name>
|
||||
<files>components/mobile/profile/ProfileChannelsSection.tsx, package.json</files>
|
||||
|
||||
<read_first>
|
||||
- .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)
|
||||
</read_first>
|
||||
|
||||
<action>
|
||||
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 `<Separator className="my-4" />`.
|
||||
|
||||
**Teams sub-section:**
|
||||
- Label "Microsoft Teams webhook URL"
|
||||
- `<Input placeholder="https://yourorg.webhook.office.com/..." value={teamsInput} onChange={...} />`
|
||||
- Below input: if `teamsTest` is set, render an inline test result row (UI-SPEC pattern):
|
||||
- Success: `<div className="flex items-center gap-2 text-xs"><CheckCircle className="w-3.5 h-3.5 text-green-600" /><span className="text-green-600">Channel verified</span></div>`
|
||||
- Failure: `<div className="flex items-center gap-2 text-xs"><XCircle className="w-3.5 h-3.5 text-destructive" /><span className="text-destructive">Test failed — {test.status ?? test.error ?? 'unknown'}</span></div>`
|
||||
- Buttons row: `<Button onClick={save}>Save Teams URL</Button> <Button variant="ghost" onClick={clear} className="text-destructive">Clear</Button>` (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 `<Button className="w-full" onClick={enableNtfy}>Enable mobile push</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: `<a href={"https://ntfy.sh/" + topic} target="_blank" rel="noopener noreferrer" className="text-primary text-sm underline">{"https://ntfy.sh/" + topic}</a>`
|
||||
- QR code: `<QRCodeSVG value={"https://ntfy.sh/" + topic} size={200} />` (from `qrcode.react`). Wrap in `<div role="img" aria-label={"Subscribe to " + topic + " on ntfy"}>` for accessibility.
|
||||
- Helper line: `<p className="text-xs text-muted-foreground">Scan with the ntfy app to subscribe.</p>`
|
||||
- Test result inline (same pattern as Teams).
|
||||
- Disclosure: `<details><summary className="text-sm text-primary cursor-pointer">Edit advanced</summary>` — 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: `<Button onClick={testNow}>Test now</Button>` (calls `POST /api/me/channels/ntfy/test`) `<Button variant="ghost" onClick={removeNtfy} className="text-destructive">Remove</Button>` (calls `DELETE /api/me/channels/ntfy`).
|
||||
|
||||
All buttons use shadcn `Button` from `@/components/ui/button`. Test result icons from `lucide-react` (`CheckCircle`, `XCircle`).
|
||||
</action>
|
||||
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
</verify>
|
||||
|
||||
<acceptance_criteria>
|
||||
- `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
|
||||
</acceptance_criteria>
|
||||
|
||||
<done>
|
||||
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.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="false">
|
||||
<name>Task 5: Theme session bridge + ThemeToggle write-through</name>
|
||||
<files>components/mobile/profile/ThemeSessionBridge.tsx, components/theme-toggle.tsx, app/layout.tsx</files>
|
||||
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
|
||||
<action>
|
||||
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 `<ThemeSessionBridge />` inside `app/layout.tsx`:
|
||||
|
||||
- Find the existing `<ThemeProvider>` block.
|
||||
- Inside the `<ThemeProvider>` children, immediately AFTER `<AuthProvider>` opens (so it has access to `useSession()`), add `<ThemeSessionBridge />`. The exact placement: as the FIRST child of `<AuthProvider>`, before the existing `<div className="min-h-screen ...">`.
|
||||
- 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).
|
||||
</action>
|
||||
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
</verify>
|
||||
|
||||
<acceptance_criteria>
|
||||
- `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 `<ThemeSessionBridge />` inside `<AuthProvider>` (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
|
||||
</acceptance_criteria>
|
||||
|
||||
<done>
|
||||
Server theme is canonical: ThemeSessionBridge reconciles next-themes from `session.user.theme` on session load. ThemeToggle writes through to the server on every selection.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
|
||||
## 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).
|
||||
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `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.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
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).
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/09-user-profile-preferences-new/09-04-SUMMARY.md` documenting:
|
||||
- The 7 new files (paths and one-line descriptions)
|
||||
- The MoreDrawer Account section structure (3 rows: identity-link / profile-preferences-link / sign-out)
|
||||
- The ThemeSessionBridge sync rule (compares session.user.theme to useTheme(), calls setTheme on mismatch, validated against allowlist)
|
||||
- The ThemeToggle write-through behavior
|
||||
- The qrcode.react version pinned in package.json
|
||||
- Whether `npm install` was run during the plan or is required as a follow-up
|
||||
- Any deviations from the UI-SPEC (expected: none)
|
||||
</output>
|
||||
393
.planning/phases/09-user-profile-preferences-new/09-05-PLAN.md
Normal file
393
.planning/phases/09-user-profile-preferences-new/09-05-PLAN.md
Normal file
|
|
@ -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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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).
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.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
|
||||
|
||||
<interfaces>
|
||||
<!-- From Plan 01 schema (already landed): -->
|
||||
|
||||
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 }
|
||||
|
||||
<!-- Auth helpers (existing): -->
|
||||
requireAuth() / requireAdmin() / requirePermission(resource, action) from lib/auth-utils.ts
|
||||
|
||||
<!-- Existing admin Channels page state (today; you'll extend, not replace): -->
|
||||
- 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
|
||||
|
||||
<!-- Existing executions endpoint shape (legacy /api/workflow/executions vs pipeline-engine): -->
|
||||
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
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="false">
|
||||
<name>Task 1: Owner column + role-scoped reads on /admin/workflow/channels</name>
|
||||
<files>app/api/notification-channels/route.ts, app/api/notification-channels/[id]/route.ts, app/admin/workflow/channels/page.tsx</files>
|
||||
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
|
||||
<action>
|
||||
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`: `<Badge variant="secondary">Global</Badge>`
|
||||
- Else: `<Badge>Personal: {channel.owner_email ?? channel.owner_user_id}</Badge>` (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'`: `<p className="text-xs text-muted-foreground">Personal channels contain user-supplied webhook URLs — handle with care.</p>` (operational hygiene).
|
||||
</action>
|
||||
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
</verify>
|
||||
|
||||
<acceptance_criteria>
|
||||
- `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
|
||||
</acceptance_criteria>
|
||||
|
||||
<done>
|
||||
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.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="false">
|
||||
<name>Task 2: /admin/workflow/event-keys CRUD page + API + pipeline executions fallback filter</name>
|
||||
<files>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</files>
|
||||
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
|
||||
<action>
|
||||
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 `<Label>`). When ON, append `?fallbacks_only=1` to the executions fetch URL and refresh.
|
||||
- For each execution row, when the executions response contains a `has_fallback: true` flag (added by Step D's SELECT — extend it to also return `EXISTS(...) AS has_fallback` so the UI can badge per row), render a `<Badge variant="outline">fallback</Badge>` next to the existing status icon.
|
||||
|
||||
Note for Step D: To keep the per-row badge cheap, extend the SELECT (always, not only with the filter) to:
|
||||
```sql
|
||||
SELECT pe.*,
|
||||
EXISTS (
|
||||
SELECT 1 FROM pipeline_execution_steps pes
|
||||
WHERE pes.execution_id = pe.id
|
||||
AND pes.output_data ? 'user_route_fallback'
|
||||
) AS has_fallback
|
||||
FROM pipeline_executions pe
|
||||
WHERE pe.pipeline_id = $1
|
||||
${fallbacks_only ? 'AND has_fallback' : ''} -- composed as parameter; never raw concat
|
||||
ORDER BY pe.created_at DESC
|
||||
LIMIT $2
|
||||
```
|
||||
|
||||
The `has_fallback` filter clause must be conditionally included via a TS branch, not string concatenation of a user value. The query is parameterized; only the predicate-presence is conditional.
|
||||
</action>
|
||||
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
</verify>
|
||||
|
||||
<acceptance_criteria>
|
||||
- `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 `<Badge>fallback</Badge>` (or equivalent) on rows where `has_fallback === true`
|
||||
- `npx tsc --noEmit --pretty` reports no errors in any modified file
|
||||
</acceptance_criteria>
|
||||
|
||||
<done>
|
||||
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).
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
|
||||
## 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).
|
||||
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- 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.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
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.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/09-user-profile-preferences-new/09-05-SUMMARY.md` documenting:
|
||||
- The Owner column rendering rule (Global vs Personal: {email})
|
||||
- The owner-filter URL parameter values supported by `/api/notification-channels`
|
||||
- The new event-keys CRUD page URL and the regex used to validate event keys
|
||||
- The exact JSONB predicate added to the executions query (`output_data ? 'user_route_fallback'`)
|
||||
- The `has_fallback` per-row flag added to the executions response
|
||||
- The deliberate redirection of ROUTE-07's `/admin/workflow/executions` filter onto the per-pipeline detail page (with rationale: pipeline_execution_steps table is the source of `user_route_fallback`, and that's where the existing pipeline-engine executions UI lives)
|
||||
- Whether a link to `/admin/workflow/event-keys` was added on `/admin/workflow` (small wire-up — note in SUMMARY if deferred)
|
||||
</output>
|
||||
Loading…
Add table
Add a link
Reference in a new issue