feat: MovieDto enrichment, movie detail page, PWA, watchlist, watchlist federation
This commit is contained in:
@@ -2,7 +2,7 @@ use chrono::NaiveDateTime;
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use application::{commands::LogReviewCommand, queries::GetDiaryQuery};
|
||||
use application::{commands::{LogReviewCommand, MovieInput}, queries::GetDiaryQuery};
|
||||
use domain::{errors::DomainError, models::SortDirection};
|
||||
|
||||
use api_types::{DiaryQueryParams, LogReviewRequest};
|
||||
@@ -124,6 +124,25 @@ pub struct ActorUrlForm {
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct WatchlistAddForm {
|
||||
pub movie_id: Option<uuid::Uuid>,
|
||||
pub query: Option<String>,
|
||||
#[serde(default, deserialize_with = "empty_string_as_none")]
|
||||
pub year: Option<u16>,
|
||||
#[serde(rename = "_csrf", default)]
|
||||
pub csrf_token: String,
|
||||
#[serde(default)]
|
||||
pub redirect_after: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, Default)]
|
||||
pub struct WatchlistQuery {
|
||||
pub limit: Option<u32>,
|
||||
pub offset: Option<u32>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
pub struct ProfileQueryParams {
|
||||
pub view: Option<String>,
|
||||
@@ -206,14 +225,17 @@ impl TryFrom<LogReviewRequest> for LogReviewData {
|
||||
impl LogReviewData {
|
||||
pub fn into_command(self, user_id: Uuid) -> LogReviewCommand {
|
||||
LogReviewCommand {
|
||||
external_metadata_id: self.external_metadata_id,
|
||||
manual_title: self.manual_title,
|
||||
manual_release_year: self.manual_release_year,
|
||||
manual_director: self.manual_director,
|
||||
user_id,
|
||||
input: MovieInput {
|
||||
movie_id: None,
|
||||
external_metadata_id: self.external_metadata_id,
|
||||
manual_title: self.manual_title,
|
||||
manual_release_year: self.manual_release_year,
|
||||
manual_director: self.manual_director,
|
||||
},
|
||||
rating: self.rating,
|
||||
comment: self.comment,
|
||||
watched_at: self.watched_at,
|
||||
user_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,11 +10,13 @@ use std::str::FromStr;
|
||||
|
||||
use application::{
|
||||
commands::{
|
||||
DeleteReviewCommand, RegisterCommand, SyncPosterCommand,
|
||||
DeleteReviewCommand, MovieInput, RegisterCommand, SyncPosterCommand,
|
||||
AddToWatchlistCommand, RemoveFromWatchlistCommand,
|
||||
},
|
||||
queries::{
|
||||
ExportQuery, GetActivityFeedQuery, GetMovieSocialPageQuery, GetMoviesQuery,
|
||||
GetReviewHistoryQuery, GetUserProfileQuery, GetUsersQuery, LoginQuery,
|
||||
GetWatchlistQuery, IsOnWatchlistQuery,
|
||||
},
|
||||
use_cases::{
|
||||
delete_review, export_diary as export_diary_uc, get_activity_feed as get_feed_uc,
|
||||
@@ -22,11 +24,12 @@ use application::{
|
||||
get_user_profile as get_user_profile_uc, get_users, log_review, login as login_uc,
|
||||
register as register_uc, sync_poster, update_profile,
|
||||
search as search_uc, get_person, get_person_credits,
|
||||
add_to_watchlist, remove_from_watchlist, get_watchlist, is_on_watchlist,
|
||||
},
|
||||
};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{DiaryEntry, ExportFormat, Movie, Review, PersonId, collections::PageParams},
|
||||
models::{DiaryEntry, ExportFormat, Movie, MovieSummary, Review, PersonId, collections::PageParams},
|
||||
services::review_history::Trend,
|
||||
value_objects::{MovieId, UserId},
|
||||
};
|
||||
@@ -44,6 +47,7 @@ use api_types::{
|
||||
MoviesQueryParams, MoviesResponse, PaginationQueryParams, ProfileResponse, RegisterRequest,
|
||||
ReviewDto, ReviewHistoryResponse, SocialFeedResponse, SocialReviewDto, UserProfileQueryParams,
|
||||
UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto, UsersResponse,
|
||||
WatchlistResponse, WatchlistEntryDto, WatchlistStatusResponse, AddToWatchlistRequest,
|
||||
};
|
||||
use api_types::search::{
|
||||
CastCreditDto, CrewCreditDto, MovieSearchHitDto, PersonCreditsDto, PersonDto,
|
||||
@@ -96,12 +100,14 @@ pub async fn list_movies(
|
||||
limit: params.limit,
|
||||
offset: params.offset,
|
||||
search: params.search,
|
||||
genre: params.genre,
|
||||
language: params.language,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(MoviesResponse {
|
||||
items: page.items.iter().map(movie_to_dto).collect(),
|
||||
items: page.items.iter().map(summary_to_dto).collect(),
|
||||
total_count: page.total_count,
|
||||
limit: page.limit,
|
||||
offset: page.offset,
|
||||
@@ -438,12 +444,11 @@ pub async fn update_profile_handler(
|
||||
}
|
||||
"avatar" => {
|
||||
let content_type = field.content_type().map(|s| s.to_string());
|
||||
if let Ok(bytes) = field.bytes().await {
|
||||
if !bytes.is_empty() {
|
||||
if let Ok(bytes) = field.bytes().await
|
||||
&& !bytes.is_empty() {
|
||||
avatar_bytes = Some(bytes.to_vec());
|
||||
avatar_content_type = content_type;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -476,6 +481,26 @@ fn movie_to_dto(movie: &Movie) -> MovieDto {
|
||||
release_year: movie.release_year().value(),
|
||||
director: movie.director().map(|d| d.to_string()),
|
||||
poster_path: movie.poster_path().map(|p| p.value().to_string()),
|
||||
genres: vec![],
|
||||
runtime_minutes: None,
|
||||
original_language: None,
|
||||
overview: None,
|
||||
collection_name: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn summary_to_dto(summary: &MovieSummary) -> MovieDto {
|
||||
MovieDto {
|
||||
id: summary.movie.id().value(),
|
||||
title: summary.movie.title().value().to_string(),
|
||||
release_year: summary.movie.release_year().value(),
|
||||
director: summary.movie.director().map(|d| d.to_string()),
|
||||
poster_path: summary.movie.poster_path().map(|p| p.value().to_string()),
|
||||
genres: summary.genres.clone(),
|
||||
runtime_minutes: summary.runtime_minutes,
|
||||
original_language: summary.original_language.clone(),
|
||||
overview: summary.overview.clone(),
|
||||
collection_name: summary.collection_name.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1233,3 +1258,119 @@ pub async fn get_person_credits_handler(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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: 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: Some(req.movie_id),
|
||||
external_metadata_id: None,
|
||||
manual_title: None,
|
||||
manual_release_year: None,
|
||||
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 }))
|
||||
}
|
||||
|
||||
@@ -15,15 +15,17 @@ use application::ports::{
|
||||
FollowersPageData, FollowingPageData,
|
||||
};
|
||||
use application::{
|
||||
commands::{DeleteReviewCommand, RegisterCommand},
|
||||
queries::{ExportQuery, GetMovieSocialPageQuery, LoginQuery},
|
||||
commands::{AddToWatchlistCommand, DeleteReviewCommand, MovieInput, RegisterCommand, RemoveFromWatchlistCommand},
|
||||
queries::{ExportQuery, GetMovieSocialPageQuery, GetWatchlistQuery, LoginQuery},
|
||||
ports::{
|
||||
HtmlPageContext, LoginPageData, MovieDetailPageData, NewReviewPageData,
|
||||
ProfileSettingsPageData, RegisterPageData, RemoteActorView,
|
||||
ProfileSettingsPageData, RegisterPageData, RemoteActorView, WatchlistDisplayEntry,
|
||||
WatchlistPageData,
|
||||
},
|
||||
use_cases::{
|
||||
delete_review, export_diary as export_diary_uc, get_movie_social_page, log_review,
|
||||
login as login_uc, register as register_uc, update_profile,
|
||||
add_to_watchlist, delete_review, export_diary as export_diary_uc, get_movie_social_page,
|
||||
get_watchlist, log_review, login as login_uc, register as register_uc,
|
||||
remove_from_watchlist, update_profile,
|
||||
},
|
||||
};
|
||||
use domain::models::ExportFormat;
|
||||
@@ -383,12 +385,11 @@ pub async fn get_activity_feed(
|
||||
let mut local_ids = vec![uid.value()];
|
||||
let mut remote_urls = Vec::new();
|
||||
for url in urls {
|
||||
if let Some(suffix) = url.strip_prefix(&format!("{}/users/", base_url)) {
|
||||
if let Ok(parsed_id) = uuid::Uuid::parse_str(suffix) {
|
||||
if let Some(suffix) = url.strip_prefix(&format!("{}/users/", base_url))
|
||||
&& let Ok(parsed_id) = uuid::Uuid::parse_str(suffix) {
|
||||
local_ids.push(parsed_id);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
remote_urls.push(url);
|
||||
}
|
||||
Some(domain::ports::FollowingFilter {
|
||||
@@ -953,7 +954,7 @@ pub async fn get_movie_detail(
|
||||
Query(params): Query<api_types::PaginationQueryParams>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> impl IntoResponse {
|
||||
let ctx = build_page_context(&state, user_id, csrf.0).await;
|
||||
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);
|
||||
|
||||
@@ -973,10 +974,19 @@ pub async fn get_movie_detail(
|
||||
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) => state.app_ctx.watchlist_repository
|
||||
.contains(uid, &domain::value_objects::MovieId::from_uuid(movie_id))
|
||||
.await
|
||||
.unwrap_or(false),
|
||||
None => false,
|
||||
};
|
||||
let data = MovieDetailPageData {
|
||||
ctx,
|
||||
movie: result.movie,
|
||||
stats: result.stats,
|
||||
profile: result.profile,
|
||||
on_watchlist,
|
||||
current_offset: result.reviews.offset,
|
||||
has_more,
|
||||
limit: result.reviews.limit,
|
||||
@@ -994,6 +1004,206 @@ pub async fn get_movie_detail(
|
||||
}
|
||||
}
|
||||
|
||||
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 limit = params.limit.unwrap_or(20);
|
||||
let offset = params.offset.unwrap_or(0);
|
||||
let is_owner = viewer_id.map(|u| u.value() == owner_id).unwrap_or(false);
|
||||
|
||||
// Try local user first
|
||||
let local_user = state.app_ctx.user_repository
|
||||
.find_by_id(&domain::value_objects::UserId::from_uuid(owner_id))
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
let (display_entries, has_more, current_offset, page_limit) = if local_user.is_some() {
|
||||
match get_watchlist::execute(
|
||||
&state.app_ctx,
|
||||
GetWatchlistQuery { user_id: owner_id, limit: Some(limit), offset: Some(offset) },
|
||||
).await {
|
||||
Err(e) => {
|
||||
tracing::error!("watchlist error: {:?}", e);
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
Ok(entries) => {
|
||||
let has_more = entries.offset + entries.limit < entries.total_count as u32;
|
||||
let display: Vec<WatchlistDisplayEntry> = entries.items.iter().map(|w| {
|
||||
let remove_url = if is_owner {
|
||||
Some(format!("/watchlist/{}/remove", w.movie.id().value()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
WatchlistDisplayEntry {
|
||||
poster_url: w.movie.poster_path()
|
||||
.map(|p| format!("/images/{}", p.value())),
|
||||
movie_title: w.movie.title().value().to_string(),
|
||||
release_year: w.movie.release_year().value(),
|
||||
movie_url: Some(format!("/movies/{}", w.movie.id().value())),
|
||||
added_at: w.entry.added_at.format("%b %-d, %Y").to_string(),
|
||||
remove_url,
|
||||
}
|
||||
}).collect();
|
||||
(display, has_more, entries.offset, entries.limit)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
#[cfg(feature = "federation")]
|
||||
{
|
||||
let remote_entries = state.app_ctx.remote_watchlist_repository
|
||||
.get_by_derived_uuid(owner_id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let display: Vec<WatchlistDisplayEntry> = remote_entries.into_iter().map(|e| {
|
||||
WatchlistDisplayEntry {
|
||||
poster_url: e.poster_url,
|
||||
movie_title: e.movie_title,
|
||||
release_year: e.release_year,
|
||||
movie_url: None,
|
||||
added_at: e.added_at.format("%b %-d, %Y").to_string(),
|
||||
remove_url: None,
|
||||
}
|
||||
}).collect();
|
||||
let len = display.len() as u32;
|
||||
(display, false, 0u32, len)
|
||||
}
|
||||
#[cfg(not(feature = "federation"))]
|
||||
{
|
||||
(vec![], false, 0u32, 0u32)
|
||||
}
|
||||
};
|
||||
|
||||
let data = WatchlistPageData {
|
||||
ctx,
|
||||
owner_id,
|
||||
display_entries,
|
||||
current_offset,
|
||||
has_more,
|
||||
limit: page_limit,
|
||||
is_owner,
|
||||
error: params.error,
|
||||
};
|
||||
match state.html_renderer.render_watchlist_page(data) {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("watchlist template error: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn post_watchlist_add(
|
||||
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) => {
|
||||
tracing::error!("watchlist add error: {:?}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn post_watchlist_remove(
|
||||
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) => {
|
||||
tracing::error!("watchlist remove error: {:?}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, Default)]
|
||||
pub struct SavedQuery {
|
||||
pub saved: Option<String>,
|
||||
@@ -1219,12 +1429,11 @@ pub async fn post_profile_settings(
|
||||
}
|
||||
"avatar" => {
|
||||
let content_type = field.content_type().map(|s| s.to_string());
|
||||
if let Ok(bytes) = field.bytes().await {
|
||||
if !bytes.is_empty() {
|
||||
if let Ok(bytes) = field.bytes().await
|
||||
&& !bytes.is_empty() {
|
||||
avatar_bytes = Some(bytes.to_vec());
|
||||
avatar_content_type = content_type;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -15,8 +15,6 @@ use presentation::{openapi, routes, state::AppState};
|
||||
|
||||
use domain::ports::{DiaryExporter, DocumentParser, EventPublisher, ImportProfileRepository, ImportSessionRepository};
|
||||
|
||||
#[cfg(feature = "sqlite")]
|
||||
use sqlite_search;
|
||||
#[cfg(feature = "postgres")]
|
||||
use postgres_search;
|
||||
|
||||
@@ -54,21 +52,21 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
|
||||
let poster_fetcher = poster_fetcher::create()?;
|
||||
let image_storage = image_storage::create()?;
|
||||
|
||||
let (movie_repository, review_repository, diary_repository, stats_repository, user_repository, import_session_repository, import_profile_repository, movie_profile_repository, person_command, person_query, search_command, search_port, db_pool) =
|
||||
let (movie_repository, review_repository, diary_repository, stats_repository, user_repository, import_session_repository, import_profile_repository, movie_profile_repository, watchlist_repository, person_command, person_query, search_command, search_port, db_pool) =
|
||||
match backend.as_str() {
|
||||
#[cfg(feature = "postgres")]
|
||||
"postgres" => {
|
||||
let (pool, m, r, d, s, u, is, ip, mp) = postgres::wire(&database_url).await?;
|
||||
let (pool, m, r, d, s, u, is, ip, mp, wl) = postgres::wire(&database_url).await?;
|
||||
let (pc, pq) = postgres::create_person_adapter(pool.clone());
|
||||
let (sc, sp) = postgres_search::create_search_adapter(pool.clone());
|
||||
(m, r, d, s, u, is, ip, mp, pc, pq, sc, sp, DbPool::Postgres(pool))
|
||||
(m, r, d, s, u, is, ip, mp, wl, pc, pq, sc, sp, DbPool::Postgres(pool))
|
||||
}
|
||||
#[cfg(feature = "sqlite")]
|
||||
_ => {
|
||||
let (pool, m, r, d, s, u, is, ip, mp) = sqlite::wire(&database_url).await?;
|
||||
let (pool, m, r, d, s, u, is, ip, mp, wl) = sqlite::wire(&database_url).await?;
|
||||
let (pc, pq) = sqlite::create_person_adapter(pool.clone());
|
||||
let (sc, sp) = sqlite_search::create_search_adapter(pool.clone());
|
||||
(m, r, d, s, u, is, ip, mp, pc, pq, sc, sp, DbPool::Sqlite(pool))
|
||||
(m, r, d, s, u, is, ip, mp, wl, pc, pq, sc, sp, DbPool::Sqlite(pool))
|
||||
}
|
||||
#[cfg(not(feature = "sqlite"))]
|
||||
_ => anyhow::bail!("DATABASE_BACKEND={backend} is not supported by this build (sqlite feature is not enabled)"),
|
||||
@@ -78,8 +76,8 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
|
||||
let event_bus = EventBusBackend::from_env()?;
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
let (event_publisher_arc, ap_router, ap_service, social_query) = {
|
||||
let (federation_repo, social_query_arc, review_store) = match &db_pool {
|
||||
let (event_publisher_arc, ap_router, ap_service, social_query, remote_watchlist_repo) = {
|
||||
let (federation_repo, social_query_arc, review_store, remote_watchlist_repo) = match &db_pool {
|
||||
#[cfg(feature = "postgres-federation")]
|
||||
DbPool::Postgres(pool) => postgres_federation::wire(pool.clone()),
|
||||
#[cfg(feature = "sqlite-federation")]
|
||||
@@ -91,6 +89,8 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
|
||||
let ap = activitypub::wire(
|
||||
federation_repo,
|
||||
review_store,
|
||||
remote_watchlist_repo.clone(),
|
||||
Arc::clone(&watchlist_repository),
|
||||
Arc::clone(&user_repository),
|
||||
Arc::clone(&movie_repository),
|
||||
Arc::clone(&review_repository),
|
||||
@@ -123,7 +123,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
|
||||
nats::create_publisher(cfg).await?
|
||||
}
|
||||
};
|
||||
(ep, ap_router, ap_service_arc, social_query_arc)
|
||||
(ep, ap_router, ap_service_arc, social_query_arc, remote_watchlist_repo)
|
||||
};
|
||||
|
||||
#[cfg(not(feature = "federation"))]
|
||||
@@ -171,6 +171,9 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
|
||||
import_session_repository: import_session_repository as Arc<dyn ImportSessionRepository>,
|
||||
import_profile_repository: import_profile_repository as Arc<dyn ImportProfileRepository>,
|
||||
movie_profile_repository,
|
||||
watchlist_repository,
|
||||
#[cfg(feature = "federation")]
|
||||
remote_watchlist_repository: remote_watchlist_repo,
|
||||
person_command,
|
||||
person_query,
|
||||
search_port,
|
||||
|
||||
@@ -5,6 +5,7 @@ mod movies;
|
||||
mod search;
|
||||
mod social;
|
||||
mod users;
|
||||
mod watchlist;
|
||||
|
||||
use axum::Router;
|
||||
use utoipa::{
|
||||
@@ -38,6 +39,7 @@ fn build() -> utoipa::openapi::OpenApi {
|
||||
api.merge(users::UsersDoc::openapi());
|
||||
api.merge(import::ImportDoc::openapi());
|
||||
api.merge(search::SearchDoc::openapi());
|
||||
api.merge(watchlist::WatchlistDoc::openapi());
|
||||
#[cfg(feature = "federation")]
|
||||
api.merge(social::SocialDoc::openapi());
|
||||
SecurityAddon.modify(&mut api);
|
||||
|
||||
19
crates/presentation/src/openapi/watchlist.rs
Normal file
19
crates/presentation/src/openapi/watchlist.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
use api_types::{AddToWatchlistRequest, WatchlistEntryDto, WatchlistResponse, WatchlistStatusResponse};
|
||||
use utoipa::OpenApi;
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(
|
||||
crate::handlers::api::get_watchlist_handler,
|
||||
crate::handlers::api::post_watchlist_add,
|
||||
crate::handlers::api::delete_watchlist_entry,
|
||||
crate::handlers::api::get_watchlist_status,
|
||||
),
|
||||
components(schemas(
|
||||
WatchlistResponse,
|
||||
WatchlistEntryDto,
|
||||
AddToWatchlistRequest,
|
||||
WatchlistStatusResponse,
|
||||
))
|
||||
)]
|
||||
pub struct WatchlistDoc;
|
||||
@@ -107,7 +107,19 @@ fn html_routes(rate_limit: u64) -> Router<AppState> {
|
||||
routing::get(handlers::html::get_profile_settings)
|
||||
.post(handlers::html::post_profile_settings),
|
||||
)
|
||||
.route("/tags/{tag}", routing::get(handlers::html::get_tag));
|
||||
.route("/tags/{tag}", routing::get(handlers::html::get_tag))
|
||||
.route(
|
||||
"/users/{id}/watchlist",
|
||||
routing::get(handlers::html::get_watchlist_page),
|
||||
)
|
||||
.route(
|
||||
"/watchlist/add",
|
||||
routing::post(handlers::html::post_watchlist_add),
|
||||
)
|
||||
.route(
|
||||
"/watchlist/{movie_id}/remove",
|
||||
routing::post(handlers::html::post_watchlist_remove),
|
||||
);
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
let base = base.merge(federation_html_routes());
|
||||
@@ -213,7 +225,17 @@ fn api_routes(rate_limit: u64) -> Router<AppState> {
|
||||
.route("/profile", routing::get(handlers::api::get_profile).put(handlers::api::update_profile_handler))
|
||||
.route("/search", routing::get(handlers::api::get_search))
|
||||
.route("/people/{id}", routing::get(handlers::api::get_person_handler))
|
||||
.route("/people/{id}/credits", routing::get(handlers::api::get_person_credits_handler));
|
||||
.route("/people/{id}/credits", routing::get(handlers::api::get_person_credits_handler))
|
||||
.route(
|
||||
"/watchlist",
|
||||
routing::get(handlers::api::get_watchlist_handler)
|
||||
.post(handlers::api::post_watchlist_add),
|
||||
)
|
||||
.route(
|
||||
"/watchlist/{movie_id}",
|
||||
routing::get(handlers::api::get_watchlist_status)
|
||||
.delete(handlers::api::delete_watchlist_entry),
|
||||
);
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
let base = base.merge(federation_api_routes());
|
||||
|
||||
@@ -143,3 +143,48 @@ async fn person_credits_endpoint_returns_404_for_unknown_id() {
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
// --- Watchlist endpoint tests ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_watchlist_requires_auth() {
|
||||
let state = make_test_state(Arc::new(Panic));
|
||||
let app = Router::new()
|
||||
.route("/api/v1/watchlist", get(crate::handlers::api::get_watchlist_handler))
|
||||
.with_state(state);
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/watchlist")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_watchlist_status_requires_auth() {
|
||||
let state = make_test_state(Arc::new(Panic));
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/api/v1/watchlist/{movie_id}",
|
||||
get(crate::handlers::api::get_watchlist_status),
|
||||
)
|
||||
.with_state(state);
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/watchlist/00000000-0000-0000-0000-000000000001")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ use domain::{
|
||||
ports::{
|
||||
AuthService, DiaryRepository, EventPublisher, GeneratedToken, ImageStorage,
|
||||
MetadataClient, MovieRepository, PasswordHasher, PosterFetcherClient, ReviewRepository,
|
||||
StatsRepository, UserRepository,
|
||||
StatsRepository, UserRepository, WatchlistRepository,
|
||||
PersonCommand, PersonQuery, SearchPort, SearchCommand,
|
||||
},
|
||||
value_objects::{
|
||||
@@ -58,7 +58,7 @@ impl MovieRepository for Panic {
|
||||
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn list_movies(&self, _: &domain::models::collections::PageParams, _: Option<&str>) -> Result<domain::models::collections::Paginated<Movie>, DomainError> {
|
||||
async fn list_movies(&self, _: &domain::models::collections::PageParams, _: &domain::models::MovieFilter) -> Result<domain::models::collections::Paginated<domain::models::MovieSummary>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
@@ -242,6 +242,14 @@ impl domain::ports::ImportProfileRepository for Panic {
|
||||
async fn delete(&self, _: &domain::value_objects::ImportProfileId) -> Result<(), DomainError> { panic!() }
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl WatchlistRepository for Panic {
|
||||
async fn add(&self, _: &domain::models::WatchlistEntry) -> Result<(), DomainError> { panic!() }
|
||||
async fn remove(&self, _: &domain::value_objects::UserId, _: &domain::value_objects::MovieId) -> Result<(), DomainError> { panic!() }
|
||||
async fn remove_if_present(&self, _: &domain::value_objects::UserId, _: &domain::value_objects::MovieId) -> Result<bool, DomainError> { Ok(false) }
|
||||
async fn get_for_user(&self, _: &domain::value_objects::UserId, _: &domain::models::collections::PageParams) -> Result<domain::models::collections::Paginated<domain::models::WatchlistWithMovie>, DomainError> { panic!() }
|
||||
async fn contains(&self, _: &domain::value_objects::UserId, _: &domain::value_objects::MovieId) -> Result<bool, DomainError> { Ok(false) }
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::MovieProfileRepository for Panic {
|
||||
async fn upsert(&self, _: &domain::models::MovieProfile) -> Result<(), DomainError> { panic!() }
|
||||
async fn get_by_movie_id(&self, _: &domain::value_objects::MovieId) -> Result<Option<domain::models::MovieProfile>, DomainError> { Ok(None) }
|
||||
@@ -335,6 +343,7 @@ impl crate::ports::HtmlRenderer for Panic {
|
||||
fn render_profile_settings_page(&self, _: application::ports::ProfileSettingsPageData) -> Result<String, String> { panic!() }
|
||||
fn render_blocked_domains_page(&self, _: application::ports::BlockedDomainsPageData) -> Result<String, String> { panic!() }
|
||||
fn render_blocked_actors_page(&self, _: application::ports::BlockedActorsPageData) -> Result<String, String> { panic!() }
|
||||
fn render_watchlist_page(&self, _: application::ports::WatchlistPageData) -> Result<String, String> { panic!() }
|
||||
}
|
||||
impl crate::ports::RssFeedRenderer for Panic {
|
||||
fn render_feed(&self, _: &[DiaryEntry], _: &str) -> Result<String, String> {
|
||||
@@ -373,6 +382,15 @@ impl SearchCommand for Panic {
|
||||
async fn index(&self, _: IndexableDocument) -> Result<(), DomainError> { panic!() }
|
||||
async fn remove(&self, _: EntityType, _: &str) -> Result<(), DomainError> { panic!() }
|
||||
}
|
||||
#[cfg(feature = "federation")]
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::RemoteWatchlistRepository for Panic {
|
||||
async fn save(&self, _: domain::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<domain::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<domain::models::RemoteWatchlistEntry>, DomainError> { Ok(vec![]) }
|
||||
}
|
||||
|
||||
// --- Single state factory — only auth_service varies ---
|
||||
|
||||
@@ -395,6 +413,9 @@ pub fn make_test_state(auth_service: Arc<dyn AuthService>) -> crate::state::AppS
|
||||
import_session_repository: Arc::clone(&repo) as _,
|
||||
import_profile_repository: Arc::clone(&repo) as _,
|
||||
movie_profile_repository: Arc::clone(&repo) as _,
|
||||
watchlist_repository: Arc::clone(&repo) as _,
|
||||
#[cfg(feature = "federation")]
|
||||
remote_watchlist_repository: Arc::clone(&repo) as _,
|
||||
person_command: Arc::clone(&repo) as _,
|
||||
person_query: Arc::clone(&repo) as _,
|
||||
search_port: Arc::clone(&repo) as _,
|
||||
|
||||
@@ -1,45 +1,10 @@
|
||||
// Re-export imports needed by subtest modules
|
||||
pub use application::{config::AppConfig, context::AppContext};
|
||||
pub use axum::{
|
||||
Router,
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
routing::get,
|
||||
};
|
||||
pub use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::{
|
||||
DiaryEntry, DiaryFilter, FeedEntry, Movie, Review, ReviewHistory, UserStats,
|
||||
UserTrends,
|
||||
collections::{PageParams, Paginated},
|
||||
PersonId, EntityType, IndexableDocument, Person, PersonCredits,
|
||||
SearchQuery, SearchResults,
|
||||
},
|
||||
ports::{
|
||||
AuthService, DiaryRepository, EventPublisher, GeneratedToken, ImageStorage,
|
||||
MetadataClient, MovieRepository, PasswordHasher, PosterFetcherClient, ReviewRepository,
|
||||
StatsRepository, UserRepository,
|
||||
PersonCommand, PersonQuery, SearchPort, SearchCommand,
|
||||
},
|
||||
value_objects::{
|
||||
Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterUrl,
|
||||
ReleaseYear, ReviewId, UserId,
|
||||
},
|
||||
};
|
||||
pub use std::sync::Arc;
|
||||
pub use tower::ServiceExt;
|
||||
|
||||
// API types for tests
|
||||
pub use api_types::{
|
||||
LoginRequest, LogReviewRequest, DiaryQueryParams,
|
||||
};
|
||||
pub use crate::{
|
||||
extractors::{AuthenticatedUser, OptionalCookieUser, RequiredCookieUser},
|
||||
forms::{LogReviewData, LogReviewForm, to_diary_query},
|
||||
state::AppState,
|
||||
};
|
||||
pub use api_types::{DiaryQueryParams, LogReviewRequest};
|
||||
|
||||
mod api_handlers;
|
||||
mod extractors;
|
||||
mod forms;
|
||||
mod api_handlers;
|
||||
|
||||
Reference in New Issue
Block a user