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, pub(crate) navigator: ListNavigator, } pub(crate) struct BrewFormData { pub(crate) bag_options: Vec, pub(crate) grinder_options: Vec, pub(crate) brewer_options: Vec, pub(crate) filter_paper_options: Vec, pub(crate) defaults: BrewDefaultsView, pub(crate) quick_note_options: Vec, } pub(crate) async fn load_brew_form_data(state: &AppState) -> Result { 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 = 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, ) -> Result, 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, search: Option<&str>, ) -> Result { 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, D::Error> where D: Deserializer<'de>, { let value: Option = 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::() .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, D::Error> where D: Deserializer<'de>, { let value: Option = 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, water_volume: i32, water_temp: f64, #[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 NewBrewSubmission { fn into_new_brew(self) -> Result { 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, _auth_user: AuthenticatedUser, headers: HeaderMap, Query(query): Query, payload: FlexiblePayload, ) -> Result { let (request, search) = query.into_request_and_search::(); 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, } #[tracing::instrument(skip(state))] pub(crate) async fn list_brews( State(state): State, Query(params): Query, ) -> Result>, 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, #[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, 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, search: Option, is_authenticated: bool, ) -> Result { 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") }