use std::sync::Arc; use activitypub_base::ApObjectHandler; use async_trait::async_trait; use domain::{ models::{Review, ReviewSource}, ports::MovieRepository, value_objects::{Comment, MovieId, Rating, ReviewId, UserId}, }; use url::Url; use crate::objects::{review_to_ap_object, ReviewObject}; use crate::remote_review_repository::RemoteReviewRepository; use crate::urls::{actor_url, review_url}; pub struct ReviewObjectHandler { pub movie_repo: Arc, pub review_store: Arc, pub base_url: String, } #[async_trait] impl ApObjectHandler for ReviewObjectHandler { async fn get_local_objects_for_user( &self, user_id: uuid::Uuid, ) -> anyhow::Result> { let domain_user_id = UserId::from_uuid(user_id); let history = self.movie_repo.get_user_history(&domain_user_id).await?; let mut results = Vec::new(); for entry in history { let review = entry.review(); if !matches!(review.source(), ReviewSource::Local) { continue; } let ap_id = review_url(&self.base_url, review.id()); let actor_url = actor_url(&self.base_url, user_id); let movie = self.movie_repo.get_movie_by_id(review.movie_id()).await.ok().flatten(); let movie_title = movie.as_ref() .map(|m| m.title().value().to_string()) .unwrap_or_else(|| "Unknown".to_string()); let release_year = movie.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!("{}/posters/{}", self.base_url, p.value())); let obj = review_to_ap_object(review, ap_id.clone(), actor_url, movie_title, release_year, poster_url); let json = serde_json::to_value(obj)?; results.push((ap_id, json)); } Ok(results) } async fn on_create( &self, _ap_id: &Url, _actor_url: &Url, object: serde_json::Value, ) -> anyhow::Result<()> { let obj: ReviewObject = match serde_json::from_value(object) { Ok(o) => o, Err(e) => { tracing::debug!("ignoring unrecognized Create object: {}", e); return Ok(()); } }; let actor_url_str = obj.attributed_to.to_string(); let review_id = ReviewId::generate(); let movie_id = MovieId::from_uuid(uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, obj.movie_title.as_bytes())); let user_id = UserId::from_uuid(uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, actor_url_str.as_bytes())); let rating = Rating::new(obj.rating.min(5))?; let comment = obj.comment.map(Comment::new).transpose()?; let review = Review::from_persistence( review_id, movie_id, user_id, rating, comment, obj.watched_at.naive_utc(), obj.published.naive_utc(), ReviewSource::Remote { actor_url: actor_url_str }, ); self.review_store .save_remote_review(&review, obj.id.as_str(), &obj.movie_title, obj.release_year, obj.poster_url.as_deref()) .await?; Ok(()) } async fn on_update( &self, ap_id: &Url, actor_url: &Url, object: serde_json::Value, ) -> anyhow::Result<()> { let obj: ReviewObject = match serde_json::from_value(object) { Ok(o) => o, Err(_) => { tracing::debug!(actor = %actor_url, "ignoring non-review Update activity"); return Ok(()); } }; if obj.attributed_to != *actor_url { anyhow::bail!("update actor does not match object attributed_to"); } self.review_store .update_remote_review( ap_id.as_str(), actor_url.as_str(), obj.rating.min(5), obj.comment.as_deref(), obj.watched_at.naive_utc(), ) .await?; Ok(()) } async fn on_delete(&self, ap_id: &Url, actor_url: &Url) -> anyhow::Result<()> { self.review_store .delete_remote_review(ap_id.as_str(), actor_url.as_str()) .await } async fn on_actor_removed(&self, actor_url: &Url) -> anyhow::Result<()> { self.review_store.delete_by_actor(actor_url.as_str()).await } }