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
52 changes: 52 additions & 0 deletions crates/domain/src/repository/record.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,44 @@ impl RecordWithMetadata {
}
}

/// Represents a scoreboard row for a single sheet. Implementations must ensure rows are sorted in
/// descending order by `score` before handing them to the domain layer.
#[derive(Debug, Clone)]
pub struct SheetScoreRankingRow {
pub user_id: String,
pub display_name: String,
pub score: u32,
}

impl SheetScoreRankingRow {
pub fn new(user_id: String, display_name: String, score: u32) -> Self {
Self {
user_id,
display_name,
score,
}
}
}

/// Represents cumulative record scores per user. Underlying queries must aggregate scores using a
/// stable SUM(...) and exclude non-public users at the persistence layer.
#[derive(Debug, Clone)]
pub struct TotalScoreRankingRow {
pub user_id: String,
pub display_name: String,
pub total_score: u64,
}

impl TotalScoreRankingRow {
pub fn new(user_id: String, display_name: String, total_score: u64) -> Self {
Self {
user_id,
display_name,
total_score,
}
}
}

#[automock]
pub trait RecordRepository: Send + Sync {
fn find_by_user_id(
Expand Down Expand Up @@ -73,4 +111,18 @@ pub trait RecordRepository: Send + Sync {
/// Returns the sum of record scores across the entire catalog. Implementations must default to
/// zero when no records are present to keep the aggregation stable for dashboards.
fn sum_scores(&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(
&self,
sheet_id: &str,
limit: u64,
) -> impl Future<Output = Result<Vec<SheetScoreRankingRow>, RecordRepositoryError>> + Send;

/// Aggregates total record scores per public user and returns the top rows in descending order.
fn find_public_total_score_ranking(
&self,
limit: u64,
) -> impl Future<Output = Result<Vec<TotalScoreRankingRow>, RecordRepositoryError>> + Send;
}
14 changes: 14 additions & 0 deletions crates/domain/src/repository/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,18 @@ pub trait UserRepository: Send + Sync {
&self,
option: UserPlayOption,
) -> impl Future<Output = Result<UserPlayOption, UserRepositoryError>> + Send;

/// Fetches public user aggregates ordered by rating in descending order. Implementations must
/// apply the visibility filter and return at most `limit` rows.
fn find_public_top_by_rating(
&self,
limit: u64,
) -> impl Future<Output = Result<Vec<User>, UserRepositoryError>> + Send;

/// Fetches public user aggregates ordered by XP in descending order. Implementations must
/// apply the visibility filter and return at most `limit` rows.
fn find_public_top_by_xp(
&self,
limit: u64,
) -> impl Future<Output = Result<Vec<User>, UserRepositoryError>> + Send;
}
38 changes: 35 additions & 3 deletions crates/infrastructure/src/record/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@ use std::sync::Arc;

use domain::{
entity::record::Record,
repository::record::{RecordRepository, RecordRepositoryError, RecordWithMetadata},
repository::record::{
RecordRepository, RecordRepositoryError, RecordWithMetadata, SheetScoreRankingRow,
TotalScoreRankingRow,
},
};
use read::{
records_by_user, records_by_user_and_sheet_ids, records_with_metadata_by_user,
sum_scores as query_sum_scores,
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,
};
use sea_orm::DbConn;
use tracing::{debug, info, instrument};
Expand Down Expand Up @@ -86,4 +89,33 @@ impl RecordRepository for RecordRepositoryImpl {
async fn sum_scores(&self) -> Result<u64, RecordRepositoryError> {
query_sum_scores(self.db.as_ref()).await
}

#[instrument(skip(self), fields(sheet_id = %sheet_id, limit))]
async fn find_public_high_scores_by_sheet(
&self,
sheet_id: &str,
limit: u64,
) -> Result<Vec<SheetScoreRankingRow>, RecordRepositoryError> {
debug!("Fetching public sheet ranking via SeaORM");
let result = public_high_scores_by_sheet(self.db.as_ref(), sheet_id, limit).await?;
info!(
count = result.len(),
"Public sheet ranking fetched successfully"
);
Ok(result)
}

#[instrument(skip(self), fields(limit))]
async fn find_public_total_score_ranking(
&self,
limit: u64,
) -> Result<Vec<TotalScoreRankingRow>, RecordRepositoryError> {
debug!("Fetching public total score ranking via SeaORM");
let result = public_total_score_ranking(self.db.as_ref(), limit).await?;
info!(
count = result.len(),
"Public total score ranking fetched successfully"
);
Ok(result)
}
}
212 changes: 209 additions & 3 deletions crates/infrastructure/src/record/read.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,13 @@ use anyhow::Error as AnyError;
use bigdecimal::{Signed, ToPrimitive};
use domain::{
entity::record::Record,
repository::record::{RecordRepositoryError, RecordWithMetadata},
repository::record::{
RecordRepositoryError, RecordWithMetadata, SheetScoreRankingRow, TotalScoreRankingRow,
},
};
use sea_orm::{
ColumnTrait, DbConn, EntityTrait, QueryFilter, QueryOrder, QuerySelect,
ColumnTrait, DbConn, EntityTrait, FromQueryResult, JoinType, QueryFilter, QueryOrder,
QuerySelect, RelationTrait,
prelude::Uuid,
sea_query::{Alias, Expr},
sqlx::types::BigDecimal,
Expand All @@ -19,6 +22,26 @@ use tracing::{debug, error, info, warn};

use crate::entities::{self, prelude::Records};

#[derive(Debug, FromQueryResult)]
struct SheetScoreRow {
#[sea_orm(column_name = "user_id")]
user_id: Uuid,
#[sea_orm(column_name = "display_name")]
display_name: String,
#[sea_orm(column_name = "score")]
score: i32,
}

#[derive(Debug, FromQueryResult)]
struct TotalScoreRow {
#[sea_orm(column_name = "user_id")]
user_id: Uuid,
#[sea_orm(column_name = "display_name")]
display_name: String,
#[sea_orm(column_name = "total_score")]
total_score: BigDecimal,
}

pub async fn records_by_user(
db: &DbConn,
user_id: &str,
Expand Down Expand Up @@ -203,12 +226,131 @@ pub async fn sum_scores(db: &DbConn) -> Result<u64, RecordRepositoryError> {
Ok(sum)
}

pub async fn public_high_scores_by_sheet(
db: &DbConn,
sheet_id: &str,
limit: u64,
) -> Result<Vec<SheetScoreRankingRow>, RecordRepositoryError> {
let sheet_uuid = crate::record::adapter::parse_sheet_uuid(sheet_id)?;

debug!(
sheet_id = %sheet_uuid,
limit,
"Fetching public high scores for sheet via SeaORM"
);
let rows = entities::records::Entity::find()
.select_only()
.column_as(entities::records::Column::UserId, "user_id")
.column_as(entities::users::Column::DisplayName, "display_name")
.column_as(entities::records::Column::Score, "score")
.join(
JoinType::InnerJoin,
entities::records::Relation::Users.def(),
)
.filter(entities::records::Column::SheetId.eq(sheet_uuid))
.filter(entities::users::Column::IsPublic.eq(true))
.order_by_desc(entities::records::Column::Score)
.order_by_asc(entities::records::Column::UpdatedAt)
.limit(limit)
.into_model::<SheetScoreRow>()
.all(db)
.await
.map_err(|err| {
error!(error = %err, "Failed to fetch sheet ranking");
RecordRepositoryError::InternalError(AnyError::from(err))
})?;

let mut result = Vec::with_capacity(rows.len());
for row in rows {
if row.score < 0 {
let err = AnyError::msg("Negative score encountered in ranking query");
error!("Ranking query returned negative score");
return Err(RecordRepositoryError::InternalError(err));
}
let score = u32::try_from(row.score).map_err(|err| {
error!(error = %err, "Failed to convert score to u32");
RecordRepositoryError::InternalError(AnyError::from(err))
})?;
result.push(SheetScoreRankingRow::new(
row.user_id.to_string(),
row.display_name,
score,
));
}

info!(
sheet_id = %sheet_uuid,
count = result.len(),
"Sheet ranking fetched successfully"
);
Ok(result)
}

pub async fn public_total_score_ranking(
db: &DbConn,
limit: u64,
) -> Result<Vec<TotalScoreRankingRow>, RecordRepositoryError> {
debug!(limit, "Fetching public total score ranking via SeaORM");
let rows = entities::records::Entity::find()
.select_only()
.column_as(entities::users::Column::Id, "user_id")
.column_as(entities::users::Column::DisplayName, "display_name")
.column_as(
Expr::col(entities::records::Column::Score)
.sum()
.cast_as(Alias::new("numeric")),
"total_score",
)
.join(
JoinType::InnerJoin,
entities::records::Relation::Users.def(),
)
.filter(entities::users::Column::IsPublic.eq(true))
.group_by(entities::users::Column::Id)
.group_by(entities::users::Column::DisplayName)
.order_by_desc(Expr::col(Alias::new("total_score")))
.order_by_asc(entities::users::Column::DisplayName)
.limit(limit)
.into_model::<TotalScoreRow>()
.all(db)
.await
.map_err(|err| {
error!(error = %err, "Failed to fetch total score ranking");
RecordRepositoryError::InternalError(AnyError::from(err))
})?;

let mut result = Vec::with_capacity(rows.len());
for row in rows {
if row.total_score.is_negative() {
let err = AnyError::msg("Negative total score encountered in ranking query");
error!("Ranking query returned negative total score");
return Err(RecordRepositoryError::InternalError(err));
}
let total_score = row.total_score.to_u64().ok_or_else(|| {
let err = AnyError::msg("Failed to convert total score to u64");
error!("Total score conversion failed for ranking");
err
})?;
result.push(TotalScoreRankingRow::new(
row.user_id.to_string(),
row.display_name,
total_score,
));
}

info!(
count = result.len(),
"Total score ranking fetched successfully"
);
Ok(result)
}

#[cfg(test)]
mod tests {
use std::collections::BTreeMap;

use bigdecimal::BigDecimal;
use sea_orm::{DatabaseBackend, MockDatabase, sea_query::Value};
use sea_orm::{DatabaseBackend, MockDatabase, prelude::Uuid, sea_query::Value};

use super::*;

Expand All @@ -219,6 +361,35 @@ mod tests {
BTreeMap::from([(label.to_owned(), mapped_value)])
}

fn sheet_row(user_id: Uuid, display_name: &str, score: i32) -> BTreeMap<String, Value> {
BTreeMap::from([
("user_id".to_owned(), Value::Uuid(Some(Box::new(user_id)))),
(
"display_name".to_owned(),
Value::String(Some(Box::new(display_name.to_owned()))),
),
("score".to_owned(), Value::Int(Some(score))),
])
}

fn total_score_row(
user_id: Uuid,
display_name: &str,
total_score: i64,
) -> BTreeMap<String, Value> {
BTreeMap::from([
("user_id".to_owned(), Value::Uuid(Some(Box::new(user_id)))),
(
"display_name".to_owned(),
Value::String(Some(Box::new(display_name.to_owned()))),
),
(
"total_score".to_owned(),
Value::BigDecimal(Some(Box::new(BigDecimal::from(total_score)))),
),
])
}

#[tokio::test]
async fn sum_scores_handles_numeric_rows() {
let db = MockDatabase::new(DatabaseBackend::Postgres)
Expand All @@ -238,4 +409,39 @@ mod tests {
let result = sum_scores(&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");
let user_id = Uuid::parse_str("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb").expect("valid uuid");
let db = MockDatabase::new(DatabaseBackend::Postgres)
.append_query_results([vec![sheet_row(user_id, "Alice", 987_654)]])
.into_connection();

let result = public_high_scores_by_sheet(&db, &sheet_id.to_string(), 20)
.await
.unwrap();

assert_eq!(result.len(), 1);
let entry = &result[0];
assert_eq!(entry.user_id, user_id.to_string());
assert_eq!(entry.display_name, "Alice");
assert_eq!(entry.score, 987_654);
}

#[tokio::test]
async fn public_total_score_ranking_converts_rows() {
let user_id = Uuid::parse_str("cccccccc-cccc-cccc-cccc-cccccccccccc").expect("valid uuid");
let db = MockDatabase::new(DatabaseBackend::Postgres)
.append_query_results([vec![total_score_row(user_id, "Bob", 1_234_567)]])
.into_connection();

let result = public_total_score_ranking(&db, 20).await.unwrap();

assert_eq!(result.len(), 1);
let entry = &result[0];
assert_eq!(entry.user_id, user_id.to_string());
assert_eq!(entry.display_name, "Bob");
assert_eq!(entry.total_score, 1_234_567);
}
}
Loading