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:
parent
2bd008a8d7
commit
91bd3172ea
5 changed files with 153 additions and 1 deletions
38
migrations/0009_add_brews.sql
Normal file
38
migrations/0009_add_brews.sql
Normal 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
96
src/domain/brews.rs
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -56,3 +56,4 @@ define_id!(TokenId);
|
|||
define_id!(SessionId);
|
||||
define_id!(BagId);
|
||||
define_id!(GearId);
|
||||
define_id!(BrewId);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
pub mod bags;
|
||||
pub mod brews;
|
||||
pub mod errors;
|
||||
pub mod gear;
|
||||
pub mod ids;
|
||||
|
|
|
|||
|
|
@ -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<Gear, 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>;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue