feat: unified social identity layer — SocialIdentity, ports, use cases, adapter

SocialIdentity value object (Local|Remote), SocialCommand/SocialQuery
domain ports, 11 CQRS use cases w/ 14 tests, CompositeSocialAdapter
wrapping k_ap, 6 new domain events, handlers migrated from ap_service
to use cases. Closes #12 foundation — AP handlers + legacy cleanup TBD.
This commit is contained in:
2026-07-10 15:29:46 +02:00
parent 96ce5f7d26
commit 322e9ee81a
45 changed files with 2095 additions and 160 deletions

View File

@@ -6,9 +6,9 @@ use domain::ports::{
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,
SocialCommand, SocialQuery, SocialQueryPort, StatsRepository, UserProfileFieldsRepository,
UserRepository, UserSettingsRepository, WatchEventCommand, WatchEventQuery,
WatchlistRepository, WebhookTokenRepository, WrapUpRepository, WrapUpStatsQuery,
};
use application::config::AppConfig;
@@ -35,6 +35,8 @@ pub struct Repositories {
pub search_command: Arc<dyn SearchCommand>,
pub profile_fields: Arc<dyn UserProfileFieldsRepository>,
pub remote_watchlist: Arc<dyn RemoteWatchlistRepository>,
pub social_command: Arc<dyn SocialCommand>,
pub social_query_unified: Arc<dyn SocialQuery>,
pub social_query: Arc<dyn SocialQueryPort>,
pub wrapup_stats: Arc<dyn WrapUpStatsQuery>,
pub wrapup_repo: Arc<dyn WrapUpRepository>,

View File

@@ -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::SocialIdentity;
use template_askama::{
BlockedActorsTemplate, BlockedDomainsTemplate, FollowersTemplate, FollowingTemplate,
RemoteActorData,
@@ -33,6 +35,38 @@ fn ap_to_domain(e: anyhow::Error) -> domain::errors::DomainError {
domain::errors::DomainError::InfrastructureError(e.to_string())
}
fn social_identity_to_dto(id: SocialIdentity) -> RemoteActorDto {
match id {
SocialIdentity::Remote { actor_url } => RemoteActorDto {
url: actor_url,
handle: String::new(),
display_name: None,
},
SocialIdentity::Local(uid) => RemoteActorDto {
url: format!("local:{}", uid.value()),
handle: String::new(),
display_name: None,
},
}
}
fn social_identity_to_blocked_dto(id: SocialIdentity) -> BlockedActorResponse {
match id {
SocialIdentity::Remote { actor_url } => BlockedActorResponse {
url: actor_url,
handle: String::new(),
display_name: None,
avatar_url: None,
},
SocialIdentity::Local(uid) => BlockedActorResponse {
url: format!("local:{}", uid.value()),
handle: String::new(),
display_name: None,
avatar_url: None,
},
}
}
// ── API ──────────────────────────────────────────────────────────────────────
#[utoipa::path(
@@ -125,11 +159,21 @@ 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 {
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(),
};
application::social::block::execute(
&deps,
application::social::commands::BlockCommand {
blocker_id: user.0.value(),
target: SocialIdentity::Remote {
actor_url: body.actor_url,
},
},
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
@@ -147,11 +191,21 @@ 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 {
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(),
};
application::social::unblock::execute(
&deps,
application::social::commands::UnblockCommand {
blocker_id: user.0.value(),
target: SocialIdentity::Remote {
actor_url: body.actor_url,
},
},
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
@@ -167,20 +221,20 @@ 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 {
social_query: state.app_ctx.repos.social_query_unified.clone(),
};
let identities = application::social::get_blocked::execute(
&deps,
application::social::queries::GetBlockedQuery {
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_identity_to_blocked_dto)
.collect(),
))
}
@@ -197,15 +251,20 @@ 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 {
social_query: state.app_ctx.repos.social_query_unified.clone(),
};
let identities = application::social::get_following::execute(
&deps,
application::social::queries::GetFollowingQuery {
user_id: user.0.value(),
},
)
.await?;
Ok(Json(ActorListResponse {
actors: actors
actors: identities
.into_iter()
.map(crate::mappers::social::remote_actor_to_dto)
.map(social_identity_to_dto)
.collect(),
}))
}
@@ -222,15 +281,20 @@ 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 {
social_query: state.app_ctx.repos.social_query_unified.clone(),
};
let identities = application::social::get_followers::execute(
&deps,
application::social::queries::GetFollowersQuery {
user_id: user.0.value(),
},
)
.await?;
Ok(Json(ActorListResponse {
actors: actors
actors: identities
.into_iter()
.map(crate::mappers::social::remote_actor_to_dto)
.map(social_identity_to_dto)
.collect(),
}))
}
@@ -240,15 +304,18 @@ 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 {
social_query: state.app_ctx.repos.social_query_unified.clone(),
};
let identities = application::social::get_following::execute(
&deps,
application::social::queries::GetFollowingQuery { user_id },
)
.await?;
Ok(Json(ActorListResponse {
actors: actors
actors: identities
.into_iter()
.map(crate::mappers::social::remote_actor_to_dto)
.map(social_identity_to_dto)
.collect(),
}))
}
@@ -258,15 +325,18 @@ 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 {
social_query: state.app_ctx.repos.social_query_unified.clone(),
};
let identities = application::social::get_followers::execute(
&deps,
application::social::queries::GetFollowersQuery { user_id },
)
.await?;
Ok(Json(ActorListResponse {
actors: actors
actors: identities
.into_iter()
.map(crate::mappers::social::remote_actor_to_dto)
.map(social_identity_to_dto)
.collect(),
}))
}
@@ -285,11 +355,21 @@ 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 {
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(),
};
application::social::follow::execute(
&deps,
application::social::commands::FollowCommand {
follower_id: user.0.value(),
target: SocialIdentity::Remote {
actor_url: body.handle,
},
},
)
.await?;
Ok(StatusCode::OK)
}
@@ -307,11 +387,21 @@ 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 {
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(),
};
application::social::unfollow::execute(
&deps,
application::social::commands::UnfollowCommand {
follower_id: user.0.value(),
target: SocialIdentity::Remote {
actor_url: body.actor_url,
},
},
)
.await?;
Ok(StatusCode::OK)
}
@@ -329,11 +419,21 @@ 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 {
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(),
};
application::social::accept::execute(
&deps,
application::social::commands::AcceptFollowCommand {
owner_id: user.0.value(),
requester: SocialIdentity::Remote {
actor_url: body.actor_url,
},
},
)
.await?;
Ok(StatusCode::OK)
}
@@ -351,11 +451,21 @@ 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 {
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(),
};
application::social::reject::execute(
&deps,
application::social::commands::RejectFollowCommand {
owner_id: user.0.value(),
requester: SocialIdentity::Remote {
actor_url: body.actor_url,
},
},
)
.await?;
Ok(StatusCode::OK)
}
@@ -373,11 +483,21 @@ 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 {
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(),
};
application::social::remove_follower::execute(
&deps,
application::social::commands::RemoveFollowerCommand {
owner_id: user.0.value(),
follower: SocialIdentity::Remote {
actor_url: body.actor_url,
},
},
)
.await?;
Ok(StatusCode::OK)
}
@@ -393,15 +513,20 @@ 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 {
social_query: state.app_ctx.repos.social_query_unified.clone(),
};
let identities = application::social::get_pending::execute(
&deps,
application::social::queries::GetPendingFollowersQuery {
user_id: user.0.value(),
},
)
.await?;
Ok(Json(ActorListResponse {
actors: actors
actors: identities
.into_iter()
.map(crate::mappers::social::remote_actor_to_dto)
.map(social_identity_to_dto)
.collect(),
}))
}
@@ -428,7 +553,20 @@ 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 {
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(),
};
match application::social::follow::execute(
&deps,
application::social::commands::FollowCommand {
follower_id: user_id.value(),
target: SocialIdentity::Remote { actor_url: form.handle },
},
)
.await
{
Ok(()) => Redirect::to(&redirect_base).into_response(),
Err(e) => {
tracing::error!("follow error: {:?}", e);
@@ -456,10 +594,19 @@ 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)
.await
let deps = SocialCommandDeps {
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(),
};
match application::social::unfollow::execute(
&deps,
application::social::commands::UnfollowCommand {
follower_id: user_id.value(),
target: SocialIdentity::Remote { actor_url: form.actor_url },
},
)
.await
{
Ok(()) => {
Redirect::to(&format!("/users/{}/following-list", profile_user_uuid)).into_response()
@@ -488,10 +635,19 @@ 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)
.await
let deps = SocialCommandDeps {
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(),
};
match application::social::accept::execute(
&deps,
application::social::commands::AcceptFollowCommand {
owner_id: user_id.value(),
requester: SocialIdentity::Remote { actor_url: form.actor_url },
},
)
.await
{
Ok(_) => Redirect::to(&format!("/users/{}", profile_user_uuid)).into_response(),
Err(e) => {
@@ -514,10 +670,19 @@ 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)
.await
let deps = SocialCommandDeps {
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(),
};
match application::social::reject::execute(
&deps,
application::social::commands::RejectFollowCommand {
owner_id: user_id.value(),
requester: SocialIdentity::Remote { actor_url: form.actor_url },
},
)
.await
{
Ok(_) => Redirect::to(&format!("/users/{}", profile_user_uuid)).into_response(),
Err(e) => {
@@ -698,10 +863,19 @@ 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)
.await
let deps = SocialCommandDeps {
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(),
};
match application::social::remove_follower::execute(
&deps,
application::social::commands::RemoveFollowerCommand {
owner_id: user_id.value(),
follower: SocialIdentity::Remote { actor_url: form.actor_url },
},
)
.await
{
Ok(_) => {
Redirect::to(&format!("/users/{}/followers-list", profile_user_uuid)).into_response()
@@ -838,10 +1012,19 @@ 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)
.await
let deps = SocialCommandDeps {
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(),
};
match application::social::block::execute(
&deps,
application::social::commands::BlockCommand {
blocker_id: user_id.value(),
target: SocialIdentity::Remote { actor_url: form.actor_url },
},
)
.await
{
Ok(()) => Redirect::to("/social/blocked").into_response(),
Err(e) => {
@@ -860,10 +1043,19 @@ 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)
.await
let deps = SocialCommandDeps {
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(),
};
match application::social::unblock::execute(
&deps,
application::social::commands::UnblockCommand {
blocker_id: user_id.value(),
target: SocialIdentity::Remote { actor_url: form.actor_url },
},
)
.await
{
Ok(()) => Redirect::to("/social/blocked").into_response(),
Err(e) => {

View File

@@ -66,7 +66,7 @@ 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 +112,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 +133,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,6 +173,8 @@ 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(),
#[cfg(not(feature = "federation"))]

View File

@@ -811,6 +811,8 @@ 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_command: Arc::new(domain::ports::noop::NoopSocialCommand) as _,
social_query_unified: Arc::new(domain::ports::noop::NoopSocialQuery) as _,
social_query: Arc::clone(&repo) as _,
wrapup_stats: Arc::clone(&repo) as _,
wrapup_repo: Arc::clone(&repo) as _,