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
1 change: 1 addition & 0 deletions crates/domain/src/entity/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ pub mod rating;
pub mod record;
pub mod sheet;
pub mod user;
pub mod user_play_option;
53 changes: 53 additions & 0 deletions crates/domain/src/entity/user_play_option.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
use chrono::{DateTime, Utc};
use getset::{Getters, Setters};

/// Domain representation of per-user play options.
#[derive(Debug, Clone, Getters, Setters)]
pub struct UserPlayOption {
#[getset(get = "pub")]
user_id: String,
#[getset(get = "pub", set = "pub")]
note_speed: f32,
#[getset(get = "pub", set = "pub")]
judgment_offset: i32,
#[getset(get = "pub", set = "pub")]
updated_at: DateTime<Utc>,
}

impl UserPlayOption {
#[allow(clippy::too_many_arguments)]
pub fn new(
user_id: String,
note_speed: f32,
judgment_offset: i32,
updated_at: DateTime<Utc>,
) -> Self {
Self {
user_id,
note_speed,
judgment_offset,
updated_at,
}
}

/// Builds play options with platform defaults.
pub fn new_with_defaults(user_id: String, updated_at: DateTime<Utc>) -> Self {
Self::new(user_id, 1.0, 0, updated_at)
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn new_with_defaults_uses_expected_values() {
let timestamp = chrono::Utc::now();
let options = UserPlayOption::new_with_defaults("user-id".to_owned(), timestamp);

assert_eq!(options.user_id(), "user-id");
assert!((options.note_speed() - 1.0).abs() < f32::EPSILON);
assert_eq!(*options.judgment_offset(), 0);
assert_eq!(*options.updated_at(), timestamp);
}
}
12 changes: 11 additions & 1 deletion crates/domain/src/repository/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use std::future::Future;
use mockall::automock;
use thiserror::Error;

use crate::entity::user::User;
use crate::entity::{user::User, user_play_option::UserPlayOption};

#[derive(Debug, Error)]
pub enum UserRepositoryError {
Expand Down Expand Up @@ -45,4 +45,14 @@ pub trait UserRepository: Send + Sync {
/// Sums the `credits` field across all user aggregates. Implementations must default to zero
/// when the table is empty to keep the operation idempotent for reporting workloads.
fn sum_credits(&self) -> impl Future<Output = Result<u64, UserRepositoryError>> + Send;

fn find_play_option(
&self,
user_id: &str,
) -> impl Future<Output = Result<Option<UserPlayOption>, UserRepositoryError>> + Send;

fn save_play_option(
&self,
option: UserPlayOption,
) -> impl Future<Output = Result<UserPlayOption, UserRepositoryError>> + Send;
}
1 change: 1 addition & 0 deletions crates/infrastructure/src/entities/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ pub mod musics;
pub mod records;
pub mod sea_orm_active_enums;
pub mod sheets;
pub mod user_play_options;
pub mod users;
2 changes: 1 addition & 1 deletion crates/infrastructure/src/entities/prelude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@

pub use super::{
musics::Entity as Musics, records::Entity as Records, sheets::Entity as Sheets,
users::Entity as Users,
user_play_options::Entity as UserPlayOptions, users::Entity as Users,
};
29 changes: 29 additions & 0 deletions crates/infrastructure/src/entities/user_play_options.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
use sea_orm::entity::prelude::*;

#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "user_play_options")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub user_id: Uuid,
pub note_speed: f64,
pub judgment_offset: i32,
pub updated_at: DateTimeWithTimeZone,
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::users::Entity",
from = "Column::UserId",
to = "super::users::Column::Id"
)]
Users,
}

impl Related<super::users::Entity> for Entity {
fn to() -> RelationDef {
Relation::Users.def()
}
}

