Files
movies-diary/crates/presentation/src/handlers/users.rs

867 lines
30 KiB
Rust

use std::str::FromStr;
use axum::{
Json,
extract::{Extension, Multipart, Path, Query, State},
http::StatusCode,
response::IntoResponse,
};
use uuid::Uuid;
use application::users::{
deps::{GetProfileDeps, UpdateProfileDeps},
get_profile as get_user_profile_uc, get_users,
queries::{GetUserProfileQuery, GetUsersQuery},
update_profile, update_profile_fields,
};
use domain::value_objects::UserId;
use crate::{
csrf::CsrfToken,
errors::ApiError,
extractors::{AuthenticatedUser, OptionalCookieUser, RequiredCookieUser},
render::render_page,
state::AppState,
};
use api_types::{
DiaryResponse, DirectorStatDto, MonthActivityDto, MonthlyRatingDto, ProfileResponse,
UserProfileQueryParams, UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto,
UsersResponse,
};
use template_askama::{
EmbedProfileTemplate, MonthlyRatingRow, ProfileSettingsTemplate, ProfileTemplate,
RemoteActorData, RemoteActorDisplay, UserSummaryView, UsersTemplate, bar_height_px,
build_heatmap, build_page_items,
};
use super::goals::goal_with_progress_to_dto;
use super::helpers::build_page_context;
// ── API ──────────────────────────────────────────────────────────────────────
#[utoipa::path(
get, path = "/api/v1/profile",
responses(
(status = 200, body = ProfileResponse),
(status = 401, description = "Unauthorized"),
(status = 404, description = "User not found"),
),
security(("bearer_auth" = []))
)]
pub async fn get_profile(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
) -> Result<Json<ProfileResponse>, ApiError> {
let profile = application::users::get_current_profile::execute(
state.app_ctx.repos.user.clone(),
application::users::queries::GetCurrentProfileQuery {
user_id: user_id.value(),
},
)
.await?;
let base_url = &state.app_ctx.config.base_url;
Ok(Json(ProfileResponse {
username: profile.username,
display_name: profile.display_name,
bio: profile.bio,
avatar_url: profile
.avatar_path
.map(|p| format!("{}/images/{}", base_url, p)),
banner_url: profile
.banner_path
.map(|p| format!("{}/images/{}", base_url, p)),
also_known_as: profile.also_known_as,
fields: profile
.fields
.into_iter()
.map(|f| api_types::ProfileFieldDto {
name: f.name,
value: f.value,
})
.collect(),
role: profile.role,
}))
}
#[utoipa::path(
put, path = "/api/v1/profile",
responses(
(status = 204, description = "Profile updated"),
(status = 400, description = "Invalid input"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn update_profile_handler(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
mut multipart: Multipart,
) -> impl IntoResponse {
let mut display_name: Option<String> = None;
let mut bio: Option<String> = None;
let mut avatar_bytes: Option<Vec<u8>> = None;
let mut avatar_content_type: Option<String> = None;
let mut banner_bytes: Option<Vec<u8>> = None;
let mut banner_content_type: Option<String> = None;
let mut also_known_as: Option<String> = None;
while let Ok(Some(field)) = multipart.next_field().await {
let name = field.name().unwrap_or("").to_string();
match name.as_str() {
"display_name" => {
if let Ok(text) = field.text().await {
display_name = Some(text).filter(|s| !s.is_empty());
}
}
"bio" => {
if let Ok(text) = field.text().await {
bio = Some(text);
}
}
"also_known_as" => {
if let Ok(text) = field.text().await {
also_known_as = Some(text).filter(|s| !s.is_empty());
}
}
"avatar" => {
let ct = field.content_type().map(|s| s.to_string());
if let Ok(bytes) = field.bytes().await
&& !bytes.is_empty()
{
avatar_bytes = Some(bytes.to_vec());
avatar_content_type = ct;
}
}
"banner" => {
let ct = field.content_type().map(|s| s.to_string());
if let Ok(bytes) = field.bytes().await
&& !bytes.is_empty()
{
banner_bytes = Some(bytes.to_vec());
banner_content_type = ct;
}
}
_ => {}
}
}
let cmd = application::users::commands::UpdateProfileCommand {
user_id: user_id.value(),
display_name,
bio,
avatar_bytes,
avatar_content_type,
banner_bytes,
banner_content_type,
also_known_as,
};
let deps = UpdateProfileDeps {
user: state.app_ctx.repos.user.clone(),
object_storage: state.app_ctx.services.object_storage.clone(),
event_publisher: state.app_ctx.services.event_publisher.clone(),
};
match update_profile::execute(&deps, cmd).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => crate::errors::domain_error_response(e),
}
}
#[utoipa::path(
put, path = "/api/v1/profile/fields",
request_body = api_types::UpdateProfileFieldsRequest,
responses(
(status = 204, description = "Profile fields updated"),
(status = 400, description = "Invalid input"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn update_profile_fields_handler(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
axum::Json(body): axum::Json<serde_json::Value>,
) -> impl IntoResponse {
let raw_fields = match body.get("fields").and_then(|f| f.as_array()) {
Some(arr) => arr.clone(),
None => return StatusCode::BAD_REQUEST.into_response(),
};
let fields: Vec<domain::models::ProfileField> = raw_fields
.iter()
.filter_map(|f| {
let name = f.get("name").and_then(|n| n.as_str())?.to_string();
let value = f.get("value").and_then(|v| v.as_str())?.to_string();
Some(domain::models::ProfileField { name, value })
})
.collect();
let cmd = application::users::commands::UpdateProfileFieldsCommand {
user_id: user_id.value(),
fields,
};
match update_profile_fields::execute(
state.app_ctx.repos.profile_fields.clone(),
state.app_ctx.services.event_publisher.clone(),
cmd,
)
.await
{
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => crate::errors::domain_error_response(e),
}
}
#[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 result = get_users::execute(
state.app_ctx.repos.user.clone(),
state.app_ctx.repos.social_query.clone(),
GetUsersQuery,
)
.await?;
Ok(Json(UsersResponse {
users: result
.users
.iter()
.map(|u| UserSummaryDto {
id: u.user_id.value(),
email: u.email().to_string(),
username: u.username().to_string(),
display_name: u.display_name().map(String::from),
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>,
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 user = match state
.app_ctx
.repos
.user
.find_by_id(&UserId::from_uuid(user_id))
.await
{
Ok(Some(u)) => u,
Ok(None) => return StatusCode::NOT_FOUND.into_response(),
Err(e) => {
return crate::errors::domain_error_response(e);
}
};
let get_profile_deps = GetProfileDeps {
stats: state.app_ctx.repos.stats.clone(),
diary: state.app_ctx.repos.diary.clone(),
social_query: state.app_ctx.repos.social_query.clone(),
};
let profile = match get_user_profile_uc::execute(
&get_profile_deps,
GetUserProfileQuery {
user_id,
view: profile_view,
limit: params.limit,
offset: params.offset,
sort_by: domain::ports::FeedSortBy::Date,
search: params.search,
is_own_profile: viewer_id.value() == user_id,
},
)
.await
{
Ok(p) => p,
Err(e) => return crate::errors::domain_error_response(e),
};
let entries = profile.entries.map(|p| DiaryResponse {
items: p
.items
.iter()
.map(crate::mappers::movies::entry_to_dto)
.collect(),
total_count: p.total_count,
limit: p.limit,
offset: p.offset,
});
let history = profile.history.map(|entries| {
crate::mappers::users::group_by_month(entries)
.into_iter()
.map(|m| MonthActivityDto {
year_month: m.year_month,
month_label: m.month_label,
count: m.count,
entries: m
.entries
.iter()
.map(crate::mappers::movies::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(),
avatar_url: user
.avatar_path()
.map(|p| format!("{}/images/{}", state.app_ctx.config.base_url, p)),
banner_url: user
.banner_path()
.map(|p| format!("{}/images/{}", state.app_ctx.config.base_url, p)),
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,
entries,
history,
trends,
goals: {
let goals_list = application::goals::list::execute(
state.app_ctx.repos.goal.clone(),
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())
}
},
})
.into_response()
}
// ── HTML ─────────────────────────────────────────────────────────────────────
pub async fn get_users_list(
OptionalCookieUser(user_id): OptionalCookieUser,
State(state): State<AppState>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
let mut ctx = build_page_context(&state, user_id, csrf.0).await;
ctx.page_title = "Members — Movies Diary".to_string();
ctx.canonical_url = format!("{}/users", state.app_ctx.config.base_url);
match application::users::get_users::execute(
state.app_ctx.repos.user.clone(),
state.app_ctx.repos.social_query.clone(),
application::users::queries::GetUsersQuery,
)
.await
{
Ok(result) => {
let users: Vec<UserSummaryView> = result
.users
.iter()
.map(crate::mappers::users::user_summary_view)
.collect();
let remote_actors: Vec<RemoteActorDisplay> = result
.remote_actors
.iter()
.map(crate::mappers::users::remote_actor_display)
.collect();
render_page(UsersTemplate {
users,
ctx: &ctx,
remote_actors,
})
.into_response()
}
Err(e) => crate::errors::domain_error_response(e),
}
}
pub async fn get_user_by_username(
State(state): State<AppState>,
Path(username): Path<String>,
) -> impl IntoResponse {
let uname = match domain::value_objects::Username::new(username) {
Ok(u) => u,
Err(_) => return StatusCode::NOT_FOUND.into_response(),
};
match state.app_ctx.repos.user.find_by_username(&uname).await {
Ok(Some(user)) => {
axum::response::Redirect::permanent(&format!("/users/{}", user.id().value()))
.into_response()
}
_ => StatusCode::NOT_FOUND.into_response(),
}
}
pub async fn get_user_profile_html(
OptionalCookieUser(user_id): OptionalCookieUser,
State(state): State<AppState>,
Path(profile_user_uuid): Path<Uuid>,
headers: axum::http::HeaderMap,
Query(params): Query<crate::forms::ProfileQueryParams>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
// Content negotiation: AP clients request application/activity+json
#[cfg(feature = "federation")]
{
let accept = headers
.get(axum::http::header::ACCEPT)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if accept.contains("application/activity+json") || accept.contains("application/ld+json") {
return match state
.ap_service
.actor_json(&profile_user_uuid.to_string())
.await
{
Ok(json) => (
[(
axum::http::header::CONTENT_TYPE,
"application/activity+json",
)],
json,
)
.into_response(),
Err(_) => StatusCode::NOT_FOUND.into_response(),
};
}
}
#[cfg(not(feature = "federation"))]
let _ = &headers;
let mut ctx = build_page_context(&state, user_id.clone(), csrf.0).await;
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 (
axum::http::StatusCode::BAD_REQUEST,
"invalid view parameter",
)
.into_response();
}
};
let profile_user = match state
.app_ctx
.repos
.user
.find_by_id(&domain::value_objects::UserId::from_uuid(profile_user_uuid))
.await
{
Ok(Some(u)) => u,
Ok(None) => return StatusCode::NOT_FOUND.into_response(),
Err(e) => return crate::errors::domain_error_response(e),
};
let display_name = profile_user.username().value();
ctx.page_title = format!("{}'s Diary — Movies Diary", display_name);
ctx.canonical_url = format!(
"{}/users/{}",
state.app_ctx.config.base_url, profile_user_uuid
);
let sort_by_str = match params.sort_by.as_str() {
"date_asc" => "date_asc",
"rating" => "rating",
"rating_asc" => "rating_asc",
_ => "date",
};
let is_own_profile = user_id
.as_ref()
.map(|u| u.value() == profile_user_uuid)
.unwrap_or(false);
let query = application::users::queries::GetUserProfileQuery {
user_id: profile_user_uuid,
view: profile_view,
limit: params.limit,
offset: params.offset,
sort_by: sort_by_str.parse().unwrap_or_default(),
search: if params.search.is_empty() {
None
} else {
Some(params.search.clone())
},
is_own_profile,
};
let html_profile_deps = GetProfileDeps {
stats: state.app_ctx.repos.stats.clone(),
diary: state.app_ctx.repos.diary.clone(),
social_query: state.app_ctx.repos.social_query.clone(),
};
match application::users::get_profile::execute(&html_profile_deps, query).await {
Ok(profile) => {
let (offset, has_more, limit) = profile
.entries
.as_ref()
.map(|e| {
let has_more = (e.offset as u64).saturating_add(e.limit as u64) < e.total_count;
(e.offset, has_more, e.limit)
})
.unwrap_or((0, false, super::DEFAULT_PAGE_LIMIT));
if !is_own_profile {
ctx.page_rss_url = Some(format!("/users/{}/feed.rss", profile_user_uuid));
}
let email = profile_user.email().value().to_string();
let display_name = email.split('@').next().unwrap_or("?").to_string();
let avg_rating_display = profile
.stats
.avg_rating
.map(|r| format!("{:.1}", r))
.unwrap_or_else(|| "\u{2014}".to_string());
let favorite_director_display = profile
.stats
.favorite_director
.clone()
.unwrap_or_else(|| "\u{2014}".to_string());
let most_active_month_display = profile
.stats
.most_active_month
.clone()
.unwrap_or_else(|| "\u{2014}".to_string());
let history = profile.history.map(crate::mappers::users::group_by_month);
let heatmap = history.as_deref().map(build_heatmap).unwrap_or_default();
let monthly_rating_rows: Vec<MonthlyRatingRow<'_>> = profile
.trends
.as_ref()
.map(|t| {
t.monthly_ratings
.iter()
.map(|r| MonthlyRatingRow {
rating: r,
bar_height_px: bar_height_px(r.avg_rating),
})
.collect()
})
.unwrap_or_default();
let total = profile
.entries
.as_ref()
.map(|e| e.total_count as u32)
.unwrap_or(0);
let total_pages = total
.saturating_add(limit.saturating_sub(1))
.checked_div(limit)
.unwrap_or(1);
let current_page = offset.checked_div(limit).unwrap_or(0);
let page_items = build_page_items(total_pages, current_page);
let pending_followers: Vec<RemoteActorData> = profile
.pending_followers
.iter()
.map(crate::mappers::users::pending_follower_data)
.collect();
if params.embed {
let profile_url = format!(
"{}/users/{}",
state.app_ctx.config.base_url, profile_user_uuid
);
let response = render_page(EmbedProfileTemplate {
profile_display_name: display_name,
profile_user_id: profile_user_uuid,
profile_url,
stats: &profile.stats,
avg_rating_display,
favorite_director_display,
most_active_month_display,
view: profile_view.as_str(),
entries: profile.entries.as_ref(),
current_offset: offset,
has_more,
limit,
history: history.as_ref(),
trends: profile.trends.as_ref(),
monthly_rating_rows,
heatmap,
page_items,
sort_by: sort_by_str.to_string(),
});
let mut resp = response.into_response();
resp.headers_mut().remove("x-frame-options");
resp
} else {
render_page(ProfileTemplate {
ctx: &ctx,
profile_display_name: display_name,
profile_user_id: profile_user_uuid,
stats: &profile.stats,
avg_rating_display,
favorite_director_display,
most_active_month_display,
view: profile_view.as_str(),
entries: profile.entries.as_ref(),
current_offset: offset,
has_more,
limit,
history: history.as_ref(),
trends: profile.trends.as_ref(),
monthly_rating_rows,
heatmap,
page_items,
is_own_profile,
error: params.error,
following_count: profile.following_count,
followers_count: profile.followers_count,
pending_followers,
sort_by: sort_by_str.to_string(),
search: params.search.clone(),
goals: {
let goals_list = application::goals::list::execute(
state.app_ctx.repos.goal.clone(),
application::goals::queries::ListGoalsQuery {
user_id: profile_user_uuid,
},
)
.await
.unwrap_or_default();
goals_list
.iter()
.map(|g| template_askama::GoalViewData {
year: g.goal.year(),
target_count: g.goal.target_count(),
current_count: g.current_count,
percentage: g.percentage().round(),
is_complete: g.is_complete(),
})
.collect()
},
})
.into_response()
}
}
Err(e) => crate::errors::domain_error_response(e),
}
}
#[derive(serde::Deserialize, Default)]
pub struct SavedQuery {
pub saved: Option<String>,
}
pub async fn get_profile_settings(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
Query(params): Query<SavedQuery>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await;
ctx.page_title = "Profile Settings — Movies Diary".to_string();
ctx.canonical_url = format!("{}/settings/profile", state.app_ctx.config.base_url);
let user = match state.app_ctx.repos.user.find_by_id(&user_id).await {
Ok(Some(u)) => u,
Ok(None) => return StatusCode::NOT_FOUND.into_response(),
Err(e) => return crate::errors::domain_error_response(e),
};
let base_url = &state.app_ctx.config.base_url;
let avatar_url = user
.avatar_path()
.map(|path| format!("{}/images/{}", base_url, path));
let banner_url = user
.banner_path()
.map(|path| format!("{}/images/{}", base_url, path));
let profile_fields: Vec<(String, String)> = state
.app_ctx
.repos
.profile_fields
.get_fields(&user_id)
.await
.unwrap_or_default()
.into_iter()
.map(|f| (f.name, f.value))
.collect();
let saved = params.saved.as_deref() == Some("1");
let bio = user.bio().map(|s| s.to_string());
let also_known_as = user.also_known_as().map(|s| s.to_string());
render_page(ProfileSettingsTemplate {
ctx: &ctx,
bio: bio.as_deref(),
avatar_url: avatar_url.as_deref(),
banner_url: banner_url.as_deref(),
also_known_as: also_known_as.as_deref(),
profile_fields: &profile_fields,
saved,
embed_url: format!(
"{}/users/{}?embed=true",
state.app_ctx.config.base_url,
user_id.value()
),
})
.into_response()
}
pub async fn post_profile_settings(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
mut multipart: Multipart,
) -> impl IntoResponse {
let mut display_name: Option<String> = None;
let mut bio: Option<String> = None;
let mut avatar_bytes: Option<Vec<u8>> = None;
let mut avatar_content_type: Option<String> = None;
let mut banner_bytes: Option<Vec<u8>> = None;
let mut banner_content_type: Option<String> = None;
let mut also_known_as: Option<String> = None;
let mut field_names: std::collections::HashMap<usize, String> =
std::collections::HashMap::new();
let mut field_values: std::collections::HashMap<usize, String> =
std::collections::HashMap::new();
while let Ok(Some(field)) = multipart.next_field().await {
let name = field.name().unwrap_or("").to_string();
match name.as_str() {
"display_name" => {
if let Ok(text) = field.text().await {
display_name = Some(text).filter(|s| !s.is_empty());
}
}
"bio" => {
if let Ok(text) = field.text().await {
bio = Some(text);
}
}
"also_known_as" => {
if let Ok(text) = field.text().await {
also_known_as = Some(text).filter(|s| !s.is_empty());
}
}
"avatar" => {
let ct = field.content_type().map(|s| s.to_string());
if let Ok(bytes) = field.bytes().await
&& !bytes.is_empty()
{
avatar_bytes = Some(bytes.to_vec());
avatar_content_type = ct;
}
}
"banner" => {
let ct = field.content_type().map(|s| s.to_string());
if let Ok(bytes) = field.bytes().await
&& !bytes.is_empty()
{
banner_bytes = Some(bytes.to_vec());
banner_content_type = ct;
}
}
n if n.starts_with("field_name_") => {
if let Ok(idx) = n["field_name_".len()..].parse::<usize>()
&& let Ok(text) = field.text().await
&& !text.is_empty()
{
field_names.insert(idx, text);
}
}
n if n.starts_with("field_value_") => {
if let Ok(idx) = n["field_value_".len()..].parse::<usize>()
&& let Ok(text) = field.text().await
&& !text.is_empty()
{
field_values.insert(idx, text);
}
}
_ => {}
}
}
let cmd = application::users::commands::UpdateProfileCommand {
user_id: user_id.value(),
display_name,
bio,
avatar_bytes,
avatar_content_type,
banner_bytes,
banner_content_type,
also_known_as,
};
let update_deps = UpdateProfileDeps {
user: state.app_ctx.repos.user.clone(),
object_storage: state.app_ctx.services.object_storage.clone(),
event_publisher: state.app_ctx.services.event_publisher.clone(),
};
let _ = update_profile::execute(&update_deps, cmd).await;
let fields: Vec<domain::models::ProfileField> = (0..4)
.filter_map(|i| {
field_names
.get(&i)
.map(|name| domain::models::ProfileField {
name: name.clone(),
value: field_values.get(&i).cloned().unwrap_or_default(),
})
})
.collect();
let fields_cmd = application::users::commands::UpdateProfileFieldsCommand {
user_id: user_id.value(),
fields,
};
let _ = update_profile_fields::execute(
state.app_ctx.repos.profile_fields.clone(),
state.app_ctx.services.event_publisher.clone(),
fields_cmd,
)
.await;
axum::response::Redirect::to("/settings/profile?saved=1").into_response()
}