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

View File

@@ -31,6 +31,7 @@ members = [
"crates/adapters/plex", "crates/adapters/plex",
"crates/adapters/sqlite-search", "crates/adapters/sqlite-search",
"crates/adapters/postgres-search", "crates/adapters/postgres-search",
"crates/infra-wiring",
] ]
resolver = "2" resolver = "2"
@@ -91,6 +92,7 @@ plex = { path = "crates/adapters/plex" }
image-converter = { path = "crates/adapters/image-converter" } image-converter = { path = "crates/adapters/image-converter" }
sqlite-search = { path = "crates/adapters/sqlite-search" } sqlite-search = { path = "crates/adapters/sqlite-search" }
postgres-search = { path = "crates/adapters/postgres-search" } postgres-search = { path = "crates/adapters/postgres-search" }
infra-wiring = { path = "crates/infra-wiring" }
[profile.dev] [profile.dev]
debug = 1 # line tables only — still debuggable, much faster linking 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/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/adapters/postgres-search/Cargo.toml crates/adapters/postgres-search/Cargo.toml
COPY crates/worker/Cargo.toml crates/worker/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 # Stub every crate so cargo can resolve and fetch deps
RUN find crates -name "Cargo.toml" | sed 's|/Cargo.toml||' | \ RUN find crates -name "Cargo.toml" | sed 's|/Cargo.toml||' | \

View File

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

View File

