refactor: extract view-model mappers from presentation handlers
Some checks failed
CI / Check / Test (push) Has been cancelled

Move mapping logic (domain→DTO/template structs) into mappers/ module.
Handlers now call mapper fns instead of inline conversions.
This commit is contained in:
2026-06-02 20:43:33 +02:00
parent b9210b6c4e
commit 62fd6682c6
10 changed files with 189 additions and 146 deletions

View File

@@ -38,9 +38,7 @@ use application::{
}; };
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::{ models::{ExportFormat, PersonId, collections::PageParams},
DiaryEntry, ExportFormat, Movie, MovieSummary, PersonId, Review, collections::PageParams,
},
services::review_history::Trend, services::review_history::Trend,
value_objects::UserId, value_objects::UserId,
}; };
@@ -57,13 +55,13 @@ use api_types::search::{
}; };
use api_types::{ use api_types::{
ActivityFeedQueryParams, ActivityFeedResponse, AddToWatchlistRequest, CastMemberDto, ActivityFeedQueryParams, ActivityFeedResponse, AddToWatchlistRequest, CastMemberDto,
CrewMemberDto, DiaryEntryDto, DiaryQueryParams, DiaryResponse, DirectorStatDto, CrewMemberDto, DiaryQueryParams, DiaryResponse, DirectorStatDto, ExportQueryParams, GenreDto,
ExportQueryParams, FeedEntryDto, GenreDto, KeywordDto, LogReviewRequest, LoginRequest, KeywordDto, LogReviewRequest, LoginRequest, LoginResponse, MonthActivityDto, MonthlyRatingDto,
LoginResponse, MonthActivityDto, MonthlyRatingDto, MovieDetailResponse, MovieDto, MovieDetailResponse, MovieProfileResponse, MovieStatsDto, MoviesQueryParams, MoviesResponse,
MovieProfileResponse, MovieStatsDto, MoviesQueryParams, MoviesResponse, PaginationQueryParams, PaginationQueryParams, ProfileResponse, RegisterRequest, ReviewHistoryResponse,
ProfileResponse, RegisterRequest, ReviewDto, ReviewHistoryResponse, SocialFeedResponse, SocialFeedResponse, SocialReviewDto, UserProfileQueryParams, UserProfileResponse, UserStatsDto,
SocialReviewDto, UserProfileQueryParams, UserProfileResponse, UserStatsDto, UserSummaryDto, UserSummaryDto, UserTrendsDto, UsersResponse, WatchlistEntryDto, WatchlistResponse,
UserTrendsDto, UsersResponse, WatchlistEntryDto, WatchlistResponse, WatchlistStatusResponse, WatchlistStatusResponse,
}; };
#[cfg(feature = "federation")] #[cfg(feature = "federation")]
use api_types::{ use api_types::{
@@ -573,51 +571,7 @@ pub async fn update_profile_fields_handler(
} }
} }
fn movie_to_dto(movie: &Movie) -> MovieDto { use crate::mappers::movies::{entry_to_dto, movie_to_dto, review_to_dto, summary_to_dto};
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()),
}
}
#[cfg(feature = "federation")] #[cfg(feature = "federation")]
#[utoipa::path( #[utoipa::path(
@@ -1020,12 +974,7 @@ pub async fn get_activity_feed(
items: page items: page
.items .items
.iter() .iter()
.map(|e| FeedEntryDto { .map(crate::mappers::diary::feed_entry_to_dto)
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(),
})
.collect(), .collect(),
total_count: page.total_count, total_count: page.total_count,
limit: page.limit, limit: page.limit,

View File

@@ -430,38 +430,13 @@ pub async fn get_users_list(
Ok(result) => { Ok(result) => {
let users: Vec<UserSummaryView> = result let users: Vec<UserSummaryView> = result
.users .users
.into_iter() .iter()
.map(|u| { .map(crate::mappers::users::user_summary_view)
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,
}
})
.collect(); .collect();
let remote_actors: Vec<RemoteActorDisplay> = result let remote_actors: Vec<RemoteActorDisplay> = result
.remote_actors .remote_actors
.into_iter() .iter()
.map(|a| { .map(crate::mappers::users::remote_actor_display)
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,
}
})
.collect(); .collect();
render_page(UsersTemplate { render_page(UsersTemplate {
users, users,
@@ -644,13 +619,8 @@ pub async fn get_user_profile(
let page_items = build_page_items(total_pages, current_page); let page_items = build_page_items(total_pages, current_page);
let pending_followers: Vec<RemoteActorData> = profile let pending_followers: Vec<RemoteActorData> = profile
.pending_followers .pending_followers
.into_iter() .iter()
.map(|p| RemoteActorData { .map(crate::mappers::users::pending_follower_data)
handle: p.handle,
url: p.url,
display_name: p.display_name,
avatar_url: p.avatar_url,
})
.collect(); .collect();
render_page(ProfileTemplate { render_page(ProfileTemplate {
ctx: &ctx, ctx: &ctx,
@@ -1576,16 +1546,8 @@ pub async fn get_integrations_page(
.unwrap_or_default(); .unwrap_or_default();
let token_views: Vec<template_askama::WebhookTokenView> = tokens let token_views: Vec<template_askama::WebhookTokenView> = tokens
.into_iter() .iter()
.map(|t| template_askama::WebhookTokenView { .map(crate::mappers::integrations::webhook_token_view)
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()),
})
.collect(); .collect();
let webhook_base_url = state.app_ctx.config.base_url.clone(); 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(); .unwrap_or_default();
let entries: Vec<template_askama::WatchQueueDisplayEntry> = events let entries: Vec<template_askama::WatchQueueDisplayEntry> = events
.into_iter() .iter()
.map(|e| template_askama::WatchQueueDisplayEntry { .map(crate::mappers::integrations::watch_queue_entry)
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())),
})
.collect(); .collect();
render_page(WatchQueueTemplate { render_page(WatchQueueTemplate {

View File

@@ -24,12 +24,12 @@ use application::import::{
}; };
use domain::models::{ use domain::models::{
AnnotatedRow, FieldMapping, FileFormat, AnnotatedRow, FieldMapping, FileFormat,
import::{DomainField, RowResult, Transform}, import::{DomainField, Transform},
}; };
use domain::value_objects::ImportSessionId; use domain::value_objects::ImportSessionId;
use template_askama::{ use template_askama::{
ImportMappingTemplate, ImportPreviewRow, ImportPreviewTemplate, ImportProfileView, ImportMappingTemplate, ImportPreviewRow, ImportPreviewTemplate, ImportProfileView,
ImportRowStatus, ImportUploadTemplate, ImportUploadTemplate,
}; };
use crate::{ use crate::{
@@ -98,34 +98,7 @@ fn parse_mapping_form(form: &HashMap<String, String>) -> Vec<FieldMapping> {
mappings mappings
} }
fn annotated_to_preview_row(idx: usize, annotated: &AnnotatedRow) -> ImportPreviewRow { use crate::mappers::import::annotated_to_preview_row;
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(),
},
}
}
// ── HTML wizard handlers ─────────────────────────────────────────────────── // ── HTML wizard handlers ───────────────────────────────────────────────────

View File

@@ -4,6 +4,7 @@ pub mod extractors;
pub mod factory; pub mod factory;
pub mod forms; pub mod forms;
pub mod handlers; pub mod handlers;
pub mod mappers;
pub mod openapi; pub mod openapi;
pub mod ports; pub mod ports;
pub mod render; pub mod render;

View File

@@ -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(),
}
}

View File

@@ -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(),
},
}
}

View File

@@ -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())),
}
}

View File

@@ -0,0 +1,5 @@
pub mod diary;
pub mod import;
pub mod integrations;
pub mod movies;
pub mod users;

View File

@@ -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()),
}
}

View File

@@ -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(),
}
}