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:
parent
c58c60c783
commit
de49f7c3b1
34 changed files with 305 additions and 147 deletions
|
|
@ -16,6 +16,7 @@ use crate::application::routes::support::{
|
||||||
};
|
};
|
||||||
use crate::application::state::AppState;
|
use crate::application::state::AppState;
|
||||||
use crate::domain::bags::{BagFilter, BagSortKey, BagWithRoast, NewBag, UpdateBag};
|
use crate::domain::bags::{BagFilter, BagSortKey, BagWithRoast, NewBag, UpdateBag};
|
||||||
|
use crate::domain::entity_type::EntityType;
|
||||||
use crate::domain::ids::{BagId, RoastId};
|
use crate::domain::ids::{BagId, RoastId};
|
||||||
use crate::domain::images::ImageData;
|
use crate::domain::images::ImageData;
|
||||||
use crate::domain::listing::{ListRequest, SortDirection};
|
use crate::domain::listing::{ListRequest, SortDirection};
|
||||||
|
|
@ -221,7 +222,13 @@ pub(crate) async fn update_bag(
|
||||||
info!(%id, closed = ?update.closed, "bag updated");
|
info!(%id, closed = ?update.closed, "bag updated");
|
||||||
state.stats_invalidator.invalidate();
|
state.stats_invalidator.invalidate();
|
||||||
|
|
||||||
save_deferred_image(&state, "bag", i64::from(bag.id), image_data_url.as_deref()).await;
|
save_deferred_image(
|
||||||
|
&state,
|
||||||
|
EntityType::Bag,
|
||||||
|
i64::from(bag.id),
|
||||||
|
image_data_url.as_deref(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
if is_datastar_request(&headers) {
|
if is_datastar_request(&headers) {
|
||||||
let from_bag_page = headers
|
let from_bag_page = headers
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ use crate::domain::bags::BagFilter;
|
||||||
use crate::domain::brews::{
|
use crate::domain::brews::{
|
||||||
BrewFilter, BrewSortKey, BrewWithDetails, NewBrew, QuickNote, UpdateBrew,
|
BrewFilter, BrewSortKey, BrewWithDetails, NewBrew, QuickNote, UpdateBrew,
|
||||||
};
|
};
|
||||||
|
use crate::domain::entity_type::EntityType;
|
||||||
use crate::domain::gear::{GearCategory, GearFilter, GearSortKey};
|
use crate::domain::gear::{GearCategory, GearFilter, GearSortKey};
|
||||||
use crate::domain::ids::{BagId, BrewId, GearId};
|
use crate::domain::ids::{BagId, BrewId, GearId};
|
||||||
use crate::domain::images::ImageData;
|
use crate::domain::images::ImageData;
|
||||||
|
|
@ -267,7 +268,7 @@ pub(crate) async fn create_brew(
|
||||||
|
|
||||||
save_deferred_image(
|
save_deferred_image(
|
||||||
&state,
|
&state,
|
||||||
"brew",
|
EntityType::Brew,
|
||||||
i64::from(enriched.brew.id),
|
i64::from(enriched.brew.id),
|
||||||
image_data_url.as_deref(),
|
image_data_url.as_deref(),
|
||||||
)
|
)
|
||||||
|
|
@ -417,7 +418,13 @@ pub(crate) async fn update_brew(
|
||||||
info!(%id, "brew updated");
|
info!(%id, "brew updated");
|
||||||
state.stats_invalidator.invalidate();
|
state.stats_invalidator.invalidate();
|
||||||
|
|
||||||
save_deferred_image(&state, "brew", i64::from(id), image_data_url.as_deref()).await;
|
save_deferred_image(
|
||||||
|
&state,
|
||||||
|
EntityType::Brew,
|
||||||
|
i64::from(id),
|
||||||
|
image_data_url.as_deref(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
let detail_url = format!("/brews/{id}");
|
let detail_url = format!("/brews/{id}");
|
||||||
let enriched = state
|
let enriched = state
|
||||||
|
|
@ -442,7 +449,7 @@ define_delete_handler!(
|
||||||
render_brew_list_fragment,
|
render_brew_list_fragment,
|
||||||
"type=brews",
|
"type=brews",
|
||||||
"/data?type=brews",
|
"/data?type=brews",
|
||||||
image_type: "brew"
|
image_type: crate::domain::entity_type::EntityType::Brew
|
||||||
);
|
);
|
||||||
|
|
||||||
async fn render_brew_list_fragment(
|
async fn render_brew_list_fragment(
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ use crate::application::routes::support::{
|
||||||
};
|
};
|
||||||
use crate::application::state::AppState;
|
use crate::application::state::AppState;
|
||||||
use crate::domain::cafes::{Cafe, CafeSortKey, NewCafe, UpdateCafe};
|
use crate::domain::cafes::{Cafe, CafeSortKey, NewCafe, UpdateCafe};
|
||||||
|
use crate::domain::entity_type::EntityType;
|
||||||
use crate::domain::ids::CafeId;
|
use crate::domain::ids::CafeId;
|
||||||
use crate::domain::images::ImageData;
|
use crate::domain::images::ImageData;
|
||||||
use crate::domain::listing::{ListRequest, SortDirection};
|
use crate::domain::listing::{ListRequest, SortDirection};
|
||||||
|
|
@ -113,7 +114,7 @@ pub(crate) async fn create_cafe(
|
||||||
|
|
||||||
save_deferred_image(
|
save_deferred_image(
|
||||||
&state,
|
&state,
|
||||||
"cafe",
|
EntityType::Cafe,
|
||||||
i64::from(cafe.id),
|
i64::from(cafe.id),
|
||||||
image_data_url.as_deref(),
|
image_data_url.as_deref(),
|
||||||
)
|
)
|
||||||
|
|
@ -206,7 +207,7 @@ pub(crate) async fn update_cafe(
|
||||||
|
|
||||||
save_deferred_image(
|
save_deferred_image(
|
||||||
&state,
|
&state,
|
||||||
"cafe",
|
EntityType::Cafe,
|
||||||
i64::from(cafe.id),
|
i64::from(cafe.id),
|
||||||
image_data_url.as_deref(),
|
image_data_url.as_deref(),
|
||||||
)
|
)
|
||||||
|
|
@ -224,7 +225,7 @@ define_delete_handler!(
|
||||||
render_cafe_list_fragment,
|
render_cafe_list_fragment,
|
||||||
"type=cafes",
|
"type=cafes",
|
||||||
"/data?type=cafes",
|
"/data?type=cafes",
|
||||||
image_type: "cafe"
|
image_type: crate::domain::entity_type::EntityType::Cafe
|
||||||
);
|
);
|
||||||
|
|
||||||
define_list_fragment_renderer!(
|
define_list_fragment_renderer!(
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ use crate::application::routes::support::{
|
||||||
use crate::application::state::AppState;
|
use crate::application::state::AppState;
|
||||||
use crate::domain::cafes::NewCafe;
|
use crate::domain::cafes::NewCafe;
|
||||||
use crate::domain::cups::NewCup;
|
use crate::domain::cups::NewCup;
|
||||||
|
use crate::domain::entity_type::EntityType;
|
||||||
use crate::domain::ids::{CafeId, RoastId};
|
use crate::domain::ids::{CafeId, RoastId};
|
||||||
use crate::domain::images::ImageData;
|
use crate::domain::images::ImageData;
|
||||||
|
|
||||||
|
|
@ -85,7 +86,7 @@ pub(crate) async fn submit_checkin(
|
||||||
|
|
||||||
save_deferred_image(
|
save_deferred_image(
|
||||||
&state,
|
&state,
|
||||||
"cafe",
|
EntityType::Cafe,
|
||||||
i64::from(cafe.id),
|
i64::from(cafe.id),
|
||||||
submission.cafe_image.as_deref(),
|
submission.cafe_image.as_deref(),
|
||||||
)
|
)
|
||||||
|
|
@ -108,7 +109,7 @@ pub(crate) async fn submit_checkin(
|
||||||
|
|
||||||
save_deferred_image(
|
save_deferred_image(
|
||||||
&state,
|
&state,
|
||||||
"cup",
|
EntityType::Cup,
|
||||||
i64::from(cup.id),
|
i64::from(cup.id),
|
||||||
submission.cup_image.as_deref(),
|
submission.cup_image.as_deref(),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ use crate::application::routes::support::{
|
||||||
};
|
};
|
||||||
use crate::application::state::AppState;
|
use crate::application::state::AppState;
|
||||||
use crate::domain::cups::{CupFilter, CupSortKey, CupWithDetails, NewCup, UpdateCup};
|
use crate::domain::cups::{CupFilter, CupSortKey, CupWithDetails, NewCup, UpdateCup};
|
||||||
|
use crate::domain::entity_type::EntityType;
|
||||||
use crate::domain::ids::{CafeId, CupId, RoastId};
|
use crate::domain::ids::{CafeId, CupId, RoastId};
|
||||||
use crate::domain::images::ImageData;
|
use crate::domain::images::ImageData;
|
||||||
use crate::domain::listing::{ListRequest, SortDirection};
|
use crate::domain::listing::{ListRequest, SortDirection};
|
||||||
|
|
@ -143,7 +144,13 @@ pub(crate) async fn update_cup(
|
||||||
info!(%id, "cup updated");
|
info!(%id, "cup updated");
|
||||||
state.stats_invalidator.invalidate();
|
state.stats_invalidator.invalidate();
|
||||||
|
|
||||||
save_deferred_image(&state, "cup", i64::from(cup.id), image_data_url.as_deref()).await;
|
save_deferred_image(
|
||||||
|
&state,
|
||||||
|
EntityType::Cup,
|
||||||
|
i64::from(cup.id),
|
||||||
|
image_data_url.as_deref(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
let detail_url = format!("/cups/{id}");
|
let detail_url = format!("/cups/{id}");
|
||||||
let enriched = state
|
let enriched = state
|
||||||
|
|
@ -168,7 +175,7 @@ define_delete_handler!(
|
||||||
render_cup_list_fragment,
|
render_cup_list_fragment,
|
||||||
"type=cups",
|
"type=cups",
|
||||||
"/data?type=cups",
|
"/data?type=cups",
|
||||||
image_type: "cup"
|
image_type: crate::domain::entity_type::EntityType::Cup
|
||||||
);
|
);
|
||||||
|
|
||||||
define_list_fragment_renderer!(
|
define_list_fragment_renderer!(
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ use crate::application::routes::support::{
|
||||||
update_response, validate_update,
|
update_response, validate_update,
|
||||||
};
|
};
|
||||||
use crate::application::state::AppState;
|
use crate::application::state::AppState;
|
||||||
|
use crate::domain::entity_type::EntityType;
|
||||||
use crate::domain::gear::{Gear, GearCategory, GearFilter, GearSortKey, NewGear, UpdateGear};
|
use crate::domain::gear::{Gear, GearCategory, GearFilter, GearSortKey, NewGear, UpdateGear};
|
||||||
use crate::domain::ids::GearId;
|
use crate::domain::ids::GearId;
|
||||||
use crate::domain::images::ImageData;
|
use crate::domain::images::ImageData;
|
||||||
|
|
@ -75,7 +76,7 @@ pub(crate) async fn create_gear(
|
||||||
|
|
||||||
save_deferred_image(
|
save_deferred_image(
|
||||||
&state,
|
&state,
|
||||||
"gear",
|
EntityType::Gear,
|
||||||
i64::from(gear.id),
|
i64::from(gear.id),
|
||||||
image_data_url.as_deref(),
|
image_data_url.as_deref(),
|
||||||
)
|
)
|
||||||
|
|
@ -176,7 +177,7 @@ pub(crate) async fn update_gear(
|
||||||
|
|
||||||
save_deferred_image(
|
save_deferred_image(
|
||||||
&state,
|
&state,
|
||||||
"gear",
|
EntityType::Gear,
|
||||||
i64::from(gear.id),
|
i64::from(gear.id),
|
||||||
image_data_url.as_deref(),
|
image_data_url.as_deref(),
|
||||||
)
|
)
|
||||||
|
|
@ -194,7 +195,7 @@ define_delete_handler!(
|
||||||
render_gear_list_fragment,
|
render_gear_list_fragment,
|
||||||
"type=gear",
|
"type=gear",
|
||||||
"/data?type=gear",
|
"/data?type=gear",
|
||||||
image_type: "gear"
|
image_type: crate::domain::entity_type::EntityType::Gear
|
||||||
);
|
);
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ use crate::application::routes::support::{
|
||||||
update_response, validate_update,
|
update_response, validate_update,
|
||||||
};
|
};
|
||||||
use crate::application::state::AppState;
|
use crate::application::state::AppState;
|
||||||
|
use crate::domain::entity_type::EntityType;
|
||||||
use crate::domain::ids::RoasterId;
|
use crate::domain::ids::RoasterId;
|
||||||
use crate::domain::images::ImageData;
|
use crate::domain::images::ImageData;
|
||||||
use crate::domain::listing::{ListRequest, SortDirection};
|
use crate::domain::listing::{ListRequest, SortDirection};
|
||||||
|
|
@ -112,7 +113,7 @@ pub(crate) async fn create_roaster(
|
||||||
|
|
||||||
save_deferred_image(
|
save_deferred_image(
|
||||||
&state,
|
&state,
|
||||||
"roaster",
|
EntityType::Roaster,
|
||||||
i64::from(roaster.id),
|
i64::from(roaster.id),
|
||||||
image_data_url.as_deref(),
|
image_data_url.as_deref(),
|
||||||
)
|
)
|
||||||
|
|
@ -197,7 +198,7 @@ pub(crate) async fn update_roaster(
|
||||||
|
|
||||||
save_deferred_image(
|
save_deferred_image(
|
||||||
&state,
|
&state,
|
||||||
"roaster",
|
EntityType::Roaster,
|
||||||
i64::from(roaster.id),
|
i64::from(roaster.id),
|
||||||
image_data_url.as_deref(),
|
image_data_url.as_deref(),
|
||||||
)
|
)
|
||||||
|
|
@ -215,7 +216,7 @@ define_delete_handler!(
|
||||||
render_roaster_list_fragment,
|
render_roaster_list_fragment,
|
||||||
"type=roasters",
|
"type=roasters",
|
||||||
"/data?type=roasters",
|
"/data?type=roasters",
|
||||||
image_type: "roaster"
|
image_type: crate::domain::entity_type::EntityType::Roaster
|
||||||
);
|
);
|
||||||
|
|
||||||
#[tracing::instrument(skip(state, auth_user, headers, payload))]
|
#[tracing::instrument(skip(state, auth_user, headers, payload))]
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ use crate::application::routes::support::{
|
||||||
render_redirect_script, update_response, validate_update,
|
render_redirect_script, update_response, validate_update,
|
||||||
};
|
};
|
||||||
use crate::application::state::AppState;
|
use crate::application::state::AppState;
|
||||||
|
use crate::domain::entity_type::EntityType;
|
||||||
use crate::domain::ids::{RoastId, RoasterId};
|
use crate::domain::ids::{RoastId, RoasterId};
|
||||||
use crate::domain::images::ImageData;
|
use crate::domain::images::ImageData;
|
||||||
use crate::domain::listing::{ListRequest, SortDirection};
|
use crate::domain::listing::{ListRequest, SortDirection};
|
||||||
|
|
@ -79,7 +80,7 @@ pub(crate) async fn create_roast(
|
||||||
|
|
||||||
save_deferred_image(
|
save_deferred_image(
|
||||||
&state,
|
&state,
|
||||||
"roast",
|
EntityType::Roast,
|
||||||
i64::from(roast.id),
|
i64::from(roast.id),
|
||||||
image_data_url.as_deref(),
|
image_data_url.as_deref(),
|
||||||
)
|
)
|
||||||
|
|
@ -174,7 +175,7 @@ define_delete_handler!(
|
||||||
render_roast_list_fragment,
|
render_roast_list_fragment,
|
||||||
"type=roasts",
|
"type=roasts",
|
||||||
"/data?type=roasts",
|
"/data?type=roasts",
|
||||||
image_type: "roast"
|
image_type: crate::domain::entity_type::EntityType::Roast
|
||||||
);
|
);
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
@ -249,7 +250,13 @@ pub(crate) async fn update_roast(
|
||||||
info!(%id, "roast updated");
|
info!(%id, "roast updated");
|
||||||
state.stats_invalidator.invalidate();
|
state.stats_invalidator.invalidate();
|
||||||
|
|
||||||
save_deferred_image(&state, "roast", i64::from(id), image_data_url.as_deref()).await;
|
save_deferred_image(
|
||||||
|
&state,
|
||||||
|
EntityType::Roast,
|
||||||
|
i64::from(id),
|
||||||
|
image_data_url.as_deref(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
let enriched = state
|
let enriched = state
|
||||||
.roast_repo
|
.roast_repo
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ use crate::application::routes::api::roasts::TastingNotesInput;
|
||||||
use crate::application::routes::support::{FlexiblePayload, is_datastar_request};
|
use crate::application::routes::support::{FlexiblePayload, is_datastar_request};
|
||||||
use crate::application::state::AppState;
|
use crate::application::state::AppState;
|
||||||
use crate::domain::bags::NewBag;
|
use crate::domain::bags::NewBag;
|
||||||
|
use crate::domain::entity_type::EntityType;
|
||||||
use crate::domain::errors::RepositoryError;
|
use crate::domain::errors::RepositoryError;
|
||||||
use crate::domain::ids::RoastId;
|
use crate::domain::ids::RoastId;
|
||||||
use crate::domain::images::ImageData;
|
use crate::domain::images::ImageData;
|
||||||
|
|
@ -366,7 +367,7 @@ pub(crate) async fn submit_scan(
|
||||||
|
|
||||||
save_deferred_image(
|
save_deferred_image(
|
||||||
&state,
|
&state,
|
||||||
"roast",
|
EntityType::Roast,
|
||||||
roast.id.into_inner(),
|
roast.id.into_inner(),
|
||||||
scan_image.as_deref(),
|
scan_image.as_deref(),
|
||||||
)
|
)
|
||||||
|
|
@ -434,11 +435,17 @@ async fn submit_existing_roast(
|
||||||
let roaster_slug = &roast_with_roaster.roaster_slug;
|
let roaster_slug = &roast_with_roaster.roaster_slug;
|
||||||
|
|
||||||
// Save scan image if roast doesn't have one yet
|
// Save scan image if roast doesn't have one yet
|
||||||
if resolve_image_url(state, "roast", roast.id.into_inner())
|
if resolve_image_url(state, EntityType::Roast, roast.id.into_inner())
|
||||||
.await
|
.await
|
||||||
.is_none()
|
.is_none()
|
||||||
{
|
{
|
||||||
save_deferred_image(state, "roast", roast.id.into_inner(), scan_image.as_deref()).await;
|
save_deferred_image(
|
||||||
|
state,
|
||||||
|
EntityType::Roast,
|
||||||
|
roast.id.into_inner(),
|
||||||
|
scan_image.as_deref(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
let wants_bag = submission
|
let wants_bag = submission
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,11 @@ use crate::application::auth::AuthenticatedUser;
|
||||||
use crate::application::errors::{ApiError, AppError};
|
use crate::application::errors::{ApiError, AppError};
|
||||||
use crate::application::routes::support::{FlexiblePayload, is_datastar_request, render_fragment};
|
use crate::application::routes::support::{FlexiblePayload, is_datastar_request, render_fragment};
|
||||||
use crate::application::state::AppState;
|
use crate::application::state::AppState;
|
||||||
|
use crate::domain::entity_type::EntityType;
|
||||||
use crate::domain::images::EntityImage;
|
use crate::domain::images::EntityImage;
|
||||||
use crate::infrastructure::image_processing::process_data_url;
|
use crate::infrastructure::image_processing::process_data_url;
|
||||||
use crate::presentation::web::templates::ImageUploadTemplate;
|
use crate::presentation::web::templates::ImageUploadTemplate;
|
||||||
|
|
||||||
const VALID_ENTITY_TYPES: &[&str] = &["roaster", "roast", "gear", "cafe", "brew", "cup"];
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub(crate) struct ImageUpload {
|
pub(crate) struct ImageUpload {
|
||||||
pub image: String,
|
pub image: String,
|
||||||
|
|
@ -26,66 +25,68 @@ pub(crate) struct ImagePath {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_entity_type(entity_type: &str) -> Result<(), ApiError> {
|
fn parse_entity_type(entity_type: &str) -> Result<EntityType, ApiError> {
|
||||||
if VALID_ENTITY_TYPES.contains(&entity_type) {
|
entity_type
|
||||||
Ok(())
|
.parse::<EntityType>()
|
||||||
} else {
|
.map_err(|()| AppError::validation(format!("invalid entity type: {entity_type}")).into())
|
||||||
Err(AppError::validation(format!("invalid entity type: {entity_type}")).into())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn validate_entity_exists(
|
async fn validate_entity_exists(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
entity_type: &str,
|
entity_type: EntityType,
|
||||||
id: i64,
|
id: i64,
|
||||||
) -> Result<(), ApiError> {
|
) -> Result<(), ApiError> {
|
||||||
use crate::domain::ids::{BrewId, CafeId, CupId, GearId, RoastId, RoasterId};
|
use crate::domain::ids::{BagId, BrewId, CafeId, CupId, GearId, RoastId, RoasterId};
|
||||||
|
|
||||||
match entity_type {
|
match entity_type {
|
||||||
"roaster" => {
|
EntityType::Roaster => {
|
||||||
state
|
state
|
||||||
.roaster_repo
|
.roaster_repo
|
||||||
.get(RoasterId::from(id))
|
.get(RoasterId::from(id))
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::from)?;
|
.map_err(AppError::from)?;
|
||||||
}
|
}
|
||||||
"roast" => {
|
EntityType::Roast => {
|
||||||
state
|
state
|
||||||
.roast_repo
|
.roast_repo
|
||||||
.get(RoastId::from(id))
|
.get(RoastId::from(id))
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::from)?;
|
.map_err(AppError::from)?;
|
||||||
}
|
}
|
||||||
"gear" => {
|
EntityType::Gear => {
|
||||||
state
|
state
|
||||||
.gear_repo
|
.gear_repo
|
||||||
.get(GearId::from(id))
|
.get(GearId::from(id))
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::from)?;
|
.map_err(AppError::from)?;
|
||||||
}
|
}
|
||||||
"cafe" => {
|
EntityType::Cafe => {
|
||||||
state
|
state
|
||||||
.cafe_repo
|
.cafe_repo
|
||||||
.get(CafeId::from(id))
|
.get(CafeId::from(id))
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::from)?;
|
.map_err(AppError::from)?;
|
||||||
}
|
}
|
||||||
"brew" => {
|
EntityType::Brew => {
|
||||||
state
|
state
|
||||||
.brew_repo
|
.brew_repo
|
||||||
.get(BrewId::from(id))
|
.get(BrewId::from(id))
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::from)?;
|
.map_err(AppError::from)?;
|
||||||
}
|
}
|
||||||
"cup" => {
|
EntityType::Cup => {
|
||||||
state
|
state
|
||||||
.cup_repo
|
.cup_repo
|
||||||
.get(CupId::from(id))
|
.get(CupId::from(id))
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::from)?;
|
.map_err(AppError::from)?;
|
||||||
}
|
}
|
||||||
_ => {
|
EntityType::Bag => {
|
||||||
return Err(AppError::validation(format!("invalid entity type: {entity_type}")).into());
|
state
|
||||||
|
.bag_repo
|
||||||
|
.get(BagId::from(id))
|
||||||
|
.await
|
||||||
|
.map_err(AppError::from)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -100,8 +101,8 @@ pub(crate) async fn upload_image(
|
||||||
Path(path): Path<ImagePath>,
|
Path(path): Path<ImagePath>,
|
||||||
payload: FlexiblePayload<ImageUpload>,
|
payload: FlexiblePayload<ImageUpload>,
|
||||||
) -> Result<Response, ApiError> {
|
) -> Result<Response, ApiError> {
|
||||||
validate_entity_type(&path.entity_type)?;
|
let entity_type = parse_entity_type(&path.entity_type)?;
|
||||||
validate_entity_exists(&state, &path.entity_type, path.id).await?;
|
validate_entity_exists(&state, entity_type, path.id).await?;
|
||||||
|
|
||||||
let (upload, _source) = payload.into_parts();
|
let (upload, _source) = payload.into_parts();
|
||||||
|
|
||||||
|
|
@ -118,7 +119,7 @@ pub(crate) async fn upload_image(
|
||||||
.map_err(|e| AppError::validation(format!("invalid image: {e}")))?;
|
.map_err(|e| AppError::validation(format!("invalid image: {e}")))?;
|
||||||
|
|
||||||
let image = EntityImage {
|
let image = EntityImage {
|
||||||
entity_type: path.entity_type.clone(),
|
entity_type,
|
||||||
entity_id: path.id,
|
entity_id: path.id,
|
||||||
content_type: processed.content_type,
|
content_type: processed.content_type,
|
||||||
image_data: processed.image_data,
|
image_data: processed.image_data,
|
||||||
|
|
@ -155,11 +156,11 @@ pub(crate) async fn get_image(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(path): Path<ImagePath>,
|
Path(path): Path<ImagePath>,
|
||||||
) -> Result<Response, ApiError> {
|
) -> Result<Response, ApiError> {
|
||||||
validate_entity_type(&path.entity_type)?;
|
let entity_type = parse_entity_type(&path.entity_type)?;
|
||||||
|
|
||||||
let image = state
|
let image = state
|
||||||
.image_repo
|
.image_repo
|
||||||
.get(&path.entity_type, path.id)
|
.get(entity_type, path.id)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::from)?;
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
|
|
@ -171,11 +172,11 @@ pub(crate) async fn get_thumbnail(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(path): Path<ImagePath>,
|
Path(path): Path<ImagePath>,
|
||||||
) -> Result<Response, ApiError> {
|
) -> Result<Response, ApiError> {
|
||||||
validate_entity_type(&path.entity_type)?;
|
let entity_type = parse_entity_type(&path.entity_type)?;
|
||||||
|
|
||||||
let image = state
|
let image = state
|
||||||
.image_repo
|
.image_repo
|
||||||
.get_thumbnail(&path.entity_type, path.id)
|
.get_thumbnail(entity_type, path.id)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::from)?;
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
|
|
@ -189,11 +190,11 @@ pub(crate) async fn delete_image(
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Path(path): Path<ImagePath>,
|
Path(path): Path<ImagePath>,
|
||||||
) -> Result<Response, ApiError> {
|
) -> Result<Response, ApiError> {
|
||||||
validate_entity_type(&path.entity_type)?;
|
let entity_type = parse_entity_type(&path.entity_type)?;
|
||||||
|
|
||||||
state
|
state
|
||||||
.image_repo
|
.image_repo
|
||||||
.delete(&path.entity_type, path.id)
|
.delete(entity_type, path.id)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::from)?;
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
|
|
@ -218,7 +219,7 @@ pub(crate) async fn delete_image(
|
||||||
/// Check if an entity has an image and return its URL if so.
|
/// Check if an entity has an image and return its URL if so.
|
||||||
pub(crate) async fn resolve_image_url(
|
pub(crate) async fn resolve_image_url(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
entity_type: &str,
|
entity_type: EntityType,
|
||||||
entity_id: i64,
|
entity_id: i64,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
state
|
state
|
||||||
|
|
@ -234,16 +235,17 @@ pub(crate) async fn resolve_image_url(
|
||||||
/// Accepts `Option<&str>` and no-ops on `None` or empty strings.
|
/// Accepts `Option<&str>` and no-ops on `None` or empty strings.
|
||||||
pub(crate) async fn save_deferred_image(
|
pub(crate) async fn save_deferred_image(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
entity_type: &str,
|
entity_type: EntityType,
|
||||||
entity_id: i64,
|
entity_id: i64,
|
||||||
data_url: Option<&str>,
|
data_url: Option<&str>,
|
||||||
) {
|
) {
|
||||||
let Some(data_url) = data_url.filter(|s| !s.is_empty()) else {
|
let Some(data_url) = data_url.filter(|s| !s.is_empty()) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
let entity_type_str = entity_type.as_str();
|
||||||
let Ok(_permit) = state.image_semaphore.acquire().await else {
|
let Ok(_permit) = state.image_semaphore.acquire().await else {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
entity_type,
|
entity_type = entity_type_str,
|
||||||
entity_id,
|
entity_id,
|
||||||
"image semaphore closed, skipping deferred image"
|
"image semaphore closed, skipping deferred image"
|
||||||
);
|
);
|
||||||
|
|
@ -254,25 +256,28 @@ pub(crate) async fn save_deferred_image(
|
||||||
let processed = match tokio::task::spawn_blocking(move || process_data_url(&data_url)).await {
|
let processed = match tokio::task::spawn_blocking(move || process_data_url(&data_url)).await {
|
||||||
Ok(Ok(p)) => p,
|
Ok(Ok(p)) => p,
|
||||||
Ok(Err(err)) => {
|
Ok(Err(err)) => {
|
||||||
tracing::warn!(entity_type, entity_id, error = %err, "failed to process deferred image");
|
tracing::warn!(entity_type = entity_type_str, entity_id, error = %err, "failed to process deferred image");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
tracing::warn!(entity_type, entity_id, error = %err, "deferred image task panicked");
|
tracing::warn!(entity_type = entity_type_str, entity_id, error = %err, "deferred image task panicked");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let image = EntityImage {
|
let image = EntityImage {
|
||||||
entity_type: entity_type.to_string(),
|
entity_type,
|
||||||
entity_id,
|
entity_id,
|
||||||
content_type: processed.content_type,
|
content_type: processed.content_type,
|
||||||
image_data: processed.image_data,
|
image_data: processed.image_data,
|
||||||
thumbnail_data: processed.thumbnail_data,
|
thumbnail_data: processed.thumbnail_data,
|
||||||
};
|
};
|
||||||
if let Err(err) = state.image_repo.upsert(image).await {
|
if let Err(err) = state.image_repo.upsert(image).await {
|
||||||
tracing::warn!(entity_type, entity_id, error = %err, "failed to save deferred image");
|
tracing::warn!(entity_type = entity_type_str, entity_id, error = %err, "failed to save deferred image");
|
||||||
} else {
|
} else {
|
||||||
info!(entity_type, entity_id, "deferred image saved");
|
info!(
|
||||||
|
entity_type = entity_type_str,
|
||||||
|
entity_id, "deferred image saved"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -89,7 +89,7 @@ macro_rules! define_delete_handler {
|
||||||
($fn_name:ident, $id_type:ty, $sort_key:ty, $repo_field:ident, $render_fragment:path, $referer_match:literal, $redirect_url:literal) => {
|
($fn_name:ident, $id_type:ty, $sort_key:ty, $repo_field:ident, $render_fragment:path, $referer_match:literal, $redirect_url:literal) => {
|
||||||
define_delete_handler!(@inner $fn_name, $id_type, $sort_key, $repo_field, $render_fragment, $referer_match, $redirect_url, None);
|
define_delete_handler!(@inner $fn_name, $id_type, $sort_key, $repo_field, $render_fragment, $referer_match, $redirect_url, None);
|
||||||
};
|
};
|
||||||
($fn_name:ident, $id_type:ty, $sort_key:ty, $repo_field:ident, $render_fragment:path, $referer_match:literal, $redirect_url:literal, image_type: $image_type:literal) => {
|
($fn_name:ident, $id_type:ty, $sort_key:ty, $repo_field:ident, $render_fragment:path, $referer_match:literal, $redirect_url:literal, image_type: $image_type:expr) => {
|
||||||
define_delete_handler!(@inner $fn_name, $id_type, $sort_key, $repo_field, $render_fragment, $referer_match, $redirect_url, Some($image_type));
|
define_delete_handler!(@inner $fn_name, $id_type, $sort_key, $repo_field, $render_fragment, $referer_match, $redirect_url, Some($image_type));
|
||||||
};
|
};
|
||||||
(@inner $fn_name:ident, $id_type:ty, $sort_key:ty, $repo_field:ident, $render_fragment:path, $referer_match:literal, $redirect_url:literal, $image_type:expr) => {
|
(@inner $fn_name:ident, $id_type:ty, $sort_key:ty, $repo_field:ident, $render_fragment:path, $referer_match:literal, $redirect_url:literal, $image_type:expr) => {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ use crate::application::routes::api::images::resolve_image_url;
|
||||||
use crate::application::routes::render_html;
|
use crate::application::routes::render_html;
|
||||||
use crate::application::routes::support::load_roast_options;
|
use crate::application::routes::support::load_roast_options;
|
||||||
use crate::application::state::AppState;
|
use crate::application::state::AppState;
|
||||||
|
use crate::domain::entity_type::EntityType;
|
||||||
use crate::domain::ids::BagId;
|
use crate::domain::ids::BagId;
|
||||||
use crate::presentation::web::templates::{BagDetailTemplate, BagEditTemplate};
|
use crate::presentation::web::templates::{BagDetailTemplate, BagEditTemplate};
|
||||||
use crate::presentation::web::views::BagDetailView;
|
use crate::presentation::web::views::BagDetailView;
|
||||||
|
|
@ -37,7 +38,7 @@ pub(crate) async fn bag_detail_page(
|
||||||
},
|
},
|
||||||
async {
|
async {
|
||||||
Ok::<_, StatusCode>(
|
Ok::<_, StatusCode>(
|
||||||
resolve_image_url(&state, "roast", i64::from(bag.bag.roast_id)).await,
|
resolve_image_url(&state, EntityType::Roast, i64::from(bag.bag.roast_id)).await,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ use crate::application::routes::api::brews::load_brew_form_data;
|
||||||
use crate::application::routes::api::images::resolve_image_url;
|
use crate::application::routes::api::images::resolve_image_url;
|
||||||
use crate::application::routes::render_html;
|
use crate::application::routes::render_html;
|
||||||
use crate::application::state::AppState;
|
use crate::application::state::AppState;
|
||||||
|
use crate::domain::entity_type::EntityType;
|
||||||
use crate::domain::ids::BrewId;
|
use crate::domain::ids::BrewId;
|
||||||
use crate::presentation::web::templates::{BrewDetailTemplate, BrewEditTemplate};
|
use crate::presentation::web::templates::{BrewDetailTemplate, BrewEditTemplate};
|
||||||
use crate::presentation::web::views::BrewDetailView;
|
use crate::presentation::web::views::BrewDetailView;
|
||||||
|
|
@ -35,7 +36,9 @@ pub(crate) async fn brew_detail_page(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| map_app_error(e.into()))
|
.map_err(|e| map_app_error(e.into()))
|
||||||
},
|
},
|
||||||
async { Ok::<_, StatusCode>(resolve_image_url(&state, "brew", i64::from(id)).await) },
|
async {
|
||||||
|
Ok::<_, StatusCode>(resolve_image_url(&state, EntityType::Brew, i64::from(id)).await)
|
||||||
|
},
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let (roast, roast_image_url) = tokio::try_join!(
|
let (roast, roast_image_url) = tokio::try_join!(
|
||||||
|
|
@ -47,7 +50,9 @@ pub(crate) async fn brew_detail_page(
|
||||||
.map_err(|e| map_app_error(e.into()))
|
.map_err(|e| map_app_error(e.into()))
|
||||||
},
|
},
|
||||||
async {
|
async {
|
||||||
Ok::<_, StatusCode>(resolve_image_url(&state, "roast", i64::from(bag.roast_id)).await)
|
Ok::<_, StatusCode>(
|
||||||
|
resolve_image_url(&state, EntityType::Roast, i64::from(bag.roast_id)).await,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
|
@ -90,7 +95,7 @@ pub(crate) async fn brew_edit_page(
|
||||||
|
|
||||||
let form_data = load_brew_form_data(&state).await.map_err(map_app_error)?;
|
let form_data = load_brew_form_data(&state).await.map_err(map_app_error)?;
|
||||||
|
|
||||||
let image_url = resolve_image_url(&state, "brew", i64::from(id)).await;
|
let image_url = resolve_image_url(&state, EntityType::Brew, i64::from(id)).await;
|
||||||
|
|
||||||
let template = BrewEditTemplate {
|
let template = BrewEditTemplate {
|
||||||
nav_active: "",
|
nav_active: "",
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ use crate::application::errors::map_app_error;
|
||||||
use crate::application::routes::api::images::resolve_image_url;
|
use crate::application::routes::api::images::resolve_image_url;
|
||||||
use crate::application::routes::render_html;
|
use crate::application::routes::render_html;
|
||||||
use crate::application::state::AppState;
|
use crate::application::state::AppState;
|
||||||
|
use crate::domain::entity_type::EntityType;
|
||||||
use crate::domain::ids::CafeId;
|
use crate::domain::ids::CafeId;
|
||||||
use crate::presentation::web::templates::{CafeDetailTemplate, CafeEditTemplate};
|
use crate::presentation::web::templates::{CafeDetailTemplate, CafeEditTemplate};
|
||||||
use crate::presentation::web::views::CafeDetailView;
|
use crate::presentation::web::views::CafeDetailView;
|
||||||
|
|
@ -26,7 +27,7 @@ pub(crate) async fn cafe_detail_page(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| map_app_error(e.into()))?;
|
.map_err(|e| map_app_error(e.into()))?;
|
||||||
|
|
||||||
let image_url = resolve_image_url(&state, "cafe", i64::from(cafe.id)).await;
|
let image_url = resolve_image_url(&state, EntityType::Cafe, i64::from(cafe.id)).await;
|
||||||
let edit_url = format!("/cafes/{}/edit", cafe.id);
|
let edit_url = format!("/cafes/{}/edit", cafe.id);
|
||||||
|
|
||||||
let view = CafeDetailView::from_domain(cafe);
|
let view = CafeDetailView::from_domain(cafe);
|
||||||
|
|
@ -56,7 +57,7 @@ pub(crate) async fn cafe_edit_page(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| map_app_error(e.into()))?;
|
.map_err(|e| map_app_error(e.into()))?;
|
||||||
|
|
||||||
let image_url = resolve_image_url(&state, "cafe", i64::from(id)).await;
|
let image_url = resolve_image_url(&state, EntityType::Cafe, i64::from(id)).await;
|
||||||
|
|
||||||
let template = CafeEditTemplate {
|
let template = CafeEditTemplate {
|
||||||
nav_active: "",
|
nav_active: "",
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ use crate::application::routes::api::images::resolve_image_url;
|
||||||
use crate::application::routes::render_html;
|
use crate::application::routes::render_html;
|
||||||
use crate::application::routes::support::{load_cafe_options, load_roast_options};
|
use crate::application::routes::support::{load_cafe_options, load_roast_options};
|
||||||
use crate::application::state::AppState;
|
use crate::application::state::AppState;
|
||||||
|
use crate::domain::entity_type::EntityType;
|
||||||
use crate::domain::ids::CupId;
|
use crate::domain::ids::CupId;
|
||||||
use crate::presentation::web::templates::{CupDetailTemplate, CupEditTemplate};
|
use crate::presentation::web::templates::{CupDetailTemplate, CupEditTemplate};
|
||||||
use crate::presentation::web::views::CupDetailView;
|
use crate::presentation::web::views::CupDetailView;
|
||||||
|
|
@ -50,10 +51,10 @@ pub(crate) async fn cup_detail_page(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| map_app_error(e.into()))?;
|
.map_err(|e| map_app_error(e.into()))?;
|
||||||
|
|
||||||
let image_url = resolve_image_url(&state, "cup", i64::from(id))
|
let image_url = resolve_image_url(&state, EntityType::Cup, i64::from(id))
|
||||||
.await
|
.await
|
||||||
.or(resolve_image_url(&state, "cafe", i64::from(cafe.id)).await)
|
.or(resolve_image_url(&state, EntityType::Cafe, i64::from(cafe.id)).await)
|
||||||
.or(resolve_image_url(&state, "roast", i64::from(roast.id)).await);
|
.or(resolve_image_url(&state, EntityType::Roast, i64::from(roast.id)).await);
|
||||||
|
|
||||||
let view = CupDetailView::from_parts(cup_details, &roast, &roaster, &cafe);
|
let view = CupDetailView::from_parts(cup_details, &roast, &roaster, &cafe);
|
||||||
|
|
||||||
|
|
@ -91,7 +92,7 @@ pub(crate) async fn cup_edit_page(
|
||||||
},)
|
},)
|
||||||
.map_err(map_app_error)?;
|
.map_err(map_app_error)?;
|
||||||
|
|
||||||
let image_url = resolve_image_url(&state, "cup", i64::from(id)).await;
|
let image_url = resolve_image_url(&state, EntityType::Cup, i64::from(id)).await;
|
||||||
|
|
||||||
let template = CupEditTemplate {
|
let template = CupEditTemplate {
|
||||||
nav_active: "",
|
nav_active: "",
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ use crate::application::errors::map_app_error;
|
||||||
use crate::application::routes::api::images::resolve_image_url;
|
use crate::application::routes::api::images::resolve_image_url;
|
||||||
use crate::application::routes::render_html;
|
use crate::application::routes::render_html;
|
||||||
use crate::application::state::AppState;
|
use crate::application::state::AppState;
|
||||||
|
use crate::domain::entity_type::EntityType;
|
||||||
use crate::domain::ids::GearId;
|
use crate::domain::ids::GearId;
|
||||||
use crate::presentation::web::templates::{GearDetailTemplate, GearEditTemplate};
|
use crate::presentation::web::templates::{GearDetailTemplate, GearEditTemplate};
|
||||||
use crate::presentation::web::views::GearDetailView;
|
use crate::presentation::web::views::GearDetailView;
|
||||||
|
|
@ -26,7 +27,7 @@ pub(crate) async fn gear_detail_page(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| map_app_error(e.into()))?;
|
.map_err(|e| map_app_error(e.into()))?;
|
||||||
|
|
||||||
let image_url = resolve_image_url(&state, "gear", i64::from(id)).await;
|
let image_url = resolve_image_url(&state, EntityType::Gear, i64::from(id)).await;
|
||||||
|
|
||||||
let view = GearDetailView::from_domain(gear);
|
let view = GearDetailView::from_domain(gear);
|
||||||
|
|
||||||
|
|
@ -55,7 +56,7 @@ pub(crate) async fn gear_edit_page(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| map_app_error(e.into()))?;
|
.map_err(|e| map_app_error(e.into()))?;
|
||||||
|
|
||||||
let image_url = resolve_image_url(&state, "gear", i64::from(id)).await;
|
let image_url = resolve_image_url(&state, EntityType::Gear, i64::from(id)).await;
|
||||||
|
|
||||||
let template = GearEditTemplate {
|
let template = GearEditTemplate {
|
||||||
nav_active: "",
|
nav_active: "",
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ use crate::application::errors::map_app_error;
|
||||||
use crate::application::routes::api::images::resolve_image_url;
|
use crate::application::routes::api::images::resolve_image_url;
|
||||||
use crate::application::routes::render_html;
|
use crate::application::routes::render_html;
|
||||||
use crate::application::state::AppState;
|
use crate::application::state::AppState;
|
||||||
|
use crate::domain::entity_type::EntityType;
|
||||||
use crate::domain::ids::RoasterId;
|
use crate::domain::ids::RoasterId;
|
||||||
use crate::presentation::web::templates::{RoasterDetailTemplate, RoasterEditTemplate};
|
use crate::presentation::web::templates::{RoasterDetailTemplate, RoasterEditTemplate};
|
||||||
use crate::presentation::web::views::RoasterDetailView;
|
use crate::presentation::web::views::RoasterDetailView;
|
||||||
|
|
@ -26,7 +27,7 @@ pub(crate) async fn roaster_detail_page(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| map_app_error(e.into()))?;
|
.map_err(|e| map_app_error(e.into()))?;
|
||||||
|
|
||||||
let image_url = resolve_image_url(&state, "roaster", i64::from(roaster.id)).await;
|
let image_url = resolve_image_url(&state, EntityType::Roaster, i64::from(roaster.id)).await;
|
||||||
let edit_url = format!("/roasters/{}/edit", roaster.id);
|
let edit_url = format!("/roasters/{}/edit", roaster.id);
|
||||||
|
|
||||||
let view = RoasterDetailView::from_domain(roaster);
|
let view = RoasterDetailView::from_domain(roaster);
|
||||||
|
|
@ -56,7 +57,7 @@ pub(crate) async fn roaster_edit_page(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| map_app_error(e.into()))?;
|
.map_err(|e| map_app_error(e.into()))?;
|
||||||
|
|
||||||
let image_url = resolve_image_url(&state, "roaster", i64::from(id)).await;
|
let image_url = resolve_image_url(&state, EntityType::Roaster, i64::from(id)).await;
|
||||||
|
|
||||||
let template = RoasterEditTemplate {
|
let template = RoasterEditTemplate {
|
||||||
nav_active: "",
|
nav_active: "",
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ use crate::application::routes::api::images::resolve_image_url;
|
||||||
use crate::application::routes::render_html;
|
use crate::application::routes::render_html;
|
||||||
use crate::application::routes::support::load_roaster_options;
|
use crate::application::routes::support::load_roaster_options;
|
||||||
use crate::application::state::AppState;
|
use crate::application::state::AppState;
|
||||||
|
use crate::domain::entity_type::EntityType;
|
||||||
use crate::domain::ids::RoastId;
|
use crate::domain::ids::RoastId;
|
||||||
use crate::presentation::web::templates::{RoastDetailTemplate, RoastEditTemplate};
|
use crate::presentation::web::templates::{RoastDetailTemplate, RoastEditTemplate};
|
||||||
use crate::presentation::web::views::RoastDetailView;
|
use crate::presentation::web::views::RoastDetailView;
|
||||||
|
|
@ -33,7 +34,7 @@ pub(crate) async fn roast_detail_page(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| map_app_error(e.into()))?;
|
.map_err(|e| map_app_error(e.into()))?;
|
||||||
|
|
||||||
let image_url = resolve_image_url(&state, "roast", i64::from(roast.id)).await;
|
let image_url = resolve_image_url(&state, EntityType::Roast, i64::from(roast.id)).await;
|
||||||
let edit_url = format!("/roasts/{}/edit", roast.id);
|
let edit_url = format!("/roasts/{}/edit", roast.id);
|
||||||
|
|
||||||
let view = RoastDetailView::from_parts(roast, &roaster);
|
let view = RoastDetailView::from_parts(roast, &roaster);
|
||||||
|
|
@ -72,7 +73,7 @@ pub(crate) async fn roast_edit_page(
|
||||||
|
|
||||||
let roaster_options = load_roaster_options(&state).await.map_err(map_app_error)?;
|
let roaster_options = load_roaster_options(&state).await.map_err(map_app_error)?;
|
||||||
|
|
||||||
let image_url = resolve_image_url(&state, "roast", i64::from(id)).await;
|
let image_url = resolve_image_url(&state, EntityType::Roast, i64::from(id)).await;
|
||||||
|
|
||||||
let template = RoastEditTemplate {
|
let template = RoastEditTemplate {
|
||||||
nav_active: "",
|
nav_active: "",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::domain::ids::TimelineEventId;
|
use crate::domain::entity_type::EntityType;
|
||||||
|
use crate::domain::ids::{BagId, GearId, TimelineEventId};
|
||||||
use crate::domain::listing::{SortDirection, SortKey};
|
use crate::domain::listing::{SortDirection, SortKey};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|
@ -13,10 +14,10 @@ pub struct TimelineEventDetail {
|
||||||
/// Raw brew data for repeating a brew from the timeline.
|
/// Raw brew data for repeating a brew from the timeline.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct TimelineBrewData {
|
pub struct TimelineBrewData {
|
||||||
pub bag_id: i64,
|
pub bag_id: BagId,
|
||||||
pub grinder_id: i64,
|
pub grinder_id: GearId,
|
||||||
pub brewer_id: i64,
|
pub brewer_id: GearId,
|
||||||
pub filter_paper_id: Option<i64>,
|
pub filter_paper_id: Option<GearId>,
|
||||||
pub coffee_weight: f64,
|
pub coffee_weight: f64,
|
||||||
pub grind_setting: f64,
|
pub grind_setting: f64,
|
||||||
pub water_volume: i32,
|
pub water_volume: i32,
|
||||||
|
|
@ -27,7 +28,7 @@ pub struct TimelineBrewData {
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct TimelineEvent {
|
pub struct TimelineEvent {
|
||||||
pub id: TimelineEventId,
|
pub id: TimelineEventId,
|
||||||
pub entity_type: String,
|
pub entity_type: EntityType,
|
||||||
pub entity_id: i64,
|
pub entity_id: i64,
|
||||||
pub action: String,
|
pub action: String,
|
||||||
pub occurred_at: DateTime<Utc>,
|
pub occurred_at: DateTime<Utc>,
|
||||||
|
|
@ -41,7 +42,7 @@ pub struct TimelineEvent {
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct NewTimelineEvent {
|
pub struct NewTimelineEvent {
|
||||||
pub entity_type: String,
|
pub entity_type: EntityType,
|
||||||
pub entity_id: i64,
|
pub entity_id: i64,
|
||||||
pub action: String,
|
pub action: String,
|
||||||
pub occurred_at: DateTime<Utc>,
|
pub occurred_at: DateTime<Utc>,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
use chrono::{DateTime, NaiveDate, Utc};
|
use chrono::{DateTime, NaiveDate, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::domain::entity_type::EntityType;
|
||||||
use crate::domain::ids::{BagId, RoastId};
|
use crate::domain::ids::{BagId, RoastId};
|
||||||
use crate::domain::listing::{SortDirection, SortKey};
|
use crate::domain::listing::{SortDirection, SortKey};
|
||||||
use crate::domain::roasters::Roaster;
|
use crate::domain::roasters::Roaster;
|
||||||
|
|
@ -148,7 +149,7 @@ pub fn bag_timeline_event(
|
||||||
roaster: &Roaster,
|
roaster: &Roaster,
|
||||||
) -> NewTimelineEvent {
|
) -> NewTimelineEvent {
|
||||||
NewTimelineEvent {
|
NewTimelineEvent {
|
||||||
entity_type: "bag".to_string(),
|
entity_type: EntityType::Bag,
|
||||||
entity_id: bag.id.into_inner(),
|
entity_id: bag.id.into_inner(),
|
||||||
action: action.to_string(),
|
action: action.to_string(),
|
||||||
occurred_at: bag.created_at,
|
occurred_at: bag.created_at,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ use std::str::FromStr;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::domain::entity_type::EntityType;
|
||||||
use crate::domain::ids::{BagId, BrewId, GearId};
|
use crate::domain::ids::{BagId, BrewId, GearId};
|
||||||
use crate::domain::listing::{SortDirection, SortKey};
|
use crate::domain::listing::{SortDirection, SortKey};
|
||||||
use crate::domain::timeline::{NewTimelineEvent, TimelineBrewData, TimelineEventDetail};
|
use crate::domain::timeline::{NewTimelineEvent, TimelineBrewData, TimelineEventDetail};
|
||||||
|
|
@ -184,7 +185,7 @@ impl BrewWithDetails {
|
||||||
}
|
}
|
||||||
|
|
||||||
NewTimelineEvent {
|
NewTimelineEvent {
|
||||||
entity_type: "brew".to_string(),
|
entity_type: EntityType::Brew,
|
||||||
entity_id: self.brew.id.into_inner(),
|
entity_id: self.brew.id.into_inner(),
|
||||||
action: "brewed".to_string(),
|
action: "brewed".to_string(),
|
||||||
occurred_at: self.brew.created_at,
|
occurred_at: self.brew.created_at,
|
||||||
|
|
@ -194,10 +195,10 @@ impl BrewWithDetails {
|
||||||
slug: Some(self.roast_slug.clone()),
|
slug: Some(self.roast_slug.clone()),
|
||||||
roaster_slug: Some(self.roaster_slug.clone()),
|
roaster_slug: Some(self.roaster_slug.clone()),
|
||||||
brew_data: Some(TimelineBrewData {
|
brew_data: Some(TimelineBrewData {
|
||||||
bag_id: self.brew.bag_id.into_inner(),
|
bag_id: self.brew.bag_id,
|
||||||
grinder_id: self.brew.grinder_id.into_inner(),
|
grinder_id: self.brew.grinder_id,
|
||||||
brewer_id: self.brew.brewer_id.into_inner(),
|
brewer_id: self.brew.brewer_id,
|
||||||
filter_paper_id: self.brew.filter_paper_id.map(GearId::into_inner),
|
filter_paper_id: self.brew.filter_paper_id,
|
||||||
coffee_weight: self.brew.coffee_weight,
|
coffee_weight: self.brew.coffee_weight,
|
||||||
grind_setting: self.brew.grind_setting,
|
grind_setting: self.brew.grind_setting,
|
||||||
water_volume: self.brew.water_volume,
|
water_volume: self.brew.water_volume,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use super::normalize_optional_field;
|
use super::normalize_optional_field;
|
||||||
|
use crate::domain::entity_type::EntityType;
|
||||||
use crate::domain::ids::CafeId;
|
use crate::domain::ids::CafeId;
|
||||||
use crate::domain::listing::{SortDirection, SortKey};
|
use crate::domain::listing::{SortDirection, SortKey};
|
||||||
use crate::domain::roasters::is_valid_url_scheme;
|
use crate::domain::roasters::is_valid_url_scheme;
|
||||||
|
|
@ -24,7 +25,7 @@ pub struct Cafe {
|
||||||
impl Cafe {
|
impl Cafe {
|
||||||
pub fn to_timeline_event(&self) -> NewTimelineEvent {
|
pub fn to_timeline_event(&self) -> NewTimelineEvent {
|
||||||
NewTimelineEvent {
|
NewTimelineEvent {
|
||||||
entity_type: "cafe".to_string(),
|
entity_type: EntityType::Cafe,
|
||||||
entity_id: self.id.into_inner(),
|
entity_id: self.id.into_inner(),
|
||||||
action: "added".to_string(),
|
action: "added".to_string(),
|
||||||
occurred_at: self.created_at,
|
occurred_at: self.created_at,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::domain::entity_type::EntityType;
|
||||||
use crate::domain::ids::{CafeId, CupId, RoastId};
|
use crate::domain::ids::{CafeId, CupId, RoastId};
|
||||||
use crate::domain::listing::{SortDirection, SortKey};
|
use crate::domain::listing::{SortDirection, SortKey};
|
||||||
use crate::domain::timeline::{NewTimelineEvent, TimelineEventDetail};
|
use crate::domain::timeline::{NewTimelineEvent, TimelineEventDetail};
|
||||||
|
|
@ -30,7 +31,7 @@ pub struct CupWithDetails {
|
||||||
impl CupWithDetails {
|
impl CupWithDetails {
|
||||||
pub fn to_timeline_event(&self) -> NewTimelineEvent {
|
pub fn to_timeline_event(&self) -> NewTimelineEvent {
|
||||||
NewTimelineEvent {
|
NewTimelineEvent {
|
||||||
entity_type: "cup".to_string(),
|
entity_type: EntityType::Cup,
|
||||||
entity_id: self.cup.id.into_inner(),
|
entity_id: self.cup.id.into_inner(),
|
||||||
action: "added".to_string(),
|
action: "added".to_string(),
|
||||||
occurred_at: self.cup.created_at,
|
occurred_at: self.cup.created_at,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ use std::str::FromStr;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::domain::entity_type::EntityType;
|
||||||
use crate::domain::ids::GearId;
|
use crate::domain::ids::GearId;
|
||||||
use crate::domain::listing::{SortDirection, SortKey};
|
use crate::domain::listing::{SortDirection, SortKey};
|
||||||
use crate::domain::timeline::{NewTimelineEvent, TimelineEventDetail};
|
use crate::domain::timeline::{NewTimelineEvent, TimelineEventDetail};
|
||||||
|
|
@ -60,7 +61,7 @@ pub struct Gear {
|
||||||
impl Gear {
|
impl Gear {
|
||||||
pub fn to_timeline_event(&self) -> NewTimelineEvent {
|
pub fn to_timeline_event(&self) -> NewTimelineEvent {
|
||||||
NewTimelineEvent {
|
NewTimelineEvent {
|
||||||
entity_type: "gear".to_string(),
|
entity_type: EntityType::Gear,
|
||||||
entity_id: self.id.into_inner(),
|
entity_id: self.id.into_inner(),
|
||||||
action: "added".to_string(),
|
action: "added".to_string(),
|
||||||
occurred_at: self.created_at,
|
occurred_at: self.created_at,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use super::normalize_optional_field;
|
use super::normalize_optional_field;
|
||||||
|
use crate::domain::entity_type::EntityType;
|
||||||
use crate::domain::ids::RoasterId;
|
use crate::domain::ids::RoasterId;
|
||||||
use crate::domain::listing::{SortDirection, SortKey};
|
use crate::domain::listing::{SortDirection, SortKey};
|
||||||
use crate::domain::timeline::{NewTimelineEvent, TimelineEventDetail};
|
use crate::domain::timeline::{NewTimelineEvent, TimelineEventDetail};
|
||||||
|
|
@ -66,7 +67,7 @@ impl Roaster {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
NewTimelineEvent {
|
NewTimelineEvent {
|
||||||
entity_type: "roaster".to_string(),
|
entity_type: EntityType::Roaster,
|
||||||
entity_id: self.id.into_inner(),
|
entity_id: self.id.into_inner(),
|
||||||
action: "added".to_string(),
|
action: "added".to_string(),
|
||||||
occurred_at: self.created_at,
|
occurred_at: self.created_at,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::domain::entity_type::EntityType;
|
||||||
use crate::domain::ids::{RoastId, RoasterId};
|
use crate::domain::ids::{RoastId, RoasterId};
|
||||||
use crate::domain::listing::{SortDirection, SortKey};
|
use crate::domain::listing::{SortDirection, SortKey};
|
||||||
use crate::domain::roasters::Roaster;
|
use crate::domain::roasters::Roaster;
|
||||||
|
|
@ -121,7 +122,7 @@ pub fn roast_timeline_event(roast: &Roast, roaster: &Roaster) -> NewTimelineEven
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
NewTimelineEvent {
|
NewTimelineEvent {
|
||||||
entity_type: "roast".to_string(),
|
entity_type: EntityType::Roast,
|
||||||
entity_id: roast.id.into_inner(),
|
entity_id: roast.id.into_inner(),
|
||||||
action: "added".to_string(),
|
action: "added".to_string(),
|
||||||
occurred_at: roast.created_at,
|
occurred_at: roast.created_at,
|
||||||
|
|
|
||||||
53
src/domain/entity_type.rs
Normal file
53
src/domain/entity_type.rs
Normal 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(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,9 +2,11 @@ use std::fmt;
|
||||||
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::domain::entity_type::EntityType;
|
||||||
|
|
||||||
/// An image associated with an entity (roaster, roast, gear, or cafe).
|
/// An image associated with an entity (roaster, roast, gear, or cafe).
|
||||||
pub struct EntityImage {
|
pub struct EntityImage {
|
||||||
pub entity_type: String,
|
pub entity_type: EntityType,
|
||||||
pub entity_id: i64,
|
pub entity_id: i64,
|
||||||
pub content_type: String,
|
pub content_type: String,
|
||||||
pub image_data: Vec<u8>,
|
pub image_data: Vec<u8>,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ pub mod analytics;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod coffee;
|
pub mod coffee;
|
||||||
pub mod countries;
|
pub mod countries;
|
||||||
|
pub mod entity_type;
|
||||||
pub mod errors;
|
pub mod errors;
|
||||||
pub mod formatting;
|
pub mod formatting;
|
||||||
pub mod ids;
|
pub mod ids;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
use super::RepositoryError;
|
use super::RepositoryError;
|
||||||
use crate::domain::ai_usage::{AiUsage, AiUsageSummary, NewAiUsage};
|
use crate::domain::ai_usage::{AiUsage, AiUsageSummary, NewAiUsage};
|
||||||
|
use crate::domain::entity_type::EntityType;
|
||||||
use crate::domain::listing::{ListRequest, Page, SortDirection, SortKey};
|
use crate::domain::listing::{ListRequest, Page, SortDirection, SortKey};
|
||||||
|
|
||||||
use crate::domain::bags::{Bag, BagFilter, BagSortKey, BagWithRoast, NewBag, UpdateBag};
|
use crate::domain::bags::{Bag, BagFilter, BagSortKey, BagWithRoast, NewBag, UpdateBag};
|
||||||
|
|
@ -275,14 +276,22 @@ pub trait AiUsageRepository: Send + Sync {
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait ImageRepository: Send + Sync {
|
pub trait ImageRepository: Send + Sync {
|
||||||
async fn upsert(&self, image: EntityImage) -> Result<(), RepositoryError>;
|
async fn upsert(&self, image: EntityImage) -> Result<(), RepositoryError>;
|
||||||
async fn get(&self, entity_type: &str, entity_id: i64) -> Result<EntityImage, RepositoryError>;
|
async fn get(
|
||||||
async fn get_thumbnail(
|
|
||||||
&self,
|
&self,
|
||||||
entity_type: &str,
|
entity_type: EntityType,
|
||||||
entity_id: i64,
|
entity_id: i64,
|
||||||
) -> Result<EntityImage, RepositoryError>;
|
) -> Result<EntityImage, RepositoryError>;
|
||||||
async fn delete(&self, entity_type: &str, entity_id: i64) -> Result<(), RepositoryError>;
|
async fn get_thumbnail(
|
||||||
async fn has_image(&self, entity_type: &str, entity_id: i64) -> Result<bool, RepositoryError>;
|
&self,
|
||||||
|
entity_type: EntityType,
|
||||||
|
entity_id: i64,
|
||||||
|
) -> Result<EntityImage, RepositoryError>;
|
||||||
|
async fn delete(&self, entity_type: EntityType, entity_id: i64) -> Result<(), RepositoryError>;
|
||||||
|
async fn has_image(
|
||||||
|
&self,
|
||||||
|
entity_type: EntityType,
|
||||||
|
entity_id: i64,
|
||||||
|
) -> Result<bool, RepositoryError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ use crate::domain::bags::Bag;
|
||||||
use crate::domain::brews::{Brew, QuickNote};
|
use crate::domain::brews::{Brew, QuickNote};
|
||||||
use crate::domain::cafes::Cafe;
|
use crate::domain::cafes::Cafe;
|
||||||
use crate::domain::cups::Cup;
|
use crate::domain::cups::Cup;
|
||||||
|
use crate::domain::entity_type::EntityType;
|
||||||
use crate::domain::gear::{Gear, GearCategory};
|
use crate::domain::gear::{Gear, GearCategory};
|
||||||
use crate::domain::ids::{
|
use crate::domain::ids::{
|
||||||
BagId, BrewId, CafeId, CupId, GearId, RoastId, RoasterId, TimelineEventId,
|
BagId, BrewId, CafeId, CupId, GearId, RoastId, RoasterId, TimelineEventId,
|
||||||
|
|
@ -549,7 +550,7 @@ impl BackupService {
|
||||||
"INSERT INTO timeline_events (id, entity_type, entity_id, action, occurred_at, title, details_json, tasting_notes_json, slug, roaster_slug, brew_data_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
"INSERT INTO timeline_events (id, entity_type, entity_id, action, occurred_at, title, details_json, tasting_notes_json, slug, roaster_slug, brew_data_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
)
|
)
|
||||||
.bind(i64::from(event.id))
|
.bind(i64::from(event.id))
|
||||||
.bind(&event.entity_type)
|
.bind(event.entity_type.as_str())
|
||||||
.bind(event.entity_id)
|
.bind(event.entity_id)
|
||||||
.bind(&event.action)
|
.bind(&event.action)
|
||||||
.bind(event.occurred_at)
|
.bind(event.occurred_at)
|
||||||
|
|
@ -823,9 +824,14 @@ impl TimelineEventRecord {
|
||||||
let tasting_notes = decode_json_vec(self.tasting_notes_json, "timeline tasting notes")?;
|
let tasting_notes = decode_json_vec(self.tasting_notes_json, "timeline tasting notes")?;
|
||||||
let brew_data = decode_json_opt(self.brew_data_json, "timeline brew data")?;
|
let brew_data = decode_json_opt(self.brew_data_json, "timeline brew data")?;
|
||||||
|
|
||||||
|
let entity_type: EntityType = self
|
||||||
|
.entity_type
|
||||||
|
.parse()
|
||||||
|
.map_err(|()| anyhow::anyhow!("unknown entity type: {}", self.entity_type))?;
|
||||||
|
|
||||||
Ok(TimelineEvent {
|
Ok(TimelineEvent {
|
||||||
id: TimelineEventId::from(self.id),
|
id: TimelineEventId::from(self.id),
|
||||||
entity_type: self.entity_type,
|
entity_type,
|
||||||
entity_id: self.entity_id,
|
entity_id: self.entity_id,
|
||||||
action: self.action,
|
action: self.action,
|
||||||
occurred_at: self.occurred_at,
|
occurred_at: self.occurred_at,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
use crate::domain::RepositoryError;
|
use crate::domain::RepositoryError;
|
||||||
|
use crate::domain::entity_type::EntityType;
|
||||||
use crate::domain::ids::TimelineEventId;
|
use crate::domain::ids::TimelineEventId;
|
||||||
use crate::domain::listing::{ListRequest, Page, SortDirection};
|
use crate::domain::listing::{ListRequest, Page, SortDirection};
|
||||||
use crate::domain::repositories::TimelineEventRepository;
|
use crate::domain::repositories::TimelineEventRepository;
|
||||||
|
|
@ -50,7 +51,7 @@ impl TimelineEventRepository for SqlTimelineEventRepository {
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let record = sqlx::query_as::<_, TimelineEventRecord>(query)
|
let record = sqlx::query_as::<_, TimelineEventRecord>(query)
|
||||||
.bind(event.entity_type)
|
.bind(event.entity_type.as_str())
|
||||||
.bind(event.entity_id)
|
.bind(event.entity_id)
|
||||||
.bind(event.action)
|
.bind(event.action)
|
||||||
.bind(event.occurred_at)
|
.bind(event.occurred_at)
|
||||||
|
|
@ -145,9 +146,13 @@ impl TimelineEventRecord {
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let entity_type: EntityType = self.entity_type.parse().map_err(|()| {
|
||||||
|
RepositoryError::unexpected(format!("unknown entity type: {}", self.entity_type))
|
||||||
|
})?;
|
||||||
|
|
||||||
Ok(TimelineEvent {
|
Ok(TimelineEvent {
|
||||||
id: TimelineEventId::from(self.id),
|
id: TimelineEventId::from(self.id),
|
||||||
entity_type: self.entity_type,
|
entity_type,
|
||||||
entity_id: self.entity_id,
|
entity_id: self.entity_id,
|
||||||
action: self.action,
|
action: self.action,
|
||||||
occurred_at: self.occurred_at,
|
occurred_at: self.occurred_at,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ use async_trait::async_trait;
|
||||||
use sqlx::{query, query_as};
|
use sqlx::{query, query_as};
|
||||||
|
|
||||||
use crate::domain::RepositoryError;
|
use crate::domain::RepositoryError;
|
||||||
|
use crate::domain::entity_type::EntityType;
|
||||||
use crate::domain::images::EntityImage;
|
use crate::domain::images::EntityImage;
|
||||||
use crate::domain::repositories::ImageRepository;
|
use crate::domain::repositories::ImageRepository;
|
||||||
use crate::infrastructure::database::DatabasePool;
|
use crate::infrastructure::database::DatabasePool;
|
||||||
|
|
@ -16,24 +17,30 @@ impl SqlImageRepository {
|
||||||
Self { pool }
|
Self { pool }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn into_domain(record: ImageRecord) -> EntityImage {
|
fn into_domain(record: ImageRecord) -> Result<EntityImage, RepositoryError> {
|
||||||
EntityImage {
|
let entity_type: EntityType = record.entity_type.parse().map_err(|()| {
|
||||||
entity_type: record.entity_type,
|
RepositoryError::unexpected(format!("unknown entity type: {}", record.entity_type))
|
||||||
|
})?;
|
||||||
|
Ok(EntityImage {
|
||||||
|
entity_type,
|
||||||
entity_id: record.entity_id,
|
entity_id: record.entity_id,
|
||||||
content_type: record.content_type,
|
content_type: record.content_type,
|
||||||
image_data: record.image_data,
|
image_data: record.image_data,
|
||||||
thumbnail_data: record.thumbnail_data,
|
thumbnail_data: record.thumbnail_data,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn thumbnail_to_domain(record: ThumbnailRecord) -> EntityImage {
|
fn thumbnail_to_domain(record: ThumbnailRecord) -> Result<EntityImage, RepositoryError> {
|
||||||
EntityImage {
|
let entity_type: EntityType = record.entity_type.parse().map_err(|()| {
|
||||||
entity_type: record.entity_type,
|
RepositoryError::unexpected(format!("unknown entity type: {}", record.entity_type))
|
||||||
|
})?;
|
||||||
|
Ok(EntityImage {
|
||||||
|
entity_type,
|
||||||
entity_id: record.entity_id,
|
entity_id: record.entity_id,
|
||||||
content_type: record.content_type,
|
content_type: record.content_type,
|
||||||
image_data: Vec::new(),
|
image_data: Vec::new(),
|
||||||
thumbnail_data: record.thumbnail_data,
|
thumbnail_data: record.thumbnail_data,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -66,7 +73,7 @@ impl ImageRepository for SqlImageRepository {
|
||||||
thumbnail_data = excluded.thumbnail_data,
|
thumbnail_data = excluded.thumbnail_data,
|
||||||
created_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')",
|
created_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')",
|
||||||
)
|
)
|
||||||
.bind(&image.entity_type)
|
.bind(image.entity_type.as_str())
|
||||||
.bind(image.entity_id)
|
.bind(image.entity_id)
|
||||||
.bind(&image.content_type)
|
.bind(&image.content_type)
|
||||||
.bind(&image.image_data)
|
.bind(&image.image_data)
|
||||||
|
|
@ -78,25 +85,29 @@ impl ImageRepository for SqlImageRepository {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get(&self, entity_type: &str, entity_id: i64) -> Result<EntityImage, RepositoryError> {
|
async fn get(
|
||||||
|
&self,
|
||||||
|
entity_type: EntityType,
|
||||||
|
entity_id: i64,
|
||||||
|
) -> Result<EntityImage, RepositoryError> {
|
||||||
let record = query_as::<_, ImageRecord>(
|
let record = query_as::<_, ImageRecord>(
|
||||||
r"SELECT entity_type, entity_id, content_type, image_data, thumbnail_data
|
r"SELECT entity_type, entity_id, content_type, image_data, thumbnail_data
|
||||||
FROM entity_images
|
FROM entity_images
|
||||||
WHERE entity_type = ? AND entity_id = ?",
|
WHERE entity_type = ? AND entity_id = ?",
|
||||||
)
|
)
|
||||||
.bind(entity_type)
|
.bind(entity_type.as_str())
|
||||||
.bind(entity_id)
|
.bind(entity_id)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| RepositoryError::unexpected(e.to_string()))?
|
.map_err(|e| RepositoryError::unexpected(e.to_string()))?
|
||||||
.ok_or(RepositoryError::NotFound)?;
|
.ok_or(RepositoryError::NotFound)?;
|
||||||
|
|
||||||
Ok(Self::into_domain(record))
|
Self::into_domain(record)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_thumbnail(
|
async fn get_thumbnail(
|
||||||
&self,
|
&self,
|
||||||
entity_type: &str,
|
entity_type: EntityType,
|
||||||
entity_id: i64,
|
entity_id: i64,
|
||||||
) -> Result<EntityImage, RepositoryError> {
|
) -> Result<EntityImage, RepositoryError> {
|
||||||
let record = query_as::<_, ThumbnailRecord>(
|
let record = query_as::<_, ThumbnailRecord>(
|
||||||
|
|
@ -104,19 +115,19 @@ impl ImageRepository for SqlImageRepository {
|
||||||
FROM entity_images
|
FROM entity_images
|
||||||
WHERE entity_type = ? AND entity_id = ?",
|
WHERE entity_type = ? AND entity_id = ?",
|
||||||
)
|
)
|
||||||
.bind(entity_type)
|
.bind(entity_type.as_str())
|
||||||
.bind(entity_id)
|
.bind(entity_id)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| RepositoryError::unexpected(e.to_string()))?
|
.map_err(|e| RepositoryError::unexpected(e.to_string()))?
|
||||||
.ok_or(RepositoryError::NotFound)?;
|
.ok_or(RepositoryError::NotFound)?;
|
||||||
|
|
||||||
Ok(Self::thumbnail_to_domain(record))
|
Self::thumbnail_to_domain(record)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete(&self, entity_type: &str, entity_id: i64) -> Result<(), RepositoryError> {
|
async fn delete(&self, entity_type: EntityType, entity_id: i64) -> Result<(), RepositoryError> {
|
||||||
query(r"DELETE FROM entity_images WHERE entity_type = ? AND entity_id = ?")
|
query(r"DELETE FROM entity_images WHERE entity_type = ? AND entity_id = ?")
|
||||||
.bind(entity_type)
|
.bind(entity_type.as_str())
|
||||||
.bind(entity_id)
|
.bind(entity_id)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
|
|
@ -125,10 +136,14 @@ impl ImageRepository for SqlImageRepository {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn has_image(&self, entity_type: &str, entity_id: i64) -> Result<bool, RepositoryError> {
|
async fn has_image(
|
||||||
|
&self,
|
||||||
|
entity_type: EntityType,
|
||||||
|
entity_id: i64,
|
||||||
|
) -> Result<bool, RepositoryError> {
|
||||||
let row: (i64,) =
|
let row: (i64,) =
|
||||||
query_as(r"SELECT COUNT(*) FROM entity_images WHERE entity_type = ? AND entity_id = ?")
|
query_as(r"SELECT COUNT(*) FROM entity_images WHERE entity_type = ? AND entity_id = ?")
|
||||||
.bind(entity_type)
|
.bind(entity_type.as_str())
|
||||||
.bind(entity_id)
|
.bind(entity_id)
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
.await
|
.await
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
use crate::domain::countries::{country_to_iso, iso_to_flag_emoji};
|
use crate::domain::countries::{country_to_iso, iso_to_flag_emoji};
|
||||||
|
use crate::domain::entity_type::EntityType;
|
||||||
use crate::domain::timeline::{TimelineEvent, TimelineEventDetail};
|
use crate::domain::timeline::{TimelineEvent, TimelineEventDetail};
|
||||||
|
|
||||||
use super::relative_date;
|
use super::relative_date;
|
||||||
|
|
@ -84,45 +85,46 @@ impl TimelineEventView {
|
||||||
brew_data,
|
brew_data,
|
||||||
} = event;
|
} = event;
|
||||||
|
|
||||||
let kind_label = match (entity_type.as_str(), action.as_str()) {
|
let entity_type_str = entity_type.as_str();
|
||||||
("roaster", "added") => "Roaster Added",
|
|
||||||
("roast", "added") => "Roast Added",
|
let kind_label = match (entity_type, action.as_str()) {
|
||||||
("bag", "added") => "Bag Added",
|
(EntityType::Roaster, "added") => "Roaster Added",
|
||||||
("bag", "finished") => "Bag Finished",
|
(EntityType::Roast, "added") => "Roast Added",
|
||||||
("gear", "added") => "Gear Added",
|
(EntityType::Bag, "added") => "Bag Added",
|
||||||
("brew", "brewed") => "Brew Added",
|
(EntityType::Bag, "finished") => "Bag Finished",
|
||||||
("cafe", "added") => "Cafe Added",
|
(EntityType::Gear, "added") => "Gear Added",
|
||||||
("cup", "added") => "Cup Added",
|
(EntityType::Brew, "brewed") => "Brew Added",
|
||||||
|
(EntityType::Cafe, "added") => "Cafe Added",
|
||||||
|
(EntityType::Cup, "added") => "Cup Added",
|
||||||
_ => "Event",
|
_ => "Event",
|
||||||
};
|
};
|
||||||
|
|
||||||
let link = match entity_type.as_str() {
|
let link = match entity_type {
|
||||||
"brew" => format!("/brews/{entity_id}"),
|
EntityType::Brew => format!("/brews/{entity_id}"),
|
||||||
"cup" => format!("/cups/{entity_id}"),
|
EntityType::Cup => format!("/cups/{entity_id}"),
|
||||||
"bag" => format!("/bags/{entity_id}"),
|
EntityType::Bag => format!("/bags/{entity_id}"),
|
||||||
"gear" => format!("/gear/{entity_id}"),
|
EntityType::Gear => format!("/gear/{entity_id}"),
|
||||||
"roaster" => slug.as_deref().map_or_else(
|
EntityType::Roaster => slug.as_deref().map_or_else(
|
||||||
|| "/data?type=roasters".to_string(),
|
|| "/data?type=roasters".to_string(),
|
||||||
|s| format!("/roasters/{s}"),
|
|s| format!("/roasters/{s}"),
|
||||||
),
|
),
|
||||||
"cafe" => slug
|
EntityType::Cafe => slug
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map_or_else(|| "/data?type=cafes".to_string(), |s| format!("/cafes/{s}")),
|
.map_or_else(|| "/data?type=cafes".to_string(), |s| format!("/cafes/{s}")),
|
||||||
"roast" => match (roaster_slug.as_deref(), slug.as_deref()) {
|
EntityType::Roast => match (roaster_slug.as_deref(), slug.as_deref()) {
|
||||||
(Some(rs), Some(s)) => format!("/roasters/{rs}/roasts/{s}"),
|
(Some(rs), Some(s)) => format!("/roasters/{rs}/roasts/{s}"),
|
||||||
_ => "/data?type=roasts".to_string(),
|
_ => "/data?type=roasts".to_string(),
|
||||||
},
|
},
|
||||||
_ => String::from("#"),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let (mut mapped_details, external_link) = Self::map_details(details);
|
let (mut mapped_details, external_link) = Self::map_details(details);
|
||||||
|
|
||||||
// Build subtitle before adding flags so it stays clean text.
|
// Build subtitle before adding flags so it stays clean text.
|
||||||
let subtitle = Self::build_subtitle(entity_type.as_str(), &mapped_details);
|
let subtitle = Self::build_subtitle(entity_type_str, &mapped_details);
|
||||||
|
|
||||||
Self::add_country_flags(&mut mapped_details);
|
Self::add_country_flags(&mut mapped_details);
|
||||||
|
|
||||||
let tasting_notes = if entity_type == "roast" {
|
let tasting_notes = if entity_type == EntityType::Roast {
|
||||||
let notes = tasting_notes
|
let notes = tasting_notes
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.flat_map(|note| {
|
.flat_map(|note| {
|
||||||
|
|
@ -139,10 +141,12 @@ impl TimelineEventView {
|
||||||
};
|
};
|
||||||
|
|
||||||
let brew_data_view = brew_data.map(|bd| TimelineBrewDataView {
|
let brew_data_view = brew_data.map(|bd| TimelineBrewDataView {
|
||||||
bag_id: bd.bag_id,
|
bag_id: bd.bag_id.into_inner(),
|
||||||
grinder_id: bd.grinder_id,
|
grinder_id: bd.grinder_id.into_inner(),
|
||||||
brewer_id: bd.brewer_id,
|
brewer_id: bd.brewer_id.into_inner(),
|
||||||
filter_paper_id: bd.filter_paper_id,
|
filter_paper_id: bd
|
||||||
|
.filter_paper_id
|
||||||
|
.map(crate::domain::ids::GearId::into_inner),
|
||||||
coffee_weight: bd.coffee_weight,
|
coffee_weight: bd.coffee_weight,
|
||||||
grind_setting: bd.grind_setting,
|
grind_setting: bd.grind_setting,
|
||||||
water_volume: bd.water_volume,
|
water_volume: bd.water_volume,
|
||||||
|
|
@ -152,7 +156,7 @@ impl TimelineEventView {
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
entity_type,
|
entity_type: entity_type_str.to_string(),
|
||||||
kind_label,
|
kind_label,
|
||||||
date_label: occurred_at.format("%b %d, %y").to_string(),
|
date_label: occurred_at.format("%b %d, %y").to_string(),
|
||||||
relative_date_label: relative_date(occurred_at),
|
relative_date_label: relative_date(occurred_at),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue