brewlog/src/application/routes/bags.rs
Jon Seager 4bae7f1762
refactor(routes): re-export is_authenticated for shorter imports
Add pub(crate) re-export of is_authenticated from routes/mod.rs,
allowing handlers to use super::is_authenticated instead of the
verbose crate::application::routes::auth::is_authenticated path.

Updated 9 call sites across roasters.rs, roasts.rs, bags.rs, and
timeline.rs.
2026-02-02 14:32:30 +00:00

337 lines
9.9 KiB
Rust

use axum::Json;
use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Redirect, Response};
use serde::Deserialize;
use super::macros::{define_delete_handler, define_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::{Bag, BagFilter, BagSortKey, BagWithRoast, NewBag, UpdateBag};
use crate::domain::ids::{BagId, RoastId};
use crate::domain::listing::{ListRequest, SortDirection};
use crate::domain::roasters::RoasterSortKey;
use crate::domain::timeline::{NewTimelineEvent, TimelineEventDetail};
use crate::presentation::web::templates::{BagListTemplate, BagsTemplate};
use crate::presentation::web::views::{BagView, ListNavigator, Paginated, RoasterOptionView};
const BAG_PAGE_PATH: &str = "/bags";
const BAG_FRAGMENT_PATH: &str = "/bags#bag-list";
struct BagPageData {
open_bags: Vec<BagView>,
bags: Paginated<BagView>,
navigator: ListNavigator<BagSortKey>,
}
#[tracing::instrument(skip(state))]
async fn load_bag_page(
state: &AppState,
request: ListRequest<BagSortKey>,
) -> Result<BagPageData, AppError> {
let open_request = ListRequest::show_all(BagSortKey::RoastDate, SortDirection::Desc);
let open_page = state
.bag_repo
.list(BagFilter::open(), &open_request)
.await
.map_err(AppError::from)?;
let open_bags_view = open_page
.items
.into_iter()
.map(BagView::from_domain)
.collect();
let page = state
.bag_repo
.list(BagFilter::closed(), &request)
.await
.map_err(AppError::from)?;
let (bags, navigator) = crate::application::routes::support::build_page_view(
page,
request,
BagView::from_domain,
BAG_PAGE_PATH,
BAG_FRAGMENT_PATH,
);
Ok(BagPageData {
open_bags: open_bags_view,
bags,
navigator,
})
}
#[tracing::instrument(skip(state, cookies, headers, query))]
pub(crate) async fn bags_page(
State(state): State<AppState>,
cookies: tower_cookies::Cookies,
headers: HeaderMap,
Query(query): Query<ListQuery>,
) -> Result<Response, StatusCode> {
let request = query.into_request::<BagSortKey>();
if is_datastar_request(&headers) {
let is_authenticated = super::is_authenticated(&state, &cookies).await;
return render_bag_list_fragment(state, request, is_authenticated)
.await
.map_err(map_app_error);
}
let roasters = state
.roaster_repo
.list_all_sorted(RoasterSortKey::Name, SortDirection::Asc)
.await
.map_err(|err| map_app_error(AppError::from(err)))?;
let roaster_options = roasters.into_iter().map(RoasterOptionView::from).collect();
let BagPageData {
open_bags,
bags,
navigator,
} = load_bag_page(&state, request)
.await
.map_err(map_app_error)?;
let is_authenticated = super::is_authenticated(&state, &cookies).await;
let template = BagsTemplate {
nav_active: "bags",
is_authenticated,
open_bags,
bags,
roaster_options,
navigator,
};
render_html(template).map(IntoResponse::into_response)
}
#[tracing::instrument(skip(state, _auth_user, headers, query))]
pub(crate) async fn create_bag(
State(state): State<AppState>,
_auth_user: AuthenticatedUser,
headers: HeaderMap,
Query(query): Query<ListQuery>,
payload: FlexiblePayload<NewBagSubmission>,
) -> Result<Response, ApiError> {
let request = query.into_request::<BagSortKey>();
let (submission, source) = payload.into_parts();
let new_bag = submission.into_new_bag().map_err(ApiError::from)?;
let roast = state
.roast_repo
.get(new_bag.roast_id)
.await
.map_err(|err| ApiError::from(AppError::from(err)))?;
let roaster = state
.roaster_repo
.get(roast.roaster_id)
.await
.map_err(|err| ApiError::from(AppError::from(err)))?;
let bag = state
.bag_repo
.insert(new_bag)
.await
.map_err(AppError::from)?;
// Add timeline event
let event = NewTimelineEvent {
entity_type: "bag".to_string(),
entity_id: bag.id.into_inner(),
action: "added".to_string(),
occurred_at: chrono::Utc::now(),
title: roast.name.to_string(),
details: vec![
TimelineEventDetail {
label: "Roaster".to_string(),
value: roaster.name,
},
TimelineEventDetail {
label: "Amount".to_string(),
value: format!("{}g", bag.amount),
},
],
tasting_notes: vec![],
};
let _ = state.timeline_repo.insert(event).await;
if is_datastar_request(&headers) {
render_bag_list_fragment(state, request, true)
.await
.map_err(ApiError::from)
} else if matches!(source, PayloadSource::Form) {
let target = ListNavigator::new(BAG_PAGE_PATH, BAG_FRAGMENT_PATH, request).page_href(1);
Ok(Redirect::to(&target).into_response())
} else {
Ok((StatusCode::CREATED, Json(bag)).into_response())
}
}
#[tracing::instrument(skip(state))]
pub(crate) async fn list_bags(
State(state): State<AppState>,
Query(params): Query<BagsQuery>,
) -> Result<Json<Vec<BagWithRoast>>, ApiError> {
let filter = match params.roast_id {
Some(roast_id) => BagFilter::for_roast(roast_id),
None => BagFilter::all(),
};
let request = ListRequest::show_all(BagSortKey::RoastDate, SortDirection::Desc);
let page = state
.bag_repo
.list(filter, &request)
.await
.map_err(AppError::from)?;
Ok(Json(page.items))
}
define_get_handler!(get_bag, BagId, Bag, bag_repo);
#[tracing::instrument(skip(state, _auth_user, headers, query))]
pub(crate) async fn update_bag(
State(state): State<AppState>,
_auth_user: AuthenticatedUser,
headers: HeaderMap,
Path(id): Path<BagId>,
Query(query): Query<ListQuery>,
Query(update_params): Query<UpdateBag>,
payload: Option<Json<UpdateBag>>,
) -> Result<Response, ApiError> {
let request = query.into_request::<BagSortKey>();
let body_update = payload.map(|Json(p)| p).unwrap_or(UpdateBag {
remaining: None,
closed: None,
finished_at: None,
});
let mut update = UpdateBag {
remaining: body_update.remaining.or(update_params.remaining),
closed: body_update.closed.or(update_params.closed),
finished_at: body_update.finished_at.or(update_params.finished_at),
};
if let Some(true) = update.closed
&& update.finished_at.is_none()
{
update.finished_at = Some(chrono::Utc::now().date_naive());
}
let bag = state
.bag_repo
.update(id, update.clone())
.await
.map_err(AppError::from)?;
if let Some(true) = update.closed {
// Fetch roast and roaster for timeline event
if let Ok(roast) = state.roast_repo.get(bag.roast_id).await
&& let Ok(roaster) = state.roaster_repo.get(roast.roaster_id).await
{
let event = NewTimelineEvent {
entity_type: "bag".to_string(),
entity_id: bag.id.into_inner(),
action: "finished".to_string(),
occurred_at: chrono::Utc::now(),
title: roast.name.to_string(),
details: vec![
TimelineEventDetail {
label: "Roaster".to_string(),
value: roaster.name,
},
TimelineEventDetail {
label: "Amount".to_string(),
value: format!("{}g", bag.amount),
},
],
tasting_notes: vec![],
};
let _ = state.timeline_repo.insert(event).await;
}
}
if is_datastar_request(&headers) {
render_bag_list_fragment(state, request, true)
.await
.map_err(ApiError::from)
} else {
Ok(Json(bag).into_response())
}
}
define_delete_handler!(
delete_bag,
BagId,
BagSortKey,
bag_repo,
render_bag_list_fragment
);
#[derive(Debug, Deserialize)]
pub struct BagsQuery {
pub roast_id: Option<RoastId>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct NewBagSubmission {
roast_id: RoastId,
roast_date: Option<String>,
amount: f64,
}
impl NewBagSubmission {
fn into_new_bag(self) -> Result<NewBag, AppError> {
let roast_id = self.roast_id;
if roast_id.into_inner() <= 0 {
return Err(AppError::validation("invalid roast id"));
}
let roast_date = match self.roast_date {
Some(date_str) if !date_str.is_empty() => Some(
chrono::NaiveDate::parse_from_str(&date_str, "%Y-%m-%d")
.map_err(|_| AppError::validation("invalid roast date format"))?,
),
_ => None,
};
if self.amount <= 0.0 {
return Err(AppError::validation("amount must be positive"));
}
Ok(NewBag {
roast_id,
roast_date,
amount: self.amount,
})
}
}
async fn render_bag_list_fragment(
state: AppState,
request: ListRequest<BagSortKey>,
is_authenticated: bool,
) -> Result<Response, AppError> {
let BagPageData {
open_bags,
bags,
navigator,
} = load_bag_page(&state, request).await?;
let template = BagListTemplate {
is_authenticated,
open_bags,
bags,
navigator,
};
crate::application::routes::support::render_fragment(template, "#bag-list")
}