refactor: split monolithic handlers + testing into domain-grouped modules
Some checks failed
CI / Check / Test (push) Has been cancelled

handlers/api.rs (1706 LOC) + html.rs (1735 LOC) → 12 domain files:
auth, diary, movies, users, search, watchlist, goals, social,
integrations, helpers + existing import/webhook/wrapup/images/rss.

domain/testing.rs (1309 LOC) → testing/ module:
in_memory, fakes, noops, panics, wrapup.

Update README + architecture.mmd with goals feature.
This commit is contained in:
2026-06-08 23:59:23 +02:00
parent 988e15eac6
commit a7a11dde08
33 changed files with 5066 additions and 4891 deletions

View File

@@ -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 - 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` - 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 - 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 - 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 - 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 - REST API v1 (`/api/v1/`) with full feature parity with the HTML interface

View File

@@ -19,6 +19,7 @@ graph TB
UC_USERS["users<br/>get_users, get_profile,<br/>update_profile"] UC_USERS["users<br/>get_users, get_profile,<br/>update_profile"]
UC_WATCHLIST["watchlist<br/>add, remove, get"] UC_WATCHLIST["watchlist<br/>add, remove, get"]
UC_WRAPUP["wrapup<br/>generate, compute,<br/>list, delete"] UC_WRAPUP["wrapup<br/>generate, compute,<br/>list, delete"]
UC_GOALS["goals<br/>create, update, delete,<br/>get, list"]
UC_INTEGRATIONS["integrations<br/>webhooks, watch_queue,<br/>confirm, dismiss"] UC_INTEGRATIONS["integrations<br/>webhooks, watch_queue,<br/>confirm, dismiss"]
UC_SEARCH["search<br/>execute"] UC_SEARCH["search<br/>execute"]
UC_PERSON["person<br/>get, get_credits"] UC_PERSON["person<br/>get, get_credits"]
@@ -47,16 +48,17 @@ graph TB
M_USER["User, UserSummary"] M_USER["User, UserSummary"]
M_PERSON["Person, PersonId,<br/>PersonCredits"] M_PERSON["Person, PersonId,<br/>PersonCredits"]
M_WATCHLIST["WatchlistEntry,<br/>WatchEvent"] M_WATCHLIST["WatchlistEntry,<br/>WatchEvent"]
M_GOAL["Goal, GoalWithProgress,<br/>UserSettings, RemoteGoalEntry"]
M_WRAPUP["WrapUpReport,<br/>MovieRef, PersonStat"] M_WRAPUP["WrapUpReport,<br/>MovieRef, PersonStat"]
M_SEARCH["SearchQuery,<br/>SearchResults"] M_SEARCH["SearchQuery,<br/>SearchResults"]
end end
subgraph Ports["Port Traits (Interfaces)"] subgraph Ports["Port Traits (Interfaces)"]
P_REPOS["MovieRepository<br/>ReviewRepository<br/>DiaryRepository<br/>UserRepository<br/>WatchlistRepository<br/>WatchEventRepository<br/>WebhookTokenRepository<br/>ImportSessionRepository<br/>MovieProfileRepository<br/>WrapUpRepository"] P_REPOS["MovieRepository<br/>ReviewRepository<br/>DiaryRepository<br/>UserRepository<br/>WatchlistRepository<br/>WatchEventRepository<br/>WebhookTokenRepository<br/>ImportSessionRepository<br/>MovieProfileRepository<br/>WrapUpRepository<br/>GoalRepository<br/>UserSettingsRepository"]
P_SERVICES["AuthService<br/>MetadataClient<br/>PosterFetcherClient<br/>ObjectStorage<br/>EventPublisher<br/>EventConsumer<br/>PasswordHasher<br/>DiaryExporter<br/>DocumentParser"] P_SERVICES["AuthService<br/>MetadataClient<br/>PosterFetcherClient<br/>ObjectStorage<br/>EventPublisher<br/>EventConsumer<br/>PasswordHasher<br/>DiaryExporter<br/>DocumentParser"]
P_SEARCH["SearchPort<br/>SearchCommand<br/>PersonQuery<br/>PersonCommand"] P_SEARCH["SearchPort<br/>SearchCommand<br/>PersonQuery<br/>PersonCommand"]
P_FEDERATION["SocialQueryPort<br/>LocalApContentQuery<br/>RemoteWatchlistRepository"] P_FEDERATION["SocialQueryPort<br/>LocalApContentQuery<br/>RemoteWatchlistRepository<br/>RemoteGoalRepository"]
end end
EVENTS["DomainEvent enum<br/><i>ReviewLogged, MovieDiscovered,<br/>SearchReindexRequested, ...</i>"] EVENTS["DomainEvent enum<br/><i>ReviewLogged, MovieDiscovered,<br/>GoalCreated, GoalUpdated,<br/>SearchReindexRequested, ...</i>"]
VO["Value Objects<br/><i>MovieId, UserId, Rating,<br/>Email, Username, ...</i>"] VO["Value Objects<br/><i>MovieId, UserId, Rating,<br/>Email, Username, ...</i>"]
end end

File diff suppressed because it is too large Load Diff

View File

@@ -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<GeneratedToken, DomainError> {
Ok(GeneratedToken {
token: user_id.value().to_string(),
expires_at: Utc::now() + chrono::Duration::hours(24),
})
}
async fn validate_token(&self, token: &str) -> Result<UserId, DomainError> {
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, DomainError> {
PasswordHash::new(format!("hashed:{plain_password}"))
}
async fn verify(&self, plain_password: &str, hash: &PasswordHash) -> Result<bool, DomainError> {
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<Movie, DomainError> {
Err(DomainError::InfrastructureError(
"fake metadata client".into(),
))
}
async fn get_poster_url(
&self,
_external_metadata_id: &ExternalMetadataId,
) -> Result<Option<PosterUrl>, DomainError> {
Ok(None)
}
}
// ── FakeDiaryRepository ───────────────────────────────────────────────────────
pub struct FakeDiaryRepository {
histories: Mutex<HashMap<Uuid, (Movie, Vec<Review>)>>,
}
impl FakeDiaryRepository {
pub fn new() -> Arc<Self> {
Arc::new(Self {
histories: Mutex::new(HashMap::new()),
})
}
pub fn seed_history(&self, movie: Movie, reviews: Vec<Review>) {
self.histories
.lock()
.unwrap()
.insert(movie.id().value(), (movie, reviews));
}
}
#[async_trait]
impl DiaryRepository for FakeDiaryRepository {
async fn query_diary(
&self,
_filter: &DiaryFilter,
) -> Result<Paginated<DiaryEntry>, DomainError> {
unimplemented!("FakeDiaryRepository::query_diary")
}
async fn query_activity_feed(
&self,
_page: &PageParams,
) -> Result<Paginated<FeedEntry>, 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<Paginated<FeedEntry>, DomainError> {
unimplemented!("FakeDiaryRepository::query_activity_feed_filtered")
}
async fn get_review_history(&self, movie_id: &MovieId) -> Result<ReviewHistory, DomainError> {
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<Vec<DiaryEntry>, DomainError> {
unimplemented!("FakeDiaryRepository::get_user_history")
}
async fn get_movie_stats(&self, _movie_id: &MovieId) -> Result<MovieStats, DomainError> {
unimplemented!("FakeDiaryRepository::get_movie_stats")
}
async fn get_movie_social_feed(
&self,
_movie_id: &MovieId,
_page: &PageParams,
) -> Result<Paginated<FeedEntry>, DomainError> {
unimplemented!("FakeDiaryRepository::get_movie_social_feed")
}
async fn count_local_posts(&self) -> Result<u64, DomainError> {
unimplemented!("FakeDiaryRepository::count_local_posts")
}
}

View File

@@ -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<HashMap<Uuid, Movie>>,
}
impl InMemoryMovieRepository {
pub fn new() -> Arc<Self> {
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<Option<Movie>, 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<Option<Movie>, 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<Vec<Movie>, 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<std::collections::HashSet<String>, DomainError> {
let store = self.store.lock().unwrap();
let known: std::collections::HashSet<String> = 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<std::collections::HashSet<(String, u16)>, 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<Paginated<MovieSummary>, DomainError> {
Ok(Paginated {
items: vec![],
total_count: 0,
limit: 10,
offset: 0,
})
}
}
// ── InMemoryReviewRepository ──────────────────────────────────────────────────
pub struct InMemoryReviewRepository {
store: Mutex<HashMap<Uuid, Review>>,
}
impl InMemoryReviewRepository {
pub fn new() -> Arc<Self> {
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<DomainEvent, DomainError> {
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<Option<Review>, 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<Vec<Review>, 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<HashMap<Uuid, User>>,
}
impl InMemoryUserRepository {
pub fn new() -> Arc<Self> {
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<Option<User>, 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<Option<User>, 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<Option<User>, DomainError> {
Ok(self.store.lock().unwrap().get(&id.value()).cloned())
}
async fn list_with_stats(&self) -> Result<Vec<UserSummary>, DomainError> {
Ok(vec![])
}
async fn update_profile(
&self,
_user_id: &UserId,
_profile: &crate::models::UserProfile,
) -> Result<(), DomainError> {
Ok(())
}
}
// ── InMemoryWatchlistRepository ───────────────────────────────────────────────
pub struct InMemoryWatchlistRepository {
store: Mutex<HashMap<(Uuid, Uuid), WatchlistEntry>>,
}
impl InMemoryWatchlistRepository {
pub fn new() -> Arc<Self> {
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<bool, DomainError> {
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<Paginated<WatchlistWithMovie>, DomainError> {
Ok(Paginated {
items: vec![],
total_count: 0,
limit: 10,
offset: 0,
})
}
async fn contains(&self, user_id: &UserId, movie_id: &MovieId) -> Result<bool, DomainError> {
let key = (user_id.value(), movie_id.value());
Ok(self.store.lock().unwrap().contains_key(&key))
}
}

View File

@@ -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::*;

View File

@@ -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<Vec<DomainEvent>>,
}
impl NoopEventPublisher {
pub fn new() -> Arc<Self> {
Arc::new(Self {
events: Mutex::new(vec![]),
})
}
pub fn published(&self) -> Vec<DomainEvent> {
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<String, DomainError> {
Ok(format!("noop://{key}"))
}
async fn get(&self, _key: &str) -> Result<Vec<u8>, DomainError> {
Ok(vec![])
}
async fn get_stream(
&self,
_key: &str,
) -> Result<futures::stream::BoxStream<'static, Result<bytes::Bytes, DomainError>>, 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<Vec<crate::models::RemoteWatchlistEntry>, DomainError> {
Ok(vec![])
}
async fn remove_all_by_actor(&self, _: &str) -> Result<(), DomainError> {
Ok(())
}
async fn get_by_derived_uuid(
&self,
_: uuid::Uuid,
) -> Result<Vec<crate::models::RemoteWatchlistEntry>, 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<Vec<String>, DomainError> {
Ok(vec![])
}
async fn list_all_followed_remote_actors(
&self,
) -> Result<Vec<crate::ports::RemoteActorInfo>, DomainError> {
Ok(vec![])
}
async fn count_following(&self, _: uuid::Uuid) -> Result<usize, DomainError> {
Ok(0)
}
async fn count_accepted_followers(&self, _: uuid::Uuid) -> Result<usize, DomainError> {
Ok(0)
}
async fn get_pending_followers(
&self,
_: uuid::Uuid,
) -> Result<Vec<crate::ports::PendingFollowerInfo>, 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<Option<crate::models::Goal>, DomainError> {
Ok(None)
}
async fn list_for_user(&self, _: &UserId) -> Result<Vec<crate::models::Goal>, DomainError> {
Ok(vec![])
}
async fn count_reviews_in_year(&self, _: &UserId, _: u16) -> Result<u32, DomainError> {
Ok(0)
}
}
// ── NoopUserSettingsRepository ────────────────────────────────────────────────
pub struct NoopUserSettingsRepository;
#[async_trait]
impl crate::ports::UserSettingsRepository for NoopUserSettingsRepository {
async fn get(&self, user_id: &UserId) -> Result<crate::models::UserSettings, DomainError> {
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<Vec<crate::models::RemoteGoalEntry>, DomainError> {
Ok(vec![])
}
}

View File

@@ -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<Paginated<DiaryEntry>, DomainError> {
panic!("PanicDiaryRepository called")
}
async fn query_activity_feed(
&self,
_: &PageParams,
) -> Result<Paginated<FeedEntry>, DomainError> {
panic!("PanicDiaryRepository called")
}
async fn query_activity_feed_filtered(
&self,
_: &PageParams,
_: &FeedSortBy,
_: Option<&str>,
_: Option<&FollowingFilter>,
) -> Result<Paginated<FeedEntry>, DomainError> {
panic!("PanicDiaryRepository called")
}
async fn get_review_history(&self, _: &MovieId) -> Result<ReviewHistory, DomainError> {
panic!("PanicDiaryRepository called")
}
async fn get_user_history(&self, _: &UserId) -> Result<Vec<DiaryEntry>, DomainError> {
panic!("PanicDiaryRepository called")
}
async fn get_movie_stats(&self, _: &MovieId) -> Result<MovieStats, DomainError> {
panic!("PanicDiaryRepository called")
}
async fn get_movie_social_feed(
&self,
_: &MovieId,
_: &PageParams,
) -> Result<Paginated<FeedEntry>, DomainError> {
panic!("PanicDiaryRepository called")
}
async fn count_local_posts(&self) -> Result<u64, DomainError> {
panic!("PanicDiaryRepository called")
}
}
pub struct PanicStatsRepository;
#[async_trait]
impl StatsRepository for PanicStatsRepository {
async fn get_user_stats(&self, _: &UserId) -> Result<UserStats, DomainError> {
panic!("PanicStatsRepository called")
}
async fn get_user_trends(&self, _: &UserId) -> Result<UserTrends, DomainError> {
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<Option<ImportSession>, 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<u64, DomainError> {
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<Vec<ImportProfile>, DomainError> {
panic!("PanicImportProfileRepository called")
}
async fn get(
&self,
_: &ImportProfileId,
_: &UserId,
) -> Result<Option<ImportProfile>, 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<Option<MovieProfile>, DomainError> {
panic!("PanicMovieProfileRepository called")
}
async fn list_stale(&self) -> Result<Vec<(MovieId, String)>, 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<Option<Person>, DomainError> {
panic!("PanicPersonQuery called")
}
async fn get_by_external_id(
&self,
_: &ExternalPersonId,
) -> Result<Option<Person>, DomainError> {
panic!("PanicPersonQuery called")
}
async fn get_credits(&self, _: &PersonId) -> Result<PersonCredits, DomainError> {
panic!("PanicPersonQuery called")
}
async fn list_orphaned_persons(&self) -> Result<Vec<PersonId>, DomainError> {
panic!("PanicPersonQuery called")
}
async fn list_page(&self, _: u32, _: u32) -> Result<Vec<Person>, DomainError> {
panic!("PanicPersonQuery called")
}
}
pub struct PanicSearchPort;
#[async_trait]
impl SearchPort for PanicSearchPort {
async fn search(&self, _: &SearchQuery) -> Result<SearchResults, DomainError> {
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<Vec<u8>, DomainError> {
panic!("PanicPosterFetcher called")
}
}
pub struct PanicDiaryExporter;
#[async_trait]
impl DiaryExporter for PanicDiaryExporter {
async fn serialize_entries(
&self,
_: &[DiaryEntry],
_: ExportFormat,
) -> Result<Vec<u8>, DomainError> {
panic!("PanicDiaryExporter called")
}
}
pub struct PanicDocumentParser;
impl DocumentParser for PanicDocumentParser {
fn parse(&self, _: &[u8], _: FileFormat) -> Result<ParsedFile, ImportError> {
panic!("PanicDocumentParser called")
}
fn apply_mapping(&self, _: &ParsedFile, _: &[FieldMapping]) -> Vec<AnnotatedRow> {
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<Vec<crate::models::RemoteWatchlistEntry>, 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<Vec<crate::models::RemoteWatchlistEntry>, DomainError> {
panic!("PanicRemoteWatchlistRepository called")
}
}
pub struct PanicProfileFieldsRepo;
#[async_trait]
impl UserProfileFieldsRepository for PanicProfileFieldsRepo {
async fn get_fields(
&self,
_: &UserId,
) -> Result<Vec<crate::models::ProfileField>, DomainError> {
panic!("PanicProfileFieldsRepo called")
}
async fn set_fields(
&self,
_: &UserId,
_: Vec<crate::models::ProfileField>,
) -> 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<Vec<String>, DomainError> {
panic!("PanicSocialQueryPort called")
}
async fn list_all_followed_remote_actors(
&self,
) -> Result<Vec<crate::ports::RemoteActorInfo>, DomainError> {
panic!("PanicSocialQueryPort called")
}
async fn count_following(&self, _: uuid::Uuid) -> Result<usize, DomainError> {
panic!("PanicSocialQueryPort called")
}
async fn count_accepted_followers(&self, _: uuid::Uuid) -> Result<usize, DomainError> {
panic!("PanicSocialQueryPort called")
}
async fn get_pending_followers(
&self,
_: uuid::Uuid,
) -> Result<Vec<crate::ports::PendingFollowerInfo>, 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<Vec<crate::models::WatchEvent>, DomainError> {
panic!("PanicWatchEventRepository called")
}
async fn get_by_id(
&self,
_: &crate::value_objects::WatchEventId,
) -> Result<Option<crate::models::WatchEvent>, DomainError> {
panic!("PanicWatchEventRepository called")
}
async fn get_by_ids(
&self,
_: &[crate::value_objects::WatchEventId],
) -> Result<Vec<crate::models::WatchEvent>, DomainError> {
panic!("PanicWatchEventRepository called")
}
async fn update_status_batch(
&self,
_: &[crate::value_objects::WatchEventId],
_: crate::models::WatchEventStatus,
) -> Result<u64, DomainError> {
panic!("PanicWatchEventRepository called")
}
async fn find_duplicate(
&self,
_: &UserId,
_: &str,
_: chrono::NaiveDateTime,
) -> Result<bool, DomainError> {
panic!("PanicWatchEventRepository called")
}
async fn delete_non_pending_older_than(
&self,
_: chrono::NaiveDateTime,
) -> Result<u64, DomainError> {
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<Option<crate::models::WebhookToken>, DomainError> {
panic!("PanicWebhookTokenRepository called")
}
async fn list_by_user(
&self,
_: &UserId,
) -> Result<Vec<crate::models::WebhookToken>, 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")
}
}

View File

@@ -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<Vec<crate::ports::WrapUpMovieRow>, DomainError> {
unimplemented!("WrapUpStatsQuery not wired")
}
}
// ── InMemoryWrapUpStatsQuery ────────────────────────────────────────────────
pub struct InMemoryWrapUpStatsQuery {
pub rows: Mutex<Vec<crate::ports::WrapUpMovieRow>>,
}
impl InMemoryWrapUpStatsQuery {
pub fn new() -> Arc<Self> {
Arc::new(Self {
rows: Mutex::new(Vec::new()),
})
}
pub fn with_rows(rows: Vec<crate::ports::WrapUpMovieRow>) -> Arc<Self> {
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<Vec<crate::ports::WrapUpMovieRow>, 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<Vec<crate::models::wrapup::WrapUpRecord>>,
}
impl InMemoryWrapUpRepository {
pub fn new() -> Arc<Self> {
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<Option<crate::models::wrapup::WrapUpRecord>, DomainError> {
Ok(self
.store
.lock()
.unwrap()
.iter()
.find(|r| r.id == *id)
.cloned())
}
async fn list_for_user(
&self,
user_id: Uuid,
) -> Result<Vec<crate::models::wrapup::WrapUpRecord>, DomainError> {
Ok(self
.store
.lock()
.unwrap()
.iter()
.filter(|r| r.user_id == Some(user_id))
.cloned()
.collect())
}
async fn list_global(&self) -> Result<Vec<crate::models::wrapup::WrapUpRecord>, DomainError> {
Ok(self
.store
.lock()
.unwrap()
.iter()
.filter(|r| r.user_id.is_none())
.cloned()
.collect())
}
async fn find_existing(
&self,
user_id: Option<Uuid>,
start: chrono::NaiveDate,
end: chrono::NaiveDate,
) -> Result<Option<crate::models::wrapup::WrapUpRecord>, 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<u64, DomainError> {
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<Option<crate::models::wrapup::WrapUpRecord>, DomainError> {
panic!("PanicWrapUpRepository called")
}
async fn list_for_user(
&self,
_: Uuid,
) -> Result<Vec<crate::models::wrapup::WrapUpRecord>, DomainError> {
panic!("PanicWrapUpRepository called")
}
async fn list_global(&self) -> Result<Vec<crate::models::wrapup::WrapUpRecord>, DomainError> {
panic!("PanicWrapUpRepository called")
}
async fn find_existing(
&self,
_: Option<Uuid>,
_: chrono::NaiveDate,
_: chrono::NaiveDate,
) -> Result<Option<crate::models::wrapup::WrapUpRecord>, DomainError> {
panic!("PanicWrapUpRepository called")
}
async fn delete(&self, _: &WrapUpId) -> Result<(), DomainError> {
panic!("PanicWrapUpRepository called")
}
async fn delete_failed_older_than(&self, _: chrono::NaiveDateTime) -> Result<u64, DomainError> {
panic!("PanicWrapUpRepository called")
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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<AppState>,
Json(req): Json<LoginRequest>,
) -> Result<Json<LoginResponse>, 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<AppState>,
Json(req): Json<RegisterRequest>,
) -> Result<StatusCode, ApiError> {
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<AppState>,
Query(params): Query<ErrorQuery>,
Extension(csrf): Extension<CsrfToken>,
) -> 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<AppState>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<LoginForm>,
) -> 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<AppState>,
Query(params): Query<ErrorQuery>,
Extension(csrf): Extension<CsrfToken>,
) -> 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<AppState>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<RegisterForm>,
) -> 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()
}
}
}

View File

@@ -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<AppState>,
Query(params): Query<DiaryQueryParams>,
) -> Result<Json<DiaryResponse>, 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<AppState>,
user: AuthenticatedUser,
Json(req): Json<LogReviewRequest>,
) -> Result<impl IntoResponse, ApiError> {
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<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
Path(review_id): Path<Uuid>,
) -> Result<StatusCode, ApiError> {
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<AppState>,
user: AuthenticatedUser,
Query(params): Query<ExportQueryParams>,
) -> 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<AppState>,
Query(params): Query<ActivityFeedQueryParams>,
) -> Result<Json<ActivityFeedResponse>, 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<AppState>,
Query(params): Query<ErrorQuery>,
Extension(csrf): Extension<CsrfToken>,
) -> 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<AppState>,
RequiredCookieUser(user_id): RequiredCookieUser,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<LogReviewForm>,
) -> 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<AppState>,
RequiredCookieUser(user_id): RequiredCookieUser,
Extension(csrf): Extension<CsrfToken>,
Path(review_id): Path<Uuid>,
Form(form): Form<crate::forms::DeleteRedirectForm>,
) -> 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<AppState>,
RequiredCookieUser(user_id): RequiredCookieUser,
Query(params): Query<api_types::ExportQueryParams>,
) -> 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<AppState>,
Query(params): Query<FeedQueryParams>,
Extension(csrf): Extension<CsrfToken>,
) -> 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),
}
}

View File

@@ -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<AppState>,
user: AuthenticatedUser,
) -> Result<Json<GoalsResponse>, 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<AppState>,
user: AuthenticatedUser,
Json(req): Json<CreateGoalRequest>,
) -> Result<Json<GoalDto>, 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<AppState>,
user: AuthenticatedUser,
Path(year): Path<u16>,
Json(req): Json<UpdateGoalRequest>,
) -> Result<Json<GoalDto>, 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<AppState>,
user: AuthenticatedUser,
Path(year): Path<u16>,
) -> Result<StatusCode, ApiError> {
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<AppState>,
AuthenticatedUser(_viewer): AuthenticatedUser,
Path(user_id): Path<Uuid>,
) -> Result<Json<GoalsResponse>, 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<AppState>,
user: AuthenticatedUser,
) -> Result<Json<UserSettingsDto>, 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<AppState>,
user: AuthenticatedUser,
Json(req): Json<UpdateUserSettingsRequest>,
) -> Result<StatusCode, ApiError> {
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)
}

View File

@@ -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<UserId>,
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,
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -108,7 +108,7 @@ pub async fn get_import_page(
RequiredCookieUser(user_id): RequiredCookieUser, RequiredCookieUser(user_id): RequiredCookieUser,
Extension(csrf): Extension<CsrfToken>, Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse { ) -> 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) let profiles = list_import_profiles::execute(&state.app_ctx, &user_id)
.await .await
.unwrap_or_default() .unwrap_or_default()
@@ -202,7 +202,7 @@ pub async fn get_mapping_page(
return Redirect::to("/import").into_response(); 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<Vec<String>> = parsed.rows.into_iter().take(5).collect(); let sample_rows: Vec<Vec<String>> = parsed.rows.into_iter().take(5).collect();
let domain_fields: Vec<(&str, &str)> = vec![ let domain_fields: Vec<(&str, &str)> = vec![
("title", "Title"), ("title", "Title"),
@@ -304,7 +304,7 @@ pub async fn get_preview_page(
.map(|(i, a)| annotated_to_preview_row(i, a)) .map(|(i, a)| annotated_to_preview_row(i, a))
.collect(); .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 { render_page(ImportPreviewTemplate {
ctx: &ctx, ctx: &ctx,
session_id: &session_id_str, session_id: &session_id_str,
@@ -421,7 +421,7 @@ pub async fn get_import_done(
Extension(csrf): Extension<CsrfToken>, Extension(csrf): Extension<CsrfToken>,
axum::extract::Query(params): axum::extract::Query<ImportDoneParams>, axum::extract::Query(params): axum::extract::Query<ImportDoneParams>,
) -> impl IntoResponse { ) -> 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!( let html = format!(
r#"<!doctype html><html><body> r#"<!doctype html><html><body>
<h1>Import Complete</h1> <h1>Import Complete</h1>

View File

@@ -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<AppState>,
Query(params): Query<crate::forms::IntegrationsQuery>,
Extension(csrf): Extension<CsrfToken>,
) -> 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<template_askama::WebhookTokenView> = 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<AppState>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<crate::forms::GenerateTokenForm>,
) -> impl IntoResponse {
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
let provider = match form.provider.parse::<domain::models::WatchEventSource>() {
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<AppState>,
Path(token_id): Path<Uuid>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<crate::forms::RevokeTokenForm>,
) -> 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<AppState>,
Query(params): Query<ErrorQuery>,
Extension(csrf): Extension<CsrfToken>,
) -> 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<template_askama::WatchQueueDisplayEntry> = 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<AppState>,
Path(event_id): Path<Uuid>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<crate::forms::ConfirmWatchForm>,
) -> 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<AppState>,
Path(event_id): Path<Uuid>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<crate::forms::DismissWatchForm>,
) -> 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()
}
}
}

View File

@@ -1,8 +1,17 @@
pub mod api; pub mod auth;
pub mod html; pub mod diary;
pub mod goals;
mod helpers;
pub mod images; pub mod images;
pub mod import; pub mod import;
pub mod integrations;
pub mod movies;
pub mod rss; pub mod rss;
pub mod search;
#[cfg(feature = "federation")]
pub mod social;
pub mod users;
pub mod watchlist;
pub mod webhook; pub mod webhook;
pub mod wrapup; pub mod wrapup;

View File

@@ -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<AppState>,
Query(params): Query<MoviesQueryParams>,
) -> Result<Json<MoviesResponse>, 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<AppState>,
Path(movie_id): Path<Uuid>,
) -> Result<Json<ReviewHistoryResponse>, 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<AppState>,
_user: AuthenticatedUser,
Path(movie_id): Path<Uuid>,
) -> Result<impl IntoResponse, ApiError> {
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<AppState>,
Path(movie_id): Path<Uuid>,
Query(params): Query<PaginationQueryParams>,
) -> Result<Json<MovieDetailResponse>, 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<AppState>,
Path(movie_id): Path<Uuid>,
) -> 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<AppState>,
Path(movie_id): Path<uuid::Uuid>,
Query(params): Query<api_types::PaginationQueryParams>,
Extension(csrf): Extension<CsrfToken>,
) -> 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()
}
}
}

View File

@@ -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<AppState>,
Query(params): Query<SearchQueryParams>,
) -> 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<AppState>,
Path(id): Path<uuid::Uuid>,
) -> 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<AppState>,
Path(id): Path<uuid::Uuid>,
) -> 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<AppState>,
_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<String>) -> impl IntoResponse {
if tag.eq_ignore_ascii_case("moviesdiary") {
Redirect::temporary("/")
} else {
Redirect::temporary(&format!("/?search={}", tag))
}
}

View File

@@ -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<BlockedDomainResponse>),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - admin only"),
),
security(("bearer_auth" = []))
)]
pub async fn get_blocked_domains_admin(
State(state): State<AppState>,
_admin: crate::extractors::AdminUser,
) -> impl IntoResponse {
match state.ap_service.get_blocked_domains().await {
Ok(domains) => {
let response: Vec<BlockedDomainResponse> = 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<AppState>,
_admin: crate::extractors::AdminUser,
axum::Json(body): axum::Json<AddBlockedDomainRequest>,
) -> 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<AppState>,
_admin: crate::extractors::AdminUser,
axum::extract::Path(domain): axum::extract::Path<String>,
) -> 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<AppState>,
user: AuthenticatedUser,
axum::Json(body): axum::Json<ActorUrlRequest>,
) -> 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<AppState>,
user: AuthenticatedUser,
axum::Json(body): axum::Json<ActorUrlRequest>,
) -> 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<BlockedActorResponse>),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn get_blocked_actors_api(
State(state): State<AppState>,
user: AuthenticatedUser,
) -> impl IntoResponse {
match state.ap_service.get_blocked_actors(user.0.value()).await {
Ok(actors) => {
let response: Vec<BlockedActorResponse> = 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<AppState>,
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<AppState>,
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<AppState>,
_user: AuthenticatedUser,
Path(user_id): Path<Uuid>,
) -> Result<Json<ActorListResponse>, ApiError> {
let actors = state
.ap_service
.get_following(user_id)
.await
.map_err(ap_to_domain)?;
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<AppState>,
_user: AuthenticatedUser,
Path(user_id): Path<Uuid>,
) -> Result<Json<ActorListResponse>, ApiError> {
let actors = state
.ap_service
.get_accepted_followers(user_id)
.await
.map_err(ap_to_domain)?;
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<AppState>,
user: AuthenticatedUser,
Json(body): Json<FollowRequest>,
) -> 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<AppState>,
user: AuthenticatedUser,
Json(body): Json<ActorUrlRequest>,
) -> 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<AppState>,
user: AuthenticatedUser,
Json(body): Json<ActorUrlRequest>,
) -> 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<AppState>,
user: AuthenticatedUser,
Json(body): Json<ActorUrlRequest>,
) -> 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<AppState>,
user: AuthenticatedUser,
Json(body): Json<ActorUrlRequest>,
) -> 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<AppState>,
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<AppState>,
Path(profile_user_uuid): Path<Uuid>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<FollowForm>,
) -> 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<AppState>,
Path(profile_user_uuid): Path<Uuid>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<UnfollowForm>,
) -> 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<AppState>,
Path(profile_user_uuid): Path<Uuid>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<FollowerActionForm>,
) -> 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<AppState>,
Path(profile_user_uuid): Path<Uuid>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<FollowerActionForm>,
) -> 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<AppState>,
Path(user_id): Path<Uuid>,
headers: axum::http::HeaderMap,
Query(params): Query<std::collections::HashMap<String, String>>,
) -> 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::<u32>().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<AppState>,
Path(user_id): Path<Uuid>,
headers: axum::http::HeaderMap,
Query(params): Query<std::collections::HashMap<String, String>>,
) -> 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::<u32>().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<AppState>,
Path(profile_user_uuid): Path<Uuid>,
Query(params): Query<crate::forms::ErrorQuery>,
Extension(csrf): Extension<CsrfToken>,
) -> 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<RemoteActorData> = 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<AppState>,
Path(profile_user_uuid): Path<Uuid>,
Query(params): Query<crate::forms::ErrorQuery>,
Extension(csrf): Extension<CsrfToken>,
) -> 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<RemoteActorData> = 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<AppState>,
Path(profile_user_uuid): Path<Uuid>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<FollowerActionForm>,
) -> 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<AppState>,
Extension(csrf): Extension<CsrfToken>,
) -> 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<template_askama::BlockedDomainEntry> = 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<AppState>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<BlockDomainForm>,
) -> 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<AppState>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<RemoveDomainForm>,
) -> 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<AppState>,
Extension(csrf): Extension<CsrfToken>,
) -> 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<template_askama::BlockedActorEntry> = 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<AppState>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<ActorUrlForm>,
) -> 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<AppState>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<ActorUrlForm>,
) -> 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()
}
}
}

View File

@@ -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<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
) -> Result<Json<ProfileResponse>, 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<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
mut multipart: Multipart,
) -> impl IntoResponse {
let mut display_name: Option<String> = None;
let mut bio: Option<String> = None;
let mut avatar_bytes: Option<Vec<u8>> = None;
let mut avatar_content_type: Option<String> = None;
let mut banner_bytes: Option<Vec<u8>> = None;
let mut banner_content_type: Option<String> = None;
let mut also_known_as: Option<String> = 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<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
axum::Json(body): axum::Json<serde_json::Value>,
) -> 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<domain::models::ProfileField> = 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<AppState>) -> Result<Json<UsersResponse>, 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<AppState>,
AuthenticatedUser(viewer_id): AuthenticatedUser,
Path(user_id): Path<Uuid>,
Query(params): Query<UserProfileQueryParams>,
) -> 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<AppState>,
Extension(csrf): Extension<CsrfToken>,
) -> 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<UserSummaryView> = result
.users
.iter()
.map(crate::mappers::users::user_summary_view)
.collect();
let remote_actors: Vec<RemoteActorDisplay> = 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<AppState>,
Path(username): Path<String>,
) -> 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<AppState>,
Path(profile_user_uuid): Path<Uuid>,
headers: axum::http::HeaderMap,
Query(params): Query<crate::forms::ProfileQueryParams>,
Extension(csrf): Extension<CsrfToken>,
) -> 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<MonthlyRatingRow<'_>> = 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<RemoteActorData> = 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<String>,
}
pub async fn get_profile_settings(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
Query(params): Query<SavedQuery>,
Extension(csrf): Extension<CsrfToken>,
) -> 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<AppState>,
mut multipart: Multipart,
) -> impl IntoResponse {
let mut display_name: Option<String> = None;
let mut bio: Option<String> = None;
let mut avatar_bytes: Option<Vec<u8>> = None;
let mut avatar_content_type: Option<String> = None;
let mut banner_bytes: Option<Vec<u8>> = None;
let mut banner_content_type: Option<String> = None;
let mut also_known_as: Option<String> = None;
let mut field_names: std::collections::HashMap<usize, String> =
std::collections::HashMap::new();
let mut field_values: std::collections::HashMap<usize, String> =
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::<usize>()
&& 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::<usize>()
&& 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<domain::models::ProfileField> = (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()
}

View File

@@ -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<u32>, Query, description = "Max results"),
("offset" = Option<u32>, Query, description = "Offset"),
),
responses(
(status = 200, body = WatchlistResponse),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn get_watchlist_handler(
State(state): State<AppState>,
user: AuthenticatedUser,
Query(params): Query<PaginationQueryParams>,
) -> Result<Json<WatchlistResponse>, 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<AppState>,
user: AuthenticatedUser,
Json(req): Json<AddToWatchlistRequest>,
) -> Result<impl IntoResponse, ApiError> {
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<AppState>,
user: AuthenticatedUser,
Path(movie_id): Path<Uuid>,
) -> Result<impl IntoResponse, ApiError> {
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<AppState>,
user: AuthenticatedUser,
Path(movie_id): Path<Uuid>,
) -> Result<Json<WatchlistStatusResponse>, 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<AppState>,
Path(owner_id): Path<uuid::Uuid>,
Query(params): Query<crate::forms::WatchlistQuery>,
Extension(csrf): Extension<CsrfToken>,
) -> 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<AppState>,
RequiredCookieUser(user_id): RequiredCookieUser,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<crate::forms::WatchlistAddForm>,
) -> 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<AppState>,
RequiredCookieUser(user_id): RequiredCookieUser,
Extension(csrf): Extension<CsrfToken>,
Path(movie_id): Path<uuid::Uuid>,
Form(form): Form<crate::forms::DeleteRedirectForm>,
) -> 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),
}
}

View File

@@ -302,7 +302,7 @@ pub async fn get_user_wrapup_html(
}; };
let video_url = format!("/api/v1/wrapups/{}/video", record.id.value()); 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)) 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 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)) render_wrapup(&report, year, &ctx, Some(video_url))
} }

View File

@@ -3,7 +3,7 @@ use utoipa::OpenApi;
#[derive(OpenApi)] #[derive(OpenApi)]
#[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)) components(schemas(LoginRequest, LoginResponse, RegisterRequest))
)] )]
pub struct AuthDoc; pub struct AuthDoc;

View File

@@ -6,11 +6,11 @@ use utoipa::OpenApi;
#[derive(OpenApi)] #[derive(OpenApi)]
#[openapi( #[openapi(
paths( paths(
crate::handlers::api::get_diary, crate::handlers::diary::get_diary,
crate::handlers::api::post_review, crate::handlers::diary::post_review,
crate::handlers::api::delete_review, crate::handlers::diary::delete_review,
crate::handlers::api::export_diary, crate::handlers::diary::export_diary,
crate::handlers::api::get_activity_feed, crate::handlers::diary::get_activity_feed,
), ),
components(schemas( components(schemas(
DiaryResponse, DiaryResponse,

View File

@@ -8,11 +8,11 @@ use utoipa::OpenApi;
#[derive(OpenApi)] #[derive(OpenApi)]
#[openapi( #[openapi(
paths( paths(
crate::handlers::api::list_movies, crate::handlers::movies::list_movies,
crate::handlers::api::get_movie_detail, crate::handlers::movies::get_movie_detail,
crate::handlers::api::get_review_history, crate::handlers::movies::get_review_history,
crate::handlers::api::get_movie_profile, crate::handlers::movies::get_movie_profile,
crate::handlers::api::sync_poster, crate::handlers::movies::sync_poster,
), ),
components(schemas( components(schemas(
MoviesResponse, MoviesResponse,

View File

@@ -7,9 +7,9 @@ use utoipa::OpenApi;
#[derive(OpenApi)] #[derive(OpenApi)]
#[openapi( #[openapi(
paths( paths(
crate::handlers::api::get_search, crate::handlers::search::get_search,
crate::handlers::api::get_person_handler, crate::handlers::search::get_person_handler,
crate::handlers::api::get_person_credits_handler, crate::handlers::search::get_person_credits_handler,
), ),
components(schemas( components(schemas(
SearchResponse, SearchResponse,

View File

@@ -10,20 +10,20 @@ use utoipa::OpenApi;
#[derive(OpenApi)] #[derive(OpenApi)]
#[openapi( #[openapi(
paths( paths(
crate::handlers::api::get_following, crate::handlers::social::get_following,
crate::handlers::api::get_followers, crate::handlers::social::get_followers,
crate::handlers::api::get_pending_followers, crate::handlers::social::get_pending_followers,
crate::handlers::api::follow, crate::handlers::social::follow,
crate::handlers::api::unfollow, crate::handlers::social::unfollow,
crate::handlers::api::accept_follower, crate::handlers::social::accept_follower,
crate::handlers::api::reject_follower, crate::handlers::social::reject_follower,
crate::handlers::api::remove_follower, crate::handlers::social::remove_follower,
crate::handlers::api::get_blocked_domains_admin, crate::handlers::social::get_blocked_domains_admin,
crate::handlers::api::add_blocked_domain_admin, crate::handlers::social::add_blocked_domain_admin,
crate::handlers::api::remove_blocked_domain_admin, crate::handlers::social::remove_blocked_domain_admin,
crate::handlers::api::block_actor_api, crate::handlers::social::block_actor_api,
crate::handlers::api::unblock_actor_api, crate::handlers::social::unblock_actor_api,
crate::handlers::api::get_blocked_actors_api, crate::handlers::social::get_blocked_actors_api,
), ),
components(schemas( components(schemas(
ActorListResponse, ActorListResponse,

View File

@@ -7,11 +7,11 @@ use utoipa::OpenApi;
#[derive(OpenApi)] #[derive(OpenApi)]
#[openapi( #[openapi(
paths( paths(
crate::handlers::api::list_users, crate::handlers::users::list_users,
crate::handlers::api::get_user_profile, crate::handlers::users::get_user_profile,
crate::handlers::api::get_profile, crate::handlers::users::get_profile,
crate::handlers::api::update_profile_handler, crate::handlers::users::update_profile_handler,
crate::handlers::api::update_profile_fields_handler, crate::handlers::users::update_profile_fields_handler,
), ),
components(schemas( components(schemas(
UsersResponse, UsersResponse,

View File

@@ -6,10 +6,10 @@ use utoipa::OpenApi;
#[derive(OpenApi)] #[derive(OpenApi)]
#[openapi( #[openapi(
paths( paths(
crate::handlers::api::get_watchlist_handler, crate::handlers::watchlist::get_watchlist_handler,
crate::handlers::api::post_watchlist_add, crate::handlers::watchlist::post_watchlist_add,
crate::handlers::api::delete_watchlist_entry, crate::handlers::watchlist::delete_watchlist_entry,
crate::handlers::api::get_watchlist_status, crate::handlers::watchlist::get_watchlist_status,
), ),
components(schemas( components(schemas(
WatchlistResponse, WatchlistResponse,

View File

@@ -48,12 +48,12 @@ fn html_routes(rate_limit: u64) -> Router<AppState> {
let auth = Router::new() let auth = Router::new()
.route( .route(
"/login", "/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( .route(
"/register", "/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({ .layer({
let cfg = GovernorConfigBuilder::default() let cfg = GovernorConfigBuilder::default()
@@ -66,29 +66,29 @@ fn html_routes(rate_limit: u64) -> Router<AppState> {
}); });
let base = Router::new() let base = Router::new()
.route("/", routing::get(handlers::html::get_activity_feed)) .route("/", routing::get(handlers::diary::get_activity_feed_html))
.route("/users", routing::get(handlers::html::get_users_list)) .route("/users", routing::get(handlers::users::get_users_list))
.route( .route(
"/u/{username}", "/u/{username}",
routing::get(handlers::html::get_user_by_username), routing::get(handlers::users::get_user_by_username),
) )
.route( .route(
"/users/{id}", "/users/{id}",
routing::get(handlers::html::get_user_profile), routing::get(handlers::users::get_user_profile_html),
) )
.route( .route(
"/movies/{movie_id}", "/movies/{movie_id}",
routing::get(handlers::html::get_movie_detail), routing::get(handlers::movies::get_movie_detail_html),
) )
.merge(auth) .merge(auth)
.route( .route(
"/reviews/new", "/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( .route(
"/reviews/{id}/delete", "/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("/images/{*key}", routing::get(handlers::images::get_image))
.route( .route(
@@ -99,7 +99,10 @@ fn html_routes(rate_limit: u64) -> Router<AppState> {
}, },
), ),
) )
.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", routing::get(handlers::import::get_import_page))
.route( .route(
"/import/upload", "/import/upload",
@@ -132,45 +135,45 @@ fn html_routes(rate_limit: u64) -> Router<AppState> {
) )
.route( .route(
"/settings/profile", "/settings/profile",
routing::get(handlers::html::get_profile_settings) routing::get(handlers::users::get_profile_settings)
.post(handlers::html::post_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( .route(
"/users/{id}/watchlist", "/users/{id}/watchlist",
routing::get(handlers::html::get_watchlist_page), routing::get(handlers::watchlist::get_watchlist_page),
) )
.route( .route(
"/watchlist/add", "/watchlist/add",
routing::post(handlers::html::post_watchlist_add), routing::post(handlers::watchlist::post_watchlist_add_html),
) )
.route( .route(
"/watchlist/{movie_id}/remove", "/watchlist/{movie_id}/remove",
routing::post(handlers::html::post_watchlist_remove), routing::post(handlers::watchlist::post_watchlist_remove_html),
) )
.route( .route(
"/settings/integrations", "/settings/integrations",
routing::get(handlers::html::get_integrations_page), routing::get(handlers::integrations::get_integrations_page),
) )
.route( .route(
"/settings/integrations/generate", "/settings/integrations/generate",
routing::post(handlers::html::post_generate_token), routing::post(handlers::integrations::post_generate_token),
) )
.route( .route(
"/settings/integrations/{id}/revoke", "/settings/integrations/{id}/revoke",
routing::post(handlers::html::post_revoke_token), routing::post(handlers::integrations::post_revoke_token),
) )
.route( .route(
"/watch-queue", "/watch-queue",
routing::get(handlers::html::get_watch_queue_page), routing::get(handlers::integrations::get_watch_queue_page),
) )
.route( .route(
"/watch-queue/{id}/confirm", "/watch-queue/{id}/confirm",
routing::post(handlers::html::post_confirm_single), routing::post(handlers::integrations::post_confirm_single),
) )
.route( .route(
"/watch-queue/{id}/dismiss", "/watch-queue/{id}/dismiss",
routing::post(handlers::html::post_dismiss_single), routing::post(handlers::integrations::post_dismiss_single),
) )
.route( .route(
"/wrapups/{user_id}/{year}", "/wrapups/{user_id}/{year}",
@@ -192,60 +195,60 @@ fn federation_html_routes() -> Router<AppState> {
Router::new() Router::new()
.route( .route(
"/users/{id}/follow", "/users/{id}/follow",
routing::post(handlers::html::follow_remote_user), routing::post(handlers::social::follow_remote_user),
) )
.route( .route(
"/users/{id}/unfollow", "/users/{id}/unfollow",
routing::post(handlers::html::unfollow_remote_user), routing::post(handlers::social::unfollow_remote_user),
) )
.route( .route(
"/users/{id}/followers/accept", "/users/{id}/followers/accept",
routing::post(handlers::html::accept_follower), routing::post(handlers::social::accept_follower_html),
) )
.route( .route(
"/users/{id}/followers/reject", "/users/{id}/followers/reject",
routing::post(handlers::html::reject_follower), routing::post(handlers::social::reject_follower_html),
) )
.route( .route(
"/users/{id}/followers", "/users/{id}/followers",
routing::get(handlers::html::get_followers_collection), routing::get(handlers::social::get_followers_collection),
) )
.route( .route(
"/users/{id}/following", "/users/{id}/following",
routing::get(handlers::html::get_following_collection), routing::get(handlers::social::get_following_collection),
) )
.route( .route(
"/users/{id}/following-list", "/users/{id}/following-list",
routing::get(handlers::html::get_following_page), routing::get(handlers::social::get_following_page),
) )
.route( .route(
"/users/{id}/followers-list", "/users/{id}/followers-list",
routing::get(handlers::html::get_followers_page), routing::get(handlers::social::get_followers_page),
) )
.route( .route(
"/users/{id}/followers/remove", "/users/{id}/followers/remove",
routing::post(handlers::html::remove_follower), routing::post(handlers::social::remove_follower_html),
) )
.route( .route(
"/admin/blocked-domains", "/admin/blocked-domains",
routing::get(handlers::html::get_blocked_domains_page) routing::get(handlers::social::get_blocked_domains_page)
.post(handlers::html::post_blocked_domain), .post(handlers::social::post_blocked_domain),
) )
.route( .route(
"/admin/blocked-domains/remove", "/admin/blocked-domains/remove",
routing::post(handlers::html::post_remove_blocked_domain), routing::post(handlers::social::post_remove_blocked_domain),
) )
.route( .route(
"/social/blocked", "/social/blocked",
routing::get(handlers::html::get_blocked_actors_page), routing::get(handlers::social::get_blocked_actors_page),
) )
.route( .route(
"/social/block", "/social/block",
routing::post(handlers::html::post_block_actor_html), routing::post(handlers::social::post_block_actor_html),
) )
.route( .route(
"/social/unblock", "/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<AppState> {
.unwrap(); .unwrap();
let base = Router::new() let base = Router::new()
.route("/diary", routing::get(handlers::api::get_diary)) .route("/diary", routing::get(handlers::diary::get_diary))
.route( .route(
"/movies/{id}/history", "/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( .route(
"/movies/{id}", "/movies/{id}",
routing::get(handlers::api::get_movie_detail), routing::get(handlers::movies::get_movie_detail),
) )
.route( .route(
"/movies/{id}/profile", "/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( .route(
"/reviews/{id}", "/reviews/{id}",
routing::delete(handlers::api::delete_review), routing::delete(handlers::diary::delete_review),
) )
.route( .route(
"/movies/{id}/sync-poster", "/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/login", routing::post(handlers::auth::login))
.route("/auth/register", routing::post(handlers::api::register)) .route("/auth/register", routing::post(handlers::auth::register))
.route("/diary/export", routing::get(handlers::api::export_diary)) .route("/diary/export", routing::get(handlers::diary::export_diary))
.route( .route(
"/activity-feed", "/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", routing::get(handlers::users::list_users))
.route("/users/{id}", routing::get(handlers::api::get_user_profile))
.route( .route(
"/users/{id}/following", "/users/{id}",
routing::get(handlers::api::get_user_following), routing::get(handlers::users::get_user_profile),
)
.route(
"/users/{id}/followers",
routing::get(handlers::api::get_user_followers),
) )
.route( .route(
"/import/sessions", "/import/sessions",
@@ -369,30 +367,30 @@ fn api_routes(rate_limit: u64) -> Router<AppState> {
) )
.route( .route(
"/profile", "/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( .route(
"/profile/fields", "/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( .route(
"/people/{id}", "/people/{id}",
routing::get(handlers::api::get_person_handler), routing::get(handlers::search::get_person_handler),
) )
.route( .route(
"/people/{id}/credits", "/people/{id}/credits",
routing::get(handlers::api::get_person_credits_handler), routing::get(handlers::search::get_person_credits_handler),
) )
.route( .route(
"/watchlist", "/watchlist",
routing::get(handlers::api::get_watchlist_handler) routing::get(handlers::watchlist::get_watchlist_handler)
.post(handlers::api::post_watchlist_add), .post(handlers::watchlist::post_watchlist_add),
) )
.route( .route(
"/watchlist/{movie_id}", "/watchlist/{movie_id}",
routing::get(handlers::api::get_watchlist_status) routing::get(handlers::watchlist::get_watchlist_status)
.delete(handlers::api::delete_watchlist_entry), .delete(handlers::watchlist::delete_watchlist_entry),
) )
.route( .route(
"/settings/webhook-tokens", "/settings/webhook-tokens",
@@ -435,23 +433,23 @@ fn api_routes(rate_limit: u64) -> Router<AppState> {
) )
.route( .route(
"/admin/reindex-search", "/admin/reindex-search",
routing::post(handlers::api::post_reindex_search), routing::post(handlers::search::post_reindex_search),
) )
.route( .route(
"/goals", "/goals",
routing::get(handlers::api::list_goals).post(handlers::api::create_goal), routing::get(handlers::goals::list_goals).post(handlers::goals::create_goal),
) )
.route( .route(
"/goals/{year}", "/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( .route(
"/users/{id}/goals", "/users/{id}/goals",
routing::get(handlers::api::get_user_goals), routing::get(handlers::goals::get_user_goals),
) )
.route( .route(
"/settings", "/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")] #[cfg(feature = "federation")]
@@ -485,49 +483,60 @@ fn federation_api_routes() -> Router<AppState> {
Router::new() Router::new()
.route( .route(
"/social/following", "/social/following",
routing::get(handlers::api::get_following), routing::get(handlers::social::get_following),
) )
.route( .route(
"/social/followers", "/social/followers",
routing::get(handlers::api::get_followers), routing::get(handlers::social::get_followers),
) )
.route( .route(
"/social/followers/pending", "/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( .route(
"/social/followers/accept", "/social/followers/accept",
routing::post(handlers::api::accept_follower), routing::post(handlers::social::accept_follower),
) )
.route( .route(
"/social/followers/reject", "/social/followers/reject",
routing::post(handlers::api::reject_follower), routing::post(handlers::social::reject_follower),
) )
.route( .route(
"/social/followers/remove", "/social/followers/remove",
routing::post(handlers::api::remove_follower), routing::post(handlers::social::remove_follower),
) )
.route( .route(
"/admin/blocked-domains", "/admin/blocked-domains",
routing::get(handlers::api::get_blocked_domains_admin) routing::get(handlers::social::get_blocked_domains_admin)
.post(handlers::api::add_blocked_domain_admin), .post(handlers::social::add_blocked_domain_admin),
) )
.route( .route(
"/admin/blocked-domains/{domain}", "/admin/blocked-domains/{domain}",
routing::delete(handlers::api::remove_blocked_domain_admin), routing::delete(handlers::social::remove_blocked_domain_admin),
) )
.route( .route(
"/social/block", "/social/block",
routing::post(handlers::api::block_actor_api), routing::post(handlers::social::block_actor_api),
) )
.route( .route(
"/social/unblock", "/social/unblock",
routing::post(handlers::api::unblock_actor_api), routing::post(handlers::social::unblock_actor_api),
) )
.route( .route(
"/social/blocked", "/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),
) )
} }

View File

@@ -77,7 +77,7 @@ async fn search_endpoint_returns_200_with_empty_results() {
// Override the search_port with our stub // Override the search_port with our stub
state.app_ctx.repos.search_port = Arc::new(SearchPortStub); state.app_ctx.repos.search_port = Arc::new(SearchPortStub);
let app = Router::new() 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); .with_state(state);
let resp = app let resp = app
@@ -99,7 +99,7 @@ async fn search_endpoint_with_no_query_returns_200() {
// Override the search_port with our stub // Override the search_port with our stub
state.app_ctx.repos.search_port = Arc::new(SearchPortStub); state.app_ctx.repos.search_port = Arc::new(SearchPortStub);
let app = Router::new() 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); .with_state(state);
let resp = app let resp = app
@@ -125,7 +125,7 @@ async fn person_endpoint_returns_404_for_unknown_id() {
let app = Router::new() let app = Router::new()
.route( .route(
"/api/v1/people/{id}", "/api/v1/people/{id}",
get(crate::handlers::api::get_person_handler), get(crate::handlers::search::get_person_handler),
) )
.with_state(state); .with_state(state);
@@ -151,7 +151,7 @@ async fn person_credits_endpoint_returns_404_for_unknown_id() {
let app = Router::new() let app = Router::new()
.route( .route(
"/api/v1/people/{id}/credits", "/api/v1/people/{id}/credits",
get(crate::handlers::api::get_person_credits_handler), get(crate::handlers::search::get_person_credits_handler),
) )
.with_state(state); .with_state(state);
@@ -177,7 +177,7 @@ async fn get_watchlist_requires_auth() {
let app = Router::new() let app = Router::new()
.route( .route(
"/api/v1/watchlist", "/api/v1/watchlist",
get(crate::handlers::api::get_watchlist_handler), get(crate::handlers::watchlist::get_watchlist_handler),
) )
.with_state(state); .with_state(state);
@@ -200,7 +200,7 @@ async fn get_watchlist_status_requires_auth() {
let app = Router::new() let app = Router::new()
.route( .route(
"/api/v1/watchlist/{movie_id}", "/api/v1/watchlist/{movie_id}",
get(crate::handlers::api::get_watchlist_status), get(crate::handlers::watchlist::get_watchlist_status),
) )
.with_state(state); .with_state(state);