//! 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, /// Number of syntheses to skip (default: 0). pub offset: Option, } /// Response for `GET /api/v1/syntheses`. #[derive(Debug, Serialize)] pub struct ListResponse { pub items: Vec, } /// `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, Query(params): Query, ) -> Result { 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 = 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::, 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, Path(id): Path, ) -> Result { 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, Path(id): Path, ) -> Result { 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), 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 = 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, Path(id): Path, Json(body): Json, ) -> Result { 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, §ions, ) .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, Path(id): Path, ) -> Result { let (week, date, sections) = fetch_owned_synthesis(&state, auth_user.id, id).await?; let markdown = export::generate_markdown(&week, &date, §ions); 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, Path(id): Path, ) -> Result { let (week, date, sections) = fetch_owned_synthesis(&state, auth_user.id, id).await?; let pdf_bytes = export::generate_pdf(&week, &date, §ions)?; 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, )) }