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.
This commit is contained in:
Jon Seager 2026-02-11 08:37:03 +00:00
parent 56a1637d63
commit 4190fc2620
No known key found for this signature in database
10 changed files with 132 additions and 80 deletions

View file

@ -17,6 +17,7 @@ use crate::application::routes::support::{
use crate::application::state::AppState; use crate::application::state::AppState;
use crate::domain::bags::{BagFilter, BagSortKey, BagWithRoast, NewBag, UpdateBag}; use crate::domain::bags::{BagFilter, BagSortKey, BagWithRoast, NewBag, UpdateBag};
use crate::domain::ids::{BagId, RoastId}; use crate::domain::ids::{BagId, RoastId};
use crate::domain::images::ImageData;
use crate::domain::listing::{ListRequest, SortDirection}; use crate::domain::listing::{ListRequest, SortDirection};
use crate::presentation::web::templates::BagListTemplate; use crate::presentation::web::templates::BagListTemplate;
use crate::presentation::web::views::{BagView, ListNavigator, Paginated}; use crate::presentation::web::views::{BagView, ListNavigator, Paginated};
@ -53,7 +54,7 @@ pub(crate) async fn load_bag_page(
Ok(BagPageData { bags, navigator }) Ok(BagPageData { bags, navigator })
} }
#[tracing::instrument(skip(state, _auth_user, headers, query, payload))] #[tracing::instrument(skip(state, _auth_user, headers, query))]
pub(crate) async fn create_bag( pub(crate) async fn create_bag(
State(state): State<AppState>, State(state): State<AppState>,
_auth_user: AuthenticatedUser, _auth_user: AuthenticatedUser,
@ -139,7 +140,7 @@ pub(crate) struct UpdateBagSubmission {
#[serde(default)] #[serde(default)]
created_at: Option<DateTime<Utc>>, created_at: Option<DateTime<Utc>>,
#[serde(default)] #[serde(default)]
image: Option<String>, image: ImageData,
} }
impl UpdateBagSubmission { impl UpdateBagSubmission {
@ -153,7 +154,7 @@ impl UpdateBagSubmission {
finished_at: self.finished_at, finished_at: self.finished_at,
created_at: self.created_at, created_at: self.created_at,
}; };
(update, self.image) (update, self.image.into_inner())
} }
} }
@ -168,7 +169,7 @@ impl_has_changes!(
created_at created_at
); );
#[tracing::instrument(skip(state, _auth_user, headers, query, payload))] #[tracing::instrument(skip(state, _auth_user, headers, query))]
pub(crate) async fn update_bag( pub(crate) async fn update_bag(
State(state): State<AppState>, State(state): State<AppState>,
_auth_user: AuthenticatedUser, _auth_user: AuthenticatedUser,

View file

@ -21,6 +21,7 @@ use crate::domain::brews::{
}; };
use crate::domain::gear::{GearCategory, GearFilter, GearSortKey}; use crate::domain::gear::{GearCategory, GearFilter, GearSortKey};
use crate::domain::ids::{BagId, BrewId, GearId}; use crate::domain::ids::{BagId, BrewId, GearId};
use crate::domain::images::ImageData;
use crate::domain::listing::{ListRequest, PageSize, SortDirection}; use crate::domain::listing::{ListRequest, PageSize, SortDirection};
use crate::presentation::web::templates::BrewListTemplate; use crate::presentation::web::templates::BrewListTemplate;
use crate::presentation::web::views::{ use crate::presentation::web::views::{
@ -198,11 +199,11 @@ pub(crate) struct NewBrewSubmission {
#[serde(default)] #[serde(default)]
created_at: Option<DateTime<Utc>>, created_at: Option<DateTime<Utc>>,
#[serde(default)] #[serde(default)]
image: Option<String>, image: ImageData,
} }
impl NewBrewSubmission { impl NewBrewSubmission {
fn into_new_brew(self) -> Result<NewBrew, AppError> { fn into_parts(self) -> Result<(NewBrew, Option<String>), AppError> {
if self.coffee_weight <= 0.0 { if self.coffee_weight <= 0.0 {
return Err(AppError::validation("coffee weight must be positive")); return Err(AppError::validation("coffee weight must be positive"));
} }
@ -223,7 +224,8 @@ impl NewBrewSubmission {
return Err(AppError::validation("brew time must be positive")); return Err(AppError::validation("brew time must be positive"));
} }
Ok(NewBrew { Ok((
NewBrew {
bag_id: self.bag_id, bag_id: self.bag_id,
coffee_weight: self.coffee_weight, coffee_weight: self.coffee_weight,
grinder_id: self.grinder_id, grinder_id: self.grinder_id,
@ -235,11 +237,13 @@ impl NewBrewSubmission {
quick_notes: self.quick_notes, quick_notes: self.quick_notes,
brew_time: self.brew_time, brew_time: self.brew_time,
created_at: self.created_at, created_at: self.created_at,
}) },
self.image.into_inner(),
))
} }
} }
#[tracing::instrument(skip(state, _auth_user, headers, query, payload))] #[tracing::instrument(skip(state, _auth_user, headers, query))]
pub(crate) async fn create_brew( pub(crate) async fn create_brew(
State(state): State<AppState>, State(state): State<AppState>,
_auth_user: AuthenticatedUser, _auth_user: AuthenticatedUser,
@ -249,8 +253,7 @@ pub(crate) async fn create_brew(
) -> Result<Response, ApiError> { ) -> Result<Response, ApiError> {
let (request, search) = query.into_request_and_search::<BrewSortKey>(); let (request, search) = query.into_request_and_search::<BrewSortKey>();
let (submission, source) = payload.into_parts(); let (submission, source) = payload.into_parts();
let image_data_url = submission.image.clone(); let (new_brew, image_data_url) = submission.into_parts().map_err(ApiError::from)?;
let new_brew = submission.into_new_brew().map_err(ApiError::from)?;
let enriched = state let enriched = state
.brew_service .brew_service
@ -350,7 +353,7 @@ pub(crate) struct UpdateBrewSubmission {
#[serde(default)] #[serde(default)]
created_at: Option<DateTime<Utc>>, created_at: Option<DateTime<Utc>>,
#[serde(default)] #[serde(default)]
image: Option<String>, image: ImageData,
} }
impl UpdateBrewSubmission { impl UpdateBrewSubmission {
@ -372,7 +375,7 @@ impl UpdateBrewSubmission {
brew_time: self.brew_time, brew_time: self.brew_time,
created_at: self.created_at, created_at: self.created_at,
}; };
(update, self.image) (update, self.image.into_inner())
} }
} }
@ -391,7 +394,7 @@ impl_has_changes!(
created_at created_at
); );
#[tracing::instrument(skip(state, _auth_user, headers, payload))] #[tracing::instrument(skip(state, _auth_user, headers))]
pub(crate) async fn update_brew( pub(crate) async fn update_brew(
State(state): State<AppState>, State(state): State<AppState>,
_auth_user: AuthenticatedUser, _auth_user: AuthenticatedUser,

View file

@ -18,6 +18,7 @@ use crate::application::routes::support::{
use crate::application::state::AppState; use crate::application::state::AppState;
use crate::domain::cafes::{Cafe, CafeSortKey, NewCafe, UpdateCafe}; use crate::domain::cafes::{Cafe, CafeSortKey, NewCafe, UpdateCafe};
use crate::domain::ids::CafeId; use crate::domain::ids::CafeId;
use crate::domain::images::ImageData;
use crate::domain::listing::{ListRequest, SortDirection}; use crate::domain::listing::{ListRequest, SortDirection};
use crate::infrastructure::foursquare; use crate::infrastructure::foursquare;
use crate::presentation::web::templates::{CafeListTemplate, NearbyCafesFragment}; use crate::presentation::web::templates::{CafeListTemplate, NearbyCafesFragment};
@ -71,7 +72,7 @@ pub(crate) struct NewCafeSubmission {
#[serde(default)] #[serde(default)]
created_at: Option<chrono::DateTime<chrono::Utc>>, created_at: Option<chrono::DateTime<chrono::Utc>>,
#[serde(default)] #[serde(default)]
image: Option<String>, image: ImageData,
} }
impl NewCafeSubmission { impl NewCafeSubmission {
@ -85,11 +86,11 @@ impl NewCafeSubmission {
website: self.website, website: self.website,
created_at: self.created_at, created_at: self.created_at,
}; };
(cafe, self.image) (cafe, self.image.into_inner())
} }
} }
#[tracing::instrument(skip(state, _auth_user, headers, query, payload))] #[tracing::instrument(skip(state, _auth_user, headers, query))]
pub(crate) async fn create_cafe( pub(crate) async fn create_cafe(
State(state): State<AppState>, State(state): State<AppState>,
_auth_user: AuthenticatedUser, _auth_user: AuthenticatedUser,
@ -159,7 +160,7 @@ pub(crate) struct UpdateCafeSubmission {
#[serde(default)] #[serde(default)]
created_at: Option<chrono::DateTime<chrono::Utc>>, created_at: Option<chrono::DateTime<chrono::Utc>>,
#[serde(default)] #[serde(default)]
image: Option<String>, image: ImageData,
} }
impl UpdateCafeSubmission { impl UpdateCafeSubmission {
@ -173,7 +174,7 @@ impl UpdateCafeSubmission {
website: self.website, website: self.website,
created_at: self.created_at, created_at: self.created_at,
}; };
(update, self.image) (update, self.image.into_inner())
} }
} }
@ -181,7 +182,7 @@ impl_has_changes!(
UpdateCafe, name, city, country, latitude, longitude, website, created_at UpdateCafe, name, city, country, latitude, longitude, website, created_at
); );
#[tracing::instrument(skip(state, _auth_user, headers, payload))] #[tracing::instrument(skip(state, _auth_user, headers))]
pub(crate) async fn update_cafe( pub(crate) async fn update_cafe(
State(state): State<AppState>, State(state): State<AppState>,
_auth_user: AuthenticatedUser, _auth_user: AuthenticatedUser,

View file

@ -14,6 +14,7 @@ use crate::application::state::AppState;
use crate::domain::cafes::NewCafe; use crate::domain::cafes::NewCafe;
use crate::domain::cups::NewCup; use crate::domain::cups::NewCup;
use crate::domain::ids::{CafeId, RoastId}; use crate::domain::ids::{CafeId, RoastId};
use crate::domain::images::ImageData;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(crate) struct CheckInSubmission { pub(crate) struct CheckInSubmission {
@ -33,12 +34,12 @@ pub(crate) struct CheckInSubmission {
cafe_website: Option<String>, cafe_website: Option<String>,
roast_id: String, roast_id: String,
#[serde(default)] #[serde(default)]
cafe_image: Option<String>, cafe_image: ImageData,
#[serde(default)] #[serde(default)]
cup_image: Option<String>, cup_image: ImageData,
} }
#[tracing::instrument(skip(state, _auth_user, headers, payload))] #[tracing::instrument(skip(state, _auth_user, headers))]
pub(crate) async fn submit_checkin( pub(crate) async fn submit_checkin(
State(state): State<AppState>, State(state): State<AppState>,
_auth_user: AuthenticatedUser, _auth_user: AuthenticatedUser,

View file

@ -19,6 +19,7 @@ use crate::application::routes::support::{
use crate::application::state::AppState; use crate::application::state::AppState;
use crate::domain::cups::{CupFilter, CupSortKey, CupWithDetails, NewCup, UpdateCup}; use crate::domain::cups::{CupFilter, CupSortKey, CupWithDetails, NewCup, UpdateCup};
use crate::domain::ids::{CafeId, CupId, RoastId}; use crate::domain::ids::{CafeId, CupId, RoastId};
use crate::domain::images::ImageData;
use crate::domain::listing::{ListRequest, SortDirection}; use crate::domain::listing::{ListRequest, SortDirection};
use crate::presentation::web::templates::CupListTemplate; use crate::presentation::web::templates::CupListTemplate;
use crate::presentation::web::views::{CupView, ListNavigator, Paginated}; use crate::presentation::web::views::{CupView, ListNavigator, Paginated};
@ -49,7 +50,7 @@ pub(crate) async fn load_cup_page(
)) ))
} }
#[tracing::instrument(skip(state, _auth_user, headers, query, payload))] #[tracing::instrument(skip(state, _auth_user, headers, query))]
pub(crate) async fn create_cup( pub(crate) async fn create_cup(
State(state): State<AppState>, State(state): State<AppState>,
_auth_user: AuthenticatedUser, _auth_user: AuthenticatedUser,
@ -104,7 +105,7 @@ pub(crate) struct UpdateCupSubmission {
#[serde(default)] #[serde(default)]
created_at: Option<DateTime<Utc>>, created_at: Option<DateTime<Utc>>,
#[serde(default)] #[serde(default)]
image: Option<String>, image: ImageData,
} }
impl UpdateCupSubmission { impl UpdateCupSubmission {
@ -114,13 +115,13 @@ impl UpdateCupSubmission {
cafe_id: self.cafe_id, cafe_id: self.cafe_id,
created_at: self.created_at, created_at: self.created_at,
}; };
(update, self.image) (update, self.image.into_inner())
} }
} }
impl_has_changes!(UpdateCup, roast_id, cafe_id, created_at); impl_has_changes!(UpdateCup, roast_id, cafe_id, created_at);
#[tracing::instrument(skip(state, _auth_user, headers, payload))] #[tracing::instrument(skip(state, _auth_user, headers))]
pub(crate) async fn update_cup( pub(crate) async fn update_cup(
State(state): State<AppState>, State(state): State<AppState>,
_auth_user: AuthenticatedUser, _auth_user: AuthenticatedUser,

View file

@ -22,6 +22,7 @@ use crate::application::routes::support::{
use crate::application::state::AppState; use crate::application::state::AppState;
use crate::domain::gear::{Gear, GearCategory, GearFilter, GearSortKey, NewGear, UpdateGear}; use crate::domain::gear::{Gear, GearCategory, GearFilter, GearSortKey, NewGear, UpdateGear};
use crate::domain::ids::GearId; use crate::domain::ids::GearId;
use crate::domain::images::ImageData;
use crate::domain::listing::{ListRequest, SortDirection}; use crate::domain::listing::{ListRequest, SortDirection};
use crate::presentation::web::templates::GearListTemplate; use crate::presentation::web::templates::GearListTemplate;
use crate::presentation::web::views::{GearView, ListNavigator, Paginated}; use crate::presentation::web::views::{GearView, ListNavigator, Paginated};
@ -51,7 +52,7 @@ pub(crate) async fn load_gear_page(
)) ))
} }
#[tracing::instrument(skip(state, _auth_user, headers, query, payload))] #[tracing::instrument(skip(state, _auth_user, headers, query))]
pub(crate) async fn create_gear( pub(crate) async fn create_gear(
State(state): State<AppState>, State(state): State<AppState>,
_auth_user: AuthenticatedUser, _auth_user: AuthenticatedUser,
@ -61,8 +62,7 @@ pub(crate) async fn create_gear(
) -> Result<Response, ApiError> { ) -> Result<Response, ApiError> {
let (request, search) = query.into_request_and_search::<GearSortKey>(); let (request, search) = query.into_request_and_search::<GearSortKey>();
let (submission, source) = payload.into_parts(); let (submission, source) = payload.into_parts();
let image_data_url = submission.image.clone(); let (new_gear, image_data_url) = submission.into_parts().map_err(ApiError::from)?;
let new_gear = submission.into_new_gear().map_err(ApiError::from)?;
let gear = state let gear = state
.gear_service .gear_service
@ -136,7 +136,7 @@ pub(crate) struct UpdateGearSubmission {
#[serde(default)] #[serde(default)]
created_at: Option<DateTime<Utc>>, created_at: Option<DateTime<Utc>>,
#[serde(default)] #[serde(default)]
image: Option<String>, image: ImageData,
} }
impl UpdateGearSubmission { impl UpdateGearSubmission {
@ -146,13 +146,13 @@ impl UpdateGearSubmission {
model: self.model, model: self.model,
created_at: self.created_at, created_at: self.created_at,
}; };
(update, self.image) (update, self.image.into_inner())
} }
} }
impl_has_changes!(UpdateGear, make, model, created_at); impl_has_changes!(UpdateGear, make, model, created_at);
#[tracing::instrument(skip(state, _auth_user, headers, payload))] #[tracing::instrument(skip(state, _auth_user, headers))]
pub(crate) async fn update_gear( pub(crate) async fn update_gear(
State(state): State<AppState>, State(state): State<AppState>,
_auth_user: AuthenticatedUser, _auth_user: AuthenticatedUser,
@ -210,11 +210,11 @@ pub(crate) struct NewGearSubmission {
#[serde(default)] #[serde(default)]
created_at: Option<DateTime<Utc>>, created_at: Option<DateTime<Utc>>,
#[serde(default)] #[serde(default)]
image: Option<String>, image: ImageData,
} }
impl NewGearSubmission { impl NewGearSubmission {
fn into_new_gear(self) -> Result<NewGear, AppError> { fn into_parts(self) -> Result<(NewGear, Option<String>), AppError> {
let category = GearCategory::from_str(&self.category) let category = GearCategory::from_str(&self.category)
.map_err(|()| AppError::validation("invalid category"))?; .map_err(|()| AppError::validation("invalid category"))?;
@ -226,12 +226,15 @@ impl NewGearSubmission {
return Err(AppError::validation("model cannot be empty")); return Err(AppError::validation("model cannot be empty"));
} }
Ok(NewGear { Ok((
NewGear {
category, category,
make: self.make, make: self.make,
model: self.model, model: self.model,
created_at: self.created_at, created_at: self.created_at,
}) },
self.image.into_inner(),
))
} }
} }

View file

@ -17,6 +17,7 @@ use crate::application::routes::support::{
}; };
use crate::application::state::AppState; use crate::application::state::AppState;
use crate::domain::ids::RoasterId; use crate::domain::ids::RoasterId;
use crate::domain::images::ImageData;
use crate::domain::listing::{ListRequest, SortDirection}; use crate::domain::listing::{ListRequest, SortDirection};
use crate::domain::roasters::{NewRoaster, Roaster, RoasterSortKey, UpdateRoaster}; use crate::domain::roasters::{NewRoaster, Roaster, RoasterSortKey, UpdateRoaster};
use crate::infrastructure::ai::{self, ExtractionInput}; use crate::infrastructure::ai::{self, ExtractionInput};
@ -72,7 +73,7 @@ pub(crate) struct NewRoasterSubmission {
#[serde(default)] #[serde(default)]
created_at: Option<chrono::DateTime<chrono::Utc>>, created_at: Option<chrono::DateTime<chrono::Utc>>,
#[serde(default)] #[serde(default)]
image: Option<String>, image: ImageData,
} }
impl NewRoasterSubmission { impl NewRoasterSubmission {
@ -84,11 +85,11 @@ impl NewRoasterSubmission {
homepage: self.homepage, homepage: self.homepage,
created_at: self.created_at, created_at: self.created_at,
}; };
(roaster, self.image) (roaster, self.image.into_inner())
} }
} }
#[tracing::instrument(skip(state, _auth_user, headers, query, payload))] #[tracing::instrument(skip(state, _auth_user, headers, query))]
pub(crate) async fn create_roaster( pub(crate) async fn create_roaster(
State(state): State<AppState>, State(state): State<AppState>,
_auth_user: AuthenticatedUser, _auth_user: AuthenticatedUser,
@ -154,7 +155,7 @@ pub(crate) struct UpdateRoasterSubmission {
#[serde(default)] #[serde(default)]
created_at: Option<chrono::DateTime<chrono::Utc>>, created_at: Option<chrono::DateTime<chrono::Utc>>,
#[serde(default)] #[serde(default)]
image: Option<String>, image: ImageData,
} }
impl UpdateRoasterSubmission { impl UpdateRoasterSubmission {
@ -166,13 +167,13 @@ impl UpdateRoasterSubmission {
homepage: self.homepage, homepage: self.homepage,
created_at: self.created_at, created_at: self.created_at,
}; };
(update, self.image) (update, self.image.into_inner())
} }
} }
impl_has_changes!(UpdateRoaster, name, country, city, homepage, created_at); impl_has_changes!(UpdateRoaster, name, country, city, homepage, created_at);
#[tracing::instrument(skip(state, _auth_user, headers, payload))] #[tracing::instrument(skip(state, _auth_user, headers))]
pub(crate) async fn update_roaster( pub(crate) async fn update_roaster(
State(state): State<AppState>, State(state): State<AppState>,
_auth_user: AuthenticatedUser, _auth_user: AuthenticatedUser,

View file

@ -17,6 +17,7 @@ use crate::application::routes::support::{
}; };
use crate::application::state::AppState; use crate::application::state::AppState;
use crate::domain::ids::{RoastId, RoasterId}; use crate::domain::ids::{RoastId, RoasterId};
use crate::domain::images::ImageData;
use crate::domain::listing::{ListRequest, SortDirection}; use crate::domain::listing::{ListRequest, SortDirection};
use crate::domain::roasts::{NewRoast, RoastSortKey, RoastWithRoaster, UpdateRoast}; use crate::domain::roasts::{NewRoast, RoastSortKey, RoastWithRoaster, UpdateRoast};
use crate::infrastructure::ai::{self, ExtractionInput}; use crate::infrastructure::ai::{self, ExtractionInput};
@ -49,7 +50,7 @@ pub(crate) async fn load_roast_page(
)) ))
} }
#[tracing::instrument(skip(state, _auth_user, headers, query, payload))] #[tracing::instrument(skip(state, _auth_user, headers, query))]
pub(crate) async fn create_roast( pub(crate) async fn create_roast(
State(state): State<AppState>, State(state): State<AppState>,
_auth_user: AuthenticatedUser, _auth_user: AuthenticatedUser,
@ -59,8 +60,7 @@ pub(crate) async fn create_roast(
) -> Result<Response, ApiError> { ) -> Result<Response, ApiError> {
let (request, search) = query.into_request_and_search::<RoastSortKey>(); let (request, search) = query.into_request_and_search::<RoastSortKey>();
let (submission, source) = payload.into_parts(); let (submission, source) = payload.into_parts();
let image_data_url = submission.image.clone(); let (new_roast, image_data_url) = submission.into_parts().map_err(ApiError::from)?;
let new_roast = submission.into_new_roast().map_err(ApiError::from)?;
state state
.roaster_repo .roaster_repo
@ -196,7 +196,7 @@ pub(crate) struct UpdateRoastSubmission {
#[serde(default)] #[serde(default)]
created_at: Option<DateTime<Utc>>, created_at: Option<DateTime<Utc>>,
#[serde(default)] #[serde(default)]
image: Option<String>, image: ImageData,
} }
impl UpdateRoastSubmission { impl UpdateRoastSubmission {
@ -211,7 +211,7 @@ impl UpdateRoastSubmission {
process: self.process, process: self.process,
created_at: self.created_at, created_at: self.created_at,
}; };
(update, self.image) (update, self.image.into_inner())
} }
} }
@ -227,7 +227,7 @@ impl_has_changes!(
created_at created_at
); );
#[tracing::instrument(skip(state, _auth_user, headers, payload))] #[tracing::instrument(skip(state, _auth_user, headers))]
pub(crate) async fn update_roast( pub(crate) async fn update_roast(
State(state): State<AppState>, State(state): State<AppState>,
_auth_user: AuthenticatedUser, _auth_user: AuthenticatedUser,
@ -288,11 +288,11 @@ pub(crate) struct NewRoastSubmission {
#[serde(default)] #[serde(default)]
created_at: Option<DateTime<Utc>>, created_at: Option<DateTime<Utc>>,
#[serde(default)] #[serde(default)]
image: Option<String>, image: ImageData,
} }
impl NewRoastSubmission { impl NewRoastSubmission {
fn into_new_roast(self) -> Result<NewRoast, AppError> { fn into_parts(self) -> Result<(NewRoast, Option<String>), AppError> {
fn require(field: &str, value: String) -> Result<String, AppError> { fn require(field: &str, value: String) -> Result<String, AppError> {
let trimmed = value.trim(); let trimmed = value.trim();
if trimmed.is_empty() { if trimmed.is_empty() {
@ -318,7 +318,8 @@ impl NewRoastSubmission {
return Err(AppError::validation("tasting notes are required")); return Err(AppError::validation("tasting notes are required"));
} }
Ok(NewRoast { Ok((
NewRoast {
roaster_id, roaster_id,
name, name,
origin, origin,
@ -327,7 +328,9 @@ impl NewRoastSubmission {
tasting_notes, tasting_notes,
process, process,
created_at: self.created_at, created_at: self.created_at,
}) },
self.image.into_inner(),
))
} }
} }

View file

@ -14,6 +14,7 @@ use crate::application::state::AppState;
use crate::domain::bags::NewBag; use crate::domain::bags::NewBag;
use crate::domain::errors::RepositoryError; use crate::domain::errors::RepositoryError;
use crate::domain::ids::RoastId; use crate::domain::ids::RoastId;
use crate::domain::images::ImageData;
use crate::domain::roasters::NewRoaster; use crate::domain::roasters::NewRoaster;
use crate::domain::roasts::NewRoast; use crate::domain::roasts::NewRoast;
use crate::infrastructure::ai::{self, ExtractionInput, Usage}; use crate::infrastructure::ai::{self, ExtractionInput, Usage};
@ -158,7 +159,7 @@ fn default_tasting_notes() -> TastingNotesInput {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(crate) struct BagScanSubmission { pub(crate) struct BagScanSubmission {
#[serde(default)] #[serde(default)]
image: Option<String>, image: ImageData,
#[serde(default)] #[serde(default)]
prompt: Option<String>, prompt: Option<String>,
#[serde(default)] #[serde(default)]
@ -186,7 +187,7 @@ pub(crate) struct BagScanSubmission {
#[serde(default)] #[serde(default)]
matched_roast_id: Option<String>, matched_roast_id: Option<String>,
#[serde(default)] #[serde(default)]
scan_image: Option<String>, scan_image: ImageData,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
@ -250,7 +251,7 @@ async fn extract_into_submission(
} }
#[allow(clippy::too_many_lines)] #[allow(clippy::too_many_lines)]
#[tracing::instrument(skip(state, auth_user, headers, payload))] #[tracing::instrument(skip(state, auth_user, headers))]
pub(crate) async fn submit_scan( pub(crate) async fn submit_scan(
State(state): State<AppState>, State(state): State<AppState>,
auth_user: AuthenticatedUser, auth_user: AuthenticatedUser,
@ -274,7 +275,7 @@ pub(crate) async fn submit_scan(
let scan_image = submission let scan_image = submission
.scan_image .scan_image
.take() .take()
.or_else(|| submission.image.clone()) .or_else(|| submission.image.cloned())
.filter(|s| !s.is_empty()); .filter(|s| !s.is_empty());
if has_raw_input { if has_raw_input {

View file

@ -1,3 +1,7 @@
use std::fmt;
use serde::Deserialize;
/// An image associated with an entity (roaster, roast, gear, or cafe). /// An image associated with an entity (roaster, roast, gear, or cafe).
pub struct EntityImage { pub struct EntityImage {
pub entity_type: String, pub entity_type: String,
@ -6,3 +10,36 @@ pub struct EntityImage {
pub image_data: Vec<u8>, pub image_data: Vec<u8>,
pub thumbnail_data: Vec<u8>, pub thumbnail_data: Vec<u8>,
} }
/// Wrapper for image data URLs that redacts content in `Debug` output,
/// allowing payloads to be traced without logging raw base64 image data.
#[derive(Default, Deserialize)]
#[serde(transparent)]
pub struct ImageData(Option<String>);
impl ImageData {
pub fn into_inner(self) -> Option<String> {
self.0
}
pub fn as_deref(&self) -> Option<&str> {
self.0.as_deref()
}
pub fn take(&mut self) -> Option<String> {
self.0.take()
}
pub fn cloned(&self) -> Option<String> {
self.0.clone()
}
}
impl fmt::Debug for ImageData {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.0 {
Some(_) => write!(f, "Some(<image>)"),
None => write!(f, "None"),
}
}
}