refactor: group use cases into DDD bounded contexts

Flat use_cases/ (44 files) + monolithic commands.rs/queries.rs
split into diary/, movies/, watchlist/, import/, auth/, users/,
integrations/, search/, person/, federation/ — each with own
commands.rs, queries.rs, and use case modules.

Inline tests extracted to sibling tests/ dirs.
This commit is contained in:
2026-06-02 19:49:09 +02:00
parent aadad3cfb0
commit dcc9244d4e
92 changed files with 1617 additions and 1500 deletions

View File

@@ -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,
}

View File

@@ -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<Utc>,
}
pub async fn execute(ctx: &AppContext, query: LoginQuery) -> Result<LoginResult, DomainError> {
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;

View File

@@ -0,0 +1,5 @@
pub mod commands;
pub mod login;
pub mod queries;
pub mod register;
pub mod register_and_login;

View File

@@ -0,0 +1,4 @@
pub struct LoginQuery {
pub email: String,
pub password: String,
}

View File

@@ -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;

View File

@@ -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<login::LoginResult, DomainError> {
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,
},

View File

@@ -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());
}

View File

@@ -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");
}

View File

@@ -1,145 +0,0 @@
use chrono::NaiveDateTime;
use domain::models::{FieldMapping, FileFormat, UserRole};
use uuid::Uuid;
pub struct MovieInput {
pub movie_id: Option<Uuid>,
pub external_metadata_id: Option<String>,
pub manual_title: Option<String>,
pub manual_release_year: Option<u16>,
pub manual_director: Option<String>,
}
pub struct LogReviewCommand {
pub user_id: Uuid,
pub input: MovieInput,
pub rating: u8,
pub comment: Option<String>,
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<u8>,
pub format: FileFormat,
}
pub struct ApplyImportMappingCommand {
pub user_id: Uuid,
pub session_id: Uuid,
pub mappings: Vec<FieldMapping>,
}
pub struct ExecuteImportCommand {
pub user_id: Uuid,
pub session_id: Uuid,
pub confirmed_indices: Vec<usize>,
}
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<u8>,
pub source: domain::models::WatchEventSource,
}
pub struct WatchEventConfirmation {
pub watch_event_id: Uuid,
pub rating: u8,
pub comment: Option<String>,
}
pub struct ConfirmWatchEventsCommand {
pub user_id: Uuid,
pub confirmations: Vec<WatchEventConfirmation>,
}
pub struct DismissWatchEventsCommand {
pub user_id: Uuid,
pub event_ids: Vec<Uuid>,
}
pub struct GenerateWebhookTokenCommand {
pub user_id: Uuid,
pub provider: domain::models::WatchEventSource,
pub label: Option<String>,
}
pub struct RevokeWebhookTokenCommand {
pub user_id: Uuid,
pub token_id: Uuid,
}
pub struct UpdateProfileCommand {
pub user_id: Uuid,
pub display_name: Option<String>,
pub bio: Option<String>,
pub avatar_bytes: Option<Vec<u8>>,
pub avatar_content_type: Option<String>,
pub banner_bytes: Option<Vec<u8>>,
pub banner_content_type: Option<String>,
pub also_known_as: Option<String>,
}
pub struct UpdateProfileFieldsCommand {
pub user_id: Uuid,
pub fields: Vec<domain::models::ProfileField>,
}
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,
}

View File

@@ -0,0 +1,28 @@
use chrono::NaiveDateTime;
use uuid::Uuid;
pub struct MovieInput {
pub movie_id: Option<Uuid>,
pub external_metadata_id: Option<String>,
pub manual_title: Option<String>,
pub manual_release_year: Option<u16>,
pub manual_director: Option<String>,
}
pub struct LogReviewCommand {
pub user_id: Uuid,
pub input: MovieInput,
pub rating: u8,
pub comment: Option<String>,
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,
}

View File

@@ -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;

View File

@@ -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<Vec<u8>, 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
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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)?;

View File

@@ -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(())
}

View File

@@ -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;

View File

@@ -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,

View File

@@ -0,0 +1,34 @@
use domain::models::SortDirection;
use uuid::Uuid;
pub struct GetDiaryQuery {
pub limit: Option<u32>,
pub offset: Option<u32>,
pub sort_by: Option<SortDirection>,
pub movie_id: Option<Uuid>,
pub user_id: Option<Uuid>,
}
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<String>,
pub viewer_user_id: Option<Uuid>,
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,
}

View File

@@ -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");
}

View File

@@ -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");
}

View File

@@ -1,5 +1,5 @@
use super::*;
use crate::commands::MovieInput;
use crate::diary::commands::MovieInput;
use domain::{
errors::DomainError,
models::Movie,

View File

@@ -6,7 +6,5 @@ pub async fn execute(
ctx: &AppContext,
uuid: uuid::Uuid,
) -> Result<Vec<RemoteWatchlistEntry>, DomainError> {
ctx.remote_watchlist_repository
.get_by_derived_uuid(uuid)
.await
ctx.repos.remote_watchlist.get_by_derived_uuid(uuid).await
}

View File

@@ -0,0 +1 @@
pub mod get_remote_watchlist;

View File

@@ -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);
}

View File

@@ -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
}

View File

@@ -2,5 +2,5 @@ use crate::context::AppContext;
use domain::errors::DomainError;
pub async fn execute(ctx: &AppContext) -> Result<u64, DomainError> {
ctx.import_session_repository.delete_expired().await
ctx.repos.import_session.delete_expired().await
}

View File

@@ -0,0 +1,37 @@
use domain::models::{FieldMapping, FileFormat};
use uuid::Uuid;
pub struct CreateImportSessionCommand {
pub user_id: Uuid,
pub bytes: Vec<u8>,
pub format: FileFormat,
}
pub struct ApplyImportMappingCommand {
pub user_id: Uuid,
pub session_id: Uuid,
pub mappings: Vec<FieldMapping>,
}
pub struct ExecuteImportCommand {
pub user_id: Uuid,
pub session_id: Uuid,
pub confirmed_indices: Vec<usize>,
}
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,
}

View File

@@ -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<CreateSessionResult, DomainError> {
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,

View File

@@ -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
}

View File

@@ -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,

View File

@@ -5,5 +5,5 @@ pub async fn execute(
ctx: &AppContext,
user_id: &UserId,
) -> Result<Vec<ImportProfile>, DomainError> {
ctx.import_profile_repository.list_for_user(user_id).await
ctx.repos.import_profile.list_for_user(user_id).await
}

View File

@@ -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;

View File

@@ -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)
}

View File

@@ -5,7 +5,8 @@ use crate::context::AppContext;
pub async fn execute(ctx: &AppContext) -> Result<u64, DomainError> {
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
}

View File

@@ -0,0 +1,34 @@
use uuid::Uuid;
pub struct IngestWatchEventCommand {
pub token: String,
pub raw_payload: Vec<u8>,
pub source: domain::models::WatchEventSource,
}
pub struct WatchEventConfirmation {
pub watch_event_id: Uuid,
pub rating: u8,
pub comment: Option<String>,
}
pub struct ConfirmWatchEventsCommand {
pub user_id: Uuid,
pub confirmations: Vec<WatchEventConfirmation>,
}
pub struct DismissWatchEventsCommand {
pub user_id: Uuid,
pub event_ids: Vec<Uuid>,
}
pub struct GenerateWebhookTokenCommand {
pub user_id: Uuid,
pub provider: domain::models::WatchEventSource,
pub label: Option<String>,
}
pub struct RevokeWebhookTokenCommand {
pub user_id: Uuid,
pub token_id: Uuid,
}

View File

@@ -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<u32, DomainError> {
@@ -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?;

View File

@@ -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<u32, DomainError> {
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?;

View File

@@ -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,

View File

@@ -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<Vec<WatchEvent>, 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
}

View File

@@ -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<Vec<WebhookToken>, 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
}

View File

@@ -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(),

View File

@@ -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;

View File

@@ -0,0 +1,9 @@
use uuid::Uuid;
pub struct GetWatchQueueQuery {
pub user_id: Uuid,
}
pub struct GetWebhookTokensQuery {
pub user_id: Uuid,
}

View File

@@ -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
}

View File

@@ -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(())
}

View File

@@ -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;

View File

@@ -0,0 +1,4 @@
pub struct EnrichMovieCommand {
pub movie_id: domain::value_objects::MovieId,
pub profile: domain::models::MovieProfile,
}

View File

@@ -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<dyn MovieRepository>,

View File

@@ -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
}

View File

