movie detail page + importer architecture fix

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

View File

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

View File

@@ -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<dyn ReviewRepository>,
pub diary_repository: Arc<dyn DiaryRepository>,
pub diary_exporter: Arc<dyn DiaryExporter>,
pub document_parser: Arc<dyn DocumentParser>,
pub stats_repository: Arc<dyn StatsRepository>,
pub metadata_client: Arc<dyn MetadataClient>,
pub poster_fetcher: Arc<dyn PosterFetcherClient>,

View File

@@ -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<String>,
}
pub struct MovieDetailPageData {
pub ctx: HtmlPageContext,
pub movie: Movie,
pub stats: MovieStats,
pub reviews: Paginated<FeedEntry>,
pub current_offset: u32,
pub has_more: bool,
pub limit: u32,
pub histogram_max: u64,
}
pub struct ImportUploadPageData {
pub ctx: HtmlPageContext,
pub profiles: Vec<ImportProfileView>,
@@ -148,6 +159,7 @@ pub trait HtmlRenderer: Send + Sync {
fn render_profile_page(&self, data: ProfilePageData) -> Result<String, String>;
fn render_following_page(&self, data: FollowingPageData) -> Result<String, String>;
fn render_followers_page(&self, data: FollowersPageData) -> Result<String, String>;
fn render_movie_detail_page(&self, data: MovieDetailPageData) -> Result<String, String>;
fn render_import_upload_page(&self, data: ImportUploadPageData) -> Result<String, String>;
fn render_import_mapping_page(&self, data: ImportMappingPageData) -> Result<String, String>;
fn render_import_preview_page(&self, data: ImportPreviewPageData) -> Result<String, String>;

View File

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

View File

@@ -1,8 +1,8 @@
use domain::{
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<bool, DomainError> {
async fn check_duplicate(ctx: &AppContext, row: &domain::models::ImportRow) -> Result<bool, DomainError> {
if let Some(ext_id) = &row.external_metadata_id {
if let Ok(eid) = ExternalMetadataId::new(ext_id.clone()) {
if ctx.movie_repository.get_movie_by_external_id(&eid).await?.is_some() {

View File

@@ -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<u8>, format: FileFormat) -> Result<ParsedFile, ImportError> {
match format {
FileFormat::Csv => importer::parse_csv(&bytes),
FileFormat::Json => importer::parse_json(&bytes),
FileFormat::Xlsx => {
#[cfg(feature = "xlsx")]
{ importer::parse_xlsx(&bytes) }
#[cfg(not(feature = "xlsx"))]
{ Err(ImportError::Xlsx("XLSX support not compiled in".into())) }
}
}
}

View File

@@ -1,6 +1,9 @@
use chrono::NaiveDateTime;
use 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<Impo
.await?
.ok_or_else(|| DomainError::NotFound("import session".into()))?;
let row_results: Vec<AnnotatedRow> = 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<usize> = confirmed_indices.into_iter().collect();
let mut imported = 0;

View File

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

View File

@@ -10,6 +10,7 @@ pub mod save_import_profile;
pub mod export_diary;
pub mod 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;