Implementation Plan: Real-Time Aircraft Tracking Module¶
FlightAware AeroAPI Enterprise Integration¶
1. Executive Summary¶
Overview¶
The Real-Time Aircraft Tracking Module enables SkyBrief to provide live fleet monitoring, predictive arrival intelligence, and automated weather correlation for Part-CAT operators. This module integrates FlightAware AeroAPI Enterprise to track aircraft positions, receive flight status updates, and deliver proactive alerts based on actual vs. planned routes.
Key Features¶
| Feature | Description | Value Proposition |
|---|---|---|
| Live Fleet Map | Real-time aircraft positions on interactive Mapbox map | Visual fleet awareness at a glance |
| Flight Status Tracking | Live ETA updates, departure/arrival notifications | Reduce dispatch workload, improve passenger comms |
| Weather Correlation | Automatic weather checks every 5 minutes for active flights | Proactive safety alerts, EASA compliance |
| Predictive Alerts | Foresight ETA predictions with weather impact analysis | Fuel planning, alternate decisions |
| Historical Tracks | 12-month retention of flight paths for audit/analysis | Regulatory compliance, trend analysis |
| Multi-tenancy | Operator isolation with role-based access | Data security, operational boundaries |
Integration Architecture¶
┌─────────────────────────────────────────────────────────────────────────┐
│ FLIGHTAWARE ENTERPRISE │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────────────┐ │
│ │ AeroAPI v4 │ │ Foresight │ │ Push Alerts │ │
│ │ (Positions) │ │ (Predictions) │ │ (Webhooks) │ │
│ └────────┬────────┘ └────────┬────────┘ └───────────┬───────────────┘ │
│ │ │ │ │
│ │ REST API │ REST API │ HTTPS POST │
│ │ (Rate Limited) │ (Rate Limited) │ (HMAC Signed) │
│ │ │ │ │
└───────────┼────────────────────┼───────────────────────┼──────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────────────┐
│ SKYBRIEF TRACKING MODULE │
│ │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ ADAPTER LAYER │ │
│ │ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ │
│ │ │ FlightAware │ │ Webhook │ │ Polling │ │ │
│ │ │ Adapter │ │ Handler │ │ Service │ │ │
│ │ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ │ │
│ └───────────┼────────────────────┼────────────────────┼───────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ APPLICATION LAYER │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │ │
│ │ │ Fleet │ │ Flight │ │ Alert │ │ │
│ │ │ Monitor │ │ Tracker │ │ Correlator │ │ │
│ │ └──────────────┘ └──────────────┘ └──────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ INFRASTRUCTURE │ │
│ │ PostgreSQL │ Redis │ WebSocket │ Mapbox GL JS │ │
│ └──────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘ Value Proposition¶
For Operations Managers: - Monitor entire fleet from single dashboard - Proactive alerts for diversions, delays, weather issues - Automated EASA compliance documentation with live position data
For Chief Pilots: - Real-time flight following for duty time monitoring - Weather correlation with actual flight progress - Post-flight analysis with accurate track data
For Dispatchers: - Reduce manual flight tracking by 80% - Automatic ETA updates to passengers/FBOs - Integration with existing flight planning workflow
2. Architecture Overview¶
Hexagonal Architecture Extension¶
The tracking module extends the existing hexagonal architecture with three new ports and corresponding adapters:
┌─────────────────────────────────────────────────────────────┐
│ EXISTING SYSTEM │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Weather │ │ Compliance │ │ Alerts │ │
│ │ Service │ │ Service │ │ Service │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
│
▼ Integration Points
┌─────────────────────────────────────────────────────────────┐
│ NEW TRACKING MODULE │
│ │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ DOMAIN LAYER ││
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ││
│ │ │ Aircraft │ │ Flight │ │ Position │ ││
│ │ │ Position │ │ Track │ │ Alert │ ││
│ │ └──────────────┘ └──────────────┘ └──────────────┘ ││
│ └─────────────────────────────────────────────────────────┘│
│ │ │
│ ┌─────────────────────────▼─────────────────────────────────┐│
│ │ APPLICATION LAYER ││
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ││
│ │ │ Fleet Monitor│ │Track Flights │ │Weather Alert│ ││
│ │ │ Service │ │ Service │ │ Correlator │ ││
│ │ └──────────────┘ └──────────────┘ └──────────────┘ ││
│ └─────────────────────────────────────────────────────────┘│
│ │ │
│ ┌─────────────────────────▼─────────────────────────────────┐│
│ │ PORTS (Interfaces) ││
│ │ ┌──────────────────────┐ ┌──────────────────────┐ ││
│ │ │ FlightTrackingService│ │ AircraftPositionRepo │ ││
│ │ │ (NEW) │ │ (NEW) │ ││
│ │ └──────────────────────┘ └──────────────────────┘ ││
│ └─────────────────────────────────────────────────────────┘│
│ │ │
│ ┌─────────────────────────▼─────────────────────────────────┐│
│ │ ADAPTERS ││
│ │ ┌──────────────────┐ ┌──────────────────┐ ││
│ │ │ FlightAware │ │ PostgreSQL │ ││
│ │ │ Adapter │ │ Position Repo │ ││
│ │ └──────────────────┘ └──────────────────┘ ││
│ │ ┌──────────────────┐ ┌──────────────────┐ ││
│ │ │ Webhook Handler │ │ Polling Service │ ││
│ │ └──────────────────┘ └──────────────────┘ ││
│ └─────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────┘ Data Flow Diagram¶
┌─────────────────────────────────────────────────────────────────────────┐
│ DATA FLOW - ACTIVE FLIGHT │
└─────────────────────────────────────────────────────────────────────────┘
1. FLIGHT CREATION
┌─────────┐ POST /api/v1/flights ┌─────────┐
│ User │ ──────────────────────────────▶ │ API │
│(Web App)│ │ Handler │
└─────────┘ └────┬────┘
│
▼
┌───────────────┐
│ Flight Created│
│ Status: PLANNED│
└───────┬───────┘
│
▼
┌───────────────┐
│ Start Polling │
│ (60s interval)│
└───────┬───────┘
2. POSITION POLLING
┌───────────────┐ GET /flights/{faFlightId} ┌─────────────────┐
│ Polling │ ─────────────────────────────────▶ │ FlightAware │
│ Service │ │ AeroAPI │
│ (60s) │ ◀───────────────────────────────── │ │
└───────┬───────┘ Position + Track └─────────────────┘
│
▼
┌───────────────┐
│ Cache in Redis│
│ (TTL: 5 min) │
└───────┬───────┘
│
▼
┌───────────────┐ INSERT/UPDATE ┌───────────────┐
│ Position Data │ ─────────────────────▶ │ PostgreSQL │
│ (Parsed) │ │ aircraft_pos │
└───────────────┘ └───────────────┘
│
│ Every 5 min
▼
┌───────────────┐
│ Weather Check │
│ Correlation │
└───────────────┘
3. WEBHOOK ALERTS (Real-time)
┌─────────────────┐ POST /webhooks/flightaware ┌───────────────┐
│ FlightAware │ ─────────────────────────────────▶ │ Webhook │
│ Push Service │ HMAC Signed │ Handler │
│ │ │ │
└─────────────────┘ └───────┬───────┘
│
▼
┌───────────────┐
│ Validate │
│ Signature │
└───────┬───────┘
│
▼
┌───────────────┐
│ Process Alert │
│ (diversion, │
│ arrival, etc) │
└───────┬───────┘
│
┌──────────────────────────┼──────────────────────────┐
│ │ │
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ Save to DB │ │ WebSocket │ │ Send Email │
│ (audit trail) │ │ Broadcast │ │ Notification │
└───────────────┘ └───────────────┘ └───────────────┘
4. FRONTEND DISPLAY
┌───────────────┐ WebSocket /ws/fleet-updates ┌───────────────┐
│ React App │ ◀────────────────────────────────────── │ WebSocket │
│ (Mapbox GL) │ Real-time updates │ Server │
└───────┬───────┘ └───────────────┘
│
│ GET /api/v1/fleet (initial load)
▼
┌───────────────┐
│ Mapbox Map │
│ - Aircraft │
│ markers │
│ - Weather │
│ overlay │
│ - Flight │
│ paths │
└───────────────┘ New Components Overview¶
| Component | Type | Responsibility | File Path |
|---|---|---|---|
FlightTrackingService | Port | Interface for tracking operations | internal/ports/tracking.go |
AircraftPositionRepo | Port | Repository for position data | internal/ports/tracking.go |
FlightAwareAdapter | Adapter | FlightAware API integration | internal/adapters/tracking/flightaware.go |
PositionRepository | Adapter | PostgreSQL position storage | internal/adapters/persistence/postgres/position_repo.go |
WebhookHandler | Adapter | HTTP webhook endpoint | internal/adapters/http/handlers/webhooks.go |
TrackingPoller | Service | Background polling job | internal/application/tracking/poller.go |
FleetDashboard | Component | React map component | frontend/src/components/fleet/FleetDashboard.tsx |
3. Backend Implementation¶
A. New Port Interfaces (internal/ports/tracking.go)¶
package ports
import (
"context"
"time"
"github.com/google/uuid"
)
// ============================================================================
// DOMAIN TYPES
// ============================================================================
// AircraftPosition represents a single position report for an aircraft
type AircraftPosition struct {
ID uuid.UUID `json:"id"`
FlightID uuid.UUID `json:"flight_id"`
AircraftID uuid.UUID `json:"aircraft_id"`
OperatorID uuid.UUID `json:"operator_id"`
// Position data
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
Altitude int `json:"altitude"` // feet MSL
GroundSpeed int `json:"ground_speed"` // knots
Track int `json:"track"` // degrees true
VerticalSpeed int `json:"vertical_speed"` // feet per minute
// Source data
Source string `json:"source"` // "flightaware", "adsb", etc.
Timestamp time.Time `json:"timestamp"` // Position timestamp
ReceivedAt time.Time `json:"received_at"` // When we received it
// FlightAware specific
FAFlightID string `json:"fa_flight_id,omitempty"`
}
// FlightTrack represents a series of positions for a flight
type FlightTrack struct {
ID uuid.UUID `json:"id"`
FlightID uuid.UUID `json:"flight_id"`
AircraftID uuid.UUID `json:"aircraft_id"`
OperatorID uuid.UUID `json:"operator_id"`
// Track metadata
StartTime time.Time `json:"start_time"`
EndTime *time.Time `json:"end_time,omitempty"`
PointCount int `json:"point_count"`
// FlightAware specific
FAFlightID string `json:"fa_flight_id"`
// Positions (loaded on demand or paginated)
Positions []AircraftPosition `json:"positions,omitempty"`
}
// FlightStatus represents the current status of a tracked flight
type FlightStatus string
const (
FlightStatusPlanned FlightStatus = "planned"
FlightStatusScheduled FlightStatus = "scheduled"
FlightStatusActive FlightStatus = "active"
FlightStatusArrived FlightStatus = "arrived"
FlightStatusDiverted FlightStatus = "diverted"
FlightStatusCancelled FlightStatus = "cancelled"
FlightStatusDelayed FlightStatus = "delayed"
)
// TrackedFlight represents a flight being actively tracked
type TrackedFlight struct {
ID uuid.UUID `json:"id"`
FlightID uuid.UUID `json:"flight_id"`
AircraftID uuid.UUID `json:"aircraft_id"`
OperatorID uuid.UUID `json:"operator_id"`
// Flight details
Callsign string `json:"callsign"`
Registration string `json:"registration"`
Origin string `json:"origin"` // ICAO
Destination string `json:"destination"` // ICAO
// Timing
ScheduledOut *time.Time `json:"scheduled_out,omitempty"`
ScheduledIn *time.Time `json:"scheduled_in,omitempty"`
ActualOut *time.Time `json:"actual_out,omitempty"`
ActualIn *time.Time `json:"actual_in,omitempty"`
EstimatedIn *time.Time `json:"estimated_in,omitempty"`
// Status
Status FlightStatus `json:"status"`
// Current position (if active)
CurrentPosition *AircraftPosition `json:"current_position,omitempty"`
// FlightAware specific
FAFlightID string `json:"fa_flight_id"`
// Tracking metadata
LastUpdated time.Time `json:"last_updated"`
IsTracking bool `json:"is_tracking"`
}
// TrackingAlertType represents types of tracking-related alerts
type TrackingAlertType string
const (
AlertTypeDiversion TrackingAlertType = "diversion"
AlertTypeArrival TrackingAlertType = "arrival"
AlertTypeDeparture TrackingAlertType = "departure"
AlertTypeCancellation TrackingAlertType = "cancellation"
AlertTypeDelay TrackingAlertType = "delay"
AlertTypeWeatherEnRoute TrackingAlertType = "weather_enroute"
AlertTypeETAChange TrackingAlertType = "eta_change"
)
// TrackingAlert represents an alert generated from flight tracking
type TrackingAlert struct {
ID uuid.UUID `json:"id"`
FlightID uuid.UUID `json:"flight_id"`
AircraftID uuid.UUID `json:"aircraft_id"`
OperatorID uuid.UUID `json:"operator_id"`
// Alert details
Type TrackingAlertType `json:"type"`
Severity string `json:"severity"` // low, medium, high, critical
Title string `json:"title"`
Message string `json:"message"`
// Related data
PreviousValue *string `json:"previous_value,omitempty"`
CurrentValue *string `json:"current_value,omitempty"`
// Acknowledgment
AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"`
AcknowledgedBy *uuid.UUID `json:"acknowledged_by,omitempty"`
// Metadata
CreatedAt time.Time `json:"created_at"`
Source string `json:"source"` // "flightaware_webhook", "weather_correlation", etc.
}
// ============================================================================
// PORT INTERFACES
// ============================================================================
// FlightTrackingService defines operations for tracking aircraft
type FlightTrackingService interface {
// Position operations
GetCurrentPosition(ctx context.Context, flightID uuid.UUID) (*AircraftPosition, error)
GetFlightTrack(ctx context.Context, flightID uuid.UUID, start, end time.Time) (*FlightTrack, error)
// Flight operations
GetTrackedFlight(ctx context.Context, flightID uuid.UUID) (*TrackedFlight, error)
ListTrackedFlights(ctx context.Context, operatorID uuid.UUID, status *FlightStatus) ([]TrackedFlight, error)
// Tracking control
StartTracking(ctx context.Context, flightID uuid.UUID, faFlightID string) error
StopTracking(ctx context.Context, flightID uuid.UUID) error
// Foresight predictions (FlightAware premium feature)
GetForesightPrediction(ctx context.Context, flightID uuid.UUID) (*ForesightPrediction, error)
// Alert operations
SetAlert(ctx context.Context, alert *TrackingAlert) error
AcknowledgeAlert(ctx context.Context, alertID, userID uuid.UUID) error
ListAlerts(ctx context.Context, operatorID uuid.UUID, unacknowledgedOnly bool) ([]TrackingAlert, error)
}
// ForesightPrediction represents FlightAware's ETA prediction
type ForesightPrediction struct {
FlightID uuid.UUID `json:"flight_id"`
PredictedOut *time.Time `json:"predicted_out,omitempty"`
PredictedOff *time.Time `json:"predicted_off,omitempty"`
PredictedOn *time.Time `json:"predicted_on,omitempty"`
PredictedIn *time.Time `json:"predicted_in,omitempty"`
PredictedETA time.Time `json:"predicted_eta"`
Confidence float64 `json:"confidence"` // 0.0 - 1.0
Trend string `json:"trend"` // "early", "late", "on_time"
MinutesOffset int `json:"minutes_offset"`
}
// AircraftPositionRepository defines storage operations for position data
type AircraftPositionRepository interface {
SavePosition(ctx context.Context, position *AircraftPosition) error
SavePositions(ctx context.Context, positions []AircraftPosition) error
GetLatestPosition(ctx context.Context, aircraftID uuid.UUID) (*AircraftPosition, error)
GetPositionsForFlight(ctx context.Context, flightID uuid.UUID, start, end time.Time) ([]AircraftPosition, error)
GetPositionsForOperator(ctx context.Context, operatorID uuid.UUID) ([]AircraftPosition, error)
// Cleanup (TTL enforcement)
DeleteOldPositions(ctx context.Context, before time.Time) (int64, error)
}
// TrackingAlertRepository defines storage operations for tracking alerts
type TrackingAlertRepository interface {
SaveAlert(ctx context.Context, alert *TrackingAlert) error
GetAlert(ctx context.Context, alertID uuid.UUID) (*TrackingAlert, error)
ListAlerts(ctx context.Context, operatorID uuid.UUID, options AlertListOptions) ([]TrackingAlert, error)
AcknowledgeAlert(ctx context.Context, alertID, userID uuid.UUID) error
// Cleanup
DeleteOldAlerts(ctx context.Context, before time.Time) (int64, error)
}
// AlertListOptions provides filtering options for alert queries
type AlertListOptions struct {
FlightID *uuid.UUID
Types []TrackingAlertType
Severities []string
UnacknowledgedOnly bool
Limit int
Offset int
}
// ExternalTrackingProvider defines the interface for external tracking services
type ExternalTrackingProvider interface {
// Flight operations
GetFlightPosition(ctx context.Context, faFlightID string) (*ProviderPosition, error)
GetFlightTrack(ctx context.Context, faFlightID string) (*ProviderTrack, error)
GetForesightPrediction(ctx context.Context, faFlightID string) (*ProviderForesight, error)
// Search operations
SearchFlight(ctx context.Context, callsign string, date time.Time) ([]FlightSearchResult, error)
GetFlightByID(ctx context.Context, faFlightID string) (*FlightInfo, error)
// Alert configuration
SetAlert(ctx context.Context, faFlightID string, alertType string, config AlertConfig) error
DeleteAlert(ctx context.Context, faFlightID string, alertType string) error
// Webhook handling
VerifyWebhookSignature(ctx context.Context, payload []byte, signature string) error
ParseWebhookPayload(ctx context.Context, payload []byte) (*WebhookEvent, error)
}
// ProviderPosition represents position data from external provider
type ProviderPosition struct {
FAFlightID string `json:"fa_flight_id"`
Callsign string `json:"callsign"`
Registration string `json:"registration"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
Altitude int `json:"altitude"`
GroundSpeed int `json:"ground_speed"`
Track int `json:"track"`
VerticalSpeed int `json:"vertical_speed"`
Timestamp time.Time `json:"timestamp"`
}
// ProviderTrack represents track data from external provider
type ProviderTrack struct {
FAFlightID string `json:"fa_flight_id"`
Positions []ProviderPosition `json:"positions"`
}
// ProviderForesight represents Foresight data from FlightAware
type ProviderForesight struct {
FAFlightID string `json:"fa_flight_id"`
PredictedETA time.Time `json:"predicted_eta"`
Confidence float64 `json:"confidence"`
Trend string `json:"trend"`
MinutesOffset int `json:"minutes_offset"`
}
// FlightSearchResult represents a flight search result
type FlightSearchResult struct {
FAFlightID string `json:"fa_flight_id"`
Callsign string `json:"callsign"`
Registration string `json:"registration"`
Origin string `json:"origin"`
Destination string `json:"destination"`
ScheduledOut *time.Time `json:"scheduled_out,omitempty"`
}
// FlightInfo represents detailed flight information
type FlightInfo struct {
FAFlightID string `json:"fa_flight_id"`
Callsign string `json:"callsign"`
Registration string `json:"registration"`
Origin string `json:"origin"`
Destination string `json:"destination"`
AircraftType string `json:"aircraft_type"`
ScheduledOut *time.Time `json:"scheduled_out,omitempty"`
ScheduledIn *time.Time `json:"scheduled_in,omitempty"`
ActualOut *time.Time `json:"actual_out,omitempty"`
ActualIn *time.Time `json:"actual_in,omitempty"`
EstimatedIn *time.Time `json:"estimated_in,omitempty"`
Status string `json:"status"`
ProgressPercent int `json:"progress_percent"`
}
// AlertConfig represents configuration for external alerts
type AlertConfig struct {
Enabled bool `json:"enabled"`
Channels []string `json:"channels"` // "webhook", "email", etc.
Conditions map[string]interface{} `json:"conditions,omitempty"`
}
// WebhookEvent represents a parsed webhook event
type WebhookEvent struct {
ID string `json:"id"`
Type string `json:"type"` // "flight", "position", etc.
EventType string `json:"event_type"` // "departure", "arrival", etc.
Timestamp time.Time `json:"timestamp"`
FAFlightID string `json:"fa_flight_id"`
Payload map[string]interface{} `json:"payload"`
} B. FlightAware Adapter (internal/adapters/tracking/flightaware.go)¶
package tracking
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"time"
"golang.org/x/time/rate"
"github.com/skybrief/internal/ports"
)
const (
defaultBaseURL = "https://aeroapi.flightaware.com/aeroapi"
defaultRateLimit = 100 // requests per minute (Enterprise tier)
)
// FlightAwareAdapter implements ExternalTrackingProvider for FlightAware AeroAPI
type FlightAwareAdapter struct {
apiKey string
apiSecret string // For webhook signature verification
baseURL string
client *http.Client
rateLimiter *rate.Limiter
// Caching
cache Cache // Redis or in-memory
}
// Config holds configuration for the FlightAware adapter
type Config struct {
APIKey string
APISecret string
BaseURL string
RateLimit int // requests per minute
Timeout time.Duration
}
// NewFlightAwareAdapter creates a new FlightAware adapter instance
func NewFlightAwareAdapter(cfg Config) (*FlightAwareAdapter, error) {
if cfg.APIKey == "" {
return nil, fmt.Errorf("flightaware API key is required")
}
if cfg.BaseURL == "" {
cfg.BaseURL = defaultBaseURL
}
if cfg.RateLimit == 0 {
cfg.RateLimit = defaultRateLimit
}
if cfg.Timeout == 0 {
cfg.Timeout = 30 * time.Second
}
limiter := rate.NewLimiter(rate.Limit(cfg.RateLimit)/60, cfg.RateLimit)
return &FlightAwareAdapter{
apiKey: cfg.APIKey,
apiSecret: cfg.APISecret,
baseURL: cfg.BaseURL,
client: &http.Client{Timeout: cfg.Timeout},
rateLimiter: limiter,
}, nil
}
// GetFlightPosition retrieves current position for a flight
func (a *FlightAwareAdapter) GetFlightPosition(ctx context.Context, faFlightID string) (*ports.ProviderPosition, error) {
if err := a.rateLimiter.Wait(ctx); err != nil {
return nil, fmt.Errorf("rate limit exceeded: %w", err)
}
endpoint := fmt.Sprintf("%s/flights/%s/position", a.baseURL, url.PathEscape(faFlightID))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("x-apikey", a.apiKey)
req.Header.Set("Accept", "application/json")
resp, err := a.client.Do(req)
if err != nil {
return nil, fmt.Errorf("executing request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("flightaware API error: status %d", resp.StatusCode)
}
var result struct {
Position struct {
FaFlightID string `json:"fa_flight_id"`
Callsign string `json:"callsign"`
Registration string `json:"registration"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
Altitude int `json:"altitude"`
GroundSpeed int `json:"groundspeed"`
Track int `json:"heading"`
Timestamp int64 `json:"timestamp"`
} `json:"position"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decoding response: %w", err)
}
return &ports.ProviderPosition{
FAFlightID: result.Position.FaFlightID,
Callsign: result.Position.Callsign,
Registration: result.Position.Registration,
Latitude: result.Position.Latitude,
Longitude: result.Position.Longitude,
Altitude: result.Position.Altitude,
GroundSpeed: result.Position.GroundSpeed,
Track: result.Position.Track,
Timestamp: time.Unix(result.Position.Timestamp, 0),
}, nil
}
// GetFlightTrack retrieves the full track for a flight
func (a *FlightAwareAdapter) GetFlightTrack(ctx context.Context, faFlightID string) (*ports.ProviderTrack, error) {
if err := a.rateLimiter.Wait(ctx); err != nil {
return nil, fmt.Errorf("rate limit exceeded: %w", err)
}
endpoint := fmt.Sprintf("%s/flights/%s/track", a.baseURL, url.PathEscape(faFlightID))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("x-apikey", a.apiKey)
req.Header.Set("Accept", "application/json")
resp, err := a.client.Do(req)
if err != nil {
return nil, fmt.Errorf("executing request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("flightaware API error: status %d", resp.StatusCode)
}
var result struct {
Track []struct {
Timestamp int64 `json:"timestamp"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
Altitude int `json:"altitude"`
GroundSpeed int `json:"groundspeed"`
Heading int `json:"heading"`
} `json:"track"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decoding response: %w", err)
}
positions := make([]ports.ProviderPosition, len(result.Track))
for i, point := range result.Track {
positions[i] = ports.ProviderPosition{
Latitude: point.Latitude,
Longitude: point.Longitude,
Altitude: point.Altitude,
GroundSpeed: point.GroundSpeed,
Track: point.Heading,
Timestamp: time.Unix(point.Timestamp, 0),
}
}
return &ports.ProviderTrack{
FAFlightID: faFlightID,
Positions: positions,
}, nil
}
// GetForesightPrediction retrieves Foresight ETA prediction
func (a *FlightAwareAdapter) GetForesightPrediction(ctx context.Context, faFlightID string) (*ports.ProviderForesight, error) {
if err := a.rateLimiter.Wait(ctx); err != nil {
return nil, fmt.Errorf("rate limit exceeded: %w", err)
}
endpoint := fmt.Sprintf("%s/flights/%s/foresight", a.baseURL, url.PathEscape(faFlightID))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("x-apikey", a.apiKey)
req.Header.Set("Accept", "application/json")
resp, err := a.client.Do(req)
if err != nil {
return nil, fmt.Errorf("executing request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
// Foresight not available for this flight
return nil, nil
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("flightaware API error: status %d", resp.StatusCode)
}
var result struct {
Foresight struct {
PredictedETA int64 `json:"predicted_arrival_time"`
Confidence float64 `json:"confidence"`
Trend string `json:"trend"`
MinutesOffset int `json:"minutes_offset"`
} `json:"foresight"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decoding response: %w", err)
}
return &ports.ProviderForesight{
FAFlightID: faFlightID,
PredictedETA: time.Unix(result.Foresight.PredictedETA, 0),
Confidence: result.Foresight.Confidence,
Trend: result.Foresight.Trend,
MinutesOffset: result.Foresight.MinutesOffset,
}, nil
}
// SearchFlight searches for flights by callsign
func (a *FlightAwareAdapter) SearchFlight(ctx context.Context, callsign string, date time.Time) ([]ports.FlightSearchResult, error) {
if err := a.rateLimiter.Wait(ctx); err != nil {
return nil, fmt.Errorf("rate limit exceeded: %w", err)
}
endpoint := fmt.Sprintf("%s/flights/search", a.baseURL)
query := url.Values{}
query.Set("query", fmt.Sprintf("-callsign %s", callsign))
query.Set("start", date.Format("2006-01-02"))
query.Set("end", date.Add(24*time.Hour).Format("2006-01-02"))
query.Set("max_pages", "1")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint+"?"+query.Encode(), nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("x-apikey", a.apiKey)
req.Header.Set("Accept", "application/json")
resp, err := a.client.Do(req)
if err != nil {
return nil, fmt.Errorf("executing request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("flightaware API error: status %d", resp.StatusCode)
}
var result struct {
Flights []struct {
FaFlightID string `json:"fa_flight_id"`
Callsign string `json:"callsign"`
Registration string `json:"registration"`
Origin struct {
Code string `json:"code"`
} `json:"origin"`
Destination struct {
Code string `json:"code"`
} `json:"destination"`
ScheduledOut int64 `json:"scheduled_out"`
} `json:"flights"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decoding response: %w", err)
}
results := make([]ports.FlightSearchResult, len(result.Flights))
for i, flight := range result.Flights {
results[i] = ports.FlightSearchResult{
FAFlightID: flight.FaFlightID,
Callsign: flight.Callsign,
Registration: flight.Registration,
Origin: flight.Origin.Code,
Destination: flight.Destination.Code,
}
if flight.ScheduledOut > 0 {
t := time.Unix(flight.ScheduledOut, 0)
results[i].ScheduledOut = &t
}
}
return results, nil
}
// GetFlightByID retrieves detailed flight information
func (a *FlightAwareAdapter) GetFlightByID(ctx context.Context, faFlightID string) (*ports.FlightInfo, error) {
if err := a.rateLimiter.Wait(ctx); err != nil {
return nil, fmt.Errorf("rate limit exceeded: %w", err)
}
endpoint := fmt.Sprintf("%s/flights/%s", a.baseURL, url.PathEscape(faFlightID))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("x-apikey", a.apiKey)
req.Header.Set("Accept", "application/json")
resp, err := a.client.Do(req)
if err != nil {
return nil, fmt.Errorf("executing request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("flightaware API error: status %d", resp.StatusCode)
}
var result struct {
Flight struct {
FaFlightID string `json:"fa_flight_id"`
Callsign string `json:"callsign"`
Registration string `json:"registration"`
AircraftType string `json:"aircraft_type"`
Origin struct {
Code string `json:"code"`
} `json:"origin"`
Destination struct {
Code string `json:"code"`
} `json:"destination"`
ScheduledOut int64 `json:"scheduled_out"`
ScheduledIn int64 `json:"scheduled_in"`
ActualOut int64 `json:"actual_out"`
ActualIn int64 `json:"actual_in"`
EstimatedIn int64 `json:"estimated_in"`
Status string `json:"status"`
ProgressPercent int `json:"progress_percent"`
} `json:"flight"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decoding response: %w", err)
}
info := &ports.FlightInfo{
FAFlightID: result.Flight.FaFlightID,
Callsign: result.Flight.Callsign,
Registration: result.Flight.Registration,
AircraftType: result.Flight.AircraftType,
Origin: result.Flight.Origin.Code,
Destination: result.Flight.Destination.Code,
Status: result.Flight.Status,
ProgressPercent: result.Flight.ProgressPercent,
}
if result.Flight.ScheduledOut > 0 {
t := time.Unix(result.Flight.ScheduledOut, 0)
info.ScheduledOut = &t
}
if result.Flight.ScheduledIn > 0 {
t := time.Unix(result.Flight.ScheduledIn, 0)
info.ScheduledIn = &t
}
if result.Flight.ActualOut > 0 {
t := time.Unix(result.Flight.ActualOut, 0)
info.ActualOut = &t
}
if result.Flight.ActualIn > 0 {
t := time.Unix(result.Flight.ActualIn, 0)
info.ActualIn = &t
}
if result.Flight.EstimatedIn > 0 {
t := time.Unix(result.Flight.EstimatedIn, 0)
info.EstimatedIn = &t
}
return info, nil
}
// SetAlert configures alerts for a flight
func (a *FlightAwareAdapter) SetAlert(ctx context.Context, faFlightID string, alertType string, config ports.AlertConfig) error {
// FlightAware Push Alerts configuration via API
// Note: This may require additional setup in FlightAware dashboard
endpoint := fmt.Sprintf("%s/alerts", a.baseURL)
body := map[string]interface{}{
"fa_flight_id": faFlightID,
"event_type": alertType,
"enabled": config.Enabled,
"channels": config.Channels,
}
jsonBody, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshaling request body: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(jsonBody))
if err != nil {
return fmt.Errorf("creating request: %w", err)
}
req.Header.Set("x-apikey", a.apiKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := a.client.Do(req)
if err != nil {
return fmt.Errorf("executing request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
return fmt.Errorf("flightaware API error: status %d", resp.StatusCode)
}
return nil
}
// DeleteAlert removes an alert configuration
func (a *FlightAwareAdapter) DeleteAlert(ctx context.Context, faFlightID string, alertType string) error {
endpoint := fmt.Sprintf("%s/alerts/%s/%s", a.baseURL, url.PathEscape(faFlightID), alertType)
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil)
if err != nil {
return fmt.Errorf("creating request: %w", err)
}
req.Header.Set("x-apikey", a.apiKey)
resp, err := a.client.Do(req)
if err != nil {
return fmt.Errorf("executing request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
return fmt.Errorf("flightaware API error: status %d", resp.StatusCode)
}
return nil
}
// VerifyWebhookSignature validates webhook HMAC signature
func (a *FlightAwareAdapter) VerifyWebhookSignature(ctx context.Context, payload []byte, signature string) error {
if a.apiSecret == "" {
return fmt.Errorf("webhook secret not configured")
}
// Expected signature format: "sha256=<hex-encoded-hmac>"
const prefix = "sha256="
if !strings.HasPrefix(signature, prefix) {
return fmt.Errorf("invalid signature format")
}
expectedSig := signature[len(prefix):]
// Calculate HMAC
mac := hmac.New(sha256.New, []byte(a.apiSecret))
mac.Write(payload)
computedSig := hex.EncodeToString(mac.Sum(nil))
// Constant-time comparison
if !hmac.Equal([]byte(expectedSig), []byte(computedSig)) {
return fmt.Errorf("invalid webhook signature")
}
return nil
}
// ParseWebhookPayload parses and validates webhook payload
func (a *FlightAwareAdapter) ParseWebhookPayload(ctx context.Context, payload []byte) (*ports.WebhookEvent, error) {
var event struct {
ID string `json:"id"`
Type string `json:"type"`
EventType string `json:"event_type"`
Timestamp int64 `json:"timestamp"`
FlightID string `json:"fa_flight_id"`
Payload map[string]interface{} `json:"payload"`
}
if err := json.Unmarshal(payload, &event); err != nil {
return nil, fmt.Errorf("parsing webhook payload: %w", err)
}
return &ports.WebhookEvent{
ID: event.ID,
Type: event.Type,
EventType: event.EventType,
Timestamp: time.Unix(event.Timestamp, 0),
FAFlightID: event.FlightID,
Payload: event.Payload,
}, nil
} C. Database Schema (PostgreSQL)¶
-- ============================================================================
-- TRACKING MODULE DATABASE SCHEMA
-- ============================================================================
--
-- This schema extends the existing SkyBrief database with tables for:
-- - Aircraft positions (24-hour TTL for performance)
-- - Flight tracks (12-month retention for compliance)
-- - Flight status events (audit trail)
-- - Tracking alerts
--
-- ============================================================================
-- Enable UUID extension if not already enabled
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Enable PostGIS for spatial queries (if not already enabled)
CREATE EXTENSION IF NOT EXISTS postgis;
-- ============================================================================
-- TABLE: tracked_flights
-- ============================================================================
-- Maps internal flights to FlightAware tracking
CREATE TABLE tracked_flights (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Relationships
flight_id UUID NOT NULL REFERENCES flights(id) ON DELETE CASCADE,
aircraft_id UUID NOT NULL REFERENCES aircraft(id) ON DELETE CASCADE,
operator_id UUID NOT NULL REFERENCES operators(id) ON DELETE CASCADE,
-- Flight identifiers
fa_flight_id VARCHAR(50) NOT NULL, -- FlightAware flight ID
callsign VARCHAR(20),
registration VARCHAR(20) NOT NULL,
-- Route
origin VARCHAR(4) NOT NULL, -- ICAO code
destination VARCHAR(4) NOT NULL, -- ICAO code
-- Timing
scheduled_out TIMESTAMP WITH TIME ZONE,
scheduled_in TIMESTAMP WITH TIME ZONE,
actual_out TIMESTAMP WITH TIME ZONE,
actual_in TIMESTAMP WITH TIME ZONE,
estimated_in TIMESTAMP WITH TIME ZONE,
-- Status
status VARCHAR(50) NOT NULL DEFAULT 'planned',
-- Tracking metadata
is_tracking BOOLEAN NOT NULL DEFAULT false,
tracking_started_at TIMESTAMP WITH TIME ZONE,
tracking_stopped_at TIMESTAMP WITH TIME ZONE,
last_position_at TIMESTAMP WITH TIME ZONE,
last_polled_at TIMESTAMP WITH TIME ZONE,
-- Metadata
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
-- Constraints
CONSTRAINT chk_status CHECK (status IN ('planned', 'scheduled', 'active', 'arrived', 'diverted', 'cancelled', 'delayed')),
CONSTRAINT uq_fa_flight_id UNIQUE (fa_flight_id)
);
-- Indexes for tracked_flights
CREATE INDEX idx_tracked_flights_operator_id ON tracked_flights(operator_id);
CREATE INDEX idx_tracked_flights_aircraft_id ON tracked_flights(aircraft_id);
CREATE INDEX idx_tracked_flights_status ON tracked_flights(status);
CREATE INDEX idx_tracked_flights_is_tracking ON tracked_flights(is_tracking) WHERE is_tracking = true;
CREATE INDEX idx_tracked_flights_fa_flight_id ON tracked_flights(fa_flight_id);
CREATE INDEX idx_tracked_flights_last_polled ON tracked_flights(last_polled_at)
WHERE is_tracking = true;
-- Trigger to update updated_at timestamp
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
CREATE TRIGGER update_tracked_flights_updated_at
BEFORE UPDATE ON tracked_flights
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- ============================================================================
-- TABLE: aircraft_positions
-- ============================================================================
-- Stores real-time position data (24-hour TTL)
-- Partitioned by date for efficient cleanup
CREATE TABLE aircraft_positions (
id UUID NOT NULL,
-- Relationships
flight_id UUID NOT NULL REFERENCES flights(id) ON DELETE CASCADE,
aircraft_id UUID NOT NULL REFERENCES aircraft(id) ON DELETE CASCADE,
operator_id UUID NOT NULL REFERENCES operators(id) ON DELETE CASCADE,
tracked_flight_id UUID REFERENCES tracked_flights(id) ON DELETE SET NULL,
-- Position data
latitude DECIMAL(10, 8) NOT NULL,
longitude DECIMAL(11, 8) NOT NULL,
altitude INTEGER, -- feet MSL
ground_speed INTEGER, -- knots
track INTEGER, -- degrees true (0-360)
vertical_speed INTEGER, -- feet per minute
-- Source
source VARCHAR(50) NOT NULL DEFAULT 'flightaware',
fa_flight_id VARCHAR(50),
-- Timing
position_timestamp TIMESTAMP WITH TIME ZONE NOT NULL, -- When the position was recorded
received_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
-- Partition key
recorded_date DATE NOT NULL GENERATED ALWAYS AS (DATE(position_timestamp)) STORED,
-- Spatial data (optional, for PostGIS queries)
geom GEOMETRY(Point, 4326) GENERATED ALWAYS AS (
ST_SetSRID(ST_MakePoint(longitude, latitude), 4326)
) STORED,
PRIMARY KEY (id, recorded_date)
) PARTITION BY RANGE (recorded_date);
-- Create partitions for current and next month
-- These should be created dynamically by a maintenance job
CREATE TABLE aircraft_positions_y2024m01 PARTITION OF aircraft_positions
FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
CREATE TABLE aircraft_positions_y2024m02 PARTITION OF aircraft_positions
FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');
-- Indexes for aircraft_positions
CREATE INDEX idx_aircraft_positions_flight_id ON aircraft_positions(flight_id);
CREATE INDEX idx_aircraft_positions_aircraft_id ON aircraft_positions(aircraft_id);
CREATE INDEX idx_aircraft_positions_operator_id ON aircraft_positions(operator_id);
CREATE INDEX idx_aircraft_positions_timestamp ON aircraft_positions(position_timestamp);
CREATE INDEX idx_aircraft_positions_fa_flight_id ON aircraft_positions(fa_flight_id);
-- Spatial index (if using PostGIS)
CREATE INDEX idx_aircraft_positions_geom ON aircraft_positions USING GIST(geom);
-- Function to auto-create partitions (run daily via cron)
CREATE OR REPLACE FUNCTION create_aircraft_positions_partition()
RETURNS void AS $$
DECLARE
partition_date DATE;
partition_name TEXT;
start_date DATE;
end_date DATE;
BEGIN
-- Create partition for tomorrow
partition_date := CURRENT_DATE + INTERVAL '1 day';
partition_name := 'aircraft_positions_y' || TO_CHAR(partition_date, 'YYYY') || 'm' || TO_CHAR(partition_date, 'MM');
start_date := DATE_TRUNC('month', partition_date);
end_date := start_date + INTERVAL '1 month';
EXECUTE format(
'CREATE TABLE IF NOT EXISTS %I PARTITION OF aircraft_positions FOR VALUES FROM (%L) TO (%L)',
partition_name,
start_date,
end_date
);
END;
$$ LANGUAGE plpgsql;
-- ============================================================================
-- TABLE: flight_tracks
-- ============================================================================
-- Stores complete flight track summaries (12-month retention)
CREATE TABLE flight_tracks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Relationships
flight_id UUID NOT NULL REFERENCES flights(id) ON DELETE CASCADE,
aircraft_id UUID NOT NULL REFERENCES aircraft(id) ON DELETE CASCADE,
operator_id UUID NOT NULL REFERENCES operators(id) ON DELETE CASCADE,
tracked_flight_id UUID REFERENCES tracked_flights(id) ON DELETE SET NULL,
-- Flight identifiers
fa_flight_id VARCHAR(50) NOT NULL,
callsign VARCHAR(20),
registration VARCHAR(20) NOT NULL,
-- Route
origin VARCHAR(4) NOT NULL,
destination VARCHAR(4) NOT NULL,
-- Timing
start_time TIMESTAMP WITH TIME ZONE NOT NULL,
end_time TIMESTAMP WITH TIME ZONE,
-- Track metadata
point_count INTEGER NOT NULL DEFAULT 0,
distance_nm DECIMAL(10, 2), -- Calculated distance in nautical miles
-- Data storage
track_data JSONB, -- Compressed track data for download
-- Compliance metadata
retention_until TIMESTAMP WITH TIME ZONE NOT NULL,
-- Metadata
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT uq_flight_tracks_flight_id UNIQUE (flight_id)
);
-- Indexes for flight_tracks
CREATE INDEX idx_flight_tracks_operator_id ON flight_tracks(operator_id);
CREATE INDEX idx_flight_tracks_aircraft_id ON flight_tracks(aircraft_id);
CREATE INDEX idx_flight_tracks_fa_flight_id ON flight_tracks(fa_flight_id);
CREATE INDEX idx_flight_tracks_start_time ON flight_tracks(start_time);
CREATE INDEX idx_flight_tracks_retention ON flight_tracks(retention_until)
WHERE retention_until < NOW();
-- ============================================================================
-- TABLE: flight_status_events
-- ============================================================================
-- Audit trail of all flight status changes
CREATE TABLE flight_status_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Relationships
flight_id UUID NOT NULL REFERENCES flights(id) ON DELETE CASCADE,
tracked_flight_id UUID REFERENCES tracked_flights(id) ON DELETE SET NULL,
operator_id UUID NOT NULL REFERENCES operators(id) ON DELETE CASCADE,
-- Event details
event_type VARCHAR(50) NOT NULL,
previous_status VARCHAR(50),
new_status VARCHAR(50),
-- Data
event_data JSONB, -- Additional context
-- Source
source VARCHAR(50) NOT NULL DEFAULT 'manual', -- manual, flightaware_webhook, polling, etc.
-- Metadata
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES users(id) ON DELETE SET NULL
);
-- Indexes for flight_status_events
CREATE INDEX idx_flight_status_events_flight_id ON flight_status_events(flight_id);
CREATE INDEX idx_flight_status_events_operator_id ON flight_status_events(operator_id);
CREATE INDEX idx_flight_status_events_created_at ON flight_status_events(created_at);
CREATE INDEX idx_flight_status_events_type ON flight_status_events(event_type);
-- ============================================================================
-- TABLE: tracking_alerts
-- ============================================================================
-- Stores alerts generated from tracking events
CREATE TABLE tracking_alerts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Relationships
flight_id UUID NOT NULL REFERENCES flights(id) ON DELETE CASCADE,
aircraft_id UUID NOT NULL REFERENCES aircraft(id) ON DELETE CASCADE,
operator_id UUID NOT NULL REFERENCES operators(id) ON DELETE CASCADE,
tracked_flight_id UUID REFERENCES tracked_flights(id) ON DELETE SET NULL,
-- Alert details
alert_type VARCHAR(50) NOT NULL,
severity VARCHAR(20) NOT NULL DEFAULT 'medium',
title VARCHAR(255) NOT NULL,
message TEXT NOT NULL,
-- Change tracking
previous_value TEXT,
current_value TEXT,
-- Source
source VARCHAR(50) NOT NULL, -- flightaware_webhook, weather_correlation, system
-- Acknowledgment
acknowledged_at TIMESTAMP WITH TIME ZONE,
acknowledged_by UUID REFERENCES users(id) ON DELETE SET NULL,
-- Metadata
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT chk_alert_type CHECK (alert_type IN (
'diversion', 'arrival', 'departure', 'cancellation',
'delay', 'weather_enroute', 'eta_change', 'other'
)),
CONSTRAINT chk_severity CHECK (severity IN ('low', 'medium', 'high', 'critical'))
);
-- Indexes for tracking_alerts
CREATE INDEX idx_tracking_alerts_operator_id ON tracking_alerts(operator_id);
CREATE INDEX idx_tracking_alerts_flight_id ON tracking_alerts(flight_id);
CREATE INDEX idx_tracking_alerts_aircraft_id ON tracking_alerts(aircraft_id);
CREATE INDEX idx_tracking_alerts_type ON tracking_alerts(alert_type);
CREATE INDEX idx_tracking_alerts_severity ON tracking_alerts(severity);
CREATE INDEX idx_tracking_alerts_created_at ON tracking_alerts(created_at);
CREATE INDEX idx_tracking_alerts_unacknowledged ON tracking_alerts(operator_id, created_at)
WHERE acknowledged_at IS NULL;
-- ============================================================================
-- TABLE: weather_correlation_checks
-- ============================================================================
-- Records when weather was checked against flight positions
CREATE TABLE weather_correlation_checks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Relationships
flight_id UUID NOT NULL REFERENCES flights(id) ON DELETE CASCADE,
tracked_flight_id UUID REFERENCES tracked_flights(id) ON DELETE SET NULL,
operator_id UUID NOT NULL REFERENCES operators(id) ON DELETE CASCADE,
-- Position data
position_id UUID NOT NULL,
latitude DECIMAL(10, 8) NOT NULL,
longitude DECIMAL(11, 8) NOT NULL,
altitude INTEGER,
-- Weather data at position
metar_station VARCHAR(4),
metar_data TEXT,
sigmet_affected BOOLEAN DEFAULT false,
sigmet_data JSONB,
-- Correlation results
alerts_generated INTEGER DEFAULT 0,
alert_ids UUID[],
-- Metadata
checked_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Indexes for weather_correlation_checks
CREATE INDEX idx_weather_correlation_flight_id ON weather_correlation_checks(flight_id);
CREATE INDEX idx_weather_correlation_operator_id ON weather_correlation_checks(operator_id);
CREATE INDEX idx_weather_correlation_checked_at ON weather_correlation_checks(checked_at);
-- ============================================================================
-- CLEANUP FUNCTIONS (TTL Enforcement)
-- ============================================================================
-- Function to clean up old positions (24-hour TTL)
CREATE OR REPLACE FUNCTION cleanup_old_positions()
RETURNS INTEGER AS $$
DECLARE
deleted_count INTEGER;
BEGIN
DELETE FROM aircraft_positions
WHERE position_timestamp < NOW() - INTERVAL '24 hours';
GET DIAGNOSTICS deleted_count = ROW_COUNT;
RETURN deleted_count;
END;
$$ LANGUAGE plpgsql;
-- Function to clean up old correlation checks (7-day TTL)
CREATE OR REPLACE FUNCTION cleanup_old_correlation_checks()
RETURNS INTEGER AS $$
DECLARE
deleted_count INTEGER;
BEGIN
DELETE FROM weather_correlation_checks
WHERE checked_at < NOW() - INTERVAL '7 days';
GET DIAGNOSTICS deleted_count = ROW_COUNT;
RETURN deleted_count;
END;
$$ LANGUAGE plpgsql;
-- Function to archive old tracks after 12 months (soft delete / compress)
CREATE OR REPLACE FUNCTION archive_old_tracks()
RETURNS INTEGER AS $$
DECLARE
archived_count INTEGER;
BEGIN
-- Compress track data and mark for cold storage
UPDATE flight_tracks
SET track_data = NULL -- Data moved to cold storage
WHERE retention_until < NOW() - INTERVAL '12 months'
AND track_data IS NOT NULL;
GET DIAGNOSTICS archived_count = ROW_COUNT;
RETURN archived_count;
END;
$$ LANGUAGE plpgsql;
-- ============================================================================
-- VIEWS
-- ============================================================================
-- Active flights view (for quick lookup)
CREATE OR REPLACE VIEW active_tracked_flights AS
SELECT
tf.*,
a.type as aircraft_type,
a.icao_type,
ap.latitude as current_latitude,
ap.longitude as current_longitude,
ap.altitude as current_altitude,
ap.ground_speed as current_ground_speed
FROM tracked_flights tf
JOIN aircraft a ON tf.aircraft_id = a.id
LEFT JOIN LATERAL (
SELECT * FROM aircraft_positions
WHERE aircraft_id = tf.aircraft_id
ORDER BY position_timestamp DESC
LIMIT 1
) ap ON true
WHERE tf.is_tracking = true
AND tf.status IN ('active', 'scheduled');
-- Fleet status view (for dashboard)
CREATE OR REPLACE VIEW fleet_status AS
SELECT
operator_id,
COUNT(*) FILTER (WHERE status = 'active') as active_flights,
COUNT(*) FILTER (WHERE status = 'scheduled') as scheduled_flights,
COUNT(*) FILTER (WHERE status = 'arrived' AND actual_in > NOW() - INTERVAL '24 hours') as recent_arrivals,
COUNT(*) FILTER (WHERE status = 'diverted') as diverted_flights,
COUNT(*) FILTER (WHERE is_tracking = true) as tracked_count,
MAX(last_position_at) as last_update
FROM tracked_flights
GROUP BY operator_id;
-- Unacknowledged alerts view
CREATE OR REPLACE VIEW unacknowledged_alerts AS
SELECT
ta.*,
f.callsign,
f.origin,
f.destination,
a.registration,
a.type as aircraft_type
FROM tracking_alerts ta
JOIN flights f ON ta.flight_id = f.id
JOIN aircraft a ON ta.aircraft_id = a.id
WHERE ta.acknowledged_at IS NULL
ORDER BY ta.created_at DESC; D. Webhook Handler (internal/adapters/http/handlers/webhooks.go)¶
package handlers
import (
"context"
"encoding/json"
"io"
"net/http"
"time"
"github.com/labstack/echo/v4"
"github.com/google/uuid"
"github.com/skybrief/internal/ports"
"github.com/skybrief/internal/application/tracking"
"github.com/skybrief/pkg/websocket"
"github.com/skybrief/pkg/logger"
)
// WebhookHandler handles incoming webhooks from external services
type WebhookHandler struct {
trackingService ports.FlightTrackingService
trackingProvider ports.ExternalTrackingProvider
alertService *tracking.AlertService
wsHub *websocket.Hub
logger logger.Logger
}
// NewWebhookHandler creates a new webhook handler
func NewWebhookHandler(
trackingService ports.FlightTrackingService,
trackingProvider ports.ExternalTrackingProvider,
alertService *tracking.AlertService,
wsHub *websocket.Hub,
logger logger.Logger,
) *WebhookHandler {
return &WebhookHandler{
trackingService: trackingService,
trackingProvider: trackingProvider,
alertService: alertService,
wsHub: wsHub,
logger: logger,
}
}
// RegisterRoutes registers webhook routes
func (h *WebhookHandler) RegisterRoutes(e *echo.Echo) {
webhooks := e.Group("/api/v1/webhooks")
// FlightAware webhooks
webhooks.POST("/flightaware", h.HandleFlightAwareWebhook)
}
// HandleFlightAwareWebhook handles incoming webhooks from FlightAware
// POST /api/v1/webhooks/flightaware
func (h *WebhookHandler) HandleFlightAwareWebhook(c echo.Context) error {
ctx := c.Request().Context()
// Read payload
payload, err := io.ReadAll(c.Request().Body)
if err != nil {
h.logger.Error("Failed to read webhook payload", "error", err)
return echo.NewHTTPError(http.StatusBadRequest, "Invalid request body")
}
defer c.Request().Body.Close()
// Verify signature
signature := c.Request().Header.Get("X-FlightAware-Signature")
if signature == "" {
h.logger.Error("Missing webhook signature")
return echo.NewHTTPError(http.StatusUnauthorized, "Missing signature")
}
if err := h.trackingProvider.VerifyWebhookSignature(ctx, payload, signature); err != nil {
h.logger.Error("Invalid webhook signature", "error", err)
return echo.NewHTTPError(http.StatusUnauthorized, "Invalid signature")
}
// Parse webhook event
event, err := h.trackingProvider.ParseWebhookPayload(ctx, payload)
if err != nil {
h.logger.Error("Failed to parse webhook payload", "error", err)
return echo.NewHTTPError(http.StatusBadRequest, "Invalid payload")
}
h.logger.Info("Received FlightAware webhook",
"event_type", event.EventType,
"flight_id", event.FAFlightID,
)
// Process based on event type
switch event.EventType {
case "departure":
err = h.handleDeparture(ctx, event)
case "arrival":
err = h.handleArrival(ctx, event)
case "diversion":
err = h.handleDiversion(ctx, event)
case "cancellation":
err = h.handleCancellation(ctx, event)
case "delay":
err = h.handleDelay(ctx, event)
case "position":
err = h.handlePositionUpdate(ctx, event)
case "eta_change":
err = h.handleETAChange(ctx, event)
default:
h.logger.Info("Unhandled webhook event type", "type", event.EventType)
}
if err != nil {
h.logger.Error("Failed to process webhook event",
"event_type", event.EventType,
"error", err,
)
return echo.NewHTTPError(http.StatusInternalServerError, "Processing failed")
}
return c.NoContent(http.StatusOK)
}
// handleDeparture processes departure events
func (h *WebhookHandler) handleDeparture(ctx context.Context, event *ports.WebhookEvent) error {
// Get tracked flight by FA flight ID
trackedFlight, err := h.getTrackedFlightByFAID(ctx, event.FAFlightID)
if err != nil {
return err
}
// Update flight status
if err := h.trackingService.StopTracking(ctx, trackedFlight.FlightID); err != nil {
h.logger.Error("Failed to stop tracking on departure", "error", err)
}
// Create alert
alert := &ports.TrackingAlert{
FlightID: trackedFlight.FlightID,
AircraftID: trackedFlight.AircraftID,
OperatorID: trackedFlight.OperatorID,
Type: ports.AlertTypeDeparture,
Severity: "low",
Title: "Flight Departed",
Message: fmt.Sprintf("Flight %s has departed from %s", trackedFlight.Callsign, trackedFlight.Origin),
Source: "flightaware_webhook",
CreatedAt: time.Now(),
}
if err := h.trackingService.SetAlert(ctx, alert); err != nil {
h.logger.Error("Failed to create departure alert", "error", err)
}
// Broadcast via WebSocket
h.broadcastUpdate(trackedFlight.OperatorID, map[string]interface{}{
"type": "departure",
"flight_id": trackedFlight.FlightID,
"callsign": trackedFlight.Callsign,
"timestamp": event.Timestamp,
})
return nil
}
// handleArrival processes arrival events
func (h *WebhookHandler) handleArrival(ctx context.Context, event *ports.WebhookEvent) error {
trackedFlight, err := h.getTrackedFlightByFAID(ctx, event.FAFlightID)
if err != nil {
return err
}
// Stop tracking
if err := h.trackingService.StopTracking(ctx, trackedFlight.FlightID); err != nil {
h.logger.Error("Failed to stop tracking on arrival", "error", err)
}
// Get actual arrival time from payload
var actualIn time.Time
if actualInStr, ok := event.Payload["actual_in"].(string); ok {
actualIn, _ = time.Parse(time.RFC3339, actualInStr)
}
// Create alert
alert := &ports.TrackingAlert{
FlightID: trackedFlight.FlightID,
AircraftID: trackedFlight.AircraftID,
OperatorID: trackedFlight.OperatorID,
Type: ports.AlertTypeArrival,
Severity: "low",
Title: "Flight Arrived",
Message: fmt.Sprintf("Flight %s has arrived at %s", trackedFlight.Callsign, trackedFlight.Destination),
Source: "flightaware_webhook",
CreatedAt: time.Now(),
}
if err := h.trackingService.SetAlert(ctx, alert); err != nil {
h.logger.Error("Failed to create arrival alert", "error", err)
}
// Broadcast via WebSocket
h.broadcastUpdate(trackedFlight.OperatorID, map[string]interface{}{
"type": "arrival",
"flight_id": trackedFlight.FlightID,
"callsign": trackedFlight.Callsign,
"destination": trackedFlight.Destination,
"timestamp": event.Timestamp,
})
return nil
}
// handleDiversion processes diversion events
func (h *WebhookHandler) handleDiversion(ctx context.Context, event *ports.WebhookEvent) error {
trackedFlight, err := h.getTrackedFlightByFAID(ctx, event.FAFlightID)
if err != nil {
return err
}
// Get diversion details
var diversionAirport string
if airport, ok := event.Payload["diversion_airport"].(string); ok {
diversionAirport = airport
}
// Create critical alert
alert := &ports.TrackingAlert{
FlightID: trackedFlight.FlightID,
AircraftID: trackedFlight.AircraftID,
OperatorID: trackedFlight.OperatorID,
Type: ports.AlertTypeDiversion,
Severity: "critical",
Title: "FLIGHT DIVERTED",
Message: fmt.Sprintf("Flight %s has diverted to %s", trackedFlight.Callsign, diversionAirport),
CurrentValue: &diversionAirport,
Source: "flightaware_webhook",
CreatedAt: time.Now(),
}
if err := h.trackingService.SetAlert(ctx, alert); err != nil {
h.logger.Error("Failed to create diversion alert", "error", err)
}
// Trigger immediate weather check for diversion airport
h.alertService.CheckWeatherForAirport(ctx, trackedFlight.OperatorID, diversionAirport)
// Broadcast via WebSocket
h.broadcastUpdate(trackedFlight.OperatorID, map[string]interface{}{
"type": "diversion",
"flight_id": trackedFlight.FlightID,
"callsign": trackedFlight.Callsign,
"diversion_airport": diversionAirport,
"severity": "critical",
})
return nil
}
// handleCancellation processes cancellation events
func (h *WebhookHandler) handleCancellation(ctx context.Context, event *ports.WebhookEvent) error {
trackedFlight, err := h.getTrackedFlightByFAID(ctx, event.FAFlightID)
if err != nil {
return err
}
// Stop tracking
if err := h.trackingService.StopTracking(ctx, trackedFlight.FlightID); err != nil {
h.logger.Error("Failed to stop tracking on cancellation", "error", err)
}
// Create alert
alert := &ports.TrackingAlert{
FlightID: trackedFlight.FlightID,
AircraftID: trackedFlight.AircraftID,
OperatorID: trackedFlight.OperatorID,
Type: ports.AlertTypeCancellation,
Severity: "high",
Title: "Flight Cancelled",
Message: fmt.Sprintf("Flight %s has been cancelled", trackedFlight.Callsign),
Source: "flightaware_webhook",
CreatedAt: time.Now(),
}
if err := h.trackingService.SetAlert(ctx, alert); err != nil {
h.logger.Error("Failed to create cancellation alert", "error", err)
}
// Broadcast via WebSocket
h.broadcastUpdate(trackedFlight.OperatorID, map[string]interface{}{
"type": "cancellation",
"flight_id": trackedFlight.FlightID,
"callsign": trackedFlight.Callsign,
})
return nil
}
// handleDelay processes delay events
func (h *WebhookHandler) handleDelay(ctx context.Context, event *ports.WebhookEvent) error {
trackedFlight, err := h.getTrackedFlightByFAID(ctx, event.FAFlightID)
if err != nil {
return err
}
// Get delay details
var delayMinutes int
if delay, ok := event.Payload["delay_minutes"].(float64); ok {
delayMinutes = int(delay)
}
delayStr := fmt.Sprintf("%d minutes", delayMinutes)
// Create alert
severity := "medium"
if delayMinutes > 60 {
severity = "high"
}
alert := &ports.TrackingAlert{
FlightID: trackedFlight.FlightID,
AircraftID: trackedFlight.AircraftID,
OperatorID: trackedFlight.OperatorID,
Type: ports.AlertTypeDelay,
Severity: severity,
Title: "Flight Delayed",
Message: fmt.Sprintf("Flight %s delayed by %s", trackedFlight.Callsign, delayStr),
CurrentValue: &delayStr,
Source: "flightaware_webhook",
CreatedAt: time.Now(),
}
if err := h.trackingService.SetAlert(ctx, alert); err != nil {
h.logger.Error("Failed to create delay alert", "error", err)
}
// Broadcast via WebSocket
h.broadcastUpdate(trackedFlight.OperatorID, map[string]interface{}{
"type": "delay",
"flight_id": trackedFlight.FlightID,
"callsign": trackedFlight.Callsign,
"delay_minutes": delayMinutes,
})
return nil
}
// handleETAChange processes ETA update events
func (h *WebhookHandler) handleETAChange(ctx context.Context, event *ports.WebhookEvent) error {
trackedFlight, err := h.getTrackedFlightByFAID(ctx, event.FAFlightID)
if err != nil {
return err
}
// Get ETA details
var newETA time.Time
var previousETA time.Time
if eta, ok := event.Payload["estimated_in"].(string); ok {
newETA, _ = time.Parse(time.RFC3339, eta)
}
if prevETA, ok := event.Payload["previous_eta"].(string); ok {
previousETA, _ = time.Parse(time.RFC3339, prevETA)
}
diff := newETA.Sub(previousETA)
diffMinutes := int(diff.Minutes())
// Only alert if significant change (> 10 minutes)
if abs(diffMinutes) < 10 {
return nil
}
changeStr := fmt.Sprintf("%d minutes", diffMinutes)
if diffMinutes > 0 {
changeStr = fmt.Sprintf("+%s", changeStr)
}
alert := &ports.TrackingAlert{
FlightID: trackedFlight.FlightID,
AircraftID: trackedFlight.AircraftID,
OperatorID: trackedFlight.OperatorID,
Type: ports.AlertTypeETAChange,
Severity: "medium",
Title: "ETA Updated",
Message: fmt.Sprintf("Flight %s ETA changed by %s", trackedFlight.Callsign, changeStr),
PreviousValue: strPtr(previousETA.Format("15:04")),
CurrentValue: strPtr(newETA.Format("15:04")),
Source: "flightaware_webhook",
CreatedAt: time.Now(),
}
if err := h.trackingService.SetAlert(ctx, alert); err != nil {
h.logger.Error("Failed to create ETA change alert", "error", err)
}
// Broadcast via WebSocket
h.broadcastUpdate(trackedFlight.OperatorID, map[string]interface{}{
"type": "eta_change",
"flight_id": trackedFlight.FlightID,
"callsign": trackedFlight.Callsign,
"new_eta": newETA,
"delta_minutes": diffMinutes,
})
return nil
}
// handlePositionUpdate processes position update events
func (h *WebhookHandler) handlePositionUpdate(ctx context.Context, event *ports.WebhookEvent) error {
// Position updates are handled by the polling service
// This webhook handler can trigger immediate updates if needed
trackedFlight, err := h.getTrackedFlightByFAID(ctx, event.FAFlightID)
if err != nil {
return err
}
// Broadcast via WebSocket for real-time map updates
h.broadcastUpdate(trackedFlight.OperatorID, map[string]interface{}{
"type": "position_update",
"flight_id": trackedFlight.FlightID,
"callsign": trackedFlight.Callsign,
"position": event.Payload,
})
return nil
}
// getTrackedFlightByFAID retrieves a tracked flight by FlightAware flight ID
func (h *WebhookHandler) getTrackedFlightByFAID(ctx context.Context, faFlightID string) (*ports.TrackedFlight, error) {
// This would query the database for the tracked flight
// Implementation depends on your repository layer
// Placeholder implementation
return nil, fmt.Errorf("not implemented")
}
// broadcastUpdate sends update to all connected clients for an operator
func (h *WebhookHandler) broadcastUpdate(operatorID uuid.UUID, data map[string]interface{}) {
if h.wsHub == nil {
return
}
message := websocket.Message{
Type: "tracking_update",
OperatorID: operatorID,
Data: data,
Timestamp: time.Now(),
}
h.wsHub.BroadcastToOperator(operatorID, message)
}
// Helper function
func strPtr(s string) *string {
return &s
}
func abs(n int) int {
if n < 0 {
return -n
}
return n
} E. Polling Service (internal/application/tracking/poller.go)¶
package tracking
import (
"context"
"fmt"
"sync"
"time"
"github.com/google/uuid"
"github.com/skybrief/internal/ports"
"github.com/skybrief/internal/domain/weather"
"github.com/skybrief/pkg/logger"
"github.com/skybrief/pkg/websocket"
)
// PollerConfig holds configuration for the tracking poller
type PollerConfig struct {
PollingInterval time.Duration // Default: 60 seconds
WeatherCheckInterval time.Duration // Default: 5 minutes
MaxConcurrentPolls int // Default: 10
EnableWeatherCorrelation bool
}
// DefaultPollerConfig returns default configuration
func DefaultPollerConfig() PollerConfig {
return PollerConfig{
PollingInterval: 60 * time.Second,
WeatherCheckInterval: 5 * time.Minute,
MaxConcurrentPolls: 10,
EnableWeatherCorrelation: true,
}
}
// PollingService manages background polling of active flights
type PollingService struct {
config PollerConfig
trackingProvider ports.ExternalTrackingProvider
positionRepo ports.AircraftPositionRepository
trackingService ports.FlightTrackingService
trackedFlightRepo TrackedFlightRepository
weatherService weather.Service
wsHub *websocket.Hub
cache Cache // Redis
logger logger.Logger
// Control
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
mu sync.RWMutex
activeFlights map[uuid.UUID]*trackedFlightState
}
// trackedFlightState tracks the state of an actively polled flight
type trackedFlightState struct {
FlightID uuid.UUID
FAFlightID string
LastPolled time.Time
LastPosition *ports.AircraftPosition
LastWeatherCheck time.Time
ConsecutiveErrors int
IsPolling bool
}
// NewPollingService creates a new polling service
func NewPollingService(
config PollerConfig,
trackingProvider ports.ExternalTrackingProvider,
positionRepo ports.AircraftPositionRepository,
trackingService ports.FlightTrackingService,
trackedFlightRepo TrackedFlightRepository,
weatherService weather.Service,
wsHub *websocket.Hub,
cache Cache,
logger logger.Logger,
) *PollingService {
ctx, cancel := context.WithCancel(context.Background())
return &PollingService{
config: config,
trackingProvider: trackingProvider,
positionRepo: positionRepo,
trackingService: trackingService,
trackedFlightRepo: trackedFlightRepo,
weatherService: weatherService,
wsHub: wsHub,
cache: cache,
logger: logger,
ctx: ctx,
cancel: cancel,
activeFlights: make(map[uuid.UUID]*trackedFlightState),
}
}
// Start begins the polling service
func (s *PollingService) Start() error {
s.logger.Info("Starting tracking polling service",
"polling_interval", s.config.PollingInterval,
"weather_interval", s.config.WeatherCheckInterval,
)
// Start the main polling loop
s.wg.Add(1)
go s.pollingLoop()
// Start the weather correlation loop
if s.config.EnableWeatherCorrelation {
s.wg.Add(1)
go s.weatherCorrelationLoop()
}
return nil
}
// Stop gracefully stops the polling service
func (s *PollingService) Stop() error {
s.logger.Info("Stopping tracking polling service")
s.cancel()
s.wg.Wait()
return nil
}
// AddFlight adds a flight to the polling list
func (s *PollingService) AddFlight(flightID uuid.UUID, faFlightID string) {
s.mu.Lock()
defer s.mu.Unlock()
s.activeFlights[flightID] = &trackedFlightState{
FlightID: flightID,
FAFlightID: faFlightID,
LastPolled: time.Time{},
IsPolling: true,
}
s.logger.Info("Added flight to polling", "flight_id", flightID, "fa_flight_id", faFlightID)
}
// RemoveFlight removes a flight from the polling list
func (s *PollingService) RemoveFlight(flightID uuid.UUID) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.activeFlights, flightID)
s.logger.Info("Removed flight from polling", "flight_id", flightID)
}
// pollingLoop runs the main polling loop
func (s *PollingService) pollingLoop() {
defer s.wg.Done()
ticker := time.NewTicker(s.config.PollingInterval)
defer ticker.Stop()
// Semaphore for concurrent polls
semaphore := make(chan struct{}, s.config.MaxConcurrentPolls)
for {
select {
case <-s.ctx.Done():
return
case <-ticker.C:
s.pollAllFlights(semaphore)
}
}
}
// pollAllFlights polls all active flights
func (s *PollingService) pollAllFlights(semaphore chan struct{}) {
s.mu.RLock()
flights := make([]*trackedFlightState, 0, len(s.activeFlights))
for _, state := range s.activeFlights {
if state.IsPolling {
flights = append(flights, state)
}
}
s.mu.RUnlock()
for _, flight := range flights {
select {
case semaphore <- struct{}{}:
s.wg.Add(1)
go func(f *trackedFlightState) {
defer s.wg.Done()
defer func() { <-semaphore }()
if err := s.pollFlight(f); err != nil {
s.logger.Error("Failed to poll flight",
"flight_id", f.FlightID,
"fa_flight_id", f.FAFlightID,
"error", err,
)
// Track consecutive errors
s.mu.Lock()
f.ConsecutiveErrors++
if f.ConsecutiveErrors > 5 {
s.logger.Warn("Disabling polling due to consecutive errors",
"flight_id", f.FlightID,
"errors", f.ConsecutiveErrors,
)
f.IsPolling = false
}
s.mu.Unlock()
} else {
s.mu.Lock()
f.ConsecutiveErrors = 0
s.mu.Unlock()
}
}(flight)
default:
// Semaphore full, skip this cycle
s.logger.Warn("Max concurrent polls reached, skipping flight", "flight_id", flight.FlightID)
}
}
}
// pollFlight polls a single flight and stores the position
func (s *PollingService) pollFlight(state *trackedFlightState) error {
ctx, cancel := context.WithTimeout(s.ctx, 30*time.Second)
defer cancel()
// Fetch current position from FlightAware
position, err := s.trackingProvider.GetFlightPosition(ctx, state.FAFlightID)
if err != nil {
return fmt.Errorf("fetching position: %w", err)
}
// Update last polled time
s.mu.Lock()
state.LastPolled = time.Now()
s.mu.Unlock()
// Skip if position hasn't changed (same timestamp)
if state.LastPosition != nil && position.Timestamp.Equal(state.LastPosition.Timestamp) {
return nil
}
// Get tracked flight details for IDs
trackedFlight, err := s.trackedFlightRepo.GetByFAFlightID(ctx, state.FAFlightID)
if err != nil {
return fmt.Errorf("getting tracked flight: %w", err)
}
// Convert to domain position
domainPosition := &ports.AircraftPosition{
ID: uuid.New(),
FlightID: trackedFlight.FlightID,
AircraftID: trackedFlight.AircraftID,
OperatorID: trackedFlight.OperatorID,
Latitude: position.Latitude,
Longitude: position.Longitude,
Altitude: position.Altitude,
GroundSpeed: position.GroundSpeed,
Track: position.Track,
VerticalSpeed: position.VerticalSpeed,
Source: "flightaware",
Timestamp: position.Timestamp,
ReceivedAt: time.Now(),
FAFlightID: state.FAFlightID,
}
// Save position to database
if err := s.positionRepo.SavePosition(ctx, domainPosition); err != nil {
return fmt.Errorf("saving position: %w", err)
}
// Update state
s.mu.Lock()
state.LastPosition = domainPosition
s.mu.Unlock()
// Cache for quick access
cacheKey := fmt.Sprintf("position:%s", trackedFlight.AircraftID)
s.cache.Set(ctx, cacheKey, domainPosition, 5*time.Minute)
// Broadcast via WebSocket
if s.wsHub != nil {
s.wsHub.BroadcastToOperator(trackedFlight.OperatorID, websocket.Message{
Type: "position",
OperatorID: trackedFlight.OperatorID,
Data: domainPosition,
Timestamp: time.Now(),
})
}
s.logger.Debug("Polled flight position",
"flight_id", state.FlightID,
"fa_flight_id", state.FAFlightID,
"altitude", position.Altitude,
"speed", position.GroundSpeed,
)
return nil
}
// weatherCorrelationLoop periodically checks weather for active flights
func (s *PollingService) weatherCorrelationLoop() {
defer s.wg.Done()
ticker := time.NewTicker(s.config.WeatherCheckInterval)
defer ticker.Stop()
for {
select {
case <-s.ctx.Done():
return
case <-ticker.C:
s.checkWeatherForActiveFlights()
}
}
}
// checkWeatherForActiveFlights checks weather for all active flights
func (s *PollingService) checkWeatherForActiveFlights() {
s.mu.RLock()
flights := make([]*trackedFlightState, 0, len(s.activeFlights))
for _, state := range s.activeFlights {
if state.IsPolling && state.LastPosition != nil {
// Check if enough time has passed since last weather check
if time.Since(state.LastWeatherCheck) >= s.config.WeatherCheckInterval {
flights = append(flights, state)
}
}
}
s.mu.RUnlock()
for _, flight := range flights {
s.wg.Add(1)
go func(f *trackedFlightState) {
defer s.wg.Done()
if err := s.correlateWeatherForFlight(f); err != nil {
s.logger.Error("Failed to correlate weather",
"flight_id", f.FlightID,
"error", err,
)
} else {
s.mu.Lock()
f.LastWeatherCheck = time.Now()
s.mu.Unlock()
}
}(flight)
}
}
// correlateWeatherForFlight checks weather at the aircraft's current position
func (s *PollingService) correlateWeatherForFlight(state *trackedFlightState) error {
ctx, cancel := context.WithTimeout(s.ctx, 30*time.Second)
defer cancel()
if state.LastPosition == nil {
return nil
}
// Get tracked flight details
trackedFlight, err := s.trackedFlightRepo.GetByFAFlightID(ctx, state.FAFlightID)
if err != nil {
return fmt.Errorf("getting tracked flight: %w", err)
}
// Find nearest airport with METAR
nearestAirport, err := s.findNearestAirport(ctx, state.LastPosition.Latitude, state.LastPosition.Longitude)
if err != nil {
return fmt.Errorf("finding nearest airport: %w", err)
}
// Fetch weather for nearest airport
metar, err := s.weatherService.GetMETAR(ctx, nearestAirport)
if err != nil {
return fmt.Errorf("fetching METAR: %w", err)
}
// Check for SIGMETs along the route
sigmets, err := s.weatherService.GetSIGMETsForArea(ctx,
state.LastPosition.Latitude,
state.LastPosition.Longitude,
50, // 50nm radius
)
if err != nil {
s.logger.Error("Failed to fetch SIGMETs", "error", err)
}
// Analyze weather conditions
alerts := s.analyzeWeatherConditions(trackedFlight, state.LastPosition, metar, sigmets)
// Generate alerts if needed
for _, alert := range alerts {
if err := s.trackingService.SetAlert(ctx, &alert); err != nil {
s.logger.Error("Failed to create weather alert", "error", err)
}
}
// Record correlation check
correlation := &WeatherCorrelationCheck{
FlightID: trackedFlight.FlightID,
TrackedFlightID: &trackedFlight.ID,
OperatorID: trackedFlight.OperatorID,
PositionID: state.LastPosition.ID,
Latitude: state.LastPosition.Latitude,
Longitude: state.LastPosition.Longitude,
Altitude: state.LastPosition.Altitude,
METARStation: nearestAirport,
METARData: metar.Raw,
SIGMETAffected: len(sigmets) > 0,
SIGMETData: sigmets,
AlertsGenerated: len(alerts),
CheckedAt: time.Now(),
}
if err := s.saveCorrelationCheck(ctx, correlation); err != nil {
s.logger.Error("Failed to save correlation check", "error", err)
}
return nil
}
// findNearestAirport finds the nearest airport to a given position
func (s *PollingService) findNearestAirport(ctx context.Context, lat, lon float64) (string, error) {
// This would query your airports database for the nearest airport
// Implementation depends on your airport repository
// Placeholder - return a hardcoded value or implement spatial query
return "", fmt.Errorf("not implemented")
}
// analyzeWeatherConditions analyzes weather and generates alerts
func (s *PollingService) analyzeWeatherConditions(
trackedFlight *ports.TrackedFlight,
position *ports.AircraftPosition,
metar *weather.METAR,
sigmets []weather.SIGMET,
) []ports.TrackingAlert {
var alerts []ports.TrackingAlert
// Check for severe weather in SIGMETs
for _, sigmet := range sigmets {
alert := ports.TrackingAlert{
FlightID: trackedFlight.FlightID,
AircraftID: trackedFlight.AircraftID,
OperatorID: trackedFlight.OperatorID,
Type: ports.AlertTypeWeatherEnRoute,
Severity: "high",
Title: "SIGMET Active Along Route",
Message: fmt.Sprintf("SIGMET for %s: %s", sigmet.Phenomenon, sigmet.Description),
Source: "weather_correlation",
CreatedAt: time.Now(),
}
alerts = append(alerts, alert)
}
// Check for deteriorating conditions
if metar != nil && metar.Visibility < 5000 { // Less than 5km visibility
alert := ports.TrackingAlert{
FlightID: trackedFlight.FlightID,
AircraftID: trackedFlight.AircraftID,
OperatorID: trackedFlight.OperatorID,
Type: ports.AlertTypeWeatherEnRoute,
Severity: "medium",
Title: "Low Visibility Along Route",
Message: fmt.Sprintf("Visibility at %s: %dm", metar.Station, metar.Visibility),
Source: "weather_correlation",
CreatedAt: time.Now(),
}
alerts = append(alerts, alert)
}
return alerts
}
// GetActiveFlights returns the list of currently tracked flights
func (s *PollingService) GetActiveFlights() []uuid.UUID {
s.mu.RLock()
defer s.mu.RUnlock()
flights := make([]uuid.UUID, 0, len(s.activeFlights))
for id := range s.activeFlights {
flights = append(flights, id)
}
return flights
}
// TrackedFlightRepository defines the interface for tracked flight storage
type TrackedFlightRepository interface {
GetByFAFlightID(ctx context.Context, faFlightID string) (*ports.TrackedFlight, error)
GetByFlightID(ctx context.Context, flightID uuid.UUID) (*ports.TrackedFlight, error)
ListActive(ctx context.Context) ([]*ports.TrackedFlight, error)
}
// WeatherCorrelationCheck represents a weather correlation record
type WeatherCorrelationCheck struct {
ID uuid.UUID
FlightID uuid.UUID
TrackedFlightID *uuid.UUID
OperatorID uuid.UUID
PositionID uuid.UUID
Latitude float64
Longitude float64
Altitude int
METARStation string
METARData string
SIGMETAffected bool
SIGMETData []weather.SIGMET
AlertsGenerated int
CheckedAt time.Time
}
// saveCorrelationCheck saves the correlation check to database
func (s *PollingService) saveCorrelationCheck(ctx context.Context, check *WeatherCorrelationCheck) error {
// Implementation depends on your repository layer
return nil
} 4. Frontend Implementation¶
A. Fleet Dashboard Map Component (React + Mapbox GL JS)¶
// frontend/src/components/fleet/FleetDashboard.tsx
import React, { useEffect, useRef, useState, useCallback } from 'react';
import mapboxgl from 'mapbox-gl';
import 'mapbox-gl/dist/mapbox-gl.css';
import { useAuth } from '@clerk/clerk-react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { AircraftMarker } from './AircraftMarker';
import { WeatherOverlay } from './WeatherOverlay';
import { useWebSocket } from '../../hooks/useWebSocket';
import { api } from '../../lib/api';
interface Aircraft {
id: string;
registration: string;
type: string;
icaoType: string;
callsign: string;
origin: string;
destination: string;
status: 'planned' | 'scheduled' | 'active' | 'arrived' | 'diverted';
latitude?: number;
longitude?: number;
altitude?: number;
groundSpeed?: number;
track?: number;
eta?: string;
}
interface FleetDashboardProps {
className?: string;
}
const MAPBOX_TOKEN = import.meta.env.VITE_MAPBOX_ACCESS_TOKEN;
export const FleetDashboard: React.FC<FleetDashboardProps> = ({ className = '' }) => {
const mapContainer = useRef<HTMLDivElement>(null);
const map = useRef<mapboxgl.Map | null>(null);
const markersRef = useRef<Map<string, mapboxgl.Marker>>(new Map());
const { orgId } = useAuth();
const queryClient = useQueryClient();
const [selectedAircraft, setSelectedAircraft] = useState<Aircraft | null>(null);
const [showWeatherOverlay, setShowWeatherOverlay] = useState(false);
const [mapStyle, setMapStyle] = useState('mapbox://styles/mapbox/light-v11');
// Fetch fleet data
const { data: fleet, isLoading } = useQuery({
queryKey: ['fleet', orgId],
queryFn: async () => {
const response = await api.get('/fleet', {
params: { operator_id: orgId },
});
return response.data as Aircraft[];
},
refetchInterval: 30000, // Refetch every 30 seconds as fallback
});
// Initialize map
useEffect(() => {
if (!mapContainer.current || map.current) return;
mapboxgl.accessToken = MAPBOX_TOKEN;
map.current = new mapboxgl.Map({
container: mapContainer.current,
style: mapStyle,
center: [2.1686, 41.3874], // Default: Barcelona
zoom: 6,
attributionControl: false,
});
map.current.addControl(new mapboxgl.NavigationControl(), 'top-right');
map.current.addControl(new mapboxgl.FullscreenControl(), 'top-right');
map.current.addControl(
new mapboxgl.AttributionControl({ compact: true }),
'bottom-right'
);
return () => {
map.current?.remove();
map.current = null;
};
}, []);
// Update map style
useEffect(() => {
if (map.current) {
map.current.setStyle(mapStyle);
}
}, [mapStyle]);
// Handle real-time updates via WebSocket
const handleWebSocketMessage = useCallback((message: any) => {
if (message.type === 'position' && message.data) {
const position = message.data;
// Update fleet data in cache
queryClient.setQueryData(['fleet', orgId], (oldData: Aircraft[] | undefined) => {
if (!oldData) return oldData;
return oldData.map(aircraft => {
if (aircraft.id === position.aircraft_id) {
return {
...aircraft,
latitude: position.latitude,
longitude: position.longitude,
altitude: position.altitude,
groundSpeed: position.ground_speed,
track: position.track,
};
}
return aircraft;
});
});
}
}, [orgId, queryClient]);
useWebSocket('/ws/fleet-updates', handleWebSocketMessage);
// Update aircraft markers
useEffect(() => {
if (!map.current || !fleet) return;
const currentMarkers = markersRef.current;
const processedAircraft = new Set<string>();
fleet.forEach(aircraft => {
processedAircraft.add(aircraft.id);
if (!aircraft.latitude || !aircraft.longitude) {
// Remove marker if no position
if (currentMarkers.has(aircraft.id)) {
currentMarkers.get(aircraft.id)!.remove();
currentMarkers.delete(aircraft.id);
}
return;
}
const el = document.createElement('div');
el.innerHTML = AircraftMarker({ aircraft, isSelected: selectedAircraft?.id === aircraft.id });
el.className = 'aircraft-marker';
el.style.cursor = 'pointer';
// Create or update marker
if (currentMarkers.has(aircraft.id)) {
const marker = currentMarkers.get(aircraft.id)!;
marker.setLngLat([aircraft.longitude, aircraft.latitude]);
marker.setPopup(new mapboxgl.Popup({ offset: 25 }).setHTML(createPopupContent(aircraft)));
} else {
const marker = new mapboxgl.Marker(el)
.setLngLat([aircraft.longitude, aircraft.latitude])
.setPopup(new mapboxgl.Popup({ offset: 25 }).setHTML(createPopupContent(aircraft)))
.addTo(map.current!);
el.addEventListener('click', () => setSelectedAircraft(aircraft));
currentMarkers.set(aircraft.id, marker);
}
});
// Remove markers for aircraft no longer in fleet
currentMarkers.forEach((marker, id) => {
if (!processedAircraft.has(id)) {
marker.remove();
currentMarkers.delete(id);
}
});
}, [fleet, selectedAircraft]);
// Fit map to show all aircraft
const fitToFleet = useCallback(() => {
if (!map.current || !fleet || fleet.length === 0) return;
const activeAircraft = fleet.filter(a => a.latitude && a.longitude);
if (activeAircraft.length === 0) return;
const bounds = new mapboxgl.LngLatBounds();
activeAircraft.forEach(aircraft => {
bounds.extend([aircraft.longitude!, aircraft.latitude!]);
});
map.current.fitBounds(bounds, { padding: 50 });
}, [fleet]);
const createPopupContent = (aircraft: Aircraft): string => {
return `
<div class="p-2">
<h3 class="font-bold text-sm">${aircraft.callsign || aircraft.registration}</h3>
<p class="text-xs text-gray-600">${aircraft.type}</p>
<div class="mt-2 text-xs">
<div>FL${Math.round((aircraft.altitude || 0) / 100)}</div>
<div>${aircraft.groundSpeed}kts</div>
<div>${aircraft.origin} → ${aircraft.destination}</div>
${aircraft.eta ? `<div>ETA: ${new Date(aircraft.eta).toLocaleTimeString()}</div>` : ''}
</div>
</div>
`;
};
return (
<div className={`relative h-full ${className}`}>
{/* Map Container */}
<div ref={mapContainer} className="absolute inset-0" />
{/* Loading Overlay */}
{isLoading && (
<div className="absolute inset-0 bg-white/50 flex items-center justify-center z-10">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600" />
</div>
)}
{/* Controls Overlay */}
<div className="absolute top-4 left-4 z-10 space-y-2">
<button
onClick={fitToFleet}
className="bg-white px-3 py-2 rounded shadow hover:bg-gray-50 text-sm font-medium"
>
Fit to Fleet
</button>
<button
onClick={() => setShowWeatherOverlay(!showWeatherOverlay)}
className={`block w-full px-3 py-2 rounded shadow text-sm font-medium ${
showWeatherOverlay ? 'bg-blue-600 text-white' : 'bg-white hover:bg-gray-50'
}`}
>
Weather Overlay
</button>
<select
value={mapStyle}
onChange={(e) => setMapStyle(e.target.value)}
className="block w-full px-3 py-2 rounded shadow text-sm bg-white"
>
<option value="mapbox://styles/mapbox/light-v11">Light</option>
<option value="mapbox://styles/mapbox/dark-v11">Dark</option>
<option value="mapbox://styles/mapbox/satellite-v9">Satellite</option>
<option value="mapbox://styles/mapbox/navigation-day-v1">Navigation</option>
</select>
</div>
{/* Weather Overlay */}
{showWeatherOverlay && map.current && (
<WeatherOverlay map={map.current} visible={showWeatherOverlay} />
)}
{/* Fleet Stats */}
{fleet && (
<div className="absolute bottom-4 left-4 z-10 bg-white/90 backdrop-blur rounded-lg p-3 shadow">
<div className="text-sm font-medium">
Fleet Status
</div>
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
<span className="text-gray-600">Active:</span>
<span className="font-medium">{fleet.filter(a => a.status === 'active').length}</span>
<span className="text-gray-600">Scheduled:</span>
<span className="font-medium">{fleet.filter(a => a.status === 'scheduled').length}</span>
<span className="text-gray-600">Total:</span>
<span className="font-medium">{fleet.length}</span>
</div>
</div>
)}
</div>
);
};
// AircraftMarker component
const AircraftMarker: React.FC<{ aircraft: Aircraft; isSelected: boolean }> = ({
aircraft,
isSelected
}) => {
const getStatusColor = (status: string) => {
switch (status) {
case 'active': return '#22c55e'; // green
case 'scheduled': return '#3b82f6'; // blue
case 'arrived': return '#6b7280'; // gray
case 'diverted': return '#ef4444'; // red
default: return '#9ca3af';
}
};
const color = getStatusColor(aircraft.status);
const rotation = aircraft.track || 0;
return (
<div
className={`relative transition-transform duration-300 ${isSelected ? 'scale-125' : ''}`}
style={{ transform: `rotate(${rotation}deg)` }}
>
{/* Aircraft icon (simplified triangle) */}
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
className="drop-shadow-md"
>
<path
d="M12 2L14 10L22 12L14 14L12 22L10 14L2 12L10 10L12 2Z"
fill={color}
stroke="white"
strokeWidth="1.5"
/>
</svg>
{/* Status indicator */}
{aircraft.status === 'active' && (
<span className="absolute -top-1 -right-1 flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
</span>
)}
</div>
);
}; B. Fleet List Component with Filtering¶
// frontend/src/components/fleet/FleetList.tsx
import React, { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useAuth } from '@clerk/clerk-react';
import { api } from '../../lib/api';
import { Pagination } from '../ui/Pagination';
import { Badge } from '../ui/Badge';
import { Input } from '../ui/Input';
import { Select } from '../ui/Select';
interface Aircraft {
id: string;
registration: string;
type: string;
icaoType: string;
callsign: string;
origin: string;
destination: string;
status: 'planned' | 'scheduled' | 'active' | 'arrived' | 'diverted';
latitude?: number;
longitude?: number;
altitude?: number;
groundSpeed?: number;
eta?: string;
}
const ITEMS_PER_PAGE = 10;
export const FleetList: React.FC = () => {
const { orgId } = useAuth();
const [currentPage, setCurrentPage] = useState(1);
const [searchQuery, setSearchQuery] = useState('');
const [typeFilter, setTypeFilter] = useState<string>('all');
const [statusFilter, setStatusFilter] = useState<string>('all');
const { data: fleet, isLoading } = useQuery({
queryKey: ['fleet', orgId],
queryFn: async () => {
const response = await api.get('/fleet', {
params: { operator_id: orgId },
});
return response.data as Aircraft[];
},
});
// Get unique aircraft types for filter dropdown
const aircraftTypes = useMemo(() => {
if (!fleet) return [];
const types = new Set(fleet.map(a => a.type));
return Array.from(types).sort();
}, [fleet]);
// Filter and paginate
const filteredFleet = useMemo(() => {
if (!fleet) return [];
return fleet.filter(aircraft => {
// Search filter (registration or callsign)
const matchesSearch = !searchQuery ||
aircraft.registration.toLowerCase().includes(searchQuery.toLowerCase()) ||
(aircraft.callsign && aircraft.callsign.toLowerCase().includes(searchQuery.toLowerCase()));
// Type filter
const matchesType = typeFilter === 'all' || aircraft.type === typeFilter;
// Status filter
const matchesStatus = statusFilter === 'all' || aircraft.status === statusFilter;
return matchesSearch && matchesType && matchesStatus;
});
}, [fleet, searchQuery, typeFilter, statusFilter]);
const totalPages = Math.ceil(filteredFleet.length / ITEMS_PER_PAGE);
const paginatedFleet = filteredFleet.slice(
(currentPage - 1) * ITEMS_PER_PAGE,
currentPage * ITEMS_PER_PAGE
);
const getStatusColor = (status: string) => {
switch (status) {
case 'active': return 'green';
case 'scheduled': return 'blue';
case 'arrived': return 'gray';
case 'diverted': return 'red';
default: return 'gray';
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600" />
</div>
);
}
return (
<div className="space-y-4">
{/* Filters */}
<div className="bg-white p-4 rounded-lg shadow-sm border space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Search */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Search Registration/Callsign
</label>
<Input
type="text"
placeholder="e.g., EC-ABC or IBE123..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full"
/>
</div>
{/* Type Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Aircraft Type
</label>
<Select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
className="w-full"
>
<option value="all">All Types</option>
{aircraftTypes.map(type => (
<option key={type} value={type}>{type}</option>
))}
</Select>
</div>
{/* Status Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Status
</label>
<Select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="w-full"
>
<option value="all">All Status</option>
<option value="active">Active</option>
<option value="scheduled">Scheduled</option>
<option value="arrived">Arrived</option>
<option value="diverted">Diverted</option>
</Select>
</div>
</div>
{/* Results count */}
<div className="text-sm text-gray-600">
Showing {filteredFleet.length} aircraft
{filteredFleet.length !== (fleet?.length || 0) && ` (filtered from ${fleet?.length})`}
</div>
</div>
{/* Aircraft Grid */}
<div className="grid grid-cols-1 gap-4">
{paginatedFleet.map(aircraft => (
<div
key={aircraft.id}
className="bg-white p-4 rounded-lg shadow-sm border hover:shadow-md transition-shadow"
>
<div className="flex items-start justify-between">
{/* Left: Aircraft Info */}
<div className="flex-1">
<div className="flex items-center gap-3">
<h3 className="text-lg font-semibold text-gray-900">
{aircraft.registration}
</h3>
<Badge color={getStatusColor(aircraft.status)}>
{aircraft.status.charAt(0).toUpperCase() + aircraft.status.slice(1)}
</Badge>
{aircraft.callsign && (
<span className="text-sm text-gray-500">
{aircraft.callsign}
</span>
)}
</div>
<p className="text-sm text-gray-600 mt-1">
{aircraft.type} ({aircraft.icaoType})
</p>
{/* Route */}
<div className="flex items-center gap-2 mt-3 text-sm">
<span className="font-medium">{aircraft.origin}</span>
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
<span className="font-medium">{aircraft.destination}</span>
</div>
{/* Position Details (if active) */}
{aircraft.status === 'active' && aircraft.latitude && (
<div className="mt-3 grid grid-cols-3 gap-4 text-sm">
<div>
<span className="text-gray-500">Altitude</span>
<p className="font-medium">FL{Math.round((aircraft.altitude || 0) / 100)}</p>
</div>
<div>
<span className="text-gray-500">Speed</span>
<p className="font-medium">{aircraft.groundSpeed}kts</p>
</div>
<div>
<span className="text-gray-500">ETA</span>
<p className="font-medium">
{aircraft.eta ? new Date(aircraft.eta).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '--:--'}
</p>
</div>
</div>
)}
</div>
{/* Right: Actions */}
<div className="flex flex-col gap-2">
<button
onClick={() => {/* Navigate to flight detail */}}
className="text-sm text-blue-600 hover:text-blue-800 font-medium"
>
View Details
</button>
{aircraft.status === 'active' && (
<button
onClick={() => {/* Focus map on aircraft */}}
className="text-sm text-gray-600 hover:text-gray-800"
>
Track Live
</button>
)}
</div>
</div>
</div>
))}
</div>
{/* Empty State */}
{paginatedFleet.length === 0 && !isLoading && (
<div className="text-center py-12 bg-gray-50 rounded-lg">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">No aircraft found</h3>
<p className="mt-1 text-sm text-gray-500">
{searchQuery || typeFilter !== 'all' || statusFilter !== 'all'
? 'Try adjusting your filters'
: 'No aircraft in your fleet'}
</p>
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
)}
</div>
);
}; C. Alert Components¶
// frontend/src/components/alerts/AlertNotification.tsx
import React, { useState, useEffect } from 'react';
import { useAuth } from '@clerk/clerk-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '../../lib/api';
import { useWebSocket } from '../../hooks/useWebSocket';
import { formatDistanceToNow } from 'date-fns';
interface Alert {
id: string;
type: 'diversion' | 'arrival' | 'departure' | 'cancellation' | 'delay' | 'weather_enroute' | 'eta_change';
severity: 'low' | 'medium' | 'high' | 'critical';
title: string;
message: string;
callsign: string;
origin: string;
destination: string;
createdAt: string;
acknowledgedAt?: string;
}
export const AlertNotification: React.FC = () => {
const { orgId } = useAuth();
const queryClient = useQueryClient();
const [isOpen, setIsOpen] = useState(false);
const [unreadCount, setUnreadCount] = useState(0);
const { data: alerts, refetch } = useQuery({
queryKey: ['alerts', orgId],
queryFn: async () => {
const response = await api.get('/alerts', {
params: {
operator_id: orgId,
unacknowledged_only: true,
limit: 50
},
});
return response.data as Alert[];
},
refetchInterval: 30000,
});
// Update unread count
useEffect(() => {
if (alerts) {
setUnreadCount(alerts.filter(a => !a.acknowledgedAt).length);
}
}, [alerts]);
// Handle real-time alert updates
const handleWebSocketMessage = (message: any) => {
if (['diversion', 'arrival', 'departure', 'cancellation', 'delay', 'weather_enroute', 'eta_change'].includes(message.type)) {
// Play notification sound for critical alerts
if (message.severity === 'critical') {
new Audio('/sounds/alert-critical.mp3').play().catch(() => {});
}
// Refetch alerts
refetch();
}
};
useWebSocket('/ws/fleet-updates', handleWebSocketMessage);
const acknowledgeMutation = useMutation({
mutationFn: async (alertId: string) => {
await api.post(`/alerts/${alertId}/acknowledge`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['alerts', orgId] });
},
});
const getSeverityColor = (severity: string) => {
switch (severity) {
case 'critical': return 'bg-red-100 text-red-800 border-red-300';
case 'high': return 'bg-orange-100 text-orange-800 border-orange-300';
case 'medium': return 'bg-yellow-100 text-yellow-800 border-yellow-300';
default: return 'bg-blue-100 text-blue-800 border-blue-300';
}
};
const getAlertIcon = (type: string) => {
switch (type) {
case 'diversion':
return (
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
);
case 'arrival':
return (
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
);
case 'departure':
return (
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
);
default:
return (
<svg className="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
);
}
};
return (
<div className="relative">
{/* Alert Bell Button */}
<button
onClick={() => setIsOpen(!isOpen)}
className="relative p-2 text-gray-600 hover:text-gray-900 focus:outline-none"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
{unreadCount > 0 && (
<span className="absolute top-0 right-0 inline-flex items-center justify-center px-2 py-1 text-xs font-bold leading-none text-white transform translate-x-1/4 -translate-y-1/4 bg-red-600 rounded-full">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
{/* Alert Dropdown */}
{isOpen && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setIsOpen(false)}
/>
<div className="absolute right-0 mt-2 w-96 bg-white rounded-lg shadow-lg border z-20 max-h-[600px] overflow-hidden">
<div className="p-4 border-b flex items-center justify-between">
<h3 className="font-semibold text-gray-900">Alerts</h3>
{unreadCount > 0 && (
<span className="text-sm text-gray-500">
{unreadCount} unread
</span>
)}
</div>
<div className="overflow-y-auto max-h-[500px]">
{!alerts || alerts.length === 0 ? (
<div className="p-8 text-center text-gray-500">
<svg className="mx-auto h-12 w-12 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="mt-2">No active alerts</p>
</div>
) : (
<div className="divide-y divide-gray-100">
{alerts.map(alert => (
<div
key={alert.id}
className={`p-4 hover:bg-gray-50 transition-colors ${
!alert.acknowledgedAt ? 'bg-blue-50/50' : ''
}`}
>
<div className="flex items-start gap-3">
<div className="flex-shrink-0 mt-0.5">
{getAlertIcon(alert.type)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${getSeverityColor(alert.severity)}`}>
{alert.severity.toUpperCase()}
</span>
<span className="text-xs text-gray-500">
{formatDistanceToNow(new Date(alert.createdAt), { addSuffix: true })}
</span>
</div>
<h4 className="mt-1 text-sm font-medium text-gray-900">
{alert.title}
</h4>
<p className="mt-1 text-sm text-gray-600">
{alert.message}
</p>
<p className="mt-1 text-xs text-gray-500">
{alert.callsign} • {alert.origin} → {alert.destination}
</p>
{!alert.acknowledgedAt && (
<button
onClick={() => acknowledgeMutation.mutate(alert.id)}
disabled={acknowledgeMutation.isPending}
className="mt-2 text-xs text-blue-600 hover:text-blue-800 font-medium disabled:opacity-50"
>
{acknowledgeMutation.isPending ? 'Acknowledging...' : 'Acknowledge'}
</button>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
<div className="p-3 border-t bg-gray-50">
<a
href="/alerts"
className="block text-center text-sm text-blue-600 hover:text-blue-800 font-medium"
>
View All Alerts
</a>
</div>
</div>
</>
)}
</div>
);
};
// Alert History Page
export const AlertHistory: React.FC = () => {
const { orgId } = useAuth();
const [filter, setFilter] = useState({
type: 'all',
severity: 'all',
acknowledged: 'all',
});
const { data: alerts, isLoading } = useQuery({
queryKey: ['alerts', 'history', orgId, filter],
queryFn: async () => {
const params: any = { operator_id: orgId, limit: 100 };
if (filter.type !== 'all') params.type = filter.type;
if (filter.severity !== 'all') params.severity = filter.severity;
if (filter.acknowledged !== 'all') {
params.unacknowledged_only = filter.acknowledged === 'unacknowledged';
}
const response = await api.get('/alerts', { params });
return response.data as Alert[];
},
});
return (
<div className="max-w-6xl mx-auto p-6">
<h1 className="text-2xl font-bold text-gray-900 mb-6">Alert History</h1>
{/* Filters */}
<div className="bg-white p-4 rounded-lg shadow-sm border mb-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<select
value={filter.type}
onChange={(e) => setFilter(f => ({ ...f, type: e.target.value }))}
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
>
<option value="all">All Types</option>
<option value="diversion">Diversion</option>
<option value="arrival">Arrival</option>
<option value="departure">Departure</option>
<option value="delay">Delay</option>
<option value="weather_enroute">Weather</option>
<option value="eta_change">ETA Change</option>
</select>
<select
value={filter.severity}
onChange={(e) => setFilter(f => ({ ...f, severity: e.target.value }))}
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
>
<option value="all">All Severities</option>
<option value="critical">Critical</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
</select>
<select
value={filter.acknowledged}
onChange={(e) => setFilter(f => ({ ...f, acknowledged: e.target.value }))}
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
>
<option value="all">All Status</option>
<option value="acknowledged">Acknowledged</option>
<option value="unacknowledged">Unacknowledged</option>
</select>
</div>
</div>
{/* Alert List */}
{isLoading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600" />
</div>
) : (
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Type
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Flight
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Message
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Time
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{alerts?.map(alert => (
<tr key={alert.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
alert.severity === 'critical' ? 'bg-red-100 text-red-800' :
alert.severity === 'high' ? 'bg-orange-100 text-orange-800' :
alert.severity === 'medium' ? 'bg-yellow-100 text-yellow-800' :
'bg-blue-100 text-blue-800'
}`}>
{alert.type.replace('_', ' ')}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{alert.callsign}
</td>
<td className="px-6 py-4 text-sm text-gray-600 max-w-md">
{alert.message}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(alert.createdAt).toLocaleString()}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{alert.acknowledgedAt ? (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Acknowledged
</span>
) : (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
Pending
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}; 5. API Endpoints¶
REST API Specification¶
# Tracking Module API Endpoints
# ============================================================================
# FLEET ENDPOINTS
# ============================================================================
GET /api/v1/fleet
Description: List all aircraft in operator's fleet with current positions
Authentication: Required (JWT + Org ID)
Query Parameters:
- status: Filter by status (planned, scheduled, active, arrived, diverted)
- type: Filter by aircraft type
- registration: Search by registration
- page: Page number (default: 1)
- per_page: Items per page (default: 10, max: 100)
Response: 200 OK
{
"data": [
{
"id": "uuid",
"registration": "EC-ABC",
"type": "Cessna Citation X",
"icao_type": "C56X",
"callsign": "IBE123",
"origin": "LEMD",
"destination": "LEBL",
"status": "active",
"latitude": 41.385,
"longitude": 2.173,
"altitude": 35000,
"ground_speed": 450,
"track": 90,
"eta": "2024-01-15T14:30:00Z",
"last_updated": "2024-01-15T13:45:00Z"
}
],
"meta": {
"current_page": 1,
"total_pages": 3,
"total_count": 25
}
}
# ============================================================================
# AIRCRAFT POSITION ENDPOINTS
# ============================================================================
GET /api/v1/aircraft/{id}/position
Description: Get current position for a specific aircraft
Authentication: Required
Path Parameters:
- id: Aircraft UUID
Response: 200 OK
{
"id": "uuid",
"aircraft_id": "uuid",
"flight_id": "uuid",
"latitude": 41.385,
"longitude": 2.173,
"altitude": 35000,
"ground_speed": 450,
"track": 90,
"vertical_speed": 0,
"timestamp": "2024-01-15T13:45:00Z",
"source": "flightaware"
}
Errors:
404: Aircraft not found or no position data
# ============================================================================
# FLIGHT TRACK ENDPOINTS
# ============================================================================
GET /api/v1/flights/{id}/track
Description: Get full flight track (position history)
Authentication: Required
Path Parameters:
- id: Flight UUID
Query Parameters:
- format: Response format (json, geojson, kml) - default: json
Response: 200 OK
{
"flight_id": "uuid",
"fa_flight_id": "string",
"start_time": "2024-01-15T12:00:00Z",
"end_time": "2024-01-15T14:30:00Z",
"point_count": 150,
"positions": [
{
"latitude": 41.385,
"longitude": 2.173,
"altitude": 35000,
"ground_speed": 450,
"track": 90,
"timestamp": "2024-01-15T13:45:00Z"
}
]
}
GET /api/v1/flights/{id}/track/download
Description: Download flight track as file
Authentication: Required
Query Parameters:
- format: File format (kml, gpx, csv)
Response: File download
# ============================================================================
# FLIGHT ALERT ENDPOINTS
# ============================================================================
GET /api/v1/flights/{id}/alerts
Description: Get alerts for a specific flight
Authentication: Required
Query Parameters:
- unacknowledged_only: boolean
Response: 200 OK
{
"data": [
{
"id": "uuid",
"type": "weather_enroute",
"severity": "high",
"title": "SIGMET Active Along Route",
"message": "SIGMET for severe turbulence...",
"created_at": "2024-01-15T13:30:00Z",
"acknowledged_at": null,
"acknowledged_by": null
}
]
}
# ============================================================================
# GLOBAL ALERT ENDPOINTS
# ============================================================================
GET /api/v1/alerts
Description: List all alerts for operator
Authentication: Required
Query Parameters:
- type: Filter by alert type
- severity: Filter by severity
- flight_id: Filter by flight
- unacknowledged_only: boolean
- page: Page number
- per_page: Items per page
Response: 200 OK
{
"data": [/* Alert objects */],
"meta": { /* Pagination info */ }
}
POST /api/v1/alerts/{id}/acknowledge
Description: Acknowledge an alert
Authentication: Required
Path Parameters:
- id: Alert UUID
Response: 200 OK
{
"id": "uuid",
"acknowledged_at": "2024-01-15T14:00:00Z",
"acknowledged_by": "user_uuid"
}
# ============================================================================
# TRACKING CONTROL ENDPOINTS
# ============================================================================
POST /api/v1/flights/{id}/tracking/start
Description: Start tracking a flight
Authentication: Required (admin, dispatcher roles)
Request Body:
{
"fa_flight_id": "flight_aware_id" // Optional - will search if not provided
}
Response: 201 Created
{
"tracked_flight_id": "uuid",
"status": "tracking_started",
"fa_flight_id": "string"
}
POST /api/v1/flights/{id}/tracking/stop
Description: Stop tracking a flight
Authentication: Required (admin, dispatcher roles)
Response: 200 OK
{
"status": "tracking_stopped"
}
# ============================================================================
# PREDICTION ENDPOINTS (Foresight)
# ============================================================================
GET /api/v1/flights/{id}/prediction
Description: Get Foresight ETA prediction
Authentication: Required
Response: 200 OK
{
"flight_id": "uuid",
"predicted_eta": "2024-01-15T14:25:00Z",
"confidence": 0.92,
"trend": "early",
"minutes_offset": -5
}
# ============================================================================
# WEBSOCKET ENDPOINT
# ============================================================================
WS /ws/fleet-updates
Description: Real-time fleet updates via WebSocket
Authentication: Required (JWT in query param or header)
Protocol: Socket.io or native WebSocket
Messages:
Client → Server:
- subscribe: { "operator_id": "uuid" }
- ping: {}
Server → Client:
- position: { "type": "position", "data": AircraftPosition }
- departure: { "type": "departure", "flight_id": "uuid", "timestamp": "..." }
- arrival: { "type": "arrival", "flight_id": "uuid", "destination": "ICAO" }
- diversion: { "type": "diversion", "flight_id": "uuid", "severity": "critical", "diversion_airport": "ICAO" }
- alert: { "type": "alert", "alert": AlertObject }
- eta_change: { "type": "eta_change", "flight_id": "uuid", "delta_minutes": -5 }
# ============================================================================
# WEBHOOK ENDPOINTS (Internal)
# ============================================================================
POST /api/v1/webhooks/flightaware
Description: Receive webhooks from FlightAware
Authentication: HMAC Signature verification
Headers:
- X-FlightAware-Signature: sha256=<hex>
Body: FlightAware webhook payload
Response: 200 OK (always return 200 to acknowledge receipt) 6. Multi-tenancy & Security¶
Operator Isolation¶
All tracking data is scoped to the operator level. The system enforces multi-tenancy at multiple layers:
Database Level¶
-- Every query automatically filters by operator_id
SELECT * FROM tracked_flights WHERE operator_id = $1;
SELECT * FROM aircraft_positions WHERE operator_id = $1;
SELECT * FROM tracking_alerts WHERE operator_id = $1; Application Level (Go Middleware)¶
// internal/adapters/http/middleware/tenant.go
func TenantMiddleware() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// Extract org_id from JWT claims (set by auth middleware)
orgID, ok := c.Get("org_id").(string)
if !ok {
return echo.NewHTTPError(401, "Organization context required")
}
// Parse as UUID
operatorID, err := uuid.Parse(orgID)
if err != nil {
return echo.NewHTTPError(400, "Invalid organization ID")
}
// Add to context
c.Set("operator_id", operatorID)
return next(c)
}
}
}
// Usage in handlers
func (h *FleetHandler) GetFleet(c echo.Context) error {
operatorID := c.Get("operator_id").(uuid.UUID)
// All queries automatically scoped to operator_id
flights, err := h.service.ListTrackedFlights(c.Request().Context(), operatorID, nil)
if err != nil {
return err
}
return c.JSON(200, flights)
} JWT Claims Structure¶
{
"sub": "user_uuid",
"org_id": "operator_uuid",
"org_role": "admin",
"org_permissions": ["flights:create", "flights:read", "tracking:write"],
"iat": 1705315200,
"exp": 1705909999
} Role-Based Access Control¶
// Permissions for tracking module
const (
PermFleetRead = "fleet:read"
PermFleetWrite = "fleet:write"
PermTrackingControl = "tracking:control" // Start/stop tracking
PermAlertsRead = "alerts:read"
PermAlertsWrite = "alerts:write" // Acknowledge alerts
)
// Role permissions matrix
var rolePermissions = map[string][]string{
"org:admin": {
PermFleetRead, PermFleetWrite,
PermTrackingControl,
PermAlertsRead, PermAlertsWrite,
},
"org:chiefpilot": {
PermFleetRead, PermFleetWrite,
PermTrackingControl,
PermAlertsRead, PermAlertsWrite,
},
"org:dispatcher": {
PermFleetRead,
PermTrackingControl,
PermAlertsRead,
},
"org:pilot": {
PermFleetRead,
PermAlertsRead,
},
"org:readonly": {
PermFleetRead,
PermAlertsRead,
},
} Webhook Security¶
// HMAC Signature Verification
func VerifyWebhookSignature(payload []byte, signature string, secret string) error {
const prefix = "sha256="
if !strings.HasPrefix(signature, prefix) {
return errors.New("invalid signature format")
}
expectedSig := signature[len(prefix):]
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(payload)
computedSig := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expectedSig), []byte(computedSig)) {
return errors.New("signature mismatch")
}
return nil
}
// Webhook endpoint security headers
// - TLS 1.3 required
// - IP allowlist (FlightAware egress IPs)
// - Request timestamp validation (prevent replay attacks) Data Retention & Compliance¶
| Data Type | Retention | Reason |
|---|---|---|
| Aircraft Positions | 24 hours | Real-time tracking only |
| Flight Tracks | 12 months | EASA compliance audit |
| Flight Status Events | 12 months | Audit trail |
| Tracking Alerts | 12 months | Incident investigation |
| Weather Correlation | 7 days | Temporary analysis |
7. Integration Points¶
Weather Service Correlation¶
// Integration with existing weather service
func (s *TrackingService) CorrelateWeather(ctx context.Context, position *AircraftPosition) error {
// Find nearest airports with METAR
airports, err := s.airportRepo.FindNearest(ctx, position.Latitude, position.Longitude, 3)
if err != nil {
return err
}
// Fetch METARs
var metars []weather.METAR
for _, airport := range airports {
metar, err := s.weatherService.GetMETAR(ctx, airport.ICAO)
if err != nil {
s.logger.Error("Failed to fetch METAR", "icao", airport.ICAO, "error", err)
continue
}
metars = append(metars, *metar)
}
// Check for SIGMETs in area
sigmets, err := s.weatherService.GetSIGMETsForArea(ctx,
position.Latitude,
position.Longitude,
100, // 100nm radius
)
if err != nil {
s.logger.Error("Failed to fetch SIGMETs", "error", err)
}
// Generate alerts if needed
if len(sigmets) > 0 {
alert := &ports.TrackingAlert{
FlightID: position.FlightID,
Type: ports.AlertTypeWeatherEnRoute,
Severity: "high",
Title: "SIGMET Active",
Message: fmt.Sprintf("SIGMET for %s in vicinity", sigmets[0].Phenomenon),
Source: "weather_correlation",
}
s.alertRepo.SaveAlert(ctx, alert)
}
return nil
} Compliance Report Enhancement¶
// Add tracking data to compliance reports
func (s *ComplianceService) GenerateReportWithTracking(ctx context.Context, flightID uuid.UUID) (*ComplianceReport, error) {
// Get standard compliance data
report, err := s.generateBaseReport(ctx, flightID)
if err != nil {
return nil, err
}
// Add tracking data
track, err := s.trackingRepo.GetTrack(ctx, flightID)
if err == nil && track != nil {
report.TrackingData = &TrackingData{
ActualDeparture: track.StartTime,
ActualArrival: track.EndTime,
TrackPoints: track.PointCount,
RouteDeviation: s.calculateDeviation(track),
MaxAltitude: s.getMaxAltitude(track),
AverageSpeed: s.getAverageSpeed(track),
}
}
// Add any weather-related alerts during flight
alerts, err := s.alertRepo.ListForFlight(ctx, flightID)
if err == nil {
report.InFlightAlerts = alerts
}
return report, nil
} Alert Service Integration¶
// Send notifications via multiple channels
type AlertService struct {
emailService EmailService
smsService SMSService
pushService PushNotificationService
websocketHub *websocket.Hub
}
func (s *AlertService) SendAlert(ctx context.Context, alert *ports.TrackingAlert, operators []uuid.UUID) error {
// In-app notification via WebSocket
if s.websocketHub != nil {
s.websocketHub.BroadcastToOperators(operators, websocket.Message{
Type: "alert",
Data: alert,
})
}
// Email for critical alerts
if alert.Severity == "critical" {
for _, operatorID := range operators {
users, err := s.userRepo.GetByOperatorWithRole(ctx, operatorID, "admin", "chiefpilot")
if err != nil {
continue
}
for _, user := range users {
s.emailService.Send(ctx, user.Email, EmailTemplateCriticalAlert, alert)
}
}
}
// SMS for diversions
if alert.Type == ports.AlertTypeDiversion {
// Get phone numbers for on-call personnel
phones, err := s.getEmergencyContacts(ctx, alert.OperatorID)
if err == nil {
for _, phone := range phones {
s.smsService.Send(ctx, phone, fmt.Sprintf("DIVERSION ALERT: %s", alert.Message))
}
}
}
return nil
} 8. Configuration¶
Environment Variables¶
# =============================================================================
# FLIGHTAWARE AEROAPI CONFIGURATION
# =============================================================================
# Required: Your FlightAware AeroAPI Enterprise API key
FLIGHTAWARE_API_KEY=your_api_key_here
# Required: Webhook secret for HMAC signature verification
FLIGHTAWARE_WEBHOOK_SECRET=your_webhook_secret_here
# Optional: API base URL (default: https://aeroapi.flightaware.com/aeroapi)
FLIGHTAWARE_BASE_URL=https://aeroapi.flightaware.com/aeroapi
# Optional: Rate limit (requests per minute, default: 100)
FLIGHTAWARE_RATE_LIMIT=100
# Optional: Request timeout (default: 30s)
FLIGHTAWARE_TIMEOUT=30s
# =============================================================================
# TRACKING MODULE CONFIGURATION
# =============================================================================
# Polling interval for active flights (default: 60s)
TRACKING_POLL_INTERVAL=60s
# Weather correlation interval (default: 5m)
TRACKING_WEATHER_INTERVAL=5m
# Maximum concurrent API calls (default: 10)
TRACKING_MAX_CONCURRENT=10
# Position data TTL in Redis (default: 5m)
TRACKING_CACHE_TTL=5m
# Enable weather correlation (default: true)
TRACKING_ENABLE_WEATHER=true
# =============================================================================
# DATABASE CONFIGURATION (PostgreSQL)
# =============================================================================
# Connection string (already configured for main app)
# DATABASE_URL=postgresql://user:pass@localhost/skybrief
# Connection pool settings
DB_MAX_OPEN_CONNS=25
DB_MAX_IDLE_CONNS=5
DB_CONN_MAX_LIFETIME=5m
# =============================================================================
# REDIS CONFIGURATION (Caching)
# =============================================================================
# Redis URL (optional - falls back to in-memory cache)
REDIS_URL=redis://localhost:6379/0
# Redis connection pool
REDIS_POOL_SIZE=10
# =============================================================================
# WEBSOCKET CONFIGURATION
# =============================================================================
# WebSocket server port (default: 8080)
WS_PORT=8080
# WebSocket ping interval (default: 30s)
WS_PING_INTERVAL=30s
# Maximum connections per operator (default: 100)
WS_MAX_CONNECTIONS_PER_ORG=100
# =============================================================================
# WEBHOOK CONFIGURATION
# =============================================================================
# Public URL for FlightAware webhooks (must be HTTPS)
WEBHOOK_BASE_URL=https://api.skybrief.io
# Webhook endpoint path (default: /api/v1/webhooks/flightaware)
WEBHOOK_PATH=/api/v1/webhooks/flightaware
# =============================================================================
# MAPBOX CONFIGURATION
# =============================================================================
# Mapbox access token (for frontend)
VITE_MAPBOX_ACCESS_TOKEN=pk.your_mapbox_token_here
# =============================================================================
# SECURITY CONFIGURATION
# =============================================================================
# JWT secret (already configured for main app)
# JWT_SECRET=your_jwt_secret
# Webhook signature algorithm (default: sha256)
WEBHOOK_SIGNATURE_ALGO=sha256
# Allowed webhook IP ranges (FlightAware egress IPs)
WEBHOOK_ALLOWED_IPS=52.0.0.0/8,54.0.0.0/8
# Request timeout for external APIs
API_REQUEST_TIMEOUT=30s
# =============================================================================
# MONITORING & LOGGING
# =============================================================================
# Log level (debug, info, warn, error)
LOG_LEVEL=info
# Enable structured logging (JSON)
LOG_FORMAT=json
# Metrics endpoint enabled
METRICS_ENABLED=true
METRICS_PORT=9090 Configuration File (config/tracking.yaml)¶
# tracking.yaml - Tracking module configuration
flightaware:
api_key: ${FLIGHTAWARE_API_KEY}
webhook_secret: ${FLIGHTAWARE_WEBHOOK_SECRET}
base_url: "https://aeroapi.flightaware.com/aeroapi"
rate_limit: 100 # requests per minute
timeout: 30s
# Alert types to subscribe to
alert_types:
- departure
- arrival
- diversion
- cancellation
- delay
- eta_change
tracking:
poll_interval: 60s
weather_interval: 5m
max_concurrent: 10
enable_weather: true
# Automatic actions
auto_start_tracking: true # Start tracking when flight status changes to scheduled
auto_stop_on_arrival: true # Stop tracking after arrival
# Position data retention
position_ttl: 24h # Database cleanup
cache_ttl: 5m # Redis cache
database:
# Partition settings
partition_months_ahead: 2 # Create partitions N months ahead
# Cleanup jobs
cleanup:
positions:
enabled: true
interval: 1h
retain_for: 24h
correlation_checks:
enabled: true
interval: 6h
retain_for: 7d
tracks:
enabled: true
interval: 24h
archive_after: 12months
websocket:
enabled: true
ping_interval: 30s
max_connections_per_org: 100
# Message buffering
buffer_size: 256
write_timeout: 10s
webhook:
base_url: ${WEBHOOK_BASE_URL}
path: /api/v1/webhooks/flightaware
# Security
verify_signature: true
allowed_ips:
- "52.0.0.0/8"
- "54.0.0.0/8"
# Retry configuration
max_retries: 3
retry_delay: 5s
alerts:
# Severity thresholds
severity_levels:
critical:
channels: [websocket, email, sms]
sound: true
high:
channels: [websocket, email]
sound: true
medium:
channels: [websocket]
sound: false
low:
channels: [websocket]
sound: false
# Auto-acknowledge settings
auto_acknowledge:
enabled: false
after: 24h 9. Testing Strategy¶
Unit Tests¶
// internal/adapters/tracking/flightaware_test.go
package tracking
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFlightAwareAdapter_GetFlightPosition(t *testing.T) {
// Mock server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "GET", r.Method)
assert.Equal(t, "/aeroapi/flights/TEST123/position", r.URL.Path)
assert.Equal(t, "test-api-key", r.Header.Get("x-apikey"))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{
"position": {
"fa_flight_id": "TEST123",
"callsign": "IBE123",
"registration": "EC-ABC",
"latitude": 41.385,
"longitude": 2.173,
"altitude": 35000,
"groundspeed": 450,
"heading": 90,
"timestamp": 1705315200
}
}`))
}))
defer server.Close()
adapter, err := NewFlightAwareAdapter(Config{
APIKey: "test-api-key",
BaseURL: server.URL + "/aeroapi",
})
require.NoError(t, err)
position, err := adapter.GetFlightPosition(context.Background(), "TEST123")
require.NoError(t, err)
assert.Equal(t, "TEST123", position.FAFlightID)
assert.Equal(t, "IBE123", position.Callsign)
assert.Equal(t, 41.385, position.Latitude)
assert.Equal(t, 35000, position.Altitude)
}
func TestFlightAwareAdapter_VerifyWebhookSignature(t *testing.T) {
adapter, err := NewFlightAwareAdapter(Config{
APIKey: "test-key",
APISecret: "webhook-secret",
})
require.NoError(t, err)
payload := []byte(`{"event": "test"}`)
// Generate valid signature
mac := hmac.New(sha256.New, []byte("webhook-secret"))
mac.Write(payload)
validSig := "sha256=" + hex.EncodeToString(mac.Sum(nil))
tests := []struct {
name string
signature string
wantErr bool
}{
{"valid signature", validSig, false},
{"invalid signature", "sha256=invalid", true},
{"missing prefix", hex.EncodeToString(mac.Sum(nil)), true},
{"empty signature", "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := adapter.VerifyWebhookSignature(context.Background(), payload, tt.signature)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
} Integration Tests¶
// tests/integration/webhook_test.go
package integration
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/suite"
)
type WebhookIntegrationTestSuite struct {
suite.Suite
server *httptest.Server
handler *handlers.WebhookHandler
}
func (s *WebhookIntegrationTestSuite) SetupSuite() {
// Setup test database
// Initialize handler with test dependencies
}
func (s *WebhookIntegrationTestSuite) TestFlightAwareWebhook_Departure() {
e := echo.New()
payload := []byte(`{
"id": "evt_123",
"type": "flight",
"event_type": "departure",
"timestamp": 1705315200,
"fa_flight_id": "TEST123",
"payload": {
"actual_out": "2024-01-15T12:00:00Z"
}
}`)
// Generate valid signature
mac := hmac.New(sha256.New, []byte("test-secret"))
mac.Write(payload)
signature := "sha256=" + hex.EncodeToString(mac.Sum(nil))
req := httptest.NewRequest(http.MethodPost, "/api/v1/webhooks/flightaware", bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-FlightAware-Signature", signature)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
err := s.handler.HandleFlightAwareWebhook(c)
s.NoError(err)
s.Equal(http.StatusOK, rec.Code)
// Verify database state
// Verify WebSocket broadcast
// Verify alert creation
} Frontend Component Tests¶
// frontend/src/components/fleet/__tests__/FleetDashboard.test.tsx
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { FleetDashboard } from '../FleetDashboard';
import { server } from '../../../mocks/server';
import { rest } from 'msw';
describe('FleetDashboard', () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
it('renders loading state initially', () => {
render(
<QueryClientProvider client={queryClient}>
<FleetDashboard />
</QueryClientProvider>
);
expect(screen.getByRole('progressbar')).toBeInTheDocument();
});
it('renders fleet data after loading', async () => {
server.use(
rest.get('/api/v1/fleet', (req, res, ctx) => {
return res(ctx.json([
{
id: '1',
registration: 'EC-ABC',
type: 'Cessna Citation X',
status: 'active',
latitude: 41.385,
longitude: 2.173,
}
]));
})
);
render(
<QueryClientProvider client={queryClient}>
<FleetDashboard />
</QueryClientProvider>
);
await waitFor(() => {
expect(screen.getByText('EC-ABC')).toBeInTheDocument();
});
});
}); Load Testing¶
// tests/load/poller_load_test.go
package load
import (
"context"
"sync"
"testing"
"time"
)
func BenchmarkPollingService_PollAllFlights(b *testing.B) {
service := setupPollingService()
// Add 100 active flights
for i := 0; i < 100; i++ {
service.AddFlight(uuid.New(), fmt.Sprintf("FLIGHT%d", i))
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
service.pollAllFlights(ctx)
cancel()
}
}
func TestPollingService_ConcurrentPolls(t *testing.T) {
service := setupPollingService()
var wg sync.WaitGroup
errors := make(chan error, 100)
// Simulate 100 concurrent polling requests
for i := 0; i < 100; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
ctx := context.Background()
_, err := service.trackingProvider.GetFlightPosition(ctx, fmt.Sprintf("TEST%d", id))
if err != nil {
errors <- err
}
}(i)
}
wg.Wait()
close(errors)
errCount := 0
for err := range errors {
if err != nil {
errCount++
}
}
// Should not exceed rate limit errors
t.Logf("Errors: %d", errCount)
} End-to-End Testing Checklist¶
Pre-flight: - [ ] Create test flight - [ ] Start tracking - [ ] Verify polling begins - [ ] Check initial position on map
During Flight: - [ ] Verify position updates every 60s - [ ] Check weather correlation every 5m - [ ] Test manual map interactions - [ ] Verify WebSocket updates
Webhook Events: - [ ] Simulate departure webhook - [ ] Simulate position update webhook - [ ] Simulate diversion webhook - [ ] Simulate arrival webhook - [ ] Verify HMAC signature validation - [ ] Test invalid signature rejection
Alert Flow: - [ ] Trigger weather alert - [ ] Receive WebSocket notification - [ ] View alert in UI - [ ] Acknowledge alert - [ ] Verify acknowledgment persists
Post-flight: - [ ] Verify track data saved - [ ] Download track in KML/GPX format - [ ] Check compliance report includes tracking data - [ ] Verify position data cleanup after 24h
10. Deployment Checklist¶
Pre-deployment¶
- [ ] FlightAware Enterprise account provisioned
- [ ] API key and webhook secret generated
- [ ] Webhook endpoint configured in FlightAware dashboard
- [ ] SSL certificate valid for webhook endpoint
- [ ] Database migrations tested in staging
- [ ] Environment variables configured
- [ ] Redis instance provisioned (if using)
Database Migrations¶
# Run migrations
cd /var/www/skybrief
./migrate -path migrations -database "$DATABASE_URL" up
# Verify tables created
psql $DATABASE_URL -c "\dt"
# Create initial partitions
psql $DATABASE_URL -c "SELECT create_aircraft_positions_partition();" Deployment Steps¶
# 1. Deploy new backend version
git pull origin main
go build -o skybrief cmd/api/main.go
sudo systemctl restart skybrief
# 2. Verify health check
curl https://api.skybrief.io/health
# 3. Deploy frontend
npm run build
rsync -avz dist/ /var/www/skybrief.io/
# 4. Verify webhook endpoint
curl -X POST https://api.skybrief.io/api/v1/webhooks/flightaware \
-H "Content-Type: application/json" \
-d '{"test": true}'
# 5. Start polling service (if separate process)
sudo systemctl start skybrief-tracker
# 6. Monitor logs
journalctl -u skybrief -f Post-deployment Verification¶
# Test API endpoints
curl -H "Authorization: Bearer $TOKEN" \
https://api.skybrief.io/api/v1/fleet
# Test WebSocket connection
wscat -c "wss://api.skybrief.io/ws/fleet-updates?token=$TOKEN"
# Verify webhook is receiving events
# (Trigger a test flight in FlightAware dashboard)
# Check database performance
psql $DATABASE_URL -c "
SELECT schemaname, tablename, pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename))
FROM pg_tables
WHERE tablename LIKE 'aircraft_positions%'
ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC;
" Rollback Plan¶
# If critical issues detected:
# 1. Stop polling service
sudo systemctl stop skybrief-tracker
# 2. Revert to previous version
git checkout v1.x.x
go build -o skybrief cmd/api/main.go
sudo systemctl restart skybrief
# 3. If database changes need rollback:
./migrate -path migrations -database "$DATABASE_URL" down 1
# 4. Disable FlightAware webhooks temporarily
# (In FlightAware dashboard) Monitoring Setup¶
# Add to Prometheus configuration
scrape_configs:
- job_name: 'skybrief-tracking'
static_configs:
- targets: ['localhost:9090']
metrics_path: /metrics
# Key metrics to alert on:
# - tracking_poll_errors_rate > 0.1
# - tracking_webhook_processing_time > 5s
# - tracking_active_flights (should be > 0 during business hours)
# - database_connection_pool_usage > 0.8 Related Documents¶
- Product Architecture - Overall system architecture
- Build Plan - SkyBrief - Development timeline
- Go-To-Market Strategy - Market entry plan
- Action Items - Weekly task tracking
Document Version: 1.0
Last Updated: March 4, 2026
Next Review: After implementation completion