@@ -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;

View File

@@ -0,0 +1,7 @@
pub struct GetMoviesQuery {
pub limit: Option<u32>,
pub offset: Option<u32>,
pub search: Option<String>,
pub genre: Option<String>,
pub language: Option<String>,
}

View File

@@ -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(),

View File

@@ -5,5 +5,5 @@ use domain::{
};
pub async fn execute(ctx: &AppContext, id: PersonId) -> Result<Option<Person>, DomainError> {
ctx.person_query.get_by_id(&id).await
ctx.repos.person_query.get_by_id(&id).await
}

View File

@@ -5,5 +5,5 @@ use domain::{
};
pub async fn execute(ctx: &AppContext, id: PersonId) -> Result<PersonCredits, DomainError> {
ctx.person_query.get_credits(&id).await
ctx.repos.person_query.get_credits(&id).await
}

View File

@@ -0,0 +1,2 @@
pub mod get;
pub mod get_credits;

View File

@@ -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<u32>,
pub offset: Option<u32>,
pub sort_by: Option<SortDirection>,
pub movie_id: Option<Uuid>,
pub user_id: Option<Uuid>,
}
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<String>,
pub viewer_user_id: Option<Uuid>,
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<Self, Self::Err> {
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<u32>,
pub offset: Option<u32>,
pub sort_by: domain::ports::FeedSortBy,
pub search: Option<String>,
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<u32>,
pub offset: Option<u32>,
pub search: Option<String>,
pub genre: Option<String>,
pub language: Option<String>,
}
pub struct GetWatchlistQuery {
pub user_id: Uuid,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
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,
}

View File

@@ -5,5 +5,5 @@ use domain::{
};
pub async fn execute(ctx: &AppContext, query: SearchQuery) -> Result<SearchResults, DomainError> {
ctx.search_port.search(&query).await
ctx.repos.search_port.search(&query).await
}

View File

@@ -0,0 +1 @@
pub mod execute;

View File

@@ -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");
}
}

View File

@@ -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");
}
}

View File

@@ -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(())
}

View File

@@ -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<Utc>,
}
pub async fn execute(ctx: &AppContext, query: LoginQuery) -> Result<LoginResult, DomainError> {
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());
}
}

View File

@@ -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;

View File

@@ -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");
}
}

View File

@@ -0,0 +1,17 @@
use uuid::Uuid;
pub struct UpdateProfileCommand {
pub user_id: Uuid,
pub display_name: Option<String>,
pub bio: Option<String>,
pub avatar_bytes: Option<Vec<u8>>,
pub avatar_content_type: Option<String>,
pub banner_bytes: Option<Vec<u8>>,
pub banner_content_type: Option<String>,
pub also_known_as: Option<String>,
}
pub struct UpdateProfileFieldsCommand {
pub user_id: Uuid,
pub fields: Vec<domain::models::ProfileField>,
}

View File

@@ -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<CurrentProfileData, DomainError> {
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()))?;

View File

@@ -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<UserProfileData, DomainError> {
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

View File

@@ -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<UsersListData, DomainError> {
#[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::<Vec<RemoteActorInfo>, DomainError>(vec![]),
);

View File

@@ -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;

View File

@@ -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<Self, Self::Err> {
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<u32>,
pub offset: Option<u32>,
pub sort_by: domain::ports::FeedSortBy,
pub search: Option<String>,
pub is_own_profile: bool,
}
pub struct GetCurrentProfileQuery {
pub user_id: Uuid,
}

View File

@@ -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?;

View File

@@ -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(())

View File

@@ -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;

View File

@@ -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,
}

View File

@@ -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<Paginated<WatchlistWithMovie>, 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
}

View File

@@ -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<WatchlistDisplayEntry>,
@@ -15,10 +17,10 @@ pub async fn execute(
is_owner: bool,
) -> Result<WatchlistPageResult, DomainError> {
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<WatchlistPageResult, DomainError> {
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;

View File

@@ -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<bool, DomainError> {
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
}

View File

@@ -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;

View File

@@ -0,0 +1,12 @@
use uuid::Uuid;
pub struct GetWatchlistQuery {
pub user_id: Uuid,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
pub struct IsOnWatchlistQuery {
pub user_id: Uuid,
pub movie_id: Uuid,
}

View File

@@ -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;

View File

@@ -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");
}