From 5ae38c834a89c0e70f1d259414ac7d5f3c08851c Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Tue, 30 Jun 2026 02:25:42 +0200 Subject: [PATCH] making it AP complaint --- Cargo.lock | 8 +- crates/adapters/activitypub/Cargo.toml | 2 +- .../activitypub/src/composite_handler.rs | 72 +- .../adapters/activitypub/src/event_handler.rs | 91 +- .../adapters/activitypub/src/goal_handler.rs | 52 +- crates/adapters/activitypub/src/lib.rs | 7 +- crates/adapters/activitypub/src/objects.rs | 66 +- .../src/remote_review_repository.rs | 1 + .../activitypub/src/review_handler.rs | 62 +- .../adapters/activitypub/src/tests/objects.rs | 30 +- .../activitypub/src/watchlist_handler.rs | 109 +- crates/adapters/event-payload/src/lib.rs | 29 + crates/adapters/metadata/src/lib.rs | 6 +- crates/adapters/nats/src/subject.rs | 2 + .../adapters/postgres-federation/Cargo.toml | 2 +- .../postgres-federation/src/activity.rs | 29 + .../adapters/postgres-federation/src/actor.rs | 100 ++ .../src/ap_content.rs | 242 +++- .../postgres-federation/src/blocklist.rs | 87 ++ .../postgres-federation/src/follow.rs | 321 +++++ .../adapters/postgres-federation/src/lib.rs | 904 +------------ .../src/remote_goals.rs | 0 .../postgres-federation/src/review.rs | 116 ++ .../postgres-federation/src/social.rs | 81 ++ .../postgres-federation/src/watchlist.rs | 91 ++ crates/adapters/postgres/Cargo.toml | 1 + crates/adapters/postgres/src/lib.rs | 13 +- crates/adapters/postgres/src/movie.rs | 13 + crates/adapters/postgres/src/movie_dedup.rs | 154 +++ crates/adapters/sqlite-federation/Cargo.toml | 2 +- .../sqlite-federation/src/activity.rs | 27 + .../adapters/sqlite-federation/src/actor.rs | 117 ++ .../sqlite-federation/src/ap_content.rs | 463 +++++++ .../sqlite-federation/src/blocklist.rs | 98 ++ .../adapters/sqlite-federation/src/follow.rs | 406 ++++++ crates/adapters/sqlite-federation/src/lib.rs | 1169 +---------------- .../src/remote_goals.rs | 0 .../adapters/sqlite-federation/src/review.rs | 116 ++ .../adapters/sqlite-federation/src/social.rs | 94 ++ .../src/tests/actor_block_tests.rs | 1 + .../src/tests/domain_block_tests.rs | 1 + .../sqlite-federation/src/tests/lib.rs | 1 + .../sqlite-federation/src/tests/outbox_url.rs | 76 ++ .../sqlite-federation/src/watchlist.rs | 100 ++ crates/adapters/sqlite/Cargo.toml | 1 + crates/adapters/sqlite/src/ap_content.rs | 197 --- crates/adapters/sqlite/src/lib.rs | 12 +- crates/adapters/sqlite/src/movie.rs | 13 + crates/adapters/sqlite/src/movie_dedup.rs | 149 +++ .../src/diary/tests/movie_resolver.rs | 15 + crates/application/src/jobs/mod.rs | 2 + crates/application/src/jobs/movie_dedup.rs | 49 + .../src/movies/merge_duplicates.rs | 90 ++ crates/application/src/movies/mod.rs | 1 + crates/application/src/tests/worker.rs | 3 + .../application/src/users/delete_account.rs | 19 + crates/application/src/users/mod.rs | 1 + .../application/src/users/update_profile.rs | 22 +- crates/domain/src/events.rs | 7 + crates/domain/src/ports/movie.rs | 14 + crates/domain/src/ports/social.rs | 5 + crates/domain/src/testing/in_memory.rs | 11 + crates/domain/src/value_objects/ids.rs | 13 + crates/presentation/src/tests/extractors.rs | 5 + crates/worker/src/db.rs | 7 +- crates/worker/src/main.rs | 6 + 66 files changed, 3635 insertions(+), 2369 deletions(-) create mode 100644 crates/adapters/postgres-federation/src/activity.rs create mode 100644 crates/adapters/postgres-federation/src/actor.rs rename crates/adapters/{postgres => postgres-federation}/src/ap_content.rs (56%) create mode 100644 crates/adapters/postgres-federation/src/blocklist.rs create mode 100644 crates/adapters/postgres-federation/src/follow.rs rename crates/adapters/{postgres => postgres-federation}/src/remote_goals.rs (100%) create mode 100644 crates/adapters/postgres-federation/src/review.rs create mode 100644 crates/adapters/postgres-federation/src/social.rs create mode 100644 crates/adapters/postgres-federation/src/watchlist.rs create mode 100644 crates/adapters/postgres/src/movie_dedup.rs create mode 100644 crates/adapters/sqlite-federation/src/activity.rs create mode 100644 crates/adapters/sqlite-federation/src/actor.rs create mode 100644 crates/adapters/sqlite-federation/src/ap_content.rs create mode 100644 crates/adapters/sqlite-federation/src/blocklist.rs create mode 100644 crates/adapters/sqlite-federation/src/follow.rs rename crates/adapters/{sqlite => sqlite-federation}/src/remote_goals.rs (100%) create mode 100644 crates/adapters/sqlite-federation/src/review.rs create mode 100644 crates/adapters/sqlite-federation/src/social.rs create mode 100644 crates/adapters/sqlite-federation/src/tests/outbox_url.rs create mode 100644 crates/adapters/sqlite-federation/src/watchlist.rs delete mode 100644 crates/adapters/sqlite/src/ap_content.rs create mode 100644 crates/adapters/sqlite/src/movie_dedup.rs create mode 100644 crates/application/src/jobs/movie_dedup.rs create mode 100644 crates/application/src/movies/merge_duplicates.rs create mode 100644 crates/application/src/users/delete_account.rs diff --git a/Cargo.lock b/Cargo.lock index 5f5191f..ed0b4ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2871,9 +2871,9 @@ dependencies = [ [[package]] name = "k-ap" -version = "0.4.0" +version = "0.4.1" source = "sparse+https://git.gabrielkaszewski.dev/api/packages/GKaszewski/cargo/" -checksum = "ccaa914953bfd45ea206e11826da8f61ce1fbe02f8fe0622880527046ad6ae24" +checksum = "03e39c04075b39960c329feba896a16aba37f0863669c28e7106b7cc45a9988d" dependencies = [ "activitypub_federation", "anyhow", @@ -3881,6 +3881,7 @@ dependencies = [ "chrono", "domain", "futures", + "postgres-federation", "serde", "serde_json", "sqlx", @@ -5162,6 +5163,7 @@ dependencies = [ "futures", "serde", "serde_json", + "sqlite-federation", "sqlx", "tokio", "tracing", @@ -6685,7 +6687,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/crates/adapters/activitypub/Cargo.toml b/crates/adapters/activitypub/Cargo.toml index 260fd52..d11b8e9 100644 --- a/crates/adapters/activitypub/Cargo.toml +++ b/crates/adapters/activitypub/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] -k-ap = { version = "0.4.0", registry = "gitea" } +k-ap = { version = "0.4.1", registry = "gitea" } domain = { workspace = true } axum = { workspace = true } serde = { workspace = true } diff --git a/crates/adapters/activitypub/src/composite_handler.rs b/crates/adapters/activitypub/src/composite_handler.rs index c14e8b1..22a9333 100644 --- a/crates/adapters/activitypub/src/composite_handler.rs +++ b/crates/adapters/activitypub/src/composite_handler.rs @@ -24,9 +24,33 @@ impl ApContentReader for CompositeObjectHandler { before: Option>, limit: usize, ) -> anyhow::Result)>> { - self.review - .get_local_objects_page(user_id, before, limit) - .await + // Fetch from all three sources (watchlist/goals return all, reviews use DB pagination) + let fetch_limit = limit * 3; + let reviews = self + .review + .get_local_objects_page(user_id, before, fetch_limit) + .await?; + let watchlist = self + .watchlist + .get_local_objects_page(user_id, None, usize::MAX) + .await?; + let goals = self + .goal + .get_local_objects_page(user_id, None, usize::MAX) + .await?; + + let mut all: Vec<(Url, serde_json::Value, DateTime)> = Vec::new(); + all.extend(reviews); + all.extend(watchlist); + all.extend(goals); + + // Apply before filter and sort descending by timestamp + if let Some(before_ts) = before { + all.retain(|(_, _, ts)| *ts < before_ts); + } + all.sort_by_key(|b| std::cmp::Reverse(b.2)); + all.truncate(limit); + Ok(all) } async fn count_local_posts(&self) -> anyhow::Result { @@ -42,10 +66,14 @@ impl ApObjectHandler for CompositeObjectHandler { actor_url: &Url, object: serde_json::Value, ) -> anyhow::Result<()> { + let is_review = object.get("review").and_then(|v| v.as_bool()) == Some(true) + || object.get("rating").is_some(); let is_watchlist = object.get("watchlistEntry").and_then(|v| v.as_bool()) == Some(true) - || (object.get("movieTitle").is_some() && object.get("rating").is_none()); + || (object.get("movieTitle").is_some() + && object.get("rating").is_none() + && object.get("review").is_none()); let is_goal = object.get("goal").and_then(|v| v.as_bool()) == Some(true); - if object.get("rating").is_some() { + if is_review { self.review.on_create(ap_id, actor_url, object).await } else if is_goal { self.goal.on_create(ap_id, actor_url, object).await @@ -63,11 +91,16 @@ impl ApObjectHandler for CompositeObjectHandler { actor_url: &Url, object: serde_json::Value, ) -> anyhow::Result<()> { + let is_review = object.get("review").and_then(|v| v.as_bool()) == Some(true) + || object.get("rating").is_some(); let is_goal = object.get("goal").and_then(|v| v.as_bool()) == Some(true); - if object.get("rating").is_some() { + let is_watchlist = object.get("watchlistEntry").and_then(|v| v.as_bool()) == Some(true); + if is_review { self.review.on_update(ap_id, actor_url, object).await } else if is_goal { self.goal.on_update(ap_id, actor_url, object).await + } else if is_watchlist { + self.watchlist.on_update(ap_id, actor_url, object).await } else { Ok(()) } @@ -87,36 +120,19 @@ impl ApObjectHandler for CompositeObjectHandler { Ok(()) } - async fn on_like(&self, _object_url: &Url, _actor_url: &Url) -> anyhow::Result<()> { + async fn on_like(&self, _: &Url, _: &Url) -> anyhow::Result<()> { Ok(()) } - - async fn on_announce_received( - &self, - _object_url: &Url, - _actor_url: &Url, - ) -> anyhow::Result<()> { + async fn on_announce_received(&self, _: &Url, _: &Url) -> anyhow::Result<()> { Ok(()) } - - async fn on_announce_of_remote( - &self, - _object_url: &Url, - _actor_url: &Url, - ) -> anyhow::Result<()> { + async fn on_announce_of_remote(&self, _: &Url, _: &Url) -> anyhow::Result<()> { Ok(()) } - - async fn on_unlike(&self, _object_url: &Url, _actor_url: &Url) -> anyhow::Result<()> { + async fn on_unlike(&self, _: &Url, _: &Url) -> anyhow::Result<()> { Ok(()) } - - async fn on_mention( - &self, - _thought_ap_id: &Url, - _mentioned_user_uuid: uuid::Uuid, - _actor_url: &Url, - ) -> anyhow::Result<()> { + async fn on_mention(&self, _: &Url, _: uuid::Uuid, _: &Url) -> anyhow::Result<()> { Ok(()) } } diff --git a/crates/adapters/activitypub/src/event_handler.rs b/crates/adapters/activitypub/src/event_handler.rs index f94c500..41e0bea 100644 --- a/crates/adapters/activitypub/src/event_handler.rs +++ b/crates/adapters/activitypub/src/event_handler.rs @@ -11,7 +11,7 @@ use std::sync::Arc; use k_ap::{ActivityPubService, ApVisibility}; -use crate::objects::{goal_to_ap_object, review_to_ap_object}; +use crate::objects::{ReviewApInput, goal_to_ap_object, review_to_ap_object}; use crate::urls::{actor_url, goal_url, review_url}; pub struct ActivityPubEventHandler { @@ -127,6 +127,25 @@ impl EventHandler for ActivityPubEventHandler { .on_goal_deleted(user_id, *year) .await .map_err(|e| DomainError::InfrastructureError(e.to_string())), + DomainEvent::UserDeleted { user_id } => { + let ap_id = actor_url(&self.base_url, user_id.value()); + self.ap_service + .broadcast_delete_to_followers(user_id.value(), ap_id) + .await + .map_err(|e| DomainError::InfrastructureError(e.to_string())) + } + DomainEvent::UserAccountMoved { + user_id, + new_actor_url, + } => { + let target = new_actor_url.parse::().map_err(|e| { + DomainError::InfrastructureError(format!("invalid new_actor_url: {e}")) + })?; + self.ap_service + .broadcast_move(user_id.value(), target) + .await + .map_err(|e| DomainError::InfrastructureError(e.to_string())) + } _ => Ok(()), } } @@ -169,19 +188,23 @@ impl ActivityPubEventHandler { .as_ref() .map(|m| m.release_year().value()) .unwrap_or(0); - let poster_url = movie - .as_ref() - .and_then(|m| m.poster_path()) - .map(|p| format!("{}/images/{}", self.base_url, p.value())); - let obj = review_to_ap_object( &review, - ap_id.clone(), - actor, - movie_title, - release_year, - poster_url, - &self.base_url, + ReviewApInput { + ap_id: ap_id.clone(), + actor_url: actor, + movie_title, + release_year, + external_metadata_id: movie + .as_ref() + .and_then(|m| m.external_metadata_id()) + .map(|id| id.value().to_string()), + poster_url: movie + .as_ref() + .and_then(|m| m.poster_path()) + .map(|p| format!("{}/images/{}", self.base_url, p.value())), + base_url: self.base_url.clone(), + }, ); let json = serde_json::to_value(obj)?; @@ -235,19 +258,23 @@ impl ActivityPubEventHandler { .as_ref() .map(|m| m.release_year().value()) .unwrap_or(0); - let poster_url = movie - .as_ref() - .and_then(|m| m.poster_path()) - .map(|p| format!("{}/images/{}", self.base_url, p.value())); - let obj = review_to_ap_object( &review, - ap_id, - actor, - movie_title, - release_year, - poster_url, - &self.base_url, + ReviewApInput { + ap_id, + actor_url: actor, + movie_title, + release_year, + external_metadata_id: movie + .as_ref() + .and_then(|m| m.external_metadata_id()) + .map(|id| id.value().to_string()), + poster_url: movie + .as_ref() + .and_then(|m| m.poster_path()) + .map(|p| format!("{}/images/{}", self.base_url, p.value())), + base_url: self.base_url.clone(), + }, ); let json = serde_json::to_value(obj)?; @@ -351,6 +378,9 @@ impl ActivityPubEventHandler { Some(m) => m, None => return Ok(()), }; + let external_metadata_id = movie + .external_metadata_id() + .map(|id| id.value().to_string()); let poster_url = movie .poster_path() .map(|p| format!("{}/images/{}", self.base_url, p.value())); @@ -377,12 +407,15 @@ impl ActivityPubEventHandler { let obj = review_to_ap_object( review, - ap_id, - actor, - movie.title().value().to_string(), - movie.release_year().value(), - poster_url.clone(), - &self.base_url, + ReviewApInput { + ap_id, + actor_url: actor, + movie_title: movie.title().value().to_string(), + release_year: movie.release_year().value(), + external_metadata_id: external_metadata_id.clone(), + poster_url: poster_url.clone(), + base_url: self.base_url.clone(), + }, ); let json = serde_json::to_value(obj)?; diff --git a/crates/adapters/activitypub/src/goal_handler.rs b/crates/adapters/activitypub/src/goal_handler.rs index 1f9543c..dc2b7f8 100644 --- a/crates/adapters/activitypub/src/goal_handler.rs +++ b/crates/adapters/activitypub/src/goal_handler.rs @@ -1,14 +1,60 @@ use std::sync::Arc; use async_trait::async_trait; -use domain::{models::RemoteGoalEntry, ports::RemoteGoalRepository}; -use k_ap::ApObjectHandler; +use chrono::DateTime; +use domain::{ + models::RemoteGoalEntry, + ports::{LocalApContentQuery, RemoteGoalRepository}, + value_objects::UserId, +}; +use k_ap::{ApContentReader, ApObjectHandler}; use url::Url; -use crate::objects::GoalObject; +use crate::objects::{GoalObject, goal_to_ap_object}; +use crate::urls::{actor_url, goal_url}; pub struct GoalObjectHandler { pub remote_goal_repo: Arc, + pub content_query: Arc, + pub base_url: String, +} + +#[async_trait] +impl ApContentReader for GoalObjectHandler { + async fn get_local_objects_page( + &self, + user_id: uuid::Uuid, + _before: Option>, + _limit: usize, + ) -> anyhow::Result)>> { + let uid = UserId::from_uuid(user_id); + let goals = self + .content_query + .list_goals_for_user(&uid) + .await + .map_err(|e| anyhow::anyhow!(e.to_string()))?; + + let actor = actor_url(&self.base_url, user_id); + let mut results = Vec::new(); + for goal in goals { + let ap_id = goal_url(&self.base_url, user_id, goal.year()); + let published = DateTime::from_naive_utc_and_offset(*goal.created_at(), chrono::Utc); + let obj = goal_to_ap_object( + ap_id.clone(), + actor.clone(), + goal.year(), + goal.target_count(), + 0, + &self.base_url, + ); + results.push((ap_id, serde_json::to_value(obj)?, published)); + } + Ok(results) + } + + async fn count_local_posts(&self) -> anyhow::Result { + Ok(0) + } } #[async_trait] diff --git a/crates/adapters/activitypub/src/lib.rs b/crates/adapters/activitypub/src/lib.rs index f1827e8..fcfcd8d 100644 --- a/crates/adapters/activitypub/src/lib.rs +++ b/crates/adapters/activitypub/src/lib.rs @@ -77,6 +77,7 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result { let review_handler = std::sync::Arc::new(ReviewObjectHandler { content_query: std::sync::Arc::clone(&local_ap_content), review_store, + event_publisher: std::sync::Arc::clone(&event_publisher), base_url: base_url.clone(), }); let watchlist_handler = std::sync::Arc::new(watchlist_handler::WatchlistObjectHandler { @@ -84,7 +85,11 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result { content_query: std::sync::Arc::clone(&local_ap_content), base_url: base_url.clone(), }); - let goal_handler = std::sync::Arc::new(goal_handler::GoalObjectHandler { remote_goal_repo }); + let goal_handler = std::sync::Arc::new(goal_handler::GoalObjectHandler { + remote_goal_repo, + content_query: std::sync::Arc::clone(&local_ap_content), + base_url: base_url.clone(), + }); let composite = std::sync::Arc::new(composite_handler::CompositeObjectHandler { review: review_handler, watchlist: watchlist_handler, diff --git a/crates/adapters/activitypub/src/objects.rs b/crates/adapters/activitypub/src/objects.rs index 071cb6b..e929868 100644 --- a/crates/adapters/activitypub/src/objects.rs +++ b/crates/adapters/activitypub/src/objects.rs @@ -6,6 +6,18 @@ use url::Url; use domain::models::Review; +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ApAttachment { + #[serde(rename = "type")] + pub(crate) kind: String, + pub(crate) url: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) media_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) name: Option, +} + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ApHashtag { #[serde(rename = "type")] @@ -31,10 +43,17 @@ pub struct ReviewObject { #[serde(default)] pub(crate) release_year: u16, #[serde(default)] + pub(crate) external_metadata_id: Option, + #[serde(default)] pub(crate) poster_url: Option, pub(crate) rating: u8, pub(crate) comment: Option, pub(crate) watched_at: DateTime, + /// Discriminator so Movies Diary instances detect this as a review Note. + #[serde(default)] + pub(crate) review: bool, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub(crate) attachment: Vec, #[serde(default)] pub(crate) tag: Vec, #[serde(skip_serializing_if = "Vec::is_empty", default)] @@ -43,17 +62,26 @@ pub struct ReviewObject { pub(crate) cc: Vec, } -/// Serialize a local Review into a ReviewObject for AP delivery. -/// Takes movie metadata explicitly since the handler fetches it separately. -pub fn review_to_ap_object( - review: &Review, - ap_id: Url, - actor_url: Url, - movie_title: String, - release_year: u16, - poster_url: Option, - base_url: &str, -) -> ReviewObject { +pub struct ReviewApInput { + pub ap_id: Url, + pub actor_url: Url, + pub movie_title: String, + pub release_year: u16, + pub external_metadata_id: Option, + pub poster_url: Option, + pub base_url: String, +} + +pub fn review_to_ap_object(review: &Review, input: ReviewApInput) -> ReviewObject { + let ReviewApInput { + ap_id, + actor_url, + movie_title, + release_year, + external_metadata_id, + poster_url, + base_url, + } = input; let stars: String = "\u{2B50}".repeat(review.rating().value() as usize); let comment_text = review.comment().map(|c| c.value().to_string()); let year_str = if release_year > 0 { @@ -74,16 +102,25 @@ pub fn review_to_ap_object( let tag = vec![ ApHashtag { kind: "Hashtag".to_string(), - href: Url::parse(&format!("{}/tags/moviesdiary", base_url)).expect("valid base_url"), + href: Url::parse(&format!("{}/tags/moviesdiary", &base_url)).expect("valid base_url"), name: "#MoviesDiary".to_string(), }, ApHashtag { kind: "Hashtag".to_string(), - href: Url::parse(&format!("{}/tags/{}", base_url, normalized.to_lowercase())) + href: Url::parse(&format!("{}/tags/{}", &base_url, normalized.to_lowercase())) .expect("valid base_url"), name: format!("#{}", normalized), }, ]; + let attachment = match &poster_url { + Some(url) => vec![ApAttachment { + kind: "Image".to_string(), + url: url.clone(), + media_type: Some("image/jpeg".to_string()), + name: Some(movie_title.clone()), + }], + None => vec![], + }; ReviewObject { kind: NoteType::default(), @@ -93,10 +130,13 @@ pub fn review_to_ap_object( published: DateTime::from_naive_utc_and_offset(*review.created_at(), Utc), movie_title, release_year, + external_metadata_id, poster_url, rating: review.rating().value(), comment: comment_text, watched_at: DateTime::from_naive_utc_and_offset(*review.watched_at(), Utc), + review: true, + attachment, tag, to: vec![AS_PUBLIC.to_string()], cc: vec![format!("{}/followers", actor_url)], diff --git a/crates/adapters/activitypub/src/remote_review_repository.rs b/crates/adapters/activitypub/src/remote_review_repository.rs index e137f84..abb08e1 100644 --- a/crates/adapters/activitypub/src/remote_review_repository.rs +++ b/crates/adapters/activitypub/src/remote_review_repository.rs @@ -11,6 +11,7 @@ pub trait RemoteReviewRepository: Send + Sync { ap_id: &str, movie_title: &str, release_year: u16, + external_metadata_id: Option<&str>, poster_url: Option<&str>, ) -> Result<()>; diff --git a/crates/adapters/activitypub/src/review_handler.rs b/crates/adapters/activitypub/src/review_handler.rs index 4e786cf..63bd495 100644 --- a/crates/adapters/activitypub/src/review_handler.rs +++ b/crates/adapters/activitypub/src/review_handler.rs @@ -2,20 +2,22 @@ use std::sync::Arc; use async_trait::async_trait; use domain::{ + events::DomainEvent, models::ReviewSource, - ports::LocalApContentQuery, - value_objects::{Comment, MovieId, Rating, ReviewId, UserId}, + ports::{EventPublisher, LocalApContentQuery}, + value_objects::{Comment, ExternalMetadataId, MovieId, Rating, ReviewId, UserId}, }; use k_ap::{ApContentReader, ApObjectHandler}; use url::Url; -use crate::objects::{ReviewObject, review_to_ap_object}; +use crate::objects::{ReviewApInput, ReviewObject, review_to_ap_object}; use crate::remote_review_repository::RemoteReviewRepository; use crate::urls::{actor_url, review_url}; pub struct ReviewObjectHandler { pub content_query: Arc, pub review_store: Arc, + pub event_publisher: Arc, pub base_url: String, } @@ -49,12 +51,17 @@ impl ApContentReader for ReviewObjectHandler { let obj = review_to_ap_object( review, - ap_id.clone(), - actor.clone(), - movie.title().value().to_string(), - movie.release_year().value(), - poster_url, - &self.base_url, + ReviewApInput { + ap_id: ap_id.clone(), + actor_url: actor.clone(), + movie_title: movie.title().value().to_string(), + release_year: movie.release_year().value(), + external_metadata_id: movie + .external_metadata_id() + .map(|id| id.value().to_string()), + poster_url, + base_url: self.base_url.clone(), + }, ); results.push((ap_id, serde_json::to_value(obj)?, published)); } @@ -89,10 +96,24 @@ impl ApObjectHandler for ReviewObjectHandler { let actor_url_str = obj.attributed_to.to_string(); let review_id = ReviewId::generate(); - let movie_id = MovieId::from_uuid(uuid::Uuid::new_v5( - &uuid::Uuid::NAMESPACE_URL, - obj.movie_title.as_bytes(), - )); + let movie_id = if let Some(ref ext_id) = obj.external_metadata_id { + match self + .content_query + .get_movie_by_external_metadata_id(ext_id) + .await + { + Ok(Some(movie)) => movie.id().clone(), + _ => MovieId::from_uuid(uuid::Uuid::new_v5( + &uuid::Uuid::NAMESPACE_URL, + ext_id.as_bytes(), + )), + } + } else { + MovieId::from_uuid(uuid::Uuid::new_v5( + &uuid::Uuid::NAMESPACE_URL, + format!("{}:{}", obj.movie_title, obj.release_year).as_bytes(), + )) + }; let user_id = UserId::from_uuid(uuid::Uuid::new_v5( &uuid::Uuid::NAMESPACE_URL, actor_url_str.as_bytes(), @@ -102,7 +123,7 @@ impl ApObjectHandler for ReviewObjectHandler { let review = domain::models::Review::from_persistence(domain::models::PersistedReview { id: review_id, - movie_id, + movie_id: movie_id.clone(), user_id, rating, comment, @@ -119,10 +140,23 @@ impl ApObjectHandler for ReviewObjectHandler { obj.id.as_str(), &obj.movie_title, obj.release_year, + obj.external_metadata_id.as_deref(), obj.poster_url.as_deref(), ) .await?; + if let Some(ref ext_id_str) = obj.external_metadata_id + && let Ok(external_metadata_id) = ExternalMetadataId::new(ext_id_str.clone()) + { + let _ = self + .event_publisher + .publish(&DomainEvent::MovieEnrichmentRequested { + movie_id: movie_id.clone(), + external_metadata_id, + }) + .await; + } + Ok(()) } diff --git a/crates/adapters/activitypub/src/tests/objects.rs b/crates/adapters/activitypub/src/tests/objects.rs index 04bc635..a4854bd 100644 --- a/crates/adapters/activitypub/src/tests/objects.rs +++ b/crates/adapters/activitypub/src/tests/objects.rs @@ -32,12 +32,15 @@ fn review_to_ap_object_includes_two_hashtags() { }); let obj = review_to_ap_object( &review, - "https://example.com/reviews/1".parse().unwrap(), - "https://example.com/users/1".parse().unwrap(), - "Dune".to_string(), - 2021, - None, - "https://example.com", + ReviewApInput { + ap_id: "https://example.com/reviews/1".parse().unwrap(), + actor_url: "https://example.com/users/1".parse().unwrap(), + movie_title: "Dune".to_string(), + release_year: 2021, + external_metadata_id: None, + poster_url: None, + base_url: "https://example.com".to_string(), + }, ); assert_eq!(obj.tag.len(), 2); let names: Vec<&str> = obj.tag.iter().map(|t| t.name.as_str()).collect(); @@ -68,12 +71,15 @@ fn review_to_ap_object_has_public_addressing() { let actor_url: url::Url = "https://example.com/users/abc".parse().unwrap(); let obj = review_to_ap_object( &review, - "https://example.com/reviews/1".parse().unwrap(), - actor_url.clone(), - "Dune".to_string(), - 2021, - None, - "https://example.com", + ReviewApInput { + ap_id: "https://example.com/reviews/1".parse().unwrap(), + actor_url: actor_url.clone(), + movie_title: "Dune".to_string(), + release_year: 2021, + external_metadata_id: None, + poster_url: None, + base_url: "https://example.com".to_string(), + }, ); assert_eq!(obj.to, vec!["https://www.w3.org/ns/activitystreams#Public"]); assert_eq!(obj.cc, vec!["https://example.com/users/abc/followers"]); diff --git a/crates/adapters/activitypub/src/watchlist_handler.rs b/crates/adapters/activitypub/src/watchlist_handler.rs index 3c49ded..538fa4e 100644 --- a/crates/adapters/activitypub/src/watchlist_handler.rs +++ b/crates/adapters/activitypub/src/watchlist_handler.rs @@ -1,14 +1,17 @@ use std::sync::Arc; use async_trait::async_trait; +use chrono::DateTime; use domain::{ - models::RemoteWatchlistEntry, + models::{RemoteWatchlistEntry, WatchlistWithMovie}, ports::{LocalApContentQuery, RemoteWatchlistRepository}, + value_objects::UserId, }; -use k_ap::ApObjectHandler; +use k_ap::{ApContentReader, ApObjectHandler}; use url::Url; -use crate::objects::WatchlistObject; +use crate::objects::{WatchlistApInput, WatchlistObject, watchlist_to_ap_object}; +use crate::urls::{actor_url, watchlist_entry_url}; pub struct WatchlistObjectHandler { pub remote_watchlist_repo: Arc, @@ -16,6 +19,51 @@ pub struct WatchlistObjectHandler { pub base_url: String, } +#[async_trait] +impl ApContentReader for WatchlistObjectHandler { + async fn get_local_objects_page( + &self, + user_id: uuid::Uuid, + _before: Option>, + _limit: usize, + ) -> anyhow::Result)>> { + let uid = UserId::from_uuid(user_id); + let entries = self + .content_query + .get_local_watchlist_for_user(&uid) + .await + .map_err(|e| anyhow::anyhow!(e.to_string()))?; + + let actor = actor_url(&self.base_url, user_id); + let mut results = Vec::new(); + for WatchlistWithMovie { entry, movie } in entries { + let ap_id = watchlist_entry_url(&self.base_url, user_id, entry.movie_id.value()); + let published = DateTime::from_naive_utc_and_offset(entry.added_at, chrono::Utc); + let poster_url = movie + .poster_path() + .map(|p| format!("{}/images/{}", self.base_url, p.value())); + let obj = watchlist_to_ap_object(WatchlistApInput { + ap_id: ap_id.clone(), + actor_url: actor.clone(), + movie_title: movie.title().value().to_string(), + release_year: movie.release_year().value(), + external_metadata_id: movie + .external_metadata_id() + .map(|id| id.value().to_string()), + poster_url, + added_at: published, + base_url: self.base_url.clone(), + }); + results.push((ap_id, serde_json::to_value(obj)?, published)); + } + Ok(results) + } + + async fn count_local_posts(&self) -> anyhow::Result { + Ok(0) + } +} + #[async_trait] impl ApObjectHandler for WatchlistObjectHandler { async fn on_create( @@ -49,10 +97,32 @@ impl ApObjectHandler for WatchlistObjectHandler { async fn on_update( &self, - _ap_id: &Url, - _actor_url: &Url, - _object: serde_json::Value, + ap_id: &Url, + actor_url: &Url, + object: serde_json::Value, ) -> anyhow::Result<()> { + let mut obj: WatchlistObject = match serde_json::from_value(object) { + Ok(o) => o, + Err(e) => { + tracing::warn!(ap_id = %ap_id, "ignoring malformed watchlist Update: {}", e); + return Ok(()); + } + }; + if obj.attributed_to != *actor_url { + anyhow::bail!("watchlist Update actor does not match object attributed_to"); + } + obj.movie_title = ammonia::clean(&obj.movie_title); + let entry = RemoteWatchlistEntry { + ap_id: ap_id.as_str().to_string(), + actor_url: actor_url.as_str().to_string(), + movie_title: obj.movie_title, + release_year: obj.release_year, + external_metadata_id: obj.external_metadata_id, + poster_url: obj.poster_url, + added_at: obj.published, + }; + self.remote_watchlist_repo.save(entry).await?; + tracing::info!(ap_id = %ap_id, "updated remote watchlist entry"); Ok(()) } @@ -71,36 +141,19 @@ impl ApObjectHandler for WatchlistObjectHandler { Ok(()) } - async fn on_like(&self, _object_url: &Url, _actor_url: &Url) -> anyhow::Result<()> { + async fn on_like(&self, _: &Url, _: &Url) -> anyhow::Result<()> { Ok(()) } - - async fn on_announce_received( - &self, - _object_url: &Url, - _actor_url: &Url, - ) -> anyhow::Result<()> { + async fn on_announce_received(&self, _: &Url, _: &Url) -> anyhow::Result<()> { Ok(()) } - - async fn on_announce_of_remote( - &self, - _object_url: &Url, - _actor_url: &Url, - ) -> anyhow::Result<()> { + async fn on_announce_of_remote(&self, _: &Url, _: &Url) -> anyhow::Result<()> { Ok(()) } - - async fn on_unlike(&self, _object_url: &Url, _actor_url: &Url) -> anyhow::Result<()> { + async fn on_unlike(&self, _: &Url, _: &Url) -> anyhow::Result<()> { Ok(()) } - - async fn on_mention( - &self, - _thought_ap_id: &Url, - _mentioned_user_uuid: uuid::Uuid, - _actor_url: &Url, - ) -> anyhow::Result<()> { + async fn on_mention(&self, _: &Url, _: uuid::Uuid, _: &Url) -> anyhow::Result<()> { Ok(()) } } diff --git a/crates/adapters/event-payload/src/lib.rs b/crates/adapters/event-payload/src/lib.rs index 6a7e29d..b8afd30 100644 --- a/crates/adapters/event-payload/src/lib.rs +++ b/crates/adapters/event-payload/src/lib.rs @@ -114,6 +114,13 @@ pub enum EventPayload { person_id: String, external_person_id: String, }, + UserDeleted { + user_id: String, + }, + UserAccountMoved { + user_id: String, + new_actor_url: String, + }, } impl EventPayload { @@ -141,6 +148,8 @@ impl EventPayload { EventPayload::GoalUpdated { .. } => "GoalUpdated", EventPayload::GoalDeleted { .. } => "GoalDeleted", EventPayload::PersonEnrichmentRequested { .. } => "PersonEnrichmentRequested", + EventPayload::UserDeleted { .. } => "UserDeleted", + EventPayload::UserAccountMoved { .. } => "UserAccountMoved", } } } @@ -324,6 +333,16 @@ impl From<&DomainEvent> for EventPayload { person_id: person_id.value().to_string(), external_person_id: external_person_id.value().to_string(), }, + DomainEvent::UserDeleted { user_id } => EventPayload::UserDeleted { + user_id: user_id.value().to_string(), + }, + DomainEvent::UserAccountMoved { + user_id, + new_actor_url, + } => EventPayload::UserAccountMoved { + user_id: user_id.value().to_string(), + new_actor_url: new_actor_url.clone(), + }, } } } @@ -517,6 +536,16 @@ impl TryFrom for DomainEvent { person_id: PersonId::from_uuid(parse_uuid(&person_id, "person_id")?), external_person_id: ExternalPersonId::new(external_person_id), }), + EventPayload::UserDeleted { user_id } => Ok(DomainEvent::UserDeleted { + user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?), + }), + EventPayload::UserAccountMoved { + user_id, + new_actor_url, + } => Ok(DomainEvent::UserAccountMoved { + user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?), + new_actor_url, + }), } } } diff --git a/crates/adapters/metadata/src/lib.rs b/crates/adapters/metadata/src/lib.rs index 87923fb..6bd8d02 100644 --- a/crates/adapters/metadata/src/lib.rs +++ b/crates/adapters/metadata/src/lib.rs @@ -3,7 +3,7 @@ use domain::{ errors::DomainError, models::{MetadataSearchCriteria, Movie}, ports::MetadataClient, - value_objects::{ExternalMetadataId, MovieTitle, PosterUrl, ReleaseYear}, + value_objects::{ExternalMetadataId, MovieId, MovieTitle, PosterUrl, ReleaseYear}, }; mod omdb; @@ -47,7 +47,9 @@ impl MetadataClient for MetadataClientImpl { criteria: &MetadataSearchCriteria, ) -> Result { let pm = self.provider.fetch(criteria).await?; - Ok(Movie::new( + let movie_id = MovieId::from_external(&pm.imdb_id); + Ok(Movie::from_persistence( + movie_id, Some(pm.imdb_id), pm.title, pm.release_year, diff --git a/crates/adapters/nats/src/subject.rs b/crates/adapters/nats/src/subject.rs index 0f4c480..f23ff96 100644 --- a/crates/adapters/nats/src/subject.rs +++ b/crates/adapters/nats/src/subject.rs @@ -24,6 +24,8 @@ pub fn event_to_subject(prefix: &str, event: &DomainEvent) -> String { DomainEvent::GoalUpdated { .. } => "goal.updated", DomainEvent::GoalDeleted { .. } => "goal.deleted", DomainEvent::PersonEnrichmentRequested { .. } => "person.enrichment.requested", + DomainEvent::UserDeleted { .. } => "user.deleted", + DomainEvent::UserAccountMoved { .. } => "user.account.moved", }; format!("{prefix}.{suffix}") } diff --git a/crates/adapters/postgres-federation/Cargo.toml b/crates/adapters/postgres-federation/Cargo.toml index 31e546b..7a43a71 100644 --- a/crates/adapters/postgres-federation/Cargo.toml +++ b/crates/adapters/postgres-federation/Cargo.toml @@ -12,7 +12,7 @@ sqlx = { version = "0.8.6", features = [ "chrono", ] } activitypub = { workspace = true } -k-ap = { version = "0.4.0", registry = "gitea" } +k-ap = { version = "0.4.1", registry = "gitea" } domain = { workspace = true } uuid = { workspace = true } chrono = { workspace = true } diff --git a/crates/adapters/postgres-federation/src/activity.rs b/crates/adapters/postgres-federation/src/activity.rs new file mode 100644 index 0000000..1b7df66 --- /dev/null +++ b/crates/adapters/postgres-federation/src/activity.rs @@ -0,0 +1,29 @@ +use anyhow::Result; +use async_trait::async_trait; +use chrono::Utc; +use k_ap::ActivityRepository; + +use super::{PostgresFederationRepository, datetime_to_str}; + +#[async_trait] +impl ActivityRepository for PostgresFederationRepository { + async fn is_activity_processed(&self, activity_id: &str) -> Result { + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ap_activities WHERE id = $1") + .bind(activity_id) + .fetch_one(&self.pool) + .await?; + Ok(count > 0) + } + + async fn mark_activity_processed(&self, activity_id: &str) -> Result<()> { + let ts = datetime_to_str(&Utc::now().naive_utc()); + sqlx::query( + "INSERT INTO ap_activities (id, processed_at) VALUES ($1, $2) ON CONFLICT DO NOTHING", + ) + .bind(activity_id) + .bind(&ts) + .execute(&self.pool) + .await?; + Ok(()) + } +} diff --git a/crates/adapters/postgres-federation/src/actor.rs b/crates/adapters/postgres-federation/src/actor.rs new file mode 100644 index 0000000..fb50e05 --- /dev/null +++ b/crates/adapters/postgres-federation/src/actor.rs @@ -0,0 +1,100 @@ +use anyhow::Result; +use async_trait::async_trait; +use chrono::Utc; +use k_ap::{ActorRepository, RemoteActor}; +use sqlx::Row; + +use super::{PG_ACTOR_COLS, PostgresFederationRepository, datetime_to_str, pg_remote_actor}; + +#[async_trait] +impl ActorRepository for PostgresFederationRepository { + async fn get_local_actor_keypair( + &self, + user_id: uuid::Uuid, + ) -> Result> { + let uid = user_id.to_string(); + let row = + sqlx::query("SELECT public_key, private_key FROM ap_local_actors WHERE user_id = $1") + .bind(&uid) + .fetch_optional(&self.pool) + .await?; + Ok(row.map(|r| (r.get("public_key"), r.get("private_key")))) + } + + async fn save_local_actor_keypair( + &self, + user_id: uuid::Uuid, + public_key: String, + private_key: String, + ) -> Result<()> { + let uid = user_id.to_string(); + let now = Utc::now().naive_utc(); + let created_at = datetime_to_str(&now); + sqlx::query( + "INSERT INTO ap_local_actors (user_id, public_key, private_key, created_at) VALUES ($1, $2, $3, $4::timestamptz) + ON CONFLICT(user_id) DO UPDATE SET public_key = EXCLUDED.public_key, private_key = EXCLUDED.private_key", + ).bind(&uid).bind(&public_key).bind(&private_key).bind(&created_at).execute(&self.pool).await?; + Ok(()) + } + + async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()> { + let now = Utc::now().naive_utc(); + let fetched_at = datetime_to_str(&now); + let aka_json = serde_json::to_string(&actor.also_known_as).unwrap_or_default(); + sqlx::query( + "INSERT INTO ap_remote_actors (url, handle, inbox_url, shared_inbox_url, display_name, avatar_url, outbox_url, bio, banner_url, followers_url, following_url, also_known_as, fetched_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::timestamptz) + ON CONFLICT(url) DO UPDATE SET + handle=EXCLUDED.handle, inbox_url=EXCLUDED.inbox_url, shared_inbox_url=EXCLUDED.shared_inbox_url, + display_name=EXCLUDED.display_name, avatar_url=EXCLUDED.avatar_url, + outbox_url=COALESCE(EXCLUDED.outbox_url, ap_remote_actors.outbox_url), + bio=EXCLUDED.bio, banner_url=EXCLUDED.banner_url, followers_url=EXCLUDED.followers_url, + following_url=EXCLUDED.following_url, also_known_as=EXCLUDED.also_known_as, fetched_at=EXCLUDED.fetched_at", + ) + .bind(&actor.url).bind(&actor.handle).bind(&actor.inbox_url).bind(&actor.shared_inbox_url) + .bind(&actor.display_name).bind(&actor.avatar_url).bind(&actor.outbox_url) + .bind(&actor.bio).bind(&actor.banner_url).bind(&actor.followers_url).bind(&actor.following_url) + .bind(&aka_json).bind(&fetched_at) + .execute(&self.pool).await?; + Ok(()) + } + + async fn get_remote_actor(&self, actor_url: &str) -> Result> { + let q = format!("SELECT url, {PG_ACTOR_COLS} FROM ap_remote_actors a WHERE url = $1"); + let row = sqlx::query(&q) + .bind(actor_url) + .fetch_optional(&self.pool) + .await?; + Ok(row.as_ref().map(|r| pg_remote_actor(r, "url"))) + } + + async fn add_announce( + &self, + activity_id: &str, + object_url: &str, + actor_url: &str, + announced_at: chrono::DateTime, + ) -> Result<()> { + let ts = announced_at.format("%Y-%m-%d %H:%M:%S").to_string(); + sqlx::query("INSERT INTO ap_announces (id, object_url, actor_url, announced_at) VALUES ($1, $2, $3, $4) ON CONFLICT (id) DO NOTHING") + .bind(activity_id).bind(object_url).bind(actor_url).bind(&ts).execute(&self.pool).await?; + Ok(()) + } + + async fn remove_announce(&self, activity_id: &str, actor_url: &str) -> Result<()> { + sqlx::query("DELETE FROM ap_announces WHERE id = $1 AND actor_url = $2") + .bind(activity_id) + .bind(actor_url) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn count_announces(&self, object_url: &str) -> Result { + let row = sqlx::query("SELECT COUNT(*) as cnt FROM ap_announces WHERE object_url = $1") + .bind(object_url) + .fetch_one(&self.pool) + .await?; + Ok(row.get::("cnt") as usize) + } +} diff --git a/crates/adapters/postgres/src/ap_content.rs b/crates/adapters/postgres-federation/src/ap_content.rs similarity index 56% rename from crates/adapters/postgres/src/ap_content.rs rename to crates/adapters/postgres-federation/src/ap_content.rs index 1fa0eb3..0138504 100644 --- a/crates/adapters/postgres/src/ap_content.rs +++ b/crates/adapters/postgres-federation/src/ap_content.rs @@ -1,13 +1,18 @@ use async_trait::async_trait; use domain::{ errors::DomainError, - models::{DiaryEntry, Movie, Review, WatchlistEntry, WatchlistWithMovie}, + models::{ + DiaryEntry, Goal, GoalType, Movie, PersistedReview, Review, ReviewSource, WatchlistEntry, + WatchlistWithMovie, + }, ports::LocalApContentQuery, - value_objects::{MovieId, ReviewId, UserId, WatchlistEntryId}, + value_objects::{ + Comment, ExternalMetadataId, GoalId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear, + ReviewId, UserId, WatchlistEntryId, + }, }; use sqlx::{PgPool, Row}; - -use crate::models::{DiaryRow, MovieRow, ReviewRow, parse_datetime, parse_uuid}; +use uuid::Uuid; pub struct PostgresApContentQuery { pool: PgPool, @@ -24,6 +29,199 @@ impl PostgresApContentQuery { } } +// ── Local row types ────────────────────────────────────────────────────────── + +fn parse_uuid(s: &str) -> Result { + Uuid::parse_str(s) + .map_err(|e| DomainError::InfrastructureError(format!("Invalid UUID '{}': {}", s, e))) +} + +fn parse_datetime(s: &str) -> Result { + chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") + .map_err(|e| DomainError::InfrastructureError(format!("Invalid datetime '{}': {}", s, e))) +} + +#[derive(sqlx::FromRow)] +struct MovieRow { + id: String, + external_metadata_id: Option, + title: String, + release_year: i64, + director: Option, + poster_path: Option, +} + +impl MovieRow { + fn into_domain(self) -> Result { + let id = MovieId::from_uuid(parse_uuid(&self.id)?); + let external_metadata_id = self + .external_metadata_id + .map(ExternalMetadataId::new) + .transpose()?; + let title = MovieTitle::new(self.title)?; + let release_year = ReleaseYear::new(self.release_year as u16)?; + let poster_path = self.poster_path.map(PosterPath::new).transpose()?; + Ok(Movie::from_persistence( + id, + external_metadata_id, + title, + release_year, + self.director, + poster_path, + )) + } +} + +#[derive(sqlx::FromRow)] +struct ReviewRow { + id: String, + movie_id: String, + user_id: String, + rating: i64, + comment: Option, + watched_at: String, + created_at: String, + remote_actor_url: Option, +} + +impl ReviewRow { + fn into_domain(self) -> Result { + let id = ReviewId::from_uuid(parse_uuid(&self.id)?); + let movie_id = MovieId::from_uuid(parse_uuid(&self.movie_id)?); + let user_id = UserId::from_uuid(parse_uuid(&self.user_id)?); + let rating = Rating::new(self.rating as u8)?; + let comment = self.comment.map(Comment::new).transpose()?; + let watched_at = parse_datetime(&self.watched_at)?; + let created_at = parse_datetime(&self.created_at)?; + let source = match self.remote_actor_url { + None => ReviewSource::Local, + Some(url) => ReviewSource::Remote { actor_url: url }, + }; + Ok(Review::from_persistence(PersistedReview { + id, + movie_id, + user_id, + rating, + comment, + watched_at, + created_at, + source, + })) + } +} + +#[derive(sqlx::FromRow)] +struct DiaryRow { + id: String, + external_metadata_id: Option, + title: String, + release_year: i64, + director: Option, + poster_path: Option, + review_id: String, + movie_id: String, + user_id: String, + rating: i64, + comment: Option, + watched_at: String, + created_at: String, + remote_actor_url: Option, +} + +impl DiaryRow { + fn into_domain(self) -> Result { + let movie = MovieRow { + id: self.id, + external_metadata_id: self.external_metadata_id, + title: self.title, + release_year: self.release_year, + director: self.director, + poster_path: self.poster_path, + } + .into_domain()?; + let review = ReviewRow { + id: self.review_id, + movie_id: self.movie_id, + user_id: self.user_id, + rating: self.rating, + comment: self.comment, + watched_at: self.watched_at, + created_at: self.created_at, + remote_actor_url: self.remote_actor_url, + } + .into_domain()?; + Ok(DiaryEntry::new(movie, review)) + } +} + +fn row_to_goal(r: &sqlx::postgres::PgRow) -> Result { + let id_str: String = r + .try_get("id") + .map_err(|e| DomainError::InfrastructureError(format!("Failed to read goal id: {e}")))?; + let user_id_str: String = r + .try_get("user_id") + .map_err(|e| DomainError::InfrastructureError(format!("Failed to read user_id: {e}")))?; + let year: i64 = r + .try_get("year") + .map_err(|e| DomainError::InfrastructureError(format!("Failed to read year: {e}")))?; + let target: i64 = r.try_get("target_count").map_err(|e| { + DomainError::InfrastructureError(format!("Failed to read target_count: {e}")) + })?; + let goal_type_str: String = r + .try_get("goal_type") + .map_err(|e| DomainError::InfrastructureError(format!("Failed to read goal_type: {e}")))?; + let created_at_str: String = r + .try_get("created_at") + .map_err(|e| DomainError::InfrastructureError(format!("Failed to read created_at: {e}")))?; + + let id = GoalId::from_uuid(parse_uuid(&id_str)?); + let user_id = UserId::from_uuid(parse_uuid(&user_id_str)?); + let goal_type: GoalType = goal_type_str.parse()?; + let created_at = parse_datetime(&created_at_str)?; + + Ok(Goal::from_persistence( + id, + user_id, + year as u16, + target as u32, + goal_type, + created_at, + )) +} + +async fn count_reviews_in_year( + pool: &PgPool, + user_id: &UserId, + year: u16, +) -> Result { + let uid = user_id.value().to_string(); + let start = format!("{year}-01-01 00:00:00"); + let end = format!("{}-01-01 00:00:00", year + 1); + + let count: i64 = sqlx::query( + "SELECT COUNT(*) FROM reviews \ + WHERE user_id = $1 \ + AND watched_at >= $2::timestamptz \ + AND watched_at < $3::timestamptz \ + AND remote_actor_url IS NULL", + ) + .bind(&uid) + .bind(&start) + .bind(&end) + .fetch_one(pool) + .await + .map_err(|e| { + tracing::error!("Database error: {:?}", e); + DomainError::InfrastructureError("Database operation failed".into()) + })? + .try_get(0) + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + + Ok(count as u32) +} + +// ── LocalApContentQuery impl ───────────────────────────────────────────────── + #[async_trait] impl LocalApContentQuery for PostgresApContentQuery { async fn get_local_reviews_for_user( @@ -169,6 +367,22 @@ impl LocalApContentQuery for PostgresApContentQuery { .transpose() } + async fn get_movie_by_external_metadata_id( + &self, + external_id: &str, + ) -> Result, DomainError> { + sqlx::query_as::<_, MovieRow>( + "SELECT id, external_metadata_id, title, release_year, director, poster_path + FROM movies WHERE external_metadata_id = $1", + ) + .bind(external_id) + .fetch_optional(&self.pool) + .await + .map_err(Self::map_err)? + .map(MovieRow::into_domain) + .transpose() + } + async fn count_local_posts(&self) -> Result { let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE remote_actor_url IS NULL") @@ -233,7 +447,7 @@ impl LocalApContentQuery for PostgresApContentQuery { &self, user_id: &UserId, year: u16, - ) -> Result, DomainError> { + ) -> Result, DomainError> { let uid = user_id.value().to_string(); let y = year as i64; @@ -250,9 +464,23 @@ impl LocalApContentQuery for PostgresApContentQuery { let Some(r) = row else { return Ok(None) }; - let goal = crate::goals::row_to_goal(&r)?; - let count = crate::goals::count_reviews_in_year(&self.pool, user_id, year).await?; + let goal = row_to_goal(&r)?; + let count = count_reviews_in_year(&self.pool, user_id, year).await?; Ok(Some((goal, count))) } + + async fn list_goals_for_user(&self, user_id: &UserId) -> Result, DomainError> { + let uid = user_id.value().to_string(); + let rows = sqlx::query( + "SELECT id, user_id, year, target_count, goal_type, \ + to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at \ + FROM goals WHERE user_id = $1 ORDER BY created_at DESC", + ) + .bind(&uid) + .fetch_all(&self.pool) + .await + .map_err(Self::map_err)?; + rows.iter().map(row_to_goal).collect() + } } diff --git a/crates/adapters/postgres-federation/src/blocklist.rs b/crates/adapters/postgres-federation/src/blocklist.rs new file mode 100644 index 0000000..0aaad19 --- /dev/null +++ b/crates/adapters/postgres-federation/src/blocklist.rs @@ -0,0 +1,87 @@ +use anyhow::Result; +use async_trait::async_trait; +use chrono::Utc; +use k_ap::{BlockedDomain, BlocklistRepository}; +use sqlx::Row; + +use super::{PostgresFederationRepository, datetime_to_str}; + +#[async_trait] +impl BlocklistRepository for PostgresFederationRepository { + async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> Result<()> { + let ts = datetime_to_str(&Utc::now().naive_utc()); + sqlx::query("INSERT INTO blocked_domains (domain, reason, blocked_at) VALUES ($1, $2, $3) ON CONFLICT(domain) DO UPDATE SET reason = EXCLUDED.reason") + .bind(domain).bind(reason).bind(&ts).execute(&self.pool).await?; + Ok(()) + } + + async fn remove_blocked_domain(&self, domain: &str) -> Result<()> { + sqlx::query("DELETE FROM blocked_domains WHERE domain = $1") + .bind(domain) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn get_blocked_domains(&self) -> Result> { + let rows = sqlx::query( + "SELECT domain, reason, blocked_at FROM blocked_domains ORDER BY blocked_at DESC", + ) + .fetch_all(&self.pool) + .await?; + Ok(rows + .iter() + .map(|r| BlockedDomain { + domain: r.get("domain"), + reason: r.get("reason"), + blocked_at: r.get("blocked_at"), + }) + .collect()) + } + + async fn is_domain_blocked(&self, domain: &str) -> Result { + let count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM blocked_domains WHERE domain = $1") + .bind(domain) + .fetch_one(&self.pool) + .await?; + Ok(count > 0) + } + + async fn add_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> { + let uid = local_user_id.to_string(); + let ts = datetime_to_str(&Utc::now().naive_utc()); + sqlx::query("INSERT INTO blocked_actors (local_user_id, remote_actor_url, blocked_at) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING") + .bind(&uid).bind(actor_url).bind(&ts).execute(&self.pool).await?; + Ok(()) + } + + async fn remove_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> { + let uid = local_user_id.to_string(); + sqlx::query( + "DELETE FROM blocked_actors WHERE local_user_id = $1 AND remote_actor_url = $2", + ) + .bind(&uid) + .bind(actor_url) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn get_blocked_actors(&self, local_user_id: uuid::Uuid) -> Result> { + let uid = local_user_id.to_string(); + let rows = sqlx::query("SELECT remote_actor_url FROM blocked_actors WHERE local_user_id = $1 ORDER BY blocked_at DESC") + .bind(&uid).fetch_all(&self.pool).await?; + Ok(rows + .iter() + .map(|r| r.get::("remote_actor_url")) + .collect()) + } + + async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result { + let uid = local_user_id.to_string(); + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM blocked_actors WHERE local_user_id = $1 AND remote_actor_url = $2") + .bind(&uid).bind(actor_url).fetch_one(&self.pool).await?; + Ok(count > 0) + } +} diff --git a/crates/adapters/postgres-federation/src/follow.rs b/crates/adapters/postgres-federation/src/follow.rs new file mode 100644 index 0000000..910f8b3 --- /dev/null +++ b/crates/adapters/postgres-federation/src/follow.rs @@ -0,0 +1,321 @@ +use anyhow::Result; +use async_trait::async_trait; +use chrono::Utc; +use k_ap::{ + ActorRepository, FollowRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor, +}; +use sqlx::Row; + +use super::{ + PG_ACTOR_COLS, PostgresFederationRepository, datetime_to_str, pg_remote_actor, status_to_str, + str_to_status, +}; + +#[async_trait] +impl FollowRepository for PostgresFederationRepository { + async fn add_follower( + &self, + local_user_id: uuid::Uuid, + remote_actor_url: &str, + status: FollowerStatus, + follow_activity_id: &str, + ) -> Result<()> { + let uid = local_user_id.to_string(); + let status_str = status_to_str(&status); + let now = Utc::now().naive_utc(); + let created_at = datetime_to_str(&now); + sqlx::query( + "INSERT INTO ap_followers (local_user_id, remote_actor_url, status, created_at, follow_activity_id) + VALUES ($1, $2, $3, $4::timestamptz, $5) + ON CONFLICT(local_user_id, remote_actor_url) DO UPDATE SET + status = EXCLUDED.status, follow_activity_id = EXCLUDED.follow_activity_id", + ).bind(&uid).bind(remote_actor_url).bind(status_str).bind(&created_at).bind(follow_activity_id).execute(&self.pool).await?; + Ok(()) + } + + async fn get_follower_follow_activity_id( + &self, + local_user_id: uuid::Uuid, + remote_actor_url: &str, + ) -> Result> { + let uid = local_user_id.to_string(); + let row: Option = sqlx::query_scalar( + "SELECT follow_activity_id FROM ap_followers WHERE local_user_id = $1 AND remote_actor_url = $2", + ).bind(&uid).bind(remote_actor_url).fetch_optional(&self.pool).await?; + Ok(row) + } + + async fn remove_follower( + &self, + local_user_id: uuid::Uuid, + remote_actor_url: &str, + ) -> Result<()> { + let uid = local_user_id.to_string(); + sqlx::query("DELETE FROM ap_followers WHERE local_user_id = $1 AND remote_actor_url = $2") + .bind(&uid) + .bind(remote_actor_url) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn get_followers(&self, local_user_id: uuid::Uuid) -> Result> { + let uid = local_user_id.to_string(); + let q = format!( + "SELECT f.remote_actor_url, f.status, {PG_ACTOR_COLS} FROM ap_followers f LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url WHERE f.local_user_id = $1" + ); + let rows = sqlx::query(&q).bind(&uid).fetch_all(&self.pool).await?; + Ok(rows + .iter() + .map(|row| { + let status_str: String = row.get("status"); + Follower { + actor: pg_remote_actor(row, "remote_actor_url"), + status: str_to_status(&status_str), + } + }) + .collect()) + } + + async fn get_followers_page( + &self, + local_user_id: uuid::Uuid, + offset: u32, + limit: usize, + ) -> Result> { + let uid = local_user_id.to_string(); + let q = format!( + "SELECT f.remote_actor_url, f.status, {PG_ACTOR_COLS} FROM ap_followers f LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url WHERE f.local_user_id = $1 AND f.status = 'accepted' ORDER BY f.created_at ASC LIMIT $2 OFFSET $3" + ); + let rows = sqlx::query(&q) + .bind(&uid) + .bind(limit as i64) + .bind(offset as i64) + .fetch_all(&self.pool) + .await?; + Ok(rows + .iter() + .map(|row| { + let status_str: String = row.get("status"); + Follower { + actor: pg_remote_actor(row, "remote_actor_url"), + status: str_to_status(&status_str), + } + }) + .collect()) + } + + async fn count_followers(&self, local_user_id: uuid::Uuid) -> Result { + let uid = local_user_id.to_string(); + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM ap_followers WHERE local_user_id = $1 AND status = 'accepted'", + ) + .bind(&uid) + .fetch_one(&self.pool) + .await?; + Ok(count as usize) + } + + async fn update_follower_status( + &self, + local_user_id: uuid::Uuid, + remote_actor_url: &str, + status: FollowerStatus, + ) -> Result<()> { + let uid = local_user_id.to_string(); + let status_str = status_to_str(&status); + let result = sqlx::query("UPDATE ap_followers SET status = $1 WHERE local_user_id = $2 AND remote_actor_url = $3") + .bind(status_str).bind(&uid).bind(remote_actor_url).execute(&self.pool).await?; + if result.rows_affected() == 0 { + tracing::warn!(local_user_id = %local_user_id, remote_actor_url, "update_follower_status: no row found"); + } + Ok(()) + } + + async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result> { + let uid = local_user_id.to_string(); + let q = format!( + "SELECT f.remote_actor_url, {PG_ACTOR_COLS} FROM ap_followers f LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url WHERE f.local_user_id = $1 AND f.status = 'pending'" + ); + let rows = sqlx::query(&q).bind(&uid).fetch_all(&self.pool).await?; + Ok(rows + .iter() + .map(|row| pg_remote_actor(row, "remote_actor_url")) + .collect()) + } + + async fn get_accepted_follower_inboxes( + &self, + local_user_id: uuid::Uuid, + ) -> Result> { + let uid = local_user_id.to_string(); + let rows = sqlx::query( + "SELECT DISTINCT COALESCE(a.shared_inbox_url, a.inbox_url) as inbox + FROM ap_followers f INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url + WHERE f.local_user_id = $1 AND f.status = 'accepted' + AND f.remote_actor_url NOT IN (SELECT remote_actor_url FROM blocked_actors WHERE local_user_id = $1)", + ).bind(&uid).fetch_all(&self.pool).await?; + Ok(rows + .iter() + .filter_map(|r| r.try_get::("inbox").ok()) + .collect()) + } + + async fn count_accepted_followers(&self, local_user_id: uuid::Uuid) -> Result { + let uid = local_user_id.to_string(); + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM ap_followers WHERE local_user_id = $1 AND status = 'accepted'", + ) + .bind(&uid) + .fetch_one(&self.pool) + .await?; + Ok(count as usize) + } + + async fn get_accepted_followers_page( + &self, + local_user_id: uuid::Uuid, + offset: u32, + limit: usize, + ) -> Result> { + let uid = local_user_id.to_string(); + let q = format!( + "SELECT f.remote_actor_url, {PG_ACTOR_COLS} FROM ap_followers f LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url WHERE f.local_user_id = $1 AND f.status = 'accepted' ORDER BY f.created_at ASC LIMIT $2 OFFSET $3" + ); + let rows = sqlx::query(&q) + .bind(&uid) + .bind(limit as i64) + .bind(offset as i64) + .fetch_all(&self.pool) + .await?; + Ok(rows + .iter() + .map(|row| pg_remote_actor(row, "remote_actor_url")) + .collect()) + } + + async fn add_following( + &self, + local_user_id: uuid::Uuid, + actor: RemoteActor, + follow_activity_id: &str, + ) -> Result<()> { + let uid = local_user_id.to_string(); + let now = Utc::now().naive_utc(); + let created_at = datetime_to_str(&now); + ActorRepository::upsert_remote_actor(self, actor.clone()).await?; + sqlx::query("INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, created_at) VALUES ($1, $2, $3, $4::timestamptz) ON CONFLICT DO NOTHING") + .bind(&uid).bind(&actor.url).bind(follow_activity_id).bind(&created_at).execute(&self.pool).await?; + Ok(()) + } + + async fn get_follow_activity_id( + &self, + local_user_id: uuid::Uuid, + remote_actor_url: &str, + ) -> Result> { + let uid = local_user_id.to_string(); + let row: Option = sqlx::query_scalar("SELECT follow_activity_id FROM ap_following WHERE local_user_id = $1 AND remote_actor_url = $2") + .bind(&uid).bind(remote_actor_url).fetch_optional(&self.pool).await?; + Ok(row) + } + + async fn remove_following(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> { + let uid = local_user_id.to_string(); + sqlx::query("DELETE FROM ap_following WHERE local_user_id = $1 AND remote_actor_url = $2") + .bind(&uid) + .bind(actor_url) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn get_following(&self, local_user_id: uuid::Uuid) -> Result> { + let uid = local_user_id.to_string(); + let q = format!( + "SELECT a.url, {PG_ACTOR_COLS} FROM ap_following f INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url WHERE f.local_user_id = $1 AND f.status = 'accepted'" + ); + let rows = sqlx::query(&q).bind(&uid).fetch_all(&self.pool).await?; + Ok(rows.iter().map(|row| pg_remote_actor(row, "url")).collect()) + } + + async fn count_following(&self, local_user_id: uuid::Uuid) -> Result { + let uid = local_user_id.to_string(); + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM ap_following WHERE local_user_id = $1 AND status = 'accepted'", + ) + .bind(&uid) + .fetch_one(&self.pool) + .await?; + Ok(count as usize) + } + + async fn get_following_page( + &self, + local_user_id: uuid::Uuid, + offset: u32, + limit: usize, + ) -> Result> { + let uid = local_user_id.to_string(); + let q = format!( + "SELECT a.url, {PG_ACTOR_COLS} FROM ap_following f INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url WHERE f.local_user_id = $1 AND f.status = 'accepted' ORDER BY f.created_at ASC LIMIT $2 OFFSET $3" + ); + let rows = sqlx::query(&q) + .bind(&uid) + .bind(limit as i64) + .bind(offset as i64) + .fetch_all(&self.pool) + .await?; + Ok(rows.iter().map(|row| pg_remote_actor(row, "url")).collect()) + } + + async fn update_following_status( + &self, + local_user_id: uuid::Uuid, + remote_actor_url: &str, + status: FollowingStatus, + ) -> Result<()> { + let uid = local_user_id.to_string(); + let status_str = match status { + FollowingStatus::Pending => "pending", + FollowingStatus::Accepted => "accepted", + }; + let result = sqlx::query("UPDATE ap_following SET status = $1 WHERE local_user_id = $2 AND remote_actor_url = $3") + .bind(status_str).bind(&uid).bind(remote_actor_url).execute(&self.pool).await?; + if result.rows_affected() == 0 { + tracing::warn!(local_user_id = %local_user_id, remote_actor_url, "update_following_status: no row found"); + } + Ok(()) + } + + async fn get_following_outbox_url( + &self, + local_user_id: uuid::Uuid, + remote_actor_url: &str, + ) -> Result> { + let uid = local_user_id.to_string(); + let row: Option> = sqlx::query_scalar( + "SELECT a.outbox_url FROM ap_following f INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url WHERE f.local_user_id = $1 AND f.remote_actor_url = $2", + ).bind(&uid).bind(remote_actor_url).fetch_optional(&self.pool).await?; + Ok(row.flatten()) + } + + async fn migrate_follower_actor( + &self, + old_actor_url: &str, + new_actor_url: &str, + ) -> Result> { + let candidates: Vec = sqlx::query_scalar( + "SELECT local_user_id FROM ap_following WHERE remote_actor_url = $1 AND local_user_id NOT IN (SELECT local_user_id FROM ap_following WHERE remote_actor_url = $2)", + ).bind(old_actor_url).bind(new_actor_url).fetch_all(&self.pool).await?; + if candidates.is_empty() { + return Ok(vec![]); + } + sqlx::query("UPDATE ap_following SET remote_actor_url = $1 WHERE remote_actor_url = $2 AND local_user_id NOT IN (SELECT local_user_id FROM ap_following WHERE remote_actor_url = $1)") + .bind(new_actor_url).bind(old_actor_url).execute(&self.pool).await?; + candidates + .into_iter() + .map(|s| uuid::Uuid::parse_str(&s).map_err(|e| anyhow::anyhow!(e))) + .collect() + } +} diff --git a/crates/adapters/postgres-federation/src/lib.rs b/crates/adapters/postgres-federation/src/lib.rs index af59fc8..c3be1f0 100644 --- a/crates/adapters/postgres-federation/src/lib.rs +++ b/crates/adapters/postgres-federation/src/lib.rs @@ -1,31 +1,25 @@ -use anyhow::{Result, anyhow}; -use async_trait::async_trait; -use chrono::{NaiveDateTime, Utc}; +mod activity; +mod actor; +pub mod ap_content; +mod blocklist; +mod follow; +pub mod remote_goals; +mod review; +mod social; +mod watchlist; + +pub use ap_content::PostgresApContentQuery; +pub use remote_goals::PostgresRemoteGoalRepository; + +use chrono::NaiveDateTime; +use k_ap::{FollowerStatus, RemoteActor}; use sqlx::{PgPool, Row}; -use activitypub::RemoteReviewRepository; -use domain::models::{RemoteWatchlistEntry, Review, ReviewSource}; -use domain::ports::RemoteWatchlistRepository; -use k_ap::{ - ActivityRepository, ActorRepository, BlockedDomain, BlocklistRepository, FollowRepository, - Follower, FollowerStatus, FollowingStatus, RemoteActor, -}; - -fn datetime_to_str(dt: &NaiveDateTime) -> String { +pub(crate) fn datetime_to_str(dt: &NaiveDateTime) -> String { dt.format("%Y-%m-%d %H:%M:%S").to_string() } -pub struct PostgresFederationRepository { - pool: PgPool, -} - -impl PostgresFederationRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } -} - -fn status_to_str(status: &FollowerStatus) -> &'static str { +pub(crate) fn status_to_str(status: &FollowerStatus) -> &'static str { match status { FollowerStatus::Pending => "pending", FollowerStatus::Accepted => "accepted", @@ -33,7 +27,7 @@ fn status_to_str(status: &FollowerStatus) -> &'static str { } } -fn str_to_status(s: &str) -> FollowerStatus { +pub(crate) fn str_to_status(s: &str) -> FollowerStatus { match s { "accepted" => FollowerStatus::Accepted, "rejected" => FollowerStatus::Rejected, @@ -41,7 +35,7 @@ fn str_to_status(s: &str) -> FollowerStatus { } } -fn pg_remote_actor(row: &sqlx::postgres::PgRow, url_col: &str) -> RemoteActor { +pub(crate) fn pg_remote_actor(row: &sqlx::postgres::PgRow, url_col: &str) -> RemoteActor { RemoteActor { url: row.get(url_col), handle: row.try_get("handle").unwrap_or_default(), @@ -69,863 +63,19 @@ fn pg_remote_actor(row: &sqlx::postgres::PgRow, url_col: &str) -> RemoteActor { } } -const PG_ACTOR_COLS: &str = "a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url, a.outbox_url, a.bio, a.banner_url, a.followers_url, a.following_url, a.also_known_as, a.fetched_at"; +pub(crate) const PG_ACTOR_COLS: &str = "a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url, a.outbox_url, a.bio, a.banner_url, a.followers_url, a.following_url, a.also_known_as, a.fetched_at"; -#[async_trait] -impl FollowRepository for PostgresFederationRepository { - async fn add_follower( - &self, - local_user_id: uuid::Uuid, - remote_actor_url: &str, - status: FollowerStatus, - follow_activity_id: &str, - ) -> Result<()> { - let uid = local_user_id.to_string(); - let status_str = status_to_str(&status); - let now = Utc::now().naive_utc(); - let created_at = datetime_to_str(&now); - sqlx::query( - "INSERT INTO ap_followers (local_user_id, remote_actor_url, status, created_at, follow_activity_id) - VALUES ($1, $2, $3, $4::timestamptz, $5) - ON CONFLICT(local_user_id, remote_actor_url) DO UPDATE SET - status = EXCLUDED.status, - follow_activity_id = EXCLUDED.follow_activity_id", - ) - .bind(&uid) - .bind(remote_actor_url) - .bind(status_str) - .bind(&created_at) - .bind(follow_activity_id) - .execute(&self.pool) - .await?; - Ok(()) - } +pub struct PostgresFederationRepository { + pub(crate) pool: PgPool, +} - async fn get_follower_follow_activity_id( - &self, - local_user_id: uuid::Uuid, - remote_actor_url: &str, - ) -> Result> { - let uid = local_user_id.to_string(); - let row: Option = sqlx::query_scalar( - "SELECT follow_activity_id FROM ap_followers WHERE local_user_id = $1 AND remote_actor_url = $2", - ) - .bind(&uid) - .bind(remote_actor_url) - .fetch_optional(&self.pool) - .await?; - Ok(row) - } - - async fn remove_follower( - &self, - local_user_id: uuid::Uuid, - remote_actor_url: &str, - ) -> Result<()> { - let uid = local_user_id.to_string(); - sqlx::query("DELETE FROM ap_followers WHERE local_user_id = $1 AND remote_actor_url = $2") - .bind(&uid) - .bind(remote_actor_url) - .execute(&self.pool) - .await?; - Ok(()) - } - - async fn get_followers(&self, local_user_id: uuid::Uuid) -> Result> { - let uid = local_user_id.to_string(); - let q = format!( - "SELECT f.remote_actor_url, f.status, {PG_ACTOR_COLS} - FROM ap_followers f LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url - WHERE f.local_user_id = $1" - ); - let rows = sqlx::query(&q).bind(&uid).fetch_all(&self.pool).await?; - Ok(rows - .iter() - .map(|row| { - let status_str: String = row.get("status"); - Follower { - actor: pg_remote_actor(row, "remote_actor_url"), - status: str_to_status(&status_str), - } - }) - .collect()) - } - - async fn get_followers_page( - &self, - local_user_id: uuid::Uuid, - offset: u32, - limit: usize, - ) -> Result> { - let uid = local_user_id.to_string(); - let q = format!( - "SELECT f.remote_actor_url, f.status, {PG_ACTOR_COLS} - FROM ap_followers f LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url - WHERE f.local_user_id = $1 AND f.status = 'accepted' - ORDER BY f.created_at ASC LIMIT $2 OFFSET $3" - ); - let rows = sqlx::query(&q) - .bind(&uid) - .bind(limit as i64) - .bind(offset as i64) - .fetch_all(&self.pool) - .await?; - Ok(rows - .iter() - .map(|row| { - let status_str: String = row.get("status"); - Follower { - actor: pg_remote_actor(row, "remote_actor_url"), - status: str_to_status(&status_str), - } - }) - .collect()) - } - - async fn count_followers(&self, local_user_id: uuid::Uuid) -> Result { - let uid = local_user_id.to_string(); - let count: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM ap_followers WHERE local_user_id = $1 AND status = 'accepted'", - ) - .bind(&uid) - .fetch_one(&self.pool) - .await?; - Ok(count as usize) - } - - async fn update_follower_status( - &self, - local_user_id: uuid::Uuid, - remote_actor_url: &str, - status: FollowerStatus, - ) -> Result<()> { - let uid = local_user_id.to_string(); - let status_str = status_to_str(&status); - let result = sqlx::query( - "UPDATE ap_followers SET status = $1 WHERE local_user_id = $2 AND remote_actor_url = $3", - ).bind(status_str).bind(&uid).bind(remote_actor_url).execute(&self.pool).await?; - if result.rows_affected() == 0 { - tracing::warn!(local_user_id = %local_user_id, remote_actor_url, "update_follower_status: no row found"); - } - Ok(()) - } - - async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result> { - let uid = local_user_id.to_string(); - let q = format!( - "SELECT f.remote_actor_url, {PG_ACTOR_COLS} - FROM ap_followers f LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url - WHERE f.local_user_id = $1 AND f.status = 'pending'" - ); - let rows = sqlx::query(&q).bind(&uid).fetch_all(&self.pool).await?; - Ok(rows - .iter() - .map(|row| pg_remote_actor(row, "remote_actor_url")) - .collect()) - } - - async fn get_accepted_follower_inboxes( - &self, - local_user_id: uuid::Uuid, - ) -> Result> { - let uid = local_user_id.to_string(); - let rows = sqlx::query( - "SELECT DISTINCT COALESCE(a.shared_inbox_url, a.inbox_url) as inbox - FROM ap_followers f - INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url - WHERE f.local_user_id = $1 AND f.status = 'accepted' - AND f.remote_actor_url NOT IN ( - SELECT remote_actor_url FROM blocked_actors WHERE local_user_id = $1 - )", - ) - .bind(&uid) - .fetch_all(&self.pool) - .await?; - Ok(rows - .iter() - .filter_map(|r| r.try_get::("inbox").ok()) - .collect()) - } - - async fn count_accepted_followers(&self, local_user_id: uuid::Uuid) -> Result { - let uid = local_user_id.to_string(); - let count: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM ap_followers WHERE local_user_id = $1 AND status = 'accepted'", - ) - .bind(&uid) - .fetch_one(&self.pool) - .await?; - Ok(count as usize) - } - - async fn get_accepted_followers_page( - &self, - local_user_id: uuid::Uuid, - offset: u32, - limit: usize, - ) -> Result> { - let uid = local_user_id.to_string(); - let q = format!( - "SELECT f.remote_actor_url, {PG_ACTOR_COLS} - FROM ap_followers f LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url - WHERE f.local_user_id = $1 AND f.status = 'accepted' - ORDER BY f.created_at ASC LIMIT $2 OFFSET $3" - ); - let rows = sqlx::query(&q) - .bind(&uid) - .bind(limit as i64) - .bind(offset as i64) - .fetch_all(&self.pool) - .await?; - Ok(rows - .iter() - .map(|row| pg_remote_actor(row, "remote_actor_url")) - .collect()) - } - - async fn add_following( - &self, - local_user_id: uuid::Uuid, - actor: RemoteActor, - follow_activity_id: &str, - ) -> Result<()> { - let uid = local_user_id.to_string(); - let now = Utc::now().naive_utc(); - let created_at = datetime_to_str(&now); - ActorRepository::upsert_remote_actor(self, actor.clone()).await?; - sqlx::query( - "INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, created_at) - VALUES ($1, $2, $3, $4::timestamptz) ON CONFLICT DO NOTHING", - ).bind(&uid).bind(&actor.url).bind(follow_activity_id).bind(&created_at).execute(&self.pool).await?; - Ok(()) - } - - async fn get_follow_activity_id( - &self, - local_user_id: uuid::Uuid, - remote_actor_url: &str, - ) -> Result> { - let uid = local_user_id.to_string(); - let row: Option = sqlx::query_scalar( - "SELECT follow_activity_id FROM ap_following WHERE local_user_id = $1 AND remote_actor_url = $2", - ).bind(&uid).bind(remote_actor_url).fetch_optional(&self.pool).await?; - Ok(row) - } - - async fn remove_following(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> { - let uid = local_user_id.to_string(); - sqlx::query("DELETE FROM ap_following WHERE local_user_id = $1 AND remote_actor_url = $2") - .bind(&uid) - .bind(actor_url) - .execute(&self.pool) - .await?; - Ok(()) - } - - async fn get_following(&self, local_user_id: uuid::Uuid) -> Result> { - let uid = local_user_id.to_string(); - let q = format!( - "SELECT a.url, {PG_ACTOR_COLS} - FROM ap_following f INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url - WHERE f.local_user_id = $1 AND f.status = 'accepted'" - ); - let rows = sqlx::query(&q).bind(&uid).fetch_all(&self.pool).await?; - Ok(rows.iter().map(|row| pg_remote_actor(row, "url")).collect()) - } - - async fn count_following(&self, local_user_id: uuid::Uuid) -> Result { - let uid = local_user_id.to_string(); - let count: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM ap_following WHERE local_user_id = $1 AND status = 'accepted'", - ) - .bind(&uid) - .fetch_one(&self.pool) - .await?; - Ok(count as usize) - } - - async fn get_following_page( - &self, - local_user_id: uuid::Uuid, - offset: u32, - limit: usize, - ) -> Result> { - let uid = local_user_id.to_string(); - let q = format!( - "SELECT a.url, {PG_ACTOR_COLS} - FROM ap_following f INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url - WHERE f.local_user_id = $1 AND f.status = 'accepted' - ORDER BY f.created_at ASC LIMIT $2 OFFSET $3" - ); - let rows = sqlx::query(&q) - .bind(&uid) - .bind(limit as i64) - .bind(offset as i64) - .fetch_all(&self.pool) - .await?; - Ok(rows.iter().map(|row| pg_remote_actor(row, "url")).collect()) - } - - async fn update_following_status( - &self, - local_user_id: uuid::Uuid, - remote_actor_url: &str, - status: FollowingStatus, - ) -> Result<()> { - let uid = local_user_id.to_string(); - let status_str = match status { - FollowingStatus::Pending => "pending", - FollowingStatus::Accepted => "accepted", - }; - let result = sqlx::query( - "UPDATE ap_following SET status = $1 WHERE local_user_id = $2 AND remote_actor_url = $3", - ).bind(status_str).bind(&uid).bind(remote_actor_url).execute(&self.pool).await?; - if result.rows_affected() == 0 { - tracing::warn!(local_user_id = %local_user_id, remote_actor_url, "update_following_status: no row found"); - } - Ok(()) - } - - async fn get_following_outbox_url( - &self, - local_user_id: uuid::Uuid, - remote_actor_url: &str, - ) -> Result> { - let uid = local_user_id.to_string(); - let row: Option> = sqlx::query_scalar( - "SELECT a.outbox_url FROM ap_following f INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url - WHERE f.local_user_id = $1 AND f.remote_actor_url = $2", - ).bind(&uid).bind(remote_actor_url).fetch_optional(&self.pool).await?; - Ok(row.flatten()) - } - - async fn migrate_follower_actor( - &self, - old_actor_url: &str, - new_actor_url: &str, - ) -> Result> { - let candidates: Vec = sqlx::query_scalar( - "SELECT local_user_id FROM ap_following WHERE remote_actor_url = $1 - AND local_user_id NOT IN (SELECT local_user_id FROM ap_following WHERE remote_actor_url = $2)", - ).bind(old_actor_url).bind(new_actor_url).fetch_all(&self.pool).await?; - - if candidates.is_empty() { - return Ok(vec![]); - } - - sqlx::query( - "UPDATE ap_following SET remote_actor_url = $1 WHERE remote_actor_url = $2 - AND local_user_id NOT IN (SELECT local_user_id FROM ap_following WHERE remote_actor_url = $1)", - ).bind(new_actor_url).bind(old_actor_url).execute(&self.pool).await?; - - candidates - .into_iter() - .map(|s| uuid::Uuid::parse_str(&s).map_err(|e| anyhow::anyhow!(e))) - .collect() +impl PostgresFederationRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } } } -#[async_trait] -impl ActorRepository for PostgresFederationRepository { - async fn get_local_actor_keypair( - &self, - user_id: uuid::Uuid, - ) -> Result> { - let uid = user_id.to_string(); - let row = - sqlx::query("SELECT public_key, private_key FROM ap_local_actors WHERE user_id = $1") - .bind(&uid) - .fetch_optional(&self.pool) - .await?; - Ok(row.map(|r| (r.get("public_key"), r.get("private_key")))) - } - - async fn save_local_actor_keypair( - &self, - user_id: uuid::Uuid, - public_key: String, - private_key: String, - ) -> Result<()> { - let uid = user_id.to_string(); - let now = Utc::now().naive_utc(); - let created_at = datetime_to_str(&now); - sqlx::query( - "INSERT INTO ap_local_actors (user_id, public_key, private_key, created_at) - VALUES ($1, $2, $3, $4::timestamptz) - ON CONFLICT(user_id) DO UPDATE SET public_key = EXCLUDED.public_key, private_key = EXCLUDED.private_key", - ).bind(&uid).bind(&public_key).bind(&private_key).bind(&created_at).execute(&self.pool).await?; - Ok(()) - } - - async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()> { - let now = Utc::now().naive_utc(); - let fetched_at = datetime_to_str(&now); - let aka_json = serde_json::to_string(&actor.also_known_as).unwrap_or_default(); - sqlx::query( - "INSERT INTO ap_remote_actors (url, handle, inbox_url, shared_inbox_url, display_name, avatar_url, outbox_url, bio, banner_url, followers_url, following_url, also_known_as, fetched_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::timestamptz) - ON CONFLICT(url) DO UPDATE SET - handle=EXCLUDED.handle, inbox_url=EXCLUDED.inbox_url, shared_inbox_url=EXCLUDED.shared_inbox_url, - display_name=EXCLUDED.display_name, avatar_url=EXCLUDED.avatar_url, - outbox_url=COALESCE(EXCLUDED.outbox_url, ap_remote_actors.outbox_url), - bio=EXCLUDED.bio, banner_url=EXCLUDED.banner_url, followers_url=EXCLUDED.followers_url, - following_url=EXCLUDED.following_url, also_known_as=EXCLUDED.also_known_as, fetched_at=EXCLUDED.fetched_at", - ) - .bind(&actor.url).bind(&actor.handle).bind(&actor.inbox_url).bind(&actor.shared_inbox_url) - .bind(&actor.display_name).bind(&actor.avatar_url).bind(&actor.outbox_url) - .bind(&actor.bio).bind(&actor.banner_url).bind(&actor.followers_url).bind(&actor.following_url) - .bind(&aka_json).bind(&fetched_at) - .execute(&self.pool).await?; - Ok(()) - } - - async fn get_remote_actor(&self, actor_url: &str) -> Result> { - let q = format!("SELECT url, {PG_ACTOR_COLS} FROM ap_remote_actors a WHERE url = $1"); - let row = sqlx::query(&q) - .bind(actor_url) - .fetch_optional(&self.pool) - .await?; - Ok(row.as_ref().map(|r| pg_remote_actor(r, "url"))) - } - - async fn add_announce( - &self, - activity_id: &str, - object_url: &str, - actor_url: &str, - announced_at: chrono::DateTime, - ) -> Result<()> { - let ts = announced_at.format("%Y-%m-%d %H:%M:%S").to_string(); - sqlx::query("INSERT INTO ap_announces (id, object_url, actor_url, announced_at) VALUES ($1, $2, $3, $4) ON CONFLICT (id) DO NOTHING") - .bind(activity_id).bind(object_url).bind(actor_url).bind(&ts).execute(&self.pool).await?; - Ok(()) - } - - async fn remove_announce(&self, activity_id: &str, actor_url: &str) -> Result<()> { - sqlx::query("DELETE FROM ap_announces WHERE id = $1 AND actor_url = $2") - .bind(activity_id) - .bind(actor_url) - .execute(&self.pool) - .await?; - Ok(()) - } - - async fn count_announces(&self, object_url: &str) -> Result { - let row = sqlx::query("SELECT COUNT(*) as cnt FROM ap_announces WHERE object_url = $1") - .bind(object_url) - .fetch_one(&self.pool) - .await?; - Ok(row.get::("cnt") as usize) - } -} - -#[async_trait] -impl BlocklistRepository for PostgresFederationRepository { - async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> Result<()> { - let ts = datetime_to_str(&Utc::now().naive_utc()); - sqlx::query("INSERT INTO blocked_domains (domain, reason, blocked_at) VALUES ($1, $2, $3) ON CONFLICT(domain) DO UPDATE SET reason = EXCLUDED.reason") - .bind(domain).bind(reason).bind(&ts).execute(&self.pool).await?; - Ok(()) - } - async fn remove_blocked_domain(&self, domain: &str) -> Result<()> { - sqlx::query("DELETE FROM blocked_domains WHERE domain = $1") - .bind(domain) - .execute(&self.pool) - .await?; - Ok(()) - } - async fn get_blocked_domains(&self) -> Result> { - let rows = sqlx::query( - "SELECT domain, reason, blocked_at FROM blocked_domains ORDER BY blocked_at DESC", - ) - .fetch_all(&self.pool) - .await?; - Ok(rows - .iter() - .map(|r| BlockedDomain { - domain: r.get("domain"), - reason: r.get("reason"), - blocked_at: r.get("blocked_at"), - }) - .collect()) - } - async fn is_domain_blocked(&self, domain: &str) -> Result { - let count: i64 = - sqlx::query_scalar("SELECT COUNT(*) FROM blocked_domains WHERE domain = $1") - .bind(domain) - .fetch_one(&self.pool) - .await?; - Ok(count > 0) - } - async fn add_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> { - let uid = local_user_id.to_string(); - let ts = datetime_to_str(&Utc::now().naive_utc()); - sqlx::query("INSERT INTO blocked_actors (local_user_id, remote_actor_url, blocked_at) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING") - .bind(&uid).bind(actor_url).bind(&ts).execute(&self.pool).await?; - Ok(()) - } - async fn remove_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> { - let uid = local_user_id.to_string(); - sqlx::query( - "DELETE FROM blocked_actors WHERE local_user_id = $1 AND remote_actor_url = $2", - ) - .bind(&uid) - .bind(actor_url) - .execute(&self.pool) - .await?; - Ok(()) - } - async fn get_blocked_actors(&self, local_user_id: uuid::Uuid) -> Result> { - let uid = local_user_id.to_string(); - let rows = sqlx::query("SELECT remote_actor_url FROM blocked_actors WHERE local_user_id = $1 ORDER BY blocked_at DESC") - .bind(&uid).fetch_all(&self.pool).await?; - Ok(rows - .iter() - .map(|r| r.get::("remote_actor_url")) - .collect()) - } - async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result { - let uid = local_user_id.to_string(); - let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM blocked_actors WHERE local_user_id = $1 AND remote_actor_url = $2") - .bind(&uid).bind(actor_url).fetch_one(&self.pool).await?; - Ok(count > 0) - } -} - -#[async_trait] -impl ActivityRepository for PostgresFederationRepository { - async fn is_activity_processed(&self, activity_id: &str) -> Result { - let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ap_activities WHERE id = $1") - .bind(activity_id) - .fetch_one(&self.pool) - .await?; - Ok(count > 0) - } - async fn mark_activity_processed(&self, activity_id: &str) -> Result<()> { - let ts = datetime_to_str(&Utc::now().naive_utc()); - sqlx::query( - "INSERT INTO ap_activities (id, processed_at) VALUES ($1, $2) ON CONFLICT DO NOTHING", - ) - .bind(activity_id) - .bind(&ts) - .execute(&self.pool) - .await?; - Ok(()) - } -} - -#[async_trait] -impl RemoteReviewRepository for PostgresFederationRepository { - async fn save_remote_review( - &self, - review: &Review, - ap_id: &str, - movie_title: &str, - release_year: u16, - poster_url: Option<&str>, - ) -> Result<()> { - let actor_url = match review.source() { - ReviewSource::Remote { actor_url } => actor_url.clone(), - ReviewSource::Local => { - return Err(anyhow!("save_remote_review called with a local review")); - } - }; - let movie_id = review.movie_id().value().to_string(); - sqlx::query( - "INSERT INTO movies (id, external_metadata_id, title, release_year, director, poster_path) - VALUES ($1, NULL, $2, $3, NULL, $4) - ON CONFLICT(id) DO UPDATE SET - poster_path = COALESCE(EXCLUDED.poster_path, movies.poster_path)", - ) - .bind(&movie_id) - .bind(movie_title) - .bind(release_year.max(1888) as i64) - .bind(poster_url) - .execute(&self.pool) - .await?; - - let id = review.id().value().to_string(); - let user_id = review.user_id().value().to_string(); - let rating = review.rating().value() as i64; - let comment = review.comment().map(|c| c.value().to_string()); - let watched_at = datetime_to_str(review.watched_at()); - let created_at = datetime_to_str(review.created_at()); - sqlx::query( - "INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, ap_id) - VALUES ($1, $2, $3, $4, $5, $6::timestamptz, $7::timestamptz, $8, $9) - ON CONFLICT DO NOTHING", - ) - .bind(&id) - .bind(&movie_id) - .bind(&user_id) - .bind(rating) - .bind(&comment) - .bind(&watched_at) - .bind(&created_at) - .bind(&actor_url) - .bind(ap_id) - .execute(&self.pool) - .await?; - Ok(()) - } - - async fn delete_remote_review(&self, ap_id: &str, actor_url: &str) -> Result<()> { - sqlx::query("DELETE FROM reviews WHERE ap_id = $1 AND remote_actor_url = $2") - .bind(ap_id) - .bind(actor_url) - .execute(&self.pool) - .await?; - Ok(()) - } - - async fn update_remote_review( - &self, - ap_id: &str, - actor_url: &str, - rating: u8, - comment: Option<&str>, - watched_at: chrono::NaiveDateTime, - poster_url: Option<&str>, - ) -> Result<()> { - let watched_at_str = datetime_to_str(&watched_at); - sqlx::query( - "UPDATE reviews SET rating = $1, comment = $2, watched_at = $3::timestamptz - WHERE ap_id = $4 AND remote_actor_url = $5", - ) - .bind(rating as i64) - .bind(comment) - .bind(&watched_at_str) - .bind(ap_id) - .bind(actor_url) - .execute(&self.pool) - .await?; - if let Some(url) = poster_url { - sqlx::query( - "UPDATE movies SET poster_path = $1 - WHERE id = (SELECT movie_id FROM reviews WHERE ap_id = $2 AND remote_actor_url = $3)", - ) - .bind(url) - .bind(ap_id) - .bind(actor_url) - .execute(&self.pool) - .await?; - } - Ok(()) - } - - async fn delete_by_actor(&self, actor_url: &str) -> Result<()> { - sqlx::query("DELETE FROM reviews WHERE remote_actor_url = $1") - .bind(actor_url) - .execute(&self.pool) - .await?; - Ok(()) - } -} - -#[async_trait] -impl domain::ports::SocialQueryPort for PostgresFederationRepository { - async fn get_accepted_following_urls( - &self, - user_id: uuid::Uuid, - ) -> Result, domain::errors::DomainError> { - let user_id_str = user_id.to_string(); - sqlx::query_scalar::<_, String>( - "SELECT remote_actor_url FROM ap_following WHERE local_user_id = $1 AND status = 'accepted'", - ) - .bind(&user_id_str) - .fetch_all(&self.pool) - .await - .map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string())) - } - - async fn list_all_followed_remote_actors( - &self, - ) -> Result, domain::errors::DomainError> { - let rows = sqlx::query_as::<_, (String, String, Option)>( - "SELECT DISTINCT ar.url, ar.handle, ar.display_name - FROM ap_remote_actors ar - JOIN ap_following f ON f.remote_actor_url = ar.url - WHERE f.status = 'accepted'", - ) - .fetch_all(&self.pool) - .await - .map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?; - Ok(rows - .into_iter() - .map( - |(url, handle, display_name)| domain::models::RemoteActorInfo { - url, - handle, - display_name, - }, - ) - .collect()) - } - - async fn count_following( - &self, - user_id: uuid::Uuid, - ) -> Result { - let uid = user_id.to_string(); - let count: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM ap_following WHERE local_user_id = $1 AND status = 'accepted'", - ) - .bind(&uid) - .fetch_one(&self.pool) - .await - .map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?; - Ok(count as usize) - } - - async fn count_accepted_followers( - &self, - user_id: uuid::Uuid, - ) -> Result { - let uid = user_id.to_string(); - let count: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM ap_followers WHERE local_user_id = $1 AND status = 'accepted'", - ) - .bind(&uid) - .fetch_one(&self.pool) - .await - .map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?; - Ok(count as usize) - } - - async fn get_pending_followers( - &self, - user_id: uuid::Uuid, - ) -> Result, domain::errors::DomainError> { - let uid = user_id.to_string(); - let rows = sqlx::query_as::<_, (String, String, Option, Option)>( - "SELECT ar.url, ar.handle, ar.display_name, ar.avatar_url - FROM ap_followers f - JOIN ap_remote_actors ar ON ar.url = f.remote_actor_url - WHERE f.local_user_id = $1 AND f.status = 'pending'", - ) - .bind(&uid) - .fetch_all(&self.pool) - .await - .map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?; - Ok(rows - .into_iter() - .map( - |(url, handle, display_name, avatar_url)| domain::models::PendingFollowerInfo { - url, - handle, - display_name, - avatar_url, - }, - ) - .collect()) - } -} - -#[async_trait] -impl RemoteWatchlistRepository for PostgresFederationRepository { - async fn save(&self, entry: RemoteWatchlistEntry) -> Result<(), domain::errors::DomainError> { - sqlx::query( - "INSERT INTO ap_remote_watchlist_entries \ - (ap_id, actor_url, movie_title, release_year, external_metadata_id, poster_url, added_at) \ - VALUES ($1, $2, $3, $4, $5, $6, $7) \ - ON CONFLICT(ap_id) DO UPDATE SET \ - movie_title=excluded.movie_title, release_year=excluded.release_year, \ - external_metadata_id=excluded.external_metadata_id, poster_url=excluded.poster_url", - ) - .bind(&entry.ap_id) - .bind(&entry.actor_url) - .bind(&entry.movie_title) - .bind(entry.release_year as i32) - .bind(&entry.external_metadata_id) - .bind(&entry.poster_url) - .bind(entry.added_at) - .execute(&self.pool) - .await - .map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?; - Ok(()) - } - - async fn remove_by_ap_id( - &self, - ap_id: &str, - actor_url: &str, - ) -> Result<(), domain::errors::DomainError> { - sqlx::query("DELETE FROM ap_remote_watchlist_entries WHERE ap_id = $1 AND actor_url = $2") - .bind(ap_id) - .bind(actor_url) - .execute(&self.pool) - .await - .map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?; - Ok(()) - } - - async fn get_by_actor_url( - &self, - actor_url: &str, - ) -> Result, domain::errors::DomainError> { - let rows = sqlx::query( - "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", - ) - .bind(actor_url) - .fetch_all(&self.pool) - .await - .map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?; - - rows.into_iter() - .map(|row| { - 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::("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: row - .try_get::, _>("added_at") - .unwrap_or_else(|_| chrono::Utc::now()), - }) - }) - .collect() - } - - 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") - .bind(actor_url) - .execute(&self.pool) - .await - .map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?; - Ok(()) - } - - async fn get_by_derived_uuid( - &self, - uuid: uuid::Uuid, - ) -> Result, domain::errors::DomainError> { - let actors: Vec = - sqlx::query("SELECT DISTINCT actor_url FROM ap_remote_watchlist_entries") - .fetch_all(&self.pool) - .await - .map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))? - .into_iter() - .filter_map(|row| row.try_get::("actor_url").ok()) - .collect(); - - let target = actors - .into_iter() - .find(|url| uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, url.as_bytes()) == uuid); - - match target { - None => Ok(vec![]), - Some(actor_url) => self.get_by_actor_url(&actor_url).await, - } - } -} - -pub fn wire(pool: sqlx::PgPool) -> activitypub::FederationRepos { +pub fn wire(pool: PgPool) -> activitypub::FederationRepos { let fed = std::sync::Arc::new(PostgresFederationRepository::new(pool)); ( std::sync::Arc::clone(&fed) as _, diff --git a/crates/adapters/postgres/src/remote_goals.rs b/crates/adapters/postgres-federation/src/remote_goals.rs similarity index 100% rename from crates/adapters/postgres/src/remote_goals.rs rename to crates/adapters/postgres-federation/src/remote_goals.rs diff --git a/crates/adapters/postgres-federation/src/review.rs b/crates/adapters/postgres-federation/src/review.rs new file mode 100644 index 0000000..711f1ae --- /dev/null +++ b/crates/adapters/postgres-federation/src/review.rs @@ -0,0 +1,116 @@ +use activitypub::RemoteReviewRepository; +use anyhow::{Result, anyhow}; +use async_trait::async_trait; +use domain::models::{Review, ReviewSource}; + +use super::{PostgresFederationRepository, datetime_to_str}; + +#[async_trait] +impl RemoteReviewRepository for PostgresFederationRepository { + async fn save_remote_review( + &self, + review: &Review, + ap_id: &str, + movie_title: &str, + release_year: u16, + external_metadata_id: Option<&str>, + poster_url: Option<&str>, + ) -> Result<()> { + let actor_url = match review.source() { + ReviewSource::Remote { actor_url } => actor_url.clone(), + ReviewSource::Local => { + return Err(anyhow!("save_remote_review called with a local review")); + } + }; + let movie_id = review.movie_id().value().to_string(); + sqlx::query( + "INSERT INTO movies (id, external_metadata_id, title, release_year, director, poster_path) + VALUES ($1, $2, $3, $4, NULL, $5) + ON CONFLICT(id) DO UPDATE SET + external_metadata_id = COALESCE(EXCLUDED.external_metadata_id, movies.external_metadata_id), + poster_path = COALESCE(EXCLUDED.poster_path, movies.poster_path)", + ) + .bind(&movie_id) + .bind(external_metadata_id) + .bind(movie_title) + .bind(release_year.max(1888) as i64) + .bind(poster_url) + .execute(&self.pool) + .await?; + + let id = review.id().value().to_string(); + let user_id = review.user_id().value().to_string(); + let rating = review.rating().value() as i64; + let comment = review.comment().map(|c| c.value().to_string()); + let watched_at = datetime_to_str(review.watched_at()); + let created_at = datetime_to_str(review.created_at()); + sqlx::query( + "INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, ap_id) + VALUES ($1, $2, $3, $4, $5, $6::timestamptz, $7::timestamptz, $8, $9) ON CONFLICT DO NOTHING", + ) + .bind(&id) + .bind(&movie_id) + .bind(&user_id) + .bind(rating) + .bind(&comment) + .bind(&watched_at) + .bind(&created_at) + .bind(&actor_url) + .bind(ap_id) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn delete_remote_review(&self, ap_id: &str, actor_url: &str) -> Result<()> { + sqlx::query("DELETE FROM reviews WHERE ap_id = $1 AND remote_actor_url = $2") + .bind(ap_id) + .bind(actor_url) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn update_remote_review( + &self, + ap_id: &str, + actor_url: &str, + rating: u8, + comment: Option<&str>, + watched_at: chrono::NaiveDateTime, + poster_url: Option<&str>, + ) -> Result<()> { + let watched_at_str = datetime_to_str(&watched_at); + sqlx::query( + "UPDATE reviews SET rating = $1, comment = $2, watched_at = $3::timestamptz + WHERE ap_id = $4 AND remote_actor_url = $5", + ) + .bind(rating as i64) + .bind(comment) + .bind(&watched_at_str) + .bind(ap_id) + .bind(actor_url) + .execute(&self.pool) + .await?; + if let Some(url) = poster_url { + sqlx::query( + "UPDATE movies SET poster_path = $1 + WHERE id = (SELECT movie_id FROM reviews WHERE ap_id = $2 AND remote_actor_url = $3)", + ) + .bind(url) + .bind(ap_id) + .bind(actor_url) + .execute(&self.pool) + .await?; + } + Ok(()) + } + + async fn delete_by_actor(&self, actor_url: &str) -> Result<()> { + sqlx::query("DELETE FROM reviews WHERE remote_actor_url = $1") + .bind(actor_url) + .execute(&self.pool) + .await?; + Ok(()) + } +} diff --git a/crates/adapters/postgres-federation/src/social.rs b/crates/adapters/postgres-federation/src/social.rs new file mode 100644 index 0000000..82ff877 --- /dev/null +++ b/crates/adapters/postgres-federation/src/social.rs @@ -0,0 +1,81 @@ +use async_trait::async_trait; +use domain::{ + errors::DomainError, + models::{PendingFollowerInfo, RemoteActorInfo}, + ports::SocialQueryPort, +}; + +use super::PostgresFederationRepository; + +#[async_trait] +impl SocialQueryPort for PostgresFederationRepository { + async fn get_accepted_following_urls( + &self, + user_id: uuid::Uuid, + ) -> Result, DomainError> { + let user_id_str = user_id.to_string(); + sqlx::query_scalar::<_, String>( + "SELECT remote_actor_url FROM ap_following WHERE local_user_id = $1 AND status = 'accepted'", + ).bind(&user_id_str).fetch_all(&self.pool).await + .map_err(|e| DomainError::InfrastructureError(e.to_string())) + } + + async fn list_all_followed_remote_actors(&self) -> Result, DomainError> { + let rows = sqlx::query_as::<_, (String, String, Option)>( + "SELECT DISTINCT ar.url, ar.handle, ar.display_name FROM ap_remote_actors ar JOIN ap_following f ON f.remote_actor_url = ar.url WHERE f.status = 'accepted'", + ).fetch_all(&self.pool).await.map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + Ok(rows + .into_iter() + .map(|(url, handle, display_name)| RemoteActorInfo { + url, + handle, + display_name, + }) + .collect()) + } + + async fn count_following(&self, user_id: uuid::Uuid) -> Result { + let uid = user_id.to_string(); + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM ap_following WHERE local_user_id = $1 AND status = 'accepted'", + ) + .bind(&uid) + .fetch_one(&self.pool) + .await + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + Ok(count as usize) + } + + async fn count_accepted_followers(&self, user_id: uuid::Uuid) -> Result { + let uid = user_id.to_string(); + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM ap_followers WHERE local_user_id = $1 AND status = 'accepted'", + ) + .bind(&uid) + .fetch_one(&self.pool) + .await + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + Ok(count as usize) + } + + async fn get_pending_followers( + &self, + user_id: uuid::Uuid, + ) -> Result, DomainError> { + let uid = user_id.to_string(); + let rows = sqlx::query_as::<_, (String, String, Option, Option)>( + "SELECT ar.url, ar.handle, ar.display_name, ar.avatar_url FROM ap_followers f JOIN ap_remote_actors ar ON ar.url = f.remote_actor_url WHERE f.local_user_id = $1 AND f.status = 'pending'", + ).bind(&uid).fetch_all(&self.pool).await.map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + Ok(rows + .into_iter() + .map( + |(url, handle, display_name, avatar_url)| PendingFollowerInfo { + url, + handle, + display_name, + avatar_url, + }, + ) + .collect()) + } +} diff --git a/crates/adapters/postgres-federation/src/watchlist.rs b/crates/adapters/postgres-federation/src/watchlist.rs new file mode 100644 index 0000000..108a1da --- /dev/null +++ b/crates/adapters/postgres-federation/src/watchlist.rs @@ -0,0 +1,91 @@ +use async_trait::async_trait; +use domain::{errors::DomainError, models::RemoteWatchlistEntry, ports::RemoteWatchlistRepository}; +use sqlx::Row; + +use super::PostgresFederationRepository; + +#[async_trait] +impl RemoteWatchlistRepository for PostgresFederationRepository { + async fn save(&self, entry: RemoteWatchlistEntry) -> Result<(), DomainError> { + sqlx::query( + "INSERT INTO ap_remote_watchlist_entries \ + (ap_id, actor_url, movie_title, release_year, external_metadata_id, poster_url, added_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7) \ + ON CONFLICT(ap_id) DO UPDATE SET \ + movie_title=excluded.movie_title, release_year=excluded.release_year, \ + external_metadata_id=excluded.external_metadata_id, poster_url=excluded.poster_url", + ) + .bind(&entry.ap_id).bind(&entry.actor_url).bind(&entry.movie_title) + .bind(entry.release_year as i32).bind(&entry.external_metadata_id).bind(&entry.poster_url) + .bind(entry.added_at) + .execute(&self.pool).await + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + Ok(()) + } + + async fn remove_by_ap_id(&self, ap_id: &str, actor_url: &str) -> Result<(), DomainError> { + sqlx::query("DELETE FROM ap_remote_watchlist_entries WHERE ap_id = $1 AND actor_url = $2") + .bind(ap_id) + .bind(actor_url) + .execute(&self.pool) + .await + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + Ok(()) + } + + async fn get_by_actor_url( + &self, + actor_url: &str, + ) -> Result, DomainError> { + let rows = sqlx::query( + "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", + ).bind(actor_url).fetch_all(&self.pool).await + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + rows.into_iter() + .map(|row| { + 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::("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: row + .try_get::, _>("added_at") + .unwrap_or_else(|_| chrono::Utc::now()), + }) + }) + .collect() + } + + async fn remove_all_by_actor(&self, actor_url: &str) -> Result<(), DomainError> { + sqlx::query("DELETE FROM ap_remote_watchlist_entries WHERE actor_url = $1") + .bind(actor_url) + .execute(&self.pool) + .await + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + Ok(()) + } + + async fn get_by_derived_uuid( + &self, + uuid: uuid::Uuid, + ) -> Result, DomainError> { + let actors: Vec = + sqlx::query("SELECT DISTINCT actor_url FROM ap_remote_watchlist_entries") + .fetch_all(&self.pool) + .await + .map_err(|e| DomainError::InfrastructureError(e.to_string()))? + .into_iter() + .filter_map(|row| row.try_get::("actor_url").ok()) + .collect(); + let target = actors + .into_iter() + .find(|url| uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, url.as_bytes()) == uuid); + match target { + None => Ok(vec![]), + Some(actor_url) => self.get_by_actor_url(&actor_url).await, + } + } +} diff --git a/crates/adapters/postgres/Cargo.toml b/crates/adapters/postgres/Cargo.toml index 7bc9fc3..26661ba 100644 --- a/crates/adapters/postgres/Cargo.toml +++ b/crates/adapters/postgres/Cargo.toml @@ -12,6 +12,7 @@ sqlx = { version = "0.8.6", features = [ "chrono", ] } domain = { workspace = true } +postgres-federation = { workspace = true } anyhow = { workspace = true } uuid = { workspace = true } chrono = { workspace = true } diff --git a/crates/adapters/postgres/src/lib.rs b/crates/adapters/postgres/src/lib.rs index 8b4789c..91d3f20 100644 --- a/crates/adapters/postgres/src/lib.rs +++ b/crates/adapters/postgres/src/lib.rs @@ -1,7 +1,6 @@ use domain::errors::DomainError; use sqlx::PgPool; -mod ap_content; mod diary; mod goals; mod image_ref; @@ -9,11 +8,11 @@ mod import_profile; mod import_session; mod models; mod movie; +mod movie_dedup; mod persons; mod profile; mod profile_fields; mod refresh_sessions; -mod remote_goals; mod review; mod stats; mod user_settings; @@ -22,13 +21,14 @@ mod watch_event; mod watchlist; mod wrapup; -pub use ap_content::PostgresApContentQuery; pub use diary::PostgresDiaryRepository; pub use image_ref::{PostgresImageRefAdapter, create_image_ref}; pub use import_profile::PostgresImportProfileRepository; pub use import_session::PostgresImportSessionRepository; pub use movie::PostgresMovieRepository; +pub use movie_dedup::PostgresMovieDeduplicator; pub use persons::{PostgresPersonAdapter, create_person_adapter}; +pub use postgres_federation::PostgresApContentQuery; pub use profile::PostgresMovieProfileRepository; pub use profile_fields::PostgresProfileFieldsRepository; pub use refresh_sessions::PostgresRefreshSessionAdapter; @@ -95,6 +95,7 @@ pub struct PostgresWireOutput { pub user_settings: std::sync::Arc, pub federation_settings: std::sync::Arc, pub remote_goal: std::sync::Arc, + pub deduplicator: std::sync::Arc, } pub async fn wire(database_url: &str) -> anyhow::Result { @@ -132,7 +133,9 @@ pub async fn wire(database_url: &str) -> anyhow::Result { goal: std::sync::Arc::new(goals::PostgresGoalRepository::new(pool.clone())) as _, user_settings: std::sync::Arc::clone(&user_settings_repo) as _, federation_settings: user_settings_repo as _, - remote_goal: std::sync::Arc::new(remote_goals::PostgresRemoteGoalRepository::new(pool)) - as _, + remote_goal: std::sync::Arc::new(postgres_federation::PostgresRemoteGoalRepository::new( + pool.clone(), + )) as _, + deduplicator: std::sync::Arc::new(PostgresMovieDeduplicator::new(pool)) as _, }) } diff --git a/crates/adapters/postgres/src/movie.rs b/crates/adapters/postgres/src/movie.rs index 584b18a..a3b1f61 100644 --- a/crates/adapters/postgres/src/movie.rs +++ b/crates/adapters/postgres/src/movie.rs @@ -239,4 +239,17 @@ impl MovieRepository for PostgresMovieRepository { offset: page.offset, }) } + + async fn list_movies_with_external_id(&self) -> Result, DomainError> { + sqlx::query_as::<_, MovieRow>( + "SELECT id, external_metadata_id, title, release_year, director, poster_path + FROM movies WHERE external_metadata_id IS NOT NULL", + ) + .fetch_all(&self.pool) + .await + .map_err(Self::map_err)? + .into_iter() + .map(|r| r.into_domain()) + .collect() + } } diff --git a/crates/adapters/postgres/src/movie_dedup.rs b/crates/adapters/postgres/src/movie_dedup.rs new file mode 100644 index 0000000..4507e32 --- /dev/null +++ b/crates/adapters/postgres/src/movie_dedup.rs @@ -0,0 +1,154 @@ +use async_trait::async_trait; +use domain::{ + errors::DomainError, models::Movie, ports::MovieDeduplicator, value_objects::MovieId, +}; +use sqlx::PgPool; + +pub struct PostgresMovieDeduplicator { + pool: PgPool, +} + +impl PostgresMovieDeduplicator { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } + + fn map_err(e: sqlx::Error) -> DomainError { + tracing::error!("Database error: {:?}", e); + DomainError::InfrastructureError("Database operation failed".into()) + } +} + +#[async_trait] +impl MovieDeduplicator for PostgresMovieDeduplicator { + async fn merge_into_canonical( + &self, + old_id: &MovieId, + canonical: &Movie, + ) -> Result { + let old = old_id.value().to_string(); + let new = canonical.id().value().to_string(); + let ext_id = canonical + .external_metadata_id() + .map(|id| id.value().to_string()); + let title = canonical.title().value().to_string(); + let year = canonical.release_year().value() as i64; + let director = canonical.director().map(str::to_string); + let poster = canonical.poster_path().map(|p| p.value().to_string()); + + let mut tx = self.pool.begin().await.map_err(Self::map_err)?; + + // 1. Upsert canonical movie record + sqlx::query( + "INSERT INTO movies (id, external_metadata_id, title, release_year, director, poster_path) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT(id) DO UPDATE SET + external_metadata_id = COALESCE(EXCLUDED.external_metadata_id, movies.external_metadata_id), + poster_path = COALESCE(EXCLUDED.poster_path, movies.poster_path)", + ) + .bind(&new).bind(&ext_id).bind(&title).bind(year).bind(&director).bind(&poster) + .execute(&mut *tx).await.map_err(Self::map_err)?; + + // 2. Re-point simple FK tables + let reviews = sqlx::query("UPDATE reviews SET movie_id = $1 WHERE movie_id = $2") + .bind(&new) + .bind(&old) + .execute(&mut *tx) + .await + .map_err(Self::map_err)? + .rows_affected(); + + let watchlist = + sqlx::query("UPDATE watchlist_entries SET movie_id = $1 WHERE movie_id = $2") + .bind(&new) + .bind(&old) + .execute(&mut *tx) + .await + .map_err(Self::map_err)? + .rows_affected(); + + let watch_events = sqlx::query("UPDATE watch_events SET movie_id = $1 WHERE movie_id = $2") + .bind(&new) + .bind(&old) + .execute(&mut *tx) + .await + .map_err(Self::map_err)? + .rows_affected(); + + // 3. Re-point movie_profiles (PK — move only if canonical has none) + let profiles = sqlx::query("UPDATE movie_profiles SET movie_id = $1 WHERE movie_id = $2") + .bind(&new) + .bind(&old) + .execute(&mut *tx) + .await + .map_err(Self::map_err)? + .rows_affected(); + + // 4. Re-point enrichment tables with composite PKs (INSERT … ON CONFLICT DO NOTHING + DELETE) + // Canonical's existing rows win on conflict — old duplicates are discarded. + sqlx::query( + "INSERT INTO movie_genres (movie_id, tmdb_id, name) + SELECT $1, tmdb_id, name FROM movie_genres WHERE movie_id = $2 + ON CONFLICT DO NOTHING", + ) + .bind(&new) + .bind(&old) + .execute(&mut *tx) + .await + .map_err(Self::map_err)?; + sqlx::query("DELETE FROM movie_genres WHERE movie_id = $1") + .bind(&old) + .execute(&mut *tx) + .await + .map_err(Self::map_err)?; + + sqlx::query( + "INSERT INTO movie_keywords (movie_id, tmdb_id, name) + SELECT $1, tmdb_id, name FROM movie_keywords WHERE movie_id = $2 + ON CONFLICT DO NOTHING", + ) + .bind(&new) + .bind(&old) + .execute(&mut *tx) + .await + .map_err(Self::map_err)?; + sqlx::query("DELETE FROM movie_keywords WHERE movie_id = $1") + .bind(&old) + .execute(&mut *tx) + .await + .map_err(Self::map_err)?; + + sqlx::query( + "INSERT INTO movie_cast (movie_id, tmdb_person_id, name, character, billing_order, profile_path) + SELECT $1, tmdb_person_id, name, character, billing_order, profile_path FROM movie_cast WHERE movie_id = $2 + ON CONFLICT DO NOTHING", + ).bind(&new).bind(&old).execute(&mut *tx).await.map_err(Self::map_err)?; + sqlx::query("DELETE FROM movie_cast WHERE movie_id = $1") + .bind(&old) + .execute(&mut *tx) + .await + .map_err(Self::map_err)?; + + sqlx::query( + "INSERT INTO movie_crew (movie_id, tmdb_person_id, name, job, department, profile_path) + SELECT $1, tmdb_person_id, name, job, department, profile_path FROM movie_crew WHERE movie_id = $2 + ON CONFLICT DO NOTHING", + ).bind(&new).bind(&old).execute(&mut *tx).await.map_err(Self::map_err)?; + sqlx::query("DELETE FROM movie_crew WHERE movie_id = $1") + .bind(&old) + .execute(&mut *tx) + .await + .map_err(Self::map_err)?; + + // 5. Delete the now-empty old movie record (remaining cascades are safe: all FKs cleared above) + sqlx::query("DELETE FROM movies WHERE id = $1") + .bind(&old) + .execute(&mut *tx) + .await + .map_err(Self::map_err)?; + + tx.commit().await.map_err(Self::map_err)?; + + Ok(reviews + watchlist + watch_events + profiles) + } +} diff --git a/crates/adapters/sqlite-federation/Cargo.toml b/crates/adapters/sqlite-federation/Cargo.toml index d3adaeb..f193276 100644 --- a/crates/adapters/sqlite-federation/Cargo.toml +++ b/crates/adapters/sqlite-federation/Cargo.toml @@ -6,7 +6,7 @@ edition = "2024" [dependencies] sqlx = { workspace = true } activitypub = { workspace = true } -k-ap = { version = "0.4.0", registry = "gitea" } +k-ap = { version = "0.4.1", registry = "gitea" } domain = { workspace = true } anyhow = { workspace = true } serde_json = { workspace = true } diff --git a/crates/adapters/sqlite-federation/src/activity.rs b/crates/adapters/sqlite-federation/src/activity.rs new file mode 100644 index 0000000..6953a72 --- /dev/null +++ b/crates/adapters/sqlite-federation/src/activity.rs @@ -0,0 +1,27 @@ +use anyhow::Result; +use async_trait::async_trait; +use chrono::Utc; +use k_ap::ActivityRepository; + +use super::{SqliteFederationRepository, datetime_to_str}; + +#[async_trait] +impl ActivityRepository for SqliteFederationRepository { + async fn is_activity_processed(&self, activity_id: &str) -> Result { + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ap_activities WHERE id = ?1") + .bind(activity_id) + .fetch_one(&self.pool) + .await?; + Ok(count > 0) + } + + async fn mark_activity_processed(&self, activity_id: &str) -> Result<()> { + let ts = datetime_to_str(&Utc::now().naive_utc()); + sqlx::query("INSERT OR IGNORE INTO ap_activities (id, processed_at) VALUES (?1, ?2)") + .bind(activity_id) + .bind(&ts) + .execute(&self.pool) + .await?; + Ok(()) + } +} diff --git a/crates/adapters/sqlite-federation/src/actor.rs b/crates/adapters/sqlite-federation/src/actor.rs new file mode 100644 index 0000000..2ea918c --- /dev/null +++ b/crates/adapters/sqlite-federation/src/actor.rs @@ -0,0 +1,117 @@ +use anyhow::Result; +use async_trait::async_trait; +use chrono::Utc; +use k_ap::{ActorRepository, RemoteActor}; +use sqlx::Row; + +use super::{SqliteFederationRepository, datetime_to_str, remote_actor_from_row}; + +#[async_trait] +impl ActorRepository for SqliteFederationRepository { + async fn get_local_actor_keypair( + &self, + user_id: uuid::Uuid, + ) -> Result> { + let uid = user_id.to_string(); + let row = + sqlx::query("SELECT public_key, private_key FROM ap_local_actors WHERE user_id = ?") + .bind(&uid) + .fetch_optional(&self.pool) + .await?; + Ok(row.map(|r| (r.get("public_key"), r.get("private_key")))) + } + + async fn save_local_actor_keypair( + &self, + user_id: uuid::Uuid, + public_key: String, + private_key: String, + ) -> Result<()> { + let uid = user_id.to_string(); + let now = Utc::now().naive_utc(); + let created_at = datetime_to_str(&now); + sqlx::query( + "INSERT INTO ap_local_actors (user_id, public_key, private_key, created_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + public_key = excluded.public_key, + private_key = excluded.private_key", + ) + .bind(&uid) + .bind(&public_key) + .bind(&private_key) + .bind(&created_at) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()> { + let now = Utc::now().naive_utc(); + let fetched_at = datetime_to_str(&now); + let aka_json = serde_json::to_string(&actor.also_known_as).unwrap_or_default(); + sqlx::query( + "INSERT INTO ap_remote_actors (url, handle, inbox_url, shared_inbox_url, display_name, avatar_url, outbox_url, bio, banner_url, followers_url, following_url, also_known_as, fetched_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(url) DO UPDATE SET + handle = excluded.handle, + inbox_url = excluded.inbox_url, + shared_inbox_url = excluded.shared_inbox_url, + display_name = excluded.display_name, + avatar_url = excluded.avatar_url, + outbox_url = COALESCE(excluded.outbox_url, ap_remote_actors.outbox_url), + bio = excluded.bio, + banner_url = excluded.banner_url, + followers_url = excluded.followers_url, + following_url = excluded.following_url, + also_known_as = excluded.also_known_as, + fetched_at = excluded.fetched_at", + ) + .bind(&actor.url).bind(&actor.handle).bind(&actor.inbox_url).bind(&actor.shared_inbox_url) + .bind(&actor.display_name).bind(&actor.avatar_url).bind(&actor.outbox_url) + .bind(&actor.bio).bind(&actor.banner_url).bind(&actor.followers_url).bind(&actor.following_url) + .bind(&aka_json).bind(&fetched_at) + .execute(&self.pool).await?; + Ok(()) + } + + async fn get_remote_actor(&self, actor_url: &str) -> Result> { + let row = sqlx::query( + "SELECT url, handle, inbox_url, shared_inbox_url, display_name, avatar_url, + outbox_url, bio, banner_url, followers_url, following_url, also_known_as, fetched_at + FROM ap_remote_actors WHERE url = ?", + ).bind(actor_url).fetch_optional(&self.pool).await?; + Ok(row.as_ref().map(|r| remote_actor_from_row(r, "url"))) + } + + async fn add_announce( + &self, + activity_id: &str, + object_url: &str, + actor_url: &str, + announced_at: chrono::DateTime, + ) -> Result<()> { + let ts = announced_at.format("%Y-%m-%d %H:%M:%S").to_string(); + sqlx::query( + "INSERT OR IGNORE INTO ap_announces (id, object_url, actor_url, announced_at) VALUES (?1, ?2, ?3, ?4)", + ).bind(activity_id).bind(object_url).bind(actor_url).bind(&ts).execute(&self.pool).await?; + Ok(()) + } + + async fn remove_announce(&self, activity_id: &str, actor_url: &str) -> Result<()> { + sqlx::query("DELETE FROM ap_announces WHERE id = ?1 AND actor_url = ?2") + .bind(activity_id) + .bind(actor_url) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn count_announces(&self, object_url: &str) -> Result { + let row = sqlx::query("SELECT COUNT(*) as cnt FROM ap_announces WHERE object_url = ?1") + .bind(object_url) + .fetch_one(&self.pool) + .await?; + Ok(row.get::("cnt") as usize) + } +} diff --git a/crates/adapters/sqlite-federation/src/ap_content.rs b/crates/adapters/sqlite-federation/src/ap_content.rs new file mode 100644 index 0000000..3a0e3db --- /dev/null +++ b/crates/adapters/sqlite-federation/src/ap_content.rs @@ -0,0 +1,463 @@ +use async_trait::async_trait; +use domain::{ + errors::DomainError, + models::{ + DiaryEntry, Goal, GoalType, Movie, PersistedReview, Review, ReviewSource, WatchlistEntry, + WatchlistWithMovie, + }, + ports::LocalApContentQuery, + value_objects::{ + Comment, ExternalMetadataId, GoalId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear, + ReviewId, UserId, WatchlistEntryId, + }, +}; +use sqlx::{Row, SqlitePool}; +use uuid::Uuid; + +pub struct SqliteApContentQuery { + pool: SqlitePool, +} + +impl SqliteApContentQuery { + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } + + fn map_err(e: sqlx::Error) -> DomainError { + tracing::error!("Database error: {:?}", e); + DomainError::InfrastructureError("Database operation failed".into()) + } +} + +// ── Local row types ────────────────────────────────────────────────────────── + +fn parse_uuid(s: &str) -> Result { + Uuid::parse_str(s) + .map_err(|e| DomainError::InfrastructureError(format!("Invalid UUID '{}': {}", s, e))) +} + +fn parse_datetime(s: &str) -> Result { + chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") + .map_err(|e| DomainError::InfrastructureError(format!("Invalid datetime '{}': {}", s, e))) +} + +#[derive(sqlx::FromRow)] +struct MovieRow { + id: String, + external_metadata_id: Option, + title: String, + release_year: i64, + director: Option, + poster_path: Option, +} + +impl MovieRow { + fn into_domain(self) -> Result { + let id = MovieId::from_uuid(parse_uuid(&self.id)?); + let external_metadata_id = self + .external_metadata_id + .map(ExternalMetadataId::new) + .transpose()?; + let title = MovieTitle::new(self.title)?; + let release_year = ReleaseYear::new(self.release_year as u16)?; + let poster_path = self.poster_path.map(PosterPath::new).transpose()?; + Ok(Movie::from_persistence( + id, + external_metadata_id, + title, + release_year, + self.director, + poster_path, + )) + } +} + +#[derive(sqlx::FromRow)] +struct ReviewRow { + id: String, + movie_id: String, + user_id: String, + rating: i64, + comment: Option, + watched_at: String, + created_at: String, + remote_actor_url: Option, +} + +impl ReviewRow { + fn into_domain(self) -> Result { + let id = ReviewId::from_uuid(parse_uuid(&self.id)?); + let movie_id = MovieId::from_uuid(parse_uuid(&self.movie_id)?); + let user_id = UserId::from_uuid(parse_uuid(&self.user_id)?); + let rating = Rating::new(self.rating as u8)?; + let comment = self.comment.map(Comment::new).transpose()?; + let watched_at = parse_datetime(&self.watched_at)?; + let created_at = parse_datetime(&self.created_at)?; + let source = match self.remote_actor_url { + None => ReviewSource::Local, + Some(url) => ReviewSource::Remote { actor_url: url }, + }; + Ok(Review::from_persistence(PersistedReview { + id, + movie_id, + user_id, + rating, + comment, + watched_at, + created_at, + source, + })) + } +} + +#[derive(sqlx::FromRow)] +struct DiaryRow { + id: String, + external_metadata_id: Option, + title: String, + release_year: i64, + director: Option, + poster_path: Option, + review_id: String, + movie_id: String, + user_id: String, + rating: i64, + comment: Option, + watched_at: String, + created_at: String, + remote_actor_url: Option, +} + +impl DiaryRow { + fn into_domain(self) -> Result { + let movie = MovieRow { + id: self.id, + external_metadata_id: self.external_metadata_id, + title: self.title, + release_year: self.release_year, + director: self.director, + poster_path: self.poster_path, + } + .into_domain()?; + let review = ReviewRow { + id: self.review_id, + movie_id: self.movie_id, + user_id: self.user_id, + rating: self.rating, + comment: self.comment, + watched_at: self.watched_at, + created_at: self.created_at, + remote_actor_url: self.remote_actor_url, + } + .into_domain()?; + Ok(DiaryEntry::new(movie, review)) + } +} + +#[derive(sqlx::FromRow)] +struct WatchlistRow { + id: String, + user_id: String, + movie_id: String, + added_at: String, + m_id: String, + external_metadata_id: Option, + title: String, + release_year: i64, + director: Option, + poster_path: Option, +} + +impl WatchlistRow { + fn into_domain(self) -> Result { + let entry = WatchlistEntry { + id: WatchlistEntryId::from_uuid(parse_uuid(&self.id)?), + user_id: UserId::from_uuid(parse_uuid(&self.user_id)?), + movie_id: MovieId::from_uuid(parse_uuid(&self.movie_id)?), + added_at: parse_datetime(&self.added_at)?, + }; + let movie = MovieRow { + id: self.m_id, + external_metadata_id: self.external_metadata_id, + title: self.title, + release_year: self.release_year, + director: self.director, + poster_path: self.poster_path, + } + .into_domain()?; + Ok(WatchlistWithMovie { entry, movie }) + } +} + +fn row_to_goal(r: &sqlx::sqlite::SqliteRow) -> Result { + let id_str: String = r + .try_get("id") + .map_err(|e| DomainError::InfrastructureError(format!("Failed to read goal id: {e}")))?; + let user_id_str: String = r + .try_get("user_id") + .map_err(|e| DomainError::InfrastructureError(format!("Failed to read user_id: {e}")))?; + let year: i64 = r + .try_get("year") + .map_err(|e| DomainError::InfrastructureError(format!("Failed to read year: {e}")))?; + let target: i64 = r.try_get("target_count").map_err(|e| { + DomainError::InfrastructureError(format!("Failed to read target_count: {e}")) + })?; + let goal_type_str: String = r + .try_get("goal_type") + .map_err(|e| DomainError::InfrastructureError(format!("Failed to read goal_type: {e}")))?; + let created_at_str: String = r + .try_get("created_at") + .map_err(|e| DomainError::InfrastructureError(format!("Failed to read created_at: {e}")))?; + + let id = GoalId::from_uuid( + Uuid::parse_str(&id_str) + .map_err(|e| DomainError::InfrastructureError(format!("Invalid goal UUID: {e}")))?, + ); + let user_id = UserId::from_uuid( + Uuid::parse_str(&user_id_str) + .map_err(|e| DomainError::InfrastructureError(format!("Invalid user UUID: {e}")))?, + ); + let goal_type: GoalType = goal_type_str.parse()?; + let created_at = parse_datetime(&created_at_str)?; + + Ok(Goal::from_persistence( + id, + user_id, + year as u16, + target as u32, + goal_type, + created_at, + )) +} + +async fn count_reviews_in_year( + pool: &SqlitePool, + user_id: &UserId, + year: u16, +) -> Result { + let uid = user_id.value().to_string(); + let start = format!("{year}-01-01 00:00:00"); + let end = format!("{}-01-01 00:00:00", year + 1); + + let count: i64 = sqlx::query( + "SELECT COUNT(*) FROM reviews \ + WHERE user_id = ? AND watched_at >= ? AND watched_at < ? \ + AND remote_actor_url IS NULL", + ) + .bind(&uid) + .bind(&start) + .bind(&end) + .fetch_one(pool) + .await + .map_err(|e| { + tracing::error!("Database error: {:?}", e); + DomainError::InfrastructureError("Database operation failed".into()) + })? + .try_get(0) + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + + Ok(count as u32) +} + +// ── LocalApContentQuery impl ───────────────────────────────────────────────── + +#[async_trait] +impl LocalApContentQuery for SqliteApContentQuery { + async fn get_local_reviews_for_user( + &self, + user_id: &UserId, + ) -> Result, DomainError> { + let uid = user_id.value().to_string(); + let rows = sqlx::query_as::<_, DiaryRow>( + "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, + r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url + FROM reviews r + INNER JOIN movies m ON m.id = r.movie_id + WHERE r.user_id = ? AND r.remote_actor_url IS NULL + ORDER BY r.created_at DESC", + ) + .bind(&uid) + .fetch_all(&self.pool) + .await + .map_err(Self::map_err)?; + rows.into_iter().map(DiaryRow::into_domain).collect() + } + + async fn get_local_watchlist_for_user( + &self, + user_id: &UserId, + ) -> Result, DomainError> { + let uid = user_id.value().to_string(); + let rows: Vec = sqlx::query_as( + "SELECT w.id, w.user_id, w.movie_id, w.added_at, + m.id AS m_id, m.external_metadata_id, m.title, m.release_year, + m.director, m.poster_path + FROM watchlist_entries w + JOIN movies m ON m.id = w.movie_id + WHERE w.user_id = ? + ORDER BY w.added_at DESC", + ) + .bind(&uid) + .fetch_all(&self.pool) + .await + .map_err(Self::map_err)?; + rows.into_iter().map(WatchlistRow::into_domain).collect() + } + + async fn get_local_reviews_for_movie( + &self, + movie_id: &MovieId, + ) -> Result, DomainError> { + let mid = movie_id.value().to_string(); + let rows = sqlx::query_as::<_, DiaryRow>( + "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, + r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url + FROM reviews r + INNER JOIN movies m ON m.id = r.movie_id + WHERE r.movie_id = ? AND r.remote_actor_url IS NULL + ORDER BY r.created_at DESC", + ) + .bind(&mid) + .fetch_all(&self.pool) + .await + .map_err(Self::map_err)?; + rows.into_iter().map(DiaryRow::into_domain).collect() + } + + async fn get_review_by_id(&self, review_id: &ReviewId) -> Result, DomainError> { + let id = review_id.value().to_string(); + sqlx::query_as::<_, ReviewRow>( + "SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url + FROM reviews WHERE id = ?", + ) + .bind(&id) + .fetch_optional(&self.pool) + .await + .map_err(Self::map_err)? + .map(ReviewRow::into_domain) + .transpose() + } + + async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result, DomainError> { + let id = movie_id.value().to_string(); + sqlx::query_as::<_, MovieRow>( + "SELECT id, external_metadata_id, title, release_year, director, poster_path + FROM movies WHERE id = ?", + ) + .bind(&id) + .fetch_optional(&self.pool) + .await + .map_err(Self::map_err)? + .map(MovieRow::into_domain) + .transpose() + } + + async fn get_movie_by_external_metadata_id( + &self, + external_id: &str, + ) -> Result, DomainError> { + sqlx::query_as::<_, MovieRow>( + "SELECT id, external_metadata_id, title, release_year, director, poster_path + FROM movies WHERE external_metadata_id = ?", + ) + .bind(external_id) + .fetch_optional(&self.pool) + .await + .map_err(Self::map_err)? + .map(MovieRow::into_domain) + .transpose() + } + + async fn count_local_posts(&self) -> Result { + let count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE remote_actor_url IS NULL") + .fetch_one(&self.pool) + .await + .map_err(Self::map_err)?; + Ok(count as u64) + } + + async fn get_local_reviews_page( + &self, + user_id: &UserId, + before: Option, + limit: usize, + ) -> Result, DomainError> { + let uid = user_id.value().to_string(); + let limit_i64 = limit as i64; + + let rows = if let Some(before_ts) = before { + let ts = before_ts.format("%Y-%m-%d %H:%M:%S").to_string(); + sqlx::query_as::<_, DiaryRow>( + "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, + r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url + FROM reviews r + INNER JOIN movies m ON m.id = r.movie_id + WHERE r.user_id = ? AND r.remote_actor_url IS NULL AND r.watched_at < ? + ORDER BY r.watched_at DESC + LIMIT ?", + ) + .bind(&uid) + .bind(&ts) + .bind(limit_i64) + .fetch_all(&self.pool) + .await + .map_err(Self::map_err)? + } else { + sqlx::query_as::<_, DiaryRow>( + "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, + r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url + FROM reviews r + INNER JOIN movies m ON m.id = r.movie_id + WHERE r.user_id = ? AND r.remote_actor_url IS NULL + ORDER BY r.watched_at DESC + LIMIT ?", + ) + .bind(&uid) + .bind(limit_i64) + .fetch_all(&self.pool) + .await + .map_err(Self::map_err)? + }; + rows.into_iter().map(DiaryRow::into_domain).collect() + } + + async fn get_goal_with_progress( + &self, + user_id: &UserId, + year: u16, + ) -> Result, DomainError> { + let uid = user_id.value().to_string(); + let y = year as i64; + + let row = sqlx::query( + "SELECT id, user_id, year, target_count, goal_type, created_at \ + FROM goals WHERE user_id = ? AND year = ?", + ) + .bind(&uid) + .bind(y) + .fetch_optional(&self.pool) + .await + .map_err(Self::map_err)?; + + let Some(r) = row else { return Ok(None) }; + + let goal = row_to_goal(&r)?; + let count = count_reviews_in_year(&self.pool, user_id, year).await?; + + Ok(Some((goal, count))) + } + + async fn list_goals_for_user(&self, user_id: &UserId) -> Result, DomainError> { + let uid = user_id.value().to_string(); + let rows = sqlx::query( + "SELECT id, user_id, year, target_count, goal_type, created_at \ + FROM goals WHERE user_id = ? ORDER BY created_at DESC", + ) + .bind(&uid) + .fetch_all(&self.pool) + .await + .map_err(Self::map_err)?; + rows.iter().map(row_to_goal).collect() + } +} diff --git a/crates/adapters/sqlite-federation/src/blocklist.rs b/crates/adapters/sqlite-federation/src/blocklist.rs new file mode 100644 index 0000000..57aae94 --- /dev/null +++ b/crates/adapters/sqlite-federation/src/blocklist.rs @@ -0,0 +1,98 @@ +use anyhow::Result; +use async_trait::async_trait; +use chrono::Utc; +use k_ap::{BlockedDomain, BlocklistRepository}; +use sqlx::Row; + +use super::{SqliteFederationRepository, datetime_to_str}; + +#[async_trait] +impl BlocklistRepository for SqliteFederationRepository { + async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> Result<()> { + let now = Utc::now().naive_utc(); + let ts = datetime_to_str(&now); + sqlx::query( + "INSERT INTO blocked_domains (domain, reason, blocked_at) VALUES (?1, ?2, ?3) + ON CONFLICT(domain) DO UPDATE SET reason = excluded.reason", + ) + .bind(domain) + .bind(reason) + .bind(&ts) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn remove_blocked_domain(&self, domain: &str) -> Result<()> { + sqlx::query("DELETE FROM blocked_domains WHERE domain = ?1") + .bind(domain) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn get_blocked_domains(&self) -> Result> { + let rows = sqlx::query( + "SELECT domain, reason, blocked_at FROM blocked_domains ORDER BY blocked_at DESC", + ) + .fetch_all(&self.pool) + .await?; + Ok(rows + .iter() + .map(|r| BlockedDomain { + domain: r.get("domain"), + reason: r.get("reason"), + blocked_at: r.get("blocked_at"), + }) + .collect()) + } + + async fn is_domain_blocked(&self, domain: &str) -> Result { + let count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM blocked_domains WHERE domain = ?1") + .bind(domain) + .fetch_one(&self.pool) + .await?; + Ok(count > 0) + } + + async fn add_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> { + let uid = local_user_id.to_string(); + let ts = datetime_to_str(&Utc::now().naive_utc()); + sqlx::query( + "INSERT OR IGNORE INTO blocked_actors (local_user_id, remote_actor_url, blocked_at) VALUES (?1, ?2, ?3)", + ).bind(&uid).bind(actor_url).bind(&ts).execute(&self.pool).await?; + Ok(()) + } + + async fn remove_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> { + let uid = local_user_id.to_string(); + sqlx::query( + "DELETE FROM blocked_actors WHERE local_user_id = ?1 AND remote_actor_url = ?2", + ) + .bind(&uid) + .bind(actor_url) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn get_blocked_actors(&self, local_user_id: uuid::Uuid) -> Result> { + let uid = local_user_id.to_string(); + let rows = sqlx::query( + "SELECT remote_actor_url FROM blocked_actors WHERE local_user_id = ?1 ORDER BY blocked_at DESC", + ).bind(&uid).fetch_all(&self.pool).await?; + Ok(rows + .iter() + .map(|r| r.get::("remote_actor_url")) + .collect()) + } + + async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result { + let uid = local_user_id.to_string(); + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM blocked_actors WHERE local_user_id = ?1 AND remote_actor_url = ?2", + ).bind(&uid).bind(actor_url).fetch_one(&self.pool).await?; + Ok(count > 0) + } +} diff --git a/crates/adapters/sqlite-federation/src/follow.rs b/crates/adapters/sqlite-federation/src/follow.rs new file mode 100644 index 0000000..b7a8e8b --- /dev/null +++ b/crates/adapters/sqlite-federation/src/follow.rs @@ -0,0 +1,406 @@ +use anyhow::Result; +use async_trait::async_trait; +use chrono::Utc; +use k_ap::{ + ActorRepository, FollowRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor, +}; +use sqlx::Row; + +use super::{ + SqliteFederationRepository, datetime_to_str, remote_actor_from_row, status_to_str, + str_to_status, +}; + +#[async_trait] +impl FollowRepository for SqliteFederationRepository { + async fn add_follower( + &self, + local_user_id: uuid::Uuid, + remote_actor_url: &str, + status: FollowerStatus, + follow_activity_id: &str, + ) -> Result<()> { + let uid = local_user_id.to_string(); + let status_str = status_to_str(&status); + let now = Utc::now().naive_utc(); + let created_at = datetime_to_str(&now); + sqlx::query( + "INSERT INTO ap_followers (local_user_id, remote_actor_url, status, created_at, follow_activity_id) + VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(local_user_id, remote_actor_url) DO UPDATE SET + status = excluded.status, + follow_activity_id = excluded.follow_activity_id", + ) + .bind(&uid) + .bind(remote_actor_url) + .bind(status_str) + .bind(&created_at) + .bind(follow_activity_id) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn get_follower_follow_activity_id( + &self, + local_user_id: uuid::Uuid, + remote_actor_url: &str, + ) -> Result> { + let uid = local_user_id.to_string(); + let row: Option> = sqlx::query_scalar( + "SELECT follow_activity_id FROM ap_followers WHERE local_user_id = ? AND remote_actor_url = ?", + ) + .bind(&uid) + .bind(remote_actor_url) + .fetch_optional(&self.pool) + .await?; + Ok(row.flatten()) + } + + async fn remove_follower( + &self, + local_user_id: uuid::Uuid, + remote_actor_url: &str, + ) -> Result<()> { + let uid = local_user_id.to_string(); + sqlx::query("DELETE FROM ap_followers WHERE local_user_id = ? AND remote_actor_url = ?") + .bind(&uid) + .bind(remote_actor_url) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn get_followers(&self, local_user_id: uuid::Uuid) -> Result> { + let uid = local_user_id.to_string(); + let rows = sqlx::query( + "SELECT f.remote_actor_url, f.status, + a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url, + a.outbox_url, a.bio, a.banner_url, a.followers_url, a.following_url, a.also_known_as, a.fetched_at + FROM ap_followers f + LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url + WHERE f.local_user_id = ?", + ) + .bind(&uid) + .fetch_all(&self.pool) + .await?; + Ok(rows + .iter() + .map(|row| { + let status_str: String = row.get("status"); + Follower { + actor: remote_actor_from_row(row, "remote_actor_url"), + status: str_to_status(&status_str), + } + }) + .collect()) + } + + async fn get_followers_page( + &self, + local_user_id: uuid::Uuid, + offset: u32, + limit: usize, + ) -> Result> { + let uid = local_user_id.to_string(); + let rows = sqlx::query( + "SELECT f.remote_actor_url, f.status, + a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url, + a.outbox_url, a.bio, a.banner_url, a.followers_url, a.following_url, a.also_known_as, a.fetched_at + FROM ap_followers f + LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url + WHERE f.local_user_id = ? AND f.status = 'accepted' + ORDER BY f.created_at ASC LIMIT ? OFFSET ?", + ) + .bind(&uid) + .bind(limit as i64) + .bind(offset as i64) + .fetch_all(&self.pool) + .await?; + Ok(rows + .iter() + .map(|row| { + let status_str: String = row.get("status"); + Follower { + actor: remote_actor_from_row(row, "remote_actor_url"), + status: str_to_status(&status_str), + } + }) + .collect()) + } + + async fn count_followers(&self, local_user_id: uuid::Uuid) -> Result { + let uid = local_user_id.to_string(); + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM ap_followers WHERE local_user_id = ? AND status = 'accepted'", + ) + .bind(&uid) + .fetch_one(&self.pool) + .await?; + Ok(count as usize) + } + + async fn update_follower_status( + &self, + local_user_id: uuid::Uuid, + remote_actor_url: &str, + status: FollowerStatus, + ) -> Result<()> { + let uid = local_user_id.to_string(); + let status_str = status_to_str(&status); + let result = sqlx::query( + "UPDATE ap_followers SET status = ? WHERE local_user_id = ? AND remote_actor_url = ?", + ) + .bind(status_str) + .bind(&uid) + .bind(remote_actor_url) + .execute(&self.pool) + .await?; + if result.rows_affected() == 0 { + tracing::warn!(local_user_id = %local_user_id, remote_actor_url, "update_follower_status: no row found"); + } + Ok(()) + } + + async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result> { + let uid = local_user_id.to_string(); + let rows = sqlx::query( + "SELECT f.remote_actor_url, + a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url, + a.outbox_url, a.bio, a.banner_url, a.followers_url, a.following_url, a.also_known_as, a.fetched_at + FROM ap_followers f + LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url + WHERE f.local_user_id = ? AND f.status = 'pending'", + ).bind(&uid).fetch_all(&self.pool).await?; + Ok(rows + .iter() + .map(|row| remote_actor_from_row(row, "remote_actor_url")) + .collect()) + } + + async fn get_accepted_follower_inboxes( + &self, + local_user_id: uuid::Uuid, + ) -> Result> { + let uid = local_user_id.to_string(); + let rows = sqlx::query( + "SELECT DISTINCT COALESCE(a.shared_inbox_url, a.inbox_url) as inbox + FROM ap_followers f + INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url + WHERE f.local_user_id = ? AND f.status = 'accepted' + AND f.remote_actor_url NOT IN ( + SELECT remote_actor_url FROM blocked_actors WHERE local_user_id = ? + )", + ) + .bind(&uid) + .bind(&uid) + .fetch_all(&self.pool) + .await?; + Ok(rows + .iter() + .filter_map(|r| r.try_get::("inbox").ok()) + .collect()) + } + + async fn count_accepted_followers(&self, local_user_id: uuid::Uuid) -> Result { + let uid = local_user_id.to_string(); + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM ap_followers WHERE local_user_id = ? AND status = 'accepted'", + ) + .bind(&uid) + .fetch_one(&self.pool) + .await?; + Ok(count as usize) + } + + async fn get_accepted_followers_page( + &self, + local_user_id: uuid::Uuid, + offset: u32, + limit: usize, + ) -> Result> { + let uid = local_user_id.to_string(); + let rows = sqlx::query( + "SELECT f.remote_actor_url, + a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url, + a.outbox_url, a.bio, a.banner_url, a.followers_url, a.following_url, a.also_known_as, a.fetched_at + FROM ap_followers f + LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url + WHERE f.local_user_id = ? AND f.status = 'accepted' + ORDER BY f.created_at ASC LIMIT ? OFFSET ?", + ).bind(&uid).bind(limit as i64).bind(offset as i64).fetch_all(&self.pool).await?; + Ok(rows + .iter() + .map(|row| remote_actor_from_row(row, "remote_actor_url")) + .collect()) + } + + async fn add_following( + &self, + local_user_id: uuid::Uuid, + actor: RemoteActor, + follow_activity_id: &str, + ) -> Result<()> { + let uid = local_user_id.to_string(); + let now = Utc::now().naive_utc(); + let created_at = datetime_to_str(&now); + ActorRepository::upsert_remote_actor(self, actor.clone()).await?; + sqlx::query( + "INSERT OR IGNORE INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, created_at) + VALUES (?, ?, ?, ?)", + ).bind(&uid).bind(&actor.url).bind(follow_activity_id).bind(&created_at).execute(&self.pool).await?; + Ok(()) + } + + async fn get_follow_activity_id( + &self, + local_user_id: uuid::Uuid, + remote_actor_url: &str, + ) -> Result> { + let uid = local_user_id.to_string(); + let row: Option> = sqlx::query_scalar( + "SELECT follow_activity_id FROM ap_following WHERE local_user_id = ? AND remote_actor_url = ?", + ).bind(&uid).bind(remote_actor_url).fetch_optional(&self.pool).await?; + Ok(row.flatten()) + } + + async fn remove_following(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> { + let uid = local_user_id.to_string(); + sqlx::query("DELETE FROM ap_following WHERE local_user_id = ? AND remote_actor_url = ?") + .bind(&uid) + .bind(actor_url) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn get_following(&self, local_user_id: uuid::Uuid) -> Result> { + let uid = local_user_id.to_string(); + let rows = sqlx::query( + "SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url, + a.outbox_url, a.bio, a.banner_url, a.followers_url, a.following_url, a.also_known_as, a.fetched_at + FROM ap_following f + INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url + WHERE f.local_user_id = ? AND f.status = 'accepted'", + ).bind(&uid).fetch_all(&self.pool).await?; + Ok(rows + .iter() + .map(|row| remote_actor_from_row(row, "url")) + .collect()) + } + + async fn count_following(&self, local_user_id: uuid::Uuid) -> Result { + let uid = local_user_id.to_string(); + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM ap_following WHERE local_user_id = ? AND status = 'accepted'", + ) + .bind(&uid) + .fetch_one(&self.pool) + .await?; + Ok(count as usize) + } + + async fn get_following_page( + &self, + local_user_id: uuid::Uuid, + offset: u32, + limit: usize, + ) -> Result> { + let uid = local_user_id.to_string(); + let rows = sqlx::query( + "SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url, + a.outbox_url, a.bio, a.banner_url, a.followers_url, a.following_url, a.also_known_as, a.fetched_at + FROM ap_following f + INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url + WHERE f.local_user_id = ? AND f.status = 'accepted' + ORDER BY f.created_at ASC LIMIT ? OFFSET ?", + ).bind(&uid).bind(limit as i64).bind(offset as i64).fetch_all(&self.pool).await?; + Ok(rows + .iter() + .map(|row| remote_actor_from_row(row, "url")) + .collect()) + } + + async fn update_following_status( + &self, + local_user_id: uuid::Uuid, + remote_actor_url: &str, + status: FollowingStatus, + ) -> Result<()> { + let uid = local_user_id.to_string(); + let status_str = match status { + FollowingStatus::Pending => "pending", + FollowingStatus::Accepted => "accepted", + }; + let result = sqlx::query( + "UPDATE ap_following SET status = ? WHERE local_user_id = ? AND remote_actor_url = ?", + ) + .bind(status_str) + .bind(&uid) + .bind(remote_actor_url) + .execute(&self.pool) + .await?; + if result.rows_affected() == 0 { + tracing::warn!(local_user_id = %local_user_id, remote_actor_url, "update_following_status: no row found"); + } + Ok(()) + } + + async fn get_following_outbox_url( + &self, + local_user_id: uuid::Uuid, + remote_actor_url: &str, + ) -> Result> { + let uid = local_user_id.to_string(); + let row: Option> = sqlx::query_scalar( + "SELECT a.outbox_url + FROM ap_following f + INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url + WHERE f.local_user_id = ? AND f.remote_actor_url = ?", + ) + .bind(&uid) + .bind(remote_actor_url) + .fetch_optional(&self.pool) + .await?; + Ok(row.flatten()) + } + + async fn migrate_follower_actor( + &self, + old_actor_url: &str, + new_actor_url: &str, + ) -> Result> { + let candidates: Vec = sqlx::query_scalar( + "SELECT local_user_id FROM ap_following + WHERE remote_actor_url = ?1 + AND local_user_id NOT IN ( + SELECT local_user_id FROM ap_following WHERE remote_actor_url = ?2 + )", + ) + .bind(old_actor_url) + .bind(new_actor_url) + .fetch_all(&self.pool) + .await?; + + if candidates.is_empty() { + return Ok(vec![]); + } + + sqlx::query( + "UPDATE ap_following SET remote_actor_url = ?1 + WHERE remote_actor_url = ?2 + AND local_user_id NOT IN ( + SELECT local_user_id FROM ap_following WHERE remote_actor_url = ?1 + )", + ) + .bind(new_actor_url) + .bind(old_actor_url) + .execute(&self.pool) + .await?; + + candidates + .into_iter() + .map(|s| uuid::Uuid::parse_str(&s).map_err(|e| anyhow::anyhow!(e))) + .collect() + } +} diff --git a/crates/adapters/sqlite-federation/src/lib.rs b/crates/adapters/sqlite-federation/src/lib.rs index 2546b4c..1158b53 100644 --- a/crates/adapters/sqlite-federation/src/lib.rs +++ b/crates/adapters/sqlite-federation/src/lib.rs @@ -1,31 +1,25 @@ -use anyhow::{Result, anyhow}; -use async_trait::async_trait; -use chrono::{NaiveDateTime, Utc}; -use sqlx::{Row, SqlitePool}; +mod activity; +mod actor; +pub mod ap_content; +mod blocklist; +mod follow; +pub mod remote_goals; +mod review; +mod social; +mod watchlist; -use activitypub::RemoteReviewRepository; -use domain::models::{RemoteWatchlistEntry, Review, ReviewSource}; -use domain::ports::RemoteWatchlistRepository; -use k_ap::{ - ActivityRepository, ActorRepository, BlockedDomain, BlocklistRepository, FollowRepository, - Follower, FollowerStatus, FollowingStatus, RemoteActor, -}; +pub use ap_content::SqliteApContentQuery; +pub use remote_goals::SqliteRemoteGoalRepository; -fn datetime_to_str(dt: &NaiveDateTime) -> String { +use chrono::NaiveDateTime; +use k_ap::{FollowerStatus, RemoteActor}; +use sqlx::SqlitePool; + +pub(crate) fn datetime_to_str(dt: &NaiveDateTime) -> String { dt.format("%Y-%m-%d %H:%M:%S").to_string() } -pub struct SqliteFederationRepository { - pool: SqlitePool, -} - -impl SqliteFederationRepository { - pub fn new(pool: SqlitePool) -> Self { - Self { pool } - } -} - -fn status_to_str(status: &FollowerStatus) -> &'static str { +pub(crate) fn status_to_str(status: &FollowerStatus) -> &'static str { match status { FollowerStatus::Pending => "pending", FollowerStatus::Accepted => "accepted", @@ -33,7 +27,7 @@ fn status_to_str(status: &FollowerStatus) -> &'static str { } } -fn str_to_status(s: &str) -> FollowerStatus { +pub(crate) fn str_to_status(s: &str) -> FollowerStatus { match s { "accepted" => FollowerStatus::Accepted, "rejected" => FollowerStatus::Rejected, @@ -41,7 +35,8 @@ fn str_to_status(s: &str) -> FollowerStatus { } } -fn remote_actor_from_row(row: &sqlx::sqlite::SqliteRow, url_col: &str) -> RemoteActor { +pub(crate) fn remote_actor_from_row(row: &sqlx::sqlite::SqliteRow, url_col: &str) -> RemoteActor { + use sqlx::Row; RemoteActor { url: row.get(url_col), handle: row.try_get("handle").unwrap_or_default(), @@ -81,1047 +76,17 @@ fn remote_actor_from_row(row: &sqlx::sqlite::SqliteRow, url_col: &str) -> Remote } } -#[async_trait] -impl FollowRepository for SqliteFederationRepository { - async fn add_follower( - &self, - local_user_id: uuid::Uuid, - remote_actor_url: &str, - status: FollowerStatus, - follow_activity_id: &str, - ) -> Result<()> { - let uid = local_user_id.to_string(); - let status_str = status_to_str(&status); - let now = Utc::now().naive_utc(); - let created_at = datetime_to_str(&now); +pub struct SqliteFederationRepository { + pub(crate) pool: SqlitePool, +} - sqlx::query( - "INSERT INTO ap_followers (local_user_id, remote_actor_url, status, created_at, follow_activity_id) - VALUES (?1, ?2, ?3, ?4, ?5) - ON CONFLICT(local_user_id, remote_actor_url) DO UPDATE SET - status = excluded.status, - follow_activity_id = excluded.follow_activity_id", - ) - .bind(&uid) - .bind(remote_actor_url) - .bind(status_str) - .bind(&created_at) - .bind(follow_activity_id) - .execute(&self.pool) - .await?; - - Ok(()) - } - - async fn get_follower_follow_activity_id( - &self, - local_user_id: uuid::Uuid, - remote_actor_url: &str, - ) -> Result> { - let uid = local_user_id.to_string(); - let row: Option> = sqlx::query_scalar( - "SELECT follow_activity_id FROM ap_followers WHERE local_user_id = ? AND remote_actor_url = ?", - ) - .bind(&uid) - .bind(remote_actor_url) - .fetch_optional(&self.pool) - .await?; - Ok(row.flatten()) - } - - async fn remove_follower( - &self, - local_user_id: uuid::Uuid, - remote_actor_url: &str, - ) -> Result<()> { - let uid = local_user_id.to_string(); - sqlx::query("DELETE FROM ap_followers WHERE local_user_id = ? AND remote_actor_url = ?") - .bind(&uid) - .bind(remote_actor_url) - .execute(&self.pool) - .await?; - Ok(()) - } - - async fn get_followers(&self, local_user_id: uuid::Uuid) -> Result> { - let uid = local_user_id.to_string(); - - let rows = sqlx::query( - "SELECT f.remote_actor_url, f.status, - a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url, - a.outbox_url, a.bio, a.banner_url, a.followers_url, a.following_url, a.also_known_as, a.fetched_at - FROM ap_followers f - LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url - WHERE f.local_user_id = ?", - ) - .bind(&uid) - .fetch_all(&self.pool) - .await?; - - Ok(rows - .iter() - .map(|row| { - let status_str: String = row.get("status"); - Follower { - actor: remote_actor_from_row(row, "remote_actor_url"), - status: str_to_status(&status_str), - } - }) - .collect()) - } - - async fn get_followers_page( - &self, - local_user_id: uuid::Uuid, - offset: u32, - limit: usize, - ) -> Result> { - let uid = local_user_id.to_string(); - let limit_i64 = limit as i64; - let offset_i64 = offset as i64; - - let rows = sqlx::query( - "SELECT f.remote_actor_url, f.status, - a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url, - a.outbox_url, a.bio, a.banner_url, a.followers_url, a.following_url, a.also_known_as, a.fetched_at - FROM ap_followers f - LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url - WHERE f.local_user_id = ? AND f.status = 'accepted' - ORDER BY f.created_at ASC - LIMIT ? OFFSET ?", - ) - .bind(&uid) - .bind(limit_i64) - .bind(offset_i64) - .fetch_all(&self.pool) - .await?; - - Ok(rows - .iter() - .map(|row| { - let status_str: String = row.get("status"); - Follower { - actor: remote_actor_from_row(row, "remote_actor_url"), - status: str_to_status(&status_str), - } - }) - .collect()) - } - - async fn count_followers(&self, local_user_id: uuid::Uuid) -> Result { - let uid = local_user_id.to_string(); - let count: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM ap_followers WHERE local_user_id = ? AND status = 'accepted'", - ) - .bind(&uid) - .fetch_one(&self.pool) - .await?; - Ok(count as usize) - } - - async fn update_follower_status( - &self, - local_user_id: uuid::Uuid, - remote_actor_url: &str, - status: FollowerStatus, - ) -> Result<()> { - let uid = local_user_id.to_string(); - let status_str = status_to_str(&status); - - let result = sqlx::query( - "UPDATE ap_followers SET status = ? WHERE local_user_id = ? AND remote_actor_url = ?", - ) - .bind(status_str) - .bind(&uid) - .bind(remote_actor_url) - .execute(&self.pool) - .await?; - - if result.rows_affected() == 0 { - tracing::warn!(local_user_id = %local_user_id, remote_actor_url, "update_follower_status: no row found"); - } - - Ok(()) - } - - async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result> { - let uid = local_user_id.to_string(); - - let rows = sqlx::query( - "SELECT f.remote_actor_url, - a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url, - a.outbox_url, a.bio, a.banner_url, a.followers_url, a.following_url, a.also_known_as, a.fetched_at - FROM ap_followers f - LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url - WHERE f.local_user_id = ? AND f.status = 'pending'", - ) - .bind(&uid) - .fetch_all(&self.pool) - .await?; - - Ok(rows - .iter() - .map(|row| remote_actor_from_row(row, "remote_actor_url")) - .collect()) - } - - async fn get_accepted_follower_inboxes( - &self, - local_user_id: uuid::Uuid, - ) -> Result> { - let uid = local_user_id.to_string(); - let rows = sqlx::query( - "SELECT DISTINCT COALESCE(a.shared_inbox_url, a.inbox_url) as inbox - FROM ap_followers f - INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url - WHERE f.local_user_id = ? AND f.status = 'accepted' - AND f.remote_actor_url NOT IN ( - SELECT remote_actor_url FROM blocked_actors WHERE local_user_id = ? - )", - ) - .bind(&uid) - .bind(&uid) - .fetch_all(&self.pool) - .await?; - - Ok(rows - .iter() - .filter_map(|r| r.try_get::("inbox").ok()) - .collect()) - } - - async fn count_accepted_followers(&self, local_user_id: uuid::Uuid) -> Result { - let uid = local_user_id.to_string(); - let count: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM ap_followers WHERE local_user_id = ? AND status = 'accepted'", - ) - .bind(&uid) - .fetch_one(&self.pool) - .await?; - Ok(count as usize) - } - - async fn get_accepted_followers_page( - &self, - local_user_id: uuid::Uuid, - offset: u32, - limit: usize, - ) -> Result> { - let uid = local_user_id.to_string(); - let limit_i64 = limit as i64; - let offset_i64 = offset as i64; - - let rows = sqlx::query( - "SELECT f.remote_actor_url, - a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url, - a.outbox_url, a.bio, a.banner_url, a.followers_url, a.following_url, a.also_known_as, a.fetched_at - FROM ap_followers f - LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url - WHERE f.local_user_id = ? AND f.status = 'accepted' - ORDER BY f.created_at ASC - LIMIT ? OFFSET ?", - ) - .bind(&uid) - .bind(limit_i64) - .bind(offset_i64) - .fetch_all(&self.pool) - .await?; - - Ok(rows - .iter() - .map(|row| remote_actor_from_row(row, "remote_actor_url")) - .collect()) - } - - async fn add_following( - &self, - local_user_id: uuid::Uuid, - actor: RemoteActor, - follow_activity_id: &str, - ) -> Result<()> { - let uid = local_user_id.to_string(); - let now = Utc::now().naive_utc(); - let created_at = datetime_to_str(&now); - - ActorRepository::upsert_remote_actor(self, actor.clone()).await?; - - sqlx::query( - "INSERT OR IGNORE INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, created_at) - VALUES (?, ?, ?, ?)", - ) - .bind(&uid) - .bind(&actor.url) - .bind(follow_activity_id) - .bind(&created_at) - .execute(&self.pool) - .await?; - - Ok(()) - } - - async fn get_follow_activity_id( - &self, - local_user_id: uuid::Uuid, - remote_actor_url: &str, - ) -> Result> { - let uid = local_user_id.to_string(); - let row: Option> = sqlx::query_scalar( - "SELECT follow_activity_id FROM ap_following WHERE local_user_id = ? AND remote_actor_url = ?", - ) - .bind(&uid) - .bind(remote_actor_url) - .fetch_optional(&self.pool) - .await?; - Ok(row.flatten()) - } - - async fn remove_following(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> { - let uid = local_user_id.to_string(); - sqlx::query("DELETE FROM ap_following WHERE local_user_id = ? AND remote_actor_url = ?") - .bind(&uid) - .bind(actor_url) - .execute(&self.pool) - .await?; - Ok(()) - } - - async fn get_following(&self, local_user_id: uuid::Uuid) -> Result> { - let uid = local_user_id.to_string(); - - let rows = sqlx::query( - "SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url, - a.outbox_url, a.bio, a.banner_url, a.followers_url, a.following_url, a.also_known_as, a.fetched_at - FROM ap_following f - INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url - WHERE f.local_user_id = ? AND f.status = 'accepted'", - ) - .bind(&uid) - .fetch_all(&self.pool) - .await?; - - Ok(rows - .iter() - .map(|row| remote_actor_from_row(row, "url")) - .collect()) - } - - async fn count_following(&self, local_user_id: uuid::Uuid) -> Result { - let uid = local_user_id.to_string(); - let count: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM ap_following WHERE local_user_id = ? AND status = 'accepted'", - ) - .bind(&uid) - .fetch_one(&self.pool) - .await?; - Ok(count as usize) - } - - async fn get_following_page( - &self, - local_user_id: uuid::Uuid, - offset: u32, - limit: usize, - ) -> Result> { - let uid = local_user_id.to_string(); - let limit_i64 = limit as i64; - let offset_i64 = offset as i64; - - let rows = sqlx::query( - "SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url, - a.outbox_url, a.bio, a.banner_url, a.followers_url, a.following_url, a.also_known_as, a.fetched_at - FROM ap_following f - INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url - WHERE f.local_user_id = ? AND f.status = 'accepted' - ORDER BY f.created_at ASC - LIMIT ? OFFSET ?", - ) - .bind(&uid) - .bind(limit_i64) - .bind(offset_i64) - .fetch_all(&self.pool) - .await?; - - Ok(rows - .iter() - .map(|row| remote_actor_from_row(row, "url")) - .collect()) - } - - async fn update_following_status( - &self, - local_user_id: uuid::Uuid, - remote_actor_url: &str, - status: FollowingStatus, - ) -> Result<()> { - let uid = local_user_id.to_string(); - let status_str = match status { - FollowingStatus::Pending => "pending", - FollowingStatus::Accepted => "accepted", - }; - - let result = sqlx::query( - "UPDATE ap_following SET status = ? WHERE local_user_id = ? AND remote_actor_url = ?", - ) - .bind(status_str) - .bind(&uid) - .bind(remote_actor_url) - .execute(&self.pool) - .await?; - - if result.rows_affected() == 0 { - tracing::warn!(local_user_id = %local_user_id, remote_actor_url, "update_following_status: no row found"); - } - - Ok(()) - } - - async fn get_following_outbox_url( - &self, - local_user_id: uuid::Uuid, - remote_actor_url: &str, - ) -> Result> { - let uid = local_user_id.to_string(); - let row: Option> = sqlx::query_scalar( - "SELECT a.outbox_url - FROM ap_following f - INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url - WHERE f.local_user_id = ? AND f.remote_actor_url = ?", - ) - .bind(&uid) - .bind(remote_actor_url) - .fetch_optional(&self.pool) - .await?; - Ok(row.flatten()) - } - - async fn migrate_follower_actor( - &self, - old_actor_url: &str, - new_actor_url: &str, - ) -> Result> { - let candidates: Vec = sqlx::query_scalar( - "SELECT local_user_id FROM ap_following - WHERE remote_actor_url = ?1 - AND local_user_id NOT IN ( - SELECT local_user_id FROM ap_following WHERE remote_actor_url = ?2 - )", - ) - .bind(old_actor_url) - .bind(new_actor_url) - .fetch_all(&self.pool) - .await?; - - if candidates.is_empty() { - return Ok(vec![]); - } - - sqlx::query( - "UPDATE ap_following SET remote_actor_url = ?1 - WHERE remote_actor_url = ?2 - AND local_user_id NOT IN ( - SELECT local_user_id FROM ap_following WHERE remote_actor_url = ?1 - )", - ) - .bind(new_actor_url) - .bind(old_actor_url) - .execute(&self.pool) - .await?; - - candidates - .into_iter() - .map(|s| uuid::Uuid::parse_str(&s).map_err(|e| anyhow::anyhow!(e))) - .collect() +impl SqliteFederationRepository { + pub fn new(pool: SqlitePool) -> Self { + Self { pool } } } -#[async_trait] -impl ActorRepository for SqliteFederationRepository { - async fn get_local_actor_keypair( - &self, - user_id: uuid::Uuid, - ) -> Result> { - let uid = user_id.to_string(); - let row = - sqlx::query("SELECT public_key, private_key FROM ap_local_actors WHERE user_id = ?") - .bind(&uid) - .fetch_optional(&self.pool) - .await?; - Ok(row.map(|r| (r.get("public_key"), r.get("private_key")))) - } - - async fn save_local_actor_keypair( - &self, - user_id: uuid::Uuid, - public_key: String, - private_key: String, - ) -> Result<()> { - let uid = user_id.to_string(); - let now = Utc::now().naive_utc(); - let created_at = datetime_to_str(&now); - - sqlx::query( - "INSERT INTO ap_local_actors (user_id, public_key, private_key, created_at) - VALUES (?, ?, ?, ?) - ON CONFLICT(user_id) DO UPDATE SET - public_key = excluded.public_key, - private_key = excluded.private_key", - ) - .bind(&uid) - .bind(&public_key) - .bind(&private_key) - .bind(&created_at) - .execute(&self.pool) - .await?; - - Ok(()) - } - - async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()> { - let now = Utc::now().naive_utc(); - let fetched_at = datetime_to_str(&now); - let aka_json = serde_json::to_string(&actor.also_known_as).unwrap_or_default(); - - sqlx::query( - "INSERT INTO ap_remote_actors (url, handle, inbox_url, shared_inbox_url, display_name, avatar_url, outbox_url, bio, banner_url, followers_url, following_url, also_known_as, fetched_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(url) DO UPDATE SET - handle = excluded.handle, - inbox_url = excluded.inbox_url, - shared_inbox_url = excluded.shared_inbox_url, - display_name = excluded.display_name, - avatar_url = excluded.avatar_url, - outbox_url = COALESCE(excluded.outbox_url, ap_remote_actors.outbox_url), - bio = excluded.bio, - banner_url = excluded.banner_url, - followers_url = excluded.followers_url, - following_url = excluded.following_url, - also_known_as = excluded.also_known_as, - fetched_at = excluded.fetched_at", - ) - .bind(&actor.url) - .bind(&actor.handle) - .bind(&actor.inbox_url) - .bind(&actor.shared_inbox_url) - .bind(&actor.display_name) - .bind(&actor.avatar_url) - .bind(&actor.outbox_url) - .bind(&actor.bio) - .bind(&actor.banner_url) - .bind(&actor.followers_url) - .bind(&actor.following_url) - .bind(&aka_json) - .bind(&fetched_at) - .execute(&self.pool) - .await?; - - Ok(()) - } - - async fn get_remote_actor(&self, actor_url: &str) -> Result> { - let row = sqlx::query( - "SELECT url, handle, inbox_url, shared_inbox_url, display_name, avatar_url, - outbox_url, bio, banner_url, followers_url, following_url, also_known_as, fetched_at - FROM ap_remote_actors WHERE url = ?", - ) - .bind(actor_url) - .fetch_optional(&self.pool) - .await?; - - Ok(row.as_ref().map(|r| remote_actor_from_row(r, "url"))) - } - - async fn add_announce( - &self, - activity_id: &str, - object_url: &str, - actor_url: &str, - announced_at: chrono::DateTime, - ) -> Result<()> { - let ts = announced_at.format("%Y-%m-%d %H:%M:%S").to_string(); - sqlx::query( - "INSERT OR IGNORE INTO ap_announces (id, object_url, actor_url, announced_at) - VALUES (?1, ?2, ?3, ?4)", - ) - .bind(activity_id) - .bind(object_url) - .bind(actor_url) - .bind(&ts) - .execute(&self.pool) - .await?; - Ok(()) - } - - async fn remove_announce(&self, activity_id: &str, actor_url: &str) -> Result<()> { - sqlx::query("DELETE FROM ap_announces WHERE id = ?1 AND actor_url = ?2") - .bind(activity_id) - .bind(actor_url) - .execute(&self.pool) - .await?; - Ok(()) - } - - async fn count_announces(&self, object_url: &str) -> Result { - let row = sqlx::query("SELECT COUNT(*) as cnt FROM ap_announces WHERE object_url = ?1") - .bind(object_url) - .fetch_one(&self.pool) - .await?; - Ok(row.get::("cnt") as usize) - } -} - -#[async_trait] -impl BlocklistRepository for SqliteFederationRepository { - async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> Result<()> { - let now = Utc::now().naive_utc(); - let ts = datetime_to_str(&now); - sqlx::query( - "INSERT INTO blocked_domains (domain, reason, blocked_at) VALUES (?1, ?2, ?3) - ON CONFLICT(domain) DO UPDATE SET reason = excluded.reason", - ) - .bind(domain) - .bind(reason) - .bind(&ts) - .execute(&self.pool) - .await?; - Ok(()) - } - - async fn remove_blocked_domain(&self, domain: &str) -> Result<()> { - sqlx::query("DELETE FROM blocked_domains WHERE domain = ?1") - .bind(domain) - .execute(&self.pool) - .await?; - Ok(()) - } - - async fn get_blocked_domains(&self) -> Result> { - let rows = sqlx::query( - "SELECT domain, reason, blocked_at FROM blocked_domains ORDER BY blocked_at DESC", - ) - .fetch_all(&self.pool) - .await?; - Ok(rows - .iter() - .map(|r| BlockedDomain { - domain: r.get("domain"), - reason: r.get("reason"), - blocked_at: r.get("blocked_at"), - }) - .collect()) - } - - async fn is_domain_blocked(&self, domain: &str) -> Result { - let count: i64 = - sqlx::query_scalar("SELECT COUNT(*) FROM blocked_domains WHERE domain = ?1") - .bind(domain) - .fetch_one(&self.pool) - .await?; - Ok(count > 0) - } - - async fn add_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> { - let uid = local_user_id.to_string(); - let ts = datetime_to_str(&Utc::now().naive_utc()); - sqlx::query( - "INSERT OR IGNORE INTO blocked_actors (local_user_id, remote_actor_url, blocked_at) - VALUES (?1, ?2, ?3)", - ) - .bind(&uid) - .bind(actor_url) - .bind(&ts) - .execute(&self.pool) - .await?; - Ok(()) - } - - async fn remove_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> { - let uid = local_user_id.to_string(); - sqlx::query( - "DELETE FROM blocked_actors WHERE local_user_id = ?1 AND remote_actor_url = ?2", - ) - .bind(&uid) - .bind(actor_url) - .execute(&self.pool) - .await?; - Ok(()) - } - - async fn get_blocked_actors(&self, local_user_id: uuid::Uuid) -> Result> { - let uid = local_user_id.to_string(); - let rows = sqlx::query( - "SELECT remote_actor_url FROM blocked_actors WHERE local_user_id = ?1 ORDER BY blocked_at DESC", - ) - .bind(&uid) - .fetch_all(&self.pool) - .await?; - Ok(rows - .iter() - .map(|r| r.get::("remote_actor_url")) - .collect()) - } - - async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result { - let uid = local_user_id.to_string(); - let count: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM blocked_actors WHERE local_user_id = ?1 AND remote_actor_url = ?2", - ) - .bind(&uid) - .bind(actor_url) - .fetch_one(&self.pool) - .await?; - Ok(count > 0) - } -} - -#[async_trait] -impl ActivityRepository for SqliteFederationRepository { - async fn is_activity_processed(&self, activity_id: &str) -> Result { - let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ap_activities WHERE id = ?1") - .bind(activity_id) - .fetch_one(&self.pool) - .await?; - Ok(count > 0) - } - - async fn mark_activity_processed(&self, activity_id: &str) -> Result<()> { - let ts = datetime_to_str(&Utc::now().naive_utc()); - sqlx::query("INSERT OR IGNORE INTO ap_activities (id, processed_at) VALUES (?1, ?2)") - .bind(activity_id) - .bind(&ts) - .execute(&self.pool) - .await?; - Ok(()) - } -} - -// --- Content-specific repository (movies-diary) --- - -#[async_trait] -impl RemoteReviewRepository for SqliteFederationRepository { - async fn save_remote_review( - &self, - review: &Review, - ap_id: &str, - movie_title: &str, - release_year: u16, - poster_url: Option<&str>, - ) -> Result<()> { - let actor_url = match review.source() { - ReviewSource::Remote { actor_url } => actor_url.clone(), - ReviewSource::Local => { - return Err(anyhow!("save_remote_review called with a local review")); - } - }; - - let movie_id = review.movie_id().value().to_string(); - - let _ = sqlx::query( - "INSERT INTO movies (id, external_metadata_id, title, release_year, director, poster_path) - VALUES (?, NULL, ?, ?, NULL, ?) - ON CONFLICT(id) DO UPDATE SET - poster_path = COALESCE(excluded.poster_path, movies.poster_path)", - ) - .bind(&movie_id) - .bind(movie_title) - .bind(release_year.max(1888) as i64) - .bind(poster_url) - .execute(&self.pool) - .await?; - - let id = review.id().value().to_string(); - let user_id = review.user_id().value().to_string(); - let rating = review.rating().value() as i64; - let comment = review.comment().map(|c| c.value().to_string()); - let watched_at = datetime_to_str(review.watched_at()); - let created_at = datetime_to_str(review.created_at()); - - sqlx::query( - "INSERT OR IGNORE INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, ap_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", - ) - .bind(&id) - .bind(&movie_id) - .bind(&user_id) - .bind(rating) - .bind(&comment) - .bind(&watched_at) - .bind(&created_at) - .bind(&actor_url) - .bind(ap_id) - .execute(&self.pool) - .await?; - - Ok(()) - } - - async fn delete_remote_review(&self, ap_id: &str, actor_url: &str) -> Result<()> { - sqlx::query("DELETE FROM reviews WHERE ap_id = ? AND remote_actor_url = ?") - .bind(ap_id) - .bind(actor_url) - .execute(&self.pool) - .await?; - Ok(()) - } - - async fn update_remote_review( - &self, - ap_id: &str, - actor_url: &str, - rating: u8, - comment: Option<&str>, - watched_at: chrono::NaiveDateTime, - poster_url: Option<&str>, - ) -> Result<()> { - let watched_at_str = datetime_to_str(&watched_at); - sqlx::query( - "UPDATE reviews SET rating = ?, comment = ?, watched_at = ? - WHERE ap_id = ? AND remote_actor_url = ?", - ) - .bind(rating as i64) - .bind(comment) - .bind(&watched_at_str) - .bind(ap_id) - .bind(actor_url) - .execute(&self.pool) - .await?; - if let Some(url) = poster_url { - sqlx::query( - "UPDATE movies SET poster_path = ? - WHERE id = (SELECT movie_id FROM reviews WHERE ap_id = ? AND remote_actor_url = ?)", - ) - .bind(url) - .bind(ap_id) - .bind(actor_url) - .execute(&self.pool) - .await?; - } - Ok(()) - } - - async fn delete_by_actor(&self, actor_url: &str) -> Result<()> { - sqlx::query("DELETE FROM reviews WHERE remote_actor_url = ?") - .bind(actor_url) - .execute(&self.pool) - .await?; - Ok(()) - } -} - -#[async_trait] -impl domain::ports::SocialQueryPort for SqliteFederationRepository { - async fn get_accepted_following_urls( - &self, - user_id: uuid::Uuid, - ) -> Result, domain::errors::DomainError> { - let user_id_str = user_id.to_string(); - let rows = sqlx::query_scalar::<_, String>( - "SELECT remote_actor_url FROM ap_following - WHERE local_user_id = ? AND status = 'accepted'", - ) - .bind(&user_id_str) - .fetch_all(&self.pool) - .await - .map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?; - Ok(rows) - } - - async fn list_all_followed_remote_actors( - &self, - ) -> Result, domain::errors::DomainError> { - let rows = sqlx::query_as::<_, (String, String, Option)>( - "SELECT DISTINCT ar.url, ar.handle, ar.display_name - FROM ap_remote_actors ar - JOIN ap_following f ON f.remote_actor_url = ar.url - WHERE f.status = 'accepted'", - ) - .fetch_all(&self.pool) - .await - .map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?; - - Ok(rows - .into_iter() - .map( - |(url, handle, display_name)| domain::models::RemoteActorInfo { - url, - handle, - display_name, - }, - ) - .collect()) - } - - async fn count_following( - &self, - user_id: uuid::Uuid, - ) -> Result { - let uid = user_id.to_string(); - let count: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM ap_following WHERE local_user_id = ? AND status = 'accepted'", - ) - .bind(&uid) - .fetch_one(&self.pool) - .await - .map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?; - Ok(count as usize) - } - - async fn count_accepted_followers( - &self, - user_id: uuid::Uuid, - ) -> Result { - let uid = user_id.to_string(); - let count: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM ap_followers WHERE local_user_id = ? AND status = 'accepted'", - ) - .bind(&uid) - .fetch_one(&self.pool) - .await - .map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?; - Ok(count as usize) - } - - async fn get_pending_followers( - &self, - user_id: uuid::Uuid, - ) -> Result, domain::errors::DomainError> { - let uid = user_id.to_string(); - let rows = sqlx::query_as::<_, (String, String, Option, Option)>( - "SELECT ar.url, ar.handle, ar.display_name, ar.avatar_url - FROM ap_followers f - JOIN ap_remote_actors ar ON ar.url = f.remote_actor_url - WHERE f.local_user_id = ? AND f.status = 'pending'", - ) - .bind(&uid) - .fetch_all(&self.pool) - .await - .map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?; - Ok(rows - .into_iter() - .map( - |(url, handle, display_name, avatar_url)| domain::models::PendingFollowerInfo { - url, - handle, - display_name, - avatar_url, - }, - ) - .collect()) - } -} - -#[async_trait] -impl RemoteWatchlistRepository for SqliteFederationRepository { - async fn save(&self, entry: RemoteWatchlistEntry) -> Result<(), domain::errors::DomainError> { - sqlx::query( - "INSERT INTO ap_remote_watchlist_entries \ - (ap_id, actor_url, movie_title, release_year, external_metadata_id, poster_url, added_at) \ - VALUES (?, ?, ?, ?, ?, ?, ?) \ - ON CONFLICT(ap_id) DO UPDATE SET \ - movie_title=excluded.movie_title, release_year=excluded.release_year, \ - external_metadata_id=excluded.external_metadata_id, poster_url=excluded.poster_url", - ) - .bind(&entry.ap_id) - .bind(&entry.actor_url) - .bind(&entry.movie_title) - .bind(entry.release_year as i64) - .bind(&entry.external_metadata_id) - .bind(&entry.poster_url) - .bind(entry.added_at.format("%Y-%m-%d %H:%M:%S").to_string()) - .execute(&self.pool) - .await - .map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?; - Ok(()) - } - - async fn remove_by_ap_id( - &self, - ap_id: &str, - actor_url: &str, - ) -> Result<(), domain::errors::DomainError> { - sqlx::query("DELETE FROM ap_remote_watchlist_entries WHERE ap_id = ? AND actor_url = ?") - .bind(ap_id) - .bind(actor_url) - .execute(&self.pool) - .await - .map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?; - Ok(()) - } - - async fn get_by_actor_url( - &self, - actor_url: &str, - ) -> Result, domain::errors::DomainError> { - let rows = sqlx::query( - "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", - ) - .bind(actor_url) - .fetch_all(&self.pool) - .await - .map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?; - - rows.into_iter() - .map(|row| { - let added_at_str: String = row.try_get("added_at").unwrap_or_default(); - let added_at = - chrono::NaiveDateTime::parse_from_str(&added_at_str, "%Y-%m-%d %H:%M:%S") - .map(|dt| { - chrono::DateTime::::from_naive_utc_and_offset( - dt, - chrono::Utc, - ) - }) - .unwrap_or_else(|_| chrono::Utc::now()); - 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::("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() - } - - 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 = ?") - .bind(actor_url) - .execute(&self.pool) - .await - .map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?; - Ok(()) - } - - async fn get_by_derived_uuid( - &self, - uuid: uuid::Uuid, - ) -> Result, domain::errors::DomainError> { - let actors: Vec = - sqlx::query("SELECT DISTINCT actor_url FROM ap_remote_watchlist_entries") - .fetch_all(&self.pool) - .await - .map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))? - .into_iter() - .filter_map(|row| row.try_get::("actor_url").ok()) - .collect(); - - let target = actors - .into_iter() - .find(|url| uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, url.as_bytes()) == uuid); - - match target { - None => Ok(vec![]), - Some(actor_url) => self.get_by_actor_url(&actor_url).await, - } - } -} - -pub fn wire(pool: sqlx::SqlitePool) -> activitypub::FederationRepos { +pub fn wire(pool: SqlitePool) -> activitypub::FederationRepos { let fed = std::sync::Arc::new(SqliteFederationRepository::new(pool)); ( std::sync::Arc::clone(&fed) as _, @@ -1135,84 +100,8 @@ pub fn wire(pool: sqlx::SqlitePool) -> activitypub::FederationRepos { } #[cfg(test)] -mod outbox_url_tests { - use super::*; - use k_ap::{FollowRepository, FollowingStatus, RemoteActor}; - - async fn setup_pool() -> SqlitePool { - let pool = SqlitePool::connect(":memory:").await.unwrap(); - sqlx::query( - "CREATE TABLE ap_remote_actors ( - url TEXT PRIMARY KEY, handle TEXT NOT NULL, inbox_url TEXT NOT NULL, - shared_inbox_url TEXT, display_name TEXT, avatar_url TEXT, - outbox_url TEXT, bio TEXT, banner_url TEXT, followers_url TEXT, - following_url TEXT, also_known_as TEXT, fetched_at TEXT NOT NULL - ); - CREATE TABLE ap_following ( - local_user_id TEXT NOT NULL, remote_actor_url TEXT NOT NULL, - follow_activity_id TEXT, created_at TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'pending', - PRIMARY KEY (local_user_id, remote_actor_url) - );", - ) - .execute(&pool) - .await - .unwrap(); - pool - } - - #[tokio::test] - async fn get_following_outbox_url_returns_stored_url() { - let pool = setup_pool().await; - let repo = SqliteFederationRepository::new(pool); - let local_user = uuid::Uuid::new_v4(); - let actor = RemoteActor { - url: "https://remote.example/users/alice".to_string(), - handle: "alice@remote.example".to_string(), - inbox_url: "https://remote.example/users/alice/inbox".to_string(), - shared_inbox_url: None, - display_name: None, - avatar_url: None, - outbox_url: Some("https://remote.example/users/alice/outbox".to_string()), - bio: None, - banner_url: None, - followers_url: None, - following_url: None, - also_known_as: vec![], - fetched_at: None, - }; - repo.add_following(local_user, actor, "https://local/activities/1") - .await - .unwrap(); - repo.update_following_status( - local_user, - "https://remote.example/users/alice", - FollowingStatus::Accepted, - ) - .await - .unwrap(); - - let result = repo - .get_following_outbox_url(local_user, "https://remote.example/users/alice") - .await - .unwrap(); - assert_eq!( - result, - Some("https://remote.example/users/alice/outbox".to_string()) - ); - } - - #[tokio::test] - async fn get_following_outbox_url_returns_none_when_not_following() { - let pool = setup_pool().await; - let repo = SqliteFederationRepository::new(pool); - let result = repo - .get_following_outbox_url(uuid::Uuid::new_v4(), "https://remote.example/users/alice") - .await - .unwrap(); - assert_eq!(result, None); - } -} +#[path = "tests/outbox_url.rs"] +mod outbox_url_tests; #[cfg(test)] #[path = "tests/actor_block_tests.rs"] diff --git a/crates/adapters/sqlite/src/remote_goals.rs b/crates/adapters/sqlite-federation/src/remote_goals.rs similarity index 100% rename from crates/adapters/sqlite/src/remote_goals.rs rename to crates/adapters/sqlite-federation/src/remote_goals.rs diff --git a/crates/adapters/sqlite-federation/src/review.rs b/crates/adapters/sqlite-federation/src/review.rs new file mode 100644 index 0000000..623cc5f --- /dev/null +++ b/crates/adapters/sqlite-federation/src/review.rs @@ -0,0 +1,116 @@ +use activitypub::RemoteReviewRepository; +use anyhow::{Result, anyhow}; +use async_trait::async_trait; +use domain::models::{Review, ReviewSource}; + +use super::{SqliteFederationRepository, datetime_to_str}; + +#[async_trait] +impl RemoteReviewRepository for SqliteFederationRepository { + async fn save_remote_review( + &self, + review: &Review, + ap_id: &str, + movie_title: &str, + release_year: u16, + external_metadata_id: Option<&str>, + poster_url: Option<&str>, + ) -> Result<()> { + let actor_url = match review.source() { + ReviewSource::Remote { actor_url } => actor_url.clone(), + ReviewSource::Local => { + return Err(anyhow!("save_remote_review called with a local review")); + } + }; + let movie_id = review.movie_id().value().to_string(); + let _ = sqlx::query( + "INSERT INTO movies (id, external_metadata_id, title, release_year, director, poster_path) + VALUES (?, ?, ?, ?, NULL, ?) + ON CONFLICT(id) DO UPDATE SET + external_metadata_id = COALESCE(excluded.external_metadata_id, movies.external_metadata_id), + poster_path = COALESCE(excluded.poster_path, movies.poster_path)", + ) + .bind(&movie_id) + .bind(external_metadata_id) + .bind(movie_title) + .bind(release_year.max(1888) as i64) + .bind(poster_url) + .execute(&self.pool) + .await?; + + let id = review.id().value().to_string(); + let user_id = review.user_id().value().to_string(); + let rating = review.rating().value() as i64; + let comment = review.comment().map(|c| c.value().to_string()); + let watched_at = datetime_to_str(review.watched_at()); + let created_at = datetime_to_str(review.created_at()); + sqlx::query( + "INSERT OR IGNORE INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, ap_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&id) + .bind(&movie_id) + .bind(&user_id) + .bind(rating) + .bind(&comment) + .bind(&watched_at) + .bind(&created_at) + .bind(&actor_url) + .bind(ap_id) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn delete_remote_review(&self, ap_id: &str, actor_url: &str) -> Result<()> { + sqlx::query("DELETE FROM reviews WHERE ap_id = ? AND remote_actor_url = ?") + .bind(ap_id) + .bind(actor_url) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn update_remote_review( + &self, + ap_id: &str, + actor_url: &str, + rating: u8, + comment: Option<&str>, + watched_at: chrono::NaiveDateTime, + poster_url: Option<&str>, + ) -> Result<()> { + let watched_at_str = datetime_to_str(&watched_at); + sqlx::query( + "UPDATE reviews SET rating = ?, comment = ?, watched_at = ? + WHERE ap_id = ? AND remote_actor_url = ?", + ) + .bind(rating as i64) + .bind(comment) + .bind(&watched_at_str) + .bind(ap_id) + .bind(actor_url) + .execute(&self.pool) + .await?; + if let Some(url) = poster_url { + sqlx::query( + "UPDATE movies SET poster_path = ? + WHERE id = (SELECT movie_id FROM reviews WHERE ap_id = ? AND remote_actor_url = ?)", + ) + .bind(url) + .bind(ap_id) + .bind(actor_url) + .execute(&self.pool) + .await?; + } + Ok(()) + } + + async fn delete_by_actor(&self, actor_url: &str) -> Result<()> { + sqlx::query("DELETE FROM reviews WHERE remote_actor_url = ?") + .bind(actor_url) + .execute(&self.pool) + .await?; + Ok(()) + } +} diff --git a/crates/adapters/sqlite-federation/src/social.rs b/crates/adapters/sqlite-federation/src/social.rs new file mode 100644 index 0000000..83b1457 --- /dev/null +++ b/crates/adapters/sqlite-federation/src/social.rs @@ -0,0 +1,94 @@ +use async_trait::async_trait; +use domain::{ + errors::DomainError, + models::{PendingFollowerInfo, RemoteActorInfo}, + ports::SocialQueryPort, +}; + +use super::SqliteFederationRepository; + +#[async_trait] +impl SocialQueryPort for SqliteFederationRepository { + async fn get_accepted_following_urls( + &self, + user_id: uuid::Uuid, + ) -> Result, DomainError> { + let user_id_str = user_id.to_string(); + sqlx::query_scalar::<_, String>( + "SELECT remote_actor_url FROM ap_following WHERE local_user_id = ? AND status = 'accepted'", + ).bind(&user_id_str).fetch_all(&self.pool).await + .map_err(|e| DomainError::InfrastructureError(e.to_string())) + } + + async fn list_all_followed_remote_actors(&self) -> Result, DomainError> { + let rows = sqlx::query_as::<_, (String, String, Option)>( + "SELECT DISTINCT ar.url, ar.handle, ar.display_name + FROM ap_remote_actors ar + JOIN ap_following f ON f.remote_actor_url = ar.url + WHERE f.status = 'accepted'", + ) + .fetch_all(&self.pool) + .await + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + Ok(rows + .into_iter() + .map(|(url, handle, display_name)| RemoteActorInfo { + url, + handle, + display_name, + }) + .collect()) + } + + async fn count_following(&self, user_id: uuid::Uuid) -> Result { + let uid = user_id.to_string(); + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM ap_following WHERE local_user_id = ? AND status = 'accepted'", + ) + .bind(&uid) + .fetch_one(&self.pool) + .await + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + Ok(count as usize) + } + + async fn count_accepted_followers(&self, user_id: uuid::Uuid) -> Result { + let uid = user_id.to_string(); + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM ap_followers WHERE local_user_id = ? AND status = 'accepted'", + ) + .bind(&uid) + .fetch_one(&self.pool) + .await + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + Ok(count as usize) + } + + async fn get_pending_followers( + &self, + user_id: uuid::Uuid, + ) -> Result, DomainError> { + let uid = user_id.to_string(); + let rows = sqlx::query_as::<_, (String, String, Option, Option)>( + "SELECT ar.url, ar.handle, ar.display_name, ar.avatar_url + FROM ap_followers f + JOIN ap_remote_actors ar ON ar.url = f.remote_actor_url + WHERE f.local_user_id = ? AND f.status = 'pending'", + ) + .bind(&uid) + .fetch_all(&self.pool) + .await + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + Ok(rows + .into_iter() + .map( + |(url, handle, display_name, avatar_url)| PendingFollowerInfo { + url, + handle, + display_name, + avatar_url, + }, + ) + .collect()) + } +} diff --git a/crates/adapters/sqlite-federation/src/tests/actor_block_tests.rs b/crates/adapters/sqlite-federation/src/tests/actor_block_tests.rs index 5d0dfb4..1dd39f4 100644 --- a/crates/adapters/sqlite-federation/src/tests/actor_block_tests.rs +++ b/crates/adapters/sqlite-federation/src/tests/actor_block_tests.rs @@ -1,4 +1,5 @@ use super::*; +use k_ap::BlocklistRepository; use sqlx::SqlitePool; async fn test_pool() -> SqlitePool { diff --git a/crates/adapters/sqlite-federation/src/tests/domain_block_tests.rs b/crates/adapters/sqlite-federation/src/tests/domain_block_tests.rs index 2f7a9af..f592852 100644 --- a/crates/adapters/sqlite-federation/src/tests/domain_block_tests.rs +++ b/crates/adapters/sqlite-federation/src/tests/domain_block_tests.rs @@ -1,4 +1,5 @@ use super::*; +use k_ap::BlocklistRepository; use sqlx::SqlitePool; async fn test_pool() -> SqlitePool { diff --git a/crates/adapters/sqlite-federation/src/tests/lib.rs b/crates/adapters/sqlite-federation/src/tests/lib.rs index ed8ff2b..f6d77cf 100644 --- a/crates/adapters/sqlite-federation/src/tests/lib.rs +++ b/crates/adapters/sqlite-federation/src/tests/lib.rs @@ -1,6 +1,7 @@ use super::*; use chrono::Utc; use domain::ports::SocialQueryPort; +use k_ap::ActorRepository; use sqlx::SqlitePool; async fn test_pool() -> SqlitePool { diff --git a/crates/adapters/sqlite-federation/src/tests/outbox_url.rs b/crates/adapters/sqlite-federation/src/tests/outbox_url.rs new file mode 100644 index 0000000..5d6ee37 --- /dev/null +++ b/crates/adapters/sqlite-federation/src/tests/outbox_url.rs @@ -0,0 +1,76 @@ +use super::*; +use k_ap::{FollowRepository, FollowingStatus, RemoteActor}; + +async fn setup_pool() -> SqlitePool { + let pool = SqlitePool::connect(":memory:").await.unwrap(); + sqlx::query( + "CREATE TABLE ap_remote_actors ( + url TEXT PRIMARY KEY, handle TEXT NOT NULL, inbox_url TEXT NOT NULL, + shared_inbox_url TEXT, display_name TEXT, avatar_url TEXT, + outbox_url TEXT, bio TEXT, banner_url TEXT, followers_url TEXT, + following_url TEXT, also_known_as TEXT, fetched_at TEXT NOT NULL + ); + CREATE TABLE ap_following ( + local_user_id TEXT NOT NULL, remote_actor_url TEXT NOT NULL, + follow_activity_id TEXT, created_at TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + PRIMARY KEY (local_user_id, remote_actor_url) + );", + ) + .execute(&pool) + .await + .unwrap(); + pool +} + +#[tokio::test] +async fn get_following_outbox_url_returns_stored_url() { + let pool = setup_pool().await; + let repo = SqliteFederationRepository::new(pool); + let local_user = uuid::Uuid::new_v4(); + let actor = RemoteActor { + url: "https://remote.example/users/alice".to_string(), + handle: "alice@remote.example".to_string(), + inbox_url: "https://remote.example/users/alice/inbox".to_string(), + shared_inbox_url: None, + display_name: None, + avatar_url: None, + outbox_url: Some("https://remote.example/users/alice/outbox".to_string()), + bio: None, + banner_url: None, + followers_url: None, + following_url: None, + also_known_as: vec![], + fetched_at: None, + }; + repo.add_following(local_user, actor, "https://local/activities/1") + .await + .unwrap(); + repo.update_following_status( + local_user, + "https://remote.example/users/alice", + FollowingStatus::Accepted, + ) + .await + .unwrap(); + + let result = repo + .get_following_outbox_url(local_user, "https://remote.example/users/alice") + .await + .unwrap(); + assert_eq!( + result, + Some("https://remote.example/users/alice/outbox".to_string()) + ); +} + +#[tokio::test] +async fn get_following_outbox_url_returns_none_when_not_following() { + let pool = setup_pool().await; + let repo = SqliteFederationRepository::new(pool); + let result = repo + .get_following_outbox_url(uuid::Uuid::new_v4(), "https://remote.example/users/alice") + .await + .unwrap(); + assert_eq!(result, None); +} diff --git a/crates/adapters/sqlite-federation/src/watchlist.rs b/crates/adapters/sqlite-federation/src/watchlist.rs new file mode 100644 index 0000000..880b320 --- /dev/null +++ b/crates/adapters/sqlite-federation/src/watchlist.rs @@ -0,0 +1,100 @@ +use async_trait::async_trait; +use domain::{errors::DomainError, models::RemoteWatchlistEntry, ports::RemoteWatchlistRepository}; +use sqlx::Row; + +use super::SqliteFederationRepository; + +#[async_trait] +impl RemoteWatchlistRepository for SqliteFederationRepository { + async fn save(&self, entry: RemoteWatchlistEntry) -> Result<(), DomainError> { + sqlx::query( + "INSERT INTO ap_remote_watchlist_entries \ + (ap_id, actor_url, movie_title, release_year, external_metadata_id, poster_url, added_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?) \ + ON CONFLICT(ap_id) DO UPDATE SET \ + movie_title=excluded.movie_title, release_year=excluded.release_year, \ + external_metadata_id=excluded.external_metadata_id, poster_url=excluded.poster_url", + ) + .bind(&entry.ap_id).bind(&entry.actor_url).bind(&entry.movie_title) + .bind(entry.release_year as i64).bind(&entry.external_metadata_id).bind(&entry.poster_url) + .bind(entry.added_at.format("%Y-%m-%d %H:%M:%S").to_string()) + .execute(&self.pool).await + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + Ok(()) + } + + async fn remove_by_ap_id(&self, ap_id: &str, actor_url: &str) -> Result<(), DomainError> { + sqlx::query("DELETE FROM ap_remote_watchlist_entries WHERE ap_id = ? AND actor_url = ?") + .bind(ap_id) + .bind(actor_url) + .execute(&self.pool) + .await + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + Ok(()) + } + + async fn get_by_actor_url( + &self, + actor_url: &str, + ) -> Result, DomainError> { + let rows = sqlx::query( + "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", + ).bind(actor_url).fetch_all(&self.pool).await + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + + rows.into_iter() + .map(|row| { + let added_at_str: String = row.try_get("added_at").unwrap_or_default(); + let added_at = + chrono::NaiveDateTime::parse_from_str(&added_at_str, "%Y-%m-%d %H:%M:%S") + .map(|dt| { + chrono::DateTime::::from_naive_utc_and_offset( + dt, + chrono::Utc, + ) + }) + .unwrap_or_else(|_| chrono::Utc::now()); + 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::("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() + } + + async fn remove_all_by_actor(&self, actor_url: &str) -> Result<(), DomainError> { + sqlx::query("DELETE FROM ap_remote_watchlist_entries WHERE actor_url = ?") + .bind(actor_url) + .execute(&self.pool) + .await + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + Ok(()) + } + + async fn get_by_derived_uuid( + &self, + uuid: uuid::Uuid, + ) -> Result, DomainError> { + let actors: Vec = + sqlx::query("SELECT DISTINCT actor_url FROM ap_remote_watchlist_entries") + .fetch_all(&self.pool) + .await + .map_err(|e| DomainError::InfrastructureError(e.to_string()))? + .into_iter() + .filter_map(|row| row.try_get::("actor_url").ok()) + .collect(); + let target = actors + .into_iter() + .find(|url| uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, url.as_bytes()) == uuid); + match target { + None => Ok(vec![]), + Some(actor_url) => self.get_by_actor_url(&actor_url).await, + } + } +} diff --git a/crates/adapters/sqlite/Cargo.toml b/crates/adapters/sqlite/Cargo.toml index 707cdd9..f31eccc 100644 --- a/crates/adapters/sqlite/Cargo.toml +++ b/crates/adapters/sqlite/Cargo.toml @@ -12,6 +12,7 @@ sqlx = { version = "0.8.6", features = [ ] } domain = { workspace = true } +sqlite-federation = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } anyhow = { workspace = true } diff --git a/crates/adapters/sqlite/src/ap_content.rs b/crates/adapters/sqlite/src/ap_content.rs deleted file mode 100644 index 22e73ca..0000000 --- a/crates/adapters/sqlite/src/ap_content.rs +++ /dev/null @@ -1,197 +0,0 @@ -use async_trait::async_trait; -use domain::{ - errors::DomainError, - models::{DiaryEntry, Goal, Movie, Review, WatchlistWithMovie}, - ports::LocalApContentQuery, - value_objects::{MovieId, ReviewId, UserId}, -}; -use sqlx::SqlitePool; - -use crate::models::{DiaryRow, MovieRow, ReviewRow, WatchlistRow}; - -pub struct SqliteApContentQuery { - pool: SqlitePool, -} - -impl SqliteApContentQuery { - pub fn new(pool: SqlitePool) -> Self { - Self { pool } - } - - fn map_err(e: sqlx::Error) -> DomainError { - tracing::error!("Database error: {:?}", e); - DomainError::InfrastructureError("Database operation failed".into()) - } -} - -#[async_trait] -impl LocalApContentQuery for SqliteApContentQuery { - async fn get_local_reviews_for_user( - &self, - user_id: &UserId, - ) -> Result, DomainError> { - let uid = user_id.value().to_string(); - let rows = sqlx::query_as::<_, DiaryRow>( - "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, - r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url - FROM reviews r - INNER JOIN movies m ON m.id = r.movie_id - WHERE r.user_id = ? AND r.remote_actor_url IS NULL - ORDER BY r.created_at DESC", - ) - .bind(&uid) - .fetch_all(&self.pool) - .await - .map_err(Self::map_err)?; - rows.into_iter().map(DiaryRow::into_domain).collect() - } - - async fn get_local_watchlist_for_user( - &self, - user_id: &UserId, - ) -> Result, DomainError> { - let uid = user_id.value().to_string(); - let rows: Vec = sqlx::query_as( - "SELECT w.id, w.user_id, w.movie_id, w.added_at, - m.id AS m_id, m.external_metadata_id, m.title, m.release_year, - m.director, m.poster_path - FROM watchlist_entries w - JOIN movies m ON m.id = w.movie_id - WHERE w.user_id = ? - ORDER BY w.added_at DESC", - ) - .bind(&uid) - .fetch_all(&self.pool) - .await - .map_err(Self::map_err)?; - rows.into_iter().map(WatchlistRow::into_domain).collect() - } - - async fn get_local_reviews_for_movie( - &self, - movie_id: &MovieId, - ) -> Result, DomainError> { - let mid = movie_id.value().to_string(); - let rows = sqlx::query_as::<_, DiaryRow>( - "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, - r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url - FROM reviews r - INNER JOIN movies m ON m.id = r.movie_id - WHERE r.movie_id = ? AND r.remote_actor_url IS NULL - ORDER BY r.created_at DESC", - ) - .bind(&mid) - .fetch_all(&self.pool) - .await - .map_err(Self::map_err)?; - rows.into_iter().map(DiaryRow::into_domain).collect() - } - - async fn get_review_by_id(&self, review_id: &ReviewId) -> Result, DomainError> { - let id = review_id.value().to_string(); - sqlx::query_as::<_, ReviewRow>( - "SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url - FROM reviews WHERE id = ?", - ) - .bind(&id) - .fetch_optional(&self.pool) - .await - .map_err(Self::map_err)? - .map(ReviewRow::into_domain) - .transpose() - } - - async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result, DomainError> { - let id = movie_id.value().to_string(); - sqlx::query_as::<_, MovieRow>( - "SELECT id, external_metadata_id, title, release_year, director, poster_path - FROM movies WHERE id = ?", - ) - .bind(&id) - .fetch_optional(&self.pool) - .await - .map_err(Self::map_err)? - .map(MovieRow::into_domain) - .transpose() - } - - async fn count_local_posts(&self) -> Result { - let count: i64 = - sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE remote_actor_url IS NULL") - .fetch_one(&self.pool) - .await - .map_err(Self::map_err)?; - Ok(count as u64) - } - - async fn get_local_reviews_page( - &self, - user_id: &UserId, - before: Option, - limit: usize, - ) -> Result, DomainError> { - let uid = user_id.value().to_string(); - let limit_i64 = limit as i64; - - let rows = if let Some(before_ts) = before { - let ts = before_ts.format("%Y-%m-%d %H:%M:%S").to_string(); - sqlx::query_as::<_, DiaryRow>( - "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, - r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url - FROM reviews r - INNER JOIN movies m ON m.id = r.movie_id - WHERE r.user_id = ? AND r.remote_actor_url IS NULL AND r.watched_at < ? - ORDER BY r.watched_at DESC - LIMIT ?", - ) - .bind(&uid) - .bind(&ts) - .bind(limit_i64) - .fetch_all(&self.pool) - .await - .map_err(Self::map_err)? - } else { - sqlx::query_as::<_, DiaryRow>( - "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, - r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url - FROM reviews r - INNER JOIN movies m ON m.id = r.movie_id - WHERE r.user_id = ? AND r.remote_actor_url IS NULL - ORDER BY r.watched_at DESC - LIMIT ?", - ) - .bind(&uid) - .bind(limit_i64) - .fetch_all(&self.pool) - .await - .map_err(Self::map_err)? - }; - rows.into_iter().map(DiaryRow::into_domain).collect() - } - - async fn get_goal_with_progress( - &self, - user_id: &UserId, - year: u16, - ) -> Result, DomainError> { - let uid = user_id.value().to_string(); - let y = year as i64; - - let row = sqlx::query( - "SELECT id, user_id, year, target_count, goal_type, created_at \ - FROM goals WHERE user_id = ? AND year = ?", - ) - .bind(&uid) - .bind(y) - .fetch_optional(&self.pool) - .await - .map_err(Self::map_err)?; - - let Some(r) = row else { return Ok(None) }; - - let goal = crate::goals::row_to_goal(&r)?; - let count = crate::goals::count_reviews_in_year(&self.pool, user_id, year).await?; - - Ok(Some((goal, count))) - } -} diff --git a/crates/adapters/sqlite/src/lib.rs b/crates/adapters/sqlite/src/lib.rs index d2b098c..b2f5a5c 100644 --- a/crates/adapters/sqlite/src/lib.rs +++ b/crates/adapters/sqlite/src/lib.rs @@ -1,6 +1,5 @@ use sqlx::SqlitePool; -mod ap_content; mod diary; mod goals; mod image_ref; @@ -9,11 +8,11 @@ mod import_session; mod migrations; mod models; mod movie; +mod movie_dedup; mod persons; mod profile; mod profile_fields; mod refresh_sessions; -mod remote_goals; mod review; mod stats; mod user_settings; @@ -22,17 +21,18 @@ mod watch_event; mod watchlist; mod wrapup; -pub use ap_content::SqliteApContentQuery; pub use diary::SqliteDiaryRepository; pub use image_ref::{SqliteImageRefAdapter, create_image_ref}; pub use import_profile::SqliteImportProfileRepository; pub use import_session::SqliteImportSessionRepository; pub use movie::SqliteMovieRepository; +pub use movie_dedup::SqliteMovieDeduplicator; pub use persons::{SqlitePersonAdapter, create_person_adapter}; pub use profile::SqliteMovieProfileRepository; pub use profile_fields::SqliteProfileFieldsRepository; pub use refresh_sessions::SqliteRefreshSessionAdapter; pub use review::SqliteReviewRepository; +pub use sqlite_federation::SqliteApContentQuery; pub use stats::SqliteStatsRepository; pub use users::SqliteUserRepository; pub use watch_event::{SqliteWatchEventRepository, SqliteWebhookTokenRepository}; @@ -91,6 +91,7 @@ pub struct SqliteWireOutput { pub user_settings: std::sync::Arc, pub federation_settings: std::sync::Arc, pub remote_goal: std::sync::Arc, + pub deduplicator: std::sync::Arc, } pub async fn wire(database_url: &str) -> anyhow::Result { @@ -135,6 +136,9 @@ pub async fn wire(database_url: &str) -> anyhow::Result { goal: std::sync::Arc::new(goals::SqliteGoalRepository::new(pool.clone())) as _, user_settings: std::sync::Arc::clone(&user_settings_repo) as _, federation_settings: user_settings_repo as _, - remote_goal: std::sync::Arc::new(remote_goals::SqliteRemoteGoalRepository::new(pool)) as _, + remote_goal: std::sync::Arc::new(sqlite_federation::SqliteRemoteGoalRepository::new( + pool.clone(), + )) as _, + deduplicator: std::sync::Arc::new(SqliteMovieDeduplicator::new(pool)) as _, }) } diff --git a/crates/adapters/sqlite/src/movie.rs b/crates/adapters/sqlite/src/movie.rs index 9cd87d3..b44eac2 100644 --- a/crates/adapters/sqlite/src/movie.rs +++ b/crates/adapters/sqlite/src/movie.rs @@ -248,4 +248,17 @@ impl MovieRepository for SqliteMovieRepository { offset: page.offset, }) } + + async fn list_movies_with_external_id(&self) -> Result, DomainError> { + sqlx::query_as::<_, MovieRow>( + "SELECT id, external_metadata_id, title, release_year, director, poster_path + FROM movies WHERE external_metadata_id IS NOT NULL", + ) + .fetch_all(&self.pool) + .await + .map_err(Self::map_err)? + .into_iter() + .map(MovieRow::into_domain) + .collect() + } } diff --git a/crates/adapters/sqlite/src/movie_dedup.rs b/crates/adapters/sqlite/src/movie_dedup.rs new file mode 100644 index 0000000..f20160d --- /dev/null +++ b/crates/adapters/sqlite/src/movie_dedup.rs @@ -0,0 +1,149 @@ +use async_trait::async_trait; +use domain::{ + errors::DomainError, models::Movie, ports::MovieDeduplicator, value_objects::MovieId, +}; +use sqlx::SqlitePool; + +pub struct SqliteMovieDeduplicator { + pool: SqlitePool, +} + +impl SqliteMovieDeduplicator { + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } + + fn map_err(e: sqlx::Error) -> DomainError { + tracing::error!("Database error: {:?}", e); + DomainError::InfrastructureError("Database operation failed".into()) + } +} + +#[async_trait] +impl MovieDeduplicator for SqliteMovieDeduplicator { + async fn merge_into_canonical( + &self, + old_id: &MovieId, + canonical: &Movie, + ) -> Result { + let old = old_id.value().to_string(); + let new = canonical.id().value().to_string(); + let ext_id = canonical + .external_metadata_id() + .map(|id| id.value().to_string()); + let title = canonical.title().value().to_string(); + let year = canonical.release_year().value() as i64; + let director = canonical.director().map(str::to_string); + let poster = canonical.poster_path().map(|p| p.value().to_string()); + + let mut tx = self.pool.begin().await.map_err(Self::map_err)?; + + // 1. Upsert canonical movie record + sqlx::query( + "INSERT INTO movies (id, external_metadata_id, title, release_year, director, poster_path) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + external_metadata_id = COALESCE(excluded.external_metadata_id, movies.external_metadata_id), + poster_path = COALESCE(excluded.poster_path, movies.poster_path)", + ) + .bind(&new).bind(&ext_id).bind(&title).bind(year).bind(&director).bind(&poster) + .execute(&mut *tx).await.map_err(Self::map_err)?; + + // 2. Re-point simple FK tables + let reviews = sqlx::query("UPDATE reviews SET movie_id = ? WHERE movie_id = ?") + .bind(&new) + .bind(&old) + .execute(&mut *tx) + .await + .map_err(Self::map_err)? + .rows_affected(); + + let watchlist = sqlx::query("UPDATE watchlist_entries SET movie_id = ? WHERE movie_id = ?") + .bind(&new) + .bind(&old) + .execute(&mut *tx) + .await + .map_err(Self::map_err)? + .rows_affected(); + + let watch_events = sqlx::query("UPDATE watch_events SET movie_id = ? WHERE movie_id = ?") + .bind(&new) + .bind(&old) + .execute(&mut *tx) + .await + .map_err(Self::map_err)? + .rows_affected(); + + // 3. Re-point movie_profiles (PK — move only if canonical has none) + let profiles = sqlx::query("UPDATE movie_profiles SET movie_id = ? WHERE movie_id = ?") + .bind(&new) + .bind(&old) + .execute(&mut *tx) + .await + .map_err(Self::map_err)? + .rows_affected(); + + // 4. Re-point enrichment tables with composite PKs (INSERT OR IGNORE + DELETE) + // Canonical's existing rows win on conflict — old duplicates are discarded. + sqlx::query( + "INSERT OR IGNORE INTO movie_genres (movie_id, tmdb_id, name) + SELECT ?, tmdb_id, name FROM movie_genres WHERE movie_id = ?", + ) + .bind(&new) + .bind(&old) + .execute(&mut *tx) + .await + .map_err(Self::map_err)?; + sqlx::query("DELETE FROM movie_genres WHERE movie_id = ?") + .bind(&old) + .execute(&mut *tx) + .await + .map_err(Self::map_err)?; + + sqlx::query( + "INSERT OR IGNORE INTO movie_keywords (movie_id, tmdb_id, name) + SELECT ?, tmdb_id, name FROM movie_keywords WHERE movie_id = ?", + ) + .bind(&new) + .bind(&old) + .execute(&mut *tx) + .await + .map_err(Self::map_err)?; + sqlx::query("DELETE FROM movie_keywords WHERE movie_id = ?") + .bind(&old) + .execute(&mut *tx) + .await + .map_err(Self::map_err)?; + + sqlx::query( + "INSERT OR IGNORE INTO movie_cast (movie_id, tmdb_person_id, name, character, billing_order, profile_path) + SELECT ?, tmdb_person_id, name, character, billing_order, profile_path FROM movie_cast WHERE movie_id = ?", + ).bind(&new).bind(&old).execute(&mut *tx).await.map_err(Self::map_err)?; + sqlx::query("DELETE FROM movie_cast WHERE movie_id = ?") + .bind(&old) + .execute(&mut *tx) + .await + .map_err(Self::map_err)?; + + sqlx::query( + "INSERT OR IGNORE INTO movie_crew (movie_id, tmdb_person_id, name, job, department, profile_path) + SELECT ?, tmdb_person_id, name, job, department, profile_path FROM movie_crew WHERE movie_id = ?", + ).bind(&new).bind(&old).execute(&mut *tx).await.map_err(Self::map_err)?; + sqlx::query("DELETE FROM movie_crew WHERE movie_id = ?") + .bind(&old) + .execute(&mut *tx) + .await + .map_err(Self::map_err)?; + + // 5. Delete the now-empty old movie record (remaining cascades are safe: all FKs cleared above) + sqlx::query("DELETE FROM movies WHERE id = ?") + .bind(&old) + .execute(&mut *tx) + .await + .map_err(Self::map_err)?; + + tx.commit().await.map_err(Self::map_err)?; + + Ok(reviews + watchlist + watch_events + profiles) + } +} diff --git a/crates/application/src/diary/tests/movie_resolver.rs b/crates/application/src/diary/tests/movie_resolver.rs index 4b94911..1316833 100644 --- a/crates/application/src/diary/tests/movie_resolver.rs +++ b/crates/application/src/diary/tests/movie_resolver.rs @@ -75,6 +75,11 @@ impl MovieRepository for RepoWithExternalMovie { { panic!("unexpected") } + async fn list_movies_with_external_id( + &self, + ) -> Result, DomainError> { + Ok(vec![]) + } } #[async_trait::async_trait] @@ -121,6 +126,11 @@ impl MovieRepository for RepoEmpty { { panic!("unexpected") } + async fn list_movies_with_external_id( + &self, + ) -> Result, DomainError> { + Ok(vec![]) + } } #[async_trait::async_trait] @@ -167,6 +177,11 @@ impl MovieRepository for RepoWithTitleMatch { { panic!("unexpected") } + async fn list_movies_with_external_id( + &self, + ) -> Result, DomainError> { + Ok(vec![]) + } } struct MetaReturnsMovie(Movie); diff --git a/crates/application/src/jobs/mod.rs b/crates/application/src/jobs/mod.rs index 5b088bd..93fab65 100644 --- a/crates/application/src/jobs/mod.rs +++ b/crates/application/src/jobs/mod.rs @@ -1,11 +1,13 @@ mod enrichment_staleness; mod import_cleanup; +mod movie_dedup; mod refresh_session_cleanup; mod watch_event_cleanup; mod wrapup; pub use enrichment_staleness::EnrichmentStalenessJob; pub use import_cleanup::ImportSessionCleanupJob; +pub use movie_dedup::MovieDeduplicationJob; pub use refresh_session_cleanup::RefreshSessionCleanupJob; pub use watch_event_cleanup::WatchEventCleanupJob; pub use wrapup::{WrapUpAutoGenerateJob, WrapUpCleanupJob}; diff --git a/crates/application/src/jobs/movie_dedup.rs b/crates/application/src/jobs/movie_dedup.rs new file mode 100644 index 0000000..5d9a514 --- /dev/null +++ b/crates/application/src/jobs/movie_dedup.rs @@ -0,0 +1,49 @@ +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use domain::{ + errors::DomainError, + ports::{MovieDeduplicator, MovieRepository, ObjectStorage, PeriodicJob}, +}; + +use crate::movies::merge_duplicates::{MergeDuplicatesDeps, execute}; + +pub struct MovieDeduplicationJob { + deps: MergeDuplicatesDeps, +} + +impl MovieDeduplicationJob { + pub fn new( + movie: Arc, + deduplicator: Arc, + object_storage: Arc, + ) -> Self { + Self { + deps: MergeDuplicatesDeps { + movie, + deduplicator, + object_storage, + }, + } + } +} + +#[async_trait] +impl PeriodicJob for MovieDeduplicationJob { + fn interval(&self) -> Duration { + Duration::from_secs(86_400) // once per day + } + + async fn run(&self) -> Result<(), DomainError> { + let report = execute(&self.deps).await?; + if report.pairs_found > 0 { + tracing::info!( + pairs_found = report.pairs_found, + rows_repointed = report.rows_repointed, + "movie dedup: merged duplicate records" + ); + } + Ok(()) + } +} diff --git a/crates/application/src/movies/merge_duplicates.rs b/crates/application/src/movies/merge_duplicates.rs new file mode 100644 index 0000000..7f9dedc --- /dev/null +++ b/crates/application/src/movies/merge_duplicates.rs @@ -0,0 +1,90 @@ +use std::sync::Arc; + +use domain::{ + errors::DomainError, + ports::{MovieDeduplicator, MovieRepository, ObjectStorage}, + value_objects::MovieId, +}; + +pub struct MergeDuplicatesDeps { + pub movie: Arc, + pub deduplicator: Arc, + pub object_storage: Arc, +} + +pub struct MergeReport { + pub pairs_found: u64, + pub rows_repointed: u64, +} + +pub async fn execute(deps: &MergeDuplicatesDeps) -> Result { + let movies = deps.movie.list_movies_with_external_id().await?; + + let mut pairs_found = 0u64; + let mut rows_repointed = 0u64; + + for movie in movies { + let external_id = match movie.external_metadata_id() { + Some(id) => id, + None => continue, + }; + + let canonical_id = MovieId::from_external(external_id); + if movie.id() == &canonical_id { + continue; // already canonical + } + + pairs_found += 1; + + // Determine which poster will be dropped after merge + let canonical = match deps.movie.get_movie_by_id(&canonical_id).await? { + Some(existing) => existing, + None => domain::models::Movie::from_persistence( + canonical_id, + movie.external_metadata_id().cloned(), + movie.title().clone(), + movie.release_year().clone(), + movie.director().map(str::to_string), + movie.poster_path().cloned(), + ), + }; + + // The COALESCE in merge_into_canonical keeps canonical's poster if it has one, + // otherwise takes old's. Work out which poster key will be orphaned. + let orphaned_poster = match (canonical.poster_path(), movie.poster_path()) { + (Some(_), Some(old_poster)) if canonical.poster_path() != movie.poster_path() => { + // Canonical wins — old movie's poster will be orphaned + Some(old_poster.value().to_string()) + } + (None, Some(_)) => None, // old poster moves to canonical, nothing orphaned + _ => None, + }; + + let repointed = deps + .deduplicator + .merge_into_canonical(movie.id(), &canonical) + .await?; + + // Delete the orphaned poster file from object storage + if let Some(key) = orphaned_poster + && let Err(e) = deps.object_storage.delete(&key).await + { + tracing::warn!(key, "failed to delete orphaned poster: {e}"); + } + + rows_repointed += repointed; + + tracing::info!( + old_id = %movie.id().value(), + canonical_id = %canonical.id().value(), + external_id = %external_id.value(), + rows_repointed = repointed, + "merged duplicate movie" + ); + } + + Ok(MergeReport { + pairs_found, + rows_repointed, + }) +} diff --git a/crates/application/src/movies/mod.rs b/crates/application/src/movies/mod.rs index ceeff85..c0f5905 100644 --- a/crates/application/src/movies/mod.rs +++ b/crates/application/src/movies/mod.rs @@ -5,6 +5,7 @@ pub mod enrich_movie; pub mod event_handler; pub mod get_movie_profile; pub mod get_movies; +pub mod merge_duplicates; pub mod queries; pub mod reindex_search; pub mod request_enrichment; diff --git a/crates/application/src/tests/worker.rs b/crates/application/src/tests/worker.rs index 641cb43..ad71889 100644 --- a/crates/application/src/tests/worker.rs +++ b/crates/application/src/tests/worker.rs @@ -84,6 +84,9 @@ impl EventHandler for RecordingHandler { | DomainEvent::GoalUpdated { .. } | DomainEvent::GoalDeleted { .. } => "goal", DomainEvent::PersonEnrichmentRequested { .. } => "person_enrichment_requested", + DomainEvent::UserDeleted { .. } | DomainEvent::UserAccountMoved { .. } => { + "user_lifecycle" + } }; self.calls.lock().unwrap().push(label); Ok(()) diff --git a/crates/application/src/users/delete_account.rs b/crates/application/src/users/delete_account.rs new file mode 100644 index 0000000..2454387 --- /dev/null +++ b/crates/application/src/users/delete_account.rs @@ -0,0 +1,19 @@ +use domain::{errors::DomainError, events::DomainEvent, value_objects::UserId}; + +use crate::users::deps::UpdateProfileDeps; + +pub async fn execute(deps: &UpdateProfileDeps, user_id: uuid::Uuid) -> Result<(), DomainError> { + let uid = UserId::from_uuid(user_id); + + deps.user + .find_by_id(&uid) + .await? + .ok_or_else(|| DomainError::NotFound("User not found".into()))?; + + // Notify federation peers before any data is removed so they can process the tombstone. + deps.event_publisher + .publish(&DomainEvent::UserDeleted { user_id: uid }) + .await?; + + Ok(()) +} diff --git a/crates/application/src/users/mod.rs b/crates/application/src/users/mod.rs index 7a0c3a7..a7e673f 100644 --- a/crates/application/src/users/mod.rs +++ b/crates/application/src/users/mod.rs @@ -1,4 +1,5 @@ pub mod commands; +pub mod delete_account; pub mod deps; pub mod get_current_profile; pub mod get_profile; diff --git a/crates/application/src/users/update_profile.rs b/crates/application/src/users/update_profile.rs index 930e3b6..23c43d4 100644 --- a/crates/application/src/users/update_profile.rs +++ b/crates/application/src/users/update_profile.rs @@ -68,6 +68,14 @@ pub async fn execute( user.banner_path().map(|s| s.to_string()) }; + let moved_to = cmd.also_known_as.as_deref().and_then(|new_url| { + if user.also_known_as().map(|s| s != new_url).unwrap_or(true) { + Some(new_url.to_string()) + } else { + None + } + }); + deps.user .update_profile( &user_id, @@ -83,9 +91,21 @@ pub async fn execute( .await?; deps.event_publisher - .publish(&DomainEvent::UserUpdated { user_id }) + .publish(&DomainEvent::UserUpdated { + user_id: user_id.clone(), + }) .await?; + if let Some(new_actor_url) = moved_to { + let _ = deps + .event_publisher + .publish(&DomainEvent::UserAccountMoved { + user_id, + new_actor_url, + }) + .await; + } + Ok(()) } diff --git a/crates/domain/src/events.rs b/crates/domain/src/events.rs index fc5f788..c4cca8c 100644 --- a/crates/domain/src/events.rs +++ b/crates/domain/src/events.rs @@ -112,6 +112,13 @@ pub enum DomainEvent { user_id: UserId, year: u16, }, + UserDeleted { + user_id: UserId, + }, + UserAccountMoved { + user_id: UserId, + new_actor_url: String, + }, } #[async_trait] diff --git a/crates/domain/src/ports/movie.rs b/crates/domain/src/ports/movie.rs index 3eb301d..8bd65d3 100644 --- a/crates/domain/src/ports/movie.rs +++ b/crates/domain/src/ports/movie.rs @@ -36,6 +36,8 @@ pub trait MovieRepository: Send + Sync { page: &PageParams, filter: &MovieFilter, ) -> Result, DomainError>; + /// Returns all movies that have an external_metadata_id set. Used for deduplication. + async fn list_movies_with_external_id(&self) -> Result, DomainError>; } #[async_trait] @@ -68,3 +70,15 @@ pub trait MovieEnrichmentClient: Send + Sync { external_metadata_id: &str, ) -> Result; } + +#[async_trait] +pub trait MovieDeduplicator: Send + Sync { + /// Atomically re-points all foreign keys (reviews, watchlist entries, movie profiles) + /// from `old_id` to `canonical_id`, upserts the canonical movie record, then deletes + /// the old duplicate. Returns the number of rows re-pointed across all tables. + async fn merge_into_canonical( + &self, + old_id: &MovieId, + canonical: &Movie, + ) -> Result; +} diff --git a/crates/domain/src/ports/social.rs b/crates/domain/src/ports/social.rs index 9933751..ab4dee6 100644 --- a/crates/domain/src/ports/social.rs +++ b/crates/domain/src/ports/social.rs @@ -74,6 +74,10 @@ pub trait LocalApContentQuery: Send + Sync { ) -> Result, DomainError>; async fn get_review_by_id(&self, review_id: &ReviewId) -> Result, DomainError>; async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result, DomainError>; + async fn get_movie_by_external_metadata_id( + &self, + external_id: &str, + ) -> Result, DomainError>; async fn count_local_posts(&self) -> Result; async fn get_local_reviews_for_movie( &self, @@ -90,4 +94,5 @@ pub trait LocalApContentQuery: Send + Sync { user_id: &UserId, year: u16, ) -> Result, DomainError>; + async fn list_goals_for_user(&self, user_id: &UserId) -> Result, DomainError>; } diff --git a/crates/domain/src/testing/in_memory.rs b/crates/domain/src/testing/in_memory.rs index 0ae859e..caf6ef2 100644 --- a/crates/domain/src/testing/in_memory.rs +++ b/crates/domain/src/testing/in_memory.rs @@ -138,6 +138,17 @@ impl MovieRepository for InMemoryMovieRepository { offset: 0, }) } + + async fn list_movies_with_external_id(&self) -> Result, DomainError> { + Ok(self + .store + .lock() + .unwrap() + .values() + .filter(|m| m.external_metadata_id().is_some()) + .cloned() + .collect()) + } } // ── InMemoryReviewRepository ────────────────────────────────────────────────── diff --git a/crates/domain/src/value_objects/ids.rs b/crates/domain/src/value_objects/ids.rs index e63fadc..678cf61 100644 --- a/crates/domain/src/value_objects/ids.rs +++ b/crates/domain/src/value_objects/ids.rs @@ -20,6 +20,19 @@ macro_rules! uuid_id { } uuid_id!(MovieId); + +impl MovieId { + /// Derives a stable, deterministic UUID from an external metadata ID (e.g. `tmdb:12345`). + /// All instances that know a movie by the same external ID will produce the same MovieId, + /// enabling remote and local reviews to be linked to the same movie record. + pub fn from_external(external_id: &crate::value_objects::ExternalMetadataId) -> Self { + Self(Uuid::new_v5( + &Uuid::NAMESPACE_URL, + external_id.value().as_bytes(), + )) + } +} + uuid_id!(ReviewId); uuid_id!(UserId); uuid_id!(ImportSessionId); diff --git a/crates/presentation/src/tests/extractors.rs b/crates/presentation/src/tests/extractors.rs index cbc2d2a..404483c 100644 --- a/crates/presentation/src/tests/extractors.rs +++ b/crates/presentation/src/tests/extractors.rs @@ -78,6 +78,11 @@ impl MovieRepository for Panic { { panic!() } + async fn list_movies_with_external_id( + &self, + ) -> Result, DomainError> { + Ok(vec![]) + } } #[async_trait::async_trait] impl ReviewRepository for Panic { diff --git a/crates/worker/src/db.rs b/crates/worker/src/db.rs index 476f718..e228d6d 100644 --- a/crates/worker/src/db.rs +++ b/crates/worker/src/db.rs @@ -3,8 +3,8 @@ use std::sync::Arc; use anyhow::Context; use domain::ports::{ ImageRefCommand, ImageRefQuery, ImportSessionRepository, LocalApContentQuery, - MovieProfileRepository, MovieRepository, PersonCommand, PersonQuery, SearchCommand, - UserRepository, WatchEventRepository, + MovieDeduplicator, MovieProfileRepository, MovieRepository, PersonCommand, PersonQuery, + SearchCommand, UserRepository, WatchEventRepository, }; pub enum DbPool { @@ -31,6 +31,7 @@ pub struct WorkerDbOutput { pub remote_goal: Arc, pub refresh_session: Arc, pub federation_settings: Arc, + pub deduplicator: Arc, pub db_pool: DbPool, } @@ -66,6 +67,7 @@ pub async fn connect(database_url: &str, backend: &str) -> anyhow::Result anyhow::Result anyhow::Result<()> { }; let movie = db.movie; + let deduplicator = db.deduplicator; let user = db.user; let import_session = db.import_session; let movie_profile = db.movie_profile; @@ -130,6 +131,11 @@ async fn main() -> anyhow::Result<()> { // ── Periodic jobs ───────────────────────────────────────────────────────── let mut periodic_jobs: Vec> = vec![ + Arc::new(application::jobs::MovieDeduplicationJob::new( + Arc::clone(&movie), + Arc::clone(&deduplicator), + Arc::clone(&object_storage), + )), Arc::new(application::jobs::ImportSessionCleanupJob::new( import_session.clone(), )),