refactor: fix HIGH+MEDIUM architectural violations from code review
HIGH: fix watch_medium data-loss bug, standardize error handling on ApiError, fix dep direction (rss/template-askama no longer dep on application), extract ImageFetcher port (remove reqwest from app layer), move event construction from save_review to use case, extract infra-wiring crate (DbPool/EventBusBackend dedup), deduplicate presentation helpers (encode_error, export streaming, multipart parsing) MEDIUM: split LocalApContentQuery god-trait 10→3 methods, dedup movie resolution orchestration, add RemoteActorDto/PersonDto mappers, move AppConfig to infra-wiring, fix SocialQueryPort Uuid→UserId, replace stringly-typed api-types with domain enums, move count_reviews_in_year to StatsRepository, dedup event publisher cfg blocks, extract should_enrich, move group_by_month to application, dedup count_local_posts, add FederationFlags Default, TUI input helper + ShowError rename + typed auth errors, api-types cleanup (UserSettingsDto/UserProfileBase/PreviewRowData) 102 files changed, -681 lines net
This commit is contained in:
@@ -6,7 +6,7 @@ edition = "2024"
|
||||
[dependencies]
|
||||
async-trait = { workspace = true }
|
||||
domain = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
infra-wiring = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
@@ -11,7 +11,7 @@ pub struct LoginResult {
|
||||
pub user_id: Uuid,
|
||||
pub email: String,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub role: String,
|
||||
pub role: domain::models::UserRole,
|
||||
}
|
||||
|
||||
pub async fn execute(deps: &LoginDeps, query: LoginQuery) -> Result<LoginResult, DomainError> {
|
||||
@@ -49,7 +49,7 @@ pub async fn execute(deps: &LoginDeps, query: LoginQuery) -> Result<LoginResult,
|
||||
user_id: user.id().value(),
|
||||
email: user.email().value().to_string(),
|
||||
expires_at: generated.expires_at,
|
||||
role: user.role().as_str().into(),
|
||||
role: user.role().clone(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,50 +1 @@
|
||||
#[derive(Clone)]
|
||||
pub struct AppConfig {
|
||||
pub allow_registration: bool,
|
||||
pub base_url: String,
|
||||
pub rate_limit: u64,
|
||||
pub refresh_ttl_seconds: u64,
|
||||
pub wrapup: WrapUpConfig,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct WrapUpConfig {
|
||||
pub font_path: Option<String>,
|
||||
pub logo_path: Option<String>,
|
||||
pub bg_dir: Option<String>,
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
pub fn from_env() -> Self {
|
||||
let allow_registration = std::env::var("ALLOW_REGISTRATION")
|
||||
.map(|v| v == "true" || v == "1")
|
||||
.unwrap_or(false);
|
||||
let base_url =
|
||||
std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:3000".to_string());
|
||||
let rate_limit = std::env::var("RATE_LIMIT")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(60);
|
||||
let refresh_ttl_seconds = std::env::var("REFRESH_TTL_SECONDS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(2_592_000u64);
|
||||
Self {
|
||||
allow_registration,
|
||||
base_url,
|
||||
rate_limit,
|
||||
refresh_ttl_seconds,
|
||||
wrapup: WrapUpConfig::from_env(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WrapUpConfig {
|
||||
pub fn from_env() -> Self {
|
||||
Self {
|
||||
font_path: std::env::var("WRAPUP_FONT_PATH").ok(),
|
||||
logo_path: std::env::var("WRAPUP_LOGO_PATH").ok(),
|
||||
bg_dir: std::env::var("WRAPUP_BG_DIR").ok(),
|
||||
}
|
||||
}
|
||||
}
|
||||
pub use infra_wiring::{AppConfig, WrapUpConfig};
|
||||
|
||||
@@ -6,6 +6,7 @@ use domain::{
|
||||
FeedEntry,
|
||||
collections::{PageParams, Paginated},
|
||||
},
|
||||
value_objects::UserId,
|
||||
};
|
||||
|
||||
pub async fn execute(
|
||||
@@ -34,9 +35,10 @@ async fn build_following_filter(
|
||||
return None;
|
||||
}
|
||||
let viewer_id = query.viewer_user_id?;
|
||||
let viewer = UserId::from_uuid(viewer_id);
|
||||
let urls = deps
|
||||
.social_query
|
||||
.get_accepted_following_urls(viewer_id)
|
||||
.get_accepted_following_urls(&viewer)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
if urls.is_empty() {
|
||||
|
||||
@@ -4,15 +4,15 @@ use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::{Movie, Review},
|
||||
models::Review,
|
||||
ports::{
|
||||
EventPublisher, MetadataClient, MovieRepository, ReviewRepository, WatchlistRepository,
|
||||
},
|
||||
value_objects::{Comment, MovieId, Rating, UserId},
|
||||
value_objects::{Comment, Rating, UserId},
|
||||
};
|
||||
|
||||
use crate::diary::commands::LogReviewCommand;
|
||||
use crate::diary::movie_resolver::{MovieResolver, MovieResolverDeps};
|
||||
use crate::movies::resolve::resolve_and_persist_movie;
|
||||
use crate::ports::ReviewLogger;
|
||||
|
||||
pub struct DefaultReviewLogger {
|
||||
@@ -48,25 +48,18 @@ impl ReviewLogger for DefaultReviewLogger {
|
||||
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 = self
|
||||
.movie_repo
|
||||
.get_movie_by_id(&movie_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound(format!("Movie {id}")))?;
|
||||
(movie, false)
|
||||
} else {
|
||||
let deps = MovieResolverDeps {
|
||||
repository: self.movie_repo.as_ref(),
|
||||
metadata_client: self.metadata_client.as_ref(),
|
||||
};
|
||||
MovieResolver::default_pipeline()
|
||||
.resolve(&cmd.input, &deps)
|
||||
.await?
|
||||
};
|
||||
let (movie, is_new_movie) = resolve_and_persist_movie(
|
||||
&cmd.input,
|
||||
self.movie_repo.as_ref(),
|
||||
self.metadata_client.as_ref(),
|
||||
self.event_publisher.as_ref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
self.movie_repo.upsert_movie(&movie).await?;
|
||||
// Always upsert: even existing movies may have updated metadata
|
||||
if !is_new_movie {
|
||||
self.movie_repo.upsert_movie(&movie).await?;
|
||||
}
|
||||
|
||||
let review = Review::new(
|
||||
movie.id().clone(),
|
||||
@@ -76,7 +69,14 @@ impl ReviewLogger for DefaultReviewLogger {
|
||||
cmd.watched_at,
|
||||
cmd.watch_medium,
|
||||
)?;
|
||||
let review_event = self.review_repo.save_review(&review).await?;
|
||||
self.review_repo.save_review(&review).await?;
|
||||
let review_event = DomainEvent::ReviewLogged {
|
||||
review_id: review.id().clone(),
|
||||
movie_id: review.movie_id().clone(),
|
||||
user_id: review.user_id().clone(),
|
||||
rating: review.rating().clone(),
|
||||
watched_at: *review.watched_at(),
|
||||
};
|
||||
|
||||
let was_on_watchlist = self
|
||||
.watchlist_repo
|
||||
@@ -92,35 +92,17 @@ impl ReviewLogger for DefaultReviewLogger {
|
||||
.await;
|
||||
}
|
||||
|
||||
publish_events(&self.event_publisher, &movie, is_new_movie, review_event).await
|
||||
}
|
||||
}
|
||||
if let Some(ext_id) = movie.external_metadata_id() {
|
||||
self.event_publisher
|
||||
.publish(&DomainEvent::MovieEnrichmentRequested {
|
||||
movie_id: movie.id().clone(),
|
||||
external_metadata_id: ext_id.clone(),
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
|
||||
async fn publish_events(
|
||||
publisher: &Arc<dyn EventPublisher>,
|
||||
movie: &Movie,
|
||||
is_new_movie: bool,
|
||||
review_event: DomainEvent,
|
||||
) -> Result<(), DomainError> {
|
||||
if is_new_movie && let Some(ext_id) = movie.external_metadata_id() {
|
||||
publisher
|
||||
.publish(&DomainEvent::MovieDiscovered {
|
||||
movie_id: movie.id().clone(),
|
||||
external_metadata_id: ext_id.clone(),
|
||||
})
|
||||
.await?;
|
||||
self.event_publisher.publish(&review_event).await
|
||||
}
|
||||
|
||||
if let Some(ext_id) = movie.external_metadata_id() {
|
||||
publisher
|
||||
.publish(&DomainEvent::MovieEnrichmentRequested {
|
||||
movie_id: movie.id().clone(),
|
||||
external_metadata_id: ext_id.clone(),
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
|
||||
publisher.publish(&review_event).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -68,18 +68,27 @@ struct FakeSocialWithFollowing(Vec<String>);
|
||||
|
||||
#[async_trait]
|
||||
impl domain::ports::SocialQueryPort for FakeSocialWithFollowing {
|
||||
async fn get_accepted_following_urls(&self, _: uuid::Uuid) -> Result<Vec<String>, DomainError> {
|
||||
async fn get_accepted_following_urls(
|
||||
&self,
|
||||
_: &domain::value_objects::UserId,
|
||||
) -> Result<Vec<String>, DomainError> {
|
||||
Ok(self.0.clone())
|
||||
}
|
||||
async fn count_following(&self, _: uuid::Uuid) -> Result<usize, DomainError> {
|
||||
async fn count_following(
|
||||
&self,
|
||||
_: &domain::value_objects::UserId,
|
||||
) -> Result<usize, DomainError> {
|
||||
Ok(0)
|
||||
}
|
||||
async fn count_accepted_followers(&self, _: uuid::Uuid) -> Result<usize, DomainError> {
|
||||
async fn count_accepted_followers(
|
||||
&self,
|
||||
_: &domain::value_objects::UserId,
|
||||
) -> Result<usize, DomainError> {
|
||||
Ok(0)
|
||||
}
|
||||
async fn get_pending_followers(
|
||||
&self,
|
||||
_: uuid::Uuid,
|
||||
_: &domain::value_objects::UserId,
|
||||
) -> Result<Vec<domain::models::PendingFollowerInfo>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::{Goal, GoalType, GoalWithProgress},
|
||||
ports::{EventPublisher, GoalRepository},
|
||||
ports::{EventPublisher, GoalRepository, StatsRepository},
|
||||
value_objects::UserId,
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ use super::commands::CreateGoalCommand;
|
||||
|
||||
pub async fn execute(
|
||||
goal: Arc<dyn GoalRepository>,
|
||||
stats: Arc<dyn StatsRepository>,
|
||||
event_publisher: Arc<dyn EventPublisher>,
|
||||
cmd: CreateGoalCommand,
|
||||
) -> Result<GoalWithProgress, DomainError> {
|
||||
@@ -32,7 +33,7 @@ pub async fn execute(
|
||||
)?;
|
||||
goal.save(&g).await?;
|
||||
|
||||
let current_count = goal.count_reviews_in_year(&user_id, cmd.year).await?;
|
||||
let current_count = stats.count_reviews_in_year(&user_id, cmd.year).await?;
|
||||
|
||||
event_publisher
|
||||
.publish(&DomainEvent::GoalCreated {
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::{
|
||||
errors::DomainError, models::GoalWithProgress, ports::GoalRepository, value_objects::UserId,
|
||||
errors::DomainError,
|
||||
models::GoalWithProgress,
|
||||
ports::{GoalRepository, StatsRepository},
|
||||
value_objects::UserId,
|
||||
};
|
||||
|
||||
use super::queries::GetGoalQuery;
|
||||
|
||||
pub async fn execute(
|
||||
goal: Arc<dyn GoalRepository>,
|
||||
stats: Arc<dyn StatsRepository>,
|
||||
query: GetGoalQuery,
|
||||
) -> Result<Option<GoalWithProgress>, DomainError> {
|
||||
let user_id = UserId::from_uuid(query.user_id);
|
||||
@@ -16,7 +20,7 @@ pub async fn execute(
|
||||
|
||||
let Some(g) = found else { return Ok(None) };
|
||||
|
||||
let current_count = goal.count_reviews_in_year(&user_id, query.year).await?;
|
||||
let current_count = stats.count_reviews_in_year(&user_id, query.year).await?;
|
||||
|
||||
Ok(Some(GoalWithProgress {
|
||||
goal: g,
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::{
|
||||
errors::DomainError, models::GoalWithProgress, ports::GoalRepository, value_objects::UserId,
|
||||
errors::DomainError,
|
||||
models::GoalWithProgress,
|
||||
ports::{GoalRepository, StatsRepository},
|
||||
value_objects::UserId,
|
||||
};
|
||||
|
||||
use super::queries::ListGoalsQuery;
|
||||
|
||||
pub async fn execute(
|
||||
goal: Arc<dyn GoalRepository>,
|
||||
stats: Arc<dyn StatsRepository>,
|
||||
query: ListGoalsQuery,
|
||||
) -> Result<Vec<GoalWithProgress>, DomainError> {
|
||||
let user_id = UserId::from_uuid(query.user_id);
|
||||
@@ -15,7 +19,7 @@ pub async fn execute(
|
||||
|
||||
let mut result = Vec::with_capacity(goals.len());
|
||||
for g in goals {
|
||||
let current_count = goal.count_reviews_in_year(&user_id, g.year()).await?;
|
||||
let current_count = stats.count_reviews_in_year(&user_id, g.year()).await?;
|
||||
result.push(GoalWithProgress {
|
||||
goal: g,
|
||||
current_count,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::events::DomainEvent;
|
||||
use domain::testing::{InMemoryGoalRepository, NoopEventPublisher};
|
||||
use domain::testing::{FakeStatsRepository, InMemoryGoalRepository, NoopEventPublisher};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::goals::{commands::CreateGoalCommand, create};
|
||||
@@ -10,10 +10,12 @@ use crate::test_helpers::TestContextBuilder;
|
||||
#[tokio::test]
|
||||
async fn creates_goal_and_returns_progress() {
|
||||
let goals = InMemoryGoalRepository::new();
|
||||
let stats = FakeStatsRepository::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
|
||||
let result = create::execute(
|
||||
Arc::clone(&goals) as _,
|
||||
Arc::clone(&stats) as _,
|
||||
Arc::clone(&events) as _,
|
||||
CreateGoalCommand {
|
||||
user_id: Uuid::nil(),
|
||||
@@ -33,11 +35,13 @@ async fn creates_goal_and_returns_progress() {
|
||||
#[tokio::test]
|
||||
async fn creates_goal_with_review_count() {
|
||||
let goals = InMemoryGoalRepository::new();
|
||||
goals.set_review_count(Uuid::nil(), 2025, 5);
|
||||
let stats = FakeStatsRepository::new();
|
||||
stats.set_review_count(Uuid::nil(), 2025, 5);
|
||||
let events = NoopEventPublisher::new();
|
||||
|
||||
let result = create::execute(
|
||||
Arc::clone(&goals) as _,
|
||||
Arc::clone(&stats) as _,
|
||||
Arc::clone(&events) as _,
|
||||
CreateGoalCommand {
|
||||
user_id: Uuid::nil(),
|
||||
@@ -59,6 +63,7 @@ async fn emits_goal_created_event() {
|
||||
|
||||
create::execute(
|
||||
b.goal_repo.clone(),
|
||||
b.stats_repo.clone(),
|
||||
Arc::clone(&events) as _,
|
||||
CreateGoalCommand {
|
||||
user_id: Uuid::nil(),
|
||||
@@ -86,12 +91,18 @@ async fn rejects_duplicate_year() {
|
||||
target_count: 10,
|
||||
};
|
||||
|
||||
create::execute(b.goal_repo.clone(), b.event_publisher.clone(), cmd)
|
||||
.await
|
||||
.unwrap();
|
||||
create::execute(
|
||||
b.goal_repo.clone(),
|
||||
b.stats_repo.clone(),
|
||||
b.event_publisher.clone(),
|
||||
cmd,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result = create::execute(
|
||||
b.goal_repo.clone(),
|
||||
b.stats_repo.clone(),
|
||||
b.event_publisher.clone(),
|
||||
CreateGoalCommand {
|
||||
user_id: Uuid::nil(),
|
||||
@@ -109,6 +120,7 @@ async fn rejects_year_before_2020() {
|
||||
let b = TestContextBuilder::new();
|
||||
let result = create::execute(
|
||||
b.goal_repo.clone(),
|
||||
b.stats_repo.clone(),
|
||||
b.event_publisher.clone(),
|
||||
CreateGoalCommand {
|
||||
user_id: Uuid::nil(),
|
||||
@@ -126,6 +138,7 @@ async fn rejects_zero_target() {
|
||||
let b = TestContextBuilder::new();
|
||||
let result = create::execute(
|
||||
b.goal_repo.clone(),
|
||||
b.stats_repo.clone(),
|
||||
b.event_publisher.clone(),
|
||||
CreateGoalCommand {
|
||||
user_id: Uuid::nil(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::testing::{InMemoryGoalRepository, NoopEventPublisher};
|
||||
use domain::testing::{FakeStatsRepository, InMemoryGoalRepository, NoopEventPublisher};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::goals::{
|
||||
@@ -12,10 +12,12 @@ use crate::test_helpers::TestContextBuilder;
|
||||
#[tokio::test]
|
||||
async fn deletes_existing_goal() {
|
||||
let goals = InMemoryGoalRepository::new();
|
||||
let stats = FakeStatsRepository::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
|
||||
create::execute(
|
||||
Arc::clone(&goals) as _,
|
||||
Arc::clone(&stats) as _,
|
||||
Arc::clone(&events) as _,
|
||||
CreateGoalCommand {
|
||||
user_id: Uuid::nil(),
|
||||
|
||||
@@ -8,6 +8,7 @@ async fn returns_goal_when_exists() {
|
||||
let b = TestContextBuilder::new();
|
||||
create::execute(
|
||||
b.goal_repo.clone(),
|
||||
b.stats_repo.clone(),
|
||||
b.event_publisher.clone(),
|
||||
CreateGoalCommand {
|
||||
user_id: Uuid::nil(),
|
||||
@@ -20,6 +21,7 @@ async fn returns_goal_when_exists() {
|
||||
|
||||
let result = get::execute(
|
||||
b.goal_repo.clone(),
|
||||
b.stats_repo.clone(),
|
||||
GetGoalQuery {
|
||||
user_id: Uuid::nil(),
|
||||
year: 2025,
|
||||
@@ -37,6 +39,7 @@ async fn returns_none_when_missing() {
|
||||
let b = TestContextBuilder::new();
|
||||
let result = get::execute(
|
||||
b.goal_repo.clone(),
|
||||
b.stats_repo.clone(),
|
||||
GetGoalQuery {
|
||||
user_id: Uuid::nil(),
|
||||
year: 2025,
|
||||
|
||||
@@ -8,6 +8,7 @@ async fn returns_empty_when_no_goals() {
|
||||
let b = TestContextBuilder::new();
|
||||
let result = list::execute(
|
||||
b.goal_repo.clone(),
|
||||
b.stats_repo.clone(),
|
||||
ListGoalsQuery {
|
||||
user_id: Uuid::nil(),
|
||||
},
|
||||
@@ -24,6 +25,7 @@ async fn returns_all_goals_for_user() {
|
||||
for year in [2023, 2024, 2025] {
|
||||
create::execute(
|
||||
b.goal_repo.clone(),
|
||||
b.stats_repo.clone(),
|
||||
b.event_publisher.clone(),
|
||||
CreateGoalCommand {
|
||||
user_id: Uuid::nil(),
|
||||
@@ -37,6 +39,7 @@ async fn returns_all_goals_for_user() {
|
||||
|
||||
let result = list::execute(
|
||||
b.goal_repo.clone(),
|
||||
b.stats_repo.clone(),
|
||||
ListGoalsQuery {
|
||||
user_id: Uuid::nil(),
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@ async fn updates_target_count() {
|
||||
let b = TestContextBuilder::new();
|
||||
create::execute(
|
||||
b.goal_repo.clone(),
|
||||
b.stats_repo.clone(),
|
||||
b.event_publisher.clone(),
|
||||
CreateGoalCommand {
|
||||
user_id: Uuid::nil(),
|
||||
@@ -23,6 +24,7 @@ async fn updates_target_count() {
|
||||
|
||||
let result = update::execute(
|
||||
b.goal_repo.clone(),
|
||||
b.stats_repo.clone(),
|
||||
b.event_publisher.clone(),
|
||||
UpdateGoalCommand {
|
||||
user_id: Uuid::nil(),
|
||||
@@ -41,6 +43,7 @@ async fn fails_when_goal_not_found() {
|
||||
let b = TestContextBuilder::new();
|
||||
let result = update::execute(
|
||||
b.goal_repo.clone(),
|
||||
b.stats_repo.clone(),
|
||||
b.event_publisher.clone(),
|
||||
UpdateGoalCommand {
|
||||
user_id: Uuid::nil(),
|
||||
@@ -58,6 +61,7 @@ async fn rejects_zero_target() {
|
||||
let b = TestContextBuilder::new();
|
||||
create::execute(
|
||||
b.goal_repo.clone(),
|
||||
b.stats_repo.clone(),
|
||||
b.event_publisher.clone(),
|
||||
CreateGoalCommand {
|
||||
user_id: Uuid::nil(),
|
||||
@@ -70,6 +74,7 @@ async fn rejects_zero_target() {
|
||||
|
||||
let result = update::execute(
|
||||
b.goal_repo.clone(),
|
||||
b.stats_repo.clone(),
|
||||
b.event_publisher.clone(),
|
||||
UpdateGoalCommand {
|
||||
user_id: Uuid::nil(),
|
||||
|
||||
@@ -4,7 +4,7 @@ use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::GoalWithProgress,
|
||||
ports::{EventPublisher, GoalRepository},
|
||||
ports::{EventPublisher, GoalRepository, StatsRepository},
|
||||
value_objects::UserId,
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ use super::commands::UpdateGoalCommand;
|
||||
|
||||
pub async fn execute(
|
||||
goal: Arc<dyn GoalRepository>,
|
||||
stats: Arc<dyn StatsRepository>,
|
||||
event_publisher: Arc<dyn EventPublisher>,
|
||||
cmd: UpdateGoalCommand,
|
||||
) -> Result<GoalWithProgress, DomainError> {
|
||||
@@ -25,7 +26,7 @@ pub async fn execute(
|
||||
g.update_target(cmd.target_count)?;
|
||||
goal.update(&g).await?;
|
||||
|
||||
let current_count = goal.count_reviews_in_year(&user_id, cmd.year).await?;
|
||||
let current_count = stats.count_reviews_in_year(&user_id, cmd.year).await?;
|
||||
|
||||
event_publisher
|
||||
.publish(&DomainEvent::GoalUpdated {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
pub mod config;
|
||||
pub mod jobs;
|
||||
pub mod ports;
|
||||
pub mod rendering;
|
||||
pub mod worker;
|
||||
|
||||
pub mod auth;
|
||||
|
||||
@@ -6,7 +6,7 @@ use domain::{
|
||||
events::DomainEvent,
|
||||
models::MovieProfile,
|
||||
ports::{
|
||||
EventHandler, MovieEnrichmentClient, MovieProfileRepository, MovieRepository,
|
||||
EventHandler, ImageFetcher, MovieEnrichmentClient, MovieProfileRepository, MovieRepository,
|
||||
ObjectStorage, PersonCommand, SearchCommand,
|
||||
},
|
||||
};
|
||||
@@ -22,7 +22,7 @@ pub struct MovieEnrichmentHandler {
|
||||
person_command: Arc<dyn PersonCommand>,
|
||||
search_command: Arc<dyn SearchCommand>,
|
||||
object_storage: Arc<dyn ObjectStorage>,
|
||||
http: reqwest::Client,
|
||||
image_fetcher: Arc<dyn ImageFetcher>,
|
||||
}
|
||||
|
||||
impl MovieEnrichmentHandler {
|
||||
@@ -33,6 +33,7 @@ impl MovieEnrichmentHandler {
|
||||
person_command: Arc<dyn PersonCommand>,
|
||||
search_command: Arc<dyn SearchCommand>,
|
||||
object_storage: Arc<dyn ObjectStorage>,
|
||||
image_fetcher: Arc<dyn ImageFetcher>,
|
||||
) -> Self {
|
||||
Self {
|
||||
enrichment_client,
|
||||
@@ -41,7 +42,7 @@ impl MovieEnrichmentHandler {
|
||||
person_command,
|
||||
search_command,
|
||||
object_storage,
|
||||
http: reqwest::Client::new(),
|
||||
image_fetcher,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,15 +56,13 @@ impl MovieEnrichmentHandler {
|
||||
continue;
|
||||
}
|
||||
let url = format!("https://image.tmdb.org/t/p/w185{path}");
|
||||
match self.http.get(&url).send().await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
if let Ok(bytes) = resp.bytes().await
|
||||
&& let Err(e) = self.object_storage.store(&key, &bytes).await
|
||||
{
|
||||
match self.image_fetcher.fetch_image(&url).await {
|
||||
Ok(bytes) => {
|
||||
if let Err(e) = self.object_storage.store(&key, &bytes).await {
|
||||
tracing::debug!("cast photo store failed for {path}: {e}");
|
||||
}
|
||||
}
|
||||
_ => tracing::debug!("cast photo download failed for {path}"),
|
||||
Err(_) => tracing::debug!("cast photo download failed for {path}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ pub mod merge_duplicates;
|
||||
pub mod queries;
|
||||
pub mod reindex_search;
|
||||
pub mod request_enrichment;
|
||||
pub mod resolve;
|
||||
pub mod search_cleanup;
|
||||
pub mod sync_poster;
|
||||
|
||||
|
||||
51
crates/application/src/movies/resolve.rs
Normal file
51
crates/application/src/movies/resolve.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::Movie,
|
||||
ports::{EventPublisher, MetadataClient, MovieRepository},
|
||||
value_objects::MovieId,
|
||||
};
|
||||
|
||||
use crate::diary::commands::MovieInput;
|
||||
use crate::diary::movie_resolver::{MovieResolver, MovieResolverDeps};
|
||||
|
||||
/// Resolves a movie from input, persists it, and publishes `MovieDiscovered` if new.
|
||||
///
|
||||
/// Returns `(movie, is_new_movie)`.
|
||||
pub async fn resolve_and_persist_movie(
|
||||
input: &MovieInput,
|
||||
movie_repo: &dyn MovieRepository,
|
||||
metadata_client: &dyn MetadataClient,
|
||||
event_publisher: &dyn EventPublisher,
|
||||
) -> Result<(Movie, bool), DomainError> {
|
||||
let (movie, is_new) = if let Some(id) = input.movie_id {
|
||||
let movie_id = MovieId::from_uuid(id);
|
||||
let movie = movie_repo
|
||||
.get_movie_by_id(&movie_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound(format!("Movie {id}")))?;
|
||||
(movie, false)
|
||||
} else {
|
||||
let deps = MovieResolverDeps {
|
||||
repository: movie_repo,
|
||||
metadata_client,
|
||||
};
|
||||
MovieResolver::default_pipeline()
|
||||
.resolve(input, &deps)
|
||||
.await?
|
||||
};
|
||||
|
||||
if is_new {
|
||||
movie_repo.upsert_movie(&movie).await?;
|
||||
if let Some(ext_id) = movie.external_metadata_id() {
|
||||
let _ = event_publisher
|
||||
.publish(&DomainEvent::MovieDiscovered {
|
||||
movie_id: movie.id().clone(),
|
||||
external_metadata_id: ext_id.clone(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
Ok((movie, is_new))
|
||||
}
|
||||
@@ -1,13 +1,10 @@
|
||||
use chrono::Utc;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::{Person, PersonId},
|
||||
};
|
||||
|
||||
use super::deps::GetPersonDeps;
|
||||
|
||||
const ENRICHMENT_TTL_DAYS: i64 = 90;
|
||||
use super::{deps::GetPersonDeps, should_enrich};
|
||||
|
||||
pub async fn execute(deps: &GetPersonDeps, id: PersonId) -> Result<Option<Person>, DomainError> {
|
||||
let person = deps.person_query.get_by_id(&id).await?;
|
||||
@@ -25,13 +22,6 @@ pub async fn execute(deps: &GetPersonDeps, id: PersonId) -> Result<Option<Person
|
||||
Ok(person)
|
||||
}
|
||||
|
||||
fn should_enrich(p: &Person) -> bool {
|
||||
match p.enriched_at() {
|
||||
None => true,
|
||||
Some(at) => (Utc::now() - at).num_days() >= ENRICHMENT_TTL_DAYS,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/get.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
use chrono::Utc;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::{Person, PersonCredits, PersonId},
|
||||
models::{PersonCredits, PersonId},
|
||||
};
|
||||
|
||||
use super::deps::GetPersonDeps;
|
||||
|
||||
const ENRICHMENT_TTL_DAYS: i64 = 90;
|
||||
use super::{deps::GetPersonDeps, should_enrich};
|
||||
|
||||
pub async fn execute(deps: &GetPersonDeps, id: PersonId) -> Result<PersonCredits, DomainError> {
|
||||
let credits = deps.person_query.get_credits(&id).await?;
|
||||
@@ -23,13 +20,6 @@ pub async fn execute(deps: &GetPersonDeps, id: PersonId) -> Result<PersonCredits
|
||||
Ok(credits)
|
||||
}
|
||||
|
||||
fn should_enrich(p: &Person) -> bool {
|
||||
match p.enriched_at() {
|
||||
None => true,
|
||||
Some(at) => (Utc::now() - at).num_days() >= ENRICHMENT_TTL_DAYS,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/get_credits.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -5,3 +5,15 @@ pub mod get;
|
||||
pub mod get_credits;
|
||||
|
||||
pub use event_handler::PersonEnrichmentHandler;
|
||||
|
||||
use chrono::Utc;
|
||||
use domain::models::Person;
|
||||
|
||||
pub(crate) const ENRICHMENT_TTL_DAYS: i64 = 90;
|
||||
|
||||
pub(crate) fn should_enrich(p: &Person) -> bool {
|
||||
match p.enriched_at() {
|
||||
None => true,
|
||||
Some(at) => (Utc::now() - at).num_days() >= ENRICHMENT_TTL_DAYS,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
use domain::errors::DomainError;
|
||||
use domain::models::DiaryEntry;
|
||||
|
||||
use crate::diary::commands::LogReviewCommand;
|
||||
|
||||
@@ -9,7 +8,3 @@ use crate::diary::commands::LogReviewCommand;
|
||||
pub trait ReviewLogger: Send + Sync {
|
||||
async fn log_review(&self, cmd: LogReviewCommand) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
pub trait RssFeedRenderer: Send + Sync {
|
||||
fn render_feed(&self, entries: &[DiaryEntry], title: &str) -> Result<String, String>;
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct HtmlPageContext {
|
||||
pub user_email: Option<String>,
|
||||
pub user_id: Option<Uuid>,
|
||||
pub is_admin: bool,
|
||||
pub register_enabled: bool,
|
||||
pub rss_url: String,
|
||||
pub page_title: String,
|
||||
pub canonical_url: String,
|
||||
pub csrf_token: String,
|
||||
pub page_rss_url: Option<String>,
|
||||
}
|
||||
|
||||
impl HtmlPageContext {
|
||||
pub fn is_current_user(&self, id: Uuid) -> bool {
|
||||
self.user_id == Some(id)
|
||||
}
|
||||
}
|
||||
@@ -88,7 +88,7 @@ impl TestContextBuilder {
|
||||
diary_repo: FakeDiaryRepository::new(),
|
||||
diary_exporter: Arc::new(PanicDiaryExporter),
|
||||
document_parser: Arc::new(FakeDocumentParser),
|
||||
stats_repo: Arc::new(FakeStatsRepository),
|
||||
stats_repo: FakeStatsRepository::new(),
|
||||
metadata_client: Arc::new(FakeMetadataClient),
|
||||
poster_fetcher: Arc::new(FakePosterFetcher),
|
||||
object_storage: Arc::new(NoopObjectStorage),
|
||||
|
||||
@@ -17,7 +17,7 @@ pub struct CurrentProfileData {
|
||||
pub banner_path: Option<String>,
|
||||
pub also_known_as: Option<String>,
|
||||
pub fields: Vec<ProfileFieldData>,
|
||||
pub role: String,
|
||||
pub role: domain::models::UserRole,
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
@@ -47,7 +47,7 @@ pub async fn execute(
|
||||
banner_path: found.banner_path().map(|s| s.to_string()),
|
||||
also_known_as: found.also_known_as().map(|s| s.to_string()),
|
||||
fields,
|
||||
role: found.role().as_str().into(),
|
||||
role: found.role().clone(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ pub async fn execute(
|
||||
let stats = deps.stats.get_user_stats(&user_id).await?;
|
||||
|
||||
let (following_count, followers_count, pending_followers) =
|
||||
load_social_counts(deps, query.user_id, query.is_own_profile).await;
|
||||
load_social_counts(deps, &user_id, query.is_own_profile).await;
|
||||
|
||||
let base = |entries, history, trends| UserProfileData {
|
||||
stats,
|
||||
@@ -76,7 +76,7 @@ pub async fn execute(
|
||||
|
||||
async fn load_social_counts(
|
||||
deps: &GetProfileDeps,
|
||||
user_id: uuid::Uuid,
|
||||
user_id: &UserId,
|
||||
is_own_profile: bool,
|
||||
) -> (usize, usize, Vec<PendingFollowerView>) {
|
||||
let following = deps
|
||||
|
||||
@@ -9,3 +9,52 @@ pub mod queries;
|
||||
pub mod update_profile;
|
||||
pub mod update_profile_fields;
|
||||
pub mod update_settings;
|
||||
|
||||
use chrono::Datelike;
|
||||
use domain::models::{DiaryEntry, MonthActivity};
|
||||
|
||||
pub fn group_by_month(entries: Vec<DiaryEntry>) -> Vec<MonthActivity> {
|
||||
use std::collections::BTreeMap;
|
||||
let mut map: BTreeMap<(i32, u32), Vec<DiaryEntry>> = BTreeMap::new();
|
||||
for entry in entries {
|
||||
let watched_at = entry.review().watched_at();
|
||||
let year = watched_at.year();
|
||||
let month = watched_at.month();
|
||||
map.entry((year, month)).or_default().push(entry);
|
||||
}
|
||||
map.into_iter()
|
||||
.rev()
|
||||
.map(|((year, month), entries)| {
|
||||
let year_month = format!("{:04}-{:02}", year, month);
|
||||
MonthActivity {
|
||||
month_label: format_year_month_long(&year_month),
|
||||
count: entries.len() as i64,
|
||||
entries,
|
||||
year_month,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn format_year_month_long(ym: &str) -> String {
|
||||
let parts: Vec<&str> = ym.splitn(2, '-').collect();
|
||||
if parts.len() != 2 {
|
||||
return ym.to_string();
|
||||
}
|
||||
let month = match parts[1] {
|
||||
"01" => "January",
|
||||
"02" => "February",
|
||||
"03" => "March",
|
||||
"04" => "April",
|
||||
"05" => "May",
|
||||
"06" => "June",
|
||||
"07" => "July",
|
||||
"08" => "August",
|
||||
"09" => "September",
|
||||
"10" => "October",
|
||||
"11" => "November",
|
||||
"12" => "December",
|
||||
_ => parts[1],
|
||||
};
|
||||
format!("{} {}", month, parts[0])
|
||||
}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::WatchlistEntry,
|
||||
value_objects::{MovieId, UserId},
|
||||
errors::DomainError, events::DomainEvent, models::WatchlistEntry, value_objects::UserId,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
diary::movie_resolver::{MovieResolver, MovieResolverDeps},
|
||||
movies::resolve::resolve_and_persist_movie,
|
||||
watchlist::{commands::AddToWatchlistCommand, deps::WatchlistAddDeps},
|
||||
};
|
||||
|
||||
@@ -16,34 +13,13 @@ pub async fn execute(
|
||||
) -> 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);
|
||||
deps.movie
|
||||
.get_movie_by_id(&movie_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound(format!("Movie {id}")))?
|
||||
} else {
|
||||
let resolver_deps = MovieResolverDeps {
|
||||
repository: deps.movie.as_ref(),
|
||||
metadata_client: deps.metadata.as_ref(),
|
||||
};
|
||||
let (movie, is_new) = MovieResolver::default_pipeline()
|
||||
.resolve(&cmd.input, &resolver_deps)
|
||||
.await?;
|
||||
if is_new {
|
||||
deps.movie.upsert_movie(&movie).await?;
|
||||
if let Some(ext_id) = movie.external_metadata_id() {
|
||||
let _ = deps
|
||||
.event_publisher
|
||||
.publish(&DomainEvent::MovieDiscovered {
|
||||
movie_id: movie.id().clone(),
|
||||
external_metadata_id: ext_id.clone(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
movie
|
||||
};
|
||||
let (movie, _is_new) = resolve_and_persist_movie(
|
||||
&cmd.input,
|
||||
deps.movie.as_ref(),
|
||||
deps.metadata.as_ref(),
|
||||
deps.event_publisher.as_ref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let entry = WatchlistEntry::new(user_id.clone(), movie.id().clone());
|
||||
deps.watchlist.add(&entry).await?;
|
||||
|
||||
Reference in New Issue
Block a user