wulf-pulse/.planning/phases/09-user-profile-preferences-new/09-01-PLAN.md
lorentz 7b644e475f docs(planning): sync STATE/ROADMAP + capture untracked phase plans before master merge
- STATE/ROADMAP/config updated to reflect Phase 09.1 execution
- 09-01 plan refreshed (gap-closure detail)
- 09-02..09-05 plans updated during execution
- Add untracked 09-06 plan + 01-01/01-02 PWA scaffolding plans (orphaned from earlier sessions)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 07:30:51 -04:00

22 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
09-user-profile-preferences-new 01 execute 1
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
true
THEME-01
THEME-05
CHAN-01
SUB-01
SUB-02
truths artifacts key_links
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)
notification_channels has a partial unique index on (owner_user_id, channel_type) WHERE owner_user_id IS NOT NULL — defense-in-depth for the API-layer UPSERT in Plan 02
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)
path provides contains
migrations/084_add_user_theme.sql theme column on user with backfill ALTER TABLE "user"
path provides contains
migrations/085_personal_notification_channels.sql owner_user_id column + partial unique index on notification_channels ALTER TABLE notification_channels
path provides contains
migrations/086_notify_event_keys_and_subscriptions.sql two new tables for event taxonomy and per-user subscriptions CREATE TABLE IF NOT EXISTS notify_event_keys
path provides contains
lib/auth.ts theme additionalField theme:
path provides contains
lib/types/pipeline.ts NotificationChannel.owner_user_id, NotifyEventKey, UserEventSubscription owner_user_id
from to via pattern
migrations/084_add_user_theme.sql user table ALTER TABLE ADD COLUMN theme TEXT NOT NULL DEFAULT 'system' DEFAULT 'system'
from to via pattern
lib/auth.ts additionalFields session.user.theme Better Auth field exposure theme:.*type:.*string
Land the foundational schema and types for Phase 9. This plan adds: 1. `theme` column on `"user"` (Better Auth) + Better Auth `additionalFields` exposure (THEME-01, THEME-05) 2. `owner_user_id` column on `notification_channels` plus a partial unique index that enforces "one personal Teams + one personal ntfy per user" at the database level (CHAN-01, defense-in-depth for D-02) 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.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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

From lib/types/pipeline.ts (current — needs owner_user_id):

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):

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):

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 '...';

From migrations/012_create_auth_tables.sql (Better Auth "user" table column casing — IMPORTANT, read this carefully):

CREATE TABLE IF NOT EXISTS "user" (
    id TEXT PRIMARY KEY,
    name TEXT NOT NULL,
    email TEXT NOT NULL UNIQUE,
    email_verified BOOLEAN NOT NULL DEFAULT FALSE,
    image TEXT,
    role TEXT DEFAULT 'user',
    banned BOOLEAN DEFAULT FALSE,
    banned_reason TEXT,
    ban_expires TIMESTAMP,
    requires_setup BOOLEAN DEFAULT FALSE,
    created_at TIMESTAMP NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);

The Better Auth "user" table uses snake_case unquoted columns (updated_at, created_at, email_verified, requires_setup). This contradicts a stale precedent in app/api/me/timezone/route.ts which writes "updatedAt" (a quoted camelCase identifier that does NOT exist in this schema). Plan 02 corrects that error — Plan 01 itself does not write to updated_at (the migration relies on the column DEFAULT NOW()).

Task 1: Add theme column to user + Better Auth additionalField migrations/084_add_user_theme.sql, lib/auth.ts

<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 that its audit columns are snake_case unquoted: updated_at, NOT "updatedAt") - 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>

Step A — Create `migrations/084_add_user_theme.sql` with exactly:
```sql
-- =============================================================================
-- Per-user theme preference (Phase 9 — THEME-01, THEME-05)
-- =============================================================================
-- Adds a `theme` column to the Better Auth "user" table so light/dark/system
-- preference persists server-side and applies on sign-in across devices.
--
-- Allowed values: 'light' | 'dark' | 'system' (validated in API layer).
-- Default 'system' means next-themes detects OS preference at render time —
-- zero behavior change for existing signed-in users.
-- =============================================================================

ALTER TABLE "user"
  ADD COLUMN IF NOT EXISTS theme TEXT NOT NULL DEFAULT 'system';

-- Defensive backfill (DEFAULT covers new inserts, but on managed Postgres a
-- column added with DEFAULT may briefly show NULL in flight on some replicas).
UPDATE "user" SET theme = 'system' WHERE theme IS NULL;

COMMENT ON COLUMN "user".theme IS
  'User theme preference: light | dark | system. Applied app-wide via next-themes; server is canonical.';
```

Step B — In `lib/auth.ts` add a `theme` entry to `user.additionalFields` immediately after the existing `timezone` entry:

```typescript
timezone: {
  type: "string",
  defaultValue: process.env.DEFAULT_TIMEZONE || "UTC",
},
theme: {
  type: "string",
  defaultValue: "system",
},
```

Do NOT change anything else in `lib/auth.ts`. The new field becomes available as `session.user.theme` on the next session refresh — same mechanism that exposed `session.user.timezone` in Phase 7.1.
grep -q "ADD COLUMN IF NOT EXISTS theme TEXT NOT NULL DEFAULT 'system'" migrations/084_add_user_theme.sql && grep -q 'theme:' lib/auth.ts && grep -q "defaultValue: \"system\"" lib/auth.ts && npx tsc --noEmit --pretty 2>&1 | grep -E "(auth.ts|error)" | head

<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>

Migration file written with the exact ALTER + backfill + COMMENT structure of 083. `lib/auth.ts` exposes `theme` as a fourth additionalField. TypeScript compiles cleanly. Task 2: Add owner_user_id + partial unique index to notification_channels + create event-keys / subscriptions tables migrations/085_personal_notification_channels.sql, migrations/086_notify_event_keys_and_subscriptions.sql, lib/types/pipeline.ts

