From dee013c7ebcf32809cd37a3bdf4b4949c22d10bb Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Fri, 10 Jul 2026 03:50:43 +0200 Subject: [PATCH] =?UTF-8?q?refactor:=20remaining=20MEDIUM=20=E2=80=94=20CQ?= =?UTF-8?q?RS=20splits,=20DI=20Deps,=20profile=20dedup,=20event=20Value,?= =?UTF-8?q?=20response=20enum?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M1: MovieRepository→MovieCommand/MovieQuery, WatchEventRepository→ WatchEventCommand/WatchEventQuery M2: goals/ and import/ use Deps structs M7: extract upload_image helper in update_profile M8: FederationDeliveryRequested activity_json String→serde_json::Value M11: UserProfileResponse uses ProfileViewData enum --- Cargo.lock | 1 + .../adapters/activitypub/src/event_handler.rs | 12 +- .../src/federation_event_bridge.rs | 4 +- crates/adapters/activitypub/src/lib.rs | 2 +- .../activitypub/src/review_handler.rs | 4 +- crates/adapters/event-payload/src/lib.rs | 2 +- crates/adapters/poster-sync/src/lib.rs | 17 +- crates/adapters/postgres/src/lib.rs | 8 +- crates/adapters/postgres/src/movie.rs | 89 ++++---- crates/adapters/postgres/src/watch_event.rs | 71 +++---- crates/adapters/sqlite/src/lib.rs | 8 +- crates/adapters/sqlite/src/movie.rs | 89 ++++---- crates/adapters/sqlite/src/watch_event.rs | 77 +++---- crates/api-types/src/users.rs | 16 +- crates/application/src/diary/delete_review.rs | 2 +- crates/application/src/diary/deps.rs | 8 +- .../src/diary/get_movie_social_page.rs | 2 +- .../application/src/diary/movie_resolver.rs | 4 +- crates/application/src/diary/review_logger.rs | 17 +- .../src/diary/tests/delete_review.rs | 6 +- .../src/diary/tests/get_movie_social_page.rs | 6 +- .../application/src/diary/tests/log_review.rs | 3 +- .../src/diary/tests/movie_resolver.rs | 26 +-- .../src/diary/tests/review_logger.rs | 4 +- crates/application/src/goals/create.rs | 17 +- crates/application/src/goals/delete.rs | 15 +- crates/application/src/goals/deps.rs | 14 ++ crates/application/src/goals/get.rs | 12 +- crates/application/src/goals/list.rs | 12 +- crates/application/src/goals/mod.rs | 1 + crates/application/src/goals/tests/create.rs | 64 +++--- crates/application/src/goals/tests/delete.rs | 21 +- crates/application/src/goals/tests/get.rs | 25 ++- crates/application/src/goals/tests/list.rs | 25 ++- crates/application/src/goals/tests/update.rs | 38 ++-- crates/application/src/goals/update.rs | 18 +- .../application/src/import/apply_mapping.rs | 19 +- .../application/src/import/apply_profile.rs | 17 +- .../application/src/import/create_session.rs | 15 +- crates/application/src/import/deps.rs | 31 +++ crates/application/src/import/execute.rs | 14 +- crates/application/src/import/mod.rs | 1 + crates/application/src/import/save_profile.rs | 14 +- .../src/import/tests/apply_mapping.rs | 67 ++++-- .../src/import/tests/apply_profile.rs | 25 ++- .../src/import/tests/create_session.rs | 9 +- .../application/src/import/tests/execute.rs | 121 ++++++++--- .../src/import/tests/save_profile.rs | 17 +- .../application/src/integrations/cleanup.rs | 6 +- .../application/src/integrations/confirm.rs | 9 +- crates/application/src/integrations/deps.rs | 5 +- .../application/src/integrations/dismiss.rs | 9 +- .../application/src/integrations/get_queue.rs | 6 +- crates/application/src/integrations/ingest.rs | 4 +- .../src/integrations/tests/cleanup.rs | 5 +- .../src/integrations/tests/confirm.rs | 48 +++-- .../src/integrations/tests/dismiss.rs | 17 +- .../src/integrations/tests/get_queue.rs | 10 +- .../src/integrations/tests/ingest.rs | 12 +- crates/application/src/jobs/movie_dedup.rs | 6 +- .../src/jobs/watch_event_cleanup.rs | 6 +- crates/application/src/movies/deps.rs | 11 +- .../src/movies/discovery_indexer.rs | 6 +- crates/application/src/movies/enrich_movie.rs | 2 +- .../application/src/movies/event_handler.rs | 8 +- crates/application/src/movies/get_movies.rs | 4 +- .../src/movies/merge_duplicates.rs | 8 +- .../application/src/movies/reindex_search.rs | 2 +- crates/application/src/movies/resolve.rs | 11 +- crates/application/src/movies/sync_poster.rs | 4 +- .../src/movies/tests/enrich_movie.rs | 6 +- .../src/movies/tests/sync_poster.rs | 11 +- crates/application/src/test_helpers.rs | 39 ++-- .../application/src/users/update_profile.rs | 121 ++++++----- crates/application/src/watchlist/add.rs | 3 +- crates/application/src/watchlist/deps.rs | 5 +- crates/application/src/watchlist/tests/add.rs | 5 +- crates/domain/Cargo.toml | 1 + crates/domain/src/events.rs | 2 +- crates/domain/src/ports/media_server.rs | 22 +- crates/domain/src/ports/movie.rs | 12 +- crates/domain/src/testing/in_memory.rs | 76 +++---- crates/domain/src/testing/panics.rs | 66 +++--- crates/presentation/src/context.rs | 11 +- crates/presentation/src/factory.rs | 26 ++- crates/presentation/src/handlers/diary.rs | 4 +- crates/presentation/src/handlers/goals.rs | 41 ++-- crates/presentation/src/handlers/import.rs | 67 +++--- .../presentation/src/handlers/integrations.rs | 7 +- crates/presentation/src/handlers/movies.rs | 9 +- crates/presentation/src/handlers/users.rs | 190 ++++++++++-------- crates/presentation/src/handlers/watchlist.rs | 6 +- crates/presentation/src/handlers/webhook.rs | 10 +- crates/presentation/src/main.rs | 11 +- crates/presentation/src/openapi/users.rs | 5 +- crates/presentation/src/tests/extractors.rs | 56 +++--- crates/presentation/tests/api_test.rs | 6 +- crates/worker/src/db.rs | 30 +-- crates/worker/src/main.rs | 21 +- 99 files changed, 1262 insertions(+), 896 deletions(-) create mode 100644 crates/application/src/goals/deps.rs create mode 100644 crates/application/src/import/deps.rs diff --git a/Cargo.lock b/Cargo.lock index b64c562..bfc1b2a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1608,6 +1608,7 @@ dependencies = [ "email_address", "futures", "serde", + "serde_json", "thiserror 2.0.18", "uuid", ] diff --git a/crates/adapters/activitypub/src/event_handler.rs b/crates/adapters/activitypub/src/event_handler.rs index 88ee00c..0d18589 100644 --- a/crates/adapters/activitypub/src/event_handler.rs +++ b/crates/adapters/activitypub/src/event_handler.rs @@ -5,7 +5,7 @@ use domain::{ errors::DomainError, events::DomainEvent, ports::{ - GoalRepository, LocalApContentQuery, MovieRepository, ReviewRepository, StatsRepository, + GoalRepository, LocalApContentQuery, MovieQuery, ReviewRepository, StatsRepository, UserFederationSettingsQuery, }, value_objects::{MovieId, ReviewId, UserId}, @@ -21,7 +21,7 @@ pub struct ActivityPubEventHandler { ap_service: Arc, content_query: Arc, review_repo: Arc, - movie_repo: Arc, + movie_repo: Arc, goal_repo: Arc, stats_repo: Arc, federation_settings: Arc, @@ -34,7 +34,7 @@ impl ActivityPubEventHandler { ap_service: Arc, content_query: Arc, review_repo: Arc, - movie_repo: Arc, + movie_repo: Arc, goal_repo: Arc, stats_repo: Arc, federation_settings: Arc, @@ -108,12 +108,8 @@ impl EventHandler for ActivityPubEventHandler { let inbox: url::Url = inbox_url .parse() .map_err(|e| DomainError::InfrastructureError(format!("bad inbox URL: {e}")))?; - let activity: serde_json::Value = - serde_json::from_str(activity_json).map_err(|e| { - DomainError::InfrastructureError(format!("bad activity JSON: {e}")) - })?; self.ap_service - .deliver_to_inbox(inbox, activity, *signing_actor_id) + .deliver_to_inbox(inbox, activity_json.clone(), *signing_actor_id) .await .map_err(|e| DomainError::InfrastructureError(e.to_string())) } diff --git a/crates/adapters/activitypub/src/federation_event_bridge.rs b/crates/adapters/activitypub/src/federation_event_bridge.rs index d1c171c..cb2ca05 100644 --- a/crates/adapters/activitypub/src/federation_event_bridge.rs +++ b/crates/adapters/activitypub/src/federation_event_bridge.rs @@ -34,12 +34,10 @@ impl k_ap::EventPublisher for FederationEventBridge { activity, signing_actor_id, } => { - let json = serde_json::to_string(&activity) - .map_err(|e| anyhow::anyhow!("serialize activity: {e}"))?; self.domain_publisher .publish(&DomainEvent::FederationDeliveryRequested { inbox_url: inbox.to_string(), - activity_json: json, + activity_json: activity, signing_actor_id, }) .await diff --git a/crates/adapters/activitypub/src/lib.rs b/crates/adapters/activitypub/src/lib.rs index 0d528a0..f82d63e 100644 --- a/crates/adapters/activitypub/src/lib.rs +++ b/crates/adapters/activitypub/src/lib.rs @@ -51,7 +51,7 @@ 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 movie_repo: std::sync::Arc, pub review_repo: std::sync::Arc, pub diary_repo: std::sync::Arc, pub goal_repo: std::sync::Arc, diff --git a/crates/adapters/activitypub/src/review_handler.rs b/crates/adapters/activitypub/src/review_handler.rs index b85c509..4a5853e 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::{DiaryRepository, EventPublisher, LocalApContentQuery, MovieRepository}, + ports::{DiaryRepository, EventPublisher, LocalApContentQuery, MovieQuery}, value_objects::{Comment, ExternalMetadataId, MovieId, Rating, ReviewId, UserId}, }; use k_ap::{ApContentReader, ApObjectHandler}; @@ -16,7 +16,7 @@ use crate::urls::{actor_url, review_url}; pub struct ReviewObjectHandler { pub content_query: Arc, - pub movie_repo: Arc, + pub movie_repo: Arc, pub diary_repo: Arc, pub review_store: Arc, pub event_publisher: Arc, diff --git a/crates/adapters/event-payload/src/lib.rs b/crates/adapters/event-payload/src/lib.rs index b8afd30..a985739 100644 --- a/crates/adapters/event-payload/src/lib.rs +++ b/crates/adapters/event-payload/src/lib.rs @@ -72,7 +72,7 @@ pub enum EventPayload { }, FederationDeliveryRequested { inbox_url: String, - activity_json: String, + activity_json: serde_json::Value, signing_actor_id: String, }, WatchEventIngested { diff --git a/crates/adapters/poster-sync/src/lib.rs b/crates/adapters/poster-sync/src/lib.rs index f1fd69a..2f69771 100644 --- a/crates/adapters/poster-sync/src/lib.rs +++ b/crates/adapters/poster-sync/src/lib.rs @@ -5,14 +5,15 @@ use domain::{ errors::DomainError, events::DomainEvent, ports::{ - EventHandler, EventPublisher, MetadataClient, MovieRepository, ObjectStorage, + EventHandler, EventPublisher, MetadataClient, MovieCommand, MovieQuery, ObjectStorage, PosterFetcherClient, }, value_objects::{ExternalMetadataId, MovieId, PosterPath}, }; pub struct PosterSyncHandler { - movie_repository: Arc, + movie_command: Arc, + movie_query: Arc, metadata_client: Arc, poster_fetcher: Arc, object_storage: Arc, @@ -22,7 +23,8 @@ pub struct PosterSyncHandler { impl PosterSyncHandler { pub fn new( - movie_repository: Arc, + movie_command: Arc, + movie_query: Arc, metadata_client: Arc, poster_fetcher: Arc, object_storage: Arc, @@ -30,7 +32,8 @@ impl PosterSyncHandler { max_retries: u32, ) -> Self { Self { - movie_repository, + movie_command, + movie_query, metadata_client, poster_fetcher, object_storage, @@ -44,7 +47,7 @@ impl PosterSyncHandler { movie_id: MovieId, external_metadata_id: ExternalMetadataId, ) -> Result<(), DomainError> { - let mut movie = match self.movie_repository.get_movie_by_id(&movie_id).await? { + let mut movie = match self.movie_query.get_movie_by_id(&movie_id).await? { Some(m) => m, None => { tracing::warn!("Sync cancelled: Movie {} not found", movie_id.value()); @@ -82,7 +85,7 @@ impl PosterSyncHandler { let poster_path = PosterPath::new(stored_path)?; movie.update_poster(poster_path); - self.movie_repository.upsert_movie(&movie).await?; + self.movie_command.upsert_movie(&movie).await?; if let Err(e) = self .event_publisher @@ -115,7 +118,7 @@ impl EventHandler for PosterSyncHandler { } => { // Only sync poster if the movie doesn't have one yet let already_has_poster = self - .movie_repository + .movie_query .get_movie_by_id(&MovieId::from_uuid(movie_id.value())) .await? .map(|m| m.poster_path().is_some()) diff --git a/crates/adapters/postgres/src/lib.rs b/crates/adapters/postgres/src/lib.rs index 91d3f20..e51790e 100644 --- a/crates/adapters/postgres/src/lib.rs +++ b/crates/adapters/postgres/src/lib.rs @@ -79,7 +79,8 @@ pub fn create_profile_fields_repo( pub struct PostgresWireOutput { pub pool: PgPool, - pub movie: std::sync::Arc, + pub movie_command: std::sync::Arc, + pub movie_query: std::sync::Arc, pub review: std::sync::Arc, pub diary: std::sync::Arc, pub stats: std::sync::Arc, @@ -114,9 +115,12 @@ pub async fn wire(database_url: &str) -> anyhow::Result { user_settings::PostgresUserSettingsRepository::new(pool.clone()), ); + let movie_repo = std::sync::Arc::new(PostgresMovieRepository::new(pool.clone())); + Ok(PostgresWireOutput { pool: pool.clone(), - movie: std::sync::Arc::new(PostgresMovieRepository::new(pool.clone())) as _, + movie_command: movie_repo.clone() as _, + movie_query: movie_repo as _, review: std::sync::Arc::new(PostgresReviewRepository::new(pool.clone())) as _, diary: std::sync::Arc::new(PostgresDiaryRepository::new(pool.clone())) as _, stats: std::sync::Arc::new(PostgresStatsRepository::new(pool.clone())) as _, diff --git a/crates/adapters/postgres/src/movie.rs b/crates/adapters/postgres/src/movie.rs index a3b1f61..793075e 100644 --- a/crates/adapters/postgres/src/movie.rs +++ b/crates/adapters/postgres/src/movie.rs @@ -5,7 +5,7 @@ use domain::{ Movie, MovieFilter, MovieSummary, collections::{PageParams, Paginated}, }, - ports::MovieRepository, + ports::{MovieCommand, MovieQuery}, value_objects::{ExternalMetadataId, MovieId, MovieTitle, ReleaseYear}, }; use sqlx::PgPool; @@ -28,7 +28,51 @@ impl PostgresMovieRepository { } #[async_trait] -impl MovieRepository for PostgresMovieRepository { +impl MovieCommand for PostgresMovieRepository { + async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError> { + let id = movie.id().value().to_string(); + let external_metadata_id = movie.external_metadata_id().map(|e| e.value().to_string()); + let title = movie.title().value(); + let release_year = movie.release_year().value() as i64; + let director = movie.director(); + let poster_path = movie.poster_path().map(|p| p.value().to_string()); + + sqlx::query( + "INSERT INTO movies (id, external_metadata_id, title, release_year, director, poster_path) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT(id) DO UPDATE SET + external_metadata_id = excluded.external_metadata_id, + title = excluded.title, + release_year = excluded.release_year, + director = excluded.director, + poster_path = excluded.poster_path", + ) + .bind(&id) + .bind(&external_metadata_id) + .bind(title) + .bind(release_year) + .bind(director) + .bind(&poster_path) + .execute(&self.pool) + .await + .map_err(Self::map_err)?; + + Ok(()) + } + + async fn delete_movie(&self, movie_id: &MovieId) -> Result<(), DomainError> { + let id = movie_id.value().to_string(); + sqlx::query("DELETE FROM movies WHERE id = $1") + .bind(&id) + .execute(&self.pool) + .await + .map_err(Self::map_err)?; + Ok(()) + } +} + +#[async_trait] +impl MovieQuery for PostgresMovieRepository { async fn get_movie_by_external_id( &self, external_metadata_id: &ExternalMetadataId, @@ -81,47 +125,6 @@ impl MovieRepository for PostgresMovieRepository { .collect() } - async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError> { - let id = movie.id().value().to_string(); - let external_metadata_id = movie.external_metadata_id().map(|e| e.value().to_string()); - let title = movie.title().value(); - let release_year = movie.release_year().value() as i64; - let director = movie.director(); - let poster_path = movie.poster_path().map(|p| p.value().to_string()); - - sqlx::query( - "INSERT INTO movies (id, external_metadata_id, title, release_year, director, poster_path) - VALUES ($1, $2, $3, $4, $5, $6) - ON CONFLICT(id) DO UPDATE SET - external_metadata_id = excluded.external_metadata_id, - title = excluded.title, - release_year = excluded.release_year, - director = excluded.director, - poster_path = excluded.poster_path", - ) - .bind(&id) - .bind(&external_metadata_id) - .bind(title) - .bind(release_year) - .bind(director) - .bind(&poster_path) - .execute(&self.pool) - .await - .map_err(Self::map_err)?; - - Ok(()) - } - - async fn delete_movie(&self, movie_id: &MovieId) -> Result<(), DomainError> { - let id = movie_id.value().to_string(); - sqlx::query("DELETE FROM movies WHERE id = $1") - .bind(&id) - .execute(&self.pool) - .await - .map_err(Self::map_err)?; - Ok(()) - } - async fn existing_external_ids( &self, ids: &[ExternalMetadataId], diff --git a/crates/adapters/postgres/src/watch_event.rs b/crates/adapters/postgres/src/watch_event.rs index 2f121e3..f7d6c04 100644 --- a/crates/adapters/postgres/src/watch_event.rs +++ b/crates/adapters/postgres/src/watch_event.rs @@ -2,7 +2,7 @@ use async_trait::async_trait; use domain::{ errors::DomainError, models::{PersistedWatchEvent, WatchEvent, WatchEventSource, WatchEventStatus, WebhookToken}, - ports::{WatchEventRepository, WebhookTokenRepository}, + ports::{WatchEventCommand, WatchEventQuery, WebhookTokenRepository}, value_objects::{MovieId, UserId, WatchEventId, WebhookTokenId}, }; use sqlx::{PgPool, Row}; @@ -27,7 +27,7 @@ impl PostgresWatchEventRepository { } #[async_trait] -impl WatchEventRepository for PostgresWatchEventRepository { +impl WatchEventCommand for PostgresWatchEventRepository { async fn save(&self, event: &WatchEvent) -> Result<(), DomainError> { let id = event.id().value().to_string(); let user_id = event.user_id().value().to_string(); @@ -75,6 +75,41 @@ impl WatchEventRepository for PostgresWatchEventRepository { Ok(()) } + async fn update_status_batch( + &self, + ids: &[WatchEventId], + status: WatchEventStatus, + ) -> Result { + if ids.is_empty() { + return Ok(0); + } + let id_strs: Vec = ids.iter().map(|id| id.value().to_string()).collect(); + let status_str = status.to_string(); + let result = sqlx::query("UPDATE watch_events SET status = $1 WHERE id = ANY($2)") + .bind(&status_str) + .bind(&id_strs) + .execute(&self.pool) + .await + .map_err(map_err)?; + Ok(result.rows_affected()) + } + + async fn delete_non_pending_older_than( + &self, + before: chrono::NaiveDateTime, + ) -> Result { + let result = + sqlx::query("DELETE FROM watch_events WHERE status != 'pending' AND created_at < $1") + .bind(before) + .execute(&self.pool) + .await + .map_err(map_err)?; + Ok(result.rows_affected()) + } +} + +#[async_trait] +impl WatchEventQuery for PostgresWatchEventRepository { async fn list_pending(&self, user_id: &UserId) -> Result, DomainError> { let uid = user_id.value().to_string(); @@ -135,25 +170,6 @@ impl WatchEventRepository for PostgresWatchEventRepository { rows.iter().map(row_to_watch_event).collect() } - async fn update_status_batch( - &self, - ids: &[WatchEventId], - status: WatchEventStatus, - ) -> Result { - if ids.is_empty() { - return Ok(0); - } - let id_strs: Vec = ids.iter().map(|id| id.value().to_string()).collect(); - let status_str = status.to_string(); - let result = sqlx::query("UPDATE watch_events SET status = $1 WHERE id = ANY($2)") - .bind(&status_str) - .bind(&id_strs) - .execute(&self.pool) - .await - .map_err(map_err)?; - Ok(result.rows_affected()) - } - async fn find_duplicate( &self, user_id: &UserId, @@ -175,19 +191,6 @@ impl WatchEventRepository for PostgresWatchEventRepository { Ok(count > 0) } - - async fn delete_non_pending_older_than( - &self, - before: chrono::NaiveDateTime, - ) -> Result { - let result = - sqlx::query("DELETE FROM watch_events WHERE status != 'pending' AND created_at < $1") - .bind(before) - .execute(&self.pool) - .await - .map_err(map_err)?; - Ok(result.rows_affected()) - } } fn row_to_watch_event(row: &sqlx::postgres::PgRow) -> Result { diff --git a/crates/adapters/sqlite/src/lib.rs b/crates/adapters/sqlite/src/lib.rs index b2f5a5c..5146e22 100644 --- a/crates/adapters/sqlite/src/lib.rs +++ b/crates/adapters/sqlite/src/lib.rs @@ -75,7 +75,8 @@ pub async fn migrate(pool: &SqlitePool) -> Result<(), domain::errors::DomainErro pub struct SqliteWireOutput { pub pool: SqlitePool, - pub movie: std::sync::Arc, + pub movie_command: std::sync::Arc, + pub movie_query: std::sync::Arc, pub review: std::sync::Arc, pub diary: std::sync::Arc, pub stats: std::sync::Arc, @@ -119,9 +120,12 @@ pub async fn wire(database_url: &str) -> anyhow::Result { pool.clone(), )); + let movie_repo = std::sync::Arc::new(SqliteMovieRepository::new(pool.clone())); + Ok(SqliteWireOutput { pool: pool.clone(), - movie: std::sync::Arc::new(SqliteMovieRepository::new(pool.clone())) as _, + movie_command: movie_repo.clone() as _, + movie_query: movie_repo as _, review: std::sync::Arc::new(SqliteReviewRepository::new(pool.clone())) as _, diary: std::sync::Arc::new(SqliteDiaryRepository::new(pool.clone())) as _, stats: std::sync::Arc::new(SqliteStatsRepository::new(pool.clone())) as _, diff --git a/crates/adapters/sqlite/src/movie.rs b/crates/adapters/sqlite/src/movie.rs index b44eac2..c34adab 100644 --- a/crates/adapters/sqlite/src/movie.rs +++ b/crates/adapters/sqlite/src/movie.rs @@ -5,7 +5,7 @@ use domain::{ Movie, MovieFilter, MovieSummary, collections::{PageParams, Paginated}, }, - ports::MovieRepository, + ports::{MovieCommand, MovieQuery}, value_objects::{ExternalMetadataId, MovieId, MovieTitle, ReleaseYear}, }; use sqlx::SqlitePool; @@ -28,7 +28,51 @@ impl SqliteMovieRepository { } #[async_trait] -impl MovieRepository for SqliteMovieRepository { +impl MovieCommand for SqliteMovieRepository { + async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError> { + let id = movie.id().value().to_string(); + let external_metadata_id = movie.external_metadata_id().map(|e| e.value().to_string()); + let title = movie.title().value(); + let release_year = movie.release_year().value() as i64; + let director = movie.director(); + let poster_path = movie.poster_path().map(|p| p.value().to_string()); + + sqlx::query( + "INSERT INTO movies (id, external_metadata_id, title, release_year, director, poster_path) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + external_metadata_id = excluded.external_metadata_id, + title = excluded.title, + release_year = excluded.release_year, + director = excluded.director, + poster_path = excluded.poster_path", + ) + .bind(&id) + .bind(&external_metadata_id) + .bind(title) + .bind(release_year) + .bind(director) + .bind(&poster_path) + .execute(&self.pool) + .await + .map_err(Self::map_err)?; + + Ok(()) + } + + async fn delete_movie(&self, movie_id: &MovieId) -> Result<(), DomainError> { + let id = movie_id.value().to_string(); + sqlx::query("DELETE FROM movies WHERE id = ?") + .bind(&id) + .execute(&self.pool) + .await + .map_err(Self::map_err)?; + Ok(()) + } +} + +#[async_trait] +impl MovieQuery for SqliteMovieRepository { async fn get_movie_by_external_id( &self, external_metadata_id: &ExternalMetadataId, @@ -81,47 +125,6 @@ impl MovieRepository for SqliteMovieRepository { .collect() } - async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError> { - let id = movie.id().value().to_string(); - let external_metadata_id = movie.external_metadata_id().map(|e| e.value().to_string()); - let title = movie.title().value(); - let release_year = movie.release_year().value() as i64; - let director = movie.director(); - let poster_path = movie.poster_path().map(|p| p.value().to_string()); - - sqlx::query( - "INSERT INTO movies (id, external_metadata_id, title, release_year, director, poster_path) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET - external_metadata_id = excluded.external_metadata_id, - title = excluded.title, - release_year = excluded.release_year, - director = excluded.director, - poster_path = excluded.poster_path", - ) - .bind(&id) - .bind(&external_metadata_id) - .bind(title) - .bind(release_year) - .bind(director) - .bind(&poster_path) - .execute(&self.pool) - .await - .map_err(Self::map_err)?; - - Ok(()) - } - - async fn delete_movie(&self, movie_id: &MovieId) -> Result<(), DomainError> { - let id = movie_id.value().to_string(); - sqlx::query("DELETE FROM movies WHERE id = ?") - .bind(&id) - .execute(&self.pool) - .await - .map_err(Self::map_err)?; - Ok(()) - } - async fn existing_external_ids( &self, ids: &[ExternalMetadataId], diff --git a/crates/adapters/sqlite/src/watch_event.rs b/crates/adapters/sqlite/src/watch_event.rs index 00af73a..190ffff 100644 --- a/crates/adapters/sqlite/src/watch_event.rs +++ b/crates/adapters/sqlite/src/watch_event.rs @@ -2,7 +2,7 @@ use async_trait::async_trait; use domain::{ errors::DomainError, models::{PersistedWatchEvent, WatchEvent, WatchEventSource, WatchEventStatus, WebhookToken}, - ports::{WatchEventRepository, WebhookTokenRepository}, + ports::{WatchEventCommand, WatchEventQuery, WebhookTokenRepository}, value_objects::{MovieId, UserId, WatchEventId, WebhookTokenId}, }; use sqlx::{Row, SqlitePool}; @@ -38,7 +38,7 @@ impl SqliteWatchEventRepository { } #[async_trait] -impl WatchEventRepository for SqliteWatchEventRepository { +impl WatchEventCommand for SqliteWatchEventRepository { async fn save(&self, event: &WatchEvent) -> Result<(), DomainError> { let id = event.id().value().to_string(); let user_id = event.user_id().value().to_string(); @@ -88,6 +88,44 @@ impl WatchEventRepository for SqliteWatchEventRepository { Ok(()) } + async fn update_status_batch( + &self, + ids: &[WatchEventId], + status: WatchEventStatus, + ) -> Result { + if ids.is_empty() { + return Ok(0); + } + let placeholders: Vec<&str> = ids.iter().map(|_| "?").collect(); + let sql = format!( + "UPDATE watch_events SET status = ? WHERE id IN ({})", + placeholders.join(",") + ); + let mut q = sqlx::query(&sql).bind(status.to_string()); + for id in ids { + q = q.bind(id.value().to_string()); + } + let result = q.execute(&self.pool).await.map_err(map_err)?; + Ok(result.rows_affected()) + } + + async fn delete_non_pending_older_than( + &self, + before: chrono::NaiveDateTime, + ) -> Result { + let before_str = datetime_to_str(&before); + let result = + sqlx::query("DELETE FROM watch_events WHERE status != 'pending' AND created_at < ?") + .bind(&before_str) + .execute(&self.pool) + .await + .map_err(map_err)?; + Ok(result.rows_affected()) + } +} + +#[async_trait] +impl WatchEventQuery for SqliteWatchEventRepository { async fn list_pending(&self, user_id: &UserId) -> Result, DomainError> { let uid = user_id.value().to_string(); @@ -141,27 +179,6 @@ impl WatchEventRepository for SqliteWatchEventRepository { rows.iter().map(row_to_watch_event).collect() } - async fn update_status_batch( - &self, - ids: &[WatchEventId], - status: WatchEventStatus, - ) -> Result { - if ids.is_empty() { - return Ok(0); - } - let placeholders: Vec<&str> = ids.iter().map(|_| "?").collect(); - let sql = format!( - "UPDATE watch_events SET status = ? WHERE id IN ({})", - placeholders.join(",") - ); - let mut q = sqlx::query(&sql).bind(status.to_string()); - for id in ids { - q = q.bind(id.value().to_string()); - } - let result = q.execute(&self.pool).await.map_err(map_err)?; - Ok(result.rows_affected()) - } - async fn find_duplicate( &self, user_id: &UserId, @@ -186,20 +203,6 @@ impl WatchEventRepository for SqliteWatchEventRepository { Ok(count > 0) } - - async fn delete_non_pending_older_than( - &self, - before: chrono::NaiveDateTime, - ) -> Result { - let before_str = datetime_to_str(&before); - let result = - sqlx::query("DELETE FROM watch_events WHERE status != 'pending' AND created_at < ?") - .bind(&before_str) - .execute(&self.pool) - .await - .map_err(map_err)?; - Ok(result.rows_affected()) - } } fn row_to_watch_event(row: &sqlx::sqlite::SqliteRow) -> Result { diff --git a/crates/api-types/src/users.rs b/crates/api-types/src/users.rs index 2069d40..9855cc2 100644 --- a/crates/api-types/src/users.rs +++ b/crates/api-types/src/users.rs @@ -79,6 +79,14 @@ pub struct UserProfileBase { pub banner_url: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(untagged)] +pub enum ProfileViewData { + Entries { entries: DiaryResponse }, + History { history: Vec }, + Trends { trends: UserTrendsDto }, +} + #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct UserProfileResponse { pub user_id: Uuid, @@ -87,12 +95,8 @@ pub struct UserProfileResponse { pub stats: UserStatsDto, pub following_count: usize, pub followers_count: usize, - /// Populated for view=recent and view=ratings - pub entries: Option, - /// Populated for view=history - pub history: Option>, - /// Populated for view=trends - pub trends: Option, + #[serde(flatten, skip_serializing_if = "Option::is_none")] + pub view_data: Option, #[serde(skip_serializing_if = "Option::is_none")] pub goals: Option>, #[serde(default, skip_serializing_if = "std::ops::Not::not")] diff --git a/crates/application/src/diary/delete_review.rs b/crates/application/src/diary/delete_review.rs index 8a260dd..0b80423 100644 --- a/crates/application/src/diary/delete_review.rs +++ b/crates/application/src/diary/delete_review.rs @@ -36,7 +36,7 @@ pub async fn execute(deps: &DeleteReviewDeps, cmd: DeleteReviewCommand) -> Resul let history = deps.diary.get_review_history(&movie_id).await?; if history.viewings().is_empty() { let poster_path = history.movie().poster_path().cloned(); - deps.movie.delete_movie(&movie_id).await?; + deps.movie_command.delete_movie(&movie_id).await?; // best-effort: movie is already deleted, so publish failure is non-fatal if let Err(e) = deps .event_publisher diff --git a/crates/application/src/diary/deps.rs b/crates/application/src/diary/deps.rs index d10054f..2fe3aee 100644 --- a/crates/application/src/diary/deps.rs +++ b/crates/application/src/diary/deps.rs @@ -1,8 +1,8 @@ use std::sync::Arc; use domain::ports::{ - DiaryRepository, EventPublisher, MovieProfileRepository, MovieRepository, ReviewRepository, - SocialQueryPort, + DiaryRepository, EventPublisher, MovieCommand, MovieProfileRepository, MovieQuery, + ReviewRepository, SocialQueryPort, }; use crate::config::AppConfig; @@ -10,7 +10,7 @@ use crate::config::AppConfig; pub struct DeleteReviewDeps { pub review: Arc, pub diary: Arc, - pub movie: Arc, + pub movie_command: Arc, pub event_publisher: Arc, } @@ -20,7 +20,7 @@ pub struct EditReviewDeps { } pub struct GetMovieSocialPageDeps { - pub movie: Arc, + pub movie_query: Arc, pub diary: Arc, pub movie_profile: Arc, } diff --git a/crates/application/src/diary/get_movie_social_page.rs b/crates/application/src/diary/get_movie_social_page.rs index ac58eaa..62d5e21 100644 --- a/crates/application/src/diary/get_movie_social_page.rs +++ b/crates/application/src/diary/get_movie_social_page.rs @@ -24,7 +24,7 @@ pub async fn execute( let page = PageParams::new(Some(query.limit), Some(query.offset))?; let movie = deps - .movie + .movie_query .get_movie_by_id(&movie_id) .await? .ok_or_else(|| DomainError::NotFound(format!("Movie {}", query.movie_id)))?; diff --git a/crates/application/src/diary/movie_resolver.rs b/crates/application/src/diary/movie_resolver.rs index e404f62..221c7b8 100644 --- a/crates/application/src/diary/movie_resolver.rs +++ b/crates/application/src/diary/movie_resolver.rs @@ -2,14 +2,14 @@ use async_trait::async_trait; use domain::{ errors::DomainError, models::{MetadataSearchCriteria, Movie}, - ports::{MetadataClient, MovieRepository}, + ports::{MetadataClient, MovieQuery}, value_objects::{ExternalMetadataId, MovieTitle, ReleaseYear}, }; use crate::diary::commands::MovieInput; pub struct MovieResolverDeps<'a> { - pub repository: &'a dyn MovieRepository, + pub repository: &'a dyn MovieQuery, pub metadata_client: &'a dyn MetadataClient, } diff --git a/crates/application/src/diary/review_logger.rs b/crates/application/src/diary/review_logger.rs index ccff5e3..3f1edd6 100644 --- a/crates/application/src/diary/review_logger.rs +++ b/crates/application/src/diary/review_logger.rs @@ -6,7 +6,8 @@ use domain::{ events::DomainEvent, models::Review, ports::{ - EventPublisher, MetadataClient, MovieRepository, ReviewRepository, WatchlistRepository, + EventPublisher, MetadataClient, MovieCommand, MovieQuery, ReviewRepository, + WatchlistRepository, }, value_objects::{Comment, Rating, UserId}, }; @@ -16,7 +17,8 @@ use crate::movies::resolve::resolve_and_persist_movie; use crate::ports::ReviewLogger; pub struct DefaultReviewLogger { - movie_repo: Arc, + movie_command: Arc, + movie_query: Arc, review_repo: Arc, watchlist_repo: Arc, metadata_client: Arc, @@ -25,14 +27,16 @@ pub struct DefaultReviewLogger { impl DefaultReviewLogger { pub fn new( - movie_repo: Arc, + movie_command: Arc, + movie_query: Arc, review_repo: Arc, watchlist_repo: Arc, metadata_client: Arc, event_publisher: Arc, ) -> Self { Self { - movie_repo, + movie_command, + movie_query, review_repo, watchlist_repo, metadata_client, @@ -50,7 +54,8 @@ impl ReviewLogger for DefaultReviewLogger { let (movie, is_new_movie) = resolve_and_persist_movie( &cmd.input, - self.movie_repo.as_ref(), + self.movie_command.as_ref(), + self.movie_query.as_ref(), self.metadata_client.as_ref(), self.event_publisher.as_ref(), ) @@ -58,7 +63,7 @@ impl ReviewLogger for DefaultReviewLogger { // Always upsert: even existing movies may have updated metadata if !is_new_movie { - self.movie_repo.upsert_movie(&movie).await?; + self.movie_command.upsert_movie(&movie).await?; } let review = Review::new( diff --git a/crates/application/src/diary/tests/delete_review.rs b/crates/application/src/diary/tests/delete_review.rs index e3fd004..565d12b 100644 --- a/crates/application/src/diary/tests/delete_review.rs +++ b/crates/application/src/diary/tests/delete_review.rs @@ -4,7 +4,7 @@ use chrono::Utc; use domain::{ models::{Movie, Review}, - ports::{MovieRepository, ReviewRepository}, + ports::{MovieCommand, MovieQuery, ReviewRepository}, testing::{ FakeDiaryRepository, InMemoryMovieRepository, InMemoryReviewRepository, NoopEventPublisher, }, @@ -55,7 +55,7 @@ async fn test_delete_review_removes_it() { let deps = DeleteReviewDeps { review: Arc::clone(&reviews) as _, diary: diary.clone() as _, - movie: Arc::clone(&movies) as _, + movie_command: Arc::clone(&movies) as _, event_publisher: Arc::clone(&events) as _, }; @@ -93,7 +93,7 @@ async fn test_delete_review_wrong_user_is_unauthorized() { let deps = DeleteReviewDeps { review: Arc::clone(&reviews) as _, diary: diary as _, - movie: movies as _, + movie_command: movies as _, event_publisher: Arc::clone(&events) as _, }; diff --git a/crates/application/src/diary/tests/get_movie_social_page.rs b/crates/application/src/diary/tests/get_movie_social_page.rs index 1252b2b..394d03d 100644 --- a/crates/application/src/diary/tests/get_movie_social_page.rs +++ b/crates/application/src/diary/tests/get_movie_social_page.rs @@ -4,7 +4,7 @@ use uuid::Uuid; use domain::{ models::Movie, - ports::MovieRepository, + ports::MovieCommand, testing::{FakeDiaryRepository, InMemoryMovieProfileRepository, InMemoryMovieRepository}, value_objects::{MovieTitle, ReleaseYear}, }; @@ -17,7 +17,7 @@ use crate::{ #[tokio::test] async fn fails_when_movie_not_found() { let deps = GetMovieSocialPageDeps { - movie: InMemoryMovieRepository::new(), + movie_query: InMemoryMovieRepository::new(), diary: FakeDiaryRepository::new() as _, movie_profile: InMemoryMovieProfileRepository::new(), }; @@ -50,7 +50,7 @@ async fn returns_movie_social_page() { movies.upsert_movie(&movie).await.unwrap(); let deps = GetMovieSocialPageDeps { - movie: Arc::clone(&movies) as _, + movie_query: Arc::clone(&movies) as _, diary: FakeDiaryRepository::new() as _, movie_profile: InMemoryMovieProfileRepository::new(), }; diff --git a/crates/application/src/diary/tests/log_review.rs b/crates/application/src/diary/tests/log_review.rs index 77d242e..431ba32 100644 --- a/crates/application/src/diary/tests/log_review.rs +++ b/crates/application/src/diary/tests/log_review.rs @@ -4,10 +4,10 @@ use chrono::Utc; use domain::{ models::Movie, + ports::MovieCommand, value_objects::{MovieTitle, ReleaseYear}, }; -use domain::ports::MovieRepository; use domain::testing::{InMemoryMovieRepository, InMemoryReviewRepository, NoopEventPublisher}; use crate::{ @@ -23,6 +23,7 @@ fn build_logger( events: &Arc, ) -> Arc { Arc::new(DefaultReviewLogger::new( + Arc::clone(movies) as _, Arc::clone(movies) as _, Arc::clone(reviews) as _, TestContextBuilder::new().watchlist_repo, diff --git a/crates/application/src/diary/tests/movie_resolver.rs b/crates/application/src/diary/tests/movie_resolver.rs index 1316833..b9416a8 100644 --- a/crates/application/src/diary/tests/movie_resolver.rs +++ b/crates/application/src/diary/tests/movie_resolver.rs @@ -3,7 +3,7 @@ use crate::diary::commands::MovieInput; use domain::{ errors::DomainError, models::{MetadataSearchCriteria, Movie}, - ports::MovieRepository, + ports::MovieQuery, value_objects::{ExternalMetadataId, MovieId, MovieTitle, PosterUrl, ReleaseYear}, }; @@ -32,7 +32,7 @@ struct RepoEmpty; struct RepoWithTitleMatch(Movie); #[async_trait::async_trait] -impl MovieRepository for RepoWithExternalMovie { +impl MovieQuery for RepoWithExternalMovie { async fn get_movie_by_external_id( &self, _: &ExternalMetadataId, @@ -49,12 +49,6 @@ impl MovieRepository for RepoWithExternalMovie { ) -> Result, DomainError> { panic!("unexpected") } - async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { - panic!("unexpected") - } - async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { - panic!("unexpected") - } async fn existing_external_ids( &self, _: &[ExternalMetadataId], @@ -83,7 +77,7 @@ impl MovieRepository for RepoWithExternalMovie { } #[async_trait::async_trait] -impl MovieRepository for RepoEmpty { +impl MovieQuery for RepoEmpty { async fn get_movie_by_external_id( &self, _: &ExternalMetadataId, @@ -100,12 +94,6 @@ impl MovieRepository for RepoEmpty { ) -> Result, DomainError> { Ok(vec![]) } - async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { - panic!("unexpected") - } - async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { - panic!("unexpected") - } async fn existing_external_ids( &self, _: &[ExternalMetadataId], @@ -134,7 +122,7 @@ impl MovieRepository for RepoEmpty { } #[async_trait::async_trait] -impl MovieRepository for RepoWithTitleMatch { +impl MovieQuery for RepoWithTitleMatch { async fn get_movie_by_external_id( &self, _: &ExternalMetadataId, @@ -151,12 +139,6 @@ impl MovieRepository for RepoWithTitleMatch { ) -> Result, DomainError> { Ok(vec![self.0.clone()]) } - async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { - panic!("unexpected") - } - async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { - panic!("unexpected") - } async fn existing_external_ids( &self, _: &[ExternalMetadataId], diff --git a/crates/application/src/diary/tests/review_logger.rs b/crates/application/src/diary/tests/review_logger.rs index 104be45..15ca1ac 100644 --- a/crates/application/src/diary/tests/review_logger.rs +++ b/crates/application/src/diary/tests/review_logger.rs @@ -6,7 +6,7 @@ use domain::{ errors::DomainError, models::WatchlistEntry, models::{MetadataSearchCriteria, Movie}, - ports::{MetadataClient, MovieRepository, WatchlistRepository}, + ports::{MetadataClient, MovieCommand, WatchlistRepository}, testing::{ FakeMetadataClient, InMemoryMovieRepository, InMemoryReviewRepository, InMemoryWatchlistRepository, NoopEventPublisher, @@ -26,6 +26,7 @@ fn make_logger( events: &Arc, ) -> DefaultReviewLogger { DefaultReviewLogger::new( + Arc::clone(movies) as _, Arc::clone(movies) as _, Arc::clone(reviews) as _, Arc::clone(watchlist) as _, @@ -276,6 +277,7 @@ async fn publishes_movie_discovered_for_new_movie_with_external_id() { let events = NoopEventPublisher::new(); let logger = DefaultReviewLogger::new( + Arc::clone(&movies) as _, Arc::clone(&movies) as _, Arc::clone(&reviews) as _, Arc::clone(&watchlist) as _, diff --git a/crates/application/src/goals/create.rs b/crates/application/src/goals/create.rs index f3e6eb3..bf8b9a9 100644 --- a/crates/application/src/goals/create.rs +++ b/crates/application/src/goals/create.rs @@ -1,24 +1,19 @@ -use std::sync::Arc; - use domain::{ errors::DomainError, events::DomainEvent, models::{Goal, GoalType, GoalWithProgress}, - ports::{EventPublisher, GoalRepository, StatsRepository}, value_objects::UserId, }; -use super::commands::CreateGoalCommand; +use super::{commands::CreateGoalCommand, deps::GoalCommandDeps}; pub async fn execute( - goal: Arc, - stats: Arc, - event_publisher: Arc, + deps: &GoalCommandDeps, cmd: CreateGoalCommand, ) -> Result { let user_id = UserId::from_uuid(cmd.user_id); - let existing = goal.find_by_user_and_year(&user_id, cmd.year).await?; + let existing = deps.goal.find_by_user_and_year(&user_id, cmd.year).await?; if existing.is_some() { return Err(DomainError::ValidationError( "Goal already exists for this year".into(), @@ -31,11 +26,11 @@ pub async fn execute( cmd.target_count, GoalType::Movies, )?; - goal.save(&g).await?; + deps.goal.save(&g).await?; - let current_count = stats.count_reviews_in_year(&user_id, cmd.year).await?; + let current_count = deps.stats.count_reviews_in_year(&user_id, cmd.year).await?; - event_publisher + deps.event_publisher .publish(&DomainEvent::GoalCreated { goal_id: g.id().clone(), user_id, diff --git a/crates/application/src/goals/delete.rs b/crates/application/src/goals/delete.rs index a318264..2095f04 100644 --- a/crates/application/src/goals/delete.rs +++ b/crates/application/src/goals/delete.rs @@ -1,29 +1,26 @@ -use std::sync::Arc; - use domain::{ errors::DomainError, events::DomainEvent, - ports::{EventPublisher, GoalRepository}, value_objects::UserId, }; -use super::commands::DeleteGoalCommand; +use super::{commands::DeleteGoalCommand, deps::GoalCommandDeps}; pub async fn execute( - goal: Arc, - event_publisher: Arc, + deps: &GoalCommandDeps, cmd: DeleteGoalCommand, ) -> Result<(), DomainError> { let user_id = UserId::from_uuid(cmd.user_id); - let g = goal + let g = deps + .goal .find_by_user_and_year(&user_id, cmd.year) .await? .ok_or_else(|| DomainError::NotFound(format!("Goal for year {}", cmd.year)))?; - goal.delete(g.id(), &user_id).await?; + deps.goal.delete(g.id(), &user_id).await?; - event_publisher + deps.event_publisher .publish(&DomainEvent::GoalDeleted { goal_id: g.id().clone(), user_id, diff --git a/crates/application/src/goals/deps.rs b/crates/application/src/goals/deps.rs new file mode 100644 index 0000000..d181399 --- /dev/null +++ b/crates/application/src/goals/deps.rs @@ -0,0 +1,14 @@ +use std::sync::Arc; + +use domain::ports::{EventPublisher, GoalRepository, StatsRepository}; + +pub struct GoalCommandDeps { + pub goal: Arc, + pub stats: Arc, + pub event_publisher: Arc, +} + +pub struct GoalQueryDeps { + pub goal: Arc, + pub stats: Arc, +} diff --git a/crates/application/src/goals/get.rs b/crates/application/src/goals/get.rs index c2b4fba..cb41900 100644 --- a/crates/application/src/goals/get.rs +++ b/crates/application/src/goals/get.rs @@ -1,26 +1,22 @@ -use std::sync::Arc; - use domain::{ errors::DomainError, models::GoalWithProgress, - ports::{GoalRepository, StatsRepository}, value_objects::UserId, }; -use super::queries::GetGoalQuery; +use super::{deps::GoalQueryDeps, queries::GetGoalQuery}; pub async fn execute( - goal: Arc, - stats: Arc, + deps: &GoalQueryDeps, query: GetGoalQuery, ) -> Result, DomainError> { let user_id = UserId::from_uuid(query.user_id); - let found = goal.find_by_user_and_year(&user_id, query.year).await?; + let found = deps.goal.find_by_user_and_year(&user_id, query.year).await?; let Some(g) = found else { return Ok(None) }; - let current_count = stats.count_reviews_in_year(&user_id, query.year).await?; + let current_count = deps.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 acc0673..340f2eb 100644 --- a/crates/application/src/goals/list.rs +++ b/crates/application/src/goals/list.rs @@ -1,25 +1,21 @@ -use std::sync::Arc; - use domain::{ errors::DomainError, models::GoalWithProgress, - ports::{GoalRepository, StatsRepository}, value_objects::UserId, }; -use super::queries::ListGoalsQuery; +use super::{deps::GoalQueryDeps, queries::ListGoalsQuery}; pub async fn execute( - goal: Arc, - stats: Arc, + deps: &GoalQueryDeps, query: ListGoalsQuery, ) -> Result, DomainError> { let user_id = UserId::from_uuid(query.user_id); - let goals = goal.list_for_user(&user_id).await?; + let goals = deps.goal.list_for_user(&user_id).await?; let mut result = Vec::with_capacity(goals.len()); for g in goals { - let current_count = stats.count_reviews_in_year(&user_id, g.year()).await?; + let current_count = deps.stats.count_reviews_in_year(&user_id, g.year()).await?; result.push(GoalWithProgress { goal: g, current_count, diff --git a/crates/application/src/goals/mod.rs b/crates/application/src/goals/mod.rs index ed059e2..fb8d377 100644 --- a/crates/application/src/goals/mod.rs +++ b/crates/application/src/goals/mod.rs @@ -1,6 +1,7 @@ pub mod commands; pub mod create; pub mod delete; +pub mod deps; pub mod get; pub mod list; pub mod queries; diff --git a/crates/application/src/goals/tests/create.rs b/crates/application/src/goals/tests/create.rs index de9014b..cb762c4 100644 --- a/crates/application/src/goals/tests/create.rs +++ b/crates/application/src/goals/tests/create.rs @@ -4,6 +4,7 @@ use domain::events::DomainEvent; use domain::testing::{FakeStatsRepository, InMemoryGoalRepository, NoopEventPublisher}; use uuid::Uuid; +use crate::goals::deps::GoalCommandDeps; use crate::goals::{commands::CreateGoalCommand, create}; use crate::test_helpers::TestContextBuilder; @@ -12,11 +13,14 @@ async fn creates_goal_and_returns_progress() { let goals = InMemoryGoalRepository::new(); let stats = FakeStatsRepository::new(); let events = NoopEventPublisher::new(); + let deps = GoalCommandDeps { + goal: Arc::clone(&goals) as _, + stats: Arc::clone(&stats) as _, + event_publisher: Arc::clone(&events) as _, + }; let result = create::execute( - Arc::clone(&goals) as _, - Arc::clone(&stats) as _, - Arc::clone(&events) as _, + &deps, CreateGoalCommand { user_id: Uuid::nil(), year: 2025, @@ -38,11 +42,14 @@ async fn creates_goal_with_review_count() { let stats = FakeStatsRepository::new(); stats.set_review_count(Uuid::nil(), 2025, 5); let events = NoopEventPublisher::new(); + let deps = GoalCommandDeps { + goal: Arc::clone(&goals) as _, + stats: Arc::clone(&stats) as _, + event_publisher: Arc::clone(&events) as _, + }; let result = create::execute( - Arc::clone(&goals) as _, - Arc::clone(&stats) as _, - Arc::clone(&events) as _, + &deps, CreateGoalCommand { user_id: Uuid::nil(), year: 2025, @@ -60,11 +67,14 @@ async fn creates_goal_with_review_count() { async fn emits_goal_created_event() { let b = TestContextBuilder::new(); let events = NoopEventPublisher::new(); + let deps = GoalCommandDeps { + goal: b.goal_repo.clone(), + stats: b.stats_repo.clone(), + event_publisher: Arc::clone(&events) as _, + }; create::execute( - b.goal_repo.clone(), - b.stats_repo.clone(), - Arc::clone(&events) as _, + &deps, CreateGoalCommand { user_id: Uuid::nil(), year: 2025, @@ -85,25 +95,21 @@ async fn emits_goal_created_event() { #[tokio::test] async fn rejects_duplicate_year() { let b = TestContextBuilder::new(); + let deps = GoalCommandDeps { + goal: b.goal_repo.clone(), + stats: b.stats_repo.clone(), + event_publisher: b.event_publisher.clone(), + }; let cmd = CreateGoalCommand { user_id: Uuid::nil(), year: 2025, target_count: 10, }; - create::execute( - b.goal_repo.clone(), - b.stats_repo.clone(), - b.event_publisher.clone(), - cmd, - ) - .await - .unwrap(); + create::execute(&deps, cmd).await.unwrap(); let result = create::execute( - b.goal_repo.clone(), - b.stats_repo.clone(), - b.event_publisher.clone(), + &deps, CreateGoalCommand { user_id: Uuid::nil(), year: 2025, @@ -118,10 +124,13 @@ async fn rejects_duplicate_year() { #[tokio::test] async fn rejects_year_before_2020() { let b = TestContextBuilder::new(); + let deps = GoalCommandDeps { + goal: b.goal_repo.clone(), + stats: b.stats_repo.clone(), + event_publisher: b.event_publisher.clone(), + }; let result = create::execute( - b.goal_repo.clone(), - b.stats_repo.clone(), - b.event_publisher.clone(), + &deps, CreateGoalCommand { user_id: Uuid::nil(), year: 2019, @@ -136,10 +145,13 @@ async fn rejects_year_before_2020() { #[tokio::test] async fn rejects_zero_target() { let b = TestContextBuilder::new(); + let deps = GoalCommandDeps { + goal: b.goal_repo.clone(), + stats: b.stats_repo.clone(), + event_publisher: b.event_publisher.clone(), + }; let result = create::execute( - b.goal_repo.clone(), - b.stats_repo.clone(), - b.event_publisher.clone(), + &deps, CreateGoalCommand { user_id: Uuid::nil(), year: 2025, diff --git a/crates/application/src/goals/tests/delete.rs b/crates/application/src/goals/tests/delete.rs index 67b154e..7f82ac7 100644 --- a/crates/application/src/goals/tests/delete.rs +++ b/crates/application/src/goals/tests/delete.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use domain::testing::{FakeStatsRepository, InMemoryGoalRepository, NoopEventPublisher}; use uuid::Uuid; +use crate::goals::deps::GoalCommandDeps; use crate::goals::{ commands::{CreateGoalCommand, DeleteGoalCommand}, create, delete, @@ -14,11 +15,14 @@ async fn deletes_existing_goal() { let goals = InMemoryGoalRepository::new(); let stats = FakeStatsRepository::new(); let events = NoopEventPublisher::new(); + let deps = GoalCommandDeps { + goal: Arc::clone(&goals) as _, + stats: Arc::clone(&stats) as _, + event_publisher: Arc::clone(&events) as _, + }; create::execute( - Arc::clone(&goals) as _, - Arc::clone(&stats) as _, - Arc::clone(&events) as _, + &deps, CreateGoalCommand { user_id: Uuid::nil(), year: 2025, @@ -30,8 +34,7 @@ async fn deletes_existing_goal() { assert_eq!(goals.count(), 1); delete::execute( - Arc::clone(&goals) as _, - Arc::clone(&events) as _, + &deps, DeleteGoalCommand { user_id: Uuid::nil(), year: 2025, @@ -46,9 +49,13 @@ async fn deletes_existing_goal() { #[tokio::test] async fn fails_when_not_found() { let b = TestContextBuilder::new(); + let deps = GoalCommandDeps { + goal: b.goal_repo.clone(), + stats: b.stats_repo.clone(), + event_publisher: b.event_publisher.clone(), + }; let result = delete::execute( - b.goal_repo.clone(), - b.event_publisher.clone(), + &deps, DeleteGoalCommand { user_id: Uuid::nil(), year: 2025, diff --git a/crates/application/src/goals/tests/get.rs b/crates/application/src/goals/tests/get.rs index e361bf7..42c983d 100644 --- a/crates/application/src/goals/tests/get.rs +++ b/crates/application/src/goals/tests/get.rs @@ -1,15 +1,24 @@ use uuid::Uuid; +use crate::goals::deps::{GoalCommandDeps, GoalQueryDeps}; use crate::goals::{commands::CreateGoalCommand, create, get, queries::GetGoalQuery}; use crate::test_helpers::TestContextBuilder; #[tokio::test] async fn returns_goal_when_exists() { let b = TestContextBuilder::new(); + let cmd_deps = GoalCommandDeps { + goal: b.goal_repo.clone(), + stats: b.stats_repo.clone(), + event_publisher: b.event_publisher.clone(), + }; + let query_deps = GoalQueryDeps { + goal: b.goal_repo.clone(), + stats: b.stats_repo.clone(), + }; + create::execute( - b.goal_repo.clone(), - b.stats_repo.clone(), - b.event_publisher.clone(), + &cmd_deps, CreateGoalCommand { user_id: Uuid::nil(), year: 2025, @@ -20,8 +29,7 @@ async fn returns_goal_when_exists() { .unwrap(); let result = get::execute( - b.goal_repo.clone(), - b.stats_repo.clone(), + &query_deps, GetGoalQuery { user_id: Uuid::nil(), year: 2025, @@ -37,9 +45,12 @@ async fn returns_goal_when_exists() { #[tokio::test] async fn returns_none_when_missing() { let b = TestContextBuilder::new(); + let query_deps = GoalQueryDeps { + goal: b.goal_repo.clone(), + stats: b.stats_repo.clone(), + }; let result = get::execute( - b.goal_repo.clone(), - b.stats_repo.clone(), + &query_deps, 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 0563982..b320f17 100644 --- a/crates/application/src/goals/tests/list.rs +++ b/crates/application/src/goals/tests/list.rs @@ -1,14 +1,18 @@ use uuid::Uuid; +use crate::goals::deps::{GoalCommandDeps, GoalQueryDeps}; use crate::goals::{commands::CreateGoalCommand, create, list, queries::ListGoalsQuery}; use crate::test_helpers::TestContextBuilder; #[tokio::test] async fn returns_empty_when_no_goals() { let b = TestContextBuilder::new(); + let query_deps = GoalQueryDeps { + goal: b.goal_repo.clone(), + stats: b.stats_repo.clone(), + }; let result = list::execute( - b.goal_repo.clone(), - b.stats_repo.clone(), + &query_deps, ListGoalsQuery { user_id: Uuid::nil(), }, @@ -22,11 +26,19 @@ async fn returns_empty_when_no_goals() { #[tokio::test] async fn returns_all_goals_for_user() { let b = TestContextBuilder::new(); + let cmd_deps = GoalCommandDeps { + goal: b.goal_repo.clone(), + stats: b.stats_repo.clone(), + event_publisher: b.event_publisher.clone(), + }; + let query_deps = GoalQueryDeps { + goal: b.goal_repo.clone(), + stats: b.stats_repo.clone(), + }; + for year in [2023, 2024, 2025] { create::execute( - b.goal_repo.clone(), - b.stats_repo.clone(), - b.event_publisher.clone(), + &cmd_deps, CreateGoalCommand { user_id: Uuid::nil(), year, @@ -38,8 +50,7 @@ async fn returns_all_goals_for_user() { } let result = list::execute( - b.goal_repo.clone(), - b.stats_repo.clone(), + &query_deps, ListGoalsQuery { user_id: Uuid::nil(), }, diff --git a/crates/application/src/goals/tests/update.rs b/crates/application/src/goals/tests/update.rs index 13fc289..5cec9db 100644 --- a/crates/application/src/goals/tests/update.rs +++ b/crates/application/src/goals/tests/update.rs @@ -1,5 +1,6 @@ use uuid::Uuid; +use crate::goals::deps::GoalCommandDeps; use crate::goals::{ commands::{CreateGoalCommand, UpdateGoalCommand}, create, update, @@ -9,10 +10,14 @@ use crate::test_helpers::TestContextBuilder; #[tokio::test] async fn updates_target_count() { let b = TestContextBuilder::new(); + let deps = GoalCommandDeps { + goal: b.goal_repo.clone(), + stats: b.stats_repo.clone(), + event_publisher: b.event_publisher.clone(), + }; + create::execute( - b.goal_repo.clone(), - b.stats_repo.clone(), - b.event_publisher.clone(), + &deps, CreateGoalCommand { user_id: Uuid::nil(), year: 2025, @@ -23,9 +28,7 @@ async fn updates_target_count() { .unwrap(); let result = update::execute( - b.goal_repo.clone(), - b.stats_repo.clone(), - b.event_publisher.clone(), + &deps, UpdateGoalCommand { user_id: Uuid::nil(), year: 2025, @@ -41,10 +44,13 @@ async fn updates_target_count() { #[tokio::test] async fn fails_when_goal_not_found() { let b = TestContextBuilder::new(); + let deps = GoalCommandDeps { + goal: b.goal_repo.clone(), + stats: b.stats_repo.clone(), + event_publisher: b.event_publisher.clone(), + }; let result = update::execute( - b.goal_repo.clone(), - b.stats_repo.clone(), - b.event_publisher.clone(), + &deps, UpdateGoalCommand { user_id: Uuid::nil(), year: 2025, @@ -59,10 +65,14 @@ async fn fails_when_goal_not_found() { #[tokio::test] async fn rejects_zero_target() { let b = TestContextBuilder::new(); + let deps = GoalCommandDeps { + goal: b.goal_repo.clone(), + stats: b.stats_repo.clone(), + event_publisher: b.event_publisher.clone(), + }; + create::execute( - b.goal_repo.clone(), - b.stats_repo.clone(), - b.event_publisher.clone(), + &deps, CreateGoalCommand { user_id: Uuid::nil(), year: 2025, @@ -73,9 +83,7 @@ async fn rejects_zero_target() { .unwrap(); let result = update::execute( - b.goal_repo.clone(), - b.stats_repo.clone(), - b.event_publisher.clone(), + &deps, UpdateGoalCommand { user_id: Uuid::nil(), year: 2025, diff --git a/crates/application/src/goals/update.rs b/crates/application/src/goals/update.rs index 4d741fe..5aa890d 100644 --- a/crates/application/src/goals/update.rs +++ b/crates/application/src/goals/update.rs @@ -1,34 +1,30 @@ -use std::sync::Arc; - use domain::{ errors::DomainError, events::DomainEvent, models::GoalWithProgress, - ports::{EventPublisher, GoalRepository, StatsRepository}, value_objects::UserId, }; -use super::commands::UpdateGoalCommand; +use super::{commands::UpdateGoalCommand, deps::GoalCommandDeps}; pub async fn execute( - goal: Arc, - stats: Arc, - event_publisher: Arc, + deps: &GoalCommandDeps, cmd: UpdateGoalCommand, ) -> Result { let user_id = UserId::from_uuid(cmd.user_id); - let mut g = goal + let mut g = deps + .goal .find_by_user_and_year(&user_id, cmd.year) .await? .ok_or_else(|| DomainError::NotFound(format!("Goal for year {}", cmd.year)))?; g.update_target(cmd.target_count)?; - goal.update(&g).await?; + deps.goal.update(&g).await?; - let current_count = stats.count_reviews_in_year(&user_id, cmd.year).await?; + let current_count = deps.stats.count_reviews_in_year(&user_id, cmd.year).await?; - event_publisher + deps.event_publisher .publish(&DomainEvent::GoalUpdated { goal_id: g.id().clone(), user_id, diff --git a/crates/application/src/import/apply_mapping.rs b/crates/application/src/import/apply_mapping.rs index a08f888..a9b3261 100644 --- a/crates/application/src/import/apply_mapping.rs +++ b/crates/application/src/import/apply_mapping.rs @@ -3,22 +3,21 @@ use std::sync::Arc; use domain::{ errors::DomainError, models::{AnnotatedRow, import::RowResult}, - ports::{DocumentParser, ImportSessionRepository, MovieRepository}, + ports::MovieQuery, value_objects::{ExternalMetadataId, ImportSessionId, MovieTitle, ReleaseYear, UserId}, }; -use crate::import::commands::ApplyImportMappingCommand; +use super::{commands::ApplyImportMappingCommand, deps::ApplyMappingDeps}; pub async fn execute( - import_session: Arc, - document_parser: Arc, - movie: Arc, + deps: &ApplyMappingDeps, cmd: ApplyImportMappingCommand, ) -> Result, DomainError> { let user_id = UserId::from_uuid(cmd.user_id); let session_id = ImportSessionId::from_uuid(cmd.session_id); let mappings = cmd.mappings; - let mut session = import_session + let mut session = deps + .import_session .get(&session_id, &user_id) .await? .ok_or_else(|| DomainError::NotFound("import session".into()))?; @@ -28,20 +27,20 @@ pub async fn execute( .clone() .ok_or_else(|| DomainError::ValidationError("session has no parsed file".into()))?; - let mut annotated = document_parser.apply_mapping(&parsed, &mappings); + let mut annotated = deps.document_parser.apply_mapping(&parsed, &mappings); - mark_duplicates(movie, &mut annotated).await?; + mark_duplicates(deps.movie_query.clone(), &mut annotated).await?; session.field_mappings = Some(mappings); session.row_results = Some(annotated.clone()); - import_session.update(&session).await?; + deps.import_session.update(&session).await?; Ok(annotated) } async fn mark_duplicates( - movie: Arc, + movie: Arc, rows: &mut [AnnotatedRow], ) -> Result<(), DomainError> { let mut ext_ids = Vec::new(); diff --git a/crates/application/src/import/apply_profile.rs b/crates/application/src/import/apply_profile.rs index 98f9617..bb999e0 100644 --- a/crates/application/src/import/apply_profile.rs +++ b/crates/application/src/import/apply_profile.rs @@ -1,34 +1,33 @@ -use std::sync::Arc; - -use crate::import::commands::ApplyImportProfileCommand; use domain::{ errors::DomainError, - ports::{ImportProfileRepository, ImportSessionRepository}, value_objects::{ImportProfileId, ImportSessionId, UserId}, }; +use super::{commands::ApplyImportProfileCommand, deps::ApplyProfileDeps}; + /// Copies the profile's field_mappings onto the session. Caller must then invoke /// apply_import_mapping to regenerate row_results with the new mappings. pub async fn execute( - import_profile: Arc, - import_session: Arc, + deps: &ApplyProfileDeps, cmd: ApplyImportProfileCommand, ) -> Result<(), DomainError> { let user_id = UserId::from_uuid(cmd.user_id); let session_id = ImportSessionId::from_uuid(cmd.session_id); let profile_id = ImportProfileId::from_uuid(cmd.profile_id); - let profile = import_profile + let profile = deps + .import_profile .get(&profile_id, &user_id) .await? .ok_or_else(|| DomainError::NotFound("import profile".into()))?; - let mut session = import_session + let mut session = deps + .import_session .get(&session_id, &user_id) .await? .ok_or_else(|| DomainError::NotFound("import session".into()))?; session.field_mappings = Some(profile.field_mappings); session.row_results = None; - import_session.update(&session).await + deps.import_session.update(&session).await } #[cfg(test)] diff --git a/crates/application/src/import/create_session.rs b/crates/application/src/import/create_session.rs index d8a66f2..6e22b53 100644 --- a/crates/application/src/import/create_session.rs +++ b/crates/application/src/import/create_session.rs @@ -1,13 +1,10 @@ -use std::sync::Arc; - use domain::{ errors::DomainError, models::ImportSession, - ports::{DocumentParser, ImportSessionRepository}, value_objects::{ImportSessionId, UserId}, }; -use crate::import::commands::CreateImportSessionCommand; +use super::{commands::CreateImportSessionCommand, deps::CreateSessionDeps}; pub struct CreateSessionResult { pub session_id: ImportSessionId, @@ -16,14 +13,14 @@ pub struct CreateSessionResult { } pub async fn execute( - import_session: Arc, - document_parser: Arc, + deps: &CreateSessionDeps, cmd: CreateImportSessionCommand, ) -> Result { let user_id = UserId::from_uuid(cmd.user_id); - import_session.delete_expired_for_user(&user_id).await?; + deps.import_session.delete_expired_for_user(&user_id).await?; - let parsed = document_parser + let parsed = deps + .document_parser .parse(&cmd.bytes, cmd.format) .map_err(|e| DomainError::ValidationError(e.to_string()))?; @@ -34,7 +31,7 @@ pub async fn execute( let session_id = session.id.clone(); session.parsed_file = Some(parsed); - import_session.create(&session).await?; + deps.import_session.create(&session).await?; Ok(CreateSessionResult { session_id, diff --git a/crates/application/src/import/deps.rs b/crates/application/src/import/deps.rs new file mode 100644 index 0000000..badb4f2 --- /dev/null +++ b/crates/application/src/import/deps.rs @@ -0,0 +1,31 @@ +use std::sync::Arc; + +use domain::ports::{DocumentParser, ImportProfileRepository, ImportSessionRepository, MovieQuery}; + +use crate::ports::ReviewLogger; + +pub struct CreateSessionDeps { + pub import_session: Arc, + pub document_parser: Arc, +} + +pub struct ApplyMappingDeps { + pub import_session: Arc, + pub document_parser: Arc, + pub movie_query: Arc, +} + +pub struct ApplyProfileDeps { + pub import_profile: Arc, + pub import_session: Arc, +} + +pub struct ExecuteImportDeps { + pub import_session: Arc, + pub review_logger: Arc, +} + +pub struct SaveProfileDeps { + pub import_session: Arc, + pub import_profile: Arc, +} diff --git a/crates/application/src/import/execute.rs b/crates/application/src/import/execute.rs index 8a23106..b957514 100644 --- a/crates/application/src/import/execute.rs +++ b/crates/application/src/import/execute.rs @@ -4,7 +4,6 @@ use chrono::NaiveDateTime; use domain::{ errors::DomainError, models::{ImportRow, import::RowResult}, - ports::ImportSessionRepository, value_objects::{ImportSessionId, UserId}, }; use uuid::Uuid; @@ -12,9 +11,10 @@ use uuid::Uuid; use crate::{ diary::commands::{LogReviewCommand, MovieInput}, import::commands::ExecuteImportCommand, - ports::ReviewLogger, }; +use super::deps::ExecuteImportDeps; + const CONCURRENCY_LIMIT: usize = 10; pub struct ImportSummary { @@ -24,14 +24,14 @@ pub struct ImportSummary { } pub async fn execute( - import_session: Arc, - review_logger: Arc, + deps: &ExecuteImportDeps, cmd: ExecuteImportCommand, ) -> Result { let user_id = UserId::from_uuid(cmd.user_id); let session_id = ImportSessionId::from_uuid(cmd.session_id); let confirmed_indices = cmd.confirmed_indices; - let session = import_session + let session = deps + .import_session .get(&session_id, &user_id) .await? .ok_or_else(|| DomainError::NotFound("import session".into()))?; @@ -59,7 +59,7 @@ pub async fn execute( Err(e) => failed.push((idx, e)), Ok(log_cmd) => { let permit = Arc::clone(&semaphore).acquire_owned().await.unwrap(); - let logger = Arc::clone(&review_logger); + let logger = deps.review_logger.clone(); tasks.spawn(async move { let result = logger.log_review(log_cmd).await.map_err(|e| e.to_string()); drop(permit); @@ -78,7 +78,7 @@ pub async fn execute( } } - import_session.delete(&session_id).await?; + deps.import_session.delete(&session_id).await?; Ok(ImportSummary { imported, diff --git a/crates/application/src/import/mod.rs b/crates/application/src/import/mod.rs index 3de9ad9..bd53d34 100644 --- a/crates/application/src/import/mod.rs +++ b/crates/application/src/import/mod.rs @@ -4,6 +4,7 @@ pub mod cleanup; pub mod commands; pub mod create_session; pub mod delete_profile; +pub mod deps; pub mod execute; pub mod list_profiles; pub mod save_profile; diff --git a/crates/application/src/import/save_profile.rs b/crates/application/src/import/save_profile.rs index b244cbd..cfc4273 100644 --- a/crates/application/src/import/save_profile.rs +++ b/crates/application/src/import/save_profile.rs @@ -1,23 +1,21 @@ -use std::sync::Arc; - -use crate::import::commands::SaveImportProfileCommand; use chrono::Utc; use domain::{ errors::DomainError, models::ImportProfile, - ports::{ImportProfileRepository, ImportSessionRepository}, value_objects::{ImportProfileId, ImportSessionId, UserId}, }; +use super::{commands::SaveImportProfileCommand, deps::SaveProfileDeps}; + pub async fn execute( - import_session: Arc, - import_profile: Arc, + deps: &SaveProfileDeps, cmd: SaveImportProfileCommand, ) -> Result { let user_id = UserId::from_uuid(cmd.user_id); let session_id = ImportSessionId::from_uuid(cmd.session_id); - let session = import_session + let session = deps + .import_session .get(&session_id, &user_id) .await? .ok_or_else(|| DomainError::NotFound("import session".into()))?; @@ -32,7 +30,7 @@ pub async fn execute( Utc::now().naive_utc(), ); let id = profile.id.clone(); - import_profile.save(&profile).await?; + deps.import_profile.save(&profile).await?; Ok(id) } diff --git a/crates/application/src/import/tests/apply_mapping.rs b/crates/application/src/import/tests/apply_mapping.rs index d72a977..252cfb7 100644 --- a/crates/application/src/import/tests/apply_mapping.rs +++ b/crates/application/src/import/tests/apply_mapping.rs @@ -7,11 +7,12 @@ use domain::{ AnnotatedRow, Movie, import::{ImportRow, ParsedFile, RowResult}, }, - ports::{DocumentParser, MovieRepository}, + ports::{DocumentParser, MovieCommand}, testing::{InMemoryImportSessionRepository, InMemoryMovieRepository}, value_objects::{ExternalMetadataId, MovieTitle, ReleaseYear}, }; +use crate::import::deps::{ApplyMappingDeps, CreateSessionDeps}; use crate::import::{ apply_mapping, commands::{ApplyImportMappingCommand, CreateImportSessionCommand}, @@ -25,9 +26,13 @@ async fn applies_mapping_to_session() { let b = TestContextBuilder::new(); let user_id = Uuid::new_v4(); + let create_deps = CreateSessionDeps { + import_session: Arc::clone(&sessions) as _, + document_parser: b.document_parser.clone(), + }; + let session = create_session::execute( - Arc::clone(&sessions) as _, - b.document_parser.clone(), + &create_deps, CreateImportSessionCommand { user_id, bytes: b"title\nTest".to_vec(), @@ -37,10 +42,14 @@ async fn applies_mapping_to_session() { .await .unwrap(); + let mapping_deps = ApplyMappingDeps { + import_session: Arc::clone(&sessions) as _, + document_parser: b.document_parser.clone(), + movie_query: b.movie_query.clone(), + }; + let rows = apply_mapping::execute( - Arc::clone(&sessions) as _, - b.document_parser.clone(), - b.movie_repo.clone(), + &mapping_deps, ApplyImportMappingCommand { user_id, session_id: session.session_id.value(), @@ -58,10 +67,14 @@ async fn fails_when_session_not_found() { let sessions = InMemoryImportSessionRepository::new(); let b = TestContextBuilder::new(); + let deps = ApplyMappingDeps { + import_session: Arc::clone(&sessions) as _, + document_parser: b.document_parser.clone(), + movie_query: b.movie_query.clone(), + }; + let result = apply_mapping::execute( - Arc::clone(&sessions) as _, - b.document_parser.clone(), - b.movie_repo.clone(), + &deps, ApplyImportMappingCommand { user_id: Uuid::new_v4(), session_id: Uuid::new_v4(), @@ -132,9 +145,13 @@ async fn marks_duplicate_by_external_id() { let user_id = Uuid::new_v4(); + let create_deps = CreateSessionDeps { + import_session: Arc::clone(&sessions) as _, + document_parser: Arc::clone(&parser) as _, + }; + let session = create_session::execute( - Arc::clone(&sessions) as _, - Arc::clone(&parser) as _, + &create_deps, CreateImportSessionCommand { user_id, bytes: b"title\nKnown Movie".to_vec(), @@ -144,10 +161,14 @@ async fn marks_duplicate_by_external_id() { .await .unwrap(); + let mapping_deps = ApplyMappingDeps { + import_session: Arc::clone(&sessions) as _, + document_parser: Arc::clone(&parser) as _, + movie_query: Arc::clone(&movies) as _, + }; + let rows = apply_mapping::execute( - Arc::clone(&sessions) as _, - Arc::clone(&parser) as _, - Arc::clone(&movies) as _, + &mapping_deps, ApplyImportMappingCommand { user_id, session_id: session.session_id.value(), @@ -185,9 +206,13 @@ async fn marks_duplicate_by_title_and_year() { let user_id = Uuid::new_v4(); + let create_deps = CreateSessionDeps { + import_session: Arc::clone(&sessions) as _, + document_parser: Arc::clone(&parser) as _, + }; + let session = create_session::execute( - Arc::clone(&sessions) as _, - Arc::clone(&parser) as _, + &create_deps, CreateImportSessionCommand { user_id, bytes: b"title\nDuplicate Film".to_vec(), @@ -197,10 +222,14 @@ async fn marks_duplicate_by_title_and_year() { .await .unwrap(); + let mapping_deps = ApplyMappingDeps { + import_session: Arc::clone(&sessions) as _, + document_parser: Arc::clone(&parser) as _, + movie_query: Arc::clone(&movies) as _, + }; + let rows = apply_mapping::execute( - Arc::clone(&sessions) as _, - Arc::clone(&parser) as _, - Arc::clone(&movies) as _, + &mapping_deps, ApplyImportMappingCommand { user_id, session_id: session.session_id.value(), diff --git a/crates/application/src/import/tests/apply_profile.rs b/crates/application/src/import/tests/apply_profile.rs index 5648c3c..59398e7 100644 --- a/crates/application/src/import/tests/apply_profile.rs +++ b/crates/application/src/import/tests/apply_profile.rs @@ -7,6 +7,7 @@ use domain::testing::{InMemoryImportProfileRepository, InMemoryImportSessionRepo use domain::value_objects::{ImportProfileId, UserId}; use uuid::Uuid; +use crate::import::deps::ApplyProfileDeps; use crate::import::{apply_profile, commands::ApplyImportProfileCommand}; #[tokio::test] @@ -14,9 +15,13 @@ async fn fails_when_profile_not_found() { let profiles = InMemoryImportProfileRepository::new(); let sessions = InMemoryImportSessionRepository::new(); + let deps = ApplyProfileDeps { + import_profile: Arc::clone(&profiles) as _, + import_session: Arc::clone(&sessions) as _, + }; + let result = apply_profile::execute( - Arc::clone(&profiles) as _, - Arc::clone(&sessions) as _, + &deps, ApplyImportProfileCommand { user_id: Uuid::new_v4(), session_id: Uuid::new_v4(), @@ -44,9 +49,13 @@ async fn fails_when_session_not_found() { let profile_id = profile.id.clone(); profiles.save(&profile).await.unwrap(); + let deps = ApplyProfileDeps { + import_profile: Arc::clone(&profiles) as _, + import_session: Arc::clone(&sessions) as _, + }; + let result = apply_profile::execute( - Arc::clone(&profiles) as _, - Arc::clone(&sessions) as _, + &deps, ApplyImportProfileCommand { user_id, session_id: Uuid::new_v4(), @@ -82,9 +91,13 @@ async fn applies_profile_mappings_to_session() { let session_id = session.id.clone(); sessions.create(&session).await.unwrap(); + let deps = ApplyProfileDeps { + import_profile: Arc::clone(&profiles) as _, + import_session: Arc::clone(&sessions) as _, + }; + apply_profile::execute( - Arc::clone(&profiles) as _, - Arc::clone(&sessions) as _, + &deps, ApplyImportProfileCommand { user_id, session_id: session_id.value(), diff --git a/crates/application/src/import/tests/create_session.rs b/crates/application/src/import/tests/create_session.rs index e1b1101..bc7f3c6 100644 --- a/crates/application/src/import/tests/create_session.rs +++ b/crates/application/src/import/tests/create_session.rs @@ -4,6 +4,7 @@ use uuid::Uuid; use domain::testing::InMemoryImportSessionRepository; +use crate::import::deps::CreateSessionDeps; use crate::import::{commands::CreateImportSessionCommand, create_session}; use crate::test_helpers::TestContextBuilder; @@ -12,9 +13,13 @@ async fn creates_session_with_parsed_file() { let sessions = InMemoryImportSessionRepository::new(); let b = TestContextBuilder::new(); + let deps = CreateSessionDeps { + import_session: Arc::clone(&sessions) as _, + document_parser: b.document_parser.clone(), + }; + let result = create_session::execute( - Arc::clone(&sessions) as _, - b.document_parser.clone(), + &deps, CreateImportSessionCommand { user_id: Uuid::new_v4(), bytes: b"col1\nval1".to_vec(), diff --git a/crates/application/src/import/tests/execute.rs b/crates/application/src/import/tests/execute.rs index 2cc3380..197b706 100644 --- a/crates/application/src/import/tests/execute.rs +++ b/crates/application/src/import/tests/execute.rs @@ -7,6 +7,7 @@ use domain::value_objects::UserId; use uuid::Uuid; use crate::import::commands::ExecuteImportCommand; +use crate::import::deps::ExecuteImportDeps; use crate::import::execute; use crate::test_helpers::NoopReviewLogger; @@ -50,9 +51,13 @@ async fn imports_confirmed_rows() { let sid = session.id.clone(); sessions.create(&session).await.unwrap(); + let deps = ExecuteImportDeps { + import_session: Arc::clone(&sessions) as _, + review_logger: Arc::new(NoopReviewLogger), + }; + let result = execute::execute( - Arc::clone(&sessions) as _, - Arc::new(NoopReviewLogger), + &deps, ExecuteImportCommand { user_id: uid, session_id: sid.value(), @@ -76,9 +81,13 @@ async fn skips_unconfirmed_rows() { let sid = session.id.clone(); sessions.create(&session).await.unwrap(); + let deps = ExecuteImportDeps { + import_session: Arc::clone(&sessions) as _, + review_logger: Arc::new(NoopReviewLogger), + }; + let result = execute::execute( - Arc::clone(&sessions) as _, - Arc::new(NoopReviewLogger), + &deps, ExecuteImportCommand { user_id: uid, session_id: sid.value(), @@ -96,9 +105,13 @@ async fn skips_unconfirmed_rows() { async fn fails_when_session_not_found() { let sessions = InMemoryImportSessionRepository::new(); + let deps = ExecuteImportDeps { + import_session: Arc::clone(&sessions) as _, + review_logger: Arc::new(NoopReviewLogger), + }; + let result = execute::execute( - Arc::clone(&sessions) as _, - Arc::new(NoopReviewLogger), + &deps, ExecuteImportCommand { user_id: Uuid::new_v4(), session_id: Uuid::new_v4(), @@ -131,9 +144,13 @@ async fn handles_datetime_format() { }]); sessions.create(&session).await.unwrap(); + let deps = ExecuteImportDeps { + import_session: Arc::clone(&sessions) as _, + review_logger: Arc::new(NoopReviewLogger), + }; + let result = execute::execute( - Arc::clone(&sessions) as _, - Arc::new(NoopReviewLogger), + &deps, ExecuteImportCommand { user_id: uid, session_id: sid.value(), @@ -168,9 +185,13 @@ async fn fails_on_invalid_rating() { }]); sessions.create(&session).await.unwrap(); + let deps = ExecuteImportDeps { + import_session: Arc::clone(&sessions) as _, + review_logger: Arc::new(NoopReviewLogger), + }; + let result = execute::execute( - Arc::clone(&sessions) as _, - Arc::new(NoopReviewLogger), + &deps, ExecuteImportCommand { user_id: uid, session_id: sid.value(), @@ -205,9 +226,13 @@ async fn fails_on_missing_watched_at() { }]); sessions.create(&session).await.unwrap(); + let deps = ExecuteImportDeps { + import_session: Arc::clone(&sessions) as _, + review_logger: Arc::new(NoopReviewLogger), + }; + let result = execute::execute( - Arc::clone(&sessions) as _, - Arc::new(NoopReviewLogger), + &deps, ExecuteImportCommand { user_id: uid, session_id: sid.value(), @@ -242,9 +267,13 @@ async fn imports_row_with_external_metadata_id() { }]); sessions.create(&session).await.unwrap(); + let deps = ExecuteImportDeps { + import_session: Arc::clone(&sessions) as _, + review_logger: Arc::new(NoopReviewLogger), + }; + let result = execute::execute( - Arc::clone(&sessions) as _, - Arc::new(NoopReviewLogger), + &deps, ExecuteImportCommand { user_id: uid, session_id: sid.value(), @@ -279,9 +308,13 @@ async fn imports_row_with_director_and_comment() { }]); sessions.create(&session).await.unwrap(); + let deps = ExecuteImportDeps { + import_session: Arc::clone(&sessions) as _, + review_logger: Arc::new(NoopReviewLogger), + }; + let result = execute::execute( - Arc::clone(&sessions) as _, - Arc::new(NoopReviewLogger), + &deps, ExecuteImportCommand { user_id: uid, session_id: sid.value(), @@ -316,9 +349,13 @@ async fn handles_space_separated_datetime_format() { }]); sessions.create(&session).await.unwrap(); + let deps = ExecuteImportDeps { + import_session: Arc::clone(&sessions) as _, + review_logger: Arc::new(NoopReviewLogger), + }; + let result = execute::execute( - Arc::clone(&sessions) as _, - Arc::new(NoopReviewLogger), + &deps, ExecuteImportCommand { user_id: uid, session_id: sid.value(), @@ -348,9 +385,13 @@ async fn reports_invalid_row_result_errors() { }]); sessions.create(&session).await.unwrap(); + let deps = ExecuteImportDeps { + import_session: Arc::clone(&sessions) as _, + review_logger: Arc::new(NoopReviewLogger), + }; + let result = execute::execute( - Arc::clone(&sessions) as _, - Arc::new(NoopReviewLogger), + &deps, ExecuteImportCommand { user_id: uid, session_id: sid.value(), @@ -387,9 +428,13 @@ async fn fails_on_missing_rating() { }]); sessions.create(&session).await.unwrap(); + let deps = ExecuteImportDeps { + import_session: Arc::clone(&sessions) as _, + review_logger: Arc::new(NoopReviewLogger), + }; + let result = execute::execute( - Arc::clone(&sessions) as _, - Arc::new(NoopReviewLogger), + &deps, ExecuteImportCommand { user_id: uid, session_id: sid.value(), @@ -425,9 +470,13 @@ async fn fails_on_unparseable_date() { }]); sessions.create(&session).await.unwrap(); + let deps = ExecuteImportDeps { + import_session: Arc::clone(&sessions) as _, + review_logger: Arc::new(NoopReviewLogger), + }; + let result = execute::execute( - Arc::clone(&sessions) as _, - Arc::new(NoopReviewLogger), + &deps, ExecuteImportCommand { user_id: uid, session_id: sid.value(), @@ -463,9 +512,13 @@ async fn imports_row_without_release_year() { }]); sessions.create(&session).await.unwrap(); + let deps = ExecuteImportDeps { + import_session: Arc::clone(&sessions) as _, + review_logger: Arc::new(NoopReviewLogger), + }; + let result = execute::execute( - Arc::clone(&sessions) as _, - Arc::new(NoopReviewLogger), + &deps, ExecuteImportCommand { user_id: uid, session_id: sid.value(), @@ -489,9 +542,13 @@ async fn deletes_session_after_import() { sessions.create(&session).await.unwrap(); assert_eq!(sessions.count(), 1); + let deps = ExecuteImportDeps { + import_session: Arc::clone(&sessions) as _, + review_logger: Arc::new(NoopReviewLogger), + }; + execute::execute( - Arc::clone(&sessions) as _, - Arc::new(NoopReviewLogger), + &deps, ExecuteImportCommand { user_id: uid, session_id: sid.value(), @@ -533,10 +590,14 @@ async fn imports_more_rows_than_concurrency_limit() { session.row_results = Some(rows); sessions.create(&session).await.unwrap(); + let deps = ExecuteImportDeps { + import_session: Arc::clone(&sessions) as _, + review_logger: Arc::new(NoopReviewLogger), + }; + let confirmed_indices: Vec = (0..15).collect(); let result = execute::execute( - Arc::clone(&sessions) as _, - Arc::new(NoopReviewLogger), + &deps, ExecuteImportCommand { user_id: uid, session_id: sid.value(), diff --git a/crates/application/src/import/tests/save_profile.rs b/crates/application/src/import/tests/save_profile.rs index 0438903..41698fd 100644 --- a/crates/application/src/import/tests/save_profile.rs +++ b/crates/application/src/import/tests/save_profile.rs @@ -6,6 +6,7 @@ use domain::testing::{InMemoryImportProfileRepository, InMemoryImportSessionRepo use domain::value_objects::UserId; use uuid::Uuid; +use crate::import::deps::SaveProfileDeps; use crate::import::{commands::SaveImportProfileCommand, save_profile}; #[tokio::test] @@ -13,9 +14,13 @@ async fn fails_when_session_not_found() { let sessions = InMemoryImportSessionRepository::new(); let profiles = InMemoryImportProfileRepository::new(); + let deps = SaveProfileDeps { + import_session: Arc::clone(&sessions) as _, + import_profile: Arc::clone(&profiles) as _, + }; + let result = save_profile::execute( - Arc::clone(&sessions) as _, - Arc::clone(&profiles) as _, + &deps, SaveImportProfileCommand { user_id: Uuid::new_v4(), session_id: Uuid::new_v4(), @@ -38,9 +43,13 @@ async fn saves_profile_from_session() { session.field_mappings = Some(vec![]); sessions.create(&session).await.unwrap(); + let deps = SaveProfileDeps { + import_session: Arc::clone(&sessions) as _, + import_profile: Arc::clone(&profiles) as _, + }; + let result = save_profile::execute( - Arc::clone(&sessions) as _, - Arc::clone(&profiles) as _, + &deps, SaveImportProfileCommand { user_id, session_id: sid.value(), diff --git a/crates/application/src/integrations/cleanup.rs b/crates/application/src/integrations/cleanup.rs index 62e5d57..54b11b1 100644 --- a/crates/application/src/integrations/cleanup.rs +++ b/crates/application/src/integrations/cleanup.rs @@ -1,11 +1,11 @@ use std::sync::Arc; use chrono::Duration; -use domain::{errors::DomainError, ports::WatchEventRepository}; +use domain::{errors::DomainError, ports::WatchEventCommand}; -pub async fn execute(watch_event: Arc) -> Result { +pub async fn execute(watch_event_command: Arc) -> Result { let cutoff = chrono::Utc::now().naive_utc() - Duration::days(30); - watch_event.delete_non_pending_older_than(cutoff).await + watch_event_command.delete_non_pending_older_than(cutoff).await } #[cfg(test)] diff --git a/crates/application/src/integrations/confirm.rs b/crates/application/src/integrations/confirm.rs index 7ca95d4..1385a5d 100644 --- a/crates/application/src/integrations/confirm.rs +++ b/crates/application/src/integrations/confirm.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use domain::{ errors::DomainError, models::WatchEventStatus, - ports::WatchEventRepository, + ports::{WatchEventCommand, WatchEventQuery}, value_objects::{UserId, WatchEventId}, }; @@ -14,7 +14,8 @@ use crate::{ }; pub async fn execute( - watch_event: Arc, + watch_event_command: Arc, + watch_event_query: Arc, review_logger: Arc, cmd: ConfirmWatchEventsCommand, ) -> Result { @@ -23,7 +24,7 @@ pub async fn execute( for c in cmd.confirmations { let event_id = WatchEventId::from_uuid(c.watch_event_id); - let event = watch_event + let event = watch_event_query .get_by_id(&event_id) .await? .ok_or_else(|| DomainError::NotFound(format!("WatchEvent {}", c.watch_event_id)))?; @@ -61,7 +62,7 @@ pub async fn execute( review_logger.log_review(review_cmd).await?; - watch_event + watch_event_command .update_status(&event_id, WatchEventStatus::Confirmed) .await?; diff --git a/crates/application/src/integrations/deps.rs b/crates/application/src/integrations/deps.rs index 8744886..f1f3e17 100644 --- a/crates/application/src/integrations/deps.rs +++ b/crates/application/src/integrations/deps.rs @@ -1,9 +1,10 @@ use std::sync::Arc; -use domain::ports::{EventPublisher, WatchEventRepository, WebhookTokenRepository}; +use domain::ports::{EventPublisher, WatchEventCommand, WatchEventQuery, WebhookTokenRepository}; pub struct IngestWatchEventDeps { pub webhook_token: Arc, - pub watch_event: Arc, + pub watch_event_command: Arc, + pub watch_event_query: Arc, pub event_publisher: Arc, } diff --git a/crates/application/src/integrations/dismiss.rs b/crates/application/src/integrations/dismiss.rs index c8d690b..e71a767 100644 --- a/crates/application/src/integrations/dismiss.rs +++ b/crates/application/src/integrations/dismiss.rs @@ -3,14 +3,15 @@ use std::sync::Arc; use domain::{ errors::DomainError, models::WatchEventStatus, - ports::WatchEventRepository, + ports::{WatchEventCommand, WatchEventQuery}, value_objects::{UserId, WatchEventId}, }; use crate::integrations::commands::DismissWatchEventsCommand; pub async fn execute( - watch_event: Arc, + watch_event_command: Arc, + watch_event_query: Arc, cmd: DismissWatchEventsCommand, ) -> Result { let user_id = UserId::from_uuid(cmd.user_id); @@ -24,7 +25,7 @@ pub async fn execute( .map(|id| WatchEventId::from_uuid(*id)) .collect(); - let events = watch_event.get_by_ids(&ids).await?; + let events = watch_event_query.get_by_ids(&ids).await?; if events.len() != ids.len() { return Err(DomainError::NotFound( @@ -37,7 +38,7 @@ pub async fn execute( } } - let count = watch_event + let count = watch_event_command .update_status_batch(&ids, WatchEventStatus::Dismissed) .await?; diff --git a/crates/application/src/integrations/get_queue.rs b/crates/application/src/integrations/get_queue.rs index 14011a3..43d83ff 100644 --- a/crates/application/src/integrations/get_queue.rs +++ b/crates/application/src/integrations/get_queue.rs @@ -1,17 +1,17 @@ use std::sync::Arc; use domain::{ - errors::DomainError, models::WatchEvent, ports::WatchEventRepository, value_objects::UserId, + errors::DomainError, models::WatchEvent, ports::WatchEventQuery, value_objects::UserId, }; use crate::integrations::queries::GetWatchQueueQuery; pub async fn execute( - watch_event: Arc, + watch_event_query: Arc, query: GetWatchQueueQuery, ) -> Result, DomainError> { let user_id = UserId::from_uuid(query.user_id); - watch_event.list_pending(&user_id).await + watch_event_query.list_pending(&user_id).await } #[cfg(test)] diff --git a/crates/application/src/integrations/ingest.rs b/crates/application/src/integrations/ingest.rs index 80144ff..384baa2 100644 --- a/crates/application/src/integrations/ingest.rs +++ b/crates/application/src/integrations/ingest.rs @@ -30,7 +30,7 @@ pub async fn execute( if let Some(ref ext_id) = external_metadata_id { let one_hour_ago = chrono::Utc::now().naive_utc() - Duration::hours(1); if deps - .watch_event + .watch_event_query .find_duplicate(&user_id, ext_id, one_hour_ago) .await? { @@ -49,7 +49,7 @@ pub async fn execute( None, ); - deps.watch_event.save(&event).await?; + deps.watch_event_command.save(&event).await?; let _ = deps .event_publisher diff --git a/crates/application/src/integrations/tests/cleanup.rs b/crates/application/src/integrations/tests/cleanup.rs index bd1ec38..df614ae 100644 --- a/crates/application/src/integrations/tests/cleanup.rs +++ b/crates/application/src/integrations/tests/cleanup.rs @@ -1,13 +1,10 @@ -use std::sync::Arc; - -use domain::ports::WatchEventRepository; use domain::testing::InMemoryWatchEventRepository; use crate::integrations::cleanup; #[tokio::test] async fn returns_zero_when_nothing_to_clean() { - let watch_events: Arc = InMemoryWatchEventRepository::new(); + let watch_events = InMemoryWatchEventRepository::new(); let count = cleanup::execute(watch_events).await.unwrap(); diff --git a/crates/application/src/integrations/tests/confirm.rs b/crates/application/src/integrations/tests/confirm.rs index a217740..5328e06 100644 --- a/crates/application/src/integrations/tests/confirm.rs +++ b/crates/application/src/integrations/tests/confirm.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use domain::models::{WatchEvent, WatchEventSource}; -use domain::ports::{MovieRepository, WatchEventRepository}; +use domain::ports::{MovieCommand, WatchEventCommand}; use domain::testing::{InMemoryWatchEventRepository, NoopEventPublisher}; use domain::value_objects::UserId; use uuid::Uuid; @@ -16,7 +16,7 @@ fn noop_logger() -> Arc { #[tokio::test] async fn confirms_watch_event_via_review_logger() { - let watch_events: Arc = InMemoryWatchEventRepository::new(); + let watch_events = InMemoryWatchEventRepository::new(); let uid = Uuid::new_v4(); let event = WatchEvent::new( @@ -32,7 +32,8 @@ async fn confirms_watch_event_via_review_logger() { watch_events.save(&event).await.unwrap(); let result = confirm::execute( - Arc::clone(&watch_events), + Arc::clone(&watch_events) as _, + Arc::clone(&watch_events) as _, noop_logger(), ConfirmWatchEventsCommand { user_id: uid, @@ -51,10 +52,11 @@ async fn confirms_watch_event_via_review_logger() { #[tokio::test] async fn empty_confirmations_returns_zero() { - let watch_events: Arc = InMemoryWatchEventRepository::new(); + let watch_events = InMemoryWatchEventRepository::new(); let result = confirm::execute( - Arc::clone(&watch_events), + Arc::clone(&watch_events) as _, + Arc::clone(&watch_events) as _, noop_logger(), ConfirmWatchEventsCommand { user_id: Uuid::new_v4(), @@ -69,7 +71,7 @@ async fn empty_confirmations_returns_zero() { #[tokio::test] async fn confirms_event_with_external_metadata_id_and_no_movie_id() { - let watch_events: Arc = InMemoryWatchEventRepository::new(); + let watch_events = InMemoryWatchEventRepository::new(); let uid = Uuid::new_v4(); let event = WatchEvent::new( @@ -85,7 +87,8 @@ async fn confirms_event_with_external_metadata_id_and_no_movie_id() { watch_events.save(&event).await.unwrap(); let result = confirm::execute( - Arc::clone(&watch_events), + Arc::clone(&watch_events) as _, + Arc::clone(&watch_events) as _, noop_logger(), ConfirmWatchEventsCommand { user_id: uid, @@ -104,7 +107,7 @@ async fn confirms_event_with_external_metadata_id_and_no_movie_id() { #[tokio::test] async fn rejects_other_users_event() { - let watch_events: Arc = InMemoryWatchEventRepository::new(); + let watch_events = InMemoryWatchEventRepository::new(); let owner = Uuid::new_v4(); let intruder = Uuid::new_v4(); @@ -121,7 +124,8 @@ async fn rejects_other_users_event() { watch_events.save(&event).await.unwrap(); let result = confirm::execute( - Arc::clone(&watch_events), + Arc::clone(&watch_events) as _, + Arc::clone(&watch_events) as _, noop_logger(), ConfirmWatchEventsCommand { user_id: intruder, @@ -139,10 +143,11 @@ async fn rejects_other_users_event() { #[tokio::test] async fn fails_when_event_not_found() { - let watch_events: Arc = InMemoryWatchEventRepository::new(); + let watch_events = InMemoryWatchEventRepository::new(); let result = confirm::execute( - Arc::clone(&watch_events), + Arc::clone(&watch_events) as _, + Arc::clone(&watch_events) as _, noop_logger(), ConfirmWatchEventsCommand { user_id: Uuid::new_v4(), @@ -160,7 +165,7 @@ async fn fails_when_event_not_found() { #[tokio::test] async fn confirms_event_with_movie_id() { - let watch_events: Arc = InMemoryWatchEventRepository::new(); + let watch_events = InMemoryWatchEventRepository::new(); let events = NoopEventPublisher::new(); let uid = Uuid::new_v4(); let movie_uuid = Uuid::new_v4(); @@ -194,6 +199,7 @@ async fn confirms_event_with_movie_id() { let watchlist = domain::testing::InMemoryWatchlistRepository::new(); let review_logger: Arc = Arc::new(crate::diary::review_logger::DefaultReviewLogger::new( + Arc::clone(&movies) as _, Arc::clone(&movies) as _, Arc::clone(&reviews) as _, Arc::clone(&watchlist) as _, @@ -202,7 +208,8 @@ async fn confirms_event_with_movie_id() { )); let result = confirm::execute( - Arc::clone(&watch_events), + Arc::clone(&watch_events) as _, + Arc::clone(&watch_events) as _, review_logger, ConfirmWatchEventsCommand { user_id: uid, @@ -221,7 +228,7 @@ async fn confirms_event_with_movie_id() { #[tokio::test] async fn confirms_event_without_movie_id_and_without_external_metadata_id() { - let watch_events: Arc = InMemoryWatchEventRepository::new(); + let watch_events = InMemoryWatchEventRepository::new(); let uid = Uuid::new_v4(); let event = WatchEvent::new( @@ -237,7 +244,8 @@ async fn confirms_event_without_movie_id_and_without_external_metadata_id() { watch_events.save(&event).await.unwrap(); let result = confirm::execute( - Arc::clone(&watch_events), + Arc::clone(&watch_events) as _, + Arc::clone(&watch_events) as _, noop_logger(), ConfirmWatchEventsCommand { user_id: uid, @@ -256,7 +264,7 @@ async fn confirms_event_without_movie_id_and_without_external_metadata_id() { #[tokio::test] async fn confirms_multiple_events() { - let watch_events: Arc = InMemoryWatchEventRepository::new(); + let watch_events = InMemoryWatchEventRepository::new(); let uid = Uuid::new_v4(); let event1 = WatchEvent::new( @@ -285,7 +293,8 @@ async fn confirms_multiple_events() { watch_events.save(&event2).await.unwrap(); let result = confirm::execute( - Arc::clone(&watch_events), + Arc::clone(&watch_events) as _, + Arc::clone(&watch_events) as _, noop_logger(), ConfirmWatchEventsCommand { user_id: uid, @@ -311,7 +320,7 @@ async fn confirms_multiple_events() { #[tokio::test] async fn confirms_event_without_year() { - let watch_events: Arc = InMemoryWatchEventRepository::new(); + let watch_events = InMemoryWatchEventRepository::new(); let uid = Uuid::new_v4(); let event = WatchEvent::new( @@ -327,7 +336,8 @@ async fn confirms_event_without_year() { watch_events.save(&event).await.unwrap(); let result = confirm::execute( - Arc::clone(&watch_events), + Arc::clone(&watch_events) as _, + Arc::clone(&watch_events) as _, noop_logger(), ConfirmWatchEventsCommand { user_id: uid, diff --git a/crates/application/src/integrations/tests/dismiss.rs b/crates/application/src/integrations/tests/dismiss.rs index 0faf5b0..fabc0b6 100644 --- a/crates/application/src/integrations/tests/dismiss.rs +++ b/crates/application/src/integrations/tests/dismiss.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use domain::models::{WatchEvent, WatchEventSource}; -use domain::ports::WatchEventRepository; +use domain::ports::WatchEventCommand; use domain::testing::InMemoryWatchEventRepository; use domain::value_objects::UserId; use uuid::Uuid; @@ -10,10 +10,11 @@ use crate::integrations::{commands::DismissWatchEventsCommand, dismiss}; #[tokio::test] async fn dismisses_empty_list_returns_zero() { - let events: Arc = InMemoryWatchEventRepository::new(); + let events = InMemoryWatchEventRepository::new(); let result = dismiss::execute( - Arc::clone(&events), + Arc::clone(&events) as _, + Arc::clone(&events) as _, DismissWatchEventsCommand { user_id: Uuid::new_v4(), event_ids: vec![], @@ -27,10 +28,11 @@ async fn dismisses_empty_list_returns_zero() { #[tokio::test] async fn fails_when_event_not_found() { - let events: Arc = InMemoryWatchEventRepository::new(); + let events = InMemoryWatchEventRepository::new(); let result = dismiss::execute( - Arc::clone(&events), + Arc::clone(&events) as _, + Arc::clone(&events) as _, DismissWatchEventsCommand { user_id: Uuid::new_v4(), event_ids: vec![Uuid::new_v4()], @@ -43,7 +45,7 @@ async fn fails_when_event_not_found() { #[tokio::test] async fn dismisses_existing_events() { - let watch_events: Arc = InMemoryWatchEventRepository::new(); + let watch_events = InMemoryWatchEventRepository::new(); let uid = Uuid::new_v4(); let user_id = UserId::from_uuid(uid); @@ -71,7 +73,8 @@ async fn dismisses_existing_events() { watch_events.save(&e2).await.unwrap(); let result = dismiss::execute( - Arc::clone(&watch_events), + Arc::clone(&watch_events) as _, + Arc::clone(&watch_events) as _, DismissWatchEventsCommand { user_id: uid, event_ids: vec![id1, id2], diff --git a/crates/application/src/integrations/tests/get_queue.rs b/crates/application/src/integrations/tests/get_queue.rs index e73b6e9..41d3898 100644 --- a/crates/application/src/integrations/tests/get_queue.rs +++ b/crates/application/src/integrations/tests/get_queue.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use chrono::Utc; use domain::models::{WatchEvent, WatchEventSource}; -use domain::ports::WatchEventRepository; +use domain::ports::WatchEventCommand; use domain::testing::InMemoryWatchEventRepository; use domain::value_objects::UserId; use uuid::Uuid; @@ -11,10 +11,10 @@ use crate::integrations::{get_queue, queries::GetWatchQueueQuery}; #[tokio::test] async fn returns_empty_when_no_events() { - let events: Arc = InMemoryWatchEventRepository::new(); + let events = InMemoryWatchEventRepository::new(); let result = get_queue::execute( - Arc::clone(&events), + Arc::clone(&events) as _, GetWatchQueueQuery { user_id: Uuid::new_v4(), }, @@ -27,7 +27,7 @@ async fn returns_empty_when_no_events() { #[tokio::test] async fn returns_pending_events() { - let events: Arc = InMemoryWatchEventRepository::new(); + let events = InMemoryWatchEventRepository::new(); let user_id = Uuid::new_v4(); let event = WatchEvent::new( @@ -41,7 +41,7 @@ async fn returns_pending_events() { ); events.save(&event).await.unwrap(); - let result = get_queue::execute(Arc::clone(&events), GetWatchQueueQuery { user_id }) + let result = get_queue::execute(Arc::clone(&events) as _, GetWatchQueueQuery { user_id }) .await .unwrap(); diff --git a/crates/application/src/integrations/tests/ingest.rs b/crates/application/src/integrations/tests/ingest.rs index 594d466..49bef6c 100644 --- a/crates/application/src/integrations/tests/ingest.rs +++ b/crates/application/src/integrations/tests/ingest.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use domain::models::WatchEventSource; -use domain::ports::{EventPublisher, WatchEventRepository, WebhookTokenRepository}; +use domain::ports::{EventPublisher, WebhookTokenRepository}; use domain::testing::{ InMemoryWatchEventRepository, InMemoryWebhookTokenRepository, NoopEventPublisher, }; @@ -30,7 +30,7 @@ impl domain::ports::MediaServerParser for FakeParser { #[tokio::test] async fn ingests_watch_event() { let tokens: Arc = InMemoryWebhookTokenRepository::new(); - let watch_events: Arc = InMemoryWatchEventRepository::new(); + let watch_events = InMemoryWatchEventRepository::new(); let event_publisher: Arc = NoopEventPublisher::new(); let user_id = Uuid::new_v4(); @@ -47,7 +47,8 @@ async fn ingests_watch_event() { let deps = IngestWatchEventDeps { webhook_token: Arc::clone(&tokens), - watch_event: Arc::clone(&watch_events), + watch_event_command: Arc::clone(&watch_events) as _, + watch_event_query: Arc::clone(&watch_events) as _, event_publisher: Arc::clone(&event_publisher), }; @@ -68,12 +69,13 @@ async fn ingests_watch_event() { #[tokio::test] async fn rejects_invalid_token() { let tokens: Arc = InMemoryWebhookTokenRepository::new(); - let watch_events: Arc = InMemoryWatchEventRepository::new(); + let watch_events = InMemoryWatchEventRepository::new(); let event_publisher: Arc = NoopEventPublisher::new(); let deps = IngestWatchEventDeps { webhook_token: Arc::clone(&tokens), - watch_event: Arc::clone(&watch_events), + watch_event_command: Arc::clone(&watch_events) as _, + watch_event_query: Arc::clone(&watch_events) as _, event_publisher: Arc::clone(&event_publisher), }; diff --git a/crates/application/src/jobs/movie_dedup.rs b/crates/application/src/jobs/movie_dedup.rs index 5d9a514..9c6c04a 100644 --- a/crates/application/src/jobs/movie_dedup.rs +++ b/crates/application/src/jobs/movie_dedup.rs @@ -4,7 +4,7 @@ use std::time::Duration; use async_trait::async_trait; use domain::{ errors::DomainError, - ports::{MovieDeduplicator, MovieRepository, ObjectStorage, PeriodicJob}, + ports::{MovieDeduplicator, MovieQuery, ObjectStorage, PeriodicJob}, }; use crate::movies::merge_duplicates::{MergeDuplicatesDeps, execute}; @@ -15,13 +15,13 @@ pub struct MovieDeduplicationJob { impl MovieDeduplicationJob { pub fn new( - movie: Arc, + movie: Arc, deduplicator: Arc, object_storage: Arc, ) -> Self { Self { deps: MergeDuplicatesDeps { - movie, + movie_query: movie, deduplicator, object_storage, }, diff --git a/crates/application/src/jobs/watch_event_cleanup.rs b/crates/application/src/jobs/watch_event_cleanup.rs index 4c5d71d..5fb80f1 100644 --- a/crates/application/src/jobs/watch_event_cleanup.rs +++ b/crates/application/src/jobs/watch_event_cleanup.rs @@ -4,15 +4,15 @@ use std::time::Duration; use async_trait::async_trait; use domain::{ errors::DomainError, - ports::{PeriodicJob, WatchEventRepository}, + ports::{PeriodicJob, WatchEventCommand}, }; pub struct WatchEventCleanupJob { - watch_event: Arc, + watch_event: Arc, } impl WatchEventCleanupJob { - pub fn new(watch_event: Arc) -> Self { + pub fn new(watch_event: Arc) -> Self { Self { watch_event } } } diff --git a/crates/application/src/movies/deps.rs b/crates/application/src/movies/deps.rs index 3c7334f..15777cb 100644 --- a/crates/application/src/movies/deps.rs +++ b/crates/application/src/movies/deps.rs @@ -1,12 +1,13 @@ use std::sync::Arc; use domain::ports::{ - EventPublisher, MetadataClient, MovieProfileRepository, MovieRepository, ObjectStorage, - PersonCommand, PersonQuery, PosterFetcherClient, SearchCommand, + EventPublisher, MetadataClient, MovieCommand, MovieProfileRepository, MovieQuery, + ObjectStorage, PersonCommand, PersonQuery, PosterFetcherClient, SearchCommand, }; pub struct SyncPosterDeps { - pub movie: Arc, + pub movie_command: Arc, + pub movie_query: Arc, pub movie_profile: Arc, pub metadata: Arc, pub poster_fetcher: Arc, @@ -16,14 +17,14 @@ pub struct SyncPosterDeps { } pub struct EnrichMovieDeps { - pub movie: Arc, + pub movie_query: Arc, pub movie_profile: Arc, pub person_command: Arc, pub search_command: Arc, } pub struct ReindexSearchDeps { - pub movie: Arc, + pub movie_query: Arc, pub movie_profile: Arc, pub search_command: Arc, pub person_command: Arc, diff --git a/crates/application/src/movies/discovery_indexer.rs b/crates/application/src/movies/discovery_indexer.rs index 20332f8..86c16c5 100644 --- a/crates/application/src/movies/discovery_indexer.rs +++ b/crates/application/src/movies/discovery_indexer.rs @@ -5,20 +5,20 @@ use domain::{ errors::DomainError, events::DomainEvent, models::IndexableDocument, - ports::{EventHandler, MovieRepository, SearchCommand}, + ports::{EventHandler, MovieQuery, SearchCommand}, }; /// Reacts to `MovieDiscovered` and inserts a bare search index entry immediately, /// so movies are findable before TMDb enrichment runs. /// Enrichment will later overwrite this with the full document (cast, genres, etc.). pub struct MovieDiscoveryIndexer { - movie_repository: Arc, + movie_repository: Arc, search_command: Arc, } impl MovieDiscoveryIndexer { pub fn new( - movie_repository: Arc, + movie_repository: Arc, search_command: Arc, ) -> Self { Self { diff --git a/crates/application/src/movies/enrich_movie.rs b/crates/application/src/movies/enrich_movie.rs index 888a67a..cd2109a 100644 --- a/crates/application/src/movies/enrich_movie.rs +++ b/crates/application/src/movies/enrich_movie.rs @@ -18,7 +18,7 @@ pub async fn execute(deps: &EnrichMovieDeps, cmd: EnrichMovieCommand) -> Result< } // 3. Fetch the movie for the search index document - let Some(movie) = deps.movie.get_movie_by_id(&cmd.movie_id).await? else { + let Some(movie) = deps.movie_query.get_movie_by_id(&cmd.movie_id).await? else { tracing::warn!(movie_id = %cmd.movie_id.value(), "enrich_movie: movie not found after profile upsert"); return Ok(()); }; diff --git a/crates/application/src/movies/event_handler.rs b/crates/application/src/movies/event_handler.rs index 43ec8cc..fdc70b3 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, ImageFetcher, MovieEnrichmentClient, MovieProfileRepository, MovieRepository, + EventHandler, ImageFetcher, MovieEnrichmentClient, MovieProfileRepository, MovieQuery, ObjectStorage, PersonCommand, SearchCommand, }, }; @@ -17,7 +17,7 @@ use crate::movies::{ pub struct MovieEnrichmentHandler { enrichment_client: Arc, - movie_repository: Arc, + movie_repository: Arc, profile_repo: Arc, person_command: Arc, search_command: Arc, @@ -28,7 +28,7 @@ pub struct MovieEnrichmentHandler { impl MovieEnrichmentHandler { pub fn new( enrichment_client: Arc, - movie_repository: Arc, + movie_repository: Arc, profile_repo: Arc, person_command: Arc, search_command: Arc, @@ -92,7 +92,7 @@ impl EventHandler for MovieEnrichmentHandler { self.download_cast_photos(&profile).await; let enrich_deps = EnrichMovieDeps { - movie: self.movie_repository.clone(), + movie_query: self.movie_repository.clone(), movie_profile: self.profile_repo.clone(), person_command: self.person_command.clone(), search_command: self.search_command.clone(), diff --git a/crates/application/src/movies/get_movies.rs b/crates/application/src/movies/get_movies.rs index 0ec7fe0..f3630e0 100644 --- a/crates/application/src/movies/get_movies.rs +++ b/crates/application/src/movies/get_movies.rs @@ -4,13 +4,13 @@ use domain::{ errors::DomainError, models::collections::{PageParams, Paginated}, models::{MovieFilter, MovieSummary}, - ports::MovieRepository, + ports::MovieQuery, }; use crate::movies::queries::GetMoviesQuery; pub async fn execute( - movie: Arc, + movie: Arc, query: GetMoviesQuery, ) -> Result, DomainError> { let page = PageParams::new(query.limit, query.offset)?; diff --git a/crates/application/src/movies/merge_duplicates.rs b/crates/application/src/movies/merge_duplicates.rs index 7f9dedc..f13e918 100644 --- a/crates/application/src/movies/merge_duplicates.rs +++ b/crates/application/src/movies/merge_duplicates.rs @@ -2,12 +2,12 @@ use std::sync::Arc; use domain::{ errors::DomainError, - ports::{MovieDeduplicator, MovieRepository, ObjectStorage}, + ports::{MovieDeduplicator, MovieQuery, ObjectStorage}, value_objects::MovieId, }; pub struct MergeDuplicatesDeps { - pub movie: Arc, + pub movie_query: Arc, pub deduplicator: Arc, pub object_storage: Arc, } @@ -18,7 +18,7 @@ pub struct MergeReport { } pub async fn execute(deps: &MergeDuplicatesDeps) -> Result { - let movies = deps.movie.list_movies_with_external_id().await?; + let movies = deps.movie_query.list_movies_with_external_id().await?; let mut pairs_found = 0u64; let mut rows_repointed = 0u64; @@ -37,7 +37,7 @@ pub async fn execute(deps: &MergeDuplicatesDeps) -> Result existing, None => domain::models::Movie::from_persistence( canonical_id, diff --git a/crates/application/src/movies/reindex_search.rs b/crates/application/src/movies/reindex_search.rs index 442c947..bf3bba5 100644 --- a/crates/application/src/movies/reindex_search.rs +++ b/crates/application/src/movies/reindex_search.rs @@ -34,7 +34,7 @@ async fn reindex_movies(deps: &ReindexSearchDeps) -> Result { let mut offset: u32 = 0; loop { let page = deps - .movie + .movie_query .list_movies( &PageParams { limit: BATCH_SIZE, diff --git a/crates/application/src/movies/resolve.rs b/crates/application/src/movies/resolve.rs index 747b63e..7e7ef3e 100644 --- a/crates/application/src/movies/resolve.rs +++ b/crates/application/src/movies/resolve.rs @@ -2,7 +2,7 @@ use domain::{ errors::DomainError, events::DomainEvent, models::Movie, - ports::{EventPublisher, MetadataClient, MovieRepository}, + ports::{EventPublisher, MetadataClient, MovieCommand, MovieQuery}, value_objects::MovieId, }; @@ -14,20 +14,21 @@ use crate::diary::movie_resolver::{MovieResolver, MovieResolverDeps}; /// Returns `(movie, is_new_movie)`. pub async fn resolve_and_persist_movie( input: &MovieInput, - movie_repo: &dyn MovieRepository, + movie_command: &dyn MovieCommand, + movie_query: &dyn MovieQuery, 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 + let movie = movie_query .get_movie_by_id(&movie_id) .await? .ok_or_else(|| DomainError::NotFound(format!("Movie {id}")))?; (movie, false) } else { let deps = MovieResolverDeps { - repository: movie_repo, + repository: movie_query, metadata_client, }; MovieResolver::default_pipeline() @@ -36,7 +37,7 @@ pub async fn resolve_and_persist_movie( }; if is_new { - movie_repo.upsert_movie(&movie).await?; + movie_command.upsert_movie(&movie).await?; if let Some(ext_id) = movie.external_metadata_id() { let _ = event_publisher .publish(&DomainEvent::MovieDiscovered { diff --git a/crates/application/src/movies/sync_poster.rs b/crates/application/src/movies/sync_poster.rs index b99f138..99a75a4 100644 --- a/crates/application/src/movies/sync_poster.rs +++ b/crates/application/src/movies/sync_poster.rs @@ -10,7 +10,7 @@ use crate::{diary::commands::SyncPosterCommand, movies::deps::SyncPosterDeps}; pub async fn execute(deps: &SyncPosterDeps, cmd: SyncPosterCommand) -> Result<(), DomainError> { let movie_id = MovieId::from_uuid(cmd.movie_id); - let mut movie = match deps.movie.get_movie_by_id(&movie_id).await? { + let mut movie = match deps.movie_query.get_movie_by_id(&movie_id).await? { Some(m) => m, None => { tracing::warn!( @@ -59,7 +59,7 @@ pub async fn execute(deps: &SyncPosterDeps, cmd: SyncPosterCommand) -> Result<() let poster_path = PosterPath::new(stored_path)?; movie.update_poster(poster_path); - deps.movie.upsert_movie(&movie).await?; + deps.movie_command.upsert_movie(&movie).await?; // Refresh search index so the new poster_path is reflected immediately. // Fetch existing profile if available for a complete index document. diff --git a/crates/application/src/movies/tests/enrich_movie.rs b/crates/application/src/movies/tests/enrich_movie.rs index c604a74..9098258 100644 --- a/crates/application/src/movies/tests/enrich_movie.rs +++ b/crates/application/src/movies/tests/enrich_movie.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use chrono::Utc; use domain::{ models::{Movie, MovieProfile}, - ports::MovieRepository, + ports::MovieCommand, testing::{ FakeSearchCommand, InMemoryMovieProfileRepository, InMemoryMovieRepository, PanicPersonCommand, @@ -50,7 +50,7 @@ async fn stores_profile_and_indexes() { }; let deps = EnrichMovieDeps { - movie: movie_repo as Arc<_>, + movie_query: movie_repo as Arc<_>, movie_profile: Arc::clone(&profile_repo) as Arc<_>, person_command: Arc::new(PanicPersonCommand), search_command: Arc::new(FakeSearchCommand), @@ -142,7 +142,7 @@ async fn extracts_and_indexes_persons() { }; let deps = EnrichMovieDeps { - movie: movie_repo as Arc<_>, + movie_query: movie_repo as Arc<_>, movie_profile: Arc::clone(&profile_repo) as Arc<_>, person_command: Arc::new(NoopPersonCommand), search_command: Arc::new(FakeSearchCommand), diff --git a/crates/application/src/movies/tests/sync_poster.rs b/crates/application/src/movies/tests/sync_poster.rs index 1b803a7..9724520 100644 --- a/crates/application/src/movies/tests/sync_poster.rs +++ b/crates/application/src/movies/tests/sync_poster.rs @@ -5,7 +5,7 @@ use uuid::Uuid; use domain::{ errors::DomainError, models::Movie, - ports::{MetadataClient, MovieRepository}, + ports::{MetadataClient, MovieCommand, MovieQuery}, testing::{ FakeSearchCommand, InMemoryMovieProfileRepository, InMemoryMovieRepository, NoopEventPublisher, NoopObjectStorage, @@ -20,7 +20,8 @@ use crate::{ fn default_deps() -> SyncPosterDeps { SyncPosterDeps { - movie: InMemoryMovieRepository::new(), + movie_command: InMemoryMovieRepository::new(), + movie_query: InMemoryMovieRepository::new(), movie_profile: InMemoryMovieProfileRepository::new(), metadata: Arc::new(domain::testing::FakeMetadataClient), poster_fetcher: Arc::new(domain::testing::FakePosterFetcher), @@ -59,7 +60,8 @@ async fn fails_when_no_external_id() { movies.upsert_movie(&movie).await.unwrap(); let deps = SyncPosterDeps { - movie: Arc::clone(&movies) as _, + movie_command: Arc::clone(&movies) as _, + movie_query: Arc::clone(&movies) as _, ..default_deps() }; @@ -103,7 +105,8 @@ async fn syncs_poster_for_movie_with_external_id() { movies.upsert_movie(&movie).await.unwrap(); let deps = SyncPosterDeps { - movie: Arc::clone(&movies) as _, + movie_command: Arc::clone(&movies) as _, + movie_query: Arc::clone(&movies) as _, metadata: Arc::new(FakeMetaWithPoster) as _, ..default_deps() }; diff --git a/crates/application/src/test_helpers.rs b/crates/application/src/test_helpers.rs index 2550a76..a0cf0f7 100644 --- a/crates/application/src/test_helpers.rs +++ b/crates/application/src/test_helpers.rs @@ -7,10 +7,11 @@ use domain::{ ports::{ AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher, GoalRepository, ImportProfileRepository, ImportSessionRepository, MetadataClient, - MovieProfileRepository, MovieRepository, ObjectStorage, PasswordHasher, PersonCommand, - PersonQuery, PosterFetcherClient, RefreshSessionRepository, ReviewRepository, - SearchCommand, SearchPort, StatsRepository, UserProfileFieldsRepository, UserRepository, - UserSettingsRepository, WatchEventRepository, WatchlistRepository, WebhookTokenRepository, + MovieCommand, MovieProfileRepository, MovieQuery, ObjectStorage, PasswordHasher, + PersonCommand, PersonQuery, PosterFetcherClient, RefreshSessionRepository, + ReviewRepository, SearchCommand, SearchPort, StatsRepository, + UserProfileFieldsRepository, UserRepository, UserSettingsRepository, WatchEventCommand, + WatchEventQuery, WatchlistRepository, WebhookTokenRepository, WrapUpRepository, WrapUpStatsQuery, }, testing::{ @@ -40,7 +41,8 @@ impl ReviewLogger for NoopReviewLogger { } pub struct TestContextBuilder { - pub movie_repo: Arc, + pub movie_command: Arc, + pub movie_query: Arc, pub review_repo: Arc, pub diary_repo: Arc, pub diary_exporter: Arc, @@ -57,7 +59,8 @@ pub struct TestContextBuilder { pub import_profile_repo: Arc, pub movie_profile_repo: Arc, pub watchlist_repo: Arc, - pub watch_event_repo: Arc, + pub watch_event_command: Arc, + pub watch_event_query: Arc, pub webhook_token_repo: Arc, pub profile_fields_repo: Arc, pub person_command: Arc, @@ -83,7 +86,8 @@ impl Default for TestContextBuilder { impl TestContextBuilder { pub fn new() -> Self { Self { - movie_repo: InMemoryMovieRepository::new(), + movie_command: InMemoryMovieRepository::new(), + movie_query: InMemoryMovieRepository::new(), review_repo: InMemoryReviewRepository::new(), diary_repo: FakeDiaryRepository::new(), diary_exporter: Arc::new(PanicDiaryExporter), @@ -100,7 +104,8 @@ impl TestContextBuilder { import_profile_repo: InMemoryImportProfileRepository::new(), movie_profile_repo: InMemoryMovieProfileRepository::new(), watchlist_repo: InMemoryWatchlistRepository::new(), - watch_event_repo: InMemoryWatchEventRepository::new(), + watch_event_command: InMemoryWatchEventRepository::new(), + watch_event_query: InMemoryWatchEventRepository::new(), webhook_token_repo: InMemoryWebhookTokenRepository::new(), profile_fields_repo: InMemoryProfileFieldsRepo::new(), person_command: Arc::new(PanicPersonCommand), @@ -128,8 +133,13 @@ impl TestContextBuilder { } } - pub fn with_movies(mut self, r: Arc) -> Self { - self.movie_repo = r; + pub fn with_movie_command(mut self, r: Arc) -> Self { + self.movie_command = r; + self + } + + pub fn with_movie_query(mut self, r: Arc) -> Self { + self.movie_query = r; self } @@ -173,8 +183,13 @@ impl TestContextBuilder { self } - pub fn with_watch_events(mut self, r: Arc) -> Self { - self.watch_event_repo = r; + pub fn with_watch_event_command(mut self, r: Arc) -> Self { + self.watch_event_command = r; + self + } + + pub fn with_watch_event_query(mut self, r: Arc) -> Self { + self.watch_event_query = r; self } diff --git a/crates/application/src/users/update_profile.rs b/crates/application/src/users/update_profile.rs index 23c43d4..596aefd 100644 --- a/crates/application/src/users/update_profile.rs +++ b/crates/application/src/users/update_profile.rs @@ -1,7 +1,51 @@ -use domain::{errors::DomainError, events::DomainEvent, value_objects::UserId}; +use domain::{ + errors::DomainError, + events::DomainEvent, + ports::{EventPublisher, ObjectStorage}, + value_objects::UserId, +}; use crate::users::{commands::UpdateProfileCommand, deps::UpdateProfileDeps}; +async fn upload_image( + storage: &dyn ObjectStorage, + event_publisher: &dyn EventPublisher, + user_id: &UserId, + kind: &str, + old_path: Option<&str>, + new_bytes: Option>, + content_type: Option<&str>, +) -> Result, DomainError> { + let Some(bytes) = new_bytes else { + return Ok(old_path.map(|s| s.to_string())); + }; + + let ct = content_type.unwrap_or(""); + if !["image/jpeg", "image/png", "image/webp"].contains(&ct) { + return Err(DomainError::ValidationError( + format!("{kind} must be jpeg, png, or webp"), + )); + } + + if let Some(old) = old_path { + let _ = storage.delete(old).await; + } + + let key = format!("{kind}/{}", user_id.value()); + let stored = storage.store(&key, &bytes).await?; + + if let Err(e) = event_publisher + .publish(&DomainEvent::ImageStored { + key: stored.clone(), + }) + .await + { + tracing::warn!("failed to emit ImageStored for {kind} {stored}: {e}"); + } + + Ok(Some(stored)) +} + pub async fn execute( deps: &UpdateProfileDeps, cmd: UpdateProfileCommand, @@ -14,59 +58,30 @@ pub async fn execute( .await? .ok_or_else(|| DomainError::NotFound("User not found".into()))?; - // Handle avatar - let new_avatar_path = if let Some(bytes) = cmd.avatar_bytes { - let content_type = cmd.avatar_content_type.as_deref().unwrap_or(""); - if !["image/jpeg", "image/png", "image/webp"].contains(&content_type) { - return Err(DomainError::ValidationError( - "Avatar must be jpeg, png, or webp".into(), - )); - } - if let Some(old_path) = user.avatar_path() { - let _ = deps.object_storage.delete(old_path).await; - } - let key = format!("avatars/{}", user_id.value()); - let stored = deps.object_storage.store(&key, &bytes).await?; - if let Err(e) = deps - .event_publisher - .publish(&DomainEvent::ImageStored { - key: stored.clone(), - }) - .await - { - tracing::warn!("failed to emit ImageStored for avatar {stored}: {e}"); - } - Some(stored) - } else { - user.avatar_path().map(|s| s.to_string()) - }; + let storage = deps.object_storage.as_ref(); + let events = deps.event_publisher.as_ref(); - // Handle banner - let new_banner_path = if let Some(bytes) = cmd.banner_bytes { - let content_type = cmd.banner_content_type.as_deref().unwrap_or(""); - if !["image/jpeg", "image/png", "image/webp"].contains(&content_type) { - return Err(DomainError::ValidationError( - "Banner must be jpeg, png, or webp".into(), - )); - } - if let Some(old_path) = user.banner_path() { - let _ = deps.object_storage.delete(old_path).await; - } - let key = format!("banners/{}", user_id.value()); - let stored = deps.object_storage.store(&key, &bytes).await?; - if let Err(e) = deps - .event_publisher - .publish(&DomainEvent::ImageStored { - key: stored.clone(), - }) - .await - { - tracing::warn!("failed to emit ImageStored for banner {stored}: {e}"); - } - Some(stored) - } else { - user.banner_path().map(|s| s.to_string()) - }; + let new_avatar_path = upload_image( + storage, + events, + &user_id, + "avatars", + user.avatar_path(), + cmd.avatar_bytes, + cmd.avatar_content_type.as_deref(), + ) + .await?; + + let new_banner_path = upload_image( + storage, + events, + &user_id, + "banners", + user.banner_path(), + cmd.banner_bytes, + cmd.banner_content_type.as_deref(), + ) + .await?; let moved_to = cmd.also_known_as.as_deref().and_then(|new_url| { if user.also_known_as().map(|s| s != new_url).unwrap_or(true) { diff --git a/crates/application/src/watchlist/add.rs b/crates/application/src/watchlist/add.rs index 3abedc8..3ef3867 100644 --- a/crates/application/src/watchlist/add.rs +++ b/crates/application/src/watchlist/add.rs @@ -15,7 +15,8 @@ pub async fn execute( let (movie, _is_new) = resolve_and_persist_movie( &cmd.input, - deps.movie.as_ref(), + deps.movie_command.as_ref(), + deps.movie_query.as_ref(), deps.metadata.as_ref(), deps.event_publisher.as_ref(), ) diff --git a/crates/application/src/watchlist/deps.rs b/crates/application/src/watchlist/deps.rs index b34e609..d0879ad 100644 --- a/crates/application/src/watchlist/deps.rs +++ b/crates/application/src/watchlist/deps.rs @@ -1,9 +1,10 @@ use std::sync::Arc; -use domain::ports::{EventPublisher, MetadataClient, MovieRepository, WatchlistRepository}; +use domain::ports::{EventPublisher, MetadataClient, MovieCommand, MovieQuery, WatchlistRepository}; pub struct WatchlistAddDeps { - pub movie: Arc, + pub movie_command: Arc, + pub movie_query: Arc, pub metadata: Arc, pub watchlist: Arc, pub event_publisher: Arc, diff --git a/crates/application/src/watchlist/tests/add.rs b/crates/application/src/watchlist/tests/add.rs index 85b2d44..d888713 100644 --- a/crates/application/src/watchlist/tests/add.rs +++ b/crates/application/src/watchlist/tests/add.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use domain::{ models::Movie, - ports::MovieRepository, + ports::MovieCommand, testing::{InMemoryMovieRepository, InMemoryWatchlistRepository, NoopEventPublisher}, value_objects::{MovieTitle, ReleaseYear}, }; @@ -17,7 +17,8 @@ fn make_deps( watchlist: Arc, ) -> WatchlistAddDeps { WatchlistAddDeps { - movie: movies, + movie_command: Arc::clone(&movies) as _, + movie_query: movies, metadata: Arc::new(domain::testing::FakeMetadataClient), watchlist, event_publisher: NoopEventPublisher::new(), diff --git a/crates/domain/Cargo.toml b/crates/domain/Cargo.toml index 3331f47..8c86696 100644 --- a/crates/domain/Cargo.toml +++ b/crates/domain/Cargo.toml @@ -11,6 +11,7 @@ thiserror = { workspace = true } bytes = { workspace = true } futures = { workspace = true } serde = { workspace = true } +serde_json = { workspace = true } email_address = "0.2.9" diff --git a/crates/domain/src/events.rs b/crates/domain/src/events.rs index c4cca8c..057370b 100644 --- a/crates/domain/src/events.rs +++ b/crates/domain/src/events.rs @@ -74,7 +74,7 @@ pub enum DomainEvent { }, FederationDeliveryRequested { inbox_url: String, - activity_json: String, + activity_json: serde_json::Value, signing_actor_id: uuid::Uuid, }, WatchEventIngested { diff --git a/crates/domain/src/ports/media_server.rs b/crates/domain/src/ports/media_server.rs index 5c482eb..0caa895 100644 --- a/crates/domain/src/ports/media_server.rs +++ b/crates/domain/src/ports/media_server.rs @@ -12,32 +12,38 @@ pub trait MediaServerParser: Send + Sync { -> Result, DomainError>; } +/// Write port — mutates watch events. #[async_trait] -pub trait WatchEventRepository: Send + Sync { +pub trait WatchEventCommand: Send + Sync { async fn save(&self, event: &WatchEvent) -> Result<(), DomainError>; async fn update_status( &self, id: &WatchEventId, status: WatchEventStatus, ) -> Result<(), DomainError>; - async fn list_pending(&self, user_id: &UserId) -> Result, DomainError>; - async fn get_by_id(&self, id: &WatchEventId) -> Result, DomainError>; - async fn get_by_ids(&self, ids: &[WatchEventId]) -> Result, DomainError>; async fn update_status_batch( &self, ids: &[WatchEventId], status: WatchEventStatus, ) -> Result; + async fn delete_non_pending_older_than( + &self, + before: NaiveDateTime, + ) -> Result; +} + +/// Read port — queries watch events. No mutations. +#[async_trait] +pub trait WatchEventQuery: Send + Sync { + async fn list_pending(&self, user_id: &UserId) -> Result, DomainError>; + async fn get_by_id(&self, id: &WatchEventId) -> Result, DomainError>; + async fn get_by_ids(&self, ids: &[WatchEventId]) -> Result, DomainError>; async fn find_duplicate( &self, user_id: &UserId, external_id: &str, after: NaiveDateTime, ) -> Result; - async fn delete_non_pending_older_than( - &self, - before: NaiveDateTime, - ) -> Result; } #[async_trait] diff --git a/crates/domain/src/ports/movie.rs b/crates/domain/src/ports/movie.rs index 8bd65d3..70a0f7a 100644 --- a/crates/domain/src/ports/movie.rs +++ b/crates/domain/src/ports/movie.rs @@ -9,8 +9,16 @@ use crate::{ value_objects::{ExternalMetadataId, MovieId, MovieTitle, PosterUrl, ReleaseYear}, }; +/// Write port — mutates the movies table. #[async_trait] -pub trait MovieRepository: Send + Sync { +pub trait MovieCommand: Send + Sync { + async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError>; + async fn delete_movie(&self, movie_id: &MovieId) -> Result<(), DomainError>; +} + +/// Read port — queries movies. No mutations. +#[async_trait] +pub trait MovieQuery: Send + Sync { async fn get_movie_by_external_id( &self, external_metadata_id: &ExternalMetadataId, @@ -21,8 +29,6 @@ pub trait MovieRepository: Send + Sync { title: &MovieTitle, year: &ReleaseYear, ) -> Result, DomainError>; - async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError>; - async fn delete_movie(&self, movie_id: &MovieId) -> Result<(), DomainError>; async fn existing_external_ids( &self, ids: &[ExternalMetadataId], diff --git a/crates/domain/src/testing/in_memory.rs b/crates/domain/src/testing/in_memory.rs index 6c2365e..65773d6 100644 --- a/crates/domain/src/testing/in_memory.rs +++ b/crates/domain/src/testing/in_memory.rs @@ -18,8 +18,9 @@ use crate::{ }, ports::{ GoalRepository, ImportProfileRepository, ImportSessionRepository, MovieProfileRepository, - MovieRepository, RefreshSessionRepository, ReviewRepository, UserFederationSettingsQuery, - UserProfileFieldsRepository, UserRepository, UserSettingsRepository, WatchEventRepository, + MovieCommand, MovieQuery, RefreshSessionRepository, ReviewRepository, + UserFederationSettingsQuery, UserProfileFieldsRepository, UserRepository, + UserSettingsRepository, WatchEventCommand, WatchEventQuery, WatchlistRepository, WebhookTokenRepository, }, value_objects::{ @@ -47,7 +48,23 @@ impl InMemoryMovieRepository { } #[async_trait] -impl MovieRepository for InMemoryMovieRepository { +impl MovieCommand for InMemoryMovieRepository { + async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError> { + self.store + .lock() + .unwrap() + .insert(movie.id().value(), movie.clone()); + Ok(()) + } + + async fn delete_movie(&self, movie_id: &MovieId) -> Result<(), DomainError> { + self.store.lock().unwrap().remove(&movie_id.value()); + Ok(()) + } +} + +#[async_trait] +impl MovieQuery for InMemoryMovieRepository { async fn get_movie_by_external_id( &self, external_metadata_id: &ExternalMetadataId, @@ -80,19 +97,6 @@ impl MovieRepository for InMemoryMovieRepository { .collect()) } - async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError> { - self.store - .lock() - .unwrap() - .insert(movie.id().value(), movie.clone()); - Ok(()) - } - - async fn delete_movie(&self, movie_id: &MovieId) -> Result<(), DomainError> { - self.store.lock().unwrap().remove(&movie_id.value()); - Ok(()) - } - async fn existing_external_ids( &self, ids: &[ExternalMetadataId], @@ -526,7 +530,7 @@ impl InMemoryWatchEventRepository { } #[async_trait] -impl WatchEventRepository for InMemoryWatchEventRepository { +impl WatchEventCommand for InMemoryWatchEventRepository { async fn save(&self, event: &WatchEvent) -> Result<(), DomainError> { self.store.lock().unwrap().push(event.clone()); Ok(()) @@ -540,6 +544,27 @@ impl WatchEventRepository for InMemoryWatchEventRepository { Ok(()) } + async fn update_status_batch( + &self, + ids: &[WatchEventId], + _status: WatchEventStatus, + ) -> Result { + Ok(ids.len() as u64) + } + + async fn delete_non_pending_older_than( + &self, + before: NaiveDateTime, + ) -> Result { + let mut store = self.store.lock().unwrap(); + let before_len = store.len(); + store.retain(|e| *e.status() == WatchEventStatus::Pending || *e.created_at() >= before); + Ok((before_len - store.len()) as u64) + } +} + +#[async_trait] +impl WatchEventQuery for InMemoryWatchEventRepository { async fn list_pending(&self, user_id: &UserId) -> Result, DomainError> { let store = self.store.lock().unwrap(); Ok(store @@ -566,14 +591,6 @@ impl WatchEventRepository for InMemoryWatchEventRepository { .collect()) } - async fn update_status_batch( - &self, - ids: &[WatchEventId], - _status: WatchEventStatus, - ) -> Result { - Ok(ids.len() as u64) - } - async fn find_duplicate( &self, user_id: &UserId, @@ -588,15 +605,6 @@ impl WatchEventRepository for InMemoryWatchEventRepository { })) } - async fn delete_non_pending_older_than( - &self, - before: NaiveDateTime, - ) -> Result { - let mut store = self.store.lock().unwrap(); - let before_len = store.len(); - store.retain(|e| *e.status() == WatchEventStatus::Pending || *e.created_at() >= before); - Ok((before_len - store.len()) as u64) - } } // ── InMemoryImportSessionRepository ───────────────────────────────────────── diff --git a/crates/domain/src/testing/panics.rs b/crates/domain/src/testing/panics.rs index 7a6fdcf..7233daf 100644 --- a/crates/domain/src/testing/panics.rs +++ b/crates/domain/src/testing/panics.rs @@ -371,44 +371,56 @@ impl crate::ports::FederatedProfileQuery for PanicFederatedProfileQuery { } } -pub struct PanicWatchEventRepository; +pub struct PanicWatchEventCommand; #[async_trait] -impl crate::ports::WatchEventRepository for PanicWatchEventRepository { +impl crate::ports::WatchEventCommand for PanicWatchEventCommand { async fn save(&self, _: &crate::models::WatchEvent) -> Result<(), DomainError> { - panic!("PanicWatchEventRepository called") + panic!("PanicWatchEventCommand called") } async fn update_status( &self, _: &crate::value_objects::WatchEventId, _: crate::models::WatchEventStatus, ) -> Result<(), DomainError> { - panic!("PanicWatchEventRepository called") - } - async fn list_pending( - &self, - _: &UserId, - ) -> Result, DomainError> { - panic!("PanicWatchEventRepository called") - } - async fn get_by_id( - &self, - _: &crate::value_objects::WatchEventId, - ) -> Result, DomainError> { - panic!("PanicWatchEventRepository called") - } - async fn get_by_ids( - &self, - _: &[crate::value_objects::WatchEventId], - ) -> Result, DomainError> { - panic!("PanicWatchEventRepository called") + panic!("PanicWatchEventCommand called") } async fn update_status_batch( &self, _: &[crate::value_objects::WatchEventId], _: crate::models::WatchEventStatus, ) -> Result { - panic!("PanicWatchEventRepository called") + panic!("PanicWatchEventCommand called") + } + async fn delete_non_pending_older_than( + &self, + _: chrono::NaiveDateTime, + ) -> Result { + panic!("PanicWatchEventCommand called") + } +} + +pub struct PanicWatchEventQuery; + +#[async_trait] +impl crate::ports::WatchEventQuery for PanicWatchEventQuery { + async fn list_pending( + &self, + _: &UserId, + ) -> Result, DomainError> { + panic!("PanicWatchEventQuery called") + } + async fn get_by_id( + &self, + _: &crate::value_objects::WatchEventId, + ) -> Result, DomainError> { + panic!("PanicWatchEventQuery called") + } + async fn get_by_ids( + &self, + _: &[crate::value_objects::WatchEventId], + ) -> Result, DomainError> { + panic!("PanicWatchEventQuery called") } async fn find_duplicate( &self, @@ -416,13 +428,7 @@ impl crate::ports::WatchEventRepository for PanicWatchEventRepository { _: &str, _: chrono::NaiveDateTime, ) -> Result { - panic!("PanicWatchEventRepository called") - } - async fn delete_non_pending_older_than( - &self, - _: chrono::NaiveDateTime, - ) -> Result { - panic!("PanicWatchEventRepository called") + panic!("PanicWatchEventQuery called") } } diff --git a/crates/presentation/src/context.rs b/crates/presentation/src/context.rs index 98b67a7..4c8d51a 100644 --- a/crates/presentation/src/context.rs +++ b/crates/presentation/src/context.rs @@ -3,11 +3,12 @@ use std::sync::Arc; use domain::ports::{ AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher, FederatedProfileQuery, GoalRepository, ImportProfileRepository, ImportSessionRepository, - MetadataClient, MovieProfileRepository, MovieRepository, ObjectStorage, PasswordHasher, + MetadataClient, MovieCommand, MovieProfileRepository, MovieQuery, ObjectStorage, PasswordHasher, PersonCommand, PersonEnrichmentClient, PersonQuery, PosterFetcherClient, RefreshSessionRepository, RemoteGoalRepository, RemoteWatchlistRepository, ReviewRepository, SearchCommand, SearchPort, SocialQueryPort, StatsRepository, UserProfileFieldsRepository, - UserRepository, UserSettingsRepository, WatchEventRepository, WatchlistRepository, + UserRepository, UserSettingsRepository, WatchEventCommand, WatchEventQuery, + WatchlistRepository, WebhookTokenRepository, WrapUpRepository, WrapUpStatsQuery, }; @@ -16,7 +17,8 @@ use application::ports::ReviewLogger; #[derive(Clone)] pub struct Repositories { - pub movie: Arc, + pub movie_command: Arc, + pub movie_query: Arc, pub review: Arc, pub diary: Arc, pub stats: Arc, @@ -25,7 +27,8 @@ pub struct Repositories { pub import_profile: Arc, pub movie_profile: Arc, pub watchlist: Arc, - pub watch_event: Arc, + pub watch_event_command: Arc, + pub watch_event_query: Arc, pub webhook_token: Arc, pub person_command: Arc, pub person_query: Arc, diff --git a/crates/presentation/src/factory.rs b/crates/presentation/src/factory.rs index aac0d2f..3b99011 100644 --- a/crates/presentation/src/factory.rs +++ b/crates/presentation/src/factory.rs @@ -4,13 +4,14 @@ use anyhow::Context; use domain::ports::{ AuthService, LocalApContentQuery, MetadataClient, ObjectStorage, PasswordHasher, PosterFetcherClient, RefreshSessionRepository, UserProfileFieldsRepository, - WatchEventRepository, WebhookTokenRepository, + WatchEventCommand, WatchEventQuery, WebhookTokenRepository, }; pub use infra_wiring::DbPool; pub struct DatabaseOutput { - pub movie: Arc, + pub movie_command: Arc, + pub movie_query: Arc, pub review: Arc, pub diary: Arc, pub stats: Arc, @@ -19,7 +20,8 @@ pub struct DatabaseOutput { pub import_profile: Arc, pub movie_profile: Arc, pub watchlist: Arc, - pub watch_event: Arc, + pub watch_event_command: Arc, + pub watch_event_query: Arc, pub webhook_token: Arc, pub person_command: Arc, pub person_query: Arc, @@ -47,13 +49,13 @@ pub async fn build_database_adapters(backend: &str, url: &str) -> anyhow::Result let (pc, pq) = postgres::create_person_adapter(w.pool.clone()); let (sc, sp) = postgres_search::create_search_adapter(w.pool.clone()); let pf = postgres::create_profile_fields_repo(w.pool.clone()); - let we: Arc = - Arc::new(postgres::PostgresWatchEventRepository::new(w.pool.clone())); + let we = Arc::new(postgres::PostgresWatchEventRepository::new(w.pool.clone())); let wt: Arc = Arc::new( postgres::PostgresWebhookTokenRepository::new(w.pool.clone()), ); Ok(DatabaseOutput { - movie: w.movie, + movie_command: w.movie_command, + movie_query: w.movie_query, review: w.review, diary: w.diary, stats: w.stats, @@ -62,7 +64,8 @@ pub async fn build_database_adapters(backend: &str, url: &str) -> anyhow::Result import_profile: w.import_profile, movie_profile: w.movie_profile, watchlist: w.watchlist, - watch_event: we, + watch_event_command: we.clone() as _, + watch_event_query: we as _, webhook_token: wt, person_command: pc, person_query: pq, @@ -90,12 +93,12 @@ pub async fn build_database_adapters(backend: &str, url: &str) -> anyhow::Result let (pc, pq) = sqlite::create_person_adapter(w.pool.clone()); let (sc, sp) = sqlite_search::create_search_adapter(w.pool.clone()); let pf = sqlite::create_profile_fields_repo(w.pool.clone()); - let we: Arc = - Arc::new(sqlite::SqliteWatchEventRepository::new(w.pool.clone())); + let we = Arc::new(sqlite::SqliteWatchEventRepository::new(w.pool.clone())); let wt: Arc = Arc::new(sqlite::SqliteWebhookTokenRepository::new(w.pool.clone())); Ok(DatabaseOutput { - movie: w.movie, + movie_command: w.movie_command, + movie_query: w.movie_query, review: w.review, diary: w.diary, stats: w.stats, @@ -104,7 +107,8 @@ pub async fn build_database_adapters(backend: &str, url: &str) -> anyhow::Result import_profile: w.import_profile, movie_profile: w.movie_profile, watchlist: w.watchlist, - watch_event: we, + watch_event_command: we.clone() as _, + watch_event_query: we as _, webhook_token: wt, person_command: pc, person_query: pq, diff --git a/crates/presentation/src/handlers/diary.rs b/crates/presentation/src/handlers/diary.rs index 99b8a12..148d384 100644 --- a/crates/presentation/src/handlers/diary.rs +++ b/crates/presentation/src/handlers/diary.rs @@ -106,7 +106,7 @@ pub async fn delete_review( let deps = DeleteReviewDeps { review: state.app_ctx.repos.review.clone(), diary: state.app_ctx.repos.diary.clone(), - movie: state.app_ctx.repos.movie.clone(), + movie_command: state.app_ctx.repos.movie_command.clone(), event_publisher: state.app_ctx.services.event_publisher.clone(), }; delete_review::execute(&deps, cmd).await?; @@ -277,7 +277,7 @@ pub async fn post_delete_review_html( let deps = DeleteReviewDeps { review: state.app_ctx.repos.review.clone(), diary: state.app_ctx.repos.diary.clone(), - movie: state.app_ctx.repos.movie.clone(), + movie_command: state.app_ctx.repos.movie_command.clone(), event_publisher: state.app_ctx.services.event_publisher.clone(), }; match delete_review::execute(&deps, cmd).await { diff --git a/crates/presentation/src/handlers/goals.rs b/crates/presentation/src/handlers/goals.rs index f352e98..c9f6c07 100644 --- a/crates/presentation/src/handlers/goals.rs +++ b/crates/presentation/src/handlers/goals.rs @@ -10,6 +10,7 @@ use api_types::{ CreateGoalRequest, GoalDto, GoalsResponse, UpdateGoalRequest, UpdateUserSettingsRequest, UserSettingsDto, }; +use application::goals::deps::{GoalCommandDeps, GoalQueryDeps}; // ── Shared mapper ──────────────────────────────────────────────────────────── @@ -38,9 +39,12 @@ pub async fn list_goals( State(state): State, user: AuthenticatedUser, ) -> Result, ApiError> { + let deps = GoalQueryDeps { + goal: state.app_ctx.repos.goal.clone(), + stats: state.app_ctx.repos.stats.clone(), + }; let goals = application::goals::list::execute( - state.app_ctx.repos.goal.clone(), - state.app_ctx.repos.stats.clone(), + &deps, application::goals::queries::ListGoalsQuery { user_id: user.0.value(), }, @@ -65,10 +69,13 @@ pub async fn create_goal( user: AuthenticatedUser, Json(req): Json, ) -> Result, ApiError> { + let deps = GoalCommandDeps { + goal: state.app_ctx.repos.goal.clone(), + stats: state.app_ctx.repos.stats.clone(), + event_publisher: state.app_ctx.services.event_publisher.clone(), + }; 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(), + &deps, application::goals::commands::CreateGoalCommand { user_id: user.0.value(), year: req.year, @@ -95,10 +102,13 @@ pub async fn update_goal( Path(year): Path, Json(req): Json, ) -> Result, ApiError> { + let deps = GoalCommandDeps { + goal: state.app_ctx.repos.goal.clone(), + stats: state.app_ctx.repos.stats.clone(), + event_publisher: state.app_ctx.services.event_publisher.clone(), + }; 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(), + &deps, application::goals::commands::UpdateGoalCommand { user_id: user.0.value(), year, @@ -123,9 +133,13 @@ pub async fn delete_goal( user: AuthenticatedUser, Path(year): Path, ) -> Result { + let deps = GoalCommandDeps { + goal: state.app_ctx.repos.goal.clone(), + stats: state.app_ctx.repos.stats.clone(), + event_publisher: state.app_ctx.services.event_publisher.clone(), + }; application::goals::delete::execute( - state.app_ctx.repos.goal.clone(), - state.app_ctx.services.event_publisher.clone(), + &deps, application::goals::commands::DeleteGoalCommand { user_id: user.0.value(), year, @@ -148,9 +162,12 @@ pub async fn get_user_goals( AuthenticatedUser(_viewer): AuthenticatedUser, Path(user_id): Path, ) -> Result, ApiError> { + let deps = GoalQueryDeps { + goal: state.app_ctx.repos.goal.clone(), + stats: state.app_ctx.repos.stats.clone(), + }; let goals = application::goals::list::execute( - state.app_ctx.repos.goal.clone(), - state.app_ctx.repos.stats.clone(), + &deps, application::goals::queries::ListGoalsQuery { user_id }, ) .await?; diff --git a/crates/presentation/src/handlers/import.rs b/crates/presentation/src/handlers/import.rs index 2ebf4be..442712e 100644 --- a/crates/presentation/src/handlers/import.rs +++ b/crates/presentation/src/handlers/import.rs @@ -19,6 +19,7 @@ use application::import::{ DeleteImportProfileCommand, ExecuteImportCommand, SaveImportProfileCommand, }, create_session as create_import_session, delete_profile as delete_import_profile, + deps::{ApplyMappingDeps, ApplyProfileDeps, CreateSessionDeps, ExecuteImportDeps, SaveProfileDeps}, execute as execute_import, list_profiles as list_import_profiles, save_profile as save_import_profile, }; @@ -160,8 +161,10 @@ pub async fn post_upload( }; match create_import_session::execute( - state.app_ctx.repos.import_session.clone(), - state.app_ctx.services.document_parser.clone(), + &CreateSessionDeps { + import_session: state.app_ctx.repos.import_session.clone(), + document_parser: state.app_ctx.services.document_parser.clone(), + }, CreateImportSessionCommand { user_id: user_id.value(), bytes, @@ -250,9 +253,11 @@ pub async fn post_mapping( .into_response(); } match apply_import_mapping::execute( - state.app_ctx.repos.import_session.clone(), - state.app_ctx.services.document_parser.clone(), - state.app_ctx.repos.movie.clone(), + &ApplyMappingDeps { + import_session: state.app_ctx.repos.import_session.clone(), + document_parser: state.app_ctx.services.document_parser.clone(), + movie_query: state.app_ctx.repos.movie_query.clone(), + }, ApplyImportMappingCommand { user_id: user_id.value(), session_id: session_id.value(), @@ -346,8 +351,10 @@ pub async fn post_confirm( .filter(|n| !n.trim().is_empty()); if let Some(name) = profile_name { let _ = save_import_profile::execute( - state.app_ctx.repos.import_session.clone(), - state.app_ctx.repos.import_profile.clone(), + &SaveProfileDeps { + import_session: state.app_ctx.repos.import_session.clone(), + import_profile: state.app_ctx.repos.import_profile.clone(), + }, SaveImportProfileCommand { user_id: user_id.value(), session_id: session_id.value(), @@ -365,8 +372,10 @@ pub async fn post_confirm( .collect(); match execute_import::execute( - state.app_ctx.repos.import_session.clone(), - state.app_ctx.services.review_logger.clone(), + &ExecuteImportDeps { + import_session: state.app_ctx.repos.import_session.clone(), + review_logger: state.app_ctx.services.review_logger.clone(), + }, ExecuteImportCommand { user_id: user_id.value(), session_id: session_id.value(), @@ -487,8 +496,10 @@ pub async fn api_post_session( _ => FileFormat::Csv, }; let r = create_import_session::execute( - state.app_ctx.repos.import_session.clone(), - state.app_ctx.services.document_parser.clone(), + &CreateSessionDeps { + import_session: state.app_ctx.repos.import_session.clone(), + document_parser: state.app_ctx.services.document_parser.clone(), + }, CreateImportSessionCommand { user_id: user_id.value(), bytes, @@ -583,9 +594,11 @@ pub async fn api_put_mapping( .collect(); 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(), + &ApplyMappingDeps { + import_session: state.app_ctx.repos.import_session.clone(), + document_parser: state.app_ctx.services.document_parser.clone(), + movie_query: state.app_ctx.repos.movie_query.clone(), + }, ApplyImportMappingCommand { user_id: user_id.value(), session_id: session_id.value(), @@ -674,8 +687,10 @@ pub async fn api_post_confirm( .map(ImportSessionId::from_uuid) .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(), + &ExecuteImportDeps { + import_session: state.app_ctx.repos.import_session.clone(), + review_logger: state.app_ctx.services.review_logger.clone(), + }, ExecuteImportCommand { user_id: user_id.value(), session_id: session_id.value(), @@ -739,8 +754,10 @@ pub async fn api_post_profile( .map(ImportSessionId::from_uuid) .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(), + &SaveProfileDeps { + import_session: state.app_ctx.repos.import_session.clone(), + import_profile: state.app_ctx.repos.import_profile.clone(), + }, SaveImportProfileCommand { user_id: user_id.value(), session_id: session_id.value(), @@ -810,8 +827,10 @@ pub async fn api_apply_profile( .map_err(|_| DomainError::ValidationError("invalid profile id".into()))?; apply_import_profile::execute( - state.app_ctx.repos.import_profile.clone(), - state.app_ctx.repos.import_session.clone(), + &ApplyProfileDeps { + import_profile: state.app_ctx.repos.import_profile.clone(), + import_session: state.app_ctx.repos.import_session.clone(), + }, ApplyImportProfileCommand { user_id: user_id.value(), session_id, @@ -832,9 +851,11 @@ pub async fn api_apply_profile( let mappings = session.field_mappings.unwrap_or_default(); 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(), + &ApplyMappingDeps { + import_session: state.app_ctx.repos.import_session.clone(), + document_parser: state.app_ctx.services.document_parser.clone(), + movie_query: state.app_ctx.repos.movie_query.clone(), + }, ApplyImportMappingCommand { user_id: user_id.value(), session_id, diff --git a/crates/presentation/src/handlers/integrations.rs b/crates/presentation/src/handlers/integrations.rs index c0ff4c7..6e7be2e 100644 --- a/crates/presentation/src/handlers/integrations.rs +++ b/crates/presentation/src/handlers/integrations.rs @@ -135,7 +135,7 @@ pub async fn get_watch_queue_page( let query = GetWatchQueueQuery { user_id: user_id.value(), }; - let events = get_watch_queue::execute(state.app_ctx.repos.watch_event.clone(), query) + let events = get_watch_queue::execute(state.app_ctx.repos.watch_event_query.clone(), query) .await .unwrap_or_default(); @@ -173,7 +173,8 @@ pub async fn post_confirm_single( }; match confirm_watch_events::execute( - state.app_ctx.repos.watch_event.clone(), + state.app_ctx.repos.watch_event_command.clone(), + state.app_ctx.repos.watch_event_query.clone(), state.app_ctx.services.review_logger.clone(), cmd, ) @@ -203,7 +204,7 @@ pub async fn post_dismiss_single( event_ids: vec![event_id], }; - match dismiss_watch_events::execute(state.app_ctx.repos.watch_event.clone(), cmd).await { + match dismiss_watch_events::execute(state.app_ctx.repos.watch_event_command.clone(), state.app_ctx.repos.watch_event_query.clone(), cmd).await { Ok(_) => Redirect::to("/watch-queue").into_response(), Err(e) => { let msg = encode_error(&e.to_string()); diff --git a/crates/presentation/src/handlers/movies.rs b/crates/presentation/src/handlers/movies.rs index 3c908a8..9424ff2 100644 --- a/crates/presentation/src/handlers/movies.rs +++ b/crates/presentation/src/handlers/movies.rs @@ -48,7 +48,7 @@ pub async fn list_movies( Query(params): Query, ) -> Result, ApiError> { let page = get_movies::execute( - state.app_ctx.repos.movie.clone(), + state.app_ctx.repos.movie_query.clone(), GetMoviesQuery { limit: params.limit, offset: params.offset, @@ -122,7 +122,8 @@ pub async fn sync_poster( ) -> Result { sync_poster::execute( &SyncPosterDeps { - movie: state.app_ctx.repos.movie.clone(), + movie_command: state.app_ctx.repos.movie_command.clone(), + movie_query: state.app_ctx.repos.movie_query.clone(), movie_profile: state.app_ctx.repos.movie_profile.clone(), metadata: state.app_ctx.services.metadata.clone(), poster_fetcher: state.app_ctx.services.poster_fetcher.clone(), @@ -154,7 +155,7 @@ pub async fn get_movie_detail( let result = get_movie_social_page::execute( &GetMovieSocialPageDeps { - movie: state.app_ctx.repos.movie.clone(), + movie_query: state.app_ctx.repos.movie_query.clone(), diary: state.app_ctx.repos.diary.clone(), movie_profile: state.app_ctx.repos.movie_profile.clone(), }, @@ -288,7 +289,7 @@ pub async fn get_movie_detail_html( match get_movie_social_page::execute( &GetMovieSocialPageDeps { - movie: state.app_ctx.repos.movie.clone(), + movie_query: state.app_ctx.repos.movie_query.clone(), diary: state.app_ctx.repos.diary.clone(), movie_profile: state.app_ctx.repos.movie_profile.clone(), }, diff --git a/crates/presentation/src/handlers/users.rs b/crates/presentation/src/handlers/users.rs index d7496c5..9f34624 100644 --- a/crates/presentation/src/handlers/users.rs +++ b/crates/presentation/src/handlers/users.rs @@ -269,54 +269,62 @@ pub async fn get_user_profile( Err(e) => return crate::errors::domain_error_response(e), }; - let entries = profile.entries.map(|p| DiaryResponse { - items: p - .items - .iter() - .map(crate::mappers::movies::entry_to_dto) - .collect(), - total_count: p.total_count, - limit: p.limit, - offset: p.offset, - }); - - let history = profile.history.map(|entries| { - application::users::group_by_month(entries) - .into_iter() - .map(|m| MonthActivityDto { - year_month: m.year_month, - month_label: m.month_label, - count: m.count, - entries: m - .entries + let view_data = if let Some(p) = profile.entries { + Some(api_types::ProfileViewData::Entries { + entries: DiaryResponse { + items: p + .items .iter() .map(crate::mappers::movies::entry_to_dto) .collect(), - }) - .collect() - }); - - let trends = profile.trends.map(|t| UserTrendsDto { - monthly_ratings: t - .monthly_ratings - .into_iter() - .map(|r| MonthlyRatingDto { - year_month: r.year_month, - month_label: r.month_label, - avg_rating: r.avg_rating, - count: r.count, - }) - .collect(), - top_directors: t - .top_directors - .into_iter() - .map(|d| DirectorStatDto { - director: d.director, - count: d.count, - }) - .collect(), - max_director_count: t.max_director_count, - }); + total_count: p.total_count, + limit: p.limit, + offset: p.offset, + }, + }) + } else if let Some(h) = profile.history { + Some(api_types::ProfileViewData::History { + history: application::users::group_by_month(h) + .into_iter() + .map(|m| MonthActivityDto { + year_month: m.year_month, + month_label: m.month_label, + count: m.count, + entries: m + .entries + .iter() + .map(crate::mappers::movies::entry_to_dto) + .collect(), + }) + .collect(), + }) + } else if let Some(t) = profile.trends { + Some(api_types::ProfileViewData::Trends { + trends: UserTrendsDto { + monthly_ratings: t + .monthly_ratings + .into_iter() + .map(|r| MonthlyRatingDto { + year_month: r.year_month, + month_label: r.month_label, + avg_rating: r.avg_rating, + count: r.count, + }) + .collect(), + top_directors: t + .top_directors + .into_iter() + .map(|d| DirectorStatDto { + director: d.director, + count: d.count, + }) + .collect(), + max_director_count: t.max_director_count, + }, + }) + } else { + None + }; Json(UserProfileResponse { user_id, @@ -339,13 +347,13 @@ pub async fn get_user_profile( }, following_count: profile.following_count, followers_count: profile.followers_count, - entries, - history, - trends, + view_data, goals: { let goals_list = application::goals::list::execute( - state.app_ctx.repos.goal.clone(), - state.app_ctx.repos.stats.clone(), + &application::goals::deps::GoalQueryDeps { + goal: state.app_ctx.repos.goal.clone(), + stats: state.app_ctx.repos.stats.clone(), + }, application::goals::queries::ListGoalsQuery { user_id }, ) .await @@ -394,38 +402,46 @@ async fn build_federated_profile_response( Err(e) => return crate::errors::domain_error_response(e), }; - let entries = profile.entries.map(|p| DiaryResponse { - items: p - .items - .iter() - .map(crate::mappers::movies::entry_to_dto) - .collect(), - total_count: p.total_count, - limit: p.limit, - offset: p.offset, - }); - - let trends = profile.trends.map(|t| UserTrendsDto { - monthly_ratings: t - .monthly_ratings - .into_iter() - .map(|r| MonthlyRatingDto { - year_month: r.year_month, - month_label: r.month_label, - avg_rating: r.avg_rating, - count: r.count, - }) - .collect(), - top_directors: t - .top_directors - .into_iter() - .map(|d| DirectorStatDto { - director: d.director, - count: d.count, - }) - .collect(), - max_director_count: t.max_director_count, - }); + let view_data = if let Some(p) = profile.entries { + Some(api_types::ProfileViewData::Entries { + entries: DiaryResponse { + items: p + .items + .iter() + .map(crate::mappers::movies::entry_to_dto) + .collect(), + total_count: p.total_count, + limit: p.limit, + offset: p.offset, + }, + }) + } else if let Some(t) = profile.trends { + Some(api_types::ProfileViewData::Trends { + trends: UserTrendsDto { + monthly_ratings: t + .monthly_ratings + .into_iter() + .map(|r| MonthlyRatingDto { + year_month: r.year_month, + month_label: r.month_label, + avg_rating: r.avg_rating, + count: r.count, + }) + .collect(), + top_directors: t + .top_directors + .into_iter() + .map(|d| DirectorStatDto { + director: d.director, + count: d.count, + }) + .collect(), + max_director_count: t.max_director_count, + }, + }) + } else { + None + }; let username = fed .display_name @@ -449,9 +465,7 @@ async fn build_federated_profile_response( }, following_count: 0, followers_count: 0, - entries, - history: None, - trends, + view_data, goals: None, is_federated: true, handle: Some(fed.handle), @@ -735,8 +749,10 @@ pub async fn get_user_profile_html( search: params.search.clone(), goals: { let goals_list = application::goals::list::execute( - state.app_ctx.repos.goal.clone(), - state.app_ctx.repos.stats.clone(), + &application::goals::deps::GoalQueryDeps { + goal: state.app_ctx.repos.goal.clone(), + stats: state.app_ctx.repos.stats.clone(), + }, application::goals::queries::ListGoalsQuery { user_id: profile_user_uuid, }, diff --git a/crates/presentation/src/handlers/watchlist.rs b/crates/presentation/src/handlers/watchlist.rs index 28fc8a3..b34f0ac 100644 --- a/crates/presentation/src/handlers/watchlist.rs +++ b/crates/presentation/src/handlers/watchlist.rs @@ -95,7 +95,8 @@ pub async fn post_watchlist_add( Json(req): Json, ) -> Result { let deps = WatchlistAddDeps { - movie: state.app_ctx.repos.movie.clone(), + movie_command: state.app_ctx.repos.movie_command.clone(), + movie_query: state.app_ctx.repos.movie_query.clone(), metadata: state.app_ctx.services.metadata.clone(), watchlist: state.app_ctx.repos.watchlist.clone(), event_publisher: state.app_ctx.services.event_publisher.clone(), @@ -280,7 +281,8 @@ pub async fn post_watchlist_add_html( }; let deps = WatchlistAddDeps { - movie: state.app_ctx.repos.movie.clone(), + movie_command: state.app_ctx.repos.movie_command.clone(), + movie_query: state.app_ctx.repos.movie_query.clone(), metadata: state.app_ctx.services.metadata.clone(), watchlist: state.app_ctx.repos.watchlist.clone(), event_publisher: state.app_ctx.services.event_publisher.clone(), diff --git a/crates/presentation/src/handlers/webhook.rs b/crates/presentation/src/handlers/webhook.rs index 584ec04..8c4edd9 100644 --- a/crates/presentation/src/handlers/webhook.rs +++ b/crates/presentation/src/handlers/webhook.rs @@ -129,7 +129,8 @@ async fn run_ingest( ) -> StatusCode { let deps = IngestWatchEventDeps { webhook_token: state.app_ctx.repos.webhook_token.clone(), - watch_event: state.app_ctx.repos.watch_event.clone(), + watch_event_command: state.app_ctx.repos.watch_event_command.clone(), + watch_event_query: state.app_ctx.repos.watch_event_query.clone(), event_publisher: state.app_ctx.services.event_publisher.clone(), }; match ingest_watch_event::execute(&deps, cmd, parser).await { @@ -250,7 +251,7 @@ pub async fn get_watch_queue( let query = GetWatchQueueQuery { user_id: user.0.value(), }; - let events = get_watch_queue::execute(state.app_ctx.repos.watch_event.clone(), query).await?; + let events = get_watch_queue::execute(state.app_ctx.repos.watch_event_query.clone(), query).await?; let dtos = events .into_iter() @@ -296,7 +297,8 @@ pub async fn post_confirm_watch_events( }; let confirmed = confirm_watch_events::execute( - state.app_ctx.repos.watch_event.clone(), + state.app_ctx.repos.watch_event_command.clone(), + state.app_ctx.repos.watch_event_query.clone(), state.app_ctx.services.review_logger.clone(), cmd, ) @@ -325,6 +327,6 @@ pub async fn post_dismiss_watch_events( }; let dismissed = - dismiss_watch_events::execute(state.app_ctx.repos.watch_event.clone(), cmd).await?; + dismiss_watch_events::execute(state.app_ctx.repos.watch_event_command.clone(), state.app_ctx.repos.watch_event_query.clone(), cmd).await?; Ok(Json(DismissWatchResponse { dismissed })) } diff --git a/crates/presentation/src/main.rs b/crates/presentation/src/main.rs index a7a7ac0..0f7b7fe 100644 --- a/crates/presentation/src/main.rs +++ b/crates/presentation/src/main.rs @@ -97,7 +97,7 @@ 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), + movie_repo: Arc::clone(&db.movie_query), review_repo: Arc::clone(&db.review), diary_repo: Arc::clone(&db.diary), goal_repo: Arc::clone(&db.goal), @@ -127,7 +127,8 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { let ap_router = axum::Router::new(); let review_logger = Arc::new(application::diary::review_logger::DefaultReviewLogger::new( - Arc::clone(&db.movie), + Arc::clone(&db.movie_command), + Arc::clone(&db.movie_query), Arc::clone(&db.review), Arc::clone(&db.watchlist), Arc::clone(&metadata_client), @@ -136,7 +137,8 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { let app_ctx = AppContext { repos: Repositories { - movie: db.movie, + movie_command: db.movie_command, + movie_query: db.movie_query, review: db.review, diary: db.diary, stats: db.stats, @@ -145,7 +147,8 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { import_profile: db.import_profile, movie_profile: db.movie_profile, watchlist: db.watchlist, - watch_event: db.watch_event, + watch_event_command: db.watch_event_command, + watch_event_query: db.watch_event_query, webhook_token: db.webhook_token, person_command: db.person_command, person_query: db.person_query, diff --git a/crates/presentation/src/openapi/users.rs b/crates/presentation/src/openapi/users.rs index 01a368c..6f9f292 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, UserProfileBase, - UserProfileResponse, UserStatsDto, UserSummaryDto, UsersResponse, + ProfileFieldDto, ProfileResponse, ProfileViewData, UpdateProfileFieldsRequest, + UserProfileBase, UserProfileResponse, UserStatsDto, UserSummaryDto, UsersResponse, }; use utoipa::OpenApi; @@ -18,6 +18,7 @@ use utoipa::OpenApi; UserSummaryDto, UserProfileBase, UserProfileResponse, + ProfileViewData, UserStatsDto, ProfileResponse, UpdateProfileFieldsRequest, diff --git a/crates/presentation/src/tests/extractors.rs b/crates/presentation/src/tests/extractors.rs index 935a0d5..5225f60 100644 --- a/crates/presentation/src/tests/extractors.rs +++ b/crates/presentation/src/tests/extractors.rs @@ -17,7 +17,7 @@ use domain::{ collections::{PageParams, Paginated}, }, ports::{ - AuthService, DiaryRepository, EventPublisher, MetadataClient, MovieRepository, + AuthService, DiaryRepository, EventPublisher, MetadataClient, MovieCommand, MovieQuery, ObjectStorage, PasswordHasher, PersonCommand, PersonQuery, PosterFetcherClient, ReviewRepository, SearchCommand, SearchPort, StatsRepository, UserRepository, WatchlistRepository, @@ -35,7 +35,16 @@ use tower::ServiceExt; pub struct Panic; #[async_trait::async_trait] -impl MovieRepository for Panic { +impl MovieCommand for Panic { + async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { + panic!() + } + async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { + panic!() + } +} +#[async_trait::async_trait] +impl MovieQuery for Panic { async fn get_movie_by_external_id( &self, _: &ExternalMetadataId, @@ -52,12 +61,6 @@ impl MovieRepository for Panic { ) -> Result, DomainError> { panic!() } - async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { - panic!() - } - async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { - panic!() - } async fn existing_external_ids( &self, _: &[ExternalMetadataId], @@ -530,7 +533,7 @@ impl domain::ports::RemoteWatchlistRepository for Panic { } #[async_trait::async_trait] -impl domain::ports::WatchEventRepository for Panic { +impl domain::ports::WatchEventCommand for Panic { async fn save(&self, _: &domain::models::WatchEvent) -> Result<(), DomainError> { panic!() } @@ -541,6 +544,22 @@ impl domain::ports::WatchEventRepository for Panic { ) -> Result<(), DomainError> { panic!() } + async fn update_status_batch( + &self, + _: &[domain::value_objects::WatchEventId], + _: domain::models::WatchEventStatus, + ) -> Result { + panic!() + } + async fn delete_non_pending_older_than( + &self, + _: chrono::NaiveDateTime, + ) -> Result { + panic!() + } +} +#[async_trait::async_trait] +impl domain::ports::WatchEventQuery for Panic { async fn list_pending( &self, _: &domain::value_objects::UserId, @@ -559,13 +578,6 @@ impl domain::ports::WatchEventRepository for Panic { ) -> Result, DomainError> { panic!() } - async fn update_status_batch( - &self, - _: &[domain::value_objects::WatchEventId], - _: domain::models::WatchEventStatus, - ) -> Result { - panic!() - } async fn find_duplicate( &self, _: &domain::value_objects::UserId, @@ -574,12 +586,6 @@ impl domain::ports::WatchEventRepository for Panic { ) -> Result { panic!() } - async fn delete_non_pending_older_than( - &self, - _: chrono::NaiveDateTime, - ) -> Result { - panic!() - } } #[async_trait::async_trait] impl domain::ports::WebhookTokenRepository for Panic { @@ -782,7 +788,8 @@ pub fn make_test_state(auth_service: Arc) -> crate::state::AppS crate::state::AppState { app_ctx: AppContext { repos: Repositories { - movie: Arc::clone(&repo) as _, + movie_command: Arc::clone(&repo) as _, + movie_query: Arc::clone(&repo) as _, review: Arc::clone(&repo) as _, diary: Arc::clone(&repo) as _, stats: Arc::clone(&repo) as _, @@ -791,7 +798,8 @@ pub fn make_test_state(auth_service: Arc) -> crate::state::AppS import_profile: Arc::clone(&repo) as _, movie_profile: Arc::clone(&repo) as _, watchlist: Arc::clone(&repo) as _, - watch_event: Arc::clone(&repo) as _, + watch_event_command: Arc::clone(&repo) as _, + watch_event_query: Arc::clone(&repo) as _, webhook_token: Arc::clone(&repo) as _, profile_fields: Arc::clone(&repo) as _, person_command: Arc::clone(&repo) as _, diff --git a/crates/presentation/tests/api_test.rs b/crates/presentation/tests/api_test.rs index 2184458..a6fc8ee 100644 --- a/crates/presentation/tests/api_test.rs +++ b/crates/presentation/tests/api_test.rs @@ -445,7 +445,8 @@ async fn test_app() -> Router { let state = AppState { app_ctx: AppContext { repos: Repositories { - movie: Arc::new(SqliteMovieRepository::new(pool.clone())) as _, + movie_command: Arc::new(SqliteMovieRepository::new(pool.clone())) as _, + movie_query: Arc::new(SqliteMovieRepository::new(pool.clone())) as _, review: Arc::new(SqliteReviewRepository::new(pool.clone())) as _, diary: Arc::new(SqliteDiaryRepository::new(pool.clone())) as _, stats: Arc::new(SqliteStatsRepository::new(pool.clone())) as _, @@ -454,7 +455,8 @@ async fn test_app() -> Router { import_profile: Arc::new(PanicImportProfile), movie_profile: Arc::new(PanicMovieProfile), watchlist: Arc::new(PanicWatchlist), - watch_event: Arc::new(domain::testing::PanicWatchEventRepository), + watch_event_command: Arc::new(domain::testing::PanicWatchEventCommand), + watch_event_query: Arc::new(domain::testing::PanicWatchEventQuery), webhook_token: Arc::new(domain::testing::PanicWebhookTokenRepository), profile_fields: Arc::new(PanicProfileFields), person_command: Arc::new(PanicPersonCommand), diff --git a/crates/worker/src/db.rs b/crates/worker/src/db.rs index 124bacb..8c1a696 100644 --- a/crates/worker/src/db.rs +++ b/crates/worker/src/db.rs @@ -3,15 +3,16 @@ use std::sync::Arc; use anyhow::Context; use domain::ports::{ DiaryRepository, GoalRepository, ImageRefCommand, ImageRefQuery, ImportSessionRepository, - LocalApContentQuery, MovieDeduplicator, MovieProfileRepository, MovieRepository, PersonCommand, - PersonQuery, ReviewRepository, SearchCommand, StatsRepository, UserRepository, - WatchEventRepository, + LocalApContentQuery, MovieCommand, MovieDeduplicator, MovieProfileRepository, MovieQuery, + PersonCommand, PersonQuery, ReviewRepository, SearchCommand, StatsRepository, UserRepository, + WatchEventCommand, WatchEventQuery, }; pub use infra_wiring::DbPool; pub struct WorkerDbOutput { - pub movie: Arc, + pub movie_command: Arc, + pub movie_query: Arc, pub review: Arc, pub diary: Arc, pub stats: Arc, @@ -19,7 +20,8 @@ pub struct WorkerDbOutput { pub user: Arc, pub import_session: Arc, pub movie_profile: Arc, - pub watch_event: Arc, + pub watch_event_command: Arc, + pub watch_event_query: Arc, pub person_command: Arc, pub person_query: Arc, pub search_command: Arc, @@ -46,10 +48,10 @@ pub async fn connect(database_url: &str, backend: &str) -> anyhow::Result = - Arc::new(postgres::PostgresWatchEventRepository::new(w.pool.clone())); + let we = Arc::new(postgres::PostgresWatchEventRepository::new(w.pool.clone())); Ok(WorkerDbOutput { - movie: w.movie, + movie_command: w.movie_command, + movie_query: w.movie_query, review: w.review, diary: w.diary, stats: w.stats, @@ -57,7 +59,8 @@ pub async fn connect(database_url: &str, backend: &str) -> anyhow::Result anyhow::Result = - Arc::new(sqlite::SqliteWatchEventRepository::new(w.pool.clone())); + let we = Arc::new(sqlite::SqliteWatchEventRepository::new(w.pool.clone())); Ok(WorkerDbOutput { - movie: w.movie, + movie_command: w.movie_command, + movie_query: w.movie_query, review: w.review, diary: w.diary, stats: w.stats, @@ -95,7 +98,8 @@ pub async fn connect(database_url: &str, backend: &str) -> anyhow::Result anyhow::Result<()> { allow_registration, ) = ( Arc::clone(&db.ap_content), - Arc::clone(&db.movie), + Arc::clone(&db.movie_query), Arc::clone(&db.review), Arc::clone(&db.diary), Arc::clone(&db.goal), @@ -76,12 +76,14 @@ async fn main() -> anyhow::Result<()> { db::DbPool::Postgres(pool) => postgres_federation::wire(pool.clone()), }; - let movie = db.movie; + let movie_command = db.movie_command; + let movie_query = db.movie_query; let deduplicator = db.deduplicator; let user = db.user; let import_session = db.import_session; let movie_profile = db.movie_profile; - let watch_event = db.watch_event; + let watch_event_command = db.watch_event_command; + let watch_event_query = db.watch_event_query; let person_command = db.person_command; let person_query = db.person_query; let search_command = db.search_command; @@ -111,7 +113,7 @@ async fn main() -> anyhow::Result<()> { let image_fetcher = poster_fetcher::create_image_fetcher()?; let handler = Arc::new(application::movies::MovieEnrichmentHandler::new( Arc::clone(&client) as Arc, - Arc::clone(&movie), + Arc::clone(&movie_query), Arc::clone(&movie_profile), Arc::clone(&person_command), Arc::clone(&search_command), @@ -149,7 +151,7 @@ async fn main() -> anyhow::Result<()> { let mut periodic_jobs: Vec> = vec![ Arc::new(application::jobs::MovieDeduplicationJob::new( - Arc::clone(&movie), + Arc::clone(&movie_query), Arc::clone(&deduplicator), Arc::clone(&object_storage), )), @@ -157,7 +159,7 @@ async fn main() -> anyhow::Result<()> { import_session.clone(), )), Arc::new(application::jobs::WatchEventCleanupJob::new( - watch_event.clone(), + watch_event_command.clone(), )), Arc::new(application::jobs::WrapUpAutoGenerateJob::new( Arc::clone(&user), @@ -194,7 +196,8 @@ async fn main() -> anyhow::Result<()> { let handlers: Vec> = { let poster = Arc::new(poster_sync::PosterSyncHandler::new( - Arc::clone(&movie), + Arc::clone(&movie_command), + Arc::clone(&movie_query), Arc::clone(&metadata), Arc::clone(&poster_fetcher), Arc::clone(&object_storage), @@ -212,7 +215,7 @@ async fn main() -> anyhow::Result<()> { )) as Arc; let discovery_indexer = Arc::new(MovieDiscoveryIndexer::new( - Arc::clone(&movie), + Arc::clone(&movie_query), Arc::clone(&search_command), )) as Arc; @@ -223,7 +226,7 @@ async fn main() -> anyhow::Result<()> { )) as Arc; let reindex_handler = Arc::new(SearchReindexHandler::new(ReindexSearchDeps { - movie: Arc::clone(&movie), + movie_query: Arc::clone(&movie_query), movie_profile: Arc::clone(&movie_profile), search_command: Arc::clone(&search_command), person_command: Arc::clone(&person_command),