fmt
Some checks failed
CI / Check / Test / Build (push) Has been cancelled

This commit is contained in:
2026-05-13 23:38:57 +02:00
parent 7415b91e23
commit 19171806b9
142 changed files with 4140 additions and 2025 deletions

View File

@@ -1,5 +1,8 @@
use async_trait::async_trait;
use domain::{errors::DomainError, ports::{ImageRefCommand, ImageRefQuery}};
use domain::{
errors::DomainError,
ports::{ImageRefCommand, ImageRefQuery},
};
use sqlx::PgPool;
use std::sync::Arc;
@@ -15,23 +18,34 @@ impl PostgresImageRefAdapter {
pub fn create_image_ref(pool: PgPool) -> (Arc<dyn ImageRefCommand>, Arc<dyn ImageRefQuery>) {
let adapter = Arc::new(PostgresImageRefAdapter::new(pool));
(Arc::clone(&adapter) as Arc<dyn ImageRefCommand>, adapter as Arc<dyn ImageRefQuery>)
(
Arc::clone(&adapter) as Arc<dyn ImageRefCommand>,
adapter as Arc<dyn ImageRefQuery>,
)
}
#[async_trait]
impl ImageRefCommand for PostgresImageRefAdapter {
async fn swap(&self, old_key: &str, new_key: &str) -> Result<(), DomainError> {
let mut tx = self.pool.begin().await
let mut tx = self
.pool
.begin()
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
sqlx::query("UPDATE users SET avatar_path = $1 WHERE avatar_path = $2")
.bind(new_key).bind(old_key)
.execute(&mut *tx).await
.bind(new_key)
.bind(old_key)
.execute(&mut *tx)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
sqlx::query("UPDATE movies SET poster_path = $1 WHERE poster_path = $2")
.bind(new_key).bind(old_key)
.execute(&mut *tx).await
.bind(new_key)
.bind(old_key)
.execute(&mut *tx)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
tx.commit().await
tx.commit()
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
}
}

View File

@@ -14,12 +14,20 @@ use sqlx::PgPool;
#[derive(Serialize, Deserialize)]
enum DomainFieldJson {
Title, ReleaseYear, Director, Rating, WatchedAt, Comment, ExternalMetadataId,
Title,
ReleaseYear,
Director,
Rating,
WatchedAt,
Comment,
ExternalMetadataId,
}
#[derive(Serialize, Deserialize)]
enum TransformJson {
RatingScale(f64), DateFormat(String), Identity,
RatingScale(f64),
DateFormat(String),
Identity,
}
#[derive(Serialize, Deserialize)]
@@ -75,8 +83,8 @@ fn serialize_mappings(ms: &[FieldMapping]) -> Result<String, DomainError> {
}
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()))?;
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())
}
@@ -85,7 +93,9 @@ pub struct PostgresImportProfileRepository {
}
impl PostgresImportProfileRepository {
pub fn new(pool: PgPool) -> Self { Self { pool } }
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("DB error: {:?}", e);
@@ -115,7 +125,13 @@ impl ImportProfileRepository for PostgresImportProfileRepository {
let uid = user_id.value().to_string();
#[derive(sqlx::FromRow)]
struct Row { id: String, user_id: String, name: String, field_mappings: String, created_at: NaiveDateTime }
struct Row {
id: String,
user_id: String,
name: String,
field_mappings: String,
created_at: NaiveDateTime,
}
let rows = sqlx::query_as::<_, Row>(
"SELECT id, user_id, name, field_mappings, created_at FROM import_profiles WHERE user_id = $1 ORDER BY created_at DESC",
@@ -125,25 +141,42 @@ impl ImportProfileRepository for PostgresImportProfileRepository {
.await
.map_err(Self::map_err)?;
rows.into_iter().map(|r| Ok(ImportProfile {
id: ImportProfileId::from_uuid(
r.id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))?
),
user_id: UserId::from_uuid(
r.user_id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))?
),
name: r.name,
field_mappings: deserialize_mappings(&r.field_mappings)?,
created_at: r.created_at,
})).collect()
rows.into_iter()
.map(|r| {
Ok(ImportProfile {
id: ImportProfileId::from_uuid(
r.id.parse::<uuid::Uuid>()
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
),
user_id: UserId::from_uuid(
r.user_id
.parse::<uuid::Uuid>()
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
),
name: r.name,
field_mappings: deserialize_mappings(&r.field_mappings)?,
created_at: r.created_at,
})
})
.collect()
}
async fn get(&self, id: &ImportProfileId, user_id: &UserId) -> Result<Option<ImportProfile>, DomainError> {
async fn get(
&self,
id: &ImportProfileId,
user_id: &UserId,
) -> Result<Option<ImportProfile>, DomainError> {
let id_str = id.value().to_string();
let uid_str = user_id.value().to_string();
#[derive(sqlx::FromRow)]
struct Row { id: String, user_id: String, name: String, field_mappings: String, created_at: NaiveDateTime }
struct Row {
id: String,
user_id: String,
name: String,
field_mappings: String,
created_at: NaiveDateTime,
}
let row = sqlx::query_as::<_, Row>(
"SELECT id, user_id, name, field_mappings, created_at FROM import_profiles WHERE id = $1 AND user_id = $2",
@@ -153,17 +186,23 @@ impl ImportProfileRepository for PostgresImportProfileRepository {
.await
.map_err(Self::map_err)?;
row.map(|r| Ok(ImportProfile {
id: ImportProfileId::from_uuid(
r.id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))?
),
user_id: UserId::from_uuid(
r.user_id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))?
),
name: r.name,
field_mappings: deserialize_mappings(&r.field_mappings)?,
created_at: r.created_at,
})).transpose()
row.map(|r| {
Ok(ImportProfile {
id: ImportProfileId::from_uuid(
r.id.parse::<uuid::Uuid>()
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
),
user_id: UserId::from_uuid(
r.user_id
.parse::<uuid::Uuid>()
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
),
name: r.name,
field_mappings: deserialize_mappings(&r.field_mappings)?,
created_at: r.created_at,
})
})
.transpose()
}
async fn delete(&self, id: &ImportProfileId) -> Result<(), DomainError> {

View File

@@ -22,7 +22,13 @@ struct ParsedFileJson {
#[derive(Serialize, Deserialize)]
enum DomainFieldJson {
Title, ReleaseYear, Director, Rating, WatchedAt, Comment, ExternalMetadataId,
Title,
ReleaseYear,
Director,
Rating,
WatchedAt,
Comment,
ExternalMetadataId,
}
#[derive(Serialize, Deserialize)]
@@ -41,19 +47,29 @@ struct FieldMappingJson {
#[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>,
#[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)> },
Invalid {
errors: Vec<String>,
raw: Vec<(String, String)>,
},
}
#[derive(Serialize, Deserialize)]
@@ -182,22 +198,37 @@ pub struct PostgresImportSessionRepository {
}
impl PostgresImportSessionRepository {
pub fn new(pool: PgPool) -> Self { Self { pool } }
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("DB error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
fn serialize_session(s: &ImportSession) -> Result<(String, Option<String>, Option<String>), DomainError> {
let parsed = s.parsed_file.as_ref()
.map(|f| ser(&ParsedFileJson { columns: f.columns.clone(), rows: f.rows.clone() }))
fn serialize_session(
s: &ImportSession,
) -> Result<(String, Option<String>, Option<String>), DomainError> {
let parsed = s
.parsed_file
.as_ref()
.map(|f| {
ser(&ParsedFileJson {
columns: f.columns.clone(),
rows: f.rows.clone(),
})
})
.transpose()?
.unwrap_or_default();
let mappings = s.field_mappings.as_ref()
let mappings = s
.field_mappings
.as_ref()
.map(|ms| ser(&ms.iter().map(mapping_to_json).collect::<Vec<_>>()))
.transpose()?;
let results = s.row_results.as_ref()
let results = s
.row_results
.as_ref()
.map(|rs| ser(&rs.iter().map(annotated_to_json).collect::<Vec<_>>()))
.transpose()?;
Ok((parsed, mappings, results))
@@ -216,15 +247,20 @@ impl PostgresImportSessionRepository {
None
} else {
let j: ParsedFileJson = de(&parsed_data)?;
Some(ParsedFile { columns: j.columns, rows: j.rows })
Some(ParsedFile {
columns: j.columns,
rows: j.rows,
})
};
let field_mappings = field_mappings.as_deref()
let field_mappings = field_mappings
.as_deref()
.map(|s| -> Result<Vec<FieldMapping>, DomainError> {
let js: Vec<FieldMappingJson> = de(s)?;
Ok(js.into_iter().map(mapping_from_json).collect())
})
.transpose()?;
let row_results = row_results.as_deref()
let row_results = row_results
.as_deref()
.map(|s| -> Result<Vec<AnnotatedRow>, DomainError> {
let js: Vec<AnnotatedRowJson> = de(s)?;
Ok(js.into_iter().map(annotated_from_json).collect())
@@ -232,10 +268,13 @@ impl PostgresImportSessionRepository {
.transpose()?;
Ok(ImportSession {
id: ImportSessionId::from_uuid(
id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))?
id.parse::<uuid::Uuid>()
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
),
user_id: UserId::from_uuid(
user_id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))?
user_id
.parse::<uuid::Uuid>()
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
),
parsed_file,
field_mappings,
@@ -265,7 +304,11 @@ impl ImportSessionRepository for PostgresImportSessionRepository {
.map_err(Self::map_err)
}
async fn get(&self, id: &ImportSessionId, user_id: &UserId) -> Result<Option<ImportSession>, DomainError> {
async fn get(
&self,
id: &ImportSessionId,
user_id: &UserId,
) -> Result<Option<ImportSession>, DomainError> {
let id_str = id.value().to_string();
let uid_str = user_id.value().to_string();
@@ -284,26 +327,39 @@ impl ImportSessionRepository for PostgresImportSessionRepository {
"SELECT id, user_id, parsed_data, field_mappings, row_results, created_at, expires_at
FROM import_sessions WHERE id = $1 AND user_id = $2",
)
.bind(&id_str).bind(&uid_str)
.bind(&id_str)
.bind(&uid_str)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?;
row.map(|r| Self::deserialize_session(
r.id, r.user_id, r.parsed_data, r.field_mappings, r.row_results,
r.created_at, r.expires_at,
)).transpose()
row.map(|r| {
Self::deserialize_session(
r.id,
r.user_id,
r.parsed_data,
r.field_mappings,
r.row_results,
r.created_at,
r.expires_at,
)
})
.transpose()
}
async fn update(&self, s: &ImportSession) -> Result<(), DomainError> {
let id = s.id.value().to_string();
let (_, field_mappings, row_results) = Self::serialize_session(s)?;
sqlx::query("UPDATE import_sessions SET field_mappings = $1, row_results = $2 WHERE id = $3")
.bind(&field_mappings).bind(&row_results).bind(&id)
.execute(&self.pool)
.await
.map(|_| ())
.map_err(Self::map_err)
sqlx::query(
"UPDATE import_sessions SET field_mappings = $1, row_results = $2 WHERE id = $3",
)
.bind(&field_mappings)
.bind(&row_results)
.bind(&id)
.execute(&self.pool)
.await
.map(|_| ())
.map_err(Self::map_err)
}
async fn delete(&self, id: &ImportSessionId) -> Result<(), DomainError> {

View File

@@ -388,11 +388,15 @@ impl MovieRepository for PostgresRepository {
&self,
page: &domain::models::collections::PageParams,
filter: &domain::models::MovieFilter,
) -> Result<domain::models::collections::Paginated<domain::models::MovieSummary>, DomainError> {
) -> Result<domain::models::collections::Paginated<domain::models::MovieSummary>, DomainError>
{
use sqlx::Row;
let limit = page.limit as i64;
let offset = page.offset as i64;
let pattern = filter.search.as_deref().map(|s| format!("%{}%", s.to_lowercase()));
let pattern = filter
.search
.as_deref()
.map(|s| format!("%{}%", s.to_lowercase()));
let genre = filter.genre.as_deref();
let language = filter.language.as_deref();
@@ -612,8 +616,7 @@ impl DiaryRepository for PostgresRepository {
}
if let Some(f) = following {
let local_params: Vec<String> =
f.local_user_ids.iter().map(|_| next_param()).collect();
let local_params: Vec<String> = f.local_user_ids.iter().map(|_| next_param()).collect();
let remote_params: Vec<String> =
f.remote_actor_urls.iter().map(|_| next_param()).collect();
@@ -691,10 +694,7 @@ impl DiaryRepository for PostgresRepository {
}
let count_q = bind_filter_params!(sqlx::query_scalar::<_, i64>(&count_sql));
let total = count_q
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)?;
let total = count_q.fetch_one(&self.pool).await.map_err(Self::map_err)?;
let rows_q = bind_filter_params!(sqlx::query_as::<_, FeedRow>(&select_sql));
let rows = rows_q
@@ -800,13 +800,11 @@ impl DiaryRepository for PostgresRepository {
let limit = page.limit as i64;
let offset = page.offset as i64;
let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM reviews WHERE movie_id = $1",
)
.bind(&id_str)
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)?;
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE movie_id = $1")
.bind(&id_str)
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)?;
let rows = sqlx::query_as::<_, FeedRow>(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
@@ -845,12 +843,11 @@ impl DiaryRepository for PostgresRepository {
}
async fn count_local_posts(&self) -> Result<u64, DomainError> {
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM reviews WHERE remote_actor_url IS NULL"
)
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)?;
let count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE remote_actor_url IS NULL")
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(count as u64)
}
}
@@ -939,7 +936,9 @@ pub fn create_profile_fields_repo(
std::sync::Arc::new(profile_fields::PostgresProfileFieldsRepository::new(pool))
}
pub async fn wire(database_url: &str) -> anyhow::Result<(
pub async fn wire(
database_url: &str,
) -> anyhow::Result<(
sqlx::PgPool,
std::sync::Arc<dyn domain::ports::MovieRepository>,
std::sync::Arc<dyn domain::ports::ReviewRepository>,
@@ -963,8 +962,10 @@ pub async fn wire(database_url: &str) -> anyhow::Result<(
.map_err(|e| anyhow::anyhow!("{e}"))
.context("Database migration failed")?;
let import_session_repo = std::sync::Arc::new(PostgresImportSessionRepository::new(pool.clone()));
let import_profile_repo = std::sync::Arc::new(PostgresImportProfileRepository::new(pool.clone()));
let import_session_repo =
std::sync::Arc::new(PostgresImportSessionRepository::new(pool.clone()));
let import_profile_repo =
std::sync::Arc::new(PostgresImportProfileRepository::new(pool.clone()));
let movie_profile_repo = std::sync::Arc::new(PostgresMovieProfileRepository::new(pool.clone()));
let watchlist_repo = std::sync::Arc::new(PostgresWatchlistRepository::new(pool.clone()));

View File

@@ -20,7 +20,10 @@ impl PostgresPersonAdapter {
pub fn create_person_adapter(pool: PgPool) -> (Arc<dyn PersonCommand>, Arc<dyn PersonQuery>) {
let adapter = Arc::new(PostgresPersonAdapter::new(pool));
(Arc::clone(&adapter) as Arc<dyn PersonCommand>, adapter as Arc<dyn PersonQuery>)
(
Arc::clone(&adapter) as Arc<dyn PersonCommand>,
adapter as Arc<dyn PersonQuery>,
)
}
fn map_err(e: sqlx::Error) -> DomainError {
@@ -88,7 +91,10 @@ impl PersonQuery for PostgresPersonAdapter {
}))
}
async fn get_by_external_id(&self, id: &ExternalPersonId) -> Result<Option<Person>, DomainError> {
async fn get_by_external_id(
&self,
id: &ExternalPersonId,
) -> Result<Option<Person>, DomainError> {
#[derive(sqlx::FromRow)]
struct Row {
id: String,
@@ -119,21 +125,25 @@ impl PersonQuery for PostgresPersonAdapter {
}
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 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 = $1",
)
.bind(id.value().to_string())
.fetch_optional(&self.pool)
.await
.map_err(map_err)?
.flatten();
let tmdb_id: Option<i64> =
sqlx::query_scalar("SELECT tmdb_person_id FROM persons WHERE id = $1")
.bind(id.value().to_string())
.fetch_optional(&self.pool)
.await
.map_err(map_err)?
.flatten();
let Some(tmdb_id) = tmdb_id else {
return Ok(PersonCredits { person, cast: vec![], crew: vec![] });
return Ok(PersonCredits {
person,
cast: vec![],
crew: vec![],
});
};
#[derive(sqlx::FromRow)]

View File

@@ -65,7 +65,9 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
sqlx::query("DELETE FROM movie_genres WHERE movie_id = $1")
.bind(&movie_id)
.execute(&mut *tx).await.map_err(Self::map_err)?;
.execute(&mut *tx)
.await
.map_err(Self::map_err)?;
for g in &p.genres {
sqlx::query("INSERT INTO movie_genres (movie_id, tmdb_id, name) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING")
.bind(&movie_id).bind(g.tmdb_id as i32).bind(&g.name)
@@ -74,7 +76,9 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
sqlx::query("DELETE FROM movie_keywords WHERE movie_id = $1")
.bind(&movie_id)
.execute(&mut *tx).await.map_err(Self::map_err)?;
.execute(&mut *tx)
.await
.map_err(Self::map_err)?;
for k in &p.keywords {
sqlx::query("INSERT INTO movie_keywords (movie_id, tmdb_id, name) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING")
.bind(&movie_id).bind(k.tmdb_id as i32).bind(&k.name)
@@ -83,30 +87,46 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
sqlx::query("DELETE FROM movie_cast WHERE movie_id = $1")
.bind(&movie_id)
.execute(&mut *tx).await.map_err(Self::map_err)?;
.execute(&mut *tx)
.await
.map_err(Self::map_err)?;
for c in &p.cast {
sqlx::query(
"INSERT INTO movie_cast \
(movie_id, tmdb_person_id, name, character, billing_order, profile_path) \
VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT DO NOTHING",
)
.bind(&movie_id).bind(c.tmdb_person_id as i64).bind(&c.name)
.bind(&c.character).bind(c.billing_order as i32).bind(&c.profile_path)
.execute(&mut *tx).await.map_err(Self::map_err)?;
.bind(&movie_id)
.bind(c.tmdb_person_id as i64)
.bind(&c.name)
.bind(&c.character)
.bind(c.billing_order as i32)
.bind(&c.profile_path)
.execute(&mut *tx)
.await
.map_err(Self::map_err)?;
}
sqlx::query("DELETE FROM movie_crew WHERE movie_id = $1")
.bind(&movie_id)
.execute(&mut *tx).await.map_err(Self::map_err)?;
.execute(&mut *tx)
.await
.map_err(Self::map_err)?;
for cr in &p.crew {
sqlx::query(
"INSERT INTO movie_crew \
(movie_id, tmdb_person_id, name, job, department, profile_path) \
VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT DO NOTHING",
)
.bind(&movie_id).bind(cr.tmdb_person_id as i64).bind(&cr.name)
.bind(&cr.job).bind(&cr.department).bind(&cr.profile_path)
.execute(&mut *tx).await.map_err(Self::map_err)?;
.bind(&movie_id)
.bind(cr.tmdb_person_id as i64)
.bind(&cr.name)
.bind(&cr.job)
.bind(&cr.department)
.bind(&cr.profile_path)
.execute(&mut *tx)
.await
.map_err(Self::map_err)?;
}
tx.commit().await.map_err(Self::map_err)
@@ -131,12 +151,15 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
None => return Ok(None),
};
let enriched_at: DateTime<Utc> = row.try_get("enriched_at")
let enriched_at: DateTime<Utc> = row
.try_get("enriched_at")
.map_err(|_| DomainError::InfrastructureError("invalid enriched_at".into()))?;
let genres = sqlx::query("SELECT tmdb_id, name FROM movie_genres WHERE movie_id = $1")
.bind(&movie_id)
.fetch_all(&self.pool).await.map_err(Self::map_err)?
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?
.into_iter()
.map(|r| Genre {
tmdb_id: r.try_get::<i32, _>("tmdb_id").unwrap_or(0) as u32,
@@ -146,7 +169,9 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
let keywords = sqlx::query("SELECT tmdb_id, name FROM movie_keywords WHERE movie_id = $1")
.bind(&movie_id)
.fetch_all(&self.pool).await.map_err(Self::map_err)?
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?
.into_iter()
.map(|r| Keyword {
tmdb_id: r.try_get::<i32, _>("tmdb_id").unwrap_or(0) as u32,
@@ -159,7 +184,9 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
FROM movie_cast WHERE movie_id = $1 ORDER BY billing_order",
)
.bind(&movie_id)
.fetch_all(&self.pool).await.map_err(Self::map_err)?
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?
.into_iter()
.map(|r| CastMember {
tmdb_person_id: r.try_get::<i64, _>("tmdb_person_id").unwrap_or(0) as u64,
@@ -175,7 +202,9 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
FROM movie_crew WHERE movie_id = $1",
)
.bind(&movie_id)
.fetch_all(&self.pool).await.map_err(Self::map_err)?
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?
.into_iter()
.map(|r| CrewMember {
tmdb_person_id: r.try_get::<i64, _>("tmdb_person_id").unwrap_or(0) as u64,
@@ -192,11 +221,19 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
imdb_id: row.try_get("imdb_id").ok(),
overview: row.try_get("overview").ok(),
tagline: row.try_get("tagline").ok(),
runtime_minutes: row.try_get::<Option<i32>, _>("runtime_minutes").ok().flatten().map(|v| v as u32),
runtime_minutes: row
.try_get::<Option<i32>, _>("runtime_minutes")
.ok()
.flatten()
.map(|v| v as u32),
budget_usd: row.try_get("budget_usd").ok(),
revenue_usd: row.try_get("revenue_usd").ok(),
vote_average: row.try_get("vote_average").ok(),
vote_count: row.try_get::<Option<i32>, _>("vote_count").ok().flatten().map(|v| v as u32),
vote_count: row
.try_get::<Option<i32>, _>("vote_count")
.ok()
.flatten()
.map(|v| v as u32),
original_language: row.try_get("original_language").ok(),
collection_name: row.try_get("collection_name").ok(),
genres,

View File

@@ -2,9 +2,7 @@ use async_trait::async_trait;
use sqlx::PgPool;
use domain::{
errors::DomainError,
models::ProfileField,
ports::UserProfileFieldsRepository,
errors::DomainError, models::ProfileField, ports::UserProfileFieldsRepository,
value_objects::UserId,
};

View File

@@ -46,8 +46,8 @@ impl PostgresUserRepository {
) -> Result<User, DomainError> {
let id = uuid::Uuid::parse_str(&id_str)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let email = Email::new(email_str)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let email =
Email::new(email_str).map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let username = Username::new(username_str)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let hash = PasswordHash::new(hash_str)
@@ -208,7 +208,10 @@ impl UserRepository for PostgresUserRepository {
let Some(r) = row else { return Ok(None) };
#[derive(sqlx::FromRow)]
struct FieldRow { name: String, value: String }
struct FieldRow {
name: String,
value: String,
}
let field_rows = sqlx::query_as::<_, FieldRow>(
"SELECT name, value FROM user_profile_fields WHERE user_id = $1 ORDER BY position ASC",
)
@@ -217,7 +220,13 @@ impl UserRepository for PostgresUserRepository {
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let profile_fields = field_rows.into_iter().map(|f| ProfileField { name: f.name, value: f.value }).collect();
let profile_fields = field_rows
.into_iter()
.map(|f| ProfileField {
name: f.name,
value: f.value,
})
.collect();
Self::row_to_user(
r.id,
@@ -230,7 +239,8 @@ impl UserRepository for PostgresUserRepository {
r.banner_path,
r.also_known_as,
profile_fields,
).map(Some)
)
.map(Some)
}
async fn update_profile(

View File

@@ -1,13 +1,16 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
models::{WatchlistEntry, WatchlistWithMovie, collections::{PageParams, Paginated}},
models::{
WatchlistEntry, WatchlistWithMovie,
collections::{PageParams, Paginated},
},
ports::WatchlistRepository,
value_objects::{MovieId, UserId, WatchlistEntryId},
};
use sqlx::{PgPool, Row};
use crate::models::{parse_uuid, parse_datetime, MovieRow};
use crate::models::{MovieRow, parse_datetime, parse_uuid};
pub struct PostgresWatchlistRepository {
pool: PgPool,
@@ -52,14 +55,13 @@ impl WatchlistRepository for PostgresWatchlistRepository {
let uid = user_id.value().to_string();
let mid = movie_id.value().to_string();
let result = sqlx::query(
"DELETE FROM watchlist_entries WHERE user_id = $1 AND movie_id = $2",
)
.bind(&uid)
.bind(&mid)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
let result =
sqlx::query("DELETE FROM watchlist_entries WHERE user_id = $1 AND movie_id = $2")
.bind(&uid)
.bind(&mid)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
if result.rows_affected() == 0 {
return Err(DomainError::NotFound(format!(
@@ -77,14 +79,13 @@ impl WatchlistRepository for PostgresWatchlistRepository {
) -> Result<bool, DomainError> {
let uid = user_id.value().to_string();
let mid = movie_id.value().to_string();
let result = sqlx::query(
"DELETE FROM watchlist_entries WHERE user_id = $1 AND movie_id = $2",
)
.bind(&uid)
.bind(&mid)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
let result =
sqlx::query("DELETE FROM watchlist_entries WHERE user_id = $1 AND movie_id = $2")
.bind(&uid)
.bind(&mid)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(result.rows_affected() > 0)
}
@@ -115,30 +116,53 @@ impl WatchlistRepository for PostgresWatchlistRepository {
.await
.map_err(Self::map_err)?;
let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM watchlist_entries WHERE user_id = $1",
)
.bind(&uid)
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)?;
let total: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM watchlist_entries WHERE user_id = $1")
.bind(&uid)
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)?;
let items = rows
.into_iter()
.map(|row| {
let entry = WatchlistEntry {
id: WatchlistEntryId::from_uuid(parse_uuid(&row.try_get::<String, _>("id").map_err(|e| DomainError::InfrastructureError(e.to_string()))?)?),
user_id: UserId::from_uuid(parse_uuid(&row.try_get::<String, _>("user_id").map_err(|e| DomainError::InfrastructureError(e.to_string()))?)?),
movie_id: MovieId::from_uuid(parse_uuid(&row.try_get::<String, _>("movie_id").map_err(|e| DomainError::InfrastructureError(e.to_string()))?)?),
added_at: parse_datetime(&row.try_get::<String, _>("added_at").map_err(|e| DomainError::InfrastructureError(e.to_string()))?)?,
id: WatchlistEntryId::from_uuid(parse_uuid(
&row.try_get::<String, _>("id")
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
)?),
user_id: UserId::from_uuid(parse_uuid(
&row.try_get::<String, _>("user_id")
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
)?),
movie_id: MovieId::from_uuid(parse_uuid(
&row.try_get::<String, _>("movie_id")
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
)?),
added_at: parse_datetime(
&row.try_get::<String, _>("added_at")
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
)?,
};
let movie = MovieRow {
id: row.try_get("m_id").map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
external_metadata_id: row.try_get("external_metadata_id").map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
title: row.try_get("title").map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
release_year: row.try_get("release_year").map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
director: row.try_get("director").map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
poster_path: row.try_get("poster_path").map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
id: row
.try_get("m_id")
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
external_metadata_id: row
.try_get("external_metadata_id")
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
title: row
.try_get("title")
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
release_year: row
.try_get("release_year")
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
director: row
.try_get("director")
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
poster_path: row
.try_get("poster_path")
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
}
.into_domain()?;
Ok(WatchlistWithMovie { entry, movie })