diff --git a/backend/src/db/mod.rs b/backend/src/db/mod.rs index ff008f8..b4f3399 100644 --- a/backend/src/db/mod.rs +++ b/backend/src/db/mod.rs @@ -9,4 +9,5 @@ pub mod sessions; pub mod settings; pub mod sources; pub mod syntheses; +pub mod themes; pub mod users; diff --git a/backend/src/db/themes.rs b/backend/src/db/themes.rs new file mode 100644 index 0000000..5529b97 --- /dev/null +++ b/backend/src/db/themes.rs @@ -0,0 +1,106 @@ +//! 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) +} diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs index 807616d..883c058 100644 --- a/backend/src/models/mod.rs +++ b/backend/src/models/mod.rs @@ -7,4 +7,5 @@ pub mod session; pub mod settings; pub mod source; pub mod synthesis; +pub mod theme; pub mod user; diff --git a/backend/src/models/theme.rs b/backend/src/models/theme.rs new file mode 100644 index 0000000..9544c55 --- /dev/null +++ b/backend/src/models/theme.rs @@ -0,0 +1,117 @@ +//! Theme model — per-topic content settings. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::errors::AppError; + +/// 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()); + } + for (i, cat) in self.categories.iter().enumerate() { + if cat.trim().is_empty() { + return Err(format!("Category at index {} cannot be empty", i)); + } + } + if let Some(max) = self.max_items_per_category { + if !(1..=50).contains(&max) { + return Err("max_items_per_category must be between 1 and 50".into()); + } + } + if let Some(days) = self.max_age_days { + if !(1..=365).contains(&days) { + return Err("max_age_days must be between 1 and 365".into()); + } + } + if let Some(sl) = self.summary_length { + if !(1..=3).contains(&sl) { + return Err("summary_length must be between 1 and 3".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 = 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, + }) + } +}