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::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

View file

@ -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(

View file

@ -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!(

View file

@ -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(),
)

View file

@ -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!(

View file

@ -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)]

View file

@ -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))]

View file

@ -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

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::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

View file

@ -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<EntityType, ApiError> {
entity_type
.parse::<EntityType>()
.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<ImagePath>,
payload: FlexiblePayload<ImageUpload>,
) -> Result<Response, ApiError> {
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<AppState>,
Path(path): Path<ImagePath>,
) -> Result<Response, ApiError> {
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<AppState>,
Path(path): Path<ImagePath>,
) -> Result<Response, ApiError> {
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<ImagePath>,
) -> Result<Response, ApiError> {
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<String> {
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"
);
}
}

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) => {
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) => {

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::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,
)
},
)?;

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::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: "",

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::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: "",

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::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: "",

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::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: "",

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::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: "",

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::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: "",

View file

@ -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<i64>,
pub bag_id: BagId,
pub grinder_id: GearId,
pub brewer_id: GearId,
pub filter_paper_id: Option<GearId>,
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<Utc>,
@ -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<Utc>,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

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 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<u8>,

View file

@ -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;

View file

@ -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<EntityImage, RepositoryError>;
async fn get_thumbnail(
async fn get(
&self,
entity_type: &str,
entity_type: EntityType,
entity_id: i64,
) -> Result<EntityImage, RepositoryError>;
async fn delete(&self, entity_type: &str, entity_id: i64) -> Result<(), RepositoryError>;
async fn has_image(&self, entity_type: &str, entity_id: i64) -> Result<bool, RepositoryError>;
async fn get_thumbnail(
&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]

View file

@ -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,

View file

@ -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,

View file

@ -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<EntityImage, RepositoryError> {
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<EntityImage, RepositoryError> {
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<EntityImage, RepositoryError> {
async fn get(
&self,
entity_type: EntityType,
entity_id: i64,
) -> Result<EntityImage, RepositoryError> {
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<EntityImage, RepositoryError> {
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<bool, RepositoryError> {
async fn has_image(
&self,
entity_type: EntityType,
entity_id: i64,
) -> Result<bool, RepositoryError> {
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

View file

@ -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),