From de49f7c3b11e3a645fd6cc35a12f3b90b7da4371 Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Fri, 13 Feb 2026 14:28:13 +0000 Subject: [PATCH] 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. --- src/application/routes/api/coffee/bags.rs | 9 ++- src/application/routes/api/coffee/brews.rs | 13 +++- src/application/routes/api/coffee/cafes.rs | 7 +- src/application/routes/api/coffee/checkin.rs | 5 +- src/application/routes/api/coffee/cups.rs | 11 ++- src/application/routes/api/coffee/gear.rs | 7 +- src/application/routes/api/coffee/roasters.rs | 7 +- src/application/routes/api/coffee/roasts.rs | 13 +++- src/application/routes/api/coffee/scan.rs | 13 +++- src/application/routes/api/images.rs | 75 ++++++++++--------- src/application/routes/api/macros.rs | 2 +- src/application/routes/app/bags.rs | 3 +- src/application/routes/app/brews.rs | 11 ++- src/application/routes/app/cafes.rs | 5 +- src/application/routes/app/cups.rs | 9 ++- src/application/routes/app/gear.rs | 5 +- src/application/routes/app/roasters.rs | 5 +- src/application/routes/app/roasts.rs | 5 +- src/domain/analytics/timeline.rs | 15 ++-- src/domain/coffee/bags.rs | 3 +- src/domain/coffee/brews.rs | 11 +-- src/domain/coffee/cafes.rs | 3 +- src/domain/coffee/cups.rs | 3 +- src/domain/coffee/gear.rs | 3 +- src/domain/coffee/roasters.rs | 3 +- src/domain/coffee/roasts.rs | 3 +- src/domain/entity_type.rs | 53 +++++++++++++ src/domain/images.rs | 4 +- src/domain/mod.rs | 1 + src/domain/repositories.rs | 19 +++-- src/infrastructure/backup.rs | 10 ++- .../repositories/analytics/timeline_events.rs | 9 ++- src/infrastructure/repositories/images.rs | 53 ++++++++----- src/presentation/web/views/timeline.rs | 54 ++++++------- 34 files changed, 305 insertions(+), 147 deletions(-) create mode 100644 src/domain/entity_type.rs diff --git a/src/application/routes/api/coffee/bags.rs b/src/application/routes/api/coffee/bags.rs index e3bb099..4e8b0de 100644 --- a/src/application/routes/api/coffee/bags.rs +++ b/src/application/routes/api/coffee/bags.rs @@ -16,6 +16,7 @@ use crate::application::routes::support::{ }; use crate::application::state::AppState; use crate::domain::bags::{BagFilter, BagSortKey, BagWithRoast, NewBag, UpdateBag}; +use crate::domain::entity_type::EntityType; use crate::domain::ids::{BagId, RoastId}; use crate::domain::images::ImageData; use crate::domain::listing::{ListRequest, SortDirection}; @@ -221,7 +222,13 @@ pub(crate) async fn update_bag( info!(%id, closed = ?update.closed, "bag updated"); 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) { let from_bag_page = headers diff --git a/src/application/routes/api/coffee/brews.rs b/src/application/routes/api/coffee/brews.rs index 4e899c6..5bf0fc1 100644 --- a/src/application/routes/api/coffee/brews.rs +++ b/src/application/routes/api/coffee/brews.rs @@ -19,6 +19,7 @@ use crate::domain::bags::BagFilter; use crate::domain::brews::{ BrewFilter, BrewSortKey, BrewWithDetails, NewBrew, QuickNote, UpdateBrew, }; +use crate::domain::entity_type::EntityType; use crate::domain::gear::{GearCategory, GearFilter, GearSortKey}; use crate::domain::ids::{BagId, BrewId, GearId}; use crate::domain::images::ImageData; @@ -267,7 +268,7 @@ pub(crate) async fn create_brew( save_deferred_image( &state, - "brew", + EntityType::Brew, i64::from(enriched.brew.id), image_data_url.as_deref(), ) @@ -417,7 +418,13 @@ pub(crate) async fn update_brew( info!(%id, "brew updated"); 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 enriched = state @@ -442,7 +449,7 @@ define_delete_handler!( render_brew_list_fragment, "type=brews", "/data?type=brews", - image_type: "brew" + image_type: crate::domain::entity_type::EntityType::Brew ); async fn render_brew_list_fragment( diff --git a/src/application/routes/api/coffee/cafes.rs b/src/application/routes/api/coffee/cafes.rs index 40e9ebc..f6b70e3 100644 --- a/src/application/routes/api/coffee/cafes.rs +++ b/src/application/routes/api/coffee/cafes.rs @@ -17,6 +17,7 @@ use crate::application::routes::support::{ }; use crate::application::state::AppState; use crate::domain::cafes::{Cafe, CafeSortKey, NewCafe, UpdateCafe}; +use crate::domain::entity_type::EntityType; use crate::domain::ids::CafeId; use crate::domain::images::ImageData; use crate::domain::listing::{ListRequest, SortDirection}; @@ -113,7 +114,7 @@ pub(crate) async fn create_cafe( save_deferred_image( &state, - "cafe", + EntityType::Cafe, i64::from(cafe.id), image_data_url.as_deref(), ) @@ -206,7 +207,7 @@ pub(crate) async fn update_cafe( save_deferred_image( &state, - "cafe", + EntityType::Cafe, i64::from(cafe.id), image_data_url.as_deref(), ) @@ -224,7 +225,7 @@ define_delete_handler!( render_cafe_list_fragment, "type=cafes", "/data?type=cafes", - image_type: "cafe" + image_type: crate::domain::entity_type::EntityType::Cafe ); define_list_fragment_renderer!( diff --git a/src/application/routes/api/coffee/checkin.rs b/src/application/routes/api/coffee/checkin.rs index 32d0dea..c2539fe 100644 --- a/src/application/routes/api/coffee/checkin.rs +++ b/src/application/routes/api/coffee/checkin.rs @@ -13,6 +13,7 @@ use crate::application::routes::support::{ use crate::application::state::AppState; use crate::domain::cafes::NewCafe; use crate::domain::cups::NewCup; +use crate::domain::entity_type::EntityType; use crate::domain::ids::{CafeId, RoastId}; use crate::domain::images::ImageData; @@ -85,7 +86,7 @@ pub(crate) async fn submit_checkin( save_deferred_image( &state, - "cafe", + EntityType::Cafe, i64::from(cafe.id), submission.cafe_image.as_deref(), ) @@ -108,7 +109,7 @@ pub(crate) async fn submit_checkin( save_deferred_image( &state, - "cup", + EntityType::Cup, i64::from(cup.id), submission.cup_image.as_deref(), ) diff --git a/src/application/routes/api/coffee/cups.rs b/src/application/routes/api/coffee/cups.rs index 314990c..806f6a5 100644 --- a/src/application/routes/api/coffee/cups.rs +++ b/src/application/routes/api/coffee/cups.rs @@ -18,6 +18,7 @@ use crate::application::routes::support::{ }; use crate::application::state::AppState; 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::images::ImageData; use crate::domain::listing::{ListRequest, SortDirection}; @@ -143,7 +144,13 @@ pub(crate) async fn update_cup( info!(%id, "cup updated"); 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 enriched = state @@ -168,7 +175,7 @@ define_delete_handler!( render_cup_list_fragment, "type=cups", "/data?type=cups", - image_type: "cup" + image_type: crate::domain::entity_type::EntityType::Cup ); define_list_fragment_renderer!( diff --git a/src/application/routes/api/coffee/gear.rs b/src/application/routes/api/coffee/gear.rs index 921ed28..ba629ae 100644 --- a/src/application/routes/api/coffee/gear.rs +++ b/src/application/routes/api/coffee/gear.rs @@ -20,6 +20,7 @@ use crate::application::routes::support::{ update_response, validate_update, }; use crate::application::state::AppState; +use crate::domain::entity_type::EntityType; use crate::domain::gear::{Gear, GearCategory, GearFilter, GearSortKey, NewGear, UpdateGear}; use crate::domain::ids::GearId; use crate::domain::images::ImageData; @@ -75,7 +76,7 @@ pub(crate) async fn create_gear( save_deferred_image( &state, - "gear", + EntityType::Gear, i64::from(gear.id), image_data_url.as_deref(), ) @@ -176,7 +177,7 @@ pub(crate) async fn update_gear( save_deferred_image( &state, - "gear", + EntityType::Gear, i64::from(gear.id), image_data_url.as_deref(), ) @@ -194,7 +195,7 @@ define_delete_handler!( render_gear_list_fragment, "type=gear", "/data?type=gear", - image_type: "gear" + image_type: crate::domain::entity_type::EntityType::Gear ); #[derive(Debug, Deserialize)] diff --git a/src/application/routes/api/coffee/roasters.rs b/src/application/routes/api/coffee/roasters.rs index b186ef2..608e6b9 100644 --- a/src/application/routes/api/coffee/roasters.rs +++ b/src/application/routes/api/coffee/roasters.rs @@ -16,6 +16,7 @@ use crate::application::routes::support::{ update_response, validate_update, }; use crate::application::state::AppState; +use crate::domain::entity_type::EntityType; use crate::domain::ids::RoasterId; use crate::domain::images::ImageData; use crate::domain::listing::{ListRequest, SortDirection}; @@ -112,7 +113,7 @@ pub(crate) async fn create_roaster( save_deferred_image( &state, - "roaster", + EntityType::Roaster, i64::from(roaster.id), image_data_url.as_deref(), ) @@ -197,7 +198,7 @@ pub(crate) async fn update_roaster( save_deferred_image( &state, - "roaster", + EntityType::Roaster, i64::from(roaster.id), image_data_url.as_deref(), ) @@ -215,7 +216,7 @@ define_delete_handler!( render_roaster_list_fragment, "type=roasters", "/data?type=roasters", - image_type: "roaster" + image_type: crate::domain::entity_type::EntityType::Roaster ); #[tracing::instrument(skip(state, auth_user, headers, payload))] diff --git a/src/application/routes/api/coffee/roasts.rs b/src/application/routes/api/coffee/roasts.rs index 121ae0e..c9c6592 100644 --- a/src/application/routes/api/coffee/roasts.rs +++ b/src/application/routes/api/coffee/roasts.rs @@ -16,6 +16,7 @@ use crate::application::routes::support::{ render_redirect_script, update_response, validate_update, }; use crate::application::state::AppState; +use crate::domain::entity_type::EntityType; use crate::domain::ids::{RoastId, RoasterId}; use crate::domain::images::ImageData; use crate::domain::listing::{ListRequest, SortDirection}; @@ -79,7 +80,7 @@ pub(crate) async fn create_roast( save_deferred_image( &state, - "roast", + EntityType::Roast, i64::from(roast.id), image_data_url.as_deref(), ) @@ -174,7 +175,7 @@ define_delete_handler!( render_roast_list_fragment, "type=roasts", "/data?type=roasts", - image_type: "roast" + image_type: crate::domain::entity_type::EntityType::Roast ); #[derive(Debug, Deserialize)] @@ -249,7 +250,13 @@ pub(crate) async fn update_roast( info!(%id, "roast updated"); 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 .roast_repo diff --git a/src/application/routes/api/coffee/scan.rs b/src/application/routes/api/coffee/scan.rs index 08e6585..6a794e1 100644 --- a/src/application/routes/api/coffee/scan.rs +++ b/src/application/routes/api/coffee/scan.rs @@ -12,6 +12,7 @@ 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::entity_type::EntityType; use crate::domain::errors::RepositoryError; use crate::domain::ids::RoastId; use crate::domain::images::ImageData; @@ -366,7 +367,7 @@ pub(crate) async fn submit_scan( save_deferred_image( &state, - "roast", + EntityType::Roast, roast.id.into_inner(), scan_image.as_deref(), ) @@ -434,11 +435,17 @@ async fn submit_existing_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()) + if resolve_image_url(state, EntityType::Roast, roast.id.into_inner()) .await .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 diff --git a/src/application/routes/api/images.rs b/src/application/routes/api/images.rs index 41468ec..c593f70 100644 --- a/src/application/routes/api/images.rs +++ b/src/application/routes/api/images.rs @@ -9,12 +9,11 @@ use crate::application::auth::AuthenticatedUser; use crate::application::errors::{ApiError, AppError}; use crate::application::routes::support::{FlexiblePayload, is_datastar_request, render_fragment}; use crate::application::state::AppState; +use crate::domain::entity_type::EntityType; use crate::domain::images::EntityImage; use crate::infrastructure::image_processing::process_data_url; use crate::presentation::web::templates::ImageUploadTemplate; -const VALID_ENTITY_TYPES: &[&str] = &["roaster", "roast", "gear", "cafe", "brew", "cup"]; - #[derive(Debug, Deserialize)] pub(crate) struct ImageUpload { pub image: String, @@ -26,66 +25,68 @@ pub(crate) struct ImagePath { pub id: i64, } -fn validate_entity_type(entity_type: &str) -> Result<(), ApiError> { - if VALID_ENTITY_TYPES.contains(&entity_type) { - Ok(()) - } else { - Err(AppError::validation(format!("invalid entity type: {entity_type}")).into()) - } +fn parse_entity_type(entity_type: &str) -> Result { + entity_type + .parse::() + .map_err(|()| AppError::validation(format!("invalid entity type: {entity_type}")).into()) } async fn validate_entity_exists( state: &AppState, - entity_type: &str, + entity_type: EntityType, id: i64, ) -> 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 { - "roaster" => { + EntityType::Roaster => { state .roaster_repo .get(RoasterId::from(id)) .await .map_err(AppError::from)?; } - "roast" => { + EntityType::Roast => { state .roast_repo .get(RoastId::from(id)) .await .map_err(AppError::from)?; } - "gear" => { + EntityType::Gear => { state .gear_repo .get(GearId::from(id)) .await .map_err(AppError::from)?; } - "cafe" => { + EntityType::Cafe => { state .cafe_repo .get(CafeId::from(id)) .await .map_err(AppError::from)?; } - "brew" => { + EntityType::Brew => { state .brew_repo .get(BrewId::from(id)) .await .map_err(AppError::from)?; } - "cup" => { + EntityType::Cup => { state .cup_repo .get(CupId::from(id)) .await .map_err(AppError::from)?; } - _ => { - return Err(AppError::validation(format!("invalid entity type: {entity_type}")).into()); + EntityType::Bag => { + 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, payload: FlexiblePayload, ) -> Result { - validate_entity_type(&path.entity_type)?; - validate_entity_exists(&state, &path.entity_type, path.id).await?; + let entity_type = parse_entity_type(&path.entity_type)?; + validate_entity_exists(&state, entity_type, path.id).await?; 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}")))?; let image = EntityImage { - entity_type: path.entity_type.clone(), + entity_type, entity_id: path.id, content_type: processed.content_type, image_data: processed.image_data, @@ -155,11 +156,11 @@ pub(crate) async fn get_image( State(state): State, Path(path): Path, ) -> Result { - validate_entity_type(&path.entity_type)?; + let entity_type = parse_entity_type(&path.entity_type)?; let image = state .image_repo - .get(&path.entity_type, path.id) + .get(entity_type, path.id) .await .map_err(AppError::from)?; @@ -171,11 +172,11 @@ pub(crate) async fn get_thumbnail( State(state): State, Path(path): Path, ) -> Result { - validate_entity_type(&path.entity_type)?; + let entity_type = parse_entity_type(&path.entity_type)?; let image = state .image_repo - .get_thumbnail(&path.entity_type, path.id) + .get_thumbnail(entity_type, path.id) .await .map_err(AppError::from)?; @@ -189,11 +190,11 @@ pub(crate) async fn delete_image( headers: HeaderMap, Path(path): Path, ) -> Result { - validate_entity_type(&path.entity_type)?; + let entity_type = parse_entity_type(&path.entity_type)?; state .image_repo - .delete(&path.entity_type, path.id) + .delete(entity_type, path.id) .await .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. pub(crate) async fn resolve_image_url( state: &AppState, - entity_type: &str, + entity_type: EntityType, entity_id: i64, ) -> Option { state @@ -234,16 +235,17 @@ pub(crate) async fn resolve_image_url( /// Accepts `Option<&str>` and no-ops on `None` or empty strings. pub(crate) async fn save_deferred_image( state: &AppState, - entity_type: &str, + entity_type: EntityType, entity_id: i64, data_url: Option<&str>, ) { let Some(data_url) = data_url.filter(|s| !s.is_empty()) else { return; }; + let entity_type_str = entity_type.as_str(); let Ok(_permit) = state.image_semaphore.acquire().await else { tracing::warn!( - entity_type, + entity_type = entity_type_str, entity_id, "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 { Ok(Ok(p)) => p, 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; } 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; } }; let image = EntityImage { - entity_type: entity_type.to_string(), + entity_type, entity_id, content_type: processed.content_type, image_data: processed.image_data, thumbnail_data: processed.thumbnail_data, }; 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 { - info!(entity_type, entity_id, "deferred image saved"); + info!( + entity_type = entity_type_str, + entity_id, "deferred image saved" + ); } } diff --git a/src/application/routes/api/macros.rs b/src/application/routes/api/macros.rs index 0156311..85bcd98 100644 --- a/src/application/routes/api/macros.rs +++ b/src/application/routes/api/macros.rs @@ -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) => { 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)); }; (@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) => { diff --git a/src/application/routes/app/bags.rs b/src/application/routes/app/bags.rs index a22fa2f..816fc99 100644 --- a/src/application/routes/app/bags.rs +++ b/src/application/routes/app/bags.rs @@ -9,6 +9,7 @@ use crate::application::routes::api::images::resolve_image_url; use crate::application::routes::render_html; use crate::application::routes::support::load_roast_options; use crate::application::state::AppState; +use crate::domain::entity_type::EntityType; use crate::domain::ids::BagId; use crate::presentation::web::templates::{BagDetailTemplate, BagEditTemplate}; use crate::presentation::web::views::BagDetailView; @@ -37,7 +38,7 @@ pub(crate) async fn bag_detail_page( }, async { 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, ) }, )?; diff --git a/src/application/routes/app/brews.rs b/src/application/routes/app/brews.rs index e26d007..f9c1f9a 100644 --- a/src/application/routes/app/brews.rs +++ b/src/application/routes/app/brews.rs @@ -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::render_html; use crate::application::state::AppState; +use crate::domain::entity_type::EntityType; use crate::domain::ids::BrewId; use crate::presentation::web::templates::{BrewDetailTemplate, BrewEditTemplate}; use crate::presentation::web::views::BrewDetailView; @@ -35,7 +36,9 @@ pub(crate) async fn brew_detail_page( .await .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!( @@ -47,7 +50,9 @@ pub(crate) async fn brew_detail_page( .map_err(|e| map_app_error(e.into())) }, 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 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 { nav_active: "", diff --git a/src/application/routes/app/cafes.rs b/src/application/routes/app/cafes.rs index 2806c0d..1f31f26 100644 --- a/src/application/routes/app/cafes.rs +++ b/src/application/routes/app/cafes.rs @@ -8,6 +8,7 @@ use crate::application::errors::map_app_error; use crate::application::routes::api::images::resolve_image_url; use crate::application::routes::render_html; use crate::application::state::AppState; +use crate::domain::entity_type::EntityType; use crate::domain::ids::CafeId; use crate::presentation::web::templates::{CafeDetailTemplate, CafeEditTemplate}; use crate::presentation::web::views::CafeDetailView; @@ -26,7 +27,7 @@ pub(crate) async fn cafe_detail_page( .await .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 view = CafeDetailView::from_domain(cafe); @@ -56,7 +57,7 @@ pub(crate) async fn cafe_edit_page( .await .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 { nav_active: "", diff --git a/src/application/routes/app/cups.rs b/src/application/routes/app/cups.rs index 2d4e73f..0241374 100644 --- a/src/application/routes/app/cups.rs +++ b/src/application/routes/app/cups.rs @@ -9,6 +9,7 @@ use crate::application::routes::api::images::resolve_image_url; use crate::application::routes::render_html; use crate::application::routes::support::{load_cafe_options, load_roast_options}; use crate::application::state::AppState; +use crate::domain::entity_type::EntityType; use crate::domain::ids::CupId; use crate::presentation::web::templates::{CupDetailTemplate, CupEditTemplate}; use crate::presentation::web::views::CupDetailView; @@ -50,10 +51,10 @@ pub(crate) async fn cup_detail_page( .await .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 - .or(resolve_image_url(&state, "cafe", i64::from(cafe.id)).await) - .or(resolve_image_url(&state, "roast", i64::from(roast.id)).await); + .or(resolve_image_url(&state, EntityType::Cafe, i64::from(cafe.id)).await) + .or(resolve_image_url(&state, EntityType::Roast, i64::from(roast.id)).await); 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)?; - 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 { nav_active: "", diff --git a/src/application/routes/app/gear.rs b/src/application/routes/app/gear.rs index 808aa38..73ac477 100644 --- a/src/application/routes/app/gear.rs +++ b/src/application/routes/app/gear.rs @@ -8,6 +8,7 @@ use crate::application::errors::map_app_error; use crate::application::routes::api::images::resolve_image_url; use crate::application::routes::render_html; use crate::application::state::AppState; +use crate::domain::entity_type::EntityType; use crate::domain::ids::GearId; use crate::presentation::web::templates::{GearDetailTemplate, GearEditTemplate}; use crate::presentation::web::views::GearDetailView; @@ -26,7 +27,7 @@ pub(crate) async fn gear_detail_page( .await .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); @@ -55,7 +56,7 @@ pub(crate) async fn gear_edit_page( .await .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 { nav_active: "", diff --git a/src/application/routes/app/roasters.rs b/src/application/routes/app/roasters.rs index 26eba92..0a4b3da 100644 --- a/src/application/routes/app/roasters.rs +++ b/src/application/routes/app/roasters.rs @@ -8,6 +8,7 @@ use crate::application::errors::map_app_error; use crate::application::routes::api::images::resolve_image_url; use crate::application::routes::render_html; use crate::application::state::AppState; +use crate::domain::entity_type::EntityType; use crate::domain::ids::RoasterId; use crate::presentation::web::templates::{RoasterDetailTemplate, RoasterEditTemplate}; use crate::presentation::web::views::RoasterDetailView; @@ -26,7 +27,7 @@ pub(crate) async fn roaster_detail_page( .await .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 view = RoasterDetailView::from_domain(roaster); @@ -56,7 +57,7 @@ pub(crate) async fn roaster_edit_page( .await .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 { nav_active: "", diff --git a/src/application/routes/app/roasts.rs b/src/application/routes/app/roasts.rs index 508401e..c0438a5 100644 --- a/src/application/routes/app/roasts.rs +++ b/src/application/routes/app/roasts.rs @@ -9,6 +9,7 @@ use crate::application::routes::api::images::resolve_image_url; use crate::application::routes::render_html; use crate::application::routes::support::load_roaster_options; use crate::application::state::AppState; +use crate::domain::entity_type::EntityType; use crate::domain::ids::RoastId; use crate::presentation::web::templates::{RoastDetailTemplate, RoastEditTemplate}; use crate::presentation::web::views::RoastDetailView; @@ -33,7 +34,7 @@ pub(crate) async fn roast_detail_page( .await .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 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 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 { nav_active: "", diff --git a/src/domain/analytics/timeline.rs b/src/domain/analytics/timeline.rs index 016ee16..fa2cec5 100644 --- a/src/domain/analytics/timeline.rs +++ b/src/domain/analytics/timeline.rs @@ -1,7 +1,8 @@ use chrono::{DateTime, Utc}; 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}; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -13,10 +14,10 @@ pub struct TimelineEventDetail { /// Raw brew data for repeating a brew from the timeline. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TimelineBrewData { - pub bag_id: i64, - pub grinder_id: i64, - pub brewer_id: i64, - pub filter_paper_id: Option, + pub bag_id: BagId, + pub grinder_id: GearId, + pub brewer_id: GearId, + pub filter_paper_id: Option, pub coffee_weight: f64, pub grind_setting: f64, pub water_volume: i32, @@ -27,7 +28,7 @@ pub struct TimelineBrewData { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TimelineEvent { pub id: TimelineEventId, - pub entity_type: String, + pub entity_type: EntityType, pub entity_id: i64, pub action: String, pub occurred_at: DateTime, @@ -41,7 +42,7 @@ pub struct TimelineEvent { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NewTimelineEvent { - pub entity_type: String, + pub entity_type: EntityType, pub entity_id: i64, pub action: String, pub occurred_at: DateTime, diff --git a/src/domain/coffee/bags.rs b/src/domain/coffee/bags.rs index c6558b8..5801e2d 100644 --- a/src/domain/coffee/bags.rs +++ b/src/domain/coffee/bags.rs @@ -1,6 +1,7 @@ use chrono::{DateTime, NaiveDate, Utc}; use serde::{Deserialize, Serialize}; +use crate::domain::entity_type::EntityType; use crate::domain::ids::{BagId, RoastId}; use crate::domain::listing::{SortDirection, SortKey}; use crate::domain::roasters::Roaster; @@ -148,7 +149,7 @@ pub fn bag_timeline_event( roaster: &Roaster, ) -> NewTimelineEvent { NewTimelineEvent { - entity_type: "bag".to_string(), + entity_type: EntityType::Bag, entity_id: bag.id.into_inner(), action: action.to_string(), occurred_at: bag.created_at, diff --git a/src/domain/coffee/brews.rs b/src/domain/coffee/brews.rs index 6daee62..3227b79 100644 --- a/src/domain/coffee/brews.rs +++ b/src/domain/coffee/brews.rs @@ -3,6 +3,7 @@ use std::str::FromStr; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use crate::domain::entity_type::EntityType; use crate::domain::ids::{BagId, BrewId, GearId}; use crate::domain::listing::{SortDirection, SortKey}; use crate::domain::timeline::{NewTimelineEvent, TimelineBrewData, TimelineEventDetail}; @@ -184,7 +185,7 @@ impl BrewWithDetails { } NewTimelineEvent { - entity_type: "brew".to_string(), + entity_type: EntityType::Brew, entity_id: self.brew.id.into_inner(), action: "brewed".to_string(), occurred_at: self.brew.created_at, @@ -194,10 +195,10 @@ impl BrewWithDetails { slug: Some(self.roast_slug.clone()), roaster_slug: Some(self.roaster_slug.clone()), brew_data: Some(TimelineBrewData { - bag_id: self.brew.bag_id.into_inner(), - grinder_id: self.brew.grinder_id.into_inner(), - brewer_id: self.brew.brewer_id.into_inner(), - filter_paper_id: self.brew.filter_paper_id.map(GearId::into_inner), + bag_id: self.brew.bag_id, + grinder_id: self.brew.grinder_id, + brewer_id: self.brew.brewer_id, + filter_paper_id: self.brew.filter_paper_id, coffee_weight: self.brew.coffee_weight, grind_setting: self.brew.grind_setting, water_volume: self.brew.water_volume, diff --git a/src/domain/coffee/cafes.rs b/src/domain/coffee/cafes.rs index 9dde616..e869d76 100644 --- a/src/domain/coffee/cafes.rs +++ b/src/domain/coffee/cafes.rs @@ -2,6 +2,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use super::normalize_optional_field; +use crate::domain::entity_type::EntityType; use crate::domain::ids::CafeId; use crate::domain::listing::{SortDirection, SortKey}; use crate::domain::roasters::is_valid_url_scheme; @@ -24,7 +25,7 @@ pub struct Cafe { impl Cafe { pub fn to_timeline_event(&self) -> NewTimelineEvent { NewTimelineEvent { - entity_type: "cafe".to_string(), + entity_type: EntityType::Cafe, entity_id: self.id.into_inner(), action: "added".to_string(), occurred_at: self.created_at, diff --git a/src/domain/coffee/cups.rs b/src/domain/coffee/cups.rs index a3338f1..cfb8a59 100644 --- a/src/domain/coffee/cups.rs +++ b/src/domain/coffee/cups.rs @@ -1,6 +1,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use crate::domain::entity_type::EntityType; use crate::domain::ids::{CafeId, CupId, RoastId}; use crate::domain::listing::{SortDirection, SortKey}; use crate::domain::timeline::{NewTimelineEvent, TimelineEventDetail}; @@ -30,7 +31,7 @@ pub struct CupWithDetails { impl CupWithDetails { pub fn to_timeline_event(&self) -> NewTimelineEvent { NewTimelineEvent { - entity_type: "cup".to_string(), + entity_type: EntityType::Cup, entity_id: self.cup.id.into_inner(), action: "added".to_string(), occurred_at: self.cup.created_at, diff --git a/src/domain/coffee/gear.rs b/src/domain/coffee/gear.rs index 0e02fc6..e69c559 100644 --- a/src/domain/coffee/gear.rs +++ b/src/domain/coffee/gear.rs @@ -3,6 +3,7 @@ use std::str::FromStr; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use crate::domain::entity_type::EntityType; use crate::domain::ids::GearId; use crate::domain::listing::{SortDirection, SortKey}; use crate::domain::timeline::{NewTimelineEvent, TimelineEventDetail}; @@ -60,7 +61,7 @@ pub struct Gear { impl Gear { pub fn to_timeline_event(&self) -> NewTimelineEvent { NewTimelineEvent { - entity_type: "gear".to_string(), + entity_type: EntityType::Gear, entity_id: self.id.into_inner(), action: "added".to_string(), occurred_at: self.created_at, diff --git a/src/domain/coffee/roasters.rs b/src/domain/coffee/roasters.rs index e55727c..69596fc 100644 --- a/src/domain/coffee/roasters.rs +++ b/src/domain/coffee/roasters.rs @@ -2,6 +2,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use super::normalize_optional_field; +use crate::domain::entity_type::EntityType; use crate::domain::ids::RoasterId; use crate::domain::listing::{SortDirection, SortKey}; use crate::domain::timeline::{NewTimelineEvent, TimelineEventDetail}; @@ -66,7 +67,7 @@ impl Roaster { }); } NewTimelineEvent { - entity_type: "roaster".to_string(), + entity_type: EntityType::Roaster, entity_id: self.id.into_inner(), action: "added".to_string(), occurred_at: self.created_at, diff --git a/src/domain/coffee/roasts.rs b/src/domain/coffee/roasts.rs index 92d4ffe..d7cf5e4 100644 --- a/src/domain/coffee/roasts.rs +++ b/src/domain/coffee/roasts.rs @@ -1,6 +1,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use crate::domain::entity_type::EntityType; use crate::domain::ids::{RoastId, RoasterId}; use crate::domain::listing::{SortDirection, SortKey}; use crate::domain::roasters::Roaster; @@ -121,7 +122,7 @@ pub fn roast_timeline_event(roast: &Roast, roaster: &Roaster) -> NewTimelineEven }); } NewTimelineEvent { - entity_type: "roast".to_string(), + entity_type: EntityType::Roast, entity_id: roast.id.into_inner(), action: "added".to_string(), occurred_at: roast.created_at, diff --git a/src/domain/entity_type.rs b/src/domain/entity_type.rs new file mode 100644 index 0000000..d7395d8 --- /dev/null +++ b/src/domain/entity_type.rs @@ -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 { + 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(()), + } + } +} diff --git a/src/domain/images.rs b/src/domain/images.rs index 8f30e5e..a90a9cf 100644 --- a/src/domain/images.rs +++ b/src/domain/images.rs @@ -2,9 +2,11 @@ use std::fmt; use serde::Deserialize; +use crate::domain::entity_type::EntityType; + /// An image associated with an entity (roaster, roast, gear, or cafe). pub struct EntityImage { - pub entity_type: String, + pub entity_type: EntityType, pub entity_id: i64, pub content_type: String, pub image_data: Vec, diff --git a/src/domain/mod.rs b/src/domain/mod.rs index 36c2c5e..3d7b29b 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -2,6 +2,7 @@ pub mod analytics; pub mod auth; pub mod coffee; pub mod countries; +pub mod entity_type; pub mod errors; pub mod formatting; pub mod ids; diff --git a/src/domain/repositories.rs b/src/domain/repositories.rs index 7c864d1..8bd8a7e 100644 --- a/src/domain/repositories.rs +++ b/src/domain/repositories.rs @@ -1,5 +1,6 @@ use super::RepositoryError; 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::bags::{Bag, BagFilter, BagSortKey, BagWithRoast, NewBag, UpdateBag}; @@ -275,14 +276,22 @@ pub trait AiUsageRepository: Send + Sync { #[async_trait] pub trait ImageRepository: Send + Sync { async fn upsert(&self, image: EntityImage) -> Result<(), RepositoryError>; - async fn get(&self, entity_type: &str, entity_id: i64) -> Result; - async fn get_thumbnail( + async fn get( &self, - entity_type: &str, + entity_type: EntityType, entity_id: i64, ) -> Result; - async fn delete(&self, entity_type: &str, entity_id: i64) -> Result<(), RepositoryError>; - async fn has_image(&self, entity_type: &str, entity_id: i64) -> Result; + async fn get_thumbnail( + &self, + entity_type: EntityType, + entity_id: i64, + ) -> Result; + async fn delete(&self, entity_type: EntityType, entity_id: i64) -> Result<(), RepositoryError>; + async fn has_image( + &self, + entity_type: EntityType, + entity_id: i64, + ) -> Result; } #[async_trait] diff --git a/src/infrastructure/backup.rs b/src/infrastructure/backup.rs index fdcd6c1..c5d74c3 100644 --- a/src/infrastructure/backup.rs +++ b/src/infrastructure/backup.rs @@ -9,6 +9,7 @@ use crate::domain::bags::Bag; use crate::domain::brews::{Brew, QuickNote}; use crate::domain::cafes::Cafe; use crate::domain::cups::Cup; +use crate::domain::entity_type::EntityType; use crate::domain::gear::{Gear, GearCategory}; use crate::domain::ids::{ 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(i64::from(event.id)) - .bind(&event.entity_type) + .bind(event.entity_type.as_str()) .bind(event.entity_id) .bind(&event.action) .bind(event.occurred_at) @@ -823,9 +824,14 @@ impl TimelineEventRecord { 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 entity_type: EntityType = self + .entity_type + .parse() + .map_err(|()| anyhow::anyhow!("unknown entity type: {}", self.entity_type))?; + Ok(TimelineEvent { id: TimelineEventId::from(self.id), - entity_type: self.entity_type, + entity_type, entity_id: self.entity_id, action: self.action, occurred_at: self.occurred_at, diff --git a/src/infrastructure/repositories/analytics/timeline_events.rs b/src/infrastructure/repositories/analytics/timeline_events.rs index c51a4a1..f5552a9 100644 --- a/src/infrastructure/repositories/analytics/timeline_events.rs +++ b/src/infrastructure/repositories/analytics/timeline_events.rs @@ -1,4 +1,5 @@ use crate::domain::RepositoryError; +use crate::domain::entity_type::EntityType; use crate::domain::ids::TimelineEventId; use crate::domain::listing::{ListRequest, Page, SortDirection}; use crate::domain::repositories::TimelineEventRepository; @@ -50,7 +51,7 @@ impl TimelineEventRepository for SqlTimelineEventRepository { })?; let record = sqlx::query_as::<_, TimelineEventRecord>(query) - .bind(event.entity_type) + .bind(event.entity_type.as_str()) .bind(event.entity_id) .bind(event.action) .bind(event.occurred_at) @@ -145,9 +146,13 @@ impl TimelineEventRecord { _ => None, }; + let entity_type: EntityType = self.entity_type.parse().map_err(|()| { + RepositoryError::unexpected(format!("unknown entity type: {}", self.entity_type)) + })?; + Ok(TimelineEvent { id: TimelineEventId::from(self.id), - entity_type: self.entity_type, + entity_type, entity_id: self.entity_id, action: self.action, occurred_at: self.occurred_at, diff --git a/src/infrastructure/repositories/images.rs b/src/infrastructure/repositories/images.rs index 5186e0a..3f6072c 100644 --- a/src/infrastructure/repositories/images.rs +++ b/src/infrastructure/repositories/images.rs @@ -2,6 +2,7 @@ use async_trait::async_trait; use sqlx::{query, query_as}; use crate::domain::RepositoryError; +use crate::domain::entity_type::EntityType; use crate::domain::images::EntityImage; use crate::domain::repositories::ImageRepository; use crate::infrastructure::database::DatabasePool; @@ -16,24 +17,30 @@ impl SqlImageRepository { Self { pool } } - fn into_domain(record: ImageRecord) -> EntityImage { - EntityImage { - entity_type: record.entity_type, + fn into_domain(record: ImageRecord) -> Result { + let entity_type: EntityType = record.entity_type.parse().map_err(|()| { + RepositoryError::unexpected(format!("unknown entity type: {}", record.entity_type)) + })?; + Ok(EntityImage { + entity_type, entity_id: record.entity_id, content_type: record.content_type, image_data: record.image_data, thumbnail_data: record.thumbnail_data, - } + }) } - fn thumbnail_to_domain(record: ThumbnailRecord) -> EntityImage { - EntityImage { - entity_type: record.entity_type, + fn thumbnail_to_domain(record: ThumbnailRecord) -> Result { + let entity_type: EntityType = record.entity_type.parse().map_err(|()| { + RepositoryError::unexpected(format!("unknown entity type: {}", record.entity_type)) + })?; + Ok(EntityImage { + entity_type, entity_id: record.entity_id, content_type: record.content_type, image_data: Vec::new(), thumbnail_data: record.thumbnail_data, - } + }) } } @@ -66,7 +73,7 @@ impl ImageRepository for SqlImageRepository { thumbnail_data = excluded.thumbnail_data, 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.content_type) .bind(&image.image_data) @@ -78,25 +85,29 @@ impl ImageRepository for SqlImageRepository { Ok(()) } - async fn get(&self, entity_type: &str, entity_id: i64) -> Result { + async fn get( + &self, + entity_type: EntityType, + entity_id: i64, + ) -> Result { let record = query_as::<_, ImageRecord>( r"SELECT entity_type, entity_id, content_type, image_data, thumbnail_data FROM entity_images WHERE entity_type = ? AND entity_id = ?", ) - .bind(entity_type) + .bind(entity_type.as_str()) .bind(entity_id) .fetch_optional(&self.pool) .await .map_err(|e| RepositoryError::unexpected(e.to_string()))? .ok_or(RepositoryError::NotFound)?; - Ok(Self::into_domain(record)) + Self::into_domain(record) } async fn get_thumbnail( &self, - entity_type: &str, + entity_type: EntityType, entity_id: i64, ) -> Result { let record = query_as::<_, ThumbnailRecord>( @@ -104,19 +115,19 @@ impl ImageRepository for SqlImageRepository { FROM entity_images WHERE entity_type = ? AND entity_id = ?", ) - .bind(entity_type) + .bind(entity_type.as_str()) .bind(entity_id) .fetch_optional(&self.pool) .await .map_err(|e| RepositoryError::unexpected(e.to_string()))? .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 = ?") - .bind(entity_type) + .bind(entity_type.as_str()) .bind(entity_id) .execute(&self.pool) .await @@ -125,10 +136,14 @@ impl ImageRepository for SqlImageRepository { Ok(()) } - async fn has_image(&self, entity_type: &str, entity_id: i64) -> Result { + async fn has_image( + &self, + entity_type: EntityType, + entity_id: i64, + ) -> Result { let row: (i64,) = 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) .fetch_one(&self.pool) .await diff --git a/src/presentation/web/views/timeline.rs b/src/presentation/web/views/timeline.rs index f8d69f2..08c49e8 100644 --- a/src/presentation/web/views/timeline.rs +++ b/src/presentation/web/views/timeline.rs @@ -1,4 +1,5 @@ use crate::domain::countries::{country_to_iso, iso_to_flag_emoji}; +use crate::domain::entity_type::EntityType; use crate::domain::timeline::{TimelineEvent, TimelineEventDetail}; use super::relative_date; @@ -84,45 +85,46 @@ impl TimelineEventView { brew_data, } = event; - let kind_label = match (entity_type.as_str(), action.as_str()) { - ("roaster", "added") => "Roaster Added", - ("roast", "added") => "Roast Added", - ("bag", "added") => "Bag Added", - ("bag", "finished") => "Bag Finished", - ("gear", "added") => "Gear Added", - ("brew", "brewed") => "Brew Added", - ("cafe", "added") => "Cafe Added", - ("cup", "added") => "Cup Added", + let entity_type_str = entity_type.as_str(); + + let kind_label = match (entity_type, action.as_str()) { + (EntityType::Roaster, "added") => "Roaster Added", + (EntityType::Roast, "added") => "Roast Added", + (EntityType::Bag, "added") => "Bag Added", + (EntityType::Bag, "finished") => "Bag Finished", + (EntityType::Gear, "added") => "Gear Added", + (EntityType::Brew, "brewed") => "Brew Added", + (EntityType::Cafe, "added") => "Cafe Added", + (EntityType::Cup, "added") => "Cup Added", _ => "Event", }; - let link = match entity_type.as_str() { - "brew" => format!("/brews/{entity_id}"), - "cup" => format!("/cups/{entity_id}"), - "bag" => format!("/bags/{entity_id}"), - "gear" => format!("/gear/{entity_id}"), - "roaster" => slug.as_deref().map_or_else( + let link = match entity_type { + EntityType::Brew => format!("/brews/{entity_id}"), + EntityType::Cup => format!("/cups/{entity_id}"), + EntityType::Bag => format!("/bags/{entity_id}"), + EntityType::Gear => format!("/gear/{entity_id}"), + EntityType::Roaster => slug.as_deref().map_or_else( || "/data?type=roasters".to_string(), |s| format!("/roasters/{s}"), ), - "cafe" => slug + EntityType::Cafe => slug .as_deref() .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}"), _ => "/data?type=roasts".to_string(), }, - _ => String::from("#"), }; let (mut mapped_details, external_link) = Self::map_details(details); // 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); - let tasting_notes = if entity_type == "roast" { + let tasting_notes = if entity_type == EntityType::Roast { let notes = tasting_notes .into_iter() .flat_map(|note| { @@ -139,10 +141,12 @@ impl TimelineEventView { }; let brew_data_view = brew_data.map(|bd| TimelineBrewDataView { - bag_id: bd.bag_id, - grinder_id: bd.grinder_id, - brewer_id: bd.brewer_id, - filter_paper_id: bd.filter_paper_id, + bag_id: bd.bag_id.into_inner(), + grinder_id: bd.grinder_id.into_inner(), + brewer_id: bd.brewer_id.into_inner(), + filter_paper_id: bd + .filter_paper_id + .map(crate::domain::ids::GearId::into_inner), coffee_weight: bd.coffee_weight, grind_setting: bd.grind_setting, water_volume: bd.water_volume, @@ -152,7 +156,7 @@ impl TimelineEventView { Self { id: id.to_string(), - entity_type, + entity_type: entity_type_str.to_string(), kind_label, date_label: occurred_at.format("%b %d, %y").to_string(), relative_date_label: relative_date(occurred_at),