diff --git a/README.md b/README.md index 311fecc..b3e979a 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ A self-hosted, server-side rendered movie logging system with a full REST API. B - User profiles — display name, bio, avatar, banner, custom profile fields; editable via HTML settings page or REST API - Jellyfin/Plex auto-import — media server sends a webhook on playback stop, movies land in a watch queue; review and confirm with a rating to create diary entries; per-user webhook tokens with SHA-256 auth; setup UI at `/settings/integrations` - Annual Wrap-Up — Spotify Wrapped for movies: per-user and instance-wide year-in-review with stats (top directors, actors, genres, rating distribution, watch time, rewatches, budget analysis), shareable HTML page at `/wrapups/{user_id}/{year}`, downloadable MP4 video with branded slides; admin-triggered or auto-generated in January +- Goals — set a "watch N movies in YEAR" target with a progress bar; progress computed from existing reviews (backwards compatible); per-user federation toggle in settings; displayed on profile (SPA: interactive with create/edit/delete, classic HTML: read-only glassmorphic card) - CSV and JSON diary export - File importer: upload CSV, TSV, JSON, or XLSX from any source (Letterboxd, IMDb, etc.), map columns to domain fields via a step-by-step wizard or REST API, save mapping profiles for repeat imports - REST API v1 (`/api/v1/`) with full feature parity with the HTML interface diff --git a/architecture.mmd b/architecture.mmd index 149c14b..e17a919 100644 --- a/architecture.mmd +++ b/architecture.mmd @@ -19,6 +19,7 @@ graph TB UC_USERS["users
get_users, get_profile,
update_profile"] UC_WATCHLIST["watchlist
add, remove, get"] UC_WRAPUP["wrapup
generate, compute,
list, delete"] + UC_GOALS["goals
create, update, delete,
get, list"] UC_INTEGRATIONS["integrations
webhooks, watch_queue,
confirm, dismiss"] UC_SEARCH["search
execute"] UC_PERSON["person
get, get_credits"] @@ -47,16 +48,17 @@ graph TB M_USER["User, UserSummary"] M_PERSON["Person, PersonId,
PersonCredits"] M_WATCHLIST["WatchlistEntry,
WatchEvent"] + M_GOAL["Goal, GoalWithProgress,
UserSettings, RemoteGoalEntry"] M_WRAPUP["WrapUpReport,
MovieRef, PersonStat"] M_SEARCH["SearchQuery,
SearchResults"] end subgraph Ports["Port Traits (Interfaces)"] - P_REPOS["MovieRepository
ReviewRepository
DiaryRepository
UserRepository
WatchlistRepository
WatchEventRepository
WebhookTokenRepository
ImportSessionRepository
MovieProfileRepository
WrapUpRepository"] + P_REPOS["MovieRepository
ReviewRepository
DiaryRepository
UserRepository
WatchlistRepository
WatchEventRepository
WebhookTokenRepository
ImportSessionRepository
MovieProfileRepository
WrapUpRepository
GoalRepository
UserSettingsRepository"] P_SERVICES["AuthService
MetadataClient
PosterFetcherClient
ObjectStorage
EventPublisher
EventConsumer
PasswordHasher
DiaryExporter
DocumentParser"] P_SEARCH["SearchPort
SearchCommand
PersonQuery
PersonCommand"] - P_FEDERATION["SocialQueryPort
LocalApContentQuery
RemoteWatchlistRepository"] + P_FEDERATION["SocialQueryPort
LocalApContentQuery
RemoteWatchlistRepository
RemoteGoalRepository"] end - EVENTS["DomainEvent enum
ReviewLogged, MovieDiscovered,
SearchReindexRequested, ...
"] + EVENTS["DomainEvent enum
ReviewLogged, MovieDiscovered,
GoalCreated, GoalUpdated,
SearchReindexRequested, ...
"] VO["Value Objects
MovieId, UserId, Rating,
Email, Username, ...
"] end diff --git a/crates/domain/src/testing.rs b/crates/domain/src/testing.rs deleted file mode 100644 index bc82deb..0000000 --- a/crates/domain/src/testing.rs +++ /dev/null @@ -1,1309 +0,0 @@ -#![cfg(any(test, feature = "test-helpers"))] - -use std::collections::HashMap; -use std::sync::{Arc, Mutex}; - -use async_trait::async_trait; -use chrono::Utc; -use uuid::Uuid; - -use crate::{ - errors::DomainError, - events::DomainEvent, - models::{ - AnnotatedRow, DiaryEntry, DiaryFilter, EntityType, ExportFormat, ExternalPersonId, - FeedEntry, FieldMapping, FileFormat, ImportError, ImportProfile, ImportSession, - IndexableDocument, Movie, MovieFilter, MovieProfile, MovieStats, MovieSummary, ParsedFile, - Person, PersonCredits, PersonId, Review, ReviewHistory, SearchQuery, SearchResults, User, - UserStats, UserSummary, UserTrends, WatchlistEntry, WatchlistWithMovie, - collections::{PageParams, Paginated}, - }, - ports::{ - AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher, FeedSortBy, - FollowingFilter, GeneratedToken, ImportProfileRepository, ImportSessionRepository, - MetadataClient, MetadataSearchCriteria, MovieProfileRepository, MovieRepository, - ObjectStorage, PasswordHasher, PersonCommand, PersonQuery, PosterFetcherClient, - ReviewRepository, SearchCommand, SearchPort, StatsRepository, UserProfileFieldsRepository, - UserRepository, WatchlistRepository, WrapUpRepository, - }, - value_objects::{ - Email, ExternalMetadataId, ImportProfileId, ImportSessionId, MovieId, MovieTitle, - PasswordHash, PosterUrl, ReleaseYear, ReviewId, UserId, Username, WrapUpId, - }, -}; - -// ── InMemoryMovieRepository ─────────────────────────────────────────────────── - -pub struct InMemoryMovieRepository { - pub store: Mutex>, -} - -impl InMemoryMovieRepository { - pub fn new() -> Arc { - Arc::new(Self { - store: Mutex::new(HashMap::new()), - }) - } - - pub fn count(&self) -> usize { - self.store.lock().unwrap().len() - } -} - -#[async_trait] -impl MovieRepository for InMemoryMovieRepository { - async fn get_movie_by_external_id( - &self, - external_metadata_id: &ExternalMetadataId, - ) -> Result, DomainError> { - let store = self.store.lock().unwrap(); - Ok(store - .values() - .find(|m| { - m.external_metadata_id() - .map(|e| e.value() == external_metadata_id.value()) - .unwrap_or(false) - }) - .cloned()) - } - - async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result, DomainError> { - Ok(self.store.lock().unwrap().get(&movie_id.value()).cloned()) - } - - async fn get_movies_by_title_and_year( - &self, - title: &MovieTitle, - year: &ReleaseYear, - ) -> Result, DomainError> { - let store = self.store.lock().unwrap(); - Ok(store - .values() - .filter(|m| m.title() == title && m.release_year() == year) - .cloned() - .collect()) - } - - async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError> { - self.store - .lock() - .unwrap() - .insert(movie.id().value(), movie.clone()); - Ok(()) - } - - async fn delete_movie(&self, movie_id: &MovieId) -> Result<(), DomainError> { - self.store.lock().unwrap().remove(&movie_id.value()); - Ok(()) - } - - async fn existing_external_ids( - &self, - ids: &[ExternalMetadataId], - ) -> Result, DomainError> { - let store = self.store.lock().unwrap(); - let known: std::collections::HashSet = store - .values() - .filter_map(|m| m.external_metadata_id().map(|e| e.value().to_string())) - .collect(); - Ok(ids - .iter() - .map(|id| id.value().to_string()) - .filter(|v| known.contains(v)) - .collect()) - } - - async fn existing_title_year_pairs( - &self, - pairs: &[(MovieTitle, ReleaseYear)], - ) -> Result, DomainError> { - let store = self.store.lock().unwrap(); - let known: std::collections::HashSet<(String, u16)> = store - .values() - .map(|m| (m.title().value().to_string(), m.release_year().value())) - .collect(); - Ok(pairs - .iter() - .map(|(t, y)| (t.value().to_string(), y.value())) - .filter(|p| known.contains(p)) - .collect()) - } - - async fn list_movies( - &self, - _page: &crate::models::collections::PageParams, - _filter: &MovieFilter, - ) -> Result, DomainError> { - Ok(Paginated { - items: vec![], - total_count: 0, - limit: 10, - offset: 0, - }) - } -} - -// ── InMemoryReviewRepository ────────────────────────────────────────────────── - -pub struct InMemoryReviewRepository { - store: Mutex>, -} - -impl InMemoryReviewRepository { - pub fn new() -> Arc { - Arc::new(Self { - store: Mutex::new(HashMap::new()), - }) - } - - pub fn count(&self) -> usize { - self.store.lock().unwrap().len() - } -} - -#[async_trait] -impl ReviewRepository for InMemoryReviewRepository { - async fn save_review(&self, review: &Review) -> Result { - self.store - .lock() - .unwrap() - .insert(review.id().value(), review.clone()); - Ok(DomainEvent::ReviewLogged { - review_id: review.id().clone(), - movie_id: review.movie_id().clone(), - user_id: review.user_id().clone(), - rating: review.rating().clone(), - watched_at: *review.watched_at(), - }) - } - - async fn get_review_by_id(&self, review_id: &ReviewId) -> Result, DomainError> { - Ok(self.store.lock().unwrap().get(&review_id.value()).cloned()) - } - - async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError> { - self.store.lock().unwrap().remove(&review_id.value()); - Ok(()) - } - - async fn get_all_reviews_for_user(&self, user_id: &UserId) -> Result, DomainError> { - let store = self.store.lock().unwrap(); - Ok(store - .values() - .filter(|r| r.user_id() == user_id) - .cloned() - .collect()) - } -} - -// ── InMemoryUserRepository ──────────────────────────────────────────────────── - -pub struct InMemoryUserRepository { - pub store: Mutex>, -} - -impl InMemoryUserRepository { - pub fn new() -> Arc { - Arc::new(Self { - store: Mutex::new(HashMap::new()), - }) - } - - pub fn count(&self) -> usize { - self.store.lock().unwrap().len() - } -} - -#[async_trait] -impl UserRepository for InMemoryUserRepository { - async fn find_by_email(&self, email: &Email) -> Result, DomainError> { - let store = self.store.lock().unwrap(); - Ok(store - .values() - .find(|u| u.email().value() == email.value()) - .cloned()) - } - - async fn find_by_username(&self, username: &Username) -> Result, DomainError> { - let store = self.store.lock().unwrap(); - Ok(store - .values() - .find(|u| u.username().value() == username.value()) - .cloned()) - } - - async fn save(&self, user: &User) -> Result<(), DomainError> { - self.store - .lock() - .unwrap() - .insert(user.id().value(), user.clone()); - Ok(()) - } - - async fn find_by_id(&self, id: &UserId) -> Result, DomainError> { - Ok(self.store.lock().unwrap().get(&id.value()).cloned()) - } - - async fn list_with_stats(&self) -> Result, DomainError> { - Ok(vec![]) - } - - async fn update_profile( - &self, - _user_id: &UserId, - _profile: &crate::models::UserProfile, - ) -> Result<(), DomainError> { - Ok(()) - } -} - -// ── InMemoryWatchlistRepository ─────────────────────────────────────────────── - -pub struct InMemoryWatchlistRepository { - store: Mutex>, -} - -impl InMemoryWatchlistRepository { - pub fn new() -> Arc { - Arc::new(Self { - store: Mutex::new(HashMap::new()), - }) - } - - pub fn count(&self) -> usize { - self.store.lock().unwrap().len() - } -} - -#[async_trait] -impl WatchlistRepository for InMemoryWatchlistRepository { - async fn add(&self, entry: &WatchlistEntry) -> Result<(), DomainError> { - let key = (entry.user_id.value(), entry.movie_id.value()); - self.store - .lock() - .unwrap() - .entry(key) - .or_insert_with(|| entry.clone()); - Ok(()) - } - - async fn remove(&self, user_id: &UserId, movie_id: &MovieId) -> Result<(), DomainError> { - let key = (user_id.value(), movie_id.value()); - self.store - .lock() - .unwrap() - .remove(&key) - .ok_or_else(|| DomainError::NotFound("watchlist entry".into()))?; - Ok(()) - } - - async fn remove_if_present( - &self, - user_id: &UserId, - movie_id: &MovieId, - ) -> Result { - let key = (user_id.value(), movie_id.value()); - Ok(self.store.lock().unwrap().remove(&key).is_some()) - } - - async fn get_for_user( - &self, - _user_id: &UserId, - _page: &PageParams, - ) -> Result, DomainError> { - Ok(Paginated { - items: vec![], - total_count: 0, - limit: 10, - offset: 0, - }) - } - - async fn contains(&self, user_id: &UserId, movie_id: &MovieId) -> Result { - let key = (user_id.value(), movie_id.value()); - Ok(self.store.lock().unwrap().contains_key(&key)) - } -} - -// ── NoopEventPublisher ──────────────────────────────────────────────────────── - -pub struct NoopEventPublisher { - pub events: Mutex>, -} - -impl NoopEventPublisher { - pub fn new() -> Arc { - Arc::new(Self { - events: Mutex::new(vec![]), - }) - } - - pub fn published(&self) -> Vec { - self.events.lock().unwrap().clone() - } -} - -#[async_trait] -impl EventPublisher for NoopEventPublisher { - async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError> { - self.events.lock().unwrap().push(event.clone()); - Ok(()) - } -} - -// ── NoopObjectStorage ────────────────────────────────────────────────────────── - -pub struct NoopObjectStorage; - -#[async_trait] -impl ObjectStorage for NoopObjectStorage { - async fn store(&self, key: &str, _image_bytes: &[u8]) -> Result { - Ok(format!("noop://{key}")) - } - - async fn get(&self, _key: &str) -> Result, DomainError> { - Ok(vec![]) - } - - async fn get_stream( - &self, - _key: &str, - ) -> Result>, DomainError> - { - Ok(Box::pin(futures::stream::empty())) - } - - async fn delete(&self, _key: &str) -> Result<(), DomainError> { - Ok(()) - } -} - -// ── FakeAuthService ─────────────────────────────────────────────────────────── - -pub struct FakeAuthService; - -#[async_trait] -impl AuthService for FakeAuthService { - async fn generate_token(&self, user_id: &UserId) -> Result { - Ok(GeneratedToken { - token: user_id.value().to_string(), - expires_at: Utc::now() + chrono::Duration::hours(24), - }) - } - - async fn validate_token(&self, token: &str) -> Result { - Uuid::parse_str(token) - .map(UserId::from_uuid) - .map_err(|_| DomainError::Unauthorized("invalid token".into())) - } -} - -// ── FakePasswordHasher ──────────────────────────────────────────────────────── - -pub struct FakePasswordHasher; - -#[async_trait] -impl PasswordHasher for FakePasswordHasher { - async fn hash(&self, plain_password: &str) -> Result { - PasswordHash::new(format!("hashed:{plain_password}")) - } - - async fn verify(&self, plain_password: &str, hash: &PasswordHash) -> Result { - Ok(hash.value() == format!("hashed:{plain_password}")) - } -} - -// ── FakeMetadataClient ──────────────────────────────────────────────────────── - -pub struct FakeMetadataClient; - -#[async_trait] -impl MetadataClient for FakeMetadataClient { - async fn fetch_movie_metadata( - &self, - _criteria: &MetadataSearchCriteria, - ) -> Result { - Err(DomainError::InfrastructureError( - "fake metadata client".into(), - )) - } - - async fn get_poster_url( - &self, - _external_metadata_id: &ExternalMetadataId, - ) -> Result, DomainError> { - Ok(None) - } -} - -// ── FakeDiaryRepository ─────────────────────────────────────────────────────── - -pub struct FakeDiaryRepository { - histories: Mutex)>>, -} - -impl FakeDiaryRepository { - pub fn new() -> Arc { - Arc::new(Self { - histories: Mutex::new(HashMap::new()), - }) - } - - pub fn seed_history(&self, movie: Movie, reviews: Vec) { - self.histories - .lock() - .unwrap() - .insert(movie.id().value(), (movie, reviews)); - } -} - -#[async_trait] -impl DiaryRepository for FakeDiaryRepository { - async fn query_diary( - &self, - _filter: &DiaryFilter, - ) -> Result, DomainError> { - unimplemented!("FakeDiaryRepository::query_diary") - } - - async fn query_activity_feed( - &self, - _page: &PageParams, - ) -> Result, DomainError> { - unimplemented!("FakeDiaryRepository::query_activity_feed") - } - - async fn query_activity_feed_filtered( - &self, - _page: &PageParams, - _sort_by: &FeedSortBy, - _search: Option<&str>, - _following: Option<&FollowingFilter>, - ) -> Result, DomainError> { - unimplemented!("FakeDiaryRepository::query_activity_feed_filtered") - } - - async fn get_review_history(&self, movie_id: &MovieId) -> Result { - let histories = self.histories.lock().unwrap(); - let (movie, reviews) = histories - .get(&movie_id.value()) - .ok_or_else(|| DomainError::NotFound(format!("movie {}", movie_id.value())))?; - Ok(ReviewHistory::new(movie.clone(), reviews.clone())) - } - - async fn get_user_history(&self, _user_id: &UserId) -> Result, DomainError> { - unimplemented!("FakeDiaryRepository::get_user_history") - } - - async fn get_movie_stats(&self, _movie_id: &MovieId) -> Result { - unimplemented!("FakeDiaryRepository::get_movie_stats") - } - - async fn get_movie_social_feed( - &self, - _movie_id: &MovieId, - _page: &PageParams, - ) -> Result, DomainError> { - unimplemented!("FakeDiaryRepository::get_movie_social_feed") - } - - async fn count_local_posts(&self) -> Result { - unimplemented!("FakeDiaryRepository::count_local_posts") - } -} - -// ── PanicDiaryRepository ────────────────────────────────────────────────────── - -pub struct PanicDiaryRepository; - -#[async_trait] -impl DiaryRepository for PanicDiaryRepository { - async fn query_diary( - &self, - _filter: &DiaryFilter, - ) -> Result, DomainError> { - panic!("PanicDiaryRepository called") - } - - async fn query_activity_feed( - &self, - _page: &PageParams, - ) -> Result, DomainError> { - panic!("PanicDiaryRepository called") - } - - async fn query_activity_feed_filtered( - &self, - _page: &PageParams, - _sort_by: &FeedSortBy, - _search: Option<&str>, - _following: Option<&FollowingFilter>, - ) -> Result, DomainError> { - panic!("PanicDiaryRepository called") - } - - async fn get_review_history(&self, _movie_id: &MovieId) -> Result { - panic!("PanicDiaryRepository called") - } - - async fn get_user_history(&self, _user_id: &UserId) -> Result, DomainError> { - panic!("PanicDiaryRepository called") - } - - async fn get_movie_stats(&self, _movie_id: &MovieId) -> Result { - panic!("PanicDiaryRepository called") - } - - async fn get_movie_social_feed( - &self, - _movie_id: &MovieId, - _page: &PageParams, - ) -> Result, DomainError> { - panic!("PanicDiaryRepository called") - } - - async fn count_local_posts(&self) -> Result { - panic!("PanicDiaryRepository called") - } -} - -// ── PanicStatsRepository ────────────────────────────────────────────────────── - -pub struct PanicStatsRepository; - -#[async_trait] -impl StatsRepository for PanicStatsRepository { - async fn get_user_stats(&self, _user_id: &UserId) -> Result { - panic!("PanicStatsRepository called") - } - - async fn get_user_trends(&self, _user_id: &UserId) -> Result { - panic!("PanicStatsRepository called") - } -} - -// ── PanicImportSessionRepository ────────────────────────────────────────────── - -pub struct PanicImportSessionRepository; - -#[async_trait] -impl ImportSessionRepository for PanicImportSessionRepository { - async fn create(&self, _session: &ImportSession) -> Result<(), DomainError> { - panic!("PanicImportSessionRepository called") - } - - async fn get( - &self, - _id: &ImportSessionId, - _user_id: &UserId, - ) -> Result, DomainError> { - panic!("PanicImportSessionRepository called") - } - - async fn update(&self, _session: &ImportSession) -> Result<(), DomainError> { - panic!("PanicImportSessionRepository called") - } - - async fn delete(&self, _id: &ImportSessionId) -> Result<(), DomainError> { - panic!("PanicImportSessionRepository called") - } - - async fn delete_expired(&self) -> Result { - panic!("PanicImportSessionRepository called") - } - - async fn delete_expired_for_user(&self, _user_id: &UserId) -> Result<(), DomainError> { - panic!("PanicImportSessionRepository called") - } -} - -// ── PanicImportProfileRepository ────────────────────────────────────────────── - -pub struct PanicImportProfileRepository; - -#[async_trait] -impl ImportProfileRepository for PanicImportProfileRepository { - async fn save(&self, _profile: &ImportProfile) -> Result<(), DomainError> { - panic!("PanicImportProfileRepository called") - } - - async fn list_for_user(&self, _user_id: &UserId) -> Result, DomainError> { - panic!("PanicImportProfileRepository called") - } - - async fn get( - &self, - _id: &ImportProfileId, - _user_id: &UserId, - ) -> Result, DomainError> { - panic!("PanicImportProfileRepository called") - } - - async fn delete(&self, _id: &ImportProfileId) -> Result<(), DomainError> { - panic!("PanicImportProfileRepository called") - } -} - -// ── PanicMovieProfileRepository ─────────────────────────────────────────────── - -pub struct PanicMovieProfileRepository; - -#[async_trait] -impl MovieProfileRepository for PanicMovieProfileRepository { - async fn upsert(&self, _profile: &MovieProfile) -> Result<(), DomainError> { - panic!("PanicMovieProfileRepository called") - } - - async fn get_by_movie_id(&self, _id: &MovieId) -> Result, DomainError> { - panic!("PanicMovieProfileRepository called") - } - - async fn list_stale(&self) -> Result, DomainError> { - panic!("PanicMovieProfileRepository called") - } -} - -// ── PanicPersonCommand ──────────────────────────────────────────────────────── - -pub struct PanicPersonCommand; - -#[async_trait] -impl PersonCommand for PanicPersonCommand { - async fn upsert_batch(&self, _persons: &[Person]) -> Result<(), DomainError> { - panic!("PanicPersonCommand called") - } - async fn backfill_from_credits_batch( - &self, - _batch_size: u32, - ) -> Result<(u64, bool), DomainError> { - panic!("PanicPersonCommand called") - } -} - -// ── PanicPersonQuery ────────────────────────────────────────────────────────── - -pub struct PanicPersonQuery; - -#[async_trait] -impl PersonQuery for PanicPersonQuery { - async fn get_by_id(&self, _id: &PersonId) -> Result, DomainError> { - panic!("PanicPersonQuery called") - } - - async fn get_by_external_id( - &self, - _id: &ExternalPersonId, - ) -> Result, DomainError> { - panic!("PanicPersonQuery called") - } - - async fn get_credits(&self, _id: &PersonId) -> Result { - panic!("PanicPersonQuery called") - } - - async fn list_orphaned_persons(&self) -> Result, DomainError> { - panic!("PanicPersonQuery called") - } - - async fn list_page(&self, _limit: u32, _offset: u32) -> Result, DomainError> { - panic!("PanicPersonQuery called") - } -} - -// ── PanicSearchPort ─────────────────────────────────────────────────────────── - -pub struct PanicSearchPort; - -#[async_trait] -impl SearchPort for PanicSearchPort { - async fn search(&self, _query: &SearchQuery) -> Result { - Ok(SearchResults { - movies: Paginated { - items: vec![], - total_count: 0, - limit: 10, - offset: 0, - }, - people: Paginated { - items: vec![], - total_count: 0, - limit: 10, - offset: 0, - }, - }) - } -} - -// ── PanicSearchCommand ──────────────────────────────────────────────────────── - -pub struct PanicSearchCommand; - -#[async_trait] -impl SearchCommand for PanicSearchCommand { - async fn index(&self, _doc: IndexableDocument) -> Result<(), DomainError> { - panic!("PanicSearchCommand called") - } - - async fn remove(&self, _entity_type: EntityType, _id: &str) -> Result<(), DomainError> { - panic!("PanicSearchCommand called") - } -} - -// ── PanicPosterFetcher ──────────────────────────────────────────────────────── - -pub struct PanicPosterFetcher; - -#[async_trait] -impl PosterFetcherClient for PanicPosterFetcher { - async fn fetch_poster_bytes(&self, _poster_url: &PosterUrl) -> Result, DomainError> { - panic!("PanicPosterFetcher called") - } -} - -// ── PanicDiaryExporter ──────────────────────────────────────────────────────── - -pub struct PanicDiaryExporter; - -#[async_trait] -impl DiaryExporter for PanicDiaryExporter { - async fn serialize_entries( - &self, - _entries: &[crate::models::DiaryEntry], - _format: ExportFormat, - ) -> Result, DomainError> { - panic!("PanicDiaryExporter called") - } -} - -// ── PanicDocumentParser ─────────────────────────────────────────────────────── - -pub struct PanicDocumentParser; - -impl DocumentParser for PanicDocumentParser { - fn parse(&self, _bytes: &[u8], _format: FileFormat) -> Result { - panic!("PanicDocumentParser called") - } - - fn apply_mapping(&self, _file: &ParsedFile, _mappings: &[FieldMapping]) -> Vec { - panic!("PanicDocumentParser called") - } -} - -// ── PanicProfileFieldsRepo ──────────────────────────────────────────────────── - -pub struct PanicRemoteWatchlistRepository; - -#[async_trait] -impl crate::ports::RemoteWatchlistRepository for PanicRemoteWatchlistRepository { - async fn save(&self, _: crate::models::RemoteWatchlistEntry) -> Result<(), DomainError> { - panic!("PanicRemoteWatchlistRepository called") - } - async fn remove_by_ap_id(&self, _: &str, _: &str) -> Result<(), DomainError> { - panic!("PanicRemoteWatchlistRepository called") - } - async fn get_by_actor_url( - &self, - _: &str, - ) -> Result, DomainError> { - panic!("PanicRemoteWatchlistRepository called") - } - async fn remove_all_by_actor(&self, _: &str) -> Result<(), DomainError> { - panic!("PanicRemoteWatchlistRepository called") - } - async fn get_by_derived_uuid( - &self, - _: uuid::Uuid, - ) -> Result, DomainError> { - panic!("PanicRemoteWatchlistRepository called") - } -} - -pub struct NoopRemoteWatchlistRepository; - -#[async_trait] -impl crate::ports::RemoteWatchlistRepository for NoopRemoteWatchlistRepository { - async fn save(&self, _: crate::models::RemoteWatchlistEntry) -> Result<(), DomainError> { - Ok(()) - } - async fn remove_by_ap_id(&self, _: &str, _: &str) -> Result<(), DomainError> { - Ok(()) - } - async fn get_by_actor_url( - &self, - _: &str, - ) -> Result, DomainError> { - Ok(vec![]) - } - async fn remove_all_by_actor(&self, _: &str) -> Result<(), DomainError> { - Ok(()) - } - async fn get_by_derived_uuid( - &self, - _: uuid::Uuid, - ) -> Result, DomainError> { - Ok(vec![]) - } -} - -pub struct PanicProfileFieldsRepo; - -#[async_trait] -impl UserProfileFieldsRepository for PanicProfileFieldsRepo { - async fn get_fields( - &self, - _user_id: &UserId, - ) -> Result, DomainError> { - panic!("PanicProfileFieldsRepo called") - } - - async fn set_fields( - &self, - _user_id: &UserId, - _fields: Vec, - ) -> Result<(), DomainError> { - panic!("PanicProfileFieldsRepo called") - } -} - -pub struct PanicSocialQueryPort; - -#[async_trait] -impl crate::ports::SocialQueryPort for PanicSocialQueryPort { - async fn get_accepted_following_urls(&self, _: uuid::Uuid) -> Result, DomainError> { - panic!("PanicSocialQueryPort called") - } - async fn list_all_followed_remote_actors( - &self, - ) -> Result, DomainError> { - panic!("PanicSocialQueryPort called") - } - async fn count_following(&self, _: uuid::Uuid) -> Result { - panic!("PanicSocialQueryPort called") - } - async fn count_accepted_followers(&self, _: uuid::Uuid) -> Result { - panic!("PanicSocialQueryPort called") - } - async fn get_pending_followers( - &self, - _: uuid::Uuid, - ) -> Result, DomainError> { - panic!("PanicSocialQueryPort called") - } -} - -pub struct NoopSocialQueryPort; - -#[async_trait] -impl crate::ports::SocialQueryPort for NoopSocialQueryPort { - async fn get_accepted_following_urls(&self, _: uuid::Uuid) -> Result, DomainError> { - Ok(vec![]) - } - async fn list_all_followed_remote_actors( - &self, - ) -> Result, DomainError> { - Ok(vec![]) - } - async fn count_following(&self, _: uuid::Uuid) -> Result { - Ok(0) - } - async fn count_accepted_followers(&self, _: uuid::Uuid) -> Result { - Ok(0) - } - async fn get_pending_followers( - &self, - _: uuid::Uuid, - ) -> Result, DomainError> { - Ok(vec![]) - } -} - -// ── PanicWatchEventRepository ──────────────────────────────────────────────── - -pub struct PanicWatchEventRepository; - -#[async_trait] -impl crate::ports::WatchEventRepository for PanicWatchEventRepository { - async fn save(&self, _: &crate::models::WatchEvent) -> Result<(), DomainError> { - panic!("PanicWatchEventRepository called") - } - async fn update_status( - &self, - _: &crate::value_objects::WatchEventId, - _: crate::models::WatchEventStatus, - ) -> Result<(), DomainError> { - panic!("PanicWatchEventRepository called") - } - async fn list_pending( - &self, - _: &UserId, - ) -> Result, DomainError> { - panic!("PanicWatchEventRepository called") - } - async fn get_by_id( - &self, - _: &crate::value_objects::WatchEventId, - ) -> Result, DomainError> { - panic!("PanicWatchEventRepository called") - } - async fn get_by_ids( - &self, - _: &[crate::value_objects::WatchEventId], - ) -> Result, DomainError> { - panic!("PanicWatchEventRepository called") - } - async fn update_status_batch( - &self, - _: &[crate::value_objects::WatchEventId], - _: crate::models::WatchEventStatus, - ) -> Result { - panic!("PanicWatchEventRepository called") - } - async fn find_duplicate( - &self, - _: &UserId, - _: &str, - _: chrono::NaiveDateTime, - ) -> Result { - panic!("PanicWatchEventRepository called") - } - async fn delete_non_pending_older_than( - &self, - _: chrono::NaiveDateTime, - ) -> Result { - panic!("PanicWatchEventRepository called") - } -} - -// ── PanicWebhookTokenRepository ────────────────────────────────────────────── - -pub struct PanicWebhookTokenRepository; - -#[async_trait] -impl crate::ports::WebhookTokenRepository for PanicWebhookTokenRepository { - async fn save(&self, _: &crate::models::WebhookToken) -> Result<(), DomainError> { - panic!("PanicWebhookTokenRepository called") - } - async fn find_by_token_hash( - &self, - _: &str, - ) -> Result, DomainError> { - panic!("PanicWebhookTokenRepository called") - } - async fn list_by_user( - &self, - _: &UserId, - ) -> Result, DomainError> { - panic!("PanicWebhookTokenRepository called") - } - async fn delete( - &self, - _: &crate::value_objects::WebhookTokenId, - _: &UserId, - ) -> Result<(), DomainError> { - panic!("PanicWebhookTokenRepository called") - } - async fn touch_last_used( - &self, - _: &crate::value_objects::WebhookTokenId, - ) -> Result<(), DomainError> { - panic!("PanicWebhookTokenRepository called") - } -} - -// ── PanicWrapUpStatsQuery ─────────────────────────────────────────────────── - -pub struct PanicWrapUpStatsQuery; - -#[async_trait] -impl crate::ports::WrapUpStatsQuery for PanicWrapUpStatsQuery { - async fn get_reviews_with_profiles( - &self, - _scope: &crate::models::wrapup::WrapUpScope, - _range: &crate::models::wrapup::DateRange, - ) -> Result, DomainError> { - unimplemented!("WrapUpStatsQuery not wired") - } -} - -// ── InMemoryWrapUpStatsQuery ──────────────────────────────────────────────── - -pub struct InMemoryWrapUpStatsQuery { - pub rows: Mutex>, -} - -impl InMemoryWrapUpStatsQuery { - pub fn new() -> Arc { - Arc::new(Self { - rows: Mutex::new(Vec::new()), - }) - } - - pub fn with_rows(rows: Vec) -> Arc { - Arc::new(Self { - rows: Mutex::new(rows), - }) - } -} - -#[async_trait] -impl crate::ports::WrapUpStatsQuery for InMemoryWrapUpStatsQuery { - async fn get_reviews_with_profiles( - &self, - scope: &crate::models::wrapup::WrapUpScope, - range: &crate::models::wrapup::DateRange, - ) -> Result, DomainError> { - let rows = self.rows.lock().unwrap(); - let filtered: Vec<_> = rows - .iter() - .filter(|r| { - let date = r.watched_at.date(); - date >= range.start() && date < range.end() - }) - .filter(|r| match scope { - crate::models::wrapup::WrapUpScope::User(uid) => r.user_id == *uid, - crate::models::wrapup::WrapUpScope::Global => true, - }) - .cloned() - .collect(); - Ok(filtered) - } -} - -// ── InMemoryWrapUpRepository ──────────────────────────────────────────────── - -pub struct InMemoryWrapUpRepository { - pub store: Mutex>, -} - -impl InMemoryWrapUpRepository { - pub fn new() -> Arc { - Arc::new(Self { - store: Mutex::new(Vec::new()), - }) - } -} - -#[async_trait] -impl WrapUpRepository for InMemoryWrapUpRepository { - async fn create( - &self, - record: &crate::models::wrapup::WrapUpRecord, - ) -> Result<(), DomainError> { - self.store.lock().unwrap().push(record.clone()); - Ok(()) - } - - async fn update_status( - &self, - id: &WrapUpId, - status: &crate::models::wrapup::WrapUpStatus, - error: Option<&str>, - ) -> Result<(), DomainError> { - let mut store = self.store.lock().unwrap(); - if let Some(rec) = store.iter_mut().find(|r| r.id == *id) { - rec.status = status.clone(); - rec.error_message = error.map(|s| s.to_string()); - Ok(()) - } else { - Err(DomainError::NotFound("wrapup record".into())) - } - } - - async fn set_complete( - &self, - id: &WrapUpId, - report: &crate::models::wrapup::WrapUpReport, - ) -> Result<(), DomainError> { - let mut store = self.store.lock().unwrap(); - if let Some(rec) = store.iter_mut().find(|r| r.id == *id) { - rec.status = crate::models::wrapup::WrapUpStatus::Ready; - rec.report = Some(report.clone()); - rec.completed_at = Some(chrono::Utc::now().naive_utc()); - Ok(()) - } else { - Err(DomainError::NotFound("wrapup record".into())) - } - } - - async fn get_by_id( - &self, - id: &WrapUpId, - ) -> Result, DomainError> { - let store = self.store.lock().unwrap(); - Ok(store.iter().find(|r| r.id == *id).cloned()) - } - - async fn list_for_user( - &self, - user_id: Uuid, - ) -> Result, DomainError> { - let store = self.store.lock().unwrap(); - Ok(store - .iter() - .filter(|r| r.user_id == Some(user_id)) - .cloned() - .collect()) - } - - async fn list_global(&self) -> Result, DomainError> { - let store = self.store.lock().unwrap(); - Ok(store - .iter() - .filter(|r| r.user_id.is_none()) - .cloned() - .collect()) - } - - async fn find_existing( - &self, - user_id: Option, - start: chrono::NaiveDate, - end: chrono::NaiveDate, - ) -> Result, DomainError> { - let store = self.store.lock().unwrap(); - Ok(store - .iter() - .find(|r| r.user_id == user_id && r.start_date == start && r.end_date == end) - .cloned()) - } - - async fn delete(&self, id: &WrapUpId) -> Result<(), DomainError> { - let mut store = self.store.lock().unwrap(); - store.retain(|r| r.id != *id); - Ok(()) - } - - async fn delete_failed_older_than( - &self, - before: chrono::NaiveDateTime, - ) -> Result { - let mut store = self.store.lock().unwrap(); - let before_len = store.len(); - store.retain(|r| { - !(r.status == crate::models::wrapup::WrapUpStatus::Failed && r.created_at < before) - }); - Ok((before_len - store.len()) as u64) - } -} - -// ── PanicWrapUpRepository ────────────────────────────────────────────────── - -pub struct PanicWrapUpRepository; - -#[async_trait] -impl WrapUpRepository for PanicWrapUpRepository { - async fn create(&self, _: &crate::models::wrapup::WrapUpRecord) -> Result<(), DomainError> { - panic!("PanicWrapUpRepository called") - } - async fn update_status( - &self, - _: &WrapUpId, - _: &crate::models::wrapup::WrapUpStatus, - _: Option<&str>, - ) -> Result<(), DomainError> { - panic!("PanicWrapUpRepository called") - } - async fn set_complete( - &self, - _: &WrapUpId, - _: &crate::models::wrapup::WrapUpReport, - ) -> Result<(), DomainError> { - panic!("PanicWrapUpRepository called") - } - async fn get_by_id( - &self, - _: &WrapUpId, - ) -> Result, DomainError> { - panic!("PanicWrapUpRepository called") - } - async fn list_for_user( - &self, - _: Uuid, - ) -> Result, DomainError> { - panic!("PanicWrapUpRepository called") - } - async fn list_global(&self) -> Result, DomainError> { - panic!("PanicWrapUpRepository called") - } - async fn find_existing( - &self, - _: Option, - _: chrono::NaiveDate, - _: chrono::NaiveDate, - ) -> Result, DomainError> { - panic!("PanicWrapUpRepository called") - } - async fn delete(&self, _: &WrapUpId) -> Result<(), DomainError> { - panic!("PanicWrapUpRepository called") - } - async fn delete_failed_older_than(&self, _: chrono::NaiveDateTime) -> Result { - panic!("PanicWrapUpRepository called") - } -} - -// ── Noop Goal/Settings repos ──────────────────────────────────────────────── - -pub struct NoopGoalRepository; - -#[async_trait] -impl crate::ports::GoalRepository for NoopGoalRepository { - async fn save(&self, _: &crate::models::Goal) -> Result<(), DomainError> { - Ok(()) - } - async fn update(&self, _: &crate::models::Goal) -> Result<(), DomainError> { - Ok(()) - } - async fn delete( - &self, - _: &crate::value_objects::GoalId, - _: &UserId, - ) -> Result<(), DomainError> { - Ok(()) - } - async fn find_by_user_and_year( - &self, - _: &UserId, - _: u16, - ) -> Result, DomainError> { - Ok(None) - } - async fn list_for_user(&self, _: &UserId) -> Result, DomainError> { - Ok(vec![]) - } - async fn count_reviews_in_year(&self, _: &UserId, _: u16) -> Result { - Ok(0) - } -} - -pub struct NoopUserSettingsRepository; - -#[async_trait] -impl crate::ports::UserSettingsRepository for NoopUserSettingsRepository { - async fn get(&self, user_id: &UserId) -> Result { - Ok(crate::models::UserSettings::new(user_id.clone())) - } - async fn save(&self, _: &crate::models::UserSettings) -> Result<(), DomainError> { - Ok(()) - } -} - -pub struct NoopRemoteGoalRepository; - -#[async_trait] -impl crate::ports::RemoteGoalRepository for NoopRemoteGoalRepository { - async fn save(&self, _: crate::models::RemoteGoalEntry) -> Result<(), DomainError> { - Ok(()) - } - async fn update_by_ap_id(&self, _: &str, _: u32, _: u32) -> Result<(), DomainError> { - Ok(()) - } - async fn remove_by_ap_id(&self, _: &str, _: &str) -> Result<(), DomainError> { - Ok(()) - } - async fn get_by_actor_url( - &self, - _: &str, - ) -> Result, DomainError> { - Ok(vec![]) - } -} diff --git a/crates/domain/src/testing/fakes.rs b/crates/domain/src/testing/fakes.rs new file mode 100644 index 0000000..7e701c0 --- /dev/null +++ b/crates/domain/src/testing/fakes.rs @@ -0,0 +1,153 @@ +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use chrono::Utc; +use uuid::Uuid; + +use crate::{ + errors::DomainError, + models::{ + DiaryEntry, DiaryFilter, FeedEntry, Movie, MovieStats, Review, ReviewHistory, + collections::{PageParams, Paginated}, + }, + ports::{ + AuthService, DiaryRepository, FeedSortBy, FollowingFilter, GeneratedToken, MetadataClient, + MetadataSearchCriteria, PasswordHasher, + }, + value_objects::{ExternalMetadataId, MovieId, PasswordHash, PosterUrl, UserId}, +}; + +// ── FakeAuthService ─────────────────────────────────────────────────────────── + +pub struct FakeAuthService; + +#[async_trait] +impl AuthService for FakeAuthService { + async fn generate_token(&self, user_id: &UserId) -> Result { + Ok(GeneratedToken { + token: user_id.value().to_string(), + expires_at: Utc::now() + chrono::Duration::hours(24), + }) + } + + async fn validate_token(&self, token: &str) -> Result { + Uuid::parse_str(token) + .map(UserId::from_uuid) + .map_err(|_| DomainError::Unauthorized("invalid token".into())) + } +} + +// ── FakePasswordHasher ──────────────────────────────────────────────────────── + +pub struct FakePasswordHasher; + +#[async_trait] +impl PasswordHasher for FakePasswordHasher { + async fn hash(&self, plain_password: &str) -> Result { + PasswordHash::new(format!("hashed:{plain_password}")) + } + + async fn verify(&self, plain_password: &str, hash: &PasswordHash) -> Result { + Ok(hash.value() == format!("hashed:{plain_password}")) + } +} + +// ── FakeMetadataClient ──────────────────────────────────────────────────────── + +pub struct FakeMetadataClient; + +#[async_trait] +impl MetadataClient for FakeMetadataClient { + async fn fetch_movie_metadata( + &self, + _criteria: &MetadataSearchCriteria, + ) -> Result { + Err(DomainError::InfrastructureError( + "fake metadata client".into(), + )) + } + + async fn get_poster_url( + &self, + _external_metadata_id: &ExternalMetadataId, + ) -> Result, DomainError> { + Ok(None) + } +} + +// ── FakeDiaryRepository ─────────────────────────────────────────────────────── + +pub struct FakeDiaryRepository { + histories: Mutex)>>, +} + +impl FakeDiaryRepository { + pub fn new() -> Arc { + Arc::new(Self { + histories: Mutex::new(HashMap::new()), + }) + } + + pub fn seed_history(&self, movie: Movie, reviews: Vec) { + self.histories + .lock() + .unwrap() + .insert(movie.id().value(), (movie, reviews)); + } +} + +#[async_trait] +impl DiaryRepository for FakeDiaryRepository { + async fn query_diary( + &self, + _filter: &DiaryFilter, + ) -> Result, DomainError> { + unimplemented!("FakeDiaryRepository::query_diary") + } + + async fn query_activity_feed( + &self, + _page: &PageParams, + ) -> Result, DomainError> { + unimplemented!("FakeDiaryRepository::query_activity_feed") + } + + async fn query_activity_feed_filtered( + &self, + _page: &PageParams, + _sort_by: &FeedSortBy, + _search: Option<&str>, + _following: Option<&FollowingFilter>, + ) -> Result, DomainError> { + unimplemented!("FakeDiaryRepository::query_activity_feed_filtered") + } + + async fn get_review_history(&self, movie_id: &MovieId) -> Result { + let histories = self.histories.lock().unwrap(); + let (movie, reviews) = histories + .get(&movie_id.value()) + .ok_or_else(|| DomainError::NotFound(format!("movie {}", movie_id.value())))?; + Ok(ReviewHistory::new(movie.clone(), reviews.clone())) + } + + async fn get_user_history(&self, _user_id: &UserId) -> Result, DomainError> { + unimplemented!("FakeDiaryRepository::get_user_history") + } + + async fn get_movie_stats(&self, _movie_id: &MovieId) -> Result { + unimplemented!("FakeDiaryRepository::get_movie_stats") + } + + async fn get_movie_social_feed( + &self, + _movie_id: &MovieId, + _page: &PageParams, + ) -> Result, DomainError> { + unimplemented!("FakeDiaryRepository::get_movie_social_feed") + } + + async fn count_local_posts(&self) -> Result { + unimplemented!("FakeDiaryRepository::count_local_posts") + } +} diff --git a/crates/domain/src/testing/in_memory.rs b/crates/domain/src/testing/in_memory.rs new file mode 100644 index 0000000..7028578 --- /dev/null +++ b/crates/domain/src/testing/in_memory.rs @@ -0,0 +1,312 @@ +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use uuid::Uuid; + +use crate::{ + errors::DomainError, + events::DomainEvent, + models::{ + Movie, MovieFilter, MovieSummary, Review, User, UserSummary, WatchlistEntry, + WatchlistWithMovie, + collections::{PageParams, Paginated}, + }, + ports::{MovieRepository, ReviewRepository, UserRepository, WatchlistRepository}, + value_objects::{ + Email, ExternalMetadataId, MovieId, MovieTitle, ReleaseYear, ReviewId, UserId, Username, + }, +}; + +// ── InMemoryMovieRepository ─────────────────────────────────────────────────── + +pub struct InMemoryMovieRepository { + pub store: Mutex>, +} + +impl InMemoryMovieRepository { + pub fn new() -> Arc { + Arc::new(Self { + store: Mutex::new(HashMap::new()), + }) + } + + pub fn count(&self) -> usize { + self.store.lock().unwrap().len() + } +} + +#[async_trait] +impl MovieRepository for InMemoryMovieRepository { + async fn get_movie_by_external_id( + &self, + external_metadata_id: &ExternalMetadataId, + ) -> Result, DomainError> { + let store = self.store.lock().unwrap(); + Ok(store + .values() + .find(|m| { + m.external_metadata_id() + .map(|e| e.value() == external_metadata_id.value()) + .unwrap_or(false) + }) + .cloned()) + } + + async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result, DomainError> { + Ok(self.store.lock().unwrap().get(&movie_id.value()).cloned()) + } + + async fn get_movies_by_title_and_year( + &self, + title: &MovieTitle, + year: &ReleaseYear, + ) -> Result, DomainError> { + let store = self.store.lock().unwrap(); + Ok(store + .values() + .filter(|m| m.title() == title && m.release_year() == year) + .cloned() + .collect()) + } + + async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError> { + self.store + .lock() + .unwrap() + .insert(movie.id().value(), movie.clone()); + Ok(()) + } + + async fn delete_movie(&self, movie_id: &MovieId) -> Result<(), DomainError> { + self.store.lock().unwrap().remove(&movie_id.value()); + Ok(()) + } + + async fn existing_external_ids( + &self, + ids: &[ExternalMetadataId], + ) -> Result, DomainError> { + let store = self.store.lock().unwrap(); + let known: std::collections::HashSet = store + .values() + .filter_map(|m| m.external_metadata_id().map(|e| e.value().to_string())) + .collect(); + Ok(ids + .iter() + .map(|id| id.value().to_string()) + .filter(|v| known.contains(v)) + .collect()) + } + + async fn existing_title_year_pairs( + &self, + pairs: &[(MovieTitle, ReleaseYear)], + ) -> Result, DomainError> { + let store = self.store.lock().unwrap(); + let known: std::collections::HashSet<(String, u16)> = store + .values() + .map(|m| (m.title().value().to_string(), m.release_year().value())) + .collect(); + Ok(pairs + .iter() + .map(|(t, y)| (t.value().to_string(), y.value())) + .filter(|p| known.contains(p)) + .collect()) + } + + async fn list_movies( + &self, + _page: &PageParams, + _filter: &MovieFilter, + ) -> Result, DomainError> { + Ok(Paginated { + items: vec![], + total_count: 0, + limit: 10, + offset: 0, + }) + } +} + +// ── InMemoryReviewRepository ────────────────────────────────────────────────── + +pub struct InMemoryReviewRepository { + store: Mutex>, +} + +impl InMemoryReviewRepository { + pub fn new() -> Arc { + Arc::new(Self { + store: Mutex::new(HashMap::new()), + }) + } + + pub fn count(&self) -> usize { + self.store.lock().unwrap().len() + } +} + +#[async_trait] +impl ReviewRepository for InMemoryReviewRepository { + async fn save_review(&self, review: &Review) -> Result { + self.store + .lock() + .unwrap() + .insert(review.id().value(), review.clone()); + Ok(DomainEvent::ReviewLogged { + review_id: review.id().clone(), + movie_id: review.movie_id().clone(), + user_id: review.user_id().clone(), + rating: review.rating().clone(), + watched_at: *review.watched_at(), + }) + } + + async fn get_review_by_id(&self, review_id: &ReviewId) -> Result, DomainError> { + Ok(self.store.lock().unwrap().get(&review_id.value()).cloned()) + } + + async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError> { + self.store.lock().unwrap().remove(&review_id.value()); + Ok(()) + } + + async fn get_all_reviews_for_user(&self, user_id: &UserId) -> Result, DomainError> { + let store = self.store.lock().unwrap(); + Ok(store + .values() + .filter(|r| r.user_id() == user_id) + .cloned() + .collect()) + } +} + +// ── InMemoryUserRepository ──────────────────────────────────────────────────── + +pub struct InMemoryUserRepository { + pub store: Mutex>, +} + +impl InMemoryUserRepository { + pub fn new() -> Arc { + Arc::new(Self { + store: Mutex::new(HashMap::new()), + }) + } + + pub fn count(&self) -> usize { + self.store.lock().unwrap().len() + } +} + +#[async_trait] +impl UserRepository for InMemoryUserRepository { + async fn find_by_email(&self, email: &Email) -> Result, DomainError> { + let store = self.store.lock().unwrap(); + Ok(store + .values() + .find(|u| u.email().value() == email.value()) + .cloned()) + } + + async fn find_by_username(&self, username: &Username) -> Result, DomainError> { + let store = self.store.lock().unwrap(); + Ok(store + .values() + .find(|u| u.username().value() == username.value()) + .cloned()) + } + + async fn save(&self, user: &User) -> Result<(), DomainError> { + self.store + .lock() + .unwrap() + .insert(user.id().value(), user.clone()); + Ok(()) + } + + async fn find_by_id(&self, id: &UserId) -> Result, DomainError> { + Ok(self.store.lock().unwrap().get(&id.value()).cloned()) + } + + async fn list_with_stats(&self) -> Result, DomainError> { + Ok(vec![]) + } + + async fn update_profile( + &self, + _user_id: &UserId, + _profile: &crate::models::UserProfile, + ) -> Result<(), DomainError> { + Ok(()) + } +} + +// ── InMemoryWatchlistRepository ─────────────────────────────────────────────── + +pub struct InMemoryWatchlistRepository { + store: Mutex>, +} + +impl InMemoryWatchlistRepository { + pub fn new() -> Arc { + Arc::new(Self { + store: Mutex::new(HashMap::new()), + }) + } + + pub fn count(&self) -> usize { + self.store.lock().unwrap().len() + } +} + +#[async_trait] +impl WatchlistRepository for InMemoryWatchlistRepository { + async fn add(&self, entry: &WatchlistEntry) -> Result<(), DomainError> { + let key = (entry.user_id.value(), entry.movie_id.value()); + self.store + .lock() + .unwrap() + .entry(key) + .or_insert_with(|| entry.clone()); + Ok(()) + } + + async fn remove(&self, user_id: &UserId, movie_id: &MovieId) -> Result<(), DomainError> { + let key = (user_id.value(), movie_id.value()); + self.store + .lock() + .unwrap() + .remove(&key) + .ok_or_else(|| DomainError::NotFound("watchlist entry".into()))?; + Ok(()) + } + + async fn remove_if_present( + &self, + user_id: &UserId, + movie_id: &MovieId, + ) -> Result { + let key = (user_id.value(), movie_id.value()); + Ok(self.store.lock().unwrap().remove(&key).is_some()) + } + + async fn get_for_user( + &self, + _user_id: &UserId, + _page: &PageParams, + ) -> Result, DomainError> { + Ok(Paginated { + items: vec![], + total_count: 0, + limit: 10, + offset: 0, + }) + } + + async fn contains(&self, user_id: &UserId, movie_id: &MovieId) -> Result { + let key = (user_id.value(), movie_id.value()); + Ok(self.store.lock().unwrap().contains_key(&key)) + } +} diff --git a/crates/domain/src/testing/mod.rs b/crates/domain/src/testing/mod.rs new file mode 100644 index 0000000..fb38fdf --- /dev/null +++ b/crates/domain/src/testing/mod.rs @@ -0,0 +1,11 @@ +mod fakes; +mod in_memory; +mod noops; +mod panics; +mod wrapup; + +pub use fakes::*; +pub use in_memory::*; +pub use noops::*; +pub use panics::*; +pub use wrapup::*; diff --git a/crates/domain/src/testing/noops.rs b/crates/domain/src/testing/noops.rs new file mode 100644 index 0000000..383c5d3 --- /dev/null +++ b/crates/domain/src/testing/noops.rs @@ -0,0 +1,191 @@ +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; + +use crate::{ + errors::DomainError, + events::DomainEvent, + ports::{EventPublisher, ObjectStorage}, + value_objects::UserId, +}; + +// ── NoopEventPublisher ──────────────────────────────────────────────────────── + +pub struct NoopEventPublisher { + pub events: Mutex>, +} + +impl NoopEventPublisher { + pub fn new() -> Arc { + Arc::new(Self { + events: Mutex::new(vec![]), + }) + } + + pub fn published(&self) -> Vec { + self.events.lock().unwrap().clone() + } +} + +#[async_trait] +impl EventPublisher for NoopEventPublisher { + async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError> { + self.events.lock().unwrap().push(event.clone()); + Ok(()) + } +} + +// ── NoopObjectStorage ────────────────────────────────────────────────────────── + +pub struct NoopObjectStorage; + +#[async_trait] +impl ObjectStorage for NoopObjectStorage { + async fn store(&self, key: &str, _image_bytes: &[u8]) -> Result { + Ok(format!("noop://{key}")) + } + + async fn get(&self, _key: &str) -> Result, DomainError> { + Ok(vec![]) + } + + async fn get_stream( + &self, + _key: &str, + ) -> Result>, DomainError> + { + Ok(Box::pin(futures::stream::empty())) + } + + async fn delete(&self, _key: &str) -> Result<(), DomainError> { + Ok(()) + } +} + +// ── NoopRemoteWatchlistRepository ───────────────────────────────────────────── + +pub struct NoopRemoteWatchlistRepository; + +#[async_trait] +impl crate::ports::RemoteWatchlistRepository for NoopRemoteWatchlistRepository { + async fn save(&self, _: crate::models::RemoteWatchlistEntry) -> Result<(), DomainError> { + Ok(()) + } + async fn remove_by_ap_id(&self, _: &str, _: &str) -> Result<(), DomainError> { + Ok(()) + } + async fn get_by_actor_url( + &self, + _: &str, + ) -> Result, DomainError> { + Ok(vec![]) + } + async fn remove_all_by_actor(&self, _: &str) -> Result<(), DomainError> { + Ok(()) + } + async fn get_by_derived_uuid( + &self, + _: uuid::Uuid, + ) -> Result, DomainError> { + Ok(vec![]) + } +} + +// ── NoopSocialQueryPort ─────────────────────────────────────────────────────── + +pub struct NoopSocialQueryPort; + +#[async_trait] +impl crate::ports::SocialQueryPort for NoopSocialQueryPort { + async fn get_accepted_following_urls(&self, _: uuid::Uuid) -> Result, DomainError> { + Ok(vec![]) + } + async fn list_all_followed_remote_actors( + &self, + ) -> Result, DomainError> { + Ok(vec![]) + } + async fn count_following(&self, _: uuid::Uuid) -> Result { + Ok(0) + } + async fn count_accepted_followers(&self, _: uuid::Uuid) -> Result { + Ok(0) + } + async fn get_pending_followers( + &self, + _: uuid::Uuid, + ) -> Result, DomainError> { + Ok(vec![]) + } +} + +// ── NoopGoalRepository ──────────────────────────────────────────────────────── + +pub struct NoopGoalRepository; + +#[async_trait] +impl crate::ports::GoalRepository for NoopGoalRepository { + async fn save(&self, _: &crate::models::Goal) -> Result<(), DomainError> { + Ok(()) + } + async fn update(&self, _: &crate::models::Goal) -> Result<(), DomainError> { + Ok(()) + } + async fn delete( + &self, + _: &crate::value_objects::GoalId, + _: &UserId, + ) -> Result<(), DomainError> { + Ok(()) + } + async fn find_by_user_and_year( + &self, + _: &UserId, + _: u16, + ) -> Result, DomainError> { + Ok(None) + } + async fn list_for_user(&self, _: &UserId) -> Result, DomainError> { + Ok(vec![]) + } + async fn count_reviews_in_year(&self, _: &UserId, _: u16) -> Result { + Ok(0) + } +} + +// ── NoopUserSettingsRepository ──────────────────────────────────────────────── + +pub struct NoopUserSettingsRepository; + +#[async_trait] +impl crate::ports::UserSettingsRepository for NoopUserSettingsRepository { + async fn get(&self, user_id: &UserId) -> Result { + Ok(crate::models::UserSettings::new(user_id.clone())) + } + async fn save(&self, _: &crate::models::UserSettings) -> Result<(), DomainError> { + Ok(()) + } +} + +// ── NoopRemoteGoalRepository ────────────────────────────────────────────────── + +pub struct NoopRemoteGoalRepository; + +#[async_trait] +impl crate::ports::RemoteGoalRepository for NoopRemoteGoalRepository { + async fn save(&self, _: crate::models::RemoteGoalEntry) -> Result<(), DomainError> { + Ok(()) + } + async fn update_by_ap_id(&self, _: &str, _: u32, _: u32) -> Result<(), DomainError> { + Ok(()) + } + async fn remove_by_ap_id(&self, _: &str, _: &str) -> Result<(), DomainError> { + Ok(()) + } + async fn get_by_actor_url( + &self, + _: &str, + ) -> Result, DomainError> { + Ok(vec![]) + } +} diff --git a/crates/domain/src/testing/panics.rs b/crates/domain/src/testing/panics.rs new file mode 100644 index 0000000..2d3bb36 --- /dev/null +++ b/crates/domain/src/testing/panics.rs @@ -0,0 +1,405 @@ +use async_trait::async_trait; + +use crate::{ + errors::DomainError, + models::{ + AnnotatedRow, DiaryEntry, DiaryFilter, EntityType, ExportFormat, ExternalPersonId, + FeedEntry, FieldMapping, FileFormat, ImportError, ImportProfile, ImportSession, + IndexableDocument, MovieProfile, MovieStats, ParsedFile, Person, PersonCredits, PersonId, + ReviewHistory, SearchQuery, SearchResults, UserStats, UserTrends, + collections::{PageParams, Paginated}, + }, + ports::{ + DiaryExporter, DiaryRepository, DocumentParser, FeedSortBy, FollowingFilter, + ImportProfileRepository, ImportSessionRepository, MovieProfileRepository, PersonCommand, + PersonQuery, PosterFetcherClient, SearchCommand, SearchPort, StatsRepository, + UserProfileFieldsRepository, + }, + value_objects::{ImportProfileId, ImportSessionId, MovieId, PosterUrl, UserId}, +}; + +// ── PanicDiaryRepository ────────────────────────────────────────────────────── + +pub struct PanicDiaryRepository; + +#[async_trait] +impl DiaryRepository for PanicDiaryRepository { + async fn query_diary(&self, _: &DiaryFilter) -> Result, DomainError> { + panic!("PanicDiaryRepository called") + } + async fn query_activity_feed( + &self, + _: &PageParams, + ) -> Result, DomainError> { + panic!("PanicDiaryRepository called") + } + async fn query_activity_feed_filtered( + &self, + _: &PageParams, + _: &FeedSortBy, + _: Option<&str>, + _: Option<&FollowingFilter>, + ) -> Result, DomainError> { + panic!("PanicDiaryRepository called") + } + async fn get_review_history(&self, _: &MovieId) -> Result { + panic!("PanicDiaryRepository called") + } + async fn get_user_history(&self, _: &UserId) -> Result, DomainError> { + panic!("PanicDiaryRepository called") + } + async fn get_movie_stats(&self, _: &MovieId) -> Result { + panic!("PanicDiaryRepository called") + } + async fn get_movie_social_feed( + &self, + _: &MovieId, + _: &PageParams, + ) -> Result, DomainError> { + panic!("PanicDiaryRepository called") + } + async fn count_local_posts(&self) -> Result { + panic!("PanicDiaryRepository called") + } +} + +pub struct PanicStatsRepository; + +#[async_trait] +impl StatsRepository for PanicStatsRepository { + async fn get_user_stats(&self, _: &UserId) -> Result { + panic!("PanicStatsRepository called") + } + async fn get_user_trends(&self, _: &UserId) -> Result { + panic!("PanicStatsRepository called") + } +} + +pub struct PanicImportSessionRepository; + +#[async_trait] +impl ImportSessionRepository for PanicImportSessionRepository { + async fn create(&self, _: &ImportSession) -> Result<(), DomainError> { + panic!("PanicImportSessionRepository called") + } + async fn get( + &self, + _: &ImportSessionId, + _: &UserId, + ) -> Result, DomainError> { + panic!("PanicImportSessionRepository called") + } + async fn update(&self, _: &ImportSession) -> Result<(), DomainError> { + panic!("PanicImportSessionRepository called") + } + async fn delete(&self, _: &ImportSessionId) -> Result<(), DomainError> { + panic!("PanicImportSessionRepository called") + } + async fn delete_expired(&self) -> Result { + panic!("PanicImportSessionRepository called") + } + async fn delete_expired_for_user(&self, _: &UserId) -> Result<(), DomainError> { + panic!("PanicImportSessionRepository called") + } +} + +pub struct PanicImportProfileRepository; + +#[async_trait] +impl ImportProfileRepository for PanicImportProfileRepository { + async fn save(&self, _: &ImportProfile) -> Result<(), DomainError> { + panic!("PanicImportProfileRepository called") + } + async fn list_for_user(&self, _: &UserId) -> Result, DomainError> { + panic!("PanicImportProfileRepository called") + } + async fn get( + &self, + _: &ImportProfileId, + _: &UserId, + ) -> Result, DomainError> { + panic!("PanicImportProfileRepository called") + } + async fn delete(&self, _: &ImportProfileId) -> Result<(), DomainError> { + panic!("PanicImportProfileRepository called") + } +} + +pub struct PanicMovieProfileRepository; + +#[async_trait] +impl MovieProfileRepository for PanicMovieProfileRepository { + async fn upsert(&self, _: &MovieProfile) -> Result<(), DomainError> { + panic!("PanicMovieProfileRepository called") + } + async fn get_by_movie_id(&self, _: &MovieId) -> Result, DomainError> { + panic!("PanicMovieProfileRepository called") + } + async fn list_stale(&self) -> Result, DomainError> { + panic!("PanicMovieProfileRepository called") + } +} + +pub struct PanicPersonCommand; + +#[async_trait] +impl PersonCommand for PanicPersonCommand { + async fn upsert_batch(&self, _: &[Person]) -> Result<(), DomainError> { + panic!("PanicPersonCommand called") + } + async fn backfill_from_credits_batch(&self, _: u32) -> Result<(u64, bool), DomainError> { + panic!("PanicPersonCommand called") + } +} + +pub struct PanicPersonQuery; + +#[async_trait] +impl PersonQuery for PanicPersonQuery { + async fn get_by_id(&self, _: &PersonId) -> Result, DomainError> { + panic!("PanicPersonQuery called") + } + async fn get_by_external_id( + &self, + _: &ExternalPersonId, + ) -> Result, DomainError> { + panic!("PanicPersonQuery called") + } + async fn get_credits(&self, _: &PersonId) -> Result { + panic!("PanicPersonQuery called") + } + async fn list_orphaned_persons(&self) -> Result, DomainError> { + panic!("PanicPersonQuery called") + } + async fn list_page(&self, _: u32, _: u32) -> Result, DomainError> { + panic!("PanicPersonQuery called") + } +} + +pub struct PanicSearchPort; + +#[async_trait] +impl SearchPort for PanicSearchPort { + async fn search(&self, _: &SearchQuery) -> Result { + Ok(SearchResults { + movies: Paginated { + items: vec![], + total_count: 0, + limit: 10, + offset: 0, + }, + people: Paginated { + items: vec![], + total_count: 0, + limit: 10, + offset: 0, + }, + }) + } +} + +pub struct PanicSearchCommand; + +#[async_trait] +impl SearchCommand for PanicSearchCommand { + async fn index(&self, _: IndexableDocument) -> Result<(), DomainError> { + panic!("PanicSearchCommand called") + } + async fn remove(&self, _: EntityType, _: &str) -> Result<(), DomainError> { + panic!("PanicSearchCommand called") + } +} + +pub struct PanicPosterFetcher; + +#[async_trait] +impl PosterFetcherClient for PanicPosterFetcher { + async fn fetch_poster_bytes(&self, _: &PosterUrl) -> Result, DomainError> { + panic!("PanicPosterFetcher called") + } +} + +pub struct PanicDiaryExporter; + +#[async_trait] +impl DiaryExporter for PanicDiaryExporter { + async fn serialize_entries( + &self, + _: &[DiaryEntry], + _: ExportFormat, + ) -> Result, DomainError> { + panic!("PanicDiaryExporter called") + } +} + +pub struct PanicDocumentParser; + +impl DocumentParser for PanicDocumentParser { + fn parse(&self, _: &[u8], _: FileFormat) -> Result { + panic!("PanicDocumentParser called") + } + fn apply_mapping(&self, _: &ParsedFile, _: &[FieldMapping]) -> Vec { + panic!("PanicDocumentParser called") + } +} + +pub struct PanicRemoteWatchlistRepository; + +#[async_trait] +impl crate::ports::RemoteWatchlistRepository for PanicRemoteWatchlistRepository { + async fn save(&self, _: crate::models::RemoteWatchlistEntry) -> Result<(), DomainError> { + panic!("PanicRemoteWatchlistRepository called") + } + async fn remove_by_ap_id(&self, _: &str, _: &str) -> Result<(), DomainError> { + panic!("PanicRemoteWatchlistRepository called") + } + async fn get_by_actor_url( + &self, + _: &str, + ) -> Result, DomainError> { + panic!("PanicRemoteWatchlistRepository called") + } + async fn remove_all_by_actor(&self, _: &str) -> Result<(), DomainError> { + panic!("PanicRemoteWatchlistRepository called") + } + async fn get_by_derived_uuid( + &self, + _: uuid::Uuid, + ) -> Result, DomainError> { + panic!("PanicRemoteWatchlistRepository called") + } +} + +pub struct PanicProfileFieldsRepo; + +#[async_trait] +impl UserProfileFieldsRepository for PanicProfileFieldsRepo { + async fn get_fields( + &self, + _: &UserId, + ) -> Result, DomainError> { + panic!("PanicProfileFieldsRepo called") + } + async fn set_fields( + &self, + _: &UserId, + _: Vec, + ) -> Result<(), DomainError> { + panic!("PanicProfileFieldsRepo called") + } +} + +pub struct PanicSocialQueryPort; + +#[async_trait] +impl crate::ports::SocialQueryPort for PanicSocialQueryPort { + async fn get_accepted_following_urls(&self, _: uuid::Uuid) -> Result, DomainError> { + panic!("PanicSocialQueryPort called") + } + async fn list_all_followed_remote_actors( + &self, + ) -> Result, DomainError> { + panic!("PanicSocialQueryPort called") + } + async fn count_following(&self, _: uuid::Uuid) -> Result { + panic!("PanicSocialQueryPort called") + } + async fn count_accepted_followers(&self, _: uuid::Uuid) -> Result { + panic!("PanicSocialQueryPort called") + } + async fn get_pending_followers( + &self, + _: uuid::Uuid, + ) -> Result, DomainError> { + panic!("PanicSocialQueryPort called") + } +} + +pub struct PanicWatchEventRepository; + +#[async_trait] +impl crate::ports::WatchEventRepository for PanicWatchEventRepository { + async fn save(&self, _: &crate::models::WatchEvent) -> Result<(), DomainError> { + panic!("PanicWatchEventRepository called") + } + async fn update_status( + &self, + _: &crate::value_objects::WatchEventId, + _: crate::models::WatchEventStatus, + ) -> Result<(), DomainError> { + panic!("PanicWatchEventRepository called") + } + async fn list_pending( + &self, + _: &UserId, + ) -> Result, DomainError> { + panic!("PanicWatchEventRepository called") + } + async fn get_by_id( + &self, + _: &crate::value_objects::WatchEventId, + ) -> Result, DomainError> { + panic!("PanicWatchEventRepository called") + } + async fn get_by_ids( + &self, + _: &[crate::value_objects::WatchEventId], + ) -> Result, DomainError> { + panic!("PanicWatchEventRepository called") + } + async fn update_status_batch( + &self, + _: &[crate::value_objects::WatchEventId], + _: crate::models::WatchEventStatus, + ) -> Result { + panic!("PanicWatchEventRepository called") + } + async fn find_duplicate( + &self, + _: &UserId, + _: &str, + _: chrono::NaiveDateTime, + ) -> Result { + panic!("PanicWatchEventRepository called") + } + async fn delete_non_pending_older_than( + &self, + _: chrono::NaiveDateTime, + ) -> Result { + panic!("PanicWatchEventRepository called") + } +} + +pub struct PanicWebhookTokenRepository; + +#[async_trait] +impl crate::ports::WebhookTokenRepository for PanicWebhookTokenRepository { + async fn save(&self, _: &crate::models::WebhookToken) -> Result<(), DomainError> { + panic!("PanicWebhookTokenRepository called") + } + async fn find_by_token_hash( + &self, + _: &str, + ) -> Result, DomainError> { + panic!("PanicWebhookTokenRepository called") + } + async fn list_by_user( + &self, + _: &UserId, + ) -> Result, DomainError> { + panic!("PanicWebhookTokenRepository called") + } + async fn delete( + &self, + _: &crate::value_objects::WebhookTokenId, + _: &UserId, + ) -> Result<(), DomainError> { + panic!("PanicWebhookTokenRepository called") + } + async fn touch_last_used( + &self, + _: &crate::value_objects::WebhookTokenId, + ) -> Result<(), DomainError> { + panic!("PanicWebhookTokenRepository called") + } +} diff --git a/crates/domain/src/testing/wrapup.rs b/crates/domain/src/testing/wrapup.rs new file mode 100644 index 0000000..4d09369 --- /dev/null +++ b/crates/domain/src/testing/wrapup.rs @@ -0,0 +1,247 @@ +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use uuid::Uuid; + +use crate::{errors::DomainError, ports::WrapUpRepository, value_objects::WrapUpId}; + +// ── PanicWrapUpStatsQuery ─────────────────────────────────────────────────── + +pub struct PanicWrapUpStatsQuery; + +#[async_trait] +impl crate::ports::WrapUpStatsQuery for PanicWrapUpStatsQuery { + async fn get_reviews_with_profiles( + &self, + _: &crate::models::wrapup::WrapUpScope, + _: &crate::models::wrapup::DateRange, + ) -> Result, DomainError> { + unimplemented!("WrapUpStatsQuery not wired") + } +} + +// ── InMemoryWrapUpStatsQuery ──────────────────────────────────────────────── + +pub struct InMemoryWrapUpStatsQuery { + pub rows: Mutex>, +} + +impl InMemoryWrapUpStatsQuery { + pub fn new() -> Arc { + Arc::new(Self { + rows: Mutex::new(Vec::new()), + }) + } + + pub fn with_rows(rows: Vec) -> Arc { + Arc::new(Self { + rows: Mutex::new(rows), + }) + } +} + +#[async_trait] +impl crate::ports::WrapUpStatsQuery for InMemoryWrapUpStatsQuery { + async fn get_reviews_with_profiles( + &self, + scope: &crate::models::wrapup::WrapUpScope, + range: &crate::models::wrapup::DateRange, + ) -> Result, DomainError> { + let rows = self.rows.lock().unwrap(); + let filtered: Vec<_> = rows + .iter() + .filter(|r| { + let date = r.watched_at.date(); + date >= range.start() && date < range.end() + }) + .filter(|r| match scope { + crate::models::wrapup::WrapUpScope::User(uid) => r.user_id == *uid, + crate::models::wrapup::WrapUpScope::Global => true, + }) + .cloned() + .collect(); + Ok(filtered) + } +} + +// ── InMemoryWrapUpRepository ──────────────────────────────────────────────── + +pub struct InMemoryWrapUpRepository { + pub store: Mutex>, +} + +impl InMemoryWrapUpRepository { + pub fn new() -> Arc { + Arc::new(Self { + store: Mutex::new(Vec::new()), + }) + } +} + +#[async_trait] +impl WrapUpRepository for InMemoryWrapUpRepository { + async fn create( + &self, + record: &crate::models::wrapup::WrapUpRecord, + ) -> Result<(), DomainError> { + self.store.lock().unwrap().push(record.clone()); + Ok(()) + } + + async fn update_status( + &self, + id: &WrapUpId, + status: &crate::models::wrapup::WrapUpStatus, + error: Option<&str>, + ) -> Result<(), DomainError> { + let mut store = self.store.lock().unwrap(); + if let Some(rec) = store.iter_mut().find(|r| r.id == *id) { + rec.status = status.clone(); + rec.error_message = error.map(|s| s.to_string()); + Ok(()) + } else { + Err(DomainError::NotFound("wrapup record".into())) + } + } + + async fn set_complete( + &self, + id: &WrapUpId, + report: &crate::models::wrapup::WrapUpReport, + ) -> Result<(), DomainError> { + let mut store = self.store.lock().unwrap(); + if let Some(rec) = store.iter_mut().find(|r| r.id == *id) { + rec.status = crate::models::wrapup::WrapUpStatus::Ready; + rec.report = Some(report.clone()); + rec.completed_at = Some(chrono::Utc::now().naive_utc()); + Ok(()) + } else { + Err(DomainError::NotFound("wrapup record".into())) + } + } + + async fn get_by_id( + &self, + id: &WrapUpId, + ) -> Result, DomainError> { + Ok(self + .store + .lock() + .unwrap() + .iter() + .find(|r| r.id == *id) + .cloned()) + } + + async fn list_for_user( + &self, + user_id: Uuid, + ) -> Result, DomainError> { + Ok(self + .store + .lock() + .unwrap() + .iter() + .filter(|r| r.user_id == Some(user_id)) + .cloned() + .collect()) + } + + async fn list_global(&self) -> Result, DomainError> { + Ok(self + .store + .lock() + .unwrap() + .iter() + .filter(|r| r.user_id.is_none()) + .cloned() + .collect()) + } + + async fn find_existing( + &self, + user_id: Option, + start: chrono::NaiveDate, + end: chrono::NaiveDate, + ) -> Result, DomainError> { + Ok(self + .store + .lock() + .unwrap() + .iter() + .find(|r| r.user_id == user_id && r.start_date == start && r.end_date == end) + .cloned()) + } + + async fn delete(&self, id: &WrapUpId) -> Result<(), DomainError> { + self.store.lock().unwrap().retain(|r| r.id != *id); + Ok(()) + } + + async fn delete_failed_older_than( + &self, + before: chrono::NaiveDateTime, + ) -> Result { + let mut store = self.store.lock().unwrap(); + let before_len = store.len(); + store.retain(|r| { + !(r.status == crate::models::wrapup::WrapUpStatus::Failed && r.created_at < before) + }); + Ok((before_len - store.len()) as u64) + } +} + +// ── PanicWrapUpRepository ────────────────────────────────────────────────── + +pub struct PanicWrapUpRepository; + +#[async_trait] +impl WrapUpRepository for PanicWrapUpRepository { + async fn create(&self, _: &crate::models::wrapup::WrapUpRecord) -> Result<(), DomainError> { + panic!("PanicWrapUpRepository called") + } + async fn update_status( + &self, + _: &WrapUpId, + _: &crate::models::wrapup::WrapUpStatus, + _: Option<&str>, + ) -> Result<(), DomainError> { + panic!("PanicWrapUpRepository called") + } + async fn set_complete( + &self, + _: &WrapUpId, + _: &crate::models::wrapup::WrapUpReport, + ) -> Result<(), DomainError> { + panic!("PanicWrapUpRepository called") + } + async fn get_by_id( + &self, + _: &WrapUpId, + ) -> Result, DomainError> { + panic!("PanicWrapUpRepository called") + } + async fn list_for_user( + &self, + _: Uuid, + ) -> Result, DomainError> { + panic!("PanicWrapUpRepository called") + } + async fn list_global(&self) -> Result, DomainError> { + panic!("PanicWrapUpRepository called") + } + async fn find_existing( + &self, + _: Option, + _: chrono::NaiveDate, + _: chrono::NaiveDate, + ) -> Result, DomainError> { + panic!("PanicWrapUpRepository called") + } + async fn delete(&self, _: &WrapUpId) -> Result<(), DomainError> { + panic!("PanicWrapUpRepository called") + } + async fn delete_failed_older_than(&self, _: chrono::NaiveDateTime) -> Result { + panic!("PanicWrapUpRepository called") + } +} diff --git a/crates/presentation/src/handlers/api.rs b/crates/presentation/src/handlers/api.rs deleted file mode 100644 index 0089e30..0000000 --- a/crates/presentation/src/handlers/api.rs +++ /dev/null @@ -1,1706 +0,0 @@ -use axum::{ - Json, - extract::{Multipart, Path, Query, State}, - http::StatusCode, - response::IntoResponse, -}; -use uuid::Uuid; - -use std::str::FromStr; - -use application::{ - auth::{ - commands::RegisterCommand, login as login_uc, queries::LoginQuery, register as register_uc, - }, - diary::{ - commands::{DeleteReviewCommand, MovieInput, SyncPosterCommand}, - delete_review, export_diary as export_diary_uc, get_activity_feed as get_feed_uc, - get_diary, get_movie_social_page, get_review_history, log_review, - queries::{ - ExportQuery, GetActivityFeedQuery, GetMovieSocialPageQuery, GetReviewHistoryQuery, - }, - }, - movies::{get_movies, queries::GetMoviesQuery, sync_poster}, - person::{get as get_person, get_credits as get_person_credits}, - search::execute as search_uc, - users::{ - get_profile as get_user_profile_uc, get_users, - queries::{GetUserProfileQuery, GetUsersQuery}, - update_profile, update_profile_fields, - }, - watchlist::{ - add as add_to_watchlist, - commands::{AddToWatchlistCommand, RemoveFromWatchlistCommand}, - get as get_watchlist, is_on as is_on_watchlist, - queries::{GetWatchlistQuery, IsOnWatchlistQuery}, - remove as remove_from_watchlist, - }, -}; -use domain::{ - models::{ExportFormat, PersonId, collections::PageParams}, - services::review_history::Trend, - value_objects::UserId, -}; - -use crate::{ - errors::ApiError, - extractors::AuthenticatedUser, - forms::{LogReviewData, to_diary_query}, - state::AppState, -}; -use api_types::search::{ - CastCreditDto, CrewCreditDto, MovieSearchHitDto, PaginatedMovieHits, PaginatedPersonHits, - PersonCreditsDto, PersonDto, PersonSearchHitDto, SearchQueryParams, SearchResponse, -}; -use api_types::{ - ActivityFeedQueryParams, ActivityFeedResponse, AddToWatchlistRequest, CastMemberDto, - CreateGoalRequest, CrewMemberDto, DiaryQueryParams, DiaryResponse, DirectorStatDto, - ExportQueryParams, GenreDto, GoalDto, GoalsResponse, KeywordDto, LogReviewRequest, - LoginRequest, LoginResponse, MonthActivityDto, MonthlyRatingDto, MovieDetailResponse, - MovieProfileResponse, MovieStatsDto, MoviesQueryParams, MoviesResponse, PaginationQueryParams, - ProfileResponse, RegisterRequest, ReviewHistoryResponse, SocialFeedResponse, SocialReviewDto, - UpdateGoalRequest, UpdateUserSettingsRequest, UserProfileQueryParams, UserProfileResponse, - UserSettingsDto, UserStatsDto, UserSummaryDto, UserTrendsDto, UsersResponse, WatchlistEntryDto, - WatchlistResponse, WatchlistStatusResponse, -}; -#[cfg(feature = "federation")] -use api_types::{ - ActorListResponse, ActorUrlRequest, AddBlockedDomainRequest, BlockedActorResponse, - BlockedDomainResponse, FollowRequest, RemoteActorDto, -}; - -#[utoipa::path( - get, path = "/api/v1/diary", - params(DiaryQueryParams), - responses( - (status = 200, body = DiaryResponse), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) -)] -pub async fn get_diary( - State(state): State, - Query(params): Query, -) -> Result, ApiError> { - let page = get_diary::execute(&state.app_ctx, to_diary_query(params)).await?; - - Ok(Json(DiaryResponse { - items: page.items.iter().map(entry_to_dto).collect(), - total_count: page.total_count, - limit: page.limit, - offset: page.offset, - })) -} - -#[utoipa::path( - get, path = "/api/v1/movies", - params(MoviesQueryParams), - responses( - (status = 200, body = MoviesResponse), - ) -)] -pub async fn list_movies( - State(state): State, - Query(params): Query, -) -> Result, ApiError> { - let page = get_movies::execute( - &state.app_ctx, - GetMoviesQuery { - limit: params.limit, - offset: params.offset, - search: params.search, - genre: params.genre, - language: params.language, - }, - ) - .await?; - - Ok(Json(MoviesResponse { - items: page.items.iter().map(summary_to_dto).collect(), - total_count: page.total_count, - limit: page.limit, - offset: page.offset, - })) -} - -#[utoipa::path( - get, path = "/api/v1/movies/{id}/history", - params(("id" = Uuid, Path, description = "Movie ID")), - responses( - (status = 200, body = ReviewHistoryResponse), - (status = 404, description = "Movie not found"), - ) -)] -pub async fn get_review_history( - State(state): State, - Path(movie_id): Path, -) -> Result, ApiError> { - let (history, trend) = - get_review_history::execute(&state.app_ctx, GetReviewHistoryQuery { movie_id }).await?; - - Ok(Json(ReviewHistoryResponse { - movie: movie_to_dto(history.movie()), - viewings: history.viewings().iter().map(review_to_dto).collect(), - trend: match trend { - Trend::Improved => "improved", - Trend::Declined => "declined", - Trend::Neutral => "neutral", - } - .to_string(), - })) -} - -#[utoipa::path( - post, path = "/api/v1/reviews", - request_body = LogReviewRequest, - responses( - (status = 201, description = "Review created"), - (status = 400, description = "Invalid input"), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) -)] -pub async fn post_review( - State(state): State, - user: AuthenticatedUser, - Json(req): Json, -) -> Result { - let data = LogReviewData::try_from(req).map_err(ApiError)?; - log_review::execute(&state.app_ctx, data.into_command(user.0.value())).await?; - Ok(StatusCode::CREATED) -} - -#[utoipa::path( - post, path = "/api/v1/movies/{id}/sync-poster", - params(("id" = Uuid, Path, description = "Movie ID")), - responses( - (status = 204, description = "Poster synced"), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Movie not found"), - ), - security(("bearer_auth" = [])) -)] -pub async fn sync_poster( - State(state): State, - _user: AuthenticatedUser, - Path(movie_id): Path, -) -> Result { - sync_poster::execute(&state.app_ctx, SyncPosterCommand { movie_id }).await?; - Ok(StatusCode::NO_CONTENT) -} - -#[utoipa::path( - post, path = "/api/v1/auth/login", - request_body = LoginRequest, - responses( - (status = 200, body = LoginResponse), - (status = 401, description = "Invalid credentials"), - ) -)] -pub async fn login( - State(state): State, - Json(req): Json, -) -> Result, ApiError> { - let result = login_uc::execute( - &state.app_ctx, - LoginQuery { - email: req.email, - password: req.password, - }, - ) - .await?; - Ok(Json(LoginResponse { - token: result.token, - user_id: result.user_id, - email: result.email, - expires_at: result.expires_at.to_rfc3339(), - role: result.role, - })) -} - -#[utoipa::path( - post, path = "/api/v1/auth/register", - request_body = RegisterRequest, - responses( - (status = 201, description = "User registered"), - (status = 400, description = "Invalid input"), - ) -)] -pub async fn register( - State(state): State, - Json(req): Json, -) -> Result { - register_uc::execute( - &state.app_ctx, - RegisterCommand { - email: req.email, - username: req.username, - password: req.password, - role: domain::models::UserRole::Standard, - }, - ) - .await?; - Ok(StatusCode::CREATED) -} - -#[utoipa::path( - delete, path = "/api/v1/reviews/{id}", - params(("id" = Uuid, Path, description = "Review ID")), - responses( - (status = 204, description = "Review deleted"), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden"), - (status = 404, description = "Review not found"), - ), - security(("bearer_auth" = [])) -)] -pub async fn delete_review( - State(state): State, - AuthenticatedUser(user_id): AuthenticatedUser, - Path(review_id): Path, -) -> Result { - let cmd = DeleteReviewCommand { - review_id, - requesting_user_id: user_id.value(), - }; - delete_review::execute(&state.app_ctx, cmd).await?; - Ok(StatusCode::NO_CONTENT) -} - -#[utoipa::path( - get, path = "/api/v1/movies/{movie_id}", - params(("movie_id" = Uuid, Path, description = "Movie ID")), - responses( - (status = 200, body = MovieDetailResponse), - (status = 404, description = "Movie not found"), - ) -)] -pub async fn get_movie_detail( - State(state): State, - Path(movie_id): Path, - Query(params): Query, -) -> Result, ApiError> { - let limit = params.limit.unwrap_or(20); - let offset = params.offset.unwrap_or(0); - - let result = get_movie_social_page::execute( - &state.app_ctx, - GetMovieSocialPageQuery { - movie_id, - limit, - offset, - }, - ) - .await?; - - Ok(Json(MovieDetailResponse { - movie: movie_to_dto(&result.movie), - stats: MovieStatsDto { - total_count: result.stats.total_count, - avg_rating: result.stats.avg_rating, - federated_count: result.stats.federated_count, - rating_histogram: result.stats.rating_histogram, - }, - reviews: SocialFeedResponse { - items: result - .reviews - .items - .iter() - .map(|e| SocialReviewDto { - user_display: e.user_display_name().to_string(), - rating: e.review().rating().value(), - comment: e.review().comment().map(|c| c.value().to_string()), - watched_at: e.review().watched_at().to_string(), - is_federated: e.review().is_remote(), - }) - .collect(), - total_count: result.reviews.total_count, - limit: result.reviews.limit, - offset: result.reviews.offset, - }, - })) -} - -#[utoipa::path( - get, path = "/api/v1/movies/{id}/profile", - params(("id" = Uuid, Path, description = "Movie ID")), - responses( - (status = 200, body = MovieProfileResponse), - (status = 404, description = "No profile found for this movie"), - ) -)] -pub async fn get_movie_profile( - State(state): State, - Path(movie_id): Path, -) -> impl IntoResponse { - use application::movies::get_movie_profile; - let query = get_movie_profile::GetMovieProfileQuery { movie_id }; - match get_movie_profile::execute(&state.app_ctx, query).await { - Ok(Some(result)) => { - let p = result.profile; - Json(MovieProfileResponse { - tmdb_id: p.tmdb_id, - imdb_id: p.imdb_id, - overview: p.overview, - tagline: p.tagline, - runtime_minutes: p.runtime_minutes, - budget_usd: p.budget_usd, - revenue_usd: p.revenue_usd, - vote_average: p.vote_average, - vote_count: p.vote_count, - original_language: p.original_language, - collection_name: p.collection_name, - genres: p - .genres - .into_iter() - .map(|g| GenreDto { - tmdb_id: g.tmdb_id, - name: g.name, - }) - .collect(), - keywords: p - .keywords - .into_iter() - .map(|k| KeywordDto { - tmdb_id: k.tmdb_id, - name: k.name, - }) - .collect(), - cast: result - .cast - .into_iter() - .map(|c| CastMemberDto { - person_id: c.person_id.value().to_string(), - tmdb_person_id: c.tmdb_person_id, - name: c.name, - character: c.character, - billing_order: c.billing_order, - profile_path: c.profile_path, - }) - .collect(), - crew: result - .crew - .into_iter() - .map(|c| CrewMemberDto { - person_id: c.person_id.value().to_string(), - tmdb_person_id: c.tmdb_person_id, - name: c.name, - job: c.job, - department: c.department, - profile_path: c.profile_path, - }) - .collect(), - enriched_at: p.enriched_at.to_rfc3339(), - }) - .into_response() - } - Ok(None) => StatusCode::NOT_FOUND.into_response(), - Err(e) => crate::errors::domain_error_response(e), - } -} - -#[utoipa::path( - get, path = "/api/v1/profile", - responses( - (status = 200, body = ProfileResponse), - (status = 401, description = "Unauthorized"), - (status = 404, description = "User not found"), - ), - security(("bearer_auth" = [])) -)] -pub async fn get_profile( - State(state): State, - AuthenticatedUser(user_id): AuthenticatedUser, -) -> Result, ApiError> { - let profile = application::users::get_current_profile::execute( - &state.app_ctx, - application::users::queries::GetCurrentProfileQuery { - user_id: user_id.value(), - }, - ) - .await?; - Ok(Json(ProfileResponse { - username: profile.username, - display_name: profile.display_name, - bio: profile.bio, - avatar_url: profile.avatar_url, - banner_url: profile.banner_url, - also_known_as: profile.also_known_as, - fields: profile - .fields - .into_iter() - .map(|f| api_types::ProfileFieldDto { - name: f.name, - value: f.value, - }) - .collect(), - role: profile.role, - })) -} - -#[utoipa::path( - put, path = "/api/v1/profile", - responses( - (status = 204, description = "Profile updated"), - (status = 400, description = "Invalid input"), - (status = 401, description = "Unauthorized"), - (status = 500, description = "Internal server error"), - ), - security(("bearer_auth" = [])) -)] -pub async fn update_profile_handler( - State(state): State, - AuthenticatedUser(user_id): AuthenticatedUser, - mut multipart: Multipart, -) -> impl IntoResponse { - let mut display_name: Option = None; - let mut bio: Option = None; - let mut avatar_bytes: Option> = None; - let mut avatar_content_type: Option = None; - let mut banner_bytes: Option> = None; - let mut banner_content_type: Option = None; - let mut also_known_as: Option = None; - - while let Ok(Some(field)) = multipart.next_field().await { - let name = field.name().unwrap_or("").to_string(); - match name.as_str() { - "display_name" => { - if let Ok(text) = field.text().await { - display_name = Some(text).filter(|s| !s.is_empty()); - } - } - "bio" => { - if let Ok(text) = field.text().await { - bio = Some(text); - } - } - "also_known_as" => { - if let Ok(text) = field.text().await { - also_known_as = Some(text).filter(|s| !s.is_empty()); - } - } - "avatar" => { - let ct = field.content_type().map(|s| s.to_string()); - if let Ok(bytes) = field.bytes().await - && !bytes.is_empty() - { - avatar_bytes = Some(bytes.to_vec()); - avatar_content_type = ct; - } - } - "banner" => { - let ct = field.content_type().map(|s| s.to_string()); - if let Ok(bytes) = field.bytes().await - && !bytes.is_empty() - { - banner_bytes = Some(bytes.to_vec()); - banner_content_type = ct; - } - } - _ => {} - } - } - - let cmd = application::users::commands::UpdateProfileCommand { - user_id: user_id.value(), - display_name, - bio, - avatar_bytes, - avatar_content_type, - banner_bytes, - banner_content_type, - also_known_as, - }; - - match update_profile::execute(&state.app_ctx, cmd).await { - Ok(()) => StatusCode::NO_CONTENT.into_response(), - Err(e) => crate::errors::domain_error_response(e), - } -} - -#[utoipa::path( - put, path = "/api/v1/profile/fields", - request_body = api_types::UpdateProfileFieldsRequest, - responses( - (status = 204, description = "Profile fields updated"), - (status = 400, description = "Invalid input"), - (status = 401, description = "Unauthorized"), - (status = 500, description = "Internal server error"), - ), - security(("bearer_auth" = [])) -)] -pub async fn update_profile_fields_handler( - State(state): State, - AuthenticatedUser(user_id): AuthenticatedUser, - axum::Json(body): axum::Json, -) -> impl IntoResponse { - let raw_fields = match body.get("fields").and_then(|f| f.as_array()) { - Some(arr) => arr.clone(), - None => return StatusCode::BAD_REQUEST.into_response(), - }; - - let fields: Vec = raw_fields - .iter() - .filter_map(|f| { - let name = f.get("name").and_then(|n| n.as_str())?.to_string(); - let value = f.get("value").and_then(|v| v.as_str())?.to_string(); - Some(domain::models::ProfileField { name, value }) - }) - .collect(); - - let cmd = application::users::commands::UpdateProfileFieldsCommand { - user_id: user_id.value(), - fields, - }; - - match update_profile_fields::execute(&state.app_ctx, cmd).await { - Ok(()) => StatusCode::NO_CONTENT.into_response(), - Err(e) => crate::errors::domain_error_response(e), - } -} - -use crate::mappers::movies::{entry_to_dto, movie_to_dto, review_to_dto, summary_to_dto}; - -#[cfg(feature = "federation")] -#[utoipa::path( - get, path = "/api/v1/admin/blocked-domains", - responses( - (status = 200, body = Vec), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden — admin only"), - ), - security(("bearer_auth" = [])) -)] -pub async fn get_blocked_domains_admin( - State(state): State, - _admin: crate::extractors::AdminUser, -) -> impl IntoResponse { - match state.ap_service.get_blocked_domains().await { - Ok(domains) => { - let response: Vec = domains - .into_iter() - .map(|d| BlockedDomainResponse { - domain: d.domain, - reason: d.reason, - blocked_at: d.blocked_at, - }) - .collect(); - axum::Json(response).into_response() - } - Err(e) => ap_err(e).into_response(), - } -} - -#[cfg(feature = "federation")] -#[utoipa::path( - post, path = "/api/v1/admin/blocked-domains", - request_body = AddBlockedDomainRequest, - responses( - (status = 201, description = "Domain blocked"), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden — admin only"), - ), - security(("bearer_auth" = [])) -)] -pub async fn add_blocked_domain_admin( - State(state): State, - _admin: crate::extractors::AdminUser, - axum::Json(body): axum::Json, -) -> impl IntoResponse { - match state - .ap_service - .add_blocked_domain(&body.domain, body.reason.as_deref()) - .await - { - Ok(()) => StatusCode::CREATED.into_response(), - Err(e) => ap_err(e).into_response(), - } -} - -#[cfg(feature = "federation")] -#[utoipa::path( - delete, path = "/api/v1/admin/blocked-domains/{domain}", - params(("domain" = String, Path, description = "Domain to unblock")), - responses( - (status = 204, description = "Domain unblocked"), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden — admin only"), - ), - security(("bearer_auth" = [])) -)] -pub async fn remove_blocked_domain_admin( - State(state): State, - _admin: crate::extractors::AdminUser, - axum::extract::Path(domain): axum::extract::Path, -) -> impl IntoResponse { - match state.ap_service.remove_blocked_domain(&domain).await { - Ok(()) => StatusCode::NO_CONTENT.into_response(), - Err(e) => ap_err(e).into_response(), - } -} - -#[cfg(feature = "federation")] -#[utoipa::path( - post, path = "/api/v1/social/block", - request_body = ActorUrlRequest, - responses( - (status = 204, description = "Actor blocked"), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) -)] -pub async fn block_actor_api( - State(state): State, - user: AuthenticatedUser, - axum::Json(body): axum::Json, -) -> impl IntoResponse { - match state - .ap_service - .block_actor(user.0.value(), &body.actor_url) - .await - { - Ok(()) => StatusCode::NO_CONTENT.into_response(), - Err(e) => ap_err(e).into_response(), - } -} - -#[cfg(feature = "federation")] -#[utoipa::path( - post, path = "/api/v1/social/unblock", - request_body = ActorUrlRequest, - responses( - (status = 204, description = "Actor unblocked"), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) -)] -pub async fn unblock_actor_api( - State(state): State, - user: AuthenticatedUser, - axum::Json(body): axum::Json, -) -> impl IntoResponse { - match state - .ap_service - .unblock_actor(user.0.value(), &body.actor_url) - .await - { - Ok(()) => StatusCode::NO_CONTENT.into_response(), - Err(e) => ap_err(e).into_response(), - } -} - -#[cfg(feature = "federation")] -#[utoipa::path( - get, path = "/api/v1/social/blocked", - responses( - (status = 200, body = Vec), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) -)] -pub async fn get_blocked_actors_api( - State(state): State, - user: AuthenticatedUser, -) -> impl IntoResponse { - match state.ap_service.get_blocked_actors(user.0.value()).await { - Ok(actors) => { - let response: Vec = actors - .into_iter() - .map(|a| BlockedActorResponse { - url: a.url, - handle: a.handle, - display_name: a.display_name, - avatar_url: a.avatar_url, - }) - .collect(); - axum::Json(response).into_response() - } - Err(e) => ap_err(e).into_response(), - } -} - -#[cfg(feature = "federation")] -fn ap_err(e: anyhow::Error) -> impl IntoResponse { - tracing::error!("ActivityPub error: {:?}", e); - StatusCode::INTERNAL_SERVER_ERROR -} - -#[cfg(feature = "federation")] -fn ap_to_domain(e: anyhow::Error) -> domain::errors::DomainError { - tracing::error!("ActivityPub error: {:?}", e); - domain::errors::DomainError::InfrastructureError(e.to_string()) -} - -#[cfg(feature = "federation")] -#[utoipa::path( - get, path = "/api/v1/social/following", - responses( - (status = 200, body = ActorListResponse), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) -)] -pub async fn get_following( - State(state): State, - user: AuthenticatedUser, -) -> impl IntoResponse { - match state.ap_service.get_following(user.0.value()).await { - Ok(actors) => Json(ActorListResponse { - actors: actors - .into_iter() - .map(|a| RemoteActorDto { - handle: a.handle, - display_name: a.display_name, - url: a.url, - }) - .collect(), - }) - .into_response(), - Err(e) => ap_err(e).into_response(), - } -} - -#[cfg(feature = "federation")] -#[utoipa::path( - get, path = "/api/v1/social/followers", - responses( - (status = 200, body = ActorListResponse), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) -)] -pub async fn get_followers( - State(state): State, - user: AuthenticatedUser, -) -> impl IntoResponse { - match state - .ap_service - .get_accepted_followers(user.0.value()) - .await - { - Ok(actors) => Json(ActorListResponse { - actors: actors - .into_iter() - .map(|a| RemoteActorDto { - handle: a.handle, - display_name: a.display_name, - url: a.url, - }) - .collect(), - }) - .into_response(), - Err(e) => ap_err(e).into_response(), - } -} - -#[cfg(feature = "federation")] -pub async fn get_user_following( - State(state): State, - _user: AuthenticatedUser, - Path(user_id): Path, -) -> Result, ApiError> { - let actors = state - .ap_service - .get_following(user_id) - .await - .map_err(ap_to_domain)?; - Ok(Json(ActorListResponse { - actors: actors - .into_iter() - .map(|a| RemoteActorDto { - handle: a.handle, - display_name: a.display_name, - url: a.url, - }) - .collect(), - })) -} - -#[cfg(feature = "federation")] -pub async fn get_user_followers( - State(state): State, - _user: AuthenticatedUser, - Path(user_id): Path, -) -> Result, ApiError> { - let actors = state - .ap_service - .get_accepted_followers(user_id) - .await - .map_err(ap_to_domain)?; - Ok(Json(ActorListResponse { - actors: actors - .into_iter() - .map(|a| RemoteActorDto { - handle: a.handle, - display_name: a.display_name, - url: a.url, - }) - .collect(), - })) -} - -#[cfg(feature = "federation")] -#[utoipa::path( - post, path = "/api/v1/social/follow", - request_body = FollowRequest, - responses( - (status = 200, description = "Follow request sent"), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) -)] -pub async fn follow( - State(state): State, - user: AuthenticatedUser, - Json(body): Json, -) -> impl IntoResponse { - match state.ap_service.follow(user.0.value(), &body.handle).await { - Ok(()) => StatusCode::OK.into_response(), - Err(e) => ap_err(e).into_response(), - } -} - -#[cfg(feature = "federation")] -#[utoipa::path( - post, path = "/api/v1/social/unfollow", - request_body = ActorUrlRequest, - responses( - (status = 200, description = "Unfollowed"), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) -)] -pub async fn unfollow( - State(state): State, - user: AuthenticatedUser, - Json(body): Json, -) -> impl IntoResponse { - match state - .ap_service - .unfollow(user.0.value(), &body.actor_url) - .await - { - Ok(()) => StatusCode::OK.into_response(), - Err(e) => ap_err(e).into_response(), - } -} - -#[cfg(feature = "federation")] -#[utoipa::path( - post, path = "/api/v1/social/followers/accept", - request_body = ActorUrlRequest, - responses( - (status = 200, description = "Follower accepted"), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) -)] -pub async fn accept_follower( - State(state): State, - user: AuthenticatedUser, - Json(body): Json, -) -> impl IntoResponse { - match state - .ap_service - .accept_follower(user.0.value(), &body.actor_url) - .await - { - Ok(()) => StatusCode::OK.into_response(), - Err(e) => ap_err(e).into_response(), - } -} - -#[cfg(feature = "federation")] -#[utoipa::path( - post, path = "/api/v1/social/followers/reject", - request_body = ActorUrlRequest, - responses( - (status = 200, description = "Follower rejected"), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) -)] -pub async fn reject_follower( - State(state): State, - user: AuthenticatedUser, - Json(body): Json, -) -> impl IntoResponse { - match state - .ap_service - .reject_follower(user.0.value(), &body.actor_url) - .await - { - Ok(()) => StatusCode::OK.into_response(), - Err(e) => ap_err(e).into_response(), - } -} - -#[cfg(feature = "federation")] -#[utoipa::path( - post, path = "/api/v1/social/followers/remove", - request_body = ActorUrlRequest, - responses( - (status = 200, description = "Follower removed"), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) -)] -pub async fn remove_follower( - State(state): State, - user: AuthenticatedUser, - Json(body): Json, -) -> impl IntoResponse { - match state - .ap_service - .remove_follower(user.0.value(), &body.actor_url) - .await - { - Ok(()) => StatusCode::OK.into_response(), - Err(e) => ap_err(e).into_response(), - } -} - -#[cfg(feature = "federation")] -#[utoipa::path( - get, path = "/api/v1/social/followers/pending", - responses( - (status = 200, body = ActorListResponse), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) -)] -pub async fn get_pending_followers( - State(state): State, - user: AuthenticatedUser, -) -> impl IntoResponse { - match state.ap_service.get_pending_followers(user.0.value()).await { - Ok(actors) => Json(ActorListResponse { - actors: actors - .into_iter() - .map(|a| RemoteActorDto { - handle: a.handle, - display_name: a.display_name, - url: a.url, - }) - .collect(), - }) - .into_response(), - Err(e) => ap_err(e).into_response(), - } -} - -pub async fn post_reindex_search( - State(state): State, - _admin: crate::extractors::AdminApiUser, -) -> impl IntoResponse { - let event = domain::events::DomainEvent::SearchReindexRequested; - match state.app_ctx.services.event_publisher.publish(&event).await { - Ok(()) => StatusCode::ACCEPTED, - Err(e) => { - tracing::error!("failed to publish reindex event: {:?}", e); - StatusCode::INTERNAL_SERVER_ERROR - } - } -} - -#[utoipa::path( - get, path = "/api/v1/activity-feed", - params(ActivityFeedQueryParams), - responses((status = 200, body = ActivityFeedResponse)), -)] -pub async fn get_activity_feed( - State(state): State, - Query(params): Query, -) -> Result, ApiError> { - let page = get_feed_uc::execute( - &state.app_ctx, - GetActivityFeedQuery { - limit: params.limit.unwrap_or(20), - offset: params.offset.unwrap_or(0), - sort_by: params - .sort_by - .as_deref() - .map(|s| s.parse().unwrap_or_default()) - .unwrap_or_default(), - search: None, - viewer_user_id: None, - filter_following: false, - }, - ) - .await?; - Ok(Json(ActivityFeedResponse { - items: page - .items - .iter() - .map(crate::mappers::diary::feed_entry_to_dto) - .collect(), - total_count: page.total_count, - limit: page.limit, - offset: page.offset, - })) -} - -#[utoipa::path( - get, path = "/api/v1/users", - responses((status = 200, body = UsersResponse)), -)] -pub async fn list_users(State(state): State) -> Result, ApiError> { - let result = get_users::execute(&state.app_ctx, GetUsersQuery).await?; - Ok(Json(UsersResponse { - users: result - .users - .iter() - .map(|u| UserSummaryDto { - id: u.user_id.value(), - email: u.email().to_string(), - username: u.username().to_string(), - display_name: u.display_name().map(String::from), - total_movies: u.total_movies, - avg_rating: u.avg_rating, - }) - .collect(), - })) -} - -#[utoipa::path( - get, path = "/api/v1/users/{id}", - params( - ("id" = Uuid, Path, description = "User ID"), - UserProfileQueryParams, - ), - responses( - (status = 200, body = UserProfileResponse), - (status = 404, description = "User not found"), - ) -)] -pub async fn get_user_profile( - State(state): State, - AuthenticatedUser(viewer_id): AuthenticatedUser, - Path(user_id): Path, - Query(params): Query, -) -> impl IntoResponse { - let view_str = params.view.as_deref().unwrap_or("recent"); - let profile_view = match application::users::queries::ProfileView::from_str(view_str) { - Ok(v) => v, - Err(_) => return StatusCode::BAD_REQUEST.into_response(), - }; - - let user = match state - .app_ctx - .repos - .user - .find_by_id(&UserId::from_uuid(user_id)) - .await - { - Ok(Some(u)) => u, - Ok(None) => return StatusCode::NOT_FOUND.into_response(), - Err(e) => { - return crate::errors::domain_error_response(e); - } - }; - - let profile = match get_user_profile_uc::execute( - &state.app_ctx, - GetUserProfileQuery { - user_id, - view: profile_view, - limit: params.limit, - offset: params.offset, - sort_by: domain::ports::FeedSortBy::Date, - search: None, - is_own_profile: viewer_id.value() == user_id, - }, - ) - .await - { - Ok(p) => p, - Err(e) => return crate::errors::domain_error_response(e), - }; - - let entries = profile.entries.map(|p| DiaryResponse { - items: p.items.iter().map(entry_to_dto).collect(), - total_count: p.total_count, - limit: p.limit, - offset: p.offset, - }); - - let history = profile.history.map(|months| { - months - .into_iter() - .map(|m| MonthActivityDto { - year_month: m.year_month, - month_label: m.month_label, - count: m.count, - entries: m.entries.iter().map(entry_to_dto).collect(), - }) - .collect() - }); - - let trends = profile.trends.map(|t| UserTrendsDto { - monthly_ratings: t - .monthly_ratings - .into_iter() - .map(|r| MonthlyRatingDto { - year_month: r.year_month, - month_label: r.month_label, - avg_rating: r.avg_rating, - count: r.count, - }) - .collect(), - top_directors: t - .top_directors - .into_iter() - .map(|d| DirectorStatDto { - director: d.director, - count: d.count, - }) - .collect(), - max_director_count: t.max_director_count, - }); - - Json(UserProfileResponse { - user_id, - username: user.username().value().to_string(), - avatar_url: user - .avatar_path() - .map(|p| format!("{}/images/{}", state.app_ctx.config.base_url, p)), - banner_url: user - .banner_path() - .map(|p| format!("{}/images/{}", state.app_ctx.config.base_url, p)), - stats: UserStatsDto { - total_movies: profile.stats.total_movies, - avg_rating: profile.stats.avg_rating, - favorite_director: profile.stats.favorite_director, - most_active_month: profile.stats.most_active_month, - }, - following_count: profile.following_count, - followers_count: profile.followers_count, - entries, - history, - trends, - goals: { - let goals_list = application::goals::list::execute( - &state.app_ctx, - application::goals::queries::ListGoalsQuery { user_id }, - ) - .await - .unwrap_or_default(); - if goals_list.is_empty() { - None - } else { - Some(goals_list.iter().map(goal_with_progress_to_dto).collect()) - } - }, - }) - .into_response() -} - -#[utoipa::path( - get, path = "/api/v1/diary/export", - params(ExportQueryParams), - responses( - (status = 200, description = "Diary file download", content_type = "text/csv"), - (status = 400, description = "Invalid format parameter"), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) -)] -pub async fn export_diary( - State(state): State, - user: AuthenticatedUser, - Query(params): Query, -) -> impl IntoResponse { - let format = match params.format.as_str() { - "csv" => ExportFormat::Csv, - "json" => ExportFormat::Json, - _ => return StatusCode::BAD_REQUEST.into_response(), - }; - let (content_type, filename) = match &format { - ExportFormat::Csv => ("text/csv; charset=utf-8", "diary.csv"), - ExportFormat::Json => ("application/json", "diary.json"), - }; - let query = ExportQuery { - user_id: user.0.value(), - format, - }; - match export_diary_uc::execute(&state.app_ctx, query).await { - Ok(bytes) => ( - StatusCode::OK, - [ - (axum::http::header::CONTENT_TYPE, content_type.to_string()), - ( - axum::http::header::CONTENT_DISPOSITION, - format!("attachment; filename=\"{}\"", filename), - ), - ], - bytes, - ) - .into_response(), - Err(e) => { - tracing::error!("export error: {:?}", e); - StatusCode::INTERNAL_SERVER_ERROR.into_response() - } - } -} - -// Search and person endpoints are intentionally public — browsing the catalog -// and people profiles does not require authentication. - -#[utoipa::path( - get, path = "/api/v1/search", - params(api_types::search::SearchQueryParams), - responses( - (status = 200, body = api_types::search::SearchResponse), - ), - tag = "search", -)] -pub async fn get_search( - State(state): State, - Query(params): Query, -) -> impl IntoResponse { - let query = domain::models::SearchQuery { - text: params.q, - filters: domain::models::SearchFilters { - genre: params.genre, - year: params.year, - person_id: params.person_id.map(PersonId::from_uuid), - department: params.department, - language: params.language, - }, - page: PageParams { - limit: params.limit.unwrap_or(5), - offset: params.offset.unwrap_or(0), - }, - }; - - match search_uc::execute(&state.app_ctx, query).await { - Ok(results) => axum::Json(SearchResponse { - movies: PaginatedMovieHits { - items: results - .movies - .items - .iter() - .map(|h| MovieSearchHitDto { - movie_id: h.movie_id.value(), - title: h.title.clone(), - release_year: h.release_year, - director: h.director.clone(), - poster_path: h.poster_path.clone(), - genres: h.genres.clone(), - }) - .collect(), - total_count: results.movies.total_count, - limit: results.movies.limit, - offset: results.movies.offset, - }, - people: PaginatedPersonHits { - items: results - .people - .items - .iter() - .map(|h| PersonSearchHitDto { - person_id: h.person_id.value(), - name: h.name.clone(), - known_for_department: h.known_for_department.clone(), - profile_path: h.profile_path.clone(), - known_for_titles: h.known_for_titles.clone(), - }) - .collect(), - total_count: results.people.total_count, - limit: results.people.limit, - offset: results.people.offset, - }, - }) - .into_response(), - Err(e) => crate::errors::domain_error_response(e), - } -} - -#[utoipa::path( - get, path = "/api/v1/people/{id}", - params(("id" = Uuid, Path, description = "Person ID")), - responses( - (status = 200, body = api_types::search::PersonDto), - (status = 404, description = "Person not found"), - ), - tag = "search", -)] -pub async fn get_person_handler( - State(state): State, - Path(id): Path, -) -> impl IntoResponse { - match get_person::execute(&state.app_ctx, PersonId::from_uuid(id)).await { - Ok(Some(person)) => axum::Json(PersonDto { - id: person.id().value(), - external_id: person.external_id().value().to_string(), - name: person.name().to_string(), - known_for_department: person.known_for_department().map(str::to_string), - profile_path: person.profile_path().map(str::to_string), - }) - .into_response(), - Ok(None) => StatusCode::NOT_FOUND.into_response(), - Err(e) => crate::errors::domain_error_response(e), - } -} - -#[utoipa::path( - get, path = "/api/v1/people/{id}/credits", - params(("id" = Uuid, Path, description = "Person ID")), - responses( - (status = 200, body = api_types::search::PersonCreditsDto), - (status = 404, description = "Person not found"), - ), - tag = "search", -)] -pub async fn get_person_credits_handler( - State(state): State, - Path(id): Path, -) -> impl IntoResponse { - match get_person_credits::execute(&state.app_ctx, PersonId::from_uuid(id)).await { - Ok(credits) => axum::Json(PersonCreditsDto { - person: PersonDto { - id: credits.person.id().value(), - external_id: credits.person.external_id().value().to_string(), - name: credits.person.name().to_string(), - known_for_department: credits.person.known_for_department().map(str::to_string), - profile_path: credits.person.profile_path().map(str::to_string), - }, - cast: credits - .cast - .iter() - .map(|c| CastCreditDto { - movie_id: c.movie_id.value(), - title: c.title.clone(), - release_year: c.release_year, - character: c.character.clone(), - poster_path: c.poster_path.clone(), - }) - .collect(), - crew: credits - .crew - .iter() - .map(|c| CrewCreditDto { - movie_id: c.movie_id.value(), - title: c.title.clone(), - release_year: c.release_year, - job: c.job.clone(), - department: c.department.clone(), - poster_path: c.poster_path.clone(), - }) - .collect(), - }) - .into_response(), - Err(e) => crate::errors::domain_error_response(e), - } -} - -#[utoipa::path( - get, path = "/api/v1/watchlist", - params( - ("limit" = Option, Query, description = "Max results"), - ("offset" = Option, Query, description = "Offset"), - ), - responses( - (status = 200, body = WatchlistResponse), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) -)] -pub async fn get_watchlist_handler( - State(state): State, - user: AuthenticatedUser, - Query(params): Query, -) -> Result, ApiError> { - let page = get_watchlist::execute( - &state.app_ctx, - GetWatchlistQuery { - user_id: user.0.value(), - limit: params.limit, - offset: params.offset, - }, - ) - .await?; - - Ok(Json(WatchlistResponse { - items: page - .items - .into_iter() - .map(|w| WatchlistEntryDto { - id: w.entry.id.value(), - movie: movie_to_dto(&w.movie), - added_at: w.entry.added_at.to_string(), - }) - .collect(), - total_count: page.total_count, - limit: page.limit, - offset: page.offset, - })) -} - -#[utoipa::path( - post, path = "/api/v1/watchlist", - request_body = AddToWatchlistRequest, - responses( - (status = 201, description = "Added to watchlist"), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Movie not found"), - ), - security(("bearer_auth" = [])) -)] -pub async fn post_watchlist_add( - State(state): State, - user: AuthenticatedUser, - Json(req): Json, -) -> Result { - add_to_watchlist::execute( - &state.app_ctx, - AddToWatchlistCommand { - user_id: user.0.value(), - input: MovieInput { - movie_id: req.movie_id, - external_metadata_id: req.external_metadata_id, - manual_title: req.manual_title, - manual_release_year: req.manual_release_year, - manual_director: None, - }, - }, - ) - .await?; - Ok(StatusCode::CREATED) -} - -#[utoipa::path( - delete, path = "/api/v1/watchlist/{movie_id}", - params(("movie_id" = Uuid, Path, description = "Movie ID")), - responses( - (status = 204, description = "Removed from watchlist"), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Not on watchlist"), - ), - security(("bearer_auth" = [])) -)] -pub async fn delete_watchlist_entry( - State(state): State, - user: AuthenticatedUser, - Path(movie_id): Path, -) -> Result { - remove_from_watchlist::execute( - &state.app_ctx, - RemoveFromWatchlistCommand { - user_id: user.0.value(), - movie_id, - }, - ) - .await?; - Ok(StatusCode::NO_CONTENT) -} - -#[utoipa::path( - get, path = "/api/v1/watchlist/{movie_id}", - params(("movie_id" = Uuid, Path, description = "Movie ID")), - responses( - (status = 200, body = WatchlistStatusResponse), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) -)] -pub async fn get_watchlist_status( - State(state): State, - user: AuthenticatedUser, - Path(movie_id): Path, -) -> Result, ApiError> { - let on_watchlist = is_on_watchlist::execute( - &state.app_ctx, - IsOnWatchlistQuery { - user_id: user.0.value(), - movie_id, - }, - ) - .await?; - Ok(Json(WatchlistStatusResponse { on_watchlist })) -} - -// ── Goals ──────────────────────────────────────────────────────────────────── - -fn goal_with_progress_to_dto(g: &domain::models::GoalWithProgress) -> GoalDto { - GoalDto { - year: g.goal.year(), - target_count: g.goal.target_count(), - current_count: g.current_count, - percentage: g.percentage(), - is_complete: g.is_complete(), - goal_type: g.goal.goal_type().as_str().to_string(), - } -} - -#[utoipa::path( - get, path = "/api/v1/goals", - responses( - (status = 200, body = GoalsResponse), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) -)] -pub async fn list_goals( - State(state): State, - user: AuthenticatedUser, -) -> Result, ApiError> { - let goals = application::goals::list::execute( - &state.app_ctx, - application::goals::queries::ListGoalsQuery { - user_id: user.0.value(), - }, - ) - .await?; - Ok(Json(GoalsResponse { - goals: goals.iter().map(goal_with_progress_to_dto).collect(), - })) -} - -#[utoipa::path( - post, path = "/api/v1/goals", - request_body = CreateGoalRequest, - responses( - (status = 200, body = GoalDto), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) -)] -pub async fn create_goal( - State(state): State, - user: AuthenticatedUser, - Json(req): Json, -) -> Result, ApiError> { - let g = application::goals::create::execute( - &state.app_ctx, - application::goals::commands::CreateGoalCommand { - user_id: user.0.value(), - year: req.year, - target_count: req.target_count, - }, - ) - .await?; - Ok(Json(goal_with_progress_to_dto(&g))) -} - -#[utoipa::path( - put, path = "/api/v1/goals/{year}", - request_body = UpdateGoalRequest, - responses( - (status = 200, body = GoalDto), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Goal not found"), - ), - security(("bearer_auth" = [])) -)] -pub async fn update_goal( - State(state): State, - user: AuthenticatedUser, - Path(year): Path, - Json(req): Json, -) -> Result, ApiError> { - let g = application::goals::update::execute( - &state.app_ctx, - application::goals::commands::UpdateGoalCommand { - user_id: user.0.value(), - year, - target_count: req.target_count, - }, - ) - .await?; - Ok(Json(goal_with_progress_to_dto(&g))) -} - -#[utoipa::path( - delete, path = "/api/v1/goals/{year}", - responses( - (status = 204, description = "Goal deleted"), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Goal not found"), - ), - security(("bearer_auth" = [])) -)] -pub async fn delete_goal( - State(state): State, - user: AuthenticatedUser, - Path(year): Path, -) -> Result { - application::goals::delete::execute( - &state.app_ctx, - application::goals::commands::DeleteGoalCommand { - user_id: user.0.value(), - year, - }, - ) - .await?; - Ok(StatusCode::NO_CONTENT) -} - -#[utoipa::path( - get, path = "/api/v1/users/{id}/goals", - responses( - (status = 200, body = GoalsResponse), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) -)] -pub async fn get_user_goals( - State(state): State, - AuthenticatedUser(_viewer): AuthenticatedUser, - Path(user_id): Path, -) -> Result, ApiError> { - let goals = application::goals::list::execute( - &state.app_ctx, - application::goals::queries::ListGoalsQuery { user_id }, - ) - .await?; - Ok(Json(GoalsResponse { - goals: goals.iter().map(goal_with_progress_to_dto).collect(), - })) -} - -// ── User Settings ──────────────────────────────────────────────────────────── - -#[utoipa::path( - get, path = "/api/v1/settings", - responses( - (status = 200, body = UserSettingsDto), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) -)] -pub async fn get_settings( - State(state): State, - user: AuthenticatedUser, -) -> Result, ApiError> { - let settings = - application::users::get_settings::execute(&state.app_ctx, user.0.value()).await?; - Ok(Json(UserSettingsDto { - federate_goals: settings.federate_goals(), - })) -} - -#[utoipa::path( - put, path = "/api/v1/settings", - request_body = UpdateUserSettingsRequest, - responses( - (status = 204, description = "Settings updated"), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) -)] -pub async fn update_settings( - State(state): State, - user: AuthenticatedUser, - Json(req): Json, -) -> Result { - application::users::update_settings::execute( - &state.app_ctx, - application::users::update_settings::UpdateUserSettingsCommand { - user_id: user.0.value(), - federate_goals: req.federate_goals, - }, - ) - .await?; - Ok(StatusCode::NO_CONTENT) -} diff --git a/crates/presentation/src/handlers/auth.rs b/crates/presentation/src/handlers/auth.rs new file mode 100644 index 0000000..2266325 --- /dev/null +++ b/crates/presentation/src/handlers/auth.rs @@ -0,0 +1,221 @@ +use axum::{ + Form, Json, + extract::{Extension, Query, State}, + http::{HeaderValue, StatusCode, header::SET_COOKIE}, + response::{IntoResponse, Redirect}, +}; +use chrono::Utc; + +use application::auth::{ + commands::RegisterCommand, login as login_uc, queries::LoginQuery, register as register_uc, +}; + +use crate::{ + csrf::CsrfToken, + errors::ApiError, + forms::{ErrorQuery, LoginForm, RegisterForm}, + render::render_page, + state::AppState, +}; +use api_types::{LoginRequest, LoginResponse, RegisterRequest}; +use application::ports::HtmlPageContext; +use template_askama::{LoginTemplate, RegisterTemplate}; + +// ── HTML helpers ───────────────────────────────────────────────────────────── + +fn secure_flag() -> &'static str { + if std::env::var("SECURE_COOKIES").as_deref() == Ok("true") { + "; Secure" + } else { + "" + } +} + +fn set_cookie_header(token: &str, max_age: i64) -> (axum::http::HeaderName, HeaderValue) { + let val = format!( + "token={}; HttpOnly; Path=/; SameSite=Strict; Max-Age={}{}", + token, + max_age, + secure_flag() + ); + ( + SET_COOKIE, + HeaderValue::from_str(&val).expect("valid cookie"), + ) +} + +// ── API ────────────────────────────────────────────────────────────────────── + +#[utoipa::path( + post, path = "/api/v1/auth/login", + request_body = LoginRequest, + responses( + (status = 200, body = LoginResponse), + (status = 401, description = "Invalid credentials"), + ) +)] +pub async fn login( + State(state): State, + Json(req): Json, +) -> Result, ApiError> { + let result = login_uc::execute( + &state.app_ctx, + LoginQuery { + email: req.email, + password: req.password, + }, + ) + .await?; + Ok(Json(LoginResponse { + token: result.token, + user_id: result.user_id, + email: result.email, + expires_at: result.expires_at.to_rfc3339(), + role: result.role, + })) +} + +#[utoipa::path( + post, path = "/api/v1/auth/register", + request_body = RegisterRequest, + responses( + (status = 201, description = "User registered"), + (status = 400, description = "Invalid input"), + ) +)] +pub async fn register( + State(state): State, + Json(req): Json, +) -> Result { + register_uc::execute( + &state.app_ctx, + RegisterCommand { + email: req.email, + username: req.username, + password: req.password, + role: domain::models::UserRole::Standard, + }, + ) + .await?; + Ok(StatusCode::CREATED) +} + +// ── HTML ───────────────────────────────────────────────────────────────────── + +pub async fn get_login_page( + State(state): State, + Query(params): Query, + Extension(csrf): Extension, +) -> impl IntoResponse { + let ctx = HtmlPageContext { + user_email: None, + user_id: None, + is_admin: false, + register_enabled: state.app_ctx.config.allow_registration, + rss_url: "/feed.rss".to_string(), + page_title: "Login — Movies Diary".to_string(), + canonical_url: format!("{}/login", state.app_ctx.config.base_url), + csrf_token: csrf.0, + page_rss_url: None, + }; + render_page(LoginTemplate { + ctx: &ctx, + error: params.error.as_deref(), + }) +} + +pub async fn post_login( + State(state): State, + Extension(csrf): Extension, + Form(form): Form, +) -> impl IntoResponse { + if crate::csrf::mismatch(&csrf, &form.csrf_token) { + return StatusCode::FORBIDDEN.into_response(); + } + match login_uc::execute( + &state.app_ctx, + LoginQuery { + email: form.email, + password: form.password, + }, + ) + .await + { + Ok(result) => { + let max_age = (result.expires_at - Utc::now()).num_seconds().max(0); + let cookie = set_cookie_header(&result.token, max_age); + ([cookie], Redirect::to("/")).into_response() + } + Err(_) => Redirect::to("/login?error=Invalid+credentials").into_response(), + } +} + +pub async fn get_logout() -> impl IntoResponse { + let val = format!( + "token=; HttpOnly; Path=/; SameSite=Strict; Max-Age=0{}", + secure_flag() + ); + let cookie = ( + SET_COOKIE, + HeaderValue::from_str(&val).expect("valid cookie"), + ); + ([cookie], Redirect::to("/")).into_response() +} + +pub async fn get_register_page( + State(state): State, + Query(params): Query, + Extension(csrf): Extension, +) -> impl IntoResponse { + if !state.app_ctx.config.allow_registration { + return Redirect::to("/").into_response(); + } + let ctx = HtmlPageContext { + user_email: None, + user_id: None, + is_admin: false, + register_enabled: true, + rss_url: "/feed.rss".to_string(), + page_title: "Register — Movies Diary".to_string(), + canonical_url: format!("{}/register", state.app_ctx.config.base_url), + csrf_token: csrf.0, + page_rss_url: None, + }; + render_page(RegisterTemplate { + ctx: &ctx, + error: params.error.as_deref(), + }) + .into_response() +} + +pub async fn post_register( + State(state): State, + Extension(csrf): Extension, + Form(form): Form, +) -> impl IntoResponse { + if !state.app_ctx.config.allow_registration { + return Redirect::to("/").into_response(); + } + if crate::csrf::mismatch(&csrf, &form.csrf_token) { + return StatusCode::FORBIDDEN.into_response(); + } + match application::auth::register_and_login::execute( + &state.app_ctx, + application::auth::commands::RegisterAndLoginCommand { + email: form.email, + username: form.username, + password: form.password, + }, + ) + .await + { + Ok(result) => { + let max_age = (result.expires_at - Utc::now()).num_seconds().max(0); + let cookie = set_cookie_header(&result.token, max_age); + ([cookie], Redirect::to("/")).into_response() + } + Err(_) => { + Redirect::to("/register?error=Registration+failed.+Please+try+again.").into_response() + } + } +} diff --git a/crates/presentation/src/handlers/diary.rs b/crates/presentation/src/handlers/diary.rs new file mode 100644 index 0000000..8854be9 --- /dev/null +++ b/crates/presentation/src/handlers/diary.rs @@ -0,0 +1,362 @@ +use axum::{ + Form, Json, + extract::{Extension, Path, Query, State}, + http::StatusCode, + response::{IntoResponse, Redirect}, +}; +use uuid::Uuid; + +use application::diary::{ + commands::DeleteReviewCommand, + delete_review, export_diary as export_diary_uc, get_activity_feed as get_feed_uc, get_diary, + log_review, + queries::{ExportQuery, GetActivityFeedQuery}, +}; +use domain::models::ExportFormat; + +use crate::{ + csrf::CsrfToken, + errors::ApiError, + extractors::{AuthenticatedUser, OptionalCookieUser, RequiredCookieUser}, + forms::{ErrorQuery, FeedQueryParams, LogReviewData, LogReviewForm, to_diary_query}, + render::render_page, + state::AppState, +}; +use api_types::{ + ActivityFeedQueryParams, ActivityFeedResponse, DiaryQueryParams, DiaryResponse, + ExportQueryParams, LogReviewRequest, +}; +use template_askama::{ActivityFeedTemplate, NewReviewTemplate, build_page_items}; + +use super::helpers::build_page_context; + +fn encode_error(msg: &str) -> String { + use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; + utf8_percent_encode(msg, NON_ALPHANUMERIC).to_string() +} + +// ── API ────────────────────────────────────────────────────────────────────── + +#[utoipa::path( + get, path = "/api/v1/diary", + params(DiaryQueryParams), + responses( + (status = 200, body = DiaryResponse), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_diary( + State(state): State, + Query(params): Query, +) -> Result, ApiError> { + let page = get_diary::execute(&state.app_ctx, to_diary_query(params)).await?; + + Ok(Json(DiaryResponse { + items: page + .items + .iter() + .map(crate::mappers::movies::entry_to_dto) + .collect(), + total_count: page.total_count, + limit: page.limit, + offset: page.offset, + })) +} + +#[utoipa::path( + post, path = "/api/v1/reviews", + request_body = LogReviewRequest, + responses( + (status = 201, description = "Review created"), + (status = 400, description = "Invalid input"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn post_review( + State(state): State, + user: AuthenticatedUser, + Json(req): Json, +) -> Result { + let data = LogReviewData::try_from(req).map_err(ApiError)?; + log_review::execute(&state.app_ctx, data.into_command(user.0.value())).await?; + Ok(StatusCode::CREATED) +} + +#[utoipa::path( + delete, path = "/api/v1/reviews/{id}", + params(("id" = Uuid, Path, description = "Review ID")), + responses( + (status = 204, description = "Review deleted"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Review not found"), + ), + security(("bearer_auth" = [])) +)] +pub async fn delete_review( + State(state): State, + AuthenticatedUser(user_id): AuthenticatedUser, + Path(review_id): Path, +) -> Result { + let cmd = DeleteReviewCommand { + review_id, + requesting_user_id: user_id.value(), + }; + delete_review::execute(&state.app_ctx, cmd).await?; + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path( + get, path = "/api/v1/diary/export", + params(ExportQueryParams), + responses( + (status = 200, description = "Diary file download", content_type = "text/csv"), + (status = 400, description = "Invalid format parameter"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn export_diary( + State(state): State, + user: AuthenticatedUser, + Query(params): Query, +) -> impl IntoResponse { + let format = match params.format.as_str() { + "csv" => ExportFormat::Csv, + "json" => ExportFormat::Json, + _ => return StatusCode::BAD_REQUEST.into_response(), + }; + let (content_type, filename) = match &format { + ExportFormat::Csv => ("text/csv; charset=utf-8", "diary.csv"), + ExportFormat::Json => ("application/json", "diary.json"), + }; + let query = ExportQuery { + user_id: user.0.value(), + format, + }; + match export_diary_uc::execute(&state.app_ctx, query).await { + Ok(bytes) => ( + StatusCode::OK, + [ + (axum::http::header::CONTENT_TYPE, content_type.to_string()), + ( + axum::http::header::CONTENT_DISPOSITION, + format!("attachment; filename=\"{}\"", filename), + ), + ], + bytes, + ) + .into_response(), + Err(e) => { + tracing::error!("export error: {:?}", e); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } +} + +#[utoipa::path( + get, path = "/api/v1/activity-feed", + params(ActivityFeedQueryParams), + responses((status = 200, body = ActivityFeedResponse)), +)] +pub async fn get_activity_feed( + State(state): State, + Query(params): Query, +) -> Result, ApiError> { + let page = get_feed_uc::execute( + &state.app_ctx, + GetActivityFeedQuery { + limit: params.limit.unwrap_or(20), + offset: params.offset.unwrap_or(0), + sort_by: params + .sort_by + .as_deref() + .map(|s| s.parse().unwrap_or_default()) + .unwrap_or_default(), + search: None, + viewer_user_id: None, + filter_following: false, + }, + ) + .await?; + Ok(Json(ActivityFeedResponse { + items: page + .items + .iter() + .map(crate::mappers::diary::feed_entry_to_dto) + .collect(), + total_count: page.total_count, + limit: page.limit, + offset: page.offset, + })) +} + +// ── HTML ───────────────────────────────────────────────────────────────────── + +pub async fn get_new_review_page( + RequiredCookieUser(user_id): RequiredCookieUser, + State(state): State, + Query(params): Query, + Extension(csrf): Extension, +) -> impl IntoResponse { + let mut ctx = build_page_context(&state, Some(user_id), csrf.0).await; + ctx.page_title = "Log a Review — Movies Diary".to_string(); + ctx.canonical_url = format!("{}/reviews/new", state.app_ctx.config.base_url); + render_page(NewReviewTemplate { + ctx: &ctx, + error: params.error.as_deref(), + }) +} + +pub async fn post_review_html( + State(state): State, + RequiredCookieUser(user_id): RequiredCookieUser, + Extension(csrf): Extension, + Form(form): Form, +) -> impl IntoResponse { + if crate::csrf::mismatch(&csrf, &form.csrf_token) { + return StatusCode::FORBIDDEN.into_response(); + } + let data = match LogReviewData::try_from(form) { + Ok(d) => d, + Err(_) => { + return Redirect::to("/reviews/new?error=Invalid+date+format").into_response(); + } + }; + + match log_review::execute(&state.app_ctx, data.into_command(user_id.value())).await { + Ok(_) => Redirect::to("/").into_response(), + Err(e) => { + let msg = encode_error(&e.to_string()); + Redirect::to(&format!("/reviews/new?error={}", msg)).into_response() + } + } +} + +pub async fn post_delete_review_html( + State(state): State, + RequiredCookieUser(user_id): RequiredCookieUser, + Extension(csrf): Extension, + Path(review_id): Path, + Form(form): Form, +) -> impl IntoResponse { + if crate::csrf::mismatch(&csrf, &form.csrf_token) { + return StatusCode::FORBIDDEN.into_response(); + } + let cmd = DeleteReviewCommand { + review_id, + requesting_user_id: user_id.value(), + }; + match delete_review::execute(&state.app_ctx, cmd).await { + Ok(()) => { + let redirect_url = form + .redirect_after + .filter(|url| { + (url.starts_with('/') && !url.starts_with("//")) || url.starts_with('?') + }) + .unwrap_or_else(|| "/".to_string()); + Redirect::to(&redirect_url).into_response() + } + Err(e) => crate::errors::domain_error_response(e), + } +} + +pub async fn get_export_html( + State(state): State, + RequiredCookieUser(user_id): RequiredCookieUser, + Query(params): Query, +) -> impl IntoResponse { + let format = match params.format.as_str() { + "csv" => ExportFormat::Csv, + "json" => ExportFormat::Json, + _ => return StatusCode::BAD_REQUEST.into_response(), + }; + let (content_type, filename) = match &format { + ExportFormat::Csv => ("text/csv; charset=utf-8", "diary.csv"), + ExportFormat::Json => ("application/json", "diary.json"), + }; + let query = ExportQuery { + user_id: user_id.value(), + format, + }; + match export_diary_uc::execute(&state.app_ctx, query).await { + Ok(bytes) => ( + StatusCode::OK, + [ + (axum::http::header::CONTENT_TYPE, content_type.to_string()), + ( + axum::http::header::CONTENT_DISPOSITION, + format!("attachment; filename=\"{}\"", filename), + ), + ], + bytes, + ) + .into_response(), + Err(e) => crate::errors::domain_error_response(e), + } +} + +pub async fn get_activity_feed_html( + OptionalCookieUser(user_id): OptionalCookieUser, + State(state): State, + Query(params): Query, + Extension(csrf): Extension, +) -> impl IntoResponse { + let ctx = build_page_context(&state, user_id.clone(), csrf.0).await; + let limit = params.limit.unwrap_or(20); + let offset = params.offset.unwrap_or(0); + + let filter_following = + cfg!(feature = "federation") && params.filter == "following" && user_id.is_some(); + let filter_str = if filter_following { "following" } else { "all" }; + + let sort_by_str = match params.sort_by.as_str() { + "date_asc" => "date_asc", + "rating" => "rating", + "rating_asc" => "rating_asc", + _ => "date", + }; + + let query = application::diary::queries::GetActivityFeedQuery { + limit, + offset, + sort_by: sort_by_str.parse().unwrap_or_default(), + search: if params.search.is_empty() { + None + } else { + Some(params.search.clone()) + }, + viewer_user_id: user_id.map(|u| u.value()), + filter_following, + }; + + match application::diary::get_activity_feed::execute(&state.app_ctx, query).await { + Ok(entries) => { + let entry_limit = entries.limit; + let entry_offset = entries.offset; + let has_more = + (entry_offset as u64).saturating_add(entry_limit as u64) < entries.total_count; + let total_pages = (entries.total_count as u32) + .saturating_add(entry_limit.saturating_sub(1)) + .checked_div(entry_limit) + .unwrap_or(1); + let current_page = entry_offset.checked_div(entry_limit).unwrap_or(0); + let page_items = build_page_items(total_pages, current_page); + render_page(ActivityFeedTemplate { + entries: entries.items.as_slice(), + current_offset: entry_offset, + limit: entry_limit, + has_more, + ctx: &ctx, + page_items, + filter: filter_str.to_string(), + sort_by: sort_by_str.to_string(), + search: params.search, + }) + .into_response() + } + Err(e) => crate::errors::domain_error_response(e), + } +} diff --git a/crates/presentation/src/handlers/goals.rs b/crates/presentation/src/handlers/goals.rs new file mode 100644 index 0000000..fb8f980 --- /dev/null +++ b/crates/presentation/src/handlers/goals.rs @@ -0,0 +1,199 @@ +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, +}; +use uuid::Uuid; + +use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; +use api_types::{ + CreateGoalRequest, GoalDto, GoalsResponse, UpdateGoalRequest, UpdateUserSettingsRequest, + UserSettingsDto, +}; + +// ── Shared mapper ──────────────────────────────────────────────────────────── + +pub fn goal_with_progress_to_dto(g: &domain::models::GoalWithProgress) -> GoalDto { + GoalDto { + year: g.goal.year(), + target_count: g.goal.target_count(), + current_count: g.current_count, + percentage: g.percentage(), + is_complete: g.is_complete(), + goal_type: g.goal.goal_type().as_str().to_string(), + } +} + +// ── Goals API ──────────────────────────────────────────────────────────────── + +#[utoipa::path( + get, path = "/api/v1/goals", + responses( + (status = 200, body = GoalsResponse), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn list_goals( + State(state): State, + user: AuthenticatedUser, +) -> Result, ApiError> { + let goals = application::goals::list::execute( + &state.app_ctx, + application::goals::queries::ListGoalsQuery { + user_id: user.0.value(), + }, + ) + .await?; + Ok(Json(GoalsResponse { + goals: goals.iter().map(goal_with_progress_to_dto).collect(), + })) +} + +#[utoipa::path( + post, path = "/api/v1/goals", + request_body = CreateGoalRequest, + responses( + (status = 200, body = GoalDto), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn create_goal( + State(state): State, + user: AuthenticatedUser, + Json(req): Json, +) -> Result, ApiError> { + let g = application::goals::create::execute( + &state.app_ctx, + application::goals::commands::CreateGoalCommand { + user_id: user.0.value(), + year: req.year, + target_count: req.target_count, + }, + ) + .await?; + Ok(Json(goal_with_progress_to_dto(&g))) +} + +#[utoipa::path( + put, path = "/api/v1/goals/{year}", + request_body = UpdateGoalRequest, + responses( + (status = 200, body = GoalDto), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Goal not found"), + ), + security(("bearer_auth" = [])) +)] +pub async fn update_goal( + State(state): State, + user: AuthenticatedUser, + Path(year): Path, + Json(req): Json, +) -> Result, ApiError> { + let g = application::goals::update::execute( + &state.app_ctx, + application::goals::commands::UpdateGoalCommand { + user_id: user.0.value(), + year, + target_count: req.target_count, + }, + ) + .await?; + Ok(Json(goal_with_progress_to_dto(&g))) +} + +#[utoipa::path( + delete, path = "/api/v1/goals/{year}", + responses( + (status = 204, description = "Goal deleted"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Goal not found"), + ), + security(("bearer_auth" = [])) +)] +pub async fn delete_goal( + State(state): State, + user: AuthenticatedUser, + Path(year): Path, +) -> Result { + application::goals::delete::execute( + &state.app_ctx, + application::goals::commands::DeleteGoalCommand { + user_id: user.0.value(), + year, + }, + ) + .await?; + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path( + get, path = "/api/v1/users/{id}/goals", + responses( + (status = 200, body = GoalsResponse), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_user_goals( + State(state): State, + AuthenticatedUser(_viewer): AuthenticatedUser, + Path(user_id): Path, +) -> Result, ApiError> { + let goals = application::goals::list::execute( + &state.app_ctx, + application::goals::queries::ListGoalsQuery { user_id }, + ) + .await?; + Ok(Json(GoalsResponse { + goals: goals.iter().map(goal_with_progress_to_dto).collect(), + })) +} + +// ── User Settings ──────────────────────────────────────────────────────────── + +#[utoipa::path( + get, path = "/api/v1/settings", + responses( + (status = 200, body = UserSettingsDto), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_settings( + State(state): State, + user: AuthenticatedUser, +) -> Result, ApiError> { + let settings = + application::users::get_settings::execute(&state.app_ctx, user.0.value()).await?; + Ok(Json(UserSettingsDto { + federate_goals: settings.federate_goals(), + })) +} + +#[utoipa::path( + put, path = "/api/v1/settings", + request_body = UpdateUserSettingsRequest, + responses( + (status = 204, description = "Settings updated"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn update_settings( + State(state): State, + user: AuthenticatedUser, + Json(req): Json, +) -> Result { + application::users::update_settings::execute( + &state.app_ctx, + application::users::update_settings::UpdateUserSettingsCommand { + user_id: user.0.value(), + federate_goals: req.federate_goals, + }, + ) + .await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/crates/presentation/src/handlers/helpers.rs b/crates/presentation/src/handlers/helpers.rs new file mode 100644 index 0000000..e92e92b --- /dev/null +++ b/crates/presentation/src/handlers/helpers.rs @@ -0,0 +1,34 @@ +use application::ports::HtmlPageContext; +use domain::value_objects::UserId; + +use crate::state::AppState; + +pub(crate) async fn build_page_context( + state: &AppState, + user_id: Option, + csrf_token: String, +) -> HtmlPageContext { + let uuid = user_id.as_ref().map(|u| u.value()); + let (user_email, is_admin) = if let Some(ref id) = user_id { + let user = state.app_ctx.repos.user.find_by_id(id).await.ok().flatten(); + let email = user.as_ref().map(|u| u.email().value().to_string()); + let admin = user + .as_ref() + .map(|u| matches!(u.role(), domain::models::UserRole::Admin)) + .unwrap_or(false); + (email, admin) + } else { + (None, false) + }; + HtmlPageContext { + user_email, + user_id: uuid, + is_admin, + register_enabled: state.app_ctx.config.allow_registration, + rss_url: "/feed.rss".to_string(), + page_title: "Movies Diary".to_string(), + canonical_url: state.app_ctx.config.base_url.clone(), + csrf_token, + page_rss_url: None, + } +} diff --git a/crates/presentation/src/handlers/html.rs b/crates/presentation/src/handlers/html.rs deleted file mode 100644 index 728862c..0000000 --- a/crates/presentation/src/handlers/html.rs +++ /dev/null @@ -1,1735 +0,0 @@ -use std::str::FromStr; - -use axum::{ - Form, - extract::{Extension, Multipart, Path, Query, State}, - http::{HeaderValue, StatusCode, header::SET_COOKIE}, - response::{IntoResponse, Redirect}, -}; -use chrono::Utc; -use uuid::Uuid; - -use application::{ - auth::{login as login_uc, queries::LoginQuery}, - diary::{ - commands::{DeleteReviewCommand, MovieInput}, - delete_review, export_diary as export_diary_uc, get_movie_social_page, log_review, - queries::{ExportQuery, GetMovieSocialPageQuery}, - }, - integrations::{ - commands::{ - ConfirmWatchEventsCommand, DismissWatchEventsCommand, GenerateWebhookTokenCommand, - RevokeWebhookTokenCommand, WatchEventConfirmation, - }, - confirm as confirm_watch_events, dismiss as dismiss_watch_events, - generate_token as generate_webhook_token, get_queue as get_watch_queue, - get_tokens as get_webhook_tokens, - queries::{GetWatchQueueQuery, GetWebhookTokensQuery}, - revoke_token as revoke_webhook_token, - }, - users::{update_profile, update_profile_fields}, - watchlist::{ - add as add_to_watchlist, - commands::{AddToWatchlistCommand, RemoveFromWatchlistCommand}, - is_on as is_on_watchlist, - queries::IsOnWatchlistQuery, - remove as remove_from_watchlist, - }, -}; - -use crate::render::render_page; -use application::ports::HtmlPageContext; -use domain::models::ExportFormat; -use domain::{errors::DomainError, value_objects::UserId}; -use template_askama::{ - ActivityFeedTemplate, EmbedProfileTemplate, IntegrationsTemplate, LoginTemplate, - MonthlyRatingRow, MovieDetailTemplate, NewReviewTemplate, ProfileSettingsTemplate, - ProfileTemplate, RegisterTemplate, RemoteActorData, RemoteActorDisplay, UserSummaryView, - UsersTemplate, WatchQueueTemplate, WatchlistTemplate, bar_height_px, build_heatmap, - build_page_items, -}; -#[cfg(feature = "federation")] -use template_askama::{ - BlockedActorsTemplate, BlockedDomainsTemplate, FollowersTemplate, FollowingTemplate, -}; - -#[cfg(feature = "federation")] -use crate::forms::{ - ActorUrlForm, BlockDomainForm, FollowForm, FollowerActionForm, RemoveDomainForm, UnfollowForm, -}; -use crate::{ - csrf::CsrfToken, - extractors::{AdminUser, OptionalCookieUser, RequiredCookieUser}, - forms::{ErrorQuery, FeedQueryParams, LogReviewData, LogReviewForm, LoginForm, RegisterForm}, - state::AppState, -}; - -pub(crate) async fn build_page_context( - state: &AppState, - user_id: Option, - csrf_token: String, -) -> HtmlPageContext { - let uuid = user_id.as_ref().map(|u| u.value()); - let (user_email, is_admin) = if let Some(ref id) = user_id { - let user = state.app_ctx.repos.user.find_by_id(id).await.ok().flatten(); - let email = user.as_ref().map(|u| u.email().value().to_string()); - let admin = user - .as_ref() - .map(|u| matches!(u.role(), domain::models::UserRole::Admin)) - .unwrap_or(false); - (email, admin) - } else { - (None, false) - }; - HtmlPageContext { - user_email, - user_id: uuid, - is_admin, - register_enabled: state.app_ctx.config.allow_registration, - rss_url: "/feed.rss".to_string(), - page_title: "Movies Diary".to_string(), - canonical_url: state.app_ctx.config.base_url.clone(), - csrf_token, - page_rss_url: None, - } -} - -fn encode_error(msg: &str) -> String { - use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; - utf8_percent_encode(msg, NON_ALPHANUMERIC).to_string() -} - -fn secure_flag() -> &'static str { - if std::env::var("SECURE_COOKIES").as_deref() == Ok("true") { - "; Secure" - } else { - "" - } -} - -fn set_cookie_header(token: &str, max_age: i64) -> (axum::http::HeaderName, HeaderValue) { - let val = format!( - "token={}; HttpOnly; Path=/; SameSite=Strict; Max-Age={}{}", - token, - max_age, - secure_flag() - ); - ( - SET_COOKIE, - HeaderValue::from_str(&val).expect("valid cookie"), - ) -} - -pub async fn get_login_page( - State(state): State, - Query(params): Query, - Extension(csrf): Extension, -) -> impl IntoResponse { - let ctx = HtmlPageContext { - user_email: None, - user_id: None, - is_admin: false, - register_enabled: state.app_ctx.config.allow_registration, - rss_url: "/feed.rss".to_string(), - page_title: "Login — Movies Diary".to_string(), - canonical_url: format!("{}/login", state.app_ctx.config.base_url), - csrf_token: csrf.0, - page_rss_url: None, - }; - render_page(LoginTemplate { - ctx: &ctx, - error: params.error.as_deref(), - }) -} - -pub async fn post_login( - State(state): State, - Extension(csrf): Extension, - Form(form): Form, -) -> impl IntoResponse { - if crate::csrf::mismatch(&csrf, &form.csrf_token) { - return StatusCode::FORBIDDEN.into_response(); - } - match login_uc::execute( - &state.app_ctx, - LoginQuery { - email: form.email, - password: form.password, - }, - ) - .await - { - Ok(result) => { - let max_age = (result.expires_at - Utc::now()).num_seconds().max(0); - let cookie = set_cookie_header(&result.token, max_age); - ([cookie], Redirect::to("/")).into_response() - } - Err(_) => Redirect::to("/login?error=Invalid+credentials").into_response(), - } -} - -pub async fn get_logout() -> impl IntoResponse { - let val = format!( - "token=; HttpOnly; Path=/; SameSite=Strict; Max-Age=0{}", - secure_flag() - ); - let cookie = ( - SET_COOKIE, - HeaderValue::from_str(&val).expect("valid cookie"), - ); - ([cookie], Redirect::to("/")).into_response() -} - -pub async fn get_register_page( - State(state): State, - Query(params): Query, - Extension(csrf): Extension, -) -> impl IntoResponse { - if !state.app_ctx.config.allow_registration { - return Redirect::to("/").into_response(); - } - let ctx = HtmlPageContext { - user_email: None, - user_id: None, - is_admin: false, - register_enabled: true, - rss_url: "/feed.rss".to_string(), - page_title: "Register — Movies Diary".to_string(), - canonical_url: format!("{}/register", state.app_ctx.config.base_url), - csrf_token: csrf.0, - page_rss_url: None, - }; - render_page(RegisterTemplate { - ctx: &ctx, - error: params.error.as_deref(), - }) - .into_response() -} - -pub async fn post_register( - State(state): State, - Extension(csrf): Extension, - Form(form): Form, -) -> impl IntoResponse { - if !state.app_ctx.config.allow_registration { - return Redirect::to("/").into_response(); - } - if crate::csrf::mismatch(&csrf, &form.csrf_token) { - return StatusCode::FORBIDDEN.into_response(); - } - match application::auth::register_and_login::execute( - &state.app_ctx, - application::auth::commands::RegisterAndLoginCommand { - email: form.email, - username: form.username, - password: form.password, - }, - ) - .await - { - Ok(result) => { - let max_age = (result.expires_at - Utc::now()).num_seconds().max(0); - let cookie = set_cookie_header(&result.token, max_age); - ([cookie], Redirect::to("/")).into_response() - } - Err(_) => { - Redirect::to("/register?error=Registration+failed.+Please+try+again.").into_response() - } - } -} - -pub async fn get_new_review_page( - RequiredCookieUser(user_id): RequiredCookieUser, - State(state): State, - Query(params): Query, - Extension(csrf): Extension, -) -> impl IntoResponse { - let mut ctx = build_page_context(&state, Some(user_id), csrf.0).await; - ctx.page_title = "Log a Review — Movies Diary".to_string(); - ctx.canonical_url = format!("{}/reviews/new", state.app_ctx.config.base_url); - render_page(NewReviewTemplate { - ctx: &ctx, - error: params.error.as_deref(), - }) -} - -pub async fn post_review( - State(state): State, - RequiredCookieUser(user_id): RequiredCookieUser, - Extension(csrf): Extension, - Form(form): Form, -) -> impl IntoResponse { - if crate::csrf::mismatch(&csrf, &form.csrf_token) { - return StatusCode::FORBIDDEN.into_response(); - } - let data = match LogReviewData::try_from(form) { - Ok(d) => d, - Err(_) => { - return Redirect::to("/reviews/new?error=Invalid+date+format").into_response(); - } - }; - - match log_review::execute(&state.app_ctx, data.into_command(user_id.value())).await { - Ok(_) => Redirect::to("/").into_response(), - Err(e) => { - let msg = encode_error(&e.to_string()); - Redirect::to(&format!("/reviews/new?error={}", msg)).into_response() - } - } -} - -pub async fn post_delete_review( - State(state): State, - RequiredCookieUser(user_id): RequiredCookieUser, - Extension(csrf): Extension, - Path(review_id): Path, - Form(form): Form, -) -> impl IntoResponse { - if crate::csrf::mismatch(&csrf, &form.csrf_token) { - return StatusCode::FORBIDDEN.into_response(); - } - let cmd = DeleteReviewCommand { - review_id, - requesting_user_id: user_id.value(), - }; - match delete_review::execute(&state.app_ctx, cmd).await { - Ok(()) => { - let redirect_url = form - .redirect_after - .filter(|url| { - (url.starts_with('/') && !url.starts_with("//")) || url.starts_with('?') - }) - .unwrap_or_else(|| "/".to_string()); - Redirect::to(&redirect_url).into_response() - } - Err(e) => crate::errors::domain_error_response(e), - } -} - -pub async fn get_export( - State(state): State, - RequiredCookieUser(user_id): RequiredCookieUser, - Query(params): Query, -) -> impl IntoResponse { - let format = match params.format.as_str() { - "csv" => ExportFormat::Csv, - "json" => ExportFormat::Json, - _ => return StatusCode::BAD_REQUEST.into_response(), - }; - let (content_type, filename) = match &format { - ExportFormat::Csv => ("text/csv; charset=utf-8", "diary.csv"), - ExportFormat::Json => ("application/json", "diary.json"), - }; - let query = ExportQuery { - user_id: user_id.value(), - format, - }; - match export_diary_uc::execute(&state.app_ctx, query).await { - Ok(bytes) => ( - StatusCode::OK, - [ - (axum::http::header::CONTENT_TYPE, content_type.to_string()), - ( - axum::http::header::CONTENT_DISPOSITION, - format!("attachment; filename=\"{}\"", filename), - ), - ], - bytes, - ) - .into_response(), - Err(e) => crate::errors::domain_error_response(e), - } -} - -pub async fn get_activity_feed( - OptionalCookieUser(user_id): OptionalCookieUser, - State(state): State, - Query(params): Query, - Extension(csrf): Extension, -) -> impl IntoResponse { - let ctx = build_page_context(&state, user_id.clone(), csrf.0).await; - let limit = params.limit.unwrap_or(20); - let offset = params.offset.unwrap_or(0); - - let filter_following = - cfg!(feature = "federation") && params.filter == "following" && user_id.is_some(); - let filter_str = if filter_following { "following" } else { "all" }; - - let sort_by_str = match params.sort_by.as_str() { - "date_asc" => "date_asc", - "rating" => "rating", - "rating_asc" => "rating_asc", - _ => "date", - }; - - let query = application::diary::queries::GetActivityFeedQuery { - limit, - offset, - sort_by: sort_by_str.parse().unwrap_or_default(), - search: if params.search.is_empty() { - None - } else { - Some(params.search.clone()) - }, - viewer_user_id: user_id.map(|u| u.value()), - filter_following, - }; - - match application::diary::get_activity_feed::execute(&state.app_ctx, query).await { - Ok(entries) => { - let entry_limit = entries.limit; - let entry_offset = entries.offset; - let has_more = - (entry_offset as u64).saturating_add(entry_limit as u64) < entries.total_count; - let total_pages = (entries.total_count as u32) - .saturating_add(entry_limit.saturating_sub(1)) - .checked_div(entry_limit) - .unwrap_or(1); - let current_page = entry_offset.checked_div(entry_limit).unwrap_or(0); - let page_items = build_page_items(total_pages, current_page); - render_page(ActivityFeedTemplate { - entries: entries.items.as_slice(), - current_offset: entry_offset, - limit: entry_limit, - has_more, - ctx: &ctx, - page_items, - filter: filter_str.to_string(), - sort_by: sort_by_str.to_string(), - search: params.search, - }) - .into_response() - } - Err(e) => crate::errors::domain_error_response(e), - } -} - -pub async fn get_users_list( - OptionalCookieUser(user_id): OptionalCookieUser, - State(state): State, - Extension(csrf): Extension, -) -> impl IntoResponse { - let mut ctx = build_page_context(&state, user_id, csrf.0).await; - ctx.page_title = "Members — Movies Diary".to_string(); - ctx.canonical_url = format!("{}/users", state.app_ctx.config.base_url); - - match application::users::get_users::execute( - &state.app_ctx, - application::users::queries::GetUsersQuery, - ) - .await - { - Ok(result) => { - let users: Vec = result - .users - .iter() - .map(crate::mappers::users::user_summary_view) - .collect(); - let remote_actors: Vec = result - .remote_actors - .iter() - .map(crate::mappers::users::remote_actor_display) - .collect(); - render_page(UsersTemplate { - users, - ctx: &ctx, - remote_actors, - }) - .into_response() - } - Err(e) => crate::errors::domain_error_response(e), - } -} - -pub async fn get_user_by_username( - State(state): State, - Path(username): Path, -) -> impl IntoResponse { - let uname = match domain::value_objects::Username::new(username) { - Ok(u) => u, - Err(_) => return StatusCode::NOT_FOUND.into_response(), - }; - match state.app_ctx.repos.user.find_by_username(&uname).await { - Ok(Some(user)) => { - axum::response::Redirect::permanent(&format!("/users/{}", user.id().value())) - .into_response() - } - _ => StatusCode::NOT_FOUND.into_response(), - } -} - -pub async fn get_user_profile( - OptionalCookieUser(user_id): OptionalCookieUser, - State(state): State, - Path(profile_user_uuid): Path, - headers: axum::http::HeaderMap, - Query(params): Query, - Extension(csrf): Extension, -) -> impl IntoResponse { - // Content negotiation: AP clients request application/activity+json - #[cfg(feature = "federation")] - { - let accept = headers - .get(axum::http::header::ACCEPT) - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - if accept.contains("application/activity+json") || accept.contains("application/ld+json") { - return match state - .ap_service - .actor_json(&profile_user_uuid.to_string()) - .await - { - Ok(json) => ( - [( - axum::http::header::CONTENT_TYPE, - "application/activity+json", - )], - json, - ) - .into_response(), - Err(_) => StatusCode::NOT_FOUND.into_response(), - }; - } - } - - let mut ctx = build_page_context(&state, user_id.clone(), csrf.0).await; - let view_str = params.view.as_deref().unwrap_or("recent"); - let profile_view = match application::users::queries::ProfileView::from_str(view_str) { - Ok(v) => v, - Err(_) => { - return ( - axum::http::StatusCode::BAD_REQUEST, - "invalid view parameter", - ) - .into_response(); - } - }; - - let profile_user = match state - .app_ctx - .repos - .user - .find_by_id(&domain::value_objects::UserId::from_uuid(profile_user_uuid)) - .await - { - Ok(Some(u)) => u, - Ok(None) => return StatusCode::NOT_FOUND.into_response(), - Err(e) => return crate::errors::domain_error_response(e), - }; - - let display_name = profile_user.username().value(); - ctx.page_title = format!("{}'s Diary — Movies Diary", display_name); - ctx.canonical_url = format!( - "{}/users/{}", - state.app_ctx.config.base_url, profile_user_uuid - ); - - let sort_by_str = match params.sort_by.as_str() { - "date_asc" => "date_asc", - "rating" => "rating", - "rating_asc" => "rating_asc", - _ => "date", - }; - - let is_own_profile = user_id - .as_ref() - .map(|u| u.value() == profile_user_uuid) - .unwrap_or(false); - - let query = application::users::queries::GetUserProfileQuery { - user_id: profile_user_uuid, - view: profile_view, - limit: params.limit, - offset: params.offset, - sort_by: sort_by_str.parse().unwrap_or_default(), - search: if params.search.is_empty() { - None - } else { - Some(params.search.clone()) - }, - is_own_profile, - }; - - match application::users::get_profile::execute(&state.app_ctx, query).await { - Ok(profile) => { - let (offset, has_more, limit) = profile - .entries - .as_ref() - .map(|e| { - let has_more = (e.offset as u64).saturating_add(e.limit as u64) < e.total_count; - (e.offset, has_more, e.limit) - }) - .unwrap_or((0, false, super::DEFAULT_PAGE_LIMIT)); - if !is_own_profile { - ctx.page_rss_url = Some(format!("/users/{}/feed.rss", profile_user_uuid)); - } - let email = profile_user.email().value().to_string(); - let display_name = email.split('@').next().unwrap_or("?").to_string(); - let avg_rating_display = profile - .stats - .avg_rating - .map(|r| format!("{:.1}", r)) - .unwrap_or_else(|| "—".to_string()); - let favorite_director_display = profile - .stats - .favorite_director - .clone() - .unwrap_or_else(|| "—".to_string()); - let most_active_month_display = profile - .stats - .most_active_month - .clone() - .unwrap_or_else(|| "—".to_string()); - let heatmap = profile - .history - .as_deref() - .map(build_heatmap) - .unwrap_or_default(); - let monthly_rating_rows: Vec> = profile - .trends - .as_ref() - .map(|t| { - t.monthly_ratings - .iter() - .map(|r| MonthlyRatingRow { - rating: r, - bar_height_px: bar_height_px(r.avg_rating), - }) - .collect() - }) - .unwrap_or_default(); - let total = profile - .entries - .as_ref() - .map(|e| e.total_count as u32) - .unwrap_or(0); - let total_pages = total - .saturating_add(limit.saturating_sub(1)) - .checked_div(limit) - .unwrap_or(1); - let current_page = offset.checked_div(limit).unwrap_or(0); - let page_items = build_page_items(total_pages, current_page); - let pending_followers: Vec = profile - .pending_followers - .iter() - .map(crate::mappers::users::pending_follower_data) - .collect(); - if params.embed { - let profile_url = format!( - "{}/users/{}", - state.app_ctx.config.base_url, profile_user_uuid - ); - let response = render_page(EmbedProfileTemplate { - profile_display_name: display_name, - profile_user_id: profile_user_uuid, - profile_url, - stats: &profile.stats, - avg_rating_display, - favorite_director_display, - most_active_month_display, - view: profile_view.as_str(), - entries: profile.entries.as_ref(), - current_offset: offset, - has_more, - limit, - history: profile.history.as_ref(), - trends: profile.trends.as_ref(), - monthly_rating_rows, - heatmap, - page_items, - sort_by: sort_by_str.to_string(), - }); - let mut resp = response.into_response(); - resp.headers_mut().remove("x-frame-options"); - resp - } else { - render_page(ProfileTemplate { - ctx: &ctx, - profile_display_name: display_name, - profile_user_id: profile_user_uuid, - stats: &profile.stats, - avg_rating_display, - favorite_director_display, - most_active_month_display, - view: profile_view.as_str(), - entries: profile.entries.as_ref(), - current_offset: offset, - has_more, - limit, - history: profile.history.as_ref(), - trends: profile.trends.as_ref(), - monthly_rating_rows, - heatmap, - page_items, - is_own_profile, - error: params.error, - following_count: profile.following_count, - followers_count: profile.followers_count, - pending_followers, - sort_by: sort_by_str.to_string(), - search: params.search.clone(), - goals: { - let goals_list = application::goals::list::execute( - &state.app_ctx, - application::goals::queries::ListGoalsQuery { - user_id: profile_user_uuid, - }, - ) - .await - .unwrap_or_default(); - goals_list - .iter() - .map(|g| template_askama::GoalViewData { - year: g.goal.year(), - target_count: g.goal.target_count(), - current_count: g.current_count, - percentage: g.percentage().round(), - is_complete: g.is_complete(), - }) - .collect() - }, - }) - .into_response() - } - } - Err(e) => crate::errors::domain_error_response(e), - } -} - -#[cfg(feature = "federation")] -pub async fn follow_remote_user( - RequiredCookieUser(user_id): RequiredCookieUser, - State(state): State, - Path(profile_user_uuid): Path, - Extension(csrf): Extension, - Form(form): Form, -) -> impl IntoResponse { - if user_id.value() != profile_user_uuid { - return StatusCode::FORBIDDEN.into_response(); - } - if crate::csrf::mismatch(&csrf, &form.csrf_token) { - return StatusCode::FORBIDDEN.into_response(); - } - let redirect_base = form - .redirect_after - .as_deref() - .filter(|u| u.starts_with('/') && !u.starts_with("//")) - .unwrap_or(&format!("/users/{}", profile_user_uuid)) - .to_string(); - - match state.ap_service.follow(user_id.value(), &form.handle).await { - Ok(()) => Redirect::to(&redirect_base).into_response(), - Err(e) => { - tracing::error!("follow error: {:?}", e); - let msg = encode_error(&e.to_string()); - let sep = if redirect_base.contains('?') { - '&' - } else { - '?' - }; - Redirect::to(&format!("{}{}error={}", redirect_base, sep, msg)).into_response() - } - } -} - -#[cfg(feature = "federation")] -pub async fn unfollow_remote_user( - RequiredCookieUser(user_id): RequiredCookieUser, - State(state): State, - Path(profile_user_uuid): Path, - Extension(csrf): Extension, - Form(form): Form, -) -> impl IntoResponse { - if user_id.value() != profile_user_uuid { - return StatusCode::FORBIDDEN.into_response(); - } - 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 - { - Ok(()) => { - Redirect::to(&format!("/users/{}/following-list", profile_user_uuid)).into_response() - } - Err(e) => { - let msg = encode_error(&e.to_string()); - Redirect::to(&format!( - "/users/{}/following-list?error={}", - profile_user_uuid, msg - )) - .into_response() - } - } -} - -#[cfg(feature = "federation")] -pub async fn accept_follower( - RequiredCookieUser(user_id): RequiredCookieUser, - State(state): State, - Path(profile_user_uuid): Path, - Extension(csrf): Extension, - Form(form): Form, -) -> impl IntoResponse { - if user_id.value() != profile_user_uuid { - return StatusCode::FORBIDDEN.into_response(); - } - 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 - { - Ok(_) => Redirect::to(&format!("/users/{}", profile_user_uuid)).into_response(), - Err(e) => { - let msg = encode_error(&e.to_string()); - Redirect::to(&format!("/users/{}?error={}", profile_user_uuid, msg)).into_response() - } - } -} - -#[cfg(feature = "federation")] -pub async fn reject_follower( - RequiredCookieUser(user_id): RequiredCookieUser, - State(state): State, - Path(profile_user_uuid): Path, - Extension(csrf): Extension, - Form(form): Form, -) -> impl IntoResponse { - if user_id.value() != profile_user_uuid { - return StatusCode::FORBIDDEN.into_response(); - } - 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 - { - Ok(_) => Redirect::to(&format!("/users/{}", profile_user_uuid)).into_response(), - Err(e) => { - let msg = encode_error(&e.to_string()); - Redirect::to(&format!("/users/{}?error={}", profile_user_uuid, msg)).into_response() - } - } -} - -#[cfg(feature = "federation")] -pub async fn get_followers_collection( - State(state): State, - Path(user_id): Path, - headers: axum::http::HeaderMap, - Query(params): Query>, -) -> impl IntoResponse { - let accept = headers - .get(axum::http::header::ACCEPT) - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - if accept.contains("application/activity+json") || accept.contains("application/ld+json") { - let page = params.get("page").and_then(|p| p.parse::().ok()); - return match state - .ap_service - .followers_collection_json(user_id, page) - .await - { - Ok(json) => ( - [( - axum::http::header::CONTENT_TYPE, - "application/activity+json", - )], - json, - ) - .into_response(), - Err(_) => StatusCode::NOT_FOUND.into_response(), - }; - } - axum::response::Redirect::to(&format!("/users/{}/followers-list", user_id)).into_response() -} - -#[cfg(feature = "federation")] -pub async fn get_following_collection( - State(state): State, - Path(user_id): Path, - headers: axum::http::HeaderMap, - Query(params): Query>, -) -> impl IntoResponse { - let accept = headers - .get(axum::http::header::ACCEPT) - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - if accept.contains("application/activity+json") || accept.contains("application/ld+json") { - let page = params.get("page").and_then(|p| p.parse::().ok()); - return match state - .ap_service - .following_collection_json(user_id, page) - .await - { - Ok(json) => ( - [( - axum::http::header::CONTENT_TYPE, - "application/activity+json", - )], - json, - ) - .into_response(), - Err(_) => StatusCode::NOT_FOUND.into_response(), - }; - } - axum::response::Redirect::to(&format!("/users/{}/following-list", user_id)).into_response() -} - -#[cfg(feature = "federation")] -pub async fn get_following_page( - RequiredCookieUser(user_id): RequiredCookieUser, - State(state): State, - Path(profile_user_uuid): Path, - Query(params): Query, - Extension(csrf): Extension, -) -> impl IntoResponse { - if user_id.value() != profile_user_uuid { - return StatusCode::FORBIDDEN.into_response(); - } - let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await; - ctx.page_title = "Following — Movies Diary".to_string(); - ctx.canonical_url = format!( - "{}/users/{}/following-list", - state.app_ctx.config.base_url, profile_user_uuid - ); - match state.ap_service.get_following(user_id.value()).await { - Ok(following) => { - let actors: Vec = following - .into_iter() - .map(|a| RemoteActorData { - handle: a.handle, - display_name: a.display_name, - url: a.url, - avatar_url: a.avatar_url.clone(), - }) - .collect(); - render_page(FollowingTemplate { - ctx, - user_id: profile_user_uuid, - actors, - error: params.error, - }) - .into_response() - } - Err(e) => { - tracing::error!("get_following error: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - "Failed to load following list", - ) - .into_response() - } - } -} - -#[cfg(feature = "federation")] -pub async fn get_followers_page( - RequiredCookieUser(user_id): RequiredCookieUser, - State(state): State, - Path(profile_user_uuid): Path, - Query(params): Query, - Extension(csrf): Extension, -) -> impl IntoResponse { - if user_id.value() != profile_user_uuid { - return StatusCode::FORBIDDEN.into_response(); - } - let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await; - ctx.page_title = "Followers — Movies Diary".to_string(); - ctx.canonical_url = format!( - "{}/users/{}/followers-list", - state.app_ctx.config.base_url, profile_user_uuid - ); - match state - .ap_service - .get_accepted_followers(user_id.value()) - .await - { - Ok(followers) => { - let actors: Vec = followers - .into_iter() - .map(|a| RemoteActorData { - handle: a.handle, - display_name: a.display_name, - url: a.url, - avatar_url: a.avatar_url.clone(), - }) - .collect(); - render_page(FollowersTemplate { - ctx, - user_id: profile_user_uuid, - actors, - error: params.error, - }) - .into_response() - } - Err(e) => { - tracing::error!("get_followers error: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - "Failed to load followers list", - ) - .into_response() - } - } -} - -#[cfg(feature = "federation")] -pub async fn remove_follower( - RequiredCookieUser(user_id): RequiredCookieUser, - State(state): State, - Path(profile_user_uuid): Path, - Extension(csrf): Extension, - Form(form): Form, -) -> impl IntoResponse { - if user_id.value() != profile_user_uuid { - return StatusCode::FORBIDDEN.into_response(); - } - 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 - { - Ok(_) => { - Redirect::to(&format!("/users/{}/followers-list", profile_user_uuid)).into_response() - } - Err(e) => { - let msg = encode_error(&e.to_string()); - Redirect::to(&format!( - "/users/{}/followers-list?error={}", - profile_user_uuid, msg - )) - .into_response() - } - } -} - -pub async fn get_movie_detail( - OptionalCookieUser(user_id): OptionalCookieUser, - State(state): State, - Path(movie_id): Path, - Query(params): Query, - Extension(csrf): Extension, -) -> impl IntoResponse { - let ctx = build_page_context(&state, user_id.clone(), csrf.0).await; - let limit = params.limit.unwrap_or(20); - let offset = params.offset.unwrap_or(0); - - match get_movie_social_page::execute( - &state.app_ctx, - GetMovieSocialPageQuery { - movie_id, - limit, - offset, - }, - ) - .await - { - Err(e) => crate::errors::domain_error_response(e), - Ok(result) => { - let histogram_max = result - .stats - .rating_histogram - .iter() - .copied() - .max() - .unwrap_or(1); - let has_more = - result.reviews.offset + result.reviews.limit < result.reviews.total_count as u32; - let on_watchlist = match &user_id { - Some(uid) => is_on_watchlist::execute( - &state.app_ctx, - IsOnWatchlistQuery { - user_id: uid.value(), - movie_id, - }, - ) - .await - .unwrap_or(false), - None => false, - }; - let current_offset = result.reviews.offset; - let reviews_limit = result.reviews.limit; - render_page(MovieDetailTemplate { - ctx: &ctx, - movie: &result.movie, - stats: &result.stats, - profile: result.profile.as_ref(), - reviews: result.reviews.items.as_slice(), - on_watchlist, - current_offset, - has_more, - limit: reviews_limit, - histogram_max, - }) - .into_response() - } - } -} - -pub async fn get_watchlist_page( - OptionalCookieUser(viewer_id): OptionalCookieUser, - State(state): State, - Path(owner_id): Path, - Query(params): Query, - Extension(csrf): Extension, -) -> impl IntoResponse { - let ctx = build_page_context(&state, viewer_id.clone(), csrf.0).await; - let is_owner = viewer_id.map(|u| u.value() == owner_id).unwrap_or(false); - - let result = match application::watchlist::get_page::execute( - &state.app_ctx, - application::watchlist::queries::GetWatchlistQuery { - user_id: owner_id, - limit: params.limit.or(Some(20)), - offset: params.offset.or(Some(0)), - }, - is_owner, - ) - .await - { - Ok(r) => r, - Err(e) => return crate::errors::domain_error_response(e), - }; - - render_page(WatchlistTemplate { - ctx: &ctx, - owner_id, - display_entries: &result.display_entries, - current_offset: result.current_offset, - has_more: result.has_more, - limit: result.limit, - is_owner, - error: params.error, - }) - .into_response() -} - -pub async fn post_watchlist_add( - State(state): State, - RequiredCookieUser(user_id): RequiredCookieUser, - Extension(csrf): Extension, - Form(form): Form, -) -> impl IntoResponse { - if crate::csrf::mismatch(&csrf, &form.csrf_token) { - return StatusCode::FORBIDDEN.into_response(); - } - - let redirect_base = form - .redirect_after - .as_deref() - .filter(|u| u.starts_with('/') && !u.starts_with("//")) - .unwrap_or("/") - .to_string(); - - let input = if let Some(id) = form.movie_id { - MovieInput { - movie_id: Some(id), - external_metadata_id: None, - manual_title: None, - manual_release_year: None, - manual_director: None, - } - } else { - let query = form.query.as_deref().unwrap_or("").trim().to_string(); - let is_external_id = query.starts_with("tmdb:") - || (query.starts_with("tt") - && query.len() > 2 - && query[2..].chars().all(|c| c.is_ascii_digit())); - if is_external_id { - MovieInput { - movie_id: None, - external_metadata_id: Some(query), - manual_title: None, - manual_release_year: None, - manual_director: None, - } - } else { - MovieInput { - movie_id: None, - external_metadata_id: None, - manual_title: if query.is_empty() { None } else { Some(query) }, - manual_release_year: form.year, - manual_director: None, - } - } - }; - - match add_to_watchlist::execute( - &state.app_ctx, - AddToWatchlistCommand { - user_id: user_id.value(), - input, - }, - ) - .await - { - Ok(()) => Redirect::to(&redirect_base).into_response(), - Err(DomainError::NotFound(_)) => Redirect::to(&redirect_base).into_response(), - Err(DomainError::ValidationError(msg)) => { - let sep = if redirect_base.contains('?') { - '&' - } else { - '?' - }; - let url = format!("{}{}error={}", redirect_base, sep, encode_error(&msg)); - Redirect::to(&url).into_response() - } - Err(e) => crate::errors::domain_error_response(e), - } -} - -pub async fn post_watchlist_remove( - State(state): State, - RequiredCookieUser(user_id): RequiredCookieUser, - Extension(csrf): Extension, - Path(movie_id): Path, - Form(form): Form, -) -> impl IntoResponse { - if crate::csrf::mismatch(&csrf, &form.csrf_token) { - return StatusCode::FORBIDDEN.into_response(); - } - match remove_from_watchlist::execute( - &state.app_ctx, - RemoveFromWatchlistCommand { - user_id: user_id.value(), - movie_id, - }, - ) - .await - { - Ok(()) | Err(DomainError::NotFound(_)) => { - let redirect_url = form - .redirect_after - .filter(|u| u.starts_with('/') && !u.starts_with("//")) - .unwrap_or_else(|| "/".to_string()); - Redirect::to(&redirect_url).into_response() - } - Err(e) => crate::errors::domain_error_response(e), - } -} - -#[derive(serde::Deserialize, Default)] -pub struct SavedQuery { - pub saved: Option, -} - -pub async fn get_profile_settings( - RequiredCookieUser(user_id): RequiredCookieUser, - State(state): State, - Query(params): Query, - Extension(csrf): Extension, -) -> impl IntoResponse { - let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await; - ctx.page_title = "Profile Settings — Movies Diary".to_string(); - ctx.canonical_url = format!("{}/settings/profile", state.app_ctx.config.base_url); - - let user = match state.app_ctx.repos.user.find_by_id(&user_id).await { - Ok(Some(u)) => u, - Ok(None) => return StatusCode::NOT_FOUND.into_response(), - Err(e) => return crate::errors::domain_error_response(e), - }; - - let base_url = &state.app_ctx.config.base_url; - let avatar_url = user - .avatar_path() - .map(|path| format!("{}/images/{}", base_url, path)); - let banner_url = user - .banner_path() - .map(|path| format!("{}/images/{}", base_url, path)); - - let profile_fields: Vec<(String, String)> = state - .app_ctx - .repos - .profile_fields - .get_fields(&user_id) - .await - .unwrap_or_default() - .into_iter() - .map(|f| (f.name, f.value)) - .collect(); - - let saved = params.saved.as_deref() == Some("1"); - - let bio = user.bio().map(|s| s.to_string()); - let also_known_as = user.also_known_as().map(|s| s.to_string()); - - render_page(ProfileSettingsTemplate { - ctx: &ctx, - bio: bio.as_deref(), - avatar_url: avatar_url.as_deref(), - banner_url: banner_url.as_deref(), - also_known_as: also_known_as.as_deref(), - profile_fields: &profile_fields, - saved, - embed_url: format!( - "{}/users/{}?embed=true", - state.app_ctx.config.base_url, - user_id.value() - ), - }) - .into_response() -} - -pub async fn get_tag(Path(tag): Path) -> impl IntoResponse { - if tag.eq_ignore_ascii_case("moviesdiary") { - Redirect::temporary("/") - } else { - Redirect::temporary(&format!("/?search={}", tag)) - } -} - -#[cfg(feature = "federation")] -pub async fn get_blocked_domains_page( - AdminUser(user_id): AdminUser, - State(state): State, - Extension(csrf): Extension, -) -> impl IntoResponse { - 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 { - Ok(domains) => { - let entries: Vec = domains - .into_iter() - .map(|d| template_askama::BlockedDomainEntry { - domain: d.domain, - reason: d.reason, - blocked_at: d.blocked_at, - }) - .collect(); - render_page(BlockedDomainsTemplate { - ctx: &ctx, - domains: &entries, - }) - .into_response() - } - Err(e) => { - tracing::error!("get_blocked_domains error: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - "Failed to load blocked domains", - ) - .into_response() - } - } -} - -#[cfg(feature = "federation")] -pub async fn post_blocked_domain( - AdminUser(_): AdminUser, - State(state): State, - Extension(csrf): Extension, - Form(form): Form, -) -> impl IntoResponse { - if crate::csrf::mismatch(&csrf, &form.csrf_token) { - return StatusCode::FORBIDDEN.into_response(); - } - let reason = form.reason.as_deref().filter(|s| !s.trim().is_empty()); - match state - .ap_service - .add_blocked_domain(&form.domain, reason) - .await - { - Ok(()) => Redirect::to("/admin/blocked-domains").into_response(), - Err(e) => { - tracing::error!("add_blocked_domain error: {:?}", e); - Redirect::to("/admin/blocked-domains").into_response() - } - } -} - -#[cfg(feature = "federation")] -pub async fn post_remove_blocked_domain( - AdminUser(_): AdminUser, - State(state): State, - Extension(csrf): Extension, - Form(form): Form, -) -> impl IntoResponse { - if crate::csrf::mismatch(&csrf, &form.csrf_token) { - return StatusCode::FORBIDDEN.into_response(); - } - match state.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); - Redirect::to("/admin/blocked-domains").into_response() - } - } -} - -#[cfg(feature = "federation")] -pub async fn get_blocked_actors_page( - RequiredCookieUser(user_id): RequiredCookieUser, - State(state): State, - Extension(csrf): Extension, -) -> impl IntoResponse { - 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 = actors - .into_iter() - .map(|a| template_askama::BlockedActorEntry { - url: a.url, - handle: a.handle, - display_name: a.display_name, - avatar_url: a.avatar_url, - }) - .collect(); - render_page(BlockedActorsTemplate { - ctx: &ctx, - actors: &entries, - }) - .into_response() - } - Err(e) => { - tracing::error!("get_blocked_actors error: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - "Failed to load blocked users", - ) - .into_response() - } - } -} - -#[cfg(feature = "federation")] -pub async fn post_block_actor_html( - RequiredCookieUser(user_id): RequiredCookieUser, - State(state): State, - Extension(csrf): Extension, - Form(form): Form, -) -> impl IntoResponse { - 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 - { - Ok(()) => Redirect::to("/social/blocked").into_response(), - Err(e) => { - tracing::error!("block_actor html error: {:?}", e); - Redirect::to("/social/blocked").into_response() - } - } -} - -#[cfg(feature = "federation")] -pub async fn post_unblock_actor( - RequiredCookieUser(user_id): RequiredCookieUser, - State(state): State, - Extension(csrf): Extension, - Form(form): Form, -) -> impl IntoResponse { - 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 - { - Ok(()) => Redirect::to("/social/blocked").into_response(), - Err(e) => { - tracing::error!("unblock_actor error: {:?}", e); - Redirect::to("/social/blocked").into_response() - } - } -} - -pub async fn post_profile_settings( - RequiredCookieUser(user_id): RequiredCookieUser, - State(state): State, - mut multipart: Multipart, -) -> impl IntoResponse { - let mut display_name: Option = None; - let mut bio: Option = None; - let mut avatar_bytes: Option> = None; - let mut avatar_content_type: Option = None; - let mut banner_bytes: Option> = None; - let mut banner_content_type: Option = None; - let mut also_known_as: Option = None; - let mut field_names: std::collections::HashMap = - std::collections::HashMap::new(); - let mut field_values: std::collections::HashMap = - std::collections::HashMap::new(); - - while let Ok(Some(field)) = multipart.next_field().await { - let name = field.name().unwrap_or("").to_string(); - match name.as_str() { - "display_name" => { - if let Ok(text) = field.text().await { - display_name = Some(text).filter(|s| !s.is_empty()); - } - } - "bio" => { - if let Ok(text) = field.text().await { - bio = Some(text); - } - } - "also_known_as" => { - if let Ok(text) = field.text().await { - also_known_as = Some(text).filter(|s| !s.is_empty()); - } - } - "avatar" => { - let ct = field.content_type().map(|s| s.to_string()); - if let Ok(bytes) = field.bytes().await - && !bytes.is_empty() - { - avatar_bytes = Some(bytes.to_vec()); - avatar_content_type = ct; - } - } - "banner" => { - let ct = field.content_type().map(|s| s.to_string()); - if let Ok(bytes) = field.bytes().await - && !bytes.is_empty() - { - banner_bytes = Some(bytes.to_vec()); - banner_content_type = ct; - } - } - n if n.starts_with("field_name_") => { - if let Ok(idx) = n["field_name_".len()..].parse::() - && let Ok(text) = field.text().await - && !text.is_empty() - { - field_names.insert(idx, text); - } - } - n if n.starts_with("field_value_") => { - if let Ok(idx) = n["field_value_".len()..].parse::() - && let Ok(text) = field.text().await - && !text.is_empty() - { - field_values.insert(idx, text); - } - } - _ => {} - } - } - - let cmd = application::users::commands::UpdateProfileCommand { - user_id: user_id.value(), - display_name, - bio, - avatar_bytes, - avatar_content_type, - banner_bytes, - banner_content_type, - also_known_as, - }; - let _ = update_profile::execute(&state.app_ctx, cmd).await; - - let fields: Vec = (0..4) - .filter_map(|i| { - field_names - .get(&i) - .map(|name| domain::models::ProfileField { - name: name.clone(), - value: field_values.get(&i).cloned().unwrap_or_default(), - }) - }) - .collect(); - - let fields_cmd = application::users::commands::UpdateProfileFieldsCommand { - user_id: user_id.value(), - fields, - }; - let _ = update_profile_fields::execute(&state.app_ctx, fields_cmd).await; - - Redirect::to("/settings/profile?saved=1").into_response() -} - -// ── Integrations ────────────────────────────────────────────────────────────── - -pub async fn get_integrations_page( - RequiredCookieUser(user_id): RequiredCookieUser, - State(state): State, - Query(params): Query, - Extension(csrf): Extension, -) -> impl IntoResponse { - let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await; - ctx.page_title = "Integrations — Movies Diary".to_string(); - ctx.canonical_url = format!("{}/settings/integrations", state.app_ctx.config.base_url); - - let query = GetWebhookTokensQuery { - user_id: user_id.value(), - }; - let tokens = get_webhook_tokens::execute(&state.app_ctx, query) - .await - .unwrap_or_default(); - - let token_views: Vec = tokens - .iter() - .map(crate::mappers::integrations::webhook_token_view) - .collect(); - - let webhook_base_url = state.app_ctx.config.base_url.clone(); - render_page(IntegrationsTemplate { - ctx: &ctx, - tokens: &token_views, - webhook_base_url: &webhook_base_url, - new_token: params.token.as_deref(), - }) - .into_response() -} - -pub async fn post_generate_token( - RequiredCookieUser(user_id): RequiredCookieUser, - State(state): State, - Extension(csrf): Extension, - Form(form): Form, -) -> impl IntoResponse { - if crate::csrf::mismatch(&csrf, &form.csrf_token) { - return StatusCode::FORBIDDEN.into_response(); - } - - let provider = match form.provider.parse::() { - Ok(p) => p, - Err(_) => return Redirect::to("/settings/integrations").into_response(), - }; - - let cmd = GenerateWebhookTokenCommand { - user_id: user_id.value(), - provider, - label: form.label.filter(|l| !l.trim().is_empty()), - }; - - match generate_webhook_token::execute(&state.app_ctx, cmd).await { - Ok(result) => { - let encoded = percent_encoding::utf8_percent_encode( - &result.token_plaintext, - percent_encoding::NON_ALPHANUMERIC, - ); - Redirect::to(&format!("/settings/integrations?token={encoded}")).into_response() - } - Err(e) => { - tracing::error!("generate token failed: {:?}", e); - Redirect::to("/settings/integrations").into_response() - } - } -} - -pub async fn post_revoke_token( - RequiredCookieUser(user_id): RequiredCookieUser, - State(state): State, - Path(token_id): Path, - Extension(csrf): Extension, - Form(form): Form, -) -> impl IntoResponse { - if crate::csrf::mismatch(&csrf, &form.csrf_token) { - return StatusCode::FORBIDDEN.into_response(); - } - - let cmd = RevokeWebhookTokenCommand { - user_id: user_id.value(), - token_id, - }; - if let Err(e) = revoke_webhook_token::execute(&state.app_ctx, cmd).await { - tracing::error!("revoke token failed: {:?}", e); - } - - Redirect::to("/settings/integrations").into_response() -} - -// ── Watch Queue ─────────────────────────────────────────────────────────────── - -pub async fn get_watch_queue_page( - RequiredCookieUser(user_id): RequiredCookieUser, - State(state): State, - Query(params): Query, - Extension(csrf): Extension, -) -> impl IntoResponse { - let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await; - ctx.page_title = "Watch Queue — Movies Diary".to_string(); - ctx.canonical_url = format!("{}/watch-queue", state.app_ctx.config.base_url); - - let query = GetWatchQueueQuery { - user_id: user_id.value(), - }; - let events = get_watch_queue::execute(&state.app_ctx, query) - .await - .unwrap_or_default(); - - let entries: Vec = events - .iter() - .map(crate::mappers::integrations::watch_queue_entry) - .collect(); - - render_page(WatchQueueTemplate { - ctx: &ctx, - entries: &entries, - error: params.error.as_deref(), - }) - .into_response() -} - -pub async fn post_confirm_single( - RequiredCookieUser(user_id): RequiredCookieUser, - State(state): State, - Path(event_id): Path, - Extension(csrf): Extension, - Form(form): Form, -) -> impl IntoResponse { - if crate::csrf::mismatch(&csrf, &form.csrf_token) { - return StatusCode::FORBIDDEN.into_response(); - } - - let cmd = ConfirmWatchEventsCommand { - user_id: user_id.value(), - confirmations: vec![WatchEventConfirmation { - watch_event_id: event_id, - rating: form.rating, - comment: form.comment.filter(|c| !c.trim().is_empty()), - }], - }; - - match confirm_watch_events::execute(&state.app_ctx, cmd).await { - Ok(_) => Redirect::to("/watch-queue").into_response(), - Err(e) => { - let msg = encode_error(&e.to_string()); - Redirect::to(&format!("/watch-queue?error={msg}")).into_response() - } - } -} - -pub async fn post_dismiss_single( - RequiredCookieUser(user_id): RequiredCookieUser, - State(state): State, - Path(event_id): Path, - Extension(csrf): Extension, - Form(form): Form, -) -> impl IntoResponse { - if crate::csrf::mismatch(&csrf, &form.csrf_token) { - return StatusCode::FORBIDDEN.into_response(); - } - - let cmd = DismissWatchEventsCommand { - user_id: user_id.value(), - event_ids: vec![event_id], - }; - - match dismiss_watch_events::execute(&state.app_ctx, cmd).await { - Ok(_) => Redirect::to("/watch-queue").into_response(), - Err(e) => { - let msg = encode_error(&e.to_string()); - Redirect::to(&format!("/watch-queue?error={msg}")).into_response() - } - } -} diff --git a/crates/presentation/src/handlers/import.rs b/crates/presentation/src/handlers/import.rs index b6afd9f..48c0574 100644 --- a/crates/presentation/src/handlers/import.rs +++ b/crates/presentation/src/handlers/import.rs @@ -108,7 +108,7 @@ pub async fn get_import_page( RequiredCookieUser(user_id): RequiredCookieUser, Extension(csrf): Extension, ) -> impl IntoResponse { - let ctx = super::html::build_page_context(&state, Some(user_id.clone()), csrf.0).await; + let ctx = super::helpers::build_page_context(&state, Some(user_id.clone()), csrf.0).await; let profiles = list_import_profiles::execute(&state.app_ctx, &user_id) .await .unwrap_or_default() @@ -202,7 +202,7 @@ pub async fn get_mapping_page( return Redirect::to("/import").into_response(); }; - let ctx = super::html::build_page_context(&state, Some(user_id), csrf.0).await; + let ctx = super::helpers::build_page_context(&state, Some(user_id), csrf.0).await; let sample_rows: Vec> = parsed.rows.into_iter().take(5).collect(); let domain_fields: Vec<(&str, &str)> = vec![ ("title", "Title"), @@ -304,7 +304,7 @@ pub async fn get_preview_page( .map(|(i, a)| annotated_to_preview_row(i, a)) .collect(); - let ctx = super::html::build_page_context(&state, Some(user_id), csrf.0).await; + let ctx = super::helpers::build_page_context(&state, Some(user_id), csrf.0).await; render_page(ImportPreviewTemplate { ctx: &ctx, session_id: &session_id_str, @@ -421,7 +421,7 @@ pub async fn get_import_done( Extension(csrf): Extension, axum::extract::Query(params): axum::extract::Query, ) -> impl IntoResponse { - let _ctx = super::html::build_page_context(&state, Some(user_id.clone()), csrf.0).await; + let _ctx = super::helpers::build_page_context(&state, Some(user_id.clone()), csrf.0).await; let html = format!( r#"

Import Complete

diff --git a/crates/presentation/src/handlers/integrations.rs b/crates/presentation/src/handlers/integrations.rs new file mode 100644 index 0000000..3adacbf --- /dev/null +++ b/crates/presentation/src/handlers/integrations.rs @@ -0,0 +1,210 @@ +use axum::{ + Form, + extract::{Extension, Path, Query, State}, + http::StatusCode, + response::{IntoResponse, Redirect}, +}; +use uuid::Uuid; + +use application::integrations::{ + commands::{ + ConfirmWatchEventsCommand, DismissWatchEventsCommand, GenerateWebhookTokenCommand, + RevokeWebhookTokenCommand, WatchEventConfirmation, + }, + confirm as confirm_watch_events, dismiss as dismiss_watch_events, + generate_token as generate_webhook_token, get_queue as get_watch_queue, + get_tokens as get_webhook_tokens, + queries::{GetWatchQueueQuery, GetWebhookTokensQuery}, + revoke_token as revoke_webhook_token, +}; + +use crate::{ + csrf::CsrfToken, extractors::RequiredCookieUser, forms::ErrorQuery, render::render_page, + state::AppState, +}; +use template_askama::{IntegrationsTemplate, WatchQueueTemplate}; + +use super::helpers::build_page_context; + +fn encode_error(msg: &str) -> String { + use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; + utf8_percent_encode(msg, NON_ALPHANUMERIC).to_string() +} + +// ── HTML ───────────────────────────────────────────────────────────────────── + +pub async fn get_integrations_page( + RequiredCookieUser(user_id): RequiredCookieUser, + State(state): State, + Query(params): Query, + Extension(csrf): Extension, +) -> impl IntoResponse { + let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await; + ctx.page_title = "Integrations — Movies Diary".to_string(); + ctx.canonical_url = format!("{}/settings/integrations", state.app_ctx.config.base_url); + + let query = GetWebhookTokensQuery { + user_id: user_id.value(), + }; + let tokens = get_webhook_tokens::execute(&state.app_ctx, query) + .await + .unwrap_or_default(); + + let token_views: Vec = tokens + .iter() + .map(crate::mappers::integrations::webhook_token_view) + .collect(); + + let webhook_base_url = state.app_ctx.config.base_url.clone(); + render_page(IntegrationsTemplate { + ctx: &ctx, + tokens: &token_views, + webhook_base_url: &webhook_base_url, + new_token: params.token.as_deref(), + }) + .into_response() +} + +pub async fn post_generate_token( + RequiredCookieUser(user_id): RequiredCookieUser, + State(state): State, + Extension(csrf): Extension, + Form(form): Form, +) -> impl IntoResponse { + if crate::csrf::mismatch(&csrf, &form.csrf_token) { + return StatusCode::FORBIDDEN.into_response(); + } + + let provider = match form.provider.parse::() { + Ok(p) => p, + Err(_) => return Redirect::to("/settings/integrations").into_response(), + }; + + let cmd = GenerateWebhookTokenCommand { + user_id: user_id.value(), + provider, + label: form.label.filter(|l| !l.trim().is_empty()), + }; + + match generate_webhook_token::execute(&state.app_ctx, cmd).await { + Ok(result) => { + let encoded = percent_encoding::utf8_percent_encode( + &result.token_plaintext, + percent_encoding::NON_ALPHANUMERIC, + ); + Redirect::to(&format!("/settings/integrations?token={encoded}")).into_response() + } + Err(e) => { + tracing::error!("generate token failed: {:?}", e); + Redirect::to("/settings/integrations").into_response() + } + } +} + +pub async fn post_revoke_token( + RequiredCookieUser(user_id): RequiredCookieUser, + State(state): State, + Path(token_id): Path, + Extension(csrf): Extension, + Form(form): Form, +) -> impl IntoResponse { + if crate::csrf::mismatch(&csrf, &form.csrf_token) { + return StatusCode::FORBIDDEN.into_response(); + } + + let cmd = RevokeWebhookTokenCommand { + user_id: user_id.value(), + token_id, + }; + if let Err(e) = revoke_webhook_token::execute(&state.app_ctx, cmd).await { + tracing::error!("revoke token failed: {:?}", e); + } + + Redirect::to("/settings/integrations").into_response() +} + +// ── Watch Queue ────────────────────────────────────────────────────────────── + +pub async fn get_watch_queue_page( + RequiredCookieUser(user_id): RequiredCookieUser, + State(state): State, + Query(params): Query, + Extension(csrf): Extension, +) -> impl IntoResponse { + let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await; + ctx.page_title = "Watch Queue — Movies Diary".to_string(); + ctx.canonical_url = format!("{}/watch-queue", state.app_ctx.config.base_url); + + let query = GetWatchQueueQuery { + user_id: user_id.value(), + }; + let events = get_watch_queue::execute(&state.app_ctx, query) + .await + .unwrap_or_default(); + + let entries: Vec = events + .iter() + .map(crate::mappers::integrations::watch_queue_entry) + .collect(); + + render_page(WatchQueueTemplate { + ctx: &ctx, + entries: &entries, + error: params.error.as_deref(), + }) + .into_response() +} + +pub async fn post_confirm_single( + RequiredCookieUser(user_id): RequiredCookieUser, + State(state): State, + Path(event_id): Path, + Extension(csrf): Extension, + Form(form): Form, +) -> impl IntoResponse { + if crate::csrf::mismatch(&csrf, &form.csrf_token) { + return StatusCode::FORBIDDEN.into_response(); + } + + let cmd = ConfirmWatchEventsCommand { + user_id: user_id.value(), + confirmations: vec![WatchEventConfirmation { + watch_event_id: event_id, + rating: form.rating, + comment: form.comment.filter(|c| !c.trim().is_empty()), + }], + }; + + match confirm_watch_events::execute(&state.app_ctx, cmd).await { + Ok(_) => Redirect::to("/watch-queue").into_response(), + Err(e) => { + let msg = encode_error(&e.to_string()); + Redirect::to(&format!("/watch-queue?error={msg}")).into_response() + } + } +} + +pub async fn post_dismiss_single( + RequiredCookieUser(user_id): RequiredCookieUser, + State(state): State, + Path(event_id): Path, + Extension(csrf): Extension, + Form(form): Form, +) -> impl IntoResponse { + if crate::csrf::mismatch(&csrf, &form.csrf_token) { + return StatusCode::FORBIDDEN.into_response(); + } + + let cmd = DismissWatchEventsCommand { + user_id: user_id.value(), + event_ids: vec![event_id], + }; + + match dismiss_watch_events::execute(&state.app_ctx, cmd).await { + Ok(_) => Redirect::to("/watch-queue").into_response(), + Err(e) => { + let msg = encode_error(&e.to_string()); + Redirect::to(&format!("/watch-queue?error={msg}")).into_response() + } + } +} diff --git a/crates/presentation/src/handlers/mod.rs b/crates/presentation/src/handlers/mod.rs index 3b619cc..3f69412 100644 --- a/crates/presentation/src/handlers/mod.rs +++ b/crates/presentation/src/handlers/mod.rs @@ -1,8 +1,17 @@ -pub mod api; -pub mod html; +pub mod auth; +pub mod diary; +pub mod goals; +mod helpers; pub mod images; pub mod import; +pub mod integrations; +pub mod movies; pub mod rss; +pub mod search; +#[cfg(feature = "federation")] +pub mod social; +pub mod users; +pub mod watchlist; pub mod webhook; pub mod wrapup; diff --git a/crates/presentation/src/handlers/movies.rs b/crates/presentation/src/handlers/movies.rs new file mode 100644 index 0000000..452ef35 --- /dev/null +++ b/crates/presentation/src/handlers/movies.rs @@ -0,0 +1,318 @@ +use axum::{ + Json, + extract::{Extension, Path, Query, State}, + http::StatusCode, + response::IntoResponse, +}; +use uuid::Uuid; + +use application::{ + diary::{ + commands::SyncPosterCommand, + get_movie_social_page, get_review_history, + queries::{GetMovieSocialPageQuery, GetReviewHistoryQuery}, + }, + movies::{get_movies, queries::GetMoviesQuery, sync_poster}, + watchlist::{is_on as is_on_watchlist, queries::IsOnWatchlistQuery}, +}; +use domain::services::review_history::Trend; + +use crate::{ + csrf::CsrfToken, + errors::ApiError, + extractors::{AuthenticatedUser, OptionalCookieUser}, + render::render_page, + state::AppState, +}; +use api_types::{ + CastMemberDto, CrewMemberDto, GenreDto, KeywordDto, MovieDetailResponse, MovieProfileResponse, + MovieStatsDto, MoviesQueryParams, MoviesResponse, PaginationQueryParams, ReviewHistoryResponse, + SocialFeedResponse, SocialReviewDto, +}; +use template_askama::MovieDetailTemplate; + +use super::helpers::build_page_context; + +// ── API ────────────────────────────────────────────────────────────────────── + +#[utoipa::path( + get, path = "/api/v1/movies", + params(MoviesQueryParams), + responses( + (status = 200, body = MoviesResponse), + ) +)] +pub async fn list_movies( + State(state): State, + Query(params): Query, +) -> Result, ApiError> { + let page = get_movies::execute( + &state.app_ctx, + GetMoviesQuery { + limit: params.limit, + offset: params.offset, + search: params.search, + genre: params.genre, + language: params.language, + }, + ) + .await?; + + Ok(Json(MoviesResponse { + items: page + .items + .iter() + .map(crate::mappers::movies::summary_to_dto) + .collect(), + total_count: page.total_count, + limit: page.limit, + offset: page.offset, + })) +} + +#[utoipa::path( + get, path = "/api/v1/movies/{id}/history", + params(("id" = Uuid, Path, description = "Movie ID")), + responses( + (status = 200, body = ReviewHistoryResponse), + (status = 404, description = "Movie not found"), + ) +)] +pub async fn get_review_history( + State(state): State, + Path(movie_id): Path, +) -> Result, ApiError> { + let (history, trend) = + get_review_history::execute(&state.app_ctx, GetReviewHistoryQuery { movie_id }).await?; + + Ok(Json(ReviewHistoryResponse { + movie: crate::mappers::movies::movie_to_dto(history.movie()), + viewings: history + .viewings() + .iter() + .map(crate::mappers::movies::review_to_dto) + .collect(), + trend: match trend { + Trend::Improved => "improved", + Trend::Declined => "declined", + Trend::Neutral => "neutral", + } + .to_string(), + })) +} + +#[utoipa::path( + post, path = "/api/v1/movies/{id}/sync-poster", + params(("id" = Uuid, Path, description = "Movie ID")), + responses( + (status = 204, description = "Poster synced"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Movie not found"), + ), + security(("bearer_auth" = [])) +)] +pub async fn sync_poster( + State(state): State, + _user: AuthenticatedUser, + Path(movie_id): Path, +) -> Result { + sync_poster::execute(&state.app_ctx, SyncPosterCommand { movie_id }).await?; + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path( + get, path = "/api/v1/movies/{movie_id}", + params(("movie_id" = Uuid, Path, description = "Movie ID")), + responses( + (status = 200, body = MovieDetailResponse), + (status = 404, description = "Movie not found"), + ) +)] +pub async fn get_movie_detail( + State(state): State, + Path(movie_id): Path, + Query(params): Query, +) -> Result, ApiError> { + let limit = params.limit.unwrap_or(20); + let offset = params.offset.unwrap_or(0); + + let result = get_movie_social_page::execute( + &state.app_ctx, + GetMovieSocialPageQuery { + movie_id, + limit, + offset, + }, + ) + .await?; + + Ok(Json(MovieDetailResponse { + movie: crate::mappers::movies::movie_to_dto(&result.movie), + stats: MovieStatsDto { + total_count: result.stats.total_count, + avg_rating: result.stats.avg_rating, + federated_count: result.stats.federated_count, + rating_histogram: result.stats.rating_histogram, + }, + reviews: SocialFeedResponse { + items: result + .reviews + .items + .iter() + .map(|e| SocialReviewDto { + user_display: e.user_display_name().to_string(), + rating: e.review().rating().value(), + comment: e.review().comment().map(|c| c.value().to_string()), + watched_at: e.review().watched_at().to_string(), + is_federated: e.review().is_remote(), + }) + .collect(), + total_count: result.reviews.total_count, + limit: result.reviews.limit, + offset: result.reviews.offset, + }, + })) +} + +#[utoipa::path( + get, path = "/api/v1/movies/{id}/profile", + params(("id" = Uuid, Path, description = "Movie ID")), + responses( + (status = 200, body = MovieProfileResponse), + (status = 404, description = "No profile found for this movie"), + ) +)] +pub async fn get_movie_profile( + State(state): State, + Path(movie_id): Path, +) -> impl IntoResponse { + use application::movies::get_movie_profile; + let query = get_movie_profile::GetMovieProfileQuery { movie_id }; + match get_movie_profile::execute(&state.app_ctx, query).await { + Ok(Some(result)) => { + let p = result.profile; + Json(MovieProfileResponse { + tmdb_id: p.tmdb_id, + imdb_id: p.imdb_id, + overview: p.overview, + tagline: p.tagline, + runtime_minutes: p.runtime_minutes, + budget_usd: p.budget_usd, + revenue_usd: p.revenue_usd, + vote_average: p.vote_average, + vote_count: p.vote_count, + original_language: p.original_language, + collection_name: p.collection_name, + genres: p + .genres + .into_iter() + .map(|g| GenreDto { + tmdb_id: g.tmdb_id, + name: g.name, + }) + .collect(), + keywords: p + .keywords + .into_iter() + .map(|k| KeywordDto { + tmdb_id: k.tmdb_id, + name: k.name, + }) + .collect(), + cast: result + .cast + .into_iter() + .map(|c| CastMemberDto { + person_id: c.person_id.value().to_string(), + tmdb_person_id: c.tmdb_person_id, + name: c.name, + character: c.character, + billing_order: c.billing_order, + profile_path: c.profile_path, + }) + .collect(), + crew: result + .crew + .into_iter() + .map(|c| CrewMemberDto { + person_id: c.person_id.value().to_string(), + tmdb_person_id: c.tmdb_person_id, + name: c.name, + job: c.job, + department: c.department, + profile_path: c.profile_path, + }) + .collect(), + enriched_at: p.enriched_at.to_rfc3339(), + }) + .into_response() + } + Ok(None) => StatusCode::NOT_FOUND.into_response(), + Err(e) => crate::errors::domain_error_response(e), + } +} + +// ── HTML ───────────────────────────────────────────────────────────────────── + +pub async fn get_movie_detail_html( + OptionalCookieUser(user_id): OptionalCookieUser, + State(state): State, + Path(movie_id): Path, + Query(params): Query, + Extension(csrf): Extension, +) -> impl IntoResponse { + let ctx = build_page_context(&state, user_id.clone(), csrf.0).await; + let limit = params.limit.unwrap_or(20); + let offset = params.offset.unwrap_or(0); + + match get_movie_social_page::execute( + &state.app_ctx, + GetMovieSocialPageQuery { + movie_id, + limit, + offset, + }, + ) + .await + { + Err(e) => crate::errors::domain_error_response(e), + Ok(result) => { + let histogram_max = result + .stats + .rating_histogram + .iter() + .copied() + .max() + .unwrap_or(1); + let has_more = + result.reviews.offset + result.reviews.limit < result.reviews.total_count as u32; + let on_watchlist = match &user_id { + Some(uid) => is_on_watchlist::execute( + &state.app_ctx, + IsOnWatchlistQuery { + user_id: uid.value(), + movie_id, + }, + ) + .await + .unwrap_or(false), + None => false, + }; + let current_offset = result.reviews.offset; + let reviews_limit = result.reviews.limit; + render_page(MovieDetailTemplate { + ctx: &ctx, + movie: &result.movie, + stats: &result.stats, + profile: result.profile.as_ref(), + reviews: result.reviews.items.as_slice(), + on_watchlist, + current_offset, + has_more, + limit: reviews_limit, + histogram_max, + }) + .into_response() + } + } +} diff --git a/crates/presentation/src/handlers/search.rs b/crates/presentation/src/handlers/search.rs new file mode 100644 index 0000000..1a70d7f --- /dev/null +++ b/crates/presentation/src/handlers/search.rs @@ -0,0 +1,191 @@ +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + response::{IntoResponse, Redirect}, +}; + +use application::{ + person::{get as get_person, get_credits as get_person_credits}, + search::execute as search_uc, +}; +use domain::models::{PersonId, collections::PageParams}; + +use crate::state::AppState; +use api_types::search::{ + CastCreditDto, CrewCreditDto, MovieSearchHitDto, PaginatedMovieHits, PaginatedPersonHits, + PersonCreditsDto, PersonDto, PersonSearchHitDto, SearchQueryParams, SearchResponse, +}; + +// ── API ────────────────────────────────────────────────────────────────────── + +#[utoipa::path( + get, path = "/api/v1/search", + params(api_types::search::SearchQueryParams), + responses( + (status = 200, body = api_types::search::SearchResponse), + ), + tag = "search", +)] +pub async fn get_search( + State(state): State, + Query(params): Query, +) -> impl IntoResponse { + let query = domain::models::SearchQuery { + text: params.q, + filters: domain::models::SearchFilters { + genre: params.genre, + year: params.year, + person_id: params.person_id.map(PersonId::from_uuid), + department: params.department, + language: params.language, + }, + page: PageParams { + limit: params.limit.unwrap_or(5), + offset: params.offset.unwrap_or(0), + }, + }; + + match search_uc::execute(&state.app_ctx, query).await { + Ok(results) => axum::Json(SearchResponse { + movies: PaginatedMovieHits { + items: results + .movies + .items + .iter() + .map(|h| MovieSearchHitDto { + movie_id: h.movie_id.value(), + title: h.title.clone(), + release_year: h.release_year, + director: h.director.clone(), + poster_path: h.poster_path.clone(), + genres: h.genres.clone(), + }) + .collect(), + total_count: results.movies.total_count, + limit: results.movies.limit, + offset: results.movies.offset, + }, + people: PaginatedPersonHits { + items: results + .people + .items + .iter() + .map(|h| PersonSearchHitDto { + person_id: h.person_id.value(), + name: h.name.clone(), + known_for_department: h.known_for_department.clone(), + profile_path: h.profile_path.clone(), + known_for_titles: h.known_for_titles.clone(), + }) + .collect(), + total_count: results.people.total_count, + limit: results.people.limit, + offset: results.people.offset, + }, + }) + .into_response(), + Err(e) => crate::errors::domain_error_response(e), + } +} + +#[utoipa::path( + get, path = "/api/v1/people/{id}", + params(("id" = Uuid, Path, description = "Person ID")), + responses( + (status = 200, body = api_types::search::PersonDto), + (status = 404, description = "Person not found"), + ), + tag = "search", +)] +pub async fn get_person_handler( + State(state): State, + Path(id): Path, +) -> impl IntoResponse { + match get_person::execute(&state.app_ctx, PersonId::from_uuid(id)).await { + Ok(Some(person)) => axum::Json(PersonDto { + id: person.id().value(), + external_id: person.external_id().value().to_string(), + name: person.name().to_string(), + known_for_department: person.known_for_department().map(str::to_string), + profile_path: person.profile_path().map(str::to_string), + }) + .into_response(), + Ok(None) => StatusCode::NOT_FOUND.into_response(), + Err(e) => crate::errors::domain_error_response(e), + } +} + +#[utoipa::path( + get, path = "/api/v1/people/{id}/credits", + params(("id" = Uuid, Path, description = "Person ID")), + responses( + (status = 200, body = api_types::search::PersonCreditsDto), + (status = 404, description = "Person not found"), + ), + tag = "search", +)] +pub async fn get_person_credits_handler( + State(state): State, + Path(id): Path, +) -> impl IntoResponse { + match get_person_credits::execute(&state.app_ctx, PersonId::from_uuid(id)).await { + Ok(credits) => axum::Json(PersonCreditsDto { + person: PersonDto { + id: credits.person.id().value(), + external_id: credits.person.external_id().value().to_string(), + name: credits.person.name().to_string(), + known_for_department: credits.person.known_for_department().map(str::to_string), + profile_path: credits.person.profile_path().map(str::to_string), + }, + cast: credits + .cast + .iter() + .map(|c| CastCreditDto { + movie_id: c.movie_id.value(), + title: c.title.clone(), + release_year: c.release_year, + character: c.character.clone(), + poster_path: c.poster_path.clone(), + }) + .collect(), + crew: credits + .crew + .iter() + .map(|c| CrewCreditDto { + movie_id: c.movie_id.value(), + title: c.title.clone(), + release_year: c.release_year, + job: c.job.clone(), + department: c.department.clone(), + poster_path: c.poster_path.clone(), + }) + .collect(), + }) + .into_response(), + Err(e) => crate::errors::domain_error_response(e), + } +} + +pub async fn post_reindex_search( + State(state): State, + _admin: crate::extractors::AdminApiUser, +) -> impl IntoResponse { + let event = domain::events::DomainEvent::SearchReindexRequested; + match state.app_ctx.services.event_publisher.publish(&event).await { + Ok(()) => StatusCode::ACCEPTED, + Err(e) => { + tracing::error!("failed to publish reindex event: {:?}", e); + StatusCode::INTERNAL_SERVER_ERROR + } + } +} + +// ── HTML ───────────────────────────────────────────────────────────────────── + +pub async fn get_tag(Path(tag): Path) -> impl IntoResponse { + if tag.eq_ignore_ascii_case("moviesdiary") { + Redirect::temporary("/") + } else { + Redirect::temporary(&format!("/?search={}", tag)) + } +} diff --git a/crates/presentation/src/handlers/social.rs b/crates/presentation/src/handlers/social.rs new file mode 100644 index 0000000..c1d53b1 --- /dev/null +++ b/crates/presentation/src/handlers/social.rs @@ -0,0 +1,913 @@ +use axum::{ + Form, Json, + extract::{Extension, Path, Query, State}, + http::StatusCode, + response::{IntoResponse, Redirect}, +}; +use uuid::Uuid; + +use crate::{ + csrf::CsrfToken, + errors::ApiError, + extractors::{AuthenticatedUser, RequiredCookieUser}, + forms::{ + ActorUrlForm, BlockDomainForm, FollowForm, FollowerActionForm, RemoveDomainForm, + UnfollowForm, + }, + render::render_page, + state::AppState, +}; +use api_types::{ + ActorListResponse, ActorUrlRequest, AddBlockedDomainRequest, BlockedActorResponse, + BlockedDomainResponse, FollowRequest, RemoteActorDto, +}; +use template_askama::{ + BlockedActorsTemplate, BlockedDomainsTemplate, FollowersTemplate, FollowingTemplate, + RemoteActorData, +}; + +use super::helpers::build_page_context; + +fn encode_error(msg: &str) -> String { + use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; + utf8_percent_encode(msg, NON_ALPHANUMERIC).to_string() +} + +fn ap_err(e: anyhow::Error) -> impl IntoResponse { + tracing::error!("ActivityPub error: {:?}", e); + StatusCode::INTERNAL_SERVER_ERROR +} + +fn ap_to_domain(e: anyhow::Error) -> domain::errors::DomainError { + tracing::error!("ActivityPub error: {:?}", e); + domain::errors::DomainError::InfrastructureError(e.to_string()) +} + +// ── API ────────────────────────────────────────────────────────────────────── + +#[utoipa::path( + get, path = "/api/v1/admin/blocked-domains", + responses( + (status = 200, body = Vec), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden - admin only"), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_blocked_domains_admin( + State(state): State, + _admin: crate::extractors::AdminUser, +) -> impl IntoResponse { + match state.ap_service.get_blocked_domains().await { + Ok(domains) => { + let response: Vec = domains + .into_iter() + .map(|d| BlockedDomainResponse { + domain: d.domain, + reason: d.reason, + blocked_at: d.blocked_at, + }) + .collect(); + axum::Json(response).into_response() + } + Err(e) => ap_err(e).into_response(), + } +} + +#[utoipa::path( + post, path = "/api/v1/admin/blocked-domains", + request_body = AddBlockedDomainRequest, + responses( + (status = 201, description = "Domain blocked"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden - admin only"), + ), + security(("bearer_auth" = [])) +)] +pub async fn add_blocked_domain_admin( + State(state): State, + _admin: crate::extractors::AdminUser, + axum::Json(body): axum::Json, +) -> impl IntoResponse { + match state + .ap_service + .add_blocked_domain(&body.domain, body.reason.as_deref()) + .await + { + Ok(()) => StatusCode::CREATED.into_response(), + Err(e) => ap_err(e).into_response(), + } +} + +#[utoipa::path( + delete, path = "/api/v1/admin/blocked-domains/{domain}", + params(("domain" = String, Path, description = "Domain to unblock")), + responses( + (status = 204, description = "Domain unblocked"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden - admin only"), + ), + security(("bearer_auth" = [])) +)] +pub async fn remove_blocked_domain_admin( + State(state): State, + _admin: crate::extractors::AdminUser, + axum::extract::Path(domain): axum::extract::Path, +) -> impl IntoResponse { + match state.ap_service.remove_blocked_domain(&domain).await { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(e) => ap_err(e).into_response(), + } +} + +#[utoipa::path( + post, path = "/api/v1/social/block", + request_body = ActorUrlRequest, + responses( + (status = 204, description = "Actor blocked"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn block_actor_api( + State(state): State, + user: AuthenticatedUser, + axum::Json(body): axum::Json, +) -> impl IntoResponse { + match state + .ap_service + .block_actor(user.0.value(), &body.actor_url) + .await + { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(e) => ap_err(e).into_response(), + } +} + +#[utoipa::path( + post, path = "/api/v1/social/unblock", + request_body = ActorUrlRequest, + responses( + (status = 204, description = "Actor unblocked"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn unblock_actor_api( + State(state): State, + user: AuthenticatedUser, + axum::Json(body): axum::Json, +) -> impl IntoResponse { + match state + .ap_service + .unblock_actor(user.0.value(), &body.actor_url) + .await + { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(e) => ap_err(e).into_response(), + } +} + +#[utoipa::path( + get, path = "/api/v1/social/blocked", + responses( + (status = 200, body = Vec), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_blocked_actors_api( + State(state): State, + user: AuthenticatedUser, +) -> impl IntoResponse { + match state.ap_service.get_blocked_actors(user.0.value()).await { + Ok(actors) => { + let response: Vec = actors + .into_iter() + .map(|a| BlockedActorResponse { + url: a.url, + handle: a.handle, + display_name: a.display_name, + avatar_url: a.avatar_url, + }) + .collect(); + axum::Json(response).into_response() + } + Err(e) => ap_err(e).into_response(), + } +} + +#[utoipa::path( + get, path = "/api/v1/social/following", + responses( + (status = 200, body = ActorListResponse), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_following( + State(state): State, + user: AuthenticatedUser, +) -> impl IntoResponse { + match state.ap_service.get_following(user.0.value()).await { + Ok(actors) => Json(ActorListResponse { + actors: actors + .into_iter() + .map(|a| RemoteActorDto { + handle: a.handle, + display_name: a.display_name, + url: a.url, + }) + .collect(), + }) + .into_response(), + Err(e) => ap_err(e).into_response(), + } +} + +#[utoipa::path( + get, path = "/api/v1/social/followers", + responses( + (status = 200, body = ActorListResponse), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_followers( + State(state): State, + user: AuthenticatedUser, +) -> impl IntoResponse { + match state + .ap_service + .get_accepted_followers(user.0.value()) + .await + { + Ok(actors) => Json(ActorListResponse { + actors: actors + .into_iter() + .map(|a| RemoteActorDto { + handle: a.handle, + display_name: a.display_name, + url: a.url, + }) + .collect(), + }) + .into_response(), + Err(e) => ap_err(e).into_response(), + } +} + +pub async fn get_user_following( + State(state): State, + _user: AuthenticatedUser, + Path(user_id): Path, +) -> Result, ApiError> { + let actors = state + .ap_service + .get_following(user_id) + .await + .map_err(ap_to_domain)?; + Ok(Json(ActorListResponse { + actors: actors + .into_iter() + .map(|a| RemoteActorDto { + handle: a.handle, + display_name: a.display_name, + url: a.url, + }) + .collect(), + })) +} + +pub async fn get_user_followers( + State(state): State, + _user: AuthenticatedUser, + Path(user_id): Path, +) -> Result, ApiError> { + let actors = state + .ap_service + .get_accepted_followers(user_id) + .await + .map_err(ap_to_domain)?; + Ok(Json(ActorListResponse { + actors: actors + .into_iter() + .map(|a| RemoteActorDto { + handle: a.handle, + display_name: a.display_name, + url: a.url, + }) + .collect(), + })) +} + +#[utoipa::path( + post, path = "/api/v1/social/follow", + request_body = FollowRequest, + responses( + (status = 200, description = "Follow request sent"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn follow( + State(state): State, + user: AuthenticatedUser, + Json(body): Json, +) -> impl IntoResponse { + match state.ap_service.follow(user.0.value(), &body.handle).await { + Ok(()) => StatusCode::OK.into_response(), + Err(e) => ap_err(e).into_response(), + } +} + +#[utoipa::path( + post, path = "/api/v1/social/unfollow", + request_body = ActorUrlRequest, + responses( + (status = 200, description = "Unfollowed"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn unfollow( + State(state): State, + user: AuthenticatedUser, + Json(body): Json, +) -> impl IntoResponse { + match state + .ap_service + .unfollow(user.0.value(), &body.actor_url) + .await + { + Ok(()) => StatusCode::OK.into_response(), + Err(e) => ap_err(e).into_response(), + } +} + +#[utoipa::path( + post, path = "/api/v1/social/followers/accept", + request_body = ActorUrlRequest, + responses( + (status = 200, description = "Follower accepted"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn accept_follower( + State(state): State, + user: AuthenticatedUser, + Json(body): Json, +) -> impl IntoResponse { + match state + .ap_service + .accept_follower(user.0.value(), &body.actor_url) + .await + { + Ok(()) => StatusCode::OK.into_response(), + Err(e) => ap_err(e).into_response(), + } +} + +#[utoipa::path( + post, path = "/api/v1/social/followers/reject", + request_body = ActorUrlRequest, + responses( + (status = 200, description = "Follower rejected"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn reject_follower( + State(state): State, + user: AuthenticatedUser, + Json(body): Json, +) -> impl IntoResponse { + match state + .ap_service + .reject_follower(user.0.value(), &body.actor_url) + .await + { + Ok(()) => StatusCode::OK.into_response(), + Err(e) => ap_err(e).into_response(), + } +} + +#[utoipa::path( + post, path = "/api/v1/social/followers/remove", + request_body = ActorUrlRequest, + responses( + (status = 200, description = "Follower removed"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn remove_follower( + State(state): State, + user: AuthenticatedUser, + Json(body): Json, +) -> impl IntoResponse { + match state + .ap_service + .remove_follower(user.0.value(), &body.actor_url) + .await + { + Ok(()) => StatusCode::OK.into_response(), + Err(e) => ap_err(e).into_response(), + } +} + +#[utoipa::path( + get, path = "/api/v1/social/followers/pending", + responses( + (status = 200, body = ActorListResponse), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_pending_followers( + State(state): State, + user: AuthenticatedUser, +) -> impl IntoResponse { + match state.ap_service.get_pending_followers(user.0.value()).await { + Ok(actors) => Json(ActorListResponse { + actors: actors + .into_iter() + .map(|a| RemoteActorDto { + handle: a.handle, + display_name: a.display_name, + url: a.url, + }) + .collect(), + }) + .into_response(), + Err(e) => ap_err(e).into_response(), + } +} + +// ── HTML ───────────────────────────────────────────────────────────────────── + +pub async fn follow_remote_user( + RequiredCookieUser(user_id): RequiredCookieUser, + State(state): State, + Path(profile_user_uuid): Path, + Extension(csrf): Extension, + Form(form): Form, +) -> impl IntoResponse { + if user_id.value() != profile_user_uuid { + return StatusCode::FORBIDDEN.into_response(); + } + if crate::csrf::mismatch(&csrf, &form.csrf_token) { + return StatusCode::FORBIDDEN.into_response(); + } + let redirect_base = form + .redirect_after + .as_deref() + .filter(|u| u.starts_with('/') && !u.starts_with("//")) + .unwrap_or(&format!("/users/{}", profile_user_uuid)) + .to_string(); + + match state.ap_service.follow(user_id.value(), &form.handle).await { + Ok(()) => Redirect::to(&redirect_base).into_response(), + Err(e) => { + tracing::error!("follow error: {:?}", e); + let msg = encode_error(&e.to_string()); + let sep = if redirect_base.contains('?') { + '&' + } else { + '?' + }; + Redirect::to(&format!("{}{}error={}", redirect_base, sep, msg)).into_response() + } + } +} + +pub async fn unfollow_remote_user( + RequiredCookieUser(user_id): RequiredCookieUser, + State(state): State, + Path(profile_user_uuid): Path, + Extension(csrf): Extension, + Form(form): Form, +) -> impl IntoResponse { + if user_id.value() != profile_user_uuid { + return StatusCode::FORBIDDEN.into_response(); + } + 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 + { + Ok(()) => { + Redirect::to(&format!("/users/{}/following-list", profile_user_uuid)).into_response() + } + Err(e) => { + let msg = encode_error(&e.to_string()); + Redirect::to(&format!( + "/users/{}/following-list?error={}", + profile_user_uuid, msg + )) + .into_response() + } + } +} + +pub async fn accept_follower_html( + RequiredCookieUser(user_id): RequiredCookieUser, + State(state): State, + Path(profile_user_uuid): Path, + Extension(csrf): Extension, + Form(form): Form, +) -> impl IntoResponse { + if user_id.value() != profile_user_uuid { + return StatusCode::FORBIDDEN.into_response(); + } + 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 + { + Ok(_) => Redirect::to(&format!("/users/{}", profile_user_uuid)).into_response(), + Err(e) => { + let msg = encode_error(&e.to_string()); + Redirect::to(&format!("/users/{}?error={}", profile_user_uuid, msg)).into_response() + } + } +} + +pub async fn reject_follower_html( + RequiredCookieUser(user_id): RequiredCookieUser, + State(state): State, + Path(profile_user_uuid): Path, + Extension(csrf): Extension, + Form(form): Form, +) -> impl IntoResponse { + if user_id.value() != profile_user_uuid { + return StatusCode::FORBIDDEN.into_response(); + } + 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 + { + Ok(_) => Redirect::to(&format!("/users/{}", profile_user_uuid)).into_response(), + Err(e) => { + let msg = encode_error(&e.to_string()); + Redirect::to(&format!("/users/{}?error={}", profile_user_uuid, msg)).into_response() + } + } +} + +pub async fn get_followers_collection( + State(state): State, + Path(user_id): Path, + headers: axum::http::HeaderMap, + Query(params): Query>, +) -> impl IntoResponse { + let accept = headers + .get(axum::http::header::ACCEPT) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + if accept.contains("application/activity+json") || accept.contains("application/ld+json") { + let page = params.get("page").and_then(|p| p.parse::().ok()); + return match state + .ap_service + .followers_collection_json(user_id, page) + .await + { + Ok(json) => ( + [( + axum::http::header::CONTENT_TYPE, + "application/activity+json", + )], + json, + ) + .into_response(), + Err(_) => StatusCode::NOT_FOUND.into_response(), + }; + } + axum::response::Redirect::to(&format!("/users/{}/followers-list", user_id)).into_response() +} + +pub async fn get_following_collection( + State(state): State, + Path(user_id): Path, + headers: axum::http::HeaderMap, + Query(params): Query>, +) -> impl IntoResponse { + let accept = headers + .get(axum::http::header::ACCEPT) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + if accept.contains("application/activity+json") || accept.contains("application/ld+json") { + let page = params.get("page").and_then(|p| p.parse::().ok()); + return match state + .ap_service + .following_collection_json(user_id, page) + .await + { + Ok(json) => ( + [( + axum::http::header::CONTENT_TYPE, + "application/activity+json", + )], + json, + ) + .into_response(), + Err(_) => StatusCode::NOT_FOUND.into_response(), + }; + } + axum::response::Redirect::to(&format!("/users/{}/following-list", user_id)).into_response() +} + +pub async fn get_following_page( + RequiredCookieUser(user_id): RequiredCookieUser, + State(state): State, + Path(profile_user_uuid): Path, + Query(params): Query, + Extension(csrf): Extension, +) -> impl IntoResponse { + if user_id.value() != profile_user_uuid { + return StatusCode::FORBIDDEN.into_response(); + } + let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await; + ctx.page_title = "Following — Movies Diary".to_string(); + ctx.canonical_url = format!( + "{}/users/{}/following-list", + state.app_ctx.config.base_url, profile_user_uuid + ); + match state.ap_service.get_following(user_id.value()).await { + Ok(following) => { + let actors: Vec = following + .into_iter() + .map(|a| RemoteActorData { + handle: a.handle, + display_name: a.display_name, + url: a.url, + avatar_url: a.avatar_url.clone(), + }) + .collect(); + render_page(FollowingTemplate { + ctx, + user_id: profile_user_uuid, + actors, + error: params.error, + }) + .into_response() + } + Err(e) => { + tracing::error!("get_following error: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to load following list", + ) + .into_response() + } + } +} + +pub async fn get_followers_page( + RequiredCookieUser(user_id): RequiredCookieUser, + State(state): State, + Path(profile_user_uuid): Path, + Query(params): Query, + Extension(csrf): Extension, +) -> impl IntoResponse { + if user_id.value() != profile_user_uuid { + return StatusCode::FORBIDDEN.into_response(); + } + let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await; + ctx.page_title = "Followers — Movies Diary".to_string(); + ctx.canonical_url = format!( + "{}/users/{}/followers-list", + state.app_ctx.config.base_url, profile_user_uuid + ); + match state + .ap_service + .get_accepted_followers(user_id.value()) + .await + { + Ok(followers) => { + let actors: Vec = followers + .into_iter() + .map(|a| RemoteActorData { + handle: a.handle, + display_name: a.display_name, + url: a.url, + avatar_url: a.avatar_url.clone(), + }) + .collect(); + render_page(FollowersTemplate { + ctx, + user_id: profile_user_uuid, + actors, + error: params.error, + }) + .into_response() + } + Err(e) => { + tracing::error!("get_followers error: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to load followers list", + ) + .into_response() + } + } +} + +pub async fn remove_follower_html( + RequiredCookieUser(user_id): RequiredCookieUser, + State(state): State, + Path(profile_user_uuid): Path, + Extension(csrf): Extension, + Form(form): Form, +) -> impl IntoResponse { + if user_id.value() != profile_user_uuid { + return StatusCode::FORBIDDEN.into_response(); + } + 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 + { + Ok(_) => { + Redirect::to(&format!("/users/{}/followers-list", profile_user_uuid)).into_response() + } + Err(e) => { + let msg = encode_error(&e.to_string()); + Redirect::to(&format!( + "/users/{}/followers-list?error={}", + profile_user_uuid, msg + )) + .into_response() + } + } +} + +pub async fn get_blocked_domains_page( + crate::extractors::AdminUser(user_id): crate::extractors::AdminUser, + State(state): State, + Extension(csrf): Extension, +) -> impl IntoResponse { + 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 { + Ok(domains) => { + let entries: Vec = domains + .into_iter() + .map(|d| template_askama::BlockedDomainEntry { + domain: d.domain, + reason: d.reason, + blocked_at: d.blocked_at, + }) + .collect(); + render_page(BlockedDomainsTemplate { + ctx: &ctx, + domains: &entries, + }) + .into_response() + } + Err(e) => { + tracing::error!("get_blocked_domains error: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to load blocked domains", + ) + .into_response() + } + } +} + +pub async fn post_blocked_domain( + crate::extractors::AdminUser(_): crate::extractors::AdminUser, + State(state): State, + Extension(csrf): Extension, + Form(form): Form, +) -> impl IntoResponse { + if crate::csrf::mismatch(&csrf, &form.csrf_token) { + return StatusCode::FORBIDDEN.into_response(); + } + let reason = form.reason.as_deref().filter(|s| !s.trim().is_empty()); + match state + .ap_service + .add_blocked_domain(&form.domain, reason) + .await + { + Ok(()) => Redirect::to("/admin/blocked-domains").into_response(), + Err(e) => { + tracing::error!("add_blocked_domain error: {:?}", e); + Redirect::to("/admin/blocked-domains").into_response() + } + } +} + +pub async fn post_remove_blocked_domain( + crate::extractors::AdminUser(_): crate::extractors::AdminUser, + State(state): State, + Extension(csrf): Extension, + Form(form): Form, +) -> impl IntoResponse { + if crate::csrf::mismatch(&csrf, &form.csrf_token) { + return StatusCode::FORBIDDEN.into_response(); + } + match state.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); + Redirect::to("/admin/blocked-domains").into_response() + } + } +} + +pub async fn get_blocked_actors_page( + RequiredCookieUser(user_id): RequiredCookieUser, + State(state): State, + Extension(csrf): Extension, +) -> impl IntoResponse { + 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 = actors + .into_iter() + .map(|a| template_askama::BlockedActorEntry { + url: a.url, + handle: a.handle, + display_name: a.display_name, + avatar_url: a.avatar_url, + }) + .collect(); + render_page(BlockedActorsTemplate { + ctx: &ctx, + actors: &entries, + }) + .into_response() + } + Err(e) => { + tracing::error!("get_blocked_actors error: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to load blocked users", + ) + .into_response() + } + } +} + +pub async fn post_block_actor_html( + RequiredCookieUser(user_id): RequiredCookieUser, + State(state): State, + Extension(csrf): Extension, + Form(form): Form, +) -> impl IntoResponse { + 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 + { + Ok(()) => Redirect::to("/social/blocked").into_response(), + Err(e) => { + tracing::error!("block_actor html error: {:?}", e); + Redirect::to("/social/blocked").into_response() + } + } +} + +pub async fn post_unblock_actor( + RequiredCookieUser(user_id): RequiredCookieUser, + State(state): State, + Extension(csrf): Extension, + Form(form): Form, +) -> impl IntoResponse { + 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 + { + Ok(()) => Redirect::to("/social/blocked").into_response(), + Err(e) => { + tracing::error!("unblock_actor error: {:?}", e); + Redirect::to("/social/blocked").into_response() + } + } +} diff --git a/crates/presentation/src/handlers/users.rs b/crates/presentation/src/handlers/users.rs new file mode 100644 index 0000000..8d4eee9 --- /dev/null +++ b/crates/presentation/src/handlers/users.rs @@ -0,0 +1,826 @@ +use std::str::FromStr; + +use axum::{ + Json, + extract::{Extension, Multipart, Path, Query, State}, + http::StatusCode, + response::IntoResponse, +}; +use uuid::Uuid; + +use application::users::{ + get_profile as get_user_profile_uc, get_users, + queries::{GetUserProfileQuery, GetUsersQuery}, + update_profile, update_profile_fields, +}; +use domain::value_objects::UserId; + +use crate::{ + csrf::CsrfToken, + errors::ApiError, + extractors::{AuthenticatedUser, OptionalCookieUser, RequiredCookieUser}, + render::render_page, + state::AppState, +}; +use api_types::{ + DiaryResponse, DirectorStatDto, MonthActivityDto, MonthlyRatingDto, ProfileResponse, + UserProfileQueryParams, UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto, + UsersResponse, +}; +use template_askama::{ + EmbedProfileTemplate, MonthlyRatingRow, ProfileSettingsTemplate, ProfileTemplate, + RemoteActorData, RemoteActorDisplay, UserSummaryView, UsersTemplate, bar_height_px, + build_heatmap, build_page_items, +}; + +use super::goals::goal_with_progress_to_dto; +use super::helpers::build_page_context; + +// ── API ────────────────────────────────────────────────────────────────────── + +#[utoipa::path( + get, path = "/api/v1/profile", + responses( + (status = 200, body = ProfileResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "User not found"), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_profile( + State(state): State, + AuthenticatedUser(user_id): AuthenticatedUser, +) -> Result, ApiError> { + let profile = application::users::get_current_profile::execute( + &state.app_ctx, + application::users::queries::GetCurrentProfileQuery { + user_id: user_id.value(), + }, + ) + .await?; + Ok(Json(ProfileResponse { + username: profile.username, + display_name: profile.display_name, + bio: profile.bio, + avatar_url: profile.avatar_url, + banner_url: profile.banner_url, + also_known_as: profile.also_known_as, + fields: profile + .fields + .into_iter() + .map(|f| api_types::ProfileFieldDto { + name: f.name, + value: f.value, + }) + .collect(), + role: profile.role, + })) +} + +#[utoipa::path( + put, path = "/api/v1/profile", + responses( + (status = 204, description = "Profile updated"), + (status = 400, description = "Invalid input"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] +pub async fn update_profile_handler( + State(state): State, + AuthenticatedUser(user_id): AuthenticatedUser, + mut multipart: Multipart, +) -> impl IntoResponse { + let mut display_name: Option = None; + let mut bio: Option = None; + let mut avatar_bytes: Option> = None; + let mut avatar_content_type: Option = None; + let mut banner_bytes: Option> = None; + let mut banner_content_type: Option = None; + let mut also_known_as: Option = None; + + while let Ok(Some(field)) = multipart.next_field().await { + let name = field.name().unwrap_or("").to_string(); + match name.as_str() { + "display_name" => { + if let Ok(text) = field.text().await { + display_name = Some(text).filter(|s| !s.is_empty()); + } + } + "bio" => { + if let Ok(text) = field.text().await { + bio = Some(text); + } + } + "also_known_as" => { + if let Ok(text) = field.text().await { + also_known_as = Some(text).filter(|s| !s.is_empty()); + } + } + "avatar" => { + let ct = field.content_type().map(|s| s.to_string()); + if let Ok(bytes) = field.bytes().await + && !bytes.is_empty() + { + avatar_bytes = Some(bytes.to_vec()); + avatar_content_type = ct; + } + } + "banner" => { + let ct = field.content_type().map(|s| s.to_string()); + if let Ok(bytes) = field.bytes().await + && !bytes.is_empty() + { + banner_bytes = Some(bytes.to_vec()); + banner_content_type = ct; + } + } + _ => {} + } + } + + let cmd = application::users::commands::UpdateProfileCommand { + user_id: user_id.value(), + display_name, + bio, + avatar_bytes, + avatar_content_type, + banner_bytes, + banner_content_type, + also_known_as, + }; + + match update_profile::execute(&state.app_ctx, cmd).await { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(e) => crate::errors::domain_error_response(e), + } +} + +#[utoipa::path( + put, path = "/api/v1/profile/fields", + request_body = api_types::UpdateProfileFieldsRequest, + responses( + (status = 204, description = "Profile fields updated"), + (status = 400, description = "Invalid input"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] +pub async fn update_profile_fields_handler( + State(state): State, + AuthenticatedUser(user_id): AuthenticatedUser, + axum::Json(body): axum::Json, +) -> impl IntoResponse { + let raw_fields = match body.get("fields").and_then(|f| f.as_array()) { + Some(arr) => arr.clone(), + None => return StatusCode::BAD_REQUEST.into_response(), + }; + + let fields: Vec = raw_fields + .iter() + .filter_map(|f| { + let name = f.get("name").and_then(|n| n.as_str())?.to_string(); + let value = f.get("value").and_then(|v| v.as_str())?.to_string(); + Some(domain::models::ProfileField { name, value }) + }) + .collect(); + + let cmd = application::users::commands::UpdateProfileFieldsCommand { + user_id: user_id.value(), + fields, + }; + + match update_profile_fields::execute(&state.app_ctx, cmd).await { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(e) => crate::errors::domain_error_response(e), + } +} + +#[utoipa::path( + get, path = "/api/v1/users", + responses((status = 200, body = UsersResponse)), +)] +pub async fn list_users(State(state): State) -> Result, ApiError> { + let result = get_users::execute(&state.app_ctx, GetUsersQuery).await?; + Ok(Json(UsersResponse { + users: result + .users + .iter() + .map(|u| UserSummaryDto { + id: u.user_id.value(), + email: u.email().to_string(), + username: u.username().to_string(), + display_name: u.display_name().map(String::from), + total_movies: u.total_movies, + avg_rating: u.avg_rating, + }) + .collect(), + })) +} + +#[utoipa::path( + get, path = "/api/v1/users/{id}", + params( + ("id" = Uuid, Path, description = "User ID"), + UserProfileQueryParams, + ), + responses( + (status = 200, body = UserProfileResponse), + (status = 404, description = "User not found"), + ) +)] +pub async fn get_user_profile( + State(state): State, + AuthenticatedUser(viewer_id): AuthenticatedUser, + Path(user_id): Path, + Query(params): Query, +) -> impl IntoResponse { + let view_str = params.view.as_deref().unwrap_or("recent"); + let profile_view = match application::users::queries::ProfileView::from_str(view_str) { + Ok(v) => v, + Err(_) => return StatusCode::BAD_REQUEST.into_response(), + }; + + let user = match state + .app_ctx + .repos + .user + .find_by_id(&UserId::from_uuid(user_id)) + .await + { + Ok(Some(u)) => u, + Ok(None) => return StatusCode::NOT_FOUND.into_response(), + Err(e) => { + return crate::errors::domain_error_response(e); + } + }; + + let profile = match get_user_profile_uc::execute( + &state.app_ctx, + GetUserProfileQuery { + user_id, + view: profile_view, + limit: params.limit, + offset: params.offset, + sort_by: domain::ports::FeedSortBy::Date, + search: None, + is_own_profile: viewer_id.value() == user_id, + }, + ) + .await + { + Ok(p) => p, + Err(e) => return crate::errors::domain_error_response(e), + }; + + let entries = profile.entries.map(|p| DiaryResponse { + items: p + .items + .iter() + .map(crate::mappers::movies::entry_to_dto) + .collect(), + total_count: p.total_count, + limit: p.limit, + offset: p.offset, + }); + + let history = profile.history.map(|months| { + months + .into_iter() + .map(|m| MonthActivityDto { + year_month: m.year_month, + month_label: m.month_label, + count: m.count, + entries: m + .entries + .iter() + .map(crate::mappers::movies::entry_to_dto) + .collect(), + }) + .collect() + }); + + let trends = profile.trends.map(|t| UserTrendsDto { + monthly_ratings: t + .monthly_ratings + .into_iter() + .map(|r| MonthlyRatingDto { + year_month: r.year_month, + month_label: r.month_label, + avg_rating: r.avg_rating, + count: r.count, + }) + .collect(), + top_directors: t + .top_directors + .into_iter() + .map(|d| DirectorStatDto { + director: d.director, + count: d.count, + }) + .collect(), + max_director_count: t.max_director_count, + }); + + Json(UserProfileResponse { + user_id, + username: user.username().value().to_string(), + avatar_url: user + .avatar_path() + .map(|p| format!("{}/images/{}", state.app_ctx.config.base_url, p)), + banner_url: user + .banner_path() + .map(|p| format!("{}/images/{}", state.app_ctx.config.base_url, p)), + stats: UserStatsDto { + total_movies: profile.stats.total_movies, + avg_rating: profile.stats.avg_rating, + favorite_director: profile.stats.favorite_director, + most_active_month: profile.stats.most_active_month, + }, + following_count: profile.following_count, + followers_count: profile.followers_count, + entries, + history, + trends, + goals: { + let goals_list = application::goals::list::execute( + &state.app_ctx, + application::goals::queries::ListGoalsQuery { user_id }, + ) + .await + .unwrap_or_default(); + if goals_list.is_empty() { + None + } else { + Some(goals_list.iter().map(goal_with_progress_to_dto).collect()) + } + }, + }) + .into_response() +} + +// ── HTML ───────────────────────────────────────────────────────────────────── + +pub async fn get_users_list( + OptionalCookieUser(user_id): OptionalCookieUser, + State(state): State, + Extension(csrf): Extension, +) -> impl IntoResponse { + let mut ctx = build_page_context(&state, user_id, csrf.0).await; + ctx.page_title = "Members — Movies Diary".to_string(); + ctx.canonical_url = format!("{}/users", state.app_ctx.config.base_url); + + match application::users::get_users::execute( + &state.app_ctx, + application::users::queries::GetUsersQuery, + ) + .await + { + Ok(result) => { + let users: Vec = result + .users + .iter() + .map(crate::mappers::users::user_summary_view) + .collect(); + let remote_actors: Vec = result + .remote_actors + .iter() + .map(crate::mappers::users::remote_actor_display) + .collect(); + render_page(UsersTemplate { + users, + ctx: &ctx, + remote_actors, + }) + .into_response() + } + Err(e) => crate::errors::domain_error_response(e), + } +} + +pub async fn get_user_by_username( + State(state): State, + Path(username): Path, +) -> impl IntoResponse { + let uname = match domain::value_objects::Username::new(username) { + Ok(u) => u, + Err(_) => return StatusCode::NOT_FOUND.into_response(), + }; + match state.app_ctx.repos.user.find_by_username(&uname).await { + Ok(Some(user)) => { + axum::response::Redirect::permanent(&format!("/users/{}", user.id().value())) + .into_response() + } + _ => StatusCode::NOT_FOUND.into_response(), + } +} + +pub async fn get_user_profile_html( + OptionalCookieUser(user_id): OptionalCookieUser, + State(state): State, + Path(profile_user_uuid): Path, + headers: axum::http::HeaderMap, + Query(params): Query, + Extension(csrf): Extension, +) -> impl IntoResponse { + // Content negotiation: AP clients request application/activity+json + #[cfg(feature = "federation")] + { + let accept = headers + .get(axum::http::header::ACCEPT) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + if accept.contains("application/activity+json") || accept.contains("application/ld+json") { + return match state + .ap_service + .actor_json(&profile_user_uuid.to_string()) + .await + { + Ok(json) => ( + [( + axum::http::header::CONTENT_TYPE, + "application/activity+json", + )], + json, + ) + .into_response(), + Err(_) => StatusCode::NOT_FOUND.into_response(), + }; + } + } + + #[cfg(not(feature = "federation"))] + let _ = &headers; + + let mut ctx = build_page_context(&state, user_id.clone(), csrf.0).await; + let view_str = params.view.as_deref().unwrap_or("recent"); + let profile_view = match application::users::queries::ProfileView::from_str(view_str) { + Ok(v) => v, + Err(_) => { + return ( + axum::http::StatusCode::BAD_REQUEST, + "invalid view parameter", + ) + .into_response(); + } + }; + + let profile_user = match state + .app_ctx + .repos + .user + .find_by_id(&domain::value_objects::UserId::from_uuid(profile_user_uuid)) + .await + { + Ok(Some(u)) => u, + Ok(None) => return StatusCode::NOT_FOUND.into_response(), + Err(e) => return crate::errors::domain_error_response(e), + }; + + let display_name = profile_user.username().value(); + ctx.page_title = format!("{}'s Diary — Movies Diary", display_name); + ctx.canonical_url = format!( + "{}/users/{}", + state.app_ctx.config.base_url, profile_user_uuid + ); + + let sort_by_str = match params.sort_by.as_str() { + "date_asc" => "date_asc", + "rating" => "rating", + "rating_asc" => "rating_asc", + _ => "date", + }; + + let is_own_profile = user_id + .as_ref() + .map(|u| u.value() == profile_user_uuid) + .unwrap_or(false); + + let query = application::users::queries::GetUserProfileQuery { + user_id: profile_user_uuid, + view: profile_view, + limit: params.limit, + offset: params.offset, + sort_by: sort_by_str.parse().unwrap_or_default(), + search: if params.search.is_empty() { + None + } else { + Some(params.search.clone()) + }, + is_own_profile, + }; + + match application::users::get_profile::execute(&state.app_ctx, query).await { + Ok(profile) => { + let (offset, has_more, limit) = profile + .entries + .as_ref() + .map(|e| { + let has_more = (e.offset as u64).saturating_add(e.limit as u64) < e.total_count; + (e.offset, has_more, e.limit) + }) + .unwrap_or((0, false, super::DEFAULT_PAGE_LIMIT)); + if !is_own_profile { + ctx.page_rss_url = Some(format!("/users/{}/feed.rss", profile_user_uuid)); + } + let email = profile_user.email().value().to_string(); + let display_name = email.split('@').next().unwrap_or("?").to_string(); + let avg_rating_display = profile + .stats + .avg_rating + .map(|r| format!("{:.1}", r)) + .unwrap_or_else(|| "\u{2014}".to_string()); + let favorite_director_display = profile + .stats + .favorite_director + .clone() + .unwrap_or_else(|| "\u{2014}".to_string()); + let most_active_month_display = profile + .stats + .most_active_month + .clone() + .unwrap_or_else(|| "\u{2014}".to_string()); + let heatmap = profile + .history + .as_deref() + .map(build_heatmap) + .unwrap_or_default(); + let monthly_rating_rows: Vec> = profile + .trends + .as_ref() + .map(|t| { + t.monthly_ratings + .iter() + .map(|r| MonthlyRatingRow { + rating: r, + bar_height_px: bar_height_px(r.avg_rating), + }) + .collect() + }) + .unwrap_or_default(); + let total = profile + .entries + .as_ref() + .map(|e| e.total_count as u32) + .unwrap_or(0); + let total_pages = total + .saturating_add(limit.saturating_sub(1)) + .checked_div(limit) + .unwrap_or(1); + let current_page = offset.checked_div(limit).unwrap_or(0); + let page_items = build_page_items(total_pages, current_page); + let pending_followers: Vec = profile + .pending_followers + .iter() + .map(crate::mappers::users::pending_follower_data) + .collect(); + if params.embed { + let profile_url = format!( + "{}/users/{}", + state.app_ctx.config.base_url, profile_user_uuid + ); + let response = render_page(EmbedProfileTemplate { + profile_display_name: display_name, + profile_user_id: profile_user_uuid, + profile_url, + stats: &profile.stats, + avg_rating_display, + favorite_director_display, + most_active_month_display, + view: profile_view.as_str(), + entries: profile.entries.as_ref(), + current_offset: offset, + has_more, + limit, + history: profile.history.as_ref(), + trends: profile.trends.as_ref(), + monthly_rating_rows, + heatmap, + page_items, + sort_by: sort_by_str.to_string(), + }); + let mut resp = response.into_response(); + resp.headers_mut().remove("x-frame-options"); + resp + } else { + render_page(ProfileTemplate { + ctx: &ctx, + profile_display_name: display_name, + profile_user_id: profile_user_uuid, + stats: &profile.stats, + avg_rating_display, + favorite_director_display, + most_active_month_display, + view: profile_view.as_str(), + entries: profile.entries.as_ref(), + current_offset: offset, + has_more, + limit, + history: profile.history.as_ref(), + trends: profile.trends.as_ref(), + monthly_rating_rows, + heatmap, + page_items, + is_own_profile, + error: params.error, + following_count: profile.following_count, + followers_count: profile.followers_count, + pending_followers, + sort_by: sort_by_str.to_string(), + search: params.search.clone(), + goals: { + let goals_list = application::goals::list::execute( + &state.app_ctx, + application::goals::queries::ListGoalsQuery { + user_id: profile_user_uuid, + }, + ) + .await + .unwrap_or_default(); + goals_list + .iter() + .map(|g| template_askama::GoalViewData { + year: g.goal.year(), + target_count: g.goal.target_count(), + current_count: g.current_count, + percentage: g.percentage().round(), + is_complete: g.is_complete(), + }) + .collect() + }, + }) + .into_response() + } + } + Err(e) => crate::errors::domain_error_response(e), + } +} + +#[derive(serde::Deserialize, Default)] +pub struct SavedQuery { + pub saved: Option, +} + +pub async fn get_profile_settings( + RequiredCookieUser(user_id): RequiredCookieUser, + State(state): State, + Query(params): Query, + Extension(csrf): Extension, +) -> impl IntoResponse { + let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await; + ctx.page_title = "Profile Settings — Movies Diary".to_string(); + ctx.canonical_url = format!("{}/settings/profile", state.app_ctx.config.base_url); + + let user = match state.app_ctx.repos.user.find_by_id(&user_id).await { + Ok(Some(u)) => u, + Ok(None) => return StatusCode::NOT_FOUND.into_response(), + Err(e) => return crate::errors::domain_error_response(e), + }; + + let base_url = &state.app_ctx.config.base_url; + let avatar_url = user + .avatar_path() + .map(|path| format!("{}/images/{}", base_url, path)); + let banner_url = user + .banner_path() + .map(|path| format!("{}/images/{}", base_url, path)); + + let profile_fields: Vec<(String, String)> = state + .app_ctx + .repos + .profile_fields + .get_fields(&user_id) + .await + .unwrap_or_default() + .into_iter() + .map(|f| (f.name, f.value)) + .collect(); + + let saved = params.saved.as_deref() == Some("1"); + + let bio = user.bio().map(|s| s.to_string()); + let also_known_as = user.also_known_as().map(|s| s.to_string()); + + render_page(ProfileSettingsTemplate { + ctx: &ctx, + bio: bio.as_deref(), + avatar_url: avatar_url.as_deref(), + banner_url: banner_url.as_deref(), + also_known_as: also_known_as.as_deref(), + profile_fields: &profile_fields, + saved, + embed_url: format!( + "{}/users/{}?embed=true", + state.app_ctx.config.base_url, + user_id.value() + ), + }) + .into_response() +} + +pub async fn post_profile_settings( + RequiredCookieUser(user_id): RequiredCookieUser, + State(state): State, + mut multipart: Multipart, +) -> impl IntoResponse { + let mut display_name: Option = None; + let mut bio: Option = None; + let mut avatar_bytes: Option> = None; + let mut avatar_content_type: Option = None; + let mut banner_bytes: Option> = None; + let mut banner_content_type: Option = None; + let mut also_known_as: Option = None; + let mut field_names: std::collections::HashMap = + std::collections::HashMap::new(); + let mut field_values: std::collections::HashMap = + std::collections::HashMap::new(); + + while let Ok(Some(field)) = multipart.next_field().await { + let name = field.name().unwrap_or("").to_string(); + match name.as_str() { + "display_name" => { + if let Ok(text) = field.text().await { + display_name = Some(text).filter(|s| !s.is_empty()); + } + } + "bio" => { + if let Ok(text) = field.text().await { + bio = Some(text); + } + } + "also_known_as" => { + if let Ok(text) = field.text().await { + also_known_as = Some(text).filter(|s| !s.is_empty()); + } + } + "avatar" => { + let ct = field.content_type().map(|s| s.to_string()); + if let Ok(bytes) = field.bytes().await + && !bytes.is_empty() + { + avatar_bytes = Some(bytes.to_vec()); + avatar_content_type = ct; + } + } + "banner" => { + let ct = field.content_type().map(|s| s.to_string()); + if let Ok(bytes) = field.bytes().await + && !bytes.is_empty() + { + banner_bytes = Some(bytes.to_vec()); + banner_content_type = ct; + } + } + n if n.starts_with("field_name_") => { + if let Ok(idx) = n["field_name_".len()..].parse::() + && let Ok(text) = field.text().await + && !text.is_empty() + { + field_names.insert(idx, text); + } + } + n if n.starts_with("field_value_") => { + if let Ok(idx) = n["field_value_".len()..].parse::() + && let Ok(text) = field.text().await + && !text.is_empty() + { + field_values.insert(idx, text); + } + } + _ => {} + } + } + + let cmd = application::users::commands::UpdateProfileCommand { + user_id: user_id.value(), + display_name, + bio, + avatar_bytes, + avatar_content_type, + banner_bytes, + banner_content_type, + also_known_as, + }; + let _ = update_profile::execute(&state.app_ctx, cmd).await; + + let fields: Vec = (0..4) + .filter_map(|i| { + field_names + .get(&i) + .map(|name| domain::models::ProfileField { + name: name.clone(), + value: field_values.get(&i).cloned().unwrap_or_default(), + }) + }) + .collect(); + + let fields_cmd = application::users::commands::UpdateProfileFieldsCommand { + user_id: user_id.value(), + fields, + }; + let _ = update_profile_fields::execute(&state.app_ctx, fields_cmd).await; + + axum::response::Redirect::to("/settings/profile?saved=1").into_response() +} diff --git a/crates/presentation/src/handlers/watchlist.rs b/crates/presentation/src/handlers/watchlist.rs new file mode 100644 index 0000000..2fdb840 --- /dev/null +++ b/crates/presentation/src/handlers/watchlist.rs @@ -0,0 +1,311 @@ +use axum::{ + Form, Json, + extract::{Extension, Path, Query, State}, + http::StatusCode, + response::{IntoResponse, Redirect}, +}; +use uuid::Uuid; + +use application::{ + diary::commands::MovieInput, + watchlist::{ + add as add_to_watchlist, + commands::{AddToWatchlistCommand, RemoveFromWatchlistCommand}, + get as get_watchlist, is_on as is_on_watchlist, + queries::{GetWatchlistQuery, IsOnWatchlistQuery}, + remove as remove_from_watchlist, + }, +}; +use domain::errors::DomainError; + +use crate::{ + csrf::CsrfToken, + errors::ApiError, + extractors::{AuthenticatedUser, OptionalCookieUser, RequiredCookieUser}, + render::render_page, + state::AppState, +}; +use api_types::{ + AddToWatchlistRequest, PaginationQueryParams, WatchlistEntryDto, WatchlistResponse, + WatchlistStatusResponse, +}; +use template_askama::WatchlistTemplate; + +use super::helpers::build_page_context; + +fn encode_error(msg: &str) -> String { + use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; + utf8_percent_encode(msg, NON_ALPHANUMERIC).to_string() +} + +// ── API ────────────────────────────────────────────────────────────────────── + +#[utoipa::path( + get, path = "/api/v1/watchlist", + params( + ("limit" = Option, Query, description = "Max results"), + ("offset" = Option, Query, description = "Offset"), + ), + responses( + (status = 200, body = WatchlistResponse), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_watchlist_handler( + State(state): State, + user: AuthenticatedUser, + Query(params): Query, +) -> Result, ApiError> { + let page = get_watchlist::execute( + &state.app_ctx, + GetWatchlistQuery { + user_id: user.0.value(), + limit: params.limit, + offset: params.offset, + }, + ) + .await?; + + Ok(Json(WatchlistResponse { + items: page + .items + .into_iter() + .map(|w| WatchlistEntryDto { + id: w.entry.id.value(), + movie: crate::mappers::movies::movie_to_dto(&w.movie), + added_at: w.entry.added_at.to_string(), + }) + .collect(), + total_count: page.total_count, + limit: page.limit, + offset: page.offset, + })) +} + +#[utoipa::path( + post, path = "/api/v1/watchlist", + request_body = AddToWatchlistRequest, + responses( + (status = 201, description = "Added to watchlist"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Movie not found"), + ), + security(("bearer_auth" = [])) +)] +pub async fn post_watchlist_add( + State(state): State, + user: AuthenticatedUser, + Json(req): Json, +) -> Result { + add_to_watchlist::execute( + &state.app_ctx, + AddToWatchlistCommand { + user_id: user.0.value(), + input: MovieInput { + movie_id: req.movie_id, + external_metadata_id: req.external_metadata_id, + manual_title: req.manual_title, + manual_release_year: req.manual_release_year, + manual_director: None, + }, + }, + ) + .await?; + Ok(StatusCode::CREATED) +} + +#[utoipa::path( + delete, path = "/api/v1/watchlist/{movie_id}", + params(("movie_id" = Uuid, Path, description = "Movie ID")), + responses( + (status = 204, description = "Removed from watchlist"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not on watchlist"), + ), + security(("bearer_auth" = [])) +)] +pub async fn delete_watchlist_entry( + State(state): State, + user: AuthenticatedUser, + Path(movie_id): Path, +) -> Result { + remove_from_watchlist::execute( + &state.app_ctx, + RemoveFromWatchlistCommand { + user_id: user.0.value(), + movie_id, + }, + ) + .await?; + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path( + get, path = "/api/v1/watchlist/{movie_id}", + params(("movie_id" = Uuid, Path, description = "Movie ID")), + responses( + (status = 200, body = WatchlistStatusResponse), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_watchlist_status( + State(state): State, + user: AuthenticatedUser, + Path(movie_id): Path, +) -> Result, ApiError> { + let on_watchlist = is_on_watchlist::execute( + &state.app_ctx, + IsOnWatchlistQuery { + user_id: user.0.value(), + movie_id, + }, + ) + .await?; + Ok(Json(WatchlistStatusResponse { on_watchlist })) +} + +// ── HTML ───────────────────────────────────────────────────────────────────── + +pub async fn get_watchlist_page( + OptionalCookieUser(viewer_id): OptionalCookieUser, + State(state): State, + Path(owner_id): Path, + Query(params): Query, + Extension(csrf): Extension, +) -> impl IntoResponse { + let ctx = build_page_context(&state, viewer_id.clone(), csrf.0).await; + let is_owner = viewer_id.map(|u| u.value() == owner_id).unwrap_or(false); + + let result = match application::watchlist::get_page::execute( + &state.app_ctx, + application::watchlist::queries::GetWatchlistQuery { + user_id: owner_id, + limit: params.limit.or(Some(20)), + offset: params.offset.or(Some(0)), + }, + is_owner, + ) + .await + { + Ok(r) => r, + Err(e) => return crate::errors::domain_error_response(e), + }; + + render_page(WatchlistTemplate { + ctx: &ctx, + owner_id, + display_entries: &result.display_entries, + current_offset: result.current_offset, + has_more: result.has_more, + limit: result.limit, + is_owner, + error: params.error, + }) + .into_response() +} + +pub async fn post_watchlist_add_html( + State(state): State, + RequiredCookieUser(user_id): RequiredCookieUser, + Extension(csrf): Extension, + Form(form): Form, +) -> impl IntoResponse { + if crate::csrf::mismatch(&csrf, &form.csrf_token) { + return StatusCode::FORBIDDEN.into_response(); + } + + let redirect_base = form + .redirect_after + .as_deref() + .filter(|u| u.starts_with('/') && !u.starts_with("//")) + .unwrap_or("/") + .to_string(); + + let input = if let Some(id) = form.movie_id { + MovieInput { + movie_id: Some(id), + external_metadata_id: None, + manual_title: None, + manual_release_year: None, + manual_director: None, + } + } else { + let query = form.query.as_deref().unwrap_or("").trim().to_string(); + let is_external_id = query.starts_with("tmdb:") + || (query.starts_with("tt") + && query.len() > 2 + && query[2..].chars().all(|c| c.is_ascii_digit())); + if is_external_id { + MovieInput { + movie_id: None, + external_metadata_id: Some(query), + manual_title: None, + manual_release_year: None, + manual_director: None, + } + } else { + MovieInput { + movie_id: None, + external_metadata_id: None, + manual_title: if query.is_empty() { None } else { Some(query) }, + manual_release_year: form.year, + manual_director: None, + } + } + }; + + match add_to_watchlist::execute( + &state.app_ctx, + AddToWatchlistCommand { + user_id: user_id.value(), + input, + }, + ) + .await + { + Ok(()) => Redirect::to(&redirect_base).into_response(), + Err(DomainError::NotFound(_)) => Redirect::to(&redirect_base).into_response(), + Err(DomainError::ValidationError(msg)) => { + let sep = if redirect_base.contains('?') { + '&' + } else { + '?' + }; + let url = format!("{}{}error={}", redirect_base, sep, encode_error(&msg)); + Redirect::to(&url).into_response() + } + Err(e) => crate::errors::domain_error_response(e), + } +} + +pub async fn post_watchlist_remove_html( + State(state): State, + RequiredCookieUser(user_id): RequiredCookieUser, + Extension(csrf): Extension, + Path(movie_id): Path, + Form(form): Form, +) -> impl IntoResponse { + if crate::csrf::mismatch(&csrf, &form.csrf_token) { + return StatusCode::FORBIDDEN.into_response(); + } + match remove_from_watchlist::execute( + &state.app_ctx, + RemoveFromWatchlistCommand { + user_id: user_id.value(), + movie_id, + }, + ) + .await + { + Ok(()) | Err(DomainError::NotFound(_)) => { + let redirect_url = form + .redirect_after + .filter(|u| u.starts_with('/') && !u.starts_with("//")) + .unwrap_or_else(|| "/".to_string()); + Redirect::to(&redirect_url).into_response() + } + Err(e) => crate::errors::domain_error_response(e), + } +} diff --git a/crates/presentation/src/handlers/wrapup.rs b/crates/presentation/src/handlers/wrapup.rs index e957a3d..3fac829 100644 --- a/crates/presentation/src/handlers/wrapup.rs +++ b/crates/presentation/src/handlers/wrapup.rs @@ -302,7 +302,7 @@ pub async fn get_user_wrapup_html( }; let video_url = format!("/api/v1/wrapups/{}/video", record.id.value()); - let ctx = super::html::build_page_context(&state, viewer, csrf.0).await; + let ctx = super::helpers::build_page_context(&state, viewer, csrf.0).await; render_wrapup(&report, year, &ctx, Some(video_url)) } @@ -338,6 +338,6 @@ pub async fn get_global_wrapup_html( }; let video_url = format!("/api/v1/wrapups/{}/video", record.id.value()); - let ctx = super::html::build_page_context(&state, viewer, csrf.0).await; + let ctx = super::helpers::build_page_context(&state, viewer, csrf.0).await; render_wrapup(&report, year, &ctx, Some(video_url)) } diff --git a/crates/presentation/src/openapi/auth.rs b/crates/presentation/src/openapi/auth.rs index 689b09a..8e9ff83 100644 --- a/crates/presentation/src/openapi/auth.rs +++ b/crates/presentation/src/openapi/auth.rs @@ -3,7 +3,7 @@ use utoipa::OpenApi; #[derive(OpenApi)] #[openapi( - paths(crate::handlers::api::login, crate::handlers::api::register,), + paths(crate::handlers::auth::login, crate::handlers::auth::register,), components(schemas(LoginRequest, LoginResponse, RegisterRequest)) )] pub struct AuthDoc; diff --git a/crates/presentation/src/openapi/diary.rs b/crates/presentation/src/openapi/diary.rs index a1a6035..971cf3d 100644 --- a/crates/presentation/src/openapi/diary.rs +++ b/crates/presentation/src/openapi/diary.rs @@ -6,11 +6,11 @@ use utoipa::OpenApi; #[derive(OpenApi)] #[openapi( paths( - crate::handlers::api::get_diary, - crate::handlers::api::post_review, - crate::handlers::api::delete_review, - crate::handlers::api::export_diary, - crate::handlers::api::get_activity_feed, + crate::handlers::diary::get_diary, + crate::handlers::diary::post_review, + crate::handlers::diary::delete_review, + crate::handlers::diary::export_diary, + crate::handlers::diary::get_activity_feed, ), components(schemas( DiaryResponse, diff --git a/crates/presentation/src/openapi/movies.rs b/crates/presentation/src/openapi/movies.rs index 1082fe1..edcecb1 100644 --- a/crates/presentation/src/openapi/movies.rs +++ b/crates/presentation/src/openapi/movies.rs @@ -8,11 +8,11 @@ use utoipa::OpenApi; #[derive(OpenApi)] #[openapi( paths( - crate::handlers::api::list_movies, - crate::handlers::api::get_movie_detail, - crate::handlers::api::get_review_history, - crate::handlers::api::get_movie_profile, - crate::handlers::api::sync_poster, + crate::handlers::movies::list_movies, + crate::handlers::movies::get_movie_detail, + crate::handlers::movies::get_review_history, + crate::handlers::movies::get_movie_profile, + crate::handlers::movies::sync_poster, ), components(schemas( MoviesResponse, diff --git a/crates/presentation/src/openapi/search.rs b/crates/presentation/src/openapi/search.rs index 8b96774..8c9fa01 100644 --- a/crates/presentation/src/openapi/search.rs +++ b/crates/presentation/src/openapi/search.rs @@ -7,9 +7,9 @@ use utoipa::OpenApi; #[derive(OpenApi)] #[openapi( paths( - crate::handlers::api::get_search, - crate::handlers::api::get_person_handler, - crate::handlers::api::get_person_credits_handler, + crate::handlers::search::get_search, + crate::handlers::search::get_person_handler, + crate::handlers::search::get_person_credits_handler, ), components(schemas( SearchResponse, diff --git a/crates/presentation/src/openapi/social.rs b/crates/presentation/src/openapi/social.rs index df52231..d72b65f 100644 --- a/crates/presentation/src/openapi/social.rs +++ b/crates/presentation/src/openapi/social.rs @@ -10,20 +10,20 @@ use utoipa::OpenApi; #[derive(OpenApi)] #[openapi( paths( - crate::handlers::api::get_following, - crate::handlers::api::get_followers, - crate::handlers::api::get_pending_followers, - crate::handlers::api::follow, - crate::handlers::api::unfollow, - crate::handlers::api::accept_follower, - crate::handlers::api::reject_follower, - crate::handlers::api::remove_follower, - crate::handlers::api::get_blocked_domains_admin, - crate::handlers::api::add_blocked_domain_admin, - crate::handlers::api::remove_blocked_domain_admin, - crate::handlers::api::block_actor_api, - crate::handlers::api::unblock_actor_api, - crate::handlers::api::get_blocked_actors_api, + crate::handlers::social::get_following, + crate::handlers::social::get_followers, + crate::handlers::social::get_pending_followers, + crate::handlers::social::follow, + crate::handlers::social::unfollow, + crate::handlers::social::accept_follower, + crate::handlers::social::reject_follower, + crate::handlers::social::remove_follower, + crate::handlers::social::get_blocked_domains_admin, + crate::handlers::social::add_blocked_domain_admin, + crate::handlers::social::remove_blocked_domain_admin, + crate::handlers::social::block_actor_api, + crate::handlers::social::unblock_actor_api, + crate::handlers::social::get_blocked_actors_api, ), components(schemas( ActorListResponse, diff --git a/crates/presentation/src/openapi/users.rs b/crates/presentation/src/openapi/users.rs index 0686708..ef64c98 100644 --- a/crates/presentation/src/openapi/users.rs +++ b/crates/presentation/src/openapi/users.rs @@ -7,11 +7,11 @@ use utoipa::OpenApi; #[derive(OpenApi)] #[openapi( paths( - crate::handlers::api::list_users, - crate::handlers::api::get_user_profile, - crate::handlers::api::get_profile, - crate::handlers::api::update_profile_handler, - crate::handlers::api::update_profile_fields_handler, + crate::handlers::users::list_users, + crate::handlers::users::get_user_profile, + crate::handlers::users::get_profile, + crate::handlers::users::update_profile_handler, + crate::handlers::users::update_profile_fields_handler, ), components(schemas( UsersResponse, diff --git a/crates/presentation/src/openapi/watchlist.rs b/crates/presentation/src/openapi/watchlist.rs index 724cd72..5573c28 100644 --- a/crates/presentation/src/openapi/watchlist.rs +++ b/crates/presentation/src/openapi/watchlist.rs @@ -6,10 +6,10 @@ use utoipa::OpenApi; #[derive(OpenApi)] #[openapi( paths( - crate::handlers::api::get_watchlist_handler, - crate::handlers::api::post_watchlist_add, - crate::handlers::api::delete_watchlist_entry, - crate::handlers::api::get_watchlist_status, + crate::handlers::watchlist::get_watchlist_handler, + crate::handlers::watchlist::post_watchlist_add, + crate::handlers::watchlist::delete_watchlist_entry, + crate::handlers::watchlist::get_watchlist_status, ), components(schemas( WatchlistResponse, diff --git a/crates/presentation/src/routes.rs b/crates/presentation/src/routes.rs index 358d034..cadc2a6 100644 --- a/crates/presentation/src/routes.rs +++ b/crates/presentation/src/routes.rs @@ -48,12 +48,12 @@ fn html_routes(rate_limit: u64) -> Router { let auth = Router::new() .route( "/login", - routing::get(handlers::html::get_login_page).post(handlers::html::post_login), + routing::get(handlers::auth::get_login_page).post(handlers::auth::post_login), ) - .route("/logout", routing::get(handlers::html::get_logout)) + .route("/logout", routing::get(handlers::auth::get_logout)) .route( "/register", - routing::get(handlers::html::get_register_page).post(handlers::html::post_register), + routing::get(handlers::auth::get_register_page).post(handlers::auth::post_register), ) .layer({ let cfg = GovernorConfigBuilder::default() @@ -66,29 +66,29 @@ fn html_routes(rate_limit: u64) -> Router { }); let base = Router::new() - .route("/", routing::get(handlers::html::get_activity_feed)) - .route("/users", routing::get(handlers::html::get_users_list)) + .route("/", routing::get(handlers::diary::get_activity_feed_html)) + .route("/users", routing::get(handlers::users::get_users_list)) .route( "/u/{username}", - routing::get(handlers::html::get_user_by_username), + routing::get(handlers::users::get_user_by_username), ) .route( "/users/{id}", - routing::get(handlers::html::get_user_profile), + routing::get(handlers::users::get_user_profile_html), ) .route( "/movies/{movie_id}", - routing::get(handlers::html::get_movie_detail), + routing::get(handlers::movies::get_movie_detail_html), ) .merge(auth) .route( "/reviews/new", - routing::get(handlers::html::get_new_review_page), + routing::get(handlers::diary::get_new_review_page), ) - .route("/reviews", routing::post(handlers::html::post_review)) + .route("/reviews", routing::post(handlers::diary::post_review_html)) .route( "/reviews/{id}/delete", - routing::post(handlers::html::post_delete_review), + routing::post(handlers::diary::post_delete_review_html), ) .route("/images/{*key}", routing::get(handlers::images::get_image)) .route( @@ -99,7 +99,10 @@ fn html_routes(rate_limit: u64) -> Router { }, ), ) - .route("/diary/export", routing::get(handlers::html::get_export)) + .route( + "/diary/export", + routing::get(handlers::diary::get_export_html), + ) .route("/import", routing::get(handlers::import::get_import_page)) .route( "/import/upload", @@ -132,45 +135,45 @@ fn html_routes(rate_limit: u64) -> Router { ) .route( "/settings/profile", - routing::get(handlers::html::get_profile_settings) - .post(handlers::html::post_profile_settings), + routing::get(handlers::users::get_profile_settings) + .post(handlers::users::post_profile_settings), ) - .route("/tags/{tag}", routing::get(handlers::html::get_tag)) + .route("/tags/{tag}", routing::get(handlers::search::get_tag)) .route( "/users/{id}/watchlist", - routing::get(handlers::html::get_watchlist_page), + routing::get(handlers::watchlist::get_watchlist_page), ) .route( "/watchlist/add", - routing::post(handlers::html::post_watchlist_add), + routing::post(handlers::watchlist::post_watchlist_add_html), ) .route( "/watchlist/{movie_id}/remove", - routing::post(handlers::html::post_watchlist_remove), + routing::post(handlers::watchlist::post_watchlist_remove_html), ) .route( "/settings/integrations", - routing::get(handlers::html::get_integrations_page), + routing::get(handlers::integrations::get_integrations_page), ) .route( "/settings/integrations/generate", - routing::post(handlers::html::post_generate_token), + routing::post(handlers::integrations::post_generate_token), ) .route( "/settings/integrations/{id}/revoke", - routing::post(handlers::html::post_revoke_token), + routing::post(handlers::integrations::post_revoke_token), ) .route( "/watch-queue", - routing::get(handlers::html::get_watch_queue_page), + routing::get(handlers::integrations::get_watch_queue_page), ) .route( "/watch-queue/{id}/confirm", - routing::post(handlers::html::post_confirm_single), + routing::post(handlers::integrations::post_confirm_single), ) .route( "/watch-queue/{id}/dismiss", - routing::post(handlers::html::post_dismiss_single), + routing::post(handlers::integrations::post_dismiss_single), ) .route( "/wrapups/{user_id}/{year}", @@ -192,60 +195,60 @@ fn federation_html_routes() -> Router { Router::new() .route( "/users/{id}/follow", - routing::post(handlers::html::follow_remote_user), + routing::post(handlers::social::follow_remote_user), ) .route( "/users/{id}/unfollow", - routing::post(handlers::html::unfollow_remote_user), + routing::post(handlers::social::unfollow_remote_user), ) .route( "/users/{id}/followers/accept", - routing::post(handlers::html::accept_follower), + routing::post(handlers::social::accept_follower_html), ) .route( "/users/{id}/followers/reject", - routing::post(handlers::html::reject_follower), + routing::post(handlers::social::reject_follower_html), ) .route( "/users/{id}/followers", - routing::get(handlers::html::get_followers_collection), + routing::get(handlers::social::get_followers_collection), ) .route( "/users/{id}/following", - routing::get(handlers::html::get_following_collection), + routing::get(handlers::social::get_following_collection), ) .route( "/users/{id}/following-list", - routing::get(handlers::html::get_following_page), + routing::get(handlers::social::get_following_page), ) .route( "/users/{id}/followers-list", - routing::get(handlers::html::get_followers_page), + routing::get(handlers::social::get_followers_page), ) .route( "/users/{id}/followers/remove", - routing::post(handlers::html::remove_follower), + routing::post(handlers::social::remove_follower_html), ) .route( "/admin/blocked-domains", - routing::get(handlers::html::get_blocked_domains_page) - .post(handlers::html::post_blocked_domain), + routing::get(handlers::social::get_blocked_domains_page) + .post(handlers::social::post_blocked_domain), ) .route( "/admin/blocked-domains/remove", - routing::post(handlers::html::post_remove_blocked_domain), + routing::post(handlers::social::post_remove_blocked_domain), ) .route( "/social/blocked", - routing::get(handlers::html::get_blocked_actors_page), + routing::get(handlers::social::get_blocked_actors_page), ) .route( "/social/block", - routing::post(handlers::html::post_block_actor_html), + routing::post(handlers::social::post_block_actor_html), ) .route( "/social/unblock", - routing::post(handlers::html::post_unblock_actor), + routing::post(handlers::social::post_unblock_actor), ) } @@ -298,45 +301,40 @@ fn api_routes(rate_limit: u64) -> Router { .unwrap(); let base = Router::new() - .route("/diary", routing::get(handlers::api::get_diary)) + .route("/diary", routing::get(handlers::diary::get_diary)) .route( "/movies/{id}/history", - routing::get(handlers::api::get_review_history), + routing::get(handlers::movies::get_review_history), ) - .route("/movies", routing::get(handlers::api::list_movies)) + .route("/movies", routing::get(handlers::movies::list_movies)) .route( "/movies/{id}", - routing::get(handlers::api::get_movie_detail), + routing::get(handlers::movies::get_movie_detail), ) .route( "/movies/{id}/profile", - routing::get(handlers::api::get_movie_profile), + routing::get(handlers::movies::get_movie_profile), ) - .route("/reviews", routing::post(handlers::api::post_review)) + .route("/reviews", routing::post(handlers::diary::post_review)) .route( "/reviews/{id}", - routing::delete(handlers::api::delete_review), + routing::delete(handlers::diary::delete_review), ) .route( "/movies/{id}/sync-poster", - routing::post(handlers::api::sync_poster), + routing::post(handlers::movies::sync_poster), ) - .route("/auth/login", routing::post(handlers::api::login)) - .route("/auth/register", routing::post(handlers::api::register)) - .route("/diary/export", routing::get(handlers::api::export_diary)) + .route("/auth/login", routing::post(handlers::auth::login)) + .route("/auth/register", routing::post(handlers::auth::register)) + .route("/diary/export", routing::get(handlers::diary::export_diary)) .route( "/activity-feed", - routing::get(handlers::api::get_activity_feed), + routing::get(handlers::diary::get_activity_feed), ) - .route("/users", routing::get(handlers::api::list_users)) - .route("/users/{id}", routing::get(handlers::api::get_user_profile)) + .route("/users", routing::get(handlers::users::list_users)) .route( - "/users/{id}/following", - routing::get(handlers::api::get_user_following), - ) - .route( - "/users/{id}/followers", - routing::get(handlers::api::get_user_followers), + "/users/{id}", + routing::get(handlers::users::get_user_profile), ) .route( "/import/sessions", @@ -369,30 +367,30 @@ fn api_routes(rate_limit: u64) -> Router { ) .route( "/profile", - routing::get(handlers::api::get_profile).put(handlers::api::update_profile_handler), + routing::get(handlers::users::get_profile).put(handlers::users::update_profile_handler), ) .route( "/profile/fields", - routing::put(handlers::api::update_profile_fields_handler), + routing::put(handlers::users::update_profile_fields_handler), ) - .route("/search", routing::get(handlers::api::get_search)) + .route("/search", routing::get(handlers::search::get_search)) .route( "/people/{id}", - routing::get(handlers::api::get_person_handler), + routing::get(handlers::search::get_person_handler), ) .route( "/people/{id}/credits", - routing::get(handlers::api::get_person_credits_handler), + routing::get(handlers::search::get_person_credits_handler), ) .route( "/watchlist", - routing::get(handlers::api::get_watchlist_handler) - .post(handlers::api::post_watchlist_add), + routing::get(handlers::watchlist::get_watchlist_handler) + .post(handlers::watchlist::post_watchlist_add), ) .route( "/watchlist/{movie_id}", - routing::get(handlers::api::get_watchlist_status) - .delete(handlers::api::delete_watchlist_entry), + routing::get(handlers::watchlist::get_watchlist_status) + .delete(handlers::watchlist::delete_watchlist_entry), ) .route( "/settings/webhook-tokens", @@ -435,23 +433,23 @@ fn api_routes(rate_limit: u64) -> Router { ) .route( "/admin/reindex-search", - routing::post(handlers::api::post_reindex_search), + routing::post(handlers::search::post_reindex_search), ) .route( "/goals", - routing::get(handlers::api::list_goals).post(handlers::api::create_goal), + routing::get(handlers::goals::list_goals).post(handlers::goals::create_goal), ) .route( "/goals/{year}", - routing::put(handlers::api::update_goal).delete(handlers::api::delete_goal), + routing::put(handlers::goals::update_goal).delete(handlers::goals::delete_goal), ) .route( "/users/{id}/goals", - routing::get(handlers::api::get_user_goals), + routing::get(handlers::goals::get_user_goals), ) .route( "/settings", - routing::get(handlers::api::get_settings).put(handlers::api::update_settings), + routing::get(handlers::goals::get_settings).put(handlers::goals::update_settings), ); #[cfg(feature = "federation")] @@ -485,49 +483,60 @@ fn federation_api_routes() -> Router { Router::new() .route( "/social/following", - routing::get(handlers::api::get_following), + routing::get(handlers::social::get_following), ) .route( "/social/followers", - routing::get(handlers::api::get_followers), + routing::get(handlers::social::get_followers), ) .route( "/social/followers/pending", - routing::get(handlers::api::get_pending_followers), + routing::get(handlers::social::get_pending_followers), + ) + .route("/social/follow", routing::post(handlers::social::follow)) + .route( + "/social/unfollow", + routing::post(handlers::social::unfollow), ) - .route("/social/follow", routing::post(handlers::api::follow)) - .route("/social/unfollow", routing::post(handlers::api::unfollow)) .route( "/social/followers/accept", - routing::post(handlers::api::accept_follower), + routing::post(handlers::social::accept_follower), ) .route( "/social/followers/reject", - routing::post(handlers::api::reject_follower), + routing::post(handlers::social::reject_follower), ) .route( "/social/followers/remove", - routing::post(handlers::api::remove_follower), + routing::post(handlers::social::remove_follower), ) .route( "/admin/blocked-domains", - routing::get(handlers::api::get_blocked_domains_admin) - .post(handlers::api::add_blocked_domain_admin), + routing::get(handlers::social::get_blocked_domains_admin) + .post(handlers::social::add_blocked_domain_admin), ) .route( "/admin/blocked-domains/{domain}", - routing::delete(handlers::api::remove_blocked_domain_admin), + routing::delete(handlers::social::remove_blocked_domain_admin), ) .route( "/social/block", - routing::post(handlers::api::block_actor_api), + routing::post(handlers::social::block_actor_api), ) .route( "/social/unblock", - routing::post(handlers::api::unblock_actor_api), + routing::post(handlers::social::unblock_actor_api), ) .route( "/social/blocked", - routing::get(handlers::api::get_blocked_actors_api), + routing::get(handlers::social::get_blocked_actors_api), + ) + .route( + "/users/{id}/following", + routing::get(handlers::social::get_user_following), + ) + .route( + "/users/{id}/followers", + routing::get(handlers::social::get_user_followers), ) } diff --git a/crates/presentation/src/tests/api_handlers.rs b/crates/presentation/src/tests/api_handlers.rs index e8745ed..2fc95a1 100644 --- a/crates/presentation/src/tests/api_handlers.rs +++ b/crates/presentation/src/tests/api_handlers.rs @@ -77,7 +77,7 @@ async fn search_endpoint_returns_200_with_empty_results() { // Override the search_port with our stub state.app_ctx.repos.search_port = Arc::new(SearchPortStub); let app = Router::new() - .route("/api/v1/search", get(crate::handlers::api::get_search)) + .route("/api/v1/search", get(crate::handlers::search::get_search)) .with_state(state); let resp = app @@ -99,7 +99,7 @@ async fn search_endpoint_with_no_query_returns_200() { // Override the search_port with our stub state.app_ctx.repos.search_port = Arc::new(SearchPortStub); let app = Router::new() - .route("/api/v1/search", get(crate::handlers::api::get_search)) + .route("/api/v1/search", get(crate::handlers::search::get_search)) .with_state(state); let resp = app @@ -125,7 +125,7 @@ async fn person_endpoint_returns_404_for_unknown_id() { let app = Router::new() .route( "/api/v1/people/{id}", - get(crate::handlers::api::get_person_handler), + get(crate::handlers::search::get_person_handler), ) .with_state(state); @@ -151,7 +151,7 @@ async fn person_credits_endpoint_returns_404_for_unknown_id() { let app = Router::new() .route( "/api/v1/people/{id}/credits", - get(crate::handlers::api::get_person_credits_handler), + get(crate::handlers::search::get_person_credits_handler), ) .with_state(state); @@ -177,7 +177,7 @@ async fn get_watchlist_requires_auth() { let app = Router::new() .route( "/api/v1/watchlist", - get(crate::handlers::api::get_watchlist_handler), + get(crate::handlers::watchlist::get_watchlist_handler), ) .with_state(state); @@ -200,7 +200,7 @@ async fn get_watchlist_status_requires_auth() { let app = Router::new() .route( "/api/v1/watchlist/{movie_id}", - get(crate::handlers::api::get_watchlist_status), + get(crate::handlers::watchlist::get_watchlist_status), ) .with_state(state);