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:
parent
5f8de35404
commit
6b7b7f9c6e
4 changed files with 341 additions and 3 deletions
|
|
@ -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<T> = Result<T, AppError>;
|
|||
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<RepositoryError> 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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
321
src/application/routes/brews.rs
Normal file
321
src/application/routes/brews.rs
Normal 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")
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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<dyn RoastRepository>,
|
||||
pub bag_repo: Arc<dyn BagRepository>,
|
||||
pub gear_repo: Arc<dyn GearRepository>,
|
||||
pub brew_repo: Arc<dyn BrewRepository>,
|
||||
pub timeline_repo: Arc<dyn TimelineEventRepository>,
|
||||
pub user_repo: Arc<dyn UserRepository>,
|
||||
pub token_repo: Arc<dyn TokenRepository>,
|
||||
|
|
@ -50,6 +52,7 @@ impl AppState {
|
|||
roast_repo: Arc<dyn RoastRepository>,
|
||||
bag_repo: Arc<dyn BagRepository>,
|
||||
gear_repo: Arc<dyn GearRepository>,
|
||||
brew_repo: Arc<dyn BrewRepository>,
|
||||
timeline_repo: Arc<dyn TimelineEventRepository>,
|
||||
user_repo: Arc<dyn UserRepository>,
|
||||
token_repo: Arc<dyn TokenRepository>,
|
||||
|
|
@ -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<dyn UserRepository> =
|
||||
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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue