From b2a2aa4262b50b180ce49f0ed8ef459822df63fd Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 10 May 2026 23:59:26 +0200 Subject: [PATCH] movie detail page + importer architecture fix --- Cargo.lock | 9 +- crates/adapters/importer/Cargo.toml | 2 +- crates/adapters/importer/src/error.rs | 13 - crates/adapters/importer/src/lib.rs | 36 ++- crates/adapters/importer/src/mapper.rs | 6 +- crates/adapters/importer/src/parsers/csv.rs | 2 +- crates/adapters/importer/src/parsers/json.rs | 2 +- crates/adapters/importer/src/parsers/xlsx.rs | 2 +- crates/adapters/postgres/Cargo.toml | 2 + .../adapters/postgres/src/import_profile.rs | 151 ++++++---- .../adapters/postgres/src/import_session.rs | 277 +++++++++++++++--- crates/adapters/postgres/src/lib.rs | 80 ++++- crates/adapters/postgres/src/models.rs | 29 ++ crates/adapters/sqlite/Cargo.toml | 2 + crates/adapters/sqlite/src/import_profile.rs | 103 +++++-- crates/adapters/sqlite/src/import_session.rs | 253 ++++++++++++++-- crates/adapters/sqlite/src/lib.rs | 170 ++++++++++- crates/adapters/sqlite/src/models.rs | 29 ++ crates/adapters/template-askama/src/lib.rs | 32 +- .../templates/activity_feed.html | 2 +- .../templates/movie_detail.html | 102 +++++++ .../template-askama/templates/profile.html | 4 +- crates/application/Cargo.toml | 4 +- crates/application/src/commands.rs | 9 +- crates/application/src/context.rs | 3 +- crates/application/src/ports.rs | 14 +- crates/application/src/queries.rs | 6 + .../src/use_cases/apply_import_mapping.rs | 23 +- .../src/use_cases/create_import_session.rs | 33 +-- .../src/use_cases/execute_import.rs | 13 +- .../src/use_cases/get_movie_social_page.rs | 34 +++ crates/application/src/use_cases/mod.rs | 1 + .../types.rs => domain/src/models/import.rs} | 38 ++- crates/domain/src/models/import_profile.rs | 15 +- crates/domain/src/models/import_session.rs | 23 +- crates/domain/src/models/mod.rs | 17 ++ crates/domain/src/ports.rs | 14 +- crates/presentation/src/dtos.rs | 38 +++ crates/presentation/src/extractors.rs | 29 ++ crates/presentation/src/handlers/api.rs | 59 +++- crates/presentation/src/handlers/html.rs | 56 +++- crates/presentation/src/handlers/import.rs | 18 +- crates/presentation/src/main.rs | 4 +- crates/presentation/src/openapi.rs | 16 +- crates/presentation/src/routes.rs | 8 + crates/presentation/tests/api_test.rs | 43 +++ crates/worker/Cargo.toml | 1 + crates/worker/src/main.rs | 5 +- static/style.css | 102 +++++++ 49 files changed, 1670 insertions(+), 264 deletions(-) delete mode 100644 crates/adapters/importer/src/error.rs create mode 100644 crates/adapters/template-askama/templates/movie_detail.html create mode 100644 crates/application/src/use_cases/get_movie_social_page.rs rename crates/{adapters/importer/src/types.rs => domain/src/models/import.rs} (60%) diff --git a/Cargo.lock b/Cargo.lock index 3dacc9f..256a7e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -307,8 +307,6 @@ dependencies = [ "chrono", "domain", "futures", - "importer", - "serde_json", "tokio", "tracing", "uuid", @@ -2413,7 +2411,7 @@ version = "0.1.0" dependencies = [ "calamine", "csv", - "serde", + "domain", "serde_json", "thiserror 2.0.18", ] @@ -3458,6 +3456,8 @@ dependencies = [ "async-trait", "chrono", "domain", + "serde", + "serde_json", "sqlx", "tokio", "tracing", @@ -4611,6 +4611,8 @@ dependencies = [ "async-trait", "chrono", "domain", + "serde", + "serde_json", "sqlx", "tokio", "tracing", @@ -6338,6 +6340,7 @@ dependencies = [ "dotenvy", "export", "futures", + "importer", "metadata", "nats", "poster-fetcher", diff --git a/crates/adapters/importer/Cargo.toml b/crates/adapters/importer/Cargo.toml index 2e793b4..1f0c8ba 100644 --- a/crates/adapters/importer/Cargo.toml +++ b/crates/adapters/importer/Cargo.toml @@ -7,7 +7,7 @@ edition = "2024" xlsx = ["dep:calamine"] [dependencies] -serde = { workspace = true, features = ["derive"] } +domain = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } csv = { workspace = true } diff --git a/crates/adapters/importer/src/error.rs b/crates/adapters/importer/src/error.rs deleted file mode 100644 index 31ac915..0000000 --- a/crates/adapters/importer/src/error.rs +++ /dev/null @@ -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, -} diff --git a/crates/adapters/importer/src/lib.rs b/crates/adapters/importer/src/lib.rs index a8a62ee..9e20362 100644 --- a/crates/adapters/importer/src/lib.rs +++ b/crates/adapters/importer/src/lib.rs @@ -1,12 +1,28 @@ -pub mod error; -pub mod mapper; -pub mod parsers; -pub mod types; +mod mapper; +mod parsers; -pub use error::ImportError; -pub use mapper::apply_mapping; -pub use parsers::{parse_csv, parse_json}; -pub use types::{AnnotatedRow, DomainField, FieldMapping, ImportRow, ParsedFile, RowResult, Transform}; +use domain::{ + models::{AnnotatedRow, FieldMapping, FileFormat, ImportError, ParsedFile}, + ports::DocumentParser, +}; -#[cfg(feature = "xlsx")] -pub use parsers::parse_xlsx; +pub struct ImporterDocumentParser; + +impl DocumentParser for ImporterDocumentParser { + fn parse(&self, bytes: &[u8], format: FileFormat) -> Result { + 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 { + mapper::apply_mapping(file, mappings) + } +} diff --git a/crates/adapters/importer/src/mapper.rs b/crates/adapters/importer/src/mapper.rs index 067e0a9..8469b5a 100644 --- a/crates/adapters/importer/src/mapper.rs +++ b/crates/adapters/importer/src/mapper.rs @@ -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 { file.rows.iter().map(|row| { @@ -76,7 +78,7 @@ fn set_field(row: &mut ImportRow, field: &DomainField, value: String) { #[cfg(test)] mod tests { use super::*; - use crate::types::{DomainField, FieldMapping, ParsedFile, RowResult, Transform}; + use domain::models::{DomainField, FieldMapping, ParsedFile, RowResult, Transform}; fn sample_file() -> ParsedFile { ParsedFile { diff --git a/crates/adapters/importer/src/parsers/csv.rs b/crates/adapters/importer/src/parsers/csv.rs index c9d62cf..53f24d0 100644 --- a/crates/adapters/importer/src/parsers/csv.rs +++ b/crates/adapters/importer/src/parsers/csv.rs @@ -1,4 +1,4 @@ -use crate::{ImportError, types::ParsedFile}; +use domain::models::{ImportError, ParsedFile}; pub fn parse_csv(bytes: &[u8]) -> Result { if bytes.is_empty() { diff --git a/crates/adapters/importer/src/parsers/json.rs b/crates/adapters/importer/src/parsers/json.rs index fe5c115..a355a6d 100644 --- a/crates/adapters/importer/src/parsers/json.rs +++ b/crates/adapters/importer/src/parsers/json.rs @@ -1,5 +1,5 @@ +use domain::models::{ImportError, ParsedFile}; use serde_json::Value; -use crate::{ImportError, types::ParsedFile}; pub fn parse_json(bytes: &[u8]) -> Result { let value: Value = serde_json::from_slice(bytes) diff --git a/crates/adapters/importer/src/parsers/xlsx.rs b/crates/adapters/importer/src/parsers/xlsx.rs index 4f10254..a88b706 100644 --- a/crates/adapters/importer/src/parsers/xlsx.rs +++ b/crates/adapters/importer/src/parsers/xlsx.rs @@ -1,6 +1,6 @@ use calamine::{Reader, open_workbook_from_rs, Xlsx, Data}; use std::io::Cursor; -use crate::{ImportError, types::ParsedFile}; +use domain::models::{ImportError, ParsedFile}; pub fn parse_xlsx(bytes: &[u8]) -> Result { let cursor = Cursor::new(bytes); diff --git a/crates/adapters/postgres/Cargo.toml b/crates/adapters/postgres/Cargo.toml index b571287..2928818 100644 --- a/crates/adapters/postgres/Cargo.toml +++ b/crates/adapters/postgres/Cargo.toml @@ -18,3 +18,5 @@ chrono = { workspace = true } tracing = { workspace = true } async-trait = { workspace = true } tokio = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } diff --git a/crates/adapters/postgres/src/import_profile.rs b/crates/adapters/postgres/src/import_profile.rs index 077187f..d506d63 100644 --- a/crates/adapters/postgres/src/import_profile.rs +++ b/crates/adapters/postgres/src/import_profile.rs @@ -2,12 +2,84 @@ use async_trait::async_trait; use chrono::NaiveDateTime; use domain::{ errors::DomainError, - models::ImportProfile, + models::{ + FieldMapping, ImportProfile, + import::{DomainField, Transform}, + }, ports::ImportProfileRepository, value_objects::{ImportProfileId, UserId}, }; +use serde::{Deserialize, Serialize}; 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 { + serde_json::to_string(&ms.iter().map(mapping_to_json).collect::>()) + .map_err(|e| DomainError::InfrastructureError(e.to_string())) +} + +fn deserialize_mappings(s: &str) -> Result, DomainError> { + let js: Vec = serde_json::from_str(s) + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + Ok(js.into_iter().map(mapping_from_json).collect()) +} + pub struct PostgresImportProfileRepository { pool: PgPool, } @@ -26,15 +98,13 @@ impl ImportProfileRepository for PostgresImportProfileRepository { async fn save(&self, p: &ImportProfile) -> Result<(), DomainError> { let id = p.id.value().to_string(); let user_id = p.user_id.value().to_string(); + let field_mappings = serialize_mappings(&p.field_mappings)?; sqlx::query( "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(&user_id) - .bind(&p.name) - .bind(&p.field_mappings) - .bind(p.created_at) + .bind(&id).bind(&user_id).bind(&p.name).bind(&field_mappings).bind(p.created_at) .execute(&self.pool) .await .map(|_| ()) @@ -45,13 +115,7 @@ impl ImportProfileRepository for PostgresImportProfileRepository { let uid = user_id.value().to_string(); #[derive(sqlx::FromRow)] - struct Row { - id: String, - user_id: String, - name: String, - field_mappings: String, - created_at: NaiveDateTime, - } + struct Row { id: String, user_id: String, name: String, field_mappings: String, created_at: NaiveDateTime } let rows = sqlx::query_as::<_, Row>( "SELECT id, user_id, name, field_mappings, created_at FROM import_profiles WHERE user_id = $1 ORDER BY created_at DESC", @@ -61,19 +125,17 @@ impl ImportProfileRepository for PostgresImportProfileRepository { .await .map_err(Self::map_err)?; - rows.into_iter().map(|r| -> Result { - Ok(ImportProfile { - id: ImportProfileId::from_uuid( - r.id.parse::().map_err(|e| DomainError::InfrastructureError(e.to_string()))? - ), - user_id: UserId::from_uuid( - r.user_id.parse::().map_err(|e| DomainError::InfrastructureError(e.to_string()))? - ), - name: r.name, - field_mappings: r.field_mappings, - created_at: r.created_at, - }) - }).collect() + rows.into_iter().map(|r| Ok(ImportProfile { + id: ImportProfileId::from_uuid( + r.id.parse::().map_err(|e| DomainError::InfrastructureError(e.to_string()))? + ), + user_id: UserId::from_uuid( + r.user_id.parse::().map_err(|e| DomainError::InfrastructureError(e.to_string()))? + ), + name: r.name, + field_mappings: deserialize_mappings(&r.field_mappings)?, + created_at: r.created_at, + })).collect() } async fn get(&self, id: &ImportProfileId, user_id: &UserId) -> Result, DomainError> { @@ -81,36 +143,27 @@ impl ImportProfileRepository for PostgresImportProfileRepository { let uid_str = user_id.value().to_string(); #[derive(sqlx::FromRow)] - struct Row { - id: String, - user_id: String, - name: String, - field_mappings: String, - created_at: NaiveDateTime, - } + struct Row { id: String, user_id: String, name: String, field_mappings: String, created_at: NaiveDateTime } let row = sqlx::query_as::<_, Row>( "SELECT id, user_id, name, field_mappings, created_at FROM import_profiles WHERE id = $1 AND user_id = $2", ) - .bind(&id_str) - .bind(&uid_str) + .bind(&id_str).bind(&uid_str) .fetch_optional(&self.pool) .await .map_err(Self::map_err)?; - Ok(row.map(|r| -> Result { - Ok(ImportProfile { - id: ImportProfileId::from_uuid( - r.id.parse::().map_err(|e| DomainError::InfrastructureError(e.to_string()))? - ), - user_id: UserId::from_uuid( - r.user_id.parse::().map_err(|e| DomainError::InfrastructureError(e.to_string()))? - ), - name: r.name, - field_mappings: r.field_mappings, - created_at: r.created_at, - }) - }).transpose()?) + row.map(|r| Ok(ImportProfile { + id: ImportProfileId::from_uuid( + r.id.parse::().map_err(|e| DomainError::InfrastructureError(e.to_string()))? + ), + user_id: UserId::from_uuid( + r.user_id.parse::().map_err(|e| DomainError::InfrastructureError(e.to_string()))? + ), + name: r.name, + field_mappings: deserialize_mappings(&r.field_mappings)?, + created_at: r.created_at, + })).transpose() } async fn delete(&self, id: &ImportProfileId) -> Result<(), DomainError> { diff --git a/crates/adapters/postgres/src/import_session.rs b/crates/adapters/postgres/src/import_session.rs index ea802a5..56fe134 100644 --- a/crates/adapters/postgres/src/import_session.rs +++ b/crates/adapters/postgres/src/import_session.rs @@ -2,12 +2,181 @@ use async_trait::async_trait; use chrono::NaiveDateTime; use domain::{ errors::DomainError, - models::ImportSession, + models::{ + AnnotatedRow, FieldMapping, ImportSession, ParsedFile, + import::{DomainField, ImportRow, RowResult, Transform}, + }, ports::ImportSessionRepository, value_objects::{ImportSessionId, UserId}, }; +use serde::{Deserialize, Serialize}; use sqlx::PgPool; +// ── serde mirror structs ── + +#[derive(Serialize, Deserialize, Default)] +struct ParsedFileJson { + columns: Vec, + rows: Vec>, +} + +#[derive(Serialize, Deserialize)] +enum DomainFieldJson { + Title, ReleaseYear, Director, Rating, WatchedAt, Comment, ExternalMetadataId, +} + +#[derive(Serialize, Deserialize)] +enum TransformJson { + RatingScale(f64), + DateFormat(String), + Identity, +} + +#[derive(Serialize, Deserialize)] +struct FieldMappingJson { + source_column: String, + domain_field: DomainFieldJson, + transform: TransformJson, +} + +#[derive(Serialize, Deserialize, Default)] +struct ImportRowJson { + #[serde(skip_serializing_if = "Option::is_none")] title: Option, + #[serde(skip_serializing_if = "Option::is_none")] release_year: Option, + #[serde(skip_serializing_if = "Option::is_none")] director: Option, + #[serde(skip_serializing_if = "Option::is_none")] rating: Option, + #[serde(skip_serializing_if = "Option::is_none")] watched_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] comment: Option, + #[serde(skip_serializing_if = "Option::is_none")] external_metadata_id: Option, +} + +#[derive(Serialize, Deserialize)] +enum RowResultJson { + Valid(ImportRowJson), + Invalid { errors: Vec, raw: Vec<(String, String)> }, +} + +#[derive(Serialize, Deserialize)] +struct AnnotatedRowJson { + result: RowResultJson, + is_duplicate: bool, +} + +// ── conversion helpers ── + +fn domain_field_to_json(f: &DomainField) -> DomainFieldJson { + match f { + DomainField::Title => DomainFieldJson::Title, + DomainField::ReleaseYear => DomainFieldJson::ReleaseYear, + DomainField::Director => DomainFieldJson::Director, + DomainField::Rating => DomainFieldJson::Rating, + DomainField::WatchedAt => DomainFieldJson::WatchedAt, + DomainField::Comment => DomainFieldJson::Comment, + DomainField::ExternalMetadataId => DomainFieldJson::ExternalMetadataId, + } +} + +fn domain_field_from_json(j: DomainFieldJson) -> DomainField { + match j { + DomainFieldJson::Title => DomainField::Title, + DomainFieldJson::ReleaseYear => DomainField::ReleaseYear, + DomainFieldJson::Director => DomainField::Director, + DomainFieldJson::Rating => DomainField::Rating, + DomainFieldJson::WatchedAt => DomainField::WatchedAt, + DomainFieldJson::Comment => DomainField::Comment, + DomainFieldJson::ExternalMetadataId => DomainField::ExternalMetadataId, + } +} + +fn transform_to_json(t: &Transform) -> TransformJson { + match t { + Transform::RatingScale(f) => TransformJson::RatingScale(*f), + Transform::DateFormat(s) => TransformJson::DateFormat(s.clone()), + Transform::Identity => TransformJson::Identity, + } +} + +fn transform_from_json(j: TransformJson) -> Transform { + match j { + TransformJson::RatingScale(f) => Transform::RatingScale(f), + TransformJson::DateFormat(s) => Transform::DateFormat(s), + TransformJson::Identity => Transform::Identity, + } +} + +fn mapping_to_json(m: &FieldMapping) -> FieldMappingJson { + FieldMappingJson { + source_column: m.source_column.clone(), + domain_field: domain_field_to_json(&m.domain_field), + transform: transform_to_json(&m.transform), + } +} + +fn mapping_from_json(j: FieldMappingJson) -> FieldMapping { + FieldMapping { + source_column: j.source_column, + domain_field: domain_field_from_json(j.domain_field), + transform: transform_from_json(j.transform), + } +} + +fn import_row_to_json(r: &ImportRow) -> ImportRowJson { + ImportRowJson { + title: r.title.clone(), + release_year: r.release_year.clone(), + director: r.director.clone(), + rating: r.rating.clone(), + watched_at: r.watched_at.clone(), + comment: r.comment.clone(), + external_metadata_id: r.external_metadata_id.clone(), + } +} + +fn import_row_from_json(j: ImportRowJson) -> ImportRow { + ImportRow { + title: j.title, + release_year: j.release_year, + director: j.director, + rating: j.rating, + watched_at: j.watched_at, + comment: j.comment, + external_metadata_id: j.external_metadata_id, + } +} + +fn annotated_to_json(a: &AnnotatedRow) -> AnnotatedRowJson { + AnnotatedRowJson { + result: match &a.result { + RowResult::Valid(row) => RowResultJson::Valid(import_row_to_json(row)), + RowResult::Invalid { errors, raw } => RowResultJson::Invalid { + errors: errors.clone(), + raw: raw.clone(), + }, + }, + is_duplicate: a.is_duplicate, + } +} + +fn annotated_from_json(j: AnnotatedRowJson) -> AnnotatedRow { + AnnotatedRow { + result: match j.result { + RowResultJson::Valid(row) => RowResult::Valid(import_row_from_json(row)), + RowResultJson::Invalid { errors, raw } => RowResult::Invalid { errors, raw }, + }, + is_duplicate: j.is_duplicate, + } +} + +fn ser(v: &T) -> Result { + serde_json::to_string(v).map_err(|e| DomainError::InfrastructureError(e.to_string())) +} + +fn de Deserialize<'de>>(s: &str) -> Result { + serde_json::from_str(s).map_err(|e| DomainError::InfrastructureError(e.to_string())) +} + +// ── repository ── + pub struct PostgresImportSessionRepository { pool: PgPool, } @@ -19,6 +188,62 @@ impl PostgresImportSessionRepository { tracing::error!("DB error: {:?}", e); DomainError::InfrastructureError("Database operation failed".into()) } + + fn serialize_session(s: &ImportSession) -> Result<(String, Option, Option), 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::>())) + .transpose()?; + let results = s.row_results.as_ref() + .map(|rs| ser(&rs.iter().map(annotated_to_json).collect::>())) + .transpose()?; + Ok((parsed, mappings, results)) + } + + fn deserialize_session( + id: String, + user_id: String, + parsed_data: String, + field_mappings: Option, + row_results: Option, + created_at: NaiveDateTime, + expires_at: NaiveDateTime, + ) -> Result { + 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, DomainError> { + let js: Vec = de(s)?; + Ok(js.into_iter().map(mapping_from_json).collect()) + }) + .transpose()?; + let row_results = row_results.as_deref() + .map(|s| -> Result, DomainError> { + let js: Vec = de(s)?; + Ok(js.into_iter().map(annotated_from_json).collect()) + }) + .transpose()?; + Ok(ImportSession { + id: ImportSessionId::from_uuid( + id.parse::().map_err(|e| DomainError::InfrastructureError(e.to_string()))? + ), + user_id: UserId::from_uuid( + user_id.parse::().map_err(|e| DomainError::InfrastructureError(e.to_string()))? + ), + parsed_file, + field_mappings, + row_results, + created_at, + expires_at, + }) + } } #[async_trait] @@ -26,17 +251,14 @@ impl ImportSessionRepository for PostgresImportSessionRepository { async fn create(&self, s: &ImportSession) -> Result<(), DomainError> { let id = s.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( "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)", ) - .bind(&id) - .bind(&user_id) - .bind(&s.parsed_data) - .bind(&s.field_mappings) - .bind(&s.row_results) - .bind(s.created_at) - .bind(s.expires_at) + .bind(&id).bind(&user_id).bind(&parsed_data) + .bind(&field_mappings).bind(&row_results) + .bind(s.created_at).bind(s.expires_at) .execute(&self.pool) .await .map(|_| ()) @@ -62,41 +284,26 @@ impl ImportSessionRepository for PostgresImportSessionRepository { "SELECT id, user_id, parsed_data, field_mappings, row_results, created_at, expires_at FROM import_sessions WHERE id = $1 AND user_id = $2", ) - .bind(&id_str) - .bind(&uid_str) + .bind(&id_str).bind(&uid_str) .fetch_optional(&self.pool) .await .map_err(Self::map_err)?; - Ok(row.map(|r| -> Result { - Ok(ImportSession { - id: ImportSessionId::from_uuid( - r.id.parse::().map_err(|e| DomainError::InfrastructureError(e.to_string()))? - ), - user_id: UserId::from_uuid( - r.user_id.parse::().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()?) + row.map(|r| Self::deserialize_session( + r.id, r.user_id, r.parsed_data, r.field_mappings, r.row_results, + r.created_at, r.expires_at, + )).transpose() } async fn update(&self, s: &ImportSession) -> Result<(), DomainError> { let id = s.id.value().to_string(); - sqlx::query( - "UPDATE import_sessions SET field_mappings = $1, row_results = $2 WHERE id = $3", - ) - .bind(&s.field_mappings) - .bind(&s.row_results) - .bind(&id) - .execute(&self.pool) - .await - .map(|_| ()) - .map_err(Self::map_err) + let (_, field_mappings, row_results) = Self::serialize_session(s)?; + sqlx::query("UPDATE import_sessions SET field_mappings = $1, row_results = $2 WHERE id = $3") + .bind(&field_mappings).bind(&row_results).bind(&id) + .execute(&self.pool) + .await + .map(|_| ()) + .map_err(Self::map_err) } async fn delete(&self, id: &ImportSessionId) -> Result<(), DomainError> { diff --git a/crates/adapters/postgres/src/lib.rs b/crates/adapters/postgres/src/lib.rs index 2dad330..e458141 100644 --- a/crates/adapters/postgres/src/lib.rs +++ b/crates/adapters/postgres/src/lib.rs @@ -3,7 +3,7 @@ use domain::{ errors::DomainError, events::DomainEvent, models::{ - DiaryEntry, DiaryFilter, DirectorStat, FeedEntry, MonthlyRating, Movie, Review, + DiaryEntry, DiaryFilter, DirectorStat, FeedEntry, MonthlyRating, Movie, MovieStats, Review, ReviewHistory, ReviewSource, SortDirection, UserStats, UserTrends, collections::{PageParams, Paginated}, }, @@ -18,8 +18,8 @@ mod models; mod users; use models::{ - DiaryRow, DirectorCountRow, FeedRow, MonthlyRatingRow, MovieRow, ReviewRow, UserTotalsRow, - datetime_to_str, + DiaryRow, DirectorCountRow, FeedRow, MonthlyRatingRow, MovieRow, MovieStatsRow, ReviewRow, + UserTotalsRow, datetime_to_str, }; pub use import_profile::PostgresImportProfileRepository; @@ -692,6 +692,80 @@ impl DiaryRepository for PostgresRepository { rows.into_iter().map(DiaryRow::to_domain).collect() } + + async fn get_movie_stats(&self, movie_id: &MovieId) -> Result { + 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, 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::, _>>()?; + + Ok(Paginated { + items, + total_count: total as u64, + limit: page.limit, + offset: page.offset, + }) + } } #[async_trait] diff --git a/crates/adapters/postgres/src/models.rs b/crates/adapters/postgres/src/models.rs index 3fe5b8e..81bf367 100644 --- a/crates/adapters/postgres/src/models.rs +++ b/crates/adapters/postgres/src/models.rs @@ -157,6 +157,35 @@ impl FeedRow { } } +#[derive(sqlx::FromRow)] +pub(crate) struct MovieStatsRow { + pub total_count: i64, + pub avg_rating: Option, + pub federated_count: i64, + pub rating_1: i64, + pub rating_2: i64, + pub rating_3: i64, + pub rating_4: i64, + pub rating_5: i64, +} + +impl MovieStatsRow { + pub fn 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)] pub(crate) struct UserSummaryRow { pub id: String, diff --git a/crates/adapters/sqlite/Cargo.toml b/crates/adapters/sqlite/Cargo.toml index d2d8825..6ea6e2c 100644 --- a/crates/adapters/sqlite/Cargo.toml +++ b/crates/adapters/sqlite/Cargo.toml @@ -12,6 +12,8 @@ sqlx = { version = "0.8.6", features = [ ] } domain = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } anyhow = { workspace = true } uuid = { workspace = true } chrono = { workspace = true } diff --git a/crates/adapters/sqlite/src/import_profile.rs b/crates/adapters/sqlite/src/import_profile.rs index 4cd4451..7ea2d1c 100644 --- a/crates/adapters/sqlite/src/import_profile.rs +++ b/crates/adapters/sqlite/src/import_profile.rs @@ -2,12 +2,84 @@ use async_trait::async_trait; use chrono::NaiveDateTime; use domain::{ errors::DomainError, - models::ImportProfile, + models::{ + FieldMapping, ImportProfile, + import::{DomainField, Transform}, + }, ports::ImportProfileRepository, value_objects::{ImportProfileId, UserId}, }; +use serde::{Deserialize, Serialize}; use sqlx::SqlitePool; +#[derive(Serialize, Deserialize)] +enum DomainFieldJson { + Title, ReleaseYear, Director, Rating, WatchedAt, Comment, ExternalMetadataId, +} + +#[derive(Serialize, Deserialize)] +enum TransformJson { + RatingScale(f64), DateFormat(String), Identity, +} + +#[derive(Serialize, Deserialize)] +struct FieldMappingJson { + source_column: String, + domain_field: DomainFieldJson, + transform: TransformJson, +} + +fn mapping_to_json(m: &FieldMapping) -> FieldMappingJson { + FieldMappingJson { + source_column: m.source_column.clone(), + domain_field: match &m.domain_field { + DomainField::Title => DomainFieldJson::Title, + DomainField::ReleaseYear => DomainFieldJson::ReleaseYear, + DomainField::Director => DomainFieldJson::Director, + DomainField::Rating => DomainFieldJson::Rating, + DomainField::WatchedAt => DomainFieldJson::WatchedAt, + DomainField::Comment => DomainFieldJson::Comment, + DomainField::ExternalMetadataId => DomainFieldJson::ExternalMetadataId, + }, + transform: match &m.transform { + Transform::RatingScale(f) => TransformJson::RatingScale(*f), + Transform::DateFormat(s) => TransformJson::DateFormat(s.clone()), + Transform::Identity => TransformJson::Identity, + }, + } +} + +fn mapping_from_json(j: FieldMappingJson) -> FieldMapping { + FieldMapping { + source_column: j.source_column, + domain_field: match j.domain_field { + DomainFieldJson::Title => DomainField::Title, + DomainFieldJson::ReleaseYear => DomainField::ReleaseYear, + DomainFieldJson::Director => DomainField::Director, + DomainFieldJson::Rating => DomainField::Rating, + DomainFieldJson::WatchedAt => DomainField::WatchedAt, + DomainFieldJson::Comment => DomainField::Comment, + DomainFieldJson::ExternalMetadataId => DomainField::ExternalMetadataId, + }, + transform: match j.transform { + TransformJson::RatingScale(f) => Transform::RatingScale(f), + TransformJson::DateFormat(s) => Transform::DateFormat(s), + TransformJson::Identity => Transform::Identity, + }, + } +} + +fn serialize_mappings(ms: &[FieldMapping]) -> Result { + serde_json::to_string(&ms.iter().map(mapping_to_json).collect::>()) + .map_err(|e| DomainError::InfrastructureError(e.to_string())) +} + +fn deserialize_mappings(s: &str) -> Result, DomainError> { + let js: Vec = serde_json::from_str(s) + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + Ok(js.into_iter().map(mapping_from_json).collect()) +} + pub struct SqliteImportProfileRepository { pool: SqlitePool, } @@ -33,10 +105,11 @@ impl ImportProfileRepository for SqliteImportProfileRepository { let id = p.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 field_mappings = serialize_mappings(&p.field_mappings)?; sqlx::query!( "INSERT OR REPLACE INTO import_profiles (id, user_id, name, field_mappings, created_at) VALUES (?, ?, ?, ?, ?)", - id, user_id, p.name, p.field_mappings, created_at + id, user_id, p.name, field_mappings, created_at ) .execute(&self.pool) .await @@ -54,16 +127,12 @@ impl ImportProfileRepository for SqliteImportProfileRepository { .await .map_err(Self::map_err)?; - rows.into_iter().map(|r| -> Result { + rows.into_iter().map(|r| { Ok(ImportProfile { - id: ImportProfileId::from_uuid( - r.id.parse::().map_err(|e| DomainError::InfrastructureError(e.to_string()))? - ), - user_id: UserId::from_uuid( - r.user_id.parse::().map_err(|e| DomainError::InfrastructureError(e.to_string()))? - ), + id: ImportProfileId::from_uuid(r.id.parse::().map_err(|e| DomainError::InfrastructureError(e.to_string()))?), + user_id: UserId::from_uuid(r.user_id.parse::().map_err(|e| DomainError::InfrastructureError(e.to_string()))?), name: r.name, - field_mappings: r.field_mappings, + field_mappings: deserialize_mappings(&r.field_mappings)?, created_at: Self::parse_dt(&r.created_at)?, }) }).collect() @@ -80,19 +149,15 @@ impl ImportProfileRepository for SqliteImportProfileRepository { .await .map_err(Self::map_err)?; - Ok(row.map(|r| -> Result { + row.map(|r| { Ok(ImportProfile { - id: ImportProfileId::from_uuid( - r.id.parse::().map_err(|e| DomainError::InfrastructureError(e.to_string()))? - ), - user_id: UserId::from_uuid( - r.user_id.parse::().map_err(|e| DomainError::InfrastructureError(e.to_string()))? - ), + id: ImportProfileId::from_uuid(r.id.parse::().map_err(|e| DomainError::InfrastructureError(e.to_string()))?), + user_id: UserId::from_uuid(r.user_id.parse::().map_err(|e| DomainError::InfrastructureError(e.to_string()))?), name: r.name, - field_mappings: r.field_mappings, + field_mappings: deserialize_mappings(&r.field_mappings)?, created_at: Self::parse_dt(&r.created_at)?, }) - }).transpose()?) + }).transpose() } async fn delete(&self, id: &ImportProfileId) -> Result<(), DomainError> { diff --git a/crates/adapters/sqlite/src/import_session.rs b/crates/adapters/sqlite/src/import_session.rs index 010928d..ec353fe 100644 --- a/crates/adapters/sqlite/src/import_session.rs +++ b/crates/adapters/sqlite/src/import_session.rs @@ -2,12 +2,181 @@ use async_trait::async_trait; use chrono::NaiveDateTime; use domain::{ errors::DomainError, - models::ImportSession, + models::{ + AnnotatedRow, FieldMapping, ImportSession, ParsedFile, + import::{DomainField, ImportRow, RowResult, Transform}, + }, ports::ImportSessionRepository, value_objects::{ImportSessionId, UserId}, }; +use serde::{Deserialize, Serialize}; use sqlx::SqlitePool; +// ── serde mirror structs (match the JSON format from the old importer types) ── + +#[derive(Serialize, Deserialize, Default)] +struct ParsedFileJson { + columns: Vec, + rows: Vec>, +} + +#[derive(Serialize, Deserialize)] +enum DomainFieldJson { + Title, ReleaseYear, Director, Rating, WatchedAt, Comment, ExternalMetadataId, +} + +#[derive(Serialize, Deserialize)] +enum TransformJson { + RatingScale(f64), + DateFormat(String), + Identity, +} + +#[derive(Serialize, Deserialize)] +struct FieldMappingJson { + source_column: String, + domain_field: DomainFieldJson, + transform: TransformJson, +} + +#[derive(Serialize, Deserialize, Default)] +struct ImportRowJson { + #[serde(skip_serializing_if = "Option::is_none")] title: Option, + #[serde(skip_serializing_if = "Option::is_none")] release_year: Option, + #[serde(skip_serializing_if = "Option::is_none")] director: Option, + #[serde(skip_serializing_if = "Option::is_none")] rating: Option, + #[serde(skip_serializing_if = "Option::is_none")] watched_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] comment: Option, + #[serde(skip_serializing_if = "Option::is_none")] external_metadata_id: Option, +} + +#[derive(Serialize, Deserialize)] +enum RowResultJson { + Valid(ImportRowJson), + Invalid { errors: Vec, raw: Vec<(String, String)> }, +} + +#[derive(Serialize, Deserialize)] +struct AnnotatedRowJson { + result: RowResultJson, + is_duplicate: bool, +} + +// ── conversion helpers ── + +fn domain_field_to_json(f: &DomainField) -> DomainFieldJson { + match f { + DomainField::Title => DomainFieldJson::Title, + DomainField::ReleaseYear => DomainFieldJson::ReleaseYear, + DomainField::Director => DomainFieldJson::Director, + DomainField::Rating => DomainFieldJson::Rating, + DomainField::WatchedAt => DomainFieldJson::WatchedAt, + DomainField::Comment => DomainFieldJson::Comment, + DomainField::ExternalMetadataId => DomainFieldJson::ExternalMetadataId, + } +} + +fn domain_field_from_json(j: DomainFieldJson) -> DomainField { + match j { + DomainFieldJson::Title => DomainField::Title, + DomainFieldJson::ReleaseYear => DomainField::ReleaseYear, + DomainFieldJson::Director => DomainField::Director, + DomainFieldJson::Rating => DomainField::Rating, + DomainFieldJson::WatchedAt => DomainField::WatchedAt, + DomainFieldJson::Comment => DomainField::Comment, + DomainFieldJson::ExternalMetadataId => DomainField::ExternalMetadataId, + } +} + +fn transform_to_json(t: &Transform) -> TransformJson { + match t { + Transform::RatingScale(f) => TransformJson::RatingScale(*f), + Transform::DateFormat(s) => TransformJson::DateFormat(s.clone()), + Transform::Identity => TransformJson::Identity, + } +} + +fn transform_from_json(j: TransformJson) -> Transform { + match j { + TransformJson::RatingScale(f) => Transform::RatingScale(f), + TransformJson::DateFormat(s) => Transform::DateFormat(s), + TransformJson::Identity => Transform::Identity, + } +} + +fn mapping_to_json(m: &FieldMapping) -> FieldMappingJson { + FieldMappingJson { + source_column: m.source_column.clone(), + domain_field: domain_field_to_json(&m.domain_field), + transform: transform_to_json(&m.transform), + } +} + +fn mapping_from_json(j: FieldMappingJson) -> FieldMapping { + FieldMapping { + source_column: j.source_column, + domain_field: domain_field_from_json(j.domain_field), + transform: transform_from_json(j.transform), + } +} + +fn import_row_to_json(r: &ImportRow) -> ImportRowJson { + ImportRowJson { + title: r.title.clone(), + release_year: r.release_year.clone(), + director: r.director.clone(), + rating: r.rating.clone(), + watched_at: r.watched_at.clone(), + comment: r.comment.clone(), + external_metadata_id: r.external_metadata_id.clone(), + } +} + +fn import_row_from_json(j: ImportRowJson) -> ImportRow { + ImportRow { + title: j.title, + release_year: j.release_year, + director: j.director, + rating: j.rating, + watched_at: j.watched_at, + comment: j.comment, + external_metadata_id: j.external_metadata_id, + } +} + +fn annotated_to_json(a: &AnnotatedRow) -> AnnotatedRowJson { + AnnotatedRowJson { + result: match &a.result { + RowResult::Valid(row) => RowResultJson::Valid(import_row_to_json(row)), + RowResult::Invalid { errors, raw } => RowResultJson::Invalid { + errors: errors.clone(), + raw: raw.clone(), + }, + }, + is_duplicate: a.is_duplicate, + } +} + +fn annotated_from_json(j: AnnotatedRowJson) -> AnnotatedRow { + AnnotatedRow { + result: match j.result { + RowResultJson::Valid(row) => RowResult::Valid(import_row_from_json(row)), + RowResultJson::Invalid { errors, raw } => RowResult::Invalid { errors, raw }, + }, + is_duplicate: j.is_duplicate, + } +} + +fn ser(v: &T) -> Result { + serde_json::to_string(v).map_err(|e| DomainError::InfrastructureError(e.to_string())) +} + +fn de Deserialize<'de>>(s: &str) -> Result { + serde_json::from_str(s).map_err(|e| DomainError::InfrastructureError(e.to_string())) +} + +// ── repository ── + pub struct SqliteImportSessionRepository { pool: SqlitePool, } @@ -25,6 +194,63 @@ impl SqliteImportSessionRepository { .or_else(|_| NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S")) .map_err(|e| DomainError::InfrastructureError(format!("invalid datetime '{}': {}", s, e))) } + + fn serialize_session(s: &ImportSession) -> Result<(String, Option, Option), 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::>())) + .transpose()?; + let results = s.row_results.as_ref() + .map(|rs| ser(&rs.iter().map(annotated_to_json).collect::>())) + .transpose()?; + Ok((parsed, mappings, results)) + } + + fn deserialize_session( + id: String, + user_id: String, + parsed_data: String, + field_mappings: Option, + row_results: Option, + created_at: &str, + expires_at: &str, + ) -> Result { + 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, DomainError> { + let js: Vec = de(s)?; + Ok(js.into_iter().map(mapping_from_json).collect()) + }) + .transpose()?; + let row_results = row_results.as_deref() + .map(|s| -> Result, DomainError> { + let js: Vec = de(s)?; + Ok(js.into_iter().map(annotated_from_json).collect()) + }) + .transpose()?; + + Ok(ImportSession { + id: ImportSessionId::from_uuid( + id.parse::().map_err(|e| DomainError::InfrastructureError(e.to_string()))? + ), + user_id: UserId::from_uuid( + user_id.parse::().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] @@ -34,10 +260,11 @@ impl ImportSessionRepository for SqliteImportSessionRepository { 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 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!( "INSERT INTO import_sessions (id, user_id, parsed_data, field_mappings, row_results, created_at, expires_at) 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) .await @@ -57,28 +284,18 @@ impl ImportSessionRepository for SqliteImportSessionRepository { .await .map_err(Self::map_err)?; - Ok(row.map(|r| -> Result { - Ok(ImportSession { - id: ImportSessionId::from_uuid( - r.id.parse::().map_err(|e| DomainError::InfrastructureError(e.to_string()))? - ), - user_id: UserId::from_uuid( - r.user_id.parse::().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()?) + row.map(|r| Self::deserialize_session( + r.id, r.user_id, r.parsed_data, r.field_mappings, r.row_results, + &r.created_at, &r.expires_at, + )).transpose() } async fn update(&self, s: &ImportSession) -> Result<(), DomainError> { let id = s.id.value().to_string(); + let (_, field_mappings, row_results) = Self::serialize_session(s)?; sqlx::query!( "UPDATE import_sessions SET field_mappings = ?, row_results = ? WHERE id = ?", - s.field_mappings, s.row_results, id + field_mappings, row_results, id ) .execute(&self.pool) .await diff --git a/crates/adapters/sqlite/src/lib.rs b/crates/adapters/sqlite/src/lib.rs index 0f74e22..5bb6b77 100644 --- a/crates/adapters/sqlite/src/lib.rs +++ b/crates/adapters/sqlite/src/lib.rs @@ -3,7 +3,7 @@ use domain::{ errors::DomainError, events::DomainEvent, models::{ - DiaryEntry, DiaryFilter, DirectorStat, FeedEntry, MonthlyRating, Movie, Review, + DiaryEntry, DiaryFilter, DirectorStat, FeedEntry, MonthlyRating, Movie, MovieStats, Review, ReviewHistory, ReviewSource, SortDirection, UserStats, UserTrends, collections::{PageParams, Paginated}, }, @@ -19,8 +19,8 @@ mod models; mod users; use models::{ - DiaryRow, DirectorCountRow, FeedRow, MonthlyRatingRow, MovieRow, ReviewRow, UserTotalsRow, - datetime_to_str, + DiaryRow, DirectorCountRow, FeedRow, MonthlyRatingRow, MovieRow, MovieStatsRow, ReviewRow, + UserTotalsRow, datetime_to_str, }; pub use import_profile::SqliteImportProfileRepository; @@ -680,6 +680,78 @@ impl DiaryRepository for SqliteMovieRepository { rows.into_iter().map(DiaryRow::to_domain).collect() } + + async fn get_movie_stats(&self, movie_id: &MovieId) -> Result { + 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, 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::, _>>()?; + + Ok(Paginated { + items, + total_count: total as u64, + limit: page.limit, + offset: page.offset, + }) + } } #[async_trait] @@ -915,4 +987,96 @@ mod feed_filter_tests { assert!(titles.contains(&"Inception".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 + } } diff --git a/crates/adapters/sqlite/src/models.rs b/crates/adapters/sqlite/src/models.rs index a6fdec6..c545f8f 100644 --- a/crates/adapters/sqlite/src/models.rs +++ b/crates/adapters/sqlite/src/models.rs @@ -118,6 +118,35 @@ impl DiaryRow { } } +#[derive(sqlx::FromRow)] +pub(crate) struct MovieStatsRow { + pub total_count: i64, + pub avg_rating: Option, + pub federated_count: i64, + pub rating_1: i64, + pub rating_2: i64, + pub rating_3: i64, + pub rating_4: i64, + pub rating_5: i64, +} + +impl MovieStatsRow { + pub fn 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 #[derive(sqlx::FromRow)] pub(crate) struct FeedRow { diff --git a/crates/adapters/template-askama/src/lib.rs b/crates/adapters/template-askama/src/lib.rs index 4ea56be..d750c68 100644 --- a/crates/adapters/template-askama/src/lib.rs +++ b/crates/adapters/template-askama/src/lib.rs @@ -1,8 +1,8 @@ use application::ports::{ ActivityFeedPageData, FollowersPageData, FollowingPageData, HtmlPageContext, HtmlRenderer, ImportMappingPageData, ImportPreviewPageData, ImportPreviewRow, ImportProfileView, - ImportRowStatus, ImportUploadPageData, LoginPageData, NewReviewPageData, ProfilePageData, - RegisterPageData, UsersPageData, + ImportRowStatus, ImportUploadPageData, LoginPageData, MovieDetailPageData, NewReviewPageData, + ProfilePageData, RegisterPageData, UsersPageData, }; use askama::Template; use chrono::Datelike; @@ -94,6 +94,19 @@ struct ActivityFeedTemplate<'a> { 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> { pub fn filter_qs(&self) -> String { let mut parts = vec![ @@ -550,6 +563,21 @@ impl HtmlRenderer for AskamaHtmlRenderer { .map_err(|e| e.to_string()) } + fn render_movie_detail_page(&self, data: MovieDetailPageData) -> Result { + 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 { FollowingTemplate { ctx: data.ctx, diff --git a/crates/adapters/template-askama/templates/activity_feed.html b/crates/adapters/template-askama/templates/activity_feed.html index 44b7247..d4c882e 100644 --- a/crates/adapters/template-askama/templates/activity_feed.html +++ b/crates/adapters/template-askama/templates/activity_feed.html @@ -31,7 +31,7 @@ {% endif %}
- {{ entry.movie().title().value() }} + {{ entry.movie().title().value() }} ({{ entry.movie().release_year().value() }})
{% if let Some(dir) = entry.movie().director() %} diff --git a/crates/adapters/template-askama/templates/movie_detail.html b/crates/adapters/template-askama/templates/movie_detail.html new file mode 100644 index 0000000..fcc076f --- /dev/null +++ b/crates/adapters/template-askama/templates/movie_detail.html @@ -0,0 +1,102 @@ +{% extends "base.html" %} +{% block content %} +
+ +
+ {% if let Some(poster) = movie.poster_path() %} +
+ {% endif %} +
+
+ {{ movie.title().value() }} + ({{ movie.release_year().value() }}) +
+ {% if let Some(dir) = movie.director() %} +
{{ dir }}
+ {% endif %} + +
+
+ +
+ {% if let Some(avg) = stats.avg_rating %} +
+
{{ format!("{:.1}", avg) }}★
+
avg rating
+
+ {% endif %} +
+
{{ stats.total_count }}
+
reviews
+
+ {% if stats.federated_count > 0 %} +
+
{{ stats.federated_count }}
+
federated
+
+ {% endif %} +
+
distribution
+
5★
{{ stats.rating_histogram[4] }}
+
4★
{{ stats.rating_histogram[3] }}
+
3★
{{ stats.rating_histogram[2] }}
+
2★
{{ stats.rating_histogram[1] }}
+
1★
{{ stats.rating_histogram[0] }}
+
+
+ + +
+ {% for entry in reviews %} +
+
+
+ {% for filled in entry.review().stars() %} + + {% endfor %} +
+ {% if let Some(comment) = entry.review().comment() %} +
{{ comment.value() }}
+ {% endif %} +
+ {% match entry.review().source() %} + {% when ReviewSource::Remote with { actor_url } %} + {{ entry.user_display_name() }} + {{ entry.review().watched_at().format("%b %-d, %Y") }} + ↗ federated + {% when ReviewSource::Local %} + {% if ctx.is_current_user(entry.review().user_id().value()) %} + you + {% else %} + {{ entry.user_display_name() }} + {% endif %} + {{ entry.review().watched_at().format("%b %-d, %Y") }} + {% endmatch %} +
+ {% if ctx.is_current_user(entry.review().user_id().value()) %} +
+ + + +
+ {% endif %} +
+
+ {% else %} +

No reviews yet.

+ {% endfor %} +
+ + + +
+{% endblock %} diff --git a/crates/adapters/template-askama/templates/profile.html b/crates/adapters/template-askama/templates/profile.html index ed40446..8474e4c 100644 --- a/crates/adapters/template-askama/templates/profile.html +++ b/crates/adapters/template-askama/templates/profile.html @@ -109,7 +109,7 @@
{% endif %}
-
{{ entry.movie().title().value() }} ({{ entry.movie().release_year().value() }})
+
{{ entry.movie().title().value() }} ({{ entry.movie().release_year().value() }})
{% if let Some(dir) = entry.movie().director() %}
{{ dir }}
{% endif %}
{% for filled in entry.review().stars() %} @@ -179,7 +179,7 @@ {% endif %}
- {{ entry.movie().title().value() }} + {{ entry.movie().title().value() }} ({{ entry.movie().release_year().value() }})
{% if let Some(dir) = entry.movie().director() %} diff --git a/crates/application/Cargo.toml b/crates/application/Cargo.toml index 92a85d4..d9fe7aa 100644 --- a/crates/application/Cargo.toml +++ b/crates/application/Cargo.toml @@ -11,11 +11,9 @@ chrono = { workspace = true } tracing = { workspace = true } futures = { workspace = true } tokio = { workspace = true } -importer = { workspace = true } -serde_json = { workspace = true } [features] -xlsx = ["importer/xlsx"] +xlsx = [] [dev-dependencies] tokio = { workspace = true } diff --git a/crates/application/src/commands.rs b/crates/application/src/commands.rs index d23aaf4..440c569 100644 --- a/crates/application/src/commands.rs +++ b/crates/application/src/commands.rs @@ -1,6 +1,5 @@ use chrono::NaiveDateTime; -use domain::models::{ExportFormat, UserRole}; -use importer::FieldMapping; +use domain::models::{ExportFormat, FieldMapping, FileFormat, UserRole}; use uuid::Uuid; pub struct LogReviewCommand { @@ -44,11 +43,7 @@ pub struct ExportCommand { pub format: ExportFormat, } -pub enum FileFormat { - Csv, - Json, - Xlsx, -} +// FileFormat is now in domain::models — no longer defined here pub struct CreateImportSessionCommand { pub user_id: Uuid, diff --git a/crates/application/src/context.rs b/crates/application/src/context.rs index 54ba93f..98f51e0 100644 --- a/crates/application/src/context.rs +++ b/crates/application/src/context.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use domain::ports::{ - AuthService, DiaryExporter, DiaryRepository, EventPublisher, + AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher, ImportProfileRepository, ImportSessionRepository, MetadataClient, MovieRepository, PasswordHasher, PosterFetcherClient, PosterStorage, ReviewRepository, StatsRepository, UserRepository, @@ -15,6 +15,7 @@ pub struct AppContext { pub review_repository: Arc, pub diary_repository: Arc, pub diary_exporter: Arc, + pub document_parser: Arc, pub stats_repository: Arc, pub metadata_client: Arc, pub poster_fetcher: Arc, diff --git a/crates/application/src/ports.rs b/crates/application/src/ports.rs index 010e861..fcda9b0 100644 --- a/crates/application/src/ports.rs +++ b/crates/application/src/ports.rs @@ -1,7 +1,7 @@ use uuid::Uuid; use domain::models::{ - DiaryEntry, FeedEntry, MonthActivity, UserStats, UserSummary, UserTrends, + DiaryEntry, FeedEntry, MonthActivity, Movie, MovieStats, UserStats, UserSummary, UserTrends, collections::Paginated, }; @@ -95,6 +95,17 @@ pub struct FollowersPageData { pub error: Option, } +pub struct MovieDetailPageData { + pub ctx: HtmlPageContext, + pub movie: Movie, + pub stats: MovieStats, + pub reviews: Paginated, + pub current_offset: u32, + pub has_more: bool, + pub limit: u32, + pub histogram_max: u64, +} + pub struct ImportUploadPageData { pub ctx: HtmlPageContext, pub profiles: Vec, @@ -148,6 +159,7 @@ pub trait HtmlRenderer: Send + Sync { fn render_profile_page(&self, data: ProfilePageData) -> Result; fn render_following_page(&self, data: FollowingPageData) -> Result; fn render_followers_page(&self, data: FollowersPageData) -> Result; + fn render_movie_detail_page(&self, data: MovieDetailPageData) -> Result; fn render_import_upload_page(&self, data: ImportUploadPageData) -> Result; fn render_import_mapping_page(&self, data: ImportMappingPageData) -> Result; fn render_import_preview_page(&self, data: ImportPreviewPageData) -> Result; diff --git a/crates/application/src/queries.rs b/crates/application/src/queries.rs index d958161..f22a6b4 100644 --- a/crates/application/src/queries.rs +++ b/crates/application/src/queries.rs @@ -64,3 +64,9 @@ pub struct GetUserProfileQuery { pub sort_by: domain::ports::FeedSortBy, pub search: Option, } + +pub struct GetMovieSocialPageQuery { + pub movie_id: uuid::Uuid, + pub limit: u32, + pub offset: u32, +} diff --git a/crates/application/src/use_cases/apply_import_mapping.rs b/crates/application/src/use_cases/apply_import_mapping.rs index face46f..73321d4 100644 --- a/crates/application/src/use_cases/apply_import_mapping.rs +++ b/crates/application/src/use_cases/apply_import_mapping.rs @@ -1,8 +1,8 @@ use domain::{ errors::DomainError, + models::{AnnotatedRow, import::RowResult}, value_objects::{ExternalMetadataId, ImportSessionId, MovieTitle, ReleaseYear, UserId}, }; -use importer::{AnnotatedRow, ParsedFile, apply_mapping}; use crate::{commands::ApplyImportMappingCommand, context::AppContext}; @@ -15,32 +15,27 @@ pub async fn execute(ctx: &AppContext, cmd: ApplyImportMappingCommand) -> Result .await? .ok_or_else(|| DomainError::NotFound("import session".into()))?; - let parsed: ParsedFile = serde_json::from_str(&session.parsed_data) - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + // clone to avoid borrow conflict when mutating session fields below + 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() { - 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?; } } - session.field_mappings = Some( - serde_json::to_string(&mappings) - .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()))? - ); + session.field_mappings = Some(mappings); + session.row_results = Some(annotated.clone()); ctx.import_session_repository.update(&session).await?; Ok(annotated) } -async fn check_duplicate(ctx: &AppContext, row: &importer::ImportRow) -> Result { +async fn check_duplicate(ctx: &AppContext, row: &domain::models::ImportRow) -> Result { if let Some(ext_id) = &row.external_metadata_id { if let Ok(eid) = ExternalMetadataId::new(ext_id.clone()) { if ctx.movie_repository.get_movie_by_external_id(&eid).await?.is_some() { diff --git a/crates/application/src/use_cases/create_import_session.rs b/crates/application/src/use_cases/create_import_session.rs index 4d2422b..90967ab 100644 --- a/crates/application/src/use_cases/create_import_session.rs +++ b/crates/application/src/use_cases/create_import_session.rs @@ -1,8 +1,11 @@ use chrono::Utc; -use domain::{errors::DomainError, models::ImportSession, value_objects::{ImportSessionId, UserId}}; -use importer::{ImportError, ParsedFile}; +use domain::{ + 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 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); 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 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 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(); + session.parsed_file = Some(parsed); ctx.import_session_repository.create(&session).await?; Ok(CreateSessionResult { session_id, columns, sample_rows }) } - -fn parse(bytes: Vec, format: FileFormat) -> Result { - 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())) } - } - } -} diff --git a/crates/application/src/use_cases/execute_import.rs b/crates/application/src/use_cases/execute_import.rs index 828ac5b..7cd754f 100644 --- a/crates/application/src/use_cases/execute_import.rs +++ b/crates/application/src/use_cases/execute_import.rs @@ -1,6 +1,9 @@ use chrono::NaiveDateTime; -use domain::{errors::DomainError, value_objects::{ImportSessionId, UserId}}; -use importer::{AnnotatedRow, ImportRow, RowResult}; +use domain::{ + errors::DomainError, + models::{ImportRow, import::RowResult}, + value_objects::{ImportSessionId, UserId}, +}; use uuid::Uuid; use crate::{commands::{ExecuteImportCommand, LogReviewCommand}, context::AppContext, use_cases::log_review}; @@ -20,11 +23,7 @@ pub async fn execute(ctx: &AppContext, cmd: ExecuteImportCommand) -> Result = session.row_results - .as_deref() - .and_then(|s| serde_json::from_str(s).ok()) - .unwrap_or_default(); - + let row_results = session.row_results.unwrap_or_default(); let confirmed_set: std::collections::HashSet = confirmed_indices.into_iter().collect(); let mut imported = 0; diff --git a/crates/application/src/use_cases/get_movie_social_page.rs b/crates/application/src/use_cases/get_movie_social_page.rs new file mode 100644 index 0000000..cf15bc0 --- /dev/null +++ b/crates/application/src/use_cases/get_movie_social_page.rs @@ -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, +} + +pub async fn execute( + ctx: &AppContext, + query: GetMovieSocialPageQuery, +) -> Result { + 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 }) +} diff --git a/crates/application/src/use_cases/mod.rs b/crates/application/src/use_cases/mod.rs index a74b3a8..f341419 100644 --- a/crates/application/src/use_cases/mod.rs +++ b/crates/application/src/use_cases/mod.rs @@ -10,6 +10,7 @@ pub mod save_import_profile; pub mod export_diary; pub mod get_activity_feed; pub mod get_diary; +pub mod get_movie_social_page; pub mod get_review_history; pub mod get_user_profile; pub mod get_users; diff --git a/crates/adapters/importer/src/types.rs b/crates/domain/src/models/import.rs similarity index 60% rename from crates/adapters/importer/src/types.rs rename to crates/domain/src/models/import.rs index 620895c..ad5d46b 100644 --- a/crates/adapters/importer/src/types.rs +++ b/crates/domain/src/models/import.rs @@ -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 columns: Vec, pub rows: Vec>, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum DomainField { Title, ReleaseYear, @@ -17,21 +17,21 @@ pub enum DomainField { ExternalMetadataId, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone)] pub enum Transform { RatingScale(f64), DateFormat(String), Identity, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone)] pub struct FieldMapping { pub source_column: String, pub domain_field: DomainField, pub transform: Transform, } -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Default)] pub struct ImportRow { pub title: Option, pub release_year: Option, @@ -42,16 +42,34 @@ pub struct ImportRow { pub external_metadata_id: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone)] pub enum RowResult { Valid(ImportRow), Invalid { errors: Vec, raw: Vec<(String, String)> }, } -/// Wraps a RowResult with a duplicate flag so this information persists when -/// serialised as JSON into the import_sessions.row_results DB column. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone)] pub struct AnnotatedRow { pub result: RowResult, 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, +} diff --git a/crates/domain/src/models/import_profile.rs b/crates/domain/src/models/import_profile.rs index ffbe072..5faa4ed 100644 --- a/crates/domain/src/models/import_profile.rs +++ b/crates/domain/src/models/import_profile.rs @@ -1,17 +1,26 @@ use chrono::NaiveDateTime; -use crate::value_objects::{ImportProfileId, UserId}; +use crate::{ + models::FieldMapping, + value_objects::{ImportProfileId, UserId}, +}; #[derive(Debug, Clone)] pub struct ImportProfile { pub id: ImportProfileId, pub user_id: UserId, pub name: String, - pub field_mappings: String, + pub field_mappings: Vec, pub created_at: NaiveDateTime, } 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, + created_at: NaiveDateTime, + ) -> Self { Self { id, user_id, name, field_mappings, created_at } } } diff --git a/crates/domain/src/models/import_session.rs b/crates/domain/src/models/import_session.rs index fd2063d..68608d1 100644 --- a/crates/domain/src/models/import_session.rs +++ b/crates/domain/src/models/import_session.rs @@ -1,20 +1,31 @@ use chrono::NaiveDateTime; -use crate::value_objects::{ImportSessionId, UserId}; +use crate::{ + models::{AnnotatedRow, FieldMapping, ParsedFile}, + value_objects::{ImportSessionId, UserId}, +}; #[derive(Debug, Clone)] pub struct ImportSession { pub id: ImportSessionId, pub user_id: UserId, - pub parsed_data: String, - pub field_mappings: Option, - pub row_results: Option, + pub parsed_file: Option, + pub field_mappings: Option>, + pub row_results: Option>, pub created_at: NaiveDateTime, pub expires_at: NaiveDateTime, } 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); - 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, + } } } diff --git a/crates/domain/src/models/mod.rs b/crates/domain/src/models/mod.rs index 9c26752..f80b6f4 100644 --- a/crates/domain/src/models/mod.rs +++ b/crates/domain/src/models/mod.rs @@ -9,9 +9,14 @@ use crate::{ }, }; pub mod collections; +pub mod import; pub mod import_session; pub mod import_profile; +pub use import::{ + AnnotatedRow, DomainField, FieldMapping, FileFormat, ImportError, + ImportRow, ParsedFile, RowResult, Transform, +}; pub use import_session::ImportSession; pub use import_profile::ImportProfile; @@ -216,6 +221,10 @@ impl Review { let r = self.rating.value(); [r >= 1, r >= 2, r >= 3, r >= 4, r >= 5] } + + pub fn is_remote(&self) -> bool { + matches!(self.source, ReviewSource::Remote { .. }) + } } #[derive(Clone, Debug)] @@ -259,6 +268,14 @@ impl ReviewHistory { } } +#[derive(Clone, Debug)] +pub struct MovieStats { + pub total_count: u64, + pub avg_rating: Option, + pub federated_count: u64, + pub rating_histogram: [u64; 5], // index 0 = 1★, index 4 = 5★ +} + #[derive(Clone, Debug, Default)] pub enum UserRole { #[default] diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 37061f7..e4fa41f 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -5,7 +5,8 @@ use crate::{ errors::DomainError, events::{DomainEvent, EventEnvelope}, 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, collections::{PageParams, Paginated}, }, @@ -15,6 +16,11 @@ use crate::{ }, }; +pub trait DocumentParser: Send + Sync { + fn parse(&self, bytes: &[u8], format: FileFormat) -> Result; + fn apply_mapping(&self, file: &ParsedFile, mappings: &[FieldMapping]) -> Vec; +} + #[derive(Debug, Clone, Default, PartialEq)] pub enum FeedSortBy { #[default] @@ -104,6 +110,12 @@ pub trait DiaryRepository: Send + Sync { ) -> Result, DomainError>; async fn get_review_history(&self, movie_id: &MovieId) -> Result; async fn get_user_history(&self, user_id: &UserId) -> Result, DomainError>; + async fn get_movie_stats(&self, movie_id: &MovieId) -> Result; + async fn get_movie_social_feed( + &self, + movie_id: &MovieId, + page: &PageParams, + ) -> Result, DomainError>; } #[async_trait] diff --git a/crates/presentation/src/dtos.rs b/crates/presentation/src/dtos.rs index f4a3f88..dee9d61 100644 --- a/crates/presentation/src/dtos.rs +++ b/crates/presentation/src/dtos.rs @@ -427,6 +427,44 @@ fn default_export_format() -> String { "csv".to_string() } +#[derive(serde::Deserialize, Default)] +pub struct PaginationQueryParams { + pub limit: Option, + pub offset: Option, +} + +#[derive(serde::Serialize, utoipa::ToSchema)] +pub struct MovieStatsDto { + pub total_count: u64, + pub avg_rating: Option, + 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, + pub watched_at: String, + pub is_federated: bool, +} + +#[derive(serde::Serialize, utoipa::ToSchema)] +pub struct SocialFeedResponse { + pub items: Vec, + 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)] mod tests { use super::*; diff --git a/crates/presentation/src/extractors.rs b/crates/presentation/src/extractors.rs index fd02414..8f3ed46 100644 --- a/crates/presentation/src/extractors.rs +++ b/crates/presentation/src/extractors.rs @@ -219,6 +219,19 @@ mod tests { async fn get_user_history(&self, _: &UserId) -> Result, DomainError> { panic!() } + async fn get_movie_stats( + &self, + _: &MovieId, + ) -> Result { + panic!() + } + async fn get_movie_social_feed( + &self, + _: &MovieId, + _: &PageParams, + ) -> Result, DomainError> { + panic!() + } } #[cfg(feature = "federation")] #[async_trait::async_trait] @@ -352,6 +365,15 @@ mod tests { } } + impl domain::ports::DocumentParser for Panic { + fn parse(&self, _: &[u8], _: domain::models::FileFormat) -> Result { + panic!() + } + fn apply_mapping(&self, _: &domain::models::ParsedFile, _: &[domain::models::FieldMapping]) -> Vec { + panic!() + } + } + impl crate::ports::HtmlRenderer for Panic { fn render_diary_page( &self, @@ -408,6 +430,12 @@ mod tests { ) -> Result { panic!() } + fn render_movie_detail_page( + &self, + _: application::ports::MovieDetailPageData, + ) -> Result { + panic!() + } fn render_import_upload_page(&self, _: application::ports::ImportUploadPageData) -> Result { panic!() } fn render_import_mapping_page(&self, _: application::ports::ImportMappingPageData) -> Result { panic!() } fn render_import_preview_page(&self, _: application::ports::ImportPreviewPageData) -> Result { panic!() } @@ -439,6 +467,7 @@ mod tests { review_repository: Arc::clone(&repo) as _, diary_repository: Arc::clone(&repo) as _, diary_exporter: Arc::clone(&repo) as _, + document_parser: Arc::clone(&repo) as _, stats_repository: Arc::clone(&repo) as _, metadata_client: Arc::clone(&repo) as _, poster_fetcher: Arc::clone(&repo) as _, diff --git a/crates/presentation/src/handlers/api.rs b/crates/presentation/src/handlers/api.rs index 65df129..6ec7159 100644 --- a/crates/presentation/src/handlers/api.rs +++ b/crates/presentation/src/handlers/api.rs @@ -13,12 +13,14 @@ use application::{ DeleteReviewCommand, ExportCommand, LoginCommand, RegisterCommand, SyncPosterCommand, }, queries::{ - GetActivityFeedQuery, GetReviewHistoryQuery, GetUserProfileQuery, GetUsersQuery, + GetActivityFeedQuery, GetMovieSocialPageQuery, GetReviewHistoryQuery, GetUserProfileQuery, + GetUsersQuery, }, use_cases::{ 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, - log_review, login as login_uc, register as register_uc, sync_poster, + get_diary, get_movie_social_page, get_review_history, + get_user_profile as get_user_profile_uc, get_users, log_review, login as login_uc, + register as register_uc, sync_poster, }, }; use domain::{ @@ -35,8 +37,10 @@ use crate::{ ActivityFeedQueryParams, ActivityFeedResponse, DiaryEntryDto, DiaryQueryParams, DiaryResponse, DirectorStatDto, ExportQueryParams, FeedEntryDto, LogReviewData, LogReviewRequest, LoginRequest, LoginResponse, MonthActivityDto, MonthlyRatingDto, - MovieDto, RegisterRequest, ReviewDto, ReviewHistoryResponse, UserProfileQueryParams, - UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto, UsersResponse, + MovieDetailResponse, MovieDto, MovieStatsDto, PaginationQueryParams, RegisterRequest, + ReviewDto, ReviewHistoryResponse, SocialFeedResponse, SocialReviewDto, + UserProfileQueryParams, UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto, + UsersResponse, }, errors::ApiError, 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, + Path(movie_id): Path, + Query(params): Query, +) -> Result, 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 { MovieDto { id: movie.id().value(), diff --git a/crates/presentation/src/handlers/html.rs b/crates/presentation/src/handlers/html.rs index 7280f4f..ff2cf73 100644 --- a/crates/presentation/src/handlers/html.rs +++ b/crates/presentation/src/handlers/html.rs @@ -14,11 +14,13 @@ use application::ports::{FollowersPageData, FollowingPageData}; use application::{ commands::{DeleteReviewCommand, ExportCommand, LoginCommand, RegisterCommand}, ports::{ - HtmlPageContext, LoginPageData, NewReviewPageData, RegisterPageData, RemoteActorView, + HtmlPageContext, LoginPageData, MovieDetailPageData, NewReviewPageData, RegisterPageData, + RemoteActorView, }, + queries::GetMovieSocialPageQuery, use_cases::{ - delete_review, export_diary as export_diary_uc, log_review, login as login_uc, - register as register_uc, + delete_review, export_diary as export_diary_uc, get_movie_social_page, log_review, + login as login_uc, register as register_uc, }, }; 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, + Path(movie_id): Path, + Query(params): Query, + Extension(csrf): Extension, +) -> 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() + } + } + } + } +} diff --git a/crates/presentation/src/handlers/import.rs b/crates/presentation/src/handlers/import.rs index db6672d..0b429e1 100644 --- a/crates/presentation/src/handlers/import.rs +++ b/crates/presentation/src/handlers/import.rs @@ -10,7 +10,7 @@ use std::collections::HashMap; use application::{ commands::{ ApplyImportMappingCommand, CreateImportSessionCommand, DeleteImportProfileCommand, - ExecuteImportCommand, FileFormat, SaveImportProfileCommand, + ExecuteImportCommand, SaveImportProfileCommand, }, ports::{ ImportMappingPageData, ImportPreviewPageData, ImportPreviewRow, ImportProfileView, @@ -21,8 +21,8 @@ use application::{ list_import_profiles, save_import_profile, }, }; +use domain::models::{AnnotatedRow, FieldMapping, FileFormat, import::{DomainField, RowResult, Transform}}; use domain::value_objects::ImportSessionId; -use importer::{AnnotatedRow, DomainField, FieldMapping, RowResult, Transform}; use crate::{ csrf::CsrfToken, @@ -220,7 +220,7 @@ pub async fn get_mapping_page( else { return Redirect::to("/import").into_response(); }; - let Ok(parsed) = serde_json::from_str::(&session.parsed_data) else { + let Some(parsed) = session.parsed_file else { 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(); } - let parsed = - serde_json::from_str::(&session.parsed_data).unwrap_or_default(); - let annotated: Vec = session - .row_results - .as_deref() - .and_then(|s| serde_json::from_str(s).ok()) - .unwrap_or_default(); + let parsed = session.parsed_file.unwrap_or_default(); + let annotated: Vec = session.row_results.unwrap_or_default(); let rows: Vec = annotated .iter() @@ -589,8 +584,7 @@ pub async fn api_get_session( .await { Ok(Some(session)) => { - let parsed = serde_json::from_str::(&session.parsed_data) - .unwrap_or_default(); + let parsed = session.parsed_file.unwrap_or_default(); let row_count = parsed.rows.len(); axum::Json(SessionStateResponse { session_id: session_id_str, diff --git a/crates/presentation/src/main.rs b/crates/presentation/src/main.rs index 26d0dd9..b476f77 100644 --- a/crates/presentation/src/main.rs +++ b/crates/presentation/src/main.rs @@ -7,6 +7,7 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use application::{config::AppConfig, context::AppContext}; use export::ExportAdapter; +use importer::ImporterDocumentParser; use rss::RssAdapter; use template_askama::AskamaHtmlRenderer; @@ -14,7 +15,7 @@ use doc::ApiDocExt; use presentation::{openapi::ApiDoc, routes, state::AppState}; 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")))] 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, diary_repository, diary_exporter: Arc::new(ExportAdapter) as Arc, + document_parser: Arc::new(ImporterDocumentParser) as Arc, stats_repository, metadata_client, poster_fetcher, diff --git a/crates/presentation/src/openapi.rs b/crates/presentation/src/openapi.rs index 450f0e7..5937c91 100644 --- a/crates/presentation/src/openapi.rs +++ b/crates/presentation/src/openapi.rs @@ -6,9 +6,9 @@ use utoipa::{ use crate::dtos::{ ActivityFeedResponse, DiaryEntryDto, DiaryResponse, DirectorStatDto, FeedEntryDto, LoginRequest, LoginResponse, LogReviewRequest, - MonthActivityDto, MonthlyRatingDto, MovieDto, RegisterRequest, ReviewDto, - ReviewHistoryResponse, UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto, - UsersResponse, + MonthActivityDto, MonthlyRatingDto, MovieDetailResponse, MovieDto, MovieStatsDto, + RegisterRequest, ReviewDto, ReviewHistoryResponse, SocialFeedResponse, SocialReviewDto, + UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto, UsersResponse, }; use crate::handlers::import::{ ApiFieldMapping, ApplyMappingRequest, ConfirmRequest, SaveProfileRequest, @@ -40,6 +40,7 @@ impl Modify for SecurityAddon { paths( crate::handlers::api::get_diary, crate::handlers::api::get_review_history, + crate::handlers::api::get_movie_detail, crate::handlers::api::post_review, crate::handlers::api::delete_review, crate::handlers::api::sync_poster, @@ -67,6 +68,10 @@ impl Modify for SecurityAddon { LoginResponse, RegisterRequest, ReviewHistoryResponse, + MovieDetailResponse, + MovieStatsDto, + SocialFeedResponse, + SocialReviewDto, ActivityFeedResponse, FeedEntryDto, UsersResponse, @@ -99,6 +104,7 @@ pub struct ApiDoc; paths( crate::handlers::api::get_diary, crate::handlers::api::get_review_history, + crate::handlers::api::get_movie_detail, crate::handlers::api::post_review, crate::handlers::api::delete_review, crate::handlers::api::sync_poster, @@ -134,6 +140,10 @@ pub struct ApiDoc; LoginResponse, RegisterRequest, ReviewHistoryResponse, + MovieDetailResponse, + MovieStatsDto, + SocialFeedResponse, + SocialReviewDto, ActorListResponse, RemoteActorDto, FollowRequest, diff --git a/crates/presentation/src/routes.rs b/crates/presentation/src/routes.rs index 85cdaa4..68c317e 100644 --- a/crates/presentation/src/routes.rs +++ b/crates/presentation/src/routes.rs @@ -50,6 +50,10 @@ fn html_routes(rate_limit: u64) -> Router { "/users/{id}", routing::get(handlers::html::get_user_profile), ) + .route( + "/movies/{movie_id}", + routing::get(handlers::html::get_movie_detail), + ) .merge(auth) .route( "/reviews/new", @@ -131,6 +135,10 @@ fn api_routes(rate_limit: u64) -> Router { "/movies/{id}/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/{id}", diff --git a/crates/presentation/tests/api_test.rs b/crates/presentation/tests/api_test.rs index d3a65a6..82509a0 100644 --- a/crates/presentation/tests/api_test.rs +++ b/crates/presentation/tests/api_test.rs @@ -136,6 +136,16 @@ impl domain::ports::ImportSessionRepository for PanicImportSession { 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 { + panic!("DocumentParser not wired in tests") + } + fn apply_mapping(&self, _: &domain::models::ParsedFile, _: &[domain::models::FieldMapping]) -> Vec { + panic!("DocumentParser not wired in tests") + } +} + struct PanicImportProfile; #[async_trait] impl domain::ports::ImportProfileRepository for PanicImportProfile { @@ -177,6 +187,7 @@ async fn test_app() -> Router { review_repository: Arc::clone(&repo) as _, diary_repository: Arc::clone(&repo) as _, diary_exporter: Arc::new(PanicExporter), + document_parser: Arc::new(PanicDocumentParser), stats_repository: Arc::clone(&repo) as _, metadata_client: Arc::new(PanicMeta), 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); } + +#[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); +} diff --git a/crates/worker/Cargo.toml b/crates/worker/Cargo.toml index feefa81..92c3f29 100644 --- a/crates/worker/Cargo.toml +++ b/crates/worker/Cargo.toml @@ -33,6 +33,7 @@ poster-fetcher = { workspace = true } poster-storage = { workspace = true } poster-sync = { workspace = true } export = { workspace = true } +importer = { workspace = true } nats = { workspace = true, optional = true } sqlx = { workspace = true } diff --git a/crates/worker/src/main.rs b/crates/worker/src/main.rs index ba055b7..2c2d50c 100644 --- a/crates/worker/src/main.rs +++ b/crates/worker/src/main.rs @@ -3,10 +3,10 @@ use std::sync::Arc; use anyhow::Context; use application::{config::AppConfig, context::AppContext, worker::WorkerService}; use export::ExportAdapter; +use importer::ImporterDocumentParser; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; - -use domain::ports::{DiaryExporter, EventHandler}; +use domain::ports::{DiaryExporter, DocumentParser, EventHandler}; #[cfg(not(any(feature = "sqlite", feature = "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, diary_repository, diary_exporter: Arc::new(ExportAdapter) as Arc, + document_parser: Arc::new(ImporterDocumentParser) as Arc, stats_repository, metadata_client, poster_fetcher, diff --git a/static/style.css b/static/style.css index c3673b6..c93ed9b 100644 --- a/static/style.css +++ b/static/style.css @@ -985,3 +985,105 @@ form button[type="submit"]:hover { 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; +}