diff --git a/src/application/errors.rs b/src/application/errors.rs index 4b5f350..2b62632 100644 --- a/src/application/errors.rs +++ b/src/application/errors.rs @@ -32,6 +32,7 @@ impl IntoResponse for ApiError { fn into_response(self) -> Response { let (status, message) = match self.0 { AppError::Validation(message) => (StatusCode::BAD_REQUEST, message), + AppError::Conflict(message) => (StatusCode::CONFLICT, message), AppError::NotFound => (StatusCode::NOT_FOUND, "entity not found".to_string()), AppError::Unexpected(message) => { error!(error = %message, "unexpected application error"); @@ -49,6 +50,7 @@ impl IntoResponse for ApiError { pub fn map_app_error(err: AppError) -> StatusCode { match err { AppError::Validation(_) => StatusCode::BAD_REQUEST, + AppError::Conflict(_) => StatusCode::CONFLICT, AppError::NotFound => StatusCode::NOT_FOUND, AppError::Unexpected(_) => StatusCode::INTERNAL_SERVER_ERROR, } @@ -60,6 +62,8 @@ pub type AppResult = Result; pub enum AppError { #[error("validation failed: {0}")] Validation(String), + #[error("conflict: {0}")] + Conflict(String), #[error("entity not found")] NotFound, #[error("unexpected error: {0}")] @@ -79,7 +83,7 @@ impl From for AppError { fn from(value: RepositoryError) -> Self { match value { RepositoryError::NotFound => Self::NotFound, - RepositoryError::Conflict(msg) => Self::Validation(msg), + RepositoryError::Conflict(msg) => Self::Conflict(msg), RepositoryError::Unexpected(msg) => Self::Unexpected(msg), } } diff --git a/src/application/routes/brews.rs b/src/application/routes/brews.rs new file mode 100644 index 0000000..30cc68c --- /dev/null +++ b/src/application/routes/brews.rs @@ -0,0 +1,321 @@ +use axum::Json; +use axum::extract::{Query, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Redirect, Response}; +use serde::Deserialize; + +use super::macros::{define_delete_handler, define_enriched_get_handler}; +use crate::application::auth::AuthenticatedUser; +use crate::application::errors::{ApiError, AppError, map_app_error}; +use crate::application::routes::render_html; +use crate::application::routes::support::{ + FlexiblePayload, ListQuery, PayloadSource, is_datastar_request, +}; +use crate::application::server::AppState; +use crate::domain::bags::BagFilter; +use crate::domain::brews::{BrewFilter, BrewSortKey, BrewWithDetails, NewBrew}; +use crate::domain::gear::{GearCategory, GearFilter, GearSortKey}; +use crate::domain::ids::{BagId, BrewId, GearId}; +use crate::domain::listing::{ListRequest, SortDirection}; +use crate::domain::timeline::{NewTimelineEvent, TimelineEventDetail}; +use crate::presentation::web::templates::{BrewListTemplate, BrewsTemplate}; +use crate::presentation::web::views::{ + BagOptionView, BrewView, GearOptionView, ListNavigator, Paginated, +}; + +const BREW_PAGE_PATH: &str = "/brews"; +const BREW_FRAGMENT_PATH: &str = "/brews#brew-list"; + +struct BrewPageData { + brews: Paginated, + navigator: ListNavigator, +} + +#[tracing::instrument(skip(state))] +async fn load_brew_page( + state: &AppState, + request: ListRequest, +) -> Result { + let page = state + .brew_repo + .list(BrewFilter::all(), &request) + .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, + ); + + Ok(BrewPageData { brews, navigator }) +} + +#[tracing::instrument(skip(state, cookies, headers, query))] +pub(crate) async fn brews_page( + State(state): State, + cookies: tower_cookies::Cookies, + headers: HeaderMap, + Query(query): Query, +) -> Result { + let request = query.into_request::(); + + if is_datastar_request(&headers) { + let is_authenticated = super::is_authenticated(&state, &cookies).await; + return render_brew_list_fragment(state, request, is_authenticated) + .await + .map_err(map_app_error); + } + + // Load open bags for the form dropdown + 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) + .await + .map_err(|err| map_app_error(AppError::from(err)))?; + let bag_options: Vec = open_bags + .items + .into_iter() + .map(BagOptionView::from) + .collect(); + + // Load grinders for dropdown + let grinder_request = ListRequest::show_all(GearSortKey::Make, SortDirection::Asc); + let grinders = state + .gear_repo + .list( + GearFilter::for_category(GearCategory::Grinder), + &grinder_request, + ) + .await + .map_err(|err| map_app_error(AppError::from(err)))?; + let grinder_options: Vec = grinders + .items + .into_iter() + .map(GearOptionView::from) + .collect(); + + // Load brewers for dropdown + let brewer_request = ListRequest::show_all(GearSortKey::Make, SortDirection::Asc); + let brewers = state + .gear_repo + .list( + GearFilter::for_category(GearCategory::Brewer), + &brewer_request, + ) + .await + .map_err(|err| map_app_error(AppError::from(err)))?; + let brewer_options: Vec = brewers + .items + .into_iter() + .map(GearOptionView::from) + .collect(); + + let BrewPageData { brews, navigator } = load_brew_page(&state, request) + .await + .map_err(map_app_error)?; + + let is_authenticated = super::is_authenticated(&state, &cookies).await; + + let template = BrewsTemplate { + nav_active: "brews", + is_authenticated, + brews, + bag_options, + grinder_options, + brewer_options, + navigator, + }; + + render_html(template).map(IntoResponse::into_response) +} + +#[derive(Debug, Deserialize)] +pub(crate) struct NewBrewSubmission { + bag_id: BagId, + coffee_weight: f64, + grinder_id: GearId, + grind_setting: f64, + brewer_id: GearId, + water_volume: i32, + water_temp: f64, +} + +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", + )); + } + + 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, + water_volume: self.water_volume, + water_temp: self.water_temp, + }) + } +} + +#[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 = query.into_request::(); + let (submission, source) = payload.into_parts(); + let new_brew = submission.into_new_brew().map_err(ApiError::from)?; + + let brew = state + .brew_repo + .insert(new_brew) + .await + .map_err(AppError::from)?; + + // Fetch enriched brew details for timeline event + let enriched = state + .brew_repo + .get_with_details(brew.id) + .await + .map_err(AppError::from)?; + + // Add timeline event + let ratio = if enriched.brew.coffee_weight > 0.0 { + format!( + "1:{:.1}", + f64::from(enriched.brew.water_volume) / enriched.brew.coffee_weight + ) + } else { + "N/A".to_string() + }; + + let event = NewTimelineEvent { + entity_type: "brew".to_string(), + entity_id: brew.id.into_inner(), + action: "brewed".to_string(), + occurred_at: chrono::Utc::now(), + title: enriched.roast_name.clone(), + details: vec![ + TimelineEventDetail { + label: "Roaster".to_string(), + value: enriched.roaster_name.clone(), + }, + TimelineEventDetail { + label: "Coffee".to_string(), + value: format!("{:.1}g", enriched.brew.coffee_weight), + }, + TimelineEventDetail { + label: "Water".to_string(), + value: format!( + "{}ml @ {:.1}\u{00B0}C", + enriched.brew.water_volume, enriched.brew.water_temp + ), + }, + TimelineEventDetail { + label: "Grinder".to_string(), + value: format!( + "{} @ {:.1}", + enriched.grinder_name, enriched.brew.grind_setting + ), + }, + TimelineEventDetail { + label: "Brewer".to_string(), + value: enriched.brewer_name.clone(), + }, + TimelineEventDetail { + label: "Ratio".to_string(), + value: ratio, + }, + ], + tasting_notes: vec![], + }; + let _ = state.timeline_repo.insert(event).await; + + if is_datastar_request(&headers) { + render_brew_list_fragment(state, request, true) + .await + .map_err(ApiError::from) + } else if matches!(source, PayloadSource::Form) { + let target = ListNavigator::new(BREW_PAGE_PATH, BREW_FRAGMENT_PATH, request).page_href(1); + Ok(Redirect::to(&target).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) + .await + .map_err(AppError::from)?; + Ok(Json(page.items)) +} + +define_enriched_get_handler!( + get_brew, + BrewId, + BrewWithDetails, + brew_repo, + get_with_details +); + +define_delete_handler!( + delete_brew, + BrewId, + BrewSortKey, + brew_repo, + render_brew_list_fragment +); + +async fn render_brew_list_fragment( + state: AppState, + request: ListRequest, + is_authenticated: bool, +) -> Result { + let BrewPageData { brews, navigator } = load_brew_page(&state, request).await?; + + let template = BrewListTemplate { + is_authenticated, + brews, + navigator, + }; + + crate::application::routes::support::render_fragment(template, "#brew-list") +} diff --git a/src/application/routes/mod.rs b/src/application/routes/mod.rs index 6c08a30..713bf30 100644 --- a/src/application/routes/mod.rs +++ b/src/application/routes/mod.rs @@ -1,5 +1,6 @@ pub mod auth; pub mod bags; +pub mod brews; pub mod gear; mod macros; pub mod roasters; @@ -59,6 +60,11 @@ pub fn app_router(state: AppState) -> axum::Router { .put(gear::update_gear) .delete(gear::delete_gear), ) + .route("/brews", get(brews::list_brews).post(brews::create_brew)) + .route( + "/brews/:id", + get(brews::get_brew).delete(brews::delete_brew), + ) .route( "/tokens", post(tokens::create_token).get(tokens::list_tokens), @@ -77,6 +83,7 @@ pub fn app_router(state: AppState) -> axum::Router { get(roasts::roast_page), ) .route("/bags", get(bags::bags_page)) + .route("/brews", get(brews::brews_page)) .route("/gear", get(gear::gear_page)) .route("/timeline", get(timeline::timeline_page)) .route("/styles.css", get(styles)) diff --git a/src/application/server.rs b/src/application/server.rs index 8e7453e..bd6299c 100644 --- a/src/application/server.rs +++ b/src/application/server.rs @@ -9,13 +9,14 @@ use tracing::info; use crate::application::routes::app_router; use crate::domain::repositories::{ - BagRepository, GearRepository, RoastRepository, RoasterRepository, SessionRepository, - TimelineEventRepository, TokenRepository, UserRepository, + BagRepository, BrewRepository, GearRepository, RoastRepository, RoasterRepository, + SessionRepository, TimelineEventRepository, TokenRepository, UserRepository, }; use crate::domain::users::NewUser; use crate::infrastructure::auth::hash_password; use crate::infrastructure::database::Database; use crate::infrastructure::repositories::bags::SqlBagRepository; +use crate::infrastructure::repositories::brews::SqlBrewRepository; use crate::infrastructure::repositories::gear::SqlGearRepository; use crate::infrastructure::repositories::roasters::SqlRoasterRepository; use crate::infrastructure::repositories::roasts::SqlRoastRepository; @@ -37,6 +38,7 @@ pub struct AppState { pub roast_repo: Arc, pub bag_repo: Arc, pub gear_repo: Arc, + pub brew_repo: Arc, pub timeline_repo: Arc, pub user_repo: Arc, pub token_repo: Arc, @@ -50,6 +52,7 @@ impl AppState { roast_repo: Arc, bag_repo: Arc, gear_repo: Arc, + brew_repo: Arc, timeline_repo: Arc, user_repo: Arc, token_repo: Arc, @@ -60,6 +63,7 @@ impl AppState { roast_repo, bag_repo, gear_repo, + brew_repo, timeline_repo, user_repo, token_repo, @@ -78,6 +82,7 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> { let roast_repo = Arc::new(SqlRoastRepository::new(database.clone_pool())); let bag_repo = Arc::new(SqlBagRepository::new(database.clone_pool())); let gear_repo = Arc::new(SqlGearRepository::new(database.clone_pool())); + let brew_repo = Arc::new(SqlBrewRepository::new(database.clone_pool())); let timeline_repo = Arc::new(SqlTimelineEventRepository::new(database.clone_pool())); let user_repo: Arc = Arc::new(SqlUserRepository::new(database.clone_pool())); @@ -94,6 +99,7 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> { roast_repo, bag_repo, gear_repo, + brew_repo, timeline_repo, user_repo, token_repo,