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

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

View File

@@ -1,16 +1,14 @@
use std::sync::Arc;
use domain::ports::{
AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher,
ImageStorage,
ImportProfileRepository, ImportSessionRepository,
MetadataClient, MovieProfileRepository, MovieRepository, PasswordHasher, PosterFetcherClient,
PersonCommand, PersonQuery, SearchCommand, SearchPort,
ReviewRepository, StatsRepository, UserProfileFieldsRepository, UserRepository,
WatchlistRepository,
};
#[cfg(feature = "federation")]
use domain::ports::RemoteWatchlistRepository;
use domain::ports::{
AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher, ImageStorage,
ImportProfileRepository, ImportSessionRepository, MetadataClient, MovieProfileRepository,
MovieRepository, PasswordHasher, PersonCommand, PersonQuery, PosterFetcherClient,
ReviewRepository, SearchCommand, SearchPort, StatsRepository, UserProfileFieldsRepository,
UserRepository, WatchlistRepository,
};
use crate::config::AppConfig;

View File

@@ -51,10 +51,12 @@ impl PeriodicJob for EnrichmentStalenessJob {
}
tracing::info!("enrichment scan: {} stale movies", stale.len());
for (movie_id, external_metadata_id) in stale {
let event = DomainEvent::MovieEnrichmentRequested { movie_id, external_metadata_id };
let event = DomainEvent::MovieEnrichmentRequested {
movie_id,
external_metadata_id,
};
self.ctx.event_publisher.publish(&event).await?;
}
Ok(())
}
}

View File

@@ -1,14 +1,14 @@
pub mod commands;
pub mod jobs;
pub mod worker;
pub mod config;
pub mod context;
pub mod jobs;
pub mod movie_discovery_indexer;
pub mod movie_resolver;
pub mod ports;
pub mod queries;
pub mod use_cases;
pub mod movie_discovery_indexer;
pub mod search_cleanup;
pub mod use_cases;
pub mod worker;
pub use movie_discovery_indexer::MovieDiscoveryIndexer;
pub use search_cleanup::SearchCleanupHandler;

View File

