Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions crates/domain/src/repository/record.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Output = Result<u64, RecordRepositoryError>> + 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<Output = Result<u64, RecordRepositoryError>> + 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(
Expand Down
8 changes: 7 additions & 1 deletion crates/infrastructure/src/record/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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<u64, RecordRepositoryError> {
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,
Expand Down
61 changes: 61 additions & 0 deletions crates/infrastructure/src/record/read.rs
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,47 @@ pub async fn sum_scores(db: &DbConn) -> Result<u64, RecordRepositoryError> {
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<u64, RecordRepositoryError> {
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::<Option<Decimal>>()
.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,
Expand Down Expand Up @@ -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");
Expand Down
7 changes: 3 additions & 4 deletions crates/presentation/src/route/statistics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) }));
Expand Down
2 changes: 1 addition & 1 deletion crates/usecase/src/statistics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ impl<R: Repositories> StatisticsUsecase<R> {

pub async fn summary(&self) -> Result<GlobalStatisticsDto, StatisticsUsecaseError> {
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(
Expand Down
Loading