From bb17beb80df6a16e934ad6eb9e4f01797dd8677a Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Mon, 29 Jun 2026 23:38:53 +0200 Subject: [PATCH] restructure --- .../adapters/activitypub/src/event_handler.rs | 14 +- crates/adapters/auth/src/lib.rs | 3 +- crates/adapters/metadata/src/lib.rs | 4 +- crates/adapters/metadata/src/omdb.rs | 2 +- crates/adapters/metadata/src/tmdb.rs | 2 +- .../adapters/postgres-federation/src/lib.rs | 8 +- crates/adapters/postgres/src/diary.rs | 8 +- crates/adapters/postgres/src/user_settings.rs | 4 +- crates/adapters/postgres/src/wrapup.rs | 6 +- crates/adapters/sqlite-federation/src/lib.rs | 8 +- crates/adapters/sqlite/src/diary.rs | 8 +- crates/adapters/sqlite/src/tests/diary.rs | 3 +- crates/adapters/sqlite/src/user_settings.rs | 4 +- crates/adapters/sqlite/src/wrapup.rs | 6 +- .../src/diary/get_activity_feed.rs | 2 +- .../application/src/diary/movie_resolver.rs | 4 +- crates/application/src/diary/queries.rs | 2 +- .../src/diary/tests/get_activity_feed.rs | 12 +- .../src/diary/tests/movie_resolver.rs | 4 +- .../src/diary/tests/review_logger.rs | 4 +- .../src/movies/tests/sync_poster.rs | 2 +- crates/application/src/users/get_profile.rs | 4 +- crates/application/src/users/get_users.rs | 4 +- crates/application/src/users/queries.rs | 2 +- .../src/users/tests/get_profile.rs | 12 +- .../application/src/wrapup/tests/compute.rs | 2 +- crates/domain/src/errors.rs | 63 -- crates/domain/src/errors/mod.rs | 32 + crates/domain/src/errors/tests.rs | 31 + crates/domain/src/models/federation.rs | 20 + crates/domain/src/models/feed.rs | 27 + crates/domain/src/models/mod.rs | 4 +- crates/domain/src/models/movie.rs | 8 + crates/domain/src/models/refresh_session.rs | 5 + crates/domain/src/models/wrapup.rs | 19 + crates/domain/src/ports.rs | 621 ------------------ crates/domain/src/ports/auth.rs | 61 ++ crates/domain/src/ports/diary.rs | 64 ++ crates/domain/src/ports/events.rs | 23 + crates/domain/src/ports/goals.rs | 21 + crates/domain/src/ports/images.rs | 30 + crates/domain/src/ports/import.rs | 41 ++ crates/domain/src/ports/jobs.rs | 9 + crates/domain/src/ports/media_server.rs | 50 ++ crates/domain/src/ports/mod.rs | 29 + crates/domain/src/ports/movie.rs | 70 ++ crates/domain/src/ports/person.rs | 45 ++ crates/domain/src/ports/search.rs | 21 + crates/domain/src/ports/social.rs | 93 +++ crates/domain/src/ports/watchlist.rs | 34 + crates/domain/src/ports/wrapup.rs | 43 ++ .../src/services/tests/wrapup_analyzer.rs | 2 +- crates/domain/src/services/wrapup_analyzer.rs | 2 +- crates/domain/src/testing/fakes.rs | 15 +- crates/domain/src/testing/in_memory.rs | 14 +- crates/domain/src/testing/noops.rs | 4 +- crates/domain/src/testing/panics.rs | 22 +- crates/domain/src/testing/wrapup.rs | 8 +- crates/domain/src/value_objects.rs | 284 -------- crates/domain/src/value_objects/ids.rs | 31 + crates/domain/src/value_objects/mod.rs | 13 + crates/domain/src/value_objects/movie.rs | 110 ++++ crates/domain/src/value_objects/review.rs | 46 ++ crates/domain/src/value_objects/user.rs | 99 +++ crates/presentation/src/handlers/users.rs | 2 +- crates/presentation/src/mappers/users.rs | 2 +- crates/presentation/src/tests/extractors.rs | 24 +- crates/presentation/tests/api_test.rs | 14 +- 68 files changed, 1202 insertions(+), 1088 deletions(-) delete mode 100644 crates/domain/src/errors.rs create mode 100644 crates/domain/src/errors/mod.rs create mode 100644 crates/domain/src/errors/tests.rs create mode 100644 crates/domain/src/models/federation.rs delete mode 100644 crates/domain/src/ports.rs create mode 100644 crates/domain/src/ports/auth.rs create mode 100644 crates/domain/src/ports/diary.rs create mode 100644 crates/domain/src/ports/events.rs create mode 100644 crates/domain/src/ports/goals.rs create mode 100644 crates/domain/src/ports/images.rs create mode 100644 crates/domain/src/ports/import.rs create mode 100644 crates/domain/src/ports/jobs.rs create mode 100644 crates/domain/src/ports/media_server.rs create mode 100644 crates/domain/src/ports/mod.rs create mode 100644 crates/domain/src/ports/movie.rs create mode 100644 crates/domain/src/ports/person.rs create mode 100644 crates/domain/src/ports/search.rs create mode 100644 crates/domain/src/ports/social.rs create mode 100644 crates/domain/src/ports/watchlist.rs create mode 100644 crates/domain/src/ports/wrapup.rs delete mode 100644 crates/domain/src/value_objects.rs create mode 100644 crates/domain/src/value_objects/ids.rs create mode 100644 crates/domain/src/value_objects/mod.rs create mode 100644 crates/domain/src/value_objects/movie.rs create mode 100644 crates/domain/src/value_objects/review.rs create mode 100644 crates/domain/src/value_objects/user.rs diff --git a/crates/adapters/activitypub/src/event_handler.rs b/crates/adapters/activitypub/src/event_handler.rs index 98cc8c2..f94c500 100644 --- a/crates/adapters/activitypub/src/event_handler.rs +++ b/crates/adapters/activitypub/src/event_handler.rs @@ -138,7 +138,7 @@ impl ActivityPubEventHandler { .federation_settings .get_federation_flags(user_id) .await - .unwrap_or(domain::ports::FederationFlags { + .unwrap_or(domain::models::FederationFlags { goals: true, reviews: true, watchlist: true, @@ -204,7 +204,7 @@ impl ActivityPubEventHandler { .federation_settings .get_federation_flags(user_id) .await - .unwrap_or(domain::ports::FederationFlags { + .unwrap_or(domain::models::FederationFlags { goals: true, reviews: true, watchlist: true, @@ -283,7 +283,7 @@ impl ActivityPubEventHandler { .federation_settings .get_federation_flags(user_id) .await - .unwrap_or(domain::ports::FederationFlags { + .unwrap_or(domain::models::FederationFlags { goals: true, reviews: true, watchlist: true, @@ -363,7 +363,7 @@ impl ActivityPubEventHandler { .federation_settings .get_federation_flags(user_id) .await - .unwrap_or(domain::ports::FederationFlags { + .unwrap_or(domain::models::FederationFlags { goals: true, reviews: true, watchlist: true, @@ -403,7 +403,7 @@ impl ActivityPubEventHandler { .federation_settings .get_federation_flags(user_id) .await - .unwrap_or(domain::ports::FederationFlags { + .unwrap_or(domain::models::FederationFlags { goals: true, reviews: true, watchlist: true, @@ -448,7 +448,7 @@ impl ActivityPubEventHandler { .federation_settings .get_federation_flags(user_id) .await - .unwrap_or(domain::ports::FederationFlags { + .unwrap_or(domain::models::FederationFlags { goals: true, reviews: true, watchlist: true, @@ -486,7 +486,7 @@ impl ActivityPubEventHandler { .federation_settings .get_federation_flags(user_id) .await - .unwrap_or(domain::ports::FederationFlags { + .unwrap_or(domain::models::FederationFlags { goals: true, reviews: true, watchlist: true, diff --git a/crates/adapters/auth/src/lib.rs b/crates/adapters/auth/src/lib.rs index b9f9e17..6271e4b 100644 --- a/crates/adapters/auth/src/lib.rs +++ b/crates/adapters/auth/src/lib.rs @@ -11,7 +11,8 @@ use uuid::Uuid; use domain::{ errors::DomainError, - ports::{AuthService, GeneratedToken, PasswordHasher}, + models::GeneratedToken, + ports::{AuthService, PasswordHasher}, value_objects::{PasswordHash, UserId}, }; diff --git a/crates/adapters/metadata/src/lib.rs b/crates/adapters/metadata/src/lib.rs index 7a66eda..87923fb 100644 --- a/crates/adapters/metadata/src/lib.rs +++ b/crates/adapters/metadata/src/lib.rs @@ -1,8 +1,8 @@ use async_trait::async_trait; use domain::{ errors::DomainError, - models::Movie, - ports::{MetadataClient, MetadataSearchCriteria}, + models::{MetadataSearchCriteria, Movie}, + ports::MetadataClient, value_objects::{ExternalMetadataId, MovieTitle, PosterUrl, ReleaseYear}, }; diff --git a/crates/adapters/metadata/src/omdb.rs b/crates/adapters/metadata/src/omdb.rs index 9d5d039..b5c4f7a 100644 --- a/crates/adapters/metadata/src/omdb.rs +++ b/crates/adapters/metadata/src/omdb.rs @@ -1,7 +1,7 @@ use async_trait::async_trait; use domain::{ errors::DomainError, - ports::MetadataSearchCriteria, + models::MetadataSearchCriteria, value_objects::{ExternalMetadataId, MovieTitle, PosterUrl, ReleaseYear}, }; use serde::Deserialize; diff --git a/crates/adapters/metadata/src/tmdb.rs b/crates/adapters/metadata/src/tmdb.rs index f80e367..7f39f0f 100644 --- a/crates/adapters/metadata/src/tmdb.rs +++ b/crates/adapters/metadata/src/tmdb.rs @@ -1,7 +1,7 @@ use async_trait::async_trait; use domain::{ errors::DomainError, - ports::MetadataSearchCriteria, + models::MetadataSearchCriteria, value_objects::{ExternalMetadataId, MovieTitle, PosterUrl, ReleaseYear}, }; use serde::Deserialize; diff --git a/crates/adapters/postgres-federation/src/lib.rs b/crates/adapters/postgres-federation/src/lib.rs index c082b03..af59fc8 100644 --- a/crates/adapters/postgres-federation/src/lib.rs +++ b/crates/adapters/postgres-federation/src/lib.rs @@ -740,7 +740,7 @@ impl domain::ports::SocialQueryPort for PostgresFederationRepository { async fn list_all_followed_remote_actors( &self, - ) -> Result, domain::errors::DomainError> { + ) -> Result, domain::errors::DomainError> { let rows = sqlx::query_as::<_, (String, String, Option)>( "SELECT DISTINCT ar.url, ar.handle, ar.display_name FROM ap_remote_actors ar @@ -753,7 +753,7 @@ impl domain::ports::SocialQueryPort for PostgresFederationRepository { Ok(rows .into_iter() .map( - |(url, handle, display_name)| domain::ports::RemoteActorInfo { + |(url, handle, display_name)| domain::models::RemoteActorInfo { url, handle, display_name, @@ -795,7 +795,7 @@ impl domain::ports::SocialQueryPort for PostgresFederationRepository { async fn get_pending_followers( &self, user_id: uuid::Uuid, - ) -> Result, domain::errors::DomainError> { + ) -> Result, domain::errors::DomainError> { let uid = user_id.to_string(); let rows = sqlx::query_as::<_, (String, String, Option, Option)>( "SELECT ar.url, ar.handle, ar.display_name, ar.avatar_url @@ -810,7 +810,7 @@ impl domain::ports::SocialQueryPort for PostgresFederationRepository { Ok(rows .into_iter() .map( - |(url, handle, display_name, avatar_url)| domain::ports::PendingFollowerInfo { + |(url, handle, display_name, avatar_url)| domain::models::PendingFollowerInfo { url, handle, display_name, diff --git a/crates/adapters/postgres/src/diary.rs b/crates/adapters/postgres/src/diary.rs index 04330b3..4b8c451 100644 --- a/crates/adapters/postgres/src/diary.rs +++ b/crates/adapters/postgres/src/diary.rs @@ -242,18 +242,18 @@ impl DiaryRepository for PostgresDiaryRepository { &self, page: &PageParams, ) -> Result, DomainError> { - self.query_activity_feed_filtered(page, &domain::ports::FeedSortBy::Date, None, None) + self.query_activity_feed_filtered(page, &domain::models::FeedSortBy::Date, None, None) .await } async fn query_activity_feed_filtered( &self, page: &PageParams, - sort_by: &domain::ports::FeedSortBy, + sort_by: &domain::models::FeedSortBy, search: Option<&str>, - following: Option<&domain::ports::FollowingFilter>, + following: Option<&domain::models::FollowingFilter>, ) -> Result, DomainError> { - use domain::ports::FeedSortBy; + use domain::models::FeedSortBy; let limit = page.limit as i64; let offset = page.offset as i64; diff --git a/crates/adapters/postgres/src/user_settings.rs b/crates/adapters/postgres/src/user_settings.rs index a89d723..f175972 100644 --- a/crates/adapters/postgres/src/user_settings.rs +++ b/crates/adapters/postgres/src/user_settings.rs @@ -1,8 +1,8 @@ use async_trait::async_trait; use domain::{ errors::DomainError, - models::UserSettings, - ports::{FederationFlags, UserFederationSettingsQuery, UserSettingsRepository}, + models::{FederationFlags, UserSettings}, + ports::{UserFederationSettingsQuery, UserSettingsRepository}, value_objects::UserId, }; use sqlx::{PgPool, Row}; diff --git a/crates/adapters/postgres/src/wrapup.rs b/crates/adapters/postgres/src/wrapup.rs index ff41f17..60486ee 100644 --- a/crates/adapters/postgres/src/wrapup.rs +++ b/crates/adapters/postgres/src/wrapup.rs @@ -4,8 +4,10 @@ use async_trait::async_trait; use chrono::NaiveDate; use domain::{ errors::DomainError, - models::wrapup::{DateRange, WrapUpRecord, WrapUpReport, WrapUpScope, WrapUpStatus}, - ports::{WrapUpMovieRow, WrapUpRepository, WrapUpStatsQuery}, + models::wrapup::{ + DateRange, WrapUpMovieRow, WrapUpRecord, WrapUpReport, WrapUpScope, WrapUpStatus, + }, + ports::{WrapUpRepository, WrapUpStatsQuery}, value_objects::WrapUpId, }; use sqlx::{PgPool, Row}; diff --git a/crates/adapters/sqlite-federation/src/lib.rs b/crates/adapters/sqlite-federation/src/lib.rs index 284b5c7..2546b4c 100644 --- a/crates/adapters/sqlite-federation/src/lib.rs +++ b/crates/adapters/sqlite-federation/src/lib.rs @@ -927,7 +927,7 @@ impl domain::ports::SocialQueryPort for SqliteFederationRepository { async fn list_all_followed_remote_actors( &self, - ) -> Result, domain::errors::DomainError> { + ) -> Result, domain::errors::DomainError> { let rows = sqlx::query_as::<_, (String, String, Option)>( "SELECT DISTINCT ar.url, ar.handle, ar.display_name FROM ap_remote_actors ar @@ -941,7 +941,7 @@ impl domain::ports::SocialQueryPort for SqliteFederationRepository { Ok(rows .into_iter() .map( - |(url, handle, display_name)| domain::ports::RemoteActorInfo { + |(url, handle, display_name)| domain::models::RemoteActorInfo { url, handle, display_name, @@ -983,7 +983,7 @@ impl domain::ports::SocialQueryPort for SqliteFederationRepository { async fn get_pending_followers( &self, user_id: uuid::Uuid, - ) -> Result, domain::errors::DomainError> { + ) -> Result, domain::errors::DomainError> { let uid = user_id.to_string(); let rows = sqlx::query_as::<_, (String, String, Option, Option)>( "SELECT ar.url, ar.handle, ar.display_name, ar.avatar_url @@ -998,7 +998,7 @@ impl domain::ports::SocialQueryPort for SqliteFederationRepository { Ok(rows .into_iter() .map( - |(url, handle, display_name, avatar_url)| domain::ports::PendingFollowerInfo { + |(url, handle, display_name, avatar_url)| domain::models::PendingFollowerInfo { url, handle, display_name, diff --git a/crates/adapters/sqlite/src/diary.rs b/crates/adapters/sqlite/src/diary.rs index 5e513fc..d51cfe7 100644 --- a/crates/adapters/sqlite/src/diary.rs +++ b/crates/adapters/sqlite/src/diary.rs @@ -223,18 +223,18 @@ impl DiaryRepository for SqliteDiaryRepository { &self, page: &PageParams, ) -> Result, DomainError> { - self.query_activity_feed_filtered(page, &domain::ports::FeedSortBy::Date, None, None) + self.query_activity_feed_filtered(page, &domain::models::FeedSortBy::Date, None, None) .await } async fn query_activity_feed_filtered( &self, page: &PageParams, - sort_by: &domain::ports::FeedSortBy, + sort_by: &domain::models::FeedSortBy, search: Option<&str>, - following: Option<&domain::ports::FollowingFilter>, + following: Option<&domain::models::FollowingFilter>, ) -> Result, DomainError> { - use domain::ports::FeedSortBy; + use domain::models::FeedSortBy; let limit = page.limit as i64; let offset = page.offset as i64; diff --git a/crates/adapters/sqlite/src/tests/diary.rs b/crates/adapters/sqlite/src/tests/diary.rs index a4f2b89..d20692e 100644 --- a/crates/adapters/sqlite/src/tests/diary.rs +++ b/crates/adapters/sqlite/src/tests/diary.rs @@ -1,7 +1,8 @@ use super::*; use domain::{ models::collections::PageParams, - ports::{DiaryRepository, FeedSortBy, FollowingFilter}, + models::{FeedSortBy, FollowingFilter}, + ports::DiaryRepository, }; use sqlx::SqlitePool; diff --git a/crates/adapters/sqlite/src/user_settings.rs b/crates/adapters/sqlite/src/user_settings.rs index c378145..990f5a7 100644 --- a/crates/adapters/sqlite/src/user_settings.rs +++ b/crates/adapters/sqlite/src/user_settings.rs @@ -1,8 +1,8 @@ use async_trait::async_trait; use domain::{ errors::DomainError, - models::UserSettings, - ports::{FederationFlags, UserFederationSettingsQuery, UserSettingsRepository}, + models::{FederationFlags, UserSettings}, + ports::{UserFederationSettingsQuery, UserSettingsRepository}, value_objects::UserId, }; use sqlx::{Row, SqlitePool}; diff --git a/crates/adapters/sqlite/src/wrapup.rs b/crates/adapters/sqlite/src/wrapup.rs index eb3d675..aa11c7d 100644 --- a/crates/adapters/sqlite/src/wrapup.rs +++ b/crates/adapters/sqlite/src/wrapup.rs @@ -4,8 +4,10 @@ use async_trait::async_trait; use chrono::NaiveDate; use domain::{ errors::DomainError, - models::wrapup::{DateRange, WrapUpRecord, WrapUpReport, WrapUpScope, WrapUpStatus}, - ports::{WrapUpMovieRow, WrapUpRepository, WrapUpStatsQuery}, + models::wrapup::{ + DateRange, WrapUpMovieRow, WrapUpRecord, WrapUpReport, WrapUpScope, WrapUpStatus, + }, + ports::{WrapUpRepository, WrapUpStatsQuery}, value_objects::WrapUpId, }; use sqlx::{Row, SqlitePool}; diff --git a/crates/application/src/diary/get_activity_feed.rs b/crates/application/src/diary/get_activity_feed.rs index 9e50da8..a844f43 100644 --- a/crates/application/src/diary/get_activity_feed.rs +++ b/crates/application/src/diary/get_activity_feed.rs @@ -1,11 +1,11 @@ use crate::diary::{deps::GetActivityFeedDeps, queries::GetActivityFeedQuery}; use domain::{ errors::DomainError, + models::FollowingFilter, models::{ FeedEntry, collections::{PageParams, Paginated}, }, - ports::FollowingFilter, }; pub async fn execute( diff --git a/crates/application/src/diary/movie_resolver.rs b/crates/application/src/diary/movie_resolver.rs index 20e5ad6..e404f62 100644 --- a/crates/application/src/diary/movie_resolver.rs +++ b/crates/application/src/diary/movie_resolver.rs @@ -1,8 +1,8 @@ use async_trait::async_trait; use domain::{ errors::DomainError, - models::Movie, - ports::{MetadataClient, MetadataSearchCriteria, MovieRepository}, + models::{MetadataSearchCriteria, Movie}, + ports::{MetadataClient, MovieRepository}, value_objects::{ExternalMetadataId, MovieTitle, ReleaseYear}, }; diff --git a/crates/application/src/diary/queries.rs b/crates/application/src/diary/queries.rs index c6f36b5..dd6b9fb 100644 --- a/crates/application/src/diary/queries.rs +++ b/crates/application/src/diary/queries.rs @@ -16,7 +16,7 @@ pub struct GetReviewHistoryQuery { pub struct GetActivityFeedQuery { pub limit: u32, pub offset: u32, - pub sort_by: domain::ports::FeedSortBy, + pub sort_by: domain::models::FeedSortBy, pub search: Option, pub viewer_user_id: Option, pub filter_following: bool, diff --git a/crates/application/src/diary/tests/get_activity_feed.rs b/crates/application/src/diary/tests/get_activity_feed.rs index c5bd601..85eefa8 100644 --- a/crates/application/src/diary/tests/get_activity_feed.rs +++ b/crates/application/src/diary/tests/get_activity_feed.rs @@ -26,7 +26,7 @@ async fn returns_empty_feed() { GetActivityFeedQuery { limit: 10, offset: 0, - sort_by: domain::ports::FeedSortBy::Date, + sort_by: domain::models::FeedSortBy::Date, search: None, viewer_user_id: None, filter_following: false, @@ -50,7 +50,7 @@ async fn returns_feed_with_following_filter() { GetActivityFeedQuery { limit: 10, offset: 0, - sort_by: domain::ports::FeedSortBy::Date, + sort_by: domain::models::FeedSortBy::Date, search: None, viewer_user_id: Some(viewer), filter_following: true, @@ -80,12 +80,12 @@ impl domain::ports::SocialQueryPort for FakeSocialWithFollowing { async fn get_pending_followers( &self, _: uuid::Uuid, - ) -> Result, DomainError> { + ) -> Result, DomainError> { Ok(vec![]) } async fn list_all_followed_remote_actors( &self, - ) -> Result, DomainError> { + ) -> Result, DomainError> { Ok(vec![]) } } @@ -123,7 +123,7 @@ async fn following_filter_parses_local_and_remote_urls() { GetActivityFeedQuery { limit: 10, offset: 0, - sort_by: domain::ports::FeedSortBy::Date, + sort_by: domain::models::FeedSortBy::Date, search: None, viewer_user_id: Some(viewer), filter_following: true, @@ -146,7 +146,7 @@ async fn following_filter_without_viewer_returns_none() { GetActivityFeedQuery { limit: 10, offset: 0, - sort_by: domain::ports::FeedSortBy::Date, + sort_by: domain::models::FeedSortBy::Date, search: None, viewer_user_id: None, filter_following: true, diff --git a/crates/application/src/diary/tests/movie_resolver.rs b/crates/application/src/diary/tests/movie_resolver.rs index 140bed1..4b94911 100644 --- a/crates/application/src/diary/tests/movie_resolver.rs +++ b/crates/application/src/diary/tests/movie_resolver.rs @@ -2,8 +2,8 @@ use super::*; use crate::diary::commands::MovieInput; use domain::{ errors::DomainError, - models::Movie, - ports::{MetadataSearchCriteria, MovieRepository}, + models::{MetadataSearchCriteria, Movie}, + ports::MovieRepository, value_objects::{ExternalMetadataId, MovieId, MovieTitle, PosterUrl, ReleaseYear}, }; diff --git a/crates/application/src/diary/tests/review_logger.rs b/crates/application/src/diary/tests/review_logger.rs index 2b29476..ee708cb 100644 --- a/crates/application/src/diary/tests/review_logger.rs +++ b/crates/application/src/diary/tests/review_logger.rs @@ -4,9 +4,9 @@ use async_trait::async_trait; use chrono::Utc; use domain::{ errors::DomainError, - models::Movie, models::WatchlistEntry, - ports::{MetadataClient, MetadataSearchCriteria, MovieRepository, WatchlistRepository}, + models::{MetadataSearchCriteria, Movie}, + ports::{MetadataClient, MovieRepository, WatchlistRepository}, testing::{ FakeMetadataClient, InMemoryMovieRepository, InMemoryReviewRepository, InMemoryWatchlistRepository, NoopEventPublisher, diff --git a/crates/application/src/movies/tests/sync_poster.rs b/crates/application/src/movies/tests/sync_poster.rs index 6442b80..1b803a7 100644 --- a/crates/application/src/movies/tests/sync_poster.rs +++ b/crates/application/src/movies/tests/sync_poster.rs @@ -74,7 +74,7 @@ struct FakeMetaWithPoster; impl MetadataClient for FakeMetaWithPoster { async fn fetch_movie_metadata( &self, - _: &domain::ports::MetadataSearchCriteria, + _: &domain::models::MetadataSearchCriteria, ) -> Result { unimplemented!() } diff --git a/crates/application/src/users/get_profile.rs b/crates/application/src/users/get_profile.rs index 0962ca4..6d31aae 100644 --- a/crates/application/src/users/get_profile.rs +++ b/crates/application/src/users/get_profile.rs @@ -4,11 +4,11 @@ use crate::users::{ }; use domain::{ errors::DomainError, + models::FeedSortBy, models::{ DiaryEntry, DiaryFilter, SortDirection, UserStats, UserTrends, collections::{PageParams, Paginated}, }, - ports::FeedSortBy, value_objects::UserId, }; @@ -143,7 +143,7 @@ mod helper_tests { #[test] fn feed_sort_to_direction_all_variants() { - use domain::ports::FeedSortBy; + use domain::models::FeedSortBy; assert!(matches!( feed_sort_to_direction(FeedSortBy::Date), SortDirection::Descending diff --git a/crates/application/src/users/get_users.rs b/crates/application/src/users/get_users.rs index c802d07..498ed04 100644 --- a/crates/application/src/users/get_users.rs +++ b/crates/application/src/users/get_users.rs @@ -3,8 +3,8 @@ use std::sync::Arc; use crate::users::queries::GetUsersQuery; use domain::{ errors::DomainError, - models::UserSummary, - ports::{RemoteActorInfo, SocialQueryPort, UserRepository}, + models::{RemoteActorInfo, UserSummary}, + ports::{SocialQueryPort, UserRepository}, }; pub struct UsersListData { diff --git a/crates/application/src/users/queries.rs b/crates/application/src/users/queries.rs index 6ea866c..610d79d 100644 --- a/crates/application/src/users/queries.rs +++ b/crates/application/src/users/queries.rs @@ -40,7 +40,7 @@ pub struct GetUserProfileQuery { pub view: ProfileView, pub limit: Option, pub offset: Option, - pub sort_by: domain::ports::FeedSortBy, + pub sort_by: domain::models::FeedSortBy, pub search: Option, pub is_own_profile: bool, } diff --git a/crates/application/src/users/tests/get_profile.rs b/crates/application/src/users/tests/get_profile.rs index 55da51b..9936c38 100644 --- a/crates/application/src/users/tests/get_profile.rs +++ b/crates/application/src/users/tests/get_profile.rs @@ -51,7 +51,7 @@ async fn returns_profile_with_empty_stats() { view: ProfileView::Recent, limit: None, offset: None, - sort_by: domain::ports::FeedSortBy::Date, + sort_by: domain::models::FeedSortBy::Date, search: None, is_own_profile: true, }, @@ -85,7 +85,7 @@ async fn returns_history_view() { view: ProfileView::History, limit: None, offset: None, - sort_by: domain::ports::FeedSortBy::Date, + sort_by: domain::models::FeedSortBy::Date, search: None, is_own_profile: true, }, @@ -121,7 +121,7 @@ async fn returns_trends_view() { view: ProfileView::Trends, limit: None, offset: None, - sort_by: domain::ports::FeedSortBy::Date, + sort_by: domain::models::FeedSortBy::Date, search: None, is_own_profile: true, }, @@ -157,7 +157,7 @@ async fn returns_ratings_view() { view: ProfileView::Ratings, limit: None, offset: None, - sort_by: domain::ports::FeedSortBy::Rating, + sort_by: domain::models::FeedSortBy::Rating, search: None, is_own_profile: true, }, @@ -191,7 +191,7 @@ async fn returns_recent_with_search() { view: ProfileView::Recent, limit: Some(10), offset: Some(0), - sort_by: domain::ports::FeedSortBy::Date, + sort_by: domain::models::FeedSortBy::Date, search: Some("blade".into()), is_own_profile: true, }, @@ -225,7 +225,7 @@ async fn non_own_profile_skips_pending_followers() { view: ProfileView::Recent, limit: None, offset: None, - sort_by: domain::ports::FeedSortBy::Date, + sort_by: domain::models::FeedSortBy::Date, search: None, is_own_profile: false, }, diff --git a/crates/application/src/wrapup/tests/compute.rs b/crates/application/src/wrapup/tests/compute.rs index 2768481..76c68c7 100644 --- a/crates/application/src/wrapup/tests/compute.rs +++ b/crates/application/src/wrapup/tests/compute.rs @@ -1,6 +1,6 @@ use chrono::NaiveDate; +use domain::models::WrapUpMovieRow; use domain::models::wrapup::{DateRange, WrapUpScope}; -use domain::ports::WrapUpMovieRow; use domain::testing::InMemoryWrapUpStatsQuery; use uuid::Uuid; diff --git a/crates/domain/src/errors.rs b/crates/domain/src/errors.rs deleted file mode 100644 index 7e96e0a..0000000 --- a/crates/domain/src/errors.rs +++ /dev/null @@ -1,63 +0,0 @@ -use thiserror::Error; - -#[derive(Error, Debug)] -pub enum DomainError { - #[error("Rating must be between 0 and {max}, but received {given}")] - InvalidRating { max: u8, given: u8 }, - - #[error("Entity not found: {0}")] - NotFound(String), - - #[error("Business rule violation: {0}")] - ValidationError(String), - - #[error("Infrastructure failure: {0}")] - InfrastructureError(String), - - #[error("Unauthorized: {0}")] - Unauthorized(String), - - #[error("Forbidden: {0}")] - Forbidden(String), -} - -impl DomainError { - pub fn is_transient(&self) -> bool { - matches!(self, DomainError::InfrastructureError(_)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn infrastructure_error_is_transient() { - assert!(DomainError::InfrastructureError("network timeout".into()).is_transient()); - } - - #[test] - fn not_found_is_not_transient() { - assert!(!DomainError::NotFound("thing".into()).is_transient()); - } - - #[test] - fn validation_error_is_not_transient() { - assert!(!DomainError::ValidationError("bad input".into()).is_transient()); - } - - #[test] - fn unauthorized_is_not_transient() { - assert!(!DomainError::Unauthorized("token expired".into()).is_transient()); - } - - #[test] - fn forbidden_is_not_transient() { - assert!(!DomainError::Forbidden("no access".into()).is_transient()); - } - - #[test] - fn invalid_rating_is_not_transient() { - assert!(!DomainError::InvalidRating { max: 5, given: 9 }.is_transient()); - } -} diff --git a/crates/domain/src/errors/mod.rs b/crates/domain/src/errors/mod.rs new file mode 100644 index 0000000..4e1dbf1 --- /dev/null +++ b/crates/domain/src/errors/mod.rs @@ -0,0 +1,32 @@ +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum DomainError { + #[error("Rating must be between 0 and {max}, but received {given}")] + InvalidRating { max: u8, given: u8 }, + + #[error("Entity not found: {0}")] + NotFound(String), + + #[error("Business rule violation: {0}")] + ValidationError(String), + + #[error("Infrastructure failure: {0}")] + InfrastructureError(String), + + #[error("Unauthorized: {0}")] + Unauthorized(String), + + #[error("Forbidden: {0}")] + Forbidden(String), +} + +impl DomainError { + pub fn is_transient(&self) -> bool { + matches!(self, DomainError::InfrastructureError(_)) + } +} + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/crates/domain/src/errors/tests.rs b/crates/domain/src/errors/tests.rs new file mode 100644 index 0000000..4dc2e66 --- /dev/null +++ b/crates/domain/src/errors/tests.rs @@ -0,0 +1,31 @@ +use super::*; + +#[test] +fn infrastructure_error_is_transient() { + assert!(DomainError::InfrastructureError("network timeout".into()).is_transient()); +} + +#[test] +fn not_found_is_not_transient() { + assert!(!DomainError::NotFound("thing".into()).is_transient()); +} + +#[test] +fn validation_error_is_not_transient() { + assert!(!DomainError::ValidationError("bad input".into()).is_transient()); +} + +#[test] +fn unauthorized_is_not_transient() { + assert!(!DomainError::Unauthorized("token expired".into()).is_transient()); +} + +#[test] +fn forbidden_is_not_transient() { + assert!(!DomainError::Forbidden("no access".into()).is_transient()); +} + +#[test] +fn invalid_rating_is_not_transient() { + assert!(!DomainError::InvalidRating { max: 5, given: 9 }.is_transient()); +} diff --git a/crates/domain/src/models/federation.rs b/crates/domain/src/models/federation.rs new file mode 100644 index 0000000..ae2f13e --- /dev/null +++ b/crates/domain/src/models/federation.rs @@ -0,0 +1,20 @@ +#[derive(Debug, Clone)] +pub struct RemoteActorInfo { + pub url: String, + pub handle: String, + pub display_name: Option, +} + +#[derive(Debug, Clone)] +pub struct PendingFollowerInfo { + pub url: String, + pub handle: String, + pub display_name: Option, + pub avatar_url: Option, +} + +pub struct FederationFlags { + pub goals: bool, + pub reviews: bool, + pub watchlist: bool, +} diff --git a/crates/domain/src/models/feed.rs b/crates/domain/src/models/feed.rs index 2f4d115..9587ba3 100644 --- a/crates/domain/src/models/feed.rs +++ b/crates/domain/src/models/feed.rs @@ -3,6 +3,33 @@ use super::{ review::{DiaryEntry, Review}, }; +#[derive(Debug, Clone, Default, PartialEq)] +pub enum FeedSortBy { + #[default] + Date, + DateAsc, + Rating, + RatingAsc, +} + +impl std::str::FromStr for FeedSortBy { + type Err = std::convert::Infallible; + fn from_str(s: &str) -> Result { + Ok(match s { + "date_asc" => Self::DateAsc, + "rating" => Self::Rating, + "rating_asc" => Self::RatingAsc, + _ => Self::Date, + }) + } +} + +#[derive(Debug, Clone, Default)] +pub struct FollowingFilter { + pub local_user_ids: Vec, + pub remote_actor_urls: Vec, +} + #[derive(Clone, Debug)] pub struct FeedEntry { entry: DiaryEntry, diff --git a/crates/domain/src/models/mod.rs b/crates/domain/src/models/mod.rs index b12b929..50d7e34 100644 --- a/crates/domain/src/models/mod.rs +++ b/crates/domain/src/models/mod.rs @@ -1,4 +1,5 @@ mod enrichment; +mod federation; mod feed; mod movie; mod refresh_session; @@ -21,6 +22,7 @@ pub mod watchlist; pub mod wrapup; pub use enrichment::*; +pub use federation::*; pub use feed::*; pub use movie::*; pub use review::*; @@ -47,7 +49,7 @@ pub use import_session::ImportSession; pub use person::{ CastCredit, CrewCredit, ExternalPersonId, Person, PersonCredits, PersonEnrichmentData, PersonId, }; -pub use refresh_session::RefreshSession; +pub use refresh_session::{GeneratedToken, RefreshSession}; pub use search::{ EntityType, IndexableDocument, MovieSearchHit, PersonSearchHit, SearchFilters, SearchQuery, SearchResults, diff --git a/crates/domain/src/models/movie.rs b/crates/domain/src/models/movie.rs index 8d6eb08..af8c30f 100644 --- a/crates/domain/src/models/movie.rs +++ b/crates/domain/src/models/movie.rs @@ -1,5 +1,13 @@ use crate::value_objects::{ExternalMetadataId, MovieId, MovieTitle, PosterPath, ReleaseYear}; +pub enum MetadataSearchCriteria { + ImdbId(ExternalMetadataId), + Title { + title: MovieTitle, + year: Option, + }, +} + #[derive(Clone, Debug, Default)] pub struct MovieFilter { pub search: Option, diff --git a/crates/domain/src/models/refresh_session.rs b/crates/domain/src/models/refresh_session.rs index 9d503a0..7863457 100644 --- a/crates/domain/src/models/refresh_session.rs +++ b/crates/domain/src/models/refresh_session.rs @@ -3,6 +3,11 @@ use uuid::Uuid; use crate::value_objects::UserId; +pub struct GeneratedToken { + pub token: String, + pub expires_at: DateTime, +} + #[derive(Clone, Debug)] pub struct RefreshSession { pub id: Uuid, diff --git a/crates/domain/src/models/wrapup.rs b/crates/domain/src/models/wrapup.rs index 8831aad..765c860 100644 --- a/crates/domain/src/models/wrapup.rs +++ b/crates/domain/src/models/wrapup.rs @@ -4,6 +4,25 @@ use uuid::Uuid; use crate::value_objects::WrapUpId; +#[derive(Clone, Debug)] +pub struct WrapUpMovieRow { + pub movie_id: Uuid, + pub title: String, + pub release_year: u16, + pub director: Option, + pub poster_path: Option, + pub rating: u8, + pub watched_at: NaiveDateTime, + pub user_id: Uuid, + pub runtime_minutes: Option, + pub budget_usd: Option, + pub original_language: Option, + pub genres: Vec, + pub keywords: Vec, + pub cast_names: Vec<(String, u32, i64)>, + pub cast_profile_paths: Vec>, +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct DateRange { start: NaiveDate, diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs deleted file mode 100644 index 407d7b6..0000000 --- a/crates/domain/src/ports.rs +++ /dev/null @@ -1,621 +0,0 @@ -use async_trait::async_trait; -use chrono::{DateTime, NaiveDate, NaiveDateTime, Utc}; -use uuid::Uuid; - -use crate::{ - errors::DomainError, - events::{DomainEvent, EventEnvelope}, - models::wrapup::WrapUpReport, - models::{ - AnnotatedRow, DiaryEntry, DiaryFilter, EntityType, ExportFormat, ExternalPersonId, - FeedEntry, FieldMapping, FileFormat, Goal, ImportError, ImportProfile, ImportSession, - IndexableDocument, Movie, MovieFilter, MovieProfile, MovieStats, MovieSummary, ParsedFile, - ParsedPlaybackEvent, Person, PersonCredits, PersonEnrichmentData, PersonId, RefreshSession, - RemoteGoalEntry, RemoteWatchlistEntry, Review, ReviewHistory, SearchQuery, SearchResults, - User, UserSettings, UserStats, UserSummary, UserTrends, WatchEvent, WatchEventStatus, - WatchlistEntry, WatchlistWithMovie, WebhookToken, - collections::{self, PageParams, Paginated}, - wrapup::{DateRange, WrapUpRecord, WrapUpScope, WrapUpStatus}, - }, - value_objects::{ - Email, ExternalMetadataId, GoalId, ImportProfileId, ImportSessionId, MovieId, MovieTitle, - PasswordHash, PosterUrl, ReleaseYear, ReviewId, UserId, Username, WatchEventId, - WebhookTokenId, WrapUpId, - }, -}; - -pub trait DocumentParser: Send + Sync { - fn parse(&self, bytes: &[u8], format: FileFormat) -> Result; - fn apply_mapping(&self, file: &ParsedFile, mappings: &[FieldMapping]) -> Vec; -} - -#[derive(Debug, Clone, Default, PartialEq)] -pub enum FeedSortBy { - #[default] - Date, - DateAsc, - Rating, - RatingAsc, -} - -impl std::str::FromStr for FeedSortBy { - type Err = std::convert::Infallible; - fn from_str(s: &str) -> Result { - Ok(match s { - "date_asc" => Self::DateAsc, - "rating" => Self::Rating, - "rating_asc" => Self::RatingAsc, - _ => Self::Date, - }) - } -} - -#[derive(Debug, Clone, Default)] -pub struct FollowingFilter { - pub local_user_ids: Vec, - pub remote_actor_urls: Vec, -} - -#[derive(Debug, Clone)] -pub struct RemoteActorInfo { - pub url: String, - pub handle: String, - pub display_name: Option, -} - -#[derive(Debug, Clone)] -pub struct PendingFollowerInfo { - pub url: String, - pub handle: String, - pub display_name: Option, - pub avatar_url: Option, -} - -#[async_trait] -pub trait SocialQueryPort: Send + Sync { - async fn get_accepted_following_urls( - &self, - user_id: uuid::Uuid, - ) -> Result, DomainError>; - - async fn list_all_followed_remote_actors(&self) -> Result, DomainError>; - - async fn count_following(&self, user_id: uuid::Uuid) -> Result; - - async fn count_accepted_followers(&self, user_id: uuid::Uuid) -> Result; - - async fn get_pending_followers( - &self, - user_id: uuid::Uuid, - ) -> Result, DomainError>; -} - -#[async_trait] -pub trait MovieRepository: Send + Sync { - async fn get_movie_by_external_id( - &self, - external_metadata_id: &ExternalMetadataId, - ) -> Result, DomainError>; - async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result, DomainError>; - async fn get_movies_by_title_and_year( - &self, - title: &MovieTitle, - year: &ReleaseYear, - ) -> Result, DomainError>; - async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError>; - async fn delete_movie(&self, movie_id: &MovieId) -> Result<(), DomainError>; - async fn existing_external_ids( - &self, - ids: &[ExternalMetadataId], - ) -> Result, DomainError>; - async fn existing_title_year_pairs( - &self, - pairs: &[(MovieTitle, ReleaseYear)], - ) -> Result, DomainError>; - async fn list_movies( - &self, - page: &collections::PageParams, - filter: &MovieFilter, - ) -> Result, DomainError>; -} - -#[async_trait] -pub trait ReviewRepository: Send + Sync { - async fn save_review(&self, review: &Review) -> Result; - async fn get_review_by_id(&self, review_id: &ReviewId) -> Result, DomainError>; - async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError>; - async fn get_all_reviews_for_user(&self, user_id: &UserId) -> Result, DomainError>; -} - -#[async_trait] -pub trait DiaryRepository: Send + Sync { - async fn query_diary(&self, filter: &DiaryFilter) - -> Result, DomainError>; - async fn query_activity_feed( - &self, - page: &PageParams, - ) -> Result, DomainError>; - async fn query_activity_feed_filtered( - &self, - page: &PageParams, - sort_by: &FeedSortBy, - search: Option<&str>, - following: Option<&FollowingFilter>, - ) -> Result, DomainError>; - async fn get_review_history(&self, movie_id: &MovieId) -> Result; - async fn get_user_history(&self, user_id: &UserId) -> Result, DomainError>; - fn stream_user_history( - &self, - user_id: UserId, - ) -> futures::stream::BoxStream<'static, Result>; - async fn get_movie_stats(&self, movie_id: &MovieId) -> Result; - async fn get_movie_social_feed( - &self, - movie_id: &MovieId, - page: &PageParams, - ) -> Result, DomainError>; - async fn count_local_posts(&self) -> Result; -} - -#[async_trait] -pub trait StatsRepository: Send + Sync { - async fn get_user_stats(&self, user_id: &UserId) -> Result; - async fn get_user_trends(&self, user_id: &UserId) -> Result; -} - -pub enum MetadataSearchCriteria { - ImdbId(ExternalMetadataId), - Title { - title: MovieTitle, - year: Option, - }, -} - -#[async_trait] -pub trait MetadataClient: Send + Sync { - async fn fetch_movie_metadata( - &self, - criteria: &MetadataSearchCriteria, - ) -> Result; - async fn get_poster_url( - &self, - external_metadata_id: &ExternalMetadataId, - ) -> Result, DomainError>; -} - -#[async_trait] -pub trait PosterFetcherClient: Send + Sync { - async fn fetch_poster_bytes(&self, poster_url: &PosterUrl) -> Result, DomainError>; -} - -#[async_trait] -pub trait ObjectStorage: Send + Sync { - /// Stores `image_bytes` at `key` and returns the stored key. - async fn store(&self, key: &str, image_bytes: &[u8]) -> Result; - async fn get(&self, key: &str) -> Result, DomainError>; - async fn get_stream( - &self, - key: &str, - ) -> Result>, DomainError>; - async fn delete(&self, key: &str) -> Result<(), DomainError>; -} - -pub struct GeneratedToken { - pub token: String, - pub expires_at: DateTime, -} - -#[async_trait] -pub trait AuthService: Send + Sync { - async fn generate_token(&self, user_id: &UserId) -> Result; - async fn validate_token(&self, token: &str) -> Result; -} - -#[async_trait] -pub trait UserRepository: Send + Sync { - async fn find_by_email(&self, email: &Email) -> Result, DomainError>; - async fn find_by_username(&self, username: &Username) -> Result, DomainError>; - async fn save(&self, user: &User) -> Result<(), DomainError>; - async fn find_by_id(&self, id: &UserId) -> Result, DomainError>; - async fn list_with_stats(&self) -> Result, DomainError>; - async fn update_profile( - &self, - user_id: &UserId, - profile: &crate::models::UserProfile, - ) -> Result<(), DomainError>; -} - -#[async_trait] -pub trait UserProfileFieldsRepository: Send + Sync { - async fn get_fields( - &self, - user_id: &UserId, - ) -> Result, DomainError>; - async fn set_fields( - &self, - user_id: &UserId, - fields: Vec, - ) -> Result<(), DomainError>; -} - -#[async_trait] -pub trait EventPublisher: Send + Sync { - async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError>; -} - -pub trait EventConsumer: Send + Sync { - /// Returns a stream of event envelopes. Each envelope carries a domain event - /// and an ack handle — callers ack after successful dispatch, nack on failure. - /// Implementations decide transport (NATS, DB queue, in-memory channel). - fn consume(&self) -> futures::stream::BoxStream<'_, Result>; -} - -#[async_trait] -pub trait PasswordHasher: Send + Sync { - async fn hash(&self, plain_password: &str) -> Result; - - async fn verify(&self, plain_password: &str, hash: &PasswordHash) -> Result; -} - -pub trait DiaryExporter: Send + Sync { - fn stream_entries( - &self, - stream: futures::stream::BoxStream<'static, Result>, - format: ExportFormat, - ) -> futures::stream::BoxStream<'static, Result>; -} - -#[async_trait] -pub trait EventHandler: Send + Sync { - async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError>; -} - -#[async_trait] -pub trait PeriodicJob: Send + Sync { - fn interval(&self) -> std::time::Duration; - async fn run(&self) -> Result<(), DomainError>; -} - -#[async_trait] -pub trait MovieProfileRepository: Send + Sync { - async fn upsert(&self, profile: &MovieProfile) -> Result<(), DomainError>; - async fn get_by_movie_id(&self, id: &MovieId) -> Result, DomainError>; - /// Returns (movie_id, external_metadata_id) for movies with no profile or a stale one - /// (enriched_at older than 30 days). - async fn list_stale(&self) -> Result, DomainError>; -} - -#[async_trait] -pub trait MovieEnrichmentClient: Send + Sync { - /// Resolves an external ID (TMDb or IMDb) and fetches the full movie profile. - async fn fetch_profile( - &self, - movie_id: MovieId, - external_metadata_id: &str, - ) -> Result; -} - -#[async_trait] -pub trait PersonEnrichmentClient: Send + Sync { - async fn fetch_details(&self, external_id: &str) -> Result; -} - -#[async_trait] -pub trait ImportSessionRepository: Send + Sync { - async fn create(&self, session: &ImportSession) -> Result<(), DomainError>; - async fn get( - &self, - id: &ImportSessionId, - user_id: &UserId, - ) -> Result, DomainError>; - async fn update(&self, session: &ImportSession) -> Result<(), DomainError>; - async fn delete(&self, id: &ImportSessionId) -> Result<(), DomainError>; - async fn delete_expired(&self) -> Result; - async fn delete_expired_for_user(&self, user_id: &UserId) -> Result<(), DomainError>; -} - -#[async_trait] -pub trait RefreshSessionRepository: Send + Sync { - async fn create(&self, session: &RefreshSession) -> Result<(), DomainError>; - async fn get_by_token(&self, token: &str) -> Result, DomainError>; - async fn revoke(&self, token: &str) -> Result<(), DomainError>; - async fn revoke_all_for_user(&self, user_id: &UserId) -> Result<(), DomainError>; - async fn delete_expired(&self) -> Result; -} - -#[async_trait] -pub trait ImportProfileRepository: Send + Sync { - async fn save(&self, profile: &ImportProfile) -> Result<(), DomainError>; - async fn list_for_user(&self, user_id: &UserId) -> Result, DomainError>; - async fn get( - &self, - id: &ImportProfileId, - user_id: &UserId, - ) -> Result, DomainError>; - async fn delete(&self, id: &ImportProfileId) -> Result<(), DomainError>; -} - -#[async_trait] -pub trait ImageRefCommand: Send + Sync { - async fn swap(&self, old_key: &str, new_key: &str) -> Result<(), DomainError>; -} - -#[async_trait] -pub trait ImageRefQuery: Send + Sync { - async fn list_keys(&self) -> Result, DomainError>; -} - -/// Write port — mutates the persons table. No reads. -#[async_trait] -pub trait PersonCommand: Send + Sync { - /// Upsert a batch of persons. Uses INSERT OR REPLACE (SQLite) / ON CONFLICT DO UPDATE (Postgres). - async fn upsert_batch(&self, persons: &[Person]) -> Result<(), DomainError>; - /// Insert a batch of missing persons from movie_cast/movie_crew into the persons table. - /// Returns (inserted_count, has_more). - async fn backfill_from_credits_batch( - &self, - batch_size: u32, - ) -> Result<(u64, bool), DomainError>; - async fn update_enrichment( - &self, - id: &PersonId, - data: &PersonEnrichmentData, - ) -> Result<(), DomainError>; -} - -/// Read port — queries persons and credits. No mutations. -#[async_trait] -pub trait PersonQuery: Send + Sync { - async fn get_by_id(&self, id: &PersonId) -> Result, DomainError>; - async fn get_by_external_id( - &self, - id: &ExternalPersonId, - ) -> Result, DomainError>; - /// Returns the person's full cast and crew credit history across all indexed movies. - async fn get_credits(&self, id: &PersonId) -> Result; - /// Returns persons who have no remaining entries in movie_cast or movie_crew. - /// Called after movie deletion to find index entries that can be pruned. - async fn list_orphaned_persons(&self) -> Result, DomainError>; - async fn list_page(&self, limit: u32, offset: u32) -> Result, DomainError>; -} - -/// Read port — executes search queries. No mutations. -#[async_trait] -pub trait SearchPort: Send + Sync { - async fn search(&self, query: &SearchQuery) -> Result; -} - -/// Write port — manages the search index. No reads. -#[async_trait] -pub trait SearchCommand: Send + Sync { - /// Add or replace a document in the search index. - async fn index(&self, doc: IndexableDocument) -> Result<(), DomainError>; - /// Remove a document from the search index by entity type and internal ID string. - async fn remove(&self, entity_type: EntityType, id: &str) -> Result<(), DomainError>; -} - -#[async_trait] -pub trait WatchlistRepository: Send + Sync { - /// Add a new entry. Silently succeeds if the entry already exists. - async fn add(&self, entry: &WatchlistEntry) -> Result<(), DomainError>; - - /// Remove an entry. Returns NotFound if the entry does not exist. - async fn remove(&self, user_id: &UserId, movie_id: &MovieId) -> Result<(), DomainError>; - - /// Remove an entry if it exists. Never returns NotFound. - async fn remove_if_present( - &self, - user_id: &UserId, - movie_id: &MovieId, - ) -> Result; - - async fn get_for_user( - &self, - user_id: &UserId, - page: &collections::PageParams, - ) -> Result, DomainError>; - - async fn contains(&self, user_id: &UserId, movie_id: &MovieId) -> Result; -} - -#[async_trait] -pub trait RemoteWatchlistRepository: Send + Sync { - async fn save(&self, entry: RemoteWatchlistEntry) -> Result<(), DomainError>; - async fn remove_by_ap_id(&self, ap_id: &str, actor_url: &str) -> Result<(), DomainError>; - async fn get_by_actor_url( - &self, - actor_url: &str, - ) -> Result, DomainError>; - async fn remove_all_by_actor(&self, actor_url: &str) -> Result<(), DomainError>; - /// Find entries for a remote actor whose URL hashes (v5 UUID) to the given UUID. - async fn get_by_derived_uuid( - &self, - uuid: uuid::Uuid, - ) -> Result, DomainError>; -} - -// ── Goals ──────────────────────────────────────────────────────────────────── - -#[async_trait] -pub trait GoalRepository: Send + Sync { - async fn save(&self, goal: &Goal) -> Result<(), DomainError>; - async fn update(&self, goal: &Goal) -> Result<(), DomainError>; - async fn delete(&self, id: &GoalId, user_id: &UserId) -> Result<(), DomainError>; - async fn find_by_user_and_year( - &self, - user_id: &UserId, - year: u16, - ) -> Result, DomainError>; - async fn list_for_user(&self, user_id: &UserId) -> Result, DomainError>; - async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result; -} - -#[async_trait] -pub trait UserSettingsRepository: Send + Sync { - async fn get(&self, user_id: &UserId) -> Result; - async fn save(&self, settings: &UserSettings) -> Result<(), DomainError>; -} - -pub struct FederationFlags { - pub goals: bool, - pub reviews: bool, - pub watchlist: bool, -} - -#[async_trait] -pub trait UserFederationSettingsQuery: Send + Sync { - async fn get_federation_flags(&self, user_id: &UserId) -> Result; -} - -#[async_trait] -pub trait RemoteGoalRepository: Send + Sync { - async fn save(&self, entry: RemoteGoalEntry) -> Result<(), DomainError>; - async fn update_by_ap_id( - &self, - ap_id: &str, - target: u32, - current: u32, - ) -> Result<(), DomainError>; - async fn remove_by_ap_id(&self, ap_id: &str, actor_url: &str) -> Result<(), DomainError>; - async fn remove_all_by_actor(&self, actor_url: &str) -> Result<(), DomainError>; - async fn get_by_actor_url(&self, actor_url: &str) -> Result, DomainError>; -} - -/// Read-only query port used exclusively by the ActivityPub adapter. -/// Consolidates all reads the AP adapter needs so it never touches write repositories. -#[async_trait] -pub trait LocalApContentQuery: Send + Sync { - async fn get_local_reviews_for_user( - &self, - user_id: &UserId, - ) -> Result, DomainError>; - - async fn get_local_watchlist_for_user( - &self, - user_id: &UserId, - ) -> Result, DomainError>; - - async fn get_review_by_id(&self, review_id: &ReviewId) -> Result, DomainError>; - - async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result, DomainError>; - - async fn count_local_posts(&self) -> Result; - - async fn get_local_reviews_for_movie( - &self, - movie_id: &MovieId, - ) -> Result, DomainError>; - - async fn get_local_reviews_page( - &self, - user_id: &UserId, - before: Option, - limit: usize, - ) -> Result, DomainError>; - - async fn get_goal_with_progress( - &self, - user_id: &UserId, - year: u16, - ) -> Result, DomainError>; -} - -// ── Media server integration ────────────────────────────────────────────────── - -pub trait MediaServerParser: Send + Sync { - fn parse_playback_event(&self, body: &[u8]) - -> Result, DomainError>; -} - -#[async_trait] -pub trait WatchEventRepository: Send + Sync { - async fn save(&self, event: &WatchEvent) -> Result<(), DomainError>; - async fn update_status( - &self, - id: &WatchEventId, - status: WatchEventStatus, - ) -> Result<(), DomainError>; - async fn list_pending(&self, user_id: &UserId) -> Result, DomainError>; - async fn get_by_id(&self, id: &WatchEventId) -> Result, DomainError>; - async fn get_by_ids(&self, ids: &[WatchEventId]) -> Result, DomainError>; - async fn update_status_batch( - &self, - ids: &[WatchEventId], - status: WatchEventStatus, - ) -> Result; - async fn find_duplicate( - &self, - user_id: &UserId, - external_id: &str, - after: chrono::NaiveDateTime, - ) -> Result; - async fn delete_non_pending_older_than( - &self, - before: chrono::NaiveDateTime, - ) -> Result; -} - -#[async_trait] -pub trait WebhookTokenRepository: Send + Sync { - async fn save(&self, token: &WebhookToken) -> Result<(), DomainError>; - async fn find_by_token_hash(&self, hash: &str) -> Result, DomainError>; - async fn list_by_user(&self, user_id: &UserId) -> Result, DomainError>; - async fn delete(&self, id: &WebhookTokenId, user_id: &UserId) -> Result<(), DomainError>; - async fn touch_last_used(&self, id: &WebhookTokenId) -> Result<(), DomainError>; -} - -#[async_trait] -pub trait WrapUpRepository: Send + Sync { - async fn create(&self, record: &WrapUpRecord) -> Result<(), DomainError>; - async fn update_status( - &self, - id: &WrapUpId, - status: &WrapUpStatus, - error: Option<&str>, - ) -> Result<(), DomainError>; - async fn set_complete(&self, id: &WrapUpId, report: &WrapUpReport) -> Result<(), DomainError>; - async fn get_by_id(&self, id: &WrapUpId) -> Result, DomainError>; - async fn list_for_user(&self, user_id: Uuid) -> Result, DomainError>; - async fn list_global(&self) -> Result, DomainError>; - async fn find_existing( - &self, - user_id: Option, - start: NaiveDate, - end: NaiveDate, - ) -> Result, DomainError>; - async fn delete(&self, id: &WrapUpId) -> Result<(), DomainError>; - async fn delete_failed_older_than( - &self, - before: chrono::NaiveDateTime, - ) -> Result; -} - -// ── Wrap-up / Year-in-Review ───────────────────────────────────────────────── - -#[derive(Clone, Debug)] -pub struct WrapUpMovieRow { - pub movie_id: Uuid, - pub title: String, - pub release_year: u16, - pub director: Option, - pub poster_path: Option, - pub rating: u8, - pub watched_at: NaiveDateTime, - pub user_id: Uuid, - pub runtime_minutes: Option, - pub budget_usd: Option, - pub original_language: Option, - pub genres: Vec, - pub keywords: Vec, - pub cast_names: Vec<(String, u32, i64)>, - pub cast_profile_paths: Vec>, -} - -#[async_trait] -pub trait WrapUpStatsQuery: Send + Sync { - async fn get_reviews_with_profiles( - &self, - scope: &WrapUpScope, - range: &DateRange, - ) -> Result, DomainError>; -} diff --git a/crates/domain/src/ports/auth.rs b/crates/domain/src/ports/auth.rs new file mode 100644 index 0000000..a1e95bf --- /dev/null +++ b/crates/domain/src/ports/auth.rs @@ -0,0 +1,61 @@ +use async_trait::async_trait; + +use crate::{ + errors::DomainError, + models::{GeneratedToken, RefreshSession, User, UserSettings, UserSummary}, + value_objects::{Email, PasswordHash, UserId, Username}, +}; + +#[async_trait] +pub trait AuthService: Send + Sync { + async fn generate_token(&self, user_id: &UserId) -> Result; + async fn validate_token(&self, token: &str) -> Result; +} + +#[async_trait] +pub trait UserRepository: Send + Sync { + async fn find_by_email(&self, email: &Email) -> Result, DomainError>; + async fn find_by_username(&self, username: &Username) -> Result, DomainError>; + async fn save(&self, user: &User) -> Result<(), DomainError>; + async fn find_by_id(&self, id: &UserId) -> Result, DomainError>; + async fn list_with_stats(&self) -> Result, DomainError>; + async fn update_profile( + &self, + user_id: &UserId, + profile: &crate::models::UserProfile, + ) -> Result<(), DomainError>; +} + +#[async_trait] +pub trait UserProfileFieldsRepository: Send + Sync { + async fn get_fields( + &self, + user_id: &UserId, + ) -> Result, DomainError>; + async fn set_fields( + &self, + user_id: &UserId, + fields: Vec, + ) -> Result<(), DomainError>; +} + +#[async_trait] +pub trait PasswordHasher: Send + Sync { + async fn hash(&self, plain_password: &str) -> Result; + async fn verify(&self, plain_password: &str, hash: &PasswordHash) -> Result; +} + +#[async_trait] +pub trait UserSettingsRepository: Send + Sync { + async fn get(&self, user_id: &UserId) -> Result; + async fn save(&self, settings: &UserSettings) -> Result<(), DomainError>; +} + +#[async_trait] +pub trait RefreshSessionRepository: Send + Sync { + async fn create(&self, session: &RefreshSession) -> Result<(), DomainError>; + async fn get_by_token(&self, token: &str) -> Result, DomainError>; + async fn revoke(&self, token: &str) -> Result<(), DomainError>; + async fn revoke_all_for_user(&self, user_id: &UserId) -> Result<(), DomainError>; + async fn delete_expired(&self) -> Result; +} diff --git a/crates/domain/src/ports/diary.rs b/crates/domain/src/ports/diary.rs new file mode 100644 index 0000000..383bd3a --- /dev/null +++ b/crates/domain/src/ports/diary.rs @@ -0,0 +1,64 @@ +use async_trait::async_trait; + +use crate::{ + errors::DomainError, + events::DomainEvent, + models::{ + DiaryEntry, DiaryFilter, ExportFormat, FeedEntry, FeedSortBy, FollowingFilter, MovieStats, + Review, ReviewHistory, UserStats, UserTrends, + collections::{PageParams, Paginated}, + }, + value_objects::{MovieId, ReviewId, UserId}, +}; + +#[async_trait] +pub trait DiaryRepository: Send + Sync { + async fn query_diary(&self, filter: &DiaryFilter) + -> Result, DomainError>; + async fn query_activity_feed( + &self, + page: &PageParams, + ) -> Result, DomainError>; + async fn query_activity_feed_filtered( + &self, + page: &PageParams, + sort_by: &FeedSortBy, + search: Option<&str>, + following: Option<&FollowingFilter>, + ) -> Result, DomainError>; + async fn get_review_history(&self, movie_id: &MovieId) -> Result; + async fn get_user_history(&self, user_id: &UserId) -> Result, DomainError>; + fn stream_user_history( + &self, + user_id: UserId, + ) -> futures::stream::BoxStream<'static, Result>; + async fn get_movie_stats(&self, movie_id: &MovieId) -> Result; + async fn get_movie_social_feed( + &self, + movie_id: &MovieId, + page: &PageParams, + ) -> Result, DomainError>; + async fn count_local_posts(&self) -> Result; +} + +#[async_trait] +pub trait ReviewRepository: Send + Sync { + async fn save_review(&self, review: &Review) -> Result; + async fn get_review_by_id(&self, review_id: &ReviewId) -> Result, DomainError>; + async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError>; + async fn get_all_reviews_for_user(&self, user_id: &UserId) -> Result, DomainError>; +} + +#[async_trait] +pub trait StatsRepository: Send + Sync { + async fn get_user_stats(&self, user_id: &UserId) -> Result; + async fn get_user_trends(&self, user_id: &UserId) -> Result; +} + +pub trait DiaryExporter: Send + Sync { + fn stream_entries( + &self, + stream: futures::stream::BoxStream<'static, Result>, + format: ExportFormat, + ) -> futures::stream::BoxStream<'static, Result>; +} diff --git a/crates/domain/src/ports/events.rs b/crates/domain/src/ports/events.rs new file mode 100644 index 0000000..a7444ed --- /dev/null +++ b/crates/domain/src/ports/events.rs @@ -0,0 +1,23 @@ +use async_trait::async_trait; + +use crate::{ + errors::DomainError, + events::{DomainEvent, EventEnvelope}, +}; + +#[async_trait] +pub trait EventPublisher: Send + Sync { + async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError>; +} + +pub trait EventConsumer: Send + Sync { + /// Returns a stream of event envelopes. Each envelope carries a domain event + /// and an ack handle — callers ack after successful dispatch, nack on failure. + /// Implementations decide transport (NATS, DB queue, in-memory channel). + fn consume(&self) -> futures::stream::BoxStream<'_, Result>; +} + +#[async_trait] +pub trait EventHandler: Send + Sync { + async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError>; +} diff --git a/crates/domain/src/ports/goals.rs b/crates/domain/src/ports/goals.rs new file mode 100644 index 0000000..1b1ae24 --- /dev/null +++ b/crates/domain/src/ports/goals.rs @@ -0,0 +1,21 @@ +use async_trait::async_trait; + +use crate::{ + errors::DomainError, + models::Goal, + value_objects::{GoalId, UserId}, +}; + +#[async_trait] +pub trait GoalRepository: Send + Sync { + async fn save(&self, goal: &Goal) -> Result<(), DomainError>; + async fn update(&self, goal: &Goal) -> Result<(), DomainError>; + async fn delete(&self, id: &GoalId, user_id: &UserId) -> Result<(), DomainError>; + async fn find_by_user_and_year( + &self, + user_id: &UserId, + year: u16, + ) -> Result, DomainError>; + async fn list_for_user(&self, user_id: &UserId) -> Result, DomainError>; + async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result; +} diff --git a/crates/domain/src/ports/images.rs b/crates/domain/src/ports/images.rs new file mode 100644 index 0000000..f329f44 --- /dev/null +++ b/crates/domain/src/ports/images.rs @@ -0,0 +1,30 @@ +use async_trait::async_trait; + +use crate::{errors::DomainError, value_objects::PosterUrl}; + +#[async_trait] +pub trait ObjectStorage: Send + Sync { + /// Stores `image_bytes` at `key` and returns the stored key. + async fn store(&self, key: &str, image_bytes: &[u8]) -> Result; + async fn get(&self, key: &str) -> Result, DomainError>; + async fn get_stream( + &self, + key: &str, + ) -> Result>, DomainError>; + async fn delete(&self, key: &str) -> Result<(), DomainError>; +} + +#[async_trait] +pub trait PosterFetcherClient: Send + Sync { + async fn fetch_poster_bytes(&self, poster_url: &PosterUrl) -> Result, DomainError>; +} + +#[async_trait] +pub trait ImageRefCommand: Send + Sync { + async fn swap(&self, old_key: &str, new_key: &str) -> Result<(), DomainError>; +} + +#[async_trait] +pub trait ImageRefQuery: Send + Sync { + async fn list_keys(&self) -> Result, DomainError>; +} diff --git a/crates/domain/src/ports/import.rs b/crates/domain/src/ports/import.rs new file mode 100644 index 0000000..2db91bc --- /dev/null +++ b/crates/domain/src/ports/import.rs @@ -0,0 +1,41 @@ +use async_trait::async_trait; + +use crate::{ + errors::DomainError, + models::{ + AnnotatedRow, FieldMapping, FileFormat, ImportError, ImportProfile, ImportSession, + ParsedFile, + }, + value_objects::{ImportProfileId, ImportSessionId, UserId}, +}; + +pub trait DocumentParser: Send + Sync { + fn parse(&self, bytes: &[u8], format: FileFormat) -> Result; + fn apply_mapping(&self, file: &ParsedFile, mappings: &[FieldMapping]) -> Vec; +} + +#[async_trait] +pub trait ImportSessionRepository: Send + Sync { + async fn create(&self, session: &ImportSession) -> Result<(), DomainError>; + async fn get( + &self, + id: &ImportSessionId, + user_id: &UserId, + ) -> Result, DomainError>; + async fn update(&self, session: &ImportSession) -> Result<(), DomainError>; + async fn delete(&self, id: &ImportSessionId) -> Result<(), DomainError>; + async fn delete_expired(&self) -> Result; + async fn delete_expired_for_user(&self, user_id: &UserId) -> Result<(), DomainError>; +} + +#[async_trait] +pub trait ImportProfileRepository: Send + Sync { + async fn save(&self, profile: &ImportProfile) -> Result<(), DomainError>; + async fn list_for_user(&self, user_id: &UserId) -> Result, DomainError>; + async fn get( + &self, + id: &ImportProfileId, + user_id: &UserId, + ) -> Result, DomainError>; + async fn delete(&self, id: &ImportProfileId) -> Result<(), DomainError>; +} diff --git a/crates/domain/src/ports/jobs.rs b/crates/domain/src/ports/jobs.rs new file mode 100644 index 0000000..a3ec807 --- /dev/null +++ b/crates/domain/src/ports/jobs.rs @@ -0,0 +1,9 @@ +use async_trait::async_trait; + +use crate::errors::DomainError; + +#[async_trait] +pub trait PeriodicJob: Send + Sync { + fn interval(&self) -> std::time::Duration; + async fn run(&self) -> Result<(), DomainError>; +} diff --git a/crates/domain/src/ports/media_server.rs b/crates/domain/src/ports/media_server.rs new file mode 100644 index 0000000..5c482eb --- /dev/null +++ b/crates/domain/src/ports/media_server.rs @@ -0,0 +1,50 @@ +use async_trait::async_trait; +use chrono::NaiveDateTime; + +use crate::{ + errors::DomainError, + models::{ParsedPlaybackEvent, WatchEvent, WatchEventStatus, WebhookToken}, + value_objects::{UserId, WatchEventId, WebhookTokenId}, +}; + +pub trait MediaServerParser: Send + Sync { + fn parse_playback_event(&self, body: &[u8]) + -> Result, DomainError>; +} + +#[async_trait] +pub trait WatchEventRepository: Send + Sync { + async fn save(&self, event: &WatchEvent) -> Result<(), DomainError>; + async fn update_status( + &self, + id: &WatchEventId, + status: WatchEventStatus, + ) -> Result<(), DomainError>; + async fn list_pending(&self, user_id: &UserId) -> Result, DomainError>; + async fn get_by_id(&self, id: &WatchEventId) -> Result, DomainError>; + async fn get_by_ids(&self, ids: &[WatchEventId]) -> Result, DomainError>; + async fn update_status_batch( + &self, + ids: &[WatchEventId], + status: WatchEventStatus, + ) -> Result; + async fn find_duplicate( + &self, + user_id: &UserId, + external_id: &str, + after: NaiveDateTime, + ) -> Result; + async fn delete_non_pending_older_than( + &self, + before: NaiveDateTime, + ) -> Result; +} + +#[async_trait] +pub trait WebhookTokenRepository: Send + Sync { + async fn save(&self, token: &WebhookToken) -> Result<(), DomainError>; + async fn find_by_token_hash(&self, hash: &str) -> Result, DomainError>; + async fn list_by_user(&self, user_id: &UserId) -> Result, DomainError>; + async fn delete(&self, id: &WebhookTokenId, user_id: &UserId) -> Result<(), DomainError>; + async fn touch_last_used(&self, id: &WebhookTokenId) -> Result<(), DomainError>; +} diff --git a/crates/domain/src/ports/mod.rs b/crates/domain/src/ports/mod.rs new file mode 100644 index 0000000..1d3d6c8 --- /dev/null +++ b/crates/domain/src/ports/mod.rs @@ -0,0 +1,29 @@ +pub mod auth; +pub mod diary; +pub mod events; +pub mod goals; +pub mod images; +pub mod import; +pub mod jobs; +pub mod media_server; +pub mod movie; +pub mod person; +pub mod search; +pub mod social; +pub mod watchlist; +pub mod wrapup; + +pub use auth::*; +pub use diary::*; +pub use events::*; +pub use goals::*; +pub use images::*; +pub use import::*; +pub use jobs::*; +pub use media_server::*; +pub use movie::*; +pub use person::*; +pub use search::*; +pub use social::*; +pub use watchlist::*; +pub use wrapup::*; diff --git a/crates/domain/src/ports/movie.rs b/crates/domain/src/ports/movie.rs new file mode 100644 index 0000000..3eb301d --- /dev/null +++ b/crates/domain/src/ports/movie.rs @@ -0,0 +1,70 @@ +use async_trait::async_trait; + +use crate::{ + errors::DomainError, + models::{ + MetadataSearchCriteria, Movie, MovieFilter, MovieProfile, MovieSummary, + collections::{PageParams, Paginated}, + }, + value_objects::{ExternalMetadataId, MovieId, MovieTitle, PosterUrl, ReleaseYear}, +}; + +#[async_trait] +pub trait MovieRepository: Send + Sync { + async fn get_movie_by_external_id( + &self, + external_metadata_id: &ExternalMetadataId, + ) -> Result, DomainError>; + async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result, DomainError>; + async fn get_movies_by_title_and_year( + &self, + title: &MovieTitle, + year: &ReleaseYear, + ) -> Result, DomainError>; + async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError>; + async fn delete_movie(&self, movie_id: &MovieId) -> Result<(), DomainError>; + async fn existing_external_ids( + &self, + ids: &[ExternalMetadataId], + ) -> Result, DomainError>; + async fn existing_title_year_pairs( + &self, + pairs: &[(MovieTitle, ReleaseYear)], + ) -> Result, DomainError>; + async fn list_movies( + &self, + page: &PageParams, + filter: &MovieFilter, + ) -> Result, DomainError>; +} + +#[async_trait] +pub trait MetadataClient: Send + Sync { + async fn fetch_movie_metadata( + &self, + criteria: &MetadataSearchCriteria, + ) -> Result; + async fn get_poster_url( + &self, + external_metadata_id: &ExternalMetadataId, + ) -> Result, DomainError>; +} + +#[async_trait] +pub trait MovieProfileRepository: Send + Sync { + async fn upsert(&self, profile: &MovieProfile) -> Result<(), DomainError>; + async fn get_by_movie_id(&self, id: &MovieId) -> Result, DomainError>; + /// Returns (movie_id, external_metadata_id) for movies with no profile or a stale one + /// (enriched_at older than 30 days). + async fn list_stale(&self) -> Result, DomainError>; +} + +#[async_trait] +pub trait MovieEnrichmentClient: Send + Sync { + /// Resolves an external ID (TMDb or IMDb) and fetches the full movie profile. + async fn fetch_profile( + &self, + movie_id: MovieId, + external_metadata_id: &str, + ) -> Result; +} diff --git a/crates/domain/src/ports/person.rs b/crates/domain/src/ports/person.rs new file mode 100644 index 0000000..a6f33b7 --- /dev/null +++ b/crates/domain/src/ports/person.rs @@ -0,0 +1,45 @@ +use async_trait::async_trait; + +use crate::{ + errors::DomainError, + models::{ExternalPersonId, Person, PersonCredits, PersonEnrichmentData, PersonId}, +}; + +#[async_trait] +pub trait PersonEnrichmentClient: Send + Sync { + async fn fetch_details(&self, external_id: &str) -> Result; +} + +/// Write port — mutates the persons table. No reads. +#[async_trait] +pub trait PersonCommand: Send + Sync { + /// Upsert a batch of persons. Uses INSERT OR REPLACE (SQLite) / ON CONFLICT DO UPDATE (Postgres). + async fn upsert_batch(&self, persons: &[Person]) -> Result<(), DomainError>; + /// Insert a batch of missing persons from movie_cast/movie_crew into the persons table. + /// Returns (inserted_count, has_more). + async fn backfill_from_credits_batch( + &self, + batch_size: u32, + ) -> Result<(u64, bool), DomainError>; + async fn update_enrichment( + &self, + id: &PersonId, + data: &PersonEnrichmentData, + ) -> Result<(), DomainError>; +} + +/// Read port — queries persons and credits. No mutations. +#[async_trait] +pub trait PersonQuery: Send + Sync { + async fn get_by_id(&self, id: &PersonId) -> Result, DomainError>; + async fn get_by_external_id( + &self, + id: &ExternalPersonId, + ) -> Result, DomainError>; + /// Returns the person's full cast and crew credit history across all indexed movies. + async fn get_credits(&self, id: &PersonId) -> Result; + /// Returns persons who have no remaining entries in movie_cast or movie_crew. + /// Called after movie deletion to find index entries that can be pruned. + async fn list_orphaned_persons(&self) -> Result, DomainError>; + async fn list_page(&self, limit: u32, offset: u32) -> Result, DomainError>; +} diff --git a/crates/domain/src/ports/search.rs b/crates/domain/src/ports/search.rs new file mode 100644 index 0000000..b482e80 --- /dev/null +++ b/crates/domain/src/ports/search.rs @@ -0,0 +1,21 @@ +use async_trait::async_trait; + +use crate::{ + errors::DomainError, + models::{EntityType, IndexableDocument, SearchQuery, SearchResults}, +}; + +/// Read port — executes search queries. No mutations. +#[async_trait] +pub trait SearchPort: Send + Sync { + async fn search(&self, query: &SearchQuery) -> Result; +} + +/// Write port — manages the search index. No reads. +#[async_trait] +pub trait SearchCommand: Send + Sync { + /// Add or replace a document in the search index. + async fn index(&self, doc: IndexableDocument) -> Result<(), DomainError>; + /// Remove a document from the search index by entity type and internal ID string. + async fn remove(&self, entity_type: EntityType, id: &str) -> Result<(), DomainError>; +} diff --git a/crates/domain/src/ports/social.rs b/crates/domain/src/ports/social.rs new file mode 100644 index 0000000..9933751 --- /dev/null +++ b/crates/domain/src/ports/social.rs @@ -0,0 +1,93 @@ +use async_trait::async_trait; +use chrono::NaiveDateTime; + +use crate::{ + errors::DomainError, + models::{ + DiaryEntry, FederationFlags, Goal, Movie, PendingFollowerInfo, RemoteActorInfo, + RemoteGoalEntry, RemoteWatchlistEntry, Review, WatchlistWithMovie, + }, + value_objects::{MovieId, ReviewId, UserId}, +}; + +#[async_trait] +pub trait SocialQueryPort: Send + Sync { + async fn get_accepted_following_urls( + &self, + user_id: uuid::Uuid, + ) -> Result, DomainError>; + async fn list_all_followed_remote_actors(&self) -> Result, DomainError>; + async fn count_following(&self, user_id: uuid::Uuid) -> Result; + async fn count_accepted_followers(&self, user_id: uuid::Uuid) -> Result; + async fn get_pending_followers( + &self, + user_id: uuid::Uuid, + ) -> Result, DomainError>; +} + +#[async_trait] +pub trait UserFederationSettingsQuery: Send + Sync { + async fn get_federation_flags(&self, user_id: &UserId) -> Result; +} + +#[async_trait] +pub trait RemoteWatchlistRepository: Send + Sync { + async fn save(&self, entry: RemoteWatchlistEntry) -> Result<(), DomainError>; + async fn remove_by_ap_id(&self, ap_id: &str, actor_url: &str) -> Result<(), DomainError>; + async fn get_by_actor_url( + &self, + actor_url: &str, + ) -> Result, DomainError>; + async fn remove_all_by_actor(&self, actor_url: &str) -> Result<(), DomainError>; + /// Find entries for a remote actor whose URL hashes (v5 UUID) to the given UUID. + async fn get_by_derived_uuid( + &self, + uuid: uuid::Uuid, + ) -> Result, DomainError>; +} + +#[async_trait] +pub trait RemoteGoalRepository: Send + Sync { + async fn save(&self, entry: RemoteGoalEntry) -> Result<(), DomainError>; + async fn update_by_ap_id( + &self, + ap_id: &str, + target: u32, + current: u32, + ) -> Result<(), DomainError>; + async fn remove_by_ap_id(&self, ap_id: &str, actor_url: &str) -> Result<(), DomainError>; + async fn remove_all_by_actor(&self, actor_url: &str) -> Result<(), DomainError>; + async fn get_by_actor_url(&self, actor_url: &str) -> Result, DomainError>; +} + +/// Read-only query port used exclusively by the ActivityPub adapter. +/// Consolidates all reads the AP adapter needs so it never touches write repositories. +#[async_trait] +pub trait LocalApContentQuery: Send + Sync { + async fn get_local_reviews_for_user( + &self, + user_id: &UserId, + ) -> Result, DomainError>; + async fn get_local_watchlist_for_user( + &self, + user_id: &UserId, + ) -> Result, DomainError>; + async fn get_review_by_id(&self, review_id: &ReviewId) -> Result, DomainError>; + async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result, DomainError>; + async fn count_local_posts(&self) -> Result; + async fn get_local_reviews_for_movie( + &self, + movie_id: &MovieId, + ) -> Result, DomainError>; + async fn get_local_reviews_page( + &self, + user_id: &UserId, + before: Option, + limit: usize, + ) -> Result, DomainError>; + async fn get_goal_with_progress( + &self, + user_id: &UserId, + year: u16, + ) -> Result, DomainError>; +} diff --git a/crates/domain/src/ports/watchlist.rs b/crates/domain/src/ports/watchlist.rs new file mode 100644 index 0000000..ecf7ee5 --- /dev/null +++ b/crates/domain/src/ports/watchlist.rs @@ -0,0 +1,34 @@ +use async_trait::async_trait; + +use crate::{ + errors::DomainError, + models::{ + WatchlistEntry, WatchlistWithMovie, + collections::{PageParams, Paginated}, + }, + value_objects::{MovieId, UserId}, +}; + +#[async_trait] +pub trait WatchlistRepository: Send + Sync { + /// Add a new entry. Silently succeeds if the entry already exists. + async fn add(&self, entry: &WatchlistEntry) -> Result<(), DomainError>; + + /// Remove an entry. Returns NotFound if the entry does not exist. + async fn remove(&self, user_id: &UserId, movie_id: &MovieId) -> Result<(), DomainError>; + + /// Remove an entry if it exists. Never returns NotFound. + async fn remove_if_present( + &self, + user_id: &UserId, + movie_id: &MovieId, + ) -> Result; + + async fn get_for_user( + &self, + user_id: &UserId, + page: &PageParams, + ) -> Result, DomainError>; + + async fn contains(&self, user_id: &UserId, movie_id: &MovieId) -> Result; +} diff --git a/crates/domain/src/ports/wrapup.rs b/crates/domain/src/ports/wrapup.rs new file mode 100644 index 0000000..cd6129c --- /dev/null +++ b/crates/domain/src/ports/wrapup.rs @@ -0,0 +1,43 @@ +use async_trait::async_trait; +use chrono::{NaiveDate, NaiveDateTime}; +use uuid::Uuid; + +use crate::{ + errors::DomainError, + models::wrapup::{ + DateRange, WrapUpMovieRow, WrapUpRecord, WrapUpReport, WrapUpScope, WrapUpStatus, + }, + value_objects::WrapUpId, +}; + +#[async_trait] +pub trait WrapUpRepository: Send + Sync { + async fn create(&self, record: &WrapUpRecord) -> Result<(), DomainError>; + async fn update_status( + &self, + id: &WrapUpId, + status: &WrapUpStatus, + error: Option<&str>, + ) -> Result<(), DomainError>; + async fn set_complete(&self, id: &WrapUpId, report: &WrapUpReport) -> Result<(), DomainError>; + async fn get_by_id(&self, id: &WrapUpId) -> Result, DomainError>; + async fn list_for_user(&self, user_id: Uuid) -> Result, DomainError>; + async fn list_global(&self) -> Result, DomainError>; + async fn find_existing( + &self, + user_id: Option, + start: NaiveDate, + end: NaiveDate, + ) -> Result, DomainError>; + async fn delete(&self, id: &WrapUpId) -> Result<(), DomainError>; + async fn delete_failed_older_than(&self, before: NaiveDateTime) -> Result; +} + +#[async_trait] +pub trait WrapUpStatsQuery: Send + Sync { + async fn get_reviews_with_profiles( + &self, + scope: &WrapUpScope, + range: &DateRange, + ) -> Result, DomainError>; +} diff --git a/crates/domain/src/services/tests/wrapup_analyzer.rs b/crates/domain/src/services/tests/wrapup_analyzer.rs index d20b221..98c93cd 100644 --- a/crates/domain/src/services/tests/wrapup_analyzer.rs +++ b/crates/domain/src/services/tests/wrapup_analyzer.rs @@ -1,8 +1,8 @@ use chrono::NaiveDate; use uuid::Uuid; +use crate::models::WrapUpMovieRow; use crate::models::wrapup::{DateRange, WrapUpScope}; -use crate::ports::WrapUpMovieRow; use super::super::wrapup_analyzer::build_report; diff --git a/crates/domain/src/services/wrapup_analyzer.rs b/crates/domain/src/services/wrapup_analyzer.rs index 29d588a..d98fb81 100644 --- a/crates/domain/src/services/wrapup_analyzer.rs +++ b/crates/domain/src/services/wrapup_analyzer.rs @@ -3,9 +3,9 @@ use std::collections::HashMap; use chrono::Datelike; use uuid::Uuid; +use crate::models::WrapUpMovieRow; use crate::models::wrapup::*; use crate::models::{ExternalPersonId, PersonId}; -use crate::ports::WrapUpMovieRow; pub fn build_report( scope: WrapUpScope, diff --git a/crates/domain/src/testing/fakes.rs b/crates/domain/src/testing/fakes.rs index 8bb9cf9..091565d 100644 --- a/crates/domain/src/testing/fakes.rs +++ b/crates/domain/src/testing/fakes.rs @@ -8,16 +8,17 @@ use uuid::Uuid; use crate::{ errors::DomainError, models::{ - AnnotatedRow, DiaryEntry, DiaryFilter, ExternalPersonId, FeedEntry, FieldMapping, - FileFormat, ImportError, ImportRow, Movie, MovieProfile, MovieStats, ParsedFile, Person, - PersonCredits, PersonId, Review, ReviewHistory, RowResult, SearchQuery, SearchResults, - UserStats, UserTrends, + AnnotatedRow, DiaryEntry, DiaryFilter, ExternalPersonId, FeedEntry, FeedSortBy, + FieldMapping, FileFormat, FollowingFilter, GeneratedToken, ImportError, ImportRow, + MetadataSearchCriteria, Movie, MovieProfile, MovieStats, ParsedFile, Person, PersonCredits, + PersonId, Review, ReviewHistory, RowResult, SearchQuery, SearchResults, UserStats, + UserTrends, collections::{PageParams, Paginated}, }, ports::{ - AuthService, DiaryRepository, DocumentParser, FeedSortBy, FollowingFilter, GeneratedToken, - MetadataClient, MetadataSearchCriteria, MovieEnrichmentClient, PasswordHasher, PersonQuery, - PosterFetcherClient, SearchCommand, SearchPort, StatsRepository, + AuthService, DiaryRepository, DocumentParser, MetadataClient, MovieEnrichmentClient, + PasswordHasher, PersonQuery, PosterFetcherClient, SearchCommand, SearchPort, + StatsRepository, }, value_objects::{ExternalMetadataId, MovieId, PasswordHash, PosterUrl, UserId}, }; diff --git a/crates/domain/src/testing/in_memory.rs b/crates/domain/src/testing/in_memory.rs index 03f4236..0ae859e 100644 --- a/crates/domain/src/testing/in_memory.rs +++ b/crates/domain/src/testing/in_memory.rs @@ -12,16 +12,16 @@ use crate::{ errors::DomainError, events::DomainEvent, models::{ - Goal, ImportProfile, ImportSession, Movie, MovieFilter, MovieProfile, MovieSummary, - ProfileField, RefreshSession, Review, User, UserSettings, UserSummary, WatchEvent, - WatchEventStatus, WatchlistEntry, WatchlistWithMovie, WebhookToken, + FederationFlags, Goal, ImportProfile, ImportSession, Movie, MovieFilter, MovieProfile, + MovieSummary, ProfileField, RefreshSession, Review, User, UserSettings, UserSummary, + WatchEvent, WatchEventStatus, WatchlistEntry, WatchlistWithMovie, WebhookToken, collections::{PageParams, Paginated}, }, ports::{ - FederationFlags, GoalRepository, ImportProfileRepository, ImportSessionRepository, - MovieProfileRepository, MovieRepository, RefreshSessionRepository, ReviewRepository, - UserFederationSettingsQuery, UserProfileFieldsRepository, UserRepository, - UserSettingsRepository, WatchEventRepository, WatchlistRepository, WebhookTokenRepository, + GoalRepository, ImportProfileRepository, ImportSessionRepository, MovieProfileRepository, + MovieRepository, RefreshSessionRepository, ReviewRepository, UserFederationSettingsQuery, + UserProfileFieldsRepository, UserRepository, UserSettingsRepository, WatchEventRepository, + WatchlistRepository, WebhookTokenRepository, }, value_objects::{ Email, ExternalMetadataId, GoalId, ImportProfileId, ImportSessionId, MovieId, MovieTitle, diff --git a/crates/domain/src/testing/noops.rs b/crates/domain/src/testing/noops.rs index c272d47..29f1b47 100644 --- a/crates/domain/src/testing/noops.rs +++ b/crates/domain/src/testing/noops.rs @@ -102,7 +102,7 @@ impl crate::ports::SocialQueryPort for NoopSocialQueryPort { } async fn list_all_followed_remote_actors( &self, - ) -> Result, DomainError> { + ) -> Result, DomainError> { Ok(vec![]) } async fn count_following(&self, _: uuid::Uuid) -> Result { @@ -114,7 +114,7 @@ impl crate::ports::SocialQueryPort for NoopSocialQueryPort { async fn get_pending_followers( &self, _: uuid::Uuid, - ) -> Result, DomainError> { + ) -> Result, DomainError> { Ok(vec![]) } } diff --git a/crates/domain/src/testing/panics.rs b/crates/domain/src/testing/panics.rs index b491927..3ddf400 100644 --- a/crates/domain/src/testing/panics.rs +++ b/crates/domain/src/testing/panics.rs @@ -4,17 +4,17 @@ use crate::{ errors::DomainError, models::{ AnnotatedRow, DiaryEntry, DiaryFilter, EntityType, ExportFormat, ExternalPersonId, - FeedEntry, FieldMapping, FileFormat, ImportError, ImportProfile, ImportSession, - IndexableDocument, MovieProfile, MovieStats, ParsedFile, Person, PersonCredits, - PersonEnrichmentData, PersonId, RefreshSession, ReviewHistory, SearchQuery, SearchResults, - UserStats, UserTrends, + FeedEntry, FeedSortBy, FieldMapping, FileFormat, FollowingFilter, ImportError, + ImportProfile, ImportSession, IndexableDocument, MovieProfile, MovieStats, ParsedFile, + PendingFollowerInfo, Person, PersonCredits, PersonEnrichmentData, PersonId, RefreshSession, + RemoteActorInfo, ReviewHistory, SearchQuery, SearchResults, UserStats, UserTrends, collections::{PageParams, Paginated}, }, ports::{ - DiaryExporter, DiaryRepository, DocumentParser, FeedSortBy, FollowingFilter, - ImportProfileRepository, ImportSessionRepository, MovieProfileRepository, PersonCommand, - PersonQuery, PosterFetcherClient, RefreshSessionRepository, SearchCommand, SearchPort, - StatsRepository, UserProfileFieldsRepository, + DiaryExporter, DiaryRepository, DocumentParser, ImportProfileRepository, + ImportSessionRepository, MovieProfileRepository, PersonCommand, PersonQuery, + PosterFetcherClient, RefreshSessionRepository, SearchCommand, SearchPort, StatsRepository, + UserProfileFieldsRepository, }, value_objects::{ImportProfileId, ImportSessionId, MovieId, PosterUrl, UserId}, }; @@ -330,9 +330,7 @@ impl crate::ports::SocialQueryPort for PanicSocialQueryPort { async fn get_accepted_following_urls(&self, _: uuid::Uuid) -> Result, DomainError> { panic!("PanicSocialQueryPort called") } - async fn list_all_followed_remote_actors( - &self, - ) -> Result, DomainError> { + async fn list_all_followed_remote_actors(&self) -> Result, DomainError> { panic!("PanicSocialQueryPort called") } async fn count_following(&self, _: uuid::Uuid) -> Result { @@ -344,7 +342,7 @@ impl crate::ports::SocialQueryPort for PanicSocialQueryPort { async fn get_pending_followers( &self, _: uuid::Uuid, - ) -> Result, DomainError> { + ) -> Result, DomainError> { panic!("PanicSocialQueryPort called") } } diff --git a/crates/domain/src/testing/wrapup.rs b/crates/domain/src/testing/wrapup.rs index 4d09369..8808541 100644 --- a/crates/domain/src/testing/wrapup.rs +++ b/crates/domain/src/testing/wrapup.rs @@ -15,7 +15,7 @@ impl crate::ports::WrapUpStatsQuery for PanicWrapUpStatsQuery { &self, _: &crate::models::wrapup::WrapUpScope, _: &crate::models::wrapup::DateRange, - ) -> Result, DomainError> { + ) -> Result, DomainError> { unimplemented!("WrapUpStatsQuery not wired") } } @@ -23,7 +23,7 @@ impl crate::ports::WrapUpStatsQuery for PanicWrapUpStatsQuery { // ── InMemoryWrapUpStatsQuery ──────────────────────────────────────────────── pub struct InMemoryWrapUpStatsQuery { - pub rows: Mutex>, + pub rows: Mutex>, } impl InMemoryWrapUpStatsQuery { @@ -33,7 +33,7 @@ impl InMemoryWrapUpStatsQuery { }) } - pub fn with_rows(rows: Vec) -> Arc { + pub fn with_rows(rows: Vec) -> Arc { Arc::new(Self { rows: Mutex::new(rows), }) @@ -46,7 +46,7 @@ impl crate::ports::WrapUpStatsQuery for InMemoryWrapUpStatsQuery { &self, scope: &crate::models::wrapup::WrapUpScope, range: &crate::models::wrapup::DateRange, - ) -> Result, DomainError> { + ) -> Result, DomainError> { let rows = self.rows.lock().unwrap(); let filtered: Vec<_> = rows .iter() diff --git a/crates/domain/src/value_objects.rs b/crates/domain/src/value_objects.rs deleted file mode 100644 index 9f596d4..0000000 --- a/crates/domain/src/value_objects.rs +++ /dev/null @@ -1,284 +0,0 @@ -use crate::errors::DomainError; -use uuid::Uuid; - -macro_rules! uuid_id { - ($name:ident) => { - #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] - pub struct $name(Uuid); - - impl $name { - pub fn generate() -> Self { - Self(Uuid::new_v4()) - } - pub fn from_uuid(uuid: Uuid) -> Self { - Self(uuid) - } - pub fn value(&self) -> Uuid { - self.0 - } - } - }; -} - -uuid_id!(MovieId); -uuid_id!(ReviewId); -uuid_id!(UserId); -uuid_id!(ImportSessionId); -uuid_id!(ImportProfileId); -uuid_id!(WatchlistEntryId); -uuid_id!(WatchEventId); -uuid_id!(WebhookTokenId); -uuid_id!(WrapUpId); -uuid_id!(GoalId); - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ExternalMetadataId(String); - -impl ExternalMetadataId { - pub fn new(id: String) -> Result { - let trimmed = id.trim(); - if trimmed.is_empty() { - Err(DomainError::ValidationError( - "External metadata ID cannot be empty".into(), - )) - } else { - Ok(Self(trimmed.to_string())) - } - } - - pub fn value(&self) -> &str { - &self.0 - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PosterPath(String); - -impl PosterPath { - pub fn new(path: String) -> Result { - let trimmed = path.trim(); - if trimmed.is_empty() { - Err(DomainError::ValidationError( - "Poster path cannot be empty".into(), - )) - } else { - Ok(Self(trimmed.to_string())) - } - } - - pub fn value(&self) -> &str { - &self.0 - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct MovieTitle(String); - -impl MovieTitle { - const MAX_LENGTH: usize = 255; - - pub fn new(title: String) -> Result { - let trimmed = title.trim(); - if trimmed.is_empty() { - Err(DomainError::ValidationError( - "Movie title cannot be empty".into(), - )) - } else if trimmed.len() > Self::MAX_LENGTH { - Err(DomainError::ValidationError(format!( - "Movie title exceeds {} characters", - Self::MAX_LENGTH - ))) - } else { - Ok(Self(trimmed.to_string())) - } - } - - pub fn value(&self) -> &str { - &self.0 - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Comment(String); - -impl Comment { - const MAX_LENGTH: usize = 10_000; - - pub fn new(comment: String) -> Result { - let trimmed = comment.trim(); - if trimmed.len() > Self::MAX_LENGTH { - Err(DomainError::ValidationError(format!( - "Comment exceeds {} characters", - Self::MAX_LENGTH - ))) - } else { - Ok(Self(trimmed.to_string())) - } - } - - pub fn value(&self) -> &str { - &self.0 - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Rating(u8); - -impl Rating { - const MAX: u8 = 5; - - pub fn new(value: u8) -> Result { - if value <= Self::MAX { - Ok(Self(value)) - } else { - Err(DomainError::InvalidRating { - max: Self::MAX, - given: value, - }) - } - } - pub fn value(&self) -> u8 { - self.0 - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ReleaseYear(u16); - -impl ReleaseYear { - const EARLIEST: u16 = 1888; - pub fn new(year: u16) -> Result { - if year < Self::EARLIEST { - Err(DomainError::ValidationError(format!( - "Release year cannot be earlier than {} (first film ever made)", - Self::EARLIEST - ))) - } else { - Ok(Self(year)) - } - } - - pub fn value(&self) -> u16 { - self.0 - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Email(String); - -impl Email { - pub fn new(email: String) -> Result { - let trimmed = email.trim(); - if email_address::EmailAddress::is_valid(trimmed) { - Ok(Self(trimmed.to_string())) - } else { - Err(DomainError::ValidationError("Invalid email format".into())) - } - } - pub fn value(&self) -> &str { - &self.0 - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Username(String); - -impl Username { - const MIN_LENGTH: usize = 2; - const MAX_LENGTH: usize = 30; - - /// Accepts 2–30 chars: lowercase letters, digits, underscores, hyphens. - /// Lowercases input automatically. - pub fn new(raw: String) -> Result { - let s = raw.trim().to_lowercase(); - if s.len() < Self::MIN_LENGTH || s.len() > Self::MAX_LENGTH { - return Err(DomainError::ValidationError(format!( - "Username must be {}–{} characters", - Self::MIN_LENGTH, - Self::MAX_LENGTH - ))); - } - if !s - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') - { - return Err(DomainError::ValidationError( - "Username may only contain letters, digits, underscores, and hyphens".into(), - )); - } - Ok(Self(s)) - } - - pub fn value(&self) -> &str { - &self.0 - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PasswordHash(String); - -impl PasswordHash { - pub fn new(hash: String) -> Result { - if hash.is_empty() { - Err(DomainError::ValidationError( - "Password hash cannot be empty".into(), - )) - } else { - Ok(Self(hash)) - } - } - pub fn value(&self) -> &str { - &self.0 - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PosterUrl(String); - -impl PosterUrl { - pub fn new(url: String) -> Result { - let trimmed = url.trim(); - if trimmed.is_empty() { - Err(DomainError::ValidationError( - "Poster URL cannot be empty".into(), - )) - } else { - Ok(Self(trimmed.to_string())) - } - } - - pub fn value(&self) -> &str { - &self.0 - } -} - -#[derive(Clone, PartialEq, Eq)] -pub struct Password(String); - -impl std::fmt::Debug for Password { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("Password([REDACTED])") - } -} - -impl Password { - const MIN_LENGTH: usize = 8; - - pub fn new(raw: String) -> Result { - if raw.chars().count() < Self::MIN_LENGTH { - Err(DomainError::ValidationError( - "Password must be at least 8 characters".into(), - )) - } else { - Ok(Self(raw)) - } - } - - pub fn value(&self) -> &str { - &self.0 - } -} - -#[cfg(test)] -#[path = "tests/value_objects.rs"] -mod tests; diff --git a/crates/domain/src/value_objects/ids.rs b/crates/domain/src/value_objects/ids.rs new file mode 100644 index 0000000..e63fadc --- /dev/null +++ b/crates/domain/src/value_objects/ids.rs @@ -0,0 +1,31 @@ +use uuid::Uuid; + +macro_rules! uuid_id { + ($name:ident) => { + #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] + pub struct $name(Uuid); + + impl $name { + pub fn generate() -> Self { + Self(Uuid::new_v4()) + } + pub fn from_uuid(uuid: Uuid) -> Self { + Self(uuid) + } + pub fn value(&self) -> Uuid { + self.0 + } + } + }; +} + +uuid_id!(MovieId); +uuid_id!(ReviewId); +uuid_id!(UserId); +uuid_id!(ImportSessionId); +uuid_id!(ImportProfileId); +uuid_id!(WatchlistEntryId); +uuid_id!(WatchEventId); +uuid_id!(WebhookTokenId); +uuid_id!(WrapUpId); +uuid_id!(GoalId); diff --git a/crates/domain/src/value_objects/mod.rs b/crates/domain/src/value_objects/mod.rs new file mode 100644 index 0000000..006577c --- /dev/null +++ b/crates/domain/src/value_objects/mod.rs @@ -0,0 +1,13 @@ +mod ids; +mod movie; +mod review; +mod user; + +pub use ids::*; +pub use movie::*; +pub use review::*; +pub use user::*; + +#[cfg(test)] +#[path = "../tests/value_objects.rs"] +mod tests; diff --git a/crates/domain/src/value_objects/movie.rs b/crates/domain/src/value_objects/movie.rs new file mode 100644 index 0000000..ae2f0b4 --- /dev/null +++ b/crates/domain/src/value_objects/movie.rs @@ -0,0 +1,110 @@ +use crate::errors::DomainError; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ExternalMetadataId(String); + +impl ExternalMetadataId { + pub fn new(id: String) -> Result { + let trimmed = id.trim(); + if trimmed.is_empty() { + Err(DomainError::ValidationError( + "External metadata ID cannot be empty".into(), + )) + } else { + Ok(Self(trimmed.to_string())) + } + } + + pub fn value(&self) -> &str { + &self.0 + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PosterPath(String); + +impl PosterPath { + pub fn new(path: String) -> Result { + let trimmed = path.trim(); + if trimmed.is_empty() { + Err(DomainError::ValidationError( + "Poster path cannot be empty".into(), + )) + } else { + Ok(Self(trimmed.to_string())) + } + } + + pub fn value(&self) -> &str { + &self.0 + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PosterUrl(String); + +impl PosterUrl { + pub fn new(url: String) -> Result { + let trimmed = url.trim(); + if trimmed.is_empty() { + Err(DomainError::ValidationError( + "Poster URL cannot be empty".into(), + )) + } else { + Ok(Self(trimmed.to_string())) + } + } + + pub fn value(&self) -> &str { + &self.0 + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MovieTitle(String); + +impl MovieTitle { + const MAX_LENGTH: usize = 255; + + pub fn new(title: String) -> Result { + let trimmed = title.trim(); + if trimmed.is_empty() { + Err(DomainError::ValidationError( + "Movie title cannot be empty".into(), + )) + } else if trimmed.len() > Self::MAX_LENGTH { + Err(DomainError::ValidationError(format!( + "Movie title exceeds {} characters", + Self::MAX_LENGTH + ))) + } else { + Ok(Self(trimmed.to_string())) + } + } + + pub fn value(&self) -> &str { + &self.0 + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ReleaseYear(u16); + +impl ReleaseYear { + const EARLIEST: u16 = 1888; + + pub fn new(year: u16) -> Result { + if year < Self::EARLIEST { + Err(DomainError::ValidationError(format!( + "Release year cannot be earlier than {} (first film ever made)", + Self::EARLIEST + ))) + } else { + Ok(Self(year)) + } + } + + pub fn value(&self) -> u16 { + self.0 + } +} diff --git a/crates/domain/src/value_objects/review.rs b/crates/domain/src/value_objects/review.rs new file mode 100644 index 0000000..b7a0f8a --- /dev/null +++ b/crates/domain/src/value_objects/review.rs @@ -0,0 +1,46 @@ +use crate::errors::DomainError; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Rating(u8); + +impl Rating { + const MAX: u8 = 5; + + pub fn new(value: u8) -> Result { + if value <= Self::MAX { + Ok(Self(value)) + } else { + Err(DomainError::InvalidRating { + max: Self::MAX, + given: value, + }) + } + } + + pub fn value(&self) -> u8 { + self.0 + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Comment(String); + +impl Comment { + const MAX_LENGTH: usize = 10_000; + + pub fn new(comment: String) -> Result { + let trimmed = comment.trim(); + if trimmed.len() > Self::MAX_LENGTH { + Err(DomainError::ValidationError(format!( + "Comment exceeds {} characters", + Self::MAX_LENGTH + ))) + } else { + Ok(Self(trimmed.to_string())) + } + } + + pub fn value(&self) -> &str { + &self.0 + } +} diff --git a/crates/domain/src/value_objects/user.rs b/crates/domain/src/value_objects/user.rs new file mode 100644 index 0000000..6aa1a99 --- /dev/null +++ b/crates/domain/src/value_objects/user.rs @@ -0,0 +1,99 @@ +use crate::errors::DomainError; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Email(String); + +impl Email { + pub fn new(email: String) -> Result { + let trimmed = email.trim(); + if email_address::EmailAddress::is_valid(trimmed) { + Ok(Self(trimmed.to_string())) + } else { + Err(DomainError::ValidationError("Invalid email format".into())) + } + } + + pub fn value(&self) -> &str { + &self.0 + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Username(String); + +impl Username { + const MIN_LENGTH: usize = 2; + const MAX_LENGTH: usize = 30; + + /// Accepts 2–30 chars: lowercase letters, digits, underscores, hyphens. + /// Lowercases input automatically. + pub fn new(raw: String) -> Result { + let s = raw.trim().to_lowercase(); + if s.len() < Self::MIN_LENGTH || s.len() > Self::MAX_LENGTH { + return Err(DomainError::ValidationError(format!( + "Username must be {}–{} characters", + Self::MIN_LENGTH, + Self::MAX_LENGTH + ))); + } + if !s + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + { + return Err(DomainError::ValidationError( + "Username may only contain letters, digits, underscores, and hyphens".into(), + )); + } + Ok(Self(s)) + } + + pub fn value(&self) -> &str { + &self.0 + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PasswordHash(String); + +impl PasswordHash { + pub fn new(hash: String) -> Result { + if hash.is_empty() { + Err(DomainError::ValidationError( + "Password hash cannot be empty".into(), + )) + } else { + Ok(Self(hash)) + } + } + + pub fn value(&self) -> &str { + &self.0 + } +} + +#[derive(Clone, PartialEq, Eq)] +pub struct Password(String); + +impl std::fmt::Debug for Password { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("Password([REDACTED])") + } +} + +impl Password { + const MIN_LENGTH: usize = 8; + + pub fn new(raw: String) -> Result { + if raw.chars().count() < Self::MIN_LENGTH { + Err(DomainError::ValidationError( + "Password must be at least 8 characters".into(), + )) + } else { + Ok(Self(raw)) + } + } + + pub fn value(&self) -> &str { + &self.0 + } +} diff --git a/crates/presentation/src/handlers/users.rs b/crates/presentation/src/handlers/users.rs index 78f837c..ee8b2e2 100644 --- a/crates/presentation/src/handlers/users.rs +++ b/crates/presentation/src/handlers/users.rs @@ -291,7 +291,7 @@ pub async fn get_user_profile( view: profile_view, limit: params.limit, offset: params.offset, - sort_by: domain::ports::FeedSortBy::Date, + sort_by: domain::models::FeedSortBy::Date, search: params.search, is_own_profile: viewer_id.value() == user_id, }, diff --git a/crates/presentation/src/mappers/users.rs b/crates/presentation/src/mappers/users.rs index 0397247..cd1b124 100644 --- a/crates/presentation/src/mappers/users.rs +++ b/crates/presentation/src/mappers/users.rs @@ -1,7 +1,7 @@ use application::users::get_profile::PendingFollowerView; use chrono::Datelike; +use domain::models::RemoteActorInfo; use domain::models::{DiaryEntry, MonthActivity, UserSummary}; -use domain::ports::RemoteActorInfo; use template_askama::{RemoteActorData, RemoteActorDisplay, UserSummaryView}; pub fn user_summary_view(u: &UserSummary) -> UserSummaryView { diff --git a/crates/presentation/src/tests/extractors.rs b/crates/presentation/src/tests/extractors.rs index 060b343..d033fb0 100644 --- a/crates/presentation/src/tests/extractors.rs +++ b/crates/presentation/src/tests/extractors.rs @@ -11,16 +11,16 @@ use domain::{ errors::DomainError, events::DomainEvent, models::{ - DiaryEntry, DiaryFilter, EntityType, FeedEntry, IndexableDocument, Movie, Person, - PersonCredits, PersonEnrichmentData, PersonId, Review, ReviewHistory, SearchQuery, + DiaryEntry, DiaryFilter, EntityType, FeedEntry, GeneratedToken, IndexableDocument, Movie, + Person, PersonCredits, PersonEnrichmentData, PersonId, Review, ReviewHistory, SearchQuery, SearchResults, UserStats, UserTrends, collections::{PageParams, Paginated}, }, ports::{ - AuthService, DiaryRepository, EventPublisher, GeneratedToken, MetadataClient, - MovieRepository, ObjectStorage, PasswordHasher, PersonCommand, PersonQuery, - PosterFetcherClient, ReviewRepository, SearchCommand, SearchPort, StatsRepository, - UserRepository, WatchlistRepository, + AuthService, DiaryRepository, EventPublisher, MetadataClient, MovieRepository, + ObjectStorage, PasswordHasher, PersonCommand, PersonQuery, PosterFetcherClient, + ReviewRepository, SearchCommand, SearchPort, StatsRepository, UserRepository, + WatchlistRepository, }, value_objects::{ Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterUrl, ReleaseYear, @@ -108,9 +108,9 @@ impl DiaryRepository for Panic { async fn query_activity_feed_filtered( &self, _: &PageParams, - _: &domain::ports::FeedSortBy, + _: &domain::models::FeedSortBy, _: Option<&str>, - _: Option<&domain::ports::FollowingFilter>, + _: Option<&domain::models::FollowingFilter>, ) -> Result, DomainError> { panic!() } @@ -151,7 +151,7 @@ impl domain::ports::SocialQueryPort for Panic { } async fn list_all_followed_remote_actors( &self, - ) -> Result, DomainError> { + ) -> Result, DomainError> { panic!() } async fn count_following(&self, _: uuid::Uuid) -> Result { @@ -163,7 +163,7 @@ impl domain::ports::SocialQueryPort for Panic { async fn get_pending_followers( &self, _: uuid::Uuid, - ) -> Result, DomainError> { + ) -> Result, DomainError> { panic!() } } @@ -180,7 +180,7 @@ impl StatsRepository for Panic { impl MetadataClient for Panic { async fn fetch_movie_metadata( &self, - _: &domain::ports::MetadataSearchCriteria, + _: &domain::models::MetadataSearchCriteria, ) -> Result { panic!() } @@ -608,7 +608,7 @@ impl domain::ports::WrapUpStatsQuery for Panic { &self, _: &domain::models::wrapup::WrapUpScope, _: &domain::models::wrapup::DateRange, - ) -> Result, DomainError> { + ) -> Result, DomainError> { panic!() } } diff --git a/crates/presentation/tests/api_test.rs b/crates/presentation/tests/api_test.rs index d757585..5c57ada 100644 --- a/crates/presentation/tests/api_test.rs +++ b/crates/presentation/tests/api_test.rs @@ -11,13 +11,13 @@ use domain::{ errors::DomainError, events::DomainEvent, models::{ - EntityType, ExternalPersonId, IndexableDocument, Movie, Person, PersonCredits, - PersonEnrichmentData, PersonId, SearchQuery, SearchResults, User, + EntityType, ExternalPersonId, GeneratedToken, IndexableDocument, MetadataSearchCriteria, + Movie, Person, PersonCredits, PersonEnrichmentData, PersonId, SearchQuery, SearchResults, + User, }, ports::{ - AuthService, EventPublisher, GeneratedToken, MetadataClient, MetadataSearchCriteria, - ObjectStorage, PasswordHasher, PersonCommand, PersonQuery, PosterFetcherClient, - SearchCommand, SearchPort, UserRepository, + AuthService, EventPublisher, MetadataClient, ObjectStorage, PasswordHasher, PersonCommand, + PersonQuery, PosterFetcherClient, SearchCommand, SearchPort, UserRepository, }, value_objects::{Email, ExternalMetadataId, PasswordHash, PosterUrl, UserId}, }; @@ -410,7 +410,7 @@ impl domain::ports::SocialQueryPort for PanicSocialQuery { } async fn list_all_followed_remote_actors( &self, - ) -> Result, DomainError> { + ) -> Result, DomainError> { panic!() } async fn count_following(&self, _: uuid::Uuid) -> Result { @@ -422,7 +422,7 @@ impl domain::ports::SocialQueryPort for PanicSocialQuery { async fn get_pending_followers( &self, _: uuid::Uuid, - ) -> Result, DomainError> { + ) -> Result, DomainError> { panic!() } }