feat: add activity feed and user profile endpoints with corresponding DTOs

This commit is contained in:
2026-05-09 21:40:45 +02:00
parent fa501706cd
commit e8874f9220
4 changed files with 335 additions and 10 deletions

View File

@@ -260,6 +260,109 @@ pub struct ProfileQueryParams {
pub error: Option<String>, pub error: Option<String>,
} }
// ── Activity feed ─────────────────────────────────────────────────────────────
#[derive(Deserialize, utoipa::IntoParams)]
#[into_params(parameter_in = Query)]
pub struct ActivityFeedQueryParams {
pub limit: Option<u32>,
pub offset: Option<u32>,
}
#[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<FeedEntryDto>,
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<f64>,
}
#[derive(Serialize, utoipa::ToSchema)]
pub struct UsersResponse {
pub users: Vec<UserSummaryDto>,
}
// ── User profile ───────────────────────────────────────────────────────────────
#[derive(Deserialize, utoipa::IntoParams)]
#[into_params(parameter_in = Query)]
pub struct UserProfileQueryParams {
/// One of: `recent` (default), `ratings`, `history`, `trends`
pub view: Option<String>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
#[derive(Serialize, utoipa::ToSchema)]
pub struct UserStatsDto {
pub total_movies: i64,
pub avg_rating: Option<f64>,
pub favorite_director: Option<String>,
pub most_active_month: Option<String>,
}
#[derive(Serialize, utoipa::ToSchema)]
pub struct MonthActivityDto {
pub year_month: String,
pub month_label: String,
pub count: i64,
pub entries: Vec<DiaryEntryDto>,
}
#[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<MonthlyRatingDto>,
pub top_directors: Vec<DirectorStatDto>,
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<DiaryResponse>,
/// Populated for view=history
pub history: Option<Vec<MonthActivityDto>>,
/// Populated for view=trends
pub trends: Option<UserTrendsDto>,
}
#[derive(Deserialize, utoipa::ToSchema)] #[derive(Deserialize, utoipa::ToSchema)]
pub struct FollowRequest { pub struct FollowRequest {
pub handle: String, pub handle: String,

View File

@@ -845,11 +845,14 @@ pub mod api {
}; };
use uuid::Uuid; use uuid::Uuid;
use std::str::FromStr;
use application::{ use application::{
commands::{DeleteReviewCommand, ExportCommand, LoginCommand, RegisterCommand, SyncPosterCommand}, commands::{DeleteReviewCommand, ExportCommand, LoginCommand, RegisterCommand, SyncPosterCommand},
queries::GetReviewHistoryQuery, queries::{GetActivityFeedQuery, GetReviewHistoryQuery, GetUserProfileQuery, GetUsersQuery},
use_cases::{ 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, log_review, login as login_uc, register as register_uc, sync_poster,
}, },
}; };
@@ -857,15 +860,17 @@ pub mod api {
errors::DomainError, errors::DomainError,
models::{DiaryEntry, ExportFormat, Movie, Review}, models::{DiaryEntry, ExportFormat, Movie, Review},
services::review_history::Trend, services::review_history::Trend,
value_objects::MovieId, value_objects::{MovieId, UserId},
}; };
use crate::{ use crate::{
dtos::{ dtos::{
ActorListResponse, ActorUrlRequest, DiaryEntryDto, DiaryQueryParams, DiaryResponse, ActivityFeedQueryParams, ActivityFeedResponse, ActorListResponse, ActorUrlRequest,
ExportQueryParams, FollowRequest, LogReviewData, LogReviewRequest, LoginRequest, DiaryEntryDto, DiaryQueryParams, DiaryResponse, DirectorStatDto, ExportQueryParams,
LoginResponse, MovieDto, RegisterRequest, RemoteActorDto, ReviewDto, FeedEntryDto, FollowRequest, LogReviewData, LogReviewRequest, LoginRequest,
ReviewHistoryResponse, LoginResponse, MonthActivityDto, MonthlyRatingDto, MovieDto, RegisterRequest,
RemoteActorDto, ReviewDto, ReviewHistoryResponse, UserProfileQueryParams,
UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto, UsersResponse,
}, },
errors::ApiError, errors::ApiError,
extractors::AuthenticatedUser, 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<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(),
}
}
#[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, 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<AppState>,
) -> Result<Json<UsersResponse>, 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<AppState>,
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::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( #[utoipa::path(
get, path = "/api/v1/diary/export", get, path = "/api/v1/diary/export",
params(ExportQueryParams), params(ExportQueryParams),

View File

@@ -4,9 +4,11 @@ use utoipa::{
}; };
use crate::dtos::{ use crate::dtos::{
ActorListResponse, ActorUrlRequest, DiaryEntryDto, DiaryResponse, FollowRequest, LoginRequest, ActivityFeedResponse, ActorListResponse, ActorUrlRequest, DiaryEntryDto, DiaryResponse,
LoginResponse, LogReviewRequest, MovieDto, RegisterRequest, RemoteActorDto, ReviewDto, DirectorStatDto, FeedEntryDto, FollowRequest, LoginRequest, LoginResponse, LogReviewRequest,
ReviewHistoryResponse, MonthActivityDto, MonthlyRatingDto, MovieDto, RegisterRequest, RemoteActorDto, ReviewDto,
ReviewHistoryResponse, UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto,
UsersResponse,
}; };
struct SecurityAddon; struct SecurityAddon;
@@ -37,8 +39,12 @@ impl Modify for SecurityAddon {
crate::handlers::api::login, crate::handlers::api::login,
crate::handlers::api::register, crate::handlers::api::register,
crate::handlers::api::export_diary, 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_following,
crate::handlers::api::get_followers, crate::handlers::api::get_followers,
crate::handlers::api::get_pending_followers,
crate::handlers::api::follow, crate::handlers::api::follow,
crate::handlers::api::unfollow, crate::handlers::api::unfollow,
crate::handlers::api::accept_follower, crate::handlers::api::accept_follower,
@@ -59,6 +65,16 @@ impl Modify for SecurityAddon {
RemoteActorDto, RemoteActorDto,
FollowRequest, FollowRequest,
ActorUrlRequest, ActorUrlRequest,
ActivityFeedResponse,
FeedEntryDto,
UsersResponse,
UserSummaryDto,
UserProfileResponse,
UserStatsDto,
MonthActivityDto,
MonthlyRatingDto,
DirectorStatDto,
UserTrendsDto,
)), )),
modifiers(&SecurityAddon), modifiers(&SecurityAddon),
)] )]

View File

@@ -176,8 +176,12 @@ fn api_routes(rate_limit: u64) -> Router<AppState> {
.route("/auth/login", routing::post(handlers::api::login)) .route("/auth/login", routing::post(handlers::api::login))
.route("/auth/register", routing::post(handlers::api::register)) .route("/auth/register", routing::post(handlers::api::register))
.route("/diary/export", routing::get(handlers::api::export_diary)) .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/following", routing::get(handlers::api::get_following))
.route("/social/followers", routing::get(handlers::api::get_followers)) .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/follow", routing::post(handlers::api::follow))
.route("/social/unfollow", routing::post(handlers::api::unfollow)) .route("/social/unfollow", routing::post(handlers::api::unfollow))
.route("/social/followers/accept", routing::post(handlers::api::accept_follower)) .route("/social/followers/accept", routing::post(handlers::api::accept_follower))