From 70b3ca0f5c665b26c96ff5f7259c7ec278a705e7 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Tue, 9 Jun 2026 02:29:11 +0200 Subject: [PATCH] refactor: split domain models, move presentation logic out of app layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split domain/models/mod.rs (630 lines) into focused files: movie.rs, review.rs, user.rs, stats.rs, enrichment.rs, feed.rs. Move URL/date formatting from application use cases to presentation mappers — use cases now return raw domain data. Delete watchlist/get_page.rs (was pure presentation logic), replace with presentation/mappers/watchlist.rs. Document signature conventions in CONTRIBUTING.md. --- CONTRIBUTING.md | 7 +- crates/adapters/template-askama/src/lib.rs | 12 +- crates/application/src/ports.rs | 10 - .../src/users/get_current_profile.rs | 15 +- crates/application/src/users/get_profile.rs | 144 +---- .../src/users/tests/get_current_profile.rs | 6 +- crates/application/src/watchlist/get_page.rs | 90 --- crates/application/src/watchlist/mod.rs | 1 - .../src/watchlist/tests/get_page.rs | 311 ---------- crates/domain/src/models/enrichment.rs | 54 ++ crates/domain/src/models/feed.rs | 28 + crates/domain/src/models/mod.rs | 577 +----------------- crates/domain/src/models/movie.rs | 106 ++++ crates/domain/src/models/person.rs | 2 +- crates/domain/src/models/review.rs | 158 +++++ crates/domain/src/models/search.rs | 3 +- crates/domain/src/models/stats.rs | 46 ++ crates/domain/src/models/user.rs | 163 +++++ crates/presentation/src/handlers/users.rs | 17 +- crates/presentation/src/handlers/watchlist.rs | 46 +- crates/presentation/src/mappers/mod.rs | 1 + crates/presentation/src/mappers/users.rs | 49 +- crates/presentation/src/mappers/watchlist.rs | 65 ++ 23 files changed, 761 insertions(+), 1150 deletions(-) delete mode 100644 crates/application/src/watchlist/get_page.rs delete mode 100644 crates/application/src/watchlist/tests/get_page.rs create mode 100644 crates/domain/src/models/enrichment.rs create mode 100644 crates/domain/src/models/feed.rs create mode 100644 crates/domain/src/models/movie.rs create mode 100644 crates/domain/src/models/review.rs create mode 100644 crates/domain/src/models/stats.rs create mode 100644 crates/domain/src/models/user.rs create mode 100644 crates/presentation/src/mappers/watchlist.rs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3c27b67..0881a87 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,7 +41,10 @@ All four must pass. PRs with clippy warnings or failing tests won't be merged. The project follows hexagonal (ports & adapters) architecture. See `architecture.mmd` for the full diagram. -**Key rule:** presentation handlers never touch repositories directly — all domain logic goes through use cases in the `application` crate. +**Key rules:** +- Presentation handlers never touch repositories directly — all domain logic goes through use cases in the `application` crate +- Application use cases return raw domain data — URL formatting, date display, and view model assembly belong in presentation mappers (`presentation/src/mappers/`) +- Use cases called from presentation handlers take `&AppContext`. Functions called from adapter event handlers take individual `Arc` params to keep adapter dependencies explicit ``` domain → pure types, traits (ports), zero deps @@ -80,5 +83,5 @@ Federation is feature-gated (`#[cfg(feature = "federation")]`). If your feature ## Areas seeking help - **TUI** (`crates/tui`) — deprecated, needs a maintainer to bring it up to feature parity -- **Tests** — integration tests for newer features (goals, watchlist, federation) +- **Tests** — the domain and application crates have 400+ unit tests; integration tests for the presentation layer are welcome - **Docs** — API usage examples, deployment guides diff --git a/crates/adapters/template-askama/src/lib.rs b/crates/adapters/template-askama/src/lib.rs index 95e3b93..20de52c 100644 --- a/crates/adapters/template-askama/src/lib.rs +++ b/crates/adapters/template-askama/src/lib.rs @@ -8,6 +8,16 @@ use domain::models::{ collections::Paginated, }; +#[derive(Clone, Debug)] +pub struct WatchlistDisplayEntry { + pub poster_url: Option, + pub movie_title: String, + pub release_year: u16, + pub movie_url: Option, + pub added_at: String, + pub remove_url: Option, +} + mod filters { #[askama::filter_fn] pub fn poster_src( @@ -126,7 +136,7 @@ pub struct MovieDetailTemplate<'a> { pub struct WatchlistTemplate<'a> { pub ctx: &'a HtmlPageContext, pub owner_id: uuid::Uuid, - pub display_entries: &'a [application::ports::WatchlistDisplayEntry], + pub display_entries: &'a [crate::WatchlistDisplayEntry], pub current_offset: u32, pub has_more: bool, pub limit: u32, diff --git a/crates/application/src/ports.rs b/crates/application/src/ports.rs index a11aad9..a519f62 100644 --- a/crates/application/src/ports.rs +++ b/crates/application/src/ports.rs @@ -29,16 +29,6 @@ impl HtmlPageContext { } } -#[derive(Clone, Debug)] -pub struct WatchlistDisplayEntry { - pub poster_url: Option, - pub movie_title: String, - pub release_year: u16, - pub movie_url: Option, - pub added_at: String, - pub remove_url: Option, -} - pub trait RssFeedRenderer: Send + Sync { fn render_feed(&self, entries: &[DiaryEntry], title: &str) -> Result; } diff --git a/crates/application/src/users/get_current_profile.rs b/crates/application/src/users/get_current_profile.rs index dc48e02..df37ad3 100644 --- a/crates/application/src/users/get_current_profile.rs +++ b/crates/application/src/users/get_current_profile.rs @@ -11,8 +11,8 @@ pub struct CurrentProfileData { pub username: String, pub display_name: Option, pub bio: Option, - pub avatar_url: Option, - pub banner_url: Option, + pub avatar_path: Option, + pub banner_path: Option, pub also_known_as: Option, pub fields: Vec, pub role: String, @@ -30,13 +30,6 @@ pub async fn execute( .await? .ok_or_else(|| DomainError::NotFound("User not found".into()))?; - let avatar_url = user - .avatar_path() - .map(|path| format!("{}/images/{}", ctx.config.base_url, path)); - let banner_url = user - .banner_path() - .map(|path| format!("{}/images/{}", ctx.config.base_url, path)); - let fields = user .profile_fields() .iter() @@ -50,8 +43,8 @@ pub async fn execute( username: user.username().value().to_string(), display_name: user.display_name().map(|s| s.to_string()), bio: user.bio().map(|s| s.to_string()), - avatar_url, - banner_url, + avatar_path: user.avatar_path().map(|s| s.to_string()), + banner_path: user.banner_path().map(|s| s.to_string()), also_known_as: user.also_known_as().map(|s| s.to_string()), fields, role: user.role().as_str().into(), diff --git a/crates/application/src/users/get_profile.rs b/crates/application/src/users/get_profile.rs index 08f0bfb..765b0ca 100644 --- a/crates/application/src/users/get_profile.rs +++ b/crates/application/src/users/get_profile.rs @@ -2,11 +2,10 @@ use crate::{ context::AppContext, users::queries::{GetUserProfileQuery, ProfileView}, }; -use chrono::Datelike; use domain::{ errors::DomainError, models::{ - DiaryEntry, DiaryFilter, MonthActivity, SortDirection, UserStats, UserTrends, + DiaryEntry, DiaryFilter, SortDirection, UserStats, UserTrends, collections::{PageParams, Paginated}, }, ports::FeedSortBy, @@ -23,7 +22,7 @@ pub struct PendingFollowerView { pub struct UserProfileData { pub stats: UserStats, pub entries: Option>, - pub history: Option>, + pub history: Option>, pub trends: Option, pub following_count: usize, pub followers_count: usize, @@ -53,8 +52,7 @@ pub async fn execute( match query.view { ProfileView::History => { let all_entries = ctx.repos.diary.get_user_history(&user_id).await?; - let history = group_by_month(all_entries); - Ok(base(None, Some(history), None)) + Ok(base(None, Some(all_entries), None)) } ProfileView::Trends => { let trends = ctx.repos.stats.get_user_trends(&user_id).await?; @@ -138,52 +136,6 @@ fn paged_user_filter( }) } -fn group_by_month(entries: Vec) -> Vec { - use std::collections::BTreeMap; - let mut map: BTreeMap<(i32, u32), Vec> = BTreeMap::new(); - for entry in entries { - let watched_at = entry.review().watched_at(); - let year = watched_at.year(); - let month = watched_at.month(); - map.entry((year, month)).or_default().push(entry); - } - map.into_iter() - .rev() - .map(|((year, month), entries)| { - let year_month = format!("{:04}-{:02}", year, month); - MonthActivity { - month_label: format_year_month_long(&year_month), - count: entries.len() as i64, - entries, - year_month, - } - }) - .collect() -} - -fn format_year_month_long(ym: &str) -> String { - let parts: Vec<&str> = ym.splitn(2, '-').collect(); - if parts.len() != 2 { - return ym.to_string(); - } - let month = match parts[1] { - "01" => "January", - "02" => "February", - "03" => "March", - "04" => "April", - "05" => "May", - "06" => "June", - "07" => "July", - "08" => "August", - "09" => "September", - "10" => "October", - "11" => "November", - "12" => "December", - _ => parts[1], - }; - format!("{} {}", month, parts[0]) -} - #[cfg(test)] #[path = "tests/get_profile.rs"] mod tests; @@ -192,28 +144,6 @@ mod tests; mod helper_tests { use super::*; - #[test] - fn format_year_month_long_all_months() { - assert_eq!(format_year_month_long("2024-01"), "January 2024"); - assert_eq!(format_year_month_long("2024-02"), "February 2024"); - assert_eq!(format_year_month_long("2024-03"), "March 2024"); - assert_eq!(format_year_month_long("2024-04"), "April 2024"); - assert_eq!(format_year_month_long("2024-05"), "May 2024"); - assert_eq!(format_year_month_long("2024-06"), "June 2024"); - assert_eq!(format_year_month_long("2024-07"), "July 2024"); - assert_eq!(format_year_month_long("2024-08"), "August 2024"); - assert_eq!(format_year_month_long("2024-09"), "September 2024"); - assert_eq!(format_year_month_long("2024-10"), "October 2024"); - assert_eq!(format_year_month_long("2024-11"), "November 2024"); - assert_eq!(format_year_month_long("2024-12"), "December 2024"); - } - - #[test] - fn format_year_month_long_invalid() { - assert_eq!(format_year_month_long("invalid"), "invalid"); - assert_eq!(format_year_month_long("2024-99"), "99 2024"); - } - #[test] fn feed_sort_to_direction_all_variants() { use domain::ports::FeedSortBy; @@ -235,74 +165,6 @@ mod helper_tests { )); } - #[test] - fn group_by_month_empty() { - assert!(group_by_month(vec![]).is_empty()); - } - - #[test] - fn group_by_month_groups_entries() { - use chrono::NaiveDateTime; - use domain::models::{Movie, Review}; - use domain::value_objects::{MovieId, MovieTitle, Rating, ReleaseYear, UserId}; - - let movie = Movie::from_persistence( - MovieId::generate(), - None, - MovieTitle::new("Test".into()).unwrap(), - ReleaseYear::new(2024).unwrap(), - None, - None, - ); - let uid = UserId::from_uuid(uuid::Uuid::new_v4()); - - let jan = - NaiveDateTime::parse_from_str("2024-01-15 10:00:00", "%Y-%m-%d %H:%M:%S").unwrap(); - let jan2 = - NaiveDateTime::parse_from_str("2024-01-20 10:00:00", "%Y-%m-%d %H:%M:%S").unwrap(); - let mar = - NaiveDateTime::parse_from_str("2024-03-05 10:00:00", "%Y-%m-%d %H:%M:%S").unwrap(); - - let r1 = Review::new( - movie.id().clone(), - uid.clone(), - Rating::new(4).unwrap(), - None, - jan, - ) - .unwrap(); - let r2 = Review::new( - movie.id().clone(), - uid.clone(), - Rating::new(3).unwrap(), - None, - jan2, - ) - .unwrap(); - let r3 = Review::new( - movie.id().clone(), - uid.clone(), - Rating::new(5).unwrap(), - None, - mar, - ) - .unwrap(); - - let entries = vec![ - DiaryEntry::new(movie.clone(), r1), - DiaryEntry::new(movie.clone(), r2), - DiaryEntry::new(movie.clone(), r3), - ]; - - let result = group_by_month(entries); - // Reversed: March first, then January - assert_eq!(result.len(), 2); - assert_eq!(result[0].month_label, "March 2024"); - assert_eq!(result[0].count, 1); - assert_eq!(result[1].month_label, "January 2024"); - assert_eq!(result[1].count, 2); - } - #[test] fn paged_user_filter_builds_correctly() { let uid = UserId::from_uuid(uuid::Uuid::new_v4()); diff --git a/crates/application/src/users/tests/get_current_profile.rs b/crates/application/src/users/tests/get_current_profile.rs index 36ee54c..59514d0 100644 --- a/crates/application/src/users/tests/get_current_profile.rs +++ b/crates/application/src/users/tests/get_current_profile.rs @@ -105,10 +105,8 @@ async fn returns_profile_with_avatar_banner_and_fields() { assert_eq!(profile.username, "fulluser"); assert_eq!(profile.display_name.as_deref(), Some("Full Name")); assert_eq!(profile.bio.as_deref(), Some("My bio")); - assert!(profile.avatar_url.is_some()); - assert!(profile.avatar_url.unwrap().contains("avatars/abc123")); - assert!(profile.banner_url.is_some()); - assert!(profile.banner_url.unwrap().contains("banners/def456")); + assert_eq!(profile.avatar_path.as_deref(), Some("avatars/abc123")); + assert_eq!(profile.banner_path.as_deref(), Some("banners/def456")); assert_eq!(profile.fields.len(), 1); assert_eq!(profile.fields[0].name, "Website"); assert_eq!(profile.fields[0].value, "https://example.com"); diff --git a/crates/application/src/watchlist/get_page.rs b/crates/application/src/watchlist/get_page.rs deleted file mode 100644 index 2e79330..0000000 --- a/crates/application/src/watchlist/get_page.rs +++ /dev/null @@ -1,90 +0,0 @@ -use domain::{errors::DomainError, value_objects::UserId}; - -use crate::{ - context::AppContext, ports::WatchlistDisplayEntry, watchlist::queries::GetWatchlistQuery, -}; - -pub struct WatchlistPageResult { - pub display_entries: Vec, - pub has_more: bool, - pub current_offset: u32, - pub limit: u32, -} - -pub async fn execute( - ctx: &AppContext, - query: GetWatchlistQuery, - is_owner: bool, -) -> Result { - let user_id = UserId::from_uuid(query.user_id); - let is_local = ctx.repos.user.find_by_id(&user_id).await?.is_some(); - - if is_local { - let page = crate::watchlist::get::execute(ctx, query).await?; - let has_more = page.offset + page.limit < page.total_count as u32; - let display_entries = page - .items - .iter() - .map(|w| { - let remove_url = if is_owner { - Some(format!("/watchlist/{}/remove", w.movie.id().value())) - } else { - None - }; - WatchlistDisplayEntry { - poster_url: w - .movie - .poster_path() - .map(|p| format!("/images/{}", p.value())), - movie_title: w.movie.title().value().to_string(), - release_year: w.movie.release_year().value(), - movie_url: Some(format!("/movies/{}", w.movie.id().value())), - added_at: w.entry.added_at.format("%b %-d, %Y").to_string(), - remove_url, - } - }) - .collect(); - Ok(WatchlistPageResult { - display_entries, - has_more, - current_offset: page.offset, - limit: page.limit, - }) - } else { - load_remote_watchlist(ctx, query.user_id).await - } -} - -async fn load_remote_watchlist( - ctx: &AppContext, - user_id: uuid::Uuid, -) -> Result { - let remote_entries = ctx - .repos - .remote_watchlist - .get_by_derived_uuid(user_id) - .await - .unwrap_or_default(); - let len = remote_entries.len() as u32; - let display_entries = remote_entries - .into_iter() - .map(|e| WatchlistDisplayEntry { - poster_url: e.poster_url, - movie_title: e.movie_title, - release_year: e.release_year, - movie_url: None, - added_at: e.added_at.format("%b %-d, %Y").to_string(), - remove_url: None, - }) - .collect(); - Ok(WatchlistPageResult { - display_entries, - has_more: false, - current_offset: 0, - limit: len, - }) -} - -#[cfg(test)] -#[path = "tests/get_page.rs"] -mod tests; diff --git a/crates/application/src/watchlist/mod.rs b/crates/application/src/watchlist/mod.rs index a2c8d2b..7ad9d2c 100644 --- a/crates/application/src/watchlist/mod.rs +++ b/crates/application/src/watchlist/mod.rs @@ -1,7 +1,6 @@ pub mod add; pub mod commands; pub mod get; -pub mod get_page; pub mod is_on; pub mod queries; pub mod remove; diff --git a/crates/application/src/watchlist/tests/get_page.rs b/crates/application/src/watchlist/tests/get_page.rs deleted file mode 100644 index ef965aa..0000000 --- a/crates/application/src/watchlist/tests/get_page.rs +++ /dev/null @@ -1,311 +0,0 @@ -use std::sync::Arc; - -use async_trait::async_trait; -use domain::errors::DomainError; -use domain::models::collections::{PageParams, Paginated}; -use domain::models::watchlist::{WatchlistEntry, WatchlistWithMovie}; -use domain::models::{Movie, UserRole}; -use domain::ports::WatchlistRepository; -use domain::value_objects::{Email, MovieId, MovieTitle, PosterPath, ReleaseYear, UserId}; - -use crate::auth::commands::RegisterCommand; -use crate::auth::register; -use crate::test_helpers::TestContextBuilder; -use crate::watchlist::get_page; -use crate::watchlist::queries::GetWatchlistQuery; - -struct FakeWatchlistWithItems { - user_id: UserId, - items: Vec, -} - -#[async_trait] -impl WatchlistRepository for FakeWatchlistWithItems { - async fn add(&self, _entry: &WatchlistEntry) -> Result<(), DomainError> { - Ok(()) - } - async fn remove(&self, _user_id: &UserId, _movie_id: &MovieId) -> Result<(), DomainError> { - Ok(()) - } - async fn remove_if_present( - &self, - _user_id: &UserId, - _movie_id: &MovieId, - ) -> Result { - Ok(false) - } - async fn get_for_user( - &self, - user_id: &UserId, - _page: &PageParams, - ) -> Result, DomainError> { - if user_id == &self.user_id { - Ok(Paginated { - total_count: self.items.len() as u64, - limit: 20, - offset: 0, - items: self.items.clone(), - }) - } else { - Ok(Paginated { - items: vec![], - total_count: 0, - limit: 20, - offset: 0, - }) - } - } - async fn contains(&self, _user_id: &UserId, _movie_id: &MovieId) -> Result { - Ok(false) - } -} - -#[tokio::test] -async fn returns_empty_for_local_user() { - let ctx = TestContextBuilder::new().build(); - - register::execute( - &ctx, - RegisterCommand { - email: "wl@test.com".into(), - username: "wluser".into(), - password: "password123".into(), - role: UserRole::Standard, - }, - ) - .await - .unwrap(); - - let email = Email::new("wl@test.com".into()).unwrap(); - let user = ctx.repos.user.find_by_email(&email).await.unwrap().unwrap(); - let uid = user.id().value(); - - let result = get_page::execute( - &ctx, - GetWatchlistQuery { - user_id: uid, - limit: None, - offset: None, - }, - true, - ) - .await - .unwrap(); - - assert!(result.display_entries.is_empty()); -} - -#[tokio::test] -async fn returns_display_entries_for_local_user_with_items() { - let ctx = TestContextBuilder::new().build(); - - register::execute( - &ctx, - RegisterCommand { - email: "wl2@test.com".into(), - username: "wluser2".into(), - password: "password123".into(), - role: UserRole::Standard, - }, - ) - .await - .unwrap(); - - let email = Email::new("wl2@test.com".into()).unwrap(); - let user = ctx.repos.user.find_by_email(&email).await.unwrap().unwrap(); - let uid = user.id().value(); - - let result = get_page::execute( - &ctx, - GetWatchlistQuery { - user_id: uid, - limit: Some(20), - offset: Some(0), - }, - true, - ) - .await - .unwrap(); - - // InMemory get_for_user returns empty, but the local-user branch is exercised - assert!(!result.has_more); - assert_eq!(result.current_offset, 0); -} - -#[tokio::test] -async fn returns_remote_watchlist_for_unknown_user() { - let ctx = TestContextBuilder::new().build(); - - let unknown_uid = uuid::Uuid::new_v4(); - - let result = get_page::execute( - &ctx, - GetWatchlistQuery { - user_id: unknown_uid, - limit: None, - offset: None, - }, - false, - ) - .await - .unwrap(); - - // NoopRemoteWatchlistRepository returns empty - assert!(result.display_entries.is_empty()); - assert!(!result.has_more); - assert_eq!(result.current_offset, 0); -} - -#[tokio::test] -async fn maps_display_entries_for_owner() { - let uid = uuid::Uuid::new_v4(); - let user_id = UserId::from_uuid(uid); - let movie_id = MovieId::generate(); - - let movie = Movie::from_persistence( - movie_id.clone(), - None, - MovieTitle::new("Blade Runner".into()).unwrap(), - ReleaseYear::new(1982).unwrap(), - None, - Some(PosterPath::new("poster123.jpg".into()).unwrap()), - ); - let entry = WatchlistEntry::new(user_id.clone(), movie_id.clone()); - - let fake_wl = Arc::new(FakeWatchlistWithItems { - user_id: user_id.clone(), - items: vec![WatchlistWithMovie { - entry, - movie: movie.clone(), - }], - }); - - let ctx = TestContextBuilder::new() - .with_watchlist(fake_wl as _) - .build(); - - // register user so find_by_id returns Some - register::execute( - &ctx, - RegisterCommand { - email: "wlmap@test.com".into(), - username: "wlmapuser".into(), - password: "password123".into(), - role: UserRole::Standard, - }, - ) - .await - .unwrap(); - - let email = Email::new("wlmap@test.com".into()).unwrap(); - let user = ctx.repos.user.find_by_email(&email).await.unwrap().unwrap(); - let real_uid = user.id().value(); - - // Rebuild with the real user_id in the fake - let movie_id2 = MovieId::generate(); - let movie2 = Movie::from_persistence( - movie_id2.clone(), - None, - MovieTitle::new("Blade Runner".into()).unwrap(), - ReleaseYear::new(1982).unwrap(), - None, - Some(PosterPath::new("poster123.jpg".into()).unwrap()), - ); - let entry2 = WatchlistEntry::new(UserId::from_uuid(real_uid), movie_id2.clone()); - - let fake_wl2 = Arc::new(FakeWatchlistWithItems { - user_id: UserId::from_uuid(real_uid), - items: vec![WatchlistWithMovie { - entry: entry2, - movie: movie2.clone(), - }], - }); - - let ctx2 = TestContextBuilder::new() - .with_watchlist(fake_wl2 as _) - .with_users(ctx.repos.user.clone()) - .build(); - - let result = get_page::execute( - &ctx2, - GetWatchlistQuery { - user_id: real_uid, - limit: Some(20), - offset: Some(0), - }, - true, - ) - .await - .unwrap(); - - assert_eq!(result.display_entries.len(), 1); - let de = &result.display_entries[0]; - assert_eq!(de.movie_title, "Blade Runner"); - assert_eq!(de.release_year, 1982); - assert_eq!(de.poster_url.as_deref(), Some("/images/poster123.jpg")); - assert!(de.movie_url.is_some()); - assert!(de.remove_url.is_some()); // owner can remove -} - -#[tokio::test] -async fn maps_display_entries_for_non_owner() { - let ctx = TestContextBuilder::new().build(); - - register::execute( - &ctx, - RegisterCommand { - email: "wlno@test.com".into(), - username: "wlnoowner".into(), - password: "password123".into(), - role: UserRole::Standard, - }, - ) - .await - .unwrap(); - - let email = Email::new("wlno@test.com".into()).unwrap(); - let user = ctx.repos.user.find_by_email(&email).await.unwrap().unwrap(); - let real_uid = user.id().value(); - - let movie_id = MovieId::generate(); - let movie = Movie::from_persistence( - movie_id.clone(), - None, - MovieTitle::new("Alien".into()).unwrap(), - ReleaseYear::new(1979).unwrap(), - None, - None, - ); - let entry = WatchlistEntry::new(UserId::from_uuid(real_uid), movie_id.clone()); - - let fake_wl = Arc::new(FakeWatchlistWithItems { - user_id: UserId::from_uuid(real_uid), - items: vec![WatchlistWithMovie { - entry, - movie: movie.clone(), - }], - }); - - let ctx2 = TestContextBuilder::new() - .with_watchlist(fake_wl as _) - .with_users(ctx.repos.user.clone()) - .build(); - - let result = get_page::execute( - &ctx2, - GetWatchlistQuery { - user_id: real_uid, - limit: Some(20), - offset: Some(0), - }, - false, // not owner - ) - .await - .unwrap(); - - assert_eq!(result.display_entries.len(), 1); - let de = &result.display_entries[0]; - assert_eq!(de.movie_title, "Alien"); - assert!(de.poster_url.is_none()); // no poster - assert!(de.remove_url.is_none()); // not owner -} diff --git a/crates/domain/src/models/enrichment.rs b/crates/domain/src/models/enrichment.rs new file mode 100644 index 0000000..4407ba7 --- /dev/null +++ b/crates/domain/src/models/enrichment.rs @@ -0,0 +1,54 @@ +use chrono::{DateTime, Utc}; + +use crate::value_objects::MovieId; + +#[derive(Clone, Debug)] +pub struct Genre { + pub tmdb_id: u32, + pub name: String, +} + +#[derive(Clone, Debug)] +pub struct Keyword { + pub tmdb_id: u32, + pub name: String, +} + +#[derive(Clone, Debug)] +pub struct CastMember { + pub tmdb_person_id: u64, + pub name: String, + pub character: String, + pub billing_order: u32, + pub profile_path: Option, +} + +#[derive(Clone, Debug)] +pub struct CrewMember { + pub tmdb_person_id: u64, + pub name: String, + pub job: String, + pub department: String, + pub profile_path: Option, +} + +#[derive(Clone, Debug)] +pub struct MovieProfile { + pub movie_id: MovieId, + pub tmdb_id: u64, + pub imdb_id: Option, + pub overview: Option, + pub tagline: Option, + pub runtime_minutes: Option, + pub budget_usd: Option, + pub revenue_usd: Option, + pub vote_average: Option, + pub vote_count: Option, + pub original_language: Option, + pub collection_name: Option, + pub genres: Vec, + pub keywords: Vec, + pub cast: Vec, + pub crew: Vec, + pub enriched_at: DateTime, +} diff --git a/crates/domain/src/models/feed.rs b/crates/domain/src/models/feed.rs new file mode 100644 index 0000000..92572bd --- /dev/null +++ b/crates/domain/src/models/feed.rs @@ -0,0 +1,28 @@ +use super::{movie::Movie, review::{DiaryEntry, Review}}; + +#[derive(Clone, Debug)] +pub struct FeedEntry { + entry: DiaryEntry, + user_email: String, +} + +impl FeedEntry { + pub fn new(entry: DiaryEntry, user_email: String) -> Self { + Self { entry, user_email } + } + pub fn movie(&self) -> &Movie { + self.entry.movie() + } + pub fn review(&self) -> &Review { + self.entry.review() + } + pub fn user_email(&self) -> &str { + &self.user_email + } + pub fn user_display_name(&self) -> &str { + self.user_email + .split('@') + .next() + .unwrap_or(&self.user_email) + } +} diff --git a/crates/domain/src/models/mod.rs b/crates/domain/src/models/mod.rs index db87830..af90564 100644 --- a/crates/domain/src/models/mod.rs +++ b/crates/domain/src/models/mod.rs @@ -1,13 +1,10 @@ -use chrono::{DateTime, NaiveDateTime, Utc}; +mod movie; +mod review; +mod user; +mod stats; +mod enrichment; +mod feed; -use crate::{ - errors::DomainError, - models::collections::PageParams, - value_objects::{ - Comment, Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterPath, Rating, - ReleaseYear, ReviewId, UserId, Username, - }, -}; pub mod collections; pub mod import; pub mod import_profile; @@ -15,17 +12,25 @@ pub mod import_session; pub mod person; pub mod search; pub mod watchlist; -pub use watchlist::{WatchlistEntry, WatchlistWithMovie}; pub mod remote_watchlist; -pub use remote_watchlist::RemoteWatchlistEntry; pub mod goal; -pub use goal::{Goal, GoalWithProgress}; pub mod user_settings; -pub use user_settings::UserSettings; pub mod remote_goal; -pub use remote_goal::RemoteGoalEntry; pub mod watch_event; pub mod wrapup; + +pub use movie::*; +pub use review::*; +pub use user::*; +pub use stats::*; +pub use enrichment::*; +pub use feed::*; + +pub use watchlist::{WatchlistEntry, WatchlistWithMovie}; +pub use remote_watchlist::RemoteWatchlistEntry; +pub use goal::{Goal, GoalWithProgress}; +pub use user_settings::UserSettings; +pub use remote_goal::RemoteGoalEntry; pub use watch_event::{ ParsedPlaybackEvent, PersistedWatchEvent, WatchEvent, WatchEventSource, WatchEventStatus, WebhookToken, @@ -44,6 +49,8 @@ pub use search::{ SearchResults, }; +use crate::errors::DomainError; + #[derive(Clone, Debug, PartialEq, Eq)] pub enum GoalType { Movies, @@ -79,495 +86,6 @@ pub enum SortDirection { ByRatingAsc, } -#[derive(Clone, Debug, Default)] -pub struct DiaryFilter { - pub sort_by: SortDirection, - pub page: PageParams, - pub movie_id: Option, - pub user_id: Option, - pub search: Option, -} - -#[derive(Clone, Debug, Default)] -pub struct MovieFilter { - pub search: Option, - pub genre: Option, - pub language: Option, -} - -#[derive(Clone, Debug)] -pub struct MovieSummary { - pub movie: Movie, - pub genres: Vec, - pub runtime_minutes: Option, - pub original_language: Option, - pub overview: Option, - pub collection_name: Option, -} - -#[derive(Clone, Debug)] -pub struct Movie { - id: MovieId, - external_metadata_id: Option, - title: MovieTitle, - release_year: ReleaseYear, - director: Option, - poster_path: Option, -} - -impl Movie { - pub fn new( - external_metadata_id: Option, - title: MovieTitle, - release_year: ReleaseYear, - director: Option, - poster_path: Option, - ) -> Self { - Self { - id: MovieId::generate(), - external_metadata_id, - title, - release_year, - director, - poster_path, - } - } - - pub fn from_persistence( - id: MovieId, - external_metadata_id: Option, - title: MovieTitle, - release_year: ReleaseYear, - director: Option, - poster_path: Option, - ) -> Self { - Self { - id, - external_metadata_id, - title, - release_year, - director, - poster_path, - } - } - - pub fn update_poster(&mut self, poster_path: PosterPath) { - self.poster_path = Some(poster_path); - } - - pub fn id(&self) -> &MovieId { - &self.id - } - pub fn external_metadata_id(&self) -> Option<&ExternalMetadataId> { - self.external_metadata_id.as_ref() - } - pub fn title(&self) -> &MovieTitle { - &self.title - } - pub fn release_year(&self) -> &ReleaseYear { - &self.release_year - } - pub fn director(&self) -> Option<&str> { - self.director.as_deref() - } - pub fn poster_path(&self) -> Option<&PosterPath> { - self.poster_path.as_ref() - } -} - -impl Movie { - pub fn is_manual_match( - &self, - title: &MovieTitle, - year: &ReleaseYear, - director: Option<&str>, - ) -> bool { - if self.title != *title || self.release_year != *year { - return false; - } - - match (self.director(), director) { - (Some(existing_dir), Some(new_dir)) => existing_dir.eq_ignore_ascii_case(new_dir), - _ => true, - } - } -} - -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub enum ReviewSource { - #[default] - Local, - Remote { - actor_url: String, - }, -} - -pub struct PersistedReview { - pub id: ReviewId, - pub movie_id: MovieId, - pub user_id: UserId, - pub rating: Rating, - pub comment: Option, - pub watched_at: NaiveDateTime, - pub created_at: NaiveDateTime, - pub source: ReviewSource, -} - -#[derive(Clone, Debug)] -pub struct Review { - id: ReviewId, - movie_id: MovieId, - user_id: UserId, - rating: Rating, - comment: Option, - watched_at: chrono::NaiveDateTime, - created_at: chrono::NaiveDateTime, - source: ReviewSource, -} - -impl Review { - pub fn new( - movie_id: MovieId, - user_id: UserId, - rating: Rating, - comment: Option, - watched_at: NaiveDateTime, - ) -> Result { - Ok(Self { - id: ReviewId::generate(), - movie_id, - user_id, - rating, - comment, - watched_at, - created_at: Utc::now().naive_utc(), - source: ReviewSource::Local, - }) - } - - pub fn from_persistence(row: PersistedReview) -> Self { - Self { - id: row.id, - movie_id: row.movie_id, - user_id: row.user_id, - rating: row.rating, - comment: row.comment, - watched_at: row.watched_at, - created_at: row.created_at, - source: row.source, - } - } - - pub fn id(&self) -> &ReviewId { - &self.id - } - pub fn movie_id(&self) -> &MovieId { - &self.movie_id - } - pub fn user_id(&self) -> &UserId { - &self.user_id - } - pub fn rating(&self) -> &Rating { - &self.rating - } - pub fn comment(&self) -> Option<&Comment> { - self.comment.as_ref() - } - pub fn watched_at(&self) -> &NaiveDateTime { - &self.watched_at - } - pub fn created_at(&self) -> &NaiveDateTime { - &self.created_at - } - pub fn source(&self) -> &ReviewSource { - &self.source - } - /// Returns [star1_filled, star2_filled, ..., star5_filled] - pub fn stars(&self) -> [bool; 5] { - let r = self.rating.value(); - [r >= 1, r >= 2, r >= 3, r >= 4, r >= 5] - } - - pub fn is_remote(&self) -> bool { - matches!(self.source, ReviewSource::Remote { .. }) - } -} - -#[derive(Clone, Debug)] -pub struct DiaryEntry { - movie: Movie, - review: Review, -} - -impl DiaryEntry { - pub fn new(movie: Movie, review: Review) -> Self { - Self { movie, review } - } - - pub fn movie(&self) -> &Movie { - &self.movie - } - pub fn review(&self) -> &Review { - &self.review - } -} - -#[derive(Clone, Debug)] -pub struct ReviewHistory { - movie: Movie, - viewings: Vec, -} - -impl ReviewHistory { - pub fn new(movie: Movie, viewings: Vec) -> Self { - Self { movie, viewings } - } - - pub fn movie(&self) -> &Movie { - &self.movie - } - pub fn viewings(&self) -> &[Review] { - &self.viewings - } - pub fn sort_by_date(&mut self) { - self.viewings.sort_by_key(|r| *r.watched_at()); - } -} - -#[derive(Clone, Debug)] -pub struct MovieStats { - pub total_count: u64, - pub avg_rating: Option, - pub federated_count: u64, - pub rating_histogram: [u64; 5], // index 0 = 1★, index 4 = 5★ -} - -#[derive(Clone, Debug, Default)] -pub enum UserRole { - #[default] - Standard, - Admin, -} - -impl UserRole { - pub fn as_str(&self) -> &'static str { - match self { - Self::Standard => "standard", - Self::Admin => "admin", - } - } -} - -#[derive(Debug, Clone)] -pub struct ProfileField { - pub name: String, - pub value: String, -} - -#[derive(Clone, Debug, Default)] -pub struct UserProfile { - pub display_name: Option, - pub bio: Option, - pub avatar_path: Option, - pub banner_path: Option, - pub also_known_as: Option, - pub profile_fields: Vec, -} - -#[derive(Clone, Debug)] -pub struct User { - id: UserId, - email: Email, - username: Username, - password_hash: PasswordHash, - role: UserRole, - profile: UserProfile, -} - -impl User { - pub fn new( - email: Email, - username: Username, - password_hash: PasswordHash, - role: UserRole, - ) -> Self { - Self { - id: UserId::generate(), - email, - username, - password_hash, - role, - profile: UserProfile::default(), - } - } - - pub fn from_persistence( - id: UserId, - email: Email, - username: Username, - password_hash: PasswordHash, - role: UserRole, - profile: UserProfile, - ) -> Self { - Self { - id, - email, - username, - password_hash, - role, - profile, - } - } - - pub fn update_password(&mut self, new_hash: PasswordHash) { - self.password_hash = new_hash; - } - - pub fn update_profile(&mut self, profile: UserProfile) { - self.profile = profile; - } - - pub fn email(&self) -> &Email { - &self.email - } - pub fn username(&self) -> &Username { - &self.username - } - pub fn id(&self) -> &UserId { - &self.id - } - pub fn password_hash(&self) -> &PasswordHash { - &self.password_hash - } - pub fn role(&self) -> &UserRole { - &self.role - } - pub fn display_name(&self) -> Option<&str> { - self.profile.display_name.as_deref() - } - pub fn bio(&self) -> Option<&str> { - self.profile.bio.as_deref() - } - pub fn avatar_path(&self) -> Option<&str> { - self.profile.avatar_path.as_deref() - } - pub fn banner_path(&self) -> Option<&str> { - self.profile.banner_path.as_deref() - } - pub fn also_known_as(&self) -> Option<&str> { - self.profile.also_known_as.as_deref() - } - pub fn profile_fields(&self) -> &[ProfileField] { - &self.profile.profile_fields - } -} - -#[derive(Clone, Debug)] -pub struct FeedEntry { - entry: DiaryEntry, - user_email: String, -} - -impl FeedEntry { - pub fn new(entry: DiaryEntry, user_email: String) -> Self { - Self { entry, user_email } - } - pub fn movie(&self) -> &Movie { - self.entry.movie() - } - pub fn review(&self) -> &Review { - self.entry.review() - } - pub fn user_email(&self) -> &str { - &self.user_email - } - pub fn user_display_name(&self) -> &str { - self.user_email - .split('@') - .next() - .unwrap_or(&self.user_email) - } -} - -#[derive(Clone, Debug)] -pub struct UserSummary { - pub user_id: UserId, - email: Email, - username: Username, - display_name: Option, - pub total_movies: i64, - pub avg_rating: Option, - pub avatar_path: Option, -} - -impl UserSummary { - pub fn new( - user_id: UserId, - email: Email, - username: Username, - display_name: Option, - total_movies: i64, - avg_rating: Option, - avatar_path: Option, - ) -> Self { - Self { - user_id, - email, - username, - display_name, - total_movies, - avg_rating, - avatar_path, - } - } - pub fn email(&self) -> &str { - self.email.value() - } - pub fn username(&self) -> &str { - self.username.value() - } - pub fn display_name(&self) -> Option<&str> { - self.display_name.as_deref() - } -} - -#[derive(Clone, Debug)] -pub struct UserStats { - pub total_movies: i64, - pub avg_rating: Option, - pub favorite_director: Option, - pub most_active_month: Option, -} - -#[derive(Clone, Debug)] -pub struct MonthActivity { - pub year_month: String, - pub month_label: String, - pub count: i64, - pub entries: Vec, -} - -#[derive(Clone, Debug)] -pub struct MonthlyRating { - pub year_month: String, - pub month_label: String, - pub avg_rating: f64, - pub count: i64, -} - -#[derive(Clone, Debug)] -pub struct DirectorStat { - pub director: String, - pub count: i64, -} - -#[derive(Clone, Debug)] -pub struct UserTrends { - pub monthly_ratings: Vec, - pub top_directors: Vec, - pub max_director_count: i64, -} - pub enum ExportFormat { Csv, Json, @@ -576,56 +94,3 @@ pub enum ExportFormat { #[cfg(test)] #[path = "tests.rs"] mod tests; - -// ── Movie enrichment ─────────────────────────────────────────────────────────── - -#[derive(Clone, Debug)] -pub struct Genre { - pub tmdb_id: u32, - pub name: String, -} - -#[derive(Clone, Debug)] -pub struct Keyword { - pub tmdb_id: u32, - pub name: String, -} - -#[derive(Clone, Debug)] -pub struct CastMember { - pub tmdb_person_id: u64, - pub name: String, - pub character: String, - pub billing_order: u32, - pub profile_path: Option, -} - -#[derive(Clone, Debug)] -pub struct CrewMember { - pub tmdb_person_id: u64, - pub name: String, - pub job: String, - pub department: String, - pub profile_path: Option, -} - -#[derive(Clone, Debug)] -pub struct MovieProfile { - pub movie_id: MovieId, - pub tmdb_id: u64, - pub imdb_id: Option, - pub overview: Option, - pub tagline: Option, - pub runtime_minutes: Option, - pub budget_usd: Option, - pub revenue_usd: Option, - pub vote_average: Option, - pub vote_count: Option, - pub original_language: Option, - pub collection_name: Option, - pub genres: Vec, - pub keywords: Vec, - pub cast: Vec, - pub crew: Vec, - pub enriched_at: DateTime, -} diff --git a/crates/domain/src/models/movie.rs b/crates/domain/src/models/movie.rs new file mode 100644 index 0000000..8d6eb08 --- /dev/null +++ b/crates/domain/src/models/movie.rs @@ -0,0 +1,106 @@ +use crate::value_objects::{ExternalMetadataId, MovieId, MovieTitle, PosterPath, ReleaseYear}; + +#[derive(Clone, Debug, Default)] +pub struct MovieFilter { + pub search: Option, + pub genre: Option, + pub language: Option, +} + +#[derive(Clone, Debug)] +pub struct MovieSummary { + pub movie: Movie, + pub genres: Vec, + pub runtime_minutes: Option, + pub original_language: Option, + pub overview: Option, + pub collection_name: Option, +} + +#[derive(Clone, Debug)] +pub struct Movie { + id: MovieId, + external_metadata_id: Option, + title: MovieTitle, + release_year: ReleaseYear, + director: Option, + poster_path: Option, +} + +impl Movie { + pub fn new( + external_metadata_id: Option, + title: MovieTitle, + release_year: ReleaseYear, + director: Option, + poster_path: Option, + ) -> Self { + Self { + id: MovieId::generate(), + external_metadata_id, + title, + release_year, + director, + poster_path, + } + } + + pub fn from_persistence( + id: MovieId, + external_metadata_id: Option, + title: MovieTitle, + release_year: ReleaseYear, + director: Option, + poster_path: Option, + ) -> Self { + Self { + id, + external_metadata_id, + title, + release_year, + director, + poster_path, + } + } + + pub fn update_poster(&mut self, poster_path: PosterPath) { + self.poster_path = Some(poster_path); + } + + pub fn id(&self) -> &MovieId { + &self.id + } + pub fn external_metadata_id(&self) -> Option<&ExternalMetadataId> { + self.external_metadata_id.as_ref() + } + pub fn title(&self) -> &MovieTitle { + &self.title + } + pub fn release_year(&self) -> &ReleaseYear { + &self.release_year + } + pub fn director(&self) -> Option<&str> { + self.director.as_deref() + } + pub fn poster_path(&self) -> Option<&PosterPath> { + self.poster_path.as_ref() + } +} + +impl Movie { + pub fn is_manual_match( + &self, + title: &MovieTitle, + year: &ReleaseYear, + director: Option<&str>, + ) -> bool { + if self.title != *title || self.release_year != *year { + return false; + } + + match (self.director(), director) { + (Some(existing_dir), Some(new_dir)) => existing_dir.eq_ignore_ascii_case(new_dir), + _ => true, + } + } +} diff --git a/crates/domain/src/models/person.rs b/crates/domain/src/models/person.rs index b4c4180..f06a729 100644 --- a/crates/domain/src/models/person.rs +++ b/crates/domain/src/models/person.rs @@ -1,6 +1,6 @@ use uuid::Uuid; -use crate::models::MovieId; +use crate::value_objects::MovieId; #[derive(Clone, Debug, PartialEq)] pub struct PersonId(Uuid); diff --git a/crates/domain/src/models/review.rs b/crates/domain/src/models/review.rs new file mode 100644 index 0000000..4cafcbc --- /dev/null +++ b/crates/domain/src/models/review.rs @@ -0,0 +1,158 @@ +use chrono::{NaiveDateTime, Utc}; + +use crate::{ + errors::DomainError, + value_objects::{Comment, MovieId, Rating, ReviewId, UserId}, +}; + +use super::movie::Movie; + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub enum ReviewSource { + #[default] + Local, + Remote { + actor_url: String, + }, +} + +pub struct PersistedReview { + pub id: ReviewId, + pub movie_id: MovieId, + pub user_id: UserId, + pub rating: Rating, + pub comment: Option, + pub watched_at: NaiveDateTime, + pub created_at: NaiveDateTime, + pub source: ReviewSource, +} + +#[derive(Clone, Debug)] +pub struct Review { + id: ReviewId, + movie_id: MovieId, + user_id: UserId, + rating: Rating, + comment: Option, + watched_at: NaiveDateTime, + created_at: NaiveDateTime, + source: ReviewSource, +} + +impl Review { + pub fn new( + movie_id: MovieId, + user_id: UserId, + rating: Rating, + comment: Option, + watched_at: NaiveDateTime, + ) -> Result { + Ok(Self { + id: ReviewId::generate(), + movie_id, + user_id, + rating, + comment, + watched_at, + created_at: Utc::now().naive_utc(), + source: ReviewSource::Local, + }) + } + + pub fn from_persistence(row: PersistedReview) -> Self { + Self { + id: row.id, + movie_id: row.movie_id, + user_id: row.user_id, + rating: row.rating, + comment: row.comment, + watched_at: row.watched_at, + created_at: row.created_at, + source: row.source, + } + } + + pub fn id(&self) -> &ReviewId { + &self.id + } + pub fn movie_id(&self) -> &MovieId { + &self.movie_id + } + pub fn user_id(&self) -> &UserId { + &self.user_id + } + pub fn rating(&self) -> &Rating { + &self.rating + } + pub fn comment(&self) -> Option<&Comment> { + self.comment.as_ref() + } + pub fn watched_at(&self) -> &NaiveDateTime { + &self.watched_at + } + pub fn created_at(&self) -> &NaiveDateTime { + &self.created_at + } + pub fn source(&self) -> &ReviewSource { + &self.source + } + /// Returns [star1_filled, star2_filled, ..., star5_filled] + pub fn stars(&self) -> [bool; 5] { + let r = self.rating.value(); + [r >= 1, r >= 2, r >= 3, r >= 4, r >= 5] + } + + pub fn is_remote(&self) -> bool { + matches!(self.source, ReviewSource::Remote { .. }) + } +} + +#[derive(Clone, Debug)] +pub struct DiaryEntry { + movie: Movie, + review: Review, +} + +impl DiaryEntry { + pub fn new(movie: Movie, review: Review) -> Self { + Self { movie, review } + } + + pub fn movie(&self) -> &Movie { + &self.movie + } + pub fn review(&self) -> &Review { + &self.review + } +} + +#[derive(Clone, Debug, Default)] +pub struct DiaryFilter { + pub sort_by: super::SortDirection, + pub page: crate::models::collections::PageParams, + pub movie_id: Option, + pub user_id: Option, + pub search: Option, +} + +#[derive(Clone, Debug)] +pub struct ReviewHistory { + movie: Movie, + viewings: Vec, +} + +impl ReviewHistory { + pub fn new(movie: Movie, viewings: Vec) -> Self { + Self { movie, viewings } + } + + pub fn movie(&self) -> &Movie { + &self.movie + } + pub fn viewings(&self) -> &[Review] { + &self.viewings + } + pub fn sort_by_date(&mut self) { + self.viewings.sort_by_key(|r| *r.watched_at()); + } +} diff --git a/crates/domain/src/models/search.rs b/crates/domain/src/models/search.rs index 8389492..abcf46d 100644 --- a/crates/domain/src/models/search.rs +++ b/crates/domain/src/models/search.rs @@ -1,7 +1,8 @@ use crate::models::{ - Movie, MovieId, MovieProfile, Person, PersonId, + Movie, MovieProfile, Person, PersonId, collections::{PageParams, Paginated}, }; +use crate::value_objects::MovieId; #[derive(Clone, Debug, Default)] pub struct SearchQuery { diff --git a/crates/domain/src/models/stats.rs b/crates/domain/src/models/stats.rs new file mode 100644 index 0000000..ccacc67 --- /dev/null +++ b/crates/domain/src/models/stats.rs @@ -0,0 +1,46 @@ +use super::review::DiaryEntry; + +#[derive(Clone, Debug)] +pub struct UserStats { + pub total_movies: i64, + pub avg_rating: Option, + pub favorite_director: Option, + pub most_active_month: Option, +} + +#[derive(Clone, Debug)] +pub struct MonthActivity { + pub year_month: String, + pub month_label: String, + pub count: i64, + pub entries: Vec, +} + +#[derive(Clone, Debug)] +pub struct MonthlyRating { + pub year_month: String, + pub month_label: String, + pub avg_rating: f64, + pub count: i64, +} + +#[derive(Clone, Debug)] +pub struct DirectorStat { + pub director: String, + pub count: i64, +} + +#[derive(Clone, Debug)] +pub struct UserTrends { + pub monthly_ratings: Vec, + pub top_directors: Vec, + pub max_director_count: i64, +} + +#[derive(Clone, Debug)] +pub struct MovieStats { + pub total_count: u64, + pub avg_rating: Option, + pub federated_count: u64, + pub rating_histogram: [u64; 5], // index 0 = 1★, index 4 = 5★ +} diff --git a/crates/domain/src/models/user.rs b/crates/domain/src/models/user.rs new file mode 100644 index 0000000..3cc128c --- /dev/null +++ b/crates/domain/src/models/user.rs @@ -0,0 +1,163 @@ +use crate::value_objects::{Email, PasswordHash, UserId, Username}; + +#[derive(Clone, Debug, Default)] +pub enum UserRole { + #[default] + Standard, + Admin, +} + +impl UserRole { + pub fn as_str(&self) -> &'static str { + match self { + Self::Standard => "standard", + Self::Admin => "admin", + } + } +} + +#[derive(Debug, Clone)] +pub struct ProfileField { + pub name: String, + pub value: String, +} + +#[derive(Clone, Debug, Default)] +pub struct UserProfile { + pub display_name: Option, + pub bio: Option, + pub avatar_path: Option, + pub banner_path: Option, + pub also_known_as: Option, + pub profile_fields: Vec, +} + +#[derive(Clone, Debug)] +pub struct User { + id: UserId, + email: Email, + username: Username, + password_hash: PasswordHash, + role: UserRole, + profile: UserProfile, +} + +impl User { + pub fn new( + email: Email, + username: Username, + password_hash: PasswordHash, + role: UserRole, + ) -> Self { + Self { + id: UserId::generate(), + email, + username, + password_hash, + role, + profile: UserProfile::default(), + } + } + + pub fn from_persistence( + id: UserId, + email: Email, + username: Username, + password_hash: PasswordHash, + role: UserRole, + profile: UserProfile, + ) -> Self { + Self { + id, + email, + username, + password_hash, + role, + profile, + } + } + + pub fn update_password(&mut self, new_hash: PasswordHash) { + self.password_hash = new_hash; + } + + pub fn update_profile(&mut self, profile: UserProfile) { + self.profile = profile; + } + + pub fn email(&self) -> &Email { + &self.email + } + pub fn username(&self) -> &Username { + &self.username + } + pub fn id(&self) -> &UserId { + &self.id + } + pub fn password_hash(&self) -> &PasswordHash { + &self.password_hash + } + pub fn role(&self) -> &UserRole { + &self.role + } + pub fn display_name(&self) -> Option<&str> { + self.profile.display_name.as_deref() + } + pub fn bio(&self) -> Option<&str> { + self.profile.bio.as_deref() + } + pub fn avatar_path(&self) -> Option<&str> { + self.profile.avatar_path.as_deref() + } + pub fn banner_path(&self) -> Option<&str> { + self.profile.banner_path.as_deref() + } + pub fn also_known_as(&self) -> Option<&str> { + self.profile.also_known_as.as_deref() + } + pub fn profile_fields(&self) -> &[ProfileField] { + &self.profile.profile_fields + } +} + +#[derive(Clone, Debug)] +pub struct UserSummary { + pub user_id: UserId, + email: Email, + username: Username, + display_name: Option, + pub total_movies: i64, + pub avg_rating: Option, + pub avatar_path: Option, +} + +impl UserSummary { + pub fn new( + user_id: UserId, + email: Email, + username: Username, + display_name: Option, + total_movies: i64, + avg_rating: Option, + avatar_path: Option, + ) -> Self { + Self { + user_id, + email, + username, + display_name, + total_movies, + avg_rating, + avatar_path, + } + } + pub fn email(&self) -> &str { + self.email.value() + } + pub fn username(&self) -> &str { + self.username.value() + } + pub fn display_name(&self) -> Option<&str> { + self.display_name.as_deref() + } +} diff --git a/crates/presentation/src/handlers/users.rs b/crates/presentation/src/handlers/users.rs index 8d4eee9..520c7fc 100644 --- a/crates/presentation/src/handlers/users.rs +++ b/crates/presentation/src/handlers/users.rs @@ -58,12 +58,13 @@ pub async fn get_profile( }, ) .await?; + let base_url = &state.app_ctx.config.base_url; Ok(Json(ProfileResponse { username: profile.username, display_name: profile.display_name, bio: profile.bio, - avatar_url: profile.avatar_url, - banner_url: profile.banner_url, + avatar_url: profile.avatar_path.map(|p| format!("{}/images/{}", base_url, p)), + banner_url: profile.banner_path.map(|p| format!("{}/images/{}", base_url, p)), also_known_as: profile.also_known_as, fields: profile .fields @@ -286,8 +287,8 @@ pub async fn get_user_profile( offset: p.offset, }); - let history = profile.history.map(|months| { - months + let history = profile.history.map(|entries| { + crate::mappers::users::group_by_month(entries) .into_iter() .map(|m| MonthActivityDto { year_month: m.year_month, @@ -542,8 +543,10 @@ pub async fn get_user_profile_html( .most_active_month .clone() .unwrap_or_else(|| "\u{2014}".to_string()); - let heatmap = profile + let history = profile .history + .map(crate::mappers::users::group_by_month); + let heatmap = history .as_deref() .map(build_heatmap) .unwrap_or_default(); @@ -594,7 +597,7 @@ pub async fn get_user_profile_html( current_offset: offset, has_more, limit, - history: profile.history.as_ref(), + history: history.as_ref(), trends: profile.trends.as_ref(), monthly_rating_rows, heatmap, @@ -618,7 +621,7 @@ pub async fn get_user_profile_html( current_offset: offset, has_more, limit, - history: profile.history.as_ref(), + history: history.as_ref(), trends: profile.trends.as_ref(), monthly_rating_rows, heatmap, diff --git a/crates/presentation/src/handlers/watchlist.rs b/crates/presentation/src/handlers/watchlist.rs index 2fdb840..25d92e5 100644 --- a/crates/presentation/src/handlers/watchlist.rs +++ b/crates/presentation/src/handlers/watchlist.rs @@ -178,19 +178,39 @@ pub async fn get_watchlist_page( let ctx = build_page_context(&state, viewer_id.clone(), csrf.0).await; let is_owner = viewer_id.map(|u| u.value() == owner_id).unwrap_or(false); - let result = match application::watchlist::get_page::execute( - &state.app_ctx, - application::watchlist::queries::GetWatchlistQuery { - user_id: owner_id, - limit: params.limit.or(Some(20)), - offset: params.offset.or(Some(0)), - }, - is_owner, - ) - .await - { - Ok(r) => r, - Err(e) => return crate::errors::domain_error_response(e), + let user_id = domain::value_objects::UserId::from_uuid(owner_id); + let is_local = state + .app_ctx + .repos + .user + .find_by_id(&user_id) + .await + .map(|u| u.is_some()) + .unwrap_or(false); + + let result = if is_local { + match get_watchlist::execute( + &state.app_ctx, + application::watchlist::queries::GetWatchlistQuery { + user_id: owner_id, + limit: params.limit.or(Some(20)), + offset: params.offset.or(Some(0)), + }, + ) + .await + { + Ok(page) => crate::mappers::watchlist::build_watchlist_page(page, is_owner), + Err(e) => return crate::errors::domain_error_response(e), + } + } else { + let remote_entries = state + .app_ctx + .repos + .remote_watchlist + .get_by_derived_uuid(owner_id) + .await + .unwrap_or_default(); + crate::mappers::watchlist::build_remote_watchlist_page(remote_entries) }; render_page(WatchlistTemplate { diff --git a/crates/presentation/src/mappers/mod.rs b/crates/presentation/src/mappers/mod.rs index 0bcdf14..0f30d64 100644 --- a/crates/presentation/src/mappers/mod.rs +++ b/crates/presentation/src/mappers/mod.rs @@ -3,3 +3,4 @@ pub mod import; pub mod integrations; pub mod movies; pub mod users; +pub mod watchlist; diff --git a/crates/presentation/src/mappers/users.rs b/crates/presentation/src/mappers/users.rs index abe347e..0397247 100644 --- a/crates/presentation/src/mappers/users.rs +++ b/crates/presentation/src/mappers/users.rs @@ -1,5 +1,6 @@ use application::users::get_profile::PendingFollowerView; -use domain::models::UserSummary; +use chrono::Datelike; +use domain::models::{DiaryEntry, MonthActivity, UserSummary}; use domain::ports::RemoteActorInfo; use template_askama::{RemoteActorData, RemoteActorDisplay, UserSummaryView}; @@ -43,3 +44,49 @@ pub fn pending_follower_data(p: &PendingFollowerView) -> RemoteActorData { avatar_url: p.avatar_url.clone(), } } + +pub fn group_by_month(entries: Vec) -> Vec { + use std::collections::BTreeMap; + let mut map: BTreeMap<(i32, u32), Vec> = BTreeMap::new(); + for entry in entries { + let watched_at = entry.review().watched_at(); + let year = watched_at.year(); + let month = watched_at.month(); + map.entry((year, month)).or_default().push(entry); + } + map.into_iter() + .rev() + .map(|((year, month), entries)| { + let year_month = format!("{:04}-{:02}", year, month); + MonthActivity { + month_label: format_year_month_long(&year_month), + count: entries.len() as i64, + entries, + year_month, + } + }) + .collect() +} + +fn format_year_month_long(ym: &str) -> String { + let parts: Vec<&str> = ym.splitn(2, '-').collect(); + if parts.len() != 2 { + return ym.to_string(); + } + let month = match parts[1] { + "01" => "January", + "02" => "February", + "03" => "March", + "04" => "April", + "05" => "May", + "06" => "June", + "07" => "July", + "08" => "August", + "09" => "September", + "10" => "October", + "11" => "November", + "12" => "December", + _ => parts[1], + }; + format!("{} {}", month, parts[0]) +} diff --git a/crates/presentation/src/mappers/watchlist.rs b/crates/presentation/src/mappers/watchlist.rs new file mode 100644 index 0000000..9ff5b83 --- /dev/null +++ b/crates/presentation/src/mappers/watchlist.rs @@ -0,0 +1,65 @@ +use domain::models::{RemoteWatchlistEntry, WatchlistWithMovie, collections::Paginated}; +use template_askama::WatchlistDisplayEntry; + +pub struct WatchlistPageResult { + pub display_entries: Vec, + pub has_more: bool, + pub current_offset: u32, + pub limit: u32, +} + +pub fn build_watchlist_page( + page: Paginated, + is_owner: bool, +) -> WatchlistPageResult { + let has_more = page.offset + page.limit < page.total_count as u32; + let display_entries = page + .items + .iter() + .map(|w| { + let remove_url = if is_owner { + Some(format!("/watchlist/{}/remove", w.movie.id().value())) + } else { + None + }; + WatchlistDisplayEntry { + poster_url: w + .movie + .poster_path() + .map(|p| format!("/images/{}", p.value())), + movie_title: w.movie.title().value().to_string(), + release_year: w.movie.release_year().value(), + movie_url: Some(format!("/movies/{}", w.movie.id().value())), + added_at: w.entry.added_at.format("%b %-d, %Y").to_string(), + remove_url, + } + }) + .collect(); + WatchlistPageResult { + display_entries, + has_more, + current_offset: page.offset, + limit: page.limit, + } +} + +pub fn build_remote_watchlist_page(entries: Vec) -> WatchlistPageResult { + let len = entries.len() as u32; + let display_entries = entries + .into_iter() + .map(|e| WatchlistDisplayEntry { + poster_url: e.poster_url, + movie_title: e.movie_title, + release_year: e.release_year, + movie_url: None, + added_at: e.added_at.format("%b %-d, %Y").to_string(), + remove_url: None, + }) + .collect(); + WatchlistPageResult { + display_entries, + has_more: false, + current_offset: 0, + limit: len, + } +}