use async_trait::async_trait; use chrono::{DateTime, Utc}; use crate::{ errors::DomainError, events::{DomainEvent, EventEnvelope}, models::{ AnnotatedRow, DiaryEntry, DiaryFilter, ExportFormat, FeedEntry, FieldMapping, FileFormat, ImportError, ImportProfile, ImportSession, Movie, MovieProfile, MovieStats, ParsedFile, Review, ReviewHistory, User, UserStats, UserSummary, UserTrends, EntityType, ExternalPersonId, IndexableDocument, Person, PersonCredits, PersonId, SearchQuery, SearchResults, collections::{self, PageParams, Paginated}, }, value_objects::{ Email, ExternalMetadataId, ImportProfileId, ImportSessionId, MovieId, MovieTitle, PasswordHash, PosterUrl, ReleaseYear, ReviewId, UserId, Username, }, }; pub trait DocumentParser: Send + Sync { fn parse(&self, bytes: &[u8], format: FileFormat) -> Result; fn apply_mapping(&self, file: &ParsedFile, mappings: &[FieldMapping]) -> Vec; } #[derive(Debug, Clone, Default, PartialEq)] pub enum FeedSortBy { #[default] Date, DateAsc, Rating, RatingAsc, } impl FeedSortBy { pub fn from_str(s: &str) -> Self { match s { "date_asc" => Self::DateAsc, "rating" => Self::Rating, "rating_asc" => Self::RatingAsc, _ => Self::Date, } } } #[derive(Debug, Clone, Default)] pub struct FollowingFilter { pub local_user_ids: Vec, pub remote_actor_urls: Vec, } #[derive(Debug, Clone)] pub struct RemoteActorInfo { pub url: String, pub handle: String, pub display_name: Option, } /// New trait for social/federation read queries #[async_trait] pub trait SocialQueryPort: Send + Sync { /// Returns all accepted remote_actor_urls followed by `user_id`. async fn get_accepted_following_urls( &self, user_id: uuid::Uuid, ) -> Result, DomainError>; /// Returns all distinct remote actors followed by any local user on this instance. async fn list_all_followed_remote_actors( &self, ) -> Result, DomainError>; } #[async_trait] pub trait MovieRepository: Send + Sync { async fn get_movie_by_external_id( &self, external_metadata_id: &ExternalMetadataId, ) -> Result, DomainError>; async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result, DomainError>; async fn get_movies_by_title_and_year( &self, title: &MovieTitle, year: &ReleaseYear, ) -> Result, DomainError>; async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError>; async fn delete_movie(&self, movie_id: &MovieId) -> Result<(), DomainError>; async fn list_movies( &self, page: &collections::PageParams, search: Option<&str>, ) -> Result, DomainError>; } #[async_trait] pub trait ReviewRepository: Send + Sync { async fn save_review(&self, review: &Review) -> Result; async fn get_review_by_id(&self, review_id: &ReviewId) -> Result, DomainError>; async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError>; async fn get_all_reviews_for_user(&self, user_id: &UserId) -> Result, DomainError>; } #[async_trait] pub trait DiaryRepository: Send + Sync { async fn query_diary(&self, filter: &DiaryFilter) -> Result, DomainError>; async fn query_activity_feed( &self, page: &PageParams, ) -> Result, DomainError>; async fn query_activity_feed_filtered( &self, page: &PageParams, sort_by: &FeedSortBy, search: Option<&str>, following: Option<&FollowingFilter>, ) -> Result, DomainError>; async fn get_review_history(&self, movie_id: &MovieId) -> Result; async fn get_user_history(&self, user_id: &UserId) -> Result, DomainError>; async fn get_movie_stats(&self, movie_id: &MovieId) -> Result; async fn get_movie_social_feed( &self, movie_id: &MovieId, page: &PageParams, ) -> Result, DomainError>; async fn count_local_posts(&self) -> Result; } #[async_trait] pub trait StatsRepository: Send + Sync { async fn get_user_stats(&self, user_id: &UserId) -> Result; async fn get_user_trends(&self, user_id: &UserId) -> Result; } pub enum MetadataSearchCriteria { ImdbId(ExternalMetadataId), Title { title: MovieTitle, year: Option, }, } #[async_trait] pub trait MetadataClient: Send + Sync { async fn fetch_movie_metadata( &self, criteria: &MetadataSearchCriteria, ) -> Result; async fn get_poster_url( &self, external_metadata_id: &ExternalMetadataId, ) -> Result, DomainError>; } #[async_trait] pub trait PosterFetcherClient: Send + Sync { async fn fetch_poster_bytes(&self, poster_url: &PosterUrl) -> Result, DomainError>; } #[async_trait] pub trait ImageStorage: Send + Sync { /// Stores `image_bytes` at `key` and returns the stored key. async fn store(&self, key: &str, image_bytes: &[u8]) -> Result; async fn get(&self, key: &str) -> Result, DomainError>; async fn delete(&self, key: &str) -> Result<(), DomainError>; } pub struct GeneratedToken { pub token: String, pub expires_at: DateTime, } #[async_trait] pub trait AuthService: Send + Sync { async fn generate_token(&self, user_id: &UserId) -> Result; async fn validate_token(&self, token: &str) -> Result; } #[async_trait] pub trait UserRepository: Send + Sync { async fn find_by_email(&self, email: &Email) -> Result, DomainError>; async fn find_by_username(&self, username: &Username) -> Result, DomainError>; async fn save(&self, user: &User) -> Result<(), DomainError>; async fn find_by_id(&self, id: &UserId) -> Result, DomainError>; async fn list_with_stats(&self) -> Result, DomainError>; async fn update_profile( &self, user_id: &UserId, bio: Option, avatar_path: Option, ) -> Result<(), DomainError>; } #[async_trait] pub trait EventPublisher: Send + Sync { async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError>; } pub trait EventConsumer: Send + Sync { /// Returns a stream of event envelopes. Each envelope carries a domain event /// and an ack handle — callers ack after successful dispatch, nack on failure. /// Implementations decide transport (NATS, DB queue, in-memory channel). fn consume(&self) -> futures::stream::BoxStream<'_, Result>; } #[async_trait] pub trait PasswordHasher: Send + Sync { async fn hash(&self, plain_password: &str) -> Result; async fn verify(&self, plain_password: &str, hash: &PasswordHash) -> Result; } #[async_trait] pub trait DiaryExporter: Send + Sync { async fn serialize_entries( &self, entries: &[DiaryEntry], format: ExportFormat, ) -> Result, DomainError>; } #[async_trait] pub trait EventHandler: Send + Sync { async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError>; } #[async_trait] pub trait PeriodicJob: Send + Sync { fn interval(&self) -> std::time::Duration; async fn run(&self) -> Result<(), DomainError>; } #[async_trait] pub trait MovieProfileRepository: Send + Sync { async fn upsert(&self, profile: &MovieProfile) -> Result<(), DomainError>; async fn get_by_movie_id(&self, id: &MovieId) -> Result, DomainError>; /// Returns (movie_id, external_metadata_id) for movies with no profile or a stale one /// (enriched_at older than 30 days). async fn list_stale(&self) -> Result, DomainError>; } #[async_trait] pub trait MovieEnrichmentClient: Send + Sync { /// Resolves an external ID (TMDb or IMDb) and fetches the full movie profile. async fn fetch_profile( &self, movie_id: MovieId, external_metadata_id: &str, ) -> Result; } #[async_trait] pub trait ImportSessionRepository: Send + Sync { async fn create(&self, session: &ImportSession) -> Result<(), DomainError>; async fn get(&self, id: &ImportSessionId, user_id: &UserId) -> Result, DomainError>; async fn update(&self, session: &ImportSession) -> Result<(), DomainError>; async fn delete(&self, id: &ImportSessionId) -> Result<(), DomainError>; async fn delete_expired(&self) -> Result; async fn delete_expired_for_user(&self, user_id: &UserId) -> Result<(), DomainError>; } #[async_trait] pub trait ImportProfileRepository: Send + Sync { async fn save(&self, profile: &ImportProfile) -> Result<(), DomainError>; async fn list_for_user(&self, user_id: &UserId) -> Result, DomainError>; async fn get(&self, id: &ImportProfileId, user_id: &UserId) -> Result, DomainError>; async fn delete(&self, id: &ImportProfileId) -> Result<(), DomainError>; } #[async_trait] pub trait ImageRefCommand: Send + Sync { async fn swap(&self, old_key: &str, new_key: &str) -> Result<(), DomainError>; } #[async_trait] pub trait ImageRefQuery: Send + Sync { async fn list_keys(&self) -> Result, DomainError>; } /// Write port — mutates the persons table. No reads. #[async_trait] pub trait PersonCommand: Send + Sync { /// Upsert a batch of persons. Uses INSERT OR REPLACE (SQLite) / ON CONFLICT DO UPDATE (Postgres). async fn upsert_batch(&self, persons: &[Person]) -> Result<(), DomainError>; } /// Read port — queries persons and credits. No mutations. #[async_trait] pub trait PersonQuery: Send + Sync { async fn get_by_id(&self, id: &PersonId) -> Result, DomainError>; async fn get_by_external_id(&self, id: &ExternalPersonId) -> Result, DomainError>; /// Returns the person's full cast and crew credit history across all indexed movies. async fn get_credits(&self, id: &PersonId) -> Result; /// Returns persons who have no remaining entries in movie_cast or movie_crew. /// Called after movie deletion to find index entries that can be pruned. async fn list_orphaned_persons(&self) -> Result, DomainError>; } /// Read port — executes search queries. No mutations. #[async_trait] pub trait SearchPort: Send + Sync { async fn search(&self, query: &SearchQuery) -> Result; } /// Write port — manages the search index. No reads. #[async_trait] pub trait SearchCommand: Send + Sync { /// Add or replace a document in the search index. async fn index(&self, doc: IndexableDocument) -> Result<(), DomainError>; /// Remove a document from the search index by entity type and internal ID string. async fn remove(&self, entity_type: EntityType, id: &str) -> Result<(), DomainError>; }