feat: add themes CRUD endpoints
Implements GET/POST/PUT/DELETE /api/v1/themes handlers following the same patterns as sources.rs, registers the module, and wires up routes in the router. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>master
parent
467ad550a5
commit
10b8d950b9
@ -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<AppState>,
|
||||||
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
let themes = db::themes::list_for_user(&state.pool, auth_user.id).await?;
|
||||||
|
let response: Vec<ThemeResponse> = themes
|
||||||
|
.into_iter()
|
||||||
|
.map(ThemeResponse::try_from)
|
||||||
|
.collect::<Result<_, _>>()?;
|
||||||
|
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<AppState>,
|
||||||
|
Json(body): Json<CreateThemeRequest>,
|
||||||
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
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<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(body): Json<UpdateThemeRequest>,
|
||||||
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
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<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue