docs: add implementation plan for multi-theme Phase 1 (data model + API)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>master
parent
4dc18a4e72
commit
0ab9a99906
@ -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<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`**
|
||||
|
||||
```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<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**
|
||||
|
||||
```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<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):
|
||||
```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<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**
|
||||
|
||||
```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<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**
|
||||
|
||||
```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<String> = 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"
|
||||
```
|
||||
Loading…
Reference in New Issue