Compare commits
12 Commits
6bf4ffc4ab
...
89045414cf
| Author | SHA1 | Date | |
|---|---|---|---|
| 89045414cf | |||
| 2de6690401 | |||
| 12378c3649 | |||
| 46b8488b09 | |||
| 7e02f15a85 | |||
| d60c47199c | |||
| 2484f1e603 | |||
| 44d7df33a2 | |||
| 7cfa234902 | |||
| 3ee75305a9 | |||
| 322e9ee81a | |||
| 96ce5f7d26 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -14,6 +14,7 @@
|
||||
.worktrees/
|
||||
.superpowers/
|
||||
docs/
|
||||
!docs/adr/
|
||||
|
||||
imgs/
|
||||
.sqlx/
|
||||
@@ -40,6 +40,10 @@ _Avoid_: Stats page, recap, summary
|
||||
A registered account with a username, email, and profile (display name, bio, avatar, banner). Can be Standard or Admin.
|
||||
_Avoid_: Account, member, profile (as a synonym for the whole User)
|
||||
|
||||
**SocialIdentity**:
|
||||
The uniform identifier for anyone involved in a social interaction — either a local User or a remote federated actor. Social commands and queries operate on SocialIdentity so the domain never branches on local vs remote.
|
||||
_Avoid_: Actor, participant, social user
|
||||
|
||||
**Follow**:
|
||||
A social relationship where one user subscribes to another's activity. Always requires acceptance by the target user. Works identically for local and federated (ActivityPub) users. Once accepted, the followed user's reviews appear in the follower's Feed.
|
||||
_Avoid_: Subscribe, connect, friend
|
||||
|
||||
6
Cargo.lock
generated
6
Cargo.lock
generated
@@ -2892,9 +2892,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "k-ap"
|
||||
version = "0.4.1"
|
||||
version = "0.4.2"
|
||||
source = "sparse+https://git.gabrielkaszewski.dev/api/packages/GKaszewski/cargo/"
|
||||
checksum = "03e39c04075b39960c329feba896a16aba37f0863669c28e7106b7cc45a9988d"
|
||||
checksum = "4291cac43b119cce0be6e2ba8d85339f3f4c69b266cb7c00fb4cb179302b97e4"
|
||||
dependencies = [
|
||||
"activitypub_federation",
|
||||
"anyhow",
|
||||
@@ -6714,7 +6714,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.48.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -4,7 +4,7 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
k-ap = { version = "0.4.1", registry = "gitea" }
|
||||
k-ap = { version = "0.4.2", registry = "gitea" }
|
||||
domain = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
|
||||
@@ -46,6 +46,28 @@ impl k_ap::EventPublisher for FederationEventBridge {
|
||||
tracing::warn!(inbox = %inbox, error = %error, "federation delivery failed permanently");
|
||||
Ok(())
|
||||
}
|
||||
FederationEvent::OutboundFollowAccepted {
|
||||
local_user_id,
|
||||
remote_actor_url,
|
||||
outbox_url,
|
||||
} => {
|
||||
let identity = domain::value_objects::SocialIdentity::Remote {
|
||||
actor_url: remote_actor_url,
|
||||
};
|
||||
self.domain_publisher
|
||||
.publish(&DomainEvent::FollowAccepted {
|
||||
owner: UserId::from_uuid(local_user_id),
|
||||
requester: identity,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
|
||||
|
||||
if let Some(outbox) = outbox_url {
|
||||
tracing::info!(outbox = %outbox, "importing remote outbox after follow accepted");
|
||||
// Handled by FollowBackfillHandler reacting to FollowAccepted
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ pub mod objects;
|
||||
pub mod port;
|
||||
pub mod remote_review_repository;
|
||||
pub mod review_handler;
|
||||
pub mod social_adapter;
|
||||
pub(crate) mod urls;
|
||||
pub mod user_adapter;
|
||||
pub mod watchlist_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 = (
|
||||
@@ -31,7 +33,7 @@ pub type FederationRepos = (
|
||||
std::sync::Arc<dyn FollowRepository>,
|
||||
std::sync::Arc<dyn ActorRepository>,
|
||||
std::sync::Arc<dyn BlocklistRepository>,
|
||||
std::sync::Arc<dyn domain::ports::SocialQueryPort>,
|
||||
std::sync::Arc<dyn domain::ports::FederationAdminQuery>,
|
||||
std::sync::Arc<dyn RemoteReviewRepository>,
|
||||
std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>,
|
||||
);
|
||||
|
||||
245
crates/adapters/activitypub/src/social_adapter.rs
Normal file
245
crates/adapters/activitypub/src/social_adapter.rs
Normal file
@@ -0,0 +1,245 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
ports::{SocialCommand, SocialQuery, UserRepository},
|
||||
value_objects::{FollowTarget, SocialActor, SocialIdentity, UserId},
|
||||
};
|
||||
|
||||
use k_ap::RemoteActor;
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
fn remote_actor_to_social_actor(&self, actor: RemoteActor) -> SocialActor {
|
||||
let identity = self.identity_from_actor_url(&actor.url);
|
||||
SocialActor {
|
||||
identity,
|
||||
handle: actor.handle,
|
||||
display_name: actor.display_name,
|
||||
avatar_url: actor.avatar_url,
|
||||
}
|
||||
}
|
||||
|
||||
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: &FollowTarget) -> Result<(), DomainError> {
|
||||
if let FollowTarget::Identity(SocialIdentity::Local(target_id)) = target
|
||||
&& follower == target_id
|
||||
{
|
||||
return Err(DomainError::ValidationError(
|
||||
"Cannot follow yourself".into(),
|
||||
));
|
||||
}
|
||||
let handle = match target {
|
||||
FollowTarget::Handle(h) => h.clone(),
|
||||
FollowTarget::Identity(id) => self.resolve_handle(id).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<SocialActor>, DomainError> {
|
||||
let actors = self
|
||||
.ap_service
|
||||
.get_following(user.value())
|
||||
.await
|
||||
.map_err(ap_err)?;
|
||||
Ok(actors
|
||||
.into_iter()
|
||||
.map(|a| self.remote_actor_to_social_actor(a))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_followers(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
||||
let actors = self
|
||||
.ap_service
|
||||
.get_accepted_followers(user.value())
|
||||
.await
|
||||
.map_err(ap_err)?;
|
||||
Ok(actors
|
||||
.into_iter()
|
||||
.map(|a| self.remote_actor_to_social_actor(a))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_pending_followers(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
||||
let actors = self
|
||||
.ap_service
|
||||
.get_pending_followers(user.value())
|
||||
.await
|
||||
.map_err(ap_err)?;
|
||||
Ok(actors
|
||||
.into_iter()
|
||||
.map(|a| self.remote_actor_to_social_actor(a))
|
||||
.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<SocialActor>, DomainError> {
|
||||
let actors = self
|
||||
.ap_service
|
||||
.get_blocked_actors(user.value())
|
||||
.await
|
||||
.map_err(ap_err)?;
|
||||
Ok(actors
|
||||
.into_iter()
|
||||
.map(|a| self.remote_actor_to_social_actor(a))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn is_following(
|
||||
&self,
|
||||
follower: &UserId,
|
||||
target: &SocialIdentity,
|
||||
) -> Result<bool, DomainError> {
|
||||
let following = self.get_following(follower).await?;
|
||||
Ok(following.iter().any(|a| a.identity == *target))
|
||||
}
|
||||
}
|
||||
@@ -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,40 @@ 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,
|
||||
},
|
||||
FollowRejected {
|
||||
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 +167,13 @@ impl EventPayload {
|
||||
EventPayload::ImageStored { .. } => "ImageStored",
|
||||
EventPayload::WatchlistEntryAdded { .. } => "WatchlistEntryAdded",
|
||||
EventPayload::WatchlistEntryRemoved { .. } => "WatchlistEntryRemoved",
|
||||
EventPayload::FollowRequested { .. } => "FollowRequested",
|
||||
EventPayload::FollowAccepted { .. } => "FollowAccepted",
|
||||
EventPayload::FollowRejected { .. } => "FollowRejected",
|
||||
EventPayload::Unfollowed { .. } => "Unfollowed",
|
||||
EventPayload::FollowerRemoved { .. } => "FollowerRemoved",
|
||||
EventPayload::ActorBlocked { .. } => "ActorBlocked",
|
||||
EventPayload::ActorUnblocked { .. } => "ActorUnblocked",
|
||||
EventPayload::BackfillFollower { .. } => "BackfillFollower",
|
||||
EventPayload::FederationDeliveryRequested { .. } => "FederationDeliveryRequested",
|
||||
EventPayload::WatchEventIngested { .. } => "WatchEventIngested",
|
||||
@@ -158,6 +195,44 @@ 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 follow_target_to_payload(target: &domain::value_objects::FollowTarget) -> (String, String) {
|
||||
match target {
|
||||
domain::value_objects::FollowTarget::Identity(id) => identity_to_payload(id),
|
||||
domain::value_objects::FollowTarget::Handle(h) => ("handle".into(), h.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 payload_to_follow_target(
|
||||
kind: &str,
|
||||
id: String,
|
||||
) -> Result<domain::value_objects::FollowTarget, DomainError> {
|
||||
match kind {
|
||||
"handle" => Ok(domain::value_objects::FollowTarget::Handle(id)),
|
||||
other => Ok(domain::value_objects::FollowTarget::Identity(
|
||||
payload_to_identity(other, id)?,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_ts(ts: i64) -> Result<NaiveDateTime, DomainError> {
|
||||
chrono::DateTime::from_timestamp(ts, 0)
|
||||
.map(|dt| dt.naive_utc())
|
||||
@@ -243,15 +318,62 @@ 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) = follow_target_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::FollowRejected { owner, requester } => {
|
||||
let (kind, id) = identity_to_payload(requester);
|
||||
EventPayload::FollowRejected {
|
||||
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 +557,61 @@ 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_follow_target(&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::FollowRejected {
|
||||
owner_id,
|
||||
requester_kind,
|
||||
requester_id,
|
||||
} => Ok(DomainEvent::FollowRejected {
|
||||
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,
|
||||
|
||||
@@ -12,7 +12,13 @@ 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::FollowRejected { .. } => "follow.rejected",
|
||||
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",
|
||||
|
||||
@@ -1,26 +1,10 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{PendingFollowerInfo, RemoteActorInfo},
|
||||
ports::SocialQueryPort,
|
||||
value_objects::UserId,
|
||||
};
|
||||
use domain::{errors::DomainError, models::RemoteActorInfo, ports::FederationAdminQuery};
|
||||
|
||||
use super::PostgresFederationRepository;
|
||||
|
||||
#[async_trait]
|
||||
impl SocialQueryPort for PostgresFederationRepository {
|
||||
async fn get_accepted_following_urls(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
) -> Result<Vec<String>, DomainError> {
|
||||
let user_id_str = user_id.value().to_string();
|
||||
sqlx::query_scalar::<_, String>(
|
||||
"SELECT remote_actor_url FROM ap_following WHERE local_user_id = $1 AND status = 'accepted'",
|
||||
).bind(&user_id_str).fetch_all(&self.pool).await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
||||
}
|
||||
|
||||
impl FederationAdminQuery for PostgresFederationRepository {
|
||||
async fn list_all_followed_remote_actors(&self) -> Result<Vec<RemoteActorInfo>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, (String, String, Option<String>)>(
|
||||
"SELECT DISTINCT ar.url, ar.handle, ar.display_name FROM ap_remote_actors ar JOIN ap_following f ON f.remote_actor_url = ar.url WHERE f.status = 'accepted'",
|
||||
@@ -34,49 +18,4 @@ impl SocialQueryPort for PostgresFederationRepository {
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn count_following(&self, user_id: &UserId) -> Result<usize, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM ap_following WHERE local_user_id = $1 AND status = 'accepted'",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(count as usize)
|
||||
}
|
||||
|
||||
async fn count_accepted_followers(&self, user_id: &UserId) -> Result<usize, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM ap_followers WHERE local_user_id = $1 AND status = 'accepted'",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(count as usize)
|
||||
}
|
||||
|
||||
async fn get_pending_followers(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
) -> Result<Vec<PendingFollowerInfo>, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let rows = sqlx::query_as::<_, (String, String, Option<String>, Option<String>)>(
|
||||
"SELECT ar.url, ar.handle, ar.display_name, ar.avatar_url FROM ap_followers f JOIN ap_remote_actors ar ON ar.url = f.remote_actor_url WHERE f.local_user_id = $1 AND f.status = 'pending'",
|
||||
).bind(&uid).fetch_all(&self.pool).await.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(
|
||||
|(url, handle, display_name, avatar_url)| PendingFollowerInfo {
|
||||
url,
|
||||
handle,
|
||||
display_name,
|
||||
avatar_url,
|
||||
},
|
||||
)
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,10 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{PendingFollowerInfo, RemoteActorInfo},
|
||||
ports::SocialQueryPort,
|
||||
value_objects::UserId,
|
||||
};
|
||||
use domain::{errors::DomainError, models::RemoteActorInfo, ports::FederationAdminQuery};
|
||||
|
||||
use super::SqliteFederationRepository;
|
||||
|
||||
#[async_trait]
|
||||
impl SocialQueryPort for SqliteFederationRepository {
|
||||
async fn get_accepted_following_urls(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
) -> Result<Vec<String>, DomainError> {
|
||||
let user_id_str = user_id.value().to_string();
|
||||
sqlx::query_scalar::<_, String>(
|
||||
"SELECT remote_actor_url FROM ap_following WHERE local_user_id = ? AND status = 'accepted'",
|
||||
).bind(&user_id_str).fetch_all(&self.pool).await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
||||
}
|
||||
|
||||
impl FederationAdminQuery for SqliteFederationRepository {
|
||||
async fn list_all_followed_remote_actors(&self) -> Result<Vec<RemoteActorInfo>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, (String, String, Option<String>)>(
|
||||
"SELECT DISTINCT ar.url, ar.handle, ar.display_name
|
||||
@@ -40,56 +24,4 @@ impl SocialQueryPort for SqliteFederationRepository {
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn count_following(&self, user_id: &UserId) -> Result<usize, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM ap_following WHERE local_user_id = ? AND status = 'accepted'",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(count as usize)
|
||||
}
|
||||
|
||||
async fn count_accepted_followers(&self, user_id: &UserId) -> Result<usize, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM ap_followers WHERE local_user_id = ? AND status = 'accepted'",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(count as usize)
|
||||
}
|
||||
|
||||
async fn get_pending_followers(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
) -> Result<Vec<PendingFollowerInfo>, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let rows = sqlx::query_as::<_, (String, String, Option<String>, Option<String>)>(
|
||||
"SELECT ar.url, ar.handle, ar.display_name, ar.avatar_url
|
||||
FROM ap_followers f
|
||||
JOIN ap_remote_actors ar ON ar.url = f.remote_actor_url
|
||||
WHERE f.local_user_id = ? AND f.status = 'pending'",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(
|
||||
|(url, handle, display_name, avatar_url)| PendingFollowerInfo {
|
||||
url,
|
||||
handle,
|
||||
display_name,
|
||||
avatar_url,
|
||||
},
|
||||
)
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::*;
|
||||
use chrono::Utc;
|
||||
use domain::ports::SocialQueryPort;
|
||||
use domain::ports::FederationAdminQuery;
|
||||
use k_ap::ActorRepository;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
@@ -79,30 +79,6 @@ async fn setup_db(pool: &SqlitePool) {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_accepted_following_urls_returns_only_accepted() {
|
||||
let pool = SqlitePool::connect(":memory:").await.unwrap();
|
||||
setup_db(&pool).await;
|
||||
let repo = SqliteFederationRepository::new(pool.clone());
|
||||
let user_id = uuid::Uuid::new_v4();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, status)
|
||||
VALUES (?, 'https://other.social/users/alice', 'act1', 'accepted'),
|
||||
(?, 'https://other.social/users/bob', 'act2', 'pending')",
|
||||
)
|
||||
.bind(user_id.to_string())
|
||||
.bind(user_id.to_string())
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let uid = domain::value_objects::UserId::from_uuid(user_id);
|
||||
let urls = repo.get_accepted_following_urls(&uid).await.unwrap();
|
||||
assert_eq!(urls.len(), 1);
|
||||
assert_eq!(urls[0], "https://other.social/users/alice");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_all_followed_remote_actors_deduplicates() {
|
||||
let pool = SqlitePool::connect(":memory:").await.unwrap();
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::sync::Arc;
|
||||
|
||||
use domain::ports::{
|
||||
DiaryQuery, EventPublisher, MovieCommand, MovieProfileRepository, MovieQuery, ReviewRepository,
|
||||
SocialQueryPort,
|
||||
SocialQuery,
|
||||
};
|
||||
|
||||
use crate::config::AppConfig;
|
||||
@@ -27,6 +27,6 @@ pub struct GetMovieSocialPageDeps {
|
||||
|
||||
pub struct GetActivityFeedDeps {
|
||||
pub diary: Arc<dyn DiaryQuery>,
|
||||
pub social_query: Arc<dyn SocialQueryPort>,
|
||||
pub social_query: Arc<dyn SocialQuery>,
|
||||
pub config: AppConfig,
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ use domain::{
|
||||
FeedEntry,
|
||||
collections::{PageParams, Paginated},
|
||||
},
|
||||
value_objects::UserId,
|
||||
value_objects::{SocialIdentity, UserId},
|
||||
};
|
||||
|
||||
pub async fn execute(
|
||||
@@ -36,28 +36,24 @@ async fn build_following_filter(
|
||||
}
|
||||
let viewer_id = query.viewer_user_id?;
|
||||
let viewer = UserId::from_uuid(viewer_id);
|
||||
let urls = deps
|
||||
let actors = deps
|
||||
.social_query
|
||||
.get_accepted_following_urls(&viewer)
|
||||
.get_following(&viewer)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
if urls.is_empty() {
|
||||
if actors.is_empty() {
|
||||
return Some(FollowingFilter {
|
||||
local_user_ids: vec![viewer_id],
|
||||
remote_actor_urls: vec![],
|
||||
});
|
||||
}
|
||||
let base_url = &deps.config.base_url;
|
||||
let mut local_ids = vec![viewer_id];
|
||||
let mut remote_urls = Vec::new();
|
||||
for url in urls {
|
||||
if let Some(suffix) = url.strip_prefix(&format!("{}/users/", base_url))
|
||||
&& let Ok(parsed_id) = uuid::Uuid::parse_str(suffix)
|
||||
{
|
||||
local_ids.push(parsed_id);
|
||||
continue;
|
||||
for actor in actors {
|
||||
match actor.identity {
|
||||
SocialIdentity::Local(uid) => local_ids.push(uid.value()),
|
||||
SocialIdentity::Remote { actor_url } => remote_urls.push(actor_url),
|
||||
}
|
||||
remote_urls.push(url);
|
||||
}
|
||||
Some(FollowingFilter {
|
||||
local_user_ids: local_ids,
|
||||
|
||||
@@ -2,7 +2,8 @@ use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::errors::DomainError;
|
||||
use domain::testing::{FakeDiaryQuery, NoopSocialQueryPort};
|
||||
use domain::testing::InMemorySocialRepository;
|
||||
use domain::value_objects::{SocialActor, SocialIdentity, UserId};
|
||||
|
||||
use crate::{
|
||||
config::AppConfig, diary::deps::GetActivityFeedDeps, diary::get_activity_feed,
|
||||
@@ -11,8 +12,8 @@ use crate::{
|
||||
|
||||
fn default_deps() -> GetActivityFeedDeps {
|
||||
GetActivityFeedDeps {
|
||||
diary: FakeDiaryQuery::new() as _,
|
||||
social_query: Arc::new(NoopSocialQueryPort),
|
||||
diary: domain::testing::FakeDiaryQuery::new() as _,
|
||||
social_query: InMemorySocialRepository::new() as _,
|
||||
config: TestContextBuilder::new().config,
|
||||
}
|
||||
}
|
||||
@@ -59,60 +60,62 @@ async fn returns_feed_with_following_filter() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// NoopSocialQueryPort returns empty following, so FollowingFilter
|
||||
// contains only the viewer's id. Feed is empty but the code path is hit.
|
||||
assert!(result.items.is_empty());
|
||||
}
|
||||
|
||||
struct FakeSocialWithFollowing(Vec<String>);
|
||||
struct FakeSocialWithFollowing(Vec<SocialActor>);
|
||||
|
||||
#[async_trait]
|
||||
impl domain::ports::SocialQueryPort for FakeSocialWithFollowing {
|
||||
async fn get_accepted_following_urls(
|
||||
&self,
|
||||
_: &domain::value_objects::UserId,
|
||||
) -> Result<Vec<String>, DomainError> {
|
||||
impl domain::ports::SocialQuery for FakeSocialWithFollowing {
|
||||
async fn get_following(&self, _: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
||||
Ok(self.0.clone())
|
||||
}
|
||||
async fn count_following(
|
||||
&self,
|
||||
_: &domain::value_objects::UserId,
|
||||
) -> Result<usize, DomainError> {
|
||||
Ok(0)
|
||||
}
|
||||
async fn count_accepted_followers(
|
||||
&self,
|
||||
_: &domain::value_objects::UserId,
|
||||
) -> Result<usize, DomainError> {
|
||||
Ok(0)
|
||||
}
|
||||
async fn get_pending_followers(
|
||||
&self,
|
||||
_: &domain::value_objects::UserId,
|
||||
) -> Result<Vec<domain::models::PendingFollowerInfo>, DomainError> {
|
||||
async fn get_followers(&self, _: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn list_all_followed_remote_actors(
|
||||
&self,
|
||||
) -> Result<Vec<domain::models::RemoteActorInfo>, DomainError> {
|
||||
async fn get_pending_followers(&self, _: &UserId) -> Result<Vec<SocialActor>, 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<SocialActor>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn is_following(&self, _: &UserId, _: &SocialIdentity) -> Result<bool, DomainError> {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn following_filter_parses_local_and_remote_urls() {
|
||||
async fn following_filter_separates_local_and_remote() {
|
||||
let viewer = uuid::Uuid::new_v4();
|
||||
let local_friend = uuid::Uuid::new_v4();
|
||||
|
||||
let following_urls = vec![
|
||||
format!("http://localhost:3000/users/{}", local_friend),
|
||||
"https://remote.example/actor/1".to_string(),
|
||||
let following = vec![
|
||||
SocialActor {
|
||||
identity: SocialIdentity::Local(UserId::from_uuid(local_friend)),
|
||||
handle: "friend".into(),
|
||||
display_name: None,
|
||||
avatar_url: None,
|
||||
},
|
||||
SocialActor {
|
||||
identity: SocialIdentity::Remote {
|
||||
actor_url: "https://remote.example/actor/1".into(),
|
||||
},
|
||||
handle: "@alice@remote.example".into(),
|
||||
display_name: None,
|
||||
avatar_url: None,
|
||||
},
|
||||
];
|
||||
|
||||
let social = Arc::new(FakeSocialWithFollowing(following_urls));
|
||||
let social = Arc::new(FakeSocialWithFollowing(following));
|
||||
|
||||
let deps = GetActivityFeedDeps {
|
||||
diary: FakeDiaryQuery::new() as _,
|
||||
diary: domain::testing::FakeDiaryQuery::new() as _,
|
||||
social_query: social as _,
|
||||
config: AppConfig {
|
||||
allow_registration: true,
|
||||
@@ -141,8 +144,6 @@ async fn following_filter_parses_local_and_remote_urls() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Feed is empty (no data seeded), but the build_following_filter code path
|
||||
// with actual URL parsing ran without errors.
|
||||
assert!(result.items.is_empty());
|
||||
}
|
||||
|
||||
@@ -164,6 +165,5 @@ async fn following_filter_without_viewer_returns_none() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// filter_following=true but viewer_user_id=None → build_following_filter returns None
|
||||
assert!(result.items.is_empty());
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod integrations;
|
||||
pub mod movies;
|
||||
pub mod person;
|
||||
pub mod search;
|
||||
pub mod social;
|
||||
pub mod users;
|
||||
pub mod watchlist;
|
||||
pub mod wrapup;
|
||||
|
||||
33
crates/application/src/social/commands.rs
Normal file
33
crates/application/src/social/commands.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use domain::value_objects::{FollowTarget, SocialIdentity};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub enum SocialCmd {
|
||||
Follow {
|
||||
follower_id: Uuid,
|
||||
target: FollowTarget,
|
||||
},
|
||||
Unfollow {
|
||||
follower_id: Uuid,
|
||||
target: SocialIdentity,
|
||||
},
|
||||
AcceptFollow {
|
||||
owner_id: Uuid,
|
||||
requester: SocialIdentity,
|
||||
},
|
||||
RejectFollow {
|
||||
owner_id: Uuid,
|
||||
requester: SocialIdentity,
|
||||
},
|
||||
RemoveFollower {
|
||||
owner_id: Uuid,
|
||||
follower: SocialIdentity,
|
||||
},
|
||||
Block {
|
||||
blocker_id: Uuid,
|
||||
target: SocialIdentity,
|
||||
},
|
||||
Unblock {
|
||||
blocker_id: Uuid,
|
||||
target: SocialIdentity,
|
||||
},
|
||||
}
|
||||
13
crates/application/src/social/deps.rs
Normal file
13
crates/application/src/social/deps.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{EventPublisher, SocialCommand, SocialQuery};
|
||||
|
||||
pub struct SocialCommandDeps {
|
||||
pub social_command: Arc<dyn SocialCommand>,
|
||||
pub social_query: Arc<dyn SocialQuery>,
|
||||
pub event_publisher: Arc<dyn EventPublisher>,
|
||||
}
|
||||
|
||||
pub struct SocialQueryDeps {
|
||||
pub social_query: Arc<dyn SocialQuery>,
|
||||
}
|
||||
92
crates/application/src/social/execute.rs
Normal file
92
crates/application/src/social/execute.rs
Normal file
@@ -0,0 +1,92 @@
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
value_objects::{SocialActor, UserId},
|
||||
};
|
||||
|
||||
use super::{
|
||||
commands::SocialCmd,
|
||||
deps::{SocialCommandDeps, SocialQueryDeps},
|
||||
queries::SocialQry,
|
||||
};
|
||||
|
||||
pub async fn execute_command(deps: &SocialCommandDeps, cmd: SocialCmd) -> Result<(), DomainError> {
|
||||
let event = match cmd {
|
||||
SocialCmd::Follow {
|
||||
follower_id,
|
||||
target,
|
||||
} => {
|
||||
let follower = UserId::from_uuid(follower_id);
|
||||
deps.social_command.follow(&follower, &target).await?;
|
||||
DomainEvent::FollowRequested { follower, target }
|
||||
}
|
||||
SocialCmd::Unfollow {
|
||||
follower_id,
|
||||
target,
|
||||
} => {
|
||||
let follower = UserId::from_uuid(follower_id);
|
||||
deps.social_command.unfollow(&follower, &target).await?;
|
||||
DomainEvent::Unfollowed { follower, target }
|
||||
}
|
||||
SocialCmd::AcceptFollow {
|
||||
owner_id,
|
||||
requester,
|
||||
} => {
|
||||
let owner = UserId::from_uuid(owner_id);
|
||||
deps.social_command
|
||||
.accept_follow(&owner, &requester)
|
||||
.await?;
|
||||
DomainEvent::FollowAccepted { owner, requester }
|
||||
}
|
||||
SocialCmd::RejectFollow {
|
||||
owner_id,
|
||||
requester,
|
||||
} => {
|
||||
let owner = UserId::from_uuid(owner_id);
|
||||
deps.social_command
|
||||
.reject_follow(&owner, &requester)
|
||||
.await?;
|
||||
DomainEvent::FollowRejected { owner, requester }
|
||||
}
|
||||
SocialCmd::RemoveFollower { owner_id, follower } => {
|
||||
let owner = UserId::from_uuid(owner_id);
|
||||
deps.social_command
|
||||
.remove_follower(&owner, &follower)
|
||||
.await?;
|
||||
DomainEvent::FollowerRemoved { owner, follower }
|
||||
}
|
||||
SocialCmd::Block { blocker_id, target } => {
|
||||
let blocker = UserId::from_uuid(blocker_id);
|
||||
deps.social_command.block(&blocker, &target).await?;
|
||||
DomainEvent::ActorBlocked { blocker, target }
|
||||
}
|
||||
SocialCmd::Unblock { blocker_id, target } => {
|
||||
let blocker = UserId::from_uuid(blocker_id);
|
||||
deps.social_command.unblock(&blocker, &target).await?;
|
||||
DomainEvent::ActorUnblocked { blocker, target }
|
||||
}
|
||||
};
|
||||
deps.event_publisher.publish(&event).await
|
||||
}
|
||||
|
||||
pub async fn execute_query(
|
||||
deps: &SocialQueryDeps,
|
||||
query: SocialQry,
|
||||
) -> Result<Vec<SocialActor>, DomainError> {
|
||||
let user_id = match &query {
|
||||
SocialQry::GetFollowing { user_id }
|
||||
| SocialQry::GetFollowers { user_id }
|
||||
| SocialQry::GetPending { user_id }
|
||||
| SocialQry::GetBlocked { user_id } => UserId::from_uuid(*user_id),
|
||||
};
|
||||
match query {
|
||||
SocialQry::GetFollowing { .. } => deps.social_query.get_following(&user_id).await,
|
||||
SocialQry::GetFollowers { .. } => deps.social_query.get_followers(&user_id).await,
|
||||
SocialQry::GetPending { .. } => deps.social_query.get_pending_followers(&user_id).await,
|
||||
SocialQry::GetBlocked { .. } => deps.social_query.get_blocked(&user_id).await,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/execute.rs"]
|
||||
mod tests;
|
||||
4
crates/application/src/social/mod.rs
Normal file
4
crates/application/src/social/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod commands;
|
||||
pub mod deps;
|
||||
pub mod execute;
|
||||
pub mod queries;
|
||||
8
crates/application/src/social/queries.rs
Normal file
8
crates/application/src/social/queries.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
pub enum SocialQry {
|
||||
GetFollowing { user_id: Uuid },
|
||||
GetFollowers { user_id: Uuid },
|
||||
GetPending { user_id: Uuid },
|
||||
GetBlocked { user_id: Uuid },
|
||||
}
|
||||
449
crates/application/src/social/tests/execute.rs
Normal file
449
crates/application/src/social/tests/execute.rs
Normal file
@@ -0,0 +1,449 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::{
|
||||
events::DomainEvent,
|
||||
testing::{InMemorySocialRepository, NoopEventPublisher},
|
||||
value_objects::{FollowTarget, SocialIdentity, UserId},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::social::{
|
||||
commands::SocialCmd,
|
||||
deps::{SocialCommandDeps, SocialQueryDeps},
|
||||
execute::{execute_command, execute_query},
|
||||
queries::SocialQry,
|
||||
};
|
||||
|
||||
fn make_cmd_deps() -> (
|
||||
Arc<InMemorySocialRepository>,
|
||||
Arc<NoopEventPublisher>,
|
||||
SocialCommandDeps,
|
||||
) {
|
||||
let social = InMemorySocialRepository::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let deps = SocialCommandDeps {
|
||||
social_command: Arc::clone(&social) as _,
|
||||
social_query: Arc::clone(&social) as _,
|
||||
event_publisher: Arc::clone(&events) as _,
|
||||
};
|
||||
(social, events, deps)
|
||||
}
|
||||
|
||||
// ── Follow ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn follow_emits_follow_requested_event() {
|
||||
let (_social, events, deps) = make_cmd_deps();
|
||||
|
||||
execute_command(
|
||||
&deps,
|
||||
SocialCmd::Follow {
|
||||
follower_id: Uuid::new_v4(),
|
||||
target: FollowTarget::Identity(SocialIdentity::Local(
|
||||
UserId::from_uuid(Uuid::new_v4()),
|
||||
)),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let published = events.published();
|
||||
assert!(
|
||||
published
|
||||
.iter()
|
||||
.any(|e| matches!(e, DomainEvent::FollowRequested { .. }))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cannot_follow_yourself() {
|
||||
let (_social, _events, deps) = make_cmd_deps();
|
||||
let user_id = Uuid::new_v4();
|
||||
|
||||
let result = execute_command(
|
||||
&deps,
|
||||
SocialCmd::Follow {
|
||||
follower_id: user_id,
|
||||
target: FollowTarget::Identity(SocialIdentity::Local(UserId::from_uuid(user_id))),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cannot_follow_same_target_twice() {
|
||||
let (_social, _events, deps) = make_cmd_deps();
|
||||
let follower_id = Uuid::new_v4();
|
||||
let target = FollowTarget::Identity(SocialIdentity::Local(UserId::from_uuid(Uuid::new_v4())));
|
||||
|
||||
execute_command(
|
||||
&deps,
|
||||
SocialCmd::Follow {
|
||||
follower_id,
|
||||
target: target.clone(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result = execute_command(
|
||||
&deps,
|
||||
SocialCmd::Follow {
|
||||
follower_id,
|
||||
target,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// ── Unfollow ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn unfollow_emits_unfollowed_event() {
|
||||
let (_social, events, deps) = make_cmd_deps();
|
||||
let follower_id = Uuid::new_v4();
|
||||
let target = SocialIdentity::Local(UserId::from_uuid(Uuid::new_v4()));
|
||||
|
||||
execute_command(
|
||||
&deps,
|
||||
SocialCmd::Follow {
|
||||
follower_id,
|
||||
target: FollowTarget::Identity(target.clone()),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
execute_command(
|
||||
&deps,
|
||||
SocialCmd::Unfollow {
|
||||
follower_id,
|
||||
target,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let published = events.published();
|
||||
assert!(
|
||||
published
|
||||
.iter()
|
||||
.any(|e| matches!(e, DomainEvent::Unfollowed { .. }))
|
||||
);
|
||||
}
|
||||
|
||||
// ── Accept ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn accept_follow_emits_follow_accepted_event() {
|
||||
let (_social, events, deps) = make_cmd_deps();
|
||||
let follower_id = Uuid::new_v4();
|
||||
let owner_id = Uuid::new_v4();
|
||||
let requester = SocialIdentity::Local(UserId::from_uuid(follower_id));
|
||||
|
||||
execute_command(
|
||||
&deps,
|
||||
SocialCmd::Follow {
|
||||
follower_id,
|
||||
target: FollowTarget::Identity(SocialIdentity::Local(UserId::from_uuid(owner_id))),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
execute_command(
|
||||
&deps,
|
||||
SocialCmd::AcceptFollow {
|
||||
owner_id,
|
||||
requester,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let published = events.published();
|
||||
assert!(
|
||||
published
|
||||
.iter()
|
||||
.any(|e| matches!(e, DomainEvent::FollowAccepted { .. }))
|
||||
);
|
||||
}
|
||||
|
||||
// ── Reject ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn reject_follow_emits_follow_rejected_event() {
|
||||
let (_social, events, deps) = make_cmd_deps();
|
||||
let follower_id = Uuid::new_v4();
|
||||
let owner_id = Uuid::new_v4();
|
||||
|
||||
execute_command(
|
||||
&deps,
|
||||
SocialCmd::Follow {
|
||||
follower_id,
|
||||
target: FollowTarget::Identity(SocialIdentity::Local(UserId::from_uuid(owner_id))),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
execute_command(
|
||||
&deps,
|
||||
SocialCmd::RejectFollow {
|
||||
owner_id,
|
||||
requester: SocialIdentity::Local(UserId::from_uuid(follower_id)),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let published = events.published();
|
||||
assert!(
|
||||
published
|
||||
.iter()
|
||||
.any(|e| matches!(e, DomainEvent::FollowRejected { .. }))
|
||||
);
|
||||
}
|
||||
|
||||
// ── Remove follower ─────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn remove_follower_emits_follower_removed_event() {
|
||||
let (_social, events, deps) = make_cmd_deps();
|
||||
let follower_id = Uuid::new_v4();
|
||||
let owner_id = Uuid::new_v4();
|
||||
|
||||
execute_command(
|
||||
&deps,
|
||||
SocialCmd::Follow {
|
||||
follower_id,
|
||||
target: FollowTarget::Identity(SocialIdentity::Local(UserId::from_uuid(owner_id))),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
execute_command(
|
||||
&deps,
|
||||
SocialCmd::AcceptFollow {
|
||||
owner_id,
|
||||
requester: SocialIdentity::Local(UserId::from_uuid(follower_id)),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
execute_command(
|
||||
&deps,
|
||||
SocialCmd::RemoveFollower {
|
||||
owner_id,
|
||||
follower: SocialIdentity::Local(UserId::from_uuid(follower_id)),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let published = events.published();
|
||||
assert!(
|
||||
published
|
||||
.iter()
|
||||
.any(|e| matches!(e, DomainEvent::FollowerRemoved { .. }))
|
||||
);
|
||||
}
|
||||
|
||||
// ── Block ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn block_emits_actor_blocked_event() {
|
||||
let (_social, events, deps) = make_cmd_deps();
|
||||
|
||||
execute_command(
|
||||
&deps,
|
||||
SocialCmd::Block {
|
||||
blocker_id: Uuid::new_v4(),
|
||||
target: SocialIdentity::Local(UserId::from_uuid(Uuid::new_v4())),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let published = events.published();
|
||||
assert!(
|
||||
published
|
||||
.iter()
|
||||
.any(|e| matches!(e, DomainEvent::ActorBlocked { .. }))
|
||||
);
|
||||
}
|
||||
|
||||
// ── Unblock ─────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn unblock_emits_actor_unblocked_event() {
|
||||
let (_social, events, deps) = make_cmd_deps();
|
||||
let target = SocialIdentity::Local(UserId::from_uuid(Uuid::new_v4()));
|
||||
let blocker_id = Uuid::new_v4();
|
||||
|
||||
execute_command(
|
||||
&deps,
|
||||
SocialCmd::Block {
|
||||
blocker_id,
|
||||
target: target.clone(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
execute_command(&deps, SocialCmd::Unblock { blocker_id, target })
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let published = events.published();
|
||||
assert!(
|
||||
published
|
||||
.iter()
|
||||
.any(|e| matches!(e, DomainEvent::ActorUnblocked { .. }))
|
||||
);
|
||||
}
|
||||
|
||||
// ── Get following ───────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_accepted_follows() {
|
||||
let social = InMemorySocialRepository::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let cmd_deps = SocialCommandDeps {
|
||||
social_command: Arc::clone(&social) as _,
|
||||
social_query: Arc::clone(&social) as _,
|
||||
event_publisher: Arc::clone(&events) as _,
|
||||
};
|
||||
let query_deps = SocialQueryDeps {
|
||||
social_query: Arc::clone(&social) as _,
|
||||
};
|
||||
|
||||
let follower_id = Uuid::new_v4();
|
||||
let target_id = Uuid::new_v4();
|
||||
|
||||
execute_command(
|
||||
&cmd_deps,
|
||||
SocialCmd::Follow {
|
||||
follower_id,
|
||||
target: FollowTarget::Identity(SocialIdentity::Local(UserId::from_uuid(target_id))),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Pending follow should not appear
|
||||
let following = execute_query(
|
||||
&query_deps,
|
||||
SocialQry::GetFollowing {
|
||||
user_id: follower_id,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(following.is_empty());
|
||||
|
||||
// Accept, then it should appear
|
||||
execute_command(
|
||||
&cmd_deps,
|
||||
SocialCmd::AcceptFollow {
|
||||
owner_id: target_id,
|
||||
requester: SocialIdentity::Local(UserId::from_uuid(follower_id)),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let following = execute_query(
|
||||
&query_deps,
|
||||
SocialQry::GetFollowing {
|
||||
user_id: follower_id,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(following.len(), 1);
|
||||
}
|
||||
|
||||
// ── Get followers ───────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_accepted_followers() {
|
||||
let social = InMemorySocialRepository::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let cmd_deps = SocialCommandDeps {
|
||||
social_command: Arc::clone(&social) as _,
|
||||
social_query: Arc::clone(&social) as _,
|
||||
event_publisher: Arc::clone(&events) as _,
|
||||
};
|
||||
let query_deps = SocialQueryDeps {
|
||||
social_query: Arc::clone(&social) as _,
|
||||
};
|
||||
|
||||
let follower_id = Uuid::new_v4();
|
||||
let owner_id = Uuid::new_v4();
|
||||
|
||||
execute_command(
|
||||
&cmd_deps,
|
||||
SocialCmd::Follow {
|
||||
follower_id,
|
||||
target: FollowTarget::Identity(SocialIdentity::Local(UserId::from_uuid(owner_id))),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
execute_command(
|
||||
&cmd_deps,
|
||||
SocialCmd::AcceptFollow {
|
||||
owner_id,
|
||||
requester: SocialIdentity::Local(UserId::from_uuid(follower_id)),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let followers = execute_query(&query_deps, SocialQry::GetFollowers { user_id: owner_id })
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(followers.len(), 1);
|
||||
}
|
||||
|
||||
// ── Get pending ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_only_pending_followers() {
|
||||
let social = InMemorySocialRepository::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let cmd_deps = SocialCommandDeps {
|
||||
social_command: Arc::clone(&social) as _,
|
||||
social_query: Arc::clone(&social) as _,
|
||||
event_publisher: Arc::clone(&events) as _,
|
||||
};
|
||||
let query_deps = SocialQueryDeps {
|
||||
social_query: Arc::clone(&social) as _,
|
||||
};
|
||||
|
||||
let follower_id = Uuid::new_v4();
|
||||
let owner_id = Uuid::new_v4();
|
||||
|
||||
execute_command(
|
||||
&cmd_deps,
|
||||
SocialCmd::Follow {
|
||||
follower_id,
|
||||
target: FollowTarget::Identity(SocialIdentity::Local(UserId::from_uuid(owner_id))),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let pending = execute_query(&query_deps, SocialQry::GetPending { user_id: owner_id })
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(pending.len(), 1);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::testing::{
|
||||
InMemoryGoalRepository, InMemoryWrapUpRepository, InMemoryWrapUpStatsQuery, NoopSocialQueryPort,
|
||||
InMemoryGoalRepository, InMemorySocialRepository, InMemoryWrapUpRepository,
|
||||
InMemoryWrapUpStatsQuery, NoopFederationAdminQuery,
|
||||
};
|
||||
use domain::{
|
||||
ports::{
|
||||
@@ -72,7 +73,9 @@ pub struct TestContextBuilder {
|
||||
pub goal_query: Arc<dyn GoalQuery>,
|
||||
pub user_settings_repo: Arc<dyn UserSettingsRepository>,
|
||||
pub review_logger: Arc<dyn ReviewLogger>,
|
||||
pub social_query: Arc<dyn domain::ports::SocialQueryPort>,
|
||||
pub social_command: Arc<dyn domain::ports::SocialCommand>,
|
||||
pub social_query_unified: Arc<dyn domain::ports::SocialQuery>,
|
||||
pub federation_admin: Arc<dyn domain::ports::FederationAdminQuery>,
|
||||
pub refresh_session_repo: Arc<dyn RefreshSessionRepository>,
|
||||
pub config: AppConfig,
|
||||
}
|
||||
@@ -88,6 +91,7 @@ impl TestContextBuilder {
|
||||
let movies = InMemoryMovieRepository::new();
|
||||
let watch_events = InMemoryWatchEventRepository::new();
|
||||
let goals = InMemoryGoalRepository::new();
|
||||
let social = InMemorySocialRepository::new();
|
||||
Self {
|
||||
movie_command: Arc::clone(&movies) as _,
|
||||
movie_query: movies as _,
|
||||
@@ -121,7 +125,9 @@ impl TestContextBuilder {
|
||||
goal_query: goals as _,
|
||||
user_settings_repo: InMemoryUserSettingsRepository::new(),
|
||||
review_logger: Arc::new(NoopReviewLogger),
|
||||
social_query: Arc::new(NoopSocialQueryPort),
|
||||
social_command: Arc::clone(&social) as _,
|
||||
social_query_unified: Arc::clone(&social) as _,
|
||||
federation_admin: Arc::new(NoopFederationAdminQuery),
|
||||
refresh_session_repo: InMemoryRefreshSessionRepository::new(),
|
||||
config: AppConfig {
|
||||
allow_registration: true,
|
||||
@@ -267,11 +273,6 @@ impl TestContextBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_social_query(mut self, r: Arc<dyn domain::ports::SocialQueryPort>) -> Self {
|
||||
self.social_query = r;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_wrapup_repo(mut self, r: Arc<dyn WrapUpRepository>) -> Self {
|
||||
self.wrapup_repo = r;
|
||||
self
|
||||
|
||||
@@ -72,7 +72,13 @@ impl EventHandler for RecordingHandler {
|
||||
DomainEvent::WatchlistEntryAdded { .. } | DomainEvent::WatchlistEntryRemoved { .. } => {
|
||||
"watchlist"
|
||||
}
|
||||
DomainEvent::FollowRequested { .. } => "follow_requested",
|
||||
DomainEvent::FollowAccepted { .. } => "follow_accepted",
|
||||
DomainEvent::FollowRejected { .. } => "follow_rejected",
|
||||
DomainEvent::Unfollowed { .. } => "unfollowed",
|
||||
DomainEvent::FollowerRemoved { .. } => "follower_removed",
|
||||
DomainEvent::ActorBlocked { .. } => "actor_blocked",
|
||||
DomainEvent::ActorUnblocked { .. } => "actor_unblocked",
|
||||
DomainEvent::BackfillFollower { .. } => "backfill_follower",
|
||||
DomainEvent::FederationDeliveryRequested { .. } => "federation_delivery",
|
||||
DomainEvent::WatchEventIngested { .. } => "watch_event_ingested",
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{
|
||||
DiaryQuery, EventPublisher, ObjectStorage, SocialQueryPort, StatsRepository, UserRepository,
|
||||
DiaryQuery, EventPublisher, FederationAdminQuery, ObjectStorage, SocialQuery, StatsRepository,
|
||||
UserRepository,
|
||||
};
|
||||
|
||||
pub struct GetProfileDeps {
|
||||
pub stats: Arc<dyn StatsRepository>,
|
||||
pub diary: Arc<dyn DiaryQuery>,
|
||||
pub social_query: Arc<dyn SocialQueryPort>,
|
||||
pub social_query: Arc<dyn SocialQuery>,
|
||||
}
|
||||
|
||||
pub struct GetUsersListDeps {
|
||||
pub user: Arc<dyn UserRepository>,
|
||||
pub federation_admin: Arc<dyn FederationAdminQuery>,
|
||||
}
|
||||
|
||||
pub struct UpdateProfileDeps {
|
||||
|
||||
@@ -86,7 +86,7 @@ async fn load_social_counts(
|
||||
.unwrap_or(0);
|
||||
let followers = deps
|
||||
.social_query
|
||||
.count_accepted_followers(user_id)
|
||||
.count_followers(user_id)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
if !is_own_profile {
|
||||
@@ -98,11 +98,19 @@ async fn load_social_counts(
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|p| PendingFollowerView {
|
||||
url: p.url,
|
||||
.map(|p| {
|
||||
let url = match &p.identity {
|
||||
domain::value_objects::SocialIdentity::Remote { actor_url } => actor_url.clone(),
|
||||
domain::value_objects::SocialIdentity::Local(uid) => {
|
||||
format!("local:{}", uid.value())
|
||||
}
|
||||
};
|
||||
PendingFollowerView {
|
||||
url,
|
||||
handle: p.handle,
|
||||
display_name: p.display_name,
|
||||
avatar_url: p.avatar_url,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
(following, followers, pending)
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::users::queries::GetUsersQuery;
|
||||
use crate::users::{deps::GetUsersListDeps, queries::GetUsersQuery};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{RemoteActorInfo, UserSummary},
|
||||
ports::{SocialQueryPort, UserRepository},
|
||||
};
|
||||
|
||||
pub struct UsersListData {
|
||||
@@ -13,13 +10,12 @@ pub struct UsersListData {
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
user: Arc<dyn UserRepository>,
|
||||
social_query: Arc<dyn SocialQueryPort>,
|
||||
deps: &GetUsersListDeps,
|
||||
_query: GetUsersQuery,
|
||||
) -> Result<UsersListData, DomainError> {
|
||||
let (users_result, actors_result) = tokio::join!(
|
||||
user.list_with_stats(),
|
||||
social_query.list_all_followed_remote_actors()
|
||||
deps.user.list_with_stats(),
|
||||
deps.federation_admin.list_all_followed_remote_actors()
|
||||
);
|
||||
|
||||
Ok(UsersListData {
|
||||
@@ -27,7 +23,3 @@ pub async fn execute(
|
||||
remote_actors: actors_result?,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/get_users.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -35,7 +35,7 @@ async fn returns_profile_with_empty_stats() {
|
||||
let deps = GetProfileDeps {
|
||||
stats: b.stats_repo.clone(),
|
||||
diary: b.diary_repo.clone(),
|
||||
social_query: b.social_query.clone(),
|
||||
social_query: b.social_query_unified.clone(),
|
||||
};
|
||||
|
||||
setup_user(&b, "profile@test.com", "profuser").await;
|
||||
@@ -70,7 +70,7 @@ async fn returns_history_view() {
|
||||
let deps = GetProfileDeps {
|
||||
stats: b.stats_repo.clone(),
|
||||
diary: b.diary_repo.clone(),
|
||||
social_query: b.social_query.clone(),
|
||||
social_query: b.social_query_unified.clone(),
|
||||
};
|
||||
|
||||
setup_user(&b, "hist@test.com", "histuser").await;
|
||||
@@ -107,7 +107,7 @@ async fn returns_trends_view() {
|
||||
let deps = GetProfileDeps {
|
||||
stats: b.stats_repo.clone(),
|
||||
diary: b.diary_repo.clone(),
|
||||
social_query: b.social_query.clone(),
|
||||
social_query: b.social_query_unified.clone(),
|
||||
};
|
||||
|
||||
setup_user(&b, "trends@test.com", "trendsuser").await;
|
||||
@@ -144,7 +144,7 @@ async fn returns_ratings_view() {
|
||||
let deps = GetProfileDeps {
|
||||
stats: b.stats_repo.clone(),
|
||||
diary: b.diary_repo.clone(),
|
||||
social_query: b.social_query.clone(),
|
||||
social_query: b.social_query_unified.clone(),
|
||||
};
|
||||
|
||||
setup_user(&b, "ratings@test.com", "ratingsuser").await;
|
||||
@@ -179,7 +179,7 @@ async fn returns_recent_with_search() {
|
||||
let deps = GetProfileDeps {
|
||||
stats: b.stats_repo.clone(),
|
||||
diary: b.diary_repo.clone(),
|
||||
social_query: b.social_query.clone(),
|
||||
social_query: b.social_query_unified.clone(),
|
||||
};
|
||||
|
||||
setup_user(&b, "search@test.com", "searchuser").await;
|
||||
@@ -214,7 +214,7 @@ async fn non_own_profile_skips_pending_followers() {
|
||||
let deps = GetProfileDeps {
|
||||
stats: b.stats_repo.clone(),
|
||||
diary: b.diary_repo.clone(),
|
||||
social_query: b.social_query.clone(),
|
||||
social_query: b.social_query_unified.clone(),
|
||||
};
|
||||
|
||||
setup_user(&b, "other@test.com", "otheruser").await;
|
||||
|
||||
@@ -63,10 +63,33 @@ pub enum DomainEvent {
|
||||
user_id: UserId,
|
||||
movie_id: MovieId,
|
||||
},
|
||||
FollowRequested {
|
||||
follower: UserId,
|
||||
target: crate::value_objects::FollowTarget,
|
||||
},
|
||||
FollowAccepted {
|
||||
local_user_id: UserId,
|
||||
remote_actor_url: String,
|
||||
outbox_url: String,
|
||||
owner: UserId,
|
||||
requester: crate::value_objects::SocialIdentity,
|
||||
},
|
||||
FollowRejected {
|
||||
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,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::{errors::DomainError, value_objects::UserId};
|
||||
use crate::{
|
||||
errors::DomainError,
|
||||
value_objects::{SocialActor, SocialIdentity, UserId},
|
||||
};
|
||||
|
||||
// ── NoopRemoteWatchlistRepository ─────────────────────────────────────────────
|
||||
|
||||
@@ -32,31 +35,78 @@ impl super::RemoteWatchlistRepository for NoopRemoteWatchlistRepository {
|
||||
}
|
||||
}
|
||||
|
||||
// ── NoopSocialQueryPort ───────────────────────────────────────────────────────
|
||||
// ── NoopSocialCommand ────────────────────────────────────────────────────────
|
||||
|
||||
/// Stub used when federation is disabled — returns empty results.
|
||||
pub struct NoopSocialQueryPort;
|
||||
pub struct NoopSocialCommand;
|
||||
|
||||
#[async_trait]
|
||||
impl super::SocialQueryPort for NoopSocialQueryPort {
|
||||
async fn get_accepted_following_urls(&self, _: &UserId) -> Result<Vec<String>, DomainError> {
|
||||
impl super::SocialCommand for NoopSocialCommand {
|
||||
async fn follow(
|
||||
&self,
|
||||
_: &UserId,
|
||||
_: &crate::value_objects::FollowTarget,
|
||||
) -> 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<SocialActor>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn list_all_followed_remote_actors(
|
||||
&self,
|
||||
) -> Result<Vec<crate::models::RemoteActorInfo>, DomainError> {
|
||||
async fn get_followers(&self, _: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn get_pending_followers(&self, _: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn count_following(&self, _: &UserId) -> Result<usize, DomainError> {
|
||||
Ok(0)
|
||||
}
|
||||
async fn count_accepted_followers(&self, _: &UserId) -> Result<usize, DomainError> {
|
||||
async fn count_followers(&self, _: &UserId) -> Result<usize, DomainError> {
|
||||
Ok(0)
|
||||
}
|
||||
async fn get_pending_followers(
|
||||
async fn get_blocked(&self, _: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn is_following(&self, _: &UserId, _: &SocialIdentity) -> Result<bool, DomainError> {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ── NoopFederationAdminQuery ─────────────────────────────────────────────────
|
||||
|
||||
/// Stub used when federation is disabled — returns empty results.
|
||||
pub struct NoopFederationAdminQuery;
|
||||
|
||||
#[async_trait]
|
||||
impl super::FederationAdminQuery for NoopFederationAdminQuery {
|
||||
async fn list_all_followed_remote_actors(
|
||||
&self,
|
||||
_: &UserId,
|
||||
) -> Result<Vec<crate::models::PendingFollowerInfo>, DomainError> {
|
||||
) -> Result<Vec<crate::models::RemoteActorInfo>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,25 +4,68 @@ use chrono::NaiveDateTime;
|
||||
use crate::{
|
||||
errors::DomainError,
|
||||
models::{
|
||||
DiaryEntry, FederationFlags, PendingFollowerInfo, RemoteActorInfo, RemoteGoalEntry,
|
||||
RemoteWatchlistEntry, WatchlistWithMovie,
|
||||
DiaryEntry, FederationFlags, RemoteActorInfo, RemoteGoalEntry, RemoteWatchlistEntry,
|
||||
WatchlistWithMovie,
|
||||
},
|
||||
value_objects::{MovieId, UserId},
|
||||
value_objects::{FollowTarget, MovieId, SocialActor, SocialIdentity, UserId},
|
||||
};
|
||||
|
||||
// ── Unified social ports (ADR-0002) ─────────────────────────────────────────
|
||||
|
||||
#[async_trait]
|
||||
pub trait SocialQueryPort: Send + Sync {
|
||||
async fn get_accepted_following_urls(
|
||||
pub trait SocialCommand: Send + Sync {
|
||||
async fn follow(&self, follower: &UserId, target: &FollowTarget) -> Result<(), DomainError>;
|
||||
|
||||
async fn unfollow(&self, follower: &UserId, target: &SocialIdentity)
|
||||
-> Result<(), DomainError>;
|
||||
|
||||
async fn accept_follow(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
) -> Result<Vec<String>, DomainError>;
|
||||
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<SocialActor>, DomainError>;
|
||||
|
||||
async fn get_followers(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError>;
|
||||
|
||||
async fn get_pending_followers(&self, user: &UserId) -> Result<Vec<SocialActor>, 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<SocialActor>, DomainError>;
|
||||
|
||||
async fn is_following(
|
||||
&self,
|
||||
follower: &UserId,
|
||||
target: &SocialIdentity,
|
||||
) -> Result<bool, DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait FederationAdminQuery: Send + Sync {
|
||||
async fn list_all_followed_remote_actors(&self) -> Result<Vec<RemoteActorInfo>, DomainError>;
|
||||
async fn count_following(&self, user_id: &UserId) -> Result<usize, DomainError>;
|
||||
async fn count_accepted_followers(&self, user_id: &UserId) -> Result<usize, DomainError>;
|
||||
async fn get_pending_followers(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
) -> Result<Vec<PendingFollowerInfo>, DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -39,7 +82,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 +102,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(
|
||||
|
||||
@@ -19,13 +19,14 @@ 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, SocialActor, SocialIdentity, UserId, Username, WatchEventId,
|
||||
WebhookTokenId,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -854,3 +855,254 @@ 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()),
|
||||
})
|
||||
}
|
||||
|
||||
fn identity_to_actor(identity: &SocialIdentity) -> SocialActor {
|
||||
let handle = match identity {
|
||||
SocialIdentity::Local(uid) => format!("user-{}", uid.value()),
|
||||
SocialIdentity::Remote { actor_url } => actor_url.clone(),
|
||||
};
|
||||
SocialActor {
|
||||
identity: identity.clone(),
|
||||
handle,
|
||||
display_name: None,
|
||||
avatar_url: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SocialCommand for InMemorySocialRepository {
|
||||
async fn follow(
|
||||
&self,
|
||||
follower: &UserId,
|
||||
target: &crate::value_objects::FollowTarget,
|
||||
) -> Result<(), DomainError> {
|
||||
let identity = match target {
|
||||
crate::value_objects::FollowTarget::Identity(id) => id.clone(),
|
||||
crate::value_objects::FollowTarget::Handle(h) => SocialIdentity::Remote {
|
||||
actor_url: h.clone(),
|
||||
},
|
||||
};
|
||||
if let SocialIdentity::Local(target_id) = &identity {
|
||||
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 == identity);
|
||||
if already {
|
||||
return Err(DomainError::ValidationError("Already following".into()));
|
||||
}
|
||||
store.push((follower.value(), identity, 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<SocialActor>, DomainError> {
|
||||
let store = self.follows.lock().unwrap();
|
||||
Ok(store
|
||||
.iter()
|
||||
.filter(|(f, _, state)| *f == user.value() && *state == FollowState::Accepted)
|
||||
.map(|(_, t, _)| Self::identity_to_actor(t))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_followers(&self, user: &UserId) -> Result<Vec<SocialActor>, 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, _, _)| {
|
||||
let id = SocialIdentity::Local(UserId::from_uuid(*f));
|
||||
Self::identity_to_actor(&id)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_pending_followers(&self, user: &UserId) -> Result<Vec<SocialActor>, 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, _, _)| {
|
||||
let id = SocialIdentity::Local(UserId::from_uuid(*f));
|
||||
Self::identity_to_actor(&id)
|
||||
})
|
||||
.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<SocialActor>, DomainError> {
|
||||
let store = self.blocked.lock().unwrap();
|
||||
Ok(store
|
||||
.iter()
|
||||
.filter(|(b, _)| *b == user.value())
|
||||
.map(|(_, t)| Self::identity_to_actor(t))
|
||||
.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
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,8 +64,8 @@ impl ObjectStorage for NoopObjectStorage {
|
||||
|
||||
// Re-export production noop types so test code that imports from
|
||||
// `domain::testing` keeps compiling without changes.
|
||||
pub use crate::ports::noop::NoopFederationAdminQuery;
|
||||
pub use crate::ports::noop::NoopRemoteWatchlistRepository;
|
||||
pub use crate::ports::noop::NoopSocialQueryPort;
|
||||
|
||||
// ── NoopGoalCommand ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ use crate::{
|
||||
AnnotatedRow, DiaryEntry, DiaryFilter, EntityType, ExportFormat, ExternalPersonId,
|
||||
FeedEntry, FeedSortBy, FieldMapping, FileFormat, FollowingFilter, ImportError,
|
||||
ImportProfile, ImportSession, IndexableDocument, MovieProfile, MovieStats, ParsedFile,
|
||||
PendingFollowerInfo, Person, PersonCredits, PersonEnrichmentData, PersonId, RefreshSession,
|
||||
RemoteActorInfo, ReviewHistory, SearchQuery, SearchResults, UserStats, UserTrends,
|
||||
Person, PersonCredits, PersonEnrichmentData, PersonId, RefreshSession, RemoteActorInfo,
|
||||
ReviewHistory, SearchQuery, SearchResults, UserStats, UserTrends,
|
||||
collections::{PageParams, Paginated},
|
||||
},
|
||||
ports::{
|
||||
@@ -326,36 +326,12 @@ impl UserProfileFieldsRepository for PanicProfileFieldsRepo {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PanicSocialQueryPort;
|
||||
pub struct PanicFederationAdminQuery;
|
||||
|
||||
#[async_trait]
|
||||
impl crate::ports::SocialQueryPort for PanicSocialQueryPort {
|
||||
async fn get_accepted_following_urls(
|
||||
&self,
|
||||
_: &crate::value_objects::UserId,
|
||||
) -> Result<Vec<String>, DomainError> {
|
||||
panic!("PanicSocialQueryPort called")
|
||||
}
|
||||
impl crate::ports::FederationAdminQuery for PanicFederationAdminQuery {
|
||||
async fn list_all_followed_remote_actors(&self) -> Result<Vec<RemoteActorInfo>, DomainError> {
|
||||
panic!("PanicSocialQueryPort called")
|
||||
}
|
||||
async fn count_following(
|
||||
&self,
|
||||
_: &crate::value_objects::UserId,
|
||||
) -> Result<usize, DomainError> {
|
||||
panic!("PanicSocialQueryPort called")
|
||||
}
|
||||
async fn count_accepted_followers(
|
||||
&self,
|
||||
_: &crate::value_objects::UserId,
|
||||
) -> Result<usize, DomainError> {
|
||||
panic!("PanicSocialQueryPort called")
|
||||
}
|
||||
async fn get_pending_followers(
|
||||
&self,
|
||||
_: &crate::value_objects::UserId,
|
||||
) -> Result<Vec<PendingFollowerInfo>, DomainError> {
|
||||
panic!("PanicSocialQueryPort called")
|
||||
panic!("PanicFederationAdminQuery called")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,55 @@
|
||||
use super::*;
|
||||
use crate::value_objects::UserId;
|
||||
use crate::value_objects::{FollowTarget, 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_with_identity() {
|
||||
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: FollowTarget::Identity(SocialIdentity::Local(target.clone())),
|
||||
};
|
||||
assert!(matches!(
|
||||
event,
|
||||
DomainEvent::FollowRequested {
|
||||
target: FollowTarget::Identity(SocialIdentity::Local(_)),
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_requested_with_handle() {
|
||||
let follower = UserId::from_uuid(uuid::Uuid::new_v4());
|
||||
let event = DomainEvent::FollowRequested {
|
||||
follower: follower.clone(),
|
||||
target: FollowTarget::Handle("@alice@remote.example".into()),
|
||||
};
|
||||
assert!(matches!(
|
||||
event,
|
||||
DomainEvent::FollowRequested {
|
||||
target: FollowTarget::Handle(_),
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
31
crates/domain/src/value_objects/social.rs
Normal file
31
crates/domain/src/value_objects/social.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
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 { .. })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum FollowTarget {
|
||||
Identity(SocialIdentity),
|
||||
Handle(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SocialActor {
|
||||
pub identity: SocialIdentity,
|
||||
pub handle: String,
|
||||
pub display_name: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
}
|
||||
@@ -2,13 +2,14 @@ use std::sync::Arc;
|
||||
|
||||
use domain::ports::{
|
||||
AuthService, DiaryExporter, DiaryQuery, DocumentParser, EventPublisher, FederatedProfileQuery,
|
||||
GoalCommand, GoalQuery, ImportProfileRepository, ImportSessionRepository, MetadataClient,
|
||||
MovieCommand, MovieProfileRepository, MovieQuery, ObjectStorage, PasswordHasher, PersonCommand,
|
||||
PersonEnrichmentClient, PersonQuery, PosterFetcherClient, RefreshSessionRepository,
|
||||
RemoteGoalRepository, RemoteWatchlistRepository, ReviewRepository, SearchCommand, SearchPort,
|
||||
SocialQueryPort, StatsRepository, UserProfileFieldsRepository, UserRepository,
|
||||
UserSettingsRepository, WatchEventCommand, WatchEventQuery, WatchlistRepository,
|
||||
WebhookTokenRepository, WrapUpRepository, WrapUpStatsQuery,
|
||||
FederationAdminQuery, GoalCommand, GoalQuery, ImportProfileRepository, ImportSessionRepository,
|
||||
MetadataClient, MovieCommand, MovieProfileRepository, MovieQuery, ObjectStorage,
|
||||
PasswordHasher, PersonCommand, PersonEnrichmentClient, PersonQuery, PosterFetcherClient,
|
||||
RefreshSessionRepository, RemoteGoalRepository, RemoteWatchlistRepository, ReviewRepository,
|
||||
SearchCommand, SearchPort, SocialCommand, SocialQuery, StatsRepository,
|
||||
UserProfileFieldsRepository, UserRepository, UserSettingsRepository, WatchEventCommand,
|
||||
WatchEventQuery, WatchlistRepository, WebhookTokenRepository, WrapUpRepository,
|
||||
WrapUpStatsQuery,
|
||||
};
|
||||
|
||||
use application::config::AppConfig;
|
||||
@@ -35,7 +36,9 @@ pub struct Repositories {
|
||||
pub search_command: Arc<dyn SearchCommand>,
|
||||
pub profile_fields: Arc<dyn UserProfileFieldsRepository>,
|
||||
pub remote_watchlist: Arc<dyn RemoteWatchlistRepository>,
|
||||
pub social_query: Arc<dyn SocialQueryPort>,
|
||||
pub social_command: Arc<dyn SocialCommand>,
|
||||
pub social_query_unified: Arc<dyn SocialQuery>,
|
||||
pub federation_admin: Arc<dyn FederationAdminQuery>,
|
||||
pub wrapup_stats: Arc<dyn WrapUpStatsQuery>,
|
||||
pub wrapup_repo: Arc<dyn WrapUpRepository>,
|
||||
pub goal_command: Arc<dyn GoalCommand>,
|
||||
@@ -58,6 +61,8 @@ pub struct Services {
|
||||
pub document_parser: Arc<dyn DocumentParser>,
|
||||
pub review_logger: Arc<dyn ReviewLogger>,
|
||||
pub person_enrichment: Option<Arc<dyn PersonEnrichmentClient>>,
|
||||
#[cfg(feature = "federation")]
|
||||
pub ap_service: Arc<dyn activitypub::ActivityPubPort>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
|
||||
@@ -182,7 +182,7 @@ pub async fn get_activity_feed(
|
||||
) -> Result<Json<ActivityFeedResponse>, ApiError> {
|
||||
let deps = GetActivityFeedDeps {
|
||||
diary: state.app_ctx.repos.diary.clone(),
|
||||
social_query: state.app_ctx.repos.social_query.clone(),
|
||||
social_query: state.app_ctx.repos.social_query_unified.clone(),
|
||||
config: state.app_ctx.config.clone(),
|
||||
};
|
||||
let page = get_feed_uc::execute(
|
||||
@@ -338,7 +338,7 @@ pub async fn get_activity_feed_html(
|
||||
|
||||
let deps = GetActivityFeedDeps {
|
||||
diary: state.app_ctx.repos.diary.clone(),
|
||||
social_query: state.app_ctx.repos.social_query.clone(),
|
||||
social_query: state.app_ctx.repos.social_query_unified.clone(),
|
||||
config: state.app_ctx.config.clone(),
|
||||
};
|
||||
|
||||
|
||||
@@ -19,8 +19,10 @@ use crate::{
|
||||
};
|
||||
use api_types::{
|
||||
ActorListResponse, ActorUrlRequest, AddBlockedDomainRequest, BlockedActorResponse,
|
||||
BlockedDomainResponse, FollowRequest,
|
||||
BlockedDomainResponse, FollowRequest, RemoteActorDto,
|
||||
};
|
||||
use application::social::deps::{SocialCommandDeps, SocialQueryDeps};
|
||||
use domain::value_objects::{FollowTarget, SocialActor, SocialIdentity};
|
||||
use template_askama::{
|
||||
BlockedActorsTemplate, BlockedDomainsTemplate, FollowersTemplate, FollowingTemplate,
|
||||
RemoteActorData,
|
||||
@@ -28,11 +30,62 @@ use template_askama::{
|
||||
|
||||
use super::helpers::{build_page_context, encode_error};
|
||||
|
||||
impl From<&AppState> for SocialCommandDeps {
|
||||
fn from(state: &AppState) -> Self {
|
||||
Self {
|
||||
social_command: state.app_ctx.repos.social_command.clone(),
|
||||
social_query: state.app_ctx.repos.social_query_unified.clone(),
|
||||
event_publisher: state.app_ctx.services.event_publisher.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&AppState> for SocialQueryDeps {
|
||||
fn from(state: &AppState) -> Self {
|
||||
Self {
|
||||
social_query: state.app_ctx.repos.social_query_unified.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ap_to_domain(e: anyhow::Error) -> domain::errors::DomainError {
|
||||
tracing::error!("ActivityPub error: {:?}", e);
|
||||
domain::errors::DomainError::InfrastructureError(e.to_string())
|
||||
}
|
||||
|
||||
fn actor_url(identity: &SocialIdentity) -> String {
|
||||
match identity {
|
||||
SocialIdentity::Remote { actor_url } => actor_url.clone(),
|
||||
SocialIdentity::Local(uid) => format!("local:{}", uid.value()),
|
||||
}
|
||||
}
|
||||
|
||||
fn social_actor_to_dto(actor: SocialActor) -> RemoteActorDto {
|
||||
RemoteActorDto {
|
||||
url: actor_url(&actor.identity),
|
||||
handle: actor.handle,
|
||||
display_name: actor.display_name,
|
||||
}
|
||||
}
|
||||
|
||||
fn social_actor_to_blocked_dto(actor: SocialActor) -> BlockedActorResponse {
|
||||
BlockedActorResponse {
|
||||
url: actor_url(&actor.identity),
|
||||
handle: actor.handle,
|
||||
display_name: actor.display_name,
|
||||
avatar_url: actor.avatar_url,
|
||||
}
|
||||
}
|
||||
|
||||
fn social_actor_to_template(actor: SocialActor) -> RemoteActorData {
|
||||
RemoteActorData {
|
||||
url: actor_url(&actor.identity),
|
||||
handle: actor.handle,
|
||||
display_name: actor.display_name,
|
||||
avatar_url: actor.avatar_url,
|
||||
}
|
||||
}
|
||||
|
||||
// ── API ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -49,6 +102,8 @@ pub async fn get_blocked_domains_admin(
|
||||
_admin: AdminApiUser,
|
||||
) -> Result<Json<Vec<BlockedDomainResponse>>, ApiError> {
|
||||
let domains = state
|
||||
.app_ctx
|
||||
.services
|
||||
.ap_service
|
||||
.get_blocked_domains()
|
||||
.await
|
||||
@@ -81,6 +136,8 @@ pub async fn add_blocked_domain_admin(
|
||||
axum::Json(body): axum::Json<AddBlockedDomainRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
state
|
||||
.app_ctx
|
||||
.services
|
||||
.ap_service
|
||||
.add_blocked_domain(&body.domain, body.reason.as_deref())
|
||||
.await
|
||||
@@ -104,6 +161,8 @@ pub async fn remove_blocked_domain_admin(
|
||||
axum::extract::Path(domain): axum::extract::Path<String>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
state
|
||||
.app_ctx
|
||||
.services
|
||||
.ap_service
|
||||
.remove_blocked_domain(&domain)
|
||||
.await
|
||||
@@ -125,11 +184,17 @@ pub async fn block_actor_api(
|
||||
user: AuthenticatedUser,
|
||||
axum::Json(body): axum::Json<ActorUrlRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
state
|
||||
.ap_service
|
||||
.block_actor(user.0.value(), &body.actor_url)
|
||||
.await
|
||||
.map_err(ap_to_domain)?;
|
||||
let deps = SocialCommandDeps::from(&state);
|
||||
application::social::execute::execute_command(
|
||||
&deps,
|
||||
application::social::commands::SocialCmd::Block {
|
||||
blocker_id: user.0.value(),
|
||||
target: SocialIdentity::Remote {
|
||||
actor_url: body.actor_url,
|
||||
},
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
@@ -147,11 +212,17 @@ pub async fn unblock_actor_api(
|
||||
user: AuthenticatedUser,
|
||||
axum::Json(body): axum::Json<ActorUrlRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
state
|
||||
.ap_service
|
||||
.unblock_actor(user.0.value(), &body.actor_url)
|
||||
.await
|
||||
.map_err(ap_to_domain)?;
|
||||
let deps = SocialCommandDeps::from(&state);
|
||||
application::social::execute::execute_command(
|
||||
&deps,
|
||||
application::social::commands::SocialCmd::Unblock {
|
||||
blocker_id: user.0.value(),
|
||||
target: SocialIdentity::Remote {
|
||||
actor_url: body.actor_url,
|
||||
},
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
@@ -167,20 +238,18 @@ pub async fn get_blocked_actors_api(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
) -> Result<Json<Vec<BlockedActorResponse>>, ApiError> {
|
||||
let actors = state
|
||||
.ap_service
|
||||
.get_blocked_actors(user.0.value())
|
||||
.await
|
||||
.map_err(ap_to_domain)?;
|
||||
let deps = SocialQueryDeps::from(&state);
|
||||
let identities = application::social::execute::execute_query(
|
||||
&deps,
|
||||
application::social::queries::SocialQry::GetBlocked {
|
||||
user_id: user.0.value(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(
|
||||
actors
|
||||
identities
|
||||
.into_iter()
|
||||
.map(|a| BlockedActorResponse {
|
||||
url: a.url,
|
||||
handle: a.handle,
|
||||
display_name: a.display_name,
|
||||
avatar_url: a.avatar_url,
|
||||
})
|
||||
.map(social_actor_to_blocked_dto)
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
@@ -197,16 +266,16 @@ pub async fn get_following(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
) -> Result<Json<ActorListResponse>, ApiError> {
|
||||
let actors = state
|
||||
.ap_service
|
||||
.get_following(user.0.value())
|
||||
.await
|
||||
.map_err(ap_to_domain)?;
|
||||
let deps = SocialQueryDeps::from(&state);
|
||||
let identities = application::social::execute::execute_query(
|
||||
&deps,
|
||||
application::social::queries::SocialQry::GetFollowing {
|
||||
user_id: user.0.value(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ActorListResponse {
|
||||
actors: actors
|
||||
.into_iter()
|
||||
.map(crate::mappers::social::remote_actor_to_dto)
|
||||
.collect(),
|
||||
actors: identities.into_iter().map(social_actor_to_dto).collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -222,16 +291,16 @@ pub async fn get_followers(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
) -> Result<Json<ActorListResponse>, ApiError> {
|
||||
let actors = state
|
||||
.ap_service
|
||||
.get_accepted_followers(user.0.value())
|
||||
.await
|
||||
.map_err(ap_to_domain)?;
|
||||
let deps = SocialQueryDeps::from(&state);
|
||||
let identities = application::social::execute::execute_query(
|
||||
&deps,
|
||||
application::social::queries::SocialQry::GetFollowers {
|
||||
user_id: user.0.value(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ActorListResponse {
|
||||
actors: actors
|
||||
.into_iter()
|
||||
.map(crate::mappers::social::remote_actor_to_dto)
|
||||
.collect(),
|
||||
actors: identities.into_iter().map(social_actor_to_dto).collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -240,16 +309,14 @@ pub async fn get_user_following(
|
||||
_user: AuthenticatedUser,
|
||||
Path(user_id): Path<Uuid>,
|
||||
) -> Result<Json<ActorListResponse>, ApiError> {
|
||||
let actors = state
|
||||
.ap_service
|
||||
.get_following(user_id)
|
||||
.await
|
||||
.map_err(ap_to_domain)?;
|
||||
let deps = SocialQueryDeps::from(&state);
|
||||
let identities = application::social::execute::execute_query(
|
||||
&deps,
|
||||
application::social::queries::SocialQry::GetFollowing { user_id },
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ActorListResponse {
|
||||
actors: actors
|
||||
.into_iter()
|
||||
.map(crate::mappers::social::remote_actor_to_dto)
|
||||
.collect(),
|
||||
actors: identities.into_iter().map(social_actor_to_dto).collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -258,16 +325,14 @@ pub async fn get_user_followers(
|
||||
_user: AuthenticatedUser,
|
||||
Path(user_id): Path<Uuid>,
|
||||
) -> Result<Json<ActorListResponse>, ApiError> {
|
||||
let actors = state
|
||||
.ap_service
|
||||
.get_accepted_followers(user_id)
|
||||
.await
|
||||
.map_err(ap_to_domain)?;
|
||||
let deps = SocialQueryDeps::from(&state);
|
||||
let identities = application::social::execute::execute_query(
|
||||
&deps,
|
||||
application::social::queries::SocialQry::GetFollowers { user_id },
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ActorListResponse {
|
||||
actors: actors
|
||||
.into_iter()
|
||||
.map(crate::mappers::social::remote_actor_to_dto)
|
||||
.collect(),
|
||||
actors: identities.into_iter().map(social_actor_to_dto).collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -285,11 +350,15 @@ pub async fn follow(
|
||||
user: AuthenticatedUser,
|
||||
Json(body): Json<FollowRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
state
|
||||
.ap_service
|
||||
.follow(user.0.value(), &body.handle)
|
||||
.await
|
||||
.map_err(ap_to_domain)?;
|
||||
let deps = SocialCommandDeps::from(&state);
|
||||
application::social::execute::execute_command(
|
||||
&deps,
|
||||
application::social::commands::SocialCmd::Follow {
|
||||
follower_id: user.0.value(),
|
||||
target: FollowTarget::Handle(body.handle),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
@@ -307,11 +376,17 @@ pub async fn unfollow(
|
||||
user: AuthenticatedUser,
|
||||
Json(body): Json<ActorUrlRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
state
|
||||
.ap_service
|
||||
.unfollow(user.0.value(), &body.actor_url)
|
||||
.await
|
||||
.map_err(ap_to_domain)?;
|
||||
let deps = SocialCommandDeps::from(&state);
|
||||
application::social::execute::execute_command(
|
||||
&deps,
|
||||
application::social::commands::SocialCmd::Unfollow {
|
||||
follower_id: user.0.value(),
|
||||
target: SocialIdentity::Remote {
|
||||
actor_url: body.actor_url,
|
||||
},
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
@@ -329,11 +404,17 @@ pub async fn accept_follower(
|
||||
user: AuthenticatedUser,
|
||||
Json(body): Json<ActorUrlRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
state
|
||||
.ap_service
|
||||
.accept_follower(user.0.value(), &body.actor_url)
|
||||
.await
|
||||
.map_err(ap_to_domain)?;
|
||||
let deps = SocialCommandDeps::from(&state);
|
||||
application::social::execute::execute_command(
|
||||
&deps,
|
||||
application::social::commands::SocialCmd::AcceptFollow {
|
||||
owner_id: user.0.value(),
|
||||
requester: SocialIdentity::Remote {
|
||||
actor_url: body.actor_url,
|
||||
},
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
@@ -351,11 +432,17 @@ pub async fn reject_follower(
|
||||
user: AuthenticatedUser,
|
||||
Json(body): Json<ActorUrlRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
state
|
||||
.ap_service
|
||||
.reject_follower(user.0.value(), &body.actor_url)
|
||||
.await
|
||||
.map_err(ap_to_domain)?;
|
||||
let deps = SocialCommandDeps::from(&state);
|
||||
application::social::execute::execute_command(
|
||||
&deps,
|
||||
application::social::commands::SocialCmd::RejectFollow {
|
||||
owner_id: user.0.value(),
|
||||
requester: SocialIdentity::Remote {
|
||||
actor_url: body.actor_url,
|
||||
},
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
@@ -373,11 +460,17 @@ pub async fn remove_follower(
|
||||
user: AuthenticatedUser,
|
||||
Json(body): Json<ActorUrlRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
state
|
||||
.ap_service
|
||||
.remove_follower(user.0.value(), &body.actor_url)
|
||||
.await
|
||||
.map_err(ap_to_domain)?;
|
||||
let deps = SocialCommandDeps::from(&state);
|
||||
application::social::execute::execute_command(
|
||||
&deps,
|
||||
application::social::commands::SocialCmd::RemoveFollower {
|
||||
owner_id: user.0.value(),
|
||||
follower: SocialIdentity::Remote {
|
||||
actor_url: body.actor_url,
|
||||
},
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
@@ -393,16 +486,16 @@ pub async fn get_pending_followers(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
) -> Result<Json<ActorListResponse>, ApiError> {
|
||||
let actors = state
|
||||
.ap_service
|
||||
.get_pending_followers(user.0.value())
|
||||
.await
|
||||
.map_err(ap_to_domain)?;
|
||||
let deps = SocialQueryDeps::from(&state);
|
||||
let identities = application::social::execute::execute_query(
|
||||
&deps,
|
||||
application::social::queries::SocialQry::GetPending {
|
||||
user_id: user.0.value(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ActorListResponse {
|
||||
actors: actors
|
||||
.into_iter()
|
||||
.map(crate::mappers::social::remote_actor_to_dto)
|
||||
.collect(),
|
||||
actors: identities.into_iter().map(social_actor_to_dto).collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -428,7 +521,16 @@ pub async fn follow_remote_user(
|
||||
.unwrap_or(&format!("/users/{}", profile_user_uuid))
|
||||
.to_string();
|
||||
|
||||
match state.ap_service.follow(user_id.value(), &form.handle).await {
|
||||
let deps = SocialCommandDeps::from(&state);
|
||||
match application::social::execute::execute_command(
|
||||
&deps,
|
||||
application::social::commands::SocialCmd::Follow {
|
||||
follower_id: user_id.value(),
|
||||
target: FollowTarget::Handle(form.handle),
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => Redirect::to(&redirect_base).into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("follow error: {:?}", e);
|
||||
@@ -456,9 +558,16 @@ pub async fn unfollow_remote_user(
|
||||
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
match state
|
||||
.ap_service
|
||||
.unfollow(user_id.value(), &form.actor_url)
|
||||
let deps = SocialCommandDeps::from(&state);
|
||||
match application::social::execute::execute_command(
|
||||
&deps,
|
||||
application::social::commands::SocialCmd::Unfollow {
|
||||
follower_id: user_id.value(),
|
||||
target: SocialIdentity::Remote {
|
||||
actor_url: form.actor_url,
|
||||
},
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
@@ -488,9 +597,16 @@ pub async fn accept_follower_html(
|
||||
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
match state
|
||||
.ap_service
|
||||
.accept_follower(user_id.value(), &form.actor_url)
|
||||
let deps = SocialCommandDeps::from(&state);
|
||||
match application::social::execute::execute_command(
|
||||
&deps,
|
||||
application::social::commands::SocialCmd::AcceptFollow {
|
||||
owner_id: user_id.value(),
|
||||
requester: SocialIdentity::Remote {
|
||||
actor_url: form.actor_url,
|
||||
},
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Redirect::to(&format!("/users/{}", profile_user_uuid)).into_response(),
|
||||
@@ -514,9 +630,16 @@ pub async fn reject_follower_html(
|
||||
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
match state
|
||||
.ap_service
|
||||
.reject_follower(user_id.value(), &form.actor_url)
|
||||
let deps = SocialCommandDeps::from(&state);
|
||||
match application::social::execute::execute_command(
|
||||
&deps,
|
||||
application::social::commands::SocialCmd::RejectFollow {
|
||||
owner_id: user_id.value(),
|
||||
requester: SocialIdentity::Remote {
|
||||
actor_url: form.actor_url,
|
||||
},
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Redirect::to(&format!("/users/{}", profile_user_uuid)).into_response(),
|
||||
@@ -540,6 +663,8 @@ pub async fn get_followers_collection(
|
||||
if accept.contains("application/activity+json") || accept.contains("application/ld+json") {
|
||||
let page = params.get("page").and_then(|p| p.parse::<u32>().ok());
|
||||
return match state
|
||||
.app_ctx
|
||||
.services
|
||||
.ap_service
|
||||
.followers_collection_json(user_id, page)
|
||||
.await
|
||||
@@ -571,6 +696,8 @@ pub async fn get_following_collection(
|
||||
if accept.contains("application/activity+json") || accept.contains("application/ld+json") {
|
||||
let page = params.get("page").and_then(|p| p.parse::<u32>().ok());
|
||||
return match state
|
||||
.app_ctx
|
||||
.services
|
||||
.ap_service
|
||||
.following_collection_json(user_id, page)
|
||||
.await
|
||||
@@ -605,16 +732,19 @@ pub async fn get_following_page(
|
||||
"{}/users/{}/following-list",
|
||||
state.app_ctx.config.base_url, profile_user_uuid
|
||||
);
|
||||
match state.ap_service.get_following(user_id.value()).await {
|
||||
let deps = SocialQueryDeps::from(&state);
|
||||
match application::social::execute::execute_query(
|
||||
&deps,
|
||||
application::social::queries::SocialQry::GetFollowing {
|
||||
user_id: user_id.value(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(following) => {
|
||||
let actors: Vec<RemoteActorData> = following
|
||||
.into_iter()
|
||||
.map(|a| RemoteActorData {
|
||||
handle: a.handle,
|
||||
display_name: a.display_name,
|
||||
url: a.url,
|
||||
avatar_url: a.avatar_url.clone(),
|
||||
})
|
||||
.map(social_actor_to_template)
|
||||
.collect();
|
||||
render_page(FollowingTemplate {
|
||||
ctx,
|
||||
@@ -651,20 +781,19 @@ pub async fn get_followers_page(
|
||||
"{}/users/{}/followers-list",
|
||||
state.app_ctx.config.base_url, profile_user_uuid
|
||||
);
|
||||
match state
|
||||
.ap_service
|
||||
.get_accepted_followers(user_id.value())
|
||||
let deps = SocialQueryDeps::from(&state);
|
||||
match application::social::execute::execute_query(
|
||||
&deps,
|
||||
application::social::queries::SocialQry::GetFollowers {
|
||||
user_id: user_id.value(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(followers) => {
|
||||
let actors: Vec<RemoteActorData> = followers
|
||||
.into_iter()
|
||||
.map(|a| RemoteActorData {
|
||||
handle: a.handle,
|
||||
display_name: a.display_name,
|
||||
url: a.url,
|
||||
avatar_url: a.avatar_url.clone(),
|
||||
})
|
||||
.map(social_actor_to_template)
|
||||
.collect();
|
||||
render_page(FollowersTemplate {
|
||||
ctx,
|
||||
@@ -698,9 +827,16 @@ pub async fn remove_follower_html(
|
||||
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
match state
|
||||
.ap_service
|
||||
.remove_follower(user_id.value(), &form.actor_url)
|
||||
let deps = SocialCommandDeps::from(&state);
|
||||
match application::social::execute::execute_command(
|
||||
&deps,
|
||||
application::social::commands::SocialCmd::RemoveFollower {
|
||||
owner_id: user_id.value(),
|
||||
follower: SocialIdentity::Remote {
|
||||
actor_url: form.actor_url,
|
||||
},
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
@@ -725,7 +861,13 @@ pub async fn get_blocked_domains_page(
|
||||
let mut ctx = build_page_context(&state, Some(user_id), csrf.0).await;
|
||||
ctx.page_title = "Blocked Domains — Movies Diary".to_string();
|
||||
ctx.canonical_url = format!("{}/admin/blocked-domains", state.app_ctx.config.base_url);
|
||||
match state.ap_service.get_blocked_domains().await {
|
||||
match state
|
||||
.app_ctx
|
||||
.services
|
||||
.ap_service
|
||||
.get_blocked_domains()
|
||||
.await
|
||||
{
|
||||
Ok(domains) => {
|
||||
let entries: Vec<template_askama::BlockedDomainEntry> = domains
|
||||
.into_iter()
|
||||
@@ -763,6 +905,8 @@ pub async fn post_blocked_domain(
|
||||
}
|
||||
let reason = form.reason.as_deref().filter(|s| !s.trim().is_empty());
|
||||
match state
|
||||
.app_ctx
|
||||
.services
|
||||
.ap_service
|
||||
.add_blocked_domain(&form.domain, reason)
|
||||
.await
|
||||
@@ -784,7 +928,13 @@ pub async fn post_remove_blocked_domain(
|
||||
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
match state.ap_service.remove_blocked_domain(&form.domain).await {
|
||||
match state
|
||||
.app_ctx
|
||||
.services
|
||||
.ap_service
|
||||
.remove_blocked_domain(&form.domain)
|
||||
.await
|
||||
{
|
||||
Ok(()) => Redirect::to("/admin/blocked-domains").into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("remove_blocked_domain error: {:?}", e);
|
||||
@@ -801,12 +951,20 @@ pub async fn get_blocked_actors_page(
|
||||
let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await;
|
||||
ctx.page_title = "Blocked Users — Movies Diary".to_string();
|
||||
ctx.canonical_url = format!("{}/social/blocked", state.app_ctx.config.base_url);
|
||||
match state.ap_service.get_blocked_actors(user_id.value()).await {
|
||||
Ok(actors) => {
|
||||
let entries: Vec<template_askama::BlockedActorEntry> = actors
|
||||
let deps = SocialQueryDeps::from(&state);
|
||||
match application::social::execute::execute_query(
|
||||
&deps,
|
||||
application::social::queries::SocialQry::GetBlocked {
|
||||
user_id: user_id.value(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(blocked) => {
|
||||
let entries: Vec<template_askama::BlockedActorEntry> = blocked
|
||||
.into_iter()
|
||||
.map(|a| template_askama::BlockedActorEntry {
|
||||
url: a.url,
|
||||
url: actor_url(&a.identity),
|
||||
handle: a.handle,
|
||||
display_name: a.display_name,
|
||||
avatar_url: a.avatar_url,
|
||||
@@ -838,9 +996,16 @@ pub async fn post_block_actor_html(
|
||||
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
match state
|
||||
.ap_service
|
||||
.block_actor(user_id.value(), &form.actor_url)
|
||||
let deps = SocialCommandDeps::from(&state);
|
||||
match application::social::execute::execute_command(
|
||||
&deps,
|
||||
application::social::commands::SocialCmd::Block {
|
||||
blocker_id: user_id.value(),
|
||||
target: SocialIdentity::Remote {
|
||||
actor_url: form.actor_url,
|
||||
},
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => Redirect::to("/social/blocked").into_response(),
|
||||
@@ -860,9 +1025,16 @@ pub async fn post_unblock_actor(
|
||||
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
match state
|
||||
.ap_service
|
||||
.unblock_actor(user_id.value(), &form.actor_url)
|
||||
let deps = SocialCommandDeps::from(&state);
|
||||
match application::social::execute::execute_command(
|
||||
&deps,
|
||||
application::social::commands::SocialCmd::Unblock {
|
||||
blocker_id: user_id.value(),
|
||||
target: SocialIdentity::Remote {
|
||||
actor_url: form.actor_url,
|
||||
},
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => Redirect::to("/social/blocked").into_response(),
|
||||
|
||||
@@ -176,12 +176,11 @@ pub async fn update_profile_fields_handler(
|
||||
responses((status = 200, body = UsersResponse)),
|
||||
)]
|
||||
pub async fn list_users(State(state): State<AppState>) -> Result<Json<UsersResponse>, ApiError> {
|
||||
let result = get_users::execute(
|
||||
state.app_ctx.repos.user.clone(),
|
||||
state.app_ctx.repos.social_query.clone(),
|
||||
GetUsersQuery,
|
||||
)
|
||||
.await?;
|
||||
let deps = application::users::deps::GetUsersListDeps {
|
||||
user: state.app_ctx.repos.user.clone(),
|
||||
federation_admin: state.app_ctx.repos.federation_admin.clone(),
|
||||
};
|
||||
let result = get_users::execute(&deps, GetUsersQuery).await?;
|
||||
Ok(Json(UsersResponse {
|
||||
users: result
|
||||
.users
|
||||
@@ -248,7 +247,7 @@ pub async fn get_user_profile(
|
||||
let get_profile_deps = GetProfileDeps {
|
||||
stats: state.app_ctx.repos.stats.clone(),
|
||||
diary: state.app_ctx.repos.diary.clone(),
|
||||
social_query: state.app_ctx.repos.social_query.clone(),
|
||||
social_query: state.app_ctx.repos.social_query_unified.clone(),
|
||||
};
|
||||
let profile = match get_user_profile_uc::execute(
|
||||
&get_profile_deps,
|
||||
@@ -381,7 +380,7 @@ async fn build_federated_profile_response(
|
||||
let get_profile_deps = GetProfileDeps {
|
||||
stats: state.app_ctx.repos.stats.clone(),
|
||||
diary: state.app_ctx.repos.diary.clone(),
|
||||
social_query: state.app_ctx.repos.social_query.clone(),
|
||||
social_query: state.app_ctx.repos.social_query_unified.clone(),
|
||||
};
|
||||
let profile = match get_user_profile_uc::execute(
|
||||
&get_profile_deps,
|
||||
@@ -485,9 +484,12 @@ pub async fn get_users_list(
|
||||
ctx.page_title = "Members — Movies Diary".to_string();
|
||||
ctx.canonical_url = format!("{}/users", state.app_ctx.config.base_url);
|
||||
|
||||
let users_deps = application::users::deps::GetUsersListDeps {
|
||||
user: state.app_ctx.repos.user.clone(),
|
||||
federation_admin: state.app_ctx.repos.federation_admin.clone(),
|
||||
};
|
||||
match application::users::get_users::execute(
|
||||
state.app_ctx.repos.user.clone(),
|
||||
state.app_ctx.repos.social_query.clone(),
|
||||
&users_deps,
|
||||
application::users::queries::GetUsersQuery,
|
||||
)
|
||||
.await
|
||||
@@ -647,6 +649,8 @@ pub async fn get_user_profile_html(
|
||||
.unwrap_or("");
|
||||
if accept.contains("application/activity+json") || accept.contains("application/ld+json") {
|
||||
return match state
|
||||
.app_ctx
|
||||
.services
|
||||
.ap_service
|
||||
.actor_json(&profile_user_uuid.to_string())
|
||||
.await
|
||||
@@ -729,7 +733,7 @@ pub async fn get_user_profile_html(
|
||||
let html_profile_deps = GetProfileDeps {
|
||||
stats: state.app_ctx.repos.stats.clone(),
|
||||
diary: state.app_ctx.repos.diary.clone(),
|
||||
social_query: state.app_ctx.repos.social_query.clone(),
|
||||
social_query: state.app_ctx.repos.social_query_unified.clone(),
|
||||
};
|
||||
match application::users::get_profile::execute(&html_profile_deps, query).await {
|
||||
Ok(profile) => {
|
||||
|
||||
@@ -66,7 +66,15 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
|
||||
let event_bus = EventBusBackend::from_env()?;
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
let (event_publisher_arc, ap_router, ap_service, social_query, remote_watchlist_repo) = {
|
||||
let (
|
||||
event_publisher_arc,
|
||||
ap_router,
|
||||
ap_service,
|
||||
social_query,
|
||||
remote_watchlist_repo,
|
||||
social_command_arc,
|
||||
social_query_unified_arc,
|
||||
) = {
|
||||
let (
|
||||
activity_repo,
|
||||
follow_repo,
|
||||
@@ -112,12 +120,20 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
|
||||
let ap_router = ap.router;
|
||||
let ap_service_arc = ap.service;
|
||||
|
||||
let composite_social = Arc::new(activitypub::CompositeSocialAdapter::new(
|
||||
Arc::clone(&ap_service_arc),
|
||||
Arc::clone(&db.user),
|
||||
app_config.base_url.clone(),
|
||||
));
|
||||
|
||||
(
|
||||
ep,
|
||||
ap_router,
|
||||
ap_service_arc,
|
||||
social_query_arc,
|
||||
remote_watchlist_repo,
|
||||
composite_social.clone() as Arc<dyn domain::ports::SocialCommand>,
|
||||
composite_social as Arc<dyn domain::ports::SocialQuery>,
|
||||
)
|
||||
};
|
||||
|
||||
@@ -125,6 +141,12 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
|
||||
let event_publisher_arc = create_event_publisher(event_bus, &db_pool).await?;
|
||||
#[cfg(not(feature = "federation"))]
|
||||
let ap_router = axum::Router::new();
|
||||
#[cfg(not(feature = "federation"))]
|
||||
let social_command_arc: Arc<dyn domain::ports::SocialCommand> =
|
||||
Arc::new(domain::ports::noop::NoopSocialCommand);
|
||||
#[cfg(not(feature = "federation"))]
|
||||
let social_query_unified_arc: Arc<dyn domain::ports::SocialQuery> =
|
||||
Arc::new(domain::ports::noop::NoopSocialQuery);
|
||||
|
||||
let review_logger = Arc::new(application::diary::review_logger::DefaultReviewLogger::new(
|
||||
Arc::clone(&db.movie_command),
|
||||
@@ -159,10 +181,12 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
|
||||
remote_watchlist: remote_watchlist_repo,
|
||||
#[cfg(not(feature = "federation"))]
|
||||
remote_watchlist: Arc::new(domain::ports::noop::NoopRemoteWatchlistRepository),
|
||||
social_command: social_command_arc,
|
||||
social_query_unified: social_query_unified_arc,
|
||||
#[cfg(feature = "federation")]
|
||||
social_query: social_query.clone(),
|
||||
federation_admin: social_query.clone(),
|
||||
#[cfg(not(feature = "federation"))]
|
||||
social_query: Arc::new(domain::ports::noop::NoopSocialQueryPort),
|
||||
federation_admin: Arc::new(domain::ports::noop::NoopFederationAdminQuery),
|
||||
wrapup_stats: db.wrapup_stats,
|
||||
wrapup_repo: db.wrapup_repo,
|
||||
goal_command: db.goal_command,
|
||||
@@ -199,6 +223,8 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
|
||||
document_parser: Arc::new(ImporterDocumentParser) as Arc<dyn DocumentParser>,
|
||||
review_logger,
|
||||
person_enrichment: None,
|
||||
#[cfg(feature = "federation")]
|
||||
ap_service,
|
||||
},
|
||||
config: app_config,
|
||||
};
|
||||
@@ -208,8 +234,6 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
|
||||
rss_renderer: Arc::new(RssAdapter::new(
|
||||
std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:3000".into()),
|
||||
)),
|
||||
#[cfg(feature = "federation")]
|
||||
ap_service,
|
||||
};
|
||||
Ok((state, ap_router))
|
||||
}
|
||||
|
||||
@@ -8,6 +8,4 @@ use domain::ports::RssFeedRenderer;
|
||||
pub struct AppState {
|
||||
pub app_ctx: AppContext,
|
||||
pub rss_renderer: Arc<dyn RssFeedRenderer>,
|
||||
#[cfg(feature = "federation")]
|
||||
pub ap_service: Arc<dyn activitypub::ActivityPubPort>,
|
||||
}
|
||||
|
||||
@@ -154,30 +154,6 @@ impl DiaryQuery for Panic {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "federation")]
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::SocialQueryPort for Panic {
|
||||
async fn get_accepted_following_urls(&self, _: &UserId) -> Result<Vec<String>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn list_all_followed_remote_actors(
|
||||
&self,
|
||||
) -> Result<Vec<domain::models::RemoteActorInfo>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn count_following(&self, _: &UserId) -> Result<usize, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn count_accepted_followers(&self, _: &UserId) -> Result<usize, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn get_pending_followers(
|
||||
&self,
|
||||
_: &UserId,
|
||||
) -> Result<Vec<domain::models::PendingFollowerInfo>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl StatsRepository for Panic {
|
||||
async fn get_user_stats(&self, _: &UserId) -> Result<UserStats, DomainError> {
|
||||
@@ -811,7 +787,9 @@ pub fn make_test_state(auth_service: Arc<dyn AuthService>) -> crate::state::AppS
|
||||
search_port: Arc::clone(&repo) as _,
|
||||
search_command: Arc::clone(&repo) as _,
|
||||
remote_watchlist: Arc::clone(&repo) as _,
|
||||
social_query: Arc::clone(&repo) as _,
|
||||
social_command: Arc::new(domain::ports::noop::NoopSocialCommand) as _,
|
||||
social_query_unified: Arc::new(domain::ports::noop::NoopSocialQuery) as _,
|
||||
federation_admin: Arc::new(domain::ports::noop::NoopFederationAdminQuery) as _,
|
||||
wrapup_stats: Arc::clone(&repo) as _,
|
||||
wrapup_repo: Arc::clone(&repo) as _,
|
||||
goal_command: Arc::clone(&repo) as _,
|
||||
@@ -832,6 +810,8 @@ pub fn make_test_state(auth_service: Arc<dyn AuthService>) -> crate::state::AppS
|
||||
document_parser: Arc::clone(&repo) as _,
|
||||
review_logger: Arc::clone(&repo) as _,
|
||||
person_enrichment: None,
|
||||
#[cfg(feature = "federation")]
|
||||
ap_service: Arc::new(activitypub::NoopActivityPubService),
|
||||
},
|
||||
config: AppConfig {
|
||||
allow_registration: false,
|
||||
@@ -846,8 +826,6 @@ pub fn make_test_state(auth_service: Arc<dyn AuthService>) -> crate::state::AppS
|
||||
},
|
||||
},
|
||||
rss_renderer: Arc::new(Panic),
|
||||
#[cfg(feature = "federation")]
|
||||
ap_service: Arc::new(activitypub::NoopActivityPubService),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -372,9 +372,6 @@ impl SearchCommand for PanicSearchCommand {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
struct PanicSocialQuery;
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
struct PanicRemoteWatchlist;
|
||||
#[cfg(feature = "federation")]
|
||||
@@ -402,40 +399,6 @@ impl domain::ports::RemoteWatchlistRepository for PanicRemoteWatchlist {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "federation")]
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::SocialQueryPort for PanicSocialQuery {
|
||||
async fn get_accepted_following_urls(
|
||||
&self,
|
||||
_: &domain::value_objects::UserId,
|
||||
) -> Result<Vec<String>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn list_all_followed_remote_actors(
|
||||
&self,
|
||||
) -> Result<Vec<domain::models::RemoteActorInfo>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn count_following(
|
||||
&self,
|
||||
_: &domain::value_objects::UserId,
|
||||
) -> Result<usize, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn count_accepted_followers(
|
||||
&self,
|
||||
_: &domain::value_objects::UserId,
|
||||
) -> Result<usize, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn get_pending_followers(
|
||||
&self,
|
||||
_: &domain::value_objects::UserId,
|
||||
) -> Result<Vec<domain::models::PendingFollowerInfo>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
|
||||
async fn test_app() -> Router {
|
||||
let pool = SqlitePool::connect("sqlite::memory:")
|
||||
.await
|
||||
@@ -464,7 +427,9 @@ async fn test_app() -> Router {
|
||||
search_port: Arc::new(PanicSearchPort),
|
||||
search_command: Arc::new(PanicSearchCommand),
|
||||
remote_watchlist: Arc::new(PanicRemoteWatchlist),
|
||||
social_query: Arc::new(PanicSocialQuery),
|
||||
social_command: Arc::new(domain::ports::noop::NoopSocialCommand),
|
||||
social_query_unified: Arc::new(domain::ports::noop::NoopSocialQuery),
|
||||
federation_admin: Arc::new(domain::ports::noop::NoopFederationAdminQuery) as _,
|
||||
wrapup_stats: Arc::new(domain::testing::PanicWrapUpStatsQuery) as _,
|
||||
wrapup_repo: Arc::new(domain::testing::PanicWrapUpRepository) as _,
|
||||
goal_command: Arc::new(domain::testing::NoopGoalCommand),
|
||||
@@ -485,6 +450,8 @@ async fn test_app() -> Router {
|
||||
document_parser: Arc::new(PanicDocumentParser),
|
||||
review_logger: Arc::new(PanicReviewLogger),
|
||||
person_enrichment: None,
|
||||
#[cfg(feature = "federation")]
|
||||
ap_service: Arc::new(activitypub::NoopActivityPubService),
|
||||
},
|
||||
config: AppConfig {
|
||||
allow_registration: false,
|
||||
@@ -499,8 +466,6 @@ async fn test_app() -> Router {
|
||||
},
|
||||
},
|
||||
rss_renderer: Arc::new(RssAdapter::new("http://localhost:3000".into())),
|
||||
#[cfg(feature = "federation")]
|
||||
ap_service: Arc::new(activitypub::NoopActivityPubService),
|
||||
};
|
||||
|
||||
routes::build_router(state, axum::Router::new())
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::{errors::DomainError, events::DomainEvent, ports::EventHandler};
|
||||
use domain::{
|
||||
errors::DomainError, events::DomainEvent, ports::EventHandler, value_objects::SocialIdentity,
|
||||
};
|
||||
|
||||
pub struct FollowBackfillHandler {
|
||||
pub ap_service: Arc<dyn activitypub::ActivityPubPort>,
|
||||
@@ -12,15 +14,27 @@ impl EventHandler for FollowBackfillHandler {
|
||||
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> {
|
||||
match event {
|
||||
DomainEvent::FollowAccepted {
|
||||
remote_actor_url,
|
||||
outbox_url,
|
||||
..
|
||||
owner,
|
||||
requester: SocialIdentity::Remote { actor_url },
|
||||
} => {
|
||||
tracing::info!(actor = %remote_actor_url, outbox = %outbox_url, "importing remote outbox");
|
||||
self.ap_service
|
||||
.import_remote_outbox(outbox_url, remote_actor_url)
|
||||
tracing::info!(actor = %actor_url, "follow accepted — looking up outbox for import");
|
||||
let following = self
|
||||
.ap_service
|
||||
.get_following(owner.value())
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
if let Some(actor) = following.iter().find(|a| a.url == *actor_url) {
|
||||
if let Some(outbox_url) = &actor.outbox_url {
|
||||
tracing::info!(outbox = %outbox_url, actor = %actor_url, "importing remote outbox");
|
||||
self.ap_service
|
||||
.import_remote_outbox(outbox_url, actor_url)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
} else {
|
||||
tracing::warn!(actor = %actor_url, "no outbox URL for accepted follow — skipping import");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
DomainEvent::BackfillFollower {
|
||||
owner_user_id,
|
||||
|
||||
11
docs/adr/0002-unified-social-identity-layer.md
Normal file
11
docs/adr/0002-unified-social-identity-layer.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# Unified social identity layer — wrap k_ap, don't gut it
|
||||
|
||||
Social interactions (follow, unfollow, block, etc.) bypassed the application layer entirely — handlers called the ActivityPub adapter (`k_ap`) directly, and there was no concept of a local-only follow. Every social operation was implicitly federated, with no domain-level orchestration, no CQRS split, and no domain events for most actions. This made it impossible to add local social features without duplicating logic, and meant the codebase would drift as federation and local paths diverged.
|
||||
|
||||
We introduce a `SocialIdentity` value object (`Local(UserId)` | `Remote { actor_url }`) in the domain layer. Social command and query ports (`SocialCommand` / `SocialQuery`) accept `SocialIdentity` instead of raw UUIDs or actor URLs. Application-layer use cases follow the existing CQRS pattern (command/query structs, separate deps, one file per use case, domain events on mutations). The adapter implementing `SocialCommand` branches on the identity variant: local goes straight to the database, remote delegates to `k_ap`. `k_ap` stays batteries-included and unchanged — this project just wraps it rather than reaching through it.
|
||||
|
||||
## Considered Options
|
||||
|
||||
- **Gut `k_ap` into a thin transport layer** — rejected because `k_ap` is shared with other projects (`thoughts`) that rely on its batteries-included API. Forcing all consumers to rewrite social orchestration defeats the purpose of the library.
|
||||
- **Two-tier API in `k_ap`** (high-level + low-level primitives) — rejected because it adds complexity to `k_ap` for one consumer's needs. Wrapping at the adapter boundary in movies-diary is simpler and keeps `k_ap` focused.
|
||||
- **Keep the status quo, add local branches in handlers** — rejected because it perpetuates the "no application layer for social" problem and guarantees local/remote drift.
|
||||
Reference in New Issue
Block a user