From 5a15bea3d4beebdcfb69330d71ac4bd8694a5d76 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Tue, 2 Jun 2026 22:17:11 +0200 Subject: [PATCH] feat: wrap-up REST API endpoints --- crates/api-types/src/lib.rs | 1 + crates/api-types/src/wrapup.rs | 30 +++++ crates/presentation/src/handlers/mod.rs | 1 + crates/presentation/src/handlers/wrapup.rs | 147 +++++++++++++++++++++ crates/presentation/src/openapi/mod.rs | 2 + crates/presentation/src/openapi/wrapup.rs | 18 +++ crates/presentation/src/routes.rs | 10 ++ 7 files changed, 209 insertions(+) create mode 100644 crates/api-types/src/wrapup.rs create mode 100644 crates/presentation/src/handlers/wrapup.rs create mode 100644 crates/presentation/src/openapi/wrapup.rs diff --git a/crates/api-types/src/lib.rs b/crates/api-types/src/lib.rs index 833f3bf..f2b604e 100644 --- a/crates/api-types/src/lib.rs +++ b/crates/api-types/src/lib.rs @@ -8,6 +8,7 @@ pub mod social; pub mod users; pub mod watchlist; pub mod webhook; +pub mod wrapup; pub use auth::*; pub use common::*; diff --git a/crates/api-types/src/wrapup.rs b/crates/api-types/src/wrapup.rs new file mode 100644 index 0000000..0d5426c --- /dev/null +++ b/crates/api-types/src/wrapup.rs @@ -0,0 +1,30 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct GenerateWrapUpRequest { + pub start_date: String, + pub end_date: String, + pub global: Option, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct WrapUpGeneratedResponse { + pub id: String, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct WrapUpStatusResponse { + pub id: String, + pub user_id: Option, + pub status: String, + pub start_date: String, + pub end_date: String, + pub created_at: String, + pub completed_at: Option, + pub error_message: Option, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct WrapUpListResponse { + pub items: Vec, +} diff --git a/crates/presentation/src/handlers/mod.rs b/crates/presentation/src/handlers/mod.rs index 7383f8e..3b619cc 100644 --- a/crates/presentation/src/handlers/mod.rs +++ b/crates/presentation/src/handlers/mod.rs @@ -4,6 +4,7 @@ pub mod images; pub mod import; pub mod rss; pub mod webhook; +pub mod wrapup; const DEFAULT_PAGE_LIMIT: u32 = 5; const RSS_FEED_LIMIT: u32 = 50; diff --git a/crates/presentation/src/handlers/wrapup.rs b/crates/presentation/src/handlers/wrapup.rs new file mode 100644 index 0000000..fa1acd0 --- /dev/null +++ b/crates/presentation/src/handlers/wrapup.rs @@ -0,0 +1,147 @@ +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, +}; +use chrono::NaiveDate; +use uuid::Uuid; + +use application::wrapup::{ + commands::RequestWrapUpCommand, + generate, get_wrapup, + list_wrapups::{self, ListWrapUpsQuery}, +}; +use domain::errors::DomainError; +use domain::models::wrapup::{WrapUpRecord, WrapUpStatus}; +use domain::value_objects::WrapUpId; + +use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; +use api_types::wrapup::{ + GenerateWrapUpRequest, WrapUpGeneratedResponse, WrapUpListResponse, WrapUpStatusResponse, +}; + +fn record_to_dto(r: &WrapUpRecord) -> WrapUpStatusResponse { + WrapUpStatusResponse { + id: r.id.value().to_string(), + user_id: r.user_id.map(|u| u.to_string()), + status: format!("{:?}", r.status), + start_date: r.start_date.to_string(), + end_date: r.end_date.to_string(), + created_at: r.created_at.to_string(), + completed_at: r.completed_at.map(|t| t.to_string()), + error_message: r.error_message.clone(), + } +} + +#[utoipa::path( + post, path = "/api/v1/wrapups/generate", + request_body = GenerateWrapUpRequest, + responses( + (status = 200, body = WrapUpGeneratedResponse), + (status = 400, description = "Invalid date format"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn post_generate( + State(state): State, + user: AuthenticatedUser, + Json(req): Json, +) -> Result, ApiError> { + let start = NaiveDate::parse_from_str(&req.start_date, "%Y-%m-%d") + .map_err(|_| DomainError::ValidationError("invalid start_date".into()))?; + let end = NaiveDate::parse_from_str(&req.end_date, "%Y-%m-%d") + .map_err(|_| DomainError::ValidationError("invalid end_date".into()))?; + let user_id = if req.global.unwrap_or(false) { + None + } else { + Some(user.0.value()) + }; + let cmd = RequestWrapUpCommand { + user_id, + start_date: start, + end_date: end, + }; + let id = generate::execute(&state.app_ctx, cmd).await?; + Ok(Json(WrapUpGeneratedResponse { + id: id.value().to_string(), + })) +} + +#[utoipa::path( + get, path = "/api/v1/wrapups", + responses( + (status = 200, body = WrapUpListResponse), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_list( + State(state): State, + user: AuthenticatedUser, +) -> Result, ApiError> { + let records = list_wrapups::execute( + &state.app_ctx, + ListWrapUpsQuery { + user_id: Some(user.0.value()), + }, + ) + .await?; + Ok(Json(WrapUpListResponse { + items: records.iter().map(record_to_dto).collect(), + })) +} + +#[utoipa::path( + get, path = "/api/v1/wrapups/{id}", + params(("id" = Uuid, Path, description = "Wrap-up ID")), + responses( + (status = 200, body = WrapUpStatusResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_status( + State(state): State, + _user: AuthenticatedUser, + Path(id): Path, +) -> Result, ApiError> { + let record = get_wrapup::execute(&state.app_ctx, WrapUpId::from_uuid(id)) + .await? + .ok_or_else(|| DomainError::NotFound("wrap-up not found".into()))?; + Ok(Json(record_to_dto(&record))) +} + +#[utoipa::path( + get, path = "/api/v1/wrapups/{id}/report", + params(("id" = Uuid, Path, description = "Wrap-up ID")), + responses( + (status = 200, description = "Report JSON", content_type = "application/json"), + (status = 202, description = "Still generating"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_report( + State(state): State, + _user: AuthenticatedUser, + Path(id): Path, +) -> impl IntoResponse { + match get_wrapup::execute(&state.app_ctx, WrapUpId::from_uuid(id)).await { + Ok(Some(record)) if record.status == WrapUpStatus::Ready => match record.report_json { + Some(json) => ( + StatusCode::OK, + [("content-type", "application/json")], + json, + ) + .into_response(), + None => StatusCode::NOT_FOUND.into_response(), + }, + Ok(Some(_)) => StatusCode::ACCEPTED.into_response(), + Ok(None) => StatusCode::NOT_FOUND.into_response(), + Err(e) => crate::errors::domain_error_response(e), + } +} diff --git a/crates/presentation/src/openapi/mod.rs b/crates/presentation/src/openapi/mod.rs index c466065..a340f96 100644 --- a/crates/presentation/src/openapi/mod.rs +++ b/crates/presentation/src/openapi/mod.rs @@ -7,6 +7,7 @@ mod social; mod users; mod watchlist; mod webhook; +mod wrapup; use axum::Router; use utoipa::{ @@ -42,6 +43,7 @@ fn build() -> utoipa::openapi::OpenApi { api.merge(search::SearchDoc::openapi()); api.merge(watchlist::WatchlistDoc::openapi()); api.merge(webhook::WebhookDoc::openapi()); + api.merge(wrapup::WrapUpDoc::openapi()); #[cfg(feature = "federation")] api.merge(social::SocialDoc::openapi()); SecurityAddon.modify(&mut api); diff --git a/crates/presentation/src/openapi/wrapup.rs b/crates/presentation/src/openapi/wrapup.rs new file mode 100644 index 0000000..7c47c6e --- /dev/null +++ b/crates/presentation/src/openapi/wrapup.rs @@ -0,0 +1,18 @@ +use utoipa::OpenApi; + +#[derive(OpenApi)] +#[openapi( + paths( + crate::handlers::wrapup::post_generate, + crate::handlers::wrapup::get_list, + crate::handlers::wrapup::get_status, + crate::handlers::wrapup::get_report, + ), + components(schemas( + api_types::wrapup::GenerateWrapUpRequest, + api_types::wrapup::WrapUpGeneratedResponse, + api_types::wrapup::WrapUpStatusResponse, + api_types::wrapup::WrapUpListResponse, + )) +)] +pub struct WrapUpDoc; diff --git a/crates/presentation/src/routes.rs b/crates/presentation/src/routes.rs index 46a28db..7f2c043 100644 --- a/crates/presentation/src/routes.rs +++ b/crates/presentation/src/routes.rs @@ -346,6 +346,16 @@ fn api_routes(rate_limit: u64) -> Router { .route( "/watch-queue/dismiss", routing::post(handlers::webhook::post_dismiss_watch_events), + ) + .route( + "/wrapups/generate", + routing::post(handlers::wrapup::post_generate), + ) + .route("/wrapups", routing::get(handlers::wrapup::get_list)) + .route("/wrapups/{id}", routing::get(handlers::wrapup::get_status)) + .route( + "/wrapups/{id}/report", + routing::get(handlers::wrapup::get_report), ); #[cfg(feature = "federation")]