Compare commits

...

8 Commits

Author SHA1 Message Date
0eb56c2be6 fmt
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-07-10 05:45:44 +02:00
c224cc6bd2 refactor: extract adapter-common crate — shared sqlx error mapping,
row-to-domain conversions, date/uuid utils

Eliminates 39 map_err copies, consolidates parse_uuid/parse_datetime/
datetime_to_str/format_year_month, extracts 7 shared row-to-domain
conversion functions (movie, review, watchlist, stats, user_summary).
Row structs stay per-adapter (FromRow is db-specific), only conversion
logic is shared. Net -281 lines.
2026-07-10 05:44:42 +02:00
6a9b4e5c00 refactor: LOW+MEDIUM cleanups — CQRS DiaryQuery/GoalCommand+GoalQuery,
test-helpers out of production, decompose get_user_profile_html,
dedup secure_flag, remove vestigial social_query, drop
PersistedImportSession, LoginCommand rename, DeleteAccountDeps,
PersonId macro, ReviewSortBy rename, TUI Command for fs::read,
generic PaginatedResponse<T>, noop ports for production fallbacks
2026-07-10 05:29:02 +02:00
5e0dde656c refactor: LOW cleanups — dedup secure_flag, remove vestigial social_query,
drop PersistedImportSession, rename LoginQuery→LoginCommand, own
DeleteAccountDeps, PersonId via uuid_id! macro, rename SortDirection→
ReviewSortBy, TUI fs::read→Command, generic PaginatedResponse<T>
2026-07-10 05:04:35 +02:00
5266646b0b fix: remove unused watch_event_query binding, prefix dead field for clippy -D warnings 2026-07-10 04:32:54 +02:00
2e32847129 docs+fix: update arch diagram/README/Insomnia, ApiError returns JSON
- architecture.mmd: CQRS trait names, ImageFetcher/RssFeedRenderer
  ports, infra-wiring crate, api-types description
- README: add infra-wiring, note CQRS ports
- Insomnia: add PATCH /api/v1/reviews/:id
- ApiError returns {"error":"..."} JSON not plain text
2026-07-10 03:52:49 +02:00
dee013c7eb refactor: remaining MEDIUM — CQRS splits, DI Deps, profile dedup, event Value, response enum
M1: MovieRepository→MovieCommand/MovieQuery, WatchEventRepository→
WatchEventCommand/WatchEventQuery
M2: goals/ and import/ use Deps structs
M7: extract upload_image helper in update_profile
M8: FederationDeliveryRequested activity_json String→serde_json::Value
M11: UserProfileResponse uses ProfileViewData enum
2026-07-10 03:50:43 +02:00
12da356a40 refactor: fix HIGH+MEDIUM architectural violations from code review
HIGH: fix watch_medium data-loss bug, standardize error handling on
ApiError, fix dep direction (rss/template-askama no longer dep on
application), extract ImageFetcher port (remove reqwest from app layer),
move event construction from save_review to use case, extract
infra-wiring crate (DbPool/EventBusBackend dedup), deduplicate
presentation helpers (encode_error, export streaming, multipart parsing)

MEDIUM: split LocalApContentQuery god-trait 10→3 methods, dedup movie
resolution orchestration, add RemoteActorDto/PersonDto mappers, move
AppConfig to infra-wiring, fix SocialQueryPort Uuid→UserId, replace
stringly-typed api-types with domain enums, move count_reviews_in_year
to StatsRepository, dedup event publisher cfg blocks, extract
should_enrich, move group_by_month to application, dedup
count_local_posts, add FederationFlags Default, TUI input helper +
ShowError rename + typed auth errors, api-types cleanup
(UserSettingsDto/UserProfileBase/PreviewRowData)

102 files changed, -681 lines net
2026-07-10 02:08:39 +02:00
250 changed files with 4362 additions and 4219 deletions

34
Cargo.lock generated
View File

@@ -220,6 +220,17 @@ dependencies = [
"url", "url",
] ]
[[package]]
name = "adapter-common"
version = "0.1.0"
dependencies = [
"chrono",
"domain",
"sqlx",
"tracing",
"uuid",
]
[[package]] [[package]]
name = "adler2" name = "adler2"
version = "2.0.1" version = "2.0.1"
@@ -293,6 +304,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
name = "api-types" name = "api-types"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"domain",
"serde", "serde",
"utoipa", "utoipa",
"uuid", "uuid",
@@ -319,8 +331,8 @@ dependencies = [
"domain", "domain",
"futures", "futures",
"hex", "hex",
"infra-wiring",
"rand 0.9.4", "rand 0.9.4",
"reqwest 0.13.3",
"serde_json", "serde_json",
"sha2", "sha2",
"tokio", "tokio",
@@ -1607,6 +1619,7 @@ dependencies = [
"email_address", "email_address",
"futures", "futures",
"serde", "serde",
"serde_json",
"thiserror 2.0.18", "thiserror 2.0.18",
"uuid", "uuid",
] ]
@@ -2701,6 +2714,14 @@ dependencies = [
"cfb", "cfb",
] ]
[[package]]
name = "infra-wiring"
version = "0.1.0"
dependencies = [
"anyhow",
"sqlx",
]
[[package]] [[package]]
name = "inout" name = "inout"
version = "0.1.4" version = "0.1.4"
@@ -3874,6 +3895,7 @@ dependencies = [
name = "postgres" name = "postgres"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"adapter-common",
"anyhow", "anyhow",
"async-stream", "async-stream",
"async-trait", "async-trait",
@@ -3910,6 +3932,7 @@ name = "postgres-federation"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"activitypub", "activitypub",
"adapter-common",
"anyhow", "anyhow",
"async-trait", "async-trait",
"chrono", "chrono",
@@ -3925,6 +3948,7 @@ dependencies = [
name = "postgres-search" name = "postgres-search"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"adapter-common",
"async-trait", "async-trait",
"domain", "domain",
"sqlx", "sqlx",
@@ -3982,6 +4006,7 @@ dependencies = [
"http-body-util", "http-body-util",
"importer", "importer",
"infer", "infer",
"infra-wiring",
"jellyfin", "jellyfin",
"metadata", "metadata",
"nats", "nats",
@@ -4587,7 +4612,6 @@ dependencies = [
name = "rss" name = "rss"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"application",
"domain", "domain",
"rss 2.0.13", "rss 2.0.13",
] ]
@@ -5154,6 +5178,7 @@ dependencies = [
name = "sqlite" name = "sqlite"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"adapter-common",
"anyhow", "anyhow",
"async-stream", "async-stream",
"async-trait", "async-trait",
@@ -5190,6 +5215,7 @@ name = "sqlite-federation"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"activitypub", "activitypub",
"adapter-common",
"anyhow", "anyhow",
"async-trait", "async-trait",
"chrono", "chrono",
@@ -5206,6 +5232,7 @@ dependencies = [
name = "sqlite-search" name = "sqlite-search"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"adapter-common",
"async-trait", "async-trait",
"domain", "domain",
"sqlx", "sqlx",
@@ -5597,7 +5624,7 @@ dependencies = [
name = "template-askama" name = "template-askama"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"application", "api-types",
"askama", "askama",
"chrono", "chrono",
"domain", "domain",
@@ -7127,6 +7154,7 @@ dependencies = [
"export", "export",
"image-converter", "image-converter",
"importer", "importer",
"infra-wiring",
"metadata", "metadata",
"nats", "nats",
"object-storage", "object-storage",

View File

@@ -31,6 +31,8 @@ members = [
"crates/adapters/plex", "crates/adapters/plex",
"crates/adapters/sqlite-search", "crates/adapters/sqlite-search",
"crates/adapters/postgres-search", "crates/adapters/postgres-search",
"crates/adapters/adapter-common",
"crates/infra-wiring",
] ]
resolver = "2" resolver = "2"
@@ -91,6 +93,8 @@ plex = { path = "crates/adapters/plex" }
image-converter = { path = "crates/adapters/image-converter" } image-converter = { path = "crates/adapters/image-converter" }
sqlite-search = { path = "crates/adapters/sqlite-search" } sqlite-search = { path = "crates/adapters/sqlite-search" }
postgres-search = { path = "crates/adapters/postgres-search" } postgres-search = { path = "crates/adapters/postgres-search" }
adapter-common = { path = "crates/adapters/adapter-common" }
infra-wiring = { path = "crates/infra-wiring" }
[profile.dev] [profile.dev]
debug = 1 # line tables only — still debuggable, much faster linking debug = 1 # line tables only — still debuggable, much faster linking

View File

@@ -44,7 +44,9 @@ COPY crates/tui/Cargo.toml crates/tui/Cargo.toml
COPY crates/adapters/image-converter/Cargo.toml crates/adapters/image-converter/Cargo.toml COPY crates/adapters/image-converter/Cargo.toml crates/adapters/image-converter/Cargo.toml
COPY crates/adapters/sqlite-search/Cargo.toml crates/adapters/sqlite-search/Cargo.toml COPY crates/adapters/sqlite-search/Cargo.toml crates/adapters/sqlite-search/Cargo.toml
COPY crates/adapters/postgres-search/Cargo.toml crates/adapters/postgres-search/Cargo.toml COPY crates/adapters/postgres-search/Cargo.toml crates/adapters/postgres-search/Cargo.toml
COPY crates/adapters/adapter-common/Cargo.toml crates/adapters/adapter-common/Cargo.toml
COPY crates/worker/Cargo.toml crates/worker/Cargo.toml COPY crates/worker/Cargo.toml crates/worker/Cargo.toml
COPY crates/infra-wiring/Cargo.toml crates/infra-wiring/Cargo.toml
# Stub every crate so cargo can resolve and fetch deps # Stub every crate so cargo can resolve and fetch deps
RUN find crates -name "Cargo.toml" | sed 's|/Cargo.toml||' | \ RUN find crates -name "Cargo.toml" | sed 's|/Cargo.toml||' | \

View File

@@ -88,8 +88,9 @@ Open `http://localhost:3000`. The HTTP server and background worker start togeth
Hexagonal (Ports & Adapters) with Domain-Driven Design: Hexagonal (Ports & Adapters) with Domain-Driven Design:
``` ```
api-types — shared REST API request/response DTOs (Serialize/Deserialize + utoipa schemas); used by presentation and tui api-types — shared REST API request/response DTOs (Serialize/Deserialize + utoipa schemas) + HtmlPageContext; used by presentation, tui, and template adapters
domain — pure types and trait definitions, no external deps infra-wiring — shared infrastructure types (DbPool, EventBusBackend, AppConfig) used by both presentation and worker binaries
domain — pure types and CQRS port traits (MovieCommand/MovieQuery, WatchEventCommand/WatchEventQuery, PersonCommand/PersonQuery, SearchCommand/SearchPort, ImageFetcher, RssFeedRenderer), no external deps except serde
application — use cases (commands + queries), business logic orchestration; handlers delegate here for all domain logic application — use cases (commands + queries), business logic orchestration; handlers delegate here for all domain logic
presentation — Axum HTTP router, OpenAPI spec assembly, Swagger UI + Scalar serving, composition root for the HTTP process presentation — Axum HTTP router, OpenAPI spec assembly, Swagger UI + Scalar serving, composition root for the HTTP process
worker — standalone worker binary (event consumer, poster sync, federation) worker — standalone worker binary (event consumer, poster sync, federation)

View File

@@ -59,8 +59,8 @@ graph TB
M_SEARCH["SearchQuery,<br/>SearchResults"] M_SEARCH["SearchQuery,<br/>SearchResults"]
end end
subgraph Ports["Port Traits (Interfaces)"] subgraph Ports["Port Traits (Interfaces)"]
P_REPOS["MovieRepository<br/>ReviewRepository<br/>DiaryRepository<br/>UserRepository<br/>WatchlistRepository<br/>WatchEventRepository<br/>WebhookTokenRepository<br/>ImportSessionRepository<br/>MovieProfileRepository<br/>WrapUpRepository<br/>GoalRepository<br/>UserSettingsRepository<br/>MovieDeduplicator"] P_REPOS["MovieCommand / MovieQuery<br/>ReviewRepository<br/>DiaryRepository / StatsRepository<br/>UserRepository<br/>WatchlistRepository<br/>WatchEventCommand / WatchEventQuery<br/>WebhookTokenRepository<br/>ImportSessionRepository<br/>MovieProfileRepository<br/>WrapUpRepository<br/>GoalRepository<br/>UserSettingsRepository<br/>MovieDeduplicator"]
P_SERVICES["AuthService<br/>MetadataClient<br/>PosterFetcherClient<br/>ObjectStorage<br/>EventPublisher<br/>EventConsumer<br/>PasswordHasher<br/>DiaryExporter<br/>DocumentParser"] P_SERVICES["AuthService<br/>MetadataClient<br/>PosterFetcherClient<br/>ImageFetcher<br/>ObjectStorage<br/>EventPublisher<br/>EventConsumer<br/>PasswordHasher<br/>DiaryExporter<br/>DocumentParser<br/>RssFeedRenderer"]
P_SEARCH["SearchPort<br/>SearchCommand<br/>PersonQuery<br/>PersonCommand"] P_SEARCH["SearchPort<br/>SearchCommand<br/>PersonQuery<br/>PersonCommand"]
P_FEDERATION["SocialQueryPort<br/>LocalApContentQuery<br/>RemoteWatchlistRepository<br/>RemoteGoalRepository"] P_FEDERATION["SocialQueryPort<br/>LocalApContentQuery<br/>RemoteWatchlistRepository<br/>RemoteGoalRepository"]
end end
@@ -72,8 +72,12 @@ graph TB
VO["Value Objects<br/><i>MovieId, UserId, Rating,<br/>WatchMedium, Email, Username,<br/>Password, ...</i>"] VO["Value Objects<br/><i>MovieId, UserId, Rating,<br/>WatchMedium, Email, Username,<br/>Password, ...</i>"]
end end
subgraph ApiTypes["api-types (0 domain deps)"] subgraph ApiTypes["api-types"]
DTO["DTOs<br/><i>MovieDto, ReviewDto,<br/>FeedEntryDto, UserSummaryDto,<br/>CastMemberDto, ...</i>"] DTO["DTOs<br/><i>MovieDto, ReviewDto,<br/>FeedEntryDto, UserSummaryDto,<br/>HtmlPageContext, ...</i>"]
end
subgraph InfraWiring["infra-wiring"]
IW["DbPool, EventBusBackend,<br/>AppConfig<br/><i>Shared infra types</i>"]
end end
subgraph Adapters["Adapters (implement Port Traits)"] subgraph Adapters["Adapters (implement Port Traits)"]

View File

@@ -4,7 +4,10 @@ use domain::ports::EventHandler;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
ports::{LocalApContentQuery, UserFederationSettingsQuery}, ports::{
GoalQuery, LocalApContentQuery, MovieQuery, ReviewRepository, StatsRepository,
UserFederationSettingsQuery,
},
value_objects::{MovieId, ReviewId, UserId}, value_objects::{MovieId, ReviewId, UserId},
}; };
use std::sync::Arc; use std::sync::Arc;
@@ -17,20 +20,33 @@ use crate::urls::{actor_url, goal_url, review_url};
pub struct ActivityPubEventHandler { pub struct ActivityPubEventHandler {
ap_service: Arc<ActivityPubService>, ap_service: Arc<ActivityPubService>,
content_query: Arc<dyn LocalApContentQuery>, content_query: Arc<dyn LocalApContentQuery>,
review_repo: Arc<dyn ReviewRepository>,
movie_repo: Arc<dyn MovieQuery>,
goal_repo: Arc<dyn GoalQuery>,
stats_repo: Arc<dyn StatsRepository>,
federation_settings: Arc<dyn UserFederationSettingsQuery>, federation_settings: Arc<dyn UserFederationSettingsQuery>,
base_url: String, base_url: String,
} }
impl ActivityPubEventHandler { impl ActivityPubEventHandler {
#[allow(clippy::too_many_arguments)]
pub fn new( pub fn new(
ap_service: Arc<ActivityPubService>, ap_service: Arc<ActivityPubService>,
content_query: Arc<dyn LocalApContentQuery>, content_query: Arc<dyn LocalApContentQuery>,
review_repo: Arc<dyn ReviewRepository>,
movie_repo: Arc<dyn MovieQuery>,
goal_repo: Arc<dyn GoalQuery>,
stats_repo: Arc<dyn StatsRepository>,
federation_settings: Arc<dyn UserFederationSettingsQuery>, federation_settings: Arc<dyn UserFederationSettingsQuery>,
base_url: String, base_url: String,
) -> Self { ) -> Self {
Self { Self {
ap_service, ap_service,
content_query, content_query,
review_repo,
movie_repo,
goal_repo,
stats_repo,
federation_settings, federation_settings,
base_url, base_url,
} }
@@ -92,12 +108,8 @@ impl EventHandler for ActivityPubEventHandler {
let inbox: url::Url = inbox_url let inbox: url::Url = inbox_url
.parse() .parse()
.map_err(|e| DomainError::InfrastructureError(format!("bad inbox URL: {e}")))?; .map_err(|e| DomainError::InfrastructureError(format!("bad inbox URL: {e}")))?;
let activity: serde_json::Value =
serde_json::from_str(activity_json).map_err(|e| {
DomainError::InfrastructureError(format!("bad activity JSON: {e}"))
})?;
self.ap_service self.ap_service
.deliver_to_inbox(inbox, activity, *signing_actor_id) .deliver_to_inbox(inbox, activity_json.clone(), *signing_actor_id)
.await .await
.map_err(|e| DomainError::InfrastructureError(e.to_string())) .map_err(|e| DomainError::InfrastructureError(e.to_string()))
} }
@@ -157,16 +169,12 @@ impl ActivityPubEventHandler {
.federation_settings .federation_settings
.get_federation_flags(user_id) .get_federation_flags(user_id)
.await .await
.unwrap_or(domain::models::FederationFlags { .unwrap_or_default();
goals: true,
reviews: true,
watchlist: true,
});
if !flags.reviews { if !flags.reviews {
return Ok(()); return Ok(());
} }
let review = match self.content_query.get_review_by_id(review_id).await? { let review = match self.review_repo.get_review_by_id(review_id).await? {
Some(r) => r, Some(r) => r,
None => return Ok(()), None => return Ok(()),
}; };
@@ -175,7 +183,7 @@ impl ActivityPubEventHandler {
let actor = actor_url(&self.base_url, user_id.value()); let actor = actor_url(&self.base_url, user_id.value());
let movie = self let movie = self
.content_query .movie_repo
.get_movie_by_id(review.movie_id()) .get_movie_by_id(review.movie_id())
.await .await
.ok() .ok()
@@ -227,16 +235,12 @@ impl ActivityPubEventHandler {
.federation_settings .federation_settings
.get_federation_flags(user_id) .get_federation_flags(user_id)
.await .await
.unwrap_or(domain::models::FederationFlags { .unwrap_or_default();
goals: true,
reviews: true,
watchlist: true,
});
if !flags.reviews { if !flags.reviews {
return Ok(()); return Ok(());
} }
let review = match self.content_query.get_review_by_id(review_id).await? { let review = match self.review_repo.get_review_by_id(review_id).await? {
Some(r) => r, Some(r) => r,
None => return Ok(()), None => return Ok(()),
}; };
@@ -245,7 +249,7 @@ impl ActivityPubEventHandler {
let actor = actor_url(&self.base_url, user_id.value()); let actor = actor_url(&self.base_url, user_id.value());
let movie = self let movie = self
.content_query .movie_repo
.get_movie_by_id(review.movie_id()) .get_movie_by_id(review.movie_id())
.await .await
.ok() .ok()
@@ -310,11 +314,7 @@ impl ActivityPubEventHandler {
.federation_settings .federation_settings
.get_federation_flags(user_id) .get_federation_flags(user_id)
.await .await
.unwrap_or(domain::models::FederationFlags { .unwrap_or_default();
goals: true,
reviews: true,
watchlist: true,
});
if !flags.watchlist { if !flags.watchlist {
return Ok(()); return Ok(());
} }
@@ -324,7 +324,7 @@ impl ActivityPubEventHandler {
let actor = actor_url(&self.base_url, user_id.value()); let actor = actor_url(&self.base_url, user_id.value());
let poster_url = self let poster_url = self
.content_query .movie_repo
.get_movie_by_id(movie_id) .get_movie_by_id(movie_id)
.await .await
.ok() .ok()
@@ -373,7 +373,7 @@ impl ActivityPubEventHandler {
.get_local_reviews_for_movie(movie_id) .get_local_reviews_for_movie(movie_id)
.await?; .await?;
let movie = self.content_query.get_movie_by_id(movie_id).await?; let movie = self.movie_repo.get_movie_by_id(movie_id).await?;
let movie = match movie { let movie = match movie {
Some(m) => m, Some(m) => m,
None => return Ok(()), None => return Ok(()),
@@ -393,11 +393,7 @@ impl ActivityPubEventHandler {
.federation_settings .federation_settings
.get_federation_flags(user_id) .get_federation_flags(user_id)
.await .await
.unwrap_or(domain::models::FederationFlags { .unwrap_or_default();
goals: true,
reviews: true,
watchlist: true,
});
if !flags.reviews { if !flags.reviews {
continue; continue;
} }
@@ -436,23 +432,24 @@ impl ActivityPubEventHandler {
.federation_settings .federation_settings
.get_federation_flags(user_id) .get_federation_flags(user_id)
.await .await
.unwrap_or(domain::models::FederationFlags { .unwrap_or_default();
goals: true,
reviews: true,
watchlist: true,
});
if !flags.goals { if !flags.goals {
return Ok(()); return Ok(());
} }
let Some((goal, current)) = self let Some(goal) = self
.content_query .goal_repo
.get_goal_with_progress(user_id, year) .find_by_user_and_year(user_id, year)
.await .await
.ok() .ok()
.flatten() .flatten()
else { else {
return Ok(()); return Ok(());
}; };
let current = self
.stats_repo
.count_reviews_in_year(user_id, year)
.await
.unwrap_or(0);
let ap_id = goal_url(&self.base_url, user_id.value(), year); let ap_id = goal_url(&self.base_url, user_id.value(), year);
let actor = actor_url(&self.base_url, user_id.value()); let actor = actor_url(&self.base_url, user_id.value());
let obj = goal_to_ap_object( let obj = goal_to_ap_object(
@@ -481,21 +478,14 @@ impl ActivityPubEventHandler {
.federation_settings .federation_settings
.get_federation_flags(user_id) .get_federation_flags(user_id)
.await .await
.unwrap_or(domain::models::FederationFlags { .unwrap_or_default();
goals: true,
reviews: true,
watchlist: true,
});
if !flags.goals { if !flags.goals {
return Ok(()); return Ok(());
} }
let current = self let current = self
.content_query .stats_repo
.get_goal_with_progress(user_id, year) .count_reviews_in_year(user_id, year)
.await .await
.ok()
.flatten()
.map(|(_, c)| c)
.unwrap_or(0); .unwrap_or(0);
let ap_id = goal_url(&self.base_url, user_id.value(), year); let ap_id = goal_url(&self.base_url, user_id.value(), year);
@@ -519,11 +509,7 @@ impl ActivityPubEventHandler {
.federation_settings .federation_settings
.get_federation_flags(user_id) .get_federation_flags(user_id)
.await .await
.unwrap_or(domain::models::FederationFlags { .unwrap_or_default();
goals: true,
reviews: true,
watchlist: true,
});
if !flags.goals { if !flags.goals {
return Ok(()); return Ok(());
} }

View File

@@ -33,18 +33,15 @@ impl k_ap::EventPublisher for FederationEventBridge {
inbox, inbox,
activity, activity,
signing_actor_id, signing_actor_id,
} => { } => self
let json = serde_json::to_string(&activity) .domain_publisher
.map_err(|e| anyhow::anyhow!("serialize activity: {e}"))?; .publish(&DomainEvent::FederationDeliveryRequested {
self.domain_publisher inbox_url: inbox.to_string(),
.publish(&DomainEvent::FederationDeliveryRequested { activity_json: activity,
inbox_url: inbox.to_string(), signing_actor_id,
activity_json: json, })
signing_actor_id, .await
}) .map_err(|e| anyhow::anyhow!(e.to_string())),
.await
.map_err(|e| anyhow::anyhow!(e.to_string()))
}
FederationEvent::DeliveryFailed { inbox, error, .. } => { FederationEvent::DeliveryFailed { inbox, error, .. } => {
tracing::warn!(inbox = %inbox, error = %error, "federation delivery failed permanently"); tracing::warn!(inbox = %inbox, error = %error, "federation delivery failed permanently");
Ok(()) Ok(())

View File

@@ -4,7 +4,7 @@ use async_trait::async_trait;
use chrono::DateTime; use chrono::DateTime;
use domain::{ use domain::{
models::RemoteGoalEntry, models::RemoteGoalEntry,
ports::{LocalApContentQuery, RemoteGoalRepository}, ports::{GoalQuery, RemoteGoalRepository},
value_objects::UserId, value_objects::UserId,
}; };
use k_ap::{ApContentReader, ApObjectHandler}; use k_ap::{ApContentReader, ApObjectHandler};
@@ -15,7 +15,7 @@ use crate::urls::{actor_url, goal_url};
pub struct GoalObjectHandler { pub struct GoalObjectHandler {
pub remote_goal_repo: Arc<dyn RemoteGoalRepository>, pub remote_goal_repo: Arc<dyn RemoteGoalRepository>,
pub content_query: Arc<dyn LocalApContentQuery>, pub goal_repo: Arc<dyn GoalQuery>,
pub base_url: String, pub base_url: String,
} }
@@ -29,8 +29,8 @@ impl ApContentReader for GoalObjectHandler {
) -> anyhow::Result<Vec<(Url, serde_json::Value, DateTime<chrono::Utc>)>> { ) -> anyhow::Result<Vec<(Url, serde_json::Value, DateTime<chrono::Utc>)>> {
let uid = UserId::from_uuid(user_id); let uid = UserId::from_uuid(user_id);
let goals = self let goals = self
.content_query .goal_repo
.list_goals_for_user(&uid) .list_for_user(&uid)
.await .await
.map_err(|e| anyhow::anyhow!(e.to_string()))?; .map_err(|e| anyhow::anyhow!(e.to_string()))?;

View File

@@ -51,6 +51,11 @@ pub struct ActivityPubDeps {
pub remote_watchlist_repo: std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>, pub remote_watchlist_repo: std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>,
pub remote_goal_repo: std::sync::Arc<dyn domain::ports::RemoteGoalRepository>, pub remote_goal_repo: std::sync::Arc<dyn domain::ports::RemoteGoalRepository>,
pub local_ap_content: std::sync::Arc<dyn domain::ports::LocalApContentQuery>, pub local_ap_content: std::sync::Arc<dyn domain::ports::LocalApContentQuery>,
pub movie_repo: std::sync::Arc<dyn domain::ports::MovieQuery>,
pub review_repo: std::sync::Arc<dyn domain::ports::ReviewRepository>,
pub diary_repo: std::sync::Arc<dyn domain::ports::DiaryQuery>,
pub goal_repo: std::sync::Arc<dyn domain::ports::GoalQuery>,
pub stats_repo: std::sync::Arc<dyn domain::ports::StatsRepository>,
pub user_repo: std::sync::Arc<dyn domain::ports::UserRepository>, pub user_repo: std::sync::Arc<dyn domain::ports::UserRepository>,
pub federation_settings: std::sync::Arc<dyn domain::ports::UserFederationSettingsQuery>, pub federation_settings: std::sync::Arc<dyn domain::ports::UserFederationSettingsQuery>,
pub base_url: String, pub base_url: String,
@@ -68,6 +73,11 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
remote_watchlist_repo, remote_watchlist_repo,
remote_goal_repo, remote_goal_repo,
local_ap_content, local_ap_content,
movie_repo,
review_repo,
diary_repo,
goal_repo,
stats_repo,
user_repo, user_repo,
federation_settings, federation_settings,
base_url, base_url,
@@ -76,6 +86,8 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
} = deps; } = deps;
let review_handler = std::sync::Arc::new(ReviewObjectHandler { let review_handler = std::sync::Arc::new(ReviewObjectHandler {
content_query: std::sync::Arc::clone(&local_ap_content), content_query: std::sync::Arc::clone(&local_ap_content),
movie_repo: std::sync::Arc::clone(&movie_repo),
diary_repo,
review_store, review_store,
event_publisher: std::sync::Arc::clone(&event_publisher), event_publisher: std::sync::Arc::clone(&event_publisher),
base_url: base_url.clone(), base_url: base_url.clone(),
@@ -87,7 +99,7 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
}); });
let goal_handler = std::sync::Arc::new(goal_handler::GoalObjectHandler { let goal_handler = std::sync::Arc::new(goal_handler::GoalObjectHandler {
remote_goal_repo, remote_goal_repo,
content_query: std::sync::Arc::clone(&local_ap_content), goal_repo: std::sync::Arc::clone(&goal_repo),
base_url: base_url.clone(), base_url: base_url.clone(),
}); });
let composite = std::sync::Arc::new(composite_handler::CompositeObjectHandler { let composite = std::sync::Arc::new(composite_handler::CompositeObjectHandler {
@@ -136,6 +148,10 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
let event_handler = std::sync::Arc::new(ActivityPubEventHandler::new( let event_handler = std::sync::Arc::new(ActivityPubEventHandler::new(
std::sync::Arc::clone(&concrete), std::sync::Arc::clone(&concrete),
local_ap_content, local_ap_content,
review_repo,
movie_repo,
goal_repo,
stats_repo,
federation_settings, federation_settings,
base_url, base_url,
)) as std::sync::Arc<dyn domain::ports::EventHandler>; )) as std::sync::Arc<dyn domain::ports::EventHandler>;

View File

@@ -4,7 +4,7 @@ use async_trait::async_trait;
use domain::{ use domain::{
events::DomainEvent, events::DomainEvent,
models::ReviewSource, models::ReviewSource,
ports::{EventPublisher, LocalApContentQuery}, ports::{DiaryQuery, EventPublisher, LocalApContentQuery, MovieQuery},
value_objects::{Comment, ExternalMetadataId, MovieId, Rating, ReviewId, UserId}, value_objects::{Comment, ExternalMetadataId, MovieId, Rating, ReviewId, UserId},
}; };
use k_ap::{ApContentReader, ApObjectHandler}; use k_ap::{ApContentReader, ApObjectHandler};
@@ -16,6 +16,8 @@ use crate::urls::{actor_url, review_url};
pub struct ReviewObjectHandler { pub struct ReviewObjectHandler {
pub content_query: Arc<dyn LocalApContentQuery>, pub content_query: Arc<dyn LocalApContentQuery>,
pub movie_repo: Arc<dyn MovieQuery>,
pub diary_repo: Arc<dyn DiaryQuery>,
pub review_store: Arc<dyn RemoteReviewRepository>, pub review_store: Arc<dyn RemoteReviewRepository>,
pub event_publisher: Arc<dyn EventPublisher>, pub event_publisher: Arc<dyn EventPublisher>,
pub base_url: String, pub base_url: String,
@@ -69,7 +71,7 @@ impl ApContentReader for ReviewObjectHandler {
} }
async fn count_local_posts(&self) -> anyhow::Result<u64> { async fn count_local_posts(&self) -> anyhow::Result<u64> {
self.content_query self.diary_repo
.count_local_posts() .count_local_posts()
.await .await
.map_err(|e| anyhow::anyhow!(e.to_string())) .map_err(|e| anyhow::anyhow!(e.to_string()))
@@ -97,13 +99,18 @@ impl ApObjectHandler for ReviewObjectHandler {
let actor_url_str = obj.attributed_to.to_string(); let actor_url_str = obj.attributed_to.to_string();
let review_id = ReviewId::generate(); let review_id = ReviewId::generate();
let movie_id = if let Some(ref ext_id) = obj.external_metadata_id { let movie_id = if let Some(ref ext_id) = obj.external_metadata_id {
match self let found = if let Ok(ext_meta_id) = ExternalMetadataId::new(ext_id.clone()) {
.content_query self.movie_repo
.get_movie_by_external_metadata_id(ext_id) .get_movie_by_external_id(&ext_meta_id)
.await .await
{ .ok()
Ok(Some(movie)) => movie.id().clone(), .flatten()
_ => MovieId::from_uuid(uuid::Uuid::new_v5( } else {
None
};
match found {
Some(movie) => movie.id().clone(),
None => MovieId::from_uuid(uuid::Uuid::new_v5(
&uuid::Uuid::NAMESPACE_URL, &uuid::Uuid::NAMESPACE_URL,
ext_id.as_bytes(), ext_id.as_bytes(),
)), )),

View File

@@ -0,0 +1,11 @@
[package]
name = "adapter-common"
version = "0.1.0"
edition = "2024"
[dependencies]
domain = { workspace = true }
sqlx = { workspace = true }
tracing = { workspace = true }
chrono = { workspace = true }
uuid = { workspace = true }

View File

@@ -0,0 +1,220 @@
use domain::{
errors::DomainError,
models::{
Movie, MovieStats, MovieSummary, PersistedReview, Review, ReviewSource, UserSummary,
WatchlistEntry, WatchlistWithMovie,
},
value_objects::{
Comment, Email, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
ReviewId, UserId, Username, WatchlistEntryId,
},
};
/// Map a [`sqlx::Error`] to a [`DomainError::InfrastructureError`], logging the
/// underlying database error at `error` level.
pub fn map_sqlx_error(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
/// Parse a string as a UUID, returning a [`DomainError`] on failure.
pub fn parse_uuid(s: &str) -> Result<uuid::Uuid, DomainError> {
uuid::Uuid::parse_str(s)
.map_err(|e| DomainError::InfrastructureError(format!("Invalid UUID '{}': {}", s, e)))
}
/// Parse a `%Y-%m-%d %H:%M:%S` string into a [`chrono::NaiveDateTime`].
pub fn parse_datetime(s: &str) -> Result<chrono::NaiveDateTime, DomainError> {
chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S")
.map_err(|e| DomainError::InfrastructureError(format!("Invalid datetime '{}': {}", s, e)))
}
/// Format a [`chrono::NaiveDateTime`] as `%Y-%m-%d %H:%M:%S`.
pub fn datetime_to_str(dt: &chrono::NaiveDateTime) -> String {
dt.format("%Y-%m-%d %H:%M:%S").to_string()
}
/// Convert a `YYYY-MM` string into a human-readable label like `Jan '24`.
pub fn format_year_month(ym: &str) -> String {
let parts: Vec<&str> = ym.splitn(2, '-').collect();
if parts.len() != 2 {
return ym.to_string();
}
let year = parts[0].get(2..).unwrap_or(parts[0]);
let month = match parts[1] {
"01" => "Jan",
"02" => "Feb",
"03" => "Mar",
"04" => "Apr",
"05" => "May",
"06" => "Jun",
"07" => "Jul",
"08" => "Aug",
"09" => "Sep",
"10" => "Oct",
"11" => "Nov",
"12" => "Dec",
_ => parts[1],
};
format!("{} '{}", month, year)
}
// ---------------------------------------------------------------------------
// Shared row-to-domain conversion functions
//
// Each database adapter keeps its own `FromRow` structs (sqlite vs postgres
// derive different impls) but the conversion from parsed row fields into
// domain types is identical. These functions capture that shared logic so
// each adapter's `into_domain()` becomes a one-liner delegation.
// ---------------------------------------------------------------------------
/// Convert raw movie row fields into a [`Movie`] domain object.
pub fn movie_row_to_domain(
id: String,
external_metadata_id: Option<String>,
title: String,
release_year: i64,
director: Option<String>,
poster_path: Option<String>,
) -> Result<Movie, DomainError> {
let id = MovieId::from_uuid(parse_uuid(&id)?);
let external_metadata_id = external_metadata_id
.map(ExternalMetadataId::new)
.transpose()?;
let title = MovieTitle::new(title)?;
let release_year = ReleaseYear::new(release_year as u16)?;
let poster_path = poster_path.map(PosterPath::new).transpose()?;
Ok(Movie::from_persistence(
id,
external_metadata_id,
title,
release_year,
director,
poster_path,
))
}
/// Convert raw review row fields into a [`Review`] domain object.
#[allow(clippy::too_many_arguments)]
pub fn review_row_to_domain(
id: String,
movie_id: String,
user_id: String,
rating: i64,
comment: Option<String>,
watched_at: String,
created_at: String,
remote_actor_url: Option<String>,
watch_medium: Option<String>,
) -> Result<Review, DomainError> {
let id = ReviewId::from_uuid(parse_uuid(&id)?);
let movie_id = MovieId::from_uuid(parse_uuid(&movie_id)?);
let user_id = UserId::from_uuid(parse_uuid(&user_id)?);
let rating = Rating::new(rating as u8)?;
let comment = comment.map(Comment::new).transpose()?;
let watched_at = parse_datetime(&watched_at)?;
let created_at = parse_datetime(&created_at)?;
let source = match remote_actor_url {
None => ReviewSource::Local,
Some(url) => ReviewSource::Remote { actor_url: url },
};
let watch_medium = watch_medium.map(|s| s.parse()).transpose()?;
Ok(Review::from_persistence(PersistedReview {
id,
movie_id,
user_id,
rating,
comment,
watched_at,
created_at,
source,
watch_medium,
}))
}
/// Assemble a [`MovieSummary`] from an already-converted [`Movie`] and extra
/// metadata fields. The caller is responsible for converting genres into a
/// `Vec<String>` (sqlite splits a comma-separated string, postgres receives a
/// `Vec` directly).
pub fn movie_summary_to_domain(
movie: Movie,
genres: Vec<String>,
runtime_minutes: Option<i64>,
original_language: Option<String>,
overview: Option<String>,
collection_name: Option<String>,
) -> MovieSummary {
MovieSummary {
movie,
genres,
runtime_minutes: runtime_minutes.map(|v| v as u32),
original_language,
overview,
collection_name,
}
}
/// Convert raw aggregate stats into a [`MovieStats`] domain object.
pub fn movie_stats_to_domain(
total_count: i64,
avg_rating: Option<f64>,
federated_count: i64,
rating_histogram: [i64; 5],
) -> MovieStats {
MovieStats {
total_count: total_count as u64,
avg_rating,
federated_count: federated_count as u64,
rating_histogram: [
rating_histogram[0] as u64,
rating_histogram[1] as u64,
rating_histogram[2] as u64,
rating_histogram[3] as u64,
rating_histogram[4] as u64,
],
}
}
/// Convert raw user summary row fields into a [`UserSummary`] domain object.
#[allow(clippy::too_many_arguments)]
pub fn user_summary_to_domain(
id: String,
email: String,
username: String,
display_name: Option<String>,
total_movies: i64,
avg_rating: Option<f64>,
avatar_path: Option<String>,
) -> Result<UserSummary, DomainError> {
Ok(UserSummary::new(
UserId::from_uuid(parse_uuid(&id)?),
Email::new(email)?,
Username::new(username)?,
display_name,
total_movies,
avg_rating,
avatar_path,
))
}
/// Convert raw watchlist entry fields into a [`WatchlistEntry`] domain object.
pub fn watchlist_entry_to_domain(
id: String,
user_id: String,
movie_id: String,
added_at: String,
) -> Result<WatchlistEntry, DomainError> {
Ok(WatchlistEntry {
id: WatchlistEntryId::from_uuid(parse_uuid(&id)?),
user_id: UserId::from_uuid(parse_uuid(&user_id)?),
movie_id: MovieId::from_uuid(parse_uuid(&movie_id)?),
added_at: parse_datetime(&added_at)?,
})
}
/// Convert raw watchlist+movie row fields into a [`WatchlistWithMovie`].
///
/// Takes the watchlist entry fields and a pre-converted [`Movie`].
pub fn watchlist_with_movie_to_domain(entry: WatchlistEntry, movie: Movie) -> WatchlistWithMovie {
WatchlistWithMovie { entry, movie }
}

View File

@@ -72,7 +72,7 @@ pub enum EventPayload {
}, },
FederationDeliveryRequested { FederationDeliveryRequested {
inbox_url: String, inbox_url: String,
activity_json: String, activity_json: serde_json::Value,
signing_actor_id: String, signing_actor_id: String,
}, },
WatchEventIngested { WatchEventIngested {

View File

@@ -4,7 +4,11 @@ pub use config::PosterFetcherConfig;
use std::time::Duration; use std::time::Duration;
use async_trait::async_trait; use async_trait::async_trait;
use domain::{errors::DomainError, ports::PosterFetcherClient, value_objects::PosterUrl}; use domain::{
errors::DomainError,
ports::{ImageFetcher, PosterFetcherClient},
value_objects::PosterUrl,
};
pub struct ReqwestPosterFetcher { pub struct ReqwestPosterFetcher {
client: reqwest::Client, client: reqwest::Client,
@@ -37,8 +41,32 @@ impl PosterFetcherClient for ReqwestPosterFetcher {
} }
} }
#[async_trait]
impl ImageFetcher for ReqwestPosterFetcher {
async fn fetch_image(&self, url: &str) -> Result<Vec<u8>, DomainError> {
let bytes = self
.client
.get(url)
.send()
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
.error_for_status()
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
.bytes()
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(bytes.to_vec())
}
}
pub fn create() -> anyhow::Result<std::sync::Arc<dyn domain::ports::PosterFetcherClient>> { pub fn create() -> anyhow::Result<std::sync::Arc<dyn domain::ports::PosterFetcherClient>> {
Ok(std::sync::Arc::new(ReqwestPosterFetcher::new( Ok(std::sync::Arc::new(ReqwestPosterFetcher::new(
PosterFetcherConfig::from_env(), PosterFetcherConfig::from_env(),
)?)) )?))
} }
pub fn create_image_fetcher() -> anyhow::Result<std::sync::Arc<dyn domain::ports::ImageFetcher>> {
Ok(std::sync::Arc::new(ReqwestPosterFetcher::new(
PosterFetcherConfig::from_env(),
)?))
}

View File

@@ -5,14 +5,15 @@ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
ports::{ ports::{
EventHandler, EventPublisher, MetadataClient, MovieRepository, ObjectStorage, EventHandler, EventPublisher, MetadataClient, MovieCommand, MovieQuery, ObjectStorage,
PosterFetcherClient, PosterFetcherClient,
}, },
value_objects::{ExternalMetadataId, MovieId, PosterPath}, value_objects::{ExternalMetadataId, MovieId, PosterPath},
}; };
pub struct PosterSyncHandler { pub struct PosterSyncHandler {
movie_repository: Arc<dyn MovieRepository>, movie_command: Arc<dyn MovieCommand>,
movie_query: Arc<dyn MovieQuery>,
metadata_client: Arc<dyn MetadataClient>, metadata_client: Arc<dyn MetadataClient>,
poster_fetcher: Arc<dyn PosterFetcherClient>, poster_fetcher: Arc<dyn PosterFetcherClient>,
object_storage: Arc<dyn ObjectStorage>, object_storage: Arc<dyn ObjectStorage>,
@@ -22,7 +23,8 @@ pub struct PosterSyncHandler {
impl PosterSyncHandler { impl PosterSyncHandler {
pub fn new( pub fn new(
movie_repository: Arc<dyn MovieRepository>, movie_command: Arc<dyn MovieCommand>,
movie_query: Arc<dyn MovieQuery>,
metadata_client: Arc<dyn MetadataClient>, metadata_client: Arc<dyn MetadataClient>,
poster_fetcher: Arc<dyn PosterFetcherClient>, poster_fetcher: Arc<dyn PosterFetcherClient>,
object_storage: Arc<dyn ObjectStorage>, object_storage: Arc<dyn ObjectStorage>,
@@ -30,7 +32,8 @@ impl PosterSyncHandler {
max_retries: u32, max_retries: u32,
) -> Self { ) -> Self {
Self { Self {
movie_repository, movie_command,
movie_query,
metadata_client, metadata_client,
poster_fetcher, poster_fetcher,
object_storage, object_storage,
@@ -44,7 +47,7 @@ impl PosterSyncHandler {
movie_id: MovieId, movie_id: MovieId,
external_metadata_id: ExternalMetadataId, external_metadata_id: ExternalMetadataId,
) -> Result<(), DomainError> { ) -> Result<(), DomainError> {
let mut movie = match self.movie_repository.get_movie_by_id(&movie_id).await? { let mut movie = match self.movie_query.get_movie_by_id(&movie_id).await? {
Some(m) => m, Some(m) => m,
None => { None => {
tracing::warn!("Sync cancelled: Movie {} not found", movie_id.value()); tracing::warn!("Sync cancelled: Movie {} not found", movie_id.value());
@@ -82,7 +85,7 @@ impl PosterSyncHandler {
let poster_path = PosterPath::new(stored_path)?; let poster_path = PosterPath::new(stored_path)?;
movie.update_poster(poster_path); movie.update_poster(poster_path);
self.movie_repository.upsert_movie(&movie).await?; self.movie_command.upsert_movie(&movie).await?;
if let Err(e) = self if let Err(e) = self
.event_publisher .event_publisher
@@ -115,7 +118,7 @@ impl EventHandler for PosterSyncHandler {
} => { } => {
// Only sync poster if the movie doesn't have one yet // Only sync poster if the movie doesn't have one yet
let already_has_poster = self let already_has_poster = self
.movie_repository .movie_query
.get_movie_by_id(&MovieId::from_uuid(movie_id.value())) .get_movie_by_id(&MovieId::from_uuid(movie_id.value()))
.await? .await?
.map(|m| m.poster_path().is_some()) .map(|m| m.poster_path().is_some())

View File

@@ -12,6 +12,7 @@ sqlx = { version = "0.8.6", features = [
"chrono", "chrono",
] } ] }
activitypub = { workspace = true } activitypub = { workspace = true }
adapter-common = { workspace = true }
k-ap = { version = "0.4.1", registry = "gitea" } k-ap = { version = "0.4.1", registry = "gitea" }
domain = { workspace = true } domain = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }

View File

@@ -3,7 +3,8 @@ use async_trait::async_trait;
use chrono::Utc; use chrono::Utc;
use k_ap::ActivityRepository; use k_ap::ActivityRepository;
use super::{PostgresFederationRepository, datetime_to_str}; use super::PostgresFederationRepository;
use adapter_common::datetime_to_str;
#[async_trait] #[async_trait]
impl ActivityRepository for PostgresFederationRepository { impl ActivityRepository for PostgresFederationRepository {

View File

@@ -4,7 +4,8 @@ use chrono::Utc;
use k_ap::{ActorRepository, RemoteActor}; use k_ap::{ActorRepository, RemoteActor};
use sqlx::Row; use sqlx::Row;
use super::{PG_ACTOR_COLS, PostgresFederationRepository, datetime_to_str, pg_remote_actor}; use super::{PG_ACTOR_COLS, PostgresFederationRepository, pg_remote_actor};
use adapter_common::datetime_to_str;
#[async_trait] #[async_trait]
impl ActorRepository for PostgresFederationRepository { impl ActorRepository for PostgresFederationRepository {

View File

@@ -2,17 +2,16 @@ use async_trait::async_trait;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::{ models::{
DiaryEntry, Goal, GoalType, Movie, PersistedReview, Review, ReviewSource, WatchlistEntry, DiaryEntry, Movie, PersistedReview, Review, ReviewSource, WatchlistEntry,
WatchlistWithMovie, WatchlistWithMovie,
}, },
ports::LocalApContentQuery, ports::LocalApContentQuery,
value_objects::{ value_objects::{
Comment, ExternalMetadataId, GoalId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear, Comment, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
ReviewId, UserId, WatchlistEntryId, ReviewId, UserId, WatchlistEntryId,
}, },
}; };
use sqlx::{PgPool, Row}; use sqlx::{PgPool, Row};
use uuid::Uuid;
pub struct PostgresApContentQuery { pub struct PostgresApContentQuery {
pool: PgPool, pool: PgPool,
@@ -22,24 +21,11 @@ impl PostgresApContentQuery {
pub fn new(pool: PgPool) -> Self { pub fn new(pool: PgPool) -> Self {
Self { pool } Self { pool }
} }
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
} }
// ── Local row types ────────────────────────────────────────────────────────── // ── Local row types ──────────────────────────────────────────────────────────
fn parse_uuid(s: &str) -> Result<Uuid, DomainError> { use adapter_common::{parse_datetime, parse_uuid};
Uuid::parse_str(s)
.map_err(|e| DomainError::InfrastructureError(format!("Invalid UUID '{}': {}", s, e)))
}
fn parse_datetime(s: &str) -> Result<chrono::NaiveDateTime, DomainError> {
chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S")
.map_err(|e| DomainError::InfrastructureError(format!("Invalid datetime '{}': {}", s, e)))
}
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct MovieRow { struct MovieRow {
@@ -82,6 +68,7 @@ struct ReviewRow {
watched_at: String, watched_at: String,
created_at: String, created_at: String,
remote_actor_url: Option<String>, remote_actor_url: Option<String>,
watch_medium: Option<String>,
} }
impl ReviewRow { impl ReviewRow {
@@ -97,6 +84,7 @@ impl ReviewRow {
None => ReviewSource::Local, None => ReviewSource::Local,
Some(url) => ReviewSource::Remote { actor_url: url }, Some(url) => ReviewSource::Remote { actor_url: url },
}; };
let watch_medium = self.watch_medium.map(|s| s.parse()).transpose()?;
Ok(Review::from_persistence(PersistedReview { Ok(Review::from_persistence(PersistedReview {
id, id,
movie_id, movie_id,
@@ -106,7 +94,7 @@ impl ReviewRow {
watched_at, watched_at,
created_at, created_at,
source, source,
watch_medium: None, watch_medium,
})) }))
} }
} }
@@ -127,6 +115,7 @@ struct DiaryRow {
watched_at: String, watched_at: String,
created_at: String, created_at: String,
remote_actor_url: Option<String>, remote_actor_url: Option<String>,
watch_medium: Option<String>,
} }
impl DiaryRow { impl DiaryRow {
@@ -149,105 +138,17 @@ impl DiaryRow {
watched_at: self.watched_at, watched_at: self.watched_at,
created_at: self.created_at, created_at: self.created_at,
remote_actor_url: self.remote_actor_url, remote_actor_url: self.remote_actor_url,
watch_medium: self.watch_medium,
} }
.into_domain()?; .into_domain()?;
Ok(DiaryEntry::new(movie, review)) Ok(DiaryEntry::new(movie, review))
} }
} }
fn row_to_goal(r: &sqlx::postgres::PgRow) -> Result<Goal, DomainError> {
let id_str: String = r
.try_get("id")
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read goal id: {e}")))?;
let user_id_str: String = r
.try_get("user_id")
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read user_id: {e}")))?;
let year: i64 = r
.try_get("year")
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read year: {e}")))?;
let target: i64 = r.try_get("target_count").map_err(|e| {
DomainError::InfrastructureError(format!("Failed to read target_count: {e}"))
})?;
let goal_type_str: String = r
.try_get("goal_type")
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read goal_type: {e}")))?;
let created_at_str: String = r
.try_get("created_at")
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read created_at: {e}")))?;
let id = GoalId::from_uuid(parse_uuid(&id_str)?);
let user_id = UserId::from_uuid(parse_uuid(&user_id_str)?);
let goal_type: GoalType = goal_type_str.parse()?;
let created_at = parse_datetime(&created_at_str)?;
Ok(Goal::from_persistence(
id,
user_id,
year as u16,
target as u32,
goal_type,
created_at,
))
}
async fn count_reviews_in_year(
pool: &PgPool,
user_id: &UserId,
year: u16,
) -> Result<u32, DomainError> {
let uid = user_id.value().to_string();
let start = format!("{year}-01-01 00:00:00");
let end = format!("{}-01-01 00:00:00", year + 1);
let count: i64 = sqlx::query(
"SELECT COUNT(*) FROM reviews \
WHERE user_id = $1 \
AND watched_at >= $2::timestamptz \
AND watched_at < $3::timestamptz \
AND remote_actor_url IS NULL",
)
.bind(&uid)
.bind(&start)
.bind(&end)
.fetch_one(pool)
.await
.map_err(|e| {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
})?
.try_get(0)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(count as u32)
}
// ── LocalApContentQuery impl ───────────────────────────────────────────────── // ── LocalApContentQuery impl ─────────────────────────────────────────────────
#[async_trait] #[async_trait]
impl LocalApContentQuery for PostgresApContentQuery { impl LocalApContentQuery for PostgresApContentQuery {
async fn get_local_reviews_for_user(
&self,
user_id: &UserId,
) -> Result<Vec<DiaryEntry>, DomainError> {
let uid = user_id.value().to_string();
let rows = sqlx::query_as::<_, DiaryRow>(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
r.remote_actor_url
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = $1 AND r.remote_actor_url IS NULL
ORDER BY r.created_at DESC",
)
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
rows.into_iter().map(DiaryRow::into_domain).collect()
}
async fn get_local_watchlist_for_user( async fn get_local_watchlist_for_user(
&self, &self,
user_id: &UserId, user_id: &UserId,
@@ -266,7 +167,7 @@ impl LocalApContentQuery for PostgresApContentQuery {
.bind(&uid) .bind(&uid)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
rows.into_iter() rows.into_iter()
.map(|row| { .map(|row| {
@@ -324,7 +225,8 @@ impl LocalApContentQuery for PostgresApContentQuery {
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
r.remote_actor_url r.remote_actor_url,
r.watch_medium
FROM reviews r FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id INNER JOIN movies m ON m.id = r.movie_id
WHERE r.movie_id = $1 AND r.remote_actor_url IS NULL WHERE r.movie_id = $1 AND r.remote_actor_url IS NULL
@@ -333,66 +235,10 @@ impl LocalApContentQuery for PostgresApContentQuery {
.bind(&mid) .bind(&mid)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
rows.into_iter().map(DiaryRow::into_domain).collect() rows.into_iter().map(DiaryRow::into_domain).collect()
} }
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError> {
let id = review_id.value().to_string();
sqlx::query_as::<_, ReviewRow>(
"SELECT id, movie_id, user_id, rating, comment,
to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
remote_actor_url
FROM reviews WHERE id = $1",
)
.bind(&id)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?
.map(ReviewRow::into_domain)
.transpose()
}
async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result<Option<Movie>, DomainError> {
let id = movie_id.value().to_string();
sqlx::query_as::<_, MovieRow>(
"SELECT id, external_metadata_id, title, release_year, director, poster_path
FROM movies WHERE id = $1",
)
.bind(&id)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?
.map(MovieRow::into_domain)
.transpose()
}
async fn get_movie_by_external_metadata_id(
&self,
external_id: &str,
) -> Result<Option<Movie>, DomainError> {
sqlx::query_as::<_, MovieRow>(
"SELECT id, external_metadata_id, title, release_year, director, poster_path
FROM movies WHERE external_metadata_id = $1",
)
.bind(external_id)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?
.map(MovieRow::into_domain)
.transpose()
}
async fn count_local_posts(&self) -> Result<u64, DomainError> {
let count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE remote_actor_url IS NULL")
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(count as u64)
}
async fn get_local_reviews_page( async fn get_local_reviews_page(
&self, &self,
user_id: &UserId, user_id: &UserId,
@@ -409,7 +255,8 @@ impl LocalApContentQuery for PostgresApContentQuery {
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
r.remote_actor_url r.remote_actor_url,
r.watch_medium
FROM reviews r FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = $1 AND r.remote_actor_url IS NULL AND r.watched_at < $2::timestamptz WHERE r.user_id = $1 AND r.remote_actor_url IS NULL AND r.watched_at < $2::timestamptz
@@ -421,14 +268,15 @@ impl LocalApContentQuery for PostgresApContentQuery {
.bind(limit_i64) .bind(limit_i64)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(Self::map_err)? .map_err(adapter_common::map_sqlx_error)?
} else { } else {
sqlx::query_as::<_, DiaryRow>( sqlx::query_as::<_, DiaryRow>(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
r.remote_actor_url r.remote_actor_url,
r.watch_medium
FROM reviews r FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = $1 AND r.remote_actor_url IS NULL WHERE r.user_id = $1 AND r.remote_actor_url IS NULL
@@ -439,49 +287,8 @@ impl LocalApContentQuery for PostgresApContentQuery {
.bind(limit_i64) .bind(limit_i64)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(Self::map_err)? .map_err(adapter_common::map_sqlx_error)?
}; };
rows.into_iter().map(DiaryRow::into_domain).collect() rows.into_iter().map(DiaryRow::into_domain).collect()
} }
async fn get_goal_with_progress(
&self,
user_id: &UserId,
year: u16,
) -> Result<Option<(Goal, u32)>, DomainError> {
let uid = user_id.value().to_string();
let y = year as i64;
let row = sqlx::query(
"SELECT id, user_id, year, target_count, goal_type, \
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at \
FROM goals WHERE user_id = $1 AND year = $2",
)
.bind(&uid)
.bind(y)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?;
let Some(r) = row else { return Ok(None) };
let goal = row_to_goal(&r)?;
let count = count_reviews_in_year(&self.pool, user_id, year).await?;
Ok(Some((goal, count)))
}
async fn list_goals_for_user(&self, user_id: &UserId) -> Result<Vec<Goal>, DomainError> {
let uid = user_id.value().to_string();
let rows = sqlx::query(
"SELECT id, user_id, year, target_count, goal_type, \
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at \
FROM goals WHERE user_id = $1 ORDER BY created_at DESC",
)
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
rows.iter().map(row_to_goal).collect()
}
} }

View File

@@ -4,7 +4,8 @@ use chrono::Utc;
use k_ap::{BlockedDomain, BlocklistRepository}; use k_ap::{BlockedDomain, BlocklistRepository};
use sqlx::Row; use sqlx::Row;
use super::{PostgresFederationRepository, datetime_to_str}; use super::PostgresFederationRepository;
use adapter_common::datetime_to_str;
#[async_trait] #[async_trait]
impl BlocklistRepository for PostgresFederationRepository { impl BlocklistRepository for PostgresFederationRepository {

View File

@@ -7,9 +7,9 @@ use k_ap::{
use sqlx::Row; use sqlx::Row;
use super::{ use super::{
PG_ACTOR_COLS, PostgresFederationRepository, datetime_to_str, pg_remote_actor, status_to_str, PG_ACTOR_COLS, PostgresFederationRepository, pg_remote_actor, status_to_str, str_to_status,
str_to_status,
}; };
use adapter_common::datetime_to_str;
#[async_trait] #[async_trait]
impl FollowRepository for PostgresFederationRepository { impl FollowRepository for PostgresFederationRepository {

View File

@@ -12,14 +12,9 @@ mod watchlist;
pub use ap_content::PostgresApContentQuery; pub use ap_content::PostgresApContentQuery;
pub use remote_goals::PostgresRemoteGoalRepository; pub use remote_goals::PostgresRemoteGoalRepository;
use chrono::NaiveDateTime;
use k_ap::{FollowerStatus, RemoteActor}; use k_ap::{FollowerStatus, RemoteActor};
use sqlx::{PgPool, Row}; use sqlx::{PgPool, Row};
pub(crate) fn datetime_to_str(dt: &NaiveDateTime) -> String {
dt.format("%Y-%m-%d %H:%M:%S").to_string()
}
pub(crate) fn status_to_str(status: &FollowerStatus) -> &'static str { pub(crate) fn status_to_str(status: &FollowerStatus) -> &'static str {
match status { match status {
FollowerStatus::Pending => "pending", FollowerStatus::Pending => "pending",

View File

@@ -11,11 +11,6 @@ impl PostgresRemoteGoalRepository {
pub fn new(pool: PgPool) -> Self { pub fn new(pool: PgPool) -> Self {
Self { pool } Self { pool }
} }
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
} }
#[async_trait] #[async_trait]
@@ -38,7 +33,7 @@ impl RemoteGoalRepository for PostgresRemoteGoalRepository {
.bind(&received) .bind(&received)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(()) Ok(())
} }
@@ -57,7 +52,7 @@ impl RemoteGoalRepository for PostgresRemoteGoalRepository {
.bind(ap_id) .bind(ap_id)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(()) Ok(())
} }
@@ -68,7 +63,7 @@ impl RemoteGoalRepository for PostgresRemoteGoalRepository {
.bind(actor_url) .bind(actor_url)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(()) Ok(())
} }
@@ -78,7 +73,7 @@ impl RemoteGoalRepository for PostgresRemoteGoalRepository {
.bind(actor_url) .bind(actor_url)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(()) Ok(())
} }
@@ -92,7 +87,7 @@ impl RemoteGoalRepository for PostgresRemoteGoalRepository {
.bind(actor_url) .bind(actor_url)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
rows.iter() rows.iter()
.map(|r| { .map(|r| {

View File

@@ -3,7 +3,8 @@ use anyhow::{Result, anyhow};
use async_trait::async_trait; use async_trait::async_trait;
use domain::models::{Review, ReviewSource}; use domain::models::{Review, ReviewSource};
use super::{PostgresFederationRepository, datetime_to_str}; use super::PostgresFederationRepository;
use adapter_common::datetime_to_str;
#[async_trait] #[async_trait]
impl RemoteReviewRepository for PostgresFederationRepository { impl RemoteReviewRepository for PostgresFederationRepository {

View File

@@ -3,6 +3,7 @@ use domain::{
errors::DomainError, errors::DomainError,
models::{PendingFollowerInfo, RemoteActorInfo}, models::{PendingFollowerInfo, RemoteActorInfo},
ports::SocialQueryPort, ports::SocialQueryPort,
value_objects::UserId,
}; };
use super::PostgresFederationRepository; use super::PostgresFederationRepository;
@@ -11,9 +12,9 @@ use super::PostgresFederationRepository;
impl SocialQueryPort for PostgresFederationRepository { impl SocialQueryPort for PostgresFederationRepository {
async fn get_accepted_following_urls( async fn get_accepted_following_urls(
&self, &self,
user_id: uuid::Uuid, user_id: &UserId,
) -> Result<Vec<String>, DomainError> { ) -> Result<Vec<String>, DomainError> {
let user_id_str = user_id.to_string(); let user_id_str = user_id.value().to_string();
sqlx::query_scalar::<_, String>( sqlx::query_scalar::<_, String>(
"SELECT remote_actor_url FROM ap_following WHERE local_user_id = $1 AND status = 'accepted'", "SELECT remote_actor_url FROM ap_following WHERE local_user_id = $1 AND status = 'accepted'",
).bind(&user_id_str).fetch_all(&self.pool).await ).bind(&user_id_str).fetch_all(&self.pool).await
@@ -34,8 +35,8 @@ impl SocialQueryPort for PostgresFederationRepository {
.collect()) .collect())
} }
async fn count_following(&self, user_id: uuid::Uuid) -> Result<usize, DomainError> { async fn count_following(&self, user_id: &UserId) -> Result<usize, DomainError> {
let uid = user_id.to_string(); let uid = user_id.value().to_string();
let count: i64 = sqlx::query_scalar( let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ap_following WHERE local_user_id = $1 AND status = 'accepted'", "SELECT COUNT(*) FROM ap_following WHERE local_user_id = $1 AND status = 'accepted'",
) )
@@ -46,8 +47,8 @@ impl SocialQueryPort for PostgresFederationRepository {
Ok(count as usize) Ok(count as usize)
} }
async fn count_accepted_followers(&self, user_id: uuid::Uuid) -> Result<usize, DomainError> { async fn count_accepted_followers(&self, user_id: &UserId) -> Result<usize, DomainError> {
let uid = user_id.to_string(); let uid = user_id.value().to_string();
let count: i64 = sqlx::query_scalar( let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ap_followers WHERE local_user_id = $1 AND status = 'accepted'", "SELECT COUNT(*) FROM ap_followers WHERE local_user_id = $1 AND status = 'accepted'",
) )
@@ -60,9 +61,9 @@ impl SocialQueryPort for PostgresFederationRepository {
async fn get_pending_followers( async fn get_pending_followers(
&self, &self,
user_id: uuid::Uuid, user_id: &UserId,
) -> Result<Vec<PendingFollowerInfo>, DomainError> { ) -> Result<Vec<PendingFollowerInfo>, DomainError> {
let uid = user_id.to_string(); let uid = user_id.value().to_string();
let rows = sqlx::query_as::<_, (String, String, Option<String>, Option<String>)>( let rows = sqlx::query_as::<_, (String, String, Option<String>, Option<String>)>(
"SELECT ar.url, ar.handle, ar.display_name, ar.avatar_url FROM ap_followers f JOIN ap_remote_actors ar ON ar.url = f.remote_actor_url WHERE f.local_user_id = $1 AND f.status = 'pending'", "SELECT ar.url, ar.handle, ar.display_name, ar.avatar_url FROM ap_followers f JOIN ap_remote_actors ar ON ar.url = f.remote_actor_url WHERE f.local_user_id = $1 AND f.status = 'pending'",
).bind(&uid).fetch_all(&self.pool).await.map_err(|e| DomainError::InfrastructureError(e.to_string()))?; ).bind(&uid).fetch_all(&self.pool).await.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;

View File

@@ -4,7 +4,8 @@ version = "0.1.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
domain = { workspace = true } domain = { workspace = true }
async-trait = { workspace = true } adapter-common = { workspace = true }
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres", "uuid", "macros"] } async-trait = { workspace = true }
uuid = { workspace = true } sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres", "uuid", "macros"] }
uuid = { workspace = true }

View File

@@ -31,10 +31,6 @@ pub fn create_search_adapter(pool: PgPool) -> (Arc<dyn SearchCommand>, Arc<dyn S
) )
} }
fn map_err(e: sqlx::Error) -> DomainError {
DomainError::InfrastructureError(e.to_string())
}
#[async_trait] #[async_trait]
impl SearchCommand for PostgresSearchAdapter { impl SearchCommand for PostgresSearchAdapter {
async fn index(&self, doc: IndexableDocument) -> Result<(), DomainError> { async fn index(&self, doc: IndexableDocument) -> Result<(), DomainError> {
@@ -91,7 +87,7 @@ impl SearchCommand for PostgresSearchAdapter {
.bind(&fts_input) .bind(&fts_input)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(()) Ok(())
} }
@@ -113,7 +109,7 @@ impl SearchCommand for PostgresSearchAdapter {
.bind(&fts_input) .bind(&fts_input)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(()) Ok(())
} }
@@ -127,14 +123,14 @@ impl SearchCommand for PostgresSearchAdapter {
.bind(id) .bind(id)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
} }
EntityType::Person => { EntityType::Person => {
sqlx::query("DELETE FROM people_search WHERE person_id = $1") sqlx::query("DELETE FROM people_search WHERE person_id = $1")
.bind(id) .bind(id)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
} }
} }
Ok(()) Ok(())
@@ -183,7 +179,7 @@ impl PostgresSearchAdapter {
.bind(query.filters.year.map(|y| y as i32)) .bind(query.filters.year.map(|y| y as i32))
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
count as u64 count as u64
} else { } else {
let count: i64 = sqlx::query_scalar( let count: i64 = sqlx::query_scalar(
@@ -196,7 +192,7 @@ impl PostgresSearchAdapter {
.bind(query.filters.year.map(|y| y as i32)) .bind(query.filters.year.map(|y| y as i32))
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
count as u64 count as u64
}; };
@@ -221,7 +217,7 @@ impl PostgresSearchAdapter {
.bind(offset) .bind(offset)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(map_err)? .map_err(adapter_common::map_sqlx_error)?
} else { } else {
sqlx::query_as::<_, Row>( sqlx::query_as::<_, Row>(
"SELECT m.id, m.title, m.release_year, m.director, m.poster_path, "SELECT m.id, m.title, m.release_year, m.director, m.poster_path,
@@ -238,7 +234,7 @@ impl PostgresSearchAdapter {
.bind(offset) .bind(offset)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(map_err)? .map_err(adapter_common::map_sqlx_error)?
}; };
let items = rows let items = rows
@@ -290,7 +286,7 @@ impl PostgresSearchAdapter {
.bind(text) .bind(text)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
count as u64 count as u64
}; };
@@ -316,7 +312,7 @@ impl PostgresSearchAdapter {
.bind(offset) .bind(offset)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
let mut items = Vec::with_capacity(rows.len()); let mut items = Vec::with_capacity(rows.len());
for row in rows { for row in rows {

View File

@@ -11,6 +11,7 @@ sqlx = { version = "0.8.6", features = [
"macros", "macros",
"chrono", "chrono",
] } ] }
adapter-common = { workspace = true }
domain = { workspace = true } domain = { workspace = true }
postgres-federation = { workspace = true } postgres-federation = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }

View File

@@ -2,10 +2,10 @@ use async_trait::async_trait;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::{ models::{
DiaryEntry, DiaryFilter, FeedEntry, MovieStats, ReviewHistory, SortDirection, DiaryEntry, DiaryFilter, FeedEntry, MovieStats, ReviewHistory, ReviewSortBy,
collections::{PageParams, Paginated}, collections::{PageParams, Paginated},
}, },
ports::DiaryRepository, ports::DiaryQuery,
value_objects::{MovieId, UserId}, value_objects::{MovieId, UserId},
}; };
use futures::stream::BoxStream; use futures::stream::BoxStream;
@@ -22,38 +22,33 @@ impl PostgresDiaryRepository {
Self { pool } Self { pool }
} }
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
async fn count_diary_entries(&self, movie_id: Option<&str>) -> Result<i64, DomainError> { async fn count_diary_entries(&self, movie_id: Option<&str>) -> Result<i64, DomainError> {
match movie_id { match movie_id {
None => sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM reviews") None => sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM reviews")
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await
.map_err(Self::map_err), .map_err(adapter_common::map_sqlx_error),
Some(id) => { Some(id) => {
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM reviews WHERE movie_id = $1") sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM reviews WHERE movie_id = $1")
.bind(id) .bind(id)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await
.map_err(Self::map_err) .map_err(adapter_common::map_sqlx_error)
} }
} }
} }
async fn fetch_all_diary_rows( async fn fetch_all_diary_rows(
&self, &self,
sort: &SortDirection, sort: &ReviewSortBy,
limit: i64, limit: i64,
offset: i64, offset: i64,
) -> Result<Vec<DiaryRow>, DomainError> { ) -> Result<Vec<DiaryRow>, DomainError> {
let order = match sort { let order = match sort {
SortDirection::ByRatingDesc => "r.rating DESC, r.watched_at DESC", ReviewSortBy::ByRatingDesc => "r.rating DESC, r.watched_at DESC",
SortDirection::ByRatingAsc => "r.rating ASC, r.watched_at ASC", ReviewSortBy::ByRatingAsc => "r.rating ASC, r.watched_at ASC",
SortDirection::Ascending => "r.watched_at ASC", ReviewSortBy::Ascending => "r.watched_at ASC",
SortDirection::Descending => "r.watched_at DESC", ReviewSortBy::Descending => "r.watched_at DESC",
}; };
let sql = format!( let sql = format!(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
@@ -73,21 +68,21 @@ impl PostgresDiaryRepository {
.bind(offset) .bind(offset)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(Self::map_err) .map_err(adapter_common::map_sqlx_error)
} }
async fn fetch_movie_diary_rows( async fn fetch_movie_diary_rows(
&self, &self,
movie_id: &str, movie_id: &str,
sort: &SortDirection, sort: &ReviewSortBy,
limit: i64, limit: i64,
offset: i64, offset: i64,
) -> Result<Vec<DiaryRow>, DomainError> { ) -> Result<Vec<DiaryRow>, DomainError> {
let order = match sort { let order = match sort {
SortDirection::ByRatingDesc => "r.rating DESC, r.watched_at DESC", ReviewSortBy::ByRatingDesc => "r.rating DESC, r.watched_at DESC",
SortDirection::ByRatingAsc => "r.rating ASC, r.watched_at ASC", ReviewSortBy::ByRatingAsc => "r.rating ASC, r.watched_at ASC",
SortDirection::Ascending => "r.watched_at ASC", ReviewSortBy::Ascending => "r.watched_at ASC",
SortDirection::Descending => "r.watched_at DESC", ReviewSortBy::Descending => "r.watched_at DESC",
}; };
let sql = format!( let sql = format!(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
@@ -109,7 +104,7 @@ impl PostgresDiaryRepository {
.bind(offset) .bind(offset)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(Self::map_err) .map_err(adapter_common::map_sqlx_error)
} }
async fn count_user_diary_entries( async fn count_user_diary_entries(
@@ -138,13 +133,15 @@ impl PostgresDiaryRepository {
if has_search { if has_search {
q = q.bind(search.unwrap()); q = q.bind(search.unwrap());
} }
q.fetch_one(&self.pool).await.map_err(Self::map_err) q.fetch_one(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)
} }
async fn fetch_user_diary_rows( async fn fetch_user_diary_rows(
&self, &self,
user_id: &str, user_id: &str,
sort: &SortDirection, sort: &ReviewSortBy,
search: Option<&str>, search: Option<&str>,
include_remote: bool, include_remote: bool,
limit: i64, limit: i64,
@@ -152,10 +149,10 @@ impl PostgresDiaryRepository {
) -> Result<Vec<DiaryRow>, DomainError> { ) -> Result<Vec<DiaryRow>, DomainError> {
let has_search = search.map(|s| !s.is_empty()).unwrap_or(false); let has_search = search.map(|s| !s.is_empty()).unwrap_or(false);
let order_clause = match sort { let order_clause = match sort {
SortDirection::ByRatingDesc => "r.rating DESC, r.watched_at DESC", ReviewSortBy::ByRatingDesc => "r.rating DESC, r.watched_at DESC",
SortDirection::ByRatingAsc => "r.rating ASC, r.watched_at ASC", ReviewSortBy::ByRatingAsc => "r.rating ASC, r.watched_at ASC",
SortDirection::Ascending => "r.watched_at ASC", ReviewSortBy::Ascending => "r.watched_at ASC",
SortDirection::Descending => "r.watched_at DESC", ReviewSortBy::Descending => "r.watched_at DESC",
}; };
let remote_clause = if include_remote { let remote_clause = if include_remote {
"" ""
@@ -197,12 +194,12 @@ impl PostgresDiaryRepository {
.bind(offset) .bind(offset)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(Self::map_err) .map_err(adapter_common::map_sqlx_error)
} }
} }
#[async_trait] #[async_trait]
impl DiaryRepository for PostgresDiaryRepository { impl DiaryQuery for PostgresDiaryRepository {
async fn query_diary( async fn query_diary(
&self, &self,
filter: &DiaryFilter, filter: &DiaryFilter,
@@ -375,7 +372,10 @@ impl DiaryRepository for PostgresDiaryRepository {
} }
let count_q = bind_filter_params!(sqlx::query_scalar::<_, i64>(&count_sql)); 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(adapter_common::map_sqlx_error)?;
let rows_q = bind_filter_params!(sqlx::query_as::<_, FeedRow>(&select_sql)); let rows_q = bind_filter_params!(sqlx::query_as::<_, FeedRow>(&select_sql));
let rows = rows_q let rows = rows_q
@@ -383,7 +383,7 @@ impl DiaryRepository for PostgresDiaryRepository {
.bind(offset) .bind(offset)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
let items = rows let items = rows
.into_iter() .into_iter()
@@ -408,7 +408,7 @@ impl DiaryRepository for PostgresDiaryRepository {
.bind(&id_str) .bind(&id_str)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(Self::map_err)? .map_err(adapter_common::map_sqlx_error)?
.ok_or_else(|| DomainError::NotFound(format!("Movie {}", id_str)))? .ok_or_else(|| DomainError::NotFound(format!("Movie {}", id_str)))?
.into_domain()?; .into_domain()?;
@@ -416,13 +416,14 @@ impl DiaryRepository for PostgresDiaryRepository {
"SELECT id, movie_id, user_id, rating, comment, "SELECT id, movie_id, user_id, rating, comment,
to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
remote_actor_url remote_actor_url,
watch_medium
FROM reviews WHERE movie_id = $1 ORDER BY watched_at ASC", FROM reviews WHERE movie_id = $1 ORDER BY watched_at ASC",
) )
.bind(&id_str) .bind(&id_str)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(Self::map_err)? .map_err(adapter_common::map_sqlx_error)?
.into_iter() .into_iter()
.map(ReviewRow::into_domain) .map(ReviewRow::into_domain)
.collect::<Result<Vec<_>, _>>()?; .collect::<Result<Vec<_>, _>>()?;
@@ -447,7 +448,7 @@ impl DiaryRepository for PostgresDiaryRepository {
.bind(&uid) .bind(&uid)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
rows.into_iter().map(DiaryRow::into_domain).collect() rows.into_iter().map(DiaryRow::into_domain).collect()
} }
@@ -464,7 +465,8 @@ impl DiaryRepository for PostgresDiaryRepository {
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
r.remote_actor_url r.remote_actor_url,
r.watch_medium
FROM reviews r FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = $1 WHERE r.user_id = $1
@@ -475,7 +477,7 @@ impl DiaryRepository for PostgresDiaryRepository {
while let Some(row) = futures::StreamExt::next(&mut rows).await { while let Some(row) = futures::StreamExt::next(&mut rows).await {
yield match row { yield match row {
Ok(r) => r.into_domain(), Ok(r) => r.into_domain(),
Err(e) => Err(Self::map_err(e)), Err(e) => Err(adapter_common::map_sqlx_error(e)),
}; };
} }
}) })
@@ -498,7 +500,7 @@ impl DiaryRepository for PostgresDiaryRepository {
.bind(id_str) .bind(id_str)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await
.map_err(Self::map_err) .map_err(adapter_common::map_sqlx_error)
.map(MovieStatsRow::into_domain) .map(MovieStatsRow::into_domain)
} }
@@ -515,7 +517,7 @@ impl DiaryRepository for PostgresDiaryRepository {
.bind(&id_str) .bind(&id_str)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
let rows = sqlx::query_as::<_, FeedRow>( let rows = sqlx::query_as::<_, FeedRow>(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
@@ -539,7 +541,7 @@ impl DiaryRepository for PostgresDiaryRepository {
.bind(offset) .bind(offset)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
let items = rows let items = rows
.into_iter() .into_iter()
@@ -559,7 +561,7 @@ impl DiaryRepository for PostgresDiaryRepository {
sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE remote_actor_url IS NULL") sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE remote_actor_url IS NULL")
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(count as u64) Ok(count as u64)
} }
} }

View File

@@ -2,12 +2,12 @@ use async_trait::async_trait;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::{Goal, GoalType}, models::{Goal, GoalType},
ports::GoalRepository, ports::{GoalCommand, GoalQuery},
value_objects::{GoalId, UserId}, value_objects::{GoalId, UserId},
}; };
use sqlx::{PgPool, Row}; use sqlx::{PgPool, Row};
use crate::models::{datetime_to_str, parse_datetime, parse_uuid}; use adapter_common::{datetime_to_str, parse_datetime, parse_uuid};
pub struct PostgresGoalRepository { pub struct PostgresGoalRepository {
pool: PgPool, pool: PgPool,
@@ -17,15 +17,10 @@ impl PostgresGoalRepository {
pub fn new(pool: PgPool) -> Self { pub fn new(pool: PgPool) -> Self {
Self { pool } Self { pool }
} }
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
} }
#[async_trait] #[async_trait]
impl GoalRepository for PostgresGoalRepository { impl GoalCommand for PostgresGoalRepository {
async fn save(&self, goal: &Goal) -> Result<(), DomainError> { async fn save(&self, goal: &Goal) -> Result<(), DomainError> {
let id = goal.id().value().to_string(); let id = goal.id().value().to_string();
let user_id = goal.user_id().value().to_string(); let user_id = goal.user_id().value().to_string();
@@ -46,7 +41,7 @@ impl GoalRepository for PostgresGoalRepository {
.bind(&created_at) .bind(&created_at)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(()) Ok(())
} }
@@ -60,7 +55,7 @@ impl GoalRepository for PostgresGoalRepository {
.bind(&id) .bind(&id)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
if result.rows_affected() == 0 { if result.rows_affected() == 0 {
return Err(DomainError::NotFound("Goal not found".into())); return Err(DomainError::NotFound("Goal not found".into()));
@@ -77,14 +72,17 @@ impl GoalRepository for PostgresGoalRepository {
.bind(&uid) .bind(&uid)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
if result.rows_affected() == 0 { if result.rows_affected() == 0 {
return Err(DomainError::NotFound("Goal not found".into())); return Err(DomainError::NotFound("Goal not found".into()));
} }
Ok(()) Ok(())
} }
}
#[async_trait]
impl GoalQuery for PostgresGoalRepository {
async fn find_by_user_and_year( async fn find_by_user_and_year(
&self, &self,
user_id: &UserId, user_id: &UserId,
@@ -102,7 +100,7 @@ impl GoalRepository for PostgresGoalRepository {
.bind(y) .bind(y)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
row.map(|r| row_to_goal(&r)).transpose() row.map(|r| row_to_goal(&r)).transpose()
} }
@@ -118,14 +116,10 @@ impl GoalRepository for PostgresGoalRepository {
.bind(&uid) .bind(&uid)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
rows.iter().map(row_to_goal).collect() rows.iter().map(row_to_goal).collect()
} }
async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result<u32, DomainError> {
count_reviews_in_year(&self.pool, user_id, year).await
}
} }
pub(crate) async fn count_reviews_in_year( pub(crate) async fn count_reviews_in_year(

View File

@@ -96,11 +96,6 @@ impl PostgresImportProfileRepository {
pub fn new(pool: PgPool) -> Self { pub fn new(pool: PgPool) -> Self {
Self { pool } Self { pool }
} }
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("DB error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
} }
#[async_trait] #[async_trait]
@@ -118,7 +113,7 @@ impl ImportProfileRepository for PostgresImportProfileRepository {
.execute(&self.pool) .execute(&self.pool)
.await .await
.map(|_| ()) .map(|_| ())
.map_err(Self::map_err) .map_err(adapter_common::map_sqlx_error)
} }
async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<ImportProfile>, DomainError> { async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<ImportProfile>, DomainError> {
@@ -139,7 +134,7 @@ impl ImportProfileRepository for PostgresImportProfileRepository {
.bind(&uid) .bind(&uid)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
rows.into_iter() rows.into_iter()
.map(|r| { .map(|r| {
@@ -184,7 +179,7 @@ impl ImportProfileRepository for PostgresImportProfileRepository {
.bind(&id_str).bind(&uid_str) .bind(&id_str).bind(&uid_str)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
row.map(|r| { row.map(|r| {
Ok(ImportProfile { Ok(ImportProfile {
@@ -212,6 +207,6 @@ impl ImportProfileRepository for PostgresImportProfileRepository {
.execute(&self.pool) .execute(&self.pool)
.await .await
.map(|_| ()) .map(|_| ())
.map_err(Self::map_err) .map_err(adapter_common::map_sqlx_error)
} }
} }

View File

@@ -5,7 +5,6 @@ use domain::{
models::{ models::{
AnnotatedRow, FieldMapping, ImportSession, ParsedFile, AnnotatedRow, FieldMapping, ImportSession, ParsedFile,
import::{DomainField, ImportRow, RowResult, Transform}, import::{DomainField, ImportRow, RowResult, Transform},
import_session::PersistedImportSession,
}, },
ports::ImportSessionRepository, ports::ImportSessionRepository,
value_objects::{ImportSessionId, UserId}, value_objects::{ImportSessionId, UserId},
@@ -203,11 +202,6 @@ impl PostgresImportSessionRepository {
Self { pool } Self { pool }
} }
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("DB error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
fn serialize_session( fn serialize_session(
s: &ImportSession, s: &ImportSession,
) -> Result<(String, Option<String>, Option<String>), DomainError> { ) -> Result<(String, Option<String>, Option<String>), DomainError> {
@@ -267,7 +261,7 @@ impl PostgresImportSessionRepository {
Ok(js.into_iter().map(annotated_from_json).collect()) Ok(js.into_iter().map(annotated_from_json).collect())
}) })
.transpose()?; .transpose()?;
Ok(ImportSession::from_persistence(PersistedImportSession { Ok(ImportSession {
id: ImportSessionId::from_uuid( id: ImportSessionId::from_uuid(
id.parse::<uuid::Uuid>() id.parse::<uuid::Uuid>()
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?, .map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
@@ -282,7 +276,7 @@ impl PostgresImportSessionRepository {
row_results, row_results,
created_at, created_at,
expires_at, expires_at,
})) })
} }
} }
@@ -302,7 +296,7 @@ impl ImportSessionRepository for PostgresImportSessionRepository {
.execute(&self.pool) .execute(&self.pool)
.await .await
.map(|_| ()) .map(|_| ())
.map_err(Self::map_err) .map_err(adapter_common::map_sqlx_error)
} }
async fn get( async fn get(
@@ -332,7 +326,7 @@ impl ImportSessionRepository for PostgresImportSessionRepository {
.bind(&uid_str) .bind(&uid_str)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
row.map(|r| { row.map(|r| {
Self::deserialize_session( Self::deserialize_session(
@@ -360,7 +354,7 @@ impl ImportSessionRepository for PostgresImportSessionRepository {
.execute(&self.pool) .execute(&self.pool)
.await .await
.map(|_| ()) .map(|_| ())
.map_err(Self::map_err) .map_err(adapter_common::map_sqlx_error)
} }
async fn delete(&self, id: &ImportSessionId) -> Result<(), DomainError> { async fn delete(&self, id: &ImportSessionId) -> Result<(), DomainError> {
@@ -370,14 +364,14 @@ impl ImportSessionRepository for PostgresImportSessionRepository {
.execute(&self.pool) .execute(&self.pool)
.await .await
.map(|_| ()) .map(|_| ())
.map_err(Self::map_err) .map_err(adapter_common::map_sqlx_error)
} }
async fn delete_expired(&self) -> Result<u64, DomainError> { async fn delete_expired(&self) -> Result<u64, DomainError> {
let result = sqlx::query("DELETE FROM import_sessions WHERE expires_at < NOW()") let result = sqlx::query("DELETE FROM import_sessions WHERE expires_at < NOW()")
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(result.rows_affected()) Ok(result.rows_affected())
} }
@@ -388,6 +382,6 @@ impl ImportSessionRepository for PostgresImportSessionRepository {
.execute(&self.pool) .execute(&self.pool)
.await .await
.map(|_| ()) .map(|_| ())
.map_err(Self::map_err) .map_err(adapter_common::map_sqlx_error)
} }
} }

View File

@@ -39,30 +39,6 @@ pub use watch_event::{PostgresWatchEventRepository, PostgresWebhookTokenReposito
pub use watchlist::PostgresWatchlistRepository; pub use watchlist::PostgresWatchlistRepository;
pub use wrapup::{PostgresWrapUpRepository, PostgresWrapUpStatsQuery}; pub use wrapup::{PostgresWrapUpRepository, PostgresWrapUpStatsQuery};
pub(crate) fn format_year_month(ym: &str) -> String {
let parts: Vec<&str> = ym.splitn(2, '-').collect();
if parts.len() != 2 {
return ym.to_string();
}
let year = parts[0].get(2..).unwrap_or(parts[0]);
let month = match parts[1] {
"01" => "Jan",
"02" => "Feb",
"03" => "Mar",
"04" => "Apr",
"05" => "May",
"06" => "Jun",
"07" => "Jul",
"08" => "Aug",
"09" => "Sep",
"10" => "Oct",
"11" => "Nov",
"12" => "Dec",
_ => parts[1],
};
format!("{} '{}", month, year)
}
pub async fn migrate(pool: &PgPool) -> Result<(), DomainError> { pub async fn migrate(pool: &PgPool) -> Result<(), DomainError> {
sqlx::migrate!("./migrations") sqlx::migrate!("./migrations")
.set_ignore_missing(true) .set_ignore_missing(true)
@@ -79,9 +55,10 @@ pub fn create_profile_fields_repo(
pub struct PostgresWireOutput { pub struct PostgresWireOutput {
pub pool: PgPool, pub pool: PgPool,
pub movie: std::sync::Arc<dyn domain::ports::MovieRepository>, pub movie_command: std::sync::Arc<dyn domain::ports::MovieCommand>,
pub movie_query: std::sync::Arc<dyn domain::ports::MovieQuery>,
pub review: std::sync::Arc<dyn domain::ports::ReviewRepository>, pub review: std::sync::Arc<dyn domain::ports::ReviewRepository>,
pub diary: std::sync::Arc<dyn domain::ports::DiaryRepository>, pub diary: std::sync::Arc<dyn domain::ports::DiaryQuery>,
pub stats: std::sync::Arc<dyn domain::ports::StatsRepository>, pub stats: std::sync::Arc<dyn domain::ports::StatsRepository>,
pub user: std::sync::Arc<dyn domain::ports::UserRepository>, pub user: std::sync::Arc<dyn domain::ports::UserRepository>,
pub import_session: std::sync::Arc<dyn domain::ports::ImportSessionRepository>, pub import_session: std::sync::Arc<dyn domain::ports::ImportSessionRepository>,
@@ -91,7 +68,8 @@ pub struct PostgresWireOutput {
pub ap_content: std::sync::Arc<dyn domain::ports::LocalApContentQuery>, pub ap_content: std::sync::Arc<dyn domain::ports::LocalApContentQuery>,
pub wrapup_repo: std::sync::Arc<dyn domain::ports::WrapUpRepository>, pub wrapup_repo: std::sync::Arc<dyn domain::ports::WrapUpRepository>,
pub wrapup_stats: std::sync::Arc<dyn domain::ports::WrapUpStatsQuery>, pub wrapup_stats: std::sync::Arc<dyn domain::ports::WrapUpStatsQuery>,
pub goal: std::sync::Arc<dyn domain::ports::GoalRepository>, pub goal_command: std::sync::Arc<dyn domain::ports::GoalCommand>,
pub goal_query: std::sync::Arc<dyn domain::ports::GoalQuery>,
pub user_settings: std::sync::Arc<dyn domain::ports::UserSettingsRepository>, pub user_settings: std::sync::Arc<dyn domain::ports::UserSettingsRepository>,
pub federation_settings: std::sync::Arc<dyn domain::ports::UserFederationSettingsQuery>, pub federation_settings: std::sync::Arc<dyn domain::ports::UserFederationSettingsQuery>,
pub remote_goal: std::sync::Arc<dyn domain::ports::RemoteGoalRepository>, pub remote_goal: std::sync::Arc<dyn domain::ports::RemoteGoalRepository>,
@@ -114,9 +92,12 @@ pub async fn wire(database_url: &str) -> anyhow::Result<PostgresWireOutput> {
user_settings::PostgresUserSettingsRepository::new(pool.clone()), user_settings::PostgresUserSettingsRepository::new(pool.clone()),
); );
let movie_repo = std::sync::Arc::new(PostgresMovieRepository::new(pool.clone()));
Ok(PostgresWireOutput { Ok(PostgresWireOutput {
pool: pool.clone(), pool: pool.clone(),
movie: std::sync::Arc::new(PostgresMovieRepository::new(pool.clone())) as _, movie_command: movie_repo.clone() as _,
movie_query: movie_repo as _,
review: std::sync::Arc::new(PostgresReviewRepository::new(pool.clone())) as _, review: std::sync::Arc::new(PostgresReviewRepository::new(pool.clone())) as _,
diary: std::sync::Arc::new(PostgresDiaryRepository::new(pool.clone())) as _, diary: std::sync::Arc::new(PostgresDiaryRepository::new(pool.clone())) as _,
stats: std::sync::Arc::new(PostgresStatsRepository::new(pool.clone())) as _, stats: std::sync::Arc::new(PostgresStatsRepository::new(pool.clone())) as _,
@@ -130,7 +111,8 @@ pub async fn wire(database_url: &str) -> anyhow::Result<PostgresWireOutput> {
ap_content: std::sync::Arc::new(PostgresApContentQuery::new(pool.clone())) as _, ap_content: std::sync::Arc::new(PostgresApContentQuery::new(pool.clone())) as _,
wrapup_repo: std::sync::Arc::new(PostgresWrapUpRepository::new(pool.clone())) as _, wrapup_repo: std::sync::Arc::new(PostgresWrapUpRepository::new(pool.clone())) as _,
wrapup_stats: std::sync::Arc::new(PostgresWrapUpStatsQuery::new(pool.clone())) as _, wrapup_stats: std::sync::Arc::new(PostgresWrapUpStatsQuery::new(pool.clone())) as _,
goal: std::sync::Arc::new(goals::PostgresGoalRepository::new(pool.clone())) as _, goal_command: std::sync::Arc::new(goals::PostgresGoalRepository::new(pool.clone())) as _,
goal_query: std::sync::Arc::new(goals::PostgresGoalRepository::new(pool.clone())) as _,
user_settings: std::sync::Arc::clone(&user_settings_repo) as _, user_settings: std::sync::Arc::clone(&user_settings_repo) as _,
federation_settings: user_settings_repo as _, federation_settings: user_settings_repo as _,
remote_goal: std::sync::Arc::new(postgres_federation::PostgresRemoteGoalRepository::new( remote_goal: std::sync::Arc::new(postgres_federation::PostgresRemoteGoalRepository::new(

View File

@@ -1,16 +1,11 @@
use chrono::NaiveDateTime; use adapter_common::{
movie_row_to_domain, movie_stats_to_domain, movie_summary_to_domain, review_row_to_domain,
user_summary_to_domain,
};
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::{ models::{DiaryEntry, FeedEntry, Movie, MovieSummary, Review},
DiaryEntry, FeedEntry, Movie, MovieSummary, PersistedReview, Review, ReviewSource,
UserSummary,
},
value_objects::{
Comment, Email, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
ReviewId, UserId, Username,
},
}; };
use uuid::Uuid;
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
pub(crate) struct MovieRow { pub(crate) struct MovieRow {
@@ -24,22 +19,14 @@ pub(crate) struct MovieRow {
impl MovieRow { impl MovieRow {
pub fn into_domain(self) -> Result<Movie, DomainError> { pub fn into_domain(self) -> Result<Movie, DomainError> {
let id = MovieId::from_uuid(parse_uuid(&self.id)?); movie_row_to_domain(
let external_metadata_id = self self.id,
.external_metadata_id self.external_metadata_id,
.map(ExternalMetadataId::new) self.title,
.transpose()?; self.release_year,
let title = MovieTitle::new(self.title)?;
let release_year = ReleaseYear::new(self.release_year as u16)?;
let poster_path = self.poster_path.map(PosterPath::new).transpose()?;
Ok(Movie::from_persistence(
id,
external_metadata_id,
title,
release_year,
self.director, self.director,
poster_path, self.poster_path,
)) )
} }
} }
@@ -60,23 +47,22 @@ pub(crate) struct MovieSummaryRow {
impl MovieSummaryRow { impl MovieSummaryRow {
pub fn into_domain(self) -> Result<MovieSummary, DomainError> { pub fn into_domain(self) -> Result<MovieSummary, DomainError> {
let movie = MovieRow { let movie = movie_row_to_domain(
id: self.id, self.id,
external_metadata_id: self.external_metadata_id, self.external_metadata_id,
title: self.title, self.title,
release_year: self.release_year, self.release_year,
director: self.director, self.director,
poster_path: self.poster_path, self.poster_path,
} )?;
.into_domain()?; Ok(movie_summary_to_domain(
Ok(MovieSummary {
movie, movie,
genres: self.genres.unwrap_or_default(), self.genres.unwrap_or_default(),
runtime_minutes: self.runtime_minutes.map(|v| v as u32), self.runtime_minutes,
original_language: self.original_language, self.original_language,
overview: self.overview, self.overview,
collection_name: self.collection_name, self.collection_name,
}) ))
} }
} }
@@ -95,29 +81,17 @@ pub(crate) struct ReviewRow {
impl ReviewRow { impl ReviewRow {
pub fn into_domain(self) -> Result<Review, DomainError> { pub fn into_domain(self) -> Result<Review, DomainError> {
let id = ReviewId::from_uuid(parse_uuid(&self.id)?); review_row_to_domain(
let movie_id = MovieId::from_uuid(parse_uuid(&self.movie_id)?); self.id,
let user_id = UserId::from_uuid(parse_uuid(&self.user_id)?); self.movie_id,
let rating = Rating::new(self.rating as u8)?; self.user_id,
let comment = self.comment.map(Comment::new).transpose()?; self.rating,
let watched_at = parse_datetime(&self.watched_at)?; self.comment,
let created_at = parse_datetime(&self.created_at)?; self.watched_at,
let source = match self.remote_actor_url { self.created_at,
None => ReviewSource::Local, self.remote_actor_url,
Some(url) => ReviewSource::Remote { actor_url: url }, self.watch_medium,
}; )
let watch_medium = self.watch_medium.map(|s| s.parse()).transpose()?;
Ok(Review::from_persistence(PersistedReview {
id,
movie_id,
user_id,
rating,
comment,
watched_at,
created_at,
source,
watch_medium,
}))
} }
} }
@@ -142,27 +116,25 @@ pub(crate) struct DiaryRow {
impl DiaryRow { impl DiaryRow {
pub fn into_domain(self) -> Result<DiaryEntry, DomainError> { pub fn into_domain(self) -> Result<DiaryEntry, DomainError> {
let movie = MovieRow { let movie = movie_row_to_domain(
id: self.id, self.id,
external_metadata_id: self.external_metadata_id, self.external_metadata_id,
title: self.title, self.title,
release_year: self.release_year, self.release_year,
director: self.director, self.director,
poster_path: self.poster_path, self.poster_path,
} )?;
.into_domain()?; let review = review_row_to_domain(
let review = ReviewRow { self.review_id,
id: self.review_id, self.movie_id,
movie_id: self.movie_id, self.user_id,
user_id: self.user_id, self.rating,
rating: self.rating, self.comment,
comment: self.comment, self.watched_at,
watched_at: self.watched_at, self.created_at,
created_at: self.created_at, self.remote_actor_url,
remote_actor_url: self.remote_actor_url, self.watch_medium,
watch_medium: self.watch_medium, )?;
}
.into_domain()?;
Ok(DiaryEntry::new(movie, review)) Ok(DiaryEntry::new(movie, review))
} }
} }
@@ -189,24 +161,26 @@ pub(crate) struct FeedRow {
impl FeedRow { impl FeedRow {
pub fn into_domain(self) -> Result<FeedEntry, DomainError> { pub fn into_domain(self) -> Result<FeedEntry, DomainError> {
let diary = DiaryRow { let movie = movie_row_to_domain(
id: self.id, self.id,
external_metadata_id: self.external_metadata_id, self.external_metadata_id,
title: self.title, self.title,
release_year: self.release_year, self.release_year,
director: self.director, self.director,
poster_path: self.poster_path, self.poster_path,
review_id: self.review_id, )?;
movie_id: self.movie_id, let review = review_row_to_domain(
user_id: self.user_id, self.review_id,
rating: self.rating, self.movie_id,
comment: self.comment, self.user_id,
watched_at: self.watched_at, self.rating,
created_at: self.created_at, self.comment,
remote_actor_url: self.remote_actor_url, self.watched_at,
watch_medium: self.watch_medium, self.created_at,
} self.remote_actor_url,
.into_domain()?; self.watch_medium,
)?;
let diary = DiaryEntry::new(movie, review);
Ok(FeedEntry::new(diary, self.user_email)) Ok(FeedEntry::new(diary, self.user_email))
} }
} }
@@ -225,18 +199,18 @@ pub(crate) struct MovieStatsRow {
impl MovieStatsRow { impl MovieStatsRow {
pub fn into_domain(self) -> domain::models::MovieStats { pub fn into_domain(self) -> domain::models::MovieStats {
domain::models::MovieStats { movie_stats_to_domain(
total_count: self.total_count as u64, self.total_count,
avg_rating: self.avg_rating, self.avg_rating,
federated_count: self.federated_count as u64, self.federated_count,
rating_histogram: [ [
self.rating_1 as u64, self.rating_1,
self.rating_2 as u64, self.rating_2,
self.rating_3 as u64, self.rating_3,
self.rating_4 as u64, self.rating_4,
self.rating_5 as u64, self.rating_5,
], ],
} )
} }
} }
@@ -252,16 +226,16 @@ pub(crate) struct UserSummaryRow {
} }
impl UserSummaryRow { impl UserSummaryRow {
pub fn into_domain(self) -> Result<UserSummary, DomainError> { pub fn into_domain(self) -> Result<domain::models::UserSummary, DomainError> {
Ok(UserSummary::new( user_summary_to_domain(
UserId::from_uuid(parse_uuid(&self.id)?), self.id,
Email::new(self.email)?, self.email,
Username::new(self.username)?, self.username,
self.display_name, self.display_name,
self.total_movies, self.total_movies,
self.avg_rating, self.avg_rating,
self.avatar_path, self.avatar_path,
)) )
} }
} }
@@ -283,17 +257,3 @@ pub(crate) struct MonthlyRatingRow {
pub avg_rating: f64, pub avg_rating: f64,
pub count: i64, pub count: i64,
} }
pub(crate) fn parse_uuid(s: &str) -> Result<Uuid, DomainError> {
Uuid::parse_str(s)
.map_err(|e| DomainError::InfrastructureError(format!("Invalid UUID '{}': {}", s, e)))
}
pub(crate) fn datetime_to_str(dt: &NaiveDateTime) -> String {
dt.format("%Y-%m-%d %H:%M:%S").to_string()
}
pub(crate) fn parse_datetime(s: &str) -> Result<NaiveDateTime, DomainError> {
NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S")
.map_err(|e| DomainError::InfrastructureError(format!("Invalid datetime '{}': {}", s, e)))
}

View File

@@ -5,7 +5,7 @@ use domain::{
Movie, MovieFilter, MovieSummary, Movie, MovieFilter, MovieSummary,
collections::{PageParams, Paginated}, collections::{PageParams, Paginated},
}, },
ports::MovieRepository, ports::{MovieCommand, MovieQuery},
value_objects::{ExternalMetadataId, MovieId, MovieTitle, ReleaseYear}, value_objects::{ExternalMetadataId, MovieId, MovieTitle, ReleaseYear},
}; };
use sqlx::PgPool; use sqlx::PgPool;
@@ -20,67 +20,10 @@ impl PostgresMovieRepository {
pub fn new(pool: PgPool) -> Self { pub fn new(pool: PgPool) -> Self {
Self { pool } Self { pool }
} }
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
} }
#[async_trait] #[async_trait]
impl MovieRepository for PostgresMovieRepository { impl MovieCommand for PostgresMovieRepository {
async fn get_movie_by_external_id(
&self,
external_metadata_id: &ExternalMetadataId,
) -> Result<Option<Movie>, DomainError> {
let id = external_metadata_id.value();
sqlx::query_as::<_, MovieRow>(
"SELECT id, external_metadata_id, title, release_year, director, poster_path
FROM movies WHERE external_metadata_id = $1",
)
.bind(id)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?
.map(MovieRow::into_domain)
.transpose()
}
async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result<Option<Movie>, DomainError> {
let id = movie_id.value().to_string();
sqlx::query_as::<_, MovieRow>(
"SELECT id, external_metadata_id, title, release_year, director, poster_path
FROM movies WHERE id = $1",
)
.bind(&id)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?
.map(MovieRow::into_domain)
.transpose()
}
async fn get_movies_by_title_and_year(
&self,
title: &MovieTitle,
year: &ReleaseYear,
) -> Result<Vec<Movie>, DomainError> {
let title = title.value();
let year = year.value() as i64;
sqlx::query_as::<_, MovieRow>(
"SELECT id, external_metadata_id, title, release_year, director, poster_path
FROM movies WHERE title = $1 AND release_year = $2",
)
.bind(title)
.bind(year)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?
.into_iter()
.map(MovieRow::into_domain)
.collect()
}
async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError> { async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError> {
let id = movie.id().value().to_string(); let id = movie.id().value().to_string();
let external_metadata_id = movie.external_metadata_id().map(|e| e.value().to_string()); let external_metadata_id = movie.external_metadata_id().map(|e| e.value().to_string());
@@ -107,7 +50,7 @@ impl MovieRepository for PostgresMovieRepository {
.bind(&poster_path) .bind(&poster_path)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(()) Ok(())
} }
@@ -118,9 +61,64 @@ impl MovieRepository for PostgresMovieRepository {
.bind(&id) .bind(&id)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(()) Ok(())
} }
}
#[async_trait]
impl MovieQuery for PostgresMovieRepository {
async fn get_movie_by_external_id(
&self,
external_metadata_id: &ExternalMetadataId,
) -> Result<Option<Movie>, DomainError> {
let id = external_metadata_id.value();
sqlx::query_as::<_, MovieRow>(
"SELECT id, external_metadata_id, title, release_year, director, poster_path
FROM movies WHERE external_metadata_id = $1",
)
.bind(id)
.fetch_optional(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?
.map(MovieRow::into_domain)
.transpose()
}
async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result<Option<Movie>, DomainError> {
let id = movie_id.value().to_string();
sqlx::query_as::<_, MovieRow>(
"SELECT id, external_metadata_id, title, release_year, director, poster_path
FROM movies WHERE id = $1",
)
.bind(&id)
.fetch_optional(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?
.map(MovieRow::into_domain)
.transpose()
}
async fn get_movies_by_title_and_year(
&self,
title: &MovieTitle,
year: &ReleaseYear,
) -> Result<Vec<Movie>, DomainError> {
let title = title.value();
let year = year.value() as i64;
sqlx::query_as::<_, MovieRow>(
"SELECT id, external_metadata_id, title, release_year, director, poster_path
FROM movies WHERE title = $1 AND release_year = $2",
)
.bind(title)
.bind(year)
.fetch_all(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?
.into_iter()
.map(MovieRow::into_domain)
.collect()
}
async fn existing_external_ids( async fn existing_external_ids(
&self, &self,
@@ -136,7 +134,7 @@ impl MovieRepository for PostgresMovieRepository {
.bind(&vals) .bind(&vals)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(rows.into_iter().map(|(id,)| id).collect()) Ok(rows.into_iter().map(|(id,)| id).collect())
} }
@@ -159,7 +157,7 @@ impl MovieRepository for PostgresMovieRepository {
.bind(&years) .bind(&years)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(rows Ok(rows
.into_iter() .into_iter()
.map(|r| { .map(|r| {
@@ -208,7 +206,7 @@ impl MovieRepository for PostgresMovieRepository {
.bind(offset) .bind(offset)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
let total: i64 = sqlx::query( let total: i64 = sqlx::query(
"SELECT COUNT(DISTINCT m.id) \ "SELECT COUNT(DISTINCT m.id) \
@@ -223,7 +221,7 @@ impl MovieRepository for PostgresMovieRepository {
.bind(genre) .bind(genre)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await
.map_err(Self::map_err)? .map_err(adapter_common::map_sqlx_error)?
.try_get(0) .try_get(0)
.unwrap_or(0); .unwrap_or(0);
@@ -247,7 +245,7 @@ impl MovieRepository for PostgresMovieRepository {
) )
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(Self::map_err)? .map_err(adapter_common::map_sqlx_error)?
.into_iter() .into_iter()
.map(|r| r.into_domain()) .map(|r| r.into_domain())
.collect() .collect()

View File

@@ -12,11 +12,6 @@ impl PostgresMovieDeduplicator {
pub fn new(pool: PgPool) -> Self { pub fn new(pool: PgPool) -> Self {
Self { pool } Self { pool }
} }
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
} }
#[async_trait] #[async_trait]
@@ -36,7 +31,11 @@ impl MovieDeduplicator for PostgresMovieDeduplicator {
let director = canonical.director().map(str::to_string); let director = canonical.director().map(str::to_string);
let poster = canonical.poster_path().map(|p| p.value().to_string()); let poster = canonical.poster_path().map(|p| p.value().to_string());
let mut tx = self.pool.begin().await.map_err(Self::map_err)?; let mut tx = self
.pool
.begin()
.await
.map_err(adapter_common::map_sqlx_error)?;
// 1. Upsert canonical movie record // 1. Upsert canonical movie record
sqlx::query( sqlx::query(
@@ -47,7 +46,7 @@ impl MovieDeduplicator for PostgresMovieDeduplicator {
poster_path = COALESCE(EXCLUDED.poster_path, movies.poster_path)", poster_path = COALESCE(EXCLUDED.poster_path, movies.poster_path)",
) )
.bind(&new).bind(&ext_id).bind(&title).bind(year).bind(&director).bind(&poster) .bind(&new).bind(&ext_id).bind(&title).bind(year).bind(&director).bind(&poster)
.execute(&mut *tx).await.map_err(Self::map_err)?; .execute(&mut *tx).await.map_err(adapter_common::map_sqlx_error)?;
// 2. Re-point simple FK tables // 2. Re-point simple FK tables
let reviews = sqlx::query("UPDATE reviews SET movie_id = $1 WHERE movie_id = $2") let reviews = sqlx::query("UPDATE reviews SET movie_id = $1 WHERE movie_id = $2")
@@ -55,7 +54,7 @@ impl MovieDeduplicator for PostgresMovieDeduplicator {
.bind(&old) .bind(&old)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(Self::map_err)? .map_err(adapter_common::map_sqlx_error)?
.rows_affected(); .rows_affected();
let watchlist = let watchlist =
@@ -64,7 +63,7 @@ impl MovieDeduplicator for PostgresMovieDeduplicator {
.bind(&old) .bind(&old)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(Self::map_err)? .map_err(adapter_common::map_sqlx_error)?
.rows_affected(); .rows_affected();
let watch_events = sqlx::query("UPDATE watch_events SET movie_id = $1 WHERE movie_id = $2") let watch_events = sqlx::query("UPDATE watch_events SET movie_id = $1 WHERE movie_id = $2")
@@ -72,7 +71,7 @@ impl MovieDeduplicator for PostgresMovieDeduplicator {
.bind(&old) .bind(&old)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(Self::map_err)? .map_err(adapter_common::map_sqlx_error)?
.rows_affected(); .rows_affected();
// 3. Re-point movie_profiles (PK — move only if canonical has none) // 3. Re-point movie_profiles (PK — move only if canonical has none)
@@ -81,7 +80,7 @@ impl MovieDeduplicator for PostgresMovieDeduplicator {
.bind(&old) .bind(&old)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(Self::map_err)? .map_err(adapter_common::map_sqlx_error)?
.rows_affected(); .rows_affected();
// 4. Re-point enrichment tables with composite PKs (INSERT … ON CONFLICT DO NOTHING + DELETE) // 4. Re-point enrichment tables with composite PKs (INSERT … ON CONFLICT DO NOTHING + DELETE)
@@ -95,12 +94,12 @@ impl MovieDeduplicator for PostgresMovieDeduplicator {
.bind(&old) .bind(&old)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
sqlx::query("DELETE FROM movie_genres WHERE movie_id = $1") sqlx::query("DELETE FROM movie_genres WHERE movie_id = $1")
.bind(&old) .bind(&old)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
sqlx::query( sqlx::query(
"INSERT INTO movie_keywords (movie_id, tmdb_id, name) "INSERT INTO movie_keywords (movie_id, tmdb_id, name)
@@ -111,43 +110,43 @@ impl MovieDeduplicator for PostgresMovieDeduplicator {
.bind(&old) .bind(&old)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
sqlx::query("DELETE FROM movie_keywords WHERE movie_id = $1") sqlx::query("DELETE FROM movie_keywords WHERE movie_id = $1")
.bind(&old) .bind(&old)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
sqlx::query( sqlx::query(
"INSERT INTO movie_cast (movie_id, tmdb_person_id, name, character, billing_order, profile_path) "INSERT INTO movie_cast (movie_id, tmdb_person_id, name, character, billing_order, profile_path)
SELECT $1, tmdb_person_id, name, character, billing_order, profile_path FROM movie_cast WHERE movie_id = $2 SELECT $1, tmdb_person_id, name, character, billing_order, profile_path FROM movie_cast WHERE movie_id = $2
ON CONFLICT DO NOTHING", ON CONFLICT DO NOTHING",
).bind(&new).bind(&old).execute(&mut *tx).await.map_err(Self::map_err)?; ).bind(&new).bind(&old).execute(&mut *tx).await.map_err(adapter_common::map_sqlx_error)?;
sqlx::query("DELETE FROM movie_cast WHERE movie_id = $1") sqlx::query("DELETE FROM movie_cast WHERE movie_id = $1")
.bind(&old) .bind(&old)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
sqlx::query( sqlx::query(
"INSERT INTO movie_crew (movie_id, tmdb_person_id, name, job, department, profile_path) "INSERT INTO movie_crew (movie_id, tmdb_person_id, name, job, department, profile_path)
SELECT $1, tmdb_person_id, name, job, department, profile_path FROM movie_crew WHERE movie_id = $2 SELECT $1, tmdb_person_id, name, job, department, profile_path FROM movie_crew WHERE movie_id = $2
ON CONFLICT DO NOTHING", ON CONFLICT DO NOTHING",
).bind(&new).bind(&old).execute(&mut *tx).await.map_err(Self::map_err)?; ).bind(&new).bind(&old).execute(&mut *tx).await.map_err(adapter_common::map_sqlx_error)?;
sqlx::query("DELETE FROM movie_crew WHERE movie_id = $1") sqlx::query("DELETE FROM movie_crew WHERE movie_id = $1")
.bind(&old) .bind(&old)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
// 5. Delete the now-empty old movie record (remaining cascades are safe: all FKs cleared above) // 5. Delete the now-empty old movie record (remaining cascades are safe: all FKs cleared above)
sqlx::query("DELETE FROM movies WHERE id = $1") sqlx::query("DELETE FROM movies WHERE id = $1")
.bind(&old) .bind(&old)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
tx.commit().await.map_err(Self::map_err)?; tx.commit().await.map_err(adapter_common::map_sqlx_error)?;
Ok(reviews + watchlist + watch_events + profiles) Ok(reviews + watchlist + watch_events + profiles)
} }

View File

@@ -29,10 +29,6 @@ pub fn create_person_adapter(pool: PgPool) -> (Arc<dyn PersonCommand>, Arc<dyn P
) )
} }
fn map_err(e: sqlx::Error) -> DomainError {
DomainError::InfrastructureError(e.to_string())
}
#[async_trait] #[async_trait]
impl PersonCommand for PostgresPersonAdapter { impl PersonCommand for PostgresPersonAdapter {
async fn upsert_batch(&self, persons: &[Person]) -> Result<(), DomainError> { async fn upsert_batch(&self, persons: &[Person]) -> Result<(), DomainError> {
@@ -56,7 +52,7 @@ impl PersonCommand for PostgresPersonAdapter {
.bind(person.profile_path()) .bind(person.profile_path())
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
} }
Ok(()) Ok(())
} }
@@ -89,7 +85,7 @@ impl PersonCommand for PostgresPersonAdapter {
.bind(batch_size as i64) .bind(batch_size as i64)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
let has_more = rows.len() as u32 >= batch_size; let has_more = rows.len() as u32 >= batch_size;
let mut count = 0u64; let mut count = 0u64;
@@ -109,7 +105,7 @@ impl PersonCommand for PostgresPersonAdapter {
.bind(&row.profile_path) .bind(&row.profile_path)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
count += 1; count += 1;
} }
Ok((count, has_more)) Ok((count, has_more))
@@ -137,7 +133,7 @@ impl PersonCommand for PostgresPersonAdapter {
.bind(id.value().to_string()) .bind(id.value().to_string())
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(()) Ok(())
} }
} }
@@ -151,7 +147,7 @@ impl PersonQuery for PostgresPersonAdapter {
.bind(id.value().to_string()) .bind(id.value().to_string())
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(row.map(PersonRow::into_person)) Ok(row.map(PersonRow::into_person))
} }
@@ -166,7 +162,7 @@ impl PersonQuery for PostgresPersonAdapter {
.bind(id.value()) .bind(id.value())
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(row.map(PersonRow::into_person)) Ok(row.map(PersonRow::into_person))
} }
@@ -182,7 +178,7 @@ impl PersonQuery for PostgresPersonAdapter {
.bind(id.value().to_string()) .bind(id.value().to_string())
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(map_err)? .map_err(adapter_common::map_sqlx_error)?
.flatten(); .flatten();
let Some(tmdb_id) = tmdb_id else { let Some(tmdb_id) = tmdb_id else {
@@ -219,7 +215,7 @@ impl PersonQuery for PostgresPersonAdapter {
.bind(tmdb_id) .bind(tmdb_id)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(map_err)? .map_err(adapter_common::map_sqlx_error)?
.into_iter() .into_iter()
.map(|r| CastCredit { .map(|r| CastCredit {
movie_id: MovieId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()), movie_id: MovieId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()),
@@ -238,7 +234,7 @@ impl PersonQuery for PostgresPersonAdapter {
.bind(tmdb_id) .bind(tmdb_id)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(map_err)? .map_err(adapter_common::map_sqlx_error)?
.into_iter() .into_iter()
.map(|r| CrewCredit { .map(|r| CrewCredit {
movie_id: MovieId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()), movie_id: MovieId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()),
@@ -261,7 +257,7 @@ impl PersonQuery for PostgresPersonAdapter {
.bind(offset as i64) .bind(offset as i64)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(rows.into_iter().map(PersonRow::into_person).collect()) Ok(rows.into_iter().map(PersonRow::into_person).collect())
} }
@@ -279,7 +275,7 @@ impl PersonQuery for PostgresPersonAdapter {
) )
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(rows Ok(rows
.into_iter() .into_iter()

View File

@@ -16,11 +16,6 @@ impl PostgresMovieProfileRepository {
pub fn new(pool: PgPool) -> Self { pub fn new(pool: PgPool) -> Self {
Self { pool } Self { pool }
} }
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
} }
#[async_trait] #[async_trait]
@@ -28,7 +23,11 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
async fn upsert(&self, p: &MovieProfile) -> Result<(), DomainError> { async fn upsert(&self, p: &MovieProfile) -> Result<(), DomainError> {
let movie_id = p.movie_id.value().to_string(); let movie_id = p.movie_id.value().to_string();
let mut tx = self.pool.begin().await.map_err(Self::map_err)?; let mut tx = self
.pool
.begin()
.await
.map_err(adapter_common::map_sqlx_error)?;
sqlx::query( sqlx::query(
r#"INSERT INTO movie_profiles r#"INSERT INTO movie_profiles
@@ -61,35 +60,35 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
.bind(p.enriched_at) .bind(p.enriched_at)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
sqlx::query("DELETE FROM movie_genres WHERE movie_id = $1") sqlx::query("DELETE FROM movie_genres WHERE movie_id = $1")
.bind(&movie_id) .bind(&movie_id)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
for g in &p.genres { for g in &p.genres {
sqlx::query("INSERT INTO movie_genres (movie_id, tmdb_id, name) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING") 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) .bind(&movie_id).bind(g.tmdb_id as i32).bind(&g.name)
.execute(&mut *tx).await.map_err(Self::map_err)?; .execute(&mut *tx).await.map_err(adapter_common::map_sqlx_error)?;
} }
sqlx::query("DELETE FROM movie_keywords WHERE movie_id = $1") sqlx::query("DELETE FROM movie_keywords WHERE movie_id = $1")
.bind(&movie_id) .bind(&movie_id)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
for k in &p.keywords { for k in &p.keywords {
sqlx::query("INSERT INTO movie_keywords (movie_id, tmdb_id, name) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING") 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) .bind(&movie_id).bind(k.tmdb_id as i32).bind(&k.name)
.execute(&mut *tx).await.map_err(Self::map_err)?; .execute(&mut *tx).await.map_err(adapter_common::map_sqlx_error)?;
} }
sqlx::query("DELETE FROM movie_cast WHERE movie_id = $1") sqlx::query("DELETE FROM movie_cast WHERE movie_id = $1")
.bind(&movie_id) .bind(&movie_id)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
for c in &p.cast { for c in &p.cast {
sqlx::query( sqlx::query(
"INSERT INTO movie_cast \ "INSERT INTO movie_cast \
@@ -104,14 +103,14 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
.bind(&c.profile_path) .bind(&c.profile_path)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
} }
sqlx::query("DELETE FROM movie_crew WHERE movie_id = $1") sqlx::query("DELETE FROM movie_crew WHERE movie_id = $1")
.bind(&movie_id) .bind(&movie_id)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
for cr in &p.crew { for cr in &p.crew {
sqlx::query( sqlx::query(
"INSERT INTO movie_crew \ "INSERT INTO movie_crew \
@@ -126,10 +125,10 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
.bind(&cr.profile_path) .bind(&cr.profile_path)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
} }
tx.commit().await.map_err(Self::map_err) tx.commit().await.map_err(adapter_common::map_sqlx_error)
} }
async fn get_by_movie_id(&self, id: &MovieId) -> Result<Option<MovieProfile>, DomainError> { async fn get_by_movie_id(&self, id: &MovieId) -> Result<Option<MovieProfile>, DomainError> {
@@ -144,7 +143,7 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
.bind(&movie_id) .bind(&movie_id)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
let row = match row { let row = match row {
Some(r) => r, Some(r) => r,
@@ -159,7 +158,7 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
.bind(&movie_id) .bind(&movie_id)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(Self::map_err)? .map_err(adapter_common::map_sqlx_error)?
.into_iter() .into_iter()
.map(|r| Genre { .map(|r| Genre {
tmdb_id: r.try_get::<i32, _>("tmdb_id").unwrap_or(0) as u32, tmdb_id: r.try_get::<i32, _>("tmdb_id").unwrap_or(0) as u32,
@@ -171,7 +170,7 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
.bind(&movie_id) .bind(&movie_id)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(Self::map_err)? .map_err(adapter_common::map_sqlx_error)?
.into_iter() .into_iter()
.map(|r| Keyword { .map(|r| Keyword {
tmdb_id: r.try_get::<i32, _>("tmdb_id").unwrap_or(0) as u32, tmdb_id: r.try_get::<i32, _>("tmdb_id").unwrap_or(0) as u32,
@@ -186,7 +185,7 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
.bind(&movie_id) .bind(&movie_id)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(Self::map_err)? .map_err(adapter_common::map_sqlx_error)?
.into_iter() .into_iter()
.map(|r| CastMember { .map(|r| CastMember {
tmdb_person_id: r.try_get::<i64, _>("tmdb_person_id").unwrap_or(0) as u64, tmdb_person_id: r.try_get::<i64, _>("tmdb_person_id").unwrap_or(0) as u64,
@@ -204,7 +203,7 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
.bind(&movie_id) .bind(&movie_id)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(Self::map_err)? .map_err(adapter_common::map_sqlx_error)?
.into_iter() .into_iter()
.map(|r| CrewMember { .map(|r| CrewMember {
tmdb_person_id: r.try_get::<i64, _>("tmdb_person_id").unwrap_or(0) as u64, tmdb_person_id: r.try_get::<i64, _>("tmdb_person_id").unwrap_or(0) as u64,
@@ -257,7 +256,7 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
.bind(threshold) .bind(threshold)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(rows Ok(rows
.into_iter() .into_iter()

View File

@@ -16,10 +16,6 @@ impl PostgresRefreshSessionAdapter {
} }
} }
fn map_err(e: sqlx::Error) -> DomainError {
DomainError::InfrastructureError(e.to_string())
}
#[async_trait] #[async_trait]
impl RefreshSessionRepository for PostgresRefreshSessionAdapter { impl RefreshSessionRepository for PostgresRefreshSessionAdapter {
async fn create(&self, session: &RefreshSession) -> Result<(), DomainError> { async fn create(&self, session: &RefreshSession) -> Result<(), DomainError> {
@@ -34,7 +30,7 @@ impl RefreshSessionRepository for PostgresRefreshSessionAdapter {
.bind(session.created_at) .bind(session.created_at)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(()) Ok(())
} }
@@ -48,7 +44,7 @@ impl RefreshSessionRepository for PostgresRefreshSessionAdapter {
.bind(token) .bind(token)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
row.map(RefreshSessionRow::into_domain).transpose() row.map(RefreshSessionRow::into_domain).transpose()
} }
@@ -58,7 +54,7 @@ impl RefreshSessionRepository for PostgresRefreshSessionAdapter {
.bind(token) .bind(token)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(()) Ok(())
} }
@@ -67,7 +63,7 @@ impl RefreshSessionRepository for PostgresRefreshSessionAdapter {
.bind(user_id.value().to_string()) .bind(user_id.value().to_string())
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(()) Ok(())
} }
@@ -75,7 +71,7 @@ impl RefreshSessionRepository for PostgresRefreshSessionAdapter {
let result = sqlx::query("DELETE FROM refresh_sessions WHERE expires_at < NOW()") let result = sqlx::query("DELETE FROM refresh_sessions WHERE expires_at < NOW()")
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(result.rows_affected()) Ok(result.rows_affected())
} }
} }

View File

@@ -1,14 +1,14 @@
use async_trait::async_trait; use async_trait::async_trait;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent,
models::{Review, ReviewSource}, models::{Review, ReviewSource},
ports::ReviewRepository, ports::ReviewRepository,
value_objects::{ReviewId, UserId}, value_objects::{ReviewId, UserId},
}; };
use sqlx::PgPool; use sqlx::PgPool;
use crate::models::{ReviewRow, datetime_to_str}; use crate::models::ReviewRow;
use adapter_common::datetime_to_str;
pub struct PostgresReviewRepository { pub struct PostgresReviewRepository {
pool: PgPool, pool: PgPool,
@@ -18,16 +18,11 @@ impl PostgresReviewRepository {
pub fn new(pool: PgPool) -> Self { pub fn new(pool: PgPool) -> Self {
Self { pool } Self { pool }
} }
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
} }
#[async_trait] #[async_trait]
impl ReviewRepository for PostgresReviewRepository { impl ReviewRepository for PostgresReviewRepository {
async fn save_review(&self, review: &Review) -> Result<DomainEvent, DomainError> { async fn save_review(&self, review: &Review) -> Result<(), DomainError> {
let id = review.id().value().to_string(); let id = review.id().value().to_string();
let movie_id = review.movie_id().value().to_string(); let movie_id = review.movie_id().value().to_string();
let user_id = review.user_id().value().to_string(); let user_id = review.user_id().value().to_string();
@@ -55,15 +50,9 @@ impl ReviewRepository for PostgresReviewRepository {
.bind(review.watch_medium().map(|wm| wm.to_string())) .bind(review.watch_medium().map(|wm| wm.to_string()))
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(DomainEvent::ReviewLogged { Ok(())
review_id: review.id().clone(),
movie_id: review.movie_id().clone(),
user_id: review.user_id().clone(),
rating: review.rating().clone(),
watched_at: *review.watched_at(),
})
} }
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError> { async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError> {
@@ -79,7 +68,7 @@ impl ReviewRepository for PostgresReviewRepository {
.bind(&id) .bind(&id)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(Self::map_err)? .map_err(adapter_common::map_sqlx_error)?
.map(ReviewRow::into_domain) .map(ReviewRow::into_domain)
.transpose() .transpose()
} }
@@ -101,7 +90,7 @@ impl ReviewRepository for PostgresReviewRepository {
.bind(&id) .bind(&id)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(()) Ok(())
} }
@@ -112,7 +101,7 @@ impl ReviewRepository for PostgresReviewRepository {
.bind(&id) .bind(&id)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(()) Ok(())
} }
@@ -129,7 +118,7 @@ impl ReviewRepository for PostgresReviewRepository {
.bind(&uid) .bind(&uid)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(Self::map_err)? .map_err(adapter_common::map_sqlx_error)?
.into_iter() .into_iter()
.map(ReviewRow::into_domain) .map(ReviewRow::into_domain)
.collect() .collect()

View File

@@ -7,8 +7,8 @@ use domain::{
}; };
use sqlx::PgPool; use sqlx::PgPool;
use crate::format_year_month;
use crate::models::{DirectorCountRow, MonthlyRatingRow, UserTotalsRow}; use crate::models::{DirectorCountRow, MonthlyRatingRow, UserTotalsRow};
use adapter_common::format_year_month;
pub struct PostgresStatsRepository { pub struct PostgresStatsRepository {
pool: PgPool, pool: PgPool,
@@ -19,11 +19,6 @@ impl PostgresStatsRepository {
Self { pool } Self { pool }
} }
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
async fn fetch_user_totals(&self, user_id: &str) -> Result<UserTotalsRow, DomainError> { async fn fetch_user_totals(&self, user_id: &str) -> Result<UserTotalsRow, DomainError> {
sqlx::query_as::<_, UserTotalsRow>( sqlx::query_as::<_, UserTotalsRow>(
r#"SELECT COUNT(DISTINCT movie_id) AS total, r#"SELECT COUNT(DISTINCT movie_id) AS total,
@@ -33,7 +28,7 @@ impl PostgresStatsRepository {
.bind(user_id) .bind(user_id)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await
.map_err(Self::map_err) .map_err(adapter_common::map_sqlx_error)
} }
async fn fetch_user_favorite_director( async fn fetch_user_favorite_director(
@@ -52,7 +47,7 @@ impl PostgresStatsRepository {
.bind(user_id) .bind(user_id)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(Self::map_err) .map_err(adapter_common::map_sqlx_error)
} }
async fn fetch_user_most_active_month( async fn fetch_user_most_active_month(
@@ -70,7 +65,7 @@ impl PostgresStatsRepository {
.bind(user_id) .bind(user_id)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(Self::map_err) .map_err(adapter_common::map_sqlx_error)
} }
} }
@@ -95,6 +90,10 @@ impl StatsRepository for PostgresStatsRepository {
}) })
} }
async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result<u32, DomainError> {
crate::goals::count_reviews_in_year(&self.pool, user_id, year).await
}
async fn get_user_trends(&self, user_id: &UserId) -> Result<UserTrends, DomainError> { async fn get_user_trends(&self, user_id: &UserId) -> Result<UserTrends, DomainError> {
let uid = user_id.value().to_string(); let uid = user_id.value().to_string();
@@ -122,7 +121,7 @@ impl StatsRepository for PostgresStatsRepository {
.bind(&uid) .bind(&uid)
.fetch_all(&self.pool) .fetch_all(&self.pool)
) )
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
let max_director_count = director_rows.iter().map(|d| d.count).max().unwrap_or(1); let max_director_count = director_rows.iter().map(|d| d.count).max().unwrap_or(1);

View File

@@ -15,11 +15,6 @@ impl PostgresUserSettingsRepository {
pub fn new(pool: PgPool) -> Self { pub fn new(pool: PgPool) -> Self {
Self { pool } Self { pool }
} }
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
} }
#[async_trait] #[async_trait]
@@ -33,7 +28,7 @@ impl UserSettingsRepository for PostgresUserSettingsRepository {
.bind(&uid) .bind(&uid)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
match row { match row {
Some(r) => { Some(r) => {
@@ -65,7 +60,7 @@ impl UserSettingsRepository for PostgresUserSettingsRepository {
.bind(settings.federate_watchlist()) .bind(settings.federate_watchlist())
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(()) Ok(())
} }
} }
@@ -81,7 +76,7 @@ impl UserFederationSettingsQuery for PostgresUserSettingsRepository {
.bind(&uid) .bind(&uid)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
match row { match row {
Some(r) => { Some(r) => {

View File

@@ -20,11 +20,6 @@ impl PostgresUserRepository {
Self { pool } Self { pool }
} }
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
fn parse_role(s: &str) -> UserRole { fn parse_role(s: &str) -> UserRole {
match s { match s {
"admin" => UserRole::Admin, "admin" => UserRole::Admin,
@@ -76,7 +71,7 @@ impl UserRepository for PostgresUserRepository {
.bind(email_str) .bind(email_str)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
row.as_ref() row.as_ref()
.map(|r| Self::row_to_user(r, vec![])) .map(|r| Self::row_to_user(r, vec![]))
.transpose() .transpose()
@@ -90,7 +85,7 @@ impl UserRepository for PostgresUserRepository {
.bind(username_str) .bind(username_str)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
row.as_ref() row.as_ref()
.map(|r| Self::row_to_user(r, vec![])) .map(|r| Self::row_to_user(r, vec![]))
.transpose() .transpose()
@@ -130,7 +125,7 @@ impl UserRepository for PostgresUserRepository {
.bind(role) .bind(role)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(()) Ok(())
} }
@@ -140,7 +135,7 @@ impl UserRepository for PostgresUserRepository {
.bind(&id_str) .bind(&id_str)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
let Some(r) = row else { return Ok(None) }; let Some(r) = row else { return Ok(None) };
@@ -197,7 +192,7 @@ impl UserRepository for PostgresUserRepository {
) )
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(Self::map_err)? .map_err(adapter_common::map_sqlx_error)?
.into_iter() .into_iter()
.map(UserSummaryRow::into_domain) .map(UserSummaryRow::into_domain)
.collect() .collect()

View File

@@ -2,17 +2,12 @@ use async_trait::async_trait;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::{PersistedWatchEvent, WatchEvent, WatchEventSource, WatchEventStatus, WebhookToken}, models::{PersistedWatchEvent, WatchEvent, WatchEventSource, WatchEventStatus, WebhookToken},
ports::{WatchEventRepository, WebhookTokenRepository}, ports::{WatchEventCommand, WatchEventQuery, WebhookTokenRepository},
value_objects::{MovieId, UserId, WatchEventId, WebhookTokenId}, value_objects::{MovieId, UserId, WatchEventId, WebhookTokenId},
}; };
use sqlx::{PgPool, Row}; use sqlx::{PgPool, Row};
use crate::models::{parse_datetime, parse_uuid}; use adapter_common::{parse_datetime, parse_uuid};
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
// ── WatchEventRepository ────────────────────────────────────────────────────── // ── WatchEventRepository ──────────────────────────────────────────────────────
@@ -27,7 +22,7 @@ impl PostgresWatchEventRepository {
} }
#[async_trait] #[async_trait]
impl WatchEventRepository for PostgresWatchEventRepository { impl WatchEventCommand for PostgresWatchEventRepository {
async fn save(&self, event: &WatchEvent) -> Result<(), DomainError> { async fn save(&self, event: &WatchEvent) -> Result<(), DomainError> {
let id = event.id().value().to_string(); let id = event.id().value().to_string();
let user_id = event.user_id().value().to_string(); let user_id = event.user_id().value().to_string();
@@ -52,7 +47,7 @@ impl WatchEventRepository for PostgresWatchEventRepository {
.bind(event.created_at()) .bind(event.created_at())
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(()) Ok(())
} }
@@ -70,11 +65,46 @@ impl WatchEventRepository for PostgresWatchEventRepository {
.bind(&id_str) .bind(&id_str)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(()) Ok(())
} }
async fn update_status_batch(
&self,
ids: &[WatchEventId],
status: WatchEventStatus,
) -> Result<u64, DomainError> {
if ids.is_empty() {
return Ok(0);
}
let id_strs: Vec<String> = ids.iter().map(|id| id.value().to_string()).collect();
let status_str = status.to_string();
let result = sqlx::query("UPDATE watch_events SET status = $1 WHERE id = ANY($2)")
.bind(&status_str)
.bind(&id_strs)
.execute(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
Ok(result.rows_affected())
}
async fn delete_non_pending_older_than(
&self,
before: chrono::NaiveDateTime,
) -> Result<u64, DomainError> {
let result =
sqlx::query("DELETE FROM watch_events WHERE status != 'pending' AND created_at < $1")
.bind(before)
.execute(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
Ok(result.rows_affected())
}
}
#[async_trait]
impl WatchEventQuery for PostgresWatchEventRepository {
async fn list_pending(&self, user_id: &UserId) -> Result<Vec<WatchEvent>, DomainError> { async fn list_pending(&self, user_id: &UserId) -> Result<Vec<WatchEvent>, DomainError> {
let uid = user_id.value().to_string(); let uid = user_id.value().to_string();
@@ -91,7 +121,7 @@ impl WatchEventRepository for PostgresWatchEventRepository {
.bind(&uid) .bind(&uid)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
rows.iter().map(row_to_watch_event).collect() rows.iter().map(row_to_watch_event).collect()
} }
@@ -110,7 +140,7 @@ impl WatchEventRepository for PostgresWatchEventRepository {
.bind(&id_str) .bind(&id_str)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
row.as_ref().map(row_to_watch_event).transpose() row.as_ref().map(row_to_watch_event).transpose()
} }
@@ -131,29 +161,10 @@ impl WatchEventRepository for PostgresWatchEventRepository {
.bind(&id_strs) .bind(&id_strs)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
rows.iter().map(row_to_watch_event).collect() rows.iter().map(row_to_watch_event).collect()
} }
async fn update_status_batch(
&self,
ids: &[WatchEventId],
status: WatchEventStatus,
) -> Result<u64, DomainError> {
if ids.is_empty() {
return Ok(0);
}
let id_strs: Vec<String> = ids.iter().map(|id| id.value().to_string()).collect();
let status_str = status.to_string();
let result = sqlx::query("UPDATE watch_events SET status = $1 WHERE id = ANY($2)")
.bind(&status_str)
.bind(&id_strs)
.execute(&self.pool)
.await
.map_err(map_err)?;
Ok(result.rows_affected())
}
async fn find_duplicate( async fn find_duplicate(
&self, &self,
user_id: &UserId, user_id: &UserId,
@@ -171,36 +182,41 @@ impl WatchEventRepository for PostgresWatchEventRepository {
.bind(after) .bind(after)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(count > 0) Ok(count > 0)
} }
async fn delete_non_pending_older_than(
&self,
before: chrono::NaiveDateTime,
) -> Result<u64, DomainError> {
let result =
sqlx::query("DELETE FROM watch_events WHERE status != 'pending' AND created_at < $1")
.bind(before)
.execute(&self.pool)
.await
.map_err(map_err)?;
Ok(result.rows_affected())
}
} }
fn row_to_watch_event(row: &sqlx::postgres::PgRow) -> Result<WatchEvent, DomainError> { fn row_to_watch_event(row: &sqlx::postgres::PgRow) -> Result<WatchEvent, DomainError> {
let id_str: String = row.try_get("id").map_err(map_err)?; let id_str: String = row.try_get("id").map_err(adapter_common::map_sqlx_error)?;
let user_id_str: String = row.try_get("user_id").map_err(map_err)?; let user_id_str: String = row
let movie_id_str: Option<String> = row.try_get("movie_id").map_err(map_err)?; .try_get("user_id")
let title: String = row.try_get("title").map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
let year: Option<i32> = row.try_get("year").map_err(map_err)?; let movie_id_str: Option<String> = row
let ext_id: Option<String> = row.try_get("external_metadata_id").map_err(map_err)?; .try_get("movie_id")
let source_str: String = row.try_get("source").map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
let watched_at_str: String = row.try_get("watched_at").map_err(map_err)?; let title: String = row
let status_str: String = row.try_get("status").map_err(map_err)?; .try_get("title")
let created_at_str: String = row.try_get("created_at").map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
let year: Option<i32> = row
.try_get("year")
.map_err(adapter_common::map_sqlx_error)?;
let ext_id: Option<String> = row
.try_get("external_metadata_id")
.map_err(adapter_common::map_sqlx_error)?;
let source_str: String = row
.try_get("source")
.map_err(adapter_common::map_sqlx_error)?;
let watched_at_str: String = row
.try_get("watched_at")
.map_err(adapter_common::map_sqlx_error)?;
let status_str: String = row
.try_get("status")
.map_err(adapter_common::map_sqlx_error)?;
let created_at_str: String = row
.try_get("created_at")
.map_err(adapter_common::map_sqlx_error)?;
let source: WatchEventSource = source_str let source: WatchEventSource = source_str
.parse() .parse()
@@ -262,7 +278,7 @@ impl WebhookTokenRepository for PostgresWebhookTokenRepository {
.bind(token.last_used_at()) .bind(token.last_used_at())
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(()) Ok(())
} }
@@ -277,7 +293,7 @@ impl WebhookTokenRepository for PostgresWebhookTokenRepository {
.bind(hash) .bind(hash)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
row.as_ref().map(row_to_webhook_token).transpose() row.as_ref().map(row_to_webhook_token).transpose()
} }
@@ -294,7 +310,7 @@ impl WebhookTokenRepository for PostgresWebhookTokenRepository {
.bind(&uid) .bind(&uid)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
rows.iter().map(row_to_webhook_token).collect() rows.iter().map(row_to_webhook_token).collect()
} }
@@ -308,7 +324,7 @@ impl WebhookTokenRepository for PostgresWebhookTokenRepository {
.bind(&uid) .bind(&uid)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
if result.rows_affected() == 0 { if result.rows_affected() == 0 {
return Err(DomainError::NotFound(format!("Webhook token {id_str}"))); return Err(DomainError::NotFound(format!("Webhook token {id_str}")));
@@ -323,20 +339,32 @@ impl WebhookTokenRepository for PostgresWebhookTokenRepository {
.bind(&id_str) .bind(&id_str)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(()) Ok(())
} }
} }
fn row_to_webhook_token(row: &sqlx::postgres::PgRow) -> Result<WebhookToken, DomainError> { fn row_to_webhook_token(row: &sqlx::postgres::PgRow) -> Result<WebhookToken, DomainError> {
let id_str: String = row.try_get("id").map_err(map_err)?; let id_str: String = row.try_get("id").map_err(adapter_common::map_sqlx_error)?;
let user_id_str: String = row.try_get("user_id").map_err(map_err)?; let user_id_str: String = row
let token_hash: String = row.try_get("token_hash").map_err(map_err)?; .try_get("user_id")
let provider_str: String = row.try_get("provider").map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
let label: Option<String> = row.try_get("label").map_err(map_err)?; let token_hash: String = row
let created_at_str: String = row.try_get("created_at").map_err(map_err)?; .try_get("token_hash")
let last_used_str: Option<String> = row.try_get("last_used_at").map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
let provider_str: String = row
.try_get("provider")
.map_err(adapter_common::map_sqlx_error)?;
let label: Option<String> = row
.try_get("label")
.map_err(adapter_common::map_sqlx_error)?;
let created_at_str: String = row
.try_get("created_at")
.map_err(adapter_common::map_sqlx_error)?;
let last_used_str: Option<String> = row
.try_get("last_used_at")
.map_err(adapter_common::map_sqlx_error)?;
let provider: WatchEventSource = provider_str let provider: WatchEventSource = provider_str
.parse() .parse()

View File

@@ -10,7 +10,8 @@ use domain::{
}; };
use sqlx::{PgPool, Row}; use sqlx::{PgPool, Row};
use crate::models::{MovieRow, parse_datetime, parse_uuid}; use crate::models::MovieRow;
use adapter_common::{parse_datetime, parse_uuid};
pub struct PostgresWatchlistRepository { pub struct PostgresWatchlistRepository {
pool: PgPool, pool: PgPool,
@@ -20,11 +21,6 @@ impl PostgresWatchlistRepository {
pub fn new(pool: PgPool) -> Self { pub fn new(pool: PgPool) -> Self {
Self { pool } Self { pool }
} }
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
} }
#[async_trait] #[async_trait]
@@ -46,7 +42,7 @@ impl WatchlistRepository for PostgresWatchlistRepository {
.bind(added_at) .bind(added_at)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(()) Ok(())
} }
@@ -61,7 +57,7 @@ impl WatchlistRepository for PostgresWatchlistRepository {
.bind(&mid) .bind(&mid)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
if result.rows_affected() == 0 { if result.rows_affected() == 0 {
return Err(DomainError::NotFound(format!( return Err(DomainError::NotFound(format!(
@@ -85,7 +81,7 @@ impl WatchlistRepository for PostgresWatchlistRepository {
.bind(&mid) .bind(&mid)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(result.rows_affected() > 0) Ok(result.rows_affected() > 0)
} }
@@ -114,14 +110,14 @@ impl WatchlistRepository for PostgresWatchlistRepository {
.bind(offset) .bind(offset)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
let total: i64 = let total: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM watchlist_entries WHERE user_id = $1") sqlx::query_scalar("SELECT COUNT(*) FROM watchlist_entries WHERE user_id = $1")
.bind(&uid) .bind(&uid)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
let items = rows let items = rows
.into_iter() .into_iter()
@@ -187,7 +183,7 @@ impl WatchlistRepository for PostgresWatchlistRepository {
.bind(&mid) .bind(&mid)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(count > 0) Ok(count > 0)
} }
} }

View File

@@ -13,12 +13,7 @@ use domain::{
use sqlx::{PgPool, Row}; use sqlx::{PgPool, Row};
use uuid::Uuid; use uuid::Uuid;
use crate::models::{parse_datetime, parse_uuid}; use adapter_common::{parse_datetime, parse_uuid};
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
fn status_to_str(s: &WrapUpStatus) -> &'static str { fn status_to_str(s: &WrapUpStatus) -> &'static str {
match s { match s {
@@ -76,7 +71,7 @@ impl WrapUpRepository for PostgresWrapUpRepository {
.bind(record.completed_at) .bind(record.completed_at)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(()) Ok(())
} }
@@ -96,7 +91,7 @@ impl WrapUpRepository for PostgresWrapUpRepository {
.bind(&id_str) .bind(&id_str)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(()) Ok(())
} }
@@ -115,7 +110,7 @@ impl WrapUpRepository for PostgresWrapUpRepository {
.bind(&id_str) .bind(&id_str)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(()) Ok(())
} }
@@ -132,7 +127,7 @@ impl WrapUpRepository for PostgresWrapUpRepository {
.bind(&id_str) .bind(&id_str)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
row.as_ref().map(row_to_record).transpose() row.as_ref().map(row_to_record).transpose()
} }
@@ -149,7 +144,7 @@ impl WrapUpRepository for PostgresWrapUpRepository {
.bind(&uid) .bind(&uid)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
rows.iter().map(row_to_record).collect() rows.iter().map(row_to_record).collect()
} }
@@ -163,7 +158,7 @@ impl WrapUpRepository for PostgresWrapUpRepository {
) )
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
rows.iter().map(row_to_record).collect() rows.iter().map(row_to_record).collect()
} }
@@ -190,7 +185,7 @@ impl WrapUpRepository for PostgresWrapUpRepository {
.bind(end) .bind(end)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
row.as_ref().map(row_to_record).transpose() row.as_ref().map(row_to_record).transpose()
} }
@@ -200,7 +195,7 @@ impl WrapUpRepository for PostgresWrapUpRepository {
.bind(id.value().to_string()) .bind(id.value().to_string())
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(()) Ok(())
} }
@@ -213,21 +208,37 @@ impl WrapUpRepository for PostgresWrapUpRepository {
.bind(before) .bind(before)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(result.rows_affected()) Ok(result.rows_affected())
} }
} }
fn row_to_record(row: &sqlx::postgres::PgRow) -> Result<WrapUpRecord, DomainError> { fn row_to_record(row: &sqlx::postgres::PgRow) -> Result<WrapUpRecord, DomainError> {
let id_str: String = row.try_get("id").map_err(map_err)?; let id_str: String = row.try_get("id").map_err(adapter_common::map_sqlx_error)?;
let user_id_str: Option<String> = row.try_get("user_id").map_err(map_err)?; let user_id_str: Option<String> = row
let start_date: NaiveDate = row.try_get("start_date").map_err(map_err)?; .try_get("user_id")
let end_date: NaiveDate = row.try_get("end_date").map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
let status_str: String = row.try_get("status").map_err(map_err)?; let start_date: NaiveDate = row
let report_json: Option<String> = row.try_get("report_json").map_err(map_err)?; .try_get("start_date")
let error_message: Option<String> = row.try_get("error_message").map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
let created_at_str: String = row.try_get("created_at").map_err(map_err)?; let end_date: NaiveDate = row
let completed_at_str: Option<String> = row.try_get("completed_at").map_err(map_err)?; .try_get("end_date")
.map_err(adapter_common::map_sqlx_error)?;
let status_str: String = row
.try_get("status")
.map_err(adapter_common::map_sqlx_error)?;
let report_json: Option<String> = row
.try_get("report_json")
.map_err(adapter_common::map_sqlx_error)?;
let error_message: Option<String> = row
.try_get("error_message")
.map_err(adapter_common::map_sqlx_error)?;
let created_at_str: String = row
.try_get("created_at")
.map_err(adapter_common::map_sqlx_error)?;
let completed_at_str: Option<String> = row
.try_get("completed_at")
.map_err(adapter_common::map_sqlx_error)?;
let user_id = user_id_str.as_deref().map(parse_uuid).transpose()?; let user_id = user_id_str.as_deref().map(parse_uuid).transpose()?;
@@ -292,7 +303,10 @@ impl WrapUpStatsQuery for PostgresWrapUpStatsQuery {
q = q.bind(uid); q = q.bind(uid);
} }
let rows = q.fetch_all(&self.pool).await.map_err(map_err)?; let rows = q
.fetch_all(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
if rows.is_empty() { if rows.is_empty() {
return Ok(vec![]); return Ok(vec![]);
@@ -302,7 +316,9 @@ impl WrapUpStatsQuery for PostgresWrapUpStatsQuery {
let mut movie_ids: Vec<String> = Vec::new(); let mut movie_ids: Vec<String> = Vec::new();
let mut seen = std::collections::HashSet::new(); let mut seen = std::collections::HashSet::new();
for row in &rows { for row in &rows {
let mid: String = row.try_get("movie_id").map_err(map_err)?; let mid: String = row
.try_get("movie_id")
.map_err(adapter_common::map_sqlx_error)?;
if seen.insert(mid.clone()) { if seen.insert(mid.clone()) {
movie_ids.push(mid); movie_ids.push(mid);
} }
@@ -318,18 +334,39 @@ impl WrapUpStatsQuery for PostgresWrapUpStatsQuery {
// 3) Build result // 3) Build result
let mut result = Vec::with_capacity(rows.len()); let mut result = Vec::with_capacity(rows.len());
for row in &rows { for row in &rows {
let movie_id_str: String = row.try_get("movie_id").map_err(map_err)?; let movie_id_str: String = row
let title: String = row.try_get("title").map_err(map_err)?; .try_get("movie_id")
let release_year: i64 = row.try_get("release_year").map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
let director: Option<String> = row.try_get("director").map_err(map_err)?; let title: String = row
let poster_path: Option<String> = row.try_get("poster_path").map_err(map_err)?; .try_get("title")
let rating: i64 = row.try_get("rating").map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
let watched_at_str: String = row.try_get("watched_at").map_err(map_err)?; let release_year: i64 = row
let user_id_str: String = row.try_get("user_id").map_err(map_err)?; .try_get("release_year")
let runtime_minutes: Option<i32> = row.try_get("runtime_minutes").map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
let budget_usd: Option<i64> = row.try_get("budget_usd").map_err(map_err)?; let director: Option<String> = row
let original_language: Option<String> = .try_get("director")
row.try_get("original_language").map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
let poster_path: Option<String> = row
.try_get("poster_path")
.map_err(adapter_common::map_sqlx_error)?;
let rating: i64 = row
.try_get("rating")
.map_err(adapter_common::map_sqlx_error)?;
let watched_at_str: String = row
.try_get("watched_at")
.map_err(adapter_common::map_sqlx_error)?;
let user_id_str: String = row
.try_get("user_id")
.map_err(adapter_common::map_sqlx_error)?;
let runtime_minutes: Option<i32> = row
.try_get("runtime_minutes")
.map_err(adapter_common::map_sqlx_error)?;
let budget_usd: Option<i64> = row
.try_get("budget_usd")
.map_err(adapter_common::map_sqlx_error)?;
let original_language: Option<String> = row
.try_get("original_language")
.map_err(adapter_common::map_sqlx_error)?;
let genres = genres_map.get(&movie_id_str).cloned().unwrap_or_default(); let genres = genres_map.get(&movie_id_str).cloned().unwrap_or_default();
let keywords = keywords_map.get(&movie_id_str).cloned().unwrap_or_default(); let keywords = keywords_map.get(&movie_id_str).cloned().unwrap_or_default();
@@ -383,12 +420,16 @@ async fn fetch_genres_pg(
.bind(movie_ids) .bind(movie_ids)
.fetch_all(pool) .fetch_all(pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
let mut map: HashMap<String, Vec<String>> = HashMap::new(); let mut map: HashMap<String, Vec<String>> = HashMap::new();
for row in rows { for row in rows {
let mid: String = row.try_get("movie_id").map_err(map_err)?; let mid: String = row
let name: String = row.try_get("name").map_err(map_err)?; .try_get("movie_id")
.map_err(adapter_common::map_sqlx_error)?;
let name: String = row
.try_get("name")
.map_err(adapter_common::map_sqlx_error)?;
map.entry(mid).or_default().push(name); map.entry(mid).or_default().push(name);
} }
Ok(map) Ok(map)
@@ -404,12 +445,16 @@ async fn fetch_keywords_pg(
.bind(movie_ids) .bind(movie_ids)
.fetch_all(pool) .fetch_all(pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
let mut map: HashMap<String, Vec<String>> = HashMap::new(); let mut map: HashMap<String, Vec<String>> = HashMap::new();
for row in rows { for row in rows {
let mid: String = row.try_get("movie_id").map_err(map_err)?; let mid: String = row
let name: String = row.try_get("name").map_err(map_err)?; .try_get("movie_id")
.map_err(adapter_common::map_sqlx_error)?;
let name: String = row
.try_get("name")
.map_err(adapter_common::map_sqlx_error)?;
map.entry(mid).or_default().push(name); map.entry(mid).or_default().push(name);
} }
Ok(map) Ok(map)
@@ -428,15 +473,25 @@ async fn fetch_cast_pg(
.bind(movie_ids) .bind(movie_ids)
.fetch_all(pool) .fetch_all(pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
let mut map: HashMap<String, Vec<CastEntry>> = HashMap::new(); let mut map: HashMap<String, Vec<CastEntry>> = HashMap::new();
for row in rows { for row in rows {
let mid: String = row.try_get("movie_id").map_err(map_err)?; let mid: String = row
let name: String = row.try_get("name").map_err(map_err)?; .try_get("movie_id")
let billing_order: i32 = row.try_get("billing_order").map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
let tmdb_person_id: i64 = row.try_get("tmdb_person_id").map_err(map_err)?; let name: String = row
let profile_path: Option<String> = row.try_get("profile_path").map_err(map_err)?; .try_get("name")
.map_err(adapter_common::map_sqlx_error)?;
let billing_order: i32 = row
.try_get("billing_order")
.map_err(adapter_common::map_sqlx_error)?;
let tmdb_person_id: i64 = row
.try_get("tmdb_person_id")
.map_err(adapter_common::map_sqlx_error)?;
let profile_path: Option<String> = row
.try_get("profile_path")
.map_err(adapter_common::map_sqlx_error)?;
map.entry(mid).or_default().push(CastEntry { map.entry(mid).or_default().push(CastEntry {
name, name,
billing_order: billing_order as u32, billing_order: billing_order as u32,

View File

@@ -6,4 +6,3 @@ edition = "2024"
[dependencies] [dependencies]
rss-feed = { package = "rss", version = "2" } rss-feed = { package = "rss", version = "2" }
domain = { workspace = true } domain = { workspace = true }
application = { workspace = true }

View File

@@ -1,5 +1,5 @@
use application::ports::RssFeedRenderer;
use domain::models::DiaryEntry; use domain::models::DiaryEntry;
use domain::ports::RssFeedRenderer;
use rss_feed::{ChannelBuilder, GuidBuilder, ItemBuilder}; use rss_feed::{ChannelBuilder, GuidBuilder, ItemBuilder};
pub struct RssAdapter { pub struct RssAdapter {

View File

@@ -6,6 +6,7 @@ edition = "2024"
[dependencies] [dependencies]
sqlx = { workspace = true } sqlx = { workspace = true }
activitypub = { workspace = true } activitypub = { workspace = true }
adapter-common = { workspace = true }
k-ap = { version = "0.4.1", registry = "gitea" } k-ap = { version = "0.4.1", registry = "gitea" }
domain = { workspace = true } domain = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }

View File

@@ -3,7 +3,8 @@ use async_trait::async_trait;
use chrono::Utc; use chrono::Utc;
use k_ap::ActivityRepository; use k_ap::ActivityRepository;
use super::{SqliteFederationRepository, datetime_to_str}; use super::SqliteFederationRepository;
use adapter_common::datetime_to_str;
#[async_trait] #[async_trait]
impl ActivityRepository for SqliteFederationRepository { impl ActivityRepository for SqliteFederationRepository {

View File

@@ -4,7 +4,8 @@ use chrono::Utc;
use k_ap::{ActorRepository, RemoteActor}; use k_ap::{ActorRepository, RemoteActor};
use sqlx::Row; use sqlx::Row;
use super::{SqliteFederationRepository, datetime_to_str, remote_actor_from_row}; use super::{SqliteFederationRepository, remote_actor_from_row};
use adapter_common::datetime_to_str;
#[async_trait] #[async_trait]
impl ActorRepository for SqliteFederationRepository { impl ActorRepository for SqliteFederationRepository {

View File

@@ -2,17 +2,16 @@ use async_trait::async_trait;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::{ models::{
DiaryEntry, Goal, GoalType, Movie, PersistedReview, Review, ReviewSource, WatchlistEntry, DiaryEntry, Movie, PersistedReview, Review, ReviewSource, WatchlistEntry,
WatchlistWithMovie, WatchlistWithMovie,
}, },
ports::LocalApContentQuery, ports::LocalApContentQuery,
value_objects::{ value_objects::{
Comment, ExternalMetadataId, GoalId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear, Comment, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
ReviewId, UserId, WatchlistEntryId, ReviewId, UserId, WatchlistEntryId,
}, },
}; };
use sqlx::{Row, SqlitePool}; use sqlx::SqlitePool;
use uuid::Uuid;
pub struct SqliteApContentQuery { pub struct SqliteApContentQuery {
pool: SqlitePool, pool: SqlitePool,
@@ -22,24 +21,11 @@ impl SqliteApContentQuery {
pub fn new(pool: SqlitePool) -> Self { pub fn new(pool: SqlitePool) -> Self {
Self { pool } Self { pool }
} }
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
} }
// ── Local row types ────────────────────────────────────────────────────────── // ── Local row types ──────────────────────────────────────────────────────────
fn parse_uuid(s: &str) -> Result<Uuid, DomainError> { use adapter_common::{parse_datetime, parse_uuid};
Uuid::parse_str(s)
.map_err(|e| DomainError::InfrastructureError(format!("Invalid UUID '{}': {}", s, e)))
}
fn parse_datetime(s: &str) -> Result<chrono::NaiveDateTime, DomainError> {
chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S")
.map_err(|e| DomainError::InfrastructureError(format!("Invalid datetime '{}': {}", s, e)))
}
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct MovieRow { struct MovieRow {
@@ -82,6 +68,7 @@ struct ReviewRow {
watched_at: String, watched_at: String,
created_at: String, created_at: String,
remote_actor_url: Option<String>, remote_actor_url: Option<String>,
watch_medium: Option<String>,
} }
impl ReviewRow { impl ReviewRow {
@@ -97,6 +84,7 @@ impl ReviewRow {
None => ReviewSource::Local, None => ReviewSource::Local,
Some(url) => ReviewSource::Remote { actor_url: url }, Some(url) => ReviewSource::Remote { actor_url: url },
}; };
let watch_medium = self.watch_medium.map(|s| s.parse()).transpose()?;
Ok(Review::from_persistence(PersistedReview { Ok(Review::from_persistence(PersistedReview {
id, id,
movie_id, movie_id,
@@ -106,7 +94,7 @@ impl ReviewRow {
watched_at, watched_at,
created_at, created_at,
source, source,
watch_medium: None, watch_medium,
})) }))
} }
} }
@@ -127,6 +115,7 @@ struct DiaryRow {
watched_at: String, watched_at: String,
created_at: String, created_at: String,
remote_actor_url: Option<String>, remote_actor_url: Option<String>,
watch_medium: Option<String>,
} }
impl DiaryRow { impl DiaryRow {
@@ -149,6 +138,7 @@ impl DiaryRow {
watched_at: self.watched_at, watched_at: self.watched_at,
created_at: self.created_at, created_at: self.created_at,
remote_actor_url: self.remote_actor_url, remote_actor_url: self.remote_actor_url,
watch_medium: self.watch_medium,
} }
.into_domain()?; .into_domain()?;
Ok(DiaryEntry::new(movie, review)) Ok(DiaryEntry::new(movie, review))
@@ -190,100 +180,10 @@ impl WatchlistRow {
} }
} }
fn row_to_goal(r: &sqlx::sqlite::SqliteRow) -> Result<Goal, DomainError> {
let id_str: String = r
.try_get("id")
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read goal id: {e}")))?;
let user_id_str: String = r
.try_get("user_id")
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read user_id: {e}")))?;
let year: i64 = r
.try_get("year")
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read year: {e}")))?;
let target: i64 = r.try_get("target_count").map_err(|e| {
DomainError::InfrastructureError(format!("Failed to read target_count: {e}"))
})?;
let goal_type_str: String = r
.try_get("goal_type")
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read goal_type: {e}")))?;
let created_at_str: String = r
.try_get("created_at")
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read created_at: {e}")))?;
let id = GoalId::from_uuid(
Uuid::parse_str(&id_str)
.map_err(|e| DomainError::InfrastructureError(format!("Invalid goal UUID: {e}")))?,
);
let user_id = UserId::from_uuid(
Uuid::parse_str(&user_id_str)
.map_err(|e| DomainError::InfrastructureError(format!("Invalid user UUID: {e}")))?,
);
let goal_type: GoalType = goal_type_str.parse()?;
let created_at = parse_datetime(&created_at_str)?;
Ok(Goal::from_persistence(
id,
user_id,
year as u16,
target as u32,
goal_type,
created_at,
))
}
async fn count_reviews_in_year(
pool: &SqlitePool,
user_id: &UserId,
year: u16,
) -> Result<u32, DomainError> {
let uid = user_id.value().to_string();
let start = format!("{year}-01-01 00:00:00");
let end = format!("{}-01-01 00:00:00", year + 1);
let count: i64 = sqlx::query(
"SELECT COUNT(*) FROM reviews \
WHERE user_id = ? AND watched_at >= ? AND watched_at < ? \
AND remote_actor_url IS NULL",
)
.bind(&uid)
.bind(&start)
.bind(&end)
.fetch_one(pool)
.await
.map_err(|e| {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
})?
.try_get(0)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(count as u32)
}
// ── LocalApContentQuery impl ───────────────────────────────────────────────── // ── LocalApContentQuery impl ─────────────────────────────────────────────────
#[async_trait] #[async_trait]
impl LocalApContentQuery for SqliteApContentQuery { impl LocalApContentQuery for SqliteApContentQuery {
async fn get_local_reviews_for_user(
&self,
user_id: &UserId,
) -> Result<Vec<DiaryEntry>, DomainError> {
let uid = user_id.value().to_string();
let rows = sqlx::query_as::<_, DiaryRow>(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = ? AND r.remote_actor_url IS NULL
ORDER BY r.created_at DESC",
)
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
rows.into_iter().map(DiaryRow::into_domain).collect()
}
async fn get_local_watchlist_for_user( async fn get_local_watchlist_for_user(
&self, &self,
user_id: &UserId, user_id: &UserId,
@@ -301,7 +201,7 @@ impl LocalApContentQuery for SqliteApContentQuery {
.bind(&uid) .bind(&uid)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
rows.into_iter().map(WatchlistRow::into_domain).collect() rows.into_iter().map(WatchlistRow::into_domain).collect()
} }
@@ -312,7 +212,7 @@ impl LocalApContentQuery for SqliteApContentQuery {
let mid = movie_id.value().to_string(); let mid = movie_id.value().to_string();
let rows = sqlx::query_as::<_, DiaryRow>( let rows = sqlx::query_as::<_, DiaryRow>(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium
FROM reviews r FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id INNER JOIN movies m ON m.id = r.movie_id
WHERE r.movie_id = ? AND r.remote_actor_url IS NULL WHERE r.movie_id = ? AND r.remote_actor_url IS NULL
@@ -321,63 +221,10 @@ impl LocalApContentQuery for SqliteApContentQuery {
.bind(&mid) .bind(&mid)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
rows.into_iter().map(DiaryRow::into_domain).collect() rows.into_iter().map(DiaryRow::into_domain).collect()
} }
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError> {
let id = review_id.value().to_string();
sqlx::query_as::<_, ReviewRow>(
"SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url
FROM reviews WHERE id = ?",
)
.bind(&id)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?
.map(ReviewRow::into_domain)
.transpose()
}
async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result<Option<Movie>, DomainError> {
let id = movie_id.value().to_string();
sqlx::query_as::<_, MovieRow>(
"SELECT id, external_metadata_id, title, release_year, director, poster_path
FROM movies WHERE id = ?",
)
.bind(&id)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?
.map(MovieRow::into_domain)
.transpose()
}
async fn get_movie_by_external_metadata_id(
&self,
external_id: &str,
) -> Result<Option<Movie>, DomainError> {
sqlx::query_as::<_, MovieRow>(
"SELECT id, external_metadata_id, title, release_year, director, poster_path
FROM movies WHERE external_metadata_id = ?",
)
.bind(external_id)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?
.map(MovieRow::into_domain)
.transpose()
}
async fn count_local_posts(&self) -> Result<u64, DomainError> {
let count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE remote_actor_url IS NULL")
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(count as u64)
}
async fn get_local_reviews_page( async fn get_local_reviews_page(
&self, &self,
user_id: &UserId, user_id: &UserId,
@@ -391,7 +238,7 @@ impl LocalApContentQuery for SqliteApContentQuery {
let ts = before_ts.format("%Y-%m-%d %H:%M:%S").to_string(); let ts = before_ts.format("%Y-%m-%d %H:%M:%S").to_string();
sqlx::query_as::<_, DiaryRow>( sqlx::query_as::<_, DiaryRow>(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium
FROM reviews r FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = ? AND r.remote_actor_url IS NULL AND r.watched_at < ? WHERE r.user_id = ? AND r.remote_actor_url IS NULL AND r.watched_at < ?
@@ -403,11 +250,11 @@ impl LocalApContentQuery for SqliteApContentQuery {
.bind(limit_i64) .bind(limit_i64)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(Self::map_err)? .map_err(adapter_common::map_sqlx_error)?
} else { } else {
sqlx::query_as::<_, DiaryRow>( sqlx::query_as::<_, DiaryRow>(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium
FROM reviews r FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = ? AND r.remote_actor_url IS NULL WHERE r.user_id = ? AND r.remote_actor_url IS NULL
@@ -418,47 +265,8 @@ impl LocalApContentQuery for SqliteApContentQuery {
.bind(limit_i64) .bind(limit_i64)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(Self::map_err)? .map_err(adapter_common::map_sqlx_error)?
}; };
rows.into_iter().map(DiaryRow::into_domain).collect() rows.into_iter().map(DiaryRow::into_domain).collect()
} }
async fn get_goal_with_progress(
&self,
user_id: &UserId,
year: u16,
) -> Result<Option<(Goal, u32)>, DomainError> {
let uid = user_id.value().to_string();
let y = year as i64;
let row = sqlx::query(
"SELECT id, user_id, year, target_count, goal_type, created_at \
FROM goals WHERE user_id = ? AND year = ?",
)
.bind(&uid)
.bind(y)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?;
let Some(r) = row else { return Ok(None) };
let goal = row_to_goal(&r)?;
let count = count_reviews_in_year(&self.pool, user_id, year).await?;
Ok(Some((goal, count)))
}
async fn list_goals_for_user(&self, user_id: &UserId) -> Result<Vec<Goal>, DomainError> {
let uid = user_id.value().to_string();
let rows = sqlx::query(
"SELECT id, user_id, year, target_count, goal_type, created_at \
FROM goals WHERE user_id = ? ORDER BY created_at DESC",
)
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
rows.iter().map(row_to_goal).collect()
}
} }

View File

@@ -4,7 +4,8 @@ use chrono::Utc;
use k_ap::{BlockedDomain, BlocklistRepository}; use k_ap::{BlockedDomain, BlocklistRepository};
use sqlx::Row; use sqlx::Row;
use super::{SqliteFederationRepository, datetime_to_str}; use super::SqliteFederationRepository;
use adapter_common::datetime_to_str;
#[async_trait] #[async_trait]
impl BlocklistRepository for SqliteFederationRepository { impl BlocklistRepository for SqliteFederationRepository {

View File

@@ -6,10 +6,8 @@ use k_ap::{
}; };
use sqlx::Row; use sqlx::Row;
use super::{ use super::{SqliteFederationRepository, remote_actor_from_row, status_to_str, str_to_status};
SqliteFederationRepository, datetime_to_str, remote_actor_from_row, status_to_str, use adapter_common::datetime_to_str;
str_to_status,
};
#[async_trait] #[async_trait]
impl FollowRepository for SqliteFederationRepository { impl FollowRepository for SqliteFederationRepository {

View File

@@ -13,14 +13,9 @@ pub mod remote_goals;
pub use ap_content::SqliteApContentQuery; pub use ap_content::SqliteApContentQuery;
pub use remote_goals::SqliteRemoteGoalRepository; pub use remote_goals::SqliteRemoteGoalRepository;
use chrono::NaiveDateTime;
use k_ap::{FollowerStatus, RemoteActor}; use k_ap::{FollowerStatus, RemoteActor};
use sqlx::SqlitePool; use sqlx::SqlitePool;
pub(crate) fn datetime_to_str(dt: &NaiveDateTime) -> String {
dt.format("%Y-%m-%d %H:%M:%S").to_string()
}
pub(crate) fn status_to_str(status: &FollowerStatus) -> &'static str { pub(crate) fn status_to_str(status: &FollowerStatus) -> &'static str {
match status { match status {
FollowerStatus::Pending => "pending", FollowerStatus::Pending => "pending",

View File

@@ -11,11 +11,6 @@ impl SqliteRemoteGoalRepository {
pub fn new(pool: SqlitePool) -> Self { pub fn new(pool: SqlitePool) -> Self {
Self { pool } Self { pool }
} }
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
} }
#[async_trait] #[async_trait]
@@ -36,7 +31,7 @@ impl RemoteGoalRepository for SqliteRemoteGoalRepository {
.bind(&received) .bind(&received)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(()) Ok(())
} }
@@ -53,7 +48,7 @@ impl RemoteGoalRepository for SqliteRemoteGoalRepository {
.bind(ap_id) .bind(ap_id)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(()) Ok(())
} }
@@ -64,7 +59,7 @@ impl RemoteGoalRepository for SqliteRemoteGoalRepository {
.bind(actor_url) .bind(actor_url)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(()) Ok(())
} }
@@ -74,7 +69,7 @@ impl RemoteGoalRepository for SqliteRemoteGoalRepository {
.bind(actor_url) .bind(actor_url)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(()) Ok(())
} }
@@ -87,7 +82,7 @@ impl RemoteGoalRepository for SqliteRemoteGoalRepository {
.bind(actor_url) .bind(actor_url)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
rows.iter() rows.iter()
.map(|r| { .map(|r| {

View File

@@ -3,7 +3,8 @@ use anyhow::{Result, anyhow};
use async_trait::async_trait; use async_trait::async_trait;
use domain::models::{Review, ReviewSource}; use domain::models::{Review, ReviewSource};
use super::{SqliteFederationRepository, datetime_to_str}; use super::SqliteFederationRepository;
use adapter_common::datetime_to_str;
#[async_trait] #[async_trait]
impl RemoteReviewRepository for SqliteFederationRepository { impl RemoteReviewRepository for SqliteFederationRepository {

View File

@@ -3,6 +3,7 @@ use domain::{
errors::DomainError, errors::DomainError,
models::{PendingFollowerInfo, RemoteActorInfo}, models::{PendingFollowerInfo, RemoteActorInfo},
ports::SocialQueryPort, ports::SocialQueryPort,
value_objects::UserId,
}; };
use super::SqliteFederationRepository; use super::SqliteFederationRepository;
@@ -11,9 +12,9 @@ use super::SqliteFederationRepository;
impl SocialQueryPort for SqliteFederationRepository { impl SocialQueryPort for SqliteFederationRepository {
async fn get_accepted_following_urls( async fn get_accepted_following_urls(
&self, &self,
user_id: uuid::Uuid, user_id: &UserId,
) -> Result<Vec<String>, DomainError> { ) -> Result<Vec<String>, DomainError> {
let user_id_str = user_id.to_string(); let user_id_str = user_id.value().to_string();
sqlx::query_scalar::<_, String>( sqlx::query_scalar::<_, String>(
"SELECT remote_actor_url FROM ap_following WHERE local_user_id = ? AND status = 'accepted'", "SELECT remote_actor_url FROM ap_following WHERE local_user_id = ? AND status = 'accepted'",
).bind(&user_id_str).fetch_all(&self.pool).await ).bind(&user_id_str).fetch_all(&self.pool).await
@@ -40,8 +41,8 @@ impl SocialQueryPort for SqliteFederationRepository {
.collect()) .collect())
} }
async fn count_following(&self, user_id: uuid::Uuid) -> Result<usize, DomainError> { async fn count_following(&self, user_id: &UserId) -> Result<usize, DomainError> {
let uid = user_id.to_string(); let uid = user_id.value().to_string();
let count: i64 = sqlx::query_scalar( let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ap_following WHERE local_user_id = ? AND status = 'accepted'", "SELECT COUNT(*) FROM ap_following WHERE local_user_id = ? AND status = 'accepted'",
) )
@@ -52,8 +53,8 @@ impl SocialQueryPort for SqliteFederationRepository {
Ok(count as usize) Ok(count as usize)
} }
async fn count_accepted_followers(&self, user_id: uuid::Uuid) -> Result<usize, DomainError> { async fn count_accepted_followers(&self, user_id: &UserId) -> Result<usize, DomainError> {
let uid = user_id.to_string(); let uid = user_id.value().to_string();
let count: i64 = sqlx::query_scalar( let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ap_followers WHERE local_user_id = ? AND status = 'accepted'", "SELECT COUNT(*) FROM ap_followers WHERE local_user_id = ? AND status = 'accepted'",
) )
@@ -66,9 +67,9 @@ impl SocialQueryPort for SqliteFederationRepository {
async fn get_pending_followers( async fn get_pending_followers(
&self, &self,
user_id: uuid::Uuid, user_id: &UserId,
) -> Result<Vec<PendingFollowerInfo>, DomainError> { ) -> Result<Vec<PendingFollowerInfo>, DomainError> {
let uid = user_id.to_string(); let uid = user_id.value().to_string();
let rows = sqlx::query_as::<_, (String, String, Option<String>, Option<String>)>( let rows = sqlx::query_as::<_, (String, String, Option<String>, Option<String>)>(
"SELECT ar.url, ar.handle, ar.display_name, ar.avatar_url "SELECT ar.url, ar.handle, ar.display_name, ar.avatar_url
FROM ap_followers f FROM ap_followers f

View File

@@ -97,7 +97,8 @@ async fn test_get_accepted_following_urls_returns_only_accepted() {
.await .await
.unwrap(); .unwrap();
let urls = repo.get_accepted_following_urls(user_id).await.unwrap(); let uid = domain::value_objects::UserId::from_uuid(user_id);
let urls = repo.get_accepted_following_urls(&uid).await.unwrap();
assert_eq!(urls.len(), 1); assert_eq!(urls.len(), 1);
assert_eq!(urls[0], "https://other.social/users/alice"); assert_eq!(urls[0], "https://other.social/users/alice");
} }

View File

@@ -4,10 +4,11 @@ version = "0.1.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
domain = { workspace = true } domain = { workspace = true }
async-trait = { workspace = true } adapter-common = { workspace = true }
sqlx = { workspace = true } async-trait = { workspace = true }
uuid = { workspace = true } sqlx = { workspace = true }
uuid = { workspace = true }
[dev-dependencies] [dev-dependencies]
tokio = { workspace = true } tokio = { workspace = true }

View File

@@ -31,10 +31,6 @@ pub fn create_search_adapter(pool: SqlitePool) -> (Arc<dyn SearchCommand>, Arc<d
) )
} }
fn map_err(e: sqlx::Error) -> DomainError {
DomainError::InfrastructureError(e.to_string())
}
#[async_trait] #[async_trait]
impl SearchCommand for SqliteSearchAdapter { impl SearchCommand for SqliteSearchAdapter {
async fn index(&self, doc: IndexableDocument) -> Result<(), DomainError> { async fn index(&self, doc: IndexableDocument) -> Result<(), DomainError> {
@@ -86,7 +82,7 @@ impl SearchCommand for SqliteSearchAdapter {
.bind(&movie_id) .bind(&movie_id)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
sqlx::query( sqlx::query(
"INSERT INTO movies_fts(movie_id, title, director, overview, genres, keywords, cast_names, crew_names, release_year, language) "INSERT INTO movies_fts(movie_id, title, director, overview, genres, keywords, cast_names, crew_names, release_year, language)
@@ -104,7 +100,7 @@ impl SearchCommand for SqliteSearchAdapter {
.bind(&language) .bind(&language)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(()) Ok(())
} }
@@ -118,7 +114,7 @@ impl SearchCommand for SqliteSearchAdapter {
.bind(&person_id) .bind(&person_id)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
sqlx::query( sqlx::query(
"INSERT INTO people_fts(person_id, name, known_for_department) VALUES (?, ?, ?)", "INSERT INTO people_fts(person_id, name, known_for_department) VALUES (?, ?, ?)",
@@ -128,7 +124,7 @@ impl SearchCommand for SqliteSearchAdapter {
.bind(person.known_for_department()) .bind(person.known_for_department())
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(()) Ok(())
} }
@@ -144,7 +140,7 @@ impl SearchCommand for SqliteSearchAdapter {
.bind(id) .bind(id)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
} }
EntityType::Person => { EntityType::Person => {
sqlx::query( sqlx::query(
@@ -153,7 +149,7 @@ impl SearchCommand for SqliteSearchAdapter {
.bind(id) .bind(id)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
} }
} }
Ok(()) Ok(())
@@ -205,7 +201,7 @@ impl SqliteSearchAdapter {
.bind(query.filters.year.map(|y| y as i64)) .bind(query.filters.year.map(|y| y as i64))
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
count as u64 count as u64
} else { } else {
let count: i64 = sqlx::query_scalar( let count: i64 = sqlx::query_scalar(
@@ -221,7 +217,7 @@ impl SqliteSearchAdapter {
.bind(query.filters.year.map(|y| y as i64)) .bind(query.filters.year.map(|y| y as i64))
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
count as u64 count as u64
}; };
@@ -249,7 +245,7 @@ impl SqliteSearchAdapter {
.bind(offset) .bind(offset)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(map_err)? .map_err(adapter_common::map_sqlx_error)?
} else { } else {
sqlx::query_as::<_, Row>( sqlx::query_as::<_, Row>(
"SELECT m.id, m.title, m.release_year, m.director, m.poster_path, "SELECT m.id, m.title, m.release_year, m.director, m.poster_path,
@@ -270,7 +266,7 @@ impl SqliteSearchAdapter {
.bind(offset) .bind(offset)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(map_err)? .map_err(adapter_common::map_sqlx_error)?
}; };
let items = rows let items = rows
.into_iter() .into_iter()
@@ -321,7 +317,7 @@ impl SqliteSearchAdapter {
.bind(&fts_query) .bind(&fts_query)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
count as u64 count as u64
}; };
@@ -346,7 +342,7 @@ impl SqliteSearchAdapter {
.bind(offset) .bind(offset)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
let mut items = Vec::with_capacity(rows.len()); let mut items = Vec::with_capacity(rows.len());
for row in rows { for row in rows {
@@ -355,7 +351,7 @@ impl SqliteSearchAdapter {
.bind(&row.person_id) .bind(&row.person_id)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(map_err)? .map_err(adapter_common::map_sqlx_error)?
.flatten(); .flatten();
let known_for_titles = if let Some(tid) = tmdb_id { let known_for_titles = if let Some(tid) = tmdb_id {

View File

@@ -11,6 +11,7 @@ sqlx = { version = "0.8.6", features = [
"macros", "macros",
] } ] }
adapter-common = { workspace = true }
domain = { workspace = true } domain = { workspace = true }
sqlite-federation = { workspace = true } sqlite-federation = { workspace = true }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }

View File

@@ -2,10 +2,10 @@ use async_trait::async_trait;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::{ models::{
DiaryEntry, DiaryFilter, FeedEntry, MovieStats, ReviewHistory, SortDirection, DiaryEntry, DiaryFilter, FeedEntry, MovieStats, ReviewHistory, ReviewSortBy,
collections::{PageParams, Paginated}, collections::{PageParams, Paginated},
}, },
ports::DiaryRepository, ports::DiaryQuery,
value_objects::{MovieId, UserId}, value_objects::{MovieId, UserId},
}; };
use futures::stream::BoxStream; use futures::stream::BoxStream;
@@ -22,38 +22,33 @@ impl SqliteDiaryRepository {
Self { pool } Self { pool }
} }
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
async fn count_diary_entries(&self, movie_id: Option<&str>) -> Result<i64, DomainError> { async fn count_diary_entries(&self, movie_id: Option<&str>) -> Result<i64, DomainError> {
match movie_id { match movie_id {
None => sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM reviews") None => sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM reviews")
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await
.map_err(Self::map_err), .map_err(adapter_common::map_sqlx_error),
Some(id) => { Some(id) => {
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM reviews WHERE movie_id = ?") sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM reviews WHERE movie_id = ?")
.bind(id) .bind(id)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await
.map_err(Self::map_err) .map_err(adapter_common::map_sqlx_error)
} }
} }
} }
async fn fetch_all_diary_rows( async fn fetch_all_diary_rows(
&self, &self,
sort: &SortDirection, sort: &ReviewSortBy,
limit: i64, limit: i64,
offset: i64, offset: i64,
) -> Result<Vec<DiaryRow>, DomainError> { ) -> Result<Vec<DiaryRow>, DomainError> {
let order_clause = match sort { let order_clause = match sort {
SortDirection::ByRatingDesc => "r.rating DESC, r.watched_at DESC", ReviewSortBy::ByRatingDesc => "r.rating DESC, r.watched_at DESC",
SortDirection::ByRatingAsc => "r.rating ASC, r.watched_at ASC", ReviewSortBy::ByRatingAsc => "r.rating ASC, r.watched_at ASC",
SortDirection::Ascending => "r.watched_at ASC", ReviewSortBy::Ascending => "r.watched_at ASC",
SortDirection::Descending => "r.watched_at DESC", ReviewSortBy::Descending => "r.watched_at DESC",
}; };
let sql = format!( let sql = format!(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
@@ -69,21 +64,21 @@ impl SqliteDiaryRepository {
.bind(offset) .bind(offset)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(Self::map_err) .map_err(adapter_common::map_sqlx_error)
} }
async fn fetch_movie_diary_rows( async fn fetch_movie_diary_rows(
&self, &self,
movie_id: &str, movie_id: &str,
sort: &SortDirection, sort: &ReviewSortBy,
limit: i64, limit: i64,
offset: i64, offset: i64,
) -> Result<Vec<DiaryRow>, DomainError> { ) -> Result<Vec<DiaryRow>, DomainError> {
let order_clause = match sort { let order_clause = match sort {
SortDirection::ByRatingDesc => "r.rating DESC, r.watched_at DESC", ReviewSortBy::ByRatingDesc => "r.rating DESC, r.watched_at DESC",
SortDirection::ByRatingAsc => "r.rating ASC, r.watched_at ASC", ReviewSortBy::ByRatingAsc => "r.rating ASC, r.watched_at ASC",
SortDirection::Ascending => "r.watched_at ASC", ReviewSortBy::Ascending => "r.watched_at ASC",
SortDirection::Descending => "r.watched_at DESC", ReviewSortBy::Descending => "r.watched_at DESC",
}; };
let sql = format!( let sql = format!(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
@@ -101,7 +96,7 @@ impl SqliteDiaryRepository {
.bind(offset) .bind(offset)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(Self::map_err) .map_err(adapter_common::map_sqlx_error)
} }
async fn count_user_diary_entries( async fn count_user_diary_entries(
@@ -130,13 +125,15 @@ impl SqliteDiaryRepository {
if has_search { if has_search {
q = q.bind(search.unwrap()); q = q.bind(search.unwrap());
} }
q.fetch_one(&self.pool).await.map_err(Self::map_err) q.fetch_one(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)
} }
async fn fetch_user_diary_rows( async fn fetch_user_diary_rows(
&self, &self,
user_id: &str, user_id: &str,
sort: &SortDirection, sort: &ReviewSortBy,
search: Option<&str>, search: Option<&str>,
include_remote: bool, include_remote: bool,
limit: i64, limit: i64,
@@ -154,10 +151,10 @@ impl SqliteDiaryRepository {
"" ""
}; };
let order_clause = match sort { let order_clause = match sort {
SortDirection::ByRatingDesc => "r.rating DESC, r.watched_at DESC", ReviewSortBy::ByRatingDesc => "r.rating DESC, r.watched_at DESC",
SortDirection::ByRatingAsc => "r.rating ASC, r.watched_at ASC", ReviewSortBy::ByRatingAsc => "r.rating ASC, r.watched_at ASC",
SortDirection::Ascending => "r.watched_at ASC", ReviewSortBy::Ascending => "r.watched_at ASC",
SortDirection::Descending => "r.watched_at DESC", ReviewSortBy::Descending => "r.watched_at DESC",
}; };
let sql = format!( let sql = format!(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
@@ -176,12 +173,12 @@ impl SqliteDiaryRepository {
.bind(offset) .bind(offset)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(Self::map_err) .map_err(adapter_common::map_sqlx_error)
} }
} }
#[async_trait] #[async_trait]
impl DiaryRepository for SqliteDiaryRepository { impl DiaryQuery for SqliteDiaryRepository {
async fn query_diary( async fn query_diary(
&self, &self,
filter: &DiaryFilter, filter: &DiaryFilter,
@@ -340,7 +337,10 @@ impl DiaryRepository for SqliteDiaryRepository {
} }
let count_q = bind_filter_params!(sqlx::query_scalar::<_, i64>(&count_sql)); 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(adapter_common::map_sqlx_error)?;
let rows_q = bind_filter_params!(sqlx::query_as::<_, FeedRow>(&select_sql)); let rows_q = bind_filter_params!(sqlx::query_as::<_, FeedRow>(&select_sql));
let rows = rows_q let rows = rows_q
@@ -348,7 +348,7 @@ impl DiaryRepository for SqliteDiaryRepository {
.bind(offset) .bind(offset)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
let items = rows let items = rows
.into_iter() .into_iter()
@@ -373,18 +373,18 @@ impl DiaryRepository for SqliteDiaryRepository {
.bind(&id_str) .bind(&id_str)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(Self::map_err)? .map_err(adapter_common::map_sqlx_error)?
.ok_or_else(|| DomainError::NotFound(format!("Movie {}", id_str)))? .ok_or_else(|| DomainError::NotFound(format!("Movie {}", id_str)))?
.into_domain()?; .into_domain()?;
let viewings = sqlx::query_as::<_, ReviewRow>( let viewings = sqlx::query_as::<_, ReviewRow>(
"SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url "SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium
FROM reviews WHERE movie_id = ? ORDER BY watched_at ASC", FROM reviews WHERE movie_id = ? ORDER BY watched_at ASC",
) )
.bind(&id_str) .bind(&id_str)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(Self::map_err)? .map_err(adapter_common::map_sqlx_error)?
.into_iter() .into_iter()
.map(ReviewRow::into_domain) .map(ReviewRow::into_domain)
.collect::<Result<Vec<_>, _>>()?; .collect::<Result<Vec<_>, _>>()?;
@@ -405,7 +405,7 @@ impl DiaryRepository for SqliteDiaryRepository {
.bind(&uid) .bind(&uid)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
rows.into_iter().map(DiaryRow::into_domain).collect() rows.into_iter().map(DiaryRow::into_domain).collect()
} }
@@ -430,7 +430,7 @@ impl DiaryRepository for SqliteDiaryRepository {
while let Some(row) = futures::StreamExt::next(&mut rows).await { while let Some(row) = futures::StreamExt::next(&mut rows).await {
yield match row { yield match row {
Ok(r) => r.into_domain(), Ok(r) => r.into_domain(),
Err(e) => Err(Self::map_err(e)), Err(e) => Err(adapter_common::map_sqlx_error(e)),
}; };
} }
}) })
@@ -453,7 +453,7 @@ impl DiaryRepository for SqliteDiaryRepository {
.bind(id_str) .bind(id_str)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await
.map_err(Self::map_err) .map_err(adapter_common::map_sqlx_error)
.map(MovieStatsRow::into_domain) .map(MovieStatsRow::into_domain)
} }
@@ -471,7 +471,7 @@ impl DiaryRepository for SqliteDiaryRepository {
.bind(&id_str) .bind(&id_str)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
let rows = sqlx::query_as::<_, FeedRow>( let rows = sqlx::query_as::<_, FeedRow>(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
@@ -492,7 +492,7 @@ impl DiaryRepository for SqliteDiaryRepository {
.bind(offset) .bind(offset)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
let items = rows let items = rows
.into_iter() .into_iter()
@@ -512,7 +512,7 @@ impl DiaryRepository for SqliteDiaryRepository {
sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE remote_actor_url IS NULL") sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE remote_actor_url IS NULL")
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(count as u64) Ok(count as u64)
} }
} }

View File

@@ -2,7 +2,7 @@ use async_trait::async_trait;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::{Goal, GoalType}, models::{Goal, GoalType},
ports::GoalRepository, ports::{GoalCommand, GoalQuery},
value_objects::{GoalId, UserId}, value_objects::{GoalId, UserId},
}; };
use sqlx::{Row, SqlitePool}; use sqlx::{Row, SqlitePool};
@@ -15,15 +15,10 @@ impl SqliteGoalRepository {
pub fn new(pool: SqlitePool) -> Self { pub fn new(pool: SqlitePool) -> Self {
Self { pool } Self { pool }
} }
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
} }
#[async_trait] #[async_trait]
impl GoalRepository for SqliteGoalRepository { impl GoalCommand for SqliteGoalRepository {
async fn save(&self, goal: &Goal) -> Result<(), DomainError> { async fn save(&self, goal: &Goal) -> Result<(), DomainError> {
let id = goal.id().value().to_string(); let id = goal.id().value().to_string();
let user_id = goal.user_id().value().to_string(); let user_id = goal.user_id().value().to_string();
@@ -44,7 +39,7 @@ impl GoalRepository for SqliteGoalRepository {
.bind(&created_at) .bind(&created_at)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(()) Ok(())
} }
@@ -58,7 +53,7 @@ impl GoalRepository for SqliteGoalRepository {
.bind(&id) .bind(&id)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
if result.rows_affected() == 0 { if result.rows_affected() == 0 {
return Err(DomainError::NotFound("Goal not found".into())); return Err(DomainError::NotFound("Goal not found".into()));
@@ -75,14 +70,17 @@ impl GoalRepository for SqliteGoalRepository {
.bind(&uid) .bind(&uid)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
if result.rows_affected() == 0 { if result.rows_affected() == 0 {
return Err(DomainError::NotFound("Goal not found".into())); return Err(DomainError::NotFound("Goal not found".into()));
} }
Ok(()) Ok(())
} }
}
#[async_trait]
impl GoalQuery for SqliteGoalRepository {
async fn find_by_user_and_year( async fn find_by_user_and_year(
&self, &self,
user_id: &UserId, user_id: &UserId,
@@ -99,7 +97,7 @@ impl GoalRepository for SqliteGoalRepository {
.bind(y) .bind(y)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
row.map(|r| row_to_goal(&r)).transpose() row.map(|r| row_to_goal(&r)).transpose()
} }
@@ -114,14 +112,10 @@ impl GoalRepository for SqliteGoalRepository {
.bind(&uid) .bind(&uid)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
rows.iter().map(row_to_goal).collect() rows.iter().map(row_to_goal).collect()
} }
async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result<u32, DomainError> {
count_reviews_in_year(&self.pool, user_id, year).await
}
} }
pub(crate) async fn count_reviews_in_year( pub(crate) async fn count_reviews_in_year(

View File

@@ -97,11 +97,6 @@ impl SqliteImportProfileRepository {
Self { pool } Self { pool }
} }
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("DB error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
fn parse_dt(s: &str) -> Result<NaiveDateTime, DomainError> { fn parse_dt(s: &str) -> Result<NaiveDateTime, DomainError> {
NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S")
.or_else(|_| NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S")) .or_else(|_| NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S"))
@@ -130,7 +125,7 @@ impl ImportProfileRepository for SqliteImportProfileRepository {
.execute(&self.pool) .execute(&self.pool)
.await .await
.map(|_| ()) .map(|_| ())
.map_err(Self::map_err) .map_err(adapter_common::map_sqlx_error)
} }
async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<ImportProfile>, DomainError> { async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<ImportProfile>, DomainError> {
@@ -141,7 +136,7 @@ impl ImportProfileRepository for SqliteImportProfileRepository {
.bind(&uid) .bind(&uid)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
rows.iter() rows.iter()
.map(|r| { .map(|r| {
@@ -183,7 +178,7 @@ impl ImportProfileRepository for SqliteImportProfileRepository {
.bind(&uid_str) .bind(&uid_str)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
row.map(|r| { row.map(|r| {
use sqlx::Row; use sqlx::Row;
@@ -215,6 +210,6 @@ impl ImportProfileRepository for SqliteImportProfileRepository {
.execute(&self.pool) .execute(&self.pool)
.await .await
.map(|_| ()) .map(|_| ())
.map_err(Self::map_err) .map_err(adapter_common::map_sqlx_error)
} }
} }

View File

@@ -5,7 +5,6 @@ use domain::{
models::{ models::{
AnnotatedRow, FieldMapping, ImportSession, ParsedFile, AnnotatedRow, FieldMapping, ImportSession, ParsedFile,
import::{DomainField, ImportRow, RowResult, Transform}, import::{DomainField, ImportRow, RowResult, Transform},
import_session::PersistedImportSession,
}, },
ports::ImportSessionRepository, ports::ImportSessionRepository,
value_objects::{ImportSessionId, UserId}, value_objects::{ImportSessionId, UserId},
@@ -203,11 +202,6 @@ impl SqliteImportSessionRepository {
Self { pool } Self { pool }
} }
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("DB error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
fn parse_dt(s: &str) -> Result<NaiveDateTime, DomainError> { fn parse_dt(s: &str) -> Result<NaiveDateTime, DomainError> {
NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S")
.or_else(|_| NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S")) .or_else(|_| NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S"))
@@ -276,7 +270,7 @@ impl SqliteImportSessionRepository {
}) })
.transpose()?; .transpose()?;
Ok(ImportSession::from_persistence(PersistedImportSession { Ok(ImportSession {
id: ImportSessionId::from_uuid( id: ImportSessionId::from_uuid(
id.parse::<uuid::Uuid>() id.parse::<uuid::Uuid>()
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?, .map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
@@ -291,7 +285,7 @@ impl SqliteImportSessionRepository {
row_results, row_results,
created_at: Self::parse_dt(created_at)?, created_at: Self::parse_dt(created_at)?,
expires_at: Self::parse_dt(expires_at)?, expires_at: Self::parse_dt(expires_at)?,
})) })
} }
} }
@@ -317,7 +311,7 @@ impl ImportSessionRepository for SqliteImportSessionRepository {
.execute(&self.pool) .execute(&self.pool)
.await .await
.map(|_| ()) .map(|_| ())
.map_err(Self::map_err) .map_err(adapter_common::map_sqlx_error)
} }
async fn get( async fn get(
@@ -335,7 +329,7 @@ impl ImportSessionRepository for SqliteImportSessionRepository {
.bind(&uid_str) .bind(&uid_str)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
row.map(|r| { row.map(|r| {
use sqlx::Row; use sqlx::Row;
@@ -362,7 +356,7 @@ impl ImportSessionRepository for SqliteImportSessionRepository {
.execute(&self.pool) .execute(&self.pool)
.await .await
.map(|_| ()) .map(|_| ())
.map_err(Self::map_err) .map_err(adapter_common::map_sqlx_error)
} }
async fn delete(&self, id: &ImportSessionId) -> Result<(), DomainError> { async fn delete(&self, id: &ImportSessionId) -> Result<(), DomainError> {
@@ -372,14 +366,14 @@ impl ImportSessionRepository for SqliteImportSessionRepository {
.execute(&self.pool) .execute(&self.pool)
.await .await
.map(|_| ()) .map(|_| ())
.map_err(Self::map_err) .map_err(adapter_common::map_sqlx_error)
} }
async fn delete_expired(&self) -> Result<u64, DomainError> { async fn delete_expired(&self) -> Result<u64, DomainError> {
let result = sqlx::query("DELETE FROM import_sessions WHERE expires_at < datetime('now')") let result = sqlx::query("DELETE FROM import_sessions WHERE expires_at < datetime('now')")
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(result.rows_affected()) Ok(result.rows_affected())
} }
@@ -392,6 +386,6 @@ impl ImportSessionRepository for SqliteImportSessionRepository {
.execute(&self.pool) .execute(&self.pool)
.await .await
.map(|_| ()) .map(|_| ())
.map_err(Self::map_err) .map_err(adapter_common::map_sqlx_error)
} }
} }

View File

@@ -45,39 +45,16 @@ pub fn create_profile_fields_repo(
std::sync::Arc::new(SqliteProfileFieldsRepository::new(pool)) std::sync::Arc::new(SqliteProfileFieldsRepository::new(pool))
} }
pub(crate) fn format_year_month(ym: &str) -> String {
let parts: Vec<&str> = ym.splitn(2, '-').collect();
if parts.len() != 2 {
return ym.to_string();
}
let year = parts[0].get(2..).unwrap_or(parts[0]);
let month = match parts[1] {
"01" => "Jan",
"02" => "Feb",
"03" => "Mar",
"04" => "Apr",
"05" => "May",
"06" => "Jun",
"07" => "Jul",
"08" => "Aug",
"09" => "Sep",
"10" => "Oct",
"11" => "Nov",
"12" => "Dec",
_ => parts[1],
};
format!("{} '{}", month, year)
}
pub async fn migrate(pool: &SqlitePool) -> Result<(), domain::errors::DomainError> { pub async fn migrate(pool: &SqlitePool) -> Result<(), domain::errors::DomainError> {
migrations::run(pool).await migrations::run(pool).await
} }
pub struct SqliteWireOutput { pub struct SqliteWireOutput {
pub pool: SqlitePool, pub pool: SqlitePool,
pub movie: std::sync::Arc<dyn domain::ports::MovieRepository>, pub movie_command: std::sync::Arc<dyn domain::ports::MovieCommand>,
pub movie_query: std::sync::Arc<dyn domain::ports::MovieQuery>,
pub review: std::sync::Arc<dyn domain::ports::ReviewRepository>, pub review: std::sync::Arc<dyn domain::ports::ReviewRepository>,
pub diary: std::sync::Arc<dyn domain::ports::DiaryRepository>, pub diary: std::sync::Arc<dyn domain::ports::DiaryQuery>,
pub stats: std::sync::Arc<dyn domain::ports::StatsRepository>, pub stats: std::sync::Arc<dyn domain::ports::StatsRepository>,
pub user: std::sync::Arc<dyn domain::ports::UserRepository>, pub user: std::sync::Arc<dyn domain::ports::UserRepository>,
pub import_session: std::sync::Arc<dyn domain::ports::ImportSessionRepository>, pub import_session: std::sync::Arc<dyn domain::ports::ImportSessionRepository>,
@@ -87,7 +64,8 @@ pub struct SqliteWireOutput {
pub ap_content: std::sync::Arc<dyn domain::ports::LocalApContentQuery>, pub ap_content: std::sync::Arc<dyn domain::ports::LocalApContentQuery>,
pub wrapup_repo: std::sync::Arc<dyn domain::ports::WrapUpRepository>, pub wrapup_repo: std::sync::Arc<dyn domain::ports::WrapUpRepository>,
pub wrapup_stats: std::sync::Arc<dyn domain::ports::WrapUpStatsQuery>, pub wrapup_stats: std::sync::Arc<dyn domain::ports::WrapUpStatsQuery>,
pub goal: std::sync::Arc<dyn domain::ports::GoalRepository>, pub goal_command: std::sync::Arc<dyn domain::ports::GoalCommand>,
pub goal_query: std::sync::Arc<dyn domain::ports::GoalQuery>,
pub user_settings: std::sync::Arc<dyn domain::ports::UserSettingsRepository>, pub user_settings: std::sync::Arc<dyn domain::ports::UserSettingsRepository>,
pub federation_settings: std::sync::Arc<dyn domain::ports::UserFederationSettingsQuery>, pub federation_settings: std::sync::Arc<dyn domain::ports::UserFederationSettingsQuery>,
pub remote_goal: std::sync::Arc<dyn domain::ports::RemoteGoalRepository>, pub remote_goal: std::sync::Arc<dyn domain::ports::RemoteGoalRepository>,
@@ -119,9 +97,12 @@ pub async fn wire(database_url: &str) -> anyhow::Result<SqliteWireOutput> {
pool.clone(), pool.clone(),
)); ));
let movie_repo = std::sync::Arc::new(SqliteMovieRepository::new(pool.clone()));
Ok(SqliteWireOutput { Ok(SqliteWireOutput {
pool: pool.clone(), pool: pool.clone(),
movie: std::sync::Arc::new(SqliteMovieRepository::new(pool.clone())) as _, movie_command: movie_repo.clone() as _,
movie_query: movie_repo as _,
review: std::sync::Arc::new(SqliteReviewRepository::new(pool.clone())) as _, review: std::sync::Arc::new(SqliteReviewRepository::new(pool.clone())) as _,
diary: std::sync::Arc::new(SqliteDiaryRepository::new(pool.clone())) as _, diary: std::sync::Arc::new(SqliteDiaryRepository::new(pool.clone())) as _,
stats: std::sync::Arc::new(SqliteStatsRepository::new(pool.clone())) as _, stats: std::sync::Arc::new(SqliteStatsRepository::new(pool.clone())) as _,
@@ -133,7 +114,8 @@ pub async fn wire(database_url: &str) -> anyhow::Result<SqliteWireOutput> {
ap_content: std::sync::Arc::new(SqliteApContentQuery::new(pool.clone())) as _, ap_content: std::sync::Arc::new(SqliteApContentQuery::new(pool.clone())) as _,
wrapup_repo: std::sync::Arc::new(SqliteWrapUpRepository::new(pool.clone())) as _, wrapup_repo: std::sync::Arc::new(SqliteWrapUpRepository::new(pool.clone())) as _,
wrapup_stats: std::sync::Arc::new(SqliteWrapUpStatsQuery::new(pool.clone())) as _, wrapup_stats: std::sync::Arc::new(SqliteWrapUpStatsQuery::new(pool.clone())) as _,
goal: std::sync::Arc::new(goals::SqliteGoalRepository::new(pool.clone())) as _, goal_command: std::sync::Arc::new(goals::SqliteGoalRepository::new(pool.clone())) as _,
goal_query: std::sync::Arc::new(goals::SqliteGoalRepository::new(pool.clone())) as _,
user_settings: std::sync::Arc::clone(&user_settings_repo) as _, user_settings: std::sync::Arc::clone(&user_settings_repo) as _,
federation_settings: user_settings_repo as _, federation_settings: user_settings_repo as _,
remote_goal: std::sync::Arc::new(sqlite_federation::SqliteRemoteGoalRepository::new( remote_goal: std::sync::Arc::new(sqlite_federation::SqliteRemoteGoalRepository::new(

View File

@@ -1,16 +1,11 @@
use chrono::NaiveDateTime; use adapter_common::{
movie_row_to_domain, movie_stats_to_domain, movie_summary_to_domain, review_row_to_domain,
user_summary_to_domain, watchlist_entry_to_domain, watchlist_with_movie_to_domain,
};
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::{ models::{DiaryEntry, FeedEntry, Movie, MovieSummary, Review, WatchlistWithMovie},
DiaryEntry, FeedEntry, Movie, MovieSummary, PersistedReview, Review, ReviewSource,
UserSummary, WatchlistEntry, WatchlistWithMovie,
},
value_objects::{
Comment, Email, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
ReviewId, UserId, Username, WatchlistEntryId,
},
}; };
use uuid::Uuid;
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
pub(crate) struct MovieRow { pub(crate) struct MovieRow {
@@ -24,22 +19,14 @@ pub(crate) struct MovieRow {
impl MovieRow { impl MovieRow {
pub fn into_domain(self) -> Result<Movie, DomainError> { pub fn into_domain(self) -> Result<Movie, DomainError> {
let id = MovieId::from_uuid(parse_uuid(&self.id)?); movie_row_to_domain(
let external_metadata_id = self self.id,
.external_metadata_id self.external_metadata_id,
.map(ExternalMetadataId::new) self.title,
.transpose()?; self.release_year,
let title = MovieTitle::new(self.title)?;
let release_year = ReleaseYear::new(self.release_year as u16)?;
let poster_path = self.poster_path.map(PosterPath::new).transpose()?;
Ok(Movie::from_persistence(
id,
external_metadata_id,
title,
release_year,
self.director, self.director,
poster_path, self.poster_path,
)) )
} }
} }
@@ -60,27 +47,26 @@ pub(crate) struct MovieSummaryRow {
impl MovieSummaryRow { impl MovieSummaryRow {
pub fn into_domain(self) -> Result<MovieSummary, DomainError> { pub fn into_domain(self) -> Result<MovieSummary, DomainError> {
let movie = MovieRow { let movie = movie_row_to_domain(
id: self.id, self.id,
external_metadata_id: self.external_metadata_id, self.external_metadata_id,
title: self.title, self.title,
release_year: self.release_year, self.release_year,
director: self.director, self.director,
poster_path: self.poster_path, self.poster_path,
} )?;
.into_domain()?;
let genres = self let genres = self
.genres .genres
.map(|g| g.split(',').map(str::to_string).collect()) .map(|g| g.split(',').map(str::to_string).collect())
.unwrap_or_default(); .unwrap_or_default();
Ok(MovieSummary { Ok(movie_summary_to_domain(
movie, movie,
genres, genres,
runtime_minutes: self.runtime_minutes.map(|v| v as u32), self.runtime_minutes,
original_language: self.original_language, self.original_language,
overview: self.overview, self.overview,
collection_name: self.collection_name, self.collection_name,
}) ))
} }
} }
@@ -99,29 +85,17 @@ pub(crate) struct ReviewRow {
impl ReviewRow { impl ReviewRow {
pub fn into_domain(self) -> Result<Review, DomainError> { pub fn into_domain(self) -> Result<Review, DomainError> {
let id = ReviewId::from_uuid(parse_uuid(&self.id)?); review_row_to_domain(
let movie_id = MovieId::from_uuid(parse_uuid(&self.movie_id)?); self.id,
let user_id = UserId::from_uuid(parse_uuid(&self.user_id)?); self.movie_id,
let rating = Rating::new(self.rating as u8)?; self.user_id,
let comment = self.comment.map(Comment::new).transpose()?; self.rating,
let watched_at = parse_datetime(&self.watched_at)?; self.comment,
let created_at = parse_datetime(&self.created_at)?; self.watched_at,
let source = match self.remote_actor_url { self.created_at,
None => ReviewSource::Local, self.remote_actor_url,
Some(url) => ReviewSource::Remote { actor_url: url }, self.watch_medium,
}; )
let watch_medium = self.watch_medium.map(|s| s.parse()).transpose()?;
Ok(Review::from_persistence(PersistedReview {
id,
movie_id,
user_id,
rating,
comment,
watched_at,
created_at,
source,
watch_medium,
}))
} }
} }
@@ -147,29 +121,25 @@ pub(crate) struct DiaryRow {
impl DiaryRow { impl DiaryRow {
pub fn into_domain(self) -> Result<DiaryEntry, DomainError> { pub fn into_domain(self) -> Result<DiaryEntry, DomainError> {
let movie = MovieRow { let movie = movie_row_to_domain(
id: self.id, self.id,
external_metadata_id: self.external_metadata_id, self.external_metadata_id,
title: self.title, self.title,
release_year: self.release_year, self.release_year,
director: self.director, self.director,
poster_path: self.poster_path, self.poster_path,
} )?;
.into_domain()?; let review = review_row_to_domain(
self.review_id,
let review = ReviewRow { self.movie_id,
id: self.review_id, self.user_id,
movie_id: self.movie_id, self.rating,
user_id: self.user_id, self.comment,
rating: self.rating, self.watched_at,
comment: self.comment, self.created_at,
watched_at: self.watched_at, self.remote_actor_url,
created_at: self.created_at, self.watch_medium,
remote_actor_url: self.remote_actor_url, )?;
watch_medium: self.watch_medium,
}
.into_domain()?;
Ok(DiaryEntry::new(movie, review)) Ok(DiaryEntry::new(movie, review))
} }
} }
@@ -188,18 +158,18 @@ pub(crate) struct MovieStatsRow {
impl MovieStatsRow { impl MovieStatsRow {
pub fn into_domain(self) -> domain::models::MovieStats { pub fn into_domain(self) -> domain::models::MovieStats {
domain::models::MovieStats { movie_stats_to_domain(
total_count: self.total_count as u64, self.total_count,
avg_rating: self.avg_rating, self.avg_rating,
federated_count: self.federated_count as u64, self.federated_count,
rating_histogram: [ [
self.rating_1 as u64, self.rating_1,
self.rating_2 as u64, self.rating_2,
self.rating_3 as u64, self.rating_3,
self.rating_4 as u64, self.rating_4,
self.rating_5 as u64, self.rating_5,
], ],
} )
} }
} }
@@ -226,24 +196,26 @@ pub(crate) struct FeedRow {
impl FeedRow { impl FeedRow {
pub fn into_domain(self) -> Result<FeedEntry, DomainError> { pub fn into_domain(self) -> Result<FeedEntry, DomainError> {
let diary = DiaryRow { let movie = movie_row_to_domain(
id: self.id, self.id,
external_metadata_id: self.external_metadata_id, self.external_metadata_id,
title: self.title, self.title,
release_year: self.release_year, self.release_year,
director: self.director, self.director,
poster_path: self.poster_path, self.poster_path,
review_id: self.review_id, )?;
movie_id: self.movie_id, let review = review_row_to_domain(
user_id: self.user_id, self.review_id,
rating: self.rating, self.movie_id,
comment: self.comment, self.user_id,
watched_at: self.watched_at, self.rating,
created_at: self.created_at, self.comment,
remote_actor_url: self.remote_actor_url, self.watched_at,
watch_medium: self.watch_medium, self.created_at,
} self.remote_actor_url,
.into_domain()?; self.watch_medium,
)?;
let diary = DiaryEntry::new(movie, review);
Ok(FeedEntry::new(diary, self.user_email)) Ok(FeedEntry::new(diary, self.user_email))
} }
} }
@@ -260,16 +232,16 @@ pub(crate) struct UserSummaryRow {
} }
impl UserSummaryRow { impl UserSummaryRow {
pub fn into_domain(self) -> Result<UserSummary, DomainError> { pub fn into_domain(self) -> Result<domain::models::UserSummary, DomainError> {
Ok(UserSummary::new( user_summary_to_domain(
UserId::from_uuid(parse_uuid(&self.id)?), self.id,
Email::new(self.email)?, self.email,
Username::new(self.username)?, self.username,
self.display_name, self.display_name,
self.total_movies, self.total_movies,
self.avg_rating, self.avg_rating,
self.avatar_path, self.avatar_path,
)) )
} }
} }
@@ -308,35 +280,15 @@ pub(crate) struct WatchlistRow {
impl WatchlistRow { impl WatchlistRow {
pub fn into_domain(self) -> Result<WatchlistWithMovie, DomainError> { pub fn into_domain(self) -> Result<WatchlistWithMovie, DomainError> {
let entry = WatchlistEntry { let entry = watchlist_entry_to_domain(self.id, self.user_id, self.movie_id, self.added_at)?;
id: WatchlistEntryId::from_uuid(parse_uuid(&self.id)?), let movie = movie_row_to_domain(
user_id: UserId::from_uuid(parse_uuid(&self.user_id)?), self.m_id,
movie_id: MovieId::from_uuid(parse_uuid(&self.movie_id)?), self.external_metadata_id,
added_at: parse_datetime(&self.added_at)?, self.title,
}; self.release_year,
let movie = MovieRow { self.director,
id: self.m_id, self.poster_path,
external_metadata_id: self.external_metadata_id, )?;
title: self.title, Ok(watchlist_with_movie_to_domain(entry, movie))
release_year: self.release_year,
director: self.director,
poster_path: self.poster_path,
}
.into_domain()?;
Ok(WatchlistWithMovie { entry, movie })
} }
} }
pub(crate) fn parse_uuid(s: &str) -> Result<Uuid, DomainError> {
Uuid::parse_str(s)
.map_err(|e| DomainError::InfrastructureError(format!("Invalid UUID '{}': {}", s, e)))
}
pub(crate) fn datetime_to_str(dt: &NaiveDateTime) -> String {
dt.format("%Y-%m-%d %H:%M:%S").to_string()
}
pub(crate) fn parse_datetime(s: &str) -> Result<NaiveDateTime, DomainError> {
NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S")
.map_err(|e| DomainError::InfrastructureError(format!("Invalid datetime '{}': {}", s, e)))
}

View File

@@ -5,7 +5,7 @@ use domain::{
Movie, MovieFilter, MovieSummary, Movie, MovieFilter, MovieSummary,
collections::{PageParams, Paginated}, collections::{PageParams, Paginated},
}, },
ports::MovieRepository, ports::{MovieCommand, MovieQuery},
value_objects::{ExternalMetadataId, MovieId, MovieTitle, ReleaseYear}, value_objects::{ExternalMetadataId, MovieId, MovieTitle, ReleaseYear},
}; };
use sqlx::SqlitePool; use sqlx::SqlitePool;
@@ -20,67 +20,10 @@ impl SqliteMovieRepository {
pub fn new(pool: SqlitePool) -> Self { pub fn new(pool: SqlitePool) -> Self {
Self { pool } Self { pool }
} }
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
} }
#[async_trait] #[async_trait]
impl MovieRepository for SqliteMovieRepository { impl MovieCommand for SqliteMovieRepository {
async fn get_movie_by_external_id(
&self,
external_metadata_id: &ExternalMetadataId,
) -> Result<Option<Movie>, DomainError> {
let id = external_metadata_id.value();
sqlx::query_as::<_, MovieRow>(
"SELECT id, external_metadata_id, title, release_year, director, poster_path
FROM movies WHERE external_metadata_id = ?",
)
.bind(id)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?
.map(MovieRow::into_domain)
.transpose()
}
async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result<Option<Movie>, DomainError> {
let id = movie_id.value().to_string();
sqlx::query_as::<_, MovieRow>(
"SELECT id, external_metadata_id, title, release_year, director, poster_path
FROM movies WHERE id = ?",
)
.bind(&id)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?
.map(MovieRow::into_domain)
.transpose()
}
async fn get_movies_by_title_and_year(
&self,
title: &MovieTitle,
year: &ReleaseYear,
) -> Result<Vec<Movie>, DomainError> {
let t = title.value();
let y = year.value() as i64;
sqlx::query_as::<_, MovieRow>(
"SELECT id, external_metadata_id, title, release_year, director, poster_path
FROM movies WHERE title = ? AND release_year = ?",
)
.bind(t)
.bind(y)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?
.into_iter()
.map(MovieRow::into_domain)
.collect()
}
async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError> { async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError> {
let id = movie.id().value().to_string(); let id = movie.id().value().to_string();
let external_metadata_id = movie.external_metadata_id().map(|e| e.value().to_string()); let external_metadata_id = movie.external_metadata_id().map(|e| e.value().to_string());
@@ -107,7 +50,7 @@ impl MovieRepository for SqliteMovieRepository {
.bind(&poster_path) .bind(&poster_path)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(()) Ok(())
} }
@@ -118,9 +61,64 @@ impl MovieRepository for SqliteMovieRepository {
.bind(&id) .bind(&id)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(()) Ok(())
} }
}
#[async_trait]
impl MovieQuery for SqliteMovieRepository {
async fn get_movie_by_external_id(
&self,
external_metadata_id: &ExternalMetadataId,
) -> Result<Option<Movie>, DomainError> {
let id = external_metadata_id.value();
sqlx::query_as::<_, MovieRow>(
"SELECT id, external_metadata_id, title, release_year, director, poster_path
FROM movies WHERE external_metadata_id = ?",
)
.bind(id)
.fetch_optional(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?
.map(MovieRow::into_domain)
.transpose()
}
async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result<Option<Movie>, DomainError> {
let id = movie_id.value().to_string();
sqlx::query_as::<_, MovieRow>(
"SELECT id, external_metadata_id, title, release_year, director, poster_path
FROM movies WHERE id = ?",
)
.bind(&id)
.fetch_optional(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?
.map(MovieRow::into_domain)
.transpose()
}
async fn get_movies_by_title_and_year(
&self,
title: &MovieTitle,
year: &ReleaseYear,
) -> Result<Vec<Movie>, DomainError> {
let t = title.value();
let y = year.value() as i64;
sqlx::query_as::<_, MovieRow>(
"SELECT id, external_metadata_id, title, release_year, director, poster_path
FROM movies WHERE title = ? AND release_year = ?",
)
.bind(t)
.bind(y)
.fetch_all(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?
.into_iter()
.map(MovieRow::into_domain)
.collect()
}
async fn existing_external_ids( async fn existing_external_ids(
&self, &self,
@@ -138,7 +136,10 @@ impl MovieRepository for SqliteMovieRepository {
for id in ids { for id in ids {
q = q.bind(id.value().to_string()); q = q.bind(id.value().to_string());
} }
let rows = q.fetch_all(&self.pool).await.map_err(Self::map_err)?; let rows = q
.fetch_all(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
Ok(rows.into_iter().collect()) Ok(rows.into_iter().collect())
} }
@@ -162,7 +163,10 @@ impl MovieRepository for SqliteMovieRepository {
for (t, y) in pairs { for (t, y) in pairs {
q = q.bind(t.value().to_string()).bind(y.value() as i64); q = q.bind(t.value().to_string()).bind(y.value() as i64);
} }
let rows = q.fetch_all(&self.pool).await.map_err(Self::map_err)?; let rows = q
.fetch_all(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
Ok(rows Ok(rows
.into_iter() .into_iter()
.map(|r| { .map(|r| {
@@ -214,7 +218,7 @@ impl MovieRepository for SqliteMovieRepository {
.bind(offset) .bind(offset)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
let total: i64 = sqlx::query( let total: i64 = sqlx::query(
"SELECT COUNT(DISTINCT m.id) \ "SELECT COUNT(DISTINCT m.id) \
@@ -232,7 +236,7 @@ impl MovieRepository for SqliteMovieRepository {
.bind(genre) .bind(genre)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await
.map_err(Self::map_err)? .map_err(adapter_common::map_sqlx_error)?
.try_get(0) .try_get(0)
.unwrap_or(0); .unwrap_or(0);
@@ -256,7 +260,7 @@ impl MovieRepository for SqliteMovieRepository {
) )
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(Self::map_err)? .map_err(adapter_common::map_sqlx_error)?
.into_iter() .into_iter()
.map(MovieRow::into_domain) .map(MovieRow::into_domain)
.collect() .collect()

View File

@@ -12,11 +12,6 @@ impl SqliteMovieDeduplicator {
pub fn new(pool: SqlitePool) -> Self { pub fn new(pool: SqlitePool) -> Self {
Self { pool } Self { pool }
} }
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
} }
#[async_trait] #[async_trait]
@@ -36,7 +31,11 @@ impl MovieDeduplicator for SqliteMovieDeduplicator {
let director = canonical.director().map(str::to_string); let director = canonical.director().map(str::to_string);
let poster = canonical.poster_path().map(|p| p.value().to_string()); let poster = canonical.poster_path().map(|p| p.value().to_string());
let mut tx = self.pool.begin().await.map_err(Self::map_err)?; let mut tx = self
.pool
.begin()
.await
.map_err(adapter_common::map_sqlx_error)?;
// 1. Upsert canonical movie record // 1. Upsert canonical movie record
sqlx::query( sqlx::query(
@@ -47,7 +46,7 @@ impl MovieDeduplicator for SqliteMovieDeduplicator {
poster_path = COALESCE(excluded.poster_path, movies.poster_path)", poster_path = COALESCE(excluded.poster_path, movies.poster_path)",
) )
.bind(&new).bind(&ext_id).bind(&title).bind(year).bind(&director).bind(&poster) .bind(&new).bind(&ext_id).bind(&title).bind(year).bind(&director).bind(&poster)
.execute(&mut *tx).await.map_err(Self::map_err)?; .execute(&mut *tx).await.map_err(adapter_common::map_sqlx_error)?;
// 2. Re-point simple FK tables // 2. Re-point simple FK tables
let reviews = sqlx::query("UPDATE reviews SET movie_id = ? WHERE movie_id = ?") let reviews = sqlx::query("UPDATE reviews SET movie_id = ? WHERE movie_id = ?")
@@ -55,7 +54,7 @@ impl MovieDeduplicator for SqliteMovieDeduplicator {
.bind(&old) .bind(&old)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(Self::map_err)? .map_err(adapter_common::map_sqlx_error)?
.rows_affected(); .rows_affected();
let watchlist = sqlx::query("UPDATE watchlist_entries SET movie_id = ? WHERE movie_id = ?") let watchlist = sqlx::query("UPDATE watchlist_entries SET movie_id = ? WHERE movie_id = ?")
@@ -63,7 +62,7 @@ impl MovieDeduplicator for SqliteMovieDeduplicator {
.bind(&old) .bind(&old)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(Self::map_err)? .map_err(adapter_common::map_sqlx_error)?
.rows_affected(); .rows_affected();
let watch_events = sqlx::query("UPDATE watch_events SET movie_id = ? WHERE movie_id = ?") let watch_events = sqlx::query("UPDATE watch_events SET movie_id = ? WHERE movie_id = ?")
@@ -71,7 +70,7 @@ impl MovieDeduplicator for SqliteMovieDeduplicator {
.bind(&old) .bind(&old)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(Self::map_err)? .map_err(adapter_common::map_sqlx_error)?
.rows_affected(); .rows_affected();
// 3. Re-point movie_profiles (PK — move only if canonical has none) // 3. Re-point movie_profiles (PK — move only if canonical has none)
@@ -80,7 +79,7 @@ impl MovieDeduplicator for SqliteMovieDeduplicator {
.bind(&old) .bind(&old)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(Self::map_err)? .map_err(adapter_common::map_sqlx_error)?
.rows_affected(); .rows_affected();
// 4. Re-point enrichment tables with composite PKs (INSERT OR IGNORE + DELETE) // 4. Re-point enrichment tables with composite PKs (INSERT OR IGNORE + DELETE)
@@ -93,12 +92,12 @@ impl MovieDeduplicator for SqliteMovieDeduplicator {
.bind(&old) .bind(&old)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
sqlx::query("DELETE FROM movie_genres WHERE movie_id = ?") sqlx::query("DELETE FROM movie_genres WHERE movie_id = ?")
.bind(&old) .bind(&old)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
sqlx::query( sqlx::query(
"INSERT OR IGNORE INTO movie_keywords (movie_id, tmdb_id, name) "INSERT OR IGNORE INTO movie_keywords (movie_id, tmdb_id, name)
@@ -108,41 +107,41 @@ impl MovieDeduplicator for SqliteMovieDeduplicator {
.bind(&old) .bind(&old)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
sqlx::query("DELETE FROM movie_keywords WHERE movie_id = ?") sqlx::query("DELETE FROM movie_keywords WHERE movie_id = ?")
.bind(&old) .bind(&old)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
sqlx::query( sqlx::query(
"INSERT OR IGNORE INTO movie_cast (movie_id, tmdb_person_id, name, character, billing_order, profile_path) "INSERT OR IGNORE INTO movie_cast (movie_id, tmdb_person_id, name, character, billing_order, profile_path)
SELECT ?, tmdb_person_id, name, character, billing_order, profile_path FROM movie_cast WHERE movie_id = ?", SELECT ?, tmdb_person_id, name, character, billing_order, profile_path FROM movie_cast WHERE movie_id = ?",
).bind(&new).bind(&old).execute(&mut *tx).await.map_err(Self::map_err)?; ).bind(&new).bind(&old).execute(&mut *tx).await.map_err(adapter_common::map_sqlx_error)?;
sqlx::query("DELETE FROM movie_cast WHERE movie_id = ?") sqlx::query("DELETE FROM movie_cast WHERE movie_id = ?")
.bind(&old) .bind(&old)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
sqlx::query( sqlx::query(
"INSERT OR IGNORE INTO movie_crew (movie_id, tmdb_person_id, name, job, department, profile_path) "INSERT OR IGNORE INTO movie_crew (movie_id, tmdb_person_id, name, job, department, profile_path)
SELECT ?, tmdb_person_id, name, job, department, profile_path FROM movie_crew WHERE movie_id = ?", SELECT ?, tmdb_person_id, name, job, department, profile_path FROM movie_crew WHERE movie_id = ?",
).bind(&new).bind(&old).execute(&mut *tx).await.map_err(Self::map_err)?; ).bind(&new).bind(&old).execute(&mut *tx).await.map_err(adapter_common::map_sqlx_error)?;
sqlx::query("DELETE FROM movie_crew WHERE movie_id = ?") sqlx::query("DELETE FROM movie_crew WHERE movie_id = ?")
.bind(&old) .bind(&old)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
// 5. Delete the now-empty old movie record (remaining cascades are safe: all FKs cleared above) // 5. Delete the now-empty old movie record (remaining cascades are safe: all FKs cleared above)
sqlx::query("DELETE FROM movies WHERE id = ?") sqlx::query("DELETE FROM movies WHERE id = ?")
.bind(&old) .bind(&old)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
tx.commit().await.map_err(Self::map_err)?; tx.commit().await.map_err(adapter_common::map_sqlx_error)?;
Ok(reviews + watchlist + watch_events + profiles) Ok(reviews + watchlist + watch_events + profiles)
} }

View File

@@ -29,10 +29,6 @@ pub fn create_person_adapter(pool: SqlitePool) -> (Arc<dyn PersonCommand>, Arc<d
) )
} }
fn map_err(e: sqlx::Error) -> DomainError {
DomainError::InfrastructureError(e.to_string())
}
#[async_trait] #[async_trait]
impl PersonCommand for SqlitePersonAdapter { impl PersonCommand for SqlitePersonAdapter {
async fn upsert_batch(&self, persons: &[Person]) -> Result<(), DomainError> { async fn upsert_batch(&self, persons: &[Person]) -> Result<(), DomainError> {
@@ -56,7 +52,7 @@ impl PersonCommand for SqlitePersonAdapter {
.bind(person.profile_path()) .bind(person.profile_path())
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
} }
Ok(()) Ok(())
} }
@@ -89,7 +85,7 @@ impl PersonCommand for SqlitePersonAdapter {
.bind(batch_size) .bind(batch_size)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
let has_more = rows.len() as u32 >= batch_size; let has_more = rows.len() as u32 >= batch_size;
let mut count = 0u64; let mut count = 0u64;
@@ -109,7 +105,7 @@ impl PersonCommand for SqlitePersonAdapter {
.bind(&row.profile_path) .bind(&row.profile_path)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
count += 1; count += 1;
} }
Ok((count, has_more)) Ok((count, has_more))
@@ -137,7 +133,7 @@ impl PersonCommand for SqlitePersonAdapter {
.bind(id.value().to_string()) .bind(id.value().to_string())
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(()) Ok(())
} }
} }
@@ -151,7 +147,7 @@ impl PersonQuery for SqlitePersonAdapter {
.bind(id.value().to_string()) .bind(id.value().to_string())
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(row.map(PersonRow::into_person)) Ok(row.map(PersonRow::into_person))
} }
@@ -166,7 +162,7 @@ impl PersonQuery for SqlitePersonAdapter {
.bind(id.value()) .bind(id.value())
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(row.map(PersonRow::into_person)) Ok(row.map(PersonRow::into_person))
} }
@@ -182,7 +178,7 @@ impl PersonQuery for SqlitePersonAdapter {
.bind(id.value().to_string()) .bind(id.value().to_string())
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(map_err)? .map_err(adapter_common::map_sqlx_error)?
.flatten(); .flatten();
let Some(tmdb_id) = tmdb_id else { let Some(tmdb_id) = tmdb_id else {
@@ -203,7 +199,7 @@ impl PersonQuery for SqlitePersonAdapter {
.bind(tmdb_id) .bind(tmdb_id)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(map_err)? .map_err(adapter_common::map_sqlx_error)?
.into_iter() .into_iter()
.map(|r| CastCredit { .map(|r| CastCredit {
movie_id: MovieId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()), movie_id: MovieId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()),
@@ -224,7 +220,7 @@ impl PersonQuery for SqlitePersonAdapter {
.bind(tmdb_id) .bind(tmdb_id)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(map_err)? .map_err(adapter_common::map_sqlx_error)?
.into_iter() .into_iter()
.map(|r| CrewCredit { .map(|r| CrewCredit {
movie_id: MovieId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()), movie_id: MovieId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()),
@@ -247,7 +243,7 @@ impl PersonQuery for SqlitePersonAdapter {
.bind(offset) .bind(offset)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(rows.into_iter().map(PersonRow::into_person).collect()) Ok(rows.into_iter().map(PersonRow::into_person).collect())
} }
@@ -265,7 +261,7 @@ impl PersonQuery for SqlitePersonAdapter {
) )
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(rows Ok(rows
.into_iter() .into_iter()

View File

@@ -16,11 +16,6 @@ impl SqliteMovieProfileRepository {
pub fn new(pool: SqlitePool) -> Self { pub fn new(pool: SqlitePool) -> Self {
Self { pool } Self { pool }
} }
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
} }
#[async_trait] #[async_trait]
@@ -29,7 +24,11 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
let movie_id = p.movie_id.value().to_string(); let movie_id = p.movie_id.value().to_string();
let enriched_at = p.enriched_at.to_rfc3339(); let enriched_at = p.enriched_at.to_rfc3339();
let mut tx = self.pool.begin().await.map_err(Self::map_err)?; let mut tx = self
.pool
.begin()
.await
.map_err(adapter_common::map_sqlx_error)?;
sqlx::query( sqlx::query(
r#"INSERT INTO movie_profiles r#"INSERT INTO movie_profiles
@@ -62,13 +61,13 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
.bind(&enriched_at) .bind(&enriched_at)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
sqlx::query("DELETE FROM movie_genres WHERE movie_id = ?") sqlx::query("DELETE FROM movie_genres WHERE movie_id = ?")
.bind(&movie_id) .bind(&movie_id)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
for g in &p.genres { for g in &p.genres {
sqlx::query( sqlx::query(
"INSERT OR IGNORE INTO movie_genres (movie_id, tmdb_id, name) VALUES (?,?,?)", "INSERT OR IGNORE INTO movie_genres (movie_id, tmdb_id, name) VALUES (?,?,?)",
@@ -78,14 +77,14 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
.bind(&g.name) .bind(&g.name)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
} }
sqlx::query("DELETE FROM movie_keywords WHERE movie_id = ?") sqlx::query("DELETE FROM movie_keywords WHERE movie_id = ?")
.bind(&movie_id) .bind(&movie_id)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
for k in &p.keywords { for k in &p.keywords {
sqlx::query( sqlx::query(
"INSERT OR IGNORE INTO movie_keywords (movie_id, tmdb_id, name) VALUES (?,?,?)", "INSERT OR IGNORE INTO movie_keywords (movie_id, tmdb_id, name) VALUES (?,?,?)",
@@ -95,14 +94,14 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
.bind(&k.name) .bind(&k.name)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
} }
sqlx::query("DELETE FROM movie_cast WHERE movie_id = ?") sqlx::query("DELETE FROM movie_cast WHERE movie_id = ?")
.bind(&movie_id) .bind(&movie_id)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
for c in &p.cast { for c in &p.cast {
sqlx::query( sqlx::query(
"INSERT OR IGNORE INTO movie_cast \ "INSERT OR IGNORE INTO movie_cast \
@@ -117,14 +116,14 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
.bind(&c.profile_path) .bind(&c.profile_path)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
} }
sqlx::query("DELETE FROM movie_crew WHERE movie_id = ?") sqlx::query("DELETE FROM movie_crew WHERE movie_id = ?")
.bind(&movie_id) .bind(&movie_id)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
for cr in &p.crew { for cr in &p.crew {
sqlx::query( sqlx::query(
"INSERT OR IGNORE INTO movie_crew \ "INSERT OR IGNORE INTO movie_crew \
@@ -139,10 +138,10 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
.bind(&cr.profile_path) .bind(&cr.profile_path)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
} }
tx.commit().await.map_err(Self::map_err) tx.commit().await.map_err(adapter_common::map_sqlx_error)
} }
async fn get_by_movie_id(&self, id: &MovieId) -> Result<Option<MovieProfile>, DomainError> { async fn get_by_movie_id(&self, id: &MovieId) -> Result<Option<MovieProfile>, DomainError> {
@@ -157,7 +156,7 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
.bind(&movie_id) .bind(&movie_id)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
let row = match row { let row = match row {
Some(r) => r, Some(r) => r,
@@ -175,7 +174,7 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
.bind(&movie_id) .bind(&movie_id)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(Self::map_err)? .map_err(adapter_common::map_sqlx_error)?
.into_iter() .into_iter()
.map(|r| Genre { .map(|r| Genre {
tmdb_id: r.try_get::<i64, _>("tmdb_id").unwrap_or(0) as u32, tmdb_id: r.try_get::<i64, _>("tmdb_id").unwrap_or(0) as u32,
@@ -187,7 +186,7 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
.bind(&movie_id) .bind(&movie_id)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(Self::map_err)? .map_err(adapter_common::map_sqlx_error)?
.into_iter() .into_iter()
.map(|r| Keyword { .map(|r| Keyword {
tmdb_id: r.try_get::<i64, _>("tmdb_id").unwrap_or(0) as u32, tmdb_id: r.try_get::<i64, _>("tmdb_id").unwrap_or(0) as u32,
@@ -202,7 +201,7 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
.bind(&movie_id) .bind(&movie_id)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(Self::map_err)? .map_err(adapter_common::map_sqlx_error)?
.into_iter() .into_iter()
.map(|r| CastMember { .map(|r| CastMember {
tmdb_person_id: r.try_get::<i64, _>("tmdb_person_id").unwrap_or(0) as u64, tmdb_person_id: r.try_get::<i64, _>("tmdb_person_id").unwrap_or(0) as u64,
@@ -220,7 +219,7 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
.bind(&movie_id) .bind(&movie_id)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(Self::map_err)? .map_err(adapter_common::map_sqlx_error)?
.into_iter() .into_iter()
.map(|r| CrewMember { .map(|r| CrewMember {
tmdb_person_id: r.try_get::<i64, _>("tmdb_person_id").unwrap_or(0) as u64, tmdb_person_id: r.try_get::<i64, _>("tmdb_person_id").unwrap_or(0) as u64,
@@ -273,7 +272,7 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
.bind(&threshold) .bind(&threshold)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(rows Ok(rows
.into_iter() .into_iter()

View File

@@ -14,11 +14,6 @@ impl SqliteProfileFieldsRepository {
pub fn new(pool: SqlitePool) -> Self { pub fn new(pool: SqlitePool) -> Self {
Self { pool } Self { pool }
} }
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
} }
#[async_trait] #[async_trait]
@@ -31,7 +26,7 @@ impl UserProfileFieldsRepository for SqliteProfileFieldsRepository {
.bind(&id_str) .bind(&id_str)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(rows Ok(rows
.iter() .iter()
@@ -53,7 +48,7 @@ impl UserProfileFieldsRepository for SqliteProfileFieldsRepository {
.bind(&id_str) .bind(&id_str)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
for (i, field) in fields.into_iter().enumerate() { for (i, field) in fields.into_iter().enumerate() {
let id = uuid::Uuid::new_v4().to_string(); let id = uuid::Uuid::new_v4().to_string();
@@ -68,7 +63,7 @@ impl UserProfileFieldsRepository for SqliteProfileFieldsRepository {
.bind(position) .bind(position)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
} }
Ok(()) Ok(())

View File

@@ -16,10 +16,6 @@ impl SqliteRefreshSessionAdapter {
} }
} }
fn map_err(e: sqlx::Error) -> DomainError {
DomainError::InfrastructureError(e.to_string())
}
#[async_trait] #[async_trait]
impl RefreshSessionRepository for SqliteRefreshSessionAdapter { impl RefreshSessionRepository for SqliteRefreshSessionAdapter {
async fn create(&self, session: &RefreshSession) -> Result<(), DomainError> { async fn create(&self, session: &RefreshSession) -> Result<(), DomainError> {
@@ -34,7 +30,7 @@ impl RefreshSessionRepository for SqliteRefreshSessionAdapter {
.bind(session.created_at.to_rfc3339()) .bind(session.created_at.to_rfc3339())
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(()) Ok(())
} }
@@ -45,7 +41,7 @@ impl RefreshSessionRepository for SqliteRefreshSessionAdapter {
.bind(token) .bind(token)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
row.map(RefreshSessionRow::into_domain).transpose() row.map(RefreshSessionRow::into_domain).transpose()
} }
@@ -55,7 +51,7 @@ impl RefreshSessionRepository for SqliteRefreshSessionAdapter {
.bind(token) .bind(token)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(()) Ok(())
} }
@@ -64,7 +60,7 @@ impl RefreshSessionRepository for SqliteRefreshSessionAdapter {
.bind(user_id.value().to_string()) .bind(user_id.value().to_string())
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(()) Ok(())
} }
@@ -74,7 +70,7 @@ impl RefreshSessionRepository for SqliteRefreshSessionAdapter {
.bind(&now) .bind(&now)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(result.rows_affected()) Ok(result.rows_affected())
} }
} }

View File

@@ -1,14 +1,14 @@
use async_trait::async_trait; use async_trait::async_trait;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent,
models::{Review, ReviewSource}, models::{Review, ReviewSource},
ports::ReviewRepository, ports::ReviewRepository,
value_objects::{ReviewId, UserId}, value_objects::{ReviewId, UserId},
}; };
use sqlx::SqlitePool; use sqlx::SqlitePool;
use crate::models::{ReviewRow, datetime_to_str}; use crate::models::ReviewRow;
use adapter_common::datetime_to_str;
pub struct SqliteReviewRepository { pub struct SqliteReviewRepository {
pool: SqlitePool, pool: SqlitePool,
@@ -18,16 +18,11 @@ impl SqliteReviewRepository {
pub fn new(pool: SqlitePool) -> Self { pub fn new(pool: SqlitePool) -> Self {
Self { pool } Self { pool }
} }
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
} }
#[async_trait] #[async_trait]
impl ReviewRepository for SqliteReviewRepository { impl ReviewRepository for SqliteReviewRepository {
async fn save_review(&self, review: &Review) -> Result<DomainEvent, DomainError> { async fn save_review(&self, review: &Review) -> Result<(), DomainError> {
let id = review.id().value().to_string(); let id = review.id().value().to_string();
let movie_id = review.movie_id().value().to_string(); let movie_id = review.movie_id().value().to_string();
let user_id = review.user_id().value().to_string(); let user_id = review.user_id().value().to_string();
@@ -55,15 +50,9 @@ impl ReviewRepository for SqliteReviewRepository {
.bind(review.watch_medium().map(|wm| wm.to_string())) .bind(review.watch_medium().map(|wm| wm.to_string()))
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(DomainEvent::ReviewLogged { Ok(())
review_id: review.id().clone(),
movie_id: review.movie_id().clone(),
user_id: review.user_id().clone(),
rating: review.rating().clone(),
watched_at: *review.watched_at(),
})
} }
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError> { async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError> {
@@ -75,7 +64,7 @@ impl ReviewRepository for SqliteReviewRepository {
.bind(&id) .bind(&id)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(Self::map_err)? .map_err(adapter_common::map_sqlx_error)?
.map(ReviewRow::into_domain) .map(ReviewRow::into_domain)
.transpose() .transpose()
} }
@@ -97,7 +86,7 @@ impl ReviewRepository for SqliteReviewRepository {
.bind(&id) .bind(&id)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(()) Ok(())
} }
@@ -108,7 +97,7 @@ impl ReviewRepository for SqliteReviewRepository {
.bind(&id) .bind(&id)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(()) Ok(())
} }
@@ -121,7 +110,7 @@ impl ReviewRepository for SqliteReviewRepository {
.bind(&uid) .bind(&uid)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(Self::map_err)? .map_err(adapter_common::map_sqlx_error)?
.into_iter() .into_iter()
.map(ReviewRow::into_domain) .map(ReviewRow::into_domain)
.collect() .collect()

View File

@@ -18,11 +18,6 @@ impl SqliteStatsRepository {
Self { pool } Self { pool }
} }
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
async fn fetch_user_totals(&self, user_id: &str) -> Result<UserTotalsRow, DomainError> { async fn fetch_user_totals(&self, user_id: &str) -> Result<UserTotalsRow, DomainError> {
sqlx::query_as::<_, UserTotalsRow>( sqlx::query_as::<_, UserTotalsRow>(
"SELECT COUNT(DISTINCT movie_id) AS total, "SELECT COUNT(DISTINCT movie_id) AS total,
@@ -32,7 +27,7 @@ impl SqliteStatsRepository {
.bind(user_id) .bind(user_id)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await
.map_err(Self::map_err) .map_err(adapter_common::map_sqlx_error)
} }
async fn fetch_user_favorite_director( async fn fetch_user_favorite_director(
@@ -51,7 +46,7 @@ impl SqliteStatsRepository {
.bind(user_id) .bind(user_id)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(row) Ok(row)
} }
@@ -70,7 +65,7 @@ impl SqliteStatsRepository {
.bind(user_id) .bind(user_id)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(row) Ok(row)
} }
} }
@@ -86,7 +81,7 @@ impl StatsRepository for SqliteStatsRepository {
self.fetch_user_most_active_month(&uid) self.fetch_user_most_active_month(&uid)
)?; )?;
let most_active_month = most_active.map(|ym| crate::format_year_month(&ym)); let most_active_month = most_active.map(|ym| adapter_common::format_year_month(&ym));
Ok(UserStats { Ok(UserStats {
total_movies: totals.total, total_movies: totals.total,
@@ -96,6 +91,10 @@ impl StatsRepository for SqliteStatsRepository {
}) })
} }
async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result<u32, DomainError> {
crate::goals::count_reviews_in_year(&self.pool, user_id, year).await
}
async fn get_user_trends(&self, user_id: &UserId) -> Result<UserTrends, DomainError> { async fn get_user_trends(&self, user_id: &UserId) -> Result<UserTrends, DomainError> {
let uid = user_id.value().to_string(); let uid = user_id.value().to_string();
@@ -124,14 +123,14 @@ impl StatsRepository for SqliteStatsRepository {
.bind(&uid) .bind(&uid)
.fetch_all(&self.pool) .fetch_all(&self.pool)
) )
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
let max_director_count = director_rows.iter().map(|d| d.count).max().unwrap_or(1); let max_director_count = director_rows.iter().map(|d| d.count).max().unwrap_or(1);
let monthly_ratings = rating_rows let monthly_ratings = rating_rows
.into_iter() .into_iter()
.map(|r| MonthlyRating { .map(|r| MonthlyRating {
month_label: crate::format_year_month(&r.month), month_label: adapter_common::format_year_month(&r.month),
year_month: r.month, year_month: r.month,
avg_rating: r.avg_rating, avg_rating: r.avg_rating,
count: r.count, count: r.count,

View File

@@ -2,7 +2,7 @@ use super::*;
use domain::{ use domain::{
models::collections::PageParams, models::collections::PageParams,
models::{FeedSortBy, FollowingFilter}, models::{FeedSortBy, FollowingFilter},
ports::DiaryRepository, ports::DiaryQuery,
}; };
use sqlx::SqlitePool; use sqlx::SqlitePool;

View File

@@ -15,11 +15,6 @@ impl SqliteUserSettingsRepository {
pub fn new(pool: SqlitePool) -> Self { pub fn new(pool: SqlitePool) -> Self {
Self { pool } Self { pool }
} }
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
} }
#[async_trait] #[async_trait]
@@ -33,7 +28,7 @@ impl UserSettingsRepository for SqliteUserSettingsRepository {
.bind(&uid) .bind(&uid)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
match row { match row {
Some(r) => { Some(r) => {
@@ -68,7 +63,7 @@ impl UserSettingsRepository for SqliteUserSettingsRepository {
}) })
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(()) Ok(())
} }
} }
@@ -84,7 +79,7 @@ impl UserFederationSettingsQuery for SqliteUserSettingsRepository {
.bind(&uid) .bind(&uid)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
match row { match row {
Some(r) => { Some(r) => {

View File

@@ -19,11 +19,6 @@ impl SqliteUserRepository {
Self { pool } Self { pool }
} }
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
fn parse_role(s: &str) -> UserRole { fn parse_role(s: &str) -> UserRole {
match s { match s {
"admin" => UserRole::Admin, "admin" => UserRole::Admin,
@@ -73,7 +68,7 @@ impl UserRepository for SqliteUserRepository {
.bind(email_str) .bind(email_str)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
row.as_ref() row.as_ref()
.map(|r| Self::row_to_user(r, vec![])) .map(|r| Self::row_to_user(r, vec![]))
@@ -86,7 +81,7 @@ impl UserRepository for SqliteUserRepository {
.bind(username_str) .bind(username_str)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
row.as_ref() row.as_ref()
.map(|r| Self::row_to_user(r, vec![])) .map(|r| Self::row_to_user(r, vec![]))
@@ -126,7 +121,7 @@ impl UserRepository for SqliteUserRepository {
.bind(role) .bind(role)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(()) Ok(())
} }
@@ -137,7 +132,7 @@ impl UserRepository for SqliteUserRepository {
.bind(&id_str) .bind(&id_str)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
let Some(r) = row else { return Ok(None) }; let Some(r) = row else { return Ok(None) };
@@ -194,7 +189,7 @@ impl UserRepository for SqliteUserRepository {
) )
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(Self::map_err)? .map_err(adapter_common::map_sqlx_error)?
.into_iter() .into_iter()
.map(UserSummaryRow::into_domain) .map(UserSummaryRow::into_domain)
.collect() .collect()

View File

@@ -2,17 +2,12 @@ use async_trait::async_trait;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::{PersistedWatchEvent, WatchEvent, WatchEventSource, WatchEventStatus, WebhookToken}, models::{PersistedWatchEvent, WatchEvent, WatchEventSource, WatchEventStatus, WebhookToken},
ports::{WatchEventRepository, WebhookTokenRepository}, ports::{WatchEventCommand, WatchEventQuery, WebhookTokenRepository},
value_objects::{MovieId, UserId, WatchEventId, WebhookTokenId}, value_objects::{MovieId, UserId, WatchEventId, WebhookTokenId},
}; };
use sqlx::{Row, SqlitePool}; use sqlx::{Row, SqlitePool};
use crate::models::datetime_to_str; use adapter_common::datetime_to_str;
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
fn parse_uuid(s: &str) -> Result<uuid::Uuid, DomainError> { fn parse_uuid(s: &str) -> Result<uuid::Uuid, DomainError> {
s.parse() s.parse()
@@ -38,7 +33,7 @@ impl SqliteWatchEventRepository {
} }
#[async_trait] #[async_trait]
impl WatchEventRepository for SqliteWatchEventRepository { impl WatchEventCommand for SqliteWatchEventRepository {
async fn save(&self, event: &WatchEvent) -> Result<(), DomainError> { async fn save(&self, event: &WatchEvent) -> Result<(), DomainError> {
let id = event.id().value().to_string(); let id = event.id().value().to_string();
let user_id = event.user_id().value().to_string(); let user_id = event.user_id().value().to_string();
@@ -65,7 +60,7 @@ impl WatchEventRepository for SqliteWatchEventRepository {
.bind(&created_at) .bind(&created_at)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(()) Ok(())
} }
@@ -83,64 +78,11 @@ impl WatchEventRepository for SqliteWatchEventRepository {
.bind(&id_str) .bind(&id_str)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(()) Ok(())
} }
async fn list_pending(&self, user_id: &UserId) -> Result<Vec<WatchEvent>, DomainError> {
let uid = user_id.value().to_string();
let rows = sqlx::query(
"SELECT id, user_id, movie_id, title, year, external_metadata_id, \
source, watched_at, status, created_at \
FROM watch_events \
WHERE user_id = ? AND status = 'pending' \
ORDER BY watched_at DESC",
)
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(map_err)?;
rows.iter().map(row_to_watch_event).collect()
}
async fn get_by_id(&self, id: &WatchEventId) -> Result<Option<WatchEvent>, DomainError> {
let id_str = id.value().to_string();
let row = sqlx::query(
"SELECT id, user_id, movie_id, title, year, external_metadata_id, \
source, watched_at, status, created_at \
FROM watch_events WHERE id = ?",
)
.bind(&id_str)
.fetch_optional(&self.pool)
.await
.map_err(map_err)?;
row.as_ref().map(row_to_watch_event).transpose()
}
async fn get_by_ids(&self, ids: &[WatchEventId]) -> Result<Vec<WatchEvent>, DomainError> {
if ids.is_empty() {
return Ok(vec![]);
}
let placeholders: Vec<&str> = ids.iter().map(|_| "?").collect();
let sql = format!(
"SELECT id, user_id, movie_id, title, year, external_metadata_id, \
source, watched_at, status, created_at \
FROM watch_events WHERE id IN ({})",
placeholders.join(",")
);
let mut q = sqlx::query(&sql);
for id in ids {
q = q.bind(id.value().to_string());
}
let rows = q.fetch_all(&self.pool).await.map_err(map_err)?;
rows.iter().map(row_to_watch_event).collect()
}
async fn update_status_batch( async fn update_status_batch(
&self, &self,
ids: &[WatchEventId], ids: &[WatchEventId],
@@ -158,10 +100,86 @@ impl WatchEventRepository for SqliteWatchEventRepository {
for id in ids { for id in ids {
q = q.bind(id.value().to_string()); q = q.bind(id.value().to_string());
} }
let result = q.execute(&self.pool).await.map_err(map_err)?; let result = q
.execute(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
Ok(result.rows_affected()) Ok(result.rows_affected())
} }
async fn delete_non_pending_older_than(
&self,
before: chrono::NaiveDateTime,
) -> Result<u64, DomainError> {
let before_str = datetime_to_str(&before);
let result =
sqlx::query("DELETE FROM watch_events WHERE status != 'pending' AND created_at < ?")
.bind(&before_str)
.execute(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
Ok(result.rows_affected())
}
}
#[async_trait]
impl WatchEventQuery for SqliteWatchEventRepository {
async fn list_pending(&self, user_id: &UserId) -> Result<Vec<WatchEvent>, DomainError> {
let uid = user_id.value().to_string();
let rows = sqlx::query(
"SELECT id, user_id, movie_id, title, year, external_metadata_id, \
source, watched_at, status, created_at \
FROM watch_events \
WHERE user_id = ? AND status = 'pending' \
ORDER BY watched_at DESC",
)
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
rows.iter().map(row_to_watch_event).collect()
}
async fn get_by_id(&self, id: &WatchEventId) -> Result<Option<WatchEvent>, DomainError> {
let id_str = id.value().to_string();
let row = sqlx::query(
"SELECT id, user_id, movie_id, title, year, external_metadata_id, \
source, watched_at, status, created_at \
FROM watch_events WHERE id = ?",
)
.bind(&id_str)
.fetch_optional(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
row.as_ref().map(row_to_watch_event).transpose()
}
async fn get_by_ids(&self, ids: &[WatchEventId]) -> Result<Vec<WatchEvent>, DomainError> {
if ids.is_empty() {
return Ok(vec![]);
}
let placeholders: Vec<&str> = ids.iter().map(|_| "?").collect();
let sql = format!(
"SELECT id, user_id, movie_id, title, year, external_metadata_id, \
source, watched_at, status, created_at \
FROM watch_events WHERE id IN ({})",
placeholders.join(",")
);
let mut q = sqlx::query(&sql);
for id in ids {
q = q.bind(id.value().to_string());
}
let rows = q
.fetch_all(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
rows.iter().map(row_to_watch_event).collect()
}
async fn find_duplicate( async fn find_duplicate(
&self, &self,
user_id: &UserId, user_id: &UserId,
@@ -180,39 +198,43 @@ impl WatchEventRepository for SqliteWatchEventRepository {
.bind(&after_str) .bind(&after_str)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await
.map_err(map_err)? .map_err(adapter_common::map_sqlx_error)?
.try_get(0) .try_get(0)
.unwrap_or(0); .unwrap_or(0);
Ok(count > 0) Ok(count > 0)
} }
async fn delete_non_pending_older_than(
&self,
before: chrono::NaiveDateTime,
) -> Result<u64, DomainError> {
let before_str = datetime_to_str(&before);
let result =
sqlx::query("DELETE FROM watch_events WHERE status != 'pending' AND created_at < ?")
.bind(&before_str)
.execute(&self.pool)
.await
.map_err(map_err)?;
Ok(result.rows_affected())
}
} }
fn row_to_watch_event(row: &sqlx::sqlite::SqliteRow) -> Result<WatchEvent, DomainError> { fn row_to_watch_event(row: &sqlx::sqlite::SqliteRow) -> Result<WatchEvent, DomainError> {
let id_str: &str = row.try_get("id").map_err(map_err)?; let id_str: &str = row.try_get("id").map_err(adapter_common::map_sqlx_error)?;
let user_id_str: &str = row.try_get("user_id").map_err(map_err)?; let user_id_str: &str = row
let movie_id_str: Option<&str> = row.try_get("movie_id").map_err(map_err)?; .try_get("user_id")
let title: String = row.try_get("title").map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
let year: Option<i64> = row.try_get("year").map_err(map_err)?; let movie_id_str: Option<&str> = row
let ext_id: Option<String> = row.try_get("external_metadata_id").map_err(map_err)?; .try_get("movie_id")
let source_str: String = row.try_get("source").map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
let watched_at_str: String = row.try_get("watched_at").map_err(map_err)?; let title: String = row
let status_str: String = row.try_get("status").map_err(map_err)?; .try_get("title")
let created_at_str: String = row.try_get("created_at").map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
let year: Option<i64> = row
.try_get("year")
.map_err(adapter_common::map_sqlx_error)?;
let ext_id: Option<String> = row
.try_get("external_metadata_id")
.map_err(adapter_common::map_sqlx_error)?;
let source_str: String = row
.try_get("source")
.map_err(adapter_common::map_sqlx_error)?;
let watched_at_str: String = row
.try_get("watched_at")
.map_err(adapter_common::map_sqlx_error)?;
let status_str: String = row
.try_get("status")
.map_err(adapter_common::map_sqlx_error)?;
let created_at_str: String = row
.try_get("created_at")
.map_err(adapter_common::map_sqlx_error)?;
let source: WatchEventSource = source_str let source: WatchEventSource = source_str
.parse() .parse()
@@ -275,7 +297,7 @@ impl WebhookTokenRepository for SqliteWebhookTokenRepository {
.bind(&last_used) .bind(&last_used)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(()) Ok(())
} }
@@ -288,7 +310,7 @@ impl WebhookTokenRepository for SqliteWebhookTokenRepository {
.bind(hash) .bind(hash)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
row.as_ref().map(row_to_webhook_token).transpose() row.as_ref().map(row_to_webhook_token).transpose()
} }
@@ -303,7 +325,7 @@ impl WebhookTokenRepository for SqliteWebhookTokenRepository {
.bind(&uid) .bind(&uid)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
rows.iter().map(row_to_webhook_token).collect() rows.iter().map(row_to_webhook_token).collect()
} }
@@ -317,7 +339,7 @@ impl WebhookTokenRepository for SqliteWebhookTokenRepository {
.bind(&uid) .bind(&uid)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
if result.rows_affected() == 0 { if result.rows_affected() == 0 {
return Err(DomainError::NotFound(format!("Webhook token {id_str}"))); return Err(DomainError::NotFound(format!("Webhook token {id_str}")));
@@ -334,20 +356,32 @@ impl WebhookTokenRepository for SqliteWebhookTokenRepository {
.bind(&id_str) .bind(&id_str)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(()) Ok(())
} }
} }
fn row_to_webhook_token(row: &sqlx::sqlite::SqliteRow) -> Result<WebhookToken, DomainError> { fn row_to_webhook_token(row: &sqlx::sqlite::SqliteRow) -> Result<WebhookToken, DomainError> {
let id_str: &str = row.try_get("id").map_err(map_err)?; let id_str: &str = row.try_get("id").map_err(adapter_common::map_sqlx_error)?;
let user_id_str: &str = row.try_get("user_id").map_err(map_err)?; let user_id_str: &str = row
let token_hash: String = row.try_get("token_hash").map_err(map_err)?; .try_get("user_id")
let provider_str: String = row.try_get("provider").map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
let label: Option<String> = row.try_get("label").map_err(map_err)?; let token_hash: String = row
let created_at_str: String = row.try_get("created_at").map_err(map_err)?; .try_get("token_hash")
let last_used_str: Option<String> = row.try_get("last_used_at").map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
let provider_str: String = row
.try_get("provider")
.map_err(adapter_common::map_sqlx_error)?;
let label: Option<String> = row
.try_get("label")
.map_err(adapter_common::map_sqlx_error)?;
let created_at_str: String = row
.try_get("created_at")
.map_err(adapter_common::map_sqlx_error)?;
let last_used_str: Option<String> = row
.try_get("last_used_at")
.map_err(adapter_common::map_sqlx_error)?;
let provider: WatchEventSource = provider_str let provider: WatchEventSource = provider_str
.parse() .parse()

View File

@@ -10,7 +10,8 @@ use domain::{
}; };
use sqlx::{Row, SqlitePool}; use sqlx::{Row, SqlitePool};
use crate::models::{WatchlistRow, datetime_to_str}; use crate::models::WatchlistRow;
use adapter_common::datetime_to_str;
pub struct SqliteWatchlistRepository { pub struct SqliteWatchlistRepository {
pool: SqlitePool, pool: SqlitePool,
@@ -20,11 +21,6 @@ impl SqliteWatchlistRepository {
pub fn new(pool: SqlitePool) -> Self { pub fn new(pool: SqlitePool) -> Self {
Self { pool } Self { pool }
} }
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
} }
#[async_trait] #[async_trait]
@@ -45,7 +41,7 @@ impl WatchlistRepository for SqliteWatchlistRepository {
.bind(&added_at) .bind(&added_at)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(()) Ok(())
} }
@@ -60,7 +56,7 @@ impl WatchlistRepository for SqliteWatchlistRepository {
.bind(&mid) .bind(&mid)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
if result.rows_affected() == 0 { if result.rows_affected() == 0 {
return Err(DomainError::NotFound(format!( return Err(DomainError::NotFound(format!(
@@ -84,7 +80,7 @@ impl WatchlistRepository for SqliteWatchlistRepository {
.bind(&mid) .bind(&mid)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(result.rows_affected() > 0) Ok(result.rows_affected() > 0)
} }
@@ -112,13 +108,13 @@ impl WatchlistRepository for SqliteWatchlistRepository {
.bind(offset) .bind(offset)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(adapter_common::map_sqlx_error)?;
let total: i64 = sqlx::query("SELECT COUNT(*) FROM watchlist_entries WHERE user_id = ?") let total: i64 = sqlx::query("SELECT COUNT(*) FROM watchlist_entries WHERE user_id = ?")
.bind(&uid) .bind(&uid)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await
.map_err(Self::map_err)? .map_err(adapter_common::map_sqlx_error)?
.try_get(0) .try_get(0)
.unwrap_or(0); .unwrap_or(0);
@@ -145,7 +141,7 @@ impl WatchlistRepository for SqliteWatchlistRepository {
.bind(&mid) .bind(&mid)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await
.map_err(Self::map_err)? .map_err(adapter_common::map_sqlx_error)?
.try_get(0) .try_get(0)
.unwrap_or(0); .unwrap_or(0);
Ok(count > 0) Ok(count > 0)

View File

@@ -13,12 +13,7 @@ use domain::{
use sqlx::{Row, SqlitePool}; use sqlx::{Row, SqlitePool};
use uuid::Uuid; use uuid::Uuid;
use crate::models::{parse_datetime, parse_uuid}; use adapter_common::{parse_datetime, parse_uuid};
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
fn status_to_str(s: &WrapUpStatus) -> &'static str { fn status_to_str(s: &WrapUpStatus) -> &'static str {
match s { match s {
@@ -87,7 +82,7 @@ impl WrapUpRepository for SqliteWrapUpRepository {
.bind(&completed) .bind(&completed)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(()) Ok(())
} }
@@ -107,7 +102,7 @@ impl WrapUpRepository for SqliteWrapUpRepository {
.bind(&id_str) .bind(&id_str)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(()) Ok(())
} }
@@ -126,7 +121,7 @@ impl WrapUpRepository for SqliteWrapUpRepository {
.bind(&id_str) .bind(&id_str)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(()) Ok(())
} }
@@ -142,7 +137,7 @@ impl WrapUpRepository for SqliteWrapUpRepository {
.bind(&id_str) .bind(&id_str)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
row.as_ref().map(row_to_record).transpose() row.as_ref().map(row_to_record).transpose()
} }
@@ -158,7 +153,7 @@ impl WrapUpRepository for SqliteWrapUpRepository {
.bind(&uid) .bind(&uid)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
rows.iter().map(row_to_record).collect() rows.iter().map(row_to_record).collect()
} }
@@ -171,7 +166,7 @@ impl WrapUpRepository for SqliteWrapUpRepository {
) )
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
rows.iter().map(row_to_record).collect() rows.iter().map(row_to_record).collect()
} }
@@ -200,7 +195,7 @@ impl WrapUpRepository for SqliteWrapUpRepository {
.bind(&end_str) .bind(&end_str)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
row.as_ref().map(row_to_record).transpose() row.as_ref().map(row_to_record).transpose()
} }
@@ -210,7 +205,7 @@ impl WrapUpRepository for SqliteWrapUpRepository {
.bind(id.value().to_string()) .bind(id.value().to_string())
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(()) Ok(())
} }
@@ -224,21 +219,37 @@ impl WrapUpRepository for SqliteWrapUpRepository {
.bind(&before_str) .bind(&before_str)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
Ok(result.rows_affected()) Ok(result.rows_affected())
} }
} }
fn row_to_record(row: &sqlx::sqlite::SqliteRow) -> Result<WrapUpRecord, DomainError> { fn row_to_record(row: &sqlx::sqlite::SqliteRow) -> Result<WrapUpRecord, DomainError> {
let id_str: String = row.try_get("id").map_err(map_err)?; let id_str: String = row.try_get("id").map_err(adapter_common::map_sqlx_error)?;
let user_id_str: Option<String> = row.try_get("user_id").map_err(map_err)?; let user_id_str: Option<String> = row
let start_date_str: String = row.try_get("start_date").map_err(map_err)?; .try_get("user_id")
let end_date_str: String = row.try_get("end_date").map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
let status_str: String = row.try_get("status").map_err(map_err)?; let start_date_str: String = row
let report_json: Option<String> = row.try_get("report_json").map_err(map_err)?; .try_get("start_date")
let error_message: Option<String> = row.try_get("error_message").map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
let created_at_str: String = row.try_get("created_at").map_err(map_err)?; let end_date_str: String = row
let completed_at_str: Option<String> = row.try_get("completed_at").map_err(map_err)?; .try_get("end_date")
.map_err(adapter_common::map_sqlx_error)?;
let status_str: String = row
.try_get("status")
.map_err(adapter_common::map_sqlx_error)?;
let report_json: Option<String> = row
.try_get("report_json")
.map_err(adapter_common::map_sqlx_error)?;
let error_message: Option<String> = row
.try_get("error_message")
.map_err(adapter_common::map_sqlx_error)?;
let created_at_str: String = row
.try_get("created_at")
.map_err(adapter_common::map_sqlx_error)?;
let completed_at_str: Option<String> = row
.try_get("completed_at")
.map_err(adapter_common::map_sqlx_error)?;
let user_id = user_id_str.as_deref().map(parse_uuid).transpose()?; let user_id = user_id_str.as_deref().map(parse_uuid).transpose()?;
@@ -304,7 +315,10 @@ impl WrapUpStatsQuery for SqliteWrapUpStatsQuery {
q = q.bind(uid); q = q.bind(uid);
} }
let rows = q.fetch_all(&self.pool).await.map_err(map_err)?; let rows = q
.fetch_all(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
if rows.is_empty() { if rows.is_empty() {
return Ok(vec![]); return Ok(vec![]);
@@ -314,7 +328,9 @@ impl WrapUpStatsQuery for SqliteWrapUpStatsQuery {
let mut movie_ids: Vec<String> = Vec::new(); let mut movie_ids: Vec<String> = Vec::new();
let mut seen = std::collections::HashSet::new(); let mut seen = std::collections::HashSet::new();
for row in &rows { for row in &rows {
let mid: String = row.try_get("movie_id").map_err(map_err)?; let mid: String = row
.try_get("movie_id")
.map_err(adapter_common::map_sqlx_error)?;
if seen.insert(mid.clone()) { if seen.insert(mid.clone()) {
movie_ids.push(mid); movie_ids.push(mid);
} }
@@ -330,18 +346,39 @@ impl WrapUpStatsQuery for SqliteWrapUpStatsQuery {
// 3) Build result // 3) Build result
let mut result = Vec::with_capacity(rows.len()); let mut result = Vec::with_capacity(rows.len());
for row in &rows { for row in &rows {
let movie_id_str: String = row.try_get("movie_id").map_err(map_err)?; let movie_id_str: String = row
let title: String = row.try_get("title").map_err(map_err)?; .try_get("movie_id")
let release_year: i64 = row.try_get("release_year").map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
let director: Option<String> = row.try_get("director").map_err(map_err)?; let title: String = row
let poster_path: Option<String> = row.try_get("poster_path").map_err(map_err)?; .try_get("title")
let rating: i64 = row.try_get("rating").map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
let watched_at_str: String = row.try_get("watched_at").map_err(map_err)?; let release_year: i64 = row
let user_id_str: String = row.try_get("user_id").map_err(map_err)?; .try_get("release_year")
let runtime_minutes: Option<i32> = row.try_get("runtime_minutes").map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
let budget_usd: Option<i64> = row.try_get("budget_usd").map_err(map_err)?; let director: Option<String> = row
let original_language: Option<String> = .try_get("director")
row.try_get("original_language").map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
let poster_path: Option<String> = row
.try_get("poster_path")
.map_err(adapter_common::map_sqlx_error)?;
let rating: i64 = row
.try_get("rating")
.map_err(adapter_common::map_sqlx_error)?;
let watched_at_str: String = row
.try_get("watched_at")
.map_err(adapter_common::map_sqlx_error)?;
let user_id_str: String = row
.try_get("user_id")
.map_err(adapter_common::map_sqlx_error)?;
let runtime_minutes: Option<i32> = row
.try_get("runtime_minutes")
.map_err(adapter_common::map_sqlx_error)?;
let budget_usd: Option<i64> = row
.try_get("budget_usd")
.map_err(adapter_common::map_sqlx_error)?;
let original_language: Option<String> = row
.try_get("original_language")
.map_err(adapter_common::map_sqlx_error)?;
let genres = genres_map.get(&movie_id_str).cloned().unwrap_or_default(); let genres = genres_map.get(&movie_id_str).cloned().unwrap_or_default();
let keywords = keywords_map.get(&movie_id_str).cloned().unwrap_or_default(); let keywords = keywords_map.get(&movie_id_str).cloned().unwrap_or_default();
@@ -411,12 +448,19 @@ async fn fetch_genres_sqlite(
for id in movie_ids { for id in movie_ids {
q = q.bind(id); q = q.bind(id);
} }
let rows = q.fetch_all(pool).await.map_err(map_err)?; let rows = q
.fetch_all(pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
let mut map: HashMap<String, Vec<String>> = HashMap::new(); let mut map: HashMap<String, Vec<String>> = HashMap::new();
for row in rows { for row in rows {
let mid: String = row.try_get("movie_id").map_err(map_err)?; let mid: String = row
let name: String = row.try_get("name").map_err(map_err)?; .try_get("movie_id")
.map_err(adapter_common::map_sqlx_error)?;
let name: String = row
.try_get("name")
.map_err(adapter_common::map_sqlx_error)?;
map.entry(mid).or_default().push(name); map.entry(mid).or_default().push(name);
} }
Ok(map) Ok(map)
@@ -437,12 +481,19 @@ async fn fetch_keywords_sqlite(
for id in movie_ids { for id in movie_ids {
q = q.bind(id); q = q.bind(id);
} }
let rows = q.fetch_all(pool).await.map_err(map_err)?; let rows = q
.fetch_all(pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
let mut map: HashMap<String, Vec<String>> = HashMap::new(); let mut map: HashMap<String, Vec<String>> = HashMap::new();
for row in rows { for row in rows {
let mid: String = row.try_get("movie_id").map_err(map_err)?; let mid: String = row
let name: String = row.try_get("name").map_err(map_err)?; .try_get("movie_id")
.map_err(adapter_common::map_sqlx_error)?;
let name: String = row
.try_get("name")
.map_err(adapter_common::map_sqlx_error)?;
map.entry(mid).or_default().push(name); map.entry(mid).or_default().push(name);
} }
Ok(map) Ok(map)
@@ -466,15 +517,28 @@ async fn fetch_cast_sqlite(
for id in movie_ids { for id in movie_ids {
q = q.bind(id); q = q.bind(id);
} }
let rows = q.fetch_all(pool).await.map_err(map_err)?; let rows = q
.fetch_all(pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
let mut map: HashMap<String, Vec<CastEntry>> = HashMap::new(); let mut map: HashMap<String, Vec<CastEntry>> = HashMap::new();
for row in rows { for row in rows {
let mid: String = row.try_get("movie_id").map_err(map_err)?; let mid: String = row
let name: String = row.try_get("name").map_err(map_err)?; .try_get("movie_id")
let billing_order: i32 = row.try_get("billing_order").map_err(map_err)?; .map_err(adapter_common::map_sqlx_error)?;
let tmdb_person_id: i64 = row.try_get("tmdb_person_id").map_err(map_err)?; let name: String = row
let profile_path: Option<String> = row.try_get("profile_path").map_err(map_err)?; .try_get("name")
.map_err(adapter_common::map_sqlx_error)?;
let billing_order: i32 = row
.try_get("billing_order")
.map_err(adapter_common::map_sqlx_error)?;
let tmdb_person_id: i64 = row
.try_get("tmdb_person_id")
.map_err(adapter_common::map_sqlx_error)?;
let profile_path: Option<String> = row
.try_get("profile_path")
.map_err(adapter_common::map_sqlx_error)?;
map.entry(mid).or_default().push(CastEntry { map.entry(mid).or_default().push(CastEntry {
name, name,
billing_order: billing_order as u32, billing_order: billing_order as u32,

View File

@@ -10,4 +10,4 @@ chrono = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
domain = { workspace = true } domain = { workspace = true }
application = { workspace = true } api-types = { workspace = true }

View File

@@ -1,7 +1,7 @@
pub use askama; pub use askama;
use askama::Template; use askama::Template;
use application::rendering::HtmlPageContext; use api_types::HtmlPageContext;
use chrono::Datelike; use chrono::Datelike;
use domain::models::{ use domain::models::{
DiaryEntry, FeedEntry, MonthActivity, MonthlyRating, ReviewSource, UserStats, UserTrends, DiaryEntry, FeedEntry, MonthActivity, MonthlyRating, ReviewSource, UserStats, UserTrends,

View File

@@ -7,3 +7,4 @@ edition = "2024"
serde = { workspace = true } serde = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
utoipa = { version = "5.5.0", features = ["axum_extras", "uuid"] } utoipa = { version = "5.5.0", features = ["axum_extras", "uuid"] }
domain = { path = "../domain" }

View File

@@ -14,7 +14,8 @@ pub struct LoginResponse {
pub user_id: Uuid, pub user_id: Uuid,
pub email: String, pub email: String,
pub expires_at: String, pub expires_at: String,
pub role: String, #[schema(value_type = String)]
pub role: domain::models::UserRole,
} }
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]

View File

@@ -1,7 +1,23 @@
use serde::Deserialize; use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, Default)] #[derive(Debug, Clone, Deserialize, Default)]
pub struct PaginationQueryParams { pub struct PaginationQueryParams {
pub limit: Option<u32>, pub limit: Option<u32>,
pub offset: Option<u32>, pub offset: Option<u32>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct PaginatedResponse<T: std::fmt::Debug + Clone> {
pub items: Vec<T>,
pub total_count: u64,
pub limit: u32,
pub offset: u32,
}
pub type MoviesResponse = PaginatedResponse<crate::movies::MovieDto>;
pub type SocialFeedResponse = PaginatedResponse<crate::movies::SocialReviewDto>;
pub type DiaryResponse = PaginatedResponse<crate::diary::DiaryEntryDto>;
pub type ActivityFeedResponse = PaginatedResponse<crate::diary::FeedEntryDto>;
pub type WatchlistResponse = PaginatedResponse<crate::watchlist::WatchlistEntryDto>;
pub type PaginatedMovieHits = PaginatedResponse<crate::search::MovieSearchHitDto>;
pub type PaginatedPersonHits = PaginatedResponse<crate::search::PersonSearchHitDto>;

View File

@@ -18,7 +18,8 @@ pub struct LogReviewRequest {
pub comment: Option<String>, pub comment: Option<String>,
pub watched_at: String, pub watched_at: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub watch_medium: Option<String>, #[schema(value_type = Option<String>)]
pub watch_medium: Option<domain::value_objects::WatchMedium>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
@@ -27,14 +28,6 @@ pub struct DiaryEntryDto {
pub review: ReviewDto, pub review: ReviewDto,
} }
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct DiaryResponse {
pub items: Vec<DiaryEntryDto>,
pub total_count: u64,
pub limit: u32,
pub offset: u32,
}
#[derive(Debug, Clone, Deserialize, utoipa::IntoParams)] #[derive(Debug, Clone, Deserialize, utoipa::IntoParams)]
#[into_params(parameter_in = Query)] #[into_params(parameter_in = Query)]
pub struct DiaryQueryParams { pub struct DiaryQueryParams {
@@ -64,14 +57,6 @@ pub struct FeedEntryDto {
pub actor_url: Option<String>, pub actor_url: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ActivityFeedResponse {
pub items: Vec<FeedEntryDto>,
pub total_count: u64,
pub limit: u32,
pub offset: u32,
}
#[derive(Debug, Clone, Deserialize, utoipa::IntoParams)] #[derive(Debug, Clone, Deserialize, utoipa::IntoParams)]
#[into_params(parameter_in = Query)] #[into_params(parameter_in = Query)]
pub struct ExportQueryParams { pub struct ExportQueryParams {
@@ -89,7 +74,8 @@ pub struct EditReviewRequest {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub watched_at: Option<String>, pub watched_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub watch_medium: Option<Option<String>>, #[schema(value_type = Option<Option<String>>)]
pub watch_medium: Option<Option<domain::value_objects::WatchMedium>>,
} }
fn default_export_format() -> String { fn default_export_format() -> String {

View File

@@ -7,7 +7,8 @@ pub struct GoalDto {
pub current_count: u32, pub current_count: u32,
pub percentage: f64, pub percentage: f64,
pub is_complete: bool, pub is_complete: bool,
pub goal_type: String, #[schema(value_type = String)]
pub goal_type: domain::models::GoalType,
} }
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
@@ -25,17 +26,3 @@ pub struct CreateGoalRequest {
pub struct UpdateGoalRequest { pub struct UpdateGoalRequest {
pub target_count: u32, pub target_count: u32,
} }
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct UserSettingsDto {
pub federate_goals: bool,
pub federate_reviews: bool,
pub federate_watchlist: bool,
}
#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)]
pub struct UpdateUserSettingsRequest {
pub federate_goals: bool,
pub federate_reviews: bool,
pub federate_watchlist: bool,
}

View File

@@ -46,29 +46,24 @@ pub struct SaveProfileRequest {
pub name: String, pub name: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct PreviewRowData {
pub index: usize,
pub title: Option<String>,
pub release_year: Option<String>,
pub director: Option<String>,
pub rating: Option<String>,
pub watched_at: Option<String>,
pub comment: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
#[serde(tag = "status")] #[serde(tag = "status")]
pub enum PreviewRowDto { pub enum PreviewRowDto {
#[serde(rename = "valid")] #[serde(rename = "valid")]
Valid { Valid(PreviewRowData),
index: usize,
title: Option<String>,
release_year: Option<String>,
director: Option<String>,
rating: Option<String>,
watched_at: Option<String>,
comment: Option<String>,
},
#[serde(rename = "duplicate")] #[serde(rename = "duplicate")]
Duplicate { Duplicate(PreviewRowData),
index: usize,
title: Option<String>,
release_year: Option<String>,
director: Option<String>,
rating: Option<String>,
watched_at: Option<String>,
comment: Option<String>,
},
#[serde(rename = "invalid")] #[serde(rename = "invalid")]
Invalid { index: usize, errors: Vec<String> }, Invalid { index: usize, errors: Vec<String> },
} }

View File

@@ -4,6 +4,7 @@ pub mod diary;
pub mod goals; pub mod goals;
pub mod import; pub mod import;
pub mod movies; pub mod movies;
pub mod rendering;
pub mod search; pub mod search;
pub mod social; pub mod social;
pub mod users; pub mod users;
@@ -17,6 +18,7 @@ pub use diary::*;
pub use goals::*; pub use goals::*;
pub use import::*; pub use import::*;
pub use movies::*; pub use movies::*;
pub use rendering::*;
pub use social::*; pub use social::*;
pub use users::*; pub use users::*;
pub use watchlist::*; pub use watchlist::*;

View File

@@ -1,6 +1,8 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
use crate::common::SocialFeedResponse;
// ── Movie list ──────────────────────────────────────────────────────────────── // ── Movie list ────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Deserialize, utoipa::IntoParams)] #[derive(Debug, Clone, Deserialize, utoipa::IntoParams)]
@@ -16,14 +18,6 @@ pub struct MoviesQueryParams {
pub language: Option<String>, pub language: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct MoviesResponse {
pub items: Vec<MovieDto>,
pub total_count: u64,
pub limit: u32,
pub offset: u32,
}
// ── Movie profile (enrichment) ──────────────────────────────────────────────── // ── Movie profile (enrichment) ────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
@@ -99,7 +93,8 @@ pub struct ReviewDto {
pub comment: Option<String>, pub comment: Option<String>,
pub watched_at: String, pub watched_at: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub watch_medium: Option<String>, #[schema(value_type = Option<String>)]
pub watch_medium: Option<domain::value_objects::WatchMedium>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
@@ -125,15 +120,8 @@ pub struct SocialReviewDto {
pub watched_at: String, pub watched_at: String,
pub is_federated: bool, pub is_federated: bool,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub watch_medium: Option<String>, #[schema(value_type = Option<String>)]
} pub watch_medium: Option<domain::value_objects::WatchMedium>,
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct SocialFeedResponse {
pub items: Vec<SocialReviewDto>,
pub total_count: u64,
pub limit: u32,
pub offset: u32,
} }
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]

View File

@@ -2,6 +2,8 @@ use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema}; use utoipa::{IntoParams, ToSchema};
use uuid::Uuid; use uuid::Uuid;
pub use crate::common::{PaginatedMovieHits, PaginatedPersonHits};
#[derive(Debug, Deserialize, IntoParams)] #[derive(Debug, Deserialize, IntoParams)]
pub struct SearchQueryParams { pub struct SearchQueryParams {
/// Free-text query matched across title, cast, crew, genres and keywords. /// Free-text query matched across title, cast, crew, genres and keywords.
@@ -28,23 +30,7 @@ pub struct SearchResponse {
pub people: PaginatedPersonHits, pub people: PaginatedPersonHits,
} }
#[derive(Debug, Serialize, ToSchema)] #[derive(Debug, Clone, Serialize, ToSchema)]
pub struct PaginatedMovieHits {
pub items: Vec<MovieSearchHitDto>,
pub total_count: u64,
pub limit: u32,
pub offset: u32,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct PaginatedPersonHits {
pub items: Vec<PersonSearchHitDto>,
pub total_count: u64,
pub limit: u32,
pub offset: u32,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct MovieSearchHitDto { pub struct MovieSearchHitDto {
pub movie_id: Uuid, pub movie_id: Uuid,
pub title: String, pub title: String,
@@ -54,7 +40,7 @@ pub struct MovieSearchHitDto {
pub genres: Vec<String>, pub genres: Vec<String>,
} }
#[derive(Debug, Serialize, ToSchema)] #[derive(Debug, Clone, Serialize, ToSchema)]
pub struct PersonSearchHitDto { pub struct PersonSearchHitDto {
pub person_id: Uuid, pub person_id: Uuid,
pub name: String, pub name: String,

View File

@@ -1,7 +1,8 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
use crate::diary::{DiaryEntryDto, DiaryResponse}; use crate::common::DiaryResponse;
use crate::diary::DiaryEntryDto;
use crate::goals::GoalDto; use crate::goals::GoalDto;
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
@@ -67,22 +68,36 @@ pub struct UserTrendsDto {
} }
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct UserProfileResponse { pub struct UserProfileBase {
pub user_id: Uuid,
pub username: String, pub username: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bio: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub avatar_url: Option<String>, pub avatar_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub banner_url: Option<String>, pub banner_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
#[serde(untagged)]
pub enum ProfileViewData {
Entries { entries: DiaryResponse },
History { history: Vec<MonthActivityDto> },
Trends { trends: UserTrendsDto },
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct UserProfileResponse {
pub user_id: Uuid,
#[serde(flatten)]
pub profile: UserProfileBase,
pub stats: UserStatsDto, pub stats: UserStatsDto,
pub following_count: usize, pub following_count: usize,
pub followers_count: usize, pub followers_count: usize,
/// Populated for view=recent and view=ratings #[serde(flatten, skip_serializing_if = "Option::is_none")]
pub entries: Option<DiaryResponse>, pub view_data: Option<ProfileViewData>,
/// Populated for view=history
pub history: Option<Vec<MonthActivityDto>>,
/// Populated for view=trends
pub trends: Option<UserTrendsDto>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub goals: Option<Vec<GoalDto>>, pub goals: Option<Vec<GoalDto>>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")] #[serde(default, skip_serializing_if = "std::ops::Not::not")]
@@ -90,23 +105,17 @@ pub struct UserProfileResponse {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub handle: Option<String>, pub handle: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bio: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub actor_url: Option<String>, pub actor_url: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ProfileResponse { pub struct ProfileResponse {
pub username: String, #[serde(flatten)]
pub display_name: Option<String>, pub profile: UserProfileBase,
pub bio: Option<String>,
pub avatar_url: Option<String>,
pub banner_url: Option<String>,
pub also_known_as: Option<String>, pub also_known_as: Option<String>,
pub fields: Vec<ProfileFieldDto>, pub fields: Vec<ProfileFieldDto>,
pub role: String, #[schema(value_type = String)]
pub role: domain::models::UserRole,
} }
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
@@ -119,3 +128,17 @@ pub struct ProfileFieldDto {
pub struct UpdateProfileFieldsRequest { pub struct UpdateProfileFieldsRequest {
pub fields: Vec<ProfileFieldDto>, pub fields: Vec<ProfileFieldDto>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct UserSettingsDto {
pub federate_goals: bool,
pub federate_reviews: bool,
pub federate_watchlist: bool,
}
#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)]
pub struct UpdateUserSettingsRequest {
pub federate_goals: bool,
pub federate_reviews: bool,
pub federate_watchlist: bool,
}

View File

@@ -10,14 +10,6 @@ pub struct WatchlistEntryDto {
pub added_at: String, pub added_at: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct WatchlistResponse {
pub items: Vec<WatchlistEntryDto>,
pub total_count: u64,
pub limit: u32,
pub offset: u32,
}
#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] #[derive(Debug, Clone, Deserialize, utoipa::ToSchema)]
pub struct AddToWatchlistRequest { pub struct AddToWatchlistRequest {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]

View File

@@ -6,7 +6,7 @@ edition = "2024"
[dependencies] [dependencies]
async-trait = { workspace = true } async-trait = { workspace = true }
domain = { workspace = true } domain = { workspace = true }
reqwest = { workspace = true } infra-wiring = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }

View File

@@ -3,7 +3,7 @@ use uuid::Uuid;
use domain::{errors::DomainError, models::RefreshSession, value_objects::Email}; use domain::{errors::DomainError, models::RefreshSession, value_objects::Email};
use crate::auth::{deps::LoginDeps, queries::LoginQuery}; use crate::auth::{deps::LoginDeps, queries::LoginCommand};
pub struct LoginResult { pub struct LoginResult {
pub token: String, pub token: String,
@@ -11,10 +11,10 @@ pub struct LoginResult {
pub user_id: Uuid, pub user_id: Uuid,
pub email: String, pub email: String,
pub expires_at: DateTime<Utc>, pub expires_at: DateTime<Utc>,
pub role: String, pub role: domain::models::UserRole,
} }
pub async fn execute(deps: &LoginDeps, query: LoginQuery) -> Result<LoginResult, DomainError> { pub async fn execute(deps: &LoginDeps, query: LoginCommand) -> Result<LoginResult, DomainError> {
let email = Email::new(query.email)?; let email = Email::new(query.email)?;
let user = deps let user = deps
.user .user
@@ -49,7 +49,7 @@ pub async fn execute(deps: &LoginDeps, query: LoginQuery) -> Result<LoginResult,
user_id: user.id().value(), user_id: user.id().value(),
email: user.email().value().to_string(), email: user.email().value().to_string(),
expires_at: generated.expires_at, expires_at: generated.expires_at,
role: user.role().as_str().into(), role: user.role().clone(),
}) })
} }

View File

@@ -1,4 +1,4 @@
pub struct LoginQuery { pub struct LoginCommand {
pub email: String, pub email: String,
pub password: String, pub password: String,
} }

View File

@@ -4,7 +4,7 @@ use crate::auth::{
commands::{RegisterAndLoginCommand, RegisterCommand}, commands::{RegisterAndLoginCommand, RegisterCommand},
deps::{LoginDeps, RegisterAndLoginDeps, RegisterDeps}, deps::{LoginDeps, RegisterAndLoginDeps, RegisterDeps},
login::{self, LoginResult}, login::{self, LoginResult},
queries::LoginQuery, queries::LoginCommand,
register, register,
}; };
@@ -37,7 +37,7 @@ pub async fn execute(
}; };
login::execute( login::execute(
&log_deps, &log_deps,
LoginQuery { LoginCommand {
email: cmd.email, email: cmd.email,
password: cmd.password, password: cmd.password,
}, },

Some files were not shown because too many files have changed in this diff Show More