diff --git a/crates/presentation/src/dtos.rs b/crates/presentation/src/dtos.rs index f568c69..1839cfe 100644 --- a/crates/presentation/src/dtos.rs +++ b/crates/presentation/src/dtos.rs @@ -260,6 +260,109 @@ pub struct ProfileQueryParams { pub error: Option, } +// ── Activity feed ───────────────────────────────────────────────────────────── + +#[derive(Deserialize, utoipa::IntoParams)] +#[into_params(parameter_in = Query)] +pub struct ActivityFeedQueryParams { + pub limit: Option, + pub offset: Option, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct FeedEntryDto { + pub movie: MovieDto, + pub review: ReviewDto, + pub user_email: String, + pub user_display_name: String, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct ActivityFeedResponse { + pub items: Vec, + pub total_count: u64, + pub limit: u32, + pub offset: u32, +} + +// ── Users ────────────────────────────────────────────────────────────────────── + +#[derive(Serialize, utoipa::ToSchema)] +pub struct UserSummaryDto { + pub id: Uuid, + pub email: String, + pub total_movies: i64, + pub avg_rating: Option, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct UsersResponse { + pub users: Vec, +} + +// ── User profile ─────────────────────────────────────────────────────────────── + +#[derive(Deserialize, utoipa::IntoParams)] +#[into_params(parameter_in = Query)] +pub struct UserProfileQueryParams { + /// One of: `recent` (default), `ratings`, `history`, `trends` + pub view: Option, + pub limit: Option, + pub offset: Option, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct UserStatsDto { + pub total_movies: i64, + pub avg_rating: Option, + pub favorite_director: Option, + pub most_active_month: Option, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct MonthActivityDto { + pub year_month: String, + pub month_label: String, + pub count: i64, + pub entries: Vec, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct MonthlyRatingDto { + pub year_month: String, + pub month_label: String, + pub avg_rating: f64, + pub count: i64, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct DirectorStatDto { + pub director: String, + pub count: i64, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct UserTrendsDto { + pub monthly_ratings: Vec, + pub top_directors: Vec, + pub max_director_count: i64, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct UserProfileResponse { + pub user_id: Uuid, + pub username: String, + pub stats: UserStatsDto, + pub following_count: usize, + pub followers_count: usize, + /// Populated for view=recent and view=ratings + pub entries: Option, + /// Populated for view=history + pub history: Option>, + /// Populated for view=trends + pub trends: Option, +} + #[derive(Deserialize, utoipa::ToSchema)] pub struct FollowRequest { pub handle: String, diff --git a/crates/presentation/src/handlers.rs b/crates/presentation/src/handlers.rs index 50888c5..472ddf1 100644 --- a/crates/presentation/src/handlers.rs +++ b/crates/presentation/src/handlers.rs @@ -845,11 +845,14 @@ pub mod api { }; use uuid::Uuid; + use std::str::FromStr; + use application::{ commands::{DeleteReviewCommand, ExportCommand, LoginCommand, RegisterCommand, SyncPosterCommand}, - queries::GetReviewHistoryQuery, + queries::{GetActivityFeedQuery, GetReviewHistoryQuery, GetUserProfileQuery, GetUsersQuery}, use_cases::{ - delete_review, export_diary as export_diary_uc, get_diary, get_review_history, + delete_review, export_diary as export_diary_uc, get_activity_feed as get_feed_uc, + get_diary, 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, }, }; @@ -857,15 +860,17 @@ pub mod api { errors::DomainError, models::{DiaryEntry, ExportFormat, Movie, Review}, services::review_history::Trend, - value_objects::MovieId, + value_objects::{MovieId, UserId}, }; use crate::{ dtos::{ - ActorListResponse, ActorUrlRequest, DiaryEntryDto, DiaryQueryParams, DiaryResponse, - ExportQueryParams, FollowRequest, LogReviewData, LogReviewRequest, LoginRequest, - LoginResponse, MovieDto, RegisterRequest, RemoteActorDto, ReviewDto, - ReviewHistoryResponse, + ActivityFeedQueryParams, ActivityFeedResponse, ActorListResponse, ActorUrlRequest, + DiaryEntryDto, DiaryQueryParams, DiaryResponse, DirectorStatDto, ExportQueryParams, + FeedEntryDto, FollowRequest, LogReviewData, LogReviewRequest, LoginRequest, + LoginResponse, MonthActivityDto, MonthlyRatingDto, MovieDto, RegisterRequest, + RemoteActorDto, ReviewDto, ReviewHistoryResponse, UserProfileQueryParams, + UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto, UsersResponse, }, errors::ApiError, extractors::AuthenticatedUser, @@ -1256,6 +1261,203 @@ pub mod api { } } + #[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, + 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(), + } + } + + #[utoipa::path( + get, path = "/api/v1/activity-feed", + params(ActivityFeedQueryParams), + responses((status = 200, body = ActivityFeedResponse)), + )] + pub async fn get_activity_feed( + State(state): State, + Query(params): Query, + ) -> Result, ApiError> { + let page = get_feed_uc::execute( + &state.app_ctx, + GetActivityFeedQuery { limit: params.limit, offset: params.offset }, + ) + .await?; + Ok(Json(ActivityFeedResponse { + items: page + .items + .iter() + .map(|e| FeedEntryDto { + movie: movie_to_dto(e.movie()), + review: review_to_dto(e.review()), + user_email: e.user_email().to_string(), + user_display_name: e.user_display_name().to_string(), + }) + .collect(), + total_count: page.total_count, + limit: page.limit, + offset: page.offset, + })) + } + + #[utoipa::path( + get, path = "/api/v1/users", + responses((status = 200, body = UsersResponse)), + )] + pub async fn list_users( + State(state): State, + ) -> Result, ApiError> { + let users = get_users::execute(&state.app_ctx, GetUsersQuery).await?; + Ok(Json(UsersResponse { + users: users + .iter() + .map(|u| UserSummaryDto { + id: u.user_id.value(), + email: u.email().to_string(), + 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, + Path(user_id): Path, + Query(params): Query, + ) -> impl IntoResponse { + let view_str = params.view.as_deref().unwrap_or("recent"); + let profile_view = match application::queries::ProfileView::from_str(view_str) { + Ok(v) => v, + Err(_) => return StatusCode::BAD_REQUEST.into_response(), + }; + + let user = match state + .app_ctx + .user_repository + .find_by_id(&UserId::from_uuid(user_id)) + .await + { + Ok(Some(u)) => u, + Ok(None) => return StatusCode::NOT_FOUND.into_response(), + Err(e) => { + tracing::error!("user lookup: {:?}", e); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; + + let profile = match get_user_profile_uc::execute( + &state.app_ctx, + GetUserProfileQuery { + user_id, + view: profile_view, + limit: params.limit, + offset: params.offset, + }, + ) + .await + { + Ok(p) => p, + Err(e) => { + tracing::error!("profile: {:?}", e); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; + + let following_count = state.ap_service.count_following(user_id).await.unwrap_or(0); + let followers_count = state + .ap_service + .count_accepted_followers(user_id) + .await + .unwrap_or(0); + + let entries = profile.entries.map(|p| DiaryResponse { + items: p.items.iter().map(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(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(), + 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, + followers_count, + entries, + history, + trends, + }) + .into_response() + } + #[utoipa::path( get, path = "/api/v1/diary/export", params(ExportQueryParams), diff --git a/crates/presentation/src/openapi.rs b/crates/presentation/src/openapi.rs index da3e78f..6c4d2c0 100644 --- a/crates/presentation/src/openapi.rs +++ b/crates/presentation/src/openapi.rs @@ -4,9 +4,11 @@ use utoipa::{ }; use crate::dtos::{ - ActorListResponse, ActorUrlRequest, DiaryEntryDto, DiaryResponse, FollowRequest, LoginRequest, - LoginResponse, LogReviewRequest, MovieDto, RegisterRequest, RemoteActorDto, ReviewDto, - ReviewHistoryResponse, + ActivityFeedResponse, ActorListResponse, ActorUrlRequest, DiaryEntryDto, DiaryResponse, + DirectorStatDto, FeedEntryDto, FollowRequest, LoginRequest, LoginResponse, LogReviewRequest, + MonthActivityDto, MonthlyRatingDto, MovieDto, RegisterRequest, RemoteActorDto, ReviewDto, + ReviewHistoryResponse, UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto, + UsersResponse, }; struct SecurityAddon; @@ -37,8 +39,12 @@ impl Modify for SecurityAddon { crate::handlers::api::login, crate::handlers::api::register, crate::handlers::api::export_diary, + crate::handlers::api::get_activity_feed, + crate::handlers::api::list_users, + crate::handlers::api::get_user_profile, crate::handlers::api::get_following, crate::handlers::api::get_followers, + crate::handlers::api::get_pending_followers, crate::handlers::api::follow, crate::handlers::api::unfollow, crate::handlers::api::accept_follower, @@ -59,6 +65,16 @@ impl Modify for SecurityAddon { RemoteActorDto, FollowRequest, ActorUrlRequest, + ActivityFeedResponse, + FeedEntryDto, + UsersResponse, + UserSummaryDto, + UserProfileResponse, + UserStatsDto, + MonthActivityDto, + MonthlyRatingDto, + DirectorStatDto, + UserTrendsDto, )), modifiers(&SecurityAddon), )] diff --git a/crates/presentation/src/routes.rs b/crates/presentation/src/routes.rs index 117796e..a3de5f4 100644 --- a/crates/presentation/src/routes.rs +++ b/crates/presentation/src/routes.rs @@ -176,8 +176,12 @@ fn api_routes(rate_limit: u64) -> Router { .route("/auth/login", routing::post(handlers::api::login)) .route("/auth/register", routing::post(handlers::api::register)) .route("/diary/export", routing::get(handlers::api::export_diary)) + .route("/activity-feed", routing::get(handlers::api::get_activity_feed)) + .route("/users", routing::get(handlers::api::list_users)) + .route("/users/{id}", routing::get(handlers::api::get_user_profile)) .route("/social/following", routing::get(handlers::api::get_following)) .route("/social/followers", routing::get(handlers::api::get_followers)) + .route("/social/followers/pending", routing::get(handlers::api::get_pending_followers)) .route("/social/follow", routing::post(handlers::api::follow)) .route("/social/unfollow", routing::post(handlers::api::unfollow)) .route("/social/followers/accept", routing::post(handlers::api::accept_follower))