@@ -4,7 +4,7 @@ use async_trait::async_trait;
use chrono::DateTime; use chrono::DateTime;
use domain::{ use domain::{
models::RemoteGoalEntry, models::RemoteGoalEntry,
ports::{LocalApContentQuery, RemoteGoalRepository}, ports::{GoalRepository, RemoteGoalRepository},
value_objects::UserId, value_objects::UserId,
}; };
use k_ap::{ApContentReader, ApObjectHandler}; use k_ap::{ApContentReader, ApObjectHandler};
@@ -15,7 +15,7 @@ use crate::urls::{actor_url, goal_url};
pub struct GoalObjectHandler { pub struct GoalObjectHandler {
pub remote_goal_repo: Arc<dyn RemoteGoalRepository>, pub remote_goal_repo: Arc<dyn RemoteGoalRepository>,
pub content_query: Arc<dyn LocalApContentQuery>, pub goal_repo: Arc<dyn GoalRepository>,
pub base_url: String, pub base_url: String,
} }
@@ -29,8 +29,8 @@ impl ApContentReader for GoalObjectHandler {
) -> anyhow::Result<Vec<(Url, serde_json::Value, DateTime<chrono::Utc>)>> { ) -> anyhow::Result<Vec<(Url, serde_json::Value, DateTime<chrono::Utc>)>> {
let uid = UserId::from_uuid(user_id); let uid = UserId::from_uuid(user_id);
let goals = self let goals = self
.content_query .goal_repo
.list_goals_for_user(&uid) .list_for_user(&uid)
.await .await
.map_err(|e| anyhow::anyhow!(e.to_string()))?; .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_watchlist_repo: std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>,
pub remote_goal_repo: std::sync::Arc<dyn domain::ports::RemoteGoalRepository>, pub remote_goal_repo: std::sync::Arc<dyn domain::ports::RemoteGoalRepository>,
pub local_ap_content: std::sync::Arc<dyn domain::ports::LocalApContentQuery>, 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 user_repo: std::sync::Arc<dyn domain::ports::UserRepository>,
pub federation_settings: std::sync::Arc<dyn domain::ports::UserFederationSettingsQuery>, pub federation_settings: std::sync::Arc<dyn domain::ports::UserFederationSettingsQuery>,
pub base_url: String, pub base_url: String,
@@ -68,6 +73,11 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
remote_watchlist_repo, remote_watchlist_repo,
remote_goal_repo, remote_goal_repo,
local_ap_content, local_ap_content,
movie_repo,
review_repo,
diary_repo,
goal_repo,
stats_repo,
user_repo, user_repo,
federation_settings, federation_settings,
base_url, base_url,
@@ -76,6 +86,8 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
} = deps; } = deps;
let review_handler = std::sync::Arc::new(ReviewObjectHandler { let review_handler = std::sync::Arc::new(ReviewObjectHandler {
content_query: std::sync::Arc::clone(&local_ap_content), content_query: std::sync::Arc::clone(&local_ap_content),
movie_repo: std::sync::Arc::clone(&movie_repo),
diary_repo,
review_store, review_store,
event_publisher: std::sync::Arc::clone(&event_publisher), event_publisher: std::sync::Arc::clone(&event_publisher),
base_url: base_url.clone(), 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 { let goal_handler = std::sync::Arc::new(goal_handler::GoalObjectHandler {
remote_goal_repo, 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(), base_url: base_url.clone(),
}); });
let composite = std::sync::Arc::new(composite_handler::CompositeObjectHandler { 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( let event_handler = std::sync::Arc::new(ActivityPubEventHandler::new(
std::sync::Arc::clone(&concrete), std::sync::Arc::clone(&concrete),
local_ap_content, local_ap_content,
review_repo,
movie_repo,
goal_repo,
stats_repo,
federation_settings, federation_settings,
base_url, base_url,
)) as std::sync::Arc<dyn domain::ports::EventHandler>; )) as std::sync::Arc<dyn domain::ports::EventHandler>;

View File

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

View File

@@ -4,7 +4,11 @@ pub use config::PosterFetcherConfig;
use std::time::Duration; use std::time::Duration;
use async_trait::async_trait; 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 { pub struct ReqwestPosterFetcher {
client: reqwest::Client, 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>> { pub fn create() -> anyhow::Result<std::sync::Arc<dyn domain::ports::PosterFetcherClient>> {
Ok(std::sync::Arc::new(ReqwestPosterFetcher::new( Ok(std::sync::Arc::new(ReqwestPosterFetcher::new(
PosterFetcherConfig::from_env(), 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::{ use domain::{
errors::DomainError, errors::DomainError,
models::{ models::{
DiaryEntry, Goal, GoalType, Movie, PersistedReview, Review, ReviewSource, WatchlistEntry, DiaryEntry, Movie, PersistedReview, Review, ReviewSource, WatchlistEntry,
WatchlistWithMovie, WatchlistWithMovie,
}, },
ports::LocalApContentQuery, ports::LocalApContentQuery,
value_objects::{ value_objects::{
Comment, ExternalMetadataId, GoalId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear, Comment, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
ReviewId, UserId, WatchlistEntryId, ReviewId, UserId, WatchlistEntryId,
}, },
}; };
@@ -82,6 +82,7 @@ struct ReviewRow {
watched_at: String, watched_at: String,
created_at: String, created_at: String,
remote_actor_url: Option<String>, remote_actor_url: Option<String>,
watch_medium: Option<String>,
} }
impl ReviewRow { impl ReviewRow {
@@ -97,6 +98,7 @@ impl ReviewRow {
None => ReviewSource::Local, None => ReviewSource::Local,
Some(url) => ReviewSource::Remote { actor_url: url }, Some(url) => ReviewSource::Remote { actor_url: url },
}; };
let watch_medium = self.watch_medium.map(|s| s.parse()).transpose()?;
Ok(Review::from_persistence(PersistedReview { Ok(Review::from_persistence(PersistedReview {
id, id,
movie_id, movie_id,
@@ -106,7 +108,7 @@ impl ReviewRow {
watched_at, watched_at,
created_at, created_at,
source, source,
watch_medium: None, watch_medium,
})) }))
} }
} }
@@ -127,6 +129,7 @@ struct DiaryRow {
watched_at: String, watched_at: String,
created_at: String, created_at: String,
remote_actor_url: Option<String>, remote_actor_url: Option<String>,
watch_medium: Option<String>,
} }
impl DiaryRow { impl DiaryRow {
@@ -149,105 +152,17 @@ impl DiaryRow {
watched_at: self.watched_at, watched_at: self.watched_at,
created_at: self.created_at, created_at: self.created_at,
remote_actor_url: self.remote_actor_url, remote_actor_url: self.remote_actor_url,
watch_medium: self.watch_medium,
} }
.into_domain()?; .into_domain()?;
Ok(DiaryEntry::new(movie, review)) 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 ───────────────────────────────────────────────── // ── LocalApContentQuery impl ─────────────────────────────────────────────────
#[async_trait] #[async_trait]
impl LocalApContentQuery for PostgresApContentQuery { 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( async fn get_local_watchlist_for_user(
&self, &self,
user_id: &UserId, 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, 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.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, 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 FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id INNER JOIN movies m ON m.id = r.movie_id
WHERE r.movie_id = $1 AND r.remote_actor_url IS NULL 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() 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( async fn get_local_reviews_page(
&self, &self,
user_id: &UserId, 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, 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.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, 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 FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id 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 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, 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.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, 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 FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = $1 AND r.remote_actor_url IS NULL 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() 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, errors::DomainError,
models::{PendingFollowerInfo, RemoteActorInfo}, models::{PendingFollowerInfo, RemoteActorInfo},
ports::SocialQueryPort, ports::SocialQueryPort,
value_objects::UserId,
}; };
use super::PostgresFederationRepository; use super::PostgresFederationRepository;
@@ -11,9 +12,9 @@ use super::PostgresFederationRepository;
impl SocialQueryPort for PostgresFederationRepository { impl SocialQueryPort for PostgresFederationRepository {
async fn get_accepted_following_urls( async fn get_accepted_following_urls(
&self, &self,
user_id: uuid::Uuid, user_id: &UserId,
) -> Result<Vec<String>, DomainError> { ) -> 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>( sqlx::query_scalar::<_, String>(
"SELECT remote_actor_url FROM ap_following WHERE local_user_id = $1 AND status = 'accepted'", "SELECT remote_actor_url FROM ap_following WHERE local_user_id = $1 AND status = 'accepted'",
).bind(&user_id_str).fetch_all(&self.pool).await ).bind(&user_id_str).fetch_all(&self.pool).await
@@ -34,8 +35,8 @@ impl SocialQueryPort for PostgresFederationRepository {
.collect()) .collect())
} }
async fn count_following(&self, user_id: uuid::Uuid) -> Result<usize, DomainError> { async fn count_following(&self, user_id: &UserId) -> Result<usize, DomainError> {
let uid = user_id.to_string(); let uid = user_id.value().to_string();
let count: i64 = sqlx::query_scalar( let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ap_following WHERE local_user_id = $1 AND status = 'accepted'", "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) Ok(count as usize)
} }
async fn count_accepted_followers(&self, user_id: uuid::Uuid) -> Result<usize, DomainError> { async fn count_accepted_followers(&self, user_id: &UserId) -> Result<usize, DomainError> {
let uid = user_id.to_string(); let uid = user_id.value().to_string();
let count: i64 = sqlx::query_scalar( let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ap_followers WHERE local_user_id = $1 AND status = 'accepted'", "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( async fn get_pending_followers(
&self, &self,
user_id: uuid::Uuid, user_id: &UserId,
) -> Result<Vec<PendingFollowerInfo>, DomainError> { ) -> 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>)>( 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'", "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()))?; ).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, "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(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, 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", FROM reviews WHERE movie_id = $1 ORDER BY watched_at ASC",
) )
.bind(&id_str) .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, 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.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, 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 FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = $1 WHERE r.user_id = $1

View File

@@ -122,10 +122,6 @@ impl GoalRepository for PostgresGoalRepository {
rows.iter().map(row_to_goal).collect() 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( pub(crate) async fn count_reviews_in_year(

View File

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

View File

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

View File

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

View File

@@ -2,16 +2,16 @@ use async_trait::async_trait;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::{ models::{
DiaryEntry, Goal, GoalType, Movie, PersistedReview, Review, ReviewSource, WatchlistEntry, DiaryEntry, Movie, PersistedReview, Review, ReviewSource, WatchlistEntry,
WatchlistWithMovie, WatchlistWithMovie,
}, },
ports::LocalApContentQuery, ports::LocalApContentQuery,
value_objects::{ value_objects::{
Comment, ExternalMetadataId, GoalId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear, Comment, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
ReviewId, UserId, WatchlistEntryId, ReviewId, UserId, WatchlistEntryId,
}, },
}; };
use sqlx::{Row, SqlitePool}; use sqlx::SqlitePool;
use uuid::Uuid; use uuid::Uuid;
pub struct SqliteApContentQuery { pub struct SqliteApContentQuery {
@@ -82,6 +82,7 @@ struct ReviewRow {
watched_at: String, watched_at: String,
created_at: String, created_at: String,
remote_actor_url: Option<String>, remote_actor_url: Option<String>,
watch_medium: Option<String>,
} }
impl ReviewRow { impl ReviewRow {
@@ -97,6 +98,7 @@ impl ReviewRow {
None => ReviewSource::Local, None => ReviewSource::Local,
Some(url) => ReviewSource::Remote { actor_url: url }, Some(url) => ReviewSource::Remote { actor_url: url },
}; };
let watch_medium = self.watch_medium.map(|s| s.parse()).transpose()?;
Ok(Review::from_persistence(PersistedReview { Ok(Review::from_persistence(PersistedReview {
id, id,
movie_id, movie_id,
@@ -106,7 +108,7 @@ impl ReviewRow {
watched_at, watched_at,
created_at, created_at,
source, source,
watch_medium: None, watch_medium,
})) }))
} }
} }
@@ -127,6 +129,7 @@ struct DiaryRow {
watched_at: String, watched_at: String,
created_at: String, created_at: String,
remote_actor_url: Option<String>, remote_actor_url: Option<String>,
watch_medium: Option<String>,
} }
impl DiaryRow { impl DiaryRow {
@@ -149,6 +152,7 @@ impl DiaryRow {
watched_at: self.watched_at, watched_at: self.watched_at,
created_at: self.created_at, created_at: self.created_at,
remote_actor_url: self.remote_actor_url, remote_actor_url: self.remote_actor_url,
watch_medium: self.watch_medium,
} }
.into_domain()?; .into_domain()?;
Ok(DiaryEntry::new(movie, review)) 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 ───────────────────────────────────────────────── // ── LocalApContentQuery impl ─────────────────────────────────────────────────
#[async_trait] #[async_trait]
impl LocalApContentQuery for SqliteApContentQuery { 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( async fn get_local_watchlist_for_user(
&self, &self,
user_id: &UserId, user_id: &UserId,
@@ -312,7 +226,7 @@ impl LocalApContentQuery for SqliteApContentQuery {
let mid = movie_id.value().to_string(); let mid = movie_id.value().to_string();
let rows = sqlx::query_as::<_, DiaryRow>( let rows = sqlx::query_as::<_, DiaryRow>(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, "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 FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id INNER JOIN movies m ON m.id = r.movie_id
WHERE r.movie_id = ? AND r.remote_actor_url IS NULL 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() 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( async fn get_local_reviews_page(
&self, &self,
user_id: &UserId, user_id: &UserId,
@@ -391,7 +252,7 @@ impl LocalApContentQuery for SqliteApContentQuery {
let ts = before_ts.format("%Y-%m-%d %H:%M:%S").to_string(); let ts = before_ts.format("%Y-%m-%d %H:%M:%S").to_string();
sqlx::query_as::<_, DiaryRow>( sqlx::query_as::<_, DiaryRow>(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, "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 FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id 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 < ? WHERE r.user_id = ? AND r.remote_actor_url IS NULL AND r.watched_at < ?
@@ -407,7 +268,7 @@ impl LocalApContentQuery for SqliteApContentQuery {
} else { } else {
sqlx::query_as::<_, DiaryRow>( sqlx::query_as::<_, DiaryRow>(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, "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 FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = ? AND r.remote_actor_url IS NULL 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() 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, errors::DomainError,
models::{PendingFollowerInfo, RemoteActorInfo}, models::{PendingFollowerInfo, RemoteActorInfo},
ports::SocialQueryPort, ports::SocialQueryPort,
value_objects::UserId,
}; };
use super::SqliteFederationRepository; use super::SqliteFederationRepository;
@@ -11,9 +12,9 @@ use super::SqliteFederationRepository;
impl SocialQueryPort for SqliteFederationRepository { impl SocialQueryPort for SqliteFederationRepository {
async fn get_accepted_following_urls( async fn get_accepted_following_urls(
&self, &self,
user_id: uuid::Uuid, user_id: &UserId,
) -> Result<Vec<String>, DomainError> { ) -> 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>( sqlx::query_scalar::<_, String>(
"SELECT remote_actor_url FROM ap_following WHERE local_user_id = ? AND status = 'accepted'", "SELECT remote_actor_url FROM ap_following WHERE local_user_id = ? AND status = 'accepted'",
).bind(&user_id_str).fetch_all(&self.pool).await ).bind(&user_id_str).fetch_all(&self.pool).await
@@ -40,8 +41,8 @@ impl SocialQueryPort for SqliteFederationRepository {
.collect()) .collect())
} }
async fn count_following(&self, user_id: uuid::Uuid) -> Result<usize, DomainError> { async fn count_following(&self, user_id: &UserId) -> Result<usize, DomainError> {
let uid = user_id.to_string(); let uid = user_id.value().to_string();
let count: i64 = sqlx::query_scalar( let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ap_following WHERE local_user_id = ? AND status = 'accepted'", "SELECT COUNT(*) FROM ap_following WHERE local_user_id = ? AND status = 'accepted'",
) )
@@ -52,8 +53,8 @@ impl SocialQueryPort for SqliteFederationRepository {
Ok(count as usize) Ok(count as usize)
} }
async fn count_accepted_followers(&self, user_id: uuid::Uuid) -> Result<usize, DomainError> { async fn count_accepted_followers(&self, user_id: &UserId) -> Result<usize, DomainError> {
let uid = user_id.to_string(); let uid = user_id.value().to_string();
let count: i64 = sqlx::query_scalar( let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ap_followers WHERE local_user_id = ? AND status = 'accepted'", "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( async fn get_pending_followers(
&self, &self,
user_id: uuid::Uuid, user_id: &UserId,
) -> Result<Vec<PendingFollowerInfo>, DomainError> { ) -> 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>)>( let rows = sqlx::query_as::<_, (String, String, Option<String>, Option<String>)>(
"SELECT ar.url, ar.handle, ar.display_name, ar.avatar_url "SELECT ar.url, ar.handle, ar.display_name, ar.avatar_url
FROM ap_followers f FROM ap_followers f

View File

@@ -97,7 +97,8 @@ async fn test_get_accepted_following_urls_returns_only_accepted() {
.await .await
.unwrap(); .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.len(), 1);
assert_eq!(urls[0], "https://other.social/users/alice"); assert_eq!(urls[0], "https://other.social/users/alice");
} }

View File

@@ -378,7 +378,7 @@ impl DiaryRepository for SqliteDiaryRepository {
.into_domain()?; .into_domain()?;
let viewings = sqlx::query_as::<_, ReviewRow>( 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", FROM reviews WHERE movie_id = ? ORDER BY watched_at ASC",
) )
.bind(&id_str) .bind(&id_str)

View File

@@ -118,10 +118,6 @@ impl GoalRepository for SqliteGoalRepository {
rows.iter().map(row_to_goal).collect() 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( pub(crate) async fn count_reviews_in_year(

View File

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

View File

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

View File

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

View File

@@ -7,3 +7,4 @@ edition = "2024"
serde = { workspace = true } serde = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
utoipa = { version = "5.5.0", features = ["axum_extras", "uuid"] } 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 user_id: Uuid,
pub email: String, pub email: String,
pub expires_at: String, pub expires_at: String,
pub role: String, #[schema(value_type = String)]
pub role: domain::models::UserRole,
} }
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]

View File

@@ -18,7 +18,8 @@ pub struct LogReviewRequest {
pub comment: Option<String>, pub comment: Option<String>,
pub watched_at: String, pub watched_at: String,
#[serde(skip_serializing_if = "Option::is_none")] #[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)] #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
@@ -89,7 +90,8 @@ pub struct EditReviewRequest {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub watched_at: Option<String>, pub watched_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[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 { fn default_export_format() -> String {

View File

@@ -7,7 +7,8 @@ pub struct GoalDto {
pub current_count: u32, pub current_count: u32,
pub percentage: f64, pub percentage: f64,
pub is_complete: bool, 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)] #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
@@ -25,17 +26,3 @@ pub struct CreateGoalRequest {
pub struct UpdateGoalRequest { pub struct UpdateGoalRequest {
pub target_count: u32, 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, 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)] #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
#[serde(tag = "status")] #[serde(tag = "status")]
pub enum PreviewRowDto { pub enum PreviewRowDto {
#[serde(rename = "valid")] #[serde(rename = "valid")]
Valid { Valid(PreviewRowData),
index: usize,
title: Option<String>,
release_year: Option<String>,
director: Option<String>,
rating: Option<String>,
watched_at: Option<String>,
comment: Option<String>,
},
#[serde(rename = "duplicate")] #[serde(rename = "duplicate")]
Duplicate { Duplicate(PreviewRowData),
index: usize,
title: Option<String>,
release_year: Option<String>,
director: Option<String>,
rating: Option<String>,
watched_at: Option<String>,
comment: Option<String>,
},
#[serde(rename = "invalid")] #[serde(rename = "invalid")]
Invalid { index: usize, errors: Vec<String> }, Invalid { index: usize, errors: Vec<String> },
} }

View File

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

View File

@@ -99,7 +99,8 @@ pub struct ReviewDto {
pub comment: Option<String>, pub comment: Option<String>,
pub watched_at: String, pub watched_at: String,
#[serde(skip_serializing_if = "Option::is_none")] #[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)] #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
@@ -125,7 +126,8 @@ pub struct SocialReviewDto {
pub watched_at: String, pub watched_at: String,
pub is_federated: bool, pub is_federated: bool,
#[serde(skip_serializing_if = "Option::is_none")] #[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)] #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]

View File

@@ -67,13 +67,23 @@ pub struct UserTrendsDto {
} }
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct UserProfileResponse { pub struct UserProfileBase {
pub user_id: Uuid,
pub username: String, pub username: String,
#[serde(skip_serializing_if = "Option::is_none")] #[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>, pub avatar_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub banner_url: Option<String>, 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 stats: UserStatsDto,
pub following_count: usize, pub following_count: usize,
pub followers_count: usize, pub followers_count: usize,
@@ -90,23 +100,17 @@ pub struct UserProfileResponse {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub handle: Option<String>, pub handle: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[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>, pub actor_url: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ProfileResponse { pub struct ProfileResponse {
pub username: String, #[serde(flatten)]
pub display_name: Option<String>, pub profile: UserProfileBase,
pub bio: Option<String>,
pub avatar_url: Option<String>,
pub banner_url: Option<String>,
pub also_known_as: Option<String>, pub also_known_as: Option<String>,
pub fields: Vec<ProfileFieldDto>, pub fields: Vec<ProfileFieldDto>,
pub role: String, #[schema(value_type = String)]
pub role: domain::models::UserRole,
} }
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
@@ -119,3 +123,17 @@ pub struct ProfileFieldDto {
pub struct UpdateProfileFieldsRequest { pub struct UpdateProfileFieldsRequest {
pub fields: Vec<ProfileFieldDto>, 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] [dependencies]
async-trait = { workspace = true } async-trait = { workspace = true }
domain = { workspace = true } domain = { workspace = true }
reqwest = { workspace = true } infra-wiring = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }

View File

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

View File

@@ -1,50 +1 @@
#[derive(Clone)] pub use infra_wiring::{AppConfig, WrapUpConfig};
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

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

View File

@@ -4,15 +4,15 @@ use async_trait::async_trait;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
models::{Movie, Review}, models::Review,
ports::{ ports::{
EventPublisher, MetadataClient, MovieRepository, ReviewRepository, WatchlistRepository, EventPublisher, MetadataClient, MovieRepository, ReviewRepository, WatchlistRepository,
}, },
value_objects::{Comment, MovieId, Rating, UserId}, value_objects::{Comment, Rating, UserId},
}; };
use crate::diary::commands::LogReviewCommand; use crate::diary::commands::LogReviewCommand;
use crate::diary::movie_resolver::{MovieResolver, MovieResolverDeps}; use crate::movies::resolve::resolve_and_persist_movie;
use crate::ports::ReviewLogger; use crate::ports::ReviewLogger;
pub struct DefaultReviewLogger { pub struct DefaultReviewLogger {
@@ -48,25 +48,18 @@ impl ReviewLogger for DefaultReviewLogger {
let user_id = UserId::from_uuid(cmd.user_id); let user_id = UserId::from_uuid(cmd.user_id);
let comment = cmd.comment.clone().map(Comment::new).transpose()?; let comment = cmd.comment.clone().map(Comment::new).transpose()?;
let (movie, is_new_movie) = if let Some(id) = cmd.input.movie_id { let (movie, is_new_movie) = resolve_and_persist_movie(
let movie_id = MovieId::from_uuid(id); &cmd.input,
let movie = self self.movie_repo.as_ref(),
.movie_repo self.metadata_client.as_ref(),
.get_movie_by_id(&movie_id) self.event_publisher.as_ref(),
.await? )
.ok_or_else(|| DomainError::NotFound(format!("Movie {id}")))?; .await?;
(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?
};
self.movie_repo.upsert_movie(&movie).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( let review = Review::new(
movie.id().clone(), movie.id().clone(),
@@ -76,7 +69,14 @@ impl ReviewLogger for DefaultReviewLogger {
cmd.watched_at, cmd.watched_at,
cmd.watch_medium, 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 let was_on_watchlist = self
.watchlist_repo .watchlist_repo
@@ -92,35 +92,17 @@ impl ReviewLogger for DefaultReviewLogger {
.await; .await;
} }
publish_events(&self.event_publisher, &movie, is_new_movie, review_event).await if let Some(ext_id) = movie.external_metadata_id() {
} self.event_publisher
} .publish(&DomainEvent::MovieEnrichmentRequested {
movie_id: movie.id().clone(),
external_metadata_id: ext_id.clone(),
})
.await?;
}
async fn publish_events( self.event_publisher.publish(&review_event).await
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
.publish(&DomainEvent::MovieEnrichmentRequested {
movie_id: movie.id().clone(),
external_metadata_id: ext_id.clone(),
})
.await?;
}
publisher.publish(&review_event).await
} }
#[cfg(test)] #[cfg(test)]

View File

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

View File

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

View File

@@ -1,13 +1,17 @@
use std::sync::Arc; use std::sync::Arc;
use domain::{ 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; use super::queries::GetGoalQuery;
pub async fn execute( pub async fn execute(
goal: Arc<dyn GoalRepository>, goal: Arc<dyn GoalRepository>,
stats: Arc<dyn StatsRepository>,
query: GetGoalQuery, query: GetGoalQuery,
) -> Result<Option<GoalWithProgress>, DomainError> { ) -> Result<Option<GoalWithProgress>, DomainError> {
let user_id = UserId::from_uuid(query.user_id); 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 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 { Ok(Some(GoalWithProgress {
goal: g, goal: g,

View File

@@ -1,13 +1,17 @@
use std::sync::Arc; use std::sync::Arc;
use domain::{ 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; use super::queries::ListGoalsQuery;
pub async fn execute( pub async fn execute(
goal: Arc<dyn GoalRepository>, goal: Arc<dyn GoalRepository>,
stats: Arc<dyn StatsRepository>,
query: ListGoalsQuery, query: ListGoalsQuery,
) -> Result<Vec<GoalWithProgress>, DomainError> { ) -> Result<Vec<GoalWithProgress>, DomainError> {
let user_id = UserId::from_uuid(query.user_id); 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()); let mut result = Vec::with_capacity(goals.len());
for g in goals { 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 { result.push(GoalWithProgress {
goal: g, goal: g,
current_count, current_count,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ use domain::{
events::DomainEvent, events::DomainEvent,
models::MovieProfile, models::MovieProfile,
ports::{ ports::{
EventHandler, MovieEnrichmentClient, MovieProfileRepository, MovieRepository, EventHandler, ImageFetcher, MovieEnrichmentClient, MovieProfileRepository, MovieRepository,
ObjectStorage, PersonCommand, SearchCommand, ObjectStorage, PersonCommand, SearchCommand,
}, },
}; };
@@ -22,7 +22,7 @@ pub struct MovieEnrichmentHandler {
person_command: Arc<dyn PersonCommand>, person_command: Arc<dyn PersonCommand>,
search_command: Arc<dyn SearchCommand>, search_command: Arc<dyn SearchCommand>,
object_storage: Arc<dyn ObjectStorage>, object_storage: Arc<dyn ObjectStorage>,
http: reqwest::Client, image_fetcher: Arc<dyn ImageFetcher>,
} }
impl MovieEnrichmentHandler { impl MovieEnrichmentHandler {
@@ -33,6 +33,7 @@ impl MovieEnrichmentHandler {
person_command: Arc<dyn PersonCommand>, person_command: Arc<dyn PersonCommand>,
search_command: Arc<dyn SearchCommand>, search_command: Arc<dyn SearchCommand>,
object_storage: Arc<dyn ObjectStorage>, object_storage: Arc<dyn ObjectStorage>,
image_fetcher: Arc<dyn ImageFetcher>,
) -> Self { ) -> Self {
Self { Self {
enrichment_client, enrichment_client,
@@ -41,7 +42,7 @@ impl MovieEnrichmentHandler {
person_command, person_command,
search_command, search_command,
object_storage, object_storage,
http: reqwest::Client::new(), image_fetcher,
} }
} }
@@ -55,15 +56,13 @@ impl MovieEnrichmentHandler {
continue; continue;
} }
let url = format!("https://image.tmdb.org/t/p/w185{path}"); let url = format!("https://image.tmdb.org/t/p/w185{path}");
match self.http.get(&url).send().await { match self.image_fetcher.fetch_image(&url).await {
Ok(resp) if resp.status().is_success() => { Ok(bytes) => {
if let Ok(bytes) = resp.bytes().await if let Err(e) = self.object_storage.store(&key, &bytes).await {
&& let Err(e) = self.object_storage.store(&key, &bytes).await
{
tracing::debug!("cast photo store failed for {path}: {e}"); 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 queries;
pub mod reindex_search; pub mod reindex_search;
pub mod request_enrichment; pub mod request_enrichment;
pub mod resolve;
pub mod search_cleanup; pub mod search_cleanup;
pub mod sync_poster; 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::{ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
models::{Person, PersonId}, models::{Person, PersonId},
}; };
use super::deps::GetPersonDeps; use super::{deps::GetPersonDeps, should_enrich};
const ENRICHMENT_TTL_DAYS: i64 = 90;
pub async fn execute(deps: &GetPersonDeps, id: PersonId) -> Result<Option<Person>, DomainError> { pub async fn execute(deps: &GetPersonDeps, id: PersonId) -> Result<Option<Person>, DomainError> {
let person = deps.person_query.get_by_id(&id).await?; 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) 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)] #[cfg(test)]
#[path = "tests/get.rs"] #[path = "tests/get.rs"]
mod tests; mod tests;

View File

@@ -1,13 +1,10 @@
use chrono::Utc;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
models::{Person, PersonCredits, PersonId}, models::{PersonCredits, PersonId},
}; };
use super::deps::GetPersonDeps; use super::{deps::GetPersonDeps, should_enrich};
const ENRICHMENT_TTL_DAYS: i64 = 90;
pub async fn execute(deps: &GetPersonDeps, id: PersonId) -> Result<PersonCredits, DomainError> { pub async fn execute(deps: &GetPersonDeps, id: PersonId) -> Result<PersonCredits, DomainError> {
let credits = deps.person_query.get_credits(&id).await?; 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) 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)] #[cfg(test)]
#[path = "tests/get_credits.rs"] #[path = "tests/get_credits.rs"]
mod tests; mod tests;

View File

@@ -5,3 +5,15 @@ pub mod get;
pub mod get_credits; pub mod get_credits;
pub use event_handler::PersonEnrichmentHandler; 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 async_trait::async_trait;
use domain::errors::DomainError; use domain::errors::DomainError;
use domain::models::DiaryEntry;
use crate::diary::commands::LogReviewCommand; use crate::diary::commands::LogReviewCommand;
@@ -9,7 +8,3 @@ use crate::diary::commands::LogReviewCommand;
pub trait ReviewLogger: Send + Sync { pub trait ReviewLogger: Send + Sync {
async fn log_review(&self, cmd: LogReviewCommand) -> Result<(), DomainError>; 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_repo: FakeDiaryRepository::new(),
diary_exporter: Arc::new(PanicDiaryExporter), diary_exporter: Arc::new(PanicDiaryExporter),
document_parser: Arc::new(FakeDocumentParser), document_parser: Arc::new(FakeDocumentParser),
stats_repo: Arc::new(FakeStatsRepository), stats_repo: FakeStatsRepository::new(),
metadata_client: Arc::new(FakeMetadataClient), metadata_client: Arc::new(FakeMetadataClient),
poster_fetcher: Arc::new(FakePosterFetcher), poster_fetcher: Arc::new(FakePosterFetcher),
object_storage: Arc::new(NoopObjectStorage), object_storage: Arc::new(NoopObjectStorage),

View File

@@ -17,7 +17,7 @@ pub struct CurrentProfileData {
pub banner_path: Option<String>, pub banner_path: Option<String>,
pub also_known_as: Option<String>, pub also_known_as: Option<String>,
pub fields: Vec<ProfileFieldData>, pub fields: Vec<ProfileFieldData>,
pub role: String, pub role: domain::models::UserRole,
} }
pub async fn execute( pub async fn execute(
@@ -47,7 +47,7 @@ pub async fn execute(
banner_path: found.banner_path().map(|s| s.to_string()), banner_path: found.banner_path().map(|s| s.to_string()),
also_known_as: found.also_known_as().map(|s| s.to_string()), also_known_as: found.also_known_as().map(|s| s.to_string()),
fields, 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 stats = deps.stats.get_user_stats(&user_id).await?;
let (following_count, followers_count, pending_followers) = 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 { let base = |entries, history, trends| UserProfileData {
stats, stats,
@@ -76,7 +76,7 @@ pub async fn execute(
async fn load_social_counts( async fn load_social_counts(
deps: &GetProfileDeps, deps: &GetProfileDeps,
user_id: uuid::Uuid, user_id: &UserId,
is_own_profile: bool, is_own_profile: bool,
) -> (usize, usize, Vec<PendingFollowerView>) { ) -> (usize, usize, Vec<PendingFollowerView>) {
let following = deps let following = deps

View File

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

View File

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

View File

@@ -57,7 +57,8 @@ pub use search::{
use crate::errors::DomainError; 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 { pub enum GoalType {
Movies, Movies,
} }

View File

@@ -1,6 +1,7 @@
use crate::value_objects::{Email, PasswordHash, UserId, Username}; 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 { pub enum UserRole {
#[default] #[default]
Standard, Standard,

View File

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

View File

@@ -17,5 +17,4 @@ pub trait GoalRepository: Send + Sync {
year: u16, year: u16,
) -> Result<Option<Goal>, DomainError>; ) -> Result<Option<Goal>, DomainError>;
async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<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 events;
pub mod federated_profile; pub mod federated_profile;
pub mod goals; pub mod goals;
pub mod image_fetcher;
pub mod images; pub mod images;
pub mod import; pub mod import;
pub mod jobs; pub mod jobs;
pub mod media_server; pub mod media_server;
pub mod movie; pub mod movie;
pub mod person; pub mod person;
pub mod rss;
pub mod search; pub mod search;
pub mod social; pub mod social;
pub mod watchlist; pub mod watchlist;
@@ -19,12 +21,14 @@ pub use diary::*;
pub use events::*; pub use events::*;
pub use federated_profile::*; pub use federated_profile::*;
pub use goals::*; pub use goals::*;
pub use image_fetcher::*;
pub use images::*; pub use images::*;
pub use import::*; pub use import::*;
pub use jobs::*; pub use jobs::*;
pub use media_server::*; pub use media_server::*;
pub use movie::*; pub use movie::*;
pub use person::*; pub use person::*;
pub use rss::*;
pub use search::*; pub use search::*;
pub use social::*; pub use social::*;
pub use watchlist::*; 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::{ use crate::{
errors::DomainError, errors::DomainError,
models::{ models::{
DiaryEntry, FederationFlags, Goal, Movie, PendingFollowerInfo, RemoteActorInfo, DiaryEntry, FederationFlags, PendingFollowerInfo, RemoteActorInfo, RemoteGoalEntry,
RemoteGoalEntry, RemoteWatchlistEntry, Review, WatchlistWithMovie, RemoteWatchlistEntry, WatchlistWithMovie,
}, },
value_objects::{MovieId, ReviewId, UserId}, value_objects::{MovieId, UserId},
}; };
#[async_trait] #[async_trait]
pub trait SocialQueryPort: Send + Sync { pub trait SocialQueryPort: Send + Sync {
async fn get_accepted_following_urls( async fn get_accepted_following_urls(
&self, &self,
user_id: uuid::Uuid, user_id: &UserId,
) -> Result<Vec<String>, DomainError>; ) -> Result<Vec<String>, DomainError>;
async fn list_all_followed_remote_actors(&self) -> Result<Vec<RemoteActorInfo>, 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_following(&self, user_id: &UserId) -> Result<usize, DomainError>;
async fn count_accepted_followers(&self, user_id: uuid::Uuid) -> Result<usize, DomainError>; async fn count_accepted_followers(&self, user_id: &UserId) -> Result<usize, DomainError>;
async fn get_pending_followers( async fn get_pending_followers(
&self, &self,
user_id: uuid::Uuid, user_id: &UserId,
) -> Result<Vec<PendingFollowerInfo>, DomainError>; ) -> 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>; async fn get_by_actor_url(&self, actor_url: &str) -> Result<Vec<RemoteGoalEntry>, DomainError>;
} }
/// Read-only query port used exclusively by the ActivityPub adapter. /// Federation-specific read-only queries that have no equivalent on the
/// Consolidates all reads the AP adapter needs so it never touches write repositories. /// 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] #[async_trait]
pub trait LocalApContentQuery: Send + Sync { 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( async fn get_local_watchlist_for_user(
&self, &self,
user_id: &UserId, user_id: &UserId,
) -> Result<Vec<WatchlistWithMovie>, DomainError>; ) -> 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( async fn get_local_reviews_for_movie(
&self, &self,
movie_id: &MovieId, movie_id: &MovieId,
@@ -89,10 +80,4 @@ pub trait LocalApContentQuery: Send + Sync {
before: Option<NaiveDateTime>, before: Option<NaiveDateTime>,
limit: usize, limit: usize,
) -> Result<Vec<DiaryEntry>, DomainError>; ) -> 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 ───────────────────────────────────────────────────── // ── 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] #[async_trait]
impl StatsRepository for FakeStatsRepository { impl StatsRepository for FakeStatsRepository {
@@ -211,6 +228,11 @@ impl StatsRepository for FakeStatsRepository {
max_director_count: 0, 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 ───────────────────────────────────────────────────────── // ── FakePersonQuery ─────────────────────────────────────────────────────────

View File

@@ -10,7 +10,6 @@ use chrono::Utc;
use crate::{ use crate::{
errors::DomainError, errors::DomainError,
events::DomainEvent,
models::{ models::{
FederationFlags, Goal, ImportProfile, ImportSession, Movie, MovieFilter, MovieProfile, FederationFlags, Goal, ImportProfile, ImportSession, Movie, MovieFilter, MovieProfile,
MovieSummary, ProfileField, RefreshSession, Review, User, UserSettings, UserSummary, MovieSummary, ProfileField, RefreshSession, Review, User, UserSettings, UserSummary,
@@ -171,18 +170,12 @@ impl InMemoryReviewRepository {
#[async_trait] #[async_trait]
impl ReviewRepository for InMemoryReviewRepository { 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 self.store
.lock() .lock()
.unwrap() .unwrap()
.insert(review.id().value(), review.clone()); .insert(review.id().value(), review.clone());
Ok(DomainEvent::ReviewLogged { Ok(())
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(),
})
} }
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError> { 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 { pub struct InMemoryGoalRepository {
store: Mutex<HashMap<Uuid, Goal>>, store: Mutex<HashMap<Uuid, Goal>>,
review_counts: Mutex<HashMap<(Uuid, u16), u32>>,
} }
impl InMemoryGoalRepository { impl InMemoryGoalRepository {
pub fn new() -> Arc<Self> { pub fn new() -> Arc<Self> {
Arc::new(Self { Arc::new(Self {
store: Mutex::new(HashMap::new()), store: Mutex::new(HashMap::new()),
review_counts: Mutex::new(HashMap::new()),
}) })
} }
pub fn count(&self) -> usize { pub fn count(&self) -> usize {
self.store.lock().unwrap().len() 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] #[async_trait]
@@ -416,11 +400,6 @@ impl GoalRepository for InMemoryGoalRepository {
.cloned() .cloned()
.collect()) .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 ────────────────────────────────────────── // ── InMemoryUserSettingsRepository ──────────────────────────────────────────

View File

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

View File

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

View File

@@ -3,10 +3,12 @@ use std::str::FromStr;
use crate::errors::DomainError; 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 { pub enum WatchMedium {
Cinema, Cinema,
Streaming, Streaming,
#[serde(rename = "tv")]
TV, TV,
PhysicalMedia, PhysicalMedia,
Download, 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] [features]
default = ["sqlite", "sqlite-federation"] default = ["sqlite", "sqlite-federation"]
sqlite = ["dep:sqlite", "dep:sqlite-event-queue", "dep:sqlite-search"] sqlite = ["dep:sqlite", "dep:sqlite-event-queue", "dep:sqlite-search", "infra-wiring/sqlite"]
postgres = ["dep:postgres", "dep:postgres-event-queue", "dep:postgres-search"] postgres = ["dep:postgres", "dep:postgres-event-queue", "dep:postgres-search", "infra-wiring/postgres"]
nats = ["dep:nats"] nats = ["dep:nats", "infra-wiring/nats"]
# Meta-feature: true when any federation adapter is active — keeps all #[cfg(feature = "federation")] gates working # Meta-feature: true when any federation adapter is active — keeps all #[cfg(feature = "federation")] gates working
federation = ["application/federation"] federation = ["application/federation"]
sqlite-federation = [ sqlite-federation = [
@@ -59,6 +59,7 @@ importer = { workspace = true }
jellyfin = { workspace = true } jellyfin = { workspace = true }
plex = { workspace = true } plex = { workspace = true }
sqlx = { workspace = true } sqlx = { workspace = true }
infra-wiring = { workspace = true }
utoipa = { version = "5.5.0", features = ["axum_extras", "uuid"] } utoipa = { version = "5.5.0", features = ["axum_extras", "uuid"] }
utoipa-scalar = { version = "0.3.0", features = ["axum"], default-features = false } utoipa-scalar = { version = "0.3.0", features = ["axum"], default-features = false }
utoipa-swagger-ui = { version = "9.0.2", features = ["axum", "vendored"] } utoipa-swagger-ui = { version = "9.0.2", features = ["axum", "vendored"] }

View File

@@ -7,12 +7,7 @@ use domain::ports::{
WatchEventRepository, WebhookTokenRepository, WatchEventRepository, WebhookTokenRepository,
}; };
pub enum DbPool { pub use infra_wiring::DbPool;
#[cfg(feature = "sqlite")]
Sqlite(sqlx::SqlitePool),
#[cfg(feature = "postgres")]
Postgres(sqlx::PgPool),
}
pub struct DatabaseOutput { pub struct DatabaseOutput {
pub movie: Arc<dyn domain::ports::MovieRepository>, 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> { fn try_from(req: LogReviewRequest) -> Result<Self, Self::Error> {
let watched_at = domain::value_objects::parse_watched_at(&req.watched_at)?; 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 { Ok(Self {
external_metadata_id: req.external_metadata_id.filter(|s| !s.trim().is_empty()), external_metadata_id: req.external_metadata_id.filter(|s| !s.trim().is_empty()),
manual_title: req.manual_title, manual_title: req.manual_title,
@@ -230,7 +229,7 @@ impl TryFrom<LogReviewRequest> for LogReviewData {
rating: req.rating, rating: req.rating,
comment: req.comment, comment: req.comment,
watched_at, watched_at,
watch_medium, watch_medium: req.watch_medium,
}) })
} }
} }

View File

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

View File

@@ -1,22 +1,18 @@
use axum::{ use axum::{
Form, Json, Form, Json,
body::Body,
extract::{Extension, Path, Query, State}, extract::{Extension, Path, Query, State},
http::StatusCode, http::StatusCode,
response::{IntoResponse, Redirect}, response::{IntoResponse, Redirect},
}; };
use futures::StreamExt;
use uuid::Uuid; use uuid::Uuid;
use application::diary::{ use application::diary::{
commands::{DeleteReviewCommand, EditReviewCommand}, commands::{DeleteReviewCommand, EditReviewCommand},
delete_review, delete_review,
deps::{DeleteReviewDeps, EditReviewDeps, GetActivityFeedDeps}, deps::{DeleteReviewDeps, EditReviewDeps, GetActivityFeedDeps},
edit_review, export_diary as export_diary_uc, get_activity_feed as get_feed_uc, get_diary, edit_review, get_activity_feed as get_feed_uc, get_diary, log_review,
log_review, queries::GetActivityFeedQuery,
queries::{ExportQuery, GetActivityFeedQuery},
}; };
use domain::models::ExportFormat;
use crate::{ use crate::{
csrf::CsrfToken, csrf::CsrfToken,
@@ -32,12 +28,7 @@ use api_types::{
}; };
use template_askama::{ActivityFeedTemplate, NewReviewTemplate, build_page_items}; use template_askama::{ActivityFeedTemplate, NewReviewTemplate, build_page_items};
use super::helpers::build_page_context; use super::helpers::{build_export_response, build_page_context, encode_error};
fn encode_error(msg: &str) -> String {
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
utf8_percent_encode(msg, NON_ALPHANUMERIC).to_string()
}
// ── API ────────────────────────────────────────────────────────────────────── // ── API ──────────────────────────────────────────────────────────────────────
@@ -146,22 +137,13 @@ pub async fn patch_review(
.map(|s| domain::value_objects::parse_watched_at(&s).map_err(ApiError)) .map(|s| domain::value_objects::parse_watched_at(&s).map_err(ApiError))
.transpose()?; .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 { let cmd = EditReviewCommand {
review_id, review_id,
requesting_user_id: user_id.value(), requesting_user_id: user_id.value(),
rating: req.rating, rating: req.rating,
comment: req.comment, comment: req.comment,
watched_at, watched_at,
watch_medium, watch_medium: req.watch_medium,
}; };
let deps = EditReviewDeps { let deps = EditReviewDeps {
review: state.app_ctx.repos.review.clone(), review: state.app_ctx.repos.review.clone(),
@@ -186,42 +168,7 @@ pub async fn export_diary(
user: AuthenticatedUser, user: AuthenticatedUser,
Query(params): Query<ExportQueryParams>, Query(params): Query<ExportQueryParams>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let format = match params.format.as_str() { build_export_response(&params.format, user.0.value(), &state)
"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()
} }
#[utoipa::path( #[utoipa::path(
@@ -352,42 +299,7 @@ pub async fn get_export_html(
RequiredCookieUser(user_id): RequiredCookieUser, RequiredCookieUser(user_id): RequiredCookieUser,
Query(params): Query<api_types::ExportQueryParams>, Query(params): Query<api_types::ExportQueryParams>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let format = match params.format.as_str() { build_export_response(&params.format, user_id.value(), &state)
"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()
} }
pub async fn get_activity_feed_html( 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, current_count: g.current_count,
percentage: g.percentage(), percentage: g.percentage(),
is_complete: g.is_complete(), 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> { ) -> Result<Json<GoalsResponse>, ApiError> {
let goals = application::goals::list::execute( let goals = application::goals::list::execute(
state.app_ctx.repos.goal.clone(), state.app_ctx.repos.goal.clone(),
state.app_ctx.repos.stats.clone(),
application::goals::queries::ListGoalsQuery { application::goals::queries::ListGoalsQuery {
user_id: user.0.value(), user_id: user.0.value(),
}, },
@@ -66,6 +67,7 @@ pub async fn create_goal(
) -> Result<Json<GoalDto>, ApiError> { ) -> Result<Json<GoalDto>, ApiError> {
let g = application::goals::create::execute( let g = application::goals::create::execute(
state.app_ctx.repos.goal.clone(), state.app_ctx.repos.goal.clone(),
state.app_ctx.repos.stats.clone(),
state.app_ctx.services.event_publisher.clone(), state.app_ctx.services.event_publisher.clone(),
application::goals::commands::CreateGoalCommand { application::goals::commands::CreateGoalCommand {
user_id: user.0.value(), user_id: user.0.value(),
@@ -95,6 +97,7 @@ pub async fn update_goal(
) -> Result<Json<GoalDto>, ApiError> { ) -> Result<Json<GoalDto>, ApiError> {
let g = application::goals::update::execute( let g = application::goals::update::execute(
state.app_ctx.repos.goal.clone(), state.app_ctx.repos.goal.clone(),
state.app_ctx.repos.stats.clone(),
state.app_ctx.services.event_publisher.clone(), state.app_ctx.services.event_publisher.clone(),
application::goals::commands::UpdateGoalCommand { application::goals::commands::UpdateGoalCommand {
user_id: user.0.value(), user_id: user.0.value(),
@@ -147,6 +150,7 @@ pub async fn get_user_goals(
) -> Result<Json<GoalsResponse>, ApiError> { ) -> Result<Json<GoalsResponse>, ApiError> {
let goals = application::goals::list::execute( let goals = application::goals::list::execute(
state.app_ctx.repos.goal.clone(), state.app_ctx.repos.goal.clone(),
state.app_ctx.repos.stats.clone(),
application::goals::queries::ListGoalsQuery { user_id }, application::goals::queries::ListGoalsQuery { user_id },
) )
.await?; .await?;

View File

@@ -1,8 +1,145 @@
use application::rendering::HtmlPageContext; use api_types::HtmlPageContext;
use domain::value_objects::UserId; use domain::value_objects::UserId;
use crate::state::AppState; 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( pub(crate) async fn build_page_context(
state: &AppState, state: &AppState,
user_id: Option<UserId>, user_id: Option<UserId>,

View File

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

View File

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

View File

@@ -185,7 +185,7 @@ pub async fn get_movie_detail(
comment: e.review().comment().map(|c| c.value().to_string()), comment: e.review().comment().map(|c| c.value().to_string()),
watched_at: domain::value_objects::format_watched_at(e.review().watched_at()), watched_at: domain::value_objects::format_watched_at(e.review().watched_at()),
is_federated: e.review().is_remote(), 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(), .collect(),
total_count: result.reviews.total_count, total_count: result.reviews.total_count,

View File

@@ -13,7 +13,7 @@ use domain::models::{PersonId, collections::PageParams};
use crate::state::AppState; use crate::state::AppState;
use api_types::search::{ use api_types::search::{
CastCreditDto, CrewCreditDto, MovieSearchHitDto, PaginatedMovieHits, PaginatedPersonHits, CastCreditDto, CrewCreditDto, MovieSearchHitDto, PaginatedMovieHits, PaginatedPersonHits,
PersonCreditsDto, PersonDto, PersonSearchHitDto, SearchQueryParams, SearchResponse, PersonCreditsDto, PersonSearchHitDto, SearchQueryParams, SearchResponse,
}; };
// ── API ────────────────────────────────────────────────────────────────────── // ── API ──────────────────────────────────────────────────────────────────────
@@ -106,24 +106,9 @@ pub async fn get_person_handler(
event_publisher: state.app_ctx.services.event_publisher.clone(), event_publisher: state.app_ctx.services.event_publisher.clone(),
}; };
match get_person::execute(&deps, PersonId::from_uuid(id)).await { match get_person::execute(&deps, PersonId::from_uuid(id)).await {
Ok(Some(person)) => axum::Json(PersonDto { Ok(Some(person)) => {
id: person.id().value(), axum::Json(crate::mappers::search::person_to_dto(&person)).into_response()
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(None) => StatusCode::NOT_FOUND.into_response(), Ok(None) => StatusCode::NOT_FOUND.into_response(),
Err(e) => crate::errors::domain_error_response(e), 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 { match get_person_credits::execute(&deps, PersonId::from_uuid(id)).await {
Ok(credits) => axum::Json(PersonCreditsDto { Ok(credits) => axum::Json(PersonCreditsDto {
person: PersonDto { person: crate::mappers::search::person_to_dto(&credits.person),
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(),
},
cast: credits cast: credits
.cast .cast
.iter() .iter()

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,6 +13,7 @@ use presentation::{factory, openapi, routes, state::AppState};
use rss::RssAdapter; use rss::RssAdapter;
use domain::ports::{DiaryExporter, DocumentParser, EventPublisher}; use domain::ports::{DiaryExporter, DocumentParser, EventPublisher};
use infra_wiring::EventBusBackend;
#[cfg(feature = "postgres")] #[cfg(feature = "postgres")]
use postgres_search; use postgres_search;
@@ -85,29 +86,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
), ),
}; };
let ep: Arc<dyn EventPublisher> = match event_bus { let ep = create_event_publisher(event_bus, &db_pool).await?;
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 ap = activitypub::wire(activitypub::ActivityPubDeps { let ap = activitypub::wire(activitypub::ActivityPubDeps {
activity_repo, activity_repo,
@@ -118,6 +97,11 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
remote_watchlist_repo: remote_watchlist_repo.clone(), remote_watchlist_repo: remote_watchlist_repo.clone(),
remote_goal_repo: Arc::clone(&db.remote_goal), remote_goal_repo: Arc::clone(&db.remote_goal),
local_ap_content: Arc::clone(&ap_content_repo), 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), user_repo: Arc::clone(&db.user),
federation_settings: std::sync::Arc::clone(&db.federation_settings), federation_settings: std::sync::Arc::clone(&db.federation_settings),
base_url: app_config.base_url.clone(), base_url: app_config.base_url.clone(),
@@ -138,32 +122,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
}; };
#[cfg(not(feature = "federation"))] #[cfg(not(feature = "federation"))]
let event_publisher_arc: Arc<dyn EventPublisher> = match event_bus { let event_publisher_arc = create_event_publisher(event_bus, &db_pool).await?;
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?
}
};
#[cfg(not(feature = "federation"))] #[cfg(not(feature = "federation"))]
let ap_router = axum::Router::new(); let ap_router = axum::Router::new();
@@ -253,27 +212,30 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
Ok((state, ap_router)) Ok((state, ap_router))
} }
#[derive(Clone, Copy)] async fn create_event_publisher(
enum EventBusBackend { event_bus: EventBusBackend,
Db, db_pool: &factory::DbPool,
#[cfg(feature = "nats")] ) -> anyhow::Result<Arc<dyn EventPublisher>> {
Nats, match event_bus {
} EventBusBackend::Db => {
tracing::info!("event bus: DB queue");
impl EventBusBackend { Ok(match db_pool {
fn from_env() -> anyhow::Result<Self> { #[cfg(feature = "postgres")]
match std::env::var("EVENT_BUS_BACKEND") factory::DbPool::Postgres(pool) => {
.unwrap_or_else(|_| "db".to_string()) postgres_event_queue::PostgresEventQueue::create_publisher(pool.clone()).await?
.as_str() }
{ #[cfg(feature = "sqlite")]
"db" => Ok(Self::Db), factory::DbPool::Sqlite(pool) => {
#[cfg(feature = "nats")] sqlite_event_queue::SqliteEventQueue::create_publisher(pool.clone()).await?
"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 = "nats")]
} EventBusBackend::Nats => {
other => anyhow::bail!("unknown EVENT_BUS_BACKEND={other}, expected 'db' or '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 import;
pub mod integrations; pub mod integrations;
pub mod movies; pub mod movies;
pub mod search;
#[cfg(feature = "federation")]
pub mod social;
pub mod users; pub mod users;
pub mod watchlist; pub mod watchlist;

View File

@@ -37,7 +37,7 @@ pub fn review_to_dto(review: &Review) -> ReviewDto {
rating: review.rating().value(), rating: review.rating().value(),
comment: review.comment().map(|c| c.value().to_string()), comment: review.comment().map(|c| c.value().to_string()),
watched_at: domain::value_objects::format_watched_at(review.watched_at()), 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 application::users::get_profile::PendingFollowerView;
use chrono::Datelike;
use domain::models::RemoteActorInfo; use domain::models::RemoteActorInfo;
use domain::models::{DiaryEntry, MonthActivity, UserSummary}; use domain::models::UserSummary;
use template_askama::{RemoteActorData, RemoteActorDisplay, UserSummaryView}; use template_askama::{RemoteActorData, RemoteActorDisplay, UserSummaryView};
pub fn user_summary_view(u: &UserSummary) -> 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(), 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