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
10 changes: 5 additions & 5 deletions crates/domain/src/entity/record.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use chrono::NaiveDateTime;
use chrono::{DateTime, Utc};
use getset::{Getters, Setters};

use super::clear_type::ClearType;
Expand All @@ -18,7 +18,7 @@ pub struct Record {
#[getset(get = "pub", set = "pub")]
play_count: u32,
#[getset(get = "pub", set = "pub")]
updated_at: NaiveDateTime,
updated_at: DateTime<Utc>,
}

#[allow(clippy::too_many_arguments)]
Expand All @@ -30,7 +30,7 @@ impl Record {
score: u32,
clear_type: ClearType,
play_count: u32,
updated_at: NaiveDateTime,
updated_at: DateTime<Utc>,
) -> Self {
Self {
id,
Expand All @@ -48,7 +48,7 @@ impl Record {
sheet_id: String,
score: u32,
clear_type: ClearType,
updated_at: NaiveDateTime,
updated_at: DateTime<Utc>,
) -> Self {
Self::new(
String::new(),
Expand All @@ -65,7 +65,7 @@ impl Record {
&mut self,
score: u32,
clear_type: ClearType,
updated_at: NaiveDateTime,
updated_at: DateTime<Utc>,
) {
self.set_play_count(self.play_count() + 1);
if score > *self.score() {
Expand Down
11 changes: 6 additions & 5 deletions crates/domain/src/entity/user.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use chrono::NaiveDateTime;
use chrono::{DateTime, Utc};
use getset::{Getters, Setters};

use super::rating::Rating;
Expand All @@ -20,7 +20,7 @@ pub struct User {
#[getset(get = "pub")]
is_admin: bool,
#[getset(get = "pub")]
created_at: NaiveDateTime,
created_at: DateTime<Utc>,
}

#[allow(clippy::too_many_arguments)]
Expand All @@ -33,7 +33,7 @@ impl User {
xp: u32,
credits: u32,
is_admin: bool,
created_at: NaiveDateTime,
created_at: DateTime<Utc>,
) -> Self {
Self {
id,
Expand All @@ -56,7 +56,7 @@ impl User {
xp: 0,
credits: 0,
is_admin: false,
created_at: chrono::Utc::now().naive_utc(),
created_at: chrono::Utc::now(),
}
}

Expand Down Expand Up @@ -95,7 +95,8 @@ mod tests {
let timestamp = chrono::NaiveDate::from_ymd_opt(2025, 10, 21)
.unwrap()
.and_hms_opt(8, 30, 0)
.unwrap();
.unwrap()
.and_utc();

let user = User::new(
"user-id".to_owned(),
Expand Down
9 changes: 9 additions & 0 deletions crates/domain/src/repository/record.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@ pub trait RecordRepository: Send + Sync {
user_id: &str,
) -> impl Future<Output = Result<Vec<RecordWithMetadata>, RecordRepositoryError>> + Send;

/// Loads records for the specified user and sheet IDs. More efficient than loading all records
/// when only a subset is needed. Returns an empty vector if none of the specified sheet IDs
/// have records.
fn find_by_user_id_and_sheet_ids(
&self,
user_id: &str,
sheet_ids: &[String],
) -> impl Future<Output = Result<Vec<Record>, RecordRepositoryError>> + Send;

/// Persists a new record aggregate. Callers must guarantee that the record identifier is
/// unique; the repository will generate an error if the tuple `(user_id, sheet_id)` already
/// exists.
Expand Down
4 changes: 2 additions & 2 deletions crates/domain/src/service/rating.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ pub fn calculate_user_rating(records: &[RecordWithMetadata]) -> Rating {
let mut values: Vec<u32> = records
.iter()
.filter(|entry| !entry.is_test)
.map(|entry| calculate_single_track_rating(&entry.level, *entry.record.score()))
.map(|entry| calculate_sheet_rating(&entry.level, *entry.record.score()))
.collect();

if values.is_empty() {
Expand All @@ -20,7 +20,7 @@ pub fn calculate_user_rating(records: &[RecordWithMetadata]) -> Rating {
Rating::new(total / count as u32)
}

fn calculate_single_track_rating(level: &Level, score: u32) -> u32 {
fn calculate_sheet_rating(level: &Level, score: u32) -> u32 {
let (integer, decimal) = level.components();
let base = integer * 100 + decimal * 10;
let bonus = compute_score_bonus(score);
Expand Down
18 changes: 10 additions & 8 deletions crates/domain/src/testing/datetime.rs
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
use chrono::{DateTime, Utc};

/// Returns a reproducible timestamp for fixtures. Implicitly depends on `chrono` being available in
/// the consuming crate.
pub fn sample_timestamp() -> NaiveDateTime {
pub fn sample_timestamp() -> DateTime<Utc> {
timestamp(2025, 10, 21, 12, 0, 0)
}

/// Returns a timestamp slightly ahead of [`sample_timestamp`] for scenarios needing variation.
pub fn later_timestamp() -> NaiveDateTime {
pub fn later_timestamp() -> DateTime<Utc> {
timestamp(2025, 10, 21, 12, 30, 0)
}

/// Constructs a `NaiveDateTime` from the provided components, panicking if they form an invalid
/// Constructs a `DateTime<Utc>` from the provided components, panicking if they form an invalid
/// combination.
pub fn timestamp(
year: i32,
Expand All @@ -20,8 +20,10 @@ pub fn timestamp(
hour: u32,
minute: u32,
second: u32,
) -> NaiveDateTime {
let date = NaiveDate::from_ymd_opt(year, month, day).expect("invalid date for fixture");
let time = NaiveTime::from_hms_opt(hour, minute, second).expect("invalid time for fixture");
NaiveDateTime::new(date, time)
) -> DateTime<Utc> {
chrono::NaiveDate::from_ymd_opt(year, month, day)
.expect("invalid date for fixture")
.and_hms_opt(hour, minute, second)
.expect("invalid time for fixture")
.and_utc()
}
8 changes: 4 additions & 4 deletions crates/domain/src/testing/user.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use chrono::NaiveDateTime;
use chrono::{DateTime, Utc};

use crate::entity::{rating::Rating, user::User};

Expand All @@ -15,7 +15,7 @@ pub struct UserSample {

impl UserSample {
/// Builds a `User` aggregate based on this sample and the supplied metadata.
pub fn build(&self, created_at: NaiveDateTime, is_admin: bool) -> User {
pub fn build(&self, created_at: DateTime<Utc>, is_admin: bool) -> User {
User::new(
self.id.to_owned(),
self.card.to_owned(),
Expand Down Expand Up @@ -56,10 +56,10 @@ pub const USER3: UserSample = UserSample {
credits: 456,
};

pub fn created_at1() -> NaiveDateTime {
pub fn created_at1() -> DateTime<Utc> {
sample_timestamp()
}

pub fn created_at2() -> NaiveDateTime {
pub fn created_at2() -> DateTime<Utc> {
later_timestamp()
}
4 changes: 4 additions & 0 deletions crates/infrastructure/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ impl RepositoriesImpl {
}

/// Initializes the SeaORM connection. Implicitly depends on a tracing subscriber already being set up so logging can emit.
///
/// # Panics
/// Panics if database connection fails. This is appropriate for startup as the application
/// cannot function without a database connection.
#[instrument(name = "infrastructure.repositories.new_default", skip(db_url))]
pub async fn new_default(db_url: &str) -> Self {
info!("Connecting to database via SeaORM");
Expand Down
31 changes: 23 additions & 8 deletions crates/infrastructure/src/model/record.rs
Original file line number Diff line number Diff line change
@@ -1,28 +1,43 @@
use chrono::NaiveDateTime;
use anyhow::Error as AnyError;
use domain::entity::{clear_type::ClearType as DomainClearType, record::Record};
use domain::repository::record::RecordRepositoryError;
use std::convert::TryFrom;

use crate::entities::{
records::Model as RecordModel, sea_orm_active_enums::ClearType as DbClearType,
};

impl From<RecordModel> for Record {
fn from(model: RecordModel) -> Self {
/// Converts database record model to domain entity.
///
/// # Errors
/// Returns `InternalError` if the database contains invalid data (negative score or play_count).
/// This conversion assumes database integrity constraints ensure valid data.
impl TryFrom<RecordModel> for Record {
type Error = RecordRepositoryError;

fn try_from(model: RecordModel) -> Result<Self, Self::Error> {
let id = model.id.to_string();
let user_id = model.user_id.to_string();
let sheet_id = model.sheet_id.to_string();
let score = u32::try_from(model.score).expect("score must be non-negative");
let play_count = u32::try_from(model.play_count).expect("play_count must be non-negative");
let updated_at: NaiveDateTime = model.updated_at.naive_utc();
let score = u32::try_from(model.score).map_err(|err| {
tracing::warn!(error = %err, value = model.score, "Score from database must be non-negative");
RecordRepositoryError::InternalError(AnyError::from(err))
})?;
let play_count = u32::try_from(model.play_count).map_err(|err| {
tracing::warn!(error = %err, value = model.play_count, "Play count from database must be non-negative");
RecordRepositoryError::InternalError(AnyError::from(err))
})?;
let updated_at = model.updated_at.with_timezone(&chrono::Utc);

Record::new(
Ok(Record::new(
id,
user_id,
sheet_id,
score,
model.clear_type.into(),
play_count,
updated_at,
)
))
}
}

Expand Down
59 changes: 39 additions & 20 deletions crates/infrastructure/src/model/user.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
use chrono::{NaiveDateTime, TimeZone, Utc};
use anyhow::Error as AnyError;
use chrono::Utc;
use sea_orm::prelude::Uuid;
use std::convert::TryFrom;

use domain::entity::rating::Rating;
use domain::entity::user::User;
use domain::repository::user::UserRepositoryError;

use crate::entities::users::ActiveModel as UserActiveModel;
use crate::entities::users::Model as UserModel;
use sea_orm::ActiveValue;

/// Converts domain user entity to database model.
///
/// # Panics
/// Panics if rating value exceeds i32 range. This should never happen in practice
/// as rating values are constrained by business logic (typically under 10,000).
impl From<User> for UserModel {
fn from(domain_user: User) -> Self {
let id = Uuid::parse_str(domain_user.id()).unwrap_or_else(|_| Uuid::nil());
Expand All @@ -21,30 +27,48 @@ impl From<User> for UserModel {
xp: domain_user.xp().to_owned() as i64,
credits: domain_user.credits().to_owned() as i64,
is_admin: *domain_user.is_admin(),
created_at: Utc.from_utc_datetime(domain_user.created_at()).into(),
created_at: (*domain_user.created_at()).into(),
updated_at: Utc::now().into(),
}
}
}

impl From<UserModel> for User {
fn from(db_user: UserModel) -> Self {
/// Converts database user model to domain entity.
///
/// # Errors
/// Returns `InternalError` if the database contains invalid data (negative rating).
/// This conversion assumes database integrity constraints ensure valid data.
impl std::convert::TryFrom<UserModel> for User {
type Error = UserRepositoryError;

fn try_from(db_user: UserModel) -> Result<Self, Self::Error> {
let id = db_user.id.to_string();
let created_at: NaiveDateTime = db_user.created_at.naive_utc();
let created_at = db_user.created_at.with_timezone(&chrono::Utc);

let rating_value = u32::try_from(db_user.rating).map_err(|err| {
tracing::warn!(error = %err, value = db_user.rating, "Rating from database must be non-negative");
UserRepositoryError::InternalError(AnyError::from(err))
})?;
let rating = Rating::new(rating_value);

Self::new(
Ok(Self::new(
id,
db_user.card,
db_user.display_name,
Rating::new(u32::try_from(db_user.rating).expect("rating must be non-negative")),
rating,
db_user.xp as u32,
db_user.credits as u32,
db_user.is_admin,
created_at,
)
))
}
}

/// Converts domain user entity to database active model for updates.
///
/// # Panics
/// Panics if rating value exceeds i32 range. This should never happen in practice
/// as rating values are constrained by business logic (typically under 10,000).
impl From<User> for UserActiveModel {
fn from(domain_user: User) -> Self {
// when inserting/updating via ActiveModel we prefer to set fields explicitly
Expand All @@ -57,7 +81,7 @@ impl From<User> for UserActiveModel {
let db_user_created_at = if domain_user.id().is_empty() {
ActiveValue::NotSet
} else {
ActiveValue::Set(Utc.from_utc_datetime(domain_user.created_at()).into())
ActiveValue::Set((*domain_user.created_at()).into())
};

UserActiveModel {
Expand All @@ -80,11 +104,7 @@ impl From<User> for UserActiveModel {
mod tests {
use super::*;
use crate::entities::users::Model as RawUserModel;
use chrono::TimeZone;
use domain::testing::{
datetime::later_timestamp,
user::{USER2, USER3, created_at1},
};
use domain::testing::user::{USER2, USER3, created_at1};
use sea_orm::prelude::Uuid;

#[test]
Expand All @@ -101,13 +121,12 @@ mod tests {
assert_eq!(model.xp, USER2.xp as i64);
assert_eq!(model.credits, USER2.credits as i64);
assert!(model.is_admin);
assert_eq!(model.created_at.naive_utc(), created_at);
assert_eq!(model.created_at, created_at);
}

#[test]
fn domain_user_from_model_preserves_scalar_fields() {
let created_at_naive = later_timestamp();
let created_at = chrono::Utc.from_utc_datetime(&created_at_naive);
let created_at = chrono::Utc::now();
let model = RawUserModel {
id: Uuid::parse_str(USER3.id).unwrap(),
card: USER3.card.to_owned(),
Expand All @@ -120,7 +139,7 @@ mod tests {
updated_at: created_at.into(),
};

let user: User = model.clone().into();
let user: User = User::try_from(model.clone()).unwrap();

let model_id = model.id.to_string();
assert_eq!(user.id(), &model_id);
Expand All @@ -130,7 +149,7 @@ mod tests {
assert_eq!(*user.xp(), USER3.xp);
assert_eq!(*user.credits(), USER3.credits);
assert!(!user.is_admin());
assert_eq!(*user.created_at(), created_at_naive);
assert_eq!(*user.created_at(), created_at);
}

#[test]
Expand Down
Loading