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:
2026-07-10 15:29:46 +02:00
parent 96ce5f7d26
commit 322e9ee81a
45 changed files with 2095 additions and 160 deletions

View File

@@ -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,

View File

@@ -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.

View File

@@ -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(

View File

@@ -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))
}
}

View File

@@ -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(_),
..
}
));
}

View File

@@ -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)]

View 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 { .. })
}
}