@@ -13,12 +13,18 @@ use domain::{
/// Enrichment will later overwrite this with the full document (cast, genres, etc.).
pub struct MovieDiscoveryIndexer {
movie_repository: Arc<dyn MovieRepository>,
search_command: Arc<dyn SearchCommand>,
search_command: Arc<dyn SearchCommand>,
}
impl MovieDiscoveryIndexer {
pub fn new(movie_repository: Arc<dyn MovieRepository>, search_command: Arc<dyn SearchCommand>) -> Self {
Self { movie_repository, search_command }
pub fn new(
movie_repository: Arc<dyn MovieRepository>,
search_command: Arc<dyn SearchCommand>,
) -> Self {
Self {
movie_repository,
search_command,
}
}
}
@@ -35,7 +41,8 @@ impl EventHandler for MovieDiscoveryIndexer {
return Ok(());
};
if let Err(e) = self.search_command
if let Err(e) = self
.search_command
.index(IndexableDocument::Movie {
id: movie_id.clone(),
movie: Box::new(movie),

View File

@@ -49,9 +49,10 @@ impl MovieResolver {
) -> Result<(Movie, bool), DomainError> {
for strategy in &self.strategies {
if strategy.can_handle(input)
&& let Some(result) = strategy.resolve(input, deps).await? {
return Ok(result);
}
&& let Some(result) = strategy.resolve(input, deps).await?
{
return Ok(result);
}
}
Err(DomainError::ValidationError(
"Manual title required if TMDB fetch fails or is omitted".into(),
@@ -108,13 +109,17 @@ impl ResolutionStrategy for TitleSearchStrategy {
let title = input.manual_title.as_deref().unwrap();
let criteria = MetadataSearchCriteria::Title {
title: MovieTitle::new(title.to_string())?,
year: input.manual_release_year.map(ReleaseYear::new).transpose()?,
year: input
.manual_release_year
.map(ReleaseYear::new)
.transpose()?,
};
match deps.metadata_client.fetch_movie_metadata(&criteria).await {
Ok(m) => {
// Movie may already exist in DB under this external_metadata_id
if let Some(ext_id) = m.external_metadata_id() {
if let Some(existing) = deps.repository.get_movie_by_external_id(ext_id).await? {
if let Some(existing) = deps.repository.get_movie_by_external_id(ext_id).await?
{
return Ok(Some((existing, false)));
}
}
@@ -164,8 +169,13 @@ impl ResolutionStrategy for ManualMovieStrategy {
if let Some(existing) = matched {
Ok(Some((existing, false)))
} else {
let new_movie =
Movie::new(None, title, release_year, input.manual_director.clone(), None);
let new_movie = Movie::new(
None,
title,
release_year,
input.manual_director.clone(),
None,
);
Ok(Some((new_movie, true)))
}
}

View File

@@ -224,10 +224,8 @@ pub trait HtmlRenderer: Send + Sync {
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>;
fn render_profile_settings_page(
&self,
data: ProfileSettingsPageData,
) -> Result<String, String>;
fn render_profile_settings_page(&self, data: ProfileSettingsPageData)
-> Result<String, String>;
fn render_blocked_domains_page(&self, data: BlockedDomainsPageData) -> Result<String, String>;
fn render_blocked_actors_page(&self, data: BlockedActorsPageData) -> Result<String, String>;
fn render_watchlist_page(&self, data: WatchlistPageData) -> Result<String, String>;

View File

@@ -10,12 +10,15 @@ use domain::{
pub struct SearchCleanupHandler {
search_command: Arc<dyn SearchCommand>,
person_query: Arc<dyn PersonQuery>,
person_query: Arc<dyn PersonQuery>,
}
impl SearchCleanupHandler {
pub fn new(search_command: Arc<dyn SearchCommand>, person_query: Arc<dyn PersonQuery>) -> Self {
Self { search_command, person_query }
Self {
search_command,
person_query,
}
}
}
@@ -27,7 +30,11 @@ impl EventHandler for SearchCleanupHandler {
_ => return Ok(()),
};
if let Err(e) = self.search_command.remove(EntityType::Movie, &movie_id).await {
if let Err(e) = self
.search_command
.remove(EntityType::Movie, &movie_id)
.await
{
tracing::warn!("search cleanup failed for movie {movie_id}: {e}");
}
@@ -41,7 +48,9 @@ impl EventHandler for SearchCleanupHandler {
}
}
}
Err(e) => tracing::warn!("failed to list orphaned persons after movie {movie_id} deletion: {e}"),
Err(e) => tracing::warn!(
"failed to list orphaned persons after movie {movie_id} deletion: {e}"
),
}
Ok(())

View File

@@ -52,8 +52,17 @@ impl MovieRepository for RepoWithExternalMovie {
async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> {
panic!("unexpected")
}
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { panic!("unexpected") }
async fn list_movies(&self, _: &domain::models::collections::PageParams, _: &domain::models::MovieFilter) -> Result<domain::models::collections::Paginated<domain::models::MovieSummary>, DomainError> { panic!("unexpected") }
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> {
panic!("unexpected")
}
async fn list_movies(
&self,
_: &domain::models::collections::PageParams,
_: &domain::models::MovieFilter,
) -> Result<domain::models::collections::Paginated<domain::models::MovieSummary>, DomainError>
{
panic!("unexpected")
}
}
#[async_trait::async_trait]
@@ -74,9 +83,20 @@ impl MovieRepository for RepoEmpty {
) -> Result<Vec<Movie>, DomainError> {
Ok(vec![])
}
async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { panic!("unexpected") }
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { panic!("unexpected") }
async fn list_movies(&self, _: &domain::models::collections::PageParams, _: &domain::models::MovieFilter) -> Result<domain::models::collections::Paginated<domain::models::MovieSummary>, DomainError> { panic!("unexpected") }
async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> {
panic!("unexpected")
}
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> {
panic!("unexpected")
}
async fn list_movies(
&self,
_: &domain::models::collections::PageParams,
_: &domain::models::MovieFilter,
) -> Result<domain::models::collections::Paginated<domain::models::MovieSummary>, DomainError>
{
panic!("unexpected")
}
}
#[async_trait::async_trait]
@@ -97,9 +117,20 @@ impl MovieRepository for RepoWithTitleMatch {
) -> Result<Vec<Movie>, DomainError> {
Ok(vec![self.0.clone()])
}
async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { panic!("unexpected") }
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { panic!("unexpected") }
async fn list_movies(&self, _: &domain::models::collections::PageParams, _: &domain::models::MovieFilter) -> Result<domain::models::collections::Paginated<domain::models::MovieSummary>, DomainError> { panic!("unexpected") }
async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> {
panic!("unexpected")
}
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> {
panic!("unexpected")
}
async fn list_movies(
&self,
_: &domain::models::collections::PageParams,
_: &domain::models::MovieFilter,
) -> Result<domain::models::collections::Paginated<domain::models::MovieSummary>, DomainError>
{
panic!("unexpected")
}
}
struct MetaReturnsMovie(Movie);
@@ -107,10 +138,7 @@ struct MetaErrors;
#[async_trait::async_trait]
impl MetadataClient for MetaReturnsMovie {
async fn fetch_movie_metadata(
&self,
_: &MetadataSearchCriteria,
) -> Result<Movie, DomainError> {
async fn fetch_movie_metadata(&self, _: &MetadataSearchCriteria) -> Result<Movie, DomainError> {
Ok(self.0.clone())
}
async fn get_poster_url(
@@ -123,10 +151,7 @@ impl MetadataClient for MetaReturnsMovie {
#[async_trait::async_trait]
impl MetadataClient for MetaErrors {
async fn fetch_movie_metadata(
&self,
_: &MetadataSearchCriteria,
) -> Result<Movie, DomainError> {
async fn fetch_movie_metadata(&self, _: &MetadataSearchCriteria) -> Result<Movie, DomainError> {
Err(DomainError::InfrastructureError(
"metadata unavailable".into(),
))
@@ -299,7 +324,9 @@ async fn resolver_returns_error_when_no_strategy_matches() {
metadata_client: &meta,
};
let input = make_input(None, None, None);
let result = MovieResolver::default_pipeline().resolve(&input, &deps).await;
let result = MovieResolver::default_pipeline()
.resolve(&input, &deps)
.await;
assert!(result.is_err());
}

View File

@@ -31,10 +31,13 @@ pub async fn execute(ctx: &AppContext, cmd: AddToWatchlistCommand) -> Result<(),
if is_new {
ctx.movie_repository.upsert_movie(&movie).await?;
if let Some(ext_id) = movie.external_metadata_id() {
let _ = ctx.event_publisher.publish(&DomainEvent::MovieDiscovered {
movie_id: movie.id().clone(),
external_metadata_id: ext_id.clone(),
}).await;
let _ = ctx
.event_publisher
.publish(&DomainEvent::MovieDiscovered {
movie_id: movie.id().clone(),
external_metadata_id: ext_id.clone(),
})
.await;
}
}
movie
@@ -43,14 +46,17 @@ pub async fn execute(ctx: &AppContext, cmd: AddToWatchlistCommand) -> Result<(),
let entry = WatchlistEntry::new(user_id.clone(), movie.id().clone());
ctx.watchlist_repository.add(&entry).await?;
let _ = ctx.event_publisher.publish(&DomainEvent::WatchlistEntryAdded {
user_id,
movie_id: movie.id().clone(),
movie_title: movie.title().value().to_string(),
release_year: movie.release_year().value(),
external_metadata_id: movie.external_metadata_id().map(|e| e.value().to_string()),
added_at: entry.added_at,
}).await;
let _ = ctx
.event_publisher
.publish(&DomainEvent::WatchlistEntryAdded {
user_id,
movie_id: movie.id().clone(),
movie_title: movie.title().value().to_string(),
release_year: movie.release_year().value(),
external_metadata_id: movie.external_metadata_id().map(|e| e.value().to_string()),
added_at: entry.added_at,
})
.await;
Ok(())
}

View File

@@ -6,17 +6,23 @@ use domain::{
use crate::{commands::ApplyImportMappingCommand, context::AppContext};
pub async fn execute(ctx: &AppContext, cmd: ApplyImportMappingCommand) -> Result<Vec<AnnotatedRow>, DomainError> {
pub async fn execute(
ctx: &AppContext,
cmd: ApplyImportMappingCommand,
) -> Result<Vec<AnnotatedRow>, DomainError> {
let user_id = UserId::from_uuid(cmd.user_id);
let session_id = ImportSessionId::from_uuid(cmd.session_id);
let mappings = cmd.mappings;
let mut session = ctx.import_session_repository
let mut session = ctx
.import_session_repository
.get(&session_id, &user_id)
.await?
.ok_or_else(|| DomainError::NotFound("import session".into()))?;
// clone to avoid borrow conflict when mutating session fields below
let parsed = session.parsed_file.clone()
let parsed = session
.parsed_file
.clone()
.ok_or_else(|| DomainError::ValidationError("session has no parsed file".into()))?;
let mut annotated = ctx.document_parser.apply_mapping(&parsed, &mappings);
@@ -35,17 +41,31 @@ pub async fn execute(ctx: &AppContext, cmd: ApplyImportMappingCommand) -> Result
Ok(annotated)
}
async fn check_duplicate(ctx: &AppContext, row: &domain::models::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
&& let Ok(eid) = ExternalMetadataId::new(ext_id.clone())
&& ctx.movie_repository.get_movie_by_external_id(&eid).await?.is_some() {
return Ok(true);
}
&& ctx
.movie_repository
.get_movie_by_external_id(&eid)
.await?
.is_some()
{
return Ok(true);
}
if let (Some(title), Some(year_str)) = (&row.title, &row.release_year) {
let title_vo = MovieTitle::new(title.clone());
let year_vo = year_str.parse::<u16>().ok().and_then(|y| ReleaseYear::new(y).ok());
let year_vo = year_str
.parse::<u16>()
.ok()
.and_then(|y| ReleaseYear::new(y).ok());
if let (Ok(t), Some(y)) = (title_vo, year_vo) {
let matches = ctx.movie_repository.get_movies_by_title_and_year(&t, &y).await?;
let matches = ctx
.movie_repository
.get_movies_by_title_and_year(&t, &y)
.await?;
if !matches.is_empty() {
return Ok(true);
}

View File

@@ -1,5 +1,8 @@
use domain::{errors::DomainError, value_objects::{ImportProfileId, ImportSessionId, UserId}};
use crate::{commands::ApplyImportProfileCommand, context::AppContext};
use domain::{
errors::DomainError,
value_objects::{ImportProfileId, ImportSessionId, UserId},
};
/// Copies the profile's field_mappings onto the session. Caller must then invoke
/// apply_import_mapping to regenerate row_results with the new mappings.
@@ -8,11 +11,15 @@ pub async fn execute(ctx: &AppContext, cmd: ApplyImportProfileCommand) -> Result
let session_id = ImportSessionId::from_uuid(cmd.session_id);
let profile_id = ImportProfileId::from_uuid(cmd.profile_id);
let profile = ctx.import_profile_repository
.get(&profile_id, &user_id).await?
let profile = ctx
.import_profile_repository
.get(&profile_id, &user_id)
.await?
.ok_or_else(|| DomainError::NotFound("import profile".into()))?;
let mut session = ctx.import_session_repository
.get(&session_id, &user_id).await?
let mut session = ctx
.import_session_repository
.get(&session_id, &user_id)
.await?
.ok_or_else(|| DomainError::NotFound("import session".into()))?;
session.field_mappings = Some(profile.field_mappings);
session.row_results = None;

View File

@@ -1,5 +1,5 @@
use domain::errors::DomainError;
use crate::context::AppContext;
use domain::errors::DomainError;
pub async fn execute(ctx: &AppContext) -> Result<u64, DomainError> {
ctx.import_session_repository.delete_expired().await

View File

@@ -13,11 +13,17 @@ pub struct CreateSessionResult {
pub sample_rows: Vec<Vec<String>>,
}
pub async fn execute(ctx: &AppContext, cmd: CreateImportSessionCommand) -> Result<CreateSessionResult, DomainError> {
pub async fn execute(
ctx: &AppContext,
cmd: CreateImportSessionCommand,
) -> Result<CreateSessionResult, DomainError> {
let user_id = UserId::from_uuid(cmd.user_id);
ctx.import_session_repository.delete_expired_for_user(&user_id).await?;
ctx.import_session_repository
.delete_expired_for_user(&user_id)
.await?;
let parsed = ctx.document_parser
let parsed = ctx
.document_parser
.parse(&cmd.bytes, cmd.format)
.map_err(|e| DomainError::ValidationError(e.to_string()))?;
@@ -31,5 +37,9 @@ pub async fn execute(ctx: &AppContext, cmd: CreateImportSessionCommand) -> Resul
ctx.import_session_repository.create(&session).await?;
Ok(CreateSessionResult { session_id, columns, sample_rows })
Ok(CreateSessionResult {
session_id,
columns,
sample_rows,
})
}

View File

@@ -1,12 +1,16 @@
use domain::{errors::DomainError, value_objects::{ImportProfileId, UserId}};
use crate::{commands::DeleteImportProfileCommand, context::AppContext};
use domain::{
errors::DomainError,
value_objects::{ImportProfileId, UserId},
};
pub async fn execute(ctx: &AppContext, cmd: DeleteImportProfileCommand) -> Result<(), DomainError> {
let user_id = UserId::from_uuid(cmd.user_id);
let profile_id = ImportProfileId::from_uuid(cmd.profile_id);
ctx.import_profile_repository
.get(&profile_id, &user_id).await?
.get(&profile_id, &user_id)
.await?
.ok_or_else(|| DomainError::NotFound("import profile".into()))?;
ctx.import_profile_repository.delete(&profile_id).await
}

View File

@@ -38,8 +38,12 @@ pub async fn execute(ctx: &AppContext, cmd: DeleteReviewCommand) -> Result<(), D
let poster_path = history.movie().poster_path().cloned();
ctx.movie_repository.delete_movie(&movie_id).await?;
// best-effort: movie is already deleted, so publish failure is non-fatal
if let Err(e) = ctx.event_publisher
.publish(&DomainEvent::MovieDeleted { movie_id, poster_path })
if let Err(e) = ctx
.event_publisher
.publish(&DomainEvent::MovieDeleted {
movie_id,
poster_path,
})
.await
{
tracing::warn!("failed to publish MovieDeleted event: {e}");

View File

@@ -3,9 +3,7 @@ use std::sync::Arc;
use domain::{
errors::DomainError,
models::{
CastMember, CrewMember, ExternalPersonId, IndexableDocument, Person, PersonId,
},
models::{CastMember, CrewMember, ExternalPersonId, IndexableDocument, Person, PersonId},
ports::{MovieProfileRepository, MovieRepository, PersonCommand, SearchCommand},
};

View File

@@ -6,7 +6,11 @@ use domain::{
};
use uuid::Uuid;
use crate::{commands::{ExecuteImportCommand, LogReviewCommand, MovieInput}, context::AppContext, use_cases::log_review};
use crate::{
commands::{ExecuteImportCommand, LogReviewCommand, MovieInput},
context::AppContext,
use_cases::log_review,
};
pub struct ImportSummary {
pub imported: usize,
@@ -14,11 +18,15 @@ pub struct ImportSummary {
pub failed: Vec<(usize, String)>,
}
pub async fn execute(ctx: &AppContext, cmd: ExecuteImportCommand) -> Result<ImportSummary, DomainError> {
pub async fn execute(
ctx: &AppContext,
cmd: ExecuteImportCommand,
) -> Result<ImportSummary, DomainError> {
let user_id = UserId::from_uuid(cmd.user_id);
let session_id = ImportSessionId::from_uuid(cmd.session_id);
let confirmed_indices = cmd.confirmed_indices;
let session = ctx.import_session_repository
let session = ctx
.import_session_repository
.get(&session_id, &user_id)
.await?
.ok_or_else(|| DomainError::NotFound("import session".into()))?;
@@ -36,17 +44,13 @@ pub async fn execute(ctx: &AppContext, cmd: ExecuteImportCommand) -> Result<Impo
continue;
}
match annotated.result {
RowResult::Valid(row) => {
match row_to_command(&row, user_id.value()) {
Ok(cmd) => {
match log_review::execute(ctx, cmd).await {
Ok(_) => imported += 1,
Err(e) => failed.push((idx, e.to_string())),
}
}
Err(e) => failed.push((idx, e)),
}
}
RowResult::Valid(row) => match row_to_command(&row, user_id.value()) {
Ok(cmd) => match log_review::execute(ctx, cmd).await {
Ok(_) => imported += 1,
Err(e) => failed.push((idx, e.to_string())),
},
Err(e) => failed.push((idx, e)),
},
RowResult::Invalid { errors, .. } => {
failed.push((idx, errors.join("; ")));
}
@@ -55,20 +59,27 @@ pub async fn execute(ctx: &AppContext, cmd: ExecuteImportCommand) -> Result<Impo
ctx.import_session_repository.delete(&session_id).await?;
Ok(ImportSummary { imported, skipped_duplicates, failed })
Ok(ImportSummary {
imported,
skipped_duplicates,
failed,
})
}
fn row_to_command(row: &ImportRow, user_id: Uuid) -> Result<LogReviewCommand, String> {
let rating = row.rating.as_deref()
let rating = row
.rating
.as_deref()
.ok_or("missing rating")?
.parse::<u8>()
.map_err(|_| "rating is not a valid u8".to_string())?;
let watched_at_str = row.watched_at.as_deref().ok_or("missing watched_at")?;
let watched_at = NaiveDateTime::parse_from_str(&format!("{} 00:00:00", watched_at_str), "%Y-%m-%d %H:%M:%S")
.or_else(|_| NaiveDateTime::parse_from_str(watched_at_str, "%Y-%m-%d %H:%M:%S"))
.or_else(|_| NaiveDateTime::parse_from_str(watched_at_str, "%Y-%m-%dT%H:%M:%S"))
.map_err(|_| format!("cannot parse watched_at: '{}'", watched_at_str))?;
let watched_at =
NaiveDateTime::parse_from_str(&format!("{} 00:00:00", watched_at_str), "%Y-%m-%d %H:%M:%S")
.or_else(|_| NaiveDateTime::parse_from_str(watched_at_str, "%Y-%m-%d %H:%M:%S"))
.or_else(|_| NaiveDateTime::parse_from_str(watched_at_str, "%Y-%m-%dT%H:%M:%S"))
.map_err(|_| format!("cannot parse watched_at: '{}'", watched_at_str))?;
Ok(LogReviewCommand {
user_id,

View File

@@ -1,6 +1,9 @@
use domain::{
errors::DomainError,
models::{FeedEntry, Movie, MovieProfile, MovieStats, collections::{PageParams, Paginated}},
models::{
FeedEntry, Movie, MovieProfile, MovieStats,
collections::{PageParams, Paginated},
},
value_objects::MovieId,
};
@@ -32,5 +35,10 @@ pub async fn execute(
ctx.movie_profile_repository.get_by_movie_id(&movie_id),
)?;
Ok(MovieSocialPageResult { movie, stats, reviews, profile })
Ok(MovieSocialPageResult {
movie,
stats,
reviews,
profile,
})
}

View File

@@ -6,7 +6,10 @@ use domain::{
use crate::{context::AppContext, queries::GetMoviesQuery};
pub async fn execute(ctx: &AppContext, query: GetMoviesQuery) -> Result<Paginated<MovieSummary>, DomainError> {
pub async fn execute(
ctx: &AppContext,
query: GetMoviesQuery,
) -> Result<Paginated<MovieSummary>, DomainError> {
let page = PageParams::new(query.limit, query.offset)?;
let filter = MovieFilter {
search: query.search,

View File

@@ -1,5 +1,8 @@
use domain::{errors::DomainError, models::{Person, PersonId}};
use crate::context::AppContext;
use domain::{
errors::DomainError,
models::{Person, PersonId},
};
pub async fn execute(ctx: &AppContext, id: PersonId) -> Result<Option<Person>, DomainError> {
ctx.person_query.get_by_id(&id).await

View File

@@ -1,5 +1,8 @@
use domain::{errors::DomainError, models::{PersonCredits, PersonId}};
use crate::context::AppContext;
use domain::{
errors::DomainError,
models::{PersonCredits, PersonId},
};
pub async fn execute(ctx: &AppContext, id: PersonId) -> Result<PersonCredits, DomainError> {
ctx.person_query.get_credits(&id).await

View File

@@ -2,6 +2,11 @@ use domain::{errors::DomainError, models::RemoteWatchlistEntry};
use crate::context::AppContext;
pub async fn execute(ctx: &AppContext, uuid: uuid::Uuid) -> Result<Vec<RemoteWatchlistEntry>, DomainError> {
ctx.remote_watchlist_repository.get_by_derived_uuid(uuid).await
pub async fn execute(
ctx: &AppContext,
uuid: uuid::Uuid,
) -> Result<Vec<RemoteWatchlistEntry>, DomainError> {
ctx.remote_watchlist_repository
.get_by_derived_uuid(uuid)
.await
}

View File

@@ -1,6 +1,9 @@
use domain::{
errors::DomainError,
models::{WatchlistWithMovie, collections::{PageParams, Paginated}},
models::{
WatchlistWithMovie,
collections::{PageParams, Paginated},
},
value_objects::UserId,
};

View File

@@ -1,6 +1,9 @@
use domain::{errors::DomainError, models::ImportProfile, value_objects::UserId};
use crate::context::AppContext;
use domain::{errors::DomainError, models::ImportProfile, value_objects::UserId};
pub async fn execute(ctx: &AppContext, user_id: &UserId) -> Result<Vec<ImportProfile>, DomainError> {
pub async fn execute(
ctx: &AppContext,
user_id: &UserId,
) -> Result<Vec<ImportProfile>, DomainError> {
ctx.import_profile_repository.list_for_user(user_id).await
}

View File

@@ -39,14 +39,18 @@ pub async fn execute(ctx: &AppContext, cmd: LogReviewCommand) -> Result<(), Doma
let review = Review::new(movie.id().clone(), user_id, rating, comment, cmd.watched_at)?;
let review_event = ctx.review_repository.save_review(&review).await?;
let was_on_watchlist = ctx.watchlist_repository
let was_on_watchlist = ctx
.watchlist_repository
.remove_if_present(review.user_id(), review.movie_id())
.await?;
if was_on_watchlist {
let _ = ctx.event_publisher.publish(&DomainEvent::WatchlistEntryRemoved {
user_id: review.user_id().clone(),
movie_id: review.movie_id().clone(),
}).await;
let _ = ctx
.event_publisher
.publish(&DomainEvent::WatchlistEntryRemoved {
user_id: review.user_id().clone(),
movie_id: review.movie_id().clone(),
})
.await;
}
publish_events(ctx, &movie, is_new_movie, review_event).await?;
@@ -60,14 +64,13 @@ async fn publish_events(
is_new_movie: bool,
review_event: DomainEvent,
) -> Result<(), DomainError> {
if is_new_movie
&& let Some(ext_id) = movie.external_metadata_id() {
let discovery_event = DomainEvent::MovieDiscovered {
movie_id: movie.id().clone(),
external_metadata_id: ext_id.clone(),
};
ctx.event_publisher.publish(&discovery_event).await?;
}
if is_new_movie && let Some(ext_id) = movie.external_metadata_id() {
let discovery_event = DomainEvent::MovieDiscovered {
movie_id: movie.id().clone(),
external_metadata_id: ext_id.clone(),
};
ctx.event_publisher.publish(&discovery_event).await?;
}
if let Some(ext_id) = movie.external_metadata_id() {
let enrichment_event = DomainEvent::MovieEnrichmentRequested {

View File

@@ -1,13 +1,12 @@
pub mod enrich_movie;
pub mod add_to_watchlist;
pub mod apply_import_mapping;
pub mod apply_import_profile;
pub mod cleanup_expired_import_sessions;
pub mod create_import_session;
pub mod delete_import_profile;
pub mod delete_review;
pub mod enrich_movie;
pub mod execute_import;
pub mod list_import_profiles;
pub mod save_import_profile;
pub mod export_diary;
pub mod get_activity_feed;
pub mod get_diary;
@@ -15,19 +14,20 @@ pub mod get_movie_social_page;
pub mod get_movies;
pub mod get_person;
pub mod get_person_credits;
#[cfg(feature = "federation")]
pub mod get_remote_watchlist;
pub mod get_review_history;
pub mod get_user_profile;
pub mod get_users;
pub mod get_watchlist;
pub mod is_on_watchlist;
pub mod list_import_profiles;
pub mod log_review;
pub mod login;
pub mod register;
pub mod remove_from_watchlist;
pub mod save_import_profile;
pub mod search;
pub mod sync_poster;
pub mod update_profile;
pub mod update_profile_fields;
pub mod add_to_watchlist;
pub mod remove_from_watchlist;
pub mod get_watchlist;
pub mod is_on_watchlist;
#[cfg(feature = "federation")]
pub mod get_remote_watchlist;

View File

@@ -11,10 +11,10 @@ pub async fn execute(ctx: &AppContext, cmd: RemoveFromWatchlistCommand) -> Resul
let movie_id = MovieId::from_uuid(cmd.movie_id);
ctx.watchlist_repository.remove(&user_id, &movie_id).await?;
let _ = ctx.event_publisher.publish(&DomainEvent::WatchlistEntryRemoved {
user_id,
movie_id,
}).await;
let _ = ctx
.event_publisher
.publish(&DomainEvent::WatchlistEntryRemoved { user_id, movie_id })
.await;
Ok(())
}

View File

@@ -1,17 +1,33 @@
use chrono::Utc;
use domain::{errors::DomainError, models::ImportProfile, value_objects::{ImportProfileId, ImportSessionId, UserId}};
use crate::{commands::SaveImportProfileCommand, context::AppContext};
use chrono::Utc;
use domain::{
errors::DomainError,
models::ImportProfile,
value_objects::{ImportProfileId, ImportSessionId, UserId},
};
pub async fn execute(ctx: &AppContext, cmd: SaveImportProfileCommand) -> Result<ImportProfileId, DomainError> {
pub async fn execute(
ctx: &AppContext,
cmd: SaveImportProfileCommand,
) -> Result<ImportProfileId, DomainError> {
let user_id = UserId::from_uuid(cmd.user_id);
let session_id = ImportSessionId::from_uuid(cmd.session_id);
let session = ctx.import_session_repository
.get(&session_id, &user_id).await?
let session = ctx
.import_session_repository
.get(&session_id, &user_id)
.await?
.ok_or_else(|| DomainError::NotFound("import session".into()))?;
let mappings = session.field_mappings
.ok_or_else(|| DomainError::ValidationError("no mapping applied to this session yet".into()))?;
let profile = ImportProfile::new(ImportProfileId::generate(), user_id, cmd.name, mappings, Utc::now().naive_utc());
let mappings = session.field_mappings.ok_or_else(|| {
DomainError::ValidationError("no mapping applied to this session yet".into())
})?;
let profile = ImportProfile::new(
ImportProfileId::generate(),
user_id,
cmd.name,
mappings,
Utc::now().naive_utc(),
);
let id = profile.id.clone();
ctx.import_profile_repository.save(&profile).await?;
Ok(id)

View File

@@ -1,5 +1,8 @@
use domain::{errors::DomainError, models::{SearchQuery, SearchResults}};
use crate::context::AppContext;
use domain::{
errors::DomainError,
models::{SearchQuery, SearchResults},
};
pub async fn execute(ctx: &AppContext, query: SearchQuery) -> Result<SearchResults, DomainError> {
ctx.search_port.search(&query).await

View File

@@ -42,8 +42,11 @@ pub async fn execute(ctx: &AppContext, cmd: SyncPosterCommand) -> Result<(), Dom
.store(&movie_id.value().to_string(), &image_bytes)
.await?;
if let Err(e) = ctx.event_publisher
.publish(&DomainEvent::ImageStored { key: stored_path.clone() })
if let Err(e) = ctx
.event_publisher
.publish(&DomainEvent::ImageStored {
key: stored_path.clone(),
})
.await
{
tracing::warn!("failed to emit ImageStored for {stored_path}: {e}");
@@ -56,8 +59,14 @@ pub async fn execute(ctx: &AppContext, cmd: SyncPosterCommand) -> Result<(), Dom
// Refresh search index so the new poster_path is reflected immediately.
// Fetch existing profile if available for a complete index document.
let profile = ctx.movie_profile_repository.get_by_movie_id(&movie_id).await.ok().flatten();
if let Err(e) = ctx.search_command
let profile = ctx
.movie_profile_repository
.get_by_movie_id(&movie_id)
.await
.ok()
.flatten();
if let Err(e) = ctx
.search_command
.index(IndexableDocument::Movie {
id: movie_id.clone(),
movie: Box::new(movie),

View File

@@ -1,8 +1,4 @@
use domain::{
errors::DomainError,
events::DomainEvent,
value_objects::UserId,
};
use domain::{errors::DomainError, events::DomainEvent, value_objects::UserId};
use crate::{commands::UpdateProfileCommand, context::AppContext};
@@ -19,14 +15,22 @@ pub async fn execute(ctx: &AppContext, cmd: UpdateProfileCommand) -> Result<(),
let new_avatar_path = if let Some(bytes) = cmd.avatar_bytes {
let content_type = cmd.avatar_content_type.as_deref().unwrap_or("");
if !["image/jpeg", "image/png", "image/webp"].contains(&content_type) {
return Err(DomainError::ValidationError("Avatar must be jpeg, png, or webp".into()));
return Err(DomainError::ValidationError(
"Avatar must be jpeg, png, or webp".into(),
));
}
if let Some(old_path) = user.avatar_path() {
let _ = ctx.image_storage.delete(old_path).await;
}
let key = format!("avatars/{}", user_id.value());
let stored = ctx.image_storage.store(&key, &bytes).await?;
if let Err(e) = ctx.event_publisher.publish(&DomainEvent::ImageStored { key: stored.clone() }).await {
if let Err(e) = ctx
.event_publisher
.publish(&DomainEvent::ImageStored {
key: stored.clone(),
})
.await
{
tracing::warn!("failed to emit ImageStored for avatar {stored}: {e}");
}
Some(stored)
@@ -38,14 +42,22 @@ pub async fn execute(ctx: &AppContext, cmd: UpdateProfileCommand) -> Result<(),
let new_banner_path = if let Some(bytes) = cmd.banner_bytes {
let content_type = cmd.banner_content_type.as_deref().unwrap_or("");
if !["image/jpeg", "image/png", "image/webp"].contains(&content_type) {
return Err(DomainError::ValidationError("Banner must be jpeg, png, or webp".into()));
return Err(DomainError::ValidationError(
"Banner must be jpeg, png, or webp".into(),
));
}
if let Some(old_path) = user.banner_path() {
let _ = ctx.image_storage.delete(old_path).await;
}
let key = format!("banners/{}", user_id.value());
let stored = ctx.image_storage.store(&key, &bytes).await?;
if let Err(e) = ctx.event_publisher.publish(&DomainEvent::ImageStored { key: stored.clone() }).await {
if let Err(e) = ctx
.event_publisher
.publish(&DomainEvent::ImageStored {
key: stored.clone(),
})
.await
{
tracing::warn!("failed to emit ImageStored for banner {stored}: {e}");
}
Some(stored)
@@ -54,7 +66,13 @@ pub async fn execute(ctx: &AppContext, cmd: UpdateProfileCommand) -> Result<(),
};
ctx.user_repository
.update_profile(&user_id, cmd.bio, new_avatar_path, new_banner_path, cmd.also_known_as)
.update_profile(
&user_id,
cmd.bio,
new_avatar_path,
new_banner_path,
cmd.also_known_as,
)
.await?;
ctx.event_publisher

View File

@@ -1,17 +1,19 @@
use domain::{
errors::DomainError,
events::DomainEvent,
value_objects::UserId,
};
use domain::{errors::DomainError, events::DomainEvent, value_objects::UserId};
use crate::{commands::UpdateProfileFieldsCommand, context::AppContext};
pub async fn execute(ctx: &AppContext, cmd: UpdateProfileFieldsCommand) -> Result<(), DomainError> {
if cmd.fields.len() > 4 {
return Err(DomainError::ValidationError("Maximum 4 profile fields allowed".into()));
return Err(DomainError::ValidationError(
"Maximum 4 profile fields allowed".into(),
));
}
let user_id = UserId::from_uuid(cmd.user_id);
ctx.profile_fields_repository.set_fields(&user_id, cmd.fields).await?;
ctx.event_publisher.publish(&DomainEvent::UserUpdated { user_id }).await?;
ctx.profile_fields_repository
.set_fields(&user_id, cmd.fields)
.await?;
ctx.event_publisher
.publish(&DomainEvent::UserUpdated { user_id })
.await?;
Ok(())
}