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
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,
|
|
§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<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, §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<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, §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,
|
|
))
|
|
}
|