brewlog/src/application/routes/api/coffee/roasters.rs
Jon Seager 2cd579a574
refactor: deduplicate update handlers and edit templates
Add HasChanges trait with impl_has_changes! macro, validate_update()
and update_response() helpers to reduce boilerplate across all 7 entity
update handlers. Extract edit form actions (error, spinner, buttons)
into a shared Askama macro. Also adds missing no-changes validation
to the bag update handler.
2026-02-10 20:04:24 +00:00

279 lines
8.3 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 crate::application::auth::AuthenticatedUser;
use crate::application::errors::{ApiError, AppError};
use crate::application::routes::api::images::save_deferred_image;
use crate::application::routes::api::macros::{
define_delete_handler, define_get_handler, define_list_fragment_renderer,
};
use crate::application::routes::support::impl_has_changes;
use crate::application::routes::support::{
FlexiblePayload, ListQuery, PayloadSource, is_datastar_request, render_redirect_script,
update_response, validate_update,
};
use crate::application::state::AppState;
use crate::domain::ids::RoasterId;
use crate::domain::listing::{ListRequest, SortDirection};
use crate::domain::roasters::{NewRoaster, Roaster, RoasterSortKey, UpdateRoaster};
use crate::infrastructure::ai::{self, ExtractionInput};
use crate::presentation::web::templates::RoasterListTemplate;
use crate::presentation::web::views::{ListNavigator, Paginated, RoasterView};
use tracing::info;
const ROASTER_PAGE_PATH: &str = "/data?type=roasters";
const ROASTER_FRAGMENT_PATH: &str = "/data?type=roasters#roaster-list";
#[tracing::instrument(skip(state))]
pub(crate) async fn load_roaster_page(
state: &AppState,
request: ListRequest<RoasterSortKey>,
search: Option<&str>,
) -> Result<(Paginated<RoasterView>, ListNavigator<RoasterSortKey>), AppError> {
let page = state
.roaster_repo
.list(&request, search)
.await
.map_err(AppError::from)?;
Ok(crate::application::routes::support::build_page_view(
page,
request,
RoasterView::from,
ROASTER_PAGE_PATH,
ROASTER_FRAGMENT_PATH,
search.map(String::from),
))
}
#[tracing::instrument(skip(state))]
pub(crate) async fn list_roasters(
State(state): State<AppState>,
) -> Result<Json<Vec<Roaster>>, ApiError> {
let roasters = state
.roaster_repo
.list_all_sorted(RoasterSortKey::Name, SortDirection::Asc)
.await
.map_err(AppError::from)?;
Ok(Json(roasters))
}
#[derive(Debug, Deserialize)]
pub(crate) struct NewRoasterSubmission {
name: String,
country: String,
#[serde(default)]
city: Option<String>,
#[serde(default)]
homepage: Option<String>,
#[serde(default)]
created_at: Option<chrono::DateTime<chrono::Utc>>,
#[serde(default)]
image: Option<String>,
}
impl NewRoasterSubmission {
fn into_parts(self) -> (NewRoaster, Option<String>) {
let roaster = NewRoaster {
name: self.name,
country: self.country,
city: self.city,
homepage: self.homepage,
created_at: self.created_at,
};
(roaster, self.image)
}
}
#[tracing::instrument(skip(state, _auth_user, headers, query, payload))]
pub(crate) async fn create_roaster(
State(state): State<AppState>,
_auth_user: AuthenticatedUser,
headers: HeaderMap,
Query(query): Query<ListQuery>,
payload: FlexiblePayload<NewRoasterSubmission>,
) -> Result<Response, ApiError> {
let (request, search) = query.into_request_and_search::<RoasterSortKey>();
let (submission, source) = payload.into_parts();
let (new_roaster, image_data_url) = submission.into_parts();
let new_roaster = new_roaster.normalize();
let roaster = state
.roaster_service
.create(new_roaster)
.await
.map_err(AppError::from)?;
info!(roaster_id = %roaster.id, name = %roaster.name, "roaster created");
state.stats_invalidator.invalidate();
save_deferred_image(
&state,
"roaster",
i64::from(roaster.id),
image_data_url.as_deref(),
)
.await;
let detail_url = format!("/roasters/{}", roaster.slug);
if is_datastar_request(&headers) {
let from_data_page = headers
.get("referer")
.and_then(|v| v.to_str().ok())
.is_some_and(|r| r.contains("type=roasters"));
if from_data_page {
render_roaster_list_fragment(state, request, search, true)
.await
.map_err(ApiError::from)
} else {
render_redirect_script(&detail_url).map_err(ApiError::from)
}
} else if matches!(source, PayloadSource::Form) {
Ok(Redirect::to(&detail_url).into_response())
} else {
Ok((StatusCode::CREATED, Json(roaster)).into_response())
}
}
define_get_handler!(get_roaster, RoasterId, Roaster, roaster_repo);
#[derive(Debug, Deserialize)]
pub(crate) struct UpdateRoasterSubmission {
#[serde(default)]
name: Option<String>,
#[serde(default)]
country: Option<String>,
#[serde(default)]
city: Option<String>,
#[serde(default)]
homepage: Option<String>,
#[serde(default)]
created_at: Option<chrono::DateTime<chrono::Utc>>,
#[serde(default)]
image: Option<String>,
}
impl UpdateRoasterSubmission {
fn into_parts(self) -> (UpdateRoaster, Option<String>) {
let update = UpdateRoaster {
name: self.name,
country: self.country,
city: self.city,
homepage: self.homepage,
created_at: self.created_at,
};
(update, self.image)
}
}
impl_has_changes!(UpdateRoaster, name, country, city, homepage, created_at);
#[tracing::instrument(skip(state, _auth_user, headers, payload))]
pub(crate) async fn update_roaster(
State(state): State<AppState>,
_auth_user: AuthenticatedUser,
headers: HeaderMap,
Path(id): Path<RoasterId>,
payload: FlexiblePayload<UpdateRoasterSubmission>,
) -> Result<Response, ApiError> {
let (submission, source) = payload.into_parts();
let (update, image_data_url) = submission.into_parts();
let update = update.normalize();
validate_update(&update, image_data_url.as_ref())?;
let roaster = state
.roaster_repo
.update(id, update)
.await
.map_err(AppError::from)?;
info!(%id, "roaster updated");
state.stats_invalidator.invalidate();
save_deferred_image(
&state,
"roaster",
i64::from(roaster.id),
image_data_url.as_deref(),
)
.await;
let detail_url = format!("/roasters/{}", roaster.slug);
update_response(&headers, source, &detail_url, Json(roaster).into_response())
}
define_delete_handler!(
delete_roaster,
RoasterId,
RoasterSortKey,
roaster_repo,
render_roaster_list_fragment,
"type=roasters",
"/data?type=roasters",
image_type: "roaster"
);
#[tracing::instrument(skip(state, auth_user, headers, payload))]
pub(crate) async fn extract_roaster(
State(state): State<AppState>,
auth_user: AuthenticatedUser,
headers: HeaderMap,
payload: FlexiblePayload<ExtractionInput>,
) -> Result<Response, ApiError> {
let (input, _) = payload.into_parts();
let (result, usage) = ai::extract_roaster(
&state.http_client,
&state.openrouter_url,
&state.openrouter_api_key,
&state.openrouter_model,
&input,
)
.await
.map_err(ApiError::from)?;
crate::application::routes::support::record_ai_usage(
state.ai_usage_repo.clone(),
auth_user.0.id,
&state.openrouter_model,
"extract-roaster",
usage,
);
if is_datastar_request(&headers) {
use serde_json::Value;
let signals = vec![
(
"_roaster-name",
Value::String(result.name.unwrap_or_default()),
),
(
"_roaster-country",
Value::String(result.country.unwrap_or_default()),
),
(
"_roaster-city",
Value::String(result.city.unwrap_or_default()),
),
(
"_roaster-homepage",
Value::String(result.homepage.unwrap_or_default()),
),
("_extracted", Value::Bool(true)),
];
crate::application::routes::support::render_signals_json(&signals).map_err(ApiError::from)
} else {
Ok(Json(result).into_response())
}
}
define_list_fragment_renderer!(
render_roaster_list_fragment,
RoasterSortKey,
load_roaster_page,
RoasterListTemplate { roasters },
"#roaster-list"
);