This commit is contained in:
8
Cargo.lock
generated
8
Cargo.lock
generated
@@ -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]]
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -24,9 +24,33 @@ impl ApContentReader for CompositeObjectHandler {
|
||||
before: Option<DateTime<Utc>>,
|
||||
limit: usize,
|
||||
) -> anyhow::Result<Vec<(Url, serde_json::Value, DateTime<Utc>)>> {
|
||||
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<Utc>)> = 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<u64> {
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<url::Url>().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,
|
||||
ReviewApInput {
|
||||
ap_id: ap_id.clone(),
|
||||
actor_url: actor,
|
||||
movie_title,
|
||||
release_year,
|
||||
poster_url,
|
||||
&self.base_url,
|
||||
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,
|
||||
ReviewApInput {
|
||||
ap_id,
|
||||
actor,
|
||||
actor_url: actor,
|
||||
movie_title,
|
||||
release_year,
|
||||
poster_url,
|
||||
&self.base_url,
|
||||
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,
|
||||
ReviewApInput {
|
||||
ap_id,
|
||||
actor,
|
||||
movie.title().value().to_string(),
|
||||
movie.release_year().value(),
|
||||
poster_url.clone(),
|
||||
&self.base_url,
|
||||
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)?;
|
||||
|
||||
|
||||
@@ -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<dyn RemoteGoalRepository>,
|
||||
pub content_query: Arc<dyn LocalApContentQuery>,
|
||||
pub base_url: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ApContentReader for GoalObjectHandler {
|
||||
async fn get_local_objects_page(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
_before: Option<DateTime<chrono::Utc>>,
|
||||
_limit: usize,
|
||||
) -> anyhow::Result<Vec<(Url, serde_json::Value, DateTime<chrono::Utc>)>> {
|
||||
let uid = UserId::from_uuid(user_id);
|
||||
let goals = self
|
||||
.content_query
|
||||
.list_goals_for_user(&uid)
|
||||
.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<u64> {
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
||||
@@ -77,6 +77,7 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
|
||||
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<ActivityPubWire> {
|
||||
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,
|
||||
|
||||
@@ -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<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) name: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) poster_url: Option<String>,
|
||||
pub(crate) rating: u8,
|
||||
pub(crate) comment: Option<String>,
|
||||
pub(crate) watched_at: DateTime<Utc>,
|
||||
/// 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<ApAttachment>,
|
||||
#[serde(default)]
|
||||
pub(crate) tag: Vec<ApHashtag>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
@@ -43,17 +62,26 @@ pub struct ReviewObject {
|
||||
pub(crate) cc: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
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<String>,
|
||||
pub poster_url: Option<String>,
|
||||
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)],
|
||||
|
||||
@@ -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<()>;
|
||||
|
||||
|
||||
@@ -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<dyn LocalApContentQuery>,
|
||||
pub review_store: Arc<dyn RemoteReviewRepository>,
|
||||
pub event_publisher: Arc<dyn EventPublisher>,
|
||||
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(),
|
||||
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,
|
||||
&self.base_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(
|
||||
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,
|
||||
obj.movie_title.as_bytes(),
|
||||
));
|
||||
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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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<dyn RemoteWatchlistRepository>,
|
||||
@@ -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<DateTime<chrono::Utc>>,
|
||||
_limit: usize,
|
||||
) -> anyhow::Result<Vec<(Url, serde_json::Value, DateTime<chrono::Utc>)>> {
|
||||
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<u64> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<EventPayload> 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,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Movie, DomainError> {
|
||||
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,
|
||||
|
||||
@@ -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}")
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
29
crates/adapters/postgres-federation/src/activity.rs
Normal file
29
crates/adapters/postgres-federation/src/activity.rs
Normal file
@@ -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<bool> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
100
crates/adapters/postgres-federation/src/actor.rs
Normal file
100
crates/adapters/postgres-federation/src/actor.rs
Normal file
@@ -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<Option<(String, String)>> {
|
||||
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<Option<RemoteActor>> {
|
||||
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<chrono::Utc>,
|
||||
) -> 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<usize> {
|
||||
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::<i64, _>("cnt") as usize)
|
||||
}
|
||||
}
|
||||
@@ -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, DomainError> {
|
||||
Uuid::parse_str(s)
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Invalid UUID '{}': {}", s, e)))
|
||||
}
|
||||
|
||||
fn parse_datetime(s: &str) -> Result<chrono::NaiveDateTime, DomainError> {
|
||||
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<String>,
|
||||
title: String,
|
||||
release_year: i64,
|
||||
director: Option<String>,
|
||||
poster_path: Option<String>,
|
||||
}
|
||||
|
||||
impl MovieRow {
|
||||
fn into_domain(self) -> Result<Movie, DomainError> {
|
||||
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<String>,
|
||||
watched_at: String,
|
||||
created_at: String,
|
||||
remote_actor_url: Option<String>,
|
||||
}
|
||||
|
||||
impl ReviewRow {
|
||||
fn into_domain(self) -> Result<Review, DomainError> {
|
||||
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<String>,
|
||||
title: String,
|
||||
release_year: i64,
|
||||
director: Option<String>,
|
||||
poster_path: Option<String>,
|
||||
review_id: String,
|
||||
movie_id: String,
|
||||
user_id: String,
|
||||
rating: i64,
|
||||
comment: Option<String>,
|
||||
watched_at: String,
|
||||
created_at: String,
|
||||
remote_actor_url: Option<String>,
|
||||
}
|
||||
|
||||
impl DiaryRow {
|
||||
fn into_domain(self) -> Result<DiaryEntry, DomainError> {
|
||||
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<Goal, DomainError> {
|
||||
let id_str: String = r
|
||||
.try_get("id")
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read goal id: {e}")))?;
|
||||
let user_id_str: String = r
|
||||
.try_get("user_id")
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read user_id: {e}")))?;
|
||||
let year: i64 = r
|
||||
.try_get("year")
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read year: {e}")))?;
|
||||
let target: i64 = r.try_get("target_count").map_err(|e| {
|
||||
DomainError::InfrastructureError(format!("Failed to read target_count: {e}"))
|
||||
})?;
|
||||
let goal_type_str: String = r
|
||||
.try_get("goal_type")
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read goal_type: {e}")))?;
|
||||
let created_at_str: String = r
|
||||
.try_get("created_at")
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read created_at: {e}")))?;
|
||||
|
||||
let id = GoalId::from_uuid(parse_uuid(&id_str)?);
|
||||
let user_id = UserId::from_uuid(parse_uuid(&user_id_str)?);
|
||||
let goal_type: GoalType = goal_type_str.parse()?;
|
||||
let created_at = parse_datetime(&created_at_str)?;
|
||||
|
||||
Ok(Goal::from_persistence(
|
||||
id,
|
||||
user_id,
|
||||
year as u16,
|
||||
target as u32,
|
||||
goal_type,
|
||||
created_at,
|
||||
))
|
||||
}
|
||||
|
||||
async fn count_reviews_in_year(
|
||||
pool: &PgPool,
|
||||
user_id: &UserId,
|
||||
year: u16,
|
||||
) -> Result<u32, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let start = format!("{year}-01-01 00:00:00");
|
||||
let end = format!("{}-01-01 00:00:00", year + 1);
|
||||
|
||||
let count: i64 = sqlx::query(
|
||||
"SELECT COUNT(*) FROM reviews \
|
||||
WHERE user_id = $1 \
|
||||
AND watched_at >= $2::timestamptz \
|
||||
AND watched_at < $3::timestamptz \
|
||||
AND remote_actor_url IS NULL",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(&start)
|
||||
.bind(&end)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Database error: {:?}", e);
|
||||
DomainError::InfrastructureError("Database operation failed".into())
|
||||
})?
|
||||
.try_get(0)
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
Ok(count as u32)
|
||||
}
|
||||
|
||||
// ── LocalApContentQuery impl ─────────────────────────────────────────────────
|
||||
|
||||
#[async_trait]
|
||||
impl LocalApContentQuery for PostgresApContentQuery {
|
||||
async fn get_local_reviews_for_user(
|
||||
@@ -169,6 +367,22 @@ impl LocalApContentQuery for PostgresApContentQuery {
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_movie_by_external_metadata_id(
|
||||
&self,
|
||||
external_id: &str,
|
||||
) -> Result<Option<Movie>, DomainError> {
|
||||
sqlx::query_as::<_, MovieRow>(
|
||||
"SELECT id, external_metadata_id, title, release_year, director, poster_path
|
||||
FROM movies WHERE external_metadata_id = $1",
|
||||
)
|
||||
.bind(external_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.map(MovieRow::into_domain)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn count_local_posts(&self) -> Result<u64, DomainError> {
|
||||
let count: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE remote_actor_url IS NULL")
|
||||
@@ -233,7 +447,7 @@ impl LocalApContentQuery for PostgresApContentQuery {
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
year: u16,
|
||||
) -> Result<Option<(domain::models::Goal, u32)>, DomainError> {
|
||||
) -> Result<Option<(Goal, u32)>, 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<Vec<Goal>, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, user_id, year, target_count, goal_type, \
|
||||
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at \
|
||||
FROM goals WHERE user_id = $1 ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
rows.iter().map(row_to_goal).collect()
|
||||
}
|
||||
}
|
||||
87
crates/adapters/postgres-federation/src/blocklist.rs
Normal file
87
crates/adapters/postgres-federation/src/blocklist.rs
Normal file
@@ -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<Vec<BlockedDomain>> {
|
||||
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<bool> {
|
||||
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<Vec<String>> {
|
||||
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::<String, _>("remote_actor_url"))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<bool> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
321
crates/adapters/postgres-federation/src/follow.rs
Normal file
321
crates/adapters/postgres-federation/src/follow.rs
Normal file
@@ -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<Option<String>> {
|
||||
let uid = local_user_id.to_string();
|
||||
let row: Option<String> = 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<Vec<Follower>> {
|
||||
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<Vec<Follower>> {
|
||||
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<usize> {
|
||||
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<Vec<RemoteActor>> {
|
||||
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<Vec<String>> {
|
||||
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::<String, _>("inbox").ok())
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn count_accepted_followers(&self, local_user_id: uuid::Uuid) -> Result<usize> {
|
||||
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<Vec<RemoteActor>> {
|
||||
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<Option<String>> {
|
||||
let uid = local_user_id.to_string();
|
||||
let row: Option<String> = 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<Vec<RemoteActor>> {
|
||||
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<usize> {
|
||||
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<Vec<RemoteActor>> {
|
||||
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<Option<String>> {
|
||||
let uid = local_user_id.to_string();
|
||||
let row: Option<Option<String>> = 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<Vec<uuid::Uuid>> {
|
||||
let candidates: Vec<String> = 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()
|
||||
}
|
||||
}
|
||||
@@ -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<Option<String>> {
|
||||
let uid = local_user_id.to_string();
|
||||
let row: Option<String> = 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<Vec<Follower>> {
|
||||
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<Vec<Follower>> {
|
||||
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<usize> {
|
||||
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<Vec<RemoteActor>> {
|
||||
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<Vec<String>> {
|
||||
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::<String, _>("inbox").ok())
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn count_accepted_followers(&self, local_user_id: uuid::Uuid) -> Result<usize> {
|
||||
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<Vec<RemoteActor>> {
|
||||
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<Option<String>> {
|
||||
let uid = local_user_id.to_string();
|
||||
let row: Option<String> = 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<Vec<RemoteActor>> {
|
||||
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<usize> {
|
||||
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<Vec<RemoteActor>> {
|
||||
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<Option<String>> {
|
||||
let uid = local_user_id.to_string();
|
||||
let row: Option<Option<String>> = 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<Vec<uuid::Uuid>> {
|
||||
let candidates: Vec<String> = 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<Option<(String, String)>> {
|
||||
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<Option<RemoteActor>> {
|
||||
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<chrono::Utc>,
|
||||
) -> 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<usize> {
|
||||
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::<i64, _>("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<Vec<BlockedDomain>> {
|
||||
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<bool> {
|
||||
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<Vec<String>> {
|
||||
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::<String, _>("remote_actor_url"))
|
||||
.collect())
|
||||
}
|
||||
async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<bool> {
|
||||
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<bool> {
|
||||
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<Vec<String>, 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<Vec<domain::models::RemoteActorInfo>, domain::errors::DomainError> {
|
||||
let rows = sqlx::query_as::<_, (String, String, Option<String>)>(
|
||||
"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<usize, domain::errors::DomainError> {
|
||||
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<usize, domain::errors::DomainError> {
|
||||
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<Vec<domain::models::PendingFollowerInfo>, domain::errors::DomainError> {
|
||||
let uid = user_id.to_string();
|
||||
let rows = sqlx::query_as::<_, (String, String, Option<String>, Option<String>)>(
|
||||
"SELECT ar.url, ar.handle, ar.display_name, ar.avatar_url
|
||||
FROM ap_followers f
|
||||
JOIN ap_remote_actors ar ON ar.url = f.remote_actor_url
|
||||
WHERE f.local_user_id = $1 AND f.status = 'pending'",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| 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<Vec<RemoteWatchlistEntry>, 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::<i32, _>("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::<chrono::DateTime<chrono::Utc>, _>("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<Vec<RemoteWatchlistEntry>, domain::errors::DomainError> {
|
||||
let actors: Vec<String> =
|
||||
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::<String, _>("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 _,
|
||||
|
||||
116
crates/adapters/postgres-federation/src/review.rs
Normal file
116
crates/adapters/postgres-federation/src/review.rs
Normal file
@@ -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(())
|
||||
}
|
||||
}
|
||||
81
crates/adapters/postgres-federation/src/social.rs
Normal file
81
crates/adapters/postgres-federation/src/social.rs
Normal file
@@ -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<Vec<String>, 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<Vec<RemoteActorInfo>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, (String, String, Option<String>)>(
|
||||
"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<usize, DomainError> {
|
||||
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<usize, DomainError> {
|
||||
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<Vec<PendingFollowerInfo>, DomainError> {
|
||||
let uid = user_id.to_string();
|
||||
let rows = sqlx::query_as::<_, (String, String, Option<String>, Option<String>)>(
|
||||
"SELECT ar.url, ar.handle, ar.display_name, ar.avatar_url FROM ap_followers f JOIN ap_remote_actors ar ON ar.url = f.remote_actor_url WHERE f.local_user_id = $1 AND f.status = 'pending'",
|
||||
).bind(&uid).fetch_all(&self.pool).await.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(
|
||||
|(url, handle, display_name, avatar_url)| PendingFollowerInfo {
|
||||
url,
|
||||
handle,
|
||||
display_name,
|
||||
avatar_url,
|
||||
},
|
||||
)
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
91
crates/adapters/postgres-federation/src/watchlist.rs
Normal file
91
crates/adapters/postgres-federation/src/watchlist.rs
Normal file
@@ -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<Vec<RemoteWatchlistEntry>, 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::<i32, _>("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::<chrono::DateTime<chrono::Utc>, _>("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<Vec<RemoteWatchlistEntry>, DomainError> {
|
||||
let actors: Vec<String> =
|
||||
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::<String, _>("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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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<dyn domain::ports::UserSettingsRepository>,
|
||||
pub federation_settings: std::sync::Arc<dyn domain::ports::UserFederationSettingsQuery>,
|
||||
pub remote_goal: std::sync::Arc<dyn domain::ports::RemoteGoalRepository>,
|
||||
pub deduplicator: std::sync::Arc<dyn domain::ports::MovieDeduplicator>,
|
||||
}
|
||||
|
||||
pub async fn wire(database_url: &str) -> anyhow::Result<PostgresWireOutput> {
|
||||
@@ -132,7 +133,9 @@ pub async fn wire(database_url: &str) -> anyhow::Result<PostgresWireOutput> {
|
||||
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 _,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -239,4 +239,17 @@ impl MovieRepository for PostgresMovieRepository {
|
||||
offset: page.offset,
|
||||
})
|
||||
}
|
||||
|
||||
async fn list_movies_with_external_id(&self) -> Result<Vec<Movie>, 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()
|
||||
}
|
||||
}
|
||||
|
||||
154
crates/adapters/postgres/src/movie_dedup.rs
Normal file
154
crates/adapters/postgres/src/movie_dedup.rs
Normal file
@@ -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<u64, DomainError> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
27
crates/adapters/sqlite-federation/src/activity.rs
Normal file
27
crates/adapters/sqlite-federation/src/activity.rs
Normal file
@@ -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<bool> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
117
crates/adapters/sqlite-federation/src/actor.rs
Normal file
117
crates/adapters/sqlite-federation/src/actor.rs
Normal file
@@ -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<Option<(String, String)>> {
|
||||
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<Option<RemoteActor>> {
|
||||
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<chrono::Utc>,
|
||||
) -> 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<usize> {
|
||||
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::<i64, _>("cnt") as usize)
|
||||
}
|
||||
}
|
||||
463
crates/adapters/sqlite-federation/src/ap_content.rs
Normal file
463
crates/adapters/sqlite-federation/src/ap_content.rs
Normal file
@@ -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, DomainError> {
|
||||
Uuid::parse_str(s)
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Invalid UUID '{}': {}", s, e)))
|
||||
}
|
||||
|
||||
fn parse_datetime(s: &str) -> Result<chrono::NaiveDateTime, DomainError> {
|
||||
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<String>,
|
||||
title: String,
|
||||
release_year: i64,
|
||||
director: Option<String>,
|
||||
poster_path: Option<String>,
|
||||
}
|
||||
|
||||
impl MovieRow {
|
||||
fn into_domain(self) -> Result<Movie, DomainError> {
|
||||
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<String>,
|
||||
watched_at: String,
|
||||
created_at: String,
|
||||
remote_actor_url: Option<String>,
|
||||
}
|
||||
|
||||
impl ReviewRow {
|
||||
fn into_domain(self) -> Result<Review, DomainError> {
|
||||
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<String>,
|
||||
title: String,
|
||||
release_year: i64,
|
||||
director: Option<String>,
|
||||
poster_path: Option<String>,
|
||||
review_id: String,
|
||||
movie_id: String,
|
||||
user_id: String,
|
||||
rating: i64,
|
||||
comment: Option<String>,
|
||||
watched_at: String,
|
||||
created_at: String,
|
||||
remote_actor_url: Option<String>,
|
||||
}
|
||||
|
||||
impl DiaryRow {
|
||||
fn into_domain(self) -> Result<DiaryEntry, DomainError> {
|
||||
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<String>,
|
||||
title: String,
|
||||
release_year: i64,
|
||||
director: Option<String>,
|
||||
poster_path: Option<String>,
|
||||
}
|
||||
|
||||
impl WatchlistRow {
|
||||
fn into_domain(self) -> Result<WatchlistWithMovie, DomainError> {
|
||||
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<Goal, DomainError> {
|
||||
let id_str: String = r
|
||||
.try_get("id")
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read goal id: {e}")))?;
|
||||
let user_id_str: String = r
|
||||
.try_get("user_id")
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read user_id: {e}")))?;
|
||||
let year: i64 = r
|
||||
.try_get("year")
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read year: {e}")))?;
|
||||
let target: i64 = r.try_get("target_count").map_err(|e| {
|
||||
DomainError::InfrastructureError(format!("Failed to read target_count: {e}"))
|
||||
})?;
|
||||
let goal_type_str: String = r
|
||||
.try_get("goal_type")
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read goal_type: {e}")))?;
|
||||
let created_at_str: String = r
|
||||
.try_get("created_at")
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read created_at: {e}")))?;
|
||||
|
||||
let id = GoalId::from_uuid(
|
||||
Uuid::parse_str(&id_str)
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Invalid goal UUID: {e}")))?,
|
||||
);
|
||||
let user_id = UserId::from_uuid(
|
||||
Uuid::parse_str(&user_id_str)
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Invalid user UUID: {e}")))?,
|
||||
);
|
||||
let goal_type: GoalType = goal_type_str.parse()?;
|
||||
let created_at = parse_datetime(&created_at_str)?;
|
||||
|
||||
Ok(Goal::from_persistence(
|
||||
id,
|
||||
user_id,
|
||||
year as u16,
|
||||
target as u32,
|
||||
goal_type,
|
||||
created_at,
|
||||
))
|
||||
}
|
||||
|
||||
async fn count_reviews_in_year(
|
||||
pool: &SqlitePool,
|
||||
user_id: &UserId,
|
||||
year: u16,
|
||||
) -> Result<u32, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let start = format!("{year}-01-01 00:00:00");
|
||||
let end = format!("{}-01-01 00:00:00", year + 1);
|
||||
|
||||
let count: i64 = sqlx::query(
|
||||
"SELECT COUNT(*) FROM reviews \
|
||||
WHERE user_id = ? AND watched_at >= ? AND watched_at < ? \
|
||||
AND remote_actor_url IS NULL",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(&start)
|
||||
.bind(&end)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Database error: {:?}", e);
|
||||
DomainError::InfrastructureError("Database operation failed".into())
|
||||
})?
|
||||
.try_get(0)
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
Ok(count as u32)
|
||||
}
|
||||
|
||||
// ── LocalApContentQuery impl ─────────────────────────────────────────────────
|
||||
|
||||
#[async_trait]
|
||||
impl LocalApContentQuery for SqliteApContentQuery {
|
||||
async fn get_local_reviews_for_user(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
) -> Result<Vec<DiaryEntry>, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let rows = sqlx::query_as::<_, DiaryRow>(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = ? AND r.remote_actor_url IS NULL
|
||||
ORDER BY r.created_at DESC",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
rows.into_iter().map(DiaryRow::into_domain).collect()
|
||||
}
|
||||
|
||||
async fn get_local_watchlist_for_user(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
) -> Result<Vec<WatchlistWithMovie>, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let rows: Vec<WatchlistRow> = 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<Vec<DiaryEntry>, 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<Option<Review>, DomainError> {
|
||||
let id = review_id.value().to_string();
|
||||
sqlx::query_as::<_, ReviewRow>(
|
||||
"SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url
|
||||
FROM reviews WHERE id = ?",
|
||||
)
|
||||
.bind(&id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.map(ReviewRow::into_domain)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result<Option<Movie>, DomainError> {
|
||||
let id = movie_id.value().to_string();
|
||||
sqlx::query_as::<_, MovieRow>(
|
||||
"SELECT id, external_metadata_id, title, release_year, director, poster_path
|
||||
FROM movies WHERE id = ?",
|
||||
)
|
||||
.bind(&id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.map(MovieRow::into_domain)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_movie_by_external_metadata_id(
|
||||
&self,
|
||||
external_id: &str,
|
||||
) -> Result<Option<Movie>, DomainError> {
|
||||
sqlx::query_as::<_, MovieRow>(
|
||||
"SELECT id, external_metadata_id, title, release_year, director, poster_path
|
||||
FROM movies WHERE external_metadata_id = ?",
|
||||
)
|
||||
.bind(external_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.map(MovieRow::into_domain)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn count_local_posts(&self) -> Result<u64, DomainError> {
|
||||
let count: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE remote_actor_url IS NULL")
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
Ok(count as u64)
|
||||
}
|
||||
|
||||
async fn get_local_reviews_page(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
before: Option<chrono::NaiveDateTime>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<DiaryEntry>, 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<Option<(Goal, u32)>, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let y = year as i64;
|
||||
|
||||
let row = sqlx::query(
|
||||
"SELECT id, user_id, year, target_count, goal_type, created_at \
|
||||
FROM goals WHERE user_id = ? AND year = ?",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(y)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
|
||||
let Some(r) = row else { return Ok(None) };
|
||||
|
||||
let goal = row_to_goal(&r)?;
|
||||
let count = count_reviews_in_year(&self.pool, user_id, year).await?;
|
||||
|
||||
Ok(Some((goal, count)))
|
||||
}
|
||||
|
||||
async fn list_goals_for_user(&self, user_id: &UserId) -> Result<Vec<Goal>, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, user_id, year, target_count, goal_type, created_at \
|
||||
FROM goals WHERE user_id = ? ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
rows.iter().map(row_to_goal).collect()
|
||||
}
|
||||
}
|
||||
98
crates/adapters/sqlite-federation/src/blocklist.rs
Normal file
98
crates/adapters/sqlite-federation/src/blocklist.rs
Normal file
@@ -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<Vec<BlockedDomain>> {
|
||||
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<bool> {
|
||||
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<Vec<String>> {
|
||||
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::<String, _>("remote_actor_url"))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<bool> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
406
crates/adapters/sqlite-federation/src/follow.rs
Normal file
406
crates/adapters/sqlite-federation/src/follow.rs
Normal file
@@ -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<Option<String>> {
|
||||
let uid = local_user_id.to_string();
|
||||
let row: Option<Option<String>> = 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<Vec<Follower>> {
|
||||
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<Vec<Follower>> {
|
||||
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<usize> {
|
||||
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<Vec<RemoteActor>> {
|
||||
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<Vec<String>> {
|
||||
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::<String, _>("inbox").ok())
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn count_accepted_followers(&self, local_user_id: uuid::Uuid) -> Result<usize> {
|
||||
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<Vec<RemoteActor>> {
|
||||
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<Option<String>> {
|
||||
let uid = local_user_id.to_string();
|
||||
let row: Option<Option<String>> = 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<Vec<RemoteActor>> {
|
||||
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<usize> {
|
||||
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<Vec<RemoteActor>> {
|
||||
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<Option<String>> {
|
||||
let uid = local_user_id.to_string();
|
||||
let row: Option<Option<String>> = 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<Vec<uuid::Uuid>> {
|
||||
let candidates: Vec<String> = 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()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
116
crates/adapters/sqlite-federation/src/review.rs
Normal file
116
crates/adapters/sqlite-federation/src/review.rs
Normal file
@@ -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(())
|
||||
}
|
||||
}
|
||||
94
crates/adapters/sqlite-federation/src/social.rs
Normal file
94
crates/adapters/sqlite-federation/src/social.rs
Normal file
@@ -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<Vec<String>, 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<Vec<RemoteActorInfo>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, (String, String, Option<String>)>(
|
||||
"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<usize, DomainError> {
|
||||
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<usize, DomainError> {
|
||||
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<Vec<PendingFollowerInfo>, DomainError> {
|
||||
let uid = user_id.to_string();
|
||||
let rows = sqlx::query_as::<_, (String, String, Option<String>, Option<String>)>(
|
||||
"SELECT ar.url, ar.handle, ar.display_name, ar.avatar_url
|
||||
FROM ap_followers f
|
||||
JOIN ap_remote_actors ar ON ar.url = f.remote_actor_url
|
||||
WHERE f.local_user_id = ? 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())
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use k_ap::BlocklistRepository;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
async fn test_pool() -> SqlitePool {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use k_ap::BlocklistRepository;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
async fn test_pool() -> SqlitePool {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
76
crates/adapters/sqlite-federation/src/tests/outbox_url.rs
Normal file
76
crates/adapters/sqlite-federation/src/tests/outbox_url.rs
Normal file
@@ -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);
|
||||
}
|
||||
100
crates/adapters/sqlite-federation/src/watchlist.rs
Normal file
100
crates/adapters/sqlite-federation/src/watchlist.rs
Normal file
@@ -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<Vec<RemoteWatchlistEntry>, 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::<chrono::Utc>::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::<i64, _>("release_year").unwrap_or(0) as u16,
|
||||
external_metadata_id: row.try_get("external_metadata_id").ok().flatten(),
|
||||
poster_url: row.try_get("poster_url").ok().flatten(),
|
||||
added_at,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
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<Vec<RemoteWatchlistEntry>, DomainError> {
|
||||
let actors: Vec<String> =
|
||||
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::<String, _>("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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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<Vec<DiaryEntry>, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let rows = sqlx::query_as::<_, DiaryRow>(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = ? AND r.remote_actor_url IS NULL
|
||||
ORDER BY r.created_at DESC",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
rows.into_iter().map(DiaryRow::into_domain).collect()
|
||||
}
|
||||
|
||||
async fn get_local_watchlist_for_user(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
) -> Result<Vec<WatchlistWithMovie>, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let rows: Vec<WatchlistRow> = 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<Vec<DiaryEntry>, 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<Option<Review>, DomainError> {
|
||||
let id = review_id.value().to_string();
|
||||
sqlx::query_as::<_, ReviewRow>(
|
||||
"SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url
|
||||
FROM reviews WHERE id = ?",
|
||||
)
|
||||
.bind(&id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.map(ReviewRow::into_domain)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result<Option<Movie>, DomainError> {
|
||||
let id = movie_id.value().to_string();
|
||||
sqlx::query_as::<_, MovieRow>(
|
||||
"SELECT id, external_metadata_id, title, release_year, director, poster_path
|
||||
FROM movies WHERE id = ?",
|
||||
)
|
||||
.bind(&id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.map(MovieRow::into_domain)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn count_local_posts(&self) -> Result<u64, DomainError> {
|
||||
let count: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE remote_actor_url IS NULL")
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
Ok(count as u64)
|
||||
}
|
||||
|
||||
async fn get_local_reviews_page(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
before: Option<chrono::NaiveDateTime>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<DiaryEntry>, 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<Option<(Goal, u32)>, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let y = year as i64;
|
||||
|
||||
let row = sqlx::query(
|
||||
"SELECT id, user_id, year, target_count, goal_type, created_at \
|
||||
FROM goals WHERE user_id = ? AND year = ?",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(y)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
|
||||
let Some(r) = row else { return Ok(None) };
|
||||
|
||||
let goal = crate::goals::row_to_goal(&r)?;
|
||||
let count = crate::goals::count_reviews_in_year(&self.pool, user_id, year).await?;
|
||||
|
||||
Ok(Some((goal, count)))
|
||||
}
|
||||
}
|
||||
@@ -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<dyn domain::ports::UserSettingsRepository>,
|
||||
pub federation_settings: std::sync::Arc<dyn domain::ports::UserFederationSettingsQuery>,
|
||||
pub remote_goal: std::sync::Arc<dyn domain::ports::RemoteGoalRepository>,
|
||||
pub deduplicator: std::sync::Arc<dyn domain::ports::MovieDeduplicator>,
|
||||
}
|
||||
|
||||
pub async fn wire(database_url: &str) -> anyhow::Result<SqliteWireOutput> {
|
||||
@@ -135,6 +136,9 @@ pub async fn wire(database_url: &str) -> anyhow::Result<SqliteWireOutput> {
|
||||
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 _,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -248,4 +248,17 @@ impl MovieRepository for SqliteMovieRepository {
|
||||
offset: page.offset,
|
||||
})
|
||||
}
|
||||
|
||||
async fn list_movies_with_external_id(&self) -> Result<Vec<Movie>, 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()
|
||||
}
|
||||
}
|
||||
|
||||
149
crates/adapters/sqlite/src/movie_dedup.rs
Normal file
149
crates/adapters/sqlite/src/movie_dedup.rs
Normal file
@@ -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<u64, DomainError> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,11 @@ impl MovieRepository for RepoWithExternalMovie {
|
||||
{
|
||||
panic!("unexpected")
|
||||
}
|
||||
async fn list_movies_with_external_id(
|
||||
&self,
|
||||
) -> Result<Vec<domain::models::Movie>, 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<Vec<domain::models::Movie>, 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<Vec<domain::models::Movie>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
struct MetaReturnsMovie(Movie);
|
||||
|
||||
@@ -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};
|
||||
|
||||
49
crates/application/src/jobs/movie_dedup.rs
Normal file
49
crates/application/src/jobs/movie_dedup.rs
Normal file
@@ -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<dyn MovieRepository>,
|
||||
deduplicator: Arc<dyn MovieDeduplicator>,
|
||||
object_storage: Arc<dyn ObjectStorage>,
|
||||
) -> 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(())
|
||||
}
|
||||
}
|
||||
90
crates/application/src/movies/merge_duplicates.rs
Normal file
90
crates/application/src/movies/merge_duplicates.rs
Normal file
@@ -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<dyn MovieRepository>,
|
||||
pub deduplicator: Arc<dyn MovieDeduplicator>,
|
||||
pub object_storage: Arc<dyn ObjectStorage>,
|
||||
}
|
||||
|
||||
pub struct MergeReport {
|
||||
pub pairs_found: u64,
|
||||
pub rows_repointed: u64,
|
||||
}
|
||||
|
||||
pub async fn execute(deps: &MergeDuplicatesDeps) -> Result<MergeReport, DomainError> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(())
|
||||
|
||||
19
crates/application/src/users/delete_account.rs
Normal file
19
crates/application/src/users/delete_account.rs
Normal file
@@ -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(())
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod commands;
|
||||
pub mod delete_account;
|
||||
pub mod deps;
|
||||
pub mod get_current_profile;
|
||||
pub mod get_profile;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -36,6 +36,8 @@ pub trait MovieRepository: Send + Sync {
|
||||
page: &PageParams,
|
||||
filter: &MovieFilter,
|
||||
) -> Result<Paginated<MovieSummary>, DomainError>;
|
||||
/// Returns all movies that have an external_metadata_id set. Used for deduplication.
|
||||
async fn list_movies_with_external_id(&self) -> Result<Vec<Movie>, DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -68,3 +70,15 @@ pub trait MovieEnrichmentClient: Send + Sync {
|
||||
external_metadata_id: &str,
|
||||
) -> Result<MovieProfile, DomainError>;
|
||||
}
|
||||
|
||||
#[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<u64, DomainError>;
|
||||
}
|
||||
|
||||
@@ -74,6 +74,10 @@ pub trait LocalApContentQuery: Send + Sync {
|
||||
) -> Result<Vec<WatchlistWithMovie>, DomainError>;
|
||||
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError>;
|
||||
async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result<Option<Movie>, DomainError>;
|
||||
async fn get_movie_by_external_metadata_id(
|
||||
&self,
|
||||
external_id: &str,
|
||||
) -> Result<Option<Movie>, DomainError>;
|
||||
async fn count_local_posts(&self) -> Result<u64, DomainError>;
|
||||
async fn get_local_reviews_for_movie(
|
||||
&self,
|
||||
@@ -90,4 +94,5 @@ pub trait LocalApContentQuery: Send + Sync {
|
||||
user_id: &UserId,
|
||||
year: u16,
|
||||
) -> Result<Option<(Goal, u32)>, DomainError>;
|
||||
async fn list_goals_for_user(&self, user_id: &UserId) -> Result<Vec<Goal>, DomainError>;
|
||||
}
|
||||
|
||||
@@ -138,6 +138,17 @@ impl MovieRepository for InMemoryMovieRepository {
|
||||
offset: 0,
|
||||
})
|
||||
}
|
||||
|
||||
async fn list_movies_with_external_id(&self) -> Result<Vec<Movie>, DomainError> {
|
||||
Ok(self
|
||||
.store
|
||||
.lock()
|
||||
.unwrap()
|
||||
.values()
|
||||
.filter(|m| m.external_metadata_id().is_some())
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
// ── InMemoryReviewRepository ──────────────────────────────────────────────────
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -78,6 +78,11 @@ impl MovieRepository for Panic {
|
||||
{
|
||||
panic!()
|
||||
}
|
||||
async fn list_movies_with_external_id(
|
||||
&self,
|
||||
) -> Result<Vec<domain::models::Movie>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl ReviewRepository for Panic {
|
||||
|
||||
@@ -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<dyn domain::ports::RemoteGoalRepository>,
|
||||
pub refresh_session: Arc<dyn domain::ports::RefreshSessionRepository>,
|
||||
pub federation_settings: Arc<dyn domain::ports::UserFederationSettingsQuery>,
|
||||
pub deduplicator: Arc<dyn MovieDeduplicator>,
|
||||
pub db_pool: DbPool,
|
||||
}
|
||||
|
||||
@@ -66,6 +67,7 @@ pub async fn connect(database_url: &str, backend: &str) -> anyhow::Result<Worker
|
||||
w.pool.clone(),
|
||||
)) as _,
|
||||
federation_settings: w.federation_settings,
|
||||
deduplicator: w.deduplicator,
|
||||
db_pool: DbPool::Postgres(w.pool),
|
||||
})
|
||||
}
|
||||
@@ -98,6 +100,7 @@ pub async fn connect(database_url: &str, backend: &str) -> anyhow::Result<Worker
|
||||
refresh_session: Arc::new(sqlite::SqliteRefreshSessionAdapter::new(w.pool.clone()))
|
||||
as _,
|
||||
federation_settings: w.federation_settings,
|
||||
deduplicator: w.deduplicator,
|
||||
db_pool: DbPool::Sqlite(w.pool),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ async fn main() -> 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<Arc<dyn PeriodicJob>> = 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(),
|
||||
)),
|
||||
|
||||
Reference in New Issue
Block a user