fmt
Some checks failed
CI / Check / Test / Build (push) Has been cancelled

This commit is contained in:
2026-05-13 23:38:57 +02:00
parent 7415b91e23
commit 19171806b9
142 changed files with 4140 additions and 2025 deletions

View File

@@ -72,7 +72,8 @@ impl Activity for FollowActivity {
let _follower = self.actor.dereference(data).await?; let _follower = self.actor.dereference(data).await?;
let local_actor = self.object.dereference(data).await?; let local_actor = self.object.dereference(data).await?;
if data.federation_repo if data
.federation_repo
.is_actor_blocked(local_actor.user_id, self.actor.inner().as_str()) .is_actor_blocked(local_actor.user_id, self.actor.inner().as_str())
.await? .await?
{ {
@@ -246,7 +247,11 @@ impl Activity for UndoActivity {
return Ok(()); return Ok(());
} }
let obj_type = self.object.get("type").and_then(|t| t.as_str()).unwrap_or(""); let obj_type = self
.object
.get("type")
.and_then(|t| t.as_str())
.unwrap_or("");
match obj_type { match obj_type {
"Follow" => { "Follow" => {
@@ -266,7 +271,8 @@ impl Activity for UndoActivity {
tracing::info!(actor = %self.actor.inner(), "unfollowed"); tracing::info!(actor = %self.actor.inner(), "unfollowed");
} }
"Add" => { "Add" => {
let ap_id_str = self.object let ap_id_str = self
.object
.get("object") .get("object")
.and_then(|o| o.get("id")) .and_then(|o| o.get("id"))
.and_then(|id| id.as_str()) .and_then(|id| id.as_str())

View File

@@ -222,14 +222,18 @@ impl Object for DbActor {
}); });
let profile_url = self.profile_url; let profile_url = self.profile_url;
let also_known_as: Vec<String> = self.also_known_as.into_iter().collect(); let also_known_as: Vec<String> = self.also_known_as.into_iter().collect();
let attachment: Vec<ProfileFieldObject> = self.attachment.into_iter().map(|f| ProfileFieldObject { let attachment: Vec<ProfileFieldObject> = self
kind: "PropertyValue".to_string(), .attachment
name: f.name, .into_iter()
value: f.value, .map(|f| ProfileFieldObject {
}).collect(); kind: "PropertyValue".to_string(),
name: f.name,
value: f.value,
})
.collect();
let shared_inbox = Url::parse(&format!("{}/inbox", data.base_url)) let shared_inbox =
.expect("base_url is always valid"); Url::parse(&format!("{}/inbox", data.base_url)).expect("base_url is always valid");
Ok(Person { Ok(Person {
kind: Default::default(), kind: Default::default(),

View File

@@ -56,9 +56,7 @@ pub async fn nodeinfo_well_known_handler(
})) }))
} }
pub async fn nodeinfo_handler( pub async fn nodeinfo_handler(data: Data<FederationData>) -> Result<Json<NodeInfo>, Error> {
data: Data<FederationData>,
) -> Result<Json<NodeInfo>, Error> {
let user_count = data.user_repo.count_users().await.unwrap_or(0); let user_count = data.user_repo.count_users().await.unwrap_or(0);
let local_posts = data.object_handler.count_local_posts().await.unwrap_or(0); let local_posts = data.object_handler.count_local_posts().await.unwrap_or(0);

View File

@@ -5,9 +5,7 @@ use serde::{Deserialize, Serialize};
use url::Url; use url::Url;
use activitypub_federation::{ use activitypub_federation::{
config::Data, config::Data, fetch::object_id::ObjectId, kinds::activity::CreateType,
fetch::object_id::ObjectId,
kinds::activity::CreateType,
protocol::context::WithContext, protocol::context::WithContext,
}; };
@@ -83,8 +81,7 @@ pub async fn outbox_handler(
let ordered_items: Vec<serde_json::Value> = items let ordered_items: Vec<serde_json::Value> = items
.into_iter() .into_iter()
.map(|(ap_id, object, _)| { .map(|(ap_id, object, _)| {
let create_id = let create_id = Url::parse(&format!("{}/activity", ap_id)).expect("valid url");
Url::parse(&format!("{}/activity", ap_id)).expect("valid url");
serde_json::to_value(WithContext::new_default(CreateActivity { serde_json::to_value(WithContext::new_default(CreateActivity {
id: create_id, id: create_id,
kind: CreateType::default(), kind: CreateType::default(),
@@ -105,9 +102,7 @@ pub async fn outbox_handler(
let next = if has_more { let next = if has_more {
oldest_ts.map(|ts| { oldest_ts.map(|ts| {
// Use RFC 3339 with Z suffix (no + sign) to avoid percent-encoding // Use RFC 3339 with Z suffix (no + sign) to avoid percent-encoding
let ts_str = ts let ts_str = ts.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
.format("%Y-%m-%dT%H:%M:%S%.3fZ")
.to_string();
format!("{}?page=true&before={}", outbox_url, ts_str) format!("{}?page=true&before={}", outbox_url, ts_str)
}) })
} else { } else {

View File

@@ -10,18 +10,23 @@ use axum::{Router, routing::get, routing::post};
use url::Url; use url::Url;
use crate::{ use crate::{
activities::{AcceptActivity, CreateActivity, FollowActivity, RejectActivity, UndoActivity, UpdateActivity}, activities::{
AcceptActivity, CreateActivity, FollowActivity, RejectActivity, UndoActivity,
UpdateActivity,
},
actors::{DbActor, get_local_actor}, actors::{DbActor, get_local_actor},
content::ApObjectHandler, content::ApObjectHandler,
data::FederationData, data::FederationData,
federation::ApFederationConfig, federation::ApFederationConfig,
followers_handler::{followers_handler, following_handler}, followers_handler::{followers_handler, following_handler},
inbox::inbox_handler, inbox::inbox_handler,
nodeinfo::{nodeinfo_handler, nodeinfo_well_known_handler},
outbox::outbox_handler, outbox::outbox_handler,
repository::{BlockedDomain, FederationRepository, FollowerStatus, FollowingStatus, RemoteActor}, repository::{
BlockedDomain, FederationRepository, FollowerStatus, FollowingStatus, RemoteActor,
},
urls::activity_url, urls::activity_url,
user::ApUserRepository, user::ApUserRepository,
nodeinfo::{nodeinfo_handler, nodeinfo_well_known_handler},
webfinger::webfinger_handler, webfinger::webfinger_handler,
}; };
@@ -35,9 +40,10 @@ fn collect_inboxes(followers: &[crate::repository::Follower]) -> Vec<Url> {
.as_deref() .as_deref()
.unwrap_or(&f.actor.inbox_url); .unwrap_or(&f.actor.inbox_url);
if seen.insert(inbox_str.to_string()) if seen.insert(inbox_str.to_string())
&& let Ok(url) = Url::parse(inbox_str) { && let Ok(url) = Url::parse(inbox_str)
inboxes.push(url); {
} inboxes.push(url);
}
} }
inboxes inboxes
} }
@@ -84,8 +90,13 @@ impl ActivityPubService {
event_publisher: Option<Arc<dyn domain::ports::EventPublisher>>, event_publisher: Option<Arc<dyn domain::ports::EventPublisher>>,
) -> anyhow::Result<Self> { ) -> anyhow::Result<Self> {
let data = FederationData::new( let data = FederationData::new(
repo, user_repo, object_handler, base_url.clone(), repo,
allow_registration, software_name, event_publisher, user_repo,
object_handler,
base_url.clone(),
allow_registration,
software_name,
event_publisher,
); );
let federation_config = ApFederationConfig::new(data, debug).await?; let federation_config = ApFederationConfig::new(data, debug).await?;
Ok(Self { Ok(Self {
@@ -550,8 +561,8 @@ impl ActivityPubService {
return Ok(()); return Ok(());
} }
let delete_id = crate::urls::activity_url(&self.base_url) let delete_id =
.map_err(|e| anyhow::anyhow!("{e}"))?; crate::urls::activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?;
let delete = crate::activities::DeleteActivity { let delete = crate::activities::DeleteActivity {
id: delete_id, id: delete_id,
kind: Default::default(), kind: Default::default(),
@@ -627,8 +638,7 @@ impl ActivityPubService {
}; };
let add_with_ctx = WithContext::new_default(add); let add_with_ctx = WithContext::new_default(add);
let inboxes = collect_inboxes(&accepted); let inboxes = collect_inboxes(&accepted);
let sends = let sends = SendActivityTask::prepare(&add_with_ctx, &local_actor, inboxes, &data).await?;
SendActivityTask::prepare(&add_with_ctx, &local_actor, inboxes, &data).await?;
let failures = send_with_retry(sends, &data).await; let failures = send_with_retry(sends, &data).await;
if !failures.is_empty() { if !failures.is_empty() {
tracing::warn!(count = failures.len(), "some Add deliveries failed"); tracing::warn!(count = failures.len(), "some Add deliveries failed");
@@ -678,8 +688,8 @@ impl ActivityPubService {
return Ok(()); return Ok(());
} }
let undo_id = crate::urls::activity_url(&self.base_url) let undo_id =
.map_err(|e| anyhow::anyhow!("{e}"))?; crate::urls::activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?;
let undo = crate::activities::UndoActivity { let undo = crate::activities::UndoActivity {
id: undo_id, id: undo_id,
kind: Default::default(), kind: Default::default(),
@@ -692,8 +702,7 @@ impl ActivityPubService {
}; };
let undo_with_ctx = WithContext::new_default(undo); let undo_with_ctx = WithContext::new_default(undo);
let inboxes = collect_inboxes(&accepted); let inboxes = collect_inboxes(&accepted);
let sends = let sends = SendActivityTask::prepare(&undo_with_ctx, &local_actor, inboxes, &data).await?;
SendActivityTask::prepare(&undo_with_ctx, &local_actor, inboxes, &data).await?;
let failures = send_with_retry(sends, &data).await; let failures = send_with_retry(sends, &data).await;
if !failures.is_empty() { if !failures.is_empty() {
tracing::warn!(count = failures.len(), "some Undo(Add) deliveries failed"); tracing::warn!(count = failures.len(), "some Undo(Add) deliveries failed");
@@ -778,7 +787,10 @@ impl ActivityPubService {
.await .await
.map_err(|e| anyhow::anyhow!("{e}"))?; .map_err(|e| anyhow::anyhow!("{e}"))?;
let person = local_actor.clone().into_json(&data).await let person = local_actor
.clone()
.into_json(&data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?; .map_err(|e| anyhow::anyhow!("{e}"))?;
// Wrap with @context so Mastodon's JSON-LD processor can resolve field names. // Wrap with @context so Mastodon's JSON-LD processor can resolve field names.
let person_json = serde_json::to_value(&WithContext::new_default(person))?; let person_json = serde_json::to_value(&WithContext::new_default(person))?;
@@ -831,29 +843,43 @@ impl ActivityPubService {
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
"actor update delivery failed for {} inbox(es): {}", "actor update delivery failed for {} inbox(es): {}",
failures.len(), failures.len(),
failures.iter().map(|e| e.to_string()).collect::<Vec<_>>().join("; ") failures
.iter()
.map(|e| e.to_string())
.collect::<Vec<_>>()
.join("; ")
)); ));
} }
tracing::info!(user_id = %user_id, "actor update broadcast complete"); tracing::info!(user_id = %user_id, "actor update broadcast complete");
Ok(()) Ok(())
} }
pub async fn block_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> anyhow::Result<()> { pub async fn block_actor(
&self,
local_user_id: uuid::Uuid,
actor_url: &str,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data(); let data = self.federation_config.to_request_data();
data.federation_repo data.federation_repo
.add_blocked_actor(local_user_id, actor_url) .add_blocked_actor(local_user_id, actor_url)
.await?; .await?;
let _ = data.federation_repo.remove_follower(local_user_id, actor_url).await; let _ = data
let _ = data.federation_repo.remove_following(local_user_id, actor_url).await; .federation_repo
.remove_follower(local_user_id, actor_url)
.await;
let _ = data
.federation_repo
.remove_following(local_user_id, actor_url)
.await;
let local_actor = get_local_actor(local_user_id, &data) let local_actor = get_local_actor(local_user_id, &data)
.await .await
.map_err(|e| anyhow::anyhow!("{e}"))?; .map_err(|e| anyhow::anyhow!("{e}"))?;
if let Ok(Some(remote_actor)) = data.federation_repo.get_remote_actor(actor_url).await { if let Ok(Some(remote_actor)) = data.federation_repo.get_remote_actor(actor_url).await {
let block_id = crate::urls::activity_url(&self.base_url) let block_id =
.map_err(|e| anyhow::anyhow!("{e}"))?; crate::urls::activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?;
let block = crate::activities::BlockActivity { let block = crate::activities::BlockActivity {
id: block_id, id: block_id,
kind: Default::default(), kind: Default::default(),
@@ -877,16 +903,26 @@ impl ActivityPubService {
Ok(()) Ok(())
} }
pub async fn unblock_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> anyhow::Result<()> { pub async fn unblock_actor(
&self,
local_user_id: uuid::Uuid,
actor_url: &str,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data(); let data = self.federation_config.to_request_data();
data.federation_repo data.federation_repo
.remove_blocked_actor(local_user_id, actor_url) .remove_blocked_actor(local_user_id, actor_url)
.await .await
} }
pub async fn get_blocked_actors(&self, local_user_id: uuid::Uuid) -> anyhow::Result<Vec<RemoteActor>> { pub async fn get_blocked_actors(
&self,
local_user_id: uuid::Uuid,
) -> anyhow::Result<Vec<RemoteActor>> {
let data = self.federation_config.to_request_data(); let data = self.federation_config.to_request_data();
let actor_urls = data.federation_repo.get_blocked_actors(local_user_id).await?; let actor_urls = data
.federation_repo
.get_blocked_actors(local_user_id)
.await?;
let mut actors = Vec::new(); let mut actors = Vec::new();
for url in actor_urls { for url in actor_urls {
let actor = match data.federation_repo.get_remote_actor(&url).await { let actor = match data.federation_repo.get_remote_actor(&url).await {
@@ -906,9 +942,15 @@ impl ActivityPubService {
Ok(actors) Ok(actors)
} }
pub async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> anyhow::Result<()> { pub async fn add_blocked_domain(
&self,
domain: &str,
reason: Option<&str>,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data(); let data = self.federation_config.to_request_data();
data.federation_repo.add_blocked_domain(domain, reason).await data.federation_repo
.add_blocked_domain(domain, reason)
.await
} }
pub async fn remove_blocked_domain(&self, domain: &str) -> anyhow::Result<()> { pub async fn remove_blocked_domain(&self, domain: &str) -> anyhow::Result<()> {

View File

@@ -4,7 +4,10 @@ use super::*;
fn person_serializes_with_enriched_fields() { fn person_serializes_with_enriched_fields() {
let person = Person { let person = Person {
kind: Default::default(), kind: Default::default(),
id: "https://example.com/users/1".parse::<url::Url>().unwrap().into(), id: "https://example.com/users/1"
.parse::<url::Url>()
.unwrap()
.into(),
preferred_username: "alice".to_string(), preferred_username: "alice".to_string(),
inbox: "https://example.com/users/1/inbox".parse().unwrap(), inbox: "https://example.com/users/1/inbox".parse().unwrap(),
outbox: "https://example.com/users/1/outbox".parse().unwrap(), outbox: "https://example.com/users/1/outbox".parse().unwrap(),
@@ -39,5 +42,8 @@ fn person_serializes_with_enriched_fields() {
assert_eq!(json["manuallyApprovesFollowers"], true); assert_eq!(json["manuallyApprovesFollowers"], true);
assert!(json.get("updated").is_some()); assert!(json.get("updated").is_some());
assert!(json.get("endpoints").is_some()); assert!(json.get("endpoints").is_some());
assert_eq!(json["endpoints"]["sharedInbox"], "https://example.com/inbox"); assert_eq!(
json["endpoints"]["sharedInbox"],
"https://example.com/inbox"
);
} }

View File

@@ -9,7 +9,10 @@ fn nodeinfo_well_known_serializes_correctly() {
}], }],
}; };
let json = serde_json::to_value(&doc).unwrap(); let json = serde_json::to_value(&doc).unwrap();
assert_eq!(json["links"][0]["rel"], "http://nodeinfo.diaspora.software/ns/schema/2.0"); assert_eq!(
json["links"][0]["rel"],
"http://nodeinfo.diaspora.software/ns/schema/2.0"
);
assert_eq!(json["links"][0]["href"], "https://example.com/nodeinfo/2.0"); assert_eq!(json["links"][0]["href"], "https://example.com/nodeinfo/2.0");
} }

View File

@@ -19,8 +19,14 @@ fn make_follower(inbox: &str, shared: Option<&str>) -> Follower {
#[test] #[test]
fn collect_inboxes_deduplicates_shared() { fn collect_inboxes_deduplicates_shared() {
let followers = vec![ let followers = vec![
make_follower("https://mastodon.social/users/a/inbox", Some("https://mastodon.social/inbox")), make_follower(
make_follower("https://mastodon.social/users/b/inbox", Some("https://mastodon.social/inbox")), "https://mastodon.social/users/a/inbox",
Some("https://mastodon.social/inbox"),
),
make_follower(
"https://mastodon.social/users/b/inbox",
Some("https://mastodon.social/inbox"),
),
make_follower("https://other.instance/users/c/inbox", None), make_follower("https://other.instance/users/c/inbox", None),
]; ];
let inboxes = collect_inboxes(&followers); let inboxes = collect_inboxes(&followers);
@@ -32,9 +38,7 @@ fn collect_inboxes_deduplicates_shared() {
#[test] #[test]
fn collect_inboxes_falls_back_to_individual_inbox() { fn collect_inboxes_falls_back_to_individual_inbox() {
let followers = vec![ let followers = vec![make_follower("https://example.com/users/x/inbox", None)];
make_follower("https://example.com/users/x/inbox", None),
];
let inboxes = collect_inboxes(&followers); let inboxes = collect_inboxes(&followers);
assert_eq!(inboxes.len(), 1); assert_eq!(inboxes.len(), 1);
assert_eq!(inboxes[0].as_str(), "https://example.com/users/x/inbox"); assert_eq!(inboxes[0].as_str(), "https://example.com/users/x/inbox");

View File

@@ -27,7 +27,9 @@ impl ApObjectHandler for CompositeObjectHandler {
before: Option<DateTime<Utc>>, before: Option<DateTime<Utc>>,
limit: usize, limit: usize,
) -> anyhow::Result<Vec<(Url, serde_json::Value, DateTime<Utc>)>> { ) -> anyhow::Result<Vec<(Url, serde_json::Value, DateTime<Utc>)>> {
self.review.get_local_objects_page(user_id, before, limit).await self.review
.get_local_objects_page(user_id, before, limit)
.await
} }
async fn on_create( async fn on_create(

View File

@@ -40,11 +40,15 @@ impl ActivityPubEventHandler {
impl EventHandler for ActivityPubEventHandler { impl EventHandler for ActivityPubEventHandler {
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> { async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> {
match event { match event {
DomainEvent::ReviewLogged { review_id, user_id, .. } => self DomainEvent::ReviewLogged {
review_id, user_id, ..
} => self
.on_review_logged(user_id, review_id) .on_review_logged(user_id, review_id)
.await .await
.map_err(|e| DomainError::InfrastructureError(e.to_string())), .map_err(|e| DomainError::InfrastructureError(e.to_string())),
DomainEvent::ReviewUpdated { review_id, user_id, .. } => self DomainEvent::ReviewUpdated {
review_id, user_id, ..
} => self
.on_review_updated(user_id, review_id) .on_review_updated(user_id, review_id)
.await .await
.map_err(|e| DomainError::InfrastructureError(e.to_string())), .map_err(|e| DomainError::InfrastructureError(e.to_string())),
@@ -65,7 +69,14 @@ impl EventHandler for ActivityPubEventHandler {
external_metadata_id, external_metadata_id,
added_at, added_at,
} => self } => self
.on_watchlist_added(user_id, movie_id, movie_title, *release_year, external_metadata_id, added_at) .on_watchlist_added(
user_id,
movie_id,
movie_title,
*release_year,
external_metadata_id,
added_at,
)
.await .await
.map_err(|e| DomainError::InfrastructureError(e.to_string())), .map_err(|e| DomainError::InfrastructureError(e.to_string())),
DomainEvent::WatchlistEntryRemoved { user_id, movie_id } => self DomainEvent::WatchlistEntryRemoved { user_id, movie_id } => self
@@ -124,7 +135,11 @@ impl ActivityPubEventHandler {
Ok(()) Ok(())
} }
async fn on_review_updated(&self, user_id: &UserId, review_id: &ReviewId) -> anyhow::Result<()> { async fn on_review_updated(
&self,
user_id: &UserId,
review_id: &ReviewId,
) -> anyhow::Result<()> {
let review = match self.review_repository.get_review_by_id(review_id).await? { let review = match self.review_repository.get_review_by_id(review_id).await? {
Some(r) => r, Some(r) => r,
None => return Ok(()), None => return Ok(()),
@@ -170,7 +185,11 @@ impl ActivityPubEventHandler {
Ok(()) Ok(())
} }
async fn on_review_deleted(&self, user_id: &UserId, review_id: &ReviewId) -> anyhow::Result<()> { async fn on_review_deleted(
&self,
user_id: &UserId,
review_id: &ReviewId,
) -> anyhow::Result<()> {
let ap_id = review_url(&self.base_url, review_id); let ap_id = review_url(&self.base_url, review_id);
self.ap_service self.ap_service
.broadcast_delete_to_followers(user_id.value(), ap_id) .broadcast_delete_to_followers(user_id.value(), ap_id)
@@ -197,7 +216,10 @@ impl ActivityPubEventHandler {
.await .await
.ok() .ok()
.flatten() .flatten()
.and_then(|m| m.poster_path().map(|p| format!("{}/images/{}", self.base_url, p.value()))); .and_then(|m| {
m.poster_path()
.map(|p| format!("{}/images/{}", self.base_url, p.value()))
});
let added_at_utc = let added_at_utc =
chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(*added_at, chrono::Utc); chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(*added_at, chrono::Utc);

View File

@@ -4,9 +4,9 @@ pub mod objects;
pub mod port; pub mod port;
pub mod remote_review_repository; pub mod remote_review_repository;
pub mod review_handler; pub mod review_handler;
pub mod watchlist_handler;
pub(crate) mod urls; pub(crate) mod urls;
pub mod user_adapter; pub mod user_adapter;
pub mod watchlist_handler;
// Re-export the generic base types that callers need // Re-export the generic base types that callers need
pub use activitypub_base::{ pub use activitypub_base::{
@@ -21,22 +21,22 @@ pub use review_handler::ReviewObjectHandler;
pub use user_adapter::DomainUserRepoAdapter; pub use user_adapter::DomainUserRepoAdapter;
pub struct ActivityPubWire { pub struct ActivityPubWire {
pub service: std::sync::Arc<dyn ActivityPubPort>, pub service: std::sync::Arc<dyn ActivityPubPort>,
pub router: axum::Router, pub router: axum::Router,
pub event_handler: std::sync::Arc<dyn domain::ports::EventHandler>, pub event_handler: std::sync::Arc<dyn domain::ports::EventHandler>,
} }
pub async fn wire( pub async fn wire(
federation_repo: std::sync::Arc<dyn FederationRepository>, federation_repo: std::sync::Arc<dyn FederationRepository>,
review_store: std::sync::Arc<dyn RemoteReviewRepository>, review_store: std::sync::Arc<dyn RemoteReviewRepository>,
remote_watchlist_repo: std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>, remote_watchlist_repo: std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>,
user_repo: std::sync::Arc<dyn domain::ports::UserRepository>, user_repo: std::sync::Arc<dyn domain::ports::UserRepository>,
movie_repo: std::sync::Arc<dyn domain::ports::MovieRepository>, movie_repo: std::sync::Arc<dyn domain::ports::MovieRepository>,
review_repo: std::sync::Arc<dyn domain::ports::ReviewRepository>, review_repo: std::sync::Arc<dyn domain::ports::ReviewRepository>,
diary_repo: std::sync::Arc<dyn domain::ports::DiaryRepository>, diary_repo: std::sync::Arc<dyn domain::ports::DiaryRepository>,
base_url: String, base_url: String,
allow_registration: bool, allow_registration: bool,
event_publisher: std::sync::Arc<dyn domain::ports::EventPublisher>, event_publisher: std::sync::Arc<dyn domain::ports::EventPublisher>,
) -> anyhow::Result<ActivityPubWire> { ) -> anyhow::Result<ActivityPubWire> {
let review_handler = std::sync::Arc::new(ReviewObjectHandler { let review_handler = std::sync::Arc::new(ReviewObjectHandler {
movie_repository: std::sync::Arc::clone(&movie_repo), movie_repository: std::sync::Arc::clone(&movie_repo),

View File

@@ -74,8 +74,7 @@ pub fn review_to_ap_object(
let tag = vec![ let tag = vec![
ApHashtag { ApHashtag {
kind: "Hashtag".to_string(), kind: "Hashtag".to_string(),
href: Url::parse(&format!("{}/tags/moviesdiary", base_url)) href: Url::parse(&format!("{}/tags/moviesdiary", base_url)).expect("valid base_url"),
.expect("valid base_url"),
name: "#MoviesDiary".to_string(), name: "#MoviesDiary".to_string(),
}, },
ApHashtag { ApHashtag {
@@ -152,8 +151,7 @@ pub fn watchlist_to_ap_object(
let tag = vec![ let tag = vec![
ApHashtag { ApHashtag {
kind: "Hashtag".to_string(), kind: "Hashtag".to_string(),
href: Url::parse(&format!("{}/tags/moviesdiary", base_url)) href: Url::parse(&format!("{}/tags/moviesdiary", base_url)).expect("valid base_url"),
.expect("valid base_url"),
name: "#MoviesDiary".to_string(), name: "#MoviesDiary".to_string(),
}, },
ApHashtag { ApHashtag {

View File

@@ -101,9 +101,10 @@ impl ApObjectHandler for ReviewObjectHandler {
chrono::DateTime::from_naive_utc_and_offset(*review.watched_at(), chrono::Utc); chrono::DateTime::from_naive_utc_and_offset(*review.watched_at(), chrono::Utc);
if let Some(cutoff) = before if let Some(cutoff) = before
&& published >= cutoff { && published >= cutoff
continue; {
} continue;
}
let ap_id = review_url(&self.base_url, review.id()); let ap_id = review_url(&self.base_url, review.id());
let actor_url = actor_url(&self.base_url, user_id); let actor_url = actor_url(&self.base_url, user_id);
@@ -118,7 +119,10 @@ impl ApObjectHandler for ReviewObjectHandler {
.as_ref() .as_ref()
.map(|m| m.title().value().to_string()) .map(|m| m.title().value().to_string())
.unwrap_or_else(|| "Unknown".to_string()); .unwrap_or_else(|| "Unknown".to_string());
let release_year = movie.as_ref().map(|m| m.release_year().value()).unwrap_or(0); let release_year = movie
.as_ref()
.map(|m| m.release_year().value())
.unwrap_or(0);
let poster_url = movie let poster_url = movie
.as_ref() .as_ref()
.and_then(|m| m.poster_path()) .and_then(|m| m.poster_path())

View File

@@ -4,7 +4,10 @@ use super::*;
fn normalize_hashtag_strips_non_alphanumeric() { fn normalize_hashtag_strips_non_alphanumeric() {
assert_eq!(normalize_hashtag("The Dark Knight"), "TheDarkKnight"); assert_eq!(normalize_hashtag("The Dark Knight"), "TheDarkKnight");
assert_eq!(normalize_hashtag("Schindler's List"), "SchindlersList"); assert_eq!(normalize_hashtag("Schindler's List"), "SchindlersList");
assert_eq!(normalize_hashtag("2001: A Space Odyssey"), "2001ASpaceOdyssey"); assert_eq!(
normalize_hashtag("2001: A Space Odyssey"),
"2001ASpaceOdyssey"
);
} }
#[test] #[test]

View File

@@ -15,6 +15,9 @@ pub fn review_url(base_url: &str, review_id: &ReviewId) -> Url {
/// Builds the canonical watchlist entry URL: `{base_url}/users/{user_id}/watchlist/{movie_id}` /// Builds the canonical watchlist entry URL: `{base_url}/users/{user_id}/watchlist/{movie_id}`
pub fn watchlist_entry_url(base_url: &str, user_id: uuid::Uuid, movie_id: uuid::Uuid) -> Url { pub fn watchlist_entry_url(base_url: &str, user_id: uuid::Uuid, movie_id: uuid::Uuid) -> Url {
Url::parse(&format!("{}/users/{}/watchlist/{}", base_url, user_id, movie_id)) Url::parse(&format!(
.expect("base_url is always a valid URL prefix") "{}/users/{}/watchlist/{}",
base_url, user_id, movie_id
))
.expect("base_url is always a valid URL prefix")
} }

View File

@@ -2,10 +2,7 @@ use std::sync::Arc;
use activitypub_base::{ApProfileField, ApUser, ApUserRepository}; use activitypub_base::{ApProfileField, ApUser, ApUserRepository};
use async_trait::async_trait; use async_trait::async_trait;
use domain::{ use domain::{ports::UserRepository, value_objects::UserId};
ports::UserRepository,
value_objects::UserId,
};
use url::Url; use url::Url;
pub struct DomainUserRepoAdapter { pub struct DomainUserRepoAdapter {
@@ -14,20 +11,17 @@ pub struct DomainUserRepoAdapter {
} }
impl DomainUserRepoAdapter { impl DomainUserRepoAdapter {
pub fn new( pub fn new(repo: Arc<dyn UserRepository>, base_url: String) -> Self {
repo: Arc<dyn UserRepository>,
base_url: String,
) -> Self {
Self { repo, base_url } Self { repo, base_url }
} }
fn build_user(&self, u: &domain::models::User) -> ApUser { fn build_user(&self, u: &domain::models::User) -> ApUser {
let avatar_url = u.avatar_path().and_then(|p| { let avatar_url = u
Url::parse(&format!("{}/images/{}", self.base_url, p)).ok() .avatar_path()
}); .and_then(|p| Url::parse(&format!("{}/images/{}", self.base_url, p)).ok());
let banner_url = u.banner_path().and_then(|p| { let banner_url = u
Url::parse(&format!("{}/images/{}", self.base_url, p)).ok() .banner_path()
}); .and_then(|p| Url::parse(&format!("{}/images/{}", self.base_url, p)).ok());
let profile_url = Url::parse(&format!("{}/u/{}", self.base_url, u.username().value())).ok(); let profile_url = Url::parse(&format!("{}/u/{}", self.base_url, u.username().value())).ok();
ApUser { ApUser {
id: u.id().value(), id: u.id().value(),
@@ -37,7 +31,14 @@ impl DomainUserRepoAdapter {
banner_url, banner_url,
also_known_as: u.also_known_as().map(|s| s.to_string()), also_known_as: u.also_known_as().map(|s| s.to_string()),
profile_url, profile_url,
attachment: u.profile_fields().iter().map(|f| ApProfileField { name: f.name.clone(), value: f.value.clone() }).collect(), attachment: u
.profile_fields()
.iter()
.map(|f| ApProfileField {
name: f.name.clone(),
value: f.value.clone(),
})
.collect(),
} }
} }
} }
@@ -55,7 +56,8 @@ impl ApUserRepository for DomainUserRepoAdapter {
async fn find_by_username(&self, username: &str) -> anyhow::Result<Option<ApUser>> { async fn find_by_username(&self, username: &str) -> anyhow::Result<Option<ApUser>> {
use domain::value_objects::Username; use domain::value_objects::Username;
let uname = Username::new(username.to_string()).map_err(|e| anyhow::anyhow!(e.to_string()))?; let uname =
Username::new(username.to_string()).map_err(|e| anyhow::anyhow!(e.to_string()))?;
let user = match self.repo.find_by_username(&uname).await? { let user = match self.repo.find_by_username(&uname).await? {
Some(u) => u, Some(u) => u,
None => return Ok(None), None => return Ok(None),
@@ -64,7 +66,10 @@ impl ApUserRepository for DomainUserRepoAdapter {
} }
async fn count_users(&self) -> anyhow::Result<usize> { async fn count_users(&self) -> anyhow::Result<usize> {
Ok(self.repo.list_with_stats().await Ok(self
.repo
.list_with_stats()
.await
.map_err(|e| anyhow::anyhow!(e.to_string()))? .map_err(|e| anyhow::anyhow!(e.to_string()))?
.len()) .len())
} }

View File

@@ -84,8 +84,7 @@ impl EventPayload {
} }
fn parse_uuid(s: &str, field: &str) -> Result<Uuid, DomainError> { fn parse_uuid(s: &str, field: &str) -> Result<Uuid, DomainError> {
Uuid::parse_str(s) Uuid::parse_str(s).map_err(|e| DomainError::InfrastructureError(format!("{field}: {e}")))
.map_err(|e| DomainError::InfrastructureError(format!("{field}: {e}")))
} }
fn parse_ts(ts: i64) -> Result<NaiveDateTime, DomainError> { fn parse_ts(ts: i64) -> Result<NaiveDateTime, DomainError> {
@@ -97,31 +96,43 @@ fn parse_ts(ts: i64) -> Result<NaiveDateTime, DomainError> {
impl From<&DomainEvent> for EventPayload { impl From<&DomainEvent> for EventPayload {
fn from(event: &DomainEvent) -> Self { fn from(event: &DomainEvent) -> Self {
match event { match event {
DomainEvent::ReviewLogged { review_id, movie_id, user_id, rating, watched_at } => { DomainEvent::ReviewLogged {
EventPayload::ReviewLogged { review_id,
review_id: review_id.value().to_string(), movie_id,
movie_id: movie_id.value().to_string(), user_id,
user_id: user_id.value().to_string(), rating,
rating: rating.value(), watched_at,
watched_at: watched_at.and_utc().timestamp(), } => EventPayload::ReviewLogged {
} review_id: review_id.value().to_string(),
} movie_id: movie_id.value().to_string(),
DomainEvent::ReviewUpdated { review_id, movie_id, user_id, rating, watched_at } => { user_id: user_id.value().to_string(),
EventPayload::ReviewUpdated { rating: rating.value(),
review_id: review_id.value().to_string(), watched_at: watched_at.and_utc().timestamp(),
movie_id: movie_id.value().to_string(), },
user_id: user_id.value().to_string(), DomainEvent::ReviewUpdated {
rating: rating.value(), review_id,
watched_at: watched_at.and_utc().timestamp(), movie_id,
} user_id,
} rating,
DomainEvent::MovieDiscovered { movie_id, external_metadata_id } => { watched_at,
EventPayload::MovieDiscovered { } => EventPayload::ReviewUpdated {
movie_id: movie_id.value().to_string(), review_id: review_id.value().to_string(),
external_metadata_id: external_metadata_id.value().to_owned(), movie_id: movie_id.value().to_string(),
} user_id: user_id.value().to_string(),
} rating: rating.value(),
DomainEvent::MovieDeleted { movie_id, poster_path } => EventPayload::MovieDeleted { watched_at: watched_at.and_utc().timestamp(),
},
DomainEvent::MovieDiscovered {
movie_id,
external_metadata_id,
} => EventPayload::MovieDiscovered {
movie_id: movie_id.value().to_string(),
external_metadata_id: external_metadata_id.value().to_owned(),
},
DomainEvent::MovieDeleted {
movie_id,
poster_path,
} => EventPayload::MovieDeleted {
movie_id: movie_id.value().to_string(), movie_id: movie_id.value().to_string(),
poster_path: poster_path.as_ref().map(|p| p.value().to_string()), poster_path: poster_path.as_ref().map(|p| p.value().to_string()),
}, },
@@ -132,36 +143,44 @@ impl From<&DomainEvent> for EventPayload {
review_id: review_id.value().to_string(), review_id: review_id.value().to_string(),
user_id: user_id.value().to_string(), user_id: user_id.value().to_string(),
}, },
DomainEvent::MovieEnrichmentRequested { movie_id, external_metadata_id } => { DomainEvent::MovieEnrichmentRequested {
EventPayload::MovieEnrichmentRequested { movie_id,
movie_id: movie_id.value().to_string(), external_metadata_id,
external_metadata_id: external_metadata_id.clone(), } => EventPayload::MovieEnrichmentRequested {
} movie_id: movie_id.value().to_string(),
} external_metadata_id: external_metadata_id.clone(),
},
DomainEvent::ImageStored { key } => EventPayload::ImageStored { key: key.clone() }, DomainEvent::ImageStored { key } => EventPayload::ImageStored { key: key.clone() },
DomainEvent::WatchlistEntryAdded { user_id, movie_id, movie_title, release_year, external_metadata_id, added_at } => { DomainEvent::WatchlistEntryAdded {
EventPayload::WatchlistEntryAdded { user_id,
user_id: user_id.value().to_string(), movie_id,
movie_id: movie_id.value().to_string(), movie_title,
movie_title: movie_title.clone(), release_year,
release_year: *release_year, external_metadata_id,
external_metadata_id: external_metadata_id.clone(), added_at,
added_at: added_at.and_utc().timestamp(), } => EventPayload::WatchlistEntryAdded {
} user_id: user_id.value().to_string(),
} movie_id: movie_id.value().to_string(),
movie_title: movie_title.clone(),
release_year: *release_year,
external_metadata_id: external_metadata_id.clone(),
added_at: added_at.and_utc().timestamp(),
},
DomainEvent::WatchlistEntryRemoved { user_id, movie_id } => { DomainEvent::WatchlistEntryRemoved { user_id, movie_id } => {
EventPayload::WatchlistEntryRemoved { EventPayload::WatchlistEntryRemoved {
user_id: user_id.value().to_string(), user_id: user_id.value().to_string(),
movie_id: movie_id.value().to_string(), movie_id: movie_id.value().to_string(),
} }
} }
DomainEvent::FollowAccepted { local_user_id, remote_actor_url, outbox_url } => { DomainEvent::FollowAccepted {
EventPayload::FollowAccepted { local_user_id,
local_user_id: local_user_id.value().to_string(), remote_actor_url,
remote_actor_url: remote_actor_url.clone(), outbox_url,
outbox_url: outbox_url.clone(), } => EventPayload::FollowAccepted {
} local_user_id: local_user_id.value().to_string(),
} remote_actor_url: remote_actor_url.clone(),
outbox_url: outbox_url.clone(),
},
} }
} }
} }
@@ -170,81 +189,98 @@ impl TryFrom<EventPayload> for DomainEvent {
type Error = DomainError; type Error = DomainError;
fn try_from(payload: EventPayload) -> Result<Self, DomainError> { fn try_from(payload: EventPayload) -> Result<Self, DomainError> {
match payload { match payload {
EventPayload::ReviewLogged { review_id, movie_id, user_id, rating, watched_at } => { EventPayload::ReviewLogged {
Ok(DomainEvent::ReviewLogged { review_id,
review_id: ReviewId::from_uuid(parse_uuid(&review_id, "review_id")?), movie_id,
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?), user_id,
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?), rating,
rating: Rating::new(rating)?, watched_at,
watched_at: parse_ts(watched_at)?, } => Ok(DomainEvent::ReviewLogged {
}) review_id: ReviewId::from_uuid(parse_uuid(&review_id, "review_id")?),
} movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
EventPayload::ReviewUpdated { review_id, movie_id, user_id, rating, watched_at } => { user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
Ok(DomainEvent::ReviewUpdated { rating: Rating::new(rating)?,
review_id: ReviewId::from_uuid(parse_uuid(&review_id, "review_id")?), watched_at: parse_ts(watched_at)?,
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?), }),
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?), EventPayload::ReviewUpdated {
rating: Rating::new(rating)?, review_id,
watched_at: parse_ts(watched_at)?, movie_id,
}) user_id,
} rating,
EventPayload::MovieDiscovered { movie_id, external_metadata_id } => { watched_at,
Ok(DomainEvent::MovieDiscovered { } => Ok(DomainEvent::ReviewUpdated {
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?), review_id: ReviewId::from_uuid(parse_uuid(&review_id, "review_id")?),
external_metadata_id: ExternalMetadataId::new(external_metadata_id)?, movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
}) user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
} rating: Rating::new(rating)?,
EventPayload::MovieDeleted { movie_id, poster_path } => { watched_at: parse_ts(watched_at)?,
}),
EventPayload::MovieDiscovered {
movie_id,
external_metadata_id,
} => Ok(DomainEvent::MovieDiscovered {
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
external_metadata_id: ExternalMetadataId::new(external_metadata_id)?,
}),
EventPayload::MovieDeleted {
movie_id,
poster_path,
} => {
let movie_id = MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?); let movie_id = MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?);
let poster_path = poster_path let poster_path = poster_path
.map(PosterPath::new) .map(PosterPath::new)
.transpose() .transpose()
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?; .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(DomainEvent::MovieDeleted { movie_id, poster_path }) Ok(DomainEvent::MovieDeleted {
} movie_id,
EventPayload::UserUpdated { user_id } => { poster_path,
Ok(DomainEvent::UserUpdated {
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
})
}
EventPayload::ReviewDeleted { review_id, user_id } => {
Ok(DomainEvent::ReviewDeleted {
review_id: ReviewId::from_uuid(parse_uuid(&review_id, "review_id")?),
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
})
}
EventPayload::MovieEnrichmentRequested { movie_id, external_metadata_id } => {
Ok(DomainEvent::MovieEnrichmentRequested {
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
external_metadata_id,
})
}
EventPayload::ImageStored { key } => {
Ok(DomainEvent::ImageStored { key })
}
EventPayload::WatchlistEntryAdded { user_id, movie_id, movie_title, release_year, external_metadata_id, added_at } => {
Ok(DomainEvent::WatchlistEntryAdded {
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
movie_title,
release_year,
external_metadata_id,
added_at: parse_ts(added_at)?,
}) })
} }
EventPayload::UserUpdated { user_id } => Ok(DomainEvent::UserUpdated {
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
}),
EventPayload::ReviewDeleted { review_id, user_id } => Ok(DomainEvent::ReviewDeleted {
review_id: ReviewId::from_uuid(parse_uuid(&review_id, "review_id")?),
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
}),
EventPayload::MovieEnrichmentRequested {
movie_id,
external_metadata_id,
} => Ok(DomainEvent::MovieEnrichmentRequested {
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
external_metadata_id,
}),
EventPayload::ImageStored { key } => Ok(DomainEvent::ImageStored { key }),
EventPayload::WatchlistEntryAdded {
user_id,
movie_id,
movie_title,
release_year,
external_metadata_id,
added_at,
} => Ok(DomainEvent::WatchlistEntryAdded {
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
movie_title,
release_year,
external_metadata_id,
added_at: parse_ts(added_at)?,
}),
EventPayload::WatchlistEntryRemoved { user_id, movie_id } => { EventPayload::WatchlistEntryRemoved { user_id, movie_id } => {
Ok(DomainEvent::WatchlistEntryRemoved { Ok(DomainEvent::WatchlistEntryRemoved {
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?), user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?), movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
}) })
} }
EventPayload::FollowAccepted { local_user_id, remote_actor_url, outbox_url } => { EventPayload::FollowAccepted {
Ok(DomainEvent::FollowAccepted { local_user_id,
local_user_id: UserId::from_uuid(parse_uuid(&local_user_id, "local_user_id")?), remote_actor_url,
remote_actor_url, outbox_url,
outbox_url, } => Ok(DomainEvent::FollowAccepted {
}) local_user_id: UserId::from_uuid(parse_uuid(&local_user_id, "local_user_id")?),
} remote_actor_url,
outbox_url,
}),
} }
} }
} }

View File

@@ -1,7 +1,9 @@
use super::*; use super::*;
fn fixed_dt() -> NaiveDateTime { fn fixed_dt() -> NaiveDateTime {
chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap().naive_utc() chrono::DateTime::from_timestamp(1_700_000_000, 0)
.unwrap()
.naive_utc()
} }
fn review_logged() -> DomainEvent { fn review_logged() -> DomainEvent {
@@ -64,14 +66,25 @@ fn serialized_format_is_tagged() {
#[test] #[test]
fn event_type_strings() { fn event_type_strings() {
assert_eq!(EventPayload::from(&review_logged()).event_type(), "ReviewLogged"); assert_eq!(
assert_eq!(EventPayload::from(&review_updated()).event_type(), "ReviewUpdated"); EventPayload::from(&review_logged()).event_type(),
assert_eq!(EventPayload::from(&movie_discovered()).event_type(), "MovieDiscovered"); "ReviewLogged"
);
assert_eq!(
EventPayload::from(&review_updated()).event_type(),
"ReviewUpdated"
);
assert_eq!(
EventPayload::from(&movie_discovered()).event_type(),
"MovieDiscovered"
);
} }
#[test] #[test]
fn round_trip_image_stored() { fn round_trip_image_stored() {
let event = DomainEvent::ImageStored { key: "avatars/abc123".into() }; let event = DomainEvent::ImageStored {
key: "avatars/abc123".into(),
};
let payload = EventPayload::from(&event); let payload = EventPayload::from(&event);
let json = serde_json::to_string(&payload).unwrap(); let json = serde_json::to_string(&payload).unwrap();
let back: EventPayload = serde_json::from_str(&json).unwrap(); let back: EventPayload = serde_json::from_str(&json).unwrap();
@@ -81,6 +94,8 @@ fn round_trip_image_stored() {
#[test] #[test]
fn image_stored_event_type() { fn image_stored_event_type() {
let payload = EventPayload::from(&DomainEvent::ImageStored { key: "posters/x".into() }); let payload = EventPayload::from(&DomainEvent::ImageStored {
key: "posters/x".into(),
});
assert_eq!(payload.event_type(), "ImageStored"); assert_eq!(payload.event_type(), "ImageStored");
} }

View File

@@ -43,8 +43,12 @@ struct NoopAck;
#[async_trait] #[async_trait]
impl AckHandle for NoopAck { impl AckHandle for NoopAck {
async fn ack(&self) -> Result<(), DomainError> { Ok(()) } async fn ack(&self) -> Result<(), DomainError> {
async fn nack(&self) -> Result<(), DomainError> { Ok(()) } Ok(())
}
async fn nack(&self) -> Result<(), DomainError> {
Ok(())
}
} }
pub struct ChannelEventConsumer { pub struct ChannelEventConsumer {

View File

@@ -22,7 +22,10 @@ async fn consumer_yields_published_events() {
let mut stream = consumer.consume(); let mut stream = consumer.consume();
let envelope = stream.next().await.unwrap().unwrap(); let envelope = stream.next().await.unwrap().unwrap();
assert!(matches!(envelope.event, DomainEvent::MovieDiscovered { .. })); assert!(matches!(
envelope.event,
DomainEvent::MovieDiscovered { .. }
));
assert!(stream.next().await.is_none()); assert!(stream.next().await.is_none());
} }

View File

@@ -61,9 +61,7 @@ async fn csv_has_header_and_one_row() {
.unwrap(); .unwrap();
let text = String::from_utf8(bytes).unwrap(); let text = String::from_utf8(bytes).unwrap();
assert!( assert!(
text.starts_with( text.starts_with("title,year,director,rating,comment,watched_at,external_metadata_id\n")
"title,year,director,rating,comment,watched_at,external_metadata_id\n"
)
); );
assert!(text.contains("Inception")); assert!(text.contains("Inception"));
assert!(text.contains("2010")); assert!(text.contains("2010"));

View File

@@ -17,7 +17,10 @@ impl ConversionBackfillJob {
image_ref: Arc<dyn ImageRefQuery>, image_ref: Arc<dyn ImageRefQuery>,
event_publisher: Arc<dyn EventPublisher>, event_publisher: Arc<dyn EventPublisher>,
) -> Self { ) -> Self {
Self { image_ref, event_publisher } Self {
image_ref,
event_publisher,
}
} }
} }
@@ -34,7 +37,8 @@ impl PeriodicJob for ConversionBackfillJob {
if key.ends_with(".avif") || key.ends_with(".webp") { if key.ends_with(".avif") || key.ends_with(".webp") {
continue; continue;
} }
if let Err(e) = self.event_publisher if let Err(e) = self
.event_publisher
.publish(&DomainEvent::ImageStored { key: key.clone() }) .publish(&DomainEvent::ImageStored { key: key.clone() })
.await .await
{ {

View File

@@ -21,7 +21,11 @@ impl ImageConversionHandler {
image_ref: Arc<dyn ImageRefCommand>, image_ref: Arc<dyn ImageRefCommand>,
format: Format, format: Format,
) -> Self { ) -> Self {
Self { storage, image_ref, format } Self {
storage,
image_ref,
format,
}
} }
} }
@@ -73,7 +77,12 @@ fn convert(bytes: Vec<u8>, format: Format) -> Result<Vec<u8>, String> {
let height = rgba.height() as usize; let height = rgba.height() as usize;
let pixels: Vec<ravif::RGBA8> = rgba let pixels: Vec<ravif::RGBA8> = rgba
.pixels() .pixels()
.map(|p| ravif::RGBA8 { r: p.0[0], g: p.0[1], b: p.0[2], a: p.0[3] }) .map(|p| ravif::RGBA8 {
r: p.0[0],
g: p.0[1],
b: p.0[2],
a: p.0[3],
})
.collect(); .collect();
let result = ravif::Encoder::new() let result = ravif::Encoder::new()
.with_quality(80.0) .with_quality(80.0)

View File

@@ -6,8 +6,10 @@ pub use backfill::ConversionBackfillJob;
pub use config::{ConversionConfig, Format}; pub use config::{ConversionConfig, Format};
pub use handler::ImageConversionHandler; pub use handler::ImageConversionHandler;
use domain::ports::{
EventHandler, EventPublisher, ImageRefCommand, ImageRefQuery, ImageStorage, PeriodicJob,
};
use std::sync::Arc; use std::sync::Arc;
use domain::ports::{EventHandler, EventPublisher, ImageRefCommand, ImageRefQuery, ImageStorage, PeriodicJob};
pub fn build( pub fn build(
image_storage: Arc<dyn ImageStorage>, image_storage: Arc<dyn ImageStorage>,

View File

@@ -18,7 +18,9 @@ struct MockPublisher {
impl MockPublisher { impl MockPublisher {
fn new() -> Arc<Self> { fn new() -> Arc<Self> {
Arc::new(Self { emitted: Mutex::new(vec![]) }) Arc::new(Self {
emitted: Mutex::new(vec![]),
})
} }
fn emitted(&self) -> Vec<String> { fn emitted(&self) -> Vec<String> {
@@ -42,10 +44,8 @@ async fn emits_image_stored_for_unconverted_keys() {
keys: vec!["avatars/u1".into(), "posters/m1".into()], keys: vec!["avatars/u1".into(), "posters/m1".into()],
}); });
let publisher = MockPublisher::new(); let publisher = MockPublisher::new();
let job = ConversionBackfillJob::new( let job =
image_ref, ConversionBackfillJob::new(image_ref, Arc::clone(&publisher) as Arc<dyn EventPublisher>);
Arc::clone(&publisher) as Arc<dyn EventPublisher>,
);
job.run().await.unwrap(); job.run().await.unwrap();
@@ -64,10 +64,8 @@ async fn skips_already_converted_keys() {
], ],
}); });
let publisher = MockPublisher::new(); let publisher = MockPublisher::new();
let job = ConversionBackfillJob::new( let job =
image_ref, ConversionBackfillJob::new(image_ref, Arc::clone(&publisher) as Arc<dyn EventPublisher>);
Arc::clone(&publisher) as Arc<dyn EventPublisher>,
);
job.run().await.unwrap(); job.run().await.unwrap();
@@ -78,10 +76,8 @@ async fn skips_already_converted_keys() {
async fn empty_keys_emits_nothing() { async fn empty_keys_emits_nothing() {
let image_ref = Arc::new(MockImageRef { keys: vec![] }); let image_ref = Arc::new(MockImageRef { keys: vec![] });
let publisher = MockPublisher::new(); let publisher = MockPublisher::new();
let job = ConversionBackfillJob::new( let job =
image_ref, ConversionBackfillJob::new(image_ref, Arc::clone(&publisher) as Arc<dyn EventPublisher>);
Arc::clone(&publisher) as Arc<dyn EventPublisher>,
);
job.run().await.unwrap(); job.run().await.unwrap();

View File

@@ -3,18 +3,24 @@ use super::*;
#[test] #[test]
fn disabled_by_default() { fn disabled_by_default() {
assert!(ConversionConfig::from_vars(None, None).unwrap().is_none()); assert!(ConversionConfig::from_vars(None, None).unwrap().is_none());
assert!(ConversionConfig::from_vars(Some("false"), None).unwrap().is_none()); assert!(ConversionConfig::from_vars(Some("false"), None)
.unwrap()
.is_none());
} }
#[test] #[test]
fn enabled_avif() { fn enabled_avif() {
let cfg = ConversionConfig::from_vars(Some("true"), Some("avif")).unwrap().unwrap(); let cfg = ConversionConfig::from_vars(Some("true"), Some("avif"))
.unwrap()
.unwrap();
assert_eq!(cfg.format, Format::Avif); assert_eq!(cfg.format, Format::Avif);
} }
#[test] #[test]
fn enabled_webp() { fn enabled_webp() {
let cfg = ConversionConfig::from_vars(Some("true"), Some("webp")).unwrap().unwrap(); let cfg = ConversionConfig::from_vars(Some("true"), Some("webp"))
.unwrap()
.unwrap();
assert_eq!(cfg.format, Format::Webp); assert_eq!(cfg.format, Format::Webp);
} }

View File

@@ -1,7 +1,7 @@
use super::*; use super::*;
use std::sync::Mutex;
use object_store::memory::InMemory;
use image_storage::ImageStorageAdapter; use image_storage::ImageStorageAdapter;
use object_store::memory::InMemory;
use std::sync::Mutex;
struct MockImageRef { struct MockImageRef {
swaps: Mutex<Vec<(String, String)>>, swaps: Mutex<Vec<(String, String)>>,
@@ -9,7 +9,9 @@ struct MockImageRef {
impl MockImageRef { impl MockImageRef {
fn new() -> Arc<Self> { fn new() -> Arc<Self> {
Arc::new(Self { swaps: Mutex::new(vec![]) }) Arc::new(Self {
swaps: Mutex::new(vec![]),
})
} }
fn swaps(&self) -> Vec<(String, String)> { fn swaps(&self) -> Vec<(String, String)> {
@@ -31,9 +33,7 @@ fn in_memory_storage() -> Arc<ImageStorageAdapter> {
fn tiny_jpeg() -> Vec<u8> { fn tiny_jpeg() -> Vec<u8> {
use image::{DynamicImage, ImageBuffer, Rgb}; use image::{DynamicImage, ImageBuffer, Rgb};
let img = DynamicImage::ImageRgb8( let img = DynamicImage::ImageRgb8(ImageBuffer::from_pixel(4, 4, Rgb([200u8, 100, 50])));
ImageBuffer::from_pixel(4, 4, Rgb([200u8, 100, 50])),
);
let mut buf = std::io::Cursor::new(Vec::new()); let mut buf = std::io::Cursor::new(Vec::new());
img.write_to(&mut buf, image::ImageFormat::Jpeg).unwrap(); img.write_to(&mut buf, image::ImageFormat::Jpeg).unwrap();
buf.into_inner() buf.into_inner()
@@ -49,9 +49,12 @@ async fn ignores_non_image_stored_events() {
Format::Avif, Format::Avif,
); );
handler.handle(&DomainEvent::UserUpdated { handler
user_id: domain::value_objects::UserId::from_uuid(uuid::Uuid::new_v4()), .handle(&DomainEvent::UserUpdated {
}).await.unwrap(); user_id: domain::value_objects::UserId::from_uuid(uuid::Uuid::new_v4()),
})
.await
.unwrap();
assert!(image_ref.swaps().is_empty()); assert!(image_ref.swaps().is_empty());
} }
@@ -59,7 +62,10 @@ async fn ignores_non_image_stored_events() {
#[tokio::test] #[tokio::test]
async fn skips_already_converted_avif_key() { async fn skips_already_converted_avif_key() {
let storage = in_memory_storage(); let storage = in_memory_storage();
storage.store("avatars/u1.avif", &tiny_jpeg()).await.unwrap(); storage
.store("avatars/u1.avif", &tiny_jpeg())
.await
.unwrap();
let image_ref = MockImageRef::new(); let image_ref = MockImageRef::new();
let handler = ImageConversionHandler::new( let handler = ImageConversionHandler::new(
Arc::clone(&storage) as Arc<dyn ImageStorage>, Arc::clone(&storage) as Arc<dyn ImageStorage>,
@@ -67,7 +73,12 @@ async fn skips_already_converted_avif_key() {
Format::Avif, Format::Avif,
); );
handler.handle(&DomainEvent::ImageStored { key: "avatars/u1.avif".into() }).await.unwrap(); handler
.handle(&DomainEvent::ImageStored {
key: "avatars/u1.avif".into(),
})
.await
.unwrap();
assert!(image_ref.swaps().is_empty()); assert!(image_ref.swaps().is_empty());
} }
@@ -75,7 +86,10 @@ async fn skips_already_converted_avif_key() {
#[tokio::test] #[tokio::test]
async fn skips_already_converted_webp_key() { async fn skips_already_converted_webp_key() {
let storage = in_memory_storage(); let storage = in_memory_storage();
storage.store("posters/m1.webp", &tiny_jpeg()).await.unwrap(); storage
.store("posters/m1.webp", &tiny_jpeg())
.await
.unwrap();
let image_ref = MockImageRef::new(); let image_ref = MockImageRef::new();
let handler = ImageConversionHandler::new( let handler = ImageConversionHandler::new(
Arc::clone(&storage) as Arc<dyn ImageStorage>, Arc::clone(&storage) as Arc<dyn ImageStorage>,
@@ -83,7 +97,12 @@ async fn skips_already_converted_webp_key() {
Format::Webp, Format::Webp,
); );
handler.handle(&DomainEvent::ImageStored { key: "posters/m1.webp".into() }).await.unwrap(); handler
.handle(&DomainEvent::ImageStored {
key: "posters/m1.webp".into(),
})
.await
.unwrap();
assert!(image_ref.swaps().is_empty()); assert!(image_ref.swaps().is_empty());
} }
@@ -99,9 +118,17 @@ async fn converts_jpeg_to_avif_and_swaps_key() {
Format::Avif, Format::Avif,
); );
handler.handle(&DomainEvent::ImageStored { key: "avatars/u1".into() }).await.unwrap(); handler
.handle(&DomainEvent::ImageStored {
key: "avatars/u1".into(),
})
.await
.unwrap();
assert_eq!(image_ref.swaps(), vec![("avatars/u1".into(), "avatars/u1.avif".into())]); assert_eq!(
image_ref.swaps(),
vec![("avatars/u1".into(), "avatars/u1.avif".into())]
);
assert!(storage.get("avatars/u1.avif").await.is_ok()); assert!(storage.get("avatars/u1.avif").await.is_ok());
assert!(storage.get("avatars/u1").await.is_err()); assert!(storage.get("avatars/u1").await.is_err());
} }
@@ -117,9 +144,17 @@ async fn converts_jpeg_to_webp_and_swaps_key() {
Format::Webp, Format::Webp,
); );
handler.handle(&DomainEvent::ImageStored { key: "avatars/u1".into() }).await.unwrap(); handler
.handle(&DomainEvent::ImageStored {
key: "avatars/u1".into(),
})
.await
.unwrap();
assert_eq!(image_ref.swaps(), vec![("avatars/u1".into(), "avatars/u1.webp".into())]); assert_eq!(
image_ref.swaps(),
vec![("avatars/u1".into(), "avatars/u1.webp".into())]
);
assert!(storage.get("avatars/u1.webp").await.is_ok()); assert!(storage.get("avatars/u1.webp").await.is_ok());
assert!(storage.get("avatars/u1").await.is_err()); assert!(storage.get("avatars/u1").await.is_err());
} }

View File

@@ -10,7 +10,6 @@ use domain::{
use object_store::{ObjectStore, path::Path}; use object_store::{ObjectStore, path::Path};
use std::sync::Arc; use std::sync::Arc;
pub struct ImageStorageAdapter { pub struct ImageStorageAdapter {
store: Arc<dyn ObjectStore>, store: Arc<dyn ObjectStore>,
} }
@@ -76,7 +75,9 @@ impl EventHandler for ImageCleanupHandler {
DomainEvent::MovieDeleted { poster_path, .. } => poster_path, DomainEvent::MovieDeleted { poster_path, .. } => poster_path,
_ => return Ok(()), _ => return Ok(()),
}; };
let Some(path) = poster_path else { return Ok(()) }; let Some(path) = poster_path else {
return Ok(());
};
if let Err(e) = self.image_storage.delete(path.value()).await { if let Err(e) = self.image_storage.delete(path.value()).await {
tracing::warn!("image cleanup failed for {}: {e}", path.value()); tracing::warn!("image cleanup failed for {}: {e}", path.value());
} }
@@ -85,7 +86,9 @@ impl EventHandler for ImageCleanupHandler {
} }
pub fn create() -> anyhow::Result<Arc<dyn ImageStorage>> { pub fn create() -> anyhow::Result<Arc<dyn ImageStorage>> {
Ok(Arc::new(ImageStorageAdapter::from_config(StorageConfig::from_env()?))) Ok(Arc::new(ImageStorageAdapter::from_config(
StorageConfig::from_env()?,
)))
} }
#[cfg(test)] #[cfg(test)]

View File

@@ -39,7 +39,10 @@ async fn delete_missing_returns_ok() {
#[tokio::test] #[tokio::test]
async fn cleanup_handler_deletes_on_movie_deleted() { async fn cleanup_handler_deletes_on_movie_deleted() {
use domain::{events::DomainEvent, value_objects::{MovieId, PosterPath}}; use domain::{
events::DomainEvent,
value_objects::{MovieId, PosterPath},
};
let inner = Arc::new(adapter()); let inner = Arc::new(adapter());
inner.store("some-uuid", b"img").await.unwrap(); inner.store("some-uuid", b"img").await.unwrap();
let path = PosterPath::new("some-uuid".to_string()).unwrap(); let path = PosterPath::new("some-uuid".to_string()).unwrap();
@@ -51,5 +54,8 @@ async fn cleanup_handler_deletes_on_movie_deleted() {
}) })
.await .await
.unwrap(); .unwrap();
assert!(matches!(inner.get("some-uuid").await, Err(DomainError::NotFound(_)))); assert!(matches!(
inner.get("some-uuid").await,
Err(DomainError::NotFound(_))
));
} }

View File

@@ -15,9 +15,13 @@ impl DocumentParser for ImporterDocumentParser {
FileFormat::Json => parsers::parse_json(bytes), FileFormat::Json => parsers::parse_json(bytes),
FileFormat::Xlsx => { FileFormat::Xlsx => {
#[cfg(feature = "xlsx")] #[cfg(feature = "xlsx")]
{ parsers::parse_xlsx(bytes) } {
parsers::parse_xlsx(bytes)
}
#[cfg(not(feature = "xlsx"))] #[cfg(not(feature = "xlsx"))]
{ Err(ImportError::Xlsx("XLSX support not compiled in".into())) } {
Err(ImportError::Xlsx("XLSX support not compiled in".into()))
}
} }
} }
} }

View File

@@ -3,10 +3,16 @@ use domain::models::{
}; };
pub fn apply_mapping(file: &ParsedFile, mappings: &[FieldMapping]) -> Vec<AnnotatedRow> { pub fn apply_mapping(file: &ParsedFile, mappings: &[FieldMapping]) -> Vec<AnnotatedRow> {
file.rows.iter().map(|row| { file.rows
let result = map_row(row, &file.columns, mappings); .iter()
AnnotatedRow { result, is_duplicate: false } .map(|row| {
}).collect() let result = map_row(row, &file.columns, mappings);
AnnotatedRow {
result,
is_duplicate: false,
}
})
.collect()
} }
fn map_row(row: &[String], columns: &[String], mappings: &[FieldMapping]) -> RowResult { fn map_row(row: &[String], columns: &[String], mappings: &[FieldMapping]) -> RowResult {
@@ -39,7 +45,8 @@ fn map_row(row: &[String], columns: &[String], mappings: &[FieldMapping]) -> Row
if errors.is_empty() { if errors.is_empty() {
RowResult::Valid(import_row) RowResult::Valid(import_row)
} else { } else {
let raw = columns.iter() let raw = columns
.iter()
.zip(row.iter()) .zip(row.iter())
.map(|(c, v)| (c.clone(), v.clone())) .map(|(c, v)| (c.clone(), v.clone()))
.collect(); .collect();
@@ -51,15 +58,13 @@ fn apply_transform(value: &str, transform: &Transform, errors: &mut Vec<String>)
match transform { match transform {
Transform::Identity => Some(value.to_string()), Transform::Identity => Some(value.to_string()),
Transform::DateFormat(_) => Some(value.to_string()), Transform::DateFormat(_) => Some(value.to_string()),
Transform::RatingScale(factor) => { Transform::RatingScale(factor) => match value.parse::<f64>() {
match value.parse::<f64>() { Ok(n) => Some((n * factor).round().to_string()),
Ok(n) => Some((n * factor).round().to_string()), Err(_) => {
Err(_) => { errors.push(format!("rating '{}' is not a number", value));
errors.push(format!("rating '{}' is not a number", value)); None
None
}
} }
} },
} }
} }

View File

@@ -24,13 +24,12 @@ pub fn parse_csv(bytes: &[u8]) -> Result<ParsedFile, ImportError> {
let rows: Vec<Vec<String>> = rdr let rows: Vec<Vec<String>> = rdr
.records() .records()
.map(|r| { .map(|r| {
r.map_err(|e| ImportError::Csv(e.to_string())) r.map_err(|e| ImportError::Csv(e.to_string())).map(|rec| {
.map(|rec| { let mut cells: Vec<String> = rec.iter().map(|f| f.trim().to_string()).collect();
let mut cells: Vec<String> = rec.iter().map(|f| f.trim().to_string()).collect(); cells.resize(columns.len(), String::new());
cells.resize(columns.len(), String::new()); cells.truncate(columns.len());
cells.truncate(columns.len()); cells
cells })
})
}) })
.collect::<Result<_, _>>()?; .collect::<Result<_, _>>()?;

View File

@@ -2,17 +2,19 @@ use domain::models::{ImportError, ParsedFile};
use serde_json::Value; use serde_json::Value;
pub fn parse_json(bytes: &[u8]) -> Result<ParsedFile, ImportError> { pub fn parse_json(bytes: &[u8]) -> Result<ParsedFile, ImportError> {
let value: Value = serde_json::from_slice(bytes) let value: Value =
.map_err(|e| ImportError::Json(e.to_string()))?; serde_json::from_slice(bytes).map_err(|e| ImportError::Json(e.to_string()))?;
let arr = value.as_array() let arr = value
.as_array()
.ok_or_else(|| ImportError::Json("expected a JSON array".into()))?; .ok_or_else(|| ImportError::Json("expected a JSON array".into()))?;
if arr.is_empty() { if arr.is_empty() {
return Err(ImportError::Empty); return Err(ImportError::Empty);
} }
let first = arr[0].as_object() let first = arr[0]
.as_object()
.ok_or_else(|| ImportError::Json("array elements must be objects".into()))?; .ok_or_else(|| ImportError::Json("array elements must be objects".into()))?;
let columns: Vec<String> = first.keys().cloned().collect(); let columns: Vec<String> = first.keys().cloned().collect();
@@ -20,12 +22,15 @@ pub fn parse_json(bytes: &[u8]) -> Result<ParsedFile, ImportError> {
return Err(ImportError::NoHeader); return Err(ImportError::NoHeader);
} }
let rows: Vec<Vec<String>> = arr.iter() let rows: Vec<Vec<String>> = arr
.iter()
.enumerate() .enumerate()
.map(|(idx, item)| { .map(|(idx, item)| {
let obj = item.as_object() let obj = item.as_object().ok_or_else(|| {
.ok_or_else(|| ImportError::Json(format!("element at index {} is not an object", idx)))?; ImportError::Json(format!("element at index {} is not an object", idx))
Ok(columns.iter() })?;
Ok(columns
.iter()
.map(|col| obj.get(col).map(value_to_string).unwrap_or_default()) .map(|col| obj.get(col).map(value_to_string).unwrap_or_default())
.collect()) .collect())
}) })

View File

@@ -1,24 +1,27 @@
use calamine::{Reader, open_workbook_from_rs, Xlsx, Data}; use calamine::{Data, Reader, Xlsx, open_workbook_from_rs};
use std::io::Cursor;
use domain::models::{ImportError, ParsedFile}; use domain::models::{ImportError, ParsedFile};
use std::io::Cursor;
pub fn parse_xlsx(bytes: &[u8]) -> Result<ParsedFile, ImportError> { pub fn parse_xlsx(bytes: &[u8]) -> Result<ParsedFile, ImportError> {
let cursor = Cursor::new(bytes); let cursor = Cursor::new(bytes);
let mut workbook: Xlsx<_> = open_workbook_from_rs(cursor) let mut workbook: Xlsx<_> = open_workbook_from_rs(cursor)
.map_err(|e: calamine::XlsxError| ImportError::Xlsx(e.to_string()))?; .map_err(|e: calamine::XlsxError| ImportError::Xlsx(e.to_string()))?;
let sheet_name = workbook.sheet_names() let sheet_name = workbook
.sheet_names()
.first() .first()
.cloned() .cloned()
.ok_or(ImportError::Empty)?; .ok_or(ImportError::Empty)?;
let range = workbook.worksheet_range(&sheet_name) let range = workbook
.worksheet_range(&sheet_name)
.map_err(|e| ImportError::Xlsx(e.to_string()))?; .map_err(|e| ImportError::Xlsx(e.to_string()))?;
let mut iter = range.rows(); let mut iter = range.rows();
let header = iter.next().ok_or(ImportError::NoHeader)?; let header = iter.next().ok_or(ImportError::NoHeader)?;
let columns: Vec<String> = header.iter() let columns: Vec<String> = header
.iter()
.map(|c| cell_to_string(c).trim().to_string()) .map(|c| cell_to_string(c).trim().to_string())
.collect(); .collect();
@@ -46,7 +49,11 @@ fn cell_to_string(cell: &Data) -> String {
match cell { match cell {
Data::String(s) => s.clone(), Data::String(s) => s.clone(),
Data::Float(f) => { Data::Float(f) => {
if f.fract() == 0.0 { format!("{}", *f as i64) } else { format!("{}", f) } if f.fract() == 0.0 {
format!("{}", *f as i64)
} else {
format!("{}", f)
}
} }
Data::Int(i) => i.to_string(), Data::Int(i) => i.to_string(),
Data::Bool(b) => b.to_string(), Data::Bool(b) => b.to_string(),

View File

@@ -14,9 +14,21 @@ fn sample_file() -> ParsedFile {
fn full_mappings() -> Vec<FieldMapping> { fn full_mappings() -> Vec<FieldMapping> {
vec![ vec![
FieldMapping { source_column: "Name".into(), domain_field: DomainField::Title, transform: Transform::Identity }, FieldMapping {
FieldMapping { source_column: "Stars".into(), domain_field: DomainField::Rating, transform: Transform::RatingScale(0.5) }, source_column: "Name".into(),
FieldMapping { source_column: "Date".into(), domain_field: DomainField::WatchedAt, transform: Transform::Identity }, domain_field: DomainField::Title,
transform: Transform::Identity,
},
FieldMapping {
source_column: "Stars".into(),
domain_field: DomainField::Rating,
transform: Transform::RatingScale(0.5),
},
FieldMapping {
source_column: "Date".into(),
domain_field: DomainField::WatchedAt,
transform: Transform::Identity,
},
] ]
} }
@@ -51,9 +63,11 @@ fn marks_missing_required_fields_invalid() {
#[test] #[test]
fn ignores_unmapped_columns() { fn ignores_unmapped_columns() {
let mappings = vec![ let mappings = vec![FieldMapping {
FieldMapping { source_column: "Name".into(), domain_field: DomainField::Title, transform: Transform::Identity }, source_column: "Name".into(),
]; domain_field: DomainField::Title,
transform: Transform::Identity,
}];
let file = ParsedFile { let file = ParsedFile {
columns: vec!["Name".into(), "Extra".into()], columns: vec!["Name".into(), "Extra".into()],
rows: vec![vec!["Inception".into(), "ignored".into()]], rows: vec![vec!["Inception".into(), "ignored".into()]],
@@ -66,9 +80,11 @@ fn ignores_unmapped_columns() {
#[test] #[test]
fn nonexistent_source_column_skipped() { fn nonexistent_source_column_skipped() {
let mappings = vec![ let mappings = vec![FieldMapping {
FieldMapping { source_column: "DoesNotExist".into(), domain_field: DomainField::Title, transform: Transform::Identity }, source_column: "DoesNotExist".into(),
]; domain_field: DomainField::Title,
transform: Transform::Identity,
}];
let file = ParsedFile { let file = ParsedFile {
columns: vec!["Name".into()], columns: vec!["Name".into()],
rows: vec![vec!["Inception".into()]], rows: vec![vec!["Inception".into()]],
@@ -81,8 +97,16 @@ fn nonexistent_source_column_skipped() {
#[test] #[test]
fn collects_all_errors_not_just_first() { fn collects_all_errors_not_just_first() {
let mappings = vec![ let mappings = vec![
FieldMapping { source_column: "Name".into(), domain_field: DomainField::Title, transform: Transform::Identity }, FieldMapping {
FieldMapping { source_column: "Stars".into(), domain_field: DomainField::Rating, transform: Transform::RatingScale(0.5) }, source_column: "Name".into(),
domain_field: DomainField::Title,
transform: Transform::Identity,
},
FieldMapping {
source_column: "Stars".into(),
domain_field: DomainField::Rating,
transform: Transform::RatingScale(0.5),
},
// no watched_at mapping // no watched_at mapping
]; ];
let file = ParsedFile { let file = ParsedFile {
@@ -91,8 +115,16 @@ fn collects_all_errors_not_just_first() {
}; };
let results = apply_mapping(&file, &mappings); let results = apply_mapping(&file, &mappings);
if let RowResult::Invalid { errors, .. } = &results[0].result { if let RowResult::Invalid { errors, .. } = &results[0].result {
assert!(errors.iter().any(|e| e.contains("not a number")), "expected rating error, got: {:?}", errors); assert!(
assert!(errors.iter().any(|e| e.contains("watched_at")), "expected watched_at error, got: {:?}", errors); errors.iter().any(|e| e.contains("not a number")),
"expected rating error, got: {:?}",
errors
);
assert!(
errors.iter().any(|e| e.contains("watched_at")),
"expected watched_at error, got: {:?}",
errors
);
} else { } else {
panic!("expected Invalid"); panic!("expected Invalid");
} }
@@ -101,9 +133,21 @@ fn collects_all_errors_not_just_first() {
#[test] #[test]
fn non_numeric_rating_produces_error_in_row() { fn non_numeric_rating_produces_error_in_row() {
let mappings = vec![ let mappings = vec![
FieldMapping { source_column: "Name".into(), domain_field: DomainField::Title, transform: Transform::Identity }, FieldMapping {
FieldMapping { source_column: "Stars".into(), domain_field: DomainField::Rating, transform: Transform::RatingScale(0.5) }, source_column: "Name".into(),
FieldMapping { source_column: "Date".into(), domain_field: DomainField::WatchedAt, transform: Transform::Identity }, domain_field: DomainField::Title,
transform: Transform::Identity,
},
FieldMapping {
source_column: "Stars".into(),
domain_field: DomainField::Rating,
transform: Transform::RatingScale(0.5),
},
FieldMapping {
source_column: "Date".into(),
domain_field: DomainField::WatchedAt,
transform: Transform::Identity,
},
]; ];
let file = ParsedFile { let file = ParsedFile {
columns: vec!["Name".into(), "Stars".into(), "Date".into()], columns: vec!["Name".into(), "Stars".into(), "Date".into()],

View File

@@ -74,9 +74,7 @@ impl TmdbProvider {
} }
let url = self.base(&format!("/movie/{}", tmdb_id)); let url = self.base(&format!("/movie/{}", tmdb_id));
let d: Details = self let d: Details = self.get(&url, &[("append_to_response", "credits")]).await?;
.get(&url, &[("append_to_response", "credits")])
.await?;
let year: u16 = d let year: u16 = d
.release_date .release_date
@@ -98,8 +96,8 @@ impl TmdbProvider {
let imdb_id = ExternalMetadataId::new(raw_id) let imdb_id = ExternalMetadataId::new(raw_id)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?; .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let title = let title = MovieTitle::new(d.title)
MovieTitle::new(d.title).map_err(|e| DomainError::InfrastructureError(e.to_string()))?; .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let release_year = let release_year =
ReleaseYear::new(year).map_err(|e| DomainError::InfrastructureError(e.to_string()))?; ReleaseYear::new(year).map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
@@ -110,10 +108,7 @@ impl TmdbProvider {
.find(|c| c.job == "Director") .find(|c| c.job == "Director")
.map(|c| c.name); .map(|c| c.name);
let poster_url = d let poster_url = d.poster_path.as_deref().and_then(|p| self.poster_url(p));
.poster_path
.as_deref()
.and_then(|p| self.poster_url(p));
Ok(ProviderMovie { Ok(ProviderMovie {
imdb_id, imdb_id,
@@ -139,12 +134,13 @@ impl MetadataProvider for TmdbProvider {
movie_results: Vec<FindResult>, movie_results: Vec<FindResult>,
} }
let url = self.base(&format!("/find/{}", id.value())); let url = self.base(&format!("/find/{}", id.value()));
let resp: FindResponse = let resp: FindResponse = self.get(&url, &[("external_source", "imdb_id")]).await?;
self.get(&url, &[("external_source", "imdb_id")]).await?;
resp.movie_results resp.movie_results
.into_iter() .into_iter()
.next() .next()
.ok_or_else(|| DomainError::NotFound(format!("TMDB: no movie for {}", id.value())))? .ok_or_else(|| {
DomainError::NotFound(format!("TMDB: no movie for {}", id.value()))
})?
.id .id
} }
MetadataSearchCriteria::Title { title, year } => { MetadataSearchCriteria::Title { title, year } => {

View File

@@ -34,16 +34,22 @@ impl NatsConfig {
let url = url.ok_or_else(|| anyhow::anyhow!("NATS_URL is not set"))?; let url = url.ok_or_else(|| anyhow::anyhow!("NATS_URL is not set"))?;
let mode = match mode.unwrap_or("jetstream") { let mode = match mode.unwrap_or("jetstream") {
"core" => NatsMode::Core, "core" => NatsMode::Core,
"jetstream" => NatsMode::JetStream, "jetstream" => NatsMode::JetStream,
other => anyhow::bail!("unknown NATS_MODE: {other}"), other => anyhow::bail!("unknown NATS_MODE: {other}"),
}; };
let subject_prefix = subject_prefix.unwrap_or("movies-diary.events").to_string(); let subject_prefix = subject_prefix.unwrap_or("movies-diary.events").to_string();
let stream_name = stream_name.unwrap_or("MOVIES_DIARY_EVENTS").to_string(); let stream_name = stream_name.unwrap_or("MOVIES_DIARY_EVENTS").to_string();
let consumer_name = consumer_name.unwrap_or("worker").to_string(); let consumer_name = consumer_name.unwrap_or("worker").to_string();
Ok(Self { url: url.to_string(), mode, subject_prefix, stream_name, consumer_name }) Ok(Self {
url: url.to_string(),
mode,
subject_prefix,
stream_name,
consumer_name,
})
} }
} }

View File

@@ -1,4 +1,4 @@
use async_nats::{jetstream, Client}; use async_nats::{Client, jetstream};
use async_trait::async_trait; use async_trait::async_trait;
use domain::{errors::DomainError, events::DomainEvent, ports::EventPublisher}; use domain::{errors::DomainError, events::DomainEvent, ports::EventPublisher};
@@ -16,11 +16,17 @@ pub struct NatsEventPublisher {
impl NatsEventPublisher { impl NatsEventPublisher {
pub fn new_core(client: Client, subject_prefix: String) -> Self { pub fn new_core(client: Client, subject_prefix: String) -> Self {
Self { mode: PublisherMode::Core(client), subject_prefix } Self {
mode: PublisherMode::Core(client),
subject_prefix,
}
} }
pub fn new_jetstream(client: Client, subject_prefix: String) -> Self { pub fn new_jetstream(client: Client, subject_prefix: String) -> Self {
Self { mode: PublisherMode::JetStream(jetstream::new(client)), subject_prefix } Self {
mode: PublisherMode::JetStream(jetstream::new(client)),
subject_prefix,
}
} }
} }

View File

@@ -2,15 +2,15 @@ use domain::events::DomainEvent;
pub fn event_to_subject(prefix: &str, event: &DomainEvent) -> String { pub fn event_to_subject(prefix: &str, event: &DomainEvent) -> String {
let suffix = match event { let suffix = match event {
DomainEvent::ReviewLogged { .. } => "review.logged", DomainEvent::ReviewLogged { .. } => "review.logged",
DomainEvent::ReviewUpdated { .. } => "review.updated", DomainEvent::ReviewUpdated { .. } => "review.updated",
DomainEvent::ReviewDeleted { .. } => "review.deleted", DomainEvent::ReviewDeleted { .. } => "review.deleted",
DomainEvent::MovieDiscovered { .. } => "movie.discovered", DomainEvent::MovieDiscovered { .. } => "movie.discovered",
DomainEvent::MovieDeleted { .. } => "movie.deleted", DomainEvent::MovieDeleted { .. } => "movie.deleted",
DomainEvent::UserUpdated { .. } => "user.updated", DomainEvent::UserUpdated { .. } => "user.updated",
DomainEvent::MovieEnrichmentRequested { .. } => "movie.enrichment.requested", DomainEvent::MovieEnrichmentRequested { .. } => "movie.enrichment.requested",
DomainEvent::ImageStored { .. } => "image.stored", DomainEvent::ImageStored { .. } => "image.stored",
DomainEvent::WatchlistEntryAdded { .. } => "watchlist.entry.added", DomainEvent::WatchlistEntryAdded { .. } => "watchlist.entry.added",
DomainEvent::WatchlistEntryRemoved { .. } => "watchlist.entry.removed", DomainEvent::WatchlistEntryRemoved { .. } => "watchlist.entry.removed",
DomainEvent::FollowAccepted { .. } => "follow.accepted", DomainEvent::FollowAccepted { .. } => "follow.accepted",
}; };

View File

@@ -17,11 +17,14 @@ fn defaults_with_only_url() {
#[test] #[test]
fn core_mode_parsed() { fn core_mode_parsed() {
let cfg = NatsConfig::from_vars(Some("nats://test:4222"), Some("core"), None, None, None).unwrap(); let cfg =
NatsConfig::from_vars(Some("nats://test:4222"), Some("core"), None, None, None).unwrap();
assert_eq!(cfg.mode, NatsMode::Core); assert_eq!(cfg.mode, NatsMode::Core);
} }
#[test] #[test]
fn invalid_mode_errors() { fn invalid_mode_errors() {
assert!(NatsConfig::from_vars(Some("nats://test:4222"), Some("kafka"), None, None, None).is_err()); assert!(
NatsConfig::from_vars(Some("nats://test:4222"), Some("kafka"), None, None, None).is_err()
);
} }

View File

@@ -4,7 +4,9 @@ use domain::value_objects::{ExternalMetadataId, MovieId, Rating, ReviewId, UserI
use uuid::Uuid; use uuid::Uuid;
fn dt() -> NaiveDateTime { fn dt() -> NaiveDateTime {
chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap().naive_utc() chrono::DateTime::from_timestamp(1_700_000_000, 0)
.unwrap()
.naive_utc()
} }
#[test] #[test]

View File

@@ -38,5 +38,7 @@ impl PosterFetcherClient for ReqwestPosterFetcher {
} }
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(PosterFetcherConfig::from_env())?)) Ok(std::sync::Arc::new(ReqwestPosterFetcher::new(
PosterFetcherConfig::from_env(),
)?))
} }

View File

@@ -4,7 +4,10 @@ use async_trait::async_trait;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
ports::{EventHandler, EventPublisher, ImageStorage, MetadataClient, MovieRepository, PosterFetcherClient}, ports::{
EventHandler, EventPublisher, ImageStorage, MetadataClient, MovieRepository,
PosterFetcherClient,
},
value_objects::{ExternalMetadataId, MovieId, PosterPath}, value_objects::{ExternalMetadataId, MovieId, PosterPath},
}; };
@@ -26,10 +29,21 @@ impl PosterSyncHandler {
event_publisher: Arc<dyn EventPublisher>, event_publisher: Arc<dyn EventPublisher>,
max_retries: u32, max_retries: u32,
) -> Self { ) -> Self {
Self { movie_repository, metadata_client, poster_fetcher, image_storage, event_publisher, max_retries } Self {
movie_repository,
metadata_client,
poster_fetcher,
image_storage,
event_publisher,
max_retries,
}
} }
async fn sync(&self, movie_id: MovieId, external_metadata_id: ExternalMetadataId) -> Result<(), DomainError> { async fn sync(
&self,
movie_id: MovieId,
external_metadata_id: ExternalMetadataId,
) -> Result<(), DomainError> {
let mut movie = match self.movie_repository.get_movie_by_id(&movie_id).await? { let mut movie = match self.movie_repository.get_movie_by_id(&movie_id).await? {
Some(m) => m, Some(m) => m,
None => { None => {
@@ -38,7 +52,11 @@ impl PosterSyncHandler {
} }
}; };
let poster_url = match self.metadata_client.get_poster_url(&external_metadata_id).await { let poster_url = match self
.metadata_client
.get_poster_url(&external_metadata_id)
.await
{
Ok(Some(url)) => url, Ok(Some(url)) => url,
Ok(None) => return Ok(()), Ok(None) => return Ok(()),
Err(e) => { Err(e) => {
@@ -48,9 +66,15 @@ impl PosterSyncHandler {
}; };
let image_bytes = self.poster_fetcher.fetch_poster_bytes(&poster_url).await?; let image_bytes = self.poster_fetcher.fetch_poster_bytes(&poster_url).await?;
let stored_path = self.image_storage.store(&movie_id.value().to_string(), &image_bytes).await?; let stored_path = self
if let Err(e) = self.event_publisher .image_storage
.publish(&DomainEvent::ImageStored { key: stored_path.clone() }) .store(&movie_id.value().to_string(), &image_bytes)
.await?;
if let Err(e) = self
.event_publisher
.publish(&DomainEvent::ImageStored {
key: stored_path.clone(),
})
.await .await
{ {
tracing::warn!("failed to emit ImageStored for {stored_path}: {e}"); tracing::warn!("failed to emit ImageStored for {stored_path}: {e}");
@@ -66,10 +90,14 @@ impl PosterSyncHandler {
impl EventHandler for PosterSyncHandler { impl EventHandler for PosterSyncHandler {
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> { async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> {
let (movie_id, external_metadata_id) = match event { let (movie_id, external_metadata_id) = match event {
DomainEvent::MovieDiscovered { movie_id, external_metadata_id } => { DomainEvent::MovieDiscovered {
(movie_id.value(), external_metadata_id.value().to_owned()) movie_id,
} external_metadata_id,
DomainEvent::MovieEnrichmentRequested { movie_id, external_metadata_id } => { } => (movie_id.value(), external_metadata_id.value().to_owned()),
DomainEvent::MovieEnrichmentRequested {
movie_id,
external_metadata_id,
} => {
// Only sync poster if the movie doesn't have one yet // Only sync poster if the movie doesn't have one yet
let already_has_poster = self let already_has_poster = self
.movie_repository .movie_repository
@@ -90,7 +118,10 @@ impl EventHandler for PosterSyncHandler {
let mut last_err: Option<DomainError> = None; let mut last_err: Option<DomainError> = None;
for attempt in 0..=self.max_retries { for attempt in 0..=self.max_retries {
match self.sync(movie_id.clone(), external_metadata_id.clone()).await { match self
.sync(movie_id.clone(), external_metadata_id.clone())
.await
{
Ok(()) => return Ok(()), Ok(()) => return Ok(()),
Err(e) => { Err(e) => {
if attempt < self.max_retries { if attempt < self.max_retries {
@@ -109,7 +140,10 @@ impl EventHandler for PosterSyncHandler {
} }
let err = last_err.expect("loop runs at least once"); let err = last_err.expect("loop runs at least once");
tracing::error!(attempts = self.max_retries + 1, "poster sync failed after all attempts: {err}"); tracing::error!(
attempts = self.max_retries + 1,
"poster sync failed after all attempts: {err}"
);
Err(err) Err(err)
} }
} }

View File

@@ -18,33 +18,42 @@ use payload::DbEventPayload;
pub struct DbEventQueueConfig { pub struct DbEventQueueConfig {
pub poll_interval_ms: u64, pub poll_interval_ms: u64,
pub batch_size: i64, pub batch_size: i64,
pub max_attempts: i32, pub max_attempts: i32,
} }
impl DbEventQueueConfig { impl DbEventQueueConfig {
pub fn from_env() -> Self { pub fn from_env() -> Self {
Self { Self {
poll_interval_ms: std::env::var("EVENT_QUEUE_POLL_INTERVAL_MS") poll_interval_ms: std::env::var("EVENT_QUEUE_POLL_INTERVAL_MS")
.ok().and_then(|v| v.parse().ok()).unwrap_or(500), .ok()
.and_then(|v| v.parse().ok())
.unwrap_or(500),
batch_size: std::env::var("EVENT_QUEUE_BATCH_SIZE") batch_size: std::env::var("EVENT_QUEUE_BATCH_SIZE")
.ok().and_then(|v| v.parse().ok()).unwrap_or(10), .ok()
.and_then(|v| v.parse().ok())
.unwrap_or(10),
max_attempts: std::env::var("EVENT_QUEUE_MAX_ATTEMPTS") max_attempts: std::env::var("EVENT_QUEUE_MAX_ATTEMPTS")
.ok().and_then(|v| v.parse().ok()).unwrap_or(5), .ok()
.and_then(|v| v.parse().ok())
.unwrap_or(5),
} }
} }
} }
#[derive(Clone)] #[derive(Clone)]
pub struct PostgresEventQueue { pub struct PostgresEventQueue {
pool: PgPool, pool: PgPool,
config: Arc<DbEventQueueConfig>, config: Arc<DbEventQueueConfig>,
} }
impl PostgresEventQueue { impl PostgresEventQueue {
pub async fn create(pool: PgPool, config: DbEventQueueConfig) -> anyhow::Result<Self> { pub async fn create(pool: PgPool, config: DbEventQueueConfig) -> anyhow::Result<Self> {
migrations::run(&pool).await?; migrations::run(&pool).await?;
Ok(Self { pool, config: Arc::new(config) }) Ok(Self {
pool,
config: Arc::new(config),
})
} }
pub async fn create_publisher(pool: PgPool) -> anyhow::Result<Arc<dyn EventPublisher>> { pub async fn create_publisher(pool: PgPool) -> anyhow::Result<Arc<dyn EventPublisher>> {
@@ -68,14 +77,12 @@ impl EventPublisher for PostgresEventQueue {
let payload_json = serde_json::to_string(&db_payload) let payload_json = serde_json::to_string(&db_payload)
.map_err(|e| DomainError::InfrastructureError(format!("serialize: {e}")))?; .map_err(|e| DomainError::InfrastructureError(format!("serialize: {e}")))?;
sqlx::query( sqlx::query("INSERT INTO event_queue (event_type, payload) VALUES ($1, $2)")
"INSERT INTO event_queue (event_type, payload) VALUES ($1, $2)" .bind(event_type)
) .bind(payload_json)
.bind(event_type) .execute(&self.pool)
.bind(payload_json) .await
.execute(&self.pool) .map_err(|e| DomainError::InfrastructureError(format!("insert event: {e}")))?;
.await
.map_err(|e| DomainError::InfrastructureError(format!("insert event: {e}")))?;
Ok(()) Ok(())
} }
@@ -83,10 +90,10 @@ impl EventPublisher for PostgresEventQueue {
impl EventConsumer for PostgresEventQueue { impl EventConsumer for PostgresEventQueue {
fn consume(&self) -> BoxStream<'_, Result<EventEnvelope, DomainError>> { fn consume(&self) -> BoxStream<'_, Result<EventEnvelope, DomainError>> {
let pool = self.pool.clone(); let pool = self.pool.clone();
let config = Arc::clone(&self.config); let config = Arc::clone(&self.config);
let (tx, rx) = mpsc::channel(128); let (tx, rx) = mpsc::channel(128);
let rx = Arc::new(Mutex::new(rx)); let rx = Arc::new(Mutex::new(rx));
tokio::spawn(async move { tokio::spawn(async move {
let poll_interval = Duration::from_millis(config.poll_interval_ms); let poll_interval = Duration::from_millis(config.poll_interval_ms);
@@ -124,13 +131,13 @@ impl EventConsumer for PostgresEventQueue {
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct QueueRow { struct QueueRow {
id: i64, id: i64,
payload: String, payload: String,
attempts: i32, attempts: i32,
} }
async fn claim_batch( async fn claim_batch(
pool: &PgPool, pool: &PgPool,
config: &DbEventQueueConfig, config: &DbEventQueueConfig,
) -> Result<Vec<QueueRow>, DomainError> { ) -> Result<Vec<QueueRow>, DomainError> {
// CTE with FOR UPDATE SKIP LOCKED — atomic and safe for multiple workers // CTE with FOR UPDATE SKIP LOCKED — atomic and safe for multiple workers
@@ -148,7 +155,7 @@ async fn claim_batch(
FROM claimed FROM claimed
WHERE q.id = claimed.id WHERE q.id = claimed.id
RETURNING q.id, q.payload, q.attempts RETURNING q.id, q.payload, q.attempts
"# "#,
) )
.bind(config.batch_size) .bind(config.batch_size)
.fetch_all(pool) .fetch_all(pool)
@@ -159,25 +166,28 @@ async fn claim_batch(
} }
fn decode_row( fn decode_row(
pool: &PgPool, pool: &PgPool,
row: QueueRow, row: QueueRow,
max_attempts: i32, max_attempts: i32,
) -> Result<EventEnvelope, DomainError> { ) -> Result<EventEnvelope, DomainError> {
let db_payload: DbEventPayload = serde_json::from_str(&row.payload) let db_payload: DbEventPayload = serde_json::from_str(&row.payload)
.map_err(|e| DomainError::InfrastructureError(format!("deserialize: {e}")))?; .map_err(|e| DomainError::InfrastructureError(format!("deserialize: {e}")))?;
let event = DomainEvent::try_from(db_payload)?; let event = DomainEvent::try_from(db_payload)?;
Ok(EventEnvelope::new(event, Box::new(DbAckHandle { Ok(EventEnvelope::new(
pool: pool.clone(), event,
row_id: row.id, Box::new(DbAckHandle {
attempts: row.attempts, pool: pool.clone(),
max_attempts, row_id: row.id,
}))) attempts: row.attempts,
max_attempts,
}),
))
} }
struct DbAckHandle { struct DbAckHandle {
pool: PgPool, pool: PgPool,
row_id: i64, row_id: i64,
attempts: i32, attempts: i32,
max_attempts: i32, max_attempts: i32,
} }

View File

@@ -7,7 +7,7 @@ use activitypub::RemoteReviewRepository;
use activitypub_base::{ use activitypub_base::{
BlockedDomain, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor, BlockedDomain, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
}; };
use domain::models::{Review, ReviewSource, RemoteWatchlistEntry}; use domain::models::{RemoteWatchlistEntry, Review, ReviewSource};
use domain::ports::RemoteWatchlistRepository; use domain::ports::RemoteWatchlistRepository;
fn datetime_to_str(dt: &NaiveDateTime) -> String { fn datetime_to_str(dt: &NaiveDateTime) -> String {
@@ -112,19 +112,31 @@ impl FederationRepository for PostgresFederationRepository {
.bind(&uid) .bind(&uid)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await?; .await?;
Ok(rows.into_iter().map(|row| { Ok(rows
let url: String = row.get("remote_actor_url"); .into_iter()
let status_str: String = row.get("status"); .map(|row| {
let handle: String = row.try_get("handle").unwrap_or_default(); let url: String = row.get("remote_actor_url");
let inbox_url: String = row.try_get("inbox_url").unwrap_or_default(); let status_str: String = row.get("status");
let shared_inbox_url: Option<String> = row.try_get("shared_inbox_url").ok().flatten(); let handle: String = row.try_get("handle").unwrap_or_default();
let display_name: Option<String> = row.try_get("display_name").ok().flatten(); let inbox_url: String = row.try_get("inbox_url").unwrap_or_default();
let avatar_url: Option<String> = row.try_get("avatar_url").ok().flatten(); let shared_inbox_url: Option<String> =
Follower { row.try_get("shared_inbox_url").ok().flatten();
actor: RemoteActor { url, handle, inbox_url, shared_inbox_url, display_name, avatar_url, outbox_url: row.try_get("outbox_url").ok().flatten() }, let display_name: Option<String> = row.try_get("display_name").ok().flatten();
status: str_to_status(&status_str), let avatar_url: Option<String> = row.try_get("avatar_url").ok().flatten();
} Follower {
}).collect()) actor: RemoteActor {
url,
handle,
inbox_url,
shared_inbox_url,
display_name,
avatar_url,
outbox_url: row.try_get("outbox_url").ok().flatten(),
},
status: str_to_status(&status_str),
}
})
.collect())
} }
async fn get_followers_page( async fn get_followers_page(
@@ -152,22 +164,31 @@ impl FederationRepository for PostgresFederationRepository {
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await?; .await?;
Ok(rows.into_iter().map(|row| { Ok(rows
let url: String = row.get("remote_actor_url"); .into_iter()
let status_str: String = row.get("status"); .map(|row| {
let handle: String = row.try_get("handle").unwrap_or_default(); let url: String = row.get("remote_actor_url");
let inbox_url: String = row.try_get("inbox_url").unwrap_or_default(); let status_str: String = row.get("status");
let shared_inbox_url: Option<String> = row.try_get("shared_inbox_url").ok().flatten(); let handle: String = row.try_get("handle").unwrap_or_default();
let display_name: Option<String> = row.try_get("display_name").ok().flatten(); let inbox_url: String = row.try_get("inbox_url").unwrap_or_default();
let avatar_url: Option<String> = row.try_get("avatar_url").ok().flatten(); let shared_inbox_url: Option<String> =
Follower { row.try_get("shared_inbox_url").ok().flatten();
actor: RemoteActor { let display_name: Option<String> = row.try_get("display_name").ok().flatten();
url, handle, inbox_url, shared_inbox_url, display_name, avatar_url, let avatar_url: Option<String> = row.try_get("avatar_url").ok().flatten();
outbox_url: row.try_get("outbox_url").ok().flatten(), Follower {
}, actor: RemoteActor {
status: str_to_status(&status_str), url,
} handle,
}).collect()) inbox_url,
shared_inbox_url,
display_name,
avatar_url,
outbox_url: row.try_get("outbox_url").ok().flatten(),
},
status: str_to_status(&status_str),
}
})
.collect())
} }
async fn count_followers(&self, local_user_id: uuid::Uuid) -> Result<usize> { async fn count_followers(&self, local_user_id: uuid::Uuid) -> Result<usize> {
@@ -264,15 +285,18 @@ impl FederationRepository for PostgresFederationRepository {
.bind(&uid) .bind(&uid)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await?; .await?;
Ok(rows.into_iter().map(|row| RemoteActor { Ok(rows
url: row.get("url"), .into_iter()
handle: row.get("handle"), .map(|row| RemoteActor {
inbox_url: row.get("inbox_url"), url: row.get("url"),
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(), handle: row.get("handle"),
display_name: row.try_get("display_name").ok().flatten(), inbox_url: row.get("inbox_url"),
avatar_url: row.try_get("avatar_url").ok().flatten(), shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
outbox_url: row.try_get("outbox_url").ok().flatten(), display_name: row.try_get("display_name").ok().flatten(),
}).collect()) avatar_url: row.try_get("avatar_url").ok().flatten(),
outbox_url: row.try_get("outbox_url").ok().flatten(),
})
.collect())
} }
async fn count_following(&self, local_user_id: uuid::Uuid) -> Result<usize> { async fn count_following(&self, local_user_id: uuid::Uuid) -> Result<usize> {
@@ -310,15 +334,18 @@ impl FederationRepository for PostgresFederationRepository {
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await?; .await?;
Ok(rows.into_iter().map(|row| RemoteActor { Ok(rows
url: row.get("url"), .into_iter()
handle: row.get("handle"), .map(|row| RemoteActor {
inbox_url: row.get("inbox_url"), url: row.get("url"),
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(), handle: row.get("handle"),
display_name: row.try_get("display_name").ok().flatten(), inbox_url: row.get("inbox_url"),
avatar_url: row.try_get("avatar_url").ok().flatten(), shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
outbox_url: row.try_get("outbox_url").ok().flatten(), display_name: row.try_get("display_name").ok().flatten(),
}).collect()) avatar_url: row.try_get("avatar_url").ok().flatten(),
outbox_url: row.try_get("outbox_url").ok().flatten(),
})
.collect())
} }
async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()> { async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()> {
@@ -368,12 +395,16 @@ impl FederationRepository for PostgresFederationRepository {
})) }))
} }
async fn get_local_actor_keypair(&self, user_id: uuid::Uuid) -> Result<Option<(String, String)>> { async fn get_local_actor_keypair(
&self,
user_id: uuid::Uuid,
) -> Result<Option<(String, String)>> {
let uid = user_id.to_string(); let uid = user_id.to_string();
let row = sqlx::query("SELECT public_key, private_key FROM ap_local_actors WHERE user_id = $1") let row =
.bind(&uid) sqlx::query("SELECT public_key, private_key FROM ap_local_actors WHERE user_id = $1")
.fetch_optional(&self.pool) .bind(&uid)
.await?; .fetch_optional(&self.pool)
.await?;
Ok(row.map(|r| (r.get("public_key"), r.get("private_key")))) Ok(row.map(|r| (r.get("public_key"), r.get("private_key"))))
} }
@@ -413,15 +444,18 @@ impl FederationRepository for PostgresFederationRepository {
.bind(&uid) .bind(&uid)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await?; .await?;
Ok(rows.into_iter().map(|row| RemoteActor { Ok(rows
url: row.get("remote_actor_url"), .into_iter()
handle: row.try_get("handle").unwrap_or_default(), .map(|row| RemoteActor {
inbox_url: row.try_get("inbox_url").unwrap_or_default(), url: row.get("remote_actor_url"),
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(), handle: row.try_get("handle").unwrap_or_default(),
display_name: row.try_get("display_name").ok().flatten(), inbox_url: row.try_get("inbox_url").unwrap_or_default(),
avatar_url: row.try_get("avatar_url").ok().flatten(), shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
outbox_url: row.try_get("outbox_url").ok().flatten(), display_name: row.try_get("display_name").ok().flatten(),
}).collect()) avatar_url: row.try_get("avatar_url").ok().flatten(),
outbox_url: row.try_get("outbox_url").ok().flatten(),
})
.collect())
} }
async fn update_following_status( async fn update_following_status(
@@ -536,12 +570,11 @@ impl FederationRepository for PostgresFederationRepository {
} }
async fn is_domain_blocked(&self, domain: &str) -> Result<bool> { async fn is_domain_blocked(&self, domain: &str) -> Result<bool> {
let count: i64 = sqlx::query_scalar( let count: i64 =
"SELECT COUNT(*) FROM blocked_domains WHERE domain = $1", sqlx::query_scalar("SELECT COUNT(*) FROM blocked_domains WHERE domain = $1")
) .bind(domain)
.bind(domain) .fetch_one(&self.pool)
.fetch_one(&self.pool) .await?;
.await?;
Ok(count > 0) Ok(count > 0)
} }
@@ -581,7 +614,10 @@ impl FederationRepository for PostgresFederationRepository {
.bind(&uid) .bind(&uid)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await?; .await?;
Ok(rows.iter().map(|r| r.get::<String, _>("remote_actor_url")).collect()) Ok(rows
.iter()
.map(|r| r.get::<String, _>("remote_actor_url"))
.collect())
} }
async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<bool> { async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<bool> {
@@ -609,7 +645,9 @@ impl RemoteReviewRepository for PostgresFederationRepository {
) -> Result<()> { ) -> Result<()> {
let actor_url = match review.source() { let actor_url = match review.source() {
ReviewSource::Remote { actor_url } => actor_url.clone(), ReviewSource::Remote { actor_url } => actor_url.clone(),
ReviewSource::Local => return Err(anyhow!("save_remote_review called with a local review")), ReviewSource::Local => {
return Err(anyhow!("save_remote_review called with a local review"));
}
}; };
let movie_id = review.movie_id().value().to_string(); let movie_id = review.movie_id().value().to_string();
sqlx::query( sqlx::query(
@@ -719,7 +757,16 @@ impl domain::ports::SocialQueryPort for PostgresFederationRepository {
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?; .map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?;
Ok(rows.into_iter().map(|(url, handle, display_name)| domain::ports::RemoteActorInfo { url, handle, display_name }).collect()) Ok(rows
.into_iter()
.map(
|(url, handle, display_name)| domain::ports::RemoteActorInfo {
url,
handle,
display_name,
},
)
.collect())
} }
} }
@@ -747,19 +794,24 @@ impl RemoteWatchlistRepository for PostgresFederationRepository {
Ok(()) Ok(())
} }
async fn remove_by_ap_id(&self, ap_id: &str, actor_url: &str) -> Result<(), domain::errors::DomainError> { async fn remove_by_ap_id(
sqlx::query( &self,
"DELETE FROM ap_remote_watchlist_entries WHERE ap_id = $1 AND actor_url = $2", ap_id: &str,
) actor_url: &str,
.bind(ap_id) ) -> Result<(), domain::errors::DomainError> {
.bind(actor_url) sqlx::query("DELETE FROM ap_remote_watchlist_entries WHERE ap_id = $1 AND actor_url = $2")
.execute(&self.pool) .bind(ap_id)
.await .bind(actor_url)
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?; .execute(&self.pool)
.await
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?;
Ok(()) Ok(())
} }
async fn get_by_actor_url(&self, actor_url: &str) -> Result<Vec<RemoteWatchlistEntry>, domain::errors::DomainError> { async fn get_by_actor_url(
&self,
actor_url: &str,
) -> Result<Vec<RemoteWatchlistEntry>, domain::errors::DomainError> {
let rows = sqlx::query( let rows = sqlx::query(
"SELECT ap_id, actor_url, movie_title, release_year, external_metadata_id, poster_url, added_at \ "SELECT ap_id, actor_url, movie_title, release_year, external_metadata_id, poster_url, added_at \
FROM ap_remote_watchlist_entries WHERE actor_url = $1 ORDER BY added_at DESC", FROM ap_remote_watchlist_entries WHERE actor_url = $1 ORDER BY added_at DESC",
@@ -769,21 +821,27 @@ impl RemoteWatchlistRepository for PostgresFederationRepository {
.await .await
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?; .map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?;
rows.into_iter().map(|row| { rows.into_iter()
Ok(RemoteWatchlistEntry { .map(|row| {
ap_id: row.try_get("ap_id").unwrap_or_default(), Ok(RemoteWatchlistEntry {
actor_url: row.try_get("actor_url").unwrap_or_default(), ap_id: row.try_get("ap_id").unwrap_or_default(),
movie_title: row.try_get("movie_title").unwrap_or_default(), actor_url: row.try_get("actor_url").unwrap_or_default(),
release_year: row.try_get::<i32, _>("release_year").unwrap_or(0) as u16, movie_title: row.try_get("movie_title").unwrap_or_default(),
external_metadata_id: row.try_get("external_metadata_id").ok().flatten(), release_year: row.try_get::<i32, _>("release_year").unwrap_or(0) as u16,
poster_url: row.try_get("poster_url").ok().flatten(), external_metadata_id: row.try_get("external_metadata_id").ok().flatten(),
added_at: row.try_get::<chrono::DateTime<chrono::Utc>, _>("added_at") poster_url: row.try_get("poster_url").ok().flatten(),
.unwrap_or_else(|_| chrono::Utc::now()), added_at: row
.try_get::<chrono::DateTime<chrono::Utc>, _>("added_at")
.unwrap_or_else(|_| chrono::Utc::now()),
})
}) })
}).collect() .collect()
} }
async fn remove_all_by_actor(&self, actor_url: &str) -> Result<(), domain::errors::DomainError> { async fn remove_all_by_actor(
&self,
actor_url: &str,
) -> Result<(), domain::errors::DomainError> {
sqlx::query("DELETE FROM ap_remote_watchlist_entries WHERE actor_url = $1") sqlx::query("DELETE FROM ap_remote_watchlist_entries WHERE actor_url = $1")
.bind(actor_url) .bind(actor_url)
.execute(&self.pool) .execute(&self.pool)
@@ -792,18 +850,22 @@ impl RemoteWatchlistRepository for PostgresFederationRepository {
Ok(()) Ok(())
} }
async fn get_by_derived_uuid(&self, uuid: uuid::Uuid) -> Result<Vec<RemoteWatchlistEntry>, domain::errors::DomainError> { async fn get_by_derived_uuid(
let actors: Vec<String> = sqlx::query("SELECT DISTINCT actor_url FROM ap_remote_watchlist_entries") &self,
.fetch_all(&self.pool) uuid: uuid::Uuid,
.await ) -> Result<Vec<RemoteWatchlistEntry>, domain::errors::DomainError> {
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))? let actors: Vec<String> =
.into_iter() sqlx::query("SELECT DISTINCT actor_url FROM ap_remote_watchlist_entries")
.filter_map(|row| row.try_get::<String, _>("actor_url").ok()) .fetch_all(&self.pool)
.collect(); .await
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?
.into_iter()
.filter_map(|row| row.try_get::<String, _>("actor_url").ok())
.collect();
let target = actors.into_iter().find(|url| { let target = actors
uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, url.as_bytes()) == uuid .into_iter()
}); .find(|url| uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, url.as_bytes()) == uuid);
match target { match target {
None => Ok(vec![]), None => Ok(vec![]),
@@ -812,7 +874,9 @@ impl RemoteWatchlistRepository for PostgresFederationRepository {
} }
} }
pub fn wire(pool: sqlx::PgPool) -> ( pub fn wire(
pool: sqlx::PgPool,
) -> (
std::sync::Arc<dyn activitypub::FederationRepository>, std::sync::Arc<dyn activitypub::FederationRepository>,
std::sync::Arc<dyn domain::ports::SocialQueryPort>, std::sync::Arc<dyn domain::ports::SocialQueryPort>,
std::sync::Arc<dyn activitypub::RemoteReviewRepository>, std::sync::Arc<dyn activitypub::RemoteReviewRepository>,

View File

@@ -3,14 +3,13 @@ use std::sync::Arc;
use async_trait::async_trait; use async_trait::async_trait;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::{
EntityType, IndexableDocument, MovieSearchHit, PersonSearchHit,
SearchQuery, SearchResults,
collections::Paginated,
},
models::PersonId, models::PersonId,
value_objects::MovieId, models::{
collections::Paginated, EntityType, IndexableDocument, MovieSearchHit, PersonSearchHit,
SearchQuery, SearchResults,
},
ports::{SearchCommand, SearchPort}, ports::{SearchCommand, SearchPort},
value_objects::MovieId,
}; };
use sqlx::PgPool; use sqlx::PgPool;
@@ -26,7 +25,10 @@ impl PostgresSearchAdapter {
pub fn create_search_adapter(pool: PgPool) -> (Arc<dyn SearchCommand>, Arc<dyn SearchPort>) { pub fn create_search_adapter(pool: PgPool) -> (Arc<dyn SearchCommand>, Arc<dyn SearchPort>) {
let adapter = Arc::new(PostgresSearchAdapter::new(pool)); let adapter = Arc::new(PostgresSearchAdapter::new(pool));
(Arc::clone(&adapter) as Arc<dyn SearchCommand>, adapter as Arc<dyn SearchPort>) (
Arc::clone(&adapter) as Arc<dyn SearchCommand>,
adapter as Arc<dyn SearchPort>,
)
} }
fn map_err(e: sqlx::Error) -> DomainError { fn map_err(e: sqlx::Error) -> DomainError {
@@ -41,17 +43,39 @@ impl SearchCommand for PostgresSearchAdapter {
let movie_id = id.value().to_string(); let movie_id = id.value().to_string();
let title = movie.title().value().to_string(); let title = movie.title().value().to_string();
let director = movie.director().unwrap_or("").to_string(); let director = movie.director().unwrap_or("").to_string();
let (overview, genres, keywords, cast_names, crew_names) = let (overview, genres, keywords, cast_names, crew_names) = match profile.as_deref()
match profile.as_deref() { {
Some(p) => ( Some(p) => (
p.overview.clone().unwrap_or_default(), p.overview.clone().unwrap_or_default(),
p.genres.iter().map(|g| g.name.as_str()).collect::<Vec<_>>().join(" "), p.genres
p.keywords.iter().map(|k| k.name.as_str()).collect::<Vec<_>>().join(" "), .iter()
p.cast.iter().map(|c| c.name.as_str()).collect::<Vec<_>>().join(" "), .map(|g| g.name.as_str())
p.crew.iter().map(|c| c.name.as_str()).collect::<Vec<_>>().join(" "), .collect::<Vec<_>>()
), .join(" "),
None => (String::new(), String::new(), String::new(), String::new(), String::new()), p.keywords
}; .iter()
.map(|k| k.name.as_str())
.collect::<Vec<_>>()
.join(" "),
p.cast
.iter()
.map(|c| c.name.as_str())
.collect::<Vec<_>>()
.join(" "),
p.crew
.iter()
.map(|c| c.name.as_str())
.collect::<Vec<_>>()
.join(" "),
),
None => (
String::new(),
String::new(),
String::new(),
String::new(),
String::new(),
),
};
let fts_input = format!( let fts_input = format!(
"{} {} {} {} {} {} {}", "{} {} {} {} {} {} {}",
@@ -127,7 +151,10 @@ impl SearchPort for PostgresSearchAdapter {
} }
impl PostgresSearchAdapter { impl PostgresSearchAdapter {
async fn search_movies(&self, query: &SearchQuery) -> Result<Paginated<MovieSearchHit>, DomainError> { async fn search_movies(
&self,
query: &SearchQuery,
) -> Result<Paginated<MovieSearchHit>, DomainError> {
let limit = query.page.limit as i64; let limit = query.page.limit as i64;
let offset = query.page.offset as i64; let offset = query.page.offset as i64;
@@ -214,24 +241,36 @@ impl PostgresSearchAdapter {
.map_err(map_err)? .map_err(map_err)?
}; };
let items = rows.into_iter().map(|r| MovieSearchHit { let items = rows
movie_id: MovieId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()), .into_iter()
title: r.title, .map(|r| MovieSearchHit {
release_year: r.release_year.map(|y| y as u16), movie_id: MovieId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()),
director: r.director, title: r.title,
poster_path: r.poster_path, release_year: r.release_year.map(|y| y as u16),
genres: r.genres director: r.director,
.unwrap_or_default() poster_path: r.poster_path,
.split(',') genres: r
.filter(|s| !s.is_empty()) .genres
.map(str::to_string) .unwrap_or_default()
.collect(), .split(',')
}).collect::<Vec<_>>(); .filter(|s| !s.is_empty())
.map(str::to_string)
.collect(),
})
.collect::<Vec<_>>();
Ok(Paginated { items, total_count: total, limit: query.page.limit, offset: query.page.offset }) Ok(Paginated {
items,
total_count: total,
limit: query.page.limit,
offset: query.page.offset,
})
} }
async fn search_people(&self, query: &SearchQuery) -> Result<Paginated<PersonSearchHit>, DomainError> { async fn search_people(
&self,
query: &SearchQuery,
) -> Result<Paginated<PersonSearchHit>, DomainError> {
let Some(text) = &query.text else { let Some(text) = &query.text else {
return Ok(Paginated { return Ok(Paginated {
items: vec![], items: vec![],
@@ -299,7 +338,7 @@ impl PostgresSearchAdapter {
items.push(PersonSearchHit { items.push(PersonSearchHit {
person_id: PersonId::from_uuid( person_id: PersonId::from_uuid(
uuid::Uuid::parse_str(&row.person_id).unwrap_or_default() uuid::Uuid::parse_str(&row.person_id).unwrap_or_default(),
), ),
name: row.name, name: row.name,
known_for_department: row.known_for_department, known_for_department: row.known_for_department,
@@ -308,6 +347,11 @@ impl PostgresSearchAdapter {
}); });
} }
Ok(Paginated { items, total_count: total, limit: query.page.limit, offset: query.page.offset }) Ok(Paginated {
items,
total_count: total,
limit: query.page.limit,
offset: query.page.offset,
})
} }
} }

View File

@@ -1,5 +1,8 @@
use async_trait::async_trait; use async_trait::async_trait;
use domain::{errors::DomainError, ports::{ImageRefCommand, ImageRefQuery}}; use domain::{
errors::DomainError,
ports::{ImageRefCommand, ImageRefQuery},
};
use sqlx::PgPool; use sqlx::PgPool;
use std::sync::Arc; use std::sync::Arc;
@@ -15,23 +18,34 @@ impl PostgresImageRefAdapter {
pub fn create_image_ref(pool: PgPool) -> (Arc<dyn ImageRefCommand>, Arc<dyn ImageRefQuery>) { pub fn create_image_ref(pool: PgPool) -> (Arc<dyn ImageRefCommand>, Arc<dyn ImageRefQuery>) {
let adapter = Arc::new(PostgresImageRefAdapter::new(pool)); let adapter = Arc::new(PostgresImageRefAdapter::new(pool));
(Arc::clone(&adapter) as Arc<dyn ImageRefCommand>, adapter as Arc<dyn ImageRefQuery>) (
Arc::clone(&adapter) as Arc<dyn ImageRefCommand>,
adapter as Arc<dyn ImageRefQuery>,
)
} }
#[async_trait] #[async_trait]
impl ImageRefCommand for PostgresImageRefAdapter { impl ImageRefCommand for PostgresImageRefAdapter {
async fn swap(&self, old_key: &str, new_key: &str) -> Result<(), DomainError> { async fn swap(&self, old_key: &str, new_key: &str) -> Result<(), DomainError> {
let mut tx = self.pool.begin().await let mut tx = self
.pool
.begin()
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?; .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
sqlx::query("UPDATE users SET avatar_path = $1 WHERE avatar_path = $2") sqlx::query("UPDATE users SET avatar_path = $1 WHERE avatar_path = $2")
.bind(new_key).bind(old_key) .bind(new_key)
.execute(&mut *tx).await .bind(old_key)
.execute(&mut *tx)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?; .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
sqlx::query("UPDATE movies SET poster_path = $1 WHERE poster_path = $2") sqlx::query("UPDATE movies SET poster_path = $1 WHERE poster_path = $2")
.bind(new_key).bind(old_key) .bind(new_key)
.execute(&mut *tx).await .bind(old_key)
.execute(&mut *tx)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?; .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
tx.commit().await tx.commit()
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string())) .map_err(|e| DomainError::InfrastructureError(e.to_string()))
} }
} }

View File

@@ -14,12 +14,20 @@ use sqlx::PgPool;
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
enum DomainFieldJson { enum DomainFieldJson {
Title, ReleaseYear, Director, Rating, WatchedAt, Comment, ExternalMetadataId, Title,
ReleaseYear,
Director,
Rating,
WatchedAt,
Comment,
ExternalMetadataId,
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
enum TransformJson { enum TransformJson {
RatingScale(f64), DateFormat(String), Identity, RatingScale(f64),
DateFormat(String),
Identity,
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
@@ -75,8 +83,8 @@ fn serialize_mappings(ms: &[FieldMapping]) -> Result<String, DomainError> {
} }
fn deserialize_mappings(s: &str) -> Result<Vec<FieldMapping>, DomainError> { fn deserialize_mappings(s: &str) -> Result<Vec<FieldMapping>, DomainError> {
let js: Vec<FieldMappingJson> = serde_json::from_str(s) let js: Vec<FieldMappingJson> =
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?; serde_json::from_str(s).map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(js.into_iter().map(mapping_from_json).collect()) Ok(js.into_iter().map(mapping_from_json).collect())
} }
@@ -85,7 +93,9 @@ pub struct PostgresImportProfileRepository {
} }
impl PostgresImportProfileRepository { impl PostgresImportProfileRepository {
pub fn new(pool: PgPool) -> Self { Self { pool } } pub fn new(pool: PgPool) -> Self {
Self { pool }
}
fn map_err(e: sqlx::Error) -> DomainError { fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("DB error: {:?}", e); tracing::error!("DB error: {:?}", e);
@@ -115,7 +125,13 @@ impl ImportProfileRepository for PostgresImportProfileRepository {
let uid = user_id.value().to_string(); let uid = user_id.value().to_string();
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct Row { id: String, user_id: String, name: String, field_mappings: String, created_at: NaiveDateTime } struct Row {
id: String,
user_id: String,
name: String,
field_mappings: String,
created_at: NaiveDateTime,
}
let rows = sqlx::query_as::<_, Row>( let rows = sqlx::query_as::<_, Row>(
"SELECT id, user_id, name, field_mappings, created_at FROM import_profiles WHERE user_id = $1 ORDER BY created_at DESC", "SELECT id, user_id, name, field_mappings, created_at FROM import_profiles WHERE user_id = $1 ORDER BY created_at DESC",
@@ -125,25 +141,42 @@ impl ImportProfileRepository for PostgresImportProfileRepository {
.await .await
.map_err(Self::map_err)?; .map_err(Self::map_err)?;
rows.into_iter().map(|r| Ok(ImportProfile { rows.into_iter()
id: ImportProfileId::from_uuid( .map(|r| {
r.id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))? Ok(ImportProfile {
), id: ImportProfileId::from_uuid(
user_id: UserId::from_uuid( r.id.parse::<uuid::Uuid>()
r.user_id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))? .map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
), ),
name: r.name, user_id: UserId::from_uuid(
field_mappings: deserialize_mappings(&r.field_mappings)?, r.user_id
created_at: r.created_at, .parse::<uuid::Uuid>()
})).collect() .map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
),
name: r.name,
field_mappings: deserialize_mappings(&r.field_mappings)?,
created_at: r.created_at,
})
})
.collect()
} }
async fn get(&self, id: &ImportProfileId, user_id: &UserId) -> Result<Option<ImportProfile>, DomainError> { async fn get(
&self,
id: &ImportProfileId,
user_id: &UserId,
) -> Result<Option<ImportProfile>, DomainError> {
let id_str = id.value().to_string(); let id_str = id.value().to_string();
let uid_str = user_id.value().to_string(); let uid_str = user_id.value().to_string();
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct Row { id: String, user_id: String, name: String, field_mappings: String, created_at: NaiveDateTime } struct Row {
id: String,
user_id: String,
name: String,
field_mappings: String,
created_at: NaiveDateTime,
}
let row = sqlx::query_as::<_, Row>( let row = sqlx::query_as::<_, Row>(
"SELECT id, user_id, name, field_mappings, created_at FROM import_profiles WHERE id = $1 AND user_id = $2", "SELECT id, user_id, name, field_mappings, created_at FROM import_profiles WHERE id = $1 AND user_id = $2",
@@ -153,17 +186,23 @@ impl ImportProfileRepository for PostgresImportProfileRepository {
.await .await
.map_err(Self::map_err)?; .map_err(Self::map_err)?;
row.map(|r| Ok(ImportProfile { row.map(|r| {
id: ImportProfileId::from_uuid( Ok(ImportProfile {
r.id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))? id: ImportProfileId::from_uuid(
), r.id.parse::<uuid::Uuid>()
user_id: UserId::from_uuid( .map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
r.user_id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))? ),
), user_id: UserId::from_uuid(
name: r.name, r.user_id
field_mappings: deserialize_mappings(&r.field_mappings)?, .parse::<uuid::Uuid>()
created_at: r.created_at, .map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
})).transpose() ),
name: r.name,
field_mappings: deserialize_mappings(&r.field_mappings)?,
created_at: r.created_at,
})
})
.transpose()
} }
async fn delete(&self, id: &ImportProfileId) -> Result<(), DomainError> { async fn delete(&self, id: &ImportProfileId) -> Result<(), DomainError> {

View File

@@ -22,7 +22,13 @@ struct ParsedFileJson {
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
enum DomainFieldJson { enum DomainFieldJson {
Title, ReleaseYear, Director, Rating, WatchedAt, Comment, ExternalMetadataId, Title,
ReleaseYear,
Director,
Rating,
WatchedAt,
Comment,
ExternalMetadataId,
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
@@ -41,19 +47,29 @@ struct FieldMappingJson {
#[derive(Serialize, Deserialize, Default)] #[derive(Serialize, Deserialize, Default)]
struct ImportRowJson { struct ImportRowJson {
#[serde(skip_serializing_if = "Option::is_none")] title: Option<String>, #[serde(skip_serializing_if = "Option::is_none")]
#[serde(skip_serializing_if = "Option::is_none")] release_year: Option<String>, title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] director: Option<String>, #[serde(skip_serializing_if = "Option::is_none")]
#[serde(skip_serializing_if = "Option::is_none")] rating: Option<String>, release_year: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] watched_at: Option<String>, #[serde(skip_serializing_if = "Option::is_none")]
#[serde(skip_serializing_if = "Option::is_none")] comment: Option<String>, director: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] external_metadata_id: Option<String>, #[serde(skip_serializing_if = "Option::is_none")]
rating: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
watched_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
comment: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
external_metadata_id: Option<String>,
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
enum RowResultJson { enum RowResultJson {
Valid(ImportRowJson), Valid(ImportRowJson),
Invalid { errors: Vec<String>, raw: Vec<(String, String)> }, Invalid {
errors: Vec<String>,
raw: Vec<(String, String)>,
},
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
@@ -182,22 +198,37 @@ pub struct PostgresImportSessionRepository {
} }
impl PostgresImportSessionRepository { impl PostgresImportSessionRepository {
pub fn new(pool: PgPool) -> Self { Self { pool } } pub fn new(pool: PgPool) -> Self {
Self { pool }
}
fn map_err(e: sqlx::Error) -> DomainError { fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("DB error: {:?}", e); tracing::error!("DB error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into()) DomainError::InfrastructureError("Database operation failed".into())
} }
fn serialize_session(s: &ImportSession) -> Result<(String, Option<String>, Option<String>), DomainError> { fn serialize_session(
let parsed = s.parsed_file.as_ref() s: &ImportSession,
.map(|f| ser(&ParsedFileJson { columns: f.columns.clone(), rows: f.rows.clone() })) ) -> Result<(String, Option<String>, Option<String>), DomainError> {
let parsed = s
.parsed_file
.as_ref()
.map(|f| {
ser(&ParsedFileJson {
columns: f.columns.clone(),
rows: f.rows.clone(),
})
})
.transpose()? .transpose()?
.unwrap_or_default(); .unwrap_or_default();
let mappings = s.field_mappings.as_ref() let mappings = s
.field_mappings
.as_ref()
.map(|ms| ser(&ms.iter().map(mapping_to_json).collect::<Vec<_>>())) .map(|ms| ser(&ms.iter().map(mapping_to_json).collect::<Vec<_>>()))
.transpose()?; .transpose()?;
let results = s.row_results.as_ref() let results = s
.row_results
.as_ref()
.map(|rs| ser(&rs.iter().map(annotated_to_json).collect::<Vec<_>>())) .map(|rs| ser(&rs.iter().map(annotated_to_json).collect::<Vec<_>>()))
.transpose()?; .transpose()?;
Ok((parsed, mappings, results)) Ok((parsed, mappings, results))
@@ -216,15 +247,20 @@ impl PostgresImportSessionRepository {
None None
} else { } else {
let j: ParsedFileJson = de(&parsed_data)?; let j: ParsedFileJson = de(&parsed_data)?;
Some(ParsedFile { columns: j.columns, rows: j.rows }) Some(ParsedFile {
columns: j.columns,
rows: j.rows,
})
}; };
let field_mappings = field_mappings.as_deref() let field_mappings = field_mappings
.as_deref()
.map(|s| -> Result<Vec<FieldMapping>, DomainError> { .map(|s| -> Result<Vec<FieldMapping>, DomainError> {
let js: Vec<FieldMappingJson> = de(s)?; let js: Vec<FieldMappingJson> = de(s)?;
Ok(js.into_iter().map(mapping_from_json).collect()) Ok(js.into_iter().map(mapping_from_json).collect())
}) })
.transpose()?; .transpose()?;
let row_results = row_results.as_deref() let row_results = row_results
.as_deref()
.map(|s| -> Result<Vec<AnnotatedRow>, DomainError> { .map(|s| -> Result<Vec<AnnotatedRow>, DomainError> {
let js: Vec<AnnotatedRowJson> = de(s)?; let js: Vec<AnnotatedRowJson> = de(s)?;
Ok(js.into_iter().map(annotated_from_json).collect()) Ok(js.into_iter().map(annotated_from_json).collect())
@@ -232,10 +268,13 @@ impl PostgresImportSessionRepository {
.transpose()?; .transpose()?;
Ok(ImportSession { Ok(ImportSession {
id: ImportSessionId::from_uuid( id: ImportSessionId::from_uuid(
id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))? id.parse::<uuid::Uuid>()
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
), ),
user_id: UserId::from_uuid( user_id: UserId::from_uuid(
user_id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))? user_id
.parse::<uuid::Uuid>()
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
), ),
parsed_file, parsed_file,
field_mappings, field_mappings,
@@ -265,7 +304,11 @@ impl ImportSessionRepository for PostgresImportSessionRepository {
.map_err(Self::map_err) .map_err(Self::map_err)
} }
async fn get(&self, id: &ImportSessionId, user_id: &UserId) -> Result<Option<ImportSession>, DomainError> { async fn get(
&self,
id: &ImportSessionId,
user_id: &UserId,
) -> Result<Option<ImportSession>, DomainError> {
let id_str = id.value().to_string(); let id_str = id.value().to_string();
let uid_str = user_id.value().to_string(); let uid_str = user_id.value().to_string();
@@ -284,26 +327,39 @@ impl ImportSessionRepository for PostgresImportSessionRepository {
"SELECT id, user_id, parsed_data, field_mappings, row_results, created_at, expires_at "SELECT id, user_id, parsed_data, field_mappings, row_results, created_at, expires_at
FROM import_sessions WHERE id = $1 AND user_id = $2", FROM import_sessions WHERE id = $1 AND user_id = $2",
) )
.bind(&id_str).bind(&uid_str) .bind(&id_str)
.bind(&uid_str)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(Self::map_err)?;
row.map(|r| Self::deserialize_session( row.map(|r| {
r.id, r.user_id, r.parsed_data, r.field_mappings, r.row_results, Self::deserialize_session(
r.created_at, r.expires_at, r.id,
)).transpose() r.user_id,
r.parsed_data,
r.field_mappings,
r.row_results,
r.created_at,
r.expires_at,
)
})
.transpose()
} }
async fn update(&self, s: &ImportSession) -> Result<(), DomainError> { async fn update(&self, s: &ImportSession) -> Result<(), DomainError> {
let id = s.id.value().to_string(); let id = s.id.value().to_string();
let (_, field_mappings, row_results) = Self::serialize_session(s)?; let (_, field_mappings, row_results) = Self::serialize_session(s)?;
sqlx::query("UPDATE import_sessions SET field_mappings = $1, row_results = $2 WHERE id = $3") sqlx::query(
.bind(&field_mappings).bind(&row_results).bind(&id) "UPDATE import_sessions SET field_mappings = $1, row_results = $2 WHERE id = $3",
.execute(&self.pool) )
.await .bind(&field_mappings)
.map(|_| ()) .bind(&row_results)
.map_err(Self::map_err) .bind(&id)
.execute(&self.pool)
.await
.map(|_| ())
.map_err(Self::map_err)
} }
async fn delete(&self, id: &ImportSessionId) -> Result<(), DomainError> { async fn delete(&self, id: &ImportSessionId) -> Result<(), DomainError> {

View File

@@ -388,11 +388,15 @@ impl MovieRepository for PostgresRepository {
&self, &self,
page: &domain::models::collections::PageParams, page: &domain::models::collections::PageParams,
filter: &domain::models::MovieFilter, filter: &domain::models::MovieFilter,
) -> Result<domain::models::collections::Paginated<domain::models::MovieSummary>, DomainError> { ) -> Result<domain::models::collections::Paginated<domain::models::MovieSummary>, DomainError>
{
use sqlx::Row; use sqlx::Row;
let limit = page.limit as i64; let limit = page.limit as i64;
let offset = page.offset as i64; let offset = page.offset as i64;
let pattern = filter.search.as_deref().map(|s| format!("%{}%", s.to_lowercase())); let pattern = filter
.search
.as_deref()
.map(|s| format!("%{}%", s.to_lowercase()));
let genre = filter.genre.as_deref(); let genre = filter.genre.as_deref();
let language = filter.language.as_deref(); let language = filter.language.as_deref();
@@ -612,8 +616,7 @@ impl DiaryRepository for PostgresRepository {
} }
if let Some(f) = following { if let Some(f) = following {
let local_params: Vec<String> = let local_params: Vec<String> = f.local_user_ids.iter().map(|_| next_param()).collect();
f.local_user_ids.iter().map(|_| next_param()).collect();
let remote_params: Vec<String> = let remote_params: Vec<String> =
f.remote_actor_urls.iter().map(|_| next_param()).collect(); f.remote_actor_urls.iter().map(|_| next_param()).collect();
@@ -691,10 +694,7 @@ impl DiaryRepository for PostgresRepository {
} }
let count_q = bind_filter_params!(sqlx::query_scalar::<_, i64>(&count_sql)); let count_q = bind_filter_params!(sqlx::query_scalar::<_, i64>(&count_sql));
let total = count_q let total = count_q.fetch_one(&self.pool).await.map_err(Self::map_err)?;
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)?;
let rows_q = bind_filter_params!(sqlx::query_as::<_, FeedRow>(&select_sql)); let rows_q = bind_filter_params!(sqlx::query_as::<_, FeedRow>(&select_sql));
let rows = rows_q let rows = rows_q
@@ -800,13 +800,11 @@ impl DiaryRepository for PostgresRepository {
let limit = page.limit as i64; let limit = page.limit as i64;
let offset = page.offset as i64; let offset = page.offset as i64;
let total: i64 = sqlx::query_scalar( let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE movie_id = $1")
"SELECT COUNT(*) FROM reviews WHERE movie_id = $1", .bind(&id_str)
) .fetch_one(&self.pool)
.bind(&id_str) .await
.fetch_one(&self.pool) .map_err(Self::map_err)?;
.await
.map_err(Self::map_err)?;
let rows = sqlx::query_as::<_, FeedRow>( let rows = sqlx::query_as::<_, FeedRow>(
"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,
@@ -845,12 +843,11 @@ impl DiaryRepository for PostgresRepository {
} }
async fn count_local_posts(&self) -> Result<u64, DomainError> { async fn count_local_posts(&self) -> Result<u64, DomainError> {
let count: i64 = sqlx::query_scalar( let count: i64 =
"SELECT COUNT(*) FROM reviews WHERE remote_actor_url IS NULL" sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE remote_actor_url IS NULL")
) .fetch_one(&self.pool)
.fetch_one(&self.pool) .await
.await .map_err(Self::map_err)?;
.map_err(Self::map_err)?;
Ok(count as u64) Ok(count as u64)
} }
} }
@@ -939,7 +936,9 @@ pub fn create_profile_fields_repo(
std::sync::Arc::new(profile_fields::PostgresProfileFieldsRepository::new(pool)) std::sync::Arc::new(profile_fields::PostgresProfileFieldsRepository::new(pool))
} }
pub async fn wire(database_url: &str) -> anyhow::Result<( pub async fn wire(
database_url: &str,
) -> anyhow::Result<(
sqlx::PgPool, sqlx::PgPool,
std::sync::Arc<dyn domain::ports::MovieRepository>, std::sync::Arc<dyn domain::ports::MovieRepository>,
std::sync::Arc<dyn domain::ports::ReviewRepository>, std::sync::Arc<dyn domain::ports::ReviewRepository>,
@@ -963,8 +962,10 @@ pub async fn wire(database_url: &str) -> anyhow::Result<(
.map_err(|e| anyhow::anyhow!("{e}")) .map_err(|e| anyhow::anyhow!("{e}"))
.context("Database migration failed")?; .context("Database migration failed")?;
let import_session_repo = std::sync::Arc::new(PostgresImportSessionRepository::new(pool.clone())); let import_session_repo =
let import_profile_repo = std::sync::Arc::new(PostgresImportProfileRepository::new(pool.clone())); std::sync::Arc::new(PostgresImportSessionRepository::new(pool.clone()));
let import_profile_repo =
std::sync::Arc::new(PostgresImportProfileRepository::new(pool.clone()));
let movie_profile_repo = std::sync::Arc::new(PostgresMovieProfileRepository::new(pool.clone())); let movie_profile_repo = std::sync::Arc::new(PostgresMovieProfileRepository::new(pool.clone()));
let watchlist_repo = std::sync::Arc::new(PostgresWatchlistRepository::new(pool.clone())); let watchlist_repo = std::sync::Arc::new(PostgresWatchlistRepository::new(pool.clone()));

View File

@@ -20,7 +20,10 @@ impl PostgresPersonAdapter {
pub fn create_person_adapter(pool: PgPool) -> (Arc<dyn PersonCommand>, Arc<dyn PersonQuery>) { pub fn create_person_adapter(pool: PgPool) -> (Arc<dyn PersonCommand>, Arc<dyn PersonQuery>) {
let adapter = Arc::new(PostgresPersonAdapter::new(pool)); let adapter = Arc::new(PostgresPersonAdapter::new(pool));
(Arc::clone(&adapter) as Arc<dyn PersonCommand>, adapter as Arc<dyn PersonQuery>) (
Arc::clone(&adapter) as Arc<dyn PersonCommand>,
adapter as Arc<dyn PersonQuery>,
)
} }
fn map_err(e: sqlx::Error) -> DomainError { fn map_err(e: sqlx::Error) -> DomainError {
@@ -88,7 +91,10 @@ impl PersonQuery for PostgresPersonAdapter {
})) }))
} }
async fn get_by_external_id(&self, id: &ExternalPersonId) -> Result<Option<Person>, DomainError> { async fn get_by_external_id(
&self,
id: &ExternalPersonId,
) -> Result<Option<Person>, DomainError> {
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct Row { struct Row {
id: String, id: String,
@@ -119,21 +125,25 @@ impl PersonQuery for PostgresPersonAdapter {
} }
async fn get_credits(&self, id: &PersonId) -> Result<PersonCredits, DomainError> { async fn get_credits(&self, id: &PersonId) -> Result<PersonCredits, DomainError> {
let person = self.get_by_id(id).await?.ok_or_else(|| { let person = self
DomainError::NotFound(format!("Person {} not found", id.value())) .get_by_id(id)
})?; .await?
.ok_or_else(|| DomainError::NotFound(format!("Person {} not found", id.value())))?;
let tmdb_id: Option<i64> = sqlx::query_scalar( let tmdb_id: Option<i64> =
"SELECT tmdb_person_id FROM persons WHERE id = $1", sqlx::query_scalar("SELECT tmdb_person_id FROM persons WHERE id = $1")
) .bind(id.value().to_string())
.bind(id.value().to_string()) .fetch_optional(&self.pool)
.fetch_optional(&self.pool) .await
.await .map_err(map_err)?
.map_err(map_err)? .flatten();
.flatten();
let Some(tmdb_id) = tmdb_id else { let Some(tmdb_id) = tmdb_id else {
return Ok(PersonCredits { person, cast: vec![], crew: vec![] }); return Ok(PersonCredits {
person,
cast: vec![],
crew: vec![],
});
}; };
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]

View File

@@ -65,7 +65,9 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
sqlx::query("DELETE FROM movie_genres WHERE movie_id = $1") sqlx::query("DELETE FROM movie_genres WHERE movie_id = $1")
.bind(&movie_id) .bind(&movie_id)
.execute(&mut *tx).await.map_err(Self::map_err)?; .execute(&mut *tx)
.await
.map_err(Self::map_err)?;
for g in &p.genres { for g in &p.genres {
sqlx::query("INSERT INTO movie_genres (movie_id, tmdb_id, name) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING") sqlx::query("INSERT INTO movie_genres (movie_id, tmdb_id, name) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING")
.bind(&movie_id).bind(g.tmdb_id as i32).bind(&g.name) .bind(&movie_id).bind(g.tmdb_id as i32).bind(&g.name)
@@ -74,7 +76,9 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
sqlx::query("DELETE FROM movie_keywords WHERE movie_id = $1") sqlx::query("DELETE FROM movie_keywords WHERE movie_id = $1")
.bind(&movie_id) .bind(&movie_id)
.execute(&mut *tx).await.map_err(Self::map_err)?; .execute(&mut *tx)
.await
.map_err(Self::map_err)?;
for k in &p.keywords { for k in &p.keywords {
sqlx::query("INSERT INTO movie_keywords (movie_id, tmdb_id, name) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING") sqlx::query("INSERT INTO movie_keywords (movie_id, tmdb_id, name) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING")
.bind(&movie_id).bind(k.tmdb_id as i32).bind(&k.name) .bind(&movie_id).bind(k.tmdb_id as i32).bind(&k.name)
@@ -83,30 +87,46 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
sqlx::query("DELETE FROM movie_cast WHERE movie_id = $1") sqlx::query("DELETE FROM movie_cast WHERE movie_id = $1")
.bind(&movie_id) .bind(&movie_id)
.execute(&mut *tx).await.map_err(Self::map_err)?; .execute(&mut *tx)
.await
.map_err(Self::map_err)?;
for c in &p.cast { for c in &p.cast {
sqlx::query( sqlx::query(
"INSERT INTO movie_cast \ "INSERT INTO movie_cast \
(movie_id, tmdb_person_id, name, character, billing_order, profile_path) \ (movie_id, tmdb_person_id, name, character, billing_order, profile_path) \
VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT DO NOTHING", VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT DO NOTHING",
) )
.bind(&movie_id).bind(c.tmdb_person_id as i64).bind(&c.name) .bind(&movie_id)
.bind(&c.character).bind(c.billing_order as i32).bind(&c.profile_path) .bind(c.tmdb_person_id as i64)
.execute(&mut *tx).await.map_err(Self::map_err)?; .bind(&c.name)
.bind(&c.character)
.bind(c.billing_order as i32)
.bind(&c.profile_path)
.execute(&mut *tx)
.await
.map_err(Self::map_err)?;
} }
sqlx::query("DELETE FROM movie_crew WHERE movie_id = $1") sqlx::query("DELETE FROM movie_crew WHERE movie_id = $1")
.bind(&movie_id) .bind(&movie_id)
.execute(&mut *tx).await.map_err(Self::map_err)?; .execute(&mut *tx)
.await
.map_err(Self::map_err)?;
for cr in &p.crew { for cr in &p.crew {
sqlx::query( sqlx::query(
"INSERT INTO movie_crew \ "INSERT INTO movie_crew \
(movie_id, tmdb_person_id, name, job, department, profile_path) \ (movie_id, tmdb_person_id, name, job, department, profile_path) \
VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT DO NOTHING", VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT DO NOTHING",
) )
.bind(&movie_id).bind(cr.tmdb_person_id as i64).bind(&cr.name) .bind(&movie_id)
.bind(&cr.job).bind(&cr.department).bind(&cr.profile_path) .bind(cr.tmdb_person_id as i64)
.execute(&mut *tx).await.map_err(Self::map_err)?; .bind(&cr.name)
.bind(&cr.job)
.bind(&cr.department)
.bind(&cr.profile_path)
.execute(&mut *tx)
.await
.map_err(Self::map_err)?;
} }
tx.commit().await.map_err(Self::map_err) tx.commit().await.map_err(Self::map_err)
@@ -131,12 +151,15 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
None => return Ok(None), None => return Ok(None),
}; };
let enriched_at: DateTime<Utc> = row.try_get("enriched_at") let enriched_at: DateTime<Utc> = row
.try_get("enriched_at")
.map_err(|_| DomainError::InfrastructureError("invalid enriched_at".into()))?; .map_err(|_| DomainError::InfrastructureError("invalid enriched_at".into()))?;
let genres = sqlx::query("SELECT tmdb_id, name FROM movie_genres WHERE movie_id = $1") let genres = sqlx::query("SELECT tmdb_id, name FROM movie_genres WHERE movie_id = $1")
.bind(&movie_id) .bind(&movie_id)
.fetch_all(&self.pool).await.map_err(Self::map_err)? .fetch_all(&self.pool)
.await
.map_err(Self::map_err)?
.into_iter() .into_iter()
.map(|r| Genre { .map(|r| Genre {
tmdb_id: r.try_get::<i32, _>("tmdb_id").unwrap_or(0) as u32, tmdb_id: r.try_get::<i32, _>("tmdb_id").unwrap_or(0) as u32,
@@ -146,7 +169,9 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
let keywords = sqlx::query("SELECT tmdb_id, name FROM movie_keywords WHERE movie_id = $1") let keywords = sqlx::query("SELECT tmdb_id, name FROM movie_keywords WHERE movie_id = $1")
.bind(&movie_id) .bind(&movie_id)
.fetch_all(&self.pool).await.map_err(Self::map_err)? .fetch_all(&self.pool)
.await
.map_err(Self::map_err)?
.into_iter() .into_iter()
.map(|r| Keyword { .map(|r| Keyword {
tmdb_id: r.try_get::<i32, _>("tmdb_id").unwrap_or(0) as u32, tmdb_id: r.try_get::<i32, _>("tmdb_id").unwrap_or(0) as u32,
@@ -159,7 +184,9 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
FROM movie_cast WHERE movie_id = $1 ORDER BY billing_order", FROM movie_cast WHERE movie_id = $1 ORDER BY billing_order",
) )
.bind(&movie_id) .bind(&movie_id)
.fetch_all(&self.pool).await.map_err(Self::map_err)? .fetch_all(&self.pool)
.await
.map_err(Self::map_err)?
.into_iter() .into_iter()
.map(|r| CastMember { .map(|r| CastMember {
tmdb_person_id: r.try_get::<i64, _>("tmdb_person_id").unwrap_or(0) as u64, tmdb_person_id: r.try_get::<i64, _>("tmdb_person_id").unwrap_or(0) as u64,
@@ -175,7 +202,9 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
FROM movie_crew WHERE movie_id = $1", FROM movie_crew WHERE movie_id = $1",
) )
.bind(&movie_id) .bind(&movie_id)
.fetch_all(&self.pool).await.map_err(Self::map_err)? .fetch_all(&self.pool)
.await
.map_err(Self::map_err)?
.into_iter() .into_iter()
.map(|r| CrewMember { .map(|r| CrewMember {
tmdb_person_id: r.try_get::<i64, _>("tmdb_person_id").unwrap_or(0) as u64, tmdb_person_id: r.try_get::<i64, _>("tmdb_person_id").unwrap_or(0) as u64,
@@ -192,11 +221,19 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
imdb_id: row.try_get("imdb_id").ok(), imdb_id: row.try_get("imdb_id").ok(),
overview: row.try_get("overview").ok(), overview: row.try_get("overview").ok(),
tagline: row.try_get("tagline").ok(), tagline: row.try_get("tagline").ok(),
runtime_minutes: row.try_get::<Option<i32>, _>("runtime_minutes").ok().flatten().map(|v| v as u32), runtime_minutes: row
.try_get::<Option<i32>, _>("runtime_minutes")
.ok()
.flatten()
.map(|v| v as u32),
budget_usd: row.try_get("budget_usd").ok(), budget_usd: row.try_get("budget_usd").ok(),
revenue_usd: row.try_get("revenue_usd").ok(), revenue_usd: row.try_get("revenue_usd").ok(),
vote_average: row.try_get("vote_average").ok(), vote_average: row.try_get("vote_average").ok(),
vote_count: row.try_get::<Option<i32>, _>("vote_count").ok().flatten().map(|v| v as u32), vote_count: row
.try_get::<Option<i32>, _>("vote_count")
.ok()
.flatten()
.map(|v| v as u32),
original_language: row.try_get("original_language").ok(), original_language: row.try_get("original_language").ok(),
collection_name: row.try_get("collection_name").ok(), collection_name: row.try_get("collection_name").ok(),
genres, genres,

View File

@@ -2,9 +2,7 @@ use async_trait::async_trait;
use sqlx::PgPool; use sqlx::PgPool;
use domain::{ use domain::{
errors::DomainError, errors::DomainError, models::ProfileField, ports::UserProfileFieldsRepository,
models::ProfileField,
ports::UserProfileFieldsRepository,
value_objects::UserId, value_objects::UserId,
}; };

View File

@@ -46,8 +46,8 @@ impl PostgresUserRepository {
) -> Result<User, DomainError> { ) -> Result<User, DomainError> {
let id = uuid::Uuid::parse_str(&id_str) let id = uuid::Uuid::parse_str(&id_str)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?; .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let email = Email::new(email_str) let email =
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?; Email::new(email_str).map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let username = Username::new(username_str) let username = Username::new(username_str)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?; .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let hash = PasswordHash::new(hash_str) let hash = PasswordHash::new(hash_str)
@@ -208,7 +208,10 @@ impl UserRepository for PostgresUserRepository {
let Some(r) = row else { return Ok(None) }; let Some(r) = row else { return Ok(None) };
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct FieldRow { name: String, value: String } struct FieldRow {
name: String,
value: String,
}
let field_rows = sqlx::query_as::<_, FieldRow>( let field_rows = sqlx::query_as::<_, FieldRow>(
"SELECT name, value FROM user_profile_fields WHERE user_id = $1 ORDER BY position ASC", "SELECT name, value FROM user_profile_fields WHERE user_id = $1 ORDER BY position ASC",
) )
@@ -217,7 +220,13 @@ impl UserRepository for PostgresUserRepository {
.await .await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?; .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let profile_fields = field_rows.into_iter().map(|f| ProfileField { name: f.name, value: f.value }).collect(); let profile_fields = field_rows
.into_iter()
.map(|f| ProfileField {
name: f.name,
value: f.value,
})
.collect();
Self::row_to_user( Self::row_to_user(
r.id, r.id,
@@ -230,7 +239,8 @@ impl UserRepository for PostgresUserRepository {
r.banner_path, r.banner_path,
r.also_known_as, r.also_known_as,
profile_fields, profile_fields,
).map(Some) )
.map(Some)
} }
async fn update_profile( async fn update_profile(

View File

@@ -1,13 +1,16 @@
use async_trait::async_trait; use async_trait::async_trait;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::{WatchlistEntry, WatchlistWithMovie, collections::{PageParams, Paginated}}, models::{
WatchlistEntry, WatchlistWithMovie,
collections::{PageParams, Paginated},
},
ports::WatchlistRepository, ports::WatchlistRepository,
value_objects::{MovieId, UserId, WatchlistEntryId}, value_objects::{MovieId, UserId, WatchlistEntryId},
}; };
use sqlx::{PgPool, Row}; use sqlx::{PgPool, Row};
use crate::models::{parse_uuid, parse_datetime, MovieRow}; use crate::models::{MovieRow, parse_datetime, parse_uuid};
pub struct PostgresWatchlistRepository { pub struct PostgresWatchlistRepository {
pool: PgPool, pool: PgPool,
@@ -52,14 +55,13 @@ impl WatchlistRepository for PostgresWatchlistRepository {
let uid = user_id.value().to_string(); let uid = user_id.value().to_string();
let mid = movie_id.value().to_string(); let mid = movie_id.value().to_string();
let result = sqlx::query( let result =
"DELETE FROM watchlist_entries WHERE user_id = $1 AND movie_id = $2", sqlx::query("DELETE FROM watchlist_entries WHERE user_id = $1 AND movie_id = $2")
) .bind(&uid)
.bind(&uid) .bind(&mid)
.bind(&mid) .execute(&self.pool)
.execute(&self.pool) .await
.await .map_err(Self::map_err)?;
.map_err(Self::map_err)?;
if result.rows_affected() == 0 { if result.rows_affected() == 0 {
return Err(DomainError::NotFound(format!( return Err(DomainError::NotFound(format!(
@@ -77,14 +79,13 @@ impl WatchlistRepository for PostgresWatchlistRepository {
) -> Result<bool, DomainError> { ) -> Result<bool, DomainError> {
let uid = user_id.value().to_string(); let uid = user_id.value().to_string();
let mid = movie_id.value().to_string(); let mid = movie_id.value().to_string();
let result = sqlx::query( let result =
"DELETE FROM watchlist_entries WHERE user_id = $1 AND movie_id = $2", sqlx::query("DELETE FROM watchlist_entries WHERE user_id = $1 AND movie_id = $2")
) .bind(&uid)
.bind(&uid) .bind(&mid)
.bind(&mid) .execute(&self.pool)
.execute(&self.pool) .await
.await .map_err(Self::map_err)?;
.map_err(Self::map_err)?;
Ok(result.rows_affected() > 0) Ok(result.rows_affected() > 0)
} }
@@ -115,30 +116,53 @@ impl WatchlistRepository for PostgresWatchlistRepository {
.await .await
.map_err(Self::map_err)?; .map_err(Self::map_err)?;
let total: i64 = sqlx::query_scalar( let total: i64 =
"SELECT COUNT(*) FROM watchlist_entries WHERE user_id = $1", sqlx::query_scalar("SELECT COUNT(*) FROM watchlist_entries WHERE user_id = $1")
) .bind(&uid)
.bind(&uid) .fetch_one(&self.pool)
.fetch_one(&self.pool) .await
.await .map_err(Self::map_err)?;
.map_err(Self::map_err)?;
let items = rows let items = rows
.into_iter() .into_iter()
.map(|row| { .map(|row| {
let entry = WatchlistEntry { let entry = WatchlistEntry {
id: WatchlistEntryId::from_uuid(parse_uuid(&row.try_get::<String, _>("id").map_err(|e| DomainError::InfrastructureError(e.to_string()))?)?), id: WatchlistEntryId::from_uuid(parse_uuid(
user_id: UserId::from_uuid(parse_uuid(&row.try_get::<String, _>("user_id").map_err(|e| DomainError::InfrastructureError(e.to_string()))?)?), &row.try_get::<String, _>("id")
movie_id: MovieId::from_uuid(parse_uuid(&row.try_get::<String, _>("movie_id").map_err(|e| DomainError::InfrastructureError(e.to_string()))?)?), .map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
added_at: parse_datetime(&row.try_get::<String, _>("added_at").map_err(|e| DomainError::InfrastructureError(e.to_string()))?)?, )?),
user_id: UserId::from_uuid(parse_uuid(
&row.try_get::<String, _>("user_id")
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
)?),
movie_id: MovieId::from_uuid(parse_uuid(
&row.try_get::<String, _>("movie_id")
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
)?),
added_at: parse_datetime(
&row.try_get::<String, _>("added_at")
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
)?,
}; };
let movie = MovieRow { let movie = MovieRow {
id: row.try_get("m_id").map_err(|e| DomainError::InfrastructureError(e.to_string()))?, id: row
external_metadata_id: row.try_get("external_metadata_id").map_err(|e| DomainError::InfrastructureError(e.to_string()))?, .try_get("m_id")
title: row.try_get("title").map_err(|e| DomainError::InfrastructureError(e.to_string()))?, .map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
release_year: row.try_get("release_year").map_err(|e| DomainError::InfrastructureError(e.to_string()))?, external_metadata_id: row
director: row.try_get("director").map_err(|e| DomainError::InfrastructureError(e.to_string()))?, .try_get("external_metadata_id")
poster_path: row.try_get("poster_path").map_err(|e| DomainError::InfrastructureError(e.to_string()))?, .map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
title: row
.try_get("title")
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
release_year: row
.try_get("release_year")
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
director: row
.try_get("director")
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
poster_path: row
.try_get("poster_path")
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
} }
.into_domain()?; .into_domain()?;
Ok(WatchlistWithMovie { entry, movie }) Ok(WatchlistWithMovie { entry, movie })

View File

@@ -18,33 +18,42 @@ use payload::DbEventPayload;
pub struct DbEventQueueConfig { pub struct DbEventQueueConfig {
pub poll_interval_ms: u64, pub poll_interval_ms: u64,
pub batch_size: i64, pub batch_size: i64,
pub max_attempts: i32, pub max_attempts: i32,
} }
impl DbEventQueueConfig { impl DbEventQueueConfig {
pub fn from_env() -> Self { pub fn from_env() -> Self {
Self { Self {
poll_interval_ms: std::env::var("EVENT_QUEUE_POLL_INTERVAL_MS") poll_interval_ms: std::env::var("EVENT_QUEUE_POLL_INTERVAL_MS")
.ok().and_then(|v| v.parse().ok()).unwrap_or(500), .ok()
.and_then(|v| v.parse().ok())
.unwrap_or(500),
batch_size: std::env::var("EVENT_QUEUE_BATCH_SIZE") batch_size: std::env::var("EVENT_QUEUE_BATCH_SIZE")
.ok().and_then(|v| v.parse().ok()).unwrap_or(10), .ok()
.and_then(|v| v.parse().ok())
.unwrap_or(10),
max_attempts: std::env::var("EVENT_QUEUE_MAX_ATTEMPTS") max_attempts: std::env::var("EVENT_QUEUE_MAX_ATTEMPTS")
.ok().and_then(|v| v.parse().ok()).unwrap_or(5), .ok()
.and_then(|v| v.parse().ok())
.unwrap_or(5),
} }
} }
} }
#[derive(Clone)] #[derive(Clone)]
pub struct SqliteEventQueue { pub struct SqliteEventQueue {
pool: SqlitePool, pool: SqlitePool,
config: Arc<DbEventQueueConfig>, config: Arc<DbEventQueueConfig>,
} }
impl SqliteEventQueue { impl SqliteEventQueue {
pub async fn create(pool: SqlitePool, config: DbEventQueueConfig) -> anyhow::Result<Self> { pub async fn create(pool: SqlitePool, config: DbEventQueueConfig) -> anyhow::Result<Self> {
migrations::run(&pool).await?; migrations::run(&pool).await?;
Ok(Self { pool, config: Arc::new(config) }) Ok(Self {
pool,
config: Arc::new(config),
})
} }
pub async fn create_publisher(pool: SqlitePool) -> anyhow::Result<Arc<dyn EventPublisher>> { pub async fn create_publisher(pool: SqlitePool) -> anyhow::Result<Arc<dyn EventPublisher>> {
@@ -68,14 +77,12 @@ impl EventPublisher for SqliteEventQueue {
let payload_json = serde_json::to_string(&db_payload) let payload_json = serde_json::to_string(&db_payload)
.map_err(|e| DomainError::InfrastructureError(format!("serialize: {e}")))?; .map_err(|e| DomainError::InfrastructureError(format!("serialize: {e}")))?;
sqlx::query( sqlx::query("INSERT INTO event_queue (event_type, payload) VALUES (?, ?)")
"INSERT INTO event_queue (event_type, payload) VALUES (?, ?)" .bind(event_type)
) .bind(payload_json)
.bind(event_type) .execute(&self.pool)
.bind(payload_json) .await
.execute(&self.pool) .map_err(|e| DomainError::InfrastructureError(format!("insert event: {e}")))?;
.await
.map_err(|e| DomainError::InfrastructureError(format!("insert event: {e}")))?;
Ok(()) Ok(())
} }
@@ -83,10 +90,10 @@ impl EventPublisher for SqliteEventQueue {
impl EventConsumer for SqliteEventQueue { impl EventConsumer for SqliteEventQueue {
fn consume(&self) -> BoxStream<'_, Result<EventEnvelope, DomainError>> { fn consume(&self) -> BoxStream<'_, Result<EventEnvelope, DomainError>> {
let pool = self.pool.clone(); let pool = self.pool.clone();
let config = Arc::clone(&self.config); let config = Arc::clone(&self.config);
let (tx, rx) = mpsc::channel(128); let (tx, rx) = mpsc::channel(128);
let rx = Arc::new(Mutex::new(rx)); let rx = Arc::new(Mutex::new(rx));
tokio::spawn(async move { tokio::spawn(async move {
let poll_interval = Duration::from_millis(config.poll_interval_ms); let poll_interval = Duration::from_millis(config.poll_interval_ms);
@@ -124,16 +131,18 @@ impl EventConsumer for SqliteEventQueue {
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct QueueRow { struct QueueRow {
id: i64, id: i64,
payload: String, payload: String,
attempts: i32, attempts: i32,
} }
async fn claim_batch( async fn claim_batch(
pool: &SqlitePool, pool: &SqlitePool,
config: &DbEventQueueConfig, config: &DbEventQueueConfig,
) -> Result<Vec<QueueRow>, DomainError> { ) -> Result<Vec<QueueRow>, DomainError> {
let mut tx = pool.begin().await let mut tx = pool
.begin()
.await
.map_err(|e| DomainError::InfrastructureError(format!("begin tx: {e}")))?; .map_err(|e| DomainError::InfrastructureError(format!("begin tx: {e}")))?;
let rows = sqlx::query_as::<_, QueueRow>( let rows = sqlx::query_as::<_, QueueRow>(
@@ -141,7 +150,7 @@ async fn claim_batch(
WHERE status = 'pending' WHERE status = 'pending'
AND next_attempt_at <= strftime('%Y-%m-%dT%H:%M:%SZ', 'now') AND next_attempt_at <= strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
ORDER BY next_attempt_at ASC ORDER BY next_attempt_at ASC
LIMIT ?" LIMIT ?",
) )
.bind(config.batch_size) .bind(config.batch_size)
.fetch_all(&mut *tx) .fetch_all(&mut *tx)
@@ -159,36 +168,43 @@ async fn claim_batch(
placeholders placeholders
); );
let mut q = sqlx::query(&sql); let mut q = sqlx::query(&sql);
for r in &rows { q = q.bind(r.id); } for r in &rows {
q.execute(&mut *tx).await q = q.bind(r.id);
}
q.execute(&mut *tx)
.await
.map_err(|e| DomainError::InfrastructureError(format!("mark processing: {e}")))?; .map_err(|e| DomainError::InfrastructureError(format!("mark processing: {e}")))?;
tx.commit().await tx.commit()
.await
.map_err(|e| DomainError::InfrastructureError(format!("commit claim: {e}")))?; .map_err(|e| DomainError::InfrastructureError(format!("commit claim: {e}")))?;
Ok(rows) Ok(rows)
} }
fn decode_row( fn decode_row(
pool: &SqlitePool, pool: &SqlitePool,
row: QueueRow, row: QueueRow,
max_attempts: i32, max_attempts: i32,
) -> Result<EventEnvelope, DomainError> { ) -> Result<EventEnvelope, DomainError> {
let db_payload: DbEventPayload = serde_json::from_str(&row.payload) let db_payload: DbEventPayload = serde_json::from_str(&row.payload)
.map_err(|e| DomainError::InfrastructureError(format!("deserialize: {e}")))?; .map_err(|e| DomainError::InfrastructureError(format!("deserialize: {e}")))?;
let event = DomainEvent::try_from(db_payload)?; let event = DomainEvent::try_from(db_payload)?;
Ok(EventEnvelope::new(event, Box::new(DbAckHandle { Ok(EventEnvelope::new(
pool: pool.clone(), event,
row_id: row.id, Box::new(DbAckHandle {
attempts: row.attempts, pool: pool.clone(),
max_attempts, row_id: row.id,
}))) attempts: row.attempts,
max_attempts,
}),
))
} }
struct DbAckHandle { struct DbAckHandle {
pool: SqlitePool, pool: SqlitePool,
row_id: i64, row_id: i64,
attempts: i32, attempts: i32,
max_attempts: i32, max_attempts: i32,
} }

View File

@@ -7,7 +7,7 @@ use activitypub::RemoteReviewRepository;
use activitypub_base::{ use activitypub_base::{
BlockedDomain, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor, BlockedDomain, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
}; };
use domain::models::{Review, ReviewSource, RemoteWatchlistEntry}; use domain::models::{RemoteWatchlistEntry, Review, ReviewSource};
use domain::ports::RemoteWatchlistRepository; use domain::ports::RemoteWatchlistRepository;
fn datetime_to_str(dt: &NaiveDateTime) -> String { fn datetime_to_str(dt: &NaiveDateTime) -> String {
@@ -178,7 +178,8 @@ impl FederationRepository for SqliteFederationRepository {
let status_str: String = row.get("status"); let status_str: String = row.get("status");
let handle: String = row.try_get("handle").unwrap_or_default(); let handle: String = row.try_get("handle").unwrap_or_default();
let inbox_url: String = row.try_get("inbox_url").unwrap_or_default(); let inbox_url: String = row.try_get("inbox_url").unwrap_or_default();
let shared_inbox_url: Option<String> = row.try_get("shared_inbox_url").ok().flatten(); let shared_inbox_url: Option<String> =
row.try_get("shared_inbox_url").ok().flatten();
let display_name: Option<String> = row.try_get("display_name").ok().flatten(); let display_name: Option<String> = row.try_get("display_name").ok().flatten();
let avatar_url: Option<String> = row.try_get("avatar_url").ok().flatten(); let avatar_url: Option<String> = row.try_get("avatar_url").ok().flatten();
Follower { Follower {
@@ -595,12 +596,11 @@ impl FederationRepository for SqliteFederationRepository {
} }
async fn is_domain_blocked(&self, domain: &str) -> Result<bool> { async fn is_domain_blocked(&self, domain: &str) -> Result<bool> {
let count: i64 = sqlx::query_scalar( let count: i64 =
"SELECT COUNT(*) FROM blocked_domains WHERE domain = ?1", sqlx::query_scalar("SELECT COUNT(*) FROM blocked_domains WHERE domain = ?1")
) .bind(domain)
.bind(domain) .fetch_one(&self.pool)
.fetch_one(&self.pool) .await?;
.await?;
Ok(count > 0) Ok(count > 0)
} }
@@ -639,7 +639,10 @@ impl FederationRepository for SqliteFederationRepository {
.bind(&uid) .bind(&uid)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await?; .await?;
Ok(rows.iter().map(|r| r.get::<String, _>("remote_actor_url")).collect()) Ok(rows
.iter()
.map(|r| r.get::<String, _>("remote_actor_url"))
.collect())
} }
async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<bool> { async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<bool> {
@@ -789,11 +792,13 @@ impl domain::ports::SocialQueryPort for SqliteFederationRepository {
Ok(rows Ok(rows
.into_iter() .into_iter()
.map(|(url, handle, display_name)| domain::ports::RemoteActorInfo { .map(
url, |(url, handle, display_name)| domain::ports::RemoteActorInfo {
handle, url,
display_name, handle,
}) display_name,
},
)
.collect()) .collect())
} }
} }
@@ -822,19 +827,24 @@ impl RemoteWatchlistRepository for SqliteFederationRepository {
Ok(()) Ok(())
} }
async fn remove_by_ap_id(&self, ap_id: &str, actor_url: &str) -> Result<(), domain::errors::DomainError> { async fn remove_by_ap_id(
sqlx::query( &self,
"DELETE FROM ap_remote_watchlist_entries WHERE ap_id = ? AND actor_url = ?", ap_id: &str,
) actor_url: &str,
.bind(ap_id) ) -> Result<(), domain::errors::DomainError> {
.bind(actor_url) sqlx::query("DELETE FROM ap_remote_watchlist_entries WHERE ap_id = ? AND actor_url = ?")
.execute(&self.pool) .bind(ap_id)
.await .bind(actor_url)
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?; .execute(&self.pool)
.await
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?;
Ok(()) Ok(())
} }
async fn get_by_actor_url(&self, actor_url: &str) -> Result<Vec<RemoteWatchlistEntry>, domain::errors::DomainError> { async fn get_by_actor_url(
&self,
actor_url: &str,
) -> Result<Vec<RemoteWatchlistEntry>, domain::errors::DomainError> {
let rows = sqlx::query( let rows = sqlx::query(
"SELECT ap_id, actor_url, movie_title, release_year, external_metadata_id, poster_url, added_at \ "SELECT ap_id, actor_url, movie_title, release_year, external_metadata_id, poster_url, added_at \
FROM ap_remote_watchlist_entries WHERE actor_url = ? ORDER BY added_at DESC", FROM ap_remote_watchlist_entries WHERE actor_url = ? ORDER BY added_at DESC",
@@ -844,24 +854,35 @@ impl RemoteWatchlistRepository for SqliteFederationRepository {
.await .await
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?; .map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?;
rows.into_iter().map(|row| { rows.into_iter()
let added_at_str: String = row.try_get("added_at").unwrap_or_default(); .map(|row| {
let added_at = chrono::NaiveDateTime::parse_from_str(&added_at_str, "%Y-%m-%d %H:%M:%S") let added_at_str: String = row.try_get("added_at").unwrap_or_default();
.map(|dt| chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(dt, chrono::Utc)) let added_at =
.unwrap_or_else(|_| chrono::Utc::now()); chrono::NaiveDateTime::parse_from_str(&added_at_str, "%Y-%m-%d %H:%M:%S")
Ok(RemoteWatchlistEntry { .map(|dt| {
ap_id: row.try_get("ap_id").unwrap_or_default(), chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(
actor_url: row.try_get("actor_url").unwrap_or_default(), dt,
movie_title: row.try_get("movie_title").unwrap_or_default(), chrono::Utc,
release_year: row.try_get::<i64, _>("release_year").unwrap_or(0) as u16, )
external_metadata_id: row.try_get("external_metadata_id").ok().flatten(), })
poster_url: row.try_get("poster_url").ok().flatten(), .unwrap_or_else(|_| chrono::Utc::now());
added_at, Ok(RemoteWatchlistEntry {
ap_id: row.try_get("ap_id").unwrap_or_default(),
actor_url: row.try_get("actor_url").unwrap_or_default(),
movie_title: row.try_get("movie_title").unwrap_or_default(),
release_year: row.try_get::<i64, _>("release_year").unwrap_or(0) as u16,
external_metadata_id: row.try_get("external_metadata_id").ok().flatten(),
poster_url: row.try_get("poster_url").ok().flatten(),
added_at,
})
}) })
}).collect() .collect()
} }
async fn remove_all_by_actor(&self, actor_url: &str) -> Result<(), domain::errors::DomainError> { async fn remove_all_by_actor(
&self,
actor_url: &str,
) -> Result<(), domain::errors::DomainError> {
sqlx::query("DELETE FROM ap_remote_watchlist_entries WHERE actor_url = ?") sqlx::query("DELETE FROM ap_remote_watchlist_entries WHERE actor_url = ?")
.bind(actor_url) .bind(actor_url)
.execute(&self.pool) .execute(&self.pool)
@@ -870,18 +891,22 @@ impl RemoteWatchlistRepository for SqliteFederationRepository {
Ok(()) Ok(())
} }
async fn get_by_derived_uuid(&self, uuid: uuid::Uuid) -> Result<Vec<RemoteWatchlistEntry>, domain::errors::DomainError> { async fn get_by_derived_uuid(
let actors: Vec<String> = sqlx::query("SELECT DISTINCT actor_url FROM ap_remote_watchlist_entries") &self,
.fetch_all(&self.pool) uuid: uuid::Uuid,
.await ) -> Result<Vec<RemoteWatchlistEntry>, domain::errors::DomainError> {
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))? let actors: Vec<String> =
.into_iter() sqlx::query("SELECT DISTINCT actor_url FROM ap_remote_watchlist_entries")
.filter_map(|row| row.try_get::<String, _>("actor_url").ok()) .fetch_all(&self.pool)
.collect(); .await
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?
.into_iter()
.filter_map(|row| row.try_get::<String, _>("actor_url").ok())
.collect();
let target = actors.into_iter().find(|url| { let target = actors
uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, url.as_bytes()) == uuid .into_iter()
}); .find(|url| uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, url.as_bytes()) == uuid);
match target { match target {
None => Ok(vec![]), None => Ok(vec![]),
@@ -890,7 +915,9 @@ impl RemoteWatchlistRepository for SqliteFederationRepository {
} }
} }
pub fn wire(pool: sqlx::SqlitePool) -> ( pub fn wire(
pool: sqlx::SqlitePool,
) -> (
std::sync::Arc<dyn activitypub::FederationRepository>, std::sync::Arc<dyn activitypub::FederationRepository>,
std::sync::Arc<dyn domain::ports::SocialQueryPort>, std::sync::Arc<dyn domain::ports::SocialQueryPort>,
std::sync::Arc<dyn activitypub::RemoteReviewRepository>, std::sync::Arc<dyn activitypub::RemoteReviewRepository>,

View File

@@ -3,14 +3,23 @@ use sqlx::SqlitePool;
async fn test_pool() -> SqlitePool { async fn test_pool() -> SqlitePool {
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
sqlx::query("CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT, password_hash TEXT, created_at TEXT)") sqlx::query(
.execute(&pool).await.unwrap(); "CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT, password_hash TEXT, created_at TEXT)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query("CREATE TABLE blocked_actors (local_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, remote_actor_url TEXT NOT NULL, blocked_at TEXT NOT NULL, PRIMARY KEY (local_user_id, remote_actor_url))") sqlx::query("CREATE TABLE blocked_actors (local_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, remote_actor_url TEXT NOT NULL, blocked_at TEXT NOT NULL, PRIMARY KEY (local_user_id, remote_actor_url))")
.execute(&pool).await.unwrap(); .execute(&pool).await.unwrap();
let uid = uuid::Uuid::new_v4().to_string(); let uid = uuid::Uuid::new_v4().to_string();
sqlx::query("INSERT INTO users (id, email, password_hash, created_at) VALUES (?, ?, ?, ?)") sqlx::query("INSERT INTO users (id, email, password_hash, created_at) VALUES (?, ?, ?, ?)")
.bind(&uid).bind("a@b.com").bind("hash").bind("2024-01-01") .bind(&uid)
.execute(&pool).await.unwrap(); .bind("a@b.com")
.bind("hash")
.bind("2024-01-01")
.execute(&pool)
.await
.unwrap();
pool pool
} }
@@ -19,8 +28,11 @@ async fn block_and_check_actor() {
let pool = test_pool().await; let pool = test_pool().await;
let user_id = uuid::Uuid::parse_str( let user_id = uuid::Uuid::parse_str(
&sqlx::query_scalar::<_, String>("SELECT id FROM users LIMIT 1") &sqlx::query_scalar::<_, String>("SELECT id FROM users LIMIT 1")
.fetch_one(&pool).await.unwrap() .fetch_one(&pool)
).unwrap(); .await
.unwrap(),
)
.unwrap();
let repo = SqliteFederationRepository::new(pool); let repo = SqliteFederationRepository::new(pool);
let actor_url = "https://mastodon.social/users/alice"; let actor_url = "https://mastodon.social/users/alice";
assert!(!repo.is_actor_blocked(user_id, actor_url).await.unwrap()); assert!(!repo.is_actor_blocked(user_id, actor_url).await.unwrap());

View File

@@ -13,7 +13,9 @@ async fn blocked_domain_is_detected() {
let pool = test_pool().await; let pool = test_pool().await;
let repo = SqliteFederationRepository::new(pool); let repo = SqliteFederationRepository::new(pool);
assert!(!repo.is_domain_blocked("mastodon.social").await.unwrap()); assert!(!repo.is_domain_blocked("mastodon.social").await.unwrap());
repo.add_blocked_domain("mastodon.social", Some("spam")).await.unwrap(); repo.add_blocked_domain("mastodon.social", Some("spam"))
.await
.unwrap();
assert!(repo.is_domain_blocked("mastodon.social").await.unwrap()); assert!(repo.is_domain_blocked("mastodon.social").await.unwrap());
} }
@@ -30,7 +32,9 @@ async fn remove_unblocks_domain() {
async fn get_blocked_domains_returns_all() { async fn get_blocked_domains_returns_all() {
let pool = test_pool().await; let pool = test_pool().await;
let repo = SqliteFederationRepository::new(pool); let repo = SqliteFederationRepository::new(pool);
repo.add_blocked_domain("a.com", Some("reason a")).await.unwrap(); repo.add_blocked_domain("a.com", Some("reason a"))
.await
.unwrap();
repo.add_blocked_domain("b.com", None).await.unwrap(); repo.add_blocked_domain("b.com", None).await.unwrap();
let domains = repo.get_blocked_domains().await.unwrap(); let domains = repo.get_blocked_domains().await.unwrap();
assert_eq!(domains.len(), 2); assert_eq!(domains.len(), 2);

View File

@@ -14,7 +14,14 @@ async fn test_pool() -> SqlitePool {
async fn add_announce_stores_and_counts() { async fn add_announce_stores_and_counts() {
let pool = test_pool().await; let pool = test_pool().await;
let repo = SqliteFederationRepository::new(pool); let repo = SqliteFederationRepository::new(pool);
repo.add_announce("https://remote/ann/1", "https://local/r/1", "https://remote/u/1", Utc::now()).await.unwrap(); repo.add_announce(
"https://remote/ann/1",
"https://local/r/1",
"https://remote/u/1",
Utc::now(),
)
.await
.unwrap();
assert_eq!(repo.count_announces("https://local/r/1").await.unwrap(), 1); assert_eq!(repo.count_announces("https://local/r/1").await.unwrap(), 1);
} }
@@ -22,8 +29,22 @@ async fn add_announce_stores_and_counts() {
async fn duplicate_announce_is_ignored() { async fn duplicate_announce_is_ignored() {
let pool = test_pool().await; let pool = test_pool().await;
let repo = SqliteFederationRepository::new(pool); let repo = SqliteFederationRepository::new(pool);
repo.add_announce("https://remote/ann/1", "https://local/r/1", "https://remote/u/1", Utc::now()).await.unwrap(); repo.add_announce(
repo.add_announce("https://remote/ann/1", "https://local/r/1", "https://remote/u/1", Utc::now()).await.unwrap(); "https://remote/ann/1",
"https://local/r/1",
"https://remote/u/1",
Utc::now(),
)
.await
.unwrap();
repo.add_announce(
"https://remote/ann/1",
"https://local/r/1",
"https://remote/u/1",
Utc::now(),
)
.await
.unwrap();
assert_eq!(repo.count_announces("https://local/r/1").await.unwrap(), 1); assert_eq!(repo.count_announces("https://local/r/1").await.unwrap(), 1);
} }

View File

@@ -3,14 +3,13 @@ use std::sync::Arc;
use async_trait::async_trait; use async_trait::async_trait;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::{
EntityType, IndexableDocument, MovieSearchHit, PersonSearchHit,
SearchQuery, SearchResults,
collections::Paginated,
},
models::PersonId, models::PersonId,
value_objects::MovieId, models::{
collections::Paginated, EntityType, IndexableDocument, MovieSearchHit, PersonSearchHit,
SearchQuery, SearchResults,
},
ports::{SearchCommand, SearchPort}, ports::{SearchCommand, SearchPort},
value_objects::MovieId,
}; };
use sqlx::SqlitePool; use sqlx::SqlitePool;
@@ -26,7 +25,10 @@ impl SqliteSearchAdapter {
pub fn create_search_adapter(pool: SqlitePool) -> (Arc<dyn SearchCommand>, Arc<dyn SearchPort>) { pub fn create_search_adapter(pool: SqlitePool) -> (Arc<dyn SearchCommand>, Arc<dyn SearchPort>) {
let adapter = Arc::new(SqliteSearchAdapter::new(pool)); let adapter = Arc::new(SqliteSearchAdapter::new(pool));
(Arc::clone(&adapter) as Arc<dyn SearchCommand>, adapter as Arc<dyn SearchPort>) (
Arc::clone(&adapter) as Arc<dyn SearchCommand>,
adapter as Arc<dyn SearchPort>,
)
} }
fn map_err(e: sqlx::Error) -> DomainError { fn map_err(e: sqlx::Error) -> DomainError {
@@ -46,13 +48,36 @@ impl SearchCommand for SqliteSearchAdapter {
match profile.as_deref() { match profile.as_deref() {
Some(p) => ( Some(p) => (
p.overview.clone().unwrap_or_default(), p.overview.clone().unwrap_or_default(),
p.genres.iter().map(|g| g.name.as_str()).collect::<Vec<_>>().join(" "), p.genres
p.keywords.iter().map(|k| k.name.as_str()).collect::<Vec<_>>().join(" "), .iter()
p.cast.iter().map(|c| c.name.as_str()).collect::<Vec<_>>().join(" "), .map(|g| g.name.as_str())
p.crew.iter().map(|c| c.name.as_str()).collect::<Vec<_>>().join(" "), .collect::<Vec<_>>()
.join(" "),
p.keywords
.iter()
.map(|k| k.name.as_str())
.collect::<Vec<_>>()
.join(" "),
p.cast
.iter()
.map(|c| c.name.as_str())
.collect::<Vec<_>>()
.join(" "),
p.crew
.iter()
.map(|c| c.name.as_str())
.collect::<Vec<_>>()
.join(" "),
p.original_language.clone().unwrap_or_default(), p.original_language.clone().unwrap_or_default(),
), ),
None => (String::new(), String::new(), String::new(), String::new(), String::new(), String::new()), None => (
String::new(),
String::new(),
String::new(),
String::new(),
String::new(),
String::new(),
),
}; };
sqlx::query( sqlx::query(
@@ -145,7 +170,10 @@ impl SearchPort for SqliteSearchAdapter {
} }
impl SqliteSearchAdapter { impl SqliteSearchAdapter {
async fn search_movies(&self, query: &SearchQuery) -> Result<Paginated<MovieSearchHit>, DomainError> { async fn search_movies(
&self,
query: &SearchQuery,
) -> Result<Paginated<MovieSearchHit>, DomainError> {
let limit = query.page.limit as i64; let limit = query.page.limit as i64;
let offset = query.page.offset as i64; let offset = query.page.offset as i64;
@@ -244,24 +272,36 @@ impl SqliteSearchAdapter {
.await .await
.map_err(map_err)? .map_err(map_err)?
}; };
let items = rows.into_iter().map(|r| MovieSearchHit { let items = rows
movie_id: MovieId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()), .into_iter()
title: r.title, .map(|r| MovieSearchHit {
release_year: r.release_year.map(|y| y as u16), movie_id: MovieId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()),
director: r.director, title: r.title,
poster_path: r.poster_path, release_year: r.release_year.map(|y| y as u16),
genres: r.genres director: r.director,
.unwrap_or_default() poster_path: r.poster_path,
.split(',') genres: r
.filter(|s| !s.is_empty()) .genres
.map(str::to_string) .unwrap_or_default()
.collect(), .split(',')
}).collect::<Vec<_>>(); .filter(|s| !s.is_empty())
.map(str::to_string)
.collect(),
})
.collect::<Vec<_>>();
Ok(Paginated { items, total_count: total, limit: query.page.limit, offset: query.page.offset }) Ok(Paginated {
items,
total_count: total,
limit: query.page.limit,
offset: query.page.offset,
})
} }
async fn search_people(&self, query: &SearchQuery) -> Result<Paginated<PersonSearchHit>, DomainError> { async fn search_people(
&self,
query: &SearchQuery,
) -> Result<Paginated<PersonSearchHit>, DomainError> {
let Some(text) = &query.text else { let Some(text) = &query.text else {
return Ok(Paginated { return Ok(Paginated {
items: vec![], items: vec![],
@@ -276,13 +316,12 @@ impl SqliteSearchAdapter {
let fts_query = format!("{}*", text.replace(['"', '*'], "")); let fts_query = format!("{}*", text.replace(['"', '*'], ""));
let total: u64 = { let total: u64 = {
let count: i64 = sqlx::query_scalar( let count: i64 =
"SELECT COUNT(*) FROM people_fts WHERE people_fts MATCH ?", sqlx::query_scalar("SELECT COUNT(*) FROM people_fts WHERE people_fts MATCH ?")
) .bind(&fts_query)
.bind(&fts_query) .fetch_one(&self.pool)
.fetch_one(&self.pool) .await
.await .map_err(map_err)?;
.map_err(map_err)?;
count as u64 count as u64
}; };
@@ -311,14 +350,13 @@ impl SqliteSearchAdapter {
let mut items = Vec::with_capacity(rows.len()); let mut items = Vec::with_capacity(rows.len());
for row in rows { for row in rows {
let tmdb_id: Option<i64> = sqlx::query_scalar( let tmdb_id: Option<i64> =
"SELECT tmdb_person_id FROM persons WHERE id = ?", sqlx::query_scalar("SELECT tmdb_person_id FROM persons WHERE id = ?")
) .bind(&row.person_id)
.bind(&row.person_id) .fetch_optional(&self.pool)
.fetch_optional(&self.pool) .await
.await .map_err(map_err)?
.map_err(map_err)? .flatten();
.flatten();
let known_for_titles = if let Some(tid) = tmdb_id { let known_for_titles = if let Some(tid) = tmdb_id {
sqlx::query_scalar::<_, String>( sqlx::query_scalar::<_, String>(
@@ -338,7 +376,7 @@ impl SqliteSearchAdapter {
items.push(PersonSearchHit { items.push(PersonSearchHit {
person_id: PersonId::from_uuid( person_id: PersonId::from_uuid(
uuid::Uuid::parse_str(&row.person_id).unwrap_or_default() uuid::Uuid::parse_str(&row.person_id).unwrap_or_default(),
), ),
name: row.name, name: row.name,
known_for_department: row.known_for_department, known_for_department: row.known_for_department,
@@ -347,7 +385,12 @@ impl SqliteSearchAdapter {
}); });
} }
Ok(Paginated { items, total_count: total, limit: query.page.limit, offset: query.page.offset }) Ok(Paginated {
items,
total_count: total,
limit: query.page.limit,
offset: query.page.offset,
})
} }
} }

View File

@@ -1,13 +1,11 @@
use super::{SqliteSearchAdapter, create_search_adapter}; use super::{create_search_adapter, SqliteSearchAdapter};
use domain::{ use domain::{
models::{ models::{
EntityType, IndexableDocument, Movie, collections::PageParams, EntityType, ExternalPersonId, IndexableDocument, Movie, Person,
Person, PersonId, SearchFilters, SearchQuery, PersonId, SearchFilters, SearchQuery,
ExternalPersonId,
collections::PageParams,
}, },
value_objects::{MovieId, MovieTitle, ReleaseYear},
ports::{SearchCommand, SearchPort}, ports::{SearchCommand, SearchPort},
value_objects::{MovieId, MovieTitle, ReleaseYear},
}; };
use sqlx::SqlitePool; use sqlx::SqlitePool;
@@ -17,33 +15,43 @@ async fn pool_with_schema() -> SqlitePool {
"CREATE TABLE movies (id TEXT PRIMARY KEY, title TEXT NOT NULL, "CREATE TABLE movies (id TEXT PRIMARY KEY, title TEXT NOT NULL,
release_year INTEGER, director TEXT, poster_path TEXT, external_metadata_id TEXT)", release_year INTEGER, director TEXT, poster_path TEXT, external_metadata_id TEXT)",
) )
.execute(&pool).await.unwrap(); .execute(&pool)
.await
.unwrap();
sqlx::query( sqlx::query(
"CREATE TABLE persons (id TEXT PRIMARY KEY, external_id TEXT UNIQUE, "CREATE TABLE persons (id TEXT PRIMARY KEY, external_id TEXT UNIQUE,
tmdb_person_id INTEGER UNIQUE, name TEXT NOT NULL, tmdb_person_id INTEGER UNIQUE, name TEXT NOT NULL,
known_for_department TEXT, profile_path TEXT)", known_for_department TEXT, profile_path TEXT)",
) )
.execute(&pool).await.unwrap(); .execute(&pool)
.await
.unwrap();
sqlx::query( sqlx::query(
"CREATE TABLE movie_cast (movie_id TEXT, tmdb_person_id INTEGER, "CREATE TABLE movie_cast (movie_id TEXT, tmdb_person_id INTEGER,
name TEXT, character TEXT, billing_order INTEGER, profile_path TEXT)", name TEXT, character TEXT, billing_order INTEGER, profile_path TEXT)",
) )
.execute(&pool).await.unwrap(); .execute(&pool)
sqlx::query( .await
"CREATE TABLE movie_genres (movie_id TEXT, tmdb_id INTEGER, name TEXT)", .unwrap();
) sqlx::query("CREATE TABLE movie_genres (movie_id TEXT, tmdb_id INTEGER, name TEXT)")
.execute(&pool).await.unwrap(); .execute(&pool)
.await
.unwrap();
sqlx::query( sqlx::query(
"CREATE VIRTUAL TABLE movies_fts USING fts5( "CREATE VIRTUAL TABLE movies_fts USING fts5(
movie_id UNINDEXED, title, director, overview, genres, keywords, movie_id UNINDEXED, title, director, overview, genres, keywords,
cast_names, crew_names, release_year UNINDEXED, language UNINDEXED)", cast_names, crew_names, release_year UNINDEXED, language UNINDEXED)",
) )
.execute(&pool).await.unwrap(); .execute(&pool)
.await
.unwrap();
sqlx::query( sqlx::query(
"CREATE VIRTUAL TABLE people_fts USING fts5( "CREATE VIRTUAL TABLE people_fts USING fts5(
person_id UNINDEXED, name, known_for_department UNINDEXED)", person_id UNINDEXED, name, known_for_department UNINDEXED)",
) )
.execute(&pool).await.unwrap(); .execute(&pool)
.await
.unwrap();
pool pool
} }
@@ -72,18 +80,32 @@ async fn index_and_search_movie_by_title() {
let movie_id = movie.id().clone(); let movie_id = movie.id().clone();
sqlx::query("INSERT INTO movies VALUES (?, ?, ?, ?, ?, ?)") sqlx::query("INSERT INTO movies VALUES (?, ?, ?, ?, ?, ?)")
.bind(id_str).bind("Interstellar").bind(2014i32) .bind(id_str)
.bind("Christopher Nolan").bind::<Option<String>>(None).bind::<Option<String>>(None) .bind("Interstellar")
.execute(&pool).await.unwrap(); .bind(2014i32)
.bind("Christopher Nolan")
.bind::<Option<String>>(None)
.bind::<Option<String>>(None)
.execute(&pool)
.await
.unwrap();
cmd.index(IndexableDocument::Movie { id: movie_id.clone(), movie: Box::new(movie), profile: None }) cmd.index(IndexableDocument::Movie {
.await.unwrap(); id: movie_id.clone(),
movie: Box::new(movie),
profile: None,
})
.await
.unwrap();
let results = query.search(&SearchQuery { let results = query
text: Some("Interstellar".to_string()), .search(&SearchQuery {
filters: SearchFilters::default(), text: Some("Interstellar".to_string()),
page: default_page(), filters: SearchFilters::default(),
}).await.unwrap(); page: default_page(),
})
.await
.unwrap();
assert_eq!(results.movies.items.len(), 1); assert_eq!(results.movies.items.len(), 1);
assert_eq!(results.movies.items[0].title, "Interstellar"); assert_eq!(results.movies.items[0].title, "Interstellar");
@@ -99,19 +121,33 @@ async fn remove_movie_clears_from_index() {
let movie_id = movie.id().clone(); let movie_id = movie.id().clone();
sqlx::query("INSERT INTO movies VALUES (?, ?, ?, ?, ?, ?)") sqlx::query("INSERT INTO movies VALUES (?, ?, ?, ?, ?, ?)")
.bind(id_str).bind("Inception").bind(2010i32) .bind(id_str)
.bind("Christopher Nolan").bind::<Option<String>>(None).bind::<Option<String>>(None) .bind("Inception")
.execute(&pool).await.unwrap(); .bind(2010i32)
.bind("Christopher Nolan")
.bind::<Option<String>>(None)
.bind::<Option<String>>(None)
.execute(&pool)
.await
.unwrap();
cmd.index(IndexableDocument::Movie { id: movie_id.clone(), movie: Box::new(movie), profile: None }) cmd.index(IndexableDocument::Movie {
.await.unwrap(); id: movie_id.clone(),
movie: Box::new(movie),
profile: None,
})
.await
.unwrap();
cmd.remove(EntityType::Movie, id_str).await.unwrap(); cmd.remove(EntityType::Movie, id_str).await.unwrap();
let results = query.search(&SearchQuery { let results = query
text: Some("Inception".to_string()), .search(&SearchQuery {
filters: SearchFilters::default(), text: Some("Inception".to_string()),
page: default_page(), filters: SearchFilters::default(),
}).await.unwrap(); page: default_page(),
})
.await
.unwrap();
assert!(results.movies.items.is_empty()); assert!(results.movies.items.is_empty());
} }
@@ -126,32 +162,54 @@ async fn search_with_genre_filter() {
let movie_id = movie.id().clone(); let movie_id = movie.id().clone();
sqlx::query("INSERT INTO movies VALUES (?, ?, ?, ?, ?, ?)") sqlx::query("INSERT INTO movies VALUES (?, ?, ?, ?, ?, ?)")
.bind(id_str).bind("The Dark Knight").bind(2008i32) .bind(id_str)
.bind("Christopher Nolan").bind::<Option<String>>(None).bind::<Option<String>>(None) .bind("The Dark Knight")
.execute(&pool).await.unwrap(); .bind(2008i32)
.bind("Christopher Nolan")
.bind::<Option<String>>(None)
.bind::<Option<String>>(None)
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO movie_genres VALUES (?, 1, 'Action')") sqlx::query("INSERT INTO movie_genres VALUES (?, 1, 'Action')")
.bind(id_str) .bind(id_str)
.execute(&pool).await.unwrap(); .execute(&pool)
.await
.unwrap();
cmd.index(IndexableDocument::Movie { cmd.index(IndexableDocument::Movie {
id: movie_id.clone(), id: movie_id.clone(),
movie: Box::new(movie), movie: Box::new(movie),
profile: None, profile: None,
}).await.unwrap(); })
.await
.unwrap();
// Matching genre — no text filter // Matching genre — no text filter
let results = query.search(&SearchQuery { let results = query
text: None, .search(&SearchQuery {
filters: SearchFilters { genre: Some("Action".to_string()), ..Default::default() }, text: None,
page: default_page(), filters: SearchFilters {
}).await.unwrap(); genre: Some("Action".to_string()),
..Default::default()
},
page: default_page(),
})
.await
.unwrap();
assert_eq!(results.movies.items.len(), 1); assert_eq!(results.movies.items.len(), 1);
// Non-matching genre // Non-matching genre
let results = query.search(&SearchQuery { let results = query
text: None, .search(&SearchQuery {
filters: SearchFilters { genre: Some("Comedy".to_string()), ..Default::default() }, text: None,
page: default_page(), filters: SearchFilters {
}).await.unwrap(); genre: Some("Comedy".to_string()),
..Default::default()
},
page: default_page(),
})
.await
.unwrap();
assert!(results.movies.items.is_empty()); assert!(results.movies.items.is_empty());
} }

View File

@@ -1,5 +1,8 @@
use async_trait::async_trait; use async_trait::async_trait;
use domain::{errors::DomainError, ports::{ImageRefCommand, ImageRefQuery}}; use domain::{
errors::DomainError,
ports::{ImageRefCommand, ImageRefQuery},
};
use sqlx::SqlitePool; use sqlx::SqlitePool;
use std::sync::Arc; use std::sync::Arc;
@@ -15,23 +18,34 @@ impl SqliteImageRefAdapter {
pub fn create_image_ref(pool: SqlitePool) -> (Arc<dyn ImageRefCommand>, Arc<dyn ImageRefQuery>) { pub fn create_image_ref(pool: SqlitePool) -> (Arc<dyn ImageRefCommand>, Arc<dyn ImageRefQuery>) {
let adapter = Arc::new(SqliteImageRefAdapter::new(pool)); let adapter = Arc::new(SqliteImageRefAdapter::new(pool));
(Arc::clone(&adapter) as Arc<dyn ImageRefCommand>, adapter as Arc<dyn ImageRefQuery>) (
Arc::clone(&adapter) as Arc<dyn ImageRefCommand>,
adapter as Arc<dyn ImageRefQuery>,
)
} }
#[async_trait] #[async_trait]
impl ImageRefCommand for SqliteImageRefAdapter { impl ImageRefCommand for SqliteImageRefAdapter {
async fn swap(&self, old_key: &str, new_key: &str) -> Result<(), DomainError> { async fn swap(&self, old_key: &str, new_key: &str) -> Result<(), DomainError> {
let mut tx = self.pool.begin().await let mut tx = self
.pool
.begin()
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?; .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
sqlx::query("UPDATE users SET avatar_path = ? WHERE avatar_path = ?") sqlx::query("UPDATE users SET avatar_path = ? WHERE avatar_path = ?")
.bind(new_key).bind(old_key) .bind(new_key)
.execute(&mut *tx).await .bind(old_key)
.execute(&mut *tx)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?; .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
sqlx::query("UPDATE movies SET poster_path = ? WHERE poster_path = ?") sqlx::query("UPDATE movies SET poster_path = ? WHERE poster_path = ?")
.bind(new_key).bind(old_key) .bind(new_key)
.execute(&mut *tx).await .bind(old_key)
.execute(&mut *tx)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?; .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
tx.commit().await tx.commit()
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string())) .map_err(|e| DomainError::InfrastructureError(e.to_string()))
} }
} }

View File

@@ -14,12 +14,20 @@ use sqlx::SqlitePool;
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
enum DomainFieldJson { enum DomainFieldJson {
Title, ReleaseYear, Director, Rating, WatchedAt, Comment, ExternalMetadataId, Title,
ReleaseYear,
Director,
Rating,
WatchedAt,
Comment,
ExternalMetadataId,
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
enum TransformJson { enum TransformJson {
RatingScale(f64), DateFormat(String), Identity, RatingScale(f64),
DateFormat(String),
Identity,
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
@@ -75,8 +83,8 @@ fn serialize_mappings(ms: &[FieldMapping]) -> Result<String, DomainError> {
} }
fn deserialize_mappings(s: &str) -> Result<Vec<FieldMapping>, DomainError> { fn deserialize_mappings(s: &str) -> Result<Vec<FieldMapping>, DomainError> {
let js: Vec<FieldMappingJson> = serde_json::from_str(s) let js: Vec<FieldMappingJson> =
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?; serde_json::from_str(s).map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(js.into_iter().map(mapping_from_json).collect()) Ok(js.into_iter().map(mapping_from_json).collect())
} }
@@ -85,7 +93,9 @@ pub struct SqliteImportProfileRepository {
} }
impl SqliteImportProfileRepository { impl SqliteImportProfileRepository {
pub fn new(pool: SqlitePool) -> Self { Self { pool } } pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
fn map_err(e: sqlx::Error) -> DomainError { fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("DB error: {:?}", e); tracing::error!("DB error: {:?}", e);
@@ -95,7 +105,9 @@ impl SqliteImportProfileRepository {
fn parse_dt(s: &str) -> Result<NaiveDateTime, DomainError> { fn parse_dt(s: &str) -> Result<NaiveDateTime, DomainError> {
NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S")
.or_else(|_| NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S")) .or_else(|_| NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S"))
.map_err(|e| DomainError::InfrastructureError(format!("invalid datetime '{}': {}", s, e))) .map_err(|e| {
DomainError::InfrastructureError(format!("invalid datetime '{}': {}", s, e))
})
} }
} }
@@ -109,7 +121,11 @@ impl ImportProfileRepository for SqliteImportProfileRepository {
sqlx::query!( sqlx::query!(
"INSERT OR REPLACE INTO import_profiles (id, user_id, name, field_mappings, created_at) "INSERT OR REPLACE INTO import_profiles (id, user_id, name, field_mappings, created_at)
VALUES (?, ?, ?, ?, ?)", VALUES (?, ?, ?, ?, ?)",
id, user_id, p.name, field_mappings, created_at id,
user_id,
p.name,
field_mappings,
created_at
) )
.execute(&self.pool) .execute(&self.pool)
.await .await
@@ -127,18 +143,31 @@ impl ImportProfileRepository for SqliteImportProfileRepository {
.await .await
.map_err(Self::map_err)?; .map_err(Self::map_err)?;
rows.into_iter().map(|r| { rows.into_iter()
Ok(ImportProfile { .map(|r| {
id: ImportProfileId::from_uuid(r.id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))?), Ok(ImportProfile {
user_id: UserId::from_uuid(r.user_id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))?), id: ImportProfileId::from_uuid(
name: r.name, r.id.parse::<uuid::Uuid>()
field_mappings: deserialize_mappings(&r.field_mappings)?, .map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
created_at: Self::parse_dt(&r.created_at)?, ),
user_id: UserId::from_uuid(
r.user_id
.parse::<uuid::Uuid>()
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
),
name: r.name,
field_mappings: deserialize_mappings(&r.field_mappings)?,
created_at: Self::parse_dt(&r.created_at)?,
})
}) })
}).collect() .collect()
} }
async fn get(&self, id: &ImportProfileId, user_id: &UserId) -> Result<Option<ImportProfile>, DomainError> { async fn get(
&self,
id: &ImportProfileId,
user_id: &UserId,
) -> Result<Option<ImportProfile>, DomainError> {
let id_str = id.value().to_string(); let id_str = id.value().to_string();
let uid_str = user_id.value().to_string(); let uid_str = user_id.value().to_string();
let row = sqlx::query!( let row = sqlx::query!(
@@ -151,13 +180,21 @@ impl ImportProfileRepository for SqliteImportProfileRepository {
row.map(|r| { row.map(|r| {
Ok(ImportProfile { Ok(ImportProfile {
id: ImportProfileId::from_uuid(r.id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))?), id: ImportProfileId::from_uuid(
user_id: UserId::from_uuid(r.user_id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))?), r.id.parse::<uuid::Uuid>()
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
),
user_id: UserId::from_uuid(
r.user_id
.parse::<uuid::Uuid>()
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
),
name: r.name, name: r.name,
field_mappings: deserialize_mappings(&r.field_mappings)?, field_mappings: deserialize_mappings(&r.field_mappings)?,
created_at: Self::parse_dt(&r.created_at)?, created_at: Self::parse_dt(&r.created_at)?,
}) })
}).transpose() })
.transpose()
} }
async fn delete(&self, id: &ImportProfileId) -> Result<(), DomainError> { async fn delete(&self, id: &ImportProfileId) -> Result<(), DomainError> {

View File

@@ -22,7 +22,13 @@ struct ParsedFileJson {
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
enum DomainFieldJson { enum DomainFieldJson {
Title, ReleaseYear, Director, Rating, WatchedAt, Comment, ExternalMetadataId, Title,
ReleaseYear,
Director,
Rating,
WatchedAt,
Comment,
ExternalMetadataId,
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
@@ -41,19 +47,29 @@ struct FieldMappingJson {
#[derive(Serialize, Deserialize, Default)] #[derive(Serialize, Deserialize, Default)]
struct ImportRowJson { struct ImportRowJson {
#[serde(skip_serializing_if = "Option::is_none")] title: Option<String>, #[serde(skip_serializing_if = "Option::is_none")]
#[serde(skip_serializing_if = "Option::is_none")] release_year: Option<String>, title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] director: Option<String>, #[serde(skip_serializing_if = "Option::is_none")]
#[serde(skip_serializing_if = "Option::is_none")] rating: Option<String>, release_year: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] watched_at: Option<String>, #[serde(skip_serializing_if = "Option::is_none")]
#[serde(skip_serializing_if = "Option::is_none")] comment: Option<String>, director: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] external_metadata_id: Option<String>, #[serde(skip_serializing_if = "Option::is_none")]
rating: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
watched_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
comment: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
external_metadata_id: Option<String>,
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
enum RowResultJson { enum RowResultJson {
Valid(ImportRowJson), Valid(ImportRowJson),
Invalid { errors: Vec<String>, raw: Vec<(String, String)> }, Invalid {
errors: Vec<String>,
raw: Vec<(String, String)>,
},
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
@@ -182,7 +198,9 @@ pub struct SqliteImportSessionRepository {
} }
impl SqliteImportSessionRepository { impl SqliteImportSessionRepository {
pub fn new(pool: SqlitePool) -> Self { Self { pool } } pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
fn map_err(e: sqlx::Error) -> DomainError { fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("DB error: {:?}", e); tracing::error!("DB error: {:?}", e);
@@ -192,18 +210,33 @@ impl SqliteImportSessionRepository {
fn parse_dt(s: &str) -> Result<NaiveDateTime, DomainError> { fn parse_dt(s: &str) -> Result<NaiveDateTime, DomainError> {
NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S")
.or_else(|_| NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S")) .or_else(|_| NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S"))
.map_err(|e| DomainError::InfrastructureError(format!("invalid datetime '{}': {}", s, e))) .map_err(|e| {
DomainError::InfrastructureError(format!("invalid datetime '{}': {}", s, e))
})
} }
fn serialize_session(s: &ImportSession) -> Result<(String, Option<String>, Option<String>), DomainError> { fn serialize_session(
let parsed = s.parsed_file.as_ref() s: &ImportSession,
.map(|f| ser(&ParsedFileJson { columns: f.columns.clone(), rows: f.rows.clone() })) ) -> Result<(String, Option<String>, Option<String>), DomainError> {
let parsed = s
.parsed_file
.as_ref()
.map(|f| {
ser(&ParsedFileJson {
columns: f.columns.clone(),
rows: f.rows.clone(),
})
})
.transpose()? .transpose()?
.unwrap_or_default(); .unwrap_or_default();
let mappings = s.field_mappings.as_ref() let mappings = s
.field_mappings
.as_ref()
.map(|ms| ser(&ms.iter().map(mapping_to_json).collect::<Vec<_>>())) .map(|ms| ser(&ms.iter().map(mapping_to_json).collect::<Vec<_>>()))
.transpose()?; .transpose()?;
let results = s.row_results.as_ref() let results = s
.row_results
.as_ref()
.map(|rs| ser(&rs.iter().map(annotated_to_json).collect::<Vec<_>>())) .map(|rs| ser(&rs.iter().map(annotated_to_json).collect::<Vec<_>>()))
.transpose()?; .transpose()?;
Ok((parsed, mappings, results)) Ok((parsed, mappings, results))
@@ -222,15 +255,20 @@ impl SqliteImportSessionRepository {
None None
} else { } else {
let j: ParsedFileJson = de(&parsed_data)?; let j: ParsedFileJson = de(&parsed_data)?;
Some(ParsedFile { columns: j.columns, rows: j.rows }) Some(ParsedFile {
columns: j.columns,
rows: j.rows,
})
}; };
let field_mappings = field_mappings.as_deref() let field_mappings = field_mappings
.as_deref()
.map(|s| -> Result<Vec<FieldMapping>, DomainError> { .map(|s| -> Result<Vec<FieldMapping>, DomainError> {
let js: Vec<FieldMappingJson> = de(s)?; let js: Vec<FieldMappingJson> = de(s)?;
Ok(js.into_iter().map(mapping_from_json).collect()) Ok(js.into_iter().map(mapping_from_json).collect())
}) })
.transpose()?; .transpose()?;
let row_results = row_results.as_deref() let row_results = row_results
.as_deref()
.map(|s| -> Result<Vec<AnnotatedRow>, DomainError> { .map(|s| -> Result<Vec<AnnotatedRow>, DomainError> {
let js: Vec<AnnotatedRowJson> = de(s)?; let js: Vec<AnnotatedRowJson> = de(s)?;
Ok(js.into_iter().map(annotated_from_json).collect()) Ok(js.into_iter().map(annotated_from_json).collect())
@@ -239,10 +277,13 @@ impl SqliteImportSessionRepository {
Ok(ImportSession { Ok(ImportSession {
id: ImportSessionId::from_uuid( id: ImportSessionId::from_uuid(
id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))? id.parse::<uuid::Uuid>()
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
), ),
user_id: UserId::from_uuid( user_id: UserId::from_uuid(
user_id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))? user_id
.parse::<uuid::Uuid>()
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
), ),
parsed_file, parsed_file,
field_mappings, field_mappings,
@@ -272,22 +313,35 @@ impl ImportSessionRepository for SqliteImportSessionRepository {
.map_err(Self::map_err) .map_err(Self::map_err)
} }
async fn get(&self, id: &ImportSessionId, user_id: &UserId) -> Result<Option<ImportSession>, DomainError> { async fn get(
&self,
id: &ImportSessionId,
user_id: &UserId,
) -> Result<Option<ImportSession>, DomainError> {
let id_str = id.value().to_string(); let id_str = id.value().to_string();
let uid_str = user_id.value().to_string(); let uid_str = user_id.value().to_string();
let row = sqlx::query!( let row = sqlx::query!(
"SELECT id, user_id, parsed_data, field_mappings, row_results, created_at, expires_at "SELECT id, user_id, parsed_data, field_mappings, row_results, created_at, expires_at
FROM import_sessions WHERE id = ? AND user_id = ?", FROM import_sessions WHERE id = ? AND user_id = ?",
id_str, uid_str id_str,
uid_str
) )
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(Self::map_err)?;
row.map(|r| Self::deserialize_session( row.map(|r| {
r.id, r.user_id, r.parsed_data, r.field_mappings, r.row_results, Self::deserialize_session(
&r.created_at, &r.expires_at, r.id,
)).transpose() r.user_id,
r.parsed_data,
r.field_mappings,
r.row_results,
&r.created_at,
&r.expires_at,
)
})
.transpose()
} }
async fn update(&self, s: &ImportSession) -> Result<(), DomainError> { async fn update(&self, s: &ImportSession) -> Result<(), DomainError> {
@@ -295,7 +349,9 @@ impl ImportSessionRepository for SqliteImportSessionRepository {
let (_, field_mappings, row_results) = Self::serialize_session(s)?; let (_, field_mappings, row_results) = Self::serialize_session(s)?;
sqlx::query!( sqlx::query!(
"UPDATE import_sessions SET field_mappings = ?, row_results = ? WHERE id = ?", "UPDATE import_sessions SET field_mappings = ?, row_results = ? WHERE id = ?",
field_mappings, row_results, id field_mappings,
row_results,
id
) )
.execute(&self.pool) .execute(&self.pool)
.await .await
@@ -322,10 +378,13 @@ impl ImportSessionRepository for SqliteImportSessionRepository {
async fn delete_expired_for_user(&self, user_id: &UserId) -> Result<(), DomainError> { async fn delete_expired_for_user(&self, user_id: &UserId) -> Result<(), DomainError> {
let uid = user_id.value().to_string(); let uid = user_id.value().to_string();
sqlx::query!("DELETE FROM import_sessions WHERE user_id = ? AND expires_at < datetime('now')", uid) sqlx::query!(
.execute(&self.pool) "DELETE FROM import_sessions WHERE user_id = ? AND expires_at < datetime('now')",
.await uid
.map(|_| ()) )
.map_err(Self::map_err) .execute(&self.pool)
.await
.map(|_| ())
.map_err(Self::map_err)
} }
} }

View File

@@ -402,11 +402,15 @@ impl MovieRepository for SqliteMovieRepository {
&self, &self,
page: &domain::models::collections::PageParams, page: &domain::models::collections::PageParams,
filter: &domain::models::MovieFilter, filter: &domain::models::MovieFilter,
) -> Result<domain::models::collections::Paginated<domain::models::MovieSummary>, DomainError> { ) -> Result<domain::models::collections::Paginated<domain::models::MovieSummary>, DomainError>
{
use sqlx::Row; use sqlx::Row;
let limit = page.limit as i64; let limit = page.limit as i64;
let offset = page.offset as i64; let offset = page.offset as i64;
let pattern = filter.search.as_deref().map(|s| format!("%{}%", s.to_lowercase())); let pattern = filter
.search
.as_deref()
.map(|s| format!("%{}%", s.to_lowercase()));
let genre = filter.genre.as_deref(); let genre = filter.genre.as_deref();
let language = filter.language.as_deref(); let language = filter.language.as_deref();
@@ -694,10 +698,7 @@ impl DiaryRepository for SqliteMovieRepository {
} }
let count_q = bind_filter_params!(sqlx::query_scalar::<_, i64>(&count_sql)); let count_q = bind_filter_params!(sqlx::query_scalar::<_, i64>(&count_sql));
let total = count_q let total = count_q.fetch_one(&self.pool).await.map_err(Self::map_err)?;
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)?;
let rows_q = bind_filter_params!(sqlx::query_as::<_, FeedRow>(&select_sql)); let rows_q = bind_filter_params!(sqlx::query_as::<_, FeedRow>(&select_sql));
let rows = rows_q let rows = rows_q
@@ -800,13 +801,10 @@ impl DiaryRepository for SqliteMovieRepository {
let limit = page.limit as i64; let limit = page.limit as i64;
let offset = page.offset as i64; let offset = page.offset as i64;
let total = sqlx::query_scalar!( let total = sqlx::query_scalar!("SELECT COUNT(*) FROM reviews WHERE movie_id = ?", id_str)
"SELECT COUNT(*) FROM reviews WHERE movie_id = ?", .fetch_one(&self.pool)
id_str .await
) .map_err(Self::map_err)?;
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)?;
let rows = sqlx::query_as::<_, FeedRow>( let rows = sqlx::query_as::<_, FeedRow>(
"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,
@@ -843,12 +841,11 @@ impl DiaryRepository for SqliteMovieRepository {
} }
async fn count_local_posts(&self) -> Result<u64, DomainError> { async fn count_local_posts(&self) -> Result<u64, DomainError> {
let count: i64 = sqlx::query_scalar( let count: i64 =
"SELECT COUNT(*) FROM reviews WHERE remote_actor_url IS NULL" sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE remote_actor_url IS NULL")
) .fetch_one(&self.pool)
.fetch_one(&self.pool) .await
.await .map_err(Self::map_err)?;
.map_err(Self::map_err)?;
Ok(count as u64) Ok(count as u64)
} }
} }
@@ -934,7 +931,9 @@ impl StatsRepository for SqliteMovieRepository {
} }
} }
pub async fn wire(database_url: &str) -> anyhow::Result<( pub async fn wire(
database_url: &str,
) -> anyhow::Result<(
sqlx::SqlitePool, sqlx::SqlitePool,
std::sync::Arc<dyn domain::ports::MovieRepository>, std::sync::Arc<dyn domain::ports::MovieRepository>,
std::sync::Arc<dyn domain::ports::ReviewRepository>, std::sync::Arc<dyn domain::ports::ReviewRepository>,
@@ -946,9 +945,9 @@ pub async fn wire(database_url: &str) -> anyhow::Result<(
std::sync::Arc<dyn domain::ports::MovieProfileRepository>, std::sync::Arc<dyn domain::ports::MovieProfileRepository>,
std::sync::Arc<dyn domain::ports::WatchlistRepository>, std::sync::Arc<dyn domain::ports::WatchlistRepository>,
)> { )> {
use std::str::FromStr;
use anyhow::Context; use anyhow::Context;
use sqlx::sqlite::SqliteConnectOptions; use sqlx::sqlite::SqliteConnectOptions;
use std::str::FromStr;
let opts = SqliteConnectOptions::from_str(database_url) let opts = SqliteConnectOptions::from_str(database_url)
.context("Invalid DATABASE_URL")? .context("Invalid DATABASE_URL")?
@@ -1073,8 +1072,9 @@ mod feed_filter_tests {
let repo = SqliteMovieRepository::new(pool); let repo = SqliteMovieRepository::new(pool);
let filter = FollowingFilter { let filter = FollowingFilter {
local_user_ids: vec![uuid::Uuid::parse_str("11111111-1111-1111-1111-111111111111") local_user_ids: vec![
.unwrap()], uuid::Uuid::parse_str("11111111-1111-1111-1111-111111111111").unwrap(),
],
remote_actor_urls: vec!["https://remote.social/users/carol".to_string()], remote_actor_urls: vec!["https://remote.social/users/carol".to_string()],
}; };
let page = PageParams::new(Some(10), Some(0)).unwrap(); let page = PageParams::new(Some(10), Some(0)).unwrap();
@@ -1147,7 +1147,10 @@ mod feed_filter_tests {
assert_eq!(result.total_count, 1); assert_eq!(result.total_count, 1);
assert_eq!(result.items.len(), 1); assert_eq!(result.items.len(), 1);
assert!(result.items[0].review().is_remote()); assert!(result.items[0].review().is_remote());
assert_eq!(result.items[0].user_email(), "https://remote.social/users/carol"); assert_eq!(
result.items[0].user_email(),
"https://remote.social/users/carol"
);
} }
#[tokio::test] #[tokio::test]
@@ -1209,8 +1212,12 @@ mod diary_count_tests {
.bind(&user_id).bind("a@b.com").bind("hash").bind("2024-01-01 00:00:00").bind("alice") .bind(&user_id).bind("a@b.com").bind("hash").bind("2024-01-01 00:00:00").bind("alice")
.execute(&pool).await.unwrap(); .execute(&pool).await.unwrap();
sqlx::query("INSERT INTO movies (id, title, release_year) VALUES (?, ?, ?)") sqlx::query("INSERT INTO movies (id, title, release_year) VALUES (?, ?, ?)")
.bind(&movie_id).bind("Test Movie").bind(2024i32) .bind(&movie_id)
.execute(&pool).await.unwrap(); .bind("Test Movie")
.bind(2024i32)
.execute(&pool)
.await
.unwrap();
// Local review (remote_actor_url IS NULL) // Local review (remote_actor_url IS NULL)
let r1 = uuid::Uuid::new_v4().to_string(); let r1 = uuid::Uuid::new_v4().to_string();

View File

@@ -1,7 +1,10 @@
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::{DiaryEntry, FeedEntry, Movie, MovieSummary, Review, ReviewSource, UserSummary, WatchlistEntry, WatchlistWithMovie}, models::{
DiaryEntry, FeedEntry, Movie, MovieSummary, Review, ReviewSource, UserSummary,
WatchlistEntry, WatchlistWithMovie,
},
value_objects::{ value_objects::{
Comment, Email, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear, Comment, Email, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
ReviewId, UserId, WatchlistEntryId, ReviewId, UserId, WatchlistEntryId,

View File

@@ -20,7 +20,10 @@ impl SqlitePersonAdapter {
pub fn create_person_adapter(pool: SqlitePool) -> (Arc<dyn PersonCommand>, Arc<dyn PersonQuery>) { pub fn create_person_adapter(pool: SqlitePool) -> (Arc<dyn PersonCommand>, Arc<dyn PersonQuery>) {
let adapter = Arc::new(SqlitePersonAdapter::new(pool)); let adapter = Arc::new(SqlitePersonAdapter::new(pool));
(Arc::clone(&adapter) as Arc<dyn PersonCommand>, adapter as Arc<dyn PersonQuery>) (
Arc::clone(&adapter) as Arc<dyn PersonCommand>,
adapter as Arc<dyn PersonQuery>,
)
} }
fn map_err(e: sqlx::Error) -> DomainError { fn map_err(e: sqlx::Error) -> DomainError {
@@ -70,7 +73,10 @@ impl PersonQuery for SqlitePersonAdapter {
Ok(row.map(PersonRow::into_person)) Ok(row.map(PersonRow::into_person))
} }
async fn get_by_external_id(&self, id: &ExternalPersonId) -> Result<Option<Person>, DomainError> { async fn get_by_external_id(
&self,
id: &ExternalPersonId,
) -> Result<Option<Person>, DomainError> {
let row = sqlx::query_as::<_, PersonRow>( let row = sqlx::query_as::<_, PersonRow>(
"SELECT id, external_id, name, known_for_department, profile_path FROM persons WHERE external_id = ?", "SELECT id, external_id, name, known_for_department, profile_path FROM persons WHERE external_id = ?",
) )
@@ -83,21 +89,25 @@ impl PersonQuery for SqlitePersonAdapter {
} }
async fn get_credits(&self, id: &PersonId) -> Result<PersonCredits, DomainError> { async fn get_credits(&self, id: &PersonId) -> Result<PersonCredits, DomainError> {
let person = self.get_by_id(id).await?.ok_or_else(|| { let person = self
DomainError::NotFound(format!("Person {} not found", id.value())) .get_by_id(id)
})?; .await?
.ok_or_else(|| DomainError::NotFound(format!("Person {} not found", id.value())))?;
let tmdb_id: Option<i64> = sqlx::query_scalar( let tmdb_id: Option<i64> =
"SELECT tmdb_person_id FROM persons WHERE id = ?", sqlx::query_scalar("SELECT tmdb_person_id FROM persons WHERE id = ?")
) .bind(id.value().to_string())
.bind(id.value().to_string()) .fetch_optional(&self.pool)
.fetch_optional(&self.pool) .await
.await .map_err(map_err)?
.map_err(map_err)? .flatten();
.flatten();
let Some(tmdb_id) = tmdb_id else { let Some(tmdb_id) = tmdb_id else {
return Ok(PersonCredits { person, cast: vec![], crew: vec![] }); return Ok(PersonCredits {
person,
cast: vec![],
crew: vec![],
});
}; };
let cast = sqlx::query_as::<_, CastRow>( let cast = sqlx::query_as::<_, CastRow>(

View File

@@ -66,48 +66,80 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
sqlx::query("DELETE FROM movie_genres WHERE movie_id = ?") sqlx::query("DELETE FROM movie_genres WHERE movie_id = ?")
.bind(&movie_id) .bind(&movie_id)
.execute(&mut *tx).await.map_err(Self::map_err)?; .execute(&mut *tx)
.await
.map_err(Self::map_err)?;
for g in &p.genres { for g in &p.genres {
sqlx::query("INSERT OR IGNORE INTO movie_genres (movie_id, tmdb_id, name) VALUES (?,?,?)") sqlx::query(
.bind(&movie_id).bind(g.tmdb_id as i64).bind(&g.name) "INSERT OR IGNORE INTO movie_genres (movie_id, tmdb_id, name) VALUES (?,?,?)",
.execute(&mut *tx).await.map_err(Self::map_err)?; )
.bind(&movie_id)
.bind(g.tmdb_id as i64)
.bind(&g.name)
.execute(&mut *tx)
.await
.map_err(Self::map_err)?;
} }
sqlx::query("DELETE FROM movie_keywords WHERE movie_id = ?") sqlx::query("DELETE FROM movie_keywords WHERE movie_id = ?")
.bind(&movie_id) .bind(&movie_id)
.execute(&mut *tx).await.map_err(Self::map_err)?; .execute(&mut *tx)
.await
.map_err(Self::map_err)?;
for k in &p.keywords { for k in &p.keywords {
sqlx::query("INSERT OR IGNORE INTO movie_keywords (movie_id, tmdb_id, name) VALUES (?,?,?)") sqlx::query(
.bind(&movie_id).bind(k.tmdb_id as i64).bind(&k.name) "INSERT OR IGNORE INTO movie_keywords (movie_id, tmdb_id, name) VALUES (?,?,?)",
.execute(&mut *tx).await.map_err(Self::map_err)?; )
.bind(&movie_id)
.bind(k.tmdb_id as i64)
.bind(&k.name)
.execute(&mut *tx)
.await
.map_err(Self::map_err)?;
} }
sqlx::query("DELETE FROM movie_cast WHERE movie_id = ?") sqlx::query("DELETE FROM movie_cast WHERE movie_id = ?")
.bind(&movie_id) .bind(&movie_id)
.execute(&mut *tx).await.map_err(Self::map_err)?; .execute(&mut *tx)
.await
.map_err(Self::map_err)?;
for c in &p.cast { for c in &p.cast {
sqlx::query( sqlx::query(
"INSERT OR IGNORE INTO movie_cast \ "INSERT OR IGNORE INTO movie_cast \
(movie_id, tmdb_person_id, name, character, billing_order, profile_path) \ (movie_id, tmdb_person_id, name, character, billing_order, profile_path) \
VALUES (?,?,?,?,?,?)", VALUES (?,?,?,?,?,?)",
) )
.bind(&movie_id).bind(c.tmdb_person_id as i64).bind(&c.name) .bind(&movie_id)
.bind(&c.character).bind(c.billing_order as i64).bind(&c.profile_path) .bind(c.tmdb_person_id as i64)
.execute(&mut *tx).await.map_err(Self::map_err)?; .bind(&c.name)
.bind(&c.character)
.bind(c.billing_order as i64)
.bind(&c.profile_path)
.execute(&mut *tx)
.await
.map_err(Self::map_err)?;
} }
sqlx::query("DELETE FROM movie_crew WHERE movie_id = ?") sqlx::query("DELETE FROM movie_crew WHERE movie_id = ?")
.bind(&movie_id) .bind(&movie_id)
.execute(&mut *tx).await.map_err(Self::map_err)?; .execute(&mut *tx)
.await
.map_err(Self::map_err)?;
for cr in &p.crew { for cr in &p.crew {
sqlx::query( sqlx::query(
"INSERT OR IGNORE INTO movie_crew \ "INSERT OR IGNORE INTO movie_crew \
(movie_id, tmdb_person_id, name, job, department, profile_path) \ (movie_id, tmdb_person_id, name, job, department, profile_path) \
VALUES (?,?,?,?,?,?)", VALUES (?,?,?,?,?,?)",
) )
.bind(&movie_id).bind(cr.tmdb_person_id as i64).bind(&cr.name) .bind(&movie_id)
.bind(&cr.job).bind(&cr.department).bind(&cr.profile_path) .bind(cr.tmdb_person_id as i64)
.execute(&mut *tx).await.map_err(Self::map_err)?; .bind(&cr.name)
.bind(&cr.job)
.bind(&cr.department)
.bind(&cr.profile_path)
.execute(&mut *tx)
.await
.map_err(Self::map_err)?;
} }
tx.commit().await.map_err(Self::map_err) tx.commit().await.map_err(Self::map_err)
@@ -132,7 +164,8 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
None => return Ok(None), None => return Ok(None),
}; };
let enriched_at_str: String = row.try_get("enriched_at") let enriched_at_str: String = row
.try_get("enriched_at")
.map_err(|_| DomainError::InfrastructureError("invalid enriched_at".into()))?; .map_err(|_| DomainError::InfrastructureError("invalid enriched_at".into()))?;
let enriched_at: DateTime<Utc> = enriched_at_str let enriched_at: DateTime<Utc> = enriched_at_str
.parse() .parse()
@@ -140,7 +173,9 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
let genres = sqlx::query("SELECT tmdb_id, name FROM movie_genres WHERE movie_id = ?") let genres = sqlx::query("SELECT tmdb_id, name FROM movie_genres WHERE movie_id = ?")
.bind(&movie_id) .bind(&movie_id)
.fetch_all(&self.pool).await.map_err(Self::map_err)? .fetch_all(&self.pool)
.await
.map_err(Self::map_err)?
.into_iter() .into_iter()
.map(|r| Genre { .map(|r| Genre {
tmdb_id: r.try_get::<i64, _>("tmdb_id").unwrap_or(0) as u32, tmdb_id: r.try_get::<i64, _>("tmdb_id").unwrap_or(0) as u32,
@@ -150,7 +185,9 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
let keywords = sqlx::query("SELECT tmdb_id, name FROM movie_keywords WHERE movie_id = ?") let keywords = sqlx::query("SELECT tmdb_id, name FROM movie_keywords WHERE movie_id = ?")
.bind(&movie_id) .bind(&movie_id)
.fetch_all(&self.pool).await.map_err(Self::map_err)? .fetch_all(&self.pool)
.await
.map_err(Self::map_err)?
.into_iter() .into_iter()
.map(|r| Keyword { .map(|r| Keyword {
tmdb_id: r.try_get::<i64, _>("tmdb_id").unwrap_or(0) as u32, tmdb_id: r.try_get::<i64, _>("tmdb_id").unwrap_or(0) as u32,
@@ -163,7 +200,9 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
FROM movie_cast WHERE movie_id = ? ORDER BY billing_order", FROM movie_cast WHERE movie_id = ? ORDER BY billing_order",
) )
.bind(&movie_id) .bind(&movie_id)
.fetch_all(&self.pool).await.map_err(Self::map_err)? .fetch_all(&self.pool)
.await
.map_err(Self::map_err)?
.into_iter() .into_iter()
.map(|r| CastMember { .map(|r| CastMember {
tmdb_person_id: r.try_get::<i64, _>("tmdb_person_id").unwrap_or(0) as u64, tmdb_person_id: r.try_get::<i64, _>("tmdb_person_id").unwrap_or(0) as u64,
@@ -179,7 +218,9 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
FROM movie_crew WHERE movie_id = ?", FROM movie_crew WHERE movie_id = ?",
) )
.bind(&movie_id) .bind(&movie_id)
.fetch_all(&self.pool).await.map_err(Self::map_err)? .fetch_all(&self.pool)
.await
.map_err(Self::map_err)?
.into_iter() .into_iter()
.map(|r| CrewMember { .map(|r| CrewMember {
tmdb_person_id: r.try_get::<i64, _>("tmdb_person_id").unwrap_or(0) as u64, tmdb_person_id: r.try_get::<i64, _>("tmdb_person_id").unwrap_or(0) as u64,
@@ -196,11 +237,19 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
imdb_id: row.try_get("imdb_id").ok(), imdb_id: row.try_get("imdb_id").ok(),
overview: row.try_get("overview").ok(), overview: row.try_get("overview").ok(),
tagline: row.try_get("tagline").ok(), tagline: row.try_get("tagline").ok(),
runtime_minutes: row.try_get::<Option<i64>, _>("runtime_minutes").ok().flatten().map(|v| v as u32), runtime_minutes: row
.try_get::<Option<i64>, _>("runtime_minutes")
.ok()
.flatten()
.map(|v| v as u32),
budget_usd: row.try_get("budget_usd").ok(), budget_usd: row.try_get("budget_usd").ok(),
revenue_usd: row.try_get("revenue_usd").ok(), revenue_usd: row.try_get("revenue_usd").ok(),
vote_average: row.try_get("vote_average").ok(), vote_average: row.try_get("vote_average").ok(),
vote_count: row.try_get::<Option<i64>, _>("vote_count").ok().flatten().map(|v| v as u32), vote_count: row
.try_get::<Option<i64>, _>("vote_count")
.ok()
.flatten()
.map(|v| v as u32),
original_language: row.try_get("original_language").ok(), original_language: row.try_get("original_language").ok(),
collection_name: row.try_get("collection_name").ok(), collection_name: row.try_get("collection_name").ok(),
genres, genres,

View File

@@ -2,9 +2,7 @@ use async_trait::async_trait;
use sqlx::SqlitePool; use sqlx::SqlitePool;
use domain::{ use domain::{
errors::DomainError, errors::DomainError, models::ProfileField, ports::UserProfileFieldsRepository,
models::ProfileField,
ports::UserProfileFieldsRepository,
value_objects::UserId, value_objects::UserId,
}; };
@@ -30,10 +28,20 @@ impl UserProfileFieldsRepository for SqliteProfileFieldsRepository {
.await .await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?; .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(rows.into_iter().map(|r| ProfileField { name: r.name, value: r.value }).collect()) Ok(rows
.into_iter()
.map(|r| ProfileField {
name: r.name,
value: r.value,
})
.collect())
} }
async fn set_fields(&self, user_id: &UserId, fields: Vec<ProfileField>) -> Result<(), DomainError> { async fn set_fields(
&self,
user_id: &UserId,
fields: Vec<ProfileField>,
) -> Result<(), DomainError> {
let id_str = user_id.value().to_string(); let id_str = user_id.value().to_string();
sqlx::query!("DELETE FROM user_profile_fields WHERE user_id = ?", id_str) sqlx::query!("DELETE FROM user_profile_fields WHERE user_id = ?", id_str)

View File

@@ -40,7 +40,9 @@ async fn list_keys_returns_both_avatar_and_poster_paths() {
sqlx::query("INSERT INTO users VALUES ('u1','e@e.com','u','h','2024-01-01','standard',NULL,'avatars/u1')") sqlx::query("INSERT INTO users VALUES ('u1','e@e.com','u','h','2024-01-01','standard',NULL,'avatars/u1')")
.execute(&pool).await.unwrap(); .execute(&pool).await.unwrap();
sqlx::query("INSERT INTO movies VALUES ('m1','tt1','Title',2020,'Dir','posters/m1')") sqlx::query("INSERT INTO movies VALUES ('m1','tt1','Title',2020,'Dir','posters/m1')")
.execute(&pool).await.unwrap(); .execute(&pool)
.await
.unwrap();
let adapter = SqliteImageRefAdapter::new(pool); let adapter = SqliteImageRefAdapter::new(pool);
let mut keys = adapter.list_keys().await.unwrap(); let mut keys = adapter.list_keys().await.unwrap();
@@ -54,8 +56,12 @@ async fn list_keys_excludes_nulls() {
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
setup(&pool).await; setup(&pool).await;
sqlx::query("INSERT INTO users VALUES ('u1','e@e.com','u','h','2024-01-01','standard',NULL,NULL)") sqlx::query(
.execute(&pool).await.unwrap(); "INSERT INTO users VALUES ('u1','e@e.com','u','h','2024-01-01','standard',NULL,NULL)",
)
.execute(&pool)
.await
.unwrap();
let adapter = SqliteImageRefAdapter::new(pool); let adapter = SqliteImageRefAdapter::new(pool);
assert_eq!(adapter.list_keys().await.unwrap(), Vec::<String>::new()); assert_eq!(adapter.list_keys().await.unwrap(), Vec::<String>::new());
@@ -73,7 +79,9 @@ async fn swap_updates_avatar_path() {
adapter.swap("avatars/u1", "avatars/u1.avif").await.unwrap(); adapter.swap("avatars/u1", "avatars/u1.avif").await.unwrap();
let row: (Option<String>,) = sqlx::query_as("SELECT avatar_path FROM users WHERE id='u1'") let row: (Option<String>,) = sqlx::query_as("SELECT avatar_path FROM users WHERE id='u1'")
.fetch_one(&pool).await.unwrap(); .fetch_one(&pool)
.await
.unwrap();
assert_eq!(row.0.as_deref(), Some("avatars/u1.avif")); assert_eq!(row.0.as_deref(), Some("avatars/u1.avif"));
} }
@@ -83,13 +91,17 @@ async fn swap_updates_poster_path() {
setup(&pool).await; setup(&pool).await;
sqlx::query("INSERT INTO movies VALUES ('m1','tt1','Title',2020,'Dir','posters/m1')") sqlx::query("INSERT INTO movies VALUES ('m1','tt1','Title',2020,'Dir','posters/m1')")
.execute(&pool).await.unwrap(); .execute(&pool)
.await
.unwrap();
let adapter = SqliteImageRefAdapter::new(pool.clone()); let adapter = SqliteImageRefAdapter::new(pool.clone());
adapter.swap("posters/m1", "posters/m1.avif").await.unwrap(); adapter.swap("posters/m1", "posters/m1.avif").await.unwrap();
let row: (Option<String>,) = sqlx::query_as("SELECT poster_path FROM movies WHERE id='m1'") let row: (Option<String>,) = sqlx::query_as("SELECT poster_path FROM movies WHERE id='m1'")
.fetch_one(&pool).await.unwrap(); .fetch_one(&pool)
.await
.unwrap();
assert_eq!(row.0.as_deref(), Some("posters/m1.avif")); assert_eq!(row.0.as_deref(), Some("posters/m1.avif"));
} }
@@ -99,5 +111,8 @@ async fn swap_noop_when_key_not_found() {
setup(&pool).await; setup(&pool).await;
let adapter = SqliteImageRefAdapter::new(pool); let adapter = SqliteImageRefAdapter::new(pool);
adapter.swap("missing/key", "missing/key.avif").await.unwrap(); adapter
.swap("missing/key", "missing/key.avif")
.await
.unwrap();
} }

View File

@@ -61,11 +61,16 @@ async fn upsert_batch_inserts_persons() {
let pool = pool_with_schema().await; let pool = pool_with_schema().await;
let adapter = SqlitePersonAdapter::new(pool.clone()); let adapter = SqlitePersonAdapter::new(pool.clone());
let persons = vec![make_person(1, "Alice", Some("Acting")), make_person(2, "Bob", Some("Directing"))]; let persons = vec![
make_person(1, "Alice", Some("Acting")),
make_person(2, "Bob", Some("Directing")),
];
adapter.upsert_batch(&persons).await.unwrap(); adapter.upsert_batch(&persons).await.unwrap();
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM persons") let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM persons")
.fetch_one(&pool).await.unwrap(); .fetch_one(&pool)
.await
.unwrap();
assert_eq!(count.0, 2); assert_eq!(count.0, 2);
} }
@@ -79,7 +84,9 @@ async fn upsert_batch_is_idempotent() {
adapter.upsert_batch(&persons).await.unwrap(); adapter.upsert_batch(&persons).await.unwrap();
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM persons") let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM persons")
.fetch_one(&pool).await.unwrap(); .fetch_one(&pool)
.await
.unwrap();
assert_eq!(count.0, 1); assert_eq!(count.0, 1);
} }
@@ -114,9 +121,13 @@ async fn get_credits_returns_cast_and_crew() {
adapter.upsert_batch(&[p.clone()]).await.unwrap(); adapter.upsert_batch(&[p.clone()]).await.unwrap();
sqlx::query("INSERT INTO movies VALUES ('m1', 'The Film', 2020, 'Dir', NULL, NULL)") sqlx::query("INSERT INTO movies VALUES ('m1', 'The Film', 2020, 'Dir', NULL, NULL)")
.execute(&pool).await.unwrap(); .execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO movie_cast VALUES ('m1', 7, 'Diana', 'Hero', 1, NULL)") sqlx::query("INSERT INTO movie_cast VALUES ('m1', 7, 'Diana', 'Hero', 1, NULL)")
.execute(&pool).await.unwrap(); .execute(&pool)
.await
.unwrap();
let credits = adapter.get_credits(p.id()).await.unwrap(); let credits = adapter.get_credits(p.id()).await.unwrap();
assert_eq!(credits.person.name(), "Diana"); assert_eq!(credits.person.name(), "Diana");

View File

@@ -36,7 +36,7 @@ async fn find_by_id_returns_user_when_found() {
let (pool, repo) = setup().await; let (pool, repo) = setup().await;
let id = uuid::Uuid::new_v4(); let id = uuid::Uuid::new_v4();
sqlx::query( sqlx::query(
"INSERT INTO users (id, email, username, password_hash, created_at) VALUES (?, ?, ?, ?, ?)" "INSERT INTO users (id, email, username, password_hash, created_at) VALUES (?, ?, ?, ?, ?)",
) )
.bind(id.to_string()) .bind(id.to_string())
.bind("test@example.com") .bind("test@example.com")
@@ -88,10 +88,18 @@ async fn update_profile_clears_fields_with_none() {
UserRole::Standard, UserRole::Standard,
); );
repo.save(&user).await.unwrap(); repo.save(&user).await.unwrap();
repo.update_profile(user.id(), Some("bio".to_string()), Some("path".to_string()), None, None) repo.update_profile(
user.id(),
Some("bio".to_string()),
Some("path".to_string()),
None,
None,
)
.await
.unwrap();
repo.update_profile(user.id(), None, None, None, None)
.await .await
.unwrap(); .unwrap();
repo.update_profile(user.id(), None, None, None, None).await.unwrap();
let found = repo.find_by_id(user.id()).await.unwrap().unwrap(); let found = repo.find_by_id(user.id()).await.unwrap().unwrap();
assert_eq!(found.bio(), None); assert_eq!(found.bio(), None);

View File

@@ -177,7 +177,13 @@ impl UserRepository for SqliteUserRepository {
.await .await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?; .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let profile_fields = field_rows.into_iter().map(|f| ProfileField { name: f.name, value: f.value }).collect(); let profile_fields = field_rows
.into_iter()
.map(|f| ProfileField {
name: f.name,
value: f.value,
})
.collect();
Self::row_to_user( Self::row_to_user(
r.id.unwrap_or_default(), r.id.unwrap_or_default(),
@@ -190,7 +196,8 @@ impl UserRepository for SqliteUserRepository {
r.banner_path, r.banner_path,
r.also_known_as, r.also_known_as,
profile_fields, profile_fields,
).map(Some) )
.map(Some)
} }
async fn update_profile( async fn update_profile(

View File

@@ -1,7 +1,10 @@
use async_trait::async_trait; use async_trait::async_trait;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::{WatchlistEntry, WatchlistWithMovie, collections::{PageParams, Paginated}}, models::{
WatchlistEntry, WatchlistWithMovie,
collections::{PageParams, Paginated},
},
ports::WatchlistRepository, ports::WatchlistRepository,
value_objects::{MovieId, UserId}, value_objects::{MovieId, UserId},
}; };
@@ -51,14 +54,13 @@ impl WatchlistRepository for SqliteWatchlistRepository {
let uid = user_id.value().to_string(); let uid = user_id.value().to_string();
let mid = movie_id.value().to_string(); let mid = movie_id.value().to_string();
let result = sqlx::query( let result =
"DELETE FROM watchlist_entries WHERE user_id = ? AND movie_id = ?", sqlx::query("DELETE FROM watchlist_entries WHERE user_id = ? AND movie_id = ?")
) .bind(&uid)
.bind(&uid) .bind(&mid)
.bind(&mid) .execute(&self.pool)
.execute(&self.pool) .await
.await .map_err(Self::map_err)?;
.map_err(Self::map_err)?;
if result.rows_affected() == 0 { if result.rows_affected() == 0 {
return Err(DomainError::NotFound(format!( return Err(DomainError::NotFound(format!(
@@ -76,14 +78,13 @@ impl WatchlistRepository for SqliteWatchlistRepository {
) -> Result<bool, DomainError> { ) -> Result<bool, DomainError> {
let uid = user_id.value().to_string(); let uid = user_id.value().to_string();
let mid = movie_id.value().to_string(); let mid = movie_id.value().to_string();
let result = sqlx::query( let result =
"DELETE FROM watchlist_entries WHERE user_id = ? AND movie_id = ?", sqlx::query("DELETE FROM watchlist_entries WHERE user_id = ? AND movie_id = ?")
) .bind(&uid)
.bind(&uid) .bind(&mid)
.bind(&mid) .execute(&self.pool)
.execute(&self.pool) .await
.await .map_err(Self::map_err)?;
.map_err(Self::map_err)?;
Ok(result.rows_affected() > 0) Ok(result.rows_affected() > 0)
} }
@@ -113,15 +114,13 @@ impl WatchlistRepository for SqliteWatchlistRepository {
.await .await
.map_err(Self::map_err)?; .map_err(Self::map_err)?;
let total: i64 = sqlx::query( let total: i64 = sqlx::query("SELECT COUNT(*) FROM watchlist_entries WHERE user_id = ?")
"SELECT COUNT(*) FROM watchlist_entries WHERE user_id = ?", .bind(&uid)
) .fetch_one(&self.pool)
.bind(&uid) .await
.fetch_one(&self.pool) .map_err(Self::map_err)?
.await .try_get(0)
.map_err(Self::map_err)? .unwrap_or(0);
.try_get(0)
.unwrap_or(0);
let items = rows let items = rows
.into_iter() .into_iter()

View File

@@ -14,7 +14,10 @@ use domain::models::{
mod filters { mod filters {
#[askama::filter_fn] #[askama::filter_fn]
pub fn poster_src<T: std::fmt::Display>(path: T, _env: &dyn askama::Values) -> askama::Result<String> { pub fn poster_src<T: std::fmt::Display>(
path: T,
_env: &dyn askama::Values,
) -> askama::Result<String> {
let p = path.to_string(); let p = path.to_string();
if p.starts_with("http://") || p.starts_with("https://") { if p.starts_with("http://") || p.starts_with("https://") {
Ok(p) Ok(p)
@@ -142,7 +145,8 @@ impl<'a> ActivityFeedTemplate<'a> {
format!("sort_by={}", self.sort_by), format!("sort_by={}", self.sort_by),
]; ];
if !self.search.is_empty() { if !self.search.is_empty() {
let encoded = self.search let encoded = self
.search
.replace(' ', "+") .replace(' ', "+")
.replace('#', "%23") .replace('#', "%23")
.replace('&', "%26") .replace('&', "%26")
@@ -217,7 +221,8 @@ impl<'a> ProfileTemplate<'a> {
format!("sort_by={}", self.sort_by), format!("sort_by={}", self.sort_by),
]; ];
if !self.search.is_empty() { if !self.search.is_empty() {
let encoded = self.search let encoded = self
.search
.replace(' ', "+") .replace(' ', "+")
.replace('#', "%23") .replace('#', "%23")
.replace('&', "%26") .replace('&', "%26")
@@ -493,7 +498,8 @@ impl HtmlRenderer for AskamaHtmlRenderer {
} }
}) })
.collect(); .collect();
let remote_actors = data.remote_actors let remote_actors = data
.remote_actors
.into_iter() .into_iter()
.map(|a| { .map(|a| {
let name = a.display_name.unwrap_or_else(|| a.handle.clone()); let name = a.display_name.unwrap_or_else(|| a.handle.clone());
@@ -543,9 +549,7 @@ impl HtmlRenderer for AskamaHtmlRenderer {
let total_pages = data let total_pages = data
.entries .entries
.as_ref() .as_ref()
.map(|e| { .map(|e| e.total_count.div_ceil(e.limit.max(1) as u64) as u32)
e.total_count.div_ceil(e.limit.max(1) as u64) as u32
})
.unwrap_or(0); .unwrap_or(0);
let current_page = data.current_offset.checked_div(data.limit).unwrap_or(0); let current_page = data.current_offset.checked_div(data.limit).unwrap_or(0);
let avg_rating_display = data let avg_rating_display = data

View File

@@ -1,13 +1,16 @@
use std::sync::Arc; use std::sync::Arc;
use application::{commands::EnrichMovieCommand, use_cases::enrich_movie};
use async_trait::async_trait; use async_trait::async_trait;
use chrono::Utc; use chrono::Utc;
use application::{commands::EnrichMovieCommand, use_cases::enrich_movie};
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
models::{CastMember, CrewMember, Genre, Keyword, MovieProfile}, models::{CastMember, CrewMember, Genre, Keyword, MovieProfile},
ports::{EventHandler, MovieEnrichmentClient, MovieProfileRepository, MovieRepository, PersonCommand, SearchCommand}, ports::{
EventHandler, MovieEnrichmentClient, MovieProfileRepository, MovieRepository,
PersonCommand, SearchCommand,
},
value_objects::MovieId, value_objects::MovieId,
}; };
use serde::Deserialize; use serde::Deserialize;
@@ -21,40 +24,56 @@ pub struct TmdbEnrichmentClient {
impl TmdbEnrichmentClient { impl TmdbEnrichmentClient {
pub fn from_env() -> Result<Self, DomainError> { pub fn from_env() -> Result<Self, DomainError> {
let api_key = std::env::var("TMDB_API_KEY").map_err(|_| { let api_key = std::env::var("TMDB_API_KEY")
DomainError::InfrastructureError("TMDB_API_KEY is not set".into()) .map_err(|_| DomainError::InfrastructureError("TMDB_API_KEY is not set".into()))?;
})?; Ok(Self {
Ok(Self { api_key, http: reqwest::Client::new() }) api_key,
http: reqwest::Client::new(),
})
} }
fn base(&self, path: &str) -> String { fn base(&self, path: &str) -> String {
format!("https://api.themoviedb.org/3{}", path) format!("https://api.themoviedb.org/3{}", path)
} }
async fn get<T: for<'de> Deserialize<'de>>(&self, url: &str, extra: &[(&str, &str)]) -> Result<T, DomainError> { async fn get<T: for<'de> Deserialize<'de>>(
let mut req = self.http.get(url).query(&[("api_key", self.api_key.as_str())]); &self,
url: &str,
extra: &[(&str, &str)],
) -> Result<T, DomainError> {
let mut req = self
.http
.get(url)
.query(&[("api_key", self.api_key.as_str())]);
for (k, v) in extra { for (k, v) in extra {
req = req.query(&[(k, v)]); req = req.query(&[(k, v)]);
} }
req.send().await req.send()
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))? .map_err(|e| DomainError::InfrastructureError(e.to_string()))?
.error_for_status() .error_for_status()
.map_err(|e| DomainError::InfrastructureError(e.to_string()))? .map_err(|e| DomainError::InfrastructureError(e.to_string()))?
.json::<T>().await .json::<T>()
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string())) .map_err(|e| DomainError::InfrastructureError(e.to_string()))
} }
async fn resolve_tmdb_id(&self, external_id: &str) -> Result<u64, DomainError> { async fn resolve_tmdb_id(&self, external_id: &str) -> Result<u64, DomainError> {
if let Some(numeric) = external_id.strip_prefix("tmdb:") { if let Some(numeric) = external_id.strip_prefix("tmdb:") {
return numeric.parse::<u64>() return numeric.parse::<u64>().map_err(|_| {
.map_err(|_| DomainError::InfrastructureError(format!("Invalid tmdb id: {numeric}"))); DomainError::InfrastructureError(format!("Invalid tmdb id: {numeric}"))
});
} }
// Assume IMDb ID (tt…) — use /find // Assume IMDb ID (tt…) — use /find
#[derive(Deserialize)] #[derive(Deserialize)]
struct FindResult { id: u64 } struct FindResult {
id: u64,
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct FindResponse { movie_results: Vec<FindResult> } struct FindResponse {
movie_results: Vec<FindResult>,
}
let url = self.base(&format!("/find/{}", external_id)); let url = self.base(&format!("/find/{}", external_id));
let resp: FindResponse = self.get(&url, &[("external_source", "imdb_id")]).await?; let resp: FindResponse = self.get(&url, &[("external_source", "imdb_id")]).await?;
@@ -68,14 +87,23 @@ impl TmdbEnrichmentClient {
#[async_trait] #[async_trait]
impl MovieEnrichmentClient for TmdbEnrichmentClient { impl MovieEnrichmentClient for TmdbEnrichmentClient {
async fn fetch_profile(&self, movie_id: MovieId, external_metadata_id: &str) -> Result<MovieProfile, DomainError> { async fn fetch_profile(
&self,
movie_id: MovieId,
external_metadata_id: &str,
) -> Result<MovieProfile, DomainError> {
let tmdb_id = self.resolve_tmdb_id(external_metadata_id).await?; let tmdb_id = self.resolve_tmdb_id(external_metadata_id).await?;
#[derive(Deserialize)] #[derive(Deserialize)]
struct GenreDto { id: u32, name: String } struct GenreDto {
id: u32,
name: String,
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct CollectionDto { name: String } struct CollectionDto {
name: String,
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct CastDto { struct CastDto {
@@ -96,13 +124,21 @@ impl MovieEnrichmentClient for TmdbEnrichmentClient {
} }
#[derive(Deserialize)] #[derive(Deserialize)]
struct Credits { cast: Vec<CastDto>, crew: Vec<CrewDto> } struct Credits {
cast: Vec<CastDto>,
crew: Vec<CrewDto>,
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct KeywordDto { id: u32, name: String } struct KeywordDto {
id: u32,
name: String,
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct Keywords { keywords: Vec<KeywordDto> } struct Keywords {
keywords: Vec<KeywordDto>,
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct Details { struct Details {
@@ -122,7 +158,9 @@ impl MovieEnrichmentClient for TmdbEnrichmentClient {
} }
let url = self.base(&format!("/movie/{}", tmdb_id)); let url = self.base(&format!("/movie/{}", tmdb_id));
let d: Details = self.get(&url, &[("append_to_response", "credits,keywords")]).await?; let d: Details = self
.get(&url, &[("append_to_response", "credits,keywords")])
.await?;
Ok(MovieProfile { Ok(MovieProfile {
movie_id, movie_id,
@@ -137,24 +175,47 @@ impl MovieEnrichmentClient for TmdbEnrichmentClient {
vote_count: d.vote_count, vote_count: d.vote_count,
original_language: d.original_language, original_language: d.original_language,
collection_name: d.belongs_to_collection.map(|c| c.name), collection_name: d.belongs_to_collection.map(|c| c.name),
genres: d.genres.into_iter().map(|g| Genre { tmdb_id: g.id, name: g.name }).collect(), genres: d
keywords: d.keywords.keywords.into_iter() .genres
.map(|k| Keyword { tmdb_id: k.id, name: k.name }) .into_iter()
.map(|g| Genre {
tmdb_id: g.id,
name: g.name,
})
.collect(),
keywords: d
.keywords
.keywords
.into_iter()
.map(|k| Keyword {
tmdb_id: k.id,
name: k.name,
})
.collect(),
cast: d
.credits
.cast
.into_iter()
.map(|c| CastMember {
tmdb_person_id: c.id,
name: c.name,
character: c.character,
billing_order: c.order,
profile_path: c.profile_path,
})
.collect(),
crew: d
.credits
.crew
.into_iter()
.map(|c| CrewMember {
tmdb_person_id: c.id,
name: c.name,
job: c.job,
department: c.department,
profile_path: c.profile_path,
})
.collect(), .collect(),
cast: d.credits.cast.into_iter().map(|c| CastMember {
tmdb_person_id: c.id,
name: c.name,
character: c.character,
billing_order: c.order,
profile_path: c.profile_path,
}).collect(),
crew: d.credits.crew.into_iter().map(|c| CrewMember {
tmdb_person_id: c.id,
name: c.name,
job: c.job,
department: c.department,
profile_path: c.profile_path,
}).collect(),
enriched_at: Utc::now(), enriched_at: Utc::now(),
}) })
} }
@@ -164,19 +225,20 @@ impl MovieEnrichmentClient for TmdbEnrichmentClient {
pub struct EnrichmentHandler { pub struct EnrichmentHandler {
pub enrichment_client: Arc<dyn MovieEnrichmentClient>, pub enrichment_client: Arc<dyn MovieEnrichmentClient>,
pub movie_repository: Arc<dyn MovieRepository>, pub movie_repository: Arc<dyn MovieRepository>,
pub profile_repo: Arc<dyn MovieProfileRepository>, pub profile_repo: Arc<dyn MovieProfileRepository>,
pub person_command: Arc<dyn PersonCommand>, pub person_command: Arc<dyn PersonCommand>,
pub search_command: Arc<dyn SearchCommand>, pub search_command: Arc<dyn SearchCommand>,
} }
#[async_trait] #[async_trait]
impl EventHandler for EnrichmentHandler { impl EventHandler for EnrichmentHandler {
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> { async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> {
let (movie_id, external_metadata_id) = match event { let (movie_id, external_metadata_id) = match event {
DomainEvent::MovieEnrichmentRequested { movie_id, external_metadata_id } => { DomainEvent::MovieEnrichmentRequested {
(movie_id.clone(), external_metadata_id.clone()) movie_id,
} external_metadata_id,
} => (movie_id.clone(), external_metadata_id.clone()),
_ => return Ok(()), _ => return Ok(()),
}; };
@@ -195,7 +257,11 @@ impl EventHandler for EnrichmentHandler {
tracing::info!(movie_id = %movie_id.value(), external_id = %external_metadata_id, "enriching movie"); tracing::info!(movie_id = %movie_id.value(), external_id = %external_metadata_id, "enriching movie");
match self.enrichment_client.fetch_profile(movie_id.clone(), &external_metadata_id).await { match self
.enrichment_client
.fetch_profile(movie_id.clone(), &external_metadata_id)
.await
{
Ok(profile) => { Ok(profile) => {
enrich_movie::execute( enrich_movie::execute(
&self.movie_repository, &self.movie_repository,

View File

@@ -1,16 +1,14 @@
use std::sync::Arc; use std::sync::Arc;
use domain::ports::{
AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher,
ImageStorage,
ImportProfileRepository, ImportSessionRepository,
MetadataClient, MovieProfileRepository, MovieRepository, PasswordHasher, PosterFetcherClient,
PersonCommand, PersonQuery, SearchCommand, SearchPort,
ReviewRepository, StatsRepository, UserProfileFieldsRepository, UserRepository,
WatchlistRepository,
};
#[cfg(feature = "federation")] #[cfg(feature = "federation")]
use domain::ports::RemoteWatchlistRepository; use domain::ports::RemoteWatchlistRepository;
use domain::ports::{
AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher, ImageStorage,
ImportProfileRepository, ImportSessionRepository, MetadataClient, MovieProfileRepository,
MovieRepository, PasswordHasher, PersonCommand, PersonQuery, PosterFetcherClient,
ReviewRepository, SearchCommand, SearchPort, StatsRepository, UserProfileFieldsRepository,
UserRepository, WatchlistRepository,
};
use crate::config::AppConfig; use crate::config::AppConfig;

View File

@@ -51,10 +51,12 @@ impl PeriodicJob for EnrichmentStalenessJob {
} }
tracing::info!("enrichment scan: {} stale movies", stale.len()); tracing::info!("enrichment scan: {} stale movies", stale.len());
for (movie_id, external_metadata_id) in stale { for (movie_id, external_metadata_id) in stale {
let event = DomainEvent::MovieEnrichmentRequested { movie_id, external_metadata_id }; let event = DomainEvent::MovieEnrichmentRequested {
movie_id,
external_metadata_id,
};
self.ctx.event_publisher.publish(&event).await?; self.ctx.event_publisher.publish(&event).await?;
} }
Ok(()) Ok(())
} }
} }

View File

@@ -1,14 +1,14 @@
pub mod commands; pub mod commands;
pub mod jobs;
pub mod worker;
pub mod config; pub mod config;
pub mod context; pub mod context;
pub mod jobs;
pub mod movie_discovery_indexer;
pub mod movie_resolver; pub mod movie_resolver;
pub mod ports; pub mod ports;
pub mod queries; pub mod queries;
pub mod use_cases;
pub mod movie_discovery_indexer;
pub mod search_cleanup; pub mod search_cleanup;
pub mod use_cases;
pub mod worker;
pub use movie_discovery_indexer::MovieDiscoveryIndexer; pub use movie_discovery_indexer::MovieDiscoveryIndexer;
pub use search_cleanup::SearchCleanupHandler; pub use search_cleanup::SearchCleanupHandler;

View File

@@ -13,12 +13,18 @@ use domain::{
/// Enrichment will later overwrite this with the full document (cast, genres, etc.). /// Enrichment will later overwrite this with the full document (cast, genres, etc.).
pub struct MovieDiscoveryIndexer { pub struct MovieDiscoveryIndexer {
movie_repository: Arc<dyn MovieRepository>, movie_repository: Arc<dyn MovieRepository>,
search_command: Arc<dyn SearchCommand>, search_command: Arc<dyn SearchCommand>,
} }
impl MovieDiscoveryIndexer { impl MovieDiscoveryIndexer {
pub fn new(movie_repository: Arc<dyn MovieRepository>, search_command: Arc<dyn SearchCommand>) -> Self { pub fn new(
Self { movie_repository, search_command } movie_repository: Arc<dyn MovieRepository>,
search_command: Arc<dyn SearchCommand>,
) -> Self {
Self {
movie_repository,
search_command,
}
} }
} }
@@ -35,7 +41,8 @@ impl EventHandler for MovieDiscoveryIndexer {
return Ok(()); return Ok(());
}; };
if let Err(e) = self.search_command if let Err(e) = self
.search_command
.index(IndexableDocument::Movie { .index(IndexableDocument::Movie {
id: movie_id.clone(), id: movie_id.clone(),
movie: Box::new(movie), movie: Box::new(movie),

View File

@@ -49,9 +49,10 @@ impl MovieResolver {
) -> Result<(Movie, bool), DomainError> { ) -> Result<(Movie, bool), DomainError> {
for strategy in &self.strategies { for strategy in &self.strategies {
if strategy.can_handle(input) if strategy.can_handle(input)
&& let Some(result) = strategy.resolve(input, deps).await? { && let Some(result) = strategy.resolve(input, deps).await?
return Ok(result); {
} return Ok(result);
}
} }
Err(DomainError::ValidationError( Err(DomainError::ValidationError(
"Manual title required if TMDB fetch fails or is omitted".into(), "Manual title required if TMDB fetch fails or is omitted".into(),
@@ -108,13 +109,17 @@ impl ResolutionStrategy for TitleSearchStrategy {
let title = input.manual_title.as_deref().unwrap(); let title = input.manual_title.as_deref().unwrap();
let criteria = MetadataSearchCriteria::Title { let criteria = MetadataSearchCriteria::Title {
title: MovieTitle::new(title.to_string())?, title: MovieTitle::new(title.to_string())?,
year: input.manual_release_year.map(ReleaseYear::new).transpose()?, year: input
.manual_release_year
.map(ReleaseYear::new)
.transpose()?,
}; };
match deps.metadata_client.fetch_movie_metadata(&criteria).await { match deps.metadata_client.fetch_movie_metadata(&criteria).await {
Ok(m) => { Ok(m) => {
// Movie may already exist in DB under this external_metadata_id // Movie may already exist in DB under this external_metadata_id
if let Some(ext_id) = m.external_metadata_id() { if let Some(ext_id) = m.external_metadata_id() {
if let Some(existing) = deps.repository.get_movie_by_external_id(ext_id).await? { if let Some(existing) = deps.repository.get_movie_by_external_id(ext_id).await?
{
return Ok(Some((existing, false))); return Ok(Some((existing, false)));
} }
} }
@@ -164,8 +169,13 @@ impl ResolutionStrategy for ManualMovieStrategy {
if let Some(existing) = matched { if let Some(existing) = matched {
Ok(Some((existing, false))) Ok(Some((existing, false)))
} else { } else {
let new_movie = let new_movie = Movie::new(
Movie::new(None, title, release_year, input.manual_director.clone(), None); None,
title,
release_year,
input.manual_director.clone(),
None,
);
Ok(Some((new_movie, true))) Ok(Some((new_movie, true)))
} }
} }

View File

@@ -224,10 +224,8 @@ pub trait HtmlRenderer: Send + Sync {
fn render_import_upload_page(&self, data: ImportUploadPageData) -> Result<String, String>; fn render_import_upload_page(&self, data: ImportUploadPageData) -> Result<String, String>;
fn render_import_mapping_page(&self, data: ImportMappingPageData) -> Result<String, String>; fn render_import_mapping_page(&self, data: ImportMappingPageData) -> Result<String, String>;
fn render_import_preview_page(&self, data: ImportPreviewPageData) -> Result<String, String>; fn render_import_preview_page(&self, data: ImportPreviewPageData) -> Result<String, String>;
fn render_profile_settings_page( fn render_profile_settings_page(&self, data: ProfileSettingsPageData)
&self, -> Result<String, String>;
data: ProfileSettingsPageData,
) -> Result<String, String>;
fn render_blocked_domains_page(&self, data: BlockedDomainsPageData) -> Result<String, String>; fn render_blocked_domains_page(&self, data: BlockedDomainsPageData) -> Result<String, String>;
fn render_blocked_actors_page(&self, data: BlockedActorsPageData) -> Result<String, String>; fn render_blocked_actors_page(&self, data: BlockedActorsPageData) -> Result<String, String>;
fn render_watchlist_page(&self, data: WatchlistPageData) -> Result<String, String>; fn render_watchlist_page(&self, data: WatchlistPageData) -> Result<String, String>;

View File

@@ -10,12 +10,15 @@ use domain::{
pub struct SearchCleanupHandler { pub struct SearchCleanupHandler {
search_command: Arc<dyn SearchCommand>, search_command: Arc<dyn SearchCommand>,
person_query: Arc<dyn PersonQuery>, person_query: Arc<dyn PersonQuery>,
} }
impl SearchCleanupHandler { impl SearchCleanupHandler {
pub fn new(search_command: Arc<dyn SearchCommand>, person_query: Arc<dyn PersonQuery>) -> Self { pub fn new(search_command: Arc<dyn SearchCommand>, person_query: Arc<dyn PersonQuery>) -> Self {
Self { search_command, person_query } Self {
search_command,
person_query,
}
} }
} }
@@ -27,7 +30,11 @@ impl EventHandler for SearchCleanupHandler {
_ => return Ok(()), _ => return Ok(()),
}; };
if let Err(e) = self.search_command.remove(EntityType::Movie, &movie_id).await { if let Err(e) = self
.search_command
.remove(EntityType::Movie, &movie_id)
.await
{
tracing::warn!("search cleanup failed for movie {movie_id}: {e}"); tracing::warn!("search cleanup failed for movie {movie_id}: {e}");
} }
@@ -41,7 +48,9 @@ impl EventHandler for SearchCleanupHandler {
} }
} }
} }
Err(e) => tracing::warn!("failed to list orphaned persons after movie {movie_id} deletion: {e}"), Err(e) => tracing::warn!(
"failed to list orphaned persons after movie {movie_id} deletion: {e}"
),
} }
Ok(()) Ok(())

View File

@@ -52,8 +52,17 @@ impl MovieRepository for RepoWithExternalMovie {
async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> {
panic!("unexpected") panic!("unexpected")
} }
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { panic!("unexpected") } async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> {
async fn list_movies(&self, _: &domain::models::collections::PageParams, _: &domain::models::MovieFilter) -> Result<domain::models::collections::Paginated<domain::models::MovieSummary>, DomainError> { panic!("unexpected") } panic!("unexpected")
}
async fn list_movies(
&self,
_: &domain::models::collections::PageParams,
_: &domain::models::MovieFilter,
) -> Result<domain::models::collections::Paginated<domain::models::MovieSummary>, DomainError>
{
panic!("unexpected")
}
} }
#[async_trait::async_trait] #[async_trait::async_trait]
@@ -74,9 +83,20 @@ impl MovieRepository for RepoEmpty {
) -> Result<Vec<Movie>, DomainError> { ) -> Result<Vec<Movie>, DomainError> {
Ok(vec![]) Ok(vec![])
} }
async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { panic!("unexpected") } async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> {
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { panic!("unexpected") } panic!("unexpected")
async fn list_movies(&self, _: &domain::models::collections::PageParams, _: &domain::models::MovieFilter) -> Result<domain::models::collections::Paginated<domain::models::MovieSummary>, DomainError> { panic!("unexpected") } }
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> {
panic!("unexpected")
}
async fn list_movies(
&self,
_: &domain::models::collections::PageParams,
_: &domain::models::MovieFilter,
) -> Result<domain::models::collections::Paginated<domain::models::MovieSummary>, DomainError>
{
panic!("unexpected")
}
} }
#[async_trait::async_trait] #[async_trait::async_trait]
@@ -97,9 +117,20 @@ impl MovieRepository for RepoWithTitleMatch {
) -> Result<Vec<Movie>, DomainError> { ) -> Result<Vec<Movie>, DomainError> {
Ok(vec![self.0.clone()]) Ok(vec![self.0.clone()])
} }
async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { panic!("unexpected") } async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> {
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { panic!("unexpected") } panic!("unexpected")
async fn list_movies(&self, _: &domain::models::collections::PageParams, _: &domain::models::MovieFilter) -> Result<domain::models::collections::Paginated<domain::models::MovieSummary>, DomainError> { panic!("unexpected") } }
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> {
panic!("unexpected")
}
async fn list_movies(
&self,
_: &domain::models::collections::PageParams,
_: &domain::models::MovieFilter,
) -> Result<domain::models::collections::Paginated<domain::models::MovieSummary>, DomainError>
{
panic!("unexpected")
}
} }
struct MetaReturnsMovie(Movie); struct MetaReturnsMovie(Movie);
@@ -107,10 +138,7 @@ struct MetaErrors;
#[async_trait::async_trait] #[async_trait::async_trait]
impl MetadataClient for MetaReturnsMovie { impl MetadataClient for MetaReturnsMovie {
async fn fetch_movie_metadata( async fn fetch_movie_metadata(&self, _: &MetadataSearchCriteria) -> Result<Movie, DomainError> {
&self,
_: &MetadataSearchCriteria,
) -> Result<Movie, DomainError> {
Ok(self.0.clone()) Ok(self.0.clone())
} }
async fn get_poster_url( async fn get_poster_url(
@@ -123,10 +151,7 @@ impl MetadataClient for MetaReturnsMovie {
#[async_trait::async_trait] #[async_trait::async_trait]
impl MetadataClient for MetaErrors { impl MetadataClient for MetaErrors {
async fn fetch_movie_metadata( async fn fetch_movie_metadata(&self, _: &MetadataSearchCriteria) -> Result<Movie, DomainError> {
&self,
_: &MetadataSearchCriteria,
) -> Result<Movie, DomainError> {
Err(DomainError::InfrastructureError( Err(DomainError::InfrastructureError(
"metadata unavailable".into(), "metadata unavailable".into(),
)) ))
@@ -299,7 +324,9 @@ async fn resolver_returns_error_when_no_strategy_matches() {
metadata_client: &meta, metadata_client: &meta,
}; };
let input = make_input(None, None, None); let input = make_input(None, None, None);
let result = MovieResolver::default_pipeline().resolve(&input, &deps).await; let result = MovieResolver::default_pipeline()
.resolve(&input, &deps)
.await;
assert!(result.is_err()); assert!(result.is_err());
} }

View File

@@ -31,10 +31,13 @@ pub async fn execute(ctx: &AppContext, cmd: AddToWatchlistCommand) -> Result<(),
if is_new { if is_new {
ctx.movie_repository.upsert_movie(&movie).await?; ctx.movie_repository.upsert_movie(&movie).await?;
if let Some(ext_id) = movie.external_metadata_id() { if let Some(ext_id) = movie.external_metadata_id() {
let _ = ctx.event_publisher.publish(&DomainEvent::MovieDiscovered { let _ = ctx
movie_id: movie.id().clone(), .event_publisher
external_metadata_id: ext_id.clone(), .publish(&DomainEvent::MovieDiscovered {
}).await; movie_id: movie.id().clone(),
external_metadata_id: ext_id.clone(),
})
.await;
} }
} }
movie movie
@@ -43,14 +46,17 @@ pub async fn execute(ctx: &AppContext, cmd: AddToWatchlistCommand) -> Result<(),
let entry = WatchlistEntry::new(user_id.clone(), movie.id().clone()); let entry = WatchlistEntry::new(user_id.clone(), movie.id().clone());
ctx.watchlist_repository.add(&entry).await?; ctx.watchlist_repository.add(&entry).await?;
let _ = ctx.event_publisher.publish(&DomainEvent::WatchlistEntryAdded { let _ = ctx
user_id, .event_publisher
movie_id: movie.id().clone(), .publish(&DomainEvent::WatchlistEntryAdded {
movie_title: movie.title().value().to_string(), user_id,
release_year: movie.release_year().value(), movie_id: movie.id().clone(),
external_metadata_id: movie.external_metadata_id().map(|e| e.value().to_string()), movie_title: movie.title().value().to_string(),
added_at: entry.added_at, release_year: movie.release_year().value(),
}).await; external_metadata_id: movie.external_metadata_id().map(|e| e.value().to_string()),
added_at: entry.added_at,
})
.await;
Ok(()) Ok(())
} }

View File

@@ -6,17 +6,23 @@ use domain::{
use crate::{commands::ApplyImportMappingCommand, context::AppContext}; use crate::{commands::ApplyImportMappingCommand, context::AppContext};
pub async fn execute(ctx: &AppContext, cmd: ApplyImportMappingCommand) -> Result<Vec<AnnotatedRow>, DomainError> { pub async fn execute(
ctx: &AppContext,
cmd: ApplyImportMappingCommand,
) -> Result<Vec<AnnotatedRow>, DomainError> {
let user_id = UserId::from_uuid(cmd.user_id); let user_id = UserId::from_uuid(cmd.user_id);
let session_id = ImportSessionId::from_uuid(cmd.session_id); let session_id = ImportSessionId::from_uuid(cmd.session_id);
let mappings = cmd.mappings; let mappings = cmd.mappings;
let mut session = ctx.import_session_repository let mut session = ctx
.import_session_repository
.get(&session_id, &user_id) .get(&session_id, &user_id)
.await? .await?
.ok_or_else(|| DomainError::NotFound("import session".into()))?; .ok_or_else(|| DomainError::NotFound("import session".into()))?;
// clone to avoid borrow conflict when mutating session fields below // clone to avoid borrow conflict when mutating session fields below
let parsed = session.parsed_file.clone() let parsed = session
.parsed_file
.clone()
.ok_or_else(|| DomainError::ValidationError("session has no parsed file".into()))?; .ok_or_else(|| DomainError::ValidationError("session has no parsed file".into()))?;
let mut annotated = ctx.document_parser.apply_mapping(&parsed, &mappings); let mut annotated = ctx.document_parser.apply_mapping(&parsed, &mappings);
@@ -35,17 +41,31 @@ pub async fn execute(ctx: &AppContext, cmd: ApplyImportMappingCommand) -> Result
Ok(annotated) Ok(annotated)
} }
async fn check_duplicate(ctx: &AppContext, row: &domain::models::ImportRow) -> Result<bool, DomainError> { async fn check_duplicate(
ctx: &AppContext,
row: &domain::models::ImportRow,
) -> Result<bool, DomainError> {
if let Some(ext_id) = &row.external_metadata_id if let Some(ext_id) = &row.external_metadata_id
&& let Ok(eid) = ExternalMetadataId::new(ext_id.clone()) && let Ok(eid) = ExternalMetadataId::new(ext_id.clone())
&& ctx.movie_repository.get_movie_by_external_id(&eid).await?.is_some() { && ctx
return Ok(true); .movie_repository
} .get_movie_by_external_id(&eid)
.await?
.is_some()
{
return Ok(true);
}
if let (Some(title), Some(year_str)) = (&row.title, &row.release_year) { if let (Some(title), Some(year_str)) = (&row.title, &row.release_year) {
let title_vo = MovieTitle::new(title.clone()); let title_vo = MovieTitle::new(title.clone());
let year_vo = year_str.parse::<u16>().ok().and_then(|y| ReleaseYear::new(y).ok()); let year_vo = year_str
.parse::<u16>()
.ok()
.and_then(|y| ReleaseYear::new(y).ok());
if let (Ok(t), Some(y)) = (title_vo, year_vo) { if let (Ok(t), Some(y)) = (title_vo, year_vo) {
let matches = ctx.movie_repository.get_movies_by_title_and_year(&t, &y).await?; let matches = ctx
.movie_repository
.get_movies_by_title_and_year(&t, &y)
.await?;
if !matches.is_empty() { if !matches.is_empty() {
return Ok(true); return Ok(true);
} }

View File

@@ -1,5 +1,8 @@
use domain::{errors::DomainError, value_objects::{ImportProfileId, ImportSessionId, UserId}};
use crate::{commands::ApplyImportProfileCommand, context::AppContext}; use crate::{commands::ApplyImportProfileCommand, context::AppContext};
use domain::{
errors::DomainError,
value_objects::{ImportProfileId, ImportSessionId, UserId},
};
/// Copies the profile's field_mappings onto the session. Caller must then invoke /// Copies the profile's field_mappings onto the session. Caller must then invoke
/// apply_import_mapping to regenerate row_results with the new mappings. /// apply_import_mapping to regenerate row_results with the new mappings.
@@ -8,11 +11,15 @@ pub async fn execute(ctx: &AppContext, cmd: ApplyImportProfileCommand) -> Result
let session_id = ImportSessionId::from_uuid(cmd.session_id); let session_id = ImportSessionId::from_uuid(cmd.session_id);
let profile_id = ImportProfileId::from_uuid(cmd.profile_id); let profile_id = ImportProfileId::from_uuid(cmd.profile_id);
let profile = ctx.import_profile_repository let profile = ctx
.get(&profile_id, &user_id).await? .import_profile_repository
.get(&profile_id, &user_id)
.await?
.ok_or_else(|| DomainError::NotFound("import profile".into()))?; .ok_or_else(|| DomainError::NotFound("import profile".into()))?;
let mut session = ctx.import_session_repository let mut session = ctx
.get(&session_id, &user_id).await? .import_session_repository
.get(&session_id, &user_id)
.await?
.ok_or_else(|| DomainError::NotFound("import session".into()))?; .ok_or_else(|| DomainError::NotFound("import session".into()))?;
session.field_mappings = Some(profile.field_mappings); session.field_mappings = Some(profile.field_mappings);
session.row_results = None; session.row_results = None;

View File

@@ -1,5 +1,5 @@
use domain::errors::DomainError;
use crate::context::AppContext; use crate::context::AppContext;
use domain::errors::DomainError;
pub async fn execute(ctx: &AppContext) -> Result<u64, DomainError> { pub async fn execute(ctx: &AppContext) -> Result<u64, DomainError> {
ctx.import_session_repository.delete_expired().await ctx.import_session_repository.delete_expired().await

View File

@@ -13,11 +13,17 @@ pub struct CreateSessionResult {
pub sample_rows: Vec<Vec<String>>, pub sample_rows: Vec<Vec<String>>,
} }
pub async fn execute(ctx: &AppContext, cmd: CreateImportSessionCommand) -> Result<CreateSessionResult, DomainError> { pub async fn execute(
ctx: &AppContext,
cmd: CreateImportSessionCommand,
) -> Result<CreateSessionResult, DomainError> {
let user_id = UserId::from_uuid(cmd.user_id); let user_id = UserId::from_uuid(cmd.user_id);
ctx.import_session_repository.delete_expired_for_user(&user_id).await?; ctx.import_session_repository
.delete_expired_for_user(&user_id)
.await?;
let parsed = ctx.document_parser let parsed = ctx
.document_parser
.parse(&cmd.bytes, cmd.format) .parse(&cmd.bytes, cmd.format)
.map_err(|e| DomainError::ValidationError(e.to_string()))?; .map_err(|e| DomainError::ValidationError(e.to_string()))?;
@@ -31,5 +37,9 @@ pub async fn execute(ctx: &AppContext, cmd: CreateImportSessionCommand) -> Resul
ctx.import_session_repository.create(&session).await?; ctx.import_session_repository.create(&session).await?;
Ok(CreateSessionResult { session_id, columns, sample_rows }) Ok(CreateSessionResult {
session_id,
columns,
sample_rows,
})
} }

View File

@@ -1,12 +1,16 @@
use domain::{errors::DomainError, value_objects::{ImportProfileId, UserId}};
use crate::{commands::DeleteImportProfileCommand, context::AppContext}; use crate::{commands::DeleteImportProfileCommand, context::AppContext};
use domain::{
errors::DomainError,
value_objects::{ImportProfileId, UserId},
};
pub async fn execute(ctx: &AppContext, cmd: DeleteImportProfileCommand) -> Result<(), DomainError> { pub async fn execute(ctx: &AppContext, cmd: DeleteImportProfileCommand) -> Result<(), DomainError> {
let user_id = UserId::from_uuid(cmd.user_id); let user_id = UserId::from_uuid(cmd.user_id);
let profile_id = ImportProfileId::from_uuid(cmd.profile_id); let profile_id = ImportProfileId::from_uuid(cmd.profile_id);
ctx.import_profile_repository ctx.import_profile_repository
.get(&profile_id, &user_id).await? .get(&profile_id, &user_id)
.await?
.ok_or_else(|| DomainError::NotFound("import profile".into()))?; .ok_or_else(|| DomainError::NotFound("import profile".into()))?;
ctx.import_profile_repository.delete(&profile_id).await ctx.import_profile_repository.delete(&profile_id).await
} }

View File

@@ -38,8 +38,12 @@ pub async fn execute(ctx: &AppContext, cmd: DeleteReviewCommand) -> Result<(), D
let poster_path = history.movie().poster_path().cloned(); let poster_path = history.movie().poster_path().cloned();
ctx.movie_repository.delete_movie(&movie_id).await?; ctx.movie_repository.delete_movie(&movie_id).await?;
// best-effort: movie is already deleted, so publish failure is non-fatal // best-effort: movie is already deleted, so publish failure is non-fatal
if let Err(e) = ctx.event_publisher if let Err(e) = ctx
.publish(&DomainEvent::MovieDeleted { movie_id, poster_path }) .event_publisher
.publish(&DomainEvent::MovieDeleted {
movie_id,
poster_path,
})
.await .await
{ {
tracing::warn!("failed to publish MovieDeleted event: {e}"); tracing::warn!("failed to publish MovieDeleted event: {e}");

View File

@@ -3,9 +3,7 @@ use std::sync::Arc;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::{ models::{CastMember, CrewMember, ExternalPersonId, IndexableDocument, Person, PersonId},
CastMember, CrewMember, ExternalPersonId, IndexableDocument, Person, PersonId,
},
ports::{MovieProfileRepository, MovieRepository, PersonCommand, SearchCommand}, ports::{MovieProfileRepository, MovieRepository, PersonCommand, SearchCommand},
}; };

View File

@@ -6,7 +6,11 @@ use domain::{
}; };
use uuid::Uuid; use uuid::Uuid;
use crate::{commands::{ExecuteImportCommand, LogReviewCommand, MovieInput}, context::AppContext, use_cases::log_review}; use crate::{
commands::{ExecuteImportCommand, LogReviewCommand, MovieInput},
context::AppContext,
use_cases::log_review,
};
pub struct ImportSummary { pub struct ImportSummary {
pub imported: usize, pub imported: usize,
@@ -14,11 +18,15 @@ pub struct ImportSummary {
pub failed: Vec<(usize, String)>, pub failed: Vec<(usize, String)>,
} }
pub async fn execute(ctx: &AppContext, cmd: ExecuteImportCommand) -> Result<ImportSummary, DomainError> { pub async fn execute(
ctx: &AppContext,
cmd: ExecuteImportCommand,
) -> Result<ImportSummary, DomainError> {
let user_id = UserId::from_uuid(cmd.user_id); let user_id = UserId::from_uuid(cmd.user_id);
let session_id = ImportSessionId::from_uuid(cmd.session_id); let session_id = ImportSessionId::from_uuid(cmd.session_id);
let confirmed_indices = cmd.confirmed_indices; let confirmed_indices = cmd.confirmed_indices;
let session = ctx.import_session_repository let session = ctx
.import_session_repository
.get(&session_id, &user_id) .get(&session_id, &user_id)
.await? .await?
.ok_or_else(|| DomainError::NotFound("import session".into()))?; .ok_or_else(|| DomainError::NotFound("import session".into()))?;
@@ -36,17 +44,13 @@ pub async fn execute(ctx: &AppContext, cmd: ExecuteImportCommand) -> Result<Impo
continue; continue;
} }
match annotated.result { match annotated.result {
RowResult::Valid(row) => { RowResult::Valid(row) => match row_to_command(&row, user_id.value()) {
match row_to_command(&row, user_id.value()) { Ok(cmd) => match log_review::execute(ctx, cmd).await {
Ok(cmd) => { Ok(_) => imported += 1,
match log_review::execute(ctx, cmd).await { Err(e) => failed.push((idx, e.to_string())),
Ok(_) => imported += 1, },
Err(e) => failed.push((idx, e.to_string())), Err(e) => failed.push((idx, e)),
} },
}
Err(e) => failed.push((idx, e)),
}
}
RowResult::Invalid { errors, .. } => { RowResult::Invalid { errors, .. } => {
failed.push((idx, errors.join("; "))); failed.push((idx, errors.join("; ")));
} }
@@ -55,20 +59,27 @@ pub async fn execute(ctx: &AppContext, cmd: ExecuteImportCommand) -> Result<Impo
ctx.import_session_repository.delete(&session_id).await?; ctx.import_session_repository.delete(&session_id).await?;
Ok(ImportSummary { imported, skipped_duplicates, failed }) Ok(ImportSummary {
imported,
skipped_duplicates,
failed,
})
} }
fn row_to_command(row: &ImportRow, user_id: Uuid) -> Result<LogReviewCommand, String> { fn row_to_command(row: &ImportRow, user_id: Uuid) -> Result<LogReviewCommand, String> {
let rating = row.rating.as_deref() let rating = row
.rating
.as_deref()
.ok_or("missing rating")? .ok_or("missing rating")?
.parse::<u8>() .parse::<u8>()
.map_err(|_| "rating is not a valid u8".to_string())?; .map_err(|_| "rating is not a valid u8".to_string())?;
let watched_at_str = row.watched_at.as_deref().ok_or("missing watched_at")?; let watched_at_str = row.watched_at.as_deref().ok_or("missing watched_at")?;
let watched_at = NaiveDateTime::parse_from_str(&format!("{} 00:00:00", watched_at_str), "%Y-%m-%d %H:%M:%S") let watched_at =
.or_else(|_| NaiveDateTime::parse_from_str(watched_at_str, "%Y-%m-%d %H:%M:%S")) NaiveDateTime::parse_from_str(&format!("{} 00:00:00", watched_at_str), "%Y-%m-%d %H:%M:%S")
.or_else(|_| NaiveDateTime::parse_from_str(watched_at_str, "%Y-%m-%dT%H:%M:%S")) .or_else(|_| NaiveDateTime::parse_from_str(watched_at_str, "%Y-%m-%d %H:%M:%S"))
.map_err(|_| format!("cannot parse watched_at: '{}'", watched_at_str))?; .or_else(|_| NaiveDateTime::parse_from_str(watched_at_str, "%Y-%m-%dT%H:%M:%S"))
.map_err(|_| format!("cannot parse watched_at: '{}'", watched_at_str))?;
Ok(LogReviewCommand { Ok(LogReviewCommand {
user_id, user_id,

View File

@@ -1,6 +1,9 @@
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::{FeedEntry, Movie, MovieProfile, MovieStats, collections::{PageParams, Paginated}}, models::{
FeedEntry, Movie, MovieProfile, MovieStats,
collections::{PageParams, Paginated},
},
value_objects::MovieId, value_objects::MovieId,
}; };
@@ -32,5 +35,10 @@ pub async fn execute(
ctx.movie_profile_repository.get_by_movie_id(&movie_id), ctx.movie_profile_repository.get_by_movie_id(&movie_id),
)?; )?;
Ok(MovieSocialPageResult { movie, stats, reviews, profile }) Ok(MovieSocialPageResult {
movie,
stats,
reviews,
profile,
})
} }

View File

@@ -6,7 +6,10 @@ use domain::{
use crate::{context::AppContext, queries::GetMoviesQuery}; use crate::{context::AppContext, queries::GetMoviesQuery};
pub async fn execute(ctx: &AppContext, query: GetMoviesQuery) -> Result<Paginated<MovieSummary>, DomainError> { pub async fn execute(
ctx: &AppContext,
query: GetMoviesQuery,
) -> Result<Paginated<MovieSummary>, DomainError> {
let page = PageParams::new(query.limit, query.offset)?; let page = PageParams::new(query.limit, query.offset)?;
let filter = MovieFilter { let filter = MovieFilter {
search: query.search, search: query.search,

View File

@@ -1,5 +1,8 @@
use domain::{errors::DomainError, models::{Person, PersonId}};
use crate::context::AppContext; use crate::context::AppContext;
use domain::{
errors::DomainError,
models::{Person, PersonId},
};
pub async fn execute(ctx: &AppContext, id: PersonId) -> Result<Option<Person>, DomainError> { pub async fn execute(ctx: &AppContext, id: PersonId) -> Result<Option<Person>, DomainError> {
ctx.person_query.get_by_id(&id).await ctx.person_query.get_by_id(&id).await

View File

@@ -1,5 +1,8 @@
use domain::{errors::DomainError, models::{PersonCredits, PersonId}};
use crate::context::AppContext; use crate::context::AppContext;
use domain::{
errors::DomainError,
models::{PersonCredits, PersonId},
};
pub async fn execute(ctx: &AppContext, id: PersonId) -> Result<PersonCredits, DomainError> { pub async fn execute(ctx: &AppContext, id: PersonId) -> Result<PersonCredits, DomainError> {
ctx.person_query.get_credits(&id).await ctx.person_query.get_credits(&id).await

View File

@@ -2,6 +2,11 @@ use domain::{errors::DomainError, models::RemoteWatchlistEntry};
use crate::context::AppContext; use crate::context::AppContext;
pub async fn execute(ctx: &AppContext, uuid: uuid::Uuid) -> Result<Vec<RemoteWatchlistEntry>, DomainError> { pub async fn execute(
ctx.remote_watchlist_repository.get_by_derived_uuid(uuid).await ctx: &AppContext,
uuid: uuid::Uuid,
) -> Result<Vec<RemoteWatchlistEntry>, DomainError> {
ctx.remote_watchlist_repository
.get_by_derived_uuid(uuid)
.await
} }

View File

@@ -1,6 +1,9 @@
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::{WatchlistWithMovie, collections::{PageParams, Paginated}}, models::{
WatchlistWithMovie,
collections::{PageParams, Paginated},
},
value_objects::UserId, value_objects::UserId,
}; };

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