brewlog/src/infrastructure/repositories/analytics/timeline_events.rs
Jon Seager edc0021792
refactor: split large directories into coffee/auth/analytics subdirs
- Restructure domain/, repositories/, and routes/api/ into themed
  subdirectories: coffee/, auth/, analytics/ (and system/ for routes)
- Convert super:: relative imports to crate:: absolute paths
- Promote macro visibility from pub(super) to pub(crate)
- Add pub use re-exports for backward-compatible import paths
2026-02-08 18:16:16 +00:00

162 lines
5.4 KiB
Rust

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::{
NewTimelineEvent, TimelineBrewData, TimelineEvent, TimelineEventDetail, TimelineSortKey,
};
use crate::infrastructure::database::DatabasePool;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde_json::from_str;
#[derive(Clone)]
pub struct SqlTimelineEventRepository {
pool: DatabasePool,
}
impl SqlTimelineEventRepository {
pub fn new(pool: DatabasePool) -> Self {
Self { pool }
}
}
#[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, action, occurred_at, title, details_json, tasting_notes_json, slug, roaster_slug, brew_data_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING id, entity_type, entity_id, action, occurred_at, title, details_json, tasting_notes_json, slug, roaster_slug, brew_data_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 brew_data_json = event
.brew_data
.as_ref()
.map(serde_json::to_string)
.transpose()
.map_err(|err| {
RepositoryError::unexpected(format!("failed to encode brew data: {err}"))
})?;
let record = sqlx::query_as::<_, TimelineEventRecord>(query)
.bind(event.entity_type)
.bind(event.entity_id)
.bind(event.action)
.bind(event.occurred_at)
.bind(event.title)
.bind(details_json)
.bind(tasting_notes_json)
.bind(event.slug)
.bind(event.roaster_slug)
.bind(brew_data_json)
.fetch_one(&self.pool)
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
record.into_domain()
}
async fn list(
&self,
request: &ListRequest<TimelineSortKey>,
) -> Result<Page<TimelineEvent>, RepositoryError> {
let direction_sql = match request.sort_direction() {
SortDirection::Asc => "ASC",
SortDirection::Desc => "DESC",
};
let order_clause = format!("occurred_at {direction_sql}, id DESC");
// All data is now denormalized in the timeline_events table - no JOINs needed
let base_query = r"SELECT
id, entity_type, entity_id, action, occurred_at, title,
details_json, tasting_notes_json, slug, roaster_slug, brew_data_json
FROM timeline_events";
let count_query = "SELECT COUNT(*) FROM timeline_events";
crate::infrastructure::repositories::pagination::paginate(
&self.pool,
request,
base_query,
count_query,
&order_clause,
None,
|record: TimelineEventRecord| record.into_domain(),
)
.await
}
}
#[derive(sqlx::FromRow)]
struct TimelineEventRecord {
id: i64,
entity_type: String,
entity_id: i64,
action: String,
occurred_at: DateTime<Utc>,
title: String,
details_json: Option<String>,
tasting_notes_json: Option<String>,
slug: Option<String>,
roaster_slug: Option<String>,
brew_data_json: Option<String>,
}
impl TimelineEventRecord {
fn into_domain(self) -> Result<TimelineEvent, RepositoryError> {
let details = match self.details_json {
Some(raw) if !raw.is_empty() => {
from_str::<Vec<TimelineEventDetail>>(&raw).map_err(|err| {
RepositoryError::unexpected(format!(
"failed to decode timeline event details: {err}"
))
})?
}
_ => Vec::new(),
};
let tasting_notes = match self.tasting_notes_json {
Some(raw) if !raw.is_empty() => from_str::<Vec<String>>(&raw).map_err(|err| {
RepositoryError::unexpected(format!(
"failed to decode timeline event tasting notes: {err}"
))
})?,
_ => Vec::new(),
};
let brew_data = match self.brew_data_json {
Some(raw) if !raw.is_empty() => {
Some(from_str::<TimelineBrewData>(&raw).map_err(|err| {
RepositoryError::unexpected(format!("failed to decode brew data: {err}"))
})?)
}
_ => None,
};
Ok(TimelineEvent {
id: TimelineEventId::from(self.id),
entity_type: self.entity_type,
entity_id: self.entity_id,
action: self.action,
occurred_at: self.occurred_at,
title: self.title,
details,
tasting_notes,
slug: self.slug,
roaster_slug: self.roaster_slug,
brew_data,
})
}
}