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:
Jon Seager 2026-02-06 10:42:52 +00:00
parent 97c46d034b
commit 0030f87eed
No known key found for this signature in database
29 changed files with 799 additions and 599 deletions

View file

@ -70,8 +70,9 @@ src/
│ ├── foursquare.rs # Foursquare Places API for nearby cafe search │ ├── foursquare.rs # Foursquare Places API for nearby cafe search
│ └── database.rs # Database pool abstraction │ └── database.rs # Database pool abstraction
├── application/ # HTTP server, routes, middleware ├── application/ # HTTP server, routes, middleware, services
│ ├── routes/ # Axum route handlers │ ├── routes/ # Axum route handlers
│ ├── services/ # Entity services (create + timeline orchestration)
│ └── errors.rs # HTTP error mapping │ └── errors.rs # HTTP error mapping
└── presentation/ # User interfaces └── 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 ### Typed IDs
Use the typed ID wrappers from `domain/ids.rs` to prevent mixing up IDs: Use the typed ID wrappers from `domain/ids.rs` to prevent mixing up IDs:

View file

@ -2,6 +2,7 @@ pub mod auth;
pub mod errors; pub mod errors;
pub mod routes; pub mod routes;
pub mod server; pub mod server;
pub mod services;
pub use routes::app_router; pub use routes::app_router;
pub use server::{ServerConfig, serve}; pub use server::{ServerConfig, serve};

View file

@ -3,7 +3,7 @@ use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, StatusCode}; use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Redirect, Response}; use axum::response::{IntoResponse, Redirect, Response};
use serde::Deserialize; use serde::Deserialize;
use tracing::{info, warn}; use tracing::info;
use super::macros::{define_delete_handler, define_enriched_get_handler}; use super::macros::{define_delete_handler, define_enriched_get_handler};
use crate::application::auth::AuthenticatedUser; 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::bags::{BagFilter, BagSortKey, BagWithRoast, NewBag, UpdateBag};
use crate::domain::ids::{BagId, RoastId}; use crate::domain::ids::{BagId, RoastId};
use crate::domain::listing::{ListRequest, SortDirection}; use crate::domain::listing::{ListRequest, SortDirection};
use crate::domain::timeline::{NewTimelineEvent, TimelineEventDetail};
use crate::presentation::web::templates::BagListTemplate; use crate::presentation::web::templates::BagListTemplate;
use crate::presentation::web::views::{BagView, ListNavigator, Paginated}; 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 (submission, source) = payload.into_parts();
let new_bag = submission.into_new_bag().map_err(ApiError::from)?; 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 let bag = state
.bag_repo .bag_service
.insert(new_bag) .create(new_bag)
.await .await
.map_err(AppError::from)?; .map_err(AppError::from)?;
info!(bag_id = %bag.id, roast = %roast.name, "bag created"); info!(bag_id = %bag.id, "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");
}
if is_datastar_request(&headers) { if is_datastar_request(&headers) {
render_bag_list_fragment(state, request, search, true) render_bag_list_fragment(state, request, search, true)
@ -168,58 +129,28 @@ pub(crate) async fn update_bag(
|Json(p)| p, |Json(p)| p,
); );
let mut update = UpdateBag { let update = UpdateBag {
remaining: body_update.remaining.or(update_params.remaining), remaining: body_update.remaining.or(update_params.remaining),
closed: body_update.closed.or(update_params.closed), closed: body_update.closed.or(update_params.closed),
finished_at: body_update.finished_at.or(update_params.finished_at), finished_at: body_update.finished_at.or(update_params.finished_at),
}; };
if let Some(true) = update.closed let bag = if let Some(true) = update.closed {
&& update.finished_at.is_none() state
{ .bag_service
update.finished_at = Some(chrono::Utc::now().date_naive()); .finish(id, update.clone())
} .await
.map_err(AppError::from)?
let bag = state } else {
state
.bag_repo .bag_repo
.update(id, update.clone()) .update(id, update.clone())
.await .await
.map_err(AppError::from)?; .map_err(AppError::from)?
};
info!(%id, closed = ?update.closed, "bag updated"); 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) { if is_datastar_request(&headers) {
render_bag_list_fragment(state, request, search, true) render_bag_list_fragment(state, request, search, true)
.await .await

View file

@ -3,7 +3,7 @@ use axum::extract::{Query, State};
use axum::http::{HeaderMap, StatusCode}; use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Redirect, Response}; use axum::response::{IntoResponse, Redirect, Response};
use serde::{Deserialize, Deserializer}; use serde::{Deserialize, Deserializer};
use tracing::{info, warn}; use tracing::info;
use super::macros::{define_delete_handler, define_enriched_get_handler}; use super::macros::{define_delete_handler, define_enriched_get_handler};
use crate::application::auth::AuthenticatedUser; 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::gear::{GearCategory, GearFilter, GearSortKey};
use crate::domain::ids::{BagId, BrewId, GearId}; use crate::domain::ids::{BagId, BrewId, GearId};
use crate::domain::listing::{ListRequest, PageSize, SortDirection}; use crate::domain::listing::{ListRequest, PageSize, SortDirection};
use crate::domain::timeline::{NewTimelineEvent, TimelineBrewData, TimelineEventDetail};
use crate::presentation::web::templates::BrewListTemplate; use crate::presentation::web::templates::BrewListTemplate;
use crate::presentation::web::views::{ use crate::presentation::web::views::{
BagOptionView, BrewDefaultsView, BrewView, GearOptionView, ListNavigator, Paginated, BagOptionView, BrewDefaultsView, BrewView, GearOptionView, ListNavigator, Paginated,
@ -234,29 +233,13 @@ pub(crate) async fn create_brew(
let (submission, source) = payload.into_parts(); let (submission, source) = payload.into_parts();
let new_brew = submission.into_new_brew().map_err(ApiError::from)?; 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 let enriched = state
.brew_repo .brew_service
.get_with_details(brew.id) .create(new_brew)
.await .await
.map_err(AppError::from)?; .map_err(AppError::from)?;
// Add timeline event info!(brew_id = %enriched.brew.id, "brew created");
if let Err(err) = state
.timeline_repo
.insert(brew_timeline_event(&enriched))
.await
{
warn!(error = %err, entity_type = "brew", "failed to record timeline event");
}
if is_datastar_request(&headers) { if is_datastar_request(&headers) {
// Check if request came from timeline - return a script that redirects // 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)] #[derive(Debug, Deserialize)]
pub struct BrewsQuery { pub struct BrewsQuery {
pub bag_id: Option<BagId>, pub bag_id: Option<BagId>,

View file

@ -66,8 +66,8 @@ pub(crate) async fn create_cafe(
let (new_cafe, source) = payload.into_parts(); let (new_cafe, source) = payload.into_parts();
let new_cafe = new_cafe.normalize(); let new_cafe = new_cafe.normalize();
let cafe = state let cafe = state
.cafe_repo .cafe_service
.insert(new_cafe) .create(new_cafe)
.await .await
.map_err(AppError::from)?; .map_err(AppError::from)?;

View file

@ -56,8 +56,8 @@ pub(crate) async fn create_cup(
let (new_cup, source) = payload.into_parts(); let (new_cup, source) = payload.into_parts();
let cup = state let cup = state
.cup_repo .cup_service
.insert(new_cup) .create(new_cup)
.await .await
.map_err(AppError::from)?; .map_err(AppError::from)?;

View file

@ -5,7 +5,7 @@ use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, StatusCode}; use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Redirect, Response}; use axum::response::{IntoResponse, Redirect, Response};
use serde::Deserialize; use serde::Deserialize;
use tracing::{info, warn}; use tracing::info;
use super::macros::{define_delete_handler, define_get_handler, define_list_fragment_renderer}; use super::macros::{define_delete_handler, define_get_handler, define_list_fragment_renderer};
use crate::application::auth::AuthenticatedUser; 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::gear::{Gear, GearCategory, GearFilter, GearSortKey, NewGear, UpdateGear};
use crate::domain::ids::GearId; use crate::domain::ids::GearId;
use crate::domain::listing::{ListRequest, SortDirection}; use crate::domain::listing::{ListRequest, SortDirection};
use crate::domain::timeline::{NewTimelineEvent, TimelineEventDetail};
use crate::presentation::web::templates::GearListTemplate; use crate::presentation::web::templates::GearListTemplate;
use crate::presentation::web::views::{GearView, ListNavigator, Paginated}; 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 new_gear = submission.into_new_gear().map_err(ApiError::from)?;
let gear = state let gear = state
.gear_repo .gear_service
.insert(new_gear) .create(new_gear)
.await .await
.map_err(AppError::from)?; .map_err(AppError::from)?;
info!(gear_id = %gear.id, make = %gear.make, model = %gear.model, "gear created"); 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) { if is_datastar_request(&headers) {
render_gear_list_fragment(state, request, search, true) render_gear_list_fragment(state, request, search, true)
.await .await

View file

@ -67,8 +67,8 @@ pub(crate) async fn create_roaster(
let (new_roaster, source) = payload.into_parts(); let (new_roaster, source) = payload.into_parts();
let new_roaster = new_roaster.normalize(); let new_roaster = new_roaster.normalize();
let roaster = state let roaster = state
.roaster_repo .roaster_service
.insert(new_roaster) .create(new_roaster)
.await .await
.map_err(AppError::from)?; .map_err(AppError::from)?;

View file

@ -65,8 +65,8 @@ pub(crate) async fn create_roast(
.map_err(|err| ApiError::from(AppError::from(err)))?; .map_err(|err| ApiError::from(AppError::from(err)))?;
let roast = state let roast = state
.roast_repo .roast_service
.insert(new_roast) .create(new_roast)
.await .await
.map_err(AppError::from)?; .map_err(AppError::from)?;

View file

@ -3,7 +3,7 @@ use axum::extract::State;
use axum::http::{HeaderMap, StatusCode}; use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tracing::{info, warn}; use tracing::info;
use super::roasts::TastingNotesInput; use super::roasts::TastingNotesInput;
use crate::application::auth::AuthenticatedUser; use crate::application::auth::AuthenticatedUser;
@ -14,7 +14,6 @@ use crate::domain::bags::NewBag;
use crate::domain::errors::RepositoryError; use crate::domain::errors::RepositoryError;
use crate::domain::roasters::NewRoaster; use crate::domain::roasters::NewRoaster;
use crate::domain::roasts::NewRoast; use crate::domain::roasts::NewRoast;
use crate::domain::timeline::{NewTimelineEvent, TimelineEventDetail};
use crate::infrastructure::ai::{self, ExtractionInput, Usage}; use crate::infrastructure::ai::{self, ExtractionInput, Usage};
#[tracing::instrument(skip(state, auth_user, headers, payload))] #[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 { let roaster = match state.roaster_repo.get_by_slug(&slug).await {
Ok(existing) => existing, Ok(existing) => existing,
Err(RepositoryError::NotFound) => state Err(RepositoryError::NotFound) => state
.roaster_repo .roaster_service
.insert(new_roaster) .create(new_roaster)
.await .await
.map_err(AppError::from)?, .map_err(AppError::from)?,
Err(err) => return Err(AppError::from(err).into()), Err(err) => return Err(AppError::from(err).into()),
@ -283,8 +282,8 @@ pub(crate) async fn submit_scan(
}; };
let roast = state let roast = state
.roast_repo .roast_service
.insert(new_roast) .create(new_roast)
.await .await
.map_err(AppError::from)?; .map_err(AppError::from)?;
@ -302,36 +301,11 @@ pub(crate) async fn submit_scan(
roast_date: None, roast_date: None,
amount, amount,
}; };
let bag = state state
.bag_repo .bag_service
.insert(new_bag) .create(new_bag)
.await .await
.map_err(AppError::from)?; .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); let redirect = format!("/roasters/{}/roasts/{}", roaster.slug, roast.slug);

View file

@ -10,6 +10,9 @@ use tracing::info;
use webauthn_rs::prelude::*; use webauthn_rs::prelude::*;
use crate::application::routes::app_router; 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::registration_tokens::NewRegistrationToken;
use crate::domain::repositories::{ use crate::domain::repositories::{
AiUsageRepository, BagRepository, BrewRepository, CafeRepository, CupRepository, AiUsageRepository, BagRepository, BrewRepository, CafeRepository, CupRepository,
@ -70,8 +73,16 @@ pub struct AppState {
pub openrouter_api_key: String, pub openrouter_api_key: String,
pub openrouter_model: String, pub openrouter_model: String,
pub backup_service: Arc<BackupService>, 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<()> { pub async fn serve(config: ServerConfig) -> anyhow::Result<()> {
let database = Database::connect(&config.database_url) let database = Database::connect(&config.database_url)
.await .await
@ -86,14 +97,20 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> {
.context("failed to build WebAuthn instance")?, .context("failed to build WebAuthn instance")?,
); );
let roaster_repo = Arc::new(SqlRoasterRepository::new(database.clone_pool())); let roaster_repo: Arc<dyn RoasterRepository> =
let roast_repo = Arc::new(SqlRoastRepository::new(database.clone_pool())); Arc::new(SqlRoasterRepository::new(database.clone_pool()));
let bag_repo = Arc::new(SqlBagRepository::new(database.clone_pool())); let roast_repo: Arc<dyn RoastRepository> =
let gear_repo = Arc::new(SqlGearRepository::new(database.clone_pool())); Arc::new(SqlRoastRepository::new(database.clone_pool()));
let brew_repo = Arc::new(SqlBrewRepository::new(database.clone_pool())); let bag_repo: Arc<dyn BagRepository> = Arc::new(SqlBagRepository::new(database.clone_pool()));
let cafe_repo = Arc::new(SqlCafeRepository::new(database.clone_pool())); let gear_repo: Arc<dyn GearRepository> =
let cup_repo = Arc::new(SqlCupRepository::new(database.clone_pool())); Arc::new(SqlGearRepository::new(database.clone_pool()));
let timeline_repo = Arc::new(SqlTimelineEventRepository::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> = let user_repo: Arc<dyn UserRepository> =
Arc::new(SqlUserRepository::new(database.clone_pool())); Arc::new(SqlUserRepository::new(database.clone_pool()));
let token_repo: Arc<dyn TokenRepository> = 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 backup_service = Arc::new(BackupService::new(database.clone_pool()));
let challenge_store = Arc::new(ChallengeStore::new()); 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: if no users exist, generate a one-time registration token
bootstrap_registration(&registration_token_repo, &user_repo, &config.rp_origin).await?; bootstrap_registration(&registration_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_api_key: config.openrouter_api_key,
openrouter_model: config.openrouter_model, openrouter_model: config.openrouter_model,
backup_service, backup_service,
roaster_service,
roast_service,
bag_service,
brew_service,
gear_service,
cafe_service,
cup_service,
}; };
let listener = TcpListener::bind(config.bind_address) let listener = TcpListener::bind(config.bind_address)

View 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");
}
}
}

View 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)
}
}

View 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)
}
}

View 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");

View 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)
}
}

