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.

242 lines
7.3 KiB
Rust

//! Syntheses CRUD handlers.
//!
//! - `GET /api/v1/syntheses` — list user's syntheses (paginated)
//! - `GET /api/v1/syntheses/:id` — get synthesis detail
//! - `DELETE /api/v1/syntheses/:id` — delete a synthesis
//! - `POST /api/v1/syntheses/:id/send-email` — send synthesis by email
//! - `GET /api/v1/syntheses/:id/export/markdown` — export as Markdown
//! - `GET /api/v1/syntheses/:id/export/pdf` — export as PDF
use axum::extract::{Path, Query, State};
use axum::http::{header, StatusCode};
use axum::response::IntoResponse;
use axum::Json;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::app_state::AppState;
use crate::db;
use crate::errors::AppError;
use crate::middleware::auth::AuthUser;
use crate::models::synthesis::{
NewsSection, SendEmailRequest, SynthesisListItem, SynthesisResponse,
};
use crate::services::email;
use crate::services::export;
/// Query parameters for `GET /api/v1/syntheses`.
#[derive(Debug, Deserialize)]
pub struct ListQuery {
/// Maximum number of syntheses to return (default: 20, max: 100).
pub limit: Option<i64>,
/// Number of syntheses to skip (default: 0).
pub offset: Option<i64>,
}
/// Response for `GET /api/v1/syntheses`.
#[derive(Debug, Serialize)]
pub struct ListResponse {
pub items: Vec<SynthesisListItem>,
}
/// `GET /api/v1/syntheses`
///
/// Returns a paginated list of the authenticated user's syntheses,
/// ordered by creation date (newest first). Each item includes a
/// preview of the first section.
pub async fn list(
auth_user: AuthUser,
State(state): State<AppState>,
Query(params): Query<ListQuery>,
) -> Result<impl IntoResponse, AppError> {
let limit = params.limit.unwrap_or(20).clamp(1, 100);
let offset = params.offset.unwrap_or(0).max(0);
let rows =
db::syntheses::list_for_user(&state.pool, auth_user.id, limit, offset).await?;
let items: Vec<SynthesisListItem> = rows
.into_iter()
.map(|row| {
let theme_name = row.theme_name.clone();
let synthesis = crate::models::synthesis::Synthesis {
id: row.id,
user_id: row.user_id,
week: row.week,
sections: row.sections,
status: row.status,
created_at: row.created_at,
job_id: row.job_id,
theme_id: row.theme_id,
};
let mut item = SynthesisListItem::try_from(synthesis)?;
item.theme_name = theme_name;
Ok(item)
})
.collect::<Result<Vec<_>, AppError>>()?;
Ok(Json(ListResponse { items }))
}
/// `GET /api/v1/syntheses/:id`
///
/// Returns the full synthesis detail including all sections and items.
/// Enforces ownership: a user can only view their own syntheses.
pub async fn get(
auth_user: AuthUser,
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
let synthesis = db::syntheses::get_by_id_for_user(&state.pool, id, auth_user.id)
.await?
.ok_or_else(|| AppError::NotFound("Synthese introuvable.".into()))?;
let response = SynthesisResponse::try_from(synthesis)?;
Ok(Json(response))
}
/// `DELETE /api/v1/syntheses/:id`
///
/// Deletes a synthesis by ID. Enforces ownership: a user can only
/// delete their own syntheses. Returns 204 No Content on success.
pub async fn delete(
auth_user: AuthUser,
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
let deleted = db::syntheses::delete(&state.pool, id, auth_user.id).await?;
if !deleted {
return Err(AppError::NotFound("Synthese introuvable.".into()));
}
tracing::info!(
user_id = %auth_user.id,
synthesis_id = %id,
"Synthesis deleted"
);
Ok(StatusCode::NO_CONTENT)
}
/// Helper: fetch a synthesis for the authenticated user and parse its sections.
async fn fetch_owned_synthesis(
state: &AppState,
user_id: uuid::Uuid,
synthesis_id: uuid::Uuid,
) -> Result<(String, String, Vec<NewsSection>), AppError> {
let synthesis = db::syntheses::get_by_id_for_user(&state.pool, synthesis_id, user_id)
.await?
.ok_or_else(|| AppError::NotFound("Synthese introuvable.".into()))?;
let sections: Vec<NewsSection> =
serde_json::from_value(synthesis.sections).map_err(|e| {
AppError::Internal(anyhow::anyhow!(
"Failed to parse synthesis sections: {}",
e
))
})?;
let date = synthesis.created_at.format("%Y-%m-%d").to_string();
Ok((synthesis.week, date, sections))
}
/// Response for `POST /api/v1/syntheses/:id/send-email`.
#[derive(Debug, Serialize)]
pub struct SendEmailResponse {
pub message: String,
}
/// `POST /api/v1/syntheses/:id/send-email`
///
/// Sends the synthesis to the specified email address via Resend.
/// Validates the email address and enforces ownership.
pub async fn send_email(
auth_user: AuthUser,
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(body): Json<SendEmailRequest>,
) -> Result<impl IntoResponse, AppError> {
body.validate()?;
let (week, date, sections) = fetch_owned_synthesis(&state, auth_user.id, id).await?;
email::send_synthesis_email(
&state.http_client,
&state.config.resend_api_key,
&state.config.email_from,
&body.email,
&week,
&date,
&sections,
)
.await?;
tracing::info!(
user_id = %auth_user.id,
synthesis_id = %id,
to = %body.email,
"Synthesis sent by email"
);
Ok(Json(SendEmailResponse {
message: "Email envoye avec succes.".into(),
}))
}
/// `GET /api/v1/syntheses/:id/export/markdown`
///
/// Returns the synthesis as a downloadable Markdown file.
/// Content-Type: text/markdown, with Content-Disposition for download.
pub async fn export_markdown(
auth_user: AuthUser,
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
let (week, date, sections) = fetch_owned_synthesis(&state, auth_user.id, id).await?;
let markdown = export::generate_markdown(&week, &date, &sections);
let filename = format!("synthese-{}.md", week);
Ok((
StatusCode::OK,
[
(header::CONTENT_TYPE, "text/markdown; charset=utf-8".to_string()),
(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{}\"", filename),
),
],
markdown,
))
}
/// `GET /api/v1/syntheses/:id/export/pdf`
///
/// Returns the synthesis as a downloadable PDF file.
/// Content-Type: application/pdf, with Content-Disposition for download.
pub async fn export_pdf(
auth_user: AuthUser,
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
let (week, date, sections) = fetch_owned_synthesis(&state, auth_user.id, id).await?;
let pdf_bytes = export::generate_pdf(&week, &date, &sections)?;
let filename = format!("synthese-{}.pdf", week);
Ok((
StatusCode::OK,
[
(header::CONTENT_TYPE, "application/pdf".to_string()),
(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{}\"", filename),
),
],
pdf_bytes,
))
}