use axum::{
extract::{Path, Query, State},
http::HeaderMap,
Json,
};
use mockforge_registry_core::models::incident::RaiseIncidentInput;
use serde::Deserialize;
use uuid::Uuid;
use crate::{
error::{ApiError, ApiResult},
middleware::{resolve_org_context, AuthUser},
models::{Incident, IncidentEvent},
AppState,
};
const DEFAULT_LIMIT: i64 = 100;
const MAX_LIMIT: i64 = 500;
#[derive(Debug, Deserialize)]
pub struct ListIncidentsQuery {
#[serde(default)]
pub status: Option<String>,
#[serde(default)]
pub limit: Option<i64>,
}
pub async fn list_incidents(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
Path(org_id): Path<Uuid>,
Query(query): Query<ListIncidentsQuery>,
headers: HeaderMap,
) -> ApiResult<Json<Vec<Incident>>> {
let ctx = resolve_org_context(&state, user_id, &headers, None)
.await
.map_err(|_| ApiError::InvalidRequest("Organization not found".into()))?;
if ctx.org_id != org_id {
return Err(ApiError::InvalidRequest("Cannot read incidents for a different org".into()));
}
let limit = query.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT);
let incidents = Incident::list_by_org(state.db.pool(), org_id, query.status.as_deref(), limit)
.await
.map_err(ApiError::Database)?;
Ok(Json(incidents))
}
#[derive(Debug, Deserialize)]
pub struct ExternalRaiseRequest {
pub source: String,
#[serde(default)]
pub source_ref: Option<String>,
pub dedupe_key: String,
pub severity: String,
pub title: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub workspace_id: Option<Uuid>,
}
pub async fn raise_incident_external(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
Path(org_id): Path<Uuid>,
headers: HeaderMap,
Json(request): Json<ExternalRaiseRequest>,
) -> ApiResult<Json<Incident>> {
let ctx = resolve_org_context(&state, user_id, &headers, None)
.await
.map_err(|_| ApiError::InvalidRequest("Organization not found".into()))?;
if ctx.org_id != org_id {
return Err(ApiError::InvalidRequest("Cannot raise incidents for a different org".into()));
}
if request.source.trim().is_empty() {
return Err(ApiError::InvalidRequest("source must not be empty".into()));
}
if request.dedupe_key.trim().is_empty() {
return Err(ApiError::InvalidRequest("dedupe_key must not be empty".into()));
}
if request.title.trim().is_empty() {
return Err(ApiError::InvalidRequest("title must not be empty".into()));
}
if !is_valid_severity(&request.severity) {
return Err(ApiError::InvalidRequest(
"severity must be 'critical', 'high', 'medium', or 'low'".into(),
));
}
let incident = Incident::raise(
state.db.pool(),
RaiseIncidentInput {
org_id,
workspace_id: request.workspace_id,
source: &request.source,
source_ref: request.source_ref.as_deref(),
dedupe_key: &request.dedupe_key,
severity: &request.severity,
title: &request.title,
description: request.description.as_deref(),
},
)
.await
.map_err(ApiError::Database)?;
Ok(Json(incident))
}
pub async fn get_incident(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
Path(id): Path<Uuid>,
headers: HeaderMap,
) -> ApiResult<Json<Incident>> {
let incident = load_authorized_incident(&state, user_id, &headers, id).await?;
Ok(Json(incident))
}
pub async fn list_incident_events(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
Path(id): Path<Uuid>,
headers: HeaderMap,
) -> ApiResult<Json<Vec<IncidentEvent>>> {
let incident = load_authorized_incident(&state, user_id, &headers, id).await?;
let events = Incident::list_events(state.db.pool(), incident.id)
.await
.map_err(ApiError::Database)?;
Ok(Json(events))
}
pub async fn acknowledge_incident(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
Path(id): Path<Uuid>,
headers: HeaderMap,
) -> ApiResult<Json<Incident>> {
load_authorized_incident(&state, user_id, &headers, id).await?;
let updated = Incident::acknowledge(state.db.pool(), id, user_id)
.await
.map_err(ApiError::Database)?
.ok_or_else(|| ApiError::InvalidRequest("Incident not found".into()))?;
Ok(Json(updated))
}
pub async fn resolve_incident(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
Path(id): Path<Uuid>,
headers: HeaderMap,
) -> ApiResult<Json<Incident>> {
load_authorized_incident(&state, user_id, &headers, id).await?;
let updated = Incident::resolve(state.db.pool(), id, user_id)
.await
.map_err(ApiError::Database)?
.ok_or_else(|| ApiError::InvalidRequest("Incident not found".into()))?;
Ok(Json(updated))
}
async fn load_authorized_incident(
state: &AppState,
user_id: Uuid,
headers: &HeaderMap,
id: Uuid,
) -> ApiResult<Incident> {
let incident = Incident::find_by_id(state.db.pool(), id)
.await
.map_err(ApiError::Database)?
.ok_or_else(|| ApiError::InvalidRequest("Incident not found".into()))?;
let ctx = resolve_org_context(state, user_id, headers, None)
.await
.map_err(|_| ApiError::InvalidRequest("Organization not found".into()))?;
if ctx.org_id != incident.org_id {
return Err(ApiError::InvalidRequest("Incident not found".into()));
}
Ok(incident)
}
fn is_valid_severity(s: &str) -> bool {
matches!(s, "critical" | "high" | "medium" | "low")
}
#[derive(Debug, serde::Serialize)]
pub struct IncidentStats {
pub open: SeverityBreakdown,
pub resolved_30d: SeverityBreakdown,
pub mttr_seconds_30d: Option<i64>,
pub notification_attempts_24h: i64,
}
#[derive(Debug, Default, serde::Serialize)]
pub struct SeverityBreakdown {
pub total: i64,
pub critical: i64,
pub high: i64,
pub medium: i64,
pub low: i64,
}
pub async fn get_stats(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
Path(org_id): Path<Uuid>,
headers: HeaderMap,
) -> ApiResult<Json<IncidentStats>> {
let ctx = resolve_org_context(&state, user_id, &headers, None)
.await
.map_err(|_| ApiError::InvalidRequest("Organization not found".into()))?;
if ctx.org_id != org_id {
return Err(ApiError::InvalidRequest("Cannot read stats for a different org".into()));
}
let pool = state.db.pool();
let open_rows: Vec<(String, i64)> = sqlx::query_as(
"SELECT severity, COUNT(*) FROM incidents \
WHERE org_id = $1 AND status = 'open' GROUP BY severity",
)
.bind(org_id)
.fetch_all(pool)
.await
.map_err(ApiError::Database)?;
let open = breakdown_from_rows(&open_rows);
let resolved_rows: Vec<(String, i64)> = sqlx::query_as(
"SELECT severity, COUNT(*) FROM incidents \
WHERE org_id = $1 AND status = 'resolved' \
AND resolved_at >= NOW() - INTERVAL '30 days' GROUP BY severity",
)
.bind(org_id)
.fetch_all(pool)
.await
.map_err(ApiError::Database)?;
let resolved_30d = breakdown_from_rows(&resolved_rows);
let mttr: Option<f64> = sqlx::query_scalar(
"SELECT AVG(EXTRACT(EPOCH FROM (resolved_at - created_at))) \
FROM incidents \
WHERE org_id = $1 AND status = 'resolved' \
AND resolved_at IS NOT NULL \
AND resolved_at >= NOW() - INTERVAL '30 days'",
)
.bind(org_id)
.fetch_one(pool)
.await
.map_err(ApiError::Database)?;
let mttr_seconds_30d = mttr.map(|s| s as i64);
let attempts: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM incident_events e \
JOIN incidents i ON i.id = e.incident_id \
WHERE i.org_id = $1 \
AND e.event_type = 'notification_sent' \
AND e.created_at >= NOW() - INTERVAL '24 hours'",
)
.bind(org_id)
.fetch_one(pool)
.await
.map_err(ApiError::Database)?;
Ok(Json(IncidentStats {
open,
resolved_30d,
mttr_seconds_30d,
notification_attempts_24h: attempts,
}))
}
fn breakdown_from_rows(rows: &[(String, i64)]) -> SeverityBreakdown {
let mut b = SeverityBreakdown::default();
for (sev, n) in rows {
b.total += n;
match sev.as_str() {
"critical" => b.critical = *n,
"high" => b.high = *n,
"medium" => b.medium = *n,
"low" => b.low = *n,
_ => {}
}
}
b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn severity_validator_accepts_canonical_values() {
assert!(is_valid_severity("critical"));
assert!(is_valid_severity("high"));
assert!(is_valid_severity("medium"));
assert!(is_valid_severity("low"));
}
#[test]
fn severity_validator_rejects_other_values() {
assert!(!is_valid_severity("urgent"));
assert!(!is_valid_severity("CRITICAL"));
assert!(!is_valid_severity(""));
assert!(!is_valid_severity("warning"));
}
}