use axum::{ Json, extract::{Extension, Path, State}, http::StatusCode, response::IntoResponse, }; use chrono::NaiveDate; use uuid::Uuid; use application::wrapup::{ commands::RequestWrapUpCommand, delete as delete_wrapup, generate, get_wrapup, list_wrapups::{self, ListWrapUpsQuery}, }; use domain::errors::DomainError; use domain::models::wrapup::{WrapUpRecord, WrapUpReport, WrapUpStatus}; use domain::value_objects::WrapUpId; use crate::{ csrf::CsrfToken, errors::ApiError, extractors::{AdminApiUser, AuthenticatedUser, OptionalCookieUser}, render::render_page, 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"), (status = 403, description = "Forbidden — admin only"), ), security(("bearer_auth" = [])) )] pub async fn post_generate( State(state): State, _admin: AdminApiUser, 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 = req.user_id; let cmd = RequestWrapUpCommand { user_id, start_date: start, end_date: end, }; let id = generate::execute( state.app_ctx.repos.wrapup_repo.clone(), state.app_ctx.services.event_publisher.clone(), 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.repos.wrapup_repo.clone(), 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.repos.wrapup_repo.clone(), 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.repos.wrapup_repo.clone(), WrapUpId::from_uuid(id)).await { Ok(Some(record)) if record.status == WrapUpStatus::Ready => match record.report { Some(ref report) => { let json = serde_json::to_string(report).unwrap_or_default(); (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), } } #[utoipa::path( delete, path = "/api/v1/wrapups/{id}", params(("id" = Uuid, Path, description = "Wrap-up ID")), responses( (status = 204, description = "Deleted"), (status = 401, description = "Unauthorized"), (status = 403, description = "Forbidden — admin only"), (status = 404, description = "Not found"), ), security(("bearer_auth" = [])) )] pub async fn delete_wrapup_handler( State(state): State, _admin: AdminApiUser, Path(id): Path, ) -> Result { delete_wrapup::execute(state.app_ctx.repos.wrapup_repo.clone(), WrapUpId::from_uuid(id)).await?; Ok(StatusCode::NO_CONTENT) } // ── HTML handlers ─────────────────────────────────────────────────────────── fn format_watch_time(minutes: u32) -> String { let h = minutes / 60; let m = minutes % 60; if h > 0 && m > 0 { format!("{}h {}m", h, m) } else if h > 0 { format!("{}h", h) } else { format!("{}m", m) } } fn render_wrapup( report: &WrapUpReport, year: i32, ctx: &application::ports::HtmlPageContext, ) -> axum::response::Response { let rating_max = report .rating_distribution .iter() .copied() .max() .unwrap_or(1) .max(1); let rating_pcts: [f64; 5] = std::array::from_fn(|i| report.rating_distribution[i] as f64 / rating_max as f64 * 100.0); let genre_max = report .top_genres .first() .map(|g| g.count) .unwrap_or(1) .max(1); let genre_pcts: Vec = report .top_genres .iter() .take(8) .map(|g| g.count as f64 / genre_max as f64 * 100.0) .collect(); let tmpl = template_askama::WrapUpPageTemplate { ctx, report, year_label: year.to_string(), watch_time_display: format_watch_time(report.total_watch_time_minutes), rating_max, genre_max, rating_pcts, genre_pcts, }; render_page(tmpl) } pub async fn get_user_wrapup_html( OptionalCookieUser(viewer): OptionalCookieUser, State(state): State, Path((user_id, year)): Path<(Uuid, i32)>, Extension(csrf): Extension, ) -> impl IntoResponse { let start = match NaiveDate::from_ymd_opt(year, 1, 1) { Some(d) => d, None => return StatusCode::BAD_REQUEST.into_response(), }; let end = match NaiveDate::from_ymd_opt(year + 1, 1, 1) { Some(d) => d, None => return StatusCode::BAD_REQUEST.into_response(), }; let record = match state .app_ctx .repos .wrapup_repo .find_existing(Some(user_id), start, end) .await { Ok(Some(r)) if r.status == WrapUpStatus::Ready => r, _ => return StatusCode::NOT_FOUND.into_response(), }; let report = match record.report { Some(r) => r, None => return StatusCode::NOT_FOUND.into_response(), }; let ctx = super::helpers::build_page_context(&state, viewer, csrf.0).await; render_wrapup(&report, year, &ctx) } pub async fn get_global_wrapup_html( OptionalCookieUser(viewer): OptionalCookieUser, State(state): State, Path(year): Path, Extension(csrf): Extension, ) -> impl IntoResponse { let start = match NaiveDate::from_ymd_opt(year, 1, 1) { Some(d) => d, None => return StatusCode::BAD_REQUEST.into_response(), }; let end = match NaiveDate::from_ymd_opt(year + 1, 1, 1) { Some(d) => d, None => return StatusCode::BAD_REQUEST.into_response(), }; let record = match state .app_ctx .repos .wrapup_repo .find_existing(None, start, end) .await { Ok(Some(r)) if r.status == WrapUpStatus::Ready => r, _ => return StatusCode::NOT_FOUND.into_response(), }; let report = match record.report { Some(r) => r, None => return StatusCode::NOT_FOUND.into_response(), }; let ctx = super::helpers::build_page_context(&state, viewer, csrf.0).await; render_wrapup(&report, year, &ctx) }