diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index bb5e74d..cfd39a8 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -1,5 +1,6 @@ use async_trait::async_trait; -use chrono::{DateTime, Utc}; +use chrono::{DateTime, NaiveDateTime, Utc}; +use uuid::Uuid; use crate::{ errors::DomainError, @@ -12,6 +13,7 @@ use crate::{ ReviewHistory, SearchQuery, SearchResults, User, UserStats, UserSummary, UserTrends, WatchEvent, WatchEventStatus, WatchlistEntry, WatchlistWithMovie, WebhookToken, collections::{self, PageParams, Paginated}, + wrapup::{DateRange, WrapUpScope}, }, value_objects::{ Email, ExternalMetadataId, ImportProfileId, ImportSessionId, MovieId, MovieTitle, @@ -468,3 +470,33 @@ pub trait WebhookTokenRepository: Send + Sync { async fn delete(&self, id: &WebhookTokenId, user_id: &UserId) -> Result<(), DomainError>; async fn touch_last_used(&self, id: &WebhookTokenId) -> Result<(), DomainError>; } + +// ── Wrap-up / Year-in-Review ───────────────────────────────────────────────── + +#[derive(Clone, Debug)] +pub struct WrapUpMovieRow { + pub movie_id: Uuid, + pub title: String, + pub release_year: u16, + pub director: Option, + pub poster_path: Option, + pub rating: u8, + pub watched_at: NaiveDateTime, + pub user_id: Uuid, + pub runtime_minutes: Option, + pub budget_usd: Option, + pub original_language: Option, + pub genres: Vec, + pub keywords: Vec, + pub cast_names: Vec<(String, u32)>, + pub cast_profile_paths: Vec>, +} + +#[async_trait] +pub trait WrapUpStatsQuery: Send + Sync { + async fn get_reviews_with_profiles( + &self, + scope: &WrapUpScope, + range: &DateRange, + ) -> Result, DomainError>; +} diff --git a/crates/domain/src/testing.rs b/crates/domain/src/testing.rs index 4a39e1b..6b67d9d 100644 --- a/crates/domain/src/testing.rs +++ b/crates/domain/src/testing.rs @@ -991,3 +991,47 @@ impl crate::ports::WebhookTokenRepository for PanicWebhookTokenRepository { panic!("PanicWebhookTokenRepository called") } } + +// ── InMemoryWrapUpStatsQuery ──────────────────────────────────────────────── + +pub struct InMemoryWrapUpStatsQuery { + pub rows: Mutex>, +} + +impl InMemoryWrapUpStatsQuery { + pub fn new() -> Arc { + Arc::new(Self { + rows: Mutex::new(Vec::new()), + }) + } + + pub fn with_rows(rows: Vec) -> Arc { + Arc::new(Self { + rows: Mutex::new(rows), + }) + } +} + +#[async_trait] +impl crate::ports::WrapUpStatsQuery for InMemoryWrapUpStatsQuery { + async fn get_reviews_with_profiles( + &self, + scope: &crate::models::wrapup::WrapUpScope, + range: &crate::models::wrapup::DateRange, + ) -> Result, DomainError> { + let rows = self.rows.lock().unwrap(); + let filtered: Vec<_> = rows + .iter() + .filter(|r| { + let date = r.watched_at.date(); + date >= range.start && date < range.end + }) + .filter(|r| match scope { + crate::models::wrapup::WrapUpScope::User(uid) => r.user_id == *uid, + crate::models::wrapup::WrapUpScope::Global => true, + }) + .cloned() + .collect(); + Ok(filtered) + } +}