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,892 @@
|
||||
//! Data Transfer Objects (DTOs) for the MAT REST API.
|
||||
//!
|
||||
//! These types are used for serializing/deserializing API requests and responses.
|
||||
//! They provide a clean separation between domain models and API contracts.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::domain::{
|
||||
DisasterType, EventStatus, ZoneStatus, TriageStatus, Priority,
|
||||
AlertStatus, SurvivorStatus,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Event DTOs
|
||||
// ============================================================================
|
||||
|
||||
/// Request body for creating a new disaster event.
|
||||
///
|
||||
/// ## Example
|
||||
///
|
||||
/// ```json
|
||||
/// {
|
||||
/// "event_type": "Earthquake",
|
||||
/// "latitude": 37.7749,
|
||||
/// "longitude": -122.4194,
|
||||
/// "description": "Magnitude 6.8 earthquake in San Francisco",
|
||||
/// "estimated_occupancy": 500,
|
||||
/// "lead_agency": "SF Fire Department"
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct CreateEventRequest {
|
||||
/// Type of disaster event
|
||||
pub event_type: DisasterTypeDto,
|
||||
/// Latitude of disaster epicenter
|
||||
pub latitude: f64,
|
||||
/// Longitude of disaster epicenter
|
||||
pub longitude: f64,
|
||||
/// Human-readable description of the event
|
||||
pub description: String,
|
||||
/// Estimated number of people in the affected area
|
||||
#[serde(default)]
|
||||
pub estimated_occupancy: Option<u32>,
|
||||
/// Lead responding agency
|
||||
#[serde(default)]
|
||||
pub lead_agency: Option<String>,
|
||||
}
|
||||
|
||||
/// Response body for disaster event details.
|
||||
///
|
||||
/// ## Example Response
|
||||
///
|
||||
/// ```json
|
||||
/// {
|
||||
/// "id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
/// "event_type": "Earthquake",
|
||||
/// "status": "Active",
|
||||
/// "start_time": "2024-01-15T14:30:00Z",
|
||||
/// "latitude": 37.7749,
|
||||
/// "longitude": -122.4194,
|
||||
/// "description": "Magnitude 6.8 earthquake",
|
||||
/// "zone_count": 5,
|
||||
/// "survivor_count": 12,
|
||||
/// "triage_summary": {
|
||||
/// "immediate": 3,
|
||||
/// "delayed": 5,
|
||||
/// "minor": 4,
|
||||
/// "deceased": 0
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct EventResponse {
|
||||
/// Unique event identifier
|
||||
pub id: Uuid,
|
||||
/// Type of disaster
|
||||
pub event_type: DisasterTypeDto,
|
||||
/// Current event status
|
||||
pub status: EventStatusDto,
|
||||
/// When the event was created/started
|
||||
pub start_time: DateTime<Utc>,
|
||||
/// Latitude of epicenter
|
||||
pub latitude: f64,
|
||||
/// Longitude of epicenter
|
||||
pub longitude: f64,
|
||||
/// Event description
|
||||
pub description: String,
|
||||
/// Number of scan zones
|
||||
pub zone_count: usize,
|
||||
/// Number of detected survivors
|
||||
pub survivor_count: usize,
|
||||
/// Summary of triage classifications
|
||||
pub triage_summary: TriageSummary,
|
||||
/// Metadata about the event
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub metadata: Option<EventMetadataDto>,
|
||||
}
|
||||
|
||||
/// Summary of triage counts across all survivors.
|
||||
#[derive(Debug, Clone, Serialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct TriageSummary {
|
||||
/// Immediate (Red) - life-threatening
|
||||
pub immediate: u32,
|
||||
/// Delayed (Yellow) - serious but stable
|
||||
pub delayed: u32,
|
||||
/// Minor (Green) - walking wounded
|
||||
pub minor: u32,
|
||||
/// Deceased (Black)
|
||||
pub deceased: u32,
|
||||
/// Unknown status
|
||||
pub unknown: u32,
|
||||
}
|
||||
|
||||
/// Event metadata DTO
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct EventMetadataDto {
|
||||
/// Estimated number of people in area at time of disaster
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub estimated_occupancy: Option<u32>,
|
||||
/// Known survivors (already rescued)
|
||||
#[serde(default)]
|
||||
pub confirmed_rescued: u32,
|
||||
/// Known fatalities
|
||||
#[serde(default)]
|
||||
pub confirmed_deceased: u32,
|
||||
/// Weather conditions
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub weather: Option<String>,
|
||||
/// Lead agency
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lead_agency: Option<String>,
|
||||
}
|
||||
|
||||
/// Paginated list of events.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct EventListResponse {
|
||||
/// List of events
|
||||
pub events: Vec<EventResponse>,
|
||||
/// Total count of events
|
||||
pub total: usize,
|
||||
/// Current page number (0-indexed)
|
||||
pub page: usize,
|
||||
/// Number of items per page
|
||||
pub page_size: usize,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Zone DTOs
|
||||
// ============================================================================
|
||||
|
||||
/// Request body for adding a scan zone to an event.
|
||||
///
|
||||
/// ## Example
|
||||
///
|
||||
/// ```json
|
||||
/// {
|
||||
/// "name": "Building A - North Wing",
|
||||
/// "bounds": {
|
||||
/// "type": "rectangle",
|
||||
/// "min_x": 0.0,
|
||||
/// "min_y": 0.0,
|
||||
/// "max_x": 50.0,
|
||||
/// "max_y": 30.0
|
||||
/// },
|
||||
/// "parameters": {
|
||||
/// "sensitivity": 0.85,
|
||||
/// "max_depth": 5.0,
|
||||
/// "heartbeat_detection": true
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct CreateZoneRequest {
|
||||
/// Human-readable zone name
|
||||
pub name: String,
|
||||
/// Geographic bounds of the zone
|
||||
pub bounds: ZoneBoundsDto,
|
||||
/// Optional scan parameters
|
||||
#[serde(default)]
|
||||
pub parameters: Option<ScanParametersDto>,
|
||||
}
|
||||
|
||||
/// Zone boundary definition.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ZoneBoundsDto {
|
||||
/// Rectangular boundary
|
||||
Rectangle {
|
||||
min_x: f64,
|
||||
min_y: f64,
|
||||
max_x: f64,
|
||||
max_y: f64,
|
||||
},
|
||||
/// Circular boundary
|
||||
Circle {
|
||||
center_x: f64,
|
||||
center_y: f64,
|
||||
radius: f64,
|
||||
},
|
||||
/// Polygon boundary (list of vertices)
|
||||
Polygon {
|
||||
vertices: Vec<(f64, f64)>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Scan parameters for a zone.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct ScanParametersDto {
|
||||
/// Detection sensitivity (0.0-1.0)
|
||||
#[serde(default = "default_sensitivity")]
|
||||
pub sensitivity: f64,
|
||||
/// Maximum depth to scan in meters
|
||||
#[serde(default = "default_max_depth")]
|
||||
pub max_depth: f64,
|
||||
/// Scan resolution level
|
||||
#[serde(default)]
|
||||
pub resolution: ScanResolutionDto,
|
||||
/// Enable enhanced breathing detection
|
||||
#[serde(default = "default_true")]
|
||||
pub enhanced_breathing: bool,
|
||||
/// Enable heartbeat detection (slower but more accurate)
|
||||
#[serde(default)]
|
||||
pub heartbeat_detection: bool,
|
||||
}
|
||||
|
||||
fn default_sensitivity() -> f64 { 0.8 }
|
||||
fn default_max_depth() -> f64 { 5.0 }
|
||||
fn default_true() -> bool { true }
|
||||
|
||||
impl Default for ScanParametersDto {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
sensitivity: default_sensitivity(),
|
||||
max_depth: default_max_depth(),
|
||||
resolution: ScanResolutionDto::default(),
|
||||
enhanced_breathing: default_true(),
|
||||
heartbeat_detection: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan resolution levels.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ScanResolutionDto {
|
||||
Quick,
|
||||
#[default]
|
||||
Standard,
|
||||
High,
|
||||
Maximum,
|
||||
}
|
||||
|
||||
/// Response for zone details.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct ZoneResponse {
|
||||
/// Zone identifier
|
||||
pub id: Uuid,
|
||||
/// Zone name
|
||||
pub name: String,
|
||||
/// Zone status
|
||||
pub status: ZoneStatusDto,
|
||||
/// Zone boundaries
|
||||
pub bounds: ZoneBoundsDto,
|
||||
/// Zone area in square meters
|
||||
pub area: f64,
|
||||
/// Scan parameters
|
||||
pub parameters: ScanParametersDto,
|
||||
/// Last scan time
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub last_scan: Option<DateTime<Utc>>,
|
||||
/// Total scan count
|
||||
pub scan_count: u32,
|
||||
/// Number of detections in this zone
|
||||
pub detections_count: u32,
|
||||
}
|
||||
|
||||
/// List of zones response.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct ZoneListResponse {
|
||||
/// List of zones
|
||||
pub zones: Vec<ZoneResponse>,
|
||||
/// Total count
|
||||
pub total: usize,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Survivor DTOs
|
||||
// ============================================================================
|
||||
|
||||
/// Response for survivor details.
|
||||
///
|
||||
/// ## Example Response
|
||||
///
|
||||
/// ```json
|
||||
/// {
|
||||
/// "id": "550e8400-e29b-41d4-a716-446655440001",
|
||||
/// "zone_id": "550e8400-e29b-41d4-a716-446655440002",
|
||||
/// "status": "Active",
|
||||
/// "triage_status": "Immediate",
|
||||
/// "location": {
|
||||
/// "x": 25.5,
|
||||
/// "y": 12.3,
|
||||
/// "z": -2.1,
|
||||
/// "uncertainty_radius": 1.5
|
||||
/// },
|
||||
/// "vital_signs": {
|
||||
/// "breathing_rate": 22.5,
|
||||
/// "has_heartbeat": true,
|
||||
/// "has_movement": false
|
||||
/// },
|
||||
/// "confidence": 0.87,
|
||||
/// "first_detected": "2024-01-15T14:32:00Z",
|
||||
/// "last_updated": "2024-01-15T14:45:00Z"
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct SurvivorResponse {
|
||||
/// Survivor identifier
|
||||
pub id: Uuid,
|
||||
/// Zone where survivor was detected
|
||||
pub zone_id: Uuid,
|
||||
/// Current survivor status
|
||||
pub status: SurvivorStatusDto,
|
||||
/// Triage classification
|
||||
pub triage_status: TriageStatusDto,
|
||||
/// Location information
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub location: Option<LocationDto>,
|
||||
/// Latest vital signs summary
|
||||
pub vital_signs: VitalSignsSummaryDto,
|
||||
/// Detection confidence (0.0-1.0)
|
||||
pub confidence: f64,
|
||||
/// When survivor was first detected
|
||||
pub first_detected: DateTime<Utc>,
|
||||
/// Last update time
|
||||
pub last_updated: DateTime<Utc>,
|
||||
/// Whether survivor is deteriorating
|
||||
pub is_deteriorating: bool,
|
||||
/// Metadata
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub metadata: Option<SurvivorMetadataDto>,
|
||||
}
|
||||
|
||||
/// Location information DTO.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct LocationDto {
|
||||
/// X coordinate (east-west, meters)
|
||||
pub x: f64,
|
||||
/// Y coordinate (north-south, meters)
|
||||
pub y: f64,
|
||||
/// Z coordinate (depth, negative is below surface)
|
||||
pub z: f64,
|
||||
/// Estimated depth below surface (positive meters)
|
||||
pub depth: f64,
|
||||
/// Horizontal uncertainty radius in meters
|
||||
pub uncertainty_radius: f64,
|
||||
/// Location confidence score
|
||||
pub confidence: f64,
|
||||
}
|
||||
|
||||
/// Summary of vital signs for API response.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct VitalSignsSummaryDto {
|
||||
/// Breathing rate (breaths per minute)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub breathing_rate: Option<f32>,
|
||||
/// Breathing pattern type
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub breathing_type: Option<String>,
|
||||
/// Heart rate if detected (bpm)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub heart_rate: Option<f32>,
|
||||
/// Whether heartbeat is detected
|
||||
pub has_heartbeat: bool,
|
||||
/// Whether movement is detected
|
||||
pub has_movement: bool,
|
||||
/// Movement type if present
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub movement_type: Option<String>,
|
||||
/// Timestamp of reading
|
||||
pub timestamp: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Survivor metadata DTO.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct SurvivorMetadataDto {
|
||||
/// Estimated age category
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub estimated_age_category: Option<String>,
|
||||
/// Assigned rescue team
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub assigned_team: Option<String>,
|
||||
/// Notes
|
||||
pub notes: Vec<String>,
|
||||
/// Tags
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
/// List of survivors response.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct SurvivorListResponse {
|
||||
/// List of survivors
|
||||
pub survivors: Vec<SurvivorResponse>,
|
||||
/// Total count
|
||||
pub total: usize,
|
||||
/// Triage summary
|
||||
pub triage_summary: TriageSummary,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Alert DTOs
|
||||
// ============================================================================
|
||||
|
||||
/// Response for alert details.
|
||||
///
|
||||
/// ## Example Response
|
||||
///
|
||||
/// ```json
|
||||
/// {
|
||||
/// "id": "550e8400-e29b-41d4-a716-446655440003",
|
||||
/// "survivor_id": "550e8400-e29b-41d4-a716-446655440001",
|
||||
/// "priority": "Critical",
|
||||
/// "status": "Pending",
|
||||
/// "title": "Immediate: Survivor detected with abnormal breathing",
|
||||
/// "message": "Survivor in Zone A showing signs of respiratory distress",
|
||||
/// "triage_status": "Immediate",
|
||||
/// "location": { ... },
|
||||
/// "created_at": "2024-01-15T14:35:00Z"
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct AlertResponse {
|
||||
/// Alert identifier
|
||||
pub id: Uuid,
|
||||
/// Related survivor ID
|
||||
pub survivor_id: Uuid,
|
||||
/// Alert priority
|
||||
pub priority: PriorityDto,
|
||||
/// Alert status
|
||||
pub status: AlertStatusDto,
|
||||
/// Alert title
|
||||
pub title: String,
|
||||
/// Detailed message
|
||||
pub message: String,
|
||||
/// Associated triage status
|
||||
pub triage_status: TriageStatusDto,
|
||||
/// Location if available
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub location: Option<LocationDto>,
|
||||
/// Recommended action
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub recommended_action: Option<String>,
|
||||
/// When alert was created
|
||||
pub created_at: DateTime<Utc>,
|
||||
/// When alert was acknowledged
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub acknowledged_at: Option<DateTime<Utc>>,
|
||||
/// Who acknowledged the alert
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub acknowledged_by: Option<String>,
|
||||
/// Escalation count
|
||||
pub escalation_count: u32,
|
||||
}
|
||||
|
||||
/// Request to acknowledge an alert.
|
||||
///
|
||||
/// ## Example
|
||||
///
|
||||
/// ```json
|
||||
/// {
|
||||
/// "acknowledged_by": "Team Alpha",
|
||||
/// "notes": "En route to location"
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct AcknowledgeAlertRequest {
|
||||
/// Who is acknowledging the alert
|
||||
pub acknowledged_by: String,
|
||||
/// Optional notes
|
||||
#[serde(default)]
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
/// Response after acknowledging an alert.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct AcknowledgeAlertResponse {
|
||||
/// Whether acknowledgement was successful
|
||||
pub success: bool,
|
||||
/// Updated alert
|
||||
pub alert: AlertResponse,
|
||||
}
|
||||
|
||||
/// List of alerts response.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct AlertListResponse {
|
||||
/// List of alerts
|
||||
pub alerts: Vec<AlertResponse>,
|
||||
/// Total count
|
||||
pub total: usize,
|
||||
/// Count by priority
|
||||
pub priority_counts: PriorityCounts,
|
||||
}
|
||||
|
||||
/// Count of alerts by priority.
|
||||
#[derive(Debug, Clone, Serialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct PriorityCounts {
|
||||
pub critical: usize,
|
||||
pub high: usize,
|
||||
pub medium: usize,
|
||||
pub low: usize,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WebSocket DTOs
|
||||
// ============================================================================
|
||||
|
||||
/// WebSocket message types for real-time streaming.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum WebSocketMessage {
|
||||
/// New survivor detected
|
||||
SurvivorDetected {
|
||||
event_id: Uuid,
|
||||
survivor: SurvivorResponse,
|
||||
},
|
||||
/// Survivor status updated
|
||||
SurvivorUpdated {
|
||||
event_id: Uuid,
|
||||
survivor: SurvivorResponse,
|
||||
},
|
||||
/// Survivor lost (signal lost)
|
||||
SurvivorLost {
|
||||
event_id: Uuid,
|
||||
survivor_id: Uuid,
|
||||
},
|
||||
/// New alert generated
|
||||
AlertCreated {
|
||||
event_id: Uuid,
|
||||
alert: AlertResponse,
|
||||
},
|
||||
/// Alert status changed
|
||||
AlertUpdated {
|
||||
event_id: Uuid,
|
||||
alert: AlertResponse,
|
||||
},
|
||||
/// Zone scan completed
|
||||
ZoneScanComplete {
|
||||
event_id: Uuid,
|
||||
zone_id: Uuid,
|
||||
detections: u32,
|
||||
},
|
||||
/// Event status changed
|
||||
EventStatusChanged {
|
||||
event_id: Uuid,
|
||||
old_status: EventStatusDto,
|
||||
new_status: EventStatusDto,
|
||||
},
|
||||
/// Heartbeat/keep-alive
|
||||
Heartbeat {
|
||||
timestamp: DateTime<Utc>,
|
||||
},
|
||||
/// Error message
|
||||
Error {
|
||||
code: String,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// WebSocket subscription request.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(tag = "action", rename_all = "snake_case")]
|
||||
pub enum WebSocketRequest {
|
||||
/// Subscribe to events for a disaster event
|
||||
Subscribe {
|
||||
event_id: Uuid,
|
||||
},
|
||||
/// Unsubscribe from events
|
||||
Unsubscribe {
|
||||
event_id: Uuid,
|
||||
},
|
||||
/// Subscribe to all events
|
||||
SubscribeAll,
|
||||
/// Request current state
|
||||
GetState {
|
||||
event_id: Uuid,
|
||||
},
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Enum DTOs (mirroring domain enums with serde)
|
||||
// ============================================================================
|
||||
|
||||
/// Disaster type DTO.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum DisasterTypeDto {
|
||||
BuildingCollapse,
|
||||
Earthquake,
|
||||
Landslide,
|
||||
Avalanche,
|
||||
Flood,
|
||||
MineCollapse,
|
||||
Industrial,
|
||||
TunnelCollapse,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl From<DisasterType> for DisasterTypeDto {
|
||||
fn from(dt: DisasterType) -> Self {
|
||||
match dt {
|
||||
DisasterType::BuildingCollapse => DisasterTypeDto::BuildingCollapse,
|
||||
DisasterType::Earthquake => DisasterTypeDto::Earthquake,
|
||||
DisasterType::Landslide => DisasterTypeDto::Landslide,
|
||||
DisasterType::Avalanche => DisasterTypeDto::Avalanche,
|
||||
DisasterType::Flood => DisasterTypeDto::Flood,
|
||||
DisasterType::MineCollapse => DisasterTypeDto::MineCollapse,
|
||||
DisasterType::Industrial => DisasterTypeDto::Industrial,
|
||||
DisasterType::TunnelCollapse => DisasterTypeDto::TunnelCollapse,
|
||||
DisasterType::Unknown => DisasterTypeDto::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DisasterTypeDto> for DisasterType {
|
||||
fn from(dt: DisasterTypeDto) -> Self {
|
||||
match dt {
|
||||
DisasterTypeDto::BuildingCollapse => DisasterType::BuildingCollapse,
|
||||
DisasterTypeDto::Earthquake => DisasterType::Earthquake,
|
||||
DisasterTypeDto::Landslide => DisasterType::Landslide,
|
||||
DisasterTypeDto::Avalanche => DisasterType::Avalanche,
|
||||
DisasterTypeDto::Flood => DisasterType::Flood,
|
||||
DisasterTypeDto::MineCollapse => DisasterType::MineCollapse,
|
||||
DisasterTypeDto::Industrial => DisasterType::Industrial,
|
||||
DisasterTypeDto::TunnelCollapse => DisasterType::TunnelCollapse,
|
||||
DisasterTypeDto::Unknown => DisasterType::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Event status DTO.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum EventStatusDto {
|
||||
Initializing,
|
||||
Active,
|
||||
Suspended,
|
||||
SecondarySearch,
|
||||
Closed,
|
||||
}
|
||||
|
||||
impl From<EventStatus> for EventStatusDto {
|
||||
fn from(es: EventStatus) -> Self {
|
||||
match es {
|
||||
EventStatus::Initializing => EventStatusDto::Initializing,
|
||||
EventStatus::Active => EventStatusDto::Active,
|
||||
EventStatus::Suspended => EventStatusDto::Suspended,
|
||||
EventStatus::SecondarySearch => EventStatusDto::SecondarySearch,
|
||||
EventStatus::Closed => EventStatusDto::Closed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Zone status DTO.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum ZoneStatusDto {
|
||||
Active,
|
||||
Paused,
|
||||
Complete,
|
||||
Inaccessible,
|
||||
Deactivated,
|
||||
}
|
||||
|
||||
impl From<ZoneStatus> for ZoneStatusDto {
|
||||
fn from(zs: ZoneStatus) -> Self {
|
||||
match zs {
|
||||
ZoneStatus::Active => ZoneStatusDto::Active,
|
||||
ZoneStatus::Paused => ZoneStatusDto::Paused,
|
||||
ZoneStatus::Complete => ZoneStatusDto::Complete,
|
||||
ZoneStatus::Inaccessible => ZoneStatusDto::Inaccessible,
|
||||
ZoneStatus::Deactivated => ZoneStatusDto::Deactivated,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Triage status DTO.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum TriageStatusDto {
|
||||
Immediate,
|
||||
Delayed,
|
||||
Minor,
|
||||
Deceased,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl From<TriageStatus> for TriageStatusDto {
|
||||
fn from(ts: TriageStatus) -> Self {
|
||||
match ts {
|
||||
TriageStatus::Immediate => TriageStatusDto::Immediate,
|
||||
TriageStatus::Delayed => TriageStatusDto::Delayed,
|
||||
TriageStatus::Minor => TriageStatusDto::Minor,
|
||||
TriageStatus::Deceased => TriageStatusDto::Deceased,
|
||||
TriageStatus::Unknown => TriageStatusDto::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Priority DTO.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum PriorityDto {
|
||||
Critical,
|
||||
High,
|
||||
Medium,
|
||||
Low,
|
||||
}
|
||||
|
||||
impl From<Priority> for PriorityDto {
|
||||
fn from(p: Priority) -> Self {
|
||||
match p {
|
||||
Priority::Critical => PriorityDto::Critical,
|
||||
Priority::High => PriorityDto::High,
|
||||
Priority::Medium => PriorityDto::Medium,
|
||||
Priority::Low => PriorityDto::Low,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Alert status DTO.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum AlertStatusDto {
|
||||
Pending,
|
||||
Acknowledged,
|
||||
InProgress,
|
||||
Resolved,
|
||||
Cancelled,
|
||||
Expired,
|
||||
}
|
||||
|
||||
impl From<AlertStatus> for AlertStatusDto {
|
||||
fn from(as_: AlertStatus) -> Self {
|
||||
match as_ {
|
||||
AlertStatus::Pending => AlertStatusDto::Pending,
|
||||
AlertStatus::Acknowledged => AlertStatusDto::Acknowledged,
|
||||
AlertStatus::InProgress => AlertStatusDto::InProgress,
|
||||
AlertStatus::Resolved => AlertStatusDto::Resolved,
|
||||
AlertStatus::Cancelled => AlertStatusDto::Cancelled,
|
||||
AlertStatus::Expired => AlertStatusDto::Expired,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Survivor status DTO.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum SurvivorStatusDto {
|
||||
Active,
|
||||
Rescued,
|
||||
Lost,
|
||||
Deceased,
|
||||
FalsePositive,
|
||||
}
|
||||
|
||||
impl From<SurvivorStatus> for SurvivorStatusDto {
|
||||
fn from(ss: SurvivorStatus) -> Self {
|
||||
match ss {
|
||||
SurvivorStatus::Active => SurvivorStatusDto::Active,
|
||||
SurvivorStatus::Rescued => SurvivorStatusDto::Rescued,
|
||||
SurvivorStatus::Lost => SurvivorStatusDto::Lost,
|
||||
SurvivorStatus::Deceased => SurvivorStatusDto::Deceased,
|
||||
SurvivorStatus::FalsePositive => SurvivorStatusDto::FalsePositive,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Query Parameters
|
||||
// ============================================================================
|
||||
|
||||
/// Query parameters for listing events.
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct ListEventsQuery {
|
||||
/// Filter by status
|
||||
pub status: Option<EventStatusDto>,
|
||||
/// Filter by disaster type
|
||||
pub event_type: Option<DisasterTypeDto>,
|
||||
/// Page number (0-indexed)
|
||||
#[serde(default)]
|
||||
pub page: usize,
|
||||
/// Page size (default 20, max 100)
|
||||
#[serde(default = "default_page_size")]
|
||||
pub page_size: usize,
|
||||
}
|
||||
|
||||
fn default_page_size() -> usize { 20 }
|
||||
|
||||
/// Query parameters for listing survivors.
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct ListSurvivorsQuery {
|
||||
/// Filter by triage status
|
||||
pub triage_status: Option<TriageStatusDto>,
|
||||
/// Filter by zone ID
|
||||
pub zone_id: Option<Uuid>,
|
||||
/// Filter by minimum confidence
|
||||
pub min_confidence: Option<f64>,
|
||||
/// Include only deteriorating
|
||||
#[serde(default)]
|
||||
pub deteriorating_only: bool,
|
||||
}
|
||||
|
||||
/// Query parameters for listing alerts.
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct ListAlertsQuery {
|
||||
/// Filter by priority
|
||||
pub priority: Option<PriorityDto>,
|
||||
/// Filter by status
|
||||
pub status: Option<AlertStatusDto>,
|
||||
/// Only pending alerts
|
||||
#[serde(default)]
|
||||
pub pending_only: bool,
|
||||
/// Only active alerts
|
||||
#[serde(default)]
|
||||
pub active_only: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_create_event_request_deserialize() {
|
||||
let json = r#"{
|
||||
"event_type": "Earthquake",
|
||||
"latitude": 37.7749,
|
||||
"longitude": -122.4194,
|
||||
"description": "Test earthquake"
|
||||
}"#;
|
||||
|
||||
let req: CreateEventRequest = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(req.event_type, DisasterTypeDto::Earthquake);
|
||||
assert!((req.latitude - 37.7749).abs() < 0.0001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zone_bounds_dto_deserialize() {
|
||||
let rect_json = r#"{
|
||||
"type": "rectangle",
|
||||
"min_x": 0.0,
|
||||
"min_y": 0.0,
|
||||
"max_x": 10.0,
|
||||
"max_y": 10.0
|
||||
}"#;
|
||||
|
||||
let bounds: ZoneBoundsDto = serde_json::from_str(rect_json).unwrap();
|
||||
assert!(matches!(bounds, ZoneBoundsDto::Rectangle { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_websocket_message_serialize() {
|
||||
let msg = WebSocketMessage::Heartbeat {
|
||||
timestamp: Utc::now(),
|
||||
};
|
||||
let json = serde_json::to_string(&msg).unwrap();
|
||||
assert!(json.contains("\"type\":\"heartbeat\""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
//! API error types and handling for the MAT REST API.
|
||||
//!
|
||||
//! This module provides a unified error type that maps to appropriate HTTP status codes
|
||||
//! and JSON error responses for the API.
|
||||
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// API error type that converts to HTTP responses.
|
||||
///
|
||||
/// All errors include:
|
||||
/// - An HTTP status code
|
||||
/// - A machine-readable error code
|
||||
/// - A human-readable message
|
||||
/// - Optional additional details
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ApiError {
|
||||
/// Resource not found (404)
|
||||
#[error("Resource not found: {resource_type} with id {id}")]
|
||||
NotFound {
|
||||
resource_type: String,
|
||||
id: String,
|
||||
},
|
||||
|
||||
/// Invalid request data (400)
|
||||
#[error("Bad request: {message}")]
|
||||
BadRequest {
|
||||
message: String,
|
||||
#[source]
|
||||
source: Option<Box<dyn std::error::Error + Send + Sync>>,
|
||||
},
|
||||
|
||||
/// Validation error (422)
|
||||
#[error("Validation failed: {message}")]
|
||||
ValidationError {
|
||||
message: String,
|
||||
field: Option<String>,
|
||||
},
|
||||
|
||||
/// Conflict with existing resource (409)
|
||||
#[error("Conflict: {message}")]
|
||||
Conflict {
|
||||
message: String,
|
||||
},
|
||||
|
||||
/// Resource is in invalid state for operation (409)
|
||||
#[error("Invalid state: {message}")]
|
||||
InvalidState {
|
||||
message: String,
|
||||
current_state: String,
|
||||
},
|
||||
|
||||
/// Internal server error (500)
|
||||
#[error("Internal error: {message}")]
|
||||
Internal {
|
||||
message: String,
|
||||
#[source]
|
||||
source: Option<Box<dyn std::error::Error + Send + Sync>>,
|
||||
},
|
||||
|
||||
/// Service unavailable (503)
|
||||
#[error("Service unavailable: {message}")]
|
||||
ServiceUnavailable {
|
||||
message: String,
|
||||
},
|
||||
|
||||
/// Domain error from business logic
|
||||
#[error("Domain error: {0}")]
|
||||
Domain(#[from] crate::MatError),
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
/// Create a not found error for an event.
|
||||
pub fn event_not_found(id: Uuid) -> Self {
|
||||
Self::NotFound {
|
||||
resource_type: "DisasterEvent".to_string(),
|
||||
id: id.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a not found error for a zone.
|
||||
pub fn zone_not_found(id: Uuid) -> Self {
|
||||
Self::NotFound {
|
||||
resource_type: "ScanZone".to_string(),
|
||||
id: id.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a not found error for a survivor.
|
||||
pub fn survivor_not_found(id: Uuid) -> Self {
|
||||
Self::NotFound {
|
||||
resource_type: "Survivor".to_string(),
|
||||
id: id.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a not found error for an alert.
|
||||
pub fn alert_not_found(id: Uuid) -> Self {
|
||||
Self::NotFound {
|
||||
resource_type: "Alert".to_string(),
|
||||
id: id.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a bad request error.
|
||||
pub fn bad_request(message: impl Into<String>) -> Self {
|
||||
Self::BadRequest {
|
||||
message: message.into(),
|
||||
source: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a validation error.
|
||||
pub fn validation(message: impl Into<String>, field: Option<String>) -> Self {
|
||||
Self::ValidationError {
|
||||
message: message.into(),
|
||||
field,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an internal error.
|
||||
pub fn internal(message: impl Into<String>) -> Self {
|
||||
Self::Internal {
|
||||
message: message.into(),
|
||||
source: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the HTTP status code for this error.
|
||||
pub fn status_code(&self) -> StatusCode {
|
||||
match self {
|
||||
Self::NotFound { .. } => StatusCode::NOT_FOUND,
|
||||
Self::BadRequest { .. } => StatusCode::BAD_REQUEST,
|
||||
Self::ValidationError { .. } => StatusCode::UNPROCESSABLE_ENTITY,
|
||||
Self::Conflict { .. } => StatusCode::CONFLICT,
|
||||
Self::InvalidState { .. } => StatusCode::CONFLICT,
|
||||
Self::Internal { .. } => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::ServiceUnavailable { .. } => StatusCode::SERVICE_UNAVAILABLE,
|
||||
Self::Domain(_) => StatusCode::BAD_REQUEST,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the error code for this error.
|
||||
pub fn error_code(&self) -> &'static str {
|
||||
match self {
|
||||
Self::NotFound { .. } => "NOT_FOUND",
|
||||
Self::BadRequest { .. } => "BAD_REQUEST",
|
||||
Self::ValidationError { .. } => "VALIDATION_ERROR",
|
||||
Self::Conflict { .. } => "CONFLICT",
|
||||
Self::InvalidState { .. } => "INVALID_STATE",
|
||||
Self::Internal { .. } => "INTERNAL_ERROR",
|
||||
Self::ServiceUnavailable { .. } => "SERVICE_UNAVAILABLE",
|
||||
Self::Domain(_) => "DOMAIN_ERROR",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON error response body.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ErrorResponse {
|
||||
/// Machine-readable error code
|
||||
pub code: String,
|
||||
/// Human-readable error message
|
||||
pub message: String,
|
||||
/// Additional error details
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub details: Option<ErrorDetails>,
|
||||
/// Request ID for tracing (if available)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub request_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Additional error details.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ErrorDetails {
|
||||
/// Resource type involved
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resource_type: Option<String>,
|
||||
/// Resource ID involved
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resource_id: Option<String>,
|
||||
/// Field that caused the error
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub field: Option<String>,
|
||||
/// Current state (for state errors)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub current_state: Option<String>,
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let status = self.status_code();
|
||||
let code = self.error_code().to_string();
|
||||
let message = self.to_string();
|
||||
|
||||
let details = match &self {
|
||||
ApiError::NotFound { resource_type, id } => Some(ErrorDetails {
|
||||
resource_type: Some(resource_type.clone()),
|
||||
resource_id: Some(id.clone()),
|
||||
field: None,
|
||||
current_state: None,
|
||||
}),
|
||||
ApiError::ValidationError { field, .. } => Some(ErrorDetails {
|
||||
resource_type: None,
|
||||
resource_id: None,
|
||||
field: field.clone(),
|
||||
current_state: None,
|
||||
}),
|
||||
ApiError::InvalidState { current_state, .. } => Some(ErrorDetails {
|
||||
resource_type: None,
|
||||
resource_id: None,
|
||||
field: None,
|
||||
current_state: Some(current_state.clone()),
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// Log errors
|
||||
match &self {
|
||||
ApiError::Internal { source, .. } | ApiError::BadRequest { source, .. } => {
|
||||
if let Some(src) = source {
|
||||
tracing::error!(error = %self, source = %src, "API error");
|
||||
} else {
|
||||
tracing::error!(error = %self, "API error");
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
tracing::warn!(error = %self, "API error");
|
||||
}
|
||||
}
|
||||
|
||||
let body = ErrorResponse {
|
||||
code,
|
||||
message,
|
||||
details,
|
||||
request_id: None, // Would be populated from request extension
|
||||
};
|
||||
|
||||
(status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
/// Result type alias for API handlers.
|
||||
pub type ApiResult<T> = Result<T, ApiError>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_error_status_codes() {
|
||||
let not_found = ApiError::event_not_found(Uuid::new_v4());
|
||||
assert_eq!(not_found.status_code(), StatusCode::NOT_FOUND);
|
||||
|
||||
let bad_request = ApiError::bad_request("test");
|
||||
assert_eq!(bad_request.status_code(), StatusCode::BAD_REQUEST);
|
||||
|
||||
let internal = ApiError::internal("test");
|
||||
assert_eq!(internal.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_codes() {
|
||||
let not_found = ApiError::event_not_found(Uuid::new_v4());
|
||||
assert_eq!(not_found.error_code(), "NOT_FOUND");
|
||||
|
||||
let validation = ApiError::validation("test", Some("field".to_string()));
|
||||
assert_eq!(validation.error_code(), "VALIDATION_ERROR");
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//! REST API endpoints for WiFi-DensePose MAT disaster response monitoring.
|
||||
//!
|
||||
//! This module provides a complete REST API and WebSocket interface for
|
||||
//! managing disaster events, zones, survivors, and alerts in real-time.
|
||||
//!
|
||||
//! ## Endpoints
|
||||
//!
|
||||
//! ### Disaster Events
|
||||
//! - `GET /api/v1/mat/events` - List all disaster events
|
||||
//! - `POST /api/v1/mat/events` - Create new disaster event
|
||||
//! - `GET /api/v1/mat/events/{id}` - Get event details
|
||||
//!
|
||||
//! ### Zones
|
||||
//! - `GET /api/v1/mat/events/{id}/zones` - List zones for event
|
||||
//! - `POST /api/v1/mat/events/{id}/zones` - Add zone to event
|
||||
//!
|
||||
//! ### Survivors
|
||||
//! - `GET /api/v1/mat/events/{id}/survivors` - List survivors in event
|
||||
//!
|
||||
//! ### Alerts
|
||||
//! - `GET /api/v1/mat/events/{id}/alerts` - List alerts for event
|
||||
//! - `POST /api/v1/mat/alerts/{id}/acknowledge` - Acknowledge alert
|
||||
//!
|
||||
//! ### WebSocket
|
||||
//! - `WS /ws/mat/stream` - Real-time survivor and alert stream
|
||||
|
||||
pub mod dto;
|
||||
pub mod handlers;
|
||||
pub mod error;
|
||||
pub mod state;
|
||||
pub mod websocket;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
routing::{get, post},
|
||||
};
|
||||
|
||||
pub use dto::*;
|
||||
pub use error::ApiError;
|
||||
pub use state::AppState;
|
||||
|
||||
/// Create the MAT API router with all endpoints.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// use wifi_densepose_mat::api::{create_router, AppState};
|
||||
///
|
||||
/// #[tokio::main]
|
||||
/// async fn main() {
|
||||
/// let state = AppState::new();
|
||||
/// let app = create_router(state);
|
||||
/// // ... serve with axum
|
||||
/// }
|
||||
/// ```
|
||||
pub fn create_router(state: AppState) -> Router {
|
||||
Router::new()
|
||||
// Event endpoints
|
||||
.route("/api/v1/mat/events", get(handlers::list_events).post(handlers::create_event))
|
||||
.route("/api/v1/mat/events/:event_id", get(handlers::get_event))
|
||||
// Zone endpoints
|
||||
.route("/api/v1/mat/events/:event_id/zones", get(handlers::list_zones).post(handlers::add_zone))
|
||||
// Survivor endpoints
|
||||
.route("/api/v1/mat/events/:event_id/survivors", get(handlers::list_survivors))
|
||||
// Alert endpoints
|
||||
.route("/api/v1/mat/events/:event_id/alerts", get(handlers::list_alerts))
|
||||
.route("/api/v1/mat/alerts/:alert_id/acknowledge", post(handlers::acknowledge_alert))
|
||||
// WebSocket endpoint
|
||||
.route("/ws/mat/stream", get(websocket::ws_handler))
|
||||
.with_state(state)
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
//! Application state for the MAT REST API.
|
||||
//!
|
||||
//! This module provides the shared state that is passed to all API handlers.
|
||||
//! It contains repositories, services, and real-time event broadcasting.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use parking_lot::RwLock;
|
||||
use tokio::sync::broadcast;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::domain::{
|
||||
DisasterEvent, Alert,
|
||||
};
|
||||
use super::dto::WebSocketMessage;
|
||||
|
||||
/// Shared application state for the API.
|
||||
///
|
||||
/// This is cloned for each request handler and provides thread-safe
|
||||
/// access to shared resources.
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
inner: Arc<AppStateInner>,
|
||||
}
|
||||
|
||||
/// Inner state (not cloned, shared via Arc).
|
||||
struct AppStateInner {
|
||||
/// In-memory event repository
|
||||
events: RwLock<HashMap<Uuid, DisasterEvent>>,
|
||||
/// In-memory alert repository
|
||||
alerts: RwLock<HashMap<Uuid, AlertWithEventId>>,
|
||||
/// Broadcast channel for real-time updates
|
||||
broadcast_tx: broadcast::Sender<WebSocketMessage>,
|
||||
/// Configuration
|
||||
config: ApiConfig,
|
||||
}
|
||||
|
||||
/// Alert with its associated event ID for lookup.
|
||||
#[derive(Clone)]
|
||||
pub struct AlertWithEventId {
|
||||
pub alert: Alert,
|
||||
pub event_id: Uuid,
|
||||
}
|
||||
|
||||
/// API configuration.
|
||||
#[derive(Clone)]
|
||||
pub struct ApiConfig {
|
||||
/// Maximum number of events to store
|
||||
pub max_events: usize,
|
||||
/// Maximum survivors per event
|
||||
pub max_survivors_per_event: usize,
|
||||
/// Broadcast channel capacity
|
||||
pub broadcast_capacity: usize,
|
||||
}
|
||||
|
||||
impl Default for ApiConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_events: 1000,
|
||||
max_survivors_per_event: 10000,
|
||||
broadcast_capacity: 1024,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
/// Create a new application state with default configuration.
|
||||
pub fn new() -> Self {
|
||||
Self::with_config(ApiConfig::default())
|
||||
}
|
||||
|
||||
/// Create a new application state with custom configuration.
|
||||
pub fn with_config(config: ApiConfig) -> Self {
|
||||
let (broadcast_tx, _) = broadcast::channel(config.broadcast_capacity);
|
||||
|
||||
Self {
|
||||
inner: Arc::new(AppStateInner {
|
||||
events: RwLock::new(HashMap::new()),
|
||||
alerts: RwLock::new(HashMap::new()),
|
||||
broadcast_tx,
|
||||
config,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Event Operations
|
||||
// ========================================================================
|
||||
|
||||
/// Store a disaster event.
|
||||
pub fn store_event(&self, event: DisasterEvent) -> Uuid {
|
||||
let id = *event.id().as_uuid();
|
||||
let mut events = self.inner.events.write();
|
||||
|
||||
// Check capacity
|
||||
if events.len() >= self.inner.config.max_events {
|
||||
// Remove oldest closed event
|
||||
let oldest_closed = events
|
||||
.iter()
|
||||
.filter(|(_, e)| matches!(e.status(), crate::EventStatus::Closed))
|
||||
.min_by_key(|(_, e)| e.start_time())
|
||||
.map(|(id, _)| *id);
|
||||
|
||||
if let Some(old_id) = oldest_closed {
|
||||
events.remove(&old_id);
|
||||
}
|
||||
}
|
||||
|
||||
events.insert(id, event);
|
||||
id
|
||||
}
|
||||
|
||||
/// Get an event by ID.
|
||||
pub fn get_event(&self, id: Uuid) -> Option<DisasterEvent> {
|
||||
self.inner.events.read().get(&id).cloned()
|
||||
}
|
||||
|
||||
/// Get mutable access to an event (for updates).
|
||||
pub fn update_event<F, R>(&self, id: Uuid, f: F) -> Option<R>
|
||||
where
|
||||
F: FnOnce(&mut DisasterEvent) -> R,
|
||||
{
|
||||
let mut events = self.inner.events.write();
|
||||
events.get_mut(&id).map(f)
|
||||
}
|
||||
|
||||
/// List all events.
|
||||
pub fn list_events(&self) -> Vec<DisasterEvent> {
|
||||
self.inner.events.read().values().cloned().collect()
|
||||
}
|
||||
|
||||
/// Get event count.
|
||||
pub fn event_count(&self) -> usize {
|
||||
self.inner.events.read().len()
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Alert Operations
|
||||
// ========================================================================
|
||||
|
||||
/// Store an alert.
|
||||
pub fn store_alert(&self, alert: Alert, event_id: Uuid) -> Uuid {
|
||||
let id = *alert.id().as_uuid();
|
||||
let mut alerts = self.inner.alerts.write();
|
||||
alerts.insert(id, AlertWithEventId { alert, event_id });
|
||||
id
|
||||
}
|
||||
|
||||
/// Get an alert by ID.
|
||||
pub fn get_alert(&self, id: Uuid) -> Option<AlertWithEventId> {
|
||||
self.inner.alerts.read().get(&id).cloned()
|
||||
}
|
||||
|
||||
/// Update an alert.
|
||||
pub fn update_alert<F, R>(&self, id: Uuid, f: F) -> Option<R>
|
||||
where
|
||||
F: FnOnce(&mut Alert) -> R,
|
||||
{
|
||||
let mut alerts = self.inner.alerts.write();
|
||||
alerts.get_mut(&id).map(|a| f(&mut a.alert))
|
||||
}
|
||||
|
||||
/// List alerts for an event.
|
||||
pub fn list_alerts_for_event(&self, event_id: Uuid) -> Vec<Alert> {
|
||||
self.inner
|
||||
.alerts
|
||||
.read()
|
||||
.values()
|
||||
.filter(|a| a.event_id == event_id)
|
||||
.map(|a| a.alert.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Broadcasting
|
||||
// ========================================================================
|
||||
|
||||
/// Get a receiver for real-time updates.
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<WebSocketMessage> {
|
||||
self.inner.broadcast_tx.subscribe()
|
||||
}
|
||||
|
||||
/// Broadcast a message to all subscribers.
|
||||
pub fn broadcast(&self, message: WebSocketMessage) {
|
||||
// Ignore send errors (no subscribers)
|
||||
let _ = self.inner.broadcast_tx.send(message);
|
||||
}
|
||||
|
||||
/// Get the number of active subscribers.
|
||||
pub fn subscriber_count(&self) -> usize {
|
||||
self.inner.broadcast_tx.receiver_count()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AppState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::domain::{DisasterType, DisasterEvent};
|
||||
use geo::Point;
|
||||
|
||||
#[test]
|
||||
fn test_store_and_get_event() {
|
||||
let state = AppState::new();
|
||||
let event = DisasterEvent::new(
|
||||
DisasterType::Earthquake,
|
||||
Point::new(-122.4194, 37.7749),
|
||||
"Test earthquake",
|
||||
);
|
||||
let id = *event.id().as_uuid();
|
||||
|
||||
state.store_event(event);
|
||||
|
||||
let retrieved = state.get_event(id);
|
||||
assert!(retrieved.is_some());
|
||||
assert_eq!(retrieved.unwrap().id().as_uuid(), &id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_event() {
|
||||
let state = AppState::new();
|
||||
let event = DisasterEvent::new(
|
||||
DisasterType::Earthquake,
|
||||
Point::new(0.0, 0.0),
|
||||
"Test",
|
||||
);
|
||||
let id = *event.id().as_uuid();
|
||||
state.store_event(event);
|
||||
|
||||
let result = state.update_event(id, |e| {
|
||||
e.set_status(crate::EventStatus::Suspended);
|
||||
true
|
||||
});
|
||||
|
||||
assert!(result.unwrap());
|
||||
let updated = state.get_event(id).unwrap();
|
||||
assert!(matches!(updated.status(), crate::EventStatus::Suspended));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_broadcast_subscribe() {
|
||||
let state = AppState::new();
|
||||
let mut rx = state.subscribe();
|
||||
|
||||
state.broadcast(WebSocketMessage::Heartbeat {
|
||||
timestamp: chrono::Utc::now(),
|
||||
});
|
||||
|
||||
// Try to receive (in async context this would work)
|
||||
assert_eq!(state.subscriber_count(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
//! WebSocket handler for real-time survivor and alert streaming.
|
||||
//!
|
||||
//! This module provides a WebSocket endpoint that streams real-time updates
|
||||
//! for survivor detections, status changes, and alerts.
|
||||
//!
|
||||
//! ## Protocol
|
||||
//!
|
||||
//! Clients connect to `/ws/mat/stream` and receive JSON-formatted messages.
|
||||
//!
|
||||
//! ### Message Types
|
||||
//!
|
||||
//! - `survivor_detected` - New survivor found
|
||||
//! - `survivor_updated` - Survivor status/vitals changed
|
||||
//! - `survivor_lost` - Survivor signal lost
|
||||
//! - `alert_created` - New alert generated
|
||||
//! - `alert_updated` - Alert status changed
|
||||
//! - `zone_scan_complete` - Zone scan finished
|
||||
//! - `event_status_changed` - Event status changed
|
||||
//! - `heartbeat` - Keep-alive ping
|
||||
//! - `error` - Error message
|
||||
//!
|
||||
//! ### Client Commands
|
||||
//!
|
||||
//! Clients can send JSON commands:
|
||||
//! - `{"action": "subscribe", "event_id": "..."}`
|
||||
//! - `{"action": "unsubscribe", "event_id": "..."}`
|
||||
//! - `{"action": "subscribe_all"}`
|
||||
//! - `{"action": "get_state", "event_id": "..."}`
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::{
|
||||
extract::{
|
||||
ws::{Message, WebSocket, WebSocketUpgrade},
|
||||
State,
|
||||
},
|
||||
response::Response,
|
||||
};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use parking_lot::Mutex;
|
||||
use tokio::sync::broadcast;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::dto::{WebSocketMessage, WebSocketRequest};
|
||||
use super::state::AppState;
|
||||
|
||||
/// WebSocket connection handler.
|
||||
///
|
||||
/// # OpenAPI Specification
|
||||
///
|
||||
/// ```yaml
|
||||
/// /ws/mat/stream:
|
||||
/// get:
|
||||
/// summary: Real-time event stream
|
||||
/// description: |
|
||||
/// WebSocket endpoint for real-time updates on survivors and alerts.
|
||||
///
|
||||
/// ## Connection
|
||||
///
|
||||
/// Connect using a WebSocket client to receive real-time updates.
|
||||
///
|
||||
/// ## Messages
|
||||
///
|
||||
/// All messages are JSON-formatted with a "type" field indicating
|
||||
/// the message type.
|
||||
///
|
||||
/// ## Subscriptions
|
||||
///
|
||||
/// By default, clients receive updates for all events. Send a
|
||||
/// subscribe/unsubscribe command to filter to specific events.
|
||||
/// tags: [WebSocket]
|
||||
/// responses:
|
||||
/// 101:
|
||||
/// description: WebSocket connection established
|
||||
/// ```
|
||||
#[tracing::instrument(skip(state, ws))]
|
||||
pub async fn ws_handler(
|
||||
State(state): State<AppState>,
|
||||
ws: WebSocketUpgrade,
|
||||
) -> Response {
|
||||
ws.on_upgrade(move |socket| handle_socket(socket, state))
|
||||
}
|
||||
|
||||
/// Handle an established WebSocket connection.
|
||||
async fn handle_socket(socket: WebSocket, state: AppState) {
|
||||
let (mut sender, mut receiver) = socket.split();
|
||||
|
||||
// Subscription state for this connection
|
||||
let subscriptions: Arc<Mutex<SubscriptionState>> = Arc::new(Mutex::new(SubscriptionState::new()));
|
||||
|
||||
// Subscribe to broadcast channel
|
||||
let mut broadcast_rx = state.subscribe();
|
||||
|
||||
// Spawn task to forward broadcast messages to client
|
||||
let subs_clone = subscriptions.clone();
|
||||
let forward_task = tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
// Receive from broadcast channel
|
||||
result = broadcast_rx.recv() => {
|
||||
match result {
|
||||
Ok(msg) => {
|
||||
// Check if this message matches subscription filter
|
||||
if subs_clone.lock().should_receive(&msg) {
|
||||
if let Ok(json) = serde_json::to_string(&msg) {
|
||||
if sender.send(Message::Text(json)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
tracing::warn!(lagged = n, "WebSocket client lagged, messages dropped");
|
||||
// Send error notification
|
||||
let error = WebSocketMessage::Error {
|
||||
code: "MESSAGES_DROPPED".to_string(),
|
||||
message: format!("{} messages were dropped due to slow client", n),
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string(&error) {
|
||||
if sender.send(Message::Text(json)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Periodic heartbeat
|
||||
_ = tokio::time::sleep(Duration::from_secs(30)) => {
|
||||
let heartbeat = WebSocketMessage::Heartbeat {
|
||||
timestamp: chrono::Utc::now(),
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string(&heartbeat) {
|
||||
if sender.send(Message::Ping(json.into_bytes())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle incoming messages from client
|
||||
let subs_clone = subscriptions.clone();
|
||||
let state_clone = state.clone();
|
||||
while let Some(Ok(msg)) = receiver.next().await {
|
||||
match msg {
|
||||
Message::Text(text) => {
|
||||
// Parse and handle client command
|
||||
if let Err(e) = handle_client_message(&text, &subs_clone, &state_clone).await {
|
||||
tracing::warn!(error = %e, "Failed to handle WebSocket message");
|
||||
}
|
||||
}
|
||||
Message::Binary(_) => {
|
||||
// Binary messages not supported
|
||||
tracing::debug!("Ignoring binary WebSocket message");
|
||||
}
|
||||
Message::Ping(data) => {
|
||||
// Pong handled automatically by axum
|
||||
tracing::trace!(len = data.len(), "Received ping");
|
||||
}
|
||||
Message::Pong(_) => {
|
||||
// Heartbeat response
|
||||
tracing::trace!("Received pong");
|
||||
}
|
||||
Message::Close(_) => {
|
||||
tracing::debug!("Client closed WebSocket connection");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up
|
||||
forward_task.abort();
|
||||
tracing::debug!("WebSocket connection closed");
|
||||
}
|
||||
|
||||
/// Handle a client message (subscription commands).
|
||||
async fn handle_client_message(
|
||||
text: &str,
|
||||
subscriptions: &Arc<Mutex<SubscriptionState>>,
|
||||
state: &AppState,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let request: WebSocketRequest = serde_json::from_str(text)?;
|
||||
|
||||
match request {
|
||||
WebSocketRequest::Subscribe { event_id } => {
|
||||
// Verify event exists
|
||||
if state.get_event(event_id).is_some() {
|
||||
subscriptions.lock().subscribe(event_id);
|
||||
tracing::debug!(event_id = %event_id, "Client subscribed to event");
|
||||
}
|
||||
}
|
||||
WebSocketRequest::Unsubscribe { event_id } => {
|
||||
subscriptions.lock().unsubscribe(&event_id);
|
||||
tracing::debug!(event_id = %event_id, "Client unsubscribed from event");
|
||||
}
|
||||
WebSocketRequest::SubscribeAll => {
|
||||
subscriptions.lock().subscribe_all();
|
||||
tracing::debug!("Client subscribed to all events");
|
||||
}
|
||||
WebSocketRequest::GetState { event_id } => {
|
||||
// This would send current state - simplified for now
|
||||
tracing::debug!(event_id = %event_id, "Client requested state");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tracks subscription state for a WebSocket connection.
|
||||
struct SubscriptionState {
|
||||
/// Subscribed event IDs (empty = all events)
|
||||
event_ids: HashSet<Uuid>,
|
||||
/// Whether subscribed to all events
|
||||
all_events: bool,
|
||||
}
|
||||
|
||||
impl SubscriptionState {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
event_ids: HashSet::new(),
|
||||
all_events: true, // Default to receiving all events
|
||||
}
|
||||
}
|
||||
|
||||
fn subscribe(&mut self, event_id: Uuid) {
|
||||
self.all_events = false;
|
||||
self.event_ids.insert(event_id);
|
||||
}
|
||||
|
||||
fn unsubscribe(&mut self, event_id: &Uuid) {
|
||||
self.event_ids.remove(event_id);
|
||||
if self.event_ids.is_empty() {
|
||||
self.all_events = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn subscribe_all(&mut self) {
|
||||
self.all_events = true;
|
||||
self.event_ids.clear();
|
||||
}
|
||||
|
||||
fn should_receive(&self, msg: &WebSocketMessage) -> bool {
|
||||
if self.all_events {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Extract event_id from message and check subscription
|
||||
let event_id = match msg {
|
||||
WebSocketMessage::SurvivorDetected { event_id, .. } => Some(*event_id),
|
||||
WebSocketMessage::SurvivorUpdated { event_id, .. } => Some(*event_id),
|
||||
WebSocketMessage::SurvivorLost { event_id, .. } => Some(*event_id),
|
||||
WebSocketMessage::AlertCreated { event_id, .. } => Some(*event_id),
|
||||
WebSocketMessage::AlertUpdated { event_id, .. } => Some(*event_id),
|
||||
WebSocketMessage::ZoneScanComplete { event_id, .. } => Some(*event_id),
|
||||
WebSocketMessage::EventStatusChanged { event_id, .. } => Some(*event_id),
|
||||
WebSocketMessage::Heartbeat { .. } => None, // Always receive
|
||||
WebSocketMessage::Error { .. } => None, // Always receive
|
||||
};
|
||||
|
||||
match event_id {
|
||||
Some(id) => self.event_ids.contains(&id),
|
||||
None => true, // Non-event-specific messages always sent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_subscription_state() {
|
||||
let mut state = SubscriptionState::new();
|
||||
|
||||
// Default is all events
|
||||
assert!(state.all_events);
|
||||
|
||||
// Subscribe to specific event
|
||||
let event_id = Uuid::new_v4();
|
||||
state.subscribe(event_id);
|
||||
assert!(!state.all_events);
|
||||
assert!(state.event_ids.contains(&event_id));
|
||||
|
||||
// Unsubscribe returns to all events
|
||||
state.unsubscribe(&event_id);
|
||||
assert!(state.all_events);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_receive() {
|
||||
let mut state = SubscriptionState::new();
|
||||
let event_id = Uuid::new_v4();
|
||||
let other_id = Uuid::new_v4();
|
||||
|
||||
// All events mode - receive everything
|
||||
let msg = WebSocketMessage::Heartbeat {
|
||||
timestamp: chrono::Utc::now(),
|
||||
};
|
||||
assert!(state.should_receive(&msg));
|
||||
|
||||
// Subscribe to specific event
|
||||
state.subscribe(event_id);
|
||||
|
||||
// Should receive messages for subscribed event
|
||||
let msg = WebSocketMessage::SurvivorLost {
|
||||
event_id,
|
||||
survivor_id: Uuid::new_v4(),
|
||||
};
|
||||
assert!(state.should_receive(&msg));
|
||||
|
||||
// Should not receive messages for other events
|
||||
let msg = WebSocketMessage::SurvivorLost {
|
||||
event_id: other_id,
|
||||
survivor_id: Uuid::new_v4(),
|
||||
};
|
||||
assert!(!state.should_receive(&msg));
|
||||
|
||||
// Heartbeats always received
|
||||
let msg = WebSocketMessage::Heartbeat {
|
||||
timestamp: chrono::Utc::now(),
|
||||
};
|
||||
assert!(state.should_receive(&msg));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user