Files
movies-diary/crates/application/src/social/local_service.rs
Gabriel Kaszewski 9a4d0c1714 feat: atomic social local writes, remote review enrichment
SocialCommand methods take events. Local follow ops = one FollowCommand::apply
tx (rows + events). k_ap path (remote, block/unblock): best-effort publish in
CompositeSocialAdapter after k_ap write; not atomic, k_ap owns tx.
save_remote_review takes events, enqueued in same tx via
sqlite::persistence::enqueue_events. Deps drop event_publisher: social
command, ReviewObjectHandler.

Remaining .publish( sites:
announce-only: users/delete_account UserDeleted, person/get + get_credits
PersonEnrichmentRequested, jobs/enrichment_staleness, http-axum search,
ImageStored (sync_poster, poster_sync_handler, image-converter backfill,
update_profile; object storage), federation_event_bridge (handler reaction),
nats relay (transport).
non-atomic by necessity: CompositeSocialAdapter::announce (k_ap-owned write).
Everything else rides its write.
2026-10-26 01:12:58 +01:00

328 lines
11 KiB
Rust

use std::sync::Arc;
use async_trait::async_trait;
use domain::{
errors::DomainError,
events::DomainEvent,
ports::{
BlockQuery, FollowCommand, FollowGraphQuery, FollowQuery, FollowTargetResolver,
FollowWrite, ResolvedFollow, SocialCommand, UserRepository,
},
value_objects::{
FollowRelation, FollowStatus, FollowTarget, InstanceIdentity, SocialActor, SocialIdentity,
UserId, Username,
},
};
pub struct LocalSocialService {
user_repo: Arc<dyn UserRepository>,
follow_command: Arc<dyn FollowCommand>,
follow_query: Arc<dyn FollowQuery>,
instance: InstanceIdentity,
}
impl LocalSocialService {
pub fn new(
user_repo: Arc<dyn UserRepository>,
follow_command: Arc<dyn FollowCommand>,
follow_query: Arc<dyn FollowQuery>,
instance: InstanceIdentity,
) -> Self {
Self {
user_repo,
follow_command,
follow_query,
instance,
}
}
async fn resolve_target_identity(
&self,
target: &FollowTarget,
) -> Result<SocialIdentity, DomainError> {
match target {
FollowTarget::Identity(id) => Ok(id.clone()),
FollowTarget::Handle(handle) => {
let host = handle.rsplit_once('@').map(|(_, h)| h).unwrap_or("");
let local_host = self.instance.host();
if host == local_host {
let username_str = handle
.trim_start_matches('@')
.split('@')
.next()
.unwrap_or("");
if let Ok(username) = Username::new(username_str.to_string())
&& let Some(user) = self.user_repo.find_by_username(&username).await?
{
return Ok(SocialIdentity::Local(user.id().clone()));
}
}
Ok(SocialIdentity::Remote {
actor_url: handle.clone(),
})
}
}
}
}
fn remote_unsupported(actor: &str) -> DomainError {
DomainError::ValidationError(format!(
"cannot reach remote actor {actor}: this instance was built without the federation feature"
))
}
#[async_trait]
impl SocialCommand for LocalSocialService {
async fn follow(
&self,
follower: &UserId,
target: &FollowTarget,
events: &[DomainEvent],
) -> Result<(), DomainError> {
let identity = self.resolve_target_identity(target).await?;
self.follow_resolved(follower, &identity, events).await
}
async fn unfollow(
&self,
follower: &UserId,
target: &SocialIdentity,
events: &[DomainEvent],
) -> Result<(), DomainError> {
let actor_url = self.instance.actor_url_of(target);
match target {
SocialIdentity::Local(target_id) => {
let follower_url = self.instance.actor_url_for(follower);
self.follow_command
.apply(
&[
FollowWrite::RemoveFollow {
follower_id: follower.value(),
target_actor_url: actor_url,
},
FollowWrite::RemoveFollowerRecord {
local_user_id: target_id.value(),
follower_actor_url: follower_url,
},
],
events,
)
.await
}
SocialIdentity::Remote { .. } => Err(remote_unsupported(&actor_url)),
}
}
async fn accept_follow(
&self,
owner: &UserId,
requester: &SocialIdentity,
events: &[DomainEvent],
) -> Result<(), DomainError> {
let actor_url = self.instance.actor_url_of(requester);
match requester {
SocialIdentity::Local(requester_id) => {
let owner_url = self.instance.actor_url_for(owner);
self.follow_command
.apply(
&[
FollowWrite::UpdateFollowerStatus {
local_user_id: owner.value(),
follower_actor_url: actor_url,
status: FollowStatus::Accepted,
},
FollowWrite::UpdateFollowStatus {
follower_id: requester_id.value(),
target_actor_url: owner_url,
status: FollowStatus::Accepted,
},
],
events,
)
.await
}
SocialIdentity::Remote { .. } => Err(remote_unsupported(&actor_url)),
}
}
async fn reject_follow(
&self,
owner: &UserId,
requester: &SocialIdentity,
events: &[DomainEvent],
) -> Result<(), DomainError> {
let actor_url = self.instance.actor_url_of(requester);
match requester {
SocialIdentity::Local(requester_id) => {
let owner_url = self.instance.actor_url_for(owner);
self.follow_command
.apply(
&[
FollowWrite::UpdateFollowerStatus {
local_user_id: owner.value(),
follower_actor_url: actor_url,
status: FollowStatus::Rejected,
},
FollowWrite::RemoveFollow {
follower_id: requester_id.value(),
target_actor_url: owner_url,
},
],
events,
)
.await
}
SocialIdentity::Remote { .. } => Err(remote_unsupported(&actor_url)),
}
}
async fn remove_follower(
&self,
owner: &UserId,
follower: &SocialIdentity,
events: &[DomainEvent],
) -> Result<(), DomainError> {
let actor_url = self.instance.actor_url_of(follower);
match follower {
SocialIdentity::Local(follower_id) => {
let owner_url = self.instance.actor_url_for(owner);
self.follow_command
.apply(
&[
FollowWrite::RemoveFollowerRecord {
local_user_id: owner.value(),
follower_actor_url: actor_url,
},
FollowWrite::RemoveFollow {
follower_id: follower_id.value(),
target_actor_url: owner_url,
},
],
events,
)
.await
}
SocialIdentity::Remote { .. } => Err(remote_unsupported(&actor_url)),
}
}
async fn block(
&self,
_blocker: &UserId,
_target: &SocialIdentity,
_events: &[DomainEvent],
) -> Result<(), DomainError> {
Err(DomainError::ValidationError(
"blocking requires the federation feature".into(),
))
}
async fn unblock(
&self,
_blocker: &UserId,
_target: &SocialIdentity,
_events: &[DomainEvent],
) -> Result<(), DomainError> {
Err(DomainError::ValidationError(
"blocking requires the federation feature".into(),
))
}
}
#[async_trait]
impl FollowGraphQuery for LocalSocialService {
async fn get_following(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
self.follow_query.get_following(user.value()).await
}
async fn get_followers(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
self.follow_query.get_followers(user.value()).await
}
async fn get_pending_followers(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
self.follow_query.get_pending_followers(user.value()).await
}
async fn get_pending_following(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
self.follow_query.get_pending_following(user.value()).await
}
async fn count_following(&self, user: &UserId) -> Result<usize, DomainError> {
self.follow_query.count_following(user.value()).await
}
async fn count_followers(&self, user: &UserId) -> Result<usize, DomainError> {
self.follow_query.count_followers(user.value()).await
}
async fn count_pending_followers(&self, user: &UserId) -> Result<usize, DomainError> {
self.follow_query
.count_pending_followers(user.value())
.await
}
async fn get_relation(
&self,
viewer: &UserId,
target: &SocialIdentity,
) -> Result<FollowRelation, DomainError> {
let actor_url = self.instance.actor_url_of(target);
self.follow_query
.get_relation(viewer.value(), &actor_url)
.await
}
}
#[async_trait]
impl BlockQuery for LocalSocialService {
async fn get_blocked(&self, _user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
Ok(vec![])
}
}
#[async_trait]
impl FollowTargetResolver for LocalSocialService {
async fn resolve_target(&self, target: &FollowTarget) -> Result<SocialIdentity, DomainError> {
self.resolve_target_identity(target).await
}
}
#[async_trait]
impl ResolvedFollow for LocalSocialService {
async fn follow_resolved(
&self,
follower: &UserId,
target: &SocialIdentity,
events: &[DomainEvent],
) -> Result<(), DomainError> {
let SocialIdentity::Local(target_id) = target else {
let actor_url = self.instance.actor_url_of(target);
return Err(remote_unsupported(&actor_url));
};
if follower == target_id {
return Err(DomainError::ValidationError(
"Cannot follow yourself".into(),
));
}
let follower_url = self.instance.actor_url_for(follower);
let target_url = self.instance.actor_url_for(target_id);
self.follow_command
.apply(
&[
FollowWrite::AddFollower {
local_user_id: target_id.value(),
follower_actor_url: follower_url,
status: FollowStatus::Pending,
},
FollowWrite::AddFollow {
follower_id: follower.value(),
target_actor_url: target_url,
status: FollowStatus::Pending,
},
],
events,
)
.await
}
}