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:
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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(¶ms.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(¶ms.format, user_id.value(), &state)
|
||||
}
|
||||
|
||||
pub async fn get_activity_feed_html(
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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()})))
|
||||
}
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
22
crates/presentation/src/mappers/search.rs
Normal file
22
crates/presentation/src/mappers/search.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
9
crates/presentation/src/mappers/social.rs
Normal file
9
crates/presentation/src/mappers/social.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user