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

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