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

@@ -7,12 +7,7 @@ use domain::ports::{
WatchEventRepository, WebhookTokenRepository,
};
pub enum DbPool {
#[cfg(feature = "sqlite")]
Sqlite(sqlx::SqlitePool),
#[cfg(feature = "postgres")]
Postgres(sqlx::PgPool),
}
pub use infra_wiring::DbPool;
pub struct DatabaseOutput {
pub movie: Arc<dyn domain::ports::MovieRepository>,

View File

@@ -221,7 +221,6 @@ impl TryFrom<LogReviewRequest> for LogReviewData {
fn try_from(req: LogReviewRequest) -> Result<Self, Self::Error> {
let watched_at = domain::value_objects::parse_watched_at(&req.watched_at)?;
let watch_medium = req.watch_medium.map(|s| s.parse()).transpose()?;
Ok(Self {
external_metadata_id: req.external_metadata_id.filter(|s| !s.trim().is_empty()),
manual_title: req.manual_title,
@@ -230,7 +229,7 @@ impl TryFrom<LogReviewRequest> for LogReviewData {
rating: req.rating,
comment: req.comment,
watched_at,
watch_medium,
watch_medium: req.watch_medium,
})
}
}

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

View File

@@ -13,6 +13,7 @@ use presentation::{factory, openapi, routes, state::AppState};
use rss::RssAdapter;
use domain::ports::{DiaryExporter, DocumentParser, EventPublisher};
use infra_wiring::EventBusBackend;
#[cfg(feature = "postgres")]
use postgres_search;
@@ -85,29 +86,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
),
};
let ep: Arc<dyn EventPublisher> = match event_bus {
EventBusBackend::Db => {
tracing::info!("event bus: DB queue");
match &db_pool {
#[cfg(feature = "postgres")]
factory::DbPool::Postgres(pool) => {
postgres_event_queue::PostgresEventQueue::create_publisher(pool.clone())
.await?
}
#[cfg(feature = "sqlite")]
factory::DbPool::Sqlite(pool) => {
sqlite_event_queue::SqliteEventQueue::create_publisher(pool.clone()).await?
}
}
}
#[cfg(feature = "nats")]
EventBusBackend::Nats => {
let cfg = nats::NatsConfig::from_env()
.context("EVENT_BUS_BACKEND=nats requires NATS_URL to be set")?;
tracing::info!("event bus: NATS ({})", cfg.url);
nats::create_publisher(cfg).await?
}
};
let ep = create_event_publisher(event_bus, &db_pool).await?;
let ap = activitypub::wire(activitypub::ActivityPubDeps {
activity_repo,
@@ -118,6 +97,11 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
remote_watchlist_repo: remote_watchlist_repo.clone(),
remote_goal_repo: Arc::clone(&db.remote_goal),
local_ap_content: Arc::clone(&ap_content_repo),
movie_repo: Arc::clone(&db.movie),
review_repo: Arc::clone(&db.review),
diary_repo: Arc::clone(&db.diary),
goal_repo: Arc::clone(&db.goal),
stats_repo: Arc::clone(&db.stats),
user_repo: Arc::clone(&db.user),
federation_settings: std::sync::Arc::clone(&db.federation_settings),
base_url: app_config.base_url.clone(),
@@ -138,32 +122,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
};
#[cfg(not(feature = "federation"))]
let event_publisher_arc: Arc<dyn EventPublisher> = match event_bus {
EventBusBackend::Db => {
tracing::info!("event bus: DB queue");
match &db_pool {
#[cfg(feature = "postgres")]
factory::DbPool::Postgres(pool) => {
postgres_event_queue::PostgresEventQueue::create_publisher(pool.clone()).await?
}
#[cfg(feature = "sqlite")]
factory::DbPool::Sqlite(pool) => {
sqlite_event_queue::SqliteEventQueue::create_publisher(pool.clone()).await?
}
#[cfg(not(feature = "sqlite"))]
_ => anyhow::bail!(
"EVENT_BUS_BACKEND=db has no adapter for DATABASE_BACKEND={backend}; enable the sqlite or postgres feature"
),
}
}
#[cfg(feature = "nats")]
EventBusBackend::Nats => {
let cfg = nats::NatsConfig::from_env()
.context("EVENT_BUS_BACKEND=nats requires NATS_URL to be set")?;
tracing::info!("event bus: NATS ({})", cfg.url);
nats::create_publisher(cfg).await?
}
};
let event_publisher_arc = create_event_publisher(event_bus, &db_pool).await?;
#[cfg(not(feature = "federation"))]
let ap_router = axum::Router::new();
@@ -253,27 +212,30 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
Ok((state, ap_router))
}
#[derive(Clone, Copy)]
enum EventBusBackend {
Db,
#[cfg(feature = "nats")]
Nats,
}
impl EventBusBackend {
fn from_env() -> anyhow::Result<Self> {
match std::env::var("EVENT_BUS_BACKEND")
.unwrap_or_else(|_| "db".to_string())
.as_str()
{
"db" => Ok(Self::Db),
#[cfg(feature = "nats")]
"nats" => Ok(Self::Nats),
#[cfg(not(feature = "nats"))]
"nats" => {
anyhow::bail!("EVENT_BUS_BACKEND=nats requires the nats feature to be compiled in")
}
other => anyhow::bail!("unknown EVENT_BUS_BACKEND={other}, expected 'db' or 'nats'"),
async fn create_event_publisher(
event_bus: EventBusBackend,
db_pool: &factory::DbPool,
) -> anyhow::Result<Arc<dyn EventPublisher>> {
match event_bus {
EventBusBackend::Db => {
tracing::info!("event bus: DB queue");
Ok(match db_pool {
#[cfg(feature = "postgres")]
factory::DbPool::Postgres(pool) => {
postgres_event_queue::PostgresEventQueue::create_publisher(pool.clone()).await?
}
#[cfg(feature = "sqlite")]
factory::DbPool::Sqlite(pool) => {
sqlite_event_queue::SqliteEventQueue::create_publisher(pool.clone()).await?
}
})
}
#[cfg(feature = "nats")]
EventBusBackend::Nats => {
let cfg = nats::NatsConfig::from_env()
.context("EVENT_BUS_BACKEND=nats requires NATS_URL to be set")?;
tracing::info!("event bus: NATS ({})", cfg.url);
Ok(nats::create_publisher(cfg).await?)
}
}
}

View File

@@ -2,5 +2,8 @@ pub mod diary;
pub mod import;
pub mod integrations;
pub mod movies;
pub mod search;
#[cfg(feature = "federation")]
pub mod social;
pub mod users;
pub mod watchlist;

View File

@@ -37,7 +37,7 @@ pub fn review_to_dto(review: &Review) -> ReviewDto {
rating: review.rating().value(),
comment: review.comment().map(|c| c.value().to_string()),
watched_at: domain::value_objects::format_watched_at(review.watched_at()),
watch_medium: review.watch_medium().map(|wm| wm.to_string()),
watch_medium: review.watch_medium().copied(),
}
}

View File

@@ -0,0 +1,22 @@
use api_types::search::PersonDto;
use domain::models::person::Person;
pub fn person_to_dto(p: &Person) -> PersonDto {
PersonDto {
id: p.id().value(),
external_id: p.external_id().value().to_string(),
name: p.name().to_string(),
known_for_department: p.known_for_department().map(str::to_string),
profile_path: p.profile_path().map(str::to_string),
biography: p.biography().map(str::to_string),
birthday: p.birthday().map(|d| d.to_string()),
deathday: p.deathday().map(|d| d.to_string()),
place_of_birth: p.place_of_birth().map(str::to_string),
also_known_as: p.also_known_as().to_vec(),
homepage: p.homepage().map(str::to_string),
imdb_url: p
.imdb_id()
.map(|id| format!("https://www.imdb.com/name/{id}")),
enriched: p.enriched_at().is_some(),
}
}

View File

@@ -0,0 +1,9 @@
use api_types::RemoteActorDto;
pub fn remote_actor_to_dto(a: activitypub::RemoteActor) -> RemoteActorDto {
RemoteActorDto {
handle: a.handle,
display_name: a.display_name,
url: a.url,
}
}

View File

@@ -1,7 +1,6 @@
use application::users::get_profile::PendingFollowerView;
use chrono::Datelike;
use domain::models::RemoteActorInfo;
use domain::models::{DiaryEntry, MonthActivity, UserSummary};
use domain::models::UserSummary;
use template_askama::{RemoteActorData, RemoteActorDisplay, UserSummaryView};
pub fn user_summary_view(u: &UserSummary) -> UserSummaryView {
@@ -44,49 +43,3 @@ pub fn pending_follower_data(p: &PendingFollowerView) -> RemoteActorData {
avatar_url: p.avatar_url.clone(),
}
}
pub fn group_by_month(entries: Vec<DiaryEntry>) -> Vec<MonthActivity> {
use std::collections::BTreeMap;
let mut map: BTreeMap<(i32, u32), Vec<DiaryEntry>> = BTreeMap::new();
for entry in entries {
let watched_at = entry.review().watched_at();
let year = watched_at.year();
let month = watched_at.month();
map.entry((year, month)).or_default().push(entry);
}
map.into_iter()
.rev()
.map(|((year, month), entries)| {
let year_month = format!("{:04}-{:02}", year, month);
MonthActivity {
month_label: format_year_month_long(&year_month),
count: entries.len() as i64,
entries,
year_month,
}
})
.collect()
}
fn format_year_month_long(ym: &str) -> String {
let parts: Vec<&str> = ym.splitn(2, '-').collect();
if parts.len() != 2 {
return ym.to_string();
}
let month = match parts[1] {
"01" => "January",
"02" => "February",
"03" => "March",
"04" => "April",
"05" => "May",
"06" => "June",
"07" => "July",
"08" => "August",
"09" => "September",
"10" => "October",
"11" => "November",
"12" => "December",
_ => parts[1],
};
format!("{} {}", month, parts[0])
}

View File

@@ -1,6 +1,6 @@
use api_types::{
ProfileFieldDto, ProfileResponse, UpdateProfileFieldsRequest, UserProfileResponse,
UserStatsDto, UserSummaryDto, UsersResponse,
ProfileFieldDto, ProfileResponse, UpdateProfileFieldsRequest, UserProfileBase,
UserProfileResponse, UserStatsDto, UserSummaryDto, UsersResponse,
};
use utoipa::OpenApi;
@@ -16,6 +16,7 @@ use utoipa::OpenApi;
components(schemas(
UsersResponse,
UserSummaryDto,
UserProfileBase,
UserProfileResponse,
UserStatsDto,
ProfileResponse,

View File

@@ -2,7 +2,7 @@ use std::sync::Arc;
use crate::context::AppContext;
use application::ports::RssFeedRenderer;
use domain::ports::RssFeedRenderer;
#[derive(Clone)]
pub struct AppState {

View File

@@ -86,7 +86,7 @@ impl MovieRepository for Panic {
}
#[async_trait::async_trait]
impl ReviewRepository for Panic {
async fn save_review(&self, _: &Review) -> Result<DomainEvent, DomainError> {
async fn save_review(&self, _: &Review) -> Result<(), DomainError> {
panic!()
}
async fn get_review_by_id(&self, _: &ReviewId) -> Result<Option<Review>, DomainError> {
@@ -154,7 +154,7 @@ impl DiaryRepository for Panic {
#[cfg(feature = "federation")]
#[async_trait::async_trait]
impl domain::ports::SocialQueryPort for Panic {
async fn get_accepted_following_urls(&self, _: uuid::Uuid) -> Result<Vec<String>, DomainError> {
async fn get_accepted_following_urls(&self, _: &UserId) -> Result<Vec<String>, DomainError> {
panic!()
}
async fn list_all_followed_remote_actors(
@@ -162,15 +162,15 @@ impl domain::ports::SocialQueryPort for Panic {
) -> Result<Vec<domain::models::RemoteActorInfo>, DomainError> {
panic!()
}
async fn count_following(&self, _: uuid::Uuid) -> Result<usize, DomainError> {
async fn count_following(&self, _: &UserId) -> Result<usize, DomainError> {
panic!()
}
async fn count_accepted_followers(&self, _: uuid::Uuid) -> Result<usize, DomainError> {
async fn count_accepted_followers(&self, _: &UserId) -> Result<usize, DomainError> {
panic!()
}
async fn get_pending_followers(
&self,
_: uuid::Uuid,
_: &UserId,
) -> Result<Vec<domain::models::PendingFollowerInfo>, DomainError> {
panic!()
}
@@ -183,6 +183,9 @@ impl StatsRepository for Panic {
async fn get_user_trends(&self, _: &UserId) -> Result<UserTrends, DomainError> {
panic!()
}
async fn count_reviews_in_year(&self, _: &UserId, _: u16) -> Result<u32, DomainError> {
panic!()
}
}
#[async_trait::async_trait]
impl MetadataClient for Panic {
@@ -424,7 +427,7 @@ impl domain::ports::DocumentParser for Panic {
}
}
impl application::ports::RssFeedRenderer for Panic {
impl domain::ports::RssFeedRenderer for Panic {
fn render_feed(&self, _: &[DiaryEntry], _: &str) -> Result<String, String> {
panic!()
}
@@ -700,13 +703,6 @@ impl domain::ports::GoalRepository for Panic {
) -> Result<Vec<domain::models::Goal>, DomainError> {
panic!()
}
async fn count_reviews_in_year(
&self,
_: &domain::value_objects::UserId,
_: u16,
) -> Result<u32, DomainError> {
panic!()
}
}
#[async_trait::async_trait]