diff --git a/crates/domain/src/repository/record.rs b/crates/domain/src/repository/record.rs index 3f5a6c5..38eb984 100644 --- a/crates/domain/src/repository/record.rs +++ b/crates/domain/src/repository/record.rs @@ -112,6 +112,11 @@ pub trait RecordRepository: Send + Sync { /// zero when no records are present to keep the aggregation stable for dashboards. fn sum_scores(&self) -> impl Future> + Send; + /// Returns the sum of persisted play counts across the entire catalog. + /// The public `totalCredits` statistic is backed by this value for compatibility with the + /// existing API contract and dashboard clients. + fn sum_play_counts(&self) -> impl Future> + Send; + /// Retrieves the highest scores for the supplied sheet. Persistence adapters must filter out /// non-public users in this query because visibility flags are enforced by the users table. fn find_public_high_scores_by_sheet( diff --git a/crates/infrastructure/src/record/mod.rs b/crates/infrastructure/src/record/mod.rs index a22e8c0..a651e4f 100644 --- a/crates/infrastructure/src/record/mod.rs +++ b/crates/infrastructure/src/record/mod.rs @@ -13,7 +13,8 @@ use domain::{ }; use read::{ public_high_scores_by_sheet, public_total_score_ranking, records_by_user, - records_by_user_and_sheet_ids, records_with_metadata_by_user, sum_scores as query_sum_scores, + records_by_user_and_sheet_ids, records_with_metadata_by_user, + sum_play_counts as query_sum_play_counts, sum_scores as query_sum_scores, }; use sea_orm::DbConn; use tracing::{debug, info, instrument}; @@ -90,6 +91,11 @@ impl RecordRepository for RecordRepositoryImpl { query_sum_scores(self.db.as_ref()).await } + #[instrument(skip(self))] + async fn sum_play_counts(&self) -> Result { + query_sum_play_counts(self.db.as_ref()).await + } + #[instrument(skip(self), fields(sheet_id = %sheet_id, limit))] async fn find_public_high_scores_by_sheet( &self, diff --git a/crates/infrastructure/src/record/read.rs b/crates/infrastructure/src/record/read.rs index cd4430f..ee17689 100644 --- a/crates/infrastructure/src/record/read.rs +++ b/crates/infrastructure/src/record/read.rs @@ -227,6 +227,47 @@ pub async fn sum_scores(db: &DbConn) -> Result { Ok(sum) } +/// Aggregates persisted play counts across all records. Casts SUM(...) to NUMERIC to +/// stabilize Postgres' return type regardless of column width. +pub async fn sum_play_counts(db: &DbConn) -> Result { + debug!("Summing record play counts via SeaORM"); + let sum = entities::records::Entity::find() + .select_only() + .column_as( + Expr::col(entities::records::Column::PlayCount) + .sum() + .cast_as(Alias::new("numeric")), + "sum", + ) + .into_tuple::>() + .one(db) + .await + .map_err(|err| { + error!(error = %err, "Failed to sum record play counts"); + RecordRepositoryError::InternalError(AnyError::from(err)) + })? + .flatten() + .unwrap_or(Decimal::ZERO); + + if sum.is_sign_negative() { + let err = AnyError::msg("Database returned negative record play count sum"); + error!("Record play count sum returned negative value"); + return Err(RecordRepositoryError::InternalError(err)); + } + + let sum = sum.to_u64().ok_or_else(|| { + let err = AnyError::msg("Record play count sum cannot fit in u64"); + error!("Record play count sum overflowed u64 conversion"); + err + })?; + + info!( + total_play_counts = sum, + "Record play counts summed successfully" + ); + Ok(sum) +} + pub async fn public_high_scores_by_sheet( db: &DbConn, sheet_id: &str, @@ -410,6 +451,26 @@ mod tests { assert_eq!(result, 0); } + #[tokio::test] + async fn sum_play_counts_handles_numeric_rows() { + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([vec![decimal_row("sum", Some(172))]]) + .into_connection(); + + let result = sum_play_counts(&db).await.unwrap(); + assert_eq!(result, 172); + } + + #[tokio::test] + async fn sum_play_counts_defaults_to_zero() { + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([vec![decimal_row("sum", None)]]) + .into_connection(); + + let result = sum_play_counts(&db).await.unwrap(); + assert_eq!(result, 0); + } + #[tokio::test] async fn public_high_scores_by_sheet_converts_rows() { let sheet_id = Uuid::parse_str("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").expect("valid uuid"); diff --git a/crates/presentation/src/route/statistics.rs b/crates/presentation/src/route/statistics.rs index a2b36df..e6dfc5c 100644 --- a/crates/presentation/src/route/statistics.rs +++ b/crates/presentation/src/route/statistics.rs @@ -48,11 +48,10 @@ mod tests { user_repo .expect_count_all() .returning(|| Box::pin(async { Ok(7) })); - user_repo - .expect_sum_credits() - .returning(|| Box::pin(async { Ok(1234) })); - let mut record_repo = MockRecordRepository::new(); + record_repo + .expect_sum_play_counts() + .returning(|| Box::pin(async { Ok(1234) })); record_repo .expect_sum_scores() .returning(|| Box::pin(async { Ok(987_654) })); diff --git a/crates/usecase/src/statistics.rs b/crates/usecase/src/statistics.rs index 8e7158a..9cba7c2 100644 --- a/crates/usecase/src/statistics.rs +++ b/crates/usecase/src/statistics.rs @@ -28,7 +28,7 @@ impl StatisticsUsecase { pub async fn summary(&self) -> Result { let total_users = self.repositories.user().count_all().await?; - let total_credits = self.repositories.user().sum_credits().await?; + let total_credits = self.repositories.record().sum_play_counts().await?; let total_score = self.repositories.record().sum_scores().await?; Ok(GlobalStatisticsDto::new(