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:
@@ -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 { .. })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user