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!(SessionId);
|
||||||
define_id!(BagId);
|
define_id!(BagId);
|
||||||
define_id!(GearId);
|
define_id!(GearId);
|
||||||
|
define_id!(BrewId);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue