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:
@@ -1,6 +1,6 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use application::{commands::EnrichMovieCommand, use_cases::enrich_movie};
|
||||
use application::movies::{commands::EnrichMovieCommand, enrich_movie};
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use domain::{
|
||||
|
||||
14
crates/application/src/auth/commands.rs
Normal file
14
crates/application/src/auth/commands.rs
Normal 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,
|
||||
}
|
||||
45
crates/application/src/auth/login.rs
Normal file
45
crates/application/src/auth/login.rs
Normal 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;
|
||||
5
crates/application/src/auth/mod.rs
Normal file
5
crates/application/src/auth/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod commands;
|
||||
pub mod login;
|
||||
pub mod queries;
|
||||
pub mod register;
|
||||
pub mod register_and_login;
|
||||
4
crates/application/src/auth/queries.rs
Normal file
4
crates/application/src/auth/queries.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub struct LoginQuery {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
46
crates/application/src/auth/register.rs
Normal file
46
crates/application/src/auth/register.rs
Normal 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;
|
||||
@@ -1,9 +1,9 @@
|
||||
use domain::errors::DomainError;
|
||||
|
||||
use crate::{
|
||||
commands::RegisterAndLoginCommand,
|
||||
auth::commands::RegisterAndLoginCommand,
|
||||
auth::{login, register},
|
||||
context::AppContext,
|
||||
use_cases::{login, register},
|
||||
};
|
||||
|
||||
pub async fn execute(
|
||||
@@ -12,7 +12,7 @@ pub async fn execute(
|
||||
) -> Result<login::LoginResult, DomainError> {
|
||||
register::execute(
|
||||
ctx,
|
||||
crate::commands::RegisterCommand {
|
||||
crate::auth::commands::RegisterCommand {
|
||||
email: cmd.email.clone(),
|
||||
username: cmd.username,
|
||||
password: cmd.password.clone(),
|
||||
@@ -23,7 +23,7 @@ pub async fn execute(
|
||||
|
||||
login::execute(
|
||||
ctx,
|
||||
crate::queries::LoginQuery {
|
||||
crate::auth::queries::LoginQuery {
|
||||
email: cmd.email,
|
||||
password: cmd.password,
|
||||
},
|
||||
85
crates/application/src/auth/tests/login.rs
Normal file
85
crates/application/src/auth/tests/login.rs
Normal 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());
|
||||
}
|
||||
48
crates/application/src/auth/tests/register.rs
Normal file
48
crates/application/src/auth/tests/register.rs
Normal 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");
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
28
crates/application/src/diary/commands.rs
Normal file
28
crates/application/src/diary/commands.rs
Normal 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,
|
||||
}
|
||||
61
crates/application/src/diary/delete_review.rs
Normal file
61
crates/application/src/diary/delete_review.rs
Normal 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;
|
||||
@@ -1,13 +1,15 @@
|
||||
use domain::{errors::DomainError, value_objects::UserId};
|
||||
|
||||
use crate::{context::AppContext, queries::ExportQuery};
|
||||
use crate::{context::AppContext, diary::queries::ExportQuery};
|
||||
|
||||
pub async fn execute(ctx: &AppContext, query: ExportQuery) -> Result<Vec<u8>, DomainError> {
|
||||
let entries = ctx
|
||||
.diary_repository
|
||||
.repos
|
||||
.diary
|
||||
.get_user_history(&UserId::from_uuid(query.user_id))
|
||||
.await?;
|
||||
ctx.diary_exporter
|
||||
ctx.services
|
||||
.diary_exporter
|
||||
.serialize_entries(&entries, query.format)
|
||||
.await
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::{context::AppContext, queries::GetActivityFeedQuery};
|
||||
use crate::{context::AppContext, diary::queries::GetActivityFeedQuery};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{
|
||||
@@ -16,7 +16,8 @@ pub async fn execute(
|
||||
|
||||
let following = build_following_filter(ctx, &query).await;
|
||||
|
||||
ctx.diary_repository
|
||||
ctx.repos
|
||||
.diary
|
||||
.query_activity_feed_filtered(
|
||||
&page,
|
||||
&query.sort_by,
|
||||
@@ -45,6 +46,7 @@ async fn build_following_filter(
|
||||
None => return None,
|
||||
};
|
||||
let urls = _ctx
|
||||
.repos
|
||||
.social_query
|
||||
.get_accepted_following_urls(viewer_id)
|
||||
.await
|
||||
@@ -7,7 +7,7 @@ use domain::{
|
||||
value_objects::{MovieId, UserId},
|
||||
};
|
||||
|
||||
use crate::{context::AppContext, queries::GetDiaryQuery};
|
||||
use crate::{context::AppContext, diary::queries::GetDiaryQuery};
|
||||
|
||||
pub async fn execute(
|
||||
ctx: &AppContext,
|
||||
@@ -25,5 +25,5 @@ pub async fn execute(
|
||||
search: None,
|
||||
};
|
||||
|
||||
ctx.diary_repository.query_diary(&filter).await
|
||||
ctx.repos.diary.query_diary(&filter).await
|
||||
}
|
||||
@@ -7,7 +7,7 @@ use domain::{
|
||||
value_objects::MovieId,
|
||||
};
|
||||
|
||||
use crate::{context::AppContext, queries::GetMovieSocialPageQuery};
|
||||
use crate::{context::AppContext, diary::queries::GetMovieSocialPageQuery};
|
||||
|
||||
pub struct MovieSocialPageResult {
|
||||
pub movie: Movie,
|
||||
@@ -24,15 +24,16 @@ pub async fn execute(
|
||||
let page = PageParams::new(Some(query.limit), Some(query.offset))?;
|
||||
|
||||
let movie = ctx
|
||||
.movie_repository
|
||||
.repos
|
||||
.movie
|
||||
.get_movie_by_id(&movie_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound(format!("Movie {}", query.movie_id)))?;
|
||||
|
||||
let (stats, reviews, profile) = tokio::try_join!(
|
||||
ctx.diary_repository.get_movie_stats(&movie_id),
|
||||
ctx.diary_repository.get_movie_social_feed(&movie_id, &page),
|
||||
ctx.movie_profile_repository.get_by_movie_id(&movie_id),
|
||||
ctx.repos.diary.get_movie_stats(&movie_id),
|
||||
ctx.repos.diary.get_movie_social_feed(&movie_id, &page),
|
||||
ctx.repos.movie_profile.get_by_movie_id(&movie_id),
|
||||
)?;
|
||||
|
||||
Ok(MovieSocialPageResult {
|
||||
@@ -5,7 +5,7 @@ use domain::{
|
||||
value_objects::MovieId,
|
||||
};
|
||||
|
||||
use crate::{context::AppContext, queries::GetReviewHistoryQuery};
|
||||
use crate::{context::AppContext, diary::queries::GetReviewHistoryQuery};
|
||||
|
||||
pub async fn execute(
|
||||
ctx: &AppContext,
|
||||
@@ -13,7 +13,7 @@ pub async fn execute(
|
||||
) -> Result<(ReviewHistory, Trend), DomainError> {
|
||||
let movie_id = MovieId::from_uuid(query.movie_id);
|
||||
|
||||
let mut history = ctx.diary_repository.get_review_history(&movie_id).await?;
|
||||
let mut history = ctx.repos.diary.get_review_history(&movie_id).await?;
|
||||
|
||||
let trend = ReviewHistoryAnalyzer::rating_trend(&history)?;
|
||||
|
||||
98
crates/application/src/diary/log_review.rs
Normal file
98
crates/application/src/diary/log_review.rs
Normal 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(())
|
||||
}
|
||||
10
crates/application/src/diary/mod.rs
Normal file
10
crates/application/src/diary/mod.rs
Normal 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;
|
||||
@@ -6,7 +6,7 @@ use domain::{
|
||||
value_objects::{ExternalMetadataId, MovieTitle, ReleaseYear},
|
||||
};
|
||||
|
||||
use crate::commands::MovieInput;
|
||||
use crate::diary::commands::MovieInput;
|
||||
|
||||
pub struct MovieResolverDeps<'a> {
|
||||
pub repository: &'a dyn MovieRepository,
|
||||
34
crates/application/src/diary/queries.rs
Normal file
34
crates/application/src/diary/queries.rs
Normal 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,
|
||||
}
|
||||
104
crates/application/src/diary/tests/delete_review.rs
Normal file
104
crates/application/src/diary/tests/delete_review.rs
Normal 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");
|
||||
}
|
||||
111
crates/application/src/diary/tests/log_review.rs
Normal file
111
crates/application/src/diary/tests/log_review.rs
Normal 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");
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::*;
|
||||
use crate::commands::MovieInput;
|
||||
use crate::diary::commands::MovieInput;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::Movie,
|
||||
@@ -6,7 +6,5 @@ pub async fn execute(
|
||||
ctx: &AppContext,
|
||||
uuid: uuid::Uuid,
|
||||
) -> Result<Vec<RemoteWatchlistEntry>, DomainError> {
|
||||
ctx.remote_watchlist_repository
|
||||
.get_by_derived_uuid(uuid)
|
||||
.await
|
||||
ctx.repos.remote_watchlist.get_by_derived_uuid(uuid).await
|
||||
}
|
||||
1
crates/application/src/federation/mod.rs
Normal file
1
crates/application/src/federation/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod get_remote_watchlist;
|
||||
@@ -4,7 +4,7 @@ use domain::{
|
||||
value_objects::{ExternalMetadataId, ImportSessionId, MovieTitle, ReleaseYear, UserId},
|
||||
};
|
||||
|
||||
use crate::{commands::ApplyImportMappingCommand, context::AppContext};
|
||||
use crate::{context::AppContext, import::commands::ApplyImportMappingCommand};
|
||||
|
||||
pub async fn execute(
|
||||
ctx: &AppContext,
|
||||
@@ -14,7 +14,8 @@ pub async fn execute(
|
||||
let session_id = ImportSessionId::from_uuid(cmd.session_id);
|
||||
let mappings = cmd.mappings;
|
||||
let mut session = ctx
|
||||
.import_session_repository
|
||||
.repos
|
||||
.import_session
|
||||
.get(&session_id, &user_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound("import session".into()))?;
|
||||
@@ -25,7 +26,10 @@ pub async fn execute(
|
||||
.clone()
|
||||
.ok_or_else(|| DomainError::ValidationError("session has no parsed file".into()))?;
|
||||
|
||||
let mut annotated = ctx.document_parser.apply_mapping(&parsed, &mappings);
|
||||
let mut annotated = ctx
|
||||
.services
|
||||
.document_parser
|
||||
.apply_mapping(&parsed, &mappings);
|
||||
|
||||
for row in annotated.iter_mut() {
|
||||
if let RowResult::Valid(ref import_row) = row.result {
|
||||
@@ -36,7 +40,7 @@ pub async fn execute(
|
||||
session.field_mappings = Some(mappings);
|
||||
session.row_results = Some(annotated.clone());
|
||||
|
||||
ctx.import_session_repository.update(&session).await?;
|
||||
ctx.repos.import_session.update(&session).await?;
|
||||
|
||||
Ok(annotated)
|
||||
}
|
||||
@@ -48,7 +52,8 @@ async fn check_duplicate(
|
||||
if let Some(ext_id) = &row.external_metadata_id
|
||||
&& let Ok(eid) = ExternalMetadataId::new(ext_id.clone())
|
||||
&& ctx
|
||||
.movie_repository
|
||||
.repos
|
||||
.movie
|
||||
.get_movie_by_external_id(&eid)
|
||||
.await?
|
||||
.is_some()
|
||||
@@ -62,10 +67,7 @@ async fn check_duplicate(
|
||||
.ok()
|
||||
.and_then(|y| ReleaseYear::new(y).ok());
|
||||
if let (Ok(t), Some(y)) = (title_vo, year_vo) {
|
||||
let matches = ctx
|
||||
.movie_repository
|
||||
.get_movies_by_title_and_year(&t, &y)
|
||||
.await?;
|
||||
let matches = ctx.repos.movie.get_movies_by_title_and_year(&t, &y).await?;
|
||||
if !matches.is_empty() {
|
||||
return Ok(true);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::{commands::ApplyImportProfileCommand, context::AppContext};
|
||||
use crate::{context::AppContext, import::commands::ApplyImportProfileCommand};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
value_objects::{ImportProfileId, ImportSessionId, UserId},
|
||||
@@ -12,16 +12,18 @@ pub async fn execute(ctx: &AppContext, cmd: ApplyImportProfileCommand) -> Result
|
||||
let profile_id = ImportProfileId::from_uuid(cmd.profile_id);
|
||||
|
||||
let profile = ctx
|
||||
.import_profile_repository
|
||||
.repos
|
||||
.import_profile
|
||||
.get(&profile_id, &user_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound("import profile".into()))?;
|
||||
let mut session = ctx
|
||||
.import_session_repository
|
||||
.repos
|
||||
.import_session
|
||||
.get(&session_id, &user_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound("import session".into()))?;
|
||||
session.field_mappings = Some(profile.field_mappings);
|
||||
session.row_results = None;
|
||||
ctx.import_session_repository.update(&session).await
|
||||
ctx.repos.import_session.update(&session).await
|
||||
}
|
||||
@@ -2,5 +2,5 @@ use crate::context::AppContext;
|
||||
use domain::errors::DomainError;
|
||||
|
||||
pub async fn execute(ctx: &AppContext) -> Result<u64, DomainError> {
|
||||
ctx.import_session_repository.delete_expired().await
|
||||
ctx.repos.import_session.delete_expired().await
|
||||
}
|
||||
37
crates/application/src/import/commands.rs
Normal file
37
crates/application/src/import/commands.rs
Normal 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,
|
||||
}
|
||||
@@ -5,7 +5,7 @@ use domain::{
|
||||
value_objects::{ImportSessionId, UserId},
|
||||
};
|
||||
|
||||
use crate::{commands::CreateImportSessionCommand, context::AppContext};
|
||||
use crate::{context::AppContext, import::commands::CreateImportSessionCommand};
|
||||
|
||||
pub struct CreateSessionResult {
|
||||
pub session_id: ImportSessionId,
|
||||
@@ -18,11 +18,13 @@ pub async fn execute(
|
||||
cmd: CreateImportSessionCommand,
|
||||
) -> Result<CreateSessionResult, DomainError> {
|
||||
let user_id = UserId::from_uuid(cmd.user_id);
|
||||
ctx.import_session_repository
|
||||
ctx.repos
|
||||
.import_session
|
||||
.delete_expired_for_user(&user_id)
|
||||
.await?;
|
||||
|
||||
let parsed = ctx
|
||||
.services
|
||||
.document_parser
|
||||
.parse(&cmd.bytes, cmd.format)
|
||||
.map_err(|e| DomainError::ValidationError(e.to_string()))?;
|
||||
@@ -35,7 +37,7 @@ pub async fn execute(
|
||||
let session_id = session.id.clone();
|
||||
session.parsed_file = Some(parsed);
|
||||
|
||||
ctx.import_session_repository.create(&session).await?;
|
||||
ctx.repos.import_session.create(&session).await?;
|
||||
|
||||
Ok(CreateSessionResult {
|
||||
session_id,
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::{commands::DeleteImportProfileCommand, context::AppContext};
|
||||
use crate::{context::AppContext, import::commands::DeleteImportProfileCommand};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
value_objects::{ImportProfileId, UserId},
|
||||
@@ -8,9 +8,10 @@ pub async fn execute(ctx: &AppContext, cmd: DeleteImportProfileCommand) -> Resul
|
||||
let user_id = UserId::from_uuid(cmd.user_id);
|
||||
let profile_id = ImportProfileId::from_uuid(cmd.profile_id);
|
||||
|
||||
ctx.import_profile_repository
|
||||
ctx.repos
|
||||
.import_profile
|
||||
.get(&profile_id, &user_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound("import profile".into()))?;
|
||||
ctx.import_profile_repository.delete(&profile_id).await
|
||||
ctx.repos.import_profile.delete(&profile_id).await
|
||||
}
|
||||
@@ -7,9 +7,10 @@ use domain::{
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
commands::{ExecuteImportCommand, LogReviewCommand, MovieInput},
|
||||
context::AppContext,
|
||||
use_cases::log_review,
|
||||
diary::commands::{LogReviewCommand, MovieInput},
|
||||
diary::log_review,
|
||||
import::commands::ExecuteImportCommand,
|
||||
};
|
||||
|
||||
pub struct ImportSummary {
|
||||
@@ -26,7 +27,8 @@ pub async fn execute(
|
||||
let session_id = ImportSessionId::from_uuid(cmd.session_id);
|
||||
let confirmed_indices = cmd.confirmed_indices;
|
||||
let session = ctx
|
||||
.import_session_repository
|
||||
.repos
|
||||
.import_session
|
||||
.get(&session_id, &user_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound("import session".into()))?;
|
||||
@@ -57,7 +59,7 @@ pub async fn execute(
|
||||
}
|
||||
}
|
||||
|
||||
ctx.import_session_repository.delete(&session_id).await?;
|
||||
ctx.repos.import_session.delete(&session_id).await?;
|
||||
|
||||
Ok(ImportSummary {
|
||||
imported,
|
||||
@@ -5,5 +5,5 @@ pub async fn execute(
|
||||
ctx: &AppContext,
|
||||
user_id: &UserId,
|
||||
) -> Result<Vec<ImportProfile>, DomainError> {
|
||||
ctx.import_profile_repository.list_for_user(user_id).await
|
||||
ctx.repos.import_profile.list_for_user(user_id).await
|
||||
}
|
||||
9
crates/application/src/import/mod.rs
Normal file
9
crates/application/src/import/mod.rs
Normal 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;
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::{commands::SaveImportProfileCommand, context::AppContext};
|
||||
use crate::{context::AppContext, import::commands::SaveImportProfileCommand};
|
||||
use chrono::Utc;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
@@ -14,7 +14,8 @@ pub async fn execute(
|
||||
let session_id = ImportSessionId::from_uuid(cmd.session_id);
|
||||
|
||||
let session = ctx
|
||||
.import_session_repository
|
||||
.repos
|
||||
.import_session
|
||||
.get(&session_id, &user_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound("import session".into()))?;
|
||||
@@ -29,6 +30,6 @@ pub async fn execute(
|
||||
Utc::now().naive_utc(),
|
||||
);
|
||||
let id = profile.id.clone();
|
||||
ctx.import_profile_repository.save(&profile).await?;
|
||||
ctx.repos.import_profile.save(&profile).await?;
|
||||
Ok(id)
|
||||
}
|
||||
@@ -5,7 +5,8 @@ use crate::context::AppContext;
|
||||
|
||||
pub async fn execute(ctx: &AppContext) -> Result<u64, DomainError> {
|
||||
let cutoff = chrono::Utc::now().naive_utc() - Duration::days(30);
|
||||
ctx.watch_event_repository
|
||||
ctx.repos
|
||||
.watch_event
|
||||
.delete_non_pending_older_than(cutoff)
|
||||
.await
|
||||
}
|
||||
34
crates/application/src/integrations/commands.rs
Normal file
34
crates/application/src/integrations/commands.rs
Normal 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,
|
||||
}
|
||||
@@ -5,9 +5,10 @@ use domain::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
commands::{ConfirmWatchEventsCommand, LogReviewCommand, MovieInput},
|
||||
context::AppContext,
|
||||
use_cases::log_review,
|
||||
diary::commands::{LogReviewCommand, MovieInput},
|
||||
diary::log_review,
|
||||
integrations::commands::ConfirmWatchEventsCommand,
|
||||
};
|
||||
|
||||
pub async fn execute(ctx: &AppContext, cmd: ConfirmWatchEventsCommand) -> Result<u32, DomainError> {
|
||||
@@ -17,7 +18,8 @@ pub async fn execute(ctx: &AppContext, cmd: ConfirmWatchEventsCommand) -> Result
|
||||
for c in cmd.confirmations {
|
||||
let event_id = WatchEventId::from_uuid(c.watch_event_id);
|
||||
let event = ctx
|
||||
.watch_event_repository
|
||||
.repos
|
||||
.watch_event
|
||||
.get_by_id(&event_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound(format!("WatchEvent {}", c.watch_event_id)))?;
|
||||
@@ -54,7 +56,8 @@ pub async fn execute(ctx: &AppContext, cmd: ConfirmWatchEventsCommand) -> Result
|
||||
|
||||
log_review::execute(ctx, review_cmd).await?;
|
||||
|
||||
ctx.watch_event_repository
|
||||
ctx.repos
|
||||
.watch_event
|
||||
.update_status(&event_id, WatchEventStatus::Confirmed)
|
||||
.await?;
|
||||
|
||||
@@ -4,7 +4,7 @@ use domain::{
|
||||
value_objects::{UserId, WatchEventId},
|
||||
};
|
||||
|
||||
use crate::{commands::DismissWatchEventsCommand, context::AppContext};
|
||||
use crate::{context::AppContext, integrations::commands::DismissWatchEventsCommand};
|
||||
|
||||
pub async fn execute(ctx: &AppContext, cmd: DismissWatchEventsCommand) -> Result<u32, DomainError> {
|
||||
let user_id = UserId::from_uuid(cmd.user_id);
|
||||
@@ -13,7 +13,8 @@ pub async fn execute(ctx: &AppContext, cmd: DismissWatchEventsCommand) -> Result
|
||||
for id in cmd.event_ids {
|
||||
let event_id = WatchEventId::from_uuid(id);
|
||||
let event = ctx
|
||||
.watch_event_repository
|
||||
.repos
|
||||
.watch_event
|
||||
.get_by_id(&event_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound(format!("WatchEvent {id}")))?;
|
||||
@@ -22,7 +23,8 @@ pub async fn execute(ctx: &AppContext, cmd: DismissWatchEventsCommand) -> Result
|
||||
return Err(DomainError::Unauthorized("not your watch event".into()));
|
||||
}
|
||||
|
||||
ctx.watch_event_repository
|
||||
ctx.repos
|
||||
.watch_event
|
||||
.update_status(&event_id, WatchEventStatus::Dismissed)
|
||||
.await?;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use domain::{errors::DomainError, models::WebhookToken, value_objects::UserId};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::{commands::GenerateWebhookTokenCommand, context::AppContext};
|
||||
use crate::{context::AppContext, integrations::commands::GenerateWebhookTokenCommand};
|
||||
|
||||
pub struct GeneratedWebhookToken {
|
||||
pub token_plaintext: String,
|
||||
@@ -18,7 +18,7 @@ pub async fn execute(
|
||||
let user_id = UserId::from_uuid(cmd.user_id);
|
||||
let token = WebhookToken::new(user_id, hash, cmd.provider, cmd.label);
|
||||
|
||||
ctx.webhook_token_repository.save(&token).await?;
|
||||
ctx.repos.webhook_token.save(&token).await?;
|
||||
|
||||
Ok(GeneratedWebhookToken {
|
||||
token_plaintext: plaintext,
|
||||
@@ -1,11 +1,11 @@
|
||||
use domain::{errors::DomainError, models::WatchEvent, value_objects::UserId};
|
||||
|
||||
use crate::{context::AppContext, queries::GetWatchQueueQuery};
|
||||
use crate::{context::AppContext, integrations::queries::GetWatchQueueQuery};
|
||||
|
||||
pub async fn execute(
|
||||
ctx: &AppContext,
|
||||
query: GetWatchQueueQuery,
|
||||
) -> Result<Vec<WatchEvent>, DomainError> {
|
||||
let user_id = UserId::from_uuid(query.user_id);
|
||||
ctx.watch_event_repository.list_pending(&user_id).await
|
||||
ctx.repos.watch_event.list_pending(&user_id).await
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
use domain::{errors::DomainError, models::WebhookToken, value_objects::UserId};
|
||||
|
||||
use crate::{context::AppContext, queries::GetWebhookTokensQuery};
|
||||
use crate::{context::AppContext, integrations::queries::GetWebhookTokensQuery};
|
||||
|
||||
pub async fn execute(
|
||||
ctx: &AppContext,
|
||||
query: GetWebhookTokensQuery,
|
||||
) -> Result<Vec<WebhookToken>, DomainError> {
|
||||
let user_id = UserId::from_uuid(query.user_id);
|
||||
ctx.webhook_token_repository.list_by_user(&user_id).await
|
||||
ctx.repos.webhook_token.list_by_user(&user_id).await
|
||||
}
|
||||
@@ -3,24 +3,24 @@ use domain::{
|
||||
errors::DomainError, events::DomainEvent, models::WatchEvent, ports::MediaServerParser,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
commands::IngestWatchEventCommand, context::AppContext, use_cases::generate_webhook_token,
|
||||
};
|
||||
use crate::{context::AppContext, integrations::commands::IngestWatchEventCommand};
|
||||
|
||||
pub async fn execute(
|
||||
ctx: &AppContext,
|
||||
cmd: IngestWatchEventCommand,
|
||||
parser: &dyn MediaServerParser,
|
||||
) -> Result<(), DomainError> {
|
||||
let token_hash = generate_webhook_token::hash_token(&cmd.token);
|
||||
let token_hash = super::generate_token::hash_token(&cmd.token);
|
||||
let webhook_token = ctx
|
||||
.webhook_token_repository
|
||||
.repos
|
||||
.webhook_token
|
||||
.find_by_token_hash(&token_hash)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::Unauthorized("invalid webhook token".into()))?;
|
||||
|
||||
let _ = ctx
|
||||
.webhook_token_repository
|
||||
.repos
|
||||
.webhook_token
|
||||
.touch_last_used(webhook_token.id())
|
||||
.await;
|
||||
|
||||
@@ -35,7 +35,8 @@ pub async fn execute(
|
||||
if let Some(ref ext_id) = external_metadata_id {
|
||||
let one_hour_ago = chrono::Utc::now().naive_utc() - Duration::hours(1);
|
||||
if ctx
|
||||
.watch_event_repository
|
||||
.repos
|
||||
.watch_event
|
||||
.find_duplicate(&user_id, ext_id, one_hour_ago)
|
||||
.await?
|
||||
{
|
||||
@@ -54,9 +55,10 @@ pub async fn execute(
|
||||
None,
|
||||
);
|
||||
|
||||
ctx.watch_event_repository.save(&event).await?;
|
||||
ctx.repos.watch_event.save(&event).await?;
|
||||
|
||||
let _ = ctx
|
||||
.services
|
||||
.event_publisher
|
||||
.publish(&DomainEvent::WatchEventIngested {
|
||||
user_id: event.user_id().clone(),
|
||||
10
crates/application/src/integrations/mod.rs
Normal file
10
crates/application/src/integrations/mod.rs
Normal 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;
|
||||
9
crates/application/src/integrations/queries.rs
Normal file
9
crates/application/src/integrations/queries.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct GetWatchQueueQuery {
|
||||
pub user_id: Uuid,
|
||||
}
|
||||
|
||||
pub struct GetWebhookTokensQuery {
|
||||
pub user_id: Uuid,
|
||||
}
|
||||
@@ -3,12 +3,10 @@ use domain::{
|
||||
value_objects::{UserId, WebhookTokenId},
|
||||
};
|
||||
|
||||
use crate::{commands::RevokeWebhookTokenCommand, context::AppContext};
|
||||
use crate::{context::AppContext, integrations::commands::RevokeWebhookTokenCommand};
|
||||
|
||||
pub async fn execute(ctx: &AppContext, cmd: RevokeWebhookTokenCommand) -> Result<(), DomainError> {
|
||||
let user_id = UserId::from_uuid(cmd.user_id);
|
||||
let token_id = WebhookTokenId::from_uuid(cmd.token_id);
|
||||
ctx.webhook_token_repository
|
||||
.delete(&token_id, &user_id)
|
||||
.await
|
||||
ctx.repos.webhook_token.delete(&token_id, &user_id).await
|
||||
}
|
||||
@@ -22,7 +22,7 @@ impl PeriodicJob for ImportSessionCleanupJob {
|
||||
}
|
||||
|
||||
async fn run(&self) -> Result<(), DomainError> {
|
||||
let n = crate::use_cases::cleanup_expired_import_sessions::execute(&self.ctx).await?;
|
||||
let n = crate::import::cleanup::execute(&self.ctx).await?;
|
||||
tracing::info!("import session cleanup: removed {} expired sessions", n);
|
||||
Ok(())
|
||||
}
|
||||
@@ -45,7 +45,7 @@ impl PeriodicJob for WatchEventCleanupJob {
|
||||
}
|
||||
|
||||
async fn run(&self) -> Result<(), DomainError> {
|
||||
let n = crate::use_cases::cleanup_watch_events::execute(&self.ctx).await?;
|
||||
let n = crate::integrations::cleanup::execute(&self.ctx).await?;
|
||||
if n > 0 {
|
||||
tracing::info!("watch event cleanup: removed {n} old entries");
|
||||
}
|
||||
@@ -70,7 +70,7 @@ impl PeriodicJob for EnrichmentStalenessJob {
|
||||
}
|
||||
|
||||
async fn run(&self) -> Result<(), DomainError> {
|
||||
let stale = self.ctx.movie_profile_repository.list_stale().await?;
|
||||
let stale = self.ctx.repos.movie_profile.list_stale().await?;
|
||||
if stale.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -80,7 +80,7 @@ impl PeriodicJob for EnrichmentStalenessJob {
|
||||
movie_id,
|
||||
external_metadata_id,
|
||||
};
|
||||
self.ctx.event_publisher.publish(&event).await?;
|
||||
self.ctx.services.event_publisher.publish(&event).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
pub mod commands;
|
||||
pub mod config;
|
||||
pub mod context;
|
||||
pub mod jobs;
|
||||
pub mod movie_discovery_indexer;
|
||||
pub mod movie_resolver;
|
||||
pub mod ports;
|
||||
pub mod queries;
|
||||
pub mod search_cleanup;
|
||||
pub mod use_cases;
|
||||
pub mod worker;
|
||||
|
||||
pub mod auth;
|
||||
pub mod diary;
|
||||
#[cfg(feature = "federation")]
|
||||
pub mod federation;
|
||||
pub mod import;
|
||||
pub mod integrations;
|
||||
pub mod movies;
|
||||
pub mod person;
|
||||
pub mod search;
|
||||
pub mod users;
|
||||
pub mod watchlist;
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod test_helpers;
|
||||
|
||||
pub use movie_discovery_indexer::MovieDiscoveryIndexer;
|
||||
pub use search_cleanup::SearchCleanupHandler;
|
||||
pub use movies::MovieDiscoveryIndexer;
|
||||
pub use movies::SearchCleanupHandler;
|
||||
|
||||
4
crates/application/src/movies/commands.rs
Normal file
4
crates/application/src/movies/commands.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub struct EnrichMovieCommand {
|
||||
pub movie_id: domain::value_objects::MovieId,
|
||||
pub profile: domain::models::MovieProfile,
|
||||
}
|
||||
@@ -7,7 +7,7 @@ use domain::{
|
||||
ports::{MovieProfileRepository, MovieRepository, PersonCommand, SearchCommand},
|
||||
};
|
||||
|
||||
use crate::commands::EnrichMovieCommand;
|
||||
use crate::movies::commands::EnrichMovieCommand;
|
||||
|
||||
pub async fn execute(
|
||||
movie_repository: &Arc<dyn MovieRepository>,
|
||||
@@ -4,7 +4,7 @@ use domain::{
|
||||
models::{MovieFilter, MovieSummary},
|
||||
};
|
||||
|
||||
use crate::{context::AppContext, queries::GetMoviesQuery};
|
||||
use crate::{context::AppContext, movies::queries::GetMoviesQuery};
|
||||
|
||||
pub async fn execute(
|
||||
ctx: &AppContext,
|
||||
@@ -16,5 +16,5 @@ pub async fn execute(
|
||||
genre: query.genre,
|
||||
language: query.language,
|
||||
};
|
||||
ctx.movie_repository.list_movies(&page, &filter).await
|
||||
ctx.repos.movie.list_movies(&page, &filter).await
|
||||
}
|
||||
10
crates/application/src/movies/mod.rs
Normal file
10
crates/application/src/movies/mod.rs
Normal 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;
|
||||
7
crates/application/src/movies/queries.rs
Normal file
7
crates/application/src/movies/queries.rs
Normal 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>,
|
||||
}
|
||||
@@ -5,12 +5,12 @@ use domain::{
|
||||
value_objects::{MovieId, PosterPath},
|
||||
};
|
||||
|
||||
use crate::{commands::SyncPosterCommand, context::AppContext};
|
||||
use crate::{context::AppContext, diary::commands::SyncPosterCommand};
|
||||
|
||||
pub async fn execute(ctx: &AppContext, cmd: SyncPosterCommand) -> Result<(), DomainError> {
|
||||
let movie_id = MovieId::from_uuid(cmd.movie_id);
|
||||
|
||||
let mut movie = match ctx.movie_repository.get_movie_by_id(&movie_id).await? {
|
||||
let mut movie = match ctx.repos.movie.get_movie_by_id(&movie_id).await? {
|
||||
Some(m) => m,
|
||||
None => {
|
||||
tracing::warn!(
|
||||
@@ -31,7 +31,8 @@ pub async fn execute(ctx: &AppContext, cmd: SyncPosterCommand) -> Result<(), Dom
|
||||
.clone();
|
||||
|
||||
let poster_url = match ctx
|
||||
.metadata_client
|
||||
.services
|
||||
.metadata
|
||||
.get_poster_url(&external_metadata_id)
|
||||
.await
|
||||
{
|
||||
@@ -43,14 +44,20 @@ pub async fn execute(ctx: &AppContext, cmd: SyncPosterCommand) -> Result<(), Dom
|
||||
}
|
||||
};
|
||||
|
||||
let image_bytes = ctx.poster_fetcher.fetch_poster_bytes(&poster_url).await?;
|
||||
let image_bytes = ctx
|
||||
.services
|
||||
.poster_fetcher
|
||||
.fetch_poster_bytes(&poster_url)
|
||||
.await?;
|
||||
|
||||
let stored_path = ctx
|
||||
.services
|
||||
.image_storage
|
||||
.store(&movie_id.value().to_string(), &image_bytes)
|
||||
.await?;
|
||||
|
||||
if let Err(e) = ctx
|
||||
.services
|
||||
.event_publisher
|
||||
.publish(&DomainEvent::ImageStored {
|
||||
key: stored_path.clone(),
|
||||
@@ -63,17 +70,19 @@ pub async fn execute(ctx: &AppContext, cmd: SyncPosterCommand) -> Result<(), Dom
|
||||
let poster_path = PosterPath::new(stored_path)?;
|
||||
|
||||
movie.update_poster(poster_path);
|
||||
ctx.movie_repository.upsert_movie(&movie).await?;
|
||||
ctx.repos.movie.upsert_movie(&movie).await?;
|
||||
|
||||
// Refresh search index so the new poster_path is reflected immediately.
|
||||
// Fetch existing profile if available for a complete index document.
|
||||
let profile = ctx
|
||||
.movie_profile_repository
|
||||
.repos
|
||||
.movie_profile
|
||||
.get_by_movie_id(&movie_id)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
if let Err(e) = ctx
|
||||
.repos
|
||||
.search_command
|
||||
.index(IndexableDocument::Movie {
|
||||
id: movie_id.clone(),
|
||||
@@ -5,5 +5,5 @@ use domain::{
|
||||
};
|
||||
|
||||
pub async fn execute(ctx: &AppContext, id: PersonId) -> Result<Option<Person>, DomainError> {
|
||||
ctx.person_query.get_by_id(&id).await
|
||||
ctx.repos.person_query.get_by_id(&id).await
|
||||
}
|
||||
@@ -5,5 +5,5 @@ use domain::{
|
||||
};
|
||||
|
||||
pub async fn execute(ctx: &AppContext, id: PersonId) -> Result<PersonCredits, DomainError> {
|
||||
ctx.person_query.get_credits(&id).await
|
||||
ctx.repos.person_query.get_credits(&id).await
|
||||
}
|
||||
2
crates/application/src/person/mod.rs
Normal file
2
crates/application/src/person/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod get;
|
||||
pub mod get_credits;
|
||||
@@ -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,
|
||||
}
|
||||
@@ -5,5 +5,5 @@ use domain::{
|
||||
};
|
||||
|
||||
pub async fn execute(ctx: &AppContext, query: SearchQuery) -> Result<SearchResults, DomainError> {
|
||||
ctx.search_port.search(&query).await
|
||||
ctx.repos.search_port.search(&query).await
|
||||
}
|
||||
1
crates/application/src/search/mod.rs
Normal file
1
crates/application/src/search/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod execute;
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
17
crates/application/src/users/commands.rs
Normal file
17
crates/application/src/users/commands.rs
Normal 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>,
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use domain::errors::DomainError;
|
||||
|
||||
use crate::{context::AppContext, queries::GetCurrentProfileQuery};
|
||||
use crate::{context::AppContext, users::queries::GetCurrentProfileQuery};
|
||||
|
||||
pub struct CurrentProfileData {
|
||||
pub username: String,
|
||||
@@ -14,7 +14,8 @@ pub async fn execute(
|
||||
) -> Result<CurrentProfileData, DomainError> {
|
||||
let user_id = domain::value_objects::UserId::from_uuid(query.user_id);
|
||||
let user = ctx
|
||||
.user_repository
|
||||
.repos
|
||||
.user
|
||||
.find_by_id(&user_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound("User not found".into()))?;
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::{
|
||||
context::AppContext,
|
||||
queries::{GetUserProfileQuery, ProfileView},
|
||||
users::queries::{GetUserProfileQuery, ProfileView},
|
||||
};
|
||||
use chrono::Datelike;
|
||||
use domain::{
|
||||
@@ -35,7 +35,7 @@ pub async fn execute(
|
||||
query: GetUserProfileQuery,
|
||||
) -> Result<UserProfileData, DomainError> {
|
||||
let user_id = UserId::from_uuid(query.user_id);
|
||||
let stats = ctx.stats_repository.get_user_stats(&user_id).await?;
|
||||
let stats = ctx.repos.stats.get_user_stats(&user_id).await?;
|
||||
|
||||
let (following_count, followers_count, pending_followers) =
|
||||
load_social_counts(ctx, query.user_id, query.is_own_profile).await;
|
||||
@@ -52,12 +52,12 @@ pub async fn execute(
|
||||
|
||||
match query.view {
|
||||
ProfileView::History => {
|
||||
let all_entries = ctx.diary_repository.get_user_history(&user_id).await?;
|
||||
let all_entries = ctx.repos.diary.get_user_history(&user_id).await?;
|
||||
let history = group_by_month(all_entries);
|
||||
Ok(base(None, Some(history), None))
|
||||
}
|
||||
ProfileView::Trends => {
|
||||
let trends = ctx.stats_repository.get_user_trends(&user_id).await?;
|
||||
let trends = ctx.repos.stats.get_user_trends(&user_id).await?;
|
||||
Ok(base(None, None, Some(trends)))
|
||||
}
|
||||
ProfileView::Ratings | ProfileView::Recent => {
|
||||
@@ -69,7 +69,7 @@ pub async fn execute(
|
||||
query.offset,
|
||||
query.search.clone(),
|
||||
)?;
|
||||
let entries = ctx.diary_repository.query_diary(&filter).await?;
|
||||
let entries = ctx.repos.diary.query_diary(&filter).await?;
|
||||
Ok(base(Some(entries), None, None))
|
||||
}
|
||||
}
|
||||
@@ -90,16 +90,19 @@ async fn load_social_counts(
|
||||
return (0, 0, vec![]);
|
||||
}
|
||||
let following = _ctx
|
||||
.repos
|
||||
.social_query
|
||||
.count_following(_user_id)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let followers = _ctx
|
||||
.repos
|
||||
.social_query
|
||||
.count_accepted_followers(_user_id)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let pending = _ctx
|
||||
.repos
|
||||
.social_query
|
||||
.get_pending_followers(_user_id)
|
||||
.await
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::{context::AppContext, queries::GetUsersQuery};
|
||||
use crate::{context::AppContext, users::queries::GetUsersQuery};
|
||||
use domain::{errors::DomainError, models::UserSummary, ports::RemoteActorInfo};
|
||||
|
||||
pub struct UsersListData {
|
||||
@@ -12,12 +12,12 @@ pub async fn execute(
|
||||
) -> Result<UsersListData, DomainError> {
|
||||
#[cfg(feature = "federation")]
|
||||
let (users_result, actors_result) = tokio::join!(
|
||||
ctx.user_repository.list_with_stats(),
|
||||
ctx.social_query.list_all_followed_remote_actors()
|
||||
ctx.repos.user.list_with_stats(),
|
||||
ctx.repos.social_query.list_all_followed_remote_actors()
|
||||
);
|
||||
#[cfg(not(feature = "federation"))]
|
||||
let (users_result, actors_result) = (
|
||||
ctx.user_repository.list_with_stats().await,
|
||||
ctx.repos.user.list_with_stats().await,
|
||||
Ok::<Vec<RemoteActorInfo>, DomainError>(vec![]),
|
||||
);
|
||||
|
||||
7
crates/application/src/users/mod.rs
Normal file
7
crates/application/src/users/mod.rs
Normal 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;
|
||||
50
crates/application/src/users/queries.rs
Normal file
50
crates/application/src/users/queries.rs
Normal 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,
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
use domain::{errors::DomainError, events::DomainEvent, value_objects::UserId};
|
||||
|
||||
use crate::{commands::UpdateProfileCommand, context::AppContext};
|
||||
use crate::{context::AppContext, users::commands::UpdateProfileCommand};
|
||||
|
||||
pub async fn execute(ctx: &AppContext, cmd: UpdateProfileCommand) -> Result<(), DomainError> {
|
||||
let user_id = UserId::from_uuid(cmd.user_id);
|
||||
|
||||
let user = ctx
|
||||
.user_repository
|
||||
.repos
|
||||
.user
|
||||
.find_by_id(&user_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound("User not found".into()))?;
|
||||
@@ -20,11 +21,12 @@ pub async fn execute(ctx: &AppContext, cmd: UpdateProfileCommand) -> Result<(),
|
||||
));
|
||||
}
|
||||
if let Some(old_path) = user.avatar_path() {
|
||||
let _ = ctx.image_storage.delete(old_path).await;
|
||||
let _ = ctx.services.image_storage.delete(old_path).await;
|
||||
}
|
||||
let key = format!("avatars/{}", user_id.value());
|
||||
let stored = ctx.image_storage.store(&key, &bytes).await?;
|
||||
let stored = ctx.services.image_storage.store(&key, &bytes).await?;
|
||||
if let Err(e) = ctx
|
||||
.services
|
||||
.event_publisher
|
||||
.publish(&DomainEvent::ImageStored {
|
||||
key: stored.clone(),
|
||||
@@ -47,11 +49,12 @@ pub async fn execute(ctx: &AppContext, cmd: UpdateProfileCommand) -> Result<(),
|
||||
));
|
||||
}
|
||||
if let Some(old_path) = user.banner_path() {
|
||||
let _ = ctx.image_storage.delete(old_path).await;
|
||||
let _ = ctx.services.image_storage.delete(old_path).await;
|
||||
}
|
||||
let key = format!("banners/{}", user_id.value());
|
||||
let stored = ctx.image_storage.store(&key, &bytes).await?;
|
||||
let stored = ctx.services.image_storage.store(&key, &bytes).await?;
|
||||
if let Err(e) = ctx
|
||||
.services
|
||||
.event_publisher
|
||||
.publish(&DomainEvent::ImageStored {
|
||||
key: stored.clone(),
|
||||
@@ -65,7 +68,8 @@ pub async fn execute(ctx: &AppContext, cmd: UpdateProfileCommand) -> Result<(),
|
||||
user.banner_path().map(|s| s.to_string())
|
||||
};
|
||||
|
||||
ctx.user_repository
|
||||
ctx.repos
|
||||
.user
|
||||
.update_profile(
|
||||
&user_id,
|
||||
&domain::models::UserProfile {
|
||||
@@ -79,7 +83,8 @@ pub async fn execute(ctx: &AppContext, cmd: UpdateProfileCommand) -> Result<(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
ctx.event_publisher
|
||||
ctx.services
|
||||
.event_publisher
|
||||
.publish(&DomainEvent::UserUpdated { user_id })
|
||||
.await?;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use domain::{errors::DomainError, events::DomainEvent, value_objects::UserId};
|
||||
|
||||
use crate::{commands::UpdateProfileFieldsCommand, context::AppContext};
|
||||
use crate::{context::AppContext, users::commands::UpdateProfileFieldsCommand};
|
||||
|
||||
pub async fn execute(ctx: &AppContext, cmd: UpdateProfileFieldsCommand) -> Result<(), DomainError> {
|
||||
if cmd.fields.len() > 4 {
|
||||
@@ -9,10 +9,12 @@ pub async fn execute(ctx: &AppContext, cmd: UpdateProfileFieldsCommand) -> Resul
|
||||
));
|
||||
}
|
||||
let user_id = UserId::from_uuid(cmd.user_id);
|
||||
ctx.profile_fields_repository
|
||||
ctx.repos
|
||||
.profile_fields
|
||||
.set_fields(&user_id, cmd.fields)
|
||||
.await?;
|
||||
ctx.event_publisher
|
||||
ctx.services
|
||||
.event_publisher
|
||||
.publish(&DomainEvent::UserUpdated { user_id })
|
||||
.await?;
|
||||
Ok(())
|
||||
69
crates/application/src/watchlist/add.rs
Normal file
69
crates/application/src/watchlist/add.rs
Normal 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;
|
||||
13
crates/application/src/watchlist/commands.rs
Normal file
13
crates/application/src/watchlist/commands.rs
Normal 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,
|
||||
}
|
||||
@@ -7,7 +7,7 @@ use domain::{
|
||||
value_objects::UserId,
|
||||
};
|
||||
|
||||
use crate::{context::AppContext, queries::GetWatchlistQuery};
|
||||
use crate::{context::AppContext, watchlist::queries::GetWatchlistQuery};
|
||||
|
||||
pub async fn execute(
|
||||
ctx: &AppContext,
|
||||
@@ -15,5 +15,5 @@ pub async fn execute(
|
||||
) -> Result<Paginated<WatchlistWithMovie>, DomainError> {
|
||||
let user_id = UserId::from_uuid(query.user_id);
|
||||
let page = PageParams::new(query.limit, query.offset)?;
|
||||
ctx.watchlist_repository.get_for_user(&user_id, &page).await
|
||||
ctx.repos.watchlist.get_for_user(&user_id, &page).await
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
use domain::{errors::DomainError, value_objects::UserId};
|
||||
|
||||
use crate::{context::AppContext, ports::WatchlistDisplayEntry, queries::GetWatchlistQuery};
|
||||
use crate::{
|
||||
context::AppContext, ports::WatchlistDisplayEntry, watchlist::queries::GetWatchlistQuery,
|
||||
};
|
||||
|
||||
pub struct WatchlistPageResult {
|
||||
pub display_entries: Vec<WatchlistDisplayEntry>,
|
||||
@@ -15,10 +17,10 @@ pub async fn execute(
|
||||
is_owner: bool,
|
||||
) -> Result<WatchlistPageResult, DomainError> {
|
||||
let user_id = UserId::from_uuid(query.user_id);
|
||||
let is_local = ctx.user_repository.find_by_id(&user_id).await?.is_some();
|
||||
let is_local = ctx.repos.user.find_by_id(&user_id).await?.is_some();
|
||||
|
||||
if is_local {
|
||||
let page = super::get_watchlist::execute(ctx, query).await?;
|
||||
let page = crate::watchlist::get::execute(ctx, query).await?;
|
||||
let has_more = page.offset + page.limit < page.total_count as u32;
|
||||
let display_entries = page
|
||||
.items
|
||||
@@ -71,7 +73,7 @@ async fn load_remote_watchlist(
|
||||
ctx: &AppContext,
|
||||
user_id: uuid::Uuid,
|
||||
) -> Result<WatchlistPageResult, DomainError> {
|
||||
let remote_entries = super::get_remote_watchlist::execute(ctx, user_id)
|
||||
let remote_entries = crate::federation::get_remote_watchlist::execute(ctx, user_id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let len = remote_entries.len() as u32;
|
||||
@@ -3,10 +3,10 @@ use domain::{
|
||||
value_objects::{MovieId, UserId},
|
||||
};
|
||||
|
||||
use crate::{context::AppContext, queries::IsOnWatchlistQuery};
|
||||
use crate::{context::AppContext, watchlist::queries::IsOnWatchlistQuery};
|
||||
|
||||
pub async fn execute(ctx: &AppContext, query: IsOnWatchlistQuery) -> Result<bool, DomainError> {
|
||||
let user_id = UserId::from_uuid(query.user_id);
|
||||
let movie_id = MovieId::from_uuid(query.movie_id);
|
||||
ctx.watchlist_repository.contains(&user_id, &movie_id).await
|
||||
ctx.repos.watchlist.contains(&user_id, &movie_id).await
|
||||
}
|
||||
7
crates/application/src/watchlist/mod.rs
Normal file
7
crates/application/src/watchlist/mod.rs
Normal 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;
|
||||
12
crates/application/src/watchlist/queries.rs
Normal file
12
crates/application/src/watchlist/queries.rs
Normal 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,
|
||||
}
|
||||
@@ -4,14 +4,15 @@ use domain::{
|
||||
value_objects::{MovieId, UserId},
|
||||
};
|
||||
|
||||
use crate::{commands::RemoveFromWatchlistCommand, context::AppContext};
|
||||
use crate::{context::AppContext, watchlist::commands::RemoveFromWatchlistCommand};
|
||||
|
||||
pub async fn execute(ctx: &AppContext, cmd: RemoveFromWatchlistCommand) -> Result<(), DomainError> {
|
||||
let user_id = UserId::from_uuid(cmd.user_id);
|
||||
let movie_id = MovieId::from_uuid(cmd.movie_id);
|
||||
ctx.watchlist_repository.remove(&user_id, &movie_id).await?;
|
||||
ctx.repos.watchlist.remove(&user_id, &movie_id).await?;
|
||||
|
||||
let _ = ctx
|
||||
.services
|
||||
.event_publisher
|
||||
.publish(&DomainEvent::WatchlistEntryRemoved { user_id, movie_id })
|
||||
.await;
|
||||
87
crates/application/src/watchlist/tests/add.rs
Normal file
87
crates/application/src/watchlist/tests/add.rs
Normal 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");
|
||||
}
|
||||
@@ -2,7 +2,7 @@ use chrono::NaiveDateTime;
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use application::{
|
||||
use application::diary::{
|
||||
commands::{LogReviewCommand, MovieInput},
|
||||
queries::GetDiaryQuery,
|
||||
};
|
||||
|
||||
@@ -9,22 +9,31 @@ use uuid::Uuid;
|
||||
use std::str::FromStr;
|
||||
|
||||
use application::{
|
||||
commands::{
|
||||
AddToWatchlistCommand, DeleteReviewCommand, MovieInput, RegisterCommand,
|
||||
RemoveFromWatchlistCommand, SyncPosterCommand,
|
||||
auth::{
|
||||
commands::RegisterCommand, login as login_uc, queries::LoginQuery, register as register_uc,
|
||||
},
|
||||
queries::{
|
||||
ExportQuery, GetActivityFeedQuery, GetMovieSocialPageQuery, GetMoviesQuery,
|
||||
GetReviewHistoryQuery, GetUserProfileQuery, GetUsersQuery, GetWatchlistQuery,
|
||||
IsOnWatchlistQuery, LoginQuery,
|
||||
diary::{
|
||||
commands::{DeleteReviewCommand, MovieInput, SyncPosterCommand},
|
||||
delete_review, export_diary as export_diary_uc, get_activity_feed as get_feed_uc,
|
||||
get_diary, get_movie_social_page, get_review_history, log_review,
|
||||
queries::{
|
||||
ExportQuery, GetActivityFeedQuery, GetMovieSocialPageQuery, GetReviewHistoryQuery,
|
||||
},
|
||||
},
|
||||
use_cases::{
|
||||
add_to_watchlist, delete_review, export_diary as export_diary_uc,
|
||||
get_activity_feed as get_feed_uc, get_diary, get_movie_social_page, get_movies, get_person,
|
||||
get_person_credits, get_review_history, get_user_profile as get_user_profile_uc, get_users,
|
||||
get_watchlist, is_on_watchlist, log_review, login as login_uc, register as register_uc,
|
||||
remove_from_watchlist, search as search_uc, sync_poster, update_profile,
|
||||
update_profile_fields,
|
||||
movies::{get_movies, queries::GetMoviesQuery, sync_poster},
|
||||
person::{get as get_person, get_credits as get_person_credits},
|
||||
search::execute as search_uc,
|
||||
users::{
|
||||
get_profile as get_user_profile_uc, get_users,
|
||||
queries::{GetUserProfileQuery, GetUsersQuery},
|
||||
update_profile, update_profile_fields,
|
||||
},
|
||||
watchlist::{
|
||||
add as add_to_watchlist,
|
||||
commands::{AddToWatchlistCommand, RemoveFromWatchlistCommand},
|
||||
get as get_watchlist, is_on as is_on_watchlist,
|
||||
queries::{GetWatchlistQuery, IsOnWatchlistQuery},
|
||||
remove as remove_from_watchlist,
|
||||
},
|
||||
};
|
||||
use domain::{
|
||||
@@ -333,12 +342,7 @@ pub async fn get_movie_profile(
|
||||
Path(movie_id): Path<Uuid>,
|
||||
) -> impl IntoResponse {
|
||||
let id = domain::value_objects::MovieId::from_uuid(movie_id);
|
||||
match state
|
||||
.app_ctx
|
||||
.movie_profile_repository
|
||||
.get_by_movie_id(&id)
|
||||
.await
|
||||
{
|
||||
match state.app_ctx.repos.movie_profile.get_by_movie_id(&id).await {
|
||||
Ok(Some(p)) => Json(MovieProfileResponse {
|
||||
tmdb_id: p.tmdb_id,
|
||||
imdb_id: p.imdb_id,
|
||||
@@ -413,9 +417,9 @@ pub async fn get_profile(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
) -> impl IntoResponse {
|
||||
match application::use_cases::get_current_profile::execute(
|
||||
match application::users::get_current_profile::execute(
|
||||
&state.app_ctx,
|
||||
application::queries::GetCurrentProfileQuery {
|
||||
application::users::queries::GetCurrentProfileQuery {
|
||||
user_id: user_id.value(),
|
||||
},
|
||||
)
|
||||
@@ -498,7 +502,7 @@ pub async fn update_profile_handler(
|
||||
}
|
||||
}
|
||||
|
||||
let cmd = application::commands::UpdateProfileCommand {
|
||||
let cmd = application::users::commands::UpdateProfileCommand {
|
||||
user_id: user_id.value(),
|
||||
display_name,
|
||||
bio,
|
||||
@@ -552,7 +556,7 @@ pub async fn update_profile_fields_handler(
|
||||
})
|
||||
.collect();
|
||||
|
||||
let cmd = application::commands::UpdateProfileFieldsCommand {
|
||||
let cmd = application::users::commands::UpdateProfileFieldsCommand {
|
||||
user_id: user_id.value(),
|
||||
fields,
|
||||
};
|
||||
@@ -1066,14 +1070,15 @@ pub async fn get_user_profile(
|
||||
Query(params): Query<UserProfileQueryParams>,
|
||||
) -> impl IntoResponse {
|
||||
let view_str = params.view.as_deref().unwrap_or("recent");
|
||||
let profile_view = match application::queries::ProfileView::from_str(view_str) {
|
||||
let profile_view = match application::users::queries::ProfileView::from_str(view_str) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return StatusCode::BAD_REQUEST.into_response(),
|
||||
};
|
||||
|
||||
let user = match state
|
||||
.app_ctx
|
||||
.user_repository
|
||||
.repos
|
||||
.user
|
||||
.find_by_id(&UserId::from_uuid(user_id))
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -4,40 +4,53 @@ use axum::{
|
||||
Form,
|
||||
extract::{Extension, Multipart, Path, Query, State},
|
||||
http::{HeaderValue, StatusCode, header::SET_COOKIE},
|
||||
response::{Html, IntoResponse, Redirect},
|
||||
response::{IntoResponse, Redirect},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
use application::ports::{
|
||||
BlockedActorEntry, BlockedActorsPageData, BlockedDomainEntry, BlockedDomainsPageData,
|
||||
FollowersPageData, FollowingPageData,
|
||||
};
|
||||
use application::{
|
||||
commands::{
|
||||
AddToWatchlistCommand, ConfirmWatchEventsCommand, DeleteReviewCommand,
|
||||
DismissWatchEventsCommand, GenerateWebhookTokenCommand, MovieInput,
|
||||
RemoveFromWatchlistCommand, RevokeWebhookTokenCommand, WatchEventConfirmation,
|
||||
auth::{login as login_uc, queries::LoginQuery},
|
||||
diary::{
|
||||
commands::{DeleteReviewCommand, MovieInput},
|
||||
delete_review, export_diary as export_diary_uc, get_movie_social_page, log_review,
|
||||
queries::{ExportQuery, GetMovieSocialPageQuery},
|
||||
},
|
||||
ports::{
|
||||
HtmlPageContext, IntegrationsPageData, LoginPageData, MovieDetailPageData,
|
||||
NewReviewPageData, ProfileSettingsPageData, RegisterPageData, RemoteActorView,
|
||||
WatchQueueDisplayEntry, WatchQueuePageData, WatchlistPageData, WebhookTokenView,
|
||||
integrations::{
|
||||
commands::{
|
||||
ConfirmWatchEventsCommand, DismissWatchEventsCommand, GenerateWebhookTokenCommand,
|
||||
RevokeWebhookTokenCommand, WatchEventConfirmation,
|
||||
},
|
||||
confirm as confirm_watch_events, dismiss as dismiss_watch_events,
|
||||
generate_token as generate_webhook_token, get_queue as get_watch_queue,
|
||||
get_tokens as get_webhook_tokens,
|
||||
queries::{GetWatchQueueQuery, GetWebhookTokensQuery},
|
||||
revoke_token as revoke_webhook_token,
|
||||
},
|
||||
queries::{
|
||||
ExportQuery, GetMovieSocialPageQuery, GetWatchQueueQuery, GetWebhookTokensQuery,
|
||||
IsOnWatchlistQuery, LoginQuery,
|
||||
},
|
||||
use_cases::{
|
||||
add_to_watchlist, confirm_watch_events, delete_review, dismiss_watch_events,
|
||||
export_diary as export_diary_uc, generate_webhook_token, get_movie_social_page,
|
||||
get_watch_queue, get_webhook_tokens, is_on_watchlist, log_review, login as login_uc,
|
||||
remove_from_watchlist, revoke_webhook_token, update_profile, update_profile_fields,
|
||||
users::{update_profile, update_profile_fields},
|
||||
watchlist::{
|
||||
add as add_to_watchlist,
|
||||
commands::{AddToWatchlistCommand, RemoveFromWatchlistCommand},
|
||||
is_on as is_on_watchlist,
|
||||
queries::IsOnWatchlistQuery,
|
||||
remove as remove_from_watchlist,
|
||||
},
|
||||
};
|
||||
|
||||
use crate::render::render_page;
|
||||
use application::ports::HtmlPageContext;
|
||||
use domain::models::ExportFormat;
|
||||
use domain::{errors::DomainError, value_objects::UserId};
|
||||
use template_askama::{
|
||||
ActivityFeedTemplate, IntegrationsTemplate, LoginTemplate, MonthlyRatingRow,
|
||||
MovieDetailTemplate, NewReviewTemplate, ProfileSettingsTemplate, ProfileTemplate,
|
||||
RegisterTemplate, RemoteActorData, RemoteActorDisplay, UserSummaryView, UsersTemplate,
|
||||
WatchQueueTemplate, WatchlistTemplate, bar_height_px, build_heatmap, build_page_items,
|
||||
};
|
||||
#[cfg(feature = "federation")]
|
||||
use template_askama::{
|
||||
BlockedActorsTemplate, BlockedDomainsTemplate, FollowersTemplate, FollowingTemplate,
|
||||
};
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
use crate::forms::{
|
||||
@@ -57,13 +70,7 @@ pub(crate) async fn build_page_context(
|
||||
) -> HtmlPageContext {
|
||||
let uuid = user_id.as_ref().map(|u| u.value());
|
||||
let (user_email, is_admin) = if let Some(ref id) = user_id {
|
||||
let user = state
|
||||
.app_ctx
|
||||
.user_repository
|
||||
.find_by_id(id)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let user = state.app_ctx.repos.user.find_by_id(id).await.ok().flatten();
|
||||
let email = user.as_ref().map(|u| u.email().value().to_string());
|
||||
let admin = user
|
||||
.as_ref()
|
||||
@@ -128,14 +135,10 @@ pub async fn get_login_page(
|
||||
csrf_token: csrf.0,
|
||||
page_rss_url: None,
|
||||
};
|
||||
let html = state
|
||||
.html_renderer
|
||||
.render_login_page(LoginPageData {
|
||||
ctx,
|
||||
error: params.error.as_deref(),
|
||||
})
|
||||
.expect("login template failed");
|
||||
Html(html)
|
||||
render_page(LoginTemplate {
|
||||
ctx: &ctx,
|
||||
error: params.error.as_deref(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn post_login(
|
||||
@@ -195,14 +198,11 @@ pub async fn get_register_page(
|
||||
csrf_token: csrf.0,
|
||||
page_rss_url: None,
|
||||
};
|
||||
let html = state
|
||||
.html_renderer
|
||||
.render_register_page(RegisterPageData {
|
||||
ctx,
|
||||
error: params.error.as_deref(),
|
||||
})
|
||||
.expect("register template failed");
|
||||
Html(html).into_response()
|
||||
render_page(RegisterTemplate {
|
||||
ctx: &ctx,
|
||||
error: params.error.as_deref(),
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub async fn post_register(
|
||||
@@ -216,9 +216,9 @@ pub async fn post_register(
|
||||
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
match application::use_cases::register_and_login::execute(
|
||||
match application::auth::register_and_login::execute(
|
||||
&state.app_ctx,
|
||||
application::commands::RegisterAndLoginCommand {
|
||||
application::auth::commands::RegisterAndLoginCommand {
|
||||
email: form.email,
|
||||
username: form.username,
|
||||
password: form.password,
|
||||
@@ -246,14 +246,10 @@ pub async fn get_new_review_page(
|
||||
let mut ctx = build_page_context(&state, Some(user_id), csrf.0).await;
|
||||
ctx.page_title = "Log a Review — Movies Diary".to_string();
|
||||
ctx.canonical_url = format!("{}/reviews/new", state.app_ctx.config.base_url);
|
||||
let html = state
|
||||
.html_renderer
|
||||
.render_new_review_page(NewReviewPageData {
|
||||
ctx,
|
||||
error: params.error.as_deref(),
|
||||
})
|
||||
.expect("new_review template failed");
|
||||
Html(html)
|
||||
render_page(NewReviewTemplate {
|
||||
ctx: &ctx,
|
||||
error: params.error.as_deref(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn post_review(
|
||||
@@ -374,7 +370,7 @@ pub async fn get_activity_feed(
|
||||
_ => "date",
|
||||
};
|
||||
|
||||
let query = application::queries::GetActivityFeedQuery {
|
||||
let query = application::diary::queries::GetActivityFeedQuery {
|
||||
limit,
|
||||
offset,
|
||||
sort_by: sort_by_str.parse().unwrap_or_default(),
|
||||
@@ -387,26 +383,30 @@ pub async fn get_activity_feed(
|
||||
filter_following,
|
||||
};
|
||||
|
||||
match application::use_cases::get_activity_feed::execute(&state.app_ctx, query).await {
|
||||
match application::diary::get_activity_feed::execute(&state.app_ctx, query).await {
|
||||
Ok(entries) => {
|
||||
let entry_limit = entries.limit;
|
||||
let entry_offset = entries.offset;
|
||||
let has_more =
|
||||
(entry_offset as u64).saturating_add(entry_limit as u64) < entries.total_count;
|
||||
let data = application::ports::ActivityFeedPageData {
|
||||
ctx,
|
||||
let total_pages = (entries.total_count as u32)
|
||||
.saturating_add(entry_limit.saturating_sub(1))
|
||||
.checked_div(entry_limit)
|
||||
.unwrap_or(1);
|
||||
let current_page = entry_offset.checked_div(entry_limit).unwrap_or(0);
|
||||
let page_items = build_page_items(total_pages, current_page);
|
||||
render_page(ActivityFeedTemplate {
|
||||
entries: entries.items.as_slice(),
|
||||
current_offset: entry_offset,
|
||||
has_more,
|
||||
limit: entry_limit,
|
||||
entries,
|
||||
has_more,
|
||||
ctx: &ctx,
|
||||
page_items,
|
||||
filter: filter_str.to_string(),
|
||||
sort_by: sort_by_str.to_string(),
|
||||
search: params.search,
|
||||
};
|
||||
match state.html_renderer.render_activity_feed_page(data) {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
@@ -421,32 +421,54 @@ pub async fn get_users_list(
|
||||
ctx.page_title = "Members — Movies Diary".to_string();
|
||||
ctx.canonical_url = format!("{}/users", state.app_ctx.config.base_url);
|
||||
|
||||
match application::use_cases::get_users::execute(
|
||||
match application::users::get_users::execute(
|
||||
&state.app_ctx,
|
||||
application::queries::GetUsersQuery,
|
||||
application::users::queries::GetUsersQuery,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
let actor_views = result
|
||||
.remote_actors
|
||||
let users: Vec<UserSummaryView> = result
|
||||
.users
|
||||
.into_iter()
|
||||
.map(|a| application::ports::RemoteActorView {
|
||||
handle: a.handle,
|
||||
display_name: a.display_name,
|
||||
url: a.url,
|
||||
avatar_url: None,
|
||||
.map(|u| {
|
||||
let name = u.email().split('@').next().unwrap_or("?").to_string();
|
||||
let initial = name.chars().next().unwrap_or('?').to_ascii_uppercase();
|
||||
let avg_display = u
|
||||
.avg_rating
|
||||
.map(|r| format!("{:.1}", r))
|
||||
.unwrap_or_else(|| "—".to_string());
|
||||
let avatar_url = u.avatar_path.map(|p| format!("/images/{}", p));
|
||||
UserSummaryView {
|
||||
user_id: u.user_id.value(),
|
||||
display_name: name,
|
||||
initial,
|
||||
avg_rating_display: avg_display,
|
||||
total_movies: u.total_movies,
|
||||
avatar_url,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let data = application::ports::UsersPageData {
|
||||
ctx,
|
||||
users: result.users,
|
||||
remote_actors: actor_views,
|
||||
};
|
||||
match state.html_renderer.render_users_page(data) {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
||||
}
|
||||
let remote_actors: Vec<RemoteActorDisplay> = result
|
||||
.remote_actors
|
||||
.into_iter()
|
||||
.map(|a| {
|
||||
let display = a.display_name.unwrap_or_else(|| a.handle.clone());
|
||||
let initial = display.chars().next().unwrap_or('?').to_ascii_uppercase();
|
||||
RemoteActorDisplay {
|
||||
handle: a.handle,
|
||||
display_name: display,
|
||||
initial,
|
||||
url: a.url,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
render_page(UsersTemplate {
|
||||
users,
|
||||
ctx: &ctx,
|
||||
remote_actors,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
@@ -460,7 +482,7 @@ pub async fn get_user_by_username(
|
||||
Ok(u) => u,
|
||||
Err(_) => return StatusCode::NOT_FOUND.into_response(),
|
||||
};
|
||||
match state.app_ctx.user_repository.find_by_username(&uname).await {
|
||||
match state.app_ctx.repos.user.find_by_username(&uname).await {
|
||||
Ok(Some(user)) => {
|
||||
axum::response::Redirect::permanent(&format!("/users/{}", user.id().value()))
|
||||
.into_response()
|
||||
@@ -505,7 +527,7 @@ pub async fn get_user_profile(
|
||||
|
||||
let mut ctx = build_page_context(&state, user_id.clone(), csrf.0).await;
|
||||
let view_str = params.view.as_deref().unwrap_or("recent");
|
||||
let profile_view = match application::queries::ProfileView::from_str(view_str) {
|
||||
let profile_view = match application::users::queries::ProfileView::from_str(view_str) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
return (
|
||||
@@ -518,7 +540,8 @@ pub async fn get_user_profile(
|
||||
|
||||
let profile_user = match state
|
||||
.app_ctx
|
||||
.user_repository
|
||||
.repos
|
||||
.user
|
||||
.find_by_id(&domain::value_objects::UserId::from_uuid(profile_user_uuid))
|
||||
.await
|
||||
{
|
||||
@@ -546,7 +569,7 @@ pub async fn get_user_profile(
|
||||
.map(|u| u.value() == profile_user_uuid)
|
||||
.unwrap_or(false);
|
||||
|
||||
let query = application::queries::GetUserProfileQuery {
|
||||
let query = application::users::queries::GetUserProfileQuery {
|
||||
user_id: profile_user_uuid,
|
||||
view: profile_view,
|
||||
limit: params.limit,
|
||||
@@ -560,7 +583,7 @@ pub async fn get_user_profile(
|
||||
is_own_profile,
|
||||
};
|
||||
|
||||
match application::use_cases::get_user_profile::execute(&state.app_ctx, query).await {
|
||||
match application::users::get_profile::execute(&state.app_ctx, query).await {
|
||||
Ok(profile) => {
|
||||
let (offset, has_more, limit) = profile
|
||||
.entries
|
||||
@@ -573,28 +596,80 @@ pub async fn get_user_profile(
|
||||
if !is_own_profile {
|
||||
ctx.page_rss_url = Some(format!("/users/{}/feed.rss", profile_user_uuid));
|
||||
}
|
||||
let pending_followers: Vec<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
|
||||
.into_iter()
|
||||
.map(|p| application::ports::RemoteActorView {
|
||||
.map(|p| RemoteActorData {
|
||||
handle: p.handle,
|
||||
url: p.url,
|
||||
display_name: p.display_name,
|
||||
avatar_url: p.avatar_url,
|
||||
})
|
||||
.collect();
|
||||
let data = application::ports::ProfilePageData {
|
||||
ctx,
|
||||
render_page(ProfileTemplate {
|
||||
ctx: &ctx,
|
||||
profile_display_name: display_name,
|
||||
profile_user_id: profile_user_uuid,
|
||||
profile_user_email: profile_user.email().value().to_string(),
|
||||
stats: profile.stats,
|
||||
view: profile_view.as_str().to_string(),
|
||||
entries: profile.entries,
|
||||
stats: &profile.stats,
|
||||
avg_rating_display,
|
||||
favorite_director_display,
|
||||
most_active_month_display,
|
||||
view: profile_view.as_str(),
|
||||
entries: profile.entries.as_ref(),
|
||||
current_offset: offset,
|
||||
has_more,
|
||||
limit,
|
||||
history: profile.history,
|
||||
trends: profile.trends,
|
||||
history: profile.history.as_ref(),
|
||||
trends: profile.trends.as_ref(),
|
||||
monthly_rating_rows,
|
||||
heatmap,
|
||||
page_items,
|
||||
is_own_profile,
|
||||
error: params.error,
|
||||
following_count: profile.following_count,
|
||||
@@ -602,11 +677,8 @@ pub async fn get_user_profile(
|
||||
pending_followers,
|
||||
sort_by: sort_by_str.to_string(),
|
||||
search: params.search.clone(),
|
||||
};
|
||||
match state.html_renderer.render_profile_page(data) {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
||||
}
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
@@ -818,25 +890,22 @@ pub async fn get_following_page(
|
||||
);
|
||||
match state.ap_service.get_following(user_id.value()).await {
|
||||
Ok(following) => {
|
||||
let actors = following
|
||||
let actors: Vec<RemoteActorData> = following
|
||||
.into_iter()
|
||||
.map(|a| RemoteActorView {
|
||||
.map(|a| RemoteActorData {
|
||||
handle: a.handle,
|
||||
display_name: a.display_name,
|
||||
url: a.url,
|
||||
avatar_url: a.avatar_url.clone(),
|
||||
})
|
||||
.collect();
|
||||
let data = FollowingPageData {
|
||||
render_page(FollowingTemplate {
|
||||
ctx,
|
||||
user_id: profile_user_uuid,
|
||||
actors,
|
||||
error: params.error,
|
||||
};
|
||||
match state.html_renderer.render_following_page(data) {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
||||
}
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("get_following error: {:?}", e);
|
||||
@@ -872,25 +941,22 @@ pub async fn get_followers_page(
|
||||
.await
|
||||
{
|
||||
Ok(followers) => {
|
||||
let actors = followers
|
||||
let actors: Vec<RemoteActorData> = followers
|
||||
.into_iter()
|
||||
.map(|a| RemoteActorView {
|
||||
.map(|a| RemoteActorData {
|
||||
handle: a.handle,
|
||||
display_name: a.display_name,
|
||||
url: a.url,
|
||||
avatar_url: a.avatar_url.clone(),
|
||||
})
|
||||
.collect();
|
||||
let data = FollowersPageData {
|
||||
render_page(FollowersTemplate {
|
||||
ctx,
|
||||
user_id: profile_user_uuid,
|
||||
actors,
|
||||
error: params.error,
|
||||
};
|
||||
match state.html_renderer.render_followers_page(data) {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
||||
}
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("get_followers error: {:?}", e);
|
||||
@@ -985,25 +1051,21 @@ pub async fn get_movie_detail(
|
||||
.unwrap_or(false),
|
||||
None => false,
|
||||
};
|
||||
let data = MovieDetailPageData {
|
||||
ctx,
|
||||
movie: result.movie,
|
||||
stats: result.stats,
|
||||
profile: result.profile,
|
||||
let current_offset = result.reviews.offset;
|
||||
let reviews_limit = result.reviews.limit;
|
||||
render_page(MovieDetailTemplate {
|
||||
ctx: &ctx,
|
||||
movie: &result.movie,
|
||||
stats: &result.stats,
|
||||
profile: result.profile.as_ref(),
|
||||
reviews: result.reviews.items.as_slice(),
|
||||
on_watchlist,
|
||||
current_offset: result.reviews.offset,
|
||||
current_offset,
|
||||
has_more,
|
||||
limit: result.reviews.limit,
|
||||
reviews: result.reviews,
|
||||
limit: reviews_limit,
|
||||
histogram_max,
|
||||
};
|
||||
match state.html_renderer.render_movie_detail_page(data) {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("template error: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
}
|
||||
}
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1018,9 +1080,9 @@ pub async fn get_watchlist_page(
|
||||
let ctx = build_page_context(&state, viewer_id.clone(), csrf.0).await;
|
||||
let is_owner = viewer_id.map(|u| u.value() == owner_id).unwrap_or(false);
|
||||
|
||||
let result = match application::use_cases::get_watchlist_page::execute(
|
||||
let result = match application::watchlist::get_page::execute(
|
||||
&state.app_ctx,
|
||||
application::queries::GetWatchlistQuery {
|
||||
application::watchlist::queries::GetWatchlistQuery {
|
||||
user_id: owner_id,
|
||||
limit: params.limit.or(Some(20)),
|
||||
offset: params.offset.or(Some(0)),
|
||||
@@ -1036,23 +1098,17 @@ pub async fn get_watchlist_page(
|
||||
}
|
||||
};
|
||||
|
||||
let data = WatchlistPageData {
|
||||
ctx,
|
||||
render_page(WatchlistTemplate {
|
||||
ctx: &ctx,
|
||||
owner_id,
|
||||
display_entries: result.display_entries,
|
||||
display_entries: &result.display_entries,
|
||||
current_offset: result.current_offset,
|
||||
has_more: result.has_more,
|
||||
limit: result.limit,
|
||||
is_owner,
|
||||
error: params.error,
|
||||
};
|
||||
match state.html_renderer.render_watchlist_page(data) {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("watchlist template error: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
}
|
||||
}
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub async fn post_watchlist_add(
|
||||
@@ -1180,7 +1236,7 @@ pub async fn get_profile_settings(
|
||||
ctx.page_title = "Profile Settings — Movies Diary".to_string();
|
||||
ctx.canonical_url = format!("{}/settings/profile", state.app_ctx.config.base_url);
|
||||
|
||||
let user = match state.app_ctx.user_repository.find_by_id(&user_id).await {
|
||||
let user = match state.app_ctx.repos.user.find_by_id(&user_id).await {
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => return StatusCode::NOT_FOUND.into_response(),
|
||||
Err(e) => {
|
||||
@@ -1197,9 +1253,10 @@ pub async fn get_profile_settings(
|
||||
.banner_path()
|
||||
.map(|path| format!("{}/images/{}", base_url, path));
|
||||
|
||||
let profile_fields = state
|
||||
let profile_fields: Vec<(String, String)> = state
|
||||
.app_ctx
|
||||
.profile_fields_repository
|
||||
.repos
|
||||
.profile_fields
|
||||
.get_fields(&user_id)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
@@ -1209,23 +1266,19 @@ pub async fn get_profile_settings(
|
||||
|
||||
let saved = params.saved.as_deref() == Some("1");
|
||||
|
||||
let data = ProfileSettingsPageData {
|
||||
ctx,
|
||||
bio: user.bio().map(|s| s.to_string()),
|
||||
avatar_url,
|
||||
banner_url,
|
||||
also_known_as: user.also_known_as().map(|s| s.to_string()),
|
||||
profile_fields,
|
||||
saved,
|
||||
};
|
||||
let bio = user.bio().map(|s| s.to_string());
|
||||
let also_known_as = user.also_known_as().map(|s| s.to_string());
|
||||
|
||||
match state.html_renderer.render_profile_settings_page(data) {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("profile_settings template error: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
}
|
||||
}
|
||||
render_page(ProfileSettingsTemplate {
|
||||
ctx: &ctx,
|
||||
bio: bio.as_deref(),
|
||||
avatar_url: avatar_url.as_deref(),
|
||||
banner_url: banner_url.as_deref(),
|
||||
also_known_as: also_known_as.as_deref(),
|
||||
profile_fields: &profile_fields,
|
||||
saved,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub async fn get_tag(Path(tag): Path<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);
|
||||
match state.ap_service.get_blocked_domains().await {
|
||||
Ok(domains) => {
|
||||
let data = BlockedDomainsPageData {
|
||||
ctx,
|
||||
domains: domains
|
||||
.into_iter()
|
||||
.map(|d| BlockedDomainEntry {
|
||||
domain: d.domain,
|
||||
reason: d.reason,
|
||||
blocked_at: d.blocked_at,
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
match state.html_renderer.render_blocked_domains_page(data) {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
||||
}
|
||||
let entries: Vec<template_askama::BlockedDomainEntry> = domains
|
||||
.into_iter()
|
||||
.map(|d| template_askama::BlockedDomainEntry {
|
||||
domain: d.domain,
|
||||
reason: d.reason,
|
||||
blocked_at: d.blocked_at,
|
||||
})
|
||||
.collect();
|
||||
render_page(BlockedDomainsTemplate {
|
||||
ctx: &ctx,
|
||||
domains: &entries,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("get_blocked_domains error: {:?}", e);
|
||||
@@ -1328,22 +1379,20 @@ pub async fn get_blocked_actors_page(
|
||||
ctx.canonical_url = format!("{}/social/blocked", state.app_ctx.config.base_url);
|
||||
match state.ap_service.get_blocked_actors(user_id.value()).await {
|
||||
Ok(actors) => {
|
||||
let data = BlockedActorsPageData {
|
||||
ctx,
|
||||
actors: actors
|
||||
.into_iter()
|
||||
.map(|a| BlockedActorEntry {
|
||||
url: a.url,
|
||||
handle: a.handle,
|
||||
display_name: a.display_name,
|
||||
avatar_url: a.avatar_url,
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
match state.html_renderer.render_blocked_actors_page(data) {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
||||
}
|
||||
let entries: Vec<template_askama::BlockedActorEntry> = actors
|
||||
.into_iter()
|
||||
.map(|a| template_askama::BlockedActorEntry {
|
||||
url: a.url,
|
||||
handle: a.handle,
|
||||
display_name: a.display_name,
|
||||
avatar_url: a.avatar_url,
|
||||
})
|
||||
.collect();
|
||||
render_page(BlockedActorsTemplate {
|
||||
ctx: &ctx,
|
||||
actors: &entries,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("get_blocked_actors error: {:?}", e);
|
||||
@@ -1475,7 +1524,7 @@ pub async fn post_profile_settings(
|
||||
}
|
||||
}
|
||||
|
||||
let cmd = application::commands::UpdateProfileCommand {
|
||||
let cmd = application::users::commands::UpdateProfileCommand {
|
||||
user_id: user_id.value(),
|
||||
display_name,
|
||||
bio,
|
||||
@@ -1498,7 +1547,7 @@ pub async fn post_profile_settings(
|
||||
})
|
||||
.collect();
|
||||
|
||||
let fields_cmd = application::commands::UpdateProfileFieldsCommand {
|
||||
let fields_cmd = application::users::commands::UpdateProfileFieldsCommand {
|
||||
user_id: user_id.value(),
|
||||
fields,
|
||||
};
|
||||
@@ -1526,9 +1575,9 @@ pub async fn get_integrations_page(
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let token_views: Vec<WebhookTokenView> = tokens
|
||||
let token_views: Vec<template_askama::WebhookTokenView> = tokens
|
||||
.into_iter()
|
||||
.map(|t| WebhookTokenView {
|
||||
.map(|t| template_askama::WebhookTokenView {
|
||||
id: t.id().value().to_string(),
|
||||
provider: t.provider().to_string(),
|
||||
label: t.label().map(String::from),
|
||||
@@ -1539,20 +1588,14 @@ pub async fn get_integrations_page(
|
||||
})
|
||||
.collect();
|
||||
|
||||
let data = IntegrationsPageData {
|
||||
ctx,
|
||||
tokens: token_views,
|
||||
webhook_base_url: state.app_ctx.config.base_url.clone(),
|
||||
new_token: params.token,
|
||||
};
|
||||
|
||||
match state.html_renderer.render_integrations_page(data) {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("integrations template error: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
}
|
||||
}
|
||||
let webhook_base_url = state.app_ctx.config.base_url.clone();
|
||||
render_page(IntegrationsTemplate {
|
||||
ctx: &ctx,
|
||||
tokens: &token_views,
|
||||
webhook_base_url: &webhook_base_url,
|
||||
new_token: params.token.as_deref(),
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub async fn post_generate_token(
|
||||
@@ -1632,9 +1675,9 @@ pub async fn get_watch_queue_page(
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let entries: Vec<WatchQueueDisplayEntry> = events
|
||||
let entries: Vec<template_askama::WatchQueueDisplayEntry> = events
|
||||
.into_iter()
|
||||
.map(|e| WatchQueueDisplayEntry {
|
||||
.map(|e| template_askama::WatchQueueDisplayEntry {
|
||||
id: e.id().value().to_string(),
|
||||
title: e.title().to_string(),
|
||||
year: e.year(),
|
||||
@@ -1644,19 +1687,12 @@ pub async fn get_watch_queue_page(
|
||||
})
|
||||
.collect();
|
||||
|
||||
let data = WatchQueuePageData {
|
||||
ctx,
|
||||
entries,
|
||||
error: params.error,
|
||||
};
|
||||
|
||||
match state.html_renderer.render_watch_queue_page(data) {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("watch_queue template error: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
}
|
||||
}
|
||||
render_page(WatchQueueTemplate {
|
||||
ctx: &ctx,
|
||||
entries: &entries,
|
||||
error: params.error.as_deref(),
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub async fn post_confirm_single(
|
||||
|
||||
@@ -11,25 +11,26 @@ use axum::{
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use application::{
|
||||
use crate::render::render_page;
|
||||
use application::import::{
|
||||
apply_mapping as apply_import_mapping,
|
||||
commands::{
|
||||
ApplyImportMappingCommand, CreateImportSessionCommand, DeleteImportProfileCommand,
|
||||
ExecuteImportCommand, SaveImportProfileCommand,
|
||||
},
|
||||
ports::{
|
||||
ImportMappingPageData, ImportPreviewPageData, ImportPreviewRow, ImportProfileView,
|
||||
ImportRowStatus, ImportUploadPageData,
|
||||
},
|
||||
use_cases::{
|
||||
apply_import_mapping, create_import_session, delete_import_profile, execute_import,
|
||||
list_import_profiles, save_import_profile,
|
||||
},
|
||||
create_session as create_import_session, delete_profile as delete_import_profile,
|
||||
execute as execute_import, list_profiles as list_import_profiles,
|
||||
save_profile as save_import_profile,
|
||||
};
|
||||
use domain::models::{
|
||||
AnnotatedRow, FieldMapping, FileFormat,
|
||||
import::{DomainField, RowResult, Transform},
|
||||
};
|
||||
use domain::value_objects::ImportSessionId;
|
||||
use template_askama::{
|
||||
ImportMappingTemplate, ImportPreviewRow, ImportPreviewTemplate, ImportProfileView,
|
||||
ImportRowStatus, ImportUploadTemplate,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
csrf::CsrfToken,
|
||||
@@ -143,15 +144,11 @@ pub async fn get_import_page(
|
||||
name: p.name,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let html = state
|
||||
.html_renderer
|
||||
.render_import_upload_page(ImportUploadPageData {
|
||||
ctx,
|
||||
profiles,
|
||||
error: None,
|
||||
})
|
||||
.unwrap_or_else(|e| e);
|
||||
Html(html)
|
||||
render_page(ImportUploadTemplate {
|
||||
ctx: &ctx,
|
||||
profiles: &profiles,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn post_upload(
|
||||
@@ -220,7 +217,8 @@ pub async fn get_mapping_page(
|
||||
};
|
||||
let Ok(Some(session)) = state
|
||||
.app_ctx
|
||||
.import_session_repository
|
||||
.repos
|
||||
.import_session
|
||||
.get(&session_id, &user_id)
|
||||
.await
|
||||
else {
|
||||
@@ -231,27 +229,25 @@ pub async fn get_mapping_page(
|
||||
};
|
||||
|
||||
let ctx = super::html::build_page_context(&state, Some(user_id), csrf.0).await;
|
||||
let sample_rows = parsed.rows.into_iter().take(5).collect();
|
||||
let html = state
|
||||
.html_renderer
|
||||
.render_import_mapping_page(ImportMappingPageData {
|
||||
ctx,
|
||||
session_id: session_id_str,
|
||||
columns: parsed.columns,
|
||||
sample_rows,
|
||||
domain_fields: vec![
|
||||
("title", "Title"),
|
||||
("release_year", "Release Year"),
|
||||
("director", "Director"),
|
||||
("rating", "Rating"),
|
||||
("watched_at", "Watched At"),
|
||||
("comment", "Comment"),
|
||||
("external_metadata_id", "External ID"),
|
||||
],
|
||||
error: None,
|
||||
})
|
||||
.unwrap_or_else(|e| e);
|
||||
Html(html).into_response()
|
||||
let sample_rows: Vec<Vec<String>> = parsed.rows.into_iter().take(5).collect();
|
||||
let domain_fields: Vec<(&str, &str)> = vec![
|
||||
("title", "Title"),
|
||||
("release_year", "Release Year"),
|
||||
("director", "Director"),
|
||||
("rating", "Rating"),
|
||||
("watched_at", "Watched At"),
|
||||
("comment", "Comment"),
|
||||
("external_metadata_id", "External ID"),
|
||||
];
|
||||
render_page(ImportMappingTemplate {
|
||||
ctx: &ctx,
|
||||
session_id: &session_id_str,
|
||||
columns: &parsed.columns,
|
||||
sample_rows: &sample_rows,
|
||||
domain_fields: &domain_fields,
|
||||
error: None,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub async fn post_mapping(
|
||||
@@ -313,7 +309,8 @@ pub async fn get_preview_page(
|
||||
};
|
||||
let Ok(Some(session)) = state
|
||||
.app_ctx
|
||||
.import_session_repository
|
||||
.repos
|
||||
.import_session
|
||||
.get(&session_id, &user_id)
|
||||
.await
|
||||
else {
|
||||
@@ -334,16 +331,13 @@ pub async fn get_preview_page(
|
||||
.collect();
|
||||
|
||||
let ctx = super::html::build_page_context(&state, Some(user_id), csrf.0).await;
|
||||
let html = state
|
||||
.html_renderer
|
||||
.render_import_preview_page(ImportPreviewPageData {
|
||||
ctx,
|
||||
session_id: session_id_str,
|
||||
columns: parsed.columns,
|
||||
rows,
|
||||
})
|
||||
.unwrap_or_else(|e| e);
|
||||
Html(html).into_response()
|
||||
render_page(ImportPreviewTemplate {
|
||||
ctx: &ctx,
|
||||
session_id: &session_id_str,
|
||||
columns: &parsed.columns,
|
||||
rows: &rows,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub async fn post_confirm(
|
||||
@@ -571,7 +565,8 @@ pub async fn api_get_session(
|
||||
};
|
||||
match state
|
||||
.app_ctx
|
||||
.import_session_repository
|
||||
.repos
|
||||
.import_session
|
||||
.get(&session_id, &user_id)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -5,7 +5,7 @@ use axum::{
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use application::{queries::GetDiaryQuery, use_cases::get_diary};
|
||||
use application::{diary::get_diary, diary::queries::GetDiaryQuery};
|
||||
use domain::{errors::DomainError, models::SortDirection, value_objects::UserId};
|
||||
|
||||
use crate::{errors::ApiError, state::AppState};
|
||||
@@ -35,7 +35,8 @@ pub async fn get_user_feed(
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let user = state
|
||||
.app_ctx
|
||||
.user_repository
|
||||
.repos
|
||||
.user
|
||||
.find_by_id(&UserId::from_uuid(user_id))
|
||||
.await
|
||||
.map_err(ApiError)?
|
||||
|
||||
@@ -10,16 +10,16 @@ use api_types::{
|
||||
ConfirmWatchRequest, ConfirmWatchResponse, DismissWatchRequest, DismissWatchResponse,
|
||||
GenerateTokenRequest, GenerateTokenResponse, WatchQueueEntryDto, WebhookTokenDto,
|
||||
};
|
||||
use application::{
|
||||
use application::integrations::{
|
||||
commands::{
|
||||
ConfirmWatchEventsCommand, DismissWatchEventsCommand, GenerateWebhookTokenCommand,
|
||||
IngestWatchEventCommand, RevokeWebhookTokenCommand, WatchEventConfirmation,
|
||||
},
|
||||
confirm as confirm_watch_events, dismiss as dismiss_watch_events,
|
||||
generate_token as generate_webhook_token, get_queue as get_watch_queue,
|
||||
get_tokens as get_webhook_tokens, ingest as ingest_watch_event,
|
||||
queries::{GetWatchQueueQuery, GetWebhookTokensQuery},
|
||||
use_cases::{
|
||||
confirm_watch_events, dismiss_watch_events, generate_webhook_token, get_watch_queue,
|
||||
get_webhook_tokens, ingest_watch_event, revoke_webhook_token,
|
||||
},
|
||||
revoke_token as revoke_webhook_token,
|
||||
};
|
||||
use domain::models::WatchEventSource;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user