feat: add update endpoints for brews and cups

Add UpdateBrewSubmission and UpdateCupSubmission types with image
support. Register PUT handlers on /brews/{id} and /cups/{id} with
three-way response pattern (Datastar/form/JSON).
This commit is contained in:
Jon Seager 2026-02-10 18:13:39 +00:00
parent bc449e520e
commit 1c029f52f4
No known key found for this signature in database
3 changed files with 197 additions and 7 deletions

View file

@ -1,5 +1,5 @@
use axum::Json;
use axum::extract::{Query, State};
use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Redirect, Response};
use chrono::{DateTime, Utc};
@ -15,7 +15,9 @@ use crate::application::routes::support::{
};
use crate::application::state::AppState;
use crate::domain::bags::BagFilter;
use crate::domain::brews::{BrewFilter, BrewSortKey, BrewWithDetails, NewBrew, QuickNote};
use crate::domain::brews::{
BrewFilter, BrewSortKey, BrewWithDetails, NewBrew, QuickNote, UpdateBrew,
};
use crate::domain::gear::{GearCategory, GearFilter, GearSortKey};
use crate::domain::ids::{BagId, BrewId, GearId};
use crate::domain::listing::{ListRequest, PageSize, SortDirection};
@ -322,6 +324,114 @@ define_enriched_get_handler!(
get_with_details
);
#[derive(Debug, Deserialize)]
pub(crate) struct UpdateBrewSubmission {
#[serde(default)]
bag_id: Option<BagId>,
#[serde(default)]
coffee_weight: Option<f64>,
#[serde(default, deserialize_with = "deserialize_optional_gear_id")]
grinder_id: Option<GearId>,
#[serde(default)]
grind_setting: Option<f64>,
#[serde(default, deserialize_with = "deserialize_optional_gear_id")]
brewer_id: Option<GearId>,
#[serde(default, deserialize_with = "deserialize_optional_gear_id")]
filter_paper_id: Option<GearId>,
#[serde(default)]
water_volume: Option<i32>,
#[serde(default)]
water_temp: Option<f64>,
#[serde(default, deserialize_with = "deserialize_quick_notes")]
quick_notes: Vec<QuickNote>,
#[serde(default)]
brew_time: Option<i32>,
#[serde(default)]
created_at: Option<DateTime<Utc>>,
#[serde(default)]
image: Option<String>,
}
impl UpdateBrewSubmission {
fn into_parts(self) -> (UpdateBrew, Option<String>) {
let update = UpdateBrew {
bag_id: self.bag_id,
coffee_weight: self.coffee_weight,
grinder_id: self.grinder_id,
grind_setting: self.grind_setting,
brewer_id: self.brewer_id,
filter_paper_id: self.filter_paper_id,
water_volume: self.water_volume,
water_temp: self.water_temp,
quick_notes: if self.quick_notes.is_empty() {
None
} else {
Some(self.quick_notes)
},
brew_time: self.brew_time,
created_at: self.created_at,
};
(update, self.image)
}
}
#[tracing::instrument(skip(state, _auth_user, headers))]
pub(crate) async fn update_brew(
State(state): State<AppState>,
_auth_user: AuthenticatedUser,
headers: HeaderMap,
Path(id): Path<BrewId>,
payload: FlexiblePayload<UpdateBrewSubmission>,
) -> Result<Response, ApiError> {
let (submission, source) = payload.into_parts();
let (update, image_data_url) = submission.into_parts();
let has_changes = update.bag_id.is_some()
|| update.coffee_weight.is_some()
|| update.grinder_id.is_some()
|| update.grind_setting.is_some()
|| update.brewer_id.is_some()
|| update.filter_paper_id.is_some()
|| update.water_volume.is_some()
|| update.water_temp.is_some()
|| update.quick_notes.is_some()
|| update.brew_time.is_some()
|| update.created_at.is_some()
|| image_data_url.is_some();
if !has_changes {
return Err(AppError::validation("no changes provided").into());
}
state
.brew_repo
.update(id, update)
.await
.map_err(AppError::from)?;
info!(%id, "brew updated");
state.stats_invalidator.invalidate();
save_deferred_image(&state, "brew", i64::from(id), image_data_url.as_deref()).await;
let enriched = state
.brew_repo
.get_with_details(id)
.await
.map_err(AppError::from)?;
let detail_url = format!("/brews/{id}");
if is_datastar_request(&headers) {
crate::application::routes::support::render_redirect_script(&detail_url)
.map_err(ApiError::from)
} else if matches!(source, PayloadSource::Form) {
Ok(Redirect::to(&detail_url).into_response())
} else {
Ok(Json(enriched).into_response())
}
}
define_delete_handler!(
delete_brew,
BrewId,

View file

@ -1,10 +1,13 @@
use axum::Json;
use axum::extract::{Query, State};
use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Redirect, Response};
use chrono::{DateTime, Utc};
use serde::Deserialize;
use crate::application::auth::AuthenticatedUser;
use crate::application::errors::{ApiError, AppError};
use crate::application::routes::api::images::save_deferred_image;
use crate::application::routes::api::macros::{
define_delete_handler, define_enriched_get_handler, define_list_fragment_renderer,
};
@ -12,8 +15,8 @@ use crate::application::routes::support::{
FlexiblePayload, ListQuery, PayloadSource, is_datastar_request, render_redirect_script,
};
use crate::application::state::AppState;
use crate::domain::cups::{CupFilter, CupSortKey, CupWithDetails, NewCup};
use crate::domain::ids::CupId;
use crate::domain::cups::{CupFilter, CupSortKey, CupWithDetails, NewCup, UpdateCup};
use crate::domain::ids::{CafeId, CupId, RoastId};
use crate::domain::listing::{ListRequest, SortDirection};
use crate::presentation::web::templates::CupListTemplate;
use crate::presentation::web::views::{CupView, ListNavigator, Paginated};
@ -90,6 +93,76 @@ pub(crate) async fn list_cups(
define_enriched_get_handler!(get_cup, CupId, CupWithDetails, cup_repo, get_with_details);
#[derive(Debug, Deserialize)]
pub(crate) struct UpdateCupSubmission {
#[serde(default)]
roast_id: Option<RoastId>,
#[serde(default)]
cafe_id: Option<CafeId>,
#[serde(default)]
created_at: Option<DateTime<Utc>>,
#[serde(default)]
image: Option<String>,
}
impl UpdateCupSubmission {
fn into_parts(self) -> (UpdateCup, Option<String>) {
let update = UpdateCup {
roast_id: self.roast_id,
cafe_id: self.cafe_id,
created_at: self.created_at,
};
(update, self.image)
}
}
#[tracing::instrument(skip(state, _auth_user, headers))]
pub(crate) async fn update_cup(
State(state): State<AppState>,
_auth_user: AuthenticatedUser,
headers: HeaderMap,
Path(id): Path<CupId>,
payload: FlexiblePayload<UpdateCupSubmission>,
) -> Result<Response, ApiError> {
let (submission, source) = payload.into_parts();
let (update, image_data_url) = submission.into_parts();
let has_changes = update.roast_id.is_some()
|| update.cafe_id.is_some()
|| update.created_at.is_some()
|| image_data_url.is_some();
if !has_changes {
return Err(AppError::validation("no changes provided").into());
}
let cup = state
.cup_repo
.update(id, update)
.await
.map_err(AppError::from)?;
info!(%id, "cup updated");
state.stats_invalidator.invalidate();
save_deferred_image(&state, "cup", i64::from(cup.id), image_data_url.as_deref()).await;
let detail_url = format!("/cups/{id}");
if is_datastar_request(&headers) {
render_redirect_script(&detail_url).map_err(ApiError::from)
} else if matches!(source, PayloadSource::Form) {
Ok(Redirect::to(&detail_url).into_response())
} else {
let enriched = state
.cup_repo
.get_with_details(id)
.await
.map_err(AppError::from)?;
Ok(Json(enriched).into_response())
}
}
define_delete_handler!(
delete_cup,
CupId,

View file

@ -56,7 +56,9 @@ pub(super) fn router() -> axum::Router<AppState> {
.route("/brews", get(brews::list_brews).post(brews::create_brew))
.route(
"/brews/{id}",
get(brews::get_brew).delete(brews::delete_brew),
get(brews::get_brew)
.put(brews::update_brew)
.delete(brews::delete_brew),
)
.route("/cafes", get(cafes::list_cafes).post(cafes::create_cafe))
.route(
@ -78,7 +80,12 @@ pub(super) fn router() -> axum::Router<AppState> {
)
.route("/check-in", post(checkin::submit_checkin))
.route("/cups", get(cups::list_cups).post(cups::create_cup))
.route("/cups/{id}", get(cups::get_cup).delete(cups::delete_cup))
.route(
"/cups/{id}",
get(cups::get_cup)
.put(cups::update_cup)
.delete(cups::delete_cup),
)
.route(
"/tokens",
post(tokens::create_token).get(tokens::list_tokens),