16 KiB
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:
- Create
themestable - Populate default themes from existing
settingsrows - Add
theme_idtosources(nullable initially, then backfill) - Add
theme_idtosyntheses(nullable, no backfill needed — existing syntheses stay NULL) - Drop moved columns from
settings
- Step 1: Create the migration
-- 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
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
//! 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<Utc>,
pub updated_at: DateTime<Utc>,
}
/// Request body for creating a theme.
#[derive(Debug, Deserialize)]
pub struct CreateThemeRequest {
pub name: String,
pub theme: String,
pub categories: Vec<String>,
pub max_items_per_category: Option<i32>,
pub max_age_days: Option<i32>,
pub summary_length: Option<i32>,
}
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<String>,
pub theme: Option<String>,
pub categories: Option<Vec<String>>,
pub max_items_per_category: Option<i32>,
pub max_age_days: Option<i32>,
pub summary_length: Option<i32>,
}
/// Response shape for theme API.
#[derive(Debug, Serialize)]
pub struct ThemeResponse {
pub id: Uuid,
pub name: String,
pub theme: String,
pub categories: Vec<String>,
pub max_items_per_category: i32,
pub max_age_days: i32,
pub summary_length: i32,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl TryFrom<Theme> for ThemeResponse {
type Error = crate::errors::AppError;
fn try_from(t: Theme) -> Result<Self, Self::Error> {
let categories: Vec<String> = 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
//! 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<Vec<Theme>, 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<Option<Theme>, 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<Theme, AppError> {
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<i32>,
max_age_days: Option<i32>,
summary_length: Option<i32>,
) -> Result<Option<Theme>, 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<bool, AppError> {
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
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 themescreate— POST /themes → create a themeupdate— PUT /themes/:id → update a themedelete— DELETE /themes/:id → delete a theme
Each handler uses AuthUser extractor, calls db::themes::*, returns JSON. Create converts to ThemeResponse, list returns Vec<ThemeResponse>.
- 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):
.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
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:
UserSettingsstructUpdateSettingsRequeststructDefault for UserSettingsSettingsRow- Both SQL queries in
db/settings.rs - All test fixtures
- Frontend
UserSettingstype andDEFAULT_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
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<Uuid> 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 optionaltheme_idfilter parameter. WhenSome, addAND theme_id = $2to WHERE. -
create: addtheme_idparameter and column. -
bulk_create: addtheme_idparameter. -
Step 3: Update handlers
In handlers/sources.rs:
-
list: read optionaltheme_idfrom query params, pass to DB. -
create: passbody.theme_idto DB create. -
Step 4: Build and test
-
Step 5: Commit
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<Uuid> to the Synthesis struct.
Add theme_name: Option<String> to SynthesisListItem (for display).
Update TryFrom<Synthesis> — 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 withthemesto gettheme_name. Add optional?sort=themesupport. -
create: addtheme_idparameter. -
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
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_generationto accepttheme_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:
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<String> = serde_json::from_value(theme.categories).unwrap_or_default();
Replace all references to moved fields:
settings.theme→theme.themesettings.categories→theme_categoriessettings.max_items_per_category→theme.max_items_per_categorysettings.max_age_days→theme.max_age_dayssettings.summary_length→theme.summary_length
Also load sources filtered by theme_id:
let sources = db::sources::list_for_user(&state.pool, user_id, Some(theme_id)).await?;
And save synthesis with theme_id:
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: Uuidto the request body (newGenerateRequeststruct withtheme_idfield) -
Pass
theme_idtorun_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
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"