refactor: sqlite repositories per aggregate

This commit is contained in:
2026-10-25 20:55:33 +01:00
parent 67597ec9a6
commit ab4e4c3138
65 changed files with 2500 additions and 2346 deletions

View File

@@ -1,54 +1,23 @@
use sqlx::SqlitePool;
mod diary;
mod event_queue;
mod goals;
mod image_ref;
mod import_profile;
mod import_session;
mod insights;
mod migrations;
mod models;
mod movie;
mod movie_dedup;
mod persons;
mod profile;
mod profile_fields;
mod refresh_sessions;
mod review;
mod search;
mod repositories;
mod shared;
mod social;
mod stats;
mod user_settings;
mod users;
mod watch_event;
mod watchlist;
mod wrapup;
pub use diary::SqliteDiaryRepository;
pub use event_queue::{DbEventQueueConfig, SqliteEventQueue};
pub use image_ref::{SqliteImageRefAdapter, create_image_ref};
pub use import_profile::SqliteImportProfileRepository;
pub use import_session::SqliteImportSessionRepository;
pub use insights::SqliteInsightsQuery;
pub use movie::SqliteMovieRepository;
pub use movie_dedup::SqliteMovieDeduplicator;
pub use persons::{SqlitePersonAdapter, create_person_adapter};
pub use profile::SqliteMovieProfileRepository;
pub use profile_fields::SqliteProfileFieldsRepository;
pub use refresh_sessions::SqliteRefreshSessionAdapter;
pub use review::SqliteReviewRepository;
pub use search::create_search_adapter;
pub use social::{
SqliteApContentQuery, SqliteRemoteGoalRepository, SqliteSocialRepository,
create_federated_profile_query,
pub use repositories::{
SqliteApContentQuery, SqliteDiaryRepository, SqliteImageRefAdapter,
SqliteImportProfileRepository, SqliteImportSessionRepository, SqliteInsightsQuery,
SqliteMovieDeduplicator, SqliteMovieProfileRepository, SqliteMovieRepository,
SqlitePersonAdapter, SqliteProfileFieldsRepository, SqliteRefreshSessionAdapter,
SqliteRemoteGoalRepository, SqliteReviewRepository, SqliteSocialRepository,
SqliteStatsRepository, SqliteUserRepository, SqliteWatchEventRepository,
SqliteWatchlistRepository, SqliteWebhookTokenRepository, SqliteWrapUpRepository,
SqliteWrapUpStatsQuery, create_federated_profile_query, create_image_ref,
create_person_adapter, create_search_adapter,
};
pub use stats::SqliteStatsRepository;
pub use users::SqliteUserRepository;
pub use watch_event::{SqliteWatchEventRepository, SqliteWebhookTokenRepository};
pub use watchlist::SqliteWatchlistRepository;
pub use wrapup::{SqliteWrapUpRepository, SqliteWrapUpStatsQuery};
use repositories::{SqliteGoalRepository, SqliteUserSettingsRepository};
/// Row helpers shared with the `sqlite-federation` crate (which cannot live
/// here because it depends on `activitypub`/`k-ap`).
@@ -111,9 +80,7 @@ pub async fn wire(database_url: &str) -> anyhow::Result<SqliteWireOutput> {
.map_err(|e| anyhow::anyhow!("{e}"))
.context("Database migration failed")?;
let user_settings_repo = std::sync::Arc::new(user_settings::SqliteUserSettingsRepository::new(
pool.clone(),
));
let user_settings_repo = std::sync::Arc::new(SqliteUserSettingsRepository::new(pool.clone()));
let movie_repo = std::sync::Arc::new(SqliteMovieRepository::new(pool.clone()));
@@ -133,8 +100,8 @@ pub async fn wire(database_url: &str) -> anyhow::Result<SqliteWireOutput> {
wrapup_repo: std::sync::Arc::new(SqliteWrapUpRepository::new(pool.clone())) as _,
wrapup_stats: std::sync::Arc::new(SqliteWrapUpStatsQuery::new(pool.clone())) as _,
insights: std::sync::Arc::new(SqliteInsightsQuery::new(pool.clone())) as _,
goal_command: std::sync::Arc::new(goals::SqliteGoalRepository::new(pool.clone())) as _,
goal_query: std::sync::Arc::new(goals::SqliteGoalRepository::new(pool.clone())) as _,
goal_command: std::sync::Arc::new(SqliteGoalRepository::new(pool.clone())) as _,
goal_query: std::sync::Arc::new(SqliteGoalRepository::new(pool.clone())) as _,
user_settings: std::sync::Arc::clone(&user_settings_repo) as _,
federation_settings: user_settings_repo as _,
remote_goal: std::sync::Arc::new(SqliteRemoteGoalRepository::new(pool.clone())) as _,

View File

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

View File

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

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

View File

@@ -1,34 +1,19 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
models::{
DiaryEntry, Movie, PersistedReview, Review, ReviewSource, WatchlistEntry,
WatchlistWithMovie,
},
ports::LocalApContentQuery,
value_objects::{
Comment, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
ReviewId, UserId, WatchlistEntryId,
},
};
use sqlx::SqlitePool;
pub struct SqliteApContentQuery {
pool: SqlitePool,
}
impl SqliteApContentQuery {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
// ── Local row types ──────────────────────────────────────────────────────────
use crate::shared::{parse_datetime, parse_uuid};
#[derive(sqlx::FromRow)]
struct MovieRow {
pub(super) struct MovieRow {
id: String,
external_metadata_id: Option<String>,
title: String,
@@ -38,7 +23,7 @@ struct 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 external_metadata_id = self
.external_metadata_id
@@ -59,7 +44,7 @@ impl MovieRow {
}
#[derive(sqlx::FromRow)]
struct ReviewRow {
pub(super) struct ReviewRow {
id: String,
movie_id: String,
user_id: String,
@@ -73,7 +58,7 @@ struct ReviewRow {
}
impl ReviewRow {
fn into_domain(self) -> Result<Review, DomainError> {
pub(super) fn into_domain(self) -> Result<Review, DomainError> {
let id = ReviewId::from_uuid(parse_uuid(&self.id)?);
let movie_id = MovieId::from_uuid(parse_uuid(&self.movie_id)?);
let user_id = UserId::from_uuid(parse_uuid(&self.user_id)?);
@@ -102,7 +87,7 @@ impl ReviewRow {
}
#[derive(sqlx::FromRow)]
struct DiaryRow {
pub(super) struct DiaryRow {
id: String,
external_metadata_id: Option<String>,
title: String,
@@ -122,7 +107,7 @@ struct DiaryRow {
}
impl DiaryRow {
fn into_domain(self) -> Result<DiaryEntry, DomainError> {
pub(super) fn into_domain(self) -> Result<DiaryEntry, DomainError> {
let movie = MovieRow {
id: self.id,
external_metadata_id: self.external_metadata_id,
@@ -150,7 +135,7 @@ impl DiaryRow {
}
#[derive(sqlx::FromRow)]
struct WatchlistRow {
pub(super) struct WatchlistRow {
id: String,
user_id: String,
movie_id: String,
@@ -164,7 +149,7 @@ struct WatchlistRow {
}
impl WatchlistRow {
fn into_domain(self) -> Result<WatchlistWithMovie, DomainError> {
pub(super) fn into_domain(self) -> Result<WatchlistWithMovie, DomainError> {
let entry = WatchlistEntry {
id: WatchlistEntryId::from_uuid(parse_uuid(&self.id)?),
user_id: UserId::from_uuid(parse_uuid(&self.user_id)?),
@@ -185,92 +170,3 @@ impl WatchlistRow {
}
// ── LocalApContentQuery impl ─────────────────────────────────────────────────
#[async_trait]
impl LocalApContentQuery for SqliteApContentQuery {
async fn get_local_watchlist_for_user(
&self,
user_id: &UserId,
) -> Result<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()
}
}

View 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;

View File

@@ -2,180 +2,18 @@ use async_trait::async_trait;
use domain::{
errors::DomainError,
models::{
DiaryEntry, DiaryFilter, FeedEntry, MovieStats, ReviewHistory, ReviewSortBy,
DiaryEntry, DiaryFilter, FeedEntry, MovieStats, ReviewHistory,
collections::{PageParams, Paginated},
},
ports::DiaryQuery,
value_objects::{MovieId, UserId},
};
use futures::stream::BoxStream;
use sqlx::SqlitePool;
use crate::models::{DiaryRow, FeedRow, MovieRow, MovieStatsRow, ReviewRow};
pub struct SqliteDiaryRepository {
pool: SqlitePool,
}
impl SqliteDiaryRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
async fn count_diary_entries(&self, movie_id: Option<&str>) -> Result<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)
}
}
use super::SqliteDiaryRepository;
use super::rows::{DiaryRow, FeedRow, MovieStatsRow};
use crate::repositories::movie::rows::MovieRow;
use crate::repositories::review::rows::ReviewRow;
#[async_trait]
impl DiaryQuery for SqliteDiaryRepository {
@@ -516,7 +354,3 @@ impl DiaryQuery for SqliteDiaryRepository {
Ok(count as u64)
}
}
#[cfg(test)]
#[path = "tests/diary.rs"]
mod tests;

View 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,
],
)
}
}

View File

@@ -66,5 +66,4 @@ impl ImageRefQuery for SqliteImageRefAdapter {
}
#[cfg(test)]
#[path = "tests/image_ref.rs"]
mod tests;

View File

@@ -2,91 +2,14 @@ use async_trait::async_trait;
use chrono::NaiveDateTime;
use domain::{
errors::DomainError,
models::{
FieldMapping, ImportProfile,
import::{DomainField, Transform},
},
models::ImportProfile,
ports::ImportProfileRepository,
value_objects::{ImportProfileId, UserId},
};
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
#[derive(Serialize, Deserialize)]
enum DomainFieldJson {
Title,
ReleaseYear,
Director,
Rating,
WatchedAt,
Comment,
ExternalMetadataId,
}
#[derive(Serialize, Deserialize)]
enum TransformJson {
RatingScale(f64),
DateFormat(String),
Identity,
}
#[derive(Serialize, Deserialize)]
struct FieldMappingJson {
source_column: String,
domain_field: DomainFieldJson,
transform: TransformJson,
}
fn mapping_to_json(m: &FieldMapping) -> FieldMappingJson {
FieldMappingJson {
source_column: m.source_column.clone(),
domain_field: match &m.domain_field {
DomainField::Title => DomainFieldJson::Title,
DomainField::ReleaseYear => DomainFieldJson::ReleaseYear,
DomainField::Director => DomainFieldJson::Director,
DomainField::Rating => DomainFieldJson::Rating,
DomainField::WatchedAt => DomainFieldJson::WatchedAt,
DomainField::Comment => DomainFieldJson::Comment,
DomainField::ExternalMetadataId => DomainFieldJson::ExternalMetadataId,
},
transform: match &m.transform {
Transform::RatingScale(f) => TransformJson::RatingScale(*f),
Transform::DateFormat(s) => TransformJson::DateFormat(s.clone()),
Transform::Identity => TransformJson::Identity,
},
}
}
fn mapping_from_json(j: FieldMappingJson) -> FieldMapping {
FieldMapping {
source_column: j.source_column,
domain_field: match j.domain_field {
DomainFieldJson::Title => DomainField::Title,
DomainFieldJson::ReleaseYear => DomainField::ReleaseYear,
DomainFieldJson::Director => DomainField::Director,
DomainFieldJson::Rating => DomainField::Rating,
DomainFieldJson::WatchedAt => DomainField::WatchedAt,
DomainFieldJson::Comment => DomainField::Comment,
DomainFieldJson::ExternalMetadataId => DomainField::ExternalMetadataId,
},
transform: match j.transform {
TransformJson::RatingScale(f) => Transform::RatingScale(f),
TransformJson::DateFormat(s) => Transform::DateFormat(s),
TransformJson::Identity => Transform::Identity,
},
}
}
fn serialize_mappings(ms: &[FieldMapping]) -> Result<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())
}
mod rows;
use rows::*;
pub struct SqliteImportProfileRepository {
pool: SqlitePool,

View File

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

View File

@@ -2,194 +2,14 @@ use async_trait::async_trait;
use chrono::NaiveDateTime;
use domain::{
errors::DomainError,
models::{
AnnotatedRow, FieldMapping, ImportSession, ParsedFile,
import::{DomainField, ImportRow, RowResult, Transform},
},
models::{AnnotatedRow, FieldMapping, ImportSession, ParsedFile},
ports::ImportSessionRepository,
value_objects::{ImportSessionId, UserId},
};
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
// ── serde mirror structs (match the JSON format from the old importer types) ──
#[derive(Serialize, Deserialize, Default)]
struct ParsedFileJson {
columns: Vec<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()))
}
mod rows;
use rows::*;
// ── repository ──

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

View 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};

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

View 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 }
}
}

View File

@@ -5,66 +5,12 @@ use domain::{
Movie, MovieFilter, MovieSummary,
collections::{PageParams, Paginated},
},
ports::{MovieCommand, MovieQuery},
ports::MovieQuery,
value_objects::{ExternalMetadataId, MovieId, MovieTitle, ReleaseYear},
};
use sqlx::SqlitePool;
use crate::models::{MovieRow, MovieSummaryRow};
pub struct SqliteMovieRepository {
pool: SqlitePool,
}
impl SqliteMovieRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
#[async_trait]
impl MovieCommand for SqliteMovieRepository {
async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError> {
let id = movie.id().value().to_string();
let external_metadata_id = movie.external_metadata_id().map(|e| e.value().to_string());
let title = movie.title().value();
let release_year = movie.release_year().value() as i64;
let director = movie.director();
let poster_path = movie.poster_path().map(|p| p.value().to_string());
sqlx::query(
"INSERT INTO movies (id, external_metadata_id, title, release_year, director, poster_path)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
external_metadata_id = excluded.external_metadata_id,
title = excluded.title,
release_year = excluded.release_year,
director = excluded.director,
poster_path = excluded.poster_path",
)
.bind(&id)
.bind(&external_metadata_id)
.bind(title)
.bind(release_year)
.bind(director)
.bind(&poster_path)
.execute(&self.pool)
.await
.map_err(crate::shared::map_sqlx_error)?;
Ok(())
}
async fn delete_movie(&self, movie_id: &MovieId) -> Result<(), DomainError> {
let id = movie_id.value().to_string();
sqlx::query("DELETE FROM movies WHERE id = ?")
.bind(&id)
.execute(&self.pool)
.await
.map_err(crate::shared::map_sqlx_error)?;
Ok(())
}
}
use super::SqliteMovieRepository;
use super::rows::{MovieRow, MovieSummaryRow};
#[async_trait]
impl MovieQuery for SqliteMovieRepository {

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

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

View 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;

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

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

View File

@@ -1,4 +1,4 @@
use super::super::persons::SqlitePersonAdapter;
use super::SqlitePersonAdapter;
use domain::{
models::{ExternalPersonId, Person, PersonId},
ports::{PersonCommand, PersonQuery},

View File

@@ -300,5 +300,4 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
}
#[cfg(test)]
#[path = "tests/profile.rs"]
mod tests;

View File

@@ -1,4 +1,4 @@
use super::super::profile::SqliteMovieProfileRepository;
use super::SqliteMovieProfileRepository;
use domain::{ports::MovieProfileRepository, value_objects::MovieId};
use sqlx::SqlitePool;

View File

@@ -7,7 +7,9 @@ use domain::{
};
use sqlx::SqlitePool;
use crate::models::ReviewRow;
use rows::ReviewRow;
pub(crate) mod rows;
use crate::shared::datetime_to_str;
pub struct SqliteReviewRepository {

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

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

View 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;

View File

@@ -1,160 +1,13 @@
use std::sync::Arc;
use async_trait::async_trait;
use domain::{
errors::DomainError,
models::PersonId,
models::{
EntityType, IndexableDocument, MovieSearchHit, PersonSearchHit, SearchQuery, SearchResults,
collections::Paginated,
},
ports::{SearchCommand, SearchPort},
models::{MovieSearchHit, PersonSearchHit, SearchQuery, SearchResults, collections::Paginated},
ports::SearchPort,
value_objects::MovieId,
};
use sqlx::SqlitePool;
pub struct SqliteSearchAdapter {
pool: SqlitePool,
}
impl SqliteSearchAdapter {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
pub fn create_search_adapter(pool: SqlitePool) -> (Arc<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(())
}
}
use super::SqliteSearchAdapter;
#[async_trait]
impl SearchPort for SqliteSearchAdapter {
@@ -389,6 +242,3 @@ impl SqliteSearchAdapter {
})
}
}
#[cfg(test)]
mod tests;

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

View File

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

View File

@@ -1,197 +1,13 @@
use async_trait::async_trait;
use chrono::Utc;
use domain::{
errors::DomainError,
value_objects::{FollowRelation, FollowStatus, InstanceIdentity, SocialActor, SocialIdentity},
value_objects::{FollowRelation, SocialActor},
};
use sqlx::Row;
use crate::SqliteSocialRepository;
use crate::shared::datetime_to_str;
fn follow_status_to_str(status: &FollowStatus) -> &'static str {
match status {
FollowStatus::Pending => "pending",
FollowStatus::Accepted => "accepted",
FollowStatus::Rejected => "rejected",
}
}
fn infra_err(e: impl std::fmt::Display) -> DomainError {
DomainError::InfrastructureError(e.to_string())
}
fn follow_status_from_str(status: &str) -> Option<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,
}
}
use super::infra_err;
use super::rows::{follow_status_from_str, social_actor_from_row};
use crate::repositories::social::SqliteSocialRepository;
#[async_trait]
impl domain::ports::FollowQuery for SqliteSocialRepository {

View File

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

View File

@@ -1,14 +1,8 @@
mod federated_profile;
mod follow_repository;
mod follow;
mod local_social;
mod watchlist;
pub mod ap_content;
pub mod remote_goals;
pub use ap_content::SqliteApContentQuery;
pub use remote_goals::SqliteRemoteGoalRepository;
use sqlx::SqlitePool;
pub struct SqliteSocialRepository {
@@ -30,5 +24,4 @@ pub fn create_federated_profile_query(
}
#[cfg(test)]
#[path = "tests/follow_relation_tests.rs"]
mod follow_relation_tests;
mod tests;

View File

@@ -9,11 +9,13 @@ use domain::{
};
use sqlx::SqlitePool;
use crate::models::{
use rows::{
ActorAppearanceRow, DirectorCountRow, GenreCountRow, MonthlyRatingRow, RatingDistRow,
UserTotalsRow, WatchMediumCountRow,
};
mod rows;
pub struct SqliteStatsRepository {
pool: SqlitePool,
}
@@ -97,7 +99,7 @@ impl StatsRepository for SqliteStatsRepository {
}
async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result<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> {

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

View File

@@ -2,7 +2,9 @@ use async_trait::async_trait;
use chrono::Utc;
use sqlx::{Row, SqlitePool};
use super::models::UserSummaryRow;
use rows::UserSummaryRow;
mod rows;
use domain::{
errors::DomainError,
models::{ProfileField, User, UserRole},
@@ -197,5 +199,4 @@ impl UserRepository for SqliteUserRepository {
}
#[cfg(test)]
#[path = "tests/users.rs"]
mod tests;

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

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

View 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 }
}
}

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

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

View File

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

View File

@@ -10,7 +10,9 @@ use domain::{
};
use sqlx::{Row, SqlitePool};
use crate::models::WatchlistRow;
use rows::WatchlistRow;
mod rows;
use crate::shared::datetime_to_str;
pub struct SqliteWatchlistRepository {

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

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

View File

@@ -1,276 +1,15 @@
use std::collections::HashMap;
use async_trait::async_trait;
use chrono::NaiveDate;
use domain::{
errors::DomainError,
models::wrapup::{
DateRange, WrapUpMovieRow, WrapUpRecord, WrapUpReport, WrapUpScope, WrapUpStatus,
},
ports::{WrapUpRepository, WrapUpStatsQuery},
value_objects::WrapUpId,
models::wrapup::{DateRange, WrapUpMovieRow, WrapUpScope},
ports::WrapUpStatsQuery,
};
use sqlx::{Row, SqlitePool};
use uuid::Uuid;
use crate::shared::{parse_datetime, parse_uuid};
fn status_to_str(s: &WrapUpStatus) -> &'static str {
match s {
WrapUpStatus::Pending => "pending",
WrapUpStatus::Generating => "generating",
WrapUpStatus::Ready => "ready",
WrapUpStatus::Failed => "failed",
}
}
fn parse_status(s: &str) -> Result<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 ─────────────────────────────────────────────────────────
pub struct SqliteWrapUpStatsQuery {

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

View File

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