feat: implement movie listing functionality with pagination and search

This commit is contained in:
2026-05-12 13:57:55 +02:00
parent fb81aa10c1
commit 4269eca582
17 changed files with 239 additions and 64 deletions

View File

@@ -375,6 +375,52 @@ impl MovieRepository for PostgresRepository {
.map_err(Self::map_err)?;
Ok(())
}
async fn list_movies(
&self,
page: &domain::models::collections::PageParams,
search: Option<&str>,
) -> Result<domain::models::collections::Paginated<domain::models::Movie>, DomainError> {
use sqlx::Row;
let limit = page.limit as i64;
let offset = page.offset as i64;
let pattern = search.map(|s| format!("%{}%", s.to_lowercase()));
let rows: Vec<models::MovieRow> = sqlx::query_as(
"SELECT id, external_metadata_id, title, release_year, director, poster_path \
FROM movies \
WHERE ($1::text IS NULL OR LOWER(title) LIKE $1) \
ORDER BY title ASC \
LIMIT $2 OFFSET $3",
)
.bind(&pattern)
.bind(limit)
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
let total: i64 = sqlx::query(
"SELECT COUNT(*) FROM movies WHERE ($1::text IS NULL OR LOWER(title) LIKE $1)",
)
.bind(&pattern)
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)?
.try_get(0)
.unwrap_or(0);
let items = rows.into_iter()
.map(|r| r.to_domain())
.collect::<Result<Vec<_>, _>>()?;
Ok(domain::models::collections::Paginated {
items,
total_count: total as u64,
limit: page.limit,
offset: page.offset,
})
}
}
#[async_trait]

View File

@@ -383,6 +383,54 @@ impl MovieRepository for SqliteMovieRepository {
.map_err(Self::map_err)?;
Ok(())
}
async fn list_movies(
&self,
page: &domain::models::collections::PageParams,
search: Option<&str>,
) -> Result<domain::models::collections::Paginated<domain::models::Movie>, DomainError> {
use sqlx::Row;
let limit = page.limit as i64;
let offset = page.offset as i64;
let pattern = search.map(|s| format!("%{}%", s.to_lowercase()));
let rows: Vec<models::MovieRow> = sqlx::query_as(
"SELECT id, external_metadata_id, title, release_year, director, poster_path \
FROM movies \
WHERE (? IS NULL OR LOWER(title) LIKE ?) \
ORDER BY title ASC \
LIMIT ? OFFSET ?",
)
.bind(&pattern)
.bind(&pattern)
.bind(limit)
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
let total: i64 = sqlx::query(
"SELECT COUNT(*) FROM movies WHERE (? IS NULL OR LOWER(title) LIKE ?)",
)
.bind(&pattern)
.bind(&pattern)
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)?
.try_get(0)
.unwrap_or(0);
let items = rows.into_iter()
.map(|r| r.to_domain())
.collect::<Result<Vec<_>, _>>()?;
Ok(domain::models::collections::Paginated {
items,
total_count: total as u64,
limit: page.limit,
offset: page.offset,
})
}
}
#[async_trait]

View File

