diff --git a/src/application/routes/api/coffee/bags.rs b/src/application/routes/api/coffee/bags.rs index f7d3eff..9a2ebd0 100644 --- a/src/application/routes/api/coffee/bags.rs +++ b/src/application/routes/api/coffee/bags.rs @@ -132,17 +132,12 @@ pub(crate) async fn update_bag( ) -> Result { let (request, search) = query.into_request_and_search::(); - let body_update = payload.map_or( - UpdateBag { - remaining: None, - closed: None, - finished_at: None, - created_at: None, - }, - |Json(p)| p, - ); + let body_update = payload.map_or(UpdateBag::default(), |Json(p)| p); let update = UpdateBag { + roast_id: body_update.roast_id.or(update_params.roast_id), + roast_date: body_update.roast_date.or(update_params.roast_date), + amount: body_update.amount.or(update_params.amount), remaining: body_update.remaining.or(update_params.remaining), closed: body_update.closed.or(update_params.closed), finished_at: body_update.finished_at.or(update_params.finished_at), diff --git a/src/domain/coffee/bags.rs b/src/domain/coffee/bags.rs index 57c54b2..c6558b8 100644 --- a/src/domain/coffee/bags.rs +++ b/src/domain/coffee/bags.rs @@ -39,8 +39,14 @@ pub struct NewBag { pub created_at: Option>, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct UpdateBag { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub roast_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub roast_date: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub amount: Option, pub remaining: Option, pub closed: Option, pub finished_at: Option, diff --git a/src/domain/coffee/brews.rs b/src/domain/coffee/brews.rs index 5f92322..ac69de9 100644 --- a/src/domain/coffee/brews.rs +++ b/src/domain/coffee/brews.rs @@ -215,6 +215,32 @@ pub struct NewBrew { pub created_at: Option>, } +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct UpdateBrew { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bag_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub coffee_weight: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub grinder_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub grind_setting: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub brewer_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub filter_paper_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub water_volume: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub water_temp: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub quick_notes: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub brew_time: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub created_at: Option>, +} + /// Filter criteria for brew queries. #[derive(Debug, Default, Clone)] pub struct BrewFilter { diff --git a/src/domain/coffee/cups.rs b/src/domain/coffee/cups.rs index f7d51e9..a3338f1 100644 --- a/src/domain/coffee/cups.rs +++ b/src/domain/coffee/cups.rs @@ -65,6 +65,16 @@ pub struct NewCup { pub created_at: Option>, } +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct UpdateCup { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub roast_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cafe_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub created_at: Option>, +} + /// Filter criteria for cup queries. #[derive(Debug, Default, Clone)] pub struct CupFilter { diff --git a/src/domain/repositories.rs b/src/domain/repositories.rs index e4e14f3..18d331e 100644 --- a/src/domain/repositories.rs +++ b/src/domain/repositories.rs @@ -3,9 +3,9 @@ use crate::domain::ai_usage::{AiUsage, AiUsageSummary, NewAiUsage}; use crate::domain::listing::{ListRequest, Page, SortDirection, SortKey}; use crate::domain::bags::{Bag, BagFilter, BagSortKey, BagWithRoast, NewBag, UpdateBag}; -use crate::domain::brews::{Brew, BrewFilter, BrewSortKey, BrewWithDetails, NewBrew}; +use crate::domain::brews::{Brew, BrewFilter, BrewSortKey, BrewWithDetails, NewBrew, UpdateBrew}; use crate::domain::cafes::{Cafe, CafeSortKey, NewCafe, UpdateCafe}; -use crate::domain::cups::{Cup, CupFilter, CupSortKey, CupWithDetails, NewCup}; +use crate::domain::cups::{Cup, CupFilter, CupSortKey, CupWithDetails, NewCup, UpdateCup}; use crate::domain::gear::{Gear, GearFilter, GearSortKey, NewGear, UpdateGear}; use crate::domain::ids::{ BagId, BrewId, CafeId, CupId, GearId, PasskeyCredentialId, RegistrationTokenId, RoastId, @@ -178,6 +178,7 @@ pub trait BrewRepository: Send + Sync { request: &ListRequest, search: Option<&str>, ) -> Result, RepositoryError>; + async fn update(&self, id: BrewId, changes: UpdateBrew) -> Result; async fn delete(&self, id: BrewId) -> Result<(), RepositoryError>; } @@ -223,6 +224,7 @@ pub trait CupRepository: Send + Sync { request: &ListRequest, search: Option<&str>, ) -> Result, RepositoryError>; + async fn update(&self, id: CupId, changes: UpdateCup) -> Result; async fn delete(&self, id: CupId) -> Result<(), RepositoryError>; } diff --git a/src/infrastructure/client/bags.rs b/src/infrastructure/client/bags.rs index 826f460..4dcbd06 100644 --- a/src/infrastructure/client/bags.rs +++ b/src/infrastructure/client/bags.rs @@ -86,6 +86,7 @@ impl<'a> BagsClient<'a> { closed, finished_at, created_at, + ..Default::default() }; let response = self diff --git a/src/infrastructure/repositories/coffee/bags.rs b/src/infrastructure/repositories/coffee/bags.rs index 5625c53..56104af 100644 --- a/src/infrastructure/repositories/coffee/bags.rs +++ b/src/infrastructure/repositories/coffee/bags.rs @@ -206,6 +206,16 @@ impl BagRepository for SqlBagRepository { let mut builder = QueryBuilder::new("UPDATE bags SET updated_at = CURRENT_TIMESTAMP"); let mut sep = true; // Already have updated_at + push_update_field!( + builder, + sep, + "roast_id", + changes + .roast_id + .map(crate::domain::ids::RoastId::into_inner) + ); + push_update_field!(builder, sep, "roast_date", changes.roast_date); + push_update_field!(builder, sep, "amount", changes.amount); push_update_field!(builder, sep, "remaining", changes.remaining); push_update_field!(builder, sep, "closed", changes.closed); push_update_field!(builder, sep, "finished_at", changes.finished_at); diff --git a/src/infrastructure/repositories/coffee/brews.rs b/src/infrastructure/repositories/coffee/brews.rs index 8c777af..bbbff43 100644 --- a/src/infrastructure/repositories/coffee/brews.rs +++ b/src/infrastructure/repositories/coffee/brews.rs @@ -1,13 +1,16 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; -use sqlx::query_as; +use sqlx::{QueryBuilder, query_as}; use crate::domain::RepositoryError; -use crate::domain::brews::{Brew, BrewFilter, BrewSortKey, BrewWithDetails, NewBrew, QuickNote}; +use crate::domain::brews::{ + Brew, BrewFilter, BrewSortKey, BrewWithDetails, NewBrew, QuickNote, UpdateBrew, +}; use crate::domain::ids::{BagId, BrewId, GearId}; use crate::domain::listing::{ListRequest, Page, SortDirection}; use crate::domain::repositories::BrewRepository; use crate::infrastructure::database::DatabasePool; +use crate::infrastructure::repositories::macros::push_update_field; const BASE_SELECT: &str = r" SELECT @@ -273,6 +276,72 @@ impl BrewRepository for SqlBrewRepository { .await } + async fn update(&self, id: BrewId, changes: UpdateBrew) -> Result { + let mut builder = QueryBuilder::new("UPDATE brews SET updated_at = CURRENT_TIMESTAMP"); + let mut sep = true; + + push_update_field!( + builder, + sep, + "bag_id", + changes.bag_id.map(crate::domain::ids::BagId::into_inner) + ); + push_update_field!(builder, sep, "coffee_weight", changes.coffee_weight); + push_update_field!( + builder, + sep, + "grinder_id", + changes + .grinder_id + .map(crate::domain::ids::GearId::into_inner) + ); + push_update_field!(builder, sep, "grind_setting", changes.grind_setting); + push_update_field!( + builder, + sep, + "brewer_id", + changes + .brewer_id + .map(crate::domain::ids::GearId::into_inner) + ); + push_update_field!( + builder, + sep, + "filter_paper_id", + changes + .filter_paper_id + .map(crate::domain::ids::GearId::into_inner) + ); + push_update_field!(builder, sep, "water_volume", changes.water_volume); + push_update_field!(builder, sep, "water_temp", changes.water_temp); + if let Some(ref notes) = changes.quick_notes { + if sep { + builder.push(", "); + } + sep = true; + builder.push("quick_notes = "); + builder.push_bind(Self::encode_quick_notes(notes)); + } + push_update_field!(builder, sep, "brew_time", changes.brew_time); + push_update_field!(builder, sep, "created_at", changes.created_at); + let _ = sep; + + builder.push(" WHERE id = "); + builder.push_bind(id.into_inner()); + builder.push( + " RETURNING id, bag_id, coffee_weight, grinder_id, grind_setting, brewer_id, filter_paper_id, water_volume, water_temp, quick_notes, brew_time, created_at, updated_at", + ); + + let record = builder + .build_query_as::() + .fetch_optional(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))? + .ok_or(RepositoryError::NotFound)?; + + Ok(Self::to_domain(record)) + } + async fn delete(&self, id: BrewId) -> Result<(), RepositoryError> { let query = "DELETE FROM brews WHERE id = ?"; diff --git a/src/infrastructure/repositories/coffee/cups.rs b/src/infrastructure/repositories/coffee/cups.rs index 69c81fb..4a94b73 100644 --- a/src/infrastructure/repositories/coffee/cups.rs +++ b/src/infrastructure/repositories/coffee/cups.rs @@ -1,13 +1,14 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; -use sqlx::{query, query_as}; +use sqlx::{QueryBuilder, query, query_as}; use crate::domain::RepositoryError; -use crate::domain::cups::{Cup, CupFilter, CupSortKey, CupWithDetails, NewCup}; +use crate::domain::cups::{Cup, CupFilter, CupSortKey, CupWithDetails, NewCup, UpdateCup}; use crate::domain::ids::{CafeId, CupId, RoastId}; use crate::domain::listing::{ListRequest, Page, SortDirection}; use crate::domain::repositories::CupRepository; use crate::infrastructure::database::DatabasePool; +use crate::infrastructure::repositories::macros::push_update_field; const BASE_SELECT: &str = r" SELECT @@ -193,6 +194,41 @@ impl CupRepository for SqlCupRepository { .await } + async fn update(&self, id: CupId, changes: UpdateCup) -> Result { + let mut builder = QueryBuilder::new("UPDATE cups SET updated_at = CURRENT_TIMESTAMP"); + let mut sep = true; + + push_update_field!( + builder, + sep, + "roast_id", + changes + .roast_id + .map(crate::domain::ids::RoastId::into_inner) + ); + push_update_field!( + builder, + sep, + "cafe_id", + changes.cafe_id.map(crate::domain::ids::CafeId::into_inner) + ); + push_update_field!(builder, sep, "created_at", changes.created_at); + let _ = sep; + + builder.push(" WHERE id = "); + builder.push_bind(i64::from(id)); + builder.push(" RETURNING id, roast_id, cafe_id, created_at, updated_at"); + + let record = builder + .build_query_as::() + .fetch_optional(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))? + .ok_or(RepositoryError::NotFound)?; + + Ok(Self::to_domain(record)) + } + async fn delete(&self, id: CupId) -> Result<(), RepositoryError> { let result = query("DELETE FROM cups WHERE id = ?") .bind(i64::from(id)) diff --git a/tests/server/bags_api.rs b/tests/server/bags_api.rs index a28015b..a661d6f 100644 --- a/tests/server/bags_api.rs +++ b/tests/server/bags_api.rs @@ -179,7 +179,7 @@ async fn updating_a_bag_returns_200_and_updates_data() { remaining: Some(100.0), closed: Some(true), finished_at: Some(NaiveDate::from_ymd_opt(2023, 2, 1).unwrap()), - created_at: None, + ..Default::default() }; // Act diff --git a/tests/server/datastar.rs b/tests/server/datastar.rs index fa0729e..1ae1ca6 100644 --- a/tests/server/datastar.rs +++ b/tests/server/datastar.rs @@ -306,9 +306,7 @@ async fn bags_update_with_datastar_header_returns_fragment() { let update = UpdateBag { remaining: Some(100.0), - closed: None, - finished_at: None, - created_at: None, + ..Default::default() }; let response = client @@ -338,9 +336,7 @@ async fn bags_update_without_datastar_header_returns_json() { let update = UpdateBag { remaining: Some(100.0), - closed: None, - finished_at: None, - created_at: None, + ..Default::default() }; let response = client