- 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>
22 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 09-user-profile-preferences-new | 01 | execute | 1 |
|
true |
|
|
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.tsFrom 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()).
<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>
```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>
<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>
<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>
git statusshows three new files inmigrations/plus modifications tolib/auth.tsandlib/types/pipeline.ts.npx tsc --noEmit --prettyexits 0.grep -c "additionalFields" lib/auth.tsreturns 1; the block contains a line matchingtheme:withdefaultValue: "system".- The three migrations are syntactically valid SQL and use
IF NOT EXISTSon every CREATE / ADD COLUMN. - Plan 02 (Wave 2) and Plan 03 (Wave 2) can both import the new types without further schema changes. </success_criteria>