From 77cb61f91bb8d4eee1f232e5b3023ecbf319c0b9 Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Tue, 10 Feb 2026 18:04:38 +0000 Subject: [PATCH] feat: add UpdateBrew, UpdateCup structs and expand UpdateBag Add domain update structs for brew and cup entities, add update() methods to their repository traits and SQL implementations, and expand UpdateBag with roast_id, roast_date, and amount fields. --- src/application/routes/api/coffee/bags.rs | 13 +--- src/domain/coffee/bags.rs | 8 +- src/domain/coffee/brews.rs | 26 +++++++ src/domain/coffee/cups.rs | 10 +++ src/domain/repositories.rs | 6 +- src/infrastructure/client/bags.rs | 1 + .../repositories/coffee/bags.rs | 10 +++ .../repositories/coffee/brews.rs | 73 ++++++++++++++++++- .../repositories/coffee/cups.rs | 40 +++++++++- tests/server/bags_api.rs | 2 +- tests/server/datastar.rs | 8 +- 11 files changed, 174 insertions(+), 23 deletions(-) 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