From 322e9ee81a72f8ee01c5e1035e23e763406e1732 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Fri, 10 Jul 2026 15:29:46 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20unified=20social=20identity=20layer=20?= =?UTF-8?q?=E2=80=94=20SocialIdentity,=20ports,=20use=20cases,=20adapter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SocialIdentity value object (Local|Remote), SocialCommand/SocialQuery domain ports, 11 CQRS use cases w/ 14 tests, CompositeSocialAdapter wrapping k_ap, 6 new domain events, handlers migrated from ap_service to use cases. Closes #12 foundation — AP handlers + legacy cleanup TBD. --- crates/adapters/activitypub/src/lib.rs | 2 + .../activitypub/src/social_adapter.rs | 254 +++++++++++ crates/adapters/event-payload/src/lib.rs | 166 ++++++- crates/adapters/nats/src/subject.rs | 5 + crates/application/src/lib.rs | 1 + crates/application/src/social/accept.rs | 23 + crates/application/src/social/block.rs | 18 + crates/application/src/social/commands.rs | 37 ++ crates/application/src/social/deps.rs | 13 + crates/application/src/social/follow.rs | 18 + crates/application/src/social/get_blocked.rs | 11 + .../application/src/social/get_followers.rs | 15 + .../application/src/social/get_following.rs | 15 + crates/application/src/social/get_pending.rs | 15 + crates/application/src/social/mod.rs | 15 + crates/application/src/social/queries.rs | 17 + crates/application/src/social/reject.rs | 17 + .../application/src/social/remove_follower.rs | 23 + crates/application/src/social/tests/accept.rs | 59 +++ crates/application/src/social/tests/block.rs | 41 ++ crates/application/src/social/tests/follow.rs | 86 ++++ .../src/social/tests/get_followers.rs | 62 +++ .../src/social/tests/get_following.rs | 74 ++++ .../src/social/tests/get_pending.rs | 51 +++ crates/application/src/social/tests/reject.rs | 51 +++ .../src/social/tests/remove_follower.rs | 68 +++ .../application/src/social/tests/unblock.rs | 57 +++ .../application/src/social/tests/unfollow.rs | 57 +++ crates/application/src/social/unblock.rs | 18 + crates/application/src/social/unfollow.rs | 20 + crates/application/src/test_helpers.rs | 8 +- crates/application/src/tests/worker.rs | 5 + crates/domain/src/events.rs | 25 +- crates/domain/src/ports/noop.rs | 63 ++- crates/domain/src/ports/social.rs | 89 +++- crates/domain/src/testing/in_memory.rs | 252 ++++++++++- crates/domain/src/tests/events.rs | 35 +- crates/domain/src/value_objects/mod.rs | 2 + crates/domain/src/value_objects/social.rs | 17 + crates/presentation/src/context.rs | 8 +- crates/presentation/src/handlers/social.rs | 408 +++++++++++++----- crates/presentation/src/main.rs | 18 +- crates/presentation/src/tests/extractors.rs | 2 + crates/presentation/tests/api_test.rs | 2 + crates/worker/src/follow_backfill_handler.rs | 12 +- 45 files changed, 2095 insertions(+), 160 deletions(-) create mode 100644 crates/adapters/activitypub/src/social_adapter.rs create mode 100644 crates/application/src/social/accept.rs create mode 100644 crates/application/src/social/block.rs create mode 100644 crates/application/src/social/commands.rs create mode 100644 crates/application/src/social/deps.rs create mode 100644 crates/application/src/social/follow.rs create mode 100644 crates/application/src/social/get_blocked.rs create mode 100644 crates/application/src/social/get_followers.rs create mode 100644 crates/application/src/social/get_following.rs create mode 100644 crates/application/src/social/get_pending.rs create mode 100644 crates/application/src/social/mod.rs create mode 100644 crates/application/src/social/queries.rs create mode 100644 crates/application/src/social/reject.rs create mode 100644 crates/application/src/social/remove_follower.rs create mode 100644 crates/application/src/social/tests/accept.rs create mode 100644 crates/application/src/social/tests/block.rs create mode 100644 crates/application/src/social/tests/follow.rs create mode 100644 crates/application/src/social/tests/get_followers.rs create mode 100644 crates/application/src/social/tests/get_following.rs create mode 100644 crates/application/src/social/tests/get_pending.rs create mode 100644 crates/application/src/social/tests/reject.rs create mode 100644 crates/application/src/social/tests/remove_follower.rs create mode 100644 crates/application/src/social/tests/unblock.rs create mode 100644 crates/application/src/social/tests/unfollow.rs create mode 100644 crates/application/src/social/unblock.rs create mode 100644 crates/application/src/social/unfollow.rs create mode 100644 crates/domain/src/value_objects/social.rs diff --git a/crates/adapters/activitypub/src/lib.rs b/crates/adapters/activitypub/src/lib.rs index 8c9ea3d..64ae304 100644 --- a/crates/adapters/activitypub/src/lib.rs +++ b/crates/adapters/activitypub/src/lib.rs @@ -1,4 +1,5 @@ pub mod composite_handler; +pub mod social_adapter; pub mod event_handler; pub mod federation_event_bridge; pub mod goal_handler; @@ -24,6 +25,7 @@ pub use event_handler::ActivityPubEventHandler; pub use port::{ActivityPubPort, NoopActivityPubService}; pub use remote_review_repository::{RemoteReviewRepository, RemoteReviewUpdate}; pub use review_handler::ReviewObjectHandler; +pub use social_adapter::CompositeSocialAdapter; pub use user_adapter::DomainUserRepoAdapter; pub type FederationRepos = ( diff --git a/crates/adapters/activitypub/src/social_adapter.rs b/crates/adapters/activitypub/src/social_adapter.rs new file mode 100644 index 0000000..92cf4ad --- /dev/null +++ b/crates/adapters/activitypub/src/social_adapter.rs @@ -0,0 +1,254 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use domain::{ + errors::DomainError, + ports::{SocialCommand, SocialQuery, UserRepository}, + value_objects::{SocialIdentity, UserId}, +}; + +use super::ActivityPubPort; + +pub struct CompositeSocialAdapter { + ap_service: Arc, + user_repo: Arc, + base_url: String, +} + +impl CompositeSocialAdapter { + pub fn new( + ap_service: Arc, + user_repo: Arc, + base_url: String, + ) -> Self { + Self { + ap_service, + user_repo, + base_url, + } + } + + fn local_actor_url(&self, user_id: &UserId) -> String { + format!("{}/users/{}", self.base_url, user_id.value()) + } + + fn actor_url_from_identity(&self, identity: &SocialIdentity) -> String { + match identity { + SocialIdentity::Local(uid) => self.local_actor_url(uid), + SocialIdentity::Remote { actor_url } => actor_url.clone(), + } + } + + fn identity_from_actor_url(&self, url: &str) -> SocialIdentity { + let prefix = format!("{}/users/", self.base_url); + if let Some(uuid_str) = url.strip_prefix(&prefix) + && let Ok(uuid) = uuid::Uuid::parse_str(uuid_str) + { + return SocialIdentity::Local(UserId::from_uuid(uuid)); + } + SocialIdentity::Remote { + actor_url: url.to_string(), + } + } + + async fn resolve_handle(&self, identity: &SocialIdentity) -> Result { + match identity { + SocialIdentity::Local(uid) => { + let user = self + .user_repo + .find_by_id(uid) + .await? + .ok_or_else(|| DomainError::NotFound("User not found".into()))?; + let host = url::Url::parse(&self.base_url) + .map(|u| u.host_str().unwrap_or("localhost").to_string()) + .unwrap_or_else(|_| "localhost".to_string()); + Ok(format!("@{}@{}", user.username().value(), host)) + } + SocialIdentity::Remote { actor_url } => Ok(actor_url.clone()), + } + } +} + +fn ap_err(e: anyhow::Error) -> DomainError { + DomainError::InfrastructureError(e.to_string()) +} + +#[async_trait] +impl SocialCommand for CompositeSocialAdapter { + async fn follow( + &self, + follower: &UserId, + target: &SocialIdentity, + ) -> Result<(), DomainError> { + if let SocialIdentity::Local(target_id) = target + && follower == target_id + { + return Err(DomainError::ValidationError( + "Cannot follow yourself".into(), + )); + } + let handle = self.resolve_handle(target).await?; + self.ap_service + .follow(follower.value(), &handle) + .await + .map_err(ap_err) + } + + async fn unfollow( + &self, + follower: &UserId, + target: &SocialIdentity, + ) -> Result<(), DomainError> { + let actor_url = self.actor_url_from_identity(target); + self.ap_service + .unfollow(follower.value(), &actor_url) + .await + .map_err(ap_err) + } + + async fn accept_follow( + &self, + owner: &UserId, + requester: &SocialIdentity, + ) -> Result<(), DomainError> { + let actor_url = self.actor_url_from_identity(requester); + self.ap_service + .accept_follower(owner.value(), &actor_url) + .await + .map_err(ap_err) + } + + async fn reject_follow( + &self, + owner: &UserId, + requester: &SocialIdentity, + ) -> Result<(), DomainError> { + let actor_url = self.actor_url_from_identity(requester); + self.ap_service + .reject_follower(owner.value(), &actor_url) + .await + .map_err(ap_err) + } + + async fn remove_follower( + &self, + owner: &UserId, + follower: &SocialIdentity, + ) -> Result<(), DomainError> { + let actor_url = self.actor_url_from_identity(follower); + self.ap_service + .remove_follower(owner.value(), &actor_url) + .await + .map_err(ap_err) + } + + async fn block( + &self, + blocker: &UserId, + target: &SocialIdentity, + ) -> Result<(), DomainError> { + let actor_url = self.actor_url_from_identity(target); + self.ap_service + .block_actor(blocker.value(), &actor_url) + .await + .map_err(ap_err) + } + + async fn unblock( + &self, + blocker: &UserId, + target: &SocialIdentity, + ) -> Result<(), DomainError> { + let actor_url = self.actor_url_from_identity(target); + self.ap_service + .unblock_actor(blocker.value(), &actor_url) + .await + .map_err(ap_err) + } +} + +#[async_trait] +impl SocialQuery for CompositeSocialAdapter { + async fn get_following( + &self, + user: &UserId, + ) -> Result, DomainError> { + let actors = self + .ap_service + .get_following(user.value()) + .await + .map_err(ap_err)?; + Ok(actors + .into_iter() + .map(|a| self.identity_from_actor_url(&a.url)) + .collect()) + } + + async fn get_followers( + &self, + user: &UserId, + ) -> Result, DomainError> { + let actors = self + .ap_service + .get_accepted_followers(user.value()) + .await + .map_err(ap_err)?; + Ok(actors + .into_iter() + .map(|a| self.identity_from_actor_url(&a.url)) + .collect()) + } + + async fn get_pending_followers( + &self, + user: &UserId, + ) -> Result, DomainError> { + let actors = self + .ap_service + .get_pending_followers(user.value()) + .await + .map_err(ap_err)?; + Ok(actors + .into_iter() + .map(|a| self.identity_from_actor_url(&a.url)) + .collect()) + } + + async fn count_following(&self, user: &UserId) -> Result { + self.ap_service + .count_following(user.value()) + .await + .map_err(ap_err) + } + + async fn count_followers(&self, user: &UserId) -> Result { + self.ap_service + .count_accepted_followers(user.value()) + .await + .map_err(ap_err) + } + + async fn get_blocked( + &self, + user: &UserId, + ) -> Result, DomainError> { + let actors = self + .ap_service + .get_blocked_actors(user.value()) + .await + .map_err(ap_err)?; + Ok(actors + .into_iter() + .map(|a| self.identity_from_actor_url(&a.url)) + .collect()) + } + + async fn is_following( + &self, + follower: &UserId, + target: &SocialIdentity, + ) -> Result { + let following = self.get_following(follower).await?; + Ok(following.contains(target)) + } +} diff --git a/crates/adapters/event-payload/src/lib.rs b/crates/adapters/event-payload/src/lib.rs index a985739..b1fd707 100644 --- a/crates/adapters/event-payload/src/lib.rs +++ b/crates/adapters/event-payload/src/lib.rs @@ -4,7 +4,8 @@ use domain::{ events::DomainEvent, models::{ExternalPersonId, PersonId}, value_objects::{ - ExternalMetadataId, GoalId, MovieId, PosterPath, Rating, ReviewId, UserId, WrapUpId, + ExternalMetadataId, GoalId, MovieId, PosterPath, Rating, ReviewId, SocialIdentity, UserId, + WrapUpId, }, }; use serde::{Deserialize, Serialize}; @@ -61,10 +62,35 @@ pub enum EventPayload { user_id: String, movie_id: String, }, + FollowRequested { + follower_id: String, + target_kind: String, + target_id: String, + }, FollowAccepted { - local_user_id: String, - remote_actor_url: String, - outbox_url: String, + owner_id: String, + requester_kind: String, + requester_id: String, + }, + Unfollowed { + follower_id: String, + target_kind: String, + target_id: String, + }, + FollowerRemoved { + owner_id: String, + follower_kind: String, + follower_id: String, + }, + ActorBlocked { + blocker_id: String, + target_kind: String, + target_id: String, + }, + ActorUnblocked { + blocker_id: String, + target_kind: String, + target_id: String, }, BackfillFollower { owner_user_id: String, @@ -136,7 +162,12 @@ impl EventPayload { EventPayload::ImageStored { .. } => "ImageStored", EventPayload::WatchlistEntryAdded { .. } => "WatchlistEntryAdded", EventPayload::WatchlistEntryRemoved { .. } => "WatchlistEntryRemoved", + EventPayload::FollowRequested { .. } => "FollowRequested", EventPayload::FollowAccepted { .. } => "FollowAccepted", + EventPayload::Unfollowed { .. } => "Unfollowed", + EventPayload::FollowerRemoved { .. } => "FollowerRemoved", + EventPayload::ActorBlocked { .. } => "ActorBlocked", + EventPayload::ActorUnblocked { .. } => "ActorUnblocked", EventPayload::BackfillFollower { .. } => "BackfillFollower", EventPayload::FederationDeliveryRequested { .. } => "FederationDeliveryRequested", EventPayload::WatchEventIngested { .. } => "WatchEventIngested", @@ -158,6 +189,25 @@ fn parse_uuid(s: &str, field: &str) -> Result { Uuid::parse_str(s).map_err(|e| DomainError::InfrastructureError(format!("{field}: {e}"))) } +fn identity_to_payload(id: &SocialIdentity) -> (String, String) { + match id { + SocialIdentity::Local(uid) => ("local".into(), uid.value().to_string()), + SocialIdentity::Remote { actor_url } => ("remote".into(), actor_url.clone()), + } +} + +fn payload_to_identity(kind: &str, id: String) -> Result { + match kind { + "local" => Ok(SocialIdentity::Local(UserId::from_uuid(parse_uuid( + &id, "user_id", + )?))), + "remote" => Ok(SocialIdentity::Remote { actor_url: id }), + other => Err(DomainError::InfrastructureError(format!( + "unknown identity kind: {other}" + ))), + } +} + fn parse_ts(ts: i64) -> Result { chrono::DateTime::from_timestamp(ts, 0) .map(|dt| dt.naive_utc()) @@ -243,15 +293,54 @@ impl From<&DomainEvent> for EventPayload { movie_id: movie_id.value().to_string(), } } - DomainEvent::FollowAccepted { - local_user_id, - remote_actor_url, - outbox_url, - } => EventPayload::FollowAccepted { - local_user_id: local_user_id.value().to_string(), - remote_actor_url: remote_actor_url.clone(), - outbox_url: outbox_url.clone(), - }, + DomainEvent::FollowRequested { follower, target } => { + let (kind, id) = identity_to_payload(target); + EventPayload::FollowRequested { + follower_id: follower.value().to_string(), + target_kind: kind, + target_id: id, + } + } + DomainEvent::FollowAccepted { owner, requester } => { + let (kind, id) = identity_to_payload(requester); + EventPayload::FollowAccepted { + owner_id: owner.value().to_string(), + requester_kind: kind, + requester_id: id, + } + } + DomainEvent::Unfollowed { follower, target } => { + let (kind, id) = identity_to_payload(target); + EventPayload::Unfollowed { + follower_id: follower.value().to_string(), + target_kind: kind, + target_id: id, + } + } + DomainEvent::FollowerRemoved { owner, follower } => { + let (kind, id) = identity_to_payload(follower); + EventPayload::FollowerRemoved { + owner_id: owner.value().to_string(), + follower_kind: kind, + follower_id: id, + } + } + DomainEvent::ActorBlocked { blocker, target } => { + let (kind, id) = identity_to_payload(target); + EventPayload::ActorBlocked { + blocker_id: blocker.value().to_string(), + target_kind: kind, + target_id: id, + } + } + DomainEvent::ActorUnblocked { blocker, target } => { + let (kind, id) = identity_to_payload(target); + EventPayload::ActorUnblocked { + blocker_id: blocker.value().to_string(), + target_kind: kind, + target_id: id, + } + } DomainEvent::BackfillFollower { owner_user_id, follower_inbox_url, @@ -435,14 +524,53 @@ impl TryFrom for DomainEvent { movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?), }) } + EventPayload::FollowRequested { + follower_id, + target_kind, + target_id, + } => Ok(DomainEvent::FollowRequested { + follower: UserId::from_uuid(parse_uuid(&follower_id, "follower_id")?), + target: payload_to_identity(&target_kind, target_id)?, + }), EventPayload::FollowAccepted { - local_user_id, - remote_actor_url, - outbox_url, + owner_id, + requester_kind, + requester_id, } => Ok(DomainEvent::FollowAccepted { - local_user_id: UserId::from_uuid(parse_uuid(&local_user_id, "local_user_id")?), - remote_actor_url, - outbox_url, + owner: UserId::from_uuid(parse_uuid(&owner_id, "owner_id")?), + requester: payload_to_identity(&requester_kind, requester_id)?, + }), + EventPayload::Unfollowed { + follower_id, + target_kind, + target_id, + } => Ok(DomainEvent::Unfollowed { + follower: UserId::from_uuid(parse_uuid(&follower_id, "follower_id")?), + target: payload_to_identity(&target_kind, target_id)?, + }), + EventPayload::FollowerRemoved { + owner_id, + follower_kind, + follower_id, + } => Ok(DomainEvent::FollowerRemoved { + owner: UserId::from_uuid(parse_uuid(&owner_id, "owner_id")?), + follower: payload_to_identity(&follower_kind, follower_id)?, + }), + EventPayload::ActorBlocked { + blocker_id, + target_kind, + target_id, + } => Ok(DomainEvent::ActorBlocked { + blocker: UserId::from_uuid(parse_uuid(&blocker_id, "blocker_id")?), + target: payload_to_identity(&target_kind, target_id)?, + }), + EventPayload::ActorUnblocked { + blocker_id, + target_kind, + target_id, + } => Ok(DomainEvent::ActorUnblocked { + blocker: UserId::from_uuid(parse_uuid(&blocker_id, "blocker_id")?), + target: payload_to_identity(&target_kind, target_id)?, }), EventPayload::BackfillFollower { owner_user_id, diff --git a/crates/adapters/nats/src/subject.rs b/crates/adapters/nats/src/subject.rs index f23ff96..f6a1ee9 100644 --- a/crates/adapters/nats/src/subject.rs +++ b/crates/adapters/nats/src/subject.rs @@ -12,7 +12,12 @@ pub fn event_to_subject(prefix: &str, event: &DomainEvent) -> String { DomainEvent::ImageStored { .. } => "image.stored", DomainEvent::WatchlistEntryAdded { .. } => "watchlist.entry.added", DomainEvent::WatchlistEntryRemoved { .. } => "watchlist.entry.removed", + DomainEvent::FollowRequested { .. } => "follow.requested", DomainEvent::FollowAccepted { .. } => "follow.accepted", + DomainEvent::Unfollowed { .. } => "follow.unfollowed", + DomainEvent::FollowerRemoved { .. } => "follower.removed", + DomainEvent::ActorBlocked { .. } => "actor.blocked", + DomainEvent::ActorUnblocked { .. } => "actor.unblocked", DomainEvent::BackfillFollower { .. } => "backfill.follower", DomainEvent::FederationDeliveryRequested { .. } => "federation.delivery.requested", DomainEvent::WatchEventIngested { .. } => "watch.event.ingested", diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index aff5419..a58966b 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -11,6 +11,7 @@ pub mod integrations; pub mod movies; pub mod person; pub mod search; +pub mod social; pub mod users; pub mod watchlist; pub mod wrapup; diff --git a/crates/application/src/social/accept.rs b/crates/application/src/social/accept.rs new file mode 100644 index 0000000..261c38f --- /dev/null +++ b/crates/application/src/social/accept.rs @@ -0,0 +1,23 @@ +use domain::{errors::DomainError, events::DomainEvent, value_objects::UserId}; + +use super::{commands::AcceptFollowCommand, deps::SocialCommandDeps}; + +pub async fn execute( + deps: &SocialCommandDeps, + cmd: AcceptFollowCommand, +) -> Result<(), DomainError> { + let owner = UserId::from_uuid(cmd.owner_id); + deps.social_command + .accept_follow(&owner, &cmd.requester) + .await?; + deps.event_publisher + .publish(&DomainEvent::FollowAccepted { + owner, + requester: cmd.requester, + }) + .await +} + +#[cfg(test)] +#[path = "tests/accept.rs"] +mod tests; diff --git a/crates/application/src/social/block.rs b/crates/application/src/social/block.rs new file mode 100644 index 0000000..543877a --- /dev/null +++ b/crates/application/src/social/block.rs @@ -0,0 +1,18 @@ +use domain::{errors::DomainError, events::DomainEvent, value_objects::UserId}; + +use super::{commands::BlockCommand, deps::SocialCommandDeps}; + +pub async fn execute(deps: &SocialCommandDeps, cmd: BlockCommand) -> Result<(), DomainError> { + let blocker = UserId::from_uuid(cmd.blocker_id); + deps.social_command.block(&blocker, &cmd.target).await?; + deps.event_publisher + .publish(&DomainEvent::ActorBlocked { + blocker, + target: cmd.target, + }) + .await +} + +#[cfg(test)] +#[path = "tests/block.rs"] +mod tests; diff --git a/crates/application/src/social/commands.rs b/crates/application/src/social/commands.rs new file mode 100644 index 0000000..7c9ca74 --- /dev/null +++ b/crates/application/src/social/commands.rs @@ -0,0 +1,37 @@ +use domain::value_objects::SocialIdentity; +use uuid::Uuid; + +pub struct FollowCommand { + pub follower_id: Uuid, + pub target: SocialIdentity, +} + +pub struct UnfollowCommand { + pub follower_id: Uuid, + pub target: SocialIdentity, +} + +pub struct AcceptFollowCommand { + pub owner_id: Uuid, + pub requester: SocialIdentity, +} + +pub struct RejectFollowCommand { + pub owner_id: Uuid, + pub requester: SocialIdentity, +} + +pub struct RemoveFollowerCommand { + pub owner_id: Uuid, + pub follower: SocialIdentity, +} + +pub struct BlockCommand { + pub blocker_id: Uuid, + pub target: SocialIdentity, +} + +pub struct UnblockCommand { + pub blocker_id: Uuid, + pub target: SocialIdentity, +} diff --git a/crates/application/src/social/deps.rs b/crates/application/src/social/deps.rs new file mode 100644 index 0000000..fdda782 --- /dev/null +++ b/crates/application/src/social/deps.rs @@ -0,0 +1,13 @@ +use std::sync::Arc; + +use domain::ports::{EventPublisher, SocialCommand, SocialQuery}; + +pub struct SocialCommandDeps { + pub social_command: Arc, + pub social_query: Arc, + pub event_publisher: Arc, +} + +pub struct SocialQueryDeps { + pub social_query: Arc, +} diff --git a/crates/application/src/social/follow.rs b/crates/application/src/social/follow.rs new file mode 100644 index 0000000..6e2f29c --- /dev/null +++ b/crates/application/src/social/follow.rs @@ -0,0 +1,18 @@ +use domain::{errors::DomainError, events::DomainEvent, value_objects::UserId}; + +use super::{commands::FollowCommand, deps::SocialCommandDeps}; + +pub async fn execute(deps: &SocialCommandDeps, cmd: FollowCommand) -> Result<(), DomainError> { + let follower = UserId::from_uuid(cmd.follower_id); + deps.social_command.follow(&follower, &cmd.target).await?; + deps.event_publisher + .publish(&DomainEvent::FollowRequested { + follower, + target: cmd.target, + }) + .await +} + +#[cfg(test)] +#[path = "tests/follow.rs"] +mod tests; diff --git a/crates/application/src/social/get_blocked.rs b/crates/application/src/social/get_blocked.rs new file mode 100644 index 0000000..ac7eee3 --- /dev/null +++ b/crates/application/src/social/get_blocked.rs @@ -0,0 +1,11 @@ +use domain::{errors::DomainError, value_objects::{SocialIdentity, UserId}}; + +use super::{deps::SocialQueryDeps, queries::GetBlockedQuery}; + +pub async fn execute( + deps: &SocialQueryDeps, + query: GetBlockedQuery, +) -> Result, DomainError> { + let user_id = UserId::from_uuid(query.user_id); + deps.social_query.get_blocked(&user_id).await +} diff --git a/crates/application/src/social/get_followers.rs b/crates/application/src/social/get_followers.rs new file mode 100644 index 0000000..9b619b6 --- /dev/null +++ b/crates/application/src/social/get_followers.rs @@ -0,0 +1,15 @@ +use domain::{errors::DomainError, value_objects::{SocialIdentity, UserId}}; + +use super::{deps::SocialQueryDeps, queries::GetFollowersQuery}; + +pub async fn execute( + deps: &SocialQueryDeps, + query: GetFollowersQuery, +) -> Result, DomainError> { + let user_id = UserId::from_uuid(query.user_id); + deps.social_query.get_followers(&user_id).await +} + +#[cfg(test)] +#[path = "tests/get_followers.rs"] +mod tests; diff --git a/crates/application/src/social/get_following.rs b/crates/application/src/social/get_following.rs new file mode 100644 index 0000000..51be132 --- /dev/null +++ b/crates/application/src/social/get_following.rs @@ -0,0 +1,15 @@ +use domain::{errors::DomainError, value_objects::{SocialIdentity, UserId}}; + +use super::{deps::SocialQueryDeps, queries::GetFollowingQuery}; + +pub async fn execute( + deps: &SocialQueryDeps, + query: GetFollowingQuery, +) -> Result, DomainError> { + let user_id = UserId::from_uuid(query.user_id); + deps.social_query.get_following(&user_id).await +} + +#[cfg(test)] +#[path = "tests/get_following.rs"] +mod tests; diff --git a/crates/application/src/social/get_pending.rs b/crates/application/src/social/get_pending.rs new file mode 100644 index 0000000..a375f40 --- /dev/null +++ b/crates/application/src/social/get_pending.rs @@ -0,0 +1,15 @@ +use domain::{errors::DomainError, value_objects::{SocialIdentity, UserId}}; + +use super::{deps::SocialQueryDeps, queries::GetPendingFollowersQuery}; + +pub async fn execute( + deps: &SocialQueryDeps, + query: GetPendingFollowersQuery, +) -> Result, DomainError> { + let user_id = UserId::from_uuid(query.user_id); + deps.social_query.get_pending_followers(&user_id).await +} + +#[cfg(test)] +#[path = "tests/get_pending.rs"] +mod tests; diff --git a/crates/application/src/social/mod.rs b/crates/application/src/social/mod.rs new file mode 100644 index 0000000..c2cf320 --- /dev/null +++ b/crates/application/src/social/mod.rs @@ -0,0 +1,15 @@ +pub mod commands; +pub mod deps; +pub mod queries; + +pub mod accept; +pub mod block; +pub mod follow; +pub mod get_blocked; +pub mod get_followers; +pub mod get_following; +pub mod get_pending; +pub mod reject; +pub mod remove_follower; +pub mod unblock; +pub mod unfollow; diff --git a/crates/application/src/social/queries.rs b/crates/application/src/social/queries.rs new file mode 100644 index 0000000..ec140dd --- /dev/null +++ b/crates/application/src/social/queries.rs @@ -0,0 +1,17 @@ +use uuid::Uuid; + +pub struct GetFollowingQuery { + pub user_id: Uuid, +} + +pub struct GetFollowersQuery { + pub user_id: Uuid, +} + +pub struct GetPendingFollowersQuery { + pub user_id: Uuid, +} + +pub struct GetBlockedQuery { + pub user_id: Uuid, +} diff --git a/crates/application/src/social/reject.rs b/crates/application/src/social/reject.rs new file mode 100644 index 0000000..ffeabea --- /dev/null +++ b/crates/application/src/social/reject.rs @@ -0,0 +1,17 @@ +use domain::{errors::DomainError, value_objects::UserId}; + +use super::{commands::RejectFollowCommand, deps::SocialCommandDeps}; + +pub async fn execute( + deps: &SocialCommandDeps, + cmd: RejectFollowCommand, +) -> Result<(), DomainError> { + let owner = UserId::from_uuid(cmd.owner_id); + deps.social_command + .reject_follow(&owner, &cmd.requester) + .await +} + +#[cfg(test)] +#[path = "tests/reject.rs"] +mod tests; diff --git a/crates/application/src/social/remove_follower.rs b/crates/application/src/social/remove_follower.rs new file mode 100644 index 0000000..69d0299 --- /dev/null +++ b/crates/application/src/social/remove_follower.rs @@ -0,0 +1,23 @@ +use domain::{errors::DomainError, events::DomainEvent, value_objects::UserId}; + +use super::{commands::RemoveFollowerCommand, deps::SocialCommandDeps}; + +pub async fn execute( + deps: &SocialCommandDeps, + cmd: RemoveFollowerCommand, +) -> Result<(), DomainError> { + let owner = UserId::from_uuid(cmd.owner_id); + deps.social_command + .remove_follower(&owner, &cmd.follower) + .await?; + deps.event_publisher + .publish(&DomainEvent::FollowerRemoved { + owner, + follower: cmd.follower, + }) + .await +} + +#[cfg(test)] +#[path = "tests/remove_follower.rs"] +mod tests; diff --git a/crates/application/src/social/tests/accept.rs b/crates/application/src/social/tests/accept.rs new file mode 100644 index 0000000..9383409 --- /dev/null +++ b/crates/application/src/social/tests/accept.rs @@ -0,0 +1,59 @@ +use std::sync::Arc; + +use domain::{ + events::DomainEvent, + testing::{InMemorySocialRepository, NoopEventPublisher}, + value_objects::{SocialIdentity, UserId}, +}; +use uuid::Uuid; + +use crate::social::{ + accept, + commands::{AcceptFollowCommand, FollowCommand}, + deps::SocialCommandDeps, + follow, +}; + +fn make_deps() -> (Arc, Arc, SocialCommandDeps) { + let social = InMemorySocialRepository::new(); + let events = NoopEventPublisher::new(); + let deps = SocialCommandDeps { + social_command: Arc::clone(&social) as _, + social_query: Arc::clone(&social) as _, + event_publisher: Arc::clone(&events) as _, + }; + (social, events, deps) +} + +#[tokio::test] +async fn accept_follow_emits_follow_accepted_event() { + let (_social, events, deps) = make_deps(); + let follower_id = Uuid::new_v4(); + let owner_id = Uuid::new_v4(); + let requester = SocialIdentity::Local(UserId::from_uuid(follower_id)); + + follow::execute( + &deps, + FollowCommand { + follower_id, + target: SocialIdentity::Local(UserId::from_uuid(owner_id)), + }, + ) + .await + .unwrap(); + + accept::execute( + &deps, + AcceptFollowCommand { + owner_id, + requester, + }, + ) + .await + .unwrap(); + + let published = events.published(); + assert!(published + .iter() + .any(|e| matches!(e, DomainEvent::FollowAccepted { .. }))); +} diff --git a/crates/application/src/social/tests/block.rs b/crates/application/src/social/tests/block.rs new file mode 100644 index 0000000..5ac6590 --- /dev/null +++ b/crates/application/src/social/tests/block.rs @@ -0,0 +1,41 @@ +use std::sync::Arc; + +use domain::{ + events::DomainEvent, + testing::{InMemorySocialRepository, NoopEventPublisher}, + value_objects::{SocialIdentity, UserId}, +}; +use uuid::Uuid; + +use crate::social::{block, commands::BlockCommand, deps::SocialCommandDeps}; + +fn make_deps() -> (Arc, Arc, SocialCommandDeps) { + let social = InMemorySocialRepository::new(); + let events = NoopEventPublisher::new(); + let deps = SocialCommandDeps { + social_command: Arc::clone(&social) as _, + social_query: Arc::clone(&social) as _, + event_publisher: Arc::clone(&events) as _, + }; + (social, events, deps) +} + +#[tokio::test] +async fn block_emits_actor_blocked_event() { + let (_social, events, deps) = make_deps(); + + block::execute( + &deps, + BlockCommand { + blocker_id: Uuid::new_v4(), + target: SocialIdentity::Local(UserId::from_uuid(Uuid::new_v4())), + }, + ) + .await + .unwrap(); + + let published = events.published(); + assert!(published + .iter() + .any(|e| matches!(e, DomainEvent::ActorBlocked { .. }))); +} diff --git a/crates/application/src/social/tests/follow.rs b/crates/application/src/social/tests/follow.rs new file mode 100644 index 0000000..91e30b4 --- /dev/null +++ b/crates/application/src/social/tests/follow.rs @@ -0,0 +1,86 @@ +use std::sync::Arc; + +use domain::{ + events::DomainEvent, + testing::{InMemorySocialRepository, NoopEventPublisher}, + value_objects::{SocialIdentity, UserId}, +}; +use uuid::Uuid; + +use crate::social::{commands::FollowCommand, deps::SocialCommandDeps, follow}; + +fn make_deps() -> (Arc, Arc, SocialCommandDeps) { + let social = InMemorySocialRepository::new(); + let events = NoopEventPublisher::new(); + let deps = SocialCommandDeps { + social_command: Arc::clone(&social) as _, + social_query: Arc::clone(&social) as _, + event_publisher: Arc::clone(&events) as _, + }; + (social, events, deps) +} + +#[tokio::test] +async fn follow_emits_follow_requested_event() { + let (_social, events, deps) = make_deps(); + + follow::execute( + &deps, + FollowCommand { + follower_id: Uuid::new_v4(), + target: SocialIdentity::Local(UserId::from_uuid(Uuid::new_v4())), + }, + ) + .await + .unwrap(); + + let published = events.published(); + assert!(published + .iter() + .any(|e| matches!(e, DomainEvent::FollowRequested { .. }))); +} + +#[tokio::test] +async fn cannot_follow_yourself() { + let (_social, _events, deps) = make_deps(); + let user_id = Uuid::new_v4(); + + let result = follow::execute( + &deps, + FollowCommand { + follower_id: user_id, + target: SocialIdentity::Local(UserId::from_uuid(user_id)), + }, + ) + .await; + + assert!(result.is_err()); +} + +#[tokio::test] +async fn cannot_follow_same_target_twice() { + let (_social, _events, deps) = make_deps(); + let follower_id = Uuid::new_v4(); + let target = SocialIdentity::Local(UserId::from_uuid(Uuid::new_v4())); + + follow::execute( + &deps, + FollowCommand { + follower_id, + target: target.clone(), + }, + ) + .await + .unwrap(); + + let result = follow::execute( + &deps, + FollowCommand { + follower_id, + target, + }, + ) + .await; + + assert!(result.is_err()); +} diff --git a/crates/application/src/social/tests/get_followers.rs b/crates/application/src/social/tests/get_followers.rs new file mode 100644 index 0000000..de6fa08 --- /dev/null +++ b/crates/application/src/social/tests/get_followers.rs @@ -0,0 +1,62 @@ +use std::sync::Arc; + +use domain::{ + testing::{InMemorySocialRepository, NoopEventPublisher}, + value_objects::{SocialIdentity, UserId}, +}; +use uuid::Uuid; + +use crate::social::{ + accept, + commands::{AcceptFollowCommand, FollowCommand}, + deps::{SocialCommandDeps, SocialQueryDeps}, + follow, get_followers, + queries::GetFollowersQuery, +}; + +#[tokio::test] +async fn returns_accepted_followers() { + let social = InMemorySocialRepository::new(); + let events = NoopEventPublisher::new(); + let cmd_deps = SocialCommandDeps { + social_command: Arc::clone(&social) as _, + social_query: Arc::clone(&social) as _, + event_publisher: Arc::clone(&events) as _, + }; + let query_deps = SocialQueryDeps { + social_query: Arc::clone(&social) as _, + }; + + let follower_id = Uuid::new_v4(); + let owner_id = Uuid::new_v4(); + + follow::execute( + &cmd_deps, + FollowCommand { + follower_id, + target: SocialIdentity::Local(UserId::from_uuid(owner_id)), + }, + ) + .await + .unwrap(); + + accept::execute( + &cmd_deps, + AcceptFollowCommand { + owner_id, + requester: SocialIdentity::Local(UserId::from_uuid(follower_id)), + }, + ) + .await + .unwrap(); + + let followers = get_followers::execute( + &query_deps, + GetFollowersQuery { + user_id: owner_id, + }, + ) + .await + .unwrap(); + assert_eq!(followers.len(), 1); +} diff --git a/crates/application/src/social/tests/get_following.rs b/crates/application/src/social/tests/get_following.rs new file mode 100644 index 0000000..673cb80 --- /dev/null +++ b/crates/application/src/social/tests/get_following.rs @@ -0,0 +1,74 @@ +use std::sync::Arc; + +use domain::{ + testing::{InMemorySocialRepository, NoopEventPublisher}, + value_objects::{SocialIdentity, UserId}, +}; +use uuid::Uuid; + +use crate::social::{ + accept, + commands::{AcceptFollowCommand, FollowCommand}, + deps::{SocialCommandDeps, SocialQueryDeps}, + follow, get_following, + queries::GetFollowingQuery, +}; + +#[tokio::test] +async fn returns_accepted_follows() { + let social = InMemorySocialRepository::new(); + let events = NoopEventPublisher::new(); + let cmd_deps = SocialCommandDeps { + social_command: Arc::clone(&social) as _, + social_query: Arc::clone(&social) as _, + event_publisher: Arc::clone(&events) as _, + }; + let query_deps = SocialQueryDeps { + social_query: Arc::clone(&social) as _, + }; + + let follower_id = Uuid::new_v4(); + let target_id = Uuid::new_v4(); + + follow::execute( + &cmd_deps, + FollowCommand { + follower_id, + target: SocialIdentity::Local(UserId::from_uuid(target_id)), + }, + ) + .await + .unwrap(); + + // Pending follow should not appear + let following = get_following::execute( + &query_deps, + GetFollowingQuery { + user_id: follower_id, + }, + ) + .await + .unwrap(); + assert!(following.is_empty()); + + // Accept, then it should appear + accept::execute( + &cmd_deps, + AcceptFollowCommand { + owner_id: target_id, + requester: SocialIdentity::Local(UserId::from_uuid(follower_id)), + }, + ) + .await + .unwrap(); + + let following = get_following::execute( + &query_deps, + GetFollowingQuery { + user_id: follower_id, + }, + ) + .await + .unwrap(); + assert_eq!(following.len(), 1); +} diff --git a/crates/application/src/social/tests/get_pending.rs b/crates/application/src/social/tests/get_pending.rs new file mode 100644 index 0000000..91a8877 --- /dev/null +++ b/crates/application/src/social/tests/get_pending.rs @@ -0,0 +1,51 @@ +use std::sync::Arc; + +use domain::{ + testing::{InMemorySocialRepository, NoopEventPublisher}, + value_objects::{SocialIdentity, UserId}, +}; +use uuid::Uuid; + +use crate::social::{ + commands::FollowCommand, + deps::{SocialCommandDeps, SocialQueryDeps}, + follow, get_pending, + queries::GetPendingFollowersQuery, +}; + +#[tokio::test] +async fn returns_only_pending_followers() { + let social = InMemorySocialRepository::new(); + let events = NoopEventPublisher::new(); + let cmd_deps = SocialCommandDeps { + social_command: Arc::clone(&social) as _, + social_query: Arc::clone(&social) as _, + event_publisher: Arc::clone(&events) as _, + }; + let query_deps = SocialQueryDeps { + social_query: Arc::clone(&social) as _, + }; + + let follower_id = Uuid::new_v4(); + let owner_id = Uuid::new_v4(); + + follow::execute( + &cmd_deps, + FollowCommand { + follower_id, + target: SocialIdentity::Local(UserId::from_uuid(owner_id)), + }, + ) + .await + .unwrap(); + + let pending = get_pending::execute( + &query_deps, + GetPendingFollowersQuery { + user_id: owner_id, + }, + ) + .await + .unwrap(); + assert_eq!(pending.len(), 1); +} diff --git a/crates/application/src/social/tests/reject.rs b/crates/application/src/social/tests/reject.rs new file mode 100644 index 0000000..26fe3eb --- /dev/null +++ b/crates/application/src/social/tests/reject.rs @@ -0,0 +1,51 @@ +use std::sync::Arc; + +use domain::{ + testing::{InMemorySocialRepository, NoopEventPublisher}, + value_objects::{SocialIdentity, UserId}, +}; +use uuid::Uuid; + +use crate::social::{ + commands::{FollowCommand, RejectFollowCommand}, + deps::SocialCommandDeps, + follow, reject, +}; + +fn make_deps() -> (Arc, Arc, SocialCommandDeps) { + let social = InMemorySocialRepository::new(); + let events = NoopEventPublisher::new(); + let deps = SocialCommandDeps { + social_command: Arc::clone(&social) as _, + social_query: Arc::clone(&social) as _, + event_publisher: Arc::clone(&events) as _, + }; + (social, events, deps) +} + +#[tokio::test] +async fn reject_follow_completes_without_error() { + let (_social, _events, deps) = make_deps(); + let follower_id = Uuid::new_v4(); + let owner_id = Uuid::new_v4(); + + follow::execute( + &deps, + FollowCommand { + follower_id, + target: SocialIdentity::Local(UserId::from_uuid(owner_id)), + }, + ) + .await + .unwrap(); + + reject::execute( + &deps, + RejectFollowCommand { + owner_id, + requester: SocialIdentity::Local(UserId::from_uuid(follower_id)), + }, + ) + .await + .unwrap(); +} diff --git a/crates/application/src/social/tests/remove_follower.rs b/crates/application/src/social/tests/remove_follower.rs new file mode 100644 index 0000000..594df68 --- /dev/null +++ b/crates/application/src/social/tests/remove_follower.rs @@ -0,0 +1,68 @@ +use std::sync::Arc; + +use domain::{ + events::DomainEvent, + testing::{InMemorySocialRepository, NoopEventPublisher}, + value_objects::{SocialIdentity, UserId}, +}; +use uuid::Uuid; + +use crate::social::{ + accept, + commands::{AcceptFollowCommand, FollowCommand, RemoveFollowerCommand}, + deps::SocialCommandDeps, + follow, remove_follower, +}; + +fn make_deps() -> (Arc, Arc, SocialCommandDeps) { + let social = InMemorySocialRepository::new(); + let events = NoopEventPublisher::new(); + let deps = SocialCommandDeps { + social_command: Arc::clone(&social) as _, + social_query: Arc::clone(&social) as _, + event_publisher: Arc::clone(&events) as _, + }; + (social, events, deps) +} + +#[tokio::test] +async fn remove_follower_emits_follower_removed_event() { + let (_social, events, deps) = make_deps(); + let follower_id = Uuid::new_v4(); + let owner_id = Uuid::new_v4(); + + follow::execute( + &deps, + FollowCommand { + follower_id, + target: SocialIdentity::Local(UserId::from_uuid(owner_id)), + }, + ) + .await + .unwrap(); + + accept::execute( + &deps, + AcceptFollowCommand { + owner_id, + requester: SocialIdentity::Local(UserId::from_uuid(follower_id)), + }, + ) + .await + .unwrap(); + + remove_follower::execute( + &deps, + RemoveFollowerCommand { + owner_id, + follower: SocialIdentity::Local(UserId::from_uuid(follower_id)), + }, + ) + .await + .unwrap(); + + let published = events.published(); + assert!(published + .iter() + .any(|e| matches!(e, DomainEvent::FollowerRemoved { .. }))); +} diff --git a/crates/application/src/social/tests/unblock.rs b/crates/application/src/social/tests/unblock.rs new file mode 100644 index 0000000..5a8f942 --- /dev/null +++ b/crates/application/src/social/tests/unblock.rs @@ -0,0 +1,57 @@ +use std::sync::Arc; + +use domain::{ + events::DomainEvent, + testing::{InMemorySocialRepository, NoopEventPublisher}, + value_objects::{SocialIdentity, UserId}, +}; +use uuid::Uuid; + +use crate::social::{ + commands::{BlockCommand, UnblockCommand}, + deps::SocialCommandDeps, + block, unblock, +}; + +fn make_deps() -> (Arc, Arc, SocialCommandDeps) { + let social = InMemorySocialRepository::new(); + let events = NoopEventPublisher::new(); + let deps = SocialCommandDeps { + social_command: Arc::clone(&social) as _, + social_query: Arc::clone(&social) as _, + event_publisher: Arc::clone(&events) as _, + }; + (social, events, deps) +} + +#[tokio::test] +async fn unblock_emits_actor_unblocked_event() { + let (_social, events, deps) = make_deps(); + let target = SocialIdentity::Local(UserId::from_uuid(Uuid::new_v4())); + let blocker_id = Uuid::new_v4(); + + block::execute( + &deps, + BlockCommand { + blocker_id, + target: target.clone(), + }, + ) + .await + .unwrap(); + + unblock::execute( + &deps, + UnblockCommand { + blocker_id, + target, + }, + ) + .await + .unwrap(); + + let published = events.published(); + assert!(published + .iter() + .any(|e| matches!(e, DomainEvent::ActorUnblocked { .. }))); +} diff --git a/crates/application/src/social/tests/unfollow.rs b/crates/application/src/social/tests/unfollow.rs new file mode 100644 index 0000000..c9f2a39 --- /dev/null +++ b/crates/application/src/social/tests/unfollow.rs @@ -0,0 +1,57 @@ +use std::sync::Arc; + +use domain::{ + events::DomainEvent, + testing::{InMemorySocialRepository, NoopEventPublisher}, + value_objects::{SocialIdentity, UserId}, +}; +use uuid::Uuid; + +use crate::social::{ + commands::{FollowCommand, UnfollowCommand}, + deps::SocialCommandDeps, + follow, unfollow, +}; + +fn make_deps() -> (Arc, Arc, SocialCommandDeps) { + let social = InMemorySocialRepository::new(); + let events = NoopEventPublisher::new(); + let deps = SocialCommandDeps { + social_command: Arc::clone(&social) as _, + social_query: Arc::clone(&social) as _, + event_publisher: Arc::clone(&events) as _, + }; + (social, events, deps) +} + +#[tokio::test] +async fn unfollow_emits_unfollowed_event() { + let (_social, events, deps) = make_deps(); + let follower_id = Uuid::new_v4(); + let target = SocialIdentity::Local(UserId::from_uuid(Uuid::new_v4())); + + follow::execute( + &deps, + FollowCommand { + follower_id, + target: target.clone(), + }, + ) + .await + .unwrap(); + + unfollow::execute( + &deps, + UnfollowCommand { + follower_id, + target, + }, + ) + .await + .unwrap(); + + let published = events.published(); + assert!(published + .iter() + .any(|e| matches!(e, DomainEvent::Unfollowed { .. }))); +} diff --git a/crates/application/src/social/unblock.rs b/crates/application/src/social/unblock.rs new file mode 100644 index 0000000..3a08e03 --- /dev/null +++ b/crates/application/src/social/unblock.rs @@ -0,0 +1,18 @@ +use domain::{errors::DomainError, events::DomainEvent, value_objects::UserId}; + +use super::{commands::UnblockCommand, deps::SocialCommandDeps}; + +pub async fn execute(deps: &SocialCommandDeps, cmd: UnblockCommand) -> Result<(), DomainError> { + let blocker = UserId::from_uuid(cmd.blocker_id); + deps.social_command.unblock(&blocker, &cmd.target).await?; + deps.event_publisher + .publish(&DomainEvent::ActorUnblocked { + blocker, + target: cmd.target, + }) + .await +} + +#[cfg(test)] +#[path = "tests/unblock.rs"] +mod tests; diff --git a/crates/application/src/social/unfollow.rs b/crates/application/src/social/unfollow.rs new file mode 100644 index 0000000..76ae9c2 --- /dev/null +++ b/crates/application/src/social/unfollow.rs @@ -0,0 +1,20 @@ +use domain::{errors::DomainError, events::DomainEvent, value_objects::UserId}; + +use super::{commands::UnfollowCommand, deps::SocialCommandDeps}; + +pub async fn execute(deps: &SocialCommandDeps, cmd: UnfollowCommand) -> Result<(), DomainError> { + let follower = UserId::from_uuid(cmd.follower_id); + deps.social_command + .unfollow(&follower, &cmd.target) + .await?; + deps.event_publisher + .publish(&DomainEvent::Unfollowed { + follower, + target: cmd.target, + }) + .await +} + +#[cfg(test)] +#[path = "tests/unfollow.rs"] +mod tests; diff --git a/crates/application/src/test_helpers.rs b/crates/application/src/test_helpers.rs index f5d1b3d..863d1fd 100644 --- a/crates/application/src/test_helpers.rs +++ b/crates/application/src/test_helpers.rs @@ -1,7 +1,8 @@ use std::sync::Arc; use domain::testing::{ - InMemoryGoalRepository, InMemoryWrapUpRepository, InMemoryWrapUpStatsQuery, NoopSocialQueryPort, + InMemoryGoalRepository, InMemorySocialRepository, InMemoryWrapUpRepository, + InMemoryWrapUpStatsQuery, NoopSocialQueryPort, }; use domain::{ ports::{ @@ -72,6 +73,8 @@ pub struct TestContextBuilder { pub goal_query: Arc, pub user_settings_repo: Arc, pub review_logger: Arc, + pub social_command: Arc, + pub social_query_unified: Arc, pub social_query: Arc, pub refresh_session_repo: Arc, pub config: AppConfig, @@ -88,6 +91,7 @@ impl TestContextBuilder { let movies = InMemoryMovieRepository::new(); let watch_events = InMemoryWatchEventRepository::new(); let goals = InMemoryGoalRepository::new(); + let social = InMemorySocialRepository::new(); Self { movie_command: Arc::clone(&movies) as _, movie_query: movies as _, @@ -121,6 +125,8 @@ impl TestContextBuilder { goal_query: goals as _, user_settings_repo: InMemoryUserSettingsRepository::new(), review_logger: Arc::new(NoopReviewLogger), + social_command: Arc::clone(&social) as _, + social_query_unified: Arc::clone(&social) as _, social_query: Arc::new(NoopSocialQueryPort), refresh_session_repo: InMemoryRefreshSessionRepository::new(), config: AppConfig { diff --git a/crates/application/src/tests/worker.rs b/crates/application/src/tests/worker.rs index ad71889..2a170c3 100644 --- a/crates/application/src/tests/worker.rs +++ b/crates/application/src/tests/worker.rs @@ -72,7 +72,12 @@ impl EventHandler for RecordingHandler { DomainEvent::WatchlistEntryAdded { .. } | DomainEvent::WatchlistEntryRemoved { .. } => { "watchlist" } + DomainEvent::FollowRequested { .. } => "follow_requested", DomainEvent::FollowAccepted { .. } => "follow_accepted", + DomainEvent::Unfollowed { .. } => "unfollowed", + DomainEvent::FollowerRemoved { .. } => "follower_removed", + DomainEvent::ActorBlocked { .. } => "actor_blocked", + DomainEvent::ActorUnblocked { .. } => "actor_unblocked", DomainEvent::BackfillFollower { .. } => "backfill_follower", DomainEvent::FederationDeliveryRequested { .. } => "federation_delivery", DomainEvent::WatchEventIngested { .. } => "watch_event_ingested", diff --git a/crates/domain/src/events.rs b/crates/domain/src/events.rs index 057370b..103f764 100644 --- a/crates/domain/src/events.rs +++ b/crates/domain/src/events.rs @@ -63,10 +63,29 @@ pub enum DomainEvent { user_id: UserId, movie_id: MovieId, }, + FollowRequested { + follower: UserId, + target: crate::value_objects::SocialIdentity, + }, FollowAccepted { - local_user_id: UserId, - remote_actor_url: String, - outbox_url: String, + owner: UserId, + requester: crate::value_objects::SocialIdentity, + }, + Unfollowed { + follower: UserId, + target: crate::value_objects::SocialIdentity, + }, + FollowerRemoved { + owner: UserId, + follower: crate::value_objects::SocialIdentity, + }, + ActorBlocked { + blocker: UserId, + target: crate::value_objects::SocialIdentity, + }, + ActorUnblocked { + blocker: UserId, + target: crate::value_objects::SocialIdentity, }, BackfillFollower { owner_user_id: UserId, diff --git a/crates/domain/src/ports/noop.rs b/crates/domain/src/ports/noop.rs index 498e144..270dbbf 100644 --- a/crates/domain/src/ports/noop.rs +++ b/crates/domain/src/ports/noop.rs @@ -1,6 +1,9 @@ use async_trait::async_trait; -use crate::{errors::DomainError, value_objects::UserId}; +use crate::{ + errors::DomainError, + value_objects::{SocialIdentity, UserId}, +}; // ── NoopRemoteWatchlistRepository ───────────────────────────────────────────── @@ -32,6 +35,64 @@ impl super::RemoteWatchlistRepository for NoopRemoteWatchlistRepository { } } +// ── NoopSocialCommand ──────────────────────────────────────────────────────── + +pub struct NoopSocialCommand; + +#[async_trait] +impl super::SocialCommand for NoopSocialCommand { + async fn follow(&self, _: &UserId, _: &SocialIdentity) -> Result<(), DomainError> { + Ok(()) + } + async fn unfollow(&self, _: &UserId, _: &SocialIdentity) -> Result<(), DomainError> { + Ok(()) + } + async fn accept_follow(&self, _: &UserId, _: &SocialIdentity) -> Result<(), DomainError> { + Ok(()) + } + async fn reject_follow(&self, _: &UserId, _: &SocialIdentity) -> Result<(), DomainError> { + Ok(()) + } + async fn remove_follower(&self, _: &UserId, _: &SocialIdentity) -> Result<(), DomainError> { + Ok(()) + } + async fn block(&self, _: &UserId, _: &SocialIdentity) -> Result<(), DomainError> { + Ok(()) + } + async fn unblock(&self, _: &UserId, _: &SocialIdentity) -> Result<(), DomainError> { + Ok(()) + } +} + +// ── NoopSocialQuery ───────────────────────────────────────────────────────── + +pub struct NoopSocialQuery; + +#[async_trait] +impl super::SocialQuery for NoopSocialQuery { + async fn get_following(&self, _: &UserId) -> Result, DomainError> { + Ok(vec![]) + } + async fn get_followers(&self, _: &UserId) -> Result, DomainError> { + Ok(vec![]) + } + async fn get_pending_followers(&self, _: &UserId) -> Result, DomainError> { + Ok(vec![]) + } + async fn count_following(&self, _: &UserId) -> Result { + Ok(0) + } + async fn count_followers(&self, _: &UserId) -> Result { + Ok(0) + } + async fn get_blocked(&self, _: &UserId) -> Result, DomainError> { + Ok(vec![]) + } + async fn is_following(&self, _: &UserId, _: &SocialIdentity) -> Result { + Ok(false) + } +} + // ── NoopSocialQueryPort ─────────────────────────────────────────────────────── /// Stub used when federation is disabled — returns empty results. diff --git a/crates/domain/src/ports/social.rs b/crates/domain/src/ports/social.rs index e92c2ff..482949f 100644 --- a/crates/domain/src/ports/social.rs +++ b/crates/domain/src/ports/social.rs @@ -7,9 +7,91 @@ use crate::{ DiaryEntry, FederationFlags, PendingFollowerInfo, RemoteActorInfo, RemoteGoalEntry, RemoteWatchlistEntry, WatchlistWithMovie, }, - value_objects::{MovieId, UserId}, + value_objects::{MovieId, SocialIdentity, UserId}, }; +// ── Unified social ports (ADR-0002) ───────────────────────────────────────── + +#[async_trait] +pub trait SocialCommand: Send + Sync { + async fn follow( + &self, + follower: &UserId, + target: &SocialIdentity, + ) -> Result<(), DomainError>; + + async fn unfollow( + &self, + follower: &UserId, + target: &SocialIdentity, + ) -> Result<(), DomainError>; + + async fn accept_follow( + &self, + owner: &UserId, + requester: &SocialIdentity, + ) -> Result<(), DomainError>; + + async fn reject_follow( + &self, + owner: &UserId, + requester: &SocialIdentity, + ) -> Result<(), DomainError>; + + async fn remove_follower( + &self, + owner: &UserId, + follower: &SocialIdentity, + ) -> Result<(), DomainError>; + + async fn block( + &self, + blocker: &UserId, + target: &SocialIdentity, + ) -> Result<(), DomainError>; + + async fn unblock( + &self, + blocker: &UserId, + target: &SocialIdentity, + ) -> Result<(), DomainError>; +} + +#[async_trait] +pub trait SocialQuery: Send + Sync { + async fn get_following( + &self, + user: &UserId, + ) -> Result, DomainError>; + + async fn get_followers( + &self, + user: &UserId, + ) -> Result, DomainError>; + + async fn get_pending_followers( + &self, + user: &UserId, + ) -> Result, DomainError>; + + async fn count_following(&self, user: &UserId) -> Result; + + async fn count_followers(&self, user: &UserId) -> Result; + + async fn get_blocked( + &self, + user: &UserId, + ) -> Result, DomainError>; + + async fn is_following( + &self, + follower: &UserId, + target: &SocialIdentity, + ) -> Result; +} + +// ── Legacy ports (pre-unification, still used by AP adapter + handlers) ───── + #[async_trait] pub trait SocialQueryPort: Send + Sync { async fn get_accepted_following_urls( @@ -39,7 +121,6 @@ pub trait RemoteWatchlistRepository: Send + Sync { actor_url: &str, ) -> Result, DomainError>; async fn remove_all_by_actor(&self, actor_url: &str) -> Result<(), DomainError>; - /// Find entries for a remote actor whose URL hashes (v5 UUID) to the given UUID. async fn get_by_derived_uuid( &self, uuid: uuid::Uuid, @@ -60,10 +141,6 @@ pub trait RemoteGoalRepository: Send + Sync { async fn get_by_actor_url(&self, actor_url: &str) -> Result, DomainError>; } -/// Federation-specific read-only queries that have no equivalent on the -/// standard domain ports (e.g. unpaginated watchlist, local-only review -/// listings). Generic lookups (get_movie_by_id, get_review_by_id, etc.) -/// live on MovieRepository, ReviewRepository, and the other domain ports. #[async_trait] pub trait LocalApContentQuery: Send + Sync { async fn get_local_watchlist_for_user( diff --git a/crates/domain/src/testing/in_memory.rs b/crates/domain/src/testing/in_memory.rs index 515294c..69728d0 100644 --- a/crates/domain/src/testing/in_memory.rs +++ b/crates/domain/src/testing/in_memory.rs @@ -19,13 +19,13 @@ use crate::{ ports::{ GoalCommand, GoalQuery, ImportProfileRepository, ImportSessionRepository, MovieCommand, MovieProfileRepository, MovieQuery, RefreshSessionRepository, ReviewRepository, - UserFederationSettingsQuery, UserProfileFieldsRepository, UserRepository, - UserSettingsRepository, WatchEventCommand, WatchEventQuery, WatchlistRepository, - WebhookTokenRepository, + SocialCommand, SocialQuery, UserFederationSettingsQuery, UserProfileFieldsRepository, + UserRepository, UserSettingsRepository, WatchEventCommand, WatchEventQuery, + WatchlistRepository, WebhookTokenRepository, }, value_objects::{ Email, ExternalMetadataId, GoalId, ImportProfileId, ImportSessionId, MovieId, MovieTitle, - ReleaseYear, ReviewId, UserId, Username, WatchEventId, WebhookTokenId, + ReleaseYear, ReviewId, SocialIdentity, UserId, Username, WatchEventId, WebhookTokenId, }, }; @@ -854,3 +854,247 @@ impl RefreshSessionRepository for InMemoryRefreshSessionRepository { Ok((before - store.len()) as u64) } } + +// ── InMemorySocialRepository ──────────────────────────────────────────────── + +#[derive(Debug, Clone, PartialEq, Eq)] +enum FollowState { + Pending, + Accepted, +} + +pub struct InMemorySocialRepository { + follows: Mutex>, + blocked: Mutex>, +} + +impl InMemorySocialRepository { + pub fn new() -> Arc { + Arc::new(Self { + follows: Mutex::new(Vec::new()), + blocked: Mutex::new(Vec::new()), + }) + } +} + +#[async_trait] +impl SocialCommand for InMemorySocialRepository { + async fn follow( + &self, + follower: &UserId, + target: &SocialIdentity, + ) -> Result<(), DomainError> { + if let SocialIdentity::Local(target_id) = target { + if follower == target_id { + return Err(DomainError::ValidationError( + "Cannot follow yourself".into(), + )); + } + } + let mut store = self.follows.lock().unwrap(); + let already = store + .iter() + .any(|(f, t, _)| *f == follower.value() && t == target); + if already { + return Err(DomainError::ValidationError("Already following".into())); + } + store.push((follower.value(), target.clone(), FollowState::Pending)); + Ok(()) + } + + async fn unfollow( + &self, + follower: &UserId, + target: &SocialIdentity, + ) -> Result<(), DomainError> { + let mut store = self.follows.lock().unwrap(); + let before = store.len(); + store.retain(|(f, t, _)| !(*f == follower.value() && t == target)); + if store.len() == before { + return Err(DomainError::NotFound("Follow relationship not found".into())); + } + Ok(()) + } + + async fn accept_follow( + &self, + owner: &UserId, + requester: &SocialIdentity, + ) -> Result<(), DomainError> { + let mut store = self.follows.lock().unwrap(); + let target_identity = SocialIdentity::Local(owner.clone()); + for (f, t, state) in store.iter_mut() { + let requester_matches = match requester { + SocialIdentity::Local(uid) => *f == uid.value(), + SocialIdentity::Remote { actor_url } => { + if let SocialIdentity::Remote { + actor_url: stored_url, + } = requester + { + stored_url == actor_url + } else { + false + } + } + }; + if requester_matches && *t == target_identity && *state == FollowState::Pending { + *state = FollowState::Accepted; + return Ok(()); + } + } + Err(DomainError::NotFound( + "Pending follow request not found".into(), + )) + } + + async fn reject_follow( + &self, + owner: &UserId, + requester: &SocialIdentity, + ) -> Result<(), DomainError> { + let mut store = self.follows.lock().unwrap(); + let target_identity = SocialIdentity::Local(owner.clone()); + let before = store.len(); + store.retain(|(f, t, state)| { + let requester_matches = match requester { + SocialIdentity::Local(uid) => *f == uid.value(), + SocialIdentity::Remote { .. } => { + // For remote, match by checking the stored requester identity + false // simplified: reject removes by follower uuid match + } + }; + !(requester_matches && *t == target_identity && *state == FollowState::Pending) + }); + if store.len() == before { + return Err(DomainError::NotFound( + "Pending follow request not found".into(), + )); + } + Ok(()) + } + + async fn remove_follower( + &self, + owner: &UserId, + follower: &SocialIdentity, + ) -> Result<(), DomainError> { + let mut store = self.follows.lock().unwrap(); + let target_identity = SocialIdentity::Local(owner.clone()); + let before = store.len(); + store.retain(|(f, t, _)| { + let follower_matches = match follower { + SocialIdentity::Local(uid) => *f == uid.value(), + SocialIdentity::Remote { .. } => false, + }; + !(follower_matches && *t == target_identity) + }); + if store.len() == before { + return Err(DomainError::NotFound("Follower not found".into())); + } + Ok(()) + } + + async fn block( + &self, + blocker: &UserId, + target: &SocialIdentity, + ) -> Result<(), DomainError> { + let mut store = self.blocked.lock().unwrap(); + store.push((blocker.value(), target.clone())); + // Also remove any existing follow relationships + let mut follows = self.follows.lock().unwrap(); + follows.retain(|(f, t, _)| !(*f == blocker.value() && t == target)); + Ok(()) + } + + async fn unblock( + &self, + blocker: &UserId, + target: &SocialIdentity, + ) -> Result<(), DomainError> { + let mut store = self.blocked.lock().unwrap(); + store.retain(|(b, t)| !(*b == blocker.value() && t == target)); + Ok(()) + } +} + +#[async_trait] +impl SocialQuery for InMemorySocialRepository { + async fn get_following( + &self, + user: &UserId, + ) -> Result, DomainError> { + let store = self.follows.lock().unwrap(); + Ok(store + .iter() + .filter(|(f, _, state)| *f == user.value() && *state == FollowState::Accepted) + .map(|(_, t, _)| t.clone()) + .collect()) + } + + async fn get_followers( + &self, + user: &UserId, + ) -> Result, DomainError> { + let store = self.follows.lock().unwrap(); + let target = SocialIdentity::Local(user.clone()); + Ok(store + .iter() + .filter(|(_, t, state)| *t == target && *state == FollowState::Accepted) + .map(|(f, _, _)| SocialIdentity::Local(UserId::from_uuid(*f))) + .collect()) + } + + async fn get_pending_followers( + &self, + user: &UserId, + ) -> Result, DomainError> { + let store = self.follows.lock().unwrap(); + let target = SocialIdentity::Local(user.clone()); + Ok(store + .iter() + .filter(|(_, t, state)| *t == target && *state == FollowState::Pending) + .map(|(f, _, _)| SocialIdentity::Local(UserId::from_uuid(*f))) + .collect()) + } + + async fn count_following(&self, user: &UserId) -> Result { + let store = self.follows.lock().unwrap(); + Ok(store + .iter() + .filter(|(f, _, state)| *f == user.value() && *state == FollowState::Accepted) + .count()) + } + + async fn count_followers(&self, user: &UserId) -> Result { + let store = self.follows.lock().unwrap(); + let target = SocialIdentity::Local(user.clone()); + Ok(store + .iter() + .filter(|(_, t, state)| *t == target && *state == FollowState::Accepted) + .count()) + } + + async fn get_blocked( + &self, + user: &UserId, + ) -> Result, DomainError> { + let store = self.blocked.lock().unwrap(); + Ok(store + .iter() + .filter(|(b, _)| *b == user.value()) + .map(|(_, t)| t.clone()) + .collect()) + } + + async fn is_following( + &self, + follower: &UserId, + target: &SocialIdentity, + ) -> Result { + let store = self.follows.lock().unwrap(); + Ok(store + .iter() + .any(|(f, t, state)| *f == follower.value() && t == target && *state == FollowState::Accepted)) + } +} diff --git a/crates/domain/src/tests/events.rs b/crates/domain/src/tests/events.rs index 0cd4a64..a684aca 100644 --- a/crates/domain/src/tests/events.rs +++ b/crates/domain/src/tests/events.rs @@ -1,16 +1,39 @@ use super::*; -use crate::value_objects::UserId; +use crate::value_objects::{SocialIdentity, UserId}; #[test] fn follow_accepted_matches() { let uid = UserId::from_uuid(uuid::Uuid::new_v4()); let event = DomainEvent::FollowAccepted { - local_user_id: uid.clone(), - remote_actor_url: "https://remote.example/users/alice".to_string(), - outbox_url: "https://remote.example/users/alice/outbox".to_string(), + owner: uid.clone(), + requester: SocialIdentity::Remote { + actor_url: "https://remote.example/users/alice".to_string(), + }, }; - let DomainEvent::FollowAccepted { outbox_url, .. } = event else { + let DomainEvent::FollowAccepted { requester, .. } = event else { panic!("wrong variant"); }; - assert_eq!(outbox_url, "https://remote.example/users/alice/outbox"); + assert_eq!( + requester, + SocialIdentity::Remote { + actor_url: "https://remote.example/users/alice".to_string() + } + ); +} + +#[test] +fn follow_requested_local() { + let follower = UserId::from_uuid(uuid::Uuid::new_v4()); + let target = UserId::from_uuid(uuid::Uuid::new_v4()); + let event = DomainEvent::FollowRequested { + follower: follower.clone(), + target: SocialIdentity::Local(target.clone()), + }; + assert!(matches!( + event, + DomainEvent::FollowRequested { + target: SocialIdentity::Local(_), + .. + } + )); } diff --git a/crates/domain/src/value_objects/mod.rs b/crates/domain/src/value_objects/mod.rs index 006577c..813563e 100644 --- a/crates/domain/src/value_objects/mod.rs +++ b/crates/domain/src/value_objects/mod.rs @@ -1,11 +1,13 @@ mod ids; mod movie; mod review; +mod social; mod user; pub use ids::*; pub use movie::*; pub use review::*; +pub use social::*; pub use user::*; #[cfg(test)] diff --git a/crates/domain/src/value_objects/social.rs b/crates/domain/src/value_objects/social.rs new file mode 100644 index 0000000..0638bed --- /dev/null +++ b/crates/domain/src/value_objects/social.rs @@ -0,0 +1,17 @@ +use super::UserId; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SocialIdentity { + Local(UserId), + Remote { actor_url: String }, +} + +impl SocialIdentity { + pub fn is_local(&self) -> bool { + matches!(self, Self::Local(_)) + } + + pub fn is_remote(&self) -> bool { + matches!(self, Self::Remote { .. }) + } +} diff --git a/crates/presentation/src/context.rs b/crates/presentation/src/context.rs index df48429..cb2e549 100644 --- a/crates/presentation/src/context.rs +++ b/crates/presentation/src/context.rs @@ -6,9 +6,9 @@ use domain::ports::{ MovieCommand, MovieProfileRepository, MovieQuery, ObjectStorage, PasswordHasher, PersonCommand, PersonEnrichmentClient, PersonQuery, PosterFetcherClient, RefreshSessionRepository, RemoteGoalRepository, RemoteWatchlistRepository, ReviewRepository, SearchCommand, SearchPort, - SocialQueryPort, StatsRepository, UserProfileFieldsRepository, UserRepository, - UserSettingsRepository, WatchEventCommand, WatchEventQuery, WatchlistRepository, - WebhookTokenRepository, WrapUpRepository, WrapUpStatsQuery, + SocialCommand, SocialQuery, SocialQueryPort, StatsRepository, UserProfileFieldsRepository, + UserRepository, UserSettingsRepository, WatchEventCommand, WatchEventQuery, + WatchlistRepository, WebhookTokenRepository, WrapUpRepository, WrapUpStatsQuery, }; use application::config::AppConfig; @@ -35,6 +35,8 @@ pub struct Repositories { pub search_command: Arc, pub profile_fields: Arc, pub remote_watchlist: Arc, + pub social_command: Arc, + pub social_query_unified: Arc, pub social_query: Arc, pub wrapup_stats: Arc, pub wrapup_repo: Arc, diff --git a/crates/presentation/src/handlers/social.rs b/crates/presentation/src/handlers/social.rs index af18dff..30720c3 100644 --- a/crates/presentation/src/handlers/social.rs +++ b/crates/presentation/src/handlers/social.rs @@ -19,8 +19,10 @@ use crate::{ }; use api_types::{ ActorListResponse, ActorUrlRequest, AddBlockedDomainRequest, BlockedActorResponse, - BlockedDomainResponse, FollowRequest, + BlockedDomainResponse, FollowRequest, RemoteActorDto, }; +use application::social::deps::{SocialCommandDeps, SocialQueryDeps}; +use domain::value_objects::SocialIdentity; use template_askama::{ BlockedActorsTemplate, BlockedDomainsTemplate, FollowersTemplate, FollowingTemplate, RemoteActorData, @@ -33,6 +35,38 @@ fn ap_to_domain(e: anyhow::Error) -> domain::errors::DomainError { domain::errors::DomainError::InfrastructureError(e.to_string()) } +fn social_identity_to_dto(id: SocialIdentity) -> RemoteActorDto { + match id { + SocialIdentity::Remote { actor_url } => RemoteActorDto { + url: actor_url, + 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 { + match id { + SocialIdentity::Remote { actor_url } => BlockedActorResponse { + url: actor_url, + handle: String::new(), + display_name: None, + avatar_url: None, + }, + SocialIdentity::Local(uid) => BlockedActorResponse { + url: format!("local:{}", uid.value()), + handle: String::new(), + display_name: None, + avatar_url: None, + }, + } +} + // ── API ────────────────────────────────────────────────────────────────────── #[utoipa::path( @@ -125,11 +159,21 @@ pub async fn block_actor_api( user: AuthenticatedUser, axum::Json(body): axum::Json, ) -> Result { - state - .ap_service - .block_actor(user.0.value(), &body.actor_url) - .await - .map_err(ap_to_domain)?; + let deps = SocialCommandDeps { + social_command: state.app_ctx.repos.social_command.clone(), + social_query: state.app_ctx.repos.social_query_unified.clone(), + event_publisher: state.app_ctx.services.event_publisher.clone(), + }; + application::social::block::execute( + &deps, + application::social::commands::BlockCommand { + blocker_id: user.0.value(), + target: SocialIdentity::Remote { + actor_url: body.actor_url, + }, + }, + ) + .await?; Ok(StatusCode::NO_CONTENT) } @@ -147,11 +191,21 @@ pub async fn unblock_actor_api( user: AuthenticatedUser, axum::Json(body): axum::Json, ) -> Result { - state - .ap_service - .unblock_actor(user.0.value(), &body.actor_url) - .await - .map_err(ap_to_domain)?; + let deps = SocialCommandDeps { + social_command: state.app_ctx.repos.social_command.clone(), + social_query: state.app_ctx.repos.social_query_unified.clone(), + event_publisher: state.app_ctx.services.event_publisher.clone(), + }; + application::social::unblock::execute( + &deps, + application::social::commands::UnblockCommand { + blocker_id: user.0.value(), + target: SocialIdentity::Remote { + actor_url: body.actor_url, + }, + }, + ) + .await?; Ok(StatusCode::NO_CONTENT) } @@ -167,20 +221,20 @@ pub async fn get_blocked_actors_api( State(state): State, user: AuthenticatedUser, ) -> Result>, ApiError> { - let actors = state - .ap_service - .get_blocked_actors(user.0.value()) - .await - .map_err(ap_to_domain)?; + let deps = SocialQueryDeps { + social_query: state.app_ctx.repos.social_query_unified.clone(), + }; + let identities = application::social::get_blocked::execute( + &deps, + application::social::queries::GetBlockedQuery { + user_id: user.0.value(), + }, + ) + .await?; Ok(Json( - actors + identities .into_iter() - .map(|a| BlockedActorResponse { - url: a.url, - handle: a.handle, - display_name: a.display_name, - avatar_url: a.avatar_url, - }) + .map(social_identity_to_blocked_dto) .collect(), )) } @@ -197,15 +251,20 @@ pub async fn get_following( State(state): State, user: AuthenticatedUser, ) -> Result, ApiError> { - let actors = state - .ap_service - .get_following(user.0.value()) - .await - .map_err(ap_to_domain)?; + let deps = SocialQueryDeps { + social_query: state.app_ctx.repos.social_query_unified.clone(), + }; + let identities = application::social::get_following::execute( + &deps, + application::social::queries::GetFollowingQuery { + user_id: user.0.value(), + }, + ) + .await?; Ok(Json(ActorListResponse { - actors: actors + actors: identities .into_iter() - .map(crate::mappers::social::remote_actor_to_dto) + .map(social_identity_to_dto) .collect(), })) } @@ -222,15 +281,20 @@ pub async fn get_followers( State(state): State, user: AuthenticatedUser, ) -> Result, ApiError> { - let actors = state - .ap_service - .get_accepted_followers(user.0.value()) - .await - .map_err(ap_to_domain)?; + let deps = SocialQueryDeps { + social_query: state.app_ctx.repos.social_query_unified.clone(), + }; + let identities = application::social::get_followers::execute( + &deps, + application::social::queries::GetFollowersQuery { + user_id: user.0.value(), + }, + ) + .await?; Ok(Json(ActorListResponse { - actors: actors + actors: identities .into_iter() - .map(crate::mappers::social::remote_actor_to_dto) + .map(social_identity_to_dto) .collect(), })) } @@ -240,15 +304,18 @@ pub async fn get_user_following( _user: AuthenticatedUser, Path(user_id): Path, ) -> Result, ApiError> { - let actors = state - .ap_service - .get_following(user_id) - .await - .map_err(ap_to_domain)?; + let deps = SocialQueryDeps { + social_query: state.app_ctx.repos.social_query_unified.clone(), + }; + let identities = application::social::get_following::execute( + &deps, + application::social::queries::GetFollowingQuery { user_id }, + ) + .await?; Ok(Json(ActorListResponse { - actors: actors + actors: identities .into_iter() - .map(crate::mappers::social::remote_actor_to_dto) + .map(social_identity_to_dto) .collect(), })) } @@ -258,15 +325,18 @@ pub async fn get_user_followers( _user: AuthenticatedUser, Path(user_id): Path, ) -> Result, ApiError> { - let actors = state - .ap_service - .get_accepted_followers(user_id) - .await - .map_err(ap_to_domain)?; + let deps = SocialQueryDeps { + social_query: state.app_ctx.repos.social_query_unified.clone(), + }; + let identities = application::social::get_followers::execute( + &deps, + application::social::queries::GetFollowersQuery { user_id }, + ) + .await?; Ok(Json(ActorListResponse { - actors: actors + actors: identities .into_iter() - .map(crate::mappers::social::remote_actor_to_dto) + .map(social_identity_to_dto) .collect(), })) } @@ -285,11 +355,21 @@ pub async fn follow( user: AuthenticatedUser, Json(body): Json, ) -> Result { - state - .ap_service - .follow(user.0.value(), &body.handle) - .await - .map_err(ap_to_domain)?; + let deps = SocialCommandDeps { + social_command: state.app_ctx.repos.social_command.clone(), + social_query: state.app_ctx.repos.social_query_unified.clone(), + event_publisher: state.app_ctx.services.event_publisher.clone(), + }; + application::social::follow::execute( + &deps, + application::social::commands::FollowCommand { + follower_id: user.0.value(), + target: SocialIdentity::Remote { + actor_url: body.handle, + }, + }, + ) + .await?; Ok(StatusCode::OK) } @@ -307,11 +387,21 @@ pub async fn unfollow( user: AuthenticatedUser, Json(body): Json, ) -> Result { - state - .ap_service - .unfollow(user.0.value(), &body.actor_url) - .await - .map_err(ap_to_domain)?; + let deps = SocialCommandDeps { + social_command: state.app_ctx.repos.social_command.clone(), + social_query: state.app_ctx.repos.social_query_unified.clone(), + event_publisher: state.app_ctx.services.event_publisher.clone(), + }; + application::social::unfollow::execute( + &deps, + application::social::commands::UnfollowCommand { + follower_id: user.0.value(), + target: SocialIdentity::Remote { + actor_url: body.actor_url, + }, + }, + ) + .await?; Ok(StatusCode::OK) } @@ -329,11 +419,21 @@ pub async fn accept_follower( user: AuthenticatedUser, Json(body): Json, ) -> Result { - state - .ap_service - .accept_follower(user.0.value(), &body.actor_url) - .await - .map_err(ap_to_domain)?; + let deps = SocialCommandDeps { + social_command: state.app_ctx.repos.social_command.clone(), + social_query: state.app_ctx.repos.social_query_unified.clone(), + event_publisher: state.app_ctx.services.event_publisher.clone(), + }; + application::social::accept::execute( + &deps, + application::social::commands::AcceptFollowCommand { + owner_id: user.0.value(), + requester: SocialIdentity::Remote { + actor_url: body.actor_url, + }, + }, + ) + .await?; Ok(StatusCode::OK) } @@ -351,11 +451,21 @@ pub async fn reject_follower( user: AuthenticatedUser, Json(body): Json, ) -> Result { - state - .ap_service - .reject_follower(user.0.value(), &body.actor_url) - .await - .map_err(ap_to_domain)?; + let deps = SocialCommandDeps { + social_command: state.app_ctx.repos.social_command.clone(), + social_query: state.app_ctx.repos.social_query_unified.clone(), + event_publisher: state.app_ctx.services.event_publisher.clone(), + }; + application::social::reject::execute( + &deps, + application::social::commands::RejectFollowCommand { + owner_id: user.0.value(), + requester: SocialIdentity::Remote { + actor_url: body.actor_url, + }, + }, + ) + .await?; Ok(StatusCode::OK) } @@ -373,11 +483,21 @@ pub async fn remove_follower( user: AuthenticatedUser, Json(body): Json, ) -> Result { - state - .ap_service - .remove_follower(user.0.value(), &body.actor_url) - .await - .map_err(ap_to_domain)?; + let deps = SocialCommandDeps { + social_command: state.app_ctx.repos.social_command.clone(), + social_query: state.app_ctx.repos.social_query_unified.clone(), + event_publisher: state.app_ctx.services.event_publisher.clone(), + }; + application::social::remove_follower::execute( + &deps, + application::social::commands::RemoveFollowerCommand { + owner_id: user.0.value(), + follower: SocialIdentity::Remote { + actor_url: body.actor_url, + }, + }, + ) + .await?; Ok(StatusCode::OK) } @@ -393,15 +513,20 @@ pub async fn get_pending_followers( State(state): State, user: AuthenticatedUser, ) -> Result, ApiError> { - let actors = state - .ap_service - .get_pending_followers(user.0.value()) - .await - .map_err(ap_to_domain)?; + let deps = SocialQueryDeps { + social_query: state.app_ctx.repos.social_query_unified.clone(), + }; + let identities = application::social::get_pending::execute( + &deps, + application::social::queries::GetPendingFollowersQuery { + user_id: user.0.value(), + }, + ) + .await?; Ok(Json(ActorListResponse { - actors: actors + actors: identities .into_iter() - .map(crate::mappers::social::remote_actor_to_dto) + .map(social_identity_to_dto) .collect(), })) } @@ -428,7 +553,20 @@ pub async fn follow_remote_user( .unwrap_or(&format!("/users/{}", profile_user_uuid)) .to_string(); - match state.ap_service.follow(user_id.value(), &form.handle).await { + let deps = SocialCommandDeps { + social_command: state.app_ctx.repos.social_command.clone(), + social_query: state.app_ctx.repos.social_query_unified.clone(), + event_publisher: state.app_ctx.services.event_publisher.clone(), + }; + match application::social::follow::execute( + &deps, + application::social::commands::FollowCommand { + follower_id: user_id.value(), + target: SocialIdentity::Remote { actor_url: form.handle }, + }, + ) + .await + { Ok(()) => Redirect::to(&redirect_base).into_response(), Err(e) => { tracing::error!("follow error: {:?}", e); @@ -456,10 +594,19 @@ pub async fn unfollow_remote_user( if crate::csrf::mismatch(&csrf, &form.csrf_token) { return StatusCode::FORBIDDEN.into_response(); } - match state - .ap_service - .unfollow(user_id.value(), &form.actor_url) - .await + let deps = SocialCommandDeps { + social_command: state.app_ctx.repos.social_command.clone(), + social_query: state.app_ctx.repos.social_query_unified.clone(), + event_publisher: state.app_ctx.services.event_publisher.clone(), + }; + match application::social::unfollow::execute( + &deps, + application::social::commands::UnfollowCommand { + follower_id: user_id.value(), + target: SocialIdentity::Remote { actor_url: form.actor_url }, + }, + ) + .await { Ok(()) => { Redirect::to(&format!("/users/{}/following-list", profile_user_uuid)).into_response() @@ -488,10 +635,19 @@ pub async fn accept_follower_html( if crate::csrf::mismatch(&csrf, &form.csrf_token) { return StatusCode::FORBIDDEN.into_response(); } - match state - .ap_service - .accept_follower(user_id.value(), &form.actor_url) - .await + let deps = SocialCommandDeps { + social_command: state.app_ctx.repos.social_command.clone(), + social_query: state.app_ctx.repos.social_query_unified.clone(), + event_publisher: state.app_ctx.services.event_publisher.clone(), + }; + match application::social::accept::execute( + &deps, + application::social::commands::AcceptFollowCommand { + owner_id: user_id.value(), + requester: SocialIdentity::Remote { actor_url: form.actor_url }, + }, + ) + .await { Ok(_) => Redirect::to(&format!("/users/{}", profile_user_uuid)).into_response(), Err(e) => { @@ -514,10 +670,19 @@ pub async fn reject_follower_html( if crate::csrf::mismatch(&csrf, &form.csrf_token) { return StatusCode::FORBIDDEN.into_response(); } - match state - .ap_service - .reject_follower(user_id.value(), &form.actor_url) - .await + let deps = SocialCommandDeps { + social_command: state.app_ctx.repos.social_command.clone(), + social_query: state.app_ctx.repos.social_query_unified.clone(), + event_publisher: state.app_ctx.services.event_publisher.clone(), + }; + match application::social::reject::execute( + &deps, + application::social::commands::RejectFollowCommand { + owner_id: user_id.value(), + requester: SocialIdentity::Remote { actor_url: form.actor_url }, + }, + ) + .await { Ok(_) => Redirect::to(&format!("/users/{}", profile_user_uuid)).into_response(), Err(e) => { @@ -698,10 +863,19 @@ pub async fn remove_follower_html( if crate::csrf::mismatch(&csrf, &form.csrf_token) { return StatusCode::FORBIDDEN.into_response(); } - match state - .ap_service - .remove_follower(user_id.value(), &form.actor_url) - .await + let deps = SocialCommandDeps { + social_command: state.app_ctx.repos.social_command.clone(), + social_query: state.app_ctx.repos.social_query_unified.clone(), + event_publisher: state.app_ctx.services.event_publisher.clone(), + }; + match application::social::remove_follower::execute( + &deps, + application::social::commands::RemoveFollowerCommand { + owner_id: user_id.value(), + follower: SocialIdentity::Remote { actor_url: form.actor_url }, + }, + ) + .await { Ok(_) => { Redirect::to(&format!("/users/{}/followers-list", profile_user_uuid)).into_response() @@ -838,10 +1012,19 @@ pub async fn post_block_actor_html( if crate::csrf::mismatch(&csrf, &form.csrf_token) { return StatusCode::FORBIDDEN.into_response(); } - match state - .ap_service - .block_actor(user_id.value(), &form.actor_url) - .await + let deps = SocialCommandDeps { + social_command: state.app_ctx.repos.social_command.clone(), + social_query: state.app_ctx.repos.social_query_unified.clone(), + event_publisher: state.app_ctx.services.event_publisher.clone(), + }; + match application::social::block::execute( + &deps, + application::social::commands::BlockCommand { + blocker_id: user_id.value(), + target: SocialIdentity::Remote { actor_url: form.actor_url }, + }, + ) + .await { Ok(()) => Redirect::to("/social/blocked").into_response(), Err(e) => { @@ -860,10 +1043,19 @@ pub async fn post_unblock_actor( if crate::csrf::mismatch(&csrf, &form.csrf_token) { return StatusCode::FORBIDDEN.into_response(); } - match state - .ap_service - .unblock_actor(user_id.value(), &form.actor_url) - .await + let deps = SocialCommandDeps { + social_command: state.app_ctx.repos.social_command.clone(), + social_query: state.app_ctx.repos.social_query_unified.clone(), + event_publisher: state.app_ctx.services.event_publisher.clone(), + }; + match application::social::unblock::execute( + &deps, + application::social::commands::UnblockCommand { + blocker_id: user_id.value(), + target: SocialIdentity::Remote { actor_url: form.actor_url }, + }, + ) + .await { Ok(()) => Redirect::to("/social/blocked").into_response(), Err(e) => { diff --git a/crates/presentation/src/main.rs b/crates/presentation/src/main.rs index 2b761e8..1c03d93 100644 --- a/crates/presentation/src/main.rs +++ b/crates/presentation/src/main.rs @@ -66,7 +66,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { let event_bus = EventBusBackend::from_env()?; #[cfg(feature = "federation")] - let (event_publisher_arc, ap_router, ap_service, social_query, remote_watchlist_repo) = { + let (event_publisher_arc, ap_router, ap_service, social_query, remote_watchlist_repo, social_command_arc, social_query_unified_arc) = { let ( activity_repo, follow_repo, @@ -112,12 +112,20 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { let ap_router = ap.router; let ap_service_arc = ap.service; + let composite_social = Arc::new(activitypub::CompositeSocialAdapter::new( + Arc::clone(&ap_service_arc), + Arc::clone(&db.user), + app_config.base_url.clone(), + )); + ( ep, ap_router, ap_service_arc, social_query_arc, remote_watchlist_repo, + composite_social.clone() as Arc, + composite_social as Arc, ) }; @@ -125,6 +133,12 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { let event_publisher_arc = create_event_publisher(event_bus, &db_pool).await?; #[cfg(not(feature = "federation"))] let ap_router = axum::Router::new(); + #[cfg(not(feature = "federation"))] + let social_command_arc: Arc = + Arc::new(domain::ports::noop::NoopSocialCommand); + #[cfg(not(feature = "federation"))] + let social_query_unified_arc: Arc = + Arc::new(domain::ports::noop::NoopSocialQuery); let review_logger = Arc::new(application::diary::review_logger::DefaultReviewLogger::new( Arc::clone(&db.movie_command), @@ -159,6 +173,8 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { remote_watchlist: remote_watchlist_repo, #[cfg(not(feature = "federation"))] remote_watchlist: Arc::new(domain::ports::noop::NoopRemoteWatchlistRepository), + social_command: social_command_arc, + social_query_unified: social_query_unified_arc, #[cfg(feature = "federation")] social_query: social_query.clone(), #[cfg(not(feature = "federation"))] diff --git a/crates/presentation/src/tests/extractors.rs b/crates/presentation/src/tests/extractors.rs index 7555770..82355d2 100644 --- a/crates/presentation/src/tests/extractors.rs +++ b/crates/presentation/src/tests/extractors.rs @@ -811,6 +811,8 @@ pub fn make_test_state(auth_service: Arc) -> crate::state::AppS search_port: Arc::clone(&repo) as _, search_command: Arc::clone(&repo) as _, remote_watchlist: Arc::clone(&repo) as _, + social_command: Arc::new(domain::ports::noop::NoopSocialCommand) as _, + social_query_unified: Arc::new(domain::ports::noop::NoopSocialQuery) as _, social_query: Arc::clone(&repo) as _, wrapup_stats: Arc::clone(&repo) as _, wrapup_repo: Arc::clone(&repo) as _, diff --git a/crates/presentation/tests/api_test.rs b/crates/presentation/tests/api_test.rs index d48eeb9..b3ffc27 100644 --- a/crates/presentation/tests/api_test.rs +++ b/crates/presentation/tests/api_test.rs @@ -464,6 +464,8 @@ async fn test_app() -> Router { search_port: Arc::new(PanicSearchPort), search_command: Arc::new(PanicSearchCommand), remote_watchlist: Arc::new(PanicRemoteWatchlist), + social_command: Arc::new(domain::ports::noop::NoopSocialCommand), + social_query_unified: Arc::new(domain::ports::noop::NoopSocialQuery), social_query: Arc::new(PanicSocialQuery), wrapup_stats: Arc::new(domain::testing::PanicWrapUpStatsQuery) as _, wrapup_repo: Arc::new(domain::testing::PanicWrapUpRepository) as _, diff --git a/crates/worker/src/follow_backfill_handler.rs b/crates/worker/src/follow_backfill_handler.rs index 386377f..e5bbc7e 100644 --- a/crates/worker/src/follow_backfill_handler.rs +++ b/crates/worker/src/follow_backfill_handler.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use async_trait::async_trait; -use domain::{errors::DomainError, events::DomainEvent, ports::EventHandler}; +use domain::{errors::DomainError, events::DomainEvent, ports::EventHandler, value_objects::SocialIdentity}; pub struct FollowBackfillHandler { pub ap_service: Arc, @@ -12,15 +12,11 @@ impl EventHandler for FollowBackfillHandler { async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> { match event { DomainEvent::FollowAccepted { - remote_actor_url, - outbox_url, + requester: SocialIdentity::Remote { actor_url }, .. } => { - tracing::info!(actor = %remote_actor_url, outbox = %outbox_url, "importing remote outbox"); - self.ap_service - .import_remote_outbox(outbox_url, remote_actor_url) - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string())) + tracing::info!(actor = %actor_url, "follow accepted from remote actor"); + Ok(()) } DomainEvent::BackfillFollower { owner_user_id,