brewlog/src/application/routes/api/coffee/brews.rs
Jon Seager 1c029f52f4
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).
2026-02-10 19:42:45 +00:00

462 lines
14 KiB
Rust

use axum::Json;
use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Redirect, Response};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Deserializer};
use tracing::info;
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};
use crate::application::routes::support::{
FlexiblePayload, ListQuery, PayloadSource, is_datastar_request,
};
use crate::application::state::AppState;
use crate::domain::bags::BagFilter;
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};
use crate::presentation::web::templates::BrewListTemplate;
use crate::presentation::web::views::{
BagOptionView, BrewDefaultsView, BrewView, GearOptionView, ListNavigator, Paginated,
QuickNoteView,
};
const BREW_PAGE_PATH: &str = "/data?type=brews";
const BREW_FRAGMENT_PATH: &str = "/data?type=brews#brew-list";
pub(crate) struct BrewPageData {
pub(crate) brews: Paginated<BrewView>,
pub(crate) navigator: ListNavigator<BrewSortKey>,
}
pub(crate) struct BrewFormData {
pub(crate) bag_options: Vec<BagOptionView>,
pub(crate) grinder_options: Vec<GearOptionView>,
pub(crate) brewer_options: Vec<GearOptionView>,
pub(crate) filter_paper_options: Vec<GearOptionView>,
pub(crate) defaults: BrewDefaultsView,
pub(crate) quick_note_options: Vec<QuickNoteView>,
}
pub(crate) async fn load_brew_form_data(state: &AppState) -> Result<BrewFormData, AppError> {
let open_bags_request = ListRequest::show_all(
crate::domain::bags::BagSortKey::RoastDate,
SortDirection::Desc,
);
let open_bags = state
.bag_repo
.list(BagFilter::open(), &open_bags_request, None)
.await
.map_err(AppError::from)?;
let bag_options: Vec<BagOptionView> = open_bags
.items
.into_iter()
.map(BagOptionView::from)
.collect();
let gear_request = ListRequest::show_all(GearSortKey::Make, SortDirection::Asc);
let grinder_options = load_gear_options(state, GearCategory::Grinder, &gear_request).await?;
let brewer_options = load_gear_options(state, GearCategory::Brewer, &gear_request).await?;
let filter_paper_options =
load_gear_options(state, GearCategory::FilterPaper, &gear_request).await?;
let last_brew_request = ListRequest::new(
1,
PageSize::Limited(1),
BrewSortKey::CreatedAt,
SortDirection::Desc,
);
let last_brew_page = state
.brew_repo
.list(BrewFilter::all(), &last_brew_request, None)
.await
.map_err(AppError::from)?;
let defaults = last_brew_page
.items
.into_iter()
.next()
.map(BrewDefaultsView::from)
.unwrap_or_default();
let quick_note_options = QuickNote::all()
.iter()
.copied()
.map(QuickNoteView::from)
.collect();
Ok(BrewFormData {
bag_options,
grinder_options,
brewer_options,
filter_paper_options,
defaults,
quick_note_options,
})
}
pub(crate) async fn load_gear_options(
state: &AppState,
category: GearCategory,
request: &ListRequest<GearSortKey>,
) -> Result<Vec<GearOptionView>, AppError> {
let page = state
.gear_repo
.list(GearFilter::for_category(category), request, None)
.await
.map_err(AppError::from)?;
Ok(page.items.into_iter().map(GearOptionView::from).collect())
}
#[tracing::instrument(skip(state))]
pub(crate) async fn load_brew_page(
state: &AppState,
request: ListRequest<BrewSortKey>,
search: Option<&str>,
) -> Result<BrewPageData, AppError> {
let page = state
.brew_repo
.list(BrewFilter::all(), &request, search)
.await
.map_err(AppError::from)?;
let (brews, navigator) = crate::application::routes::support::build_page_view(
page,
request,
BrewView::from_domain,
BREW_PAGE_PATH,
BREW_FRAGMENT_PATH,
search.map(String::from),
);
Ok(BrewPageData { brews, navigator })
}
/// Deserializes an optional `GearId`, treating empty strings (from HTML forms) as None.
fn deserialize_optional_gear_id<'de, D>(deserializer: D) -> Result<Option<GearId>, D::Error>
where
D: Deserializer<'de>,
{
let value: Option<serde_json::Value> = Option::deserialize(deserializer)?;
match value {
None | Some(serde_json::Value::Null) => Ok(None),
Some(serde_json::Value::String(s)) if s.is_empty() => Ok(None),
Some(serde_json::Value::Number(n)) => n
.as_i64()
.map(|id| Some(GearId::new(id)))
.ok_or_else(|| serde::de::Error::custom("invalid gear id")),
Some(serde_json::Value::String(s)) => s
.parse::<i64>()
.map(|id| Some(GearId::new(id)))
.map_err(serde::de::Error::custom),
Some(_) => Err(serde::de::Error::custom("invalid gear id")),
}
}
fn deserialize_quick_notes<'de, D>(deserializer: D) -> Result<Vec<QuickNote>, D::Error>
where
D: Deserializer<'de>,
{
let value: Option<serde_json::Value> = Option::deserialize(deserializer)?;
match value {
None | Some(serde_json::Value::Null) => Ok(Vec::new()),
Some(serde_json::Value::String(s)) if s.is_empty() => Ok(Vec::new()),
Some(serde_json::Value::String(s)) => Ok(s
.split(',')
.filter_map(|v| QuickNote::from_str_value(v.trim()))
.collect()),
Some(serde_json::Value::Array(arr)) => Ok(arr
.iter()
.filter_map(|v| v.as_str().and_then(QuickNote::from_str_value))
.collect()),
Some(_) => Err(serde::de::Error::custom("invalid quick_notes")),
}
}
#[derive(Debug, Deserialize)]
pub(crate) struct NewBrewSubmission {
bag_id: BagId,
coffee_weight: f64,
grinder_id: GearId,
grind_setting: f64,
brewer_id: GearId,
#[serde(default, deserialize_with = "deserialize_optional_gear_id")]
filter_paper_id: Option<GearId>,
water_volume: i32,
water_temp: 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 NewBrewSubmission {
fn into_new_brew(self) -> Result<NewBrew, AppError> {
if self.coffee_weight <= 0.0 {
return Err(AppError::validation("coffee weight must be positive"));
}
if self.grind_setting < 0.0 {
return Err(AppError::validation("grind setting must be non-negative"));
}
if self.water_volume <= 0 {
return Err(AppError::validation("water volume must be positive"));
}
if self.water_temp <= 0.0 || self.water_temp > 100.0 {
return Err(AppError::validation(
"water temperature must be between 0 and 100",
));
}
if let Some(bt) = self.brew_time
&& bt <= 0
{
return Err(AppError::validation("brew time must be positive"));
}
Ok(NewBrew {
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: self.quick_notes,
brew_time: self.brew_time,
created_at: self.created_at,
})
}
}
#[tracing::instrument(skip(state, _auth_user, headers, query))]
pub(crate) async fn create_brew(
State(state): State<AppState>,
_auth_user: AuthenticatedUser,
headers: HeaderMap,
Query(query): Query<ListQuery>,
payload: FlexiblePayload<NewBrewSubmission>,
) -> Result<Response, ApiError> {
let (request, search) = query.into_request_and_search::<BrewSortKey>();
let (submission, source) = payload.into_parts();
let image_data_url = submission.image.clone();
let new_brew = submission.into_new_brew().map_err(ApiError::from)?;
let enriched = state
.brew_service
.create(new_brew)
.await
.map_err(AppError::from)?;
info!(brew_id = %enriched.brew.id, "brew created");
state.stats_invalidator.invalidate();
save_deferred_image(
&state,
"brew",
i64::from(enriched.brew.id),
image_data_url.as_deref(),
)
.await;
let detail_url = format!("/brews/{}", enriched.brew.id);
if is_datastar_request(&headers) {
// If the request came from a page that has #brew-list, return the updated fragment.
// Otherwise (homepage, add page, etc.), redirect to the brew detail page.
let from_brew_page = headers
.get("referer")
.and_then(|v| v.to_str().ok())
.is_some_and(|r| r.contains("type=brews"));
if from_brew_page {
render_brew_list_fragment(state, request, search, true)
.await
.map_err(ApiError::from)
} else {
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((StatusCode::CREATED, Json(enriched)).into_response())
}
}
#[derive(Debug, Deserialize)]
pub struct BrewsQuery {
pub bag_id: Option<BagId>,
}
#[tracing::instrument(skip(state))]
pub(crate) async fn list_brews(
State(state): State<AppState>,
Query(params): Query<BrewsQuery>,
) -> Result<Json<Vec<BrewWithDetails>>, ApiError> {
let filter = match params.bag_id {
Some(bag_id) => BrewFilter::for_bag(bag_id),
None => BrewFilter::all(),
};
let request = ListRequest::show_all(BrewSortKey::CreatedAt, SortDirection::Desc);
let page = state
.brew_repo
.list(filter, &request, None)
.await
.map_err(AppError::from)?;
Ok(Json(page.items))
}
define_enriched_get_handler!(
get_brew,
BrewId,
BrewWithDetails,
brew_repo,
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,
BrewSortKey,
brew_repo,
render_brew_list_fragment,
"type=brews",
"/data?type=brews",
image_type: "brew"
);
async fn render_brew_list_fragment(
state: AppState,
request: ListRequest<BrewSortKey>,
search: Option<String>,
is_authenticated: bool,
) -> Result<Response, AppError> {
let BrewPageData { brews, navigator } =
load_brew_page(&state, request, search.as_deref()).await?;
let template = BrewListTemplate {
is_authenticated,
brews,
navigator,
};
crate::application::routes::support::render_fragment(template, "#brew-list")
}