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

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

View File

@@ -72,7 +72,8 @@ impl Activity for FollowActivity {
let _follower = self.actor.dereference(data).await?;
let 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())

View File

@@ -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(),

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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<()> {

View File

@@ -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"
);
}

View File

@@ -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");
}

View File

@@ -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");

View File

@@ -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(

View File

@@ -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);

View File

@@ -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),

View File

@@ -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 {

View File

@@ -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())

View File

@@ -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]

View File

@@ -15,6 +15,9 @@ pub fn review_url(base_url: &str, review_id: &ReviewId) -> Url {
/// Builds the canonical watchlist entry URL: `{base_url}/users/{user_id}/watchlist/{movie_id}`
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")
}

View File

@@ -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())
}

View File

@@ -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,
}),
}
}
}

View File

@@ -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");
}

View File

@@ -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 {

View File

@@ -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());
}

View File

@@ -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"));

View File

@@ -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
{

View File

@@ -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)

View File

@@ -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>,

View File

@@ -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();

View File

@@ -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);
}

View File

@@ -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());
}

View File

@@ -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)]

View File

@@ -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(_))
));
}

View File

@@ -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()))
}
}
}
}

View File

@@ -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
}
}
},
}
}

View File

@@ -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<_, _>>()?;

View File

@@ -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())
})

View File

@@ -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(),

View File

@@ -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()],

View File

@@ -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 } => {

View File

@@ -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,
})
}
}

View File

@@ -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,
}
}
}

View File

@@ -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",
};

View File

@@ -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()
);
}

View File

@@ -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]

View File

@@ -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(),
)?))
}

View File

@@ -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)
}
}

View File

@@ -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,
}

View File

@@ -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>,

View File

@@ -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,
})
}
}

View File

@@ -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()))
}
}

View File

@@ -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> {

View File

@@ -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> {

View File

@@ -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()));

View File

@@ -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)]

View File

@@ -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,

View File

@@ -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,
};

View File

@@ -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(

View File

@@ -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 })

View File

@@ -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,
}

View File

@@ -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>,

View File

@@ -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());

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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,
})
}
}

View File

@@ -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());
}

View File

@@ -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()))
}
}

View File

@@ -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> {

View File

@@ -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)
}
}

View File

@@ -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();

View File

@@ -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,

View File

@@ -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>(

View File

@@ -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,

View File

@@ -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)

View File

@@ -40,7 +40,9 @@ async fn list_keys_returns_both_avatar_and_poster_paths() {
sqlx::query("INSERT INTO users VALUES ('u1','e@e.com','u','h','2024-01-01','standard',NULL,'avatars/u1')")
.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();
}

View File

@@ -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");

View File

@@ -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);

View File

@@ -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(

View File

@@ -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()

View File

@@ -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

View File

@@ -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,