feat: unified social identity layer — SocialIdentity, ports, use cases, adapter
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.
This commit is contained in:
@@ -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 = (
|
||||
|
||||
254
crates/adapters/activitypub/src/social_adapter.rs
Normal file
254
crates/adapters/activitypub/src/social_adapter.rs
Normal file
@@ -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<dyn ActivityPubPort>,
|
||||
user_repo: Arc<dyn UserRepository>,
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
impl CompositeSocialAdapter {
|
||||
pub fn new(
|
||||
ap_service: Arc<dyn ActivityPubPort>,
|
||||
user_repo: Arc<dyn UserRepository>,
|
||||
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<String, DomainError> {
|
||||
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<Vec<SocialIdentity>, 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<Vec<SocialIdentity>, 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<Vec<SocialIdentity>, 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<usize, DomainError> {
|
||||
self.ap_service
|
||||
.count_following(user.value())
|
||||
.await
|
||||
.map_err(ap_err)
|
||||
}
|
||||
|
||||
async fn count_followers(&self, user: &UserId) -> Result<usize, DomainError> {
|
||||
self.ap_service
|
||||
.count_accepted_followers(user.value())
|
||||
.await
|
||||
.map_err(ap_err)
|
||||
}
|
||||
|
||||
async fn get_blocked(
|
||||
&self,
|
||||
user: &UserId,
|
||||
) -> Result<Vec<SocialIdentity>, 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<bool, DomainError> {
|
||||
let following = self.get_following(follower).await?;
|
||||
Ok(following.contains(target))
|
||||
}
|
||||
}
|
||||
@@ -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, DomainError> {
|
||||
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<SocialIdentity, DomainError> {
|
||||
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<NaiveDateTime, DomainError> {
|
||||
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<EventPayload> 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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
23
crates/application/src/social/accept.rs
Normal file
23
crates/application/src/social/accept.rs
Normal file
@@ -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;
|
||||
18
crates/application/src/social/block.rs
Normal file
18
crates/application/src/social/block.rs
Normal file
@@ -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;
|
||||
37
crates/application/src/social/commands.rs
Normal file
37
crates/application/src/social/commands.rs
Normal file
@@ -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,
|
||||
}
|
||||
13
crates/application/src/social/deps.rs
Normal file
13
crates/application/src/social/deps.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{EventPublisher, SocialCommand, SocialQuery};
|
||||
|
||||
pub struct SocialCommandDeps {
|
||||
pub social_command: Arc<dyn SocialCommand>,
|
||||
pub social_query: Arc<dyn SocialQuery>,
|
||||
pub event_publisher: Arc<dyn EventPublisher>,
|
||||
}
|
||||
|
||||
pub struct SocialQueryDeps {
|
||||
pub social_query: Arc<dyn SocialQuery>,
|
||||
}
|
||||
18
crates/application/src/social/follow.rs
Normal file
18
crates/application/src/social/follow.rs
Normal file
@@ -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;
|
||||
11
crates/application/src/social/get_blocked.rs
Normal file
11
crates/application/src/social/get_blocked.rs
Normal file
@@ -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<Vec<SocialIdentity>, DomainError> {
|
||||
let user_id = UserId::from_uuid(query.user_id);
|
||||
deps.social_query.get_blocked(&user_id).await
|
||||
}
|
||||
15
crates/application/src/social/get_followers.rs
Normal file
15
crates/application/src/social/get_followers.rs
Normal file
@@ -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<Vec<SocialIdentity>, 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;
|
||||
15
crates/application/src/social/get_following.rs
Normal file
15
crates/application/src/social/get_following.rs
Normal file
@@ -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<Vec<SocialIdentity>, 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;
|
||||
15
crates/application/src/social/get_pending.rs
Normal file
15
crates/application/src/social/get_pending.rs
Normal file
@@ -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<Vec<SocialIdentity>, 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;
|
||||
15
crates/application/src/social/mod.rs
Normal file
15
crates/application/src/social/mod.rs
Normal file
@@ -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;
|
||||
17
crates/application/src/social/queries.rs
Normal file
17
crates/application/src/social/queries.rs
Normal file
@@ -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,
|
||||
}
|
||||
17
crates/application/src/social/reject.rs
Normal file
17
crates/application/src/social/reject.rs
Normal file
@@ -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;
|
||||
23
crates/application/src/social/remove_follower.rs
Normal file
23
crates/application/src/social/remove_follower.rs
Normal file
@@ -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;
|
||||
59
crates/application/src/social/tests/accept.rs
Normal file
59
crates/application/src/social/tests/accept.rs
Normal file
@@ -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<InMemorySocialRepository>, Arc<NoopEventPublisher>, 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 { .. })));
|
||||
}
|
||||
41
crates/application/src/social/tests/block.rs
Normal file
41
crates/application/src/social/tests/block.rs
Normal file
@@ -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<InMemorySocialRepository>, Arc<NoopEventPublisher>, 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 { .. })));
|
||||
}
|
||||
86
crates/application/src/social/tests/follow.rs
Normal file
86
crates/application/src/social/tests/follow.rs
Normal file
@@ -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<InMemorySocialRepository>, Arc<NoopEventPublisher>, 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());
|
||||
}
|
||||
62
crates/application/src/social/tests/get_followers.rs
Normal file
62
crates/application/src/social/tests/get_followers.rs
Normal file
@@ -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);
|
||||
}
|
||||
74
crates/application/src/social/tests/get_following.rs
Normal file
74
crates/application/src/social/tests/get_following.rs
Normal file
@@ -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);
|
||||
}
|
||||
51
crates/application/src/social/tests/get_pending.rs
Normal file
51
crates/application/src/social/tests/get_pending.rs
Normal file
@@ -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);
|
||||
}
|
||||
51
crates/application/src/social/tests/reject.rs
Normal file
51
crates/application/src/social/tests/reject.rs
Normal file
@@ -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<InMemorySocialRepository>, Arc<NoopEventPublisher>, 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();
|
||||
}
|
||||
68
crates/application/src/social/tests/remove_follower.rs
Normal file
68
crates/application/src/social/tests/remove_follower.rs
Normal file
@@ -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<InMemorySocialRepository>, Arc<NoopEventPublisher>, 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 { .. })));
|
||||
}
|
||||
57
crates/application/src/social/tests/unblock.rs
Normal file
57
crates/application/src/social/tests/unblock.rs
Normal file
@@ -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<InMemorySocialRepository>, Arc<NoopEventPublisher>, 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 { .. })));
|
||||
}
|
||||
57
crates/application/src/social/tests/unfollow.rs
Normal file
57
crates/application/src/social/tests/unfollow.rs
Normal file
@@ -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<InMemorySocialRepository>, Arc<NoopEventPublisher>, 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 { .. })));
|
||||
}
|
||||
18
crates/application/src/social/unblock.rs
Normal file
18
crates/application/src/social/unblock.rs
Normal file
@@ -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;
|
||||
20
crates/application/src/social/unfollow.rs
Normal file
20
crates/application/src/social/unfollow.rs
Normal file
@@ -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;
|
||||
@@ -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<dyn GoalQuery>,
|
||||
pub user_settings_repo: Arc<dyn UserSettingsRepository>,
|
||||
pub review_logger: Arc<dyn ReviewLogger>,
|
||||
pub social_command: Arc<dyn domain::ports::SocialCommand>,
|
||||
pub social_query_unified: Arc<dyn domain::ports::SocialQuery>,
|
||||
pub social_query: Arc<dyn domain::ports::SocialQueryPort>,
|
||||
pub refresh_session_repo: Arc<dyn RefreshSessionRepository>,
|
||||
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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Vec<SocialIdentity>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn get_followers(&self, _: &UserId) -> Result<Vec<SocialIdentity>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn get_pending_followers(&self, _: &UserId) -> Result<Vec<SocialIdentity>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn count_following(&self, _: &UserId) -> Result<usize, DomainError> {
|
||||
Ok(0)
|
||||
}
|
||||
async fn count_followers(&self, _: &UserId) -> Result<usize, DomainError> {
|
||||
Ok(0)
|
||||
}
|
||||
async fn get_blocked(&self, _: &UserId) -> Result<Vec<SocialIdentity>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn is_following(&self, _: &UserId, _: &SocialIdentity) -> Result<bool, DomainError> {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ── NoopSocialQueryPort ───────────────────────────────────────────────────────
|
||||
|
||||
/// Stub used when federation is disabled — returns empty results.
|
||||
|
||||
@@ -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<Vec<SocialIdentity>, DomainError>;
|
||||
|
||||
async fn get_followers(
|
||||
&self,
|
||||
user: &UserId,
|
||||
) -> Result<Vec<SocialIdentity>, DomainError>;
|
||||
|
||||
async fn get_pending_followers(
|
||||
&self,
|
||||
user: &UserId,
|
||||
) -> Result<Vec<SocialIdentity>, DomainError>;
|
||||
|
||||
async fn count_following(&self, user: &UserId) -> Result<usize, DomainError>;
|
||||
|
||||
async fn count_followers(&self, user: &UserId) -> Result<usize, DomainError>;
|
||||
|
||||
async fn get_blocked(
|
||||
&self,
|
||||
user: &UserId,
|
||||
) -> Result<Vec<SocialIdentity>, DomainError>;
|
||||
|
||||
async fn is_following(
|
||||
&self,
|
||||
follower: &UserId,
|
||||
target: &SocialIdentity,
|
||||
) -> Result<bool, DomainError>;
|
||||
}
|
||||
|
||||
// ── 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<Vec<RemoteWatchlistEntry>, 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<Vec<RemoteGoalEntry>, 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(
|
||||
|
||||
@@ -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<Vec<(Uuid, SocialIdentity, FollowState)>>,
|
||||
blocked: Mutex<Vec<(Uuid, SocialIdentity)>>,
|
||||
}
|
||||
|
||||
impl InMemorySocialRepository {
|
||||
pub fn new() -> Arc<Self> {
|
||||
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<Vec<SocialIdentity>, 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<Vec<SocialIdentity>, 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<Vec<SocialIdentity>, 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<usize, DomainError> {
|
||||
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<usize, DomainError> {
|
||||
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<Vec<SocialIdentity>, 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<bool, DomainError> {
|
||||
let store = self.follows.lock().unwrap();
|
||||
Ok(store
|
||||
.iter()
|
||||
.any(|(f, t, state)| *f == follower.value() && t == target && *state == FollowState::Accepted))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(_),
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
17
crates/domain/src/value_objects/social.rs
Normal file
17
crates/domain/src/value_objects/social.rs
Normal file
@@ -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 { .. })
|
||||
}
|
||||
}
|
||||
@@ -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<dyn SearchCommand>,
|
||||
pub profile_fields: Arc<dyn UserProfileFieldsRepository>,
|
||||
pub remote_watchlist: Arc<dyn RemoteWatchlistRepository>,
|
||||
pub social_command: Arc<dyn SocialCommand>,
|
||||
pub social_query_unified: Arc<dyn SocialQuery>,
|
||||
pub social_query: Arc<dyn SocialQueryPort>,
|
||||
pub wrapup_stats: Arc<dyn WrapUpStatsQuery>,
|
||||
pub wrapup_repo: Arc<dyn WrapUpRepository>,
|
||||
|
||||
@@ -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<ActorUrlRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
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<ActorUrlRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
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<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
) -> Result<Json<Vec<BlockedActorResponse>>, 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<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
) -> Result<Json<ActorListResponse>, 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<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
) -> Result<Json<ActorListResponse>, 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<Uuid>,
|
||||
) -> Result<Json<ActorListResponse>, 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<Uuid>,
|
||||
) -> Result<Json<ActorListResponse>, 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<FollowRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
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<ActorUrlRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
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<ActorUrlRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
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<ActorUrlRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
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<ActorUrlRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
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<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
) -> Result<Json<ActorListResponse>, 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) => {
|
||||
|
||||
@@ -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<dyn domain::ports::SocialCommand>,
|
||||
composite_social as Arc<dyn domain::ports::SocialQuery>,
|
||||
)
|
||||
};
|
||||
|
||||
@@ -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<dyn domain::ports::SocialCommand> =
|
||||
Arc::new(domain::ports::noop::NoopSocialCommand);
|
||||
#[cfg(not(feature = "federation"))]
|
||||
let social_query_unified_arc: Arc<dyn domain::ports::SocialQuery> =
|
||||
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"))]
|
||||
|
||||
@@ -811,6 +811,8 @@ pub fn make_test_state(auth_service: Arc<dyn AuthService>) -> 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 _,
|
||||
|
||||
@@ -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 _,
|
||||
|
||||
@@ -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<dyn activitypub::ActivityPubPort>,
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user