<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-02 singular per-type, D-03 owner_user_id, D-13 event keys, D-14 subscriptions, D-15 default-enabled) </read_first>

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.
--
-- Defense-in-depth: a partial unique index enforces one-personal-channel-per-
-- type-per-user at the DB layer (D-02 / CHAN-02). The API-layer WITH-CTE UPSERT
-- in Plan 02 still has a small race window for concurrent saves; this index
-- closes that gap. The WHERE clause keeps existing global rows (NULL owner)
-- exempt — multiple global rows of the same type can still 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;

-- One personal channel per (owner, type). Globals (NULL owner) unaffected.
CREATE UNIQUE INDEX IF NOT EXISTS notification_channels_owner_user_id_channel_type_uniq
  ON notification_channels (owner_user_id, channel_type)
  WHERE owner_user_id IS NOT NULL;

COMMENT ON COLUMN notification_channels.owner_user_id IS
  'Personal channel owner. NULL = global channel (admin-managed). NOT NULL = personal channel reachable only by the owner and admins.';
```

Step B — Create `migrations/086_notify_event_keys_and_subscriptions.sql`:

```sql
-- =============================================================================
-- Notify event taxonomy + per-user subscriptions (Phase 9 — SUB-01, SUB-02)
-- =============================================================================
-- notify_event_keys: humanization layer for the event keys that appear in
--   pipeline_steps.config->'route_to_user'->>'event_key'. Admin-managed at
--   /admin/workflow/event-keys. NOT a gate — keys not in this table are
--   still routable; they just render with the raw key as their label.
--
-- user_event_subscriptions: opt-out matrix per (user, event_key, channel_type).
--   Row absence = enabled (D-15 default-enabled / opt-out).
-- =============================================================================

CREATE TABLE IF NOT EXISTS notify_event_keys (
  key TEXT PRIMARY KEY,
  display_label TEXT NOT NULL,
  description TEXT,
  sort_order INTEGER NOT NULL DEFAULT 0,
  is_active BOOLEAN NOT NULL DEFAULT true,
  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_notify_event_keys_active
  ON notify_event_keys(is_active, sort_order, key);

CREATE TABLE IF NOT EXISTS user_event_subscriptions (
  user_id      TEXT        NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
  event_key    TEXT        NOT NULL,
  channel_type VARCHAR(20) NOT NULL,
  enabled      BOOLEAN     NOT NULL DEFAULT true,
  updated_at   TIMESTAMP   NOT NULL DEFAULT NOW(),
  PRIMARY KEY (user_id, event_key, channel_type)
);

CREATE INDEX IF NOT EXISTS idx_user_event_subs_user
  ON user_event_subscriptions(user_id);

COMMENT ON TABLE notify_event_keys IS
  'Humanization layer for event keys used in notify-step route_to_user blocks. Admins curate display_label/description/sort. Not a gate — unknown keys are still routable.';
COMMENT ON TABLE user_event_subscriptions IS
  'Opt-out matrix. Row absence = enabled. Composite PK enforces one row per (user, event_key, channel_type).';

-- Seed an example row so the admin UI is not empty on first run.
INSERT INTO notify_event_keys (key, display_label, description, sort_order, is_active)
VALUES ('ticket_assigned_to_me', 'Ticket assigned to me', 'Fires when an Autotask ticket is assigned to your resource.', 10, true)
ON CONFLICT (key) DO NOTHING;
```

Step C — In `lib/types/pipeline.ts`:

1. Add `owner_user_id: string | null;` to the `NotificationChannel` interface, immediately after the `is_active` line.
2. Append two new exported interfaces at the bottom of the file (after `NotificationChannelInput`):

```typescript
// ============================================================================
// Phase 9 — Event taxonomy + user subscriptions
// ============================================================================

export interface NotifyEventKey {
  key: string;
  display_label: string;
  description: string | null;
  sort_order: number;
  is_active: boolean;
  created_at: Date;
  updated_at: Date;
}

export interface UserEventSubscription {
  user_id: string;
  event_key: string;
  channel_type: ChannelType;
  enabled: boolean;
  updated_at: Date;
}
```

Do NOT touch any other type in this file. Do NOT export a barrel index.
grep -q "ADD COLUMN IF NOT EXISTS owner_user_id" migrations/085_personal_notification_channels.sql && grep -q "notification_channels_owner_user_id_channel_type_uniq" 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

<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 - File contains CREATE UNIQUE INDEX IF NOT EXISTS notification_channels_owner_user_id_channel_type_uniq with the predicate WHERE owner_user_id IS NOT NULL (defense-in-depth for the API-layer UPSERT race window — medium 10 from plan checker) - 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>

Two new migrations land at the end of the migration sequence (head moves 083 → 086). The partial unique index closes the API-layer UPSERT race window. `lib/types/pipeline.ts` types match the new schema. TypeScript compiles cleanly.

<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 06); 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
T-09-01-06 Race Condition / Duplicate Insertion notification_channels personal UPSERT mitigate Partial unique index notification_channels_owner_user_id_channel_type_uniq enforces one-row-per-(owner,type) at the DB layer, closing the small race window in the API-layer WITH-CTE UPSERT (Plan 02). Globals (NULL owner) remain exempt

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>

- 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`). - Partial unique index uses `IF NOT EXISTS` and the matching `WHERE owner_user_id IS NOT NULL` predicate so re-running the migration on an already-migrated database is a no-op.

<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>
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) - Confirmation the partial unique index `notification_channels_owner_user_id_channel_type_uniq` exists with the correct WHERE predicate - Any deviations from the plan (expected: none)