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
This commit is contained in:
Jon Seager 2026-02-02 19:29:10 +00:00
parent 2bd008a8d7
commit 91bd3172ea
No known key found for this signature in database
5 changed files with 153 additions and 1 deletions

View file

@ -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);

96
src/domain/brews.rs Normal file
View file

@ -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<Utc>,
pub updated_at: DateTime<Utc>,
}
#[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<BagId>,
}
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<Self> {
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
}
}

View file

@ -56,3 +56,4 @@ define_id!(TokenId);
define_id!(SessionId); define_id!(SessionId);
define_id!(BagId); define_id!(BagId);
define_id!(GearId); define_id!(GearId);
define_id!(BrewId);

View file

@ -1,4 +1,5 @@
pub mod bags; pub mod bags;
pub mod brews;
pub mod errors; pub mod errors;
pub mod gear; pub mod gear;
pub mod ids; pub mod ids;

View file

@ -2,8 +2,9 @@ use super::RepositoryError;
use crate::domain::listing::{ListRequest, Page, SortDirection, SortKey}; use crate::domain::listing::{ListRequest, Page, SortDirection, SortKey};
use crate::domain::bags::{Bag, BagFilter, BagSortKey, BagWithRoast, NewBag, UpdateBag}; 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::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::RoasterSortKey;
use crate::domain::roasters::{NewRoaster, Roaster, UpdateRoaster}; use crate::domain::roasters::{NewRoaster, Roaster, UpdateRoaster};
use crate::domain::roasts::RoastSortKey; use crate::domain::roasts::RoastSortKey;
@ -148,3 +149,18 @@ pub trait GearRepository: Send + Sync {
async fn update(&self, id: GearId, changes: UpdateGear) -> Result<Gear, RepositoryError>; async fn update(&self, id: GearId, changes: UpdateGear) -> Result<Gear, RepositoryError>;
async fn delete(&self, id: GearId) -> Result<(), RepositoryError>; 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<Brew, RepositoryError>;
async fn get(&self, id: BrewId) -> Result<Brew, RepositoryError>;
async fn get_with_details(&self, id: BrewId) -> Result<BrewWithDetails, RepositoryError>;
async fn list(
&self,
filter: BrewFilter,
request: &ListRequest<BrewSortKey>,
) -> Result<Page<BrewWithDetails>, RepositoryError>;
async fn delete(&self, id: BrewId) -> Result<(), RepositoryError>;
}