movie detail page + importer architecture fix

This commit is contained in:
2026-05-10 23:59:26 +02:00
parent f2f1317660
commit b2a2aa4262
49 changed files with 1670 additions and 264 deletions

9
Cargo.lock generated
View File

@@ -307,8 +307,6 @@ dependencies = [
"chrono", "chrono",
"domain", "domain",
"futures", "futures",
"importer",
"serde_json",
"tokio", "tokio",
"tracing", "tracing",
"uuid", "uuid",
@@ -2413,7 +2411,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"calamine", "calamine",
"csv", "csv",
"serde", "domain",
"serde_json", "serde_json",
"thiserror 2.0.18", "thiserror 2.0.18",
] ]
@@ -3458,6 +3456,8 @@ dependencies = [
"async-trait", "async-trait",
"chrono", "chrono",
"domain", "domain",
"serde",
"serde_json",
"sqlx", "sqlx",
"tokio", "tokio",
"tracing", "tracing",
@@ -4611,6 +4611,8 @@ dependencies = [
"async-trait", "async-trait",
"chrono", "chrono",
"domain", "domain",
"serde",
"serde_json",
"sqlx", "sqlx",
"tokio", "tokio",
"tracing", "tracing",
@@ -6338,6 +6340,7 @@ dependencies = [
"dotenvy", "dotenvy",
"export", "export",
"futures", "futures",
"importer",
"metadata", "metadata",
"nats", "nats",
"poster-fetcher", "poster-fetcher",

View File

@@ -7,7 +7,7 @@ edition = "2024"
xlsx = ["dep:calamine"] xlsx = ["dep:calamine"]
[dependencies] [dependencies]
serde = { workspace = true, features = ["derive"] } domain = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
csv = { workspace = true } csv = { workspace = true }

View File

@@ -1,13 +0,0 @@
#[derive(Debug, thiserror::Error)]
pub enum ImportError {
#[error("CSV parse error: {0}")]
Csv(String),
#[error("JSON parse error: {0}")]
Json(String),
#[error("XLSX parse error: {0}")]
Xlsx(String),
#[error("Empty file")]
Empty,
#[error("Missing header row")]
NoHeader,
}

View File

@@ -1,12 +1,28 @@
pub mod error; mod mapper;
pub mod mapper; mod parsers;
pub mod parsers;
pub mod types;
pub use error::ImportError; use domain::{
pub use mapper::apply_mapping; models::{AnnotatedRow, FieldMapping, FileFormat, ImportError, ParsedFile},
pub use parsers::{parse_csv, parse_json}; ports::DocumentParser,
pub use types::{AnnotatedRow, DomainField, FieldMapping, ImportRow, ParsedFile, RowResult, Transform}; };
#[cfg(feature = "xlsx")] pub struct ImporterDocumentParser;
pub use parsers::parse_xlsx;
impl DocumentParser for ImporterDocumentParser {
fn parse(&self, bytes: &[u8], format: FileFormat) -> Result<ParsedFile, ImportError> {
match format {
FileFormat::Csv => parsers::parse_csv(bytes),
FileFormat::Json => parsers::parse_json(bytes),
FileFormat::Xlsx => {
#[cfg(feature = "xlsx")]
{ parsers::parse_xlsx(bytes) }
#[cfg(not(feature = "xlsx"))]
{ Err(ImportError::Xlsx("XLSX support not compiled in".into())) }
}
}
}
fn apply_mapping(&self, file: &ParsedFile, mappings: &[FieldMapping]) -> Vec<AnnotatedRow> {
mapper::apply_mapping(file, mappings)
}
}

View File

@@ -1,4 +1,6 @@
use crate::types::{AnnotatedRow, DomainField, FieldMapping, ImportRow, ParsedFile, RowResult, Transform}; use domain::models::{
AnnotatedRow, DomainField, FieldMapping, ImportRow, ParsedFile, RowResult, Transform,
};
pub fn apply_mapping(file: &ParsedFile, mappings: &[FieldMapping]) -> Vec<AnnotatedRow> { pub fn apply_mapping(file: &ParsedFile, mappings: &[FieldMapping]) -> Vec<AnnotatedRow> {
file.rows.iter().map(|row| { file.rows.iter().map(|row| {
@@ -76,7 +78,7 @@ fn set_field(row: &mut ImportRow, field: &DomainField, value: String) {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::types::{DomainField, FieldMapping, ParsedFile, RowResult, Transform}; use domain::models::{DomainField, FieldMapping, ParsedFile, RowResult, Transform};
fn sample_file() -> ParsedFile { fn sample_file() -> ParsedFile {
ParsedFile { ParsedFile {

View File

@@ -1,4 +1,4 @@
use crate::{ImportError, types::ParsedFile}; use domain::models::{ImportError, ParsedFile};
pub fn parse_csv(bytes: &[u8]) -> Result<ParsedFile, ImportError> { pub fn parse_csv(bytes: &[u8]) -> Result<ParsedFile, ImportError> {
if bytes.is_empty() { if bytes.is_empty() {

View File

@@ -1,5 +1,5 @@
use domain::models::{ImportError, ParsedFile};
use serde_json::Value; use serde_json::Value;
use crate::{ImportError, types::ParsedFile};
pub fn parse_json(bytes: &[u8]) -> Result<ParsedFile, ImportError> { pub fn parse_json(bytes: &[u8]) -> Result<ParsedFile, ImportError> {
let value: Value = serde_json::from_slice(bytes) let value: Value = serde_json::from_slice(bytes)

View File

@@ -1,6 +1,6 @@
use calamine::{Reader, open_workbook_from_rs, Xlsx, Data}; use calamine::{Reader, open_workbook_from_rs, Xlsx, Data};
use std::io::Cursor; use std::io::Cursor;
use crate::{ImportError, types::ParsedFile}; use domain::models::{ImportError, ParsedFile};
pub fn parse_xlsx(bytes: &[u8]) -> Result<ParsedFile, ImportError> { pub fn parse_xlsx(bytes: &[u8]) -> Result<ParsedFile, ImportError> {
let cursor = Cursor::new(bytes); let cursor = Cursor::new(bytes);

View File

@@ -18,3 +18,5 @@ chrono = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }

View File

@@ -2,12 +2,84 @@ use async_trait::async_trait;
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::ImportProfile, models::{
FieldMapping, ImportProfile,
import::{DomainField, Transform},
},
ports::ImportProfileRepository, ports::ImportProfileRepository,
value_objects::{ImportProfileId, UserId}, value_objects::{ImportProfileId, UserId},
}; };
use serde::{Deserialize, Serialize};
use sqlx::PgPool; use sqlx::PgPool;
#[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())
}
pub struct PostgresImportProfileRepository { pub struct PostgresImportProfileRepository {
pool: PgPool, pool: PgPool,
} }
@@ -26,15 +98,13 @@ impl ImportProfileRepository for PostgresImportProfileRepository {
async fn save(&self, p: &ImportProfile) -> Result<(), DomainError> { async fn save(&self, p: &ImportProfile) -> Result<(), DomainError> {
let id = p.id.value().to_string(); let id = p.id.value().to_string();
let user_id = p.user_id.value().to_string(); let user_id = p.user_id.value().to_string();
let field_mappings = serialize_mappings(&p.field_mappings)?;
sqlx::query( sqlx::query(
"INSERT INTO import_profiles (id, user_id, name, field_mappings, created_at) "INSERT INTO import_profiles (id, user_id, name, field_mappings, created_at)
VALUES ($1, $2, $3, $4, $5) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, field_mappings = EXCLUDED.field_mappings", VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, field_mappings = EXCLUDED.field_mappings",
) )
.bind(&id) .bind(&id).bind(&user_id).bind(&p.name).bind(&field_mappings).bind(p.created_at)
.bind(&user_id)
.bind(&p.name)
.bind(&p.field_mappings)
.bind(p.created_at)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map(|_| ()) .map(|_| ())
@@ -45,13 +115,7 @@ impl ImportProfileRepository for PostgresImportProfileRepository {
let uid = user_id.value().to_string(); let uid = user_id.value().to_string();
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct Row { struct Row { id: String, user_id: String, name: String, field_mappings: String, created_at: NaiveDateTime }
id: String,
user_id: String,
name: String,
field_mappings: String,
created_at: NaiveDateTime,
}
let rows = sqlx::query_as::<_, Row>( 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", "SELECT id, user_id, name, field_mappings, created_at FROM import_profiles WHERE user_id = $1 ORDER BY created_at DESC",
@@ -61,8 +125,7 @@ impl ImportProfileRepository for PostgresImportProfileRepository {
.await .await
.map_err(Self::map_err)?; .map_err(Self::map_err)?;
rows.into_iter().map(|r| -> Result<ImportProfile, DomainError> { rows.into_iter().map(|r| Ok(ImportProfile {
Ok(ImportProfile {
id: ImportProfileId::from_uuid( id: ImportProfileId::from_uuid(
r.id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))? r.id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))?
), ),
@@ -70,10 +133,9 @@ impl ImportProfileRepository for PostgresImportProfileRepository {
r.user_id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))? r.user_id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))?
), ),
name: r.name, name: r.name,
field_mappings: r.field_mappings, field_mappings: deserialize_mappings(&r.field_mappings)?,
created_at: r.created_at, created_at: r.created_at,
}) })).collect()
}).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> {
@@ -81,25 +143,17 @@ impl ImportProfileRepository for PostgresImportProfileRepository {
let uid_str = user_id.value().to_string(); let uid_str = user_id.value().to_string();
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct Row { struct Row { id: String, user_id: String, name: String, field_mappings: String, created_at: NaiveDateTime }
id: String,
user_id: String,
name: String,
field_mappings: String,
created_at: NaiveDateTime,
}
let row = sqlx::query_as::<_, Row>( 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", "SELECT id, user_id, name, field_mappings, created_at FROM import_profiles WHERE id = $1 AND user_id = $2",
) )
.bind(&id_str) .bind(&id_str).bind(&uid_str)
.bind(&uid_str)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(Self::map_err)?;
Ok(row.map(|r| -> Result<ImportProfile, DomainError> { row.map(|r| Ok(ImportProfile {
Ok(ImportProfile {
id: ImportProfileId::from_uuid( id: ImportProfileId::from_uuid(
r.id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))? r.id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))?
), ),
@@ -107,10 +161,9 @@ impl ImportProfileRepository for PostgresImportProfileRepository {
r.user_id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))? r.user_id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))?
), ),
name: r.name, name: r.name,
field_mappings: r.field_mappings, field_mappings: deserialize_mappings(&r.field_mappings)?,
created_at: r.created_at, created_at: r.created_at,
}) })).transpose()
}).transpose()?)
} }
async fn delete(&self, id: &ImportProfileId) -> Result<(), DomainError> { async fn delete(&self, id: &ImportProfileId) -> Result<(), DomainError> {

View File

@@ -2,12 +2,181 @@ use async_trait::async_trait;
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::ImportSession, models::{
AnnotatedRow, FieldMapping, ImportSession, ParsedFile,
import::{DomainField, ImportRow, RowResult, Transform},
},
ports::ImportSessionRepository, ports::ImportSessionRepository,
value_objects::{ImportSessionId, UserId}, value_objects::{ImportSessionId, UserId},
}; };
use serde::{Deserialize, Serialize};
use sqlx::PgPool; use sqlx::PgPool;
// ── serde mirror structs ──
#[derive(Serialize, Deserialize, Default)]
struct ParsedFileJson {
columns: Vec<String>,
rows: Vec<Vec<String>>,
}
#[derive(Serialize, Deserialize)]
enum DomainFieldJson {
Title, ReleaseYear, Director, Rating, WatchedAt, Comment, ExternalMetadataId,
}
#[derive(Serialize, Deserialize)]
enum TransformJson {
RatingScale(f64),
DateFormat(String),
Identity,
}
#[derive(Serialize, Deserialize)]
struct FieldMappingJson {
source_column: String,
domain_field: DomainFieldJson,
transform: TransformJson,
}
#[derive(Serialize, Deserialize, Default)]
struct ImportRowJson {
#[serde(skip_serializing_if = "Option::is_none")] title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] release_year: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] director: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] rating: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] watched_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] comment: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] external_metadata_id: Option<String>,
}
#[derive(Serialize, Deserialize)]
enum RowResultJson {
Valid(ImportRowJson),
Invalid { errors: Vec<String>, raw: Vec<(String, String)> },
}
#[derive(Serialize, Deserialize)]
struct AnnotatedRowJson {
result: RowResultJson,
is_duplicate: bool,
}
// ── conversion helpers ──
fn domain_field_to_json(f: &DomainField) -> DomainFieldJson {
match f {
DomainField::Title => DomainFieldJson::Title,
DomainField::ReleaseYear => DomainFieldJson::ReleaseYear,
DomainField::Director => DomainFieldJson::Director,
DomainField::Rating => DomainFieldJson::Rating,
DomainField::WatchedAt => DomainFieldJson::WatchedAt,
DomainField::Comment => DomainFieldJson::Comment,
DomainField::ExternalMetadataId => DomainFieldJson::ExternalMetadataId,
}
}
fn domain_field_from_json(j: DomainFieldJson) -> DomainField {
match j {
DomainFieldJson::Title => DomainField::Title,
DomainFieldJson::ReleaseYear => DomainField::ReleaseYear,
DomainFieldJson::Director => DomainField::Director,
DomainFieldJson::Rating => DomainField::Rating,
DomainFieldJson::WatchedAt => DomainField::WatchedAt,
DomainFieldJson::Comment => DomainField::Comment,
DomainFieldJson::ExternalMetadataId => DomainField::ExternalMetadataId,
}
}
fn transform_to_json(t: &Transform) -> TransformJson {
match t {
Transform::RatingScale(f) => TransformJson::RatingScale(*f),
Transform::DateFormat(s) => TransformJson::DateFormat(s.clone()),
Transform::Identity => TransformJson::Identity,
}
}
fn transform_from_json(j: TransformJson) -> Transform {
match j {
TransformJson::RatingScale(f) => Transform::RatingScale(f),
TransformJson::DateFormat(s) => Transform::DateFormat(s),
TransformJson::Identity => Transform::Identity,
}
}
fn mapping_to_json(m: &FieldMapping) -> FieldMappingJson {
FieldMappingJson {
source_column: m.source_column.clone(),
domain_field: domain_field_to_json(&m.domain_field),
transform: transform_to_json(&m.transform),
}
}
fn mapping_from_json(j: FieldMappingJson) -> FieldMapping {
FieldMapping {
source_column: j.source_column,
domain_field: domain_field_from_json(j.domain_field),
transform: transform_from_json(j.transform),
}
}
fn import_row_to_json(r: &ImportRow) -> ImportRowJson {
ImportRowJson {
title: r.title.clone(),
release_year: r.release_year.clone(),
director: r.director.clone(),
rating: r.rating.clone(),
watched_at: r.watched_at.clone(),
comment: r.comment.clone(),
external_metadata_id: r.external_metadata_id.clone(),
}
}
fn import_row_from_json(j: ImportRowJson) -> ImportRow {
ImportRow {
title: j.title,
release_year: j.release_year,
director: j.director,
rating: j.rating,
watched_at: j.watched_at,
comment: j.comment,
external_metadata_id: j.external_metadata_id,
}
}
fn annotated_to_json(a: &AnnotatedRow) -> AnnotatedRowJson {
AnnotatedRowJson {
result: match &a.result {
RowResult::Valid(row) => RowResultJson::Valid(import_row_to_json(row)),
RowResult::Invalid { errors, raw } => RowResultJson::Invalid {
errors: errors.clone(),
raw: raw.clone(),
},
},
is_duplicate: a.is_duplicate,
}
}
fn annotated_from_json(j: AnnotatedRowJson) -> AnnotatedRow {
AnnotatedRow {
result: match j.result {
RowResultJson::Valid(row) => RowResult::Valid(import_row_from_json(row)),
RowResultJson::Invalid { errors, raw } => RowResult::Invalid { errors, raw },
},
is_duplicate: j.is_duplicate,
}
}
fn ser<T: Serialize>(v: &T) -> Result<String, DomainError> {
serde_json::to_string(v).map_err(|e| DomainError::InfrastructureError(e.to_string()))
}
fn de<T: for<'de> Deserialize<'de>>(s: &str) -> Result<T, DomainError> {
serde_json::from_str(s).map_err(|e| DomainError::InfrastructureError(e.to_string()))
}
// ── repository ──
pub struct PostgresImportSessionRepository { pub struct PostgresImportSessionRepository {
pool: PgPool, pool: PgPool,
} }
@@ -19,6 +188,62 @@ impl PostgresImportSessionRepository {
tracing::error!("DB error: {:?}", e); tracing::error!("DB error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into()) 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() }))
.transpose()?
.unwrap_or_default();
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()
.map(|rs| ser(&rs.iter().map(annotated_to_json).collect::<Vec<_>>()))
.transpose()?;
Ok((parsed, mappings, results))
}
fn deserialize_session(
id: String,
user_id: String,
parsed_data: String,
field_mappings: Option<String>,
row_results: Option<String>,
created_at: NaiveDateTime,
expires_at: NaiveDateTime,
) -> Result<ImportSession, DomainError> {
let parsed_file = if parsed_data.is_empty() {
None
} else {
let j: ParsedFileJson = de(&parsed_data)?;
Some(ParsedFile { columns: j.columns, rows: j.rows })
};
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()
.map(|s| -> Result<Vec<AnnotatedRow>, DomainError> {
let js: Vec<AnnotatedRowJson> = de(s)?;
Ok(js.into_iter().map(annotated_from_json).collect())
})
.transpose()?;
Ok(ImportSession {
id: ImportSessionId::from_uuid(
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()))?
),
parsed_file,
field_mappings,
row_results,
created_at,
expires_at,
})
}
} }
#[async_trait] #[async_trait]
@@ -26,17 +251,14 @@ impl ImportSessionRepository for PostgresImportSessionRepository {
async fn create(&self, s: &ImportSession) -> Result<(), DomainError> { async fn create(&self, s: &ImportSession) -> Result<(), DomainError> {
let id = s.id.value().to_string(); let id = s.id.value().to_string();
let user_id = s.user_id.value().to_string(); let user_id = s.user_id.value().to_string();
let (parsed_data, field_mappings, row_results) = Self::serialize_session(s)?;
sqlx::query( sqlx::query(
"INSERT INTO import_sessions (id, user_id, parsed_data, field_mappings, row_results, created_at, expires_at) "INSERT INTO import_sessions (id, user_id, parsed_data, field_mappings, row_results, created_at, expires_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)", VALUES ($1, $2, $3, $4, $5, $6, $7)",
) )
.bind(&id) .bind(&id).bind(&user_id).bind(&parsed_data)
.bind(&user_id) .bind(&field_mappings).bind(&row_results)
.bind(&s.parsed_data) .bind(s.created_at).bind(s.expires_at)
.bind(&s.field_mappings)
.bind(&s.row_results)
.bind(s.created_at)
.bind(s.expires_at)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map(|_| ()) .map(|_| ())
@@ -62,37 +284,22 @@ impl ImportSessionRepository for PostgresImportSessionRepository {
"SELECT id, user_id, parsed_data, field_mappings, row_results, created_at, expires_at "SELECT id, user_id, parsed_data, field_mappings, row_results, created_at, expires_at
FROM import_sessions WHERE id = $1 AND user_id = $2", FROM import_sessions WHERE id = $1 AND user_id = $2",
) )
.bind(&id_str) .bind(&id_str).bind(&uid_str)
.bind(&uid_str)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(Self::map_err)?;
Ok(row.map(|r| -> Result<ImportSession, DomainError> { row.map(|r| Self::deserialize_session(
Ok(ImportSession { r.id, r.user_id, r.parsed_data, r.field_mappings, r.row_results,
id: ImportSessionId::from_uuid( r.created_at, r.expires_at,
r.id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))? )).transpose()
),
user_id: UserId::from_uuid(
r.user_id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))?
),
parsed_data: r.parsed_data,
field_mappings: r.field_mappings,
row_results: r.row_results,
created_at: r.created_at,
expires_at: r.expires_at,
})
}).transpose()?)
} }
async fn update(&self, s: &ImportSession) -> Result<(), DomainError> { async fn update(&self, s: &ImportSession) -> Result<(), DomainError> {
let id = s.id.value().to_string(); let id = s.id.value().to_string();
sqlx::query( let (_, field_mappings, row_results) = Self::serialize_session(s)?;
"UPDATE import_sessions SET field_mappings = $1, row_results = $2 WHERE id = $3", sqlx::query("UPDATE import_sessions SET field_mappings = $1, row_results = $2 WHERE id = $3")
) .bind(&field_mappings).bind(&row_results).bind(&id)
.bind(&s.field_mappings)
.bind(&s.row_results)
.bind(&id)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map(|_| ()) .map(|_| ())

View File

@@ -3,7 +3,7 @@ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
models::{ models::{
DiaryEntry, DiaryFilter, DirectorStat, FeedEntry, MonthlyRating, Movie, Review, DiaryEntry, DiaryFilter, DirectorStat, FeedEntry, MonthlyRating, Movie, MovieStats, Review,
ReviewHistory, ReviewSource, SortDirection, UserStats, UserTrends, ReviewHistory, ReviewSource, SortDirection, UserStats, UserTrends,
collections::{PageParams, Paginated}, collections::{PageParams, Paginated},
}, },
@@ -18,8 +18,8 @@ mod models;
mod users; mod users;
use models::{ use models::{
DiaryRow, DirectorCountRow, FeedRow, MonthlyRatingRow, MovieRow, ReviewRow, UserTotalsRow, DiaryRow, DirectorCountRow, FeedRow, MonthlyRatingRow, MovieRow, MovieStatsRow, ReviewRow,
datetime_to_str, UserTotalsRow, datetime_to_str,
}; };
pub use import_profile::PostgresImportProfileRepository; pub use import_profile::PostgresImportProfileRepository;
@@ -692,6 +692,80 @@ impl DiaryRepository for PostgresRepository {
rows.into_iter().map(DiaryRow::to_domain).collect() rows.into_iter().map(DiaryRow::to_domain).collect()
} }
async fn get_movie_stats(&self, movie_id: &MovieId) -> Result<MovieStats, DomainError> {
let id_str = movie_id.value().to_string();
sqlx::query_as::<_, MovieStatsRow>(
"SELECT
COUNT(*) AS total_count,
AVG(CAST(rating AS FLOAT)) AS avg_rating,
COUNT(CASE WHEN remote_actor_url IS NOT NULL THEN 1 END) AS federated_count,
COUNT(CASE WHEN rating = 1 THEN 1 END) AS rating_1,
COUNT(CASE WHEN rating = 2 THEN 1 END) AS rating_2,
COUNT(CASE WHEN rating = 3 THEN 1 END) AS rating_3,
COUNT(CASE WHEN rating = 4 THEN 1 END) AS rating_4,
COUNT(CASE WHEN rating = 5 THEN 1 END) AS rating_5
FROM reviews WHERE movie_id = $1",
)
.bind(id_str)
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)
.map(MovieStatsRow::to_domain)
}
async fn get_movie_social_feed(
&self,
movie_id: &MovieId,
page: &PageParams,
) -> Result<Paginated<FeedEntry>, DomainError> {
let id_str = movie_id.value().to_string();
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 rows = sqlx::query_as::<_, FeedRow>(
"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,
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
r.remote_actor_url,
CASE WHEN r.remote_actor_url IS NOT NULL THEN r.remote_actor_url
WHEN u.email IS NOT NULL THEN u.email
ELSE r.user_id END AS user_email
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
LEFT JOIN users u ON u.id = r.user_id
WHERE r.movie_id = $1
ORDER BY r.watched_at DESC
LIMIT $2 OFFSET $3",
)
.bind(&id_str)
.bind(limit)
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
let items = rows
.into_iter()
.map(FeedRow::to_domain)
.collect::<Result<Vec<_>, _>>()?;
Ok(Paginated {
items,
total_count: total as u64,
limit: page.limit,
offset: page.offset,
})
}
} }
#[async_trait] #[async_trait]

View File

@@ -157,6 +157,35 @@ impl FeedRow {
} }
} }
#[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 to_domain(self) -> domain::models::MovieStats {
domain::models::MovieStats {
total_count: self.total_count as u64,
avg_rating: self.avg_rating,
federated_count: self.federated_count as u64,
rating_histogram: [
self.rating_1 as u64,
self.rating_2 as u64,
self.rating_3 as u64,
self.rating_4 as u64,
self.rating_5 as u64,
],
}
}
}
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
pub(crate) struct UserSummaryRow { pub(crate) struct UserSummaryRow {
pub id: String, pub id: String,

View File

@@ -12,6 +12,8 @@ sqlx = { version = "0.8.6", features = [
] } ] }
domain = { workspace = true } domain = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }

View File

@@ -2,12 +2,84 @@ use async_trait::async_trait;
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::ImportProfile, models::{
FieldMapping, ImportProfile,
import::{DomainField, Transform},
},
ports::ImportProfileRepository, ports::ImportProfileRepository,
value_objects::{ImportProfileId, UserId}, value_objects::{ImportProfileId, UserId},
}; };
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool; use sqlx::SqlitePool;
#[derive(Serialize, Deserialize)]
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())
}
pub struct SqliteImportProfileRepository { pub struct SqliteImportProfileRepository {
pool: SqlitePool, pool: SqlitePool,
} }
@@ -33,10 +105,11 @@ impl ImportProfileRepository for SqliteImportProfileRepository {
let id = p.id.value().to_string(); let id = p.id.value().to_string();
let user_id = p.user_id.value().to_string(); let user_id = p.user_id.value().to_string();
let created_at = p.created_at.format("%Y-%m-%d %H:%M:%S").to_string(); let created_at = p.created_at.format("%Y-%m-%d %H:%M:%S").to_string();
let field_mappings = serialize_mappings(&p.field_mappings)?;
sqlx::query!( sqlx::query!(
"INSERT OR REPLACE INTO import_profiles (id, user_id, name, field_mappings, created_at) "INSERT OR REPLACE INTO import_profiles (id, user_id, name, field_mappings, created_at)
VALUES (?, ?, ?, ?, ?)", VALUES (?, ?, ?, ?, ?)",
id, user_id, p.name, p.field_mappings, created_at id, user_id, p.name, field_mappings, created_at
) )
.execute(&self.pool) .execute(&self.pool)
.await .await
@@ -54,16 +127,12 @@ impl ImportProfileRepository for SqliteImportProfileRepository {
.await .await
.map_err(Self::map_err)?; .map_err(Self::map_err)?;
rows.into_iter().map(|r| -> Result<ImportProfile, DomainError> { rows.into_iter().map(|r| {
Ok(ImportProfile { Ok(ImportProfile {
id: ImportProfileId::from_uuid( id: ImportProfileId::from_uuid(r.id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))?),
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()))?),
),
user_id: UserId::from_uuid(
r.user_id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))?
),
name: r.name, name: r.name,
field_mappings: r.field_mappings, field_mappings: deserialize_mappings(&r.field_mappings)?,
created_at: Self::parse_dt(&r.created_at)?, created_at: Self::parse_dt(&r.created_at)?,
}) })
}).collect() }).collect()
@@ -80,19 +149,15 @@ impl ImportProfileRepository for SqliteImportProfileRepository {
.await .await
.map_err(Self::map_err)?; .map_err(Self::map_err)?;
Ok(row.map(|r| -> Result<ImportProfile, DomainError> { row.map(|r| {
Ok(ImportProfile { Ok(ImportProfile {
id: ImportProfileId::from_uuid( id: ImportProfileId::from_uuid(r.id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))?),
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()))?),
),
user_id: UserId::from_uuid(
r.user_id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))?
),
name: r.name, name: r.name,
field_mappings: r.field_mappings, field_mappings: deserialize_mappings(&r.field_mappings)?,
created_at: Self::parse_dt(&r.created_at)?, created_at: Self::parse_dt(&r.created_at)?,
}) })
}).transpose()?) }).transpose()
} }
async fn delete(&self, id: &ImportProfileId) -> Result<(), DomainError> { async fn delete(&self, id: &ImportProfileId) -> Result<(), DomainError> {

View File

@@ -2,12 +2,181 @@ use async_trait::async_trait;
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::ImportSession, models::{
AnnotatedRow, FieldMapping, ImportSession, ParsedFile,
import::{DomainField, ImportRow, RowResult, Transform},
},
ports::ImportSessionRepository, ports::ImportSessionRepository,
value_objects::{ImportSessionId, UserId}, value_objects::{ImportSessionId, UserId},
}; };
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool; use sqlx::SqlitePool;
// ── serde mirror structs (match the JSON format from the old importer types) ──
#[derive(Serialize, Deserialize, Default)]
struct ParsedFileJson {
columns: Vec<String>,
rows: Vec<Vec<String>>,
}
#[derive(Serialize, Deserialize)]
enum DomainFieldJson {
Title, ReleaseYear, Director, Rating, WatchedAt, Comment, ExternalMetadataId,
}
#[derive(Serialize, Deserialize)]
enum TransformJson {
RatingScale(f64),
DateFormat(String),
Identity,
}
#[derive(Serialize, Deserialize)]
struct FieldMappingJson {
source_column: String,
domain_field: DomainFieldJson,
transform: TransformJson,
}
#[derive(Serialize, Deserialize, Default)]
struct ImportRowJson {
#[serde(skip_serializing_if = "Option::is_none")] title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] release_year: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] director: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] rating: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] watched_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] comment: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] external_metadata_id: Option<String>,
}
#[derive(Serialize, Deserialize)]
enum RowResultJson {
Valid(ImportRowJson),
Invalid { errors: Vec<String>, raw: Vec<(String, String)> },
}
#[derive(Serialize, Deserialize)]
struct AnnotatedRowJson {
result: RowResultJson,
is_duplicate: bool,
}
// ── conversion helpers ──
fn domain_field_to_json(f: &DomainField) -> DomainFieldJson {
match f {
DomainField::Title => DomainFieldJson::Title,
DomainField::ReleaseYear => DomainFieldJson::ReleaseYear,
DomainField::Director => DomainFieldJson::Director,
DomainField::Rating => DomainFieldJson::Rating,
DomainField::WatchedAt => DomainFieldJson::WatchedAt,
DomainField::Comment => DomainFieldJson::Comment,
DomainField::ExternalMetadataId => DomainFieldJson::ExternalMetadataId,
}
}
fn domain_field_from_json(j: DomainFieldJson) -> DomainField {
match j {
DomainFieldJson::Title => DomainField::Title,
DomainFieldJson::ReleaseYear => DomainField::ReleaseYear,
DomainFieldJson::Director => DomainField::Director,
DomainFieldJson::Rating => DomainField::Rating,
DomainFieldJson::WatchedAt => DomainField::WatchedAt,
DomainFieldJson::Comment => DomainField::Comment,
DomainFieldJson::ExternalMetadataId => DomainField::ExternalMetadataId,
}
}
fn transform_to_json(t: &Transform) -> TransformJson {
match t {
Transform::RatingScale(f) => TransformJson::RatingScale(*f),
Transform::DateFormat(s) => TransformJson::DateFormat(s.clone()),
Transform::Identity => TransformJson::Identity,
}
}
fn transform_from_json(j: TransformJson) -> Transform {
match j {
TransformJson::RatingScale(f) => Transform::RatingScale(f),
TransformJson::DateFormat(s) => Transform::DateFormat(s),
TransformJson::Identity => Transform::Identity,
}
}
fn mapping_to_json(m: &FieldMapping) -> FieldMappingJson {
FieldMappingJson {
source_column: m.source_column.clone(),
domain_field: domain_field_to_json(&m.domain_field),
transform: transform_to_json(&m.transform),
}
}
fn mapping_from_json(j: FieldMappingJson) -> FieldMapping {
FieldMapping {
source_column: j.source_column,
domain_field: domain_field_from_json(j.domain_field),
transform: transform_from_json(j.transform),
}
}
fn import_row_to_json(r: &ImportRow) -> ImportRowJson {
ImportRowJson {
title: r.title.clone(),
release_year: r.release_year.clone(),
director: r.director.clone(),
rating: r.rating.clone(),
watched_at: r.watched_at.clone(),
comment: r.comment.clone(),
external_metadata_id: r.external_metadata_id.clone(),
}
}
fn import_row_from_json(j: ImportRowJson) -> ImportRow {
ImportRow {
title: j.title,
release_year: j.release_year,
director: j.director,
rating: j.rating,
watched_at: j.watched_at,
comment: j.comment,
external_metadata_id: j.external_metadata_id,
}
}
fn annotated_to_json(a: &AnnotatedRow) -> AnnotatedRowJson {
AnnotatedRowJson {
result: match &a.result {
RowResult::Valid(row) => RowResultJson::Valid(import_row_to_json(row)),
RowResult::Invalid { errors, raw } => RowResultJson::Invalid {
errors: errors.clone(),
raw: raw.clone(),
},
},
is_duplicate: a.is_duplicate,
}
}
fn annotated_from_json(j: AnnotatedRowJson) -> AnnotatedRow {
AnnotatedRow {
result: match j.result {
RowResultJson::Valid(row) => RowResult::Valid(import_row_from_json(row)),
RowResultJson::Invalid { errors, raw } => RowResult::Invalid { errors, raw },
},
is_duplicate: j.is_duplicate,
}
}
fn ser<T: Serialize>(v: &T) -> Result<String, DomainError> {
serde_json::to_string(v).map_err(|e| DomainError::InfrastructureError(e.to_string()))
}
fn de<T: for<'de> Deserialize<'de>>(s: &str) -> Result<T, DomainError> {
serde_json::from_str(s).map_err(|e| DomainError::InfrastructureError(e.to_string()))
}
// ── repository ──
pub struct SqliteImportSessionRepository { pub struct SqliteImportSessionRepository {
pool: SqlitePool, pool: SqlitePool,
} }
@@ -25,6 +194,63 @@ impl SqliteImportSessionRepository {
.or_else(|_| NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S")) .or_else(|_| NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S"))
.map_err(|e| DomainError::InfrastructureError(format!("invalid datetime '{}': {}", s, e))) .map_err(|e| DomainError::InfrastructureError(format!("invalid datetime '{}': {}", s, e)))
} }
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()
.map(|ms| ser(&ms.iter().map(mapping_to_json).collect::<Vec<_>>()))
.transpose()?;
let results = s.row_results.as_ref()
.map(|rs| ser(&rs.iter().map(annotated_to_json).collect::<Vec<_>>()))
.transpose()?;
Ok((parsed, mappings, results))
}
fn deserialize_session(
id: String,
user_id: String,
parsed_data: String,
field_mappings: Option<String>,
row_results: Option<String>,
created_at: &str,
expires_at: &str,
) -> Result<ImportSession, DomainError> {
let parsed_file = if parsed_data.is_empty() {
None
} else {
let j: ParsedFileJson = de(&parsed_data)?;
Some(ParsedFile { columns: j.columns, rows: j.rows })
};
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()
.map(|s| -> Result<Vec<AnnotatedRow>, DomainError> {
let js: Vec<AnnotatedRowJson> = de(s)?;
Ok(js.into_iter().map(annotated_from_json).collect())
})
.transpose()?;
Ok(ImportSession {
id: ImportSessionId::from_uuid(
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()))?
),
parsed_file,
field_mappings,
row_results,
created_at: Self::parse_dt(created_at)?,
expires_at: Self::parse_dt(expires_at)?,
})
}
} }
#[async_trait] #[async_trait]
@@ -34,10 +260,11 @@ impl ImportSessionRepository for SqliteImportSessionRepository {
let user_id = s.user_id.value().to_string(); let user_id = s.user_id.value().to_string();
let created_at = s.created_at.format("%Y-%m-%d %H:%M:%S").to_string(); let created_at = s.created_at.format("%Y-%m-%d %H:%M:%S").to_string();
let expires_at = s.expires_at.format("%Y-%m-%d %H:%M:%S").to_string(); let expires_at = s.expires_at.format("%Y-%m-%d %H:%M:%S").to_string();
let (parsed_data, field_mappings, row_results) = Self::serialize_session(s)?;
sqlx::query!( sqlx::query!(
"INSERT INTO import_sessions (id, user_id, parsed_data, field_mappings, row_results, created_at, expires_at) "INSERT INTO import_sessions (id, user_id, parsed_data, field_mappings, row_results, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?)", VALUES (?, ?, ?, ?, ?, ?, ?)",
id, user_id, s.parsed_data, s.field_mappings, s.row_results, created_at, expires_at id, user_id, parsed_data, field_mappings, row_results, created_at, expires_at
) )
.execute(&self.pool) .execute(&self.pool)
.await .await
@@ -57,28 +284,18 @@ impl ImportSessionRepository for SqliteImportSessionRepository {
.await .await
.map_err(Self::map_err)?; .map_err(Self::map_err)?;
Ok(row.map(|r| -> Result<ImportSession, DomainError> { row.map(|r| Self::deserialize_session(
Ok(ImportSession { r.id, r.user_id, r.parsed_data, r.field_mappings, r.row_results,
id: ImportSessionId::from_uuid( &r.created_at, &r.expires_at,
r.id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))? )).transpose()
),
user_id: UserId::from_uuid(
r.user_id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))?
),
parsed_data: r.parsed_data,
field_mappings: r.field_mappings,
row_results: r.row_results,
created_at: Self::parse_dt(&r.created_at)?,
expires_at: Self::parse_dt(&r.expires_at)?,
})
}).transpose()?)
} }
async fn update(&self, s: &ImportSession) -> Result<(), DomainError> { async fn update(&self, s: &ImportSession) -> Result<(), DomainError> {
let id = s.id.value().to_string(); let id = s.id.value().to_string();
let (_, field_mappings, row_results) = Self::serialize_session(s)?;
sqlx::query!( sqlx::query!(
"UPDATE import_sessions SET field_mappings = ?, row_results = ? WHERE id = ?", "UPDATE import_sessions SET field_mappings = ?, row_results = ? WHERE id = ?",
s.field_mappings, s.row_results, id field_mappings, row_results, id
) )
.execute(&self.pool) .execute(&self.pool)
.await .await

View File

@@ -3,7 +3,7 @@ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
models::{ models::{
DiaryEntry, DiaryFilter, DirectorStat, FeedEntry, MonthlyRating, Movie, Review, DiaryEntry, DiaryFilter, DirectorStat, FeedEntry, MonthlyRating, Movie, MovieStats, Review,
ReviewHistory, ReviewSource, SortDirection, UserStats, UserTrends, ReviewHistory, ReviewSource, SortDirection, UserStats, UserTrends,
collections::{PageParams, Paginated}, collections::{PageParams, Paginated},
}, },
@@ -19,8 +19,8 @@ mod models;
mod users; mod users;
use models::{ use models::{
DiaryRow, DirectorCountRow, FeedRow, MonthlyRatingRow, MovieRow, ReviewRow, UserTotalsRow, DiaryRow, DirectorCountRow, FeedRow, MonthlyRatingRow, MovieRow, MovieStatsRow, ReviewRow,
datetime_to_str, UserTotalsRow, datetime_to_str,
}; };
pub use import_profile::SqliteImportProfileRepository; pub use import_profile::SqliteImportProfileRepository;
@@ -680,6 +680,78 @@ impl DiaryRepository for SqliteMovieRepository {
rows.into_iter().map(DiaryRow::to_domain).collect() rows.into_iter().map(DiaryRow::to_domain).collect()
} }
async fn get_movie_stats(&self, movie_id: &MovieId) -> Result<MovieStats, DomainError> {
let id_str = movie_id.value().to_string();
sqlx::query_as::<_, MovieStatsRow>(
"SELECT
COUNT(*) AS total_count,
AVG(CAST(rating AS REAL)) AS avg_rating,
COUNT(CASE WHEN remote_actor_url IS NOT NULL THEN 1 END) AS federated_count,
COUNT(CASE WHEN rating = 1 THEN 1 END) AS rating_1,
COUNT(CASE WHEN rating = 2 THEN 1 END) AS rating_2,
COUNT(CASE WHEN rating = 3 THEN 1 END) AS rating_3,
COUNT(CASE WHEN rating = 4 THEN 1 END) AS rating_4,
COUNT(CASE WHEN rating = 5 THEN 1 END) AS rating_5
FROM reviews WHERE movie_id = ?",
)
.bind(id_str)
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)
.map(MovieStatsRow::to_domain)
}
async fn get_movie_social_feed(
&self,
movie_id: &MovieId,
page: &PageParams,
) -> Result<Paginated<FeedEntry>, DomainError> {
let id_str = movie_id.value().to_string();
let limit = page.limit as i64;
let offset = page.offset as i64;
let total = sqlx::query_scalar!(
"SELECT COUNT(*) FROM reviews WHERE movie_id = ?",
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,
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,
CASE WHEN r.remote_actor_url IS NOT NULL THEN r.remote_actor_url
WHEN u.email IS NOT NULL THEN u.email
ELSE r.user_id END AS user_email
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
LEFT JOIN users u ON u.id = r.user_id
WHERE r.movie_id = ?
ORDER BY r.watched_at DESC
LIMIT ? OFFSET ?",
)
.bind(&id_str)
.bind(limit)
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
let items = rows
.into_iter()
.map(FeedRow::to_domain)
.collect::<Result<Vec<_>, _>>()?;
Ok(Paginated {
items,
total_count: total as u64,
limit: page.limit,
offset: page.offset,
})
}
} }
#[async_trait] #[async_trait]
@@ -915,4 +987,96 @@ mod feed_filter_tests {
assert!(titles.contains(&"Inception".to_string())); assert!(titles.contains(&"Inception".to_string()));
assert!(titles.contains(&"Dune".to_string())); assert!(titles.contains(&"Dune".to_string()));
} }
#[tokio::test]
async fn test_get_movie_stats_local() {
let pool = SqlitePool::connect(":memory:").await.unwrap();
setup(&pool).await;
let repo = SqliteMovieRepository::new(pool);
// Inception: 1 local review, rating=5, no federated
let movie_id = domain::value_objects::MovieId::from_uuid(
uuid::Uuid::parse_str("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").unwrap(),
);
let stats = repo.get_movie_stats(&movie_id).await.unwrap();
assert_eq!(stats.total_count, 1);
assert_eq!(stats.federated_count, 0);
assert!((stats.avg_rating.unwrap() - 5.0).abs() < 0.001);
assert_eq!(stats.rating_histogram[4], 1); // 5★ bucket
assert_eq!(stats.rating_histogram[0], 0); // 1★ bucket
}
#[tokio::test]
async fn test_get_movie_social_feed_returns_reviews_for_movie() {
let pool = SqlitePool::connect(":memory:").await.unwrap();
setup(&pool).await;
let repo = SqliteMovieRepository::new(pool);
let movie_id = domain::value_objects::MovieId::from_uuid(
uuid::Uuid::parse_str("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").unwrap(),
);
let page = PageParams::new(Some(10), Some(0)).unwrap();
let result = repo.get_movie_social_feed(&movie_id, &page).await.unwrap();
assert_eq!(result.total_count, 1);
assert_eq!(result.items.len(), 1);
assert_eq!(result.items[0].movie().title().value(), "Inception");
assert_eq!(result.items[0].review().rating().value(), 5);
assert_eq!(result.items[0].user_display_name(), "alice");
assert!(!result.items[0].review().is_remote());
}
#[tokio::test]
async fn test_get_movie_social_feed_federated_review() {
let pool = SqlitePool::connect(":memory:").await.unwrap();
setup(&pool).await;
let repo = SqliteMovieRepository::new(pool);
let movie_id = domain::value_objects::MovieId::from_uuid(
uuid::Uuid::parse_str("cccccccc-cccc-cccc-cccc-cccccccccccc").unwrap(),
);
let page = PageParams::new(Some(10), Some(0)).unwrap();
let result = repo.get_movie_social_feed(&movie_id, &page).await.unwrap();
assert_eq!(result.total_count, 1);
assert_eq!(result.items.len(), 1);
assert!(result.items[0].review().is_remote());
assert_eq!(result.items[0].user_email(), "https://remote.social/users/carol");
}
#[tokio::test]
async fn test_get_movie_social_feed_pagination() {
let pool = SqlitePool::connect(":memory:").await.unwrap();
setup(&pool).await;
let repo = SqliteMovieRepository::new(pool);
let movie_id = domain::value_objects::MovieId::from_uuid(
uuid::Uuid::parse_str("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").unwrap(),
);
// offset beyond results: total_count still correct, items empty
let page = PageParams::new(Some(10), Some(5)).unwrap();
let result = repo.get_movie_social_feed(&movie_id, &page).await.unwrap();
assert_eq!(result.total_count, 1);
assert_eq!(result.items.len(), 0);
}
#[tokio::test]
async fn test_get_movie_stats_federated() {
let pool = SqlitePool::connect(":memory:").await.unwrap();
setup(&pool).await;
let repo = SqliteMovieRepository::new(pool);
// Dune: 1 federated review, rating=4
let movie_id = domain::value_objects::MovieId::from_uuid(
uuid::Uuid::parse_str("cccccccc-cccc-cccc-cccc-cccccccccccc").unwrap(),
);
let stats = repo.get_movie_stats(&movie_id).await.unwrap();
assert_eq!(stats.total_count, 1);
assert_eq!(stats.federated_count, 1);
assert_eq!(stats.rating_histogram[3], 1); // 4★ bucket
assert_eq!(stats.rating_histogram[4], 0); // 5★ bucket
}
} }

