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.
This commit is contained in:
Jon Seager 2026-02-10 18:04:38 +00:00
parent fbf5a288c1
commit 77cb61f91b
No known key found for this signature in database
11 changed files with 174 additions and 23 deletions

View file

@ -132,17 +132,12 @@ pub(crate) async fn update_bag(
) -> Result<Response, ApiError> {
let (request, search) = query.into_request_and_search::<BagSortKey>();
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),

View file

@ -39,8 +39,14 @@ pub struct NewBag {
pub created_at: Option<DateTime<Utc>>,
}
#[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<RoastId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub roast_date: Option<NaiveDate>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub amount: Option<f64>,
pub remaining: Option<f64>,
pub closed: Option<bool>,
pub finished_at: Option<NaiveDate>,

View file

@ -215,6 +215,32 @@ pub struct NewBrew {
pub created_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct UpdateBrew {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bag_id: Option<BagId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub coffee_weight: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub grinder_id: Option<GearId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub grind_setting: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub brewer_id: Option<GearId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub filter_paper_id: Option<GearId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub water_volume: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub water_temp: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub quick_notes: Option<Vec<QuickNote>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub brew_time: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at: Option<DateTime<Utc>>,
}
/// Filter criteria for brew queries.
#[derive(Debug, Default, Clone)]
pub struct BrewFilter {

View file

@ -65,6 +65,16 @@ pub struct NewCup {
pub created_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct UpdateCup {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub roast_id: Option<RoastId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cafe_id: Option<CafeId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at: Option<DateTime<Utc>>,
}
/// Filter criteria for cup queries.
#[derive(Debug, Default, Clone)]
pub struct CupFilter {

View file

@ -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<BrewSortKey>,
search: Option<&str>,
) -> Result<Page<BrewWithDetails>, RepositoryError>;
async fn update(&self, id: BrewId, changes: UpdateBrew) -> Result<Brew, RepositoryError>;
async fn delete(&self, id: BrewId) -> Result<(), RepositoryError>;
}
@ -223,6 +224,7 @@ pub trait CupRepository: Send + Sync {
request: &ListRequest<CupSortKey>,
search: Option<&str>,
) -> Result<Page<CupWithDetails>, RepositoryError>;
async fn update(&self, id: CupId, changes: UpdateCup) -> Result<Cup, RepositoryError>;
async fn delete(&self, id: CupId) -> Result<(), RepositoryError>;
}

View file

@ -86,6 +86,7 @@ impl<'a> BagsClient<'a> {
closed,
finished_at,
created_at,
..Default::default()
};
let response = self

View file

@ -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);

View file

@ -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<Brew, RepositoryError> {
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::<BrewRecord>()
.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 = ?";

View file

@ -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<Cup, RepositoryError> {
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::<CupRecord>()
.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))

View file

@ -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

View file

@ -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