refactor: SocialActor rich queries, migrate remaining handlers, slim SocialQueryPort

SocialQuery returns SocialActor (identity+handle+display_name+avatar_url)
instead of bare SocialIdentity. Migrated get_following_page,
get_followers_page, get_blocked_actors_page to use cases. Moved
get_activity_feed + get_profile from SocialQueryPort to SocialQuery.
Legacy SocialQueryPort remains only for get_users listing.
This commit is contained in:
2026-07-10 15:52:37 +02:00
parent 3ee75305a9
commit 7cfa234902
18 changed files with 276 additions and 148 deletions

View File

@@ -4,9 +4,11 @@ use async_trait::async_trait;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
ports::{SocialCommand, SocialQuery, UserRepository}, ports::{SocialCommand, SocialQuery, UserRepository},
value_objects::{SocialIdentity, UserId}, value_objects::{SocialActor, SocialIdentity, UserId},
}; };
use k_ap::RemoteActor;
use super::ActivityPubPort; use super::ActivityPubPort;
pub struct CompositeSocialAdapter { pub struct CompositeSocialAdapter {
@@ -51,6 +53,16 @@ impl CompositeSocialAdapter {
} }
} }
fn remote_actor_to_social_actor(&self, actor: RemoteActor) -> SocialActor {
let identity = self.identity_from_actor_url(&actor.url);
SocialActor {
identity,
handle: actor.handle,
display_name: actor.display_name,
avatar_url: actor.avatar_url,
}
}
async fn resolve_handle(&self, identity: &SocialIdentity) -> Result<String, DomainError> { async fn resolve_handle(&self, identity: &SocialIdentity) -> Result<String, DomainError> {
match identity { match identity {
SocialIdentity::Local(uid) => { SocialIdentity::Local(uid) => {
@@ -172,7 +184,7 @@ impl SocialQuery for CompositeSocialAdapter {
async fn get_following( async fn get_following(
&self, &self,
user: &UserId, user: &UserId,
) -> Result<Vec<SocialIdentity>, DomainError> { ) -> Result<Vec<SocialActor>, DomainError> {
let actors = self let actors = self
.ap_service .ap_service
.get_following(user.value()) .get_following(user.value())
@@ -180,14 +192,14 @@ impl SocialQuery for CompositeSocialAdapter {
.map_err(ap_err)?; .map_err(ap_err)?;
Ok(actors Ok(actors
.into_iter() .into_iter()
.map(|a| self.identity_from_actor_url(&a.url)) .map(|a| self.remote_actor_to_social_actor(a))
.collect()) .collect())
} }
async fn get_followers( async fn get_followers(
&self, &self,
user: &UserId, user: &UserId,
) -> Result<Vec<SocialIdentity>, DomainError> { ) -> Result<Vec<SocialActor>, DomainError> {
let actors = self let actors = self
.ap_service .ap_service
.get_accepted_followers(user.value()) .get_accepted_followers(user.value())
@@ -195,14 +207,14 @@ impl SocialQuery for CompositeSocialAdapter {
.map_err(ap_err)?; .map_err(ap_err)?;
Ok(actors Ok(actors
.into_iter() .into_iter()
.map(|a| self.identity_from_actor_url(&a.url)) .map(|a| self.remote_actor_to_social_actor(a))
.collect()) .collect())
} }
async fn get_pending_followers( async fn get_pending_followers(
&self, &self,
user: &UserId, user: &UserId,
) -> Result<Vec<SocialIdentity>, DomainError> { ) -> Result<Vec<SocialActor>, DomainError> {
let actors = self let actors = self
.ap_service .ap_service
.get_pending_followers(user.value()) .get_pending_followers(user.value())
@@ -210,7 +222,7 @@ impl SocialQuery for CompositeSocialAdapter {
.map_err(ap_err)?; .map_err(ap_err)?;
Ok(actors Ok(actors
.into_iter() .into_iter()
.map(|a| self.identity_from_actor_url(&a.url)) .map(|a| self.remote_actor_to_social_actor(a))
.collect()) .collect())
} }
@@ -231,7 +243,7 @@ impl SocialQuery for CompositeSocialAdapter {
async fn get_blocked( async fn get_blocked(
&self, &self,
user: &UserId, user: &UserId,
) -> Result<Vec<SocialIdentity>, DomainError> { ) -> Result<Vec<SocialActor>, DomainError> {
let actors = self let actors = self
.ap_service .ap_service
.get_blocked_actors(user.value()) .get_blocked_actors(user.value())
@@ -239,7 +251,7 @@ impl SocialQuery for CompositeSocialAdapter {
.map_err(ap_err)?; .map_err(ap_err)?;
Ok(actors Ok(actors
.into_iter() .into_iter()
.map(|a| self.identity_from_actor_url(&a.url)) .map(|a| self.remote_actor_to_social_actor(a))
.collect()) .collect())
} }
@@ -249,6 +261,24 @@ impl SocialQuery for CompositeSocialAdapter {
target: &SocialIdentity, target: &SocialIdentity,
) -> Result<bool, DomainError> { ) -> Result<bool, DomainError> {
let following = self.get_following(follower).await?; let following = self.get_following(follower).await?;
Ok(following.contains(target)) Ok(following.iter().any(|a| a.identity == *target))
}
async fn get_accepted_following_urls(
&self,
user_id: &UserId,
) -> Result<Vec<String>, DomainError> {
let actors = self
.ap_service
.get_following(user_id.value())
.await
.map_err(ap_err)?;
Ok(actors.into_iter().map(|a| a.url).collect())
}
async fn list_all_followed_remote_actors(
&self,
) -> Result<Vec<domain::models::RemoteActorInfo>, DomainError> {
Ok(vec![])
} }
} }

View File

@@ -2,7 +2,7 @@ use std::sync::Arc;
use domain::ports::{ use domain::ports::{
DiaryQuery, EventPublisher, MovieCommand, MovieProfileRepository, MovieQuery, ReviewRepository, DiaryQuery, EventPublisher, MovieCommand, MovieProfileRepository, MovieQuery, ReviewRepository,
SocialQueryPort, SocialQuery,
}; };
use crate::config::AppConfig; use crate::config::AppConfig;
@@ -27,6 +27,6 @@ pub struct GetMovieSocialPageDeps {
pub struct GetActivityFeedDeps { pub struct GetActivityFeedDeps {
pub diary: Arc<dyn DiaryQuery>, pub diary: Arc<dyn DiaryQuery>,
pub social_query: Arc<dyn SocialQueryPort>, pub social_query: Arc<dyn SocialQuery>,
pub config: AppConfig, pub config: AppConfig,
} }

View File

@@ -2,7 +2,8 @@ use std::sync::Arc;
use async_trait::async_trait; use async_trait::async_trait;
use domain::errors::DomainError; use domain::errors::DomainError;
use domain::testing::{FakeDiaryQuery, NoopSocialQueryPort}; use domain::testing::InMemorySocialRepository;
use domain::value_objects::SocialActor;
use crate::{ use crate::{
config::AppConfig, diary::deps::GetActivityFeedDeps, diary::get_activity_feed, config::AppConfig, diary::deps::GetActivityFeedDeps, diary::get_activity_feed,
@@ -11,8 +12,8 @@ use crate::{
fn default_deps() -> GetActivityFeedDeps { fn default_deps() -> GetActivityFeedDeps {
GetActivityFeedDeps { GetActivityFeedDeps {
diary: FakeDiaryQuery::new() as _, diary: domain::testing::FakeDiaryQuery::new() as _,
social_query: Arc::new(NoopSocialQueryPort), social_query: InMemorySocialRepository::new() as _,
config: TestContextBuilder::new().config, config: TestContextBuilder::new().config,
} }
} }
@@ -59,20 +60,30 @@ async fn returns_feed_with_following_filter() {
.await .await
.unwrap(); .unwrap();
// NoopSocialQueryPort returns empty following, so FollowingFilter
// contains only the viewer's id. Feed is empty but the code path is hit.
assert!(result.items.is_empty()); assert!(result.items.is_empty());
} }
struct FakeSocialWithFollowing(Vec<String>); struct FakeSocialWithFollowing(Vec<String>);
#[async_trait] #[async_trait]
impl domain::ports::SocialQueryPort for FakeSocialWithFollowing { impl domain::ports::SocialQuery for FakeSocialWithFollowing {
async fn get_accepted_following_urls( async fn get_following(
&self, &self,
_: &domain::value_objects::UserId, _: &domain::value_objects::UserId,
) -> Result<Vec<String>, DomainError> { ) -> Result<Vec<SocialActor>, DomainError> {
Ok(self.0.clone()) Ok(vec![])
}
async fn get_followers(
&self,
_: &domain::value_objects::UserId,
) -> Result<Vec<SocialActor>, DomainError> {
Ok(vec![])
}
async fn get_pending_followers(
&self,
_: &domain::value_objects::UserId,
) -> Result<Vec<SocialActor>, DomainError> {
Ok(vec![])
} }
async fn count_following( async fn count_following(
&self, &self,
@@ -80,18 +91,31 @@ impl domain::ports::SocialQueryPort for FakeSocialWithFollowing {
) -> Result<usize, DomainError> { ) -> Result<usize, DomainError> {
Ok(0) Ok(0)
} }
async fn count_accepted_followers( async fn count_followers(
&self, &self,
_: &domain::value_objects::UserId, _: &domain::value_objects::UserId,
) -> Result<usize, DomainError> { ) -> Result<usize, DomainError> {
Ok(0) Ok(0)
} }
async fn get_pending_followers( async fn get_blocked(
&self, &self,
_: &domain::value_objects::UserId, _: &domain::value_objects::UserId,
) -> Result<Vec<domain::models::PendingFollowerInfo>, DomainError> { ) -> Result<Vec<SocialActor>, DomainError> {
Ok(vec![]) Ok(vec![])
} }
async fn is_following(
&self,
_: &domain::value_objects::UserId,
_: &domain::value_objects::SocialIdentity,
) -> Result<bool, DomainError> {
Ok(false)
}
async fn get_accepted_following_urls(
&self,
_: &domain::value_objects::UserId,
) -> Result<Vec<String>, DomainError> {
Ok(self.0.clone())
}
async fn list_all_followed_remote_actors( async fn list_all_followed_remote_actors(
&self, &self,
) -> Result<Vec<domain::models::RemoteActorInfo>, DomainError> { ) -> Result<Vec<domain::models::RemoteActorInfo>, DomainError> {
@@ -112,7 +136,7 @@ async fn following_filter_parses_local_and_remote_urls() {
let social = Arc::new(FakeSocialWithFollowing(following_urls)); let social = Arc::new(FakeSocialWithFollowing(following_urls));
let deps = GetActivityFeedDeps { let deps = GetActivityFeedDeps {
diary: FakeDiaryQuery::new() as _, diary: domain::testing::FakeDiaryQuery::new() as _,
social_query: social as _, social_query: social as _,
config: AppConfig { config: AppConfig {
allow_registration: true, allow_registration: true,
@@ -141,8 +165,6 @@ async fn following_filter_parses_local_and_remote_urls() {
.await .await
.unwrap(); .unwrap();
// Feed is empty (no data seeded), but the build_following_filter code path
// with actual URL parsing ran without errors.
assert!(result.items.is_empty()); assert!(result.items.is_empty());
} }
@@ -164,6 +186,5 @@ async fn following_filter_without_viewer_returns_none() {
.await .await
.unwrap(); .unwrap();
// filter_following=true but viewer_user_id=None → build_following_filter returns None
assert!(result.items.is_empty()); assert!(result.items.is_empty());
} }

View File

@@ -1,11 +1,11 @@
use domain::{errors::DomainError, value_objects::{SocialIdentity, UserId}}; use domain::{errors::DomainError, value_objects::{SocialActor, UserId}};
use super::{deps::SocialQueryDeps, queries::GetBlockedQuery}; use super::{deps::SocialQueryDeps, queries::GetBlockedQuery};
pub async fn execute( pub async fn execute(
deps: &SocialQueryDeps, deps: &SocialQueryDeps,
query: GetBlockedQuery, query: GetBlockedQuery,
) -> Result<Vec<SocialIdentity>, DomainError> { ) -> Result<Vec<SocialActor>, DomainError> {
let user_id = UserId::from_uuid(query.user_id); let user_id = UserId::from_uuid(query.user_id);
deps.social_query.get_blocked(&user_id).await deps.social_query.get_blocked(&user_id).await
} }

View File

@@ -1,11 +1,11 @@
use domain::{errors::DomainError, value_objects::{SocialIdentity, UserId}}; use domain::{errors::DomainError, value_objects::{SocialActor, UserId}};
use super::{deps::SocialQueryDeps, queries::GetFollowersQuery}; use super::{deps::SocialQueryDeps, queries::GetFollowersQuery};
pub async fn execute( pub async fn execute(
deps: &SocialQueryDeps, deps: &SocialQueryDeps,
query: GetFollowersQuery, query: GetFollowersQuery,
) -> Result<Vec<SocialIdentity>, DomainError> { ) -> Result<Vec<SocialActor>, DomainError> {
let user_id = UserId::from_uuid(query.user_id); let user_id = UserId::from_uuid(query.user_id);
deps.social_query.get_followers(&user_id).await deps.social_query.get_followers(&user_id).await
} }

View File

@@ -1,11 +1,11 @@
use domain::{errors::DomainError, value_objects::{SocialIdentity, UserId}}; use domain::{errors::DomainError, value_objects::{SocialActor, UserId}};
use super::{deps::SocialQueryDeps, queries::GetFollowingQuery}; use super::{deps::SocialQueryDeps, queries::GetFollowingQuery};
pub async fn execute( pub async fn execute(
deps: &SocialQueryDeps, deps: &SocialQueryDeps,
query: GetFollowingQuery, query: GetFollowingQuery,
) -> Result<Vec<SocialIdentity>, DomainError> { ) -> Result<Vec<SocialActor>, DomainError> {
let user_id = UserId::from_uuid(query.user_id); let user_id = UserId::from_uuid(query.user_id);
deps.social_query.get_following(&user_id).await deps.social_query.get_following(&user_id).await
} }

View File

@@ -1,11 +1,11 @@
use domain::{errors::DomainError, value_objects::{SocialIdentity, UserId}}; use domain::{errors::DomainError, value_objects::{SocialActor, UserId}};
use super::{deps::SocialQueryDeps, queries::GetPendingFollowersQuery}; use super::{deps::SocialQueryDeps, queries::GetPendingFollowersQuery};
pub async fn execute( pub async fn execute(
deps: &SocialQueryDeps, deps: &SocialQueryDeps,
query: GetPendingFollowersQuery, query: GetPendingFollowersQuery,
) -> Result<Vec<SocialIdentity>, DomainError> { ) -> Result<Vec<SocialActor>, DomainError> {
let user_id = UserId::from_uuid(query.user_id); let user_id = UserId::from_uuid(query.user_id);
deps.social_query.get_pending_followers(&user_id).await deps.social_query.get_pending_followers(&user_id).await
} }

View File

@@ -1,13 +1,19 @@
use std::sync::Arc; use std::sync::Arc;
use domain::ports::{ use domain::ports::{
DiaryQuery, EventPublisher, ObjectStorage, SocialQueryPort, StatsRepository, UserRepository, DiaryQuery, EventPublisher, ObjectStorage, SocialQuery, SocialQueryPort, StatsRepository,
UserRepository,
}; };
pub struct GetProfileDeps { pub struct GetProfileDeps {
pub stats: Arc<dyn StatsRepository>, pub stats: Arc<dyn StatsRepository>,
pub diary: Arc<dyn DiaryQuery>, pub diary: Arc<dyn DiaryQuery>,
pub social_query: Arc<dyn SocialQueryPort>, pub social_query: Arc<dyn SocialQuery>,
}
pub struct GetUsersListDeps {
pub user: Arc<dyn UserRepository>,
pub social_query_legacy: Arc<dyn SocialQueryPort>,
} }
pub struct UpdateProfileDeps { pub struct UpdateProfileDeps {

View File

@@ -86,7 +86,7 @@ async fn load_social_counts(
.unwrap_or(0); .unwrap_or(0);
let followers = deps let followers = deps
.social_query .social_query
.count_accepted_followers(user_id) .count_followers(user_id)
.await .await
.unwrap_or(0); .unwrap_or(0);
if !is_own_profile { if !is_own_profile {
@@ -98,11 +98,19 @@ async fn load_social_counts(
.await .await
.unwrap_or_default() .unwrap_or_default()
.into_iter() .into_iter()
.map(|p| PendingFollowerView { .map(|p| {
url: p.url, let url = match &p.identity {
handle: p.handle, domain::value_objects::SocialIdentity::Remote { actor_url } => actor_url.clone(),
display_name: p.display_name, domain::value_objects::SocialIdentity::Local(uid) => {
avatar_url: p.avatar_url, format!("local:{}", uid.value())
}
};
PendingFollowerView {
url,
handle: p.handle,
display_name: p.display_name,
avatar_url: p.avatar_url,
}
}) })
.collect(); .collect();
(following, followers, pending) (following, followers, pending)

View File

@@ -1,10 +1,7 @@
use std::sync::Arc; use crate::users::{deps::GetUsersListDeps, queries::GetUsersQuery};
use crate::users::queries::GetUsersQuery;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::{RemoteActorInfo, UserSummary}, models::{RemoteActorInfo, UserSummary},
ports::{SocialQueryPort, UserRepository},
}; };
pub struct UsersListData { pub struct UsersListData {
@@ -13,13 +10,12 @@ pub struct UsersListData {
} }
pub async fn execute( pub async fn execute(
user: Arc<dyn UserRepository>, deps: &GetUsersListDeps,
social_query: Arc<dyn SocialQueryPort>,
_query: GetUsersQuery, _query: GetUsersQuery,
) -> Result<UsersListData, DomainError> { ) -> Result<UsersListData, DomainError> {
let (users_result, actors_result) = tokio::join!( let (users_result, actors_result) = tokio::join!(
user.list_with_stats(), deps.user.list_with_stats(),
social_query.list_all_followed_remote_actors() deps.social_query_legacy.list_all_followed_remote_actors()
); );
Ok(UsersListData { Ok(UsersListData {
@@ -27,7 +23,3 @@ pub async fn execute(
remote_actors: actors_result?, remote_actors: actors_result?,
}) })
} }
#[cfg(test)]
#[path = "tests/get_users.rs"]
mod tests;

View File

@@ -35,7 +35,7 @@ async fn returns_profile_with_empty_stats() {
let deps = GetProfileDeps { let deps = GetProfileDeps {
stats: b.stats_repo.clone(), stats: b.stats_repo.clone(),
diary: b.diary_repo.clone(), diary: b.diary_repo.clone(),
social_query: b.social_query.clone(), social_query: b.social_query_unified.clone(),
}; };
setup_user(&b, "profile@test.com", "profuser").await; setup_user(&b, "profile@test.com", "profuser").await;
@@ -70,7 +70,7 @@ async fn returns_history_view() {
let deps = GetProfileDeps { let deps = GetProfileDeps {
stats: b.stats_repo.clone(), stats: b.stats_repo.clone(),
diary: b.diary_repo.clone(), diary: b.diary_repo.clone(),
social_query: b.social_query.clone(), social_query: b.social_query_unified.clone(),
}; };
setup_user(&b, "hist@test.com", "histuser").await; setup_user(&b, "hist@test.com", "histuser").await;
@@ -107,7 +107,7 @@ async fn returns_trends_view() {
let deps = GetProfileDeps { let deps = GetProfileDeps {
stats: b.stats_repo.clone(), stats: b.stats_repo.clone(),
diary: b.diary_repo.clone(), diary: b.diary_repo.clone(),
social_query: b.social_query.clone(), social_query: b.social_query_unified.clone(),
}; };
setup_user(&b, "trends@test.com", "trendsuser").await; setup_user(&b, "trends@test.com", "trendsuser").await;
@@ -144,7 +144,7 @@ async fn returns_ratings_view() {
let deps = GetProfileDeps { let deps = GetProfileDeps {
stats: b.stats_repo.clone(), stats: b.stats_repo.clone(),
diary: b.diary_repo.clone(), diary: b.diary_repo.clone(),
social_query: b.social_query.clone(), social_query: b.social_query_unified.clone(),
}; };
setup_user(&b, "ratings@test.com", "ratingsuser").await; setup_user(&b, "ratings@test.com", "ratingsuser").await;
@@ -179,7 +179,7 @@ async fn returns_recent_with_search() {
let deps = GetProfileDeps { let deps = GetProfileDeps {
stats: b.stats_repo.clone(), stats: b.stats_repo.clone(),
diary: b.diary_repo.clone(), diary: b.diary_repo.clone(),
social_query: b.social_query.clone(), social_query: b.social_query_unified.clone(),
}; };
setup_user(&b, "search@test.com", "searchuser").await; setup_user(&b, "search@test.com", "searchuser").await;
@@ -214,7 +214,7 @@ async fn non_own_profile_skips_pending_followers() {
let deps = GetProfileDeps { let deps = GetProfileDeps {
stats: b.stats_repo.clone(), stats: b.stats_repo.clone(),
diary: b.diary_repo.clone(), diary: b.diary_repo.clone(),
social_query: b.social_query.clone(), social_query: b.social_query_unified.clone(),
}; };
setup_user(&b, "other@test.com", "otheruser").await; setup_user(&b, "other@test.com", "otheruser").await;

View File

@@ -2,7 +2,7 @@ use async_trait::async_trait;
use crate::{ use crate::{
errors::DomainError, errors::DomainError,
value_objects::{SocialIdentity, UserId}, value_objects::{SocialActor, SocialIdentity, UserId},
}; };
// ── NoopRemoteWatchlistRepository ───────────────────────────────────────────── // ── NoopRemoteWatchlistRepository ─────────────────────────────────────────────
@@ -70,13 +70,13 @@ pub struct NoopSocialQuery;
#[async_trait] #[async_trait]
impl super::SocialQuery for NoopSocialQuery { impl super::SocialQuery for NoopSocialQuery {
async fn get_following(&self, _: &UserId) -> Result<Vec<SocialIdentity>, DomainError> { async fn get_following(&self, _: &UserId) -> Result<Vec<SocialActor>, DomainError> {
Ok(vec![]) Ok(vec![])
} }
async fn get_followers(&self, _: &UserId) -> Result<Vec<SocialIdentity>, DomainError> { async fn get_followers(&self, _: &UserId) -> Result<Vec<SocialActor>, DomainError> {
Ok(vec![]) Ok(vec![])
} }
async fn get_pending_followers(&self, _: &UserId) -> Result<Vec<SocialIdentity>, DomainError> { async fn get_pending_followers(&self, _: &UserId) -> Result<Vec<SocialActor>, DomainError> {
Ok(vec![]) Ok(vec![])
} }
async fn count_following(&self, _: &UserId) -> Result<usize, DomainError> { async fn count_following(&self, _: &UserId) -> Result<usize, DomainError> {
@@ -85,12 +85,20 @@ impl super::SocialQuery for NoopSocialQuery {
async fn count_followers(&self, _: &UserId) -> Result<usize, DomainError> { async fn count_followers(&self, _: &UserId) -> Result<usize, DomainError> {
Ok(0) Ok(0)
} }
async fn get_blocked(&self, _: &UserId) -> Result<Vec<SocialIdentity>, DomainError> { async fn get_blocked(&self, _: &UserId) -> Result<Vec<SocialActor>, DomainError> {
Ok(vec![]) Ok(vec![])
} }
async fn is_following(&self, _: &UserId, _: &SocialIdentity) -> Result<bool, DomainError> { async fn is_following(&self, _: &UserId, _: &SocialIdentity) -> Result<bool, DomainError> {
Ok(false) Ok(false)
} }
async fn get_accepted_following_urls(&self, _: &UserId) -> Result<Vec<String>, DomainError> {
Ok(vec![])
}
async fn list_all_followed_remote_actors(
&self,
) -> Result<Vec<crate::models::RemoteActorInfo>, DomainError> {
Ok(vec![])
}
} }
// ── NoopSocialQueryPort ─────────────────────────────────────────────────────── // ── NoopSocialQueryPort ───────────────────────────────────────────────────────

View File

@@ -7,7 +7,7 @@ use crate::{
DiaryEntry, FederationFlags, PendingFollowerInfo, RemoteActorInfo, RemoteGoalEntry, DiaryEntry, FederationFlags, PendingFollowerInfo, RemoteActorInfo, RemoteGoalEntry,
RemoteWatchlistEntry, WatchlistWithMovie, RemoteWatchlistEntry, WatchlistWithMovie,
}, },
value_objects::{MovieId, SocialIdentity, UserId}, value_objects::{MovieId, SocialActor, SocialIdentity, UserId},
}; };
// ── Unified social ports (ADR-0002) ───────────────────────────────────────── // ── Unified social ports (ADR-0002) ─────────────────────────────────────────
@@ -62,17 +62,17 @@ pub trait SocialQuery: Send + Sync {
async fn get_following( async fn get_following(
&self, &self,
user: &UserId, user: &UserId,
) -> Result<Vec<SocialIdentity>, DomainError>; ) -> Result<Vec<SocialActor>, DomainError>;
async fn get_followers( async fn get_followers(
&self, &self,
user: &UserId, user: &UserId,
) -> Result<Vec<SocialIdentity>, DomainError>; ) -> Result<Vec<SocialActor>, DomainError>;
async fn get_pending_followers( async fn get_pending_followers(
&self, &self,
user: &UserId, user: &UserId,
) -> Result<Vec<SocialIdentity>, DomainError>; ) -> Result<Vec<SocialActor>, DomainError>;
async fn count_following(&self, user: &UserId) -> Result<usize, DomainError>; async fn count_following(&self, user: &UserId) -> Result<usize, DomainError>;
@@ -81,13 +81,22 @@ pub trait SocialQuery: Send + Sync {
async fn get_blocked( async fn get_blocked(
&self, &self,
user: &UserId, user: &UserId,
) -> Result<Vec<SocialIdentity>, DomainError>; ) -> Result<Vec<SocialActor>, DomainError>;
async fn is_following( async fn is_following(
&self, &self,
follower: &UserId, follower: &UserId,
target: &SocialIdentity, target: &SocialIdentity,
) -> Result<bool, DomainError>; ) -> Result<bool, DomainError>;
async fn get_accepted_following_urls(
&self,
user_id: &UserId,
) -> Result<Vec<String>, DomainError>;
async fn list_all_followed_remote_actors(
&self,
) -> Result<Vec<crate::models::RemoteActorInfo>, DomainError>;
} }
// ── Legacy ports (pre-unification, still used by AP adapter + handlers) ───── // ── Legacy ports (pre-unification, still used by AP adapter + handlers) ─────

View File

@@ -25,7 +25,8 @@ use crate::{
}, },
value_objects::{ value_objects::{
Email, ExternalMetadataId, GoalId, ImportProfileId, ImportSessionId, MovieId, MovieTitle, Email, ExternalMetadataId, GoalId, ImportProfileId, ImportSessionId, MovieId, MovieTitle,
ReleaseYear, ReviewId, SocialIdentity, UserId, Username, WatchEventId, WebhookTokenId, ReleaseYear, ReviewId, SocialActor, SocialIdentity, UserId, Username, WatchEventId,
WebhookTokenId,
}, },
}; };
@@ -875,6 +876,19 @@ impl InMemorySocialRepository {
blocked: Mutex::new(Vec::new()), blocked: Mutex::new(Vec::new()),
}) })
} }
fn identity_to_actor(identity: &SocialIdentity) -> SocialActor {
let handle = match identity {
SocialIdentity::Local(uid) => format!("user-{}", uid.value()),
SocialIdentity::Remote { actor_url } => actor_url.clone(),
};
SocialActor {
identity: identity.clone(),
handle,
display_name: None,
avatar_url: None,
}
}
} }
#[async_trait] #[async_trait]
@@ -1023,38 +1037,44 @@ impl SocialQuery for InMemorySocialRepository {
async fn get_following( async fn get_following(
&self, &self,
user: &UserId, user: &UserId,
) -> Result<Vec<SocialIdentity>, DomainError> { ) -> Result<Vec<SocialActor>, DomainError> {
let store = self.follows.lock().unwrap(); let store = self.follows.lock().unwrap();
Ok(store Ok(store
.iter() .iter()
.filter(|(f, _, state)| *f == user.value() && *state == FollowState::Accepted) .filter(|(f, _, state)| *f == user.value() && *state == FollowState::Accepted)
.map(|(_, t, _)| t.clone()) .map(|(_, t, _)| Self::identity_to_actor(t))
.collect()) .collect())
} }
async fn get_followers( async fn get_followers(
&self, &self,
user: &UserId, user: &UserId,
) -> Result<Vec<SocialIdentity>, DomainError> { ) -> Result<Vec<SocialActor>, DomainError> {
let store = self.follows.lock().unwrap(); let store = self.follows.lock().unwrap();
let target = SocialIdentity::Local(user.clone()); let target = SocialIdentity::Local(user.clone());
Ok(store Ok(store
.iter() .iter()
.filter(|(_, t, state)| *t == target && *state == FollowState::Accepted) .filter(|(_, t, state)| *t == target && *state == FollowState::Accepted)
.map(|(f, _, _)| SocialIdentity::Local(UserId::from_uuid(*f))) .map(|(f, _, _)| {
let id = SocialIdentity::Local(UserId::from_uuid(*f));
Self::identity_to_actor(&id)
})
.collect()) .collect())
} }
async fn get_pending_followers( async fn get_pending_followers(
&self, &self,
user: &UserId, user: &UserId,
) -> Result<Vec<SocialIdentity>, DomainError> { ) -> Result<Vec<SocialActor>, DomainError> {
let store = self.follows.lock().unwrap(); let store = self.follows.lock().unwrap();
let target = SocialIdentity::Local(user.clone()); let target = SocialIdentity::Local(user.clone());
Ok(store Ok(store
.iter() .iter()
.filter(|(_, t, state)| *t == target && *state == FollowState::Pending) .filter(|(_, t, state)| *t == target && *state == FollowState::Pending)
.map(|(f, _, _)| SocialIdentity::Local(UserId::from_uuid(*f))) .map(|(f, _, _)| {
let id = SocialIdentity::Local(UserId::from_uuid(*f));
Self::identity_to_actor(&id)
})
.collect()) .collect())
} }
@@ -1078,12 +1098,12 @@ impl SocialQuery for InMemorySocialRepository {
async fn get_blocked( async fn get_blocked(
&self, &self,
user: &UserId, user: &UserId,
) -> Result<Vec<SocialIdentity>, DomainError> { ) -> Result<Vec<SocialActor>, DomainError> {
let store = self.blocked.lock().unwrap(); let store = self.blocked.lock().unwrap();
Ok(store Ok(store
.iter() .iter()
.filter(|(b, _)| *b == user.value()) .filter(|(b, _)| *b == user.value())
.map(|(_, t)| t.clone()) .map(|(_, t)| Self::identity_to_actor(t))
.collect()) .collect())
} }
@@ -1097,4 +1117,17 @@ impl SocialQuery for InMemorySocialRepository {
.iter() .iter()
.any(|(f, t, state)| *f == follower.value() && t == target && *state == FollowState::Accepted)) .any(|(f, t, state)| *f == follower.value() && t == target && *state == FollowState::Accepted))
} }
async fn get_accepted_following_urls(
&self,
_user_id: &UserId,
) -> Result<Vec<String>, DomainError> {
Ok(vec![])
}
async fn list_all_followed_remote_actors(
&self,
) -> Result<Vec<crate::models::RemoteActorInfo>, DomainError> {
Ok(vec![])
}
} }

View File

@@ -15,3 +15,11 @@ impl SocialIdentity {
matches!(self, Self::Remote { .. }) matches!(self, Self::Remote { .. })
} }
} }
#[derive(Clone, Debug)]
pub struct SocialActor {
pub identity: SocialIdentity,
pub handle: String,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
}

View File

@@ -182,7 +182,7 @@ pub async fn get_activity_feed(
) -> Result<Json<ActivityFeedResponse>, ApiError> { ) -> Result<Json<ActivityFeedResponse>, ApiError> {
let deps = GetActivityFeedDeps { let deps = GetActivityFeedDeps {
diary: state.app_ctx.repos.diary.clone(), diary: state.app_ctx.repos.diary.clone(),
social_query: state.app_ctx.repos.social_query.clone(), social_query: state.app_ctx.repos.social_query_unified.clone(),
config: state.app_ctx.config.clone(), config: state.app_ctx.config.clone(),
}; };
let page = get_feed_uc::execute( let page = get_feed_uc::execute(
@@ -338,7 +338,7 @@ pub async fn get_activity_feed_html(
let deps = GetActivityFeedDeps { let deps = GetActivityFeedDeps {
diary: state.app_ctx.repos.diary.clone(), diary: state.app_ctx.repos.diary.clone(),
social_query: state.app_ctx.repos.social_query.clone(), social_query: state.app_ctx.repos.social_query_unified.clone(),
config: state.app_ctx.config.clone(), config: state.app_ctx.config.clone(),
}; };

View File

@@ -22,7 +22,7 @@ use api_types::{
BlockedDomainResponse, FollowRequest, RemoteActorDto, BlockedDomainResponse, FollowRequest, RemoteActorDto,
}; };
use application::social::deps::{SocialCommandDeps, SocialQueryDeps}; use application::social::deps::{SocialCommandDeps, SocialQueryDeps};
use domain::value_objects::SocialIdentity; use domain::value_objects::{SocialActor, SocialIdentity};
use template_askama::{ use template_askama::{
BlockedActorsTemplate, BlockedDomainsTemplate, FollowersTemplate, FollowingTemplate, BlockedActorsTemplate, BlockedDomainsTemplate, FollowersTemplate, FollowingTemplate,
RemoteActorData, RemoteActorData,
@@ -35,35 +35,36 @@ fn ap_to_domain(e: anyhow::Error) -> domain::errors::DomainError {
domain::errors::DomainError::InfrastructureError(e.to_string()) domain::errors::DomainError::InfrastructureError(e.to_string())
} }
fn social_identity_to_dto(id: SocialIdentity) -> RemoteActorDto { fn actor_url(identity: &SocialIdentity) -> String {
match id { match identity {
SocialIdentity::Remote { actor_url } => RemoteActorDto { SocialIdentity::Remote { actor_url } => actor_url.clone(),
url: actor_url, SocialIdentity::Local(uid) => format!("local:{}", uid.value()),
handle: String::new(),
display_name: None,
},
SocialIdentity::Local(uid) => RemoteActorDto {
url: format!("local:{}", uid.value()),
handle: String::new(),
display_name: None,
},
} }
} }
fn social_identity_to_blocked_dto(id: SocialIdentity) -> BlockedActorResponse { fn social_actor_to_dto(actor: SocialActor) -> RemoteActorDto {
match id { RemoteActorDto {
SocialIdentity::Remote { actor_url } => BlockedActorResponse { url: actor_url(&actor.identity),
url: actor_url, handle: actor.handle,
handle: String::new(), display_name: actor.display_name,
display_name: None, }
avatar_url: None, }
},
SocialIdentity::Local(uid) => BlockedActorResponse { fn social_actor_to_blocked_dto(actor: SocialActor) -> BlockedActorResponse {
url: format!("local:{}", uid.value()), BlockedActorResponse {
handle: String::new(), url: actor_url(&actor.identity),
display_name: None, handle: actor.handle,
avatar_url: None, display_name: actor.display_name,
}, avatar_url: actor.avatar_url,
}
}
fn social_actor_to_template(actor: SocialActor) -> RemoteActorData {
RemoteActorData {
url: actor_url(&actor.identity),
handle: actor.handle,
display_name: actor.display_name,
avatar_url: actor.avatar_url,
} }
} }
@@ -234,7 +235,7 @@ pub async fn get_blocked_actors_api(
Ok(Json( Ok(Json(
identities identities
.into_iter() .into_iter()
.map(social_identity_to_blocked_dto) .map(social_actor_to_blocked_dto)
.collect(), .collect(),
)) ))
} }
@@ -264,7 +265,7 @@ pub async fn get_following(
Ok(Json(ActorListResponse { Ok(Json(ActorListResponse {
actors: identities actors: identities
.into_iter() .into_iter()
.map(social_identity_to_dto) .map(social_actor_to_dto)
.collect(), .collect(),
})) }))
} }
@@ -294,7 +295,7 @@ pub async fn get_followers(
Ok(Json(ActorListResponse { Ok(Json(ActorListResponse {
actors: identities actors: identities
.into_iter() .into_iter()
.map(social_identity_to_dto) .map(social_actor_to_dto)
.collect(), .collect(),
})) }))
} }
@@ -315,7 +316,7 @@ pub async fn get_user_following(
Ok(Json(ActorListResponse { Ok(Json(ActorListResponse {
actors: identities actors: identities
.into_iter() .into_iter()
.map(social_identity_to_dto) .map(social_actor_to_dto)
.collect(), .collect(),
})) }))
} }
@@ -336,7 +337,7 @@ pub async fn get_user_followers(
Ok(Json(ActorListResponse { Ok(Json(ActorListResponse {
actors: identities actors: identities
.into_iter() .into_iter()
.map(social_identity_to_dto) .map(social_actor_to_dto)
.collect(), .collect(),
})) }))
} }
@@ -526,7 +527,7 @@ pub async fn get_pending_followers(
Ok(Json(ActorListResponse { Ok(Json(ActorListResponse {
actors: identities actors: identities
.into_iter() .into_iter()
.map(social_identity_to_dto) .map(social_actor_to_dto)
.collect(), .collect(),
})) }))
} }
@@ -770,16 +771,19 @@ pub async fn get_following_page(
"{}/users/{}/following-list", "{}/users/{}/following-list",
state.app_ctx.config.base_url, profile_user_uuid state.app_ctx.config.base_url, profile_user_uuid
); );
match state.app_ctx.services.ap_service.get_following(user_id.value()).await { let deps = SocialQueryDeps {
social_query: state.app_ctx.repos.social_query_unified.clone(),
};
match application::social::get_following::execute(
&deps,
application::social::queries::GetFollowingQuery { user_id: user_id.value() },
)
.await
{
Ok(following) => { Ok(following) => {
let actors: Vec<RemoteActorData> = following let actors: Vec<RemoteActorData> = following
.into_iter() .into_iter()
.map(|a| RemoteActorData { .map(social_actor_to_template)
handle: a.handle,
display_name: a.display_name,
url: a.url,
avatar_url: a.avatar_url.clone(),
})
.collect(); .collect();
render_page(FollowingTemplate { render_page(FollowingTemplate {
ctx, ctx,
@@ -816,20 +820,19 @@ pub async fn get_followers_page(
"{}/users/{}/followers-list", "{}/users/{}/followers-list",
state.app_ctx.config.base_url, profile_user_uuid state.app_ctx.config.base_url, profile_user_uuid
); );
match state let deps = SocialQueryDeps {
.app_ctx.services.ap_service social_query: state.app_ctx.repos.social_query_unified.clone(),
.get_accepted_followers(user_id.value()) };
.await match application::social::get_followers::execute(
&deps,
application::social::queries::GetFollowersQuery { user_id: user_id.value() },
)
.await
{ {
Ok(followers) => { Ok(followers) => {
let actors: Vec<RemoteActorData> = followers let actors: Vec<RemoteActorData> = followers
.into_iter() .into_iter()
.map(|a| RemoteActorData { .map(social_actor_to_template)
handle: a.handle,
display_name: a.display_name,
url: a.url,
avatar_url: a.avatar_url.clone(),
})
.collect(); .collect();
render_page(FollowersTemplate { render_page(FollowersTemplate {
ctx, ctx,
@@ -975,12 +978,20 @@ pub async fn get_blocked_actors_page(
let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await; let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await;
ctx.page_title = "Blocked Users — Movies Diary".to_string(); ctx.page_title = "Blocked Users — Movies Diary".to_string();
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.app_ctx.services.ap_service.get_blocked_actors(user_id.value()).await { let deps = SocialQueryDeps {
Ok(actors) => { social_query: state.app_ctx.repos.social_query_unified.clone(),
let entries: Vec<template_askama::BlockedActorEntry> = actors };
match application::social::get_blocked::execute(
&deps,
application::social::queries::GetBlockedQuery { user_id: user_id.value() },
)
.await
{
Ok(blocked) => {
let entries: Vec<template_askama::BlockedActorEntry> = blocked
.into_iter() .into_iter()
.map(|a| template_askama::BlockedActorEntry { .map(|a| template_askama::BlockedActorEntry {
url: a.url, url: actor_url(&a.identity),
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,

View File

@@ -176,12 +176,11 @@ pub async fn update_profile_fields_handler(
responses((status = 200, body = UsersResponse)), responses((status = 200, body = UsersResponse)),
)] )]
pub async fn list_users(State(state): State<AppState>) -> Result<Json<UsersResponse>, ApiError> { pub async fn list_users(State(state): State<AppState>) -> Result<Json<UsersResponse>, ApiError> {
let result = get_users::execute( let deps = application::users::deps::GetUsersListDeps {
state.app_ctx.repos.user.clone(), user: state.app_ctx.repos.user.clone(),
state.app_ctx.repos.social_query.clone(), social_query_legacy: state.app_ctx.repos.social_query.clone(),
GetUsersQuery, };
) let result = get_users::execute(&deps, GetUsersQuery).await?;
.await?;
Ok(Json(UsersResponse { Ok(Json(UsersResponse {
users: result users: result
.users .users
@@ -248,7 +247,7 @@ pub async fn get_user_profile(
let get_profile_deps = GetProfileDeps { let get_profile_deps = GetProfileDeps {
stats: state.app_ctx.repos.stats.clone(), stats: state.app_ctx.repos.stats.clone(),
diary: state.app_ctx.repos.diary.clone(), diary: state.app_ctx.repos.diary.clone(),
social_query: state.app_ctx.repos.social_query.clone(), social_query: state.app_ctx.repos.social_query_unified.clone(),
}; };
let profile = match get_user_profile_uc::execute( let profile = match get_user_profile_uc::execute(
&get_profile_deps, &get_profile_deps,
@@ -381,7 +380,7 @@ async fn build_federated_profile_response(
let get_profile_deps = GetProfileDeps { let get_profile_deps = GetProfileDeps {
stats: state.app_ctx.repos.stats.clone(), stats: state.app_ctx.repos.stats.clone(),
diary: state.app_ctx.repos.diary.clone(), diary: state.app_ctx.repos.diary.clone(),
social_query: state.app_ctx.repos.social_query.clone(), social_query: state.app_ctx.repos.social_query_unified.clone(),
}; };
let profile = match get_user_profile_uc::execute( let profile = match get_user_profile_uc::execute(
&get_profile_deps, &get_profile_deps,
@@ -485,9 +484,12 @@ 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);
let users_deps = application::users::deps::GetUsersListDeps {
user: state.app_ctx.repos.user.clone(),
social_query_legacy: state.app_ctx.repos.social_query.clone(),
};
match application::users::get_users::execute( match application::users::get_users::execute(
state.app_ctx.repos.user.clone(), &users_deps,
state.app_ctx.repos.social_query.clone(),
application::users::queries::GetUsersQuery, application::users::queries::GetUsersQuery,
) )
.await .await
@@ -731,7 +733,7 @@ pub async fn get_user_profile_html(
let html_profile_deps = GetProfileDeps { let html_profile_deps = GetProfileDeps {
stats: state.app_ctx.repos.stats.clone(), stats: state.app_ctx.repos.stats.clone(),
diary: state.app_ctx.repos.diary.clone(), diary: state.app_ctx.repos.diary.clone(),
social_query: state.app_ctx.repos.social_query.clone(), social_query: state.app_ctx.repos.social_query_unified.clone(),
}; };
match application::users::get_profile::execute(&html_profile_deps, query).await { match application::users::get_profile::execute(&html_profile_deps, query).await {
Ok(profile) => { Ok(profile) => {