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::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 list( &self, request: &ListRequest, ) -> Result, RepositoryError> { let direction_sql = match request.sort_direction() { SortDirection::Asc => "ASC", SortDirection::Desc => "DESC", }; let order_clause = format!("t.occurred_at {direction_sql}, t.id DESC"); let base_query = "SELECT t.id, t.entity_type, t.entity_id, t.occurred_at, t.title, t.details_json, t.tasting_notes_json, CASE WHEN t.entity_type = 'roaster' THEN r.slug WHEN t.entity_type = 'roast' THEN rst.slug ELSE NULL END as slug, CASE WHEN t.entity_type = 'roast' THEN rst_r.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"; let count_query = "SELECT COUNT(*) FROM timeline_events"; crate::infrastructure::repositories::pagination::paginate( &self.pool, request, base_query, count_query, &order_clause, |record: TimelineEventRecord| record.into_domain(), ) .await } } #[derive(sqlx::FromRow)] struct TimelineEventRecord { id: i64, entity_type: String, entity_id: i64, occurred_at: DateTime, title: String, details_json: Option, tasting_notes_json: Option, slug: Option, roaster_slug: Option, } impl TimelineEventRecord { fn into_domain(self) -> Result { let details = match self.details_json { Some(raw) if !raw.is_empty() => { from_str::>(&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::>(&raw).map_err(|err| { RepositoryError::unexpected(format!( "failed to decode timeline event tasting notes: {err}" )) })?, _ => Vec::new(), }; Ok(TimelineEvent { id: TimelineEventId::from(self.id), entity_type: self.entity_type, entity_id: self.entity_id, occurred_at: self.occurred_at, title: self.title, details, tasting_notes, slug: self.slug, roaster_slug: self.roaster_slug, }) } }