refactor: http-axum split into api/html/federation modules, router.rs, templates per page group

This commit is contained in:
2026-10-25 21:46:03 +01:00
parent a931fb548f
commit 614aec8df9
97 changed files with 5317 additions and 5160 deletions

View File

@@ -0,0 +1,279 @@
use crate::{extractors::AuthenticatedUser, state::AppState};
use api_types::mappers::goal_with_progress_to_dto;
use api_types::{
ActorStatDto, DiaryResponse, DirectorStatDto, GenreStatDto, MonthActivityDto, MonthlyRatingDto,
UserProfileQueryParams, UserProfileResponse, UserStatsDto, UserTrendsDto, WatchMediumStatDto,
};
use application::users::{
get_federated_profile, get_federated_profile_stats, get_local_profile,
queries::GetUserProfileQuery,
};
use axum::{
Json,
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
};
use domain::errors::DomainError;
use std::str::FromStr;
use uuid::Uuid;
#[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>,
AuthenticatedUser(viewer_id): AuthenticatedUser,
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::users::queries::ProfileView::from_str(view_str) {
Ok(v) => v,
Err(_) => return StatusCode::BAD_REQUEST.into_response(),
};
let profile = match get_local_profile::execute(
&state.app_ctx.deps.users.get_local_profile,
GetUserProfileQuery {
user_id,
view: profile_view,
limit: params.limit,
offset: params.offset,
sort_by: domain::models::FeedSortBy::Date,
search: params.search.clone(),
is_own_profile: viewer_id.value() == user_id,
include_remote: false,
},
)
.await
{
Ok(p) => p,
Err(DomainError::NotFound(_)) => {
if let Ok(Some(fed)) = get_federated_profile::execute(
&state.app_ctx.deps.users.get_federated_profile,
user_id,
)
.await
{
return build_federated_profile_response(
&state,
user_id,
fed,
profile_view,
&params,
)
.await;
}
return StatusCode::NOT_FOUND.into_response();
}
Err(e) => return crate::errors::domain_error_response(e),
};
let view_data = if let Some(p) = profile.entries {
Some(api_types::ProfileViewData::Entries {
entries: DiaryResponse {
items: p
.items
.iter()
.map(api_types::mappers::entry_to_dto)
.collect(),
total_count: p.total_count,
limit: p.limit,
offset: p.offset,
},
})
} else if let Some(h) = profile.history {
Some(api_types::ProfileViewData::History {
history: application::users::group_by_month(h)
.into_iter()
.map(|m| MonthActivityDto {
year_month: m.year_month,
month_label: m.month_label,
count: m.count,
entries: m
.entries
.iter()
.map(api_types::mappers::entry_to_dto)
.collect(),
})
.collect(),
})
} else {
profile.trends.map(|t| api_types::ProfileViewData::Trends {
trends: trends_to_dto(t),
})
};
Json(UserProfileResponse {
user_id,
profile: api_types::UserProfileBase {
username: profile.identity.username.clone(),
avatar_url: profile.identity.avatar_url.clone(),
banner_url: profile.identity.banner_url.clone(),
display_name: profile.identity.display_name.clone(),
bio: profile.identity.bio.clone(),
},
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: profile.following_count,
followers_count: profile.followers_count,
view_data,
goals: {
let goals_list = application::goals::list::execute(
&state.app_ctx.deps.goals.query,
application::goals::queries::ListGoalsQuery { user_id },
)
.await
.unwrap_or_default();
if goals_list.is_empty() {
None
} else {
Some(goals_list.iter().map(goal_with_progress_to_dto).collect())
}
},
is_federated: false,
handle: Some(profile.identity.handle.clone()),
actor_url: Some(profile.identity.actor_url.clone()),
})
.into_response()
}
async fn build_federated_profile_response(
state: &AppState,
user_id: Uuid,
fed: domain::models::FederatedProfile,
profile_view: application::users::queries::ProfileView,
params: &UserProfileQueryParams,
) -> axum::response::Response {
let profile = match get_federated_profile_stats::execute(
&state.app_ctx.deps.users.get_federated_profile_stats,
GetUserProfileQuery {
user_id,
view: profile_view,
limit: params.limit,
offset: params.offset,
sort_by: domain::models::FeedSortBy::Date,
search: params.search.clone(),
is_own_profile: false,
include_remote: true,
},
)
.await
{
Ok(p) => p,
Err(e) => return crate::errors::domain_error_response(e),
};
let view_data = if let Some(p) = profile.entries {
Some(api_types::ProfileViewData::Entries {
entries: DiaryResponse {
items: p
.items
.iter()
.map(api_types::mappers::entry_to_dto)
.collect(),
total_count: p.total_count,
limit: p.limit,
offset: p.offset,
},
})
} else {
profile.trends.map(|t| api_types::ProfileViewData::Trends {
trends: trends_to_dto(t),
})
};
let username = fed
.display_name
.clone()
.unwrap_or_else(|| fed.handle.clone());
Json(UserProfileResponse {
user_id,
profile: api_types::UserProfileBase {
username,
avatar_url: fed.avatar_url,
banner_url: fed.banner_url,
display_name: fed.display_name,
bio: fed.bio,
},
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: 0,
followers_count: 0,
view_data,
goals: None,
is_federated: true,
handle: Some(fed.handle),
actor_url: Some(fed.actor_url),
})
.into_response()
}
fn trends_to_dto(t: domain::models::UserTrends) -> UserTrendsDto {
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,
top_genres: t
.top_genres
.into_iter()
.map(|g| GenreStatDto {
genre: g.genre,
count: g.count,
})
.collect(),
rating_distribution: t.rating_distribution,
watch_medium_distribution: t
.watch_medium_distribution
.into_iter()
.map(|m| WatchMediumStatDto {
medium: m.medium,
count: m.count,
})
.collect(),
top_actors: t
.top_actors
.into_iter()
.map(|a| ActorStatDto {
name: a.name,
profile_path: a.profile_path,
movie_count: a.movie_count,
score: a.score,
})
.collect(),
}
}