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:
16
Cargo.lock
generated
16
Cargo.lock
generated
@@ -293,6 +293,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
name = "api-types"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"domain",
|
||||
"serde",
|
||||
"utoipa",
|
||||
"uuid",
|
||||
@@ -319,8 +320,8 @@ dependencies = [
|
||||
"domain",
|
||||
"futures",
|
||||
"hex",
|
||||
"infra-wiring",
|
||||
"rand 0.9.4",
|
||||
"reqwest 0.13.3",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tokio",
|
||||
@@ -2701,6 +2702,14 @@ dependencies = [
|
||||
"cfb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "infra-wiring"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"sqlx",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
@@ -3982,6 +3991,7 @@ dependencies = [
|
||||
"http-body-util",
|
||||
"importer",
|
||||
"infer",
|
||||
"infra-wiring",
|
||||
"jellyfin",
|
||||
"metadata",
|
||||
"nats",
|
||||
@@ -4587,7 +4597,6 @@ dependencies = [
|
||||
name = "rss"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"application",
|
||||
"domain",
|
||||
"rss 2.0.13",
|
||||
]
|
||||
@@ -5597,7 +5606,7 @@ dependencies = [
|
||||
name = "template-askama"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"application",
|
||||
"api-types",
|
||||
"askama",
|
||||
"chrono",
|
||||
"domain",
|
||||
@@ -7127,6 +7136,7 @@ dependencies = [
|
||||
"export",
|
||||
"image-converter",
|
||||
"importer",
|
||||
"infra-wiring",
|
||||
"metadata",
|
||||
"nats",
|
||||
"object-storage",
|
||||
|
||||
@@ -31,6 +31,7 @@ members = [
|
||||
"crates/adapters/plex",
|
||||
"crates/adapters/sqlite-search",
|
||||
"crates/adapters/postgres-search",
|
||||
"crates/infra-wiring",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
@@ -91,6 +92,7 @@ plex = { path = "crates/adapters/plex" }
|
||||
image-converter = { path = "crates/adapters/image-converter" }
|
||||
sqlite-search = { path = "crates/adapters/sqlite-search" }
|
||||
postgres-search = { path = "crates/adapters/postgres-search" }
|
||||
infra-wiring = { path = "crates/infra-wiring" }
|
||||
|
||||
[profile.dev]
|
||||
debug = 1 # line tables only — still debuggable, much faster linking
|
||||
|
||||
@@ -45,6 +45,7 @@ COPY crates/adapters/image-converter/Cargo.toml crates/adapters/image-converte
|
||||
COPY crates/adapters/sqlite-search/Cargo.toml crates/adapters/sqlite-search/Cargo.toml
|
||||
COPY crates/adapters/postgres-search/Cargo.toml crates/adapters/postgres-search/Cargo.toml
|
||||
COPY crates/worker/Cargo.toml crates/worker/Cargo.toml
|
||||
COPY crates/infra-wiring/Cargo.toml crates/infra-wiring/Cargo.toml
|
||||
|
||||
# Stub every crate so cargo can resolve and fetch deps
|
||||
RUN find crates -name "Cargo.toml" | sed 's|/Cargo.toml||' | \
|
||||
|
||||
@@ -4,7 +4,10 @@ use domain::ports::EventHandler;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
ports::{LocalApContentQuery, UserFederationSettingsQuery},
|
||||
ports::{
|
||||
GoalRepository, LocalApContentQuery, MovieRepository, ReviewRepository, StatsRepository,
|
||||
UserFederationSettingsQuery,
|
||||
},
|
||||
value_objects::{MovieId, ReviewId, UserId},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
@@ -17,20 +20,33 @@ use crate::urls::{actor_url, goal_url, review_url};
|
||||
pub struct ActivityPubEventHandler {
|
||||
ap_service: Arc<ActivityPubService>,
|
||||
content_query: Arc<dyn LocalApContentQuery>,
|
||||
review_repo: Arc<dyn ReviewRepository>,
|
||||
movie_repo: Arc<dyn MovieRepository>,
|
||||
goal_repo: Arc<dyn GoalRepository>,
|
||||
stats_repo: Arc<dyn StatsRepository>,
|
||||
federation_settings: Arc<dyn UserFederationSettingsQuery>,
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
impl ActivityPubEventHandler {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
ap_service: Arc<ActivityPubService>,
|
||||
content_query: Arc<dyn LocalApContentQuery>,
|
||||
review_repo: Arc<dyn ReviewRepository>,
|
||||
movie_repo: Arc<dyn MovieRepository>,
|
||||
goal_repo: Arc<dyn GoalRepository>,
|
||||
stats_repo: Arc<dyn StatsRepository>,
|
||||
federation_settings: Arc<dyn UserFederationSettingsQuery>,
|
||||
base_url: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
ap_service,
|
||||
content_query,
|
||||
review_repo,
|
||||
movie_repo,
|
||||
goal_repo,
|
||||
stats_repo,
|
||||
federation_settings,
|
||||
base_url,
|
||||
}
|
||||
@@ -157,16 +173,12 @@ impl ActivityPubEventHandler {
|
||||
.federation_settings
|
||||
.get_federation_flags(user_id)
|
||||
.await
|
||||
.unwrap_or(domain::models::FederationFlags {
|
||||
goals: true,
|
||||
reviews: true,
|
||||
watchlist: true,
|
||||
});
|
||||
.unwrap_or_default();
|
||||
if !flags.reviews {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let review = match self.content_query.get_review_by_id(review_id).await? {
|
||||
let review = match self.review_repo.get_review_by_id(review_id).await? {
|
||||
Some(r) => r,
|
||||
None => return Ok(()),
|
||||
};
|
||||
@@ -175,7 +187,7 @@ impl ActivityPubEventHandler {
|
||||
let actor = actor_url(&self.base_url, user_id.value());
|
||||
|
||||
let movie = self
|
||||
.content_query
|
||||
.movie_repo
|
||||
.get_movie_by_id(review.movie_id())
|
||||
.await
|
||||
.ok()
|
||||
@@ -227,16 +239,12 @@ impl ActivityPubEventHandler {
|
||||
.federation_settings
|
||||
.get_federation_flags(user_id)
|
||||
.await
|
||||
.unwrap_or(domain::models::FederationFlags {
|
||||
goals: true,
|
||||
reviews: true,
|
||||
watchlist: true,
|
||||
});
|
||||
.unwrap_or_default();
|
||||
if !flags.reviews {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let review = match self.content_query.get_review_by_id(review_id).await? {
|
||||
let review = match self.review_repo.get_review_by_id(review_id).await? {
|
||||
Some(r) => r,
|
||||
None => return Ok(()),
|
||||
};
|
||||
@@ -245,7 +253,7 @@ impl ActivityPubEventHandler {
|
||||
let actor = actor_url(&self.base_url, user_id.value());
|
||||
|
||||
let movie = self
|
||||
.content_query
|
||||
.movie_repo
|
||||
.get_movie_by_id(review.movie_id())
|
||||
.await
|
||||
.ok()
|
||||
@@ -310,11 +318,7 @@ impl ActivityPubEventHandler {
|
||||
.federation_settings
|
||||
.get_federation_flags(user_id)
|
||||
.await
|
||||
.unwrap_or(domain::models::FederationFlags {
|
||||
goals: true,
|
||||
reviews: true,
|
||||
watchlist: true,
|
||||
});
|
||||
.unwrap_or_default();
|
||||
if !flags.watchlist {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -324,7 +328,7 @@ impl ActivityPubEventHandler {
|
||||
let actor = actor_url(&self.base_url, user_id.value());
|
||||
|
||||
let poster_url = self
|
||||
.content_query
|
||||
.movie_repo
|
||||
.get_movie_by_id(movie_id)
|
||||
.await
|
||||
.ok()
|
||||
@@ -373,7 +377,7 @@ impl ActivityPubEventHandler {
|
||||
.get_local_reviews_for_movie(movie_id)
|
||||
.await?;
|
||||
|
||||
let movie = self.content_query.get_movie_by_id(movie_id).await?;
|
||||
let movie = self.movie_repo.get_movie_by_id(movie_id).await?;
|
||||
let movie = match movie {
|
||||
Some(m) => m,
|
||||
None => return Ok(()),
|
||||
@@ -393,11 +397,7 @@ impl ActivityPubEventHandler {
|
||||
.federation_settings
|
||||
.get_federation_flags(user_id)
|
||||
.await
|
||||
.unwrap_or(domain::models::FederationFlags {
|
||||
goals: true,
|
||||
reviews: true,
|
||||
watchlist: true,
|
||||
});
|
||||
.unwrap_or_default();
|
||||
if !flags.reviews {
|
||||
continue;
|
||||
}
|
||||
@@ -436,23 +436,24 @@ impl ActivityPubEventHandler {
|
||||
.federation_settings
|
||||
.get_federation_flags(user_id)
|
||||
.await
|
||||
.unwrap_or(domain::models::FederationFlags {
|
||||
goals: true,
|
||||
reviews: true,
|
||||
watchlist: true,
|
||||
});
|
||||
.unwrap_or_default();
|
||||
if !flags.goals {
|
||||
return Ok(());
|
||||
}
|
||||
let Some((goal, current)) = self
|
||||
.content_query
|
||||
.get_goal_with_progress(user_id, year)
|
||||
let Some(goal) = self
|
||||
.goal_repo
|
||||
.find_by_user_and_year(user_id, year)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let current = self
|
||||
.stats_repo
|
||||
.count_reviews_in_year(user_id, year)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let ap_id = goal_url(&self.base_url, user_id.value(), year);
|
||||
let actor = actor_url(&self.base_url, user_id.value());
|
||||
let obj = goal_to_ap_object(
|
||||
@@ -481,21 +482,14 @@ impl ActivityPubEventHandler {
|
||||
.federation_settings
|
||||
.get_federation_flags(user_id)
|
||||
.await
|
||||
.unwrap_or(domain::models::FederationFlags {
|
||||
goals: true,
|
||||
reviews: true,
|
||||
watchlist: true,
|
||||
});
|
||||
.unwrap_or_default();
|
||||
if !flags.goals {
|
||||
return Ok(());
|
||||
}
|
||||
let current = self
|
||||
.content_query
|
||||
.get_goal_with_progress(user_id, year)
|
||||
.stats_repo
|
||||
.count_reviews_in_year(user_id, year)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|(_, c)| c)
|
||||
.unwrap_or(0);
|
||||
|
||||
let ap_id = goal_url(&self.base_url, user_id.value(), year);
|
||||
@@ -519,11 +513,7 @@ impl ActivityPubEventHandler {
|
||||
.federation_settings
|
||||
.get_federation_flags(user_id)
|
||||
.await
|
||||
.unwrap_or(domain::models::FederationFlags {
|
||||
goals: true,
|
||||
reviews: true,
|
||||
watchlist: true,
|
||||
});
|
||||
.unwrap_or_default();
|
||||
if !flags.goals {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use async_trait::async_trait;
|
||||
use chrono::DateTime;
|
||||
use domain::{
|
||||
models::RemoteGoalEntry,
|
||||
ports::{LocalApContentQuery, RemoteGoalRepository},
|
||||
ports::{GoalRepository, RemoteGoalRepository},
|
||||
value_objects::UserId,
|
||||
};
|
||||
use k_ap::{ApContentReader, ApObjectHandler};
|
||||
@@ -15,7 +15,7 @@ use crate::urls::{actor_url, goal_url};
|
||||
|
||||
pub struct GoalObjectHandler {
|
||||
pub remote_goal_repo: Arc<dyn RemoteGoalRepository>,
|
||||
pub content_query: Arc<dyn LocalApContentQuery>,
|
||||
pub goal_repo: Arc<dyn GoalRepository>,
|
||||
pub base_url: String,
|
||||
}
|
||||
|
||||
@@ -29,8 +29,8 @@ impl ApContentReader for GoalObjectHandler {
|
||||
) -> anyhow::Result<Vec<(Url, serde_json::Value, DateTime<chrono::Utc>)>> {
|
||||
let uid = UserId::from_uuid(user_id);
|
||||
let goals = self
|
||||
.content_query
|
||||
.list_goals_for_user(&uid)
|
||||
.goal_repo
|
||||
.list_for_user(&uid)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
|
||||
|
||||
|
||||
@@ -51,6 +51,11 @@ pub struct ActivityPubDeps {
|
||||
pub remote_watchlist_repo: std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>,
|
||||
pub remote_goal_repo: std::sync::Arc<dyn domain::ports::RemoteGoalRepository>,
|
||||
pub local_ap_content: std::sync::Arc<dyn domain::ports::LocalApContentQuery>,
|
||||
pub movie_repo: std::sync::Arc<dyn domain::ports::MovieRepository>,
|
||||
pub review_repo: std::sync::Arc<dyn domain::ports::ReviewRepository>,
|
||||
pub diary_repo: std::sync::Arc<dyn domain::ports::DiaryRepository>,
|
||||
pub goal_repo: std::sync::Arc<dyn domain::ports::GoalRepository>,
|
||||
pub stats_repo: std::sync::Arc<dyn domain::ports::StatsRepository>,
|
||||
pub user_repo: std::sync::Arc<dyn domain::ports::UserRepository>,
|
||||
pub federation_settings: std::sync::Arc<dyn domain::ports::UserFederationSettingsQuery>,
|
||||
pub base_url: String,
|
||||
@@ -68,6 +73,11 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
|
||||
remote_watchlist_repo,
|
||||
remote_goal_repo,
|
||||
local_ap_content,
|
||||
movie_repo,
|
||||
review_repo,
|
||||
diary_repo,
|
||||
goal_repo,
|
||||
stats_repo,
|
||||
user_repo,
|
||||
federation_settings,
|
||||
base_url,
|
||||
@@ -76,6 +86,8 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
|
||||
} = deps;
|
||||
let review_handler = std::sync::Arc::new(ReviewObjectHandler {
|
||||
content_query: std::sync::Arc::clone(&local_ap_content),
|
||||
movie_repo: std::sync::Arc::clone(&movie_repo),
|
||||
diary_repo,
|
||||
review_store,
|
||||
event_publisher: std::sync::Arc::clone(&event_publisher),
|
||||
base_url: base_url.clone(),
|
||||
@@ -87,7 +99,7 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
|
||||
});
|
||||
let goal_handler = std::sync::Arc::new(goal_handler::GoalObjectHandler {
|
||||
remote_goal_repo,
|
||||
content_query: std::sync::Arc::clone(&local_ap_content),
|
||||
goal_repo: std::sync::Arc::clone(&goal_repo),
|
||||
base_url: base_url.clone(),
|
||||
});
|
||||
let composite = std::sync::Arc::new(composite_handler::CompositeObjectHandler {
|
||||
@@ -136,6 +148,10 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
|
||||
let event_handler = std::sync::Arc::new(ActivityPubEventHandler::new(
|
||||
std::sync::Arc::clone(&concrete),
|
||||
local_ap_content,
|
||||
review_repo,
|
||||
movie_repo,
|
||||
goal_repo,
|
||||
stats_repo,
|
||||
federation_settings,
|
||||
base_url,
|
||||
)) as std::sync::Arc<dyn domain::ports::EventHandler>;
|
||||
|
||||
@@ -4,7 +4,7 @@ use async_trait::async_trait;
|
||||
use domain::{
|
||||
events::DomainEvent,
|
||||
models::ReviewSource,
|
||||
ports::{EventPublisher, LocalApContentQuery},
|
||||
ports::{DiaryRepository, EventPublisher, LocalApContentQuery, MovieRepository},
|
||||
value_objects::{Comment, ExternalMetadataId, MovieId, Rating, ReviewId, UserId},
|
||||
};
|
||||
use k_ap::{ApContentReader, ApObjectHandler};
|
||||
@@ -16,6 +16,8 @@ use crate::urls::{actor_url, review_url};
|
||||
|
||||
pub struct ReviewObjectHandler {
|
||||
pub content_query: Arc<dyn LocalApContentQuery>,
|
||||
pub movie_repo: Arc<dyn MovieRepository>,
|
||||
pub diary_repo: Arc<dyn DiaryRepository>,
|
||||
pub review_store: Arc<dyn RemoteReviewRepository>,
|
||||
pub event_publisher: Arc<dyn EventPublisher>,
|
||||
pub base_url: String,
|
||||
@@ -69,7 +71,7 @@ impl ApContentReader for ReviewObjectHandler {
|
||||
}
|
||||
|
||||
async fn count_local_posts(&self) -> anyhow::Result<u64> {
|
||||
self.content_query
|
||||
self.diary_repo
|
||||
.count_local_posts()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!(e.to_string()))
|
||||
@@ -97,13 +99,18 @@ impl ApObjectHandler for ReviewObjectHandler {
|
||||
let actor_url_str = obj.attributed_to.to_string();
|
||||
let review_id = ReviewId::generate();
|
||||
let movie_id = if let Some(ref ext_id) = obj.external_metadata_id {
|
||||
match self
|
||||
.content_query
|
||||
.get_movie_by_external_metadata_id(ext_id)
|
||||
let found = if let Ok(ext_meta_id) = ExternalMetadataId::new(ext_id.clone()) {
|
||||
self.movie_repo
|
||||
.get_movie_by_external_id(&ext_meta_id)
|
||||
.await
|
||||
{
|
||||
Ok(Some(movie)) => movie.id().clone(),
|
||||
_ => MovieId::from_uuid(uuid::Uuid::new_v5(
|
||||
.ok()
|
||||
.flatten()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match found {
|
||||
Some(movie) => movie.id().clone(),
|
||||
None => MovieId::from_uuid(uuid::Uuid::new_v5(
|
||||
&uuid::Uuid::NAMESPACE_URL,
|
||||
ext_id.as_bytes(),
|
||||
)),
|
||||
|
||||
@@ -4,7 +4,11 @@ pub use config::PosterFetcherConfig;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::{errors::DomainError, ports::PosterFetcherClient, value_objects::PosterUrl};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
ports::{ImageFetcher, PosterFetcherClient},
|
||||
value_objects::PosterUrl,
|
||||
};
|
||||
|
||||
pub struct ReqwestPosterFetcher {
|
||||
client: reqwest::Client,
|
||||
@@ -37,8 +41,32 @@ impl PosterFetcherClient for ReqwestPosterFetcher {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ImageFetcher for ReqwestPosterFetcher {
|
||||
async fn fetch_image(&self, url: &str) -> Result<Vec<u8>, DomainError> {
|
||||
let bytes = self
|
||||
.client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
|
||||
.error_for_status()
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(bytes.to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create() -> anyhow::Result<std::sync::Arc<dyn domain::ports::PosterFetcherClient>> {
|
||||
Ok(std::sync::Arc::new(ReqwestPosterFetcher::new(
|
||||
PosterFetcherConfig::from_env(),
|
||||
)?))
|
||||
}
|
||||
|
||||
pub fn create_image_fetcher() -> anyhow::Result<std::sync::Arc<dyn domain::ports::ImageFetcher>> {
|
||||
Ok(std::sync::Arc::new(ReqwestPosterFetcher::new(
|
||||
PosterFetcherConfig::from_env(),
|
||||
)?))
|
||||
}
|
||||
|
||||
@@ -2,12 +2,12 @@ use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{
|
||||
DiaryEntry, Goal, GoalType, Movie, PersistedReview, Review, ReviewSource, WatchlistEntry,
|
||||
DiaryEntry, Movie, PersistedReview, Review, ReviewSource, WatchlistEntry,
|
||||
WatchlistWithMovie,
|
||||
},
|
||||
ports::LocalApContentQuery,
|
||||
value_objects::{
|
||||
Comment, ExternalMetadataId, GoalId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
|
||||
Comment, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
|
||||
ReviewId, UserId, WatchlistEntryId,
|
||||
},
|
||||
};
|
||||
@@ -82,6 +82,7 @@ struct ReviewRow {
|
||||
watched_at: String,
|
||||
created_at: String,
|
||||
remote_actor_url: Option<String>,
|
||||
watch_medium: Option<String>,
|
||||
}
|
||||
|
||||
impl ReviewRow {
|
||||
@@ -97,6 +98,7 @@ impl ReviewRow {
|
||||
None => ReviewSource::Local,
|
||||
Some(url) => ReviewSource::Remote { actor_url: url },
|
||||
};
|
||||
let watch_medium = self.watch_medium.map(|s| s.parse()).transpose()?;
|
||||
Ok(Review::from_persistence(PersistedReview {
|
||||
id,
|
||||
movie_id,
|
||||
@@ -106,7 +108,7 @@ impl ReviewRow {
|
||||
watched_at,
|
||||
created_at,
|
||||
source,
|
||||
watch_medium: None,
|
||||
watch_medium,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -127,6 +129,7 @@ struct DiaryRow {
|
||||
watched_at: String,
|
||||
created_at: String,
|
||||
remote_actor_url: Option<String>,
|
||||
watch_medium: Option<String>,
|
||||
}
|
||||
|
||||
impl DiaryRow {
|
||||
@@ -149,105 +152,17 @@ impl DiaryRow {
|
||||
watched_at: self.watched_at,
|
||||
created_at: self.created_at,
|
||||
remote_actor_url: self.remote_actor_url,
|
||||
watch_medium: self.watch_medium,
|
||||
}
|
||||
.into_domain()?;
|
||||
Ok(DiaryEntry::new(movie, review))
|
||||
}
|
||||
}
|
||||
|
||||
fn row_to_goal(r: &sqlx::postgres::PgRow) -> Result<Goal, DomainError> {
|
||||
let id_str: String = r
|
||||
.try_get("id")
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read goal id: {e}")))?;
|
||||
let user_id_str: String = r
|
||||
.try_get("user_id")
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read user_id: {e}")))?;
|
||||
let year: i64 = r
|
||||
.try_get("year")
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read year: {e}")))?;
|
||||
let target: i64 = r.try_get("target_count").map_err(|e| {
|
||||
DomainError::InfrastructureError(format!("Failed to read target_count: {e}"))
|
||||
})?;
|
||||
let goal_type_str: String = r
|
||||
.try_get("goal_type")
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read goal_type: {e}")))?;
|
||||
let created_at_str: String = r
|
||||
.try_get("created_at")
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read created_at: {e}")))?;
|
||||
|
||||
let id = GoalId::from_uuid(parse_uuid(&id_str)?);
|
||||
let user_id = UserId::from_uuid(parse_uuid(&user_id_str)?);
|
||||
let goal_type: GoalType = goal_type_str.parse()?;
|
||||
let created_at = parse_datetime(&created_at_str)?;
|
||||
|
||||
Ok(Goal::from_persistence(
|
||||
id,
|
||||
user_id,
|
||||
year as u16,
|
||||
target as u32,
|
||||
goal_type,
|
||||
created_at,
|
||||
))
|
||||
}
|
||||
|
||||
async fn count_reviews_in_year(
|
||||
pool: &PgPool,
|
||||
user_id: &UserId,
|
||||
year: u16,
|
||||
) -> Result<u32, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let start = format!("{year}-01-01 00:00:00");
|
||||
let end = format!("{}-01-01 00:00:00", year + 1);
|
||||
|
||||
let count: i64 = sqlx::query(
|
||||
"SELECT COUNT(*) FROM reviews \
|
||||
WHERE user_id = $1 \
|
||||
AND watched_at >= $2::timestamptz \
|
||||
AND watched_at < $3::timestamptz \
|
||||
AND remote_actor_url IS NULL",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(&start)
|
||||
.bind(&end)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Database error: {:?}", e);
|
||||
DomainError::InfrastructureError("Database operation failed".into())
|
||||
})?
|
||||
.try_get(0)
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
Ok(count as u32)
|
||||
}
|
||||
|
||||
// ── LocalApContentQuery impl ─────────────────────────────────────────────────
|
||||
|
||||
#[async_trait]
|
||||
impl LocalApContentQuery for PostgresApContentQuery {
|
||||
async fn get_local_reviews_for_user(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
) -> Result<Vec<DiaryEntry>, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let rows = sqlx::query_as::<_, DiaryRow>(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
|
||||
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
||||
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
r.remote_actor_url
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = $1 AND r.remote_actor_url IS NULL
|
||||
ORDER BY r.created_at DESC",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
rows.into_iter().map(DiaryRow::into_domain).collect()
|
||||
}
|
||||
|
||||
async fn get_local_watchlist_for_user(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
@@ -324,7 +239,8 @@ impl LocalApContentQuery for PostgresApContentQuery {
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
|
||||
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
||||
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
r.remote_actor_url
|
||||
r.remote_actor_url,
|
||||
r.watch_medium
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.movie_id = $1 AND r.remote_actor_url IS NULL
|
||||
@@ -337,62 +253,6 @@ impl LocalApContentQuery for PostgresApContentQuery {
|
||||
rows.into_iter().map(DiaryRow::into_domain).collect()
|
||||
}
|
||||
|
||||
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError> {
|
||||
let id = review_id.value().to_string();
|
||||
sqlx::query_as::<_, ReviewRow>(
|
||||
"SELECT id, movie_id, user_id, rating, comment,
|
||||
to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
||||
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
remote_actor_url
|
||||
FROM reviews WHERE id = $1",
|
||||
)
|
||||
.bind(&id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.map(ReviewRow::into_domain)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result<Option<Movie>, DomainError> {
|
||||
let id = movie_id.value().to_string();
|
||||
sqlx::query_as::<_, MovieRow>(
|
||||
"SELECT id, external_metadata_id, title, release_year, director, poster_path
|
||||
FROM movies WHERE id = $1",
|
||||
)
|
||||
.bind(&id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.map(MovieRow::into_domain)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_movie_by_external_metadata_id(
|
||||
&self,
|
||||
external_id: &str,
|
||||
) -> Result<Option<Movie>, DomainError> {
|
||||
sqlx::query_as::<_, MovieRow>(
|
||||
"SELECT id, external_metadata_id, title, release_year, director, poster_path
|
||||
FROM movies WHERE external_metadata_id = $1",
|
||||
)
|
||||
.bind(external_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.map(MovieRow::into_domain)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn count_local_posts(&self) -> Result<u64, DomainError> {
|
||||
let count: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE remote_actor_url IS NULL")
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
Ok(count as u64)
|
||||
}
|
||||
|
||||
async fn get_local_reviews_page(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
@@ -409,7 +269,8 @@ impl LocalApContentQuery for PostgresApContentQuery {
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
|
||||
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
||||
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
r.remote_actor_url
|
||||
r.remote_actor_url,
|
||||
r.watch_medium
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = $1 AND r.remote_actor_url IS NULL AND r.watched_at < $2::timestamptz
|
||||
@@ -428,7 +289,8 @@ impl LocalApContentQuery for PostgresApContentQuery {
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
|
||||
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
||||
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
r.remote_actor_url
|
||||
r.remote_actor_url,
|
||||
r.watch_medium
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = $1 AND r.remote_actor_url IS NULL
|
||||
@@ -443,45 +305,4 @@ impl LocalApContentQuery for PostgresApContentQuery {
|
||||
};
|
||||
rows.into_iter().map(DiaryRow::into_domain).collect()
|
||||
}
|
||||
|
||||
async fn get_goal_with_progress(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
year: u16,
|
||||
) -> Result<Option<(Goal, u32)>, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let y = year as i64;
|
||||
|
||||
let row = sqlx::query(
|
||||
"SELECT id, user_id, year, target_count, goal_type, \
|
||||
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at \
|
||||
FROM goals WHERE user_id = $1 AND year = $2",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(y)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
|
||||
let Some(r) = row else { return Ok(None) };
|
||||
|
||||
let goal = row_to_goal(&r)?;
|
||||
let count = count_reviews_in_year(&self.pool, user_id, year).await?;
|
||||
|
||||
Ok(Some((goal, count)))
|
||||
}
|
||||
|
||||
async fn list_goals_for_user(&self, user_id: &UserId) -> Result<Vec<Goal>, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, user_id, year, target_count, goal_type, \
|
||||
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at \
|
||||
FROM goals WHERE user_id = $1 ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
rows.iter().map(row_to_goal).collect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ use domain::{
|
||||
errors::DomainError,
|
||||
models::{PendingFollowerInfo, RemoteActorInfo},
|
||||
ports::SocialQueryPort,
|
||||
value_objects::UserId,
|
||||
};
|
||||
|
||||
use super::PostgresFederationRepository;
|
||||
@@ -11,9 +12,9 @@ use super::PostgresFederationRepository;
|
||||
impl SocialQueryPort for PostgresFederationRepository {
|
||||
async fn get_accepted_following_urls(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
user_id: &UserId,
|
||||
) -> Result<Vec<String>, DomainError> {
|
||||
let user_id_str = user_id.to_string();
|
||||
let user_id_str = user_id.value().to_string();
|
||||
sqlx::query_scalar::<_, String>(
|
||||
"SELECT remote_actor_url FROM ap_following WHERE local_user_id = $1 AND status = 'accepted'",
|
||||
).bind(&user_id_str).fetch_all(&self.pool).await
|
||||
@@ -34,8 +35,8 @@ impl SocialQueryPort for PostgresFederationRepository {
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn count_following(&self, user_id: uuid::Uuid) -> Result<usize, DomainError> {
|
||||
let uid = user_id.to_string();
|
||||
async fn count_following(&self, user_id: &UserId) -> Result<usize, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM ap_following WHERE local_user_id = $1 AND status = 'accepted'",
|
||||
)
|
||||
@@ -46,8 +47,8 @@ impl SocialQueryPort for PostgresFederationRepository {
|
||||
Ok(count as usize)
|
||||
}
|
||||
|
||||
async fn count_accepted_followers(&self, user_id: uuid::Uuid) -> Result<usize, DomainError> {
|
||||
let uid = user_id.to_string();
|
||||
async fn count_accepted_followers(&self, user_id: &UserId) -> Result<usize, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM ap_followers WHERE local_user_id = $1 AND status = 'accepted'",
|
||||
)
|
||||
@@ -60,9 +61,9 @@ impl SocialQueryPort for PostgresFederationRepository {
|
||||
|
||||
async fn get_pending_followers(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
user_id: &UserId,
|
||||
) -> Result<Vec<PendingFollowerInfo>, DomainError> {
|
||||
let uid = user_id.to_string();
|
||||
let uid = user_id.value().to_string();
|
||||
let rows = sqlx::query_as::<_, (String, String, Option<String>, Option<String>)>(
|
||||
"SELECT ar.url, ar.handle, ar.display_name, ar.avatar_url FROM ap_followers f JOIN ap_remote_actors ar ON ar.url = f.remote_actor_url WHERE f.local_user_id = $1 AND f.status = 'pending'",
|
||||
).bind(&uid).fetch_all(&self.pool).await.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
@@ -416,7 +416,8 @@ impl DiaryRepository for PostgresDiaryRepository {
|
||||
"SELECT id, movie_id, user_id, rating, comment,
|
||||
to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
||||
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
remote_actor_url
|
||||
remote_actor_url,
|
||||
watch_medium
|
||||
FROM reviews WHERE movie_id = $1 ORDER BY watched_at ASC",
|
||||
)
|
||||
.bind(&id_str)
|
||||
@@ -464,7 +465,8 @@ impl DiaryRepository for PostgresDiaryRepository {
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
|
||||
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
||||
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
r.remote_actor_url
|
||||
r.remote_actor_url,
|
||||
r.watch_medium
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = $1
|
||||
|
||||
@@ -122,10 +122,6 @@ impl GoalRepository for PostgresGoalRepository {
|
||||
|
||||
rows.iter().map(row_to_goal).collect()
|
||||
}
|
||||
|
||||
async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result<u32, DomainError> {
|
||||
count_reviews_in_year(&self.pool, user_id, year).await
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn count_reviews_in_year(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::{Review, ReviewSource},
|
||||
ports::ReviewRepository,
|
||||
value_objects::{ReviewId, UserId},
|
||||
@@ -27,7 +26,7 @@ impl PostgresReviewRepository {
|
||||
|
||||
#[async_trait]
|
||||
impl ReviewRepository for PostgresReviewRepository {
|
||||
async fn save_review(&self, review: &Review) -> Result<DomainEvent, DomainError> {
|
||||
async fn save_review(&self, review: &Review) -> Result<(), DomainError> {
|
||||
let id = review.id().value().to_string();
|
||||
let movie_id = review.movie_id().value().to_string();
|
||||
let user_id = review.user_id().value().to_string();
|
||||
@@ -57,13 +56,7 @@ impl ReviewRepository for PostgresReviewRepository {
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
|
||||
Ok(DomainEvent::ReviewLogged {
|
||||
review_id: review.id().clone(),
|
||||
movie_id: review.movie_id().clone(),
|
||||
user_id: review.user_id().clone(),
|
||||
rating: review.rating().clone(),
|
||||
watched_at: *review.watched_at(),
|
||||
})
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError> {
|
||||
|
||||
@@ -95,6 +95,10 @@ impl StatsRepository for PostgresStatsRepository {
|
||||
})
|
||||
}
|
||||
|
||||
async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result<u32, DomainError> {
|
||||
crate::goals::count_reviews_in_year(&self.pool, user_id, year).await
|
||||
}
|
||||
|
||||
async fn get_user_trends(&self, user_id: &UserId) -> Result<UserTrends, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
|
||||
|
||||
@@ -6,4 +6,3 @@ edition = "2024"
|
||||
[dependencies]
|
||||
rss-feed = { package = "rss", version = "2" }
|
||||
domain = { workspace = true }
|
||||
application = { workspace = true }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use application::ports::RssFeedRenderer;
|
||||
use domain::models::DiaryEntry;
|
||||
use domain::ports::RssFeedRenderer;
|
||||
use rss_feed::{ChannelBuilder, GuidBuilder, ItemBuilder};
|
||||
|
||||
pub struct RssAdapter {
|
||||
|
||||
@@ -2,16 +2,16 @@ use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{
|
||||
DiaryEntry, Goal, GoalType, Movie, PersistedReview, Review, ReviewSource, WatchlistEntry,
|
||||
DiaryEntry, Movie, PersistedReview, Review, ReviewSource, WatchlistEntry,
|
||||
WatchlistWithMovie,
|
||||
},
|
||||
ports::LocalApContentQuery,
|
||||
value_objects::{
|
||||
Comment, ExternalMetadataId, GoalId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
|
||||
Comment, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
|
||||
ReviewId, UserId, WatchlistEntryId,
|
||||
},
|
||||
};
|
||||
use sqlx::{Row, SqlitePool};
|
||||
use sqlx::SqlitePool;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct SqliteApContentQuery {
|
||||
@@ -82,6 +82,7 @@ struct ReviewRow {
|
||||
watched_at: String,
|
||||
created_at: String,
|
||||
remote_actor_url: Option<String>,
|
||||
watch_medium: Option<String>,
|
||||
}
|
||||
|
||||
impl ReviewRow {
|
||||
@@ -97,6 +98,7 @@ impl ReviewRow {
|
||||
None => ReviewSource::Local,
|
||||
Some(url) => ReviewSource::Remote { actor_url: url },
|
||||
};
|
||||
let watch_medium = self.watch_medium.map(|s| s.parse()).transpose()?;
|
||||
Ok(Review::from_persistence(PersistedReview {
|
||||
id,
|
||||
movie_id,
|
||||
@@ -106,7 +108,7 @@ impl ReviewRow {
|
||||
watched_at,
|
||||
created_at,
|
||||
source,
|
||||
watch_medium: None,
|
||||
watch_medium,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -127,6 +129,7 @@ struct DiaryRow {
|
||||
watched_at: String,
|
||||
created_at: String,
|
||||
remote_actor_url: Option<String>,
|
||||
watch_medium: Option<String>,
|
||||
}
|
||||
|
||||
impl DiaryRow {
|
||||
@@ -149,6 +152,7 @@ impl DiaryRow {
|
||||
watched_at: self.watched_at,
|
||||
created_at: self.created_at,
|
||||
remote_actor_url: self.remote_actor_url,
|
||||
watch_medium: self.watch_medium,
|
||||
}
|
||||
.into_domain()?;
|
||||
Ok(DiaryEntry::new(movie, review))
|
||||
@@ -190,100 +194,10 @@ impl WatchlistRow {
|
||||
}
|
||||
}
|
||||
|
||||
fn row_to_goal(r: &sqlx::sqlite::SqliteRow) -> Result<Goal, DomainError> {
|
||||
let id_str: String = r
|
||||
.try_get("id")
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read goal id: {e}")))?;
|
||||
let user_id_str: String = r
|
||||
.try_get("user_id")
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read user_id: {e}")))?;
|
||||
let year: i64 = r
|
||||
.try_get("year")
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read year: {e}")))?;
|
||||
let target: i64 = r.try_get("target_count").map_err(|e| {
|
||||
DomainError::InfrastructureError(format!("Failed to read target_count: {e}"))
|
||||
})?;
|
||||
let goal_type_str: String = r
|
||||
.try_get("goal_type")
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read goal_type: {e}")))?;
|
||||
let created_at_str: String = r
|
||||
.try_get("created_at")
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read created_at: {e}")))?;
|
||||
|
||||
let id = GoalId::from_uuid(
|
||||
Uuid::parse_str(&id_str)
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Invalid goal UUID: {e}")))?,
|
||||
);
|
||||
let user_id = UserId::from_uuid(
|
||||
Uuid::parse_str(&user_id_str)
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Invalid user UUID: {e}")))?,
|
||||
);
|
||||
let goal_type: GoalType = goal_type_str.parse()?;
|
||||
let created_at = parse_datetime(&created_at_str)?;
|
||||
|
||||
Ok(Goal::from_persistence(
|
||||
id,
|
||||
user_id,
|
||||
year as u16,
|
||||
target as u32,
|
||||
goal_type,
|
||||
created_at,
|
||||
))
|
||||
}
|
||||
|
||||
async fn count_reviews_in_year(
|
||||
pool: &SqlitePool,
|
||||
user_id: &UserId,
|
||||
year: u16,
|
||||
) -> Result<u32, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let start = format!("{year}-01-01 00:00:00");
|
||||
let end = format!("{}-01-01 00:00:00", year + 1);
|
||||
|
||||
let count: i64 = sqlx::query(
|
||||
"SELECT COUNT(*) FROM reviews \
|
||||
WHERE user_id = ? AND watched_at >= ? AND watched_at < ? \
|
||||
AND remote_actor_url IS NULL",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(&start)
|
||||
.bind(&end)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Database error: {:?}", e);
|
||||
DomainError::InfrastructureError("Database operation failed".into())
|
||||
})?
|
||||
.try_get(0)
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
Ok(count as u32)
|
||||
}
|
||||
|
||||
// ── LocalApContentQuery impl ─────────────────────────────────────────────────
|
||||
|
||||
#[async_trait]
|
||||
impl LocalApContentQuery for SqliteApContentQuery {
|
||||
async fn get_local_reviews_for_user(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
) -> Result<Vec<DiaryEntry>, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let rows = sqlx::query_as::<_, DiaryRow>(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = ? AND r.remote_actor_url IS NULL
|
||||
ORDER BY r.created_at DESC",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
rows.into_iter().map(DiaryRow::into_domain).collect()
|
||||
}
|
||||
|
||||
async fn get_local_watchlist_for_user(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
@@ -312,7 +226,7 @@ impl LocalApContentQuery for SqliteApContentQuery {
|
||||
let mid = movie_id.value().to_string();
|
||||
let rows = sqlx::query_as::<_, DiaryRow>(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.movie_id = ? AND r.remote_actor_url IS NULL
|
||||
@@ -325,59 +239,6 @@ impl LocalApContentQuery for SqliteApContentQuery {
|
||||
rows.into_iter().map(DiaryRow::into_domain).collect()
|
||||
}
|
||||
|
||||
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError> {
|
||||
let id = review_id.value().to_string();
|
||||
sqlx::query_as::<_, ReviewRow>(
|
||||
"SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url
|
||||
FROM reviews WHERE id = ?",
|
||||
)
|
||||
.bind(&id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.map(ReviewRow::into_domain)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result<Option<Movie>, DomainError> {
|
||||
let id = movie_id.value().to_string();
|
||||
sqlx::query_as::<_, MovieRow>(
|
||||
"SELECT id, external_metadata_id, title, release_year, director, poster_path
|
||||
FROM movies WHERE id = ?",
|
||||
)
|
||||
.bind(&id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.map(MovieRow::into_domain)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_movie_by_external_metadata_id(
|
||||
&self,
|
||||
external_id: &str,
|
||||
) -> Result<Option<Movie>, DomainError> {
|
||||
sqlx::query_as::<_, MovieRow>(
|
||||
"SELECT id, external_metadata_id, title, release_year, director, poster_path
|
||||
FROM movies WHERE external_metadata_id = ?",
|
||||
)
|
||||
.bind(external_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.map(MovieRow::into_domain)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn count_local_posts(&self) -> Result<u64, DomainError> {
|
||||
let count: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE remote_actor_url IS NULL")
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
Ok(count as u64)
|
||||
}
|
||||
|
||||
async fn get_local_reviews_page(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
@@ -391,7 +252,7 @@ impl LocalApContentQuery for SqliteApContentQuery {
|
||||
let ts = before_ts.format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
sqlx::query_as::<_, DiaryRow>(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = ? AND r.remote_actor_url IS NULL AND r.watched_at < ?
|
||||
@@ -407,7 +268,7 @@ impl LocalApContentQuery for SqliteApContentQuery {
|
||||
} else {
|
||||
sqlx::query_as::<_, DiaryRow>(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = ? AND r.remote_actor_url IS NULL
|
||||
@@ -422,43 +283,4 @@ impl LocalApContentQuery for SqliteApContentQuery {
|
||||
};
|
||||
rows.into_iter().map(DiaryRow::into_domain).collect()
|
||||
}
|
||||
|
||||
async fn get_goal_with_progress(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
year: u16,
|
||||
) -> Result<Option<(Goal, u32)>, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let y = year as i64;
|
||||
|
||||
let row = sqlx::query(
|
||||
"SELECT id, user_id, year, target_count, goal_type, created_at \
|
||||
FROM goals WHERE user_id = ? AND year = ?",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(y)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
|
||||
let Some(r) = row else { return Ok(None) };
|
||||
|
||||
let goal = row_to_goal(&r)?;
|
||||
let count = count_reviews_in_year(&self.pool, user_id, year).await?;
|
||||
|
||||
Ok(Some((goal, count)))
|
||||
}
|
||||
|
||||
async fn list_goals_for_user(&self, user_id: &UserId) -> Result<Vec<Goal>, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, user_id, year, target_count, goal_type, created_at \
|
||||
FROM goals WHERE user_id = ? ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
rows.iter().map(row_to_goal).collect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ use domain::{
|
||||
errors::DomainError,
|
||||
models::{PendingFollowerInfo, RemoteActorInfo},
|
||||
ports::SocialQueryPort,
|
||||
value_objects::UserId,
|
||||
};
|
||||
|
||||
use super::SqliteFederationRepository;
|
||||
@@ -11,9 +12,9 @@ use super::SqliteFederationRepository;
|
||||
impl SocialQueryPort for SqliteFederationRepository {
|
||||
async fn get_accepted_following_urls(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
user_id: &UserId,
|
||||
) -> Result<Vec<String>, DomainError> {
|
||||
let user_id_str = user_id.to_string();
|
||||
let user_id_str = user_id.value().to_string();
|
||||
sqlx::query_scalar::<_, String>(
|
||||
"SELECT remote_actor_url FROM ap_following WHERE local_user_id = ? AND status = 'accepted'",
|
||||
).bind(&user_id_str).fetch_all(&self.pool).await
|
||||
@@ -40,8 +41,8 @@ impl SocialQueryPort for SqliteFederationRepository {
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn count_following(&self, user_id: uuid::Uuid) -> Result<usize, DomainError> {
|
||||
let uid = user_id.to_string();
|
||||
async fn count_following(&self, user_id: &UserId) -> Result<usize, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM ap_following WHERE local_user_id = ? AND status = 'accepted'",
|
||||
)
|
||||
@@ -52,8 +53,8 @@ impl SocialQueryPort for SqliteFederationRepository {
|
||||
Ok(count as usize)
|
||||
}
|
||||
|
||||
async fn count_accepted_followers(&self, user_id: uuid::Uuid) -> Result<usize, DomainError> {
|
||||
let uid = user_id.to_string();
|
||||
async fn count_accepted_followers(&self, user_id: &UserId) -> Result<usize, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM ap_followers WHERE local_user_id = ? AND status = 'accepted'",
|
||||
)
|
||||
@@ -66,9 +67,9 @@ impl SocialQueryPort for SqliteFederationRepository {
|
||||
|
||||
async fn get_pending_followers(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
user_id: &UserId,
|
||||
) -> Result<Vec<PendingFollowerInfo>, DomainError> {
|
||||
let uid = user_id.to_string();
|
||||
let uid = user_id.value().to_string();
|
||||
let rows = sqlx::query_as::<_, (String, String, Option<String>, Option<String>)>(
|
||||
"SELECT ar.url, ar.handle, ar.display_name, ar.avatar_url
|
||||
FROM ap_followers f
|
||||
|
||||
@@ -97,7 +97,8 @@ async fn test_get_accepted_following_urls_returns_only_accepted() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let urls = repo.get_accepted_following_urls(user_id).await.unwrap();
|
||||
let uid = domain::value_objects::UserId::from_uuid(user_id);
|
||||
let urls = repo.get_accepted_following_urls(&uid).await.unwrap();
|
||||
assert_eq!(urls.len(), 1);
|
||||
assert_eq!(urls[0], "https://other.social/users/alice");
|
||||
}
|
||||
|
||||
@@ -378,7 +378,7 @@ impl DiaryRepository for SqliteDiaryRepository {
|
||||
.into_domain()?;
|
||||
|
||||
let viewings = sqlx::query_as::<_, ReviewRow>(
|
||||
"SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url
|
||||
"SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium
|
||||
FROM reviews WHERE movie_id = ? ORDER BY watched_at ASC",
|
||||
)
|
||||
.bind(&id_str)
|
||||
|
||||
@@ -118,10 +118,6 @@ impl GoalRepository for SqliteGoalRepository {
|
||||
|
||||
rows.iter().map(row_to_goal).collect()
|
||||
}
|
||||
|
||||
async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result<u32, DomainError> {
|
||||
count_reviews_in_year(&self.pool, user_id, year).await
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn count_reviews_in_year(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::{Review, ReviewSource},
|
||||
ports::ReviewRepository,
|
||||
value_objects::{ReviewId, UserId},
|
||||
@@ -27,7 +26,7 @@ impl SqliteReviewRepository {
|
||||
|
||||
#[async_trait]
|
||||
impl ReviewRepository for SqliteReviewRepository {
|
||||
async fn save_review(&self, review: &Review) -> Result<DomainEvent, DomainError> {
|
||||
async fn save_review(&self, review: &Review) -> Result<(), DomainError> {
|
||||
let id = review.id().value().to_string();
|
||||
let movie_id = review.movie_id().value().to_string();
|
||||
let user_id = review.user_id().value().to_string();
|
||||
@@ -57,13 +56,7 @@ impl ReviewRepository for SqliteReviewRepository {
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
|
||||
Ok(DomainEvent::ReviewLogged {
|
||||
review_id: review.id().clone(),
|
||||
movie_id: review.movie_id().clone(),
|
||||
user_id: review.user_id().clone(),
|
||||
rating: review.rating().clone(),
|
||||
watched_at: *review.watched_at(),
|
||||
})
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError> {
|
||||
|
||||
@@ -96,6 +96,10 @@ impl StatsRepository for SqliteStatsRepository {
|
||||
})
|
||||
}
|
||||
|
||||
async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result<u32, DomainError> {
|
||||
crate::goals::count_reviews_in_year(&self.pool, user_id, year).await
|
||||
}
|
||||
|
||||
async fn get_user_trends(&self, user_id: &UserId) -> Result<UserTrends, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
|
||||
|
||||
@@ -10,4 +10,4 @@ chrono = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
|
||||
domain = { workspace = true }
|
||||
application = { workspace = true }
|
||||
api-types = { workspace = true }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
pub use askama;
|
||||
use askama::Template;
|
||||
|
||||
use application::rendering::HtmlPageContext;
|
||||
use api_types::HtmlPageContext;
|
||||
use chrono::Datelike;
|
||||
use domain::models::{
|
||||
DiaryEntry, FeedEntry, MonthActivity, MonthlyRating, ReviewSource, UserStats, UserTrends,
|
||||
|
||||
@@ -7,3 +7,4 @@ edition = "2024"
|
||||
serde = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
utoipa = { version = "5.5.0", features = ["axum_extras", "uuid"] }
|
||||
domain = { path = "../domain" }
|
||||
|
||||
@@ -14,7 +14,8 @@ pub struct LoginResponse {
|
||||
pub user_id: Uuid,
|
||||
pub email: String,
|
||||
pub expires_at: String,
|
||||
pub role: String,
|
||||
#[schema(value_type = String)]
|
||||
pub role: domain::models::UserRole,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
|
||||
@@ -18,7 +18,8 @@ pub struct LogReviewRequest {
|
||||
pub comment: Option<String>,
|
||||
pub watched_at: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub watch_medium: Option<String>,
|
||||
#[schema(value_type = Option<String>)]
|
||||
pub watch_medium: Option<domain::value_objects::WatchMedium>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
@@ -89,7 +90,8 @@ pub struct EditReviewRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub watched_at: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub watch_medium: Option<Option<String>>,
|
||||
#[schema(value_type = Option<Option<String>>)]
|
||||
pub watch_medium: Option<Option<domain::value_objects::WatchMedium>>,
|
||||
}
|
||||
|
||||
fn default_export_format() -> String {
|
||||
|
||||
@@ -7,7 +7,8 @@ pub struct GoalDto {
|
||||
pub current_count: u32,
|
||||
pub percentage: f64,
|
||||
pub is_complete: bool,
|
||||
pub goal_type: String,
|
||||
#[schema(value_type = String)]
|
||||
pub goal_type: domain::models::GoalType,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
@@ -25,17 +26,3 @@ pub struct CreateGoalRequest {
|
||||
pub struct UpdateGoalRequest {
|
||||
pub target_count: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct UserSettingsDto {
|
||||
pub federate_goals: bool,
|
||||
pub federate_reviews: bool,
|
||||
pub federate_watchlist: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)]
|
||||
pub struct UpdateUserSettingsRequest {
|
||||
pub federate_goals: bool,
|
||||
pub federate_reviews: bool,
|
||||
pub federate_watchlist: bool,
|
||||
}
|
||||
|
||||
@@ -46,29 +46,24 @@ pub struct SaveProfileRequest {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct PreviewRowData {
|
||||
pub index: usize,
|
||||
pub title: Option<String>,
|
||||
pub release_year: Option<String>,
|
||||
pub director: Option<String>,
|
||||
pub rating: Option<String>,
|
||||
pub watched_at: Option<String>,
|
||||
pub comment: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
#[serde(tag = "status")]
|
||||
pub enum PreviewRowDto {
|
||||
#[serde(rename = "valid")]
|
||||
Valid {
|
||||
index: usize,
|
||||
title: Option<String>,
|
||||
release_year: Option<String>,
|
||||
director: Option<String>,
|
||||
rating: Option<String>,
|
||||
watched_at: Option<String>,
|
||||
comment: Option<String>,
|
||||
},
|
||||
Valid(PreviewRowData),
|
||||
#[serde(rename = "duplicate")]
|
||||
Duplicate {
|
||||
index: usize,
|
||||
title: Option<String>,
|
||||
release_year: Option<String>,
|
||||
director: Option<String>,
|
||||
rating: Option<String>,
|
||||
watched_at: Option<String>,
|
||||
comment: Option<String>,
|
||||
},
|
||||
Duplicate(PreviewRowData),
|
||||
#[serde(rename = "invalid")]
|
||||
Invalid { index: usize, errors: Vec<String> },
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ pub mod diary;
|
||||
pub mod goals;
|
||||
pub mod import;
|
||||
pub mod movies;
|
||||
pub mod rendering;
|
||||
pub mod search;
|
||||
pub mod social;
|
||||
pub mod users;
|
||||
@@ -17,6 +18,7 @@ pub use diary::*;
|
||||
pub use goals::*;
|
||||
pub use import::*;
|
||||
pub use movies::*;
|
||||
pub use rendering::*;
|
||||
pub use social::*;
|
||||
pub use users::*;
|
||||
pub use watchlist::*;
|
||||
|
||||
@@ -99,7 +99,8 @@ pub struct ReviewDto {
|
||||
pub comment: Option<String>,
|
||||
pub watched_at: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub watch_medium: Option<String>,
|
||||
#[schema(value_type = Option<String>)]
|
||||
pub watch_medium: Option<domain::value_objects::WatchMedium>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
@@ -125,7 +126,8 @@ pub struct SocialReviewDto {
|
||||
pub watched_at: String,
|
||||
pub is_federated: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub watch_medium: Option<String>,
|
||||
#[schema(value_type = Option<String>)]
|
||||
pub watch_medium: Option<domain::value_objects::WatchMedium>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
|
||||
@@ -67,13 +67,23 @@ pub struct UserTrendsDto {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct UserProfileResponse {
|
||||
pub user_id: Uuid,
|
||||
pub struct UserProfileBase {
|
||||
pub username: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub display_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bio: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub avatar_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub banner_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct UserProfileResponse {
|
||||
pub user_id: Uuid,
|
||||
#[serde(flatten)]
|
||||
pub profile: UserProfileBase,
|
||||
pub stats: UserStatsDto,
|
||||
pub following_count: usize,
|
||||
pub followers_count: usize,
|
||||
@@ -90,23 +100,17 @@ pub struct UserProfileResponse {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub handle: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub display_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bio: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub actor_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct ProfileResponse {
|
||||
pub username: String,
|
||||
pub display_name: Option<String>,
|
||||
pub bio: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
pub banner_url: Option<String>,
|
||||
#[serde(flatten)]
|
||||
pub profile: UserProfileBase,
|
||||
pub also_known_as: Option<String>,
|
||||
pub fields: Vec<ProfileFieldDto>,
|
||||
pub role: String,
|
||||
#[schema(value_type = String)]
|
||||
pub role: domain::models::UserRole,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
@@ -119,3 +123,17 @@ pub struct ProfileFieldDto {
|
||||
pub struct UpdateProfileFieldsRequest {
|
||||
pub fields: Vec<ProfileFieldDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct UserSettingsDto {
|
||||
pub federate_goals: bool,
|
||||
pub federate_reviews: bool,
|
||||
pub federate_watchlist: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)]
|
||||
pub struct UpdateUserSettingsRequest {
|
||||
pub federate_goals: bool,
|
||||
pub federate_reviews: bool,
|
||||
pub federate_watchlist: bool,
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ edition = "2024"
|
||||
[dependencies]
|
||||
async-trait = { workspace = true }
|
||||
domain = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
infra-wiring = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
@@ -11,7 +11,7 @@ pub struct LoginResult {
|
||||
pub user_id: Uuid,
|
||||
pub email: String,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub role: String,
|
||||
pub role: domain::models::UserRole,
|
||||
}
|
||||
|
||||
pub async fn execute(deps: &LoginDeps, query: LoginQuery) -> Result<LoginResult, DomainError> {
|
||||
@@ -49,7 +49,7 @@ pub async fn execute(deps: &LoginDeps, query: LoginQuery) -> Result<LoginResult,
|
||||
user_id: user.id().value(),
|
||||
email: user.email().value().to_string(),
|
||||
expires_at: generated.expires_at,
|
||||
role: user.role().as_str().into(),
|
||||
role: user.role().clone(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,50 +1 @@
|
||||
#[derive(Clone)]
|
||||
pub struct AppConfig {
|
||||
pub allow_registration: bool,
|
||||
pub base_url: String,
|
||||
pub rate_limit: u64,
|
||||
pub refresh_ttl_seconds: u64,
|
||||
pub wrapup: WrapUpConfig,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct WrapUpConfig {
|
||||
pub font_path: Option<String>,
|
||||
pub logo_path: Option<String>,
|
||||
pub bg_dir: Option<String>,
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
pub fn from_env() -> Self {
|
||||
let allow_registration = std::env::var("ALLOW_REGISTRATION")
|
||||
.map(|v| v == "true" || v == "1")
|
||||
.unwrap_or(false);
|
||||
let base_url =
|
||||
std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:3000".to_string());
|
||||
let rate_limit = std::env::var("RATE_LIMIT")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(60);
|
||||
let refresh_ttl_seconds = std::env::var("REFRESH_TTL_SECONDS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(2_592_000u64);
|
||||
Self {
|
||||
allow_registration,
|
||||
base_url,
|
||||
rate_limit,
|
||||
refresh_ttl_seconds,
|
||||
wrapup: WrapUpConfig::from_env(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WrapUpConfig {
|
||||
pub fn from_env() -> Self {
|
||||
Self {
|
||||
font_path: std::env::var("WRAPUP_FONT_PATH").ok(),
|
||||
logo_path: std::env::var("WRAPUP_LOGO_PATH").ok(),
|
||||
bg_dir: std::env::var("WRAPUP_BG_DIR").ok(),
|
||||
}
|
||||
}
|
||||
}
|
||||
pub use infra_wiring::{AppConfig, WrapUpConfig};
|
||||
|
||||
@@ -6,6 +6,7 @@ use domain::{
|
||||
FeedEntry,
|
||||
collections::{PageParams, Paginated},
|
||||
},
|
||||
value_objects::UserId,
|
||||
};
|
||||
|
||||
pub async fn execute(
|
||||
@@ -34,9 +35,10 @@ async fn build_following_filter(
|
||||
return None;
|
||||
}
|
||||
let viewer_id = query.viewer_user_id?;
|
||||
let viewer = UserId::from_uuid(viewer_id);
|
||||
let urls = deps
|
||||
.social_query
|
||||
.get_accepted_following_urls(viewer_id)
|
||||
.get_accepted_following_urls(&viewer)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
if urls.is_empty() {
|
||||
|
||||
@@ -4,15 +4,15 @@ use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::{Movie, Review},
|
||||
models::Review,
|
||||
ports::{
|
||||
EventPublisher, MetadataClient, MovieRepository, ReviewRepository, WatchlistRepository,
|
||||
},
|
||||
value_objects::{Comment, MovieId, Rating, UserId},
|
||||
value_objects::{Comment, Rating, UserId},
|
||||
};
|
||||
|
||||
use crate::diary::commands::LogReviewCommand;
|
||||
use crate::diary::movie_resolver::{MovieResolver, MovieResolverDeps};
|
||||
use crate::movies::resolve::resolve_and_persist_movie;
|
||||
use crate::ports::ReviewLogger;
|
||||
|
||||
pub struct DefaultReviewLogger {
|
||||
@@ -48,25 +48,18 @@ impl ReviewLogger for DefaultReviewLogger {
|
||||
let user_id = UserId::from_uuid(cmd.user_id);
|
||||
let comment = cmd.comment.clone().map(Comment::new).transpose()?;
|
||||
|
||||
let (movie, is_new_movie) = if let Some(id) = cmd.input.movie_id {
|
||||
let movie_id = MovieId::from_uuid(id);
|
||||
let movie = self
|
||||
.movie_repo
|
||||
.get_movie_by_id(&movie_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound(format!("Movie {id}")))?;
|
||||
(movie, false)
|
||||
} else {
|
||||
let deps = MovieResolverDeps {
|
||||
repository: self.movie_repo.as_ref(),
|
||||
metadata_client: self.metadata_client.as_ref(),
|
||||
};
|
||||
MovieResolver::default_pipeline()
|
||||
.resolve(&cmd.input, &deps)
|
||||
.await?
|
||||
};
|
||||
let (movie, is_new_movie) = resolve_and_persist_movie(
|
||||
&cmd.input,
|
||||
self.movie_repo.as_ref(),
|
||||
self.metadata_client.as_ref(),
|
||||
self.event_publisher.as_ref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Always upsert: even existing movies may have updated metadata
|
||||
if !is_new_movie {
|
||||
self.movie_repo.upsert_movie(&movie).await?;
|
||||
}
|
||||
|
||||
let review = Review::new(
|
||||
movie.id().clone(),
|
||||
@@ -76,7 +69,14 @@ impl ReviewLogger for DefaultReviewLogger {
|
||||
cmd.watched_at,
|
||||
cmd.watch_medium,
|
||||
)?;
|
||||
let review_event = self.review_repo.save_review(&review).await?;
|
||||
self.review_repo.save_review(&review).await?;
|
||||
let review_event = DomainEvent::ReviewLogged {
|
||||
review_id: review.id().clone(),
|
||||
movie_id: review.movie_id().clone(),
|
||||
user_id: review.user_id().clone(),
|
||||
rating: review.rating().clone(),
|
||||
watched_at: *review.watched_at(),
|
||||
};
|
||||
|
||||
let was_on_watchlist = self
|
||||
.watchlist_repo
|
||||
@@ -92,27 +92,8 @@ impl ReviewLogger for DefaultReviewLogger {
|
||||
.await;
|
||||
}
|
||||
|
||||
publish_events(&self.event_publisher, &movie, is_new_movie, review_event).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn publish_events(
|
||||
publisher: &Arc<dyn EventPublisher>,
|
||||
movie: &Movie,
|
||||
is_new_movie: bool,
|
||||
review_event: DomainEvent,
|
||||
) -> Result<(), DomainError> {
|
||||
if is_new_movie && let Some(ext_id) = movie.external_metadata_id() {
|
||||
publisher
|
||||
.publish(&DomainEvent::MovieDiscovered {
|
||||
movie_id: movie.id().clone(),
|
||||
external_metadata_id: ext_id.clone(),
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(ext_id) = movie.external_metadata_id() {
|
||||
publisher
|
||||
self.event_publisher
|
||||
.publish(&DomainEvent::MovieEnrichmentRequested {
|
||||
movie_id: movie.id().clone(),
|
||||
external_metadata_id: ext_id.clone(),
|
||||
@@ -120,7 +101,8 @@ async fn publish_events(
|
||||
.await?;
|
||||
}
|
||||
|
||||
publisher.publish(&review_event).await
|
||||
self.event_publisher.publish(&review_event).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -68,18 +68,27 @@ struct FakeSocialWithFollowing(Vec<String>);
|
||||
|
||||
#[async_trait]
|
||||
impl domain::ports::SocialQueryPort for FakeSocialWithFollowing {
|
||||
async fn get_accepted_following_urls(&self, _: uuid::Uuid) -> Result<Vec<String>, DomainError> {
|
||||
async fn get_accepted_following_urls(
|
||||
&self,
|
||||
_: &domain::value_objects::UserId,
|
||||
) -> Result<Vec<String>, DomainError> {
|
||||
Ok(self.0.clone())
|
||||
}
|
||||
async fn count_following(&self, _: uuid::Uuid) -> Result<usize, DomainError> {
|
||||
async fn count_following(
|
||||
&self,
|
||||
_: &domain::value_objects::UserId,
|
||||
) -> Result<usize, DomainError> {
|
||||
Ok(0)
|
||||
}
|
||||
async fn count_accepted_followers(&self, _: uuid::Uuid) -> Result<usize, DomainError> {
|
||||
async fn count_accepted_followers(
|
||||
&self,
|
||||
_: &domain::value_objects::UserId,
|
||||
) -> Result<usize, DomainError> {
|
||||
Ok(0)
|
||||
}
|
||||
async fn get_pending_followers(
|
||||
&self,
|
||||
_: uuid::Uuid,
|
||||
_: &domain::value_objects::UserId,
|
||||
) -> Result<Vec<domain::models::PendingFollowerInfo>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::{Goal, GoalType, GoalWithProgress},
|
||||
ports::{EventPublisher, GoalRepository},
|
||||
ports::{EventPublisher, GoalRepository, StatsRepository},
|
||||
value_objects::UserId,
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ use super::commands::CreateGoalCommand;
|
||||
|
||||
pub async fn execute(
|
||||
goal: Arc<dyn GoalRepository>,
|
||||
stats: Arc<dyn StatsRepository>,
|
||||
event_publisher: Arc<dyn EventPublisher>,
|
||||
cmd: CreateGoalCommand,
|
||||
) -> Result<GoalWithProgress, DomainError> {
|
||||
@@ -32,7 +33,7 @@ pub async fn execute(
|
||||
)?;
|
||||
goal.save(&g).await?;
|
||||
|
||||
let current_count = goal.count_reviews_in_year(&user_id, cmd.year).await?;
|
||||
let current_count = stats.count_reviews_in_year(&user_id, cmd.year).await?;
|
||||
|
||||
event_publisher
|
||||
.publish(&DomainEvent::GoalCreated {
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::{
|
||||
errors::DomainError, models::GoalWithProgress, ports::GoalRepository, value_objects::UserId,
|
||||
errors::DomainError,
|
||||
models::GoalWithProgress,
|
||||
ports::{GoalRepository, StatsRepository},
|
||||
value_objects::UserId,
|
||||
};
|
||||
|
||||
use super::queries::GetGoalQuery;
|
||||
|
||||
pub async fn execute(
|
||||
goal: Arc<dyn GoalRepository>,
|
||||
stats: Arc<dyn StatsRepository>,
|
||||
query: GetGoalQuery,
|
||||
) -> Result<Option<GoalWithProgress>, DomainError> {
|
||||
let user_id = UserId::from_uuid(query.user_id);
|
||||
@@ -16,7 +20,7 @@ pub async fn execute(
|
||||
|
||||
let Some(g) = found else { return Ok(None) };
|
||||
|
||||
let current_count = goal.count_reviews_in_year(&user_id, query.year).await?;
|
||||
let current_count = stats.count_reviews_in_year(&user_id, query.year).await?;
|
||||
|
||||
Ok(Some(GoalWithProgress {
|
||||
goal: g,
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::{
|
||||
errors::DomainError, models::GoalWithProgress, ports::GoalRepository, value_objects::UserId,
|
||||
errors::DomainError,
|
||||
models::GoalWithProgress,
|
||||
ports::{GoalRepository, StatsRepository},
|
||||
value_objects::UserId,
|
||||
};
|
||||
|
||||
use super::queries::ListGoalsQuery;
|
||||
|
||||
pub async fn execute(
|
||||
goal: Arc<dyn GoalRepository>,
|
||||
stats: Arc<dyn StatsRepository>,
|
||||
query: ListGoalsQuery,
|
||||
) -> Result<Vec<GoalWithProgress>, DomainError> {
|
||||
let user_id = UserId::from_uuid(query.user_id);
|
||||
@@ -15,7 +19,7 @@ pub async fn execute(
|
||||
|
||||
let mut result = Vec::with_capacity(goals.len());
|
||||
for g in goals {
|
||||
let current_count = goal.count_reviews_in_year(&user_id, g.year()).await?;
|
||||
let current_count = stats.count_reviews_in_year(&user_id, g.year()).await?;
|
||||
result.push(GoalWithProgress {
|
||||
goal: g,
|
||||
current_count,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::events::DomainEvent;
|
||||
use domain::testing::{InMemoryGoalRepository, NoopEventPublisher};
|
||||
use domain::testing::{FakeStatsRepository, InMemoryGoalRepository, NoopEventPublisher};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::goals::{commands::CreateGoalCommand, create};
|
||||
@@ -10,10 +10,12 @@ use crate::test_helpers::TestContextBuilder;
|
||||
#[tokio::test]
|
||||
async fn creates_goal_and_returns_progress() {
|
||||
let goals = InMemoryGoalRepository::new();
|
||||
let stats = FakeStatsRepository::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
|
||||
let result = create::execute(
|
||||
Arc::clone(&goals) as _,
|
||||
Arc::clone(&stats) as _,
|
||||
Arc::clone(&events) as _,
|
||||
CreateGoalCommand {
|
||||
user_id: Uuid::nil(),
|
||||
@@ -33,11 +35,13 @@ async fn creates_goal_and_returns_progress() {
|
||||
#[tokio::test]
|
||||
async fn creates_goal_with_review_count() {
|
||||
let goals = InMemoryGoalRepository::new();
|
||||
goals.set_review_count(Uuid::nil(), 2025, 5);
|
||||
let stats = FakeStatsRepository::new();
|
||||
stats.set_review_count(Uuid::nil(), 2025, 5);
|
||||
let events = NoopEventPublisher::new();
|
||||
|
||||
let result = create::execute(
|
||||
Arc::clone(&goals) as _,
|
||||
Arc::clone(&stats) as _,
|
||||
Arc::clone(&events) as _,
|
||||
CreateGoalCommand {
|
||||
user_id: Uuid::nil(),
|
||||
@@ -59,6 +63,7 @@ async fn emits_goal_created_event() {
|
||||
|
||||
create::execute(
|
||||
b.goal_repo.clone(),
|
||||
b.stats_repo.clone(),
|
||||
Arc::clone(&events) as _,
|
||||
CreateGoalCommand {
|
||||
user_id: Uuid::nil(),
|
||||
@@ -86,12 +91,18 @@ async fn rejects_duplicate_year() {
|
||||
target_count: 10,
|
||||
};
|
||||
|
||||
create::execute(b.goal_repo.clone(), b.event_publisher.clone(), cmd)
|
||||
create::execute(
|
||||
b.goal_repo.clone(),
|
||||
b.stats_repo.clone(),
|
||||
b.event_publisher.clone(),
|
||||
cmd,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result = create::execute(
|
||||
b.goal_repo.clone(),
|
||||
b.stats_repo.clone(),
|
||||
b.event_publisher.clone(),
|
||||
CreateGoalCommand {
|
||||
user_id: Uuid::nil(),
|
||||
@@ -109,6 +120,7 @@ async fn rejects_year_before_2020() {
|
||||
let b = TestContextBuilder::new();
|
||||
let result = create::execute(
|
||||
b.goal_repo.clone(),
|
||||
b.stats_repo.clone(),
|
||||
b.event_publisher.clone(),
|
||||
CreateGoalCommand {
|
||||
user_id: Uuid::nil(),
|
||||
@@ -126,6 +138,7 @@ async fn rejects_zero_target() {
|
||||
let b = TestContextBuilder::new();
|
||||
let result = create::execute(
|
||||
b.goal_repo.clone(),
|
||||
b.stats_repo.clone(),
|
||||
b.event_publisher.clone(),
|
||||
CreateGoalCommand {
|
||||
user_id: Uuid::nil(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::testing::{InMemoryGoalRepository, NoopEventPublisher};
|
||||
use domain::testing::{FakeStatsRepository, InMemoryGoalRepository, NoopEventPublisher};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::goals::{
|
||||
@@ -12,10 +12,12 @@ use crate::test_helpers::TestContextBuilder;
|
||||
#[tokio::test]
|
||||
async fn deletes_existing_goal() {
|
||||
let goals = InMemoryGoalRepository::new();
|
||||
let stats = FakeStatsRepository::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
|
||||
create::execute(
|
||||
Arc::clone(&goals) as _,
|
||||
Arc::clone(&stats) as _,
|
||||
Arc::clone(&events) as _,
|
||||
CreateGoalCommand {
|
||||
user_id: Uuid::nil(),
|
||||
|
||||
@@ -8,6 +8,7 @@ async fn returns_goal_when_exists() {
|
||||
let b = TestContextBuilder::new();
|
||||
create::execute(
|
||||
b.goal_repo.clone(),
|
||||
b.stats_repo.clone(),
|
||||
b.event_publisher.clone(),
|
||||
CreateGoalCommand {
|
||||
user_id: Uuid::nil(),
|
||||
@@ -20,6 +21,7 @@ async fn returns_goal_when_exists() {
|
||||
|
||||
let result = get::execute(
|
||||
b.goal_repo.clone(),
|
||||
b.stats_repo.clone(),
|
||||
GetGoalQuery {
|
||||
user_id: Uuid::nil(),
|
||||
year: 2025,
|
||||
@@ -37,6 +39,7 @@ async fn returns_none_when_missing() {
|
||||
let b = TestContextBuilder::new();
|
||||
let result = get::execute(
|
||||
b.goal_repo.clone(),
|
||||
b.stats_repo.clone(),
|
||||
GetGoalQuery {
|
||||
user_id: Uuid::nil(),
|
||||
year: 2025,
|
||||
|
||||
@@ -8,6 +8,7 @@ async fn returns_empty_when_no_goals() {
|
||||
let b = TestContextBuilder::new();
|
||||
let result = list::execute(
|
||||
b.goal_repo.clone(),
|
||||
b.stats_repo.clone(),
|
||||
ListGoalsQuery {
|
||||
user_id: Uuid::nil(),
|
||||
},
|
||||
@@ -24,6 +25,7 @@ async fn returns_all_goals_for_user() {
|
||||
for year in [2023, 2024, 2025] {
|
||||
create::execute(
|
||||
b.goal_repo.clone(),
|
||||
b.stats_repo.clone(),
|
||||
b.event_publisher.clone(),
|
||||
CreateGoalCommand {
|
||||
user_id: Uuid::nil(),
|
||||
@@ -37,6 +39,7 @@ async fn returns_all_goals_for_user() {
|
||||
|
||||
let result = list::execute(
|
||||
b.goal_repo.clone(),
|
||||
b.stats_repo.clone(),
|
||||
ListGoalsQuery {
|
||||
user_id: Uuid::nil(),
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@ async fn updates_target_count() {
|
||||
let b = TestContextBuilder::new();
|
||||
create::execute(
|
||||
b.goal_repo.clone(),
|
||||
b.stats_repo.clone(),
|
||||
b.event_publisher.clone(),
|
||||
CreateGoalCommand {
|
||||
user_id: Uuid::nil(),
|
||||
@@ -23,6 +24,7 @@ async fn updates_target_count() {
|
||||
|
||||
let result = update::execute(
|
||||
b.goal_repo.clone(),
|
||||
b.stats_repo.clone(),
|
||||
b.event_publisher.clone(),
|
||||
UpdateGoalCommand {
|
||||
user_id: Uuid::nil(),
|
||||
@@ -41,6 +43,7 @@ async fn fails_when_goal_not_found() {
|
||||
let b = TestContextBuilder::new();
|
||||
let result = update::execute(
|
||||
b.goal_repo.clone(),
|
||||
b.stats_repo.clone(),
|
||||
b.event_publisher.clone(),
|
||||
UpdateGoalCommand {
|
||||
user_id: Uuid::nil(),
|
||||
@@ -58,6 +61,7 @@ async fn rejects_zero_target() {
|
||||
let b = TestContextBuilder::new();
|
||||
create::execute(
|
||||
b.goal_repo.clone(),
|
||||
b.stats_repo.clone(),
|
||||
b.event_publisher.clone(),
|
||||
CreateGoalCommand {
|
||||
user_id: Uuid::nil(),
|
||||
@@ -70,6 +74,7 @@ async fn rejects_zero_target() {
|
||||
|
||||
let result = update::execute(
|
||||
b.goal_repo.clone(),
|
||||
b.stats_repo.clone(),
|
||||
b.event_publisher.clone(),
|
||||
UpdateGoalCommand {
|
||||
user_id: Uuid::nil(),
|
||||
|
||||
@@ -4,7 +4,7 @@ use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::GoalWithProgress,
|
||||
ports::{EventPublisher, GoalRepository},
|
||||
ports::{EventPublisher, GoalRepository, StatsRepository},
|
||||
value_objects::UserId,
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ use super::commands::UpdateGoalCommand;
|
||||
|
||||
pub async fn execute(
|
||||
goal: Arc<dyn GoalRepository>,
|
||||
stats: Arc<dyn StatsRepository>,
|
||||
event_publisher: Arc<dyn EventPublisher>,
|
||||
cmd: UpdateGoalCommand,
|
||||
) -> Result<GoalWithProgress, DomainError> {
|
||||
@@ -25,7 +26,7 @@ pub async fn execute(
|
||||
g.update_target(cmd.target_count)?;
|
||||
goal.update(&g).await?;
|
||||
|
||||
let current_count = goal.count_reviews_in_year(&user_id, cmd.year).await?;
|
||||
let current_count = stats.count_reviews_in_year(&user_id, cmd.year).await?;
|
||||
|
||||
event_publisher
|
||||
.publish(&DomainEvent::GoalUpdated {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
pub mod config;
|
||||
pub mod jobs;
|
||||
pub mod ports;
|
||||
pub mod rendering;
|
||||
pub mod worker;
|
||||
|
||||
pub mod auth;
|
||||
|
||||
@@ -6,7 +6,7 @@ use domain::{
|
||||
events::DomainEvent,
|
||||
models::MovieProfile,
|
||||
ports::{
|
||||
EventHandler, MovieEnrichmentClient, MovieProfileRepository, MovieRepository,
|
||||
EventHandler, ImageFetcher, MovieEnrichmentClient, MovieProfileRepository, MovieRepository,
|
||||
ObjectStorage, PersonCommand, SearchCommand,
|
||||
},
|
||||
};
|
||||
@@ -22,7 +22,7 @@ pub struct MovieEnrichmentHandler {
|
||||
person_command: Arc<dyn PersonCommand>,
|
||||
search_command: Arc<dyn SearchCommand>,
|
||||
object_storage: Arc<dyn ObjectStorage>,
|
||||
http: reqwest::Client,
|
||||
image_fetcher: Arc<dyn ImageFetcher>,
|
||||
}
|
||||
|
||||
impl MovieEnrichmentHandler {
|
||||
@@ -33,6 +33,7 @@ impl MovieEnrichmentHandler {
|
||||
person_command: Arc<dyn PersonCommand>,
|
||||
search_command: Arc<dyn SearchCommand>,
|
||||
object_storage: Arc<dyn ObjectStorage>,
|
||||
image_fetcher: Arc<dyn ImageFetcher>,
|
||||
) -> Self {
|
||||
Self {
|
||||
enrichment_client,
|
||||
@@ -41,7 +42,7 @@ impl MovieEnrichmentHandler {
|
||||
person_command,
|
||||
search_command,
|
||||
object_storage,
|
||||
http: reqwest::Client::new(),
|
||||
image_fetcher,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,15 +56,13 @@ impl MovieEnrichmentHandler {
|
||||
continue;
|
||||
}
|
||||
let url = format!("https://image.tmdb.org/t/p/w185{path}");
|
||||
match self.http.get(&url).send().await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
if let Ok(bytes) = resp.bytes().await
|
||||
&& let Err(e) = self.object_storage.store(&key, &bytes).await
|
||||
{
|
||||
match self.image_fetcher.fetch_image(&url).await {
|
||||
Ok(bytes) => {
|
||||
if let Err(e) = self.object_storage.store(&key, &bytes).await {
|
||||
tracing::debug!("cast photo store failed for {path}: {e}");
|
||||
}
|
||||
}
|
||||
_ => tracing::debug!("cast photo download failed for {path}"),
|
||||
Err(_) => tracing::debug!("cast photo download failed for {path}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ pub mod merge_duplicates;
|
||||
pub mod queries;
|
||||
pub mod reindex_search;
|
||||
pub mod request_enrichment;
|
||||
pub mod resolve;
|
||||
pub mod search_cleanup;
|
||||
pub mod sync_poster;
|
||||
|
||||
|
||||
51
crates/application/src/movies/resolve.rs
Normal file
51
crates/application/src/movies/resolve.rs
Normal 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))
|
||||
}
|
||||
@@ -1,13 +1,10 @@
|
||||
use chrono::Utc;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::{Person, PersonId},
|
||||
};
|
||||
|
||||
use super::deps::GetPersonDeps;
|
||||
|
||||
const ENRICHMENT_TTL_DAYS: i64 = 90;
|
||||
use super::{deps::GetPersonDeps, should_enrich};
|
||||
|
||||
pub async fn execute(deps: &GetPersonDeps, id: PersonId) -> Result<Option<Person>, DomainError> {
|
||||
let person = deps.person_query.get_by_id(&id).await?;
|
||||
@@ -25,13 +22,6 @@ pub async fn execute(deps: &GetPersonDeps, id: PersonId) -> Result<Option<Person
|
||||
Ok(person)
|
||||
}
|
||||
|
||||
fn should_enrich(p: &Person) -> bool {
|
||||
match p.enriched_at() {
|
||||
None => true,
|
||||
Some(at) => (Utc::now() - at).num_days() >= ENRICHMENT_TTL_DAYS,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/get.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
use chrono::Utc;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::{Person, PersonCredits, PersonId},
|
||||
models::{PersonCredits, PersonId},
|
||||
};
|
||||
|
||||
use super::deps::GetPersonDeps;
|
||||
|
||||
const ENRICHMENT_TTL_DAYS: i64 = 90;
|
||||
use super::{deps::GetPersonDeps, should_enrich};
|
||||
|
||||
pub async fn execute(deps: &GetPersonDeps, id: PersonId) -> Result<PersonCredits, DomainError> {
|
||||
let credits = deps.person_query.get_credits(&id).await?;
|
||||
@@ -23,13 +20,6 @@ pub async fn execute(deps: &GetPersonDeps, id: PersonId) -> Result<PersonCredits
|
||||
Ok(credits)
|
||||
}
|
||||
|
||||
fn should_enrich(p: &Person) -> bool {
|
||||
match p.enriched_at() {
|
||||
None => true,
|
||||
Some(at) => (Utc::now() - at).num_days() >= ENRICHMENT_TTL_DAYS,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/get_credits.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -5,3 +5,15 @@ pub mod get;
|
||||
pub mod get_credits;
|
||||
|
||||
pub use event_handler::PersonEnrichmentHandler;
|
||||
|
||||
use chrono::Utc;
|
||||
use domain::models::Person;
|
||||
|
||||
pub(crate) const ENRICHMENT_TTL_DAYS: i64 = 90;
|
||||
|
||||
pub(crate) fn should_enrich(p: &Person) -> bool {
|
||||
match p.enriched_at() {
|
||||
None => true,
|
||||
Some(at) => (Utc::now() - at).num_days() >= ENRICHMENT_TTL_DAYS,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
use domain::errors::DomainError;
|
||||
use domain::models::DiaryEntry;
|
||||
|
||||
use crate::diary::commands::LogReviewCommand;
|
||||
|
||||
@@ -9,7 +8,3 @@ use crate::diary::commands::LogReviewCommand;
|
||||
pub trait ReviewLogger: Send + Sync {
|
||||
async fn log_review(&self, cmd: LogReviewCommand) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
pub trait RssFeedRenderer: Send + Sync {
|
||||
fn render_feed(&self, entries: &[DiaryEntry], title: &str) -> Result<String, String>;
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ impl TestContextBuilder {
|
||||
diary_repo: FakeDiaryRepository::new(),
|
||||
diary_exporter: Arc::new(PanicDiaryExporter),
|
||||
document_parser: Arc::new(FakeDocumentParser),
|
||||
stats_repo: Arc::new(FakeStatsRepository),
|
||||
stats_repo: FakeStatsRepository::new(),
|
||||
metadata_client: Arc::new(FakeMetadataClient),
|
||||
poster_fetcher: Arc::new(FakePosterFetcher),
|
||||
object_storage: Arc::new(NoopObjectStorage),
|
||||
|
||||
@@ -17,7 +17,7 @@ pub struct CurrentProfileData {
|
||||
pub banner_path: Option<String>,
|
||||
pub also_known_as: Option<String>,
|
||||
pub fields: Vec<ProfileFieldData>,
|
||||
pub role: String,
|
||||
pub role: domain::models::UserRole,
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
@@ -47,7 +47,7 @@ pub async fn execute(
|
||||
banner_path: found.banner_path().map(|s| s.to_string()),
|
||||
also_known_as: found.also_known_as().map(|s| s.to_string()),
|
||||
fields,
|
||||
role: found.role().as_str().into(),
|
||||
role: found.role().clone(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ pub async fn execute(
|
||||
let stats = deps.stats.get_user_stats(&user_id).await?;
|
||||
|
||||
let (following_count, followers_count, pending_followers) =
|
||||
load_social_counts(deps, query.user_id, query.is_own_profile).await;
|
||||
load_social_counts(deps, &user_id, query.is_own_profile).await;
|
||||
|
||||
let base = |entries, history, trends| UserProfileData {
|
||||
stats,
|
||||
@@ -76,7 +76,7 @@ pub async fn execute(
|
||||
|
||||
async fn load_social_counts(
|
||||
deps: &GetProfileDeps,
|
||||
user_id: uuid::Uuid,
|
||||
user_id: &UserId,
|
||||
is_own_profile: bool,
|
||||
) -> (usize, usize, Vec<PendingFollowerView>) {
|
||||
let following = deps
|
||||
|
||||
@@ -9,3 +9,52 @@ pub mod queries;
|
||||
pub mod update_profile;
|
||||
pub mod update_profile_fields;
|
||||
pub mod update_settings;
|
||||
|
||||
use chrono::Datelike;
|
||||
use domain::models::{DiaryEntry, MonthActivity};
|
||||
|
||||
pub fn group_by_month(entries: Vec<DiaryEntry>) -> Vec<MonthActivity> {
|
||||
use std::collections::BTreeMap;
|
||||
let mut map: BTreeMap<(i32, u32), Vec<DiaryEntry>> = BTreeMap::new();
|
||||
for entry in entries {
|
||||
let watched_at = entry.review().watched_at();
|
||||
let year = watched_at.year();
|
||||
let month = watched_at.month();
|
||||
map.entry((year, month)).or_default().push(entry);
|
||||
}
|
||||
map.into_iter()
|
||||
.rev()
|
||||
.map(|((year, month), entries)| {
|
||||
let year_month = format!("{:04}-{:02}", year, month);
|
||||
MonthActivity {
|
||||
month_label: format_year_month_long(&year_month),
|
||||
count: entries.len() as i64,
|
||||
entries,
|
||||
year_month,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn format_year_month_long(ym: &str) -> String {
|
||||
let parts: Vec<&str> = ym.splitn(2, '-').collect();
|
||||
if parts.len() != 2 {
|
||||
return ym.to_string();
|
||||
}
|
||||
let month = match parts[1] {
|
||||
"01" => "January",
|
||||
"02" => "February",
|
||||
"03" => "March",
|
||||
"04" => "April",
|
||||
"05" => "May",
|
||||
"06" => "June",
|
||||
"07" => "July",
|
||||
"08" => "August",
|
||||
"09" => "September",
|
||||
"10" => "October",
|
||||
"11" => "November",
|
||||
"12" => "December",
|
||||
_ => parts[1],
|
||||
};
|
||||
format!("{} {}", month, parts[0])
|
||||
}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::WatchlistEntry,
|
||||
value_objects::{MovieId, UserId},
|
||||
errors::DomainError, events::DomainEvent, models::WatchlistEntry, value_objects::UserId,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
diary::movie_resolver::{MovieResolver, MovieResolverDeps},
|
||||
movies::resolve::resolve_and_persist_movie,
|
||||
watchlist::{commands::AddToWatchlistCommand, deps::WatchlistAddDeps},
|
||||
};
|
||||
|
||||
@@ -16,34 +13,13 @@ pub async fn execute(
|
||||
) -> Result<(), DomainError> {
|
||||
let user_id = UserId::from_uuid(cmd.user_id);
|
||||
|
||||
let movie = if let Some(id) = cmd.input.movie_id {
|
||||
let movie_id = MovieId::from_uuid(id);
|
||||
deps.movie
|
||||
.get_movie_by_id(&movie_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound(format!("Movie {id}")))?
|
||||
} else {
|
||||
let resolver_deps = MovieResolverDeps {
|
||||
repository: deps.movie.as_ref(),
|
||||
metadata_client: deps.metadata.as_ref(),
|
||||
};
|
||||
let (movie, is_new) = MovieResolver::default_pipeline()
|
||||
.resolve(&cmd.input, &resolver_deps)
|
||||
let (movie, _is_new) = resolve_and_persist_movie(
|
||||
&cmd.input,
|
||||
deps.movie.as_ref(),
|
||||
deps.metadata.as_ref(),
|
||||
deps.event_publisher.as_ref(),
|
||||
)
|
||||
.await?;
|
||||
if is_new {
|
||||
deps.movie.upsert_movie(&movie).await?;
|
||||
if let Some(ext_id) = movie.external_metadata_id() {
|
||||
let _ = deps
|
||||
.event_publisher
|
||||
.publish(&DomainEvent::MovieDiscovered {
|
||||
movie_id: movie.id().clone(),
|
||||
external_metadata_id: ext_id.clone(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
movie
|
||||
};
|
||||
|
||||
let entry = WatchlistEntry::new(user_id.clone(), movie.id().clone());
|
||||
deps.watchlist.add(&entry).await?;
|
||||
|
||||
@@ -19,6 +19,16 @@ pub struct FederationFlags {
|
||||
pub watchlist: bool,
|
||||
}
|
||||
|
||||
impl Default for FederationFlags {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
goals: true,
|
||||
reviews: true,
|
||||
watchlist: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FederatedProfile {
|
||||
pub actor_url: String,
|
||||
|
||||
@@ -57,7 +57,8 @@ pub use search::{
|
||||
|
||||
use crate::errors::DomainError;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum GoalType {
|
||||
Movies,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::value_objects::{Email, PasswordHash, UserId, Username};
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum UserRole {
|
||||
#[default]
|
||||
Standard,
|
||||
|
||||
@@ -2,7 +2,6 @@ use async_trait::async_trait;
|
||||
|
||||
use crate::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::{
|
||||
DiaryEntry, DiaryFilter, ExportFormat, FeedEntry, FeedSortBy, FollowingFilter, MovieStats,
|
||||
Review, ReviewHistory, UserStats, UserTrends,
|
||||
@@ -43,7 +42,7 @@ pub trait DiaryRepository: Send + Sync {
|
||||
|
||||
#[async_trait]
|
||||
pub trait ReviewRepository: Send + Sync {
|
||||
async fn save_review(&self, review: &Review) -> Result<DomainEvent, DomainError>;
|
||||
async fn save_review(&self, review: &Review) -> Result<(), DomainError>;
|
||||
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError>;
|
||||
async fn update_review(&self, review: &Review) -> Result<(), DomainError>;
|
||||
async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError>;
|
||||
@@ -54,6 +53,7 @@ pub trait ReviewRepository: Send + Sync {
|
||||
pub trait StatsRepository: Send + Sync {
|
||||
async fn get_user_stats(&self, user_id: &UserId) -> Result<UserStats, DomainError>;
|
||||
async fn get_user_trends(&self, user_id: &UserId) -> Result<UserTrends, DomainError>;
|
||||
async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result<u32, DomainError>;
|
||||
}
|
||||
|
||||
pub trait DiaryExporter: Send + Sync {
|
||||
|
||||
@@ -17,5 +17,4 @@ pub trait GoalRepository: Send + Sync {
|
||||
year: u16,
|
||||
) -> Result<Option<Goal>, DomainError>;
|
||||
async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<Goal>, DomainError>;
|
||||
async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result<u32, DomainError>;
|
||||
}
|
||||
|
||||
8
crates/domain/src/ports/image_fetcher.rs
Normal file
8
crates/domain/src/ports/image_fetcher.rs
Normal 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>;
|
||||
}
|
||||
@@ -3,12 +3,14 @@ pub mod diary;
|
||||
pub mod events;
|
||||
pub mod federated_profile;
|
||||
pub mod goals;
|
||||
pub mod image_fetcher;
|
||||
pub mod images;
|
||||
pub mod import;
|
||||
pub mod jobs;
|
||||
pub mod media_server;
|
||||
pub mod movie;
|
||||
pub mod person;
|
||||
pub mod rss;
|
||||
pub mod search;
|
||||
pub mod social;
|
||||
pub mod watchlist;
|
||||
@@ -19,12 +21,14 @@ pub use diary::*;
|
||||
pub use events::*;
|
||||
pub use federated_profile::*;
|
||||
pub use goals::*;
|
||||
pub use image_fetcher::*;
|
||||
pub use images::*;
|
||||
pub use import::*;
|
||||
pub use jobs::*;
|
||||
pub use media_server::*;
|
||||
pub use movie::*;
|
||||
pub use person::*;
|
||||
pub use rss::*;
|
||||
pub use search::*;
|
||||
pub use social::*;
|
||||
pub use watchlist::*;
|
||||
|
||||
5
crates/domain/src/ports/rss.rs
Normal file
5
crates/domain/src/ports/rss.rs
Normal 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>;
|
||||
}
|
||||
@@ -4,24 +4,24 @@ use chrono::NaiveDateTime;
|
||||
use crate::{
|
||||
errors::DomainError,
|
||||
models::{
|
||||
DiaryEntry, FederationFlags, Goal, Movie, PendingFollowerInfo, RemoteActorInfo,
|
||||
RemoteGoalEntry, RemoteWatchlistEntry, Review, WatchlistWithMovie,
|
||||
DiaryEntry, FederationFlags, PendingFollowerInfo, RemoteActorInfo, RemoteGoalEntry,
|
||||
RemoteWatchlistEntry, WatchlistWithMovie,
|
||||
},
|
||||
value_objects::{MovieId, ReviewId, UserId},
|
||||
value_objects::{MovieId, UserId},
|
||||
};
|
||||
|
||||
#[async_trait]
|
||||
pub trait SocialQueryPort: Send + Sync {
|
||||
async fn get_accepted_following_urls(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
user_id: &UserId,
|
||||
) -> Result<Vec<String>, DomainError>;
|
||||
async fn list_all_followed_remote_actors(&self) -> Result<Vec<RemoteActorInfo>, DomainError>;
|
||||
async fn count_following(&self, user_id: uuid::Uuid) -> Result<usize, DomainError>;
|
||||
async fn count_accepted_followers(&self, user_id: uuid::Uuid) -> Result<usize, DomainError>;
|
||||
async fn count_following(&self, user_id: &UserId) -> Result<usize, DomainError>;
|
||||
async fn count_accepted_followers(&self, user_id: &UserId) -> Result<usize, DomainError>;
|
||||
async fn get_pending_followers(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
user_id: &UserId,
|
||||
) -> Result<Vec<PendingFollowerInfo>, DomainError>;
|
||||
}
|
||||
|
||||
@@ -60,25 +60,16 @@ pub trait RemoteGoalRepository: Send + Sync {
|
||||
async fn get_by_actor_url(&self, actor_url: &str) -> Result<Vec<RemoteGoalEntry>, DomainError>;
|
||||
}
|
||||
|
||||
/// Read-only query port used exclusively by the ActivityPub adapter.
|
||||
/// Consolidates all reads the AP adapter needs so it never touches write repositories.
|
||||
/// Federation-specific read-only queries that have no equivalent on the
|
||||
/// standard domain ports (e.g. unpaginated watchlist, local-only review
|
||||
/// listings). Generic lookups (get_movie_by_id, get_review_by_id, etc.)
|
||||
/// live on MovieRepository, ReviewRepository, and the other domain ports.
|
||||
#[async_trait]
|
||||
pub trait LocalApContentQuery: Send + Sync {
|
||||
async fn get_local_reviews_for_user(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
) -> Result<Vec<DiaryEntry>, DomainError>;
|
||||
async fn get_local_watchlist_for_user(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
) -> Result<Vec<WatchlistWithMovie>, DomainError>;
|
||||
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError>;
|
||||
async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result<Option<Movie>, DomainError>;
|
||||
async fn get_movie_by_external_metadata_id(
|
||||
&self,
|
||||
external_id: &str,
|
||||
) -> Result<Option<Movie>, DomainError>;
|
||||
async fn count_local_posts(&self) -> Result<u64, DomainError>;
|
||||
async fn get_local_reviews_for_movie(
|
||||
&self,
|
||||
movie_id: &MovieId,
|
||||
@@ -89,10 +80,4 @@ pub trait LocalApContentQuery: Send + Sync {
|
||||
before: Option<NaiveDateTime>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<DiaryEntry>, DomainError>;
|
||||
async fn get_goal_with_progress(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
year: u16,
|
||||
) -> Result<Option<(Goal, u32)>, DomainError>;
|
||||
async fn list_goals_for_user(&self, user_id: &UserId) -> Result<Vec<Goal>, DomainError>;
|
||||
}
|
||||
|
||||
@@ -191,7 +191,24 @@ impl DiaryRepository for FakeDiaryRepository {
|
||||
|
||||
// ── FakeStatsRepository ─────────────────────────────────────────────────────
|
||||
|
||||
pub struct FakeStatsRepository;
|
||||
pub struct FakeStatsRepository {
|
||||
review_counts: Mutex<HashMap<(Uuid, u16), u32>>,
|
||||
}
|
||||
|
||||
impl FakeStatsRepository {
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
review_counts: Mutex::new(HashMap::new()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_review_count(&self, user_id: Uuid, year: u16, count: u32) {
|
||||
self.review_counts
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert((user_id, year), count);
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl StatsRepository for FakeStatsRepository {
|
||||
@@ -211,6 +228,11 @@ impl StatsRepository for FakeStatsRepository {
|
||||
max_director_count: 0,
|
||||
})
|
||||
}
|
||||
|
||||
async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result<u32, DomainError> {
|
||||
let counts = self.review_counts.lock().unwrap();
|
||||
Ok(counts.get(&(user_id.value(), year)).copied().unwrap_or(0))
|
||||
}
|
||||
}
|
||||
|
||||
// ── FakePersonQuery ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -10,7 +10,6 @@ use chrono::Utc;
|
||||
|
||||
use crate::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::{
|
||||
FederationFlags, Goal, ImportProfile, ImportSession, Movie, MovieFilter, MovieProfile,
|
||||
MovieSummary, ProfileField, RefreshSession, Review, User, UserSettings, UserSummary,
|
||||
@@ -171,18 +170,12 @@ impl InMemoryReviewRepository {
|
||||
|
||||
#[async_trait]
|
||||
impl ReviewRepository for InMemoryReviewRepository {
|
||||
async fn save_review(&self, review: &Review) -> Result<DomainEvent, DomainError> {
|
||||
async fn save_review(&self, review: &Review) -> Result<(), DomainError> {
|
||||
self.store
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(review.id().value(), review.clone());
|
||||
Ok(DomainEvent::ReviewLogged {
|
||||
review_id: review.id().clone(),
|
||||
movie_id: review.movie_id().clone(),
|
||||
user_id: review.user_id().clone(),
|
||||
rating: review.rating().clone(),
|
||||
watched_at: *review.watched_at(),
|
||||
})
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError> {
|
||||
@@ -345,27 +338,18 @@ impl WatchlistRepository for InMemoryWatchlistRepository {
|
||||
|
||||
pub struct InMemoryGoalRepository {
|
||||
store: Mutex<HashMap<Uuid, Goal>>,
|
||||
review_counts: Mutex<HashMap<(Uuid, u16), u32>>,
|
||||
}
|
||||
|
||||
impl InMemoryGoalRepository {
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
store: Mutex::new(HashMap::new()),
|
||||
review_counts: Mutex::new(HashMap::new()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn count(&self) -> usize {
|
||||
self.store.lock().unwrap().len()
|
||||
}
|
||||
|
||||
pub fn set_review_count(&self, user_id: Uuid, year: u16, count: u32) {
|
||||
self.review_counts
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert((user_id, year), count);
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -416,11 +400,6 @@ impl GoalRepository for InMemoryGoalRepository {
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result<u32, DomainError> {
|
||||
let counts = self.review_counts.lock().unwrap();
|
||||
Ok(counts.get(&(user_id.value(), year)).copied().unwrap_or(0))
|
||||
}
|
||||
}
|
||||
|
||||
// ── InMemoryUserSettingsRepository ──────────────────────────────────────────
|
||||
|
||||
@@ -97,7 +97,10 @@ pub struct NoopSocialQueryPort;
|
||||
|
||||
#[async_trait]
|
||||
impl crate::ports::SocialQueryPort for NoopSocialQueryPort {
|
||||
async fn get_accepted_following_urls(&self, _: uuid::Uuid) -> Result<Vec<String>, DomainError> {
|
||||
async fn get_accepted_following_urls(
|
||||
&self,
|
||||
_: &crate::value_objects::UserId,
|
||||
) -> Result<Vec<String>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn list_all_followed_remote_actors(
|
||||
@@ -105,15 +108,21 @@ impl crate::ports::SocialQueryPort for NoopSocialQueryPort {
|
||||
) -> Result<Vec<crate::models::RemoteActorInfo>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn count_following(&self, _: uuid::Uuid) -> Result<usize, DomainError> {
|
||||
async fn count_following(
|
||||
&self,
|
||||
_: &crate::value_objects::UserId,
|
||||
) -> Result<usize, DomainError> {
|
||||
Ok(0)
|
||||
}
|
||||
async fn count_accepted_followers(&self, _: uuid::Uuid) -> Result<usize, DomainError> {
|
||||
async fn count_accepted_followers(
|
||||
&self,
|
||||
_: &crate::value_objects::UserId,
|
||||
) -> Result<usize, DomainError> {
|
||||
Ok(0)
|
||||
}
|
||||
async fn get_pending_followers(
|
||||
&self,
|
||||
_: uuid::Uuid,
|
||||
_: &crate::value_objects::UserId,
|
||||
) -> Result<Vec<crate::models::PendingFollowerInfo>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
@@ -148,9 +157,6 @@ impl crate::ports::GoalRepository for NoopGoalRepository {
|
||||
async fn list_for_user(&self, _: &UserId) -> Result<Vec<crate::models::Goal>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn count_reviews_in_year(&self, _: &UserId, _: u16) -> Result<u32, DomainError> {
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
|
||||
// ── NoopUserSettingsRepository ────────────────────────────────────────────────
|
||||
|
||||
@@ -80,6 +80,9 @@ impl StatsRepository for PanicStatsRepository {
|
||||
async fn get_user_trends(&self, _: &UserId) -> Result<UserTrends, DomainError> {
|
||||
panic!("PanicStatsRepository called")
|
||||
}
|
||||
async fn count_reviews_in_year(&self, _: &UserId, _: u16) -> Result<u32, DomainError> {
|
||||
panic!("PanicStatsRepository called")
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PanicImportSessionRepository;
|
||||
@@ -327,21 +330,30 @@ pub struct PanicSocialQueryPort;
|
||||
|
||||
#[async_trait]
|
||||
impl crate::ports::SocialQueryPort for PanicSocialQueryPort {
|
||||
async fn get_accepted_following_urls(&self, _: uuid::Uuid) -> Result<Vec<String>, DomainError> {
|
||||
async fn get_accepted_following_urls(
|
||||
&self,
|
||||
_: &crate::value_objects::UserId,
|
||||
) -> Result<Vec<String>, DomainError> {
|
||||
panic!("PanicSocialQueryPort called")
|
||||
}
|
||||
async fn list_all_followed_remote_actors(&self) -> Result<Vec<RemoteActorInfo>, DomainError> {
|
||||
panic!("PanicSocialQueryPort called")
|
||||
}
|
||||
async fn count_following(&self, _: uuid::Uuid) -> Result<usize, DomainError> {
|
||||
async fn count_following(
|
||||
&self,
|
||||
_: &crate::value_objects::UserId,
|
||||
) -> Result<usize, DomainError> {
|
||||
panic!("PanicSocialQueryPort called")
|
||||
}
|
||||
async fn count_accepted_followers(&self, _: uuid::Uuid) -> Result<usize, DomainError> {
|
||||
async fn count_accepted_followers(
|
||||
&self,
|
||||
_: &crate::value_objects::UserId,
|
||||
) -> Result<usize, DomainError> {
|
||||
panic!("PanicSocialQueryPort called")
|
||||
}
|
||||
async fn get_pending_followers(
|
||||
&self,
|
||||
_: uuid::Uuid,
|
||||
_: &crate::value_objects::UserId,
|
||||
) -> Result<Vec<PendingFollowerInfo>, DomainError> {
|
||||
panic!("PanicSocialQueryPort called")
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@ use std::str::FromStr;
|
||||
|
||||
use crate::errors::DomainError;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WatchMedium {
|
||||
Cinema,
|
||||
Streaming,
|
||||
#[serde(rename = "tv")]
|
||||
TV,
|
||||
PhysicalMedia,
|
||||
Download,
|
||||
|
||||
14
crates/infra-wiring/Cargo.toml
Normal file
14
crates/infra-wiring/Cargo.toml
Normal 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 = []
|
||||
50
crates/infra-wiring/src/config.rs
Normal file
50
crates/infra-wiring/src/config.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
35
crates/infra-wiring/src/lib.rs
Normal file
35
crates/infra-wiring/src/lib.rs
Normal 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'"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,9 @@ license = "MIT"
|
||||
|
||||
[features]
|
||||
default = ["sqlite", "sqlite-federation"]
|
||||
sqlite = ["dep:sqlite", "dep:sqlite-event-queue", "dep:sqlite-search"]
|
||||
postgres = ["dep:postgres", "dep:postgres-event-queue", "dep:postgres-search"]
|
||||
nats = ["dep:nats"]
|
||||
sqlite = ["dep:sqlite", "dep:sqlite-event-queue", "dep:sqlite-search", "infra-wiring/sqlite"]
|
||||
postgres = ["dep:postgres", "dep:postgres-event-queue", "dep:postgres-search", "infra-wiring/postgres"]
|
||||
nats = ["dep:nats", "infra-wiring/nats"]
|
||||
# Meta-feature: true when any federation adapter is active — keeps all #[cfg(feature = "federation")] gates working
|
||||
federation = ["application/federation"]
|
||||
sqlite-federation = [
|
||||
@@ -59,6 +59,7 @@ importer = { workspace = true }
|
||||
jellyfin = { workspace = true }
|
||||
plex = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
infra-wiring = { workspace = true }
|
||||
utoipa = { version = "5.5.0", features = ["axum_extras", "uuid"] }
|
||||
utoipa-scalar = { version = "0.3.0", features = ["axum"], default-features = false }
|
||||
utoipa-swagger-ui = { version = "9.0.2", features = ["axum", "vendored"] }
|
||||
|
||||
@@ -7,12 +7,7 @@ use domain::ports::{
|
||||
WatchEventRepository, WebhookTokenRepository,
|
||||
};
|
||||
|
||||
pub enum DbPool {
|
||||
#[cfg(feature = "sqlite")]
|
||||
Sqlite(sqlx::SqlitePool),
|
||||
#[cfg(feature = "postgres")]
|
||||
Postgres(sqlx::PgPool),
|
||||
}
|
||||
pub use infra_wiring::DbPool;
|
||||
|
||||
pub struct DatabaseOutput {
|
||||
pub movie: Arc<dyn domain::ports::MovieRepository>,
|
||||
|
||||
@@ -221,7 +221,6 @@ impl TryFrom<LogReviewRequest> for LogReviewData {
|
||||
|
||||
fn try_from(req: LogReviewRequest) -> Result<Self, Self::Error> {
|
||||
let watched_at = domain::value_objects::parse_watched_at(&req.watched_at)?;
|
||||
let watch_medium = req.watch_medium.map(|s| s.parse()).transpose()?;
|
||||
Ok(Self {
|
||||
external_metadata_id: req.external_metadata_id.filter(|s| !s.trim().is_empty()),
|
||||
manual_title: req.manual_title,
|
||||
@@ -230,7 +229,7 @@ impl TryFrom<LogReviewRequest> for LogReviewData {
|
||||
rating: req.rating,
|
||||
comment: req.comment,
|
||||
watched_at,
|
||||
watch_medium,
|
||||
watch_medium: req.watch_medium,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,10 +21,10 @@ use crate::{
|
||||
render::render_page,
|
||||
state::AppState,
|
||||
};
|
||||
use api_types::HtmlPageContext;
|
||||
use api_types::{
|
||||
LoginRequest, LoginResponse, LogoutRequest, RefreshRequest, RefreshResponse, RegisterRequest,
|
||||
};
|
||||
use application::rendering::HtmlPageContext;
|
||||
use template_askama::{LoginTemplate, RegisterTemplate};
|
||||
|
||||
// ── HTML helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
use axum::{
|
||||
Form, Json,
|
||||
body::Body,
|
||||
extract::{Extension, Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Redirect},
|
||||
};
|
||||
use futures::StreamExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
use application::diary::{
|
||||
commands::{DeleteReviewCommand, EditReviewCommand},
|
||||
delete_review,
|
||||
deps::{DeleteReviewDeps, EditReviewDeps, GetActivityFeedDeps},
|
||||
edit_review, export_diary as export_diary_uc, get_activity_feed as get_feed_uc, get_diary,
|
||||
log_review,
|
||||
queries::{ExportQuery, GetActivityFeedQuery},
|
||||
edit_review, get_activity_feed as get_feed_uc, get_diary, log_review,
|
||||
queries::GetActivityFeedQuery,
|
||||
};
|
||||
use domain::models::ExportFormat;
|
||||
|
||||
use crate::{
|
||||
csrf::CsrfToken,
|
||||
@@ -32,12 +28,7 @@ use api_types::{
|
||||
};
|
||||
use template_askama::{ActivityFeedTemplate, NewReviewTemplate, build_page_items};
|
||||
|
||||
use super::helpers::build_page_context;
|
||||
|
||||
fn encode_error(msg: &str) -> String {
|
||||
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
|
||||
utf8_percent_encode(msg, NON_ALPHANUMERIC).to_string()
|
||||
}
|
||||
use super::helpers::{build_export_response, build_page_context, encode_error};
|
||||
|
||||
// ── API ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -146,22 +137,13 @@ pub async fn patch_review(
|
||||
.map(|s| domain::value_objects::parse_watched_at(&s).map_err(ApiError))
|
||||
.transpose()?;
|
||||
|
||||
let watch_medium = req
|
||||
.watch_medium
|
||||
.map(|opt| {
|
||||
opt.map(|s| s.parse::<domain::value_objects::WatchMedium>())
|
||||
.transpose()
|
||||
.map_err(ApiError)
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
let cmd = EditReviewCommand {
|
||||
review_id,
|
||||
requesting_user_id: user_id.value(),
|
||||
rating: req.rating,
|
||||
comment: req.comment,
|
||||
watched_at,
|
||||
watch_medium,
|
||||
watch_medium: req.watch_medium,
|
||||
};
|
||||
let deps = EditReviewDeps {
|
||||
review: state.app_ctx.repos.review.clone(),
|
||||
@@ -186,42 +168,7 @@ pub async fn export_diary(
|
||||
user: AuthenticatedUser,
|
||||
Query(params): Query<ExportQueryParams>,
|
||||
) -> impl IntoResponse {
|
||||
let format = match params.format.as_str() {
|
||||
"csv" => ExportFormat::Csv,
|
||||
"json" => ExportFormat::Json,
|
||||
_ => return StatusCode::BAD_REQUEST.into_response(),
|
||||
};
|
||||
let (content_type, filename) = match &format {
|
||||
ExportFormat::Csv => ("text/csv; charset=utf-8", "diary.csv"),
|
||||
ExportFormat::Json => ("application/json", "diary.json"),
|
||||
};
|
||||
let query = ExportQuery {
|
||||
user_id: user.0.value(),
|
||||
format,
|
||||
};
|
||||
let stream = export_diary_uc::execute(
|
||||
&state.app_ctx.repos.diary,
|
||||
&state.app_ctx.services.diary_exporter,
|
||||
query,
|
||||
);
|
||||
let stream = stream.map(|r| {
|
||||
if let Err(ref e) = r {
|
||||
tracing::error!("diary export stream error: {e}");
|
||||
}
|
||||
r
|
||||
});
|
||||
(
|
||||
StatusCode::OK,
|
||||
[
|
||||
(axum::http::header::CONTENT_TYPE, content_type.to_string()),
|
||||
(
|
||||
axum::http::header::CONTENT_DISPOSITION,
|
||||
format!("attachment; filename=\"{}\"", filename),
|
||||
),
|
||||
],
|
||||
Body::from_stream(stream),
|
||||
)
|
||||
.into_response()
|
||||
build_export_response(¶ms.format, user.0.value(), &state)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -352,42 +299,7 @@ pub async fn get_export_html(
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
Query(params): Query<api_types::ExportQueryParams>,
|
||||
) -> impl IntoResponse {
|
||||
let format = match params.format.as_str() {
|
||||
"csv" => ExportFormat::Csv,
|
||||
"json" => ExportFormat::Json,
|
||||
_ => return StatusCode::BAD_REQUEST.into_response(),
|
||||
};
|
||||
let (content_type, filename) = match &format {
|
||||
ExportFormat::Csv => ("text/csv; charset=utf-8", "diary.csv"),
|
||||
ExportFormat::Json => ("application/json", "diary.json"),
|
||||
};
|
||||
let query = ExportQuery {
|
||||
user_id: user_id.value(),
|
||||
format,
|
||||
};
|
||||
let stream = export_diary_uc::execute(
|
||||
&state.app_ctx.repos.diary,
|
||||
&state.app_ctx.services.diary_exporter,
|
||||
query,
|
||||
);
|
||||
let stream = stream.map(|r| {
|
||||
if let Err(ref e) = r {
|
||||
tracing::error!("diary export stream error: {e}");
|
||||
}
|
||||
r
|
||||
});
|
||||
(
|
||||
StatusCode::OK,
|
||||
[
|
||||
(axum::http::header::CONTENT_TYPE, content_type.to_string()),
|
||||
(
|
||||
axum::http::header::CONTENT_DISPOSITION,
|
||||
format!("attachment; filename=\"{}\"", filename),
|
||||
),
|
||||
],
|
||||
Body::from_stream(stream),
|
||||
)
|
||||
.into_response()
|
||||
build_export_response(¶ms.format, user_id.value(), &state)
|
||||
}
|
||||
|
||||
pub async fn get_activity_feed_html(
|
||||
|
||||
@@ -20,7 +20,7 @@ pub fn goal_with_progress_to_dto(g: &domain::models::GoalWithProgress) -> GoalDt
|
||||
current_count: g.current_count,
|
||||
percentage: g.percentage(),
|
||||
is_complete: g.is_complete(),
|
||||
goal_type: g.goal.goal_type().as_str().to_string(),
|
||||
goal_type: g.goal.goal_type().clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ pub async fn list_goals(
|
||||
) -> Result<Json<GoalsResponse>, ApiError> {
|
||||
let goals = application::goals::list::execute(
|
||||
state.app_ctx.repos.goal.clone(),
|
||||
state.app_ctx.repos.stats.clone(),
|
||||
application::goals::queries::ListGoalsQuery {
|
||||
user_id: user.0.value(),
|
||||
},
|
||||
@@ -66,6 +67,7 @@ pub async fn create_goal(
|
||||
) -> Result<Json<GoalDto>, ApiError> {
|
||||
let g = application::goals::create::execute(
|
||||
state.app_ctx.repos.goal.clone(),
|
||||
state.app_ctx.repos.stats.clone(),
|
||||
state.app_ctx.services.event_publisher.clone(),
|
||||
application::goals::commands::CreateGoalCommand {
|
||||
user_id: user.0.value(),
|
||||
@@ -95,6 +97,7 @@ pub async fn update_goal(
|
||||
) -> Result<Json<GoalDto>, ApiError> {
|
||||
let g = application::goals::update::execute(
|
||||
state.app_ctx.repos.goal.clone(),
|
||||
state.app_ctx.repos.stats.clone(),
|
||||
state.app_ctx.services.event_publisher.clone(),
|
||||
application::goals::commands::UpdateGoalCommand {
|
||||
user_id: user.0.value(),
|
||||
@@ -147,6 +150,7 @@ pub async fn get_user_goals(
|
||||
) -> Result<Json<GoalsResponse>, ApiError> {
|
||||
let goals = application::goals::list::execute(
|
||||
state.app_ctx.repos.goal.clone(),
|
||||
state.app_ctx.repos.stats.clone(),
|
||||
application::goals::queries::ListGoalsQuery { user_id },
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -1,8 +1,145 @@
|
||||
use application::rendering::HtmlPageContext;
|
||||
use api_types::HtmlPageContext;
|
||||
use domain::value_objects::UserId;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
pub(crate) fn encode_error(msg: &str) -> String {
|
||||
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
|
||||
utf8_percent_encode(msg, NON_ALPHANUMERIC).to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn build_export_response(
|
||||
format_str: &str,
|
||||
user_id: uuid::Uuid,
|
||||
state: &AppState,
|
||||
) -> axum::response::Response {
|
||||
use axum::{body::Body, http::StatusCode, response::IntoResponse};
|
||||
use futures::StreamExt;
|
||||
|
||||
use application::diary::{export_diary as export_diary_uc, queries::ExportQuery};
|
||||
use domain::models::ExportFormat;
|
||||
|
||||
let format = match format_str {
|
||||
"csv" => ExportFormat::Csv,
|
||||
"json" => ExportFormat::Json,
|
||||
_ => return StatusCode::BAD_REQUEST.into_response(),
|
||||
};
|
||||
let (content_type, filename) = match &format {
|
||||
ExportFormat::Csv => ("text/csv; charset=utf-8", "diary.csv"),
|
||||
ExportFormat::Json => ("application/json", "diary.json"),
|
||||
};
|
||||
let query = ExportQuery { user_id, format };
|
||||
let stream = export_diary_uc::execute(
|
||||
&state.app_ctx.repos.diary,
|
||||
&state.app_ctx.services.diary_exporter,
|
||||
query,
|
||||
);
|
||||
let stream = stream.map(|r| {
|
||||
if let Err(ref e) = r {
|
||||
tracing::error!("diary export stream error: {e}");
|
||||
}
|
||||
r
|
||||
});
|
||||
(
|
||||
StatusCode::OK,
|
||||
[
|
||||
(axum::http::header::CONTENT_TYPE, content_type.to_string()),
|
||||
(
|
||||
axum::http::header::CONTENT_DISPOSITION,
|
||||
format!("attachment; filename=\"{}\"", filename),
|
||||
),
|
||||
],
|
||||
Body::from_stream(stream),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub(crate) struct ProfileFormData {
|
||||
pub display_name: Option<String>,
|
||||
pub bio: Option<String>,
|
||||
pub avatar_bytes: Option<Vec<u8>>,
|
||||
pub avatar_content_type: Option<String>,
|
||||
pub banner_bytes: Option<Vec<u8>>,
|
||||
pub banner_content_type: Option<String>,
|
||||
pub also_known_as: Option<String>,
|
||||
pub profile_field_names: std::collections::HashMap<usize, String>,
|
||||
pub profile_field_values: std::collections::HashMap<usize, String>,
|
||||
}
|
||||
|
||||
pub(crate) async fn parse_profile_multipart(
|
||||
mut multipart: axum::extract::Multipart,
|
||||
) -> ProfileFormData {
|
||||
let mut data = ProfileFormData {
|
||||
display_name: None,
|
||||
bio: None,
|
||||
avatar_bytes: None,
|
||||
avatar_content_type: None,
|
||||
banner_bytes: None,
|
||||
banner_content_type: None,
|
||||
also_known_as: None,
|
||||
profile_field_names: std::collections::HashMap::new(),
|
||||
profile_field_values: std::collections::HashMap::new(),
|
||||
};
|
||||
|
||||
while let Ok(Some(field)) = multipart.next_field().await {
|
||||
let name = field.name().unwrap_or("").to_string();
|
||||
match name.as_str() {
|
||||
"display_name" => {
|
||||
if let Ok(text) = field.text().await {
|
||||
data.display_name = Some(text).filter(|s| !s.is_empty());
|
||||
}
|
||||
}
|
||||
"bio" => {
|
||||
if let Ok(text) = field.text().await {
|
||||
data.bio = Some(text);
|
||||
}
|
||||
}
|
||||
"also_known_as" => {
|
||||
if let Ok(text) = field.text().await {
|
||||
data.also_known_as = Some(text).filter(|s| !s.is_empty());
|
||||
}
|
||||
}
|
||||
"avatar" => {
|
||||
let ct = field.content_type().map(|s| s.to_string());
|
||||
if let Ok(bytes) = field.bytes().await
|
||||
&& !bytes.is_empty()
|
||||
{
|
||||
data.avatar_bytes = Some(bytes.to_vec());
|
||||
data.avatar_content_type = ct;
|
||||
}
|
||||
}
|
||||
"banner" => {
|
||||
let ct = field.content_type().map(|s| s.to_string());
|
||||
if let Ok(bytes) = field.bytes().await
|
||||
&& !bytes.is_empty()
|
||||
{
|
||||
data.banner_bytes = Some(bytes.to_vec());
|
||||
data.banner_content_type = ct;
|
||||
}
|
||||
}
|
||||
n if n.starts_with("field_name_") => {
|
||||
if let Ok(idx) = n["field_name_".len()..].parse::<usize>()
|
||||
&& let Ok(text) = field.text().await
|
||||
&& !text.is_empty()
|
||||
{
|
||||
data.profile_field_names.insert(idx, text);
|
||||
}
|
||||
}
|
||||
n if n.starts_with("field_value_") => {
|
||||
if let Ok(idx) = n["field_value_".len()..].parse::<usize>()
|
||||
&& let Ok(text) = field.text().await
|
||||
&& !text.is_empty()
|
||||
{
|
||||
data.profile_field_values.insert(idx, text);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
data
|
||||
}
|
||||
|
||||
pub(crate) async fn build_page_context(
|
||||
state: &AppState,
|
||||
user_id: Option<UserId>,
|
||||
|
||||
@@ -22,6 +22,7 @@ use application::import::{
|
||||
execute as execute_import, list_profiles as list_import_profiles,
|
||||
save_profile as save_import_profile,
|
||||
};
|
||||
use domain::errors::DomainError;
|
||||
use domain::models::{
|
||||
AnnotatedRow, FieldMapping, FileFormat,
|
||||
import::{DomainField, Transform},
|
||||
@@ -39,10 +40,7 @@ use crate::{
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
fn encode_error(msg: &str) -> String {
|
||||
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
|
||||
utf8_percent_encode(msg, NON_ALPHANUMERIC).to_string()
|
||||
}
|
||||
use super::helpers::encode_error;
|
||||
|
||||
fn str_to_domain_field(field: &str) -> Option<DomainField> {
|
||||
match field {
|
||||
@@ -461,7 +459,7 @@ pub async fn api_post_session(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
mut multipart: Multipart,
|
||||
) -> impl IntoResponse {
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let mut file_bytes: Option<Vec<u8>> = None;
|
||||
let mut format_str = "csv".to_string();
|
||||
while let Ok(Some(field)) = multipart.next_field().await {
|
||||
@@ -481,20 +479,14 @@ pub async fn api_post_session(
|
||||
}
|
||||
let bytes = match file_bytes {
|
||||
Some(b) if !b.is_empty() => b,
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
axum::Json(serde_json::json!({"error": "no file"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
_ => return Err(DomainError::ValidationError("no file".into()).into()),
|
||||
};
|
||||
let format = match format_str.as_str() {
|
||||
"json" => FileFormat::Json,
|
||||
"xlsx" => FileFormat::Xlsx,
|
||||
_ => FileFormat::Csv,
|
||||
};
|
||||
match create_import_session::execute(
|
||||
let r = create_import_session::execute(
|
||||
state.app_ctx.repos.import_session.clone(),
|
||||
state.app_ctx.services.document_parser.clone(),
|
||||
CreateImportSessionCommand {
|
||||
@@ -503,20 +495,12 @@ pub async fn api_post_session(
|
||||
format,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(r) => axum::Json(SessionCreatedResponse {
|
||||
.await?;
|
||||
Ok(axum::Json(SessionCreatedResponse {
|
||||
session_id: r.session_id.value().to_string(),
|
||||
columns: r.columns,
|
||||
sample_rows: r.sample_rows,
|
||||
})
|
||||
.into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
axum::Json(serde_json::json!({"error": e.to_string()})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -533,46 +517,26 @@ pub async fn api_get_session(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Path(session_id_str): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let Ok(session_id) = session_id_str
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let session_id = session_id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
.map(ImportSessionId::from_uuid)
|
||||
else {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
axum::Json(serde_json::json!({"error": "invalid session id"})),
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
match state
|
||||
.map_err(|_| DomainError::ValidationError("invalid session id".into()))?;
|
||||
let session = state
|
||||
.app_ctx
|
||||
.repos
|
||||
.import_session
|
||||
.get(&session_id, &user_id)
|
||||
.await
|
||||
{
|
||||
Ok(Some(session)) => {
|
||||
.await?
|
||||
.ok_or(DomainError::NotFound("session not found".into()))?;
|
||||
let parsed = session.parsed_file.unwrap_or_default();
|
||||
let row_count = parsed.rows.len();
|
||||
axum::Json(SessionStateResponse {
|
||||
Ok(axum::Json(SessionStateResponse {
|
||||
session_id: session_id_str,
|
||||
columns: parsed.columns,
|
||||
has_mappings: session.field_mappings.is_some(),
|
||||
row_count,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
Ok(None) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
axum::Json(serde_json::json!({"error": "session not found"})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
axum::Json(serde_json::json!({"error": e.to_string()})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -591,17 +555,11 @@ pub async fn api_put_mapping(
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Path(session_id_str): Path<String>,
|
||||
axum::Json(body): axum::Json<ApplyMappingRequest>,
|
||||
) -> impl IntoResponse {
|
||||
let Ok(session_id) = session_id_str
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let session_id = session_id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
.map(ImportSessionId::from_uuid)
|
||||
else {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
axum::Json(serde_json::json!({"error": "invalid session id"})),
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
.map_err(|_| DomainError::ValidationError("invalid session id".into()))?;
|
||||
let mappings: Vec<FieldMapping> = body
|
||||
.mappings
|
||||
.into_iter()
|
||||
@@ -624,7 +582,7 @@ pub async fn api_put_mapping(
|
||||
})
|
||||
.collect();
|
||||
|
||||
match apply_import_mapping::execute(
|
||||
let rows = apply_import_mapping::execute(
|
||||
state.app_ctx.repos.import_session.clone(),
|
||||
state.app_ctx.services.document_parser.clone(),
|
||||
state.app_ctx.repos.movie.clone(),
|
||||
@@ -634,15 +592,8 @@ pub async fn api_put_mapping(
|
||||
mappings,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(rows) => axum::Json(serde_json::json!({"row_count": rows.len()})).into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
axum::Json(serde_json::json!({"error": e.to_string()})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
.await?;
|
||||
Ok(axum::Json(serde_json::json!({"row_count": rows.len()})))
|
||||
}
|
||||
|
||||
pub async fn api_get_preview(
|
||||
@@ -653,11 +604,7 @@ pub async fn api_get_preview(
|
||||
let session_id = session_id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
.map(ImportSessionId::from_uuid)
|
||||
.map_err(|_| {
|
||||
ApiError(domain::errors::DomainError::ValidationError(
|
||||
"invalid session id".into(),
|
||||
))
|
||||
})?;
|
||||
.map_err(|_| DomainError::ValidationError("invalid session id".into()))?;
|
||||
|
||||
let session = state
|
||||
.app_ctx
|
||||
@@ -665,11 +612,7 @@ pub async fn api_get_preview(
|
||||
.import_session
|
||||
.get(&session_id, &user_id)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError(domain::errors::DomainError::NotFound(
|
||||
"session not found".into(),
|
||||
))
|
||||
})?;
|
||||
.ok_or(DomainError::NotFound("session not found".into()))?;
|
||||
|
||||
let annotated: Vec<AnnotatedRow> = session.row_results.unwrap_or_default();
|
||||
let rows = annotated
|
||||
@@ -678,7 +621,8 @@ pub async fn api_get_preview(
|
||||
.map(|(i, a)| {
|
||||
use domain::models::import::RowResult;
|
||||
match &a.result {
|
||||
RowResult::Valid(row) if a.is_duplicate => PreviewRowDto::Duplicate {
|
||||
RowResult::Valid(row) if a.is_duplicate => {
|
||||
PreviewRowDto::Duplicate(api_types::PreviewRowData {
|
||||
index: i,
|
||||
title: row.title.clone(),
|
||||
release_year: row.release_year.clone(),
|
||||
@@ -686,8 +630,9 @@ pub async fn api_get_preview(
|
||||
rating: row.rating.clone(),
|
||||
watched_at: row.watched_at.clone(),
|
||||
comment: row.comment.clone(),
|
||||
},
|
||||
RowResult::Valid(row) => PreviewRowDto::Valid {
|
||||
})
|
||||
}
|
||||
RowResult::Valid(row) => PreviewRowDto::Valid(api_types::PreviewRowData {
|
||||
index: i,
|
||||
title: row.title.clone(),
|
||||
release_year: row.release_year.clone(),
|
||||
@@ -695,7 +640,7 @@ pub async fn api_get_preview(
|
||||
rating: row.rating.clone(),
|
||||
watched_at: row.watched_at.clone(),
|
||||
comment: row.comment.clone(),
|
||||
},
|
||||
}),
|
||||
RowResult::Invalid { errors, .. } => PreviewRowDto::Invalid {
|
||||
index: i,
|
||||
errors: errors.clone(),
|
||||
@@ -723,32 +668,26 @@ pub async fn api_post_confirm(
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Path(session_id_str): Path<String>,
|
||||
axum::Json(body): axum::Json<ConfirmRequest>,
|
||||
) -> impl IntoResponse {
|
||||
let Ok(session_id) = session_id_str
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let session_id = session_id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
.map(ImportSessionId::from_uuid)
|
||||
else {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
axum::Json(serde_json::json!({"error": "invalid session id"})),
|
||||
.map_err(|_| DomainError::ValidationError("invalid session id".into()))?;
|
||||
let s = execute_import::execute(
|
||||
state.app_ctx.repos.import_session.clone(),
|
||||
state.app_ctx.services.review_logger.clone(),
|
||||
ExecuteImportCommand {
|
||||
user_id: user_id.value(),
|
||||
session_id: session_id.value(),
|
||||
confirmed_indices: body.confirmed_indices,
|
||||
},
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
match execute_import::execute(state.app_ctx.repos.import_session.clone(), state.app_ctx.services.review_logger.clone(), ExecuteImportCommand { user_id: user_id.value(), session_id: session_id.value(), confirmed_indices: body.confirmed_indices }).await {
|
||||
Ok(s) => axum::Json(serde_json::json!({
|
||||
.await?;
|
||||
Ok(axum::Json(serde_json::json!({
|
||||
"imported": s.imported,
|
||||
"skipped_duplicates": s.skipped_duplicates,
|
||||
"failed": s.failed.iter().map(|(i, e)| serde_json::json!({"index": i, "error": e})).collect::<Vec<_>>(),
|
||||
})).into_response(),
|
||||
Err(e) => {
|
||||
let status = if matches!(e, domain::errors::DomainError::NotFound(_)) {
|
||||
StatusCode::NOT_FOUND
|
||||
} else {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
};
|
||||
(status, axum::Json(serde_json::json!({"error": e.to_string()}))).into_response()
|
||||
}
|
||||
}
|
||||
})))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -762,10 +701,10 @@ pub async fn api_post_confirm(
|
||||
pub async fn api_get_profiles(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
) -> impl IntoResponse {
|
||||
match list_import_profiles::execute(state.app_ctx.repos.import_profile.clone(), &user_id).await
|
||||
{
|
||||
Ok(profiles) => axum::Json(
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let profiles =
|
||||
list_import_profiles::execute(state.app_ctx.repos.import_profile.clone(), &user_id).await?;
|
||||
Ok(axum::Json(
|
||||
profiles
|
||||
.into_iter()
|
||||
.map(|p| {
|
||||
@@ -776,14 +715,7 @@ pub async fn api_get_profiles(
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
axum::Json(serde_json::json!({"error": e.to_string()})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -800,19 +732,13 @@ pub async fn api_post_profile(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
axum::Json(body): axum::Json<SaveProfileRequest>,
|
||||
) -> impl IntoResponse {
|
||||
let Ok(session_id) = body
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let session_id = body
|
||||
.session_id
|
||||
.parse::<uuid::Uuid>()
|
||||
.map(ImportSessionId::from_uuid)
|
||||
else {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
axum::Json(serde_json::json!({"error": "invalid session id"})),
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
match save_import_profile::execute(
|
||||
.map_err(|_| DomainError::ValidationError("invalid session id".into()))?;
|
||||
let id = save_import_profile::execute(
|
||||
state.app_ctx.repos.import_session.clone(),
|
||||
state.app_ctx.repos.import_profile.clone(),
|
||||
SaveImportProfileCommand {
|
||||
@@ -821,15 +747,10 @@ pub async fn api_post_profile(
|
||||
name: body.name,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(id) => axum::Json(serde_json::json!({"id": id.value().to_string()})).into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
axum::Json(serde_json::json!({"error": e.to_string()})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
.await?;
|
||||
Ok(axum::Json(
|
||||
serde_json::json!({"id": id.value().to_string()}),
|
||||
))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -846,29 +767,19 @@ pub async fn api_delete_profile(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Path(profile_id_str): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let Ok(profile_id) = profile_id_str.parse::<uuid::Uuid>() else {
|
||||
return StatusCode::BAD_REQUEST.into_response();
|
||||
};
|
||||
match delete_import_profile::execute(
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let profile_id = profile_id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
.map_err(|_| DomainError::ValidationError("invalid profile id".into()))?;
|
||||
delete_import_profile::execute(
|
||||
state.app_ctx.repos.import_profile.clone(),
|
||||
DeleteImportProfileCommand {
|
||||
user_id: user_id.value(),
|
||||
profile_id,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(e) => {
|
||||
let status = if matches!(e, domain::errors::DomainError::NotFound(_)) {
|
||||
StatusCode::NOT_FOUND
|
||||
} else {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
};
|
||||
status.into_response()
|
||||
}
|
||||
}
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -890,23 +801,15 @@ pub async fn api_apply_profile(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Path((session_id_str, profile_id_str)): Path<(String, String)>,
|
||||
) -> impl IntoResponse {
|
||||
let Ok(session_id) = session_id_str.parse::<uuid::Uuid>() else {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
axum::Json(serde_json::json!({"error": "invalid session id"})),
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
let Ok(profile_id) = profile_id_str.parse::<uuid::Uuid>() else {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
axum::Json(serde_json::json!({"error": "invalid profile id"})),
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let session_id = session_id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
.map_err(|_| DomainError::ValidationError("invalid session id".into()))?;
|
||||
let profile_id = profile_id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
.map_err(|_| DomainError::ValidationError("invalid profile id".into()))?;
|
||||
|
||||
if let Err(e) = apply_import_profile::execute(
|
||||
apply_import_profile::execute(
|
||||
state.app_ctx.repos.import_profile.clone(),
|
||||
state.app_ctx.repos.import_session.clone(),
|
||||
ApplyImportProfileCommand {
|
||||
@@ -915,39 +818,20 @@ pub async fn api_apply_profile(
|
||||
profile_id,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
let status = if matches!(e, domain::errors::DomainError::NotFound(_)) {
|
||||
StatusCode::NOT_FOUND
|
||||
} else {
|
||||
StatusCode::UNPROCESSABLE_ENTITY
|
||||
};
|
||||
return (
|
||||
status,
|
||||
axum::Json(serde_json::json!({"error": e.to_string()})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
.await?;
|
||||
|
||||
let session = match state
|
||||
let session = state
|
||||
.app_ctx
|
||||
.repos
|
||||
.import_session
|
||||
.get(&ImportSessionId::from_uuid(session_id), &user_id)
|
||||
.await
|
||||
{
|
||||
Ok(Some(s)) => s,
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
axum::Json(serde_json::json!({"error": "session not found after profile apply"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
.await?
|
||||
.ok_or(DomainError::NotFound(
|
||||
"session not found after profile apply".into(),
|
||||
))?;
|
||||
|
||||
let mappings = session.field_mappings.unwrap_or_default();
|
||||
match apply_import_mapping::execute(
|
||||
let rows = apply_import_mapping::execute(
|
||||
state.app_ctx.repos.import_session.clone(),
|
||||
state.app_ctx.services.document_parser.clone(),
|
||||
state.app_ctx.repos.movie.clone(),
|
||||
@@ -957,13 +841,6 @@ pub async fn api_apply_profile(
|
||||
mappings,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(rows) => axum::Json(serde_json::json!({"row_count": rows.len()})).into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
axum::Json(serde_json::json!({"error": e.to_string()})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
.await?;
|
||||
Ok(axum::Json(serde_json::json!({"row_count": rows.len()})))
|
||||
}
|
||||
|
||||
@@ -24,12 +24,7 @@ use crate::{
|
||||
};
|
||||
use template_askama::{IntegrationsTemplate, WatchQueueTemplate};
|
||||
|
||||
use super::helpers::build_page_context;
|
||||
|
||||
fn encode_error(msg: &str) -> String {
|
||||
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
|
||||
utf8_percent_encode(msg, NON_ALPHANUMERIC).to_string()
|
||||
}
|
||||
use super::helpers::{build_page_context, encode_error};
|
||||
|
||||
// ── HTML ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -185,7 +185,7 @@ pub async fn get_movie_detail(
|
||||
comment: e.review().comment().map(|c| c.value().to_string()),
|
||||
watched_at: domain::value_objects::format_watched_at(e.review().watched_at()),
|
||||
is_federated: e.review().is_remote(),
|
||||
watch_medium: e.review().watch_medium().map(|wm| wm.to_string()),
|
||||
watch_medium: e.review().watch_medium().copied(),
|
||||
})
|
||||
.collect(),
|
||||
total_count: result.reviews.total_count,
|
||||
|
||||
@@ -13,7 +13,7 @@ use domain::models::{PersonId, collections::PageParams};
|
||||
use crate::state::AppState;
|
||||
use api_types::search::{
|
||||
CastCreditDto, CrewCreditDto, MovieSearchHitDto, PaginatedMovieHits, PaginatedPersonHits,
|
||||
PersonCreditsDto, PersonDto, PersonSearchHitDto, SearchQueryParams, SearchResponse,
|
||||
PersonCreditsDto, PersonSearchHitDto, SearchQueryParams, SearchResponse,
|
||||
};
|
||||
|
||||
// ── API ──────────────────────────────────────────────────────────────────────
|
||||
@@ -106,24 +106,9 @@ pub async fn get_person_handler(
|
||||
event_publisher: state.app_ctx.services.event_publisher.clone(),
|
||||
};
|
||||
match get_person::execute(&deps, PersonId::from_uuid(id)).await {
|
||||
Ok(Some(person)) => axum::Json(PersonDto {
|
||||
id: person.id().value(),
|
||||
external_id: person.external_id().value().to_string(),
|
||||
name: person.name().to_string(),
|
||||
known_for_department: person.known_for_department().map(str::to_string),
|
||||
profile_path: person.profile_path().map(str::to_string),
|
||||
biography: person.biography().map(str::to_string),
|
||||
birthday: person.birthday().map(|d| d.to_string()),
|
||||
deathday: person.deathday().map(|d| d.to_string()),
|
||||
place_of_birth: person.place_of_birth().map(str::to_string),
|
||||
also_known_as: person.also_known_as().to_vec(),
|
||||
homepage: person.homepage().map(str::to_string),
|
||||
imdb_url: person
|
||||
.imdb_id()
|
||||
.map(|id| format!("https://www.imdb.com/name/{id}")),
|
||||
enriched: person.enriched_at().is_some(),
|
||||
})
|
||||
.into_response(),
|
||||
Ok(Some(person)) => {
|
||||
axum::Json(crate::mappers::search::person_to_dto(&person)).into_response()
|
||||
}
|
||||
Ok(None) => StatusCode::NOT_FOUND.into_response(),
|
||||
Err(e) => crate::errors::domain_error_response(e),
|
||||
}
|
||||
@@ -148,24 +133,7 @@ pub async fn get_person_credits_handler(
|
||||
};
|
||||
match get_person_credits::execute(&deps, PersonId::from_uuid(id)).await {
|
||||
Ok(credits) => axum::Json(PersonCreditsDto {
|
||||
person: PersonDto {
|
||||
id: credits.person.id().value(),
|
||||
external_id: credits.person.external_id().value().to_string(),
|
||||
name: credits.person.name().to_string(),
|
||||
known_for_department: credits.person.known_for_department().map(str::to_string),
|
||||
profile_path: credits.person.profile_path().map(str::to_string),
|
||||
biography: credits.person.biography().map(str::to_string),
|
||||
birthday: credits.person.birthday().map(|d| d.to_string()),
|
||||
deathday: credits.person.deathday().map(|d| d.to_string()),
|
||||
place_of_birth: credits.person.place_of_birth().map(str::to_string),
|
||||
also_known_as: credits.person.also_known_as().to_vec(),
|
||||
homepage: credits.person.homepage().map(str::to_string),
|
||||
imdb_url: credits
|
||||
.person
|
||||
.imdb_id()
|
||||
.map(|id| format!("https://www.imdb.com/name/{id}")),
|
||||
enriched: credits.person.enriched_at().is_some(),
|
||||
},
|
||||
person: crate::mappers::search::person_to_dto(&credits.person),
|
||||
cast: credits
|
||||
.cast
|
||||
.iter()
|
||||
|
||||
@@ -9,7 +9,7 @@ use uuid::Uuid;
|
||||
use crate::{
|
||||
csrf::CsrfToken,
|
||||
errors::ApiError,
|
||||
extractors::{AuthenticatedUser, RequiredCookieUser},
|
||||
extractors::{AdminApiUser, AuthenticatedUser, RequiredCookieUser},
|
||||
forms::{
|
||||
ActorUrlForm, BlockDomainForm, FollowForm, FollowerActionForm, RemoveDomainForm,
|
||||
UnfollowForm,
|
||||
@@ -19,24 +19,14 @@ use crate::{
|
||||
};
|
||||
use api_types::{
|
||||
ActorListResponse, ActorUrlRequest, AddBlockedDomainRequest, BlockedActorResponse,
|
||||
BlockedDomainResponse, FollowRequest, RemoteActorDto,
|
||||
BlockedDomainResponse, FollowRequest,
|
||||
};
|
||||
use template_askama::{
|
||||
BlockedActorsTemplate, BlockedDomainsTemplate, FollowersTemplate, FollowingTemplate,
|
||||
RemoteActorData,
|
||||
};
|
||||
|
||||
use super::helpers::build_page_context;
|
||||
|
||||
fn encode_error(msg: &str) -> String {
|
||||
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
|
||||
utf8_percent_encode(msg, NON_ALPHANUMERIC).to_string()
|
||||
}
|
||||
|
||||
fn ap_err(e: anyhow::Error) -> impl IntoResponse {
|
||||
tracing::error!("ActivityPub error: {:?}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
}
|
||||
use super::helpers::{build_page_context, encode_error};
|
||||
|
||||
fn ap_to_domain(e: anyhow::Error) -> domain::errors::DomainError {
|
||||
tracing::error!("ActivityPub error: {:?}", e);
|
||||
@@ -56,22 +46,23 @@ fn ap_to_domain(e: anyhow::Error) -> domain::errors::DomainError {
|
||||
)]
|
||||
pub async fn get_blocked_domains_admin(
|
||||
State(state): State<AppState>,
|
||||
_admin: crate::extractors::AdminUser,
|
||||
) -> impl IntoResponse {
|
||||
match state.ap_service.get_blocked_domains().await {
|
||||
Ok(domains) => {
|
||||
let response: Vec<BlockedDomainResponse> = domains
|
||||
_admin: AdminApiUser,
|
||||
) -> Result<Json<Vec<BlockedDomainResponse>>, ApiError> {
|
||||
let domains = state
|
||||
.ap_service
|
||||
.get_blocked_domains()
|
||||
.await
|
||||
.map_err(ap_to_domain)?;
|
||||
Ok(Json(
|
||||
domains
|
||||
.into_iter()
|
||||
.map(|d| BlockedDomainResponse {
|
||||
domain: d.domain,
|
||||
reason: d.reason,
|
||||
blocked_at: d.blocked_at,
|
||||
})
|
||||
.collect();
|
||||
axum::Json(response).into_response()
|
||||
}
|
||||
Err(e) => ap_err(e).into_response(),
|
||||
}
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -86,17 +77,15 @@ pub async fn get_blocked_domains_admin(
|
||||
)]
|
||||
pub async fn add_blocked_domain_admin(
|
||||
State(state): State<AppState>,
|
||||
_admin: crate::extractors::AdminUser,
|
||||
_admin: AdminApiUser,
|
||||
axum::Json(body): axum::Json<AddBlockedDomainRequest>,
|
||||
) -> impl IntoResponse {
|
||||
match state
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
state
|
||||
.ap_service
|
||||
.add_blocked_domain(&body.domain, body.reason.as_deref())
|
||||
.await
|
||||
{
|
||||
Ok(()) => StatusCode::CREATED.into_response(),
|
||||
Err(e) => ap_err(e).into_response(),
|
||||
}
|
||||
.map_err(ap_to_domain)?;
|
||||
Ok(StatusCode::CREATED)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -111,13 +100,15 @@ pub async fn add_blocked_domain_admin(
|
||||
)]
|
||||
pub async fn remove_blocked_domain_admin(
|
||||
State(state): State<AppState>,
|
||||
_admin: crate::extractors::AdminUser,
|
||||
_admin: AdminApiUser,
|
||||
axum::extract::Path(domain): axum::extract::Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
match state.ap_service.remove_blocked_domain(&domain).await {
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(e) => ap_err(e).into_response(),
|
||||
}
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
state
|
||||
.ap_service
|
||||
.remove_blocked_domain(&domain)
|
||||
.await
|
||||
.map_err(ap_to_domain)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -133,15 +124,13 @@ pub async fn block_actor_api(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
axum::Json(body): axum::Json<ActorUrlRequest>,
|
||||
) -> impl IntoResponse {
|
||||
match state
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
state
|
||||
.ap_service
|
||||
.block_actor(user.0.value(), &body.actor_url)
|
||||
.await
|
||||
{
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(e) => ap_err(e).into_response(),
|
||||
}
|
||||
.map_err(ap_to_domain)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -157,15 +146,13 @@ pub async fn unblock_actor_api(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
axum::Json(body): axum::Json<ActorUrlRequest>,
|
||||
) -> impl IntoResponse {
|
||||
match state
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
state
|
||||
.ap_service
|
||||
.unblock_actor(user.0.value(), &body.actor_url)
|
||||
.await
|
||||
{
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(e) => ap_err(e).into_response(),
|
||||
}
|
||||
.map_err(ap_to_domain)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -179,10 +166,14 @@ pub async fn unblock_actor_api(
|
||||
pub async fn get_blocked_actors_api(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
) -> impl IntoResponse {
|
||||
match state.ap_service.get_blocked_actors(user.0.value()).await {
|
||||
Ok(actors) => {
|
||||
let response: Vec<BlockedActorResponse> = actors
|
||||
) -> Result<Json<Vec<BlockedActorResponse>>, ApiError> {
|
||||
let actors = state
|
||||
.ap_service
|
||||
.get_blocked_actors(user.0.value())
|
||||
.await
|
||||
.map_err(ap_to_domain)?;
|
||||
Ok(Json(
|
||||
actors
|
||||
.into_iter()
|
||||
.map(|a| BlockedActorResponse {
|
||||
url: a.url,
|
||||
@@ -190,11 +181,8 @@ pub async fn get_blocked_actors_api(
|
||||
display_name: a.display_name,
|
||||
avatar_url: a.avatar_url,
|
||||
})
|
||||
.collect();
|
||||
axum::Json(response).into_response()
|
||||
}
|
||||
Err(e) => ap_err(e).into_response(),
|
||||
}
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -208,21 +196,18 @@ pub async fn get_blocked_actors_api(
|
||||
pub async fn get_following(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
) -> impl IntoResponse {
|
||||
match state.ap_service.get_following(user.0.value()).await {
|
||||
Ok(actors) => Json(ActorListResponse {
|
||||
) -> Result<Json<ActorListResponse>, ApiError> {
|
||||
let actors = state
|
||||
.ap_service
|
||||
.get_following(user.0.value())
|
||||
.await
|
||||
.map_err(ap_to_domain)?;
|
||||
Ok(Json(ActorListResponse {
|
||||
actors: actors
|
||||
.into_iter()
|
||||
.map(|a| RemoteActorDto {
|
||||
handle: a.handle,
|
||||
display_name: a.display_name,
|
||||
url: a.url,
|
||||
})
|
||||
.map(crate::mappers::social::remote_actor_to_dto)
|
||||
.collect(),
|
||||
})
|
||||
.into_response(),
|
||||
Err(e) => ap_err(e).into_response(),
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -236,25 +221,18 @@ pub async fn get_following(
|
||||
pub async fn get_followers(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
) -> impl IntoResponse {
|
||||
match state
|
||||
) -> Result<Json<ActorListResponse>, ApiError> {
|
||||
let actors = state
|
||||
.ap_service
|
||||
.get_accepted_followers(user.0.value())
|
||||
.await
|
||||
{
|
||||
Ok(actors) => Json(ActorListResponse {
|
||||
.map_err(ap_to_domain)?;
|
||||
Ok(Json(ActorListResponse {
|
||||
actors: actors
|
||||
.into_iter()
|
||||
.map(|a| RemoteActorDto {
|
||||
handle: a.handle,
|
||||
display_name: a.display_name,
|
||||
url: a.url,
|
||||
})
|
||||
.map(crate::mappers::social::remote_actor_to_dto)
|
||||
.collect(),
|
||||
})
|
||||
.into_response(),
|
||||
Err(e) => ap_err(e).into_response(),
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn get_user_following(
|
||||
@@ -270,11 +248,7 @@ pub async fn get_user_following(
|
||||
Ok(Json(ActorListResponse {
|
||||
actors: actors
|
||||
.into_iter()
|
||||
.map(|a| RemoteActorDto {
|
||||
handle: a.handle,
|
||||
display_name: a.display_name,
|
||||
url: a.url,
|
||||
})
|
||||
.map(crate::mappers::social::remote_actor_to_dto)
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
@@ -292,11 +266,7 @@ pub async fn get_user_followers(
|
||||
Ok(Json(ActorListResponse {
|
||||
actors: actors
|
||||
.into_iter()
|
||||
.map(|a| RemoteActorDto {
|
||||
handle: a.handle,
|
||||
display_name: a.display_name,
|
||||
url: a.url,
|
||||
})
|
||||
.map(crate::mappers::social::remote_actor_to_dto)
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
@@ -314,11 +284,13 @@ pub async fn follow(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Json(body): Json<FollowRequest>,
|
||||
) -> impl IntoResponse {
|
||||
match state.ap_service.follow(user.0.value(), &body.handle).await {
|
||||
Ok(()) => StatusCode::OK.into_response(),
|
||||
Err(e) => ap_err(e).into_response(),
|
||||
}
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
state
|
||||
.ap_service
|
||||
.follow(user.0.value(), &body.handle)
|
||||
.await
|
||||
.map_err(ap_to_domain)?;
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -334,15 +306,13 @@ pub async fn unfollow(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Json(body): Json<ActorUrlRequest>,
|
||||
) -> impl IntoResponse {
|
||||
match state
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
state
|
||||
.ap_service
|
||||
.unfollow(user.0.value(), &body.actor_url)
|
||||
.await
|
||||
{
|
||||
Ok(()) => StatusCode::OK.into_response(),
|
||||
Err(e) => ap_err(e).into_response(),
|
||||
}
|
||||
.map_err(ap_to_domain)?;
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -358,15 +328,13 @@ pub async fn accept_follower(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Json(body): Json<ActorUrlRequest>,
|
||||
) -> impl IntoResponse {
|
||||
match state
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
state
|
||||
.ap_service
|
||||
.accept_follower(user.0.value(), &body.actor_url)
|
||||
.await
|
||||
{
|
||||
Ok(()) => StatusCode::OK.into_response(),
|
||||
Err(e) => ap_err(e).into_response(),
|
||||
}
|
||||
.map_err(ap_to_domain)?;
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -382,15 +350,13 @@ pub async fn reject_follower(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Json(body): Json<ActorUrlRequest>,
|
||||
) -> impl IntoResponse {
|
||||
match state
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
state
|
||||
.ap_service
|
||||
.reject_follower(user.0.value(), &body.actor_url)
|
||||
.await
|
||||
{
|
||||
Ok(()) => StatusCode::OK.into_response(),
|
||||
Err(e) => ap_err(e).into_response(),
|
||||
}
|
||||
.map_err(ap_to_domain)?;
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -406,15 +372,13 @@ pub async fn remove_follower(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Json(body): Json<ActorUrlRequest>,
|
||||
) -> impl IntoResponse {
|
||||
match state
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
state
|
||||
.ap_service
|
||||
.remove_follower(user.0.value(), &body.actor_url)
|
||||
.await
|
||||
{
|
||||
Ok(()) => StatusCode::OK.into_response(),
|
||||
Err(e) => ap_err(e).into_response(),
|
||||
}
|
||||
.map_err(ap_to_domain)?;
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -428,21 +392,18 @@ pub async fn remove_follower(
|
||||
pub async fn get_pending_followers(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
) -> impl IntoResponse {
|
||||
match state.ap_service.get_pending_followers(user.0.value()).await {
|
||||
Ok(actors) => Json(ActorListResponse {
|
||||
) -> Result<Json<ActorListResponse>, ApiError> {
|
||||
let actors = state
|
||||
.ap_service
|
||||
.get_pending_followers(user.0.value())
|
||||
.await
|
||||
.map_err(ap_to_domain)?;
|
||||
Ok(Json(ActorListResponse {
|
||||
actors: actors
|
||||
.into_iter()
|
||||
.map(|a| RemoteActorDto {
|
||||
handle: a.handle,
|
||||
display_name: a.display_name,
|
||||
url: a.url,
|
||||
})
|
||||
.map(crate::mappers::social::remote_actor_to_dto)
|
||||
.collect(),
|
||||
})
|
||||
.into_response(),
|
||||
Err(e) => ap_err(e).into_response(),
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
// ── HTML ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -61,6 +61,7 @@ pub async fn get_profile(
|
||||
.await?;
|
||||
let base_url = &state.app_ctx.config.base_url;
|
||||
Ok(Json(ProfileResponse {
|
||||
profile: api_types::UserProfileBase {
|
||||
username: profile.username,
|
||||
display_name: profile.display_name,
|
||||
bio: profile.bio,
|
||||
@@ -70,6 +71,7 @@ pub async fn get_profile(
|
||||
banner_url: profile
|
||||
.banner_path
|
||||
.map(|p| format!("{}/images/{}", base_url, p)),
|
||||
},
|
||||
also_known_as: profile.also_known_as,
|
||||
fields: profile
|
||||
.fields
|
||||
@@ -96,65 +98,19 @@ pub async fn get_profile(
|
||||
pub async fn update_profile_handler(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
mut multipart: Multipart,
|
||||
multipart: Multipart,
|
||||
) -> impl IntoResponse {
|
||||
let mut display_name: Option<String> = None;
|
||||
let mut bio: Option<String> = None;
|
||||
let mut avatar_bytes: Option<Vec<u8>> = None;
|
||||
let mut avatar_content_type: Option<String> = None;
|
||||
let mut banner_bytes: Option<Vec<u8>> = None;
|
||||
let mut banner_content_type: Option<String> = None;
|
||||
let mut also_known_as: Option<String> = None;
|
||||
|
||||
while let Ok(Some(field)) = multipart.next_field().await {
|
||||
let name = field.name().unwrap_or("").to_string();
|
||||
match name.as_str() {
|
||||
"display_name" => {
|
||||
if let Ok(text) = field.text().await {
|
||||
display_name = Some(text).filter(|s| !s.is_empty());
|
||||
}
|
||||
}
|
||||
"bio" => {
|
||||
if let Ok(text) = field.text().await {
|
||||
bio = Some(text);
|
||||
}
|
||||
}
|
||||
"also_known_as" => {
|
||||
if let Ok(text) = field.text().await {
|
||||
also_known_as = Some(text).filter(|s| !s.is_empty());
|
||||
}
|
||||
}
|
||||
"avatar" => {
|
||||
let ct = field.content_type().map(|s| s.to_string());
|
||||
if let Ok(bytes) = field.bytes().await
|
||||
&& !bytes.is_empty()
|
||||
{
|
||||
avatar_bytes = Some(bytes.to_vec());
|
||||
avatar_content_type = ct;
|
||||
}
|
||||
}
|
||||
"banner" => {
|
||||
let ct = field.content_type().map(|s| s.to_string());
|
||||
if let Ok(bytes) = field.bytes().await
|
||||
&& !bytes.is_empty()
|
||||
{
|
||||
banner_bytes = Some(bytes.to_vec());
|
||||
banner_content_type = ct;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let data = super::helpers::parse_profile_multipart(multipart).await;
|
||||
|
||||
let cmd = application::users::commands::UpdateProfileCommand {
|
||||
user_id: user_id.value(),
|
||||
display_name,
|
||||
bio,
|
||||
avatar_bytes,
|
||||
avatar_content_type,
|
||||
banner_bytes,
|
||||
banner_content_type,
|
||||
also_known_as,
|
||||
display_name: data.display_name,
|
||||
bio: data.bio,
|
||||
avatar_bytes: data.avatar_bytes,
|
||||
avatar_content_type: data.avatar_content_type,
|
||||
banner_bytes: data.banner_bytes,
|
||||
banner_content_type: data.banner_content_type,
|
||||
also_known_as: data.also_known_as,
|
||||
};
|
||||
|
||||
let deps = UpdateProfileDeps {
|
||||
@@ -325,7 +281,7 @@ pub async fn get_user_profile(
|
||||
});
|
||||
|
||||
let history = profile.history.map(|entries| {
|
||||
crate::mappers::users::group_by_month(entries)
|
||||
application::users::group_by_month(entries)
|
||||
.into_iter()
|
||||
.map(|m| MonthActivityDto {
|
||||
year_month: m.year_month,
|
||||
@@ -364,6 +320,7 @@ pub async fn get_user_profile(
|
||||
|
||||
Json(UserProfileResponse {
|
||||
user_id,
|
||||
profile: api_types::UserProfileBase {
|
||||
username: user.username().value().to_string(),
|
||||
avatar_url: user
|
||||
.avatar_path()
|
||||
@@ -371,6 +328,9 @@ pub async fn get_user_profile(
|
||||
banner_url: user
|
||||
.banner_path()
|
||||
.map(|p| format!("{}/images/{}", state.app_ctx.config.base_url, p)),
|
||||
display_name: None,
|
||||
bio: None,
|
||||
},
|
||||
stats: UserStatsDto {
|
||||
total_movies: profile.stats.total_movies,
|
||||
avg_rating: profile.stats.avg_rating,
|
||||
@@ -385,6 +345,7 @@ pub async fn get_user_profile(
|
||||
goals: {
|
||||
let goals_list = application::goals::list::execute(
|
||||
state.app_ctx.repos.goal.clone(),
|
||||
state.app_ctx.repos.stats.clone(),
|
||||
application::goals::queries::ListGoalsQuery { user_id },
|
||||
)
|
||||
.await
|
||||
@@ -397,8 +358,6 @@ pub async fn get_user_profile(
|
||||
},
|
||||
is_federated: false,
|
||||
handle: None,
|
||||
display_name: None,
|
||||
bio: None,
|
||||
actor_url: None,
|
||||
})
|
||||
.into_response()
|
||||
@@ -475,9 +434,13 @@ async fn build_federated_profile_response(
|
||||
|
||||
Json(UserProfileResponse {
|
||||
user_id,
|
||||
profile: api_types::UserProfileBase {
|
||||
username,
|
||||
avatar_url: fed.avatar_url,
|
||||
banner_url: fed.banner_url,
|
||||
display_name: fed.display_name,
|
||||
bio: fed.bio,
|
||||
},
|
||||
stats: UserStatsDto {
|
||||
total_movies: profile.stats.total_movies,
|
||||
avg_rating: profile.stats.avg_rating,
|
||||
@@ -492,8 +455,6 @@ async fn build_federated_profile_response(
|
||||
goals: None,
|
||||
is_federated: true,
|
||||
handle: Some(fed.handle),
|
||||
display_name: fed.display_name,
|
||||
bio: fed.bio,
|
||||
actor_url: Some(fed.actor_url),
|
||||
})
|
||||
.into_response()
|
||||
@@ -687,7 +648,7 @@ pub async fn get_user_profile_html(
|
||||
.most_active_month
|
||||
.clone()
|
||||
.unwrap_or_else(|| "\u{2014}".to_string());
|
||||
let history = profile.history.map(crate::mappers::users::group_by_month);
|
||||
let history = profile.history.map(application::users::group_by_month);
|
||||
let heatmap = history.as_deref().map(build_heatmap).unwrap_or_default();
|
||||
let monthly_rating_rows: Vec<MonthlyRatingRow<'_>> = profile
|
||||
.trends
|
||||
@@ -775,6 +736,7 @@ pub async fn get_user_profile_html(
|
||||
goals: {
|
||||
let goals_list = application::goals::list::execute(
|
||||
state.app_ctx.repos.goal.clone(),
|
||||
state.app_ctx.repos.stats.clone(),
|
||||
application::goals::queries::ListGoalsQuery {
|
||||
user_id: profile_user_uuid,
|
||||
},
|
||||
@@ -865,100 +827,45 @@ pub async fn get_profile_settings(
|
||||
pub async fn post_profile_settings(
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
State(state): State<AppState>,
|
||||
mut multipart: Multipart,
|
||||
multipart: Multipart,
|
||||
) -> impl IntoResponse {
|
||||
let mut display_name: Option<String> = None;
|
||||
let mut bio: Option<String> = None;
|
||||
let mut avatar_bytes: Option<Vec<u8>> = None;
|
||||
let mut avatar_content_type: Option<String> = None;
|
||||
let mut banner_bytes: Option<Vec<u8>> = None;
|
||||
let mut banner_content_type: Option<String> = None;
|
||||
let mut also_known_as: Option<String> = None;
|
||||
let mut field_names: std::collections::HashMap<usize, String> =
|
||||
std::collections::HashMap::new();
|
||||
let mut field_values: std::collections::HashMap<usize, String> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
while let Ok(Some(field)) = multipart.next_field().await {
|
||||
let name = field.name().unwrap_or("").to_string();
|
||||
match name.as_str() {
|
||||
"display_name" => {
|
||||
if let Ok(text) = field.text().await {
|
||||
display_name = Some(text).filter(|s| !s.is_empty());
|
||||
}
|
||||
}
|
||||
"bio" => {
|
||||
if let Ok(text) = field.text().await {
|
||||
bio = Some(text);
|
||||
}
|
||||
}
|
||||
"also_known_as" => {
|
||||
if let Ok(text) = field.text().await {
|
||||
also_known_as = Some(text).filter(|s| !s.is_empty());
|
||||
}
|
||||
}
|
||||
"avatar" => {
|
||||
let ct = field.content_type().map(|s| s.to_string());
|
||||
if let Ok(bytes) = field.bytes().await
|
||||
&& !bytes.is_empty()
|
||||
{
|
||||
avatar_bytes = Some(bytes.to_vec());
|
||||
avatar_content_type = ct;
|
||||
}
|
||||
}
|
||||
"banner" => {
|
||||
let ct = field.content_type().map(|s| s.to_string());
|
||||
if let Ok(bytes) = field.bytes().await
|
||||
&& !bytes.is_empty()
|
||||
{
|
||||
banner_bytes = Some(bytes.to_vec());
|
||||
banner_content_type = ct;
|
||||
}
|
||||
}
|
||||
n if n.starts_with("field_name_") => {
|
||||
if let Ok(idx) = n["field_name_".len()..].parse::<usize>()
|
||||
&& let Ok(text) = field.text().await
|
||||
&& !text.is_empty()
|
||||
{
|
||||
field_names.insert(idx, text);
|
||||
}
|
||||
}
|
||||
n if n.starts_with("field_value_") => {
|
||||
if let Ok(idx) = n["field_value_".len()..].parse::<usize>()
|
||||
&& let Ok(text) = field.text().await
|
||||
&& !text.is_empty()
|
||||
{
|
||||
field_values.insert(idx, text);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let data = super::helpers::parse_profile_multipart(multipart).await;
|
||||
|
||||
let cmd = application::users::commands::UpdateProfileCommand {
|
||||
user_id: user_id.value(),
|
||||
display_name,
|
||||
bio,
|
||||
avatar_bytes,
|
||||
avatar_content_type,
|
||||
banner_bytes,
|
||||
banner_content_type,
|
||||
also_known_as,
|
||||
display_name: data.display_name,
|
||||
bio: data.bio,
|
||||
avatar_bytes: data.avatar_bytes,
|
||||
avatar_content_type: data.avatar_content_type,
|
||||
banner_bytes: data.banner_bytes,
|
||||
banner_content_type: data.banner_content_type,
|
||||
also_known_as: data.also_known_as,
|
||||
};
|
||||
let update_deps = UpdateProfileDeps {
|
||||
user: state.app_ctx.repos.user.clone(),
|
||||
object_storage: state.app_ctx.services.object_storage.clone(),
|
||||
event_publisher: state.app_ctx.services.event_publisher.clone(),
|
||||
};
|
||||
let _ = update_profile::execute(&update_deps, cmd).await;
|
||||
if let Err(e) = update_profile::execute(&update_deps, cmd).await {
|
||||
tracing::error!("update_profile error: {:?}", e);
|
||||
return axum::response::Redirect::to(&format!(
|
||||
"/settings/profile?error={}",
|
||||
super::helpers::encode_error(&e.to_string())
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let fields: Vec<domain::models::ProfileField> = (0..4)
|
||||
.filter_map(|i| {
|
||||
field_names
|
||||
data.profile_field_names
|
||||
.get(&i)
|
||||
.map(|name| domain::models::ProfileField {
|
||||
name: name.clone(),
|
||||
value: field_values.get(&i).cloned().unwrap_or_default(),
|
||||
value: data
|
||||
.profile_field_values
|
||||
.get(&i)
|
||||
.cloned()
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -967,12 +874,20 @@ pub async fn post_profile_settings(
|
||||
user_id: user_id.value(),
|
||||
fields,
|
||||
};
|
||||
let _ = update_profile_fields::execute(
|
||||
if let Err(e) = update_profile_fields::execute(
|
||||
state.app_ctx.repos.profile_fields.clone(),
|
||||
state.app_ctx.services.event_publisher.clone(),
|
||||
fields_cmd,
|
||||
)
|
||||
.await;
|
||||
.await
|
||||
{
|
||||
tracing::error!("update_profile_fields error: {:?}", e);
|
||||
return axum::response::Redirect::to(&format!(
|
||||
"/settings/profile?error={}",
|
||||
super::helpers::encode_error(&e.to_string())
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
|
||||
axum::response::Redirect::to("/settings/profile?saved=1").into_response()
|
||||
}
|
||||
|
||||
@@ -32,12 +32,7 @@ use api_types::{
|
||||
};
|
||||
use template_askama::WatchlistTemplate;
|
||||
|
||||
use super::helpers::build_page_context;
|
||||
|
||||
fn encode_error(msg: &str) -> String {
|
||||
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
|
||||
utf8_percent_encode(msg, NON_ALPHANUMERIC).to_string()
|
||||
}
|
||||
use super::helpers::{build_page_context, encode_error};
|
||||
|
||||
// ── API ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -201,7 +201,7 @@ fn format_watch_time(minutes: u32) -> String {
|
||||
fn render_wrapup(
|
||||
report: &WrapUpReport,
|
||||
year: i32,
|
||||
ctx: &application::rendering::HtmlPageContext,
|
||||
ctx: &api_types::HtmlPageContext,
|
||||
) -> axum::response::Response {
|
||||
let rating_max = report
|
||||
.rating_distribution
|
||||
|
||||
@@ -13,6 +13,7 @@ use presentation::{factory, openapi, routes, state::AppState};
|
||||
use rss::RssAdapter;
|
||||
|
||||
use domain::ports::{DiaryExporter, DocumentParser, EventPublisher};
|
||||
use infra_wiring::EventBusBackend;
|
||||
|
||||
#[cfg(feature = "postgres")]
|
||||
use postgres_search;
|
||||
@@ -85,29 +86,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
|
||||
),
|
||||
};
|
||||
|
||||
let ep: Arc<dyn EventPublisher> = match event_bus {
|
||||
EventBusBackend::Db => {
|
||||
tracing::info!("event bus: DB queue");
|
||||
match &db_pool {
|
||||
#[cfg(feature = "postgres")]
|
||||
factory::DbPool::Postgres(pool) => {
|
||||
postgres_event_queue::PostgresEventQueue::create_publisher(pool.clone())
|
||||
.await?
|
||||
}
|
||||
#[cfg(feature = "sqlite")]
|
||||
factory::DbPool::Sqlite(pool) => {
|
||||
sqlite_event_queue::SqliteEventQueue::create_publisher(pool.clone()).await?
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "nats")]
|
||||
EventBusBackend::Nats => {
|
||||
let cfg = nats::NatsConfig::from_env()
|
||||
.context("EVENT_BUS_BACKEND=nats requires NATS_URL to be set")?;
|
||||
tracing::info!("event bus: NATS ({})", cfg.url);
|
||||
nats::create_publisher(cfg).await?
|
||||
}
|
||||
};
|
||||
let ep = create_event_publisher(event_bus, &db_pool).await?;
|
||||
|
||||
let ap = activitypub::wire(activitypub::ActivityPubDeps {
|
||||
activity_repo,
|
||||
@@ -118,6 +97,11 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
|
||||
remote_watchlist_repo: remote_watchlist_repo.clone(),
|
||||
remote_goal_repo: Arc::clone(&db.remote_goal),
|
||||
local_ap_content: Arc::clone(&ap_content_repo),
|
||||
movie_repo: Arc::clone(&db.movie),
|
||||
review_repo: Arc::clone(&db.review),
|
||||
diary_repo: Arc::clone(&db.diary),
|
||||
goal_repo: Arc::clone(&db.goal),
|
||||
stats_repo: Arc::clone(&db.stats),
|
||||
user_repo: Arc::clone(&db.user),
|
||||
federation_settings: std::sync::Arc::clone(&db.federation_settings),
|
||||
base_url: app_config.base_url.clone(),
|
||||
@@ -138,32 +122,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
|
||||
};
|
||||
|
||||
#[cfg(not(feature = "federation"))]
|
||||
let event_publisher_arc: Arc<dyn EventPublisher> = match event_bus {
|
||||
EventBusBackend::Db => {
|
||||
tracing::info!("event bus: DB queue");
|
||||
match &db_pool {
|
||||
#[cfg(feature = "postgres")]
|
||||
factory::DbPool::Postgres(pool) => {
|
||||
postgres_event_queue::PostgresEventQueue::create_publisher(pool.clone()).await?
|
||||
}
|
||||
#[cfg(feature = "sqlite")]
|
||||
factory::DbPool::Sqlite(pool) => {
|
||||
sqlite_event_queue::SqliteEventQueue::create_publisher(pool.clone()).await?
|
||||
}
|
||||
#[cfg(not(feature = "sqlite"))]
|
||||
_ => anyhow::bail!(
|
||||
"EVENT_BUS_BACKEND=db has no adapter for DATABASE_BACKEND={backend}; enable the sqlite or postgres feature"
|
||||
),
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "nats")]
|
||||
EventBusBackend::Nats => {
|
||||
let cfg = nats::NatsConfig::from_env()
|
||||
.context("EVENT_BUS_BACKEND=nats requires NATS_URL to be set")?;
|
||||
tracing::info!("event bus: NATS ({})", cfg.url);
|
||||
nats::create_publisher(cfg).await?
|
||||
}
|
||||
};
|
||||
let event_publisher_arc = create_event_publisher(event_bus, &db_pool).await?;
|
||||
#[cfg(not(feature = "federation"))]
|
||||
let ap_router = axum::Router::new();
|
||||
|
||||
@@ -253,27 +212,30 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
|
||||
Ok((state, ap_router))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum EventBusBackend {
|
||||
Db,
|
||||
#[cfg(feature = "nats")]
|
||||
Nats,
|
||||
}
|
||||
|
||||
impl EventBusBackend {
|
||||
fn from_env() -> anyhow::Result<Self> {
|
||||
match std::env::var("EVENT_BUS_BACKEND")
|
||||
.unwrap_or_else(|_| "db".to_string())
|
||||
.as_str()
|
||||
{
|
||||
"db" => Ok(Self::Db),
|
||||
#[cfg(feature = "nats")]
|
||||
"nats" => Ok(Self::Nats),
|
||||
#[cfg(not(feature = "nats"))]
|
||||
"nats" => {
|
||||
anyhow::bail!("EVENT_BUS_BACKEND=nats requires the nats feature to be compiled in")
|
||||
async fn create_event_publisher(
|
||||
event_bus: EventBusBackend,
|
||||
db_pool: &factory::DbPool,
|
||||
) -> anyhow::Result<Arc<dyn EventPublisher>> {
|
||||
match event_bus {
|
||||
EventBusBackend::Db => {
|
||||
tracing::info!("event bus: DB queue");
|
||||
Ok(match db_pool {
|
||||
#[cfg(feature = "postgres")]
|
||||
factory::DbPool::Postgres(pool) => {
|
||||
postgres_event_queue::PostgresEventQueue::create_publisher(pool.clone()).await?
|
||||
}
|
||||
other => anyhow::bail!("unknown EVENT_BUS_BACKEND={other}, expected 'db' or 'nats'"),
|
||||
#[cfg(feature = "sqlite")]
|
||||
factory::DbPool::Sqlite(pool) => {
|
||||
sqlite_event_queue::SqliteEventQueue::create_publisher(pool.clone()).await?
|
||||
}
|
||||
})
|
||||
}
|
||||
#[cfg(feature = "nats")]
|
||||
EventBusBackend::Nats => {
|
||||
let cfg = nats::NatsConfig::from_env()
|
||||
.context("EVENT_BUS_BACKEND=nats requires NATS_URL to be set")?;
|
||||
tracing::info!("event bus: NATS ({})", cfg.url);
|
||||
Ok(nats::create_publisher(cfg).await?)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,5 +2,8 @@ pub mod diary;
|
||||
pub mod import;
|
||||
pub mod integrations;
|
||||
pub mod movies;
|
||||
pub mod search;
|
||||
#[cfg(feature = "federation")]
|
||||
pub mod social;
|
||||
pub mod users;
|
||||
pub mod watchlist;
|
||||
|
||||
@@ -37,7 +37,7 @@ pub fn review_to_dto(review: &Review) -> ReviewDto {
|
||||
rating: review.rating().value(),
|
||||
comment: review.comment().map(|c| c.value().to_string()),
|
||||
watched_at: domain::value_objects::format_watched_at(review.watched_at()),
|
||||
watch_medium: review.watch_medium().map(|wm| wm.to_string()),
|
||||
watch_medium: review.watch_medium().copied(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
22
crates/presentation/src/mappers/search.rs
Normal file
22
crates/presentation/src/mappers/search.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use api_types::search::PersonDto;
|
||||
use domain::models::person::Person;
|
||||
|
||||
pub fn person_to_dto(p: &Person) -> PersonDto {
|
||||
PersonDto {
|
||||
id: p.id().value(),
|
||||
external_id: p.external_id().value().to_string(),
|
||||
name: p.name().to_string(),
|
||||
known_for_department: p.known_for_department().map(str::to_string),
|
||||
profile_path: p.profile_path().map(str::to_string),
|
||||
biography: p.biography().map(str::to_string),
|
||||
birthday: p.birthday().map(|d| d.to_string()),
|
||||
deathday: p.deathday().map(|d| d.to_string()),
|
||||
place_of_birth: p.place_of_birth().map(str::to_string),
|
||||
also_known_as: p.also_known_as().to_vec(),
|
||||
homepage: p.homepage().map(str::to_string),
|
||||
imdb_url: p
|
||||
.imdb_id()
|
||||
.map(|id| format!("https://www.imdb.com/name/{id}")),
|
||||
enriched: p.enriched_at().is_some(),
|
||||
}
|
||||
}
|
||||
9
crates/presentation/src/mappers/social.rs
Normal file
9
crates/presentation/src/mappers/social.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
use api_types::RemoteActorDto;
|
||||
|
||||
pub fn remote_actor_to_dto(a: activitypub::RemoteActor) -> RemoteActorDto {
|
||||
RemoteActorDto {
|
||||
handle: a.handle,
|
||||
display_name: a.display_name,
|
||||
url: a.url,
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
use application::users::get_profile::PendingFollowerView;
|
||||
use chrono::Datelike;
|
||||
use domain::models::RemoteActorInfo;
|
||||
use domain::models::{DiaryEntry, MonthActivity, UserSummary};
|
||||
use domain::models::UserSummary;
|
||||
use template_askama::{RemoteActorData, RemoteActorDisplay, UserSummaryView};
|
||||
|
||||
pub fn user_summary_view(u: &UserSummary) -> UserSummaryView {
|
||||
@@ -44,49 +43,3 @@ pub fn pending_follower_data(p: &PendingFollowerView) -> RemoteActorData {
|
||||
avatar_url: p.avatar_url.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn group_by_month(entries: Vec<DiaryEntry>) -> Vec<MonthActivity> {
|
||||
use std::collections::BTreeMap;
|
||||
let mut map: BTreeMap<(i32, u32), Vec<DiaryEntry>> = BTreeMap::new();
|
||||
for entry in entries {
|
||||
let watched_at = entry.review().watched_at();
|
||||
let year = watched_at.year();
|
||||
let month = watched_at.month();
|
||||
map.entry((year, month)).or_default().push(entry);
|
||||
}
|
||||
map.into_iter()
|
||||
.rev()
|
||||
.map(|((year, month), entries)| {
|
||||
let year_month = format!("{:04}-{:02}", year, month);
|
||||
MonthActivity {
|
||||
month_label: format_year_month_long(&year_month),
|
||||
count: entries.len() as i64,
|
||||
entries,
|
||||
year_month,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn format_year_month_long(ym: &str) -> String {
|
||||
let parts: Vec<&str> = ym.splitn(2, '-').collect();
|
||||
if parts.len() != 2 {
|
||||
return ym.to_string();
|
||||
}
|
||||
let month = match parts[1] {
|
||||
"01" => "January",
|
||||
"02" => "February",
|
||||
"03" => "March",
|
||||
"04" => "April",
|
||||
"05" => "May",
|
||||
"06" => "June",
|
||||
"07" => "July",
|
||||
"08" => "August",
|
||||
"09" => "September",
|
||||
"10" => "October",
|
||||
"11" => "November",
|
||||
"12" => "December",
|
||||
_ => parts[1],
|
||||
};
|
||||
format!("{} {}", month, parts[0])
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user