brewlog/src/application/routes/api/coffee/scan.rs
Jon Seager 4190fc2620
feat: log payload fields in trace spans by redacting image data
Add ImageData newtype that wraps Option<String> with a custom Debug impl
showing Some(<image>)/None instead of raw base64. Replace image fields on
all 14 submission structs and remove payload from tracing skip lists so
textual/numeric fields appear in spans.
2026-02-11 08:37:03 +00:00

488 lines
15 KiB
Rust

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<AppState>,
auth_user: AuthenticatedUser,
headers: HeaderMap,
payload: FlexiblePayload<ExtractionInput>,
) -> Result<Response, ApiError> {
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<String>,
#[serde(default)]
roaster_name: String,
#[serde(default)]
roaster_country: String,
roaster_city: Option<String>,
roaster_homepage: Option<String>,
#[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<String>,
#[serde(default)]
bag_amount: Option<f64>,
#[serde(default)]
matched_roast_id: Option<String>,
#[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<Option<Usage>, 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<AppState>,
auth_user: AuthenticatedUser,
headers: HeaderMap,
payload: FlexiblePayload<BagScanSubmission>,
) -> Result<Response, ApiError> {
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<String, AppError> {
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<RoastId> {
value
.map(String::as_str)
.filter(|s| !s.is_empty())
.and_then(|s| s.parse::<i64>().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<String>,
) -> Result<Response, ApiError> {
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())
}
}