You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
ai_synth/docs/superpowers/plans/2026-03-26-multi-theme-phas...

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:

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

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

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

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.themetheme.theme
  • settings.categoriestheme_categories
  • settings.max_items_per_categorytheme.max_items_per_category
  • settings.max_age_daystheme.max_age_days
  • settings.summary_lengththeme.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, &sections_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
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"