From ab4e4c31382e4b12ec13c02d7f1b7546d8073293 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 20:55:33 +0100 Subject: [PATCH] refactor: sqlite repositories per aggregate --- crates/adapters/sqlite/src/lib.rs | 63 +-- crates/adapters/sqlite/src/models.rs | 325 -------------- crates/adapters/sqlite/src/persons.rs | 348 --------------- .../sqlite/src/repositories/ap_content/mod.rs | 110 +++++ .../ap_content/rows.rs} | 120 +----- .../sqlite/src/repositories/diary/mod.rs | 174 ++++++++ .../{diary.rs => repositories/diary/query.rs} | 176 +------- .../sqlite/src/repositories/diary/rows.rs | 128 ++++++ .../diary.rs => repositories/diary/tests.rs} | 0 .../{goals.rs => repositories/goals/mod.rs} | 0 .../image_ref/mod.rs} | 1 - .../image_ref/tests.rs} | 0 .../import_profile/mod.rs} | 83 +--- .../src/repositories/import_profile/rows.rs | 84 ++++ .../import_session/mod.rs} | 186 +------- .../src/repositories/import_session/rows.rs | 187 ++++++++ .../insights/mod.rs} | 0 .../adapters/sqlite/src/repositories/mod.rs | 47 +++ .../sqlite/src/repositories/movie/command.rs | 48 +++ .../sqlite/src/repositories/movie/mod.rs | 15 + .../{movie.rs => repositories/movie/query.rs} | 60 +-- .../sqlite/src/repositories/movie/rows.rs | 68 +++ .../movie_dedup/mod.rs} | 0 .../src/repositories/persons/command.rs | 117 +++++ .../sqlite/src/repositories/persons/mod.rs | 28 ++ .../sqlite/src/repositories/persons/query.rs | 142 +++++++ .../sqlite/src/repositories/persons/rows.rs | 72 ++++ .../persons/tests.rs} | 2 +- .../profile/mod.rs} | 1 - .../profile/tests.rs} | 2 +- .../profile_fields/mod.rs} | 0 .../refresh_sessions/mod.rs} | 0 .../remote_goals/mod.rs} | 0 .../{review.rs => repositories/review/mod.rs} | 4 +- .../sqlite/src/repositories/review/rows.rs | 33 ++ .../sqlite/src/repositories/search/command.rs | 133 ++++++ .../sqlite/src/repositories/search/mod.rs | 27 ++ .../search/query.rs} | 156 +------ .../src/{ => repositories}/search/tests.rs | 0 .../social/federated_profile.rs | 0 .../src/repositories/social/follow/command.rs | 130 ++++++ .../src/repositories/social/follow/mod.rs | 9 + .../social/follow/query.rs} | 192 +-------- .../src/repositories/social/follow/rows.rs | 61 +++ .../{ => repositories}/social/local_social.rs | 0 .../src/{ => repositories}/social/mod.rs | 11 +- .../social/tests.rs} | 0 .../{ => repositories}/social/watchlist.rs | 0 .../{stats.rs => repositories/stats/mod.rs} | 6 +- .../sqlite/src/repositories/stats/rows.rs | 45 ++ .../user_settings/mod.rs} | 0 .../{users.rs => repositories/users/mod.rs} | 5 +- .../sqlite/src/repositories/users/rows.rs | 27 ++ .../users.rs => repositories/users/tests.rs} | 0 .../src/repositories/watch_event/command.rs | 101 +++++ .../src/repositories/watch_event/mod.rs | 18 + .../src/repositories/watch_event/query.rs | 97 +++++ .../src/repositories/watch_event/rows.rs | 111 +++++ .../repositories/watch_event/webhook_token.rs | 110 +++++ .../watchlist/mod.rs} | 4 +- .../sqlite/src/repositories/watchlist/rows.rs | 33 ++ .../sqlite/src/repositories/wrapup/mod.rs | 200 +++++++++ .../wrapup/query.rs} | 265 +----------- .../sqlite/src/repositories/wrapup/rows.rs | 82 ++++ crates/adapters/sqlite/src/watch_event.rs | 399 ------------------ 65 files changed, 2500 insertions(+), 2346 deletions(-) delete mode 100644 crates/adapters/sqlite/src/models.rs delete mode 100644 crates/adapters/sqlite/src/persons.rs create mode 100644 crates/adapters/sqlite/src/repositories/ap_content/mod.rs rename crates/adapters/sqlite/src/{social/ap_content.rs => repositories/ap_content/rows.rs} (52%) create mode 100644 crates/adapters/sqlite/src/repositories/diary/mod.rs rename crates/adapters/sqlite/src/{diary.rs => repositories/diary/query.rs} (67%) create mode 100644 crates/adapters/sqlite/src/repositories/diary/rows.rs rename crates/adapters/sqlite/src/{tests/diary.rs => repositories/diary/tests.rs} (100%) rename crates/adapters/sqlite/src/{goals.rs => repositories/goals/mod.rs} (100%) rename crates/adapters/sqlite/src/{image_ref.rs => repositories/image_ref/mod.rs} (98%) rename crates/adapters/sqlite/src/{tests/image_ref.rs => repositories/image_ref/tests.rs} (100%) rename crates/adapters/sqlite/src/{import_profile.rs => repositories/import_profile/mod.rs} (62%) create mode 100644 crates/adapters/sqlite/src/repositories/import_profile/rows.rs rename crates/adapters/sqlite/src/{import_session.rs => repositories/import_session/mod.rs} (55%) create mode 100644 crates/adapters/sqlite/src/repositories/import_session/rows.rs rename crates/adapters/sqlite/src/{insights.rs => repositories/insights/mod.rs} (100%) create mode 100644 crates/adapters/sqlite/src/repositories/mod.rs create mode 100644 crates/adapters/sqlite/src/repositories/movie/command.rs create mode 100644 crates/adapters/sqlite/src/repositories/movie/mod.rs rename crates/adapters/sqlite/src/{movie.rs => repositories/movie/query.rs} (78%) create mode 100644 crates/adapters/sqlite/src/repositories/movie/rows.rs rename crates/adapters/sqlite/src/{movie_dedup.rs => repositories/movie_dedup/mod.rs} (100%) create mode 100644 crates/adapters/sqlite/src/repositories/persons/command.rs create mode 100644 crates/adapters/sqlite/src/repositories/persons/mod.rs create mode 100644 crates/adapters/sqlite/src/repositories/persons/query.rs create mode 100644 crates/adapters/sqlite/src/repositories/persons/rows.rs rename crates/adapters/sqlite/src/{tests/persons.rs => repositories/persons/tests.rs} (98%) rename crates/adapters/sqlite/src/{profile.rs => repositories/profile/mod.rs} (99%) rename crates/adapters/sqlite/src/{tests/profile.rs => repositories/profile/tests.rs} (98%) rename crates/adapters/sqlite/src/{profile_fields.rs => repositories/profile_fields/mod.rs} (100%) rename crates/adapters/sqlite/src/{refresh_sessions.rs => repositories/refresh_sessions/mod.rs} (100%) rename crates/adapters/sqlite/src/{social/remote_goals.rs => repositories/remote_goals/mod.rs} (100%) rename crates/adapters/sqlite/src/{review.rs => repositories/review/mod.rs} (98%) create mode 100644 crates/adapters/sqlite/src/repositories/review/rows.rs create mode 100644 crates/adapters/sqlite/src/repositories/search/command.rs create mode 100644 crates/adapters/sqlite/src/repositories/search/mod.rs rename crates/adapters/sqlite/src/{search.rs => repositories/search/query.rs} (60%) rename crates/adapters/sqlite/src/{ => repositories}/search/tests.rs (100%) rename crates/adapters/sqlite/src/{ => repositories}/social/federated_profile.rs (100%) create mode 100644 crates/adapters/sqlite/src/repositories/social/follow/command.rs create mode 100644 crates/adapters/sqlite/src/repositories/social/follow/mod.rs rename crates/adapters/sqlite/src/{social/follow_repository.rs => repositories/social/follow/query.rs} (51%) create mode 100644 crates/adapters/sqlite/src/repositories/social/follow/rows.rs rename crates/adapters/sqlite/src/{ => repositories}/social/local_social.rs (100%) rename crates/adapters/sqlite/src/{ => repositories}/social/mod.rs (73%) rename crates/adapters/sqlite/src/{social/tests/follow_relation_tests.rs => repositories/social/tests.rs} (100%) rename crates/adapters/sqlite/src/{ => repositories}/social/watchlist.rs (100%) rename crates/adapters/sqlite/src/{stats.rs => repositories/stats/mod.rs} (98%) create mode 100644 crates/adapters/sqlite/src/repositories/stats/rows.rs rename crates/adapters/sqlite/src/{user_settings.rs => repositories/user_settings/mod.rs} (100%) rename crates/adapters/sqlite/src/{users.rs => repositories/users/mod.rs} (99%) create mode 100644 crates/adapters/sqlite/src/repositories/users/rows.rs rename crates/adapters/sqlite/src/{tests/users.rs => repositories/users/tests.rs} (100%) create mode 100644 crates/adapters/sqlite/src/repositories/watch_event/command.rs create mode 100644 crates/adapters/sqlite/src/repositories/watch_event/mod.rs create mode 100644 crates/adapters/sqlite/src/repositories/watch_event/query.rs create mode 100644 crates/adapters/sqlite/src/repositories/watch_event/rows.rs create mode 100644 crates/adapters/sqlite/src/repositories/watch_event/webhook_token.rs rename crates/adapters/sqlite/src/{watchlist.rs => repositories/watchlist/mod.rs} (99%) create mode 100644 crates/adapters/sqlite/src/repositories/watchlist/rows.rs create mode 100644 crates/adapters/sqlite/src/repositories/wrapup/mod.rs rename crates/adapters/sqlite/src/{wrapup.rs => repositories/wrapup/query.rs} (51%) create mode 100644 crates/adapters/sqlite/src/repositories/wrapup/rows.rs delete mode 100644 crates/adapters/sqlite/src/watch_event.rs diff --git a/crates/adapters/sqlite/src/lib.rs b/crates/adapters/sqlite/src/lib.rs index 87589544..f25126a2 100644 --- a/crates/adapters/sqlite/src/lib.rs +++ b/crates/adapters/sqlite/src/lib.rs @@ -1,54 +1,23 @@ use sqlx::SqlitePool; -mod diary; mod event_queue; -mod goals; -mod image_ref; -mod import_profile; -mod import_session; -mod insights; mod migrations; -mod models; -mod movie; -mod movie_dedup; -mod persons; -mod profile; -mod profile_fields; -mod refresh_sessions; -mod review; -mod search; +mod repositories; mod shared; -mod social; -mod stats; -mod user_settings; -mod users; -mod watch_event; -mod watchlist; -mod wrapup; -pub use diary::SqliteDiaryRepository; pub use event_queue::{DbEventQueueConfig, SqliteEventQueue}; -pub use image_ref::{SqliteImageRefAdapter, create_image_ref}; -pub use import_profile::SqliteImportProfileRepository; -pub use import_session::SqliteImportSessionRepository; -pub use insights::SqliteInsightsQuery; -pub use movie::SqliteMovieRepository; -pub use movie_dedup::SqliteMovieDeduplicator; -pub use persons::{SqlitePersonAdapter, create_person_adapter}; -pub use profile::SqliteMovieProfileRepository; -pub use profile_fields::SqliteProfileFieldsRepository; -pub use refresh_sessions::SqliteRefreshSessionAdapter; -pub use review::SqliteReviewRepository; -pub use search::create_search_adapter; -pub use social::{ - SqliteApContentQuery, SqliteRemoteGoalRepository, SqliteSocialRepository, - create_federated_profile_query, +pub use repositories::{ + SqliteApContentQuery, SqliteDiaryRepository, SqliteImageRefAdapter, + SqliteImportProfileRepository, SqliteImportSessionRepository, SqliteInsightsQuery, + SqliteMovieDeduplicator, SqliteMovieProfileRepository, SqliteMovieRepository, + SqlitePersonAdapter, SqliteProfileFieldsRepository, SqliteRefreshSessionAdapter, + SqliteRemoteGoalRepository, SqliteReviewRepository, SqliteSocialRepository, + SqliteStatsRepository, SqliteUserRepository, SqliteWatchEventRepository, + SqliteWatchlistRepository, SqliteWebhookTokenRepository, SqliteWrapUpRepository, + SqliteWrapUpStatsQuery, create_federated_profile_query, create_image_ref, + create_person_adapter, create_search_adapter, }; -pub use stats::SqliteStatsRepository; -pub use users::SqliteUserRepository; -pub use watch_event::{SqliteWatchEventRepository, SqliteWebhookTokenRepository}; -pub use watchlist::SqliteWatchlistRepository; -pub use wrapup::{SqliteWrapUpRepository, SqliteWrapUpStatsQuery}; +use repositories::{SqliteGoalRepository, SqliteUserSettingsRepository}; /// Row helpers shared with the `sqlite-federation` crate (which cannot live /// here because it depends on `activitypub`/`k-ap`). @@ -111,9 +80,7 @@ pub async fn wire(database_url: &str) -> anyhow::Result { .map_err(|e| anyhow::anyhow!("{e}")) .context("Database migration failed")?; - let user_settings_repo = std::sync::Arc::new(user_settings::SqliteUserSettingsRepository::new( - pool.clone(), - )); + let user_settings_repo = std::sync::Arc::new(SqliteUserSettingsRepository::new(pool.clone())); let movie_repo = std::sync::Arc::new(SqliteMovieRepository::new(pool.clone())); @@ -133,8 +100,8 @@ pub async fn wire(database_url: &str) -> anyhow::Result { wrapup_repo: std::sync::Arc::new(SqliteWrapUpRepository::new(pool.clone())) as _, wrapup_stats: std::sync::Arc::new(SqliteWrapUpStatsQuery::new(pool.clone())) as _, insights: std::sync::Arc::new(SqliteInsightsQuery::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 _, + goal_command: std::sync::Arc::new(SqliteGoalRepository::new(pool.clone())) as _, + goal_query: std::sync::Arc::new(SqliteGoalRepository::new(pool.clone())) as _, user_settings: std::sync::Arc::clone(&user_settings_repo) as _, federation_settings: user_settings_repo as _, remote_goal: std::sync::Arc::new(SqliteRemoteGoalRepository::new(pool.clone())) as _, diff --git a/crates/adapters/sqlite/src/models.rs b/crates/adapters/sqlite/src/models.rs deleted file mode 100644 index 250a26da..00000000 --- a/crates/adapters/sqlite/src/models.rs +++ /dev/null @@ -1,325 +0,0 @@ -use crate::shared::{ - 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::{ - errors::DomainError, - models::{DiaryEntry, FeedEntry, Movie, MovieSummary, Review, WatchlistWithMovie}, -}; - -#[derive(sqlx::FromRow)] -pub(crate) struct MovieRow { - pub id: String, - pub external_metadata_id: Option, - pub title: String, - pub release_year: i64, - pub director: Option, - pub poster_path: Option, -} - -impl MovieRow { - pub fn into_domain(self) -> Result { - movie_row_to_domain( - self.id, - self.external_metadata_id, - self.title, - self.release_year, - self.director, - self.poster_path, - ) - } -} - -#[derive(sqlx::FromRow)] -pub(crate) struct MovieSummaryRow { - pub id: String, - pub external_metadata_id: Option, - pub title: String, - pub release_year: i64, - pub director: Option, - pub poster_path: Option, - pub genres: Option, - pub runtime_minutes: Option, - pub original_language: Option, - pub overview: Option, - pub collection_name: Option, -} - -impl MovieSummaryRow { - pub fn into_domain(self) -> Result { - let movie = movie_row_to_domain( - self.id, - self.external_metadata_id, - self.title, - self.release_year, - self.director, - self.poster_path, - )?; - let genres = self - .genres - .map(|g| g.split(',').map(str::to_string).collect()) - .unwrap_or_default(); - Ok(movie_summary_to_domain( - movie, - genres, - self.runtime_minutes, - self.original_language, - self.overview, - self.collection_name, - )) - } -} - -#[derive(sqlx::FromRow)] -pub(crate) struct ReviewRow { - pub id: String, - pub movie_id: String, - pub user_id: String, - pub rating: i64, - pub comment: Option, - pub watched_at: String, - pub created_at: String, - pub remote_actor_url: Option, - pub watch_medium: Option, - pub spoilers: bool, -} - -impl ReviewRow { - pub fn into_domain(self) -> Result { - review_row_to_domain( - self.id, - self.movie_id, - self.user_id, - self.rating, - self.comment, - self.watched_at, - self.created_at, - self.remote_actor_url, - self.watch_medium, - self.spoilers, - ) - } -} - -#[derive(sqlx::FromRow)] -pub(crate) struct DiaryRow { - pub id: String, - pub external_metadata_id: Option, - pub title: String, - pub release_year: i64, - pub director: Option, - pub poster_path: Option, - pub review_id: String, - pub movie_id: String, - pub user_id: String, - pub rating: i64, - pub comment: Option, - pub watched_at: String, - pub created_at: String, - pub remote_actor_url: Option, - pub watch_medium: Option, - pub spoilers: bool, -} - -impl DiaryRow { - pub fn into_domain(self) -> Result { - let movie = movie_row_to_domain( - self.id, - self.external_metadata_id, - self.title, - self.release_year, - self.director, - self.poster_path, - )?; - let review = review_row_to_domain( - self.review_id, - self.movie_id, - self.user_id, - self.rating, - self.comment, - self.watched_at, - self.created_at, - self.remote_actor_url, - self.watch_medium, - self.spoilers, - )?; - Ok(DiaryEntry::new(movie, review)) - } -} - -#[derive(sqlx::FromRow)] -pub(crate) struct MovieStatsRow { - pub total_count: i64, - pub avg_rating: Option, - pub federated_count: i64, - pub rating_1: i64, - pub rating_2: i64, - pub rating_3: i64, - pub rating_4: i64, - pub rating_5: i64, -} - -impl MovieStatsRow { - pub fn into_domain(self) -> domain::models::MovieStats { - movie_stats_to_domain( - self.total_count, - self.avg_rating, - self.federated_count, - [ - self.rating_1, - self.rating_2, - self.rating_3, - self.rating_4, - self.rating_5, - ], - ) - } -} - -#[derive(sqlx::FromRow)] -pub(crate) struct FeedRow { - pub id: String, - pub external_metadata_id: Option, - pub title: String, - pub release_year: i64, - pub director: Option, - pub poster_path: Option, - pub review_id: String, - pub movie_id: String, - pub user_id: String, - pub rating: i64, - pub comment: Option, - pub watched_at: String, - pub created_at: String, - pub remote_actor_url: Option, - pub watch_medium: Option, - pub spoilers: bool, - pub user_email: String, -} - -impl FeedRow { - pub fn into_domain(self) -> Result { - let movie = movie_row_to_domain( - self.id, - self.external_metadata_id, - self.title, - self.release_year, - self.director, - self.poster_path, - )?; - let review = review_row_to_domain( - self.review_id, - self.movie_id, - self.user_id, - self.rating, - self.comment, - self.watched_at, - self.created_at, - self.remote_actor_url, - self.watch_medium, - self.spoilers, - )?; - let diary = DiaryEntry::new(movie, review); - Ok(FeedEntry::new(diary, self.user_email)) - } -} - -#[derive(sqlx::FromRow)] -pub(crate) struct UserSummaryRow { - pub id: String, - pub email: String, - pub username: String, - pub display_name: Option, - pub total_movies: i64, - pub avg_rating: Option, - pub avatar_path: Option, -} - -impl UserSummaryRow { - pub fn into_domain(self) -> Result { - user_summary_to_domain( - self.id, - self.email, - self.username, - self.display_name, - self.total_movies, - self.avg_rating, - self.avatar_path, - ) - } -} - -#[derive(sqlx::FromRow)] -pub(crate) struct UserTotalsRow { - pub total: i64, - pub avg_rating: Option, -} - -#[derive(sqlx::FromRow)] -pub(crate) struct DirectorCountRow { - pub director: String, - pub count: i64, -} - -#[derive(sqlx::FromRow)] -pub(crate) struct MonthlyRatingRow { - pub month: String, - pub avg_rating: f64, - pub count: i64, -} - -#[derive(sqlx::FromRow)] -pub(crate) struct GenreCountRow { - pub genre: String, - pub count: i64, -} - -#[derive(sqlx::FromRow)] -pub(crate) struct RatingDistRow { - pub rating: i64, - pub count: i64, -} - -#[derive(sqlx::FromRow)] -pub(crate) struct WatchMediumCountRow { - pub watch_medium: String, - pub count: i64, -} - -#[derive(sqlx::FromRow)] -pub(crate) struct ActorAppearanceRow { - pub tmdb_person_id: i64, - pub name: String, - pub profile_path: Option, - pub billing_order: i32, - pub movie_id: String, -} - -#[derive(sqlx::FromRow)] -pub(crate) struct WatchlistRow { - pub id: String, - pub user_id: String, - pub movie_id: String, - pub added_at: String, - pub m_id: String, - pub external_metadata_id: Option, - pub title: String, - pub release_year: i64, - pub director: Option, - pub poster_path: Option, -} - -impl WatchlistRow { - pub fn into_domain(self) -> Result { - let entry = watchlist_entry_to_domain(self.id, self.user_id, self.movie_id, self.added_at)?; - let movie = movie_row_to_domain( - self.m_id, - self.external_metadata_id, - self.title, - self.release_year, - self.director, - self.poster_path, - )?; - Ok(watchlist_with_movie_to_domain(entry, movie)) - } -} diff --git a/crates/adapters/sqlite/src/persons.rs b/crates/adapters/sqlite/src/persons.rs deleted file mode 100644 index 110e2e45..00000000 --- a/crates/adapters/sqlite/src/persons.rs +++ /dev/null @@ -1,348 +0,0 @@ -use async_trait::async_trait; -use domain::{ - errors::DomainError, - models::{ - CastCredit, CrewCredit, ExternalPersonId, Person, PersonCredits, PersonEnrichmentData, - PersonId, - }, - ports::{PersonCommand, PersonQuery}, - value_objects::MovieId, -}; -use sqlx::SqlitePool; -use std::sync::Arc; - -pub struct SqlitePersonAdapter { - pool: SqlitePool, -} - -impl SqlitePersonAdapter { - pub fn new(pool: SqlitePool) -> Self { - Self { pool } - } -} - -pub fn create_person_adapter(pool: SqlitePool) -> (Arc, Arc) { - let adapter = Arc::new(SqlitePersonAdapter::new(pool)); - ( - Arc::clone(&adapter) as Arc, - adapter as Arc, - ) -} - -#[async_trait] -impl PersonCommand for SqlitePersonAdapter { - async fn upsert_batch(&self, persons: &[Person]) -> Result<(), DomainError> { - for person in persons { - let tmdb_id = person.external_id().tmdb_id(); - sqlx::query( - "INSERT INTO persons (id, external_id, tmdb_person_id, name, known_for_department, profile_path) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET - external_id = excluded.external_id, - tmdb_person_id = excluded.tmdb_person_id, - name = excluded.name, - known_for_department = excluded.known_for_department, - profile_path = excluded.profile_path", - ) - .bind(person.id().value().to_string()) - .bind(person.external_id().value()) - .bind(tmdb_id) - .bind(person.name()) - .bind(person.known_for_department()) - .bind(person.profile_path()) - .execute(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - } - Ok(()) - } - - async fn backfill_from_credits_batch( - &self, - batch_size: u32, - ) -> Result<(u64, bool), DomainError> { - #[derive(sqlx::FromRow)] - struct MissingPerson { - tmdb_person_id: i64, - name: String, - department: Option, - profile_path: Option, - } - - let rows = sqlx::query_as::<_, MissingPerson>( - "SELECT mc.tmdb_person_id, mc.name, 'Acting' AS department, mc.profile_path - FROM movie_cast mc - WHERE NOT EXISTS (SELECT 1 FROM persons WHERE persons.tmdb_person_id = mc.tmdb_person_id) - GROUP BY mc.tmdb_person_id - UNION ALL - SELECT mc.tmdb_person_id, mc.name, mc.department, mc.profile_path - FROM movie_crew mc - WHERE NOT EXISTS (SELECT 1 FROM persons WHERE persons.tmdb_person_id = mc.tmdb_person_id) - AND NOT EXISTS (SELECT 1 FROM movie_cast c2 WHERE c2.tmdb_person_id = mc.tmdb_person_id) - GROUP BY mc.tmdb_person_id - LIMIT ?", - ) - .bind(batch_size) - .fetch_all(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - - let has_more = rows.len() as u32 >= batch_size; - let mut count = 0u64; - for row in &rows { - let ext = ExternalPersonId::new(format!("tmdb:{}", row.tmdb_person_id)); - let pid = PersonId::from_external(&ext); - sqlx::query( - "INSERT INTO persons (id, external_id, tmdb_person_id, name, known_for_department, profile_path) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(tmdb_person_id) DO NOTHING", - ) - .bind(pid.value().to_string()) - .bind(ext.value()) - .bind(row.tmdb_person_id) - .bind(&row.name) - .bind(&row.department) - .bind(&row.profile_path) - .execute(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - count += 1; - } - Ok((count, has_more)) - } - - async fn update_enrichment( - &self, - id: &PersonId, - data: &PersonEnrichmentData, - ) -> Result<(), DomainError> { - let also_known_as_json = - serde_json::to_string(&data.also_known_as).unwrap_or_else(|_| "[]".into()); - let now = chrono::Utc::now().to_rfc3339(); - sqlx::query( - "UPDATE persons SET biography = ?, birthday = ?, deathday = ?, place_of_birth = ?, also_known_as = ?, homepage = ?, imdb_id = ?, enriched_at = ? WHERE id = ?", - ) - .bind(&data.biography) - .bind(data.birthday.map(|d| d.to_string())) - .bind(data.deathday.map(|d| d.to_string())) - .bind(&data.place_of_birth) - .bind(&also_known_as_json) - .bind(&data.homepage) - .bind(&data.imdb_id) - .bind(&now) - .bind(id.value().to_string()) - .execute(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - Ok(()) - } -} - -#[async_trait] -impl PersonQuery for SqlitePersonAdapter { - async fn get_by_id(&self, id: &PersonId) -> Result, DomainError> { - let row = sqlx::query_as::<_, PersonRow>( - "SELECT id, external_id, name, known_for_department, profile_path, biography, birthday, deathday, place_of_birth, also_known_as, homepage, imdb_id, enriched_at FROM persons WHERE id = ?", - ) - .bind(id.value().to_string()) - .fetch_optional(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - - Ok(row.map(PersonRow::into_person)) - } - - async fn get_by_external_id( - &self, - id: &ExternalPersonId, - ) -> Result, DomainError> { - let row = sqlx::query_as::<_, PersonRow>( - "SELECT id, external_id, name, known_for_department, profile_path, biography, birthday, deathday, place_of_birth, also_known_as, homepage, imdb_id, enriched_at FROM persons WHERE external_id = ?", - ) - .bind(id.value()) - .fetch_optional(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - - Ok(row.map(PersonRow::into_person)) - } - - async fn get_credits(&self, id: &PersonId) -> Result { - let person = self - .get_by_id(id) - .await? - .ok_or_else(|| DomainError::NotFound(format!("Person {} not found", id.value())))?; - - let tmdb_id: Option = - sqlx::query_scalar("SELECT tmdb_person_id FROM persons WHERE id = ?") - .bind(id.value().to_string()) - .fetch_optional(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)? - .flatten(); - - let Some(tmdb_id) = tmdb_id else { - return Ok(PersonCredits { - person, - cast: vec![], - crew: vec![], - }); - }; - - let cast = sqlx::query_as::<_, CastRow>( - "SELECT m.id, m.title, m.release_year, mc.character, m.poster_path - FROM movie_cast mc - JOIN movies m ON m.id = mc.movie_id - WHERE mc.tmdb_person_id = ? - ORDER BY mc.billing_order", - ) - .bind(tmdb_id) - .fetch_all(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)? - .into_iter() - .map(|r| CastCredit { - movie_id: MovieId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()), - title: r.title, - release_year: r.release_year.map(|y| y as u16), - character: r.character, - poster_path: r.poster_path, - }) - .collect(); - - let crew = sqlx::query_as::<_, CrewRow>( - "SELECT m.id, m.title, m.release_year, mc.job, mc.department, m.poster_path - FROM movie_crew mc - JOIN movies m ON m.id = mc.movie_id - WHERE mc.tmdb_person_id = ? - ORDER BY m.title", - ) - .bind(tmdb_id) - .fetch_all(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)? - .into_iter() - .map(|r| CrewCredit { - movie_id: MovieId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()), - title: r.title, - release_year: r.release_year.map(|y| y as u16), - job: r.job, - department: r.department, - poster_path: r.poster_path, - }) - .collect(); - - Ok(PersonCredits { person, cast, crew }) - } - - async fn list_page(&self, limit: u32, offset: u32) -> Result, DomainError> { - let rows = sqlx::query_as::<_, PersonRow>( - "SELECT id, external_id, name, known_for_department, profile_path, biography, birthday, deathday, place_of_birth, also_known_as, homepage, imdb_id, enriched_at FROM persons ORDER BY id LIMIT ? OFFSET ?", - ) - .bind(limit) - .bind(offset) - .fetch_all(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - - Ok(rows.into_iter().map(PersonRow::into_person).collect()) - } - - async fn list_orphaned_persons(&self) -> Result, DomainError> { - let rows: Vec<(String,)> = sqlx::query_as( - "SELECT id FROM persons - WHERE NOT EXISTS ( - SELECT 1 FROM movie_cast WHERE movie_cast.tmdb_person_id = persons.tmdb_person_id - ) - AND NOT EXISTS ( - SELECT 1 FROM movie_crew WHERE movie_crew.tmdb_person_id = persons.tmdb_person_id - ) - LIMIT 500", - ) - .fetch_all(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - - Ok(rows - .into_iter() - .filter_map(|(id,)| uuid::Uuid::parse_str(&id).ok().map(PersonId::from_uuid)) - .collect()) - } -} - -// ── Row types ──────────────────────────────────────────────────────────────── - -#[derive(sqlx::FromRow)] -struct PersonRow { - id: String, - external_id: String, - name: String, - known_for_department: Option, - profile_path: Option, - biography: Option, - birthday: Option, - deathday: Option, - place_of_birth: Option, - also_known_as: Option, - homepage: Option, - imdb_id: Option, - enriched_at: Option, -} - -impl PersonRow { - fn into_person(self) -> Person { - let ext = ExternalPersonId::new(self.external_id); - let also_known_as = self - .also_known_as - .and_then(|s| serde_json::from_str::>(&s).ok()) - .unwrap_or_default(); - let birthday = self - .birthday - .and_then(|s| chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()); - let deathday = self - .deathday - .and_then(|s| chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()); - let enriched_at = self - .enriched_at - .and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok()) - .map(|d| d.with_timezone(&chrono::Utc)); - Person::new( - PersonId::from_uuid(uuid::Uuid::parse_str(&self.id).unwrap_or_default()), - ext, - self.name, - self.known_for_department, - self.profile_path, - self.biography, - birthday, - deathday, - self.place_of_birth, - also_known_as, - self.homepage, - self.imdb_id, - enriched_at, - ) - } -} - -#[derive(sqlx::FromRow)] -struct CastRow { - id: String, - title: String, - release_year: Option, - character: String, - poster_path: Option, -} - -#[derive(sqlx::FromRow)] -struct CrewRow { - id: String, - title: String, - release_year: Option, - job: String, - department: String, - poster_path: Option, -} - -#[cfg(test)] -#[path = "tests/persons.rs"] -mod tests; diff --git a/crates/adapters/sqlite/src/repositories/ap_content/mod.rs b/crates/adapters/sqlite/src/repositories/ap_content/mod.rs new file mode 100644 index 00000000..0887476b --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/ap_content/mod.rs @@ -0,0 +1,110 @@ +use async_trait::async_trait; +use domain::{ + errors::DomainError, + models::{DiaryEntry, WatchlistWithMovie}, + ports::LocalApContentQuery, + value_objects::{MovieId, UserId}, +}; +use sqlx::SqlitePool; + +pub struct SqliteApContentQuery { + pool: SqlitePool, +} + +impl SqliteApContentQuery { + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } +} + +mod rows; +use rows::{DiaryRow, WatchlistRow}; + +#[async_trait] +impl LocalApContentQuery for SqliteApContentQuery { + async fn get_local_watchlist_for_user( + &self, + user_id: &UserId, + ) -> Result, DomainError> { + let uid = user_id.value().to_string(); + let rows: Vec = sqlx::query_as( + "SELECT w.id, w.user_id, w.movie_id, w.added_at, + m.id AS m_id, m.external_metadata_id, m.title, m.release_year, + m.director, m.poster_path + FROM watchlist_entries w + JOIN movies m ON m.id = w.movie_id + WHERE w.user_id = ? + ORDER BY w.added_at DESC", + ) + .bind(&uid) + .fetch_all(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + rows.into_iter().map(WatchlistRow::into_domain).collect() + } + + async fn get_local_reviews_for_movie( + &self, + movie_id: &MovieId, + ) -> Result, DomainError> { + let mid = movie_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, r.watch_medium, r.spoilers + FROM reviews r + INNER JOIN movies m ON m.id = r.movie_id + WHERE r.movie_id = ? AND r.remote_actor_url IS NULL + ORDER BY r.created_at DESC", + ) + .bind(&mid) + .fetch_all(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + rows.into_iter().map(DiaryRow::into_domain).collect() + } + + async fn get_local_reviews_page( + &self, + user_id: &UserId, + before: Option, + limit: usize, + ) -> Result, DomainError> { + let uid = user_id.value().to_string(); + let limit_i64 = limit as i64; + + let rows = if let Some(before_ts) = before { + let ts = before_ts.format("%Y-%m-%d %H:%M:%S").to_string(); + 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, r.watch_medium, r.spoilers + FROM reviews r + 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 < ? + ORDER BY r.watched_at DESC + LIMIT ?", + ) + .bind(&uid) + .bind(&ts) + .bind(limit_i64) + .fetch_all(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)? + } else { + 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, r.watch_medium, r.spoilers + 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.watched_at DESC + LIMIT ?", + ) + .bind(&uid) + .bind(limit_i64) + .fetch_all(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)? + }; + rows.into_iter().map(DiaryRow::into_domain).collect() + } +} diff --git a/crates/adapters/sqlite/src/social/ap_content.rs b/crates/adapters/sqlite/src/repositories/ap_content/rows.rs similarity index 52% rename from crates/adapters/sqlite/src/social/ap_content.rs rename to crates/adapters/sqlite/src/repositories/ap_content/rows.rs index b441b467..6880e659 100644 --- a/crates/adapters/sqlite/src/social/ap_content.rs +++ b/crates/adapters/sqlite/src/repositories/ap_content/rows.rs @@ -1,34 +1,19 @@ -use async_trait::async_trait; use domain::{ errors::DomainError, models::{ DiaryEntry, Movie, PersistedReview, Review, ReviewSource, WatchlistEntry, WatchlistWithMovie, }, - ports::LocalApContentQuery, value_objects::{ Comment, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear, ReviewId, UserId, WatchlistEntryId, }, }; -use sqlx::SqlitePool; - -pub struct SqliteApContentQuery { - pool: SqlitePool, -} - -impl SqliteApContentQuery { - pub fn new(pool: SqlitePool) -> Self { - Self { pool } - } -} - -// ── Local row types ────────────────────────────────────────────────────────── use crate::shared::{parse_datetime, parse_uuid}; #[derive(sqlx::FromRow)] -struct MovieRow { +pub(super) struct MovieRow { id: String, external_metadata_id: Option, title: String, @@ -38,7 +23,7 @@ struct MovieRow { } impl MovieRow { - fn into_domain(self) -> Result { + pub(super) fn into_domain(self) -> Result { let id = MovieId::from_uuid(parse_uuid(&self.id)?); let external_metadata_id = self .external_metadata_id @@ -59,7 +44,7 @@ impl MovieRow { } #[derive(sqlx::FromRow)] -struct ReviewRow { +pub(super) struct ReviewRow { id: String, movie_id: String, user_id: String, @@ -73,7 +58,7 @@ struct ReviewRow { } impl ReviewRow { - fn into_domain(self) -> Result { + pub(super) fn into_domain(self) -> Result { let id = ReviewId::from_uuid(parse_uuid(&self.id)?); let movie_id = MovieId::from_uuid(parse_uuid(&self.movie_id)?); let user_id = UserId::from_uuid(parse_uuid(&self.user_id)?); @@ -102,7 +87,7 @@ impl ReviewRow { } #[derive(sqlx::FromRow)] -struct DiaryRow { +pub(super) struct DiaryRow { id: String, external_metadata_id: Option, title: String, @@ -122,7 +107,7 @@ struct DiaryRow { } impl DiaryRow { - fn into_domain(self) -> Result { + pub(super) fn into_domain(self) -> Result { let movie = MovieRow { id: self.id, external_metadata_id: self.external_metadata_id, @@ -150,7 +135,7 @@ impl DiaryRow { } #[derive(sqlx::FromRow)] -struct WatchlistRow { +pub(super) struct WatchlistRow { id: String, user_id: String, movie_id: String, @@ -164,7 +149,7 @@ struct WatchlistRow { } impl WatchlistRow { - fn into_domain(self) -> Result { + pub(super) fn into_domain(self) -> Result { let entry = WatchlistEntry { id: WatchlistEntryId::from_uuid(parse_uuid(&self.id)?), user_id: UserId::from_uuid(parse_uuid(&self.user_id)?), @@ -185,92 +170,3 @@ impl WatchlistRow { } // ── LocalApContentQuery impl ───────────────────────────────────────────────── - -#[async_trait] -impl LocalApContentQuery for SqliteApContentQuery { - async fn get_local_watchlist_for_user( - &self, - user_id: &UserId, - ) -> Result, DomainError> { - let uid = user_id.value().to_string(); - let rows: Vec = sqlx::query_as( - "SELECT w.id, w.user_id, w.movie_id, w.added_at, - m.id AS m_id, m.external_metadata_id, m.title, m.release_year, - m.director, m.poster_path - FROM watchlist_entries w - JOIN movies m ON m.id = w.movie_id - WHERE w.user_id = ? - ORDER BY w.added_at DESC", - ) - .bind(&uid) - .fetch_all(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - rows.into_iter().map(WatchlistRow::into_domain).collect() - } - - async fn get_local_reviews_for_movie( - &self, - movie_id: &MovieId, - ) -> Result, DomainError> { - let mid = movie_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, r.watch_medium, r.spoilers - FROM reviews r - INNER JOIN movies m ON m.id = r.movie_id - WHERE r.movie_id = ? AND r.remote_actor_url IS NULL - ORDER BY r.created_at DESC", - ) - .bind(&mid) - .fetch_all(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - rows.into_iter().map(DiaryRow::into_domain).collect() - } - - async fn get_local_reviews_page( - &self, - user_id: &UserId, - before: Option, - limit: usize, - ) -> Result, DomainError> { - let uid = user_id.value().to_string(); - let limit_i64 = limit as i64; - - let rows = if let Some(before_ts) = before { - let ts = before_ts.format("%Y-%m-%d %H:%M:%S").to_string(); - 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, r.watch_medium, r.spoilers - FROM reviews r - 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 < ? - ORDER BY r.watched_at DESC - LIMIT ?", - ) - .bind(&uid) - .bind(&ts) - .bind(limit_i64) - .fetch_all(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)? - } else { - 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, r.watch_medium, r.spoilers - 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.watched_at DESC - LIMIT ?", - ) - .bind(&uid) - .bind(limit_i64) - .fetch_all(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)? - }; - rows.into_iter().map(DiaryRow::into_domain).collect() - } -} diff --git a/crates/adapters/sqlite/src/repositories/diary/mod.rs b/crates/adapters/sqlite/src/repositories/diary/mod.rs new file mode 100644 index 00000000..c1a211d7 --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/diary/mod.rs @@ -0,0 +1,174 @@ +use domain::{errors::DomainError, models::ReviewSortBy}; +use sqlx::SqlitePool; + +use rows::DiaryRow; + +mod query; +mod rows; + +pub struct SqliteDiaryRepository { + pool: SqlitePool, +} + +impl SqliteDiaryRepository { + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } + + async fn count_diary_entries(&self, movie_id: Option<&str>) -> Result { + match movie_id { + None => sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM reviews") + .fetch_one(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error), + Some(id) => { + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM reviews WHERE movie_id = ?") + .bind(id) + .fetch_one(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error) + } + } + } + + async fn fetch_all_diary_rows( + &self, + sort: &ReviewSortBy, + limit: i64, + offset: i64, + ) -> Result, DomainError> { + let order_clause = match sort { + ReviewSortBy::ByRatingDesc => "r.rating DESC, r.watched_at DESC", + ReviewSortBy::ByRatingAsc => "r.rating ASC, r.watched_at ASC", + ReviewSortBy::Ascending => "r.watched_at ASC", + ReviewSortBy::Descending => "r.watched_at DESC", + }; + let sql = format!( + "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.watch_medium, r.spoilers + FROM reviews r + INNER JOIN movies m ON m.id = r.movie_id + ORDER BY {} + LIMIT ? OFFSET ?", + order_clause + ); + sqlx::query_as::<_, DiaryRow>(&sql) + .bind(limit) + .bind(offset) + .fetch_all(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error) + } + + async fn fetch_movie_diary_rows( + &self, + movie_id: &str, + sort: &ReviewSortBy, + limit: i64, + offset: i64, + ) -> Result, DomainError> { + let order_clause = match sort { + ReviewSortBy::ByRatingDesc => "r.rating DESC, r.watched_at DESC", + ReviewSortBy::ByRatingAsc => "r.rating ASC, r.watched_at ASC", + ReviewSortBy::Ascending => "r.watched_at ASC", + ReviewSortBy::Descending => "r.watched_at DESC", + }; + let sql = format!( + "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.watch_medium, r.spoilers + FROM reviews r + INNER JOIN movies m ON m.id = r.movie_id + WHERE r.movie_id = ? + ORDER BY {} + LIMIT ? OFFSET ?", + order_clause + ); + sqlx::query_as::<_, DiaryRow>(&sql) + .bind(movie_id) + .bind(limit) + .bind(offset) + .fetch_all(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error) + } + + async fn count_user_diary_entries( + &self, + user_id: &str, + search: Option<&str>, + include_remote: bool, + ) -> Result { + let has_search = search.map(|s| !s.is_empty()).unwrap_or(false); + let remote_clause = if include_remote { + "" + } else { + " AND r.remote_actor_url IS NULL" + }; + let search_clause = if has_search { + " AND m.title LIKE '%' || ? || '%'" + } else { + "" + }; + let sql = format!( + "SELECT COUNT(*) FROM reviews r + INNER JOIN movies m ON m.id = r.movie_id + WHERE r.user_id = ?{remote_clause}{search_clause}" + ); + let mut q = sqlx::query_scalar::<_, i64>(&sql).bind(user_id); + if has_search { + q = q.bind(search.unwrap()); + } + q.fetch_one(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error) + } + + async fn fetch_user_diary_rows( + &self, + user_id: &str, + sort: &ReviewSortBy, + search: Option<&str>, + include_remote: bool, + limit: i64, + offset: i64, + ) -> Result, DomainError> { + let has_search = search.map(|s| !s.is_empty()).unwrap_or(false); + let remote_clause = if include_remote { + "" + } else { + " AND r.remote_actor_url IS NULL" + }; + let search_clause = if has_search { + " AND m.title LIKE '%' || ? || '%'" + } else { + "" + }; + let order_clause = match sort { + ReviewSortBy::ByRatingDesc => "r.rating DESC, r.watched_at DESC", + ReviewSortBy::ByRatingAsc => "r.rating ASC, r.watched_at ASC", + ReviewSortBy::Ascending => "r.watched_at ASC", + ReviewSortBy::Descending => "r.watched_at DESC", + }; + let sql = format!( + "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.watch_medium, r.spoilers + FROM reviews r + INNER JOIN movies m ON m.id = r.movie_id + WHERE r.user_id = ?{remote_clause}{search_clause} + ORDER BY {order_clause} + LIMIT ? OFFSET ?", + ); + let mut q = sqlx::query_as::<_, DiaryRow>(&sql).bind(user_id); + if has_search { + q = q.bind(search.unwrap()); + } + q.bind(limit) + .bind(offset) + .fetch_all(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/adapters/sqlite/src/diary.rs b/crates/adapters/sqlite/src/repositories/diary/query.rs similarity index 67% rename from crates/adapters/sqlite/src/diary.rs rename to crates/adapters/sqlite/src/repositories/diary/query.rs index 0ca6bb62..1ce028c8 100644 --- a/crates/adapters/sqlite/src/diary.rs +++ b/crates/adapters/sqlite/src/repositories/diary/query.rs @@ -2,180 +2,18 @@ use async_trait::async_trait; use domain::{ errors::DomainError, models::{ - DiaryEntry, DiaryFilter, FeedEntry, MovieStats, ReviewHistory, ReviewSortBy, + DiaryEntry, DiaryFilter, FeedEntry, MovieStats, ReviewHistory, collections::{PageParams, Paginated}, }, ports::DiaryQuery, value_objects::{MovieId, UserId}, }; use futures::stream::BoxStream; -use sqlx::SqlitePool; -use crate::models::{DiaryRow, FeedRow, MovieRow, MovieStatsRow, ReviewRow}; - -pub struct SqliteDiaryRepository { - pool: SqlitePool, -} - -impl SqliteDiaryRepository { - pub fn new(pool: SqlitePool) -> Self { - Self { pool } - } - - async fn count_diary_entries(&self, movie_id: Option<&str>) -> Result { - match movie_id { - None => sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM reviews") - .fetch_one(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error), - Some(id) => { - sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM reviews WHERE movie_id = ?") - .bind(id) - .fetch_one(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error) - } - } - } - - async fn fetch_all_diary_rows( - &self, - sort: &ReviewSortBy, - limit: i64, - offset: i64, - ) -> Result, DomainError> { - let order_clause = match sort { - ReviewSortBy::ByRatingDesc => "r.rating DESC, r.watched_at DESC", - ReviewSortBy::ByRatingAsc => "r.rating ASC, r.watched_at ASC", - ReviewSortBy::Ascending => "r.watched_at ASC", - ReviewSortBy::Descending => "r.watched_at DESC", - }; - let sql = format!( - "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.watch_medium, r.spoilers - FROM reviews r - INNER JOIN movies m ON m.id = r.movie_id - ORDER BY {} - LIMIT ? OFFSET ?", - order_clause - ); - sqlx::query_as::<_, DiaryRow>(&sql) - .bind(limit) - .bind(offset) - .fetch_all(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error) - } - - async fn fetch_movie_diary_rows( - &self, - movie_id: &str, - sort: &ReviewSortBy, - limit: i64, - offset: i64, - ) -> Result, DomainError> { - let order_clause = match sort { - ReviewSortBy::ByRatingDesc => "r.rating DESC, r.watched_at DESC", - ReviewSortBy::ByRatingAsc => "r.rating ASC, r.watched_at ASC", - ReviewSortBy::Ascending => "r.watched_at ASC", - ReviewSortBy::Descending => "r.watched_at DESC", - }; - let sql = format!( - "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.watch_medium, r.spoilers - FROM reviews r - INNER JOIN movies m ON m.id = r.movie_id - WHERE r.movie_id = ? - ORDER BY {} - LIMIT ? OFFSET ?", - order_clause - ); - sqlx::query_as::<_, DiaryRow>(&sql) - .bind(movie_id) - .bind(limit) - .bind(offset) - .fetch_all(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error) - } - - async fn count_user_diary_entries( - &self, - user_id: &str, - search: Option<&str>, - include_remote: bool, - ) -> Result { - let has_search = search.map(|s| !s.is_empty()).unwrap_or(false); - let remote_clause = if include_remote { - "" - } else { - " AND r.remote_actor_url IS NULL" - }; - let search_clause = if has_search { - " AND m.title LIKE '%' || ? || '%'" - } else { - "" - }; - let sql = format!( - "SELECT COUNT(*) FROM reviews r - INNER JOIN movies m ON m.id = r.movie_id - WHERE r.user_id = ?{remote_clause}{search_clause}" - ); - let mut q = sqlx::query_scalar::<_, i64>(&sql).bind(user_id); - if has_search { - q = q.bind(search.unwrap()); - } - q.fetch_one(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error) - } - - async fn fetch_user_diary_rows( - &self, - user_id: &str, - sort: &ReviewSortBy, - search: Option<&str>, - include_remote: bool, - limit: i64, - offset: i64, - ) -> Result, DomainError> { - let has_search = search.map(|s| !s.is_empty()).unwrap_or(false); - let remote_clause = if include_remote { - "" - } else { - " AND r.remote_actor_url IS NULL" - }; - let search_clause = if has_search { - " AND m.title LIKE '%' || ? || '%'" - } else { - "" - }; - let order_clause = match sort { - ReviewSortBy::ByRatingDesc => "r.rating DESC, r.watched_at DESC", - ReviewSortBy::ByRatingAsc => "r.rating ASC, r.watched_at ASC", - ReviewSortBy::Ascending => "r.watched_at ASC", - ReviewSortBy::Descending => "r.watched_at DESC", - }; - let sql = format!( - "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.watch_medium, r.spoilers - FROM reviews r - INNER JOIN movies m ON m.id = r.movie_id - WHERE r.user_id = ?{remote_clause}{search_clause} - ORDER BY {order_clause} - LIMIT ? OFFSET ?", - ); - let mut q = sqlx::query_as::<_, DiaryRow>(&sql).bind(user_id); - if has_search { - q = q.bind(search.unwrap()); - } - q.bind(limit) - .bind(offset) - .fetch_all(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error) - } -} +use super::SqliteDiaryRepository; +use super::rows::{DiaryRow, FeedRow, MovieStatsRow}; +use crate::repositories::movie::rows::MovieRow; +use crate::repositories::review::rows::ReviewRow; #[async_trait] impl DiaryQuery for SqliteDiaryRepository { @@ -516,7 +354,3 @@ impl DiaryQuery for SqliteDiaryRepository { Ok(count as u64) } } - -#[cfg(test)] -#[path = "tests/diary.rs"] -mod tests; diff --git a/crates/adapters/sqlite/src/repositories/diary/rows.rs b/crates/adapters/sqlite/src/repositories/diary/rows.rs new file mode 100644 index 00000000..1150c8ea --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/diary/rows.rs @@ -0,0 +1,128 @@ +use crate::shared::{movie_row_to_domain, movie_stats_to_domain, review_row_to_domain}; +use domain::{ + errors::DomainError, + models::{DiaryEntry, FeedEntry}, +}; + +#[derive(sqlx::FromRow)] +pub(crate) struct DiaryRow { + pub id: String, + pub external_metadata_id: Option, + pub title: String, + pub release_year: i64, + pub director: Option, + pub poster_path: Option, + pub review_id: String, + pub movie_id: String, + pub user_id: String, + pub rating: i64, + pub comment: Option, + pub watched_at: String, + pub created_at: String, + pub remote_actor_url: Option, + pub watch_medium: Option, + pub spoilers: bool, +} + +impl DiaryRow { + pub fn into_domain(self) -> Result { + let movie = movie_row_to_domain( + self.id, + self.external_metadata_id, + self.title, + self.release_year, + self.director, + self.poster_path, + )?; + let review = review_row_to_domain( + self.review_id, + self.movie_id, + self.user_id, + self.rating, + self.comment, + self.watched_at, + self.created_at, + self.remote_actor_url, + self.watch_medium, + self.spoilers, + )?; + Ok(DiaryEntry::new(movie, review)) + } +} + +#[derive(sqlx::FromRow)] +pub(crate) struct FeedRow { + pub id: String, + pub external_metadata_id: Option, + pub title: String, + pub release_year: i64, + pub director: Option, + pub poster_path: Option, + pub review_id: String, + pub movie_id: String, + pub user_id: String, + pub rating: i64, + pub comment: Option, + pub watched_at: String, + pub created_at: String, + pub remote_actor_url: Option, + pub watch_medium: Option, + pub spoilers: bool, + pub user_email: String, +} + +impl FeedRow { + pub fn into_domain(self) -> Result { + let movie = movie_row_to_domain( + self.id, + self.external_metadata_id, + self.title, + self.release_year, + self.director, + self.poster_path, + )?; + let review = review_row_to_domain( + self.review_id, + self.movie_id, + self.user_id, + self.rating, + self.comment, + self.watched_at, + self.created_at, + self.remote_actor_url, + self.watch_medium, + self.spoilers, + )?; + let diary = DiaryEntry::new(movie, review); + Ok(FeedEntry::new(diary, self.user_email)) + } +} + +#[derive(sqlx::FromRow)] +pub(crate) struct MovieStatsRow { + pub total_count: i64, + pub avg_rating: Option, + pub federated_count: i64, + pub rating_1: i64, + pub rating_2: i64, + pub rating_3: i64, + pub rating_4: i64, + pub rating_5: i64, +} + +impl MovieStatsRow { + pub fn into_domain(self) -> domain::models::MovieStats { + movie_stats_to_domain( + self.total_count, + self.avg_rating, + self.federated_count, + [ + self.rating_1, + self.rating_2, + self.rating_3, + self.rating_4, + self.rating_5, + ], + ) + } +} diff --git a/crates/adapters/sqlite/src/tests/diary.rs b/crates/adapters/sqlite/src/repositories/diary/tests.rs similarity index 100% rename from crates/adapters/sqlite/src/tests/diary.rs rename to crates/adapters/sqlite/src/repositories/diary/tests.rs diff --git a/crates/adapters/sqlite/src/goals.rs b/crates/adapters/sqlite/src/repositories/goals/mod.rs similarity index 100% rename from crates/adapters/sqlite/src/goals.rs rename to crates/adapters/sqlite/src/repositories/goals/mod.rs diff --git a/crates/adapters/sqlite/src/image_ref.rs b/crates/adapters/sqlite/src/repositories/image_ref/mod.rs similarity index 98% rename from crates/adapters/sqlite/src/image_ref.rs rename to crates/adapters/sqlite/src/repositories/image_ref/mod.rs index 0da8a0b2..322d1b90 100644 --- a/crates/adapters/sqlite/src/image_ref.rs +++ b/crates/adapters/sqlite/src/repositories/image_ref/mod.rs @@ -66,5 +66,4 @@ impl ImageRefQuery for SqliteImageRefAdapter { } #[cfg(test)] -#[path = "tests/image_ref.rs"] mod tests; diff --git a/crates/adapters/sqlite/src/tests/image_ref.rs b/crates/adapters/sqlite/src/repositories/image_ref/tests.rs similarity index 100% rename from crates/adapters/sqlite/src/tests/image_ref.rs rename to crates/adapters/sqlite/src/repositories/image_ref/tests.rs diff --git a/crates/adapters/sqlite/src/import_profile.rs b/crates/adapters/sqlite/src/repositories/import_profile/mod.rs similarity index 62% rename from crates/adapters/sqlite/src/import_profile.rs rename to crates/adapters/sqlite/src/repositories/import_profile/mod.rs index 86bf680d..c8324617 100644 --- a/crates/adapters/sqlite/src/import_profile.rs +++ b/crates/adapters/sqlite/src/repositories/import_profile/mod.rs @@ -2,91 +2,14 @@ use async_trait::async_trait; use chrono::NaiveDateTime; use domain::{ errors::DomainError, - models::{ - FieldMapping, ImportProfile, - import::{DomainField, Transform}, - }, + models::ImportProfile, ports::ImportProfileRepository, value_objects::{ImportProfileId, UserId}, }; -use serde::{Deserialize, Serialize}; use sqlx::SqlitePool; -#[derive(Serialize, Deserialize)] -enum DomainFieldJson { - Title, - ReleaseYear, - Director, - Rating, - WatchedAt, - Comment, - ExternalMetadataId, -} - -#[derive(Serialize, Deserialize)] -enum TransformJson { - RatingScale(f64), - DateFormat(String), - Identity, -} - -#[derive(Serialize, Deserialize)] -struct FieldMappingJson { - source_column: String, - domain_field: DomainFieldJson, - transform: TransformJson, -} - -fn mapping_to_json(m: &FieldMapping) -> FieldMappingJson { - FieldMappingJson { - source_column: m.source_column.clone(), - domain_field: match &m.domain_field { - DomainField::Title => DomainFieldJson::Title, - DomainField::ReleaseYear => DomainFieldJson::ReleaseYear, - DomainField::Director => DomainFieldJson::Director, - DomainField::Rating => DomainFieldJson::Rating, - DomainField::WatchedAt => DomainFieldJson::WatchedAt, - DomainField::Comment => DomainFieldJson::Comment, - DomainField::ExternalMetadataId => DomainFieldJson::ExternalMetadataId, - }, - transform: match &m.transform { - Transform::RatingScale(f) => TransformJson::RatingScale(*f), - Transform::DateFormat(s) => TransformJson::DateFormat(s.clone()), - Transform::Identity => TransformJson::Identity, - }, - } -} - -fn mapping_from_json(j: FieldMappingJson) -> FieldMapping { - FieldMapping { - source_column: j.source_column, - domain_field: match j.domain_field { - DomainFieldJson::Title => DomainField::Title, - DomainFieldJson::ReleaseYear => DomainField::ReleaseYear, - DomainFieldJson::Director => DomainField::Director, - DomainFieldJson::Rating => DomainField::Rating, - DomainFieldJson::WatchedAt => DomainField::WatchedAt, - DomainFieldJson::Comment => DomainField::Comment, - DomainFieldJson::ExternalMetadataId => DomainField::ExternalMetadataId, - }, - transform: match j.transform { - TransformJson::RatingScale(f) => Transform::RatingScale(f), - TransformJson::DateFormat(s) => Transform::DateFormat(s), - TransformJson::Identity => Transform::Identity, - }, - } -} - -fn serialize_mappings(ms: &[FieldMapping]) -> Result { - serde_json::to_string(&ms.iter().map(mapping_to_json).collect::>()) - .map_err(|e| DomainError::InfrastructureError(e.to_string())) -} - -fn deserialize_mappings(s: &str) -> Result, DomainError> { - let js: Vec = - serde_json::from_str(s).map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - Ok(js.into_iter().map(mapping_from_json).collect()) -} +mod rows; +use rows::*; pub struct SqliteImportProfileRepository { pool: SqlitePool, diff --git a/crates/adapters/sqlite/src/repositories/import_profile/rows.rs b/crates/adapters/sqlite/src/repositories/import_profile/rows.rs new file mode 100644 index 00000000..3e87dc15 --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/import_profile/rows.rs @@ -0,0 +1,84 @@ +use domain::{ + errors::DomainError, + models::{ + FieldMapping, + import::{DomainField, Transform}, + }, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize)] +pub(super) enum DomainFieldJson { + Title, + ReleaseYear, + Director, + Rating, + WatchedAt, + Comment, + ExternalMetadataId, +} + +#[derive(Serialize, Deserialize)] +pub(super) enum TransformJson { + RatingScale(f64), + DateFormat(String), + Identity, +} + +#[derive(Serialize, Deserialize)] +pub(super) struct FieldMappingJson { + source_column: String, + domain_field: DomainFieldJson, + transform: TransformJson, +} + +pub(super) fn mapping_to_json(m: &FieldMapping) -> FieldMappingJson { + FieldMappingJson { + source_column: m.source_column.clone(), + domain_field: match &m.domain_field { + DomainField::Title => DomainFieldJson::Title, + DomainField::ReleaseYear => DomainFieldJson::ReleaseYear, + DomainField::Director => DomainFieldJson::Director, + DomainField::Rating => DomainFieldJson::Rating, + DomainField::WatchedAt => DomainFieldJson::WatchedAt, + DomainField::Comment => DomainFieldJson::Comment, + DomainField::ExternalMetadataId => DomainFieldJson::ExternalMetadataId, + }, + transform: match &m.transform { + Transform::RatingScale(f) => TransformJson::RatingScale(*f), + Transform::DateFormat(s) => TransformJson::DateFormat(s.clone()), + Transform::Identity => TransformJson::Identity, + }, + } +} + +pub(super) fn mapping_from_json(j: FieldMappingJson) -> FieldMapping { + FieldMapping { + source_column: j.source_column, + domain_field: match j.domain_field { + DomainFieldJson::Title => DomainField::Title, + DomainFieldJson::ReleaseYear => DomainField::ReleaseYear, + DomainFieldJson::Director => DomainField::Director, + DomainFieldJson::Rating => DomainField::Rating, + DomainFieldJson::WatchedAt => DomainField::WatchedAt, + DomainFieldJson::Comment => DomainField::Comment, + DomainFieldJson::ExternalMetadataId => DomainField::ExternalMetadataId, + }, + transform: match j.transform { + TransformJson::RatingScale(f) => Transform::RatingScale(f), + TransformJson::DateFormat(s) => Transform::DateFormat(s), + TransformJson::Identity => Transform::Identity, + }, + } +} + +pub(super) fn serialize_mappings(ms: &[FieldMapping]) -> Result { + serde_json::to_string(&ms.iter().map(mapping_to_json).collect::>()) + .map_err(|e| DomainError::InfrastructureError(e.to_string())) +} + +pub(super) fn deserialize_mappings(s: &str) -> Result, DomainError> { + let js: Vec = + serde_json::from_str(s).map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + Ok(js.into_iter().map(mapping_from_json).collect()) +} diff --git a/crates/adapters/sqlite/src/import_session.rs b/crates/adapters/sqlite/src/repositories/import_session/mod.rs similarity index 55% rename from crates/adapters/sqlite/src/import_session.rs rename to crates/adapters/sqlite/src/repositories/import_session/mod.rs index fd58a00a..7029ce6b 100644 --- a/crates/adapters/sqlite/src/import_session.rs +++ b/crates/adapters/sqlite/src/repositories/import_session/mod.rs @@ -2,194 +2,14 @@ use async_trait::async_trait; use chrono::NaiveDateTime; use domain::{ errors::DomainError, - models::{ - AnnotatedRow, FieldMapping, ImportSession, ParsedFile, - import::{DomainField, ImportRow, RowResult, Transform}, - }, + models::{AnnotatedRow, FieldMapping, ImportSession, ParsedFile}, ports::ImportSessionRepository, value_objects::{ImportSessionId, UserId}, }; -use serde::{Deserialize, Serialize}; use sqlx::SqlitePool; -// ── serde mirror structs (match the JSON format from the old importer types) ── - -#[derive(Serialize, Deserialize, Default)] -struct ParsedFileJson { - columns: Vec, - rows: Vec>, -} - -#[derive(Serialize, Deserialize)] -enum DomainFieldJson { - Title, - ReleaseYear, - Director, - Rating, - WatchedAt, - Comment, - ExternalMetadataId, -} - -#[derive(Serialize, Deserialize)] -enum TransformJson { - RatingScale(f64), - DateFormat(String), - Identity, -} - -#[derive(Serialize, Deserialize)] -struct FieldMappingJson { - source_column: String, - domain_field: DomainFieldJson, - transform: TransformJson, -} - -#[derive(Serialize, Deserialize, Default)] -struct ImportRowJson { - #[serde(skip_serializing_if = "Option::is_none")] - title: Option, - #[serde(skip_serializing_if = "Option::is_none")] - release_year: Option, - #[serde(skip_serializing_if = "Option::is_none")] - director: Option, - #[serde(skip_serializing_if = "Option::is_none")] - rating: Option, - #[serde(skip_serializing_if = "Option::is_none")] - watched_at: Option, - #[serde(skip_serializing_if = "Option::is_none")] - comment: Option, - #[serde(skip_serializing_if = "Option::is_none")] - external_metadata_id: Option, -} - -#[derive(Serialize, Deserialize)] -enum RowResultJson { - Valid(ImportRowJson), - Invalid { - errors: Vec, - raw: Vec<(String, String)>, - }, -} - -#[derive(Serialize, Deserialize)] -struct AnnotatedRowJson { - result: RowResultJson, - is_duplicate: bool, -} - -// ── conversion helpers ── - -fn domain_field_to_json(f: &DomainField) -> DomainFieldJson { - match f { - DomainField::Title => DomainFieldJson::Title, - DomainField::ReleaseYear => DomainFieldJson::ReleaseYear, - DomainField::Director => DomainFieldJson::Director, - DomainField::Rating => DomainFieldJson::Rating, - DomainField::WatchedAt => DomainFieldJson::WatchedAt, - DomainField::Comment => DomainFieldJson::Comment, - DomainField::ExternalMetadataId => DomainFieldJson::ExternalMetadataId, - } -} - -fn domain_field_from_json(j: DomainFieldJson) -> DomainField { - match j { - DomainFieldJson::Title => DomainField::Title, - DomainFieldJson::ReleaseYear => DomainField::ReleaseYear, - DomainFieldJson::Director => DomainField::Director, - DomainFieldJson::Rating => DomainField::Rating, - DomainFieldJson::WatchedAt => DomainField::WatchedAt, - DomainFieldJson::Comment => DomainField::Comment, - DomainFieldJson::ExternalMetadataId => DomainField::ExternalMetadataId, - } -} - -fn transform_to_json(t: &Transform) -> TransformJson { - match t { - Transform::RatingScale(f) => TransformJson::RatingScale(*f), - Transform::DateFormat(s) => TransformJson::DateFormat(s.clone()), - Transform::Identity => TransformJson::Identity, - } -} - -fn transform_from_json(j: TransformJson) -> Transform { - match j { - TransformJson::RatingScale(f) => Transform::RatingScale(f), - TransformJson::DateFormat(s) => Transform::DateFormat(s), - TransformJson::Identity => Transform::Identity, - } -} - -fn mapping_to_json(m: &FieldMapping) -> FieldMappingJson { - FieldMappingJson { - source_column: m.source_column.clone(), - domain_field: domain_field_to_json(&m.domain_field), - transform: transform_to_json(&m.transform), - } -} - -fn mapping_from_json(j: FieldMappingJson) -> FieldMapping { - FieldMapping { - source_column: j.source_column, - domain_field: domain_field_from_json(j.domain_field), - transform: transform_from_json(j.transform), - } -} - -fn import_row_to_json(r: &ImportRow) -> ImportRowJson { - ImportRowJson { - title: r.title.clone(), - release_year: r.release_year.clone(), - director: r.director.clone(), - rating: r.rating.clone(), - watched_at: r.watched_at.clone(), - comment: r.comment.clone(), - external_metadata_id: r.external_metadata_id.clone(), - } -} - -fn import_row_from_json(j: ImportRowJson) -> ImportRow { - ImportRow { - title: j.title, - release_year: j.release_year, - director: j.director, - rating: j.rating, - watched_at: j.watched_at, - comment: j.comment, - external_metadata_id: j.external_metadata_id, - } -} - -fn annotated_to_json(a: &AnnotatedRow) -> AnnotatedRowJson { - AnnotatedRowJson { - result: match &a.result { - RowResult::Valid(row) => RowResultJson::Valid(import_row_to_json(row)), - RowResult::Invalid { errors, raw } => RowResultJson::Invalid { - errors: errors.clone(), - raw: raw.clone(), - }, - }, - is_duplicate: a.is_duplicate, - } -} - -fn annotated_from_json(j: AnnotatedRowJson) -> AnnotatedRow { - AnnotatedRow { - result: match j.result { - RowResultJson::Valid(row) => RowResult::Valid(import_row_from_json(row)), - RowResultJson::Invalid { errors, raw } => RowResult::Invalid { errors, raw }, - }, - is_duplicate: j.is_duplicate, - } -} - -fn ser(v: &T) -> Result { - serde_json::to_string(v).map_err(|e| DomainError::InfrastructureError(e.to_string())) -} - -fn de Deserialize<'de>>(s: &str) -> Result { - serde_json::from_str(s).map_err(|e| DomainError::InfrastructureError(e.to_string())) -} +mod rows; +use rows::*; // ── repository ── diff --git a/crates/adapters/sqlite/src/repositories/import_session/rows.rs b/crates/adapters/sqlite/src/repositories/import_session/rows.rs new file mode 100644 index 00000000..82945559 --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/import_session/rows.rs @@ -0,0 +1,187 @@ +use domain::{ + errors::DomainError, + models::{ + AnnotatedRow, FieldMapping, + import::{DomainField, ImportRow, RowResult, Transform}, + }, +}; +use serde::{Deserialize, Serialize}; + +// ── serde mirror structs (match the JSON format from the old importer types) ── + +#[derive(Serialize, Deserialize, Default)] +pub(super) struct ParsedFileJson { + pub(super) columns: Vec, + pub(super) rows: Vec>, +} + +#[derive(Serialize, Deserialize)] +pub(super) enum DomainFieldJson { + Title, + ReleaseYear, + Director, + Rating, + WatchedAt, + Comment, + ExternalMetadataId, +} + +#[derive(Serialize, Deserialize)] +pub(super) enum TransformJson { + RatingScale(f64), + DateFormat(String), + Identity, +} + +#[derive(Serialize, Deserialize)] +pub(super) struct FieldMappingJson { + source_column: String, + domain_field: DomainFieldJson, + transform: TransformJson, +} + +#[derive(Serialize, Deserialize, Default)] +pub(super) struct ImportRowJson { + #[serde(skip_serializing_if = "Option::is_none")] + title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + release_year: Option, + #[serde(skip_serializing_if = "Option::is_none")] + director: Option, + #[serde(skip_serializing_if = "Option::is_none")] + rating: Option, + #[serde(skip_serializing_if = "Option::is_none")] + watched_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + comment: Option, + #[serde(skip_serializing_if = "Option::is_none")] + external_metadata_id: Option, +} + +#[derive(Serialize, Deserialize)] +pub(super) enum RowResultJson { + Valid(ImportRowJson), + Invalid { + errors: Vec, + raw: Vec<(String, String)>, + }, +} + +#[derive(Serialize, Deserialize)] +pub(super) struct AnnotatedRowJson { + result: RowResultJson, + is_duplicate: bool, +} + +// ── conversion helpers ── + +pub(super) fn domain_field_to_json(f: &DomainField) -> DomainFieldJson { + match f { + DomainField::Title => DomainFieldJson::Title, + DomainField::ReleaseYear => DomainFieldJson::ReleaseYear, + DomainField::Director => DomainFieldJson::Director, + DomainField::Rating => DomainFieldJson::Rating, + DomainField::WatchedAt => DomainFieldJson::WatchedAt, + DomainField::Comment => DomainFieldJson::Comment, + DomainField::ExternalMetadataId => DomainFieldJson::ExternalMetadataId, + } +} + +pub(super) fn domain_field_from_json(j: DomainFieldJson) -> DomainField { + match j { + DomainFieldJson::Title => DomainField::Title, + DomainFieldJson::ReleaseYear => DomainField::ReleaseYear, + DomainFieldJson::Director => DomainField::Director, + DomainFieldJson::Rating => DomainField::Rating, + DomainFieldJson::WatchedAt => DomainField::WatchedAt, + DomainFieldJson::Comment => DomainField::Comment, + DomainFieldJson::ExternalMetadataId => DomainField::ExternalMetadataId, + } +} + +pub(super) fn transform_to_json(t: &Transform) -> TransformJson { + match t { + Transform::RatingScale(f) => TransformJson::RatingScale(*f), + Transform::DateFormat(s) => TransformJson::DateFormat(s.clone()), + Transform::Identity => TransformJson::Identity, + } +} + +pub(super) fn transform_from_json(j: TransformJson) -> Transform { + match j { + TransformJson::RatingScale(f) => Transform::RatingScale(f), + TransformJson::DateFormat(s) => Transform::DateFormat(s), + TransformJson::Identity => Transform::Identity, + } +} + +pub(super) fn mapping_to_json(m: &FieldMapping) -> FieldMappingJson { + FieldMappingJson { + source_column: m.source_column.clone(), + domain_field: domain_field_to_json(&m.domain_field), + transform: transform_to_json(&m.transform), + } +} + +pub(super) fn mapping_from_json(j: FieldMappingJson) -> FieldMapping { + FieldMapping { + source_column: j.source_column, + domain_field: domain_field_from_json(j.domain_field), + transform: transform_from_json(j.transform), + } +} + +pub(super) fn import_row_to_json(r: &ImportRow) -> ImportRowJson { + ImportRowJson { + title: r.title.clone(), + release_year: r.release_year.clone(), + director: r.director.clone(), + rating: r.rating.clone(), + watched_at: r.watched_at.clone(), + comment: r.comment.clone(), + external_metadata_id: r.external_metadata_id.clone(), + } +} + +pub(super) fn import_row_from_json(j: ImportRowJson) -> ImportRow { + ImportRow { + title: j.title, + release_year: j.release_year, + director: j.director, + rating: j.rating, + watched_at: j.watched_at, + comment: j.comment, + external_metadata_id: j.external_metadata_id, + } +} + +pub(super) fn annotated_to_json(a: &AnnotatedRow) -> AnnotatedRowJson { + AnnotatedRowJson { + result: match &a.result { + RowResult::Valid(row) => RowResultJson::Valid(import_row_to_json(row)), + RowResult::Invalid { errors, raw } => RowResultJson::Invalid { + errors: errors.clone(), + raw: raw.clone(), + }, + }, + is_duplicate: a.is_duplicate, + } +} + +pub(super) fn annotated_from_json(j: AnnotatedRowJson) -> AnnotatedRow { + AnnotatedRow { + result: match j.result { + RowResultJson::Valid(row) => RowResult::Valid(import_row_from_json(row)), + RowResultJson::Invalid { errors, raw } => RowResult::Invalid { errors, raw }, + }, + is_duplicate: j.is_duplicate, + } +} + +pub(super) fn ser(v: &T) -> Result { + serde_json::to_string(v).map_err(|e| DomainError::InfrastructureError(e.to_string())) +} + +pub(super) fn de Deserialize<'de>>(s: &str) -> Result { + serde_json::from_str(s).map_err(|e| DomainError::InfrastructureError(e.to_string())) +} diff --git a/crates/adapters/sqlite/src/insights.rs b/crates/adapters/sqlite/src/repositories/insights/mod.rs similarity index 100% rename from crates/adapters/sqlite/src/insights.rs rename to crates/adapters/sqlite/src/repositories/insights/mod.rs diff --git a/crates/adapters/sqlite/src/repositories/mod.rs b/crates/adapters/sqlite/src/repositories/mod.rs new file mode 100644 index 00000000..53803c82 --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/mod.rs @@ -0,0 +1,47 @@ +mod ap_content; +mod diary; +mod goals; +mod image_ref; +mod import_profile; +mod import_session; +mod insights; +mod movie; +mod movie_dedup; +mod persons; +mod profile; +mod profile_fields; +mod refresh_sessions; +mod remote_goals; +mod review; +mod search; +mod social; +mod stats; +mod user_settings; +mod users; +mod watch_event; +mod watchlist; +mod wrapup; + +pub use ap_content::SqliteApContentQuery; +pub use diary::SqliteDiaryRepository; +pub use goals::SqliteGoalRepository; +pub use image_ref::{SqliteImageRefAdapter, create_image_ref}; +pub use import_profile::SqliteImportProfileRepository; +pub use import_session::SqliteImportSessionRepository; +pub use insights::SqliteInsightsQuery; +pub use movie::SqliteMovieRepository; +pub use movie_dedup::SqliteMovieDeduplicator; +pub use persons::{SqlitePersonAdapter, create_person_adapter}; +pub use profile::SqliteMovieProfileRepository; +pub use profile_fields::SqliteProfileFieldsRepository; +pub use refresh_sessions::SqliteRefreshSessionAdapter; +pub use remote_goals::SqliteRemoteGoalRepository; +pub use review::SqliteReviewRepository; +pub use search::create_search_adapter; +pub use social::{SqliteSocialRepository, create_federated_profile_query}; +pub use stats::SqliteStatsRepository; +pub use user_settings::SqliteUserSettingsRepository; +pub use users::SqliteUserRepository; +pub use watch_event::{SqliteWatchEventRepository, SqliteWebhookTokenRepository}; +pub use watchlist::SqliteWatchlistRepository; +pub use wrapup::{SqliteWrapUpRepository, SqliteWrapUpStatsQuery}; diff --git a/crates/adapters/sqlite/src/repositories/movie/command.rs b/crates/adapters/sqlite/src/repositories/movie/command.rs new file mode 100644 index 00000000..79946f26 --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/movie/command.rs @@ -0,0 +1,48 @@ +use async_trait::async_trait; +use domain::{errors::DomainError, models::Movie, ports::MovieCommand, value_objects::MovieId}; + +use super::SqliteMovieRepository; + +#[async_trait] +impl MovieCommand for SqliteMovieRepository { + async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError> { + let id = movie.id().value().to_string(); + let external_metadata_id = movie.external_metadata_id().map(|e| e.value().to_string()); + let title = movie.title().value(); + let release_year = movie.release_year().value() as i64; + let director = movie.director(); + let poster_path = movie.poster_path().map(|p| p.value().to_string()); + + sqlx::query( + "INSERT INTO movies (id, external_metadata_id, title, release_year, director, poster_path) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + external_metadata_id = excluded.external_metadata_id, + title = excluded.title, + release_year = excluded.release_year, + director = excluded.director, + poster_path = excluded.poster_path", + ) + .bind(&id) + .bind(&external_metadata_id) + .bind(title) + .bind(release_year) + .bind(director) + .bind(&poster_path) + .execute(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + Ok(()) + } + + async fn delete_movie(&self, movie_id: &MovieId) -> Result<(), DomainError> { + let id = movie_id.value().to_string(); + sqlx::query("DELETE FROM movies WHERE id = ?") + .bind(&id) + .execute(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + Ok(()) + } +} diff --git a/crates/adapters/sqlite/src/repositories/movie/mod.rs b/crates/adapters/sqlite/src/repositories/movie/mod.rs new file mode 100644 index 00000000..2e4af344 --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/movie/mod.rs @@ -0,0 +1,15 @@ +use sqlx::SqlitePool; + +mod command; +mod query; +pub(crate) mod rows; + +pub struct SqliteMovieRepository { + pool: SqlitePool, +} + +impl SqliteMovieRepository { + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } +} diff --git a/crates/adapters/sqlite/src/movie.rs b/crates/adapters/sqlite/src/repositories/movie/query.rs similarity index 78% rename from crates/adapters/sqlite/src/movie.rs rename to crates/adapters/sqlite/src/repositories/movie/query.rs index e60029e6..1c61473f 100644 --- a/crates/adapters/sqlite/src/movie.rs +++ b/crates/adapters/sqlite/src/repositories/movie/query.rs @@ -5,66 +5,12 @@ use domain::{ Movie, MovieFilter, MovieSummary, collections::{PageParams, Paginated}, }, - ports::{MovieCommand, MovieQuery}, + ports::MovieQuery, value_objects::{ExternalMetadataId, MovieId, MovieTitle, ReleaseYear}, }; -use sqlx::SqlitePool; -use crate::models::{MovieRow, MovieSummaryRow}; - -pub struct SqliteMovieRepository { - pool: SqlitePool, -} - -impl SqliteMovieRepository { - pub fn new(pool: SqlitePool) -> Self { - Self { pool } - } -} - -#[async_trait] -impl MovieCommand for SqliteMovieRepository { - async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError> { - let id = movie.id().value().to_string(); - let external_metadata_id = movie.external_metadata_id().map(|e| e.value().to_string()); - let title = movie.title().value(); - let release_year = movie.release_year().value() as i64; - let director = movie.director(); - let poster_path = movie.poster_path().map(|p| p.value().to_string()); - - sqlx::query( - "INSERT INTO movies (id, external_metadata_id, title, release_year, director, poster_path) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET - external_metadata_id = excluded.external_metadata_id, - title = excluded.title, - release_year = excluded.release_year, - director = excluded.director, - poster_path = excluded.poster_path", - ) - .bind(&id) - .bind(&external_metadata_id) - .bind(title) - .bind(release_year) - .bind(director) - .bind(&poster_path) - .execute(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - - Ok(()) - } - - async fn delete_movie(&self, movie_id: &MovieId) -> Result<(), DomainError> { - let id = movie_id.value().to_string(); - sqlx::query("DELETE FROM movies WHERE id = ?") - .bind(&id) - .execute(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - Ok(()) - } -} +use super::SqliteMovieRepository; +use super::rows::{MovieRow, MovieSummaryRow}; #[async_trait] impl MovieQuery for SqliteMovieRepository { diff --git a/crates/adapters/sqlite/src/repositories/movie/rows.rs b/crates/adapters/sqlite/src/repositories/movie/rows.rs new file mode 100644 index 00000000..ff3eb4da --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/movie/rows.rs @@ -0,0 +1,68 @@ +use crate::shared::{movie_row_to_domain, movie_summary_to_domain}; +use domain::{ + errors::DomainError, + models::{Movie, MovieSummary}, +}; + +#[derive(sqlx::FromRow)] +pub(crate) struct MovieRow { + pub id: String, + pub external_metadata_id: Option, + pub title: String, + pub release_year: i64, + pub director: Option, + pub poster_path: Option, +} + +impl MovieRow { + pub fn into_domain(self) -> Result { + movie_row_to_domain( + self.id, + self.external_metadata_id, + self.title, + self.release_year, + self.director, + self.poster_path, + ) + } +} + +#[derive(sqlx::FromRow)] +pub(crate) struct MovieSummaryRow { + pub id: String, + pub external_metadata_id: Option, + pub title: String, + pub release_year: i64, + pub director: Option, + pub poster_path: Option, + pub genres: Option, + pub runtime_minutes: Option, + pub original_language: Option, + pub overview: Option, + pub collection_name: Option, +} + +impl MovieSummaryRow { + pub fn into_domain(self) -> Result { + let movie = movie_row_to_domain( + self.id, + self.external_metadata_id, + self.title, + self.release_year, + self.director, + self.poster_path, + )?; + let genres = self + .genres + .map(|g| g.split(',').map(str::to_string).collect()) + .unwrap_or_default(); + Ok(movie_summary_to_domain( + movie, + genres, + self.runtime_minutes, + self.original_language, + self.overview, + self.collection_name, + )) + } +} diff --git a/crates/adapters/sqlite/src/movie_dedup.rs b/crates/adapters/sqlite/src/repositories/movie_dedup/mod.rs similarity index 100% rename from crates/adapters/sqlite/src/movie_dedup.rs rename to crates/adapters/sqlite/src/repositories/movie_dedup/mod.rs diff --git a/crates/adapters/sqlite/src/repositories/persons/command.rs b/crates/adapters/sqlite/src/repositories/persons/command.rs new file mode 100644 index 00000000..ddbb490c --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/persons/command.rs @@ -0,0 +1,117 @@ +use async_trait::async_trait; +use domain::{ + errors::DomainError, + models::{ExternalPersonId, Person, PersonEnrichmentData, PersonId}, + ports::PersonCommand, +}; + +use super::SqlitePersonAdapter; + +#[async_trait] +impl PersonCommand for SqlitePersonAdapter { + async fn upsert_batch(&self, persons: &[Person]) -> Result<(), DomainError> { + for person in persons { + let tmdb_id = person.external_id().tmdb_id(); + sqlx::query( + "INSERT INTO persons (id, external_id, tmdb_person_id, name, known_for_department, profile_path) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + external_id = excluded.external_id, + tmdb_person_id = excluded.tmdb_person_id, + name = excluded.name, + known_for_department = excluded.known_for_department, + profile_path = excluded.profile_path", + ) + .bind(person.id().value().to_string()) + .bind(person.external_id().value()) + .bind(tmdb_id) + .bind(person.name()) + .bind(person.known_for_department()) + .bind(person.profile_path()) + .execute(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + } + Ok(()) + } + + async fn backfill_from_credits_batch( + &self, + batch_size: u32, + ) -> Result<(u64, bool), DomainError> { + #[derive(sqlx::FromRow)] + struct MissingPerson { + tmdb_person_id: i64, + name: String, + department: Option, + profile_path: Option, + } + + let rows = sqlx::query_as::<_, MissingPerson>( + "SELECT mc.tmdb_person_id, mc.name, 'Acting' AS department, mc.profile_path + FROM movie_cast mc + WHERE NOT EXISTS (SELECT 1 FROM persons WHERE persons.tmdb_person_id = mc.tmdb_person_id) + GROUP BY mc.tmdb_person_id + UNION ALL + SELECT mc.tmdb_person_id, mc.name, mc.department, mc.profile_path + FROM movie_crew mc + WHERE NOT EXISTS (SELECT 1 FROM persons WHERE persons.tmdb_person_id = mc.tmdb_person_id) + AND NOT EXISTS (SELECT 1 FROM movie_cast c2 WHERE c2.tmdb_person_id = mc.tmdb_person_id) + GROUP BY mc.tmdb_person_id + LIMIT ?", + ) + .bind(batch_size) + .fetch_all(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + let has_more = rows.len() as u32 >= batch_size; + let mut count = 0u64; + for row in &rows { + let ext = ExternalPersonId::new(format!("tmdb:{}", row.tmdb_person_id)); + let pid = PersonId::from_external(&ext); + sqlx::query( + "INSERT INTO persons (id, external_id, tmdb_person_id, name, known_for_department, profile_path) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(tmdb_person_id) DO NOTHING", + ) + .bind(pid.value().to_string()) + .bind(ext.value()) + .bind(row.tmdb_person_id) + .bind(&row.name) + .bind(&row.department) + .bind(&row.profile_path) + .execute(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + count += 1; + } + Ok((count, has_more)) + } + + async fn update_enrichment( + &self, + id: &PersonId, + data: &PersonEnrichmentData, + ) -> Result<(), DomainError> { + let also_known_as_json = + serde_json::to_string(&data.also_known_as).unwrap_or_else(|_| "[]".into()); + let now = chrono::Utc::now().to_rfc3339(); + sqlx::query( + "UPDATE persons SET biography = ?, birthday = ?, deathday = ?, place_of_birth = ?, also_known_as = ?, homepage = ?, imdb_id = ?, enriched_at = ? WHERE id = ?", + ) + .bind(&data.biography) + .bind(data.birthday.map(|d| d.to_string())) + .bind(data.deathday.map(|d| d.to_string())) + .bind(&data.place_of_birth) + .bind(&also_known_as_json) + .bind(&data.homepage) + .bind(&data.imdb_id) + .bind(&now) + .bind(id.value().to_string()) + .execute(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + Ok(()) + } +} diff --git a/crates/adapters/sqlite/src/repositories/persons/mod.rs b/crates/adapters/sqlite/src/repositories/persons/mod.rs new file mode 100644 index 00000000..19118f8f --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/persons/mod.rs @@ -0,0 +1,28 @@ +use domain::ports::{PersonCommand, PersonQuery}; +use sqlx::SqlitePool; +use std::sync::Arc; + +mod command; +mod query; +mod rows; + +pub struct SqlitePersonAdapter { + pool: SqlitePool, +} + +impl SqlitePersonAdapter { + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } +} + +pub fn create_person_adapter(pool: SqlitePool) -> (Arc, Arc) { + let adapter = Arc::new(SqlitePersonAdapter::new(pool)); + ( + Arc::clone(&adapter) as Arc, + adapter as Arc, + ) +} + +#[cfg(test)] +mod tests; diff --git a/crates/adapters/sqlite/src/repositories/persons/query.rs b/crates/adapters/sqlite/src/repositories/persons/query.rs new file mode 100644 index 00000000..386f3b92 --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/persons/query.rs @@ -0,0 +1,142 @@ +use async_trait::async_trait; +use domain::{ + errors::DomainError, + models::{CastCredit, CrewCredit, ExternalPersonId, Person, PersonCredits, PersonId}, + ports::PersonQuery, + value_objects::MovieId, +}; + +use super::SqlitePersonAdapter; +use super::rows::{CastRow, CrewRow, PersonRow}; + +#[async_trait] +impl PersonQuery for SqlitePersonAdapter { + async fn get_by_id(&self, id: &PersonId) -> Result, DomainError> { + let row = sqlx::query_as::<_, PersonRow>( + "SELECT id, external_id, name, known_for_department, profile_path, biography, birthday, deathday, place_of_birth, also_known_as, homepage, imdb_id, enriched_at FROM persons WHERE id = ?", + ) + .bind(id.value().to_string()) + .fetch_optional(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + Ok(row.map(PersonRow::into_person)) + } + + async fn get_by_external_id( + &self, + id: &ExternalPersonId, + ) -> Result, DomainError> { + let row = sqlx::query_as::<_, PersonRow>( + "SELECT id, external_id, name, known_for_department, profile_path, biography, birthday, deathday, place_of_birth, also_known_as, homepage, imdb_id, enriched_at FROM persons WHERE external_id = ?", + ) + .bind(id.value()) + .fetch_optional(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + Ok(row.map(PersonRow::into_person)) + } + + async fn get_credits(&self, id: &PersonId) -> Result { + let person = self + .get_by_id(id) + .await? + .ok_or_else(|| DomainError::NotFound(format!("Person {} not found", id.value())))?; + + let tmdb_id: Option = + sqlx::query_scalar("SELECT tmdb_person_id FROM persons WHERE id = ?") + .bind(id.value().to_string()) + .fetch_optional(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)? + .flatten(); + + let Some(tmdb_id) = tmdb_id else { + return Ok(PersonCredits { + person, + cast: vec![], + crew: vec![], + }); + }; + + let cast = sqlx::query_as::<_, CastRow>( + "SELECT m.id, m.title, m.release_year, mc.character, m.poster_path + FROM movie_cast mc + JOIN movies m ON m.id = mc.movie_id + WHERE mc.tmdb_person_id = ? + ORDER BY mc.billing_order", + ) + .bind(tmdb_id) + .fetch_all(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)? + .into_iter() + .map(|r| CastCredit { + movie_id: MovieId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()), + title: r.title, + release_year: r.release_year.map(|y| y as u16), + character: r.character, + poster_path: r.poster_path, + }) + .collect(); + + let crew = sqlx::query_as::<_, CrewRow>( + "SELECT m.id, m.title, m.release_year, mc.job, mc.department, m.poster_path + FROM movie_crew mc + JOIN movies m ON m.id = mc.movie_id + WHERE mc.tmdb_person_id = ? + ORDER BY m.title", + ) + .bind(tmdb_id) + .fetch_all(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)? + .into_iter() + .map(|r| CrewCredit { + movie_id: MovieId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()), + title: r.title, + release_year: r.release_year.map(|y| y as u16), + job: r.job, + department: r.department, + poster_path: r.poster_path, + }) + .collect(); + + Ok(PersonCredits { person, cast, crew }) + } + + async fn list_page(&self, limit: u32, offset: u32) -> Result, DomainError> { + let rows = sqlx::query_as::<_, PersonRow>( + "SELECT id, external_id, name, known_for_department, profile_path, biography, birthday, deathday, place_of_birth, also_known_as, homepage, imdb_id, enriched_at FROM persons ORDER BY id LIMIT ? OFFSET ?", + ) + .bind(limit) + .bind(offset) + .fetch_all(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + Ok(rows.into_iter().map(PersonRow::into_person).collect()) + } + + async fn list_orphaned_persons(&self) -> Result, DomainError> { + let rows: Vec<(String,)> = sqlx::query_as( + "SELECT id FROM persons + WHERE NOT EXISTS ( + SELECT 1 FROM movie_cast WHERE movie_cast.tmdb_person_id = persons.tmdb_person_id + ) + AND NOT EXISTS ( + SELECT 1 FROM movie_crew WHERE movie_crew.tmdb_person_id = persons.tmdb_person_id + ) + LIMIT 500", + ) + .fetch_all(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + Ok(rows + .into_iter() + .filter_map(|(id,)| uuid::Uuid::parse_str(&id).ok().map(PersonId::from_uuid)) + .collect()) + } +} diff --git a/crates/adapters/sqlite/src/repositories/persons/rows.rs b/crates/adapters/sqlite/src/repositories/persons/rows.rs new file mode 100644 index 00000000..903705c6 --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/persons/rows.rs @@ -0,0 +1,72 @@ +use domain::models::{ExternalPersonId, Person, PersonId}; + +#[derive(sqlx::FromRow)] +pub(super) struct PersonRow { + pub(super) id: String, + pub(super) external_id: String, + pub(super) name: String, + pub(super) known_for_department: Option, + pub(super) profile_path: Option, + pub(super) biography: Option, + pub(super) birthday: Option, + pub(super) deathday: Option, + pub(super) place_of_birth: Option, + pub(super) also_known_as: Option, + pub(super) homepage: Option, + pub(super) imdb_id: Option, + pub(super) enriched_at: Option, +} + +impl PersonRow { + pub(super) fn into_person(self) -> Person { + let ext = ExternalPersonId::new(self.external_id); + let also_known_as = self + .also_known_as + .and_then(|s| serde_json::from_str::>(&s).ok()) + .unwrap_or_default(); + let birthday = self + .birthday + .and_then(|s| chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()); + let deathday = self + .deathday + .and_then(|s| chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()); + let enriched_at = self + .enriched_at + .and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok()) + .map(|d| d.with_timezone(&chrono::Utc)); + Person::new( + PersonId::from_uuid(uuid::Uuid::parse_str(&self.id).unwrap_or_default()), + ext, + self.name, + self.known_for_department, + self.profile_path, + self.biography, + birthday, + deathday, + self.place_of_birth, + also_known_as, + self.homepage, + self.imdb_id, + enriched_at, + ) + } +} + +#[derive(sqlx::FromRow)] +pub(super) struct CastRow { + pub(super) id: String, + pub(super) title: String, + pub(super) release_year: Option, + pub(super) character: String, + pub(super) poster_path: Option, +} + +#[derive(sqlx::FromRow)] +pub(super) struct CrewRow { + pub(super) id: String, + pub(super) title: String, + pub(super) release_year: Option, + pub(super) job: String, + pub(super) department: String, + pub(super) poster_path: Option, +} diff --git a/crates/adapters/sqlite/src/tests/persons.rs b/crates/adapters/sqlite/src/repositories/persons/tests.rs similarity index 98% rename from crates/adapters/sqlite/src/tests/persons.rs rename to crates/adapters/sqlite/src/repositories/persons/tests.rs index 1589b1d2..3dff2026 100644 --- a/crates/adapters/sqlite/src/tests/persons.rs +++ b/crates/adapters/sqlite/src/repositories/persons/tests.rs @@ -1,4 +1,4 @@ -use super::super::persons::SqlitePersonAdapter; +use super::SqlitePersonAdapter; use domain::{ models::{ExternalPersonId, Person, PersonId}, ports::{PersonCommand, PersonQuery}, diff --git a/crates/adapters/sqlite/src/profile.rs b/crates/adapters/sqlite/src/repositories/profile/mod.rs similarity index 99% rename from crates/adapters/sqlite/src/profile.rs rename to crates/adapters/sqlite/src/repositories/profile/mod.rs index e518d3ef..9b827dd2 100644 --- a/crates/adapters/sqlite/src/profile.rs +++ b/crates/adapters/sqlite/src/repositories/profile/mod.rs @@ -300,5 +300,4 @@ impl MovieProfileRepository for SqliteMovieProfileRepository { } #[cfg(test)] -#[path = "tests/profile.rs"] mod tests; diff --git a/crates/adapters/sqlite/src/tests/profile.rs b/crates/adapters/sqlite/src/repositories/profile/tests.rs similarity index 98% rename from crates/adapters/sqlite/src/tests/profile.rs rename to crates/adapters/sqlite/src/repositories/profile/tests.rs index bf19abc0..2a85a1c0 100644 --- a/crates/adapters/sqlite/src/tests/profile.rs +++ b/crates/adapters/sqlite/src/repositories/profile/tests.rs @@ -1,4 +1,4 @@ -use super::super::profile::SqliteMovieProfileRepository; +use super::SqliteMovieProfileRepository; use domain::{ports::MovieProfileRepository, value_objects::MovieId}; use sqlx::SqlitePool; diff --git a/crates/adapters/sqlite/src/profile_fields.rs b/crates/adapters/sqlite/src/repositories/profile_fields/mod.rs similarity index 100% rename from crates/adapters/sqlite/src/profile_fields.rs rename to crates/adapters/sqlite/src/repositories/profile_fields/mod.rs diff --git a/crates/adapters/sqlite/src/refresh_sessions.rs b/crates/adapters/sqlite/src/repositories/refresh_sessions/mod.rs similarity index 100% rename from crates/adapters/sqlite/src/refresh_sessions.rs rename to crates/adapters/sqlite/src/repositories/refresh_sessions/mod.rs diff --git a/crates/adapters/sqlite/src/social/remote_goals.rs b/crates/adapters/sqlite/src/repositories/remote_goals/mod.rs similarity index 100% rename from crates/adapters/sqlite/src/social/remote_goals.rs rename to crates/adapters/sqlite/src/repositories/remote_goals/mod.rs diff --git a/crates/adapters/sqlite/src/review.rs b/crates/adapters/sqlite/src/repositories/review/mod.rs similarity index 98% rename from crates/adapters/sqlite/src/review.rs rename to crates/adapters/sqlite/src/repositories/review/mod.rs index 9fc2c6b5..ca1f8e69 100644 --- a/crates/adapters/sqlite/src/review.rs +++ b/crates/adapters/sqlite/src/repositories/review/mod.rs @@ -7,7 +7,9 @@ use domain::{ }; use sqlx::SqlitePool; -use crate::models::ReviewRow; +use rows::ReviewRow; + +pub(crate) mod rows; use crate::shared::datetime_to_str; pub struct SqliteReviewRepository { diff --git a/crates/adapters/sqlite/src/repositories/review/rows.rs b/crates/adapters/sqlite/src/repositories/review/rows.rs new file mode 100644 index 00000000..b6943e7a --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/review/rows.rs @@ -0,0 +1,33 @@ +use crate::shared::review_row_to_domain; +use domain::{errors::DomainError, models::Review}; + +#[derive(sqlx::FromRow)] +pub(crate) struct ReviewRow { + pub id: String, + pub movie_id: String, + pub user_id: String, + pub rating: i64, + pub comment: Option, + pub watched_at: String, + pub created_at: String, + pub remote_actor_url: Option, + pub watch_medium: Option, + pub spoilers: bool, +} + +impl ReviewRow { + pub fn into_domain(self) -> Result { + review_row_to_domain( + self.id, + self.movie_id, + self.user_id, + self.rating, + self.comment, + self.watched_at, + self.created_at, + self.remote_actor_url, + self.watch_medium, + self.spoilers, + ) + } +} diff --git a/crates/adapters/sqlite/src/repositories/search/command.rs b/crates/adapters/sqlite/src/repositories/search/command.rs new file mode 100644 index 00000000..78753479 --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/search/command.rs @@ -0,0 +1,133 @@ +use async_trait::async_trait; +use domain::{ + errors::DomainError, + models::{EntityType, IndexableDocument}, + ports::SearchCommand, +}; + +use super::SqliteSearchAdapter; + +#[async_trait] +impl SearchCommand for SqliteSearchAdapter { + async fn index(&self, doc: IndexableDocument) -> Result<(), DomainError> { + match doc { + IndexableDocument::Movie { id, movie, profile } => { + let movie_id = id.value().to_string(); + let title = movie.title().value().to_string(); + let director = movie.director().unwrap_or("").to_string(); + let release_year = movie.release_year().value() as i64; + let (overview, genres, keywords, cast_names, crew_names, language) = + match profile.as_deref() { + Some(p) => ( + p.overview.clone().unwrap_or_default(), + p.genres + .iter() + .map(|g| g.name.as_str()) + .collect::>() + .join(" "), + p.keywords + .iter() + .map(|k| k.name.as_str()) + .collect::>() + .join(" "), + p.cast + .iter() + .map(|c| c.name.as_str()) + .collect::>() + .join(" "), + p.crew + .iter() + .map(|c| c.name.as_str()) + .collect::>() + .join(" "), + p.original_language.clone().unwrap_or_default(), + ), + None => ( + String::new(), + String::new(), + String::new(), + String::new(), + String::new(), + String::new(), + ), + }; + + sqlx::query( + "DELETE FROM movies_fts WHERE rowid = (SELECT rowid FROM movies_fts WHERE movie_id = ? LIMIT 1)", + ) + .bind(&movie_id) + .execute(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + sqlx::query( + "INSERT INTO movies_fts(movie_id, title, director, overview, genres, keywords, cast_names, crew_names, release_year, language) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&movie_id) + .bind(&title) + .bind(&director) + .bind(&overview) + .bind(&genres) + .bind(&keywords) + .bind(&cast_names) + .bind(&crew_names) + .bind(release_year) + .bind(&language) + .execute(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + Ok(()) + } + + IndexableDocument::Person { id, person } => { + let person_id = id.value().to_string(); + + sqlx::query( + "DELETE FROM people_fts WHERE rowid = (SELECT rowid FROM people_fts WHERE person_id = ? LIMIT 1)", + ) + .bind(&person_id) + .execute(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + sqlx::query( + "INSERT INTO people_fts(person_id, name, known_for_department) VALUES (?, ?, ?)", + ) + .bind(&person_id) + .bind(person.name()) + .bind(person.known_for_department()) + .execute(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + Ok(()) + } + } + } + + async fn remove(&self, entity_type: EntityType, id: &str) -> Result<(), DomainError> { + match entity_type { + EntityType::Movie => { + sqlx::query( + "DELETE FROM movies_fts WHERE rowid = (SELECT rowid FROM movies_fts WHERE movie_id = ? LIMIT 1)", + ) + .bind(id) + .execute(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + } + EntityType::Person => { + sqlx::query( + "DELETE FROM people_fts WHERE rowid = (SELECT rowid FROM people_fts WHERE person_id = ? LIMIT 1)", + ) + .bind(id) + .execute(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + } + } + Ok(()) + } +} diff --git a/crates/adapters/sqlite/src/repositories/search/mod.rs b/crates/adapters/sqlite/src/repositories/search/mod.rs new file mode 100644 index 00000000..9dd37f91 --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/search/mod.rs @@ -0,0 +1,27 @@ +use domain::ports::{SearchCommand, SearchPort}; +use sqlx::SqlitePool; +use std::sync::Arc; + +mod command; +mod query; + +pub struct SqliteSearchAdapter { + pool: SqlitePool, +} + +impl SqliteSearchAdapter { + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } +} + +pub fn create_search_adapter(pool: SqlitePool) -> (Arc, Arc) { + let adapter = Arc::new(SqliteSearchAdapter::new(pool)); + ( + Arc::clone(&adapter) as Arc, + adapter as Arc, + ) +} + +#[cfg(test)] +mod tests; diff --git a/crates/adapters/sqlite/src/search.rs b/crates/adapters/sqlite/src/repositories/search/query.rs similarity index 60% rename from crates/adapters/sqlite/src/search.rs rename to crates/adapters/sqlite/src/repositories/search/query.rs index 528f5520..989a6f5c 100644 --- a/crates/adapters/sqlite/src/search.rs +++ b/crates/adapters/sqlite/src/repositories/search/query.rs @@ -1,160 +1,13 @@ -use std::sync::Arc; - use async_trait::async_trait; use domain::{ errors::DomainError, models::PersonId, - models::{ - EntityType, IndexableDocument, MovieSearchHit, PersonSearchHit, SearchQuery, SearchResults, - collections::Paginated, - }, - ports::{SearchCommand, SearchPort}, + models::{MovieSearchHit, PersonSearchHit, SearchQuery, SearchResults, collections::Paginated}, + ports::SearchPort, value_objects::MovieId, }; -use sqlx::SqlitePool; -pub struct SqliteSearchAdapter { - pool: SqlitePool, -} - -impl SqliteSearchAdapter { - pub fn new(pool: SqlitePool) -> Self { - Self { pool } - } -} - -pub fn create_search_adapter(pool: SqlitePool) -> (Arc, Arc) { - let adapter = Arc::new(SqliteSearchAdapter::new(pool)); - ( - Arc::clone(&adapter) as Arc, - adapter as Arc, - ) -} - -#[async_trait] -impl SearchCommand for SqliteSearchAdapter { - async fn index(&self, doc: IndexableDocument) -> Result<(), DomainError> { - match doc { - IndexableDocument::Movie { id, movie, profile } => { - let movie_id = id.value().to_string(); - let title = movie.title().value().to_string(); - let director = movie.director().unwrap_or("").to_string(); - let release_year = movie.release_year().value() as i64; - let (overview, genres, keywords, cast_names, crew_names, language) = - match profile.as_deref() { - Some(p) => ( - p.overview.clone().unwrap_or_default(), - p.genres - .iter() - .map(|g| g.name.as_str()) - .collect::>() - .join(" "), - p.keywords - .iter() - .map(|k| k.name.as_str()) - .collect::>() - .join(" "), - p.cast - .iter() - .map(|c| c.name.as_str()) - .collect::>() - .join(" "), - p.crew - .iter() - .map(|c| c.name.as_str()) - .collect::>() - .join(" "), - p.original_language.clone().unwrap_or_default(), - ), - None => ( - String::new(), - String::new(), - String::new(), - String::new(), - String::new(), - String::new(), - ), - }; - - sqlx::query( - "DELETE FROM movies_fts WHERE rowid = (SELECT rowid FROM movies_fts WHERE movie_id = ? LIMIT 1)", - ) - .bind(&movie_id) - .execute(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - - sqlx::query( - "INSERT INTO movies_fts(movie_id, title, director, overview, genres, keywords, cast_names, crew_names, release_year, language) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - ) - .bind(&movie_id) - .bind(&title) - .bind(&director) - .bind(&overview) - .bind(&genres) - .bind(&keywords) - .bind(&cast_names) - .bind(&crew_names) - .bind(release_year) - .bind(&language) - .execute(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - - Ok(()) - } - - IndexableDocument::Person { id, person } => { - let person_id = id.value().to_string(); - - sqlx::query( - "DELETE FROM people_fts WHERE rowid = (SELECT rowid FROM people_fts WHERE person_id = ? LIMIT 1)", - ) - .bind(&person_id) - .execute(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - - sqlx::query( - "INSERT INTO people_fts(person_id, name, known_for_department) VALUES (?, ?, ?)", - ) - .bind(&person_id) - .bind(person.name()) - .bind(person.known_for_department()) - .execute(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - - Ok(()) - } - } - } - - async fn remove(&self, entity_type: EntityType, id: &str) -> Result<(), DomainError> { - match entity_type { - EntityType::Movie => { - sqlx::query( - "DELETE FROM movies_fts WHERE rowid = (SELECT rowid FROM movies_fts WHERE movie_id = ? LIMIT 1)", - ) - .bind(id) - .execute(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - } - EntityType::Person => { - sqlx::query( - "DELETE FROM people_fts WHERE rowid = (SELECT rowid FROM people_fts WHERE person_id = ? LIMIT 1)", - ) - .bind(id) - .execute(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - } - } - Ok(()) - } -} +use super::SqliteSearchAdapter; #[async_trait] impl SearchPort for SqliteSearchAdapter { @@ -389,6 +242,3 @@ impl SqliteSearchAdapter { }) } } - -#[cfg(test)] -mod tests; diff --git a/crates/adapters/sqlite/src/search/tests.rs b/crates/adapters/sqlite/src/repositories/search/tests.rs similarity index 100% rename from crates/adapters/sqlite/src/search/tests.rs rename to crates/adapters/sqlite/src/repositories/search/tests.rs diff --git a/crates/adapters/sqlite/src/social/federated_profile.rs b/crates/adapters/sqlite/src/repositories/social/federated_profile.rs similarity index 100% rename from crates/adapters/sqlite/src/social/federated_profile.rs rename to crates/adapters/sqlite/src/repositories/social/federated_profile.rs diff --git a/crates/adapters/sqlite/src/repositories/social/follow/command.rs b/crates/adapters/sqlite/src/repositories/social/follow/command.rs new file mode 100644 index 00000000..7424c2a7 --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/social/follow/command.rs @@ -0,0 +1,130 @@ +use async_trait::async_trait; +use chrono::Utc; +use domain::{errors::DomainError, value_objects::FollowStatus}; + +use crate::shared::datetime_to_str; + +use super::infra_err; +use super::rows::follow_status_to_str; +use crate::repositories::social::SqliteSocialRepository; + +#[async_trait] +impl domain::ports::FollowCommand for SqliteSocialRepository { + async fn add_follow( + &self, + follower_id: uuid::Uuid, + target_actor_url: &str, + status: FollowStatus, + ) -> Result<(), DomainError> { + let uid = follower_id.to_string(); + let status_str = follow_status_to_str(&status); + let now = datetime_to_str(&Utc::now().naive_utc()); + sqlx::query( + "INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, created_at, status) + VALUES (?1, ?2, '', ?3, ?4) + ON CONFLICT(local_user_id, remote_actor_url) DO UPDATE SET status = excluded.status", + ) + .bind(&uid) + .bind(target_actor_url) + .bind(&now) + .bind(status_str) + .execute(&self.pool) + .await + .map_err(infra_err)?; + Ok(()) + } + + async fn update_follow_status( + &self, + follower_id: uuid::Uuid, + target_actor_url: &str, + status: FollowStatus, + ) -> Result<(), DomainError> { + let uid = follower_id.to_string(); + let status_str = follow_status_to_str(&status); + sqlx::query( + "UPDATE ap_following SET status = ?1 WHERE local_user_id = ?2 AND remote_actor_url = ?3", + ) + .bind(status_str) + .bind(&uid) + .bind(target_actor_url) + .execute(&self.pool) + .await + .map_err(infra_err)?; + Ok(()) + } + + async fn remove_follow( + &self, + follower_id: uuid::Uuid, + target_actor_url: &str, + ) -> Result<(), DomainError> { + let uid = follower_id.to_string(); + sqlx::query("DELETE FROM ap_following WHERE local_user_id = ?1 AND remote_actor_url = ?2") + .bind(&uid) + .bind(target_actor_url) + .execute(&self.pool) + .await + .map_err(infra_err)?; + Ok(()) + } + + async fn add_follower( + &self, + local_user_id: uuid::Uuid, + follower_actor_url: &str, + status: FollowStatus, + ) -> Result<(), DomainError> { + let uid = local_user_id.to_string(); + let status_str = follow_status_to_str(&status); + let now = datetime_to_str(&Utc::now().naive_utc()); + sqlx::query( + "INSERT INTO ap_followers (local_user_id, remote_actor_url, status, created_at, follow_activity_id) + VALUES (?1, ?2, ?3, ?4, '') + ON CONFLICT(local_user_id, remote_actor_url) DO UPDATE SET status = excluded.status", + ) + .bind(&uid) + .bind(follower_actor_url) + .bind(status_str) + .bind(&now) + .execute(&self.pool) + .await + .map_err(infra_err)?; + Ok(()) + } + + async fn update_follower_status( + &self, + local_user_id: uuid::Uuid, + follower_actor_url: &str, + status: FollowStatus, + ) -> Result<(), DomainError> { + let uid = local_user_id.to_string(); + let status_str = follow_status_to_str(&status); + sqlx::query( + "UPDATE ap_followers SET status = ?1 WHERE local_user_id = ?2 AND remote_actor_url = ?3", + ) + .bind(status_str) + .bind(&uid) + .bind(follower_actor_url) + .execute(&self.pool) + .await + .map_err(infra_err)?; + Ok(()) + } + + async fn remove_follower_record( + &self, + local_user_id: uuid::Uuid, + follower_actor_url: &str, + ) -> Result<(), DomainError> { + let uid = local_user_id.to_string(); + sqlx::query("DELETE FROM ap_followers WHERE local_user_id = ?1 AND remote_actor_url = ?2") + .bind(&uid) + .bind(follower_actor_url) + .execute(&self.pool) + .await + .map_err(infra_err)?; + Ok(()) + } +} diff --git a/crates/adapters/sqlite/src/repositories/social/follow/mod.rs b/crates/adapters/sqlite/src/repositories/social/follow/mod.rs new file mode 100644 index 00000000..5a4e1d74 --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/social/follow/mod.rs @@ -0,0 +1,9 @@ +use domain::errors::DomainError; + +mod command; +mod query; +mod rows; + +fn infra_err(e: impl std::fmt::Display) -> DomainError { + DomainError::InfrastructureError(e.to_string()) +} diff --git a/crates/adapters/sqlite/src/social/follow_repository.rs b/crates/adapters/sqlite/src/repositories/social/follow/query.rs similarity index 51% rename from crates/adapters/sqlite/src/social/follow_repository.rs rename to crates/adapters/sqlite/src/repositories/social/follow/query.rs index 506c9c7c..05e7c3ea 100644 --- a/crates/adapters/sqlite/src/social/follow_repository.rs +++ b/crates/adapters/sqlite/src/repositories/social/follow/query.rs @@ -1,197 +1,13 @@ use async_trait::async_trait; -use chrono::Utc; use domain::{ errors::DomainError, - value_objects::{FollowRelation, FollowStatus, InstanceIdentity, SocialActor, SocialIdentity}, + value_objects::{FollowRelation, SocialActor}, }; use sqlx::Row; -use crate::SqliteSocialRepository; -use crate::shared::datetime_to_str; - -fn follow_status_to_str(status: &FollowStatus) -> &'static str { - match status { - FollowStatus::Pending => "pending", - FollowStatus::Accepted => "accepted", - FollowStatus::Rejected => "rejected", - } -} - -fn infra_err(e: impl std::fmt::Display) -> DomainError { - DomainError::InfrastructureError(e.to_string()) -} - -fn follow_status_from_str(status: &str) -> Option { - match status { - "pending" => Some(FollowStatus::Pending), - "accepted" => Some(FollowStatus::Accepted), - "rejected" => Some(FollowStatus::Rejected), - _ => None, - } -} - -#[async_trait] -impl domain::ports::FollowCommand for SqliteSocialRepository { - async fn add_follow( - &self, - follower_id: uuid::Uuid, - target_actor_url: &str, - status: FollowStatus, - ) -> Result<(), DomainError> { - let uid = follower_id.to_string(); - let status_str = follow_status_to_str(&status); - let now = datetime_to_str(&Utc::now().naive_utc()); - sqlx::query( - "INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, created_at, status) - VALUES (?1, ?2, '', ?3, ?4) - ON CONFLICT(local_user_id, remote_actor_url) DO UPDATE SET status = excluded.status", - ) - .bind(&uid) - .bind(target_actor_url) - .bind(&now) - .bind(status_str) - .execute(&self.pool) - .await - .map_err(infra_err)?; - Ok(()) - } - - async fn update_follow_status( - &self, - follower_id: uuid::Uuid, - target_actor_url: &str, - status: FollowStatus, - ) -> Result<(), DomainError> { - let uid = follower_id.to_string(); - let status_str = follow_status_to_str(&status); - sqlx::query( - "UPDATE ap_following SET status = ?1 WHERE local_user_id = ?2 AND remote_actor_url = ?3", - ) - .bind(status_str) - .bind(&uid) - .bind(target_actor_url) - .execute(&self.pool) - .await - .map_err(infra_err)?; - Ok(()) - } - - async fn remove_follow( - &self, - follower_id: uuid::Uuid, - target_actor_url: &str, - ) -> Result<(), DomainError> { - let uid = follower_id.to_string(); - sqlx::query("DELETE FROM ap_following WHERE local_user_id = ?1 AND remote_actor_url = ?2") - .bind(&uid) - .bind(target_actor_url) - .execute(&self.pool) - .await - .map_err(infra_err)?; - Ok(()) - } - - async fn add_follower( - &self, - local_user_id: uuid::Uuid, - follower_actor_url: &str, - status: FollowStatus, - ) -> Result<(), DomainError> { - let uid = local_user_id.to_string(); - let status_str = follow_status_to_str(&status); - let now = datetime_to_str(&Utc::now().naive_utc()); - sqlx::query( - "INSERT INTO ap_followers (local_user_id, remote_actor_url, status, created_at, follow_activity_id) - VALUES (?1, ?2, ?3, ?4, '') - ON CONFLICT(local_user_id, remote_actor_url) DO UPDATE SET status = excluded.status", - ) - .bind(&uid) - .bind(follower_actor_url) - .bind(status_str) - .bind(&now) - .execute(&self.pool) - .await - .map_err(infra_err)?; - Ok(()) - } - - async fn update_follower_status( - &self, - local_user_id: uuid::Uuid, - follower_actor_url: &str, - status: FollowStatus, - ) -> Result<(), DomainError> { - let uid = local_user_id.to_string(); - let status_str = follow_status_to_str(&status); - sqlx::query( - "UPDATE ap_followers SET status = ?1 WHERE local_user_id = ?2 AND remote_actor_url = ?3", - ) - .bind(status_str) - .bind(&uid) - .bind(follower_actor_url) - .execute(&self.pool) - .await - .map_err(infra_err)?; - Ok(()) - } - - async fn remove_follower_record( - &self, - local_user_id: uuid::Uuid, - follower_actor_url: &str, - ) -> Result<(), DomainError> { - let uid = local_user_id.to_string(); - sqlx::query("DELETE FROM ap_followers WHERE local_user_id = ?1 AND remote_actor_url = ?2") - .bind(&uid) - .bind(follower_actor_url) - .execute(&self.pool) - .await - .map_err(infra_err)?; - Ok(()) - } -} - -fn social_actor_from_row( - row: &sqlx::sqlite::SqliteRow, - instance: &InstanceIdentity, -) -> SocialActor { - let actor_url: String = row.get("remote_actor_url"); - let identity = instance.identify(&actor_url); - - let (handle, display_name, avatar_url) = match &identity { - SocialIdentity::Local(_) => { - let username: Option = row.try_get("local_username").ok().flatten(); - let display: Option = row.try_get("local_display").ok().flatten(); - let avatar: Option = row - .try_get::, _>("local_avatar_path") - .ok() - .flatten() - .map(|p| instance.image_url_for(&p)); - let handle = username - .as_deref() - .map(|u| instance.handle_for(u)) - .unwrap_or_else(|| actor_url.clone()); - (handle, display, avatar) - } - SocialIdentity::Remote { .. } => { - let handle: String = row - .try_get::, _>("remote_handle") - .ok() - .flatten() - .unwrap_or_else(|| actor_url.clone()); - let display: Option = row.try_get("remote_display").ok().flatten(); - let avatar: Option = row.try_get("remote_avatar").ok().flatten(); - (handle, display, avatar) - } - }; - - SocialActor { - identity, - handle, - display_name, - avatar_url, - } -} +use super::infra_err; +use super::rows::{follow_status_from_str, social_actor_from_row}; +use crate::repositories::social::SqliteSocialRepository; #[async_trait] impl domain::ports::FollowQuery for SqliteSocialRepository { diff --git a/crates/adapters/sqlite/src/repositories/social/follow/rows.rs b/crates/adapters/sqlite/src/repositories/social/follow/rows.rs new file mode 100644 index 00000000..d13bd1a9 --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/social/follow/rows.rs @@ -0,0 +1,61 @@ +use domain::value_objects::{FollowStatus, InstanceIdentity, SocialActor, SocialIdentity}; +use sqlx::Row; + +pub(super) fn follow_status_to_str(status: &FollowStatus) -> &'static str { + match status { + FollowStatus::Pending => "pending", + FollowStatus::Accepted => "accepted", + FollowStatus::Rejected => "rejected", + } +} + +pub(super) fn follow_status_from_str(status: &str) -> Option { + match status { + "pending" => Some(FollowStatus::Pending), + "accepted" => Some(FollowStatus::Accepted), + "rejected" => Some(FollowStatus::Rejected), + _ => None, + } +} + +pub(super) fn social_actor_from_row( + row: &sqlx::sqlite::SqliteRow, + instance: &InstanceIdentity, +) -> SocialActor { + let actor_url: String = row.get("remote_actor_url"); + let identity = instance.identify(&actor_url); + + let (handle, display_name, avatar_url) = match &identity { + SocialIdentity::Local(_) => { + let username: Option = row.try_get("local_username").ok().flatten(); + let display: Option = row.try_get("local_display").ok().flatten(); + let avatar: Option = row + .try_get::, _>("local_avatar_path") + .ok() + .flatten() + .map(|p| instance.image_url_for(&p)); + let handle = username + .as_deref() + .map(|u| instance.handle_for(u)) + .unwrap_or_else(|| actor_url.clone()); + (handle, display, avatar) + } + SocialIdentity::Remote { .. } => { + let handle: String = row + .try_get::, _>("remote_handle") + .ok() + .flatten() + .unwrap_or_else(|| actor_url.clone()); + let display: Option = row.try_get("remote_display").ok().flatten(); + let avatar: Option = row.try_get("remote_avatar").ok().flatten(); + (handle, display, avatar) + } + }; + + SocialActor { + identity, + handle, + display_name, + avatar_url, + } +} diff --git a/crates/adapters/sqlite/src/social/local_social.rs b/crates/adapters/sqlite/src/repositories/social/local_social.rs similarity index 100% rename from crates/adapters/sqlite/src/social/local_social.rs rename to crates/adapters/sqlite/src/repositories/social/local_social.rs diff --git a/crates/adapters/sqlite/src/social/mod.rs b/crates/adapters/sqlite/src/repositories/social/mod.rs similarity index 73% rename from crates/adapters/sqlite/src/social/mod.rs rename to crates/adapters/sqlite/src/repositories/social/mod.rs index 1f447813..1c003a59 100644 --- a/crates/adapters/sqlite/src/social/mod.rs +++ b/crates/adapters/sqlite/src/repositories/social/mod.rs @@ -1,14 +1,8 @@ mod federated_profile; -mod follow_repository; +mod follow; mod local_social; mod watchlist; -pub mod ap_content; -pub mod remote_goals; - -pub use ap_content::SqliteApContentQuery; -pub use remote_goals::SqliteRemoteGoalRepository; - use sqlx::SqlitePool; pub struct SqliteSocialRepository { @@ -30,5 +24,4 @@ pub fn create_federated_profile_query( } #[cfg(test)] -#[path = "tests/follow_relation_tests.rs"] -mod follow_relation_tests; +mod tests; diff --git a/crates/adapters/sqlite/src/social/tests/follow_relation_tests.rs b/crates/adapters/sqlite/src/repositories/social/tests.rs similarity index 100% rename from crates/adapters/sqlite/src/social/tests/follow_relation_tests.rs rename to crates/adapters/sqlite/src/repositories/social/tests.rs diff --git a/crates/adapters/sqlite/src/social/watchlist.rs b/crates/adapters/sqlite/src/repositories/social/watchlist.rs similarity index 100% rename from crates/adapters/sqlite/src/social/watchlist.rs rename to crates/adapters/sqlite/src/repositories/social/watchlist.rs diff --git a/crates/adapters/sqlite/src/stats.rs b/crates/adapters/sqlite/src/repositories/stats/mod.rs similarity index 98% rename from crates/adapters/sqlite/src/stats.rs rename to crates/adapters/sqlite/src/repositories/stats/mod.rs index 75d39b4f..a45ba6c2 100644 --- a/crates/adapters/sqlite/src/stats.rs +++ b/crates/adapters/sqlite/src/repositories/stats/mod.rs @@ -9,11 +9,13 @@ use domain::{ }; use sqlx::SqlitePool; -use crate::models::{ +use rows::{ ActorAppearanceRow, DirectorCountRow, GenreCountRow, MonthlyRatingRow, RatingDistRow, UserTotalsRow, WatchMediumCountRow, }; +mod rows; + pub struct SqliteStatsRepository { pool: SqlitePool, } @@ -97,7 +99,7 @@ impl StatsRepository for SqliteStatsRepository { } async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result { - crate::goals::count_reviews_in_year(&self.pool, user_id, year).await + crate::repositories::goals::count_reviews_in_year(&self.pool, user_id, year).await } async fn get_user_trends(&self, user_id: &UserId) -> Result { diff --git a/crates/adapters/sqlite/src/repositories/stats/rows.rs b/crates/adapters/sqlite/src/repositories/stats/rows.rs new file mode 100644 index 00000000..38ea237f --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/stats/rows.rs @@ -0,0 +1,45 @@ +#[derive(sqlx::FromRow)] +pub(crate) struct UserTotalsRow { + pub total: i64, + pub avg_rating: Option, +} + +#[derive(sqlx::FromRow)] +pub(crate) struct DirectorCountRow { + pub director: String, + pub count: i64, +} + +#[derive(sqlx::FromRow)] +pub(crate) struct MonthlyRatingRow { + pub month: String, + pub avg_rating: f64, + pub count: i64, +} + +#[derive(sqlx::FromRow)] +pub(crate) struct GenreCountRow { + pub genre: String, + pub count: i64, +} + +#[derive(sqlx::FromRow)] +pub(crate) struct RatingDistRow { + pub rating: i64, + pub count: i64, +} + +#[derive(sqlx::FromRow)] +pub(crate) struct WatchMediumCountRow { + pub watch_medium: String, + pub count: i64, +} + +#[derive(sqlx::FromRow)] +pub(crate) struct ActorAppearanceRow { + pub tmdb_person_id: i64, + pub name: String, + pub profile_path: Option, + pub billing_order: i32, + pub movie_id: String, +} diff --git a/crates/adapters/sqlite/src/user_settings.rs b/crates/adapters/sqlite/src/repositories/user_settings/mod.rs similarity index 100% rename from crates/adapters/sqlite/src/user_settings.rs rename to crates/adapters/sqlite/src/repositories/user_settings/mod.rs diff --git a/crates/adapters/sqlite/src/users.rs b/crates/adapters/sqlite/src/repositories/users/mod.rs similarity index 99% rename from crates/adapters/sqlite/src/users.rs rename to crates/adapters/sqlite/src/repositories/users/mod.rs index 96793d0d..d69b8be8 100644 --- a/crates/adapters/sqlite/src/users.rs +++ b/crates/adapters/sqlite/src/repositories/users/mod.rs @@ -2,7 +2,9 @@ use async_trait::async_trait; use chrono::Utc; use sqlx::{Row, SqlitePool}; -use super::models::UserSummaryRow; +use rows::UserSummaryRow; + +mod rows; use domain::{ errors::DomainError, models::{ProfileField, User, UserRole}, @@ -197,5 +199,4 @@ impl UserRepository for SqliteUserRepository { } #[cfg(test)] -#[path = "tests/users.rs"] mod tests; diff --git a/crates/adapters/sqlite/src/repositories/users/rows.rs b/crates/adapters/sqlite/src/repositories/users/rows.rs new file mode 100644 index 00000000..6a36cba6 --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/users/rows.rs @@ -0,0 +1,27 @@ +use crate::shared::user_summary_to_domain; +use domain::errors::DomainError; + +#[derive(sqlx::FromRow)] +pub(crate) struct UserSummaryRow { + pub id: String, + pub email: String, + pub username: String, + pub display_name: Option, + pub total_movies: i64, + pub avg_rating: Option, + pub avatar_path: Option, +} + +impl UserSummaryRow { + pub fn into_domain(self) -> Result { + user_summary_to_domain( + self.id, + self.email, + self.username, + self.display_name, + self.total_movies, + self.avg_rating, + self.avatar_path, + ) + } +} diff --git a/crates/adapters/sqlite/src/tests/users.rs b/crates/adapters/sqlite/src/repositories/users/tests.rs similarity index 100% rename from crates/adapters/sqlite/src/tests/users.rs rename to crates/adapters/sqlite/src/repositories/users/tests.rs diff --git a/crates/adapters/sqlite/src/repositories/watch_event/command.rs b/crates/adapters/sqlite/src/repositories/watch_event/command.rs new file mode 100644 index 00000000..4d20916d --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/watch_event/command.rs @@ -0,0 +1,101 @@ +use async_trait::async_trait; +use domain::{ + errors::DomainError, + models::{WatchEvent, WatchEventStatus}, + ports::WatchEventCommand, + value_objects::WatchEventId, +}; + +use crate::shared::datetime_to_str; + +use super::SqliteWatchEventRepository; + +#[async_trait] +impl WatchEventCommand for SqliteWatchEventRepository { + async fn save(&self, event: &WatchEvent) -> Result<(), DomainError> { + let id = event.id().value().to_string(); + let user_id = event.user_id().value().to_string(); + let movie_id = event.movie_id().map(|m| m.value().to_string()); + let source = event.source().to_string(); + let watched_at = datetime_to_str(event.watched_at()); + let status = event.status().to_string(); + let created_at = datetime_to_str(event.created_at()); + + sqlx::query( + "INSERT INTO watch_events \ + (id, user_id, movie_id, title, year, external_metadata_id, source, watched_at, status, created_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&id) + .bind(&user_id) + .bind(&movie_id) + .bind(event.title()) + .bind(event.year().map(|y| y as i64)) + .bind(event.external_metadata_id()) + .bind(&source) + .bind(&watched_at) + .bind(&status) + .bind(&created_at) + .execute(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + Ok(()) + } + + async fn update_status( + &self, + id: &WatchEventId, + status: WatchEventStatus, + ) -> Result<(), DomainError> { + let id_str = id.value().to_string(); + let status_str = status.to_string(); + + sqlx::query("UPDATE watch_events SET status = ? WHERE id = ?") + .bind(&status_str) + .bind(&id_str) + .execute(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + Ok(()) + } + + async fn update_status_batch( + &self, + ids: &[WatchEventId], + status: WatchEventStatus, + ) -> Result { + if ids.is_empty() { + return Ok(0); + } + let placeholders: Vec<&str> = ids.iter().map(|_| "?").collect(); + let sql = format!( + "UPDATE watch_events SET status = ? WHERE id IN ({})", + placeholders.join(",") + ); + let mut q = sqlx::query(&sql).bind(status.to_string()); + for id in ids { + q = q.bind(id.value().to_string()); + } + let result = q + .execute(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + Ok(result.rows_affected()) + } + + async fn delete_non_pending_older_than( + &self, + before: chrono::NaiveDateTime, + ) -> Result { + 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(crate::shared::map_sqlx_error)?; + Ok(result.rows_affected()) + } +} diff --git a/crates/adapters/sqlite/src/repositories/watch_event/mod.rs b/crates/adapters/sqlite/src/repositories/watch_event/mod.rs new file mode 100644 index 00000000..c86b2da2 --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/watch_event/mod.rs @@ -0,0 +1,18 @@ +use sqlx::SqlitePool; + +mod command; +mod query; +mod rows; +mod webhook_token; + +pub use webhook_token::SqliteWebhookTokenRepository; + +pub struct SqliteWatchEventRepository { + pool: SqlitePool, +} + +impl SqliteWatchEventRepository { + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } +} diff --git a/crates/adapters/sqlite/src/repositories/watch_event/query.rs b/crates/adapters/sqlite/src/repositories/watch_event/query.rs new file mode 100644 index 00000000..c8331053 --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/watch_event/query.rs @@ -0,0 +1,97 @@ +use async_trait::async_trait; +use domain::{ + errors::DomainError, + models::WatchEvent, + ports::WatchEventQuery, + value_objects::{UserId, WatchEventId}, +}; +use sqlx::Row; + +use crate::shared::datetime_to_str; + +use super::SqliteWatchEventRepository; +use super::rows::row_to_watch_event; + +#[async_trait] +impl WatchEventQuery for SqliteWatchEventRepository { + async fn list_pending(&self, user_id: &UserId) -> Result, 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(crate::shared::map_sqlx_error)?; + + rows.iter().map(row_to_watch_event).collect() + } + + async fn get_by_id(&self, id: &WatchEventId) -> Result, 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(crate::shared::map_sqlx_error)?; + + row.as_ref().map(row_to_watch_event).transpose() + } + + async fn get_by_ids(&self, ids: &[WatchEventId]) -> Result, 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(crate::shared::map_sqlx_error)?; + rows.iter().map(row_to_watch_event).collect() + } + + async fn find_duplicate( + &self, + user_id: &UserId, + external_id: &str, + after: chrono::NaiveDateTime, + ) -> Result { + let uid = user_id.value().to_string(); + let after_str = datetime_to_str(&after); + + let count: i64 = sqlx::query( + "SELECT COUNT(*) FROM watch_events \ + WHERE user_id = ? AND external_metadata_id = ? AND created_at > ?", + ) + .bind(&uid) + .bind(external_id) + .bind(&after_str) + .fetch_one(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)? + .try_get(0) + .unwrap_or(0); + + Ok(count > 0) + } +} diff --git a/crates/adapters/sqlite/src/repositories/watch_event/rows.rs b/crates/adapters/sqlite/src/repositories/watch_event/rows.rs new file mode 100644 index 00000000..39dd101c --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/watch_event/rows.rs @@ -0,0 +1,111 @@ +use domain::{ + errors::DomainError, + models::{PersistedWatchEvent, WatchEvent, WatchEventSource, WatchEventStatus, WebhookToken}, + value_objects::{MovieId, UserId, WatchEventId, WebhookTokenId}, +}; +use sqlx::Row; + +pub(super) fn parse_uuid(s: &str) -> Result { + s.parse() + .map_err(|_| DomainError::InfrastructureError(format!("invalid UUID: {s}"))) +} + +pub(super) fn parse_datetime(s: &str) -> Result { + chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") + .or_else(|_| chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S")) + .map_err(|_| DomainError::InfrastructureError(format!("invalid datetime: {s}"))) +} + +pub(super) fn row_to_watch_event(row: &sqlx::sqlite::SqliteRow) -> Result { + let id_str: &str = row.try_get("id").map_err(crate::shared::map_sqlx_error)?; + let user_id_str: &str = row + .try_get("user_id") + .map_err(crate::shared::map_sqlx_error)?; + let movie_id_str: Option<&str> = row + .try_get("movie_id") + .map_err(crate::shared::map_sqlx_error)?; + let title: String = row + .try_get("title") + .map_err(crate::shared::map_sqlx_error)?; + let year: Option = row.try_get("year").map_err(crate::shared::map_sqlx_error)?; + let ext_id: Option = row + .try_get("external_metadata_id") + .map_err(crate::shared::map_sqlx_error)?; + let source_str: String = row + .try_get("source") + .map_err(crate::shared::map_sqlx_error)?; + let watched_at_str: String = row + .try_get("watched_at") + .map_err(crate::shared::map_sqlx_error)?; + let status_str: String = row + .try_get("status") + .map_err(crate::shared::map_sqlx_error)?; + let created_at_str: String = row + .try_get("created_at") + .map_err(crate::shared::map_sqlx_error)?; + + let source: WatchEventSource = source_str + .parse() + .map_err(|e: String| DomainError::InfrastructureError(e))?; + let status: WatchEventStatus = status_str + .parse() + .map_err(|e: String| DomainError::InfrastructureError(e))?; + + let movie_id = movie_id_str + .map(parse_uuid) + .transpose()? + .map(MovieId::from_uuid); + + Ok(WatchEvent::from_persistence(PersistedWatchEvent { + id: WatchEventId::from_uuid(parse_uuid(id_str)?), + user_id: UserId::from_uuid(parse_uuid(user_id_str)?), + movie_id, + title, + year: year.map(|y| y as u16), + external_metadata_id: ext_id, + source, + watched_at: parse_datetime(&watched_at_str)?, + status, + created_at: parse_datetime(&created_at_str)?, + })) +} + +pub(super) fn row_to_webhook_token( + row: &sqlx::sqlite::SqliteRow, +) -> Result { + let id_str: &str = row.try_get("id").map_err(crate::shared::map_sqlx_error)?; + let user_id_str: &str = row + .try_get("user_id") + .map_err(crate::shared::map_sqlx_error)?; + let token_hash: String = row + .try_get("token_hash") + .map_err(crate::shared::map_sqlx_error)?; + let provider_str: String = row + .try_get("provider") + .map_err(crate::shared::map_sqlx_error)?; + let label: Option = row + .try_get("label") + .map_err(crate::shared::map_sqlx_error)?; + let created_at_str: String = row + .try_get("created_at") + .map_err(crate::shared::map_sqlx_error)?; + let last_used_str: Option = row + .try_get("last_used_at") + .map_err(crate::shared::map_sqlx_error)?; + + let provider: WatchEventSource = provider_str + .parse() + .map_err(|e: String| DomainError::InfrastructureError(e))?; + + let last_used = last_used_str.map(|s| parse_datetime(&s)).transpose()?; + + Ok(WebhookToken::from_persistence( + WebhookTokenId::from_uuid(parse_uuid(id_str)?), + UserId::from_uuid(parse_uuid(user_id_str)?), + token_hash, + provider, + label, + parse_datetime(&created_at_str)?, + last_used, + )) +} diff --git a/crates/adapters/sqlite/src/repositories/watch_event/webhook_token.rs b/crates/adapters/sqlite/src/repositories/watch_event/webhook_token.rs new file mode 100644 index 00000000..ccf772ae --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/watch_event/webhook_token.rs @@ -0,0 +1,110 @@ +use async_trait::async_trait; +use domain::{ + errors::DomainError, + models::WebhookToken, + ports::WebhookTokenRepository, + value_objects::{UserId, WebhookTokenId}, +}; +use sqlx::SqlitePool; + +use crate::shared::datetime_to_str; + +use super::rows::row_to_webhook_token; + +pub struct SqliteWebhookTokenRepository { + pool: SqlitePool, +} + +impl SqliteWebhookTokenRepository { + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl WebhookTokenRepository for SqliteWebhookTokenRepository { + async fn save(&self, token: &WebhookToken) -> Result<(), DomainError> { + let id = token.id().value().to_string(); + let user_id = token.user_id().value().to_string(); + let provider = token.provider().to_string(); + let created_at = datetime_to_str(token.created_at()); + let last_used = token.last_used_at().map(datetime_to_str); + + sqlx::query( + "INSERT INTO webhook_tokens \ + (id, user_id, token_hash, provider, label, created_at, last_used_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&id) + .bind(&user_id) + .bind(token.token_hash()) + .bind(&provider) + .bind(token.label()) + .bind(&created_at) + .bind(&last_used) + .execute(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + Ok(()) + } + + async fn find_by_token_hash(&self, hash: &str) -> Result, DomainError> { + let row = sqlx::query( + "SELECT id, user_id, token_hash, provider, label, created_at, last_used_at \ + FROM webhook_tokens WHERE token_hash = ?", + ) + .bind(hash) + .fetch_optional(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + row.as_ref().map(row_to_webhook_token).transpose() + } + + async fn list_by_user(&self, user_id: &UserId) -> Result, DomainError> { + let uid = user_id.value().to_string(); + + let rows = sqlx::query( + "SELECT id, user_id, token_hash, provider, label, created_at, last_used_at \ + FROM webhook_tokens WHERE user_id = ? ORDER BY created_at DESC", + ) + .bind(&uid) + .fetch_all(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + rows.iter().map(row_to_webhook_token).collect() + } + + async fn delete(&self, id: &WebhookTokenId, user_id: &UserId) -> Result<(), DomainError> { + let id_str = id.value().to_string(); + let uid = user_id.value().to_string(); + + let result = sqlx::query("DELETE FROM webhook_tokens WHERE id = ? AND user_id = ?") + .bind(&id_str) + .bind(&uid) + .execute(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + if result.rows_affected() == 0 { + return Err(DomainError::NotFound(format!("Webhook token {id_str}"))); + } + Ok(()) + } + + async fn touch_last_used(&self, id: &WebhookTokenId) -> Result<(), DomainError> { + let id_str = id.value().to_string(); + let now = datetime_to_str(&chrono::Utc::now().naive_utc()); + + sqlx::query("UPDATE webhook_tokens SET last_used_at = ? WHERE id = ?") + .bind(&now) + .bind(&id_str) + .execute(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + Ok(()) + } +} diff --git a/crates/adapters/sqlite/src/watchlist.rs b/crates/adapters/sqlite/src/repositories/watchlist/mod.rs similarity index 99% rename from crates/adapters/sqlite/src/watchlist.rs rename to crates/adapters/sqlite/src/repositories/watchlist/mod.rs index ae78e83c..2485dd53 100644 --- a/crates/adapters/sqlite/src/watchlist.rs +++ b/crates/adapters/sqlite/src/repositories/watchlist/mod.rs @@ -10,7 +10,9 @@ use domain::{ }; use sqlx::{Row, SqlitePool}; -use crate::models::WatchlistRow; +use rows::WatchlistRow; + +mod rows; use crate::shared::datetime_to_str; pub struct SqliteWatchlistRepository { diff --git a/crates/adapters/sqlite/src/repositories/watchlist/rows.rs b/crates/adapters/sqlite/src/repositories/watchlist/rows.rs new file mode 100644 index 00000000..992f0460 --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/watchlist/rows.rs @@ -0,0 +1,33 @@ +use crate::shared::{ + movie_row_to_domain, watchlist_entry_to_domain, watchlist_with_movie_to_domain, +}; +use domain::{errors::DomainError, models::WatchlistWithMovie}; + +#[derive(sqlx::FromRow)] +pub(crate) struct WatchlistRow { + pub id: String, + pub user_id: String, + pub movie_id: String, + pub added_at: String, + pub m_id: String, + pub external_metadata_id: Option, + pub title: String, + pub release_year: i64, + pub director: Option, + pub poster_path: Option, +} + +impl WatchlistRow { + pub fn into_domain(self) -> Result { + let entry = watchlist_entry_to_domain(self.id, self.user_id, self.movie_id, self.added_at)?; + let movie = movie_row_to_domain( + self.m_id, + self.external_metadata_id, + self.title, + self.release_year, + self.director, + self.poster_path, + )?; + Ok(watchlist_with_movie_to_domain(entry, movie)) + } +} diff --git a/crates/adapters/sqlite/src/repositories/wrapup/mod.rs b/crates/adapters/sqlite/src/repositories/wrapup/mod.rs new file mode 100644 index 00000000..b694630e --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/wrapup/mod.rs @@ -0,0 +1,200 @@ +use async_trait::async_trait; +use chrono::NaiveDate; +use domain::{ + errors::DomainError, + models::wrapup::{WrapUpRecord, WrapUpReport, WrapUpStatus}, + ports::WrapUpRepository, + value_objects::WrapUpId, +}; +use sqlx::SqlitePool; +use uuid::Uuid; + +use rows::{row_to_record, status_to_str}; + +mod query; +mod rows; + +pub use query::SqliteWrapUpStatsQuery; + +// ── WrapUpRepository ───────────────────────────────────────────────────────── + +pub struct SqliteWrapUpRepository { + pool: SqlitePool, +} + +impl SqliteWrapUpRepository { + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl WrapUpRepository for SqliteWrapUpRepository { + async fn create(&self, record: &WrapUpRecord) -> Result<(), DomainError> { + let id = record.id.value().to_string(); + let user_id = record.user_id.map(|u| u.to_string()); + let status = status_to_str(&record.status); + let start = record.start_date.format("%Y-%m-%d").to_string(); + let end = record.end_date.format("%Y-%m-%d").to_string(); + let created = record.created_at.format("%Y-%m-%d %H:%M:%S").to_string(); + let completed = record + .completed_at + .map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string()); + + sqlx::query( + "INSERT INTO wrap_up_records \ + (id, user_id, start_date, end_date, status, report_json, error_message, created_at, completed_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&id) + .bind(&user_id) + .bind(&start) + .bind(&end) + .bind(status) + .bind(record.report.as_ref().and_then(|r| serde_json::to_string(r).ok())) + .bind(&record.error_message) + .bind(&created) + .bind(&completed) + .execute(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + Ok(()) + } + + async fn update_status( + &self, + id: &WrapUpId, + status: &WrapUpStatus, + error: Option<&str>, + ) -> Result<(), DomainError> { + let id_str = id.value().to_string(); + let status_str = status_to_str(status); + + sqlx::query("UPDATE wrap_up_records SET status = ?, error_message = ? WHERE id = ?") + .bind(status_str) + .bind(error) + .bind(&id_str) + .execute(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + Ok(()) + } + + async fn set_complete(&self, id: &WrapUpId, report: &WrapUpReport) -> Result<(), DomainError> { + let id_str = id.value().to_string(); + let json = serde_json::to_string(report) + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + + sqlx::query( + "UPDATE wrap_up_records \ + SET status = 'ready', report_json = ?, completed_at = strftime('%Y-%m-%d %H:%M:%S', 'now') \ + WHERE id = ?", + ) + .bind(&json) + .bind(&id_str) + .execute(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + Ok(()) + } + + async fn get_by_id(&self, id: &WrapUpId) -> Result, DomainError> { + let id_str = id.value().to_string(); + + let row = sqlx::query( + "SELECT id, user_id, start_date, end_date, status, report_json, error_message, \ + created_at, completed_at \ + FROM wrap_up_records WHERE id = ?", + ) + .bind(&id_str) + .fetch_optional(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + row.as_ref().map(row_to_record).transpose() + } + + async fn list_for_user(&self, user_id: Uuid) -> Result, DomainError> { + let uid = user_id.to_string(); + + let rows = sqlx::query( + "SELECT id, user_id, start_date, end_date, status, report_json, error_message, \ + created_at, completed_at \ + FROM wrap_up_records WHERE user_id = ? ORDER BY created_at DESC", + ) + .bind(&uid) + .fetch_all(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + rows.iter().map(row_to_record).collect() + } + + async fn list_global(&self) -> Result, DomainError> { + let rows = sqlx::query( + "SELECT id, user_id, start_date, end_date, status, report_json, error_message, \ + created_at, completed_at \ + FROM wrap_up_records WHERE user_id IS NULL ORDER BY created_at DESC", + ) + .fetch_all(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + rows.iter().map(row_to_record).collect() + } + + async fn find_existing( + &self, + user_id: Option, + start: NaiveDate, + end: NaiveDate, + ) -> Result, DomainError> { + let uid = user_id.map(|u| u.to_string()); + let start_str = start.format("%Y-%m-%d").to_string(); + let end_str = end.format("%Y-%m-%d").to_string(); + + let row = sqlx::query( + "SELECT id, user_id, start_date, end_date, status, report_json, error_message, \ + created_at, completed_at \ + FROM wrap_up_records \ + WHERE ((? IS NULL AND user_id IS NULL) OR user_id = ?) \ + AND start_date = ? AND end_date = ? \ + LIMIT 1", + ) + .bind(&uid) + .bind(&uid) + .bind(&start_str) + .bind(&end_str) + .fetch_optional(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + row.as_ref().map(row_to_record).transpose() + } + + async fn delete(&self, id: &WrapUpId) -> Result<(), DomainError> { + sqlx::query("DELETE FROM wrap_up_records WHERE id = ?") + .bind(id.value().to_string()) + .execute(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + Ok(()) + } + + async fn delete_failed_older_than( + &self, + before: chrono::NaiveDateTime, + ) -> Result { + let before_str = before.format("%Y-%m-%dT%H:%M:%SZ").to_string(); + let result = + sqlx::query("DELETE FROM wrap_up_records WHERE status = 'failed' AND created_at < ?") + .bind(&before_str) + .execute(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + Ok(result.rows_affected()) + } +} diff --git a/crates/adapters/sqlite/src/wrapup.rs b/crates/adapters/sqlite/src/repositories/wrapup/query.rs similarity index 51% rename from crates/adapters/sqlite/src/wrapup.rs rename to crates/adapters/sqlite/src/repositories/wrapup/query.rs index 1348a0c2..95416ae9 100644 --- a/crates/adapters/sqlite/src/wrapup.rs +++ b/crates/adapters/sqlite/src/repositories/wrapup/query.rs @@ -1,276 +1,15 @@ use std::collections::HashMap; use async_trait::async_trait; -use chrono::NaiveDate; use domain::{ errors::DomainError, - models::wrapup::{ - DateRange, WrapUpMovieRow, WrapUpRecord, WrapUpReport, WrapUpScope, WrapUpStatus, - }, - ports::{WrapUpRepository, WrapUpStatsQuery}, - value_objects::WrapUpId, + models::wrapup::{DateRange, WrapUpMovieRow, WrapUpScope}, + ports::WrapUpStatsQuery, }; use sqlx::{Row, SqlitePool}; -use uuid::Uuid; use crate::shared::{parse_datetime, parse_uuid}; -fn status_to_str(s: &WrapUpStatus) -> &'static str { - match s { - WrapUpStatus::Pending => "pending", - WrapUpStatus::Generating => "generating", - WrapUpStatus::Ready => "ready", - WrapUpStatus::Failed => "failed", - } -} - -fn parse_status(s: &str) -> Result { - match s { - "pending" => Ok(WrapUpStatus::Pending), - "generating" => Ok(WrapUpStatus::Generating), - "ready" => Ok(WrapUpStatus::Ready), - "failed" => Ok(WrapUpStatus::Failed), - other => Err(DomainError::InfrastructureError(format!( - "Unknown wrap-up status: {other}" - ))), - } -} - -fn parse_date(s: &str) -> Result { - NaiveDate::parse_from_str(s, "%Y-%m-%d") - .map_err(|e| DomainError::InfrastructureError(format!("Invalid date '{s}': {e}"))) -} - -// ── WrapUpRepository ───────────────────────────────────────────────────────── - -pub struct SqliteWrapUpRepository { - pool: SqlitePool, -} - -impl SqliteWrapUpRepository { - pub fn new(pool: SqlitePool) -> Self { - Self { pool } - } -} - -#[async_trait] -impl WrapUpRepository for SqliteWrapUpRepository { - async fn create(&self, record: &WrapUpRecord) -> Result<(), DomainError> { - let id = record.id.value().to_string(); - let user_id = record.user_id.map(|u| u.to_string()); - let status = status_to_str(&record.status); - let start = record.start_date.format("%Y-%m-%d").to_string(); - let end = record.end_date.format("%Y-%m-%d").to_string(); - let created = record.created_at.format("%Y-%m-%d %H:%M:%S").to_string(); - let completed = record - .completed_at - .map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string()); - - sqlx::query( - "INSERT INTO wrap_up_records \ - (id, user_id, start_date, end_date, status, report_json, error_message, created_at, completed_at) \ - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", - ) - .bind(&id) - .bind(&user_id) - .bind(&start) - .bind(&end) - .bind(status) - .bind(record.report.as_ref().and_then(|r| serde_json::to_string(r).ok())) - .bind(&record.error_message) - .bind(&created) - .bind(&completed) - .execute(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - - Ok(()) - } - - async fn update_status( - &self, - id: &WrapUpId, - status: &WrapUpStatus, - error: Option<&str>, - ) -> Result<(), DomainError> { - let id_str = id.value().to_string(); - let status_str = status_to_str(status); - - sqlx::query("UPDATE wrap_up_records SET status = ?, error_message = ? WHERE id = ?") - .bind(status_str) - .bind(error) - .bind(&id_str) - .execute(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - - Ok(()) - } - - async fn set_complete(&self, id: &WrapUpId, report: &WrapUpReport) -> Result<(), DomainError> { - let id_str = id.value().to_string(); - let json = serde_json::to_string(report) - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - - sqlx::query( - "UPDATE wrap_up_records \ - SET status = 'ready', report_json = ?, completed_at = strftime('%Y-%m-%d %H:%M:%S', 'now') \ - WHERE id = ?", - ) - .bind(&json) - .bind(&id_str) - .execute(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - - Ok(()) - } - - async fn get_by_id(&self, id: &WrapUpId) -> Result, DomainError> { - let id_str = id.value().to_string(); - - let row = sqlx::query( - "SELECT id, user_id, start_date, end_date, status, report_json, error_message, \ - created_at, completed_at \ - FROM wrap_up_records WHERE id = ?", - ) - .bind(&id_str) - .fetch_optional(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - - row.as_ref().map(row_to_record).transpose() - } - - async fn list_for_user(&self, user_id: Uuid) -> Result, DomainError> { - let uid = user_id.to_string(); - - let rows = sqlx::query( - "SELECT id, user_id, start_date, end_date, status, report_json, error_message, \ - created_at, completed_at \ - FROM wrap_up_records WHERE user_id = ? ORDER BY created_at DESC", - ) - .bind(&uid) - .fetch_all(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - - rows.iter().map(row_to_record).collect() - } - - async fn list_global(&self) -> Result, DomainError> { - let rows = sqlx::query( - "SELECT id, user_id, start_date, end_date, status, report_json, error_message, \ - created_at, completed_at \ - FROM wrap_up_records WHERE user_id IS NULL ORDER BY created_at DESC", - ) - .fetch_all(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - - rows.iter().map(row_to_record).collect() - } - - async fn find_existing( - &self, - user_id: Option, - start: NaiveDate, - end: NaiveDate, - ) -> Result, DomainError> { - let uid = user_id.map(|u| u.to_string()); - let start_str = start.format("%Y-%m-%d").to_string(); - let end_str = end.format("%Y-%m-%d").to_string(); - - let row = sqlx::query( - "SELECT id, user_id, start_date, end_date, status, report_json, error_message, \ - created_at, completed_at \ - FROM wrap_up_records \ - WHERE ((? IS NULL AND user_id IS NULL) OR user_id = ?) \ - AND start_date = ? AND end_date = ? \ - LIMIT 1", - ) - .bind(&uid) - .bind(&uid) - .bind(&start_str) - .bind(&end_str) - .fetch_optional(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - - row.as_ref().map(row_to_record).transpose() - } - - async fn delete(&self, id: &WrapUpId) -> Result<(), DomainError> { - sqlx::query("DELETE FROM wrap_up_records WHERE id = ?") - .bind(id.value().to_string()) - .execute(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - Ok(()) - } - - async fn delete_failed_older_than( - &self, - before: chrono::NaiveDateTime, - ) -> Result { - let before_str = before.format("%Y-%m-%dT%H:%M:%SZ").to_string(); - let result = - sqlx::query("DELETE FROM wrap_up_records WHERE status = 'failed' AND created_at < ?") - .bind(&before_str) - .execute(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - Ok(result.rows_affected()) - } -} - -fn row_to_record(row: &sqlx::sqlite::SqliteRow) -> Result { - let id_str: String = row.try_get("id").map_err(crate::shared::map_sqlx_error)?; - let user_id_str: Option = row - .try_get("user_id") - .map_err(crate::shared::map_sqlx_error)?; - let start_date_str: String = row - .try_get("start_date") - .map_err(crate::shared::map_sqlx_error)?; - let end_date_str: String = row - .try_get("end_date") - .map_err(crate::shared::map_sqlx_error)?; - let status_str: String = row - .try_get("status") - .map_err(crate::shared::map_sqlx_error)?; - let report_json: Option = row - .try_get("report_json") - .map_err(crate::shared::map_sqlx_error)?; - let error_message: Option = row - .try_get("error_message") - .map_err(crate::shared::map_sqlx_error)?; - let created_at_str: String = row - .try_get("created_at") - .map_err(crate::shared::map_sqlx_error)?; - let completed_at_str: Option = row - .try_get("completed_at") - .map_err(crate::shared::map_sqlx_error)?; - - let user_id = user_id_str.as_deref().map(parse_uuid).transpose()?; - - let report = report_json.and_then(|j| serde_json::from_str(&j).ok()); - - Ok(WrapUpRecord { - id: WrapUpId::from_uuid(parse_uuid(&id_str)?), - user_id, - start_date: parse_date(&start_date_str)?, - end_date: parse_date(&end_date_str)?, - status: parse_status(&status_str)?, - report, - error_message, - created_at: parse_datetime(&created_at_str)?, - completed_at: completed_at_str - .as_deref() - .map(parse_datetime) - .transpose()?, - }) -} - // ── WrapUpStatsQuery ───────────────────────────────────────────────────────── pub struct SqliteWrapUpStatsQuery { diff --git a/crates/adapters/sqlite/src/repositories/wrapup/rows.rs b/crates/adapters/sqlite/src/repositories/wrapup/rows.rs new file mode 100644 index 00000000..fc189041 --- /dev/null +++ b/crates/adapters/sqlite/src/repositories/wrapup/rows.rs @@ -0,0 +1,82 @@ +use chrono::NaiveDate; +use domain::{ + errors::DomainError, + models::wrapup::{WrapUpRecord, WrapUpStatus}, + value_objects::WrapUpId, +}; +use sqlx::Row; + +use crate::shared::{parse_datetime, parse_uuid}; + +pub(super) fn status_to_str(s: &WrapUpStatus) -> &'static str { + match s { + WrapUpStatus::Pending => "pending", + WrapUpStatus::Generating => "generating", + WrapUpStatus::Ready => "ready", + WrapUpStatus::Failed => "failed", + } +} + +pub(super) fn parse_status(s: &str) -> Result { + match s { + "pending" => Ok(WrapUpStatus::Pending), + "generating" => Ok(WrapUpStatus::Generating), + "ready" => Ok(WrapUpStatus::Ready), + "failed" => Ok(WrapUpStatus::Failed), + other => Err(DomainError::InfrastructureError(format!( + "Unknown wrap-up status: {other}" + ))), + } +} + +pub(super) fn parse_date(s: &str) -> Result { + NaiveDate::parse_from_str(s, "%Y-%m-%d") + .map_err(|e| DomainError::InfrastructureError(format!("Invalid date '{s}': {e}"))) +} + +pub(super) fn row_to_record(row: &sqlx::sqlite::SqliteRow) -> Result { + let id_str: String = row.try_get("id").map_err(crate::shared::map_sqlx_error)?; + let user_id_str: Option = row + .try_get("user_id") + .map_err(crate::shared::map_sqlx_error)?; + let start_date_str: String = row + .try_get("start_date") + .map_err(crate::shared::map_sqlx_error)?; + let end_date_str: String = row + .try_get("end_date") + .map_err(crate::shared::map_sqlx_error)?; + let status_str: String = row + .try_get("status") + .map_err(crate::shared::map_sqlx_error)?; + let report_json: Option = row + .try_get("report_json") + .map_err(crate::shared::map_sqlx_error)?; + let error_message: Option = row + .try_get("error_message") + .map_err(crate::shared::map_sqlx_error)?; + let created_at_str: String = row + .try_get("created_at") + .map_err(crate::shared::map_sqlx_error)?; + let completed_at_str: Option = row + .try_get("completed_at") + .map_err(crate::shared::map_sqlx_error)?; + + let user_id = user_id_str.as_deref().map(parse_uuid).transpose()?; + + let report = report_json.and_then(|j| serde_json::from_str(&j).ok()); + + Ok(WrapUpRecord { + id: WrapUpId::from_uuid(parse_uuid(&id_str)?), + user_id, + start_date: parse_date(&start_date_str)?, + end_date: parse_date(&end_date_str)?, + status: parse_status(&status_str)?, + report, + error_message, + created_at: parse_datetime(&created_at_str)?, + completed_at: completed_at_str + .as_deref() + .map(parse_datetime) + .transpose()?, + }) +} diff --git a/crates/adapters/sqlite/src/watch_event.rs b/crates/adapters/sqlite/src/watch_event.rs deleted file mode 100644 index ff821c82..00000000 --- a/crates/adapters/sqlite/src/watch_event.rs +++ /dev/null @@ -1,399 +0,0 @@ -use async_trait::async_trait; -use domain::{ - errors::DomainError, - models::{PersistedWatchEvent, WatchEvent, WatchEventSource, WatchEventStatus, WebhookToken}, - ports::{WatchEventCommand, WatchEventQuery, WebhookTokenRepository}, - value_objects::{MovieId, UserId, WatchEventId, WebhookTokenId}, -}; -use sqlx::{Row, SqlitePool}; - -use crate::shared::datetime_to_str; - -fn parse_uuid(s: &str) -> Result { - s.parse() - .map_err(|_| DomainError::InfrastructureError(format!("invalid UUID: {s}"))) -} - -fn parse_datetime(s: &str) -> Result { - chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") - .or_else(|_| chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S")) - .map_err(|_| DomainError::InfrastructureError(format!("invalid datetime: {s}"))) -} - -// ── WatchEventRepository ────────────────────────────────────────────────────── - -pub struct SqliteWatchEventRepository { - pool: SqlitePool, -} - -impl SqliteWatchEventRepository { - pub fn new(pool: SqlitePool) -> Self { - Self { pool } - } -} - -#[async_trait] -impl WatchEventCommand for SqliteWatchEventRepository { - async fn save(&self, event: &WatchEvent) -> Result<(), DomainError> { - let id = event.id().value().to_string(); - let user_id = event.user_id().value().to_string(); - let movie_id = event.movie_id().map(|m| m.value().to_string()); - let source = event.source().to_string(); - let watched_at = datetime_to_str(event.watched_at()); - let status = event.status().to_string(); - let created_at = datetime_to_str(event.created_at()); - - sqlx::query( - "INSERT INTO watch_events \ - (id, user_id, movie_id, title, year, external_metadata_id, source, watched_at, status, created_at) \ - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - ) - .bind(&id) - .bind(&user_id) - .bind(&movie_id) - .bind(event.title()) - .bind(event.year().map(|y| y as i64)) - .bind(event.external_metadata_id()) - .bind(&source) - .bind(&watched_at) - .bind(&status) - .bind(&created_at) - .execute(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - - Ok(()) - } - - async fn update_status( - &self, - id: &WatchEventId, - status: WatchEventStatus, - ) -> Result<(), DomainError> { - let id_str = id.value().to_string(); - let status_str = status.to_string(); - - sqlx::query("UPDATE watch_events SET status = ? WHERE id = ?") - .bind(&status_str) - .bind(&id_str) - .execute(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - - Ok(()) - } - - async fn update_status_batch( - &self, - ids: &[WatchEventId], - status: WatchEventStatus, - ) -> Result { - if ids.is_empty() { - return Ok(0); - } - let placeholders: Vec<&str> = ids.iter().map(|_| "?").collect(); - let sql = format!( - "UPDATE watch_events SET status = ? WHERE id IN ({})", - placeholders.join(",") - ); - let mut q = sqlx::query(&sql).bind(status.to_string()); - for id in ids { - q = q.bind(id.value().to_string()); - } - let result = q - .execute(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - Ok(result.rows_affected()) - } - - async fn delete_non_pending_older_than( - &self, - before: chrono::NaiveDateTime, - ) -> Result { - 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(crate::shared::map_sqlx_error)?; - Ok(result.rows_affected()) - } -} - -#[async_trait] -impl WatchEventQuery for SqliteWatchEventRepository { - async fn list_pending(&self, user_id: &UserId) -> Result, 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(crate::shared::map_sqlx_error)?; - - rows.iter().map(row_to_watch_event).collect() - } - - async fn get_by_id(&self, id: &WatchEventId) -> Result, 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(crate::shared::map_sqlx_error)?; - - row.as_ref().map(row_to_watch_event).transpose() - } - - async fn get_by_ids(&self, ids: &[WatchEventId]) -> Result, 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(crate::shared::map_sqlx_error)?; - rows.iter().map(row_to_watch_event).collect() - } - - async fn find_duplicate( - &self, - user_id: &UserId, - external_id: &str, - after: chrono::NaiveDateTime, - ) -> Result { - let uid = user_id.value().to_string(); - let after_str = datetime_to_str(&after); - - let count: i64 = sqlx::query( - "SELECT COUNT(*) FROM watch_events \ - WHERE user_id = ? AND external_metadata_id = ? AND created_at > ?", - ) - .bind(&uid) - .bind(external_id) - .bind(&after_str) - .fetch_one(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)? - .try_get(0) - .unwrap_or(0); - - Ok(count > 0) - } -} - -fn row_to_watch_event(row: &sqlx::sqlite::SqliteRow) -> Result { - let id_str: &str = row.try_get("id").map_err(crate::shared::map_sqlx_error)?; - let user_id_str: &str = row - .try_get("user_id") - .map_err(crate::shared::map_sqlx_error)?; - let movie_id_str: Option<&str> = row - .try_get("movie_id") - .map_err(crate::shared::map_sqlx_error)?; - let title: String = row - .try_get("title") - .map_err(crate::shared::map_sqlx_error)?; - let year: Option = row.try_get("year").map_err(crate::shared::map_sqlx_error)?; - let ext_id: Option = row - .try_get("external_metadata_id") - .map_err(crate::shared::map_sqlx_error)?; - let source_str: String = row - .try_get("source") - .map_err(crate::shared::map_sqlx_error)?; - let watched_at_str: String = row - .try_get("watched_at") - .map_err(crate::shared::map_sqlx_error)?; - let status_str: String = row - .try_get("status") - .map_err(crate::shared::map_sqlx_error)?; - let created_at_str: String = row - .try_get("created_at") - .map_err(crate::shared::map_sqlx_error)?; - - let source: WatchEventSource = source_str - .parse() - .map_err(|e: String| DomainError::InfrastructureError(e))?; - let status: WatchEventStatus = status_str - .parse() - .map_err(|e: String| DomainError::InfrastructureError(e))?; - - let movie_id = movie_id_str - .map(parse_uuid) - .transpose()? - .map(MovieId::from_uuid); - - Ok(WatchEvent::from_persistence(PersistedWatchEvent { - id: WatchEventId::from_uuid(parse_uuid(id_str)?), - user_id: UserId::from_uuid(parse_uuid(user_id_str)?), - movie_id, - title, - year: year.map(|y| y as u16), - external_metadata_id: ext_id, - source, - watched_at: parse_datetime(&watched_at_str)?, - status, - created_at: parse_datetime(&created_at_str)?, - })) -} - -// ── WebhookTokenRepository ──────────────────────────────────────────────────── - -pub struct SqliteWebhookTokenRepository { - pool: SqlitePool, -} - -impl SqliteWebhookTokenRepository { - pub fn new(pool: SqlitePool) -> Self { - Self { pool } - } -} - -#[async_trait] -impl WebhookTokenRepository for SqliteWebhookTokenRepository { - async fn save(&self, token: &WebhookToken) -> Result<(), DomainError> { - let id = token.id().value().to_string(); - let user_id = token.user_id().value().to_string(); - let provider = token.provider().to_string(); - let created_at = datetime_to_str(token.created_at()); - let last_used = token.last_used_at().map(datetime_to_str); - - sqlx::query( - "INSERT INTO webhook_tokens \ - (id, user_id, token_hash, provider, label, created_at, last_used_at) \ - VALUES (?, ?, ?, ?, ?, ?, ?)", - ) - .bind(&id) - .bind(&user_id) - .bind(token.token_hash()) - .bind(&provider) - .bind(token.label()) - .bind(&created_at) - .bind(&last_used) - .execute(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - - Ok(()) - } - - async fn find_by_token_hash(&self, hash: &str) -> Result, DomainError> { - let row = sqlx::query( - "SELECT id, user_id, token_hash, provider, label, created_at, last_used_at \ - FROM webhook_tokens WHERE token_hash = ?", - ) - .bind(hash) - .fetch_optional(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - - row.as_ref().map(row_to_webhook_token).transpose() - } - - async fn list_by_user(&self, user_id: &UserId) -> Result, DomainError> { - let uid = user_id.value().to_string(); - - let rows = sqlx::query( - "SELECT id, user_id, token_hash, provider, label, created_at, last_used_at \ - FROM webhook_tokens WHERE user_id = ? ORDER BY created_at DESC", - ) - .bind(&uid) - .fetch_all(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - - rows.iter().map(row_to_webhook_token).collect() - } - - async fn delete(&self, id: &WebhookTokenId, user_id: &UserId) -> Result<(), DomainError> { - let id_str = id.value().to_string(); - let uid = user_id.value().to_string(); - - let result = sqlx::query("DELETE FROM webhook_tokens WHERE id = ? AND user_id = ?") - .bind(&id_str) - .bind(&uid) - .execute(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - - if result.rows_affected() == 0 { - return Err(DomainError::NotFound(format!("Webhook token {id_str}"))); - } - Ok(()) - } - - async fn touch_last_used(&self, id: &WebhookTokenId) -> Result<(), DomainError> { - let id_str = id.value().to_string(); - let now = datetime_to_str(&chrono::Utc::now().naive_utc()); - - sqlx::query("UPDATE webhook_tokens SET last_used_at = ? WHERE id = ?") - .bind(&now) - .bind(&id_str) - .execute(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; - - Ok(()) - } -} - -fn row_to_webhook_token(row: &sqlx::sqlite::SqliteRow) -> Result { - let id_str: &str = row.try_get("id").map_err(crate::shared::map_sqlx_error)?; - let user_id_str: &str = row - .try_get("user_id") - .map_err(crate::shared::map_sqlx_error)?; - let token_hash: String = row - .try_get("token_hash") - .map_err(crate::shared::map_sqlx_error)?; - let provider_str: String = row - .try_get("provider") - .map_err(crate::shared::map_sqlx_error)?; - let label: Option = row - .try_get("label") - .map_err(crate::shared::map_sqlx_error)?; - let created_at_str: String = row - .try_get("created_at") - .map_err(crate::shared::map_sqlx_error)?; - let last_used_str: Option = row - .try_get("last_used_at") - .map_err(crate::shared::map_sqlx_error)?; - - let provider: WatchEventSource = provider_str - .parse() - .map_err(|e: String| DomainError::InfrastructureError(e))?; - - let last_used = last_used_str.map(|s| parse_datetime(&s)).transpose()?; - - Ok(WebhookToken::from_persistence( - WebhookTokenId::from_uuid(parse_uuid(id_str)?), - UserId::from_uuid(parse_uuid(user_id_str)?), - token_hash, - provider, - label, - parse_datetime(&created_at_str)?, - last_used, - )) -}