//! History DTOs for API requests and responses //! //! These types represent the API-facing view of entity history records //! stored in TimescaleDB hypertables. use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use serde_json::Value as JsonValue; use utoipa::{IntoParams, ToSchema}; use attune_common::models::entity_history::HistoryEntityType; /// Response DTO for a single entity history record. #[derive(Debug, Clone, Serialize, ToSchema)] pub struct HistoryRecordResponse { /// When the change occurred #[schema(example = "2026-02-26T10:30:00Z")] pub time: DateTime, /// The operation: `INSERT`, `UPDATE`, or `DELETE` #[schema(example = "UPDATE")] pub operation: String, /// The primary key of the changed entity #[schema(example = 42)] pub entity_id: i64, /// Denormalized human-readable identifier (e.g., action_ref, worker name) #[schema(example = "core.http_request")] pub entity_ref: Option, /// Names of fields that changed (empty for INSERT/DELETE) #[schema(example = json!(["status", "result"]))] pub changed_fields: Vec, /// Previous values of changed fields (null for INSERT) #[schema(value_type = Object, example = json!({"status": "requested"}))] pub old_values: Option, /// New values of changed fields (null for DELETE) #[schema(value_type = Object, example = json!({"status": "running"}))] pub new_values: Option, } impl From for HistoryRecordResponse { fn from(record: attune_common::models::entity_history::EntityHistoryRecord) -> Self { Self { time: record.time, operation: record.operation, entity_id: record.entity_id, entity_ref: record.entity_ref, changed_fields: record.changed_fields, old_values: record.old_values, new_values: record.new_values, } } } /// Query parameters for filtering history records. #[derive(Debug, Clone, Deserialize, IntoParams)] pub struct HistoryQueryParams { /// Filter by entity ID #[param(example = 42)] pub entity_id: Option, /// Filter by entity ref (e.g., action_ref, worker name) #[param(example = "core.http_request")] pub entity_ref: Option, /// Filter by operation type: `INSERT`, `UPDATE`, or `DELETE` #[param(example = "UPDATE")] pub operation: Option, /// Only include records where this field was changed #[param(example = "status")] pub changed_field: Option, /// Only include records at or after this time (ISO 8601) #[param(example = "2026-02-01T00:00:00Z")] pub since: Option>, /// Only include records at or before this time (ISO 8601) #[param(example = "2026-02-28T23:59:59Z")] pub until: Option>, /// Page number (1-based) #[serde(default = "default_page")] #[param(example = 1, minimum = 1)] pub page: u32, /// Number of items per page #[serde(default = "default_page_size")] #[param(example = 50, minimum = 1, maximum = 1000)] pub page_size: u32, } fn default_page() -> u32 { 1 } fn default_page_size() -> u32 { 50 } impl HistoryQueryParams { /// Convert to the repository-level query params. pub fn to_repo_params( &self, ) -> attune_common::repositories::entity_history::HistoryQueryParams { let limit = (self.page_size.min(1000).max(1)) as i64; let offset = ((self.page.saturating_sub(1)) as i64) * limit; attune_common::repositories::entity_history::HistoryQueryParams { entity_id: self.entity_id, entity_ref: self.entity_ref.clone(), operation: self.operation.clone(), changed_field: self.changed_field.clone(), since: self.since, until: self.until, limit: Some(limit), offset: Some(offset), } } } /// Path parameter for the entity type segment. #[derive(Debug, Clone, Deserialize, IntoParams)] pub struct HistoryEntityTypePath { /// Entity type: `execution` or `worker` pub entity_type: String, } impl HistoryEntityTypePath { /// Parse the entity type string, returning a typed enum or an error message. pub fn parse(&self) -> Result { self.entity_type.parse::() } } /// Path parameters for entity-specific history (e.g., `/executions/42/history`). #[derive(Debug, Clone, Deserialize, IntoParams)] pub struct EntityIdPath { /// The entity's primary key pub id: i64, } #[cfg(test)] mod tests { use super::*; #[test] fn test_query_params_defaults() { let json = r#"{}"#; let params: HistoryQueryParams = serde_json::from_str(json).unwrap(); assert_eq!(params.page, 1); assert_eq!(params.page_size, 50); assert!(params.entity_id.is_none()); assert!(params.operation.is_none()); } #[test] fn test_query_params_to_repo_params() { let params = HistoryQueryParams { entity_id: Some(42), entity_ref: None, operation: Some("UPDATE".to_string()), changed_field: Some("status".to_string()), since: None, until: None, page: 3, page_size: 20, }; let repo = params.to_repo_params(); assert_eq!(repo.entity_id, Some(42)); assert_eq!(repo.operation, Some("UPDATE".to_string())); assert_eq!(repo.changed_field, Some("status".to_string())); assert_eq!(repo.limit, Some(20)); assert_eq!(repo.offset, Some(40)); // (3-1) * 20 } #[test] fn test_query_params_page_size_cap() { let params = HistoryQueryParams { entity_id: None, entity_ref: None, operation: None, changed_field: None, since: None, until: None, page: 1, page_size: 5000, }; let repo = params.to_repo_params(); assert_eq!(repo.limit, Some(1000)); } #[test] fn test_entity_type_path_parse() { let path = HistoryEntityTypePath { entity_type: "execution".to_string(), }; assert_eq!(path.parse().unwrap(), HistoryEntityType::Execution); let path = HistoryEntityTypePath { entity_type: "unknown".to_string(), }; assert!(path.parse().is_err()); } }