feat(brews): add application routes and server integration

- Add brew routes: page, create, list, get, delete
- Add timeline event creation when brews are logged
- Add brew_repo to AppState
- Add Conflict variant to AppError for 409 responses
This commit is contained in:
Jon Seager 2026-02-02 19:30:45 +00:00
parent 5f8de35404
commit 6b7b7f9c6e
No known key found for this signature in database
4 changed files with 341 additions and 3 deletions

View file

@ -32,6 +32,7 @@ impl IntoResponse for ApiError {
fn into_response(self) -> Response { fn into_response(self) -> Response {
let (status, message) = match self.0 { let (status, message) = match self.0 {
AppError::Validation(message) => (StatusCode::BAD_REQUEST, message), AppError::Validation(message) => (StatusCode::BAD_REQUEST, message),
AppError::Conflict(message) => (StatusCode::CONFLICT, message),
AppError::NotFound => (StatusCode::NOT_FOUND, "entity not found".to_string()), AppError::NotFound => (StatusCode::NOT_FOUND, "entity not found".to_string()),
AppError::Unexpected(message) => { AppError::Unexpected(message) => {
error!(error = %message, "unexpected application error"); error!(error = %message, "unexpected application error");
@ -49,6 +50,7 @@ impl IntoResponse for ApiError {
pub fn map_app_error(err: AppError) -> StatusCode { pub fn map_app_error(err: AppError) -> StatusCode {
match err { match err {
AppError::Validation(_) => StatusCode::BAD_REQUEST, AppError::Validation(_) => StatusCode::BAD_REQUEST,
AppError::Conflict(_) => StatusCode::CONFLICT,
AppError::NotFound => StatusCode::NOT_FOUND, AppError::NotFound => StatusCode::NOT_FOUND,
AppError::Unexpected(_) => StatusCode::INTERNAL_SERVER_ERROR, AppError::Unexpected(_) => StatusCode::INTERNAL_SERVER_ERROR,
} }
@ -60,6 +62,8 @@ pub type AppResult<T> = Result<T, AppError>;
pub enum AppError { pub enum AppError {
#[error("validation failed: {0}")] #[error("validation failed: {0}")]
Validation(String), Validation(String),
#[error("conflict: {0}")]
Conflict(String),
#[error("entity not found")] #[error("entity not found")]
NotFound, NotFound,
#[error("unexpected error: {0}")] #[error("unexpected error: {0}")]
@ -79,7 +83,7 @@ impl From<RepositoryError> for AppError {
fn from(value: RepositoryError) -> Self { fn from(value: RepositoryError) -> Self {
match value { match value {
RepositoryError::NotFound => Self::NotFound, RepositoryError::NotFound => Self::NotFound,
RepositoryError::Conflict(msg) => Self::Validation(msg), RepositoryError::Conflict(msg) => Self::Conflict(msg),
RepositoryError::Unexpected(msg) => Self::Unexpected(msg), RepositoryError::Unexpected(msg) => Self::Unexpected(msg),
} }
} }

View file

@ -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<BrewView>,
navigator: ListNavigator<BrewSortKey>,
}
#[tracing::instrument(skip(state))]
async fn load_brew_page(
state: &AppState,
request: ListRequest<BrewSortKey>,
) -> Result<BrewPageData, AppError> {
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<AppState>,
cookies: tower_cookies::Cookies,
headers: HeaderMap,
Query(query): Query<ListQuery>,
) -> Result<Response, StatusCode> {
let request = query.into_request::<BrewSortKey>();
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<BagOptionView> = 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<GearOptionView> = 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<GearOptionView> = 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<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",
));
}
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<AppState>,
_auth_user: AuthenticatedUser,
headers: HeaderMap,
Query(query): Query<ListQuery>,
payload: FlexiblePayload<NewBrewSubmission>,
) -> Result<Response, ApiError> {
let request = query.into_request::<BrewSortKey>();
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<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)
.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<BrewSortKey>,
is_authenticated: bool,
) -> Result<Response, AppError> {
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")
}

View file

@ -1,5 +1,6 @@
pub mod auth; pub mod auth;
pub mod bags; pub mod bags;
pub mod brews;
pub mod gear; pub mod gear;
mod macros; mod macros;
pub mod roasters; pub mod roasters;
@ -59,6 +60,11 @@ pub fn app_router(state: AppState) -> axum::Router {
.put(gear::update_gear) .put(gear::update_gear)
.delete(gear::delete_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( .route(
"/tokens", "/tokens",
post(tokens::create_token).get(tokens::list_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), get(roasts::roast_page),
) )
.route("/bags", get(bags::bags_page)) .route("/bags", get(bags::bags_page))
.route("/brews", get(brews::brews_page))
.route("/gear", get(gear::gear_page)) .route("/gear", get(gear::gear_page))
.route("/timeline", get(timeline::timeline_page)) .route("/timeline", get(timeline::timeline_page))
.route("/styles.css", get(styles)) .route("/styles.css", get(styles))

View file

@ -9,13 +9,14 @@ use tracing::info;
use crate::application::routes::app_router; use crate::application::routes::app_router;
use crate::domain::repositories::{ use crate::domain::repositories::{
BagRepository, GearRepository, RoastRepository, RoasterRepository, SessionRepository, BagRepository, BrewRepository, GearRepository, RoastRepository, RoasterRepository,
TimelineEventRepository, TokenRepository, UserRepository, SessionRepository, TimelineEventRepository, TokenRepository, UserRepository,
}; };
use crate::domain::users::NewUser; use crate::domain::users::NewUser;
use crate::infrastructure::auth::hash_password; use crate::infrastructure::auth::hash_password;
use crate::infrastructure::database::Database; use crate::infrastructure::database::Database;
use crate::infrastructure::repositories::bags::SqlBagRepository; use crate::infrastructure::repositories::bags::SqlBagRepository;
use crate::infrastructure::repositories::brews::SqlBrewRepository;
use crate::infrastructure::repositories::gear::SqlGearRepository; use crate::infrastructure::repositories::gear::SqlGearRepository;
use crate::infrastructure::repositories::roasters::SqlRoasterRepository; use crate::infrastructure::repositories::roasters::SqlRoasterRepository;
use crate::infrastructure::repositories::roasts::SqlRoastRepository; use crate::infrastructure::repositories::roasts::SqlRoastRepository;
@ -37,6 +38,7 @@ pub struct AppState {
pub roast_repo: Arc<dyn RoastRepository>, pub roast_repo: Arc<dyn RoastRepository>,
pub bag_repo: Arc<dyn BagRepository>, pub bag_repo: Arc<dyn BagRepository>,
pub gear_repo: Arc<dyn GearRepository>, pub gear_repo: Arc<dyn GearRepository>,
pub brew_repo: Arc<dyn BrewRepository>,
pub timeline_repo: Arc<dyn TimelineEventRepository>, pub timeline_repo: Arc<dyn TimelineEventRepository>,
pub user_repo: Arc<dyn UserRepository>, pub user_repo: Arc<dyn UserRepository>,
pub token_repo: Arc<dyn TokenRepository>, pub token_repo: Arc<dyn TokenRepository>,
@ -50,6 +52,7 @@ impl AppState {
roast_repo: Arc<dyn RoastRepository>, roast_repo: Arc<dyn RoastRepository>,
bag_repo: Arc<dyn BagRepository>, bag_repo: Arc<dyn BagRepository>,
gear_repo: Arc<dyn GearRepository>, gear_repo: Arc<dyn GearRepository>,
brew_repo: Arc<dyn BrewRepository>,
timeline_repo: Arc<dyn TimelineEventRepository>, timeline_repo: Arc<dyn TimelineEventRepository>,
user_repo: Arc<dyn UserRepository>, user_repo: Arc<dyn UserRepository>,
token_repo: Arc<dyn TokenRepository>, token_repo: Arc<dyn TokenRepository>,
@ -60,6 +63,7 @@ impl AppState {
roast_repo, roast_repo,
bag_repo, bag_repo,
gear_repo, gear_repo,
brew_repo,
timeline_repo, timeline_repo,
user_repo, user_repo,
token_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 roast_repo = Arc::new(SqlRoastRepository::new(database.clone_pool()));
let bag_repo = Arc::new(SqlBagRepository::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 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 timeline_repo = Arc::new(SqlTimelineEventRepository::new(database.clone_pool()));
let user_repo: Arc<dyn UserRepository> = let user_repo: Arc<dyn UserRepository> =
Arc::new(SqlUserRepository::new(database.clone_pool())); Arc::new(SqlUserRepository::new(database.clone_pool()));
@ -94,6 +99,7 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> {
roast_repo, roast_repo,
bag_repo, bag_repo,
gear_repo, gear_repo,
brew_repo,
timeline_repo, timeline_repo,
user_repo, user_repo,
token_repo, token_repo,