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

@@ -1,6 +1,6 @@
use std::sync::Arc; 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 async_trait::async_trait;
use chrono::Utc; use chrono::Utc;
use domain::{ use domain::{

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 domain::errors::DomainError;
use crate::{ use crate::{
commands::RegisterAndLoginCommand, auth::commands::RegisterAndLoginCommand,
auth::{login, register},
context::AppContext, context::AppContext,
use_cases::{login, register},
}; };
pub async fn execute( pub async fn execute(
@@ -12,7 +12,7 @@ pub async fn execute(
) -> Result<login::LoginResult, DomainError> { ) -> Result<login::LoginResult, DomainError> {
register::execute( register::execute(
ctx, ctx,
crate::commands::RegisterCommand { crate::auth::commands::RegisterCommand {
email: cmd.email.clone(), email: cmd.email.clone(),
username: cmd.username, username: cmd.username,
password: cmd.password.clone(), password: cmd.password.clone(),
@@ -23,7 +23,7 @@ pub async fn execute(
login::execute( login::execute(
ctx, ctx,
crate::queries::LoginQuery { crate::auth::queries::LoginQuery {
email: cmd.email, email: cmd.email,
password: cmd.password, 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 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> { pub async fn execute(ctx: &AppContext, query: ExportQuery) -> Result<Vec<u8>, DomainError> {
let entries = ctx let entries = ctx
.diary_repository .repos
.diary
.get_user_history(&UserId::from_uuid(query.user_id)) .get_user_history(&UserId::from_uuid(query.user_id))
.await?; .await?;
ctx.diary_exporter ctx.services
.diary_exporter
.serialize_entries(&entries, query.format) .serialize_entries(&entries, query.format)
.await .await
} }

View File

@@ -1,4 +1,4 @@
use crate::{context::AppContext, queries::GetActivityFeedQuery}; use crate::{context::AppContext, diary::queries::GetActivityFeedQuery};
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::{ models::{
@@ -16,7 +16,8 @@ pub async fn execute(
let following = build_following_filter(ctx, &query).await; let following = build_following_filter(ctx, &query).await;
ctx.diary_repository ctx.repos
.diary
.query_activity_feed_filtered( .query_activity_feed_filtered(
&page, &page,
&query.sort_by, &query.sort_by,
@@ -45,6 +46,7 @@ async fn build_following_filter(
None => return None, None => return None,
}; };
let urls = _ctx let urls = _ctx
.repos
.social_query .social_query
.get_accepted_following_urls(viewer_id) .get_accepted_following_urls(viewer_id)
.await .await

View File

@@ -7,7 +7,7 @@ use domain::{
value_objects::{MovieId, UserId}, value_objects::{MovieId, UserId},
}; };
use crate::{context::AppContext, queries::GetDiaryQuery}; use crate::{context::AppContext, diary::queries::GetDiaryQuery};
pub async fn execute( pub async fn execute(
ctx: &AppContext, ctx: &AppContext,
@@ -25,5 +25,5 @@ pub async fn execute(
search: None, 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, value_objects::MovieId,
}; };
use crate::{context::AppContext, queries::GetMovieSocialPageQuery}; use crate::{context::AppContext, diary::queries::GetMovieSocialPageQuery};
pub struct MovieSocialPageResult { pub struct MovieSocialPageResult {
pub movie: Movie, pub movie: Movie,
@@ -24,15 +24,16 @@ pub async fn execute(
let page = PageParams::new(Some(query.limit), Some(query.offset))?; let page = PageParams::new(Some(query.limit), Some(query.offset))?;
let movie = ctx let movie = ctx
.movie_repository .repos
.movie
.get_movie_by_id(&movie_id) .get_movie_by_id(&movie_id)
.await? .await?
.ok_or_else(|| DomainError::NotFound(format!("Movie {}", query.movie_id)))?; .ok_or_else(|| DomainError::NotFound(format!("Movie {}", query.movie_id)))?;
let (stats, reviews, profile) = tokio::try_join!( let (stats, reviews, profile) = tokio::try_join!(
ctx.diary_repository.get_movie_stats(&movie_id), ctx.repos.diary.get_movie_stats(&movie_id),
ctx.diary_repository.get_movie_social_feed(&movie_id, &page), ctx.repos.diary.get_movie_social_feed(&movie_id, &page),
ctx.movie_profile_repository.get_by_movie_id(&movie_id), ctx.repos.movie_profile.get_by_movie_id(&movie_id),
)?; )?;
Ok(MovieSocialPageResult { Ok(MovieSocialPageResult {

View File

@@ -5,7 +5,7 @@ use domain::{
value_objects::MovieId, value_objects::MovieId,
}; };
use crate::{context::AppContext, queries::GetReviewHistoryQuery}; use crate::{context::AppContext, diary::queries::GetReviewHistoryQuery};
pub async fn execute( pub async fn execute(
ctx: &AppContext, ctx: &AppContext,
@@ -13,7 +13,7 @@ pub async fn execute(
) -> Result<(ReviewHistory, Trend), DomainError> { ) -> Result<(ReviewHistory, Trend), DomainError> {
let movie_id = MovieId::from_uuid(query.movie_id); 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)?; 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}, value_objects::{ExternalMetadataId, MovieTitle, ReleaseYear},
}; };
use crate::commands::MovieInput; use crate::diary::commands::MovieInput;
pub struct MovieResolverDeps<'a> { pub struct MovieResolverDeps<'a> {
pub repository: &'a dyn MovieRepository, 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 super::*;
use crate::commands::MovieInput; use crate::diary::commands::MovieInput;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::Movie, models::Movie,

View File

@@ -6,7 +6,5 @@ pub async fn execute(
ctx: &AppContext, ctx: &AppContext,
uuid: uuid::Uuid, uuid: uuid::Uuid,
) -> Result<Vec<RemoteWatchlistEntry>, DomainError> { ) -> Result<Vec<RemoteWatchlistEntry>, DomainError> {
ctx.remote_watchlist_repository ctx.repos.remote_watchlist.get_by_derived_uuid(uuid).await
.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}, value_objects::{ExternalMetadataId, ImportSessionId, MovieTitle, ReleaseYear, UserId},
}; };
use crate::{commands::ApplyImportMappingCommand, context::AppContext}; use crate::{context::AppContext, import::commands::ApplyImportMappingCommand};
pub async fn execute( pub async fn execute(
ctx: &AppContext, ctx: &AppContext,
@@ -14,7 +14,8 @@ pub async fn execute(
let session_id = ImportSessionId::from_uuid(cmd.session_id); let session_id = ImportSessionId::from_uuid(cmd.session_id);
let mappings = cmd.mappings; let mappings = cmd.mappings;
let mut session = ctx let mut session = ctx
.import_session_repository .repos
.import_session
.get(&session_id, &user_id) .get(&session_id, &user_id)
.await? .await?
.ok_or_else(|| DomainError::NotFound("import session".into()))?; .ok_or_else(|| DomainError::NotFound("import session".into()))?;
@@ -25,7 +26,10 @@ pub async fn execute(
.clone() .clone()
.ok_or_else(|| DomainError::ValidationError("session has no parsed file".into()))?; .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() { for row in annotated.iter_mut() {
if let RowResult::Valid(ref import_row) = row.result { if let RowResult::Valid(ref import_row) = row.result {
@@ -36,7 +40,7 @@ pub async fn execute(
session.field_mappings = Some(mappings); session.field_mappings = Some(mappings);
session.row_results = Some(annotated.clone()); session.row_results = Some(annotated.clone());
ctx.import_session_repository.update(&session).await?; ctx.repos.import_session.update(&session).await?;
Ok(annotated) Ok(annotated)
} }
@@ -48,7 +52,8 @@ async fn check_duplicate(
if let Some(ext_id) = &row.external_metadata_id if let Some(ext_id) = &row.external_metadata_id
&& let Ok(eid) = ExternalMetadataId::new(ext_id.clone()) && let Ok(eid) = ExternalMetadataId::new(ext_id.clone())
&& ctx && ctx
.movie_repository .repos
.movie
.get_movie_by_external_id(&eid) .get_movie_by_external_id(&eid)
.await? .await?
.is_some() .is_some()
@@ -62,10 +67,7 @@ async fn check_duplicate(
.ok() .ok()
.and_then(|y| ReleaseYear::new(y).ok()); .and_then(|y| ReleaseYear::new(y).ok());
if let (Ok(t), Some(y)) = (title_vo, year_vo) { if let (Ok(t), Some(y)) = (title_vo, year_vo) {
let matches = ctx let matches = ctx.repos.movie.get_movies_by_title_and_year(&t, &y).await?;
.movie_repository
.get_movies_by_title_and_year(&t, &y)
.await?;
if !matches.is_empty() { if !matches.is_empty() {
return Ok(true); return Ok(true);
} }

View File

@@ -1,4 +1,4 @@
use crate::{commands::ApplyImportProfileCommand, context::AppContext}; use crate::{context::AppContext, import::commands::ApplyImportProfileCommand};
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
value_objects::{ImportProfileId, ImportSessionId, UserId}, 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_id = ImportProfileId::from_uuid(cmd.profile_id);
let profile = ctx let profile = ctx
.import_profile_repository .repos
.import_profile
.get(&profile_id, &user_id) .get(&profile_id, &user_id)
.await? .await?
.ok_or_else(|| DomainError::NotFound("import profile".into()))?; .ok_or_else(|| DomainError::NotFound("import profile".into()))?;
let mut session = ctx let mut session = ctx
.import_session_repository .repos
.import_session
.get(&session_id, &user_id) .get(&session_id, &user_id)
.await? .await?
.ok_or_else(|| DomainError::NotFound("import session".into()))?; .ok_or_else(|| DomainError::NotFound("import session".into()))?;
session.field_mappings = Some(profile.field_mappings); session.field_mappings = Some(profile.field_mappings);
session.row_results = None; 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; use domain::errors::DomainError;
pub async fn execute(ctx: &AppContext) -> Result<u64, 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}, value_objects::{ImportSessionId, UserId},
}; };
use crate::{commands::CreateImportSessionCommand, context::AppContext}; use crate::{context::AppContext, import::commands::CreateImportSessionCommand};
pub struct CreateSessionResult { pub struct CreateSessionResult {
pub session_id: ImportSessionId, pub session_id: ImportSessionId,
@@ -18,11 +18,13 @@ pub async fn execute(
cmd: CreateImportSessionCommand, cmd: CreateImportSessionCommand,
) -> Result<CreateSessionResult, DomainError> { ) -> Result<CreateSessionResult, DomainError> {
let user_id = UserId::from_uuid(cmd.user_id); let user_id = UserId::from_uuid(cmd.user_id);
ctx.import_session_repository ctx.repos
.import_session
.delete_expired_for_user(&user_id) .delete_expired_for_user(&user_id)
.await?; .await?;
let parsed = ctx let parsed = ctx
.services
.document_parser .document_parser
.parse(&cmd.bytes, cmd.format) .parse(&cmd.bytes, cmd.format)
.map_err(|e| DomainError::ValidationError(e.to_string()))?; .map_err(|e| DomainError::ValidationError(e.to_string()))?;
@@ -35,7 +37,7 @@ pub async fn execute(
let session_id = session.id.clone(); let session_id = session.id.clone();
session.parsed_file = Some(parsed); session.parsed_file = Some(parsed);
ctx.import_session_repository.create(&session).await?; ctx.repos.import_session.create(&session).await?;
Ok(CreateSessionResult { Ok(CreateSessionResult {
session_id, session_id,

View File

@@ -1,4 +1,4 @@
use crate::{commands::DeleteImportProfileCommand, context::AppContext}; use crate::{context::AppContext, import::commands::DeleteImportProfileCommand};
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
value_objects::{ImportProfileId, UserId}, 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 user_id = UserId::from_uuid(cmd.user_id);
let profile_id = ImportProfileId::from_uuid(cmd.profile_id); let profile_id = ImportProfileId::from_uuid(cmd.profile_id);
ctx.import_profile_repository ctx.repos
.import_profile
.get(&profile_id, &user_id) .get(&profile_id, &user_id)
.await? .await?
.ok_or_else(|| DomainError::NotFound("import profile".into()))?; .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 uuid::Uuid;
use crate::{ use crate::{
commands::{ExecuteImportCommand, LogReviewCommand, MovieInput},
context::AppContext, context::AppContext,
use_cases::log_review, diary::commands::{LogReviewCommand, MovieInput},
diary::log_review,
import::commands::ExecuteImportCommand,
}; };
pub struct ImportSummary { pub struct ImportSummary {
@@ -26,7 +27,8 @@ pub async fn execute(
let session_id = ImportSessionId::from_uuid(cmd.session_id); let session_id = ImportSessionId::from_uuid(cmd.session_id);
let confirmed_indices = cmd.confirmed_indices; let confirmed_indices = cmd.confirmed_indices;
let session = ctx let session = ctx
.import_session_repository .repos
.import_session
.get(&session_id, &user_id) .get(&session_id, &user_id)
.await? .await?
.ok_or_else(|| DomainError::NotFound("import session".into()))?; .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 { Ok(ImportSummary {
imported, imported,

View File

@@ -5,5 +5,5 @@ pub async fn execute(
ctx: &AppContext, ctx: &AppContext,
user_id: &UserId, user_id: &UserId,
) -> Result<Vec<ImportProfile>, DomainError> { ) -> 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 chrono::Utc;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
@@ -14,7 +14,8 @@ pub async fn execute(
let session_id = ImportSessionId::from_uuid(cmd.session_id); let session_id = ImportSessionId::from_uuid(cmd.session_id);
let session = ctx let session = ctx
.import_session_repository .repos
.import_session
.get(&session_id, &user_id) .get(&session_id, &user_id)
.await? .await?
.ok_or_else(|| DomainError::NotFound("import session".into()))?; .ok_or_else(|| DomainError::NotFound("import session".into()))?;
@@ -29,6 +30,6 @@ pub async fn execute(
Utc::now().naive_utc(), Utc::now().naive_utc(),
); );
let id = profile.id.clone(); let id = profile.id.clone();
ctx.import_profile_repository.save(&profile).await?; ctx.repos.import_profile.save(&profile).await?;
Ok(id) Ok(id)
} }

View File

@@ -5,7 +5,8 @@ use crate::context::AppContext;
pub async fn execute(ctx: &AppContext) -> Result<u64, DomainError> { pub async fn execute(ctx: &AppContext) -> Result<u64, DomainError> {
let cutoff = chrono::Utc::now().naive_utc() - Duration::days(30); let cutoff = chrono::Utc::now().naive_utc() - Duration::days(30);
ctx.watch_event_repository ctx.repos
.watch_event
.delete_non_pending_older_than(cutoff) .delete_non_pending_older_than(cutoff)
.await .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::{ use crate::{
commands::{ConfirmWatchEventsCommand, LogReviewCommand, MovieInput},
context::AppContext, 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> { 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 { for c in cmd.confirmations {
let event_id = WatchEventId::from_uuid(c.watch_event_id); let event_id = WatchEventId::from_uuid(c.watch_event_id);
let event = ctx let event = ctx
.watch_event_repository .repos
.watch_event
.get_by_id(&event_id) .get_by_id(&event_id)
.await? .await?
.ok_or_else(|| DomainError::NotFound(format!("WatchEvent {}", c.watch_event_id)))?; .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?; log_review::execute(ctx, review_cmd).await?;
ctx.watch_event_repository ctx.repos
.watch_event
.update_status(&event_id, WatchEventStatus::Confirmed) .update_status(&event_id, WatchEventStatus::Confirmed)
.await?; .await?;

View File

@@ -4,7 +4,7 @@ use domain::{
value_objects::{UserId, WatchEventId}, 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> { pub async fn execute(ctx: &AppContext, cmd: DismissWatchEventsCommand) -> Result<u32, DomainError> {
let user_id = UserId::from_uuid(cmd.user_id); 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 { for id in cmd.event_ids {
let event_id = WatchEventId::from_uuid(id); let event_id = WatchEventId::from_uuid(id);
let event = ctx let event = ctx
.watch_event_repository .repos
.watch_event
.get_by_id(&event_id) .get_by_id(&event_id)
.await? .await?
.ok_or_else(|| DomainError::NotFound(format!("WatchEvent {id}")))?; .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())); return Err(DomainError::Unauthorized("not your watch event".into()));
} }
ctx.watch_event_repository ctx.repos
.watch_event
.update_status(&event_id, WatchEventStatus::Dismissed) .update_status(&event_id, WatchEventStatus::Dismissed)
.await?; .await?;

View File

@@ -1,7 +1,7 @@
use domain::{errors::DomainError, models::WebhookToken, value_objects::UserId}; use domain::{errors::DomainError, models::WebhookToken, value_objects::UserId};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use crate::{commands::GenerateWebhookTokenCommand, context::AppContext}; use crate::{context::AppContext, integrations::commands::GenerateWebhookTokenCommand};
pub struct GeneratedWebhookToken { pub struct GeneratedWebhookToken {
pub token_plaintext: String, pub token_plaintext: String,
@@ -18,7 +18,7 @@ pub async fn execute(
let user_id = UserId::from_uuid(cmd.user_id); let user_id = UserId::from_uuid(cmd.user_id);
let token = WebhookToken::new(user_id, hash, cmd.provider, cmd.label); 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 { Ok(GeneratedWebhookToken {
token_plaintext: plaintext, token_plaintext: plaintext,

View File

@@ -1,11 +1,11 @@
use domain::{errors::DomainError, models::WatchEvent, value_objects::UserId}; 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( pub async fn execute(
ctx: &AppContext, ctx: &AppContext,
query: GetWatchQueueQuery, query: GetWatchQueueQuery,
) -> Result<Vec<WatchEvent>, DomainError> { ) -> Result<Vec<WatchEvent>, DomainError> {
let user_id = UserId::from_uuid(query.user_id); 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 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( pub async fn execute(
ctx: &AppContext, ctx: &AppContext,
query: GetWebhookTokensQuery, query: GetWebhookTokensQuery,
) -> Result<Vec<WebhookToken>, DomainError> { ) -> Result<Vec<WebhookToken>, DomainError> {
let user_id = UserId::from_uuid(query.user_id); 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, errors::DomainError, events::DomainEvent, models::WatchEvent, ports::MediaServerParser,
}; };
use crate::{ use crate::{context::AppContext, integrations::commands::IngestWatchEventCommand};
commands::IngestWatchEventCommand, context::AppContext, use_cases::generate_webhook_token,
};
pub async fn execute( pub async fn execute(
ctx: &AppContext, ctx: &AppContext,
cmd: IngestWatchEventCommand, cmd: IngestWatchEventCommand,
parser: &dyn MediaServerParser, parser: &dyn MediaServerParser,
) -> Result<(), DomainError> { ) -> 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 let webhook_token = ctx
.webhook_token_repository .repos
.webhook_token
.find_by_token_hash(&token_hash) .find_by_token_hash(&token_hash)
.await? .await?
.ok_or_else(|| DomainError::Unauthorized("invalid webhook token".into()))?; .ok_or_else(|| DomainError::Unauthorized("invalid webhook token".into()))?;
let _ = ctx let _ = ctx
.webhook_token_repository .repos
.webhook_token
.touch_last_used(webhook_token.id()) .touch_last_used(webhook_token.id())
.await; .await;
@@ -35,7 +35,8 @@ pub async fn execute(
if let Some(ref ext_id) = external_metadata_id { if let Some(ref ext_id) = external_metadata_id {
let one_hour_ago = chrono::Utc::now().naive_utc() - Duration::hours(1); let one_hour_ago = chrono::Utc::now().naive_utc() - Duration::hours(1);
if ctx if ctx
.watch_event_repository .repos
.watch_event
.find_duplicate(&user_id, ext_id, one_hour_ago) .find_duplicate(&user_id, ext_id, one_hour_ago)
.await? .await?
{ {
@@ -54,9 +55,10 @@ pub async fn execute(
None, None,
); );
ctx.watch_event_repository.save(&event).await?; ctx.repos.watch_event.save(&event).await?;
let _ = ctx let _ = ctx
.services
.event_publisher .event_publisher
.publish(&DomainEvent::WatchEventIngested { .publish(&DomainEvent::WatchEventIngested {
user_id: event.user_id().clone(), 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}, 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> { pub async fn execute(ctx: &AppContext, cmd: RevokeWebhookTokenCommand) -> Result<(), DomainError> {
let user_id = UserId::from_uuid(cmd.user_id); let user_id = UserId::from_uuid(cmd.user_id);
let token_id = WebhookTokenId::from_uuid(cmd.token_id); let token_id = WebhookTokenId::from_uuid(cmd.token_id);
ctx.webhook_token_repository ctx.repos.webhook_token.delete(&token_id, &user_id).await
.delete(&token_id, &user_id)
.await
} }

View File

@@ -22,7 +22,7 @@ impl PeriodicJob for ImportSessionCleanupJob {
} }
async fn run(&self) -> Result<(), DomainError> { 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); tracing::info!("import session cleanup: removed {} expired sessions", n);
Ok(()) Ok(())
} }
@@ -45,7 +45,7 @@ impl PeriodicJob for WatchEventCleanupJob {
} }
async fn run(&self) -> Result<(), DomainError> { 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 { if n > 0 {
tracing::info!("watch event cleanup: removed {n} old entries"); tracing::info!("watch event cleanup: removed {n} old entries");
} }
@@ -70,7 +70,7 @@ impl PeriodicJob for EnrichmentStalenessJob {
} }
async fn run(&self) -> Result<(), DomainError> { 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() { if stale.is_empty() {
return Ok(()); return Ok(());
} }
@@ -80,7 +80,7 @@ impl PeriodicJob for EnrichmentStalenessJob {
movie_id, movie_id,
external_metadata_id, external_metadata_id,
}; };
self.ctx.event_publisher.publish(&event).await?; self.ctx.services.event_publisher.publish(&event).await?;
} }
Ok(()) Ok(())
} }

View File

@@ -1,17 +1,23 @@
pub mod commands;
pub mod config; pub mod config;
pub mod context; pub mod context;
pub mod jobs; pub mod jobs;
pub mod movie_discovery_indexer;
pub mod movie_resolver;
pub mod ports; pub mod ports;
pub mod queries;
pub mod search_cleanup;
pub mod use_cases;
pub mod worker; 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)] #[cfg(test)]
pub mod test_helpers; pub mod test_helpers;
pub use movie_discovery_indexer::MovieDiscoveryIndexer; pub use movies::MovieDiscoveryIndexer;
pub use search_cleanup::SearchCleanupHandler; 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}, ports::{MovieProfileRepository, MovieRepository, PersonCommand, SearchCommand},
}; };
use crate::commands::EnrichMovieCommand; use crate::movies::commands::EnrichMovieCommand;
pub async fn execute( pub async fn execute(
movie_repository: &Arc<dyn MovieRepository>, movie_repository: &Arc<dyn MovieRepository>,

View File

@@ -4,7 +4,7 @@ use domain::{
models::{MovieFilter, MovieSummary}, models::{MovieFilter, MovieSummary},
}; };
use crate::{context::AppContext, queries::GetMoviesQuery}; use crate::{context::AppContext, movies::queries::GetMoviesQuery};
pub async fn execute( pub async fn execute(
ctx: &AppContext, ctx: &AppContext,
@@ -16,5 +16,5 @@ pub async fn execute(
genre: query.genre, genre: query.genre,
language: query.language, 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}, 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> { pub async fn execute(ctx: &AppContext, cmd: SyncPosterCommand) -> Result<(), DomainError> {
let movie_id = MovieId::from_uuid(cmd.movie_id); 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, Some(m) => m,
None => { None => {
tracing::warn!( tracing::warn!(
@@ -31,7 +31,8 @@ pub async fn execute(ctx: &AppContext, cmd: SyncPosterCommand) -> Result<(), Dom
.clone(); .clone();
let poster_url = match ctx let poster_url = match ctx
.metadata_client .services
.metadata
.get_poster_url(&external_metadata_id) .get_poster_url(&external_metadata_id)
.await .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 let stored_path = ctx
.services
.image_storage .image_storage
.store(&movie_id.value().to_string(), &image_bytes) .store(&movie_id.value().to_string(), &image_bytes)
.await?; .await?;
if let Err(e) = ctx if let Err(e) = ctx
.services
.event_publisher .event_publisher
.publish(&DomainEvent::ImageStored { .publish(&DomainEvent::ImageStored {
key: stored_path.clone(), 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)?; let poster_path = PosterPath::new(stored_path)?;
movie.update_poster(poster_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. // Refresh search index so the new poster_path is reflected immediately.
// Fetch existing profile if available for a complete index document. // Fetch existing profile if available for a complete index document.
let profile = ctx let profile = ctx
.movie_profile_repository .repos
.movie_profile
.get_by_movie_id(&movie_id) .get_by_movie_id(&movie_id)
.await .await
.ok() .ok()
.flatten(); .flatten();
if let Err(e) = ctx if let Err(e) = ctx
.repos
.search_command .search_command
.index(IndexableDocument::Movie { .index(IndexableDocument::Movie {
id: movie_id.clone(), id: movie_id.clone(),

View File

@@ -5,5 +5,5 @@ use domain::{
}; };
pub async fn execute(ctx: &AppContext, id: PersonId) -> Result<Option<Person>, DomainError> { 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> { 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> { 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 domain::errors::DomainError;
use crate::{context::AppContext, queries::GetCurrentProfileQuery}; use crate::{context::AppContext, users::queries::GetCurrentProfileQuery};
pub struct CurrentProfileData { pub struct CurrentProfileData {
pub username: String, pub username: String,
@@ -14,7 +14,8 @@ pub async fn execute(
) -> Result<CurrentProfileData, DomainError> { ) -> Result<CurrentProfileData, DomainError> {
let user_id = domain::value_objects::UserId::from_uuid(query.user_id); let user_id = domain::value_objects::UserId::from_uuid(query.user_id);
let user = ctx let user = ctx
.user_repository .repos
.user
.find_by_id(&user_id) .find_by_id(&user_id)
.await? .await?
.ok_or_else(|| DomainError::NotFound("User not found".into()))?; .ok_or_else(|| DomainError::NotFound("User not found".into()))?;

View File

@@ -1,6 +1,6 @@
use crate::{ use crate::{
context::AppContext, context::AppContext,
queries::{GetUserProfileQuery, ProfileView}, users::queries::{GetUserProfileQuery, ProfileView},
}; };
use chrono::Datelike; use chrono::Datelike;
use domain::{ use domain::{
@@ -35,7 +35,7 @@ pub async fn execute(
query: GetUserProfileQuery, query: GetUserProfileQuery,
) -> Result<UserProfileData, DomainError> { ) -> Result<UserProfileData, DomainError> {
let user_id = UserId::from_uuid(query.user_id); 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) = let (following_count, followers_count, pending_followers) =
load_social_counts(ctx, query.user_id, query.is_own_profile).await; load_social_counts(ctx, query.user_id, query.is_own_profile).await;
@@ -52,12 +52,12 @@ pub async fn execute(
match query.view { match query.view {
ProfileView::History => { 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); let history = group_by_month(all_entries);
Ok(base(None, Some(history), None)) Ok(base(None, Some(history), None))
} }
ProfileView::Trends => { 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))) Ok(base(None, None, Some(trends)))
} }
ProfileView::Ratings | ProfileView::Recent => { ProfileView::Ratings | ProfileView::Recent => {
@@ -69,7 +69,7 @@ pub async fn execute(
query.offset, query.offset,
query.search.clone(), 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)) Ok(base(Some(entries), None, None))
} }
} }
@@ -90,16 +90,19 @@ async fn load_social_counts(
return (0, 0, vec![]); return (0, 0, vec![]);
} }
let following = _ctx let following = _ctx
.repos
.social_query .social_query
.count_following(_user_id) .count_following(_user_id)
.await .await
.unwrap_or(0); .unwrap_or(0);
let followers = _ctx let followers = _ctx
.repos
.social_query .social_query
.count_accepted_followers(_user_id) .count_accepted_followers(_user_id)
.await .await
.unwrap_or(0); .unwrap_or(0);
let pending = _ctx let pending = _ctx
.repos
.social_query .social_query
.get_pending_followers(_user_id) .get_pending_followers(_user_id)
.await .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}; use domain::{errors::DomainError, models::UserSummary, ports::RemoteActorInfo};
pub struct UsersListData { pub struct UsersListData {
@@ -12,12 +12,12 @@ pub async fn execute(
) -> Result<UsersListData, DomainError> { ) -> Result<UsersListData, DomainError> {
#[cfg(feature = "federation")] #[cfg(feature = "federation")]
let (users_result, actors_result) = tokio::join!( let (users_result, actors_result) = tokio::join!(
ctx.user_repository.list_with_stats(), ctx.repos.user.list_with_stats(),
ctx.social_query.list_all_followed_remote_actors() ctx.repos.social_query.list_all_followed_remote_actors()
); );
#[cfg(not(feature = "federation"))] #[cfg(not(feature = "federation"))]
let (users_result, actors_result) = ( let (users_result, actors_result) = (
ctx.user_repository.list_with_stats().await, ctx.repos.user.list_with_stats().await,
Ok::<Vec<RemoteActorInfo>, DomainError>(vec![]), 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 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> { pub async fn execute(ctx: &AppContext, cmd: UpdateProfileCommand) -> Result<(), DomainError> {
let user_id = UserId::from_uuid(cmd.user_id); let user_id = UserId::from_uuid(cmd.user_id);
let user = ctx let user = ctx
.user_repository .repos
.user
.find_by_id(&user_id) .find_by_id(&user_id)
.await? .await?
.ok_or_else(|| DomainError::NotFound("User not found".into()))?; .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() { 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 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 if let Err(e) = ctx
.services
.event_publisher .event_publisher
.publish(&DomainEvent::ImageStored { .publish(&DomainEvent::ImageStored {
key: stored.clone(), key: stored.clone(),
@@ -47,11 +49,12 @@ pub async fn execute(ctx: &AppContext, cmd: UpdateProfileCommand) -> Result<(),
)); ));
} }
if let Some(old_path) = user.banner_path() { 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 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 if let Err(e) = ctx
.services
.event_publisher .event_publisher
.publish(&DomainEvent::ImageStored { .publish(&DomainEvent::ImageStored {
key: stored.clone(), key: stored.clone(),
@@ -65,7 +68,8 @@ pub async fn execute(ctx: &AppContext, cmd: UpdateProfileCommand) -> Result<(),
user.banner_path().map(|s| s.to_string()) user.banner_path().map(|s| s.to_string())
}; };
ctx.user_repository ctx.repos
.user
.update_profile( .update_profile(
&user_id, &user_id,
&domain::models::UserProfile { &domain::models::UserProfile {
@@ -79,7 +83,8 @@ pub async fn execute(ctx: &AppContext, cmd: UpdateProfileCommand) -> Result<(),
) )
.await?; .await?;
ctx.event_publisher ctx.services
.event_publisher
.publish(&DomainEvent::UserUpdated { user_id }) .publish(&DomainEvent::UserUpdated { user_id })
.await?; .await?;

View File

@@ -1,6 +1,6 @@
use domain::{errors::DomainError, events::DomainEvent, value_objects::UserId}; 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> { pub async fn execute(ctx: &AppContext, cmd: UpdateProfileFieldsCommand) -> Result<(), DomainError> {
if cmd.fields.len() > 4 { 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); let user_id = UserId::from_uuid(cmd.user_id);
ctx.profile_fields_repository ctx.repos
.profile_fields
.set_fields(&user_id, cmd.fields) .set_fields(&user_id, cmd.fields)
.await?; .await?;
ctx.event_publisher ctx.services
.event_publisher
.publish(&DomainEvent::UserUpdated { user_id }) .publish(&DomainEvent::UserUpdated { user_id })
.await?; .await?;
Ok(()) 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, value_objects::UserId,
}; };
use crate::{context::AppContext, queries::GetWatchlistQuery}; use crate::{context::AppContext, watchlist::queries::GetWatchlistQuery};
pub async fn execute( pub async fn execute(
ctx: &AppContext, ctx: &AppContext,
@@ -15,5 +15,5 @@ pub async fn execute(
) -> Result<Paginated<WatchlistWithMovie>, DomainError> { ) -> Result<Paginated<WatchlistWithMovie>, DomainError> {
let user_id = UserId::from_uuid(query.user_id); let user_id = UserId::from_uuid(query.user_id);
let page = PageParams::new(query.limit, query.offset)?; 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 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 struct WatchlistPageResult {
pub display_entries: Vec<WatchlistDisplayEntry>, pub display_entries: Vec<WatchlistDisplayEntry>,
@@ -15,10 +17,10 @@ pub async fn execute(
is_owner: bool, is_owner: bool,
) -> Result<WatchlistPageResult, DomainError> { ) -> Result<WatchlistPageResult, DomainError> {
let user_id = UserId::from_uuid(query.user_id); 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 { 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 has_more = page.offset + page.limit < page.total_count as u32;
let display_entries = page let display_entries = page
.items .items
@@ -71,7 +73,7 @@ async fn load_remote_watchlist(
ctx: &AppContext, ctx: &AppContext,
user_id: uuid::Uuid, user_id: uuid::Uuid,
) -> Result<WatchlistPageResult, DomainError> { ) -> 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 .await
.unwrap_or_default(); .unwrap_or_default();
let len = remote_entries.len() as u32; let len = remote_entries.len() as u32;

View File

@@ -3,10 +3,10 @@ use domain::{
value_objects::{MovieId, UserId}, 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> { pub async fn execute(ctx: &AppContext, query: IsOnWatchlistQuery) -> Result<bool, DomainError> {
let user_id = UserId::from_uuid(query.user_id); let user_id = UserId::from_uuid(query.user_id);
let movie_id = MovieId::from_uuid(query.movie_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}, 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> { pub async fn execute(ctx: &AppContext, cmd: RemoveFromWatchlistCommand) -> Result<(), DomainError> {
let user_id = UserId::from_uuid(cmd.user_id); let user_id = UserId::from_uuid(cmd.user_id);
let movie_id = MovieId::from_uuid(cmd.movie_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 let _ = ctx
.services
.event_publisher .event_publisher
.publish(&DomainEvent::WatchlistEntryRemoved { user_id, movie_id }) .publish(&DomainEvent::WatchlistEntryRemoved { user_id, movie_id })
.await; .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");
}

View File

@@ -2,7 +2,7 @@ use chrono::NaiveDateTime;
use serde::Deserialize; use serde::Deserialize;
use uuid::Uuid; use uuid::Uuid;
use application::{ use application::diary::{
commands::{LogReviewCommand, MovieInput}, commands::{LogReviewCommand, MovieInput},
queries::GetDiaryQuery, queries::GetDiaryQuery,
}; };

View File

@@ -9,22 +9,31 @@ use uuid::Uuid;
use std::str::FromStr; use std::str::FromStr;
use application::{ use application::{
commands::{ auth::{
AddToWatchlistCommand, DeleteReviewCommand, MovieInput, RegisterCommand, commands::RegisterCommand, login as login_uc, queries::LoginQuery, register as register_uc,
RemoveFromWatchlistCommand, SyncPosterCommand,
}, },
queries::{ diary::{
ExportQuery, GetActivityFeedQuery, GetMovieSocialPageQuery, GetMoviesQuery, commands::{DeleteReviewCommand, MovieInput, SyncPosterCommand},
GetReviewHistoryQuery, GetUserProfileQuery, GetUsersQuery, GetWatchlistQuery, delete_review, export_diary as export_diary_uc, get_activity_feed as get_feed_uc,
IsOnWatchlistQuery, LoginQuery, get_diary, get_movie_social_page, get_review_history, log_review,
queries::{
ExportQuery, GetActivityFeedQuery, GetMovieSocialPageQuery, GetReviewHistoryQuery,
},
}, },
use_cases::{ movies::{get_movies, queries::GetMoviesQuery, sync_poster},
add_to_watchlist, delete_review, export_diary as export_diary_uc, person::{get as get_person, get_credits as get_person_credits},
get_activity_feed as get_feed_uc, get_diary, get_movie_social_page, get_movies, get_person, search::execute as search_uc,
get_person_credits, get_review_history, get_user_profile as get_user_profile_uc, get_users, users::{
get_watchlist, is_on_watchlist, log_review, login as login_uc, register as register_uc, get_profile as get_user_profile_uc, get_users,
remove_from_watchlist, search as search_uc, sync_poster, update_profile, queries::{GetUserProfileQuery, GetUsersQuery},
update_profile_fields, 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::{ use domain::{
@@ -333,12 +342,7 @@ pub async fn get_movie_profile(
Path(movie_id): Path<Uuid>, Path(movie_id): Path<Uuid>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let id = domain::value_objects::MovieId::from_uuid(movie_id); let id = domain::value_objects::MovieId::from_uuid(movie_id);
match state match state.app_ctx.repos.movie_profile.get_by_movie_id(&id).await {
.app_ctx
.movie_profile_repository
.get_by_movie_id(&id)
.await
{
Ok(Some(p)) => Json(MovieProfileResponse { Ok(Some(p)) => Json(MovieProfileResponse {
tmdb_id: p.tmdb_id, tmdb_id: p.tmdb_id,
imdb_id: p.imdb_id, imdb_id: p.imdb_id,
@@ -413,9 +417,9 @@ pub async fn get_profile(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, AuthenticatedUser(user_id): AuthenticatedUser,
) -> impl IntoResponse { ) -> impl IntoResponse {
match application::use_cases::get_current_profile::execute( match application::users::get_current_profile::execute(
&state.app_ctx, &state.app_ctx,
application::queries::GetCurrentProfileQuery { application::users::queries::GetCurrentProfileQuery {
user_id: user_id.value(), 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(), user_id: user_id.value(),
display_name, display_name,
bio, bio,
@@ -552,7 +556,7 @@ pub async fn update_profile_fields_handler(
}) })
.collect(); .collect();
let cmd = application::commands::UpdateProfileFieldsCommand { let cmd = application::users::commands::UpdateProfileFieldsCommand {
user_id: user_id.value(), user_id: user_id.value(),
fields, fields,
}; };
@@ -1066,14 +1070,15 @@ pub async fn get_user_profile(
Query(params): Query<UserProfileQueryParams>, Query(params): Query<UserProfileQueryParams>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let view_str = params.view.as_deref().unwrap_or("recent"); 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, Ok(v) => v,
Err(_) => return StatusCode::BAD_REQUEST.into_response(), Err(_) => return StatusCode::BAD_REQUEST.into_response(),
}; };
let user = match state let user = match state
.app_ctx .app_ctx
.user_repository .repos
.user
.find_by_id(&UserId::from_uuid(user_id)) .find_by_id(&UserId::from_uuid(user_id))
.await .await
{ {

View File

@@ -4,40 +4,53 @@ use axum::{
Form, Form,
extract::{Extension, Multipart, Path, Query, State}, extract::{Extension, Multipart, Path, Query, State},
http::{HeaderValue, StatusCode, header::SET_COOKIE}, http::{HeaderValue, StatusCode, header::SET_COOKIE},
response::{Html, IntoResponse, Redirect}, response::{IntoResponse, Redirect},
}; };
use chrono::Utc; use chrono::Utc;
use uuid::Uuid; use uuid::Uuid;
#[cfg(feature = "federation")]
use application::ports::{
BlockedActorEntry, BlockedActorsPageData, BlockedDomainEntry, BlockedDomainsPageData,
FollowersPageData, FollowingPageData,
};
use application::{ use application::{
commands::{ auth::{login as login_uc, queries::LoginQuery},
AddToWatchlistCommand, ConfirmWatchEventsCommand, DeleteReviewCommand, diary::{
DismissWatchEventsCommand, GenerateWebhookTokenCommand, MovieInput, commands::{DeleteReviewCommand, MovieInput},
RemoveFromWatchlistCommand, RevokeWebhookTokenCommand, WatchEventConfirmation, delete_review, export_diary as export_diary_uc, get_movie_social_page, log_review,
queries::{ExportQuery, GetMovieSocialPageQuery},
}, },
ports::{ integrations::{
HtmlPageContext, IntegrationsPageData, LoginPageData, MovieDetailPageData, commands::{
NewReviewPageData, ProfileSettingsPageData, RegisterPageData, RemoteActorView, ConfirmWatchEventsCommand, DismissWatchEventsCommand, GenerateWebhookTokenCommand,
WatchQueueDisplayEntry, WatchQueuePageData, WatchlistPageData, WebhookTokenView, 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::{ users::{update_profile, update_profile_fields},
ExportQuery, GetMovieSocialPageQuery, GetWatchQueueQuery, GetWebhookTokensQuery, watchlist::{
IsOnWatchlistQuery, LoginQuery, add as add_to_watchlist,
}, commands::{AddToWatchlistCommand, RemoveFromWatchlistCommand},
use_cases::{ is_on as is_on_watchlist,
add_to_watchlist, confirm_watch_events, delete_review, dismiss_watch_events, queries::IsOnWatchlistQuery,
export_diary as export_diary_uc, generate_webhook_token, get_movie_social_page, remove as remove_from_watchlist,
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,
}, },
}; };
use crate::render::render_page;
use application::ports::HtmlPageContext;
use domain::models::ExportFormat; use domain::models::ExportFormat;
use domain::{errors::DomainError, value_objects::UserId}; 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")] #[cfg(feature = "federation")]
use crate::forms::{ use crate::forms::{
@@ -57,13 +70,7 @@ pub(crate) async fn build_page_context(
) -> HtmlPageContext { ) -> HtmlPageContext {
let uuid = user_id.as_ref().map(|u| u.value()); let uuid = user_id.as_ref().map(|u| u.value());
let (user_email, is_admin) = if let Some(ref id) = user_id { let (user_email, is_admin) = if let Some(ref id) = user_id {
let user = state let user = state.app_ctx.repos.user.find_by_id(id).await.ok().flatten();
.app_ctx
.user_repository
.find_by_id(id)
.await
.ok()
.flatten();
let email = user.as_ref().map(|u| u.email().value().to_string()); let email = user.as_ref().map(|u| u.email().value().to_string());
let admin = user let admin = user
.as_ref() .as_ref()
@@ -128,14 +135,10 @@ pub async fn get_login_page(
csrf_token: csrf.0, csrf_token: csrf.0,
page_rss_url: None, page_rss_url: None,
}; };
let html = state render_page(LoginTemplate {
.html_renderer ctx: &ctx,
.render_login_page(LoginPageData { error: params.error.as_deref(),
ctx, })
error: params.error.as_deref(),
})
.expect("login template failed");
Html(html)
} }
pub async fn post_login( pub async fn post_login(
@@ -195,14 +198,11 @@ pub async fn get_register_page(
csrf_token: csrf.0, csrf_token: csrf.0,
page_rss_url: None, page_rss_url: None,
}; };
let html = state render_page(RegisterTemplate {
.html_renderer ctx: &ctx,
.render_register_page(RegisterPageData { error: params.error.as_deref(),
ctx, })
error: params.error.as_deref(), .into_response()
})
.expect("register template failed");
Html(html).into_response()
} }
pub async fn post_register( pub async fn post_register(
@@ -216,9 +216,9 @@ pub async fn post_register(
if crate::csrf::mismatch(&csrf, &form.csrf_token) { if crate::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response(); return StatusCode::FORBIDDEN.into_response();
} }
match application::use_cases::register_and_login::execute( match application::auth::register_and_login::execute(
&state.app_ctx, &state.app_ctx,
application::commands::RegisterAndLoginCommand { application::auth::commands::RegisterAndLoginCommand {
email: form.email, email: form.email,
username: form.username, username: form.username,
password: form.password, 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; let mut ctx = build_page_context(&state, Some(user_id), csrf.0).await;
ctx.page_title = "Log a Review — Movies Diary".to_string(); ctx.page_title = "Log a Review — Movies Diary".to_string();
ctx.canonical_url = format!("{}/reviews/new", state.app_ctx.config.base_url); ctx.canonical_url = format!("{}/reviews/new", state.app_ctx.config.base_url);
let html = state render_page(NewReviewTemplate {
.html_renderer ctx: &ctx,
.render_new_review_page(NewReviewPageData { error: params.error.as_deref(),
ctx, })
error: params.error.as_deref(),
})
.expect("new_review template failed");
Html(html)
} }
pub async fn post_review( pub async fn post_review(
@@ -374,7 +370,7 @@ pub async fn get_activity_feed(
_ => "date", _ => "date",
}; };
let query = application::queries::GetActivityFeedQuery { let query = application::diary::queries::GetActivityFeedQuery {
limit, limit,
offset, offset,
sort_by: sort_by_str.parse().unwrap_or_default(), sort_by: sort_by_str.parse().unwrap_or_default(),
@@ -387,26 +383,30 @@ pub async fn get_activity_feed(
filter_following, 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) => { Ok(entries) => {
let entry_limit = entries.limit; let entry_limit = entries.limit;
let entry_offset = entries.offset; let entry_offset = entries.offset;
let has_more = let has_more =
(entry_offset as u64).saturating_add(entry_limit as u64) < entries.total_count; (entry_offset as u64).saturating_add(entry_limit as u64) < entries.total_count;
let data = application::ports::ActivityFeedPageData { let total_pages = (entries.total_count as u32)
ctx, .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, current_offset: entry_offset,
has_more,
limit: entry_limit, limit: entry_limit,
entries, has_more,
ctx: &ctx,
page_items,
filter: filter_str.to_string(), filter: filter_str.to_string(),
sort_by: sort_by_str.to_string(), sort_by: sort_by_str.to_string(),
search: params.search, search: params.search,
}; })
match state.html_renderer.render_activity_feed_page(data) { .into_response()
Ok(html) => Html(html).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
} }
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).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.page_title = "Members — Movies Diary".to_string();
ctx.canonical_url = format!("{}/users", state.app_ctx.config.base_url); 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, &state.app_ctx,
application::queries::GetUsersQuery, application::users::queries::GetUsersQuery,
) )
.await .await
{ {
Ok(result) => { Ok(result) => {
let actor_views = result let users: Vec<UserSummaryView> = result
.remote_actors .users
.into_iter() .into_iter()
.map(|a| application::ports::RemoteActorView { .map(|u| {
handle: a.handle, let name = u.email().split('@').next().unwrap_or("?").to_string();
display_name: a.display_name, let initial = name.chars().next().unwrap_or('?').to_ascii_uppercase();
url: a.url, let avg_display = u
avatar_url: None, .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(); .collect();
let data = application::ports::UsersPageData { let remote_actors: Vec<RemoteActorDisplay> = result
ctx, .remote_actors
users: result.users, .into_iter()
remote_actors: actor_views, .map(|a| {
}; let display = a.display_name.unwrap_or_else(|| a.handle.clone());
match state.html_renderer.render_users_page(data) { let initial = display.chars().next().unwrap_or('?').to_ascii_uppercase();
Ok(html) => Html(html).into_response(), RemoteActorDisplay {
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(), 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(), 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, Ok(u) => u,
Err(_) => return StatusCode::NOT_FOUND.into_response(), 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)) => { Ok(Some(user)) => {
axum::response::Redirect::permanent(&format!("/users/{}", user.id().value())) axum::response::Redirect::permanent(&format!("/users/{}", user.id().value()))
.into_response() .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 mut ctx = build_page_context(&state, user_id.clone(), csrf.0).await;
let view_str = params.view.as_deref().unwrap_or("recent"); 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, Ok(v) => v,
Err(_) => { Err(_) => {
return ( return (
@@ -518,7 +540,8 @@ pub async fn get_user_profile(
let profile_user = match state let profile_user = match state
.app_ctx .app_ctx
.user_repository .repos
.user
.find_by_id(&domain::value_objects::UserId::from_uuid(profile_user_uuid)) .find_by_id(&domain::value_objects::UserId::from_uuid(profile_user_uuid))
.await .await
{ {
@@ -546,7 +569,7 @@ pub async fn get_user_profile(
.map(|u| u.value() == profile_user_uuid) .map(|u| u.value() == profile_user_uuid)
.unwrap_or(false); .unwrap_or(false);
let query = application::queries::GetUserProfileQuery { let query = application::users::queries::GetUserProfileQuery {
user_id: profile_user_uuid, user_id: profile_user_uuid,
view: profile_view, view: profile_view,
limit: params.limit, limit: params.limit,
@@ -560,7 +583,7 @@ pub async fn get_user_profile(
is_own_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) => { Ok(profile) => {
let (offset, has_more, limit) = profile let (offset, has_more, limit) = profile
.entries .entries
@@ -573,28 +596,80 @@ pub async fn get_user_profile(
if !is_own_profile { if !is_own_profile {
ctx.page_rss_url = Some(format!("/users/{}/feed.rss", profile_user_uuid)); ctx.page_rss_url = Some(format!("/users/{}/feed.rss", profile_user_uuid));
} }
let pending_followers: Vec<application::ports::RemoteActorView> = 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<MonthlyRatingRow<'_>> = 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<RemoteActorData> = profile
.pending_followers .pending_followers
.into_iter() .into_iter()
.map(|p| application::ports::RemoteActorView { .map(|p| RemoteActorData {
handle: p.handle, handle: p.handle,
url: p.url, url: p.url,
display_name: p.display_name, display_name: p.display_name,
avatar_url: p.avatar_url, avatar_url: p.avatar_url,
}) })
.collect(); .collect();
let data = application::ports::ProfilePageData { render_page(ProfileTemplate {
ctx, ctx: &ctx,
profile_display_name: display_name,
profile_user_id: profile_user_uuid, profile_user_id: profile_user_uuid,
profile_user_email: profile_user.email().value().to_string(), stats: &profile.stats,
stats: profile.stats, avg_rating_display,
view: profile_view.as_str().to_string(), favorite_director_display,
entries: profile.entries, most_active_month_display,
view: profile_view.as_str(),
entries: profile.entries.as_ref(),
current_offset: offset, current_offset: offset,
has_more, has_more,
limit, limit,
history: profile.history, history: profile.history.as_ref(),
trends: profile.trends, trends: profile.trends.as_ref(),
monthly_rating_rows,
heatmap,
page_items,
is_own_profile, is_own_profile,
error: params.error, error: params.error,
following_count: profile.following_count, following_count: profile.following_count,
@@ -602,11 +677,8 @@ pub async fn get_user_profile(
pending_followers, pending_followers,
sort_by: sort_by_str.to_string(), sort_by: sort_by_str.to_string(),
search: params.search.clone(), search: params.search.clone(),
}; })
match state.html_renderer.render_profile_page(data) { .into_response()
Ok(html) => Html(html).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
} }
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).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 { match state.ap_service.get_following(user_id.value()).await {
Ok(following) => { Ok(following) => {
let actors = following let actors: Vec<RemoteActorData> = following
.into_iter() .into_iter()
.map(|a| RemoteActorView { .map(|a| RemoteActorData {
handle: a.handle, handle: a.handle,
display_name: a.display_name, display_name: a.display_name,
url: a.url, url: a.url,
avatar_url: a.avatar_url.clone(), avatar_url: a.avatar_url.clone(),
}) })
.collect(); .collect();
let data = FollowingPageData { render_page(FollowingTemplate {
ctx, ctx,
user_id: profile_user_uuid, user_id: profile_user_uuid,
actors, actors,
error: params.error, error: params.error,
}; })
match state.html_renderer.render_following_page(data) { .into_response()
Ok(html) => Html(html).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
} }
Err(e) => { Err(e) => {
tracing::error!("get_following error: {:?}", e); tracing::error!("get_following error: {:?}", e);
@@ -872,25 +941,22 @@ pub async fn get_followers_page(
.await .await
{ {
Ok(followers) => { Ok(followers) => {
let actors = followers let actors: Vec<RemoteActorData> = followers
.into_iter() .into_iter()
.map(|a| RemoteActorView { .map(|a| RemoteActorData {
handle: a.handle, handle: a.handle,
display_name: a.display_name, display_name: a.display_name,
url: a.url, url: a.url,
avatar_url: a.avatar_url.clone(), avatar_url: a.avatar_url.clone(),
}) })
.collect(); .collect();
let data = FollowersPageData { render_page(FollowersTemplate {
ctx, ctx,
user_id: profile_user_uuid, user_id: profile_user_uuid,
actors, actors,
error: params.error, error: params.error,
}; })
match state.html_renderer.render_followers_page(data) { .into_response()
Ok(html) => Html(html).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
} }
Err(e) => { Err(e) => {
tracing::error!("get_followers error: {:?}", e); tracing::error!("get_followers error: {:?}", e);
@@ -985,25 +1051,21 @@ pub async fn get_movie_detail(
.unwrap_or(false), .unwrap_or(false),
None => false, None => false,
}; };
let data = MovieDetailPageData { let current_offset = result.reviews.offset;
ctx, let reviews_limit = result.reviews.limit;
movie: result.movie, render_page(MovieDetailTemplate {
stats: result.stats, ctx: &ctx,
profile: result.profile, movie: &result.movie,
stats: &result.stats,
profile: result.profile.as_ref(),
reviews: result.reviews.items.as_slice(),
on_watchlist, on_watchlist,
current_offset: result.reviews.offset, current_offset,
has_more, has_more,
limit: result.reviews.limit, limit: reviews_limit,
reviews: result.reviews,
histogram_max, histogram_max,
}; })
match state.html_renderer.render_movie_detail_page(data) { .into_response()
Ok(html) => Html(html).into_response(),
Err(e) => {
tracing::error!("template error: {}", e);
StatusCode::INTERNAL_SERVER_ERROR.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 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 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, &state.app_ctx,
application::queries::GetWatchlistQuery { application::watchlist::queries::GetWatchlistQuery {
user_id: owner_id, user_id: owner_id,
limit: params.limit.or(Some(20)), limit: params.limit.or(Some(20)),
offset: params.offset.or(Some(0)), offset: params.offset.or(Some(0)),
@@ -1036,23 +1098,17 @@ pub async fn get_watchlist_page(
} }
}; };
let data = WatchlistPageData { render_page(WatchlistTemplate {
ctx, ctx: &ctx,
owner_id, owner_id,
display_entries: result.display_entries, display_entries: &result.display_entries,
current_offset: result.current_offset, current_offset: result.current_offset,
has_more: result.has_more, has_more: result.has_more,
limit: result.limit, limit: result.limit,
is_owner, is_owner,
error: params.error, error: params.error,
}; })
match state.html_renderer.render_watchlist_page(data) { .into_response()
Ok(html) => Html(html).into_response(),
Err(e) => {
tracing::error!("watchlist template error: {}", e);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
} }
pub async fn post_watchlist_add( 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.page_title = "Profile Settings — Movies Diary".to_string();
ctx.canonical_url = format!("{}/settings/profile", state.app_ctx.config.base_url); 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(Some(u)) => u,
Ok(None) => return StatusCode::NOT_FOUND.into_response(), Ok(None) => return StatusCode::NOT_FOUND.into_response(),
Err(e) => { Err(e) => {
@@ -1197,9 +1253,10 @@ pub async fn get_profile_settings(
.banner_path() .banner_path()
.map(|path| format!("{}/images/{}", base_url, path)); .map(|path| format!("{}/images/{}", base_url, path));
let profile_fields = state let profile_fields: Vec<(String, String)> = state
.app_ctx .app_ctx
.profile_fields_repository .repos
.profile_fields
.get_fields(&user_id) .get_fields(&user_id)
.await .await
.unwrap_or_default() .unwrap_or_default()
@@ -1209,23 +1266,19 @@ pub async fn get_profile_settings(
let saved = params.saved.as_deref() == Some("1"); let saved = params.saved.as_deref() == Some("1");
let data = ProfileSettingsPageData { let bio = user.bio().map(|s| s.to_string());
ctx, let also_known_as = user.also_known_as().map(|s| s.to_string());
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,
};
match state.html_renderer.render_profile_settings_page(data) { render_page(ProfileSettingsTemplate {
Ok(html) => Html(html).into_response(), ctx: &ctx,
Err(e) => { bio: bio.as_deref(),
tracing::error!("profile_settings template error: {}", e); avatar_url: avatar_url.as_deref(),
StatusCode::INTERNAL_SERVER_ERROR.into_response() 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<String>) -> impl IntoResponse { pub async fn get_tag(Path(tag): Path<String>) -> 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); ctx.canonical_url = format!("{}/admin/blocked-domains", state.app_ctx.config.base_url);
match state.ap_service.get_blocked_domains().await { match state.ap_service.get_blocked_domains().await {
Ok(domains) => { Ok(domains) => {
let data = BlockedDomainsPageData { let entries: Vec<template_askama::BlockedDomainEntry> = domains
ctx, .into_iter()
domains: domains .map(|d| template_askama::BlockedDomainEntry {
.into_iter() domain: d.domain,
.map(|d| BlockedDomainEntry { reason: d.reason,
domain: d.domain, blocked_at: d.blocked_at,
reason: d.reason, })
blocked_at: d.blocked_at, .collect();
}) render_page(BlockedDomainsTemplate {
.collect(), ctx: &ctx,
}; domains: &entries,
match state.html_renderer.render_blocked_domains_page(data) { })
Ok(html) => Html(html).into_response(), .into_response()
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
} }
Err(e) => { Err(e) => {
tracing::error!("get_blocked_domains error: {:?}", 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); ctx.canonical_url = format!("{}/social/blocked", state.app_ctx.config.base_url);
match state.ap_service.get_blocked_actors(user_id.value()).await { match state.ap_service.get_blocked_actors(user_id.value()).await {
Ok(actors) => { Ok(actors) => {
let data = BlockedActorsPageData { let entries: Vec<template_askama::BlockedActorEntry> = actors
ctx, .into_iter()
actors: actors .map(|a| template_askama::BlockedActorEntry {
.into_iter() url: a.url,
.map(|a| BlockedActorEntry { handle: a.handle,
url: a.url, display_name: a.display_name,
handle: a.handle, avatar_url: a.avatar_url,
display_name: a.display_name, })
avatar_url: a.avatar_url, .collect();
}) render_page(BlockedActorsTemplate {
.collect(), ctx: &ctx,
}; actors: &entries,
match state.html_renderer.render_blocked_actors_page(data) { })
Ok(html) => Html(html).into_response(), .into_response()
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
} }
Err(e) => { Err(e) => {
tracing::error!("get_blocked_actors error: {:?}", 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(), user_id: user_id.value(),
display_name, display_name,
bio, bio,
@@ -1498,7 +1547,7 @@ pub async fn post_profile_settings(
}) })
.collect(); .collect();
let fields_cmd = application::commands::UpdateProfileFieldsCommand { let fields_cmd = application::users::commands::UpdateProfileFieldsCommand {
user_id: user_id.value(), user_id: user_id.value(),
fields, fields,
}; };
@@ -1526,9 +1575,9 @@ pub async fn get_integrations_page(
.await .await
.unwrap_or_default(); .unwrap_or_default();
let token_views: Vec<WebhookTokenView> = tokens let token_views: Vec<template_askama::WebhookTokenView> = tokens
.into_iter() .into_iter()
.map(|t| WebhookTokenView { .map(|t| template_askama::WebhookTokenView {
id: t.id().value().to_string(), id: t.id().value().to_string(),
provider: t.provider().to_string(), provider: t.provider().to_string(),
label: t.label().map(String::from), label: t.label().map(String::from),
@@ -1539,20 +1588,14 @@ pub async fn get_integrations_page(
}) })
.collect(); .collect();
let data = IntegrationsPageData { let webhook_base_url = state.app_ctx.config.base_url.clone();
ctx, render_page(IntegrationsTemplate {
tokens: token_views, ctx: &ctx,
webhook_base_url: state.app_ctx.config.base_url.clone(), tokens: &token_views,
new_token: params.token, webhook_base_url: &webhook_base_url,
}; new_token: params.token.as_deref(),
})
match state.html_renderer.render_integrations_page(data) { .into_response()
Ok(html) => Html(html).into_response(),
Err(e) => {
tracing::error!("integrations template error: {}", e);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
} }
pub async fn post_generate_token( pub async fn post_generate_token(
@@ -1632,9 +1675,9 @@ pub async fn get_watch_queue_page(
.await .await
.unwrap_or_default(); .unwrap_or_default();
let entries: Vec<WatchQueueDisplayEntry> = events let entries: Vec<template_askama::WatchQueueDisplayEntry> = events
.into_iter() .into_iter()
.map(|e| WatchQueueDisplayEntry { .map(|e| template_askama::WatchQueueDisplayEntry {
id: e.id().value().to_string(), id: e.id().value().to_string(),
title: e.title().to_string(), title: e.title().to_string(),
year: e.year(), year: e.year(),
@@ -1644,19 +1687,12 @@ pub async fn get_watch_queue_page(
}) })
.collect(); .collect();
let data = WatchQueuePageData { render_page(WatchQueueTemplate {
ctx, ctx: &ctx,
entries, entries: &entries,
error: params.error, error: params.error.as_deref(),
}; })
.into_response()
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()
}
}
} }
pub async fn post_confirm_single( pub async fn post_confirm_single(

View File

@@ -11,25 +11,26 @@ use axum::{
use serde::Deserialize; use serde::Deserialize;
use std::collections::HashMap; use std::collections::HashMap;
use application::{ use crate::render::render_page;
use application::import::{
apply_mapping as apply_import_mapping,
commands::{ commands::{
ApplyImportMappingCommand, CreateImportSessionCommand, DeleteImportProfileCommand, ApplyImportMappingCommand, CreateImportSessionCommand, DeleteImportProfileCommand,
ExecuteImportCommand, SaveImportProfileCommand, ExecuteImportCommand, SaveImportProfileCommand,
}, },
ports::{ create_session as create_import_session, delete_profile as delete_import_profile,
ImportMappingPageData, ImportPreviewPageData, ImportPreviewRow, ImportProfileView, execute as execute_import, list_profiles as list_import_profiles,
ImportRowStatus, ImportUploadPageData, save_profile as save_import_profile,
},
use_cases::{
apply_import_mapping, create_import_session, delete_import_profile, execute_import,
list_import_profiles, save_import_profile,
},
}; };
use domain::models::{ use domain::models::{
AnnotatedRow, FieldMapping, FileFormat, AnnotatedRow, FieldMapping, FileFormat,
import::{DomainField, RowResult, Transform}, import::{DomainField, RowResult, Transform},
}; };
use domain::value_objects::ImportSessionId; use domain::value_objects::ImportSessionId;
use template_askama::{
ImportMappingTemplate, ImportPreviewRow, ImportPreviewTemplate, ImportProfileView,
ImportRowStatus, ImportUploadTemplate,
};
use crate::{ use crate::{
csrf::CsrfToken, csrf::CsrfToken,
@@ -143,15 +144,11 @@ pub async fn get_import_page(
name: p.name, name: p.name,
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let html = state render_page(ImportUploadTemplate {
.html_renderer ctx: &ctx,
.render_import_upload_page(ImportUploadPageData { profiles: &profiles,
ctx, error: None,
profiles, })
error: None,
})
.unwrap_or_else(|e| e);
Html(html)
} }
pub async fn post_upload( pub async fn post_upload(
@@ -220,7 +217,8 @@ pub async fn get_mapping_page(
}; };
let Ok(Some(session)) = state let Ok(Some(session)) = state
.app_ctx .app_ctx
.import_session_repository .repos
.import_session
.get(&session_id, &user_id) .get(&session_id, &user_id)
.await .await
else { 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 ctx = super::html::build_page_context(&state, Some(user_id), csrf.0).await;
let sample_rows = parsed.rows.into_iter().take(5).collect(); let sample_rows: Vec<Vec<String>> = parsed.rows.into_iter().take(5).collect();
let html = state let domain_fields: Vec<(&str, &str)> = vec![
.html_renderer ("title", "Title"),
.render_import_mapping_page(ImportMappingPageData { ("release_year", "Release Year"),
ctx, ("director", "Director"),
session_id: session_id_str, ("rating", "Rating"),
columns: parsed.columns, ("watched_at", "Watched At"),
sample_rows, ("comment", "Comment"),
domain_fields: vec![ ("external_metadata_id", "External ID"),
("title", "Title"), ];
("release_year", "Release Year"), render_page(ImportMappingTemplate {
("director", "Director"), ctx: &ctx,
("rating", "Rating"), session_id: &session_id_str,
("watched_at", "Watched At"), columns: &parsed.columns,
("comment", "Comment"), sample_rows: &sample_rows,
("external_metadata_id", "External ID"), domain_fields: &domain_fields,
], error: None,
error: None, })
}) .into_response()
.unwrap_or_else(|e| e);
Html(html).into_response()
} }
pub async fn post_mapping( pub async fn post_mapping(
@@ -313,7 +309,8 @@ pub async fn get_preview_page(
}; };
let Ok(Some(session)) = state let Ok(Some(session)) = state
.app_ctx .app_ctx
.import_session_repository .repos
.import_session
.get(&session_id, &user_id) .get(&session_id, &user_id)
.await .await
else { else {
@@ -334,16 +331,13 @@ pub async fn get_preview_page(
.collect(); .collect();
let ctx = super::html::build_page_context(&state, Some(user_id), csrf.0).await; let ctx = super::html::build_page_context(&state, Some(user_id), csrf.0).await;
let html = state render_page(ImportPreviewTemplate {
.html_renderer ctx: &ctx,
.render_import_preview_page(ImportPreviewPageData { session_id: &session_id_str,
ctx, columns: &parsed.columns,
session_id: session_id_str, rows: &rows,
columns: parsed.columns, })
rows, .into_response()
})
.unwrap_or_else(|e| e);
Html(html).into_response()
} }
pub async fn post_confirm( pub async fn post_confirm(
@@ -571,7 +565,8 @@ pub async fn api_get_session(
}; };
match state match state
.app_ctx .app_ctx
.import_session_repository .repos
.import_session
.get(&session_id, &user_id) .get(&session_id, &user_id)
.await .await
{ {

View File

@@ -5,7 +5,7 @@ use axum::{
}; };
use uuid::Uuid; 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 domain::{errors::DomainError, models::SortDirection, value_objects::UserId};
use crate::{errors::ApiError, state::AppState}; use crate::{errors::ApiError, state::AppState};
@@ -35,7 +35,8 @@ pub async fn get_user_feed(
) -> Result<impl IntoResponse, ApiError> { ) -> Result<impl IntoResponse, ApiError> {
let user = state let user = state
.app_ctx .app_ctx
.user_repository .repos
.user
.find_by_id(&UserId::from_uuid(user_id)) .find_by_id(&UserId::from_uuid(user_id))
.await .await
.map_err(ApiError)? .map_err(ApiError)?

View File

@@ -10,16 +10,16 @@ use api_types::{
ConfirmWatchRequest, ConfirmWatchResponse, DismissWatchRequest, DismissWatchResponse, ConfirmWatchRequest, ConfirmWatchResponse, DismissWatchRequest, DismissWatchResponse,
GenerateTokenRequest, GenerateTokenResponse, WatchQueueEntryDto, WebhookTokenDto, GenerateTokenRequest, GenerateTokenResponse, WatchQueueEntryDto, WebhookTokenDto,
}; };
use application::{ use application::integrations::{
commands::{ commands::{
ConfirmWatchEventsCommand, DismissWatchEventsCommand, GenerateWebhookTokenCommand, ConfirmWatchEventsCommand, DismissWatchEventsCommand, GenerateWebhookTokenCommand,
IngestWatchEventCommand, RevokeWebhookTokenCommand, WatchEventConfirmation, 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}, queries::{GetWatchQueueQuery, GetWebhookTokensQuery},
use_cases::{ revoke_token as revoke_webhook_token,
confirm_watch_events, dismiss_watch_events, generate_webhook_token, get_watch_queue,
get_webhook_tokens, ingest_watch_event, revoke_webhook_token,
},
}; };
use domain::models::WatchEventSource; use domain::models::WatchEventSource;