impl ActiveModelBehavior for ActiveModel {}
1 change: 1 addition & 0 deletions crates/infrastructure/src/model/mod.rs
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
pub mod record;
pub mod user;
pub mod user_play_option;
52 changes: 52 additions & 0 deletions crates/infrastructure/src/model/user_play_option.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
use chrono::Utc;
use domain::{entity::user_play_option::UserPlayOption, repository::user::UserRepositoryError};
use sea_orm::{ActiveValue, prelude::Uuid};

use crate::entities::user_play_options::{
ActiveModel as UserPlayOptionActiveModel, Model as UserPlayOptionModel,
};

impl From<UserPlayOption> for UserPlayOptionModel {
fn from(option: UserPlayOption) -> Self {
let user_id = Uuid::parse_str(option.user_id()).unwrap_or_else(|_| Uuid::nil());
Self {
user_id,
note_speed: f64::from(*option.note_speed()),
judgment_offset: *option.judgment_offset(),
updated_at: (*option.updated_at()).into(),
}
}
}

impl std::convert::TryFrom<UserPlayOptionModel> for UserPlayOption {
type Error = UserRepositoryError;

fn try_from(model: UserPlayOptionModel) -> Result<Self, Self::Error> {
let user_id = model.user_id.to_string();
let note_speed = model.note_speed as f32;

Ok(Self::new(
user_id,
note_speed,
model.judgment_offset,
model.updated_at.with_timezone(&Utc),
))
}
}

impl From<UserPlayOption> for UserPlayOptionActiveModel {
fn from(option: UserPlayOption) -> Self {
let user_id = if option.user_id().is_empty() {
ActiveValue::NotSet
} else {
ActiveValue::Set(Uuid::parse_str(option.user_id()).unwrap_or_else(|_| Uuid::nil()))
};

UserPlayOptionActiveModel {
user_id,
note_speed: ActiveValue::Set(f64::from(*option.note_speed())),
judgment_offset: ActiveValue::Set(*option.judgment_offset()),
updated_at: ActiveValue::Set((*option.updated_at()).into()),
}
}
}
25 changes: 22 additions & 3 deletions crates/infrastructure/src/user/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,19 @@ mod write;
use std::sync::Arc;

use domain::{
entity::user::User,
entity::{user::User, user_play_option::UserPlayOption},
repository::user::{UserRepository, UserRepositoryError},
};
use read::{
count_all as query_count_users, find_by_card as query_by_card, find_by_id as query_by_id,
sum_credits as query_sum_credits,
find_play_option as query_play_option, sum_credits as query_sum_credits,
};
use sea_orm::DbConn;
use tracing::{debug, info, instrument};
use write::{create_user, increment_credits as mutate_increment_credits, save_user};
use write::{
create_user, increment_credits as mutate_increment_credits,
save_play_option as mutate_play_option, save_user,
};