View File

@@ -118,6 +118,35 @@ impl DiaryRow {
} }
} }
#[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 to_domain(self) -> domain::models::MovieStats {
domain::models::MovieStats {
total_count: self.total_count as u64,
avg_rating: self.avg_rating,
federated_count: self.federated_count as u64,
rating_histogram: [
self.rating_1 as u64,
self.rating_2 as u64,
self.rating_3 as u64,
self.rating_4 as u64,
self.rating_5 as u64,
],
}
}
}
// Like DiaryRow but includes user_email from JOIN with users table // Like DiaryRow but includes user_email from JOIN with users table
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
pub(crate) struct FeedRow { pub(crate) struct FeedRow {

View File

@@ -1,8 +1,8 @@
use application::ports::{ use application::ports::{
ActivityFeedPageData, FollowersPageData, FollowingPageData, HtmlPageContext, HtmlRenderer, ActivityFeedPageData, FollowersPageData, FollowingPageData, HtmlPageContext, HtmlRenderer,
ImportMappingPageData, ImportPreviewPageData, ImportPreviewRow, ImportProfileView, ImportMappingPageData, ImportPreviewPageData, ImportPreviewRow, ImportProfileView,
ImportRowStatus, ImportUploadPageData, LoginPageData, NewReviewPageData, ProfilePageData, ImportRowStatus, ImportUploadPageData, LoginPageData, MovieDetailPageData, NewReviewPageData,
RegisterPageData, UsersPageData, ProfilePageData, RegisterPageData, UsersPageData,
}; };
use askama::Template; use askama::Template;
use chrono::Datelike; use chrono::Datelike;
@@ -94,6 +94,19 @@ struct ActivityFeedTemplate<'a> {
pub search: String, pub search: String,
} }
#[derive(Template)]
#[template(path = "movie_detail.html")]
struct MovieDetailTemplate<'a> {
ctx: &'a HtmlPageContext,
movie: &'a domain::models::Movie,
stats: &'a domain::models::MovieStats,
reviews: &'a [domain::models::FeedEntry],
current_offset: u32,
has_more: bool,
limit: u32,
histogram_max: u64,
}
impl<'a> ActivityFeedTemplate<'a> { impl<'a> ActivityFeedTemplate<'a> {
pub fn filter_qs(&self) -> String { pub fn filter_qs(&self) -> String {
let mut parts = vec![ let mut parts = vec![
@@ -550,6 +563,21 @@ impl HtmlRenderer for AskamaHtmlRenderer {
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
fn render_movie_detail_page(&self, data: MovieDetailPageData) -> Result<String, String> {
MovieDetailTemplate {
ctx: &data.ctx,
movie: &data.movie,
stats: &data.stats,
reviews: &data.reviews.items,
current_offset: data.current_offset,
has_more: data.has_more,
limit: data.limit,
histogram_max: data.histogram_max,
}
.render()
.map_err(|e| e.to_string())
}
fn render_following_page(&self, data: FollowingPageData) -> Result<String, String> { fn render_following_page(&self, data: FollowingPageData) -> Result<String, String> {
FollowingTemplate { FollowingTemplate {
ctx: data.ctx, ctx: data.ctx,

View File

@@ -31,7 +31,7 @@
{% endif %} {% endif %}
<div class="entry-body"> <div class="entry-body">
<div class="entry-title"> <div class="entry-title">
{{ entry.movie().title().value() }} <a href="/movies/{{ entry.movie().id().value() }}" class="movie-title-link">{{ entry.movie().title().value() }}</a>
<span class="year">({{ entry.movie().release_year().value() }})</span> <span class="year">({{ entry.movie().release_year().value() }})</span>
</div> </div>
{% if let Some(dir) = entry.movie().director() %} {% if let Some(dir) = entry.movie().director() %}

View File

@@ -0,0 +1,102 @@
{% extends "base.html" %}
{% block content %}
<div class="movie-detail">
<article class="entry" style="margin-bottom:1.5rem">
{% if let Some(poster) = movie.poster_path() %}
<div class="poster"><img src="/posters/{{ poster.value() }}" alt=""></div>
{% endif %}
<div class="entry-body">
<div class="entry-title">
{{ movie.title().value() }}
<span class="year">({{ movie.release_year().value() }})</span>
</div>
{% if let Some(dir) = movie.director() %}
<div class="director">{{ dir }}</div>
{% endif %}
<div style="margin-top:0.75rem">
<a href="/reviews/new" class="btn-small">+ Log a review</a>
</div>
</div>
</article>
<div class="stats-bar">
{% if let Some(avg) = stats.avg_rating %}
<div class="stat-box">
<div class="stat-value">{{ format!("{:.1}", avg) }}★</div>
<div class="stat-label">avg rating</div>
</div>
{% endif %}
<div class="stat-box">
<div class="stat-value">{{ stats.total_count }}</div>
<div class="stat-label">reviews</div>
</div>
{% if stats.federated_count > 0 %}
<div class="stat-box">
<div class="stat-value">{{ stats.federated_count }}</div>
<div class="stat-label">federated</div>
</div>
{% endif %}
<div class="stat-box histogram">
<div class="stat-label">distribution</div>
<div class="histogram-row"><span class="hist-label">5★</span><div class="hist-bar-wrap"><div class="hist-bar" style="width:{% if histogram_max > 0 %}{{ stats.rating_histogram[4] * 100 / histogram_max }}{% else %}0{% endif %}%"></div></div><span class="hist-count">{{ stats.rating_histogram[4] }}</span></div>
<div class="histogram-row"><span class="hist-label">4★</span><div class="hist-bar-wrap"><div class="hist-bar" style="width:{% if histogram_max > 0 %}{{ stats.rating_histogram[3] * 100 / histogram_max }}{% else %}0{% endif %}%"></div></div><span class="hist-count">{{ stats.rating_histogram[3] }}</span></div>
<div class="histogram-row"><span class="hist-label">3★</span><div class="hist-bar-wrap"><div class="hist-bar" style="width:{% if histogram_max > 0 %}{{ stats.rating_histogram[2] * 100 / histogram_max }}{% else %}0{% endif %}%"></div></div><span class="hist-count">{{ stats.rating_histogram[2] }}</span></div>
<div class="histogram-row"><span class="hist-label">2★</span><div class="hist-bar-wrap"><div class="hist-bar" style="width:{% if histogram_max > 0 %}{{ stats.rating_histogram[1] * 100 / histogram_max }}{% else %}0{% endif %}%"></div></div><span class="hist-count">{{ stats.rating_histogram[1] }}</span></div>
<div class="histogram-row"><span class="hist-label">1★</span><div class="hist-bar-wrap"><div class="hist-bar" style="width:{% if histogram_max > 0 %}{{ stats.rating_histogram[0] * 100 / histogram_max }}{% else %}0{% endif %}%"></div></div><span class="hist-count">{{ stats.rating_histogram[0] }}</span></div>
</div>
</div>
<div class="feed-section-label">REVIEWS</div>
<div class="diary">
{% for entry in reviews %}
<article class="entry review-card {% if ctx.is_current_user(entry.review().user_id().value()) %}review-own{% endif %} {% if entry.review().is_remote() %}review-federated{% endif %}">
<div class="entry-body">
<div class="rating">
{% for filled in entry.review().stars() %}
<span class="star {% if filled %}filled{% else %}empty{% endif %}"></span>
{% endfor %}
</div>
{% if let Some(comment) = entry.review().comment() %}
<div class="comment">{{ comment.value() }}</div>
{% endif %}
<div class="feed-meta">
{% match entry.review().source() %}
{% when ReviewSource::Remote with { actor_url } %}
<a href="{{ actor_url }}" class="feed-user" target="_blank" rel="noopener noreferrer">{{ entry.user_display_name() }}</a>
<span class="feed-time">{{ entry.review().watched_at().format("%b %-d, %Y") }}</span>
<span class="remote-badge">&#8599; federated</span>
{% when ReviewSource::Local %}
{% if ctx.is_current_user(entry.review().user_id().value()) %}
<span class="feed-user">you</span>
{% else %}
<a href="/users/{{ entry.review().user_id().value() }}" class="feed-user">{{ entry.user_display_name() }}</a>
{% endif %}
<span class="feed-time">{{ entry.review().watched_at().format("%b %-d, %Y") }}</span>
{% endmatch %}
</div>
{% if ctx.is_current_user(entry.review().user_id().value()) %}
<form method="post" action="/reviews/{{ entry.review().id().value() }}/delete" class="delete-form">
<input type="hidden" name="redirect_after" value="/movies/{{ movie.id().value() }}">
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
<button type="submit">Delete</button>
</form>
{% endif %}
</div>
</article>
{% else %}
<p class="empty">No reviews yet.</p>
{% endfor %}
</div>
<nav class="pagination">
{% if current_offset >= limit %}
<a href="/movies/{{ movie.id().value() }}?offset={{ current_offset - limit }}&limit={{ limit }}" class="page-nav">&larr; Prev</a>
{% endif %}
{% if has_more %}
<a href="/movies/{{ movie.id().value() }}?offset={{ current_offset + limit }}&limit={{ limit }}" class="page-nav">Next &rarr;</a>
{% endif %}
</nav>
</div>
{% endblock %}

View File

@@ -109,7 +109,7 @@
<div class="poster"><img src="/posters/{{ poster.value() }}" alt=""></div> <div class="poster"><img src="/posters/{{ poster.value() }}" alt=""></div>
{% endif %} {% endif %}
<div class="entry-body"> <div class="entry-body">
<div class="entry-title">{{ entry.movie().title().value() }} <span class="year">({{ entry.movie().release_year().value() }})</span></div> <div class="entry-title"><a href="/movies/{{ entry.movie().id().value() }}" class="movie-title-link">{{ entry.movie().title().value() }}</a> <span class="year">({{ entry.movie().release_year().value() }})</span></div>
{% if let Some(dir) = entry.movie().director() %}<div class="director">{{ dir }}</div>{% endif %} {% if let Some(dir) = entry.movie().director() %}<div class="director">{{ dir }}</div>{% endif %}
<div class="rating"> <div class="rating">
{% for filled in entry.review().stars() %} {% for filled in entry.review().stars() %}
@@ -179,7 +179,7 @@
{% endif %} {% endif %}
<div class="entry-body"> <div class="entry-body">
<div class="entry-title"> <div class="entry-title">
{{ entry.movie().title().value() }} <a href="/movies/{{ entry.movie().id().value() }}" class="movie-title-link">{{ entry.movie().title().value() }}</a>
<span class="year">({{ entry.movie().release_year().value() }})</span> <span class="year">({{ entry.movie().release_year().value() }})</span>
</div> </div>
{% if let Some(dir) = entry.movie().director() %} {% if let Some(dir) = entry.movie().director() %}

View File

@@ -11,11 +11,9 @@ chrono = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
futures = { workspace = true } futures = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
importer = { workspace = true }
serde_json = { workspace = true }
[features] [features]
xlsx = ["importer/xlsx"] xlsx = []
[dev-dependencies] [dev-dependencies]
tokio = { workspace = true } tokio = { workspace = true }

View File

@@ -1,6 +1,5 @@
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
use domain::models::{ExportFormat, UserRole}; use domain::models::{ExportFormat, FieldMapping, FileFormat, UserRole};
use importer::FieldMapping;
use uuid::Uuid; use uuid::Uuid;
pub struct LogReviewCommand { pub struct LogReviewCommand {
@@ -44,11 +43,7 @@ pub struct ExportCommand {
pub format: ExportFormat, pub format: ExportFormat,
} }
pub enum FileFormat { // FileFormat is now in domain::models — no longer defined here
Csv,
Json,
Xlsx,
}
pub struct CreateImportSessionCommand { pub struct CreateImportSessionCommand {
pub user_id: Uuid, pub user_id: Uuid,

View File

@@ -1,7 +1,7 @@
use std::sync::Arc; use std::sync::Arc;
use domain::ports::{ use domain::ports::{
AuthService, DiaryExporter, DiaryRepository, EventPublisher, AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher,
ImportProfileRepository, ImportSessionRepository, ImportProfileRepository, ImportSessionRepository,
MetadataClient, MovieRepository, PasswordHasher, PosterFetcherClient, MetadataClient, MovieRepository, PasswordHasher, PosterFetcherClient,
PosterStorage, ReviewRepository, StatsRepository, UserRepository, PosterStorage, ReviewRepository, StatsRepository, UserRepository,
@@ -15,6 +15,7 @@ pub struct AppContext {
pub review_repository: Arc<dyn ReviewRepository>, pub review_repository: Arc<dyn ReviewRepository>,
pub diary_repository: Arc<dyn DiaryRepository>, pub diary_repository: Arc<dyn DiaryRepository>,
pub diary_exporter: Arc<dyn DiaryExporter>, pub diary_exporter: Arc<dyn DiaryExporter>,
pub document_parser: Arc<dyn DocumentParser>,
pub stats_repository: Arc<dyn StatsRepository>, pub stats_repository: Arc<dyn StatsRepository>,
pub metadata_client: Arc<dyn MetadataClient>, pub metadata_client: Arc<dyn MetadataClient>,
pub poster_fetcher: Arc<dyn PosterFetcherClient>, pub poster_fetcher: Arc<dyn PosterFetcherClient>,

View File

@@ -1,7 +1,7 @@
use uuid::Uuid; use uuid::Uuid;
use domain::models::{ use domain::models::{
DiaryEntry, FeedEntry, MonthActivity, UserStats, UserSummary, UserTrends, DiaryEntry, FeedEntry, MonthActivity, Movie, MovieStats, UserStats, UserSummary, UserTrends,
collections::Paginated, collections::Paginated,
}; };
@@ -95,6 +95,17 @@ pub struct FollowersPageData {
pub error: Option<String>, pub error: Option<String>,
} }
pub struct MovieDetailPageData {
pub ctx: HtmlPageContext,
pub movie: Movie,
pub stats: MovieStats,
pub reviews: Paginated<FeedEntry>,
pub current_offset: u32,
pub has_more: bool,
pub limit: u32,
pub histogram_max: u64,
}
pub struct ImportUploadPageData { pub struct ImportUploadPageData {
pub ctx: HtmlPageContext, pub ctx: HtmlPageContext,
pub profiles: Vec<ImportProfileView>, pub profiles: Vec<ImportProfileView>,
@@ -148,6 +159,7 @@ pub trait HtmlRenderer: Send + Sync {
fn render_profile_page(&self, data: ProfilePageData) -> Result<String, String>; fn render_profile_page(&self, data: ProfilePageData) -> Result<String, String>;
fn render_following_page(&self, data: FollowingPageData) -> Result<String, String>; fn render_following_page(&self, data: FollowingPageData) -> Result<String, String>;
fn render_followers_page(&self, data: FollowersPageData) -> Result<String, String>; fn render_followers_page(&self, data: FollowersPageData) -> Result<String, String>;
fn render_movie_detail_page(&self, data: MovieDetailPageData) -> Result<String, String>;
fn render_import_upload_page(&self, data: ImportUploadPageData) -> Result<String, String>; fn render_import_upload_page(&self, data: ImportUploadPageData) -> Result<String, String>;
fn render_import_mapping_page(&self, data: ImportMappingPageData) -> Result<String, String>; fn render_import_mapping_page(&self, data: ImportMappingPageData) -> Result<String, String>;
fn render_import_preview_page(&self, data: ImportPreviewPageData) -> Result<String, String>; fn render_import_preview_page(&self, data: ImportPreviewPageData) -> Result<String, String>;

View File

@@ -64,3 +64,9 @@ pub struct GetUserProfileQuery {
pub sort_by: domain::ports::FeedSortBy, pub sort_by: domain::ports::FeedSortBy,
pub search: Option<String>, pub search: Option<String>,
} }
pub struct GetMovieSocialPageQuery {
pub movie_id: uuid::Uuid,
pub limit: u32,
pub offset: u32,
}

View File

@@ -1,8 +1,8 @@
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::{AnnotatedRow, import::RowResult},
value_objects::{ExternalMetadataId, ImportSessionId, MovieTitle, ReleaseYear, UserId}, value_objects::{ExternalMetadataId, ImportSessionId, MovieTitle, ReleaseYear, UserId},
}; };
use importer::{AnnotatedRow, ParsedFile, apply_mapping};
use crate::{commands::ApplyImportMappingCommand, context::AppContext}; use crate::{commands::ApplyImportMappingCommand, context::AppContext};
@@ -15,32 +15,27 @@ pub async fn execute(ctx: &AppContext, cmd: ApplyImportMappingCommand) -> Result
.await? .await?
.ok_or_else(|| DomainError::NotFound("import session".into()))?; .ok_or_else(|| DomainError::NotFound("import session".into()))?;
let parsed: ParsedFile = serde_json::from_str(&session.parsed_data) // clone to avoid borrow conflict when mutating session fields below
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?; let parsed = session.parsed_file.clone()
.ok_or_else(|| DomainError::ValidationError("session has no parsed file".into()))?;
let mut annotated = apply_mapping(&parsed, &mappings); let mut annotated = ctx.document_parser.apply_mapping(&parsed, &mappings);
for row in annotated.iter_mut() { for row in annotated.iter_mut() {
if let importer::RowResult::Valid(ref import_row) = row.result { if let RowResult::Valid(ref import_row) = row.result {
row.is_duplicate = check_duplicate(ctx, import_row).await?; row.is_duplicate = check_duplicate(ctx, import_row).await?;
} }
} }
session.field_mappings = Some( session.field_mappings = Some(mappings);
serde_json::to_string(&mappings) session.row_results = Some(annotated.clone());
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
);
session.row_results = Some(
serde_json::to_string(&annotated)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
);
ctx.import_session_repository.update(&session).await?; ctx.import_session_repository.update(&session).await?;
Ok(annotated) Ok(annotated)
} }
async fn check_duplicate(ctx: &AppContext, row: &importer::ImportRow) -> Result<bool, DomainError> { async fn check_duplicate(ctx: &AppContext, row: &domain::models::ImportRow) -> Result<bool, DomainError> {
if let Some(ext_id) = &row.external_metadata_id { if let Some(ext_id) = &row.external_metadata_id {
if let Ok(eid) = ExternalMetadataId::new(ext_id.clone()) { if let Ok(eid) = ExternalMetadataId::new(ext_id.clone()) {
if ctx.movie_repository.get_movie_by_external_id(&eid).await?.is_some() { if ctx.movie_repository.get_movie_by_external_id(&eid).await?.is_some() {

View File

@@ -1,8 +1,11 @@
use chrono::Utc; use chrono::Utc;
use domain::{errors::DomainError, models::ImportSession, value_objects::{ImportSessionId, UserId}}; use domain::{
use importer::{ImportError, ParsedFile}; errors::DomainError,
models::ImportSession,
value_objects::{ImportSessionId, UserId},
};
use crate::{commands::{CreateImportSessionCommand, FileFormat}, context::AppContext}; use crate::{commands::CreateImportSessionCommand, context::AppContext};
pub struct CreateSessionResult { pub struct CreateSessionResult {
pub session_id: ImportSessionId, pub session_id: ImportSessionId,
@@ -14,31 +17,19 @@ pub async fn execute(ctx: &AppContext, cmd: CreateImportSessionCommand) -> Resul
let user_id = UserId::from_uuid(cmd.user_id); let user_id = UserId::from_uuid(cmd.user_id);
ctx.import_session_repository.delete_expired_for_user(&user_id).await?; ctx.import_session_repository.delete_expired_for_user(&user_id).await?;
let parsed = parse(cmd.bytes, cmd.format).map_err(|e| DomainError::ValidationError(e.to_string()))?; let parsed = ctx.document_parser
.parse(&cmd.bytes, cmd.format)
.map_err(|e| DomainError::ValidationError(e.to_string()))?;
let sample_rows = parsed.rows.iter().take(5).cloned().collect(); let sample_rows = parsed.rows.iter().take(5).cloned().collect();
let columns = parsed.columns.clone(); let columns = parsed.columns.clone();
let parsed_data = serde_json::to_string(&parsed)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let now = Utc::now().naive_utc(); let now = Utc::now().naive_utc();
let session = ImportSession::new(ImportSessionId::generate(), user_id, parsed_data, now); let mut session = ImportSession::new(ImportSessionId::generate(), user_id, now);
let session_id = session.id.clone(); let session_id = session.id.clone();
session.parsed_file = Some(parsed);
ctx.import_session_repository.create(&session).await?; ctx.import_session_repository.create(&session).await?;
Ok(CreateSessionResult { session_id, columns, sample_rows }) Ok(CreateSessionResult { session_id, columns, sample_rows })
} }
fn parse(bytes: Vec<u8>, format: FileFormat) -> Result<ParsedFile, ImportError> {
match format {
FileFormat::Csv => importer::parse_csv(&bytes),
FileFormat::Json => importer::parse_json(&bytes),
FileFormat::Xlsx => {
#[cfg(feature = "xlsx")]
{ importer::parse_xlsx(&bytes) }
#[cfg(not(feature = "xlsx"))]
{ Err(ImportError::Xlsx("XLSX support not compiled in".into())) }
}
}
}

View File

@@ -1,6 +1,9 @@
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
use domain::{errors::DomainError, value_objects::{ImportSessionId, UserId}}; use domain::{
use importer::{AnnotatedRow, ImportRow, RowResult}; errors::DomainError,
models::{ImportRow, import::RowResult},
value_objects::{ImportSessionId, UserId},
};
use uuid::Uuid; use uuid::Uuid;
use crate::{commands::{ExecuteImportCommand, LogReviewCommand}, context::AppContext, use_cases::log_review}; use crate::{commands::{ExecuteImportCommand, LogReviewCommand}, context::AppContext, use_cases::log_review};
@@ -20,11 +23,7 @@ pub async fn execute(ctx: &AppContext, cmd: ExecuteImportCommand) -> Result<Impo
.await? .await?
.ok_or_else(|| DomainError::NotFound("import session".into()))?; .ok_or_else(|| DomainError::NotFound("import session".into()))?;
let row_results: Vec<AnnotatedRow> = session.row_results let row_results = session.row_results.unwrap_or_default();
.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or_default();
let confirmed_set: std::collections::HashSet<usize> = confirmed_indices.into_iter().collect(); let confirmed_set: std::collections::HashSet<usize> = confirmed_indices.into_iter().collect();
let mut imported = 0; let mut imported = 0;

View File

@@ -0,0 +1,34 @@
use domain::{
errors::DomainError,
models::{FeedEntry, Movie, MovieStats, collections::{PageParams, Paginated}},
value_objects::MovieId,
};
use crate::{context::AppContext, queries::GetMovieSocialPageQuery};
pub struct MovieSocialPageResult {
pub movie: Movie,
pub stats: MovieStats,
pub reviews: Paginated<FeedEntry>,
}
pub async fn execute(
ctx: &AppContext,
query: GetMovieSocialPageQuery,
) -> Result<MovieSocialPageResult, DomainError> {
let movie_id = MovieId::from_uuid(query.movie_id);
let page = PageParams::new(Some(query.limit), Some(query.offset))?;
let movie = ctx
.movie_repository
.get_movie_by_id(&movie_id)
.await?
.ok_or_else(|| DomainError::NotFound(format!("Movie {}", query.movie_id)))?;
let (stats, reviews) = tokio::try_join!(
ctx.diary_repository.get_movie_stats(&movie_id),
ctx.diary_repository.get_movie_social_feed(&movie_id, &page),
)?;
Ok(MovieSocialPageResult { movie, stats, reviews })
}

View File

@@ -10,6 +10,7 @@ pub mod save_import_profile;
pub mod export_diary; pub mod export_diary;
pub mod get_activity_feed; pub mod get_activity_feed;
pub mod get_diary; pub mod get_diary;
pub mod get_movie_social_page;
pub mod get_review_history; pub mod get_review_history;
pub mod get_user_profile; pub mod get_user_profile;
pub mod get_users; pub mod get_users;

View File

@@ -1,12 +1,12 @@
use serde::{Deserialize, Serialize}; use thiserror::Error;
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Default)]
pub struct ParsedFile { pub struct ParsedFile {
pub columns: Vec<String>, pub columns: Vec<String>,
pub rows: Vec<Vec<String>>, pub rows: Vec<Vec<String>>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum DomainField { pub enum DomainField {
Title, Title,
ReleaseYear, ReleaseYear,
@@ -17,21 +17,21 @@ pub enum DomainField {
ExternalMetadataId, ExternalMetadataId,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone)]
pub enum Transform { pub enum Transform {
RatingScale(f64), RatingScale(f64),
DateFormat(String), DateFormat(String),
Identity, Identity,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone)]
pub struct FieldMapping { pub struct FieldMapping {
pub source_column: String, pub source_column: String,
pub domain_field: DomainField, pub domain_field: DomainField,
pub transform: Transform, pub transform: Transform,
} }
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Default)]
pub struct ImportRow { pub struct ImportRow {
pub title: Option<String>, pub title: Option<String>,
pub release_year: Option<String>, pub release_year: Option<String>,
@@ -42,16 +42,34 @@ pub struct ImportRow {
pub external_metadata_id: Option<String>, pub external_metadata_id: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone)]
pub enum RowResult { pub enum RowResult {
Valid(ImportRow), Valid(ImportRow),
Invalid { errors: Vec<String>, raw: Vec<(String, String)> }, Invalid { errors: Vec<String>, raw: Vec<(String, String)> },
} }
/// Wraps a RowResult with a duplicate flag so this information persists when #[derive(Debug, Clone)]
/// serialised as JSON into the import_sessions.row_results DB column.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnnotatedRow { pub struct AnnotatedRow {
pub result: RowResult, pub result: RowResult,
pub is_duplicate: bool, pub is_duplicate: bool,
} }
#[derive(Debug, Error)]
pub enum ImportError {
#[error("CSV parse error: {0}")]
Csv(String),
#[error("JSON parse error: {0}")]
Json(String),
#[error("XLSX parse error: {0}")]
Xlsx(String),
#[error("Empty file")]
Empty,
#[error("Missing header row")]
NoHeader,
}
pub enum FileFormat {
Csv,
Json,
Xlsx,
}

View File

@@ -1,17 +1,26 @@
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
use crate::value_objects::{ImportProfileId, UserId}; use crate::{
models::FieldMapping,
value_objects::{ImportProfileId, UserId},
};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ImportProfile { pub struct ImportProfile {
pub id: ImportProfileId, pub id: ImportProfileId,
pub user_id: UserId, pub user_id: UserId,
pub name: String, pub name: String,
pub field_mappings: String, pub field_mappings: Vec<FieldMapping>,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
} }
impl ImportProfile { impl ImportProfile {
pub fn new(id: ImportProfileId, user_id: UserId, name: String, field_mappings: String, created_at: NaiveDateTime) -> Self { pub fn new(
id: ImportProfileId,
user_id: UserId,
name: String,
field_mappings: Vec<FieldMapping>,
created_at: NaiveDateTime,
) -> Self {
Self { id, user_id, name, field_mappings, created_at } Self { id, user_id, name, field_mappings, created_at }
} }
} }

View File

@@ -1,20 +1,31 @@
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
use crate::value_objects::{ImportSessionId, UserId}; use crate::{
models::{AnnotatedRow, FieldMapping, ParsedFile},
value_objects::{ImportSessionId, UserId},
};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ImportSession { pub struct ImportSession {
pub id: ImportSessionId, pub id: ImportSessionId,
pub user_id: UserId, pub user_id: UserId,
pub parsed_data: String, pub parsed_file: Option<ParsedFile>,
pub field_mappings: Option<String>, pub field_mappings: Option<Vec<FieldMapping>>,
pub row_results: Option<String>, pub row_results: Option<Vec<AnnotatedRow>>,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub expires_at: NaiveDateTime, pub expires_at: NaiveDateTime,
} }
impl ImportSession { impl ImportSession {
pub fn new(id: ImportSessionId, user_id: UserId, parsed_data: String, created_at: NaiveDateTime) -> Self { pub fn new(id: ImportSessionId, user_id: UserId, created_at: NaiveDateTime) -> Self {
let expires_at = created_at + chrono::Duration::hours(24); let expires_at = created_at + chrono::Duration::hours(24);
Self { id, user_id, parsed_data, field_mappings: None, row_results: None, created_at, expires_at } Self {
id,
user_id,
parsed_file: None,
field_mappings: None,
row_results: None,
created_at,
expires_at,
}
} }
} }

View File

@@ -9,9 +9,14 @@ use crate::{
}, },
}; };
pub mod collections; pub mod collections;
pub mod import;
pub mod import_session; pub mod import_session;
pub mod import_profile; pub mod import_profile;
pub use import::{
AnnotatedRow, DomainField, FieldMapping, FileFormat, ImportError,
ImportRow, ParsedFile, RowResult, Transform,
};
pub use import_session::ImportSession; pub use import_session::ImportSession;
pub use import_profile::ImportProfile; pub use import_profile::ImportProfile;
@@ -216,6 +221,10 @@ impl Review {
let r = self.rating.value(); let r = self.rating.value();
[r >= 1, r >= 2, r >= 3, r >= 4, r >= 5] [r >= 1, r >= 2, r >= 3, r >= 4, r >= 5]
} }
pub fn is_remote(&self) -> bool {
matches!(self.source, ReviewSource::Remote { .. })
}
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@@ -259,6 +268,14 @@ impl ReviewHistory {
} }
} }
#[derive(Clone, Debug)]
pub struct MovieStats {
pub total_count: u64,
pub avg_rating: Option<f64>,
pub federated_count: u64,
pub rating_histogram: [u64; 5], // index 0 = 1★, index 4 = 5★
}
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default)]
pub enum UserRole { pub enum UserRole {
#[default] #[default]

View File

@@ -5,7 +5,8 @@ use crate::{
errors::DomainError, errors::DomainError,
events::{DomainEvent, EventEnvelope}, events::{DomainEvent, EventEnvelope},
models::{ models::{
DiaryEntry, DiaryFilter, ExportFormat, FeedEntry, ImportProfile, ImportSession, Movie, AnnotatedRow, DiaryEntry, DiaryFilter, ExportFormat, FeedEntry, FieldMapping,
FileFormat, ImportError, ImportProfile, ImportSession, Movie, MovieStats, ParsedFile,
Review, ReviewHistory, User, UserStats, UserSummary, UserTrends, Review, ReviewHistory, User, UserStats, UserSummary, UserTrends,
collections::{PageParams, Paginated}, collections::{PageParams, Paginated},
}, },
@@ -15,6 +16,11 @@ use crate::{
}, },
}; };
pub trait DocumentParser: Send + Sync {
fn parse(&self, bytes: &[u8], format: FileFormat) -> Result<ParsedFile, ImportError>;
fn apply_mapping(&self, file: &ParsedFile, mappings: &[FieldMapping]) -> Vec<AnnotatedRow>;
}
#[derive(Debug, Clone, Default, PartialEq)] #[derive(Debug, Clone, Default, PartialEq)]
pub enum FeedSortBy { pub enum FeedSortBy {
#[default] #[default]
@@ -104,6 +110,12 @@ pub trait DiaryRepository: Send + Sync {
) -> Result<Paginated<FeedEntry>, DomainError>; ) -> Result<Paginated<FeedEntry>, DomainError>;
async fn get_review_history(&self, movie_id: &MovieId) -> Result<ReviewHistory, DomainError>; async fn get_review_history(&self, movie_id: &MovieId) -> Result<ReviewHistory, DomainError>;
async fn get_user_history(&self, user_id: &UserId) -> Result<Vec<DiaryEntry>, DomainError>; async fn get_user_history(&self, user_id: &UserId) -> Result<Vec<DiaryEntry>, DomainError>;
async fn get_movie_stats(&self, movie_id: &MovieId) -> Result<MovieStats, DomainError>;
async fn get_movie_social_feed(
&self,
movie_id: &MovieId,
page: &PageParams,
) -> Result<Paginated<FeedEntry>, DomainError>;
} }
#[async_trait] #[async_trait]

View File

@@ -427,6 +427,44 @@ fn default_export_format() -> String {
"csv".to_string() "csv".to_string()
} }
#[derive(serde::Deserialize, Default)]
pub struct PaginationQueryParams {
pub limit: Option<u32>,
pub offset: Option<u32>,
}
#[derive(serde::Serialize, utoipa::ToSchema)]
pub struct MovieStatsDto {
pub total_count: u64,
pub avg_rating: Option<f64>,
pub federated_count: u64,
pub rating_histogram: [u64; 5],
}
#[derive(serde::Serialize, utoipa::ToSchema)]
pub struct SocialReviewDto {
pub user_display: String,
pub rating: u8,
pub comment: Option<String>,
pub watched_at: String,
pub is_federated: bool,
}
#[derive(serde::Serialize, utoipa::ToSchema)]
pub struct SocialFeedResponse {
pub items: Vec<SocialReviewDto>,
pub total_count: u64,
pub limit: u32,
pub offset: u32,
}
#[derive(serde::Serialize, utoipa::ToSchema)]
pub struct MovieDetailResponse {
pub movie: MovieDto,
pub stats: MovieStatsDto,
pub reviews: SocialFeedResponse,
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View File

@@ -219,6 +219,19 @@ mod tests {
async fn get_user_history(&self, _: &UserId) -> Result<Vec<DiaryEntry>, DomainError> { async fn get_user_history(&self, _: &UserId) -> Result<Vec<DiaryEntry>, DomainError> {
panic!() panic!()
} }
async fn get_movie_stats(
&self,
_: &MovieId,
) -> Result<domain::models::MovieStats, DomainError> {
panic!()
}
async fn get_movie_social_feed(
&self,
_: &MovieId,
_: &PageParams,
) -> Result<Paginated<FeedEntry>, DomainError> {
panic!()
}
} }
#[cfg(feature = "federation")] #[cfg(feature = "federation")]
#[async_trait::async_trait] #[async_trait::async_trait]
@@ -352,6 +365,15 @@ mod tests {
} }
} }
impl domain::ports::DocumentParser for Panic {
fn parse(&self, _: &[u8], _: domain::models::FileFormat) -> Result<domain::models::ParsedFile, domain::models::ImportError> {
panic!()
}
fn apply_mapping(&self, _: &domain::models::ParsedFile, _: &[domain::models::FieldMapping]) -> Vec<domain::models::AnnotatedRow> {
panic!()
}
}
impl crate::ports::HtmlRenderer for Panic { impl crate::ports::HtmlRenderer for Panic {
fn render_diary_page( fn render_diary_page(
&self, &self,
@@ -408,6 +430,12 @@ mod tests {
) -> Result<String, String> { ) -> Result<String, String> {
panic!() panic!()
} }
fn render_movie_detail_page(
&self,
_: application::ports::MovieDetailPageData,
) -> Result<String, String> {
panic!()
}
fn render_import_upload_page(&self, _: application::ports::ImportUploadPageData) -> Result<String, String> { panic!() } fn render_import_upload_page(&self, _: application::ports::ImportUploadPageData) -> Result<String, String> { panic!() }
fn render_import_mapping_page(&self, _: application::ports::ImportMappingPageData) -> Result<String, String> { panic!() } fn render_import_mapping_page(&self, _: application::ports::ImportMappingPageData) -> Result<String, String> { panic!() }
fn render_import_preview_page(&self, _: application::ports::ImportPreviewPageData) -> Result<String, String> { panic!() } fn render_import_preview_page(&self, _: application::ports::ImportPreviewPageData) -> Result<String, String> { panic!() }
@@ -439,6 +467,7 @@ mod tests {
review_repository: Arc::clone(&repo) as _, review_repository: Arc::clone(&repo) as _,
diary_repository: Arc::clone(&repo) as _, diary_repository: Arc::clone(&repo) as _,
diary_exporter: Arc::clone(&repo) as _, diary_exporter: Arc::clone(&repo) as _,
document_parser: Arc::clone(&repo) as _,
stats_repository: Arc::clone(&repo) as _, stats_repository: Arc::clone(&repo) as _,
metadata_client: Arc::clone(&repo) as _, metadata_client: Arc::clone(&repo) as _,
poster_fetcher: Arc::clone(&repo) as _, poster_fetcher: Arc::clone(&repo) as _,

View File

@@ -13,12 +13,14 @@ use application::{
DeleteReviewCommand, ExportCommand, LoginCommand, RegisterCommand, SyncPosterCommand, DeleteReviewCommand, ExportCommand, LoginCommand, RegisterCommand, SyncPosterCommand,
}, },
queries::{ queries::{
GetActivityFeedQuery, GetReviewHistoryQuery, GetUserProfileQuery, GetUsersQuery, GetActivityFeedQuery, GetMovieSocialPageQuery, GetReviewHistoryQuery, GetUserProfileQuery,
GetUsersQuery,
}, },
use_cases::{ use_cases::{
delete_review, export_diary as export_diary_uc, get_activity_feed as get_feed_uc, delete_review, export_diary as export_diary_uc, get_activity_feed as get_feed_uc,
get_diary, get_review_history, get_user_profile as get_user_profile_uc, get_users, get_diary, get_movie_social_page, get_review_history,
log_review, login as login_uc, register as register_uc, sync_poster, get_user_profile as get_user_profile_uc, get_users, log_review, login as login_uc,
register as register_uc, sync_poster,
}, },
}; };
use domain::{ use domain::{
@@ -35,8 +37,10 @@ use crate::{
ActivityFeedQueryParams, ActivityFeedResponse, DiaryEntryDto, DiaryQueryParams, ActivityFeedQueryParams, ActivityFeedResponse, DiaryEntryDto, DiaryQueryParams,
DiaryResponse, DirectorStatDto, ExportQueryParams, FeedEntryDto, LogReviewData, DiaryResponse, DirectorStatDto, ExportQueryParams, FeedEntryDto, LogReviewData,
LogReviewRequest, LoginRequest, LoginResponse, MonthActivityDto, MonthlyRatingDto, LogReviewRequest, LoginRequest, LoginResponse, MonthActivityDto, MonthlyRatingDto,
MovieDto, RegisterRequest, ReviewDto, ReviewHistoryResponse, UserProfileQueryParams, MovieDetailResponse, MovieDto, MovieStatsDto, PaginationQueryParams, RegisterRequest,
UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto, UsersResponse, ReviewDto, ReviewHistoryResponse, SocialFeedResponse, SocialReviewDto,
UserProfileQueryParams, UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto,
UsersResponse,
}, },
errors::ApiError, errors::ApiError,
extractors::AuthenticatedUser, extractors::AuthenticatedUser,
@@ -241,6 +245,51 @@ pub async fn delete_review(
} }
} }
#[utoipa::path(
get, path = "/api/v1/movies/{movie_id}",
params(("movie_id" = Uuid, Path, description = "Movie ID")),
responses(
(status = 200, body = MovieDetailResponse),
(status = 404, description = "Movie not found"),
)
)]
pub async fn get_movie_detail(
State(state): State<AppState>,
Path(movie_id): Path<Uuid>,
Query(params): Query<PaginationQueryParams>,
) -> Result<Json<MovieDetailResponse>, ApiError> {
let limit = params.limit.unwrap_or(20);
let offset = params.offset.unwrap_or(0);
let result = get_movie_social_page::execute(
&state.app_ctx,
GetMovieSocialPageQuery { movie_id, limit, offset },
)
.await?;
Ok(Json(MovieDetailResponse {
movie: movie_to_dto(&result.movie),
stats: MovieStatsDto {
total_count: result.stats.total_count,
avg_rating: result.stats.avg_rating,
federated_count: result.stats.federated_count,
rating_histogram: result.stats.rating_histogram,
},
reviews: SocialFeedResponse {
items: result.reviews.items.iter().map(|e| SocialReviewDto {
user_display: e.user_display_name().to_string(),
rating: e.review().rating().value(),
comment: e.review().comment().map(|c| c.value().to_string()),
watched_at: e.review().watched_at().to_string(),
is_federated: e.review().is_remote(),
}).collect(),
total_count: result.reviews.total_count,
limit: result.reviews.limit,
offset: result.reviews.offset,
},
}))
}
fn movie_to_dto(movie: &Movie) -> MovieDto { fn movie_to_dto(movie: &Movie) -> MovieDto {
MovieDto { MovieDto {
id: movie.id().value(), id: movie.id().value(),

View File

@@ -14,11 +14,13 @@ use application::ports::{FollowersPageData, FollowingPageData};
use application::{ use application::{
commands::{DeleteReviewCommand, ExportCommand, LoginCommand, RegisterCommand}, commands::{DeleteReviewCommand, ExportCommand, LoginCommand, RegisterCommand},
ports::{ ports::{
HtmlPageContext, LoginPageData, NewReviewPageData, RegisterPageData, RemoteActorView, HtmlPageContext, LoginPageData, MovieDetailPageData, NewReviewPageData, RegisterPageData,
RemoteActorView,
}, },
queries::GetMovieSocialPageQuery,
use_cases::{ use_cases::{
delete_review, export_diary as export_diary_uc, log_review, login as login_uc, delete_review, export_diary as export_diary_uc, get_movie_social_page, log_review,
register as register_uc, login as login_uc, register as register_uc,
}, },
}; };
use domain::models::ExportFormat; use domain::models::ExportFormat;
@@ -916,3 +918,51 @@ pub async fn remove_follower(
} }
} }
} }
pub async fn get_movie_detail(
OptionalCookieUser(user_id): OptionalCookieUser,
State(state): State<AppState>,
Path(movie_id): Path<uuid::Uuid>,
Query(params): Query<crate::dtos::PaginationQueryParams>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
let ctx = build_page_context(&state, user_id, csrf.0).await;
let limit = params.limit.unwrap_or(20);
let offset = params.offset.unwrap_or(0);
match get_movie_social_page::execute(
&state.app_ctx,
GetMovieSocialPageQuery { movie_id, limit, offset },
)
.await
{
Err(DomainError::NotFound(_)) => StatusCode::NOT_FOUND.into_response(),
Err(DomainError::ValidationError(_)) => StatusCode::BAD_REQUEST.into_response(),
Err(e) => {
tracing::error!("movie detail error: {:?}", e);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
Ok(result) => {
let histogram_max = result.stats.rating_histogram.iter().copied().max().unwrap_or(1);
let has_more = result.reviews.offset + result.reviews.limit
< result.reviews.total_count as u32;
let data = MovieDetailPageData {
ctx,
movie: result.movie,
stats: result.stats,
current_offset: result.reviews.offset,
has_more,
limit: result.reviews.limit,
reviews: result.reviews,
histogram_max,
};
match state.html_renderer.render_movie_detail_page(data) {
Ok(html) => Html(html).into_response(),
Err(e) => {
tracing::error!("template error: {}", e);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
}
}

View File

@@ -10,7 +10,7 @@ use std::collections::HashMap;
use application::{ use application::{
commands::{ commands::{
ApplyImportMappingCommand, CreateImportSessionCommand, DeleteImportProfileCommand, ApplyImportMappingCommand, CreateImportSessionCommand, DeleteImportProfileCommand,
ExecuteImportCommand, FileFormat, SaveImportProfileCommand, ExecuteImportCommand, SaveImportProfileCommand,
}, },
ports::{ ports::{
ImportMappingPageData, ImportPreviewPageData, ImportPreviewRow, ImportProfileView, ImportMappingPageData, ImportPreviewPageData, ImportPreviewRow, ImportProfileView,
@@ -21,8 +21,8 @@ use application::{
list_import_profiles, save_import_profile, list_import_profiles, save_import_profile,
}, },
}; };
use domain::models::{AnnotatedRow, FieldMapping, FileFormat, import::{DomainField, RowResult, Transform}};
use domain::value_objects::ImportSessionId; use domain::value_objects::ImportSessionId;
use importer::{AnnotatedRow, DomainField, FieldMapping, RowResult, Transform};
use crate::{ use crate::{
csrf::CsrfToken, csrf::CsrfToken,
@@ -220,7 +220,7 @@ pub async fn get_mapping_page(
else { else {
return Redirect::to("/import").into_response(); return Redirect::to("/import").into_response();
}; };
let Ok(parsed) = serde_json::from_str::<importer::ParsedFile>(&session.parsed_data) else { let Some(parsed) = session.parsed_file else {
return Redirect::to("/import").into_response(); return Redirect::to("/import").into_response();
}; };
@@ -318,13 +318,8 @@ pub async fn get_preview_page(
return Redirect::to(&format!("/import/{}/mapping", session_id_str)).into_response(); return Redirect::to(&format!("/import/{}/mapping", session_id_str)).into_response();
} }
let parsed = let parsed = session.parsed_file.unwrap_or_default();
serde_json::from_str::<importer::ParsedFile>(&session.parsed_data).unwrap_or_default(); let annotated: Vec<AnnotatedRow> = session.row_results.unwrap_or_default();
let annotated: Vec<AnnotatedRow> = session
.row_results
.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or_default();
let rows: Vec<ImportPreviewRow> = annotated let rows: Vec<ImportPreviewRow> = annotated
.iter() .iter()
@@ -589,8 +584,7 @@ pub async fn api_get_session(
.await .await
{ {
Ok(Some(session)) => { Ok(Some(session)) => {
let parsed = serde_json::from_str::<importer::ParsedFile>(&session.parsed_data) let parsed = session.parsed_file.unwrap_or_default();
.unwrap_or_default();
let row_count = parsed.rows.len(); let row_count = parsed.rows.len();
axum::Json(SessionStateResponse { axum::Json(SessionStateResponse {
session_id: session_id_str, session_id: session_id_str,

View File

@@ -7,6 +7,7 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use application::{config::AppConfig, context::AppContext}; use application::{config::AppConfig, context::AppContext};
use export::ExportAdapter; use export::ExportAdapter;
use importer::ImporterDocumentParser;
use rss::RssAdapter; use rss::RssAdapter;
use template_askama::AskamaHtmlRenderer; use template_askama::AskamaHtmlRenderer;
@@ -14,7 +15,7 @@ use doc::ApiDocExt;
use presentation::{openapi::ApiDoc, routes, state::AppState}; use presentation::{openapi::ApiDoc, routes, state::AppState};
use utoipa::OpenApi as _; use utoipa::OpenApi as _;
use domain::ports::{DiaryExporter, EventPublisher, ImportProfileRepository, ImportSessionRepository}; use domain::ports::{DiaryExporter, DocumentParser, EventPublisher, ImportProfileRepository, ImportSessionRepository};
#[cfg(not(any(feature = "sqlite", feature = "postgres")))] #[cfg(not(any(feature = "sqlite", feature = "postgres")))]
compile_error!("At least one database backend must be enabled. Use --features sqlite or --features postgres"); compile_error!("At least one database backend must be enabled. Use --features sqlite or --features postgres");
@@ -150,6 +151,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
review_repository, review_repository,
diary_repository, diary_repository,
diary_exporter: Arc::new(ExportAdapter) as Arc<dyn DiaryExporter>, diary_exporter: Arc::new(ExportAdapter) as Arc<dyn DiaryExporter>,
document_parser: Arc::new(ImporterDocumentParser) as Arc<dyn DocumentParser>,
stats_repository, stats_repository,
metadata_client, metadata_client,
poster_fetcher, poster_fetcher,

View File

@@ -6,9 +6,9 @@ use utoipa::{
use crate::dtos::{ use crate::dtos::{
ActivityFeedResponse, DiaryEntryDto, DiaryResponse, ActivityFeedResponse, DiaryEntryDto, DiaryResponse,
DirectorStatDto, FeedEntryDto, LoginRequest, LoginResponse, LogReviewRequest, DirectorStatDto, FeedEntryDto, LoginRequest, LoginResponse, LogReviewRequest,
MonthActivityDto, MonthlyRatingDto, MovieDto, RegisterRequest, ReviewDto, MonthActivityDto, MonthlyRatingDto, MovieDetailResponse, MovieDto, MovieStatsDto,
ReviewHistoryResponse, UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto, RegisterRequest, ReviewDto, ReviewHistoryResponse, SocialFeedResponse, SocialReviewDto,
UsersResponse, UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto, UsersResponse,
}; };
use crate::handlers::import::{ use crate::handlers::import::{
ApiFieldMapping, ApplyMappingRequest, ConfirmRequest, SaveProfileRequest, ApiFieldMapping, ApplyMappingRequest, ConfirmRequest, SaveProfileRequest,
@@ -40,6 +40,7 @@ impl Modify for SecurityAddon {
paths( paths(
crate::handlers::api::get_diary, crate::handlers::api::get_diary,
crate::handlers::api::get_review_history, crate::handlers::api::get_review_history,
crate::handlers::api::get_movie_detail,
crate::handlers::api::post_review, crate::handlers::api::post_review,
crate::handlers::api::delete_review, crate::handlers::api::delete_review,
crate::handlers::api::sync_poster, crate::handlers::api::sync_poster,
@@ -67,6 +68,10 @@ impl Modify for SecurityAddon {
LoginResponse, LoginResponse,
RegisterRequest, RegisterRequest,
ReviewHistoryResponse, ReviewHistoryResponse,
MovieDetailResponse,
MovieStatsDto,
SocialFeedResponse,
SocialReviewDto,
ActivityFeedResponse, ActivityFeedResponse,
FeedEntryDto, FeedEntryDto,
UsersResponse, UsersResponse,
@@ -99,6 +104,7 @@ pub struct ApiDoc;
paths( paths(
crate::handlers::api::get_diary, crate::handlers::api::get_diary,
crate::handlers::api::get_review_history, crate::handlers::api::get_review_history,
crate::handlers::api::get_movie_detail,
crate::handlers::api::post_review, crate::handlers::api::post_review,
crate::handlers::api::delete_review, crate::handlers::api::delete_review,
crate::handlers::api::sync_poster, crate::handlers::api::sync_poster,
@@ -134,6 +140,10 @@ pub struct ApiDoc;
LoginResponse, LoginResponse,
RegisterRequest, RegisterRequest,
ReviewHistoryResponse, ReviewHistoryResponse,
MovieDetailResponse,
MovieStatsDto,
SocialFeedResponse,
SocialReviewDto,
ActorListResponse, ActorListResponse,
RemoteActorDto, RemoteActorDto,
FollowRequest, FollowRequest,

View File

@@ -50,6 +50,10 @@ fn html_routes(rate_limit: u64) -> Router<AppState> {
"/users/{id}", "/users/{id}",
routing::get(handlers::html::get_user_profile), routing::get(handlers::html::get_user_profile),
) )
.route(
"/movies/{movie_id}",
routing::get(handlers::html::get_movie_detail),
)
.merge(auth) .merge(auth)
.route( .route(
"/reviews/new", "/reviews/new",
@@ -131,6 +135,10 @@ fn api_routes(rate_limit: u64) -> Router<AppState> {
"/movies/{id}/history", "/movies/{id}/history",
routing::get(handlers::api::get_review_history), routing::get(handlers::api::get_review_history),
) )
.route(
"/movies/{id}",
routing::get(handlers::api::get_movie_detail),
)
.route("/reviews", routing::post(handlers::api::post_review)) .route("/reviews", routing::post(handlers::api::post_review))
.route( .route(
"/reviews/{id}", "/reviews/{id}",

View File

@@ -136,6 +136,16 @@ impl domain::ports::ImportSessionRepository for PanicImportSession {
async fn delete_expired_for_user(&self, _: &UserId) -> Result<(), DomainError> { panic!() } async fn delete_expired_for_user(&self, _: &UserId) -> Result<(), DomainError> { panic!() }
} }
struct PanicDocumentParser;
impl domain::ports::DocumentParser for PanicDocumentParser {
fn parse(&self, _: &[u8], _: domain::models::FileFormat) -> Result<domain::models::ParsedFile, domain::models::ImportError> {
panic!("DocumentParser not wired in tests")
}
fn apply_mapping(&self, _: &domain::models::ParsedFile, _: &[domain::models::FieldMapping]) -> Vec<domain::models::AnnotatedRow> {
panic!("DocumentParser not wired in tests")
}
}
struct PanicImportProfile; struct PanicImportProfile;
#[async_trait] #[async_trait]
impl domain::ports::ImportProfileRepository for PanicImportProfile { impl domain::ports::ImportProfileRepository for PanicImportProfile {
@@ -177,6 +187,7 @@ async fn test_app() -> Router {
review_repository: Arc::clone(&repo) as _, review_repository: Arc::clone(&repo) as _,
diary_repository: Arc::clone(&repo) as _, diary_repository: Arc::clone(&repo) as _,
diary_exporter: Arc::new(PanicExporter), diary_exporter: Arc::new(PanicExporter),
document_parser: Arc::new(PanicDocumentParser),
stats_repository: Arc::clone(&repo) as _, stats_repository: Arc::clone(&repo) as _,
metadata_client: Arc::new(PanicMeta), metadata_client: Arc::new(PanicMeta),
poster_fetcher: Arc::new(PanicFetcher), poster_fetcher: Arc::new(PanicFetcher),
@@ -274,3 +285,35 @@ async fn post_api_auth_login_unknown_user_returns_401() {
assert_eq!(response.status(), StatusCode::UNAUTHORIZED); assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
} }
#[tokio::test]
async fn get_api_movie_detail_returns_404_for_unknown_id() {
let app = test_app().await;
let response = app
.oneshot(with_ip(
Request::builder()
.uri("/api/v1/movies/00000000-0000-0000-0000-000000000000")
.body(Body::empty())
.unwrap(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn get_movie_detail_html_returns_404_for_unknown_id() {
let app = test_app().await;
let response = app
.oneshot(with_ip(
Request::builder()
.uri("/movies/00000000-0000-0000-0000-000000000000")
.body(Body::empty())
.unwrap(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}

View File

@@ -33,6 +33,7 @@ poster-fetcher = { workspace = true }
poster-storage = { workspace = true } poster-storage = { workspace = true }
poster-sync = { workspace = true } poster-sync = { workspace = true }
export = { workspace = true } export = { workspace = true }
importer = { workspace = true }
nats = { workspace = true, optional = true } nats = { workspace = true, optional = true }
sqlx = { workspace = true } sqlx = { workspace = true }

View File

@@ -3,10 +3,10 @@ use std::sync::Arc;
use anyhow::Context; use anyhow::Context;
use application::{config::AppConfig, context::AppContext, worker::WorkerService}; use application::{config::AppConfig, context::AppContext, worker::WorkerService};
use export::ExportAdapter; use export::ExportAdapter;
use importer::ImporterDocumentParser;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use domain::ports::{DiaryExporter, DocumentParser, EventHandler};
use domain::ports::{DiaryExporter, EventHandler};
#[cfg(not(any(feature = "sqlite", feature = "postgres")))] #[cfg(not(any(feature = "sqlite", feature = "postgres")))]
compile_error!("At least one database backend must be enabled. Use --features sqlite or --features postgres"); compile_error!("At least one database backend must be enabled. Use --features sqlite or --features postgres");
@@ -78,6 +78,7 @@ async fn main() -> anyhow::Result<()> {
review_repository, review_repository,
diary_repository, diary_repository,
diary_exporter: Arc::new(ExportAdapter) as Arc<dyn DiaryExporter>, diary_exporter: Arc::new(ExportAdapter) as Arc<dyn DiaryExporter>,
document_parser: Arc::new(ImporterDocumentParser) as Arc<dyn DocumentParser>,
stats_repository, stats_repository,
metadata_client, metadata_client,
poster_fetcher, poster_fetcher,

View File

@@ -985,3 +985,105 @@ form button[type="submit"]:hover {
justify-content: center; justify-content: center;
} }
} }
/* ── Movie detail page ───────────────────────────────────────── */
.stats-bar {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
margin-bottom: 1.25rem;
}
.stat-box {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 6px;
padding: 0.6rem 1rem;
text-align: center;
min-width: 60px;
}
.stat-box.histogram {
text-align: left;
flex: 1;
min-width: 160px;
}
.stats-bar .stat-value {
font-size: 1.25rem;
font-weight: 700;
color: oklch(85.2% 0.199 91.936);
}
.stats-bar .stat-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: rgba(255, 255, 255, 0.45);
margin-bottom: 0.35rem;
}
.histogram-row {
display: flex;
align-items: center;
gap: 0.4rem;
margin-bottom: 3px;
font-size: 0.7rem;
}
.hist-label { color: rgba(255, 255, 255, 0.45); width: 1.8rem; text-align: right; }
.hist-bar-wrap { flex: 1; background: rgba(255, 255, 255, 0.06); border-radius: 2px; height: 6px; }
.hist-bar { background: oklch(85.2% 0.199 91.936); border-radius: 2px; height: 100%; min-width: 1px; }
.hist-count { color: rgba(255, 255, 255, 0.45); width: 1.5rem; }
.feed-section-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: rgba(255, 255, 255, 0.4);
margin-bottom: 0.5rem;
}
.review-card {
border-radius: 6px;
padding: 0.1rem 0;
margin-bottom: 0.2rem;
}
.review-own {
background: rgba(74, 170, 119, 0.06);
border-radius: 6px;
border: 1px solid rgba(74, 170, 119, 0.3);
padding: 0 0.5rem;
}
.review-federated {
border-left: 3px solid #4aaa77;
padding-left: 0.75rem;
}
.btn-small {
display: inline-block;
font-size: 0.75rem;
padding: 0.25rem 0.75rem;
border-radius: 10px;
background: rgba(126, 184, 247, 0.12);
color: #7eb8f7;
text-decoration: none;
border: 1px solid rgba(126, 184, 247, 0.25);
transition: background 0.2s;
}
.btn-small:hover {
background: rgba(126, 184, 247, 0.22);
}
.movie-title-link {
color: inherit;
text-decoration: none;
}
.movie-title-link:hover {
text-decoration: underline;
}