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, search: Option<&str>, ) -> Result<(Paginated, ListNavigator), 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, ) -> Result>, 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, #[serde(default)] homepage: Option, #[serde(default)] created_at: Option>, #[serde(default)] image: Option, } impl NewRoasterSubmission { fn into_parts(self) -> (NewRoaster, Option) { 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, _auth_user: AuthenticatedUser, headers: HeaderMap, Query(query): Query, payload: FlexiblePayload, ) -> Result { let (request, search) = query.into_request_and_search::(); 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, #[serde(default)] country: Option, #[serde(default)] city: Option, #[serde(default)] homepage: Option, #[serde(default)] created_at: Option>, #[serde(default)] image: Option, } impl UpdateRoasterSubmission { fn into_parts(self) -> (UpdateRoaster, Option) { 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, _auth_user: AuthenticatedUser, headers: HeaderMap, Path(id): Path, payload: FlexiblePayload, ) -> Result { 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, auth_user: AuthenticatedUser, headers: HeaderMap, payload: FlexiblePayload, ) -> Result { 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" );