From 12da356a4037660aa7f380149f64ef530a08332b Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Fri, 10 Jul 2026 02:08:39 +0200 Subject: [PATCH] refactor: fix HIGH+MEDIUM architectural violations from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HIGH: fix watch_medium data-loss bug, standardize error handling on ApiError, fix dep direction (rss/template-askama no longer dep on application), extract ImageFetcher port (remove reqwest from app layer), move event construction from save_review to use case, extract infra-wiring crate (DbPool/EventBusBackend dedup), deduplicate presentation helpers (encode_error, export streaming, multipart parsing) MEDIUM: split LocalApContentQuery god-trait 10→3 methods, dedup movie resolution orchestration, add RemoteActorDto/PersonDto mappers, move AppConfig to infra-wiring, fix SocialQueryPort Uuid→UserId, replace stringly-typed api-types with domain enums, move count_reviews_in_year to StatsRepository, dedup event publisher cfg blocks, extract should_enrich, move group_by_month to application, dedup count_local_posts, add FederationFlags Default, TUI input helper + ShowError rename + typed auth errors, api-types cleanup (UserSettingsDto/UserProfileBase/PreviewRowData) 102 files changed, -681 lines net --- Cargo.lock | 16 +- Cargo.toml | 2 + Dockerfile | 1 + .../adapters/activitypub/src/event_handler.rs | 90 ++--- .../adapters/activitypub/src/goal_handler.rs | 8 +- crates/adapters/activitypub/src/lib.rs | 18 +- .../activitypub/src/review_handler.rs | 25 +- crates/adapters/poster-fetcher/src/lib.rs | 30 +- .../postgres-federation/src/ap_content.rs | 205 +---------- .../postgres-federation/src/social.rs | 17 +- crates/adapters/postgres/src/diary.rs | 6 +- crates/adapters/postgres/src/goals.rs | 4 - crates/adapters/postgres/src/review.rs | 11 +- crates/adapters/postgres/src/stats.rs | 4 + crates/adapters/rss/Cargo.toml | 1 - crates/adapters/rss/src/lib.rs | 2 +- .../sqlite-federation/src/ap_content.rs | 200 +--------- .../adapters/sqlite-federation/src/social.rs | 17 +- .../sqlite-federation/src/tests/lib.rs | 3 +- crates/adapters/sqlite/src/diary.rs | 2 +- crates/adapters/sqlite/src/goals.rs | 4 - crates/adapters/sqlite/src/review.rs | 11 +- crates/adapters/sqlite/src/stats.rs | 4 + crates/adapters/template-askama/Cargo.toml | 2 +- crates/adapters/template-askama/src/lib.rs | 2 +- crates/api-types/Cargo.toml | 1 + crates/api-types/src/auth.rs | 3 +- crates/api-types/src/diary.rs | 6 +- crates/api-types/src/goals.rs | 17 +- crates/api-types/src/import.rs | 31 +- crates/api-types/src/lib.rs | 2 + crates/api-types/src/movies.rs | 6 +- .../src/rendering.rs | 0 crates/api-types/src/users.rs | 42 ++- crates/application/Cargo.toml | 2 +- crates/application/src/auth/login.rs | 4 +- crates/application/src/config.rs | 51 +-- .../src/diary/get_activity_feed.rs | 4 +- crates/application/src/diary/review_logger.rs | 80 ++-- .../src/diary/tests/get_activity_feed.rs | 17 +- crates/application/src/goals/create.rs | 5 +- crates/application/src/goals/get.rs | 8 +- crates/application/src/goals/list.rs | 8 +- crates/application/src/goals/tests/create.rs | 23 +- crates/application/src/goals/tests/delete.rs | 4 +- crates/application/src/goals/tests/get.rs | 3 + crates/application/src/goals/tests/list.rs | 3 + crates/application/src/goals/tests/update.rs | 5 + crates/application/src/goals/update.rs | 5 +- crates/application/src/lib.rs | 1 - .../application/src/movies/event_handler.rs | 17 +- crates/application/src/movies/mod.rs | 1 + crates/application/src/movies/resolve.rs | 51 +++ crates/application/src/person/get.rs | 12 +- crates/application/src/person/get_credits.rs | 14 +- crates/application/src/person/mod.rs | 12 + crates/application/src/ports.rs | 5 - crates/application/src/test_helpers.rs | 2 +- .../src/users/get_current_profile.rs | 4 +- crates/application/src/users/get_profile.rs | 4 +- crates/application/src/users/mod.rs | 49 +++ crates/application/src/watchlist/add.rs | 42 +-- crates/domain/src/models/federation.rs | 10 + crates/domain/src/models/mod.rs | 3 +- crates/domain/src/models/user.rs | 3 +- crates/domain/src/ports/diary.rs | 4 +- crates/domain/src/ports/goals.rs | 1 - crates/domain/src/ports/image_fetcher.rs | 8 + crates/domain/src/ports/mod.rs | 4 + crates/domain/src/ports/rss.rs | 5 + crates/domain/src/ports/social.rs | 37 +- crates/domain/src/testing/fakes.rs | 24 +- crates/domain/src/testing/in_memory.rs | 25 +- crates/domain/src/testing/noops.rs | 20 +- crates/domain/src/testing/panics.rs | 20 +- crates/domain/src/value_objects/review.rs | 4 +- crates/infra-wiring/Cargo.toml | 14 + crates/infra-wiring/src/config.rs | 50 +++ crates/infra-wiring/src/lib.rs | 35 ++ crates/presentation/Cargo.toml | 7 +- crates/presentation/src/factory.rs | 7 +- crates/presentation/src/forms.rs | 3 +- crates/presentation/src/handlers/auth.rs | 2 +- crates/presentation/src/handlers/diary.rs | 100 +---- crates/presentation/src/handlers/goals.rs | 6 +- crates/presentation/src/handlers/helpers.rs | 139 ++++++- crates/presentation/src/handlers/import.rs | 343 ++++++------------ .../presentation/src/handlers/integrations.rs | 7 +- crates/presentation/src/handlers/movies.rs | 2 +- crates/presentation/src/handlers/search.rs | 42 +-- crates/presentation/src/handlers/social.rs | 271 ++++++-------- crates/presentation/src/handlers/users.rs | 235 ++++-------- crates/presentation/src/handlers/watchlist.rs | 7 +- crates/presentation/src/handlers/wrapup.rs | 2 +- crates/presentation/src/main.rs | 102 ++---- crates/presentation/src/mappers/mod.rs | 3 + crates/presentation/src/mappers/movies.rs | 2 +- crates/presentation/src/mappers/search.rs | 22 ++ crates/presentation/src/mappers/social.rs | 9 + crates/presentation/src/mappers/users.rs | 49 +-- crates/presentation/src/openapi/users.rs | 5 +- crates/presentation/src/state.rs | 2 +- crates/presentation/src/tests/extractors.rs | 22 +- crates/presentation/tests/api_test.rs | 17 +- crates/tui/src/app.rs | 124 +++---- crates/tui/src/main.rs | 21 +- crates/worker/Cargo.toml | 7 +- crates/worker/src/db.rs | 26 +- crates/worker/src/event_bus.rs | 26 +- crates/worker/src/main.rs | 160 ++++---- 110 files changed, 1399 insertions(+), 1867 deletions(-) rename crates/{application => api-types}/src/rendering.rs (100%) create mode 100644 crates/application/src/movies/resolve.rs create mode 100644 crates/domain/src/ports/image_fetcher.rs create mode 100644 crates/domain/src/ports/rss.rs create mode 100644 crates/infra-wiring/Cargo.toml create mode 100644 crates/infra-wiring/src/config.rs create mode 100644 crates/infra-wiring/src/lib.rs create mode 100644 crates/presentation/src/mappers/search.rs create mode 100644 crates/presentation/src/mappers/social.rs diff --git a/Cargo.lock b/Cargo.lock index ed0b4ba..b64c562 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -293,6 +293,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" name = "api-types" version = "0.1.0" dependencies = [ + "domain", "serde", "utoipa", "uuid", @@ -319,8 +320,8 @@ dependencies = [ "domain", "futures", "hex", + "infra-wiring", "rand 0.9.4", - "reqwest 0.13.3", "serde_json", "sha2", "tokio", @@ -2701,6 +2702,14 @@ dependencies = [ "cfb", ] +[[package]] +name = "infra-wiring" +version = "0.1.0" +dependencies = [ + "anyhow", + "sqlx", +] + [[package]] name = "inout" version = "0.1.4" @@ -3982,6 +3991,7 @@ dependencies = [ "http-body-util", "importer", "infer", + "infra-wiring", "jellyfin", "metadata", "nats", @@ -4587,7 +4597,6 @@ dependencies = [ name = "rss" version = "0.1.0" dependencies = [ - "application", "domain", "rss 2.0.13", ] @@ -5597,7 +5606,7 @@ dependencies = [ name = "template-askama" version = "0.1.0" dependencies = [ - "application", + "api-types", "askama", "chrono", "domain", @@ -7127,6 +7136,7 @@ dependencies = [ "export", "image-converter", "importer", + "infra-wiring", "metadata", "nats", "object-storage", diff --git a/Cargo.toml b/Cargo.toml index 7f1721d..19a0479 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ members = [ "crates/adapters/plex", "crates/adapters/sqlite-search", "crates/adapters/postgres-search", + "crates/infra-wiring", ] resolver = "2" @@ -91,6 +92,7 @@ plex = { path = "crates/adapters/plex" } image-converter = { path = "crates/adapters/image-converter" } sqlite-search = { path = "crates/adapters/sqlite-search" } postgres-search = { path = "crates/adapters/postgres-search" } +infra-wiring = { path = "crates/infra-wiring" } [profile.dev] debug = 1 # line tables only — still debuggable, much faster linking diff --git a/Dockerfile b/Dockerfile index 897e544..54fb9d8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -45,6 +45,7 @@ COPY crates/adapters/image-converter/Cargo.toml crates/adapters/image-converte COPY crates/adapters/sqlite-search/Cargo.toml crates/adapters/sqlite-search/Cargo.toml COPY crates/adapters/postgres-search/Cargo.toml crates/adapters/postgres-search/Cargo.toml COPY crates/worker/Cargo.toml crates/worker/Cargo.toml +COPY crates/infra-wiring/Cargo.toml crates/infra-wiring/Cargo.toml # Stub every crate so cargo can resolve and fetch deps RUN find crates -name "Cargo.toml" | sed 's|/Cargo.toml||' | \ diff --git a/crates/adapters/activitypub/src/event_handler.rs b/crates/adapters/activitypub/src/event_handler.rs index 41e0bea..88ee00c 100644 --- a/crates/adapters/activitypub/src/event_handler.rs +++ b/crates/adapters/activitypub/src/event_handler.rs @@ -4,7 +4,10 @@ use domain::ports::EventHandler; use domain::{ errors::DomainError, events::DomainEvent, - ports::{LocalApContentQuery, UserFederationSettingsQuery}, + ports::{ + GoalRepository, LocalApContentQuery, MovieRepository, ReviewRepository, StatsRepository, + UserFederationSettingsQuery, + }, value_objects::{MovieId, ReviewId, UserId}, }; use std::sync::Arc; @@ -17,20 +20,33 @@ use crate::urls::{actor_url, goal_url, review_url}; pub struct ActivityPubEventHandler { ap_service: Arc, content_query: Arc, + review_repo: Arc, + movie_repo: Arc, + goal_repo: Arc, + stats_repo: Arc, federation_settings: Arc, base_url: String, } impl ActivityPubEventHandler { + #[allow(clippy::too_many_arguments)] pub fn new( ap_service: Arc, content_query: Arc, + review_repo: Arc, + movie_repo: Arc, + goal_repo: Arc, + stats_repo: Arc, federation_settings: Arc, base_url: String, ) -> Self { Self { ap_service, content_query, + review_repo, + movie_repo, + goal_repo, + stats_repo, federation_settings, base_url, } @@ -157,16 +173,12 @@ impl ActivityPubEventHandler { .federation_settings .get_federation_flags(user_id) .await - .unwrap_or(domain::models::FederationFlags { - goals: true, - reviews: true, - watchlist: true, - }); + .unwrap_or_default(); if !flags.reviews { return Ok(()); } - let review = match self.content_query.get_review_by_id(review_id).await? { + let review = match self.review_repo.get_review_by_id(review_id).await? { Some(r) => r, None => return Ok(()), }; @@ -175,7 +187,7 @@ impl ActivityPubEventHandler { let actor = actor_url(&self.base_url, user_id.value()); let movie = self - .content_query + .movie_repo .get_movie_by_id(review.movie_id()) .await .ok() @@ -227,16 +239,12 @@ impl ActivityPubEventHandler { .federation_settings .get_federation_flags(user_id) .await - .unwrap_or(domain::models::FederationFlags { - goals: true, - reviews: true, - watchlist: true, - }); + .unwrap_or_default(); if !flags.reviews { return Ok(()); } - let review = match self.content_query.get_review_by_id(review_id).await? { + let review = match self.review_repo.get_review_by_id(review_id).await? { Some(r) => r, None => return Ok(()), }; @@ -245,7 +253,7 @@ impl ActivityPubEventHandler { let actor = actor_url(&self.base_url, user_id.value()); let movie = self - .content_query + .movie_repo .get_movie_by_id(review.movie_id()) .await .ok() @@ -310,11 +318,7 @@ impl ActivityPubEventHandler { .federation_settings .get_federation_flags(user_id) .await - .unwrap_or(domain::models::FederationFlags { - goals: true, - reviews: true, - watchlist: true, - }); + .unwrap_or_default(); if !flags.watchlist { return Ok(()); } @@ -324,7 +328,7 @@ impl ActivityPubEventHandler { let actor = actor_url(&self.base_url, user_id.value()); let poster_url = self - .content_query + .movie_repo .get_movie_by_id(movie_id) .await .ok() @@ -373,7 +377,7 @@ impl ActivityPubEventHandler { .get_local_reviews_for_movie(movie_id) .await?; - let movie = self.content_query.get_movie_by_id(movie_id).await?; + let movie = self.movie_repo.get_movie_by_id(movie_id).await?; let movie = match movie { Some(m) => m, None => return Ok(()), @@ -393,11 +397,7 @@ impl ActivityPubEventHandler { .federation_settings .get_federation_flags(user_id) .await - .unwrap_or(domain::models::FederationFlags { - goals: true, - reviews: true, - watchlist: true, - }); + .unwrap_or_default(); if !flags.reviews { continue; } @@ -436,23 +436,24 @@ impl ActivityPubEventHandler { .federation_settings .get_federation_flags(user_id) .await - .unwrap_or(domain::models::FederationFlags { - goals: true, - reviews: true, - watchlist: true, - }); + .unwrap_or_default(); if !flags.goals { return Ok(()); } - let Some((goal, current)) = self - .content_query - .get_goal_with_progress(user_id, year) + let Some(goal) = self + .goal_repo + .find_by_user_and_year(user_id, year) .await .ok() .flatten() else { return Ok(()); }; + let current = self + .stats_repo + .count_reviews_in_year(user_id, year) + .await + .unwrap_or(0); let ap_id = goal_url(&self.base_url, user_id.value(), year); let actor = actor_url(&self.base_url, user_id.value()); let obj = goal_to_ap_object( @@ -481,21 +482,14 @@ impl ActivityPubEventHandler { .federation_settings .get_federation_flags(user_id) .await - .unwrap_or(domain::models::FederationFlags { - goals: true, - reviews: true, - watchlist: true, - }); + .unwrap_or_default(); if !flags.goals { return Ok(()); } let current = self - .content_query - .get_goal_with_progress(user_id, year) + .stats_repo + .count_reviews_in_year(user_id, year) .await - .ok() - .flatten() - .map(|(_, c)| c) .unwrap_or(0); let ap_id = goal_url(&self.base_url, user_id.value(), year); @@ -519,11 +513,7 @@ impl ActivityPubEventHandler { .federation_settings .get_federation_flags(user_id) .await - .unwrap_or(domain::models::FederationFlags { - goals: true, - reviews: true, - watchlist: true, - }); + .unwrap_or_default(); if !flags.goals { return Ok(()); } diff --git a/crates/adapters/activitypub/src/goal_handler.rs b/crates/adapters/activitypub/src/goal_handler.rs index dc2b7f8..9d85c5d 100644 --- a/crates/adapters/activitypub/src/goal_handler.rs +++ b/crates/adapters/activitypub/src/goal_handler.rs @@ -4,7 +4,7 @@ use async_trait::async_trait; use chrono::DateTime; use domain::{ models::RemoteGoalEntry, - ports::{LocalApContentQuery, RemoteGoalRepository}, + ports::{GoalRepository, RemoteGoalRepository}, value_objects::UserId, }; use k_ap::{ApContentReader, ApObjectHandler}; @@ -15,7 +15,7 @@ use crate::urls::{actor_url, goal_url}; pub struct GoalObjectHandler { pub remote_goal_repo: Arc, - pub content_query: Arc, + pub goal_repo: Arc, pub base_url: String, } @@ -29,8 +29,8 @@ impl ApContentReader for GoalObjectHandler { ) -> anyhow::Result)>> { let uid = UserId::from_uuid(user_id); let goals = self - .content_query - .list_goals_for_user(&uid) + .goal_repo + .list_for_user(&uid) .await .map_err(|e| anyhow::anyhow!(e.to_string()))?; diff --git a/crates/adapters/activitypub/src/lib.rs b/crates/adapters/activitypub/src/lib.rs index 197ae35..0d528a0 100644 --- a/crates/adapters/activitypub/src/lib.rs +++ b/crates/adapters/activitypub/src/lib.rs @@ -51,6 +51,11 @@ pub struct ActivityPubDeps { pub remote_watchlist_repo: std::sync::Arc, pub remote_goal_repo: std::sync::Arc, pub local_ap_content: std::sync::Arc, + pub movie_repo: std::sync::Arc, + pub review_repo: std::sync::Arc, + pub diary_repo: std::sync::Arc, + pub goal_repo: std::sync::Arc, + pub stats_repo: std::sync::Arc, pub user_repo: std::sync::Arc, pub federation_settings: std::sync::Arc, pub base_url: String, @@ -68,6 +73,11 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result { remote_watchlist_repo, remote_goal_repo, local_ap_content, + movie_repo, + review_repo, + diary_repo, + goal_repo, + stats_repo, user_repo, federation_settings, base_url, @@ -76,6 +86,8 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result { } = deps; let review_handler = std::sync::Arc::new(ReviewObjectHandler { content_query: std::sync::Arc::clone(&local_ap_content), + movie_repo: std::sync::Arc::clone(&movie_repo), + diary_repo, review_store, event_publisher: std::sync::Arc::clone(&event_publisher), base_url: base_url.clone(), @@ -87,7 +99,7 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result { }); let goal_handler = std::sync::Arc::new(goal_handler::GoalObjectHandler { remote_goal_repo, - content_query: std::sync::Arc::clone(&local_ap_content), + goal_repo: std::sync::Arc::clone(&goal_repo), base_url: base_url.clone(), }); let composite = std::sync::Arc::new(composite_handler::CompositeObjectHandler { @@ -136,6 +148,10 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result { let event_handler = std::sync::Arc::new(ActivityPubEventHandler::new( std::sync::Arc::clone(&concrete), local_ap_content, + review_repo, + movie_repo, + goal_repo, + stats_repo, federation_settings, base_url, )) as std::sync::Arc; diff --git a/crates/adapters/activitypub/src/review_handler.rs b/crates/adapters/activitypub/src/review_handler.rs index 9960850..b85c509 100644 --- a/crates/adapters/activitypub/src/review_handler.rs +++ b/crates/adapters/activitypub/src/review_handler.rs @@ -4,7 +4,7 @@ use async_trait::async_trait; use domain::{ events::DomainEvent, models::ReviewSource, - ports::{EventPublisher, LocalApContentQuery}, + ports::{DiaryRepository, EventPublisher, LocalApContentQuery, MovieRepository}, value_objects::{Comment, ExternalMetadataId, MovieId, Rating, ReviewId, UserId}, }; use k_ap::{ApContentReader, ApObjectHandler}; @@ -16,6 +16,8 @@ use crate::urls::{actor_url, review_url}; pub struct ReviewObjectHandler { pub content_query: Arc, + pub movie_repo: Arc, + pub diary_repo: Arc, pub review_store: Arc, pub event_publisher: Arc, pub base_url: String, @@ -69,7 +71,7 @@ impl ApContentReader for ReviewObjectHandler { } async fn count_local_posts(&self) -> anyhow::Result { - self.content_query + self.diary_repo .count_local_posts() .await .map_err(|e| anyhow::anyhow!(e.to_string())) @@ -97,13 +99,18 @@ impl ApObjectHandler for ReviewObjectHandler { let actor_url_str = obj.attributed_to.to_string(); let review_id = ReviewId::generate(); let movie_id = if let Some(ref ext_id) = obj.external_metadata_id { - match self - .content_query - .get_movie_by_external_metadata_id(ext_id) - .await - { - Ok(Some(movie)) => movie.id().clone(), - _ => MovieId::from_uuid(uuid::Uuid::new_v5( + let found = if let Ok(ext_meta_id) = ExternalMetadataId::new(ext_id.clone()) { + self.movie_repo + .get_movie_by_external_id(&ext_meta_id) + .await + .ok() + .flatten() + } else { + None + }; + match found { + Some(movie) => movie.id().clone(), + None => MovieId::from_uuid(uuid::Uuid::new_v5( &uuid::Uuid::NAMESPACE_URL, ext_id.as_bytes(), )), diff --git a/crates/adapters/poster-fetcher/src/lib.rs b/crates/adapters/poster-fetcher/src/lib.rs index b0a1c2d..187e4d7 100644 --- a/crates/adapters/poster-fetcher/src/lib.rs +++ b/crates/adapters/poster-fetcher/src/lib.rs @@ -4,7 +4,11 @@ pub use config::PosterFetcherConfig; use std::time::Duration; use async_trait::async_trait; -use domain::{errors::DomainError, ports::PosterFetcherClient, value_objects::PosterUrl}; +use domain::{ + errors::DomainError, + ports::{ImageFetcher, PosterFetcherClient}, + value_objects::PosterUrl, +}; pub struct ReqwestPosterFetcher { client: reqwest::Client, @@ -37,8 +41,32 @@ impl PosterFetcherClient for ReqwestPosterFetcher { } } +#[async_trait] +impl ImageFetcher for ReqwestPosterFetcher { + async fn fetch_image(&self, url: &str) -> Result, DomainError> { + let bytes = self + .client + .get(url) + .send() + .await + .map_err(|e| DomainError::InfrastructureError(e.to_string()))? + .error_for_status() + .map_err(|e| DomainError::InfrastructureError(e.to_string()))? + .bytes() + .await + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + Ok(bytes.to_vec()) + } +} + pub fn create() -> anyhow::Result> { Ok(std::sync::Arc::new(ReqwestPosterFetcher::new( PosterFetcherConfig::from_env(), )?)) } + +pub fn create_image_fetcher() -> anyhow::Result> { + Ok(std::sync::Arc::new(ReqwestPosterFetcher::new( + PosterFetcherConfig::from_env(), + )?)) +} diff --git a/crates/adapters/postgres-federation/src/ap_content.rs b/crates/adapters/postgres-federation/src/ap_content.rs index 2170e98..8e35e18 100644 --- a/crates/adapters/postgres-federation/src/ap_content.rs +++ b/crates/adapters/postgres-federation/src/ap_content.rs @@ -2,12 +2,12 @@ use async_trait::async_trait; use domain::{ errors::DomainError, models::{ - DiaryEntry, Goal, GoalType, Movie, PersistedReview, Review, ReviewSource, WatchlistEntry, + DiaryEntry, Movie, PersistedReview, Review, ReviewSource, WatchlistEntry, WatchlistWithMovie, }, ports::LocalApContentQuery, value_objects::{ - Comment, ExternalMetadataId, GoalId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear, + Comment, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear, ReviewId, UserId, WatchlistEntryId, }, }; @@ -82,6 +82,7 @@ struct ReviewRow { watched_at: String, created_at: String, remote_actor_url: Option, + watch_medium: Option, } impl ReviewRow { @@ -97,6 +98,7 @@ impl ReviewRow { None => ReviewSource::Local, Some(url) => ReviewSource::Remote { actor_url: url }, }; + let watch_medium = self.watch_medium.map(|s| s.parse()).transpose()?; Ok(Review::from_persistence(PersistedReview { id, movie_id, @@ -106,7 +108,7 @@ impl ReviewRow { watched_at, created_at, source, - watch_medium: None, + watch_medium, })) } } @@ -127,6 +129,7 @@ struct DiaryRow { watched_at: String, created_at: String, remote_actor_url: Option, + watch_medium: Option, } impl DiaryRow { @@ -149,105 +152,17 @@ impl DiaryRow { watched_at: self.watched_at, created_at: self.created_at, remote_actor_url: self.remote_actor_url, + watch_medium: self.watch_medium, } .into_domain()?; Ok(DiaryEntry::new(movie, review)) } } -fn row_to_goal(r: &sqlx::postgres::PgRow) -> Result { - let id_str: String = r - .try_get("id") - .map_err(|e| DomainError::InfrastructureError(format!("Failed to read goal id: {e}")))?; - let user_id_str: String = r - .try_get("user_id") - .map_err(|e| DomainError::InfrastructureError(format!("Failed to read user_id: {e}")))?; - let year: i64 = r - .try_get("year") - .map_err(|e| DomainError::InfrastructureError(format!("Failed to read year: {e}")))?; - let target: i64 = r.try_get("target_count").map_err(|e| { - DomainError::InfrastructureError(format!("Failed to read target_count: {e}")) - })?; - let goal_type_str: String = r - .try_get("goal_type") - .map_err(|e| DomainError::InfrastructureError(format!("Failed to read goal_type: {e}")))?; - let created_at_str: String = r - .try_get("created_at") - .map_err(|e| DomainError::InfrastructureError(format!("Failed to read created_at: {e}")))?; - - let id = GoalId::from_uuid(parse_uuid(&id_str)?); - let user_id = UserId::from_uuid(parse_uuid(&user_id_str)?); - let goal_type: GoalType = goal_type_str.parse()?; - let created_at = parse_datetime(&created_at_str)?; - - Ok(Goal::from_persistence( - id, - user_id, - year as u16, - target as u32, - goal_type, - created_at, - )) -} - -async fn count_reviews_in_year( - pool: &PgPool, - user_id: &UserId, - year: u16, -) -> Result { - let uid = user_id.value().to_string(); - let start = format!("{year}-01-01 00:00:00"); - let end = format!("{}-01-01 00:00:00", year + 1); - - let count: i64 = sqlx::query( - "SELECT COUNT(*) FROM reviews \ - WHERE user_id = $1 \ - AND watched_at >= $2::timestamptz \ - AND watched_at < $3::timestamptz \ - AND remote_actor_url IS NULL", - ) - .bind(&uid) - .bind(&start) - .bind(&end) - .fetch_one(pool) - .await - .map_err(|e| { - tracing::error!("Database error: {:?}", e); - DomainError::InfrastructureError("Database operation failed".into()) - })? - .try_get(0) - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - - Ok(count as u32) -} - // ── LocalApContentQuery impl ───────────────────────────────────────────────── #[async_trait] impl LocalApContentQuery for PostgresApContentQuery { - async fn get_local_reviews_for_user( - &self, - user_id: &UserId, - ) -> Result, DomainError> { - let uid = user_id.value().to_string(); - let rows = sqlx::query_as::<_, DiaryRow>( - "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, - r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, - to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, - to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, - r.remote_actor_url - FROM reviews r - INNER JOIN movies m ON m.id = r.movie_id - WHERE r.user_id = $1 AND r.remote_actor_url IS NULL - ORDER BY r.created_at DESC", - ) - .bind(&uid) - .fetch_all(&self.pool) - .await - .map_err(Self::map_err)?; - rows.into_iter().map(DiaryRow::into_domain).collect() - } - async fn get_local_watchlist_for_user( &self, user_id: &UserId, @@ -324,7 +239,8 @@ impl LocalApContentQuery for PostgresApContentQuery { r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, - r.remote_actor_url + r.remote_actor_url, + r.watch_medium FROM reviews r INNER JOIN movies m ON m.id = r.movie_id WHERE r.movie_id = $1 AND r.remote_actor_url IS NULL @@ -337,62 +253,6 @@ impl LocalApContentQuery for PostgresApContentQuery { rows.into_iter().map(DiaryRow::into_domain).collect() } - async fn get_review_by_id(&self, review_id: &ReviewId) -> Result, DomainError> { - let id = review_id.value().to_string(); - sqlx::query_as::<_, ReviewRow>( - "SELECT id, movie_id, user_id, rating, comment, - to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, - to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, - remote_actor_url - FROM reviews WHERE id = $1", - ) - .bind(&id) - .fetch_optional(&self.pool) - .await - .map_err(Self::map_err)? - .map(ReviewRow::into_domain) - .transpose() - } - - async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result, DomainError> { - let id = movie_id.value().to_string(); - sqlx::query_as::<_, MovieRow>( - "SELECT id, external_metadata_id, title, release_year, director, poster_path - FROM movies WHERE id = $1", - ) - .bind(&id) - .fetch_optional(&self.pool) - .await - .map_err(Self::map_err)? - .map(MovieRow::into_domain) - .transpose() - } - - async fn get_movie_by_external_metadata_id( - &self, - external_id: &str, - ) -> Result, DomainError> { - sqlx::query_as::<_, MovieRow>( - "SELECT id, external_metadata_id, title, release_year, director, poster_path - FROM movies WHERE external_metadata_id = $1", - ) - .bind(external_id) - .fetch_optional(&self.pool) - .await - .map_err(Self::map_err)? - .map(MovieRow::into_domain) - .transpose() - } - - async fn count_local_posts(&self) -> Result { - let count: i64 = - sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE remote_actor_url IS NULL") - .fetch_one(&self.pool) - .await - .map_err(Self::map_err)?; - Ok(count as u64) - } - async fn get_local_reviews_page( &self, user_id: &UserId, @@ -409,7 +269,8 @@ impl LocalApContentQuery for PostgresApContentQuery { r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, - r.remote_actor_url + r.remote_actor_url, + r.watch_medium FROM reviews r INNER JOIN movies m ON m.id = r.movie_id WHERE r.user_id = $1 AND r.remote_actor_url IS NULL AND r.watched_at < $2::timestamptz @@ -428,7 +289,8 @@ impl LocalApContentQuery for PostgresApContentQuery { r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, - r.remote_actor_url + r.remote_actor_url, + r.watch_medium FROM reviews r INNER JOIN movies m ON m.id = r.movie_id WHERE r.user_id = $1 AND r.remote_actor_url IS NULL @@ -443,45 +305,4 @@ impl LocalApContentQuery for PostgresApContentQuery { }; rows.into_iter().map(DiaryRow::into_domain).collect() } - - async fn get_goal_with_progress( - &self, - user_id: &UserId, - year: u16, - ) -> Result, DomainError> { - let uid = user_id.value().to_string(); - let y = year as i64; - - let row = sqlx::query( - "SELECT id, user_id, year, target_count, goal_type, \ - to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at \ - FROM goals WHERE user_id = $1 AND year = $2", - ) - .bind(&uid) - .bind(y) - .fetch_optional(&self.pool) - .await - .map_err(Self::map_err)?; - - let Some(r) = row else { return Ok(None) }; - - let goal = row_to_goal(&r)?; - let count = count_reviews_in_year(&self.pool, user_id, year).await?; - - Ok(Some((goal, count))) - } - - async fn list_goals_for_user(&self, user_id: &UserId) -> Result, DomainError> { - let uid = user_id.value().to_string(); - let rows = sqlx::query( - "SELECT id, user_id, year, target_count, goal_type, \ - to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at \ - FROM goals WHERE user_id = $1 ORDER BY created_at DESC", - ) - .bind(&uid) - .fetch_all(&self.pool) - .await - .map_err(Self::map_err)?; - rows.iter().map(row_to_goal).collect() - } } diff --git a/crates/adapters/postgres-federation/src/social.rs b/crates/adapters/postgres-federation/src/social.rs index 82ff877..28c60f5 100644 --- a/crates/adapters/postgres-federation/src/social.rs +++ b/crates/adapters/postgres-federation/src/social.rs @@ -3,6 +3,7 @@ use domain::{ errors::DomainError, models::{PendingFollowerInfo, RemoteActorInfo}, ports::SocialQueryPort, + value_objects::UserId, }; use super::PostgresFederationRepository; @@ -11,9 +12,9 @@ use super::PostgresFederationRepository; impl SocialQueryPort for PostgresFederationRepository { async fn get_accepted_following_urls( &self, - user_id: uuid::Uuid, + user_id: &UserId, ) -> Result, DomainError> { - let user_id_str = user_id.to_string(); + let user_id_str = user_id.value().to_string(); sqlx::query_scalar::<_, String>( "SELECT remote_actor_url FROM ap_following WHERE local_user_id = $1 AND status = 'accepted'", ).bind(&user_id_str).fetch_all(&self.pool).await @@ -34,8 +35,8 @@ impl SocialQueryPort for PostgresFederationRepository { .collect()) } - async fn count_following(&self, user_id: uuid::Uuid) -> Result { - let uid = user_id.to_string(); + async fn count_following(&self, user_id: &UserId) -> Result { + let uid = user_id.value().to_string(); let count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM ap_following WHERE local_user_id = $1 AND status = 'accepted'", ) @@ -46,8 +47,8 @@ impl SocialQueryPort for PostgresFederationRepository { Ok(count as usize) } - async fn count_accepted_followers(&self, user_id: uuid::Uuid) -> Result { - let uid = user_id.to_string(); + async fn count_accepted_followers(&self, user_id: &UserId) -> Result { + let uid = user_id.value().to_string(); let count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM ap_followers WHERE local_user_id = $1 AND status = 'accepted'", ) @@ -60,9 +61,9 @@ impl SocialQueryPort for PostgresFederationRepository { async fn get_pending_followers( &self, - user_id: uuid::Uuid, + user_id: &UserId, ) -> Result, DomainError> { - let uid = user_id.to_string(); + let uid = user_id.value().to_string(); let rows = sqlx::query_as::<_, (String, String, Option, Option)>( "SELECT ar.url, ar.handle, ar.display_name, ar.avatar_url FROM ap_followers f JOIN ap_remote_actors ar ON ar.url = f.remote_actor_url WHERE f.local_user_id = $1 AND f.status = 'pending'", ).bind(&uid).fetch_all(&self.pool).await.map_err(|e| DomainError::InfrastructureError(e.to_string()))?; diff --git a/crates/adapters/postgres/src/diary.rs b/crates/adapters/postgres/src/diary.rs index a949813..fceeadf 100644 --- a/crates/adapters/postgres/src/diary.rs +++ b/crates/adapters/postgres/src/diary.rs @@ -416,7 +416,8 @@ impl DiaryRepository for PostgresDiaryRepository { "SELECT id, movie_id, user_id, rating, comment, to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, - remote_actor_url + remote_actor_url, + watch_medium FROM reviews WHERE movie_id = $1 ORDER BY watched_at ASC", ) .bind(&id_str) @@ -464,7 +465,8 @@ impl DiaryRepository for PostgresDiaryRepository { r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, - r.remote_actor_url + r.remote_actor_url, + r.watch_medium FROM reviews r INNER JOIN movies m ON m.id = r.movie_id WHERE r.user_id = $1 diff --git a/crates/adapters/postgres/src/goals.rs b/crates/adapters/postgres/src/goals.rs index 853c271..8ad4fde 100644 --- a/crates/adapters/postgres/src/goals.rs +++ b/crates/adapters/postgres/src/goals.rs @@ -122,10 +122,6 @@ impl GoalRepository for PostgresGoalRepository { rows.iter().map(row_to_goal).collect() } - - async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result { - count_reviews_in_year(&self.pool, user_id, year).await - } } pub(crate) async fn count_reviews_in_year( diff --git a/crates/adapters/postgres/src/review.rs b/crates/adapters/postgres/src/review.rs index d592898..a644b73 100644 --- a/crates/adapters/postgres/src/review.rs +++ b/crates/adapters/postgres/src/review.rs @@ -1,7 +1,6 @@ use async_trait::async_trait; use domain::{ errors::DomainError, - events::DomainEvent, models::{Review, ReviewSource}, ports::ReviewRepository, value_objects::{ReviewId, UserId}, @@ -27,7 +26,7 @@ impl PostgresReviewRepository { #[async_trait] impl ReviewRepository for PostgresReviewRepository { - async fn save_review(&self, review: &Review) -> Result { + async fn save_review(&self, review: &Review) -> Result<(), DomainError> { let id = review.id().value().to_string(); let movie_id = review.movie_id().value().to_string(); let user_id = review.user_id().value().to_string(); @@ -57,13 +56,7 @@ impl ReviewRepository for PostgresReviewRepository { .await .map_err(Self::map_err)?; - Ok(DomainEvent::ReviewLogged { - review_id: review.id().clone(), - movie_id: review.movie_id().clone(), - user_id: review.user_id().clone(), - rating: review.rating().clone(), - watched_at: *review.watched_at(), - }) + Ok(()) } async fn get_review_by_id(&self, review_id: &ReviewId) -> Result, DomainError> { diff --git a/crates/adapters/postgres/src/stats.rs b/crates/adapters/postgres/src/stats.rs index 721780f..0444c4c 100644 --- a/crates/adapters/postgres/src/stats.rs +++ b/crates/adapters/postgres/src/stats.rs @@ -95,6 +95,10 @@ impl StatsRepository for PostgresStatsRepository { }) } + async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result { + crate::goals::count_reviews_in_year(&self.pool, user_id, year).await + } + async fn get_user_trends(&self, user_id: &UserId) -> Result { let uid = user_id.value().to_string(); diff --git a/crates/adapters/rss/Cargo.toml b/crates/adapters/rss/Cargo.toml index 18502c4..bde6130 100644 --- a/crates/adapters/rss/Cargo.toml +++ b/crates/adapters/rss/Cargo.toml @@ -6,4 +6,3 @@ edition = "2024" [dependencies] rss-feed = { package = "rss", version = "2" } domain = { workspace = true } -application = { workspace = true } diff --git a/crates/adapters/rss/src/lib.rs b/crates/adapters/rss/src/lib.rs index 6eb1aef..caff37f 100644 --- a/crates/adapters/rss/src/lib.rs +++ b/crates/adapters/rss/src/lib.rs @@ -1,5 +1,5 @@ -use application::ports::RssFeedRenderer; use domain::models::DiaryEntry; +use domain::ports::RssFeedRenderer; use rss_feed::{ChannelBuilder, GuidBuilder, ItemBuilder}; pub struct RssAdapter { diff --git a/crates/adapters/sqlite-federation/src/ap_content.rs b/crates/adapters/sqlite-federation/src/ap_content.rs index f498b5a..aa47863 100644 --- a/crates/adapters/sqlite-federation/src/ap_content.rs +++ b/crates/adapters/sqlite-federation/src/ap_content.rs @@ -2,16 +2,16 @@ use async_trait::async_trait; use domain::{ errors::DomainError, models::{ - DiaryEntry, Goal, GoalType, Movie, PersistedReview, Review, ReviewSource, WatchlistEntry, + DiaryEntry, Movie, PersistedReview, Review, ReviewSource, WatchlistEntry, WatchlistWithMovie, }, ports::LocalApContentQuery, value_objects::{ - Comment, ExternalMetadataId, GoalId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear, + Comment, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear, ReviewId, UserId, WatchlistEntryId, }, }; -use sqlx::{Row, SqlitePool}; +use sqlx::SqlitePool; use uuid::Uuid; pub struct SqliteApContentQuery { @@ -82,6 +82,7 @@ struct ReviewRow { watched_at: String, created_at: String, remote_actor_url: Option, + watch_medium: Option, } impl ReviewRow { @@ -97,6 +98,7 @@ impl ReviewRow { None => ReviewSource::Local, Some(url) => ReviewSource::Remote { actor_url: url }, }; + let watch_medium = self.watch_medium.map(|s| s.parse()).transpose()?; Ok(Review::from_persistence(PersistedReview { id, movie_id, @@ -106,7 +108,7 @@ impl ReviewRow { watched_at, created_at, source, - watch_medium: None, + watch_medium, })) } } @@ -127,6 +129,7 @@ struct DiaryRow { watched_at: String, created_at: String, remote_actor_url: Option, + watch_medium: Option, } impl DiaryRow { @@ -149,6 +152,7 @@ impl DiaryRow { watched_at: self.watched_at, created_at: self.created_at, remote_actor_url: self.remote_actor_url, + watch_medium: self.watch_medium, } .into_domain()?; Ok(DiaryEntry::new(movie, review)) @@ -190,100 +194,10 @@ impl WatchlistRow { } } -fn row_to_goal(r: &sqlx::sqlite::SqliteRow) -> Result { - let id_str: String = r - .try_get("id") - .map_err(|e| DomainError::InfrastructureError(format!("Failed to read goal id: {e}")))?; - let user_id_str: String = r - .try_get("user_id") - .map_err(|e| DomainError::InfrastructureError(format!("Failed to read user_id: {e}")))?; - let year: i64 = r - .try_get("year") - .map_err(|e| DomainError::InfrastructureError(format!("Failed to read year: {e}")))?; - let target: i64 = r.try_get("target_count").map_err(|e| { - DomainError::InfrastructureError(format!("Failed to read target_count: {e}")) - })?; - let goal_type_str: String = r - .try_get("goal_type") - .map_err(|e| DomainError::InfrastructureError(format!("Failed to read goal_type: {e}")))?; - let created_at_str: String = r - .try_get("created_at") - .map_err(|e| DomainError::InfrastructureError(format!("Failed to read created_at: {e}")))?; - - let id = GoalId::from_uuid( - Uuid::parse_str(&id_str) - .map_err(|e| DomainError::InfrastructureError(format!("Invalid goal UUID: {e}")))?, - ); - let user_id = UserId::from_uuid( - Uuid::parse_str(&user_id_str) - .map_err(|e| DomainError::InfrastructureError(format!("Invalid user UUID: {e}")))?, - ); - let goal_type: GoalType = goal_type_str.parse()?; - let created_at = parse_datetime(&created_at_str)?; - - Ok(Goal::from_persistence( - id, - user_id, - year as u16, - target as u32, - goal_type, - created_at, - )) -} - -async fn count_reviews_in_year( - pool: &SqlitePool, - user_id: &UserId, - year: u16, -) -> Result { - let uid = user_id.value().to_string(); - let start = format!("{year}-01-01 00:00:00"); - let end = format!("{}-01-01 00:00:00", year + 1); - - let count: i64 = sqlx::query( - "SELECT COUNT(*) FROM reviews \ - WHERE user_id = ? AND watched_at >= ? AND watched_at < ? \ - AND remote_actor_url IS NULL", - ) - .bind(&uid) - .bind(&start) - .bind(&end) - .fetch_one(pool) - .await - .map_err(|e| { - tracing::error!("Database error: {:?}", e); - DomainError::InfrastructureError("Database operation failed".into()) - })? - .try_get(0) - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - - Ok(count as u32) -} - // ── LocalApContentQuery impl ───────────────────────────────────────────────── #[async_trait] impl LocalApContentQuery for SqliteApContentQuery { - async fn get_local_reviews_for_user( - &self, - user_id: &UserId, - ) -> Result, DomainError> { - let uid = user_id.value().to_string(); - let rows = sqlx::query_as::<_, DiaryRow>( - "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, - r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url - FROM reviews r - INNER JOIN movies m ON m.id = r.movie_id - WHERE r.user_id = ? AND r.remote_actor_url IS NULL - ORDER BY r.created_at DESC", - ) - .bind(&uid) - .fetch_all(&self.pool) - .await - .map_err(Self::map_err)?; - rows.into_iter().map(DiaryRow::into_domain).collect() - } - async fn get_local_watchlist_for_user( &self, user_id: &UserId, @@ -312,7 +226,7 @@ impl LocalApContentQuery for SqliteApContentQuery { let mid = movie_id.value().to_string(); let rows = sqlx::query_as::<_, DiaryRow>( "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, - r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url + r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium FROM reviews r INNER JOIN movies m ON m.id = r.movie_id WHERE r.movie_id = ? AND r.remote_actor_url IS NULL @@ -325,59 +239,6 @@ impl LocalApContentQuery for SqliteApContentQuery { rows.into_iter().map(DiaryRow::into_domain).collect() } - async fn get_review_by_id(&self, review_id: &ReviewId) -> Result, DomainError> { - let id = review_id.value().to_string(); - sqlx::query_as::<_, ReviewRow>( - "SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url - FROM reviews WHERE id = ?", - ) - .bind(&id) - .fetch_optional(&self.pool) - .await - .map_err(Self::map_err)? - .map(ReviewRow::into_domain) - .transpose() - } - - async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result, DomainError> { - let id = movie_id.value().to_string(); - sqlx::query_as::<_, MovieRow>( - "SELECT id, external_metadata_id, title, release_year, director, poster_path - FROM movies WHERE id = ?", - ) - .bind(&id) - .fetch_optional(&self.pool) - .await - .map_err(Self::map_err)? - .map(MovieRow::into_domain) - .transpose() - } - - async fn get_movie_by_external_metadata_id( - &self, - external_id: &str, - ) -> Result, DomainError> { - sqlx::query_as::<_, MovieRow>( - "SELECT id, external_metadata_id, title, release_year, director, poster_path - FROM movies WHERE external_metadata_id = ?", - ) - .bind(external_id) - .fetch_optional(&self.pool) - .await - .map_err(Self::map_err)? - .map(MovieRow::into_domain) - .transpose() - } - - async fn count_local_posts(&self) -> Result { - let count: i64 = - sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE remote_actor_url IS NULL") - .fetch_one(&self.pool) - .await - .map_err(Self::map_err)?; - Ok(count as u64) - } - async fn get_local_reviews_page( &self, user_id: &UserId, @@ -391,7 +252,7 @@ impl LocalApContentQuery for SqliteApContentQuery { let ts = before_ts.format("%Y-%m-%d %H:%M:%S").to_string(); sqlx::query_as::<_, DiaryRow>( "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, - r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url + r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium FROM reviews r INNER JOIN movies m ON m.id = r.movie_id WHERE r.user_id = ? AND r.remote_actor_url IS NULL AND r.watched_at < ? @@ -407,7 +268,7 @@ impl LocalApContentQuery for SqliteApContentQuery { } else { sqlx::query_as::<_, DiaryRow>( "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, - r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url + r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium FROM reviews r INNER JOIN movies m ON m.id = r.movie_id WHERE r.user_id = ? AND r.remote_actor_url IS NULL @@ -422,43 +283,4 @@ impl LocalApContentQuery for SqliteApContentQuery { }; rows.into_iter().map(DiaryRow::into_domain).collect() } - - async fn get_goal_with_progress( - &self, - user_id: &UserId, - year: u16, - ) -> Result, DomainError> { - let uid = user_id.value().to_string(); - let y = year as i64; - - let row = sqlx::query( - "SELECT id, user_id, year, target_count, goal_type, created_at \ - FROM goals WHERE user_id = ? AND year = ?", - ) - .bind(&uid) - .bind(y) - .fetch_optional(&self.pool) - .await - .map_err(Self::map_err)?; - - let Some(r) = row else { return Ok(None) }; - - let goal = row_to_goal(&r)?; - let count = count_reviews_in_year(&self.pool, user_id, year).await?; - - Ok(Some((goal, count))) - } - - async fn list_goals_for_user(&self, user_id: &UserId) -> Result, DomainError> { - let uid = user_id.value().to_string(); - let rows = sqlx::query( - "SELECT id, user_id, year, target_count, goal_type, created_at \ - FROM goals WHERE user_id = ? ORDER BY created_at DESC", - ) - .bind(&uid) - .fetch_all(&self.pool) - .await - .map_err(Self::map_err)?; - rows.iter().map(row_to_goal).collect() - } } diff --git a/crates/adapters/sqlite-federation/src/social.rs b/crates/adapters/sqlite-federation/src/social.rs index 83b1457..a21c0de 100644 --- a/crates/adapters/sqlite-federation/src/social.rs +++ b/crates/adapters/sqlite-federation/src/social.rs @@ -3,6 +3,7 @@ use domain::{ errors::DomainError, models::{PendingFollowerInfo, RemoteActorInfo}, ports::SocialQueryPort, + value_objects::UserId, }; use super::SqliteFederationRepository; @@ -11,9 +12,9 @@ use super::SqliteFederationRepository; impl SocialQueryPort for SqliteFederationRepository { async fn get_accepted_following_urls( &self, - user_id: uuid::Uuid, + user_id: &UserId, ) -> Result, DomainError> { - let user_id_str = user_id.to_string(); + let user_id_str = user_id.value().to_string(); sqlx::query_scalar::<_, String>( "SELECT remote_actor_url FROM ap_following WHERE local_user_id = ? AND status = 'accepted'", ).bind(&user_id_str).fetch_all(&self.pool).await @@ -40,8 +41,8 @@ impl SocialQueryPort for SqliteFederationRepository { .collect()) } - async fn count_following(&self, user_id: uuid::Uuid) -> Result { - let uid = user_id.to_string(); + async fn count_following(&self, user_id: &UserId) -> Result { + let uid = user_id.value().to_string(); let count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM ap_following WHERE local_user_id = ? AND status = 'accepted'", ) @@ -52,8 +53,8 @@ impl SocialQueryPort for SqliteFederationRepository { Ok(count as usize) } - async fn count_accepted_followers(&self, user_id: uuid::Uuid) -> Result { - let uid = user_id.to_string(); + async fn count_accepted_followers(&self, user_id: &UserId) -> Result { + let uid = user_id.value().to_string(); let count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM ap_followers WHERE local_user_id = ? AND status = 'accepted'", ) @@ -66,9 +67,9 @@ impl SocialQueryPort for SqliteFederationRepository { async fn get_pending_followers( &self, - user_id: uuid::Uuid, + user_id: &UserId, ) -> Result, DomainError> { - let uid = user_id.to_string(); + let uid = user_id.value().to_string(); let rows = sqlx::query_as::<_, (String, String, Option, Option)>( "SELECT ar.url, ar.handle, ar.display_name, ar.avatar_url FROM ap_followers f diff --git a/crates/adapters/sqlite-federation/src/tests/lib.rs b/crates/adapters/sqlite-federation/src/tests/lib.rs index f6d77cf..cc2ab89 100644 --- a/crates/adapters/sqlite-federation/src/tests/lib.rs +++ b/crates/adapters/sqlite-federation/src/tests/lib.rs @@ -97,7 +97,8 @@ async fn test_get_accepted_following_urls_returns_only_accepted() { .await .unwrap(); - let urls = repo.get_accepted_following_urls(user_id).await.unwrap(); + let uid = domain::value_objects::UserId::from_uuid(user_id); + let urls = repo.get_accepted_following_urls(&uid).await.unwrap(); assert_eq!(urls.len(), 1); assert_eq!(urls[0], "https://other.social/users/alice"); } diff --git a/crates/adapters/sqlite/src/diary.rs b/crates/adapters/sqlite/src/diary.rs index fb3364c..1f31d70 100644 --- a/crates/adapters/sqlite/src/diary.rs +++ b/crates/adapters/sqlite/src/diary.rs @@ -378,7 +378,7 @@ impl DiaryRepository for SqliteDiaryRepository { .into_domain()?; let viewings = sqlx::query_as::<_, ReviewRow>( - "SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url + "SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium FROM reviews WHERE movie_id = ? ORDER BY watched_at ASC", ) .bind(&id_str) diff --git a/crates/adapters/sqlite/src/goals.rs b/crates/adapters/sqlite/src/goals.rs index 571d90d..42bc852 100644 --- a/crates/adapters/sqlite/src/goals.rs +++ b/crates/adapters/sqlite/src/goals.rs @@ -118,10 +118,6 @@ impl GoalRepository for SqliteGoalRepository { rows.iter().map(row_to_goal).collect() } - - async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result { - count_reviews_in_year(&self.pool, user_id, year).await - } } pub(crate) async fn count_reviews_in_year( diff --git a/crates/adapters/sqlite/src/review.rs b/crates/adapters/sqlite/src/review.rs index ccd01a5..80b1933 100644 --- a/crates/adapters/sqlite/src/review.rs +++ b/crates/adapters/sqlite/src/review.rs @@ -1,7 +1,6 @@ use async_trait::async_trait; use domain::{ errors::DomainError, - events::DomainEvent, models::{Review, ReviewSource}, ports::ReviewRepository, value_objects::{ReviewId, UserId}, @@ -27,7 +26,7 @@ impl SqliteReviewRepository { #[async_trait] impl ReviewRepository for SqliteReviewRepository { - async fn save_review(&self, review: &Review) -> Result { + async fn save_review(&self, review: &Review) -> Result<(), DomainError> { let id = review.id().value().to_string(); let movie_id = review.movie_id().value().to_string(); let user_id = review.user_id().value().to_string(); @@ -57,13 +56,7 @@ impl ReviewRepository for SqliteReviewRepository { .await .map_err(Self::map_err)?; - Ok(DomainEvent::ReviewLogged { - review_id: review.id().clone(), - movie_id: review.movie_id().clone(), - user_id: review.user_id().clone(), - rating: review.rating().clone(), - watched_at: *review.watched_at(), - }) + Ok(()) } async fn get_review_by_id(&self, review_id: &ReviewId) -> Result, DomainError> { diff --git a/crates/adapters/sqlite/src/stats.rs b/crates/adapters/sqlite/src/stats.rs index 0c1b97f..5a2bffd 100644 --- a/crates/adapters/sqlite/src/stats.rs +++ b/crates/adapters/sqlite/src/stats.rs @@ -96,6 +96,10 @@ impl StatsRepository for SqliteStatsRepository { }) } + async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result { + crate::goals::count_reviews_in_year(&self.pool, user_id, year).await + } + async fn get_user_trends(&self, user_id: &UserId) -> Result { let uid = user_id.value().to_string(); diff --git a/crates/adapters/template-askama/Cargo.toml b/crates/adapters/template-askama/Cargo.toml index 312505f..9b03754 100644 --- a/crates/adapters/template-askama/Cargo.toml +++ b/crates/adapters/template-askama/Cargo.toml @@ -10,4 +10,4 @@ chrono = { workspace = true } uuid = { workspace = true } domain = { workspace = true } -application = { workspace = true } +api-types = { workspace = true } diff --git a/crates/adapters/template-askama/src/lib.rs b/crates/adapters/template-askama/src/lib.rs index 4bd2859..39c65c7 100644 --- a/crates/adapters/template-askama/src/lib.rs +++ b/crates/adapters/template-askama/src/lib.rs @@ -1,7 +1,7 @@ pub use askama; use askama::Template; -use application::rendering::HtmlPageContext; +use api_types::HtmlPageContext; use chrono::Datelike; use domain::models::{ DiaryEntry, FeedEntry, MonthActivity, MonthlyRating, ReviewSource, UserStats, UserTrends, diff --git a/crates/api-types/Cargo.toml b/crates/api-types/Cargo.toml index bf8d7ab..bd2618f 100644 --- a/crates/api-types/Cargo.toml +++ b/crates/api-types/Cargo.toml @@ -7,3 +7,4 @@ edition = "2024" serde = { workspace = true } uuid = { workspace = true } utoipa = { version = "5.5.0", features = ["axum_extras", "uuid"] } +domain = { path = "../domain" } diff --git a/crates/api-types/src/auth.rs b/crates/api-types/src/auth.rs index fb1a31d..911806f 100644 --- a/crates/api-types/src/auth.rs +++ b/crates/api-types/src/auth.rs @@ -14,7 +14,8 @@ pub struct LoginResponse { pub user_id: Uuid, pub email: String, pub expires_at: String, - pub role: String, + #[schema(value_type = String)] + pub role: domain::models::UserRole, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] diff --git a/crates/api-types/src/diary.rs b/crates/api-types/src/diary.rs index 68bc6fe..108e6b4 100644 --- a/crates/api-types/src/diary.rs +++ b/crates/api-types/src/diary.rs @@ -18,7 +18,8 @@ pub struct LogReviewRequest { pub comment: Option, pub watched_at: String, #[serde(skip_serializing_if = "Option::is_none")] - pub watch_medium: Option, + #[schema(value_type = Option)] + pub watch_medium: Option, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] @@ -89,7 +90,8 @@ pub struct EditReviewRequest { #[serde(skip_serializing_if = "Option::is_none")] pub watched_at: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub watch_medium: Option>, + #[schema(value_type = Option>)] + pub watch_medium: Option>, } fn default_export_format() -> String { diff --git a/crates/api-types/src/goals.rs b/crates/api-types/src/goals.rs index f3f8803..67c0afa 100644 --- a/crates/api-types/src/goals.rs +++ b/crates/api-types/src/goals.rs @@ -7,7 +7,8 @@ pub struct GoalDto { pub current_count: u32, pub percentage: f64, pub is_complete: bool, - pub goal_type: String, + #[schema(value_type = String)] + pub goal_type: domain::models::GoalType, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] @@ -25,17 +26,3 @@ pub struct CreateGoalRequest { pub struct UpdateGoalRequest { pub target_count: u32, } - -#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] -pub struct UserSettingsDto { - pub federate_goals: bool, - pub federate_reviews: bool, - pub federate_watchlist: bool, -} - -#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] -pub struct UpdateUserSettingsRequest { - pub federate_goals: bool, - pub federate_reviews: bool, - pub federate_watchlist: bool, -} diff --git a/crates/api-types/src/import.rs b/crates/api-types/src/import.rs index 6f3302b..a5ee887 100644 --- a/crates/api-types/src/import.rs +++ b/crates/api-types/src/import.rs @@ -46,29 +46,24 @@ pub struct SaveProfileRequest { pub name: String, } +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct PreviewRowData { + pub index: usize, + pub title: Option, + pub release_year: Option, + pub director: Option, + pub rating: Option, + pub watched_at: Option, + pub comment: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[serde(tag = "status")] pub enum PreviewRowDto { #[serde(rename = "valid")] - Valid { - index: usize, - title: Option, - release_year: Option, - director: Option, - rating: Option, - watched_at: Option, - comment: Option, - }, + Valid(PreviewRowData), #[serde(rename = "duplicate")] - Duplicate { - index: usize, - title: Option, - release_year: Option, - director: Option, - rating: Option, - watched_at: Option, - comment: Option, - }, + Duplicate(PreviewRowData), #[serde(rename = "invalid")] Invalid { index: usize, errors: Vec }, } diff --git a/crates/api-types/src/lib.rs b/crates/api-types/src/lib.rs index 2453284..537df34 100644 --- a/crates/api-types/src/lib.rs +++ b/crates/api-types/src/lib.rs @@ -4,6 +4,7 @@ pub mod diary; pub mod goals; pub mod import; pub mod movies; +pub mod rendering; pub mod search; pub mod social; pub mod users; @@ -17,6 +18,7 @@ pub use diary::*; pub use goals::*; pub use import::*; pub use movies::*; +pub use rendering::*; pub use social::*; pub use users::*; pub use watchlist::*; diff --git a/crates/api-types/src/movies.rs b/crates/api-types/src/movies.rs index b75db6b..16df66a 100644 --- a/crates/api-types/src/movies.rs +++ b/crates/api-types/src/movies.rs @@ -99,7 +99,8 @@ pub struct ReviewDto { pub comment: Option, pub watched_at: String, #[serde(skip_serializing_if = "Option::is_none")] - pub watch_medium: Option, + #[schema(value_type = Option)] + pub watch_medium: Option, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] @@ -125,7 +126,8 @@ pub struct SocialReviewDto { pub watched_at: String, pub is_federated: bool, #[serde(skip_serializing_if = "Option::is_none")] - pub watch_medium: Option, + #[schema(value_type = Option)] + pub watch_medium: Option, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] diff --git a/crates/application/src/rendering.rs b/crates/api-types/src/rendering.rs similarity index 100% rename from crates/application/src/rendering.rs rename to crates/api-types/src/rendering.rs diff --git a/crates/api-types/src/users.rs b/crates/api-types/src/users.rs index 74d5dba..2069d40 100644 --- a/crates/api-types/src/users.rs +++ b/crates/api-types/src/users.rs @@ -67,13 +67,23 @@ pub struct UserTrendsDto { } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] -pub struct UserProfileResponse { - pub user_id: Uuid, +pub struct UserProfileBase { pub username: String, #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bio: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub avatar_url: Option, #[serde(skip_serializing_if = "Option::is_none")] pub banner_url: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct UserProfileResponse { + pub user_id: Uuid, + #[serde(flatten)] + pub profile: UserProfileBase, pub stats: UserStatsDto, pub following_count: usize, pub followers_count: usize, @@ -90,23 +100,17 @@ pub struct UserProfileResponse { #[serde(skip_serializing_if = "Option::is_none")] pub handle: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub display_name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub bio: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub actor_url: Option, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct ProfileResponse { - pub username: String, - pub display_name: Option, - pub bio: Option, - pub avatar_url: Option, - pub banner_url: Option, + #[serde(flatten)] + pub profile: UserProfileBase, pub also_known_as: Option, pub fields: Vec, - pub role: String, + #[schema(value_type = String)] + pub role: domain::models::UserRole, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] @@ -119,3 +123,17 @@ pub struct ProfileFieldDto { pub struct UpdateProfileFieldsRequest { pub fields: Vec, } + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct UserSettingsDto { + pub federate_goals: bool, + pub federate_reviews: bool, + pub federate_watchlist: bool, +} + +#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] +pub struct UpdateUserSettingsRequest { + pub federate_goals: bool, + pub federate_reviews: bool, + pub federate_watchlist: bool, +} diff --git a/crates/application/Cargo.toml b/crates/application/Cargo.toml index 97bfe1c..50d8e29 100644 --- a/crates/application/Cargo.toml +++ b/crates/application/Cargo.toml @@ -6,7 +6,7 @@ edition = "2024" [dependencies] async-trait = { workspace = true } domain = { workspace = true } -reqwest = { workspace = true } +infra-wiring = { workspace = true } uuid = { workspace = true } chrono = { workspace = true } tracing = { workspace = true } diff --git a/crates/application/src/auth/login.rs b/crates/application/src/auth/login.rs index 54a43c8..d02ca83 100644 --- a/crates/application/src/auth/login.rs +++ b/crates/application/src/auth/login.rs @@ -11,7 +11,7 @@ pub struct LoginResult { pub user_id: Uuid, pub email: String, pub expires_at: DateTime, - pub role: String, + pub role: domain::models::UserRole, } pub async fn execute(deps: &LoginDeps, query: LoginQuery) -> Result { @@ -49,7 +49,7 @@ pub async fn execute(deps: &LoginDeps, query: LoginQuery) -> Result, - pub logo_path: Option, - pub bg_dir: Option, -} - -impl AppConfig { - pub fn from_env() -> Self { - let allow_registration = std::env::var("ALLOW_REGISTRATION") - .map(|v| v == "true" || v == "1") - .unwrap_or(false); - let base_url = - std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:3000".to_string()); - let rate_limit = std::env::var("RATE_LIMIT") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(60); - let refresh_ttl_seconds = std::env::var("REFRESH_TTL_SECONDS") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(2_592_000u64); - Self { - allow_registration, - base_url, - rate_limit, - refresh_ttl_seconds, - wrapup: WrapUpConfig::from_env(), - } - } -} - -impl WrapUpConfig { - pub fn from_env() -> Self { - Self { - font_path: std::env::var("WRAPUP_FONT_PATH").ok(), - logo_path: std::env::var("WRAPUP_LOGO_PATH").ok(), - bg_dir: std::env::var("WRAPUP_BG_DIR").ok(), - } - } -} +pub use infra_wiring::{AppConfig, WrapUpConfig}; diff --git a/crates/application/src/diary/get_activity_feed.rs b/crates/application/src/diary/get_activity_feed.rs index a844f43..cdadbd3 100644 --- a/crates/application/src/diary/get_activity_feed.rs +++ b/crates/application/src/diary/get_activity_feed.rs @@ -6,6 +6,7 @@ use domain::{ FeedEntry, collections::{PageParams, Paginated}, }, + value_objects::UserId, }; pub async fn execute( @@ -34,9 +35,10 @@ async fn build_following_filter( return None; } let viewer_id = query.viewer_user_id?; + let viewer = UserId::from_uuid(viewer_id); let urls = deps .social_query - .get_accepted_following_urls(viewer_id) + .get_accepted_following_urls(&viewer) .await .unwrap_or_default(); if urls.is_empty() { diff --git a/crates/application/src/diary/review_logger.rs b/crates/application/src/diary/review_logger.rs index 31b90f1..ccff5e3 100644 --- a/crates/application/src/diary/review_logger.rs +++ b/crates/application/src/diary/review_logger.rs @@ -4,15 +4,15 @@ use async_trait::async_trait; use domain::{ errors::DomainError, events::DomainEvent, - models::{Movie, Review}, + models::Review, ports::{ EventPublisher, MetadataClient, MovieRepository, ReviewRepository, WatchlistRepository, }, - value_objects::{Comment, MovieId, Rating, UserId}, + value_objects::{Comment, Rating, UserId}, }; use crate::diary::commands::LogReviewCommand; -use crate::diary::movie_resolver::{MovieResolver, MovieResolverDeps}; +use crate::movies::resolve::resolve_and_persist_movie; use crate::ports::ReviewLogger; pub struct DefaultReviewLogger { @@ -48,25 +48,18 @@ impl ReviewLogger for DefaultReviewLogger { let user_id = UserId::from_uuid(cmd.user_id); let comment = cmd.comment.clone().map(Comment::new).transpose()?; - let (movie, is_new_movie) = if let Some(id) = cmd.input.movie_id { - let movie_id = MovieId::from_uuid(id); - let movie = self - .movie_repo - .get_movie_by_id(&movie_id) - .await? - .ok_or_else(|| DomainError::NotFound(format!("Movie {id}")))?; - (movie, false) - } else { - let deps = MovieResolverDeps { - repository: self.movie_repo.as_ref(), - metadata_client: self.metadata_client.as_ref(), - }; - MovieResolver::default_pipeline() - .resolve(&cmd.input, &deps) - .await? - }; + let (movie, is_new_movie) = resolve_and_persist_movie( + &cmd.input, + self.movie_repo.as_ref(), + self.metadata_client.as_ref(), + self.event_publisher.as_ref(), + ) + .await?; - self.movie_repo.upsert_movie(&movie).await?; + // Always upsert: even existing movies may have updated metadata + if !is_new_movie { + self.movie_repo.upsert_movie(&movie).await?; + } let review = Review::new( movie.id().clone(), @@ -76,7 +69,14 @@ impl ReviewLogger for DefaultReviewLogger { cmd.watched_at, cmd.watch_medium, )?; - let review_event = self.review_repo.save_review(&review).await?; + self.review_repo.save_review(&review).await?; + let review_event = DomainEvent::ReviewLogged { + review_id: review.id().clone(), + movie_id: review.movie_id().clone(), + user_id: review.user_id().clone(), + rating: review.rating().clone(), + watched_at: *review.watched_at(), + }; let was_on_watchlist = self .watchlist_repo @@ -92,35 +92,17 @@ impl ReviewLogger for DefaultReviewLogger { .await; } - publish_events(&self.event_publisher, &movie, is_new_movie, review_event).await - } -} + if let Some(ext_id) = movie.external_metadata_id() { + self.event_publisher + .publish(&DomainEvent::MovieEnrichmentRequested { + movie_id: movie.id().clone(), + external_metadata_id: ext_id.clone(), + }) + .await?; + } -async fn publish_events( - publisher: &Arc, - movie: &Movie, - is_new_movie: bool, - review_event: DomainEvent, -) -> Result<(), DomainError> { - if is_new_movie && let Some(ext_id) = movie.external_metadata_id() { - publisher - .publish(&DomainEvent::MovieDiscovered { - movie_id: movie.id().clone(), - external_metadata_id: ext_id.clone(), - }) - .await?; + self.event_publisher.publish(&review_event).await } - - if let Some(ext_id) = movie.external_metadata_id() { - publisher - .publish(&DomainEvent::MovieEnrichmentRequested { - movie_id: movie.id().clone(), - external_metadata_id: ext_id.clone(), - }) - .await?; - } - - publisher.publish(&review_event).await } #[cfg(test)] diff --git a/crates/application/src/diary/tests/get_activity_feed.rs b/crates/application/src/diary/tests/get_activity_feed.rs index 85eefa8..50fcea9 100644 --- a/crates/application/src/diary/tests/get_activity_feed.rs +++ b/crates/application/src/diary/tests/get_activity_feed.rs @@ -68,18 +68,27 @@ struct FakeSocialWithFollowing(Vec); #[async_trait] impl domain::ports::SocialQueryPort for FakeSocialWithFollowing { - async fn get_accepted_following_urls(&self, _: uuid::Uuid) -> Result, DomainError> { + async fn get_accepted_following_urls( + &self, + _: &domain::value_objects::UserId, + ) -> Result, DomainError> { Ok(self.0.clone()) } - async fn count_following(&self, _: uuid::Uuid) -> Result { + async fn count_following( + &self, + _: &domain::value_objects::UserId, + ) -> Result { Ok(0) } - async fn count_accepted_followers(&self, _: uuid::Uuid) -> Result { + async fn count_accepted_followers( + &self, + _: &domain::value_objects::UserId, + ) -> Result { Ok(0) } async fn get_pending_followers( &self, - _: uuid::Uuid, + _: &domain::value_objects::UserId, ) -> Result, DomainError> { Ok(vec![]) } diff --git a/crates/application/src/goals/create.rs b/crates/application/src/goals/create.rs index 6c763e5..f3e6eb3 100644 --- a/crates/application/src/goals/create.rs +++ b/crates/application/src/goals/create.rs @@ -4,7 +4,7 @@ use domain::{ errors::DomainError, events::DomainEvent, models::{Goal, GoalType, GoalWithProgress}, - ports::{EventPublisher, GoalRepository}, + ports::{EventPublisher, GoalRepository, StatsRepository}, value_objects::UserId, }; @@ -12,6 +12,7 @@ use super::commands::CreateGoalCommand; pub async fn execute( goal: Arc, + stats: Arc, event_publisher: Arc, cmd: CreateGoalCommand, ) -> Result { @@ -32,7 +33,7 @@ pub async fn execute( )?; goal.save(&g).await?; - let current_count = goal.count_reviews_in_year(&user_id, cmd.year).await?; + let current_count = stats.count_reviews_in_year(&user_id, cmd.year).await?; event_publisher .publish(&DomainEvent::GoalCreated { diff --git a/crates/application/src/goals/get.rs b/crates/application/src/goals/get.rs index a8651ad..c2b4fba 100644 --- a/crates/application/src/goals/get.rs +++ b/crates/application/src/goals/get.rs @@ -1,13 +1,17 @@ use std::sync::Arc; use domain::{ - errors::DomainError, models::GoalWithProgress, ports::GoalRepository, value_objects::UserId, + errors::DomainError, + models::GoalWithProgress, + ports::{GoalRepository, StatsRepository}, + value_objects::UserId, }; use super::queries::GetGoalQuery; pub async fn execute( goal: Arc, + stats: Arc, query: GetGoalQuery, ) -> Result, DomainError> { let user_id = UserId::from_uuid(query.user_id); @@ -16,7 +20,7 @@ pub async fn execute( let Some(g) = found else { return Ok(None) }; - let current_count = goal.count_reviews_in_year(&user_id, query.year).await?; + let current_count = stats.count_reviews_in_year(&user_id, query.year).await?; Ok(Some(GoalWithProgress { goal: g, diff --git a/crates/application/src/goals/list.rs b/crates/application/src/goals/list.rs index 0f2c20b..acc0673 100644 --- a/crates/application/src/goals/list.rs +++ b/crates/application/src/goals/list.rs @@ -1,13 +1,17 @@ use std::sync::Arc; use domain::{ - errors::DomainError, models::GoalWithProgress, ports::GoalRepository, value_objects::UserId, + errors::DomainError, + models::GoalWithProgress, + ports::{GoalRepository, StatsRepository}, + value_objects::UserId, }; use super::queries::ListGoalsQuery; pub async fn execute( goal: Arc, + stats: Arc, query: ListGoalsQuery, ) -> Result, DomainError> { let user_id = UserId::from_uuid(query.user_id); @@ -15,7 +19,7 @@ pub async fn execute( let mut result = Vec::with_capacity(goals.len()); for g in goals { - let current_count = goal.count_reviews_in_year(&user_id, g.year()).await?; + let current_count = stats.count_reviews_in_year(&user_id, g.year()).await?; result.push(GoalWithProgress { goal: g, current_count, diff --git a/crates/application/src/goals/tests/create.rs b/crates/application/src/goals/tests/create.rs index a4eec61..de9014b 100644 --- a/crates/application/src/goals/tests/create.rs +++ b/crates/application/src/goals/tests/create.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use domain::events::DomainEvent; -use domain::testing::{InMemoryGoalRepository, NoopEventPublisher}; +use domain::testing::{FakeStatsRepository, InMemoryGoalRepository, NoopEventPublisher}; use uuid::Uuid; use crate::goals::{commands::CreateGoalCommand, create}; @@ -10,10 +10,12 @@ use crate::test_helpers::TestContextBuilder; #[tokio::test] async fn creates_goal_and_returns_progress() { let goals = InMemoryGoalRepository::new(); + let stats = FakeStatsRepository::new(); let events = NoopEventPublisher::new(); let result = create::execute( Arc::clone(&goals) as _, + Arc::clone(&stats) as _, Arc::clone(&events) as _, CreateGoalCommand { user_id: Uuid::nil(), @@ -33,11 +35,13 @@ async fn creates_goal_and_returns_progress() { #[tokio::test] async fn creates_goal_with_review_count() { let goals = InMemoryGoalRepository::new(); - goals.set_review_count(Uuid::nil(), 2025, 5); + let stats = FakeStatsRepository::new(); + stats.set_review_count(Uuid::nil(), 2025, 5); let events = NoopEventPublisher::new(); let result = create::execute( Arc::clone(&goals) as _, + Arc::clone(&stats) as _, Arc::clone(&events) as _, CreateGoalCommand { user_id: Uuid::nil(), @@ -59,6 +63,7 @@ async fn emits_goal_created_event() { create::execute( b.goal_repo.clone(), + b.stats_repo.clone(), Arc::clone(&events) as _, CreateGoalCommand { user_id: Uuid::nil(), @@ -86,12 +91,18 @@ async fn rejects_duplicate_year() { target_count: 10, }; - create::execute(b.goal_repo.clone(), b.event_publisher.clone(), cmd) - .await - .unwrap(); + create::execute( + b.goal_repo.clone(), + b.stats_repo.clone(), + b.event_publisher.clone(), + cmd, + ) + .await + .unwrap(); let result = create::execute( b.goal_repo.clone(), + b.stats_repo.clone(), b.event_publisher.clone(), CreateGoalCommand { user_id: Uuid::nil(), @@ -109,6 +120,7 @@ async fn rejects_year_before_2020() { let b = TestContextBuilder::new(); let result = create::execute( b.goal_repo.clone(), + b.stats_repo.clone(), b.event_publisher.clone(), CreateGoalCommand { user_id: Uuid::nil(), @@ -126,6 +138,7 @@ async fn rejects_zero_target() { let b = TestContextBuilder::new(); let result = create::execute( b.goal_repo.clone(), + b.stats_repo.clone(), b.event_publisher.clone(), CreateGoalCommand { user_id: Uuid::nil(), diff --git a/crates/application/src/goals/tests/delete.rs b/crates/application/src/goals/tests/delete.rs index d2b51b2..67b154e 100644 --- a/crates/application/src/goals/tests/delete.rs +++ b/crates/application/src/goals/tests/delete.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use domain::testing::{InMemoryGoalRepository, NoopEventPublisher}; +use domain::testing::{FakeStatsRepository, InMemoryGoalRepository, NoopEventPublisher}; use uuid::Uuid; use crate::goals::{ @@ -12,10 +12,12 @@ use crate::test_helpers::TestContextBuilder; #[tokio::test] async fn deletes_existing_goal() { let goals = InMemoryGoalRepository::new(); + let stats = FakeStatsRepository::new(); let events = NoopEventPublisher::new(); create::execute( Arc::clone(&goals) as _, + Arc::clone(&stats) as _, Arc::clone(&events) as _, CreateGoalCommand { user_id: Uuid::nil(), diff --git a/crates/application/src/goals/tests/get.rs b/crates/application/src/goals/tests/get.rs index 7aac131..e361bf7 100644 --- a/crates/application/src/goals/tests/get.rs +++ b/crates/application/src/goals/tests/get.rs @@ -8,6 +8,7 @@ async fn returns_goal_when_exists() { let b = TestContextBuilder::new(); create::execute( b.goal_repo.clone(), + b.stats_repo.clone(), b.event_publisher.clone(), CreateGoalCommand { user_id: Uuid::nil(), @@ -20,6 +21,7 @@ async fn returns_goal_when_exists() { let result = get::execute( b.goal_repo.clone(), + b.stats_repo.clone(), GetGoalQuery { user_id: Uuid::nil(), year: 2025, @@ -37,6 +39,7 @@ async fn returns_none_when_missing() { let b = TestContextBuilder::new(); let result = get::execute( b.goal_repo.clone(), + b.stats_repo.clone(), GetGoalQuery { user_id: Uuid::nil(), year: 2025, diff --git a/crates/application/src/goals/tests/list.rs b/crates/application/src/goals/tests/list.rs index c142694..0563982 100644 --- a/crates/application/src/goals/tests/list.rs +++ b/crates/application/src/goals/tests/list.rs @@ -8,6 +8,7 @@ async fn returns_empty_when_no_goals() { let b = TestContextBuilder::new(); let result = list::execute( b.goal_repo.clone(), + b.stats_repo.clone(), ListGoalsQuery { user_id: Uuid::nil(), }, @@ -24,6 +25,7 @@ async fn returns_all_goals_for_user() { for year in [2023, 2024, 2025] { create::execute( b.goal_repo.clone(), + b.stats_repo.clone(), b.event_publisher.clone(), CreateGoalCommand { user_id: Uuid::nil(), @@ -37,6 +39,7 @@ async fn returns_all_goals_for_user() { let result = list::execute( b.goal_repo.clone(), + b.stats_repo.clone(), ListGoalsQuery { user_id: Uuid::nil(), }, diff --git a/crates/application/src/goals/tests/update.rs b/crates/application/src/goals/tests/update.rs index f3dc27f..13fc289 100644 --- a/crates/application/src/goals/tests/update.rs +++ b/crates/application/src/goals/tests/update.rs @@ -11,6 +11,7 @@ async fn updates_target_count() { let b = TestContextBuilder::new(); create::execute( b.goal_repo.clone(), + b.stats_repo.clone(), b.event_publisher.clone(), CreateGoalCommand { user_id: Uuid::nil(), @@ -23,6 +24,7 @@ async fn updates_target_count() { let result = update::execute( b.goal_repo.clone(), + b.stats_repo.clone(), b.event_publisher.clone(), UpdateGoalCommand { user_id: Uuid::nil(), @@ -41,6 +43,7 @@ async fn fails_when_goal_not_found() { let b = TestContextBuilder::new(); let result = update::execute( b.goal_repo.clone(), + b.stats_repo.clone(), b.event_publisher.clone(), UpdateGoalCommand { user_id: Uuid::nil(), @@ -58,6 +61,7 @@ async fn rejects_zero_target() { let b = TestContextBuilder::new(); create::execute( b.goal_repo.clone(), + b.stats_repo.clone(), b.event_publisher.clone(), CreateGoalCommand { user_id: Uuid::nil(), @@ -70,6 +74,7 @@ async fn rejects_zero_target() { let result = update::execute( b.goal_repo.clone(), + b.stats_repo.clone(), b.event_publisher.clone(), UpdateGoalCommand { user_id: Uuid::nil(), diff --git a/crates/application/src/goals/update.rs b/crates/application/src/goals/update.rs index f6fe43a..4d741fe 100644 --- a/crates/application/src/goals/update.rs +++ b/crates/application/src/goals/update.rs @@ -4,7 +4,7 @@ use domain::{ errors::DomainError, events::DomainEvent, models::GoalWithProgress, - ports::{EventPublisher, GoalRepository}, + ports::{EventPublisher, GoalRepository, StatsRepository}, value_objects::UserId, }; @@ -12,6 +12,7 @@ use super::commands::UpdateGoalCommand; pub async fn execute( goal: Arc, + stats: Arc, event_publisher: Arc, cmd: UpdateGoalCommand, ) -> Result { @@ -25,7 +26,7 @@ pub async fn execute( g.update_target(cmd.target_count)?; goal.update(&g).await?; - let current_count = goal.count_reviews_in_year(&user_id, cmd.year).await?; + let current_count = stats.count_reviews_in_year(&user_id, cmd.year).await?; event_publisher .publish(&DomainEvent::GoalUpdated { diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index ec02896..aff5419 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -1,7 +1,6 @@ pub mod config; pub mod jobs; pub mod ports; -pub mod rendering; pub mod worker; pub mod auth; diff --git a/crates/application/src/movies/event_handler.rs b/crates/application/src/movies/event_handler.rs index 50b83b5..43ec8cc 100644 --- a/crates/application/src/movies/event_handler.rs +++ b/crates/application/src/movies/event_handler.rs @@ -6,7 +6,7 @@ use domain::{ events::DomainEvent, models::MovieProfile, ports::{ - EventHandler, MovieEnrichmentClient, MovieProfileRepository, MovieRepository, + EventHandler, ImageFetcher, MovieEnrichmentClient, MovieProfileRepository, MovieRepository, ObjectStorage, PersonCommand, SearchCommand, }, }; @@ -22,7 +22,7 @@ pub struct MovieEnrichmentHandler { person_command: Arc, search_command: Arc, object_storage: Arc, - http: reqwest::Client, + image_fetcher: Arc, } impl MovieEnrichmentHandler { @@ -33,6 +33,7 @@ impl MovieEnrichmentHandler { person_command: Arc, search_command: Arc, object_storage: Arc, + image_fetcher: Arc, ) -> Self { Self { enrichment_client, @@ -41,7 +42,7 @@ impl MovieEnrichmentHandler { person_command, search_command, object_storage, - http: reqwest::Client::new(), + image_fetcher, } } @@ -55,15 +56,13 @@ impl MovieEnrichmentHandler { continue; } let url = format!("https://image.tmdb.org/t/p/w185{path}"); - match self.http.get(&url).send().await { - Ok(resp) if resp.status().is_success() => { - if let Ok(bytes) = resp.bytes().await - && let Err(e) = self.object_storage.store(&key, &bytes).await - { + match self.image_fetcher.fetch_image(&url).await { + Ok(bytes) => { + if let Err(e) = self.object_storage.store(&key, &bytes).await { tracing::debug!("cast photo store failed for {path}: {e}"); } } - _ => tracing::debug!("cast photo download failed for {path}"), + Err(_) => tracing::debug!("cast photo download failed for {path}"), } } } diff --git a/crates/application/src/movies/mod.rs b/crates/application/src/movies/mod.rs index c0f5905..6ca5248 100644 --- a/crates/application/src/movies/mod.rs +++ b/crates/application/src/movies/mod.rs @@ -9,6 +9,7 @@ pub mod merge_duplicates; pub mod queries; pub mod reindex_search; pub mod request_enrichment; +pub mod resolve; pub mod search_cleanup; pub mod sync_poster; diff --git a/crates/application/src/movies/resolve.rs b/crates/application/src/movies/resolve.rs new file mode 100644 index 0000000..747b63e --- /dev/null +++ b/crates/application/src/movies/resolve.rs @@ -0,0 +1,51 @@ +use domain::{ + errors::DomainError, + events::DomainEvent, + models::Movie, + ports::{EventPublisher, MetadataClient, MovieRepository}, + value_objects::MovieId, +}; + +use crate::diary::commands::MovieInput; +use crate::diary::movie_resolver::{MovieResolver, MovieResolverDeps}; + +/// Resolves a movie from input, persists it, and publishes `MovieDiscovered` if new. +/// +/// Returns `(movie, is_new_movie)`. +pub async fn resolve_and_persist_movie( + input: &MovieInput, + movie_repo: &dyn MovieRepository, + metadata_client: &dyn MetadataClient, + event_publisher: &dyn EventPublisher, +) -> Result<(Movie, bool), DomainError> { + let (movie, is_new) = if let Some(id) = input.movie_id { + let movie_id = MovieId::from_uuid(id); + let movie = movie_repo + .get_movie_by_id(&movie_id) + .await? + .ok_or_else(|| DomainError::NotFound(format!("Movie {id}")))?; + (movie, false) + } else { + let deps = MovieResolverDeps { + repository: movie_repo, + metadata_client, + }; + MovieResolver::default_pipeline() + .resolve(input, &deps) + .await? + }; + + if is_new { + movie_repo.upsert_movie(&movie).await?; + if let Some(ext_id) = movie.external_metadata_id() { + let _ = event_publisher + .publish(&DomainEvent::MovieDiscovered { + movie_id: movie.id().clone(), + external_metadata_id: ext_id.clone(), + }) + .await; + } + } + + Ok((movie, is_new)) +} diff --git a/crates/application/src/person/get.rs b/crates/application/src/person/get.rs index c5b9d22..9626720 100644 --- a/crates/application/src/person/get.rs +++ b/crates/application/src/person/get.rs @@ -1,13 +1,10 @@ -use chrono::Utc; use domain::{ errors::DomainError, events::DomainEvent, models::{Person, PersonId}, }; -use super::deps::GetPersonDeps; - -const ENRICHMENT_TTL_DAYS: i64 = 90; +use super::{deps::GetPersonDeps, should_enrich}; pub async fn execute(deps: &GetPersonDeps, id: PersonId) -> Result, DomainError> { let person = deps.person_query.get_by_id(&id).await?; @@ -25,13 +22,6 @@ pub async fn execute(deps: &GetPersonDeps, id: PersonId) -> Result bool { - match p.enriched_at() { - None => true, - Some(at) => (Utc::now() - at).num_days() >= ENRICHMENT_TTL_DAYS, - } -} - #[cfg(test)] #[path = "tests/get.rs"] mod tests; diff --git a/crates/application/src/person/get_credits.rs b/crates/application/src/person/get_credits.rs index 0715acf..c252876 100644 --- a/crates/application/src/person/get_credits.rs +++ b/crates/application/src/person/get_credits.rs @@ -1,13 +1,10 @@ -use chrono::Utc; use domain::{ errors::DomainError, events::DomainEvent, - models::{Person, PersonCredits, PersonId}, + models::{PersonCredits, PersonId}, }; -use super::deps::GetPersonDeps; - -const ENRICHMENT_TTL_DAYS: i64 = 90; +use super::{deps::GetPersonDeps, should_enrich}; pub async fn execute(deps: &GetPersonDeps, id: PersonId) -> Result { let credits = deps.person_query.get_credits(&id).await?; @@ -23,13 +20,6 @@ pub async fn execute(deps: &GetPersonDeps, id: PersonId) -> Result bool { - match p.enriched_at() { - None => true, - Some(at) => (Utc::now() - at).num_days() >= ENRICHMENT_TTL_DAYS, - } -} - #[cfg(test)] #[path = "tests/get_credits.rs"] mod tests; diff --git a/crates/application/src/person/mod.rs b/crates/application/src/person/mod.rs index e507ee2..fac389b 100644 --- a/crates/application/src/person/mod.rs +++ b/crates/application/src/person/mod.rs @@ -5,3 +5,15 @@ pub mod get; pub mod get_credits; pub use event_handler::PersonEnrichmentHandler; + +use chrono::Utc; +use domain::models::Person; + +pub(crate) const ENRICHMENT_TTL_DAYS: i64 = 90; + +pub(crate) fn should_enrich(p: &Person) -> bool { + match p.enriched_at() { + None => true, + Some(at) => (Utc::now() - at).num_days() >= ENRICHMENT_TTL_DAYS, + } +} diff --git a/crates/application/src/ports.rs b/crates/application/src/ports.rs index d54e89a..1c8f51e 100644 --- a/crates/application/src/ports.rs +++ b/crates/application/src/ports.rs @@ -1,7 +1,6 @@ use async_trait::async_trait; use domain::errors::DomainError; -use domain::models::DiaryEntry; use crate::diary::commands::LogReviewCommand; @@ -9,7 +8,3 @@ use crate::diary::commands::LogReviewCommand; pub trait ReviewLogger: Send + Sync { async fn log_review(&self, cmd: LogReviewCommand) -> Result<(), DomainError>; } - -pub trait RssFeedRenderer: Send + Sync { - fn render_feed(&self, entries: &[DiaryEntry], title: &str) -> Result; -} diff --git a/crates/application/src/test_helpers.rs b/crates/application/src/test_helpers.rs index 6c888f0..2550a76 100644 --- a/crates/application/src/test_helpers.rs +++ b/crates/application/src/test_helpers.rs @@ -88,7 +88,7 @@ impl TestContextBuilder { diary_repo: FakeDiaryRepository::new(), diary_exporter: Arc::new(PanicDiaryExporter), document_parser: Arc::new(FakeDocumentParser), - stats_repo: Arc::new(FakeStatsRepository), + stats_repo: FakeStatsRepository::new(), metadata_client: Arc::new(FakeMetadataClient), poster_fetcher: Arc::new(FakePosterFetcher), object_storage: Arc::new(NoopObjectStorage), diff --git a/crates/application/src/users/get_current_profile.rs b/crates/application/src/users/get_current_profile.rs index fb02270..0015a83 100644 --- a/crates/application/src/users/get_current_profile.rs +++ b/crates/application/src/users/get_current_profile.rs @@ -17,7 +17,7 @@ pub struct CurrentProfileData { pub banner_path: Option, pub also_known_as: Option, pub fields: Vec, - pub role: String, + pub role: domain::models::UserRole, } pub async fn execute( @@ -47,7 +47,7 @@ pub async fn execute( banner_path: found.banner_path().map(|s| s.to_string()), also_known_as: found.also_known_as().map(|s| s.to_string()), fields, - role: found.role().as_str().into(), + role: found.role().clone(), }) } diff --git a/crates/application/src/users/get_profile.rs b/crates/application/src/users/get_profile.rs index 221651c..e98a9c0 100644 --- a/crates/application/src/users/get_profile.rs +++ b/crates/application/src/users/get_profile.rs @@ -37,7 +37,7 @@ pub async fn execute( let stats = deps.stats.get_user_stats(&user_id).await?; let (following_count, followers_count, pending_followers) = - load_social_counts(deps, query.user_id, query.is_own_profile).await; + load_social_counts(deps, &user_id, query.is_own_profile).await; let base = |entries, history, trends| UserProfileData { stats, @@ -76,7 +76,7 @@ pub async fn execute( async fn load_social_counts( deps: &GetProfileDeps, - user_id: uuid::Uuid, + user_id: &UserId, is_own_profile: bool, ) -> (usize, usize, Vec) { let following = deps diff --git a/crates/application/src/users/mod.rs b/crates/application/src/users/mod.rs index a7e673f..e420628 100644 --- a/crates/application/src/users/mod.rs +++ b/crates/application/src/users/mod.rs @@ -9,3 +9,52 @@ pub mod queries; pub mod update_profile; pub mod update_profile_fields; pub mod update_settings; + +use chrono::Datelike; +use domain::models::{DiaryEntry, MonthActivity}; + +pub fn group_by_month(entries: Vec) -> Vec { + use std::collections::BTreeMap; + let mut map: BTreeMap<(i32, u32), Vec> = BTreeMap::new(); + for entry in entries { + let watched_at = entry.review().watched_at(); + let year = watched_at.year(); + let month = watched_at.month(); + map.entry((year, month)).or_default().push(entry); + } + map.into_iter() + .rev() + .map(|((year, month), entries)| { + let year_month = format!("{:04}-{:02}", year, month); + MonthActivity { + month_label: format_year_month_long(&year_month), + count: entries.len() as i64, + entries, + year_month, + } + }) + .collect() +} + +fn format_year_month_long(ym: &str) -> String { + let parts: Vec<&str> = ym.splitn(2, '-').collect(); + if parts.len() != 2 { + return ym.to_string(); + } + let month = match parts[1] { + "01" => "January", + "02" => "February", + "03" => "March", + "04" => "April", + "05" => "May", + "06" => "June", + "07" => "July", + "08" => "August", + "09" => "September", + "10" => "October", + "11" => "November", + "12" => "December", + _ => parts[1], + }; + format!("{} {}", month, parts[0]) +} diff --git a/crates/application/src/watchlist/add.rs b/crates/application/src/watchlist/add.rs index d5581bb..3abedc8 100644 --- a/crates/application/src/watchlist/add.rs +++ b/crates/application/src/watchlist/add.rs @@ -1,12 +1,9 @@ use domain::{ - errors::DomainError, - events::DomainEvent, - models::WatchlistEntry, - value_objects::{MovieId, UserId}, + errors::DomainError, events::DomainEvent, models::WatchlistEntry, value_objects::UserId, }; use crate::{ - diary::movie_resolver::{MovieResolver, MovieResolverDeps}, + movies::resolve::resolve_and_persist_movie, watchlist::{commands::AddToWatchlistCommand, deps::WatchlistAddDeps}, }; @@ -16,34 +13,13 @@ pub async fn execute( ) -> Result<(), DomainError> { let user_id = UserId::from_uuid(cmd.user_id); - let movie = if let Some(id) = cmd.input.movie_id { - let movie_id = MovieId::from_uuid(id); - deps.movie - .get_movie_by_id(&movie_id) - .await? - .ok_or_else(|| DomainError::NotFound(format!("Movie {id}")))? - } else { - let resolver_deps = MovieResolverDeps { - repository: deps.movie.as_ref(), - metadata_client: deps.metadata.as_ref(), - }; - let (movie, is_new) = MovieResolver::default_pipeline() - .resolve(&cmd.input, &resolver_deps) - .await?; - if is_new { - deps.movie.upsert_movie(&movie).await?; - if let Some(ext_id) = movie.external_metadata_id() { - let _ = deps - .event_publisher - .publish(&DomainEvent::MovieDiscovered { - movie_id: movie.id().clone(), - external_metadata_id: ext_id.clone(), - }) - .await; - } - } - movie - }; + let (movie, _is_new) = resolve_and_persist_movie( + &cmd.input, + deps.movie.as_ref(), + deps.metadata.as_ref(), + deps.event_publisher.as_ref(), + ) + .await?; let entry = WatchlistEntry::new(user_id.clone(), movie.id().clone()); deps.watchlist.add(&entry).await?; diff --git a/crates/domain/src/models/federation.rs b/crates/domain/src/models/federation.rs index 84796fb..e67697a 100644 --- a/crates/domain/src/models/federation.rs +++ b/crates/domain/src/models/federation.rs @@ -19,6 +19,16 @@ pub struct FederationFlags { pub watchlist: bool, } +impl Default for FederationFlags { + fn default() -> Self { + Self { + goals: true, + reviews: true, + watchlist: true, + } + } +} + #[derive(Debug, Clone)] pub struct FederatedProfile { pub actor_url: String, diff --git a/crates/domain/src/models/mod.rs b/crates/domain/src/models/mod.rs index 50d7e34..5d194ae 100644 --- a/crates/domain/src/models/mod.rs +++ b/crates/domain/src/models/mod.rs @@ -57,7 +57,8 @@ pub use search::{ use crate::errors::DomainError; -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] pub enum GoalType { Movies, } diff --git a/crates/domain/src/models/user.rs b/crates/domain/src/models/user.rs index bfd9dd1..08dbc9f 100644 --- a/crates/domain/src/models/user.rs +++ b/crates/domain/src/models/user.rs @@ -1,6 +1,7 @@ use crate::value_objects::{Email, PasswordHash, UserId, Username}; -#[derive(Clone, Debug, Default)] +#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] pub enum UserRole { #[default] Standard, diff --git a/crates/domain/src/ports/diary.rs b/crates/domain/src/ports/diary.rs index b838fce..bdef1ef 100644 --- a/crates/domain/src/ports/diary.rs +++ b/crates/domain/src/ports/diary.rs @@ -2,7 +2,6 @@ use async_trait::async_trait; use crate::{ errors::DomainError, - events::DomainEvent, models::{ DiaryEntry, DiaryFilter, ExportFormat, FeedEntry, FeedSortBy, FollowingFilter, MovieStats, Review, ReviewHistory, UserStats, UserTrends, @@ -43,7 +42,7 @@ pub trait DiaryRepository: Send + Sync { #[async_trait] pub trait ReviewRepository: Send + Sync { - async fn save_review(&self, review: &Review) -> Result; + async fn save_review(&self, review: &Review) -> Result<(), DomainError>; async fn get_review_by_id(&self, review_id: &ReviewId) -> Result, DomainError>; async fn update_review(&self, review: &Review) -> Result<(), DomainError>; async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError>; @@ -54,6 +53,7 @@ pub trait ReviewRepository: Send + Sync { 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; + async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result; } pub trait DiaryExporter: Send + Sync { diff --git a/crates/domain/src/ports/goals.rs b/crates/domain/src/ports/goals.rs index 1b1ae24..5fac75a 100644 --- a/crates/domain/src/ports/goals.rs +++ b/crates/domain/src/ports/goals.rs @@ -17,5 +17,4 @@ pub trait GoalRepository: Send + Sync { 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/image_fetcher.rs b/crates/domain/src/ports/image_fetcher.rs new file mode 100644 index 0000000..66365af --- /dev/null +++ b/crates/domain/src/ports/image_fetcher.rs @@ -0,0 +1,8 @@ +use async_trait::async_trait; + +use crate::errors::DomainError; + +#[async_trait] +pub trait ImageFetcher: Send + Sync { + async fn fetch_image(&self, url: &str) -> Result, DomainError>; +} diff --git a/crates/domain/src/ports/mod.rs b/crates/domain/src/ports/mod.rs index 9e687f9..2d951c6 100644 --- a/crates/domain/src/ports/mod.rs +++ b/crates/domain/src/ports/mod.rs @@ -3,12 +3,14 @@ pub mod diary; pub mod events; pub mod federated_profile; pub mod goals; +pub mod image_fetcher; pub mod images; pub mod import; pub mod jobs; pub mod media_server; pub mod movie; pub mod person; +pub mod rss; pub mod search; pub mod social; pub mod watchlist; @@ -19,12 +21,14 @@ pub use diary::*; pub use events::*; pub use federated_profile::*; pub use goals::*; +pub use image_fetcher::*; pub use images::*; pub use import::*; pub use jobs::*; pub use media_server::*; pub use movie::*; pub use person::*; +pub use rss::*; pub use search::*; pub use social::*; pub use watchlist::*; diff --git a/crates/domain/src/ports/rss.rs b/crates/domain/src/ports/rss.rs new file mode 100644 index 0000000..5b42047 --- /dev/null +++ b/crates/domain/src/ports/rss.rs @@ -0,0 +1,5 @@ +use crate::models::DiaryEntry; + +pub trait RssFeedRenderer: Send + Sync { + fn render_feed(&self, entries: &[DiaryEntry], title: &str) -> Result; +} diff --git a/crates/domain/src/ports/social.rs b/crates/domain/src/ports/social.rs index ab4dee6..e92c2ff 100644 --- a/crates/domain/src/ports/social.rs +++ b/crates/domain/src/ports/social.rs @@ -4,24 +4,24 @@ use chrono::NaiveDateTime; use crate::{ errors::DomainError, models::{ - DiaryEntry, FederationFlags, Goal, Movie, PendingFollowerInfo, RemoteActorInfo, - RemoteGoalEntry, RemoteWatchlistEntry, Review, WatchlistWithMovie, + DiaryEntry, FederationFlags, PendingFollowerInfo, RemoteActorInfo, RemoteGoalEntry, + RemoteWatchlistEntry, WatchlistWithMovie, }, - value_objects::{MovieId, ReviewId, UserId}, + value_objects::{MovieId, UserId}, }; #[async_trait] pub trait SocialQueryPort: Send + Sync { async fn get_accepted_following_urls( &self, - user_id: uuid::Uuid, + user_id: &UserId, ) -> 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 count_following(&self, user_id: &UserId) -> Result; + async fn count_accepted_followers(&self, user_id: &UserId) -> Result; async fn get_pending_followers( &self, - user_id: uuid::Uuid, + user_id: &UserId, ) -> Result, DomainError>; } @@ -60,25 +60,16 @@ pub trait RemoteGoalRepository: Send + Sync { 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. +/// Federation-specific read-only queries that have no equivalent on the +/// standard domain ports (e.g. unpaginated watchlist, local-only review +/// listings). Generic lookups (get_movie_by_id, get_review_by_id, etc.) +/// live on MovieRepository, ReviewRepository, and the other domain ports. #[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 get_movie_by_external_metadata_id( - &self, - external_id: &str, - ) -> Result, DomainError>; - async fn count_local_posts(&self) -> Result; async fn get_local_reviews_for_movie( &self, movie_id: &MovieId, @@ -89,10 +80,4 @@ pub trait LocalApContentQuery: Send + Sync { before: Option, limit: usize, ) -> Result, DomainError>; - async fn get_goal_with_progress( - &self, - user_id: &UserId, - year: u16, - ) -> Result, DomainError>; - async fn list_goals_for_user(&self, user_id: &UserId) -> Result, DomainError>; } diff --git a/crates/domain/src/testing/fakes.rs b/crates/domain/src/testing/fakes.rs index 091565d..3f61655 100644 --- a/crates/domain/src/testing/fakes.rs +++ b/crates/domain/src/testing/fakes.rs @@ -191,7 +191,24 @@ impl DiaryRepository for FakeDiaryRepository { // ── FakeStatsRepository ───────────────────────────────────────────────────── -pub struct FakeStatsRepository; +pub struct FakeStatsRepository { + review_counts: Mutex>, +} + +impl FakeStatsRepository { + pub fn new() -> Arc { + Arc::new(Self { + review_counts: Mutex::new(HashMap::new()), + }) + } + + pub fn set_review_count(&self, user_id: Uuid, year: u16, count: u32) { + self.review_counts + .lock() + .unwrap() + .insert((user_id, year), count); + } +} #[async_trait] impl StatsRepository for FakeStatsRepository { @@ -211,6 +228,11 @@ impl StatsRepository for FakeStatsRepository { max_director_count: 0, }) } + + async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result { + let counts = self.review_counts.lock().unwrap(); + Ok(counts.get(&(user_id.value(), year)).copied().unwrap_or(0)) + } } // ── FakePersonQuery ───────────────────────────────────────────────────────── diff --git a/crates/domain/src/testing/in_memory.rs b/crates/domain/src/testing/in_memory.rs index a70644c..6c2365e 100644 --- a/crates/domain/src/testing/in_memory.rs +++ b/crates/domain/src/testing/in_memory.rs @@ -10,7 +10,6 @@ use chrono::Utc; use crate::{ errors::DomainError, - events::DomainEvent, models::{ FederationFlags, Goal, ImportProfile, ImportSession, Movie, MovieFilter, MovieProfile, MovieSummary, ProfileField, RefreshSession, Review, User, UserSettings, UserSummary, @@ -171,18 +170,12 @@ impl InMemoryReviewRepository { #[async_trait] impl ReviewRepository for InMemoryReviewRepository { - async fn save_review(&self, review: &Review) -> Result { + async fn save_review(&self, review: &Review) -> Result<(), DomainError> { self.store .lock() .unwrap() .insert(review.id().value(), review.clone()); - Ok(DomainEvent::ReviewLogged { - review_id: review.id().clone(), - movie_id: review.movie_id().clone(), - user_id: review.user_id().clone(), - rating: review.rating().clone(), - watched_at: *review.watched_at(), - }) + Ok(()) } async fn get_review_by_id(&self, review_id: &ReviewId) -> Result, DomainError> { @@ -345,27 +338,18 @@ impl WatchlistRepository for InMemoryWatchlistRepository { pub struct InMemoryGoalRepository { store: Mutex>, - review_counts: Mutex>, } impl InMemoryGoalRepository { pub fn new() -> Arc { Arc::new(Self { store: Mutex::new(HashMap::new()), - review_counts: Mutex::new(HashMap::new()), }) } pub fn count(&self) -> usize { self.store.lock().unwrap().len() } - - pub fn set_review_count(&self, user_id: Uuid, year: u16, count: u32) { - self.review_counts - .lock() - .unwrap() - .insert((user_id, year), count); - } } #[async_trait] @@ -416,11 +400,6 @@ impl GoalRepository for InMemoryGoalRepository { .cloned() .collect()) } - - async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result { - let counts = self.review_counts.lock().unwrap(); - Ok(counts.get(&(user_id.value(), year)).copied().unwrap_or(0)) - } } // ── InMemoryUserSettingsRepository ────────────────────────────────────────── diff --git a/crates/domain/src/testing/noops.rs b/crates/domain/src/testing/noops.rs index 29f1b47..0be1df1 100644 --- a/crates/domain/src/testing/noops.rs +++ b/crates/domain/src/testing/noops.rs @@ -97,7 +97,10 @@ pub struct NoopSocialQueryPort; #[async_trait] impl crate::ports::SocialQueryPort for NoopSocialQueryPort { - async fn get_accepted_following_urls(&self, _: uuid::Uuid) -> Result, DomainError> { + async fn get_accepted_following_urls( + &self, + _: &crate::value_objects::UserId, + ) -> Result, DomainError> { Ok(vec![]) } async fn list_all_followed_remote_actors( @@ -105,15 +108,21 @@ impl crate::ports::SocialQueryPort for NoopSocialQueryPort { ) -> Result, DomainError> { Ok(vec![]) } - async fn count_following(&self, _: uuid::Uuid) -> Result { + async fn count_following( + &self, + _: &crate::value_objects::UserId, + ) -> Result { Ok(0) } - async fn count_accepted_followers(&self, _: uuid::Uuid) -> Result { + async fn count_accepted_followers( + &self, + _: &crate::value_objects::UserId, + ) -> Result { Ok(0) } async fn get_pending_followers( &self, - _: uuid::Uuid, + _: &crate::value_objects::UserId, ) -> Result, DomainError> { Ok(vec![]) } @@ -148,9 +157,6 @@ impl crate::ports::GoalRepository for NoopGoalRepository { async fn list_for_user(&self, _: &UserId) -> Result, DomainError> { Ok(vec![]) } - async fn count_reviews_in_year(&self, _: &UserId, _: u16) -> Result { - Ok(0) - } } // ── NoopUserSettingsRepository ──────────────────────────────────────────────── diff --git a/crates/domain/src/testing/panics.rs b/crates/domain/src/testing/panics.rs index 2a304ed..7a6fdcf 100644 --- a/crates/domain/src/testing/panics.rs +++ b/crates/domain/src/testing/panics.rs @@ -80,6 +80,9 @@ impl StatsRepository for PanicStatsRepository { async fn get_user_trends(&self, _: &UserId) -> Result { panic!("PanicStatsRepository called") } + async fn count_reviews_in_year(&self, _: &UserId, _: u16) -> Result { + panic!("PanicStatsRepository called") + } } pub struct PanicImportSessionRepository; @@ -327,21 +330,30 @@ pub struct PanicSocialQueryPort; #[async_trait] impl crate::ports::SocialQueryPort for PanicSocialQueryPort { - async fn get_accepted_following_urls(&self, _: uuid::Uuid) -> Result, DomainError> { + async fn get_accepted_following_urls( + &self, + _: &crate::value_objects::UserId, + ) -> Result, DomainError> { panic!("PanicSocialQueryPort called") } async fn list_all_followed_remote_actors(&self) -> Result, DomainError> { panic!("PanicSocialQueryPort called") } - async fn count_following(&self, _: uuid::Uuid) -> Result { + async fn count_following( + &self, + _: &crate::value_objects::UserId, + ) -> Result { panic!("PanicSocialQueryPort called") } - async fn count_accepted_followers(&self, _: uuid::Uuid) -> Result { + async fn count_accepted_followers( + &self, + _: &crate::value_objects::UserId, + ) -> Result { panic!("PanicSocialQueryPort called") } async fn get_pending_followers( &self, - _: uuid::Uuid, + _: &crate::value_objects::UserId, ) -> Result, DomainError> { panic!("PanicSocialQueryPort called") } diff --git a/crates/domain/src/value_objects/review.rs b/crates/domain/src/value_objects/review.rs index 49e7d05..5758eb6 100644 --- a/crates/domain/src/value_objects/review.rs +++ b/crates/domain/src/value_objects/review.rs @@ -3,10 +3,12 @@ use std::str::FromStr; use crate::errors::DomainError; -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] pub enum WatchMedium { Cinema, Streaming, + #[serde(rename = "tv")] TV, PhysicalMedia, Download, diff --git a/crates/infra-wiring/Cargo.toml b/crates/infra-wiring/Cargo.toml new file mode 100644 index 0000000..fff101e --- /dev/null +++ b/crates/infra-wiring/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "infra-wiring" +version = "0.1.0" +edition = "2021" + +[dependencies] +sqlx = { workspace = true } +anyhow = { workspace = true } + +[features] +default = [] +sqlite = [] +postgres = ["sqlx/postgres"] +nats = [] diff --git a/crates/infra-wiring/src/config.rs b/crates/infra-wiring/src/config.rs new file mode 100644 index 0000000..37d3813 --- /dev/null +++ b/crates/infra-wiring/src/config.rs @@ -0,0 +1,50 @@ +#[derive(Clone)] +pub struct AppConfig { + pub allow_registration: bool, + pub base_url: String, + pub rate_limit: u64, + pub refresh_ttl_seconds: u64, + pub wrapup: WrapUpConfig, +} + +#[derive(Clone)] +pub struct WrapUpConfig { + pub font_path: Option, + pub logo_path: Option, + pub bg_dir: Option, +} + +impl AppConfig { + pub fn from_env() -> Self { + let allow_registration = std::env::var("ALLOW_REGISTRATION") + .map(|v| v == "true" || v == "1") + .unwrap_or(false); + let base_url = + std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:3000".to_string()); + let rate_limit = std::env::var("RATE_LIMIT") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(60); + let refresh_ttl_seconds = std::env::var("REFRESH_TTL_SECONDS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(2_592_000u64); + Self { + allow_registration, + base_url, + rate_limit, + refresh_ttl_seconds, + wrapup: WrapUpConfig::from_env(), + } + } +} + +impl WrapUpConfig { + pub fn from_env() -> Self { + Self { + font_path: std::env::var("WRAPUP_FONT_PATH").ok(), + logo_path: std::env::var("WRAPUP_LOGO_PATH").ok(), + bg_dir: std::env::var("WRAPUP_BG_DIR").ok(), + } + } +} diff --git a/crates/infra-wiring/src/lib.rs b/crates/infra-wiring/src/lib.rs new file mode 100644 index 0000000..dae26a9 --- /dev/null +++ b/crates/infra-wiring/src/lib.rs @@ -0,0 +1,35 @@ +pub mod config; + +pub use config::{AppConfig, WrapUpConfig}; + +pub enum DbPool { + #[cfg(feature = "sqlite")] + Sqlite(sqlx::SqlitePool), + #[cfg(feature = "postgres")] + Postgres(sqlx::PgPool), +} + +#[derive(Clone, Copy)] +pub enum EventBusBackend { + Db, + #[cfg(feature = "nats")] + Nats, +} + +impl EventBusBackend { + pub fn from_env() -> anyhow::Result { + match std::env::var("EVENT_BUS_BACKEND") + .unwrap_or_else(|_| "db".to_string()) + .as_str() + { + "db" => Ok(Self::Db), + #[cfg(feature = "nats")] + "nats" => Ok(Self::Nats), + #[cfg(not(feature = "nats"))] + "nats" => { + anyhow::bail!("EVENT_BUS_BACKEND=nats requires the nats feature to be compiled in") + } + other => anyhow::bail!("unknown EVENT_BUS_BACKEND={other}, expected 'db' or 'nats'"), + } + } +} diff --git a/crates/presentation/Cargo.toml b/crates/presentation/Cargo.toml index a982de0..f1a263c 100644 --- a/crates/presentation/Cargo.toml +++ b/crates/presentation/Cargo.toml @@ -7,9 +7,9 @@ license = "MIT" [features] default = ["sqlite", "sqlite-federation"] -sqlite = ["dep:sqlite", "dep:sqlite-event-queue", "dep:sqlite-search"] -postgres = ["dep:postgres", "dep:postgres-event-queue", "dep:postgres-search"] -nats = ["dep:nats"] +sqlite = ["dep:sqlite", "dep:sqlite-event-queue", "dep:sqlite-search", "infra-wiring/sqlite"] +postgres = ["dep:postgres", "dep:postgres-event-queue", "dep:postgres-search", "infra-wiring/postgres"] +nats = ["dep:nats", "infra-wiring/nats"] # Meta-feature: true when any federation adapter is active — keeps all #[cfg(feature = "federation")] gates working federation = ["application/federation"] sqlite-federation = [ @@ -59,6 +59,7 @@ importer = { workspace = true } jellyfin = { workspace = true } plex = { workspace = true } sqlx = { workspace = true } +infra-wiring = { workspace = true } utoipa = { version = "5.5.0", features = ["axum_extras", "uuid"] } utoipa-scalar = { version = "0.3.0", features = ["axum"], default-features = false } utoipa-swagger-ui = { version = "9.0.2", features = ["axum", "vendored"] } diff --git a/crates/presentation/src/factory.rs b/crates/presentation/src/factory.rs index b8f061f..aac0d2f 100644 --- a/crates/presentation/src/factory.rs +++ b/crates/presentation/src/factory.rs @@ -7,12 +7,7 @@ use domain::ports::{ WatchEventRepository, WebhookTokenRepository, }; -pub enum DbPool { - #[cfg(feature = "sqlite")] - Sqlite(sqlx::SqlitePool), - #[cfg(feature = "postgres")] - Postgres(sqlx::PgPool), -} +pub use infra_wiring::DbPool; pub struct DatabaseOutput { pub movie: Arc, diff --git a/crates/presentation/src/forms.rs b/crates/presentation/src/forms.rs index b99a21e..ee37522 100644 --- a/crates/presentation/src/forms.rs +++ b/crates/presentation/src/forms.rs @@ -221,7 +221,6 @@ impl TryFrom for LogReviewData { fn try_from(req: LogReviewRequest) -> Result { let watched_at = domain::value_objects::parse_watched_at(&req.watched_at)?; - let watch_medium = req.watch_medium.map(|s| s.parse()).transpose()?; Ok(Self { external_metadata_id: req.external_metadata_id.filter(|s| !s.trim().is_empty()), manual_title: req.manual_title, @@ -230,7 +229,7 @@ impl TryFrom for LogReviewData { rating: req.rating, comment: req.comment, watched_at, - watch_medium, + watch_medium: req.watch_medium, }) } } diff --git a/crates/presentation/src/handlers/auth.rs b/crates/presentation/src/handlers/auth.rs index c5cc0ba..d7e4165 100644 --- a/crates/presentation/src/handlers/auth.rs +++ b/crates/presentation/src/handlers/auth.rs @@ -21,10 +21,10 @@ use crate::{ render::render_page, state::AppState, }; +use api_types::HtmlPageContext; use api_types::{ LoginRequest, LoginResponse, LogoutRequest, RefreshRequest, RefreshResponse, RegisterRequest, }; -use application::rendering::HtmlPageContext; use template_askama::{LoginTemplate, RegisterTemplate}; // ── HTML helpers ───────────────────────────────────────────────────────────── diff --git a/crates/presentation/src/handlers/diary.rs b/crates/presentation/src/handlers/diary.rs index f1d4c9c..99b8a12 100644 --- a/crates/presentation/src/handlers/diary.rs +++ b/crates/presentation/src/handlers/diary.rs @@ -1,22 +1,18 @@ use axum::{ Form, Json, - body::Body, extract::{Extension, Path, Query, State}, http::StatusCode, response::{IntoResponse, Redirect}, }; -use futures::StreamExt; use uuid::Uuid; use application::diary::{ commands::{DeleteReviewCommand, EditReviewCommand}, delete_review, deps::{DeleteReviewDeps, EditReviewDeps, GetActivityFeedDeps}, - edit_review, export_diary as export_diary_uc, get_activity_feed as get_feed_uc, get_diary, - log_review, - queries::{ExportQuery, GetActivityFeedQuery}, + edit_review, get_activity_feed as get_feed_uc, get_diary, log_review, + queries::GetActivityFeedQuery, }; -use domain::models::ExportFormat; use crate::{ csrf::CsrfToken, @@ -32,12 +28,7 @@ use api_types::{ }; use template_askama::{ActivityFeedTemplate, NewReviewTemplate, build_page_items}; -use super::helpers::build_page_context; - -fn encode_error(msg: &str) -> String { - use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; - utf8_percent_encode(msg, NON_ALPHANUMERIC).to_string() -} +use super::helpers::{build_export_response, build_page_context, encode_error}; // ── API ────────────────────────────────────────────────────────────────────── @@ -146,22 +137,13 @@ pub async fn patch_review( .map(|s| domain::value_objects::parse_watched_at(&s).map_err(ApiError)) .transpose()?; - let watch_medium = req - .watch_medium - .map(|opt| { - opt.map(|s| s.parse::()) - .transpose() - .map_err(ApiError) - }) - .transpose()?; - let cmd = EditReviewCommand { review_id, requesting_user_id: user_id.value(), rating: req.rating, comment: req.comment, watched_at, - watch_medium, + watch_medium: req.watch_medium, }; let deps = EditReviewDeps { review: state.app_ctx.repos.review.clone(), @@ -186,42 +168,7 @@ pub async fn export_diary( user: AuthenticatedUser, Query(params): Query, ) -> impl IntoResponse { - let format = match params.format.as_str() { - "csv" => ExportFormat::Csv, - "json" => ExportFormat::Json, - _ => return StatusCode::BAD_REQUEST.into_response(), - }; - let (content_type, filename) = match &format { - ExportFormat::Csv => ("text/csv; charset=utf-8", "diary.csv"), - ExportFormat::Json => ("application/json", "diary.json"), - }; - let query = ExportQuery { - user_id: user.0.value(), - format, - }; - let stream = export_diary_uc::execute( - &state.app_ctx.repos.diary, - &state.app_ctx.services.diary_exporter, - query, - ); - let stream = stream.map(|r| { - if let Err(ref e) = r { - tracing::error!("diary export stream error: {e}"); - } - r - }); - ( - StatusCode::OK, - [ - (axum::http::header::CONTENT_TYPE, content_type.to_string()), - ( - axum::http::header::CONTENT_DISPOSITION, - format!("attachment; filename=\"{}\"", filename), - ), - ], - Body::from_stream(stream), - ) - .into_response() + build_export_response(¶ms.format, user.0.value(), &state) } #[utoipa::path( @@ -352,42 +299,7 @@ pub async fn get_export_html( RequiredCookieUser(user_id): RequiredCookieUser, Query(params): Query, ) -> impl IntoResponse { - let format = match params.format.as_str() { - "csv" => ExportFormat::Csv, - "json" => ExportFormat::Json, - _ => return StatusCode::BAD_REQUEST.into_response(), - }; - let (content_type, filename) = match &format { - ExportFormat::Csv => ("text/csv; charset=utf-8", "diary.csv"), - ExportFormat::Json => ("application/json", "diary.json"), - }; - let query = ExportQuery { - user_id: user_id.value(), - format, - }; - let stream = export_diary_uc::execute( - &state.app_ctx.repos.diary, - &state.app_ctx.services.diary_exporter, - query, - ); - let stream = stream.map(|r| { - if let Err(ref e) = r { - tracing::error!("diary export stream error: {e}"); - } - r - }); - ( - StatusCode::OK, - [ - (axum::http::header::CONTENT_TYPE, content_type.to_string()), - ( - axum::http::header::CONTENT_DISPOSITION, - format!("attachment; filename=\"{}\"", filename), - ), - ], - Body::from_stream(stream), - ) - .into_response() + build_export_response(¶ms.format, user_id.value(), &state) } pub async fn get_activity_feed_html( diff --git a/crates/presentation/src/handlers/goals.rs b/crates/presentation/src/handlers/goals.rs index 6257a31..f352e98 100644 --- a/crates/presentation/src/handlers/goals.rs +++ b/crates/presentation/src/handlers/goals.rs @@ -20,7 +20,7 @@ pub fn goal_with_progress_to_dto(g: &domain::models::GoalWithProgress) -> GoalDt current_count: g.current_count, percentage: g.percentage(), is_complete: g.is_complete(), - goal_type: g.goal.goal_type().as_str().to_string(), + goal_type: g.goal.goal_type().clone(), } } @@ -40,6 +40,7 @@ pub async fn list_goals( ) -> Result, ApiError> { let goals = application::goals::list::execute( state.app_ctx.repos.goal.clone(), + state.app_ctx.repos.stats.clone(), application::goals::queries::ListGoalsQuery { user_id: user.0.value(), }, @@ -66,6 +67,7 @@ pub async fn create_goal( ) -> Result, ApiError> { let g = application::goals::create::execute( state.app_ctx.repos.goal.clone(), + state.app_ctx.repos.stats.clone(), state.app_ctx.services.event_publisher.clone(), application::goals::commands::CreateGoalCommand { user_id: user.0.value(), @@ -95,6 +97,7 @@ pub async fn update_goal( ) -> Result, ApiError> { let g = application::goals::update::execute( state.app_ctx.repos.goal.clone(), + state.app_ctx.repos.stats.clone(), state.app_ctx.services.event_publisher.clone(), application::goals::commands::UpdateGoalCommand { user_id: user.0.value(), @@ -147,6 +150,7 @@ pub async fn get_user_goals( ) -> Result, ApiError> { let goals = application::goals::list::execute( state.app_ctx.repos.goal.clone(), + state.app_ctx.repos.stats.clone(), application::goals::queries::ListGoalsQuery { user_id }, ) .await?; diff --git a/crates/presentation/src/handlers/helpers.rs b/crates/presentation/src/handlers/helpers.rs index d599f5b..99969d3 100644 --- a/crates/presentation/src/handlers/helpers.rs +++ b/crates/presentation/src/handlers/helpers.rs @@ -1,8 +1,145 @@ -use application::rendering::HtmlPageContext; +use api_types::HtmlPageContext; use domain::value_objects::UserId; use crate::state::AppState; +pub(crate) fn encode_error(msg: &str) -> String { + use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; + utf8_percent_encode(msg, NON_ALPHANUMERIC).to_string() +} + +pub(crate) fn build_export_response( + format_str: &str, + user_id: uuid::Uuid, + state: &AppState, +) -> axum::response::Response { + use axum::{body::Body, http::StatusCode, response::IntoResponse}; + use futures::StreamExt; + + use application::diary::{export_diary as export_diary_uc, queries::ExportQuery}; + use domain::models::ExportFormat; + + let format = match format_str { + "csv" => ExportFormat::Csv, + "json" => ExportFormat::Json, + _ => return StatusCode::BAD_REQUEST.into_response(), + }; + let (content_type, filename) = match &format { + ExportFormat::Csv => ("text/csv; charset=utf-8", "diary.csv"), + ExportFormat::Json => ("application/json", "diary.json"), + }; + let query = ExportQuery { user_id, format }; + let stream = export_diary_uc::execute( + &state.app_ctx.repos.diary, + &state.app_ctx.services.diary_exporter, + query, + ); + let stream = stream.map(|r| { + if let Err(ref e) = r { + tracing::error!("diary export stream error: {e}"); + } + r + }); + ( + StatusCode::OK, + [ + (axum::http::header::CONTENT_TYPE, content_type.to_string()), + ( + axum::http::header::CONTENT_DISPOSITION, + format!("attachment; filename=\"{}\"", filename), + ), + ], + Body::from_stream(stream), + ) + .into_response() +} + +pub(crate) struct ProfileFormData { + pub display_name: Option, + pub bio: Option, + pub avatar_bytes: Option>, + pub avatar_content_type: Option, + pub banner_bytes: Option>, + pub banner_content_type: Option, + pub also_known_as: Option, + pub profile_field_names: std::collections::HashMap, + pub profile_field_values: std::collections::HashMap, +} + +pub(crate) async fn parse_profile_multipart( + mut multipart: axum::extract::Multipart, +) -> ProfileFormData { + let mut data = ProfileFormData { + display_name: None, + bio: None, + avatar_bytes: None, + avatar_content_type: None, + banner_bytes: None, + banner_content_type: None, + also_known_as: None, + profile_field_names: std::collections::HashMap::new(), + profile_field_values: std::collections::HashMap::new(), + }; + + while let Ok(Some(field)) = multipart.next_field().await { + let name = field.name().unwrap_or("").to_string(); + match name.as_str() { + "display_name" => { + if let Ok(text) = field.text().await { + data.display_name = Some(text).filter(|s| !s.is_empty()); + } + } + "bio" => { + if let Ok(text) = field.text().await { + data.bio = Some(text); + } + } + "also_known_as" => { + if let Ok(text) = field.text().await { + data.also_known_as = Some(text).filter(|s| !s.is_empty()); + } + } + "avatar" => { + let ct = field.content_type().map(|s| s.to_string()); + if let Ok(bytes) = field.bytes().await + && !bytes.is_empty() + { + data.avatar_bytes = Some(bytes.to_vec()); + data.avatar_content_type = ct; + } + } + "banner" => { + let ct = field.content_type().map(|s| s.to_string()); + if let Ok(bytes) = field.bytes().await + && !bytes.is_empty() + { + data.banner_bytes = Some(bytes.to_vec()); + data.banner_content_type = ct; + } + } + n if n.starts_with("field_name_") => { + if let Ok(idx) = n["field_name_".len()..].parse::() + && let Ok(text) = field.text().await + && !text.is_empty() + { + data.profile_field_names.insert(idx, text); + } + } + n if n.starts_with("field_value_") => { + if let Ok(idx) = n["field_value_".len()..].parse::() + && let Ok(text) = field.text().await + && !text.is_empty() + { + data.profile_field_values.insert(idx, text); + } + } + _ => {} + } + } + + data +} + pub(crate) async fn build_page_context( state: &AppState, user_id: Option, diff --git a/crates/presentation/src/handlers/import.rs b/crates/presentation/src/handlers/import.rs index f86c3fd..2ebf4be 100644 --- a/crates/presentation/src/handlers/import.rs +++ b/crates/presentation/src/handlers/import.rs @@ -22,6 +22,7 @@ use application::import::{ execute as execute_import, list_profiles as list_import_profiles, save_profile as save_import_profile, }; +use domain::errors::DomainError; use domain::models::{ AnnotatedRow, FieldMapping, FileFormat, import::{DomainField, Transform}, @@ -39,10 +40,7 @@ use crate::{ state::AppState, }; -fn encode_error(msg: &str) -> String { - use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; - utf8_percent_encode(msg, NON_ALPHANUMERIC).to_string() -} +use super::helpers::encode_error; fn str_to_domain_field(field: &str) -> Option { match field { @@ -461,7 +459,7 @@ pub async fn api_post_session( State(state): State, AuthenticatedUser(user_id): AuthenticatedUser, mut multipart: Multipart, -) -> impl IntoResponse { +) -> Result { let mut file_bytes: Option> = None; let mut format_str = "csv".to_string(); while let Ok(Some(field)) = multipart.next_field().await { @@ -481,20 +479,14 @@ pub async fn api_post_session( } let bytes = match file_bytes { Some(b) if !b.is_empty() => b, - _ => { - return ( - StatusCode::BAD_REQUEST, - axum::Json(serde_json::json!({"error": "no file"})), - ) - .into_response(); - } + _ => return Err(DomainError::ValidationError("no file".into()).into()), }; let format = match format_str.as_str() { "json" => FileFormat::Json, "xlsx" => FileFormat::Xlsx, _ => FileFormat::Csv, }; - match create_import_session::execute( + let r = create_import_session::execute( state.app_ctx.repos.import_session.clone(), state.app_ctx.services.document_parser.clone(), CreateImportSessionCommand { @@ -503,20 +495,12 @@ pub async fn api_post_session( format, }, ) - .await - { - Ok(r) => axum::Json(SessionCreatedResponse { - session_id: r.session_id.value().to_string(), - columns: r.columns, - sample_rows: r.sample_rows, - }) - .into_response(), - Err(e) => ( - StatusCode::UNPROCESSABLE_ENTITY, - axum::Json(serde_json::json!({"error": e.to_string()})), - ) - .into_response(), - } + .await?; + Ok(axum::Json(SessionCreatedResponse { + session_id: r.session_id.value().to_string(), + columns: r.columns, + sample_rows: r.sample_rows, + })) } #[utoipa::path( @@ -533,46 +517,26 @@ pub async fn api_get_session( State(state): State, AuthenticatedUser(user_id): AuthenticatedUser, Path(session_id_str): Path, -) -> impl IntoResponse { - let Ok(session_id) = session_id_str +) -> Result { + let session_id = session_id_str .parse::() .map(ImportSessionId::from_uuid) - else { - return ( - StatusCode::BAD_REQUEST, - axum::Json(serde_json::json!({"error": "invalid session id"})), - ) - .into_response(); - }; - match state + .map_err(|_| DomainError::ValidationError("invalid session id".into()))?; + let session = state .app_ctx .repos .import_session .get(&session_id, &user_id) - .await - { - Ok(Some(session)) => { - let parsed = session.parsed_file.unwrap_or_default(); - let row_count = parsed.rows.len(); - axum::Json(SessionStateResponse { - session_id: session_id_str, - columns: parsed.columns, - has_mappings: session.field_mappings.is_some(), - row_count, - }) - .into_response() - } - Ok(None) => ( - StatusCode::NOT_FOUND, - axum::Json(serde_json::json!({"error": "session not found"})), - ) - .into_response(), - Err(e) => ( - StatusCode::INTERNAL_SERVER_ERROR, - axum::Json(serde_json::json!({"error": e.to_string()})), - ) - .into_response(), - } + .await? + .ok_or(DomainError::NotFound("session not found".into()))?; + let parsed = session.parsed_file.unwrap_or_default(); + let row_count = parsed.rows.len(); + Ok(axum::Json(SessionStateResponse { + session_id: session_id_str, + columns: parsed.columns, + has_mappings: session.field_mappings.is_some(), + row_count, + })) } #[utoipa::path( @@ -591,17 +555,11 @@ pub async fn api_put_mapping( AuthenticatedUser(user_id): AuthenticatedUser, Path(session_id_str): Path, axum::Json(body): axum::Json, -) -> impl IntoResponse { - let Ok(session_id) = session_id_str +) -> Result { + let session_id = session_id_str .parse::() .map(ImportSessionId::from_uuid) - else { - return ( - StatusCode::BAD_REQUEST, - axum::Json(serde_json::json!({"error": "invalid session id"})), - ) - .into_response(); - }; + .map_err(|_| DomainError::ValidationError("invalid session id".into()))?; let mappings: Vec = body .mappings .into_iter() @@ -624,7 +582,7 @@ pub async fn api_put_mapping( }) .collect(); - match apply_import_mapping::execute( + let rows = apply_import_mapping::execute( state.app_ctx.repos.import_session.clone(), state.app_ctx.services.document_parser.clone(), state.app_ctx.repos.movie.clone(), @@ -634,15 +592,8 @@ pub async fn api_put_mapping( mappings, }, ) - .await - { - Ok(rows) => axum::Json(serde_json::json!({"row_count": rows.len()})).into_response(), - Err(e) => ( - StatusCode::UNPROCESSABLE_ENTITY, - axum::Json(serde_json::json!({"error": e.to_string()})), - ) - .into_response(), - } + .await?; + Ok(axum::Json(serde_json::json!({"row_count": rows.len()}))) } pub async fn api_get_preview( @@ -653,11 +604,7 @@ pub async fn api_get_preview( let session_id = session_id_str .parse::() .map(ImportSessionId::from_uuid) - .map_err(|_| { - ApiError(domain::errors::DomainError::ValidationError( - "invalid session id".into(), - )) - })?; + .map_err(|_| DomainError::ValidationError("invalid session id".into()))?; let session = state .app_ctx @@ -665,11 +612,7 @@ pub async fn api_get_preview( .import_session .get(&session_id, &user_id) .await? - .ok_or_else(|| { - ApiError(domain::errors::DomainError::NotFound( - "session not found".into(), - )) - })?; + .ok_or(DomainError::NotFound("session not found".into()))?; let annotated: Vec = session.row_results.unwrap_or_default(); let rows = annotated @@ -678,7 +621,18 @@ pub async fn api_get_preview( .map(|(i, a)| { use domain::models::import::RowResult; match &a.result { - RowResult::Valid(row) if a.is_duplicate => PreviewRowDto::Duplicate { + RowResult::Valid(row) if a.is_duplicate => { + PreviewRowDto::Duplicate(api_types::PreviewRowData { + index: i, + title: row.title.clone(), + release_year: row.release_year.clone(), + director: row.director.clone(), + rating: row.rating.clone(), + watched_at: row.watched_at.clone(), + comment: row.comment.clone(), + }) + } + RowResult::Valid(row) => PreviewRowDto::Valid(api_types::PreviewRowData { index: i, title: row.title.clone(), release_year: row.release_year.clone(), @@ -686,16 +640,7 @@ pub async fn api_get_preview( rating: row.rating.clone(), watched_at: row.watched_at.clone(), comment: row.comment.clone(), - }, - RowResult::Valid(row) => PreviewRowDto::Valid { - index: i, - title: row.title.clone(), - release_year: row.release_year.clone(), - director: row.director.clone(), - rating: row.rating.clone(), - watched_at: row.watched_at.clone(), - comment: row.comment.clone(), - }, + }), RowResult::Invalid { errors, .. } => PreviewRowDto::Invalid { index: i, errors: errors.clone(), @@ -723,32 +668,26 @@ pub async fn api_post_confirm( AuthenticatedUser(user_id): AuthenticatedUser, Path(session_id_str): Path, axum::Json(body): axum::Json, -) -> impl IntoResponse { - let Ok(session_id) = session_id_str +) -> Result { + let session_id = session_id_str .parse::() .map(ImportSessionId::from_uuid) - else { - return ( - StatusCode::BAD_REQUEST, - axum::Json(serde_json::json!({"error": "invalid session id"})), - ) - .into_response(); - }; - match execute_import::execute(state.app_ctx.repos.import_session.clone(), state.app_ctx.services.review_logger.clone(), ExecuteImportCommand { user_id: user_id.value(), session_id: session_id.value(), confirmed_indices: body.confirmed_indices }).await { - Ok(s) => axum::Json(serde_json::json!({ - "imported": s.imported, - "skipped_duplicates": s.skipped_duplicates, - "failed": s.failed.iter().map(|(i, e)| serde_json::json!({"index": i, "error": e})).collect::>(), - })).into_response(), - Err(e) => { - let status = if matches!(e, domain::errors::DomainError::NotFound(_)) { - StatusCode::NOT_FOUND - } else { - StatusCode::INTERNAL_SERVER_ERROR - }; - (status, axum::Json(serde_json::json!({"error": e.to_string()}))).into_response() - } - } + .map_err(|_| DomainError::ValidationError("invalid session id".into()))?; + let s = execute_import::execute( + state.app_ctx.repos.import_session.clone(), + state.app_ctx.services.review_logger.clone(), + ExecuteImportCommand { + user_id: user_id.value(), + session_id: session_id.value(), + confirmed_indices: body.confirmed_indices, + }, + ) + .await?; + Ok(axum::Json(serde_json::json!({ + "imported": s.imported, + "skipped_duplicates": s.skipped_duplicates, + "failed": s.failed.iter().map(|(i, e)| serde_json::json!({"index": i, "error": e})).collect::>(), + }))) } #[utoipa::path( @@ -762,28 +701,21 @@ pub async fn api_post_confirm( pub async fn api_get_profiles( State(state): State, AuthenticatedUser(user_id): AuthenticatedUser, -) -> impl IntoResponse { - match list_import_profiles::execute(state.app_ctx.repos.import_profile.clone(), &user_id).await - { - Ok(profiles) => axum::Json( - profiles - .into_iter() - .map(|p| { - serde_json::json!({ - "id": p.id.value().to_string(), - "name": p.name, - "created_at": p.created_at.to_string(), - }) +) -> Result { + let profiles = + list_import_profiles::execute(state.app_ctx.repos.import_profile.clone(), &user_id).await?; + Ok(axum::Json( + profiles + .into_iter() + .map(|p| { + serde_json::json!({ + "id": p.id.value().to_string(), + "name": p.name, + "created_at": p.created_at.to_string(), }) - .collect::>(), - ) - .into_response(), - Err(e) => ( - StatusCode::INTERNAL_SERVER_ERROR, - axum::Json(serde_json::json!({"error": e.to_string()})), - ) - .into_response(), - } + }) + .collect::>(), + )) } #[utoipa::path( @@ -800,19 +732,13 @@ pub async fn api_post_profile( State(state): State, AuthenticatedUser(user_id): AuthenticatedUser, axum::Json(body): axum::Json, -) -> impl IntoResponse { - let Ok(session_id) = body +) -> Result { + let session_id = body .session_id .parse::() .map(ImportSessionId::from_uuid) - else { - return ( - StatusCode::BAD_REQUEST, - axum::Json(serde_json::json!({"error": "invalid session id"})), - ) - .into_response(); - }; - match save_import_profile::execute( + .map_err(|_| DomainError::ValidationError("invalid session id".into()))?; + let id = save_import_profile::execute( state.app_ctx.repos.import_session.clone(), state.app_ctx.repos.import_profile.clone(), SaveImportProfileCommand { @@ -821,15 +747,10 @@ pub async fn api_post_profile( name: body.name, }, ) - .await - { - Ok(id) => axum::Json(serde_json::json!({"id": id.value().to_string()})).into_response(), - Err(e) => ( - StatusCode::UNPROCESSABLE_ENTITY, - axum::Json(serde_json::json!({"error": e.to_string()})), - ) - .into_response(), - } + .await?; + Ok(axum::Json( + serde_json::json!({"id": id.value().to_string()}), + )) } #[utoipa::path( @@ -846,29 +767,19 @@ pub async fn api_delete_profile( State(state): State, AuthenticatedUser(user_id): AuthenticatedUser, Path(profile_id_str): Path, -) -> impl IntoResponse { - let Ok(profile_id) = profile_id_str.parse::() else { - return StatusCode::BAD_REQUEST.into_response(); - }; - match delete_import_profile::execute( +) -> Result { + let profile_id = profile_id_str + .parse::() + .map_err(|_| DomainError::ValidationError("invalid profile id".into()))?; + delete_import_profile::execute( state.app_ctx.repos.import_profile.clone(), DeleteImportProfileCommand { user_id: user_id.value(), profile_id, }, ) - .await - { - Ok(_) => StatusCode::NO_CONTENT.into_response(), - Err(e) => { - let status = if matches!(e, domain::errors::DomainError::NotFound(_)) { - StatusCode::NOT_FOUND - } else { - StatusCode::INTERNAL_SERVER_ERROR - }; - status.into_response() - } - } + .await?; + Ok(StatusCode::NO_CONTENT) } #[utoipa::path( @@ -890,23 +801,15 @@ pub async fn api_apply_profile( State(state): State, AuthenticatedUser(user_id): AuthenticatedUser, Path((session_id_str, profile_id_str)): Path<(String, String)>, -) -> impl IntoResponse { - let Ok(session_id) = session_id_str.parse::() else { - return ( - StatusCode::BAD_REQUEST, - axum::Json(serde_json::json!({"error": "invalid session id"})), - ) - .into_response(); - }; - let Ok(profile_id) = profile_id_str.parse::() else { - return ( - StatusCode::BAD_REQUEST, - axum::Json(serde_json::json!({"error": "invalid profile id"})), - ) - .into_response(); - }; +) -> Result { + let session_id = session_id_str + .parse::() + .map_err(|_| DomainError::ValidationError("invalid session id".into()))?; + let profile_id = profile_id_str + .parse::() + .map_err(|_| DomainError::ValidationError("invalid profile id".into()))?; - if let Err(e) = apply_import_profile::execute( + apply_import_profile::execute( state.app_ctx.repos.import_profile.clone(), state.app_ctx.repos.import_session.clone(), ApplyImportProfileCommand { @@ -915,39 +818,20 @@ pub async fn api_apply_profile( profile_id, }, ) - .await - { - let status = if matches!(e, domain::errors::DomainError::NotFound(_)) { - StatusCode::NOT_FOUND - } else { - StatusCode::UNPROCESSABLE_ENTITY - }; - return ( - status, - axum::Json(serde_json::json!({"error": e.to_string()})), - ) - .into_response(); - } + .await?; - let session = match state + let session = state .app_ctx .repos .import_session .get(&ImportSessionId::from_uuid(session_id), &user_id) - .await - { - Ok(Some(s)) => s, - _ => { - return ( - StatusCode::NOT_FOUND, - axum::Json(serde_json::json!({"error": "session not found after profile apply"})), - ) - .into_response(); - } - }; + .await? + .ok_or(DomainError::NotFound( + "session not found after profile apply".into(), + ))?; let mappings = session.field_mappings.unwrap_or_default(); - match apply_import_mapping::execute( + let rows = apply_import_mapping::execute( state.app_ctx.repos.import_session.clone(), state.app_ctx.services.document_parser.clone(), state.app_ctx.repos.movie.clone(), @@ -957,13 +841,6 @@ pub async fn api_apply_profile( mappings, }, ) - .await - { - Ok(rows) => axum::Json(serde_json::json!({"row_count": rows.len()})).into_response(), - Err(e) => ( - StatusCode::UNPROCESSABLE_ENTITY, - axum::Json(serde_json::json!({"error": e.to_string()})), - ) - .into_response(), - } + .await?; + Ok(axum::Json(serde_json::json!({"row_count": rows.len()}))) } diff --git a/crates/presentation/src/handlers/integrations.rs b/crates/presentation/src/handlers/integrations.rs index 8cb8814..c0ff4c7 100644 --- a/crates/presentation/src/handlers/integrations.rs +++ b/crates/presentation/src/handlers/integrations.rs @@ -24,12 +24,7 @@ use crate::{ }; use template_askama::{IntegrationsTemplate, WatchQueueTemplate}; -use super::helpers::build_page_context; - -fn encode_error(msg: &str) -> String { - use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; - utf8_percent_encode(msg, NON_ALPHANUMERIC).to_string() -} +use super::helpers::{build_page_context, encode_error}; // ── HTML ───────────────────────────────────────────────────────────────────── diff --git a/crates/presentation/src/handlers/movies.rs b/crates/presentation/src/handlers/movies.rs index dcebf2a..3c908a8 100644 --- a/crates/presentation/src/handlers/movies.rs +++ b/crates/presentation/src/handlers/movies.rs @@ -185,7 +185,7 @@ pub async fn get_movie_detail( comment: e.review().comment().map(|c| c.value().to_string()), watched_at: domain::value_objects::format_watched_at(e.review().watched_at()), is_federated: e.review().is_remote(), - watch_medium: e.review().watch_medium().map(|wm| wm.to_string()), + watch_medium: e.review().watch_medium().copied(), }) .collect(), total_count: result.reviews.total_count, diff --git a/crates/presentation/src/handlers/search.rs b/crates/presentation/src/handlers/search.rs index be86f3c..4fafd55 100644 --- a/crates/presentation/src/handlers/search.rs +++ b/crates/presentation/src/handlers/search.rs @@ -13,7 +13,7 @@ use domain::models::{PersonId, collections::PageParams}; use crate::state::AppState; use api_types::search::{ CastCreditDto, CrewCreditDto, MovieSearchHitDto, PaginatedMovieHits, PaginatedPersonHits, - PersonCreditsDto, PersonDto, PersonSearchHitDto, SearchQueryParams, SearchResponse, + PersonCreditsDto, PersonSearchHitDto, SearchQueryParams, SearchResponse, }; // ── API ────────────────────────────────────────────────────────────────────── @@ -106,24 +106,9 @@ pub async fn get_person_handler( event_publisher: state.app_ctx.services.event_publisher.clone(), }; match get_person::execute(&deps, PersonId::from_uuid(id)).await { - Ok(Some(person)) => axum::Json(PersonDto { - id: person.id().value(), - external_id: person.external_id().value().to_string(), - name: person.name().to_string(), - known_for_department: person.known_for_department().map(str::to_string), - profile_path: person.profile_path().map(str::to_string), - biography: person.biography().map(str::to_string), - birthday: person.birthday().map(|d| d.to_string()), - deathday: person.deathday().map(|d| d.to_string()), - place_of_birth: person.place_of_birth().map(str::to_string), - also_known_as: person.also_known_as().to_vec(), - homepage: person.homepage().map(str::to_string), - imdb_url: person - .imdb_id() - .map(|id| format!("https://www.imdb.com/name/{id}")), - enriched: person.enriched_at().is_some(), - }) - .into_response(), + Ok(Some(person)) => { + axum::Json(crate::mappers::search::person_to_dto(&person)).into_response() + } Ok(None) => StatusCode::NOT_FOUND.into_response(), Err(e) => crate::errors::domain_error_response(e), } @@ -148,24 +133,7 @@ pub async fn get_person_credits_handler( }; match get_person_credits::execute(&deps, PersonId::from_uuid(id)).await { Ok(credits) => axum::Json(PersonCreditsDto { - person: PersonDto { - id: credits.person.id().value(), - external_id: credits.person.external_id().value().to_string(), - name: credits.person.name().to_string(), - known_for_department: credits.person.known_for_department().map(str::to_string), - profile_path: credits.person.profile_path().map(str::to_string), - biography: credits.person.biography().map(str::to_string), - birthday: credits.person.birthday().map(|d| d.to_string()), - deathday: credits.person.deathday().map(|d| d.to_string()), - place_of_birth: credits.person.place_of_birth().map(str::to_string), - also_known_as: credits.person.also_known_as().to_vec(), - homepage: credits.person.homepage().map(str::to_string), - imdb_url: credits - .person - .imdb_id() - .map(|id| format!("https://www.imdb.com/name/{id}")), - enriched: credits.person.enriched_at().is_some(), - }, + person: crate::mappers::search::person_to_dto(&credits.person), cast: credits .cast .iter() diff --git a/crates/presentation/src/handlers/social.rs b/crates/presentation/src/handlers/social.rs index c1d53b1..af18dff 100644 --- a/crates/presentation/src/handlers/social.rs +++ b/crates/presentation/src/handlers/social.rs @@ -9,7 +9,7 @@ use uuid::Uuid; use crate::{ csrf::CsrfToken, errors::ApiError, - extractors::{AuthenticatedUser, RequiredCookieUser}, + extractors::{AdminApiUser, AuthenticatedUser, RequiredCookieUser}, forms::{ ActorUrlForm, BlockDomainForm, FollowForm, FollowerActionForm, RemoveDomainForm, UnfollowForm, @@ -19,24 +19,14 @@ use crate::{ }; use api_types::{ ActorListResponse, ActorUrlRequest, AddBlockedDomainRequest, BlockedActorResponse, - BlockedDomainResponse, FollowRequest, RemoteActorDto, + BlockedDomainResponse, FollowRequest, }; use template_askama::{ BlockedActorsTemplate, BlockedDomainsTemplate, FollowersTemplate, FollowingTemplate, RemoteActorData, }; -use super::helpers::build_page_context; - -fn encode_error(msg: &str) -> String { - use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; - utf8_percent_encode(msg, NON_ALPHANUMERIC).to_string() -} - -fn ap_err(e: anyhow::Error) -> impl IntoResponse { - tracing::error!("ActivityPub error: {:?}", e); - StatusCode::INTERNAL_SERVER_ERROR -} +use super::helpers::{build_page_context, encode_error}; fn ap_to_domain(e: anyhow::Error) -> domain::errors::DomainError { tracing::error!("ActivityPub error: {:?}", e); @@ -56,22 +46,23 @@ fn ap_to_domain(e: anyhow::Error) -> domain::errors::DomainError { )] pub async fn get_blocked_domains_admin( State(state): State, - _admin: crate::extractors::AdminUser, -) -> impl IntoResponse { - match state.ap_service.get_blocked_domains().await { - Ok(domains) => { - let response: Vec = domains - .into_iter() - .map(|d| BlockedDomainResponse { - domain: d.domain, - reason: d.reason, - blocked_at: d.blocked_at, - }) - .collect(); - axum::Json(response).into_response() - } - Err(e) => ap_err(e).into_response(), - } + _admin: AdminApiUser, +) -> Result>, ApiError> { + let domains = state + .ap_service + .get_blocked_domains() + .await + .map_err(ap_to_domain)?; + Ok(Json( + domains + .into_iter() + .map(|d| BlockedDomainResponse { + domain: d.domain, + reason: d.reason, + blocked_at: d.blocked_at, + }) + .collect(), + )) } #[utoipa::path( @@ -86,17 +77,15 @@ pub async fn get_blocked_domains_admin( )] pub async fn add_blocked_domain_admin( State(state): State, - _admin: crate::extractors::AdminUser, + _admin: AdminApiUser, axum::Json(body): axum::Json, -) -> impl IntoResponse { - match state +) -> Result { + state .ap_service .add_blocked_domain(&body.domain, body.reason.as_deref()) .await - { - Ok(()) => StatusCode::CREATED.into_response(), - Err(e) => ap_err(e).into_response(), - } + .map_err(ap_to_domain)?; + Ok(StatusCode::CREATED) } #[utoipa::path( @@ -111,13 +100,15 @@ pub async fn add_blocked_domain_admin( )] pub async fn remove_blocked_domain_admin( State(state): State, - _admin: crate::extractors::AdminUser, + _admin: AdminApiUser, axum::extract::Path(domain): axum::extract::Path, -) -> impl IntoResponse { - match state.ap_service.remove_blocked_domain(&domain).await { - Ok(()) => StatusCode::NO_CONTENT.into_response(), - Err(e) => ap_err(e).into_response(), - } +) -> Result { + state + .ap_service + .remove_blocked_domain(&domain) + .await + .map_err(ap_to_domain)?; + Ok(StatusCode::NO_CONTENT) } #[utoipa::path( @@ -133,15 +124,13 @@ pub async fn block_actor_api( State(state): State, user: AuthenticatedUser, axum::Json(body): axum::Json, -) -> impl IntoResponse { - match state +) -> Result { + state .ap_service .block_actor(user.0.value(), &body.actor_url) .await - { - Ok(()) => StatusCode::NO_CONTENT.into_response(), - Err(e) => ap_err(e).into_response(), - } + .map_err(ap_to_domain)?; + Ok(StatusCode::NO_CONTENT) } #[utoipa::path( @@ -157,15 +146,13 @@ pub async fn unblock_actor_api( State(state): State, user: AuthenticatedUser, axum::Json(body): axum::Json, -) -> impl IntoResponse { - match state +) -> Result { + state .ap_service .unblock_actor(user.0.value(), &body.actor_url) .await - { - Ok(()) => StatusCode::NO_CONTENT.into_response(), - Err(e) => ap_err(e).into_response(), - } + .map_err(ap_to_domain)?; + Ok(StatusCode::NO_CONTENT) } #[utoipa::path( @@ -179,22 +166,23 @@ pub async fn unblock_actor_api( pub async fn get_blocked_actors_api( State(state): State, user: AuthenticatedUser, -) -> impl IntoResponse { - match state.ap_service.get_blocked_actors(user.0.value()).await { - Ok(actors) => { - let response: Vec = actors - .into_iter() - .map(|a| BlockedActorResponse { - url: a.url, - handle: a.handle, - display_name: a.display_name, - avatar_url: a.avatar_url, - }) - .collect(); - axum::Json(response).into_response() - } - Err(e) => ap_err(e).into_response(), - } +) -> Result>, ApiError> { + let actors = state + .ap_service + .get_blocked_actors(user.0.value()) + .await + .map_err(ap_to_domain)?; + Ok(Json( + actors + .into_iter() + .map(|a| BlockedActorResponse { + url: a.url, + handle: a.handle, + display_name: a.display_name, + avatar_url: a.avatar_url, + }) + .collect(), + )) } #[utoipa::path( @@ -208,21 +196,18 @@ pub async fn get_blocked_actors_api( pub async fn get_following( State(state): State, user: AuthenticatedUser, -) -> impl IntoResponse { - match state.ap_service.get_following(user.0.value()).await { - Ok(actors) => Json(ActorListResponse { - actors: actors - .into_iter() - .map(|a| RemoteActorDto { - handle: a.handle, - display_name: a.display_name, - url: a.url, - }) - .collect(), - }) - .into_response(), - Err(e) => ap_err(e).into_response(), - } +) -> Result, ApiError> { + let actors = state + .ap_service + .get_following(user.0.value()) + .await + .map_err(ap_to_domain)?; + Ok(Json(ActorListResponse { + actors: actors + .into_iter() + .map(crate::mappers::social::remote_actor_to_dto) + .collect(), + })) } #[utoipa::path( @@ -236,25 +221,18 @@ pub async fn get_following( pub async fn get_followers( State(state): State, user: AuthenticatedUser, -) -> impl IntoResponse { - match state +) -> Result, ApiError> { + let actors = state .ap_service .get_accepted_followers(user.0.value()) .await - { - Ok(actors) => Json(ActorListResponse { - actors: actors - .into_iter() - .map(|a| RemoteActorDto { - handle: a.handle, - display_name: a.display_name, - url: a.url, - }) - .collect(), - }) - .into_response(), - Err(e) => ap_err(e).into_response(), - } + .map_err(ap_to_domain)?; + Ok(Json(ActorListResponse { + actors: actors + .into_iter() + .map(crate::mappers::social::remote_actor_to_dto) + .collect(), + })) } pub async fn get_user_following( @@ -270,11 +248,7 @@ pub async fn get_user_following( Ok(Json(ActorListResponse { actors: actors .into_iter() - .map(|a| RemoteActorDto { - handle: a.handle, - display_name: a.display_name, - url: a.url, - }) + .map(crate::mappers::social::remote_actor_to_dto) .collect(), })) } @@ -292,11 +266,7 @@ pub async fn get_user_followers( Ok(Json(ActorListResponse { actors: actors .into_iter() - .map(|a| RemoteActorDto { - handle: a.handle, - display_name: a.display_name, - url: a.url, - }) + .map(crate::mappers::social::remote_actor_to_dto) .collect(), })) } @@ -314,11 +284,13 @@ pub async fn follow( State(state): State, user: AuthenticatedUser, Json(body): Json, -) -> impl IntoResponse { - match state.ap_service.follow(user.0.value(), &body.handle).await { - Ok(()) => StatusCode::OK.into_response(), - Err(e) => ap_err(e).into_response(), - } +) -> Result { + state + .ap_service + .follow(user.0.value(), &body.handle) + .await + .map_err(ap_to_domain)?; + Ok(StatusCode::OK) } #[utoipa::path( @@ -334,15 +306,13 @@ pub async fn unfollow( State(state): State, user: AuthenticatedUser, Json(body): Json, -) -> impl IntoResponse { - match state +) -> Result { + state .ap_service .unfollow(user.0.value(), &body.actor_url) .await - { - Ok(()) => StatusCode::OK.into_response(), - Err(e) => ap_err(e).into_response(), - } + .map_err(ap_to_domain)?; + Ok(StatusCode::OK) } #[utoipa::path( @@ -358,15 +328,13 @@ pub async fn accept_follower( State(state): State, user: AuthenticatedUser, Json(body): Json, -) -> impl IntoResponse { - match state +) -> Result { + state .ap_service .accept_follower(user.0.value(), &body.actor_url) .await - { - Ok(()) => StatusCode::OK.into_response(), - Err(e) => ap_err(e).into_response(), - } + .map_err(ap_to_domain)?; + Ok(StatusCode::OK) } #[utoipa::path( @@ -382,15 +350,13 @@ pub async fn reject_follower( State(state): State, user: AuthenticatedUser, Json(body): Json, -) -> impl IntoResponse { - match state +) -> Result { + state .ap_service .reject_follower(user.0.value(), &body.actor_url) .await - { - Ok(()) => StatusCode::OK.into_response(), - Err(e) => ap_err(e).into_response(), - } + .map_err(ap_to_domain)?; + Ok(StatusCode::OK) } #[utoipa::path( @@ -406,15 +372,13 @@ pub async fn remove_follower( State(state): State, user: AuthenticatedUser, Json(body): Json, -) -> impl IntoResponse { - match state +) -> Result { + state .ap_service .remove_follower(user.0.value(), &body.actor_url) .await - { - Ok(()) => StatusCode::OK.into_response(), - Err(e) => ap_err(e).into_response(), - } + .map_err(ap_to_domain)?; + Ok(StatusCode::OK) } #[utoipa::path( @@ -428,21 +392,18 @@ pub async fn remove_follower( pub async fn get_pending_followers( State(state): State, user: AuthenticatedUser, -) -> impl IntoResponse { - match state.ap_service.get_pending_followers(user.0.value()).await { - Ok(actors) => Json(ActorListResponse { - actors: actors - .into_iter() - .map(|a| RemoteActorDto { - handle: a.handle, - display_name: a.display_name, - url: a.url, - }) - .collect(), - }) - .into_response(), - Err(e) => ap_err(e).into_response(), - } +) -> Result, ApiError> { + let actors = state + .ap_service + .get_pending_followers(user.0.value()) + .await + .map_err(ap_to_domain)?; + Ok(Json(ActorListResponse { + actors: actors + .into_iter() + .map(crate::mappers::social::remote_actor_to_dto) + .collect(), + })) } // ── HTML ───────────────────────────────────────────────────────────────────── diff --git a/crates/presentation/src/handlers/users.rs b/crates/presentation/src/handlers/users.rs index 2471225..d7496c5 100644 --- a/crates/presentation/src/handlers/users.rs +++ b/crates/presentation/src/handlers/users.rs @@ -61,15 +61,17 @@ pub async fn get_profile( .await?; let base_url = &state.app_ctx.config.base_url; Ok(Json(ProfileResponse { - username: profile.username, - display_name: profile.display_name, - bio: profile.bio, - avatar_url: profile - .avatar_path - .map(|p| format!("{}/images/{}", base_url, p)), - banner_url: profile - .banner_path - .map(|p| format!("{}/images/{}", base_url, p)), + profile: api_types::UserProfileBase { + username: profile.username, + display_name: profile.display_name, + bio: profile.bio, + avatar_url: profile + .avatar_path + .map(|p| format!("{}/images/{}", base_url, p)), + banner_url: profile + .banner_path + .map(|p| format!("{}/images/{}", base_url, p)), + }, also_known_as: profile.also_known_as, fields: profile .fields @@ -96,65 +98,19 @@ pub async fn get_profile( pub async fn update_profile_handler( State(state): State, AuthenticatedUser(user_id): AuthenticatedUser, - mut multipart: Multipart, + multipart: Multipart, ) -> impl IntoResponse { - let mut display_name: Option = None; - let mut bio: Option = None; - let mut avatar_bytes: Option> = None; - let mut avatar_content_type: Option = None; - let mut banner_bytes: Option> = None; - let mut banner_content_type: Option = None; - let mut also_known_as: Option = None; - - while let Ok(Some(field)) = multipart.next_field().await { - let name = field.name().unwrap_or("").to_string(); - match name.as_str() { - "display_name" => { - if let Ok(text) = field.text().await { - display_name = Some(text).filter(|s| !s.is_empty()); - } - } - "bio" => { - if let Ok(text) = field.text().await { - bio = Some(text); - } - } - "also_known_as" => { - if let Ok(text) = field.text().await { - also_known_as = Some(text).filter(|s| !s.is_empty()); - } - } - "avatar" => { - let ct = field.content_type().map(|s| s.to_string()); - if let Ok(bytes) = field.bytes().await - && !bytes.is_empty() - { - avatar_bytes = Some(bytes.to_vec()); - avatar_content_type = ct; - } - } - "banner" => { - let ct = field.content_type().map(|s| s.to_string()); - if let Ok(bytes) = field.bytes().await - && !bytes.is_empty() - { - banner_bytes = Some(bytes.to_vec()); - banner_content_type = ct; - } - } - _ => {} - } - } + let data = super::helpers::parse_profile_multipart(multipart).await; let cmd = application::users::commands::UpdateProfileCommand { user_id: user_id.value(), - display_name, - bio, - avatar_bytes, - avatar_content_type, - banner_bytes, - banner_content_type, - also_known_as, + display_name: data.display_name, + bio: data.bio, + avatar_bytes: data.avatar_bytes, + avatar_content_type: data.avatar_content_type, + banner_bytes: data.banner_bytes, + banner_content_type: data.banner_content_type, + also_known_as: data.also_known_as, }; let deps = UpdateProfileDeps { @@ -325,7 +281,7 @@ pub async fn get_user_profile( }); let history = profile.history.map(|entries| { - crate::mappers::users::group_by_month(entries) + application::users::group_by_month(entries) .into_iter() .map(|m| MonthActivityDto { year_month: m.year_month, @@ -364,13 +320,17 @@ pub async fn get_user_profile( Json(UserProfileResponse { user_id, - username: user.username().value().to_string(), - avatar_url: user - .avatar_path() - .map(|p| format!("{}/images/{}", state.app_ctx.config.base_url, p)), - banner_url: user - .banner_path() - .map(|p| format!("{}/images/{}", state.app_ctx.config.base_url, p)), + profile: api_types::UserProfileBase { + username: user.username().value().to_string(), + avatar_url: user + .avatar_path() + .map(|p| format!("{}/images/{}", state.app_ctx.config.base_url, p)), + banner_url: user + .banner_path() + .map(|p| format!("{}/images/{}", state.app_ctx.config.base_url, p)), + display_name: None, + bio: None, + }, stats: UserStatsDto { total_movies: profile.stats.total_movies, avg_rating: profile.stats.avg_rating, @@ -385,6 +345,7 @@ pub async fn get_user_profile( goals: { let goals_list = application::goals::list::execute( state.app_ctx.repos.goal.clone(), + state.app_ctx.repos.stats.clone(), application::goals::queries::ListGoalsQuery { user_id }, ) .await @@ -397,8 +358,6 @@ pub async fn get_user_profile( }, is_federated: false, handle: None, - display_name: None, - bio: None, actor_url: None, }) .into_response() @@ -475,9 +434,13 @@ async fn build_federated_profile_response( Json(UserProfileResponse { user_id, - username, - avatar_url: fed.avatar_url, - banner_url: fed.banner_url, + profile: api_types::UserProfileBase { + username, + avatar_url: fed.avatar_url, + banner_url: fed.banner_url, + display_name: fed.display_name, + bio: fed.bio, + }, stats: UserStatsDto { total_movies: profile.stats.total_movies, avg_rating: profile.stats.avg_rating, @@ -492,8 +455,6 @@ async fn build_federated_profile_response( goals: None, is_federated: true, handle: Some(fed.handle), - display_name: fed.display_name, - bio: fed.bio, actor_url: Some(fed.actor_url), }) .into_response() @@ -687,7 +648,7 @@ pub async fn get_user_profile_html( .most_active_month .clone() .unwrap_or_else(|| "\u{2014}".to_string()); - let history = profile.history.map(crate::mappers::users::group_by_month); + let history = profile.history.map(application::users::group_by_month); let heatmap = history.as_deref().map(build_heatmap).unwrap_or_default(); let monthly_rating_rows: Vec> = profile .trends @@ -775,6 +736,7 @@ pub async fn get_user_profile_html( goals: { let goals_list = application::goals::list::execute( state.app_ctx.repos.goal.clone(), + state.app_ctx.repos.stats.clone(), application::goals::queries::ListGoalsQuery { user_id: profile_user_uuid, }, @@ -865,100 +827,45 @@ pub async fn get_profile_settings( pub async fn post_profile_settings( RequiredCookieUser(user_id): RequiredCookieUser, State(state): State, - mut multipart: Multipart, + multipart: Multipart, ) -> impl IntoResponse { - let mut display_name: Option = None; - let mut bio: Option = None; - let mut avatar_bytes: Option> = None; - let mut avatar_content_type: Option = None; - let mut banner_bytes: Option> = None; - let mut banner_content_type: Option = None; - let mut also_known_as: Option = None; - let mut field_names: std::collections::HashMap = - std::collections::HashMap::new(); - let mut field_values: std::collections::HashMap = - std::collections::HashMap::new(); - - while let Ok(Some(field)) = multipart.next_field().await { - let name = field.name().unwrap_or("").to_string(); - match name.as_str() { - "display_name" => { - if let Ok(text) = field.text().await { - display_name = Some(text).filter(|s| !s.is_empty()); - } - } - "bio" => { - if let Ok(text) = field.text().await { - bio = Some(text); - } - } - "also_known_as" => { - if let Ok(text) = field.text().await { - also_known_as = Some(text).filter(|s| !s.is_empty()); - } - } - "avatar" => { - let ct = field.content_type().map(|s| s.to_string()); - if let Ok(bytes) = field.bytes().await - && !bytes.is_empty() - { - avatar_bytes = Some(bytes.to_vec()); - avatar_content_type = ct; - } - } - "banner" => { - let ct = field.content_type().map(|s| s.to_string()); - if let Ok(bytes) = field.bytes().await - && !bytes.is_empty() - { - banner_bytes = Some(bytes.to_vec()); - banner_content_type = ct; - } - } - n if n.starts_with("field_name_") => { - if let Ok(idx) = n["field_name_".len()..].parse::() - && let Ok(text) = field.text().await - && !text.is_empty() - { - field_names.insert(idx, text); - } - } - n if n.starts_with("field_value_") => { - if let Ok(idx) = n["field_value_".len()..].parse::() - && let Ok(text) = field.text().await - && !text.is_empty() - { - field_values.insert(idx, text); - } - } - _ => {} - } - } + let data = super::helpers::parse_profile_multipart(multipart).await; let cmd = application::users::commands::UpdateProfileCommand { user_id: user_id.value(), - display_name, - bio, - avatar_bytes, - avatar_content_type, - banner_bytes, - banner_content_type, - also_known_as, + display_name: data.display_name, + bio: data.bio, + avatar_bytes: data.avatar_bytes, + avatar_content_type: data.avatar_content_type, + banner_bytes: data.banner_bytes, + banner_content_type: data.banner_content_type, + also_known_as: data.also_known_as, }; let update_deps = UpdateProfileDeps { user: state.app_ctx.repos.user.clone(), object_storage: state.app_ctx.services.object_storage.clone(), event_publisher: state.app_ctx.services.event_publisher.clone(), }; - let _ = update_profile::execute(&update_deps, cmd).await; + if let Err(e) = update_profile::execute(&update_deps, cmd).await { + tracing::error!("update_profile error: {:?}", e); + return axum::response::Redirect::to(&format!( + "/settings/profile?error={}", + super::helpers::encode_error(&e.to_string()) + )) + .into_response(); + } let fields: Vec = (0..4) .filter_map(|i| { - field_names + data.profile_field_names .get(&i) .map(|name| domain::models::ProfileField { name: name.clone(), - value: field_values.get(&i).cloned().unwrap_or_default(), + value: data + .profile_field_values + .get(&i) + .cloned() + .unwrap_or_default(), }) }) .collect(); @@ -967,12 +874,20 @@ pub async fn post_profile_settings( user_id: user_id.value(), fields, }; - let _ = update_profile_fields::execute( + if let Err(e) = update_profile_fields::execute( state.app_ctx.repos.profile_fields.clone(), state.app_ctx.services.event_publisher.clone(), fields_cmd, ) - .await; + .await + { + tracing::error!("update_profile_fields error: {:?}", e); + return axum::response::Redirect::to(&format!( + "/settings/profile?error={}", + super::helpers::encode_error(&e.to_string()) + )) + .into_response(); + } axum::response::Redirect::to("/settings/profile?saved=1").into_response() } diff --git a/crates/presentation/src/handlers/watchlist.rs b/crates/presentation/src/handlers/watchlist.rs index bc8d921..28fc8a3 100644 --- a/crates/presentation/src/handlers/watchlist.rs +++ b/crates/presentation/src/handlers/watchlist.rs @@ -32,12 +32,7 @@ use api_types::{ }; use template_askama::WatchlistTemplate; -use super::helpers::build_page_context; - -fn encode_error(msg: &str) -> String { - use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; - utf8_percent_encode(msg, NON_ALPHANUMERIC).to_string() -} +use super::helpers::{build_page_context, encode_error}; // ── API ────────────────────────────────────────────────────────────────────── diff --git a/crates/presentation/src/handlers/wrapup.rs b/crates/presentation/src/handlers/wrapup.rs index af5fd97..75a74dd 100644 --- a/crates/presentation/src/handlers/wrapup.rs +++ b/crates/presentation/src/handlers/wrapup.rs @@ -201,7 +201,7 @@ fn format_watch_time(minutes: u32) -> String { fn render_wrapup( report: &WrapUpReport, year: i32, - ctx: &application::rendering::HtmlPageContext, + ctx: &api_types::HtmlPageContext, ) -> axum::response::Response { let rating_max = report .rating_distribution diff --git a/crates/presentation/src/main.rs b/crates/presentation/src/main.rs index fff379e..a7a7ac0 100644 --- a/crates/presentation/src/main.rs +++ b/crates/presentation/src/main.rs @@ -13,6 +13,7 @@ use presentation::{factory, openapi, routes, state::AppState}; use rss::RssAdapter; use domain::ports::{DiaryExporter, DocumentParser, EventPublisher}; +use infra_wiring::EventBusBackend; #[cfg(feature = "postgres")] use postgres_search; @@ -85,29 +86,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { ), }; - let ep: Arc = match event_bus { - EventBusBackend::Db => { - tracing::info!("event bus: DB queue"); - match &db_pool { - #[cfg(feature = "postgres")] - factory::DbPool::Postgres(pool) => { - postgres_event_queue::PostgresEventQueue::create_publisher(pool.clone()) - .await? - } - #[cfg(feature = "sqlite")] - factory::DbPool::Sqlite(pool) => { - sqlite_event_queue::SqliteEventQueue::create_publisher(pool.clone()).await? - } - } - } - #[cfg(feature = "nats")] - EventBusBackend::Nats => { - let cfg = nats::NatsConfig::from_env() - .context("EVENT_BUS_BACKEND=nats requires NATS_URL to be set")?; - tracing::info!("event bus: NATS ({})", cfg.url); - nats::create_publisher(cfg).await? - } - }; + let ep = create_event_publisher(event_bus, &db_pool).await?; let ap = activitypub::wire(activitypub::ActivityPubDeps { activity_repo, @@ -118,6 +97,11 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { remote_watchlist_repo: remote_watchlist_repo.clone(), remote_goal_repo: Arc::clone(&db.remote_goal), local_ap_content: Arc::clone(&ap_content_repo), + movie_repo: Arc::clone(&db.movie), + review_repo: Arc::clone(&db.review), + diary_repo: Arc::clone(&db.diary), + goal_repo: Arc::clone(&db.goal), + stats_repo: Arc::clone(&db.stats), user_repo: Arc::clone(&db.user), federation_settings: std::sync::Arc::clone(&db.federation_settings), base_url: app_config.base_url.clone(), @@ -138,32 +122,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { }; #[cfg(not(feature = "federation"))] - let event_publisher_arc: Arc = match event_bus { - EventBusBackend::Db => { - tracing::info!("event bus: DB queue"); - match &db_pool { - #[cfg(feature = "postgres")] - factory::DbPool::Postgres(pool) => { - postgres_event_queue::PostgresEventQueue::create_publisher(pool.clone()).await? - } - #[cfg(feature = "sqlite")] - factory::DbPool::Sqlite(pool) => { - sqlite_event_queue::SqliteEventQueue::create_publisher(pool.clone()).await? - } - #[cfg(not(feature = "sqlite"))] - _ => anyhow::bail!( - "EVENT_BUS_BACKEND=db has no adapter for DATABASE_BACKEND={backend}; enable the sqlite or postgres feature" - ), - } - } - #[cfg(feature = "nats")] - EventBusBackend::Nats => { - let cfg = nats::NatsConfig::from_env() - .context("EVENT_BUS_BACKEND=nats requires NATS_URL to be set")?; - tracing::info!("event bus: NATS ({})", cfg.url); - nats::create_publisher(cfg).await? - } - }; + let event_publisher_arc = create_event_publisher(event_bus, &db_pool).await?; #[cfg(not(feature = "federation"))] let ap_router = axum::Router::new(); @@ -253,27 +212,30 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { Ok((state, ap_router)) } -#[derive(Clone, Copy)] -enum EventBusBackend { - Db, - #[cfg(feature = "nats")] - Nats, -} - -impl EventBusBackend { - fn from_env() -> anyhow::Result { - match std::env::var("EVENT_BUS_BACKEND") - .unwrap_or_else(|_| "db".to_string()) - .as_str() - { - "db" => Ok(Self::Db), - #[cfg(feature = "nats")] - "nats" => Ok(Self::Nats), - #[cfg(not(feature = "nats"))] - "nats" => { - anyhow::bail!("EVENT_BUS_BACKEND=nats requires the nats feature to be compiled in") - } - other => anyhow::bail!("unknown EVENT_BUS_BACKEND={other}, expected 'db' or 'nats'"), +async fn create_event_publisher( + event_bus: EventBusBackend, + db_pool: &factory::DbPool, +) -> anyhow::Result> { + match event_bus { + EventBusBackend::Db => { + tracing::info!("event bus: DB queue"); + Ok(match db_pool { + #[cfg(feature = "postgres")] + factory::DbPool::Postgres(pool) => { + postgres_event_queue::PostgresEventQueue::create_publisher(pool.clone()).await? + } + #[cfg(feature = "sqlite")] + factory::DbPool::Sqlite(pool) => { + sqlite_event_queue::SqliteEventQueue::create_publisher(pool.clone()).await? + } + }) + } + #[cfg(feature = "nats")] + EventBusBackend::Nats => { + let cfg = nats::NatsConfig::from_env() + .context("EVENT_BUS_BACKEND=nats requires NATS_URL to be set")?; + tracing::info!("event bus: NATS ({})", cfg.url); + Ok(nats::create_publisher(cfg).await?) } } } diff --git a/crates/presentation/src/mappers/mod.rs b/crates/presentation/src/mappers/mod.rs index 0f30d64..f1574c5 100644 --- a/crates/presentation/src/mappers/mod.rs +++ b/crates/presentation/src/mappers/mod.rs @@ -2,5 +2,8 @@ pub mod diary; pub mod import; pub mod integrations; pub mod movies; +pub mod search; +#[cfg(feature = "federation")] +pub mod social; pub mod users; pub mod watchlist; diff --git a/crates/presentation/src/mappers/movies.rs b/crates/presentation/src/mappers/movies.rs index 57b7cfb..a161fb3 100644 --- a/crates/presentation/src/mappers/movies.rs +++ b/crates/presentation/src/mappers/movies.rs @@ -37,7 +37,7 @@ pub fn review_to_dto(review: &Review) -> ReviewDto { rating: review.rating().value(), comment: review.comment().map(|c| c.value().to_string()), watched_at: domain::value_objects::format_watched_at(review.watched_at()), - watch_medium: review.watch_medium().map(|wm| wm.to_string()), + watch_medium: review.watch_medium().copied(), } } diff --git a/crates/presentation/src/mappers/search.rs b/crates/presentation/src/mappers/search.rs new file mode 100644 index 0000000..874dc75 --- /dev/null +++ b/crates/presentation/src/mappers/search.rs @@ -0,0 +1,22 @@ +use api_types::search::PersonDto; +use domain::models::person::Person; + +pub fn person_to_dto(p: &Person) -> PersonDto { + PersonDto { + id: p.id().value(), + external_id: p.external_id().value().to_string(), + name: p.name().to_string(), + known_for_department: p.known_for_department().map(str::to_string), + profile_path: p.profile_path().map(str::to_string), + biography: p.biography().map(str::to_string), + birthday: p.birthday().map(|d| d.to_string()), + deathday: p.deathday().map(|d| d.to_string()), + place_of_birth: p.place_of_birth().map(str::to_string), + also_known_as: p.also_known_as().to_vec(), + homepage: p.homepage().map(str::to_string), + imdb_url: p + .imdb_id() + .map(|id| format!("https://www.imdb.com/name/{id}")), + enriched: p.enriched_at().is_some(), + } +} diff --git a/crates/presentation/src/mappers/social.rs b/crates/presentation/src/mappers/social.rs new file mode 100644 index 0000000..bc92fd3 --- /dev/null +++ b/crates/presentation/src/mappers/social.rs @@ -0,0 +1,9 @@ +use api_types::RemoteActorDto; + +pub fn remote_actor_to_dto(a: activitypub::RemoteActor) -> RemoteActorDto { + RemoteActorDto { + handle: a.handle, + display_name: a.display_name, + url: a.url, + } +} diff --git a/crates/presentation/src/mappers/users.rs b/crates/presentation/src/mappers/users.rs index cd1b124..00a1932 100644 --- a/crates/presentation/src/mappers/users.rs +++ b/crates/presentation/src/mappers/users.rs @@ -1,7 +1,6 @@ use application::users::get_profile::PendingFollowerView; -use chrono::Datelike; use domain::models::RemoteActorInfo; -use domain::models::{DiaryEntry, MonthActivity, UserSummary}; +use domain::models::UserSummary; use template_askama::{RemoteActorData, RemoteActorDisplay, UserSummaryView}; pub fn user_summary_view(u: &UserSummary) -> UserSummaryView { @@ -44,49 +43,3 @@ pub fn pending_follower_data(p: &PendingFollowerView) -> RemoteActorData { avatar_url: p.avatar_url.clone(), } } - -pub fn group_by_month(entries: Vec) -> Vec { - use std::collections::BTreeMap; - let mut map: BTreeMap<(i32, u32), Vec> = BTreeMap::new(); - for entry in entries { - let watched_at = entry.review().watched_at(); - let year = watched_at.year(); - let month = watched_at.month(); - map.entry((year, month)).or_default().push(entry); - } - map.into_iter() - .rev() - .map(|((year, month), entries)| { - let year_month = format!("{:04}-{:02}", year, month); - MonthActivity { - month_label: format_year_month_long(&year_month), - count: entries.len() as i64, - entries, - year_month, - } - }) - .collect() -} - -fn format_year_month_long(ym: &str) -> String { - let parts: Vec<&str> = ym.splitn(2, '-').collect(); - if parts.len() != 2 { - return ym.to_string(); - } - let month = match parts[1] { - "01" => "January", - "02" => "February", - "03" => "March", - "04" => "April", - "05" => "May", - "06" => "June", - "07" => "July", - "08" => "August", - "09" => "September", - "10" => "October", - "11" => "November", - "12" => "December", - _ => parts[1], - }; - format!("{} {}", month, parts[0]) -} diff --git a/crates/presentation/src/openapi/users.rs b/crates/presentation/src/openapi/users.rs index ef64c98..01a368c 100644 --- a/crates/presentation/src/openapi/users.rs +++ b/crates/presentation/src/openapi/users.rs @@ -1,6 +1,6 @@ use api_types::{ - ProfileFieldDto, ProfileResponse, UpdateProfileFieldsRequest, UserProfileResponse, - UserStatsDto, UserSummaryDto, UsersResponse, + ProfileFieldDto, ProfileResponse, UpdateProfileFieldsRequest, UserProfileBase, + UserProfileResponse, UserStatsDto, UserSummaryDto, UsersResponse, }; use utoipa::OpenApi; @@ -16,6 +16,7 @@ use utoipa::OpenApi; components(schemas( UsersResponse, UserSummaryDto, + UserProfileBase, UserProfileResponse, UserStatsDto, ProfileResponse, diff --git a/crates/presentation/src/state.rs b/crates/presentation/src/state.rs index 73e646c..ddf58dd 100644 --- a/crates/presentation/src/state.rs +++ b/crates/presentation/src/state.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use crate::context::AppContext; -use application::ports::RssFeedRenderer; +use domain::ports::RssFeedRenderer; #[derive(Clone)] pub struct AppState { diff --git a/crates/presentation/src/tests/extractors.rs b/crates/presentation/src/tests/extractors.rs index 8a58eb9..935a0d5 100644 --- a/crates/presentation/src/tests/extractors.rs +++ b/crates/presentation/src/tests/extractors.rs @@ -86,7 +86,7 @@ impl MovieRepository for Panic { } #[async_trait::async_trait] impl ReviewRepository for Panic { - async fn save_review(&self, _: &Review) -> Result { + async fn save_review(&self, _: &Review) -> Result<(), DomainError> { panic!() } async fn get_review_by_id(&self, _: &ReviewId) -> Result, DomainError> { @@ -154,7 +154,7 @@ impl DiaryRepository for Panic { #[cfg(feature = "federation")] #[async_trait::async_trait] impl domain::ports::SocialQueryPort for Panic { - async fn get_accepted_following_urls(&self, _: uuid::Uuid) -> Result, DomainError> { + async fn get_accepted_following_urls(&self, _: &UserId) -> Result, DomainError> { panic!() } async fn list_all_followed_remote_actors( @@ -162,15 +162,15 @@ impl domain::ports::SocialQueryPort for Panic { ) -> Result, DomainError> { panic!() } - async fn count_following(&self, _: uuid::Uuid) -> Result { + async fn count_following(&self, _: &UserId) -> Result { panic!() } - async fn count_accepted_followers(&self, _: uuid::Uuid) -> Result { + async fn count_accepted_followers(&self, _: &UserId) -> Result { panic!() } async fn get_pending_followers( &self, - _: uuid::Uuid, + _: &UserId, ) -> Result, DomainError> { panic!() } @@ -183,6 +183,9 @@ impl StatsRepository for Panic { async fn get_user_trends(&self, _: &UserId) -> Result { panic!() } + async fn count_reviews_in_year(&self, _: &UserId, _: u16) -> Result { + panic!() + } } #[async_trait::async_trait] impl MetadataClient for Panic { @@ -424,7 +427,7 @@ impl domain::ports::DocumentParser for Panic { } } -impl application::ports::RssFeedRenderer for Panic { +impl domain::ports::RssFeedRenderer for Panic { fn render_feed(&self, _: &[DiaryEntry], _: &str) -> Result { panic!() } @@ -700,13 +703,6 @@ impl domain::ports::GoalRepository for Panic { ) -> Result, DomainError> { panic!() } - async fn count_reviews_in_year( - &self, - _: &domain::value_objects::UserId, - _: u16, - ) -> Result { - panic!() - } } #[async_trait::async_trait] diff --git a/crates/presentation/tests/api_test.rs b/crates/presentation/tests/api_test.rs index 3553652..2184458 100644 --- a/crates/presentation/tests/api_test.rs +++ b/crates/presentation/tests/api_test.rs @@ -405,7 +405,10 @@ impl domain::ports::RemoteWatchlistRepository for PanicRemoteWatchlist { #[cfg(feature = "federation")] #[async_trait::async_trait] impl domain::ports::SocialQueryPort for PanicSocialQuery { - async fn get_accepted_following_urls(&self, _: uuid::Uuid) -> Result, DomainError> { + async fn get_accepted_following_urls( + &self, + _: &domain::value_objects::UserId, + ) -> Result, DomainError> { panic!() } async fn list_all_followed_remote_actors( @@ -413,15 +416,21 @@ impl domain::ports::SocialQueryPort for PanicSocialQuery { ) -> Result, DomainError> { panic!() } - async fn count_following(&self, _: uuid::Uuid) -> Result { + async fn count_following( + &self, + _: &domain::value_objects::UserId, + ) -> Result { panic!() } - async fn count_accepted_followers(&self, _: uuid::Uuid) -> Result { + async fn count_accepted_followers( + &self, + _: &domain::value_objects::UserId, + ) -> Result { panic!() } async fn get_pending_followers( &self, - _: uuid::Uuid, + _: &domain::value_objects::UserId, ) -> Result, DomainError> { panic!() } diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index c8926d8..7586527 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -240,7 +240,8 @@ pub enum Action { entries: Vec, total: u64, }, - DiaryLoadFailed(String), + ShowError(String), + AuthExpired, HistoryLoaded(ReviewHistoryResponse), HistoryLoadFailed(String), ReviewCreated, @@ -371,6 +372,35 @@ pub fn parse_csv(content: &str) -> Vec { rows } +/// Returns a mutable reference to whichever text field currently has focus, +/// or `None` if the active widget is non-textual (e.g. a rating spinner). +fn focused_input(app: &mut App) -> Option<&mut String> { + match &mut app.screen { + Screen::Setup(s) => Some(&mut s.api_url), + Screen::Login(s) => match s.focused { + LoginField::Email => Some(&mut s.email), + LoginField::Password => Some(&mut s.password), + }, + Screen::Main(m) => match m.tab { + Tab::AddReview => match m.add_review.focused { + AddReviewField::ExternalId => Some(&mut m.add_review.external_id), + AddReviewField::Title => Some(&mut m.add_review.title), + AddReviewField::Year => Some(&mut m.add_review.year), + AddReviewField::WatchedAt => Some(&mut m.add_review.watched_at), + AddReviewField::Comment => Some(&mut m.add_review.comment), + _ => None, + }, + Tab::BulkImport if matches!(m.bulk_import.stage, BulkImportStage::EnterPath) => { + Some(&mut m.bulk_import.file_path) + } + Tab::Settings if matches!(m.settings.focused, SettingsField::ApiUrl) => { + Some(&mut m.settings.api_url) + } + _ => None, + }, + } +} + pub fn update(app: &mut App, action: Action) -> Vec { match action { // ── Global ─────────────────────────────────────────────────────────── @@ -435,77 +465,15 @@ pub fn update(app: &mut App, action: Action) -> Vec { // ── Shared text input ──────────────────────────────────────────────── Action::InputChar(c) => { - match &mut app.screen { - Screen::Setup(s) => s.api_url.push(c), - Screen::Login(s) => match s.focused { - LoginField::Email => s.email.push(c), - LoginField::Password => s.password.push(c), - }, - Screen::Main(m) => match m.tab { - Tab::AddReview => match m.add_review.focused { - AddReviewField::ExternalId => m.add_review.external_id.push(c), - AddReviewField::Title => m.add_review.title.push(c), - AddReviewField::Year => m.add_review.year.push(c), - AddReviewField::WatchedAt => m.add_review.watched_at.push(c), - AddReviewField::Comment => m.add_review.comment.push(c), - _ => {} - }, - Tab::BulkImport - if matches!(m.bulk_import.stage, BulkImportStage::EnterPath) => - { - m.bulk_import.file_path.push(c); - } - Tab::Settings if matches!(m.settings.focused, SettingsField::ApiUrl) => { - m.settings.api_url.push(c); - } - _ => {} - }, + if let Some(field) = focused_input(app) { + field.push(c); } vec![] } Action::Backspace => { - match &mut app.screen { - Screen::Setup(s) => { - s.api_url.pop(); - } - Screen::Login(s) => match s.focused { - LoginField::Email => { - s.email.pop(); - } - LoginField::Password => { - s.password.pop(); - } - }, - Screen::Main(m) => match m.tab { - Tab::AddReview => match m.add_review.focused { - AddReviewField::ExternalId => { - m.add_review.external_id.pop(); - } - AddReviewField::Title => { - m.add_review.title.pop(); - } - AddReviewField::Year => { - m.add_review.year.pop(); - } - AddReviewField::WatchedAt => { - m.add_review.watched_at.pop(); - } - AddReviewField::Comment => { - m.add_review.comment.pop(); - } - _ => {} - }, - Tab::BulkImport - if matches!(m.bulk_import.stage, BulkImportStage::EnterPath) => - { - m.bulk_import.file_path.pop(); - } - Tab::Settings if matches!(m.settings.focused, SettingsField::ApiUrl) => { - m.settings.api_url.pop(); - } - _ => {} - }, + if let Some(field) = focused_input(app) { + field.pop(); } vec![] } @@ -705,17 +673,8 @@ pub fn update(app: &mut App, action: Action) -> Vec { vec![] } - Action::DiaryLoadFailed(msg) => { + Action::ShowError(msg) => { app.loading = false; - if msg.contains("unauthorized") || msg.contains("Unauthorized") { - app.token = None; - app.screen = Screen::Login(LoginState::default()); - app.status = Some(StatusMsg { - text: "Session expired. Please log in again.".into(), - is_error: true, - }); - return vec![Command::ClearToken]; - } app.status = Some(StatusMsg { text: msg, is_error: true, @@ -723,6 +682,17 @@ pub fn update(app: &mut App, action: Action) -> Vec { vec![] } + Action::AuthExpired => { + app.loading = false; + app.token = None; + app.screen = Screen::Login(LoginState::default()); + app.status = Some(StatusMsg { + text: "Session expired. Please log in again.".into(), + is_error: true, + }); + vec![Command::ClearToken] + } + Action::HistoryLoaded(h) => { app.loading = false; if let Screen::Main(m) = &mut app.screen { diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index d63eec6..f4c8b3a 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -5,9 +5,17 @@ use tokio::sync::mpsc; use ratatui::crossterm::event::{self, Event, KeyCode, KeyModifiers}; use tui::app::{self, Action, App, BulkImportStage, Command, Screen, SettingsField, Tab}; -use tui::client::ApiClient; +use tui::client::{ApiClient, ApiError}; use tui::config::Config; +/// Convert an API error into either `AuthExpired` (for 401s) or a `ShowError`. +fn api_err_action(e: ApiError) -> Action { + match e { + ApiError::Unauthorized => Action::AuthExpired, + other => Action::ShowError(other.to_string()), + } +} + fn main() -> anyhow::Result<()> { Config::init_keyring()?; tokio::runtime::Builder::new_multi_thread() @@ -53,7 +61,7 @@ async fn run() -> anyhow::Result<()> { entries: r.items, total: r.total_count, }, - Err(e) => Action::DiaryLoadFailed(e.to_string()), + Err(e) => api_err_action(e), }; let _ = tx2.send(action).await; }); @@ -109,7 +117,7 @@ fn handle_command(cmd: Command, app: &App, client: &Arc, tx: &mpsc::S let tx2 = tx.clone(); let msg = format!("Failed to save config: {e}"); tokio::spawn(async move { - let _ = tx2.send(Action::DiaryLoadFailed(msg)).await; + let _ = tx2.send(Action::ShowError(msg)).await; }); } client.update_url(&url); @@ -123,7 +131,7 @@ fn handle_command(cmd: Command, app: &App, client: &Arc, tx: &mpsc::S .unwrap_or_else(|e| Err(anyhow::anyhow!(e))) { let msg = format!("Token not saved to keychain: {e}"); - let _ = tx2.send(Action::DiaryLoadFailed(msg)).await; + let _ = tx2.send(Action::ShowError(msg)).await; } }); } @@ -158,7 +166,7 @@ fn handle_command(cmd: Command, app: &App, client: &Arc, tx: &mpsc::S entries: r.items, total: r.total_count, }, - Err(e) => Action::DiaryLoadFailed(e.to_string()), + Err(e) => api_err_action(e), }; let _ = tx.send(action).await; }); @@ -173,6 +181,7 @@ fn handle_command(cmd: Command, app: &App, client: &Arc, tx: &mpsc::S tokio::spawn(async move { let action = match c.get_movie_history(&token, movie_id).await { Ok(r) => Action::HistoryLoaded(r), + Err(ApiError::Unauthorized) => Action::AuthExpired, Err(e) => Action::HistoryLoadFailed(e.to_string()), }; let _ = tx.send(action).await; @@ -188,6 +197,7 @@ fn handle_command(cmd: Command, app: &App, client: &Arc, tx: &mpsc::S tokio::spawn(async move { let action = match c.create_review(&token, &req).await { Ok(()) => Action::ReviewCreated, + Err(ApiError::Unauthorized) => Action::AuthExpired, Err(e) => Action::ReviewCreateFailed(e.to_string()), }; let _ = tx.send(action).await; @@ -203,6 +213,7 @@ fn handle_command(cmd: Command, app: &App, client: &Arc, tx: &mpsc::S tokio::spawn(async move { let action = match c.delete_review(&token, id).await { Ok(()) => Action::ReviewDeleted(id), + Err(ApiError::Unauthorized) => Action::AuthExpired, Err(e) => Action::ReviewDeleteFailed(e.to_string()), }; let _ = tx.send(action).await; diff --git a/crates/worker/Cargo.toml b/crates/worker/Cargo.toml index 7718ef3..0c7dea5 100644 --- a/crates/worker/Cargo.toml +++ b/crates/worker/Cargo.toml @@ -5,9 +5,9 @@ edition = "2024" [features] default = ["sqlite", "sqlite-federation"] -sqlite = ["dep:sqlite", "dep:sqlite-event-queue", "dep:sqlite-search"] -postgres = ["dep:postgres", "dep:postgres-event-queue", "dep:postgres-search"] -nats = ["dep:nats"] +sqlite = ["dep:sqlite", "dep:sqlite-event-queue", "dep:sqlite-search", "infra-wiring/sqlite"] +postgres = ["dep:postgres", "dep:postgres-event-queue", "dep:postgres-search", "infra-wiring/postgres"] +nats = ["dep:nats", "infra-wiring/nats"] federation = ["application/federation"] sqlite-federation = ["sqlite", "dep:sqlite-federation", "dep:activitypub", "federation"] postgres-federation = ["postgres", "dep:postgres-federation", "dep:activitypub", "federation"] @@ -31,6 +31,7 @@ importer = { workspace = true } image-converter = { workspace = true } nats = { workspace = true, optional = true } sqlx = { workspace = true } +infra-wiring = { workspace = true } async-trait = { workspace = true } # Optional — database backends diff --git a/crates/worker/src/db.rs b/crates/worker/src/db.rs index e228d6d..124bacb 100644 --- a/crates/worker/src/db.rs +++ b/crates/worker/src/db.rs @@ -2,20 +2,20 @@ use std::sync::Arc; use anyhow::Context; use domain::ports::{ - ImageRefCommand, ImageRefQuery, ImportSessionRepository, LocalApContentQuery, - MovieDeduplicator, MovieProfileRepository, MovieRepository, PersonCommand, PersonQuery, - SearchCommand, UserRepository, WatchEventRepository, + DiaryRepository, GoalRepository, ImageRefCommand, ImageRefQuery, ImportSessionRepository, + LocalApContentQuery, MovieDeduplicator, MovieProfileRepository, MovieRepository, PersonCommand, + PersonQuery, ReviewRepository, SearchCommand, StatsRepository, UserRepository, + WatchEventRepository, }; -pub enum DbPool { - #[cfg(feature = "sqlite")] - Sqlite(sqlx::SqlitePool), - #[cfg(feature = "postgres")] - Postgres(sqlx::PgPool), -} +pub use infra_wiring::DbPool; pub struct WorkerDbOutput { pub movie: Arc, + pub review: Arc, + pub diary: Arc, + pub stats: Arc, + pub goal: Arc, pub user: Arc, pub import_session: Arc, pub movie_profile: Arc, @@ -50,6 +50,10 @@ pub async fn connect(database_url: &str, backend: &str) -> anyhow::Result anyhow::Result anyhow::Result { - match std::env::var("EVENT_BUS_BACKEND") - .unwrap_or_else(|_| "db".to_string()) - .as_str() - { - "db" => Ok(Self::Db), - #[cfg(feature = "nats")] - "nats" => Ok(Self::Nats), - #[cfg(not(feature = "nats"))] - "nats" => { - anyhow::bail!("EVENT_BUS_BACKEND=nats requires the nats feature to be compiled in") - } - other => anyhow::bail!("unknown EVENT_BUS_BACKEND={other}, expected 'db' or 'nats'"), - } - } -} +use infra_wiring::EventBusBackend; pub async fn create( db_pool: &DbPool, diff --git a/crates/worker/src/main.rs b/crates/worker/src/main.rs index 738fa22..ba07521 100644 --- a/crates/worker/src/main.rs +++ b/crates/worker/src/main.rs @@ -38,8 +38,23 @@ async fn main() -> anyhow::Result<()> { let image_ref_query = Arc::clone(&db.image_ref_query); #[cfg(feature = "federation")] - let (fed_ap_content, fed_user_repo, base_url, allow_registration) = ( + let ( + fed_ap_content, + fed_movie_repo, + fed_review_repo, + fed_diary_repo, + fed_goal_repo, + fed_stats_repo, + fed_user_repo, + base_url, + allow_registration, + ) = ( Arc::clone(&db.ap_content), + Arc::clone(&db.movie), + Arc::clone(&db.review), + Arc::clone(&db.diary), + Arc::clone(&db.goal), + Arc::clone(&db.stats), Arc::clone(&db.user), app_config.base_url.clone(), app_config.allow_registration, @@ -93,6 +108,7 @@ async fn main() -> anyhow::Result<()> { Ok(client) => { tracing::info!("TMDb enrichment enabled"); let client = Arc::new(client); + let image_fetcher = poster_fetcher::create_image_fetcher()?; let handler = Arc::new(application::movies::MovieEnrichmentHandler::new( Arc::clone(&client) as Arc, Arc::clone(&movie), @@ -100,6 +116,7 @@ async fn main() -> anyhow::Result<()> { Arc::clone(&person_command), Arc::clone(&search_command), Arc::clone(&object_storage), + image_fetcher, )) as Arc; let person_enrichment_arc = Arc::clone(&client) as Arc; let person_handler = Arc::new(application::person::PersonEnrichmentHandler::new( @@ -189,48 +206,38 @@ async fn main() -> anyhow::Result<()> { &object_storage, ))) as Arc; - #[cfg(not(feature = "federation"))] - { - let search_cleanup = Arc::new(SearchCleanupHandler::new( - Arc::clone(&search_command), - Arc::clone(&person_query), - )) as Arc; - let discovery_indexer = Arc::new(MovieDiscoveryIndexer::new( - Arc::clone(&movie), - Arc::clone(&search_command), - )) as Arc; - let wrapup_handler = - Arc::new(application::wrapup::event_handler::WrapUpEventHandler::new( - Arc::clone(&wrapup_repo), - Arc::clone(&event_publisher), - Arc::clone(&wrapup_stats), - )) as Arc; - let reindex_handler = Arc::new(SearchReindexHandler::new(ReindexSearchDeps { - movie: Arc::clone(&movie), - movie_profile: Arc::clone(&movie_profile), - search_command: Arc::clone(&search_command), - person_command: Arc::clone(&person_command), - person_query: Arc::clone(&person_query), - })) as Arc; - let mut h = vec![ - poster, - cleanup, - search_cleanup, - discovery_indexer, - wrapup_handler, - reindex_handler, - ]; - if let Some(e) = enrichment_handler { - h.push(e); - } - if let Some(e) = person_enrichment_handler { - h.push(e); - } - if let Some((ref conv_handler, _)) = conversion { - h.push(Arc::clone(conv_handler)); - } - h - } + let search_cleanup = Arc::new(SearchCleanupHandler::new( + Arc::clone(&search_command), + Arc::clone(&person_query), + )) as Arc; + + let discovery_indexer = Arc::new(MovieDiscoveryIndexer::new( + Arc::clone(&movie), + Arc::clone(&search_command), + )) as Arc; + + let wrapup_handler = Arc::new(application::wrapup::event_handler::WrapUpEventHandler::new( + Arc::clone(&wrapup_repo), + Arc::clone(&event_publisher), + Arc::clone(&wrapup_stats), + )) as Arc; + + let reindex_handler = Arc::new(SearchReindexHandler::new(ReindexSearchDeps { + movie: Arc::clone(&movie), + movie_profile: Arc::clone(&movie_profile), + search_command: Arc::clone(&search_command), + person_command: Arc::clone(&person_command), + person_query: Arc::clone(&person_query), + })) as Arc; + + let mut h = vec![ + poster, + cleanup, + search_cleanup, + discovery_indexer, + wrapup_handler, + reindex_handler, + ]; #[cfg(feature = "federation")] { @@ -243,6 +250,11 @@ async fn main() -> anyhow::Result<()> { remote_watchlist_repo: fed_remote_watchlist_repo, remote_goal_repo: Arc::clone(&remote_goal), local_ap_content: fed_ap_content, + movie_repo: fed_movie_repo, + review_repo: fed_review_repo, + diary_repo: fed_diary_repo, + goal_repo: fed_goal_repo, + stats_repo: fed_stats_repo, user_repo: fed_user_repo, base_url, allow_registration, @@ -251,54 +263,24 @@ async fn main() -> anyhow::Result<()> { }) .await?; - let ap_event_handler = ap_wire.event_handler; - let backfill = Arc::new(follow_backfill_handler::FollowBackfillHandler { - ap_service: ap_wire.service, - }) as Arc; - - let search_cleanup = Arc::new(SearchCleanupHandler::new( - Arc::clone(&search_command), - Arc::clone(&person_query), - )) as Arc; - let discovery_indexer = Arc::new(MovieDiscoveryIndexer::new( - Arc::clone(&movie), - Arc::clone(&search_command), - )) as Arc; tracing::info!("federation event handler registered"); - let wrapup_handler = - Arc::new(application::wrapup::event_handler::WrapUpEventHandler::new( - Arc::clone(&wrapup_repo), - Arc::clone(&event_publisher), - Arc::clone(&wrapup_stats), - )) as Arc; - let reindex_handler = Arc::new(SearchReindexHandler::new(ReindexSearchDeps { - movie: Arc::clone(&movie), - movie_profile: Arc::clone(&movie_profile), - search_command: Arc::clone(&search_command), - person_command: Arc::clone(&person_command), - person_query: Arc::clone(&person_query), - })) as Arc; - let mut h = vec![ - poster, - cleanup, - ap_event_handler, - backfill, - search_cleanup, - discovery_indexer, - wrapup_handler, - reindex_handler, - ]; - if let Some(e) = enrichment_handler { - h.push(e); - } - if let Some(e) = person_enrichment_handler { - h.push(e); - } - if let Some((ref conv_handler, _)) = conversion { - h.push(Arc::clone(conv_handler)); - } - h + h.push(ap_wire.event_handler); + h.push(Arc::new(follow_backfill_handler::FollowBackfillHandler { + ap_service: ap_wire.service, + }) as Arc); } + + if let Some(e) = enrichment_handler { + h.push(e); + } + if let Some(e) = person_enrichment_handler { + h.push(e); + } + if let Some((ref conv_handler, _)) = conversion { + h.push(Arc::clone(conv_handler)); + } + + h }; // ── Run ───────────────────────────────────────────────────────────────────