refactor: fix HIGH+MEDIUM architectural violations from code review

HIGH: fix watch_medium data-loss bug, standardize error handling on
ApiError, fix dep direction (rss/template-askama no longer dep on
application), extract ImageFetcher port (remove reqwest from app layer),
move event construction from save_review to use case, extract
infra-wiring crate (DbPool/EventBusBackend dedup), deduplicate
presentation helpers (encode_error, export streaming, multipart parsing)

MEDIUM: split LocalApContentQuery god-trait 10→3 methods, dedup movie
resolution orchestration, add RemoteActorDto/PersonDto mappers, move
AppConfig to infra-wiring, fix SocialQueryPort Uuid→UserId, replace
stringly-typed api-types with domain enums, move count_reviews_in_year
to StatsRepository, dedup event publisher cfg blocks, extract
should_enrich, move group_by_month to application, dedup
count_local_posts, add FederationFlags Default, TUI input helper +
ShowError rename + typed auth errors, api-types cleanup
(UserSettingsDto/UserProfileBase/PreviewRowData)

102 files changed, -681 lines net
This commit is contained in:
2026-07-10 02:08:39 +02:00
parent 26152660bb
commit 12da356a40
110 changed files with 1399 additions and 1867 deletions

View File

@@ -21,10 +21,10 @@ use crate::{
render::render_page,
state::AppState,
};
use api_types::HtmlPageContext;
use api_types::{
LoginRequest, LoginResponse, LogoutRequest, RefreshRequest, RefreshResponse, RegisterRequest,
};
use application::rendering::HtmlPageContext;
use template_askama::{LoginTemplate, RegisterTemplate};
// ── HTML helpers ─────────────────────────────────────────────────────────────

View File

