feat: atomic wrapup, movie, integration writes

Ride writes: WrapUpRequested/Completed (wrapup create/set_complete),
MovieDiscovered (resolve upsert, new+ext id only), PosterSynced (poster
handler upsert), MovieDeleted (delete_movie), WatchEventIngested (save).
Deps drop event_publisher: wrapup, ingest, delete_review, watchlist add,
review logger.

Remaining .publish( sites:
announce-only: users/delete_account UserDeleted, person/get +
get_credits PersonEnrichmentRequested, jobs/enrichment_staleness,
http-axum search, movies/sync_poster + poster_sync_handler +
image-converter backfill + users/update_profile ImageStored (object
storage), activitypub federation_event_bridge (reacting to events).
rides write, deferred: social/execute (see report),
activitypub review_handler MovieEnrichmentRequested (after
save_remote_review).
This commit is contained in:
2026-10-26 01:02:34 +01:00
parent 2f133afb96
commit 6c295866a0
51 changed files with 1281 additions and 329 deletions

View File

@@ -33,18 +33,15 @@ pub async fn execute(deps: &DeleteReviewDeps, cmd: DeleteReviewCommand) -> Resul
let history = deps.diary.get_review_history(&movie_id).await?;
if history.viewings().is_empty() {
let poster_path = history.movie().poster_path().cloned();
deps.movie_command.delete_movie(&movie_id).await?;
// best-effort: movie is already deleted, so publish failure is non-fatal
if let Err(e) = deps
.event_publisher
.publish(&DomainEvent::MovieDeleted {
movie_id,
poster_path,
})
.await
{
tracing::warn!("failed to publish MovieDeleted event: {e}");
}
deps.movie_command
.delete_movie(
&movie_id,
&[DomainEvent::MovieDeleted {
movie_id: movie_id.clone(),
poster_path,
}],
)
.await?;
}
Ok(())

View File

@@ -1,8 +1,8 @@
use std::sync::Arc;
use domain::ports::{
DiaryExporter, DiaryQuery, EventPublisher, FollowGraphQuery, MovieCommand,
MovieProfileRepository, MovieQuery, ReviewRepository, UserRepository,
DiaryExporter, DiaryQuery, FollowGraphQuery, MovieCommand, MovieProfileRepository, MovieQuery,
ReviewRepository, UserRepository,
};
use crate::config::AppSettings;
@@ -11,7 +11,6 @@ pub struct DeleteReviewDeps {
pub review: Arc<dyn ReviewRepository>,
pub diary: Arc<dyn DiaryQuery>,
pub movie_command: Arc<dyn MovieCommand>,
pub event_publisher: Arc<dyn EventPublisher>,
}
pub struct EditReviewDeps {

View File

@@ -5,10 +5,7 @@ use domain::{
errors::DomainError,
events::DomainEvent,
models::Review,
ports::{
EventPublisher, MetadataClient, MovieCommand, MovieQuery, ReviewRepository,
WatchlistRepository,
},
ports::{MetadataClient, MovieCommand, MovieQuery, ReviewRepository, WatchlistRepository},
value_objects::{Comment, Rating, UserId},
};
@@ -22,7 +19,6 @@ pub struct DefaultReviewLogger {
review_repo: Arc<dyn ReviewRepository>,
watchlist_repo: Arc<dyn WatchlistRepository>,
metadata_client: Arc<dyn MetadataClient>,
event_publisher: Arc<dyn EventPublisher>,
}
impl DefaultReviewLogger {
@@ -32,7 +28,6 @@ impl DefaultReviewLogger {
review_repo: Arc<dyn ReviewRepository>,
watchlist_repo: Arc<dyn WatchlistRepository>,
metadata_client: Arc<dyn MetadataClient>,
event_publisher: Arc<dyn EventPublisher>,
) -> Self {
Self {
movie_command,
@@ -40,7 +35,6 @@ impl DefaultReviewLogger {
review_repo,
watchlist_repo,
metadata_client,
event_publisher,
}
}
}
@@ -57,13 +51,12 @@ impl ReviewLogger for DefaultReviewLogger {
self.movie_command.as_ref(),
self.movie_query.as_ref(),
self.metadata_client.as_ref(),
self.event_publisher.as_ref(),
)
.await?;
// Always upsert: even existing movies may have updated metadata
if !is_new_movie {
self.movie_command.upsert_movie(&movie).await?;
self.movie_command.upsert_movie(&movie, &[]).await?;
}
let review = Review::new(

View File

@@ -1,6 +1,6 @@
use std::sync::Arc;
use domain::ports::{EventPublisher, WatchEventCommand, WatchEventQuery, WebhookTokenRepository};
use domain::ports::{WatchEventCommand, WatchEventQuery, WebhookTokenRepository};
use crate::ports::ReviewLogger;
@@ -8,7 +8,6 @@ pub struct IngestWatchEventDeps {
pub webhook_token: Arc<dyn WebhookTokenRepository>,
pub watch_event_command: Arc<dyn WatchEventCommand>,
pub watch_event_query: Arc<dyn WatchEventQuery>,
pub event_publisher: Arc<dyn EventPublisher>,
}
pub struct ConfirmWatchEventsDeps {

View File

@@ -49,16 +49,16 @@ pub async fn execute(
None,
);
deps.watch_event_command.save(&event).await?;
let _ = deps
.event_publisher
.publish(&DomainEvent::WatchEventIngested {
user_id: event.user_id().clone(),
title: event.title().to_string(),
source: event.source().to_string(),
})
.await;
deps.watch_event_command
.save(
&event,
&[DomainEvent::WatchEventIngested {
user_id: event.user_id().clone(),
title: event.title().to_string(),
source: event.source().to_string(),
}],
)
.await?;
Ok(())
}

View File

@@ -5,26 +5,17 @@ use async_trait::async_trait;
use chrono::Datelike;
use domain::{
errors::DomainError,
ports::{EventPublisher, PeriodicJob, UserRepository, WrapUpRepository},
ports::{PeriodicJob, UserRepository, WrapUpRepository},
};
pub struct WrapUpAutoGenerateJob {
user: Arc<dyn UserRepository>,
wrapup_repo: Arc<dyn WrapUpRepository>,
event_publisher: Arc<dyn EventPublisher>,
}
impl WrapUpAutoGenerateJob {
pub fn new(
user: Arc<dyn UserRepository>,
wrapup_repo: Arc<dyn WrapUpRepository>,
event_publisher: Arc<dyn EventPublisher>,
) -> Self {
Self {
user,
wrapup_repo,
event_publisher,
}
pub fn new(user: Arc<dyn UserRepository>, wrapup_repo: Arc<dyn WrapUpRepository>) -> Self {
Self { user, wrapup_repo }
}
}
@@ -60,7 +51,6 @@ impl PeriodicJob for WrapUpAutoGenerateJob {
};
let deps = crate::wrapup::deps::GenerateWrapUpDeps {
wrapup_repo: self.wrapup_repo.clone(),
event_publisher: self.event_publisher.clone(),
};
if let Err(e) = crate::wrapup::generate::execute(&deps, cmd).await {
tracing::warn!(
@@ -81,7 +71,6 @@ impl PeriodicJob for WrapUpAutoGenerateJob {
};
let deps = crate::wrapup::deps::GenerateWrapUpDeps {
wrapup_repo: self.wrapup_repo.clone(),
event_publisher: self.event_publisher.clone(),
};
if let Err(e) = crate::wrapup::generate::execute(&deps, cmd).await {
tracing::warn!("auto-generate global wrapup failed: {e}");

View File

@@ -85,20 +85,14 @@ impl PosterSyncHandler {
let poster_path = PosterPath::new(stored_path)?;
movie.update_poster(poster_path);
self.movie_command.upsert_movie(&movie).await?;
if let Err(e) = self
.event_publisher
.publish(&DomainEvent::PosterSynced {
movie_id: movie.id().clone(),
})
.await
{
tracing::warn!(
"failed to emit PosterSynced for {}: {e}",
movie.id().value()
);
}
self.movie_command
.upsert_movie(
&movie,
&[DomainEvent::PosterSynced {
movie_id: movie.id().clone(),
}],
)
.await?;
Ok(())
}

View File

@@ -2,7 +2,7 @@ use domain::{
errors::DomainError,
events::DomainEvent,
models::Movie,
ports::{EventPublisher, MetadataClient, MovieCommand, MovieQuery},
ports::{MetadataClient, MovieCommand, MovieQuery},
value_objects::MovieId,
};
@@ -14,7 +14,6 @@ pub async fn resolve_and_persist_movie(
movie_command: &dyn MovieCommand,
movie_query: &dyn MovieQuery,
metadata_client: &dyn MetadataClient,
event_publisher: &dyn EventPublisher,
) -> Result<(Movie, bool), DomainError> {
let (movie, is_new) = if let Some(id) = input.movie_id {
let movie_id = MovieId::from_uuid(id);
@@ -34,15 +33,15 @@ pub async fn resolve_and_persist_movie(
};
if is_new {
movie_command.upsert_movie(&movie).await?;
if let Some(ext_id) = movie.external_metadata_id() {
let _ = event_publisher
.publish(&DomainEvent::MovieDiscovered {
movie_id: movie.id().clone(),
external_metadata_id: ext_id.clone(),
})
.await;
}
let events: Vec<DomainEvent> = movie
.external_metadata_id()
.map(|ext_id| DomainEvent::MovieDiscovered {
movie_id: movie.id().clone(),
external_metadata_id: ext_id.clone(),
})
.into_iter()
.collect();
movie_command.upsert_movie(&movie, &events).await?;
}
Ok((movie, is_new))

View File

@@ -59,7 +59,7 @@ pub async fn execute(deps: &SyncPosterDeps, cmd: SyncPosterCommand) -> Result<()
let poster_path = PosterPath::new(stored_path)?;
movie.update_poster(poster_path);
deps.movie_command.upsert_movie(&movie).await?;
deps.movie_command.upsert_movie(&movie, &[]).await?;
// Refresh search index so the new poster_path is reflected immediately.
// Fetch existing profile if available for a complete index document.

View File

@@ -18,7 +18,6 @@ pub async fn execute(
deps.movie_command.as_ref(),
deps.movie_query.as_ref(),
deps.metadata.as_ref(),
deps.event_publisher.as_ref(),
)
.await?;

View File

@@ -1,8 +1,8 @@
use std::sync::Arc;
use domain::ports::{
EventPublisher, MetadataClient, MovieCommand, MovieQuery, RemoteWatchlistRepository,
UserRepository, WatchlistRepository,
MetadataClient, MovieCommand, MovieQuery, RemoteWatchlistRepository, UserRepository,
WatchlistRepository,
};
pub struct WatchlistAddDeps {
@@ -10,7 +10,6 @@ pub struct WatchlistAddDeps {
pub movie_query: Arc<dyn MovieQuery>,
pub metadata: Arc<dyn MetadataClient>,
pub watchlist: Arc<dyn WatchlistRepository>,
pub event_publisher: Arc<dyn EventPublisher>,
}
pub struct GetWatchlistForOwnerDeps {

View File

@@ -1,10 +1,9 @@
use std::sync::Arc;
use domain::ports::{EventPublisher, WrapUpRepository, WrapUpStatsQuery};
use domain::ports::{WrapUpRepository, WrapUpStatsQuery};
pub struct HandleWrapUpRequestedDeps {
pub wrapup_repo: Arc<dyn WrapUpRepository>,
pub event_publisher: Arc<dyn EventPublisher>,
pub wrapup_stats: Arc<dyn WrapUpStatsQuery>,
}
@@ -18,7 +17,6 @@ pub struct DeleteWrapUpDeps {
pub struct GenerateWrapUpDeps {
pub wrapup_repo: Arc<dyn WrapUpRepository>,
pub event_publisher: Arc<dyn EventPublisher>,
}
pub struct GetWrapUpDeps {

View File

@@ -3,7 +3,7 @@ use std::sync::Arc;
use async_trait::async_trait;
use domain::errors::DomainError;
use domain::events::DomainEvent;
use domain::ports::{EventHandler, EventPublisher, WrapUpRepository, WrapUpStatsQuery};
use domain::ports::{EventHandler, WrapUpRepository, WrapUpStatsQuery};
use tokio::sync::Semaphore;
use super::deps::HandleWrapUpRequestedDeps;
@@ -16,13 +16,11 @@ pub struct WrapUpEventHandler {
impl WrapUpEventHandler {
pub fn new(
wrapup_repo: Arc<dyn WrapUpRepository>,
event_publisher: Arc<dyn EventPublisher>,
wrapup_stats: Arc<dyn WrapUpStatsQuery>,
) -> Self {
Self {
deps: HandleWrapUpRequestedDeps {
wrapup_repo,
event_publisher,
wrapup_stats,
},
semaphore: Arc::new(Semaphore::new(2)),

View File

@@ -46,15 +46,16 @@ pub async fn execute(
created_at: Utc::now().naive_utc(),
completed_at: None,
};
deps.wrapup_repo.create(&record).await?;
deps.event_publisher
.publish(&DomainEvent::WrapUpRequested {
wrapup_id: id.clone(),
user_id: cmd.user_id.map(UserId::from_uuid),
start_date: date_range.start(),
end_date: date_range.end(),
})
deps.wrapup_repo
.create(
&record,
&[DomainEvent::WrapUpRequested {
wrapup_id: id.clone(),
user_id: cmd.user_id.map(UserId::from_uuid),
start_date: date_range.start(),
end_date: date_range.end(),
}],
)
.await?;
Ok(id)

View File

@@ -37,10 +37,14 @@ pub async fn execute(
match compute::execute(deps.wrapup_stats.clone(), query).await {
Ok(report) => {
deps.wrapup_repo.set_complete(&wrapup_id, &report).await?;
deps.event_publisher
.publish(&DomainEvent::WrapUpCompleted { wrapup_id })
deps.wrapup_repo
.set_complete(
&wrapup_id,
&report,
&[DomainEvent::WrapUpCompleted {
wrapup_id: wrapup_id.clone(),
}],
)
.await?;
Ok(())
}