From 14c3079600271afe7451e54671ff1b5fb7f9bd3c Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Thu, 27 Nov 2025 13:56:06 +0000 Subject: [PATCH] feat: add `bags` domain and repositories --- migrations/0001_init.sql | 2 +- migrations/0004_add_bags.sql | 15 + src/application/server.rs | 8 +- src/domain/bags.rs | 85 +++++ src/domain/ids.rs | 1 + src/domain/listing.rs | 8 +- src/domain/mod.rs | 1 + src/domain/repositories.rs | 24 +- src/infrastructure/repositories/bags.rs | 324 ++++++++++++++++++ src/infrastructure/repositories/mod.rs | 1 + .../repositories/timeline_events.rs | 42 ++- 11 files changed, 501 insertions(+), 10 deletions(-) create mode 100644 migrations/0004_add_bags.sql create mode 100644 src/domain/bags.rs create mode 100644 src/infrastructure/repositories/bags.rs diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql index a5b4457..ff27701 100644 --- a/migrations/0001_init.sql +++ b/migrations/0001_init.sql @@ -31,7 +31,7 @@ CREATE UNIQUE INDEX idx_roasts_roaster_slug ON roasts(roaster_id, slug); CREATE TABLE timeline_events ( id INTEGER PRIMARY KEY, - entity_type TEXT NOT NULL CHECK (entity_type IN ('roaster', 'roast')), + entity_type TEXT NOT NULL CHECK (entity_type IN ('roaster', 'roast', 'bag')), entity_id INTEGER NOT NULL, occurred_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), title TEXT NOT NULL, diff --git a/migrations/0004_add_bags.sql b/migrations/0004_add_bags.sql new file mode 100644 index 0000000..884a1a8 --- /dev/null +++ b/migrations/0004_add_bags.sql @@ -0,0 +1,15 @@ +CREATE TABLE bags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + roast_id INTEGER NOT NULL, + roast_date DATE, + amount REAL NOT NULL, + remaining REAL NOT NULL, + closed BOOLEAN NOT NULL DEFAULT FALSE, + finished_at DATE, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (roast_id) REFERENCES roasts (id) ON DELETE CASCADE +); + +CREATE INDEX idx_bags_roast_id ON bags(roast_id); +CREATE INDEX idx_bags_closed ON bags(closed); diff --git a/src/application/server.rs b/src/application/server.rs index 0f71a53..b1a7d02 100644 --- a/src/application/server.rs +++ b/src/application/server.rs @@ -9,12 +9,13 @@ use tracing::info; use crate::application::routes::app_router; use crate::domain::repositories::{ - RoastRepository, RoasterRepository, SessionRepository, TimelineEventRepository, + BagRepository, RoastRepository, RoasterRepository, SessionRepository, TimelineEventRepository, TokenRepository, UserRepository, }; use crate::domain::users::NewUser; use crate::infrastructure::auth::hash_password; use crate::infrastructure::database::Database; +use crate::infrastructure::repositories::bags::SqlBagRepository; use crate::infrastructure::repositories::roasters::SqlRoasterRepository; use crate::infrastructure::repositories::roasts::SqlRoastRepository; use crate::infrastructure::repositories::sessions::SqlSessionRepository; @@ -33,6 +34,7 @@ pub struct ServerConfig { pub struct AppState { pub roaster_repo: Arc, pub roast_repo: Arc, + pub bag_repo: Arc, pub timeline_repo: Arc, pub user_repo: Arc, pub token_repo: Arc, @@ -43,6 +45,7 @@ impl AppState { pub fn new( roaster_repo: Arc, roast_repo: Arc, + bag_repo: Arc, timeline_repo: Arc, user_repo: Arc, token_repo: Arc, @@ -51,6 +54,7 @@ impl AppState { Self { roaster_repo, roast_repo, + bag_repo, timeline_repo, user_repo, token_repo, @@ -67,6 +71,7 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> { 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 timeline_repo = Arc::new(SqlTimelineEventRepository::new(database.clone_pool())); let user_repo: Arc = Arc::new(SqlUserRepository::new(database.clone_pool())); @@ -81,6 +86,7 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> { let state = AppState::new( roaster_repo, roast_repo, + bag_repo, timeline_repo, user_repo, token_repo, diff --git a/src/domain/bags.rs b/src/domain/bags.rs new file mode 100644 index 0000000..913be17 --- /dev/null +++ b/src/domain/bags.rs @@ -0,0 +1,85 @@ +use chrono::{DateTime, NaiveDate, Utc}; +use serde::{Deserialize, Serialize}; + +use super::ids::{BagId, RoastId}; +use super::listing::{SortDirection, SortKey}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Bag { + pub id: BagId, + pub roast_id: RoastId, + pub roast_date: Option, + pub amount: f64, + pub remaining: f64, + pub closed: bool, + pub finished_at: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BagWithRoast { + #[serde(flatten)] + pub bag: Bag, + pub roast_name: String, + pub roaster_name: String, + pub roast_slug: String, + pub roaster_slug: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NewBag { + pub roast_id: RoastId, + pub roast_date: Option, + pub amount: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateBag { + pub remaining: Option, + pub closed: Option, + pub finished_at: Option, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum BagSortKey { + RoastDate, + CreatedAt, + Roaster, + Roast, + FinishedAt, +} + +impl SortKey for BagSortKey { + fn default() -> Self { + BagSortKey::RoastDate + } + + fn from_query(value: &str) -> Option { + match value { + "roast-date" => Some(BagSortKey::RoastDate), + "created-at" => Some(BagSortKey::CreatedAt), + "roaster" => Some(BagSortKey::Roaster), + "roast" => Some(BagSortKey::Roast), + "finished-at" => Some(BagSortKey::FinishedAt), + _ => None, + } + } + + fn query_value(self) -> &'static str { + match self { + BagSortKey::RoastDate => "roast-date", + BagSortKey::CreatedAt => "created-at", + BagSortKey::Roaster => "roaster", + BagSortKey::Roast => "roast", + BagSortKey::FinishedAt => "finished-at", + } + } + + fn default_direction(self) -> SortDirection { + match self { + BagSortKey::Roaster | BagSortKey::Roast => SortDirection::Asc, + _ => SortDirection::Desc, + } + } +} diff --git a/src/domain/ids.rs b/src/domain/ids.rs index 4cb5076..94a6f24 100644 --- a/src/domain/ids.rs +++ b/src/domain/ids.rs @@ -54,3 +54,4 @@ define_id!(TimelineEventId); define_id!(UserId); define_id!(TokenId); define_id!(SessionId); +define_id!(BagId); diff --git a/src/domain/listing.rs b/src/domain/listing.rs index d4f7255..96df7c9 100644 --- a/src/domain/listing.rs +++ b/src/domain/listing.rs @@ -68,10 +68,10 @@ pub const MAX_PAGE_SIZE: u32 = 50; #[derive(Debug, Clone, Copy, Eq, PartialEq)] pub struct ListRequest { - page: u32, - page_size: PageSize, - sort_key: K, - sort_direction: SortDirection, + pub page: u32, + pub page_size: PageSize, + pub sort_key: K, + pub sort_direction: SortDirection, } impl ListRequest { diff --git a/src/domain/mod.rs b/src/domain/mod.rs index 64ef27c..a74755c 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -1,3 +1,4 @@ +pub mod bags; pub mod ids; pub mod listing; pub mod repositories; diff --git a/src/domain/repositories.rs b/src/domain/repositories.rs index 45f093f..f12668f 100644 --- a/src/domain/repositories.rs +++ b/src/domain/repositories.rs @@ -1,13 +1,14 @@ use super::RepositoryError; use crate::domain::listing::{ListRequest, Page, SortDirection, SortKey}; -use crate::domain::ids::{RoastId, RoasterId, SessionId, TokenId, UserId}; +use crate::domain::bags::{Bag, BagSortKey, BagWithRoast, NewBag, UpdateBag}; +use crate::domain::ids::{BagId, RoastId, RoasterId, SessionId, TokenId, UserId}; use crate::domain::roasters::RoasterSortKey; use crate::domain::roasters::{NewRoaster, Roaster, UpdateRoaster}; use crate::domain::roasts::RoastSortKey; use crate::domain::roasts::{NewRoast, Roast, RoastWithRoaster, UpdateRoast}; use crate::domain::sessions::{NewSession, Session}; -use crate::domain::timeline::{TimelineEvent, TimelineSortKey}; +use crate::domain::timeline::{NewTimelineEvent, TimelineEvent, TimelineSortKey}; use crate::domain::tokens::{NewToken, Token}; use crate::domain::users::{NewUser, User}; use async_trait::async_trait; @@ -77,6 +78,7 @@ pub trait RoastRepository: Send + Sync { #[async_trait] pub trait TimelineEventRepository: Send + Sync { + async fn insert(&self, event: NewTimelineEvent) -> Result; async fn list( &self, request: &ListRequest, @@ -117,3 +119,21 @@ pub trait SessionRepository: Send + Sync { async fn delete(&self, id: SessionId) -> Result<(), RepositoryError>; async fn delete_expired(&self) -> Result<(), RepositoryError>; } + +#[async_trait] +pub trait BagRepository: Send + Sync { + async fn insert(&self, bag: NewBag) -> Result; + async fn get(&self, id: BagId) -> Result; + async fn list( + &self, + request: &ListRequest, + ) -> Result, RepositoryError>; + async fn list_by_roast(&self, roast_id: RoastId) -> Result, RepositoryError>; + async fn update(&self, id: BagId, changes: UpdateBag) -> Result; + async fn delete(&self, id: BagId) -> Result<(), RepositoryError>; + async fn list_open(&self) -> Result, RepositoryError>; + async fn list_closed( + &self, + request: &ListRequest, + ) -> Result, RepositoryError>; +} diff --git a/src/infrastructure/repositories/bags.rs b/src/infrastructure/repositories/bags.rs new file mode 100644 index 0000000..0c9d2d7 --- /dev/null +++ b/src/infrastructure/repositories/bags.rs @@ -0,0 +1,324 @@ +use async_trait::async_trait; +use chrono::{DateTime, NaiveDate, Utc}; +use sqlx::query_as; + +use crate::domain::RepositoryError; +use crate::domain::bags::{Bag, BagSortKey, BagWithRoast, NewBag, UpdateBag}; +use crate::domain::ids::{BagId, RoastId}; +use crate::domain::listing::{ListRequest, Page, SortDirection}; +use crate::domain::repositories::BagRepository; +use crate::infrastructure::database::DatabasePool; + +#[derive(Clone)] +pub struct SqlBagRepository { + pool: DatabasePool, +} + +impl SqlBagRepository { + pub fn new(pool: DatabasePool) -> Self { + Self { pool } + } + + fn to_domain(record: BagRecord) -> Bag { + Bag { + id: BagId::new(record.id), + roast_id: RoastId::new(record.roast_id), + roast_date: record.roast_date, + amount: record.amount, + remaining: record.remaining, + closed: record.closed, + finished_at: record.finished_at, + created_at: record.created_at, + updated_at: record.updated_at, + } + } + + fn to_domain_with_roast(record: BagWithRoastRecord) -> BagWithRoast { + BagWithRoast { + bag: Bag { + id: BagId::new(record.id), + roast_id: RoastId::new(record.roast_id), + roast_date: record.roast_date, + amount: record.amount, + remaining: record.remaining, + closed: record.closed, + finished_at: record.finished_at, + created_at: record.created_at, + updated_at: record.updated_at, + }, + roast_name: record.roast_name, + roaster_name: record.roaster_name, + roast_slug: record.roast_slug, + roaster_slug: record.roaster_slug, + } + } +} + +#[async_trait] +impl BagRepository for SqlBagRepository { + async fn insert(&self, bag: NewBag) -> Result { + let query = r#" + INSERT INTO bags (roast_id, roast_date, amount, remaining) + VALUES (?, ?, ?, ?) + RETURNING id, roast_id, roast_date, amount, remaining, closed, finished_at, created_at, updated_at + "#; + + let record = query_as::<_, BagRecord>(query) + .bind(bag.roast_id.into_inner()) + .bind(bag.roast_date) + .bind(bag.amount) + .bind(bag.amount) // remaining starts as amount + .fetch_one(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + + Ok(Self::to_domain(record)) + } + + async fn get(&self, id: BagId) -> Result { + let query = r#" + SELECT id, roast_id, roast_date, amount, remaining, closed, finished_at, created_at, updated_at + FROM bags + WHERE id = ? + "#; + + let record = query_as::<_, BagRecord>(query) + .bind(id.into_inner()) + .fetch_optional(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))? + .ok_or(RepositoryError::NotFound)?; + + Ok(Self::to_domain(record)) + } + + async fn list( + &self, + request: &ListRequest, + ) -> Result, RepositoryError> { + let sort_column = match request.sort_key { + BagSortKey::RoastDate => "b.roast_date", + BagSortKey::CreatedAt => "b.created_at", + BagSortKey::Roaster => "rr.name", + BagSortKey::Roast => "r.name", + BagSortKey::FinishedAt => "b.finished_at", + }; + + let direction = match request.sort_direction { + SortDirection::Asc => "ASC", + SortDirection::Desc => "DESC", + }; + + let order_clause = format!("{} {}", sort_column, direction); + + let base_query = r#" + SELECT + b.id, b.roast_id, b.roast_date, b.amount, b.remaining, b.closed, b.finished_at, b.created_at, b.updated_at, + r.name as roast_name, r.slug as roast_slug, + rr.name as roaster_name, rr.slug as roaster_slug + FROM bags b + JOIN roasts r ON b.roast_id = r.id + JOIN roasters rr ON r.roaster_id = rr.id + "#; + + let count_query = "SELECT COUNT(*) FROM bags"; + + crate::infrastructure::repositories::pagination::paginate( + &self.pool, + request, + base_query, + count_query, + &order_clause, + |record| Ok(Self::to_domain_with_roast(record)), + ) + .await + } + + async fn list_by_roast(&self, roast_id: RoastId) -> Result, RepositoryError> { + let query = r#" + SELECT + b.id, b.roast_id, b.roast_date, b.amount, b.remaining, b.closed, b.finished_at, b.created_at, b.updated_at, + r.name as roast_name, r.slug as roast_slug, + rr.name as roaster_name, rr.slug as roaster_slug + FROM bags b + JOIN roasts r ON b.roast_id = r.id + JOIN roasters rr ON r.roaster_id = rr.id + WHERE b.roast_id = ? + ORDER BY b.roast_date DESC + "#; + + let records = query_as::<_, BagWithRoastRecord>(query) + .bind(roast_id.into_inner()) + .fetch_all(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + + Ok(records + .into_iter() + .map(Self::to_domain_with_roast) + .collect()) + } + + async fn update(&self, id: BagId, changes: UpdateBag) -> Result { + let mut query = "UPDATE bags SET updated_at = CURRENT_TIMESTAMP".to_string(); + let mut has_changes = false; + + if changes.remaining.is_some() { + query.push_str(", remaining = ?"); + has_changes = true; + } + + if changes.closed.is_some() { + query.push_str(", closed = ?"); + has_changes = true; + } + + if changes.finished_at.is_some() { + query.push_str(", finished_at = ?"); + has_changes = true; + } + + if !has_changes { + return self.get(id).await; + } + + query.push_str(" WHERE id = ? RETURNING id, roast_id, roast_date, amount, remaining, closed, finished_at, created_at, updated_at"); + + let mut q = query_as::<_, BagRecord>(&query); + + if let Some(remaining) = changes.remaining { + q = q.bind(remaining); + } + + if let Some(closed) = changes.closed { + q = q.bind(closed); + } + + if let Some(finished_at) = changes.finished_at { + q = q.bind(finished_at); + } + + q = q.bind(id.into_inner()); + + let record = q + .fetch_optional(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))? + .ok_or(RepositoryError::NotFound)?; + + Ok(Self::to_domain(record)) + } + + async fn delete(&self, id: BagId) -> Result<(), RepositoryError> { + let query = "DELETE FROM bags WHERE id = ?"; + + let result = sqlx::query(query) + .bind(id.into_inner()) + .execute(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + + if result.rows_affected() == 0 { + return Err(RepositoryError::NotFound); + } + + Ok(()) + } + + async fn list_open(&self) -> Result, RepositoryError> { + let query = r#" + SELECT + b.id, b.roast_id, b.roast_date, b.amount, b.remaining, b.closed, b.finished_at, b.created_at, b.updated_at, + r.name as roast_name, r.slug as roast_slug, + rr.name as roaster_name, rr.slug as roaster_slug + FROM bags b + JOIN roasts r ON b.roast_id = r.id + JOIN roasters rr ON r.roaster_id = rr.id + WHERE b.closed = FALSE + ORDER BY b.roast_date DESC + "#; + + let records = query_as::<_, BagWithRoastRecord>(query) + .fetch_all(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + + Ok(records + .into_iter() + .map(Self::to_domain_with_roast) + .collect()) + } + + async fn list_closed( + &self, + request: &ListRequest, + ) -> Result, RepositoryError> { + let sort_column = match request.sort_key { + BagSortKey::RoastDate => "b.roast_date", + BagSortKey::CreatedAt => "b.created_at", + BagSortKey::Roaster => "rr.name", + BagSortKey::Roast => "r.name", + BagSortKey::FinishedAt => "b.finished_at", + }; + + let direction = match request.sort_direction { + SortDirection::Asc => "ASC", + SortDirection::Desc => "DESC", + }; + + let order_clause = format!("{} {}", sort_column, direction); + + let base_query = r#" + SELECT + b.id, b.roast_id, b.roast_date, b.amount, b.remaining, b.closed, b.finished_at, b.created_at, b.updated_at, + r.name as roast_name, r.slug as roast_slug, + rr.name as roaster_name, rr.slug as roaster_slug + FROM bags b + JOIN roasts r ON b.roast_id = r.id + JOIN roasters rr ON r.roaster_id = rr.id + WHERE b.closed = TRUE + "#; + + let count_query = "SELECT COUNT(*) FROM bags WHERE closed = TRUE"; + + crate::infrastructure::repositories::pagination::paginate( + &self.pool, + request, + base_query, + count_query, + &order_clause, + |record| Ok(Self::to_domain_with_roast(record)), + ) + .await + } +} + +#[derive(sqlx::FromRow)] +struct BagRecord { + id: i64, + roast_id: i64, + roast_date: Option, + amount: f64, + remaining: f64, + closed: bool, + finished_at: Option, + created_at: DateTime, + updated_at: DateTime, +} + +#[derive(sqlx::FromRow)] +struct BagWithRoastRecord { + id: i64, + roast_id: i64, + roast_date: Option, + amount: f64, + remaining: f64, + closed: bool, + finished_at: Option, + created_at: DateTime, + updated_at: DateTime, + roast_name: String, + roast_slug: String, + roaster_name: String, + roaster_slug: String, +} diff --git a/src/infrastructure/repositories/mod.rs b/src/infrastructure/repositories/mod.rs index de97f82..a08ad8f 100644 --- a/src/infrastructure/repositories/mod.rs +++ b/src/infrastructure/repositories/mod.rs @@ -1,3 +1,4 @@ +pub mod bags; pub mod pagination; pub mod roasters; pub mod roasts; diff --git a/src/infrastructure/repositories/timeline_events.rs b/src/infrastructure/repositories/timeline_events.rs index 9a525b8..d639093 100644 --- a/src/infrastructure/repositories/timeline_events.rs +++ b/src/infrastructure/repositories/timeline_events.rs @@ -2,7 +2,9 @@ use crate::domain::RepositoryError; use crate::domain::ids::TimelineEventId; use crate::domain::listing::{ListRequest, Page, SortDirection}; use crate::domain::repositories::TimelineEventRepository; -use crate::domain::timeline::{TimelineEvent, TimelineEventDetail, TimelineSortKey}; +use crate::domain::timeline::{ + NewTimelineEvent, TimelineEvent, TimelineEventDetail, TimelineSortKey, +}; use crate::infrastructure::database::DatabasePool; use async_trait::async_trait; use chrono::{DateTime, Utc}; @@ -21,6 +23,37 @@ impl SqlTimelineEventRepository { #[async_trait] impl TimelineEventRepository for SqlTimelineEventRepository { + async fn insert(&self, event: NewTimelineEvent) -> Result { + let query = r#" + INSERT INTO timeline_events (entity_type, entity_id, occurred_at, title, details_json, tasting_notes_json) + VALUES (?, ?, ?, ?, ?, ?) + RETURNING id, entity_type, entity_id, occurred_at, title, details_json, tasting_notes_json + "#; + + let details_json = serde_json::to_string(&event.details).map_err(|err| { + RepositoryError::unexpected(format!("failed to encode timeline event details: {err}")) + })?; + + let tasting_notes_json = serde_json::to_string(&event.tasting_notes).map_err(|err| { + RepositoryError::unexpected(format!( + "failed to encode timeline event tasting notes: {err}" + )) + })?; + + let record = sqlx::query_as::<_, TimelineEventRecord>(query) + .bind(event.entity_type) + .bind(event.entity_id) + .bind(event.occurred_at) + .bind(event.title) + .bind(details_json) + .bind(tasting_notes_json) + .fetch_one(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + + record.into_domain() + } + async fn list( &self, request: &ListRequest, @@ -36,16 +69,21 @@ impl TimelineEventRepository for SqlTimelineEventRepository { CASE WHEN t.entity_type = 'roaster' THEN r.slug WHEN t.entity_type = 'roast' THEN rst.slug + WHEN t.entity_type = 'bag' THEN b_r.slug ELSE NULL END as slug, CASE WHEN t.entity_type = 'roast' THEN rst_r.slug + WHEN t.entity_type = 'bag' THEN b_rr.slug ELSE NULL END as roaster_slug FROM timeline_events t LEFT JOIN roasters r ON t.entity_type = 'roaster' AND t.entity_id = r.id LEFT JOIN roasts rst ON t.entity_type = 'roast' AND t.entity_id = rst.id - LEFT JOIN roasters rst_r ON rst.roaster_id = rst_r.id"; + LEFT JOIN roasters rst_r ON rst.roaster_id = rst_r.id + LEFT JOIN bags b ON t.entity_type = 'bag' AND t.entity_id = b.id + LEFT JOIN roasts b_r ON b.roast_id = b_r.id + LEFT JOIN roasters b_rr ON b_r.roaster_id = b_rr.id"; let count_query = "SELECT COUNT(*) FROM timeline_events"; crate::infrastructure::repositories::pagination::paginate(