feat: add bags domain and repositories

This commit is contained in:
Jon Seager 2025-11-27 13:56:06 +00:00
parent 02824a90f1
commit 14c3079600
No known key found for this signature in database
11 changed files with 501 additions and 10 deletions

View file

@ -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,

View file

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

View file

@ -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<dyn RoasterRepository>,
pub roast_repo: Arc<dyn RoastRepository>,
pub bag_repo: Arc<dyn BagRepository>,
pub timeline_repo: Arc<dyn TimelineEventRepository>,
pub user_repo: Arc<dyn UserRepository>,
pub token_repo: Arc<dyn TokenRepository>,
@ -43,6 +45,7 @@ impl AppState {
pub fn new(
roaster_repo: Arc<dyn RoasterRepository>,
roast_repo: Arc<dyn RoastRepository>,
bag_repo: Arc<dyn BagRepository>,
timeline_repo: Arc<dyn TimelineEventRepository>,
user_repo: Arc<dyn UserRepository>,
token_repo: Arc<dyn TokenRepository>,
@ -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<dyn UserRepository> =
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,

85
src/domain/bags.rs Normal file
View file

@ -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<NaiveDate>,
pub amount: f64,
pub remaining: f64,
pub closed: bool,
pub finished_at: Option<NaiveDate>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[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<NaiveDate>,
pub amount: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateBag {
pub remaining: Option<f64>,
pub closed: Option<bool>,
pub finished_at: Option<NaiveDate>,
}
#[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<Self> {
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,
}
}
}

View file

@ -54,3 +54,4 @@ define_id!(TimelineEventId);
define_id!(UserId);
define_id!(TokenId);
define_id!(SessionId);
define_id!(BagId);

View file

@ -68,10 +68,10 @@ pub const MAX_PAGE_SIZE: u32 = 50;
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub struct ListRequest<K: SortKey> {
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<K: SortKey> ListRequest<K> {

View file

@ -1,3 +1,4 @@
pub mod bags;
pub mod ids;
pub mod listing;
pub mod repositories;

View file

@ -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<TimelineEvent, RepositoryError>;
async fn list(
&self,
request: &ListRequest<TimelineSortKey>,
@ -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<Bag, RepositoryError>;
async fn get(&self, id: BagId) -> Result<Bag, RepositoryError>;
async fn list(
&self,
request: &ListRequest<BagSortKey>,
) -> Result<Page<BagWithRoast>, RepositoryError>;
async fn list_by_roast(&self, roast_id: RoastId) -> Result<Vec<BagWithRoast>, RepositoryError>;
async fn update(&self, id: BagId, changes: UpdateBag) -> Result<Bag, RepositoryError>;
async fn delete(&self, id: BagId) -> Result<(), RepositoryError>;
async fn list_open(&self) -> Result<Vec<BagWithRoast>, RepositoryError>;
async fn list_closed(
&self,
request: &ListRequest<BagSortKey>,
) -> Result<Page<BagWithRoast>, RepositoryError>;
}

View file

@ -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<Bag, RepositoryError> {
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<Bag, RepositoryError> {
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<BagSortKey>,
) -> Result<Page<BagWithRoast>, 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<Vec<BagWithRoast>, 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<Bag, RepositoryError> {
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<Vec<BagWithRoast>, 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<BagSortKey>,
) -> Result<Page<BagWithRoast>, 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<NaiveDate>,
amount: f64,
remaining: f64,
closed: bool,
finished_at: Option<NaiveDate>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
#[derive(sqlx::FromRow)]
struct BagWithRoastRecord {
id: i64,
roast_id: i64,
roast_date: Option<NaiveDate>,
amount: f64,
remaining: f64,
closed: bool,
finished_at: Option<NaiveDate>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
roast_name: String,
roast_slug: String,
roaster_name: String,
roaster_slug: String,
}

View file

@ -1,3 +1,4 @@
pub mod bags;
pub mod pagination;
pub mod roasters;
pub mod roasts;

View file

@ -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<TimelineEvent, RepositoryError> {
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<TimelineSortKey>,
@ -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(