//! HTTP handlers for escalation event management. use crate::{ dto::{ErrorResponse, EscalationEventDto, EscalationListResponse}, AppState, }; use axum::{ extract::{Path, Query, State}, http::StatusCode, Json, }; use serde::Deserialize; use stemedb_storage::EscalationStore; use tracing::instrument; /// Query parameters for listing escalations. #[derive(Debug, Deserialize)] pub struct EscalationQueryParams { /// Only return escalations since this timestamp (nanoseconds). pub since: Option, } /// GET /v1/admin/escalations /// /// List all pending escalation events, or all escalations since a timestamp. #[utoipa::path( get, path = "/v1/admin/escalations", params( ("since" = Option, Query, description = "Only return escalations since this timestamp (nanoseconds)") ), responses( (status = 200, description = "Escalations retrieved successfully", body = EscalationListResponse), (status = 500, description = "Internal server error", body = ErrorResponse) ), tag = "admin" )] #[instrument(skip(state))] pub async fn list_escalations( State(state): State, Query(params): Query, ) -> std::result::Result, (StatusCode, Json)> { let store = &state.escalation_store; let events = match params.since { Some(since) => store.get_escalations_since(since).await.map_err(|e| { tracing::error!(error = %e, "Failed to get escalations since timestamp"); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: "Failed to retrieve escalations".to_string(), code: "ESCALATION_RETRIEVAL_ERROR".to_string(), }), ) })?, None => store.get_pending_escalations().await.map_err(|e| { tracing::error!(error = %e, "Failed to get pending escalations"); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: "Failed to retrieve pending escalations".to_string(), code: "ESCALATION_RETRIEVAL_ERROR".to_string(), }), ) })?, }; let dtos: Vec = events.iter().map(EscalationEventDto::from).collect(); Ok(Json(EscalationListResponse { escalations: dtos, count: events.len() })) } /// POST /v1/admin/escalations/{id}/resolve /// /// Mark an escalation event as resolved. #[utoipa::path( post, path = "/v1/admin/escalations/{id}/resolve", params( ("id" = String, Path, description = "Hex-encoded escalation event ID") ), responses( (status = 200, description = "Escalation resolved successfully"), (status = 404, description = "Escalation not found", body = ErrorResponse), (status = 400, description = "Invalid escalation ID", body = ErrorResponse), (status = 500, description = "Internal server error", body = ErrorResponse) ), tag = "admin" )] #[instrument(skip(state))] pub async fn resolve_escalation( State(state): State, Path(id_hex): Path, ) -> std::result::Result)> { let start = std::time::Instant::now(); metrics::counter!("stemedb_http_requests_total", "method" => "POST", "path" => "/v1/admin/escalations/{id}/resolve").increment(1); let store = &state.escalation_store; // Decode the hex ID let id_bytes = hex::decode(&id_hex).map_err(|_| { ( StatusCode::BAD_REQUEST, Json(ErrorResponse { error: "Invalid escalation ID format".to_string(), code: "INVALID_ESCALATION_ID".to_string(), }), ) })?; if id_bytes.len() != 32 { return Err(( StatusCode::BAD_REQUEST, Json(ErrorResponse { error: "Escalation ID must be 32 bytes".to_string(), code: "INVALID_ESCALATION_ID".to_string(), }), )); } let mut id = [0u8; 32]; id.copy_from_slice(&id_bytes); let resolved = store.resolve_escalation(&id).await.map_err(|e| { tracing::error!(error = %e, id = %id_hex, "Failed to resolve escalation"); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: "Failed to resolve escalation".to_string(), code: "ESCALATION_RESOLVE_ERROR".to_string(), }), ) })?; if resolved { // Track request duration (success case) metrics::histogram!("stemedb_http_request_duration_seconds", "method" => "POST", "path" => "/v1/admin/escalations/{id}/resolve", "status" => "200" ) .record(start.elapsed().as_secs_f64()); Ok(StatusCode::OK) } else { Err(( StatusCode::NOT_FOUND, Json(ErrorResponse { error: "Escalation not found".to_string(), code: "ESCALATION_NOT_FOUND".to_string(), }), )) } }