@@ -1,6 +1,25 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
// ── Movie list ────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Deserialize, utoipa::IntoParams)]
#[into_params(parameter_in = Query)]
pub struct MoviesQueryParams {
pub limit: Option<u32>,
pub offset: Option<u32>,
/// Optional title filter (case-insensitive substring match)
pub search: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct MoviesResponse {
pub items: Vec<MovieDto>,
pub total_count: u64,
pub limit: u32,
pub offset: u32,
}
// ── Movie profile (enrichment) ────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]

View File

@@ -1,5 +1,5 @@
use chrono::NaiveDateTime;
use domain::models::{ExportFormat, FieldMapping, FileFormat, UserRole};
use domain::models::{FieldMapping, FileFormat, UserRole};
use uuid::Uuid;
pub struct LogReviewCommand {
@@ -21,11 +21,6 @@ pub struct SyncPosterCommand {
pub external_metadata_id: String,
}
pub struct LoginCommand {
pub email: String,
pub password: String,
}
pub struct RegisterCommand {
pub email: String,
pub username: String,
@@ -38,11 +33,6 @@ pub struct DeleteReviewCommand {
pub requesting_user_id: Uuid,
}
pub struct ExportCommand {
pub user_id: Uuid,
pub format: ExportFormat,
}
// FileFormat is now in domain::models — no longer defined here
pub struct CreateImportSessionCommand {
@@ -79,3 +69,10 @@ pub struct DeleteImportProfileCommand {
pub user_id: Uuid,
pub profile_id: Uuid,
}
pub struct UpdateProfileCommand {
pub user_id: Uuid,
pub bio: Option<String>,
pub avatar_bytes: Option<Vec<u8>>,
pub avatar_content_type: Option<String>,
}

View File

@@ -226,9 +226,8 @@ mod tests {
async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> {
panic!("unexpected")
}
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> {
panic!("unexpected")
}
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { panic!("unexpected") }
async fn list_movies(&self, _: &domain::models::collections::PageParams, _: Option<&str>) -> Result<domain::models::collections::Paginated<Movie>, DomainError> { panic!("unexpected") }
}
#[async_trait]
@@ -249,12 +248,9 @@ mod tests {
) -> Result<Vec<Movie>, DomainError> {
Ok(vec![])
}
async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> {
panic!("unexpected")
}
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> {
panic!("unexpected")
}
async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { panic!("unexpected") }
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { panic!("unexpected") }
async fn list_movies(&self, _: &domain::models::collections::PageParams, _: Option<&str>) -> Result<domain::models::collections::Paginated<Movie>, DomainError> { panic!("unexpected") }
}
#[async_trait]
@@ -275,12 +271,9 @@ mod tests {
) -> Result<Vec<Movie>, DomainError> {
Ok(vec![self.0.clone()])
}
async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> {
panic!("unexpected")
}
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> {
panic!("unexpected")
}
async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { panic!("unexpected") }
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { panic!("unexpected") }
async fn list_movies(&self, _: &domain::models::collections::PageParams, _: Option<&str>) -> Result<domain::models::collections::Paginated<Movie>, DomainError> { panic!("unexpected") }
}
struct MetaReturnsMovie(Movie);

View File

@@ -1,6 +1,16 @@
use domain::models::SortDirection;
use domain::models::{ExportFormat, SortDirection};
use uuid::Uuid;
pub struct LoginQuery {
pub email: String,
pub password: String,
}
pub struct ExportQuery {
pub user_id: Uuid,
pub format: ExportFormat,
}
pub struct GetDiaryQuery {
pub limit: Option<u32>,
pub offset: Option<u32>,
@@ -70,3 +80,9 @@ pub struct GetMovieSocialPageQuery {
pub limit: u32,
pub offset: u32,
}
pub struct GetMoviesQuery {
pub limit: Option<u32>,
pub offset: Option<u32>,
pub search: Option<String>,
}

View File

@@ -1,13 +1,13 @@
use domain::{errors::DomainError, value_objects::UserId};
use crate::{commands::ExportCommand, context::AppContext};
use crate::{context::AppContext, queries::ExportQuery};
pub async fn execute(ctx: &AppContext, cmd: ExportCommand) -> Result<Vec<u8>, DomainError> {
pub async fn execute(ctx: &AppContext, query: ExportQuery) -> Result<Vec<u8>, DomainError> {
let entries = ctx
.diary_repository
.get_user_history(&UserId::from_uuid(cmd.user_id))
.get_user_history(&UserId::from_uuid(query.user_id))
.await?;
ctx.diary_exporter
.serialize_entries(&entries, cmd.format)
.serialize_entries(&entries, query.format)
.await
}

View File

@@ -0,0 +1,14 @@
use domain::{
errors::DomainError,
models::collections::{PageParams, Paginated},
models::Movie,
};
use crate::{context::AppContext, queries::GetMoviesQuery};
pub async fn execute(ctx: &AppContext, query: GetMoviesQuery) -> Result<Paginated<Movie>, DomainError> {
let page = PageParams::new(query.limit, query.offset)?;
ctx.movie_repository
.list_movies(&page, query.search.as_deref())
.await
}

View File

@@ -3,7 +3,7 @@ use uuid::Uuid;
use domain::{errors::DomainError, value_objects::Email};
use crate::{commands::LoginCommand, context::AppContext};
use crate::{context::AppContext, queries::LoginQuery};
pub struct LoginResult {
pub token: String,
@@ -12,8 +12,8 @@ pub struct LoginResult {
pub expires_at: DateTime<Utc>,
}
pub async fn execute(ctx: &AppContext, cmd: LoginCommand) -> Result<LoginResult, DomainError> {
let email = Email::new(cmd.email)?;
pub async fn execute(ctx: &AppContext, query: LoginQuery) -> Result<LoginResult, DomainError> {
let email = Email::new(query.email)?;
let user = ctx
.user_repository
.find_by_email(&email)
@@ -22,7 +22,7 @@ pub async fn execute(ctx: &AppContext, cmd: LoginCommand) -> Result<LoginResult,
let valid = ctx
.password_hasher
.verify(&cmd.password, user.password_hash())
.verify(&query.password, user.password_hash())
.await?;
if !valid {
return Err(DomainError::Unauthorized("Invalid credentials".into()));

View File

@@ -11,6 +11,7 @@ pub mod export_diary;
pub mod get_activity_feed;
pub mod get_diary;
pub mod get_movie_social_page;
pub mod get_movies;
pub mod get_review_history;
pub mod get_user_profile;
pub mod get_users;

View File

@@ -4,14 +4,7 @@ use domain::{
value_objects::UserId,
};
use crate::context::AppContext;
pub struct UpdateProfileCommand {
pub user_id: uuid::Uuid,
pub bio: Option<String>,
pub avatar_bytes: Option<Vec<u8>>,
pub avatar_content_type: Option<String>,
}
use crate::{commands::UpdateProfileCommand, context::AppContext};
pub async fn execute(ctx: &AppContext, cmd: UpdateProfileCommand) -> Result<(), DomainError> {
let user_id = UserId::from_uuid(cmd.user_id);

View File

@@ -8,7 +8,7 @@ use crate::{
AnnotatedRow, DiaryEntry, DiaryFilter, ExportFormat, FeedEntry, FieldMapping,
FileFormat, ImportError, ImportProfile, ImportSession, Movie, MovieProfile, MovieStats,
ParsedFile, Review, ReviewHistory, User, UserStats, UserSummary, UserTrends,
collections::{PageParams, Paginated},
collections::{self, PageParams, Paginated},
},
value_objects::{
Email, ExternalMetadataId, ImportProfileId, ImportSessionId, MovieId, MovieTitle,
@@ -83,6 +83,11 @@ pub trait MovieRepository: Send + Sync {
) -> Result<Vec<Movie>, DomainError>;
async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError>;
async fn delete_movie(&self, movie_id: &MovieId) -> Result<(), DomainError>;
async fn list_movies(
&self,
page: &collections::PageParams,
search: Option<&str>,
) -> Result<collections::Paginated<Movie>, DomainError>;
}
#[async_trait]

View File

@@ -177,6 +177,9 @@ mod tests {
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> {
panic!()
}
}
#[async_trait::async_trait]
impl ReviewRepository for Panic {

View File

@@ -10,15 +10,15 @@ use std::str::FromStr;
use application::{
commands::{
DeleteReviewCommand, ExportCommand, LoginCommand, RegisterCommand, SyncPosterCommand,
DeleteReviewCommand, RegisterCommand, SyncPosterCommand,
},
queries::{
GetActivityFeedQuery, GetMovieSocialPageQuery, GetReviewHistoryQuery, GetUserProfileQuery,
GetUsersQuery,
ExportQuery, GetActivityFeedQuery, GetMovieSocialPageQuery, GetMoviesQuery,
GetReviewHistoryQuery, GetUserProfileQuery, GetUsersQuery, LoginQuery,
},
use_cases::{
delete_review, export_diary as export_diary_uc, get_activity_feed as get_feed_uc,
get_diary, get_movie_social_page, get_review_history,
get_diary, get_movie_social_page, get_movies, get_review_history,
get_user_profile as get_user_profile_uc, get_users, log_review, login as login_uc,
register as register_uc, sync_poster, update_profile,
},
@@ -40,9 +40,9 @@ use api_types::{
DiaryQueryParams, DiaryResponse, DirectorStatDto, ExportQueryParams, FeedEntryDto,
GenreDto, KeywordDto, LogReviewRequest, LoginRequest, LoginResponse, MonthActivityDto,
MonthlyRatingDto, MovieDetailResponse, MovieDto, MovieProfileResponse, MovieStatsDto,
PaginationQueryParams, ProfileResponse, RegisterRequest, ReviewDto, ReviewHistoryResponse,
SocialFeedResponse, SocialReviewDto, UserProfileQueryParams, UserProfileResponse, UserStatsDto,
UserSummaryDto, UserTrendsDto, UsersResponse,
MoviesQueryParams, MoviesResponse, PaginationQueryParams, ProfileResponse, RegisterRequest,
ReviewDto, ReviewHistoryResponse, SocialFeedResponse, SocialReviewDto, UserProfileQueryParams,
UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto, UsersResponse,
};
use crate::{
errors::ApiError,
@@ -74,6 +74,35 @@ pub async fn get_diary(
}))
}
#[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,
},
)
.await?;
Ok(Json(MoviesResponse {
items: page.items.iter().map(movie_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")),
@@ -179,7 +208,7 @@ pub async fn login(
) -> Result<Json<LoginResponse>, ApiError> {
let result = login_uc::execute(
&state.app_ctx,
LoginCommand {
LoginQuery {
email: req.email,
password: req.password,
},
@@ -415,7 +444,7 @@ pub async fn update_profile_handler(
}
}
let cmd = update_profile::UpdateProfileCommand {
let cmd = application::commands::UpdateProfileCommand {
user_id: user_id.value(),
bio,
avatar_bytes,
@@ -1036,11 +1065,11 @@ pub async fn export_diary(
ExportFormat::Csv => ("text/csv; charset=utf-8", "diary.csv"),
ExportFormat::Json => ("application/json", "diary.json"),
};
let cmd = ExportCommand {
let query = ExportQuery {
user_id: user.0.value(),
format,
};
match export_diary_uc::execute(&state.app_ctx, cmd).await {
match export_diary_uc::execute(&state.app_ctx, query).await {
Ok(bytes) => (
StatusCode::OK,
[

View File

@@ -15,12 +15,12 @@ use application::ports::{
FollowersPageData, FollowingPageData,
};
use application::{
commands::{DeleteReviewCommand, ExportCommand, LoginCommand, RegisterCommand},
commands::{DeleteReviewCommand, RegisterCommand},
queries::{ExportQuery, GetMovieSocialPageQuery, LoginQuery},
ports::{
HtmlPageContext, LoginPageData, MovieDetailPageData, NewReviewPageData,
ProfileSettingsPageData, RegisterPageData, RemoteActorView,
},
queries::GetMovieSocialPageQuery,
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,
@@ -133,7 +133,7 @@ pub async fn post_login(
}
match login_uc::execute(
&state.app_ctx,
LoginCommand {
LoginQuery {
email: form.email,
password: form.password,
},
@@ -215,7 +215,7 @@ pub async fn post_register(
.await
{
Ok(_) => {
match login_uc::execute(&state.app_ctx, LoginCommand { email, password }).await {
match login_uc::execute(&state.app_ctx, LoginQuery { email, 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);
@@ -320,11 +320,11 @@ pub async fn get_export(
ExportFormat::Csv => ("text/csv; charset=utf-8", "diary.csv"),
ExportFormat::Json => ("application/json", "diary.json"),
};
let cmd = ExportCommand {
let query = ExportQuery {
user_id: user_id.value(),
format,
};
match export_diary_uc::execute(&state.app_ctx, cmd).await {
match export_diary_uc::execute(&state.app_ctx, query).await {
Ok(bytes) => (
StatusCode::OK,
[
@@ -1230,7 +1230,7 @@ pub async fn post_profile_settings(
}
}
let cmd = update_profile::UpdateProfileCommand {
let cmd = application::commands::UpdateProfileCommand {
user_id: user_id.value(),
bio,
avatar_bytes,

View File

@@ -1,20 +1,30 @@
use api_types::{
DirectorStatDto, MonthActivityDto, MonthlyRatingDto, MovieDetailResponse, MovieDto,
MovieStatsDto, ReviewHistoryResponse, SocialFeedResponse, SocialReviewDto, UserTrendsDto,
CastMemberDto, CrewMemberDto, DirectorStatDto, GenreDto, KeywordDto, MonthActivityDto,
MonthlyRatingDto, MovieDetailResponse, MovieDto, MovieProfileResponse, MovieStatsDto,
MoviesQueryParams, MoviesResponse, ReviewHistoryResponse, SocialFeedResponse, SocialReviewDto,
UserTrendsDto,
};
use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(
paths(
crate::handlers::api::list_movies,
crate::handlers::api::get_movie_detail,
crate::handlers::api::get_review_history,
crate::handlers::api::get_movie_profile,
crate::handlers::api::sync_poster,
),
components(schemas(
MoviesResponse,
MovieDto,
MovieDetailResponse,
MovieStatsDto,
MovieProfileResponse,
GenreDto,
KeywordDto,
CastMemberDto,
CrewMemberDto,
ReviewHistoryResponse,
SocialFeedResponse,
SocialReviewDto,

View File

@@ -177,6 +177,7 @@ fn api_routes(rate_limit: u64) -> Router<AppState> {
"/movies/{id}/history",
routing::get(handlers::api::get_review_history),
)
.route("/movies", routing::get(handlers::api::list_movies))
.route(
"/movies/{id}",
routing::get(handlers::api::get_movie_detail),