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, 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 { let query = r" INSERT INTO timeline_events (entity_type, entity_id, action, occurred_at, title, details_json, tasting_notes_json) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id, entity_type, entity_id, action, 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.action) .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, ) -> 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.action, 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 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 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 LEFT JOIN gear g ON t.entity_type = 'gear' AND t.entity_id = g.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, action: String, 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, action: self.action, occurred_at: self.occurred_at, title: self.title, details, tasting_notes, slug: self.slug, roaster_slug: self.roaster_slug, }) } }