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::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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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))]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
},
|
||||
)?;
|
||||
|
|
|
|||
|
|
@ -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: "",
|
||||
|
|
|
|||
|
|
@ -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: "",
|
||||
|
|
|
|||
|
|
@ -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: "",
|
||||
|
|
|
|||
|
|
@ -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: "",
|
||||
|
|
|
|||
|
|
@ -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: "",
|
||||
|
|
|
|||
|
|
@ -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: "",
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
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 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>,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Reference in a new issue