This commit is contained in:
@@ -72,7 +72,8 @@ impl Activity for FollowActivity {
|
||||
let _follower = self.actor.dereference(data).await?;
|
||||
let local_actor = self.object.dereference(data).await?;
|
||||
|
||||
if data.federation_repo
|
||||
if data
|
||||
.federation_repo
|
||||
.is_actor_blocked(local_actor.user_id, self.actor.inner().as_str())
|
||||
.await?
|
||||
{
|
||||
@@ -246,7 +247,11 @@ impl Activity for UndoActivity {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let obj_type = self.object.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||
let obj_type = self
|
||||
.object
|
||||
.get("type")
|
||||
.and_then(|t| t.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
match obj_type {
|
||||
"Follow" => {
|
||||
@@ -266,7 +271,8 @@ impl Activity for UndoActivity {
|
||||
tracing::info!(actor = %self.actor.inner(), "unfollowed");
|
||||
}
|
||||
"Add" => {
|
||||
let ap_id_str = self.object
|
||||
let ap_id_str = self
|
||||
.object
|
||||
.get("object")
|
||||
.and_then(|o| o.get("id"))
|
||||
.and_then(|id| id.as_str())
|
||||
|
||||
@@ -222,14 +222,18 @@ impl Object for DbActor {
|
||||
});
|
||||
let profile_url = self.profile_url;
|
||||
let also_known_as: Vec<String> = self.also_known_as.into_iter().collect();
|
||||
let attachment: Vec<ProfileFieldObject> = self.attachment.into_iter().map(|f| ProfileFieldObject {
|
||||
kind: "PropertyValue".to_string(),
|
||||
name: f.name,
|
||||
value: f.value,
|
||||
}).collect();
|
||||
let attachment: Vec<ProfileFieldObject> = self
|
||||
.attachment
|
||||
.into_iter()
|
||||
.map(|f| ProfileFieldObject {
|
||||
kind: "PropertyValue".to_string(),
|
||||
name: f.name,
|
||||
value: f.value,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let shared_inbox = Url::parse(&format!("{}/inbox", data.base_url))
|
||||
.expect("base_url is always valid");
|
||||
let shared_inbox =
|
||||
Url::parse(&format!("{}/inbox", data.base_url)).expect("base_url is always valid");
|
||||
|
||||
Ok(Person {
|
||||
kind: Default::default(),
|
||||
|
||||
@@ -56,9 +56,7 @@ pub async fn nodeinfo_well_known_handler(
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn nodeinfo_handler(
|
||||
data: Data<FederationData>,
|
||||
) -> Result<Json<NodeInfo>, Error> {
|
||||
pub async fn nodeinfo_handler(data: Data<FederationData>) -> Result<Json<NodeInfo>, Error> {
|
||||
let user_count = data.user_repo.count_users().await.unwrap_or(0);
|
||||
let local_posts = data.object_handler.count_local_posts().await.unwrap_or(0);
|
||||
|
||||
|
||||
@@ -5,9 +5,7 @@ use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
|
||||
use activitypub_federation::{
|
||||
config::Data,
|
||||
fetch::object_id::ObjectId,
|
||||
kinds::activity::CreateType,
|
||||
config::Data, fetch::object_id::ObjectId, kinds::activity::CreateType,
|
||||
protocol::context::WithContext,
|
||||
};
|
||||
|
||||
@@ -83,8 +81,7 @@ pub async fn outbox_handler(
|
||||
let ordered_items: Vec<serde_json::Value> = items
|
||||
.into_iter()
|
||||
.map(|(ap_id, object, _)| {
|
||||
let create_id =
|
||||
Url::parse(&format!("{}/activity", ap_id)).expect("valid url");
|
||||
let create_id = Url::parse(&format!("{}/activity", ap_id)).expect("valid url");
|
||||
serde_json::to_value(WithContext::new_default(CreateActivity {
|
||||
id: create_id,
|
||||
kind: CreateType::default(),
|
||||
@@ -105,9 +102,7 @@ pub async fn outbox_handler(
|
||||
let next = if has_more {
|
||||
oldest_ts.map(|ts| {
|
||||
// Use RFC 3339 with Z suffix (no + sign) to avoid percent-encoding
|
||||
let ts_str = ts
|
||||
.format("%Y-%m-%dT%H:%M:%S%.3fZ")
|
||||
.to_string();
|
||||
let ts_str = ts.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
|
||||
format!("{}?page=true&before={}", outbox_url, ts_str)
|
||||
})
|
||||
} else {
|
||||
|
||||
@@ -10,18 +10,23 @@ use axum::{Router, routing::get, routing::post};
|
||||
use url::Url;
|
||||
|
||||
use crate::{
|
||||
activities::{AcceptActivity, CreateActivity, FollowActivity, RejectActivity, UndoActivity, UpdateActivity},
|
||||
activities::{
|
||||
AcceptActivity, CreateActivity, FollowActivity, RejectActivity, UndoActivity,
|
||||
UpdateActivity,
|
||||
},
|
||||
actors::{DbActor, get_local_actor},
|
||||
content::ApObjectHandler,
|
||||
data::FederationData,
|
||||
federation::ApFederationConfig,
|
||||
followers_handler::{followers_handler, following_handler},
|
||||
inbox::inbox_handler,
|
||||
nodeinfo::{nodeinfo_handler, nodeinfo_well_known_handler},
|
||||
outbox::outbox_handler,
|
||||
repository::{BlockedDomain, FederationRepository, FollowerStatus, FollowingStatus, RemoteActor},
|
||||
repository::{
|
||||
BlockedDomain, FederationRepository, FollowerStatus, FollowingStatus, RemoteActor,
|
||||
},
|
||||
urls::activity_url,
|
||||
user::ApUserRepository,
|
||||
nodeinfo::{nodeinfo_handler, nodeinfo_well_known_handler},
|
||||
webfinger::webfinger_handler,
|
||||
};
|
||||
|
||||
@@ -35,9 +40,10 @@ fn collect_inboxes(followers: &[crate::repository::Follower]) -> Vec<Url> {
|
||||
.as_deref()
|
||||
.unwrap_or(&f.actor.inbox_url);
|
||||
if seen.insert(inbox_str.to_string())
|
||||
&& let Ok(url) = Url::parse(inbox_str) {
|
||||
inboxes.push(url);
|
||||
}
|
||||
&& let Ok(url) = Url::parse(inbox_str)
|
||||
{
|
||||
inboxes.push(url);
|
||||
}
|
||||
}
|
||||
inboxes
|
||||
}
|
||||
@@ -84,8 +90,13 @@ impl ActivityPubService {
|
||||
event_publisher: Option<Arc<dyn domain::ports::EventPublisher>>,
|
||||
) -> anyhow::Result<Self> {
|
||||
let data = FederationData::new(
|
||||
repo, user_repo, object_handler, base_url.clone(),
|
||||
allow_registration, software_name, event_publisher,
|
||||
repo,
|
||||
user_repo,
|
||||
object_handler,
|
||||
base_url.clone(),
|
||||
allow_registration,
|
||||
software_name,
|
||||
event_publisher,
|
||||
);
|
||||
let federation_config = ApFederationConfig::new(data, debug).await?;
|
||||
Ok(Self {
|
||||
@@ -550,8 +561,8 @@ impl ActivityPubService {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let delete_id = crate::urls::activity_url(&self.base_url)
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
let delete_id =
|
||||
crate::urls::activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
let delete = crate::activities::DeleteActivity {
|
||||
id: delete_id,
|
||||
kind: Default::default(),
|
||||
@@ -627,8 +638,7 @@ impl ActivityPubService {
|
||||
};
|
||||
let add_with_ctx = WithContext::new_default(add);
|
||||
let inboxes = collect_inboxes(&accepted);
|
||||
let sends =
|
||||
SendActivityTask::prepare(&add_with_ctx, &local_actor, inboxes, &data).await?;
|
||||
let sends = SendActivityTask::prepare(&add_with_ctx, &local_actor, inboxes, &data).await?;
|
||||
let failures = send_with_retry(sends, &data).await;
|
||||
if !failures.is_empty() {
|
||||
tracing::warn!(count = failures.len(), "some Add deliveries failed");
|
||||
@@ -678,8 +688,8 @@ impl ActivityPubService {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let undo_id = crate::urls::activity_url(&self.base_url)
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
let undo_id =
|
||||
crate::urls::activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
let undo = crate::activities::UndoActivity {
|
||||
id: undo_id,
|
||||
kind: Default::default(),
|
||||
@@ -692,8 +702,7 @@ impl ActivityPubService {
|
||||
};
|
||||
let undo_with_ctx = WithContext::new_default(undo);
|
||||
let inboxes = collect_inboxes(&accepted);
|
||||
let sends =
|
||||
SendActivityTask::prepare(&undo_with_ctx, &local_actor, inboxes, &data).await?;
|
||||
let sends = SendActivityTask::prepare(&undo_with_ctx, &local_actor, inboxes, &data).await?;
|
||||
let failures = send_with_retry(sends, &data).await;
|
||||
if !failures.is_empty() {
|
||||
tracing::warn!(count = failures.len(), "some Undo(Add) deliveries failed");
|
||||
@@ -778,7 +787,10 @@ impl ActivityPubService {
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
|
||||
let person = local_actor.clone().into_json(&data).await
|
||||
let person = local_actor
|
||||
.clone()
|
||||
.into_json(&data)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
// Wrap with @context so Mastodon's JSON-LD processor can resolve field names.
|
||||
let person_json = serde_json::to_value(&WithContext::new_default(person))?;
|
||||
@@ -831,29 +843,43 @@ impl ActivityPubService {
|
||||
return Err(anyhow::anyhow!(
|
||||
"actor update delivery failed for {} inbox(es): {}",
|
||||
failures.len(),
|
||||
failures.iter().map(|e| e.to_string()).collect::<Vec<_>>().join("; ")
|
||||
failures
|
||||
.iter()
|
||||
.map(|e| e.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ")
|
||||
));
|
||||
}
|
||||
tracing::info!(user_id = %user_id, "actor update broadcast complete");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn block_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> anyhow::Result<()> {
|
||||
pub async fn block_actor(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
actor_url: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let data = self.federation_config.to_request_data();
|
||||
|
||||
data.federation_repo
|
||||
.add_blocked_actor(local_user_id, actor_url)
|
||||
.await?;
|
||||
let _ = data.federation_repo.remove_follower(local_user_id, actor_url).await;
|
||||
let _ = data.federation_repo.remove_following(local_user_id, actor_url).await;
|
||||
let _ = data
|
||||
.federation_repo
|
||||
.remove_follower(local_user_id, actor_url)
|
||||
.await;
|
||||
let _ = data
|
||||
.federation_repo
|
||||
.remove_following(local_user_id, actor_url)
|
||||
.await;
|
||||
|
||||
let local_actor = get_local_actor(local_user_id, &data)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
|
||||
if let Ok(Some(remote_actor)) = data.federation_repo.get_remote_actor(actor_url).await {
|
||||
let block_id = crate::urls::activity_url(&self.base_url)
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
let block_id =
|
||||
crate::urls::activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
let block = crate::activities::BlockActivity {
|
||||
id: block_id,
|
||||
kind: Default::default(),
|
||||
@@ -877,16 +903,26 @@ impl ActivityPubService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn unblock_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> anyhow::Result<()> {
|
||||
pub async fn unblock_actor(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
actor_url: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let data = self.federation_config.to_request_data();
|
||||
data.federation_repo
|
||||
.remove_blocked_actor(local_user_id, actor_url)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_blocked_actors(&self, local_user_id: uuid::Uuid) -> anyhow::Result<Vec<RemoteActor>> {
|
||||
pub async fn get_blocked_actors(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
) -> anyhow::Result<Vec<RemoteActor>> {
|
||||
let data = self.federation_config.to_request_data();
|
||||
let actor_urls = data.federation_repo.get_blocked_actors(local_user_id).await?;
|
||||
let actor_urls = data
|
||||
.federation_repo
|
||||
.get_blocked_actors(local_user_id)
|
||||
.await?;
|
||||
let mut actors = Vec::new();
|
||||
for url in actor_urls {
|
||||
let actor = match data.federation_repo.get_remote_actor(&url).await {
|
||||
@@ -906,9 +942,15 @@ impl ActivityPubService {
|
||||
Ok(actors)
|
||||
}
|
||||
|
||||
pub async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> anyhow::Result<()> {
|
||||
pub async fn add_blocked_domain(
|
||||
&self,
|
||||
domain: &str,
|
||||
reason: Option<&str>,
|
||||
) -> anyhow::Result<()> {
|
||||
let data = self.federation_config.to_request_data();
|
||||
data.federation_repo.add_blocked_domain(domain, reason).await
|
||||
data.federation_repo
|
||||
.add_blocked_domain(domain, reason)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn remove_blocked_domain(&self, domain: &str) -> anyhow::Result<()> {
|
||||
|
||||
@@ -4,7 +4,10 @@ use super::*;
|
||||
fn person_serializes_with_enriched_fields() {
|
||||
let person = Person {
|
||||
kind: Default::default(),
|
||||
id: "https://example.com/users/1".parse::<url::Url>().unwrap().into(),
|
||||
id: "https://example.com/users/1"
|
||||
.parse::<url::Url>()
|
||||
.unwrap()
|
||||
.into(),
|
||||
preferred_username: "alice".to_string(),
|
||||
inbox: "https://example.com/users/1/inbox".parse().unwrap(),
|
||||
outbox: "https://example.com/users/1/outbox".parse().unwrap(),
|
||||
@@ -39,5 +42,8 @@ fn person_serializes_with_enriched_fields() {
|
||||
assert_eq!(json["manuallyApprovesFollowers"], true);
|
||||
assert!(json.get("updated").is_some());
|
||||
assert!(json.get("endpoints").is_some());
|
||||
assert_eq!(json["endpoints"]["sharedInbox"], "https://example.com/inbox");
|
||||
assert_eq!(
|
||||
json["endpoints"]["sharedInbox"],
|
||||
"https://example.com/inbox"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,10 @@ fn nodeinfo_well_known_serializes_correctly() {
|
||||
}],
|
||||
};
|
||||
let json = serde_json::to_value(&doc).unwrap();
|
||||
assert_eq!(json["links"][0]["rel"], "http://nodeinfo.diaspora.software/ns/schema/2.0");
|
||||
assert_eq!(
|
||||
json["links"][0]["rel"],
|
||||
"http://nodeinfo.diaspora.software/ns/schema/2.0"
|
||||
);
|
||||
assert_eq!(json["links"][0]["href"], "https://example.com/nodeinfo/2.0");
|
||||
}
|
||||
|
||||
|
||||
@@ -19,8 +19,14 @@ fn make_follower(inbox: &str, shared: Option<&str>) -> Follower {
|
||||
#[test]
|
||||
fn collect_inboxes_deduplicates_shared() {
|
||||
let followers = vec![
|
||||
make_follower("https://mastodon.social/users/a/inbox", Some("https://mastodon.social/inbox")),
|
||||
make_follower("https://mastodon.social/users/b/inbox", Some("https://mastodon.social/inbox")),
|
||||
make_follower(
|
||||
"https://mastodon.social/users/a/inbox",
|
||||
Some("https://mastodon.social/inbox"),
|
||||
),
|
||||
make_follower(
|
||||
"https://mastodon.social/users/b/inbox",
|
||||
Some("https://mastodon.social/inbox"),
|
||||
),
|
||||
make_follower("https://other.instance/users/c/inbox", None),
|
||||
];
|
||||
let inboxes = collect_inboxes(&followers);
|
||||
@@ -32,9 +38,7 @@ fn collect_inboxes_deduplicates_shared() {
|
||||
|
||||
#[test]
|
||||
fn collect_inboxes_falls_back_to_individual_inbox() {
|
||||
let followers = vec![
|
||||
make_follower("https://example.com/users/x/inbox", None),
|
||||
];
|
||||
let followers = vec![make_follower("https://example.com/users/x/inbox", None)];
|
||||
let inboxes = collect_inboxes(&followers);
|
||||
assert_eq!(inboxes.len(), 1);
|
||||
assert_eq!(inboxes[0].as_str(), "https://example.com/users/x/inbox");
|
||||
|
||||
@@ -27,7 +27,9 @@ impl ApObjectHandler 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
|
||||
self.review
|
||||
.get_local_objects_page(user_id, before, limit)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn on_create(
|
||||
|
||||
@@ -40,11 +40,15 @@ impl ActivityPubEventHandler {
|
||||
impl EventHandler for ActivityPubEventHandler {
|
||||
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> {
|
||||
match event {
|
||||
DomainEvent::ReviewLogged { review_id, user_id, .. } => self
|
||||
DomainEvent::ReviewLogged {
|
||||
review_id, user_id, ..
|
||||
} => self
|
||||
.on_review_logged(user_id, review_id)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
|
||||
DomainEvent::ReviewUpdated { review_id, user_id, .. } => self
|
||||
DomainEvent::ReviewUpdated {
|
||||
review_id, user_id, ..
|
||||
} => self
|
||||
.on_review_updated(user_id, review_id)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
|
||||
@@ -65,7 +69,14 @@ impl EventHandler for ActivityPubEventHandler {
|
||||
external_metadata_id,
|
||||
added_at,
|
||||
} => self
|
||||
.on_watchlist_added(user_id, movie_id, movie_title, *release_year, external_metadata_id, added_at)
|
||||
.on_watchlist_added(
|
||||
user_id,
|
||||
movie_id,
|
||||
movie_title,
|
||||
*release_year,
|
||||
external_metadata_id,
|
||||
added_at,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
|
||||
DomainEvent::WatchlistEntryRemoved { user_id, movie_id } => self
|
||||
@@ -124,7 +135,11 @@ impl ActivityPubEventHandler {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_review_updated(&self, user_id: &UserId, review_id: &ReviewId) -> anyhow::Result<()> {
|
||||
async fn on_review_updated(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
review_id: &ReviewId,
|
||||
) -> anyhow::Result<()> {
|
||||
let review = match self.review_repository.get_review_by_id(review_id).await? {
|
||||
Some(r) => r,
|
||||
None => return Ok(()),
|
||||
@@ -170,7 +185,11 @@ impl ActivityPubEventHandler {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_review_deleted(&self, user_id: &UserId, review_id: &ReviewId) -> anyhow::Result<()> {
|
||||
async fn on_review_deleted(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
review_id: &ReviewId,
|
||||
) -> anyhow::Result<()> {
|
||||
let ap_id = review_url(&self.base_url, review_id);
|
||||
self.ap_service
|
||||
.broadcast_delete_to_followers(user_id.value(), ap_id)
|
||||
@@ -197,7 +216,10 @@ impl ActivityPubEventHandler {
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|m| m.poster_path().map(|p| format!("{}/images/{}", self.base_url, p.value())));
|
||||
.and_then(|m| {
|
||||
m.poster_path()
|
||||
.map(|p| format!("{}/images/{}", self.base_url, p.value()))
|
||||
});
|
||||
|
||||
let added_at_utc =
|
||||
chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(*added_at, chrono::Utc);
|
||||
|
||||
@@ -4,9 +4,9 @@ pub mod objects;
|
||||
pub mod port;
|
||||
pub mod remote_review_repository;
|
||||
pub mod review_handler;
|
||||
pub mod watchlist_handler;
|
||||
pub(crate) mod urls;
|
||||
pub mod user_adapter;
|
||||
pub mod watchlist_handler;
|
||||
|
||||
// Re-export the generic base types that callers need
|
||||
pub use activitypub_base::{
|
||||
@@ -21,22 +21,22 @@ pub use review_handler::ReviewObjectHandler;
|
||||
pub use user_adapter::DomainUserRepoAdapter;
|
||||
|
||||
pub struct ActivityPubWire {
|
||||
pub service: std::sync::Arc<dyn ActivityPubPort>,
|
||||
pub router: axum::Router,
|
||||
pub service: std::sync::Arc<dyn ActivityPubPort>,
|
||||
pub router: axum::Router,
|
||||
pub event_handler: std::sync::Arc<dyn domain::ports::EventHandler>,
|
||||
}
|
||||
|
||||
pub async fn wire(
|
||||
federation_repo: std::sync::Arc<dyn FederationRepository>,
|
||||
review_store: std::sync::Arc<dyn RemoteReviewRepository>,
|
||||
federation_repo: std::sync::Arc<dyn FederationRepository>,
|
||||
review_store: std::sync::Arc<dyn RemoteReviewRepository>,
|
||||
remote_watchlist_repo: std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>,
|
||||
user_repo: std::sync::Arc<dyn domain::ports::UserRepository>,
|
||||
movie_repo: std::sync::Arc<dyn domain::ports::MovieRepository>,
|
||||
review_repo: std::sync::Arc<dyn domain::ports::ReviewRepository>,
|
||||
diary_repo: std::sync::Arc<dyn domain::ports::DiaryRepository>,
|
||||
base_url: String,
|
||||
allow_registration: bool,
|
||||
event_publisher: std::sync::Arc<dyn domain::ports::EventPublisher>,
|
||||
user_repo: std::sync::Arc<dyn domain::ports::UserRepository>,
|
||||
movie_repo: std::sync::Arc<dyn domain::ports::MovieRepository>,
|
||||
review_repo: std::sync::Arc<dyn domain::ports::ReviewRepository>,
|
||||
diary_repo: std::sync::Arc<dyn domain::ports::DiaryRepository>,
|
||||
base_url: String,
|
||||
allow_registration: bool,
|
||||
event_publisher: std::sync::Arc<dyn domain::ports::EventPublisher>,
|
||||
) -> anyhow::Result<ActivityPubWire> {
|
||||
let review_handler = std::sync::Arc::new(ReviewObjectHandler {
|
||||
movie_repository: std::sync::Arc::clone(&movie_repo),
|
||||
|
||||
@@ -74,8 +74,7 @@ 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 {
|
||||
@@ -152,8 +151,7 @@ pub fn watchlist_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 {
|
||||
|
||||
@@ -101,9 +101,10 @@ impl ApObjectHandler for ReviewObjectHandler {
|
||||
chrono::DateTime::from_naive_utc_and_offset(*review.watched_at(), chrono::Utc);
|
||||
|
||||
if let Some(cutoff) = before
|
||||
&& published >= cutoff {
|
||||
continue;
|
||||
}
|
||||
&& published >= cutoff
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let ap_id = review_url(&self.base_url, review.id());
|
||||
let actor_url = actor_url(&self.base_url, user_id);
|
||||
@@ -118,7 +119,10 @@ impl ApObjectHandler for ReviewObjectHandler {
|
||||
.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 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())
|
||||
|
||||
@@ -4,7 +4,10 @@ use super::*;
|
||||
fn normalize_hashtag_strips_non_alphanumeric() {
|
||||
assert_eq!(normalize_hashtag("The Dark Knight"), "TheDarkKnight");
|
||||
assert_eq!(normalize_hashtag("Schindler's List"), "SchindlersList");
|
||||
assert_eq!(normalize_hashtag("2001: A Space Odyssey"), "2001ASpaceOdyssey");
|
||||
assert_eq!(
|
||||
normalize_hashtag("2001: A Space Odyssey"),
|
||||
"2001ASpaceOdyssey"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -15,6 +15,9 @@ pub fn review_url(base_url: &str, review_id: &ReviewId) -> Url {
|
||||
|
||||
/// Builds the canonical watchlist entry URL: `{base_url}/users/{user_id}/watchlist/{movie_id}`
|
||||
pub fn watchlist_entry_url(base_url: &str, user_id: uuid::Uuid, movie_id: uuid::Uuid) -> Url {
|
||||
Url::parse(&format!("{}/users/{}/watchlist/{}", base_url, user_id, movie_id))
|
||||
.expect("base_url is always a valid URL prefix")
|
||||
Url::parse(&format!(
|
||||
"{}/users/{}/watchlist/{}",
|
||||
base_url, user_id, movie_id
|
||||
))
|
||||
.expect("base_url is always a valid URL prefix")
|
||||
}
|
||||
|
||||
@@ -2,10 +2,7 @@ use std::sync::Arc;
|
||||
|
||||
use activitypub_base::{ApProfileField, ApUser, ApUserRepository};
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
ports::UserRepository,
|
||||
value_objects::UserId,
|
||||
};
|
||||
use domain::{ports::UserRepository, value_objects::UserId};
|
||||
use url::Url;
|
||||
|
||||
pub struct DomainUserRepoAdapter {
|
||||
@@ -14,20 +11,17 @@ pub struct DomainUserRepoAdapter {
|
||||
}
|
||||
|
||||
impl DomainUserRepoAdapter {
|
||||
pub fn new(
|
||||
repo: Arc<dyn UserRepository>,
|
||||
base_url: String,
|
||||
) -> Self {
|
||||
pub fn new(repo: Arc<dyn UserRepository>, base_url: String) -> Self {
|
||||
Self { repo, base_url }
|
||||
}
|
||||
|
||||
fn build_user(&self, u: &domain::models::User) -> ApUser {
|
||||
let avatar_url = u.avatar_path().and_then(|p| {
|
||||
Url::parse(&format!("{}/images/{}", self.base_url, p)).ok()
|
||||
});
|
||||
let banner_url = u.banner_path().and_then(|p| {
|
||||
Url::parse(&format!("{}/images/{}", self.base_url, p)).ok()
|
||||
});
|
||||
let avatar_url = u
|
||||
.avatar_path()
|
||||
.and_then(|p| Url::parse(&format!("{}/images/{}", self.base_url, p)).ok());
|
||||
let banner_url = u
|
||||
.banner_path()
|
||||
.and_then(|p| Url::parse(&format!("{}/images/{}", self.base_url, p)).ok());
|
||||
let profile_url = Url::parse(&format!("{}/u/{}", self.base_url, u.username().value())).ok();
|
||||
ApUser {
|
||||
id: u.id().value(),
|
||||
@@ -37,7 +31,14 @@ impl DomainUserRepoAdapter {
|
||||
banner_url,
|
||||
also_known_as: u.also_known_as().map(|s| s.to_string()),
|
||||
profile_url,
|
||||
attachment: u.profile_fields().iter().map(|f| ApProfileField { name: f.name.clone(), value: f.value.clone() }).collect(),
|
||||
attachment: u
|
||||
.profile_fields()
|
||||
.iter()
|
||||
.map(|f| ApProfileField {
|
||||
name: f.name.clone(),
|
||||
value: f.value.clone(),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,7 +56,8 @@ impl ApUserRepository for DomainUserRepoAdapter {
|
||||
|
||||
async fn find_by_username(&self, username: &str) -> anyhow::Result<Option<ApUser>> {
|
||||
use domain::value_objects::Username;
|
||||
let uname = Username::new(username.to_string()).map_err(|e| anyhow::anyhow!(e.to_string()))?;
|
||||
let uname =
|
||||
Username::new(username.to_string()).map_err(|e| anyhow::anyhow!(e.to_string()))?;
|
||||
let user = match self.repo.find_by_username(&uname).await? {
|
||||
Some(u) => u,
|
||||
None => return Ok(None),
|
||||
@@ -64,7 +66,10 @@ impl ApUserRepository for DomainUserRepoAdapter {
|
||||
}
|
||||
|
||||
async fn count_users(&self) -> anyhow::Result<usize> {
|
||||
Ok(self.repo.list_with_stats().await
|
||||
Ok(self
|
||||
.repo
|
||||
.list_with_stats()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!(e.to_string()))?
|
||||
.len())
|
||||
}
|
||||
|
||||
@@ -84,8 +84,7 @@ impl EventPayload {
|
||||
}
|
||||
|
||||
fn parse_uuid(s: &str, field: &str) -> Result<Uuid, DomainError> {
|
||||
Uuid::parse_str(s)
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("{field}: {e}")))
|
||||
Uuid::parse_str(s).map_err(|e| DomainError::InfrastructureError(format!("{field}: {e}")))
|
||||
}
|
||||
|
||||
fn parse_ts(ts: i64) -> Result<NaiveDateTime, DomainError> {
|
||||
@@ -97,31 +96,43 @@ fn parse_ts(ts: i64) -> Result<NaiveDateTime, DomainError> {
|
||||
impl From<&DomainEvent> for EventPayload {
|
||||
fn from(event: &DomainEvent) -> Self {
|
||||
match event {
|
||||
DomainEvent::ReviewLogged { review_id, movie_id, user_id, rating, watched_at } => {
|
||||
EventPayload::ReviewLogged {
|
||||
review_id: review_id.value().to_string(),
|
||||
movie_id: movie_id.value().to_string(),
|
||||
user_id: user_id.value().to_string(),
|
||||
rating: rating.value(),
|
||||
watched_at: watched_at.and_utc().timestamp(),
|
||||
}
|
||||
}
|
||||
DomainEvent::ReviewUpdated { review_id, movie_id, user_id, rating, watched_at } => {
|
||||
EventPayload::ReviewUpdated {
|
||||
review_id: review_id.value().to_string(),
|
||||
movie_id: movie_id.value().to_string(),
|
||||
user_id: user_id.value().to_string(),
|
||||
rating: rating.value(),
|
||||
watched_at: watched_at.and_utc().timestamp(),
|
||||
}
|
||||
}
|
||||
DomainEvent::MovieDiscovered { movie_id, external_metadata_id } => {
|
||||
EventPayload::MovieDiscovered {
|
||||
movie_id: movie_id.value().to_string(),
|
||||
external_metadata_id: external_metadata_id.value().to_owned(),
|
||||
}
|
||||
}
|
||||
DomainEvent::MovieDeleted { movie_id, poster_path } => EventPayload::MovieDeleted {
|
||||
DomainEvent::ReviewLogged {
|
||||
review_id,
|
||||
movie_id,
|
||||
user_id,
|
||||
rating,
|
||||
watched_at,
|
||||
} => EventPayload::ReviewLogged {
|
||||
review_id: review_id.value().to_string(),
|
||||
movie_id: movie_id.value().to_string(),
|
||||
user_id: user_id.value().to_string(),
|
||||
rating: rating.value(),
|
||||
watched_at: watched_at.and_utc().timestamp(),
|
||||
},
|
||||
DomainEvent::ReviewUpdated {
|
||||
review_id,
|
||||
movie_id,
|
||||
user_id,
|
||||
rating,
|
||||
watched_at,
|
||||
} => EventPayload::ReviewUpdated {
|
||||
review_id: review_id.value().to_string(),
|
||||
movie_id: movie_id.value().to_string(),
|
||||
user_id: user_id.value().to_string(),
|
||||
rating: rating.value(),
|
||||
watched_at: watched_at.and_utc().timestamp(),
|
||||
},
|
||||
DomainEvent::MovieDiscovered {
|
||||
movie_id,
|
||||
external_metadata_id,
|
||||
} => EventPayload::MovieDiscovered {
|
||||
movie_id: movie_id.value().to_string(),
|
||||
external_metadata_id: external_metadata_id.value().to_owned(),
|
||||
},
|
||||
DomainEvent::MovieDeleted {
|
||||
movie_id,
|
||||
poster_path,
|
||||
} => EventPayload::MovieDeleted {
|
||||
movie_id: movie_id.value().to_string(),
|
||||
poster_path: poster_path.as_ref().map(|p| p.value().to_string()),
|
||||
},
|
||||
@@ -132,36 +143,44 @@ impl From<&DomainEvent> for EventPayload {
|
||||
review_id: review_id.value().to_string(),
|
||||
user_id: user_id.value().to_string(),
|
||||
},
|
||||
DomainEvent::MovieEnrichmentRequested { movie_id, external_metadata_id } => {
|
||||
EventPayload::MovieEnrichmentRequested {
|
||||
movie_id: movie_id.value().to_string(),
|
||||
external_metadata_id: external_metadata_id.clone(),
|
||||
}
|
||||
}
|
||||
DomainEvent::MovieEnrichmentRequested {
|
||||
movie_id,
|
||||
external_metadata_id,
|
||||
} => EventPayload::MovieEnrichmentRequested {
|
||||
movie_id: movie_id.value().to_string(),
|
||||
external_metadata_id: external_metadata_id.clone(),
|
||||
},
|
||||
DomainEvent::ImageStored { key } => EventPayload::ImageStored { key: key.clone() },
|
||||
DomainEvent::WatchlistEntryAdded { user_id, movie_id, movie_title, release_year, external_metadata_id, added_at } => {
|
||||
EventPayload::WatchlistEntryAdded {
|
||||
user_id: user_id.value().to_string(),
|
||||
movie_id: movie_id.value().to_string(),
|
||||
movie_title: movie_title.clone(),
|
||||
release_year: *release_year,
|
||||
external_metadata_id: external_metadata_id.clone(),
|
||||
added_at: added_at.and_utc().timestamp(),
|
||||
}
|
||||
}
|
||||
DomainEvent::WatchlistEntryAdded {
|
||||
user_id,
|
||||
movie_id,
|
||||
movie_title,
|
||||
release_year,
|
||||
external_metadata_id,
|
||||
added_at,
|
||||
} => EventPayload::WatchlistEntryAdded {
|
||||
user_id: user_id.value().to_string(),
|
||||
movie_id: movie_id.value().to_string(),
|
||||
movie_title: movie_title.clone(),
|
||||
release_year: *release_year,
|
||||
external_metadata_id: external_metadata_id.clone(),
|
||||
added_at: added_at.and_utc().timestamp(),
|
||||
},
|
||||
DomainEvent::WatchlistEntryRemoved { user_id, movie_id } => {
|
||||
EventPayload::WatchlistEntryRemoved {
|
||||
user_id: user_id.value().to_string(),
|
||||
movie_id: movie_id.value().to_string(),
|
||||
}
|
||||
}
|
||||
DomainEvent::FollowAccepted { local_user_id, remote_actor_url, outbox_url } => {
|
||||
EventPayload::FollowAccepted {
|
||||
local_user_id: local_user_id.value().to_string(),
|
||||
remote_actor_url: remote_actor_url.clone(),
|
||||
outbox_url: outbox_url.clone(),
|
||||
}
|
||||
}
|
||||
DomainEvent::FollowAccepted {
|
||||
local_user_id,
|
||||
remote_actor_url,
|
||||
outbox_url,
|
||||
} => EventPayload::FollowAccepted {
|
||||
local_user_id: local_user_id.value().to_string(),
|
||||
remote_actor_url: remote_actor_url.clone(),
|
||||
outbox_url: outbox_url.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -170,81 +189,98 @@ impl TryFrom<EventPayload> for DomainEvent {
|
||||
type Error = DomainError;
|
||||
fn try_from(payload: EventPayload) -> Result<Self, DomainError> {
|
||||
match payload {
|
||||
EventPayload::ReviewLogged { review_id, movie_id, user_id, rating, watched_at } => {
|
||||
Ok(DomainEvent::ReviewLogged {
|
||||
review_id: ReviewId::from_uuid(parse_uuid(&review_id, "review_id")?),
|
||||
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
rating: Rating::new(rating)?,
|
||||
watched_at: parse_ts(watched_at)?,
|
||||
})
|
||||
}
|
||||
EventPayload::ReviewUpdated { review_id, movie_id, user_id, rating, watched_at } => {
|
||||
Ok(DomainEvent::ReviewUpdated {
|
||||
review_id: ReviewId::from_uuid(parse_uuid(&review_id, "review_id")?),
|
||||
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
rating: Rating::new(rating)?,
|
||||
watched_at: parse_ts(watched_at)?,
|
||||
})
|
||||
}
|
||||
EventPayload::MovieDiscovered { movie_id, external_metadata_id } => {
|
||||
Ok(DomainEvent::MovieDiscovered {
|
||||
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
|
||||
external_metadata_id: ExternalMetadataId::new(external_metadata_id)?,
|
||||
})
|
||||
}
|
||||
EventPayload::MovieDeleted { movie_id, poster_path } => {
|
||||
EventPayload::ReviewLogged {
|
||||
review_id,
|
||||
movie_id,
|
||||
user_id,
|
||||
rating,
|
||||
watched_at,
|
||||
} => Ok(DomainEvent::ReviewLogged {
|
||||
review_id: ReviewId::from_uuid(parse_uuid(&review_id, "review_id")?),
|
||||
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
rating: Rating::new(rating)?,
|
||||
watched_at: parse_ts(watched_at)?,
|
||||
}),
|
||||
EventPayload::ReviewUpdated {
|
||||
review_id,
|
||||
movie_id,
|
||||
user_id,
|
||||
rating,
|
||||
watched_at,
|
||||
} => Ok(DomainEvent::ReviewUpdated {
|
||||
review_id: ReviewId::from_uuid(parse_uuid(&review_id, "review_id")?),
|
||||
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
rating: Rating::new(rating)?,
|
||||
watched_at: parse_ts(watched_at)?,
|
||||
}),
|
||||
EventPayload::MovieDiscovered {
|
||||
movie_id,
|
||||
external_metadata_id,
|
||||
} => Ok(DomainEvent::MovieDiscovered {
|
||||
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
|
||||
external_metadata_id: ExternalMetadataId::new(external_metadata_id)?,
|
||||
}),
|
||||
EventPayload::MovieDeleted {
|
||||
movie_id,
|
||||
poster_path,
|
||||
} => {
|
||||
let movie_id = MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?);
|
||||
let poster_path = poster_path
|
||||
.map(PosterPath::new)
|
||||
.transpose()
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(DomainEvent::MovieDeleted { movie_id, poster_path })
|
||||
}
|
||||
EventPayload::UserUpdated { user_id } => {
|
||||
Ok(DomainEvent::UserUpdated {
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
})
|
||||
}
|
||||
EventPayload::ReviewDeleted { review_id, user_id } => {
|
||||
Ok(DomainEvent::ReviewDeleted {
|
||||
review_id: ReviewId::from_uuid(parse_uuid(&review_id, "review_id")?),
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
})
|
||||
}
|
||||
EventPayload::MovieEnrichmentRequested { movie_id, external_metadata_id } => {
|
||||
Ok(DomainEvent::MovieEnrichmentRequested {
|
||||
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
|
||||
external_metadata_id,
|
||||
})
|
||||
}
|
||||
EventPayload::ImageStored { key } => {
|
||||
Ok(DomainEvent::ImageStored { key })
|
||||
}
|
||||
EventPayload::WatchlistEntryAdded { user_id, movie_id, movie_title, release_year, external_metadata_id, added_at } => {
|
||||
Ok(DomainEvent::WatchlistEntryAdded {
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
|
||||
movie_title,
|
||||
release_year,
|
||||
external_metadata_id,
|
||||
added_at: parse_ts(added_at)?,
|
||||
Ok(DomainEvent::MovieDeleted {
|
||||
movie_id,
|
||||
poster_path,
|
||||
})
|
||||
}
|
||||
EventPayload::UserUpdated { user_id } => Ok(DomainEvent::UserUpdated {
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
}),
|
||||
EventPayload::ReviewDeleted { review_id, user_id } => Ok(DomainEvent::ReviewDeleted {
|
||||
review_id: ReviewId::from_uuid(parse_uuid(&review_id, "review_id")?),
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
}),
|
||||
EventPayload::MovieEnrichmentRequested {
|
||||
movie_id,
|
||||
external_metadata_id,
|
||||
} => Ok(DomainEvent::MovieEnrichmentRequested {
|
||||
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
|
||||
external_metadata_id,
|
||||
}),
|
||||
EventPayload::ImageStored { key } => Ok(DomainEvent::ImageStored { key }),
|
||||
EventPayload::WatchlistEntryAdded {
|
||||
user_id,
|
||||
movie_id,
|
||||
movie_title,
|
||||
release_year,
|
||||
external_metadata_id,
|
||||
added_at,
|
||||
} => Ok(DomainEvent::WatchlistEntryAdded {
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
|
||||
movie_title,
|
||||
release_year,
|
||||
external_metadata_id,
|
||||
added_at: parse_ts(added_at)?,
|
||||
}),
|
||||
EventPayload::WatchlistEntryRemoved { user_id, movie_id } => {
|
||||
Ok(DomainEvent::WatchlistEntryRemoved {
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
|
||||
})
|
||||
}
|
||||
EventPayload::FollowAccepted { local_user_id, remote_actor_url, outbox_url } => {
|
||||
Ok(DomainEvent::FollowAccepted {
|
||||
local_user_id: UserId::from_uuid(parse_uuid(&local_user_id, "local_user_id")?),
|
||||
remote_actor_url,
|
||||
outbox_url,
|
||||
})
|
||||
}
|
||||
EventPayload::FollowAccepted {
|
||||
local_user_id,
|
||||
remote_actor_url,
|
||||
outbox_url,
|
||||
} => Ok(DomainEvent::FollowAccepted {
|
||||
local_user_id: UserId::from_uuid(parse_uuid(&local_user_id, "local_user_id")?),
|
||||
remote_actor_url,
|
||||
outbox_url,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use super::*;
|
||||
|
||||
fn fixed_dt() -> NaiveDateTime {
|
||||
chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap().naive_utc()
|
||||
chrono::DateTime::from_timestamp(1_700_000_000, 0)
|
||||
.unwrap()
|
||||
.naive_utc()
|
||||
}
|
||||
|
||||
fn review_logged() -> DomainEvent {
|
||||
@@ -64,14 +66,25 @@ fn serialized_format_is_tagged() {
|
||||
|
||||
#[test]
|
||||
fn event_type_strings() {
|
||||
assert_eq!(EventPayload::from(&review_logged()).event_type(), "ReviewLogged");
|
||||
assert_eq!(EventPayload::from(&review_updated()).event_type(), "ReviewUpdated");
|
||||
assert_eq!(EventPayload::from(&movie_discovered()).event_type(), "MovieDiscovered");
|
||||
assert_eq!(
|
||||
EventPayload::from(&review_logged()).event_type(),
|
||||
"ReviewLogged"
|
||||
);
|
||||
assert_eq!(
|
||||
EventPayload::from(&review_updated()).event_type(),
|
||||
"ReviewUpdated"
|
||||
);
|
||||
assert_eq!(
|
||||
EventPayload::from(&movie_discovered()).event_type(),
|
||||
"MovieDiscovered"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_image_stored() {
|
||||
let event = DomainEvent::ImageStored { key: "avatars/abc123".into() };
|
||||
let event = DomainEvent::ImageStored {
|
||||
key: "avatars/abc123".into(),
|
||||
};
|
||||
let payload = EventPayload::from(&event);
|
||||
let json = serde_json::to_string(&payload).unwrap();
|
||||
let back: EventPayload = serde_json::from_str(&json).unwrap();
|
||||
@@ -81,6 +94,8 @@ fn round_trip_image_stored() {
|
||||
|
||||
#[test]
|
||||
fn image_stored_event_type() {
|
||||
let payload = EventPayload::from(&DomainEvent::ImageStored { key: "posters/x".into() });
|
||||
let payload = EventPayload::from(&DomainEvent::ImageStored {
|
||||
key: "posters/x".into(),
|
||||
});
|
||||
assert_eq!(payload.event_type(), "ImageStored");
|
||||
}
|
||||
|
||||
@@ -43,8 +43,12 @@ struct NoopAck;
|
||||
|
||||
#[async_trait]
|
||||
impl AckHandle for NoopAck {
|
||||
async fn ack(&self) -> Result<(), DomainError> { Ok(()) }
|
||||
async fn nack(&self) -> Result<(), DomainError> { Ok(()) }
|
||||
async fn ack(&self) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn nack(&self) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ChannelEventConsumer {
|
||||
|
||||
@@ -22,7 +22,10 @@ async fn consumer_yields_published_events() {
|
||||
|
||||
let mut stream = consumer.consume();
|
||||
let envelope = stream.next().await.unwrap().unwrap();
|
||||
assert!(matches!(envelope.event, DomainEvent::MovieDiscovered { .. }));
|
||||
assert!(matches!(
|
||||
envelope.event,
|
||||
DomainEvent::MovieDiscovered { .. }
|
||||
));
|
||||
assert!(stream.next().await.is_none());
|
||||
}
|
||||
|
||||
|
||||
@@ -61,9 +61,7 @@ async fn csv_has_header_and_one_row() {
|
||||
.unwrap();
|
||||
let text = String::from_utf8(bytes).unwrap();
|
||||
assert!(
|
||||
text.starts_with(
|
||||
"title,year,director,rating,comment,watched_at,external_metadata_id\n"
|
||||
)
|
||||
text.starts_with("title,year,director,rating,comment,watched_at,external_metadata_id\n")
|
||||
);
|
||||
assert!(text.contains("Inception"));
|
||||
assert!(text.contains("2010"));
|
||||
|
||||
@@ -17,7 +17,10 @@ impl ConversionBackfillJob {
|
||||
image_ref: Arc<dyn ImageRefQuery>,
|
||||
event_publisher: Arc<dyn EventPublisher>,
|
||||
) -> Self {
|
||||
Self { image_ref, event_publisher }
|
||||
Self {
|
||||
image_ref,
|
||||
event_publisher,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +37,8 @@ impl PeriodicJob for ConversionBackfillJob {
|
||||
if key.ends_with(".avif") || key.ends_with(".webp") {
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = self.event_publisher
|
||||
if let Err(e) = self
|
||||
.event_publisher
|
||||
.publish(&DomainEvent::ImageStored { key: key.clone() })
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -21,7 +21,11 @@ impl ImageConversionHandler {
|
||||
image_ref: Arc<dyn ImageRefCommand>,
|
||||
format: Format,
|
||||
) -> Self {
|
||||
Self { storage, image_ref, format }
|
||||
Self {
|
||||
storage,
|
||||
image_ref,
|
||||
format,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +77,12 @@ fn convert(bytes: Vec<u8>, format: Format) -> Result<Vec<u8>, String> {
|
||||
let height = rgba.height() as usize;
|
||||
let pixels: Vec<ravif::RGBA8> = rgba
|
||||
.pixels()
|
||||
.map(|p| ravif::RGBA8 { r: p.0[0], g: p.0[1], b: p.0[2], a: p.0[3] })
|
||||
.map(|p| ravif::RGBA8 {
|
||||
r: p.0[0],
|
||||
g: p.0[1],
|
||||
b: p.0[2],
|
||||
a: p.0[3],
|
||||
})
|
||||
.collect();
|
||||
let result = ravif::Encoder::new()
|
||||
.with_quality(80.0)
|
||||
|
||||
@@ -6,8 +6,10 @@ pub use backfill::ConversionBackfillJob;
|
||||
pub use config::{ConversionConfig, Format};
|
||||
pub use handler::ImageConversionHandler;
|
||||
|
||||
use domain::ports::{
|
||||
EventHandler, EventPublisher, ImageRefCommand, ImageRefQuery, ImageStorage, PeriodicJob,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use domain::ports::{EventHandler, EventPublisher, ImageRefCommand, ImageRefQuery, ImageStorage, PeriodicJob};
|
||||
|
||||
pub fn build(
|
||||
image_storage: Arc<dyn ImageStorage>,
|
||||
|
||||
@@ -18,7 +18,9 @@ struct MockPublisher {
|
||||
|
||||
impl MockPublisher {
|
||||
fn new() -> Arc<Self> {
|
||||
Arc::new(Self { emitted: Mutex::new(vec![]) })
|
||||
Arc::new(Self {
|
||||
emitted: Mutex::new(vec![]),
|
||||
})
|
||||
}
|
||||
|
||||
fn emitted(&self) -> Vec<String> {
|
||||
@@ -42,10 +44,8 @@ async fn emits_image_stored_for_unconverted_keys() {
|
||||
keys: vec!["avatars/u1".into(), "posters/m1".into()],
|
||||
});
|
||||
let publisher = MockPublisher::new();
|
||||
let job = ConversionBackfillJob::new(
|
||||
image_ref,
|
||||
Arc::clone(&publisher) as Arc<dyn EventPublisher>,
|
||||
);
|
||||
let job =
|
||||
ConversionBackfillJob::new(image_ref, Arc::clone(&publisher) as Arc<dyn EventPublisher>);
|
||||
|
||||
job.run().await.unwrap();
|
||||
|
||||
@@ -64,10 +64,8 @@ async fn skips_already_converted_keys() {
|
||||
],
|
||||
});
|
||||
let publisher = MockPublisher::new();
|
||||
let job = ConversionBackfillJob::new(
|
||||
image_ref,
|
||||
Arc::clone(&publisher) as Arc<dyn EventPublisher>,
|
||||
);
|
||||
let job =
|
||||
ConversionBackfillJob::new(image_ref, Arc::clone(&publisher) as Arc<dyn EventPublisher>);
|
||||
|
||||
job.run().await.unwrap();
|
||||
|
||||
@@ -78,10 +76,8 @@ async fn skips_already_converted_keys() {
|
||||
async fn empty_keys_emits_nothing() {
|
||||
let image_ref = Arc::new(MockImageRef { keys: vec![] });
|
||||
let publisher = MockPublisher::new();
|
||||
let job = ConversionBackfillJob::new(
|
||||
image_ref,
|
||||
Arc::clone(&publisher) as Arc<dyn EventPublisher>,
|
||||
);
|
||||
let job =
|
||||
ConversionBackfillJob::new(image_ref, Arc::clone(&publisher) as Arc<dyn EventPublisher>);
|
||||
|
||||
job.run().await.unwrap();
|
||||
|
||||
|
||||
@@ -3,18 +3,24 @@ use super::*;
|
||||
#[test]
|
||||
fn disabled_by_default() {
|
||||
assert!(ConversionConfig::from_vars(None, None).unwrap().is_none());
|
||||
assert!(ConversionConfig::from_vars(Some("false"), None).unwrap().is_none());
|
||||
assert!(ConversionConfig::from_vars(Some("false"), None)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_avif() {
|
||||
let cfg = ConversionConfig::from_vars(Some("true"), Some("avif")).unwrap().unwrap();
|
||||
let cfg = ConversionConfig::from_vars(Some("true"), Some("avif"))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(cfg.format, Format::Avif);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_webp() {
|
||||
let cfg = ConversionConfig::from_vars(Some("true"), Some("webp")).unwrap().unwrap();
|
||||
let cfg = ConversionConfig::from_vars(Some("true"), Some("webp"))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(cfg.format, Format::Webp);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
use object_store::memory::InMemory;
|
||||
use image_storage::ImageStorageAdapter;
|
||||
use object_store::memory::InMemory;
|
||||
use std::sync::Mutex;
|
||||
|
||||
struct MockImageRef {
|
||||
swaps: Mutex<Vec<(String, String)>>,
|
||||
@@ -9,7 +9,9 @@ struct MockImageRef {
|
||||
|
||||
impl MockImageRef {
|
||||
fn new() -> Arc<Self> {
|
||||
Arc::new(Self { swaps: Mutex::new(vec![]) })
|
||||
Arc::new(Self {
|
||||
swaps: Mutex::new(vec![]),
|
||||
})
|
||||
}
|
||||
|
||||
fn swaps(&self) -> Vec<(String, String)> {
|
||||
@@ -31,9 +33,7 @@ fn in_memory_storage() -> Arc<ImageStorageAdapter> {
|
||||
|
||||
fn tiny_jpeg() -> Vec<u8> {
|
||||
use image::{DynamicImage, ImageBuffer, Rgb};
|
||||
let img = DynamicImage::ImageRgb8(
|
||||
ImageBuffer::from_pixel(4, 4, Rgb([200u8, 100, 50])),
|
||||
);
|
||||
let img = DynamicImage::ImageRgb8(ImageBuffer::from_pixel(4, 4, Rgb([200u8, 100, 50])));
|
||||
let mut buf = std::io::Cursor::new(Vec::new());
|
||||
img.write_to(&mut buf, image::ImageFormat::Jpeg).unwrap();
|
||||
buf.into_inner()
|
||||
@@ -49,9 +49,12 @@ async fn ignores_non_image_stored_events() {
|
||||
Format::Avif,
|
||||
);
|
||||
|
||||
handler.handle(&DomainEvent::UserUpdated {
|
||||
user_id: domain::value_objects::UserId::from_uuid(uuid::Uuid::new_v4()),
|
||||
}).await.unwrap();
|
||||
handler
|
||||
.handle(&DomainEvent::UserUpdated {
|
||||
user_id: domain::value_objects::UserId::from_uuid(uuid::Uuid::new_v4()),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(image_ref.swaps().is_empty());
|
||||
}
|
||||
@@ -59,7 +62,10 @@ async fn ignores_non_image_stored_events() {
|
||||
#[tokio::test]
|
||||
async fn skips_already_converted_avif_key() {
|
||||
let storage = in_memory_storage();
|
||||
storage.store("avatars/u1.avif", &tiny_jpeg()).await.unwrap();
|
||||
storage
|
||||
.store("avatars/u1.avif", &tiny_jpeg())
|
||||
.await
|
||||
.unwrap();
|
||||
let image_ref = MockImageRef::new();
|
||||
let handler = ImageConversionHandler::new(
|
||||
Arc::clone(&storage) as Arc<dyn ImageStorage>,
|
||||
@@ -67,7 +73,12 @@ async fn skips_already_converted_avif_key() {
|
||||
Format::Avif,
|
||||
);
|
||||
|
||||
handler.handle(&DomainEvent::ImageStored { key: "avatars/u1.avif".into() }).await.unwrap();
|
||||
handler
|
||||
.handle(&DomainEvent::ImageStored {
|
||||
key: "avatars/u1.avif".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(image_ref.swaps().is_empty());
|
||||
}
|
||||
@@ -75,7 +86,10 @@ async fn skips_already_converted_avif_key() {
|
||||
#[tokio::test]
|
||||
async fn skips_already_converted_webp_key() {
|
||||
let storage = in_memory_storage();
|
||||
storage.store("posters/m1.webp", &tiny_jpeg()).await.unwrap();
|
||||
storage
|
||||
.store("posters/m1.webp", &tiny_jpeg())
|
||||
.await
|
||||
.unwrap();
|
||||
let image_ref = MockImageRef::new();
|
||||
let handler = ImageConversionHandler::new(
|
||||
Arc::clone(&storage) as Arc<dyn ImageStorage>,
|
||||
@@ -83,7 +97,12 @@ async fn skips_already_converted_webp_key() {
|
||||
Format::Webp,
|
||||
);
|
||||
|
||||
handler.handle(&DomainEvent::ImageStored { key: "posters/m1.webp".into() }).await.unwrap();
|
||||
handler
|
||||
.handle(&DomainEvent::ImageStored {
|
||||
key: "posters/m1.webp".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(image_ref.swaps().is_empty());
|
||||
}
|
||||
@@ -99,9 +118,17 @@ async fn converts_jpeg_to_avif_and_swaps_key() {
|
||||
Format::Avif,
|
||||
);
|
||||
|
||||
handler.handle(&DomainEvent::ImageStored { key: "avatars/u1".into() }).await.unwrap();
|
||||
handler
|
||||
.handle(&DomainEvent::ImageStored {
|
||||
key: "avatars/u1".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(image_ref.swaps(), vec![("avatars/u1".into(), "avatars/u1.avif".into())]);
|
||||
assert_eq!(
|
||||
image_ref.swaps(),
|
||||
vec![("avatars/u1".into(), "avatars/u1.avif".into())]
|
||||
);
|
||||
assert!(storage.get("avatars/u1.avif").await.is_ok());
|
||||
assert!(storage.get("avatars/u1").await.is_err());
|
||||
}
|
||||
@@ -117,9 +144,17 @@ async fn converts_jpeg_to_webp_and_swaps_key() {
|
||||
Format::Webp,
|
||||
);
|
||||
|
||||
handler.handle(&DomainEvent::ImageStored { key: "avatars/u1".into() }).await.unwrap();
|
||||
handler
|
||||
.handle(&DomainEvent::ImageStored {
|
||||
key: "avatars/u1".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(image_ref.swaps(), vec![("avatars/u1".into(), "avatars/u1.webp".into())]);
|
||||
assert_eq!(
|
||||
image_ref.swaps(),
|
||||
vec![("avatars/u1".into(), "avatars/u1.webp".into())]
|
||||
);
|
||||
assert!(storage.get("avatars/u1.webp").await.is_ok());
|
||||
assert!(storage.get("avatars/u1").await.is_err());
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ use domain::{
|
||||
use object_store::{ObjectStore, path::Path};
|
||||
use std::sync::Arc;
|
||||
|
||||
|
||||
pub struct ImageStorageAdapter {
|
||||
store: Arc<dyn ObjectStore>,
|
||||
}
|
||||
@@ -76,7 +75,9 @@ impl EventHandler for ImageCleanupHandler {
|
||||
DomainEvent::MovieDeleted { poster_path, .. } => poster_path,
|
||||
_ => return Ok(()),
|
||||
};
|
||||
let Some(path) = poster_path else { return Ok(()) };
|
||||
let Some(path) = poster_path else {
|
||||
return Ok(());
|
||||
};
|
||||
if let Err(e) = self.image_storage.delete(path.value()).await {
|
||||
tracing::warn!("image cleanup failed for {}: {e}", path.value());
|
||||
}
|
||||
@@ -85,7 +86,9 @@ impl EventHandler for ImageCleanupHandler {
|
||||
}
|
||||
|
||||
pub fn create() -> anyhow::Result<Arc<dyn ImageStorage>> {
|
||||
Ok(Arc::new(ImageStorageAdapter::from_config(StorageConfig::from_env()?)))
|
||||
Ok(Arc::new(ImageStorageAdapter::from_config(
|
||||
StorageConfig::from_env()?,
|
||||
)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -39,7 +39,10 @@ async fn delete_missing_returns_ok() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn cleanup_handler_deletes_on_movie_deleted() {
|
||||
use domain::{events::DomainEvent, value_objects::{MovieId, PosterPath}};
|
||||
use domain::{
|
||||
events::DomainEvent,
|
||||
value_objects::{MovieId, PosterPath},
|
||||
};
|
||||
let inner = Arc::new(adapter());
|
||||
inner.store("some-uuid", b"img").await.unwrap();
|
||||
let path = PosterPath::new("some-uuid".to_string()).unwrap();
|
||||
@@ -51,5 +54,8 @@ async fn cleanup_handler_deletes_on_movie_deleted() {
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(inner.get("some-uuid").await, Err(DomainError::NotFound(_))));
|
||||
assert!(matches!(
|
||||
inner.get("some-uuid").await,
|
||||
Err(DomainError::NotFound(_))
|
||||
));
|
||||
}
|
||||
|
||||
@@ -15,9 +15,13 @@ impl DocumentParser for ImporterDocumentParser {
|
||||
FileFormat::Json => parsers::parse_json(bytes),
|
||||
FileFormat::Xlsx => {
|
||||
#[cfg(feature = "xlsx")]
|
||||
{ parsers::parse_xlsx(bytes) }
|
||||
{
|
||||
parsers::parse_xlsx(bytes)
|
||||
}
|
||||
#[cfg(not(feature = "xlsx"))]
|
||||
{ Err(ImportError::Xlsx("XLSX support not compiled in".into())) }
|
||||
{
|
||||
Err(ImportError::Xlsx("XLSX support not compiled in".into()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,16 @@ use domain::models::{
|
||||
};
|
||||
|
||||
pub fn apply_mapping(file: &ParsedFile, mappings: &[FieldMapping]) -> Vec<AnnotatedRow> {
|
||||
file.rows.iter().map(|row| {
|
||||
let result = map_row(row, &file.columns, mappings);
|
||||
AnnotatedRow { result, is_duplicate: false }
|
||||
}).collect()
|
||||
file.rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
let result = map_row(row, &file.columns, mappings);
|
||||
AnnotatedRow {
|
||||
result,
|
||||
is_duplicate: false,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn map_row(row: &[String], columns: &[String], mappings: &[FieldMapping]) -> RowResult {
|
||||
@@ -39,7 +45,8 @@ fn map_row(row: &[String], columns: &[String], mappings: &[FieldMapping]) -> Row
|
||||
if errors.is_empty() {
|
||||
RowResult::Valid(import_row)
|
||||
} else {
|
||||
let raw = columns.iter()
|
||||
let raw = columns
|
||||
.iter()
|
||||
.zip(row.iter())
|
||||
.map(|(c, v)| (c.clone(), v.clone()))
|
||||
.collect();
|
||||
@@ -51,15 +58,13 @@ fn apply_transform(value: &str, transform: &Transform, errors: &mut Vec<String>)
|
||||
match transform {
|
||||
Transform::Identity => Some(value.to_string()),
|
||||
Transform::DateFormat(_) => Some(value.to_string()),
|
||||
Transform::RatingScale(factor) => {
|
||||
match value.parse::<f64>() {
|
||||
Ok(n) => Some((n * factor).round().to_string()),
|
||||
Err(_) => {
|
||||
errors.push(format!("rating '{}' is not a number", value));
|
||||
None
|
||||
}
|
||||
Transform::RatingScale(factor) => match value.parse::<f64>() {
|
||||
Ok(n) => Some((n * factor).round().to_string()),
|
||||
Err(_) => {
|
||||
errors.push(format!("rating '{}' is not a number", value));
|
||||
None
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,13 +24,12 @@ pub fn parse_csv(bytes: &[u8]) -> Result<ParsedFile, ImportError> {
|
||||
let rows: Vec<Vec<String>> = rdr
|
||||
.records()
|
||||
.map(|r| {
|
||||
r.map_err(|e| ImportError::Csv(e.to_string()))
|
||||
.map(|rec| {
|
||||
let mut cells: Vec<String> = rec.iter().map(|f| f.trim().to_string()).collect();
|
||||
cells.resize(columns.len(), String::new());
|
||||
cells.truncate(columns.len());
|
||||
cells
|
||||
})
|
||||
r.map_err(|e| ImportError::Csv(e.to_string())).map(|rec| {
|
||||
let mut cells: Vec<String> = rec.iter().map(|f| f.trim().to_string()).collect();
|
||||
cells.resize(columns.len(), String::new());
|
||||
cells.truncate(columns.len());
|
||||
cells
|
||||
})
|
||||
})
|
||||
.collect::<Result<_, _>>()?;
|
||||
|
||||
|
||||
@@ -2,17 +2,19 @@ use domain::models::{ImportError, ParsedFile};
|
||||
use serde_json::Value;
|
||||
|
||||
pub fn parse_json(bytes: &[u8]) -> Result<ParsedFile, ImportError> {
|
||||
let value: Value = serde_json::from_slice(bytes)
|
||||
.map_err(|e| ImportError::Json(e.to_string()))?;
|
||||
let value: Value =
|
||||
serde_json::from_slice(bytes).map_err(|e| ImportError::Json(e.to_string()))?;
|
||||
|
||||
let arr = value.as_array()
|
||||
let arr = value
|
||||
.as_array()
|
||||
.ok_or_else(|| ImportError::Json("expected a JSON array".into()))?;
|
||||
|
||||
if arr.is_empty() {
|
||||
return Err(ImportError::Empty);
|
||||
}
|
||||
|
||||
let first = arr[0].as_object()
|
||||
let first = arr[0]
|
||||
.as_object()
|
||||
.ok_or_else(|| ImportError::Json("array elements must be objects".into()))?;
|
||||
let columns: Vec<String> = first.keys().cloned().collect();
|
||||
|
||||
@@ -20,12 +22,15 @@ pub fn parse_json(bytes: &[u8]) -> Result<ParsedFile, ImportError> {
|
||||
return Err(ImportError::NoHeader);
|
||||
}
|
||||
|
||||
let rows: Vec<Vec<String>> = arr.iter()
|
||||
let rows: Vec<Vec<String>> = arr
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, item)| {
|
||||
let obj = item.as_object()
|
||||
.ok_or_else(|| ImportError::Json(format!("element at index {} is not an object", idx)))?;
|
||||
Ok(columns.iter()
|
||||
let obj = item.as_object().ok_or_else(|| {
|
||||
ImportError::Json(format!("element at index {} is not an object", idx))
|
||||
})?;
|
||||
Ok(columns
|
||||
.iter()
|
||||
.map(|col| obj.get(col).map(value_to_string).unwrap_or_default())
|
||||
.collect())
|
||||
})
|
||||
|
||||
@@ -1,24 +1,27 @@
|
||||
use calamine::{Reader, open_workbook_from_rs, Xlsx, Data};
|
||||
use std::io::Cursor;
|
||||
use calamine::{Data, Reader, Xlsx, open_workbook_from_rs};
|
||||
use domain::models::{ImportError, ParsedFile};
|
||||
use std::io::Cursor;
|
||||
|
||||
pub fn parse_xlsx(bytes: &[u8]) -> Result<ParsedFile, ImportError> {
|
||||
let cursor = Cursor::new(bytes);
|
||||
let mut workbook: Xlsx<_> = open_workbook_from_rs(cursor)
|
||||
.map_err(|e: calamine::XlsxError| ImportError::Xlsx(e.to_string()))?;
|
||||
|
||||
let sheet_name = workbook.sheet_names()
|
||||
let sheet_name = workbook
|
||||
.sheet_names()
|
||||
.first()
|
||||
.cloned()
|
||||
.ok_or(ImportError::Empty)?;
|
||||
|
||||
let range = workbook.worksheet_range(&sheet_name)
|
||||
let range = workbook
|
||||
.worksheet_range(&sheet_name)
|
||||
.map_err(|e| ImportError::Xlsx(e.to_string()))?;
|
||||
|
||||
let mut iter = range.rows();
|
||||
|
||||
let header = iter.next().ok_or(ImportError::NoHeader)?;
|
||||
let columns: Vec<String> = header.iter()
|
||||
let columns: Vec<String> = header
|
||||
.iter()
|
||||
.map(|c| cell_to_string(c).trim().to_string())
|
||||
.collect();
|
||||
|
||||
@@ -46,7 +49,11 @@ fn cell_to_string(cell: &Data) -> String {
|
||||
match cell {
|
||||
Data::String(s) => s.clone(),
|
||||
Data::Float(f) => {
|
||||
if f.fract() == 0.0 { format!("{}", *f as i64) } else { format!("{}", f) }
|
||||
if f.fract() == 0.0 {
|
||||
format!("{}", *f as i64)
|
||||
} else {
|
||||
format!("{}", f)
|
||||
}
|
||||
}
|
||||
Data::Int(i) => i.to_string(),
|
||||
Data::Bool(b) => b.to_string(),
|
||||
|
||||
@@ -14,9 +14,21 @@ fn sample_file() -> ParsedFile {
|
||||
|
||||
fn full_mappings() -> Vec<FieldMapping> {
|
||||
vec![
|
||||
FieldMapping { source_column: "Name".into(), domain_field: DomainField::Title, transform: Transform::Identity },
|
||||
FieldMapping { source_column: "Stars".into(), domain_field: DomainField::Rating, transform: Transform::RatingScale(0.5) },
|
||||
FieldMapping { source_column: "Date".into(), domain_field: DomainField::WatchedAt, transform: Transform::Identity },
|
||||
FieldMapping {
|
||||
source_column: "Name".into(),
|
||||
domain_field: DomainField::Title,
|
||||
transform: Transform::Identity,
|
||||
},
|
||||
FieldMapping {
|
||||
source_column: "Stars".into(),
|
||||
domain_field: DomainField::Rating,
|
||||
transform: Transform::RatingScale(0.5),
|
||||
},
|
||||
FieldMapping {
|
||||
source_column: "Date".into(),
|
||||
domain_field: DomainField::WatchedAt,
|
||||
transform: Transform::Identity,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -51,9 +63,11 @@ fn marks_missing_required_fields_invalid() {
|
||||
|
||||
#[test]
|
||||
fn ignores_unmapped_columns() {
|
||||
let mappings = vec![
|
||||
FieldMapping { source_column: "Name".into(), domain_field: DomainField::Title, transform: Transform::Identity },
|
||||
];
|
||||
let mappings = vec![FieldMapping {
|
||||
source_column: "Name".into(),
|
||||
domain_field: DomainField::Title,
|
||||
transform: Transform::Identity,
|
||||
}];
|
||||
let file = ParsedFile {
|
||||
columns: vec!["Name".into(), "Extra".into()],
|
||||
rows: vec![vec!["Inception".into(), "ignored".into()]],
|
||||
@@ -66,9 +80,11 @@ fn ignores_unmapped_columns() {
|
||||
|
||||
#[test]
|
||||
fn nonexistent_source_column_skipped() {
|
||||
let mappings = vec![
|
||||
FieldMapping { source_column: "DoesNotExist".into(), domain_field: DomainField::Title, transform: Transform::Identity },
|
||||
];
|
||||
let mappings = vec![FieldMapping {
|
||||
source_column: "DoesNotExist".into(),
|
||||
domain_field: DomainField::Title,
|
||||
transform: Transform::Identity,
|
||||
}];
|
||||
let file = ParsedFile {
|
||||
columns: vec!["Name".into()],
|
||||
rows: vec![vec!["Inception".into()]],
|
||||
@@ -81,8 +97,16 @@ fn nonexistent_source_column_skipped() {
|
||||
#[test]
|
||||
fn collects_all_errors_not_just_first() {
|
||||
let mappings = vec![
|
||||
FieldMapping { source_column: "Name".into(), domain_field: DomainField::Title, transform: Transform::Identity },
|
||||
FieldMapping { source_column: "Stars".into(), domain_field: DomainField::Rating, transform: Transform::RatingScale(0.5) },
|
||||
FieldMapping {
|
||||
source_column: "Name".into(),
|
||||
domain_field: DomainField::Title,
|
||||
transform: Transform::Identity,
|
||||
},
|
||||
FieldMapping {
|
||||
source_column: "Stars".into(),
|
||||
domain_field: DomainField::Rating,
|
||||
transform: Transform::RatingScale(0.5),
|
||||
},
|
||||
// no watched_at mapping
|
||||
];
|
||||
let file = ParsedFile {
|
||||
@@ -91,8 +115,16 @@ fn collects_all_errors_not_just_first() {
|
||||
};
|
||||
let results = apply_mapping(&file, &mappings);
|
||||
if let RowResult::Invalid { errors, .. } = &results[0].result {
|
||||
assert!(errors.iter().any(|e| e.contains("not a number")), "expected rating error, got: {:?}", errors);
|
||||
assert!(errors.iter().any(|e| e.contains("watched_at")), "expected watched_at error, got: {:?}", errors);
|
||||
assert!(
|
||||
errors.iter().any(|e| e.contains("not a number")),
|
||||
"expected rating error, got: {:?}",
|
||||
errors
|
||||
);
|
||||
assert!(
|
||||
errors.iter().any(|e| e.contains("watched_at")),
|
||||
"expected watched_at error, got: {:?}",
|
||||
errors
|
||||
);
|
||||
} else {
|
||||
panic!("expected Invalid");
|
||||
}
|
||||
@@ -101,9 +133,21 @@ fn collects_all_errors_not_just_first() {
|
||||
#[test]
|
||||
fn non_numeric_rating_produces_error_in_row() {
|
||||
let mappings = vec![
|
||||
FieldMapping { source_column: "Name".into(), domain_field: DomainField::Title, transform: Transform::Identity },
|
||||
FieldMapping { source_column: "Stars".into(), domain_field: DomainField::Rating, transform: Transform::RatingScale(0.5) },
|
||||
FieldMapping { source_column: "Date".into(), domain_field: DomainField::WatchedAt, transform: Transform::Identity },
|
||||
FieldMapping {
|
||||
source_column: "Name".into(),
|
||||
domain_field: DomainField::Title,
|
||||
transform: Transform::Identity,
|
||||
},
|
||||
FieldMapping {
|
||||
source_column: "Stars".into(),
|
||||
domain_field: DomainField::Rating,
|
||||
transform: Transform::RatingScale(0.5),
|
||||
},
|
||||
FieldMapping {
|
||||
source_column: "Date".into(),
|
||||
domain_field: DomainField::WatchedAt,
|
||||
transform: Transform::Identity,
|
||||
},
|
||||
];
|
||||
let file = ParsedFile {
|
||||
columns: vec!["Name".into(), "Stars".into(), "Date".into()],
|
||||
|
||||
@@ -74,9 +74,7 @@ impl TmdbProvider {
|
||||
}
|
||||
|
||||
let url = self.base(&format!("/movie/{}", tmdb_id));
|
||||
let d: Details = self
|
||||
.get(&url, &[("append_to_response", "credits")])
|
||||
.await?;
|
||||
let d: Details = self.get(&url, &[("append_to_response", "credits")]).await?;
|
||||
|
||||
let year: u16 = d
|
||||
.release_date
|
||||
@@ -98,8 +96,8 @@ impl TmdbProvider {
|
||||
|
||||
let imdb_id = ExternalMetadataId::new(raw_id)
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
let title =
|
||||
MovieTitle::new(d.title).map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
let title = MovieTitle::new(d.title)
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
let release_year =
|
||||
ReleaseYear::new(year).map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
@@ -110,10 +108,7 @@ impl TmdbProvider {
|
||||
.find(|c| c.job == "Director")
|
||||
.map(|c| c.name);
|
||||
|
||||
let poster_url = d
|
||||
.poster_path
|
||||
.as_deref()
|
||||
.and_then(|p| self.poster_url(p));
|
||||
let poster_url = d.poster_path.as_deref().and_then(|p| self.poster_url(p));
|
||||
|
||||
Ok(ProviderMovie {
|
||||
imdb_id,
|
||||
@@ -139,12 +134,13 @@ impl MetadataProvider for TmdbProvider {
|
||||
movie_results: Vec<FindResult>,
|
||||
}
|
||||
let url = self.base(&format!("/find/{}", id.value()));
|
||||
let resp: FindResponse =
|
||||
self.get(&url, &[("external_source", "imdb_id")]).await?;
|
||||
let resp: FindResponse = self.get(&url, &[("external_source", "imdb_id")]).await?;
|
||||
resp.movie_results
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| DomainError::NotFound(format!("TMDB: no movie for {}", id.value())))?
|
||||
.ok_or_else(|| {
|
||||
DomainError::NotFound(format!("TMDB: no movie for {}", id.value()))
|
||||
})?
|
||||
.id
|
||||
}
|
||||
MetadataSearchCriteria::Title { title, year } => {
|
||||
|
||||
@@ -34,16 +34,22 @@ impl NatsConfig {
|
||||
let url = url.ok_or_else(|| anyhow::anyhow!("NATS_URL is not set"))?;
|
||||
|
||||
let mode = match mode.unwrap_or("jetstream") {
|
||||
"core" => NatsMode::Core,
|
||||
"core" => NatsMode::Core,
|
||||
"jetstream" => NatsMode::JetStream,
|
||||
other => anyhow::bail!("unknown NATS_MODE: {other}"),
|
||||
other => anyhow::bail!("unknown NATS_MODE: {other}"),
|
||||
};
|
||||
|
||||
let subject_prefix = subject_prefix.unwrap_or("movies-diary.events").to_string();
|
||||
let stream_name = stream_name.unwrap_or("MOVIES_DIARY_EVENTS").to_string();
|
||||
let consumer_name = consumer_name.unwrap_or("worker").to_string();
|
||||
|
||||
Ok(Self { url: url.to_string(), mode, subject_prefix, stream_name, consumer_name })
|
||||
Ok(Self {
|
||||
url: url.to_string(),
|
||||
mode,
|
||||
subject_prefix,
|
||||
stream_name,
|
||||
consumer_name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use async_nats::{jetstream, Client};
|
||||
use async_nats::{Client, jetstream};
|
||||
use async_trait::async_trait;
|
||||
use domain::{errors::DomainError, events::DomainEvent, ports::EventPublisher};
|
||||
|
||||
@@ -16,11 +16,17 @@ pub struct NatsEventPublisher {
|
||||
|
||||
impl NatsEventPublisher {
|
||||
pub fn new_core(client: Client, subject_prefix: String) -> Self {
|
||||
Self { mode: PublisherMode::Core(client), subject_prefix }
|
||||
Self {
|
||||
mode: PublisherMode::Core(client),
|
||||
subject_prefix,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_jetstream(client: Client, subject_prefix: String) -> Self {
|
||||
Self { mode: PublisherMode::JetStream(jetstream::new(client)), subject_prefix }
|
||||
Self {
|
||||
mode: PublisherMode::JetStream(jetstream::new(client)),
|
||||
subject_prefix,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,15 +2,15 @@ use domain::events::DomainEvent;
|
||||
|
||||
pub fn event_to_subject(prefix: &str, event: &DomainEvent) -> String {
|
||||
let suffix = match event {
|
||||
DomainEvent::ReviewLogged { .. } => "review.logged",
|
||||
DomainEvent::ReviewUpdated { .. } => "review.updated",
|
||||
DomainEvent::ReviewDeleted { .. } => "review.deleted",
|
||||
DomainEvent::ReviewLogged { .. } => "review.logged",
|
||||
DomainEvent::ReviewUpdated { .. } => "review.updated",
|
||||
DomainEvent::ReviewDeleted { .. } => "review.deleted",
|
||||
DomainEvent::MovieDiscovered { .. } => "movie.discovered",
|
||||
DomainEvent::MovieDeleted { .. } => "movie.deleted",
|
||||
DomainEvent::UserUpdated { .. } => "user.updated",
|
||||
DomainEvent::MovieEnrichmentRequested { .. } => "movie.enrichment.requested",
|
||||
DomainEvent::ImageStored { .. } => "image.stored",
|
||||
DomainEvent::WatchlistEntryAdded { .. } => "watchlist.entry.added",
|
||||
DomainEvent::MovieDeleted { .. } => "movie.deleted",
|
||||
DomainEvent::UserUpdated { .. } => "user.updated",
|
||||
DomainEvent::MovieEnrichmentRequested { .. } => "movie.enrichment.requested",
|
||||
DomainEvent::ImageStored { .. } => "image.stored",
|
||||
DomainEvent::WatchlistEntryAdded { .. } => "watchlist.entry.added",
|
||||
DomainEvent::WatchlistEntryRemoved { .. } => "watchlist.entry.removed",
|
||||
DomainEvent::FollowAccepted { .. } => "follow.accepted",
|
||||
};
|
||||
|
||||
@@ -17,11 +17,14 @@ fn defaults_with_only_url() {
|
||||
|
||||
#[test]
|
||||
fn core_mode_parsed() {
|
||||
let cfg = NatsConfig::from_vars(Some("nats://test:4222"), Some("core"), None, None, None).unwrap();
|
||||
let cfg =
|
||||
NatsConfig::from_vars(Some("nats://test:4222"), Some("core"), None, None, None).unwrap();
|
||||
assert_eq!(cfg.mode, NatsMode::Core);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_mode_errors() {
|
||||
assert!(NatsConfig::from_vars(Some("nats://test:4222"), Some("kafka"), None, None, None).is_err());
|
||||
assert!(
|
||||
NatsConfig::from_vars(Some("nats://test:4222"), Some("kafka"), None, None, None).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ use domain::value_objects::{ExternalMetadataId, MovieId, Rating, ReviewId, UserI
|
||||
use uuid::Uuid;
|
||||
|
||||
fn dt() -> NaiveDateTime {
|
||||
chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap().naive_utc()
|
||||
chrono::DateTime::from_timestamp(1_700_000_000, 0)
|
||||
.unwrap()
|
||||
.naive_utc()
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -38,5 +38,7 @@ impl PosterFetcherClient for ReqwestPosterFetcher {
|
||||
}
|
||||
|
||||
pub fn create() -> anyhow::Result<std::sync::Arc<dyn domain::ports::PosterFetcherClient>> {
|
||||
Ok(std::sync::Arc::new(ReqwestPosterFetcher::new(PosterFetcherConfig::from_env())?))
|
||||
Ok(std::sync::Arc::new(ReqwestPosterFetcher::new(
|
||||
PosterFetcherConfig::from_env(),
|
||||
)?))
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@ use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
ports::{EventHandler, EventPublisher, ImageStorage, MetadataClient, MovieRepository, PosterFetcherClient},
|
||||
ports::{
|
||||
EventHandler, EventPublisher, ImageStorage, MetadataClient, MovieRepository,
|
||||
PosterFetcherClient,
|
||||
},
|
||||
value_objects::{ExternalMetadataId, MovieId, PosterPath},
|
||||
};
|
||||
|
||||
@@ -26,10 +29,21 @@ impl PosterSyncHandler {
|
||||
event_publisher: Arc<dyn EventPublisher>,
|
||||
max_retries: u32,
|
||||
) -> Self {
|
||||
Self { movie_repository, metadata_client, poster_fetcher, image_storage, event_publisher, max_retries }
|
||||
Self {
|
||||
movie_repository,
|
||||
metadata_client,
|
||||
poster_fetcher,
|
||||
image_storage,
|
||||
event_publisher,
|
||||
max_retries,
|
||||
}
|
||||
}
|
||||
|
||||
async fn sync(&self, movie_id: MovieId, external_metadata_id: ExternalMetadataId) -> Result<(), DomainError> {
|
||||
async fn sync(
|
||||
&self,
|
||||
movie_id: MovieId,
|
||||
external_metadata_id: ExternalMetadataId,
|
||||
) -> Result<(), DomainError> {
|
||||
let mut movie = match self.movie_repository.get_movie_by_id(&movie_id).await? {
|
||||
Some(m) => m,
|
||||
None => {
|
||||
@@ -38,7 +52,11 @@ impl PosterSyncHandler {
|
||||
}
|
||||
};
|
||||
|
||||
let poster_url = match self.metadata_client.get_poster_url(&external_metadata_id).await {
|
||||
let poster_url = match self
|
||||
.metadata_client
|
||||
.get_poster_url(&external_metadata_id)
|
||||
.await
|
||||
{
|
||||
Ok(Some(url)) => url,
|
||||
Ok(None) => return Ok(()),
|
||||
Err(e) => {
|
||||
@@ -48,9 +66,15 @@ impl PosterSyncHandler {
|
||||
};
|
||||
|
||||
let image_bytes = self.poster_fetcher.fetch_poster_bytes(&poster_url).await?;
|
||||
let stored_path = self.image_storage.store(&movie_id.value().to_string(), &image_bytes).await?;
|
||||
if let Err(e) = self.event_publisher
|
||||
.publish(&DomainEvent::ImageStored { key: stored_path.clone() })
|
||||
let stored_path = self
|
||||
.image_storage
|
||||
.store(&movie_id.value().to_string(), &image_bytes)
|
||||
.await?;
|
||||
if let Err(e) = self
|
||||
.event_publisher
|
||||
.publish(&DomainEvent::ImageStored {
|
||||
key: stored_path.clone(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
tracing::warn!("failed to emit ImageStored for {stored_path}: {e}");
|
||||
@@ -66,10 +90,14 @@ impl PosterSyncHandler {
|
||||
impl EventHandler for PosterSyncHandler {
|
||||
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> {
|
||||
let (movie_id, external_metadata_id) = match event {
|
||||
DomainEvent::MovieDiscovered { movie_id, external_metadata_id } => {
|
||||
(movie_id.value(), external_metadata_id.value().to_owned())
|
||||
}
|
||||
DomainEvent::MovieEnrichmentRequested { movie_id, external_metadata_id } => {
|
||||
DomainEvent::MovieDiscovered {
|
||||
movie_id,
|
||||
external_metadata_id,
|
||||
} => (movie_id.value(), external_metadata_id.value().to_owned()),
|
||||
DomainEvent::MovieEnrichmentRequested {
|
||||
movie_id,
|
||||
external_metadata_id,
|
||||
} => {
|
||||
// Only sync poster if the movie doesn't have one yet
|
||||
let already_has_poster = self
|
||||
.movie_repository
|
||||
@@ -90,7 +118,10 @@ impl EventHandler for PosterSyncHandler {
|
||||
|
||||
let mut last_err: Option<DomainError> = None;
|
||||
for attempt in 0..=self.max_retries {
|
||||
match self.sync(movie_id.clone(), external_metadata_id.clone()).await {
|
||||
match self
|
||||
.sync(movie_id.clone(), external_metadata_id.clone())
|
||||
.await
|
||||
{
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e) => {
|
||||
if attempt < self.max_retries {
|
||||
@@ -109,7 +140,10 @@ impl EventHandler for PosterSyncHandler {
|
||||
}
|
||||
|
||||
let err = last_err.expect("loop runs at least once");
|
||||
tracing::error!(attempts = self.max_retries + 1, "poster sync failed after all attempts: {err}");
|
||||
tracing::error!(
|
||||
attempts = self.max_retries + 1,
|
||||
"poster sync failed after all attempts: {err}"
|
||||
);
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,33 +18,42 @@ use payload::DbEventPayload;
|
||||
|
||||
pub struct DbEventQueueConfig {
|
||||
pub poll_interval_ms: u64,
|
||||
pub batch_size: i64,
|
||||
pub max_attempts: i32,
|
||||
pub batch_size: i64,
|
||||
pub max_attempts: i32,
|
||||
}
|
||||
|
||||
impl DbEventQueueConfig {
|
||||
pub fn from_env() -> Self {
|
||||
Self {
|
||||
poll_interval_ms: std::env::var("EVENT_QUEUE_POLL_INTERVAL_MS")
|
||||
.ok().and_then(|v| v.parse().ok()).unwrap_or(500),
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(500),
|
||||
batch_size: std::env::var("EVENT_QUEUE_BATCH_SIZE")
|
||||
.ok().and_then(|v| v.parse().ok()).unwrap_or(10),
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(10),
|
||||
max_attempts: std::env::var("EVENT_QUEUE_MAX_ATTEMPTS")
|
||||
.ok().and_then(|v| v.parse().ok()).unwrap_or(5),
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(5),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PostgresEventQueue {
|
||||
pool: PgPool,
|
||||
pool: PgPool,
|
||||
config: Arc<DbEventQueueConfig>,
|
||||
}
|
||||
|
||||
impl PostgresEventQueue {
|
||||
pub async fn create(pool: PgPool, config: DbEventQueueConfig) -> anyhow::Result<Self> {
|
||||
migrations::run(&pool).await?;
|
||||
Ok(Self { pool, config: Arc::new(config) })
|
||||
Ok(Self {
|
||||
pool,
|
||||
config: Arc::new(config),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn create_publisher(pool: PgPool) -> anyhow::Result<Arc<dyn EventPublisher>> {
|
||||
@@ -68,14 +77,12 @@ impl EventPublisher for PostgresEventQueue {
|
||||
let payload_json = serde_json::to_string(&db_payload)
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("serialize: {e}")))?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO event_queue (event_type, payload) VALUES ($1, $2)"
|
||||
)
|
||||
.bind(event_type)
|
||||
.bind(payload_json)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("insert event: {e}")))?;
|
||||
sqlx::query("INSERT INTO event_queue (event_type, payload) VALUES ($1, $2)")
|
||||
.bind(event_type)
|
||||
.bind(payload_json)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("insert event: {e}")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -83,10 +90,10 @@ impl EventPublisher for PostgresEventQueue {
|
||||
|
||||
impl EventConsumer for PostgresEventQueue {
|
||||
fn consume(&self) -> BoxStream<'_, Result<EventEnvelope, DomainError>> {
|
||||
let pool = self.pool.clone();
|
||||
let config = Arc::clone(&self.config);
|
||||
let (tx, rx) = mpsc::channel(128);
|
||||
let rx = Arc::new(Mutex::new(rx));
|
||||
let pool = self.pool.clone();
|
||||
let config = Arc::clone(&self.config);
|
||||
let (tx, rx) = mpsc::channel(128);
|
||||
let rx = Arc::new(Mutex::new(rx));
|
||||
|
||||
tokio::spawn(async move {
|
||||
let poll_interval = Duration::from_millis(config.poll_interval_ms);
|
||||
@@ -124,13 +131,13 @@ impl EventConsumer for PostgresEventQueue {
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct QueueRow {
|
||||
id: i64,
|
||||
payload: String,
|
||||
id: i64,
|
||||
payload: String,
|
||||
attempts: i32,
|
||||
}
|
||||
|
||||
async fn claim_batch(
|
||||
pool: &PgPool,
|
||||
pool: &PgPool,
|
||||
config: &DbEventQueueConfig,
|
||||
) -> Result<Vec<QueueRow>, DomainError> {
|
||||
// CTE with FOR UPDATE SKIP LOCKED — atomic and safe for multiple workers
|
||||
@@ -148,7 +155,7 @@ async fn claim_batch(
|
||||
FROM claimed
|
||||
WHERE q.id = claimed.id
|
||||
RETURNING q.id, q.payload, q.attempts
|
||||
"#
|
||||
"#,
|
||||
)
|
||||
.bind(config.batch_size)
|
||||
.fetch_all(pool)
|
||||
@@ -159,25 +166,28 @@ async fn claim_batch(
|
||||
}
|
||||
|
||||
fn decode_row(
|
||||
pool: &PgPool,
|
||||
row: QueueRow,
|
||||
pool: &PgPool,
|
||||
row: QueueRow,
|
||||
max_attempts: i32,
|
||||
) -> Result<EventEnvelope, DomainError> {
|
||||
let db_payload: DbEventPayload = serde_json::from_str(&row.payload)
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("deserialize: {e}")))?;
|
||||
let event = DomainEvent::try_from(db_payload)?;
|
||||
Ok(EventEnvelope::new(event, Box::new(DbAckHandle {
|
||||
pool: pool.clone(),
|
||||
row_id: row.id,
|
||||
attempts: row.attempts,
|
||||
max_attempts,
|
||||
})))
|
||||
Ok(EventEnvelope::new(
|
||||
event,
|
||||
Box::new(DbAckHandle {
|
||||
pool: pool.clone(),
|
||||
row_id: row.id,
|
||||
attempts: row.attempts,
|
||||
max_attempts,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
struct DbAckHandle {
|
||||
pool: PgPool,
|
||||
row_id: i64,
|
||||
attempts: i32,
|
||||
pool: PgPool,
|
||||
row_id: i64,
|
||||
attempts: i32,
|
||||
max_attempts: i32,
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ use activitypub::RemoteReviewRepository;
|
||||
use activitypub_base::{
|
||||
BlockedDomain, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
|
||||
};
|
||||
use domain::models::{Review, ReviewSource, RemoteWatchlistEntry};
|
||||
use domain::models::{RemoteWatchlistEntry, Review, ReviewSource};
|
||||
use domain::ports::RemoteWatchlistRepository;
|
||||
|
||||
fn datetime_to_str(dt: &NaiveDateTime) -> String {
|
||||
@@ -112,19 +112,31 @@ impl FederationRepository for PostgresFederationRepository {
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(|row| {
|
||||
let url: String = row.get("remote_actor_url");
|
||||
let status_str: String = row.get("status");
|
||||
let handle: String = row.try_get("handle").unwrap_or_default();
|
||||
let inbox_url: String = row.try_get("inbox_url").unwrap_or_default();
|
||||
let shared_inbox_url: Option<String> = row.try_get("shared_inbox_url").ok().flatten();
|
||||
let display_name: Option<String> = row.try_get("display_name").ok().flatten();
|
||||
let avatar_url: Option<String> = row.try_get("avatar_url").ok().flatten();
|
||||
Follower {
|
||||
actor: RemoteActor { url, handle, inbox_url, shared_inbox_url, display_name, avatar_url, outbox_url: row.try_get("outbox_url").ok().flatten() },
|
||||
status: str_to_status(&status_str),
|
||||
}
|
||||
}).collect())
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
let url: String = row.get("remote_actor_url");
|
||||
let status_str: String = row.get("status");
|
||||
let handle: String = row.try_get("handle").unwrap_or_default();
|
||||
let inbox_url: String = row.try_get("inbox_url").unwrap_or_default();
|
||||
let shared_inbox_url: Option<String> =
|
||||
row.try_get("shared_inbox_url").ok().flatten();
|
||||
let display_name: Option<String> = row.try_get("display_name").ok().flatten();
|
||||
let avatar_url: Option<String> = row.try_get("avatar_url").ok().flatten();
|
||||
Follower {
|
||||
actor: RemoteActor {
|
||||
url,
|
||||
handle,
|
||||
inbox_url,
|
||||
shared_inbox_url,
|
||||
display_name,
|
||||
avatar_url,
|
||||
outbox_url: row.try_get("outbox_url").ok().flatten(),
|
||||
},
|
||||
status: str_to_status(&status_str),
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_followers_page(
|
||||
@@ -152,22 +164,31 @@ impl FederationRepository for PostgresFederationRepository {
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows.into_iter().map(|row| {
|
||||
let url: String = row.get("remote_actor_url");
|
||||
let status_str: String = row.get("status");
|
||||
let handle: String = row.try_get("handle").unwrap_or_default();
|
||||
let inbox_url: String = row.try_get("inbox_url").unwrap_or_default();
|
||||
let shared_inbox_url: Option<String> = row.try_get("shared_inbox_url").ok().flatten();
|
||||
let display_name: Option<String> = row.try_get("display_name").ok().flatten();
|
||||
let avatar_url: Option<String> = row.try_get("avatar_url").ok().flatten();
|
||||
Follower {
|
||||
actor: RemoteActor {
|
||||
url, handle, inbox_url, shared_inbox_url, display_name, avatar_url,
|
||||
outbox_url: row.try_get("outbox_url").ok().flatten(),
|
||||
},
|
||||
status: str_to_status(&status_str),
|
||||
}
|
||||
}).collect())
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
let url: String = row.get("remote_actor_url");
|
||||
let status_str: String = row.get("status");
|
||||
let handle: String = row.try_get("handle").unwrap_or_default();
|
||||
let inbox_url: String = row.try_get("inbox_url").unwrap_or_default();
|
||||
let shared_inbox_url: Option<String> =
|
||||
row.try_get("shared_inbox_url").ok().flatten();
|
||||
let display_name: Option<String> = row.try_get("display_name").ok().flatten();
|
||||
let avatar_url: Option<String> = row.try_get("avatar_url").ok().flatten();
|
||||
Follower {
|
||||
actor: RemoteActor {
|
||||
url,
|
||||
handle,
|
||||
inbox_url,
|
||||
shared_inbox_url,
|
||||
display_name,
|
||||
avatar_url,
|
||||
outbox_url: row.try_get("outbox_url").ok().flatten(),
|
||||
},
|
||||
status: str_to_status(&status_str),
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn count_followers(&self, local_user_id: uuid::Uuid) -> Result<usize> {
|
||||
@@ -264,15 +285,18 @@ impl FederationRepository for PostgresFederationRepository {
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(|row| RemoteActor {
|
||||
url: row.get("url"),
|
||||
handle: row.get("handle"),
|
||||
inbox_url: row.get("inbox_url"),
|
||||
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
|
||||
display_name: row.try_get("display_name").ok().flatten(),
|
||||
avatar_url: row.try_get("avatar_url").ok().flatten(),
|
||||
outbox_url: row.try_get("outbox_url").ok().flatten(),
|
||||
}).collect())
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|row| RemoteActor {
|
||||
url: row.get("url"),
|
||||
handle: row.get("handle"),
|
||||
inbox_url: row.get("inbox_url"),
|
||||
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
|
||||
display_name: row.try_get("display_name").ok().flatten(),
|
||||
avatar_url: row.try_get("avatar_url").ok().flatten(),
|
||||
outbox_url: row.try_get("outbox_url").ok().flatten(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn count_following(&self, local_user_id: uuid::Uuid) -> Result<usize> {
|
||||
@@ -310,15 +334,18 @@ impl FederationRepository for PostgresFederationRepository {
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows.into_iter().map(|row| RemoteActor {
|
||||
url: row.get("url"),
|
||||
handle: row.get("handle"),
|
||||
inbox_url: row.get("inbox_url"),
|
||||
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
|
||||
display_name: row.try_get("display_name").ok().flatten(),
|
||||
avatar_url: row.try_get("avatar_url").ok().flatten(),
|
||||
outbox_url: row.try_get("outbox_url").ok().flatten(),
|
||||
}).collect())
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|row| RemoteActor {
|
||||
url: row.get("url"),
|
||||
handle: row.get("handle"),
|
||||
inbox_url: row.get("inbox_url"),
|
||||
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
|
||||
display_name: row.try_get("display_name").ok().flatten(),
|
||||
avatar_url: row.try_get("avatar_url").ok().flatten(),
|
||||
outbox_url: row.try_get("outbox_url").ok().flatten(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()> {
|
||||
@@ -368,12 +395,16 @@ impl FederationRepository for PostgresFederationRepository {
|
||||
}))
|
||||
}
|
||||
|
||||
async fn get_local_actor_keypair(&self, user_id: uuid::Uuid) -> Result<Option<(String, String)>> {
|
||||
async fn get_local_actor_keypair(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
) -> Result<Option<(String, String)>> {
|
||||
let uid = user_id.to_string();
|
||||
let row = sqlx::query("SELECT public_key, private_key FROM ap_local_actors WHERE user_id = $1")
|
||||
.bind(&uid)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
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"))))
|
||||
}
|
||||
|
||||
@@ -413,15 +444,18 @@ impl FederationRepository for PostgresFederationRepository {
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(|row| RemoteActor {
|
||||
url: row.get("remote_actor_url"),
|
||||
handle: row.try_get("handle").unwrap_or_default(),
|
||||
inbox_url: row.try_get("inbox_url").unwrap_or_default(),
|
||||
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
|
||||
display_name: row.try_get("display_name").ok().flatten(),
|
||||
avatar_url: row.try_get("avatar_url").ok().flatten(),
|
||||
outbox_url: row.try_get("outbox_url").ok().flatten(),
|
||||
}).collect())
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|row| RemoteActor {
|
||||
url: row.get("remote_actor_url"),
|
||||
handle: row.try_get("handle").unwrap_or_default(),
|
||||
inbox_url: row.try_get("inbox_url").unwrap_or_default(),
|
||||
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
|
||||
display_name: row.try_get("display_name").ok().flatten(),
|
||||
avatar_url: row.try_get("avatar_url").ok().flatten(),
|
||||
outbox_url: row.try_get("outbox_url").ok().flatten(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn update_following_status(
|
||||
@@ -536,12 +570,11 @@ impl FederationRepository for PostgresFederationRepository {
|
||||
}
|
||||
|
||||
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?;
|
||||
let count: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM blocked_domains WHERE domain = $1")
|
||||
.bind(domain)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
@@ -581,7 +614,10 @@ impl FederationRepository for PostgresFederationRepository {
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.iter().map(|r| r.get::<String, _>("remote_actor_url")).collect())
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|r| r.get::<String, _>("remote_actor_url"))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<bool> {
|
||||
@@ -609,7 +645,9 @@ impl RemoteReviewRepository for PostgresFederationRepository {
|
||||
) -> 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")),
|
||||
ReviewSource::Local => {
|
||||
return Err(anyhow!("save_remote_review called with a local review"));
|
||||
}
|
||||
};
|
||||
let movie_id = review.movie_id().value().to_string();
|
||||
sqlx::query(
|
||||
@@ -719,7 +757,16 @@ impl domain::ports::SocialQueryPort for PostgresFederationRepository {
|
||||
.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::ports::RemoteActorInfo { url, handle, display_name }).collect())
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(
|
||||
|(url, handle, display_name)| domain::ports::RemoteActorInfo {
|
||||
url,
|
||||
handle,
|
||||
display_name,
|
||||
},
|
||||
)
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -747,19 +794,24 @@ impl RemoteWatchlistRepository for PostgresFederationRepository {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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()))?;
|
||||
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> {
|
||||
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",
|
||||
@@ -769,21 +821,27 @@ impl RemoteWatchlistRepository for PostgresFederationRepository {
|
||||
.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()),
|
||||
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()
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn remove_all_by_actor(&self, actor_url: &str) -> Result<(), domain::errors::DomainError> {
|
||||
async fn remove_all_by_actor(
|
||||
&self,
|
||||
actor_url: &str,
|
||||
) -> Result<(), domain::errors::DomainError> {
|
||||
sqlx::query("DELETE FROM ap_remote_watchlist_entries WHERE actor_url = $1")
|
||||
.bind(actor_url)
|
||||
.execute(&self.pool)
|
||||
@@ -792,18 +850,22 @@ impl RemoteWatchlistRepository for PostgresFederationRepository {
|
||||
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();
|
||||
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
|
||||
});
|
||||
let target = actors
|
||||
.into_iter()
|
||||
.find(|url| uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, url.as_bytes()) == uuid);
|
||||
|
||||
match target {
|
||||
None => Ok(vec![]),
|
||||
@@ -812,7 +874,9 @@ impl RemoteWatchlistRepository for PostgresFederationRepository {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn wire(pool: sqlx::PgPool) -> (
|
||||
pub fn wire(
|
||||
pool: sqlx::PgPool,
|
||||
) -> (
|
||||
std::sync::Arc<dyn activitypub::FederationRepository>,
|
||||
std::sync::Arc<dyn domain::ports::SocialQueryPort>,
|
||||
std::sync::Arc<dyn activitypub::RemoteReviewRepository>,
|
||||
|
||||
@@ -3,14 +3,13 @@ use std::sync::Arc;
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{
|
||||
EntityType, IndexableDocument, MovieSearchHit, PersonSearchHit,
|
||||
SearchQuery, SearchResults,
|
||||
collections::Paginated,
|
||||
},
|
||||
models::PersonId,
|
||||
value_objects::MovieId,
|
||||
models::{
|
||||
collections::Paginated, EntityType, IndexableDocument, MovieSearchHit, PersonSearchHit,
|
||||
SearchQuery, SearchResults,
|
||||
},
|
||||
ports::{SearchCommand, SearchPort},
|
||||
value_objects::MovieId,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
|
||||
@@ -26,7 +25,10 @@ impl PostgresSearchAdapter {
|
||||
|
||||
pub fn create_search_adapter(pool: PgPool) -> (Arc<dyn SearchCommand>, Arc<dyn SearchPort>) {
|
||||
let adapter = Arc::new(PostgresSearchAdapter::new(pool));
|
||||
(Arc::clone(&adapter) as Arc<dyn SearchCommand>, adapter as Arc<dyn SearchPort>)
|
||||
(
|
||||
Arc::clone(&adapter) as Arc<dyn SearchCommand>,
|
||||
adapter as Arc<dyn SearchPort>,
|
||||
)
|
||||
}
|
||||
|
||||
fn map_err(e: sqlx::Error) -> DomainError {
|
||||
@@ -41,17 +43,39 @@ impl SearchCommand for PostgresSearchAdapter {
|
||||
let movie_id = id.value().to_string();
|
||||
let title = movie.title().value().to_string();
|
||||
let director = movie.director().unwrap_or("").to_string();
|
||||
let (overview, genres, keywords, cast_names, crew_names) =
|
||||
match profile.as_deref() {
|
||||
Some(p) => (
|
||||
p.overview.clone().unwrap_or_default(),
|
||||
p.genres.iter().map(|g| g.name.as_str()).collect::<Vec<_>>().join(" "),
|
||||
p.keywords.iter().map(|k| k.name.as_str()).collect::<Vec<_>>().join(" "),
|
||||
p.cast.iter().map(|c| c.name.as_str()).collect::<Vec<_>>().join(" "),
|
||||
p.crew.iter().map(|c| c.name.as_str()).collect::<Vec<_>>().join(" "),
|
||||
),
|
||||
None => (String::new(), String::new(), String::new(), String::new(), String::new()),
|
||||
};
|
||||
let (overview, genres, keywords, cast_names, crew_names) = match profile.as_deref()
|
||||
{
|
||||
Some(p) => (
|
||||
p.overview.clone().unwrap_or_default(),
|
||||
p.genres
|
||||
.iter()
|
||||
.map(|g| g.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
p.keywords
|
||||
.iter()
|
||||
.map(|k| k.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
p.cast
|
||||
.iter()
|
||||
.map(|c| c.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
p.crew
|
||||
.iter()
|
||||
.map(|c| c.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
),
|
||||
None => (
|
||||
String::new(),
|
||||
String::new(),
|
||||
String::new(),
|
||||
String::new(),
|
||||
String::new(),
|
||||
),
|
||||
};
|
||||
|
||||
let fts_input = format!(
|
||||
"{} {} {} {} {} {} {}",
|
||||
@@ -127,7 +151,10 @@ impl SearchPort for PostgresSearchAdapter {
|
||||
}
|
||||
|
||||
impl PostgresSearchAdapter {
|
||||
async fn search_movies(&self, query: &SearchQuery) -> Result<Paginated<MovieSearchHit>, DomainError> {
|
||||
async fn search_movies(
|
||||
&self,
|
||||
query: &SearchQuery,
|
||||
) -> Result<Paginated<MovieSearchHit>, DomainError> {
|
||||
let limit = query.page.limit as i64;
|
||||
let offset = query.page.offset as i64;
|
||||
|
||||
@@ -214,24 +241,36 @@ impl PostgresSearchAdapter {
|
||||
.map_err(map_err)?
|
||||
};
|
||||
|
||||
let items = rows.into_iter().map(|r| MovieSearchHit {
|
||||
movie_id: MovieId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()),
|
||||
title: r.title,
|
||||
release_year: r.release_year.map(|y| y as u16),
|
||||
director: r.director,
|
||||
poster_path: r.poster_path,
|
||||
genres: r.genres
|
||||
.unwrap_or_default()
|
||||
.split(',')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
}).collect::<Vec<_>>();
|
||||
let items = rows
|
||||
.into_iter()
|
||||
.map(|r| MovieSearchHit {
|
||||
movie_id: MovieId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()),
|
||||
title: r.title,
|
||||
release_year: r.release_year.map(|y| y as u16),
|
||||
director: r.director,
|
||||
poster_path: r.poster_path,
|
||||
genres: r
|
||||
.genres
|
||||
.unwrap_or_default()
|
||||
.split(',')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(Paginated { items, total_count: total, limit: query.page.limit, offset: query.page.offset })
|
||||
Ok(Paginated {
|
||||
items,
|
||||
total_count: total,
|
||||
limit: query.page.limit,
|
||||
offset: query.page.offset,
|
||||
})
|
||||
}
|
||||
|
||||
async fn search_people(&self, query: &SearchQuery) -> Result<Paginated<PersonSearchHit>, DomainError> {
|
||||
async fn search_people(
|
||||
&self,
|
||||
query: &SearchQuery,
|
||||
) -> Result<Paginated<PersonSearchHit>, DomainError> {
|
||||
let Some(text) = &query.text else {
|
||||
return Ok(Paginated {
|
||||
items: vec![],
|
||||
@@ -299,7 +338,7 @@ impl PostgresSearchAdapter {
|
||||
|
||||
items.push(PersonSearchHit {
|
||||
person_id: PersonId::from_uuid(
|
||||
uuid::Uuid::parse_str(&row.person_id).unwrap_or_default()
|
||||
uuid::Uuid::parse_str(&row.person_id).unwrap_or_default(),
|
||||
),
|
||||
name: row.name,
|
||||
known_for_department: row.known_for_department,
|
||||
@@ -308,6 +347,11 @@ impl PostgresSearchAdapter {
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Paginated { items, total_count: total, limit: query.page.limit, offset: query.page.offset })
|
||||
Ok(Paginated {
|
||||
items,
|
||||
total_count: total,
|
||||
limit: query.page.limit,
|
||||
offset: query.page.offset,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{errors::DomainError, ports::{ImageRefCommand, ImageRefQuery}};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
ports::{ImageRefCommand, ImageRefQuery},
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -15,23 +18,34 @@ impl PostgresImageRefAdapter {
|
||||
|
||||
pub fn create_image_ref(pool: PgPool) -> (Arc<dyn ImageRefCommand>, Arc<dyn ImageRefQuery>) {
|
||||
let adapter = Arc::new(PostgresImageRefAdapter::new(pool));
|
||||
(Arc::clone(&adapter) as Arc<dyn ImageRefCommand>, adapter as Arc<dyn ImageRefQuery>)
|
||||
(
|
||||
Arc::clone(&adapter) as Arc<dyn ImageRefCommand>,
|
||||
adapter as Arc<dyn ImageRefQuery>,
|
||||
)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ImageRefCommand for PostgresImageRefAdapter {
|
||||
async fn swap(&self, old_key: &str, new_key: &str) -> Result<(), DomainError> {
|
||||
let mut tx = self.pool.begin().await
|
||||
let mut tx = self
|
||||
.pool
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
sqlx::query("UPDATE users SET avatar_path = $1 WHERE avatar_path = $2")
|
||||
.bind(new_key).bind(old_key)
|
||||
.execute(&mut *tx).await
|
||||
.bind(new_key)
|
||||
.bind(old_key)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
sqlx::query("UPDATE movies SET poster_path = $1 WHERE poster_path = $2")
|
||||
.bind(new_key).bind(old_key)
|
||||
.execute(&mut *tx).await
|
||||
.bind(new_key)
|
||||
.bind(old_key)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
tx.commit().await
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,12 +14,20 @@ use sqlx::PgPool;
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
enum DomainFieldJson {
|
||||
Title, ReleaseYear, Director, Rating, WatchedAt, Comment, ExternalMetadataId,
|
||||
Title,
|
||||
ReleaseYear,
|
||||
Director,
|
||||
Rating,
|
||||
WatchedAt,
|
||||
Comment,
|
||||
ExternalMetadataId,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
enum TransformJson {
|
||||
RatingScale(f64), DateFormat(String), Identity,
|
||||
RatingScale(f64),
|
||||
DateFormat(String),
|
||||
Identity,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
@@ -75,8 +83,8 @@ fn serialize_mappings(ms: &[FieldMapping]) -> Result<String, DomainError> {
|
||||
}
|
||||
|
||||
fn deserialize_mappings(s: &str) -> Result<Vec<FieldMapping>, DomainError> {
|
||||
let js: Vec<FieldMappingJson> = serde_json::from_str(s)
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
let js: Vec<FieldMappingJson> =
|
||||
serde_json::from_str(s).map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(js.into_iter().map(mapping_from_json).collect())
|
||||
}
|
||||
|
||||
@@ -85,7 +93,9 @@ pub struct PostgresImportProfileRepository {
|
||||
}
|
||||
|
||||
impl PostgresImportProfileRepository {
|
||||
pub fn new(pool: PgPool) -> Self { Self { pool } }
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
fn map_err(e: sqlx::Error) -> DomainError {
|
||||
tracing::error!("DB error: {:?}", e);
|
||||
@@ -115,7 +125,13 @@ impl ImportProfileRepository for PostgresImportProfileRepository {
|
||||
let uid = user_id.value().to_string();
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row { id: String, user_id: String, name: String, field_mappings: String, created_at: NaiveDateTime }
|
||||
struct Row {
|
||||
id: String,
|
||||
user_id: String,
|
||||
name: String,
|
||||
field_mappings: String,
|
||||
created_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
let rows = sqlx::query_as::<_, Row>(
|
||||
"SELECT id, user_id, name, field_mappings, created_at FROM import_profiles WHERE user_id = $1 ORDER BY created_at DESC",
|
||||
@@ -125,25 +141,42 @@ impl ImportProfileRepository for PostgresImportProfileRepository {
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
|
||||
rows.into_iter().map(|r| Ok(ImportProfile {
|
||||
id: ImportProfileId::from_uuid(
|
||||
r.id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))?
|
||||
),
|
||||
user_id: UserId::from_uuid(
|
||||
r.user_id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))?
|
||||
),
|
||||
name: r.name,
|
||||
field_mappings: deserialize_mappings(&r.field_mappings)?,
|
||||
created_at: r.created_at,
|
||||
})).collect()
|
||||
rows.into_iter()
|
||||
.map(|r| {
|
||||
Ok(ImportProfile {
|
||||
id: ImportProfileId::from_uuid(
|
||||
r.id.parse::<uuid::Uuid>()
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
||||
),
|
||||
user_id: UserId::from_uuid(
|
||||
r.user_id
|
||||
.parse::<uuid::Uuid>()
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
||||
),
|
||||
name: r.name,
|
||||
field_mappings: deserialize_mappings(&r.field_mappings)?,
|
||||
created_at: r.created_at,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn get(&self, id: &ImportProfileId, user_id: &UserId) -> Result<Option<ImportProfile>, DomainError> {
|
||||
async fn get(
|
||||
&self,
|
||||
id: &ImportProfileId,
|
||||
user_id: &UserId,
|
||||
) -> Result<Option<ImportProfile>, DomainError> {
|
||||
let id_str = id.value().to_string();
|
||||
let uid_str = user_id.value().to_string();
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row { id: String, user_id: String, name: String, field_mappings: String, created_at: NaiveDateTime }
|
||||
struct Row {
|
||||
id: String,
|
||||
user_id: String,
|
||||
name: String,
|
||||
field_mappings: String,
|
||||
created_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
let row = sqlx::query_as::<_, Row>(
|
||||
"SELECT id, user_id, name, field_mappings, created_at FROM import_profiles WHERE id = $1 AND user_id = $2",
|
||||
@@ -153,17 +186,23 @@ impl ImportProfileRepository for PostgresImportProfileRepository {
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
|
||||
row.map(|r| Ok(ImportProfile {
|
||||
id: ImportProfileId::from_uuid(
|
||||
r.id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))?
|
||||
),
|
||||
user_id: UserId::from_uuid(
|
||||
r.user_id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))?
|
||||
),
|
||||
name: r.name,
|
||||
field_mappings: deserialize_mappings(&r.field_mappings)?,
|
||||
created_at: r.created_at,
|
||||
})).transpose()
|
||||
row.map(|r| {
|
||||
Ok(ImportProfile {
|
||||
id: ImportProfileId::from_uuid(
|
||||
r.id.parse::<uuid::Uuid>()
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
||||
),
|
||||
user_id: UserId::from_uuid(
|
||||
r.user_id
|
||||
.parse::<uuid::Uuid>()
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
||||
),
|
||||
name: r.name,
|
||||
field_mappings: deserialize_mappings(&r.field_mappings)?,
|
||||
created_at: r.created_at,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &ImportProfileId) -> Result<(), DomainError> {
|
||||
|
||||
@@ -22,7 +22,13 @@ struct ParsedFileJson {
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
enum DomainFieldJson {
|
||||
Title, ReleaseYear, Director, Rating, WatchedAt, Comment, ExternalMetadataId,
|
||||
Title,
|
||||
ReleaseYear,
|
||||
Director,
|
||||
Rating,
|
||||
WatchedAt,
|
||||
Comment,
|
||||
ExternalMetadataId,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
@@ -41,19 +47,29 @@ struct FieldMappingJson {
|
||||
|
||||
#[derive(Serialize, Deserialize, Default)]
|
||||
struct ImportRowJson {
|
||||
#[serde(skip_serializing_if = "Option::is_none")] title: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")] release_year: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")] director: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")] rating: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")] watched_at: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")] comment: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")] external_metadata_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
title: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
release_year: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
director: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
rating: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
watched_at: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
comment: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
external_metadata_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
enum RowResultJson {
|
||||
Valid(ImportRowJson),
|
||||
Invalid { errors: Vec<String>, raw: Vec<(String, String)> },
|
||||
Invalid {
|
||||
errors: Vec<String>,
|
||||
raw: Vec<(String, String)>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
@@ -182,22 +198,37 @@ pub struct PostgresImportSessionRepository {
|
||||
}
|
||||
|
||||
impl PostgresImportSessionRepository {
|
||||
pub fn new(pool: PgPool) -> Self { Self { pool } }
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
fn map_err(e: sqlx::Error) -> DomainError {
|
||||
tracing::error!("DB error: {:?}", e);
|
||||
DomainError::InfrastructureError("Database operation failed".into())
|
||||
}
|
||||
|
||||
fn serialize_session(s: &ImportSession) -> Result<(String, Option<String>, Option<String>), DomainError> {
|
||||
let parsed = s.parsed_file.as_ref()
|
||||
.map(|f| ser(&ParsedFileJson { columns: f.columns.clone(), rows: f.rows.clone() }))
|
||||
fn serialize_session(
|
||||
s: &ImportSession,
|
||||
) -> Result<(String, Option<String>, Option<String>), DomainError> {
|
||||
let parsed = s
|
||||
.parsed_file
|
||||
.as_ref()
|
||||
.map(|f| {
|
||||
ser(&ParsedFileJson {
|
||||
columns: f.columns.clone(),
|
||||
rows: f.rows.clone(),
|
||||
})
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
let mappings = s.field_mappings.as_ref()
|
||||
let mappings = s
|
||||
.field_mappings
|
||||
.as_ref()
|
||||
.map(|ms| ser(&ms.iter().map(mapping_to_json).collect::<Vec<_>>()))
|
||||
.transpose()?;
|
||||
let results = s.row_results.as_ref()
|
||||
let results = s
|
||||
.row_results
|
||||
.as_ref()
|
||||
.map(|rs| ser(&rs.iter().map(annotated_to_json).collect::<Vec<_>>()))
|
||||
.transpose()?;
|
||||
Ok((parsed, mappings, results))
|
||||
@@ -216,15 +247,20 @@ impl PostgresImportSessionRepository {
|
||||
None
|
||||
} else {
|
||||
let j: ParsedFileJson = de(&parsed_data)?;
|
||||
Some(ParsedFile { columns: j.columns, rows: j.rows })
|
||||
Some(ParsedFile {
|
||||
columns: j.columns,
|
||||
rows: j.rows,
|
||||
})
|
||||
};
|
||||
let field_mappings = field_mappings.as_deref()
|
||||
let field_mappings = field_mappings
|
||||
.as_deref()
|
||||
.map(|s| -> Result<Vec<FieldMapping>, DomainError> {
|
||||
let js: Vec<FieldMappingJson> = de(s)?;
|
||||
Ok(js.into_iter().map(mapping_from_json).collect())
|
||||
})
|
||||
.transpose()?;
|
||||
let row_results = row_results.as_deref()
|
||||
let row_results = row_results
|
||||
.as_deref()
|
||||
.map(|s| -> Result<Vec<AnnotatedRow>, DomainError> {
|
||||
let js: Vec<AnnotatedRowJson> = de(s)?;
|
||||
Ok(js.into_iter().map(annotated_from_json).collect())
|
||||
@@ -232,10 +268,13 @@ impl PostgresImportSessionRepository {
|
||||
.transpose()?;
|
||||
Ok(ImportSession {
|
||||
id: ImportSessionId::from_uuid(
|
||||
id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))?
|
||||
id.parse::<uuid::Uuid>()
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
||||
),
|
||||
user_id: UserId::from_uuid(
|
||||
user_id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))?
|
||||
user_id
|
||||
.parse::<uuid::Uuid>()
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
||||
),
|
||||
parsed_file,
|
||||
field_mappings,
|
||||
@@ -265,7 +304,11 @@ impl ImportSessionRepository for PostgresImportSessionRepository {
|
||||
.map_err(Self::map_err)
|
||||
}
|
||||
|
||||
async fn get(&self, id: &ImportSessionId, user_id: &UserId) -> Result<Option<ImportSession>, DomainError> {
|
||||
async fn get(
|
||||
&self,
|
||||
id: &ImportSessionId,
|
||||
user_id: &UserId,
|
||||
) -> Result<Option<ImportSession>, DomainError> {
|
||||
let id_str = id.value().to_string();
|
||||
let uid_str = user_id.value().to_string();
|
||||
|
||||
@@ -284,26 +327,39 @@ impl ImportSessionRepository for PostgresImportSessionRepository {
|
||||
"SELECT id, user_id, parsed_data, field_mappings, row_results, created_at, expires_at
|
||||
FROM import_sessions WHERE id = $1 AND user_id = $2",
|
||||
)
|
||||
.bind(&id_str).bind(&uid_str)
|
||||
.bind(&id_str)
|
||||
.bind(&uid_str)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
|
||||
row.map(|r| Self::deserialize_session(
|
||||
r.id, r.user_id, r.parsed_data, r.field_mappings, r.row_results,
|
||||
r.created_at, r.expires_at,
|
||||
)).transpose()
|
||||
row.map(|r| {
|
||||
Self::deserialize_session(
|
||||
r.id,
|
||||
r.user_id,
|
||||
r.parsed_data,
|
||||
r.field_mappings,
|
||||
r.row_results,
|
||||
r.created_at,
|
||||
r.expires_at,
|
||||
)
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn update(&self, s: &ImportSession) -> Result<(), DomainError> {
|
||||
let id = s.id.value().to_string();
|
||||
let (_, field_mappings, row_results) = Self::serialize_session(s)?;
|
||||
sqlx::query("UPDATE import_sessions SET field_mappings = $1, row_results = $2 WHERE id = $3")
|
||||
.bind(&field_mappings).bind(&row_results).bind(&id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(Self::map_err)
|
||||
sqlx::query(
|
||||
"UPDATE import_sessions SET field_mappings = $1, row_results = $2 WHERE id = $3",
|
||||
)
|
||||
.bind(&field_mappings)
|
||||
.bind(&row_results)
|
||||
.bind(&id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(Self::map_err)
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &ImportSessionId) -> Result<(), DomainError> {
|
||||
|
||||
@@ -388,11 +388,15 @@ impl MovieRepository for PostgresRepository {
|
||||
&self,
|
||||
page: &domain::models::collections::PageParams,
|
||||
filter: &domain::models::MovieFilter,
|
||||
) -> Result<domain::models::collections::Paginated<domain::models::MovieSummary>, DomainError> {
|
||||
) -> Result<domain::models::collections::Paginated<domain::models::MovieSummary>, DomainError>
|
||||
{
|
||||
use sqlx::Row;
|
||||
let limit = page.limit as i64;
|
||||
let offset = page.offset as i64;
|
||||
let pattern = filter.search.as_deref().map(|s| format!("%{}%", s.to_lowercase()));
|
||||
let pattern = filter
|
||||
.search
|
||||
.as_deref()
|
||||
.map(|s| format!("%{}%", s.to_lowercase()));
|
||||
let genre = filter.genre.as_deref();
|
||||
let language = filter.language.as_deref();
|
||||
|
||||
@@ -612,8 +616,7 @@ impl DiaryRepository for PostgresRepository {
|
||||
}
|
||||
|
||||
if let Some(f) = following {
|
||||
let local_params: Vec<String> =
|
||||
f.local_user_ids.iter().map(|_| next_param()).collect();
|
||||
let local_params: Vec<String> = f.local_user_ids.iter().map(|_| next_param()).collect();
|
||||
let remote_params: Vec<String> =
|
||||
f.remote_actor_urls.iter().map(|_| next_param()).collect();
|
||||
|
||||
@@ -691,10 +694,7 @@ impl DiaryRepository for PostgresRepository {
|
||||
}
|
||||
|
||||
let count_q = bind_filter_params!(sqlx::query_scalar::<_, i64>(&count_sql));
|
||||
let total = count_q
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
let total = count_q.fetch_one(&self.pool).await.map_err(Self::map_err)?;
|
||||
|
||||
let rows_q = bind_filter_params!(sqlx::query_as::<_, FeedRow>(&select_sql));
|
||||
let rows = rows_q
|
||||
@@ -800,13 +800,11 @@ impl DiaryRepository for PostgresRepository {
|
||||
let limit = page.limit as i64;
|
||||
let offset = page.offset as i64;
|
||||
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM reviews WHERE movie_id = $1",
|
||||
)
|
||||
.bind(&id_str)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE movie_id = $1")
|
||||
.bind(&id_str)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
|
||||
let rows = sqlx::query_as::<_, FeedRow>(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
@@ -845,12 +843,11 @@ impl DiaryRepository for PostgresRepository {
|
||||
}
|
||||
|
||||
async fn count_local_posts(&self) -> Result<u64, DomainError> {
|
||||
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)?;
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -939,7 +936,9 @@ pub fn create_profile_fields_repo(
|
||||
std::sync::Arc::new(profile_fields::PostgresProfileFieldsRepository::new(pool))
|
||||
}
|
||||
|
||||
pub async fn wire(database_url: &str) -> anyhow::Result<(
|
||||
pub async fn wire(
|
||||
database_url: &str,
|
||||
) -> anyhow::Result<(
|
||||
sqlx::PgPool,
|
||||
std::sync::Arc<dyn domain::ports::MovieRepository>,
|
||||
std::sync::Arc<dyn domain::ports::ReviewRepository>,
|
||||
@@ -963,8 +962,10 @@ pub async fn wire(database_url: &str) -> anyhow::Result<(
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))
|
||||
.context("Database migration failed")?;
|
||||
|
||||
let import_session_repo = std::sync::Arc::new(PostgresImportSessionRepository::new(pool.clone()));
|
||||
let import_profile_repo = std::sync::Arc::new(PostgresImportProfileRepository::new(pool.clone()));
|
||||
let import_session_repo =
|
||||
std::sync::Arc::new(PostgresImportSessionRepository::new(pool.clone()));
|
||||
let import_profile_repo =
|
||||
std::sync::Arc::new(PostgresImportProfileRepository::new(pool.clone()));
|
||||
let movie_profile_repo = std::sync::Arc::new(PostgresMovieProfileRepository::new(pool.clone()));
|
||||
let watchlist_repo = std::sync::Arc::new(PostgresWatchlistRepository::new(pool.clone()));
|
||||
|
||||
|
||||
@@ -20,7 +20,10 @@ impl PostgresPersonAdapter {
|
||||
|
||||
pub fn create_person_adapter(pool: PgPool) -> (Arc<dyn PersonCommand>, Arc<dyn PersonQuery>) {
|
||||
let adapter = Arc::new(PostgresPersonAdapter::new(pool));
|
||||
(Arc::clone(&adapter) as Arc<dyn PersonCommand>, adapter as Arc<dyn PersonQuery>)
|
||||
(
|
||||
Arc::clone(&adapter) as Arc<dyn PersonCommand>,
|
||||
adapter as Arc<dyn PersonQuery>,
|
||||
)
|
||||
}
|
||||
|
||||
fn map_err(e: sqlx::Error) -> DomainError {
|
||||
@@ -88,7 +91,10 @@ impl PersonQuery for PostgresPersonAdapter {
|
||||
}))
|
||||
}
|
||||
|
||||
async fn get_by_external_id(&self, id: &ExternalPersonId) -> Result<Option<Person>, DomainError> {
|
||||
async fn get_by_external_id(
|
||||
&self,
|
||||
id: &ExternalPersonId,
|
||||
) -> Result<Option<Person>, DomainError> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row {
|
||||
id: String,
|
||||
@@ -119,21 +125,25 @@ impl PersonQuery for PostgresPersonAdapter {
|
||||
}
|
||||
|
||||
async fn get_credits(&self, id: &PersonId) -> Result<PersonCredits, DomainError> {
|
||||
let person = self.get_by_id(id).await?.ok_or_else(|| {
|
||||
DomainError::NotFound(format!("Person {} not found", id.value()))
|
||||
})?;
|
||||
let person = self
|
||||
.get_by_id(id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound(format!("Person {} not found", id.value())))?;
|
||||
|
||||
let tmdb_id: Option<i64> = sqlx::query_scalar(
|
||||
"SELECT tmdb_person_id FROM persons WHERE id = $1",
|
||||
)
|
||||
.bind(id.value().to_string())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?
|
||||
.flatten();
|
||||
let tmdb_id: Option<i64> =
|
||||
sqlx::query_scalar("SELECT tmdb_person_id FROM persons WHERE id = $1")
|
||||
.bind(id.value().to_string())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?
|
||||
.flatten();
|
||||
|
||||
let Some(tmdb_id) = tmdb_id else {
|
||||
return Ok(PersonCredits { person, cast: vec![], crew: vec![] });
|
||||
return Ok(PersonCredits {
|
||||
person,
|
||||
cast: vec![],
|
||||
crew: vec![],
|
||||
});
|
||||
};
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
|
||||
@@ -65,7 +65,9 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
|
||||
|
||||
sqlx::query("DELETE FROM movie_genres WHERE movie_id = $1")
|
||||
.bind(&movie_id)
|
||||
.execute(&mut *tx).await.map_err(Self::map_err)?;
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
for g in &p.genres {
|
||||
sqlx::query("INSERT INTO movie_genres (movie_id, tmdb_id, name) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING")
|
||||
.bind(&movie_id).bind(g.tmdb_id as i32).bind(&g.name)
|
||||
@@ -74,7 +76,9 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
|
||||
|
||||
sqlx::query("DELETE FROM movie_keywords WHERE movie_id = $1")
|
||||
.bind(&movie_id)
|
||||
.execute(&mut *tx).await.map_err(Self::map_err)?;
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
for k in &p.keywords {
|
||||
sqlx::query("INSERT INTO movie_keywords (movie_id, tmdb_id, name) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING")
|
||||
.bind(&movie_id).bind(k.tmdb_id as i32).bind(&k.name)
|
||||
@@ -83,30 +87,46 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
|
||||
|
||||
sqlx::query("DELETE FROM movie_cast WHERE movie_id = $1")
|
||||
.bind(&movie_id)
|
||||
.execute(&mut *tx).await.map_err(Self::map_err)?;
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
for c in &p.cast {
|
||||
sqlx::query(
|
||||
"INSERT INTO movie_cast \
|
||||
(movie_id, tmdb_person_id, name, character, billing_order, profile_path) \
|
||||
VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(&movie_id).bind(c.tmdb_person_id as i64).bind(&c.name)
|
||||
.bind(&c.character).bind(c.billing_order as i32).bind(&c.profile_path)
|
||||
.execute(&mut *tx).await.map_err(Self::map_err)?;
|
||||
.bind(&movie_id)
|
||||
.bind(c.tmdb_person_id as i64)
|
||||
.bind(&c.name)
|
||||
.bind(&c.character)
|
||||
.bind(c.billing_order as i32)
|
||||
.bind(&c.profile_path)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM movie_crew WHERE movie_id = $1")
|
||||
.bind(&movie_id)
|
||||
.execute(&mut *tx).await.map_err(Self::map_err)?;
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
for cr in &p.crew {
|
||||
sqlx::query(
|
||||
"INSERT INTO movie_crew \
|
||||
(movie_id, tmdb_person_id, name, job, department, profile_path) \
|
||||
VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(&movie_id).bind(cr.tmdb_person_id as i64).bind(&cr.name)
|
||||
.bind(&cr.job).bind(&cr.department).bind(&cr.profile_path)
|
||||
.execute(&mut *tx).await.map_err(Self::map_err)?;
|
||||
.bind(&movie_id)
|
||||
.bind(cr.tmdb_person_id as i64)
|
||||
.bind(&cr.name)
|
||||
.bind(&cr.job)
|
||||
.bind(&cr.department)
|
||||
.bind(&cr.profile_path)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
}
|
||||
|
||||
tx.commit().await.map_err(Self::map_err)
|
||||
@@ -131,12 +151,15 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let enriched_at: DateTime<Utc> = row.try_get("enriched_at")
|
||||
let enriched_at: DateTime<Utc> = row
|
||||
.try_get("enriched_at")
|
||||
.map_err(|_| DomainError::InfrastructureError("invalid enriched_at".into()))?;
|
||||
|
||||
let genres = sqlx::query("SELECT tmdb_id, name FROM movie_genres WHERE movie_id = $1")
|
||||
.bind(&movie_id)
|
||||
.fetch_all(&self.pool).await.map_err(Self::map_err)?
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.into_iter()
|
||||
.map(|r| Genre {
|
||||
tmdb_id: r.try_get::<i32, _>("tmdb_id").unwrap_or(0) as u32,
|
||||
@@ -146,7 +169,9 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
|
||||
|
||||
let keywords = sqlx::query("SELECT tmdb_id, name FROM movie_keywords WHERE movie_id = $1")
|
||||
.bind(&movie_id)
|
||||
.fetch_all(&self.pool).await.map_err(Self::map_err)?
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.into_iter()
|
||||
.map(|r| Keyword {
|
||||
tmdb_id: r.try_get::<i32, _>("tmdb_id").unwrap_or(0) as u32,
|
||||
@@ -159,7 +184,9 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
|
||||
FROM movie_cast WHERE movie_id = $1 ORDER BY billing_order",
|
||||
)
|
||||
.bind(&movie_id)
|
||||
.fetch_all(&self.pool).await.map_err(Self::map_err)?
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.into_iter()
|
||||
.map(|r| CastMember {
|
||||
tmdb_person_id: r.try_get::<i64, _>("tmdb_person_id").unwrap_or(0) as u64,
|
||||
@@ -175,7 +202,9 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
|
||||
FROM movie_crew WHERE movie_id = $1",
|
||||
)
|
||||
.bind(&movie_id)
|
||||
.fetch_all(&self.pool).await.map_err(Self::map_err)?
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.into_iter()
|
||||
.map(|r| CrewMember {
|
||||
tmdb_person_id: r.try_get::<i64, _>("tmdb_person_id").unwrap_or(0) as u64,
|
||||
@@ -192,11 +221,19 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
|
||||
imdb_id: row.try_get("imdb_id").ok(),
|
||||
overview: row.try_get("overview").ok(),
|
||||
tagline: row.try_get("tagline").ok(),
|
||||
runtime_minutes: row.try_get::<Option<i32>, _>("runtime_minutes").ok().flatten().map(|v| v as u32),
|
||||
runtime_minutes: row
|
||||
.try_get::<Option<i32>, _>("runtime_minutes")
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|v| v as u32),
|
||||
budget_usd: row.try_get("budget_usd").ok(),
|
||||
revenue_usd: row.try_get("revenue_usd").ok(),
|
||||
vote_average: row.try_get("vote_average").ok(),
|
||||
vote_count: row.try_get::<Option<i32>, _>("vote_count").ok().flatten().map(|v| v as u32),
|
||||
vote_count: row
|
||||
.try_get::<Option<i32>, _>("vote_count")
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|v| v as u32),
|
||||
original_language: row.try_get("original_language").ok(),
|
||||
collection_name: row.try_get("collection_name").ok(),
|
||||
genres,
|
||||
|
||||
@@ -2,9 +2,7 @@ use async_trait::async_trait;
|
||||
use sqlx::PgPool;
|
||||
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::ProfileField,
|
||||
ports::UserProfileFieldsRepository,
|
||||
errors::DomainError, models::ProfileField, ports::UserProfileFieldsRepository,
|
||||
value_objects::UserId,
|
||||
};
|
||||
|
||||
|
||||
@@ -46,8 +46,8 @@ impl PostgresUserRepository {
|
||||
) -> Result<User, DomainError> {
|
||||
let id = uuid::Uuid::parse_str(&id_str)
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
let email = Email::new(email_str)
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
let email =
|
||||
Email::new(email_str).map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
let username = Username::new(username_str)
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
let hash = PasswordHash::new(hash_str)
|
||||
@@ -208,7 +208,10 @@ impl UserRepository for PostgresUserRepository {
|
||||
let Some(r) = row else { return Ok(None) };
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct FieldRow { name: String, value: String }
|
||||
struct FieldRow {
|
||||
name: String,
|
||||
value: String,
|
||||
}
|
||||
let field_rows = sqlx::query_as::<_, FieldRow>(
|
||||
"SELECT name, value FROM user_profile_fields WHERE user_id = $1 ORDER BY position ASC",
|
||||
)
|
||||
@@ -217,7 +220,13 @@ impl UserRepository for PostgresUserRepository {
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
let profile_fields = field_rows.into_iter().map(|f| ProfileField { name: f.name, value: f.value }).collect();
|
||||
let profile_fields = field_rows
|
||||
.into_iter()
|
||||
.map(|f| ProfileField {
|
||||
name: f.name,
|
||||
value: f.value,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Self::row_to_user(
|
||||
r.id,
|
||||
@@ -230,7 +239,8 @@ impl UserRepository for PostgresUserRepository {
|
||||
r.banner_path,
|
||||
r.also_known_as,
|
||||
profile_fields,
|
||||
).map(Some)
|
||||
)
|
||||
.map(Some)
|
||||
}
|
||||
|
||||
async fn update_profile(
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{WatchlistEntry, WatchlistWithMovie, collections::{PageParams, Paginated}},
|
||||
models::{
|
||||
WatchlistEntry, WatchlistWithMovie,
|
||||
collections::{PageParams, Paginated},
|
||||
},
|
||||
ports::WatchlistRepository,
|
||||
value_objects::{MovieId, UserId, WatchlistEntryId},
|
||||
};
|
||||
use sqlx::{PgPool, Row};
|
||||
|
||||
use crate::models::{parse_uuid, parse_datetime, MovieRow};
|
||||
use crate::models::{MovieRow, parse_datetime, parse_uuid};
|
||||
|
||||
pub struct PostgresWatchlistRepository {
|
||||
pool: PgPool,
|
||||
@@ -52,14 +55,13 @@ impl WatchlistRepository for PostgresWatchlistRepository {
|
||||
let uid = user_id.value().to_string();
|
||||
let mid = movie_id.value().to_string();
|
||||
|
||||
let result = sqlx::query(
|
||||
"DELETE FROM watchlist_entries WHERE user_id = $1 AND movie_id = $2",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(&mid)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
let result =
|
||||
sqlx::query("DELETE FROM watchlist_entries WHERE user_id = $1 AND movie_id = $2")
|
||||
.bind(&uid)
|
||||
.bind(&mid)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DomainError::NotFound(format!(
|
||||
@@ -77,14 +79,13 @@ impl WatchlistRepository for PostgresWatchlistRepository {
|
||||
) -> Result<bool, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let mid = movie_id.value().to_string();
|
||||
let result = sqlx::query(
|
||||
"DELETE FROM watchlist_entries WHERE user_id = $1 AND movie_id = $2",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(&mid)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
let result =
|
||||
sqlx::query("DELETE FROM watchlist_entries WHERE user_id = $1 AND movie_id = $2")
|
||||
.bind(&uid)
|
||||
.bind(&mid)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
@@ -115,30 +116,53 @@ impl WatchlistRepository for PostgresWatchlistRepository {
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM watchlist_entries WHERE user_id = $1",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
let total: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM watchlist_entries WHERE user_id = $1")
|
||||
.bind(&uid)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
|
||||
let items = rows
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
let entry = WatchlistEntry {
|
||||
id: WatchlistEntryId::from_uuid(parse_uuid(&row.try_get::<String, _>("id").map_err(|e| DomainError::InfrastructureError(e.to_string()))?)?),
|
||||
user_id: UserId::from_uuid(parse_uuid(&row.try_get::<String, _>("user_id").map_err(|e| DomainError::InfrastructureError(e.to_string()))?)?),
|
||||
movie_id: MovieId::from_uuid(parse_uuid(&row.try_get::<String, _>("movie_id").map_err(|e| DomainError::InfrastructureError(e.to_string()))?)?),
|
||||
added_at: parse_datetime(&row.try_get::<String, _>("added_at").map_err(|e| DomainError::InfrastructureError(e.to_string()))?)?,
|
||||
id: WatchlistEntryId::from_uuid(parse_uuid(
|
||||
&row.try_get::<String, _>("id")
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
||||
)?),
|
||||
user_id: UserId::from_uuid(parse_uuid(
|
||||
&row.try_get::<String, _>("user_id")
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
||||
)?),
|
||||
movie_id: MovieId::from_uuid(parse_uuid(
|
||||
&row.try_get::<String, _>("movie_id")
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
||||
)?),
|
||||
added_at: parse_datetime(
|
||||
&row.try_get::<String, _>("added_at")
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
||||
)?,
|
||||
};
|
||||
let movie = MovieRow {
|
||||
id: row.try_get("m_id").map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
||||
external_metadata_id: row.try_get("external_metadata_id").map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
||||
title: row.try_get("title").map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
||||
release_year: row.try_get("release_year").map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
||||
director: row.try_get("director").map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
||||
poster_path: row.try_get("poster_path").map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
||||
id: row
|
||||
.try_get("m_id")
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
||||
external_metadata_id: row
|
||||
.try_get("external_metadata_id")
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
||||
title: row
|
||||
.try_get("title")
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
||||
release_year: row
|
||||
.try_get("release_year")
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
||||
director: row
|
||||
.try_get("director")
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
||||
poster_path: row
|
||||
.try_get("poster_path")
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
||||
}
|
||||
.into_domain()?;
|
||||
Ok(WatchlistWithMovie { entry, movie })
|
||||
|
||||
@@ -18,33 +18,42 @@ use payload::DbEventPayload;
|
||||
|
||||
pub struct DbEventQueueConfig {
|
||||
pub poll_interval_ms: u64,
|
||||
pub batch_size: i64,
|
||||
pub max_attempts: i32,
|
||||
pub batch_size: i64,
|
||||
pub max_attempts: i32,
|
||||
}
|
||||
|
||||
impl DbEventQueueConfig {
|
||||
pub fn from_env() -> Self {
|
||||
Self {
|
||||
poll_interval_ms: std::env::var("EVENT_QUEUE_POLL_INTERVAL_MS")
|
||||
.ok().and_then(|v| v.parse().ok()).unwrap_or(500),
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(500),
|
||||
batch_size: std::env::var("EVENT_QUEUE_BATCH_SIZE")
|
||||
.ok().and_then(|v| v.parse().ok()).unwrap_or(10),
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(10),
|
||||
max_attempts: std::env::var("EVENT_QUEUE_MAX_ATTEMPTS")
|
||||
.ok().and_then(|v| v.parse().ok()).unwrap_or(5),
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(5),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SqliteEventQueue {
|
||||
pool: SqlitePool,
|
||||
pool: SqlitePool,
|
||||
config: Arc<DbEventQueueConfig>,
|
||||
}
|
||||
|
||||
impl SqliteEventQueue {
|
||||
pub async fn create(pool: SqlitePool, config: DbEventQueueConfig) -> anyhow::Result<Self> {
|
||||
migrations::run(&pool).await?;
|
||||
Ok(Self { pool, config: Arc::new(config) })
|
||||
Ok(Self {
|
||||
pool,
|
||||
config: Arc::new(config),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn create_publisher(pool: SqlitePool) -> anyhow::Result<Arc<dyn EventPublisher>> {
|
||||
@@ -68,14 +77,12 @@ impl EventPublisher for SqliteEventQueue {
|
||||
let payload_json = serde_json::to_string(&db_payload)
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("serialize: {e}")))?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO event_queue (event_type, payload) VALUES (?, ?)"
|
||||
)
|
||||
.bind(event_type)
|
||||
.bind(payload_json)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("insert event: {e}")))?;
|
||||
sqlx::query("INSERT INTO event_queue (event_type, payload) VALUES (?, ?)")
|
||||
.bind(event_type)
|
||||
.bind(payload_json)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("insert event: {e}")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -83,10 +90,10 @@ impl EventPublisher for SqliteEventQueue {
|
||||
|
||||
impl EventConsumer for SqliteEventQueue {
|
||||
fn consume(&self) -> BoxStream<'_, Result<EventEnvelope, DomainError>> {
|
||||
let pool = self.pool.clone();
|
||||
let config = Arc::clone(&self.config);
|
||||
let (tx, rx) = mpsc::channel(128);
|
||||
let rx = Arc::new(Mutex::new(rx));
|
||||
let pool = self.pool.clone();
|
||||
let config = Arc::clone(&self.config);
|
||||
let (tx, rx) = mpsc::channel(128);
|
||||
let rx = Arc::new(Mutex::new(rx));
|
||||
|
||||
tokio::spawn(async move {
|
||||
let poll_interval = Duration::from_millis(config.poll_interval_ms);
|
||||
@@ -124,16 +131,18 @@ impl EventConsumer for SqliteEventQueue {
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct QueueRow {
|
||||
id: i64,
|
||||
payload: String,
|
||||
id: i64,
|
||||
payload: String,
|
||||
attempts: i32,
|
||||
}
|
||||
|
||||
async fn claim_batch(
|
||||
pool: &SqlitePool,
|
||||
pool: &SqlitePool,
|
||||
config: &DbEventQueueConfig,
|
||||
) -> Result<Vec<QueueRow>, DomainError> {
|
||||
let mut tx = pool.begin().await
|
||||
let mut tx = pool
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("begin tx: {e}")))?;
|
||||
|
||||
let rows = sqlx::query_as::<_, QueueRow>(
|
||||
@@ -141,7 +150,7 @@ async fn claim_batch(
|
||||
WHERE status = 'pending'
|
||||
AND next_attempt_at <= strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
ORDER BY next_attempt_at ASC
|
||||
LIMIT ?"
|
||||
LIMIT ?",
|
||||
)
|
||||
.bind(config.batch_size)
|
||||
.fetch_all(&mut *tx)
|
||||
@@ -159,36 +168,43 @@ async fn claim_batch(
|
||||
placeholders
|
||||
);
|
||||
let mut q = sqlx::query(&sql);
|
||||
for r in &rows { q = q.bind(r.id); }
|
||||
q.execute(&mut *tx).await
|
||||
for r in &rows {
|
||||
q = q.bind(r.id);
|
||||
}
|
||||
q.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("mark processing: {e}")))?;
|
||||
|
||||
tx.commit().await
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("commit claim: {e}")))?;
|
||||
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
fn decode_row(
|
||||
pool: &SqlitePool,
|
||||
row: QueueRow,
|
||||
pool: &SqlitePool,
|
||||
row: QueueRow,
|
||||
max_attempts: i32,
|
||||
) -> Result<EventEnvelope, DomainError> {
|
||||
let db_payload: DbEventPayload = serde_json::from_str(&row.payload)
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("deserialize: {e}")))?;
|
||||
let event = DomainEvent::try_from(db_payload)?;
|
||||
Ok(EventEnvelope::new(event, Box::new(DbAckHandle {
|
||||
pool: pool.clone(),
|
||||
row_id: row.id,
|
||||
attempts: row.attempts,
|
||||
max_attempts,
|
||||
})))
|
||||
Ok(EventEnvelope::new(
|
||||
event,
|
||||
Box::new(DbAckHandle {
|
||||
pool: pool.clone(),
|
||||
row_id: row.id,
|
||||
attempts: row.attempts,
|
||||
max_attempts,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
struct DbAckHandle {
|
||||
pool: SqlitePool,
|
||||
row_id: i64,
|
||||
attempts: i32,
|
||||
pool: SqlitePool,
|
||||
row_id: i64,
|
||||
attempts: i32,
|
||||
max_attempts: i32,
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ use activitypub::RemoteReviewRepository;
|
||||
use activitypub_base::{
|
||||
BlockedDomain, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
|
||||
};
|
||||
use domain::models::{Review, ReviewSource, RemoteWatchlistEntry};
|
||||
use domain::models::{RemoteWatchlistEntry, Review, ReviewSource};
|
||||
use domain::ports::RemoteWatchlistRepository;
|
||||
|
||||
fn datetime_to_str(dt: &NaiveDateTime) -> String {
|
||||
@@ -178,7 +178,8 @@ impl FederationRepository for SqliteFederationRepository {
|
||||
let status_str: String = row.get("status");
|
||||
let handle: String = row.try_get("handle").unwrap_or_default();
|
||||
let inbox_url: String = row.try_get("inbox_url").unwrap_or_default();
|
||||
let shared_inbox_url: Option<String> = row.try_get("shared_inbox_url").ok().flatten();
|
||||
let shared_inbox_url: Option<String> =
|
||||
row.try_get("shared_inbox_url").ok().flatten();
|
||||
let display_name: Option<String> = row.try_get("display_name").ok().flatten();
|
||||
let avatar_url: Option<String> = row.try_get("avatar_url").ok().flatten();
|
||||
Follower {
|
||||
@@ -595,12 +596,11 @@ impl FederationRepository for SqliteFederationRepository {
|
||||
}
|
||||
|
||||
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?;
|
||||
let count: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM blocked_domains WHERE domain = ?1")
|
||||
.bind(domain)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
@@ -639,7 +639,10 @@ impl FederationRepository for SqliteFederationRepository {
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.iter().map(|r| r.get::<String, _>("remote_actor_url")).collect())
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|r| r.get::<String, _>("remote_actor_url"))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<bool> {
|
||||
@@ -789,11 +792,13 @@ impl domain::ports::SocialQueryPort for SqliteFederationRepository {
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(url, handle, display_name)| domain::ports::RemoteActorInfo {
|
||||
url,
|
||||
handle,
|
||||
display_name,
|
||||
})
|
||||
.map(
|
||||
|(url, handle, display_name)| domain::ports::RemoteActorInfo {
|
||||
url,
|
||||
handle,
|
||||
display_name,
|
||||
},
|
||||
)
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
@@ -822,19 +827,24 @@ impl RemoteWatchlistRepository for SqliteFederationRepository {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_by_ap_id(&self, ap_id: &str, actor_url: &str) -> Result<(), domain::errors::DomainError> {
|
||||
sqlx::query(
|
||||
"DELETE FROM ap_remote_watchlist_entries WHERE ap_id = ? AND actor_url = ?",
|
||||
)
|
||||
.bind(ap_id)
|
||||
.bind(actor_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?;
|
||||
async fn remove_by_ap_id(
|
||||
&self,
|
||||
ap_id: &str,
|
||||
actor_url: &str,
|
||||
) -> Result<(), domain::errors::DomainError> {
|
||||
sqlx::query("DELETE FROM ap_remote_watchlist_entries WHERE ap_id = ? AND actor_url = ?")
|
||||
.bind(ap_id)
|
||||
.bind(actor_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_by_actor_url(&self, actor_url: &str) -> Result<Vec<RemoteWatchlistEntry>, domain::errors::DomainError> {
|
||||
async fn get_by_actor_url(
|
||||
&self,
|
||||
actor_url: &str,
|
||||
) -> Result<Vec<RemoteWatchlistEntry>, domain::errors::DomainError> {
|
||||
let rows = sqlx::query(
|
||||
"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",
|
||||
@@ -844,24 +854,35 @@ impl RemoteWatchlistRepository for SqliteFederationRepository {
|
||||
.await
|
||||
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
rows.into_iter().map(|row| {
|
||||
let added_at_str: String = row.try_get("added_at").unwrap_or_default();
|
||||
let added_at = chrono::NaiveDateTime::parse_from_str(&added_at_str, "%Y-%m-%d %H:%M:%S")
|
||||
.map(|dt| chrono::DateTime::<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,
|
||||
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()
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn remove_all_by_actor(&self, actor_url: &str) -> Result<(), domain::errors::DomainError> {
|
||||
async fn remove_all_by_actor(
|
||||
&self,
|
||||
actor_url: &str,
|
||||
) -> Result<(), domain::errors::DomainError> {
|
||||
sqlx::query("DELETE FROM ap_remote_watchlist_entries WHERE actor_url = ?")
|
||||
.bind(actor_url)
|
||||
.execute(&self.pool)
|
||||
@@ -870,18 +891,22 @@ impl RemoteWatchlistRepository for SqliteFederationRepository {
|
||||
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();
|
||||
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
|
||||
});
|
||||
let target = actors
|
||||
.into_iter()
|
||||
.find(|url| uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, url.as_bytes()) == uuid);
|
||||
|
||||
match target {
|
||||
None => Ok(vec![]),
|
||||
@@ -890,7 +915,9 @@ impl RemoteWatchlistRepository for SqliteFederationRepository {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn wire(pool: sqlx::SqlitePool) -> (
|
||||
pub fn wire(
|
||||
pool: sqlx::SqlitePool,
|
||||
) -> (
|
||||
std::sync::Arc<dyn activitypub::FederationRepository>,
|
||||
std::sync::Arc<dyn domain::ports::SocialQueryPort>,
|
||||
std::sync::Arc<dyn activitypub::RemoteReviewRepository>,
|
||||
|
||||
@@ -3,14 +3,23 @@ use sqlx::SqlitePool;
|
||||
|
||||
async fn test_pool() -> SqlitePool {
|
||||
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
|
||||
sqlx::query("CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT, password_hash TEXT, created_at TEXT)")
|
||||
.execute(&pool).await.unwrap();
|
||||
sqlx::query(
|
||||
"CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT, password_hash TEXT, created_at TEXT)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("CREATE TABLE blocked_actors (local_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, remote_actor_url TEXT NOT NULL, blocked_at TEXT NOT NULL, PRIMARY KEY (local_user_id, remote_actor_url))")
|
||||
.execute(&pool).await.unwrap();
|
||||
let uid = uuid::Uuid::new_v4().to_string();
|
||||
sqlx::query("INSERT INTO users (id, email, password_hash, created_at) VALUES (?, ?, ?, ?)")
|
||||
.bind(&uid).bind("a@b.com").bind("hash").bind("2024-01-01")
|
||||
.execute(&pool).await.unwrap();
|
||||
.bind(&uid)
|
||||
.bind("a@b.com")
|
||||
.bind("hash")
|
||||
.bind("2024-01-01")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
pool
|
||||
}
|
||||
|
||||
@@ -19,8 +28,11 @@ async fn block_and_check_actor() {
|
||||
let pool = test_pool().await;
|
||||
let user_id = uuid::Uuid::parse_str(
|
||||
&sqlx::query_scalar::<_, String>("SELECT id FROM users LIMIT 1")
|
||||
.fetch_one(&pool).await.unwrap()
|
||||
).unwrap();
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let repo = SqliteFederationRepository::new(pool);
|
||||
let actor_url = "https://mastodon.social/users/alice";
|
||||
assert!(!repo.is_actor_blocked(user_id, actor_url).await.unwrap());
|
||||
|
||||
@@ -13,7 +13,9 @@ async fn blocked_domain_is_detected() {
|
||||
let pool = test_pool().await;
|
||||
let repo = SqliteFederationRepository::new(pool);
|
||||
assert!(!repo.is_domain_blocked("mastodon.social").await.unwrap());
|
||||
repo.add_blocked_domain("mastodon.social", Some("spam")).await.unwrap();
|
||||
repo.add_blocked_domain("mastodon.social", Some("spam"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(repo.is_domain_blocked("mastodon.social").await.unwrap());
|
||||
}
|
||||
|
||||
@@ -30,7 +32,9 @@ async fn remove_unblocks_domain() {
|
||||
async fn get_blocked_domains_returns_all() {
|
||||
let pool = test_pool().await;
|
||||
let repo = SqliteFederationRepository::new(pool);
|
||||
repo.add_blocked_domain("a.com", Some("reason a")).await.unwrap();
|
||||
repo.add_blocked_domain("a.com", Some("reason a"))
|
||||
.await
|
||||
.unwrap();
|
||||
repo.add_blocked_domain("b.com", None).await.unwrap();
|
||||
let domains = repo.get_blocked_domains().await.unwrap();
|
||||
assert_eq!(domains.len(), 2);
|
||||
|
||||
@@ -14,7 +14,14 @@ async fn test_pool() -> SqlitePool {
|
||||
async fn add_announce_stores_and_counts() {
|
||||
let pool = test_pool().await;
|
||||
let repo = SqliteFederationRepository::new(pool);
|
||||
repo.add_announce("https://remote/ann/1", "https://local/r/1", "https://remote/u/1", Utc::now()).await.unwrap();
|
||||
repo.add_announce(
|
||||
"https://remote/ann/1",
|
||||
"https://local/r/1",
|
||||
"https://remote/u/1",
|
||||
Utc::now(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(repo.count_announces("https://local/r/1").await.unwrap(), 1);
|
||||
}
|
||||
|
||||
@@ -22,8 +29,22 @@ async fn add_announce_stores_and_counts() {
|
||||
async fn duplicate_announce_is_ignored() {
|
||||
let pool = test_pool().await;
|
||||
let repo = SqliteFederationRepository::new(pool);
|
||||
repo.add_announce("https://remote/ann/1", "https://local/r/1", "https://remote/u/1", Utc::now()).await.unwrap();
|
||||
repo.add_announce("https://remote/ann/1", "https://local/r/1", "https://remote/u/1", Utc::now()).await.unwrap();
|
||||
repo.add_announce(
|
||||
"https://remote/ann/1",
|
||||
"https://local/r/1",
|
||||
"https://remote/u/1",
|
||||
Utc::now(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
repo.add_announce(
|
||||
"https://remote/ann/1",
|
||||
"https://local/r/1",
|
||||
"https://remote/u/1",
|
||||
Utc::now(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(repo.count_announces("https://local/r/1").await.unwrap(), 1);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,14 +3,13 @@ use std::sync::Arc;
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{
|
||||
EntityType, IndexableDocument, MovieSearchHit, PersonSearchHit,
|
||||
SearchQuery, SearchResults,
|
||||
collections::Paginated,
|
||||
},
|
||||
models::PersonId,
|
||||
value_objects::MovieId,
|
||||
models::{
|
||||
collections::Paginated, EntityType, IndexableDocument, MovieSearchHit, PersonSearchHit,
|
||||
SearchQuery, SearchResults,
|
||||
},
|
||||
ports::{SearchCommand, SearchPort},
|
||||
value_objects::MovieId,
|
||||
};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
@@ -26,7 +25,10 @@ impl SqliteSearchAdapter {
|
||||
|
||||
pub fn create_search_adapter(pool: SqlitePool) -> (Arc<dyn SearchCommand>, Arc<dyn SearchPort>) {
|
||||
let adapter = Arc::new(SqliteSearchAdapter::new(pool));
|
||||
(Arc::clone(&adapter) as Arc<dyn SearchCommand>, adapter as Arc<dyn SearchPort>)
|
||||
(
|
||||
Arc::clone(&adapter) as Arc<dyn SearchCommand>,
|
||||
adapter as Arc<dyn SearchPort>,
|
||||
)
|
||||
}
|
||||
|
||||
fn map_err(e: sqlx::Error) -> DomainError {
|
||||
@@ -46,13 +48,36 @@ impl SearchCommand for SqliteSearchAdapter {
|
||||
match profile.as_deref() {
|
||||
Some(p) => (
|
||||
p.overview.clone().unwrap_or_default(),
|
||||
p.genres.iter().map(|g| g.name.as_str()).collect::<Vec<_>>().join(" "),
|
||||
p.keywords.iter().map(|k| k.name.as_str()).collect::<Vec<_>>().join(" "),
|
||||
p.cast.iter().map(|c| c.name.as_str()).collect::<Vec<_>>().join(" "),
|
||||
p.crew.iter().map(|c| c.name.as_str()).collect::<Vec<_>>().join(" "),
|
||||
p.genres
|
||||
.iter()
|
||||
.map(|g| g.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
p.keywords
|
||||
.iter()
|
||||
.map(|k| k.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
p.cast
|
||||
.iter()
|
||||
.map(|c| c.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
p.crew
|
||||
.iter()
|
||||
.map(|c| c.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
p.original_language.clone().unwrap_or_default(),
|
||||
),
|
||||
None => (String::new(), String::new(), String::new(), String::new(), String::new(), String::new()),
|
||||
None => (
|
||||
String::new(),
|
||||
String::new(),
|
||||
String::new(),
|
||||
String::new(),
|
||||
String::new(),
|
||||
String::new(),
|
||||
),
|
||||
};
|
||||
|
||||
sqlx::query(
|
||||
@@ -145,7 +170,10 @@ impl SearchPort for SqliteSearchAdapter {
|
||||
}
|
||||
|
||||
impl SqliteSearchAdapter {
|
||||
async fn search_movies(&self, query: &SearchQuery) -> Result<Paginated<MovieSearchHit>, DomainError> {
|
||||
async fn search_movies(
|
||||
&self,
|
||||
query: &SearchQuery,
|
||||
) -> Result<Paginated<MovieSearchHit>, DomainError> {
|
||||
let limit = query.page.limit as i64;
|
||||
let offset = query.page.offset as i64;
|
||||
|
||||
@@ -244,24 +272,36 @@ impl SqliteSearchAdapter {
|
||||
.await
|
||||
.map_err(map_err)?
|
||||
};
|
||||
let items = rows.into_iter().map(|r| MovieSearchHit {
|
||||
movie_id: MovieId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()),
|
||||
title: r.title,
|
||||
release_year: r.release_year.map(|y| y as u16),
|
||||
director: r.director,
|
||||
poster_path: r.poster_path,
|
||||
genres: r.genres
|
||||
.unwrap_or_default()
|
||||
.split(',')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
}).collect::<Vec<_>>();
|
||||
let items = rows
|
||||
.into_iter()
|
||||
.map(|r| MovieSearchHit {
|
||||
movie_id: MovieId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()),
|
||||
title: r.title,
|
||||
release_year: r.release_year.map(|y| y as u16),
|
||||
director: r.director,
|
||||
poster_path: r.poster_path,
|
||||
genres: r
|
||||
.genres
|
||||
.unwrap_or_default()
|
||||
.split(',')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(Paginated { items, total_count: total, limit: query.page.limit, offset: query.page.offset })
|
||||
Ok(Paginated {
|
||||
items,
|
||||
total_count: total,
|
||||
limit: query.page.limit,
|
||||
offset: query.page.offset,
|
||||
})
|
||||
}
|
||||
|
||||
async fn search_people(&self, query: &SearchQuery) -> Result<Paginated<PersonSearchHit>, DomainError> {
|
||||
async fn search_people(
|
||||
&self,
|
||||
query: &SearchQuery,
|
||||
) -> Result<Paginated<PersonSearchHit>, DomainError> {
|
||||
let Some(text) = &query.text else {
|
||||
return Ok(Paginated {
|
||||
items: vec![],
|
||||
@@ -276,13 +316,12 @@ impl SqliteSearchAdapter {
|
||||
let fts_query = format!("{}*", text.replace(['"', '*'], ""));
|
||||
|
||||
let total: u64 = {
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM people_fts WHERE people_fts MATCH ?",
|
||||
)
|
||||
.bind(&fts_query)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
let count: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM people_fts WHERE people_fts MATCH ?")
|
||||
.bind(&fts_query)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
count as u64
|
||||
};
|
||||
|
||||
@@ -311,14 +350,13 @@ impl SqliteSearchAdapter {
|
||||
|
||||
let mut items = Vec::with_capacity(rows.len());
|
||||
for row in rows {
|
||||
let tmdb_id: Option<i64> = sqlx::query_scalar(
|
||||
"SELECT tmdb_person_id FROM persons WHERE id = ?",
|
||||
)
|
||||
.bind(&row.person_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?
|
||||
.flatten();
|
||||
let tmdb_id: Option<i64> =
|
||||
sqlx::query_scalar("SELECT tmdb_person_id FROM persons WHERE id = ?")
|
||||
.bind(&row.person_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?
|
||||
.flatten();
|
||||
|
||||
let known_for_titles = if let Some(tid) = tmdb_id {
|
||||
sqlx::query_scalar::<_, String>(
|
||||
@@ -338,7 +376,7 @@ impl SqliteSearchAdapter {
|
||||
|
||||
items.push(PersonSearchHit {
|
||||
person_id: PersonId::from_uuid(
|
||||
uuid::Uuid::parse_str(&row.person_id).unwrap_or_default()
|
||||
uuid::Uuid::parse_str(&row.person_id).unwrap_or_default(),
|
||||
),
|
||||
name: row.name,
|
||||
known_for_department: row.known_for_department,
|
||||
@@ -347,7 +385,12 @@ impl SqliteSearchAdapter {
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Paginated { items, total_count: total, limit: query.page.limit, offset: query.page.offset })
|
||||
Ok(Paginated {
|
||||
items,
|
||||
total_count: total,
|
||||
limit: query.page.limit,
|
||||
offset: query.page.offset,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
use super::{SqliteSearchAdapter, create_search_adapter};
|
||||
use super::{create_search_adapter, SqliteSearchAdapter};
|
||||
use domain::{
|
||||
models::{
|
||||
EntityType, IndexableDocument, Movie,
|
||||
Person, PersonId, SearchFilters, SearchQuery,
|
||||
ExternalPersonId,
|
||||
collections::PageParams,
|
||||
collections::PageParams, EntityType, ExternalPersonId, IndexableDocument, Movie, Person,
|
||||
PersonId, SearchFilters, SearchQuery,
|
||||
},
|
||||
value_objects::{MovieId, MovieTitle, ReleaseYear},
|
||||
ports::{SearchCommand, SearchPort},
|
||||
value_objects::{MovieId, MovieTitle, ReleaseYear},
|
||||
};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
@@ -17,33 +15,43 @@ async fn pool_with_schema() -> SqlitePool {
|
||||
"CREATE TABLE movies (id TEXT PRIMARY KEY, title TEXT NOT NULL,
|
||||
release_year INTEGER, director TEXT, poster_path TEXT, external_metadata_id TEXT)",
|
||||
)
|
||||
.execute(&pool).await.unwrap();
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"CREATE TABLE persons (id TEXT PRIMARY KEY, external_id TEXT UNIQUE,
|
||||
tmdb_person_id INTEGER UNIQUE, name TEXT NOT NULL,
|
||||
known_for_department TEXT, profile_path TEXT)",
|
||||
)
|
||||
.execute(&pool).await.unwrap();
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"CREATE TABLE movie_cast (movie_id TEXT, tmdb_person_id INTEGER,
|
||||
name TEXT, character TEXT, billing_order INTEGER, profile_path TEXT)",
|
||||
)
|
||||
.execute(&pool).await.unwrap();
|
||||
sqlx::query(
|
||||
"CREATE TABLE movie_genres (movie_id TEXT, tmdb_id INTEGER, name TEXT)",
|
||||
)
|
||||
.execute(&pool).await.unwrap();
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("CREATE TABLE movie_genres (movie_id TEXT, tmdb_id INTEGER, name TEXT)")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"CREATE VIRTUAL TABLE movies_fts USING fts5(
|
||||
movie_id UNINDEXED, title, director, overview, genres, keywords,
|
||||
cast_names, crew_names, release_year UNINDEXED, language UNINDEXED)",
|
||||
)
|
||||
.execute(&pool).await.unwrap();
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"CREATE VIRTUAL TABLE people_fts USING fts5(
|
||||
person_id UNINDEXED, name, known_for_department UNINDEXED)",
|
||||
)
|
||||
.execute(&pool).await.unwrap();
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
pool
|
||||
}
|
||||
|
||||
@@ -72,18 +80,32 @@ async fn index_and_search_movie_by_title() {
|
||||
let movie_id = movie.id().clone();
|
||||
|
||||
sqlx::query("INSERT INTO movies VALUES (?, ?, ?, ?, ?, ?)")
|
||||
.bind(id_str).bind("Interstellar").bind(2014i32)
|
||||
.bind("Christopher Nolan").bind::<Option<String>>(None).bind::<Option<String>>(None)
|
||||
.execute(&pool).await.unwrap();
|
||||
.bind(id_str)
|
||||
.bind("Interstellar")
|
||||
.bind(2014i32)
|
||||
.bind("Christopher Nolan")
|
||||
.bind::<Option<String>>(None)
|
||||
.bind::<Option<String>>(None)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
cmd.index(IndexableDocument::Movie { id: movie_id.clone(), movie: Box::new(movie), profile: None })
|
||||
.await.unwrap();
|
||||
cmd.index(IndexableDocument::Movie {
|
||||
id: movie_id.clone(),
|
||||
movie: Box::new(movie),
|
||||
profile: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let results = query.search(&SearchQuery {
|
||||
text: Some("Interstellar".to_string()),
|
||||
filters: SearchFilters::default(),
|
||||
page: default_page(),
|
||||
}).await.unwrap();
|
||||
let results = query
|
||||
.search(&SearchQuery {
|
||||
text: Some("Interstellar".to_string()),
|
||||
filters: SearchFilters::default(),
|
||||
page: default_page(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(results.movies.items.len(), 1);
|
||||
assert_eq!(results.movies.items[0].title, "Interstellar");
|
||||
@@ -99,19 +121,33 @@ async fn remove_movie_clears_from_index() {
|
||||
let movie_id = movie.id().clone();
|
||||
|
||||
sqlx::query("INSERT INTO movies VALUES (?, ?, ?, ?, ?, ?)")
|
||||
.bind(id_str).bind("Inception").bind(2010i32)
|
||||
.bind("Christopher Nolan").bind::<Option<String>>(None).bind::<Option<String>>(None)
|
||||
.execute(&pool).await.unwrap();
|
||||
.bind(id_str)
|
||||
.bind("Inception")
|
||||
.bind(2010i32)
|
||||
.bind("Christopher Nolan")
|
||||
.bind::<Option<String>>(None)
|
||||
.bind::<Option<String>>(None)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
cmd.index(IndexableDocument::Movie { id: movie_id.clone(), movie: Box::new(movie), profile: None })
|
||||
.await.unwrap();
|
||||
cmd.index(IndexableDocument::Movie {
|
||||
id: movie_id.clone(),
|
||||
movie: Box::new(movie),
|
||||
profile: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
cmd.remove(EntityType::Movie, id_str).await.unwrap();
|
||||
|
||||
let results = query.search(&SearchQuery {
|
||||
text: Some("Inception".to_string()),
|
||||
filters: SearchFilters::default(),
|
||||
page: default_page(),
|
||||
}).await.unwrap();
|
||||
let results = query
|
||||
.search(&SearchQuery {
|
||||
text: Some("Inception".to_string()),
|
||||
filters: SearchFilters::default(),
|
||||
page: default_page(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(results.movies.items.is_empty());
|
||||
}
|
||||
@@ -126,32 +162,54 @@ async fn search_with_genre_filter() {
|
||||
let movie_id = movie.id().clone();
|
||||
|
||||
sqlx::query("INSERT INTO movies VALUES (?, ?, ?, ?, ?, ?)")
|
||||
.bind(id_str).bind("The Dark Knight").bind(2008i32)
|
||||
.bind("Christopher Nolan").bind::<Option<String>>(None).bind::<Option<String>>(None)
|
||||
.execute(&pool).await.unwrap();
|
||||
.bind(id_str)
|
||||
.bind("The Dark Knight")
|
||||
.bind(2008i32)
|
||||
.bind("Christopher Nolan")
|
||||
.bind::<Option<String>>(None)
|
||||
.bind::<Option<String>>(None)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO movie_genres VALUES (?, 1, 'Action')")
|
||||
.bind(id_str)
|
||||
.execute(&pool).await.unwrap();
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
cmd.index(IndexableDocument::Movie {
|
||||
id: movie_id.clone(),
|
||||
movie: Box::new(movie),
|
||||
profile: None,
|
||||
}).await.unwrap();
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Matching genre — no text filter
|
||||
let results = query.search(&SearchQuery {
|
||||
text: None,
|
||||
filters: SearchFilters { genre: Some("Action".to_string()), ..Default::default() },
|
||||
page: default_page(),
|
||||
}).await.unwrap();
|
||||
let results = query
|
||||
.search(&SearchQuery {
|
||||
text: None,
|
||||
filters: SearchFilters {
|
||||
genre: Some("Action".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
page: default_page(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(results.movies.items.len(), 1);
|
||||
|
||||
// Non-matching genre
|
||||
let results = query.search(&SearchQuery {
|
||||
text: None,
|
||||
filters: SearchFilters { genre: Some("Comedy".to_string()), ..Default::default() },
|
||||
page: default_page(),
|
||||
}).await.unwrap();
|
||||
let results = query
|
||||
.search(&SearchQuery {
|
||||
text: None,
|
||||
filters: SearchFilters {
|
||||
genre: Some("Comedy".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
page: default_page(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(results.movies.items.is_empty());
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{errors::DomainError, ports::{ImageRefCommand, ImageRefQuery}};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
ports::{ImageRefCommand, ImageRefQuery},
|
||||
};
|
||||
use sqlx::SqlitePool;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -15,23 +18,34 @@ impl SqliteImageRefAdapter {
|
||||
|
||||
pub fn create_image_ref(pool: SqlitePool) -> (Arc<dyn ImageRefCommand>, Arc<dyn ImageRefQuery>) {
|
||||
let adapter = Arc::new(SqliteImageRefAdapter::new(pool));
|
||||
(Arc::clone(&adapter) as Arc<dyn ImageRefCommand>, adapter as Arc<dyn ImageRefQuery>)
|
||||
(
|
||||
Arc::clone(&adapter) as Arc<dyn ImageRefCommand>,
|
||||
adapter as Arc<dyn ImageRefQuery>,
|
||||
)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ImageRefCommand for SqliteImageRefAdapter {
|
||||
async fn swap(&self, old_key: &str, new_key: &str) -> Result<(), DomainError> {
|
||||
let mut tx = self.pool.begin().await
|
||||
let mut tx = self
|
||||
.pool
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
sqlx::query("UPDATE users SET avatar_path = ? WHERE avatar_path = ?")
|
||||
.bind(new_key).bind(old_key)
|
||||
.execute(&mut *tx).await
|
||||
.bind(new_key)
|
||||
.bind(old_key)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
sqlx::query("UPDATE movies SET poster_path = ? WHERE poster_path = ?")
|
||||
.bind(new_key).bind(old_key)
|
||||
.execute(&mut *tx).await
|
||||
.bind(new_key)
|
||||
.bind(old_key)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
tx.commit().await
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,12 +14,20 @@ use sqlx::SqlitePool;
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
enum DomainFieldJson {
|
||||
Title, ReleaseYear, Director, Rating, WatchedAt, Comment, ExternalMetadataId,
|
||||
Title,
|
||||
ReleaseYear,
|
||||
Director,
|
||||
Rating,
|
||||
WatchedAt,
|
||||
Comment,
|
||||
ExternalMetadataId,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
enum TransformJson {
|
||||
RatingScale(f64), DateFormat(String), Identity,
|
||||
RatingScale(f64),
|
||||
DateFormat(String),
|
||||
Identity,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
@@ -75,8 +83,8 @@ fn serialize_mappings(ms: &[FieldMapping]) -> Result<String, DomainError> {
|
||||
}
|
||||
|
||||
fn deserialize_mappings(s: &str) -> Result<Vec<FieldMapping>, DomainError> {
|
||||
let js: Vec<FieldMappingJson> = serde_json::from_str(s)
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
let js: Vec<FieldMappingJson> =
|
||||
serde_json::from_str(s).map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(js.into_iter().map(mapping_from_json).collect())
|
||||
}
|
||||
|
||||
@@ -85,7 +93,9 @@ pub struct SqliteImportProfileRepository {
|
||||
}
|
||||
|
||||
impl SqliteImportProfileRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self { Self { pool } }
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
fn map_err(e: sqlx::Error) -> DomainError {
|
||||
tracing::error!("DB error: {:?}", e);
|
||||
@@ -95,7 +105,9 @@ impl SqliteImportProfileRepository {
|
||||
fn parse_dt(s: &str) -> Result<NaiveDateTime, DomainError> {
|
||||
NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S")
|
||||
.or_else(|_| NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S"))
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("invalid datetime '{}': {}", s, e)))
|
||||
.map_err(|e| {
|
||||
DomainError::InfrastructureError(format!("invalid datetime '{}': {}", s, e))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +121,11 @@ impl ImportProfileRepository for SqliteImportProfileRepository {
|
||||
sqlx::query!(
|
||||
"INSERT OR REPLACE INTO import_profiles (id, user_id, name, field_mappings, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)",
|
||||
id, user_id, p.name, field_mappings, created_at
|
||||
id,
|
||||
user_id,
|
||||
p.name,
|
||||
field_mappings,
|
||||
created_at
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
@@ -127,18 +143,31 @@ impl ImportProfileRepository for SqliteImportProfileRepository {
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
|
||||
rows.into_iter().map(|r| {
|
||||
Ok(ImportProfile {
|
||||
id: ImportProfileId::from_uuid(r.id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))?),
|
||||
user_id: UserId::from_uuid(r.user_id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))?),
|
||||
name: r.name,
|
||||
field_mappings: deserialize_mappings(&r.field_mappings)?,
|
||||
created_at: Self::parse_dt(&r.created_at)?,
|
||||
rows.into_iter()
|
||||
.map(|r| {
|
||||
Ok(ImportProfile {
|
||||
id: ImportProfileId::from_uuid(
|
||||
r.id.parse::<uuid::Uuid>()
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
||||
),
|
||||
user_id: UserId::from_uuid(
|
||||
r.user_id
|
||||
.parse::<uuid::Uuid>()
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
||||
),
|
||||
name: r.name,
|
||||
field_mappings: deserialize_mappings(&r.field_mappings)?,
|
||||
created_at: Self::parse_dt(&r.created_at)?,
|
||||
})
|
||||
})
|
||||
}).collect()
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn get(&self, id: &ImportProfileId, user_id: &UserId) -> Result<Option<ImportProfile>, DomainError> {
|
||||
async fn get(
|
||||
&self,
|
||||
id: &ImportProfileId,
|
||||
user_id: &UserId,
|
||||
) -> Result<Option<ImportProfile>, DomainError> {
|
||||
let id_str = id.value().to_string();
|
||||
let uid_str = user_id.value().to_string();
|
||||
let row = sqlx::query!(
|
||||
@@ -151,13 +180,21 @@ impl ImportProfileRepository for SqliteImportProfileRepository {
|
||||
|
||||
row.map(|r| {
|
||||
Ok(ImportProfile {
|
||||
id: ImportProfileId::from_uuid(r.id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))?),
|
||||
user_id: UserId::from_uuid(r.user_id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))?),
|
||||
id: ImportProfileId::from_uuid(
|
||||
r.id.parse::<uuid::Uuid>()
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
||||
),
|
||||
user_id: UserId::from_uuid(
|
||||
r.user_id
|
||||
.parse::<uuid::Uuid>()
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
||||
),
|
||||
name: r.name,
|
||||
field_mappings: deserialize_mappings(&r.field_mappings)?,
|
||||
created_at: Self::parse_dt(&r.created_at)?,
|
||||
})
|
||||
}).transpose()
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &ImportProfileId) -> Result<(), DomainError> {
|
||||
|
||||
@@ -22,7 +22,13 @@ struct ParsedFileJson {
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
enum DomainFieldJson {
|
||||
Title, ReleaseYear, Director, Rating, WatchedAt, Comment, ExternalMetadataId,
|
||||
Title,
|
||||
ReleaseYear,
|
||||
Director,
|
||||
Rating,
|
||||
WatchedAt,
|
||||
Comment,
|
||||
ExternalMetadataId,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
@@ -41,19 +47,29 @@ struct FieldMappingJson {
|
||||
|
||||
#[derive(Serialize, Deserialize, Default)]
|
||||
struct ImportRowJson {
|
||||
#[serde(skip_serializing_if = "Option::is_none")] title: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")] release_year: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")] director: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")] rating: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")] watched_at: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")] comment: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")] external_metadata_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
title: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
release_year: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
director: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
rating: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
watched_at: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
comment: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
external_metadata_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
enum RowResultJson {
|
||||
Valid(ImportRowJson),
|
||||
Invalid { errors: Vec<String>, raw: Vec<(String, String)> },
|
||||
Invalid {
|
||||
errors: Vec<String>,
|
||||
raw: Vec<(String, String)>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
@@ -182,7 +198,9 @@ pub struct SqliteImportSessionRepository {
|
||||
}
|
||||
|
||||
impl SqliteImportSessionRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self { Self { pool } }
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
fn map_err(e: sqlx::Error) -> DomainError {
|
||||
tracing::error!("DB error: {:?}", e);
|
||||
@@ -192,18 +210,33 @@ impl SqliteImportSessionRepository {
|
||||
fn parse_dt(s: &str) -> Result<NaiveDateTime, DomainError> {
|
||||
NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S")
|
||||
.or_else(|_| NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S"))
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("invalid datetime '{}': {}", s, e)))
|
||||
.map_err(|e| {
|
||||
DomainError::InfrastructureError(format!("invalid datetime '{}': {}", s, e))
|
||||
})
|
||||
}
|
||||
|
||||
fn serialize_session(s: &ImportSession) -> Result<(String, Option<String>, Option<String>), DomainError> {
|
||||
let parsed = s.parsed_file.as_ref()
|
||||
.map(|f| ser(&ParsedFileJson { columns: f.columns.clone(), rows: f.rows.clone() }))
|
||||
fn serialize_session(
|
||||
s: &ImportSession,
|
||||
) -> Result<(String, Option<String>, Option<String>), DomainError> {
|
||||
let parsed = s
|
||||
.parsed_file
|
||||
.as_ref()
|
||||
.map(|f| {
|
||||
ser(&ParsedFileJson {
|
||||
columns: f.columns.clone(),
|
||||
rows: f.rows.clone(),
|
||||
})
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
let mappings = s.field_mappings.as_ref()
|
||||
let mappings = s
|
||||
.field_mappings
|
||||
.as_ref()
|
||||
.map(|ms| ser(&ms.iter().map(mapping_to_json).collect::<Vec<_>>()))
|
||||
.transpose()?;
|
||||
let results = s.row_results.as_ref()
|
||||
let results = s
|
||||
.row_results
|
||||
.as_ref()
|
||||
.map(|rs| ser(&rs.iter().map(annotated_to_json).collect::<Vec<_>>()))
|
||||
.transpose()?;
|
||||
Ok((parsed, mappings, results))
|
||||
@@ -222,15 +255,20 @@ impl SqliteImportSessionRepository {
|
||||
None
|
||||
} else {
|
||||
let j: ParsedFileJson = de(&parsed_data)?;
|
||||
Some(ParsedFile { columns: j.columns, rows: j.rows })
|
||||
Some(ParsedFile {
|
||||
columns: j.columns,
|
||||
rows: j.rows,
|
||||
})
|
||||
};
|
||||
let field_mappings = field_mappings.as_deref()
|
||||
let field_mappings = field_mappings
|
||||
.as_deref()
|
||||
.map(|s| -> Result<Vec<FieldMapping>, DomainError> {
|
||||
let js: Vec<FieldMappingJson> = de(s)?;
|
||||
Ok(js.into_iter().map(mapping_from_json).collect())
|
||||
})
|
||||
.transpose()?;
|
||||
let row_results = row_results.as_deref()
|
||||
let row_results = row_results
|
||||
.as_deref()
|
||||
.map(|s| -> Result<Vec<AnnotatedRow>, DomainError> {
|
||||
let js: Vec<AnnotatedRowJson> = de(s)?;
|
||||
Ok(js.into_iter().map(annotated_from_json).collect())
|
||||
@@ -239,10 +277,13 @@ impl SqliteImportSessionRepository {
|
||||
|
||||
Ok(ImportSession {
|
||||
id: ImportSessionId::from_uuid(
|
||||
id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))?
|
||||
id.parse::<uuid::Uuid>()
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
||||
),
|
||||
user_id: UserId::from_uuid(
|
||||
user_id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))?
|
||||
user_id
|
||||
.parse::<uuid::Uuid>()
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
||||
),
|
||||
parsed_file,
|
||||
field_mappings,
|
||||
@@ -272,22 +313,35 @@ impl ImportSessionRepository for SqliteImportSessionRepository {
|
||||
.map_err(Self::map_err)
|
||||
}
|
||||
|
||||
async fn get(&self, id: &ImportSessionId, user_id: &UserId) -> Result<Option<ImportSession>, DomainError> {
|
||||
async fn get(
|
||||
&self,
|
||||
id: &ImportSessionId,
|
||||
user_id: &UserId,
|
||||
) -> Result<Option<ImportSession>, DomainError> {
|
||||
let id_str = id.value().to_string();
|
||||
let uid_str = user_id.value().to_string();
|
||||
let row = sqlx::query!(
|
||||
"SELECT id, user_id, parsed_data, field_mappings, row_results, created_at, expires_at
|
||||
FROM import_sessions WHERE id = ? AND user_id = ?",
|
||||
id_str, uid_str
|
||||
id_str,
|
||||
uid_str
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
|
||||
row.map(|r| Self::deserialize_session(
|
||||
r.id, r.user_id, r.parsed_data, r.field_mappings, r.row_results,
|
||||
&r.created_at, &r.expires_at,
|
||||
)).transpose()
|
||||
row.map(|r| {
|
||||
Self::deserialize_session(
|
||||
r.id,
|
||||
r.user_id,
|
||||
r.parsed_data,
|
||||
r.field_mappings,
|
||||
r.row_results,
|
||||
&r.created_at,
|
||||
&r.expires_at,
|
||||
)
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn update(&self, s: &ImportSession) -> Result<(), DomainError> {
|
||||
@@ -295,7 +349,9 @@ impl ImportSessionRepository for SqliteImportSessionRepository {
|
||||
let (_, field_mappings, row_results) = Self::serialize_session(s)?;
|
||||
sqlx::query!(
|
||||
"UPDATE import_sessions SET field_mappings = ?, row_results = ? WHERE id = ?",
|
||||
field_mappings, row_results, id
|
||||
field_mappings,
|
||||
row_results,
|
||||
id
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
@@ -322,10 +378,13 @@ impl ImportSessionRepository for SqliteImportSessionRepository {
|
||||
|
||||
async fn delete_expired_for_user(&self, user_id: &UserId) -> Result<(), DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
sqlx::query!("DELETE FROM import_sessions WHERE user_id = ? AND expires_at < datetime('now')", uid)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(Self::map_err)
|
||||
sqlx::query!(
|
||||
"DELETE FROM import_sessions WHERE user_id = ? AND expires_at < datetime('now')",
|
||||
uid
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(Self::map_err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,11 +402,15 @@ impl MovieRepository for SqliteMovieRepository {
|
||||
&self,
|
||||
page: &domain::models::collections::PageParams,
|
||||
filter: &domain::models::MovieFilter,
|
||||
) -> Result<domain::models::collections::Paginated<domain::models::MovieSummary>, DomainError> {
|
||||
) -> Result<domain::models::collections::Paginated<domain::models::MovieSummary>, DomainError>
|
||||
{
|
||||
use sqlx::Row;
|
||||
let limit = page.limit as i64;
|
||||
let offset = page.offset as i64;
|
||||
let pattern = filter.search.as_deref().map(|s| format!("%{}%", s.to_lowercase()));
|
||||
let pattern = filter
|
||||
.search
|
||||
.as_deref()
|
||||
.map(|s| format!("%{}%", s.to_lowercase()));
|
||||
let genre = filter.genre.as_deref();
|
||||
let language = filter.language.as_deref();
|
||||
|
||||
@@ -694,10 +698,7 @@ impl DiaryRepository for SqliteMovieRepository {
|
||||
}
|
||||
|
||||
let count_q = bind_filter_params!(sqlx::query_scalar::<_, i64>(&count_sql));
|
||||
let total = count_q
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
let total = count_q.fetch_one(&self.pool).await.map_err(Self::map_err)?;
|
||||
|
||||
let rows_q = bind_filter_params!(sqlx::query_as::<_, FeedRow>(&select_sql));
|
||||
let rows = rows_q
|
||||
@@ -800,13 +801,10 @@ impl DiaryRepository for SqliteMovieRepository {
|
||||
let limit = page.limit as i64;
|
||||
let offset = page.offset as i64;
|
||||
|
||||
let total = sqlx::query_scalar!(
|
||||
"SELECT COUNT(*) FROM reviews WHERE movie_id = ?",
|
||||
id_str
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
let total = sqlx::query_scalar!("SELECT COUNT(*) FROM reviews WHERE movie_id = ?", id_str)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
|
||||
let rows = sqlx::query_as::<_, FeedRow>(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
@@ -843,12 +841,11 @@ impl DiaryRepository for SqliteMovieRepository {
|
||||
}
|
||||
|
||||
async fn count_local_posts(&self) -> Result<u64, DomainError> {
|
||||
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)?;
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -934,7 +931,9 @@ impl StatsRepository for SqliteMovieRepository {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn wire(database_url: &str) -> anyhow::Result<(
|
||||
pub async fn wire(
|
||||
database_url: &str,
|
||||
) -> anyhow::Result<(
|
||||
sqlx::SqlitePool,
|
||||
std::sync::Arc<dyn domain::ports::MovieRepository>,
|
||||
std::sync::Arc<dyn domain::ports::ReviewRepository>,
|
||||
@@ -946,9 +945,9 @@ pub async fn wire(database_url: &str) -> anyhow::Result<(
|
||||
std::sync::Arc<dyn domain::ports::MovieProfileRepository>,
|
||||
std::sync::Arc<dyn domain::ports::WatchlistRepository>,
|
||||
)> {
|
||||
use std::str::FromStr;
|
||||
use anyhow::Context;
|
||||
use sqlx::sqlite::SqliteConnectOptions;
|
||||
use std::str::FromStr;
|
||||
|
||||
let opts = SqliteConnectOptions::from_str(database_url)
|
||||
.context("Invalid DATABASE_URL")?
|
||||
@@ -1073,8 +1072,9 @@ mod feed_filter_tests {
|
||||
let repo = SqliteMovieRepository::new(pool);
|
||||
|
||||
let filter = FollowingFilter {
|
||||
local_user_ids: vec![uuid::Uuid::parse_str("11111111-1111-1111-1111-111111111111")
|
||||
.unwrap()],
|
||||
local_user_ids: vec![
|
||||
uuid::Uuid::parse_str("11111111-1111-1111-1111-111111111111").unwrap(),
|
||||
],
|
||||
remote_actor_urls: vec!["https://remote.social/users/carol".to_string()],
|
||||
};
|
||||
let page = PageParams::new(Some(10), Some(0)).unwrap();
|
||||
@@ -1147,7 +1147,10 @@ mod feed_filter_tests {
|
||||
assert_eq!(result.total_count, 1);
|
||||
assert_eq!(result.items.len(), 1);
|
||||
assert!(result.items[0].review().is_remote());
|
||||
assert_eq!(result.items[0].user_email(), "https://remote.social/users/carol");
|
||||
assert_eq!(
|
||||
result.items[0].user_email(),
|
||||
"https://remote.social/users/carol"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1209,8 +1212,12 @@ mod diary_count_tests {
|
||||
.bind(&user_id).bind("a@b.com").bind("hash").bind("2024-01-01 00:00:00").bind("alice")
|
||||
.execute(&pool).await.unwrap();
|
||||
sqlx::query("INSERT INTO movies (id, title, release_year) VALUES (?, ?, ?)")
|
||||
.bind(&movie_id).bind("Test Movie").bind(2024i32)
|
||||
.execute(&pool).await.unwrap();
|
||||
.bind(&movie_id)
|
||||
.bind("Test Movie")
|
||||
.bind(2024i32)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Local review (remote_actor_url IS NULL)
|
||||
let r1 = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
use chrono::NaiveDateTime;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{DiaryEntry, FeedEntry, Movie, MovieSummary, Review, ReviewSource, UserSummary, WatchlistEntry, WatchlistWithMovie},
|
||||
models::{
|
||||
DiaryEntry, FeedEntry, Movie, MovieSummary, Review, ReviewSource, UserSummary,
|
||||
WatchlistEntry, WatchlistWithMovie,
|
||||
},
|
||||
value_objects::{
|
||||
Comment, Email, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
|
||||
ReviewId, UserId, WatchlistEntryId,
|
||||
|
||||
@@ -20,7 +20,10 @@ impl SqlitePersonAdapter {
|
||||
|
||||
pub fn create_person_adapter(pool: SqlitePool) -> (Arc<dyn PersonCommand>, Arc<dyn PersonQuery>) {
|
||||
let adapter = Arc::new(SqlitePersonAdapter::new(pool));
|
||||
(Arc::clone(&adapter) as Arc<dyn PersonCommand>, adapter as Arc<dyn PersonQuery>)
|
||||
(
|
||||
Arc::clone(&adapter) as Arc<dyn PersonCommand>,
|
||||
adapter as Arc<dyn PersonQuery>,
|
||||
)
|
||||
}
|
||||
|
||||
fn map_err(e: sqlx::Error) -> DomainError {
|
||||
@@ -70,7 +73,10 @@ impl PersonQuery for SqlitePersonAdapter {
|
||||
Ok(row.map(PersonRow::into_person))
|
||||
}
|
||||
|
||||
async fn get_by_external_id(&self, id: &ExternalPersonId) -> Result<Option<Person>, DomainError> {
|
||||
async fn get_by_external_id(
|
||||
&self,
|
||||
id: &ExternalPersonId,
|
||||
) -> Result<Option<Person>, DomainError> {
|
||||
let row = sqlx::query_as::<_, PersonRow>(
|
||||
"SELECT id, external_id, name, known_for_department, profile_path FROM persons WHERE external_id = ?",
|
||||
)
|
||||
@@ -83,21 +89,25 @@ impl PersonQuery for SqlitePersonAdapter {
|
||||
}
|
||||
|
||||
async fn get_credits(&self, id: &PersonId) -> Result<PersonCredits, DomainError> {
|
||||
let person = self.get_by_id(id).await?.ok_or_else(|| {
|
||||
DomainError::NotFound(format!("Person {} not found", id.value()))
|
||||
})?;
|
||||
let person = self
|
||||
.get_by_id(id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound(format!("Person {} not found", id.value())))?;
|
||||
|
||||
let tmdb_id: Option<i64> = sqlx::query_scalar(
|
||||
"SELECT tmdb_person_id FROM persons WHERE id = ?",
|
||||
)
|
||||
.bind(id.value().to_string())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?
|
||||
.flatten();
|
||||
let tmdb_id: Option<i64> =
|
||||
sqlx::query_scalar("SELECT tmdb_person_id FROM persons WHERE id = ?")
|
||||
.bind(id.value().to_string())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?
|
||||
.flatten();
|
||||
|
||||
let Some(tmdb_id) = tmdb_id else {
|
||||
return Ok(PersonCredits { person, cast: vec![], crew: vec![] });
|
||||
return Ok(PersonCredits {
|
||||
person,
|
||||
cast: vec![],
|
||||
crew: vec![],
|
||||
});
|
||||
};
|
||||
|
||||
let cast = sqlx::query_as::<_, CastRow>(
|
||||
|
||||
@@ -66,48 +66,80 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
|
||||
|
||||
sqlx::query("DELETE FROM movie_genres WHERE movie_id = ?")
|
||||
.bind(&movie_id)
|
||||
.execute(&mut *tx).await.map_err(Self::map_err)?;
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
for g in &p.genres {
|
||||
sqlx::query("INSERT OR IGNORE INTO movie_genres (movie_id, tmdb_id, name) VALUES (?,?,?)")
|
||||
.bind(&movie_id).bind(g.tmdb_id as i64).bind(&g.name)
|
||||
.execute(&mut *tx).await.map_err(Self::map_err)?;
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO movie_genres (movie_id, tmdb_id, name) VALUES (?,?,?)",
|
||||
)
|
||||
.bind(&movie_id)
|
||||
.bind(g.tmdb_id as i64)
|
||||
.bind(&g.name)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM movie_keywords WHERE movie_id = ?")
|
||||
.bind(&movie_id)
|
||||
.execute(&mut *tx).await.map_err(Self::map_err)?;
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
for k in &p.keywords {
|
||||
sqlx::query("INSERT OR IGNORE INTO movie_keywords (movie_id, tmdb_id, name) VALUES (?,?,?)")
|
||||
.bind(&movie_id).bind(k.tmdb_id as i64).bind(&k.name)
|
||||
.execute(&mut *tx).await.map_err(Self::map_err)?;
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO movie_keywords (movie_id, tmdb_id, name) VALUES (?,?,?)",
|
||||
)
|
||||
.bind(&movie_id)
|
||||
.bind(k.tmdb_id as i64)
|
||||
.bind(&k.name)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM movie_cast WHERE movie_id = ?")
|
||||
.bind(&movie_id)
|
||||
.execute(&mut *tx).await.map_err(Self::map_err)?;
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
for c in &p.cast {
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO movie_cast \
|
||||
(movie_id, tmdb_person_id, name, character, billing_order, profile_path) \
|
||||
VALUES (?,?,?,?,?,?)",
|
||||
)
|
||||
.bind(&movie_id).bind(c.tmdb_person_id as i64).bind(&c.name)
|
||||
.bind(&c.character).bind(c.billing_order as i64).bind(&c.profile_path)
|
||||
.execute(&mut *tx).await.map_err(Self::map_err)?;
|
||||
.bind(&movie_id)
|
||||
.bind(c.tmdb_person_id as i64)
|
||||
.bind(&c.name)
|
||||
.bind(&c.character)
|
||||
.bind(c.billing_order as i64)
|
||||
.bind(&c.profile_path)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM movie_crew WHERE movie_id = ?")
|
||||
.bind(&movie_id)
|
||||
.execute(&mut *tx).await.map_err(Self::map_err)?;
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
for cr in &p.crew {
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO movie_crew \
|
||||
(movie_id, tmdb_person_id, name, job, department, profile_path) \
|
||||
VALUES (?,?,?,?,?,?)",
|
||||
)
|
||||
.bind(&movie_id).bind(cr.tmdb_person_id as i64).bind(&cr.name)
|
||||
.bind(&cr.job).bind(&cr.department).bind(&cr.profile_path)
|
||||
.execute(&mut *tx).await.map_err(Self::map_err)?;
|
||||
.bind(&movie_id)
|
||||
.bind(cr.tmdb_person_id as i64)
|
||||
.bind(&cr.name)
|
||||
.bind(&cr.job)
|
||||
.bind(&cr.department)
|
||||
.bind(&cr.profile_path)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
}
|
||||
|
||||
tx.commit().await.map_err(Self::map_err)
|
||||
@@ -132,7 +164,8 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let enriched_at_str: String = row.try_get("enriched_at")
|
||||
let enriched_at_str: String = row
|
||||
.try_get("enriched_at")
|
||||
.map_err(|_| DomainError::InfrastructureError("invalid enriched_at".into()))?;
|
||||
let enriched_at: DateTime<Utc> = enriched_at_str
|
||||
.parse()
|
||||
@@ -140,7 +173,9 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
|
||||
|
||||
let genres = sqlx::query("SELECT tmdb_id, name FROM movie_genres WHERE movie_id = ?")
|
||||
.bind(&movie_id)
|
||||
.fetch_all(&self.pool).await.map_err(Self::map_err)?
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.into_iter()
|
||||
.map(|r| Genre {
|
||||
tmdb_id: r.try_get::<i64, _>("tmdb_id").unwrap_or(0) as u32,
|
||||
@@ -150,7 +185,9 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
|
||||
|
||||
let keywords = sqlx::query("SELECT tmdb_id, name FROM movie_keywords WHERE movie_id = ?")
|
||||
.bind(&movie_id)
|
||||
.fetch_all(&self.pool).await.map_err(Self::map_err)?
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.into_iter()
|
||||
.map(|r| Keyword {
|
||||
tmdb_id: r.try_get::<i64, _>("tmdb_id").unwrap_or(0) as u32,
|
||||
@@ -163,7 +200,9 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
|
||||
FROM movie_cast WHERE movie_id = ? ORDER BY billing_order",
|
||||
)
|
||||
.bind(&movie_id)
|
||||
.fetch_all(&self.pool).await.map_err(Self::map_err)?
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.into_iter()
|
||||
.map(|r| CastMember {
|
||||
tmdb_person_id: r.try_get::<i64, _>("tmdb_person_id").unwrap_or(0) as u64,
|
||||
@@ -179,7 +218,9 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
|
||||
FROM movie_crew WHERE movie_id = ?",
|
||||
)
|
||||
.bind(&movie_id)
|
||||
.fetch_all(&self.pool).await.map_err(Self::map_err)?
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.into_iter()
|
||||
.map(|r| CrewMember {
|
||||
tmdb_person_id: r.try_get::<i64, _>("tmdb_person_id").unwrap_or(0) as u64,
|
||||
@@ -196,11 +237,19 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
|
||||
imdb_id: row.try_get("imdb_id").ok(),
|
||||
overview: row.try_get("overview").ok(),
|
||||
tagline: row.try_get("tagline").ok(),
|
||||
runtime_minutes: row.try_get::<Option<i64>, _>("runtime_minutes").ok().flatten().map(|v| v as u32),
|
||||
runtime_minutes: row
|
||||
.try_get::<Option<i64>, _>("runtime_minutes")
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|v| v as u32),
|
||||
budget_usd: row.try_get("budget_usd").ok(),
|
||||
revenue_usd: row.try_get("revenue_usd").ok(),
|
||||
vote_average: row.try_get("vote_average").ok(),
|
||||
vote_count: row.try_get::<Option<i64>, _>("vote_count").ok().flatten().map(|v| v as u32),
|
||||
vote_count: row
|
||||
.try_get::<Option<i64>, _>("vote_count")
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|v| v as u32),
|
||||
original_language: row.try_get("original_language").ok(),
|
||||
collection_name: row.try_get("collection_name").ok(),
|
||||
genres,
|
||||
|
||||
@@ -2,9 +2,7 @@ use async_trait::async_trait;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::ProfileField,
|
||||
ports::UserProfileFieldsRepository,
|
||||
errors::DomainError, models::ProfileField, ports::UserProfileFieldsRepository,
|
||||
value_objects::UserId,
|
||||
};
|
||||
|
||||
@@ -30,10 +28,20 @@ impl UserProfileFieldsRepository for SqliteProfileFieldsRepository {
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
Ok(rows.into_iter().map(|r| ProfileField { name: r.name, value: r.value }).collect())
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| ProfileField {
|
||||
name: r.name,
|
||||
value: r.value,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn set_fields(&self, user_id: &UserId, fields: Vec<ProfileField>) -> Result<(), DomainError> {
|
||||
async fn set_fields(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
fields: Vec<ProfileField>,
|
||||
) -> Result<(), DomainError> {
|
||||
let id_str = user_id.value().to_string();
|
||||
|
||||
sqlx::query!("DELETE FROM user_profile_fields WHERE user_id = ?", id_str)
|
||||
|
||||
@@ -40,7 +40,9 @@ async fn list_keys_returns_both_avatar_and_poster_paths() {
|
||||
sqlx::query("INSERT INTO users VALUES ('u1','e@e.com','u','h','2024-01-01','standard',NULL,'avatars/u1')")
|
||||
.execute(&pool).await.unwrap();
|
||||
sqlx::query("INSERT INTO movies VALUES ('m1','tt1','Title',2020,'Dir','posters/m1')")
|
||||
.execute(&pool).await.unwrap();
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let adapter = SqliteImageRefAdapter::new(pool);
|
||||
let mut keys = adapter.list_keys().await.unwrap();
|
||||
@@ -54,8 +56,12 @@ async fn list_keys_excludes_nulls() {
|
||||
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
|
||||
setup(&pool).await;
|
||||
|
||||
sqlx::query("INSERT INTO users VALUES ('u1','e@e.com','u','h','2024-01-01','standard',NULL,NULL)")
|
||||
.execute(&pool).await.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO users VALUES ('u1','e@e.com','u','h','2024-01-01','standard',NULL,NULL)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let adapter = SqliteImageRefAdapter::new(pool);
|
||||
assert_eq!(adapter.list_keys().await.unwrap(), Vec::<String>::new());
|
||||
@@ -73,7 +79,9 @@ async fn swap_updates_avatar_path() {
|
||||
adapter.swap("avatars/u1", "avatars/u1.avif").await.unwrap();
|
||||
|
||||
let row: (Option<String>,) = sqlx::query_as("SELECT avatar_path FROM users WHERE id='u1'")
|
||||
.fetch_one(&pool).await.unwrap();
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(row.0.as_deref(), Some("avatars/u1.avif"));
|
||||
}
|
||||
|
||||
@@ -83,13 +91,17 @@ async fn swap_updates_poster_path() {
|
||||
setup(&pool).await;
|
||||
|
||||
sqlx::query("INSERT INTO movies VALUES ('m1','tt1','Title',2020,'Dir','posters/m1')")
|
||||
.execute(&pool).await.unwrap();
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let adapter = SqliteImageRefAdapter::new(pool.clone());
|
||||
adapter.swap("posters/m1", "posters/m1.avif").await.unwrap();
|
||||
|
||||
let row: (Option<String>,) = sqlx::query_as("SELECT poster_path FROM movies WHERE id='m1'")
|
||||
.fetch_one(&pool).await.unwrap();
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(row.0.as_deref(), Some("posters/m1.avif"));
|
||||
}
|
||||
|
||||
@@ -99,5 +111,8 @@ async fn swap_noop_when_key_not_found() {
|
||||
setup(&pool).await;
|
||||
|
||||
let adapter = SqliteImageRefAdapter::new(pool);
|
||||
adapter.swap("missing/key", "missing/key.avif").await.unwrap();
|
||||
adapter
|
||||
.swap("missing/key", "missing/key.avif")
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
@@ -61,11 +61,16 @@ async fn upsert_batch_inserts_persons() {
|
||||
let pool = pool_with_schema().await;
|
||||
let adapter = SqlitePersonAdapter::new(pool.clone());
|
||||
|
||||
let persons = vec![make_person(1, "Alice", Some("Acting")), make_person(2, "Bob", Some("Directing"))];
|
||||
let persons = vec![
|
||||
make_person(1, "Alice", Some("Acting")),
|
||||
make_person(2, "Bob", Some("Directing")),
|
||||
];
|
||||
adapter.upsert_batch(&persons).await.unwrap();
|
||||
|
||||
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM persons")
|
||||
.fetch_one(&pool).await.unwrap();
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count.0, 2);
|
||||
}
|
||||
|
||||
@@ -79,7 +84,9 @@ async fn upsert_batch_is_idempotent() {
|
||||
adapter.upsert_batch(&persons).await.unwrap();
|
||||
|
||||
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM persons")
|
||||
.fetch_one(&pool).await.unwrap();
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count.0, 1);
|
||||
}
|
||||
|
||||
@@ -114,9 +121,13 @@ async fn get_credits_returns_cast_and_crew() {
|
||||
adapter.upsert_batch(&[p.clone()]).await.unwrap();
|
||||
|
||||
sqlx::query("INSERT INTO movies VALUES ('m1', 'The Film', 2020, 'Dir', NULL, NULL)")
|
||||
.execute(&pool).await.unwrap();
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO movie_cast VALUES ('m1', 7, 'Diana', 'Hero', 1, NULL)")
|
||||
.execute(&pool).await.unwrap();
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let credits = adapter.get_credits(p.id()).await.unwrap();
|
||||
assert_eq!(credits.person.name(), "Diana");
|
||||
|
||||
@@ -36,7 +36,7 @@ async fn find_by_id_returns_user_when_found() {
|
||||
let (pool, repo) = setup().await;
|
||||
let id = uuid::Uuid::new_v4();
|
||||
sqlx::query(
|
||||
"INSERT INTO users (id, email, username, password_hash, created_at) VALUES (?, ?, ?, ?, ?)"
|
||||
"INSERT INTO users (id, email, username, password_hash, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(id.to_string())
|
||||
.bind("test@example.com")
|
||||
@@ -88,10 +88,18 @@ async fn update_profile_clears_fields_with_none() {
|
||||
UserRole::Standard,
|
||||
);
|
||||
repo.save(&user).await.unwrap();
|
||||
repo.update_profile(user.id(), Some("bio".to_string()), Some("path".to_string()), None, None)
|
||||
repo.update_profile(
|
||||
user.id(),
|
||||
Some("bio".to_string()),
|
||||
Some("path".to_string()),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
repo.update_profile(user.id(), None, None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
repo.update_profile(user.id(), None, None, None, None).await.unwrap();
|
||||
|
||||
let found = repo.find_by_id(user.id()).await.unwrap().unwrap();
|
||||
assert_eq!(found.bio(), None);
|
||||
|
||||
@@ -177,7 +177,13 @@ impl UserRepository for SqliteUserRepository {
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
let profile_fields = field_rows.into_iter().map(|f| ProfileField { name: f.name, value: f.value }).collect();
|
||||
let profile_fields = field_rows
|
||||
.into_iter()
|
||||
.map(|f| ProfileField {
|
||||
name: f.name,
|
||||
value: f.value,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Self::row_to_user(
|
||||
r.id.unwrap_or_default(),
|
||||
@@ -190,7 +196,8 @@ impl UserRepository for SqliteUserRepository {
|
||||
r.banner_path,
|
||||
r.also_known_as,
|
||||
profile_fields,
|
||||
).map(Some)
|
||||
)
|
||||
.map(Some)
|
||||
}
|
||||
|
||||
async fn update_profile(
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{WatchlistEntry, WatchlistWithMovie, collections::{PageParams, Paginated}},
|
||||
models::{
|
||||
WatchlistEntry, WatchlistWithMovie,
|
||||
collections::{PageParams, Paginated},
|
||||
},
|
||||
ports::WatchlistRepository,
|
||||
value_objects::{MovieId, UserId},
|
||||
};
|
||||
@@ -51,14 +54,13 @@ impl WatchlistRepository for SqliteWatchlistRepository {
|
||||
let uid = user_id.value().to_string();
|
||||
let mid = movie_id.value().to_string();
|
||||
|
||||
let result = sqlx::query(
|
||||
"DELETE FROM watchlist_entries WHERE user_id = ? AND movie_id = ?",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(&mid)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
let result =
|
||||
sqlx::query("DELETE FROM watchlist_entries WHERE user_id = ? AND movie_id = ?")
|
||||
.bind(&uid)
|
||||
.bind(&mid)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DomainError::NotFound(format!(
|
||||
@@ -76,14 +78,13 @@ impl WatchlistRepository for SqliteWatchlistRepository {
|
||||
) -> Result<bool, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let mid = movie_id.value().to_string();
|
||||
let result = sqlx::query(
|
||||
"DELETE FROM watchlist_entries WHERE user_id = ? AND movie_id = ?",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(&mid)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
let result =
|
||||
sqlx::query("DELETE FROM watchlist_entries WHERE user_id = ? AND movie_id = ?")
|
||||
.bind(&uid)
|
||||
.bind(&mid)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
@@ -113,15 +114,13 @@ impl WatchlistRepository for SqliteWatchlistRepository {
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
|
||||
let total: i64 = sqlx::query(
|
||||
"SELECT COUNT(*) FROM watchlist_entries WHERE user_id = ?",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.try_get(0)
|
||||
.unwrap_or(0);
|
||||
let total: i64 = sqlx::query("SELECT COUNT(*) FROM watchlist_entries WHERE user_id = ?")
|
||||
.bind(&uid)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.try_get(0)
|
||||
.unwrap_or(0);
|
||||
|
||||
let items = rows
|
||||
.into_iter()
|
||||
|
||||
@@ -14,7 +14,10 @@ use domain::models::{
|
||||
|
||||
mod filters {
|
||||
#[askama::filter_fn]
|
||||
pub fn poster_src<T: std::fmt::Display>(path: T, _env: &dyn askama::Values) -> askama::Result<String> {
|
||||
pub fn poster_src<T: std::fmt::Display>(
|
||||
path: T,
|
||||
_env: &dyn askama::Values,
|
||||
) -> askama::Result<String> {
|
||||
let p = path.to_string();
|
||||
if p.starts_with("http://") || p.starts_with("https://") {
|
||||
Ok(p)
|
||||
@@ -142,7 +145,8 @@ impl<'a> ActivityFeedTemplate<'a> {
|
||||
format!("sort_by={}", self.sort_by),
|
||||
];
|
||||
if !self.search.is_empty() {
|
||||
let encoded = self.search
|
||||
let encoded = self
|
||||
.search
|
||||
.replace(' ', "+")
|
||||
.replace('#', "%23")
|
||||
.replace('&', "%26")
|
||||
@@ -217,7 +221,8 @@ impl<'a> ProfileTemplate<'a> {
|
||||
format!("sort_by={}", self.sort_by),
|
||||
];
|
||||
if !self.search.is_empty() {
|
||||
let encoded = self.search
|
||||
let encoded = self
|
||||
.search
|
||||
.replace(' ', "+")
|
||||
.replace('#', "%23")
|
||||
.replace('&', "%26")
|
||||
@@ -493,7 +498,8 @@ impl HtmlRenderer for AskamaHtmlRenderer {
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let remote_actors = data.remote_actors
|
||||
let remote_actors = data
|
||||
.remote_actors
|
||||
.into_iter()
|
||||
.map(|a| {
|
||||
let name = a.display_name.unwrap_or_else(|| a.handle.clone());
|
||||
@@ -543,9 +549,7 @@ impl HtmlRenderer for AskamaHtmlRenderer {
|
||||
let total_pages = data
|
||||
.entries
|
||||
.as_ref()
|
||||
.map(|e| {
|
||||
e.total_count.div_ceil(e.limit.max(1) as u64) as u32
|
||||
})
|
||||
.map(|e| e.total_count.div_ceil(e.limit.max(1) as u64) as u32)
|
||||
.unwrap_or(0);
|
||||
let current_page = data.current_offset.checked_div(data.limit).unwrap_or(0);
|
||||
let avg_rating_display = data
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use application::{commands::EnrichMovieCommand, use_cases::enrich_movie};
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use application::{commands::EnrichMovieCommand, use_cases::enrich_movie};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::{CastMember, CrewMember, Genre, Keyword, MovieProfile},
|
||||
ports::{EventHandler, MovieEnrichmentClient, MovieProfileRepository, MovieRepository, PersonCommand, SearchCommand},
|
||||
ports::{
|
||||
EventHandler, MovieEnrichmentClient, MovieProfileRepository, MovieRepository,
|
||||
PersonCommand, SearchCommand,
|
||||
},
|
||||
value_objects::MovieId,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
@@ -21,40 +24,56 @@ pub struct TmdbEnrichmentClient {
|
||||
|
||||
impl TmdbEnrichmentClient {
|
||||
pub fn from_env() -> Result<Self, DomainError> {
|
||||
let api_key = std::env::var("TMDB_API_KEY").map_err(|_| {
|
||||
DomainError::InfrastructureError("TMDB_API_KEY is not set".into())
|
||||
})?;
|
||||
Ok(Self { api_key, http: reqwest::Client::new() })
|
||||
let api_key = std::env::var("TMDB_API_KEY")
|
||||
.map_err(|_| DomainError::InfrastructureError("TMDB_API_KEY is not set".into()))?;
|
||||
Ok(Self {
|
||||
api_key,
|
||||
http: reqwest::Client::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn base(&self, path: &str) -> String {
|
||||
format!("https://api.themoviedb.org/3{}", path)
|
||||
}
|
||||
|
||||
async fn get<T: for<'de> Deserialize<'de>>(&self, url: &str, extra: &[(&str, &str)]) -> Result<T, DomainError> {
|
||||
let mut req = self.http.get(url).query(&[("api_key", self.api_key.as_str())]);
|
||||
async fn get<T: for<'de> Deserialize<'de>>(
|
||||
&self,
|
||||
url: &str,
|
||||
extra: &[(&str, &str)],
|
||||
) -> Result<T, DomainError> {
|
||||
let mut req = self
|
||||
.http
|
||||
.get(url)
|
||||
.query(&[("api_key", self.api_key.as_str())]);
|
||||
for (k, v) in extra {
|
||||
req = req.query(&[(k, v)]);
|
||||
}
|
||||
req.send().await
|
||||
req.send()
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
|
||||
.error_for_status()
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
|
||||
.json::<T>().await
|
||||
.json::<T>()
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
||||
}
|
||||
|
||||
async fn resolve_tmdb_id(&self, external_id: &str) -> Result<u64, DomainError> {
|
||||
if let Some(numeric) = external_id.strip_prefix("tmdb:") {
|
||||
return numeric.parse::<u64>()
|
||||
.map_err(|_| DomainError::InfrastructureError(format!("Invalid tmdb id: {numeric}")));
|
||||
return numeric.parse::<u64>().map_err(|_| {
|
||||
DomainError::InfrastructureError(format!("Invalid tmdb id: {numeric}"))
|
||||
});
|
||||
}
|
||||
|
||||
// Assume IMDb ID (tt…) — use /find
|
||||
#[derive(Deserialize)]
|
||||
struct FindResult { id: u64 }
|
||||
struct FindResult {
|
||||
id: u64,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct FindResponse { movie_results: Vec<FindResult> }
|
||||
struct FindResponse {
|
||||
movie_results: Vec<FindResult>,
|
||||
}
|
||||
|
||||
let url = self.base(&format!("/find/{}", external_id));
|
||||
let resp: FindResponse = self.get(&url, &[("external_source", "imdb_id")]).await?;
|
||||
@@ -68,14 +87,23 @@ impl TmdbEnrichmentClient {
|
||||
|
||||
#[async_trait]
|
||||
impl MovieEnrichmentClient for TmdbEnrichmentClient {
|
||||
async fn fetch_profile(&self, movie_id: MovieId, external_metadata_id: &str) -> Result<MovieProfile, DomainError> {
|
||||
async fn fetch_profile(
|
||||
&self,
|
||||
movie_id: MovieId,
|
||||
external_metadata_id: &str,
|
||||
) -> Result<MovieProfile, DomainError> {
|
||||
let tmdb_id = self.resolve_tmdb_id(external_metadata_id).await?;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GenreDto { id: u32, name: String }
|
||||
struct GenreDto {
|
||||
id: u32,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CollectionDto { name: String }
|
||||
struct CollectionDto {
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CastDto {
|
||||
@@ -96,13 +124,21 @@ impl MovieEnrichmentClient for TmdbEnrichmentClient {
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Credits { cast: Vec<CastDto>, crew: Vec<CrewDto> }
|
||||
struct Credits {
|
||||
cast: Vec<CastDto>,
|
||||
crew: Vec<CrewDto>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct KeywordDto { id: u32, name: String }
|
||||
struct KeywordDto {
|
||||
id: u32,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Keywords { keywords: Vec<KeywordDto> }
|
||||
struct Keywords {
|
||||
keywords: Vec<KeywordDto>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Details {
|
||||
@@ -122,7 +158,9 @@ impl MovieEnrichmentClient for TmdbEnrichmentClient {
|
||||
}
|
||||
|
||||
let url = self.base(&format!("/movie/{}", tmdb_id));
|
||||
let d: Details = self.get(&url, &[("append_to_response", "credits,keywords")]).await?;
|
||||
let d: Details = self
|
||||
.get(&url, &[("append_to_response", "credits,keywords")])
|
||||
.await?;
|
||||
|
||||
Ok(MovieProfile {
|
||||
movie_id,
|
||||
@@ -137,24 +175,47 @@ impl MovieEnrichmentClient for TmdbEnrichmentClient {
|
||||
vote_count: d.vote_count,
|
||||
original_language: d.original_language,
|
||||
collection_name: d.belongs_to_collection.map(|c| c.name),
|
||||
genres: d.genres.into_iter().map(|g| Genre { tmdb_id: g.id, name: g.name }).collect(),
|
||||
keywords: d.keywords.keywords.into_iter()
|
||||
.map(|k| Keyword { tmdb_id: k.id, name: k.name })
|
||||
genres: d
|
||||
.genres
|
||||
.into_iter()
|
||||
.map(|g| Genre {
|
||||
tmdb_id: g.id,
|
||||
name: g.name,
|
||||
})
|
||||
.collect(),
|
||||
keywords: d
|
||||
.keywords
|
||||
.keywords
|
||||
.into_iter()
|
||||
.map(|k| Keyword {
|
||||
tmdb_id: k.id,
|
||||
name: k.name,
|
||||
})
|
||||
.collect(),
|
||||
cast: d
|
||||
.credits
|
||||
.cast
|
||||
.into_iter()
|
||||
.map(|c| CastMember {
|
||||
tmdb_person_id: c.id,
|
||||
name: c.name,
|
||||
character: c.character,
|
||||
billing_order: c.order,
|
||||
profile_path: c.profile_path,
|
||||
})
|
||||
.collect(),
|
||||
crew: d
|
||||
.credits
|
||||
.crew
|
||||
.into_iter()
|
||||
.map(|c| CrewMember {
|
||||
tmdb_person_id: c.id,
|
||||
name: c.name,
|
||||
job: c.job,
|
||||
department: c.department,
|
||||
profile_path: c.profile_path,
|
||||
})
|
||||
.collect(),
|
||||
cast: d.credits.cast.into_iter().map(|c| CastMember {
|
||||
tmdb_person_id: c.id,
|
||||
name: c.name,
|
||||
character: c.character,
|
||||
billing_order: c.order,
|
||||
profile_path: c.profile_path,
|
||||
}).collect(),
|
||||
crew: d.credits.crew.into_iter().map(|c| CrewMember {
|
||||
tmdb_person_id: c.id,
|
||||
name: c.name,
|
||||
job: c.job,
|
||||
department: c.department,
|
||||
profile_path: c.profile_path,
|
||||
}).collect(),
|
||||
enriched_at: Utc::now(),
|
||||
})
|
||||
}
|
||||
@@ -164,19 +225,20 @@ impl MovieEnrichmentClient for TmdbEnrichmentClient {
|
||||
|
||||
pub struct EnrichmentHandler {
|
||||
pub enrichment_client: Arc<dyn MovieEnrichmentClient>,
|
||||
pub movie_repository: Arc<dyn MovieRepository>,
|
||||
pub profile_repo: Arc<dyn MovieProfileRepository>,
|
||||
pub person_command: Arc<dyn PersonCommand>,
|
||||
pub search_command: Arc<dyn SearchCommand>,
|
||||
pub movie_repository: Arc<dyn MovieRepository>,
|
||||
pub profile_repo: Arc<dyn MovieProfileRepository>,
|
||||
pub person_command: Arc<dyn PersonCommand>,
|
||||
pub search_command: Arc<dyn SearchCommand>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EventHandler for EnrichmentHandler {
|
||||
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> {
|
||||
let (movie_id, external_metadata_id) = match event {
|
||||
DomainEvent::MovieEnrichmentRequested { movie_id, external_metadata_id } => {
|
||||
(movie_id.clone(), external_metadata_id.clone())
|
||||
}
|
||||
DomainEvent::MovieEnrichmentRequested {
|
||||
movie_id,
|
||||
external_metadata_id,
|
||||
} => (movie_id.clone(), external_metadata_id.clone()),
|
||||
_ => return Ok(()),
|
||||
};
|
||||
|
||||
@@ -195,7 +257,11 @@ impl EventHandler for EnrichmentHandler {
|
||||
|
||||
tracing::info!(movie_id = %movie_id.value(), external_id = %external_metadata_id, "enriching movie");
|
||||
|
||||
match self.enrichment_client.fetch_profile(movie_id.clone(), &external_metadata_id).await {
|
||||
match self
|
||||
.enrichment_client
|
||||
.fetch_profile(movie_id.clone(), &external_metadata_id)
|
||||
.await
|
||||
{
|
||||
Ok(profile) => {
|
||||
enrich_movie::execute(
|
||||
&self.movie_repository,
|
||||
|
||||
Reference in New Issue
Block a user