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