Files
thoughts/crates/domain/src/ports.rs

548 lines
18 KiB
Rust

use crate::{
errors::DomainError,
events::{DomainEvent, EventEnvelope},
models::{
api_key::ApiKey,
feed::{FeedEntry, PageParams, Paginated, UserSummary},
notification::Notification,
remote_actor::RemoteActor,
social::{Block, Boost, Follow, FollowState, Like},
tag::Tag,
thought::Thought,
top_friend::TopFriend,
user::User,
},
value_objects::{
ApiKeyId, Content, Email, NotificationId, PasswordHash, ThoughtId, UserId, Username,
},
};
use async_trait::async_trait;
pub struct GeneratedToken {
pub token: String,
pub user_id: UserId,
}
#[async_trait]
pub trait AuthService: Send + Sync {
fn generate_token(&self, user_id: &UserId) -> Result<GeneratedToken, DomainError>;
fn validate_token(&self, token: &str) -> Result<UserId, DomainError>;
}
#[async_trait]
pub trait PasswordHasher: Send + Sync {
async fn hash(&self, plain: &str) -> Result<PasswordHash, DomainError>;
async fn verify(&self, plain: &str, hash: &PasswordHash) -> Result<bool, DomainError>;
}
#[async_trait]
pub trait EventPublisher: Send + Sync {
async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError>;
}
pub trait EventConsumer: Send + Sync {
fn consume(&self) -> futures::stream::BoxStream<'_, Result<EventEnvelope, DomainError>>;
}
#[async_trait]
pub trait OutboxWriter: Send + Sync {
async fn append(&self, event: &DomainEvent) -> Result<(), DomainError>;
}
#[async_trait]
pub trait UserReader: Send + Sync {
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError>;
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError>;
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError>;
async fn list_with_stats(&self) -> Result<Vec<UserSummary>, DomainError>;
async fn count(&self) -> Result<i64, DomainError>;
}
#[async_trait]
pub trait UserWriter: Send + Sync {
async fn save(&self, user: &User) -> Result<(), DomainError>;
async fn update_profile(
&self,
user_id: &UserId,
display_name: Option<String>,
bio: Option<String>,
avatar_url: Option<String>,
header_url: Option<String>,
custom_css: Option<String>,
) -> Result<(), DomainError>;
}
/// Combined supertrait — `AppState.users` stays `Arc<dyn UserRepository>`.
/// Blanket impl: any type implementing both sub-traits gets `UserRepository` for free.
pub trait UserRepository: UserReader + UserWriter {}
impl<T: UserReader + UserWriter> UserRepository for T {}
#[async_trait]
pub trait ThoughtRepository: Send + Sync {
async fn save(&self, thought: &Thought) -> Result<(), DomainError>;
async fn find_by_id(&self, id: &ThoughtId) -> Result<Option<Thought>, DomainError>;
async fn delete(&self, id: &ThoughtId, user_id: &UserId) -> Result<(), DomainError>;
async fn update_content(&self, id: &ThoughtId, content: &Content) -> Result<(), DomainError>;
async fn get_thread(&self, id: &ThoughtId) -> Result<Vec<Thought>, DomainError>;
async fn list_by_user(
&self,
user_id: &UserId,
page: &PageParams,
) -> Result<Paginated<Thought>, DomainError>;
}
#[async_trait]
pub trait LikeRepository: Send + Sync {
async fn save(&self, like: &Like) -> Result<(), DomainError>;
async fn delete(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError>;
async fn find(
&self,
user_id: &UserId,
thought_id: &ThoughtId,
) -> Result<Option<Like>, DomainError>;
async fn count_for_thought(&self, thought_id: &ThoughtId) -> Result<i64, DomainError>;
}
#[async_trait]
pub trait BoostRepository: Send + Sync {
async fn save(&self, boost: &Boost) -> Result<(), DomainError>;
async fn delete(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError>;
async fn find(
&self,
user_id: &UserId,
thought_id: &ThoughtId,
) -> Result<Option<Boost>, DomainError>;
async fn count_for_thought(&self, thought_id: &ThoughtId) -> Result<i64, DomainError>;
}
#[async_trait]
pub trait FollowRepository: Send + Sync {
async fn save(&self, follow: &Follow) -> Result<(), DomainError>;
async fn delete(&self, follower_id: &UserId, following_id: &UserId) -> Result<(), DomainError>;
async fn find(
&self,
follower_id: &UserId,
following_id: &UserId,
) -> Result<Option<Follow>, DomainError>;
async fn update_state(
&self,
follower_id: &UserId,
following_id: &UserId,
state: &FollowState,
) -> Result<(), DomainError>;
async fn list_followers(
&self,
user_id: &UserId,
page: &PageParams,
) -> Result<Paginated<User>, DomainError>;
async fn list_following(
&self,
user_id: &UserId,
page: &PageParams,
) -> Result<Paginated<User>, DomainError>;
async fn get_accepted_following_ids(
&self,
user_id: &UserId,
) -> Result<Vec<UserId>, DomainError>;
}
#[async_trait]
pub trait BlockRepository: Send + Sync {
async fn save(&self, block: &Block) -> Result<(), DomainError>;
async fn delete(&self, blocker_id: &UserId, blocked_id: &UserId) -> Result<(), DomainError>;
async fn exists(&self, blocker_id: &UserId, blocked_id: &UserId) -> Result<bool, DomainError>;
}
#[async_trait]
pub trait TagRepository: Send + Sync {
async fn find_or_create(&self, name: &str) -> Result<Tag, DomainError>;
async fn attach_to_thought(
&self,
thought_id: &ThoughtId,
tag_id: i32,
) -> Result<(), DomainError>;
async fn detach_from_thought(&self, thought_id: &ThoughtId) -> Result<(), DomainError>;
async fn list_for_thought(&self, thought_id: &ThoughtId) -> Result<Vec<Tag>, DomainError>;
async fn list_thoughts_by_tag(
&self,
tag_name: &str,
page: &PageParams,
) -> Result<Paginated<Thought>, DomainError>;
/// Returns (tag_name, thought_count) pairs ordered by usage, most popular first.
async fn popular_tags(&self, limit: usize) -> Result<Vec<(String, i64)>, DomainError>;
}
#[async_trait]
pub trait ApiKeyRepository: Send + Sync {
async fn save(&self, key: &ApiKey) -> Result<(), DomainError>;
async fn find_by_hash(&self, key_hash: &str) -> Result<Option<ApiKey>, DomainError>;
async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<ApiKey>, DomainError>;
async fn delete(&self, id: &ApiKeyId, user_id: &UserId) -> Result<(), DomainError>;
}
#[async_trait]
pub trait TopFriendRepository: Send + Sync {
async fn set_top_friends(
&self,
user_id: &UserId,
friends: Vec<(UserId, i16)>,
) -> Result<(), DomainError>;
async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<(TopFriend, User)>, DomainError>;
}
#[async_trait]
pub trait NotificationRepository: Send + Sync {
async fn save(&self, n: &Notification) -> Result<(), DomainError>;
async fn list_for_user(
&self,
user_id: &UserId,
page: &PageParams,
) -> Result<Paginated<Notification>, DomainError>;
async fn count_unread(&self, user_id: &UserId) -> Result<u64, DomainError>;
async fn mark_read(&self, id: &NotificationId, user_id: &UserId) -> Result<(), DomainError>;
async fn mark_all_read(&self, user_id: &UserId) -> Result<(), DomainError>;
}
#[async_trait]
pub trait RemoteActorRepository: Send + Sync {
async fn upsert(&self, actor: &RemoteActor) -> Result<(), DomainError>;
async fn find_by_url(&self, url: &str) -> Result<Option<RemoteActor>, DomainError>;
}
#[async_trait]
pub trait RemoteActorConnectionRepository: Send + Sync {
async fn upsert_connections(
&self,
actor_url: &str,
connection_type: &str,
page: u32,
actors: &[crate::models::actor_connection_summary::ActorConnectionSummary],
) -> Result<(), DomainError>;
async fn list_connections(
&self,
actor_url: &str,
connection_type: &str,
page: u32,
) -> Result<Vec<crate::models::actor_connection_summary::ActorConnectionSummary>, DomainError>;
async fn connection_page_age(
&self,
actor_url: &str,
connection_type: &str,
page: u32,
) -> Result<Option<chrono::DateTime<chrono::Utc>>, DomainError>;
}
#[async_trait]
pub trait FederationLookupPort: Send + Sync {
async fn lookup_actor(&self, handle: &str) -> Result<RemoteActor, DomainError>;
async fn actor_json(&self, user_id: &UserId) -> Result<String, DomainError>;
async fn followers_collection_json(
&self,
user_id: &UserId,
page: Option<u32>,
) -> Result<String, DomainError>;
async fn following_collection_json(
&self,
user_id: &UserId,
page: Option<u32>,
) -> Result<String, DomainError>;
}
#[async_trait]
pub trait FederationFollowPort: Send + Sync {
async fn follow_remote(&self, local_user_id: &UserId, handle: &str) -> Result<(), DomainError>;
async fn unfollow_remote(
&self,
local_user_id: &UserId,
handle: &str,
) -> Result<(), DomainError>;
async fn get_remote_following(&self, user_id: &UserId)
-> Result<Vec<RemoteActor>, DomainError>;
}
#[async_trait]
pub trait FederationFollowRequestPort: Send + Sync {
async fn get_pending_followers(
&self,
user_id: &UserId,
) -> Result<Vec<RemoteActor>, DomainError>;
async fn accept_follow_request(
&self,
user_id: &UserId,
actor_url: &str,
) -> Result<(), DomainError>;
async fn reject_follow_request(
&self,
user_id: &UserId,
actor_url: &str,
) -> Result<(), DomainError>;
async fn get_remote_followers(&self, user_id: &UserId)
-> Result<Vec<RemoteActor>, DomainError>;
async fn remove_remote_follower(
&self,
user_id: &UserId,
actor_url: &str,
) -> Result<(), DomainError>;
}
#[async_trait]
pub trait FederationFetchPort: Send + Sync {
async fn fetch_outbox_page(
&self,
outbox_url: &str,
page: u32,
) -> Result<Vec<crate::models::remote_note::RemoteNote>, DomainError>;
async fn fetch_actor_urls_from_collection(
&self,
collection_url: &str,
) -> Result<Vec<String>, DomainError>;
async fn resolve_actor_profiles(
&self,
urls: Vec<String>,
) -> Vec<crate::models::actor_connection_summary::ActorConnectionSummary>;
}
pub trait FederationActionPort:
FederationLookupPort + FederationFollowPort + FederationFollowRequestPort + FederationFetchPort
{
}
impl<
T: FederationLookupPort
+ FederationFollowPort
+ FederationFollowRequestPort
+ FederationFetchPort,
> FederationActionPort for T
{
}
#[async_trait]
pub trait FeedRepository: Send + Sync {
async fn home_feed(
&self,
following_ids: &[UserId],
page: &PageParams,
viewer_id: Option<&UserId>,
) -> Result<Paginated<FeedEntry>, DomainError>;
async fn public_feed(
&self,
page: &PageParams,
viewer_id: Option<&UserId>,
) -> Result<Paginated<FeedEntry>, DomainError>;
async fn search(
&self,
query: &str,
page: &PageParams,
viewer_id: Option<&UserId>,
) -> Result<Paginated<FeedEntry>, DomainError>;
async fn tag_feed(
&self,
tag_name: &str,
page: &PageParams,
viewer_id: Option<&UserId>,
) -> Result<Paginated<FeedEntry>, DomainError>;
async fn user_feed(
&self,
user_id: &UserId,
page: &PageParams,
viewer_id: Option<&UserId>,
) -> Result<Paginated<FeedEntry>, DomainError>;
}
#[async_trait]
pub trait SearchPort: Send + Sync {
/// Full-text search over public thoughts, ranked by trigram similarity.
async fn search_thoughts(
&self,
query: &str,
page: &PageParams,
viewer_id: Option<&UserId>,
) -> Result<Paginated<FeedEntry>, DomainError>;
/// Search users by username or display_name, ranked by trigram similarity.
async fn search_users(
&self,
query: &str,
page: &PageParams,
) -> Result<Paginated<User>, DomainError>;
}
/// AP-protocol endpoints for a locally-stored user (local or interned remote).
#[derive(Debug, Clone)]
pub struct ActorApUrls {
pub ap_id: String,
pub inbox_url: String,
}
/// A local thought ready for AP serialization, with the author's username
/// pre-joined so the handler can build AP URLs without a second query.
#[derive(Debug, Clone)]
pub struct OutboxEntry {
pub thought: crate::models::thought::Thought,
pub author_username: Username,
}
#[async_trait]
pub trait ActivityPubRepository: Send + Sync {
// ── Outbox (local → remote) ──────────────────────────────────────
/// All public local thoughts for this actor. Used for outbox totals
/// and full-collection delivery.
async fn outbox_entries_for_actor(
&self,
user_id: &UserId,
) -> Result<Vec<OutboxEntry>, DomainError>;
/// Cursor page of public local thoughts, newest-first, before `before`.
/// Used for OrderedCollectionPage responses.
async fn outbox_page_for_actor(
&self,
user_id: &UserId,
before: Option<chrono::DateTime<chrono::Utc>>,
limit: usize,
) -> Result<Vec<OutboxEntry>, DomainError>;
// ── Remote actor resolution ──────────────────────────────────────
/// Find the local UserId for a remote actor by its AP URL.
async fn find_remote_actor_id(&self, actor_ap_url: &str)
-> Result<Option<UserId>, DomainError>;
/// Ensure a remote actor placeholder exists; create one if absent.
/// Idempotent — safe to call multiple times with the same URL.
async fn intern_remote_actor(&self, actor_ap_url: &str) -> Result<UserId, DomainError>;
/// Update display_name and avatar_url for an already-interned remote actor.
async fn update_remote_actor_display(
&self,
user_id: &UserId,
display_name: Option<&str>,
avatar_url: Option<&str>,
) -> Result<(), DomainError>;
// ── Inbox processing (remote → local) ───────────────────────────
/// Persist an incoming remote Note. Idempotent on ap_id.
#[allow(clippy::too_many_arguments)]
async fn accept_note(
&self,
ap_id: &str,
author_id: &UserId,
content: &str,
published: chrono::DateTime<chrono::Utc>,
sensitive: bool,
content_warning: Option<String>,
visibility: &str,
in_reply_to: Option<&str>,
) -> Result<(), DomainError>;
/// Apply an Update to a previously accepted remote Note.
async fn apply_note_update(&self, ap_id: &str, new_content: &str) -> Result<(), DomainError>;
/// Remove a specific remote Note (Delete activity). Only touches
/// remotely-originated thoughts.
async fn retract_note(&self, ap_id: &str) -> Result<(), DomainError>;
/// Remove all Notes from a remote actor (actor-level Delete/Tombstone).
async fn retract_actor_notes(&self, actor_ap_url: &str) -> Result<(), DomainError>;
// ── Node-level stats ─────────────────────────────────────────────
/// Total locally-authored thought count for NodeInfo responses.
async fn count_local_notes(&self) -> Result<u64, DomainError>;
/// Return the ActivityPub object URL for a thought, if one is stored.
/// Returns None for local thoughts (caller constructs URL from base_url + thought_id).
async fn get_thought_ap_id(
&self,
thought_id: &ThoughtId,
) -> Result<Option<String>, DomainError>;
/// Return the AP actor URL and inbox URL for a user, if stored.
/// Returns None for users that have not been federated.
async fn get_actor_ap_urls(&self, user_id: &UserId)
-> Result<Option<ActorApUrls>, DomainError>;
}
#[async_trait]
pub trait OutboundFederationPort: Send + Sync {
/// Fan out a new local Note to all accepted followers.
async fn broadcast_create(
&self,
author_user_id: &UserId,
thought: &Thought,
author_username: &str,
in_reply_to_url: Option<&str>,
) -> Result<(), DomainError>;
/// Fan out a Delete tombstone for a now-deleted local Note.
/// `thought_ap_id` is pre-constructed by the caller because the thought
/// has already been deleted from the DB when this fires.
async fn broadcast_delete(
&self,
author_user_id: &UserId,
thought_ap_id: &str,
) -> Result<(), DomainError>;
/// Fan out an Update(Note) for an edited local thought.
async fn broadcast_update(
&self,
author_user_id: &UserId,
thought: &Thought,
author_username: &str,
in_reply_to_url: Option<&str>,
) -> Result<(), DomainError>;
/// Fan out an Announce(object_ap_id) for a boost.
async fn broadcast_announce(
&self,
booster_user_id: &UserId,
object_ap_id: &str,
) -> Result<(), DomainError>;
/// Fan out an Undo(Announce) to followers when a boost is removed.
async fn broadcast_undo_announce(
&self,
booster_user_id: &UserId,
object_ap_id: &str,
) -> Result<(), DomainError>;
/// Send a Like activity to a remote thought author's inbox.
/// Only called when a LOCAL user likes a REMOTE thought (one with an ap_id).
async fn broadcast_like(
&self,
liker_user_id: &UserId,
object_ap_id: &str,
author_inbox_url: &str,
) -> Result<(), DomainError>;
/// Send Undo(Like) to a remote thought author's inbox.
async fn broadcast_undo_like(
&self,
liker_user_id: &UserId,
object_ap_id: &str,
author_inbox_url: &str,
) -> Result<(), DomainError>;
/// Fan out an Update(Actor) to all accepted followers after a profile change.
async fn broadcast_actor_update(&self, user_id: &UserId) -> Result<(), DomainError>;
}
#[async_trait]
pub trait FederationSchedulerPort: Send + Sync {
async fn schedule_actor_posts_fetch(
&self,
actor_ap_url: &str,
outbox_url: &str,
) -> Result<(), DomainError>;
async fn schedule_connections_fetch(
&self,
actor_ap_url: &str,
collection_url: &str,
connection_type: &str,
page: u32,
) -> Result<(), DomainError>;
}