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

16
Cargo.lock generated
View File

@@ -293,6 +293,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
name = "api-types"
version = "0.1.0"
dependencies = [
"domain",
"serde",
"utoipa",
"uuid",
@@ -319,8 +320,8 @@ dependencies = [
"domain",
"futures",
"hex",
"infra-wiring",
"rand 0.9.4",
"reqwest 0.13.3",
"serde_json",
"sha2",
"tokio",
@@ -2701,6 +2702,14 @@ dependencies = [
"cfb",
]
[[package]]
name = "infra-wiring"
version = "0.1.0"
dependencies = [
"anyhow",
"sqlx",
]
[[package]]
name = "inout"
version = "0.1.4"
@@ -3982,6 +3991,7 @@ dependencies = [
"http-body-util",
"importer",
"infer",
"infra-wiring",
"jellyfin",
"metadata",
"nats",
@@ -4587,7 +4597,6 @@ dependencies = [
name = "rss"
version = "0.1.0"
dependencies = [
"application",
"domain",
"rss 2.0.13",
]
@@ -5597,7 +5606,7 @@ dependencies = [
name = "template-askama"
version = "0.1.0"
dependencies = [
"application",
"api-types",
"askama",
"chrono",
"domain",
@@ -7127,6 +7136,7 @@ dependencies = [
"export",
"image-converter",
"importer",
"infra-wiring",
"metadata",
"nats",
"object-storage",

View File

@@ -31,6 +31,7 @@ members = [
"crates/adapters/plex",
"crates/adapters/sqlite-search",
"crates/adapters/postgres-search",
"crates/infra-wiring",
]
resolver = "2"
@@ -91,6 +92,7 @@ plex = { path = "crates/adapters/plex" }
image-converter = { path = "crates/adapters/image-converter" }
sqlite-search = { path = "crates/adapters/sqlite-search" }
postgres-search = { path = "crates/adapters/postgres-search" }
infra-wiring = { path = "crates/infra-wiring" }
[profile.dev]
debug = 1 # line tables only — still debuggable, much faster linking

View File

@@ -45,6 +45,7 @@ COPY crates/adapters/image-converter/Cargo.toml crates/adapters/image-converte
COPY crates/adapters/sqlite-search/Cargo.toml crates/adapters/sqlite-search/Cargo.toml
COPY crates/adapters/postgres-search/Cargo.toml crates/adapters/postgres-search/Cargo.toml
COPY crates/worker/Cargo.toml crates/worker/Cargo.toml
COPY crates/infra-wiring/Cargo.toml crates/infra-wiring/Cargo.toml
# Stub every crate so cargo can resolve and fetch deps
RUN find crates -name "Cargo.toml" | sed 's|/Cargo.toml||' | \

View File

@@ -4,7 +4,10 @@ use domain::ports::EventHandler;
use domain::{
errors::DomainError,
events::DomainEvent,
ports::{LocalApContentQuery, UserFederationSettingsQuery},
ports::{
GoalRepository, LocalApContentQuery, MovieRepository, ReviewRepository, StatsRepository,
UserFederationSettingsQuery,
},
value_objects::{MovieId, ReviewId, UserId},
};
use std::sync::Arc;
@@ -17,20 +20,33 @@ use crate::urls::{actor_url, goal_url, review_url};
pub struct ActivityPubEventHandler {
ap_service: Arc<ActivityPubService>,
content_query: Arc<dyn LocalApContentQuery>,
review_repo: Arc<dyn ReviewRepository>,
movie_repo: Arc<dyn MovieRepository>,
goal_repo: Arc<dyn GoalRepository>,
stats_repo: Arc<dyn StatsRepository>,
federation_settings: Arc<dyn UserFederationSettingsQuery>,
base_url: String,
}
impl ActivityPubEventHandler {
#[allow(clippy::too_many_arguments)]
pub fn new(
ap_service: Arc<ActivityPubService>,
content_query: Arc<dyn LocalApContentQuery>,
review_repo: Arc<dyn ReviewRepository>,
movie_repo: Arc<dyn MovieRepository>,
goal_repo: Arc<dyn GoalRepository>,
stats_repo: Arc<dyn StatsRepository>,
federation_settings: Arc<dyn UserFederationSettingsQuery>,
base_url: String,
) -> Self {
Self {
ap_service,
content_query,
review_repo,
movie_repo,
goal_repo,
stats_repo,
federation_settings,
base_url,
}
@@ -157,16 +173,12 @@ impl ActivityPubEventHandler {
.federation_settings
.get_federation_flags(user_id)
.await
.unwrap_or(domain::models::FederationFlags {
goals: true,
reviews: true,
watchlist: true,
});
.unwrap_or_default();
if !flags.reviews {
return Ok(());
}
let review = match self.content_query.get_review_by_id(review_id).await? {
let review = match self.review_repo.get_review_by_id(review_id).await? {
Some(r) => r,
None => return Ok(()),
};
@@ -175,7 +187,7 @@ impl ActivityPubEventHandler {
let actor = actor_url(&self.base_url, user_id.value());
let movie = self
.content_query
.movie_repo
.get_movie_by_id(review.movie_id())
.await
.ok()
@@ -227,16 +239,12 @@ impl ActivityPubEventHandler {
.federation_settings
.get_federation_flags(user_id)
.await
.unwrap_or(domain::models::FederationFlags {
goals: true,
reviews: true,
watchlist: true,
});
.unwrap_or_default();
if !flags.reviews {
return Ok(());
}
let review = match self.content_query.get_review_by_id(review_id).await? {
let review = match self.review_repo.get_review_by_id(review_id).await? {
Some(r) => r,
None => return Ok(()),
};
@@ -245,7 +253,7 @@ impl ActivityPubEventHandler {
let actor = actor_url(&self.base_url, user_id.value());
let movie = self
.content_query
.movie_repo
.get_movie_by_id(review.movie_id())
.await
.ok()
@@ -310,11 +318,7 @@ impl ActivityPubEventHandler {
.federation_settings
.get_federation_flags(user_id)
.await
.unwrap_or(domain::models::FederationFlags {
goals: true,
reviews: true,
watchlist: true,
});
.unwrap_or_default();
if !flags.watchlist {
return Ok(());
}
@@ -324,7 +328,7 @@ impl ActivityPubEventHandler {
let actor = actor_url(&self.base_url, user_id.value());
let poster_url = self
.content_query
.movie_repo
.get_movie_by_id(movie_id)
.await
.ok()
@@ -373,7 +377,7 @@ impl ActivityPubEventHandler {
.get_local_reviews_for_movie(movie_id)
.await?;
let movie = self.content_query.get_movie_by_id(movie_id).await?;
let movie = self.movie_repo.get_movie_by_id(movie_id).await?;
let movie = match movie {
Some(m) => m,
None => return Ok(()),
@@ -393,11 +397,7 @@ impl ActivityPubEventHandler {
.federation_settings
.get_federation_flags(user_id)
.await
.unwrap_or(domain::models::FederationFlags {
goals: true,
reviews: true,
watchlist: true,
});
.unwrap_or_default();
if !flags.reviews {
continue;
}
@@ -436,23 +436,24 @@ impl ActivityPubEventHandler {
.federation_settings
.get_federation_flags(user_id)
.await
.unwrap_or(domain::models::FederationFlags {
goals: true,
reviews: true,
watchlist: true,
});
.unwrap_or_default();
if !flags.goals {
return Ok(());
}
let Some((goal, current)) = self
.content_query
.get_goal_with_progress(user_id, year)
let Some(goal) = self
.goal_repo
.find_by_user_and_year(user_id, year)
.await
.ok()
.flatten()
else {
return Ok(());
};
let current = self
.stats_repo
.count_reviews_in_year(user_id, year)
.await
.unwrap_or(0);
let ap_id = goal_url(&self.base_url, user_id.value(), year);
let actor = actor_url(&self.base_url, user_id.value());
let obj = goal_to_ap_object(
@@ -481,21 +482,14 @@ impl ActivityPubEventHandler {
.federation_settings
.get_federation_flags(user_id)
.await
.unwrap_or(domain::models::FederationFlags {
goals: true,
reviews: true,
watchlist: true,
});
.unwrap_or_default();
if !flags.goals {
return Ok(());
}
let current = self
.content_query
.get_goal_with_progress(user_id, year)
.stats_repo
.count_reviews_in_year(user_id, year)
.await
.ok()
.flatten()
.map(|(_, c)| c)
.unwrap_or(0);
let ap_id = goal_url(&self.base_url, user_id.value(), year);
@@ -519,11 +513,7 @@ impl ActivityPubEventHandler {
.federation_settings
.get_federation_flags(user_id)
.await
.unwrap_or(domain::models::FederationFlags {
goals: true,
reviews: true,
watchlist: true,
});
.unwrap_or_default();
if !flags.goals {
return Ok(());
}

View File

@@ -4,7 +4,7 @@ use async_trait::async_trait;
use chrono::DateTime;
use domain::{
models::RemoteGoalEntry,
ports::{LocalApContentQuery, RemoteGoalRepository},
ports::{GoalRepository, RemoteGoalRepository},
value_objects::UserId,
};
use k_ap::{ApContentReader, ApObjectHandler};
@@ -15,7 +15,7 @@ use crate::urls::{actor_url, goal_url};
pub struct GoalObjectHandler {
pub remote_goal_repo: Arc<dyn RemoteGoalRepository>,
pub content_query: Arc<dyn LocalApContentQuery>,
pub goal_repo: Arc<dyn GoalRepository>,
pub base_url: String,
}
@@ -29,8 +29,8 @@ impl ApContentReader for GoalObjectHandler {
) -> anyhow::Result<Vec<(Url, serde_json::Value, DateTime<chrono::Utc>)>> {
let uid = UserId::from_uuid(user_id);
let goals = self
.content_query
.list_goals_for_user(&uid)
.goal_repo
.list_for_user(&uid)
.await
.map_err(|e| anyhow::anyhow!(e.to_string()))?;

View File

@@ -51,6 +51,11 @@ pub struct ActivityPubDeps {
pub remote_watchlist_repo: std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>,
pub remote_goal_repo: std::sync::Arc<dyn domain::ports::RemoteGoalRepository>,
pub local_ap_content: std::sync::Arc<dyn domain::ports::LocalApContentQuery>,
pub movie_repo: std::sync::Arc<dyn domain::ports::MovieRepository>,
pub review_repo: std::sync::Arc<dyn domain::ports::ReviewRepository>,
pub diary_repo: std::sync::Arc<dyn domain::ports::DiaryRepository>,
pub goal_repo: std::sync::Arc<dyn domain::ports::GoalRepository>,
pub stats_repo: std::sync::Arc<dyn domain::ports::StatsRepository>,
pub user_repo: std::sync::Arc<dyn domain::ports::UserRepository>,
pub federation_settings: std::sync::Arc<dyn domain::ports::UserFederationSettingsQuery>,
pub base_url: String,
@@ -68,6 +73,11 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
remote_watchlist_repo,
remote_goal_repo,
local_ap_content,
movie_repo,
review_repo,
diary_repo,
goal_repo,
stats_repo,
user_repo,
federation_settings,
base_url,
@@ -76,6 +86,8 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
} = deps;
let review_handler = std::sync::Arc::new(ReviewObjectHandler {
content_query: std::sync::Arc::clone(&local_ap_content),
movie_repo: std::sync::Arc::clone(&movie_repo),
diary_repo,
review_store,
event_publisher: std::sync::Arc::clone(&event_publisher),
base_url: base_url.clone(),
@@ -87,7 +99,7 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
});
let goal_handler = std::sync::Arc::new(goal_handler::GoalObjectHandler {
remote_goal_repo,
content_query: std::sync::Arc::clone(&local_ap_content),
goal_repo: std::sync::Arc::clone(&goal_repo),
base_url: base_url.clone(),
});
let composite = std::sync::Arc::new(composite_handler::CompositeObjectHandler {
@@ -136,6 +148,10 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
let event_handler = std::sync::Arc::new(ActivityPubEventHandler::new(
std::sync::Arc::clone(&concrete),
local_ap_content,
review_repo,
movie_repo,
goal_repo,
stats_repo,
federation_settings,
base_url,
)) as std::sync::Arc<dyn domain::ports::EventHandler>;

View File

@@ -4,7 +4,7 @@ use async_trait::async_trait;
use domain::{
events::DomainEvent,
models::ReviewSource,
ports::{EventPublisher, LocalApContentQuery},
ports::{DiaryRepository, EventPublisher, LocalApContentQuery, MovieRepository},
value_objects::{Comment, ExternalMetadataId, MovieId, Rating, ReviewId, UserId},
};
use k_ap::{ApContentReader, ApObjectHandler};
@@ -16,6 +16,8 @@ use crate::urls::{actor_url, review_url};
pub struct ReviewObjectHandler {
pub content_query: Arc<dyn LocalApContentQuery>,
pub movie_repo: Arc<dyn MovieRepository>,
pub diary_repo: Arc<dyn DiaryRepository>,
pub review_store: Arc<dyn RemoteReviewRepository>,
pub event_publisher: Arc<dyn EventPublisher>,
pub base_url: String,
@@ -69,7 +71,7 @@ impl ApContentReader for ReviewObjectHandler {
}
async fn count_local_posts(&self) -> anyhow::Result<u64> {
self.content_query
self.diary_repo
.count_local_posts()
.await
.map_err(|e| anyhow::anyhow!(e.to_string()))
@@ -97,13 +99,18 @@ impl ApObjectHandler for ReviewObjectHandler {
let actor_url_str = obj.attributed_to.to_string();
let review_id = ReviewId::generate();
let movie_id = if let Some(ref ext_id) = obj.external_metadata_id {
match self
.content_query
.get_movie_by_external_metadata_id(ext_id)
let found = if let Ok(ext_meta_id) = ExternalMetadataId::new(ext_id.clone()) {
self.movie_repo
.get_movie_by_external_id(&ext_meta_id)
.await
{
Ok(Some(movie)) => movie.id().clone(),
_ => MovieId::from_uuid(uuid::Uuid::new_v5(
.ok()
.flatten()
} else {
None
};
match found {
Some(movie) => movie.id().clone(),
None => MovieId::from_uuid(uuid::Uuid::new_v5(
&uuid::Uuid::NAMESPACE_URL,
ext_id.as_bytes(),
)),

View File

@@ -4,7 +4,11 @@ pub use config::PosterFetcherConfig;
use std::time::Duration;
use async_trait::async_trait;
use domain::{errors::DomainError, ports::PosterFetcherClient, value_objects::PosterUrl};
use domain::{
errors::DomainError,
ports::{ImageFetcher, PosterFetcherClient},
value_objects::PosterUrl,
};
pub struct ReqwestPosterFetcher {
client: reqwest::Client,
@@ -37,8 +41,32 @@ impl PosterFetcherClient for ReqwestPosterFetcher {
}
}
#[async_trait]
impl ImageFetcher for ReqwestPosterFetcher {
async fn fetch_image(&self, url: &str) -> Result<Vec<u8>, DomainError> {
let bytes = self
.client
.get(url)
.send()
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
.error_for_status()
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
.bytes()
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(bytes.to_vec())
}
}
pub fn create() -> anyhow::Result<std::sync::Arc<dyn domain::ports::PosterFetcherClient>> {
Ok(std::sync::Arc::new(ReqwestPosterFetcher::new(
PosterFetcherConfig::from_env(),
)?))
}
pub fn create_image_fetcher() -> anyhow::Result<std::sync::Arc<dyn domain::ports::ImageFetcher>> {
Ok(std::sync::Arc::new(ReqwestPosterFetcher::new(
PosterFetcherConfig::from_env(),
)?))
}

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()))?;

View File

@@ -416,7 +416,8 @@ impl DiaryRepository for PostgresDiaryRepository {
"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
remote_actor_url,
watch_medium
FROM reviews WHERE movie_id = $1 ORDER BY watched_at ASC",
)
.bind(&id_str)
@@ -464,7 +465,8 @@ impl DiaryRepository for PostgresDiaryRepository {
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

View File

@@ -122,10 +122,6 @@ impl GoalRepository for PostgresGoalRepository {
rows.iter().map(row_to_goal).collect()
}
async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result<u32, DomainError> {
count_reviews_in_year(&self.pool, user_id, year).await
}
}
pub(crate) async fn count_reviews_in_year(

View File

@@ -1,7 +1,6 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
events::DomainEvent,
models::{Review, ReviewSource},
ports::ReviewRepository,
value_objects::{ReviewId, UserId},
@@ -27,7 +26,7 @@ impl PostgresReviewRepository {
#[async_trait]
impl ReviewRepository for PostgresReviewRepository {
async fn save_review(&self, review: &Review) -> Result<DomainEvent, DomainError> {
async fn save_review(&self, review: &Review) -> Result<(), DomainError> {
let id = review.id().value().to_string();
let movie_id = review.movie_id().value().to_string();
let user_id = review.user_id().value().to_string();
@@ -57,13 +56,7 @@ impl ReviewRepository for PostgresReviewRepository {
.await
.map_err(Self::map_err)?;
Ok(DomainEvent::ReviewLogged {
review_id: review.id().clone(),
movie_id: review.movie_id().clone(),
user_id: review.user_id().clone(),
rating: review.rating().clone(),
watched_at: *review.watched_at(),
})
Ok(())
}
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError> {

View File

@@ -95,6 +95,10 @@ impl StatsRepository for PostgresStatsRepository {
})
}
async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result<u32, DomainError> {
crate::goals::count_reviews_in_year(&self.pool, user_id, year).await
}
async fn get_user_trends(&self, user_id: &UserId) -> Result<UserTrends, DomainError> {
let uid = user_id.value().to_string();

View File

@@ -6,4 +6,3 @@ edition = "2024"
[dependencies]
rss-feed = { package = "rss", version = "2" }
domain = { workspace = true }
application = { workspace = true }

View File

@@ -1,5 +1,5 @@
use application::ports::RssFeedRenderer;
use domain::models::DiaryEntry;
use domain::ports::RssFeedRenderer;
use rss_feed::{ChannelBuilder, GuidBuilder, ItemBuilder};
pub struct RssAdapter {

View File

@@ -2,16 +2,16 @@ 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,
},
};
use sqlx::{Row, SqlitePool};
use sqlx::SqlitePool;
use uuid::Uuid;
pub struct SqliteApContentQuery {
@@ -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,6 +152,7 @@ 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))
@@ -190,100 +194,10 @@ impl WatchlistRow {
}
}
fn row_to_goal(r: &sqlx::sqlite::SqliteRow) -> 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(
Uuid::parse_str(&id_str)
.map_err(|e| DomainError::InfrastructureError(format!("Invalid goal UUID: {e}")))?,
);
let user_id = UserId::from_uuid(
Uuid::parse_str(&user_id_str)
.map_err(|e| DomainError::InfrastructureError(format!("Invalid user UUID: {e}")))?,
);
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: &SqlitePool,
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 = ? AND watched_at >= ? AND watched_at < ? \
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 SqliteApContentQuery {
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, r.watched_at, r.created_at, r.remote_actor_url
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = ? 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,
@@ -312,7 +226,7 @@ impl LocalApContentQuery for SqliteApContentQuery {
let mid = movie_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, r.watched_at, r.created_at, r.remote_actor_url
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.movie_id = ? AND r.remote_actor_url IS NULL
@@ -325,59 +239,6 @@ impl LocalApContentQuery for SqliteApContentQuery {
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, watched_at, created_at, remote_actor_url
FROM reviews WHERE id = ?",
)
.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 = ?",
)
.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 = ?",
)
.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,
@@ -391,7 +252,7 @@ impl LocalApContentQuery for SqliteApContentQuery {
let ts = before_ts.format("%Y-%m-%d %H:%M:%S").to_string();
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, r.watched_at, r.created_at, r.remote_actor_url
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = ? AND r.remote_actor_url IS NULL AND r.watched_at < ?
@@ -407,7 +268,7 @@ impl LocalApContentQuery for SqliteApContentQuery {
} else {
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, r.watched_at, r.created_at, r.remote_actor_url
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = ? AND r.remote_actor_url IS NULL
@@ -422,43 +283,4 @@ impl LocalApContentQuery for SqliteApContentQuery {
};
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, created_at \
FROM goals WHERE user_id = ? AND year = ?",
)
.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, created_at \
FROM goals WHERE user_id = ? 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::SqliteFederationRepository;
@@ -11,9 +12,9 @@ use super::SqliteFederationRepository;
impl SocialQueryPort for SqliteFederationRepository {
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 = ? AND status = 'accepted'",
).bind(&user_id_str).fetch_all(&self.pool).await
@@ -40,8 +41,8 @@ impl SocialQueryPort for SqliteFederationRepository {
.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 = ? AND status = 'accepted'",
)
@@ -52,8 +53,8 @@ impl SocialQueryPort for SqliteFederationRepository {
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 = ? AND status = 'accepted'",
)
@@ -66,9 +67,9 @@ impl SocialQueryPort for SqliteFederationRepository {
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

View File

@@ -97,7 +97,8 @@ async fn test_get_accepted_following_urls_returns_only_accepted() {
.await
.unwrap();
let urls = repo.get_accepted_following_urls(user_id).await.unwrap();
let uid = domain::value_objects::UserId::from_uuid(user_id);
let urls = repo.get_accepted_following_urls(&uid).await.unwrap();
assert_eq!(urls.len(), 1);
assert_eq!(urls[0], "https://other.social/users/alice");
}

View File

@@ -378,7 +378,7 @@ impl DiaryRepository for SqliteDiaryRepository {
.into_domain()?;
let viewings = sqlx::query_as::<_, ReviewRow>(
"SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url
"SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium
FROM reviews WHERE movie_id = ? ORDER BY watched_at ASC",
)
.bind(&id_str)

View File

@@ -118,10 +118,6 @@ impl GoalRepository for SqliteGoalRepository {
rows.iter().map(row_to_goal).collect()
}
async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result<u32, DomainError> {
count_reviews_in_year(&self.pool, user_id, year).await
}
}
pub(crate) async fn count_reviews_in_year(

View File

@@ -1,7 +1,6 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
events::DomainEvent,
models::{Review, ReviewSource},
ports::ReviewRepository,
value_objects::{ReviewId, UserId},
@@ -27,7 +26,7 @@ impl SqliteReviewRepository {
#[async_trait]
impl ReviewRepository for SqliteReviewRepository {
async fn save_review(&self, review: &Review) -> Result<DomainEvent, DomainError> {
async fn save_review(&self, review: &Review) -> Result<(), DomainError> {
let id = review.id().value().to_string();
let movie_id = review.movie_id().value().to_string();
let user_id = review.user_id().value().to_string();
@@ -57,13 +56,7 @@ impl ReviewRepository for SqliteReviewRepository {
.await
.map_err(Self::map_err)?;
Ok(DomainEvent::ReviewLogged {
review_id: review.id().clone(),
movie_id: review.movie_id().clone(),
user_id: review.user_id().clone(),
rating: review.rating().clone(),
watched_at: *review.watched_at(),
})
Ok(())
}
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError> {

View File

@@ -96,6 +96,10 @@ impl StatsRepository for SqliteStatsRepository {
})
}
async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result<u32, DomainError> {
crate::goals::count_reviews_in_year(&self.pool, user_id, year).await
}
async fn get_user_trends(&self, user_id: &UserId) -> Result<UserTrends, DomainError> {
let uid = user_id.value().to_string();

View File

@@ -10,4 +10,4 @@ chrono = { workspace = true }
uuid = { workspace = true }
domain = { workspace = true }
application = { workspace = true }
api-types = { workspace = true }

View File

@@ -1,7 +1,7 @@
pub use askama;
use askama::Template;
use application::rendering::HtmlPageContext;
use api_types::HtmlPageContext;
use chrono::Datelike;
use domain::models::{
DiaryEntry, FeedEntry, MonthActivity, MonthlyRating, ReviewSource, UserStats, UserTrends,

View File

@@ -7,3 +7,4 @@ edition = "2024"
serde = { workspace = true }
uuid = { workspace = true }
utoipa = { version = "5.5.0", features = ["axum_extras", "uuid"] }
domain = { path = "../domain" }

View File

@@ -14,7 +14,8 @@ pub struct LoginResponse {
pub user_id: Uuid,
pub email: String,
pub expires_at: String,
pub role: String,
#[schema(value_type = String)]
pub role: domain::models::UserRole,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]

View File

@@ -18,7 +18,8 @@ pub struct LogReviewRequest {
pub comment: Option<String>,
pub watched_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub watch_medium: Option<String>,
#[schema(value_type = Option<String>)]
pub watch_medium: Option<domain::value_objects::WatchMedium>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
@@ -89,7 +90,8 @@ pub struct EditReviewRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub watched_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub watch_medium: Option<Option<String>>,
#[schema(value_type = Option<Option<String>>)]
pub watch_medium: Option<Option<domain::value_objects::WatchMedium>>,
}
fn default_export_format() -> String {

View File

@@ -7,7 +7,8 @@ pub struct GoalDto {
pub current_count: u32,
pub percentage: f64,
pub is_complete: bool,
pub goal_type: String,
#[schema(value_type = String)]
pub goal_type: domain::models::GoalType,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
@@ -25,17 +26,3 @@ pub struct CreateGoalRequest {
pub struct UpdateGoalRequest {
pub target_count: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct UserSettingsDto {
pub federate_goals: bool,
pub federate_reviews: bool,
pub federate_watchlist: bool,
}
#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)]
pub struct UpdateUserSettingsRequest {
pub federate_goals: bool,
pub federate_reviews: bool,
pub federate_watchlist: bool,
}

View File

@@ -46,29 +46,24 @@ pub struct SaveProfileRequest {
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct PreviewRowData {
pub index: usize,
pub title: Option<String>,
pub release_year: Option<String>,
pub director: Option<String>,
pub rating: Option<String>,
pub watched_at: Option<String>,
pub comment: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
#[serde(tag = "status")]
pub enum PreviewRowDto {
#[serde(rename = "valid")]
Valid {
index: usize,
title: Option<String>,
release_year: Option<String>,
director: Option<String>,
rating: Option<String>,
watched_at: Option<String>,
comment: Option<String>,
},
Valid(PreviewRowData),
#[serde(rename = "duplicate")]
Duplicate {
index: usize,
title: Option<String>,
release_year: Option<String>,
director: Option<String>,
rating: Option<String>,
watched_at: Option<String>,
comment: Option<String>,
},
Duplicate(PreviewRowData),
#[serde(rename = "invalid")]
Invalid { index: usize, errors: Vec<String> },
}

View File

@@ -4,6 +4,7 @@ pub mod diary;
pub mod goals;
pub mod import;
pub mod movies;
pub mod rendering;
pub mod search;
pub mod social;
pub mod users;
@@ -17,6 +18,7 @@ pub use diary::*;
pub use goals::*;
pub use import::*;
pub use movies::*;
pub use rendering::*;
pub use social::*;
pub use users::*;
pub use watchlist::*;

View File

@@ -99,7 +99,8 @@ pub struct ReviewDto {
pub comment: Option<String>,
pub watched_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub watch_medium: Option<String>,
#[schema(value_type = Option<String>)]
pub watch_medium: Option<domain::value_objects::WatchMedium>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
@@ -125,7 +126,8 @@ pub struct SocialReviewDto {
pub watched_at: String,
pub is_federated: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub watch_medium: Option<String>,
#[schema(value_type = Option<String>)]
pub watch_medium: Option<domain::value_objects::WatchMedium>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]

View File

@@ -67,13 +67,23 @@ pub struct UserTrendsDto {
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct UserProfileResponse {
pub user_id: Uuid,
pub struct UserProfileBase {
pub username: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bio: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub avatar_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub banner_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct UserProfileResponse {
pub user_id: Uuid,
#[serde(flatten)]
pub profile: UserProfileBase,
pub stats: UserStatsDto,
pub following_count: usize,
pub followers_count: usize,
@@ -90,23 +100,17 @@ pub struct UserProfileResponse {
#[serde(skip_serializing_if = "Option::is_none")]
pub handle: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bio: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub actor_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ProfileResponse {
pub username: String,
pub display_name: Option<String>,
pub bio: Option<String>,
pub avatar_url: Option<String>,
pub banner_url: Option<String>,
#[serde(flatten)]
pub profile: UserProfileBase,
pub also_known_as: Option<String>,
pub fields: Vec<ProfileFieldDto>,
pub role: String,
#[schema(value_type = String)]
pub role: domain::models::UserRole,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
@@ -119,3 +123,17 @@ pub struct ProfileFieldDto {
pub struct UpdateProfileFieldsRequest {
pub fields: Vec<ProfileFieldDto>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct UserSettingsDto {
pub federate_goals: bool,
pub federate_reviews: bool,
pub federate_watchlist: bool,
}
#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)]
pub struct UpdateUserSettingsRequest {
pub federate_goals: bool,
pub federate_reviews: bool,
pub federate_watchlist: bool,
}

View File

@@ -6,7 +6,7 @@ edition = "2024"
[dependencies]
async-trait = { workspace = true }
domain = { workspace = true }
reqwest = { workspace = true }
infra-wiring = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
tracing = { workspace = true }

View File

@@ -11,7 +11,7 @@ pub struct LoginResult {
pub user_id: Uuid,
pub email: String,
pub expires_at: DateTime<Utc>,
pub role: String,
pub role: domain::models::UserRole,
}
pub async fn execute(deps: &LoginDeps, query: LoginQuery) -> Result<LoginResult, DomainError> {
@@ -49,7 +49,7 @@ pub async fn execute(deps: &LoginDeps, query: LoginQuery) -> Result<LoginResult,
user_id: user.id().value(),
email: user.email().value().to_string(),
expires_at: generated.expires_at,
role: user.role().as_str().into(),
role: user.role().clone(),
})
}

View File

@@ -1,50 +1 @@
#[derive(Clone)]
pub struct AppConfig {
pub allow_registration: bool,
pub base_url: String,
pub rate_limit: u64,
pub refresh_ttl_seconds: u64,
pub wrapup: WrapUpConfig,
}
#[derive(Clone)]
pub struct WrapUpConfig {
pub font_path: Option<String>,
pub logo_path: Option<String>,
pub bg_dir: Option<String>,
}
impl AppConfig {
pub fn from_env() -> Self {
let allow_registration = std::env::var("ALLOW_REGISTRATION")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
let base_url =
std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:3000".to_string());
let rate_limit = std::env::var("RATE_LIMIT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(60);
let refresh_ttl_seconds = std::env::var("REFRESH_TTL_SECONDS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(2_592_000u64);
Self {
allow_registration,
base_url,
rate_limit,
refresh_ttl_seconds,
wrapup: WrapUpConfig::from_env(),
}
}
}
impl WrapUpConfig {
pub fn from_env() -> Self {
Self {
font_path: std::env::var("WRAPUP_FONT_PATH").ok(),
logo_path: std::env::var("WRAPUP_LOGO_PATH").ok(),
bg_dir: std::env::var("WRAPUP_BG_DIR").ok(),
}
}
}
pub use infra_wiring::{AppConfig, WrapUpConfig};

View File

@@ -6,6 +6,7 @@ use domain::{
FeedEntry,
collections::{PageParams, Paginated},
},
value_objects::UserId,
};
pub async fn execute(
@@ -34,9 +35,10 @@ async fn build_following_filter(
return None;
}
let viewer_id = query.viewer_user_id?;
let viewer = UserId::from_uuid(viewer_id);
let urls = deps
.social_query
.get_accepted_following_urls(viewer_id)
.get_accepted_following_urls(&viewer)
.await
.unwrap_or_default();
if urls.is_empty() {

View File

@@ -4,15 +4,15 @@ use async_trait::async_trait;
use domain::{
errors::DomainError,
events::DomainEvent,
models::{Movie, Review},
models::Review,
ports::{
EventPublisher, MetadataClient, MovieRepository, ReviewRepository, WatchlistRepository,
},
value_objects::{Comment, MovieId, Rating, UserId},
value_objects::{Comment, Rating, UserId},
};
use crate::diary::commands::LogReviewCommand;
use crate::diary::movie_resolver::{MovieResolver, MovieResolverDeps};
use crate::movies::resolve::resolve_and_persist_movie;
use crate::ports::ReviewLogger;
pub struct DefaultReviewLogger {
@@ -48,25 +48,18 @@ impl ReviewLogger for DefaultReviewLogger {
let user_id = UserId::from_uuid(cmd.user_id);
let comment = cmd.comment.clone().map(Comment::new).transpose()?;
let (movie, is_new_movie) = if let Some(id) = cmd.input.movie_id {
let movie_id = MovieId::from_uuid(id);
let movie = self
.movie_repo
.get_movie_by_id(&movie_id)
.await?
.ok_or_else(|| DomainError::NotFound(format!("Movie {id}")))?;
(movie, false)
} else {
let deps = MovieResolverDeps {
repository: self.movie_repo.as_ref(),
metadata_client: self.metadata_client.as_ref(),
};
MovieResolver::default_pipeline()
.resolve(&cmd.input, &deps)
.await?
};
let (movie, is_new_movie) = resolve_and_persist_movie(
&cmd.input,
self.movie_repo.as_ref(),
self.metadata_client.as_ref(),
self.event_publisher.as_ref(),
)
.await?;
// Always upsert: even existing movies may have updated metadata
if !is_new_movie {
self.movie_repo.upsert_movie(&movie).await?;
}
let review = Review::new(
movie.id().clone(),
@@ -76,7 +69,14 @@ impl ReviewLogger for DefaultReviewLogger {
cmd.watched_at,
cmd.watch_medium,
)?;
let review_event = self.review_repo.save_review(&review).await?;
self.review_repo.save_review(&review).await?;
let review_event = DomainEvent::ReviewLogged {
review_id: review.id().clone(),
movie_id: review.movie_id().clone(),
user_id: review.user_id().clone(),
rating: review.rating().clone(),
watched_at: *review.watched_at(),
};
let was_on_watchlist = self
.watchlist_repo
@@ -92,27 +92,8 @@ impl ReviewLogger for DefaultReviewLogger {
.await;
}
publish_events(&self.event_publisher, &movie, is_new_movie, review_event).await
}
}
async fn publish_events(
publisher: &Arc<dyn EventPublisher>,
movie: &Movie,
is_new_movie: bool,
review_event: DomainEvent,
) -> Result<(), DomainError> {
if is_new_movie && let Some(ext_id) = movie.external_metadata_id() {
publisher
.publish(&DomainEvent::MovieDiscovered {
movie_id: movie.id().clone(),
external_metadata_id: ext_id.clone(),
})
.await?;
}
if let Some(ext_id) = movie.external_metadata_id() {
publisher
self.event_publisher
.publish(&DomainEvent::MovieEnrichmentRequested {
movie_id: movie.id().clone(),
external_metadata_id: ext_id.clone(),
@@ -120,7 +101,8 @@ async fn publish_events(
.await?;
}
publisher.publish(&review_event).await
self.event_publisher.publish(&review_event).await
}
}
#[cfg(test)]

View File

@@ -68,18 +68,27 @@ struct FakeSocialWithFollowing(Vec<String>);
#[async_trait]
impl domain::ports::SocialQueryPort for FakeSocialWithFollowing {
async fn get_accepted_following_urls(&self, _: uuid::Uuid) -> Result<Vec<String>, DomainError> {
async fn get_accepted_following_urls(
&self,
_: &domain::value_objects::UserId,
) -> Result<Vec<String>, DomainError> {
Ok(self.0.clone())
}
async fn count_following(&self, _: uuid::Uuid) -> Result<usize, DomainError> {
async fn count_following(
&self,
_: &domain::value_objects::UserId,
) -> Result<usize, DomainError> {
Ok(0)
}
async fn count_accepted_followers(&self, _: uuid::Uuid) -> Result<usize, DomainError> {
async fn count_accepted_followers(
&self,
_: &domain::value_objects::UserId,
) -> Result<usize, DomainError> {
Ok(0)
}
async fn get_pending_followers(
&self,
_: uuid::Uuid,
_: &domain::value_objects::UserId,
) -> Result<Vec<domain::models::PendingFollowerInfo>, DomainError> {
Ok(vec![])
}

View File

@@ -4,7 +4,7 @@ use domain::{
errors::DomainError,
events::DomainEvent,
models::{Goal, GoalType, GoalWithProgress},
ports::{EventPublisher, GoalRepository},
ports::{EventPublisher, GoalRepository, StatsRepository},
value_objects::UserId,
};
@@ -12,6 +12,7 @@ use super::commands::CreateGoalCommand;
pub async fn execute(
goal: Arc<dyn GoalRepository>,
stats: Arc<dyn StatsRepository>,
event_publisher: Arc<dyn EventPublisher>,
cmd: CreateGoalCommand,
) -> Result<GoalWithProgress, DomainError> {
@@ -32,7 +33,7 @@ pub async fn execute(
)?;
goal.save(&g).await?;
let current_count = goal.count_reviews_in_year(&user_id, cmd.year).await?;
let current_count = stats.count_reviews_in_year(&user_id, cmd.year).await?;
event_publisher
.publish(&DomainEvent::GoalCreated {

View File

@@ -1,13 +1,17 @@
use std::sync::Arc;
use domain::{
errors::DomainError, models::GoalWithProgress, ports::GoalRepository, value_objects::UserId,
errors::DomainError,
models::GoalWithProgress,
ports::{GoalRepository, StatsRepository},
value_objects::UserId,
};
use super::queries::GetGoalQuery;
pub async fn execute(
goal: Arc<dyn GoalRepository>,
stats: Arc<dyn StatsRepository>,
query: GetGoalQuery,
) -> Result<Option<GoalWithProgress>, DomainError> {
let user_id = UserId::from_uuid(query.user_id);
@@ -16,7 +20,7 @@ pub async fn execute(
let Some(g) = found else { return Ok(None) };
let current_count = goal.count_reviews_in_year(&user_id, query.year).await?;
let current_count = stats.count_reviews_in_year(&user_id, query.year).await?;
Ok(Some(GoalWithProgress {
goal: g,

View File

@@ -1,13 +1,17 @@
use std::sync::Arc;
use domain::{
errors::DomainError, models::GoalWithProgress, ports::GoalRepository, value_objects::UserId,
errors::DomainError,
models::GoalWithProgress,
ports::{GoalRepository, StatsRepository},
value_objects::UserId,
};
use super::queries::ListGoalsQuery;
pub async fn execute(
goal: Arc<dyn GoalRepository>,
stats: Arc<dyn StatsRepository>,
query: ListGoalsQuery,
) -> Result<Vec<GoalWithProgress>, DomainError> {
let user_id = UserId::from_uuid(query.user_id);
@@ -15,7 +19,7 @@ pub async fn execute(
let mut result = Vec::with_capacity(goals.len());
for g in goals {
let current_count = goal.count_reviews_in_year(&user_id, g.year()).await?;
let current_count = stats.count_reviews_in_year(&user_id, g.year()).await?;
result.push(GoalWithProgress {
goal: g,
current_count,

View File

@@ -1,7 +1,7 @@
use std::sync::Arc;
use domain::events::DomainEvent;
use domain::testing::{InMemoryGoalRepository, NoopEventPublisher};
use domain::testing::{FakeStatsRepository, InMemoryGoalRepository, NoopEventPublisher};
use uuid::Uuid;
use crate::goals::{commands::CreateGoalCommand, create};
@@ -10,10 +10,12 @@ use crate::test_helpers::TestContextBuilder;
#[tokio::test]
async fn creates_goal_and_returns_progress() {
let goals = InMemoryGoalRepository::new();
let stats = FakeStatsRepository::new();
let events = NoopEventPublisher::new();
let result = create::execute(
Arc::clone(&goals) as _,
Arc::clone(&stats) as _,
Arc::clone(&events) as _,
CreateGoalCommand {
user_id: Uuid::nil(),
@@ -33,11 +35,13 @@ async fn creates_goal_and_returns_progress() {
#[tokio::test]
async fn creates_goal_with_review_count() {
let goals = InMemoryGoalRepository::new();
goals.set_review_count(Uuid::nil(), 2025, 5);
let stats = FakeStatsRepository::new();
stats.set_review_count(Uuid::nil(), 2025, 5);
let events = NoopEventPublisher::new();
let result = create::execute(
Arc::clone(&goals) as _,
Arc::clone(&stats) as _,
Arc::clone(&events) as _,
CreateGoalCommand {
user_id: Uuid::nil(),
@@ -59,6 +63,7 @@ async fn emits_goal_created_event() {
create::execute(
b.goal_repo.clone(),
b.stats_repo.clone(),
Arc::clone(&events) as _,
CreateGoalCommand {
user_id: Uuid::nil(),
@@ -86,12 +91,18 @@ async fn rejects_duplicate_year() {
target_count: 10,
};
create::execute(b.goal_repo.clone(), b.event_publisher.clone(), cmd)
create::execute(
b.goal_repo.clone(),
b.stats_repo.clone(),
b.event_publisher.clone(),
cmd,
)
.await
.unwrap();
let result = create::execute(
b.goal_repo.clone(),
b.stats_repo.clone(),
b.event_publisher.clone(),
CreateGoalCommand {
user_id: Uuid::nil(),
@@ -109,6 +120,7 @@ async fn rejects_year_before_2020() {
let b = TestContextBuilder::new();
let result = create::execute(
b.goal_repo.clone(),
b.stats_repo.clone(),
b.event_publisher.clone(),
CreateGoalCommand {
user_id: Uuid::nil(),
@@ -126,6 +138,7 @@ async fn rejects_zero_target() {
let b = TestContextBuilder::new();
let result = create::execute(
b.goal_repo.clone(),
b.stats_repo.clone(),
b.event_publisher.clone(),
CreateGoalCommand {
user_id: Uuid::nil(),

View File

@@ -1,6 +1,6 @@
use std::sync::Arc;
use domain::testing::{InMemoryGoalRepository, NoopEventPublisher};
use domain::testing::{FakeStatsRepository, InMemoryGoalRepository, NoopEventPublisher};
use uuid::Uuid;
use crate::goals::{
@@ -12,10 +12,12 @@ use crate::test_helpers::TestContextBuilder;
#[tokio::test]
async fn deletes_existing_goal() {
let goals = InMemoryGoalRepository::new();
let stats = FakeStatsRepository::new();
let events = NoopEventPublisher::new();
create::execute(
Arc::clone(&goals) as _,
Arc::clone(&stats) as _,
Arc::clone(&events) as _,
CreateGoalCommand {
user_id: Uuid::nil(),

View File

@@ -8,6 +8,7 @@ async fn returns_goal_when_exists() {
let b = TestContextBuilder::new();
create::execute(
b.goal_repo.clone(),
b.stats_repo.clone(),
b.event_publisher.clone(),
CreateGoalCommand {
user_id: Uuid::nil(),
@@ -20,6 +21,7 @@ async fn returns_goal_when_exists() {
let result = get::execute(
b.goal_repo.clone(),
b.stats_repo.clone(),
GetGoalQuery {
user_id: Uuid::nil(),
year: 2025,
@@ -37,6 +39,7 @@ async fn returns_none_when_missing() {
let b = TestContextBuilder::new();
let result = get::execute(
b.goal_repo.clone(),
b.stats_repo.clone(),
GetGoalQuery {
user_id: Uuid::nil(),
year: 2025,

View File

@@ -8,6 +8,7 @@ async fn returns_empty_when_no_goals() {
let b = TestContextBuilder::new();
let result = list::execute(
b.goal_repo.clone(),
b.stats_repo.clone(),
ListGoalsQuery {
user_id: Uuid::nil(),
},
@@ -24,6 +25,7 @@ async fn returns_all_goals_for_user() {
for year in [2023, 2024, 2025] {
create::execute(
b.goal_repo.clone(),
b.stats_repo.clone(),
b.event_publisher.clone(),
CreateGoalCommand {
user_id: Uuid::nil(),
@@ -37,6 +39,7 @@ async fn returns_all_goals_for_user() {
let result = list::execute(
b.goal_repo.clone(),
b.stats_repo.clone(),
ListGoalsQuery {
user_id: Uuid::nil(),
},

View File

@@ -11,6 +11,7 @@ async fn updates_target_count() {
let b = TestContextBuilder::new();
create::execute(
b.goal_repo.clone(),
b.stats_repo.clone(),
b.event_publisher.clone(),
CreateGoalCommand {
user_id: Uuid::nil(),
@@ -23,6 +24,7 @@ async fn updates_target_count() {
let result = update::execute(
b.goal_repo.clone(),
b.stats_repo.clone(),
b.event_publisher.clone(),
UpdateGoalCommand {
user_id: Uuid::nil(),
@@ -41,6 +43,7 @@ async fn fails_when_goal_not_found() {
let b = TestContextBuilder::new();
let result = update::execute(
b.goal_repo.clone(),
b.stats_repo.clone(),
b.event_publisher.clone(),
UpdateGoalCommand {
user_id: Uuid::nil(),
@@ -58,6 +61,7 @@ async fn rejects_zero_target() {
let b = TestContextBuilder::new();
create::execute(
b.goal_repo.clone(),
b.stats_repo.clone(),
b.event_publisher.clone(),
CreateGoalCommand {
user_id: Uuid::nil(),
@@ -70,6 +74,7 @@ async fn rejects_zero_target() {
let result = update::execute(
b.goal_repo.clone(),
b.stats_repo.clone(),
b.event_publisher.clone(),
UpdateGoalCommand {
user_id: Uuid::nil(),

View File

@@ -4,7 +4,7 @@ use domain::{
errors::DomainError,
events::DomainEvent,
models::GoalWithProgress,
ports::{EventPublisher, GoalRepository},
ports::{EventPublisher, GoalRepository, StatsRepository},
value_objects::UserId,
};
@@ -12,6 +12,7 @@ use super::commands::UpdateGoalCommand;
pub async fn execute(
goal: Arc<dyn GoalRepository>,
stats: Arc<dyn StatsRepository>,
event_publisher: Arc<dyn EventPublisher>,
cmd: UpdateGoalCommand,
) -> Result<GoalWithProgress, DomainError> {
@@ -25,7 +26,7 @@ pub async fn execute(
g.update_target(cmd.target_count)?;
goal.update(&g).await?;
let current_count = goal.count_reviews_in_year(&user_id, cmd.year).await?;
let current_count = stats.count_reviews_in_year(&user_id, cmd.year).await?;
event_publisher
.publish(&DomainEvent::GoalUpdated {

View File

@@ -1,7 +1,6 @@
pub mod config;
pub mod jobs;
pub mod ports;
pub mod rendering;
pub mod worker;
pub mod auth;

View File

@@ -6,7 +6,7 @@ use domain::{
events::DomainEvent,
models::MovieProfile,
ports::{
EventHandler, MovieEnrichmentClient, MovieProfileRepository, MovieRepository,
EventHandler, ImageFetcher, MovieEnrichmentClient, MovieProfileRepository, MovieRepository,
ObjectStorage, PersonCommand, SearchCommand,
},
};
@@ -22,7 +22,7 @@ pub struct MovieEnrichmentHandler {
person_command: Arc<dyn PersonCommand>,
search_command: Arc<dyn SearchCommand>,
object_storage: Arc<dyn ObjectStorage>,
http: reqwest::Client,
image_fetcher: Arc<dyn ImageFetcher>,
}
impl MovieEnrichmentHandler {
@@ -33,6 +33,7 @@ impl MovieEnrichmentHandler {
person_command: Arc<dyn PersonCommand>,
search_command: Arc<dyn SearchCommand>,
object_storage: Arc<dyn ObjectStorage>,
image_fetcher: Arc<dyn ImageFetcher>,
) -> Self {
Self {
enrichment_client,
@@ -41,7 +42,7 @@ impl MovieEnrichmentHandler {
person_command,
search_command,
object_storage,
http: reqwest::Client::new(),
image_fetcher,
}
}
@@ -55,15 +56,13 @@ impl MovieEnrichmentHandler {
continue;
}
let url = format!("https://image.tmdb.org/t/p/w185{path}");
match self.http.get(&url).send().await {
Ok(resp) if resp.status().is_success() => {
if let Ok(bytes) = resp.bytes().await
&& let Err(e) = self.object_storage.store(&key, &bytes).await
{
match self.image_fetcher.fetch_image(&url).await {
Ok(bytes) => {
if let Err(e) = self.object_storage.store(&key, &bytes).await {
tracing::debug!("cast photo store failed for {path}: {e}");
}
}
_ => tracing::debug!("cast photo download failed for {path}"),
Err(_) => tracing::debug!("cast photo download failed for {path}"),
}
}
}

View File

@@ -9,6 +9,7 @@ pub mod merge_duplicates;
pub mod queries;
pub mod reindex_search;
pub mod request_enrichment;
pub mod resolve;
pub mod search_cleanup;
pub mod sync_poster;

View File

@@ -0,0 +1,51 @@
use domain::{
errors::DomainError,
events::DomainEvent,
models::Movie,
ports::{EventPublisher, MetadataClient, MovieRepository},
value_objects::MovieId,
};
use crate::diary::commands::MovieInput;
use crate::diary::movie_resolver::{MovieResolver, MovieResolverDeps};
/// Resolves a movie from input, persists it, and publishes `MovieDiscovered` if new.
///
/// Returns `(movie, is_new_movie)`.
pub async fn resolve_and_persist_movie(
input: &MovieInput,
movie_repo: &dyn MovieRepository,
metadata_client: &dyn MetadataClient,
event_publisher: &dyn EventPublisher,
) -> Result<(Movie, bool), DomainError> {
let (movie, is_new) = if let Some(id) = input.movie_id {
let movie_id = MovieId::from_uuid(id);
let movie = movie_repo
.get_movie_by_id(&movie_id)
.await?
.ok_or_else(|| DomainError::NotFound(format!("Movie {id}")))?;
(movie, false)
} else {
let deps = MovieResolverDeps {
repository: movie_repo,
metadata_client,
};
MovieResolver::default_pipeline()
.resolve(input, &deps)
.await?
};
if is_new {
movie_repo.upsert_movie(&movie).await?;
if let Some(ext_id) = movie.external_metadata_id() {
let _ = event_publisher
.publish(&DomainEvent::MovieDiscovered {
movie_id: movie.id().clone(),
external_metadata_id: ext_id.clone(),
})
.await;
}
}
Ok((movie, is_new))
}

View File

@@ -1,13 +1,10 @@
use chrono::Utc;
use domain::{
errors::DomainError,
events::DomainEvent,
models::{Person, PersonId},
};
use super::deps::GetPersonDeps;
const ENRICHMENT_TTL_DAYS: i64 = 90;
use super::{deps::GetPersonDeps, should_enrich};
pub async fn execute(deps: &GetPersonDeps, id: PersonId) -> Result<Option<Person>, DomainError> {
let person = deps.person_query.get_by_id(&id).await?;
@@ -25,13 +22,6 @@ pub async fn execute(deps: &GetPersonDeps, id: PersonId) -> Result<Option<Person
Ok(person)
}
fn should_enrich(p: &Person) -> bool {
match p.enriched_at() {
None => true,
Some(at) => (Utc::now() - at).num_days() >= ENRICHMENT_TTL_DAYS,
}
}
#[cfg(test)]
#[path = "tests/get.rs"]
mod tests;

View File

@@ -1,13 +1,10 @@
use chrono::Utc;
use domain::{
errors::DomainError,
events::DomainEvent,
models::{Person, PersonCredits, PersonId},
models::{PersonCredits, PersonId},
};
use super::deps::GetPersonDeps;
const ENRICHMENT_TTL_DAYS: i64 = 90;
use super::{deps::GetPersonDeps, should_enrich};
pub async fn execute(deps: &GetPersonDeps, id: PersonId) -> Result<PersonCredits, DomainError> {
let credits = deps.person_query.get_credits(&id).await?;
@@ -23,13 +20,6 @@ pub async fn execute(deps: &GetPersonDeps, id: PersonId) -> Result<PersonCredits
Ok(credits)
}
fn should_enrich(p: &Person) -> bool {
match p.enriched_at() {
None => true,
Some(at) => (Utc::now() - at).num_days() >= ENRICHMENT_TTL_DAYS,
}
}
#[cfg(test)]
#[path = "tests/get_credits.rs"]
mod tests;

View File

@@ -5,3 +5,15 @@ pub mod get;
pub mod get_credits;
pub use event_handler::PersonEnrichmentHandler;
use chrono::Utc;
use domain::models::Person;
pub(crate) const ENRICHMENT_TTL_DAYS: i64 = 90;
pub(crate) fn should_enrich(p: &Person) -> bool {
match p.enriched_at() {
None => true,
Some(at) => (Utc::now() - at).num_days() >= ENRICHMENT_TTL_DAYS,
}
}

View File

@@ -1,7 +1,6 @@
use async_trait::async_trait;
use domain::errors::DomainError;
use domain::models::DiaryEntry;
use crate::diary::commands::LogReviewCommand;
@@ -9,7 +8,3 @@ use crate::diary::commands::LogReviewCommand;
pub trait ReviewLogger: Send + Sync {
async fn log_review(&self, cmd: LogReviewCommand) -> Result<(), DomainError>;
}
pub trait RssFeedRenderer: Send + Sync {
fn render_feed(&self, entries: &[DiaryEntry], title: &str) -> Result<String, String>;
}

View File

@@ -88,7 +88,7 @@ impl TestContextBuilder {
diary_repo: FakeDiaryRepository::new(),
diary_exporter: Arc::new(PanicDiaryExporter),
document_parser: Arc::new(FakeDocumentParser),
stats_repo: Arc::new(FakeStatsRepository),
stats_repo: FakeStatsRepository::new(),
metadata_client: Arc::new(FakeMetadataClient),
poster_fetcher: Arc::new(FakePosterFetcher),
object_storage: Arc::new(NoopObjectStorage),

View File

@@ -17,7 +17,7 @@ pub struct CurrentProfileData {
pub banner_path: Option<String>,
pub also_known_as: Option<String>,
pub fields: Vec<ProfileFieldData>,
pub role: String,
pub role: domain::models::UserRole,
}
pub async fn execute(
@@ -47,7 +47,7 @@ pub async fn execute(
banner_path: found.banner_path().map(|s| s.to_string()),
also_known_as: found.also_known_as().map(|s| s.to_string()),
fields,
role: found.role().as_str().into(),
role: found.role().clone(),
})
}

View File

@@ -37,7 +37,7 @@ pub async fn execute(
let stats = deps.stats.get_user_stats(&user_id).await?;
let (following_count, followers_count, pending_followers) =
load_social_counts(deps, query.user_id, query.is_own_profile).await;
load_social_counts(deps, &user_id, query.is_own_profile).await;
let base = |entries, history, trends| UserProfileData {
stats,
@@ -76,7 +76,7 @@ pub async fn execute(
async fn load_social_counts(
deps: &GetProfileDeps,
user_id: uuid::Uuid,
user_id: &UserId,
is_own_profile: bool,
) -> (usize, usize, Vec<PendingFollowerView>) {
let following = deps

View File

@@ -9,3 +9,52 @@ pub mod queries;
pub mod update_profile;
pub mod update_profile_fields;
pub mod update_settings;
use chrono::Datelike;
use domain::models::{DiaryEntry, MonthActivity};
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,12 +1,9 @@
use domain::{
errors::DomainError,
events::DomainEvent,
models::WatchlistEntry,
value_objects::{MovieId, UserId},
errors::DomainError, events::DomainEvent, models::WatchlistEntry, value_objects::UserId,
};
use crate::{
diary::movie_resolver::{MovieResolver, MovieResolverDeps},
movies::resolve::resolve_and_persist_movie,
watchlist::{commands::AddToWatchlistCommand, deps::WatchlistAddDeps},
};
@@ -16,34 +13,13 @@ pub async fn execute(
) -> Result<(), DomainError> {
let user_id = UserId::from_uuid(cmd.user_id);
let movie = if let Some(id) = cmd.input.movie_id {
let movie_id = MovieId::from_uuid(id);
deps.movie
.get_movie_by_id(&movie_id)
.await?
.ok_or_else(|| DomainError::NotFound(format!("Movie {id}")))?
} else {
let resolver_deps = MovieResolverDeps {
repository: deps.movie.as_ref(),
metadata_client: deps.metadata.as_ref(),
};
let (movie, is_new) = MovieResolver::default_pipeline()
.resolve(&cmd.input, &resolver_deps)
let (movie, _is_new) = resolve_and_persist_movie(
&cmd.input,
deps.movie.as_ref(),
deps.metadata.as_ref(),
deps.event_publisher.as_ref(),
)
.await?;
if is_new {
deps.movie.upsert_movie(&movie).await?;
if let Some(ext_id) = movie.external_metadata_id() {
let _ = deps
.event_publisher
.publish(&DomainEvent::MovieDiscovered {
movie_id: movie.id().clone(),
external_metadata_id: ext_id.clone(),
})
.await;
}
}
movie
};
let entry = WatchlistEntry::new(user_id.clone(), movie.id().clone());
deps.watchlist.add(&entry).await?;

View File

@@ -19,6 +19,16 @@ pub struct FederationFlags {
pub watchlist: bool,
}
impl Default for FederationFlags {
fn default() -> Self {
Self {
goals: true,
reviews: true,
watchlist: true,
}
}
}
#[derive(Debug, Clone)]
pub struct FederatedProfile {
pub actor_url: String,

View File

@@ -57,7 +57,8 @@ pub use search::{
use crate::errors::DomainError;
#[derive(Clone, Debug, PartialEq, Eq)]
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GoalType {
Movies,
}

View File

@@ -1,6 +1,7 @@
use crate::value_objects::{Email, PasswordHash, UserId, Username};
#[derive(Clone, Debug, Default)]
#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum UserRole {
#[default]
Standard,

View File

@@ -2,7 +2,6 @@ use async_trait::async_trait;
use crate::{
errors::DomainError,
events::DomainEvent,
models::{
DiaryEntry, DiaryFilter, ExportFormat, FeedEntry, FeedSortBy, FollowingFilter, MovieStats,
Review, ReviewHistory, UserStats, UserTrends,
@@ -43,7 +42,7 @@ pub trait DiaryRepository: Send + Sync {
#[async_trait]
pub trait ReviewRepository: Send + Sync {
async fn save_review(&self, review: &Review) -> Result<DomainEvent, DomainError>;
async fn save_review(&self, review: &Review) -> Result<(), DomainError>;
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError>;
async fn update_review(&self, review: &Review) -> Result<(), DomainError>;
async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError>;
@@ -54,6 +53,7 @@ pub trait ReviewRepository: Send + Sync {
pub trait StatsRepository: Send + Sync {
async fn get_user_stats(&self, user_id: &UserId) -> Result<UserStats, DomainError>;
async fn get_user_trends(&self, user_id: &UserId) -> Result<UserTrends, DomainError>;
async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result<u32, DomainError>;
}
pub trait DiaryExporter: Send + Sync {

View File

@@ -17,5 +17,4 @@ pub trait GoalRepository: Send + Sync {
year: u16,
) -> Result<Option<Goal>, DomainError>;
async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<Goal>, DomainError>;
async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result<u32, DomainError>;
}

View File

@@ -0,0 +1,8 @@
use async_trait::async_trait;
use crate::errors::DomainError;
#[async_trait]
pub trait ImageFetcher: Send + Sync {
async fn fetch_image(&self, url: &str) -> Result<Vec<u8>, DomainError>;
}

View File

@@ -3,12 +3,14 @@ pub mod diary;
pub mod events;
pub mod federated_profile;
pub mod goals;
pub mod image_fetcher;
pub mod images;
pub mod import;
pub mod jobs;
pub mod media_server;
pub mod movie;
pub mod person;
pub mod rss;
pub mod search;
pub mod social;
pub mod watchlist;
@@ -19,12 +21,14 @@ pub use diary::*;
pub use events::*;
pub use federated_profile::*;
pub use goals::*;
pub use image_fetcher::*;
pub use images::*;
pub use import::*;
pub use jobs::*;
pub use media_server::*;
pub use movie::*;
pub use person::*;
pub use rss::*;
pub use search::*;
pub use social::*;
pub use watchlist::*;

View File

@@ -0,0 +1,5 @@
use crate::models::DiaryEntry;
pub trait RssFeedRenderer: Send + Sync {
fn render_feed(&self, entries: &[DiaryEntry], title: &str) -> Result<String, String>;
}

View File

@@ -4,24 +4,24 @@ use chrono::NaiveDateTime;
use crate::{
errors::DomainError,
models::{
DiaryEntry, FederationFlags, Goal, Movie, PendingFollowerInfo, RemoteActorInfo,
RemoteGoalEntry, RemoteWatchlistEntry, Review, WatchlistWithMovie,
DiaryEntry, FederationFlags, PendingFollowerInfo, RemoteActorInfo, RemoteGoalEntry,
RemoteWatchlistEntry, WatchlistWithMovie,
},
value_objects::{MovieId, ReviewId, UserId},
value_objects::{MovieId, UserId},
};
#[async_trait]
pub trait SocialQueryPort: Send + Sync {
async fn get_accepted_following_urls(
&self,
user_id: uuid::Uuid,
user_id: &UserId,
) -> Result<Vec<String>, DomainError>;
async fn list_all_followed_remote_actors(&self) -> Result<Vec<RemoteActorInfo>, DomainError>;
async fn count_following(&self, user_id: uuid::Uuid) -> Result<usize, DomainError>;
async fn count_accepted_followers(&self, user_id: uuid::Uuid) -> Result<usize, DomainError>;
async fn count_following(&self, user_id: &UserId) -> Result<usize, DomainError>;
async fn count_accepted_followers(&self, user_id: &UserId) -> Result<usize, DomainError>;
async fn get_pending_followers(
&self,
user_id: uuid::Uuid,
user_id: &UserId,
) -> Result<Vec<PendingFollowerInfo>, DomainError>;
}
@@ -60,25 +60,16 @@ pub trait RemoteGoalRepository: Send + Sync {
async fn get_by_actor_url(&self, actor_url: &str) -> Result<Vec<RemoteGoalEntry>, DomainError>;
}
/// Read-only query port used exclusively by the ActivityPub adapter.
/// Consolidates all reads the AP adapter needs so it never touches write repositories.
/// Federation-specific read-only queries that have no equivalent on the
/// standard domain ports (e.g. unpaginated watchlist, local-only review
/// listings). Generic lookups (get_movie_by_id, get_review_by_id, etc.)
/// live on MovieRepository, ReviewRepository, and the other domain ports.
#[async_trait]
pub trait LocalApContentQuery: Send + Sync {
async fn get_local_reviews_for_user(
&self,
user_id: &UserId,
) -> Result<Vec<DiaryEntry>, DomainError>;
async fn get_local_watchlist_for_user(
&self,
user_id: &UserId,
) -> Result<Vec<WatchlistWithMovie>, DomainError>;
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError>;
async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result<Option<Movie>, DomainError>;
async fn get_movie_by_external_metadata_id(
&self,
external_id: &str,
) -> Result<Option<Movie>, DomainError>;
async fn count_local_posts(&self) -> Result<u64, DomainError>;
async fn get_local_reviews_for_movie(
&self,
movie_id: &MovieId,
@@ -89,10 +80,4 @@ pub trait LocalApContentQuery: Send + Sync {
before: Option<NaiveDateTime>,
limit: usize,
) -> Result<Vec<DiaryEntry>, DomainError>;
async fn get_goal_with_progress(
&self,
user_id: &UserId,
year: u16,
) -> Result<Option<(Goal, u32)>, DomainError>;
async fn list_goals_for_user(&self, user_id: &UserId) -> Result<Vec<Goal>, DomainError>;
}

View File

@@ -191,7 +191,24 @@ impl DiaryRepository for FakeDiaryRepository {
// ── FakeStatsRepository ─────────────────────────────────────────────────────
pub struct FakeStatsRepository;
pub struct FakeStatsRepository {
review_counts: Mutex<HashMap<(Uuid, u16), u32>>,
}
impl FakeStatsRepository {
pub fn new() -> Arc<Self> {
Arc::new(Self {
review_counts: Mutex::new(HashMap::new()),
})
}
pub fn set_review_count(&self, user_id: Uuid, year: u16, count: u32) {
self.review_counts
.lock()
.unwrap()
.insert((user_id, year), count);
}
}
#[async_trait]
impl StatsRepository for FakeStatsRepository {
@@ -211,6 +228,11 @@ impl StatsRepository for FakeStatsRepository {
max_director_count: 0,
})
}
async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result<u32, DomainError> {
let counts = self.review_counts.lock().unwrap();
Ok(counts.get(&(user_id.value(), year)).copied().unwrap_or(0))
}
}
// ── FakePersonQuery ─────────────────────────────────────────────────────────

View File

@@ -10,7 +10,6 @@ use chrono::Utc;
use crate::{
errors::DomainError,
events::DomainEvent,
models::{
FederationFlags, Goal, ImportProfile, ImportSession, Movie, MovieFilter, MovieProfile,
MovieSummary, ProfileField, RefreshSession, Review, User, UserSettings, UserSummary,
@@ -171,18 +170,12 @@ impl InMemoryReviewRepository {
#[async_trait]
impl ReviewRepository for InMemoryReviewRepository {
async fn save_review(&self, review: &Review) -> Result<DomainEvent, DomainError> {
async fn save_review(&self, review: &Review) -> Result<(), DomainError> {
self.store
.lock()
.unwrap()
.insert(review.id().value(), review.clone());
Ok(DomainEvent::ReviewLogged {
review_id: review.id().clone(),
movie_id: review.movie_id().clone(),
user_id: review.user_id().clone(),
rating: review.rating().clone(),
watched_at: *review.watched_at(),
})
Ok(())
}
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError> {
@@ -345,27 +338,18 @@ impl WatchlistRepository for InMemoryWatchlistRepository {
pub struct InMemoryGoalRepository {
store: Mutex<HashMap<Uuid, Goal>>,
review_counts: Mutex<HashMap<(Uuid, u16), u32>>,
}
impl InMemoryGoalRepository {
pub fn new() -> Arc<Self> {
Arc::new(Self {
store: Mutex::new(HashMap::new()),
review_counts: Mutex::new(HashMap::new()),
})
}
pub fn count(&self) -> usize {
self.store.lock().unwrap().len()
}
pub fn set_review_count(&self, user_id: Uuid, year: u16, count: u32) {
self.review_counts
.lock()
.unwrap()
.insert((user_id, year), count);
}
}
#[async_trait]
@@ -416,11 +400,6 @@ impl GoalRepository for InMemoryGoalRepository {
.cloned()
.collect())
}
async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result<u32, DomainError> {
let counts = self.review_counts.lock().unwrap();
Ok(counts.get(&(user_id.value(), year)).copied().unwrap_or(0))
}
}
// ── InMemoryUserSettingsRepository ──────────────────────────────────────────

View File

@@ -97,7 +97,10 @@ pub struct NoopSocialQueryPort;
#[async_trait]
impl crate::ports::SocialQueryPort for NoopSocialQueryPort {
async fn get_accepted_following_urls(&self, _: uuid::Uuid) -> Result<Vec<String>, DomainError> {
async fn get_accepted_following_urls(
&self,
_: &crate::value_objects::UserId,
) -> Result<Vec<String>, DomainError> {
Ok(vec![])
}
async fn list_all_followed_remote_actors(
@@ -105,15 +108,21 @@ impl crate::ports::SocialQueryPort for NoopSocialQueryPort {
) -> Result<Vec<crate::models::RemoteActorInfo>, DomainError> {
Ok(vec![])
}
async fn count_following(&self, _: uuid::Uuid) -> Result<usize, DomainError> {
async fn count_following(
&self,
_: &crate::value_objects::UserId,
) -> Result<usize, DomainError> {
Ok(0)
}
async fn count_accepted_followers(&self, _: uuid::Uuid) -> Result<usize, DomainError> {
async fn count_accepted_followers(
&self,
_: &crate::value_objects::UserId,
) -> Result<usize, DomainError> {
Ok(0)
}
async fn get_pending_followers(
&self,
_: uuid::Uuid,
_: &crate::value_objects::UserId,
) -> Result<Vec<crate::models::PendingFollowerInfo>, DomainError> {
Ok(vec![])
}
@@ -148,9 +157,6 @@ impl crate::ports::GoalRepository for NoopGoalRepository {
async fn list_for_user(&self, _: &UserId) -> Result<Vec<crate::models::Goal>, DomainError> {
Ok(vec![])
}
async fn count_reviews_in_year(&self, _: &UserId, _: u16) -> Result<u32, DomainError> {
Ok(0)
}
}
// ── NoopUserSettingsRepository ────────────────────────────────────────────────

View File

@@ -80,6 +80,9 @@ impl StatsRepository for PanicStatsRepository {
async fn get_user_trends(&self, _: &UserId) -> Result<UserTrends, DomainError> {
panic!("PanicStatsRepository called")
}
async fn count_reviews_in_year(&self, _: &UserId, _: u16) -> Result<u32, DomainError> {
panic!("PanicStatsRepository called")
}
}
pub struct PanicImportSessionRepository;
@@ -327,21 +330,30 @@ pub struct PanicSocialQueryPort;
#[async_trait]
impl crate::ports::SocialQueryPort for PanicSocialQueryPort {
async fn get_accepted_following_urls(&self, _: uuid::Uuid) -> Result<Vec<String>, DomainError> {
async fn get_accepted_following_urls(
&self,
_: &crate::value_objects::UserId,
) -> Result<Vec<String>, DomainError> {
panic!("PanicSocialQueryPort called")
}
async fn list_all_followed_remote_actors(&self) -> Result<Vec<RemoteActorInfo>, DomainError> {
panic!("PanicSocialQueryPort called")
}
async fn count_following(&self, _: uuid::Uuid) -> Result<usize, DomainError> {
async fn count_following(
&self,
_: &crate::value_objects::UserId,
) -> Result<usize, DomainError> {
panic!("PanicSocialQueryPort called")
}
async fn count_accepted_followers(&self, _: uuid::Uuid) -> Result<usize, DomainError> {
async fn count_accepted_followers(
&self,
_: &crate::value_objects::UserId,
) -> Result<usize, DomainError> {
panic!("PanicSocialQueryPort called")
}
async fn get_pending_followers(
&self,
_: uuid::Uuid,
_: &crate::value_objects::UserId,
) -> Result<Vec<PendingFollowerInfo>, DomainError> {
panic!("PanicSocialQueryPort called")
}

View File

@@ -3,10 +3,12 @@ use std::str::FromStr;
use crate::errors::DomainError;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WatchMedium {
Cinema,
Streaming,
#[serde(rename = "tv")]
TV,
PhysicalMedia,
Download,

View File

@@ -0,0 +1,14 @@
[package]
name = "infra-wiring"
version = "0.1.0"
edition = "2021"
[dependencies]
sqlx = { workspace = true }
anyhow = { workspace = true }
[features]
default = []
sqlite = []
postgres = ["sqlx/postgres"]
nats = []

View File

@@ -0,0 +1,50 @@
#[derive(Clone)]
pub struct AppConfig {
pub allow_registration: bool,
pub base_url: String,
pub rate_limit: u64,
pub refresh_ttl_seconds: u64,
pub wrapup: WrapUpConfig,
}
#[derive(Clone)]
pub struct WrapUpConfig {
pub font_path: Option<String>,
pub logo_path: Option<String>,
pub bg_dir: Option<String>,
}
impl AppConfig {
pub fn from_env() -> Self {
let allow_registration = std::env::var("ALLOW_REGISTRATION")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
let base_url =
std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:3000".to_string());
let rate_limit = std::env::var("RATE_LIMIT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(60);
let refresh_ttl_seconds = std::env::var("REFRESH_TTL_SECONDS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(2_592_000u64);
Self {
allow_registration,
base_url,
rate_limit,
refresh_ttl_seconds,
wrapup: WrapUpConfig::from_env(),
}
}
}
impl WrapUpConfig {
pub fn from_env() -> Self {
Self {
font_path: std::env::var("WRAPUP_FONT_PATH").ok(),
logo_path: std::env::var("WRAPUP_LOGO_PATH").ok(),
bg_dir: std::env::var("WRAPUP_BG_DIR").ok(),
}
}
}

View File

@@ -0,0 +1,35 @@
pub mod config;
pub use config::{AppConfig, WrapUpConfig};
pub enum DbPool {
#[cfg(feature = "sqlite")]
Sqlite(sqlx::SqlitePool),
#[cfg(feature = "postgres")]
Postgres(sqlx::PgPool),
}
#[derive(Clone, Copy)]
pub enum EventBusBackend {
Db,
#[cfg(feature = "nats")]
Nats,
}
impl EventBusBackend {
pub 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'"),
}
}
}

View File

@@ -7,9 +7,9 @@ license = "MIT"
[features]
default = ["sqlite", "sqlite-federation"]
sqlite = ["dep:sqlite", "dep:sqlite-event-queue", "dep:sqlite-search"]
postgres = ["dep:postgres", "dep:postgres-event-queue", "dep:postgres-search"]
nats = ["dep:nats"]
sqlite = ["dep:sqlite", "dep:sqlite-event-queue", "dep:sqlite-search", "infra-wiring/sqlite"]
postgres = ["dep:postgres", "dep:postgres-event-queue", "dep:postgres-search", "infra-wiring/postgres"]
nats = ["dep:nats", "infra-wiring/nats"]
# Meta-feature: true when any federation adapter is active — keeps all #[cfg(feature = "federation")] gates working
federation = ["application/federation"]
sqlite-federation = [
@@ -59,6 +59,7 @@ importer = { workspace = true }
jellyfin = { workspace = true }
plex = { workspace = true }
sqlx = { workspace = true }
infra-wiring = { workspace = true }
utoipa = { version = "5.5.0", features = ["axum_extras", "uuid"] }
utoipa-scalar = { version = "0.3.0", features = ["axum"], default-features = false }
utoipa-swagger-ui = { version = "9.0.2", features = ["axum", "vendored"] }

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 {
.await?;
Ok(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(),
}
}))
}
#[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)) => {
.await?
.ok_or(DomainError::NotFound("session not found".into()))?;
let parsed = session.parsed_file.unwrap_or_default();
let row_count = parsed.rows.len();
axum::Json(SessionStateResponse {
Ok(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(),
}
}))
}
#[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,8 @@ 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(),
@@ -686,8 +630,9 @@ 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 {
})
}
RowResult::Valid(row) => PreviewRowDto::Valid(api_types::PreviewRowData {
index: i,
title: row.title.clone(),
release_year: row.release_year.clone(),
@@ -695,7 +640,7 @@ pub async fn api_get_preview(
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"})),
.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,
},
)
.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!({
.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<_>>(),
})).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()
}
}
})))
}
#[utoipa::path(
@@ -762,10 +701,10 @@ 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(
) -> 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| {
@@ -776,14 +715,7 @@ pub async fn api_get_profiles(
})
})
.collect::<Vec<_>>(),
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
axum::Json(serde_json::json!({"error": e.to_string()})),
)
.into_response(),
}
))
}
#[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
_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();
axum::Json(response).into_response()
}
Err(e) => ap_err(e).into_response(),
}
.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,10 +166,14 @@ 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
) -> 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,
@@ -190,11 +181,8 @@ pub async fn get_blocked_actors_api(
display_name: a.display_name,
avatar_url: a.avatar_url,
})
.collect();
axum::Json(response).into_response()
}
Err(e) => ap_err(e).into_response(),
}
.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 {
) -> 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(|a| RemoteActorDto {
handle: a.handle,
display_name: a.display_name,
url: a.url,
})
.map(crate::mappers::social::remote_actor_to_dto)
.collect(),
})
.into_response(),
Err(e) => ap_err(e).into_response(),
}
}))
}
#[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 {
.map_err(ap_to_domain)?;
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(),
})
.into_response(),
Err(e) => ap_err(e).into_response(),
}
}))
}
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 {
) -> 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(|a| RemoteActorDto {
handle: a.handle,
display_name: a.display_name,
url: a.url,
})
.map(crate::mappers::social::remote_actor_to_dto)
.collect(),
})
.into_response(),
Err(e) => ap_err(e).into_response(),
}
}))
}
// ── HTML ─────────────────────────────────────────────────────────────────────

View File

@@ -61,6 +61,7 @@ pub async fn get_profile(
.await?;
let base_url = &state.app_ctx.config.base_url;
Ok(Json(ProfileResponse {
profile: api_types::UserProfileBase {
username: profile.username,
display_name: profile.display_name,
bio: profile.bio,
@@ -70,6 +71,7 @@ pub async fn get_profile(
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,6 +320,7 @@ pub async fn get_user_profile(
Json(UserProfileResponse {
user_id,
profile: api_types::UserProfileBase {
username: user.username().value().to_string(),
avatar_url: user
.avatar_path()
@@ -371,6 +328,9 @@ pub async fn get_user_profile(
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,
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,
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?
}
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")
#[cfg(feature = "sqlite")]
factory::DbPool::Sqlite(pool) => {
sqlite_event_queue::SqliteEventQueue::create_publisher(pool.clone()).await?
}
other => anyhow::bail!("unknown EVENT_BUS_BACKEND={other}, expected 'db' or 'nats'"),
})
}
#[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])
}

Some files were not shown because too many files have changed in this diff Show More