@@ -1,22 +1,18 @@
use axum::{
Form, Json,
body::Body,
extract::{Extension, Path, Query, State},
http::StatusCode,
response::{IntoResponse, Redirect},
};
use futures::StreamExt;
use uuid::Uuid;
use application::diary::{
commands::{DeleteReviewCommand, EditReviewCommand},
delete_review,
deps::{DeleteReviewDeps, EditReviewDeps, GetActivityFeedDeps},
edit_review, export_diary as export_diary_uc, get_activity_feed as get_feed_uc, get_diary,
log_review,
queries::{ExportQuery, GetActivityFeedQuery},
edit_review, get_activity_feed as get_feed_uc, get_diary, log_review,
queries::GetActivityFeedQuery,
};
use domain::models::ExportFormat;
use crate::{
csrf::CsrfToken,
@@ -32,12 +28,7 @@ use api_types::{
};
use template_askama::{ActivityFeedTemplate, NewReviewTemplate, build_page_items};
use super::helpers::build_page_context;
fn encode_error(msg: &str) -> String {
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
utf8_percent_encode(msg, NON_ALPHANUMERIC).to_string()
}
use super::helpers::{build_export_response, build_page_context, encode_error};
// ── API ──────────────────────────────────────────────────────────────────────
@@ -146,22 +137,13 @@ pub async fn patch_review(
.map(|s| domain::value_objects::parse_watched_at(&s).map_err(ApiError))
.transpose()?;
let watch_medium = req
.watch_medium
.map(|opt| {
opt.map(|s| s.parse::<domain::value_objects::WatchMedium>())
.transpose()
.map_err(ApiError)
})
.transpose()?;
let cmd = EditReviewCommand {
review_id,
requesting_user_id: user_id.value(),
rating: req.rating,
comment: req.comment,
watched_at,
watch_medium,
watch_medium: req.watch_medium,
};
let deps = EditReviewDeps {
review: state.app_ctx.repos.review.clone(),
@@ -186,42 +168,7 @@ pub async fn export_diary(
user: AuthenticatedUser,
Query(params): Query<ExportQueryParams>,
) -> impl IntoResponse {
let format = match params.format.as_str() {
"csv" => ExportFormat::Csv,
"json" => ExportFormat::Json,
_ => return StatusCode::BAD_REQUEST.into_response(),
};
let (content_type, filename) = match &format {
ExportFormat::Csv => ("text/csv; charset=utf-8", "diary.csv"),
ExportFormat::Json => ("application/json", "diary.json"),
};
let query = ExportQuery {
user_id: user.0.value(),
format,
};
let stream = export_diary_uc::execute(
&state.app_ctx.repos.diary,
&state.app_ctx.services.diary_exporter,
query,
);
let stream = stream.map(|r| {
if let Err(ref e) = r {
tracing::error!("diary export stream error: {e}");
}
r
});
(
StatusCode::OK,
[
(axum::http::header::CONTENT_TYPE, content_type.to_string()),
(
axum::http::header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{}\"", filename),
),
],
Body::from_stream(stream),
)
.into_response()
build_export_response(&params.format, user.0.value(), &state)
}
#[utoipa::path(
@@ -352,42 +299,7 @@ pub async fn get_export_html(
RequiredCookieUser(user_id): RequiredCookieUser,
Query(params): Query<api_types::ExportQueryParams>,
) -> impl IntoResponse {
let format = match params.format.as_str() {
"csv" => ExportFormat::Csv,
"json" => ExportFormat::Json,
_ => return StatusCode::BAD_REQUEST.into_response(),
};
let (content_type, filename) = match &format {
ExportFormat::Csv => ("text/csv; charset=utf-8", "diary.csv"),
ExportFormat::Json => ("application/json", "diary.json"),
};
let query = ExportQuery {
user_id: user_id.value(),
format,
};
let stream = export_diary_uc::execute(
&state.app_ctx.repos.diary,
&state.app_ctx.services.diary_exporter,
query,
);
let stream = stream.map(|r| {
if let Err(ref e) = r {
tracing::error!("diary export stream error: {e}");
}
r
});
(
StatusCode::OK,
[
(axum::http::header::CONTENT_TYPE, content_type.to_string()),
(
axum::http::header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{}\"", filename),
),
],
Body::from_stream(stream),
)
.into_response()
build_export_response(&params.format, user_id.value(), &state)
}
pub async fn get_activity_feed_html(

View File

@@ -20,7 +20,7 @@ pub fn goal_with_progress_to_dto(g: &domain::models::GoalWithProgress) -> GoalDt
current_count: g.current_count,
percentage: g.percentage(),
is_complete: g.is_complete(),
goal_type: g.goal.goal_type().as_str().to_string(),
goal_type: g.goal.goal_type().clone(),
}
}
@@ -40,6 +40,7 @@ pub async fn list_goals(
) -> Result<Json<GoalsResponse>, ApiError> {
let goals = application::goals::list::execute(
state.app_ctx.repos.goal.clone(),
state.app_ctx.repos.stats.clone(),
application::goals::queries::ListGoalsQuery {
user_id: user.0.value(),
},
@@ -66,6 +67,7 @@ pub async fn create_goal(
) -> Result<Json<GoalDto>, ApiError> {
let g = application::goals::create::execute(
state.app_ctx.repos.goal.clone(),
state.app_ctx.repos.stats.clone(),
state.app_ctx.services.event_publisher.clone(),
application::goals::commands::CreateGoalCommand {
user_id: user.0.value(),
@@ -95,6 +97,7 @@ pub async fn update_goal(
) -> Result<Json<GoalDto>, ApiError> {
let g = application::goals::update::execute(
state.app_ctx.repos.goal.clone(),
state.app_ctx.repos.stats.clone(),
state.app_ctx.services.event_publisher.clone(),
application::goals::commands::UpdateGoalCommand {
user_id: user.0.value(),
@@ -147,6 +150,7 @@ pub async fn get_user_goals(
) -> Result<Json<GoalsResponse>, ApiError> {
let goals = application::goals::list::execute(
state.app_ctx.repos.goal.clone(),
state.app_ctx.repos.stats.clone(),
application::goals::queries::ListGoalsQuery { user_id },
)
.await?;

View File

@@ -1,8 +1,145 @@
use application::rendering::HtmlPageContext;
use api_types::HtmlPageContext;
use domain::value_objects::UserId;
use crate::state::AppState;
pub(crate) fn encode_error(msg: &str) -> String {
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
utf8_percent_encode(msg, NON_ALPHANUMERIC).to_string()
}
pub(crate) fn build_export_response(
format_str: &str,
user_id: uuid::Uuid,
state: &AppState,
) -> axum::response::Response {
use axum::{body::Body, http::StatusCode, response::IntoResponse};
use futures::StreamExt;
use application::diary::{export_diary as export_diary_uc, queries::ExportQuery};
use domain::models::ExportFormat;
let format = match format_str {
"csv" => ExportFormat::Csv,
"json" => ExportFormat::Json,
_ => return StatusCode::BAD_REQUEST.into_response(),
};
let (content_type, filename) = match &format {
ExportFormat::Csv => ("text/csv; charset=utf-8", "diary.csv"),
ExportFormat::Json => ("application/json", "diary.json"),
};
let query = ExportQuery { user_id, format };
let stream = export_diary_uc::execute(
&state.app_ctx.repos.diary,
&state.app_ctx.services.diary_exporter,
query,
);
let stream = stream.map(|r| {
if let Err(ref e) = r {
tracing::error!("diary export stream error: {e}");
}
r
});
(
StatusCode::OK,
[
(axum::http::header::CONTENT_TYPE, content_type.to_string()),
(
axum::http::header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{}\"", filename),
),
],
Body::from_stream(stream),
)
.into_response()
}
pub(crate) struct ProfileFormData {
pub display_name: Option<String>,
pub bio: Option<String>,
pub avatar_bytes: Option<Vec<u8>>,
pub avatar_content_type: Option<String>,
pub banner_bytes: Option<Vec<u8>>,
pub banner_content_type: Option<String>,
pub also_known_as: Option<String>,
pub profile_field_names: std::collections::HashMap<usize, String>,
pub profile_field_values: std::collections::HashMap<usize, String>,
}
pub(crate) async fn parse_profile_multipart(
mut multipart: axum::extract::Multipart,
) -> ProfileFormData {
let mut data = ProfileFormData {
display_name: None,
bio: None,
avatar_bytes: None,
avatar_content_type: None,
banner_bytes: None,
banner_content_type: None,
also_known_as: None,
profile_field_names: std::collections::HashMap::new(),
profile_field_values: 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 {
data.display_name = Some(text).filter(|s| !s.is_empty());
}
}
"bio" => {
if let Ok(text) = field.text().await {
data.bio = Some(text);
}
}
"also_known_as" => {
if let Ok(text) = field.text().await {
data.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()
{
data.avatar_bytes = Some(bytes.to_vec());
data.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()
{
data.banner_bytes = Some(bytes.to_vec());
data.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()
{
data.profile_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()
{
data.profile_field_values.insert(idx, text);
}
}
_ => {}
}
}
data
}
pub(crate) async fn build_page_context(
state: &AppState,
user_id: Option<UserId>,

View File

@@ -22,6 +22,7 @@ use application::import::{
execute as execute_import, list_profiles as list_import_profiles,
save_profile as save_import_profile,
};
use domain::errors::DomainError;
use domain::models::{
AnnotatedRow, FieldMapping, FileFormat,
import::{DomainField, Transform},
@@ -39,10 +40,7 @@ use crate::{
state::AppState,
};
fn encode_error(msg: &str) -> String {
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
utf8_percent_encode(msg, NON_ALPHANUMERIC).to_string()
}
use super::helpers::encode_error;
fn str_to_domain_field(field: &str) -> Option<DomainField> {
match field {
@@ -461,7 +459,7 @@ pub async fn api_post_session(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
mut multipart: Multipart,
) -> impl IntoResponse {
) -> Result<impl IntoResponse, ApiError> {
let mut file_bytes: Option<Vec<u8>> = None;
let mut format_str = "csv".to_string();
while let Ok(Some(field)) = multipart.next_field().await {
@@ -481,20 +479,14 @@ pub async fn api_post_session(
}
let bytes = match file_bytes {
Some(b) if !b.is_empty() => b,
_ => {
return (
StatusCode::BAD_REQUEST,
axum::Json(serde_json::json!({"error": "no file"})),
)
.into_response();
}
_ => return Err(DomainError::ValidationError("no file".into()).into()),
};
let format = match format_str.as_str() {
"json" => FileFormat::Json,
"xlsx" => FileFormat::Xlsx,
_ => FileFormat::Csv,
};
match create_import_session::execute(
let r = create_import_session::execute(
state.app_ctx.repos.import_session.clone(),
state.app_ctx.services.document_parser.clone(),
CreateImportSessionCommand {
@@ -503,20 +495,12 @@ pub async fn api_post_session(
format,
},
)
.await
{
Ok(r) => axum::Json(SessionCreatedResponse {
session_id: r.session_id.value().to_string(),
columns: r.columns,
sample_rows: r.sample_rows,
})
.into_response(),
Err(e) => (
StatusCode::UNPROCESSABLE_ENTITY,
axum::Json(serde_json::json!({"error": e.to_string()})),
)
.into_response(),
}
.await?;
Ok(axum::Json(SessionCreatedResponse {
session_id: r.session_id.value().to_string(),
columns: r.columns,
sample_rows: r.sample_rows,
}))
}
#[utoipa::path(
@@ -533,46 +517,26 @@ pub async fn api_get_session(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
Path(session_id_str): Path<String>,
) -> impl IntoResponse {
let Ok(session_id) = session_id_str
) -> Result<impl IntoResponse, ApiError> {
let session_id = session_id_str
.parse::<uuid::Uuid>()
.map(ImportSessionId::from_uuid)
else {
return (
StatusCode::BAD_REQUEST,
axum::Json(serde_json::json!({"error": "invalid session id"})),
)
.into_response();
};
match state
.map_err(|_| DomainError::ValidationError("invalid session id".into()))?;
let session = state
.app_ctx
.repos
.import_session
.get(&session_id, &user_id)
.await
{
Ok(Some(session)) => {
let parsed = session.parsed_file.unwrap_or_default();
let row_count = parsed.rows.len();
axum::Json(SessionStateResponse {
session_id: session_id_str,
columns: parsed.columns,
has_mappings: session.field_mappings.is_some(),
row_count,
})
.into_response()
}
Ok(None) => (
StatusCode::NOT_FOUND,
axum::Json(serde_json::json!({"error": "session not found"})),
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
axum::Json(serde_json::json!({"error": e.to_string()})),
)
.into_response(),
}
.await?
.ok_or(DomainError::NotFound("session not found".into()))?;
let parsed = session.parsed_file.unwrap_or_default();
let row_count = parsed.rows.len();
Ok(axum::Json(SessionStateResponse {
session_id: session_id_str,
columns: parsed.columns,
has_mappings: session.field_mappings.is_some(),
row_count,
}))
}
#[utoipa::path(
@@ -591,17 +555,11 @@ pub async fn api_put_mapping(
AuthenticatedUser(user_id): AuthenticatedUser,
Path(session_id_str): Path<String>,
axum::Json(body): axum::Json<ApplyMappingRequest>,
) -> impl IntoResponse {
let Ok(session_id) = session_id_str
) -> Result<impl IntoResponse, ApiError> {
let session_id = session_id_str
.parse::<uuid::Uuid>()
.map(ImportSessionId::from_uuid)
else {
return (
StatusCode::BAD_REQUEST,
axum::Json(serde_json::json!({"error": "invalid session id"})),
)
.into_response();
};
.map_err(|_| DomainError::ValidationError("invalid session id".into()))?;
let mappings: Vec<FieldMapping> = body
.mappings
.into_iter()
@@ -624,7 +582,7 @@ pub async fn api_put_mapping(
})
.collect();
match apply_import_mapping::execute(
let rows = apply_import_mapping::execute(
state.app_ctx.repos.import_session.clone(),
state.app_ctx.services.document_parser.clone(),
state.app_ctx.repos.movie.clone(),
@@ -634,15 +592,8 @@ pub async fn api_put_mapping(
mappings,
},
)
.await
{
Ok(rows) => axum::Json(serde_json::json!({"row_count": rows.len()})).into_response(),
Err(e) => (
StatusCode::UNPROCESSABLE_ENTITY,
axum::Json(serde_json::json!({"error": e.to_string()})),
)
.into_response(),
}
.await?;
Ok(axum::Json(serde_json::json!({"row_count": rows.len()})))
}
pub async fn api_get_preview(
@@ -653,11 +604,7 @@ pub async fn api_get_preview(
let session_id = session_id_str
.parse::<uuid::Uuid>()
.map(ImportSessionId::from_uuid)
.map_err(|_| {
ApiError(domain::errors::DomainError::ValidationError(
"invalid session id".into(),
))
})?;
.map_err(|_| DomainError::ValidationError("invalid session id".into()))?;
let session = state
.app_ctx
@@ -665,11 +612,7 @@ pub async fn api_get_preview(
.import_session
.get(&session_id, &user_id)
.await?
.ok_or_else(|| {
ApiError(domain::errors::DomainError::NotFound(
"session not found".into(),
))
})?;
.ok_or(DomainError::NotFound("session not found".into()))?;
let annotated: Vec<AnnotatedRow> = session.row_results.unwrap_or_default();
let rows = annotated
@@ -678,7 +621,18 @@ pub async fn api_get_preview(
.map(|(i, a)| {
use domain::models::import::RowResult;
match &a.result {
RowResult::Valid(row) if a.is_duplicate => PreviewRowDto::Duplicate {
RowResult::Valid(row) if a.is_duplicate => {
PreviewRowDto::Duplicate(api_types::PreviewRowData {
index: i,
title: row.title.clone(),
release_year: row.release_year.clone(),
director: row.director.clone(),
rating: row.rating.clone(),
watched_at: row.watched_at.clone(),
comment: row.comment.clone(),
})
}
RowResult::Valid(row) => PreviewRowDto::Valid(api_types::PreviewRowData {
index: i,
title: row.title.clone(),
release_year: row.release_year.clone(),
@@ -686,16 +640,7 @@ pub async fn api_get_preview(
rating: row.rating.clone(),
watched_at: row.watched_at.clone(),
comment: row.comment.clone(),
},
RowResult::Valid(row) => PreviewRowDto::Valid {
index: i,
title: row.title.clone(),
release_year: row.release_year.clone(),
director: row.director.clone(),
rating: row.rating.clone(),
watched_at: row.watched_at.clone(),
comment: row.comment.clone(),
},
}),
RowResult::Invalid { errors, .. } => PreviewRowDto::Invalid {
index: i,
errors: errors.clone(),
@@ -723,32 +668,26 @@ pub async fn api_post_confirm(
AuthenticatedUser(user_id): AuthenticatedUser,
Path(session_id_str): Path<String>,
axum::Json(body): axum::Json<ConfirmRequest>,
) -> impl IntoResponse {
let Ok(session_id) = session_id_str
) -> Result<impl IntoResponse, ApiError> {
let session_id = session_id_str
.parse::<uuid::Uuid>()
.map(ImportSessionId::from_uuid)
else {
return (
StatusCode::BAD_REQUEST,
axum::Json(serde_json::json!({"error": "invalid session id"})),
)
.into_response();
};
match execute_import::execute(state.app_ctx.repos.import_session.clone(), state.app_ctx.services.review_logger.clone(), ExecuteImportCommand { user_id: user_id.value(), session_id: session_id.value(), confirmed_indices: body.confirmed_indices }).await {
Ok(s) => axum::Json(serde_json::json!({
"imported": s.imported,
"skipped_duplicates": s.skipped_duplicates,
"failed": s.failed.iter().map(|(i, e)| serde_json::json!({"index": i, "error": e})).collect::<Vec<_>>(),
})).into_response(),
Err(e) => {
let status = if matches!(e, domain::errors::DomainError::NotFound(_)) {
StatusCode::NOT_FOUND
} else {
StatusCode::INTERNAL_SERVER_ERROR
};
(status, axum::Json(serde_json::json!({"error": e.to_string()}))).into_response()
}
}
.map_err(|_| DomainError::ValidationError("invalid session id".into()))?;
let s = execute_import::execute(
state.app_ctx.repos.import_session.clone(),
state.app_ctx.services.review_logger.clone(),
ExecuteImportCommand {
user_id: user_id.value(),
session_id: session_id.value(),
confirmed_indices: body.confirmed_indices,
},
)
.await?;
Ok(axum::Json(serde_json::json!({
"imported": s.imported,
"skipped_duplicates": s.skipped_duplicates,
"failed": s.failed.iter().map(|(i, e)| serde_json::json!({"index": i, "error": e})).collect::<Vec<_>>(),
})))
}
#[utoipa::path(
@@ -762,28 +701,21 @@ pub async fn api_post_confirm(
pub async fn api_get_profiles(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
) -> impl IntoResponse {
match list_import_profiles::execute(state.app_ctx.repos.import_profile.clone(), &user_id).await
{
Ok(profiles) => axum::Json(
profiles
.into_iter()
.map(|p| {
serde_json::json!({
"id": p.id.value().to_string(),
"name": p.name,
"created_at": p.created_at.to_string(),
})
) -> Result<impl IntoResponse, ApiError> {
let profiles =
list_import_profiles::execute(state.app_ctx.repos.import_profile.clone(), &user_id).await?;
Ok(axum::Json(
profiles
.into_iter()
.map(|p| {
serde_json::json!({
"id": p.id.value().to_string(),
"name": p.name,
"created_at": p.created_at.to_string(),
})
.collect::<Vec<_>>(),
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
axum::Json(serde_json::json!({"error": e.to_string()})),
)
.into_response(),
}
})
.collect::<Vec<_>>(),
))
}
#[utoipa::path(
@@ -800,19 +732,13 @@ pub async fn api_post_profile(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
axum::Json(body): axum::Json<SaveProfileRequest>,
) -> impl IntoResponse {
let Ok(session_id) = body
) -> Result<impl IntoResponse, ApiError> {
let session_id = body
.session_id
.parse::<uuid::Uuid>()
.map(ImportSessionId::from_uuid)
else {
return (
StatusCode::BAD_REQUEST,
axum::Json(serde_json::json!({"error": "invalid session id"})),
)
.into_response();
};
match save_import_profile::execute(
.map_err(|_| DomainError::ValidationError("invalid session id".into()))?;
let id = save_import_profile::execute(
state.app_ctx.repos.import_session.clone(),
state.app_ctx.repos.import_profile.clone(),
SaveImportProfileCommand {
@@ -821,15 +747,10 @@ pub async fn api_post_profile(
name: body.name,
},
)
.await
{
Ok(id) => axum::Json(serde_json::json!({"id": id.value().to_string()})).into_response(),
Err(e) => (
StatusCode::UNPROCESSABLE_ENTITY,
axum::Json(serde_json::json!({"error": e.to_string()})),
)
.into_response(),
}
.await?;
Ok(axum::Json(
serde_json::json!({"id": id.value().to_string()}),
))
}
#[utoipa::path(
@@ -846,29 +767,19 @@ pub async fn api_delete_profile(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
Path(profile_id_str): Path<String>,
) -> impl IntoResponse {
let Ok(profile_id) = profile_id_str.parse::<uuid::Uuid>() else {
return StatusCode::BAD_REQUEST.into_response();
};
match delete_import_profile::execute(
) -> Result<impl IntoResponse, ApiError> {
let profile_id = profile_id_str
.parse::<uuid::Uuid>()
.map_err(|_| DomainError::ValidationError("invalid profile id".into()))?;
delete_import_profile::execute(
state.app_ctx.repos.import_profile.clone(),
DeleteImportProfileCommand {
user_id: user_id.value(),
profile_id,
},
)
.await
{
Ok(_) => StatusCode::NO_CONTENT.into_response(),
Err(e) => {
let status = if matches!(e, domain::errors::DomainError::NotFound(_)) {
StatusCode::NOT_FOUND
} else {
StatusCode::INTERNAL_SERVER_ERROR
};
status.into_response()
}
}
.await?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
@@ -890,23 +801,15 @@ pub async fn api_apply_profile(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
Path((session_id_str, profile_id_str)): Path<(String, String)>,
) -> impl IntoResponse {
let Ok(session_id) = session_id_str.parse::<uuid::Uuid>() else {
return (
StatusCode::BAD_REQUEST,
axum::Json(serde_json::json!({"error": "invalid session id"})),
)
.into_response();
};
let Ok(profile_id) = profile_id_str.parse::<uuid::Uuid>() else {
return (
StatusCode::BAD_REQUEST,
axum::Json(serde_json::json!({"error": "invalid profile id"})),
)
.into_response();
};
) -> Result<impl IntoResponse, ApiError> {
let session_id = session_id_str
.parse::<uuid::Uuid>()
.map_err(|_| DomainError::ValidationError("invalid session id".into()))?;
let profile_id = profile_id_str
.parse::<uuid::Uuid>()
.map_err(|_| DomainError::ValidationError("invalid profile id".into()))?;
if let Err(e) = apply_import_profile::execute(
apply_import_profile::execute(
state.app_ctx.repos.import_profile.clone(),
state.app_ctx.repos.import_session.clone(),
ApplyImportProfileCommand {
@@ -915,39 +818,20 @@ pub async fn api_apply_profile(
profile_id,
},
)
.await
{
let status = if matches!(e, domain::errors::DomainError::NotFound(_)) {
StatusCode::NOT_FOUND
} else {
StatusCode::UNPROCESSABLE_ENTITY
};
return (
status,
axum::Json(serde_json::json!({"error": e.to_string()})),
)
.into_response();
}
.await?;
let session = match state
let session = state
.app_ctx
.repos
.import_session
.get(&ImportSessionId::from_uuid(session_id), &user_id)
.await
{
Ok(Some(s)) => s,
_ => {
return (
StatusCode::NOT_FOUND,
axum::Json(serde_json::json!({"error": "session not found after profile apply"})),
)
.into_response();
}
};
.await?
.ok_or(DomainError::NotFound(
"session not found after profile apply".into(),
))?;
let mappings = session.field_mappings.unwrap_or_default();
match apply_import_mapping::execute(
let rows = apply_import_mapping::execute(
state.app_ctx.repos.import_session.clone(),
state.app_ctx.services.document_parser.clone(),
state.app_ctx.repos.movie.clone(),
@@ -957,13 +841,6 @@ pub async fn api_apply_profile(
mappings,
},
)
.await
{
Ok(rows) => axum::Json(serde_json::json!({"row_count": rows.len()})).into_response(),
Err(e) => (
StatusCode::UNPROCESSABLE_ENTITY,
axum::Json(serde_json::json!({"error": e.to_string()})),
)
.into_response(),
}
.await?;
Ok(axum::Json(serde_json::json!({"row_count": rows.len()})))
}

View File

@@ -24,12 +24,7 @@ use crate::{
};
use template_askama::{IntegrationsTemplate, WatchQueueTemplate};
use super::helpers::build_page_context;
fn encode_error(msg: &str) -> String {
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
utf8_percent_encode(msg, NON_ALPHANUMERIC).to_string()
}
use super::helpers::{build_page_context, encode_error};
// ── HTML ─────────────────────────────────────────────────────────────────────

View File

@@ -185,7 +185,7 @@ pub async fn get_movie_detail(
comment: e.review().comment().map(|c| c.value().to_string()),
watched_at: domain::value_objects::format_watched_at(e.review().watched_at()),
is_federated: e.review().is_remote(),
watch_medium: e.review().watch_medium().map(|wm| wm.to_string()),
watch_medium: e.review().watch_medium().copied(),
})
.collect(),
total_count: result.reviews.total_count,

View File

@@ -13,7 +13,7 @@ use domain::models::{PersonId, collections::PageParams};
use crate::state::AppState;
use api_types::search::{
CastCreditDto, CrewCreditDto, MovieSearchHitDto, PaginatedMovieHits, PaginatedPersonHits,
PersonCreditsDto, PersonDto, PersonSearchHitDto, SearchQueryParams, SearchResponse,
PersonCreditsDto, PersonSearchHitDto, SearchQueryParams, SearchResponse,
};
// ── API ──────────────────────────────────────────────────────────────────────
@@ -106,24 +106,9 @@ pub async fn get_person_handler(
event_publisher: state.app_ctx.services.event_publisher.clone(),
};
match get_person::execute(&deps, PersonId::from_uuid(id)).await {
Ok(Some(person)) => axum::Json(PersonDto {
id: person.id().value(),
external_id: person.external_id().value().to_string(),
name: person.name().to_string(),
known_for_department: person.known_for_department().map(str::to_string),
profile_path: person.profile_path().map(str::to_string),
biography: person.biography().map(str::to_string),
birthday: person.birthday().map(|d| d.to_string()),
deathday: person.deathday().map(|d| d.to_string()),
place_of_birth: person.place_of_birth().map(str::to_string),
also_known_as: person.also_known_as().to_vec(),
homepage: person.homepage().map(str::to_string),
imdb_url: person
.imdb_id()
.map(|id| format!("https://www.imdb.com/name/{id}")),
enriched: person.enriched_at().is_some(),
})
.into_response(),
Ok(Some(person)) => {
axum::Json(crate::mappers::search::person_to_dto(&person)).into_response()
}
Ok(None) => StatusCode::NOT_FOUND.into_response(),
Err(e) => crate::errors::domain_error_response(e),
}
@@ -148,24 +133,7 @@ pub async fn get_person_credits_handler(
};
match get_person_credits::execute(&deps, PersonId::from_uuid(id)).await {
Ok(credits) => axum::Json(PersonCreditsDto {
person: PersonDto {
id: credits.person.id().value(),
external_id: credits.person.external_id().value().to_string(),
name: credits.person.name().to_string(),
known_for_department: credits.person.known_for_department().map(str::to_string),
profile_path: credits.person.profile_path().map(str::to_string),
biography: credits.person.biography().map(str::to_string),
birthday: credits.person.birthday().map(|d| d.to_string()),
deathday: credits.person.deathday().map(|d| d.to_string()),
place_of_birth: credits.person.place_of_birth().map(str::to_string),
also_known_as: credits.person.also_known_as().to_vec(),
homepage: credits.person.homepage().map(str::to_string),
imdb_url: credits
.person
.imdb_id()
.map(|id| format!("https://www.imdb.com/name/{id}")),
enriched: credits.person.enriched_at().is_some(),
},
person: crate::mappers::search::person_to_dto(&credits.person),
cast: credits
.cast
.iter()

View File

@@ -9,7 +9,7 @@ use uuid::Uuid;
use crate::{
csrf::CsrfToken,
errors::ApiError,
extractors::{AuthenticatedUser, RequiredCookieUser},
extractors::{AdminApiUser, AuthenticatedUser, RequiredCookieUser},
forms::{
ActorUrlForm, BlockDomainForm, FollowForm, FollowerActionForm, RemoveDomainForm,
UnfollowForm,
@@ -19,24 +19,14 @@ use crate::{
};
use api_types::{
ActorListResponse, ActorUrlRequest, AddBlockedDomainRequest, BlockedActorResponse,
BlockedDomainResponse, FollowRequest, RemoteActorDto,
BlockedDomainResponse, FollowRequest,
};
use template_askama::{
BlockedActorsTemplate, BlockedDomainsTemplate, FollowersTemplate, FollowingTemplate,
RemoteActorData,
};
use super::helpers::build_page_context;
fn encode_error(msg: &str) -> String {
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
utf8_percent_encode(msg, NON_ALPHANUMERIC).to_string()
}
fn ap_err(e: anyhow::Error) -> impl IntoResponse {
tracing::error!("ActivityPub error: {:?}", e);
StatusCode::INTERNAL_SERVER_ERROR
}
use super::helpers::{build_page_context, encode_error};
fn ap_to_domain(e: anyhow::Error) -> domain::errors::DomainError {
tracing::error!("ActivityPub error: {:?}", e);
@@ -56,22 +46,23 @@ fn ap_to_domain(e: anyhow::Error) -> domain::errors::DomainError {
)]
pub async fn get_blocked_domains_admin(
State(state): State<AppState>,
_admin: crate::extractors::AdminUser,
) -> impl IntoResponse {
match state.ap_service.get_blocked_domains().await {
Ok(domains) => {
let response: Vec<BlockedDomainResponse> = domains
.into_iter()
.map(|d| BlockedDomainResponse {
domain: d.domain,
reason: d.reason,
blocked_at: d.blocked_at,
})
.collect();
axum::Json(response).into_response()
}
Err(e) => ap_err(e).into_response(),
}
_admin: AdminApiUser,
) -> Result<Json<Vec<BlockedDomainResponse>>, ApiError> {
let domains = state
.ap_service
.get_blocked_domains()
.await
.map_err(ap_to_domain)?;
Ok(Json(
domains
.into_iter()
.map(|d| BlockedDomainResponse {
domain: d.domain,
reason: d.reason,
blocked_at: d.blocked_at,
})
.collect(),
))
}
#[utoipa::path(
@@ -86,17 +77,15 @@ pub async fn get_blocked_domains_admin(
)]
pub async fn add_blocked_domain_admin(
State(state): State<AppState>,
_admin: crate::extractors::AdminUser,
_admin: AdminApiUser,
axum::Json(body): axum::Json<AddBlockedDomainRequest>,
) -> impl IntoResponse {
match state
) -> Result<impl IntoResponse, ApiError> {
state
.ap_service
.add_blocked_domain(&body.domain, body.reason.as_deref())
.await
{
Ok(()) => StatusCode::CREATED.into_response(),
Err(e) => ap_err(e).into_response(),
}
.map_err(ap_to_domain)?;
Ok(StatusCode::CREATED)
}
#[utoipa::path(
@@ -111,13 +100,15 @@ pub async fn add_blocked_domain_admin(
)]
pub async fn remove_blocked_domain_admin(
State(state): State<AppState>,
_admin: crate::extractors::AdminUser,
_admin: AdminApiUser,
axum::extract::Path(domain): axum::extract::Path<String>,
) -> impl IntoResponse {
match state.ap_service.remove_blocked_domain(&domain).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => ap_err(e).into_response(),
}
) -> Result<impl IntoResponse, ApiError> {
state
.ap_service
.remove_blocked_domain(&domain)
.await
.map_err(ap_to_domain)?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
@@ -133,15 +124,13 @@ pub async fn block_actor_api(
State(state): State<AppState>,
user: AuthenticatedUser,
axum::Json(body): axum::Json<ActorUrlRequest>,
) -> impl IntoResponse {
match state
) -> Result<impl IntoResponse, ApiError> {
state
.ap_service
.block_actor(user.0.value(), &body.actor_url)
.await
{
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => ap_err(e).into_response(),
}
.map_err(ap_to_domain)?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
@@ -157,15 +146,13 @@ pub async fn unblock_actor_api(
State(state): State<AppState>,
user: AuthenticatedUser,
axum::Json(body): axum::Json<ActorUrlRequest>,
) -> impl IntoResponse {
match state
) -> Result<impl IntoResponse, ApiError> {
state
.ap_service
.unblock_actor(user.0.value(), &body.actor_url)
.await
{
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => ap_err(e).into_response(),
}
.map_err(ap_to_domain)?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
@@ -179,22 +166,23 @@ pub async fn unblock_actor_api(
pub async fn get_blocked_actors_api(
State(state): State<AppState>,
user: AuthenticatedUser,
) -> impl IntoResponse {
match state.ap_service.get_blocked_actors(user.0.value()).await {
Ok(actors) => {
let response: Vec<BlockedActorResponse> = actors
.into_iter()
.map(|a| BlockedActorResponse {
url: a.url,
handle: a.handle,
display_name: a.display_name,
avatar_url: a.avatar_url,
})
.collect();
axum::Json(response).into_response()
}
Err(e) => ap_err(e).into_response(),
}
) -> Result<Json<Vec<BlockedActorResponse>>, ApiError> {
let actors = state
.ap_service
.get_blocked_actors(user.0.value())
.await
.map_err(ap_to_domain)?;
Ok(Json(
actors
.into_iter()
.map(|a| BlockedActorResponse {
url: a.url,
handle: a.handle,
display_name: a.display_name,
avatar_url: a.avatar_url,
})
.collect(),
))
}
#[utoipa::path(
@@ -208,21 +196,18 @@ pub async fn get_blocked_actors_api(
pub async fn get_following(
State(state): State<AppState>,
user: AuthenticatedUser,
) -> impl IntoResponse {
match state.ap_service.get_following(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(),
}
) -> Result<Json<ActorListResponse>, ApiError> {
let actors = state
.ap_service
.get_following(user.0.value())
.await
.map_err(ap_to_domain)?;
Ok(Json(ActorListResponse {
actors: actors
.into_iter()
.map(crate::mappers::social::remote_actor_to_dto)
.collect(),
}))
}
#[utoipa::path(
@@ -236,25 +221,18 @@ pub async fn get_following(
pub async fn get_followers(
State(state): State<AppState>,
user: AuthenticatedUser,
) -> impl IntoResponse {
match state
) -> Result<Json<ActorListResponse>, ApiError> {
let actors = state
.ap_service
.get_accepted_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(),
}
.map_err(ap_to_domain)?;
Ok(Json(ActorListResponse {
actors: actors
.into_iter()
.map(crate::mappers::social::remote_actor_to_dto)
.collect(),
}))
}
pub async fn get_user_following(
@@ -270,11 +248,7 @@ pub async fn get_user_following(
Ok(Json(ActorListResponse {
actors: actors
.into_iter()
.map(|a| RemoteActorDto {
handle: a.handle,
display_name: a.display_name,
url: a.url,
})
.map(crate::mappers::social::remote_actor_to_dto)
.collect(),
}))
}
@@ -292,11 +266,7 @@ pub async fn get_user_followers(
Ok(Json(ActorListResponse {
actors: actors
.into_iter()
.map(|a| RemoteActorDto {
handle: a.handle,
display_name: a.display_name,
url: a.url,
})
.map(crate::mappers::social::remote_actor_to_dto)
.collect(),
}))
}
@@ -314,11 +284,13 @@ pub async fn follow(
State(state): State<AppState>,
user: AuthenticatedUser,
Json(body): Json<FollowRequest>,
) -> impl IntoResponse {
match state.ap_service.follow(user.0.value(), &body.handle).await {
Ok(()) => StatusCode::OK.into_response(),
Err(e) => ap_err(e).into_response(),
}
) -> Result<impl IntoResponse, ApiError> {
state
.ap_service
.follow(user.0.value(), &body.handle)
.await
.map_err(ap_to_domain)?;
Ok(StatusCode::OK)
}
#[utoipa::path(
@@ -334,15 +306,13 @@ pub async fn unfollow(
State(state): State<AppState>,
user: AuthenticatedUser,
Json(body): Json<ActorUrlRequest>,
) -> impl IntoResponse {
match state
) -> Result<impl IntoResponse, ApiError> {
state
.ap_service
.unfollow(user.0.value(), &body.actor_url)
.await
{
Ok(()) => StatusCode::OK.into_response(),
Err(e) => ap_err(e).into_response(),
}
.map_err(ap_to_domain)?;
Ok(StatusCode::OK)
}
#[utoipa::path(
@@ -358,15 +328,13 @@ pub async fn accept_follower(
State(state): State<AppState>,
user: AuthenticatedUser,
Json(body): Json<ActorUrlRequest>,
) -> impl IntoResponse {
match state
) -> Result<impl IntoResponse, ApiError> {
state
.ap_service
.accept_follower(user.0.value(), &body.actor_url)
.await
{
Ok(()) => StatusCode::OK.into_response(),
Err(e) => ap_err(e).into_response(),
}
.map_err(ap_to_domain)?;
Ok(StatusCode::OK)
}
#[utoipa::path(
@@ -382,15 +350,13 @@ pub async fn reject_follower(
State(state): State<AppState>,
user: AuthenticatedUser,
Json(body): Json<ActorUrlRequest>,
) -> impl IntoResponse {
match state
) -> Result<impl IntoResponse, ApiError> {
state
.ap_service
.reject_follower(user.0.value(), &body.actor_url)
.await
{
Ok(()) => StatusCode::OK.into_response(),
Err(e) => ap_err(e).into_response(),
}
.map_err(ap_to_domain)?;
Ok(StatusCode::OK)
}
#[utoipa::path(
@@ -406,15 +372,13 @@ pub async fn remove_follower(
State(state): State<AppState>,
user: AuthenticatedUser,
Json(body): Json<ActorUrlRequest>,
) -> impl IntoResponse {
match state
) -> Result<impl IntoResponse, ApiError> {
state
.ap_service
.remove_follower(user.0.value(), &body.actor_url)
.await
{
Ok(()) => StatusCode::OK.into_response(),
Err(e) => ap_err(e).into_response(),
}
.map_err(ap_to_domain)?;
Ok(StatusCode::OK)
}
#[utoipa::path(
@@ -428,21 +392,18 @@ pub async fn remove_follower(
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(),
}
) -> Result<Json<ActorListResponse>, ApiError> {
let actors = state
.ap_service
.get_pending_followers(user.0.value())
.await
.map_err(ap_to_domain)?;
Ok(Json(ActorListResponse {
actors: actors
.into_iter()
.map(crate::mappers::social::remote_actor_to_dto)
.collect(),
}))
}
// ── HTML ─────────────────────────────────────────────────────────────────────

View File

@@ -61,15 +61,17 @@ pub async fn get_profile(
.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)),
profile: api_types::UserProfileBase {
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
@@ -96,65 +98,19 @@ pub async fn get_profile(
pub async fn update_profile_handler(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
mut multipart: Multipart,
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 data = super::helpers::parse_profile_multipart(multipart).await;
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,
display_name: data.display_name,
bio: data.bio,
avatar_bytes: data.avatar_bytes,
avatar_content_type: data.avatar_content_type,
banner_bytes: data.banner_bytes,
banner_content_type: data.banner_content_type,
also_known_as: data.also_known_as,
};
let deps = UpdateProfileDeps {
@@ -325,7 +281,7 @@ pub async fn get_user_profile(
});
let history = profile.history.map(|entries| {
crate::mappers::users::group_by_month(entries)
application::users::group_by_month(entries)
.into_iter()
.map(|m| MonthActivityDto {
year_month: m.year_month,
@@ -364,13 +320,17 @@ pub async fn get_user_profile(
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)),
profile: api_types::UserProfileBase {
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)),
display_name: None,
bio: None,
},
stats: UserStatsDto {
total_movies: profile.stats.total_movies,
avg_rating: profile.stats.avg_rating,
@@ -385,6 +345,7 @@ pub async fn get_user_profile(
goals: {
let goals_list = application::goals::list::execute(
state.app_ctx.repos.goal.clone(),
state.app_ctx.repos.stats.clone(),
application::goals::queries::ListGoalsQuery { user_id },
)
.await
@@ -397,8 +358,6 @@ pub async fn get_user_profile(
},
is_federated: false,
handle: None,
display_name: None,
bio: None,
actor_url: None,
})
.into_response()
@@ -475,9 +434,13 @@ async fn build_federated_profile_response(
Json(UserProfileResponse {
user_id,
username,
avatar_url: fed.avatar_url,
banner_url: fed.banner_url,
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,
@@ -492,8 +455,6 @@ async fn build_federated_profile_response(
goals: None,
is_federated: true,
handle: Some(fed.handle),
display_name: fed.display_name,
bio: fed.bio,
actor_url: Some(fed.actor_url),
})
.into_response()
@@ -687,7 +648,7 @@ pub async fn get_user_profile_html(
.most_active_month
.clone()
.unwrap_or_else(|| "\u{2014}".to_string());
let history = profile.history.map(crate::mappers::users::group_by_month);
let history = profile.history.map(application::users::group_by_month);
let heatmap = history.as_deref().map(build_heatmap).unwrap_or_default();
let monthly_rating_rows: Vec<MonthlyRatingRow<'_>> = profile
.trends
@@ -775,6 +736,7 @@ pub async fn get_user_profile_html(
goals: {
let goals_list = application::goals::list::execute(
state.app_ctx.repos.goal.clone(),
state.app_ctx.repos.stats.clone(),
application::goals::queries::ListGoalsQuery {
user_id: profile_user_uuid,
},
@@ -865,100 +827,45 @@ pub async fn get_profile_settings(
pub async fn post_profile_settings(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
mut multipart: Multipart,
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 data = super::helpers::parse_profile_multipart(multipart).await;
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,
display_name: data.display_name,
bio: data.bio,
avatar_bytes: data.avatar_bytes,
avatar_content_type: data.avatar_content_type,
banner_bytes: data.banner_bytes,
banner_content_type: data.banner_content_type,
also_known_as: data.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;
if let Err(e) = update_profile::execute(&update_deps, cmd).await {
tracing::error!("update_profile error: {:?}", e);
return axum::response::Redirect::to(&format!(
"/settings/profile?error={}",
super::helpers::encode_error(&e.to_string())
))
.into_response();
}
let fields: Vec<domain::models::ProfileField> = (0..4)
.filter_map(|i| {
field_names
data.profile_field_names
.get(&i)
.map(|name| domain::models::ProfileField {
name: name.clone(),
value: field_values.get(&i).cloned().unwrap_or_default(),
value: data
.profile_field_values
.get(&i)
.cloned()
.unwrap_or_default(),
})
})
.collect();
@@ -967,12 +874,20 @@ pub async fn post_profile_settings(
user_id: user_id.value(),
fields,
};
let _ = update_profile_fields::execute(
if let Err(e) = update_profile_fields::execute(
state.app_ctx.repos.profile_fields.clone(),
state.app_ctx.services.event_publisher.clone(),
fields_cmd,
)
.await;
.await
{
tracing::error!("update_profile_fields error: {:?}", e);
return axum::response::Redirect::to(&format!(
"/settings/profile?error={}",
super::helpers::encode_error(&e.to_string())
))
.into_response();
}
axum::response::Redirect::to("/settings/profile?saved=1").into_response()
}

View File

@@ -32,12 +32,7 @@ use api_types::{
};
use template_askama::WatchlistTemplate;
use super::helpers::build_page_context;
fn encode_error(msg: &str) -> String {
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
utf8_percent_encode(msg, NON_ALPHANUMERIC).to_string()
}
use super::helpers::{build_page_context, encode_error};
// ── API ──────────────────────────────────────────────────────────────────────

View File

@@ -201,7 +201,7 @@ fn format_watch_time(minutes: u32) -> String {
fn render_wrapup(
report: &WrapUpReport,
year: i32,
ctx: &application::rendering::HtmlPageContext,
ctx: &api_types::HtmlPageContext,
) -> axum::response::Response {
let rating_max = report
.rating_distribution