use crate::extractors::{Body, Params, PathId}; use crate::support::build_export_response; use crate::{ errors::ApiError, extractors::AuthenticatedUser, html::forms::{LogReviewData, to_diary_query}, state::AppState, }; use api_types::{ ActivityFeedQueryParams, ActivityFeedResponse, DiaryQueryParams, DiaryResponse, EditReviewRequest, ExportQueryParams, LogReviewRequest, }; use application::diary::{ commands::{DeleteReviewCommand, EditReviewCommand}, delete_review, edit_review, get_activity_feed as get_feed_uc, get_diary, log_review, queries::GetActivityFeedQuery, }; use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; use uuid::Uuid; #[utoipa::path( get, path = "/api/v1/diary", params(DiaryQueryParams), responses( (status = 200, body = DiaryResponse), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn get_diary( State(state): State, Params(params): Params, ) -> Result, ApiError> { let page = get_diary::execute(&state.app_ctx.deps.diary.get_diary, to_diary_query(params)).await?; Ok(Json(DiaryResponse { items: page .items .iter() .map(api_types::mappers::entry_to_dto) .collect(), total_count: page.total_count, limit: page.limit, offset: page.offset, })) } #[utoipa::path( post, path = "/api/v1/reviews", request_body = LogReviewRequest, responses( (status = 201, description = "Review created"), (status = 400, description = "Invalid input", body = api_types::ErrorResponse), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn post_review( State(state): State, user: AuthenticatedUser, Body(req): Body, ) -> Result { let data = LogReviewData::try_from(req).map_err(ApiError)?; log_review::execute( &state.app_ctx.services.review_logger, data.into_command(user.0.value()), ) .await?; Ok(StatusCode::CREATED) } #[utoipa::path( delete, path = "/api/v1/reviews/{id}", params(("id" = Uuid, Path, description = "Review ID")), responses( (status = 204, description = "Review deleted"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), (status = 403, description = "Forbidden", body = api_types::ErrorResponse), (status = 404, description = "Review not found", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn delete_review( State(state): State, AuthenticatedUser(user_id): AuthenticatedUser, PathId(review_id): PathId, ) -> Result { let cmd = DeleteReviewCommand { review_id, requesting_user_id: user_id.value(), }; delete_review::execute(&state.app_ctx.deps.diary.delete_review, cmd).await?; Ok(StatusCode::NO_CONTENT) } #[utoipa::path( patch, path = "/api/v1/reviews/{id}", request_body = EditReviewRequest, params(("id" = Uuid, Path, description = "Review ID")), responses( (status = 200, description = "Review updated"), (status = 400, description = "Invalid input", body = api_types::ErrorResponse), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), (status = 403, description = "Forbidden", body = api_types::ErrorResponse), (status = 404, description = "Review not found", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn patch_review( State(state): State, AuthenticatedUser(user_id): AuthenticatedUser, PathId(review_id): PathId, Body(req): Body, ) -> Result { let watched_at = req .watched_at .map(|s| domain::value_objects::parse_watched_at(&s).map_err(ApiError)) .transpose()?; let cmd = EditReviewCommand { review_id, requesting_user_id: user_id.value(), rating: req.rating, comment: req.comment, watched_at, watch_medium: req.watch_medium, spoilers: req.spoilers, }; edit_review::execute(&state.app_ctx.deps.diary.edit_review, cmd).await?; Ok(StatusCode::OK) } #[utoipa::path( get, path = "/api/v1/diary/export", params(ExportQueryParams), responses( (status = 200, description = "Diary file download", content_type = "text/csv"), (status = 400, description = "Invalid format parameter", body = api_types::ErrorResponse), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn export_diary( State(state): State, user: AuthenticatedUser, Params(params): Params, ) -> impl IntoResponse { if !matches!(params.format.as_str(), "csv" | "json") { return crate::errors::refuse( StatusCode::BAD_REQUEST, crate::errors::INVALID_INPUT, "format must be csv or json", ); } build_export_response(¶ms.format, user.0.value(), &state) } #[utoipa::path( get, path = "/api/v1/activity-feed", params(ActivityFeedQueryParams), responses( (status = 200, body = ActivityFeedResponse), (status = 400, description = "Invalid query", body = api_types::ErrorResponse), ), )] pub async fn get_activity_feed( State(state): State, Params(params): Params, ) -> Result, ApiError> { let page = get_feed_uc::execute( &state.app_ctx.deps.diary.get_activity_feed, GetActivityFeedQuery { limit: params.limit.unwrap_or(20), offset: params.offset.unwrap_or(0), sort_by: params .sort_by .as_deref() .map(|s| s.parse().unwrap_or_default()) .unwrap_or_default(), search: None, viewer_user_id: None, filter_following: false, }, ) .await?; Ok(Json(ActivityFeedResponse { items: page .items .iter() .map(api_types::mappers::feed_entry_to_dto) .collect(), total_count: page.total_count, limit: page.limit, offset: page.offset, })) }