From 91bd3172eaa7e8930dfffce582735485c6a56e3f Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Mon, 2 Feb 2026 19:29:10 +0000 Subject: [PATCH] feat(brews): add domain layer and database migration - Add BrewId typed wrapper - Add Brew, BrewWithDetails, NewBrew, BrewFilter, BrewSortKey - Add BrewRepository trait with transactional insert semantics - Add brews table with foreign keys to bags and gear - Update timeline_events constraint to include 'brew' entity type --- migrations/0009_add_brews.sql | 38 ++++++++++++++ src/domain/brews.rs | 96 +++++++++++++++++++++++++++++++++++ src/domain/ids.rs | 1 + src/domain/mod.rs | 1 + src/domain/repositories.rs | 18 ++++++- 5 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 migrations/0009_add_brews.sql create mode 100644 src/domain/brews.rs diff --git a/migrations/0009_add_brews.sql b/migrations/0009_add_brews.sql new file mode 100644 index 0000000..a74c750 --- /dev/null +++ b/migrations/0009_add_brews.sql @@ -0,0 +1,38 @@ +-- Brews table for logging individual coffee brews +CREATE TABLE brews ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + bag_id INTEGER NOT NULL REFERENCES bags(id) ON DELETE CASCADE, + coffee_weight REAL NOT NULL, + grinder_id INTEGER NOT NULL REFERENCES gear(id) ON DELETE RESTRICT, + grind_setting REAL NOT NULL, + brewer_id INTEGER NOT NULL REFERENCES gear(id) ON DELETE RESTRICT, + water_volume INTEGER NOT NULL, + water_temp REAL NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_brews_bag_id ON brews(bag_id); +CREATE INDEX idx_brews_created_at ON brews(created_at DESC); + +-- Update timeline constraint to include brew entity type +DROP TABLE IF EXISTS timeline_events_new; +CREATE TABLE timeline_events_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + entity_type TEXT NOT NULL CHECK (entity_type IN ('roaster', 'roast', 'bag', 'gear', 'brew')), + entity_id INTEGER NOT NULL, + action TEXT NOT NULL, + occurred_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + title TEXT NOT NULL, + details_json TEXT, + tasting_notes_json TEXT +); + +INSERT INTO timeline_events_new (id, entity_type, entity_id, action, occurred_at, title, details_json, tasting_notes_json) +SELECT id, entity_type, entity_id, action, occurred_at, title, details_json, tasting_notes_json +FROM timeline_events; + +DROP TABLE timeline_events; +ALTER TABLE timeline_events_new RENAME TO timeline_events; +CREATE INDEX idx_timeline_events_entity ON timeline_events(entity_type, entity_id); +CREATE INDEX idx_timeline_events_occurred_at ON timeline_events(occurred_at DESC); diff --git a/src/domain/brews.rs b/src/domain/brews.rs new file mode 100644 index 0000000..b081531 --- /dev/null +++ b/src/domain/brews.rs @@ -0,0 +1,96 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use super::ids::{BagId, BrewId, GearId}; +use super::listing::{SortDirection, SortKey}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Brew { + pub id: BrewId, + pub bag_id: BagId, + pub coffee_weight: f64, + pub grinder_id: GearId, + pub grind_setting: f64, + pub brewer_id: GearId, + pub water_volume: i32, + pub water_temp: f64, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BrewWithDetails { + #[serde(flatten)] + pub brew: Brew, + pub roast_name: String, + pub roaster_name: String, + pub roast_slug: String, + pub roaster_slug: String, + pub grinder_name: String, + pub brewer_name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NewBrew { + pub bag_id: BagId, + pub coffee_weight: f64, + pub grinder_id: GearId, + pub grind_setting: f64, + pub brewer_id: GearId, + pub water_volume: i32, + pub water_temp: f64, +} + +/// Filter criteria for brew queries. +#[derive(Debug, Default, Clone)] +pub struct BrewFilter { + pub bag_id: Option, +} + +impl BrewFilter { + /// No filter - returns all brews. + pub fn all() -> Self { + Self::default() + } + + /// Filter for brews from a specific bag. + pub fn for_bag(bag_id: BagId) -> Self { + Self { + bag_id: Some(bag_id), + } + } +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum BrewSortKey { + CreatedAt, + CoffeeWeight, + WaterVolume, +} + +impl SortKey for BrewSortKey { + fn default() -> Self { + BrewSortKey::CreatedAt + } + + fn from_query(value: &str) -> Option { + match value { + "created-at" => Some(BrewSortKey::CreatedAt), + "coffee-weight" => Some(BrewSortKey::CoffeeWeight), + "water-volume" => Some(BrewSortKey::WaterVolume), + _ => None, + } + } + + fn query_value(self) -> &'static str { + match self { + BrewSortKey::CreatedAt => "created-at", + BrewSortKey::CoffeeWeight => "coffee-weight", + BrewSortKey::WaterVolume => "water-volume", + } + } + + fn default_direction(self) -> SortDirection { + SortDirection::Desc + } +} diff --git a/src/domain/ids.rs b/src/domain/ids.rs index 24058b5..9b6348f 100644 --- a/src/domain/ids.rs +++ b/src/domain/ids.rs @@ -56,3 +56,4 @@ define_id!(TokenId); define_id!(SessionId); define_id!(BagId); define_id!(GearId); +define_id!(BrewId); diff --git a/src/domain/mod.rs b/src/domain/mod.rs index 48a57f3..c845c46 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -1,4 +1,5 @@ pub mod bags; +pub mod brews; pub mod errors; pub mod gear; pub mod ids; diff --git a/src/domain/repositories.rs b/src/domain/repositories.rs index 743a953..1f9ad5d 100644 --- a/src/domain/repositories.rs +++ b/src/domain/repositories.rs @@ -2,8 +2,9 @@ use super::RepositoryError; use crate::domain::listing::{ListRequest, Page, SortDirection, SortKey}; use crate::domain::bags::{Bag, BagFilter, BagSortKey, BagWithRoast, NewBag, UpdateBag}; +use crate::domain::brews::{Brew, BrewFilter, BrewSortKey, BrewWithDetails, NewBrew}; use crate::domain::gear::{Gear, GearFilter, GearSortKey, NewGear, UpdateGear}; -use crate::domain::ids::{BagId, GearId, RoastId, RoasterId, SessionId, TokenId, UserId}; +use crate::domain::ids::{BagId, BrewId, GearId, RoastId, RoasterId, SessionId, TokenId, UserId}; use crate::domain::roasters::RoasterSortKey; use crate::domain::roasters::{NewRoaster, Roaster, UpdateRoaster}; use crate::domain::roasts::RoastSortKey; @@ -148,3 +149,18 @@ pub trait GearRepository: Send + Sync { async fn update(&self, id: GearId, changes: UpdateGear) -> Result; async fn delete(&self, id: GearId) -> Result<(), RepositoryError>; } + +#[async_trait] +pub trait BrewRepository: Send + Sync { + /// Insert a new brew and deduct `coffee_weight` from the bag's remaining amount. + /// This is a transactional operation. + async fn insert(&self, brew: NewBrew) -> Result; + async fn get(&self, id: BrewId) -> Result; + async fn get_with_details(&self, id: BrewId) -> Result; + async fn list( + &self, + filter: BrewFilter, + request: &ListRequest, + ) -> Result, RepositoryError>; + async fn delete(&self, id: BrewId) -> Result<(), RepositoryError>; +}