diff --git a/backend/src/handlers/mod.rs b/backend/src/handlers/mod.rs index fd059f2..9abb378 100644 --- a/backend/src/handlers/mod.rs +++ b/backend/src/handlers/mod.rs @@ -9,3 +9,4 @@ pub mod llm_logs; pub mod settings; pub mod sources; pub mod syntheses; +pub mod themes; diff --git a/backend/src/handlers/themes.rs b/backend/src/handlers/themes.rs new file mode 100644 index 0000000..8e93157 --- /dev/null +++ b/backend/src/handlers/themes.rs @@ -0,0 +1,127 @@ +//! Themes handlers. +//! +//! - `GET /api/v1/themes` — list user's themes +//! - `POST /api/v1/themes` — create a theme +//! - `PUT /api/v1/themes/:id` — update a theme (partial) +//! - `DELETE /api/v1/themes/:id` — delete a theme (ownership check) + +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::Json; +use uuid::Uuid; + +use crate::app_state::AppState; +use crate::db; +use crate::errors::AppError; +use crate::middleware::auth::AuthUser; +use crate::models::theme::{CreateThemeRequest, ThemeResponse, UpdateThemeRequest}; + +/// `GET /api/v1/themes` +/// +/// Returns all themes belonging to the authenticated user, +/// ordered by creation date (oldest first). +pub async fn list( + auth_user: AuthUser, + State(state): State, +) -> Result { + let themes = db::themes::list_for_user(&state.pool, auth_user.id).await?; + let response: Vec = themes + .into_iter() + .map(ThemeResponse::try_from) + .collect::>()?; + Ok(Json(response)) +} + +/// `POST /api/v1/themes` +/// +/// Creates a theme for the authenticated user. +/// Validates the request fields before inserting. +pub async fn create( + auth_user: AuthUser, + State(state): State, + Json(body): Json, +) -> Result { + body.validate().map_err(AppError::Validation)?; + + let categories = serde_json::to_value(&body.categories) + .map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to serialize categories: {}", e)))?; + + let theme = db::themes::create( + &state.pool, + auth_user.id, + &body.name, + &body.theme, + &categories, + body.max_items_per_category.unwrap_or(5), + body.max_age_days.unwrap_or(7), + body.summary_length.unwrap_or(2), + ) + .await?; + + tracing::info!(user_id = %auth_user.id, theme_id = %theme.id, "Theme created"); + + let response = ThemeResponse::try_from(theme)?; + Ok((StatusCode::CREATED, Json(response))) +} + +/// `PUT /api/v1/themes/:id` +/// +/// Partially updates a theme by ID. Returns 404 if the theme doesn't exist +/// or doesn't belong to the current user. +pub async fn update( + auth_user: AuthUser, + State(state): State, + Path(id): Path, + Json(body): Json, +) -> Result { + let categories = body + .categories + .as_ref() + .map(|cats| { + serde_json::to_value(cats) + .map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to serialize categories: {}", e))) + }) + .transpose()?; + + let theme = db::themes::update( + &state.pool, + auth_user.id, + id, + body.name.as_deref(), + body.theme.as_deref(), + categories.as_ref(), + body.max_items_per_category, + body.max_age_days, + body.summary_length, + ) + .await?; + + match theme { + Some(t) => { + tracing::info!(user_id = %auth_user.id, theme_id = %id, "Theme updated"); + Ok(Json(ThemeResponse::try_from(t)?)) + } + None => Err(AppError::NotFound("Theme not found".into())), + } +} + +/// `DELETE /api/v1/themes/:id` +/// +/// Deletes a theme by ID. Returns 404 (not 403) if the theme doesn't exist +/// or doesn't belong to the current user, to avoid leaking information about +/// other users' themes. +pub async fn delete( + auth_user: AuthUser, + State(state): State, + Path(id): Path, +) -> Result { + let deleted = db::themes::delete(&state.pool, auth_user.id, id).await?; + + if !deleted { + return Err(AppError::NotFound("Theme not found".into())); + } + + tracing::info!(user_id = %auth_user.id, theme_id = %id, "Theme deleted"); + Ok(StatusCode::NO_CONTENT) +} diff --git a/backend/src/router.rs b/backend/src/router.rs index 81b2b12..12d8fbd 100644 --- a/backend/src/router.rs +++ b/backend/src/router.rs @@ -37,6 +37,11 @@ pub fn build_router(state: AppState, config: &AppConfig) -> Router { // Settings routes (authenticated) .route("/settings", get(handlers::settings::get_settings)) .route("/settings", put(handlers::settings::update_settings)) + // Themes routes (authenticated) + .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)) // Sources routes (authenticated) .route("/sources", get(handlers::sources::list)) .route("/sources", post(handlers::sources::create))