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