1use axum::{
10 extract::{Query, State},
11 http::StatusCode,
12 response::Json,
13};
14use serde::{Deserialize, Serialize};
15use std::sync::Arc;
16
17#[cfg(feature = "database")]
18use chrono::{DateTime, Utc};
19
20#[derive(Clone)]
22pub struct ContractHealthState {
23 pub incident_manager: Arc<mockforge_core::incidents::IncidentManager>,
25 pub semantic_manager: Arc<mockforge_core::incidents::SemanticIncidentManager>,
27 #[cfg(feature = "database")]
29 pub database: Option<crate::database::Database>,
30}
31
32#[derive(Debug, Deserialize)]
34pub struct TimelineQuery {
35 pub workspace_id: Option<String>,
37 pub endpoint: Option<String>,
39 pub method: Option<String>,
41 pub start_date: Option<String>,
43 pub end_date: Option<String>,
45 pub event_type: Option<String>,
47 pub limit: Option<usize>,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
53#[serde(tag = "type")]
54pub enum TimelineEvent {
55 #[serde(rename = "structural_drift")]
57 StructuralDrift {
58 id: String,
60 endpoint: String,
62 method: String,
64 incident_type: String,
66 severity: String,
68 status: String,
70 detected_at: i64,
72 details: serde_json::Value,
74 },
75 #[serde(rename = "semantic_drift")]
77 SemanticDrift {
78 id: String,
80 endpoint: String,
82 method: String,
84 change_type: String,
86 severity: String,
88 status: String,
90 semantic_confidence: f64,
92 soft_breaking_score: f64,
94 detected_at: i64,
96 details: serde_json::Value,
98 },
99 #[serde(rename = "threat_assessment")]
101 ThreatAssessment {
102 id: String,
104 endpoint: Option<String>,
106 method: Option<String>,
108 threat_level: String,
110 threat_score: f64,
112 assessed_at: i64,
114 findings_count: usize,
116 },
117 #[serde(rename = "forecast")]
119 Forecast {
120 id: String,
122 endpoint: String,
124 method: String,
126 window_days: u32,
128 change_probability: f64,
130 break_probability: f64,
132 next_expected_change: Option<i64>,
134 confidence: f64,
136 predicted_at: i64,
138 },
139}
140
141#[derive(Debug, Serialize)]
143pub struct TimelineResponse {
144 pub events: Vec<TimelineEvent>,
146 pub total: usize,
148}
149
150pub async fn get_timeline(
154 State(state): State<ContractHealthState>,
155 Query(params): Query<TimelineQuery>,
156) -> Result<Json<TimelineResponse>, StatusCode> {
157 let mut events = Vec::new();
158
159 let event_type_filter = params.event_type.as_deref().unwrap_or("all");
160
161 if event_type_filter == "all" || event_type_filter == "structural" {
163 let query = mockforge_core::incidents::types::IncidentQuery {
164 workspace_id: params.workspace_id.clone(),
165 endpoint: params.endpoint.clone(),
166 method: params.method.clone(),
167 ..mockforge_core::incidents::types::IncidentQuery::default()
168 };
169
170 let incidents = state.incident_manager.query_incidents(query).await;
171 for incident in incidents {
172 events.push(TimelineEvent::StructuralDrift {
173 id: incident.id,
174 endpoint: incident.endpoint,
175 method: incident.method,
176 incident_type: format!("{:?}", incident.incident_type),
177 severity: format!("{:?}", incident.severity),
178 status: format!("{:?}", incident.status),
179 detected_at: incident.detected_at,
180 details: incident.details,
181 });
182 }
183 }
184
185 if event_type_filter == "all" || event_type_filter == "semantic" {
187 let status = None; let semantic_incidents = state
189 .semantic_manager
190 .list_incidents(
191 params.workspace_id.as_deref(),
192 params.endpoint.as_deref(),
193 params.method.as_deref(),
194 status,
195 params.limit,
196 )
197 .await;
198
199 for incident in semantic_incidents {
200 events.push(TimelineEvent::SemanticDrift {
201 id: incident.id,
202 endpoint: incident.endpoint,
203 method: incident.method,
204 change_type: format!("{:?}", incident.semantic_change_type),
205 severity: format!("{:?}", incident.severity),
206 status: format!("{:?}", incident.status),
207 semantic_confidence: incident.semantic_confidence,
208 soft_breaking_score: incident.soft_breaking_score,
209 detected_at: incident.detected_at,
210 details: incident.details,
211 });
212 }
213 }
214
215 #[cfg(feature = "database")]
217 {
218 use sqlx::Row;
219 if let Some(pool) = state.database.as_ref().and_then(|db| db.pool()) {
220 if let Ok(ta_rows) = sqlx::query(
222 "SELECT id, workspace_id, service_id, service_name, endpoint, method, aggregation_level,
223 threat_level, threat_score, threat_categories, findings, remediation_suggestions, assessed_at
224 FROM contract_threat_assessments
225 WHERE workspace_id = $1 OR workspace_id IS NULL
226 ORDER BY assessed_at DESC LIMIT 50"
227 )
228 .bind(params.workspace_id.as_deref())
229 .fetch_all(pool)
230 .await
231 {
232 use mockforge_foundation::threat_modeling_types::ThreatLevel;
233 for row in ta_rows {
234 let id: uuid::Uuid = match row.try_get("id") {
235 Ok(id) => id,
236 Err(_) => continue,
237 };
238 let threat_level_str: String = match row.try_get("threat_level") {
239 Ok(s) => s,
240 Err(_) => continue,
241 };
242 let threat_score: f64 = match row.try_get("threat_score") {
243 Ok(s) => s,
244 Err(_) => continue,
245 };
246 let assessed_at: DateTime<Utc> = match row.try_get("assessed_at") {
247 Ok(dt) => dt,
248 Err(_) => continue,
249 };
250 let endpoint: Option<String> = row.try_get("endpoint").ok();
251 let method: Option<String> = row.try_get("method").ok();
252
253 let threat_level = match threat_level_str.as_str() {
254 "low" => ThreatLevel::Low,
255 "medium" => ThreatLevel::Medium,
256 "high" => ThreatLevel::High,
257 "critical" => ThreatLevel::Critical,
258 _ => continue,
259 };
260
261 let findings_count = row.try_get::<serde_json::Value, _>("findings")
263 .ok()
264 .and_then(|v| v.as_array().map(|arr| arr.len()))
265 .unwrap_or(0);
266
267 events.push(TimelineEvent::ThreatAssessment {
268 id: id.to_string(),
269 endpoint,
270 method,
271 threat_level: format!("{:?}", threat_level),
272 threat_score,
273 assessed_at: assessed_at.timestamp(),
274 findings_count,
275 });
276 }
277 }
278
279 if let Ok(forecast_rows) = sqlx::query(
281 "SELECT id, service_id, service_name, endpoint, method, forecast_window_days,
282 predicted_change_probability, predicted_break_probability, next_expected_change_date,
283 confidence, predicted_at
284 FROM api_change_forecasts
285 WHERE workspace_id = $1 OR workspace_id IS NULL
286 ORDER BY predicted_at DESC LIMIT 50",
287 )
288 .bind(params.workspace_id.as_deref())
289 .fetch_all(pool)
290 .await
291 {
292 use sqlx::Row;
293 for row in forecast_rows {
294 let id: uuid::Uuid = match row.try_get("id") {
295 Ok(id) => id,
296 Err(_) => continue,
297 };
298 let endpoint: String = match row.try_get("endpoint") {
299 Ok(e) => e,
300 Err(_) => continue,
301 };
302 let method: String = match row.try_get("method") {
303 Ok(m) => m,
304 Err(_) => continue,
305 };
306 let forecast_window_days: i32 = match row.try_get("forecast_window_days") {
307 Ok(d) => d,
308 Err(_) => continue,
309 };
310 let predicted_change_probability: f64 =
311 match row.try_get("predicted_change_probability") {
312 Ok(p) => p,
313 Err(_) => continue,
314 };
315 let predicted_break_probability: f64 =
316 match row.try_get("predicted_break_probability") {
317 Ok(p) => p,
318 Err(_) => continue,
319 };
320 let next_expected_change_date: Option<DateTime<Utc>> =
321 row.try_get("next_expected_change_date").ok();
322 let predicted_at: DateTime<Utc> = match row.try_get("predicted_at") {
323 Ok(dt) => dt,
324 Err(_) => continue,
325 };
326 let confidence: f64 = match row.try_get("confidence") {
327 Ok(c) => c,
328 Err(_) => continue,
329 };
330
331 events.push(TimelineEvent::Forecast {
332 id: id.to_string(),
333 endpoint,
334 method,
335 window_days: forecast_window_days as u32,
336 change_probability: predicted_change_probability,
337 break_probability: predicted_break_probability,
338 next_expected_change: next_expected_change_date.map(|d| d.timestamp()),
339 confidence,
340 predicted_at: predicted_at.timestamp(),
341 });
342 }
343 }
344 }
345 }
346
347 events.sort_by_key(|e| {
349 std::cmp::Reverse(match e {
350 TimelineEvent::StructuralDrift { detected_at, .. } => *detected_at,
351 TimelineEvent::SemanticDrift { detected_at, .. } => *detected_at,
352 TimelineEvent::ThreatAssessment { assessed_at, .. } => *assessed_at,
353 TimelineEvent::Forecast { predicted_at, .. } => *predicted_at,
354 })
355 });
356
357 let total = events.len();
359 if let Some(limit) = params.limit {
360 events.truncate(limit);
361 }
362
363 Ok(Json(TimelineResponse { events, total }))
364}
365
366pub fn contract_health_router(state: ContractHealthState) -> axum::Router {
368 use axum::routing::get;
369 use axum::Router;
370
371 Router::new()
372 .route("/api/v1/contract-health/timeline", get(get_timeline))
373 .with_state(state)
374}