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,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 = (

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

View File

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

View File

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