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

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