diff --git a/Cargo.lock b/Cargo.lock index e4918aaa..a305b799 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -824,6 +824,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -833,7 +845,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 6.0.0", "rand_core 0.10.1", "wasm-bindgen", ] @@ -1875,6 +1887,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -3168,6 +3186,7 @@ dependencies = [ "thiserror", "tokio", "tracing", + "uuid", ] [[package]] @@ -3188,6 +3207,7 @@ version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" dependencies = [ + "getrandom 0.3.4", "js-sys", "serde", "wasm-bindgen", @@ -3226,6 +3246,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasite" version = "0.1.0" @@ -3606,6 +3635,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "writeable" version = "0.6.1" diff --git a/Cargo.toml b/Cargo.toml index 51ca8e18..4007e3d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,4 +47,4 @@ tower-http = { version = "0.6.6", features = ["trace", "cors"] } tracing = "0.1.41" tracing-subscriber = { version = "0.3.20", features = ["env-filter", "fmt"] } usecase = { path = "crates/usecase" } -uuid = "1.18.1" +uuid = { version = "1.18.1", features = ["v4"] } diff --git a/crates/domain/src/repository/music.rs b/crates/domain/src/repository/music.rs index 8c2d2ee4..6c6f7c26 100644 --- a/crates/domain/src/repository/music.rs +++ b/crates/domain/src/repository/music.rs @@ -54,4 +54,14 @@ pub trait MusicRepository: Send + Sync { &self, music_id: &str, ) -> impl Future> + Send; + + fn insert_with_sheets( + &self, + music: MusicWithSheets, + ) -> impl Future> + Send; + + fn update_with_sheets( + &self, + music: MusicWithSheets, + ) -> impl Future> + Send; } diff --git a/crates/infrastructure/src/music/mod.rs b/crates/infrastructure/src/music/mod.rs index 73e379b3..825bc093 100644 --- a/crates/infrastructure/src/music/mod.rs +++ b/crates/infrastructure/src/music/mod.rs @@ -1,5 +1,7 @@ -mod adapter; mod read; +mod read_adapter; +mod write; +mod write_adapter; use std::sync::Arc; @@ -46,4 +48,20 @@ impl MusicRepository for MusicRepositoryImpl { debug!("Loading music metadata by id via SeaORM"); read::find_with_sheets(self.db.as_ref(), music_id).await } + + #[instrument(skip(self), fields(music_id = %music.music.id()))] + async fn insert_with_sheets( + &self, + music: MusicWithSheets, + ) -> Result { + write::insert_with_sheets(self.db.as_ref(), music).await + } + + #[instrument(skip(self), fields(music_id = %music.music.id()))] + async fn update_with_sheets( + &self, + music: MusicWithSheets, + ) -> Result { + write::update_with_sheets(self.db.as_ref(), music).await + } } diff --git a/crates/infrastructure/src/music/read.rs b/crates/infrastructure/src/music/read.rs index b7869e7e..1fa007f7 100644 --- a/crates/infrastructure/src/music/read.rs +++ b/crates/infrastructure/src/music/read.rs @@ -6,7 +6,7 @@ use domain::repository::music::{ use sea_orm::{ColumnTrait, Condition, DbConn, EntityTrait, QueryFilter, QueryOrder, QuerySelect}; use tracing::{debug, error}; -use super::adapter; +use super::read_adapter; use crate::entities; /// Collects every music alongside its sheets. @@ -28,8 +28,8 @@ pub async fn list_with_sheets(db: &DbConn) -> Result, Music let mut musics = Vec::with_capacity(models.len()); for (music_model, sheet_models) in models { - let music = adapter::convert_music(music_model)?; - let sheets = adapter::convert_sheets(sheet_models)?; + let music = read_adapter::convert_music(music_model)?; + let sheets = read_adapter::convert_sheets(sheet_models)?; musics.push(MusicWithSheets::new(music, sheets)); } @@ -88,8 +88,8 @@ pub async fn list_with_sheets_page( let mut items = Vec::with_capacity(models.len()); for (music_model, sheet_models) in models { - let music = adapter::convert_music(music_model)?; - let sheets = adapter::convert_sheets(sheet_models)?; + let music = read_adapter::convert_music(music_model)?; + let sheets = read_adapter::convert_sheets(sheet_models)?; items.push(MusicWithSheets::new(music, sheets)); } @@ -119,7 +119,7 @@ pub async fn find_with_sheets( }; Ok(MusicWithSheets::new( - adapter::convert_music(music_model)?, - adapter::convert_sheets(sheet_models)?, + read_adapter::convert_music(music_model)?, + read_adapter::convert_sheets(sheet_models)?, )) } diff --git a/crates/infrastructure/src/music/adapter.rs b/crates/infrastructure/src/music/read_adapter.rs similarity index 100% rename from crates/infrastructure/src/music/adapter.rs rename to crates/infrastructure/src/music/read_adapter.rs diff --git a/crates/infrastructure/src/music/write.rs b/crates/infrastructure/src/music/write.rs new file mode 100644 index 00000000..40a30fc7 --- /dev/null +++ b/crates/infrastructure/src/music/write.rs @@ -0,0 +1,89 @@ +use anyhow::Error as AnyError; +use domain::repository::music::{MusicRepositoryError, MusicWithSheets}; +use sea_orm::{ActiveModelTrait, ColumnTrait, DbConn, EntityTrait, QueryFilter, TransactionTrait}; +use tracing::error; + +use super::write_adapter::{ + music_active_model_for_insert, music_active_model_for_update, sheet_active_model_for_insert, + sheet_active_model_for_update, +}; +use crate::entities; + +pub async fn insert_with_sheets( + db: &DbConn, + music: MusicWithSheets, +) -> Result { + let txn = db.begin().await.map_err(internal)?; + let music_model = music_active_model_for_insert(&music.music)?; + let result = async { + music_model.insert(&txn).await.map_err(internal)?; + for sheet in &music.sheets { + sheet_active_model_for_insert(sheet)? + .insert(&txn) + .await + .map_err(internal)?; + } + Ok::<_, MusicRepositoryError>(()) + } + .await; + if let Err(error) = result { + let _ = txn.rollback().await; + return Err(error); + } + txn.commit().await.map_err(internal)?; + Ok(music) +} + +pub async fn update_with_sheets( + db: &DbConn, + music: MusicWithSheets, +) -> Result { + let txn = db.begin().await.map_err(internal)?; + let music_id = uuid::Uuid::parse_str(music.music.id()) + .map_err(|error| MusicRepositoryError::InternalError(AnyError::from(error)))?; + let result = async { + let existing = entities::sheets::Entity::find() + .filter(entities::sheets::Column::MusicId.eq(music_id)) + .all(&txn) + .await + .map_err(internal)?; + let existing_ids: std::collections::HashSet<_> = + existing.iter().map(|sheet| sheet.id).collect(); + let requested_ids: std::collections::HashSet<_> = music + .sheets + .iter() + .map(|sheet| { + uuid::Uuid::parse_str(sheet.id()) + .map_err(|error| MusicRepositoryError::InternalError(AnyError::from(error))) + }) + .collect::>()?; + if existing_ids != requested_ids || existing.len() != 3 { + return Err(MusicRepositoryError::InternalError(AnyError::msg( + "music must have exactly three existing sheets", + ))); + } + music_active_model_for_update(&music.music)? + .update(&txn) + .await + .map_err(internal)?; + for sheet in &music.sheets { + sheet_active_model_for_update(sheet)? + .update(&txn) + .await + .map_err(internal)?; + } + Ok::<_, MusicRepositoryError>(()) + } + .await; + if let Err(error) = result { + let _ = txn.rollback().await; + return Err(error); + } + txn.commit().await.map_err(internal)?; + Ok(music) +} + +fn internal(error: sea_orm::DbErr) -> MusicRepositoryError { + error!(error = %error, "Failed to write music metadata"); + MusicRepositoryError::InternalError(AnyError::from(error)) +} diff --git a/crates/infrastructure/src/music/write_adapter.rs b/crates/infrastructure/src/music/write_adapter.rs new file mode 100644 index 00000000..a386bcdd --- /dev/null +++ b/crates/infrastructure/src/music/write_adapter.rs @@ -0,0 +1,145 @@ +use anyhow::Error as AnyError; +use domain::{ + entity::{difficulty::Difficulty, music::Music, sheet::Sheet}, + repository::music::MusicRepositoryError, +}; +use sea_orm::{ + ActiveValue, + prelude::{Decimal, Uuid}, +}; + +use crate::entities::{ + musics::ActiveModel as MusicActiveModel, sea_orm_active_enums::Difficulty as DbDifficulty, + sheets::ActiveModel as SheetActiveModel, +}; + +pub fn music_active_model_for_insert( + music: &Music, +) -> Result { + music_active_model(music, ActiveValue::Set(parse_uuid(music.id())?)) +} + +pub fn music_active_model_for_update( + music: &Music, +) -> Result { + music_active_model(music, ActiveValue::Unchanged(parse_uuid(music.id())?)) +} + +fn music_active_model( + music: &Music, + id: ActiveValue, +) -> Result { + Ok(MusicActiveModel { + id, + title: ActiveValue::Set(music.title().to_owned()), + artist: ActiveValue::Set(music.artist().to_owned()), + bpm: ActiveValue::Set(decimal(*music.bpm())?), + genre: ActiveValue::Set(0), + jacket: ActiveValue::Set(music.jacket_image_url().to_owned()), + registration_date: ActiveValue::Set((*music.registration_date()).into()), + is_test: ActiveValue::Set(*music.is_test()), + }) +} + +pub fn sheet_active_model_for_insert( + sheet: &Sheet, +) -> Result { + sheet_active_model( + sheet, + ActiveValue::Set(parse_uuid(sheet.id())?), + ActiveValue::Set(parse_uuid(sheet.music_id())?), + ) +} + +pub fn sheet_active_model_for_update( + sheet: &Sheet, +) -> Result { + sheet_active_model( + sheet, + ActiveValue::Unchanged(parse_uuid(sheet.id())?), + ActiveValue::Unchanged(parse_uuid(sheet.music_id())?), + ) +} + +fn sheet_active_model( + sheet: &Sheet, + id: ActiveValue, + music_id: ActiveValue, +) -> Result { + let level = sheet.level().components(); + let difficulty = match sheet.difficulty() { + Difficulty::Easy => DbDifficulty::Easy, + Difficulty::Normal => DbDifficulty::Normal, + Difficulty::Hard => DbDifficulty::Hard, + }; + Ok(SheetActiveModel { + id, + music_id, + difficulty: ActiveValue::Set(difficulty), + level: ActiveValue::Set((level.0 * 10 + level.1) as i32), + notes_designer: ActiveValue::Set(sheet.notes_designer().to_owned()), + }) +} + +fn decimal(value: f32) -> Result { + value.to_string().parse().map_err(|error| { + MusicRepositoryError::InternalError(AnyError::msg(format!("invalid BPM: {error}"))) + }) +} + +fn parse_uuid(value: &str) -> Result { + Uuid::parse_str(value) + .map_err(|error| MusicRepositoryError::InternalError(AnyError::from(error))) +} + +#[cfg(test)] +mod tests { + use chrono::{TimeZone, Utc}; + use domain::entity::{difficulty::Difficulty, genre::Genre, level::Level}; + use sea_orm::ActiveValue; + + use super::*; + + fn music() -> Music { + Music::new( + "00000000-0000-0000-0000-000000000001".to_owned(), + "Song".to_owned(), + "Artist".to_owned(), + 135.5, + Genre::ORIGINAL, + "jacket.png".to_owned(), + Utc.with_ymd_and_hms(2025, 10, 1, 12, 0, 0).unwrap(), + false, + ) + } + + fn sheet() -> Sheet { + Sheet::new( + "00000000-0000-0000-0000-000000000002".to_owned(), + "00000000-0000-0000-0000-000000000001".to_owned(), + Difficulty::Hard, + Level::new(14, 7).unwrap(), + "Designer".to_owned(), + ) + } + + #[test] + fn insert_active_models_set_identity_fields() { + let music_model = music_active_model_for_insert(&music()).unwrap(); + let sheet_model = sheet_active_model_for_insert(&sheet()).unwrap(); + + assert!(matches!(music_model.id, ActiveValue::Set(_))); + assert!(matches!(sheet_model.id, ActiveValue::Set(_))); + assert!(matches!(sheet_model.music_id, ActiveValue::Set(_))); + } + + #[test] + fn update_active_models_keep_identity_fields_unchanged() { + let music_model = music_active_model_for_update(&music()).unwrap(); + let sheet_model = sheet_active_model_for_update(&sheet()).unwrap(); + + assert!(matches!(music_model.id, ActiveValue::Unchanged(_))); + assert!(matches!(sheet_model.id, ActiveValue::Unchanged(_))); + assert!(matches!(sheet_model.music_id, ActiveValue::Unchanged(_))); + } +} diff --git a/crates/presentation/src/error/convert.rs b/crates/presentation/src/error/convert.rs index c8553aca..dbdb10ce 100644 --- a/crates/presentation/src/error/convert.rs +++ b/crates/presentation/src/error/convert.rs @@ -86,6 +86,7 @@ impl From for AppError { fn from(error: MusicUsecaseError) -> Self { match error { MusicUsecaseError::MusicRepository(err) => err.into(), + MusicUsecaseError::InvalidInput(message) => AppError::bad_request(message), } } } diff --git a/crates/presentation/src/model/admin.rs b/crates/presentation/src/model/admin.rs new file mode 100644 index 00000000..90bfc454 --- /dev/null +++ b/crates/presentation/src/model/admin.rs @@ -0,0 +1,160 @@ +use chrono::{DateTime, Utc}; +use domain::entity::{difficulty::Difficulty, genre::Genre}; +use serde::{Deserialize, Serialize}; +use usecase::model::music::{ + CreateMusicInput, MusicDataInput, SheetDataInput, SheetInput, UpdateMusicInput, +}; + +use crate::{error::AppError, model::sync::SyncItemResponse}; + +#[derive(Deserialize)] +pub struct MusicListQuery { + pub cursor: Option, + pub limit: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MusicListResponse { + pub items: Vec, + pub next_cursor: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DbSynchronizationResponse { + pub updated_users: u64, + pub updated_ratings: u64, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MusicDataRequest { + pub title: String, + pub artist: String, + pub bpm: f32, + pub genre: String, + pub jacket: String, + pub registration_date: String, + pub is_test: bool, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateMusicRequest { + #[serde(flatten)] + pub music: MusicDataRequest, + pub sheets: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateMusicRequest { + #[serde(flatten)] + pub music: MusicDataRequest, + pub sheets: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SheetDataRequest { + pub difficulty: String, + pub level: f64, + pub notes_designer: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SheetRequest { + pub id: String, + pub difficulty: String, + pub level: f64, + pub notes_designer: String, +} + +impl TryFrom for MusicDataInput { + type Error = AppError; + + fn try_from(request: MusicDataRequest) -> Result { + let registration_date = DateTime::parse_from_rfc3339(&request.registration_date) + .map_err(|_| AppError::bad_request("registrationDate is invalid"))? + .with_timezone(&Utc); + let genre = match request.genre.as_str() { + "ORIGINAL" => Genre::ORIGINAL, + _ => return Err(AppError::bad_request("genre is invalid")), + }; + Ok(Self { + title: request.title, + artist: request.artist, + bpm: request.bpm, + genre, + jacket: request.jacket, + registration_date, + is_test: request.is_test, + }) + } +} + +fn parse_difficulty(value: &str) -> Result { + match value { + "easy" => Ok(Difficulty::Easy), + "normal" => Ok(Difficulty::Normal), + "hard" => Ok(Difficulty::Hard), + _ => Err(AppError::bad_request("difficulty is invalid")), + } +} + +impl TryFrom for SheetDataInput { + type Error = AppError; + + fn try_from(request: SheetDataRequest) -> Result { + Ok(Self { + difficulty: parse_difficulty(&request.difficulty)?, + level: request.level, + notes_designer: request.notes_designer, + }) + } +} + +impl TryFrom for SheetInput { + type Error = AppError; + + fn try_from(request: SheetRequest) -> Result { + Ok(Self { + id: request.id, + difficulty: parse_difficulty(&request.difficulty)?, + level: request.level, + notes_designer: request.notes_designer, + }) + } +} + +impl TryFrom for CreateMusicInput { + type Error = AppError; + + fn try_from(request: CreateMusicRequest) -> Result { + Ok(Self { + music: request.music.try_into()?, + sheets: request + .sheets + .into_iter() + .map(TryInto::try_into) + .collect::>()?, + }) + } +} + +impl TryFrom for UpdateMusicInput { + type Error = AppError; + + fn try_from(request: UpdateMusicRequest) -> Result { + Ok(Self { + music: request.music.try_into()?, + sheets: request + .sheets + .into_iter() + .map(TryInto::try_into) + .collect::>()?, + }) + } +} diff --git a/crates/presentation/src/model/mod.rs b/crates/presentation/src/model/mod.rs index 7b6be1dd..ab647e67 100644 --- a/crates/presentation/src/model/mod.rs +++ b/crates/presentation/src/model/mod.rs @@ -1,3 +1,4 @@ +pub mod admin; pub mod ranking; pub mod statistics; pub mod sync; diff --git a/crates/presentation/src/route/admin.rs b/crates/presentation/src/route/admin.rs index a1e52a01..6eb5528d 100644 --- a/crates/presentation/src/route/admin.rs +++ b/crates/presentation/src/route/admin.rs @@ -1,6 +1,7 @@ use axum::{ Json, extract::{Path, Query, State}, + http::StatusCode, }; use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use chrono::{DateTime, Utc}; @@ -8,37 +9,26 @@ use domain::repository::music::MusicListCursor; use serde::{Deserialize, Serialize}; use tracing::info; -use crate::{error::AppError, model::sync::SyncItemResponse}; +use crate::{ + error::AppError, + model::{ + admin::{ + CreateMusicRequest, DbSynchronizationResponse, MusicListQuery, MusicListResponse, + UpdateMusicRequest, + }, + sync::SyncItemResponse, + }, +}; const DEFAULT_PAGE_LIMIT: u64 = 50; const MAX_PAGE_LIMIT: u64 = 100; -#[derive(Deserialize)] -pub struct MusicListQuery { - pub cursor: Option, - pub limit: Option, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct MusicListResponse { - pub items: Vec, - pub next_cursor: Option, -} - #[derive(Deserialize, Serialize)] struct CursorPayload { registration_date: String, id: String, } -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct DbSynchronizationResponse { - pub updated_users: u64, - pub updated_ratings: u64, -} - pub async fn handle_list_musics( State(state): State, Query(query): Query, @@ -62,10 +52,34 @@ pub async fn handle_get_music( State(state): State, Path(music_id): Path, ) -> Result, AppError> { + if uuid::Uuid::parse_str(&music_id).is_err() { + return Err(AppError::bad_request("music id is invalid")); + } let music = state.usecases.music.find_by_id(music_id).await?; Ok(Json(SyncItemResponse::from(music))) } +pub async fn handle_create_music( + State(state): State, + Json(request): Json, +) -> Result<(StatusCode, Json), AppError> { + let music = state.usecases.music.create(request.try_into()?).await?; + Ok((StatusCode::CREATED, Json(SyncItemResponse::from(music)))) +} + +pub async fn handle_update_music( + State(state): State, + Path(music_id): Path, + Json(request): Json, +) -> Result, AppError> { + let music = state + .usecases + .music + .update(music_id, request.try_into()?) + .await?; + Ok(Json(SyncItemResponse::from(music))) +} + fn encode_cursor(cursor: MusicListCursor) -> Result { let payload = CursorPayload { registration_date: cursor.registration_date.to_rfc3339(), diff --git a/crates/presentation/src/route/mod.rs b/crates/presentation/src/route/mod.rs index a1b38da6..b378cef3 100644 --- a/crates/presentation/src/route/mod.rs +++ b/crates/presentation/src/route/mod.rs @@ -44,8 +44,14 @@ pub fn create_app(state: State, authenticator: Option) -> Router .route("/xp", get(ranking::handle_get_xp_ranking)); let health = Router::new().route("/", get(|| async { "OK" })); let admin_routes = Router::new() - .route("/musics", get(admin::handle_list_musics)) - .route("/musics/{musicId}", get(admin::handle_get_music)) + .route( + "/musics", + get(admin::handle_list_musics).post(admin::handle_create_music), + ) + .route( + "/musics/{musicId}", + get(admin::handle_get_music).post(admin::handle_update_music), + ) .route("/db/synchronize", post(admin::handle_db_synchronization)); let private_routes = Router::new() @@ -87,7 +93,7 @@ pub fn create_app(state: State, authenticator: Option) -> Router let cors = CorsLayer::new() .allow_origin(allowed_origin().parse::().unwrap()) .allow_methods([Method::GET, Method::POST, Method::OPTIONS]) - .allow_headers([header::CONTENT_TYPE]); + .allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE]); Router::new() .merge(private_routes) diff --git a/crates/usecase/Cargo.toml b/crates/usecase/Cargo.toml index 6c85af3c..97c3100b 100644 --- a/crates/usecase/Cargo.toml +++ b/crates/usecase/Cargo.toml @@ -9,6 +9,7 @@ chrono.workspace = true domain.workspace = true thiserror.workspace = true tracing.workspace = true +uuid.workspace = true [dev-dependencies] domain = { workspace = true, features = ["test-support"] } diff --git a/crates/usecase/src/model/music.rs b/crates/usecase/src/model/music.rs index 0024bcc2..2fffa796 100644 --- a/crates/usecase/src/model/music.rs +++ b/crates/usecase/src/model/music.rs @@ -1,6 +1,44 @@ use chrono::{DateTime, Utc}; use domain::entity::{difficulty::Difficulty, genre::Genre, music::Music, sheet::Sheet}; +#[derive(Debug)] +pub struct MusicDataInput { + pub title: String, + pub artist: String, + pub bpm: f32, + pub genre: Genre, + pub jacket: String, + pub registration_date: DateTime, + pub is_test: bool, +} + +#[derive(Debug)] +pub struct SheetDataInput { + pub difficulty: Difficulty, + pub level: f64, + pub notes_designer: String, +} + +#[derive(Debug)] +pub struct SheetInput { + pub id: String, + pub difficulty: Difficulty, + pub level: f64, + pub notes_designer: String, +} + +#[derive(Debug)] +pub struct CreateMusicInput { + pub music: MusicDataInput, + pub sheets: Vec, +} + +#[derive(Debug)] +pub struct UpdateMusicInput { + pub music: MusicDataInput, + pub sheets: Vec, +} + #[derive(Debug)] pub struct MusicDto { pub id: String, diff --git a/crates/usecase/src/music/mod.rs b/crates/usecase/src/music/mod.rs index cae4a2ce..7faf9854 100644 --- a/crates/usecase/src/music/mod.rs +++ b/crates/usecase/src/music/mod.rs @@ -2,16 +2,21 @@ use std::sync::Arc; use domain::repository::{ Repositories, - music::{MusicListCursor, MusicRepository, MusicRepositoryError, MusicWithSheets}, + music::{MusicListCursor, MusicRepositoryError, MusicWithSheets}, }; use thiserror::Error; use crate::model::music::MusicWithSheetsDto; +mod read; +mod write; + #[derive(Debug, Error)] pub enum MusicUsecaseError { #[error(transparent)] MusicRepository(#[from] MusicRepositoryError), + #[error("Invalid music input: {0}")] + InvalidInput(String), } pub struct MusicUsecase { @@ -22,39 +27,6 @@ impl MusicUsecase { pub fn new(repositories: Arc) -> Self { Self { repositories } } - - pub async fn list_all(&self) -> Result, MusicUsecaseError> { - let musics = self.repositories.music().list_with_sheets().await?; - Ok(musics.into_iter().map(MusicWithSheetsDto::from).collect()) - } - - pub async fn list_page( - &self, - cursor: Option, - limit: u64, - ) -> Result { - let page = self - .repositories - .music() - .list_with_sheets_page(cursor, limit) - .await?; - Ok(MusicPageDto { - items: page.items.into_iter().map(Into::into).collect(), - next_cursor: page.next_cursor, - }) - } - - pub async fn find_by_id( - &self, - music_id: String, - ) -> Result { - let music = self - .repositories - .music() - .find_with_sheets(&music_id) - .await?; - Ok(music.into()) - } } pub struct MusicPageDto { @@ -83,7 +55,7 @@ impl From for MusicWithSheetsDto { mod tests { use std::sync::Arc; - use chrono::Utc; + use chrono::{TimeZone, Utc}; use domain::{ entity::{difficulty::Difficulty, genre::Genre, level::Level, music::Music, sheet::Sheet}, repository::{ @@ -95,6 +67,7 @@ mod tests { }; use super::*; + use crate::model::music::{CreateMusicInput, MusicDataInput, SheetDataInput}; #[tokio::test] async fn list_all_returns_entries() { @@ -168,4 +141,77 @@ mod tests { assert_eq!(result.music.id, "music-1"); assert!(result.sheets.is_empty()); } + + fn write_input() -> CreateMusicInput { + CreateMusicInput { + music: MusicDataInput { + title: "Song".to_owned(), + artist: "Artist".to_owned(), + bpm: 135.5, + genre: Genre::ORIGINAL, + jacket: "jacket.png".to_owned(), + registration_date: Utc.with_ymd_and_hms(2025, 10, 1, 12, 0, 0).unwrap(), + is_test: false, + }, + sheets: vec![ + SheetDataInput { + difficulty: Difficulty::Easy, + level: 12.3, + notes_designer: "Easy Designer".to_owned(), + }, + SheetDataInput { + difficulty: Difficulty::Normal, + level: 13.0, + notes_designer: "Normal Designer".to_owned(), + }, + SheetDataInput { + difficulty: Difficulty::Hard, + level: 14.7, + notes_designer: "Hard Designer".to_owned(), + }, + ], + } + } + + #[tokio::test] + async fn create_generates_ids_for_music_and_sheets() { + let mut music_repo = MockMusicRepository::new(); + music_repo + .expect_insert_with_sheets() + .returning(|music| Box::pin(async move { Ok(music) })); + + let repositories = MockRepositories { + user: MockUserRepository::new(), + record: MockRecordRepository::new(), + music: music_repo, + }; + let usecase = MusicUsecase::new(Arc::new(repositories)); + + let result = usecase.create(write_input()).await.expect("should succeed"); + assert!(uuid::Uuid::parse_str(&result.music.id).is_ok()); + assert_eq!(result.sheets.len(), 3); + assert!( + result + .sheets + .iter() + .all(|sheet| uuid::Uuid::parse_str(&sheet.id).is_ok()) + ); + } + + #[tokio::test] + async fn create_rejects_duplicate_difficulties() { + let repositories = MockRepositories { + user: MockUserRepository::new(), + record: MockRecordRepository::new(), + music: MockMusicRepository::new(), + }; + let usecase = MusicUsecase::new(Arc::new(repositories)); + let mut input = write_input(); + input.sheets[1].difficulty = Difficulty::Easy; + + assert!(matches!( + usecase.create(input).await, + Err(MusicUsecaseError::InvalidInput(_)) + )); + } } diff --git a/crates/usecase/src/music/read.rs b/crates/usecase/src/music/read.rs new file mode 100644 index 00000000..4201c4fe --- /dev/null +++ b/crates/usecase/src/music/read.rs @@ -0,0 +1,42 @@ +use domain::repository::{ + Repositories, + music::{MusicListCursor, MusicRepository}, +}; + +use super::{MusicPageDto, MusicUsecase, MusicUsecaseError}; +use crate::model::music::MusicWithSheetsDto; + +impl MusicUsecase { + pub async fn list_all(&self) -> Result, MusicUsecaseError> { + let musics = self.repositories.music().list_with_sheets().await?; + Ok(musics.into_iter().map(MusicWithSheetsDto::from).collect()) + } + + pub async fn list_page( + &self, + cursor: Option, + limit: u64, + ) -> Result { + let page = self + .repositories + .music() + .list_with_sheets_page(cursor, limit) + .await?; + Ok(MusicPageDto { + items: page.items.into_iter().map(Into::into).collect(), + next_cursor: page.next_cursor, + }) + } + + pub async fn find_by_id( + &self, + music_id: String, + ) -> Result { + let music = self + .repositories + .music() + .find_with_sheets(&music_id) + .await?; + Ok(music.into()) + } +} diff --git a/crates/usecase/src/music/write.rs b/crates/usecase/src/music/write.rs new file mode 100644 index 00000000..f6cc5e30 --- /dev/null +++ b/crates/usecase/src/music/write.rs @@ -0,0 +1,208 @@ +use std::collections::HashSet; + +use domain::{ + entity::{difficulty::Difficulty, genre::Genre, level::Level, music::Music, sheet::Sheet}, + repository::{ + Repositories, + music::{MusicRepository, MusicWithSheets}, + }, +}; + +use super::{MusicUsecase, MusicUsecaseError}; +use crate::model::music::{ + CreateMusicInput, MusicDataInput, MusicWithSheetsDto, SheetDataInput, SheetInput, + UpdateMusicInput, +}; + +impl MusicUsecase { + pub async fn create( + &self, + input: CreateMusicInput, + ) -> Result { + let music = build_music( + input.music, + uuid::Uuid::new_v4().to_string(), + input.sheets.into_iter().map(Into::into).collect(), + None, + )?; + let created = self.repositories.music().insert_with_sheets(music).await?; + Ok(created.into()) + } + + pub async fn update( + &self, + music_id: String, + input: UpdateMusicInput, + ) -> Result { + if uuid::Uuid::parse_str(&music_id).is_err() { + return Err(MusicUsecaseError::InvalidInput( + "music id is invalid".to_owned(), + )); + } + let existing = self + .repositories + .music() + .find_with_sheets(&music_id) + .await?; + let existing_sheet_ids: HashSet<&str> = existing + .sheets + .iter() + .map(|sheet| sheet.id().as_str()) + .collect(); + let requested_sheet_ids: HashSet<&str> = + input.sheets.iter().map(|sheet| sheet.id.as_str()).collect(); + if existing_sheet_ids != requested_sheet_ids { + return Err(MusicUsecaseError::InvalidInput( + "sheet ids must match the existing sheets".to_owned(), + )); + } + let music = build_music( + input.music, + music_id, + input.sheets.into_iter().map(Into::into).collect(), + Some(existing), + )?; + let updated = self.repositories.music().update_with_sheets(music).await?; + Ok(updated.into()) + } +} + +fn build_music( + input: MusicDataInput, + music_id: String, + sheets_input: Vec, + existing: Option, +) -> Result { + if input.title.trim().is_empty() + || input.artist.trim().is_empty() + || input.jacket.trim().is_empty() + || !input.bpm.is_finite() + || input.bpm <= 0.0 + { + return Err(MusicUsecaseError::InvalidInput( + "title, artist, jacket, and bpm must be valid".to_owned(), + )); + } + if !matches!(input.genre, Genre::ORIGINAL) { + return Err(MusicUsecaseError::InvalidInput( + "genre is invalid".to_owned(), + )); + } + if sheets_input.len() != 3 { + return Err(MusicUsecaseError::InvalidInput( + "exactly one sheet for each difficulty is required".to_owned(), + )); + } + + let mut sheets = Vec::with_capacity(3); + let mut seen = [false; 3]; + for sheet in sheets_input { + let difficulty = match sheet.data.difficulty { + Difficulty::Easy => { + if seen[0] { + return invalid_sheet(); + } + seen[0] = true; + Difficulty::Easy + } + Difficulty::Normal => { + if seen[1] { + return invalid_sheet(); + } + seen[1] = true; + Difficulty::Normal + } + Difficulty::Hard => { + if seen[2] { + return invalid_sheet(); + } + seen[2] = true; + Difficulty::Hard + } + }; + let level = level_from_value(sheet.data.level)?; + let id = match (&existing, sheet.id) { + (None, None) => uuid::Uuid::new_v4().to_string(), + (Some(_), Some(id)) if uuid::Uuid::parse_str(&id).is_ok() => id, + _ => return invalid_sheet(), + }; + sheets.push(Sheet::new( + id, + music_id.clone(), + difficulty, + level, + non_empty(sheet.data.notes_designer, "notesDesigner")?, + )); + } + if seen != [true; 3] { + return invalid_sheet(); + } + Ok(MusicWithSheets::new( + Music::new( + music_id, + input.title, + input.artist, + input.bpm, + input.genre, + input.jacket, + input.registration_date, + input.is_test, + ), + sheets, + )) +} + +struct SheetBuildInput { + id: Option, + data: SheetDataInput, +} + +impl From for SheetBuildInput { + fn from(data: SheetDataInput) -> Self { + Self { id: None, data } + } +} + +impl From for SheetBuildInput { + fn from(value: SheetInput) -> Self { + Self { + id: Some(value.id), + data: SheetDataInput { + difficulty: value.difficulty, + level: value.level, + notes_designer: value.notes_designer, + }, + } + } +} + +fn invalid_sheet() -> Result { + Err(MusicUsecaseError::InvalidInput( + "sheets are invalid".to_owned(), + )) +} + +fn non_empty(value: String, field: &str) -> Result { + if value.trim().is_empty() { + return Err(MusicUsecaseError::InvalidInput(format!( + "{field} must not be empty" + ))); + } + Ok(value) +} + +fn level_from_value(value: f64) -> Result { + if !value.is_finite() || value < 1.0 || value > 99.9 { + return Err(MusicUsecaseError::InvalidInput( + "sheet level is invalid".to_owned(), + )); + } + let scaled = (value * 10.0).round(); + if (scaled / 10.0 - value).abs() > f64::EPSILON { + return Err(MusicUsecaseError::InvalidInput( + "sheet level is invalid".to_owned(), + )); + } + Level::new((scaled as u32) / 10, (scaled as u32) % 10) + .map_err(|_| MusicUsecaseError::InvalidInput("sheet level is invalid".to_owned())) +} diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 452a699d..81f7c322 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -144,6 +144,44 @@ paths: "500": description: Internal server error /admin/musics: + post: + tags: + - admin + summary: 楽曲と譜面を登録 + security: + - userAuth: [] + requestBody: + required: true + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/musicData" + - type: object + properties: + sheets: + type: array + minItems: 3 + maxItems: 3 + items: + $ref: "#/components/schemas/sheetData" + required: + - sheets + responses: + "201": + description: 登録された楽曲と譜面 + content: + application/json: + schema: + $ref: "#/components/schemas/musicWithSheets" + "400": + description: Bad request - Invalid music or sheet data + "401": + description: Unauthorized - Invalid access token + "403": + description: Forbidden - The authenticated user is not an administrator + "500": + description: Internal server error get: tags: - admin @@ -213,6 +251,54 @@ paths: description: Not found - Music not found "500": description: Internal server error + post: + tags: + - admin + summary: 楽曲と譜面を更新 + security: + - userAuth: [] + parameters: + - name: musicId + in: path + description: 楽曲のID + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/musicData" + - type: object + properties: + sheets: + type: array + minItems: 3 + maxItems: 3 + items: + $ref: "#/components/schemas/sheet" + required: + - sheets + responses: + "200": + description: 更新された楽曲と譜面 + content: + application/json: + schema: + $ref: "#/components/schemas/musicWithSheets" + "400": + description: Bad request - Invalid music or sheet data + "401": + description: Unauthorized - Invalid access token + "403": + description: Forbidden - The authenticated user is not an administrator + "404": + description: Not found - Music not found + "500": + description: Internal server error /users/{userId}/credits/increment: post: tags: @@ -654,12 +740,9 @@ components: - clearType - playCount - updatedAt - music: + musicData: type: object properties: - id: - type: string - description: 楽曲のID title: type: string description: タイトル @@ -672,6 +755,8 @@ components: description: BPM genre: type: string + enum: + - ORIGINAL description: ジャンル jacket: type: string @@ -679,12 +764,11 @@ components: registrationDate: type: string format: date-time - description: 楽曲追加日 + description: 運用上の楽曲登録日 isTest: type: boolean description: テスト楽曲かどうか required: - - id - title - artist - bpm @@ -692,15 +776,19 @@ components: - jacket - registrationDate - isTest - sheet: + music: + allOf: + - $ref: "#/components/schemas/musicData" + - type: object + properties: + id: + type: string + description: 楽曲のID + required: + - id + sheetData: type: object properties: - id: - type: string - description: 譜面のID - musicId: - type: string - description: 楽曲のID difficulty: type: string enum: @@ -715,11 +803,34 @@ components: type: string description: 譜面のノーツデザイナー required: - - id - - musicId - difficulty - level - notesDesigner + sheetWithMusicId: + allOf: + - $ref: "#/components/schemas/sheetData" + - type: object + properties: + id: + type: string + description: 譜面のID + musicId: + type: string + description: 楽曲のID + required: + - id + - musicId + sheet: + allOf: + - $ref: "#/components/schemas/sheetData" + - type: object + properties: + id: + type: string + format: uuid + description: 譜面のID + required: + - id musicWithSheets: type: object properties: @@ -728,7 +839,7 @@ components: sheets: type: array items: - $ref: "#/components/schemas/sheet" + $ref: "#/components/schemas/sheetWithMusicId" required: - music - sheets