movie detail page + importer architecture fix

This commit is contained in:
2026-05-10 23:59:26 +02:00
parent f2f1317660
commit b2a2aa4262
49 changed files with 1670 additions and 264 deletions

View File

@@ -13,12 +13,14 @@ use application::{
DeleteReviewCommand, ExportCommand, LoginCommand, RegisterCommand, SyncPosterCommand,
},
queries::{
GetActivityFeedQuery, GetReviewHistoryQuery, GetUserProfileQuery, GetUsersQuery,
GetActivityFeedQuery, GetMovieSocialPageQuery, GetReviewHistoryQuery, GetUserProfileQuery,
GetUsersQuery,
},
use_cases::{
delete_review, export_diary as export_diary_uc, get_activity_feed as get_feed_uc,
get_diary, get_review_history, get_user_profile as get_user_profile_uc, get_users,
log_review, login as login_uc, register as register_uc, sync_poster,
get_diary, get_movie_social_page, get_review_history,
get_user_profile as get_user_profile_uc, get_users, log_review, login as login_uc,
register as register_uc, sync_poster,
},
};
use domain::{
@@ -35,8 +37,10 @@ use crate::{
ActivityFeedQueryParams, ActivityFeedResponse, DiaryEntryDto, DiaryQueryParams,
DiaryResponse, DirectorStatDto, ExportQueryParams, FeedEntryDto, LogReviewData,
LogReviewRequest, LoginRequest, LoginResponse, MonthActivityDto, MonthlyRatingDto,
MovieDto, RegisterRequest, ReviewDto, ReviewHistoryResponse, UserProfileQueryParams,
UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto, UsersResponse,
MovieDetailResponse, MovieDto, MovieStatsDto, PaginationQueryParams, RegisterRequest,
ReviewDto, ReviewHistoryResponse, SocialFeedResponse, SocialReviewDto,
UserProfileQueryParams, UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto,
UsersResponse,
},
errors::ApiError,
extractors::AuthenticatedUser,
@@ -241,6 +245,51 @@ pub async fn delete_review(
}
}
#[utoipa::path(
get, path = "/api/v1/movies/{movie_id}",
params(("movie_id" = Uuid, Path, description = "Movie ID")),
responses(
(status = 200, body = MovieDetailResponse),
(status = 404, description = "Movie not found"),
)
)]
pub async fn get_movie_detail(
State(state): State<AppState>,
Path(movie_id): Path<Uuid>,
Query(params): Query<PaginationQueryParams>,
) -> Result<Json<MovieDetailResponse>, ApiError> {
let limit = params.limit.unwrap_or(20);
let offset = params.offset.unwrap_or(0);
let result = get_movie_social_page::execute(
&state.app_ctx,
GetMovieSocialPageQuery { movie_id, limit, offset },
)
.await?;
Ok(Json(MovieDetailResponse {
movie: movie_to_dto(&result.movie),
stats: MovieStatsDto {
total_count: result.stats.total_count,
avg_rating: result.stats.avg_rating,
federated_count: result.stats.federated_count,
rating_histogram: result.stats.rating_histogram,
},
reviews: SocialFeedResponse {
items: result.reviews.items.iter().map(|e| SocialReviewDto {
user_display: e.user_display_name().to_string(),
rating: e.review().rating().value(),
comment: e.review().comment().map(|c| c.value().to_string()),
watched_at: e.review().watched_at().to_string(),
is_federated: e.review().is_remote(),
}).collect(),
total_count: result.reviews.total_count,
limit: result.reviews.limit,
offset: result.reviews.offset,
},
}))
}
fn movie_to_dto(movie: &Movie) -> MovieDto {
MovieDto {
id: movie.id().value(),

View File

@@ -14,11 +14,13 @@ use application::ports::{FollowersPageData, FollowingPageData};
use application::{
commands::{DeleteReviewCommand, ExportCommand, LoginCommand, RegisterCommand},
ports::{
HtmlPageContext, LoginPageData, NewReviewPageData, RegisterPageData, RemoteActorView,
HtmlPageContext, LoginPageData, MovieDetailPageData, NewReviewPageData, RegisterPageData,
RemoteActorView,
},
queries::GetMovieSocialPageQuery,
use_cases::{
delete_review, export_diary as export_diary_uc, log_review, login as login_uc,
register as register_uc,
delete_review, export_diary as export_diary_uc, get_movie_social_page, log_review,
login as login_uc, register as register_uc,
},
};
use domain::models::ExportFormat;
@@ -916,3 +918,51 @@ pub async fn remove_follower(
}
}
}
pub async fn get_movie_detail(
OptionalCookieUser(user_id): OptionalCookieUser,
State(state): State<AppState>,
Path(movie_id): Path<uuid::Uuid>,
Query(params): Query<crate::dtos::PaginationQueryParams>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
let ctx = build_page_context(&state, user_id, csrf.0).await;
let limit = params.limit.unwrap_or(20);
let offset = params.offset.unwrap_or(0);
match get_movie_social_page::execute(
&state.app_ctx,
GetMovieSocialPageQuery { movie_id, limit, offset },
)
.await
{
Err(DomainError::NotFound(_)) => StatusCode::NOT_FOUND.into_response(),
Err(DomainError::ValidationError(_)) => StatusCode::BAD_REQUEST.into_response(),
Err(e) => {
tracing::error!("movie detail error: {:?}", e);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
Ok(result) => {
let histogram_max = result.stats.rating_histogram.iter().copied().max().unwrap_or(1);
let has_more = result.reviews.offset + result.reviews.limit
< result.reviews.total_count as u32;
let data = MovieDetailPageData {
ctx,
movie: result.movie,
stats: result.stats,
current_offset: result.reviews.offset,
has_more,
limit: result.reviews.limit,
reviews: result.reviews,
histogram_max,
};
match state.html_renderer.render_movie_detail_page(data) {
Ok(html) => Html(html).into_response(),
Err(e) => {
tracing::error!("template error: {}", e);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
}
}

View File

@@ -10,7 +10,7 @@ use std::collections::HashMap;
use application::{
commands::{
ApplyImportMappingCommand, CreateImportSessionCommand, DeleteImportProfileCommand,
ExecuteImportCommand, FileFormat, SaveImportProfileCommand,
ExecuteImportCommand, SaveImportProfileCommand,
},
ports::{
ImportMappingPageData, ImportPreviewPageData, ImportPreviewRow, ImportProfileView,
@@ -21,8 +21,8 @@ use application::{
list_import_profiles, save_import_profile,
},
};
use domain::models::{AnnotatedRow, FieldMapping, FileFormat, import::{DomainField, RowResult, Transform}};
use domain::value_objects::ImportSessionId;
use importer::{AnnotatedRow, DomainField, FieldMapping, RowResult, Transform};
use crate::{
csrf::CsrfToken,
@@ -220,7 +220,7 @@ pub async fn get_mapping_page(
else {
return Redirect::to("/import").into_response();
};
let Ok(parsed) = serde_json::from_str::<importer::ParsedFile>(&session.parsed_data) else {
let Some(parsed) = session.parsed_file else {
return Redirect::to("/import").into_response();
};
@@ -318,13 +318,8 @@ pub async fn get_preview_page(
return Redirect::to(&format!("/import/{}/mapping", session_id_str)).into_response();
}
let parsed =
serde_json::from_str::<importer::ParsedFile>(&session.parsed_data).unwrap_or_default();
let annotated: Vec<AnnotatedRow> = session
.row_results
.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or_default();
let parsed = session.parsed_file.unwrap_or_default();
let annotated: Vec<AnnotatedRow> = session.row_results.unwrap_or_default();
let rows: Vec<ImportPreviewRow> = annotated
.iter()
@@ -589,8 +584,7 @@ pub async fn api_get_session(
.await
{
Ok(Some(session)) => {
let parsed = serde_json::from_str::<importer::ParsedFile>(&session.parsed_data)
.unwrap_or_default();
let parsed = session.parsed_file.unwrap_or_default();
let row_count = parsed.rows.len();
axum::Json(SessionStateResponse {
session_id: session_id_str,