View file

@ -3,6 +3,9 @@ use serde::{Deserialize, Serialize};
use super::ids::{BagId, RoastId}; use super::ids::{BagId, RoastId};
use super::listing::{SortDirection, SortKey}; 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Bag { 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,
}
}

View file

@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize};
use super::ids::{BagId, BrewId, GearId}; use super::ids::{BagId, BrewId, GearId};
use super::listing::{SortDirection, SortKey}; use super::listing::{SortDirection, SortKey};
use crate::domain::timeline::{NewTimelineEvent, TimelineBrewData, TimelineEventDetail};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum QuickNote { pub enum QuickNote {
@ -94,6 +95,87 @@ pub struct BrewWithDetails {
pub filter_paper_name: Option<String>, 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NewBrew { pub struct NewBrew {
pub bag_id: BagId, pub bag_id: BagId,

View file

@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize};
use crate::domain::ids::CafeId; use crate::domain::ids::CafeId;
use crate::domain::listing::{SortDirection, SortKey}; use crate::domain::listing::{SortDirection, SortKey};
use crate::domain::timeline::{NewTimelineEvent, TimelineEventDetail};
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Cafe { pub struct Cafe {
@ -18,6 +19,32 @@ pub struct Cafe {
pub updated_at: DateTime<Utc>, 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NewCafe { pub struct NewCafe {
pub name: String, pub name: String,

View file

@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize};
use super::ids::{CafeId, CupId, RoastId}; use super::ids::{CafeId, CupId, RoastId};
use super::listing::{SortDirection, SortKey}; use super::listing::{SortDirection, SortKey};
use crate::domain::timeline::{NewTimelineEvent, TimelineEventDetail};
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Cup { pub struct Cup {
@ -25,6 +26,36 @@ pub struct CupWithDetails {
pub cafe_slug: String, 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NewCup { pub struct NewCup {
pub roast_id: RoastId, pub roast_id: RoastId,

View file

@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize};
use super::ids::GearId; use super::ids::GearId;
use super::listing::{SortDirection, SortKey}; use super::listing::{SortDirection, SortKey};
use crate::domain::timeline::{NewTimelineEvent, TimelineEventDetail};
#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
@ -56,6 +57,36 @@ pub struct Gear {
pub updated_at: DateTime<Utc>, 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NewGear { pub struct NewGear {
pub category: GearCategory, pub category: GearCategory,

View file

@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize};
use crate::domain::ids::RoasterId; use crate::domain::ids::RoasterId;
use crate::domain::listing::{SortDirection, SortKey}; use crate::domain::listing::{SortDirection, SortKey};
use crate::domain::timeline::{NewTimelineEvent, TimelineEventDetail};
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Roaster { 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)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct UpdateRoaster { pub struct UpdateRoaster {
pub name: Option<String>, pub name: Option<String>,

View file

@ -3,6 +3,8 @@ use serde::{Deserialize, Serialize};
use crate::domain::ids::{RoastId, RoasterId}; use crate::domain::ids::{RoastId, RoasterId};
use crate::domain::listing::{SortDirection, SortKey}; use crate::domain::listing::{SortDirection, SortKey};
use crate::domain::roasters::Roaster;
use crate::domain::timeline::{NewTimelineEvent, TimelineEventDetail};
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Roast { 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,
}
}

View file

@ -8,7 +8,6 @@ use crate::domain::cafes::{Cafe, CafeSortKey, NewCafe, UpdateCafe};
use crate::domain::ids::CafeId; use crate::domain::ids::CafeId;
use crate::domain::listing::{ListRequest, Page, SortDirection}; use crate::domain::listing::{ListRequest, Page, SortDirection};
use crate::domain::repositories::CafeRepository; use crate::domain::repositories::CafeRepository;
use crate::domain::timeline::TimelineEventDetail;
use crate::infrastructure::database::DatabasePool; use crate::infrastructure::database::DatabasePool;
#[derive(Clone)] #[derive(Clone)]
@ -62,52 +61,11 @@ impl SqlCafeRepository {
updated_at, 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] #[async_trait]
impl CafeRepository for SqlCafeRepository { impl CafeRepository for SqlCafeRepository {
async fn insert(&self, new_cafe: NewCafe) -> Result<Cafe, RepositoryError> { 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 new_cafe = new_cafe.normalize();
let slug = new_cafe.slug(); let slug = new_cafe.slug();
let now = Utc::now(); let now = Utc::now();
@ -125,7 +83,7 @@ impl CafeRepository for SqlCafeRepository {
.bind(new_cafe.website.as_deref()) .bind(new_cafe.website.as_deref())
.bind(now) .bind(now)
.bind(now) .bind(now)
.fetch_one(&mut *tx) .fetch_one(&self.pool)
.await .await
.map_err(|err| { .map_err(|err| {
if let sqlx::Error::Database(db_err) = &err if let sqlx::Error::Database(db_err) = &err
@ -138,31 +96,7 @@ impl CafeRepository for SqlCafeRepository {
RepositoryError::unexpected(err.to_string()) RepositoryError::unexpected(err.to_string())
})?; })?;
let cafe = Self::into_domain(record); Ok(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)
} }
async fn get(&self, id: CafeId) -> Result<Cafe, RepositoryError> { async fn get(&self, id: CafeId) -> Result<Cafe, RepositoryError> {

View file

@ -7,7 +7,6 @@ use crate::domain::cups::{Cup, CupFilter, CupSortKey, CupWithDetails, NewCup};
use crate::domain::ids::{CafeId, CupId, RoastId}; use crate::domain::ids::{CafeId, CupId, RoastId};
use crate::domain::listing::{ListRequest, Page, SortDirection}; use crate::domain::listing::{ListRequest, Page, SortDirection};
use crate::domain::repositories::CupRepository; use crate::domain::repositories::CupRepository;
use crate::domain::timeline::TimelineEventDetail;
use crate::infrastructure::database::DatabasePool; use crate::infrastructure::database::DatabasePool;
const BASE_SELECT: &str = r" const BASE_SELECT: &str = r"
@ -95,85 +94,22 @@ impl SqlCupRepository {
Some(conditions.join(" AND ")) 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] #[async_trait]
impl CupRepository for SqlCupRepository { impl CupRepository for SqlCupRepository {
async fn insert(&self, new_cup: NewCup) -> Result<Cup, RepositoryError> { 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>( let record = query_as::<_, CupRecord>(
"INSERT INTO cups (roast_id, cafe_id) VALUES (?, ?) \ "INSERT INTO cups (roast_id, cafe_id) VALUES (?, ?) \
RETURNING id, roast_id, cafe_id, created_at, updated_at", RETURNING id, roast_id, cafe_id, created_at, updated_at",
) )
.bind(new_cup.roast_id.into_inner()) .bind(new_cup.roast_id.into_inner())
.bind(new_cup.cafe_id.into_inner()) .bind(new_cup.cafe_id.into_inner())
.fetch_one(&mut *tx) .fetch_one(&self.pool)
.await .await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?; .map_err(|err| RepositoryError::unexpected(err.to_string()))?;
let cup = Self::to_domain(record); Ok(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)
} }
async fn get(&self, id: CupId) -> Result<Cup, RepositoryError> { async fn get(&self, id: CupId) -> Result<Cup, RepositoryError> {

View file

@ -8,7 +8,6 @@ use crate::domain::ids::RoasterId;
use crate::domain::listing::{ListRequest, Page, SortDirection}; use crate::domain::listing::{ListRequest, Page, SortDirection};
use crate::domain::repositories::RoasterRepository; use crate::domain::repositories::RoasterRepository;
use crate::domain::roasters::{NewRoaster, Roaster, RoasterSortKey, UpdateRoaster}; use crate::domain::roasters::{NewRoaster, Roaster, RoasterSortKey, UpdateRoaster};
use crate::domain::timeline::TimelineEventDetail;
use crate::infrastructure::database::DatabasePool; use crate::infrastructure::database::DatabasePool;
#[derive(Clone)] #[derive(Clone)]
@ -56,50 +55,11 @@ impl SqlRoasterRepository {
created_at, 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] #[async_trait]
impl RoasterRepository for SqlRoasterRepository { impl RoasterRepository for SqlRoasterRepository {
async fn insert(&self, new_roaster: NewRoaster) -> Result<Roaster, RepositoryError> { 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 new_roaster = new_roaster.normalize();
let slug = new_roaster.slug(); let slug = new_roaster.slug();
let created_at = Utc::now(); let created_at = Utc::now();
@ -114,7 +74,7 @@ impl RoasterRepository for SqlRoasterRepository {
.bind(new_roaster.city.as_deref()) .bind(new_roaster.city.as_deref())
.bind(new_roaster.homepage.as_deref()) .bind(new_roaster.homepage.as_deref())
.bind(created_at) .bind(created_at)
.fetch_one(&mut *tx) .fetch_one(&self.pool)
.await .await
.map_err(|err| { .map_err(|err| {
if let sqlx::Error::Database(db_err) = &err if let sqlx::Error::Database(db_err) = &err
@ -127,31 +87,7 @@ impl RoasterRepository for SqlRoasterRepository {
RepositoryError::unexpected(err.to_string()) RepositoryError::unexpected(err.to_string())
})?; })?;
let roaster = Self::into_domain(record); Ok(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)
} }
async fn get(&self, id: RoasterId) -> Result<Roaster, RepositoryError> { async fn get(&self, id: RoasterId) -> Result<Roaster, RepositoryError> {

View file

@ -9,8 +9,7 @@ use crate::domain::ids::{RoastId, RoasterId};
use crate::domain::listing::{ListRequest, Page, SortDirection}; use crate::domain::listing::{ListRequest, Page, SortDirection};
use crate::domain::repositories::RoastRepository; use crate::domain::repositories::RoastRepository;
use crate::domain::roasts::{NewRoast, Roast, RoastSortKey, RoastWithRoaster, UpdateRoast}; use crate::domain::roasts::{NewRoast, Roast, RoastSortKey, RoastWithRoaster, UpdateRoast};
use crate::domain::timeline::TimelineEventDetail; use crate::infrastructure::database::DatabasePool;
use crate::infrastructure::database::{DatabasePool, DatabaseTransaction};
#[derive(Clone)] #[derive(Clone)]
pub struct SqlRoastRepository { 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> { fn empty_to_none(s: String) -> Option<String> {
@ -132,12 +58,6 @@ fn empty_to_none(s: String) -> Option<String> {
#[async_trait] #[async_trait]
impl RoastRepository for SqlRoastRepository { impl RoastRepository for SqlRoastRepository {
async fn insert(&self, new_roast: NewRoast) -> Result<Roast, RepositoryError> { 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 slug = new_roast.slug();
let NewRoast { let NewRoast {
roaster_id, roaster_id,
@ -170,7 +90,7 @@ impl RoastRepository for SqlRoastRepository {
.bind(process_value.as_deref()) .bind(process_value.as_deref())
.bind(notes_json.as_deref()) .bind(notes_json.as_deref())
.bind(created_at) .bind(created_at)
.fetch_one(&mut *tx) .fetch_one(&self.pool)
.await .await
.map_err(|err| { .map_err(|err| {
if let sqlx::Error::Database(db_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") map_insert_error(err, "unknown roaster reference")
})?; })?;
let roast = record.into_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)
} }
async fn get(&self, id: RoastId) -> Result<Roast, RepositoryError> { async fn get(&self, id: RoastId) -> Result<Roast, RepositoryError> {

View file

@ -1,5 +1,6 @@
use std::sync::Arc; use std::sync::Arc;
use brewlog::application::services::{CafeService, GearService, RoastService, RoasterService};
use brewlog::domain::bags::{Bag, BagFilter, BagSortKey, NewBag}; use brewlog::domain::bags::{Bag, BagFilter, BagSortKey, NewBag};
use brewlog::domain::brews::{Brew, BrewFilter, BrewSortKey, NewBrew}; use brewlog::domain::brews::{Brew, BrewFilter, BrewSortKey, NewBrew};
use brewlog::domain::cafes::{Cafe, CafeSortKey, NewCafe}; use brewlog::domain::cafes::{Cafe, CafeSortKey, NewCafe};
@ -33,6 +34,10 @@ struct TestDb {
cafe_repo: Arc<dyn CafeRepository>, cafe_repo: Arc<dyn CafeRepository>,
timeline_repo: Arc<dyn TimelineEventRepository>, timeline_repo: Arc<dyn TimelineEventRepository>,
backup_service: BackupService, backup_service: BackupService,
roaster_service: RoasterService,
roast_service: RoastService,
gear_service: GearService,
cafe_service: CafeService,
} }
async fn create_test_db() -> TestDb { async fn create_test_db() -> TestDb {
@ -42,15 +47,39 @@ async fn create_test_db() -> TestDb {
let pool = database.clone_pool(); 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 { TestDb {
roaster_repo: Arc::new(SqlRoasterRepository::new(pool.clone())), roaster_repo,
roast_repo: Arc::new(SqlRoastRepository::new(pool.clone())), roast_repo,
bag_repo: Arc::new(SqlBagRepository::new(pool.clone())), bag_repo,
gear_repo: Arc::new(SqlGearRepository::new(pool.clone())), gear_repo,
brew_repo: Arc::new(SqlBrewRepository::new(pool.clone())), brew_repo,
cafe_repo: Arc::new(SqlCafeRepository::new(pool.clone())), cafe_repo,
timeline_repo: Arc::new(SqlTimelineEventRepository::new(pool.clone())), timeline_repo,
backup_service: BackupService::new(pool), 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. /// 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) { 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 let roaster = db
.roaster_repo .roaster_service
.insert(NewRoaster { .create(NewRoaster {
name: "Square Mile".to_string(), name: "Square Mile".to_string(),
country: "UK".to_string(), country: "UK".to_string(),
city: Some("London".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 .await
.expect("failed to create roaster"); .expect("failed to create roaster");
// Create roast // Create roast (via service to generate timeline event)
let roast = db let roast = db
.roast_repo .roast_service
.insert(NewRoast { .create(NewRoast {
roaster_id: roaster.id, roaster_id: roaster.id,
name: "Red Brick".to_string(), name: "Red Brick".to_string(),
origin: "Brazil".to_string(), origin: "Brazil".to_string(),
@ -158,10 +187,10 @@ async fn populate_test_data(db: &TestDb) -> (Roaster, Roast, Bag, Gear, Gear, Ge
.await .await
.expect("failed to create bag"); .expect("failed to create bag");
// Create gear // Create gear (via service to generate timeline events)
let grinder = db let grinder = db
.gear_repo .gear_service
.insert(NewGear { .create(NewGear {
category: GearCategory::Grinder, category: GearCategory::Grinder,
make: "Comandante".to_string(), make: "Comandante".to_string(),
model: "C40 MK4".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"); .expect("failed to create grinder");
let brewer = db let brewer = db
.gear_repo .gear_service
.insert(NewGear { .create(NewGear {
category: GearCategory::Brewer, category: GearCategory::Brewer,
make: "Hario".to_string(), make: "Hario".to_string(),
model: "V60 02".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"); .expect("failed to create brewer");
let filter_paper = db let filter_paper = db
.gear_repo .gear_service
.insert(NewGear { .create(NewGear {
category: GearCategory::FilterPaper, category: GearCategory::FilterPaper,
make: "Hario".to_string(), make: "Hario".to_string(),
model: "V60 Tabbed 02".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" "bag remaining should be 235 after brew"
); );
// Create cafe // Create cafe (via service to generate timeline event)
let cafe = db let cafe = db
.cafe_repo .cafe_service
.insert(NewCafe { .create(NewCafe {
name: "Prufrock".to_string(), name: "Prufrock".to_string(),
city: "London".to_string(), city: "London".to_string(),
country: "UK".to_string(), country: "UK".to_string(),

View file

@ -2,10 +2,14 @@ use std::sync::Arc;
use brewlog::application::routes::app_router; use brewlog::application::routes::app_router;
use brewlog::application::server::AppState; 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::cafes::{Cafe, NewCafe};
use brewlog::domain::repositories::{ use brewlog::domain::repositories::{
CafeRepository, PasskeyCredentialRepository, RegistrationTokenRepository, RoastRepository, BagRepository, BrewRepository, CafeRepository, CupRepository, GearRepository,
RoasterRepository, SessionRepository, TimelineEventRepository, TokenRepository, UserRepository, PasskeyCredentialRepository, RegistrationTokenRepository, RoastRepository, RoasterRepository,
SessionRepository, TimelineEventRepository, TokenRepository, UserRepository,
}; };
use brewlog::domain::roasters::{NewRoaster, Roaster}; use brewlog::domain::roasters::{NewRoaster, Roaster};
use brewlog::domain::users::NewUser; use brewlog::domain::users::NewUser;
@ -158,6 +162,39 @@ async fn spawn_app_inner(
let session_repo_clone: Arc<dyn SessionRepository> = session_repo.clone(); 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 // Create application state
let state = AppState { let state = AppState {
roaster_repo: roaster_repo.clone(), roaster_repo: roaster_repo.clone(),
@ -187,6 +224,13 @@ async fn spawn_app_inner(
openrouter_api_key: String::new(), openrouter_api_key: String::new(),
openrouter_model: "openrouter/free".to_string(), openrouter_model: "openrouter/free".to_string(),
backup_service, backup_service,
roaster_service,
roast_service,
bag_service,
brew_service,
gear_service,
cafe_service,
cup_service,
}; };
// Create router // Create router