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:
parent
bc449e520e
commit
1c029f52f4
3 changed files with 197 additions and 7 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Reference in a new issue