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.
488 lines
15 KiB
Rust
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())
|
|
}
|
|
}
|