feat: Add wifi-Mat disaster detection enhancements
Implement 6 optional enhancements for the wifi-Mat module: 1. Hardware Integration (csi_receiver.rs + hardware_adapter.rs) - ESP32 CSI support via serial/UDP - Intel 5300 BFEE file parsing - Atheros CSI Tool integration - Live UDP packet streaming - PCAP replay capability 2. CLI Commands (wifi-densepose-cli/src/mat.rs) - `wifi-mat scan` - Run disaster detection scan - `wifi-mat status` - Check event status - `wifi-mat zones` - Manage scan zones - `wifi-mat survivors` - List detected survivors - `wifi-mat alerts` - View and acknowledge alerts - `wifi-mat export` - Export data in various formats 3. REST API (wifi-densepose-mat/src/api/) - Full CRUD for disaster events - Zone management endpoints - Survivor and alert queries - WebSocket streaming for real-time updates - Comprehensive DTOs and error handling 4. WASM Build (wifi-densepose-wasm/src/mat.rs) - Browser-based disaster dashboard - Real-time survivor tracking - Zone visualization - Alert management - JavaScript API bindings 5. Detection Benchmarks (benches/detection_bench.rs) - Single survivor detection - Multi-survivor detection - Full pipeline benchmarks - Signal processing benchmarks - Hardware adapter benchmarks 6. ML Models for Debris Penetration (ml/) - DebrisModel for material analysis - VitalSignsClassifier for triage - FFT-based feature extraction - Bandpass filtering - Monte Carlo dropout for uncertainty All 134 unit tests pass. Compilation verified for: - wifi-densepose-mat - wifi-densepose-cli - wifi-densepose-wasm (with mat feature)
This commit is contained in:
@@ -0,0 +1,886 @@
|
||||
//! Axum request handlers for the MAT REST API.
|
||||
//!
|
||||
//! This module contains all the HTTP endpoint handlers for disaster response operations.
|
||||
//! Each handler is documented with OpenAPI-style documentation comments.
|
||||
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use geo::Point;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::dto::*;
|
||||
use super::error::{ApiError, ApiResult};
|
||||
use super::state::AppState;
|
||||
use crate::domain::{
|
||||
DisasterEvent, DisasterType, ScanZone, ZoneBounds,
|
||||
ScanParameters, ScanResolution, MovementType,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Event Handlers
|
||||
// ============================================================================
|
||||
|
||||
/// List all disaster events.
|
||||
///
|
||||
/// # OpenAPI Specification
|
||||
///
|
||||
/// ```yaml
|
||||
/// /api/v1/mat/events:
|
||||
/// get:
|
||||
/// summary: List disaster events
|
||||
/// description: Returns a paginated list of disaster events with optional filtering
|
||||
/// tags: [Events]
|
||||
/// parameters:
|
||||
/// - name: status
|
||||
/// in: query
|
||||
/// description: Filter by event status
|
||||
/// schema:
|
||||
/// type: string
|
||||
/// enum: [Initializing, Active, Suspended, SecondarySearch, Closed]
|
||||
/// - name: event_type
|
||||
/// in: query
|
||||
/// description: Filter by disaster type
|
||||
/// schema:
|
||||
/// type: string
|
||||
/// - name: page
|
||||
/// in: query
|
||||
/// description: Page number (0-indexed)
|
||||
/// schema:
|
||||
/// type: integer
|
||||
/// default: 0
|
||||
/// - name: page_size
|
||||
/// in: query
|
||||
/// description: Items per page (max 100)
|
||||
/// schema:
|
||||
/// type: integer
|
||||
/// default: 20
|
||||
/// responses:
|
||||
/// 200:
|
||||
/// description: List of events
|
||||
/// content:
|
||||
/// application/json:
|
||||
/// schema:
|
||||
/// $ref: '#/components/schemas/EventListResponse'
|
||||
/// ```
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub async fn list_events(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<ListEventsQuery>,
|
||||
) -> ApiResult<Json<EventListResponse>> {
|
||||
let all_events = state.list_events();
|
||||
|
||||
// Apply filters
|
||||
let filtered: Vec<_> = all_events
|
||||
.into_iter()
|
||||
.filter(|e| {
|
||||
if let Some(ref status) = query.status {
|
||||
let event_status: EventStatusDto = e.status().clone().into();
|
||||
if !matches_status(&event_status, status) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(ref event_type) = query.event_type {
|
||||
let et: DisasterTypeDto = e.event_type().clone().into();
|
||||
if et != *event_type {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
})
|
||||
.collect();
|
||||
|
||||
let total = filtered.len();
|
||||
|
||||
// Apply pagination
|
||||
let page_size = query.page_size.min(100).max(1);
|
||||
let start = query.page * page_size;
|
||||
let events: Vec<_> = filtered
|
||||
.into_iter()
|
||||
.skip(start)
|
||||
.take(page_size)
|
||||
.map(event_to_response)
|
||||
.collect();
|
||||
|
||||
Ok(Json(EventListResponse {
|
||||
events,
|
||||
total,
|
||||
page: query.page,
|
||||
page_size,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Create a new disaster event.
|
||||
///
|
||||
/// # OpenAPI Specification
|
||||
///
|
||||
/// ```yaml
|
||||
/// /api/v1/mat/events:
|
||||
/// post:
|
||||
/// summary: Create a new disaster event
|
||||
/// description: Creates a new disaster event for search and rescue operations
|
||||
/// tags: [Events]
|
||||
/// requestBody:
|
||||
/// required: true
|
||||
/// content:
|
||||
/// application/json:
|
||||
/// schema:
|
||||
/// $ref: '#/components/schemas/CreateEventRequest'
|
||||
/// responses:
|
||||
/// 201:
|
||||
/// description: Event created successfully
|
||||
/// content:
|
||||
/// application/json:
|
||||
/// schema:
|
||||
/// $ref: '#/components/schemas/EventResponse'
|
||||
/// 400:
|
||||
/// description: Invalid request data
|
||||
/// content:
|
||||
/// application/json:
|
||||
/// schema:
|
||||
/// $ref: '#/components/schemas/ErrorResponse'
|
||||
/// ```
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub async fn create_event(
|
||||
State(state): State<AppState>,
|
||||
Json(request): Json<CreateEventRequest>,
|
||||
) -> ApiResult<(StatusCode, Json<EventResponse>)> {
|
||||
// Validate coordinates
|
||||
if request.latitude < -90.0 || request.latitude > 90.0 {
|
||||
return Err(ApiError::validation(
|
||||
"Latitude must be between -90 and 90",
|
||||
Some("latitude".to_string()),
|
||||
));
|
||||
}
|
||||
if request.longitude < -180.0 || request.longitude > 180.0 {
|
||||
return Err(ApiError::validation(
|
||||
"Longitude must be between -180 and 180",
|
||||
Some("longitude".to_string()),
|
||||
));
|
||||
}
|
||||
|
||||
let disaster_type: DisasterType = request.event_type.into();
|
||||
let location = Point::new(request.longitude, request.latitude);
|
||||
let mut event = DisasterEvent::new(disaster_type, location, &request.description);
|
||||
|
||||
// Set metadata if provided
|
||||
if let Some(occupancy) = request.estimated_occupancy {
|
||||
event.metadata_mut().estimated_occupancy = Some(occupancy);
|
||||
}
|
||||
if let Some(agency) = request.lead_agency {
|
||||
event.metadata_mut().lead_agency = Some(agency);
|
||||
}
|
||||
|
||||
let response = event_to_response(event.clone());
|
||||
let event_id = *event.id().as_uuid();
|
||||
state.store_event(event);
|
||||
|
||||
// Broadcast event creation
|
||||
state.broadcast(WebSocketMessage::EventStatusChanged {
|
||||
event_id,
|
||||
old_status: EventStatusDto::Initializing,
|
||||
new_status: response.status,
|
||||
});
|
||||
|
||||
tracing::info!(event_id = %event_id, "Created new disaster event");
|
||||
|
||||
Ok((StatusCode::CREATED, Json(response)))
|
||||
}
|
||||
|
||||
/// Get a specific disaster event by ID.
|
||||
///
|
||||
/// # OpenAPI Specification
|
||||
///
|
||||
/// ```yaml
|
||||
/// /api/v1/mat/events/{event_id}:
|
||||
/// get:
|
||||
/// summary: Get event details
|
||||
/// description: Returns detailed information about a specific disaster event
|
||||
/// tags: [Events]
|
||||
/// parameters:
|
||||
/// - name: event_id
|
||||
/// in: path
|
||||
/// required: true
|
||||
/// description: Event UUID
|
||||
/// schema:
|
||||
/// type: string
|
||||
/// format: uuid
|
||||
/// responses:
|
||||
/// 200:
|
||||
/// description: Event details
|
||||
/// content:
|
||||
/// application/json:
|
||||
/// schema:
|
||||
/// $ref: '#/components/schemas/EventResponse'
|
||||
/// 404:
|
||||
/// description: Event not found
|
||||
/// ```
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub async fn get_event(
|
||||
State(state): State<AppState>,
|
||||
Path(event_id): Path<Uuid>,
|
||||
) -> ApiResult<Json<EventResponse>> {
|
||||
let event = state
|
||||
.get_event(event_id)
|
||||
.ok_or_else(|| ApiError::event_not_found(event_id))?;
|
||||
|
||||
Ok(Json(event_to_response(event)))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Zone Handlers
|
||||
// ============================================================================
|
||||
|
||||
/// List all zones for a disaster event.
|
||||
///
|
||||
/// # OpenAPI Specification
|
||||
///
|
||||
/// ```yaml
|
||||
/// /api/v1/mat/events/{event_id}/zones:
|
||||
/// get:
|
||||
/// summary: List zones for an event
|
||||
/// description: Returns all scan zones configured for a disaster event
|
||||
/// tags: [Zones]
|
||||
/// parameters:
|
||||
/// - name: event_id
|
||||
/// in: path
|
||||
/// required: true
|
||||
/// schema:
|
||||
/// type: string
|
||||
/// format: uuid
|
||||
/// responses:
|
||||
/// 200:
|
||||
/// description: List of zones
|
||||
/// content:
|
||||
/// application/json:
|
||||
/// schema:
|
||||
/// $ref: '#/components/schemas/ZoneListResponse'
|
||||
/// 404:
|
||||
/// description: Event not found
|
||||
/// ```
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub async fn list_zones(
|
||||
State(state): State<AppState>,
|
||||
Path(event_id): Path<Uuid>,
|
||||
) -> ApiResult<Json<ZoneListResponse>> {
|
||||
let event = state
|
||||
.get_event(event_id)
|
||||
.ok_or_else(|| ApiError::event_not_found(event_id))?;
|
||||
|
||||
let zones: Vec<_> = event.zones().iter().map(zone_to_response).collect();
|
||||
let total = zones.len();
|
||||
|
||||
Ok(Json(ZoneListResponse { zones, total }))
|
||||
}
|
||||
|
||||
/// Add a scan zone to a disaster event.
|
||||
///
|
||||
/// # OpenAPI Specification
|
||||
///
|
||||
/// ```yaml
|
||||
/// /api/v1/mat/events/{event_id}/zones:
|
||||
/// post:
|
||||
/// summary: Add a scan zone
|
||||
/// description: Creates a new scan zone within a disaster event area
|
||||
/// tags: [Zones]
|
||||
/// parameters:
|
||||
/// - name: event_id
|
||||
/// in: path
|
||||
/// required: true
|
||||
/// schema:
|
||||
/// type: string
|
||||
/// format: uuid
|
||||
/// requestBody:
|
||||
/// required: true
|
||||
/// content:
|
||||
/// application/json:
|
||||
/// schema:
|
||||
/// $ref: '#/components/schemas/CreateZoneRequest'
|
||||
/// responses:
|
||||
/// 201:
|
||||
/// description: Zone created successfully
|
||||
/// content:
|
||||
/// application/json:
|
||||
/// schema:
|
||||
/// $ref: '#/components/schemas/ZoneResponse'
|
||||
/// 404:
|
||||
/// description: Event not found
|
||||
/// 400:
|
||||
/// description: Invalid zone configuration
|
||||
/// ```
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub async fn add_zone(
|
||||
State(state): State<AppState>,
|
||||
Path(event_id): Path<Uuid>,
|
||||
Json(request): Json<CreateZoneRequest>,
|
||||
) -> ApiResult<(StatusCode, Json<ZoneResponse>)> {
|
||||
// Convert DTO to domain
|
||||
let bounds = match request.bounds {
|
||||
ZoneBoundsDto::Rectangle { min_x, min_y, max_x, max_y } => {
|
||||
if max_x <= min_x || max_y <= min_y {
|
||||
return Err(ApiError::validation(
|
||||
"max coordinates must be greater than min coordinates",
|
||||
Some("bounds".to_string()),
|
||||
));
|
||||
}
|
||||
ZoneBounds::rectangle(min_x, min_y, max_x, max_y)
|
||||
}
|
||||
ZoneBoundsDto::Circle { center_x, center_y, radius } => {
|
||||
if radius <= 0.0 {
|
||||
return Err(ApiError::validation(
|
||||
"radius must be positive",
|
||||
Some("bounds.radius".to_string()),
|
||||
));
|
||||
}
|
||||
ZoneBounds::circle(center_x, center_y, radius)
|
||||
}
|
||||
ZoneBoundsDto::Polygon { vertices } => {
|
||||
if vertices.len() < 3 {
|
||||
return Err(ApiError::validation(
|
||||
"polygon must have at least 3 vertices",
|
||||
Some("bounds.vertices".to_string()),
|
||||
));
|
||||
}
|
||||
ZoneBounds::polygon(vertices)
|
||||
}
|
||||
};
|
||||
|
||||
let params = if let Some(p) = request.parameters {
|
||||
ScanParameters {
|
||||
sensitivity: p.sensitivity.clamp(0.0, 1.0),
|
||||
max_depth: p.max_depth.max(0.0),
|
||||
resolution: match p.resolution {
|
||||
ScanResolutionDto::Quick => ScanResolution::Quick,
|
||||
ScanResolutionDto::Standard => ScanResolution::Standard,
|
||||
ScanResolutionDto::High => ScanResolution::High,
|
||||
ScanResolutionDto::Maximum => ScanResolution::Maximum,
|
||||
},
|
||||
enhanced_breathing: p.enhanced_breathing,
|
||||
heartbeat_detection: p.heartbeat_detection,
|
||||
}
|
||||
} else {
|
||||
ScanParameters::default()
|
||||
};
|
||||
|
||||
let zone = ScanZone::with_parameters(&request.name, bounds, params);
|
||||
let zone_response = zone_to_response(&zone);
|
||||
let zone_id = *zone.id().as_uuid();
|
||||
|
||||
// Add zone to event
|
||||
let added = state.update_event(event_id, move |e| {
|
||||
e.add_zone(zone);
|
||||
true
|
||||
});
|
||||
|
||||
if added.is_none() {
|
||||
return Err(ApiError::event_not_found(event_id));
|
||||
}
|
||||
|
||||
tracing::info!(event_id = %event_id, zone_id = %zone_id, "Added scan zone");
|
||||
|
||||
Ok((StatusCode::CREATED, Json(zone_response)))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Survivor Handlers
|
||||
// ============================================================================
|
||||
|
||||
/// List survivors detected in a disaster event.
|
||||
///
|
||||
/// # OpenAPI Specification
|
||||
///
|
||||
/// ```yaml
|
||||
/// /api/v1/mat/events/{event_id}/survivors:
|
||||
/// get:
|
||||
/// summary: List survivors
|
||||
/// description: Returns all detected survivors in a disaster event
|
||||
/// tags: [Survivors]
|
||||
/// parameters:
|
||||
/// - name: event_id
|
||||
/// in: path
|
||||
/// required: true
|
||||
/// schema:
|
||||
/// type: string
|
||||
/// format: uuid
|
||||
/// - name: triage_status
|
||||
/// in: query
|
||||
/// description: Filter by triage status
|
||||
/// schema:
|
||||
/// type: string
|
||||
/// enum: [Immediate, Delayed, Minor, Deceased, Unknown]
|
||||
/// - name: zone_id
|
||||
/// in: query
|
||||
/// description: Filter by zone
|
||||
/// schema:
|
||||
/// type: string
|
||||
/// format: uuid
|
||||
/// - name: min_confidence
|
||||
/// in: query
|
||||
/// description: Minimum confidence threshold
|
||||
/// schema:
|
||||
/// type: number
|
||||
/// - name: deteriorating_only
|
||||
/// in: query
|
||||
/// description: Only return deteriorating survivors
|
||||
/// schema:
|
||||
/// type: boolean
|
||||
/// responses:
|
||||
/// 200:
|
||||
/// description: List of survivors
|
||||
/// content:
|
||||
/// application/json:
|
||||
/// schema:
|
||||
/// $ref: '#/components/schemas/SurvivorListResponse'
|
||||
/// 404:
|
||||
/// description: Event not found
|
||||
/// ```
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub async fn list_survivors(
|
||||
State(state): State<AppState>,
|
||||
Path(event_id): Path<Uuid>,
|
||||
Query(query): Query<ListSurvivorsQuery>,
|
||||
) -> ApiResult<Json<SurvivorListResponse>> {
|
||||
let event = state
|
||||
.get_event(event_id)
|
||||
.ok_or_else(|| ApiError::event_not_found(event_id))?;
|
||||
|
||||
let mut triage_summary = TriageSummary::default();
|
||||
let survivors: Vec<_> = event
|
||||
.survivors()
|
||||
.into_iter()
|
||||
.filter(|s| {
|
||||
// Update triage counts for all survivors
|
||||
update_triage_summary(&mut triage_summary, s.triage_status());
|
||||
|
||||
// Apply filters
|
||||
if let Some(ref ts) = query.triage_status {
|
||||
let survivor_triage: TriageStatusDto = s.triage_status().clone().into();
|
||||
if !matches_triage_status(&survivor_triage, ts) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(zone_id) = query.zone_id {
|
||||
if s.zone_id().as_uuid() != &zone_id {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(min_conf) = query.min_confidence {
|
||||
if s.confidence() < min_conf {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if query.deteriorating_only && !s.is_deteriorating() {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
})
|
||||
.map(survivor_to_response)
|
||||
.collect();
|
||||
|
||||
let total = survivors.len();
|
||||
|
||||
Ok(Json(SurvivorListResponse {
|
||||
survivors,
|
||||
total,
|
||||
triage_summary,
|
||||
}))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Alert Handlers
|
||||
// ============================================================================
|
||||
|
||||
/// List alerts for a disaster event.
|
||||
///
|
||||
/// # OpenAPI Specification
|
||||
///
|
||||
/// ```yaml
|
||||
/// /api/v1/mat/events/{event_id}/alerts:
|
||||
/// get:
|
||||
/// summary: List alerts
|
||||
/// description: Returns all alerts generated for a disaster event
|
||||
/// tags: [Alerts]
|
||||
/// parameters:
|
||||
/// - name: event_id
|
||||
/// in: path
|
||||
/// required: true
|
||||
/// schema:
|
||||
/// type: string
|
||||
/// format: uuid
|
||||
/// - name: priority
|
||||
/// in: query
|
||||
/// description: Filter by priority
|
||||
/// schema:
|
||||
/// type: string
|
||||
/// enum: [Critical, High, Medium, Low]
|
||||
/// - name: status
|
||||
/// in: query
|
||||
/// description: Filter by status
|
||||
/// schema:
|
||||
/// type: string
|
||||
/// - name: pending_only
|
||||
/// in: query
|
||||
/// description: Only return pending alerts
|
||||
/// schema:
|
||||
/// type: boolean
|
||||
/// - name: active_only
|
||||
/// in: query
|
||||
/// description: Only return active alerts
|
||||
/// schema:
|
||||
/// type: boolean
|
||||
/// responses:
|
||||
/// 200:
|
||||
/// description: List of alerts
|
||||
/// content:
|
||||
/// application/json:
|
||||
/// schema:
|
||||
/// $ref: '#/components/schemas/AlertListResponse'
|
||||
/// 404:
|
||||
/// description: Event not found
|
||||
/// ```
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub async fn list_alerts(
|
||||
State(state): State<AppState>,
|
||||
Path(event_id): Path<Uuid>,
|
||||
Query(query): Query<ListAlertsQuery>,
|
||||
) -> ApiResult<Json<AlertListResponse>> {
|
||||
// Verify event exists
|
||||
if state.get_event(event_id).is_none() {
|
||||
return Err(ApiError::event_not_found(event_id));
|
||||
}
|
||||
|
||||
let all_alerts = state.list_alerts_for_event(event_id);
|
||||
let mut priority_counts = PriorityCounts::default();
|
||||
|
||||
let alerts: Vec<_> = all_alerts
|
||||
.into_iter()
|
||||
.filter(|a| {
|
||||
// Update priority counts
|
||||
update_priority_counts(&mut priority_counts, a.priority());
|
||||
|
||||
// Apply filters
|
||||
if let Some(ref priority) = query.priority {
|
||||
let alert_priority: PriorityDto = a.priority().into();
|
||||
if !matches_priority(&alert_priority, priority) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(ref status) = query.status {
|
||||
let alert_status: AlertStatusDto = a.status().clone().into();
|
||||
if !matches_alert_status(&alert_status, status) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if query.pending_only && !a.is_pending() {
|
||||
return false;
|
||||
}
|
||||
if query.active_only && !a.is_active() {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
})
|
||||
.map(|a| alert_to_response(&a))
|
||||
.collect();
|
||||
|
||||
let total = alerts.len();
|
||||
|
||||
Ok(Json(AlertListResponse {
|
||||
alerts,
|
||||
total,
|
||||
priority_counts,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Acknowledge an alert.
|
||||
///
|
||||
/// # OpenAPI Specification
|
||||
///
|
||||
/// ```yaml
|
||||
/// /api/v1/mat/alerts/{alert_id}/acknowledge:
|
||||
/// post:
|
||||
/// summary: Acknowledge an alert
|
||||
/// description: Marks an alert as acknowledged by a rescue team
|
||||
/// tags: [Alerts]
|
||||
/// parameters:
|
||||
/// - name: alert_id
|
||||
/// in: path
|
||||
/// required: true
|
||||
/// schema:
|
||||
/// type: string
|
||||
/// format: uuid
|
||||
/// requestBody:
|
||||
/// required: true
|
||||
/// content:
|
||||
/// application/json:
|
||||
/// schema:
|
||||
/// $ref: '#/components/schemas/AcknowledgeAlertRequest'
|
||||
/// responses:
|
||||
/// 200:
|
||||
/// description: Alert acknowledged
|
||||
/// content:
|
||||
/// application/json:
|
||||
/// schema:
|
||||
/// $ref: '#/components/schemas/AcknowledgeAlertResponse'
|
||||
/// 404:
|
||||
/// description: Alert not found
|
||||
/// 409:
|
||||
/// description: Alert already acknowledged
|
||||
/// ```
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub async fn acknowledge_alert(
|
||||
State(state): State<AppState>,
|
||||
Path(alert_id): Path<Uuid>,
|
||||
Json(request): Json<AcknowledgeAlertRequest>,
|
||||
) -> ApiResult<Json<AcknowledgeAlertResponse>> {
|
||||
let alert_data = state
|
||||
.get_alert(alert_id)
|
||||
.ok_or_else(|| ApiError::alert_not_found(alert_id))?;
|
||||
|
||||
if !alert_data.alert.is_pending() {
|
||||
return Err(ApiError::InvalidState {
|
||||
message: "Alert is not in pending state".to_string(),
|
||||
current_state: format!("{:?}", alert_data.alert.status()),
|
||||
});
|
||||
}
|
||||
|
||||
let event_id = alert_data.event_id;
|
||||
|
||||
// Acknowledge the alert
|
||||
state.update_alert(alert_id, |a| {
|
||||
a.acknowledge(&request.acknowledged_by);
|
||||
});
|
||||
|
||||
// Get updated alert
|
||||
let updated = state
|
||||
.get_alert(alert_id)
|
||||
.ok_or_else(|| ApiError::alert_not_found(alert_id))?;
|
||||
|
||||
let response = alert_to_response(&updated.alert);
|
||||
|
||||
// Broadcast update
|
||||
state.broadcast(WebSocketMessage::AlertUpdated {
|
||||
event_id,
|
||||
alert: response.clone(),
|
||||
});
|
||||
|
||||
tracing::info!(
|
||||
alert_id = %alert_id,
|
||||
acknowledged_by = %request.acknowledged_by,
|
||||
"Alert acknowledged"
|
||||
);
|
||||
|
||||
Ok(Json(AcknowledgeAlertResponse {
|
||||
success: true,
|
||||
alert: response,
|
||||
}))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
fn event_to_response(event: DisasterEvent) -> EventResponse {
|
||||
let triage_counts = event.triage_counts();
|
||||
|
||||
EventResponse {
|
||||
id: *event.id().as_uuid(),
|
||||
event_type: event.event_type().clone().into(),
|
||||
status: event.status().clone().into(),
|
||||
start_time: *event.start_time(),
|
||||
latitude: event.location().y(),
|
||||
longitude: event.location().x(),
|
||||
description: event.description().to_string(),
|
||||
zone_count: event.zones().len(),
|
||||
survivor_count: event.survivors().len(),
|
||||
triage_summary: TriageSummary {
|
||||
immediate: triage_counts.immediate,
|
||||
delayed: triage_counts.delayed,
|
||||
minor: triage_counts.minor,
|
||||
deceased: triage_counts.deceased,
|
||||
unknown: triage_counts.unknown,
|
||||
},
|
||||
metadata: Some(EventMetadataDto {
|
||||
estimated_occupancy: event.metadata().estimated_occupancy,
|
||||
confirmed_rescued: event.metadata().confirmed_rescued,
|
||||
confirmed_deceased: event.metadata().confirmed_deceased,
|
||||
weather: event.metadata().weather.clone(),
|
||||
lead_agency: event.metadata().lead_agency.clone(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn zone_to_response(zone: &ScanZone) -> ZoneResponse {
|
||||
let bounds = match zone.bounds() {
|
||||
ZoneBounds::Rectangle { min_x, min_y, max_x, max_y } => {
|
||||
ZoneBoundsDto::Rectangle {
|
||||
min_x: *min_x,
|
||||
min_y: *min_y,
|
||||
max_x: *max_x,
|
||||
max_y: *max_y,
|
||||
}
|
||||
}
|
||||
ZoneBounds::Circle { center_x, center_y, radius } => {
|
||||
ZoneBoundsDto::Circle {
|
||||
center_x: *center_x,
|
||||
center_y: *center_y,
|
||||
radius: *radius,
|
||||
}
|
||||
}
|
||||
ZoneBounds::Polygon { vertices } => {
|
||||
ZoneBoundsDto::Polygon {
|
||||
vertices: vertices.clone(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let params = zone.parameters();
|
||||
let parameters = ScanParametersDto {
|
||||
sensitivity: params.sensitivity,
|
||||
max_depth: params.max_depth,
|
||||
resolution: match params.resolution {
|
||||
ScanResolution::Quick => ScanResolutionDto::Quick,
|
||||
ScanResolution::Standard => ScanResolutionDto::Standard,
|
||||
ScanResolution::High => ScanResolutionDto::High,
|
||||
ScanResolution::Maximum => ScanResolutionDto::Maximum,
|
||||
},
|
||||
enhanced_breathing: params.enhanced_breathing,
|
||||
heartbeat_detection: params.heartbeat_detection,
|
||||
};
|
||||
|
||||
ZoneResponse {
|
||||
id: *zone.id().as_uuid(),
|
||||
name: zone.name().to_string(),
|
||||
status: zone.status().clone().into(),
|
||||
bounds,
|
||||
area: zone.area(),
|
||||
parameters,
|
||||
last_scan: zone.last_scan().cloned(),
|
||||
scan_count: zone.scan_count(),
|
||||
detections_count: zone.detections_count(),
|
||||
}
|
||||
}
|
||||
|
||||
fn survivor_to_response(survivor: &crate::Survivor) -> SurvivorResponse {
|
||||
let location = survivor.location().map(|loc| LocationDto {
|
||||
x: loc.x,
|
||||
y: loc.y,
|
||||
z: loc.z,
|
||||
depth: loc.depth(),
|
||||
uncertainty_radius: loc.uncertainty.horizontal_error,
|
||||
confidence: loc.uncertainty.confidence,
|
||||
});
|
||||
|
||||
let latest_vitals = survivor.vital_signs().latest();
|
||||
let vital_signs = VitalSignsSummaryDto {
|
||||
breathing_rate: latest_vitals.and_then(|v| v.breathing.as_ref().map(|b| b.rate_bpm)),
|
||||
breathing_type: latest_vitals.and_then(|v| v.breathing.as_ref().map(|b| format!("{:?}", b.pattern_type))),
|
||||
heart_rate: latest_vitals.and_then(|v| v.heartbeat.as_ref().map(|h| h.rate_bpm)),
|
||||
has_heartbeat: latest_vitals.map(|v| v.has_heartbeat()).unwrap_or(false),
|
||||
has_movement: latest_vitals.map(|v| v.has_movement()).unwrap_or(false),
|
||||
movement_type: latest_vitals.and_then(|v| {
|
||||
if v.movement.movement_type != MovementType::None {
|
||||
Some(format!("{:?}", v.movement.movement_type))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}),
|
||||
timestamp: latest_vitals.map(|v| v.timestamp).unwrap_or_else(chrono::Utc::now),
|
||||
};
|
||||
|
||||
let metadata = {
|
||||
let m = survivor.metadata();
|
||||
if m.notes.is_empty() && m.tags.is_empty() && m.assigned_team.is_none() {
|
||||
None
|
||||
} else {
|
||||
Some(SurvivorMetadataDto {
|
||||
estimated_age_category: m.estimated_age_category.as_ref().map(|a| format!("{:?}", a)),
|
||||
assigned_team: m.assigned_team.clone(),
|
||||
notes: m.notes.clone(),
|
||||
tags: m.tags.clone(),
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
SurvivorResponse {
|
||||
id: *survivor.id().as_uuid(),
|
||||
zone_id: *survivor.zone_id().as_uuid(),
|
||||
status: survivor.status().clone().into(),
|
||||
triage_status: survivor.triage_status().clone().into(),
|
||||
location,
|
||||
vital_signs,
|
||||
confidence: survivor.confidence(),
|
||||
first_detected: *survivor.first_detected(),
|
||||
last_updated: *survivor.last_updated(),
|
||||
is_deteriorating: survivor.is_deteriorating(),
|
||||
metadata,
|
||||
}
|
||||
}
|
||||
|
||||
fn alert_to_response(alert: &crate::Alert) -> AlertResponse {
|
||||
let location = alert.payload().location.as_ref().map(|loc| LocationDto {
|
||||
x: loc.x,
|
||||
y: loc.y,
|
||||
z: loc.z,
|
||||
depth: loc.depth(),
|
||||
uncertainty_radius: loc.uncertainty.horizontal_error,
|
||||
confidence: loc.uncertainty.confidence,
|
||||
});
|
||||
|
||||
AlertResponse {
|
||||
id: *alert.id().as_uuid(),
|
||||
survivor_id: *alert.survivor_id().as_uuid(),
|
||||
priority: alert.priority().into(),
|
||||
status: alert.status().clone().into(),
|
||||
title: alert.payload().title.clone(),
|
||||
message: alert.payload().message.clone(),
|
||||
triage_status: alert.payload().triage_status.clone().into(),
|
||||
location,
|
||||
recommended_action: if alert.payload().recommended_action.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(alert.payload().recommended_action.clone())
|
||||
},
|
||||
created_at: *alert.created_at(),
|
||||
acknowledged_at: alert.acknowledged_at().cloned(),
|
||||
acknowledged_by: alert.acknowledged_by().map(String::from),
|
||||
escalation_count: alert.escalation_count(),
|
||||
}
|
||||
}
|
||||
|
||||
fn update_triage_summary(summary: &mut TriageSummary, status: &crate::TriageStatus) {
|
||||
match status {
|
||||
crate::TriageStatus::Immediate => summary.immediate += 1,
|
||||
crate::TriageStatus::Delayed => summary.delayed += 1,
|
||||
crate::TriageStatus::Minor => summary.minor += 1,
|
||||
crate::TriageStatus::Deceased => summary.deceased += 1,
|
||||
crate::TriageStatus::Unknown => summary.unknown += 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn update_priority_counts(counts: &mut PriorityCounts, priority: crate::Priority) {
|
||||
match priority {
|
||||
crate::Priority::Critical => counts.critical += 1,
|
||||
crate::Priority::High => counts.high += 1,
|
||||
crate::Priority::Medium => counts.medium += 1,
|
||||
crate::Priority::Low => counts.low += 1,
|
||||
}
|
||||
}
|
||||
|
||||
// Match helper functions (avoiding PartialEq on DTOs for flexibility)
|
||||
fn matches_status(a: &EventStatusDto, b: &EventStatusDto) -> bool {
|
||||
std::mem::discriminant(a) == std::mem::discriminant(b)
|
||||
}
|
||||
|
||||
fn matches_triage_status(a: &TriageStatusDto, b: &TriageStatusDto) -> bool {
|
||||
std::mem::discriminant(a) == std::mem::discriminant(b)
|
||||
}
|
||||
|
||||
fn matches_priority(a: &PriorityDto, b: &PriorityDto) -> bool {
|
||||
std::mem::discriminant(a) == std::mem::discriminant(b)
|
||||
}
|
||||
|
||||
fn matches_alert_status(a: &AlertStatusDto, b: &AlertStatusDto) -> bool {
|
||||
std::mem::discriminant(a) == std::mem::discriminant(b)
|
||||
}
|
||||
Reference in New Issue
Block a user