From 0ab9a99906fb01c3faae9c953267e67ca17a7e54 Mon Sep 17 00:00:00 2001 From: oabrivard Date: Thu, 26 Mar 2026 22:55:07 +0100 Subject: [PATCH] docs: add implementation plan for multi-theme Phase 1 (data model + API) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-03-26-multi-theme-phase1.md | 539 ++++++++++++++++++ 1 file changed, 539 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-26-multi-theme-phase1.md diff --git a/docs/superpowers/plans/2026-03-26-multi-theme-phase1.md b/docs/superpowers/plans/2026-03-26-multi-theme-phase1.md new file mode 100644 index 0000000..36dfb03 --- /dev/null +++ b/docs/superpowers/plans/2026-03-26-multi-theme-phase1.md @@ -0,0 +1,539 @@ +# Multi-Theme Phase 1: Data Model + Backend API — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Create the `themes` table, migrate existing content settings from `settings` to themes, add theme_id to sources and syntheses, create themes CRUD API, and update the pipeline to load content settings from the selected theme. + +**Architecture:** New `themes` entity owns per-topic content settings (name, theme, categories, max_items, max_age, summary_length). Sources and syntheses get a `theme_id` FK. The pipeline accepts `theme_id` and loads content from the theme instead of settings. A migration creates a default theme per user from their existing settings. + +**Tech Stack:** Rust (Axum, sqlx), PostgreSQL + +**Spec:** `docs/superpowers/specs/2026-03-26-multi-theme-design.md` — Phase 1 + +--- + +### Task 1: Migration — create themes table + migrate data + +**Files:** +- Create: `backend/migrations/20260326000028_create_themes_and_migrate.sql` + +This single migration does everything atomically: +1. Create `themes` table +2. Populate default themes from existing `settings` rows +3. Add `theme_id` to `sources` (nullable initially, then backfill) +4. Add `theme_id` to `syntheses` (nullable, no backfill needed — existing syntheses stay NULL) +5. Drop moved columns from `settings` + +- [ ] **Step 1: Create the migration** + +```sql +-- 1. Create themes table +CREATE TABLE themes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + theme TEXT NOT NULL, + categories JSONB NOT NULL DEFAULT '[]', + max_items_per_category INTEGER NOT NULL DEFAULT 4, + max_age_days INTEGER NOT NULL DEFAULT 7, + summary_length INTEGER NOT NULL DEFAULT 3, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_themes_user_id ON themes(user_id); + +-- 2. Migrate existing settings to default themes +INSERT INTO themes (user_id, name, theme, categories, max_items_per_category, max_age_days, summary_length) +SELECT user_id, theme, theme, categories, max_items_per_category, max_age_days, summary_length +FROM settings; + +-- 3. Add theme_id to sources (nullable) +ALTER TABLE sources ADD COLUMN theme_id UUID REFERENCES themes(id) ON DELETE CASCADE; + +-- 4. Backfill sources with the user's default theme +UPDATE sources s +SET theme_id = t.id +FROM themes t +WHERE s.user_id = t.user_id; + +-- 5. Add theme_id to syntheses (nullable, SET NULL on theme deletion) +ALTER TABLE syntheses ADD COLUMN theme_id UUID REFERENCES themes(id) ON DELETE SET NULL; + +-- 6. Backfill syntheses with the user's default theme +UPDATE syntheses sy +SET theme_id = t.id +FROM themes t +WHERE sy.user_id = t.user_id; + +-- 7. Drop moved columns from settings +ALTER TABLE settings DROP COLUMN theme; +ALTER TABLE settings DROP COLUMN categories; +ALTER TABLE settings DROP COLUMN max_items_per_category; +ALTER TABLE settings DROP COLUMN max_age_days; +ALTER TABLE settings DROP COLUMN summary_length; +``` + +- [ ] **Step 2: Update CLAUDE.md** + +Change to `## Database (28 migrations)`. + +- [ ] **Step 3: Commit** + +```bash +git add backend/migrations/20260326000028_create_themes_and_migrate.sql CLAUDE.md +git commit -m "feat: create themes table and migrate content settings from settings" +``` + +--- + +### Task 2: Theme model + DB module + +**Files:** +- Create: `backend/src/models/theme.rs` +- Create: `backend/src/db/themes.rs` +- Modify: `backend/src/models/mod.rs` +- Modify: `backend/src/db/mod.rs` + +- [ ] **Step 1: Create `models/theme.rs`** + +```rust +//! Theme model — per-topic content settings. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// A user's theme with content settings. +#[derive(Debug, Clone, Serialize, sqlx::FromRow)] +pub struct Theme { + pub id: Uuid, + pub user_id: Uuid, + pub name: String, + pub theme: String, + pub categories: serde_json::Value, + pub max_items_per_category: i32, + pub max_age_days: i32, + pub summary_length: i32, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// Request body for creating a theme. +#[derive(Debug, Deserialize)] +pub struct CreateThemeRequest { + pub name: String, + pub theme: String, + pub categories: Vec, + pub max_items_per_category: Option, + pub max_age_days: Option, + pub summary_length: Option, +} + +impl CreateThemeRequest { + pub fn validate(&self) -> Result<(), String> { + if self.name.trim().is_empty() { + return Err("Theme name cannot be empty".into()); + } + if self.name.len() > 200 { + return Err("Theme name must be at most 200 characters".into()); + } + if self.theme.trim().is_empty() { + return Err("Theme search topic cannot be empty".into()); + } + if self.categories.is_empty() { + return Err("Categories cannot be empty".into()); + } + if self.categories.len() > 20 { + return Err("At most 20 categories are allowed".into()); + } + Ok(()) + } +} + +/// Request body for updating a theme. +#[derive(Debug, Deserialize)] +pub struct UpdateThemeRequest { + pub name: Option, + pub theme: Option, + pub categories: Option>, + pub max_items_per_category: Option, + pub max_age_days: Option, + pub summary_length: Option, +} + +/// Response shape for theme API. +#[derive(Debug, Serialize)] +pub struct ThemeResponse { + pub id: Uuid, + pub name: String, + pub theme: String, + pub categories: Vec, + pub max_items_per_category: i32, + pub max_age_days: i32, + pub summary_length: i32, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl TryFrom for ThemeResponse { + type Error = crate::errors::AppError; + fn try_from(t: Theme) -> Result { + let categories: Vec = serde_json::from_value(t.categories) + .unwrap_or_default(); + Ok(Self { + id: t.id, + name: t.name, + theme: t.theme, + categories, + max_items_per_category: t.max_items_per_category, + max_age_days: t.max_age_days, + summary_length: t.summary_length, + created_at: t.created_at, + updated_at: t.updated_at, + }) + } +} +``` + +- [ ] **Step 2: Create `db/themes.rs`** + +```rust +//! Database queries for the `themes` table. + +use sqlx::PgPool; +use uuid::Uuid; + +use crate::errors::AppError; +use crate::models::theme::Theme; + +pub async fn list_for_user(pool: &PgPool, user_id: Uuid) -> Result, AppError> { + let themes = sqlx::query_as::<_, Theme>( + "SELECT * FROM themes WHERE user_id = $1 ORDER BY created_at ASC" + ) + .bind(user_id) + .fetch_all(pool) + .await?; + Ok(themes) +} + +pub async fn get_by_id(pool: &PgPool, user_id: Uuid, id: Uuid) -> Result, AppError> { + let theme = sqlx::query_as::<_, Theme>( + "SELECT * FROM themes WHERE id = $1 AND user_id = $2" + ) + .bind(id) + .bind(user_id) + .fetch_optional(pool) + .await?; + Ok(theme) +} + +pub async fn create( + pool: &PgPool, + user_id: Uuid, + name: &str, + theme: &str, + categories: &serde_json::Value, + max_items_per_category: i32, + max_age_days: i32, + summary_length: i32, +) -> Result { + let row = sqlx::query_as::<_, Theme>( + r#" + INSERT INTO themes (user_id, name, theme, categories, max_items_per_category, max_age_days, summary_length) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING * + "#, + ) + .bind(user_id) + .bind(name) + .bind(theme) + .bind(categories) + .bind(max_items_per_category) + .bind(max_age_days) + .bind(summary_length) + .fetch_one(pool) + .await?; + Ok(row) +} + +pub async fn update( + pool: &PgPool, + user_id: Uuid, + id: Uuid, + name: Option<&str>, + theme: Option<&str>, + categories: Option<&serde_json::Value>, + max_items_per_category: Option, + max_age_days: Option, + summary_length: Option, +) -> Result, AppError> { + let row = sqlx::query_as::<_, Theme>( + r#" + UPDATE themes SET + name = COALESCE($3, name), + theme = COALESCE($4, theme), + categories = COALESCE($5, categories), + max_items_per_category = COALESCE($6, max_items_per_category), + max_age_days = COALESCE($7, max_age_days), + summary_length = COALESCE($8, summary_length), + updated_at = now() + WHERE id = $1 AND user_id = $2 + RETURNING * + "#, + ) + .bind(id) + .bind(user_id) + .bind(name) + .bind(theme) + .bind(categories) + .bind(max_items_per_category) + .bind(max_age_days) + .bind(summary_length) + .fetch_optional(pool) + .await?; + Ok(row) +} + +pub async fn delete(pool: &PgPool, user_id: Uuid, id: Uuid) -> Result { + let result = sqlx::query( + "DELETE FROM themes WHERE id = $1 AND user_id = $2" + ) + .bind(id) + .bind(user_id) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) +} +``` + +- [ ] **Step 3: Register modules** + +In `src/models/mod.rs`, add: `pub mod theme;` +In `src/db/mod.rs`, add: `pub mod themes;` + +- [ ] **Step 4: Build** + +Run: `cd backend && cargo build` + +- [ ] **Step 5: Commit** + +```bash +git add backend/src/models/theme.rs backend/src/db/themes.rs backend/src/models/mod.rs backend/src/db/mod.rs +git commit -m "feat: add Theme model and DB queries" +``` + +--- + +### Task 3: Themes CRUD handler + routes + +**Files:** +- Create: `backend/src/handlers/themes.rs` +- Modify: `backend/src/handlers/mod.rs` +- Modify: `backend/src/router.rs` + +- [ ] **Step 1: Create `handlers/themes.rs`** + +Standard CRUD handler following the patterns in `handlers/sources.rs`: +- `list` — GET /themes → list user's themes +- `create` — POST /themes → create a theme +- `update` — PUT /themes/:id → update a theme +- `delete` — DELETE /themes/:id → delete a theme + +Each handler uses `AuthUser` extractor, calls `db::themes::*`, returns JSON. Create converts to `ThemeResponse`, list returns `Vec`. + +- [ ] **Step 2: Register handler module** + +In `src/handlers/mod.rs`, add: `pub mod themes;` + +- [ ] **Step 3: Add routes** + +In `src/router.rs`, add theme routes (before source routes): +```rust +.route("/themes", get(handlers::themes::list)) +.route("/themes", post(handlers::themes::create)) +.route("/themes/{id}", put(handlers::themes::update)) +.route("/themes/{id}", delete(handlers::themes::delete)) +``` + +- [ ] **Step 4: Build and test** + +Run: `cd backend && cargo build && cargo test --lib` + +- [ ] **Step 5: Commit** + +```bash +git add backend/src/handlers/themes.rs backend/src/handlers/mod.rs backend/src/router.rs +git commit -m "feat: add themes CRUD endpoints" +``` + +--- + +### Task 4: Update settings — remove moved fields + +**Files:** +- Modify: `backend/src/models/settings.rs` +- Modify: `backend/src/db/settings.rs` +- Modify: `backend/src/services/prompts.rs` (test fixture) +- Modify: `backend/tests/api_settings_test.rs` +- Modify: `backend/tests/pipeline_test.rs` +- Modify: `e2e/tests/generation-live.spec.ts` +- Modify: `frontend/src/types.ts` + +Remove `theme`, `categories`, `max_items_per_category`, `max_age_days`, `summary_length` from: +- `UserSettings` struct +- `UpdateSettingsRequest` struct +- `Default for UserSettings` +- `SettingsRow` +- Both SQL queries in `db/settings.rs` +- All test fixtures +- Frontend `UserSettings` type and `DEFAULT_SETTINGS` + +This is the same mechanical removal pattern we've done many times. + +- [ ] **Step 1: Remove from Rust structs + DB queries** +- [ ] **Step 2: Remove from all test fixtures** +- [ ] **Step 3: Remove from frontend types** +- [ ] **Step 4: Build and test** +- [ ] **Step 5: Commit** + +```bash +git add backend/src/models/settings.rs backend/src/db/settings.rs \ + backend/src/services/prompts.rs backend/tests/ \ + e2e/tests/ frontend/src/types.ts +git commit -m "refactor: remove content settings from settings (moved to themes)" +``` + +--- + +### Task 5: Update sources — add theme_id + +**Files:** +- Modify: `backend/src/models/source.rs` +- Modify: `backend/src/db/sources.rs` +- Modify: `backend/src/handlers/sources.rs` + +- [ ] **Step 1: Update Source model** + +Add `theme_id: Option` to the `Source` struct. +Add `theme_id: Uuid` to `CreateSourceRequest` (required for new sources). + +- [ ] **Step 2: Update DB queries** + +In `db/sources.rs`: +- `list_for_user`: add optional `theme_id` filter parameter. When `Some`, add `AND theme_id = $2` to WHERE. +- `create`: add `theme_id` parameter and column. +- `bulk_create`: add `theme_id` parameter. + +- [ ] **Step 3: Update handlers** + +In `handlers/sources.rs`: +- `list`: read optional `theme_id` from query params, pass to DB. +- `create`: pass `body.theme_id` to DB create. + +- [ ] **Step 4: Build and test** +- [ ] **Step 5: Commit** + +```bash +git add backend/src/models/source.rs backend/src/db/sources.rs backend/src/handlers/sources.rs +git commit -m "feat: add theme_id to sources" +``` + +--- + +### Task 6: Update syntheses — add theme_id + theme_name + +**Files:** +- Modify: `backend/src/models/synthesis.rs` +- Modify: `backend/src/db/syntheses.rs` +- Modify: `backend/src/handlers/syntheses.rs` +- Modify: `frontend/src/types.ts` + +- [ ] **Step 1: Update Synthesis model** + +Add `theme_id: Option` to the `Synthesis` struct. +Add `theme_name: Option` to `SynthesisListItem` (for display). + +Update `TryFrom` — `theme_name` cannot be extracted from the Synthesis row alone; it needs a JOIN or a separate query. The simplest approach: add `theme_name` as an optional field populated by the list query via a LEFT JOIN. + +- [ ] **Step 2: Update DB queries** + +In `db/syntheses.rs`: +- `list_for_user`: LEFT JOIN with `themes` to get `theme_name`. Add optional `?sort=theme` support. +- `create`: add `theme_id` parameter. + +- [ ] **Step 3: Update frontend types** + +Add `theme_name: string | null` and `theme_id: string | null` to `SynthesisListItem`. + +- [ ] **Step 4: Build and test** +- [ ] **Step 5: Commit** + +```bash +git add backend/src/models/synthesis.rs backend/src/db/syntheses.rs \ + backend/src/handlers/syntheses.rs frontend/src/types.ts +git commit -m "feat: add theme_id and theme_name to syntheses" +``` + +--- + +### Task 7: Update pipeline — load content from theme + +**Files:** +- Modify: `backend/src/services/synthesis.rs` +- Modify: `backend/src/handlers/generation.rs` + +This is the core change. The pipeline currently loads content settings from `settings`. It needs to load them from the selected `theme` instead. + +- [ ] **Step 1: Update `run_generation` to accept `theme_id`** + +Add `theme_id: Uuid` parameter to both `run_generation` and `run_generation_inner`. + +- [ ] **Step 2: Load theme in the pipeline** + +In `run_generation_inner`, after loading `settings`, load the theme: +```rust +let theme = db::themes::get_by_id(&state.pool, user_id, theme_id).await? + .ok_or_else(|| AppError::BadRequest("Theme introuvable.".into()))?; +let theme_categories: Vec = serde_json::from_value(theme.categories).unwrap_or_default(); +``` + +Replace all references to moved fields: +- `settings.theme` → `theme.theme` +- `settings.categories` → `theme_categories` +- `settings.max_items_per_category` → `theme.max_items_per_category` +- `settings.max_age_days` → `theme.max_age_days` +- `settings.summary_length` → `theme.summary_length` + +Also load sources filtered by `theme_id`: +```rust +let sources = db::sources::list_for_user(&state.pool, user_id, Some(theme_id)).await?; +``` + +And save synthesis with `theme_id`: +```rust +let synthesis = db::syntheses::create(&state.pool, user_id, &week, §ions_json, job_id, Some(theme_id)).await?; +``` + +- [ ] **Step 3: Update the handler** + +In `handlers/generation.rs`: +- Add `theme_id: Uuid` to the request body (new `GenerateRequest` struct with `theme_id` field) +- Pass `theme_id` to `run_generation` + +- [ ] **Step 4: Update mock provider tests** + +In `backend/tests/pipeline_test.rs`: +- Create a theme in the test setup before calling `run_generation_inner` +- Pass the theme_id + +- [ ] **Step 5: Build and test** + +Run: `cd backend && cargo build && cargo test --lib` + +- [ ] **Step 6: Commit** + +```bash +git add backend/src/services/synthesis.rs backend/src/handlers/generation.rs \ + backend/tests/pipeline_test.rs +git commit -m "feat: pipeline loads content settings from selected theme" +```