# 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" ```