diff --git a/crates/adapters/tmdb-enrichment/src/lib.rs b/crates/adapters/tmdb-enrichment/src/lib.rs index 543765b..4bc3caa 100644 --- a/crates/adapters/tmdb-enrichment/src/lib.rs +++ b/crates/adapters/tmdb-enrichment/src/lib.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use application::{commands::EnrichMovieCommand, use_cases::enrich_movie}; +use application::movies::{commands::EnrichMovieCommand, enrich_movie}; use async_trait::async_trait; use chrono::Utc; use domain::{ diff --git a/crates/application/src/auth/commands.rs b/crates/application/src/auth/commands.rs new file mode 100644 index 0000000..0574faf --- /dev/null +++ b/crates/application/src/auth/commands.rs @@ -0,0 +1,14 @@ +use domain::models::UserRole; + +pub struct RegisterCommand { + pub email: String, + pub username: String, + pub password: String, + pub role: UserRole, +} + +pub struct RegisterAndLoginCommand { + pub email: String, + pub username: String, + pub password: String, +} diff --git a/crates/application/src/auth/login.rs b/crates/application/src/auth/login.rs new file mode 100644 index 0000000..0836522 --- /dev/null +++ b/crates/application/src/auth/login.rs @@ -0,0 +1,45 @@ +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +use domain::{errors::DomainError, value_objects::Email}; + +use crate::{auth::queries::LoginQuery, context::AppContext}; + +pub struct LoginResult { + pub token: String, + pub user_id: Uuid, + pub email: String, + pub expires_at: DateTime, +} + +pub async fn execute(ctx: &AppContext, query: LoginQuery) -> Result { + let email = Email::new(query.email)?; + let user = ctx + .repos + .user + .find_by_email(&email) + .await? + .ok_or_else(|| DomainError::Unauthorized("Invalid credentials".into()))?; + + let valid = ctx + .services + .password_hasher + .verify(&query.password, user.password_hash()) + .await?; + if !valid { + return Err(DomainError::Unauthorized("Invalid credentials".into())); + } + + let generated = ctx.services.auth.generate_token(user.id()).await?; + + Ok(LoginResult { + token: generated.token, + user_id: user.id().value(), + email: user.email().value().to_string(), + expires_at: generated.expires_at, + }) +} + +#[cfg(test)] +#[path = "tests/login.rs"] +mod tests; diff --git a/crates/application/src/auth/mod.rs b/crates/application/src/auth/mod.rs new file mode 100644 index 0000000..cf0fc74 --- /dev/null +++ b/crates/application/src/auth/mod.rs @@ -0,0 +1,5 @@ +pub mod commands; +pub mod login; +pub mod queries; +pub mod register; +pub mod register_and_login; diff --git a/crates/application/src/auth/queries.rs b/crates/application/src/auth/queries.rs new file mode 100644 index 0000000..a363863 --- /dev/null +++ b/crates/application/src/auth/queries.rs @@ -0,0 +1,4 @@ +pub struct LoginQuery { + pub email: String, + pub password: String, +} diff --git a/crates/application/src/auth/register.rs b/crates/application/src/auth/register.rs new file mode 100644 index 0000000..adf4be8 --- /dev/null +++ b/crates/application/src/auth/register.rs @@ -0,0 +1,46 @@ +use domain::{ + errors::DomainError, + models::User, + value_objects::{Email, Username}, +}; + +use crate::{auth::commands::RegisterCommand, context::AppContext}; + +const MIN_PASSWORD_LENGTH: usize = 8; + +pub async fn execute(ctx: &AppContext, cmd: RegisterCommand) -> Result<(), DomainError> { + if !ctx.config.allow_registration { + return Err(DomainError::Unauthorized("Registration is disabled".into())); + } + + if cmd.password.len() < MIN_PASSWORD_LENGTH { + return Err(DomainError::ValidationError( + "Password must be at least 8 characters".into(), + )); + } + + let email = Email::new(cmd.email)?; + let username = Username::new(cmd.username)?; + + if ctx.repos.user.find_by_email(&email).await?.is_some() { + return Err(DomainError::ValidationError( + "Email already registered".into(), + )); + } + + if ctx.repos.user.find_by_username(&username).await?.is_some() { + return Err(DomainError::ValidationError( + "Username already taken".into(), + )); + } + + let hash = ctx.services.password_hasher.hash(&cmd.password).await?; + ctx.repos + .user + .save(&User::new(email, username, hash, cmd.role)) + .await +} + +#[cfg(test)] +#[path = "tests/register.rs"] +mod tests; diff --git a/crates/application/src/use_cases/register_and_login.rs b/crates/application/src/auth/register_and_login.rs similarity index 77% rename from crates/application/src/use_cases/register_and_login.rs rename to crates/application/src/auth/register_and_login.rs index 0cbaaee..7d76c4c 100644 --- a/crates/application/src/use_cases/register_and_login.rs +++ b/crates/application/src/auth/register_and_login.rs @@ -1,9 +1,9 @@ use domain::errors::DomainError; use crate::{ - commands::RegisterAndLoginCommand, + auth::commands::RegisterAndLoginCommand, + auth::{login, register}, context::AppContext, - use_cases::{login, register}, }; pub async fn execute( @@ -12,7 +12,7 @@ pub async fn execute( ) -> Result { register::execute( ctx, - crate::commands::RegisterCommand { + crate::auth::commands::RegisterCommand { email: cmd.email.clone(), username: cmd.username, password: cmd.password.clone(), @@ -23,7 +23,7 @@ pub async fn execute( login::execute( ctx, - crate::queries::LoginQuery { + crate::auth::queries::LoginQuery { email: cmd.email, password: cmd.password, }, diff --git a/crates/application/src/auth/tests/login.rs b/crates/application/src/auth/tests/login.rs new file mode 100644 index 0000000..e333239 --- /dev/null +++ b/crates/application/src/auth/tests/login.rs @@ -0,0 +1,85 @@ +use std::sync::Arc; + +use domain::models::UserRole; +use domain::testing::InMemoryUserRepository; + +use crate::{ + auth::commands::RegisterCommand, + auth::queries::LoginQuery, + auth::{login, register}, + test_helpers::TestContextBuilder, +}; + +async fn setup_user(ctx: &crate::context::AppContext, email: &str, password: &str) { + register::execute( + ctx, + RegisterCommand { + email: email.to_string(), + username: "testuser".to_string(), + password: password.to_string(), + role: UserRole::Standard, + }, + ) + .await + .unwrap(); +} + +#[tokio::test] +async fn test_login_valid_credentials_returns_token() { + let users = InMemoryUserRepository::new(); + let ctx = TestContextBuilder::new() + .with_users(Arc::clone(&users) as _) + .build(); + + setup_user(&ctx, "carol@example.com", "secret123").await; + + let result = login::execute( + &ctx, + LoginQuery { + email: "carol@example.com".into(), + password: "secret123".into(), + }, + ) + .await + .unwrap(); + + assert!(!result.token.is_empty()); + assert_eq!(result.email, "carol@example.com"); +} + +#[tokio::test] +async fn test_login_wrong_password_fails() { + let users = InMemoryUserRepository::new(); + let ctx = TestContextBuilder::new() + .with_users(Arc::clone(&users) as _) + .build(); + + setup_user(&ctx, "dave@example.com", "correct_password").await; + + let result = login::execute( + &ctx, + LoginQuery { + email: "dave@example.com".into(), + password: "wrong_password".into(), + }, + ) + .await; + + assert!(result.is_err()); +} + +#[tokio::test] +async fn test_login_unknown_email_fails() { + let ctx = TestContextBuilder::new().build(); + + let result = login::execute( + &ctx, + LoginQuery { + email: "nobody@example.com".into(), + password: "anything".into(), + }, + ) + .await; + + assert!(result.is_err()); +} diff --git a/crates/application/src/auth/tests/register.rs b/crates/application/src/auth/tests/register.rs new file mode 100644 index 0000000..33eea8a --- /dev/null +++ b/crates/application/src/auth/tests/register.rs @@ -0,0 +1,48 @@ +use std::sync::Arc; + +use domain::models::UserRole; +use domain::ports::UserRepository; +use domain::testing::InMemoryUserRepository; +use domain::value_objects::Email; + +use crate::{auth::commands::RegisterCommand, auth::register, test_helpers::TestContextBuilder}; + +fn cmd(email: &str) -> RegisterCommand { + RegisterCommand { + email: email.to_string(), + username: "alice".to_string(), + password: "password123".to_string(), + role: UserRole::Standard, + } +} + +#[tokio::test] +async fn test_register_creates_user() { + let users = InMemoryUserRepository::new(); + let ctx = TestContextBuilder::new() + .with_users(Arc::clone(&users) as _) + .build(); + + register::execute(&ctx, cmd("alice@example.com")) + .await + .unwrap(); + + let email = Email::new("alice@example.com".into()).unwrap(); + let user = users.find_by_email(&email).await.unwrap().unwrap(); + assert_eq!(user.email().value(), "alice@example.com"); + assert!(user.password_hash().value().starts_with("hashed:")); +} + +#[tokio::test] +async fn test_register_duplicate_email_fails() { + let users = InMemoryUserRepository::new(); + let ctx = TestContextBuilder::new() + .with_users(Arc::clone(&users) as _) + .build(); + + register::execute(&ctx, cmd("bob@example.com")) + .await + .unwrap(); + let result = register::execute(&ctx, cmd("bob@example.com")).await; + assert!(result.is_err(), "duplicate email should fail"); +} diff --git a/crates/application/src/commands.rs b/crates/application/src/commands.rs deleted file mode 100644 index ada0d80..0000000 --- a/crates/application/src/commands.rs +++ /dev/null @@ -1,145 +0,0 @@ -use chrono::NaiveDateTime; -use domain::models::{FieldMapping, FileFormat, UserRole}; -use uuid::Uuid; - -pub struct MovieInput { - pub movie_id: Option, - pub external_metadata_id: Option, - pub manual_title: Option, - pub manual_release_year: Option, - pub manual_director: Option, -} - -pub struct LogReviewCommand { - pub user_id: Uuid, - pub input: MovieInput, - pub rating: u8, - pub comment: Option, - pub watched_at: NaiveDateTime, -} - -#[derive(Clone)] -pub struct SyncPosterCommand { - pub movie_id: Uuid, -} - -pub struct RegisterCommand { - pub email: String, - pub username: String, - pub password: String, - pub role: UserRole, -} - -pub struct DeleteReviewCommand { - pub review_id: Uuid, - pub requesting_user_id: Uuid, -} - -// FileFormat is now in domain::models — no longer defined here - -pub struct CreateImportSessionCommand { - pub user_id: Uuid, - pub bytes: Vec, - pub format: FileFormat, -} - -pub struct ApplyImportMappingCommand { - pub user_id: Uuid, - pub session_id: Uuid, - pub mappings: Vec, -} - -pub struct ExecuteImportCommand { - pub user_id: Uuid, - pub session_id: Uuid, - pub confirmed_indices: Vec, -} - -pub struct SaveImportProfileCommand { - pub user_id: Uuid, - pub session_id: Uuid, - pub name: String, -} - -pub struct ApplyImportProfileCommand { - pub user_id: Uuid, - pub session_id: Uuid, - pub profile_id: Uuid, -} - -pub struct DeleteImportProfileCommand { - pub user_id: Uuid, - pub profile_id: Uuid, -} - -// ── Media server integration ────────────────────────────────────────────────── - -pub struct IngestWatchEventCommand { - pub token: String, - pub raw_payload: Vec, - pub source: domain::models::WatchEventSource, -} - -pub struct WatchEventConfirmation { - pub watch_event_id: Uuid, - pub rating: u8, - pub comment: Option, -} - -pub struct ConfirmWatchEventsCommand { - pub user_id: Uuid, - pub confirmations: Vec, -} - -pub struct DismissWatchEventsCommand { - pub user_id: Uuid, - pub event_ids: Vec, -} - -pub struct GenerateWebhookTokenCommand { - pub user_id: Uuid, - pub provider: domain::models::WatchEventSource, - pub label: Option, -} - -pub struct RevokeWebhookTokenCommand { - pub user_id: Uuid, - pub token_id: Uuid, -} - -pub struct UpdateProfileCommand { - pub user_id: Uuid, - pub display_name: Option, - pub bio: Option, - pub avatar_bytes: Option>, - pub avatar_content_type: Option, - pub banner_bytes: Option>, - pub banner_content_type: Option, - pub also_known_as: Option, -} - -pub struct UpdateProfileFieldsCommand { - pub user_id: Uuid, - pub fields: Vec, -} - -pub struct EnrichMovieCommand { - pub movie_id: domain::value_objects::MovieId, - pub profile: domain::models::MovieProfile, -} - -pub struct AddToWatchlistCommand { - pub user_id: Uuid, - pub input: MovieInput, -} - -pub struct RemoveFromWatchlistCommand { - pub user_id: Uuid, - pub movie_id: Uuid, -} - -pub struct RegisterAndLoginCommand { - pub email: String, - pub username: String, - pub password: String, -} diff --git a/crates/application/src/diary/commands.rs b/crates/application/src/diary/commands.rs new file mode 100644 index 0000000..0d4fb25 --- /dev/null +++ b/crates/application/src/diary/commands.rs @@ -0,0 +1,28 @@ +use chrono::NaiveDateTime; +use uuid::Uuid; + +pub struct MovieInput { + pub movie_id: Option, + pub external_metadata_id: Option, + pub manual_title: Option, + pub manual_release_year: Option, + pub manual_director: Option, +} + +pub struct LogReviewCommand { + pub user_id: Uuid, + pub input: MovieInput, + pub rating: u8, + pub comment: Option, + pub watched_at: NaiveDateTime, +} + +pub struct DeleteReviewCommand { + pub review_id: Uuid, + pub requesting_user_id: Uuid, +} + +#[derive(Clone)] +pub struct SyncPosterCommand { + pub movie_id: Uuid, +} diff --git a/crates/application/src/diary/delete_review.rs b/crates/application/src/diary/delete_review.rs new file mode 100644 index 0000000..cdc0d1f --- /dev/null +++ b/crates/application/src/diary/delete_review.rs @@ -0,0 +1,61 @@ +use crate::{context::AppContext, diary::commands::DeleteReviewCommand}; +use domain::{ + errors::DomainError, + events::DomainEvent, + value_objects::{ReviewId, UserId}, +}; + +pub async fn execute(ctx: &AppContext, cmd: DeleteReviewCommand) -> Result<(), DomainError> { + let review_id = ReviewId::from_uuid(cmd.review_id); + let requesting_user_id = UserId::from_uuid(cmd.requesting_user_id); + + let review = ctx + .repos + .review + .get_review_by_id(&review_id) + .await? + .ok_or_else(|| DomainError::NotFound(format!("review {}", cmd.review_id)))?; + + if review.user_id() != &requesting_user_id { + return Err(DomainError::Unauthorized("not your review".into())); + } + + let movie_id = review.movie_id().clone(); + ctx.repos.review.delete_review(&review_id).await?; + + if let Err(e) = ctx + .services + .event_publisher + .publish(&DomainEvent::ReviewDeleted { + review_id: review_id.clone(), + user_id: requesting_user_id.clone(), + }) + .await + { + tracing::warn!("failed to publish ReviewDeleted: {e}"); + } + + let history = ctx.repos.diary.get_review_history(&movie_id).await?; + if history.viewings().is_empty() { + let poster_path = history.movie().poster_path().cloned(); + ctx.repos.movie.delete_movie(&movie_id).await?; + // best-effort: movie is already deleted, so publish failure is non-fatal + if let Err(e) = ctx + .services + .event_publisher + .publish(&DomainEvent::MovieDeleted { + movie_id, + poster_path, + }) + .await + { + tracing::warn!("failed to publish MovieDeleted event: {e}"); + } + } + + Ok(()) +} + +#[cfg(test)] +#[path = "tests/delete_review.rs"] +mod tests; diff --git a/crates/application/src/use_cases/export_diary.rs b/crates/application/src/diary/export_diary.rs similarity index 70% rename from crates/application/src/use_cases/export_diary.rs rename to crates/application/src/diary/export_diary.rs index 927ef8a..02098df 100644 --- a/crates/application/src/use_cases/export_diary.rs +++ b/crates/application/src/diary/export_diary.rs @@ -1,13 +1,15 @@ use domain::{errors::DomainError, value_objects::UserId}; -use crate::{context::AppContext, queries::ExportQuery}; +use crate::{context::AppContext, diary::queries::ExportQuery}; pub async fn execute(ctx: &AppContext, query: ExportQuery) -> Result, DomainError> { let entries = ctx - .diary_repository + .repos + .diary .get_user_history(&UserId::from_uuid(query.user_id)) .await?; - ctx.diary_exporter + ctx.services + .diary_exporter .serialize_entries(&entries, query.format) .await } diff --git a/crates/application/src/use_cases/get_activity_feed.rs b/crates/application/src/diary/get_activity_feed.rs similarity index 93% rename from crates/application/src/use_cases/get_activity_feed.rs rename to crates/application/src/diary/get_activity_feed.rs index 80c2580..e31f5f4 100644 --- a/crates/application/src/use_cases/get_activity_feed.rs +++ b/crates/application/src/diary/get_activity_feed.rs @@ -1,4 +1,4 @@ -use crate::{context::AppContext, queries::GetActivityFeedQuery}; +use crate::{context::AppContext, diary::queries::GetActivityFeedQuery}; use domain::{ errors::DomainError, models::{ @@ -16,7 +16,8 @@ pub async fn execute( let following = build_following_filter(ctx, &query).await; - ctx.diary_repository + ctx.repos + .diary .query_activity_feed_filtered( &page, &query.sort_by, @@ -45,6 +46,7 @@ async fn build_following_filter( None => return None, }; let urls = _ctx + .repos .social_query .get_accepted_following_urls(viewer_id) .await diff --git a/crates/application/src/use_cases/get_diary.rs b/crates/application/src/diary/get_diary.rs similarity index 85% rename from crates/application/src/use_cases/get_diary.rs rename to crates/application/src/diary/get_diary.rs index 1119d20..712c996 100644 --- a/crates/application/src/use_cases/get_diary.rs +++ b/crates/application/src/diary/get_diary.rs @@ -7,7 +7,7 @@ use domain::{ value_objects::{MovieId, UserId}, }; -use crate::{context::AppContext, queries::GetDiaryQuery}; +use crate::{context::AppContext, diary::queries::GetDiaryQuery}; pub async fn execute( ctx: &AppContext, @@ -25,5 +25,5 @@ pub async fn execute( search: None, }; - ctx.diary_repository.query_diary(&filter).await + ctx.repos.diary.query_diary(&filter).await } diff --git a/crates/application/src/use_cases/get_movie_social_page.rs b/crates/application/src/diary/get_movie_social_page.rs similarity index 76% rename from crates/application/src/use_cases/get_movie_social_page.rs rename to crates/application/src/diary/get_movie_social_page.rs index 2df33a6..6105e01 100644 --- a/crates/application/src/use_cases/get_movie_social_page.rs +++ b/crates/application/src/diary/get_movie_social_page.rs @@ -7,7 +7,7 @@ use domain::{ value_objects::MovieId, }; -use crate::{context::AppContext, queries::GetMovieSocialPageQuery}; +use crate::{context::AppContext, diary::queries::GetMovieSocialPageQuery}; pub struct MovieSocialPageResult { pub movie: Movie, @@ -24,15 +24,16 @@ pub async fn execute( let page = PageParams::new(Some(query.limit), Some(query.offset))?; let movie = ctx - .movie_repository + .repos + .movie .get_movie_by_id(&movie_id) .await? .ok_or_else(|| DomainError::NotFound(format!("Movie {}", query.movie_id)))?; let (stats, reviews, profile) = tokio::try_join!( - ctx.diary_repository.get_movie_stats(&movie_id), - ctx.diary_repository.get_movie_social_feed(&movie_id, &page), - ctx.movie_profile_repository.get_by_movie_id(&movie_id), + ctx.repos.diary.get_movie_stats(&movie_id), + ctx.repos.diary.get_movie_social_feed(&movie_id, &page), + ctx.repos.movie_profile.get_by_movie_id(&movie_id), )?; Ok(MovieSocialPageResult { diff --git a/crates/application/src/use_cases/get_review_history.rs b/crates/application/src/diary/get_review_history.rs similarity index 77% rename from crates/application/src/use_cases/get_review_history.rs rename to crates/application/src/diary/get_review_history.rs index 617e16d..aa2c7ed 100644 --- a/crates/application/src/use_cases/get_review_history.rs +++ b/crates/application/src/diary/get_review_history.rs @@ -5,7 +5,7 @@ use domain::{ value_objects::MovieId, }; -use crate::{context::AppContext, queries::GetReviewHistoryQuery}; +use crate::{context::AppContext, diary::queries::GetReviewHistoryQuery}; pub async fn execute( ctx: &AppContext, @@ -13,7 +13,7 @@ pub async fn execute( ) -> Result<(ReviewHistory, Trend), DomainError> { let movie_id = MovieId::from_uuid(query.movie_id); - let mut history = ctx.diary_repository.get_review_history(&movie_id).await?; + let mut history = ctx.repos.diary.get_review_history(&movie_id).await?; let trend = ReviewHistoryAnalyzer::rating_trend(&history)?; diff --git a/crates/application/src/diary/log_review.rs b/crates/application/src/diary/log_review.rs new file mode 100644 index 0000000..d7a505c --- /dev/null +++ b/crates/application/src/diary/log_review.rs @@ -0,0 +1,98 @@ +use domain::{ + errors::DomainError, + events::DomainEvent, + models::{Movie, Review}, + value_objects::{Comment, MovieId, Rating, UserId}, +}; + +use crate::{ + context::AppContext, + diary::commands::LogReviewCommand, + diary::movie_resolver::{MovieResolver, MovieResolverDeps}, +}; + +pub async fn execute(ctx: &AppContext, cmd: LogReviewCommand) -> Result<(), DomainError> { + let rating = Rating::new(cmd.rating)?; + let user_id = UserId::from_uuid(cmd.user_id); + let comment = cmd.comment.clone().map(Comment::new).transpose()?; + + let (movie, is_new_movie) = if let Some(id) = cmd.input.movie_id { + let movie_id = MovieId::from_uuid(id); + let movie = ctx + .repos + .movie + .get_movie_by_id(&movie_id) + .await? + .ok_or_else(|| DomainError::NotFound(format!("Movie {id}")))?; + (movie, false) + } else { + let deps = MovieResolverDeps { + repository: ctx.repos.movie.as_ref(), + metadata_client: ctx.services.metadata.as_ref(), + }; + MovieResolver::default_pipeline() + .resolve(&cmd.input, &deps) + .await? + }; + + ctx.repos.movie.upsert_movie(&movie).await?; + + let review = Review::new(movie.id().clone(), user_id, rating, comment, cmd.watched_at)?; + let review_event = ctx.repos.review.save_review(&review).await?; + + let was_on_watchlist = ctx + .repos + .watchlist + .remove_if_present(review.user_id(), review.movie_id()) + .await?; + if was_on_watchlist { + let _ = ctx + .services + .event_publisher + .publish(&DomainEvent::WatchlistEntryRemoved { + user_id: review.user_id().clone(), + movie_id: review.movie_id().clone(), + }) + .await; + } + + publish_events(ctx, &movie, is_new_movie, review_event).await?; + + Ok(()) +} + +#[cfg(test)] +#[path = "tests/log_review.rs"] +mod tests; + +async fn publish_events( + ctx: &AppContext, + movie: &Movie, + is_new_movie: bool, + review_event: DomainEvent, +) -> Result<(), DomainError> { + if is_new_movie && let Some(ext_id) = movie.external_metadata_id() { + let discovery_event = DomainEvent::MovieDiscovered { + movie_id: movie.id().clone(), + external_metadata_id: ext_id.clone(), + }; + ctx.services + .event_publisher + .publish(&discovery_event) + .await?; + } + + if let Some(ext_id) = movie.external_metadata_id() { + let enrichment_event = DomainEvent::MovieEnrichmentRequested { + movie_id: movie.id().clone(), + external_metadata_id: ext_id.value().to_string(), + }; + ctx.services + .event_publisher + .publish(&enrichment_event) + .await?; + } + + ctx.services.event_publisher.publish(&review_event).await?; + Ok(()) +} diff --git a/crates/application/src/diary/mod.rs b/crates/application/src/diary/mod.rs new file mode 100644 index 0000000..2a46996 --- /dev/null +++ b/crates/application/src/diary/mod.rs @@ -0,0 +1,10 @@ +pub mod commands; +pub mod delete_review; +pub mod export_diary; +pub mod get_activity_feed; +pub mod get_diary; +pub mod get_movie_social_page; +pub mod get_review_history; +pub mod log_review; +pub mod movie_resolver; +pub mod queries; diff --git a/crates/application/src/movie_resolver.rs b/crates/application/src/diary/movie_resolver.rs similarity index 99% rename from crates/application/src/movie_resolver.rs rename to crates/application/src/diary/movie_resolver.rs index 733514a..20e5ad6 100644 --- a/crates/application/src/movie_resolver.rs +++ b/crates/application/src/diary/movie_resolver.rs @@ -6,7 +6,7 @@ use domain::{ value_objects::{ExternalMetadataId, MovieTitle, ReleaseYear}, }; -use crate::commands::MovieInput; +use crate::diary::commands::MovieInput; pub struct MovieResolverDeps<'a> { pub repository: &'a dyn MovieRepository, diff --git a/crates/application/src/diary/queries.rs b/crates/application/src/diary/queries.rs new file mode 100644 index 0000000..c6f36b5 --- /dev/null +++ b/crates/application/src/diary/queries.rs @@ -0,0 +1,34 @@ +use domain::models::SortDirection; +use uuid::Uuid; + +pub struct GetDiaryQuery { + pub limit: Option, + pub offset: Option, + pub sort_by: Option, + pub movie_id: Option, + pub user_id: Option, +} + +pub struct GetReviewHistoryQuery { + pub movie_id: Uuid, +} + +pub struct GetActivityFeedQuery { + pub limit: u32, + pub offset: u32, + pub sort_by: domain::ports::FeedSortBy, + pub search: Option, + pub viewer_user_id: Option, + pub filter_following: bool, +} + +pub struct ExportQuery { + pub user_id: Uuid, + pub format: domain::models::ExportFormat, +} + +pub struct GetMovieSocialPageQuery { + pub movie_id: uuid::Uuid, + pub limit: u32, + pub offset: u32, +} diff --git a/crates/application/src/diary/tests/delete_review.rs b/crates/application/src/diary/tests/delete_review.rs new file mode 100644 index 0000000..7b00439 --- /dev/null +++ b/crates/application/src/diary/tests/delete_review.rs @@ -0,0 +1,104 @@ +use std::sync::Arc; + +use chrono::Utc; + +use domain::{ + models::{Movie, Review}, + ports::{MovieRepository, ReviewRepository}, + testing::{ + FakeDiaryRepository, InMemoryMovieRepository, InMemoryReviewRepository, NoopEventPublisher, + }, + value_objects::{MovieId, MovieTitle, Rating, ReleaseYear, UserId}, +}; + +use crate::{ + diary::commands::DeleteReviewCommand, diary::delete_review, test_helpers::TestContextBuilder, +}; + +fn make_movie() -> Movie { + Movie::new( + None, + MovieTitle::new("Terminator".into()).unwrap(), + ReleaseYear::new(1984).unwrap(), + None, + None, + ) +} + +fn make_review(movie_id: MovieId, user_id: UserId) -> Review { + Review::new( + movie_id, + user_id, + Rating::new(4).unwrap(), + None, + Utc::now().naive_utc(), + ) + .unwrap() +} + +#[tokio::test] +async fn test_delete_review_removes_it() { + let movies = InMemoryMovieRepository::new(); + let reviews = InMemoryReviewRepository::new(); + let diary = FakeDiaryRepository::new(); + let events = NoopEventPublisher::new(); + + let movie = make_movie(); + let user_id = UserId::from_uuid(uuid::Uuid::new_v4()); + let review = make_review(movie.id().clone(), user_id.clone()); + + movies.upsert_movie(&movie).await.unwrap(); + reviews.save_review(&review).await.unwrap(); + diary.seed_history(movie.clone(), vec![]); + + let ctx = TestContextBuilder::new() + .with_movies(Arc::clone(&movies) as _) + .with_reviews(Arc::clone(&reviews) as _) + .with_diary(Arc::clone(&diary) as _) + .with_event_publisher(Arc::clone(&events) as _) + .build(); + + delete_review::execute( + &ctx, + DeleteReviewCommand { + review_id: review.id().value(), + requesting_user_id: user_id.value(), + }, + ) + .await + .unwrap(); + + assert_eq!(reviews.count(), 0, "review should be deleted"); + assert!( + movies.get_movie_by_id(movie.id()).await.unwrap().is_none(), + "movie should be deleted when no reviews remain" + ); +} + +#[tokio::test] +async fn test_delete_review_wrong_user_is_unauthorized() { + let reviews = InMemoryReviewRepository::new(); + + let movie_id = MovieId::from_uuid(uuid::Uuid::new_v4()); + let owner_id = UserId::from_uuid(uuid::Uuid::new_v4()); + let other_id = uuid::Uuid::new_v4(); + let review = make_review(movie_id, owner_id); + + reviews.save_review(&review).await.unwrap(); + + let ctx = TestContextBuilder::new() + .with_reviews(Arc::clone(&reviews) as _) + .build(); + + let result = delete_review::execute( + &ctx, + DeleteReviewCommand { + review_id: review.id().value(), + requesting_user_id: other_id, + }, + ) + .await; + + assert!(result.is_err(), "wrong user should not be able to delete"); + assert_eq!(reviews.count(), 1, "review should still exist"); +} diff --git a/crates/application/src/diary/tests/log_review.rs b/crates/application/src/diary/tests/log_review.rs new file mode 100644 index 0000000..b509cb2 --- /dev/null +++ b/crates/application/src/diary/tests/log_review.rs @@ -0,0 +1,111 @@ +use std::sync::Arc; + +use chrono::Utc; + +use domain::{ + models::Movie, + value_objects::{MovieTitle, ReleaseYear}, +}; + +use domain::ports::MovieRepository; +use domain::testing::{InMemoryMovieRepository, InMemoryReviewRepository, NoopEventPublisher}; + +use crate::{ + diary::commands::{LogReviewCommand, MovieInput}, + diary::log_review, + test_helpers::TestContextBuilder, +}; + +fn movie_input_manual(title: &str, year: u16) -> MovieInput { + MovieInput { + movie_id: None, + external_metadata_id: None, + manual_title: Some(title.to_string()), + manual_release_year: Some(year), + manual_director: None, + } +} + +fn movie_input_by_id(id: uuid::Uuid) -> MovieInput { + MovieInput { + movie_id: Some(id), + external_metadata_id: None, + manual_title: None, + manual_release_year: None, + manual_director: None, + } +} + +#[tokio::test] +async fn test_log_review_creates_movie_and_review() { + let movies = InMemoryMovieRepository::new(); + let reviews = InMemoryReviewRepository::new(); + let events = NoopEventPublisher::new(); + let ctx = TestContextBuilder::new() + .with_movies(Arc::clone(&movies) as _) + .with_reviews(Arc::clone(&reviews) as _) + .with_event_publisher(Arc::clone(&events) as _) + .build(); + + let user_id = uuid::Uuid::new_v4(); + let cmd = LogReviewCommand { + user_id, + input: movie_input_manual("Blade Runner", 1982), + rating: 4, + comment: None, + watched_at: Utc::now().naive_utc(), + }; + + log_review::execute(&ctx, cmd).await.unwrap(); + + assert_eq!(reviews.count(), 1, "review should be saved"); + assert!(!events.published().is_empty(), "events should be published"); +} + +#[tokio::test] +async fn test_log_review_reuses_existing_movie() { + let movies = InMemoryMovieRepository::new(); + let reviews = InMemoryReviewRepository::new(); + + let existing_movie = Movie::new( + None, + MovieTitle::new("Alien".into()).unwrap(), + ReleaseYear::new(1979).unwrap(), + None, + None, + ); + let movie_uuid = existing_movie.id().value(); + movies.upsert_movie(&existing_movie).await.unwrap(); + + let ctx = TestContextBuilder::new() + .with_movies(Arc::clone(&movies) as _) + .with_reviews(Arc::clone(&reviews) as _) + .build(); + + let cmd = LogReviewCommand { + user_id: uuid::Uuid::new_v4(), + input: movie_input_by_id(movie_uuid), + rating: 5, + comment: None, + watched_at: Utc::now().naive_utc(), + }; + + log_review::execute(&ctx, cmd).await.unwrap(); + + assert_eq!(movies.count(), 1, "no duplicate movie"); + assert_eq!(reviews.count(), 1); +} + +#[tokio::test] +async fn test_log_review_with_invalid_rating_fails() { + let ctx = TestContextBuilder::new().build(); + let cmd = LogReviewCommand { + user_id: uuid::Uuid::new_v4(), + input: movie_input_manual("Some Film", 2000), + rating: 6, + comment: None, + watched_at: Utc::now().naive_utc(), + }; + let result = log_review::execute(&ctx, cmd).await; + assert!(result.is_err(), "rating > 5 should fail"); +} diff --git a/crates/application/src/tests/movie_resolver.rs b/crates/application/src/diary/tests/movie_resolver.rs similarity index 99% rename from crates/application/src/tests/movie_resolver.rs rename to crates/application/src/diary/tests/movie_resolver.rs index bb676f8..eb576a3 100644 --- a/crates/application/src/tests/movie_resolver.rs +++ b/crates/application/src/diary/tests/movie_resolver.rs @@ -1,5 +1,5 @@ use super::*; -use crate::commands::MovieInput; +use crate::diary::commands::MovieInput; use domain::{ errors::DomainError, models::Movie, diff --git a/crates/application/src/use_cases/get_remote_watchlist.rs b/crates/application/src/federation/get_remote_watchlist.rs similarity index 71% rename from crates/application/src/use_cases/get_remote_watchlist.rs rename to crates/application/src/federation/get_remote_watchlist.rs index a3c8685..4e2f097 100644 --- a/crates/application/src/use_cases/get_remote_watchlist.rs +++ b/crates/application/src/federation/get_remote_watchlist.rs @@ -6,7 +6,5 @@ pub async fn execute( ctx: &AppContext, uuid: uuid::Uuid, ) -> Result, DomainError> { - ctx.remote_watchlist_repository - .get_by_derived_uuid(uuid) - .await + ctx.repos.remote_watchlist.get_by_derived_uuid(uuid).await } diff --git a/crates/application/src/federation/mod.rs b/crates/application/src/federation/mod.rs new file mode 100644 index 0000000..c53ea6f --- /dev/null +++ b/crates/application/src/federation/mod.rs @@ -0,0 +1 @@ +pub mod get_remote_watchlist; diff --git a/crates/application/src/use_cases/apply_import_mapping.rs b/crates/application/src/import/apply_mapping.rs similarity index 82% rename from crates/application/src/use_cases/apply_import_mapping.rs rename to crates/application/src/import/apply_mapping.rs index d38e9a1..0972c5a 100644 --- a/crates/application/src/use_cases/apply_import_mapping.rs +++ b/crates/application/src/import/apply_mapping.rs @@ -4,7 +4,7 @@ use domain::{ value_objects::{ExternalMetadataId, ImportSessionId, MovieTitle, ReleaseYear, UserId}, }; -use crate::{commands::ApplyImportMappingCommand, context::AppContext}; +use crate::{context::AppContext, import::commands::ApplyImportMappingCommand}; pub async fn execute( ctx: &AppContext, @@ -14,7 +14,8 @@ pub async fn execute( let session_id = ImportSessionId::from_uuid(cmd.session_id); let mappings = cmd.mappings; let mut session = ctx - .import_session_repository + .repos + .import_session .get(&session_id, &user_id) .await? .ok_or_else(|| DomainError::NotFound("import session".into()))?; @@ -25,7 +26,10 @@ pub async fn execute( .clone() .ok_or_else(|| DomainError::ValidationError("session has no parsed file".into()))?; - let mut annotated = ctx.document_parser.apply_mapping(&parsed, &mappings); + let mut annotated = ctx + .services + .document_parser + .apply_mapping(&parsed, &mappings); for row in annotated.iter_mut() { if let RowResult::Valid(ref import_row) = row.result { @@ -36,7 +40,7 @@ pub async fn execute( session.field_mappings = Some(mappings); session.row_results = Some(annotated.clone()); - ctx.import_session_repository.update(&session).await?; + ctx.repos.import_session.update(&session).await?; Ok(annotated) } @@ -48,7 +52,8 @@ async fn check_duplicate( if let Some(ext_id) = &row.external_metadata_id && let Ok(eid) = ExternalMetadataId::new(ext_id.clone()) && ctx - .movie_repository + .repos + .movie .get_movie_by_external_id(&eid) .await? .is_some() @@ -62,10 +67,7 @@ async fn check_duplicate( .ok() .and_then(|y| ReleaseYear::new(y).ok()); if let (Ok(t), Some(y)) = (title_vo, year_vo) { - let matches = ctx - .movie_repository - .get_movies_by_title_and_year(&t, &y) - .await?; + let matches = ctx.repos.movie.get_movies_by_title_and_year(&t, &y).await?; if !matches.is_empty() { return Ok(true); } diff --git a/crates/application/src/use_cases/apply_import_profile.rs b/crates/application/src/import/apply_profile.rs similarity index 81% rename from crates/application/src/use_cases/apply_import_profile.rs rename to crates/application/src/import/apply_profile.rs index 787bf5c..7fa7f26 100644 --- a/crates/application/src/use_cases/apply_import_profile.rs +++ b/crates/application/src/import/apply_profile.rs @@ -1,4 +1,4 @@ -use crate::{commands::ApplyImportProfileCommand, context::AppContext}; +use crate::{context::AppContext, import::commands::ApplyImportProfileCommand}; use domain::{ errors::DomainError, value_objects::{ImportProfileId, ImportSessionId, UserId}, @@ -12,16 +12,18 @@ pub async fn execute(ctx: &AppContext, cmd: ApplyImportProfileCommand) -> Result let profile_id = ImportProfileId::from_uuid(cmd.profile_id); let profile = ctx - .import_profile_repository + .repos + .import_profile .get(&profile_id, &user_id) .await? .ok_or_else(|| DomainError::NotFound("import profile".into()))?; let mut session = ctx - .import_session_repository + .repos + .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; - ctx.import_session_repository.update(&session).await + ctx.repos.import_session.update(&session).await } diff --git a/crates/application/src/use_cases/cleanup_expired_import_sessions.rs b/crates/application/src/import/cleanup.rs similarity index 70% rename from crates/application/src/use_cases/cleanup_expired_import_sessions.rs rename to crates/application/src/import/cleanup.rs index 2610ef5..f3cc726 100644 --- a/crates/application/src/use_cases/cleanup_expired_import_sessions.rs +++ b/crates/application/src/import/cleanup.rs @@ -2,5 +2,5 @@ use crate::context::AppContext; use domain::errors::DomainError; pub async fn execute(ctx: &AppContext) -> Result { - ctx.import_session_repository.delete_expired().await + ctx.repos.import_session.delete_expired().await } diff --git a/crates/application/src/import/commands.rs b/crates/application/src/import/commands.rs new file mode 100644 index 0000000..9f49700 --- /dev/null +++ b/crates/application/src/import/commands.rs @@ -0,0 +1,37 @@ +use domain::models::{FieldMapping, FileFormat}; +use uuid::Uuid; + +pub struct CreateImportSessionCommand { + pub user_id: Uuid, + pub bytes: Vec, + pub format: FileFormat, +} + +pub struct ApplyImportMappingCommand { + pub user_id: Uuid, + pub session_id: Uuid, + pub mappings: Vec, +} + +pub struct ExecuteImportCommand { + pub user_id: Uuid, + pub session_id: Uuid, + pub confirmed_indices: Vec, +} + +pub struct SaveImportProfileCommand { + pub user_id: Uuid, + pub session_id: Uuid, + pub name: String, +} + +pub struct ApplyImportProfileCommand { + pub user_id: Uuid, + pub session_id: Uuid, + pub profile_id: Uuid, +} + +pub struct DeleteImportProfileCommand { + pub user_id: Uuid, + pub profile_id: Uuid, +} diff --git a/crates/application/src/use_cases/create_import_session.rs b/crates/application/src/import/create_session.rs similarity index 85% rename from crates/application/src/use_cases/create_import_session.rs rename to crates/application/src/import/create_session.rs index b295a26..0b0e033 100644 --- a/crates/application/src/use_cases/create_import_session.rs +++ b/crates/application/src/import/create_session.rs @@ -5,7 +5,7 @@ use domain::{ value_objects::{ImportSessionId, UserId}, }; -use crate::{commands::CreateImportSessionCommand, context::AppContext}; +use crate::{context::AppContext, import::commands::CreateImportSessionCommand}; pub struct CreateSessionResult { pub session_id: ImportSessionId, @@ -18,11 +18,13 @@ pub async fn execute( cmd: CreateImportSessionCommand, ) -> Result { let user_id = UserId::from_uuid(cmd.user_id); - ctx.import_session_repository + ctx.repos + .import_session .delete_expired_for_user(&user_id) .await?; let parsed = ctx + .services .document_parser .parse(&cmd.bytes, cmd.format) .map_err(|e| DomainError::ValidationError(e.to_string()))?; @@ -35,7 +37,7 @@ pub async fn execute( let session_id = session.id.clone(); session.parsed_file = Some(parsed); - ctx.import_session_repository.create(&session).await?; + ctx.repos.import_session.create(&session).await?; Ok(CreateSessionResult { session_id, diff --git a/crates/application/src/use_cases/delete_import_profile.rs b/crates/application/src/import/delete_profile.rs similarity index 71% rename from crates/application/src/use_cases/delete_import_profile.rs rename to crates/application/src/import/delete_profile.rs index bf3c7df..edf3bb6 100644 --- a/crates/application/src/use_cases/delete_import_profile.rs +++ b/crates/application/src/import/delete_profile.rs @@ -1,4 +1,4 @@ -use crate::{commands::DeleteImportProfileCommand, context::AppContext}; +use crate::{context::AppContext, import::commands::DeleteImportProfileCommand}; use domain::{ errors::DomainError, value_objects::{ImportProfileId, UserId}, @@ -8,9 +8,10 @@ pub async fn execute(ctx: &AppContext, cmd: DeleteImportProfileCommand) -> Resul let user_id = UserId::from_uuid(cmd.user_id); let profile_id = ImportProfileId::from_uuid(cmd.profile_id); - ctx.import_profile_repository + ctx.repos + .import_profile .get(&profile_id, &user_id) .await? .ok_or_else(|| DomainError::NotFound("import profile".into()))?; - ctx.import_profile_repository.delete(&profile_id).await + ctx.repos.import_profile.delete(&profile_id).await } diff --git a/crates/application/src/use_cases/execute_import.rs b/crates/application/src/import/execute.rs similarity index 93% rename from crates/application/src/use_cases/execute_import.rs rename to crates/application/src/import/execute.rs index 0f83068..dc79657 100644 --- a/crates/application/src/use_cases/execute_import.rs +++ b/crates/application/src/import/execute.rs @@ -7,9 +7,10 @@ use domain::{ use uuid::Uuid; use crate::{ - commands::{ExecuteImportCommand, LogReviewCommand, MovieInput}, context::AppContext, - use_cases::log_review, + diary::commands::{LogReviewCommand, MovieInput}, + diary::log_review, + import::commands::ExecuteImportCommand, }; pub struct ImportSummary { @@ -26,7 +27,8 @@ pub async fn execute( let session_id = ImportSessionId::from_uuid(cmd.session_id); let confirmed_indices = cmd.confirmed_indices; let session = ctx - .import_session_repository + .repos + .import_session .get(&session_id, &user_id) .await? .ok_or_else(|| DomainError::NotFound("import session".into()))?; @@ -57,7 +59,7 @@ pub async fn execute( } } - ctx.import_session_repository.delete(&session_id).await?; + ctx.repos.import_session.delete(&session_id).await?; Ok(ImportSummary { imported, diff --git a/crates/application/src/use_cases/list_import_profiles.rs b/crates/application/src/import/list_profiles.rs similarity index 78% rename from crates/application/src/use_cases/list_import_profiles.rs rename to crates/application/src/import/list_profiles.rs index 89282af..72af997 100644 --- a/crates/application/src/use_cases/list_import_profiles.rs +++ b/crates/application/src/import/list_profiles.rs @@ -5,5 +5,5 @@ pub async fn execute( ctx: &AppContext, user_id: &UserId, ) -> Result, DomainError> { - ctx.import_profile_repository.list_for_user(user_id).await + ctx.repos.import_profile.list_for_user(user_id).await } diff --git a/crates/application/src/import/mod.rs b/crates/application/src/import/mod.rs new file mode 100644 index 0000000..3de9ad9 --- /dev/null +++ b/crates/application/src/import/mod.rs @@ -0,0 +1,9 @@ +pub mod apply_mapping; +pub mod apply_profile; +pub mod cleanup; +pub mod commands; +pub mod create_session; +pub mod delete_profile; +pub mod execute; +pub mod list_profiles; +pub mod save_profile; diff --git a/crates/application/src/use_cases/save_import_profile.rs b/crates/application/src/import/save_profile.rs similarity index 84% rename from crates/application/src/use_cases/save_import_profile.rs rename to crates/application/src/import/save_profile.rs index 0a1a1a5..9e1a790 100644 --- a/crates/application/src/use_cases/save_import_profile.rs +++ b/crates/application/src/import/save_profile.rs @@ -1,4 +1,4 @@ -use crate::{commands::SaveImportProfileCommand, context::AppContext}; +use crate::{context::AppContext, import::commands::SaveImportProfileCommand}; use chrono::Utc; use domain::{ errors::DomainError, @@ -14,7 +14,8 @@ pub async fn execute( let session_id = ImportSessionId::from_uuid(cmd.session_id); let session = ctx - .import_session_repository + .repos + .import_session .get(&session_id, &user_id) .await? .ok_or_else(|| DomainError::NotFound("import session".into()))?; @@ -29,6 +30,6 @@ pub async fn execute( Utc::now().naive_utc(), ); let id = profile.id.clone(); - ctx.import_profile_repository.save(&profile).await?; + ctx.repos.import_profile.save(&profile).await?; Ok(id) } diff --git a/crates/application/src/use_cases/cleanup_watch_events.rs b/crates/application/src/integrations/cleanup.rs similarity index 89% rename from crates/application/src/use_cases/cleanup_watch_events.rs rename to crates/application/src/integrations/cleanup.rs index 62d73b7..a31ea12 100644 --- a/crates/application/src/use_cases/cleanup_watch_events.rs +++ b/crates/application/src/integrations/cleanup.rs @@ -5,7 +5,8 @@ use crate::context::AppContext; pub async fn execute(ctx: &AppContext) -> Result { let cutoff = chrono::Utc::now().naive_utc() - Duration::days(30); - ctx.watch_event_repository + ctx.repos + .watch_event .delete_non_pending_older_than(cutoff) .await } diff --git a/crates/application/src/integrations/commands.rs b/crates/application/src/integrations/commands.rs new file mode 100644 index 0000000..f91f635 --- /dev/null +++ b/crates/application/src/integrations/commands.rs @@ -0,0 +1,34 @@ +use uuid::Uuid; + +pub struct IngestWatchEventCommand { + pub token: String, + pub raw_payload: Vec, + pub source: domain::models::WatchEventSource, +} + +pub struct WatchEventConfirmation { + pub watch_event_id: Uuid, + pub rating: u8, + pub comment: Option, +} + +pub struct ConfirmWatchEventsCommand { + pub user_id: Uuid, + pub confirmations: Vec, +} + +pub struct DismissWatchEventsCommand { + pub user_id: Uuid, + pub event_ids: Vec, +} + +pub struct GenerateWebhookTokenCommand { + pub user_id: Uuid, + pub provider: domain::models::WatchEventSource, + pub label: Option, +} + +pub struct RevokeWebhookTokenCommand { + pub user_id: Uuid, + pub token_id: Uuid, +} diff --git a/crates/application/src/use_cases/confirm_watch_events.rs b/crates/application/src/integrations/confirm.rs similarity index 89% rename from crates/application/src/use_cases/confirm_watch_events.rs rename to crates/application/src/integrations/confirm.rs index a28b7c7..3be4a0f 100644 --- a/crates/application/src/use_cases/confirm_watch_events.rs +++ b/crates/application/src/integrations/confirm.rs @@ -5,9 +5,10 @@ use domain::{ }; use crate::{ - commands::{ConfirmWatchEventsCommand, LogReviewCommand, MovieInput}, context::AppContext, - use_cases::log_review, + diary::commands::{LogReviewCommand, MovieInput}, + diary::log_review, + integrations::commands::ConfirmWatchEventsCommand, }; pub async fn execute(ctx: &AppContext, cmd: ConfirmWatchEventsCommand) -> Result { @@ -17,7 +18,8 @@ pub async fn execute(ctx: &AppContext, cmd: ConfirmWatchEventsCommand) -> Result for c in cmd.confirmations { let event_id = WatchEventId::from_uuid(c.watch_event_id); let event = ctx - .watch_event_repository + .repos + .watch_event .get_by_id(&event_id) .await? .ok_or_else(|| DomainError::NotFound(format!("WatchEvent {}", c.watch_event_id)))?; @@ -54,7 +56,8 @@ pub async fn execute(ctx: &AppContext, cmd: ConfirmWatchEventsCommand) -> Result log_review::execute(ctx, review_cmd).await?; - ctx.watch_event_repository + ctx.repos + .watch_event .update_status(&event_id, WatchEventStatus::Confirmed) .await?; diff --git a/crates/application/src/use_cases/dismiss_watch_events.rs b/crates/application/src/integrations/dismiss.rs similarity index 82% rename from crates/application/src/use_cases/dismiss_watch_events.rs rename to crates/application/src/integrations/dismiss.rs index faa7e3f..f587d84 100644 --- a/crates/application/src/use_cases/dismiss_watch_events.rs +++ b/crates/application/src/integrations/dismiss.rs @@ -4,7 +4,7 @@ use domain::{ value_objects::{UserId, WatchEventId}, }; -use crate::{commands::DismissWatchEventsCommand, context::AppContext}; +use crate::{context::AppContext, integrations::commands::DismissWatchEventsCommand}; pub async fn execute(ctx: &AppContext, cmd: DismissWatchEventsCommand) -> Result { let user_id = UserId::from_uuid(cmd.user_id); @@ -13,7 +13,8 @@ pub async fn execute(ctx: &AppContext, cmd: DismissWatchEventsCommand) -> Result for id in cmd.event_ids { let event_id = WatchEventId::from_uuid(id); let event = ctx - .watch_event_repository + .repos + .watch_event .get_by_id(&event_id) .await? .ok_or_else(|| DomainError::NotFound(format!("WatchEvent {id}")))?; @@ -22,7 +23,8 @@ pub async fn execute(ctx: &AppContext, cmd: DismissWatchEventsCommand) -> Result return Err(DomainError::Unauthorized("not your watch event".into())); } - ctx.watch_event_repository + ctx.repos + .watch_event .update_status(&event_id, WatchEventStatus::Dismissed) .await?; diff --git a/crates/application/src/use_cases/generate_webhook_token.rs b/crates/application/src/integrations/generate_token.rs similarity index 87% rename from crates/application/src/use_cases/generate_webhook_token.rs rename to crates/application/src/integrations/generate_token.rs index 5fd9b4e..393ec9e 100644 --- a/crates/application/src/use_cases/generate_webhook_token.rs +++ b/crates/application/src/integrations/generate_token.rs @@ -1,7 +1,7 @@ use domain::{errors::DomainError, models::WebhookToken, value_objects::UserId}; use sha2::{Digest, Sha256}; -use crate::{commands::GenerateWebhookTokenCommand, context::AppContext}; +use crate::{context::AppContext, integrations::commands::GenerateWebhookTokenCommand}; pub struct GeneratedWebhookToken { pub token_plaintext: String, @@ -18,7 +18,7 @@ pub async fn execute( let user_id = UserId::from_uuid(cmd.user_id); let token = WebhookToken::new(user_id, hash, cmd.provider, cmd.label); - ctx.webhook_token_repository.save(&token).await?; + ctx.repos.webhook_token.save(&token).await?; Ok(GeneratedWebhookToken { token_plaintext: plaintext, diff --git a/crates/application/src/use_cases/get_watch_queue.rs b/crates/application/src/integrations/get_queue.rs similarity index 65% rename from crates/application/src/use_cases/get_watch_queue.rs rename to crates/application/src/integrations/get_queue.rs index 89bc040..20a06d8 100644 --- a/crates/application/src/use_cases/get_watch_queue.rs +++ b/crates/application/src/integrations/get_queue.rs @@ -1,11 +1,11 @@ use domain::{errors::DomainError, models::WatchEvent, value_objects::UserId}; -use crate::{context::AppContext, queries::GetWatchQueueQuery}; +use crate::{context::AppContext, integrations::queries::GetWatchQueueQuery}; pub async fn execute( ctx: &AppContext, query: GetWatchQueueQuery, ) -> Result, DomainError> { let user_id = UserId::from_uuid(query.user_id); - ctx.watch_event_repository.list_pending(&user_id).await + ctx.repos.watch_event.list_pending(&user_id).await } diff --git a/crates/application/src/use_cases/get_webhook_tokens.rs b/crates/application/src/integrations/get_tokens.rs similarity index 65% rename from crates/application/src/use_cases/get_webhook_tokens.rs rename to crates/application/src/integrations/get_tokens.rs index f263c4d..3c75260 100644 --- a/crates/application/src/use_cases/get_webhook_tokens.rs +++ b/crates/application/src/integrations/get_tokens.rs @@ -1,11 +1,11 @@ use domain::{errors::DomainError, models::WebhookToken, value_objects::UserId}; -use crate::{context::AppContext, queries::GetWebhookTokensQuery}; +use crate::{context::AppContext, integrations::queries::GetWebhookTokensQuery}; pub async fn execute( ctx: &AppContext, query: GetWebhookTokensQuery, ) -> Result, DomainError> { let user_id = UserId::from_uuid(query.user_id); - ctx.webhook_token_repository.list_by_user(&user_id).await + ctx.repos.webhook_token.list_by_user(&user_id).await } diff --git a/crates/application/src/use_cases/ingest_watch_event.rs b/crates/application/src/integrations/ingest.rs similarity index 82% rename from crates/application/src/use_cases/ingest_watch_event.rs rename to crates/application/src/integrations/ingest.rs index 66c1183..1197011 100644 --- a/crates/application/src/use_cases/ingest_watch_event.rs +++ b/crates/application/src/integrations/ingest.rs @@ -3,24 +3,24 @@ use domain::{ errors::DomainError, events::DomainEvent, models::WatchEvent, ports::MediaServerParser, }; -use crate::{ - commands::IngestWatchEventCommand, context::AppContext, use_cases::generate_webhook_token, -}; +use crate::{context::AppContext, integrations::commands::IngestWatchEventCommand}; pub async fn execute( ctx: &AppContext, cmd: IngestWatchEventCommand, parser: &dyn MediaServerParser, ) -> Result<(), DomainError> { - let token_hash = generate_webhook_token::hash_token(&cmd.token); + let token_hash = super::generate_token::hash_token(&cmd.token); let webhook_token = ctx - .webhook_token_repository + .repos + .webhook_token .find_by_token_hash(&token_hash) .await? .ok_or_else(|| DomainError::Unauthorized("invalid webhook token".into()))?; let _ = ctx - .webhook_token_repository + .repos + .webhook_token .touch_last_used(webhook_token.id()) .await; @@ -35,7 +35,8 @@ 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 ctx - .watch_event_repository + .repos + .watch_event .find_duplicate(&user_id, ext_id, one_hour_ago) .await? { @@ -54,9 +55,10 @@ pub async fn execute( None, ); - ctx.watch_event_repository.save(&event).await?; + ctx.repos.watch_event.save(&event).await?; let _ = ctx + .services .event_publisher .publish(&DomainEvent::WatchEventIngested { user_id: event.user_id().clone(), diff --git a/crates/application/src/integrations/mod.rs b/crates/application/src/integrations/mod.rs new file mode 100644 index 0000000..2dbca51 --- /dev/null +++ b/crates/application/src/integrations/mod.rs @@ -0,0 +1,10 @@ +pub mod cleanup; +pub mod commands; +pub mod confirm; +pub mod dismiss; +pub mod generate_token; +pub mod get_queue; +pub mod get_tokens; +pub mod ingest; +pub mod queries; +pub mod revoke_token; diff --git a/crates/application/src/integrations/queries.rs b/crates/application/src/integrations/queries.rs new file mode 100644 index 0000000..bd2add2 --- /dev/null +++ b/crates/application/src/integrations/queries.rs @@ -0,0 +1,9 @@ +use uuid::Uuid; + +pub struct GetWatchQueueQuery { + pub user_id: Uuid, +} + +pub struct GetWebhookTokensQuery { + pub user_id: Uuid, +} diff --git a/crates/application/src/use_cases/revoke_webhook_token.rs b/crates/application/src/integrations/revoke_token.rs similarity index 65% rename from crates/application/src/use_cases/revoke_webhook_token.rs rename to crates/application/src/integrations/revoke_token.rs index dc288f4..731f459 100644 --- a/crates/application/src/use_cases/revoke_webhook_token.rs +++ b/crates/application/src/integrations/revoke_token.rs @@ -3,12 +3,10 @@ use domain::{ value_objects::{UserId, WebhookTokenId}, }; -use crate::{commands::RevokeWebhookTokenCommand, context::AppContext}; +use crate::{context::AppContext, integrations::commands::RevokeWebhookTokenCommand}; pub async fn execute(ctx: &AppContext, cmd: RevokeWebhookTokenCommand) -> Result<(), DomainError> { let user_id = UserId::from_uuid(cmd.user_id); let token_id = WebhookTokenId::from_uuid(cmd.token_id); - ctx.webhook_token_repository - .delete(&token_id, &user_id) - .await + ctx.repos.webhook_token.delete(&token_id, &user_id).await } diff --git a/crates/application/src/jobs.rs b/crates/application/src/jobs.rs index 0a5d5e2..7eeb90a 100644 --- a/crates/application/src/jobs.rs +++ b/crates/application/src/jobs.rs @@ -22,7 +22,7 @@ impl PeriodicJob for ImportSessionCleanupJob { } async fn run(&self) -> Result<(), DomainError> { - let n = crate::use_cases::cleanup_expired_import_sessions::execute(&self.ctx).await?; + let n = crate::import::cleanup::execute(&self.ctx).await?; tracing::info!("import session cleanup: removed {} expired sessions", n); Ok(()) } @@ -45,7 +45,7 @@ impl PeriodicJob for WatchEventCleanupJob { } async fn run(&self) -> Result<(), DomainError> { - let n = crate::use_cases::cleanup_watch_events::execute(&self.ctx).await?; + let n = crate::integrations::cleanup::execute(&self.ctx).await?; if n > 0 { tracing::info!("watch event cleanup: removed {n} old entries"); } @@ -70,7 +70,7 @@ impl PeriodicJob for EnrichmentStalenessJob { } async fn run(&self) -> Result<(), DomainError> { - let stale = self.ctx.movie_profile_repository.list_stale().await?; + let stale = self.ctx.repos.movie_profile.list_stale().await?; if stale.is_empty() { return Ok(()); } @@ -80,7 +80,7 @@ impl PeriodicJob for EnrichmentStalenessJob { movie_id, external_metadata_id, }; - self.ctx.event_publisher.publish(&event).await?; + self.ctx.services.event_publisher.publish(&event).await?; } Ok(()) } diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 190b061..027a117 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -1,17 +1,23 @@ -pub mod commands; pub mod config; pub mod context; pub mod jobs; -pub mod movie_discovery_indexer; -pub mod movie_resolver; pub mod ports; -pub mod queries; -pub mod search_cleanup; -pub mod use_cases; pub mod worker; +pub mod auth; +pub mod diary; +#[cfg(feature = "federation")] +pub mod federation; +pub mod import; +pub mod integrations; +pub mod movies; +pub mod person; +pub mod search; +pub mod users; +pub mod watchlist; + #[cfg(test)] pub mod test_helpers; -pub use movie_discovery_indexer::MovieDiscoveryIndexer; -pub use search_cleanup::SearchCleanupHandler; +pub use movies::MovieDiscoveryIndexer; +pub use movies::SearchCleanupHandler; diff --git a/crates/application/src/movies/commands.rs b/crates/application/src/movies/commands.rs new file mode 100644 index 0000000..2167aaa --- /dev/null +++ b/crates/application/src/movies/commands.rs @@ -0,0 +1,4 @@ +pub struct EnrichMovieCommand { + pub movie_id: domain::value_objects::MovieId, + pub profile: domain::models::MovieProfile, +} diff --git a/crates/application/src/movie_discovery_indexer.rs b/crates/application/src/movies/discovery_indexer.rs similarity index 100% rename from crates/application/src/movie_discovery_indexer.rs rename to crates/application/src/movies/discovery_indexer.rs diff --git a/crates/application/src/use_cases/enrich_movie.rs b/crates/application/src/movies/enrich_movie.rs similarity index 98% rename from crates/application/src/use_cases/enrich_movie.rs rename to crates/application/src/movies/enrich_movie.rs index 4c23e95..d8fd819 100644 --- a/crates/application/src/use_cases/enrich_movie.rs +++ b/crates/application/src/movies/enrich_movie.rs @@ -7,7 +7,7 @@ use domain::{ ports::{MovieProfileRepository, MovieRepository, PersonCommand, SearchCommand}, }; -use crate::commands::EnrichMovieCommand; +use crate::movies::commands::EnrichMovieCommand; pub async fn execute( movie_repository: &Arc, diff --git a/crates/application/src/use_cases/get_movies.rs b/crates/application/src/movies/get_movies.rs similarity index 78% rename from crates/application/src/use_cases/get_movies.rs rename to crates/application/src/movies/get_movies.rs index 2cff3ac..e73dafd 100644 --- a/crates/application/src/use_cases/get_movies.rs +++ b/crates/application/src/movies/get_movies.rs @@ -4,7 +4,7 @@ use domain::{ models::{MovieFilter, MovieSummary}, }; -use crate::{context::AppContext, queries::GetMoviesQuery}; +use crate::{context::AppContext, movies::queries::GetMoviesQuery}; pub async fn execute( ctx: &AppContext, @@ -16,5 +16,5 @@ pub async fn execute( genre: query.genre, language: query.language, }; - ctx.movie_repository.list_movies(&page, &filter).await + ctx.repos.movie.list_movies(&page, &filter).await } diff --git a/crates/application/src/movies/mod.rs b/crates/application/src/movies/mod.rs new file mode 100644 index 0000000..225dbac --- /dev/null +++ b/crates/application/src/movies/mod.rs @@ -0,0 +1,10 @@ +pub mod commands; +pub mod discovery_indexer; +pub mod enrich_movie; +pub mod get_movies; +pub mod queries; +pub mod search_cleanup; +pub mod sync_poster; + +pub use discovery_indexer::MovieDiscoveryIndexer; +pub use search_cleanup::SearchCleanupHandler; diff --git a/crates/application/src/movies/queries.rs b/crates/application/src/movies/queries.rs new file mode 100644 index 0000000..e2a58d1 --- /dev/null +++ b/crates/application/src/movies/queries.rs @@ -0,0 +1,7 @@ +pub struct GetMoviesQuery { + pub limit: Option, + pub offset: Option, + pub search: Option, + pub genre: Option, + pub language: Option, +} diff --git a/crates/application/src/search_cleanup.rs b/crates/application/src/movies/search_cleanup.rs similarity index 100% rename from crates/application/src/search_cleanup.rs rename to crates/application/src/movies/search_cleanup.rs diff --git a/crates/application/src/use_cases/sync_poster.rs b/crates/application/src/movies/sync_poster.rs similarity index 83% rename from crates/application/src/use_cases/sync_poster.rs rename to crates/application/src/movies/sync_poster.rs index 981c8f9..2db3ddb 100644 --- a/crates/application/src/use_cases/sync_poster.rs +++ b/crates/application/src/movies/sync_poster.rs @@ -5,12 +5,12 @@ use domain::{ value_objects::{MovieId, PosterPath}, }; -use crate::{commands::SyncPosterCommand, context::AppContext}; +use crate::{context::AppContext, diary::commands::SyncPosterCommand}; pub async fn execute(ctx: &AppContext, cmd: SyncPosterCommand) -> Result<(), DomainError> { let movie_id = MovieId::from_uuid(cmd.movie_id); - let mut movie = match ctx.movie_repository.get_movie_by_id(&movie_id).await? { + let mut movie = match ctx.repos.movie.get_movie_by_id(&movie_id).await? { Some(m) => m, None => { tracing::warn!( @@ -31,7 +31,8 @@ pub async fn execute(ctx: &AppContext, cmd: SyncPosterCommand) -> Result<(), Dom .clone(); let poster_url = match ctx - .metadata_client + .services + .metadata .get_poster_url(&external_metadata_id) .await { @@ -43,14 +44,20 @@ pub async fn execute(ctx: &AppContext, cmd: SyncPosterCommand) -> Result<(), Dom } }; - let image_bytes = ctx.poster_fetcher.fetch_poster_bytes(&poster_url).await?; + let image_bytes = ctx + .services + .poster_fetcher + .fetch_poster_bytes(&poster_url) + .await?; let stored_path = ctx + .services .image_storage .store(&movie_id.value().to_string(), &image_bytes) .await?; if let Err(e) = ctx + .services .event_publisher .publish(&DomainEvent::ImageStored { key: stored_path.clone(), @@ -63,17 +70,19 @@ pub async fn execute(ctx: &AppContext, cmd: SyncPosterCommand) -> Result<(), Dom let poster_path = PosterPath::new(stored_path)?; movie.update_poster(poster_path); - ctx.movie_repository.upsert_movie(&movie).await?; + ctx.repos.movie.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. let profile = ctx - .movie_profile_repository + .repos + .movie_profile .get_by_movie_id(&movie_id) .await .ok() .flatten(); if let Err(e) = ctx + .repos .search_command .index(IndexableDocument::Movie { id: movie_id.clone(), diff --git a/crates/application/src/use_cases/get_person.rs b/crates/application/src/person/get.rs similarity index 80% rename from crates/application/src/use_cases/get_person.rs rename to crates/application/src/person/get.rs index 8ec6fbd..a4177bb 100644 --- a/crates/application/src/use_cases/get_person.rs +++ b/crates/application/src/person/get.rs @@ -5,5 +5,5 @@ use domain::{ }; pub async fn execute(ctx: &AppContext, id: PersonId) -> Result, DomainError> { - ctx.person_query.get_by_id(&id).await + ctx.repos.person_query.get_by_id(&id).await } diff --git a/crates/application/src/use_cases/get_person_credits.rs b/crates/application/src/person/get_credits.rs similarity index 80% rename from crates/application/src/use_cases/get_person_credits.rs rename to crates/application/src/person/get_credits.rs index 1eebfa5..a3843db 100644 --- a/crates/application/src/use_cases/get_person_credits.rs +++ b/crates/application/src/person/get_credits.rs @@ -5,5 +5,5 @@ use domain::{ }; pub async fn execute(ctx: &AppContext, id: PersonId) -> Result { - ctx.person_query.get_credits(&id).await + ctx.repos.person_query.get_credits(&id).await } diff --git a/crates/application/src/person/mod.rs b/crates/application/src/person/mod.rs new file mode 100644 index 0000000..6b19006 --- /dev/null +++ b/crates/application/src/person/mod.rs @@ -0,0 +1,2 @@ +pub mod get; +pub mod get_credits; diff --git a/crates/application/src/queries.rs b/crates/application/src/queries.rs deleted file mode 100644 index 387679b..0000000 --- a/crates/application/src/queries.rs +++ /dev/null @@ -1,115 +0,0 @@ -use domain::models::{ExportFormat, SortDirection}; -use uuid::Uuid; - -pub struct LoginQuery { - pub email: String, - pub password: String, -} - -pub struct ExportQuery { - pub user_id: Uuid, - pub format: ExportFormat, -} - -pub struct GetDiaryQuery { - pub limit: Option, - pub offset: Option, - pub sort_by: Option, - pub movie_id: Option, - pub user_id: Option, -} - -pub struct GetReviewHistoryQuery { - pub movie_id: Uuid, -} - -pub struct GetActivityFeedQuery { - pub limit: u32, - pub offset: u32, - pub sort_by: domain::ports::FeedSortBy, - pub search: Option, - pub viewer_user_id: Option, - pub filter_following: bool, -} - -pub struct GetUsersQuery; - -#[derive(Debug, Clone, Copy, Default)] -pub enum ProfileView { - History, - Trends, - Ratings, - #[default] - Recent, -} - -impl ProfileView { - pub fn as_str(&self) -> &'static str { - match self { - Self::History => "history", - Self::Trends => "trends", - Self::Ratings => "ratings", - Self::Recent => "recent", - } - } -} - -impl std::str::FromStr for ProfileView { - type Err = String; - fn from_str(s: &str) -> Result { - match s { - "history" => Ok(Self::History), - "trends" => Ok(Self::Trends), - "ratings" => Ok(Self::Ratings), - "recent" => Ok(Self::Recent), - other => Err(format!("unknown profile view: {other}")), - } - } -} - -pub struct GetUserProfileQuery { - pub user_id: Uuid, - pub view: ProfileView, - pub limit: Option, - pub offset: Option, - pub sort_by: domain::ports::FeedSortBy, - pub search: Option, - pub is_own_profile: bool, -} - -pub struct GetMovieSocialPageQuery { - pub movie_id: uuid::Uuid, - pub limit: u32, - pub offset: u32, -} - -pub struct GetMoviesQuery { - pub limit: Option, - pub offset: Option, - pub search: Option, - pub genre: Option, - pub language: Option, -} - -pub struct GetWatchlistQuery { - pub user_id: Uuid, - pub limit: Option, - pub offset: Option, -} - -pub struct IsOnWatchlistQuery { - pub user_id: Uuid, - pub movie_id: Uuid, -} - -pub struct GetCurrentProfileQuery { - pub user_id: Uuid, -} - -pub struct GetWatchQueueQuery { - pub user_id: Uuid, -} - -pub struct GetWebhookTokensQuery { - pub user_id: Uuid, -} diff --git a/crates/application/src/use_cases/search.rs b/crates/application/src/search/execute.rs similarity index 82% rename from crates/application/src/use_cases/search.rs rename to crates/application/src/search/execute.rs index 62cccb0..ebea467 100644 --- a/crates/application/src/use_cases/search.rs +++ b/crates/application/src/search/execute.rs @@ -5,5 +5,5 @@ use domain::{ }; pub async fn execute(ctx: &AppContext, query: SearchQuery) -> Result { - ctx.search_port.search(&query).await + ctx.repos.search_port.search(&query).await } diff --git a/crates/application/src/search/mod.rs b/crates/application/src/search/mod.rs new file mode 100644 index 0000000..2e8bddd --- /dev/null +++ b/crates/application/src/search/mod.rs @@ -0,0 +1 @@ +pub mod execute; diff --git a/crates/application/src/use_cases/add_to_watchlist.rs b/crates/application/src/use_cases/add_to_watchlist.rs deleted file mode 100644 index f3b6fb2..0000000 --- a/crates/application/src/use_cases/add_to_watchlist.rs +++ /dev/null @@ -1,154 +0,0 @@ -use domain::{ - errors::DomainError, - events::DomainEvent, - models::WatchlistEntry, - value_objects::{MovieId, UserId}, -}; - -use crate::{ - commands::AddToWatchlistCommand, - context::AppContext, - movie_resolver::{MovieResolver, MovieResolverDeps}, -}; - -pub async fn execute(ctx: &AppContext, cmd: AddToWatchlistCommand) -> Result<(), DomainError> { - let user_id = UserId::from_uuid(cmd.user_id); - - let movie = if let Some(id) = cmd.input.movie_id { - let movie_id = MovieId::from_uuid(id); - ctx.movie_repository - .get_movie_by_id(&movie_id) - .await? - .ok_or_else(|| DomainError::NotFound(format!("Movie {id}")))? - } else { - let deps = MovieResolverDeps { - repository: ctx.movie_repository.as_ref(), - metadata_client: ctx.metadata_client.as_ref(), - }; - let (movie, is_new) = MovieResolver::default_pipeline() - .resolve(&cmd.input, &deps) - .await?; - if is_new { - ctx.movie_repository.upsert_movie(&movie).await?; - if let Some(ext_id) = movie.external_metadata_id() { - let _ = ctx - .event_publisher - .publish(&DomainEvent::MovieDiscovered { - movie_id: movie.id().clone(), - external_metadata_id: ext_id.clone(), - }) - .await; - } - } - movie - }; - - let entry = WatchlistEntry::new(user_id.clone(), movie.id().clone()); - ctx.watchlist_repository.add(&entry).await?; - - let _ = ctx - .event_publisher - .publish(&DomainEvent::WatchlistEntryAdded { - user_id, - movie_id: movie.id().clone(), - movie_title: movie.title().value().to_string(), - release_year: movie.release_year().value(), - external_metadata_id: movie.external_metadata_id().map(|e| e.value().to_string()), - added_at: entry.added_at, - }) - .await; - - Ok(()) -} - -#[cfg(test)] -mod tests { - use std::sync::Arc; - - use domain::{ - models::Movie, - ports::MovieRepository, - testing::{InMemoryMovieRepository, InMemoryWatchlistRepository}, - value_objects::{MovieTitle, ReleaseYear}, - }; - - use crate::{ - commands::{AddToWatchlistCommand, MovieInput}, - test_helpers::TestContextBuilder, - use_cases::add_to_watchlist, - }; - - #[tokio::test] - async fn test_add_to_watchlist_resolves_and_saves() { - let movies = InMemoryMovieRepository::new(); - let watchlist = InMemoryWatchlistRepository::new(); - - let movie = Movie::new( - None, - MovieTitle::new("The Thing".into()).unwrap(), - ReleaseYear::new(1982).unwrap(), - None, - None, - ); - let movie_uuid = movie.id().value(); - movies.upsert_movie(&movie).await.unwrap(); - - let ctx = TestContextBuilder::new() - .with_movies(Arc::clone(&movies) as _) - .with_watchlist(Arc::clone(&watchlist) as _) - .build(); - - let cmd = AddToWatchlistCommand { - user_id: uuid::Uuid::new_v4(), - input: MovieInput { - movie_id: Some(movie_uuid), - external_metadata_id: None, - manual_title: None, - manual_release_year: None, - manual_director: None, - }, - }; - - add_to_watchlist::execute(&ctx, cmd).await.unwrap(); - - assert_eq!(watchlist.count(), 1); - } - - #[tokio::test] - async fn test_add_to_watchlist_already_present_is_idempotent() { - let movies = InMemoryMovieRepository::new(); - let watchlist = InMemoryWatchlistRepository::new(); - - let movie = Movie::new( - None, - MovieTitle::new("RoboCop".into()).unwrap(), - ReleaseYear::new(1987).unwrap(), - None, - None, - ); - let movie_uuid = movie.id().value(); - let user_id = uuid::Uuid::new_v4(); - movies.upsert_movie(&movie).await.unwrap(); - - let ctx = TestContextBuilder::new() - .with_movies(Arc::clone(&movies) as _) - .with_watchlist(Arc::clone(&watchlist) as _) - .build(); - - let make_cmd = || AddToWatchlistCommand { - user_id, - input: MovieInput { - movie_id: Some(movie_uuid), - external_metadata_id: None, - manual_title: None, - manual_release_year: None, - manual_director: None, - }, - }; - - add_to_watchlist::execute(&ctx, make_cmd()).await.unwrap(); - add_to_watchlist::execute(&ctx, make_cmd()).await.unwrap(); - - assert_eq!(watchlist.count(), 1, "idempotent add should not duplicate"); - } -} diff --git a/crates/application/src/use_cases/delete_review.rs b/crates/application/src/use_cases/delete_review.rs deleted file mode 100644 index 13cda8f..0000000 --- a/crates/application/src/use_cases/delete_review.rs +++ /dev/null @@ -1,163 +0,0 @@ -use crate::{commands::DeleteReviewCommand, context::AppContext}; -use domain::{ - errors::DomainError, - events::DomainEvent, - value_objects::{ReviewId, UserId}, -}; - -pub async fn execute(ctx: &AppContext, cmd: DeleteReviewCommand) -> Result<(), DomainError> { - let review_id = ReviewId::from_uuid(cmd.review_id); - let requesting_user_id = UserId::from_uuid(cmd.requesting_user_id); - - let review = ctx - .review_repository - .get_review_by_id(&review_id) - .await? - .ok_or_else(|| DomainError::NotFound(format!("review {}", cmd.review_id)))?; - - if review.user_id() != &requesting_user_id { - return Err(DomainError::Unauthorized("not your review".into())); - } - - let movie_id = review.movie_id().clone(); - ctx.review_repository.delete_review(&review_id).await?; - - if let Err(e) = ctx - .event_publisher - .publish(&DomainEvent::ReviewDeleted { - review_id: review_id.clone(), - user_id: requesting_user_id.clone(), - }) - .await - { - tracing::warn!("failed to publish ReviewDeleted: {e}"); - } - - let history = ctx.diary_repository.get_review_history(&movie_id).await?; - if history.viewings().is_empty() { - let poster_path = history.movie().poster_path().cloned(); - ctx.movie_repository.delete_movie(&movie_id).await?; - // best-effort: movie is already deleted, so publish failure is non-fatal - if let Err(e) = ctx - .event_publisher - .publish(&DomainEvent::MovieDeleted { - movie_id, - poster_path, - }) - .await - { - tracing::warn!("failed to publish MovieDeleted event: {e}"); - } - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use std::sync::Arc; - - use chrono::Utc; - - use domain::{ - models::{Movie, Review}, - ports::{MovieRepository, ReviewRepository}, - testing::{ - FakeDiaryRepository, InMemoryMovieRepository, InMemoryReviewRepository, - NoopEventPublisher, - }, - value_objects::{MovieId, MovieTitle, Rating, ReleaseYear, UserId}, - }; - - use crate::{ - commands::DeleteReviewCommand, test_helpers::TestContextBuilder, use_cases::delete_review, - }; - - fn make_movie() -> Movie { - Movie::new( - None, - MovieTitle::new("Terminator".into()).unwrap(), - ReleaseYear::new(1984).unwrap(), - None, - None, - ) - } - - fn make_review(movie_id: MovieId, user_id: UserId) -> Review { - Review::new( - movie_id, - user_id, - Rating::new(4).unwrap(), - None, - Utc::now().naive_utc(), - ) - .unwrap() - } - - #[tokio::test] - async fn test_delete_review_removes_it() { - let movies = InMemoryMovieRepository::new(); - let reviews = InMemoryReviewRepository::new(); - let diary = FakeDiaryRepository::new(); - let events = NoopEventPublisher::new(); - - let movie = make_movie(); - let user_id = UserId::from_uuid(uuid::Uuid::new_v4()); - let review = make_review(movie.id().clone(), user_id.clone()); - - movies.upsert_movie(&movie).await.unwrap(); - reviews.save_review(&review).await.unwrap(); - diary.seed_history(movie.clone(), vec![]); - - let ctx = TestContextBuilder::new() - .with_movies(Arc::clone(&movies) as _) - .with_reviews(Arc::clone(&reviews) as _) - .with_diary(Arc::clone(&diary) as _) - .with_event_publisher(Arc::clone(&events) as _) - .build(); - - delete_review::execute( - &ctx, - DeleteReviewCommand { - review_id: review.id().value(), - requesting_user_id: user_id.value(), - }, - ) - .await - .unwrap(); - - assert_eq!(reviews.count(), 0, "review should be deleted"); - assert!( - movies.get_movie_by_id(movie.id()).await.unwrap().is_none(), - "movie should be deleted when no reviews remain" - ); - } - - #[tokio::test] - async fn test_delete_review_wrong_user_is_unauthorized() { - let reviews = InMemoryReviewRepository::new(); - - let movie_id = MovieId::from_uuid(uuid::Uuid::new_v4()); - let owner_id = UserId::from_uuid(uuid::Uuid::new_v4()); - let other_id = uuid::Uuid::new_v4(); - let review = make_review(movie_id, owner_id); - - reviews.save_review(&review).await.unwrap(); - - let ctx = TestContextBuilder::new() - .with_reviews(Arc::clone(&reviews) as _) - .build(); - - let result = delete_review::execute( - &ctx, - DeleteReviewCommand { - review_id: review.id().value(), - requesting_user_id: other_id, - }, - ) - .await; - - assert!(result.is_err(), "wrong user should not be able to delete"); - assert_eq!(reviews.count(), 1, "review should still exist"); - } -} diff --git a/crates/application/src/use_cases/log_review.rs b/crates/application/src/use_cases/log_review.rs deleted file mode 100644 index 77841f3..0000000 --- a/crates/application/src/use_cases/log_review.rs +++ /dev/null @@ -1,200 +0,0 @@ -use domain::{ - errors::DomainError, - events::DomainEvent, - models::{Movie, Review}, - value_objects::{Comment, MovieId, Rating, UserId}, -}; - -use crate::{ - commands::LogReviewCommand, - context::AppContext, - movie_resolver::{MovieResolver, MovieResolverDeps}, -}; - -pub async fn execute(ctx: &AppContext, cmd: LogReviewCommand) -> Result<(), DomainError> { - let rating = Rating::new(cmd.rating)?; - let user_id = UserId::from_uuid(cmd.user_id); - let comment = cmd.comment.clone().map(Comment::new).transpose()?; - - let (movie, is_new_movie) = if let Some(id) = cmd.input.movie_id { - let movie_id = MovieId::from_uuid(id); - let movie = ctx - .movie_repository - .get_movie_by_id(&movie_id) - .await? - .ok_or_else(|| DomainError::NotFound(format!("Movie {id}")))?; - (movie, false) - } else { - let deps = MovieResolverDeps { - repository: ctx.movie_repository.as_ref(), - metadata_client: ctx.metadata_client.as_ref(), - }; - MovieResolver::default_pipeline() - .resolve(&cmd.input, &deps) - .await? - }; - - ctx.movie_repository.upsert_movie(&movie).await?; - - let review = Review::new(movie.id().clone(), user_id, rating, comment, cmd.watched_at)?; - let review_event = ctx.review_repository.save_review(&review).await?; - - let was_on_watchlist = ctx - .watchlist_repository - .remove_if_present(review.user_id(), review.movie_id()) - .await?; - if was_on_watchlist { - let _ = ctx - .event_publisher - .publish(&DomainEvent::WatchlistEntryRemoved { - user_id: review.user_id().clone(), - movie_id: review.movie_id().clone(), - }) - .await; - } - - publish_events(ctx, &movie, is_new_movie, review_event).await?; - - Ok(()) -} - -#[cfg(test)] -mod tests { - use std::sync::Arc; - - use chrono::Utc; - - use domain::{ - models::Movie, - value_objects::{MovieTitle, ReleaseYear}, - }; - - use domain::ports::MovieRepository; - use domain::testing::{InMemoryMovieRepository, InMemoryReviewRepository, NoopEventPublisher}; - - use crate::{ - commands::{LogReviewCommand, MovieInput}, - test_helpers::TestContextBuilder, - use_cases::log_review, - }; - - fn movie_input_manual(title: &str, year: u16) -> MovieInput { - MovieInput { - movie_id: None, - external_metadata_id: None, - manual_title: Some(title.to_string()), - manual_release_year: Some(year), - manual_director: None, - } - } - - fn movie_input_by_id(id: uuid::Uuid) -> MovieInput { - MovieInput { - movie_id: Some(id), - external_metadata_id: None, - manual_title: None, - manual_release_year: None, - manual_director: None, - } - } - - #[tokio::test] - async fn test_log_review_creates_movie_and_review() { - let movies = InMemoryMovieRepository::new(); - let reviews = InMemoryReviewRepository::new(); - let events = NoopEventPublisher::new(); - let ctx = TestContextBuilder::new() - .with_movies(Arc::clone(&movies) as _) - .with_reviews(Arc::clone(&reviews) as _) - .with_event_publisher(Arc::clone(&events) as _) - .build(); - - let user_id = uuid::Uuid::new_v4(); - let cmd = LogReviewCommand { - user_id, - input: movie_input_manual("Blade Runner", 1982), - rating: 4, - comment: None, - watched_at: Utc::now().naive_utc(), - }; - - log_review::execute(&ctx, cmd).await.unwrap(); - - assert_eq!(reviews.count(), 1, "review should be saved"); - assert!(!events.published().is_empty(), "events should be published"); - } - - #[tokio::test] - async fn test_log_review_reuses_existing_movie() { - let movies = InMemoryMovieRepository::new(); - let reviews = InMemoryReviewRepository::new(); - - let existing_movie = Movie::new( - None, - MovieTitle::new("Alien".into()).unwrap(), - ReleaseYear::new(1979).unwrap(), - None, - None, - ); - let movie_uuid = existing_movie.id().value(); - movies.upsert_movie(&existing_movie).await.unwrap(); - - let ctx = TestContextBuilder::new() - .with_movies(Arc::clone(&movies) as _) - .with_reviews(Arc::clone(&reviews) as _) - .build(); - - let cmd = LogReviewCommand { - user_id: uuid::Uuid::new_v4(), - input: movie_input_by_id(movie_uuid), - rating: 5, - comment: None, - watched_at: Utc::now().naive_utc(), - }; - - log_review::execute(&ctx, cmd).await.unwrap(); - - assert_eq!(movies.count(), 1, "no duplicate movie"); - assert_eq!(reviews.count(), 1); - } - - #[tokio::test] - async fn test_log_review_with_invalid_rating_fails() { - let ctx = TestContextBuilder::new().build(); - let cmd = LogReviewCommand { - user_id: uuid::Uuid::new_v4(), - input: movie_input_manual("Some Film", 2000), - rating: 6, - comment: None, - watched_at: Utc::now().naive_utc(), - }; - let result = log_review::execute(&ctx, cmd).await; - assert!(result.is_err(), "rating > 5 should fail"); - } -} - -async fn publish_events( - ctx: &AppContext, - movie: &Movie, - is_new_movie: bool, - review_event: DomainEvent, -) -> Result<(), DomainError> { - if is_new_movie && let Some(ext_id) = movie.external_metadata_id() { - let discovery_event = DomainEvent::MovieDiscovered { - movie_id: movie.id().clone(), - external_metadata_id: ext_id.clone(), - }; - ctx.event_publisher.publish(&discovery_event).await?; - } - - if let Some(ext_id) = movie.external_metadata_id() { - let enrichment_event = DomainEvent::MovieEnrichmentRequested { - movie_id: movie.id().clone(), - external_metadata_id: ext_id.value().to_string(), - }; - ctx.event_publisher.publish(&enrichment_event).await?; - } - - ctx.event_publisher.publish(&review_event).await?; - Ok(()) -} diff --git a/crates/application/src/use_cases/login.rs b/crates/application/src/use_cases/login.rs deleted file mode 100644 index 2265bfc..0000000 --- a/crates/application/src/use_cases/login.rs +++ /dev/null @@ -1,128 +0,0 @@ -use chrono::{DateTime, Utc}; -use uuid::Uuid; - -use domain::{errors::DomainError, value_objects::Email}; - -use crate::{context::AppContext, queries::LoginQuery}; - -pub struct LoginResult { - pub token: String, - pub user_id: Uuid, - pub email: String, - pub expires_at: DateTime, -} - -pub async fn execute(ctx: &AppContext, query: LoginQuery) -> Result { - let email = Email::new(query.email)?; - let user = ctx - .user_repository - .find_by_email(&email) - .await? - .ok_or_else(|| DomainError::Unauthorized("Invalid credentials".into()))?; - - let valid = ctx - .password_hasher - .verify(&query.password, user.password_hash()) - .await?; - if !valid { - return Err(DomainError::Unauthorized("Invalid credentials".into())); - } - - let generated = ctx.auth_service.generate_token(user.id()).await?; - - Ok(LoginResult { - token: generated.token, - user_id: user.id().value(), - email: user.email().value().to_string(), - expires_at: generated.expires_at, - }) -} - -#[cfg(test)] -mod tests { - use std::sync::Arc; - - use domain::models::UserRole; - use domain::testing::InMemoryUserRepository; - - use crate::{ - commands::RegisterCommand, - queries::LoginQuery, - test_helpers::TestContextBuilder, - use_cases::{login, register}, - }; - - async fn setup_user(ctx: &crate::context::AppContext, email: &str, password: &str) { - register::execute( - ctx, - RegisterCommand { - email: email.to_string(), - username: "testuser".to_string(), - password: password.to_string(), - role: UserRole::Standard, - }, - ) - .await - .unwrap(); - } - - #[tokio::test] - async fn test_login_valid_credentials_returns_token() { - let users = InMemoryUserRepository::new(); - let ctx = TestContextBuilder::new() - .with_users(Arc::clone(&users) as _) - .build(); - - setup_user(&ctx, "carol@example.com", "secret123").await; - - let result = login::execute( - &ctx, - LoginQuery { - email: "carol@example.com".into(), - password: "secret123".into(), - }, - ) - .await - .unwrap(); - - assert!(!result.token.is_empty()); - assert_eq!(result.email, "carol@example.com"); - } - - #[tokio::test] - async fn test_login_wrong_password_fails() { - let users = InMemoryUserRepository::new(); - let ctx = TestContextBuilder::new() - .with_users(Arc::clone(&users) as _) - .build(); - - setup_user(&ctx, "dave@example.com", "correct_password").await; - - let result = login::execute( - &ctx, - LoginQuery { - email: "dave@example.com".into(), - password: "wrong_password".into(), - }, - ) - .await; - - assert!(result.is_err()); - } - - #[tokio::test] - async fn test_login_unknown_email_fails() { - let ctx = TestContextBuilder::new().build(); - - let result = login::execute( - &ctx, - LoginQuery { - email: "nobody@example.com".into(), - password: "anything".into(), - }, - ) - .await; - - assert!(result.is_err()); - } -} diff --git a/crates/application/src/use_cases/mod.rs b/crates/application/src/use_cases/mod.rs deleted file mode 100644 index 7d77c04..0000000 --- a/crates/application/src/use_cases/mod.rs +++ /dev/null @@ -1,44 +0,0 @@ -pub mod add_to_watchlist; -pub mod apply_import_mapping; -pub mod apply_import_profile; -pub mod cleanup_expired_import_sessions; -pub mod cleanup_watch_events; -pub mod confirm_watch_events; -pub mod create_import_session; -pub mod delete_import_profile; -pub mod delete_review; -pub mod dismiss_watch_events; -pub mod enrich_movie; -pub mod execute_import; -pub mod export_diary; -pub mod generate_webhook_token; -pub mod get_activity_feed; -pub mod get_current_profile; -pub mod get_diary; -pub mod get_movie_social_page; -pub mod get_movies; -pub mod get_person; -pub mod get_person_credits; -#[cfg(feature = "federation")] -pub mod get_remote_watchlist; -pub mod get_review_history; -pub mod get_user_profile; -pub mod get_users; -pub mod get_watch_queue; -pub mod get_watchlist; -pub mod get_watchlist_page; -pub mod get_webhook_tokens; -pub mod ingest_watch_event; -pub mod is_on_watchlist; -pub mod list_import_profiles; -pub mod log_review; -pub mod login; -pub mod register; -pub mod register_and_login; -pub mod remove_from_watchlist; -pub mod revoke_webhook_token; -pub mod save_import_profile; -pub mod search; -pub mod sync_poster; -pub mod update_profile; -pub mod update_profile_fields; diff --git a/crates/application/src/use_cases/register.rs b/crates/application/src/use_cases/register.rs deleted file mode 100644 index c2e4a4d..0000000 --- a/crates/application/src/use_cases/register.rs +++ /dev/null @@ -1,98 +0,0 @@ -use domain::{ - errors::DomainError, - models::User, - value_objects::{Email, Username}, -}; - -use crate::{commands::RegisterCommand, context::AppContext}; - -const MIN_PASSWORD_LENGTH: usize = 8; - -pub async fn execute(ctx: &AppContext, cmd: RegisterCommand) -> Result<(), DomainError> { - if !ctx.config.allow_registration { - return Err(DomainError::Unauthorized("Registration is disabled".into())); - } - - if cmd.password.len() < MIN_PASSWORD_LENGTH { - return Err(DomainError::ValidationError( - "Password must be at least 8 characters".into(), - )); - } - - let email = Email::new(cmd.email)?; - let username = Username::new(cmd.username)?; - - if ctx.user_repository.find_by_email(&email).await?.is_some() { - return Err(DomainError::ValidationError( - "Email already registered".into(), - )); - } - - if ctx - .user_repository - .find_by_username(&username) - .await? - .is_some() - { - return Err(DomainError::ValidationError( - "Username already taken".into(), - )); - } - - let hash = ctx.password_hasher.hash(&cmd.password).await?; - ctx.user_repository - .save(&User::new(email, username, hash, cmd.role)) - .await -} - -#[cfg(test)] -mod tests { - use std::sync::Arc; - - use domain::models::UserRole; - use domain::ports::UserRepository; - use domain::testing::InMemoryUserRepository; - use domain::value_objects::Email; - - use crate::{commands::RegisterCommand, test_helpers::TestContextBuilder, use_cases::register}; - - fn cmd(email: &str) -> RegisterCommand { - RegisterCommand { - email: email.to_string(), - username: "alice".to_string(), - password: "password123".to_string(), - role: UserRole::Standard, - } - } - - #[tokio::test] - async fn test_register_creates_user() { - let users = InMemoryUserRepository::new(); - let ctx = TestContextBuilder::new() - .with_users(Arc::clone(&users) as _) - .build(); - - register::execute(&ctx, cmd("alice@example.com")) - .await - .unwrap(); - - let email = Email::new("alice@example.com".into()).unwrap(); - let user = users.find_by_email(&email).await.unwrap().unwrap(); - assert_eq!(user.email().value(), "alice@example.com"); - assert!(user.password_hash().value().starts_with("hashed:")); - } - - #[tokio::test] - async fn test_register_duplicate_email_fails() { - let users = InMemoryUserRepository::new(); - let ctx = TestContextBuilder::new() - .with_users(Arc::clone(&users) as _) - .build(); - - register::execute(&ctx, cmd("bob@example.com")) - .await - .unwrap(); - let result = register::execute(&ctx, cmd("bob@example.com")).await; - assert!(result.is_err(), "duplicate email should fail"); - } -} diff --git a/crates/application/src/users/commands.rs b/crates/application/src/users/commands.rs new file mode 100644 index 0000000..b28d4c2 --- /dev/null +++ b/crates/application/src/users/commands.rs @@ -0,0 +1,17 @@ +use uuid::Uuid; + +pub struct UpdateProfileCommand { + pub user_id: Uuid, + pub display_name: Option, + pub bio: Option, + pub avatar_bytes: Option>, + pub avatar_content_type: Option, + pub banner_bytes: Option>, + pub banner_content_type: Option, + pub also_known_as: Option, +} + +pub struct UpdateProfileFieldsCommand { + pub user_id: Uuid, + pub fields: Vec, +} diff --git a/crates/application/src/use_cases/get_current_profile.rs b/crates/application/src/users/get_current_profile.rs similarity index 88% rename from crates/application/src/use_cases/get_current_profile.rs rename to crates/application/src/users/get_current_profile.rs index 9166176..4029f9e 100644 --- a/crates/application/src/use_cases/get_current_profile.rs +++ b/crates/application/src/users/get_current_profile.rs @@ -1,6 +1,6 @@ use domain::errors::DomainError; -use crate::{context::AppContext, queries::GetCurrentProfileQuery}; +use crate::{context::AppContext, users::queries::GetCurrentProfileQuery}; pub struct CurrentProfileData { pub username: String, @@ -14,7 +14,8 @@ pub async fn execute( ) -> Result { let user_id = domain::value_objects::UserId::from_uuid(query.user_id); let user = ctx - .user_repository + .repos + .user .find_by_id(&user_id) .await? .ok_or_else(|| DomainError::NotFound("User not found".into()))?; diff --git a/crates/application/src/use_cases/get_user_profile.rs b/crates/application/src/users/get_profile.rs similarity index 92% rename from crates/application/src/use_cases/get_user_profile.rs rename to crates/application/src/users/get_profile.rs index f77a5b3..e6e9e8e 100644 --- a/crates/application/src/use_cases/get_user_profile.rs +++ b/crates/application/src/users/get_profile.rs @@ -1,6 +1,6 @@ use crate::{ context::AppContext, - queries::{GetUserProfileQuery, ProfileView}, + users::queries::{GetUserProfileQuery, ProfileView}, }; use chrono::Datelike; use domain::{ @@ -35,7 +35,7 @@ pub async fn execute( query: GetUserProfileQuery, ) -> Result { let user_id = UserId::from_uuid(query.user_id); - let stats = ctx.stats_repository.get_user_stats(&user_id).await?; + let stats = ctx.repos.stats.get_user_stats(&user_id).await?; let (following_count, followers_count, pending_followers) = load_social_counts(ctx, query.user_id, query.is_own_profile).await; @@ -52,12 +52,12 @@ pub async fn execute( match query.view { ProfileView::History => { - let all_entries = ctx.diary_repository.get_user_history(&user_id).await?; + let all_entries = ctx.repos.diary.get_user_history(&user_id).await?; let history = group_by_month(all_entries); Ok(base(None, Some(history), None)) } ProfileView::Trends => { - let trends = ctx.stats_repository.get_user_trends(&user_id).await?; + let trends = ctx.repos.stats.get_user_trends(&user_id).await?; Ok(base(None, None, Some(trends))) } ProfileView::Ratings | ProfileView::Recent => { @@ -69,7 +69,7 @@ pub async fn execute( query.offset, query.search.clone(), )?; - let entries = ctx.diary_repository.query_diary(&filter).await?; + let entries = ctx.repos.diary.query_diary(&filter).await?; Ok(base(Some(entries), None, None)) } } @@ -90,16 +90,19 @@ async fn load_social_counts( return (0, 0, vec![]); } let following = _ctx + .repos .social_query .count_following(_user_id) .await .unwrap_or(0); let followers = _ctx + .repos .social_query .count_accepted_followers(_user_id) .await .unwrap_or(0); let pending = _ctx + .repos .social_query .get_pending_followers(_user_id) .await diff --git a/crates/application/src/use_cases/get_users.rs b/crates/application/src/users/get_users.rs similarity index 74% rename from crates/application/src/use_cases/get_users.rs rename to crates/application/src/users/get_users.rs index f605ec0..405da44 100644 --- a/crates/application/src/use_cases/get_users.rs +++ b/crates/application/src/users/get_users.rs @@ -1,4 +1,4 @@ -use crate::{context::AppContext, queries::GetUsersQuery}; +use crate::{context::AppContext, users::queries::GetUsersQuery}; use domain::{errors::DomainError, models::UserSummary, ports::RemoteActorInfo}; pub struct UsersListData { @@ -12,12 +12,12 @@ pub async fn execute( ) -> Result { #[cfg(feature = "federation")] let (users_result, actors_result) = tokio::join!( - ctx.user_repository.list_with_stats(), - ctx.social_query.list_all_followed_remote_actors() + ctx.repos.user.list_with_stats(), + ctx.repos.social_query.list_all_followed_remote_actors() ); #[cfg(not(feature = "federation"))] let (users_result, actors_result) = ( - ctx.user_repository.list_with_stats().await, + ctx.repos.user.list_with_stats().await, Ok::, DomainError>(vec![]), ); diff --git a/crates/application/src/users/mod.rs b/crates/application/src/users/mod.rs new file mode 100644 index 0000000..a78466d --- /dev/null +++ b/crates/application/src/users/mod.rs @@ -0,0 +1,7 @@ +pub mod commands; +pub mod get_current_profile; +pub mod get_profile; +pub mod get_users; +pub mod queries; +pub mod update_profile; +pub mod update_profile_fields; diff --git a/crates/application/src/users/queries.rs b/crates/application/src/users/queries.rs new file mode 100644 index 0000000..6ea866c --- /dev/null +++ b/crates/application/src/users/queries.rs @@ -0,0 +1,50 @@ +use uuid::Uuid; + +pub struct GetUsersQuery; + +#[derive(Debug, Clone, Copy, Default)] +pub enum ProfileView { + History, + Trends, + Ratings, + #[default] + Recent, +} + +impl ProfileView { + pub fn as_str(&self) -> &'static str { + match self { + Self::History => "history", + Self::Trends => "trends", + Self::Ratings => "ratings", + Self::Recent => "recent", + } + } +} + +impl std::str::FromStr for ProfileView { + type Err = String; + fn from_str(s: &str) -> Result { + match s { + "history" => Ok(Self::History), + "trends" => Ok(Self::Trends), + "ratings" => Ok(Self::Ratings), + "recent" => Ok(Self::Recent), + other => Err(format!("unknown profile view: {other}")), + } + } +} + +pub struct GetUserProfileQuery { + pub user_id: Uuid, + pub view: ProfileView, + pub limit: Option, + pub offset: Option, + pub sort_by: domain::ports::FeedSortBy, + pub search: Option, + pub is_own_profile: bool, +} + +pub struct GetCurrentProfileQuery { + pub user_id: Uuid, +} diff --git a/crates/application/src/use_cases/update_profile.rs b/crates/application/src/users/update_profile.rs similarity index 83% rename from crates/application/src/use_cases/update_profile.rs rename to crates/application/src/users/update_profile.rs index 8b04029..bb74f4d 100644 --- a/crates/application/src/use_cases/update_profile.rs +++ b/crates/application/src/users/update_profile.rs @@ -1,12 +1,13 @@ use domain::{errors::DomainError, events::DomainEvent, value_objects::UserId}; -use crate::{commands::UpdateProfileCommand, context::AppContext}; +use crate::{context::AppContext, users::commands::UpdateProfileCommand}; pub async fn execute(ctx: &AppContext, cmd: UpdateProfileCommand) -> Result<(), DomainError> { let user_id = UserId::from_uuid(cmd.user_id); let user = ctx - .user_repository + .repos + .user .find_by_id(&user_id) .await? .ok_or_else(|| DomainError::NotFound("User not found".into()))?; @@ -20,11 +21,12 @@ pub async fn execute(ctx: &AppContext, cmd: UpdateProfileCommand) -> Result<(), )); } if let Some(old_path) = user.avatar_path() { - let _ = ctx.image_storage.delete(old_path).await; + let _ = ctx.services.image_storage.delete(old_path).await; } let key = format!("avatars/{}", user_id.value()); - let stored = ctx.image_storage.store(&key, &bytes).await?; + let stored = ctx.services.image_storage.store(&key, &bytes).await?; if let Err(e) = ctx + .services .event_publisher .publish(&DomainEvent::ImageStored { key: stored.clone(), @@ -47,11 +49,12 @@ pub async fn execute(ctx: &AppContext, cmd: UpdateProfileCommand) -> Result<(), )); } if let Some(old_path) = user.banner_path() { - let _ = ctx.image_storage.delete(old_path).await; + let _ = ctx.services.image_storage.delete(old_path).await; } let key = format!("banners/{}", user_id.value()); - let stored = ctx.image_storage.store(&key, &bytes).await?; + let stored = ctx.services.image_storage.store(&key, &bytes).await?; if let Err(e) = ctx + .services .event_publisher .publish(&DomainEvent::ImageStored { key: stored.clone(), @@ -65,7 +68,8 @@ pub async fn execute(ctx: &AppContext, cmd: UpdateProfileCommand) -> Result<(), user.banner_path().map(|s| s.to_string()) }; - ctx.user_repository + ctx.repos + .user .update_profile( &user_id, &domain::models::UserProfile { @@ -79,7 +83,8 @@ pub async fn execute(ctx: &AppContext, cmd: UpdateProfileCommand) -> Result<(), ) .await?; - ctx.event_publisher + ctx.services + .event_publisher .publish(&DomainEvent::UserUpdated { user_id }) .await?; diff --git a/crates/application/src/use_cases/update_profile_fields.rs b/crates/application/src/users/update_profile_fields.rs similarity index 76% rename from crates/application/src/use_cases/update_profile_fields.rs rename to crates/application/src/users/update_profile_fields.rs index aaecb75..5719d4a 100644 --- a/crates/application/src/use_cases/update_profile_fields.rs +++ b/crates/application/src/users/update_profile_fields.rs @@ -1,6 +1,6 @@ use domain::{errors::DomainError, events::DomainEvent, value_objects::UserId}; -use crate::{commands::UpdateProfileFieldsCommand, context::AppContext}; +use crate::{context::AppContext, users::commands::UpdateProfileFieldsCommand}; pub async fn execute(ctx: &AppContext, cmd: UpdateProfileFieldsCommand) -> Result<(), DomainError> { if cmd.fields.len() > 4 { @@ -9,10 +9,12 @@ pub async fn execute(ctx: &AppContext, cmd: UpdateProfileFieldsCommand) -> Resul )); } let user_id = UserId::from_uuid(cmd.user_id); - ctx.profile_fields_repository + ctx.repos + .profile_fields .set_fields(&user_id, cmd.fields) .await?; - ctx.event_publisher + ctx.services + .event_publisher .publish(&DomainEvent::UserUpdated { user_id }) .await?; Ok(()) diff --git a/crates/application/src/watchlist/add.rs b/crates/application/src/watchlist/add.rs new file mode 100644 index 0000000..f3caf5d --- /dev/null +++ b/crates/application/src/watchlist/add.rs @@ -0,0 +1,69 @@ +use domain::{ + errors::DomainError, + events::DomainEvent, + models::WatchlistEntry, + value_objects::{MovieId, UserId}, +}; + +use crate::{ + context::AppContext, + diary::movie_resolver::{MovieResolver, MovieResolverDeps}, + watchlist::commands::AddToWatchlistCommand, +}; + +pub async fn execute(ctx: &AppContext, cmd: AddToWatchlistCommand) -> Result<(), DomainError> { + let user_id = UserId::from_uuid(cmd.user_id); + + let movie = if let Some(id) = cmd.input.movie_id { + let movie_id = MovieId::from_uuid(id); + ctx.repos + .movie + .get_movie_by_id(&movie_id) + .await? + .ok_or_else(|| DomainError::NotFound(format!("Movie {id}")))? + } else { + let deps = MovieResolverDeps { + repository: ctx.repos.movie.as_ref(), + metadata_client: ctx.services.metadata.as_ref(), + }; + let (movie, is_new) = MovieResolver::default_pipeline() + .resolve(&cmd.input, &deps) + .await?; + if is_new { + ctx.repos.movie.upsert_movie(&movie).await?; + if let Some(ext_id) = movie.external_metadata_id() { + let _ = ctx + .services + .event_publisher + .publish(&DomainEvent::MovieDiscovered { + movie_id: movie.id().clone(), + external_metadata_id: ext_id.clone(), + }) + .await; + } + } + movie + }; + + let entry = WatchlistEntry::new(user_id.clone(), movie.id().clone()); + ctx.repos.watchlist.add(&entry).await?; + + let _ = ctx + .services + .event_publisher + .publish(&DomainEvent::WatchlistEntryAdded { + user_id, + movie_id: movie.id().clone(), + movie_title: movie.title().value().to_string(), + release_year: movie.release_year().value(), + external_metadata_id: movie.external_metadata_id().map(|e| e.value().to_string()), + added_at: entry.added_at, + }) + .await; + + Ok(()) +} + +#[cfg(test)] +#[path = "tests/add.rs"] +mod tests; diff --git a/crates/application/src/watchlist/commands.rs b/crates/application/src/watchlist/commands.rs new file mode 100644 index 0000000..f28e777 --- /dev/null +++ b/crates/application/src/watchlist/commands.rs @@ -0,0 +1,13 @@ +use uuid::Uuid; + +use crate::diary::commands::MovieInput; + +pub struct AddToWatchlistCommand { + pub user_id: Uuid, + pub input: MovieInput, +} + +pub struct RemoveFromWatchlistCommand { + pub user_id: Uuid, + pub movie_id: Uuid, +} diff --git a/crates/application/src/use_cases/get_watchlist.rs b/crates/application/src/watchlist/get.rs similarity index 75% rename from crates/application/src/use_cases/get_watchlist.rs rename to crates/application/src/watchlist/get.rs index b9f0fa6..6bcae68 100644 --- a/crates/application/src/use_cases/get_watchlist.rs +++ b/crates/application/src/watchlist/get.rs @@ -7,7 +7,7 @@ use domain::{ value_objects::UserId, }; -use crate::{context::AppContext, queries::GetWatchlistQuery}; +use crate::{context::AppContext, watchlist::queries::GetWatchlistQuery}; pub async fn execute( ctx: &AppContext, @@ -15,5 +15,5 @@ pub async fn execute( ) -> Result, DomainError> { let user_id = UserId::from_uuid(query.user_id); let page = PageParams::new(query.limit, query.offset)?; - ctx.watchlist_repository.get_for_user(&user_id, &page).await + ctx.repos.watchlist.get_for_user(&user_id, &page).await } diff --git a/crates/application/src/use_cases/get_watchlist_page.rs b/crates/application/src/watchlist/get_page.rs similarity index 88% rename from crates/application/src/use_cases/get_watchlist_page.rs rename to crates/application/src/watchlist/get_page.rs index 38016c1..b759357 100644 --- a/crates/application/src/use_cases/get_watchlist_page.rs +++ b/crates/application/src/watchlist/get_page.rs @@ -1,6 +1,8 @@ use domain::{errors::DomainError, value_objects::UserId}; -use crate::{context::AppContext, ports::WatchlistDisplayEntry, queries::GetWatchlistQuery}; +use crate::{ + context::AppContext, ports::WatchlistDisplayEntry, watchlist::queries::GetWatchlistQuery, +}; pub struct WatchlistPageResult { pub display_entries: Vec, @@ -15,10 +17,10 @@ pub async fn execute( is_owner: bool, ) -> Result { let user_id = UserId::from_uuid(query.user_id); - let is_local = ctx.user_repository.find_by_id(&user_id).await?.is_some(); + let is_local = ctx.repos.user.find_by_id(&user_id).await?.is_some(); if is_local { - let page = super::get_watchlist::execute(ctx, query).await?; + let page = crate::watchlist::get::execute(ctx, query).await?; let has_more = page.offset + page.limit < page.total_count as u32; let display_entries = page .items @@ -71,7 +73,7 @@ async fn load_remote_watchlist( ctx: &AppContext, user_id: uuid::Uuid, ) -> Result { - let remote_entries = super::get_remote_watchlist::execute(ctx, user_id) + let remote_entries = crate::federation::get_remote_watchlist::execute(ctx, user_id) .await .unwrap_or_default(); let len = remote_entries.len() as u32; diff --git a/crates/application/src/use_cases/is_on_watchlist.rs b/crates/application/src/watchlist/is_on.rs similarity index 68% rename from crates/application/src/use_cases/is_on_watchlist.rs rename to crates/application/src/watchlist/is_on.rs index fae8dea..3d44c45 100644 --- a/crates/application/src/use_cases/is_on_watchlist.rs +++ b/crates/application/src/watchlist/is_on.rs @@ -3,10 +3,10 @@ use domain::{ value_objects::{MovieId, UserId}, }; -use crate::{context::AppContext, queries::IsOnWatchlistQuery}; +use crate::{context::AppContext, watchlist::queries::IsOnWatchlistQuery}; pub async fn execute(ctx: &AppContext, query: IsOnWatchlistQuery) -> Result { let user_id = UserId::from_uuid(query.user_id); let movie_id = MovieId::from_uuid(query.movie_id); - ctx.watchlist_repository.contains(&user_id, &movie_id).await + ctx.repos.watchlist.contains(&user_id, &movie_id).await } diff --git a/crates/application/src/watchlist/mod.rs b/crates/application/src/watchlist/mod.rs new file mode 100644 index 0000000..a2c8d2b --- /dev/null +++ b/crates/application/src/watchlist/mod.rs @@ -0,0 +1,7 @@ +pub mod add; +pub mod commands; +pub mod get; +pub mod get_page; +pub mod is_on; +pub mod queries; +pub mod remove; diff --git a/crates/application/src/watchlist/queries.rs b/crates/application/src/watchlist/queries.rs new file mode 100644 index 0000000..c0f2e19 --- /dev/null +++ b/crates/application/src/watchlist/queries.rs @@ -0,0 +1,12 @@ +use uuid::Uuid; + +pub struct GetWatchlistQuery { + pub user_id: Uuid, + pub limit: Option, + pub offset: Option, +} + +pub struct IsOnWatchlistQuery { + pub user_id: Uuid, + pub movie_id: Uuid, +} diff --git a/crates/application/src/use_cases/remove_from_watchlist.rs b/crates/application/src/watchlist/remove.rs similarity index 74% rename from crates/application/src/use_cases/remove_from_watchlist.rs rename to crates/application/src/watchlist/remove.rs index 8fefdc1..f432f28 100644 --- a/crates/application/src/use_cases/remove_from_watchlist.rs +++ b/crates/application/src/watchlist/remove.rs @@ -4,14 +4,15 @@ use domain::{ value_objects::{MovieId, UserId}, }; -use crate::{commands::RemoveFromWatchlistCommand, context::AppContext}; +use crate::{context::AppContext, watchlist::commands::RemoveFromWatchlistCommand}; pub async fn execute(ctx: &AppContext, cmd: RemoveFromWatchlistCommand) -> Result<(), DomainError> { let user_id = UserId::from_uuid(cmd.user_id); let movie_id = MovieId::from_uuid(cmd.movie_id); - ctx.watchlist_repository.remove(&user_id, &movie_id).await?; + ctx.repos.watchlist.remove(&user_id, &movie_id).await?; let _ = ctx + .services .event_publisher .publish(&DomainEvent::WatchlistEntryRemoved { user_id, movie_id }) .await; diff --git a/crates/application/src/watchlist/tests/add.rs b/crates/application/src/watchlist/tests/add.rs new file mode 100644 index 0000000..58bea04 --- /dev/null +++ b/crates/application/src/watchlist/tests/add.rs @@ -0,0 +1,87 @@ +use std::sync::Arc; + +use domain::{ + models::Movie, + ports::MovieRepository, + testing::{InMemoryMovieRepository, InMemoryWatchlistRepository}, + value_objects::{MovieTitle, ReleaseYear}, +}; + +use crate::{ + diary::commands::MovieInput, test_helpers::TestContextBuilder, watchlist::add, + watchlist::commands::AddToWatchlistCommand, +}; + +#[tokio::test] +async fn test_add_to_watchlist_resolves_and_saves() { + let movies = InMemoryMovieRepository::new(); + let watchlist = InMemoryWatchlistRepository::new(); + + let movie = Movie::new( + None, + MovieTitle::new("The Thing".into()).unwrap(), + ReleaseYear::new(1982).unwrap(), + None, + None, + ); + let movie_uuid = movie.id().value(); + movies.upsert_movie(&movie).await.unwrap(); + + let ctx = TestContextBuilder::new() + .with_movies(Arc::clone(&movies) as _) + .with_watchlist(Arc::clone(&watchlist) as _) + .build(); + + let cmd = AddToWatchlistCommand { + user_id: uuid::Uuid::new_v4(), + input: MovieInput { + movie_id: Some(movie_uuid), + external_metadata_id: None, + manual_title: None, + manual_release_year: None, + manual_director: None, + }, + }; + + add::execute(&ctx, cmd).await.unwrap(); + + assert_eq!(watchlist.count(), 1); +} + +#[tokio::test] +async fn test_add_to_watchlist_already_present_is_idempotent() { + let movies = InMemoryMovieRepository::new(); + let watchlist = InMemoryWatchlistRepository::new(); + + let movie = Movie::new( + None, + MovieTitle::new("RoboCop".into()).unwrap(), + ReleaseYear::new(1987).unwrap(), + None, + None, + ); + let movie_uuid = movie.id().value(); + let user_id = uuid::Uuid::new_v4(); + movies.upsert_movie(&movie).await.unwrap(); + + let ctx = TestContextBuilder::new() + .with_movies(Arc::clone(&movies) as _) + .with_watchlist(Arc::clone(&watchlist) as _) + .build(); + + let make_cmd = || AddToWatchlistCommand { + user_id, + input: MovieInput { + movie_id: Some(movie_uuid), + external_metadata_id: None, + manual_title: None, + manual_release_year: None, + manual_director: None, + }, + }; + + add::execute(&ctx, make_cmd()).await.unwrap(); + add::execute(&ctx, make_cmd()).await.unwrap(); + + assert_eq!(watchlist.count(), 1, "idempotent add should not duplicate"); +} diff --git a/crates/presentation/src/forms.rs b/crates/presentation/src/forms.rs index b57c69f..a34afa6 100644 --- a/crates/presentation/src/forms.rs +++ b/crates/presentation/src/forms.rs @@ -2,7 +2,7 @@ use chrono::NaiveDateTime; use serde::Deserialize; use uuid::Uuid; -use application::{ +use application::diary::{ commands::{LogReviewCommand, MovieInput}, queries::GetDiaryQuery, }; diff --git a/crates/presentation/src/handlers/api.rs b/crates/presentation/src/handlers/api.rs index be8b11e..5ba1ab2 100644 --- a/crates/presentation/src/handlers/api.rs +++ b/crates/presentation/src/handlers/api.rs @@ -9,22 +9,31 @@ use uuid::Uuid; use std::str::FromStr; use application::{ - commands::{ - AddToWatchlistCommand, DeleteReviewCommand, MovieInput, RegisterCommand, - RemoveFromWatchlistCommand, SyncPosterCommand, + auth::{ + commands::RegisterCommand, login as login_uc, queries::LoginQuery, register as register_uc, }, - queries::{ - ExportQuery, GetActivityFeedQuery, GetMovieSocialPageQuery, GetMoviesQuery, - GetReviewHistoryQuery, GetUserProfileQuery, GetUsersQuery, GetWatchlistQuery, - IsOnWatchlistQuery, LoginQuery, + diary::{ + commands::{DeleteReviewCommand, MovieInput, SyncPosterCommand}, + delete_review, export_diary as export_diary_uc, get_activity_feed as get_feed_uc, + get_diary, get_movie_social_page, get_review_history, log_review, + queries::{ + ExportQuery, GetActivityFeedQuery, GetMovieSocialPageQuery, GetReviewHistoryQuery, + }, }, - use_cases::{ - add_to_watchlist, delete_review, export_diary as export_diary_uc, - get_activity_feed as get_feed_uc, get_diary, get_movie_social_page, get_movies, get_person, - get_person_credits, get_review_history, get_user_profile as get_user_profile_uc, get_users, - get_watchlist, is_on_watchlist, log_review, login as login_uc, register as register_uc, - remove_from_watchlist, search as search_uc, sync_poster, update_profile, - update_profile_fields, + movies::{get_movies, queries::GetMoviesQuery, sync_poster}, + person::{get as get_person, get_credits as get_person_credits}, + search::execute as search_uc, + users::{ + get_profile as get_user_profile_uc, get_users, + queries::{GetUserProfileQuery, GetUsersQuery}, + update_profile, update_profile_fields, + }, + watchlist::{ + add as add_to_watchlist, + commands::{AddToWatchlistCommand, RemoveFromWatchlistCommand}, + get as get_watchlist, is_on as is_on_watchlist, + queries::{GetWatchlistQuery, IsOnWatchlistQuery}, + remove as remove_from_watchlist, }, }; use domain::{ @@ -333,12 +342,7 @@ pub async fn get_movie_profile( Path(movie_id): Path, ) -> impl IntoResponse { let id = domain::value_objects::MovieId::from_uuid(movie_id); - match state - .app_ctx - .movie_profile_repository - .get_by_movie_id(&id) - .await - { + match state.app_ctx.repos.movie_profile.get_by_movie_id(&id).await { Ok(Some(p)) => Json(MovieProfileResponse { tmdb_id: p.tmdb_id, imdb_id: p.imdb_id, @@ -413,9 +417,9 @@ pub async fn get_profile( State(state): State, AuthenticatedUser(user_id): AuthenticatedUser, ) -> impl IntoResponse { - match application::use_cases::get_current_profile::execute( + match application::users::get_current_profile::execute( &state.app_ctx, - application::queries::GetCurrentProfileQuery { + application::users::queries::GetCurrentProfileQuery { user_id: user_id.value(), }, ) @@ -498,7 +502,7 @@ pub async fn update_profile_handler( } } - let cmd = application::commands::UpdateProfileCommand { + let cmd = application::users::commands::UpdateProfileCommand { user_id: user_id.value(), display_name, bio, @@ -552,7 +556,7 @@ pub async fn update_profile_fields_handler( }) .collect(); - let cmd = application::commands::UpdateProfileFieldsCommand { + let cmd = application::users::commands::UpdateProfileFieldsCommand { user_id: user_id.value(), fields, }; @@ -1066,14 +1070,15 @@ pub async fn get_user_profile( Query(params): Query, ) -> impl IntoResponse { let view_str = params.view.as_deref().unwrap_or("recent"); - let profile_view = match application::queries::ProfileView::from_str(view_str) { + let profile_view = match application::users::queries::ProfileView::from_str(view_str) { Ok(v) => v, Err(_) => return StatusCode::BAD_REQUEST.into_response(), }; let user = match state .app_ctx - .user_repository + .repos + .user .find_by_id(&UserId::from_uuid(user_id)) .await { diff --git a/crates/presentation/src/handlers/html.rs b/crates/presentation/src/handlers/html.rs index ca4a388..98e19e5 100644 --- a/crates/presentation/src/handlers/html.rs +++ b/crates/presentation/src/handlers/html.rs @@ -4,40 +4,53 @@ use axum::{ Form, extract::{Extension, Multipart, Path, Query, State}, http::{HeaderValue, StatusCode, header::SET_COOKIE}, - response::{Html, IntoResponse, Redirect}, + response::{IntoResponse, Redirect}, }; use chrono::Utc; use uuid::Uuid; -#[cfg(feature = "federation")] -use application::ports::{ - BlockedActorEntry, BlockedActorsPageData, BlockedDomainEntry, BlockedDomainsPageData, - FollowersPageData, FollowingPageData, -}; use application::{ - commands::{ - AddToWatchlistCommand, ConfirmWatchEventsCommand, DeleteReviewCommand, - DismissWatchEventsCommand, GenerateWebhookTokenCommand, MovieInput, - RemoveFromWatchlistCommand, RevokeWebhookTokenCommand, WatchEventConfirmation, + auth::{login as login_uc, queries::LoginQuery}, + diary::{ + commands::{DeleteReviewCommand, MovieInput}, + delete_review, export_diary as export_diary_uc, get_movie_social_page, log_review, + queries::{ExportQuery, GetMovieSocialPageQuery}, }, - ports::{ - HtmlPageContext, IntegrationsPageData, LoginPageData, MovieDetailPageData, - NewReviewPageData, ProfileSettingsPageData, RegisterPageData, RemoteActorView, - WatchQueueDisplayEntry, WatchQueuePageData, WatchlistPageData, WebhookTokenView, + integrations::{ + commands::{ + ConfirmWatchEventsCommand, DismissWatchEventsCommand, GenerateWebhookTokenCommand, + RevokeWebhookTokenCommand, WatchEventConfirmation, + }, + confirm as confirm_watch_events, dismiss as dismiss_watch_events, + generate_token as generate_webhook_token, get_queue as get_watch_queue, + get_tokens as get_webhook_tokens, + queries::{GetWatchQueueQuery, GetWebhookTokensQuery}, + revoke_token as revoke_webhook_token, }, - queries::{ - ExportQuery, GetMovieSocialPageQuery, GetWatchQueueQuery, GetWebhookTokensQuery, - IsOnWatchlistQuery, LoginQuery, - }, - use_cases::{ - add_to_watchlist, confirm_watch_events, delete_review, dismiss_watch_events, - export_diary as export_diary_uc, generate_webhook_token, get_movie_social_page, - get_watch_queue, get_webhook_tokens, is_on_watchlist, log_review, login as login_uc, - remove_from_watchlist, revoke_webhook_token, update_profile, update_profile_fields, + users::{update_profile, update_profile_fields}, + watchlist::{ + add as add_to_watchlist, + commands::{AddToWatchlistCommand, RemoveFromWatchlistCommand}, + is_on as is_on_watchlist, + queries::IsOnWatchlistQuery, + remove as remove_from_watchlist, }, }; + +use crate::render::render_page; +use application::ports::HtmlPageContext; use domain::models::ExportFormat; use domain::{errors::DomainError, value_objects::UserId}; +use template_askama::{ + ActivityFeedTemplate, IntegrationsTemplate, LoginTemplate, MonthlyRatingRow, + MovieDetailTemplate, NewReviewTemplate, ProfileSettingsTemplate, ProfileTemplate, + RegisterTemplate, RemoteActorData, RemoteActorDisplay, UserSummaryView, UsersTemplate, + WatchQueueTemplate, WatchlistTemplate, bar_height_px, build_heatmap, build_page_items, +}; +#[cfg(feature = "federation")] +use template_askama::{ + BlockedActorsTemplate, BlockedDomainsTemplate, FollowersTemplate, FollowingTemplate, +}; #[cfg(feature = "federation")] use crate::forms::{ @@ -57,13 +70,7 @@ pub(crate) async fn build_page_context( ) -> HtmlPageContext { let uuid = user_id.as_ref().map(|u| u.value()); let (user_email, is_admin) = if let Some(ref id) = user_id { - let user = state - .app_ctx - .user_repository - .find_by_id(id) - .await - .ok() - .flatten(); + let user = state.app_ctx.repos.user.find_by_id(id).await.ok().flatten(); let email = user.as_ref().map(|u| u.email().value().to_string()); let admin = user .as_ref() @@ -128,14 +135,10 @@ pub async fn get_login_page( csrf_token: csrf.0, page_rss_url: None, }; - let html = state - .html_renderer - .render_login_page(LoginPageData { - ctx, - error: params.error.as_deref(), - }) - .expect("login template failed"); - Html(html) + render_page(LoginTemplate { + ctx: &ctx, + error: params.error.as_deref(), + }) } pub async fn post_login( @@ -195,14 +198,11 @@ pub async fn get_register_page( csrf_token: csrf.0, page_rss_url: None, }; - let html = state - .html_renderer - .render_register_page(RegisterPageData { - ctx, - error: params.error.as_deref(), - }) - .expect("register template failed"); - Html(html).into_response() + render_page(RegisterTemplate { + ctx: &ctx, + error: params.error.as_deref(), + }) + .into_response() } pub async fn post_register( @@ -216,9 +216,9 @@ pub async fn post_register( if crate::csrf::mismatch(&csrf, &form.csrf_token) { return StatusCode::FORBIDDEN.into_response(); } - match application::use_cases::register_and_login::execute( + match application::auth::register_and_login::execute( &state.app_ctx, - application::commands::RegisterAndLoginCommand { + application::auth::commands::RegisterAndLoginCommand { email: form.email, username: form.username, password: form.password, @@ -246,14 +246,10 @@ pub async fn get_new_review_page( let mut ctx = build_page_context(&state, Some(user_id), csrf.0).await; ctx.page_title = "Log a Review — Movies Diary".to_string(); ctx.canonical_url = format!("{}/reviews/new", state.app_ctx.config.base_url); - let html = state - .html_renderer - .render_new_review_page(NewReviewPageData { - ctx, - error: params.error.as_deref(), - }) - .expect("new_review template failed"); - Html(html) + render_page(NewReviewTemplate { + ctx: &ctx, + error: params.error.as_deref(), + }) } pub async fn post_review( @@ -374,7 +370,7 @@ pub async fn get_activity_feed( _ => "date", }; - let query = application::queries::GetActivityFeedQuery { + let query = application::diary::queries::GetActivityFeedQuery { limit, offset, sort_by: sort_by_str.parse().unwrap_or_default(), @@ -387,26 +383,30 @@ pub async fn get_activity_feed( filter_following, }; - match application::use_cases::get_activity_feed::execute(&state.app_ctx, query).await { + match application::diary::get_activity_feed::execute(&state.app_ctx, query).await { Ok(entries) => { let entry_limit = entries.limit; let entry_offset = entries.offset; let has_more = (entry_offset as u64).saturating_add(entry_limit as u64) < entries.total_count; - let data = application::ports::ActivityFeedPageData { - ctx, + let total_pages = (entries.total_count as u32) + .saturating_add(entry_limit.saturating_sub(1)) + .checked_div(entry_limit) + .unwrap_or(1); + let current_page = entry_offset.checked_div(entry_limit).unwrap_or(0); + let page_items = build_page_items(total_pages, current_page); + render_page(ActivityFeedTemplate { + entries: entries.items.as_slice(), current_offset: entry_offset, - has_more, limit: entry_limit, - entries, + has_more, + ctx: &ctx, + page_items, filter: filter_str.to_string(), sort_by: sort_by_str.to_string(), search: params.search, - }; - match state.html_renderer.render_activity_feed_page(data) { - Ok(html) => Html(html).into_response(), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), - } + }) + .into_response() } Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), } @@ -421,32 +421,54 @@ pub async fn get_users_list( ctx.page_title = "Members — Movies Diary".to_string(); ctx.canonical_url = format!("{}/users", state.app_ctx.config.base_url); - match application::use_cases::get_users::execute( + match application::users::get_users::execute( &state.app_ctx, - application::queries::GetUsersQuery, + application::users::queries::GetUsersQuery, ) .await { Ok(result) => { - let actor_views = result - .remote_actors + let users: Vec = result + .users .into_iter() - .map(|a| application::ports::RemoteActorView { - handle: a.handle, - display_name: a.display_name, - url: a.url, - avatar_url: None, + .map(|u| { + let name = u.email().split('@').next().unwrap_or("?").to_string(); + let initial = name.chars().next().unwrap_or('?').to_ascii_uppercase(); + let avg_display = u + .avg_rating + .map(|r| format!("{:.1}", r)) + .unwrap_or_else(|| "—".to_string()); + let avatar_url = u.avatar_path.map(|p| format!("/images/{}", p)); + UserSummaryView { + user_id: u.user_id.value(), + display_name: name, + initial, + avg_rating_display: avg_display, + total_movies: u.total_movies, + avatar_url, + } }) .collect(); - let data = application::ports::UsersPageData { - ctx, - users: result.users, - remote_actors: actor_views, - }; - match state.html_renderer.render_users_page(data) { - Ok(html) => Html(html).into_response(), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(), - } + let remote_actors: Vec = result + .remote_actors + .into_iter() + .map(|a| { + let display = a.display_name.unwrap_or_else(|| a.handle.clone()); + let initial = display.chars().next().unwrap_or('?').to_ascii_uppercase(); + RemoteActorDisplay { + handle: a.handle, + display_name: display, + initial, + url: a.url, + } + }) + .collect(); + render_page(UsersTemplate { + users, + ctx: &ctx, + remote_actors, + }) + .into_response() } Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), } @@ -460,7 +482,7 @@ pub async fn get_user_by_username( Ok(u) => u, Err(_) => return StatusCode::NOT_FOUND.into_response(), }; - match state.app_ctx.user_repository.find_by_username(&uname).await { + match state.app_ctx.repos.user.find_by_username(&uname).await { Ok(Some(user)) => { axum::response::Redirect::permanent(&format!("/users/{}", user.id().value())) .into_response() @@ -505,7 +527,7 @@ pub async fn get_user_profile( let mut ctx = build_page_context(&state, user_id.clone(), csrf.0).await; let view_str = params.view.as_deref().unwrap_or("recent"); - let profile_view = match application::queries::ProfileView::from_str(view_str) { + let profile_view = match application::users::queries::ProfileView::from_str(view_str) { Ok(v) => v, Err(_) => { return ( @@ -518,7 +540,8 @@ pub async fn get_user_profile( let profile_user = match state .app_ctx - .user_repository + .repos + .user .find_by_id(&domain::value_objects::UserId::from_uuid(profile_user_uuid)) .await { @@ -546,7 +569,7 @@ pub async fn get_user_profile( .map(|u| u.value() == profile_user_uuid) .unwrap_or(false); - let query = application::queries::GetUserProfileQuery { + let query = application::users::queries::GetUserProfileQuery { user_id: profile_user_uuid, view: profile_view, limit: params.limit, @@ -560,7 +583,7 @@ pub async fn get_user_profile( is_own_profile, }; - match application::use_cases::get_user_profile::execute(&state.app_ctx, query).await { + match application::users::get_profile::execute(&state.app_ctx, query).await { Ok(profile) => { let (offset, has_more, limit) = profile .entries @@ -573,28 +596,80 @@ pub async fn get_user_profile( if !is_own_profile { ctx.page_rss_url = Some(format!("/users/{}/feed.rss", profile_user_uuid)); } - let pending_followers: Vec = profile + let email = profile_user.email().value().to_string(); + let display_name = email.split('@').next().unwrap_or("?").to_string(); + let avg_rating_display = profile + .stats + .avg_rating + .map(|r| format!("{:.1}", r)) + .unwrap_or_else(|| "—".to_string()); + let favorite_director_display = profile + .stats + .favorite_director + .clone() + .unwrap_or_else(|| "—".to_string()); + let most_active_month_display = profile + .stats + .most_active_month + .clone() + .unwrap_or_else(|| "—".to_string()); + let heatmap = profile + .history + .as_deref() + .map(build_heatmap) + .unwrap_or_default(); + let monthly_rating_rows: Vec> = profile + .trends + .as_ref() + .map(|t| { + t.monthly_ratings + .iter() + .map(|r| MonthlyRatingRow { + rating: r, + bar_height_px: bar_height_px(r.avg_rating), + }) + .collect() + }) + .unwrap_or_default(); + let total = profile + .entries + .as_ref() + .map(|e| e.total_count as u32) + .unwrap_or(0); + let total_pages = total + .saturating_add(limit.saturating_sub(1)) + .checked_div(limit) + .unwrap_or(1); + let current_page = offset.checked_div(limit).unwrap_or(0); + let page_items = build_page_items(total_pages, current_page); + let pending_followers: Vec = profile .pending_followers .into_iter() - .map(|p| application::ports::RemoteActorView { + .map(|p| RemoteActorData { handle: p.handle, url: p.url, display_name: p.display_name, avatar_url: p.avatar_url, }) .collect(); - let data = application::ports::ProfilePageData { - ctx, + render_page(ProfileTemplate { + ctx: &ctx, + profile_display_name: display_name, profile_user_id: profile_user_uuid, - profile_user_email: profile_user.email().value().to_string(), - stats: profile.stats, - view: profile_view.as_str().to_string(), - entries: profile.entries, + stats: &profile.stats, + avg_rating_display, + favorite_director_display, + most_active_month_display, + view: profile_view.as_str(), + entries: profile.entries.as_ref(), current_offset: offset, has_more, limit, - history: profile.history, - trends: profile.trends, + history: profile.history.as_ref(), + trends: profile.trends.as_ref(), + monthly_rating_rows, + heatmap, + page_items, is_own_profile, error: params.error, following_count: profile.following_count, @@ -602,11 +677,8 @@ pub async fn get_user_profile( pending_followers, sort_by: sort_by_str.to_string(), search: params.search.clone(), - }; - match state.html_renderer.render_profile_page(data) { - Ok(html) => Html(html).into_response(), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(), - } + }) + .into_response() } Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), } @@ -818,25 +890,22 @@ pub async fn get_following_page( ); match state.ap_service.get_following(user_id.value()).await { Ok(following) => { - let actors = following + let actors: Vec = following .into_iter() - .map(|a| RemoteActorView { + .map(|a| RemoteActorData { handle: a.handle, display_name: a.display_name, url: a.url, avatar_url: a.avatar_url.clone(), }) .collect(); - let data = FollowingPageData { + render_page(FollowingTemplate { ctx, user_id: profile_user_uuid, actors, error: params.error, - }; - match state.html_renderer.render_following_page(data) { - Ok(html) => Html(html).into_response(), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(), - } + }) + .into_response() } Err(e) => { tracing::error!("get_following error: {:?}", e); @@ -872,25 +941,22 @@ pub async fn get_followers_page( .await { Ok(followers) => { - let actors = followers + let actors: Vec = followers .into_iter() - .map(|a| RemoteActorView { + .map(|a| RemoteActorData { handle: a.handle, display_name: a.display_name, url: a.url, avatar_url: a.avatar_url.clone(), }) .collect(); - let data = FollowersPageData { + render_page(FollowersTemplate { ctx, user_id: profile_user_uuid, actors, error: params.error, - }; - match state.html_renderer.render_followers_page(data) { - Ok(html) => Html(html).into_response(), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(), - } + }) + .into_response() } Err(e) => { tracing::error!("get_followers error: {:?}", e); @@ -985,25 +1051,21 @@ pub async fn get_movie_detail( .unwrap_or(false), None => false, }; - let data = MovieDetailPageData { - ctx, - movie: result.movie, - stats: result.stats, - profile: result.profile, + let current_offset = result.reviews.offset; + let reviews_limit = result.reviews.limit; + render_page(MovieDetailTemplate { + ctx: &ctx, + movie: &result.movie, + stats: &result.stats, + profile: result.profile.as_ref(), + reviews: result.reviews.items.as_slice(), on_watchlist, - current_offset: result.reviews.offset, + current_offset, has_more, - limit: result.reviews.limit, - reviews: result.reviews, + limit: reviews_limit, histogram_max, - }; - match state.html_renderer.render_movie_detail_page(data) { - Ok(html) => Html(html).into_response(), - Err(e) => { - tracing::error!("template error: {}", e); - StatusCode::INTERNAL_SERVER_ERROR.into_response() - } - } + }) + .into_response() } } } @@ -1018,9 +1080,9 @@ pub async fn get_watchlist_page( let ctx = build_page_context(&state, viewer_id.clone(), csrf.0).await; let is_owner = viewer_id.map(|u| u.value() == owner_id).unwrap_or(false); - let result = match application::use_cases::get_watchlist_page::execute( + let result = match application::watchlist::get_page::execute( &state.app_ctx, - application::queries::GetWatchlistQuery { + application::watchlist::queries::GetWatchlistQuery { user_id: owner_id, limit: params.limit.or(Some(20)), offset: params.offset.or(Some(0)), @@ -1036,23 +1098,17 @@ pub async fn get_watchlist_page( } }; - let data = WatchlistPageData { - ctx, + render_page(WatchlistTemplate { + ctx: &ctx, owner_id, - display_entries: result.display_entries, + display_entries: &result.display_entries, current_offset: result.current_offset, has_more: result.has_more, limit: result.limit, is_owner, error: params.error, - }; - match state.html_renderer.render_watchlist_page(data) { - Ok(html) => Html(html).into_response(), - Err(e) => { - tracing::error!("watchlist template error: {}", e); - StatusCode::INTERNAL_SERVER_ERROR.into_response() - } - } + }) + .into_response() } pub async fn post_watchlist_add( @@ -1180,7 +1236,7 @@ pub async fn get_profile_settings( ctx.page_title = "Profile Settings — Movies Diary".to_string(); ctx.canonical_url = format!("{}/settings/profile", state.app_ctx.config.base_url); - let user = match state.app_ctx.user_repository.find_by_id(&user_id).await { + let user = match state.app_ctx.repos.user.find_by_id(&user_id).await { Ok(Some(u)) => u, Ok(None) => return StatusCode::NOT_FOUND.into_response(), Err(e) => { @@ -1197,9 +1253,10 @@ pub async fn get_profile_settings( .banner_path() .map(|path| format!("{}/images/{}", base_url, path)); - let profile_fields = state + let profile_fields: Vec<(String, String)> = state .app_ctx - .profile_fields_repository + .repos + .profile_fields .get_fields(&user_id) .await .unwrap_or_default() @@ -1209,23 +1266,19 @@ pub async fn get_profile_settings( let saved = params.saved.as_deref() == Some("1"); - let data = ProfileSettingsPageData { - ctx, - bio: user.bio().map(|s| s.to_string()), - avatar_url, - banner_url, - also_known_as: user.also_known_as().map(|s| s.to_string()), - profile_fields, - saved, - }; + let bio = user.bio().map(|s| s.to_string()); + let also_known_as = user.also_known_as().map(|s| s.to_string()); - match state.html_renderer.render_profile_settings_page(data) { - Ok(html) => Html(html).into_response(), - Err(e) => { - tracing::error!("profile_settings template error: {}", e); - StatusCode::INTERNAL_SERVER_ERROR.into_response() - } - } + render_page(ProfileSettingsTemplate { + ctx: &ctx, + bio: bio.as_deref(), + avatar_url: avatar_url.as_deref(), + banner_url: banner_url.as_deref(), + also_known_as: also_known_as.as_deref(), + profile_fields: &profile_fields, + saved, + }) + .into_response() } pub async fn get_tag(Path(tag): Path) -> impl IntoResponse { @@ -1247,21 +1300,19 @@ pub async fn get_blocked_domains_page( ctx.canonical_url = format!("{}/admin/blocked-domains", state.app_ctx.config.base_url); match state.ap_service.get_blocked_domains().await { Ok(domains) => { - let data = BlockedDomainsPageData { - ctx, - domains: domains - .into_iter() - .map(|d| BlockedDomainEntry { - domain: d.domain, - reason: d.reason, - blocked_at: d.blocked_at, - }) - .collect(), - }; - match state.html_renderer.render_blocked_domains_page(data) { - Ok(html) => Html(html).into_response(), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(), - } + let entries: Vec = domains + .into_iter() + .map(|d| template_askama::BlockedDomainEntry { + domain: d.domain, + reason: d.reason, + blocked_at: d.blocked_at, + }) + .collect(); + render_page(BlockedDomainsTemplate { + ctx: &ctx, + domains: &entries, + }) + .into_response() } Err(e) => { tracing::error!("get_blocked_domains error: {:?}", e); @@ -1328,22 +1379,20 @@ pub async fn get_blocked_actors_page( ctx.canonical_url = format!("{}/social/blocked", state.app_ctx.config.base_url); match state.ap_service.get_blocked_actors(user_id.value()).await { Ok(actors) => { - let data = BlockedActorsPageData { - ctx, - actors: actors - .into_iter() - .map(|a| BlockedActorEntry { - url: a.url, - handle: a.handle, - display_name: a.display_name, - avatar_url: a.avatar_url, - }) - .collect(), - }; - match state.html_renderer.render_blocked_actors_page(data) { - Ok(html) => Html(html).into_response(), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(), - } + let entries: Vec = actors + .into_iter() + .map(|a| template_askama::BlockedActorEntry { + url: a.url, + handle: a.handle, + display_name: a.display_name, + avatar_url: a.avatar_url, + }) + .collect(); + render_page(BlockedActorsTemplate { + ctx: &ctx, + actors: &entries, + }) + .into_response() } Err(e) => { tracing::error!("get_blocked_actors error: {:?}", e); @@ -1475,7 +1524,7 @@ pub async fn post_profile_settings( } } - let cmd = application::commands::UpdateProfileCommand { + let cmd = application::users::commands::UpdateProfileCommand { user_id: user_id.value(), display_name, bio, @@ -1498,7 +1547,7 @@ pub async fn post_profile_settings( }) .collect(); - let fields_cmd = application::commands::UpdateProfileFieldsCommand { + let fields_cmd = application::users::commands::UpdateProfileFieldsCommand { user_id: user_id.value(), fields, }; @@ -1526,9 +1575,9 @@ pub async fn get_integrations_page( .await .unwrap_or_default(); - let token_views: Vec = tokens + let token_views: Vec = tokens .into_iter() - .map(|t| WebhookTokenView { + .map(|t| template_askama::WebhookTokenView { id: t.id().value().to_string(), provider: t.provider().to_string(), label: t.label().map(String::from), @@ -1539,20 +1588,14 @@ pub async fn get_integrations_page( }) .collect(); - let data = IntegrationsPageData { - ctx, - tokens: token_views, - webhook_base_url: state.app_ctx.config.base_url.clone(), - new_token: params.token, - }; - - match state.html_renderer.render_integrations_page(data) { - Ok(html) => Html(html).into_response(), - Err(e) => { - tracing::error!("integrations template error: {}", e); - StatusCode::INTERNAL_SERVER_ERROR.into_response() - } - } + let webhook_base_url = state.app_ctx.config.base_url.clone(); + render_page(IntegrationsTemplate { + ctx: &ctx, + tokens: &token_views, + webhook_base_url: &webhook_base_url, + new_token: params.token.as_deref(), + }) + .into_response() } pub async fn post_generate_token( @@ -1632,9 +1675,9 @@ pub async fn get_watch_queue_page( .await .unwrap_or_default(); - let entries: Vec = events + let entries: Vec = events .into_iter() - .map(|e| WatchQueueDisplayEntry { + .map(|e| template_askama::WatchQueueDisplayEntry { id: e.id().value().to_string(), title: e.title().to_string(), year: e.year(), @@ -1644,19 +1687,12 @@ pub async fn get_watch_queue_page( }) .collect(); - let data = WatchQueuePageData { - ctx, - entries, - error: params.error, - }; - - match state.html_renderer.render_watch_queue_page(data) { - Ok(html) => Html(html).into_response(), - Err(e) => { - tracing::error!("watch_queue template error: {}", e); - StatusCode::INTERNAL_SERVER_ERROR.into_response() - } - } + render_page(WatchQueueTemplate { + ctx: &ctx, + entries: &entries, + error: params.error.as_deref(), + }) + .into_response() } pub async fn post_confirm_single( diff --git a/crates/presentation/src/handlers/import.rs b/crates/presentation/src/handlers/import.rs index 7ebfbdd..47e7af3 100644 --- a/crates/presentation/src/handlers/import.rs +++ b/crates/presentation/src/handlers/import.rs @@ -11,25 +11,26 @@ use axum::{ use serde::Deserialize; use std::collections::HashMap; -use application::{ +use crate::render::render_page; +use application::import::{ + apply_mapping as apply_import_mapping, commands::{ ApplyImportMappingCommand, CreateImportSessionCommand, DeleteImportProfileCommand, ExecuteImportCommand, SaveImportProfileCommand, }, - ports::{ - ImportMappingPageData, ImportPreviewPageData, ImportPreviewRow, ImportProfileView, - ImportRowStatus, ImportUploadPageData, - }, - use_cases::{ - apply_import_mapping, create_import_session, delete_import_profile, execute_import, - list_import_profiles, save_import_profile, - }, + create_session as create_import_session, delete_profile as delete_import_profile, + execute as execute_import, list_profiles as list_import_profiles, + save_profile as save_import_profile, }; use domain::models::{ AnnotatedRow, FieldMapping, FileFormat, import::{DomainField, RowResult, Transform}, }; use domain::value_objects::ImportSessionId; +use template_askama::{ + ImportMappingTemplate, ImportPreviewRow, ImportPreviewTemplate, ImportProfileView, + ImportRowStatus, ImportUploadTemplate, +}; use crate::{ csrf::CsrfToken, @@ -143,15 +144,11 @@ pub async fn get_import_page( name: p.name, }) .collect::>(); - let html = state - .html_renderer - .render_import_upload_page(ImportUploadPageData { - ctx, - profiles, - error: None, - }) - .unwrap_or_else(|e| e); - Html(html) + render_page(ImportUploadTemplate { + ctx: &ctx, + profiles: &profiles, + error: None, + }) } pub async fn post_upload( @@ -220,7 +217,8 @@ pub async fn get_mapping_page( }; let Ok(Some(session)) = state .app_ctx - .import_session_repository + .repos + .import_session .get(&session_id, &user_id) .await else { @@ -231,27 +229,25 @@ pub async fn get_mapping_page( }; let ctx = super::html::build_page_context(&state, Some(user_id), csrf.0).await; - let sample_rows = parsed.rows.into_iter().take(5).collect(); - let html = state - .html_renderer - .render_import_mapping_page(ImportMappingPageData { - ctx, - session_id: session_id_str, - columns: parsed.columns, - sample_rows, - domain_fields: vec![ - ("title", "Title"), - ("release_year", "Release Year"), - ("director", "Director"), - ("rating", "Rating"), - ("watched_at", "Watched At"), - ("comment", "Comment"), - ("external_metadata_id", "External ID"), - ], - error: None, - }) - .unwrap_or_else(|e| e); - Html(html).into_response() + let sample_rows: Vec> = parsed.rows.into_iter().take(5).collect(); + let domain_fields: Vec<(&str, &str)> = vec![ + ("title", "Title"), + ("release_year", "Release Year"), + ("director", "Director"), + ("rating", "Rating"), + ("watched_at", "Watched At"), + ("comment", "Comment"), + ("external_metadata_id", "External ID"), + ]; + render_page(ImportMappingTemplate { + ctx: &ctx, + session_id: &session_id_str, + columns: &parsed.columns, + sample_rows: &sample_rows, + domain_fields: &domain_fields, + error: None, + }) + .into_response() } pub async fn post_mapping( @@ -313,7 +309,8 @@ pub async fn get_preview_page( }; let Ok(Some(session)) = state .app_ctx - .import_session_repository + .repos + .import_session .get(&session_id, &user_id) .await else { @@ -334,16 +331,13 @@ pub async fn get_preview_page( .collect(); let ctx = super::html::build_page_context(&state, Some(user_id), csrf.0).await; - let html = state - .html_renderer - .render_import_preview_page(ImportPreviewPageData { - ctx, - session_id: session_id_str, - columns: parsed.columns, - rows, - }) - .unwrap_or_else(|e| e); - Html(html).into_response() + render_page(ImportPreviewTemplate { + ctx: &ctx, + session_id: &session_id_str, + columns: &parsed.columns, + rows: &rows, + }) + .into_response() } pub async fn post_confirm( @@ -571,7 +565,8 @@ pub async fn api_get_session( }; match state .app_ctx - .import_session_repository + .repos + .import_session .get(&session_id, &user_id) .await { diff --git a/crates/presentation/src/handlers/rss.rs b/crates/presentation/src/handlers/rss.rs index 10bc0a4..31c1239 100644 --- a/crates/presentation/src/handlers/rss.rs +++ b/crates/presentation/src/handlers/rss.rs @@ -5,7 +5,7 @@ use axum::{ }; use uuid::Uuid; -use application::{queries::GetDiaryQuery, use_cases::get_diary}; +use application::{diary::get_diary, diary::queries::GetDiaryQuery}; use domain::{errors::DomainError, models::SortDirection, value_objects::UserId}; use crate::{errors::ApiError, state::AppState}; @@ -35,7 +35,8 @@ pub async fn get_user_feed( ) -> Result { let user = state .app_ctx - .user_repository + .repos + .user .find_by_id(&UserId::from_uuid(user_id)) .await .map_err(ApiError)? diff --git a/crates/presentation/src/handlers/webhook.rs b/crates/presentation/src/handlers/webhook.rs index d527767..2b5b6a7 100644 --- a/crates/presentation/src/handlers/webhook.rs +++ b/crates/presentation/src/handlers/webhook.rs @@ -10,16 +10,16 @@ use api_types::{ ConfirmWatchRequest, ConfirmWatchResponse, DismissWatchRequest, DismissWatchResponse, GenerateTokenRequest, GenerateTokenResponse, WatchQueueEntryDto, WebhookTokenDto, }; -use application::{ +use application::integrations::{ commands::{ ConfirmWatchEventsCommand, DismissWatchEventsCommand, GenerateWebhookTokenCommand, IngestWatchEventCommand, RevokeWebhookTokenCommand, WatchEventConfirmation, }, + confirm as confirm_watch_events, dismiss as dismiss_watch_events, + generate_token as generate_webhook_token, get_queue as get_watch_queue, + get_tokens as get_webhook_tokens, ingest as ingest_watch_event, queries::{GetWatchQueueQuery, GetWebhookTokensQuery}, - use_cases::{ - confirm_watch_events, dismiss_watch_events, generate_webhook_token, get_watch_queue, - get_webhook_tokens, ingest_watch_event, revoke_webhook_token, - }, + revoke_token as revoke_webhook_token, }; use domain::models::WatchEventSource;