From 62fd6682c6e3b15ed49f809ce225355850a7c7a9 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Tue, 2 Jun 2026 20:43:33 +0200 Subject: [PATCH] refactor: extract view-model mappers from presentation handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move mapping logic (domain→DTO/template structs) into mappers/ module. Handlers now call mapper fns instead of inline conversions. --- crates/presentation/src/handlers/api.rs | 71 +++---------------- crates/presentation/src/handlers/html.rs | 65 +++-------------- crates/presentation/src/handlers/import.rs | 33 +-------- crates/presentation/src/lib.rs | 1 + crates/presentation/src/mappers/diary.rs | 13 ++++ crates/presentation/src/mappers/import.rs | 32 +++++++++ .../presentation/src/mappers/integrations.rs | 25 +++++++ crates/presentation/src/mappers/mod.rs | 5 ++ crates/presentation/src/mappers/movies.rs | 48 +++++++++++++ crates/presentation/src/mappers/users.rs | 42 +++++++++++ 10 files changed, 189 insertions(+), 146 deletions(-) create mode 100644 crates/presentation/src/mappers/diary.rs create mode 100644 crates/presentation/src/mappers/import.rs create mode 100644 crates/presentation/src/mappers/integrations.rs create mode 100644 crates/presentation/src/mappers/mod.rs create mode 100644 crates/presentation/src/mappers/movies.rs create mode 100644 crates/presentation/src/mappers/users.rs diff --git a/crates/presentation/src/handlers/api.rs b/crates/presentation/src/handlers/api.rs index 5ba1ab2..53a6a96 100644 --- a/crates/presentation/src/handlers/api.rs +++ b/crates/presentation/src/handlers/api.rs @@ -38,9 +38,7 @@ use application::{ }; use domain::{ errors::DomainError, - models::{ - DiaryEntry, ExportFormat, Movie, MovieSummary, PersonId, Review, collections::PageParams, - }, + models::{ExportFormat, PersonId, collections::PageParams}, services::review_history::Trend, value_objects::UserId, }; @@ -57,13 +55,13 @@ use api_types::search::{ }; use api_types::{ ActivityFeedQueryParams, ActivityFeedResponse, AddToWatchlistRequest, CastMemberDto, - CrewMemberDto, DiaryEntryDto, DiaryQueryParams, DiaryResponse, DirectorStatDto, - ExportQueryParams, FeedEntryDto, GenreDto, KeywordDto, LogReviewRequest, LoginRequest, - LoginResponse, MonthActivityDto, MonthlyRatingDto, MovieDetailResponse, MovieDto, - MovieProfileResponse, MovieStatsDto, MoviesQueryParams, MoviesResponse, PaginationQueryParams, - ProfileResponse, RegisterRequest, ReviewDto, ReviewHistoryResponse, SocialFeedResponse, - SocialReviewDto, UserProfileQueryParams, UserProfileResponse, UserStatsDto, UserSummaryDto, - UserTrendsDto, UsersResponse, WatchlistEntryDto, WatchlistResponse, WatchlistStatusResponse, + CrewMemberDto, DiaryQueryParams, DiaryResponse, DirectorStatDto, ExportQueryParams, GenreDto, + KeywordDto, LogReviewRequest, LoginRequest, LoginResponse, MonthActivityDto, MonthlyRatingDto, + MovieDetailResponse, MovieProfileResponse, MovieStatsDto, MoviesQueryParams, MoviesResponse, + PaginationQueryParams, ProfileResponse, RegisterRequest, ReviewHistoryResponse, + SocialFeedResponse, SocialReviewDto, UserProfileQueryParams, UserProfileResponse, UserStatsDto, + UserSummaryDto, UserTrendsDto, UsersResponse, WatchlistEntryDto, WatchlistResponse, + WatchlistStatusResponse, }; #[cfg(feature = "federation")] use api_types::{ @@ -573,51 +571,7 @@ pub async fn update_profile_fields_handler( } } -fn movie_to_dto(movie: &Movie) -> MovieDto { - MovieDto { - id: movie.id().value(), - title: movie.title().value().to_string(), - release_year: movie.release_year().value(), - director: movie.director().map(|d| d.to_string()), - poster_path: movie.poster_path().map(|p| p.value().to_string()), - genres: vec![], - runtime_minutes: None, - original_language: None, - overview: None, - collection_name: None, - } -} - -fn summary_to_dto(summary: &MovieSummary) -> MovieDto { - MovieDto { - id: summary.movie.id().value(), - title: summary.movie.title().value().to_string(), - release_year: summary.movie.release_year().value(), - director: summary.movie.director().map(|d| d.to_string()), - poster_path: summary.movie.poster_path().map(|p| p.value().to_string()), - genres: summary.genres.clone(), - runtime_minutes: summary.runtime_minutes, - original_language: summary.original_language.clone(), - overview: summary.overview.clone(), - collection_name: summary.collection_name.clone(), - } -} - -fn review_to_dto(review: &Review) -> ReviewDto { - ReviewDto { - id: review.id().value(), - rating: review.rating().value(), - comment: review.comment().map(|c| c.value().to_string()), - watched_at: review.watched_at().to_string(), - } -} - -fn entry_to_dto(entry: &DiaryEntry) -> DiaryEntryDto { - DiaryEntryDto { - movie: movie_to_dto(entry.movie()), - review: review_to_dto(entry.review()), - } -} +use crate::mappers::movies::{entry_to_dto, movie_to_dto, review_to_dto, summary_to_dto}; #[cfg(feature = "federation")] #[utoipa::path( @@ -1020,12 +974,7 @@ pub async fn get_activity_feed( items: page .items .iter() - .map(|e| FeedEntryDto { - movie: movie_to_dto(e.movie()), - review: review_to_dto(e.review()), - user_email: e.user_email().to_string(), - user_display_name: e.user_display_name().to_string(), - }) + .map(crate::mappers::diary::feed_entry_to_dto) .collect(), total_count: page.total_count, limit: page.limit, diff --git a/crates/presentation/src/handlers/html.rs b/crates/presentation/src/handlers/html.rs index 98e19e5..87e9211 100644 --- a/crates/presentation/src/handlers/html.rs +++ b/crates/presentation/src/handlers/html.rs @@ -430,38 +430,13 @@ pub async fn get_users_list( Ok(result) => { let users: Vec = result .users - .into_iter() - .map(|u| { - let name = u.email().split('@').next().unwrap_or("?").to_string(); - let initial = name.chars().next().unwrap_or('?').to_ascii_uppercase(); - let avg_display = u - .avg_rating - .map(|r| format!("{:.1}", r)) - .unwrap_or_else(|| "—".to_string()); - let avatar_url = u.avatar_path.map(|p| format!("/images/{}", p)); - UserSummaryView { - user_id: u.user_id.value(), - display_name: name, - initial, - avg_rating_display: avg_display, - total_movies: u.total_movies, - avatar_url, - } - }) + .iter() + .map(crate::mappers::users::user_summary_view) .collect(); let remote_actors: Vec = result .remote_actors - .into_iter() - .map(|a| { - let display = a.display_name.unwrap_or_else(|| a.handle.clone()); - let initial = display.chars().next().unwrap_or('?').to_ascii_uppercase(); - RemoteActorDisplay { - handle: a.handle, - display_name: display, - initial, - url: a.url, - } - }) + .iter() + .map(crate::mappers::users::remote_actor_display) .collect(); render_page(UsersTemplate { users, @@ -644,13 +619,8 @@ pub async fn get_user_profile( let page_items = build_page_items(total_pages, current_page); let pending_followers: Vec = profile .pending_followers - .into_iter() - .map(|p| RemoteActorData { - handle: p.handle, - url: p.url, - display_name: p.display_name, - avatar_url: p.avatar_url, - }) + .iter() + .map(crate::mappers::users::pending_follower_data) .collect(); render_page(ProfileTemplate { ctx: &ctx, @@ -1576,16 +1546,8 @@ pub async fn get_integrations_page( .unwrap_or_default(); let token_views: Vec = tokens - .into_iter() - .map(|t| template_askama::WebhookTokenView { - id: t.id().value().to_string(), - provider: t.provider().to_string(), - label: t.label().map(String::from), - created_at: t.created_at().format("%Y-%m-%d %H:%M").to_string(), - last_used_at: t - .last_used_at() - .map(|d| d.format("%Y-%m-%d %H:%M").to_string()), - }) + .iter() + .map(crate::mappers::integrations::webhook_token_view) .collect(); let webhook_base_url = state.app_ctx.config.base_url.clone(); @@ -1676,15 +1638,8 @@ pub async fn get_watch_queue_page( .unwrap_or_default(); let entries: Vec = events - .into_iter() - .map(|e| template_askama::WatchQueueDisplayEntry { - id: e.id().value().to_string(), - title: e.title().to_string(), - year: e.year(), - source: e.source().to_string(), - watched_at: e.watched_at().format("%Y-%m-%d %H:%M").to_string(), - movie_url: e.movie_id().map(|m| format!("/movies/{}", m.value())), - }) + .iter() + .map(crate::mappers::integrations::watch_queue_entry) .collect(); render_page(WatchQueueTemplate { diff --git a/crates/presentation/src/handlers/import.rs b/crates/presentation/src/handlers/import.rs index 47e7af3..235edfd 100644 --- a/crates/presentation/src/handlers/import.rs +++ b/crates/presentation/src/handlers/import.rs @@ -24,12 +24,12 @@ use application::import::{ }; use domain::models::{ AnnotatedRow, FieldMapping, FileFormat, - import::{DomainField, RowResult, Transform}, + import::{DomainField, Transform}, }; use domain::value_objects::ImportSessionId; use template_askama::{ ImportMappingTemplate, ImportPreviewRow, ImportPreviewTemplate, ImportProfileView, - ImportRowStatus, ImportUploadTemplate, + ImportUploadTemplate, }; use crate::{ @@ -98,34 +98,7 @@ fn parse_mapping_form(form: &HashMap) -> Vec { mappings } -fn annotated_to_preview_row(idx: usize, annotated: &AnnotatedRow) -> ImportPreviewRow { - match &annotated.result { - RowResult::Valid(row) => { - let cells = vec![ - row.title.clone().unwrap_or_default(), - row.release_year.clone().unwrap_or_default(), - row.director.clone().unwrap_or_default(), - row.rating.clone().unwrap_or_default(), - row.watched_at.clone().unwrap_or_default(), - row.comment.clone().unwrap_or_default(), - ]; - ImportPreviewRow { - index: idx, - status: if annotated.is_duplicate { - ImportRowStatus::Duplicate - } else { - ImportRowStatus::Valid - }, - cells, - } - } - RowResult::Invalid { errors, raw } => ImportPreviewRow { - index: idx, - status: ImportRowStatus::Invalid(errors.join("; ")), - cells: raw.iter().map(|(_, v)| v.clone()).collect(), - }, - } -} +use crate::mappers::import::annotated_to_preview_row; // ── HTML wizard handlers ─────────────────────────────────────────────────── diff --git a/crates/presentation/src/lib.rs b/crates/presentation/src/lib.rs index c230ef4..7cadd60 100644 --- a/crates/presentation/src/lib.rs +++ b/crates/presentation/src/lib.rs @@ -4,6 +4,7 @@ pub mod extractors; pub mod factory; pub mod forms; pub mod handlers; +pub mod mappers; pub mod openapi; pub mod ports; pub mod render; diff --git a/crates/presentation/src/mappers/diary.rs b/crates/presentation/src/mappers/diary.rs new file mode 100644 index 0000000..b0728d8 --- /dev/null +++ b/crates/presentation/src/mappers/diary.rs @@ -0,0 +1,13 @@ +use api_types::FeedEntryDto; +use domain::models::FeedEntry; + +use super::movies::{movie_to_dto, review_to_dto}; + +pub fn feed_entry_to_dto(e: &FeedEntry) -> FeedEntryDto { + FeedEntryDto { + movie: movie_to_dto(e.movie()), + review: review_to_dto(e.review()), + user_email: e.user_email().to_string(), + user_display_name: e.user_display_name().to_string(), + } +} diff --git a/crates/presentation/src/mappers/import.rs b/crates/presentation/src/mappers/import.rs new file mode 100644 index 0000000..f5ce702 --- /dev/null +++ b/crates/presentation/src/mappers/import.rs @@ -0,0 +1,32 @@ +use domain::models::AnnotatedRow; +use domain::models::import::RowResult; +use template_askama::{ImportPreviewRow, ImportRowStatus}; + +pub fn annotated_to_preview_row(idx: usize, annotated: &AnnotatedRow) -> ImportPreviewRow { + match &annotated.result { + RowResult::Valid(row) => { + let cells = vec![ + row.title.clone().unwrap_or_default(), + row.release_year.clone().unwrap_or_default(), + row.director.clone().unwrap_or_default(), + row.rating.clone().unwrap_or_default(), + row.watched_at.clone().unwrap_or_default(), + row.comment.clone().unwrap_or_default(), + ]; + ImportPreviewRow { + index: idx, + status: if annotated.is_duplicate { + ImportRowStatus::Duplicate + } else { + ImportRowStatus::Valid + }, + cells, + } + } + RowResult::Invalid { errors, raw } => ImportPreviewRow { + index: idx, + status: ImportRowStatus::Invalid(errors.join("; ")), + cells: raw.iter().map(|(_, v)| v.clone()).collect(), + }, + } +} diff --git a/crates/presentation/src/mappers/integrations.rs b/crates/presentation/src/mappers/integrations.rs new file mode 100644 index 0000000..f2477fb --- /dev/null +++ b/crates/presentation/src/mappers/integrations.rs @@ -0,0 +1,25 @@ +use domain::models::{WatchEvent, WebhookToken}; +use template_askama::{WatchQueueDisplayEntry, WebhookTokenView}; + +pub fn webhook_token_view(t: &WebhookToken) -> WebhookTokenView { + WebhookTokenView { + id: t.id().value().to_string(), + provider: t.provider().to_string(), + label: t.label().map(String::from), + created_at: t.created_at().format("%Y-%m-%d %H:%M").to_string(), + last_used_at: t + .last_used_at() + .map(|d| d.format("%Y-%m-%d %H:%M").to_string()), + } +} + +pub fn watch_queue_entry(e: &WatchEvent) -> WatchQueueDisplayEntry { + WatchQueueDisplayEntry { + id: e.id().value().to_string(), + title: e.title().to_string(), + year: e.year(), + source: e.source().to_string(), + watched_at: e.watched_at().format("%Y-%m-%d %H:%M").to_string(), + movie_url: e.movie_id().map(|m| format!("/movies/{}", m.value())), + } +} diff --git a/crates/presentation/src/mappers/mod.rs b/crates/presentation/src/mappers/mod.rs new file mode 100644 index 0000000..0bcdf14 --- /dev/null +++ b/crates/presentation/src/mappers/mod.rs @@ -0,0 +1,5 @@ +pub mod diary; +pub mod import; +pub mod integrations; +pub mod movies; +pub mod users; diff --git a/crates/presentation/src/mappers/movies.rs b/crates/presentation/src/mappers/movies.rs new file mode 100644 index 0000000..14c8b6b --- /dev/null +++ b/crates/presentation/src/mappers/movies.rs @@ -0,0 +1,48 @@ +use api_types::{DiaryEntryDto, MovieDto, ReviewDto}; +use domain::models::{DiaryEntry, Movie, MovieSummary, Review}; + +pub fn movie_to_dto(movie: &Movie) -> MovieDto { + MovieDto { + id: movie.id().value(), + title: movie.title().value().to_string(), + release_year: movie.release_year().value(), + director: movie.director().map(|d| d.to_string()), + poster_path: movie.poster_path().map(|p| p.value().to_string()), + genres: vec![], + runtime_minutes: None, + original_language: None, + overview: None, + collection_name: None, + } +} + +pub fn summary_to_dto(summary: &MovieSummary) -> MovieDto { + MovieDto { + id: summary.movie.id().value(), + title: summary.movie.title().value().to_string(), + release_year: summary.movie.release_year().value(), + director: summary.movie.director().map(|d| d.to_string()), + poster_path: summary.movie.poster_path().map(|p| p.value().to_string()), + genres: summary.genres.clone(), + runtime_minutes: summary.runtime_minutes, + original_language: summary.original_language.clone(), + overview: summary.overview.clone(), + collection_name: summary.collection_name.clone(), + } +} + +pub fn review_to_dto(review: &Review) -> ReviewDto { + ReviewDto { + id: review.id().value(), + rating: review.rating().value(), + comment: review.comment().map(|c| c.value().to_string()), + watched_at: review.watched_at().to_string(), + } +} + +pub fn entry_to_dto(entry: &DiaryEntry) -> DiaryEntryDto { + DiaryEntryDto { + movie: movie_to_dto(entry.movie()), + review: review_to_dto(entry.review()), + } +} diff --git a/crates/presentation/src/mappers/users.rs b/crates/presentation/src/mappers/users.rs new file mode 100644 index 0000000..7091450 --- /dev/null +++ b/crates/presentation/src/mappers/users.rs @@ -0,0 +1,42 @@ +use application::users::get_profile::PendingFollowerView; +use domain::models::UserSummary; +use domain::ports::RemoteActorInfo; +use template_askama::{RemoteActorData, RemoteActorDisplay, UserSummaryView}; + +pub fn user_summary_view(u: &UserSummary) -> UserSummaryView { + let name = u.email().split('@').next().unwrap_or("?").to_string(); + let initial = name.chars().next().unwrap_or('?').to_ascii_uppercase(); + let avg_display = u + .avg_rating + .map(|r| format!("{:.1}", r)) + .unwrap_or_else(|| "\u{2014}".to_string()); + let avatar_url = u.avatar_path.as_ref().map(|p| format!("/images/{}", p)); + UserSummaryView { + user_id: u.user_id.value(), + display_name: name, + initial, + avg_rating_display: avg_display, + total_movies: u.total_movies, + avatar_url, + } +} + +pub fn remote_actor_display(a: &RemoteActorInfo) -> RemoteActorDisplay { + let display = a.display_name.clone().unwrap_or_else(|| a.handle.clone()); + let initial = display.chars().next().unwrap_or('?').to_ascii_uppercase(); + RemoteActorDisplay { + handle: a.handle.clone(), + display_name: display, + initial, + url: a.url.clone(), + } +} + +pub fn pending_follower_data(p: &PendingFollowerView) -> RemoteActorData { + RemoteActorData { + handle: p.handle.clone(), + url: p.url.clone(), + display_name: p.display_name.clone(), + avatar_url: p.avatar_url.clone(), + } +}