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

@@ -2,12 +2,12 @@ use async_trait::async_trait;
use domain::{
errors::DomainError,
models::{
DiaryEntry, Goal, GoalType, Movie, PersistedReview, Review, ReviewSource, WatchlistEntry,
DiaryEntry, Movie, PersistedReview, Review, ReviewSource, WatchlistEntry,
WatchlistWithMovie,
},
ports::LocalApContentQuery,
value_objects::{
Comment, ExternalMetadataId, GoalId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
Comment, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
ReviewId, UserId, WatchlistEntryId,
},
};
@@ -82,6 +82,7 @@ struct ReviewRow {
watched_at: String,
created_at: String,
remote_actor_url: Option<String>,
watch_medium: Option<String>,
}
impl ReviewRow {
@@ -97,6 +98,7 @@ impl ReviewRow {
None => ReviewSource::Local,
Some(url) => ReviewSource::Remote { actor_url: url },
};
let watch_medium = self.watch_medium.map(|s| s.parse()).transpose()?;
Ok(Review::from_persistence(PersistedReview {
id,
movie_id,
@@ -106,7 +108,7 @@ impl ReviewRow {
watched_at,
created_at,
source,
watch_medium: None,
watch_medium,
}))
}
}
@@ -127,6 +129,7 @@ struct DiaryRow {
watched_at: String,
created_at: String,
remote_actor_url: Option<String>,
watch_medium: Option<String>,
}
impl DiaryRow {
@@ -149,105 +152,17 @@ impl DiaryRow {
watched_at: self.watched_at,
created_at: self.created_at,
remote_actor_url: self.remote_actor_url,
watch_medium: self.watch_medium,
}
.into_domain()?;
Ok(DiaryEntry::new(movie, review))
}
}
fn row_to_goal(r: &sqlx::postgres::PgRow) -> Result<Goal, DomainError> {
let id_str: String = r
.try_get("id")
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read goal id: {e}")))?;
let user_id_str: String = r
.try_get("user_id")
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read user_id: {e}")))?;
let year: i64 = r
.try_get("year")
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read year: {e}")))?;
let target: i64 = r.try_get("target_count").map_err(|e| {
DomainError::InfrastructureError(format!("Failed to read target_count: {e}"))
})?;
let goal_type_str: String = r
.try_get("goal_type")
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read goal_type: {e}")))?;
let created_at_str: String = r
.try_get("created_at")
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read created_at: {e}")))?;
let id = GoalId::from_uuid(parse_uuid(&id_str)?);
let user_id = UserId::from_uuid(parse_uuid(&user_id_str)?);
let goal_type: GoalType = goal_type_str.parse()?;
let created_at = parse_datetime(&created_at_str)?;
Ok(Goal::from_persistence(
id,
user_id,
year as u16,
target as u32,
goal_type,
created_at,
))
}
async fn count_reviews_in_year(
pool: &PgPool,
user_id: &UserId,
year: u16,
) -> Result<u32, DomainError> {
let uid = user_id.value().to_string();
let start = format!("{year}-01-01 00:00:00");
let end = format!("{}-01-01 00:00:00", year + 1);
let count: i64 = sqlx::query(
"SELECT COUNT(*) FROM reviews \
WHERE user_id = $1 \
AND watched_at >= $2::timestamptz \
AND watched_at < $3::timestamptz \
AND remote_actor_url IS NULL",
)
.bind(&uid)
.bind(&start)
.bind(&end)
.fetch_one(pool)
.await
.map_err(|e| {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
})?
.try_get(0)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(count as u32)
}
// ── LocalApContentQuery impl ─────────────────────────────────────────────────
#[async_trait]
impl LocalApContentQuery for PostgresApContentQuery {
async fn get_local_reviews_for_user(
&self,
user_id: &UserId,
) -> Result<Vec<DiaryEntry>, DomainError> {
let uid = user_id.value().to_string();
let rows = sqlx::query_as::<_, DiaryRow>(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
r.remote_actor_url
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = $1 AND r.remote_actor_url IS NULL
ORDER BY r.created_at DESC",
)
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
rows.into_iter().map(DiaryRow::into_domain).collect()
}
async fn get_local_watchlist_for_user(
&self,
user_id: &UserId,
@@ -324,7 +239,8 @@ impl LocalApContentQuery for PostgresApContentQuery {
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
r.remote_actor_url
r.remote_actor_url,
r.watch_medium
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.movie_id = $1 AND r.remote_actor_url IS NULL
@@ -337,62 +253,6 @@ impl LocalApContentQuery for PostgresApContentQuery {
rows.into_iter().map(DiaryRow::into_domain).collect()
}
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError> {
let id = review_id.value().to_string();
sqlx::query_as::<_, ReviewRow>(
"SELECT id, movie_id, user_id, rating, comment,
to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
remote_actor_url
FROM reviews WHERE id = $1",
)
.bind(&id)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?
.map(ReviewRow::into_domain)
.transpose()
}
async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result<Option<Movie>, DomainError> {
let id = movie_id.value().to_string();
sqlx::query_as::<_, MovieRow>(
"SELECT id, external_metadata_id, title, release_year, director, poster_path
FROM movies WHERE id = $1",
)
.bind(&id)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?
.map(MovieRow::into_domain)
.transpose()
}
async fn get_movie_by_external_metadata_id(
&self,
external_id: &str,
) -> Result<Option<Movie>, DomainError> {
sqlx::query_as::<_, MovieRow>(
"SELECT id, external_metadata_id, title, release_year, director, poster_path
FROM movies WHERE external_metadata_id = $1",
)
.bind(external_id)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?
.map(MovieRow::into_domain)
.transpose()
}
async fn count_local_posts(&self) -> Result<u64, DomainError> {
let count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE remote_actor_url IS NULL")
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(count as u64)
}
async fn get_local_reviews_page(
&self,
user_id: &UserId,
@@ -409,7 +269,8 @@ impl LocalApContentQuery for PostgresApContentQuery {
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
r.remote_actor_url
r.remote_actor_url,
r.watch_medium
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = $1 AND r.remote_actor_url IS NULL AND r.watched_at < $2::timestamptz
@@ -428,7 +289,8 @@ impl LocalApContentQuery for PostgresApContentQuery {
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
r.remote_actor_url
r.remote_actor_url,
r.watch_medium
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = $1 AND r.remote_actor_url IS NULL
@@ -443,45 +305,4 @@ impl LocalApContentQuery for PostgresApContentQuery {
};
rows.into_iter().map(DiaryRow::into_domain).collect()
}
async fn get_goal_with_progress(
&self,
user_id: &UserId,
year: u16,
) -> Result<Option<(Goal, u32)>, DomainError> {
let uid = user_id.value().to_string();
let y = year as i64;
let row = sqlx::query(
"SELECT id, user_id, year, target_count, goal_type, \
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at \
FROM goals WHERE user_id = $1 AND year = $2",
)
.bind(&uid)
.bind(y)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?;
let Some(r) = row else { return Ok(None) };
let goal = row_to_goal(&r)?;
let count = count_reviews_in_year(&self.pool, user_id, year).await?;
Ok(Some((goal, count)))
}
async fn list_goals_for_user(&self, user_id: &UserId) -> Result<Vec<Goal>, DomainError> {
let uid = user_id.value().to_string();
let rows = sqlx::query(
"SELECT id, user_id, year, target_count, goal_type, \
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at \
FROM goals WHERE user_id = $1 ORDER BY created_at DESC",
)
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
rows.iter().map(row_to_goal).collect()
}
}

View File

@@ -3,6 +3,7 @@ use domain::{
errors::DomainError,
models::{PendingFollowerInfo, RemoteActorInfo},
ports::SocialQueryPort,
value_objects::UserId,
};
use super::PostgresFederationRepository;
@@ -11,9 +12,9 @@ use super::PostgresFederationRepository;
impl SocialQueryPort for PostgresFederationRepository {
async fn get_accepted_following_urls(
&self,
user_id: uuid::Uuid,
user_id: &UserId,
) -> Result<Vec<String>, DomainError> {
let user_id_str = user_id.to_string();
let user_id_str = user_id.value().to_string();
sqlx::query_scalar::<_, String>(
"SELECT remote_actor_url FROM ap_following WHERE local_user_id = $1 AND status = 'accepted'",
).bind(&user_id_str).fetch_all(&self.pool).await
@@ -34,8 +35,8 @@ impl SocialQueryPort for PostgresFederationRepository {
.collect())
}
async fn count_following(&self, user_id: uuid::Uuid) -> Result<usize, DomainError> {
let uid = user_id.to_string();
async fn count_following(&self, user_id: &UserId) -> Result<usize, DomainError> {
let uid = user_id.value().to_string();
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ap_following WHERE local_user_id = $1 AND status = 'accepted'",
)
@@ -46,8 +47,8 @@ impl SocialQueryPort for PostgresFederationRepository {
Ok(count as usize)
}
async fn count_accepted_followers(&self, user_id: uuid::Uuid) -> Result<usize, DomainError> {
let uid = user_id.to_string();
async fn count_accepted_followers(&self, user_id: &UserId) -> Result<usize, DomainError> {
let uid = user_id.value().to_string();
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ap_followers WHERE local_user_id = $1 AND status = 'accepted'",
)
@@ -60,9 +61,9 @@ impl SocialQueryPort for PostgresFederationRepository {
async fn get_pending_followers(
&self,
user_id: uuid::Uuid,
user_id: &UserId,
) -> Result<Vec<PendingFollowerInfo>, DomainError> {
let uid = user_id.to_string();
let uid = user_id.value().to_string();
let rows = sqlx::query_as::<_, (String, String, Option<String>, Option<String>)>(
"SELECT ar.url, ar.handle, ar.display_name, ar.avatar_url FROM ap_followers f JOIN ap_remote_actors ar ON ar.url = f.remote_actor_url WHERE f.local_user_id = $1 AND f.status = 'pending'",
).bind(&uid).fetch_all(&self.pool).await.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;