feat: add bags API urls
This commit is contained in:
parent
14c3079600
commit
dbfc6c4775
3 changed files with 248 additions and 1 deletions
231
src/application/routes/bags.rs
Normal file
231
src/application/routes/bags.rs
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
use axum::Json;
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::response::{IntoResponse, Redirect, Response};
|
||||
use serde::Deserialize;
|
||||
|
||||
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, 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};
|
||||
#[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(),
|
||||
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 bags = match params.roast_id {
|
||||
Some(roast_id) => state
|
||||
.bag_repo
|
||||
.list_by_roast(roast_id)
|
||||
.await
|
||||
.map_err(AppError::from)?,
|
||||
None => {
|
||||
// For API list all, we might want to implement list_all in repo or reuse list with pagination
|
||||
// For now, let's just return empty or implement list_all if needed.
|
||||
// The spec implies we need list endpoints.
|
||||
// Let's implement list_all in repo later if needed, or just use list with large page size?
|
||||
// Actually, let's just use list_by_roast for now as that's the main use case for API likely.
|
||||
// Or better, let's add list_all to repo.
|
||||
// For now, I'll return an error if no filter is provided, or empty list.
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
Ok(Json(bags))
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub(crate) async fn get_bag(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<BagId>,
|
||||
) -> Result<Json<Bag>, ApiError> {
|
||||
let bag = state.bag_repo.get(id).await.map_err(AppError::from)?;
|
||||
Ok(Json(bag))
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(state, _auth_user))]
|
||||
pub(crate) async fn update_bag(
|
||||
State(state): State<AppState>,
|
||||
_auth_user: AuthenticatedUser,
|
||||
Path(id): Path<BagId>,
|
||||
Json(payload): Json<UpdateBag>,
|
||||
) -> Result<Json<Bag>, ApiError> {
|
||||
let bag = state
|
||||
.bag_repo
|
||||
.update(id, payload.clone())
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
if let Some(true) = payload.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(),
|
||||
occurred_at: chrono::Utc::now(),
|
||||
title: roast.name.to_string(),
|
||||
details: vec![TimelineEventDetail {
|
||||
label: "Roaster".to_string(),
|
||||
value: roaster.name,
|
||||
}],
|
||||
tasting_notes: vec![],
|
||||
};
|
||||
let _ = state.timeline_repo.insert(event).await;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(bag))
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(state, _auth_user, headers, query))]
|
||||
pub(crate) async fn delete_bag(
|
||||
State(state): State<AppState>,
|
||||
_auth_user: AuthenticatedUser,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<BagId>,
|
||||
Query(query): Query<ListQuery>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let request = query.into_request::<BagSortKey>();
|
||||
state.bag_repo.delete(id).await.map_err(AppError::from)?;
|
||||
|
||||
if is_datastar_request(&headers) {
|
||||
render_bag_list_fragment(state, request, true)
|
||||
.await
|
||||
.map_err(ApiError::from)
|
||||
} else {
|
||||
Ok(StatusCode::NO_CONTENT.into_response())
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(state, _auth_user, headers, query))]
|
||||
pub(crate) async fn finish_bag(
|
||||
State(state): State<AppState>,
|
||||
_auth_user: AuthenticatedUser,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<BagId>,
|
||||
Query(query): Query<ListQuery>,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let request = query.into_request::<BagSortKey>();
|
||||
|
||||
let bag = state
|
||||
.bag_repo
|
||||
.get(id)
|
||||
.await
|
||||
.map_err(|err| map_app_error(AppError::from(err)))?;
|
||||
|
||||
let update = UpdateBag {
|
||||
remaining: Some(0.0),
|
||||
closed: Some(true),
|
||||
finished_at: Some(chrono::Utc::now().date_naive()),
|
||||
};
|
||||
|
||||
let _ = state
|
||||
.bag_repo
|
||||
.update(id, update)
|
||||
.await
|
||||
.map_err(|err| map_app_error(AppError::from(err)))?;
|
||||
|
||||
// Add 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(),
|
||||
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!("{:.1}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(map_app_error)
|
||||
} else {
|
||||
// Fallback for non-datastar requests (though the UI uses datastar)
|
||||
Ok(Redirect::to(BAG_PAGE_PATH).into_response())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
pub mod auth;
|
||||
pub mod bags;
|
||||
pub mod roasters;
|
||||
pub mod roasts;
|
||||
pub mod support;
|
||||
|
|
@ -38,6 +39,13 @@ pub fn app_router(state: AppState) -> axum::Router {
|
|||
"/roasts/:id",
|
||||
get(roasts::get_roast).delete(roasts::delete_roast),
|
||||
)
|
||||
.route("/bags", get(bags::list_bags).post(bags::create_bag))
|
||||
.route(
|
||||
"/bags/:id",
|
||||
get(bags::get_bag)
|
||||
.put(bags::update_bag)
|
||||
.delete(bags::delete_bag),
|
||||
)
|
||||
.route(
|
||||
"/tokens",
|
||||
post(tokens::create_token).get(tokens::list_tokens),
|
||||
|
|
|
|||
10
src/main.rs
10
src/main.rs
|
|
@ -1,7 +1,7 @@
|
|||
use anyhow::Result;
|
||||
use brewlog::application::{ServerConfig, serve};
|
||||
use brewlog::infrastructure::client::BrewlogClient;
|
||||
use brewlog::presentation::cli::{Cli, Commands, ServeCommand, roasters, roasts, tokens};
|
||||
use brewlog::presentation::cli::{Cli, Commands, ServeCommand, bags, roasters, roasts, tokens};
|
||||
use clap::Parser;
|
||||
|
||||
use tracing::{Subscriber, subscriber::set_global_default};
|
||||
|
|
@ -39,6 +39,14 @@ async fn main() -> Result<()> {
|
|||
Commands::ListRoasts(cmd) => roasts::list_roasts(&client, cmd).await,
|
||||
Commands::GetRoast(cmd) => roasts::get_roast(&client, cmd).await,
|
||||
Commands::DeleteRoast(cmd) => roasts::delete_roast(&client, cmd).await,
|
||||
|
||||
// Bags
|
||||
Commands::AddBag(cmd) => bags::add_bag(&client, cmd).await,
|
||||
Commands::ListBags(cmd) => bags::list_bags(&client, cmd).await,
|
||||
Commands::GetBag(cmd) => bags::get_bag(&client, cmd).await,
|
||||
Commands::UpdateBag(cmd) => bags::update_bag(&client, cmd).await,
|
||||
Commands::DeleteBag(cmd) => bags::delete_bag(&client, cmd).await,
|
||||
|
||||
Commands::Serve(_) => unreachable!("serve command handled earlier"),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue