use axum::Json; use axum::extract::State; use axum::http::{HeaderMap, StatusCode}; use axum::response::{IntoResponse, Response}; use serde::{Deserialize, Serialize}; use tracing::info; use crate::application::auth::AuthenticatedUser; use crate::application::errors::{ApiError, AppError}; use crate::application::routes::api::images::{resolve_image_url, save_deferred_image}; use crate::application::routes::api::roasts::TastingNotesInput; use crate::application::routes::support::{FlexiblePayload, is_datastar_request}; use crate::application::state::AppState; use crate::domain::bags::NewBag; use crate::domain::errors::RepositoryError; use crate::domain::ids::RoastId; use crate::domain::images::ImageData; use crate::domain::roasters::NewRoaster; use crate::domain::roasts::NewRoast; use crate::infrastructure::ai::{self, ExtractionInput, Usage}; #[tracing::instrument(skip(state, auth_user, headers, payload))] pub(crate) async fn extract_bag_scan( State(state): State, auth_user: AuthenticatedUser, headers: HeaderMap, payload: FlexiblePayload, ) -> Result { let (input, _) = payload.into_parts(); let (result, usage) = ai::extract_bag_scan( &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-bag-scan", usage, ); // Try to match existing roaster/roast by slug let (matched_roaster_id, matched_roast_id) = match_existing_entities(&state, &result).await; if is_datastar_request(&headers) { use serde_json::Value; let tasting_notes = result .roast .tasting_notes .as_ref() .map(|notes| notes.join(", ")) .unwrap_or_default(); let signals = vec![ ( "_roaster-name", Value::String(result.roaster.name.unwrap_or_default()), ), ( "_roaster-country", Value::String(result.roaster.country.unwrap_or_default()), ), ( "_roaster-city", Value::String(result.roaster.city.unwrap_or_default()), ), ( "_roaster-homepage", Value::String(result.roaster.homepage.unwrap_or_default()), ), ( "_roast-name", Value::String(result.roast.name.unwrap_or_default()), ), ( "_origin", Value::String(result.roast.origin.unwrap_or_default()), ), ( "_region", Value::String(result.roast.region.unwrap_or_default()), ), ( "_producer", Value::String(result.roast.producer.unwrap_or_default()), ), ( "_process", Value::String(result.roast.process.unwrap_or_default()), ), ("_tasting-notes", Value::String(tasting_notes)), ("_scan-extracted", Value::Bool(true)), ("_matched-roaster-id", Value::String(matched_roaster_id)), ("_matched-roast-id", Value::String(matched_roast_id)), ]; crate::application::routes::support::render_signals_json(&signals).map_err(ApiError::from) } else { Ok(Json(result).into_response()) } } /// Check if the extracted roaster/roast already exist by slug matching. /// Returns `(matched_roaster_id, matched_roast_id)` as strings (empty if no match). async fn match_existing_entities( state: &AppState, result: &ai::ExtractedBagScan, ) -> (String, String) { let roaster_name = result.roaster.name.as_deref().unwrap_or_default(); let roaster_country = result.roaster.country.as_deref().unwrap_or_default(); if roaster_name.is_empty() { return (String::new(), String::new()); } let temp_roaster = NewRoaster { name: roaster_name.to_string(), country: roaster_country.to_string(), city: result.roaster.city.clone(), homepage: None, created_at: None, } .normalize(); let roaster_slug = temp_roaster.slug(); let Ok(existing_roaster) = state.roaster_repo.get_by_slug(&roaster_slug).await else { return (String::new(), String::new()); }; let matched_roaster_id = existing_roaster.id.into_inner().to_string(); let roast_name = result.roast.name.as_deref().unwrap_or_default(); if roast_name.is_empty() { return (matched_roaster_id, String::new()); } let roast_slug = slug::slugify(roast_name); let matched_roast_id = match state .roast_repo .get_by_slug(existing_roaster.id, &roast_slug) .await { Ok(r) => r.id.into_inner().to_string(), Err(_) => String::new(), }; (matched_roaster_id, matched_roast_id) } fn default_tasting_notes() -> TastingNotesInput { TastingNotesInput::Text(String::new()) } #[derive(Debug, Deserialize)] pub(crate) struct BagScanSubmission { #[serde(default)] image: ImageData, #[serde(default)] prompt: Option, #[serde(default)] roaster_name: String, #[serde(default)] roaster_country: String, roaster_city: Option, roaster_homepage: Option, #[serde(default)] roast_name: String, #[serde(default)] origin: String, #[serde(default)] region: String, #[serde(default)] producer: String, #[serde(default)] process: String, #[serde(default = "default_tasting_notes")] tasting_notes: TastingNotesInput, #[serde(default)] open_bag: Option, #[serde(default)] bag_amount: Option, #[serde(default)] matched_roast_id: Option, #[serde(default)] scan_image: ImageData, } #[derive(Debug, Serialize)] struct ScanResult { redirect: String, roast_id: i64, } /// Populate a `BagScanSubmission` from AI extraction when image/prompt is provided. /// Returns the usage data so the caller can record it. async fn extract_into_submission( state: &AppState, submission: &mut BagScanSubmission, ) -> Result, ApiError> { let input = ExtractionInput { image: submission.image.take(), prompt: submission.prompt.take(), }; let (result, usage) = ai::extract_bag_scan( &state.http_client, &state.openrouter_url, &state.openrouter_api_key, &state.openrouter_model, &input, ) .await .map_err(ApiError::from)?; if let Some(name) = result.roaster.name { submission.roaster_name = name; } if let Some(country) = result.roaster.country { submission.roaster_country = country; } if result.roaster.city.is_some() { submission.roaster_city = result.roaster.city; } if result.roaster.homepage.is_some() { submission.roaster_homepage = result.roaster.homepage; } if let Some(name) = result.roast.name { submission.roast_name = name; } if let Some(origin) = result.roast.origin { submission.origin = origin; } if let Some(region) = result.roast.region { submission.region = region; } if let Some(producer) = result.roast.producer { submission.producer = producer; } if let Some(process) = result.roast.process { submission.process = process; } if let Some(notes) = result.roast.tasting_notes { submission.tasting_notes = TastingNotesInput::Text(notes.join(", ")); } Ok(usage) } #[allow(clippy::too_many_lines)] #[tracing::instrument(skip(state, auth_user, headers))] pub(crate) async fn submit_scan( State(state): State, auth_user: AuthenticatedUser, headers: HeaderMap, payload: FlexiblePayload, ) -> Result { let (mut submission, _) = payload.into_parts(); // If the roast already exists (matched during extraction), skip creation if let Some(roast_id) = parse_matched_roast_id(submission.matched_roast_id.as_ref()) { let scan_image = submission.scan_image.take(); return submit_existing_roast(&state, &headers, roast_id, &submission, scan_image).await; } // Check for raw input (image/prompt triggers extraction first) let has_raw_input = submission.image.as_deref().is_some_and(|s| !s.is_empty()) || submission.prompt.as_deref().is_some_and(|s| !s.is_empty()); // Preserve scan image: either from the dedicated field (two-step Datastar flow) // or from the raw image input (one-step API flow, before extraction consumes it) let scan_image = submission .scan_image .take() .or_else(|| submission.image.cloned()) .filter(|s| !s.is_empty()); if has_raw_input { let usage = extract_into_submission(&state, &mut submission).await?; crate::application::routes::support::record_ai_usage( state.ai_usage_repo.clone(), auth_user.0.id, &state.openrouter_model, "extract-bag-scan", usage, ); } // Build and normalize the roaster let new_roaster = NewRoaster { name: submission.roaster_name, country: submission.roaster_country, city: submission.roaster_city, homepage: submission.roaster_homepage, created_at: None, } .normalize(); let slug = new_roaster.slug(); // Try to find existing roaster by slug, otherwise create let roaster = match state.roaster_repo.get_by_slug(&slug).await { Ok(existing) => existing, Err(RepositoryError::NotFound) => state .roaster_service .create(new_roaster) .await .map_err(AppError::from)?, Err(err) => return Err(AppError::from(err).into()), }; // Validate and build the roast let tasting_notes = submission.tasting_notes.into_vec(); if !has_raw_input && tasting_notes.is_empty() { return Err(AppError::validation("tasting notes are required").into()); } let new_roast = if has_raw_input { if submission.roast_name.trim().is_empty() { return Err( AppError::validation("could not extract a roast name from the image/text").into(), ); } NewRoast { roaster_id: roaster.id, name: submission.roast_name.trim().to_string(), origin: submission.origin.trim().to_string(), region: submission.region.trim().to_string(), producer: submission.producer.trim().to_string(), process: submission.process.trim().to_string(), tasting_notes, created_at: None, } } else { fn require(field: &str, value: &str) -> Result { let trimmed = value.trim(); if trimmed.is_empty() { Err(AppError::validation(format!("{field} is required"))) } else { Ok(trimmed.to_string()) } } NewRoast { roaster_id: roaster.id, name: require("roast name", &submission.roast_name)?, origin: require("origin", &submission.origin)?, region: require("region", &submission.region)?, producer: require("producer", &submission.producer)?, process: require("process", &submission.process)?, tasting_notes, created_at: None, } }; let roast = state .roast_service .create(new_roast) .await .map_err(AppError::from)?; info!(roaster_id = %roaster.id, roast_id = %roast.id, roast_name = %roast.name, "scan created roast"); save_deferred_image( &state, "roast", roast.id.into_inner(), scan_image.as_deref(), ) .await; // Optionally create a bag let wants_bag = submission .open_bag .as_deref() .is_some_and(|v| v == "true" || v == "on"); if wants_bag { let amount = submission.bag_amount.unwrap_or(250.0); let new_bag = NewBag { roast_id: roast.id, roast_date: None, amount, created_at: None, }; state .bag_service .create(new_bag) .await .map_err(AppError::from)?; } let redirect = format!("/roasters/{}/roasts/{}", roaster.slug, roast.slug); let roast_id = roast.id.into_inner(); if is_datastar_request(&headers) { use serde_json::Value; let signals = vec![ ("_roast-id", Value::String(roast_id.to_string())), ("_scan-success", Value::String(roast.name.clone())), ("_roaster-name", Value::String(roaster.name.clone())), ]; crate::application::routes::support::render_signals_json(&signals).map_err(ApiError::from) } else { Ok((StatusCode::CREATED, Json(ScanResult { redirect, roast_id })).into_response()) } } fn parse_matched_roast_id(value: Option<&String>) -> Option { value .map(String::as_str) .filter(|s| !s.is_empty()) .and_then(|s| s.parse::().ok()) .map(RoastId::from) } /// Handle submission when the roast already exists — only create a bag if requested. async fn submit_existing_roast( state: &AppState, headers: &HeaderMap, roast_id: RoastId, submission: &BagScanSubmission, scan_image: Option, ) -> Result { let roast_with_roaster = state .roast_repo .get_with_roaster(roast_id) .await .map_err(AppError::from)?; let roast = &roast_with_roaster.roast; let roaster_slug = &roast_with_roaster.roaster_slug; // Save scan image if roast doesn't have one yet if resolve_image_url(state, "roast", roast.id.into_inner()) .await .is_none() { save_deferred_image(state, "roast", roast.id.into_inner(), scan_image.as_deref()).await; } let wants_bag = submission .open_bag .as_deref() .is_some_and(|v| v == "true" || v == "on"); if wants_bag { let amount = submission.bag_amount.unwrap_or(250.0); let new_bag = NewBag { roast_id: roast.id, roast_date: None, amount, created_at: None, }; state .bag_service .create(new_bag) .await .map_err(AppError::from)?; info!(roast_id = %roast.id, roast_name = %roast.name, "scan opened bag for existing roast"); } let redirect = format!("/roasters/{}/roasts/{}", roaster_slug, roast.slug); let roast_id_raw = roast.id.into_inner(); if is_datastar_request(headers) { use serde_json::Value; let signals = vec![ ("_roast-id", Value::String(roast_id_raw.to_string())), ("_scan-success", Value::String(roast.name.clone())), ( "_roaster-name", Value::String(roast_with_roaster.roaster_name.clone()), ), ]; crate::application::routes::support::render_signals_json(&signals).map_err(ApiError::from) } else { Ok(( StatusCode::CREATED, Json(ScanResult { redirect, roast_id: roast_id_raw, }), ) .into_response()) } }