refactor(timeline): extract event creation into service layer
Introduce application services that encapsulate entity creation + timeline event recording. Route handlers call service.create() instead of repo.insert() + inline timeline construction. - Add define_simple_service! macro for roaster/cafe/gear services - Add custom services for roasts, bags, brews, cups (need enrichment or cross-entity lookups) - Add to_timeline_event() methods to all 7 domain entity types - Remove timeline SQL from 4 repository insert() methods - Delete brew_timeline_event() helper from brews route handler - Update backup test to use services (timeline events created naturally) - Document service layer pattern in CLAUDE.md
This commit is contained in:
parent
97c46d034b
commit
0030f87eed
29 changed files with 799 additions and 599 deletions
46
CLAUDE.md
46
CLAUDE.md
|
|
@ -70,8 +70,9 @@ src/
|
|||
│ ├── foursquare.rs # Foursquare Places API for nearby cafe search
|
||||
│ └── database.rs # Database pool abstraction
|
||||
│
|
||||
├── application/ # HTTP server, routes, middleware
|
||||
├── application/ # HTTP server, routes, middleware, services
|
||||
│ ├── routes/ # Axum route handlers
|
||||
│ ├── services/ # Entity services (create + timeline orchestration)
|
||||
│ └── errors.rs # HTTP error mapping
|
||||
│
|
||||
└── presentation/ # User interfaces
|
||||
|
|
@ -104,6 +105,49 @@ impl BagRecord {
|
|||
}
|
||||
```
|
||||
|
||||
### Service Layer
|
||||
|
||||
Entity services in `application/services/` encapsulate "create entity + record timeline event" as a single operation. Repositories are pure data access with no side effects; services add the timeline side effect on top.
|
||||
|
||||
**When to use services vs repos:**
|
||||
- **Services** — for `create()` (and `finish()` for bags). These record a timeline event after the insert.
|
||||
- **Repos** — for `get()`, `list()`, `update()`, `delete()`. No side effects needed.
|
||||
|
||||
`AppState` holds both repos and services. Route handlers call `state.xxx_service.create()` for creation and `state.xxx_repo.get()` / `.list()` / etc. for reads and updates.
|
||||
|
||||
#### `define_simple_service!` macro
|
||||
|
||||
For entities whose `to_timeline_event()` needs only `&self` (no cross-entity lookups), the macro in `services/mod.rs` generates the struct, constructor, and `create` method:
|
||||
|
||||
```rust
|
||||
define_simple_service!(RoasterService, RoasterRepository, Roaster, NewRoaster, "roaster");
|
||||
```
|
||||
|
||||
This covers: `RoasterService`, `CafeService`, `GearService`.
|
||||
|
||||
#### Custom services
|
||||
|
||||
Entities needing enrichment or related-entity lookups are written by hand:
|
||||
|
||||
| Service | Extra repos | Why |
|
||||
|---------|-------------|-----|
|
||||
| `RoastService` | `roaster_repo` | Needs roaster name/slug for timeline |
|
||||
| `BagService` | `roast_repo`, `roaster_repo` | `create()` + `finish()`, needs roast+roaster for timeline |
|
||||
| `BrewService` | — | `create()` enriches via `get_with_details()` for timeline + response |
|
||||
| `CupService` | — | `create()` enriches via `get_with_details()` for timeline |
|
||||
|
||||
#### Timeline events
|
||||
|
||||
Each entity type has a `to_timeline_event()` method (or standalone function) in its domain file that builds a `NewTimelineEvent`. Services call these methods and insert the result via `timeline_repo` with fire-and-forget error handling:
|
||||
|
||||
```rust
|
||||
if let Err(err) = self.timeline_repo.insert(entity.to_timeline_event()).await {
|
||||
warn!(error = %err, id = %entity.id, "failed to record timeline event");
|
||||
}
|
||||
```
|
||||
|
||||
Timeline events are display-only (cosmetic, not data integrity), so they are not in the same transaction as the entity insert.
|
||||
|
||||
### Typed IDs
|
||||
|
||||
Use the typed ID wrappers from `domain/ids.rs` to prevent mixing up IDs:
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ pub mod auth;
|
|||
pub mod errors;
|
||||
pub mod routes;
|
||||
pub mod server;
|
||||
pub mod services;
|
||||
|
||||
pub use routes::app_router;
|
||||
pub use server::{ServerConfig, serve};
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ use axum::extract::{Path, Query, State};
|
|||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::response::{IntoResponse, Redirect, Response};
|
||||
use serde::Deserialize;
|
||||
use tracing::{info, warn};
|
||||
use tracing::info;
|
||||
|
||||
use super::macros::{define_delete_handler, define_enriched_get_handler};
|
||||
use crate::application::auth::AuthenticatedUser;
|
||||
|
|
@ -15,7 +15,6 @@ use crate::application::server::AppState;
|
|||
use crate::domain::bags::{BagFilter, BagSortKey, BagWithRoast, NewBag, UpdateBag};
|
||||
use crate::domain::ids::{BagId, RoastId};
|
||||
use crate::domain::listing::{ListRequest, SortDirection};
|
||||
use crate::domain::timeline::{NewTimelineEvent, TimelineEventDetail};
|
||||
use crate::presentation::web::templates::BagListTemplate;
|
||||
use crate::presentation::web::views::{BagView, ListNavigator, Paginated};
|
||||
|
||||
|
|
@ -63,51 +62,13 @@ pub(crate) async fn create_bag(
|
|||
let (submission, source) = payload.into_parts();
|
||||
let new_bag = submission.into_new_bag().map_err(ApiError::from)?;
|
||||
|
||||
let roast = state
|
||||
.roast_repo
|
||||
.get(new_bag.roast_id)
|
||||
.await
|
||||
.map_err(|err| ApiError::from(AppError::from(err)))?;
|
||||
|
||||
let roaster = state
|
||||
.roaster_repo
|
||||
.get(roast.roaster_id)
|
||||
.await
|
||||
.map_err(|err| ApiError::from(AppError::from(err)))?;
|
||||
|
||||
let bag = state
|
||||
.bag_repo
|
||||
.insert(new_bag)
|
||||
.bag_service
|
||||
.create(new_bag)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
info!(bag_id = %bag.id, roast = %roast.name, "bag created");
|
||||
|
||||
// Add timeline event
|
||||
let event = NewTimelineEvent {
|
||||
entity_type: "bag".to_string(),
|
||||
entity_id: bag.id.into_inner(),
|
||||
action: "added".to_string(),
|
||||
occurred_at: chrono::Utc::now(),
|
||||
title: roast.name.clone(),
|
||||
details: vec![
|
||||
TimelineEventDetail {
|
||||
label: "Roaster".to_string(),
|
||||
value: roaster.name.clone(),
|
||||
},
|
||||
TimelineEventDetail {
|
||||
label: "Amount".to_string(),
|
||||
value: format!("{}g", bag.amount),
|
||||
},
|
||||
],
|
||||
tasting_notes: vec![],
|
||||
slug: Some(roast.slug.clone()),
|
||||
roaster_slug: Some(roaster.slug.clone()),
|
||||
brew_data: None,
|
||||
};
|
||||
if let Err(err) = state.timeline_repo.insert(event).await {
|
||||
warn!(error = %err, entity_type = "bag", "failed to record timeline event");
|
||||
}
|
||||
info!(bag_id = %bag.id, "bag created");
|
||||
|
||||
if is_datastar_request(&headers) {
|
||||
render_bag_list_fragment(state, request, search, true)
|
||||
|
|
@ -168,58 +129,28 @@ pub(crate) async fn update_bag(
|
|||
|Json(p)| p,
|
||||
);
|
||||
|
||||
let mut update = UpdateBag {
|
||||
let update = UpdateBag {
|
||||
remaining: body_update.remaining.or(update_params.remaining),
|
||||
closed: body_update.closed.or(update_params.closed),
|
||||
finished_at: body_update.finished_at.or(update_params.finished_at),
|
||||
};
|
||||
|
||||
if let Some(true) = update.closed
|
||||
&& update.finished_at.is_none()
|
||||
{
|
||||
update.finished_at = Some(chrono::Utc::now().date_naive());
|
||||
}
|
||||
|
||||
let bag = state
|
||||
.bag_repo
|
||||
.update(id, update.clone())
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
let bag = if let Some(true) = update.closed {
|
||||
state
|
||||
.bag_service
|
||||
.finish(id, update.clone())
|
||||
.await
|
||||
.map_err(AppError::from)?
|
||||
} else {
|
||||
state
|
||||
.bag_repo
|
||||
.update(id, update.clone())
|
||||
.await
|
||||
.map_err(AppError::from)?
|
||||
};
|
||||
|
||||
info!(%id, closed = ?update.closed, "bag updated");
|
||||
|
||||
if let Some(true) = update.closed {
|
||||
// Fetch roast and roaster for timeline event
|
||||
if let Ok(roast) = state.roast_repo.get(bag.roast_id).await
|
||||
&& let Ok(roaster) = state.roaster_repo.get(roast.roaster_id).await
|
||||
{
|
||||
let event = NewTimelineEvent {
|
||||
entity_type: "bag".to_string(),
|
||||
entity_id: bag.id.into_inner(),
|
||||
action: "finished".to_string(),
|
||||
occurred_at: chrono::Utc::now(),
|
||||
title: roast.name.clone(),
|
||||
details: vec![
|
||||
TimelineEventDetail {
|
||||
label: "Roaster".to_string(),
|
||||
value: roaster.name.clone(),
|
||||
},
|
||||
TimelineEventDetail {
|
||||
label: "Amount".to_string(),
|
||||
value: format!("{}g", bag.amount),
|
||||
},
|
||||
],
|
||||
tasting_notes: vec![],
|
||||
slug: Some(roast.slug.clone()),
|
||||
roaster_slug: Some(roaster.slug.clone()),
|
||||
brew_data: None,
|
||||
};
|
||||
if let Err(err) = state.timeline_repo.insert(event).await {
|
||||
warn!(error = %err, entity_type = "bag", "failed to record timeline event");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if is_datastar_request(&headers) {
|
||||
render_bag_list_fragment(state, request, search, true)
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ use axum::extract::{Query, State};
|
|||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::response::{IntoResponse, Redirect, Response};
|
||||
use serde::{Deserialize, Deserializer};
|
||||
use tracing::{info, warn};
|
||||
use tracing::info;
|
||||
|
||||
use super::macros::{define_delete_handler, define_enriched_get_handler};
|
||||
use crate::application::auth::AuthenticatedUser;
|
||||
|
|
@ -17,7 +17,6 @@ use crate::domain::brews::{BrewFilter, BrewSortKey, BrewWithDetails, NewBrew, Qu
|
|||
use crate::domain::gear::{GearCategory, GearFilter, GearSortKey};
|
||||
use crate::domain::ids::{BagId, BrewId, GearId};
|
||||
use crate::domain::listing::{ListRequest, PageSize, SortDirection};
|
||||
use crate::domain::timeline::{NewTimelineEvent, TimelineBrewData, TimelineEventDetail};
|
||||
use crate::presentation::web::templates::BrewListTemplate;
|
||||
use crate::presentation::web::views::{
|
||||
BagOptionView, BrewDefaultsView, BrewView, GearOptionView, ListNavigator, Paginated,
|
||||
|
|
@ -234,29 +233,13 @@ pub(crate) async fn create_brew(
|
|||
let (submission, source) = payload.into_parts();
|
||||
let new_brew = submission.into_new_brew().map_err(ApiError::from)?;
|
||||
|
||||
let brew = state
|
||||
.brew_repo
|
||||
.insert(new_brew)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
info!(brew_id = %brew.id, "brew created");
|
||||
|
||||
// Fetch enriched brew details for timeline event
|
||||
let enriched = state
|
||||
.brew_repo
|
||||
.get_with_details(brew.id)
|
||||
.brew_service
|
||||
.create(new_brew)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
// Add timeline event
|
||||
if let Err(err) = state
|
||||
.timeline_repo
|
||||
.insert(brew_timeline_event(&enriched))
|
||||
.await
|
||||
{
|
||||
warn!(error = %err, entity_type = "brew", "failed to record timeline event");
|
||||
}
|
||||
info!(brew_id = %enriched.brew.id, "brew created");
|
||||
|
||||
if is_datastar_request(&headers) {
|
||||
// Check if request came from timeline - return a script that redirects
|
||||
|
|
@ -290,96 +273,6 @@ pub(crate) async fn create_brew(
|
|||
}
|
||||
}
|
||||
|
||||
fn brew_timeline_event(enriched: &BrewWithDetails) -> NewTimelineEvent {
|
||||
let ratio = if enriched.brew.coffee_weight > 0.0 {
|
||||
format!(
|
||||
"1:{:.1}",
|
||||
f64::from(enriched.brew.water_volume) / enriched.brew.coffee_weight
|
||||
)
|
||||
} else {
|
||||
"N/A".to_string()
|
||||
};
|
||||
|
||||
let mut details = vec![
|
||||
TimelineEventDetail {
|
||||
label: "Roaster".to_string(),
|
||||
value: enriched.roaster_name.clone(),
|
||||
},
|
||||
TimelineEventDetail {
|
||||
label: "Coffee".to_string(),
|
||||
value: format!("{:.1}g", enriched.brew.coffee_weight),
|
||||
},
|
||||
TimelineEventDetail {
|
||||
label: "Water".to_string(),
|
||||
value: format!(
|
||||
"{}ml @ {:.1}\u{00B0}C",
|
||||
enriched.brew.water_volume, enriched.brew.water_temp
|
||||
),
|
||||
},
|
||||
TimelineEventDetail {
|
||||
label: "Grinder".to_string(),
|
||||
value: format!(
|
||||
"{} @ {:.1}",
|
||||
enriched.grinder_name, enriched.brew.grind_setting
|
||||
),
|
||||
},
|
||||
TimelineEventDetail {
|
||||
label: "Brewer".to_string(),
|
||||
value: enriched.brewer_name.clone(),
|
||||
},
|
||||
];
|
||||
|
||||
if let Some(ref fp_name) = enriched.filter_paper_name {
|
||||
details.push(TimelineEventDetail {
|
||||
label: "Filter".to_string(),
|
||||
value: fp_name.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
details.push(TimelineEventDetail {
|
||||
label: "Ratio".to_string(),
|
||||
value: ratio,
|
||||
});
|
||||
|
||||
if !enriched.brew.quick_notes.is_empty() {
|
||||
let labels: Vec<&str> = enriched
|
||||
.brew
|
||||
.quick_notes
|
||||
.iter()
|
||||
.map(|n| n.label())
|
||||
.collect();
|
||||
details.push(TimelineEventDetail {
|
||||
label: "Notes".to_string(),
|
||||
value: labels.join(", "),
|
||||
});
|
||||
}
|
||||
|
||||
NewTimelineEvent {
|
||||
entity_type: "brew".to_string(),
|
||||
entity_id: enriched.brew.id.into_inner(),
|
||||
action: "brewed".to_string(),
|
||||
occurred_at: chrono::Utc::now(),
|
||||
title: enriched.roast_name.clone(),
|
||||
details,
|
||||
tasting_notes: vec![],
|
||||
slug: Some(enriched.roast_slug.clone()),
|
||||
roaster_slug: Some(enriched.roaster_slug.clone()),
|
||||
brew_data: Some(TimelineBrewData {
|
||||
bag_id: enriched.brew.bag_id.into_inner(),
|
||||
grinder_id: enriched.brew.grinder_id.into_inner(),
|
||||
brewer_id: enriched.brew.brewer_id.into_inner(),
|
||||
filter_paper_id: enriched
|
||||
.brew
|
||||
.filter_paper_id
|
||||
.map(crate::domain::ids::GearId::into_inner),
|
||||
coffee_weight: enriched.brew.coffee_weight,
|
||||
grind_setting: enriched.brew.grind_setting,
|
||||
water_volume: enriched.brew.water_volume,
|
||||
water_temp: enriched.brew.water_temp,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct BrewsQuery {
|
||||
pub bag_id: Option<BagId>,
|
||||
|
|
|
|||
|
|
@ -66,8 +66,8 @@ pub(crate) async fn create_cafe(
|
|||
let (new_cafe, source) = payload.into_parts();
|
||||
let new_cafe = new_cafe.normalize();
|
||||
let cafe = state
|
||||
.cafe_repo
|
||||
.insert(new_cafe)
|
||||
.cafe_service
|
||||
.create(new_cafe)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
|
|
|
|||
|
|
@ -56,8 +56,8 @@ pub(crate) async fn create_cup(
|
|||
let (new_cup, source) = payload.into_parts();
|
||||
|
||||
let cup = state
|
||||
.cup_repo
|
||||
.insert(new_cup)
|
||||
.cup_service
|
||||
.create(new_cup)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ use axum::extract::{Path, Query, State};
|
|||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::response::{IntoResponse, Redirect, Response};
|
||||
use serde::Deserialize;
|
||||
use tracing::{info, warn};
|
||||
use tracing::info;
|
||||
|
||||
use super::macros::{define_delete_handler, define_get_handler, define_list_fragment_renderer};
|
||||
use crate::application::auth::AuthenticatedUser;
|
||||
|
|
@ -17,7 +17,6 @@ use crate::application::server::AppState;
|
|||
use crate::domain::gear::{Gear, GearCategory, GearFilter, GearSortKey, NewGear, UpdateGear};
|
||||
use crate::domain::ids::GearId;
|
||||
use crate::domain::listing::{ListRequest, SortDirection};
|
||||
use crate::domain::timeline::{NewTimelineEvent, TimelineEventDetail};
|
||||
use crate::presentation::web::templates::GearListTemplate;
|
||||
use crate::presentation::web::views::{GearView, ListNavigator, Paginated};
|
||||
|
||||
|
|
@ -59,43 +58,13 @@ pub(crate) async fn create_gear(
|
|||
let new_gear = submission.into_new_gear().map_err(ApiError::from)?;
|
||||
|
||||
let gear = state
|
||||
.gear_repo
|
||||
.insert(new_gear)
|
||||
.gear_service
|
||||
.create(new_gear)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
info!(gear_id = %gear.id, make = %gear.make, model = %gear.model, "gear created");
|
||||
|
||||
// Add timeline event
|
||||
let event = NewTimelineEvent {
|
||||
entity_type: "gear".to_string(),
|
||||
entity_id: gear.id.into_inner(),
|
||||
action: "added".to_string(),
|
||||
occurred_at: chrono::Utc::now(),
|
||||
title: format!("{} {}", gear.make, gear.model),
|
||||
details: vec![
|
||||
TimelineEventDetail {
|
||||
label: "Category".to_string(),
|
||||
value: gear.category.display_label().to_string(),
|
||||
},
|
||||
TimelineEventDetail {
|
||||
label: "Make".to_string(),
|
||||
value: gear.make.clone(),
|
||||
},
|
||||
TimelineEventDetail {
|
||||
label: "Model".to_string(),
|
||||
value: gear.model.clone(),
|
||||
},
|
||||
],
|
||||
tasting_notes: vec![],
|
||||
slug: None, // Gear has no slug
|
||||
roaster_slug: None, // Gear is not related to roasters
|
||||
brew_data: None,
|
||||
};
|
||||
if let Err(err) = state.timeline_repo.insert(event).await {
|
||||
warn!(error = %err, entity_type = "gear", "failed to record timeline event");
|
||||
}
|
||||
|
||||
if is_datastar_request(&headers) {
|
||||
render_gear_list_fragment(state, request, search, true)
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -67,8 +67,8 @@ pub(crate) async fn create_roaster(
|
|||
let (new_roaster, source) = payload.into_parts();
|
||||
let new_roaster = new_roaster.normalize();
|
||||
let roaster = state
|
||||
.roaster_repo
|
||||
.insert(new_roaster)
|
||||
.roaster_service
|
||||
.create(new_roaster)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
|
|
|
|||
|
|
@ -65,8 +65,8 @@ pub(crate) async fn create_roast(
|
|||
.map_err(|err| ApiError::from(AppError::from(err)))?;
|
||||
|
||||
let roast = state
|
||||
.roast_repo
|
||||
.insert(new_roast)
|
||||
.roast_service
|
||||
.create(new_roast)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ use axum::extract::State;
|
|||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{info, warn};
|
||||
use tracing::info;
|
||||
|
||||
use super::roasts::TastingNotesInput;
|
||||
use crate::application::auth::AuthenticatedUser;
|
||||
|
|
@ -14,7 +14,6 @@ use crate::domain::bags::NewBag;
|
|||
use crate::domain::errors::RepositoryError;
|
||||
use crate::domain::roasters::NewRoaster;
|
||||
use crate::domain::roasts::NewRoast;
|
||||
use crate::domain::timeline::{NewTimelineEvent, TimelineEventDetail};
|
||||
use crate::infrastructure::ai::{self, ExtractionInput, Usage};
|
||||
|
||||
#[tracing::instrument(skip(state, auth_user, headers, payload))]
|
||||
|
|
@ -233,8 +232,8 @@ pub(crate) async fn submit_scan(
|
|||
let roaster = match state.roaster_repo.get_by_slug(&slug).await {
|
||||
Ok(existing) => existing,
|
||||
Err(RepositoryError::NotFound) => state
|
||||
.roaster_repo
|
||||
.insert(new_roaster)
|
||||
.roaster_service
|
||||
.create(new_roaster)
|
||||
.await
|
||||
.map_err(AppError::from)?,
|
||||
Err(err) => return Err(AppError::from(err).into()),
|
||||
|
|
@ -283,8 +282,8 @@ pub(crate) async fn submit_scan(
|
|||
};
|
||||
|
||||
let roast = state
|
||||
.roast_repo
|
||||
.insert(new_roast)
|
||||
.roast_service
|
||||
.create(new_roast)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
|
|
@ -302,36 +301,11 @@ pub(crate) async fn submit_scan(
|
|||
roast_date: None,
|
||||
amount,
|
||||
};
|
||||
let bag = state
|
||||
.bag_repo
|
||||
.insert(new_bag)
|
||||
state
|
||||
.bag_service
|
||||
.create(new_bag)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
let event = NewTimelineEvent {
|
||||
entity_type: "bag".to_string(),
|
||||
entity_id: bag.id.into_inner(),
|
||||
action: "added".to_string(),
|
||||
occurred_at: chrono::Utc::now(),
|
||||
title: roast.name.clone(),
|
||||
details: vec![
|
||||
TimelineEventDetail {
|
||||
label: "Roaster".to_string(),
|
||||
value: roaster.name.clone(),
|
||||
},
|
||||
TimelineEventDetail {
|
||||
label: "Amount".to_string(),
|
||||
value: format!("{}g", bag.amount),
|
||||
},
|
||||
],
|
||||
tasting_notes: vec![],
|
||||
slug: Some(roast.slug.clone()),
|
||||
roaster_slug: Some(roaster.slug.clone()),
|
||||
brew_data: None,
|
||||
};
|
||||
if let Err(err) = state.timeline_repo.insert(event).await {
|
||||
warn!(error = %err, entity_type = "bag", "failed to record timeline event");
|
||||
}
|
||||
}
|
||||
|
||||
let redirect = format!("/roasters/{}/roasts/{}", roaster.slug, roast.slug);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ use tracing::info;
|
|||
use webauthn_rs::prelude::*;
|
||||
|
||||
use crate::application::routes::app_router;
|
||||
use crate::application::services::{
|
||||
BagService, BrewService, CafeService, CupService, GearService, RoastService, RoasterService,
|
||||
};
|
||||
use crate::domain::registration_tokens::NewRegistrationToken;
|
||||
use crate::domain::repositories::{
|
||||
AiUsageRepository, BagRepository, BrewRepository, CafeRepository, CupRepository,
|
||||
|
|
@ -70,8 +73,16 @@ pub struct AppState {
|
|||
pub openrouter_api_key: String,
|
||||
pub openrouter_model: String,
|
||||
pub backup_service: Arc<BackupService>,
|
||||
pub roaster_service: RoasterService,
|
||||
pub roast_service: RoastService,
|
||||
pub bag_service: BagService,
|
||||
pub brew_service: BrewService,
|
||||
pub gear_service: GearService,
|
||||
pub cafe_service: CafeService,
|
||||
pub cup_service: CupService,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub async fn serve(config: ServerConfig) -> anyhow::Result<()> {
|
||||
let database = Database::connect(&config.database_url)
|
||||
.await
|
||||
|
|
@ -86,14 +97,20 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> {
|
|||
.context("failed to build WebAuthn instance")?,
|
||||
);
|
||||
|
||||
let roaster_repo = Arc::new(SqlRoasterRepository::new(database.clone_pool()));
|
||||
let roast_repo = Arc::new(SqlRoastRepository::new(database.clone_pool()));
|
||||
let bag_repo = Arc::new(SqlBagRepository::new(database.clone_pool()));
|
||||
let gear_repo = Arc::new(SqlGearRepository::new(database.clone_pool()));
|
||||
let brew_repo = Arc::new(SqlBrewRepository::new(database.clone_pool()));
|
||||
let cafe_repo = Arc::new(SqlCafeRepository::new(database.clone_pool()));
|
||||
let cup_repo = Arc::new(SqlCupRepository::new(database.clone_pool()));
|
||||
let timeline_repo = Arc::new(SqlTimelineEventRepository::new(database.clone_pool()));
|
||||
let roaster_repo: Arc<dyn RoasterRepository> =
|
||||
Arc::new(SqlRoasterRepository::new(database.clone_pool()));
|
||||
let roast_repo: Arc<dyn RoastRepository> =
|
||||
Arc::new(SqlRoastRepository::new(database.clone_pool()));
|
||||
let bag_repo: Arc<dyn BagRepository> = Arc::new(SqlBagRepository::new(database.clone_pool()));
|
||||
let gear_repo: Arc<dyn GearRepository> =
|
||||
Arc::new(SqlGearRepository::new(database.clone_pool()));
|
||||
let brew_repo: Arc<dyn BrewRepository> =
|
||||
Arc::new(SqlBrewRepository::new(database.clone_pool()));
|
||||
let cafe_repo: Arc<dyn CafeRepository> =
|
||||
Arc::new(SqlCafeRepository::new(database.clone_pool()));
|
||||
let cup_repo: Arc<dyn CupRepository> = Arc::new(SqlCupRepository::new(database.clone_pool()));
|
||||
let timeline_repo: Arc<dyn TimelineEventRepository> =
|
||||
Arc::new(SqlTimelineEventRepository::new(database.clone_pool()));
|
||||
let user_repo: Arc<dyn UserRepository> =
|
||||
Arc::new(SqlUserRepository::new(database.clone_pool()));
|
||||
let token_repo: Arc<dyn TokenRepository> =
|
||||
|
|
@ -110,6 +127,24 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> {
|
|||
let backup_service = Arc::new(BackupService::new(database.clone_pool()));
|
||||
let challenge_store = Arc::new(ChallengeStore::new());
|
||||
|
||||
let roaster_service =
|
||||
RoasterService::new(Arc::clone(&roaster_repo), Arc::clone(&timeline_repo));
|
||||
let roast_service = RoastService::new(
|
||||
Arc::clone(&roast_repo),
|
||||
Arc::clone(&roaster_repo),
|
||||
Arc::clone(&timeline_repo),
|
||||
);
|
||||
let bag_service = BagService::new(
|
||||
Arc::clone(&bag_repo),
|
||||
Arc::clone(&roast_repo),
|
||||
Arc::clone(&roaster_repo),
|
||||
Arc::clone(&timeline_repo),
|
||||
);
|
||||
let brew_service = BrewService::new(Arc::clone(&brew_repo), Arc::clone(&timeline_repo));
|
||||
let gear_service = GearService::new(Arc::clone(&gear_repo), Arc::clone(&timeline_repo));
|
||||
let cafe_service = CafeService::new(Arc::clone(&cafe_repo), Arc::clone(&timeline_repo));
|
||||
let cup_service = CupService::new(Arc::clone(&cup_repo), Arc::clone(&timeline_repo));
|
||||
|
||||
// Bootstrap: if no users exist, generate a one-time registration token
|
||||
bootstrap_registration(®istration_token_repo, &user_repo, &config.rp_origin).await?;
|
||||
|
||||
|
|
@ -137,6 +172,13 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> {
|
|||
openrouter_api_key: config.openrouter_api_key,
|
||||
openrouter_model: config.openrouter_model,
|
||||
backup_service,
|
||||
roaster_service,
|
||||
roast_service,
|
||||
bag_service,
|
||||
brew_service,
|
||||
gear_service,
|
||||
cafe_service,
|
||||
cup_service,
|
||||
};
|
||||
|
||||
let listener = TcpListener::bind(config.bind_address)
|
||||
|
|
|
|||
76
src/application/services/bags.rs
Normal file
76
src/application/services/bags.rs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use tracing::warn;
|
||||
|
||||
use crate::domain::bags::{Bag, NewBag, UpdateBag, bag_timeline_event};
|
||||
use crate::domain::errors::RepositoryError;
|
||||
use crate::domain::ids::BagId;
|
||||
use crate::domain::repositories::{
|
||||
BagRepository, RoastRepository, RoasterRepository, TimelineEventRepository,
|
||||
};
|
||||
|
||||
#[allow(clippy::struct_field_names)]
|
||||
#[derive(Clone)]
|
||||
pub struct BagService {
|
||||
bag_repo: Arc<dyn BagRepository>,
|
||||
roast_repo: Arc<dyn RoastRepository>,
|
||||
roaster_repo: Arc<dyn RoasterRepository>,
|
||||
timeline_repo: Arc<dyn TimelineEventRepository>,
|
||||
}
|
||||
|
||||
impl BagService {
|
||||
pub fn new(
|
||||
bag_repo: Arc<dyn BagRepository>,
|
||||
roast_repo: Arc<dyn RoastRepository>,
|
||||
roaster_repo: Arc<dyn RoasterRepository>,
|
||||
timeline_repo: Arc<dyn TimelineEventRepository>,
|
||||
) -> Self {
|
||||
Self {
|
||||
bag_repo,
|
||||
roast_repo,
|
||||
roaster_repo,
|
||||
timeline_repo,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create(&self, new: NewBag) -> Result<Bag, RepositoryError> {
|
||||
let bag = self.bag_repo.insert(new).await?;
|
||||
self.record_timeline_event(&bag, "added").await;
|
||||
Ok(bag)
|
||||
}
|
||||
|
||||
/// Close a bag: sets `finished_at` (if not provided), updates via the
|
||||
/// repository, and records a "finished" timeline event.
|
||||
pub async fn finish(&self, id: BagId, mut update: UpdateBag) -> Result<Bag, RepositoryError> {
|
||||
if update.finished_at.is_none() {
|
||||
update.finished_at = Some(chrono::Utc::now().date_naive());
|
||||
}
|
||||
let bag = self.bag_repo.update(id, update).await?;
|
||||
self.record_timeline_event(&bag, "finished").await;
|
||||
Ok(bag)
|
||||
}
|
||||
|
||||
async fn record_timeline_event(&self, bag: &Bag, action: &str) {
|
||||
let roast = match self.roast_repo.get(bag.roast_id).await {
|
||||
Ok(r) => r,
|
||||
Err(err) => {
|
||||
warn!(error = %err, bag_id = %bag.id, "failed to fetch roast for bag timeline event");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let roaster = match self.roaster_repo.get(roast.roaster_id).await {
|
||||
Ok(r) => r,
|
||||
Err(err) => {
|
||||
warn!(error = %err, bag_id = %bag.id, "failed to fetch roaster for bag timeline event");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Err(err) = self
|
||||
.timeline_repo
|
||||
.insert(bag_timeline_event(bag, action, &roast, &roaster))
|
||||
.await
|
||||
{
|
||||
warn!(error = %err, bag_id = %bag.id, "failed to record bag timeline event");
|
||||
}
|
||||
}
|
||||
}
|
||||
40
src/application/services/brews.rs
Normal file
40
src/application/services/brews.rs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use tracing::warn;
|
||||
|
||||
use crate::domain::brews::{BrewWithDetails, NewBrew};
|
||||
use crate::domain::errors::RepositoryError;
|
||||
use crate::domain::repositories::{BrewRepository, TimelineEventRepository};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct BrewService {
|
||||
brew_repo: Arc<dyn BrewRepository>,
|
||||
timeline_repo: Arc<dyn TimelineEventRepository>,
|
||||
}
|
||||
|
||||
impl BrewService {
|
||||
pub fn new(
|
||||
brew_repo: Arc<dyn BrewRepository>,
|
||||
timeline_repo: Arc<dyn TimelineEventRepository>,
|
||||
) -> Self {
|
||||
Self {
|
||||
brew_repo,
|
||||
timeline_repo,
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a brew, enrich it with related entity names, record a timeline
|
||||
/// event, and return the enriched result.
|
||||
pub async fn create(&self, new: NewBrew) -> Result<BrewWithDetails, RepositoryError> {
|
||||
let brew = self.brew_repo.insert(new).await?;
|
||||
let enriched = self.brew_repo.get_with_details(brew.id).await?;
|
||||
if let Err(err) = self
|
||||
.timeline_repo
|
||||
.insert(enriched.to_timeline_event())
|
||||
.await
|
||||
{
|
||||
warn!(error = %err, brew_id = %brew.id, "failed to record brew timeline event");
|
||||
}
|
||||
Ok(enriched)
|
||||
}
|
||||
}
|
||||
44
src/application/services/cups.rs
Normal file
44
src/application/services/cups.rs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use tracing::warn;
|
||||
|
||||
use crate::domain::cups::{Cup, NewCup};
|
||||
use crate::domain::errors::RepositoryError;
|
||||
use crate::domain::repositories::{CupRepository, TimelineEventRepository};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CupService {
|
||||
cup_repo: Arc<dyn CupRepository>,
|
||||
timeline_repo: Arc<dyn TimelineEventRepository>,
|
||||
}
|
||||
|
||||
impl CupService {
|
||||
pub fn new(
|
||||
cup_repo: Arc<dyn CupRepository>,
|
||||
timeline_repo: Arc<dyn TimelineEventRepository>,
|
||||
) -> Self {
|
||||
Self {
|
||||
cup_repo,
|
||||
timeline_repo,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create(&self, new: NewCup) -> Result<Cup, RepositoryError> {
|
||||
let cup = self.cup_repo.insert(new).await?;
|
||||
match self.cup_repo.get_with_details(cup.id).await {
|
||||
Ok(enriched) => {
|
||||
if let Err(err) = self
|
||||
.timeline_repo
|
||||
.insert(enriched.to_timeline_event())
|
||||
.await
|
||||
{
|
||||
warn!(error = %err, cup_id = %cup.id, "failed to record cup timeline event");
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(error = %err, cup_id = %cup.id, "failed to enrich cup for timeline event");
|
||||
}
|
||||
}
|
||||
Ok(cup)
|
||||
}
|
||||
}
|
||||
83
src/application/services/mod.rs
Normal file
83
src/application/services/mod.rs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
mod bags;
|
||||
mod brews;
|
||||
mod cups;
|
||||
mod roasts;
|
||||
|
||||
pub use bags::BagService;
|
||||
pub use brews::BrewService;
|
||||
pub use cups::CupService;
|
||||
pub use roasts::RoastService;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use tracing::warn;
|
||||
|
||||
use crate::domain::errors::RepositoryError;
|
||||
use crate::domain::repositories::TimelineEventRepository;
|
||||
|
||||
/// Generates a service struct with a `create` method that inserts via the
|
||||
/// repository and then records a timeline event (fire-and-forget).
|
||||
///
|
||||
/// Use this for entities whose `to_timeline_event()` method needs only `&self`
|
||||
/// (no related-entity lookups). For entities that need enrichment or
|
||||
/// cross-repo lookups, write the service by hand.
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// define_simple_service!(RoasterService, RoasterRepository, Roaster, NewRoaster, "roaster");
|
||||
/// ```
|
||||
macro_rules! define_simple_service {
|
||||
($service:ident, $repo_trait:path, $entity:ty, $new_entity:ty, $entity_name:literal) => {
|
||||
#[derive(Clone)]
|
||||
pub struct $service {
|
||||
repo: Arc<dyn $repo_trait>,
|
||||
timeline_repo: Arc<dyn TimelineEventRepository>,
|
||||
}
|
||||
|
||||
impl $service {
|
||||
pub fn new(
|
||||
repo: Arc<dyn $repo_trait>,
|
||||
timeline_repo: Arc<dyn TimelineEventRepository>,
|
||||
) -> Self {
|
||||
Self {
|
||||
repo,
|
||||
timeline_repo,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
&self,
|
||||
new: $new_entity,
|
||||
) -> Result<$entity, RepositoryError> {
|
||||
let entity = self.repo.insert(new).await?;
|
||||
if let Err(err) = self
|
||||
.timeline_repo
|
||||
.insert(entity.to_timeline_event())
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
error = %err,
|
||||
id = %entity.id,
|
||||
concat!("failed to record ", $entity_name, " timeline event"),
|
||||
);
|
||||
}
|
||||
Ok(entity)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
use crate::domain::cafes::{Cafe, NewCafe};
|
||||
use crate::domain::gear::{Gear, NewGear};
|
||||
use crate::domain::repositories::{CafeRepository, GearRepository, RoasterRepository};
|
||||
use crate::domain::roasters::{NewRoaster, Roaster};
|
||||
|
||||
define_simple_service!(
|
||||
RoasterService,
|
||||
RoasterRepository,
|
||||
Roaster,
|
||||
NewRoaster,
|
||||
"roaster"
|
||||
);
|
||||
define_simple_service!(CafeService, CafeRepository, Cafe, NewCafe, "cafe");
|
||||
define_simple_service!(GearService, GearRepository, Gear, NewGear, "gear");
|
||||
48
src/application/services/roasts.rs
Normal file
48
src/application/services/roasts.rs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use tracing::warn;
|
||||
|
||||
use crate::domain::errors::RepositoryError;
|
||||
use crate::domain::repositories::{RoastRepository, RoasterRepository, TimelineEventRepository};
|
||||
use crate::domain::roasts::{NewRoast, Roast, roast_timeline_event};
|
||||
|
||||
#[allow(clippy::struct_field_names)]
|
||||
#[derive(Clone)]
|
||||
pub struct RoastService {
|
||||
roast_repo: Arc<dyn RoastRepository>,
|
||||
roaster_repo: Arc<dyn RoasterRepository>,
|
||||
timeline_repo: Arc<dyn TimelineEventRepository>,
|
||||
}
|
||||
|
||||
impl RoastService {
|
||||
pub fn new(
|
||||
roast_repo: Arc<dyn RoastRepository>,
|
||||
roaster_repo: Arc<dyn RoasterRepository>,
|
||||
timeline_repo: Arc<dyn TimelineEventRepository>,
|
||||
) -> Self {
|
||||
Self {
|
||||
roast_repo,
|
||||
roaster_repo,
|
||||
timeline_repo,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create(&self, new: NewRoast) -> Result<Roast, RepositoryError> {
|
||||
let roast = self.roast_repo.insert(new).await?;
|
||||
match self.roaster_repo.get(roast.roaster_id).await {
|
||||
Ok(roaster) => {
|
||||
if let Err(err) = self
|
||||
.timeline_repo
|
||||
.insert(roast_timeline_event(&roast, &roaster))
|
||||
.await
|
||||
{
|
||||
warn!(error = %err, roast_id = %roast.id, "failed to record roast timeline event");
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(error = %err, roast_id = %roast.id, "failed to fetch roaster for roast timeline event");
|
||||
}
|
||||
}
|
||||
Ok(roast)
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,9 @@ use serde::{Deserialize, Serialize};
|
|||
|
||||
use super::ids::{BagId, RoastId};
|
||||
use super::listing::{SortDirection, SortKey};
|
||||
use crate::domain::roasters::Roaster;
|
||||
use crate::domain::roasts::Roast;
|
||||
use crate::domain::timeline::{NewTimelineEvent, TimelineEventDetail};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Bag {
|
||||
|
|
@ -124,3 +127,32 @@ impl SortKey for BagSortKey {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bag_timeline_event(
|
||||
bag: &Bag,
|
||||
action: &str,
|
||||
roast: &Roast,
|
||||
roaster: &Roaster,
|
||||
) -> NewTimelineEvent {
|
||||
NewTimelineEvent {
|
||||
entity_type: "bag".to_string(),
|
||||
entity_id: bag.id.into_inner(),
|
||||
action: action.to_string(),
|
||||
occurred_at: Utc::now(),
|
||||
title: roast.name.clone(),
|
||||
details: vec![
|
||||
TimelineEventDetail {
|
||||
label: "Roaster".to_string(),
|
||||
value: roaster.name.clone(),
|
||||
},
|
||||
TimelineEventDetail {
|
||||
label: "Amount".to_string(),
|
||||
value: format!("{}g", bag.amount),
|
||||
},
|
||||
],
|
||||
tasting_notes: vec![],
|
||||
slug: Some(roast.slug.clone()),
|
||||
roaster_slug: Some(roaster.slug.clone()),
|
||||
brew_data: None,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize};
|
|||
|
||||
use super::ids::{BagId, BrewId, GearId};
|
||||
use super::listing::{SortDirection, SortKey};
|
||||
use crate::domain::timeline::{NewTimelineEvent, TimelineBrewData, TimelineEventDetail};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum QuickNote {
|
||||
|
|
@ -94,6 +95,87 @@ pub struct BrewWithDetails {
|
|||
pub filter_paper_name: Option<String>,
|
||||
}
|
||||
|
||||
impl BrewWithDetails {
|
||||
pub fn to_timeline_event(&self) -> NewTimelineEvent {
|
||||
let ratio = if self.brew.coffee_weight > 0.0 {
|
||||
format!(
|
||||
"1:{:.1}",
|
||||
f64::from(self.brew.water_volume) / self.brew.coffee_weight
|
||||
)
|
||||
} else {
|
||||
"N/A".to_string()
|
||||
};
|
||||
|
||||
let mut details = vec![
|
||||
TimelineEventDetail {
|
||||
label: "Roaster".to_string(),
|
||||
value: self.roaster_name.clone(),
|
||||
},
|
||||
TimelineEventDetail {
|
||||
label: "Coffee".to_string(),
|
||||
value: format!("{:.1}g", self.brew.coffee_weight),
|
||||
},
|
||||
TimelineEventDetail {
|
||||
label: "Water".to_string(),
|
||||
value: format!(
|
||||
"{}ml @ {:.1}\u{00B0}C",
|
||||
self.brew.water_volume, self.brew.water_temp
|
||||
),
|
||||
},
|
||||
TimelineEventDetail {
|
||||
label: "Grinder".to_string(),
|
||||
value: format!("{} @ {:.1}", self.grinder_name, self.brew.grind_setting),
|
||||
},
|
||||
TimelineEventDetail {
|
||||
label: "Brewer".to_string(),
|
||||
value: self.brewer_name.clone(),
|
||||
},
|
||||
];
|
||||
|
||||
if let Some(ref fp_name) = self.filter_paper_name {
|
||||
details.push(TimelineEventDetail {
|
||||
label: "Filter".to_string(),
|
||||
value: fp_name.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
details.push(TimelineEventDetail {
|
||||
label: "Ratio".to_string(),
|
||||
value: ratio,
|
||||
});
|
||||
|
||||
if !self.brew.quick_notes.is_empty() {
|
||||
let labels: Vec<&str> = self.brew.quick_notes.iter().map(|n| n.label()).collect();
|
||||
details.push(TimelineEventDetail {
|
||||
label: "Notes".to_string(),
|
||||
value: labels.join(", "),
|
||||
});
|
||||
}
|
||||
|
||||
NewTimelineEvent {
|
||||
entity_type: "brew".to_string(),
|
||||
entity_id: self.brew.id.into_inner(),
|
||||
action: "brewed".to_string(),
|
||||
occurred_at: Utc::now(),
|
||||
title: self.roast_name.clone(),
|
||||
details,
|
||||
tasting_notes: vec![],
|
||||
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),
|
||||
coffee_weight: self.brew.coffee_weight,
|
||||
grind_setting: self.brew.grind_setting,
|
||||
water_volume: self.brew.water_volume,
|
||||
water_temp: self.brew.water_temp,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NewBrew {
|
||||
pub bag_id: BagId,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize};
|
|||
|
||||
use crate::domain::ids::CafeId;
|
||||
use crate::domain::listing::{SortDirection, SortKey};
|
||||
use crate::domain::timeline::{NewTimelineEvent, TimelineEventDetail};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Cafe {
|
||||
|
|
@ -18,6 +19,32 @@ pub struct Cafe {
|
|||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl Cafe {
|
||||
pub fn to_timeline_event(&self) -> NewTimelineEvent {
|
||||
NewTimelineEvent {
|
||||
entity_type: "cafe".to_string(),
|
||||
entity_id: self.id.into_inner(),
|
||||
action: "added".to_string(),
|
||||
occurred_at: Utc::now(),
|
||||
title: self.name.clone(),
|
||||
details: vec![
|
||||
TimelineEventDetail {
|
||||
label: "City".to_string(),
|
||||
value: self.city.clone(),
|
||||
},
|
||||
TimelineEventDetail {
|
||||
label: "Country".to_string(),
|
||||
value: self.country.clone(),
|
||||
},
|
||||
],
|
||||
tasting_notes: vec![],
|
||||
slug: Some(self.slug.clone()),
|
||||
roaster_slug: None,
|
||||
brew_data: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NewCafe {
|
||||
pub name: String,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize};
|
|||
|
||||
use super::ids::{CafeId, CupId, RoastId};
|
||||
use super::listing::{SortDirection, SortKey};
|
||||
use crate::domain::timeline::{NewTimelineEvent, TimelineEventDetail};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Cup {
|
||||
|
|
@ -25,6 +26,36 @@ pub struct CupWithDetails {
|
|||
pub cafe_slug: String,
|
||||
}
|
||||
|
||||
impl CupWithDetails {
|
||||
pub fn to_timeline_event(&self) -> NewTimelineEvent {
|
||||
NewTimelineEvent {
|
||||
entity_type: "cup".to_string(),
|
||||
entity_id: self.cup.id.into_inner(),
|
||||
action: "added".to_string(),
|
||||
occurred_at: Utc::now(),
|
||||
title: self.roast_name.clone(),
|
||||
details: vec![
|
||||
TimelineEventDetail {
|
||||
label: "Coffee".to_string(),
|
||||
value: self.roast_name.clone(),
|
||||
},
|
||||
TimelineEventDetail {
|
||||
label: "Roaster".to_string(),
|
||||
value: self.roaster_name.clone(),
|
||||
},
|
||||
TimelineEventDetail {
|
||||
label: "Cafe".to_string(),
|
||||
value: self.cafe_name.clone(),
|
||||
},
|
||||
],
|
||||
tasting_notes: vec![],
|
||||
slug: Some(self.roast_slug.clone()),
|
||||
roaster_slug: Some(self.roaster_slug.clone()),
|
||||
brew_data: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NewCup {
|
||||
pub roast_id: RoastId,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize};
|
|||
|
||||
use super::ids::GearId;
|
||||
use super::listing::{SortDirection, SortKey};
|
||||
use crate::domain::timeline::{NewTimelineEvent, TimelineEventDetail};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
|
|
@ -56,6 +57,36 @@ pub struct Gear {
|
|||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl Gear {
|
||||
pub fn to_timeline_event(&self) -> NewTimelineEvent {
|
||||
NewTimelineEvent {
|
||||
entity_type: "gear".to_string(),
|
||||
entity_id: self.id.into_inner(),
|
||||
action: "added".to_string(),
|
||||
occurred_at: Utc::now(),
|
||||
title: format!("{} {}", self.make, self.model),
|
||||
details: vec![
|
||||
TimelineEventDetail {
|
||||
label: "Category".to_string(),
|
||||
value: self.category.display_label().to_string(),
|
||||
},
|
||||
TimelineEventDetail {
|
||||
label: "Make".to_string(),
|
||||
value: self.make.clone(),
|
||||
},
|
||||
TimelineEventDetail {
|
||||
label: "Model".to_string(),
|
||||
value: self.model.clone(),
|
||||
},
|
||||
],
|
||||
tasting_notes: vec![],
|
||||
slug: None,
|
||||
roaster_slug: None,
|
||||
brew_data: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NewGear {
|
||||
pub category: GearCategory,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize};
|
|||
|
||||
use crate::domain::ids::RoasterId;
|
||||
use crate::domain::listing::{SortDirection, SortKey};
|
||||
use crate::domain::timeline::{NewTimelineEvent, TimelineEventDetail};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Roaster {
|
||||
|
|
@ -52,6 +53,33 @@ fn normalize_optional_field(value: Option<String>) -> Option<String> {
|
|||
})
|
||||
}
|
||||
|
||||
impl Roaster {
|
||||
pub fn to_timeline_event(&self) -> NewTimelineEvent {
|
||||
let mut details = vec![TimelineEventDetail {
|
||||
label: "Country".to_string(),
|
||||
value: self.country.clone(),
|
||||
}];
|
||||
if let Some(ref city) = self.city {
|
||||
details.push(TimelineEventDetail {
|
||||
label: "City".to_string(),
|
||||
value: city.clone(),
|
||||
});
|
||||
}
|
||||
NewTimelineEvent {
|
||||
entity_type: "roaster".to_string(),
|
||||
entity_id: self.id.into_inner(),
|
||||
action: "added".to_string(),
|
||||
occurred_at: Utc::now(),
|
||||
title: self.name.clone(),
|
||||
details,
|
||||
tasting_notes: vec![],
|
||||
slug: Some(self.slug.clone()),
|
||||
roaster_slug: Some(self.slug.clone()),
|
||||
brew_data: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct UpdateRoaster {
|
||||
pub name: Option<String>,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ use serde::{Deserialize, Serialize};
|
|||
|
||||
use crate::domain::ids::{RoastId, RoasterId};
|
||||
use crate::domain::listing::{SortDirection, SortKey};
|
||||
use crate::domain::roasters::Roaster;
|
||||
use crate::domain::timeline::{NewTimelineEvent, TimelineEventDetail};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Roast {
|
||||
|
|
@ -96,3 +98,34 @@ impl SortKey for RoastSortKey {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn roast_timeline_event(roast: &Roast, roaster: &Roaster) -> NewTimelineEvent {
|
||||
let mut details = vec![TimelineEventDetail {
|
||||
label: "Roaster".to_string(),
|
||||
value: roaster.name.clone(),
|
||||
}];
|
||||
if let Some(ref origin) = roast.origin {
|
||||
details.push(TimelineEventDetail {
|
||||
label: "Origin".to_string(),
|
||||
value: origin.clone(),
|
||||
});
|
||||
}
|
||||
if !roast.tasting_notes.is_empty() {
|
||||
details.push(TimelineEventDetail {
|
||||
label: "Tasting Notes".to_string(),
|
||||
value: roast.tasting_notes.join(", "),
|
||||
});
|
||||
}
|
||||
NewTimelineEvent {
|
||||
entity_type: "roast".to_string(),
|
||||
entity_id: roast.id.into_inner(),
|
||||
action: "added".to_string(),
|
||||
occurred_at: Utc::now(),
|
||||
title: roast.name.clone(),
|
||||
details,
|
||||
tasting_notes: roast.tasting_notes.clone(),
|
||||
slug: Some(roast.slug.clone()),
|
||||
roaster_slug: Some(roaster.slug.clone()),
|
||||
brew_data: None,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ use crate::domain::cafes::{Cafe, CafeSortKey, NewCafe, UpdateCafe};
|
|||
use crate::domain::ids::CafeId;
|
||||
use crate::domain::listing::{ListRequest, Page, SortDirection};
|
||||
use crate::domain::repositories::CafeRepository;
|
||||
use crate::domain::timeline::TimelineEventDetail;
|
||||
use crate::infrastructure::database::DatabasePool;
|
||||
|
||||
#[derive(Clone)]
|
||||
|
|
@ -62,52 +61,11 @@ impl SqlCafeRepository {
|
|||
updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn details_for_cafe(cafe: &Cafe) -> Result<String, RepositoryError> {
|
||||
let website_value = cafe
|
||||
.website
|
||||
.as_ref()
|
||||
.filter(|value| !value.is_empty())
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "—".to_string());
|
||||
|
||||
let details = vec![
|
||||
TimelineEventDetail {
|
||||
label: "City".to_string(),
|
||||
value: cafe.city.clone(),
|
||||
},
|
||||
TimelineEventDetail {
|
||||
label: "Country".to_string(),
|
||||
value: cafe.country.clone(),
|
||||
},
|
||||
TimelineEventDetail {
|
||||
label: "Website".to_string(),
|
||||
value: website_value,
|
||||
},
|
||||
TimelineEventDetail {
|
||||
label: "Position".to_string(),
|
||||
value: format!(
|
||||
"https://www.google.com/maps?q={},{}",
|
||||
cafe.latitude, cafe.longitude
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
serde_json::to_string(&details).map_err(|err| {
|
||||
RepositoryError::unexpected(format!("failed to encode timeline event details: {err}"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CafeRepository for SqlCafeRepository {
|
||||
async fn insert(&self, new_cafe: NewCafe) -> Result<Cafe, RepositoryError> {
|
||||
let mut tx = self
|
||||
.pool
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||
|
||||
let new_cafe = new_cafe.normalize();
|
||||
let slug = new_cafe.slug();
|
||||
let now = Utc::now();
|
||||
|
|
@ -125,7 +83,7 @@ impl CafeRepository for SqlCafeRepository {
|
|||
.bind(new_cafe.website.as_deref())
|
||||
.bind(now)
|
||||
.bind(now)
|
||||
.fetch_one(&mut *tx)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
if let sqlx::Error::Database(db_err) = &err
|
||||
|
|
@ -138,31 +96,7 @@ impl CafeRepository for SqlCafeRepository {
|
|||
RepositoryError::unexpected(err.to_string())
|
||||
})?;
|
||||
|
||||
let cafe = Self::into_domain(record);
|
||||
let details_json = Self::details_for_cafe(&cafe)?;
|
||||
|
||||
query(
|
||||
"INSERT INTO timeline_events (entity_type, entity_id, action, occurred_at, title, details_json, tasting_notes_json, slug, roaster_slug, brew_data_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind("cafe")
|
||||
.bind(i64::from(cafe.id))
|
||||
.bind("added")
|
||||
.bind(cafe.created_at)
|
||||
.bind(&cafe.name)
|
||||
.bind(details_json)
|
||||
.bind::<Option<&str>>(None)
|
||||
.bind(&cafe.slug)
|
||||
.bind::<Option<&str>>(None)
|
||||
.bind::<Option<&str>>(None)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||
|
||||
Ok(cafe)
|
||||
Ok(Self::into_domain(record))
|
||||
}
|
||||
|
||||
async fn get(&self, id: CafeId) -> Result<Cafe, RepositoryError> {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ use crate::domain::cups::{Cup, CupFilter, CupSortKey, CupWithDetails, NewCup};
|
|||
use crate::domain::ids::{CafeId, CupId, RoastId};
|
||||
use crate::domain::listing::{ListRequest, Page, SortDirection};
|
||||
use crate::domain::repositories::CupRepository;
|
||||
use crate::domain::timeline::TimelineEventDetail;
|
||||
use crate::infrastructure::database::DatabasePool;
|
||||
|
||||
const BASE_SELECT: &str = r"
|
||||
|
|
@ -95,85 +94,22 @@ impl SqlCupRepository {
|
|||
Some(conditions.join(" AND "))
|
||||
}
|
||||
}
|
||||
|
||||
fn details_for_cup(cup_with_details: &CupWithDetails) -> Result<String, RepositoryError> {
|
||||
let details = vec![
|
||||
TimelineEventDetail {
|
||||
label: "Coffee".to_string(),
|
||||
value: cup_with_details.roast_name.clone(),
|
||||
},
|
||||
TimelineEventDetail {
|
||||
label: "Roaster".to_string(),
|
||||
value: cup_with_details.roaster_name.clone(),
|
||||
},
|
||||
TimelineEventDetail {
|
||||
label: "Cafe".to_string(),
|
||||
value: cup_with_details.cafe_name.clone(),
|
||||
},
|
||||
];
|
||||
|
||||
serde_json::to_string(&details).map_err(|err| {
|
||||
RepositoryError::unexpected(format!("failed to encode timeline event details: {err}"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CupRepository for SqlCupRepository {
|
||||
async fn insert(&self, new_cup: NewCup) -> Result<Cup, RepositoryError> {
|
||||
let mut tx = self
|
||||
.pool
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||
|
||||
let record = query_as::<_, CupRecord>(
|
||||
"INSERT INTO cups (roast_id, cafe_id) VALUES (?, ?) \
|
||||
RETURNING id, roast_id, cafe_id, created_at, updated_at",
|
||||
)
|
||||
.bind(new_cup.roast_id.into_inner())
|
||||
.bind(new_cup.cafe_id.into_inner())
|
||||
.fetch_one(&mut *tx)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||
|
||||
let cup = Self::to_domain(record);
|
||||
|
||||
// Fetch enriched details for the timeline event
|
||||
let details_record =
|
||||
query_as::<_, CupWithDetailsRecord>(&format!("{BASE_SELECT} WHERE c.id = ?"))
|
||||
.bind(cup.id.into_inner())
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||
|
||||
let cup_with_details = Self::to_domain_with_details(details_record);
|
||||
let details_json = Self::details_for_cup(&cup_with_details)?;
|
||||
|
||||
let title = cup_with_details.roast_name.clone();
|
||||
|
||||
query(
|
||||
"INSERT INTO timeline_events (entity_type, entity_id, action, occurred_at, title, details_json, tasting_notes_json, slug, roaster_slug, brew_data_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind("cup")
|
||||
.bind(i64::from(cup.id))
|
||||
.bind("added")
|
||||
.bind(cup.created_at)
|
||||
.bind(&title)
|
||||
.bind(details_json)
|
||||
.bind::<Option<&str>>(None)
|
||||
.bind(&cup_with_details.roast_slug)
|
||||
.bind(&cup_with_details.roaster_slug)
|
||||
.bind::<Option<&str>>(None)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||
|
||||
Ok(cup)
|
||||
Ok(Self::to_domain(record))
|
||||
}
|
||||
|
||||
async fn get(&self, id: CupId) -> Result<Cup, RepositoryError> {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ use crate::domain::ids::RoasterId;
|
|||
use crate::domain::listing::{ListRequest, Page, SortDirection};
|
||||
use crate::domain::repositories::RoasterRepository;
|
||||
use crate::domain::roasters::{NewRoaster, Roaster, RoasterSortKey, UpdateRoaster};
|
||||
use crate::domain::timeline::TimelineEventDetail;
|
||||
use crate::infrastructure::database::DatabasePool;
|
||||
|
||||
#[derive(Clone)]
|
||||
|
|
@ -56,50 +55,11 @@ impl SqlRoasterRepository {
|
|||
created_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn details_for_roaster(roaster: &Roaster) -> Result<String, RepositoryError> {
|
||||
let homepage_value = roaster
|
||||
.homepage
|
||||
.as_ref()
|
||||
.filter(|value| !value.is_empty())
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "—".to_string());
|
||||
|
||||
let details = vec![
|
||||
TimelineEventDetail {
|
||||
label: "Country".to_string(),
|
||||
value: roaster.country.clone(),
|
||||
},
|
||||
TimelineEventDetail {
|
||||
label: "City".to_string(),
|
||||
value: roaster
|
||||
.city
|
||||
.as_ref()
|
||||
.filter(|value| !value.is_empty())
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "—".to_string()),
|
||||
},
|
||||
TimelineEventDetail {
|
||||
label: "Homepage".to_string(),
|
||||
value: homepage_value,
|
||||
},
|
||||
];
|
||||
|
||||
serde_json::to_string(&details).map_err(|err| {
|
||||
RepositoryError::unexpected(format!("failed to encode timeline event details: {err}"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RoasterRepository for SqlRoasterRepository {
|
||||
async fn insert(&self, new_roaster: NewRoaster) -> Result<Roaster, RepositoryError> {
|
||||
let mut tx = self
|
||||
.pool
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||
|
||||
let new_roaster = new_roaster.normalize();
|
||||
let slug = new_roaster.slug();
|
||||
let created_at = Utc::now();
|
||||
|
|
@ -114,7 +74,7 @@ impl RoasterRepository for SqlRoasterRepository {
|
|||
.bind(new_roaster.city.as_deref())
|
||||
.bind(new_roaster.homepage.as_deref())
|
||||
.bind(created_at)
|
||||
.fetch_one(&mut *tx)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
if let sqlx::Error::Database(db_err) = &err
|
||||
|
|
@ -127,31 +87,7 @@ impl RoasterRepository for SqlRoasterRepository {
|
|||
RepositoryError::unexpected(err.to_string())
|
||||
})?;
|
||||
|
||||
let roaster = Self::into_domain(record);
|
||||
let details_json = Self::details_for_roaster(&roaster)?;
|
||||
|
||||
query(
|
||||
"INSERT INTO timeline_events (entity_type, entity_id, action, occurred_at, title, details_json, tasting_notes_json, slug, roaster_slug, brew_data_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind("roaster")
|
||||
.bind(i64::from(roaster.id))
|
||||
.bind("added")
|
||||
.bind(roaster.created_at)
|
||||
.bind(&roaster.name)
|
||||
.bind(details_json)
|
||||
.bind::<Option<&str>>(None)
|
||||
.bind(&roaster.slug) // slug = roaster's own slug
|
||||
.bind::<Option<&str>>(None) // roaster_slug not applicable for roaster events
|
||||
.bind::<Option<&str>>(None) // brew_data_json not applicable
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||
|
||||
Ok(roaster)
|
||||
Ok(Self::into_domain(record))
|
||||
}
|
||||
|
||||
async fn get(&self, id: RoasterId) -> Result<Roaster, RepositoryError> {
|
||||
|
|
|
|||
|
|
@ -9,8 +9,7 @@ use crate::domain::ids::{RoastId, RoasterId};
|
|||
use crate::domain::listing::{ListRequest, Page, SortDirection};
|
||||
use crate::domain::repositories::RoastRepository;
|
||||
use crate::domain::roasts::{NewRoast, Roast, RoastSortKey, RoastWithRoaster, UpdateRoast};
|
||||
use crate::domain::timeline::TimelineEventDetail;
|
||||
use crate::infrastructure::database::{DatabasePool, DatabaseTransaction};
|
||||
use crate::infrastructure::database::DatabasePool;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SqlRoastRepository {
|
||||
|
|
@ -50,79 +49,6 @@ impl SqlRoastRepository {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn insert_timeline_event(
|
||||
tx: &mut DatabaseTransaction<'_>,
|
||||
roast: &Roast,
|
||||
) -> Result<(), RepositoryError> {
|
||||
let roaster_info: Option<(String, String)> =
|
||||
query_as("SELECT name, slug FROM roasters WHERE id = ?")
|
||||
.bind(i64::from(roast.roaster_id))
|
||||
.fetch_optional(&mut **tx)
|
||||
.await
|
||||
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||
|
||||
let (roaster_label, roaster_slug) = roaster_info.map_or_else(
|
||||
|| ("Unknown roaster".to_string(), None),
|
||||
|(name, slug)| (name, Some(slug)),
|
||||
);
|
||||
|
||||
let details = vec![
|
||||
TimelineEventDetail {
|
||||
label: "Roaster".to_string(),
|
||||
value: roaster_label,
|
||||
},
|
||||
TimelineEventDetail {
|
||||
label: "Origin".to_string(),
|
||||
value: roast.origin.clone().unwrap_or_else(|| "—".to_string()),
|
||||
},
|
||||
TimelineEventDetail {
|
||||
label: "Region".to_string(),
|
||||
value: roast.region.clone().unwrap_or_else(|| "—".to_string()),
|
||||
},
|
||||
TimelineEventDetail {
|
||||
label: "Producer".to_string(),
|
||||
value: roast.producer.clone().unwrap_or_else(|| "—".to_string()),
|
||||
},
|
||||
TimelineEventDetail {
|
||||
label: "Process".to_string(),
|
||||
value: roast.process.clone().unwrap_or_else(|| "—".to_string()),
|
||||
},
|
||||
];
|
||||
|
||||
let details_json = to_string(&details).map_err(|err| {
|
||||
RepositoryError::unexpected(format!("failed to encode timeline event details: {err}"))
|
||||
})?;
|
||||
|
||||
let tasting_notes_json = if roast.tasting_notes.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(to_string(&roast.tasting_notes).map_err(|err| {
|
||||
RepositoryError::unexpected(format!(
|
||||
"failed to encode timeline event tasting notes: {err}"
|
||||
))
|
||||
})?)
|
||||
};
|
||||
|
||||
query(
|
||||
"INSERT INTO timeline_events (entity_type, entity_id, action, occurred_at, title, details_json, tasting_notes_json, slug, roaster_slug, brew_data_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind("roast")
|
||||
.bind(i64::from(roast.id))
|
||||
.bind("added")
|
||||
.bind(roast.created_at)
|
||||
.bind(&roast.name)
|
||||
.bind(details_json)
|
||||
.bind(tasting_notes_json.as_deref())
|
||||
.bind(&roast.slug)
|
||||
.bind(roaster_slug.as_deref())
|
||||
.bind::<Option<&str>>(None)
|
||||
.execute(&mut **tx)
|
||||
.await
|
||||
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_to_none(s: String) -> Option<String> {
|
||||
|
|
@ -132,12 +58,6 @@ fn empty_to_none(s: String) -> Option<String> {
|
|||
#[async_trait]
|
||||
impl RoastRepository for SqlRoastRepository {
|
||||
async fn insert(&self, new_roast: NewRoast) -> Result<Roast, RepositoryError> {
|
||||
let mut tx = self
|
||||
.pool
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||
|
||||
let slug = new_roast.slug();
|
||||
let NewRoast {
|
||||
roaster_id,
|
||||
|
|
@ -170,7 +90,7 @@ impl RoastRepository for SqlRoastRepository {
|
|||
.bind(process_value.as_deref())
|
||||
.bind(notes_json.as_deref())
|
||||
.bind(created_at)
|
||||
.fetch_one(&mut *tx)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
if let sqlx::Error::Database(db_err) = &err
|
||||
|
|
@ -183,15 +103,7 @@ impl RoastRepository for SqlRoastRepository {
|
|||
map_insert_error(err, "unknown roaster reference")
|
||||
})?;
|
||||
|
||||
let roast = record.into_roast()?;
|
||||
|
||||
Self::insert_timeline_event(&mut tx, &roast).await?;
|
||||
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||
|
||||
Ok(roast)
|
||||
record.into_roast()
|
||||
}
|
||||
|
||||
async fn get(&self, id: RoastId) -> Result<Roast, RepositoryError> {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use brewlog::application::services::{CafeService, GearService, RoastService, RoasterService};
|
||||
use brewlog::domain::bags::{Bag, BagFilter, BagSortKey, NewBag};
|
||||
use brewlog::domain::brews::{Brew, BrewFilter, BrewSortKey, NewBrew};
|
||||
use brewlog::domain::cafes::{Cafe, CafeSortKey, NewCafe};
|
||||
|
|
@ -33,6 +34,10 @@ struct TestDb {
|
|||
cafe_repo: Arc<dyn CafeRepository>,
|
||||
timeline_repo: Arc<dyn TimelineEventRepository>,
|
||||
backup_service: BackupService,
|
||||
roaster_service: RoasterService,
|
||||
roast_service: RoastService,
|
||||
gear_service: GearService,
|
||||
cafe_service: CafeService,
|
||||
}
|
||||
|
||||
async fn create_test_db() -> TestDb {
|
||||
|
|
@ -42,15 +47,39 @@ async fn create_test_db() -> TestDb {
|
|||
|
||||
let pool = database.clone_pool();
|
||||
|
||||
let roaster_repo: Arc<dyn RoasterRepository> =
|
||||
Arc::new(SqlRoasterRepository::new(pool.clone()));
|
||||
let roast_repo: Arc<dyn RoastRepository> = Arc::new(SqlRoastRepository::new(pool.clone()));
|
||||
let bag_repo: Arc<dyn BagRepository> = Arc::new(SqlBagRepository::new(pool.clone()));
|
||||
let gear_repo: Arc<dyn GearRepository> = Arc::new(SqlGearRepository::new(pool.clone()));
|
||||
let brew_repo: Arc<dyn BrewRepository> = Arc::new(SqlBrewRepository::new(pool.clone()));
|
||||
let cafe_repo: Arc<dyn CafeRepository> = Arc::new(SqlCafeRepository::new(pool.clone()));
|
||||
let timeline_repo: Arc<dyn TimelineEventRepository> =
|
||||
Arc::new(SqlTimelineEventRepository::new(pool.clone()));
|
||||
|
||||
let roaster_service =
|
||||
RoasterService::new(Arc::clone(&roaster_repo), Arc::clone(&timeline_repo));
|
||||
let roast_service = RoastService::new(
|
||||
Arc::clone(&roast_repo),
|
||||
Arc::clone(&roaster_repo),
|
||||
Arc::clone(&timeline_repo),
|
||||
);
|
||||
let gear_service = GearService::new(Arc::clone(&gear_repo), Arc::clone(&timeline_repo));
|
||||
let cafe_service = CafeService::new(Arc::clone(&cafe_repo), Arc::clone(&timeline_repo));
|
||||
|
||||
TestDb {
|
||||
roaster_repo: Arc::new(SqlRoasterRepository::new(pool.clone())),
|
||||
roast_repo: Arc::new(SqlRoastRepository::new(pool.clone())),
|
||||
bag_repo: Arc::new(SqlBagRepository::new(pool.clone())),
|
||||
gear_repo: Arc::new(SqlGearRepository::new(pool.clone())),
|
||||
brew_repo: Arc::new(SqlBrewRepository::new(pool.clone())),
|
||||
cafe_repo: Arc::new(SqlCafeRepository::new(pool.clone())),
|
||||
timeline_repo: Arc::new(SqlTimelineEventRepository::new(pool.clone())),
|
||||
roaster_repo,
|
||||
roast_repo,
|
||||
bag_repo,
|
||||
gear_repo,
|
||||
brew_repo,
|
||||
cafe_repo,
|
||||
timeline_repo,
|
||||
backup_service: BackupService::new(pool),
|
||||
roaster_service,
|
||||
roast_service,
|
||||
gear_service,
|
||||
cafe_service,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -116,10 +145,10 @@ async fn list_all_timeline_events(repo: &dyn TimelineEventRepository) -> Vec<Tim
|
|||
|
||||
/// Populate a database with representative test data and return the key entities.
|
||||
async fn populate_test_data(db: &TestDb) -> (Roaster, Roast, Bag, Gear, Gear, Gear, Brew, Cafe) {
|
||||
// Create roaster
|
||||
// Create roaster (via service to generate timeline event)
|
||||
let roaster = db
|
||||
.roaster_repo
|
||||
.insert(NewRoaster {
|
||||
.roaster_service
|
||||
.create(NewRoaster {
|
||||
name: "Square Mile".to_string(),
|
||||
country: "UK".to_string(),
|
||||
city: Some("London".to_string()),
|
||||
|
|
@ -128,10 +157,10 @@ async fn populate_test_data(db: &TestDb) -> (Roaster, Roast, Bag, Gear, Gear, Ge
|
|||
.await
|
||||
.expect("failed to create roaster");
|
||||
|
||||
// Create roast
|
||||
// Create roast (via service to generate timeline event)
|
||||
let roast = db
|
||||
.roast_repo
|
||||
.insert(NewRoast {
|
||||
.roast_service
|
||||
.create(NewRoast {
|
||||
roaster_id: roaster.id,
|
||||
name: "Red Brick".to_string(),
|
||||
origin: "Brazil".to_string(),
|
||||
|
|
@ -158,10 +187,10 @@ async fn populate_test_data(db: &TestDb) -> (Roaster, Roast, Bag, Gear, Gear, Ge
|
|||
.await
|
||||
.expect("failed to create bag");
|
||||
|
||||
// Create gear
|
||||
// Create gear (via service to generate timeline events)
|
||||
let grinder = db
|
||||
.gear_repo
|
||||
.insert(NewGear {
|
||||
.gear_service
|
||||
.create(NewGear {
|
||||
category: GearCategory::Grinder,
|
||||
make: "Comandante".to_string(),
|
||||
model: "C40 MK4".to_string(),
|
||||
|
|
@ -170,8 +199,8 @@ async fn populate_test_data(db: &TestDb) -> (Roaster, Roast, Bag, Gear, Gear, Ge
|
|||
.expect("failed to create grinder");
|
||||
|
||||
let brewer = db
|
||||
.gear_repo
|
||||
.insert(NewGear {
|
||||
.gear_service
|
||||
.create(NewGear {
|
||||
category: GearCategory::Brewer,
|
||||
make: "Hario".to_string(),
|
||||
model: "V60 02".to_string(),
|
||||
|
|
@ -180,8 +209,8 @@ async fn populate_test_data(db: &TestDb) -> (Roaster, Roast, Bag, Gear, Gear, Ge
|
|||
.expect("failed to create brewer");
|
||||
|
||||
let filter_paper = db
|
||||
.gear_repo
|
||||
.insert(NewGear {
|
||||
.gear_service
|
||||
.create(NewGear {
|
||||
category: GearCategory::FilterPaper,
|
||||
make: "Hario".to_string(),
|
||||
model: "V60 Tabbed 02".to_string(),
|
||||
|
|
@ -218,10 +247,10 @@ async fn populate_test_data(db: &TestDb) -> (Roaster, Roast, Bag, Gear, Gear, Ge
|
|||
"bag remaining should be 235 after brew"
|
||||
);
|
||||
|
||||
// Create cafe
|
||||
// Create cafe (via service to generate timeline event)
|
||||
let cafe = db
|
||||
.cafe_repo
|
||||
.insert(NewCafe {
|
||||
.cafe_service
|
||||
.create(NewCafe {
|
||||
name: "Prufrock".to_string(),
|
||||
city: "London".to_string(),
|
||||
country: "UK".to_string(),
|
||||
|
|
|
|||
|
|
@ -2,10 +2,14 @@ use std::sync::Arc;
|
|||
|
||||
use brewlog::application::routes::app_router;
|
||||
use brewlog::application::server::AppState;
|
||||
use brewlog::application::services::{
|
||||
BagService, BrewService, CafeService, CupService, GearService, RoastService, RoasterService,
|
||||
};
|
||||
use brewlog::domain::cafes::{Cafe, NewCafe};
|
||||
use brewlog::domain::repositories::{
|
||||
CafeRepository, PasskeyCredentialRepository, RegistrationTokenRepository, RoastRepository,
|
||||
RoasterRepository, SessionRepository, TimelineEventRepository, TokenRepository, UserRepository,
|
||||
BagRepository, BrewRepository, CafeRepository, CupRepository, GearRepository,
|
||||
PasskeyCredentialRepository, RegistrationTokenRepository, RoastRepository, RoasterRepository,
|
||||
SessionRepository, TimelineEventRepository, TokenRepository, UserRepository,
|
||||
};
|
||||
use brewlog::domain::roasters::{NewRoaster, Roaster};
|
||||
use brewlog::domain::users::NewUser;
|
||||
|
|
@ -158,6 +162,39 @@ async fn spawn_app_inner(
|
|||
|
||||
let session_repo_clone: Arc<dyn SessionRepository> = session_repo.clone();
|
||||
|
||||
// Create services
|
||||
let roaster_service = RoasterService::new(
|
||||
roaster_repo.clone() as Arc<dyn RoasterRepository>,
|
||||
timeline_repo.clone() as Arc<dyn TimelineEventRepository>,
|
||||
);
|
||||
let roast_service = RoastService::new(
|
||||
roast_repo.clone() as Arc<dyn RoastRepository>,
|
||||
roaster_repo.clone() as Arc<dyn RoasterRepository>,
|
||||
timeline_repo.clone() as Arc<dyn TimelineEventRepository>,
|
||||
);
|
||||
let bag_service = BagService::new(
|
||||
bag_repo.clone() as Arc<dyn BagRepository>,
|
||||
roast_repo.clone() as Arc<dyn RoastRepository>,
|
||||
roaster_repo.clone() as Arc<dyn RoasterRepository>,
|
||||
timeline_repo.clone() as Arc<dyn TimelineEventRepository>,
|
||||
);
|
||||
let brew_service = BrewService::new(
|
||||
brew_repo.clone() as Arc<dyn BrewRepository>,
|
||||
timeline_repo.clone() as Arc<dyn TimelineEventRepository>,
|
||||
);
|
||||
let gear_service = GearService::new(
|
||||
gear_repo.clone() as Arc<dyn GearRepository>,
|
||||
timeline_repo.clone() as Arc<dyn TimelineEventRepository>,
|
||||
);
|
||||
let cafe_service = CafeService::new(
|
||||
cafe_repo.clone() as Arc<dyn CafeRepository>,
|
||||
timeline_repo.clone() as Arc<dyn TimelineEventRepository>,
|
||||
);
|
||||
let cup_service = CupService::new(
|
||||
cup_repo.clone() as Arc<dyn CupRepository>,
|
||||
timeline_repo.clone() as Arc<dyn TimelineEventRepository>,
|
||||
);
|
||||
|
||||
// Create application state
|
||||
let state = AppState {
|
||||
roaster_repo: roaster_repo.clone(),
|
||||
|
|
@ -187,6 +224,13 @@ async fn spawn_app_inner(
|
|||
openrouter_api_key: String::new(),
|
||||
openrouter_model: "openrouter/free".to_string(),
|
||||
backup_service,
|
||||
roaster_service,
|
||||
roast_service,
|
||||
bag_service,
|
||||
brew_service,
|
||||
gear_service,
|
||||
cafe_service,
|
||||
cup_service,
|
||||
};
|
||||
|
||||
// Create router
|
||||
|
|
|
|||
Loading…
Reference in a new issue