feat: add bags domain and repositories
This commit is contained in:
parent
02824a90f1
commit
14c3079600
11 changed files with 501 additions and 10 deletions
|
|
@ -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,
|
||||
|
|
|
|||
15
migrations/0004_add_bags.sql
Normal file
15
migrations/0004_add_bags.sql
Normal 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);
|
||||
|
|
@ -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
85
src/domain/bags.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -54,3 +54,4 @@ define_id!(TimelineEventId);
|
|||
define_id!(UserId);
|
||||
define_id!(TokenId);
|
||||
define_id!(SessionId);
|
||||
define_id!(BagId);
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
pub mod bags;
|
||||
pub mod ids;
|
||||
pub mod listing;
|
||||
pub mod repositories;
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
324
src/infrastructure/repositories/bags.rs
Normal file
324
src/infrastructure/repositories/bags.rs
Normal 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,
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
pub mod bags;
|
||||
pub mod pagination;
|
||||
pub mod roasters;
|
||||
pub mod roasts;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue