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, }, }; /// THE outbox write path: inserts one `event_queue` row per event, each with a /// fresh `EventId`, inside the caller's transaction so a change and the events /// announcing it commit or roll back together. pub async fn enqueue_events( tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, events: &[domain::events::DomainEvent], ) -> Result<(), DomainError> { for event in events { let payload = event_payload::EventPayload::from(event); let payload_json = serde_json::to_string(&payload) .map_err(|e| DomainError::InfrastructureError(format!("serialize: {e}")))?; sqlx::query("INSERT INTO event_queue (event_type, payload, event_id) VALUES (?, ?, ?)") .bind(payload.event_type()) .bind(payload_json) .bind(domain::events::EventId::new().value().to_string()) .execute(&mut **tx) .await .map_err(|e| DomainError::InfrastructureError(format!("insert event: {e}")))?; } Ok(()) } pub(crate) fn map_sqlx_error(e: sqlx::Error) -> DomainError { tracing::error!("Database error: {:?}", e); DomainError::InfrastructureError("Database operation failed".into()) } pub(crate) fn parse_uuid(s: &str) -> Result { uuid::Uuid::parse_str(s) .map_err(|e| DomainError::InfrastructureError(format!("Invalid UUID '{}': {}", s, e))) } pub(crate) fn parse_datetime(s: &str) -> Result { chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") .map_err(|e| DomainError::InfrastructureError(format!("Invalid datetime '{}': {}", s, e))) } pub fn datetime_to_str(dt: &chrono::NaiveDateTime) -> String { dt.format("%Y-%m-%d %H:%M:%S").to_string() } 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(crate) fn movie_row_to_domain( id: String, external_metadata_id: Option, title: String, release_year: i64, director: Option, poster_path: Option, ) -> Result { 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, )) } #[allow(clippy::too_many_arguments)] pub(crate) fn review_row_to_domain( id: String, movie_id: String, user_id: String, rating: i64, comment: Option, watched_at: String, created_at: String, remote_actor_url: Option, watch_medium: Option, spoilers: bool, ) -> Result { 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, spoilers, })) } pub(crate) fn movie_summary_to_domain( movie: Movie, genres: Vec, runtime_minutes: Option, original_language: Option, overview: Option, collection_name: Option, ) -> MovieSummary { MovieSummary { movie, genres, runtime_minutes: runtime_minutes.map(|v| v as u32), original_language, overview, collection_name, } } pub(crate) fn movie_stats_to_domain( total_count: i64, avg_rating: Option, 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, ], } } #[allow(clippy::too_many_arguments)] pub(crate) fn user_summary_to_domain( id: String, email: String, username: String, display_name: Option, total_movies: i64, avg_rating: Option, avatar_path: Option, ) -> Result { Ok(UserSummary::new( UserId::from_uuid(parse_uuid(&id)?), Email::new(email)?, Username::new(username)?, display_name, total_movies, avg_rating, avatar_path, )) } pub(crate) fn watchlist_entry_to_domain( id: String, user_id: String, movie_id: String, added_at: String, ) -> Result { 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)?, }) } pub(crate) fn watchlist_with_movie_to_domain( entry: WatchlistEntry, movie: Movie, ) -> WatchlistWithMovie { WatchlistWithMovie { entry, movie } }