297 lines
9.2 KiB
Rust
297 lines
9.2 KiB
Rust
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<AppState>,
|
|
_admin: AdminApiUser,
|
|
Json(req): Json<GenerateWrapUpRequest>,
|
|
) -> Result<Json<WrapUpGeneratedResponse>, 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<AppState>,
|
|
user: AuthenticatedUser,
|
|
) -> Result<Json<WrapUpListResponse>, 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<AppState>,
|
|
_user: AuthenticatedUser,
|
|
Path(id): Path<Uuid>,
|
|
) -> Result<Json<WrapUpStatusResponse>, 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<AppState>,
|
|
_user: AuthenticatedUser,
|
|
Path(id): Path<Uuid>,
|
|
) -> 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<AppState>,
|
|
_admin: AdminApiUser,
|
|
Path(id): Path<Uuid>,
|
|
) -> Result<StatusCode, ApiError> {
|
|
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<f64> = 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<AppState>,
|
|
Path((user_id, year)): Path<(Uuid, i32)>,
|
|
Extension(csrf): Extension<CsrfToken>,
|
|
) -> 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<AppState>,
|
|
Path(year): Path<i32>,
|
|
Extension(csrf): Extension<CsrfToken>,
|
|
) -> 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)
|
|
}
|