refactor: extract business logic from handlers to application layer
Move domain logic out of 7 handlers into use cases:
- activity feed: FollowingFilter construction
- user profile: social counts + pending followers
- users list: parallel local+remote actor loading
- watchlist page: local-vs-remote branching
- sync_poster: movie lookup + validation
- get_profile: avatar URL construction
- post_register: register+login orchestration
Add SocialQueryPort.{count_following,count_accepted_followers,
get_pending_followers} to AppContext behind federation feature gate.
This commit is contained in:
@@ -21,7 +21,6 @@ pub struct LogReviewCommand {
|
||||
#[derive(Clone)]
|
||||
pub struct SyncPosterCommand {
|
||||
pub movie_id: Uuid,
|
||||
pub external_metadata_id: String,
|
||||
}
|
||||
|
||||
pub struct RegisterCommand {
|
||||
@@ -103,3 +102,9 @@ pub struct RemoveFromWatchlistCommand {
|
||||
pub user_id: Uuid,
|
||||
pub movie_id: Uuid,
|
||||
}
|
||||
|
||||
pub struct RegisterAndLoginCommand {
|
||||
pub email: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ use domain::ports::{
|
||||
AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher, ImageStorage,
|
||||
ImportProfileRepository, ImportSessionRepository, MetadataClient, MovieProfileRepository,
|
||||
MovieRepository, PasswordHasher, PersonCommand, PersonQuery, PosterFetcherClient,
|
||||
ReviewRepository, SearchCommand, SearchPort, StatsRepository, UserProfileFieldsRepository,
|
||||
UserRepository, WatchlistRepository,
|
||||
ReviewRepository, SearchCommand, SearchPort, SocialQueryPort, StatsRepository,
|
||||
UserProfileFieldsRepository, UserRepository, WatchlistRepository,
|
||||
};
|
||||
|
||||
use crate::config::AppConfig;
|
||||
@@ -38,5 +38,7 @@ pub struct AppContext {
|
||||
pub profile_fields_repository: Arc<dyn UserProfileFieldsRepository>,
|
||||
#[cfg(feature = "federation")]
|
||||
pub remote_watchlist_repository: Arc<dyn RemoteWatchlistRepository>,
|
||||
#[cfg(feature = "federation")]
|
||||
pub social_query: Arc<dyn SocialQueryPort>,
|
||||
pub config: AppConfig,
|
||||
}
|
||||
|
||||
@@ -28,7 +28,8 @@ pub struct GetActivityFeedQuery {
|
||||
pub offset: u32,
|
||||
pub sort_by: domain::ports::FeedSortBy,
|
||||
pub search: Option<String>,
|
||||
pub following: Option<domain::ports::FollowingFilter>,
|
||||
pub viewer_user_id: Option<Uuid>,
|
||||
pub filter_following: bool,
|
||||
}
|
||||
|
||||
pub struct GetUsersQuery;
|
||||
@@ -73,6 +74,7 @@ pub struct GetUserProfileQuery {
|
||||
pub offset: Option<u32>,
|
||||
pub sort_by: domain::ports::FeedSortBy,
|
||||
pub search: Option<String>,
|
||||
pub is_own_profile: bool,
|
||||
}
|
||||
|
||||
pub struct GetMovieSocialPageQuery {
|
||||
@@ -99,3 +101,7 @@ pub struct IsOnWatchlistQuery {
|
||||
pub user_id: Uuid,
|
||||
pub movie_id: Uuid,
|
||||
}
|
||||
|
||||
pub struct GetCurrentProfileQuery {
|
||||
pub user_id: Uuid,
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ use std::sync::Arc;
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
use domain::testing::PanicRemoteWatchlistRepository;
|
||||
#[cfg(feature = "federation")]
|
||||
use domain::testing::PanicSocialQueryPort;
|
||||
use domain::{
|
||||
ports::{
|
||||
AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher, ImageStorage,
|
||||
@@ -144,6 +146,8 @@ impl TestContextBuilder {
|
||||
config: self.config,
|
||||
#[cfg(feature = "federation")]
|
||||
remote_watchlist_repository: std::sync::Arc::new(PanicRemoteWatchlistRepository),
|
||||
#[cfg(feature = "federation")]
|
||||
social_query: std::sync::Arc::new(PanicSocialQueryPort),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ use domain::{
|
||||
FeedEntry,
|
||||
collections::{PageParams, Paginated},
|
||||
},
|
||||
ports::FollowingFilter,
|
||||
};
|
||||
|
||||
pub async fn execute(
|
||||
@@ -12,12 +13,57 @@ pub async fn execute(
|
||||
query: GetActivityFeedQuery,
|
||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
let page = PageParams::new(Some(query.limit), Some(query.offset))?;
|
||||
|
||||
let following = build_following_filter(ctx, &query).await;
|
||||
|
||||
ctx.diary_repository
|
||||
.query_activity_feed_filtered(
|
||||
&page,
|
||||
&query.sort_by,
|
||||
query.search.as_deref(),
|
||||
query.following.as_ref(),
|
||||
following.as_ref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn build_following_filter(
|
||||
_ctx: &AppContext,
|
||||
query: &GetActivityFeedQuery,
|
||||
) -> Option<FollowingFilter> {
|
||||
#[cfg(not(feature = "federation"))]
|
||||
{
|
||||
let _ = query;
|
||||
return None;
|
||||
}
|
||||
#[cfg(feature = "federation")]
|
||||
{
|
||||
if !query.filter_following {
|
||||
return None;
|
||||
}
|
||||
let viewer_id = match query.viewer_user_id {
|
||||
Some(id) => id,
|
||||
None => return None,
|
||||
};
|
||||
let urls = _ctx
|
||||
.social_query
|
||||
.get_accepted_following_urls(viewer_id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let base_url = &_ctx.config.base_url;
|
||||
let mut local_ids = vec![viewer_id];
|
||||
let mut remote_urls = Vec::new();
|
||||
for url in urls {
|
||||
if let Some(suffix) = url.strip_prefix(&format!("{}/users/", base_url))
|
||||
&& let Ok(parsed_id) = uuid::Uuid::parse_str(suffix)
|
||||
{
|
||||
local_ids.push(parsed_id);
|
||||
continue;
|
||||
}
|
||||
remote_urls.push(url);
|
||||
}
|
||||
Some(FollowingFilter {
|
||||
local_user_ids: local_ids,
|
||||
remote_actor_urls: remote_urls,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
31
crates/application/src/use_cases/get_current_profile.rs
Normal file
31
crates/application/src/use_cases/get_current_profile.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use domain::errors::DomainError;
|
||||
|
||||
use crate::{context::AppContext, queries::GetCurrentProfileQuery};
|
||||
|
||||
pub struct CurrentProfileData {
|
||||
pub username: String,
|
||||
pub bio: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
ctx: &AppContext,
|
||||
query: GetCurrentProfileQuery,
|
||||
) -> Result<CurrentProfileData, DomainError> {
|
||||
let user_id = domain::value_objects::UserId::from_uuid(query.user_id);
|
||||
let user = ctx
|
||||
.user_repository
|
||||
.find_by_id(&user_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound("User not found".into()))?;
|
||||
|
||||
let avatar_url = user
|
||||
.avatar_path()
|
||||
.map(|path| format!("{}/images/{}", ctx.config.base_url, path));
|
||||
|
||||
Ok(CurrentProfileData {
|
||||
username: user.username().value().to_string(),
|
||||
bio: user.bio().map(|s| s.to_string()),
|
||||
avatar_url,
|
||||
})
|
||||
}
|
||||
@@ -13,11 +13,21 @@ use domain::{
|
||||
value_objects::UserId,
|
||||
};
|
||||
|
||||
pub struct PendingFollowerView {
|
||||
pub url: String,
|
||||
pub handle: String,
|
||||
pub display_name: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
pub struct UserProfileData {
|
||||
pub stats: UserStats,
|
||||
pub entries: Option<Paginated<DiaryEntry>>,
|
||||
pub history: Option<Vec<MonthActivity>>,
|
||||
pub trends: Option<UserTrends>,
|
||||
pub following_count: usize,
|
||||
pub followers_count: usize,
|
||||
pub pending_followers: Vec<PendingFollowerView>,
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
@@ -27,27 +37,30 @@ pub async fn execute(
|
||||
let user_id = UserId::from_uuid(query.user_id);
|
||||
let stats = ctx.stats_repository.get_user_stats(&user_id).await?;
|
||||
|
||||
let (following_count, followers_count, pending_followers) =
|
||||
load_social_counts(ctx, query.user_id, query.is_own_profile).await;
|
||||
|
||||
let base = |entries, history, trends| UserProfileData {
|
||||
stats,
|
||||
entries,
|
||||
history,
|
||||
trends,
|
||||
following_count,
|
||||
followers_count,
|
||||
pending_followers,
|
||||
};
|
||||
|
||||
match query.view {
|
||||
ProfileView::History => {
|
||||
let all_entries = ctx.diary_repository.get_user_history(&user_id).await?;
|
||||
let history = group_by_month(all_entries);
|
||||
Ok(UserProfileData {
|
||||
stats,
|
||||
entries: None,
|
||||
history: Some(history),
|
||||
trends: None,
|
||||
})
|
||||
Ok(base(None, Some(history), None))
|
||||
}
|
||||
ProfileView::Trends => {
|
||||
let trends = ctx.stats_repository.get_user_trends(&user_id).await?;
|
||||
Ok(UserProfileData {
|
||||
stats,
|
||||
entries: None,
|
||||
history: None,
|
||||
trends: Some(trends),
|
||||
})
|
||||
Ok(base(None, None, Some(trends)))
|
||||
}
|
||||
ProfileView::Ratings => {
|
||||
ProfileView::Ratings | ProfileView::Recent => {
|
||||
let sort_direction = feed_sort_to_direction(query.sort_by);
|
||||
let filter = paged_user_filter(
|
||||
user_id,
|
||||
@@ -57,30 +70,49 @@ pub async fn execute(
|
||||
query.search.clone(),
|
||||
)?;
|
||||
let entries = ctx.diary_repository.query_diary(&filter).await?;
|
||||
Ok(UserProfileData {
|
||||
stats,
|
||||
entries: Some(entries),
|
||||
history: None,
|
||||
trends: None,
|
||||
})
|
||||
Ok(base(Some(entries), None, None))
|
||||
}
|
||||
ProfileView::Recent => {
|
||||
let sort_direction = feed_sort_to_direction(query.sort_by);
|
||||
let filter = paged_user_filter(
|
||||
user_id,
|
||||
sort_direction,
|
||||
query.limit,
|
||||
query.offset,
|
||||
query.search.clone(),
|
||||
)?;
|
||||
let entries = ctx.diary_repository.query_diary(&filter).await?;
|
||||
Ok(UserProfileData {
|
||||
stats,
|
||||
entries: Some(entries),
|
||||
history: None,
|
||||
trends: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_social_counts(
|
||||
_ctx: &AppContext,
|
||||
_user_id: uuid::Uuid,
|
||||
_is_own_profile: bool,
|
||||
) -> (usize, usize, Vec<PendingFollowerView>) {
|
||||
#[cfg(not(feature = "federation"))]
|
||||
{
|
||||
(0, 0, vec![])
|
||||
}
|
||||
#[cfg(feature = "federation")]
|
||||
{
|
||||
if !_is_own_profile {
|
||||
return (0, 0, vec![]);
|
||||
}
|
||||
let following = _ctx
|
||||
.social_query
|
||||
.count_following(_user_id)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let followers = _ctx
|
||||
.social_query
|
||||
.count_accepted_followers(_user_id)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let pending = _ctx
|
||||
.social_query
|
||||
.get_pending_followers(_user_id)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|p| PendingFollowerView {
|
||||
url: p.url,
|
||||
handle: p.handle,
|
||||
display_name: p.display_name,
|
||||
avatar_url: p.avatar_url,
|
||||
})
|
||||
.collect();
|
||||
(following, followers, pending)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,28 @@
|
||||
use crate::{context::AppContext, queries::GetUsersQuery};
|
||||
use domain::{errors::DomainError, models::UserSummary};
|
||||
use domain::{errors::DomainError, models::UserSummary, ports::RemoteActorInfo};
|
||||
|
||||
pub struct UsersListData {
|
||||
pub users: Vec<UserSummary>,
|
||||
pub remote_actors: Vec<RemoteActorInfo>,
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
ctx: &AppContext,
|
||||
_query: GetUsersQuery,
|
||||
) -> Result<Vec<UserSummary>, DomainError> {
|
||||
ctx.user_repository.list_with_stats().await
|
||||
) -> Result<UsersListData, DomainError> {
|
||||
#[cfg(feature = "federation")]
|
||||
let (users_result, actors_result) = tokio::join!(
|
||||
ctx.user_repository.list_with_stats(),
|
||||
ctx.social_query.list_all_followed_remote_actors()
|
||||
);
|
||||
#[cfg(not(feature = "federation"))]
|
||||
let (users_result, actors_result) = (
|
||||
ctx.user_repository.list_with_stats().await,
|
||||
Ok::<Vec<RemoteActorInfo>, DomainError>(vec![]),
|
||||
);
|
||||
|
||||
Ok(UsersListData {
|
||||
users: users_result?,
|
||||
remote_actors: actors_result?,
|
||||
})
|
||||
}
|
||||
|
||||
95
crates/application/src/use_cases/get_watchlist_page.rs
Normal file
95
crates/application/src/use_cases/get_watchlist_page.rs
Normal file
@@ -0,0 +1,95 @@
|
||||
use domain::{errors::DomainError, value_objects::UserId};
|
||||
|
||||
use crate::{context::AppContext, ports::WatchlistDisplayEntry, queries::GetWatchlistQuery};
|
||||
|
||||
pub struct WatchlistPageResult {
|
||||
pub display_entries: Vec<WatchlistDisplayEntry>,
|
||||
pub has_more: bool,
|
||||
pub current_offset: u32,
|
||||
pub limit: u32,
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
ctx: &AppContext,
|
||||
query: GetWatchlistQuery,
|
||||
is_owner: bool,
|
||||
) -> Result<WatchlistPageResult, DomainError> {
|
||||
let user_id = UserId::from_uuid(query.user_id);
|
||||
let is_local = ctx.user_repository.find_by_id(&user_id).await?.is_some();
|
||||
|
||||
if is_local {
|
||||
let page = super::get_watchlist::execute(ctx, query).await?;
|
||||
let has_more = page.offset + page.limit < page.total_count as u32;
|
||||
let display_entries = page
|
||||
.items
|
||||
.iter()
|
||||
.map(|w| {
|
||||
let remove_url = if is_owner {
|
||||
Some(format!("/watchlist/{}/remove", w.movie.id().value()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
WatchlistDisplayEntry {
|
||||
poster_url: w
|
||||
.movie
|
||||
.poster_path()
|
||||
.map(|p| format!("/images/{}", p.value())),
|
||||
movie_title: w.movie.title().value().to_string(),
|
||||
release_year: w.movie.release_year().value(),
|
||||
movie_url: Some(format!("/movies/{}", w.movie.id().value())),
|
||||
added_at: w.entry.added_at.format("%b %-d, %Y").to_string(),
|
||||
remove_url,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok(WatchlistPageResult {
|
||||
display_entries,
|
||||
has_more,
|
||||
current_offset: page.offset,
|
||||
limit: page.limit,
|
||||
})
|
||||
} else {
|
||||
load_remote_watchlist(ctx, query.user_id).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "federation"))]
|
||||
async fn load_remote_watchlist(
|
||||
_ctx: &AppContext,
|
||||
_user_id: uuid::Uuid,
|
||||
) -> Result<WatchlistPageResult, DomainError> {
|
||||
Ok(WatchlistPageResult {
|
||||
display_entries: vec![],
|
||||
has_more: false,
|
||||
current_offset: 0,
|
||||
limit: 0,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
async fn load_remote_watchlist(
|
||||
ctx: &AppContext,
|
||||
user_id: uuid::Uuid,
|
||||
) -> Result<WatchlistPageResult, DomainError> {
|
||||
let remote_entries = super::get_remote_watchlist::execute(ctx, user_id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let len = remote_entries.len() as u32;
|
||||
let display_entries = remote_entries
|
||||
.into_iter()
|
||||
.map(|e| WatchlistDisplayEntry {
|
||||
poster_url: e.poster_url,
|
||||
movie_title: e.movie_title,
|
||||
release_year: e.release_year,
|
||||
movie_url: None,
|
||||
added_at: e.added_at.format("%b %-d, %Y").to_string(),
|
||||
remove_url: None,
|
||||
})
|
||||
.collect();
|
||||
Ok(WatchlistPageResult {
|
||||
display_entries,
|
||||
has_more: false,
|
||||
current_offset: 0,
|
||||
limit: len,
|
||||
})
|
||||
}
|
||||
@@ -9,6 +9,7 @@ pub mod enrich_movie;
|
||||
pub mod execute_import;
|
||||
pub mod export_diary;
|
||||
pub mod get_activity_feed;
|
||||
pub mod get_current_profile;
|
||||
pub mod get_diary;
|
||||
pub mod get_movie_social_page;
|
||||
pub mod get_movies;
|
||||
@@ -20,11 +21,13 @@ pub mod get_review_history;
|
||||
pub mod get_user_profile;
|
||||
pub mod get_users;
|
||||
pub mod get_watchlist;
|
||||
pub mod get_watchlist_page;
|
||||
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 save_import_profile;
|
||||
pub mod search;
|
||||
|
||||
32
crates/application/src/use_cases/register_and_login.rs
Normal file
32
crates/application/src/use_cases/register_and_login.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
use domain::errors::DomainError;
|
||||
|
||||
use crate::{
|
||||
commands::RegisterAndLoginCommand,
|
||||
context::AppContext,
|
||||
use_cases::{login, register},
|
||||
};
|
||||
|
||||
pub async fn execute(
|
||||
ctx: &AppContext,
|
||||
cmd: RegisterAndLoginCommand,
|
||||
) -> Result<login::LoginResult, DomainError> {
|
||||
register::execute(
|
||||
ctx,
|
||||
crate::commands::RegisterCommand {
|
||||
email: cmd.email.clone(),
|
||||
username: cmd.username,
|
||||
password: cmd.password.clone(),
|
||||
role: domain::models::UserRole::Standard,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
login::execute(
|
||||
ctx,
|
||||
crate::queries::LoginQuery {
|
||||
email: cmd.email,
|
||||
password: cmd.password,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -2,14 +2,13 @@ use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::IndexableDocument,
|
||||
value_objects::{ExternalMetadataId, MovieId, PosterPath},
|
||||
value_objects::{MovieId, PosterPath},
|
||||
};
|
||||
|
||||
use crate::{commands::SyncPosterCommand, context::AppContext};
|
||||
|
||||
pub async fn execute(ctx: &AppContext, cmd: SyncPosterCommand) -> Result<(), DomainError> {
|
||||
let movie_id = MovieId::from_uuid(cmd.movie_id);
|
||||
let external_metadata_id = ExternalMetadataId::new(cmd.external_metadata_id)?;
|
||||
|
||||
let mut movie = match ctx.movie_repository.get_movie_by_id(&movie_id).await? {
|
||||
Some(m) => m,
|
||||
@@ -22,6 +21,15 @@ pub async fn execute(ctx: &AppContext, cmd: SyncPosterCommand) -> Result<(), Dom
|
||||
}
|
||||
};
|
||||
|
||||
let external_metadata_id = movie
|
||||
.external_metadata_id()
|
||||
.ok_or_else(|| {
|
||||
DomainError::ValidationError(
|
||||
"Movie has no external metadata ID, cannot sync poster".into(),
|
||||
)
|
||||
})?
|
||||
.clone();
|
||||
|
||||
let poster_url = match ctx
|
||||
.metadata_client
|
||||
.get_poster_url(&external_metadata_id)
|
||||
|
||||
Reference in New Issue
Block a user