From 0030f87eed0a46d6729c7ca2956e9687c84f6870 Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Fri, 6 Feb 2026 10:42:52 +0000 Subject: [PATCH] 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 --- CLAUDE.md | 46 +++++++- src/application/mod.rs | 1 + src/application/routes/api/bags.rs | 105 +++--------------- src/application/routes/api/brews.rs | 115 +------------------- src/application/routes/api/cafes.rs | 4 +- src/application/routes/api/cups.rs | 4 +- src/application/routes/api/gear.rs | 37 +------ src/application/routes/api/roasters.rs | 4 +- src/application/routes/api/roasts.rs | 4 +- src/application/routes/api/scan.rs | 42 ++----- src/application/server.rs | 58 ++++++++-- src/application/services/bags.rs | 76 +++++++++++++ src/application/services/brews.rs | 40 +++++++ src/application/services/cups.rs | 44 ++++++++ src/application/services/mod.rs | 83 ++++++++++++++ src/application/services/roasts.rs | 48 ++++++++ src/domain/bags.rs | 32 ++++++ src/domain/brews.rs | 82 ++++++++++++++ src/domain/cafes.rs | 27 +++++ src/domain/cups.rs | 31 ++++++ src/domain/gear.rs | 31 ++++++ src/domain/roasters.rs | 28 +++++ src/domain/roasts.rs | 33 ++++++ src/infrastructure/repositories/cafes.rs | 70 +----------- src/infrastructure/repositories/cups.rs | 68 +----------- src/infrastructure/repositories/roasters.rs | 68 +----------- src/infrastructure/repositories/roasts.rs | 94 +--------------- tests/server/backup.rs | 75 +++++++++---- tests/server/helpers.rs | 48 +++++++- 29 files changed, 799 insertions(+), 599 deletions(-) create mode 100644 src/application/services/bags.rs create mode 100644 src/application/services/brews.rs create mode 100644 src/application/services/cups.rs create mode 100644 src/application/services/mod.rs create mode 100644 src/application/services/roasts.rs diff --git a/CLAUDE.md b/CLAUDE.md index 6b4fd5b..d16d252 100644 --- a/CLAUDE.md +++ b/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: diff --git a/src/application/mod.rs b/src/application/mod.rs index b396f8b..c3ddc22 100644 --- a/src/application/mod.rs +++ b/src/application/mod.rs @@ -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}; diff --git a/src/application/routes/api/bags.rs b/src/application/routes/api/bags.rs index 3388273..5bd706c 100644 --- a/src/application/routes/api/bags.rs +++ b/src/application/routes/api/bags.rs @@ -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 diff --git a/src/application/routes/api/brews.rs b/src/application/routes/api/brews.rs index 2eb6de3..006ca80 100644 --- a/src/application/routes/api/brews.rs +++ b/src/application/routes/api/brews.rs @@ -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, diff --git a/src/application/routes/api/cafes.rs b/src/application/routes/api/cafes.rs index ff9a179..0376c87 100644 --- a/src/application/routes/api/cafes.rs +++ b/src/application/routes/api/cafes.rs @@ -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)?; diff --git a/src/application/routes/api/cups.rs b/src/application/routes/api/cups.rs index 9c1f885..32b892f 100644 --- a/src/application/routes/api/cups.rs +++ b/src/application/routes/api/cups.rs @@ -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)?; diff --git a/src/application/routes/api/gear.rs b/src/application/routes/api/gear.rs index 7f438a9..6e533e4 100644 --- a/src/application/routes/api/gear.rs +++ b/src/application/routes/api/gear.rs @@ -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 diff --git a/src/application/routes/api/roasters.rs b/src/application/routes/api/roasters.rs index ca65e4b..8120269 100644 --- a/src/application/routes/api/roasters.rs +++ b/src/application/routes/api/roasters.rs @@ -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)?; diff --git a/src/application/routes/api/roasts.rs b/src/application/routes/api/roasts.rs index b2b92ea..8212204 100644 --- a/src/application/routes/api/roasts.rs +++ b/src/application/routes/api/roasts.rs @@ -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)?; diff --git a/src/application/routes/api/scan.rs b/src/application/routes/api/scan.rs index be3cd1d..3aef01d 100644 --- a/src/application/routes/api/scan.rs +++ b/src/application/routes/api/scan.rs @@ -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); diff --git a/src/application/server.rs b/src/application/server.rs index d96a3d9..617e425 100644 --- a/src/application/server.rs +++ b/src/application/server.rs @@ -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, + 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 = + Arc::new(SqlRoasterRepository::new(database.clone_pool())); + let roast_repo: Arc = + Arc::new(SqlRoastRepository::new(database.clone_pool())); + let bag_repo: Arc = Arc::new(SqlBagRepository::new(database.clone_pool())); + let gear_repo: Arc = + Arc::new(SqlGearRepository::new(database.clone_pool())); + let brew_repo: Arc = + Arc::new(SqlBrewRepository::new(database.clone_pool())); + let cafe_repo: Arc = + Arc::new(SqlCafeRepository::new(database.clone_pool())); + let cup_repo: Arc = Arc::new(SqlCupRepository::new(database.clone_pool())); + let timeline_repo: Arc = + Arc::new(SqlTimelineEventRepository::new(database.clone_pool())); let user_repo: Arc = Arc::new(SqlUserRepository::new(database.clone_pool())); let token_repo: Arc = @@ -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) diff --git a/src/application/services/bags.rs b/src/application/services/bags.rs new file mode 100644 index 0000000..1317063 --- /dev/null +++ b/src/application/services/bags.rs @@ -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, + roast_repo: Arc, + roaster_repo: Arc, + timeline_repo: Arc, +} + +impl BagService { + pub fn new( + bag_repo: Arc, + roast_repo: Arc, + roaster_repo: Arc, + timeline_repo: Arc, + ) -> Self { + Self { + bag_repo, + roast_repo, + roaster_repo, + timeline_repo, + } + } + + pub async fn create(&self, new: NewBag) -> Result { + 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 { + 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"); + } + } +} diff --git a/src/application/services/brews.rs b/src/application/services/brews.rs new file mode 100644 index 0000000..1ce2a13 --- /dev/null +++ b/src/application/services/brews.rs @@ -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, + timeline_repo: Arc, +} + +impl BrewService { + pub fn new( + brew_repo: Arc, + timeline_repo: Arc, + ) -> 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 { + 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) + } +} diff --git a/src/application/services/cups.rs b/src/application/services/cups.rs new file mode 100644 index 0000000..3d29b90 --- /dev/null +++ b/src/application/services/cups.rs @@ -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, + timeline_repo: Arc, +} + +impl CupService { + pub fn new( + cup_repo: Arc, + timeline_repo: Arc, + ) -> Self { + Self { + cup_repo, + timeline_repo, + } + } + + pub async fn create(&self, new: NewCup) -> Result { + 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) + } +} diff --git a/src/application/services/mod.rs b/src/application/services/mod.rs new file mode 100644 index 0000000..e3bb2cb --- /dev/null +++ b/src/application/services/mod.rs @@ -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, + timeline_repo: Arc, + } + + impl $service { + pub fn new( + repo: Arc, + timeline_repo: Arc, + ) -> 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"); diff --git a/src/application/services/roasts.rs b/src/application/services/roasts.rs new file mode 100644 index 0000000..17d3cc3 --- /dev/null +++ b/src/application/services/roasts.rs @@ -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, + roaster_repo: Arc, + timeline_repo: Arc, +} + +impl RoastService { + pub fn new( + roast_repo: Arc, + roaster_repo: Arc, + timeline_repo: Arc, + ) -> Self { + Self { + roast_repo, + roaster_repo, + timeline_repo, + } + } + + pub async fn create(&self, new: NewRoast) -> Result { + 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) + } +} diff --git a/src/domain/bags.rs b/src/domain/bags.rs index 0de5bca..49c4eee 100644 --- a/src/domain/bags.rs +++ b/src/domain/bags.rs @@ -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, + } +} diff --git a/src/domain/brews.rs b/src/domain/brews.rs index 1d2995d..e40b241 100644 --- a/src/domain/brews.rs +++ b/src/domain/brews.rs @@ -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, } +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, diff --git a/src/domain/cafes.rs b/src/domain/cafes.rs index 7020cc6..fb2b9d5 100644 --- a/src/domain/cafes.rs +++ b/src/domain/cafes.rs @@ -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, } +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, diff --git a/src/domain/cups.rs b/src/domain/cups.rs index 425ccd1..51f598a 100644 --- a/src/domain/cups.rs +++ b/src/domain/cups.rs @@ -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, diff --git a/src/domain/gear.rs b/src/domain/gear.rs index 3fefe53..882aa33 100644 --- a/src/domain/gear.rs +++ b/src/domain/gear.rs @@ -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, } +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, diff --git a/src/domain/roasters.rs b/src/domain/roasters.rs index 56f00c6..fcd6edf 100644 --- a/src/domain/roasters.rs +++ b/src/domain/roasters.rs @@ -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) -> Option { }) } +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, diff --git a/src/domain/roasts.rs b/src/domain/roasts.rs index 73a0d5b..336451f 100644 --- a/src/domain/roasts.rs +++ b/src/domain/roasts.rs @@ -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, + } +} diff --git a/src/infrastructure/repositories/cafes.rs b/src/infrastructure/repositories/cafes.rs index e37f4a0..200f605 100644 --- a/src/infrastructure/repositories/cafes.rs +++ b/src/infrastructure/repositories/cafes.rs @@ -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 { - 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 { - 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::>(None) - .bind(&cafe.slug) - .bind::>(None) - .bind::>(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 { diff --git a/src/infrastructure/repositories/cups.rs b/src/infrastructure/repositories/cups.rs index 9ca0c82..1591c41 100644 --- a/src/infrastructure/repositories/cups.rs +++ b/src/infrastructure/repositories/cups.rs @@ -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 { - 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 { - 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::>(None) - .bind(&cup_with_details.roast_slug) - .bind(&cup_with_details.roaster_slug) - .bind::>(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 { diff --git a/src/infrastructure/repositories/roasters.rs b/src/infrastructure/repositories/roasters.rs index 2e1c84a..35db125 100644 --- a/src/infrastructure/repositories/roasters.rs +++ b/src/infrastructure/repositories/roasters.rs @@ -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 { - 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 { - 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::>(None) - .bind(&roaster.slug) // slug = roaster's own slug - .bind::>(None) // roaster_slug not applicable for roaster events - .bind::>(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 { diff --git a/src/infrastructure/repositories/roasts.rs b/src/infrastructure/repositories/roasts.rs index 61f02e2..b0e705f 100644 --- a/src/infrastructure/repositories/roasts.rs +++ b/src/infrastructure/repositories/roasts.rs @@ -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::>(None) - .execute(&mut **tx) - .await - .map_err(|err| RepositoryError::unexpected(err.to_string()))?; - - Ok(()) - } } fn empty_to_none(s: String) -> Option { @@ -132,12 +58,6 @@ fn empty_to_none(s: String) -> Option { #[async_trait] impl RoastRepository for SqlRoastRepository { async fn insert(&self, new_roast: NewRoast) -> Result { - 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 { diff --git a/tests/server/backup.rs b/tests/server/backup.rs index 91b9d77..253d934 100644 --- a/tests/server/backup.rs +++ b/tests/server/backup.rs @@ -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, timeline_repo: Arc, 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 = + Arc::new(SqlRoasterRepository::new(pool.clone())); + let roast_repo: Arc = Arc::new(SqlRoastRepository::new(pool.clone())); + let bag_repo: Arc = Arc::new(SqlBagRepository::new(pool.clone())); + let gear_repo: Arc = Arc::new(SqlGearRepository::new(pool.clone())); + let brew_repo: Arc = Arc::new(SqlBrewRepository::new(pool.clone())); + let cafe_repo: Arc = Arc::new(SqlCafeRepository::new(pool.clone())); + let timeline_repo: Arc = + 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 (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(), diff --git a/tests/server/helpers.rs b/tests/server/helpers.rs index 6f604b7..a9382e1 100644 --- a/tests/server/helpers.rs +++ b/tests/server/helpers.rs @@ -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 = session_repo.clone(); + // Create services + let roaster_service = RoasterService::new( + roaster_repo.clone() as Arc, + timeline_repo.clone() as Arc, + ); + let roast_service = RoastService::new( + roast_repo.clone() as Arc, + roaster_repo.clone() as Arc, + timeline_repo.clone() as Arc, + ); + let bag_service = BagService::new( + bag_repo.clone() as Arc, + roast_repo.clone() as Arc, + roaster_repo.clone() as Arc, + timeline_repo.clone() as Arc, + ); + let brew_service = BrewService::new( + brew_repo.clone() as Arc, + timeline_repo.clone() as Arc, + ); + let gear_service = GearService::new( + gear_repo.clone() as Arc, + timeline_repo.clone() as Arc, + ); + let cafe_service = CafeService::new( + cafe_repo.clone() as Arc, + timeline_repo.clone() as Arc, + ); + let cup_service = CupService::new( + cup_repo.clone() as Arc, + timeline_repo.clone() as Arc, + ); + // 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