feat: add activity feed and user profile endpoints with corresponding DTOs
This commit is contained in:
@@ -260,6 +260,109 @@ pub struct ProfileQueryParams {
|
||||
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)]
|
||||
pub struct FollowRequest {
|
||||
pub handle: String,
|
||||
|
||||
@@ -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<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(
|
||||
get, path = "/api/v1/diary/export",
|
||||
params(ExportQueryParams),
|
||||
|
||||
@@ -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),
|
||||
)]
|
||||
|
||||
@@ -176,8 +176,12 @@ fn api_routes(rate_limit: u64) -> Router<AppState> {
|
||||
.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))
|
||||
|
||||
Reference in New Issue
Block a user