movie detail page + importer architecture fix
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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())) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
34
crates/application/src/use_cases/get_movie_social_page.rs
Normal file
34
crates/application/src/use_cases/get_movie_social_page.rs
Normal 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 })
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user