refactor: split monolithic handlers + testing into domain-grouped modules
Some checks failed
CI / Check / Test (push) Has been cancelled
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:
@@ -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
|
||||||
|
|||||||
@@ -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
153
crates/domain/src/testing/fakes.rs
Normal file
153
crates/domain/src/testing/fakes.rs
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
312
crates/domain/src/testing/in_memory.rs
Normal file
312
crates/domain/src/testing/in_memory.rs
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
11
crates/domain/src/testing/mod.rs
Normal file
11
crates/domain/src/testing/mod.rs
Normal 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::*;
|
||||||
191
crates/domain/src/testing/noops.rs
Normal file
191
crates/domain/src/testing/noops.rs
Normal 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![])
|
||||||
|
}
|
||||||
|
}
|
||||||
405
crates/domain/src/testing/panics.rs
Normal file
405
crates/domain/src/testing/panics.rs
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
247
crates/domain/src/testing/wrapup.rs
Normal file
247
crates/domain/src/testing/wrapup.rs
Normal 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
221
crates/presentation/src/handlers/auth.rs
Normal file
221
crates/presentation/src/handlers/auth.rs
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
362
crates/presentation/src/handlers/diary.rs
Normal file
362
crates/presentation/src/handlers/diary.rs
Normal 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
199
crates/presentation/src/handlers/goals.rs
Normal file
199
crates/presentation/src/handlers/goals.rs
Normal 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)
|
||||||
|
}
|
||||||
34
crates/presentation/src/handlers/helpers.rs
Normal file
34
crates/presentation/src/handlers/helpers.rs
Normal 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
@@ -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>
|
||||||
|
|||||||
210
crates/presentation/src/handlers/integrations.rs
Normal file
210
crates/presentation/src/handlers/integrations.rs
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
318
crates/presentation/src/handlers/movies.rs
Normal file
318
crates/presentation/src/handlers/movies.rs
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
191
crates/presentation/src/handlers/search.rs
Normal file
191
crates/presentation/src/handlers/search.rs
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
913
crates/presentation/src/handlers/social.rs
Normal file
913
crates/presentation/src/handlers/social.rs
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
826
crates/presentation/src/handlers/users.rs
Normal file
826
crates/presentation/src/handlers/users.rs
Normal 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()
|
||||||
|
}
|
||||||
311
crates/presentation/src/handlers/watchlist.rs
Normal file
311
crates/presentation/src/handlers/watchlist.rs
Normal 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user