diff --git a/crates/adapters/postgres/src/lib.rs b/crates/adapters/postgres/src/lib.rs index 94c0ec7..ae124ec 100644 --- a/crates/adapters/postgres/src/lib.rs +++ b/crates/adapters/postgres/src/lib.rs @@ -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, 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 = 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::, _>>()?; + + Ok(domain::models::collections::Paginated { + items, + total_count: total as u64, + limit: page.limit, + offset: page.offset, + }) + } } #[async_trait] diff --git a/crates/adapters/sqlite/src/lib.rs b/crates/adapters/sqlite/src/lib.rs index 05c4a46..2c67771 100644 --- a/crates/adapters/sqlite/src/lib.rs +++ b/crates/adapters/sqlite/src/lib.rs @@ -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, 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 = 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::, _>>()?; + + Ok(domain::models::collections::Paginated { + items, + total_count: total as u64, + limit: page.limit, + offset: page.offset, + }) + } } #[async_trait] diff --git a/crates/api-types/src/movies.rs b/crates/api-types/src/movies.rs index 23bb1ae..72119b0 100644 --- a/crates/api-types/src/movies.rs +++ b/crates/api-types/src/movies.rs @@ -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, + pub offset: Option, + /// Optional title filter (case-insensitive substring match) + pub search: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct MoviesResponse { + pub items: Vec, + pub total_count: u64, + pub limit: u32, + pub offset: u32, +} + // ── Movie profile (enrichment) ──────────────────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] diff --git a/crates/application/src/commands.rs b/crates/application/src/commands.rs index 440c569..5186d63 100644 --- a/crates/application/src/commands.rs +++ b/crates/application/src/commands.rs @@ -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, + pub avatar_bytes: Option>, + pub avatar_content_type: Option, +} diff --git a/crates/application/src/movie_resolver.rs b/crates/application/src/movie_resolver.rs index 6b2649a..fed1706 100644 --- a/crates/application/src/movie_resolver.rs +++ b/crates/application/src/movie_resolver.rs @@ -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, DomainError> { panic!("unexpected") } } #[async_trait] @@ -249,12 +248,9 @@ mod tests { ) -> Result, 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, DomainError> { panic!("unexpected") } } #[async_trait] @@ -275,12 +271,9 @@ mod tests { ) -> Result, 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, DomainError> { panic!("unexpected") } } struct MetaReturnsMovie(Movie); diff --git a/crates/application/src/queries.rs b/crates/application/src/queries.rs index f22a6b4..61fd408 100644 --- a/crates/application/src/queries.rs +++ b/crates/application/src/queries.rs @@ -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, pub offset: Option, @@ -70,3 +80,9 @@ pub struct GetMovieSocialPageQuery { pub limit: u32, pub offset: u32, } + +pub struct GetMoviesQuery { + pub limit: Option, + pub offset: Option, + pub search: Option, +} diff --git a/crates/application/src/use_cases/export_diary.rs b/crates/application/src/use_cases/export_diary.rs index 7d3a4a7..927ef8a 100644 --- a/crates/application/src/use_cases/export_diary.rs +++ b/crates/application/src/use_cases/export_diary.rs @@ -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, DomainError> { +pub async fn execute(ctx: &AppContext, query: ExportQuery) -> Result, 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 } diff --git a/crates/application/src/use_cases/get_movies.rs b/crates/application/src/use_cases/get_movies.rs new file mode 100644 index 0000000..0c6242b --- /dev/null +++ b/crates/application/src/use_cases/get_movies.rs @@ -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, DomainError> { + let page = PageParams::new(query.limit, query.offset)?; + ctx.movie_repository + .list_movies(&page, query.search.as_deref()) + .await +} diff --git a/crates/application/src/use_cases/login.rs b/crates/application/src/use_cases/login.rs index 015747b..8198cee 100644 --- a/crates/application/src/use_cases/login.rs +++ b/crates/application/src/use_cases/login.rs @@ -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, } -pub async fn execute(ctx: &AppContext, cmd: LoginCommand) -> Result { - let email = Email::new(cmd.email)?; +pub async fn execute(ctx: &AppContext, query: LoginQuery) -> Result { + 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, - pub avatar_bytes: Option>, - pub avatar_content_type: Option, -} +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); diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index a9c44d1..4cca8ce 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -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, 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, DomainError>; } #[async_trait] diff --git a/crates/presentation/src/extractors.rs b/crates/presentation/src/extractors.rs index 72f8578..6e2d923 100644 --- a/crates/presentation/src/extractors.rs +++ b/crates/presentation/src/extractors.rs @@ -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, DomainError> { + panic!() + } } #[async_trait::async_trait] impl ReviewRepository for Panic { diff --git a/crates/presentation/src/handlers/api.rs b/crates/presentation/src/handlers/api.rs index c1dbc6e..a415d8a 100644 --- a/crates/presentation/src/handlers/api.rs +++ b/crates/presentation/src/handlers/api.rs @@ -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, + Query(params): Query, +) -> Result, 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, 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, [ diff --git a/crates/presentation/src/handlers/html.rs b/crates/presentation/src/handlers/html.rs index 2848f28..be16ca8 100644 --- a/crates/presentation/src/handlers/html.rs +++ b/crates/presentation/src/handlers/html.rs @@ -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, diff --git a/crates/presentation/src/openapi/movies.rs b/crates/presentation/src/openapi/movies.rs index e3e41f7..696e268 100644 --- a/crates/presentation/src/openapi/movies.rs +++ b/crates/presentation/src/openapi/movies.rs @@ -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, diff --git a/crates/presentation/src/routes.rs b/crates/presentation/src/routes.rs index 73a8eef..88e7112 100644 --- a/crates/presentation/src/routes.rs +++ b/crates/presentation/src/routes.rs @@ -177,6 +177,7 @@ fn api_routes(rate_limit: u64) -> Router { "/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),