From 1c029f52f41c1de5201d70344a908c79a08625a5 Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Tue, 10 Feb 2026 18:13:39 +0000 Subject: [PATCH] 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). --- src/application/routes/api/coffee/brews.rs | 114 ++++++++++++++++++++- src/application/routes/api/coffee/cups.rs | 79 +++++++++++++- src/application/routes/api/mod.rs | 11 +- 3 files changed, 197 insertions(+), 7 deletions(-) diff --git a/src/application/routes/api/coffee/brews.rs b/src/application/routes/api/coffee/brews.rs index c227cc3..880ee43 100644 --- a/src/application/routes/api/coffee/brews.rs +++ b/src/application/routes/api/coffee/brews.rs @@ -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, + #[serde(default)] + coffee_weight: Option, + #[serde(default, deserialize_with = "deserialize_optional_gear_id")] + grinder_id: Option, + #[serde(default)] + grind_setting: Option, + #[serde(default, deserialize_with = "deserialize_optional_gear_id")] + brewer_id: Option, + #[serde(default, deserialize_with = "deserialize_optional_gear_id")] + filter_paper_id: Option, + #[serde(default)] + water_volume: Option, + #[serde(default)] + water_temp: Option, + #[serde(default, deserialize_with = "deserialize_quick_notes")] + quick_notes: Vec, + #[serde(default)] + brew_time: Option, + #[serde(default)] + created_at: Option>, + #[serde(default)] + image: Option, +} + +impl UpdateBrewSubmission { + fn into_parts(self) -> (UpdateBrew, Option) { + 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, + _auth_user: AuthenticatedUser, + headers: HeaderMap, + Path(id): Path, + payload: FlexiblePayload, +) -> Result { + 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, diff --git a/src/application/routes/api/coffee/cups.rs b/src/application/routes/api/coffee/cups.rs index a6cce88..a170bfd 100644 --- a/src/application/routes/api/coffee/cups.rs +++ b/src/application/routes/api/coffee/cups.rs @@ -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, + #[serde(default)] + cafe_id: Option, + #[serde(default)] + created_at: Option>, + #[serde(default)] + image: Option, +} + +impl UpdateCupSubmission { + fn into_parts(self) -> (UpdateCup, Option) { + 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, + _auth_user: AuthenticatedUser, + headers: HeaderMap, + Path(id): Path, + payload: FlexiblePayload, +) -> Result { + 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, diff --git a/src/application/routes/api/mod.rs b/src/application/routes/api/mod.rs index 1d0a4cb..82e0887 100644 --- a/src/application/routes/api/mod.rs +++ b/src/application/routes/api/mod.rs @@ -56,7 +56,9 @@ pub(super) fn router() -> axum::Router { .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 { ) .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),