pub struct UserRepositoryImpl {
db: Arc<DbConn>,
Expand Down Expand Up @@ -71,4 +74,20 @@ impl UserRepository for UserRepositoryImpl {
async fn sum_credits(&self) -> Result<u64, UserRepositoryError> {
query_sum_credits(self.db.as_ref()).await
}

#[instrument(skip(self), fields(user_id = %user_id))]
async fn find_play_option(
&self,
user_id: &str,
) -> Result<Option<UserPlayOption>, UserRepositoryError> {
query_play_option(self.db.as_ref(), user_id).await
}

#[instrument(skip(self, option), fields(user_id = %option.user_id()))]
async fn save_play_option(
&self,
option: UserPlayOption,
) -> Result<UserPlayOption, UserRepositoryError> {
mutate_play_option(self.db.as_ref(), option).await
}
}
32 changes: 31 additions & 1 deletion crates/infrastructure/src/user/read.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ use std::convert::TryFrom;

use anyhow::Error as AnyError;
use bigdecimal::{Signed, ToPrimitive};
use domain::{entity::user::User, repository::user::UserRepositoryError};
use domain::{
entity::{user::User, user_play_option::UserPlayOption},
repository::user::UserRepositoryError,
};
use sea_orm::{
ColumnTrait, DbConn, EntityTrait, PaginatorTrait, QueryFilter, QuerySelect,
sea_query::{Alias, Expr},
Expand Down Expand Up @@ -46,6 +49,33 @@ pub async fn find_by_id(db: &DbConn, user_id: &str) -> Result<Option<User>, User
model.map(User::try_from).transpose()
}

pub async fn find_play_option(
db: &DbConn,
user_id: &str,
) -> Result<Option<UserPlayOption>, UserRepositoryError> {
let uuid = parse_user_uuid(user_id)?;

debug!(user_id = %uuid, "Querying user play option via SeaORM");
let model = entities::user_play_options::Entity::find_by_id(uuid)
.one(db)
.await
.map_err(|err| {
error!(error = %err, user_id = %uuid, "Failed to query user play option");
UserRepositoryError::InternalError(AnyError::from(err))
})?;

match model {
Some(model) => {
info!(user_id = %uuid, "User play option fetched successfully");
UserPlayOption::try_from(model).map(Some)
}
None => {
debug!(user_id = %uuid, "User play option not found");
Ok(None)
}
}
}

pub async fn count_all(db: &DbConn) -> Result<u64, UserRepositoryError> {
debug!("Counting users via SeaORM");
let count = entities::users::Entity::find()
Expand Down
44 changes: 42 additions & 2 deletions crates/infrastructure/src/user/write.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
use std::convert::TryFrom;

use anyhow::Error as AnyError;
use domain::{entity::user::User, repository::user::UserRepositoryError};
use chrono::Utc;
use domain::{
entity::{user::User, user_play_option::UserPlayOption},
repository::user::UserRepositoryError,
};
use sea_orm::{
ActiveModelTrait, ActiveValue, ColumnTrait, DbConn, EntityTrait, QueryFilter, sea_query::Expr,
ActiveModelTrait, ActiveValue, ColumnTrait, DbConn, EntityTrait, QueryFilter,
sea_query::{Expr, OnConflict},
};
use tracing::{debug, error, info};

Expand Down Expand Up @@ -63,3 +68,38 @@ pub async fn save_user(db: &DbConn, user: User) -> Result<User, UserRepositoryEr
info!(user_id = %model.id, "User updated successfully");
User::try_from(model)
}

pub async fn save_play_option(
db: &DbConn,
mut option: UserPlayOption,
) -> Result<UserPlayOption, UserRepositoryError> {
let uuid = parse_user_uuid(option.user_id())?;
option.set_updated_at(Utc::now());

let mut active: entities::user_play_options::ActiveModel = option.into();
active.user_id = ActiveValue::Set(uuid);

let model = entities::user_play_options::Entity::insert(active)
.on_conflict(
OnConflict::column(entities::user_play_options::Column::UserId)
.update_columns([
entities::user_play_options::Column::NoteSpeed,
entities::user_play_options::Column::JudgmentOffset,
entities::user_play_options::Column::UpdatedAt,
])
.to_owned(),
)
.exec_with_returning(db)
.await
.map_err(|err| {
error!(
error = %err,
user_id = %uuid,
"Failed to persist user play option"
);
UserRepositoryError::InternalError(AnyError::from(err))
})?;

info!(user_id = %uuid, "User play option persisted successfully");
UserPlayOption::try_from(model)
}
2 changes: 2 additions & 0 deletions crates/migration/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ mod m20251007_000001_create_users_table;
mod m20251007_000002_create_musics_table;
mod m20251007_000003_create_sheets_table;
mod m20251007_000004_create_records_table;
mod m20251007_000005_create_user_play_options_table;

pub struct Migrator;

Expand All @@ -15,6 +16,7 @@ impl MigratorTrait for Migrator {
Box::new(m20251007_000002_create_musics_table::Migration),
Box::new(m20251007_000003_create_sheets_table::Migration),
Box::new(m20251007_000004_create_records_table::Migration),
Box::new(m20251007_000005_create_user_play_options_table::Migration),
]
}
}
Loading