brewlog/src/infrastructure/repositories/roasts.rs
copilot-swe-agent[bot] e710c6dc62
refactor: improve code quality and add comprehensive documentation
Code Quality Improvements:
- Fix hex literal grouping in ID generator (0xB10C_1D -> 0x00B1_0C1D)
- Rename ListQuery::default() to default_query() to avoid confusion with Default trait
- Use div_ceil() instead of manual ceiling division
- Remove unnecessary borrows in auth token generation and hashing
- Simplify nested if statements in error handling

Documentation:
- Add comprehensive authentication section to README
- Document environment variables for server and CLI
- Add security best practices and considerations
- Document password hashing (Argon2id), token storage (SHA-256), and session management
- Include step-by-step authentication setup guide
- Add production deployment recommendations

All 70 tests pass (8 unit + 46 server + 16 CLI) 

Co-authored-by: jnsgruk <668505+jnsgruk@users.noreply.github.com>
2025-11-25 16:28:51 +00:00

479 lines
15 KiB
Rust

use async_trait::async_trait;
use chrono::{DateTime, Utc};
use sqlx::{Error as SqlxError, QueryBuilder, query, query_as, query_scalar};
use crate::domain::RepositoryError;
use crate::domain::ids::generate_id;
use crate::domain::listing::{ListRequest, Page, PageSize, SortDirection};
use crate::domain::repositories::RoastRepository;
use crate::domain::roasts::{Roast, RoastSortKey, RoastWithRoaster, UpdateRoast};
use crate::domain::timeline::TimelineEventDetail;
use crate::infrastructure::database::DatabasePool;
#[derive(Clone)]
pub struct SqlRoastRepository {
pool: DatabasePool,
}
impl SqlRoastRepository {
pub fn new(pool: DatabasePool) -> Self {
Self { pool }
}
fn to_domain(record: RoastRecord) -> Result<Roast, RepositoryError> {
let RoastRecord {
id,
roaster_id,
name,
origin,
region,
producer,
process,
tasting_notes,
created_at,
} = record;
let tasting_notes = match tasting_notes {
Some(raw) if !raw.is_empty() => serde_json::from_str(&raw).map_err(|err| {
RepositoryError::unexpected(format!("failed to decode tasting notes: {err}"))
})?,
_ => Vec::new(),
};
Ok(Roast {
id,
roaster_id,
name,
origin,
region,
producer,
tasting_notes,
process,
created_at,
})
}
fn to_with_roaster(
record: RoastWithRoasterRecord,
) -> Result<RoastWithRoaster, RepositoryError> {
let RoastWithRoasterRecord {
id,
roaster_id,
name,
origin,
region,
producer,
process,
tasting_notes,
created_at,
roaster_name,
} = record;
let roast = Self::to_domain(RoastRecord {
id,
roaster_id,
name,
origin,
region,
producer,
process,
tasting_notes,
created_at,
})?;
Ok(RoastWithRoaster {
roast,
roaster_name,
})
}
async fn get_record(&self, id: &str) -> Result<Roast, RepositoryError> {
let record = query_as::<_, RoastRecord>(
"SELECT id, roaster_id, name, origin, region, producer, process, tasting_notes, created_at FROM roasts WHERE id = ?",
)
.bind(id)
.fetch_optional(&self.pool)
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
let Some(record) = record else {
return Err(RepositoryError::NotFound);
};
Self::to_domain(record)
}
fn encode_notes(notes: &[String]) -> Result<Option<String>, RepositoryError> {
if notes.is_empty() {
Ok(None)
} else {
serde_json::to_string(notes).map(Some).map_err(|err| {
RepositoryError::unexpected(format!("failed to encode tasting notes: {err}"))
})
}
}
}
fn roast_order_clause(request: &ListRequest<RoastSortKey>) -> String {
let dir_sql = match request.sort_direction() {
SortDirection::Asc => "ASC",
SortDirection::Desc => "DESC",
};
match request.sort_key() {
RoastSortKey::CreatedAt => format!("r.created_at {dir_sql}, LOWER(r.name) ASC"),
RoastSortKey::Name => format!("LOWER(r.name) {dir_sql}, r.created_at DESC"),
RoastSortKey::Roaster => format!("LOWER(ro.name) {dir_sql}, r.created_at DESC"),
RoastSortKey::Origin => {
format!("LOWER(COALESCE(r.origin, '')) {dir_sql}, r.created_at DESC")
}
RoastSortKey::Producer => {
format!("LOWER(COALESCE(r.producer, '')) {dir_sql}, r.created_at DESC")
}
}
}
#[async_trait]
impl RoastRepository for SqlRoastRepository {
async fn insert(&self, roast: Roast) -> Result<Roast, RepositoryError> {
let mut tx = self
.pool
.begin()
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
let notes = Self::encode_notes(&roast.tasting_notes)?;
query(
"INSERT INTO roasts (id, roaster_id, name, origin, region, producer, process, tasting_notes, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&roast.id)
.bind(&roast.roaster_id)
.bind(&roast.name)
.bind(&roast.origin)
.bind(&roast.region)
.bind(&roast.producer)
.bind(&roast.process)
.bind(notes.as_deref())
.bind(roast.created_at)
.execute(&mut *tx)
.await
.map_err(|err| map_insert_error(err, "unknown roaster reference"))?;
let roaster_name: Option<String> =
sqlx::query_scalar("SELECT name FROM roasters WHERE id = ?")
.bind(&roast.roaster_id)
.fetch_optional(&mut *tx)
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
let roaster_label = roaster_name.unwrap_or_else(|| "Unknown roaster".to_string());
let details = vec![
TimelineEventDetail {
label: "Roaster".to_string(),
value: roaster_label.clone(),
},
TimelineEventDetail {
label: "Origin".to_string(),
value: roast.origin.clone().unwrap_or_else(|| "".to_string()),
},
TimelineEventDetail {
label: "Region".to_string(),
value: roast.region.clone().unwrap_or_else(|| "".to_string()),
},
TimelineEventDetail {
label: "Producer".to_string(),
value: roast.producer.clone().unwrap_or_else(|| "".to_string()),
},
TimelineEventDetail {
label: "Process".to_string(),
value: roast.process.clone().unwrap_or_else(|| "".to_string()),
},
];
let details_json = serde_json::to_string(&details).map_err(|err| {
RepositoryError::unexpected(format!("failed to encode timeline event details: {err}"))
})?;
let tasting_notes_json = if roast.tasting_notes.is_empty() {
None
} else {
Some(serde_json::to_string(&roast.tasting_notes).map_err(|err| {
RepositoryError::unexpected(format!(
"failed to encode timeline event tasting notes: {err}"
))
})?)
};
sqlx::query("INSERT INTO timeline_events (id, entity_type, entity_id, occurred_at, title, details_json, tasting_notes_json) VALUES (?, ?, ?, ?, ?, ?, ?)")
.bind(generate_id())
.bind("roast")
.bind(&roast.id)
.bind(roast.created_at)
.bind(&roast.name)
.bind(details_json)
.bind(tasting_notes_json)
.execute(&mut *tx)
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
tx.commit()
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
Ok(roast)
}
async fn get(&self, id: String) -> Result<Roast, RepositoryError> {
self.get_record(&id).await
}
async fn list(
&self,
request: &ListRequest<RoastSortKey>,
) -> Result<Page<RoastWithRoaster>, RepositoryError> {
let order_clause = roast_order_clause(request);
match request.page_size() {
PageSize::All => {
let query = format!(
"SELECT r.id, r.roaster_id, r.name, r.origin, r.region, r.producer, r.process, r.tasting_notes, r.created_at, ro.name AS roaster_name \
FROM roasts r \
JOIN roasters ro ON ro.id = r.roaster_id \
ORDER BY {}",
order_clause
);
let records = query_as::<_, RoastWithRoasterRecord>(&query)
.fetch_all(&self.pool)
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
let items = records
.into_iter()
.map(Self::to_with_roaster)
.collect::<Result<Vec<_>, _>>()?;
let total = items.len() as u64;
let page_size = total.min(u64::from(u32::MAX)) as u32;
Ok(Page::new(items, 1, page_size.max(1), total, true))
}
PageSize::Limited(page_size) => {
let page_size_i64 = page_size as i64;
let mut page = request.page();
let offset = ((page - 1) as i64).saturating_mul(page_size_i64);
let query = format!(
"SELECT r.id, r.roaster_id, r.name, r.origin, r.region, r.producer, r.process, r.tasting_notes, r.created_at, ro.name AS roaster_name \
FROM roasts r \
JOIN roasters ro ON ro.id = r.roaster_id \
ORDER BY {} \
LIMIT ? OFFSET ?",
order_clause
);
let mut records = query_as::<_, RoastWithRoasterRecord>(&query)
.bind(page_size_i64)
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
let total: i64 = query_scalar::<_, i64>("SELECT COUNT(*) FROM roasts")
.fetch_one(&self.pool)
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
if page > 1 && records.is_empty() && total > 0 {
let last_page = ((total + page_size_i64 - 1) / page_size_i64) as u32;
page = last_page.max(1);
let offset = ((page - 1) as i64).saturating_mul(page_size_i64);
records = query_as::<_, RoastWithRoasterRecord>(&query)
.bind(page_size_i64)
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
}
let items = records
.into_iter()
.map(Self::to_with_roaster)
.collect::<Result<Vec<_>, _>>()?;
Ok(Page::new(items, page, page_size, total as u64, false))
}
}
}
async fn list_by_roaster(
&self,
roaster_id: String,
) -> Result<Vec<RoastWithRoaster>, RepositoryError> {
let records = query_as::<_, RoastWithRoasterRecord>(
"SELECT r.id, r.roaster_id, r.name, r.origin, r.region, r.producer, r.process, r.tasting_notes, r.created_at, ro.name AS roaster_name \
FROM roasts r \
JOIN roasters ro ON ro.id = r.roaster_id \
WHERE r.roaster_id = ? \
ORDER BY r.created_at DESC",
)
.bind(roaster_id)
.fetch_all(&self.pool)
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
records.into_iter().map(Self::to_with_roaster).collect()
}
async fn update(&self, id: String, changes: UpdateRoast) -> Result<Roast, RepositoryError> {
let mut tx = self
.pool
.begin()
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
let UpdateRoast {
roaster_id,
name,
origin,
region,
producer,
tasting_notes,
process,
} = changes;
let mut builder = QueryBuilder::new("UPDATE roasts SET ");
let mut updated = false;
if let Some(roaster_id) = roaster_id {
if updated {
builder.push(", ");
}
updated = true;
builder.push("roaster_id = ");
builder.push_bind(roaster_id);
}
if let Some(name) = name {
if updated {
builder.push(", ");
}
updated = true;
builder.push("name = ");
builder.push_bind(name);
}
if let Some(origin) = origin {
if updated {
builder.push(", ");
}
updated = true;
builder.push("origin = ");
builder.push_bind(origin);
}
if let Some(region) = region {
if updated {
builder.push(", ");
}
updated = true;
builder.push("region = ");
builder.push_bind(region);
}
if let Some(producer) = producer {
if updated {
builder.push(", ");
}
updated = true;
builder.push("producer = ");
builder.push_bind(producer);
}
if let Some(process) = process {
if updated {
builder.push(", ");
}
updated = true;
builder.push("process = ");
builder.push_bind(process);
}
if let Some(tasting_notes) = tasting_notes {
let notes = Self::encode_notes(&tasting_notes)?;
if updated {
builder.push(", ");
}
updated = true;
builder.push("tasting_notes = ");
builder.push_bind(notes);
}
if updated {
builder.push(" WHERE id = ");
builder.push_bind(&id);
let result = builder
.build()
.execute(&mut *tx)
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
if result.rows_affected() == 0 {
return Err(RepositoryError::NotFound);
}
}
tx.commit()
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
self.get_record(&id).await
}
async fn delete(&self, id: String) -> Result<(), RepositoryError> {
let result = query("DELETE FROM roasts WHERE id = ?")
.bind(&id)
.execute(&self.pool)
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
if result.rows_affected() == 0 {
return Err(RepositoryError::NotFound);
}
Ok(())
}
}
fn map_insert_error(err: SqlxError, message: &'static str) -> RepositoryError {
if let SqlxError::Database(db_err) = &err
&& db_err.code().as_deref() == Some("787")
{
return RepositoryError::conflict(message);
}
RepositoryError::unexpected(err.to_string())
}
#[derive(sqlx::FromRow)]
struct RoastRecord {
id: String,
roaster_id: String,
name: String,
origin: Option<String>,
region: Option<String>,
producer: Option<String>,
process: Option<String>,
tasting_notes: Option<String>,
created_at: DateTime<Utc>,
}
#[derive(sqlx::FromRow)]
struct RoastWithRoasterRecord {
id: String,
roaster_id: String,
name: String,
origin: Option<String>,
region: Option<String>,
producer: Option<String>,
process: Option<String>,
tasting_notes: Option<String>,
created_at: DateTime<Utc>,
roaster_name: String,
}