refactor: add EntityType enum and typed IDs in TimelineBrewData

Replace stringly-typed entity references with a compile-time-safe
EntityType enum throughout timeline events, images, and repository
operations. Also replace raw i64 fields in TimelineBrewData with
typed BagId/GearId wrappers.
This commit is contained in:
Jon Seager 2026-02-13 14:28:13 +00:00
parent c58c60c783
commit de49f7c3b1
No known key found for this signature in database
34 changed files with 305 additions and 147 deletions

View file

@ -16,6 +16,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::entity_type::EntityType;
use crate::domain::ids::{BagId, RoastId}; use crate::domain::ids::{BagId, RoastId};
use crate::domain::images::ImageData; use crate::domain::images::ImageData;
use crate::domain::listing::{ListRequest, SortDirection}; use crate::domain::listing::{ListRequest, SortDirection};
@ -221,7 +222,13 @@ pub(crate) async fn update_bag(
info!(%id, closed = ?update.closed, "bag updated"); info!(%id, closed = ?update.closed, "bag updated");
state.stats_invalidator.invalidate(); state.stats_invalidator.invalidate();
save_deferred_image(&state, "bag", i64::from(bag.id), image_data_url.as_deref()).await; save_deferred_image(
&state,
EntityType::Bag,
i64::from(bag.id),
image_data_url.as_deref(),
)
.await;
if is_datastar_request(&headers) { if is_datastar_request(&headers) {
let from_bag_page = headers let from_bag_page = headers

View file

@ -19,6 +19,7 @@ use crate::domain::bags::BagFilter;
use crate::domain::brews::{ use crate::domain::brews::{
BrewFilter, BrewSortKey, BrewWithDetails, NewBrew, QuickNote, UpdateBrew, BrewFilter, BrewSortKey, BrewWithDetails, NewBrew, QuickNote, UpdateBrew,
}; };
use crate::domain::entity_type::EntityType;
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::images::ImageData;
@ -267,7 +268,7 @@ pub(crate) async fn create_brew(
save_deferred_image( save_deferred_image(
&state, &state,
"brew", EntityType::Brew,
i64::from(enriched.brew.id), i64::from(enriched.brew.id),
image_data_url.as_deref(), image_data_url.as_deref(),
) )
@ -417,7 +418,13 @@ pub(crate) async fn update_brew(
info!(%id, "brew updated"); info!(%id, "brew updated");
state.stats_invalidator.invalidate(); state.stats_invalidator.invalidate();
save_deferred_image(&state, "brew", i64::from(id), image_data_url.as_deref()).await; save_deferred_image(
&state,
EntityType::Brew,
i64::from(id),
image_data_url.as_deref(),
)
.await;
let detail_url = format!("/brews/{id}"); let detail_url = format!("/brews/{id}");
let enriched = state let enriched = state
@ -442,7 +449,7 @@ define_delete_handler!(
render_brew_list_fragment, render_brew_list_fragment,
"type=brews", "type=brews",
"/data?type=brews", "/data?type=brews",
image_type: "brew" image_type: crate::domain::entity_type::EntityType::Brew
); );
async fn render_brew_list_fragment( async fn render_brew_list_fragment(

View file

@ -17,6 +17,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::entity_type::EntityType;
use crate::domain::ids::CafeId; use crate::domain::ids::CafeId;
use crate::domain::images::ImageData; use crate::domain::images::ImageData;
use crate::domain::listing::{ListRequest, SortDirection}; use crate::domain::listing::{ListRequest, SortDirection};
@ -113,7 +114,7 @@ pub(crate) async fn create_cafe(
save_deferred_image( save_deferred_image(
&state, &state,
"cafe", EntityType::Cafe,
i64::from(cafe.id), i64::from(cafe.id),
image_data_url.as_deref(), image_data_url.as_deref(),
) )
@ -206,7 +207,7 @@ pub(crate) async fn update_cafe(
save_deferred_image( save_deferred_image(
&state, &state,
"cafe", EntityType::Cafe,
i64::from(cafe.id), i64::from(cafe.id),
image_data_url.as_deref(), image_data_url.as_deref(),
) )
@ -224,7 +225,7 @@ define_delete_handler!(
render_cafe_list_fragment, render_cafe_list_fragment,
"type=cafes", "type=cafes",
"/data?type=cafes", "/data?type=cafes",
image_type: "cafe" image_type: crate::domain::entity_type::EntityType::Cafe
); );
define_list_fragment_renderer!( define_list_fragment_renderer!(

View file

@ -13,6 +13,7 @@ use crate::application::routes::support::{
use crate::application::state::AppState; 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::entity_type::EntityType;
use crate::domain::ids::{CafeId, RoastId}; use crate::domain::ids::{CafeId, RoastId};
use crate::domain::images::ImageData; use crate::domain::images::ImageData;
@ -85,7 +86,7 @@ pub(crate) async fn submit_checkin(
save_deferred_image( save_deferred_image(
&state, &state,
"cafe", EntityType::Cafe,
i64::from(cafe.id), i64::from(cafe.id),
submission.cafe_image.as_deref(), submission.cafe_image.as_deref(),
) )
@ -108,7 +109,7 @@ pub(crate) async fn submit_checkin(
save_deferred_image( save_deferred_image(
&state, &state,
"cup", EntityType::Cup,
i64::from(cup.id), i64::from(cup.id),
submission.cup_image.as_deref(), submission.cup_image.as_deref(),
) )

View file

@ -18,6 +18,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::entity_type::EntityType;
use crate::domain::ids::{CafeId, CupId, RoastId}; use crate::domain::ids::{CafeId, CupId, RoastId};
use crate::domain::images::ImageData; use crate::domain::images::ImageData;
use crate::domain::listing::{ListRequest, SortDirection}; use crate::domain::listing::{ListRequest, SortDirection};
@ -143,7 +144,13 @@ pub(crate) async fn update_cup(
info!(%id, "cup updated"); info!(%id, "cup updated");
state.stats_invalidator.invalidate(); state.stats_invalidator.invalidate();
save_deferred_image(&state, "cup", i64::from(cup.id), image_data_url.as_deref()).await; save_deferred_image(
&state,
EntityType::Cup,
i64::from(cup.id),
image_data_url.as_deref(),
)
.await;
let detail_url = format!("/cups/{id}"); let detail_url = format!("/cups/{id}");
let enriched = state let enriched = state
@ -168,7 +175,7 @@ define_delete_handler!(
render_cup_list_fragment, render_cup_list_fragment,
"type=cups", "type=cups",
"/data?type=cups", "/data?type=cups",
image_type: "cup" image_type: crate::domain::entity_type::EntityType::Cup
); );
define_list_fragment_renderer!( define_list_fragment_renderer!(

View file

@ -20,6 +20,7 @@ use crate::application::routes::support::{
update_response, validate_update, update_response, validate_update,
}; };
use crate::application::state::AppState; use crate::application::state::AppState;
use crate::domain::entity_type::EntityType;
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::images::ImageData;
@ -75,7 +76,7 @@ pub(crate) async fn create_gear(
save_deferred_image( save_deferred_image(
&state, &state,
"gear", EntityType::Gear,
i64::from(gear.id), i64::from(gear.id),
image_data_url.as_deref(), image_data_url.as_deref(),
) )
@ -176,7 +177,7 @@ pub(crate) async fn update_gear(
save_deferred_image( save_deferred_image(
&state, &state,
"gear", EntityType::Gear,
i64::from(gear.id), i64::from(gear.id),
image_data_url.as_deref(), image_data_url.as_deref(),
) )
@ -194,7 +195,7 @@ define_delete_handler!(
render_gear_list_fragment, render_gear_list_fragment,
"type=gear", "type=gear",
"/data?type=gear", "/data?type=gear",
image_type: "gear" image_type: crate::domain::entity_type::EntityType::Gear
); );
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]

View file

@ -16,6 +16,7 @@ use crate::application::routes::support::{
update_response, validate_update, update_response, validate_update,
}; };
use crate::application::state::AppState; use crate::application::state::AppState;
use crate::domain::entity_type::EntityType;
use crate::domain::ids::RoasterId; use crate::domain::ids::RoasterId;
use crate::domain::images::ImageData; use crate::domain::images::ImageData;
use crate::domain::listing::{ListRequest, SortDirection}; use crate::domain::listing::{ListRequest, SortDirection};
@ -112,7 +113,7 @@ pub(crate) async fn create_roaster(
save_deferred_image( save_deferred_image(
&state, &state,
"roaster", EntityType::Roaster,
i64::from(roaster.id), i64::from(roaster.id),
image_data_url.as_deref(), image_data_url.as_deref(),
) )
@ -197,7 +198,7 @@ pub(crate) async fn update_roaster(
save_deferred_image( save_deferred_image(
&state, &state,
"roaster", EntityType::Roaster,
i64::from(roaster.id), i64::from(roaster.id),
image_data_url.as_deref(), image_data_url.as_deref(),
) )
@ -215,7 +216,7 @@ define_delete_handler!(
render_roaster_list_fragment, render_roaster_list_fragment,
"type=roasters", "type=roasters",
"/data?type=roasters", "/data?type=roasters",
image_type: "roaster" image_type: crate::domain::entity_type::EntityType::Roaster
); );
#[tracing::instrument(skip(state, auth_user, headers, payload))] #[tracing::instrument(skip(state, auth_user, headers, payload))]

View file

@ -16,6 +16,7 @@ use crate::application::routes::support::{
render_redirect_script, update_response, validate_update, render_redirect_script, update_response, validate_update,
}; };
use crate::application::state::AppState; use crate::application::state::AppState;
use crate::domain::entity_type::EntityType;
use crate::domain::ids::{RoastId, RoasterId}; use crate::domain::ids::{RoastId, RoasterId};
use crate::domain::images::ImageData; use crate::domain::images::ImageData;
use crate::domain::listing::{ListRequest, SortDirection}; use crate::domain::listing::{ListRequest, SortDirection};
@ -79,7 +80,7 @@ pub(crate) async fn create_roast(
save_deferred_image( save_deferred_image(
&state, &state,
"roast", EntityType::Roast,
i64::from(roast.id), i64::from(roast.id),
image_data_url.as_deref(), image_data_url.as_deref(),
) )
@ -174,7 +175,7 @@ define_delete_handler!(
render_roast_list_fragment, render_roast_list_fragment,
"type=roasts", "type=roasts",
"/data?type=roasts", "/data?type=roasts",
image_type: "roast" image_type: crate::domain::entity_type::EntityType::Roast
); );
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -249,7 +250,13 @@ pub(crate) async fn update_roast(
info!(%id, "roast updated"); info!(%id, "roast updated");
state.stats_invalidator.invalidate(); state.stats_invalidator.invalidate();
save_deferred_image(&state, "roast", i64::from(id), image_data_url.as_deref()).await; save_deferred_image(
&state,
EntityType::Roast,
i64::from(id),
image_data_url.as_deref(),
)
.await;
let enriched = state let enriched = state
.roast_repo .roast_repo

View file

@ -12,6 +12,7 @@ use crate::application::routes::api::roasts::TastingNotesInput;
use crate::application::routes::support::{FlexiblePayload, is_datastar_request}; use crate::application::routes::support::{FlexiblePayload, is_datastar_request};
use crate::application::state::AppState; use crate::application::state::AppState;
use crate::domain::bags::NewBag; use crate::domain::bags::NewBag;
use crate::domain::entity_type::EntityType;
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::images::ImageData;
@ -366,7 +367,7 @@ pub(crate) async fn submit_scan(
save_deferred_image( save_deferred_image(
&state, &state,
"roast", EntityType::Roast,
roast.id.into_inner(), roast.id.into_inner(),
scan_image.as_deref(), scan_image.as_deref(),
) )
@ -434,11 +435,17 @@ async fn submit_existing_roast(
let roaster_slug = &roast_with_roaster.roaster_slug; let roaster_slug = &roast_with_roaster.roaster_slug;
// Save scan image if roast doesn't have one yet // Save scan image if roast doesn't have one yet
if resolve_image_url(state, "roast", roast.id.into_inner()) if resolve_image_url(state, EntityType::Roast, roast.id.into_inner())
.await .await
.is_none() .is_none()
{ {
save_deferred_image(state, "roast", roast.id.into_inner(), scan_image.as_deref()).await; save_deferred_image(
state,
EntityType::Roast,
roast.id.into_inner(),
scan_image.as_deref(),
)
.await;
} }
let wants_bag = submission let wants_bag = submission

View file

@ -9,12 +9,11 @@ use crate::application::auth::AuthenticatedUser;
use crate::application::errors::{ApiError, AppError}; use crate::application::errors::{ApiError, AppError};
use crate::application::routes::support::{FlexiblePayload, is_datastar_request, render_fragment}; use crate::application::routes::support::{FlexiblePayload, is_datastar_request, render_fragment};
use crate::application::state::AppState; use crate::application::state::AppState;
use crate::domain::entity_type::EntityType;
use crate::domain::images::EntityImage; use crate::domain::images::EntityImage;
use crate::infrastructure::image_processing::process_data_url; use crate::infrastructure::image_processing::process_data_url;
use crate::presentation::web::templates::ImageUploadTemplate; use crate::presentation::web::templates::ImageUploadTemplate;
const VALID_ENTITY_TYPES: &[&str] = &["roaster", "roast", "gear", "cafe", "brew", "cup"];
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(crate) struct ImageUpload { pub(crate) struct ImageUpload {
pub image: String, pub image: String,
@ -26,66 +25,68 @@ pub(crate) struct ImagePath {
pub id: i64, pub id: i64,
} }
fn validate_entity_type(entity_type: &str) -> Result<(), ApiError> { fn parse_entity_type(entity_type: &str) -> Result<EntityType, ApiError> {
if VALID_ENTITY_TYPES.contains(&entity_type) { entity_type
Ok(()) .parse::<EntityType>()
} else { .map_err(|()| AppError::validation(format!("invalid entity type: {entity_type}")).into())
Err(AppError::validation(format!("invalid entity type: {entity_type}")).into())
}
} }
async fn validate_entity_exists( async fn validate_entity_exists(
state: &AppState, state: &AppState,
entity_type: &str, entity_type: EntityType,
id: i64, id: i64,
) -> Result<(), ApiError> { ) -> Result<(), ApiError> {
use crate::domain::ids::{BrewId, CafeId, CupId, GearId, RoastId, RoasterId}; use crate::domain::ids::{BagId, BrewId, CafeId, CupId, GearId, RoastId, RoasterId};
match entity_type { match entity_type {
"roaster" => { EntityType::Roaster => {
state state
.roaster_repo .roaster_repo
.get(RoasterId::from(id)) .get(RoasterId::from(id))
.await .await
.map_err(AppError::from)?; .map_err(AppError::from)?;
} }
"roast" => { EntityType::Roast => {
state state
.roast_repo .roast_repo
.get(RoastId::from(id)) .get(RoastId::from(id))
.await .await
.map_err(AppError::from)?; .map_err(AppError::from)?;
} }
"gear" => { EntityType::Gear => {
state state
.gear_repo .gear_repo
.get(GearId::from(id)) .get(GearId::from(id))
.await .await
.map_err(AppError::from)?; .map_err(AppError::from)?;
} }
"cafe" => { EntityType::Cafe => {
state state
.cafe_repo .cafe_repo
.get(CafeId::from(id)) .get(CafeId::from(id))
.await .await
.map_err(AppError::from)?; .map_err(AppError::from)?;
} }
"brew" => { EntityType::Brew => {
state state
.brew_repo .brew_repo
.get(BrewId::from(id)) .get(BrewId::from(id))
.await .await
.map_err(AppError::from)?; .map_err(AppError::from)?;
} }
"cup" => { EntityType::Cup => {
state state
.cup_repo .cup_repo
.get(CupId::from(id)) .get(CupId::from(id))
.await .await
.map_err(AppError::from)?; .map_err(AppError::from)?;
} }
_ => { EntityType::Bag => {
return Err(AppError::validation(format!("invalid entity type: {entity_type}")).into()); state
.bag_repo
.get(BagId::from(id))
.await
.map_err(AppError::from)?;
} }
} }
@ -100,8 +101,8 @@ pub(crate) async fn upload_image(
Path(path): Path<ImagePath>, Path(path): Path<ImagePath>,
payload: FlexiblePayload<ImageUpload>, payload: FlexiblePayload<ImageUpload>,
) -> Result<Response, ApiError> { ) -> Result<Response, ApiError> {
validate_entity_type(&path.entity_type)?; let entity_type = parse_entity_type(&path.entity_type)?;
validate_entity_exists(&state, &path.entity_type, path.id).await?; validate_entity_exists(&state, entity_type, path.id).await?;
let (upload, _source) = payload.into_parts(); let (upload, _source) = payload.into_parts();
@ -118,7 +119,7 @@ pub(crate) async fn upload_image(
.map_err(|e| AppError::validation(format!("invalid image: {e}")))?; .map_err(|e| AppError::validation(format!("invalid image: {e}")))?;
let image = EntityImage { let image = EntityImage {
entity_type: path.entity_type.clone(), entity_type,
entity_id: path.id, entity_id: path.id,
content_type: processed.content_type, content_type: processed.content_type,
image_data: processed.image_data, image_data: processed.image_data,
@ -155,11 +156,11 @@ pub(crate) async fn get_image(
State(state): State<AppState>, State(state): State<AppState>,
Path(path): Path<ImagePath>, Path(path): Path<ImagePath>,
) -> Result<Response, ApiError> { ) -> Result<Response, ApiError> {
validate_entity_type(&path.entity_type)?; let entity_type = parse_entity_type(&path.entity_type)?;
let image = state let image = state
.image_repo .image_repo
.get(&path.entity_type, path.id) .get(entity_type, path.id)
.await .await
.map_err(AppError::from)?; .map_err(AppError::from)?;
@ -171,11 +172,11 @@ pub(crate) async fn get_thumbnail(
State(state): State<AppState>, State(state): State<AppState>,
Path(path): Path<ImagePath>, Path(path): Path<ImagePath>,
) -> Result<Response, ApiError> { ) -> Result<Response, ApiError> {
validate_entity_type(&path.entity_type)?; let entity_type = parse_entity_type(&path.entity_type)?;
let image = state let image = state
.image_repo .image_repo
.get_thumbnail(&path.entity_type, path.id) .get_thumbnail(entity_type, path.id)
.await .await
.map_err(AppError::from)?; .map_err(AppError::from)?;
@ -189,11 +190,11 @@ pub(crate) async fn delete_image(
headers: HeaderMap, headers: HeaderMap,
Path(path): Path<ImagePath>, Path(path): Path<ImagePath>,
) -> Result<Response, ApiError> { ) -> Result<Response, ApiError> {
validate_entity_type(&path.entity_type)?; let entity_type = parse_entity_type(&path.entity_type)?;
state state
.image_repo .image_repo
.delete(&path.entity_type, path.id) .delete(entity_type, path.id)
.await .await
.map_err(AppError::from)?; .map_err(AppError::from)?;
@ -218,7 +219,7 @@ pub(crate) async fn delete_image(
/// Check if an entity has an image and return its URL if so. /// Check if an entity has an image and return its URL if so.
pub(crate) async fn resolve_image_url( pub(crate) async fn resolve_image_url(
state: &AppState, state: &AppState,
entity_type: &str, entity_type: EntityType,
entity_id: i64, entity_id: i64,
) -> Option<String> { ) -> Option<String> {
state state
@ -234,16 +235,17 @@ pub(crate) async fn resolve_image_url(
/// Accepts `Option<&str>` and no-ops on `None` or empty strings. /// Accepts `Option<&str>` and no-ops on `None` or empty strings.
pub(crate) async fn save_deferred_image( pub(crate) async fn save_deferred_image(
state: &AppState, state: &AppState,
entity_type: &str, entity_type: EntityType,
entity_id: i64, entity_id: i64,
data_url: Option<&str>, data_url: Option<&str>,
) { ) {
let Some(data_url) = data_url.filter(|s| !s.is_empty()) else { let Some(data_url) = data_url.filter(|s| !s.is_empty()) else {
return; return;
}; };
let entity_type_str = entity_type.as_str();
let Ok(_permit) = state.image_semaphore.acquire().await else { let Ok(_permit) = state.image_semaphore.acquire().await else {
tracing::warn!( tracing::warn!(
entity_type, entity_type = entity_type_str,
entity_id, entity_id,
"image semaphore closed, skipping deferred image" "image semaphore closed, skipping deferred image"
); );
@ -254,25 +256,28 @@ pub(crate) async fn save_deferred_image(
let processed = match tokio::task::spawn_blocking(move || process_data_url(&data_url)).await { let processed = match tokio::task::spawn_blocking(move || process_data_url(&data_url)).await {
Ok(Ok(p)) => p, Ok(Ok(p)) => p,
Ok(Err(err)) => { Ok(Err(err)) => {
tracing::warn!(entity_type, entity_id, error = %err, "failed to process deferred image"); tracing::warn!(entity_type = entity_type_str, entity_id, error = %err, "failed to process deferred image");
return; return;
} }
Err(err) => { Err(err) => {
tracing::warn!(entity_type, entity_id, error = %err, "deferred image task panicked"); tracing::warn!(entity_type = entity_type_str, entity_id, error = %err, "deferred image task panicked");
return; return;
} }
}; };
let image = EntityImage { let image = EntityImage {
entity_type: entity_type.to_string(), entity_type,
entity_id, entity_id,
content_type: processed.content_type, content_type: processed.content_type,
image_data: processed.image_data, image_data: processed.image_data,
thumbnail_data: processed.thumbnail_data, thumbnail_data: processed.thumbnail_data,
}; };
if let Err(err) = state.image_repo.upsert(image).await { if let Err(err) = state.image_repo.upsert(image).await {
tracing::warn!(entity_type, entity_id, error = %err, "failed to save deferred image"); tracing::warn!(entity_type = entity_type_str, entity_id, error = %err, "failed to save deferred image");
} else { } else {
info!(entity_type, entity_id, "deferred image saved"); info!(
entity_type = entity_type_str,
entity_id, "deferred image saved"
);
} }
} }

View file

@ -89,7 +89,7 @@ macro_rules! define_delete_handler {
($fn_name:ident, $id_type:ty, $sort_key:ty, $repo_field:ident, $render_fragment:path, $referer_match:literal, $redirect_url:literal) => { ($fn_name:ident, $id_type:ty, $sort_key:ty, $repo_field:ident, $render_fragment:path, $referer_match:literal, $redirect_url:literal) => {
define_delete_handler!(@inner $fn_name, $id_type, $sort_key, $repo_field, $render_fragment, $referer_match, $redirect_url, None); define_delete_handler!(@inner $fn_name, $id_type, $sort_key, $repo_field, $render_fragment, $referer_match, $redirect_url, None);
}; };
($fn_name:ident, $id_type:ty, $sort_key:ty, $repo_field:ident, $render_fragment:path, $referer_match:literal, $redirect_url:literal, image_type: $image_type:literal) => { ($fn_name:ident, $id_type:ty, $sort_key:ty, $repo_field:ident, $render_fragment:path, $referer_match:literal, $redirect_url:literal, image_type: $image_type:expr) => {
define_delete_handler!(@inner $fn_name, $id_type, $sort_key, $repo_field, $render_fragment, $referer_match, $redirect_url, Some($image_type)); define_delete_handler!(@inner $fn_name, $id_type, $sort_key, $repo_field, $render_fragment, $referer_match, $redirect_url, Some($image_type));
}; };
(@inner $fn_name:ident, $id_type:ty, $sort_key:ty, $repo_field:ident, $render_fragment:path, $referer_match:literal, $redirect_url:literal, $image_type:expr) => { (@inner $fn_name:ident, $id_type:ty, $sort_key:ty, $repo_field:ident, $render_fragment:path, $referer_match:literal, $redirect_url:literal, $image_type:expr) => {

View file

@ -9,6 +9,7 @@ use crate::application::routes::api::images::resolve_image_url;
use crate::application::routes::render_html; use crate::application::routes::render_html;
use crate::application::routes::support::load_roast_options; use crate::application::routes::support::load_roast_options;
use crate::application::state::AppState; use crate::application::state::AppState;
use crate::domain::entity_type::EntityType;
use crate::domain::ids::BagId; use crate::domain::ids::BagId;
use crate::presentation::web::templates::{BagDetailTemplate, BagEditTemplate}; use crate::presentation::web::templates::{BagDetailTemplate, BagEditTemplate};
use crate::presentation::web::views::BagDetailView; use crate::presentation::web::views::BagDetailView;
@ -37,7 +38,7 @@ pub(crate) async fn bag_detail_page(
}, },
async { async {
Ok::<_, StatusCode>( Ok::<_, StatusCode>(
resolve_image_url(&state, "roast", i64::from(bag.bag.roast_id)).await, resolve_image_url(&state, EntityType::Roast, i64::from(bag.bag.roast_id)).await,
) )
}, },
)?; )?;

View file

@ -9,6 +9,7 @@ use crate::application::routes::api::brews::load_brew_form_data;
use crate::application::routes::api::images::resolve_image_url; use crate::application::routes::api::images::resolve_image_url;
use crate::application::routes::render_html; use crate::application::routes::render_html;
use crate::application::state::AppState; use crate::application::state::AppState;
use crate::domain::entity_type::EntityType;
use crate::domain::ids::BrewId; use crate::domain::ids::BrewId;
use crate::presentation::web::templates::{BrewDetailTemplate, BrewEditTemplate}; use crate::presentation::web::templates::{BrewDetailTemplate, BrewEditTemplate};
use crate::presentation::web::views::BrewDetailView; use crate::presentation::web::views::BrewDetailView;
@ -35,7 +36,9 @@ pub(crate) async fn brew_detail_page(
.await .await
.map_err(|e| map_app_error(e.into())) .map_err(|e| map_app_error(e.into()))
}, },
async { Ok::<_, StatusCode>(resolve_image_url(&state, "brew", i64::from(id)).await) }, async {
Ok::<_, StatusCode>(resolve_image_url(&state, EntityType::Brew, i64::from(id)).await)
},
)?; )?;
let (roast, roast_image_url) = tokio::try_join!( let (roast, roast_image_url) = tokio::try_join!(
@ -47,7 +50,9 @@ pub(crate) async fn brew_detail_page(
.map_err(|e| map_app_error(e.into())) .map_err(|e| map_app_error(e.into()))
}, },
async { async {
Ok::<_, StatusCode>(resolve_image_url(&state, "roast", i64::from(bag.roast_id)).await) Ok::<_, StatusCode>(
resolve_image_url(&state, EntityType::Roast, i64::from(bag.roast_id)).await,
)
}, },
)?; )?;
@ -90,7 +95,7 @@ pub(crate) async fn brew_edit_page(
let form_data = load_brew_form_data(&state).await.map_err(map_app_error)?; let form_data = load_brew_form_data(&state).await.map_err(map_app_error)?;
let image_url = resolve_image_url(&state, "brew", i64::from(id)).await; let image_url = resolve_image_url(&state, EntityType::Brew, i64::from(id)).await;
let template = BrewEditTemplate { let template = BrewEditTemplate {
nav_active: "", nav_active: "",

View file

@ -8,6 +8,7 @@ use crate::application::errors::map_app_error;
use crate::application::routes::api::images::resolve_image_url; use crate::application::routes::api::images::resolve_image_url;
use crate::application::routes::render_html; use crate::application::routes::render_html;
use crate::application::state::AppState; use crate::application::state::AppState;
use crate::domain::entity_type::EntityType;
use crate::domain::ids::CafeId; use crate::domain::ids::CafeId;
use crate::presentation::web::templates::{CafeDetailTemplate, CafeEditTemplate}; use crate::presentation::web::templates::{CafeDetailTemplate, CafeEditTemplate};
use crate::presentation::web::views::CafeDetailView; use crate::presentation::web::views::CafeDetailView;
@ -26,7 +27,7 @@ pub(crate) async fn cafe_detail_page(
.await .await
.map_err(|e| map_app_error(e.into()))?; .map_err(|e| map_app_error(e.into()))?;
let image_url = resolve_image_url(&state, "cafe", i64::from(cafe.id)).await; let image_url = resolve_image_url(&state, EntityType::Cafe, i64::from(cafe.id)).await;
let edit_url = format!("/cafes/{}/edit", cafe.id); let edit_url = format!("/cafes/{}/edit", cafe.id);
let view = CafeDetailView::from_domain(cafe); let view = CafeDetailView::from_domain(cafe);
@ -56,7 +57,7 @@ pub(crate) async fn cafe_edit_page(
.await .await
.map_err(|e| map_app_error(e.into()))?; .map_err(|e| map_app_error(e.into()))?;
let image_url = resolve_image_url(&state, "cafe", i64::from(id)).await; let image_url = resolve_image_url(&state, EntityType::Cafe, i64::from(id)).await;
let template = CafeEditTemplate { let template = CafeEditTemplate {
nav_active: "", nav_active: "",

View file

@ -9,6 +9,7 @@ use crate::application::routes::api::images::resolve_image_url;
use crate::application::routes::render_html; use crate::application::routes::render_html;
use crate::application::routes::support::{load_cafe_options, load_roast_options}; use crate::application::routes::support::{load_cafe_options, load_roast_options};
use crate::application::state::AppState; use crate::application::state::AppState;
use crate::domain::entity_type::EntityType;
use crate::domain::ids::CupId; use crate::domain::ids::CupId;
use crate::presentation::web::templates::{CupDetailTemplate, CupEditTemplate}; use crate::presentation::web::templates::{CupDetailTemplate, CupEditTemplate};
use crate::presentation::web::views::CupDetailView; use crate::presentation::web::views::CupDetailView;
@ -50,10 +51,10 @@ pub(crate) async fn cup_detail_page(
.await .await
.map_err(|e| map_app_error(e.into()))?; .map_err(|e| map_app_error(e.into()))?;
let image_url = resolve_image_url(&state, "cup", i64::from(id)) let image_url = resolve_image_url(&state, EntityType::Cup, i64::from(id))
.await .await
.or(resolve_image_url(&state, "cafe", i64::from(cafe.id)).await) .or(resolve_image_url(&state, EntityType::Cafe, i64::from(cafe.id)).await)
.or(resolve_image_url(&state, "roast", i64::from(roast.id)).await); .or(resolve_image_url(&state, EntityType::Roast, i64::from(roast.id)).await);
let view = CupDetailView::from_parts(cup_details, &roast, &roaster, &cafe); let view = CupDetailView::from_parts(cup_details, &roast, &roaster, &cafe);
@ -91,7 +92,7 @@ pub(crate) async fn cup_edit_page(
},) },)
.map_err(map_app_error)?; .map_err(map_app_error)?;
let image_url = resolve_image_url(&state, "cup", i64::from(id)).await; let image_url = resolve_image_url(&state, EntityType::Cup, i64::from(id)).await;
let template = CupEditTemplate { let template = CupEditTemplate {
nav_active: "", nav_active: "",

View file

@ -8,6 +8,7 @@ use crate::application::errors::map_app_error;
use crate::application::routes::api::images::resolve_image_url; use crate::application::routes::api::images::resolve_image_url;
use crate::application::routes::render_html; use crate::application::routes::render_html;
use crate::application::state::AppState; use crate::application::state::AppState;
use crate::domain::entity_type::EntityType;
use crate::domain::ids::GearId; use crate::domain::ids::GearId;
use crate::presentation::web::templates::{GearDetailTemplate, GearEditTemplate}; use crate::presentation::web::templates::{GearDetailTemplate, GearEditTemplate};
use crate::presentation::web::views::GearDetailView; use crate::presentation::web::views::GearDetailView;
@ -26,7 +27,7 @@ pub(crate) async fn gear_detail_page(
.await .await
.map_err(|e| map_app_error(e.into()))?; .map_err(|e| map_app_error(e.into()))?;
let image_url = resolve_image_url(&state, "gear", i64::from(id)).await; let image_url = resolve_image_url(&state, EntityType::Gear, i64::from(id)).await;
let view = GearDetailView::from_domain(gear); let view = GearDetailView::from_domain(gear);
@ -55,7 +56,7 @@ pub(crate) async fn gear_edit_page(
.await .await
.map_err(|e| map_app_error(e.into()))?; .map_err(|e| map_app_error(e.into()))?;
let image_url = resolve_image_url(&state, "gear", i64::from(id)).await; let image_url = resolve_image_url(&state, EntityType::Gear, i64::from(id)).await;
let template = GearEditTemplate { let template = GearEditTemplate {
nav_active: "", nav_active: "",

View file

@ -8,6 +8,7 @@ use crate::application::errors::map_app_error;
use crate::application::routes::api::images::resolve_image_url; use crate::application::routes::api::images::resolve_image_url;
use crate::application::routes::render_html; use crate::application::routes::render_html;
use crate::application::state::AppState; use crate::application::state::AppState;
use crate::domain::entity_type::EntityType;
use crate::domain::ids::RoasterId; use crate::domain::ids::RoasterId;
use crate::presentation::web::templates::{RoasterDetailTemplate, RoasterEditTemplate}; use crate::presentation::web::templates::{RoasterDetailTemplate, RoasterEditTemplate};
use crate::presentation::web::views::RoasterDetailView; use crate::presentation::web::views::RoasterDetailView;
@ -26,7 +27,7 @@ pub(crate) async fn roaster_detail_page(
.await .await
.map_err(|e| map_app_error(e.into()))?; .map_err(|e| map_app_error(e.into()))?;
let image_url = resolve_image_url(&state, "roaster", i64::from(roaster.id)).await; let image_url = resolve_image_url(&state, EntityType::Roaster, i64::from(roaster.id)).await;
let edit_url = format!("/roasters/{}/edit", roaster.id); let edit_url = format!("/roasters/{}/edit", roaster.id);
let view = RoasterDetailView::from_domain(roaster); let view = RoasterDetailView::from_domain(roaster);
@ -56,7 +57,7 @@ pub(crate) async fn roaster_edit_page(
.await .await
.map_err(|e| map_app_error(e.into()))?; .map_err(|e| map_app_error(e.into()))?;
let image_url = resolve_image_url(&state, "roaster", i64::from(id)).await; let image_url = resolve_image_url(&state, EntityType::Roaster, i64::from(id)).await;
let template = RoasterEditTemplate { let template = RoasterEditTemplate {
nav_active: "", nav_active: "",

View file

@ -9,6 +9,7 @@ use crate::application::routes::api::images::resolve_image_url;
use crate::application::routes::render_html; use crate::application::routes::render_html;
use crate::application::routes::support::load_roaster_options; use crate::application::routes::support::load_roaster_options;
use crate::application::state::AppState; use crate::application::state::AppState;
use crate::domain::entity_type::EntityType;
use crate::domain::ids::RoastId; use crate::domain::ids::RoastId;
use crate::presentation::web::templates::{RoastDetailTemplate, RoastEditTemplate}; use crate::presentation::web::templates::{RoastDetailTemplate, RoastEditTemplate};
use crate::presentation::web::views::RoastDetailView; use crate::presentation::web::views::RoastDetailView;
@ -33,7 +34,7 @@ pub(crate) async fn roast_detail_page(
.await .await
.map_err(|e| map_app_error(e.into()))?; .map_err(|e| map_app_error(e.into()))?;
let image_url = resolve_image_url(&state, "roast", i64::from(roast.id)).await; let image_url = resolve_image_url(&state, EntityType::Roast, i64::from(roast.id)).await;
let edit_url = format!("/roasts/{}/edit", roast.id); let edit_url = format!("/roasts/{}/edit", roast.id);
let view = RoastDetailView::from_parts(roast, &roaster); let view = RoastDetailView::from_parts(roast, &roaster);
@ -72,7 +73,7 @@ pub(crate) async fn roast_edit_page(
let roaster_options = load_roaster_options(&state).await.map_err(map_app_error)?; let roaster_options = load_roaster_options(&state).await.map_err(map_app_error)?;
let image_url = resolve_image_url(&state, "roast", i64::from(id)).await; let image_url = resolve_image_url(&state, EntityType::Roast, i64::from(id)).await;
let template = RoastEditTemplate { let template = RoastEditTemplate {
nav_active: "", nav_active: "",

View file

@ -1,7 +1,8 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::domain::ids::TimelineEventId; use crate::domain::entity_type::EntityType;
use crate::domain::ids::{BagId, GearId, TimelineEventId};
use crate::domain::listing::{SortDirection, SortKey}; use crate::domain::listing::{SortDirection, SortKey};
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -13,10 +14,10 @@ pub struct TimelineEventDetail {
/// Raw brew data for repeating a brew from the timeline. /// Raw brew data for repeating a brew from the timeline.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimelineBrewData { pub struct TimelineBrewData {
pub bag_id: i64, pub bag_id: BagId,
pub grinder_id: i64, pub grinder_id: GearId,
pub brewer_id: i64, pub brewer_id: GearId,
pub filter_paper_id: Option<i64>, pub filter_paper_id: Option<GearId>,
pub coffee_weight: f64, pub coffee_weight: f64,
pub grind_setting: f64, pub grind_setting: f64,
pub water_volume: i32, pub water_volume: i32,
@ -27,7 +28,7 @@ pub struct TimelineBrewData {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimelineEvent { pub struct TimelineEvent {
pub id: TimelineEventId, pub id: TimelineEventId,
pub entity_type: String, pub entity_type: EntityType,
pub entity_id: i64, pub entity_id: i64,
pub action: String, pub action: String,
pub occurred_at: DateTime<Utc>, pub occurred_at: DateTime<Utc>,
@ -41,7 +42,7 @@ pub struct TimelineEvent {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NewTimelineEvent { pub struct NewTimelineEvent {
pub entity_type: String, pub entity_type: EntityType,
pub entity_id: i64, pub entity_id: i64,
pub action: String, pub action: String,
pub occurred_at: DateTime<Utc>, pub occurred_at: DateTime<Utc>,

View file

@ -1,6 +1,7 @@
use chrono::{DateTime, NaiveDate, Utc}; use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::domain::entity_type::EntityType;
use crate::domain::ids::{BagId, RoastId}; use crate::domain::ids::{BagId, RoastId};
use crate::domain::listing::{SortDirection, SortKey}; use crate::domain::listing::{SortDirection, SortKey};
use crate::domain::roasters::Roaster; use crate::domain::roasters::Roaster;
@ -148,7 +149,7 @@ pub fn bag_timeline_event(
roaster: &Roaster, roaster: &Roaster,
) -> NewTimelineEvent { ) -> NewTimelineEvent {
NewTimelineEvent { NewTimelineEvent {
entity_type: "bag".to_string(), entity_type: EntityType::Bag,
entity_id: bag.id.into_inner(), entity_id: bag.id.into_inner(),
action: action.to_string(), action: action.to_string(),
occurred_at: bag.created_at, occurred_at: bag.created_at,

View file

@ -3,6 +3,7 @@ use std::str::FromStr;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::domain::entity_type::EntityType;
use crate::domain::ids::{BagId, BrewId, GearId}; use crate::domain::ids::{BagId, BrewId, GearId};
use crate::domain::listing::{SortDirection, SortKey}; use crate::domain::listing::{SortDirection, SortKey};
use crate::domain::timeline::{NewTimelineEvent, TimelineBrewData, TimelineEventDetail}; use crate::domain::timeline::{NewTimelineEvent, TimelineBrewData, TimelineEventDetail};
@ -184,7 +185,7 @@ impl BrewWithDetails {
} }
NewTimelineEvent { NewTimelineEvent {
entity_type: "brew".to_string(), entity_type: EntityType::Brew,
entity_id: self.brew.id.into_inner(), entity_id: self.brew.id.into_inner(),
action: "brewed".to_string(), action: "brewed".to_string(),
occurred_at: self.brew.created_at, occurred_at: self.brew.created_at,
@ -194,10 +195,10 @@ impl BrewWithDetails {
slug: Some(self.roast_slug.clone()), slug: Some(self.roast_slug.clone()),
roaster_slug: Some(self.roaster_slug.clone()), roaster_slug: Some(self.roaster_slug.clone()),
brew_data: Some(TimelineBrewData { brew_data: Some(TimelineBrewData {
bag_id: self.brew.bag_id.into_inner(), bag_id: self.brew.bag_id,
grinder_id: self.brew.grinder_id.into_inner(), grinder_id: self.brew.grinder_id,
brewer_id: self.brew.brewer_id.into_inner(), brewer_id: self.brew.brewer_id,
filter_paper_id: self.brew.filter_paper_id.map(GearId::into_inner), filter_paper_id: self.brew.filter_paper_id,
coffee_weight: self.brew.coffee_weight, coffee_weight: self.brew.coffee_weight,
grind_setting: self.brew.grind_setting, grind_setting: self.brew.grind_setting,
water_volume: self.brew.water_volume, water_volume: self.brew.water_volume,

View file

@ -2,6 +2,7 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use super::normalize_optional_field; use super::normalize_optional_field;
use crate::domain::entity_type::EntityType;
use crate::domain::ids::CafeId; use crate::domain::ids::CafeId;
use crate::domain::listing::{SortDirection, SortKey}; use crate::domain::listing::{SortDirection, SortKey};
use crate::domain::roasters::is_valid_url_scheme; use crate::domain::roasters::is_valid_url_scheme;
@ -24,7 +25,7 @@ pub struct Cafe {
impl Cafe { impl Cafe {
pub fn to_timeline_event(&self) -> NewTimelineEvent { pub fn to_timeline_event(&self) -> NewTimelineEvent {
NewTimelineEvent { NewTimelineEvent {
entity_type: "cafe".to_string(), entity_type: EntityType::Cafe,
entity_id: self.id.into_inner(), entity_id: self.id.into_inner(),
action: "added".to_string(), action: "added".to_string(),
occurred_at: self.created_at, occurred_at: self.created_at,

View file

@ -1,6 +1,7 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::domain::entity_type::EntityType;
use crate::domain::ids::{CafeId, CupId, RoastId}; use crate::domain::ids::{CafeId, CupId, RoastId};
use crate::domain::listing::{SortDirection, SortKey}; use crate::domain::listing::{SortDirection, SortKey};
use crate::domain::timeline::{NewTimelineEvent, TimelineEventDetail}; use crate::domain::timeline::{NewTimelineEvent, TimelineEventDetail};
@ -30,7 +31,7 @@ pub struct CupWithDetails {
impl CupWithDetails { impl CupWithDetails {
pub fn to_timeline_event(&self) -> NewTimelineEvent { pub fn to_timeline_event(&self) -> NewTimelineEvent {
NewTimelineEvent { NewTimelineEvent {
entity_type: "cup".to_string(), entity_type: EntityType::Cup,
entity_id: self.cup.id.into_inner(), entity_id: self.cup.id.into_inner(),
action: "added".to_string(), action: "added".to_string(),
occurred_at: self.cup.created_at, occurred_at: self.cup.created_at,

View file

@ -3,6 +3,7 @@ use std::str::FromStr;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::domain::entity_type::EntityType;
use crate::domain::ids::GearId; use crate::domain::ids::GearId;
use crate::domain::listing::{SortDirection, SortKey}; use crate::domain::listing::{SortDirection, SortKey};
use crate::domain::timeline::{NewTimelineEvent, TimelineEventDetail}; use crate::domain::timeline::{NewTimelineEvent, TimelineEventDetail};
@ -60,7 +61,7 @@ pub struct Gear {
impl Gear { impl Gear {
pub fn to_timeline_event(&self) -> NewTimelineEvent { pub fn to_timeline_event(&self) -> NewTimelineEvent {
NewTimelineEvent { NewTimelineEvent {
entity_type: "gear".to_string(), entity_type: EntityType::Gear,
entity_id: self.id.into_inner(), entity_id: self.id.into_inner(),
action: "added".to_string(), action: "added".to_string(),
occurred_at: self.created_at, occurred_at: self.created_at,

View file

@ -2,6 +2,7 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use super::normalize_optional_field; use super::normalize_optional_field;
use crate::domain::entity_type::EntityType;
use crate::domain::ids::RoasterId; use crate::domain::ids::RoasterId;
use crate::domain::listing::{SortDirection, SortKey}; use crate::domain::listing::{SortDirection, SortKey};
use crate::domain::timeline::{NewTimelineEvent, TimelineEventDetail}; use crate::domain::timeline::{NewTimelineEvent, TimelineEventDetail};
@ -66,7 +67,7 @@ impl Roaster {
}); });
} }
NewTimelineEvent { NewTimelineEvent {
entity_type: "roaster".to_string(), entity_type: EntityType::Roaster,
entity_id: self.id.into_inner(), entity_id: self.id.into_inner(),
action: "added".to_string(), action: "added".to_string(),
occurred_at: self.created_at, occurred_at: self.created_at,

View file

@ -1,6 +1,7 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::domain::entity_type::EntityType;
use crate::domain::ids::{RoastId, RoasterId}; use crate::domain::ids::{RoastId, RoasterId};
use crate::domain::listing::{SortDirection, SortKey}; use crate::domain::listing::{SortDirection, SortKey};
use crate::domain::roasters::Roaster; use crate::domain::roasters::Roaster;
@ -121,7 +122,7 @@ pub fn roast_timeline_event(roast: &Roast, roaster: &Roaster) -> NewTimelineEven
}); });
} }
NewTimelineEvent { NewTimelineEvent {
entity_type: "roast".to_string(), entity_type: EntityType::Roast,
entity_id: roast.id.into_inner(), entity_id: roast.id.into_inner(),
action: "added".to_string(), action: "added".to_string(),
occurred_at: roast.created_at, occurred_at: roast.created_at,

53
src/domain/entity_type.rs Normal file
View file

@ -0,0 +1,53 @@
use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum EntityType {
Roaster,
Roast,
Bag,
Brew,
Cup,
Cafe,
Gear,
}
impl EntityType {
pub const fn as_str(self) -> &'static str {
match self {
Self::Roaster => "roaster",
Self::Roast => "roast",
Self::Bag => "bag",
Self::Brew => "brew",
Self::Cup => "cup",
Self::Cafe => "cafe",
Self::Gear => "gear",
}
}
}
impl fmt::Display for EntityType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for EntityType {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"roaster" => Ok(Self::Roaster),
"roast" => Ok(Self::Roast),
"bag" => Ok(Self::Bag),
"brew" => Ok(Self::Brew),
"cup" => Ok(Self::Cup),
"cafe" => Ok(Self::Cafe),
"gear" => Ok(Self::Gear),
_ => Err(()),
}
}
}

View file

@ -2,9 +2,11 @@ use std::fmt;
use serde::Deserialize; use serde::Deserialize;
use crate::domain::entity_type::EntityType;
/// 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: EntityType,
pub entity_id: i64, pub entity_id: i64,
pub content_type: String, pub content_type: String,
pub image_data: Vec<u8>, pub image_data: Vec<u8>,

View file

@ -2,6 +2,7 @@ pub mod analytics;
pub mod auth; pub mod auth;
pub mod coffee; pub mod coffee;
pub mod countries; pub mod countries;
pub mod entity_type;
pub mod errors; pub mod errors;
pub mod formatting; pub mod formatting;
pub mod ids; pub mod ids;

View file

@ -1,5 +1,6 @@
use super::RepositoryError; use super::RepositoryError;
use crate::domain::ai_usage::{AiUsage, AiUsageSummary, NewAiUsage}; use crate::domain::ai_usage::{AiUsage, AiUsageSummary, NewAiUsage};
use crate::domain::entity_type::EntityType;
use crate::domain::listing::{ListRequest, Page, SortDirection, SortKey}; use crate::domain::listing::{ListRequest, Page, SortDirection, SortKey};
use crate::domain::bags::{Bag, BagFilter, BagSortKey, BagWithRoast, NewBag, UpdateBag}; use crate::domain::bags::{Bag, BagFilter, BagSortKey, BagWithRoast, NewBag, UpdateBag};
@ -275,14 +276,22 @@ pub trait AiUsageRepository: Send + Sync {
#[async_trait] #[async_trait]
pub trait ImageRepository: Send + Sync { pub trait ImageRepository: Send + Sync {
async fn upsert(&self, image: EntityImage) -> Result<(), RepositoryError>; async fn upsert(&self, image: EntityImage) -> Result<(), RepositoryError>;
async fn get(&self, entity_type: &str, entity_id: i64) -> Result<EntityImage, RepositoryError>; async fn get(
async fn get_thumbnail(
&self, &self,
entity_type: &str, entity_type: EntityType,
entity_id: i64, entity_id: i64,
) -> Result<EntityImage, RepositoryError>; ) -> Result<EntityImage, RepositoryError>;
async fn delete(&self, entity_type: &str, entity_id: i64) -> Result<(), RepositoryError>; async fn get_thumbnail(
async fn has_image(&self, entity_type: &str, entity_id: i64) -> Result<bool, RepositoryError>; &self,
entity_type: EntityType,
entity_id: i64,
) -> Result<EntityImage, RepositoryError>;
async fn delete(&self, entity_type: EntityType, entity_id: i64) -> Result<(), RepositoryError>;
async fn has_image(
&self,
entity_type: EntityType,
entity_id: i64,
) -> Result<bool, RepositoryError>;
} }
#[async_trait] #[async_trait]

View file

@ -9,6 +9,7 @@ use crate::domain::bags::Bag;
use crate::domain::brews::{Brew, QuickNote}; use crate::domain::brews::{Brew, QuickNote};
use crate::domain::cafes::Cafe; use crate::domain::cafes::Cafe;
use crate::domain::cups::Cup; use crate::domain::cups::Cup;
use crate::domain::entity_type::EntityType;
use crate::domain::gear::{Gear, GearCategory}; use crate::domain::gear::{Gear, GearCategory};
use crate::domain::ids::{ use crate::domain::ids::{
BagId, BrewId, CafeId, CupId, GearId, RoastId, RoasterId, TimelineEventId, BagId, BrewId, CafeId, CupId, GearId, RoastId, RoasterId, TimelineEventId,
@ -549,7 +550,7 @@ impl BackupService {
"INSERT INTO timeline_events (id, entity_type, entity_id, action, occurred_at, title, details_json, tasting_notes_json, slug, roaster_slug, brew_data_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", "INSERT INTO timeline_events (id, entity_type, entity_id, action, occurred_at, title, details_json, tasting_notes_json, slug, roaster_slug, brew_data_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
) )
.bind(i64::from(event.id)) .bind(i64::from(event.id))
.bind(&event.entity_type) .bind(event.entity_type.as_str())
.bind(event.entity_id) .bind(event.entity_id)
.bind(&event.action) .bind(&event.action)
.bind(event.occurred_at) .bind(event.occurred_at)
@ -823,9 +824,14 @@ impl TimelineEventRecord {
let tasting_notes = decode_json_vec(self.tasting_notes_json, "timeline tasting notes")?; let tasting_notes = decode_json_vec(self.tasting_notes_json, "timeline tasting notes")?;
let brew_data = decode_json_opt(self.brew_data_json, "timeline brew data")?; let brew_data = decode_json_opt(self.brew_data_json, "timeline brew data")?;
let entity_type: EntityType = self
.entity_type
.parse()
.map_err(|()| anyhow::anyhow!("unknown entity type: {}", self.entity_type))?;
Ok(TimelineEvent { Ok(TimelineEvent {
id: TimelineEventId::from(self.id), id: TimelineEventId::from(self.id),
entity_type: self.entity_type, entity_type,
entity_id: self.entity_id, entity_id: self.entity_id,
action: self.action, action: self.action,
occurred_at: self.occurred_at, occurred_at: self.occurred_at,

View file

@ -1,4 +1,5 @@
use crate::domain::RepositoryError; use crate::domain::RepositoryError;
use crate::domain::entity_type::EntityType;
use crate::domain::ids::TimelineEventId; use crate::domain::ids::TimelineEventId;
use crate::domain::listing::{ListRequest, Page, SortDirection}; use crate::domain::listing::{ListRequest, Page, SortDirection};
use crate::domain::repositories::TimelineEventRepository; use crate::domain::repositories::TimelineEventRepository;
@ -50,7 +51,7 @@ impl TimelineEventRepository for SqlTimelineEventRepository {
})?; })?;
let record = sqlx::query_as::<_, TimelineEventRecord>(query) let record = sqlx::query_as::<_, TimelineEventRecord>(query)
.bind(event.entity_type) .bind(event.entity_type.as_str())
.bind(event.entity_id) .bind(event.entity_id)
.bind(event.action) .bind(event.action)
.bind(event.occurred_at) .bind(event.occurred_at)
@ -145,9 +146,13 @@ impl TimelineEventRecord {
_ => None, _ => None,
}; };
let entity_type: EntityType = self.entity_type.parse().map_err(|()| {
RepositoryError::unexpected(format!("unknown entity type: {}", self.entity_type))
})?;
Ok(TimelineEvent { Ok(TimelineEvent {
id: TimelineEventId::from(self.id), id: TimelineEventId::from(self.id),
entity_type: self.entity_type, entity_type,
entity_id: self.entity_id, entity_id: self.entity_id,
action: self.action, action: self.action,
occurred_at: self.occurred_at, occurred_at: self.occurred_at,

View file

@ -2,6 +2,7 @@ use async_trait::async_trait;
use sqlx::{query, query_as}; use sqlx::{query, query_as};
use crate::domain::RepositoryError; use crate::domain::RepositoryError;
use crate::domain::entity_type::EntityType;
use crate::domain::images::EntityImage; use crate::domain::images::EntityImage;
use crate::domain::repositories::ImageRepository; use crate::domain::repositories::ImageRepository;
use crate::infrastructure::database::DatabasePool; use crate::infrastructure::database::DatabasePool;
@ -16,24 +17,30 @@ impl SqlImageRepository {
Self { pool } Self { pool }
} }
fn into_domain(record: ImageRecord) -> EntityImage { fn into_domain(record: ImageRecord) -> Result<EntityImage, RepositoryError> {
EntityImage { let entity_type: EntityType = record.entity_type.parse().map_err(|()| {
entity_type: record.entity_type, RepositoryError::unexpected(format!("unknown entity type: {}", record.entity_type))
})?;
Ok(EntityImage {
entity_type,
entity_id: record.entity_id, entity_id: record.entity_id,
content_type: record.content_type, content_type: record.content_type,
image_data: record.image_data, image_data: record.image_data,
thumbnail_data: record.thumbnail_data, thumbnail_data: record.thumbnail_data,
} })
} }
fn thumbnail_to_domain(record: ThumbnailRecord) -> EntityImage { fn thumbnail_to_domain(record: ThumbnailRecord) -> Result<EntityImage, RepositoryError> {
EntityImage { let entity_type: EntityType = record.entity_type.parse().map_err(|()| {
entity_type: record.entity_type, RepositoryError::unexpected(format!("unknown entity type: {}", record.entity_type))
})?;
Ok(EntityImage {
entity_type,
entity_id: record.entity_id, entity_id: record.entity_id,
content_type: record.content_type, content_type: record.content_type,
image_data: Vec::new(), image_data: Vec::new(),
thumbnail_data: record.thumbnail_data, thumbnail_data: record.thumbnail_data,
} })
} }
} }
@ -66,7 +73,7 @@ impl ImageRepository for SqlImageRepository {
thumbnail_data = excluded.thumbnail_data, thumbnail_data = excluded.thumbnail_data,
created_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')", created_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')",
) )
.bind(&image.entity_type) .bind(image.entity_type.as_str())
.bind(image.entity_id) .bind(image.entity_id)
.bind(&image.content_type) .bind(&image.content_type)
.bind(&image.image_data) .bind(&image.image_data)
@ -78,25 +85,29 @@ impl ImageRepository for SqlImageRepository {
Ok(()) Ok(())
} }
async fn get(&self, entity_type: &str, entity_id: i64) -> Result<EntityImage, RepositoryError> { async fn get(
&self,
entity_type: EntityType,
entity_id: i64,
) -> Result<EntityImage, RepositoryError> {
let record = query_as::<_, ImageRecord>( let record = query_as::<_, ImageRecord>(
r"SELECT entity_type, entity_id, content_type, image_data, thumbnail_data r"SELECT entity_type, entity_id, content_type, image_data, thumbnail_data
FROM entity_images FROM entity_images
WHERE entity_type = ? AND entity_id = ?", WHERE entity_type = ? AND entity_id = ?",
) )
.bind(entity_type) .bind(entity_type.as_str())
.bind(entity_id) .bind(entity_id)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(|e| RepositoryError::unexpected(e.to_string()))? .map_err(|e| RepositoryError::unexpected(e.to_string()))?
.ok_or(RepositoryError::NotFound)?; .ok_or(RepositoryError::NotFound)?;
Ok(Self::into_domain(record)) Self::into_domain(record)
} }
async fn get_thumbnail( async fn get_thumbnail(
&self, &self,
entity_type: &str, entity_type: EntityType,
entity_id: i64, entity_id: i64,
) -> Result<EntityImage, RepositoryError> { ) -> Result<EntityImage, RepositoryError> {
let record = query_as::<_, ThumbnailRecord>( let record = query_as::<_, ThumbnailRecord>(
@ -104,19 +115,19 @@ impl ImageRepository for SqlImageRepository {
FROM entity_images FROM entity_images
WHERE entity_type = ? AND entity_id = ?", WHERE entity_type = ? AND entity_id = ?",
) )
.bind(entity_type) .bind(entity_type.as_str())
.bind(entity_id) .bind(entity_id)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(|e| RepositoryError::unexpected(e.to_string()))? .map_err(|e| RepositoryError::unexpected(e.to_string()))?
.ok_or(RepositoryError::NotFound)?; .ok_or(RepositoryError::NotFound)?;
Ok(Self::thumbnail_to_domain(record)) Self::thumbnail_to_domain(record)
} }
async fn delete(&self, entity_type: &str, entity_id: i64) -> Result<(), RepositoryError> { async fn delete(&self, entity_type: EntityType, entity_id: i64) -> Result<(), RepositoryError> {
query(r"DELETE FROM entity_images WHERE entity_type = ? AND entity_id = ?") query(r"DELETE FROM entity_images WHERE entity_type = ? AND entity_id = ?")
.bind(entity_type) .bind(entity_type.as_str())
.bind(entity_id) .bind(entity_id)
.execute(&self.pool) .execute(&self.pool)
.await .await
@ -125,10 +136,14 @@ impl ImageRepository for SqlImageRepository {
Ok(()) Ok(())
} }
async fn has_image(&self, entity_type: &str, entity_id: i64) -> Result<bool, RepositoryError> { async fn has_image(
&self,
entity_type: EntityType,
entity_id: i64,
) -> Result<bool, RepositoryError> {
let row: (i64,) = let row: (i64,) =
query_as(r"SELECT COUNT(*) FROM entity_images WHERE entity_type = ? AND entity_id = ?") query_as(r"SELECT COUNT(*) FROM entity_images WHERE entity_type = ? AND entity_id = ?")
.bind(entity_type) .bind(entity_type.as_str())
.bind(entity_id) .bind(entity_id)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await

View file

@ -1,4 +1,5 @@
use crate::domain::countries::{country_to_iso, iso_to_flag_emoji}; use crate::domain::countries::{country_to_iso, iso_to_flag_emoji};
use crate::domain::entity_type::EntityType;
use crate::domain::timeline::{TimelineEvent, TimelineEventDetail}; use crate::domain::timeline::{TimelineEvent, TimelineEventDetail};
use super::relative_date; use super::relative_date;
@ -84,45 +85,46 @@ impl TimelineEventView {
brew_data, brew_data,
} = event; } = event;
let kind_label = match (entity_type.as_str(), action.as_str()) { let entity_type_str = entity_type.as_str();
("roaster", "added") => "Roaster Added",
("roast", "added") => "Roast Added", let kind_label = match (entity_type, action.as_str()) {
("bag", "added") => "Bag Added", (EntityType::Roaster, "added") => "Roaster Added",
("bag", "finished") => "Bag Finished", (EntityType::Roast, "added") => "Roast Added",
("gear", "added") => "Gear Added", (EntityType::Bag, "added") => "Bag Added",
("brew", "brewed") => "Brew Added", (EntityType::Bag, "finished") => "Bag Finished",
("cafe", "added") => "Cafe Added", (EntityType::Gear, "added") => "Gear Added",
("cup", "added") => "Cup Added", (EntityType::Brew, "brewed") => "Brew Added",
(EntityType::Cafe, "added") => "Cafe Added",
(EntityType::Cup, "added") => "Cup Added",
_ => "Event", _ => "Event",
}; };
let link = match entity_type.as_str() { let link = match entity_type {
"brew" => format!("/brews/{entity_id}"), EntityType::Brew => format!("/brews/{entity_id}"),
"cup" => format!("/cups/{entity_id}"), EntityType::Cup => format!("/cups/{entity_id}"),
"bag" => format!("/bags/{entity_id}"), EntityType::Bag => format!("/bags/{entity_id}"),
"gear" => format!("/gear/{entity_id}"), EntityType::Gear => format!("/gear/{entity_id}"),
"roaster" => slug.as_deref().map_or_else( EntityType::Roaster => slug.as_deref().map_or_else(
|| "/data?type=roasters".to_string(), || "/data?type=roasters".to_string(),
|s| format!("/roasters/{s}"), |s| format!("/roasters/{s}"),
), ),
"cafe" => slug EntityType::Cafe => slug
.as_deref() .as_deref()
.map_or_else(|| "/data?type=cafes".to_string(), |s| format!("/cafes/{s}")), .map_or_else(|| "/data?type=cafes".to_string(), |s| format!("/cafes/{s}")),
"roast" => match (roaster_slug.as_deref(), slug.as_deref()) { EntityType::Roast => match (roaster_slug.as_deref(), slug.as_deref()) {
(Some(rs), Some(s)) => format!("/roasters/{rs}/roasts/{s}"), (Some(rs), Some(s)) => format!("/roasters/{rs}/roasts/{s}"),
_ => "/data?type=roasts".to_string(), _ => "/data?type=roasts".to_string(),
}, },
_ => String::from("#"),
}; };
let (mut mapped_details, external_link) = Self::map_details(details); let (mut mapped_details, external_link) = Self::map_details(details);
// Build subtitle before adding flags so it stays clean text. // Build subtitle before adding flags so it stays clean text.
let subtitle = Self::build_subtitle(entity_type.as_str(), &mapped_details); let subtitle = Self::build_subtitle(entity_type_str, &mapped_details);
Self::add_country_flags(&mut mapped_details); Self::add_country_flags(&mut mapped_details);
let tasting_notes = if entity_type == "roast" { let tasting_notes = if entity_type == EntityType::Roast {
let notes = tasting_notes let notes = tasting_notes
.into_iter() .into_iter()
.flat_map(|note| { .flat_map(|note| {
@ -139,10 +141,12 @@ impl TimelineEventView {
}; };
let brew_data_view = brew_data.map(|bd| TimelineBrewDataView { let brew_data_view = brew_data.map(|bd| TimelineBrewDataView {
bag_id: bd.bag_id, bag_id: bd.bag_id.into_inner(),
grinder_id: bd.grinder_id, grinder_id: bd.grinder_id.into_inner(),
brewer_id: bd.brewer_id, brewer_id: bd.brewer_id.into_inner(),
filter_paper_id: bd.filter_paper_id, filter_paper_id: bd
.filter_paper_id
.map(crate::domain::ids::GearId::into_inner),
coffee_weight: bd.coffee_weight, coffee_weight: bd.coffee_weight,
grind_setting: bd.grind_setting, grind_setting: bd.grind_setting,
water_volume: bd.water_volume, water_volume: bd.water_volume,
@ -152,7 +156,7 @@ impl TimelineEventView {
Self { Self {
id: id.to_string(), id: id.to_string(),
entity_type, entity_type: entity_type_str.to_string(),
kind_label, kind_label,
date_label: occurred_at.format("%b %d, %y").to_string(), date_label: occurred_at.format("%b %d, %y").to_string(),
relative_date_label: relative_date(occurred_at), relative_date_label: relative_date(occurred_at),