refactor: sqlite repositories per aggregate
This commit is contained in:
@@ -1,54 +1,23 @@
|
|||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
mod diary;
|
|
||||||
mod event_queue;
|
mod event_queue;
|
||||||
mod goals;
|
|
||||||
mod image_ref;
|
|
||||||
mod import_profile;
|
|
||||||
mod import_session;
|
|
||||||
mod insights;
|
|
||||||
mod migrations;
|
mod migrations;
|
||||||
mod models;
|
mod repositories;
|
||||||
mod movie;
|
|
||||||
mod movie_dedup;
|
|
||||||
mod persons;
|
|
||||||
mod profile;
|
|
||||||
mod profile_fields;
|
|
||||||
mod refresh_sessions;
|
|
||||||
mod review;
|
|
||||||
mod search;
|
|
||||||
mod shared;
|
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 event_queue::{DbEventQueueConfig, SqliteEventQueue};
|
||||||
pub use image_ref::{SqliteImageRefAdapter, create_image_ref};
|
pub use repositories::{
|
||||||
pub use import_profile::SqliteImportProfileRepository;
|
SqliteApContentQuery, SqliteDiaryRepository, SqliteImageRefAdapter,
|
||||||
pub use import_session::SqliteImportSessionRepository;
|
SqliteImportProfileRepository, SqliteImportSessionRepository, SqliteInsightsQuery,
|
||||||
pub use insights::SqliteInsightsQuery;
|
SqliteMovieDeduplicator, SqliteMovieProfileRepository, SqliteMovieRepository,
|
||||||
pub use movie::SqliteMovieRepository;
|
SqlitePersonAdapter, SqliteProfileFieldsRepository, SqliteRefreshSessionAdapter,
|
||||||
pub use movie_dedup::SqliteMovieDeduplicator;
|
SqliteRemoteGoalRepository, SqliteReviewRepository, SqliteSocialRepository,
|
||||||
pub use persons::{SqlitePersonAdapter, create_person_adapter};
|
SqliteStatsRepository, SqliteUserRepository, SqliteWatchEventRepository,
|
||||||
pub use profile::SqliteMovieProfileRepository;
|
SqliteWatchlistRepository, SqliteWebhookTokenRepository, SqliteWrapUpRepository,
|
||||||
pub use profile_fields::SqliteProfileFieldsRepository;
|
SqliteWrapUpStatsQuery, create_federated_profile_query, create_image_ref,
|
||||||
pub use refresh_sessions::SqliteRefreshSessionAdapter;
|
create_person_adapter, create_search_adapter,
|
||||||
pub use review::SqliteReviewRepository;
|
|
||||||
pub use search::create_search_adapter;
|
|
||||||
pub use social::{
|
|
||||||
SqliteApContentQuery, SqliteRemoteGoalRepository, SqliteSocialRepository,
|
|
||||||
create_federated_profile_query,
|
|
||||||
};
|
};
|
||||||
pub use stats::SqliteStatsRepository;
|
use repositories::{SqliteGoalRepository, SqliteUserSettingsRepository};
|
||||||
pub use users::SqliteUserRepository;
|
|
||||||
pub use watch_event::{SqliteWatchEventRepository, SqliteWebhookTokenRepository};
|
|
||||||
pub use watchlist::SqliteWatchlistRepository;
|
|
||||||
pub use wrapup::{SqliteWrapUpRepository, SqliteWrapUpStatsQuery};
|
|
||||||
|
|
||||||
/// Row helpers shared with the `sqlite-federation` crate (which cannot live
|
/// Row helpers shared with the `sqlite-federation` crate (which cannot live
|
||||||
/// here because it depends on `activitypub`/`k-ap`).
|
/// here because it depends on `activitypub`/`k-ap`).
|
||||||
@@ -111,9 +80,7 @@ pub async fn wire(database_url: &str) -> anyhow::Result<SqliteWireOutput> {
|
|||||||
.map_err(|e| anyhow::anyhow!("{e}"))
|
.map_err(|e| anyhow::anyhow!("{e}"))
|
||||||
.context("Database migration failed")?;
|
.context("Database migration failed")?;
|
||||||
|
|
||||||
let user_settings_repo = std::sync::Arc::new(user_settings::SqliteUserSettingsRepository::new(
|
let user_settings_repo = std::sync::Arc::new(SqliteUserSettingsRepository::new(pool.clone()));
|
||||||
pool.clone(),
|
|
||||||
));
|
|
||||||
|
|
||||||
let movie_repo = std::sync::Arc::new(SqliteMovieRepository::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<SqliteWireOutput> {
|
|||||||
wrapup_repo: std::sync::Arc::new(SqliteWrapUpRepository::new(pool.clone())) as _,
|
wrapup_repo: std::sync::Arc::new(SqliteWrapUpRepository::new(pool.clone())) as _,
|
||||||
wrapup_stats: std::sync::Arc::new(SqliteWrapUpStatsQuery::new(pool.clone())) as _,
|
wrapup_stats: std::sync::Arc::new(SqliteWrapUpStatsQuery::new(pool.clone())) as _,
|
||||||
insights: std::sync::Arc::new(SqliteInsightsQuery::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_command: std::sync::Arc::new(SqliteGoalRepository::new(pool.clone())) as _,
|
||||||
goal_query: std::sync::Arc::new(goals::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 _,
|
user_settings: std::sync::Arc::clone(&user_settings_repo) as _,
|
||||||
federation_settings: user_settings_repo as _,
|
federation_settings: user_settings_repo as _,
|
||||||
remote_goal: std::sync::Arc::new(SqliteRemoteGoalRepository::new(pool.clone())) as _,
|
remote_goal: std::sync::Arc::new(SqliteRemoteGoalRepository::new(pool.clone())) as _,
|
||||||
|
|||||||
@@ -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<String>,
|
|
||||||
pub title: String,
|
|
||||||
pub release_year: i64,
|
|
||||||
pub director: Option<String>,
|
|
||||||
pub poster_path: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MovieRow {
|
|
||||||
pub fn into_domain(self) -> Result<Movie, DomainError> {
|
|
||||||
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<String>,
|
|
||||||
pub title: String,
|
|
||||||
pub release_year: i64,
|
|
||||||
pub director: Option<String>,
|
|
||||||
pub poster_path: Option<String>,
|
|
||||||
pub genres: Option<String>,
|
|
||||||
pub runtime_minutes: Option<i64>,
|
|
||||||
pub original_language: Option<String>,
|
|
||||||
pub overview: Option<String>,
|
|
||||||
pub collection_name: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MovieSummaryRow {
|
|
||||||
pub fn into_domain(self) -> Result<MovieSummary, DomainError> {
|
|
||||||
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<String>,
|
|
||||||
pub watched_at: String,
|
|
||||||
pub created_at: String,
|
|
||||||
pub remote_actor_url: Option<String>,
|
|
||||||
pub watch_medium: Option<String>,
|
|
||||||
pub spoilers: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ReviewRow {
|
|
||||||
pub fn into_domain(self) -> Result<Review, DomainError> {
|
|
||||||
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<String>,
|
|
||||||
pub title: String,
|
|
||||||
pub release_year: i64,
|
|
||||||
pub director: Option<String>,
|
|
||||||
pub poster_path: Option<String>,
|
|
||||||
pub review_id: String,
|
|
||||||
pub movie_id: String,
|
|
||||||
pub user_id: String,
|
|
||||||
pub rating: i64,
|
|
||||||
pub comment: Option<String>,
|
|
||||||
pub watched_at: String,
|
|
||||||
pub created_at: String,
|
|
||||||
pub remote_actor_url: Option<String>,
|
|
||||||
pub watch_medium: Option<String>,
|
|
||||||
pub spoilers: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DiaryRow {
|
|
||||||
pub fn into_domain(self) -> Result<DiaryEntry, DomainError> {
|
|
||||||
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<f64>,
|
|
||||||
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<String>,
|
|
||||||
pub title: String,
|
|
||||||
pub release_year: i64,
|
|
||||||
pub director: Option<String>,
|
|
||||||
pub poster_path: Option<String>,
|
|
||||||
pub review_id: String,
|
|
||||||
pub movie_id: String,
|
|
||||||
pub user_id: String,
|
|
||||||
pub rating: i64,
|
|
||||||
pub comment: Option<String>,
|
|
||||||
pub watched_at: String,
|
|
||||||
pub created_at: String,
|
|
||||||
pub remote_actor_url: Option<String>,
|
|
||||||
pub watch_medium: Option<String>,
|
|
||||||
pub spoilers: bool,
|
|
||||||
pub user_email: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FeedRow {
|
|
||||||
pub fn into_domain(self) -> Result<FeedEntry, DomainError> {
|
|
||||||
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<String>,
|
|
||||||
pub total_movies: i64,
|
|
||||||
pub avg_rating: Option<f64>,
|
|
||||||
pub avatar_path: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl UserSummaryRow {
|
|
||||||
pub fn into_domain(self) -> Result<domain::models::UserSummary, DomainError> {
|
|
||||||
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<f64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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<String>,
|
|
||||||
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<String>,
|
|
||||||
pub title: String,
|
|
||||||
pub release_year: i64,
|
|
||||||
pub director: Option<String>,
|
|
||||||
pub poster_path: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WatchlistRow {
|
|
||||||
pub fn into_domain(self) -> Result<WatchlistWithMovie, DomainError> {
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<dyn PersonCommand>, Arc<dyn PersonQuery>) {
|
|
||||||
let adapter = Arc::new(SqlitePersonAdapter::new(pool));
|
|
||||||
(
|
|
||||||
Arc::clone(&adapter) as Arc<dyn PersonCommand>,
|
|
||||||
adapter as Arc<dyn PersonQuery>,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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<String>,
|
|
||||||
profile_path: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
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<Option<Person>, 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<Option<Person>, 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<PersonCredits, DomainError> {
|
|
||||||
let person = self
|
|
||||||
.get_by_id(id)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| DomainError::NotFound(format!("Person {} not found", id.value())))?;
|
|
||||||
|
|
||||||
let tmdb_id: Option<i64> =
|
|
||||||
sqlx::query_scalar("SELECT tmdb_person_id FROM persons WHERE id = ?")
|
|
||||||
.bind(id.value().to_string())
|
|
||||||
.fetch_optional(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(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<Vec<Person>, 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<Vec<PersonId>, 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<String>,
|
|
||||||
profile_path: Option<String>,
|
|
||||||
biography: Option<String>,
|
|
||||||
birthday: Option<String>,
|
|
||||||
deathday: Option<String>,
|
|
||||||
place_of_birth: Option<String>,
|
|
||||||
also_known_as: Option<String>,
|
|
||||||
homepage: Option<String>,
|
|
||||||
imdb_id: Option<String>,
|
|
||||||
enriched_at: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
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::<Vec<String>>(&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<i64>,
|
|
||||||
character: String,
|
|
||||||
poster_path: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct CrewRow {
|
|
||||||
id: String,
|
|
||||||
title: String,
|
|
||||||
release_year: Option<i64>,
|
|
||||||
job: String,
|
|
||||||
department: String,
|
|
||||||
poster_path: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
#[path = "tests/persons.rs"]
|
|
||||||
mod tests;
|
|
||||||
110
crates/adapters/sqlite/src/repositories/ap_content/mod.rs
Normal file
110
crates/adapters/sqlite/src/repositories/ap_content/mod.rs
Normal file
@@ -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<Vec<WatchlistWithMovie>, DomainError> {
|
||||||
|
let uid = user_id.value().to_string();
|
||||||
|
let rows: Vec<WatchlistRow> = 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<Vec<DiaryEntry>, 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<chrono::NaiveDateTime>,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<DiaryEntry>, 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,34 +1,19 @@
|
|||||||
use async_trait::async_trait;
|
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::{
|
models::{
|
||||||
DiaryEntry, Movie, PersistedReview, Review, ReviewSource, WatchlistEntry,
|
DiaryEntry, Movie, PersistedReview, Review, ReviewSource, WatchlistEntry,
|
||||||
WatchlistWithMovie,
|
WatchlistWithMovie,
|
||||||
},
|
},
|
||||||
ports::LocalApContentQuery,
|
|
||||||
value_objects::{
|
value_objects::{
|
||||||
Comment, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
|
Comment, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
|
||||||
ReviewId, UserId, WatchlistEntryId,
|
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};
|
use crate::shared::{parse_datetime, parse_uuid};
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
struct MovieRow {
|
pub(super) struct MovieRow {
|
||||||
id: String,
|
id: String,
|
||||||
external_metadata_id: Option<String>,
|
external_metadata_id: Option<String>,
|
||||||
title: String,
|
title: String,
|
||||||
@@ -38,7 +23,7 @@ struct MovieRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl MovieRow {
|
impl MovieRow {
|
||||||
fn into_domain(self) -> Result<Movie, DomainError> {
|
pub(super) fn into_domain(self) -> Result<Movie, DomainError> {
|
||||||
let id = MovieId::from_uuid(parse_uuid(&self.id)?);
|
let id = MovieId::from_uuid(parse_uuid(&self.id)?);
|
||||||
let external_metadata_id = self
|
let external_metadata_id = self
|
||||||
.external_metadata_id
|
.external_metadata_id
|
||||||
@@ -59,7 +44,7 @@ impl MovieRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
struct ReviewRow {
|
pub(super) struct ReviewRow {
|
||||||
id: String,
|
id: String,
|
||||||
movie_id: String,
|
movie_id: String,
|
||||||
user_id: String,
|
user_id: String,
|
||||||
@@ -73,7 +58,7 @@ struct ReviewRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ReviewRow {
|
impl ReviewRow {
|
||||||
fn into_domain(self) -> Result<Review, DomainError> {
|
pub(super) fn into_domain(self) -> Result<Review, DomainError> {
|
||||||
let id = ReviewId::from_uuid(parse_uuid(&self.id)?);
|
let id = ReviewId::from_uuid(parse_uuid(&self.id)?);
|
||||||
let movie_id = MovieId::from_uuid(parse_uuid(&self.movie_id)?);
|
let movie_id = MovieId::from_uuid(parse_uuid(&self.movie_id)?);
|
||||||
let user_id = UserId::from_uuid(parse_uuid(&self.user_id)?);
|
let user_id = UserId::from_uuid(parse_uuid(&self.user_id)?);
|
||||||
@@ -102,7 +87,7 @@ impl ReviewRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
struct DiaryRow {
|
pub(super) struct DiaryRow {
|
||||||
id: String,
|
id: String,
|
||||||
external_metadata_id: Option<String>,
|
external_metadata_id: Option<String>,
|
||||||
title: String,
|
title: String,
|
||||||
@@ -122,7 +107,7 @@ struct DiaryRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl DiaryRow {
|
impl DiaryRow {
|
||||||
fn into_domain(self) -> Result<DiaryEntry, DomainError> {
|
pub(super) fn into_domain(self) -> Result<DiaryEntry, DomainError> {
|
||||||
let movie = MovieRow {
|
let movie = MovieRow {
|
||||||
id: self.id,
|
id: self.id,
|
||||||
external_metadata_id: self.external_metadata_id,
|
external_metadata_id: self.external_metadata_id,
|
||||||
@@ -150,7 +135,7 @@ impl DiaryRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
struct WatchlistRow {
|
pub(super) struct WatchlistRow {
|
||||||
id: String,
|
id: String,
|
||||||
user_id: String,
|
user_id: String,
|
||||||
movie_id: String,
|
movie_id: String,
|
||||||
@@ -164,7 +149,7 @@ struct WatchlistRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl WatchlistRow {
|
impl WatchlistRow {
|
||||||
fn into_domain(self) -> Result<WatchlistWithMovie, DomainError> {
|
pub(super) fn into_domain(self) -> Result<WatchlistWithMovie, DomainError> {
|
||||||
let entry = WatchlistEntry {
|
let entry = WatchlistEntry {
|
||||||
id: WatchlistEntryId::from_uuid(parse_uuid(&self.id)?),
|
id: WatchlistEntryId::from_uuid(parse_uuid(&self.id)?),
|
||||||
user_id: UserId::from_uuid(parse_uuid(&self.user_id)?),
|
user_id: UserId::from_uuid(parse_uuid(&self.user_id)?),
|
||||||
@@ -185,92 +170,3 @@ impl WatchlistRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── LocalApContentQuery impl ─────────────────────────────────────────────────
|
// ── LocalApContentQuery impl ─────────────────────────────────────────────────
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl LocalApContentQuery for SqliteApContentQuery {
|
|
||||||
async fn get_local_watchlist_for_user(
|
|
||||||
&self,
|
|
||||||
user_id: &UserId,
|
|
||||||
) -> Result<Vec<WatchlistWithMovie>, DomainError> {
|
|
||||||
let uid = user_id.value().to_string();
|
|
||||||
let rows: Vec<WatchlistRow> = 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<Vec<DiaryEntry>, 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<chrono::NaiveDateTime>,
|
|
||||||
limit: usize,
|
|
||||||
) -> Result<Vec<DiaryEntry>, 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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
174
crates/adapters/sqlite/src/repositories/diary/mod.rs
Normal file
174
crates/adapters/sqlite/src/repositories/diary/mod.rs
Normal file
@@ -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<i64, DomainError> {
|
||||||
|
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<Vec<DiaryRow>, 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<Vec<DiaryRow>, 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<i64, 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 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<Vec<DiaryRow>, 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;
|
||||||
@@ -2,180 +2,18 @@ use async_trait::async_trait;
|
|||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::{
|
models::{
|
||||||
DiaryEntry, DiaryFilter, FeedEntry, MovieStats, ReviewHistory, ReviewSortBy,
|
DiaryEntry, DiaryFilter, FeedEntry, MovieStats, ReviewHistory,
|
||||||
collections::{PageParams, Paginated},
|
collections::{PageParams, Paginated},
|
||||||
},
|
},
|
||||||
ports::DiaryQuery,
|
ports::DiaryQuery,
|
||||||
value_objects::{MovieId, UserId},
|
value_objects::{MovieId, UserId},
|
||||||
};
|
};
|
||||||
use futures::stream::BoxStream;
|
use futures::stream::BoxStream;
|
||||||
use sqlx::SqlitePool;
|
|
||||||
|
|
||||||
use crate::models::{DiaryRow, FeedRow, MovieRow, MovieStatsRow, ReviewRow};
|
use super::SqliteDiaryRepository;
|
||||||
|
use super::rows::{DiaryRow, FeedRow, MovieStatsRow};
|
||||||
pub struct SqliteDiaryRepository {
|
use crate::repositories::movie::rows::MovieRow;
|
||||||
pool: SqlitePool,
|
use crate::repositories::review::rows::ReviewRow;
|
||||||
}
|
|
||||||
|
|
||||||
impl SqliteDiaryRepository {
|
|
||||||
pub fn new(pool: SqlitePool) -> Self {
|
|
||||||
Self { pool }
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn count_diary_entries(&self, movie_id: Option<&str>) -> Result<i64, DomainError> {
|
|
||||||
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<Vec<DiaryRow>, 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<Vec<DiaryRow>, 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<i64, 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 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<Vec<DiaryRow>, 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl DiaryQuery for SqliteDiaryRepository {
|
impl DiaryQuery for SqliteDiaryRepository {
|
||||||
@@ -516,7 +354,3 @@ impl DiaryQuery for SqliteDiaryRepository {
|
|||||||
Ok(count as u64)
|
Ok(count as u64)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
#[path = "tests/diary.rs"]
|
|
||||||
mod tests;
|
|
||||||
128
crates/adapters/sqlite/src/repositories/diary/rows.rs
Normal file
128
crates/adapters/sqlite/src/repositories/diary/rows.rs
Normal file
@@ -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<String>,
|
||||||
|
pub title: String,
|
||||||
|
pub release_year: i64,
|
||||||
|
pub director: Option<String>,
|
||||||
|
pub poster_path: Option<String>,
|
||||||
|
pub review_id: String,
|
||||||
|
pub movie_id: String,
|
||||||
|
pub user_id: String,
|
||||||
|
pub rating: i64,
|
||||||
|
pub comment: Option<String>,
|
||||||
|
pub watched_at: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub remote_actor_url: Option<String>,
|
||||||
|
pub watch_medium: Option<String>,
|
||||||
|
pub spoilers: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DiaryRow {
|
||||||
|
pub fn into_domain(self) -> Result<DiaryEntry, DomainError> {
|
||||||
|
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<String>,
|
||||||
|
pub title: String,
|
||||||
|
pub release_year: i64,
|
||||||
|
pub director: Option<String>,
|
||||||
|
pub poster_path: Option<String>,
|
||||||
|
pub review_id: String,
|
||||||
|
pub movie_id: String,
|
||||||
|
pub user_id: String,
|
||||||
|
pub rating: i64,
|
||||||
|
pub comment: Option<String>,
|
||||||
|
pub watched_at: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub remote_actor_url: Option<String>,
|
||||||
|
pub watch_medium: Option<String>,
|
||||||
|
pub spoilers: bool,
|
||||||
|
pub user_email: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FeedRow {
|
||||||
|
pub fn into_domain(self) -> Result<FeedEntry, DomainError> {
|
||||||
|
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<f64>,
|
||||||
|
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,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,5 +66,4 @@ impl ImageRefQuery for SqliteImageRefAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[path = "tests/image_ref.rs"]
|
|
||||||
mod tests;
|
mod tests;
|
||||||
@@ -2,91 +2,14 @@ use async_trait::async_trait;
|
|||||||
use chrono::NaiveDateTime;
|
use chrono::NaiveDateTime;
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::{
|
models::ImportProfile,
|
||||||
FieldMapping, ImportProfile,
|
|
||||||
import::{DomainField, Transform},
|
|
||||||
},
|
|
||||||
ports::ImportProfileRepository,
|
ports::ImportProfileRepository,
|
||||||
value_objects::{ImportProfileId, UserId},
|
value_objects::{ImportProfileId, UserId},
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
mod rows;
|
||||||
enum DomainFieldJson {
|
use rows::*;
|
||||||
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<String, DomainError> {
|
|
||||||
serde_json::to_string(&ms.iter().map(mapping_to_json).collect::<Vec<_>>())
|
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn deserialize_mappings(s: &str) -> Result<Vec<FieldMapping>, DomainError> {
|
|
||||||
let js: Vec<FieldMappingJson> =
|
|
||||||
serde_json::from_str(s).map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
|
||||||
Ok(js.into_iter().map(mapping_from_json).collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct SqliteImportProfileRepository {
|
pub struct SqliteImportProfileRepository {
|
||||||
pool: SqlitePool,
|
pool: SqlitePool,
|
||||||
@@ -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<String, DomainError> {
|
||||||
|
serde_json::to_string(&ms.iter().map(mapping_to_json).collect::<Vec<_>>())
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn deserialize_mappings(s: &str) -> Result<Vec<FieldMapping>, DomainError> {
|
||||||
|
let js: Vec<FieldMappingJson> =
|
||||||
|
serde_json::from_str(s).map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
Ok(js.into_iter().map(mapping_from_json).collect())
|
||||||
|
}
|
||||||
@@ -2,194 +2,14 @@ use async_trait::async_trait;
|
|||||||
use chrono::NaiveDateTime;
|
use chrono::NaiveDateTime;
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::{
|
models::{AnnotatedRow, FieldMapping, ImportSession, ParsedFile},
|
||||||
AnnotatedRow, FieldMapping, ImportSession, ParsedFile,
|
|
||||||
import::{DomainField, ImportRow, RowResult, Transform},
|
|
||||||
},
|
|
||||||
ports::ImportSessionRepository,
|
ports::ImportSessionRepository,
|
||||||
value_objects::{ImportSessionId, UserId},
|
value_objects::{ImportSessionId, UserId},
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
// ── serde mirror structs (match the JSON format from the old importer types) ──
|
mod rows;
|
||||||
|
use rows::*;
|
||||||
#[derive(Serialize, Deserialize, Default)]
|
|
||||||
struct ParsedFileJson {
|
|
||||||
columns: Vec<String>,
|
|
||||||
rows: Vec<Vec<String>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
release_year: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
director: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
rating: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
watched_at: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
comment: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
external_metadata_id: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
|
||||||
enum RowResultJson {
|
|
||||||
Valid(ImportRowJson),
|
|
||||||
Invalid {
|
|
||||||
errors: Vec<String>,
|
|
||||||
raw: Vec<(String, String)>,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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<T: Serialize>(v: &T) -> Result<String, DomainError> {
|
|
||||||
serde_json::to_string(v).map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn de<T: for<'de> Deserialize<'de>>(s: &str) -> Result<T, DomainError> {
|
|
||||||
serde_json::from_str(s).map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── repository ──
|
// ── repository ──
|
||||||
|
|
||||||
187
crates/adapters/sqlite/src/repositories/import_session/rows.rs
Normal file
187
crates/adapters/sqlite/src/repositories/import_session/rows.rs
Normal file
@@ -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<String>,
|
||||||
|
pub(super) rows: Vec<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
release_year: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
director: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
rating: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
watched_at: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
comment: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
external_metadata_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub(super) enum RowResultJson {
|
||||||
|
Valid(ImportRowJson),
|
||||||
|
Invalid {
|
||||||
|
errors: Vec<String>,
|
||||||
|
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<T: Serialize>(v: &T) -> Result<String, DomainError> {
|
||||||
|
serde_json::to_string(v).map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn de<T: for<'de> Deserialize<'de>>(s: &str) -> Result<T, DomainError> {
|
||||||
|
serde_json::from_str(s).map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
||||||
|
}
|
||||||
47
crates/adapters/sqlite/src/repositories/mod.rs
Normal file
47
crates/adapters/sqlite/src/repositories/mod.rs
Normal file
@@ -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};
|
||||||
48
crates/adapters/sqlite/src/repositories/movie/command.rs
Normal file
48
crates/adapters/sqlite/src/repositories/movie/command.rs
Normal file
@@ -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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
15
crates/adapters/sqlite/src/repositories/movie/mod.rs
Normal file
15
crates/adapters/sqlite/src/repositories/movie/mod.rs
Normal file
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,66 +5,12 @@ use domain::{
|
|||||||
Movie, MovieFilter, MovieSummary,
|
Movie, MovieFilter, MovieSummary,
|
||||||
collections::{PageParams, Paginated},
|
collections::{PageParams, Paginated},
|
||||||
},
|
},
|
||||||
ports::{MovieCommand, MovieQuery},
|
ports::MovieQuery,
|
||||||
value_objects::{ExternalMetadataId, MovieId, MovieTitle, ReleaseYear},
|
value_objects::{ExternalMetadataId, MovieId, MovieTitle, ReleaseYear},
|
||||||
};
|
};
|
||||||
use sqlx::SqlitePool;
|
|
||||||
|
|
||||||
use crate::models::{MovieRow, MovieSummaryRow};
|
use super::SqliteMovieRepository;
|
||||||
|
use super::rows::{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(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl MovieQuery for SqliteMovieRepository {
|
impl MovieQuery for SqliteMovieRepository {
|
||||||
68
crates/adapters/sqlite/src/repositories/movie/rows.rs
Normal file
68
crates/adapters/sqlite/src/repositories/movie/rows.rs
Normal file
@@ -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<String>,
|
||||||
|
pub title: String,
|
||||||
|
pub release_year: i64,
|
||||||
|
pub director: Option<String>,
|
||||||
|
pub poster_path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MovieRow {
|
||||||
|
pub fn into_domain(self) -> Result<Movie, DomainError> {
|
||||||
|
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<String>,
|
||||||
|
pub title: String,
|
||||||
|
pub release_year: i64,
|
||||||
|
pub director: Option<String>,
|
||||||
|
pub poster_path: Option<String>,
|
||||||
|
pub genres: Option<String>,
|
||||||
|
pub runtime_minutes: Option<i64>,
|
||||||
|
pub original_language: Option<String>,
|
||||||
|
pub overview: Option<String>,
|
||||||
|
pub collection_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MovieSummaryRow {
|
||||||
|
pub fn into_domain(self) -> Result<MovieSummary, DomainError> {
|
||||||
|
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,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
117
crates/adapters/sqlite/src/repositories/persons/command.rs
Normal file
117
crates/adapters/sqlite/src/repositories/persons/command.rs
Normal file
@@ -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<String>,
|
||||||
|
profile_path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
28
crates/adapters/sqlite/src/repositories/persons/mod.rs
Normal file
28
crates/adapters/sqlite/src/repositories/persons/mod.rs
Normal file
@@ -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<dyn PersonCommand>, Arc<dyn PersonQuery>) {
|
||||||
|
let adapter = Arc::new(SqlitePersonAdapter::new(pool));
|
||||||
|
(
|
||||||
|
Arc::clone(&adapter) as Arc<dyn PersonCommand>,
|
||||||
|
adapter as Arc<dyn PersonQuery>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
142
crates/adapters/sqlite/src/repositories/persons/query.rs
Normal file
142
crates/adapters/sqlite/src/repositories/persons/query.rs
Normal file
@@ -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<Option<Person>, 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<Option<Person>, 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<PersonCredits, DomainError> {
|
||||||
|
let person = self
|
||||||
|
.get_by_id(id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| DomainError::NotFound(format!("Person {} not found", id.value())))?;
|
||||||
|
|
||||||
|
let tmdb_id: Option<i64> =
|
||||||
|
sqlx::query_scalar("SELECT tmdb_person_id FROM persons WHERE id = ?")
|
||||||
|
.bind(id.value().to_string())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(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<Vec<Person>, 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<Vec<PersonId>, 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
72
crates/adapters/sqlite/src/repositories/persons/rows.rs
Normal file
72
crates/adapters/sqlite/src/repositories/persons/rows.rs
Normal file
@@ -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<String>,
|
||||||
|
pub(super) profile_path: Option<String>,
|
||||||
|
pub(super) biography: Option<String>,
|
||||||
|
pub(super) birthday: Option<String>,
|
||||||
|
pub(super) deathday: Option<String>,
|
||||||
|
pub(super) place_of_birth: Option<String>,
|
||||||
|
pub(super) also_known_as: Option<String>,
|
||||||
|
pub(super) homepage: Option<String>,
|
||||||
|
pub(super) imdb_id: Option<String>,
|
||||||
|
pub(super) enriched_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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::<Vec<String>>(&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<i64>,
|
||||||
|
pub(super) character: String,
|
||||||
|
pub(super) poster_path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub(super) struct CrewRow {
|
||||||
|
pub(super) id: String,
|
||||||
|
pub(super) title: String,
|
||||||
|
pub(super) release_year: Option<i64>,
|
||||||
|
pub(super) job: String,
|
||||||
|
pub(super) department: String,
|
||||||
|
pub(super) poster_path: Option<String>,
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
use super::super::persons::SqlitePersonAdapter;
|
use super::SqlitePersonAdapter;
|
||||||
use domain::{
|
use domain::{
|
||||||
models::{ExternalPersonId, Person, PersonId},
|
models::{ExternalPersonId, Person, PersonId},
|
||||||
ports::{PersonCommand, PersonQuery},
|
ports::{PersonCommand, PersonQuery},
|
||||||
@@ -300,5 +300,4 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[path = "tests/profile.rs"]
|
|
||||||
mod tests;
|
mod tests;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
use super::super::profile::SqliteMovieProfileRepository;
|
use super::SqliteMovieProfileRepository;
|
||||||
use domain::{ports::MovieProfileRepository, value_objects::MovieId};
|
use domain::{ports::MovieProfileRepository, value_objects::MovieId};
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
@@ -7,7 +7,9 @@ use domain::{
|
|||||||
};
|
};
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
use crate::models::ReviewRow;
|
use rows::ReviewRow;
|
||||||
|
|
||||||
|
pub(crate) mod rows;
|
||||||
use crate::shared::datetime_to_str;
|
use crate::shared::datetime_to_str;
|
||||||
|
|
||||||
pub struct SqliteReviewRepository {
|
pub struct SqliteReviewRepository {
|
||||||
33
crates/adapters/sqlite/src/repositories/review/rows.rs
Normal file
33
crates/adapters/sqlite/src/repositories/review/rows.rs
Normal file
@@ -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<String>,
|
||||||
|
pub watched_at: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub remote_actor_url: Option<String>,
|
||||||
|
pub watch_medium: Option<String>,
|
||||||
|
pub spoilers: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReviewRow {
|
||||||
|
pub fn into_domain(self) -> Result<Review, DomainError> {
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
133
crates/adapters/sqlite/src/repositories/search/command.rs
Normal file
133
crates/adapters/sqlite/src/repositories/search/command.rs
Normal file
@@ -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::<Vec<_>>()
|
||||||
|
.join(" "),
|
||||||
|
p.keywords
|
||||||
|
.iter()
|
||||||
|
.map(|k| k.name.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" "),
|
||||||
|
p.cast
|
||||||
|
.iter()
|
||||||
|
.map(|c| c.name.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" "),
|
||||||
|
p.crew
|
||||||
|
.iter()
|
||||||
|
.map(|c| c.name.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" "),
|
||||||
|
p.original_language.clone().unwrap_or_default(),
|
||||||
|
),
|
||||||
|
None => (
|
||||||
|
String::new(),
|
||||||
|
String::new(),
|
||||||
|
String::new(),
|
||||||
|
String::new(),
|
||||||
|
String::new(),
|
||||||
|
String::new(),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
27
crates/adapters/sqlite/src/repositories/search/mod.rs
Normal file
27
crates/adapters/sqlite/src/repositories/search/mod.rs
Normal file
@@ -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<dyn SearchCommand>, Arc<dyn SearchPort>) {
|
||||||
|
let adapter = Arc::new(SqliteSearchAdapter::new(pool));
|
||||||
|
(
|
||||||
|
Arc::clone(&adapter) as Arc<dyn SearchCommand>,
|
||||||
|
adapter as Arc<dyn SearchPort>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
@@ -1,160 +1,13 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::PersonId,
|
models::PersonId,
|
||||||
models::{
|
models::{MovieSearchHit, PersonSearchHit, SearchQuery, SearchResults, collections::Paginated},
|
||||||
EntityType, IndexableDocument, MovieSearchHit, PersonSearchHit, SearchQuery, SearchResults,
|
ports::SearchPort,
|
||||||
collections::Paginated,
|
|
||||||
},
|
|
||||||
ports::{SearchCommand, SearchPort},
|
|
||||||
value_objects::MovieId,
|
value_objects::MovieId,
|
||||||
};
|
};
|
||||||
use sqlx::SqlitePool;
|
|
||||||
|
|
||||||
pub struct SqliteSearchAdapter {
|
use super::SqliteSearchAdapter;
|
||||||
pool: SqlitePool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SqliteSearchAdapter {
|
|
||||||
pub fn new(pool: SqlitePool) -> Self {
|
|
||||||
Self { pool }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn create_search_adapter(pool: SqlitePool) -> (Arc<dyn SearchCommand>, Arc<dyn SearchPort>) {
|
|
||||||
let adapter = Arc::new(SqliteSearchAdapter::new(pool));
|
|
||||||
(
|
|
||||||
Arc::clone(&adapter) as Arc<dyn SearchCommand>,
|
|
||||||
adapter as Arc<dyn SearchPort>,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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::<Vec<_>>()
|
|
||||||
.join(" "),
|
|
||||||
p.keywords
|
|
||||||
.iter()
|
|
||||||
.map(|k| k.name.as_str())
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(" "),
|
|
||||||
p.cast
|
|
||||||
.iter()
|
|
||||||
.map(|c| c.name.as_str())
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(" "),
|
|
||||||
p.crew
|
|
||||||
.iter()
|
|
||||||
.map(|c| c.name.as_str())
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(" "),
|
|
||||||
p.original_language.clone().unwrap_or_default(),
|
|
||||||
),
|
|
||||||
None => (
|
|
||||||
String::new(),
|
|
||||||
String::new(),
|
|
||||||
String::new(),
|
|
||||||
String::new(),
|
|
||||||
String::new(),
|
|
||||||
String::new(),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
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(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl SearchPort for SqliteSearchAdapter {
|
impl SearchPort for SqliteSearchAdapter {
|
||||||
@@ -389,6 +242,3 @@ impl SqliteSearchAdapter {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests;
|
|
||||||
130
crates/adapters/sqlite/src/repositories/social/follow/command.rs
Normal file
130
crates/adapters/sqlite/src/repositories/social/follow/command.rs
Normal file
@@ -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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -1,197 +1,13 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use chrono::Utc;
|
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
value_objects::{FollowRelation, FollowStatus, InstanceIdentity, SocialActor, SocialIdentity},
|
value_objects::{FollowRelation, SocialActor},
|
||||||
};
|
};
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
|
|
||||||
use crate::SqliteSocialRepository;
|
use super::infra_err;
|
||||||
use crate::shared::datetime_to_str;
|
use super::rows::{follow_status_from_str, social_actor_from_row};
|
||||||
|
use crate::repositories::social::SqliteSocialRepository;
|
||||||
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<FollowStatus> {
|
|
||||||
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<String> = row.try_get("local_username").ok().flatten();
|
|
||||||
let display: Option<String> = row.try_get("local_display").ok().flatten();
|
|
||||||
let avatar: Option<String> = row
|
|
||||||
.try_get::<Option<String>, _>("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::<Option<String>, _>("remote_handle")
|
|
||||||
.ok()
|
|
||||||
.flatten()
|
|
||||||
.unwrap_or_else(|| actor_url.clone());
|
|
||||||
let display: Option<String> = row.try_get("remote_display").ok().flatten();
|
|
||||||
let avatar: Option<String> = row.try_get("remote_avatar").ok().flatten();
|
|
||||||
(handle, display, avatar)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
SocialActor {
|
|
||||||
identity,
|
|
||||||
handle,
|
|
||||||
display_name,
|
|
||||||
avatar_url,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl domain::ports::FollowQuery for SqliteSocialRepository {
|
impl domain::ports::FollowQuery for SqliteSocialRepository {
|
||||||
@@ -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<FollowStatus> {
|
||||||
|
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<String> = row.try_get("local_username").ok().flatten();
|
||||||
|
let display: Option<String> = row.try_get("local_display").ok().flatten();
|
||||||
|
let avatar: Option<String> = row
|
||||||
|
.try_get::<Option<String>, _>("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::<Option<String>, _>("remote_handle")
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.unwrap_or_else(|| actor_url.clone());
|
||||||
|
let display: Option<String> = row.try_get("remote_display").ok().flatten();
|
||||||
|
let avatar: Option<String> = row.try_get("remote_avatar").ok().flatten();
|
||||||
|
(handle, display, avatar)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
SocialActor {
|
||||||
|
identity,
|
||||||
|
handle,
|
||||||
|
display_name,
|
||||||
|
avatar_url,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,8 @@
|
|||||||
mod federated_profile;
|
mod federated_profile;
|
||||||
mod follow_repository;
|
mod follow;
|
||||||
mod local_social;
|
mod local_social;
|
||||||
mod watchlist;
|
mod watchlist;
|
||||||
|
|
||||||
pub mod ap_content;
|
|
||||||
pub mod remote_goals;
|
|
||||||
|
|
||||||
pub use ap_content::SqliteApContentQuery;
|
|
||||||
pub use remote_goals::SqliteRemoteGoalRepository;
|
|
||||||
|
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
pub struct SqliteSocialRepository {
|
pub struct SqliteSocialRepository {
|
||||||
@@ -30,5 +24,4 @@ pub fn create_federated_profile_query(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[path = "tests/follow_relation_tests.rs"]
|
mod tests;
|
||||||
mod follow_relation_tests;
|
|
||||||
@@ -9,11 +9,13 @@ use domain::{
|
|||||||
};
|
};
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
use crate::models::{
|
use rows::{
|
||||||
ActorAppearanceRow, DirectorCountRow, GenreCountRow, MonthlyRatingRow, RatingDistRow,
|
ActorAppearanceRow, DirectorCountRow, GenreCountRow, MonthlyRatingRow, RatingDistRow,
|
||||||
UserTotalsRow, WatchMediumCountRow,
|
UserTotalsRow, WatchMediumCountRow,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
mod rows;
|
||||||
|
|
||||||
pub struct SqliteStatsRepository {
|
pub struct SqliteStatsRepository {
|
||||||
pool: SqlitePool,
|
pool: SqlitePool,
|
||||||
}
|
}
|
||||||
@@ -97,7 +99,7 @@ impl StatsRepository for SqliteStatsRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result<u32, DomainError> {
|
async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result<u32, DomainError> {
|
||||||
crate::goals::count_reviews_in_year(&self.pool, user_id, year).await
|
crate::repositories::goals::count_reviews_in_year(&self.pool, user_id, year).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_user_trends(&self, user_id: &UserId) -> Result<UserTrends, DomainError> {
|
async fn get_user_trends(&self, user_id: &UserId) -> Result<UserTrends, DomainError> {
|
||||||
45
crates/adapters/sqlite/src/repositories/stats/rows.rs
Normal file
45
crates/adapters/sqlite/src/repositories/stats/rows.rs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub(crate) struct UserTotalsRow {
|
||||||
|
pub total: i64,
|
||||||
|
pub avg_rating: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
pub billing_order: i32,
|
||||||
|
pub movie_id: String,
|
||||||
|
}
|
||||||
@@ -2,7 +2,9 @@ use async_trait::async_trait;
|
|||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use sqlx::{Row, SqlitePool};
|
use sqlx::{Row, SqlitePool};
|
||||||
|
|
||||||
use super::models::UserSummaryRow;
|
use rows::UserSummaryRow;
|
||||||
|
|
||||||
|
mod rows;
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::{ProfileField, User, UserRole},
|
models::{ProfileField, User, UserRole},
|
||||||
@@ -197,5 +199,4 @@ impl UserRepository for SqliteUserRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[path = "tests/users.rs"]
|
|
||||||
mod tests;
|
mod tests;
|
||||||
27
crates/adapters/sqlite/src/repositories/users/rows.rs
Normal file
27
crates/adapters/sqlite/src/repositories/users/rows.rs
Normal file
@@ -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<String>,
|
||||||
|
pub total_movies: i64,
|
||||||
|
pub avg_rating: Option<f64>,
|
||||||
|
pub avatar_path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserSummaryRow {
|
||||||
|
pub fn into_domain(self) -> Result<domain::models::UserSummary, DomainError> {
|
||||||
|
user_summary_to_domain(
|
||||||
|
self.id,
|
||||||
|
self.email,
|
||||||
|
self.username,
|
||||||
|
self.display_name,
|
||||||
|
self.total_movies,
|
||||||
|
self.avg_rating,
|
||||||
|
self.avatar_path,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
101
crates/adapters/sqlite/src/repositories/watch_event/command.rs
Normal file
101
crates/adapters/sqlite/src/repositories/watch_event/command.rs
Normal file
@@ -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<u64, DomainError> {
|
||||||
|
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<u64, DomainError> {
|
||||||
|
let before_str = datetime_to_str(&before);
|
||||||
|
let result =
|
||||||
|
sqlx::query("DELETE FROM watch_events WHERE status != 'pending' AND created_at < ?")
|
||||||
|
.bind(&before_str)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(crate::shared::map_sqlx_error)?;
|
||||||
|
Ok(result.rows_affected())
|
||||||
|
}
|
||||||
|
}
|
||||||
18
crates/adapters/sqlite/src/repositories/watch_event/mod.rs
Normal file
18
crates/adapters/sqlite/src/repositories/watch_event/mod.rs
Normal file
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
97
crates/adapters/sqlite/src/repositories/watch_event/query.rs
Normal file
97
crates/adapters/sqlite/src/repositories/watch_event/query.rs
Normal file
@@ -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<Vec<WatchEvent>, DomainError> {
|
||||||
|
let uid = user_id.value().to_string();
|
||||||
|
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT id, user_id, movie_id, title, year, external_metadata_id, \
|
||||||
|
source, watched_at, status, created_at \
|
||||||
|
FROM watch_events \
|
||||||
|
WHERE user_id = ? AND status = 'pending' \
|
||||||
|
ORDER BY watched_at DESC",
|
||||||
|
)
|
||||||
|
.bind(&uid)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(crate::shared::map_sqlx_error)?;
|
||||||
|
|
||||||
|
rows.iter().map(row_to_watch_event).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_by_id(&self, id: &WatchEventId) -> Result<Option<WatchEvent>, DomainError> {
|
||||||
|
let id_str = id.value().to_string();
|
||||||
|
|
||||||
|
let row = sqlx::query(
|
||||||
|
"SELECT id, user_id, movie_id, title, year, external_metadata_id, \
|
||||||
|
source, watched_at, status, created_at \
|
||||||
|
FROM watch_events WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(&id_str)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(crate::shared::map_sqlx_error)?;
|
||||||
|
|
||||||
|
row.as_ref().map(row_to_watch_event).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_by_ids(&self, ids: &[WatchEventId]) -> Result<Vec<WatchEvent>, DomainError> {
|
||||||
|
if ids.is_empty() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
let placeholders: Vec<&str> = ids.iter().map(|_| "?").collect();
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT id, user_id, movie_id, title, year, external_metadata_id, \
|
||||||
|
source, watched_at, status, created_at \
|
||||||
|
FROM watch_events WHERE id IN ({})",
|
||||||
|
placeholders.join(",")
|
||||||
|
);
|
||||||
|
let mut q = sqlx::query(&sql);
|
||||||
|
for id in ids {
|
||||||
|
q = q.bind(id.value().to_string());
|
||||||
|
}
|
||||||
|
let rows = q
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(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<bool, DomainError> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
111
crates/adapters/sqlite/src/repositories/watch_event/rows.rs
Normal file
111
crates/adapters/sqlite/src/repositories/watch_event/rows.rs
Normal file
@@ -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<uuid::Uuid, DomainError> {
|
||||||
|
s.parse()
|
||||||
|
.map_err(|_| DomainError::InfrastructureError(format!("invalid UUID: {s}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn parse_datetime(s: &str) -> Result<chrono::NaiveDateTime, DomainError> {
|
||||||
|
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<WatchEvent, DomainError> {
|
||||||
|
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<i64> = row.try_get("year").map_err(crate::shared::map_sqlx_error)?;
|
||||||
|
let ext_id: Option<String> = 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<WebhookToken, DomainError> {
|
||||||
|
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<String> = 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<String> = 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,
|
||||||
|
))
|
||||||
|
}
|
||||||
@@ -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<Option<WebhookToken>, 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<Vec<WebhookToken>, 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,9 @@ use domain::{
|
|||||||
};
|
};
|
||||||
use sqlx::{Row, SqlitePool};
|
use sqlx::{Row, SqlitePool};
|
||||||
|
|
||||||
use crate::models::WatchlistRow;
|
use rows::WatchlistRow;
|
||||||
|
|
||||||
|
mod rows;
|
||||||
use crate::shared::datetime_to_str;
|
use crate::shared::datetime_to_str;
|
||||||
|
|
||||||
pub struct SqliteWatchlistRepository {
|
pub struct SqliteWatchlistRepository {
|
||||||
33
crates/adapters/sqlite/src/repositories/watchlist/rows.rs
Normal file
33
crates/adapters/sqlite/src/repositories/watchlist/rows.rs
Normal file
@@ -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<String>,
|
||||||
|
pub title: String,
|
||||||
|
pub release_year: i64,
|
||||||
|
pub director: Option<String>,
|
||||||
|
pub poster_path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WatchlistRow {
|
||||||
|
pub fn into_domain(self) -> Result<WatchlistWithMovie, DomainError> {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
200
crates/adapters/sqlite/src/repositories/wrapup/mod.rs
Normal file
200
crates/adapters/sqlite/src/repositories/wrapup/mod.rs
Normal file
@@ -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<Option<WrapUpRecord>, 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<Vec<WrapUpRecord>, 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<Vec<WrapUpRecord>, 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<Uuid>,
|
||||||
|
start: NaiveDate,
|
||||||
|
end: NaiveDate,
|
||||||
|
) -> Result<Option<WrapUpRecord>, 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<u64, DomainError> {
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,276 +1,15 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use chrono::NaiveDate;
|
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::wrapup::{
|
models::wrapup::{DateRange, WrapUpMovieRow, WrapUpScope},
|
||||||
DateRange, WrapUpMovieRow, WrapUpRecord, WrapUpReport, WrapUpScope, WrapUpStatus,
|
ports::WrapUpStatsQuery,
|
||||||
},
|
|
||||||
ports::{WrapUpRepository, WrapUpStatsQuery},
|
|
||||||
value_objects::WrapUpId,
|
|
||||||
};
|
};
|
||||||
use sqlx::{Row, SqlitePool};
|
use sqlx::{Row, SqlitePool};
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::shared::{parse_datetime, parse_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<WrapUpStatus, DomainError> {
|
|
||||||
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, DomainError> {
|
|
||||||
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<Option<WrapUpRecord>, 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<Vec<WrapUpRecord>, 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<Vec<WrapUpRecord>, 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<Uuid>,
|
|
||||||
start: NaiveDate,
|
|
||||||
end: NaiveDate,
|
|
||||||
) -> Result<Option<WrapUpRecord>, 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<u64, DomainError> {
|
|
||||||
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<WrapUpRecord, DomainError> {
|
|
||||||
let id_str: String = row.try_get("id").map_err(crate::shared::map_sqlx_error)?;
|
|
||||||
let user_id_str: Option<String> = 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<String> = row
|
|
||||||
.try_get("report_json")
|
|
||||||
.map_err(crate::shared::map_sqlx_error)?;
|
|
||||||
let error_message: Option<String> = 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<String> = 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 ─────────────────────────────────────────────────────────
|
// ── WrapUpStatsQuery ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
pub struct SqliteWrapUpStatsQuery {
|
pub struct SqliteWrapUpStatsQuery {
|
||||||
82
crates/adapters/sqlite/src/repositories/wrapup/rows.rs
Normal file
82
crates/adapters/sqlite/src/repositories/wrapup/rows.rs
Normal file
@@ -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<WrapUpStatus, DomainError> {
|
||||||
|
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, DomainError> {
|
||||||
|
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<WrapUpRecord, DomainError> {
|
||||||
|
let id_str: String = row.try_get("id").map_err(crate::shared::map_sqlx_error)?;
|
||||||
|
let user_id_str: Option<String> = 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<String> = row
|
||||||
|
.try_get("report_json")
|
||||||
|
.map_err(crate::shared::map_sqlx_error)?;
|
||||||
|
let error_message: Option<String> = 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<String> = 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()?,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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<uuid::Uuid, DomainError> {
|
|
||||||
s.parse()
|
|
||||||
.map_err(|_| DomainError::InfrastructureError(format!("invalid UUID: {s}")))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_datetime(s: &str) -> Result<chrono::NaiveDateTime, DomainError> {
|
|
||||||
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<u64, DomainError> {
|
|
||||||
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<u64, DomainError> {
|
|
||||||
let before_str = datetime_to_str(&before);
|
|
||||||
let result =
|
|
||||||
sqlx::query("DELETE FROM watch_events WHERE status != 'pending' AND created_at < ?")
|
|
||||||
.bind(&before_str)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(crate::shared::map_sqlx_error)?;
|
|
||||||
Ok(result.rows_affected())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl WatchEventQuery for SqliteWatchEventRepository {
|
|
||||||
async fn list_pending(&self, user_id: &UserId) -> Result<Vec<WatchEvent>, DomainError> {
|
|
||||||
let uid = user_id.value().to_string();
|
|
||||||
|
|
||||||
let rows = sqlx::query(
|
|
||||||
"SELECT id, user_id, movie_id, title, year, external_metadata_id, \
|
|
||||||
source, watched_at, status, created_at \
|
|
||||||
FROM watch_events \
|
|
||||||
WHERE user_id = ? AND status = 'pending' \
|
|
||||||
ORDER BY watched_at DESC",
|
|
||||||
)
|
|
||||||
.bind(&uid)
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(crate::shared::map_sqlx_error)?;
|
|
||||||
|
|
||||||
rows.iter().map(row_to_watch_event).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_by_id(&self, id: &WatchEventId) -> Result<Option<WatchEvent>, DomainError> {
|
|
||||||
let id_str = id.value().to_string();
|
|
||||||
|
|
||||||
let row = sqlx::query(
|
|
||||||
"SELECT id, user_id, movie_id, title, year, external_metadata_id, \
|
|
||||||
source, watched_at, status, created_at \
|
|
||||||
FROM watch_events WHERE id = ?",
|
|
||||||
)
|
|
||||||
.bind(&id_str)
|
|
||||||
.fetch_optional(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(crate::shared::map_sqlx_error)?;
|
|
||||||
|
|
||||||
row.as_ref().map(row_to_watch_event).transpose()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_by_ids(&self, ids: &[WatchEventId]) -> Result<Vec<WatchEvent>, DomainError> {
|
|
||||||
if ids.is_empty() {
|
|
||||||
return Ok(vec![]);
|
|
||||||
}
|
|
||||||
let placeholders: Vec<&str> = ids.iter().map(|_| "?").collect();
|
|
||||||
let sql = format!(
|
|
||||||
"SELECT id, user_id, movie_id, title, year, external_metadata_id, \
|
|
||||||
source, watched_at, status, created_at \
|
|
||||||
FROM watch_events WHERE id IN ({})",
|
|
||||||
placeholders.join(",")
|
|
||||||
);
|
|
||||||
let mut q = sqlx::query(&sql);
|
|
||||||
for id in ids {
|
|
||||||
q = q.bind(id.value().to_string());
|
|
||||||
}
|
|
||||||
let rows = q
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(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<bool, DomainError> {
|
|
||||||
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<WatchEvent, DomainError> {
|
|
||||||
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<i64> = row.try_get("year").map_err(crate::shared::map_sqlx_error)?;
|
|
||||||
let ext_id: Option<String> = 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<Option<WebhookToken>, 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<Vec<WebhookToken>, 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<WebhookToken, DomainError> {
|
|
||||||
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<String> = 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<String> = 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,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user