feat: add smart flight tracking with AviationStack API + visual progress

- Add 20+ flight fields (terminal, gate, delays, estimated times, etc.)
- Smart polling cron with budget-aware priority queue (100 req/month)
- Tracking phases: FAR_OUT → PRE_DEPARTURE → ACTIVE → LANDED
- Visual FlightProgressBar with animated airplane between airports
- FlightCard with status dots, delay badges, expandable details
- FlightList rewrite: card-based, grouped by status, search/filter
- Dashboard: enriched flight status widget with compact progress bars
- CommandCenter: flight alerts + enriched arrivals with gate/terminal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-07 19:42:52 +01:00
parent 74a292ea93
commit 0f0f1cbf38
13 changed files with 1688 additions and 325 deletions

View File

@@ -0,0 +1,47 @@
-- AlterTable
ALTER TABLE "flights" ADD COLUMN "aircraftType" TEXT,
ADD COLUMN "airlineIata" TEXT,
ADD COLUMN "airlineName" TEXT,
ADD COLUMN "arrivalBaggage" TEXT,
ADD COLUMN "arrivalDelay" INTEGER,
ADD COLUMN "arrivalGate" TEXT,
ADD COLUMN "arrivalTerminal" TEXT,
ADD COLUMN "autoTrackEnabled" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "departureDelay" INTEGER,
ADD COLUMN "departureGate" TEXT,
ADD COLUMN "departureTerminal" TEXT,
ADD COLUMN "estimatedArrival" TIMESTAMP(3),
ADD COLUMN "estimatedDeparture" TIMESTAMP(3),
ADD COLUMN "lastApiResponse" JSONB,
ADD COLUMN "lastPolledAt" TIMESTAMP(3),
ADD COLUMN "liveAltitude" DOUBLE PRECISION,
ADD COLUMN "liveDirection" DOUBLE PRECISION,
ADD COLUMN "liveIsGround" BOOLEAN,
ADD COLUMN "liveLatitude" DOUBLE PRECISION,
ADD COLUMN "liveLongitude" DOUBLE PRECISION,
ADD COLUMN "liveSpeed" DOUBLE PRECISION,
ADD COLUMN "liveUpdatedAt" TIMESTAMP(3),
ADD COLUMN "pollCount" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "trackingPhase" TEXT NOT NULL DEFAULT 'FAR_OUT';
-- CreateTable
CREATE TABLE "flight_api_budget" (
"id" TEXT NOT NULL,
"monthYear" TEXT NOT NULL,
"requestsUsed" INTEGER NOT NULL DEFAULT 0,
"requestLimit" INTEGER NOT NULL DEFAULT 100,
"lastRequestAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "flight_api_budget_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "flight_api_budget_monthYear_key" ON "flight_api_budget"("monthYear");
-- CreateIndex
CREATE INDEX "flights_trackingPhase_idx" ON "flights"("trackingPhase");
-- CreateIndex
CREATE INDEX "flights_scheduledDeparture_idx" ON "flights"("scheduledDeparture");

View File

@@ -73,6 +73,7 @@ model VIP {
enum Department {
OFFICE_OF_DEVELOPMENT
ADMIN
OTHER
}
enum ArrivalMode {
@@ -97,13 +98,70 @@ model Flight {
scheduledArrival DateTime?
actualDeparture DateTime?
actualArrival DateTime?
status String? // scheduled, delayed, landed, etc.
status String? // scheduled, active, landed, cancelled, incident, diverted
// Airline info (from AviationStack API)
airlineName String?
airlineIata String? // "AA", "UA", "DL"
// Terminal/gate/baggage (critical for driver dispatch)
departureTerminal String?
departureGate String?
arrivalTerminal String?
arrivalGate String?
arrivalBaggage String?
// Estimated times (updated by API, distinct from scheduled)
estimatedDeparture DateTime?
estimatedArrival DateTime?
// Delay in minutes (from API)
departureDelay Int?
arrivalDelay Int?
// Aircraft info
aircraftType String? // IATA type code e.g. "A321", "B738"
// Live position data (may not be available on free tier)
liveLatitude Float?
liveLongitude Float?
liveAltitude Float?
liveSpeed Float? // horizontal speed
liveDirection Float? // heading in degrees
liveIsGround Boolean?
liveUpdatedAt DateTime?
// Polling metadata
lastPolledAt DateTime?
pollCount Int @default(0)
trackingPhase String @default("FAR_OUT") // FAR_OUT, PRE_DEPARTURE, DEPARTURE_WINDOW, ACTIVE, ARRIVAL_WINDOW, LANDED, TERMINAL
autoTrackEnabled Boolean @default(true)
lastApiResponse Json? // Full AviationStack response for debugging
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("flights")
@@index([vipId])
@@index([flightNumber, flightDate])
@@index([trackingPhase])
@@index([scheduledDeparture])
}
// ============================================
// Flight API Budget Tracking
// ============================================
model FlightApiBudget {
id String @id @default(uuid())
monthYear String @unique // "2026-02" format
requestsUsed Int @default(0)
requestLimit Int @default(100)
lastRequestAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("flight_api_budget")
}
// ============================================

View File

@@ -0,0 +1,465 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { Cron } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
import { firstValueFrom } from 'rxjs';
import { Flight } from '@prisma/client';
// Tracking phases - determines polling priority
const PHASE = {
FAR_OUT: 'FAR_OUT', // >24h before departure - no auto-poll
PRE_DEPARTURE: 'PRE_DEPARTURE', // 6-24h before departure
DEPARTURE_WINDOW: 'DEPARTURE_WINDOW', // 0-6h before departure
ACTIVE: 'ACTIVE', // In flight
ARRIVAL_WINDOW: 'ARRIVAL_WINDOW', // Within 1h of ETA
LANDED: 'LANDED', // Flight has landed
TERMINAL: 'TERMINAL', // Cancelled/diverted/incident - terminal state
} as const;
// Priority scores for each phase (higher = more urgent)
const PHASE_PRIORITY: Record<string, number> = {
[PHASE.ARRIVAL_WINDOW]: 100,
[PHASE.ACTIVE]: 60,
[PHASE.DEPARTURE_WINDOW]: 40,
[PHASE.PRE_DEPARTURE]: 10,
[PHASE.FAR_OUT]: 0,
[PHASE.LANDED]: 0,
[PHASE.TERMINAL]: 0,
};
// Minimum minutes between polls per phase (to prevent wasting budget)
const MIN_POLL_INTERVAL: Record<string, number> = {
[PHASE.ARRIVAL_WINDOW]: 20,
[PHASE.ACTIVE]: 45,
[PHASE.DEPARTURE_WINDOW]: 60,
[PHASE.PRE_DEPARTURE]: 180,
[PHASE.FAR_OUT]: Infinity,
[PHASE.LANDED]: Infinity,
[PHASE.TERMINAL]: Infinity,
};
// Map AviationStack status to our tracking phase
const STATUS_TO_TERMINAL: string[] = ['cancelled', 'incident', 'diverted'];
@Injectable()
export class FlightTrackingService {
private readonly logger = new Logger(FlightTrackingService.name);
private readonly apiKey: string;
private readonly baseUrl = 'http://api.aviationstack.com/v1';
constructor(
private prisma: PrismaService,
private httpService: HttpService,
private configService: ConfigService,
) {
this.apiKey = this.configService.get('AVIATIONSTACK_API_KEY') || '';
if (this.apiKey) {
this.logger.log('AviationStack API key configured - flight tracking enabled');
} else {
this.logger.warn('AviationStack API key not configured - flight tracking disabled');
}
}
// ============================================
// Cron Job: Smart Flight Polling (every 5 min)
// ============================================
@Cron('*/5 * * * *')
async pollFlightsCron(): Promise<void> {
if (!this.apiKey) return;
try {
// 1. Check budget
const budget = await this.getOrCreateBudget();
const budgetPercent = (budget.requestsUsed / budget.requestLimit) * 100;
if (budgetPercent >= 95) {
this.logger.debug('Flight API budget exhausted (>=95%) - skipping auto-poll');
return;
}
// 2. Get all trackable flights (not in terminal states)
const flights = await this.prisma.flight.findMany({
where: {
autoTrackEnabled: true,
trackingPhase: {
notIn: [PHASE.LANDED, PHASE.TERMINAL, PHASE.FAR_OUT],
},
},
include: { vip: true },
});
if (flights.length === 0) return;
// 3. Recalculate phases and score each flight
const candidates: { flight: Flight; phase: string; priority: number }[] = [];
for (const flight of flights) {
const phase = this.calculateTrackingPhase(flight);
// Update phase in DB if changed
if (phase !== flight.trackingPhase) {
await this.prisma.flight.update({
where: { id: flight.id },
data: { trackingPhase: phase },
});
}
// Skip phases that shouldn't be polled
if (PHASE_PRIORITY[phase] === 0) continue;
// Budget conservation: if >80% used, only poll high-priority
if (budgetPercent > 80 && PHASE_PRIORITY[phase] < 60) continue;
// Check minimum polling interval
if (!this.shouldPoll(flight, phase)) continue;
candidates.push({
flight,
phase,
priority: PHASE_PRIORITY[phase],
});
}
if (candidates.length === 0) return;
// 4. Pick the highest-priority candidate
candidates.sort((a, b) => b.priority - a.priority);
const best = candidates[0];
this.logger.log(
`Auto-polling flight ${best.flight.flightNumber} (phase: ${best.phase}, priority: ${best.priority}, budget: ${budget.requestsUsed}/${budget.requestLimit})`,
);
// 5. Poll it
await this.callAviationStackAndUpdate(best.flight);
} catch (error) {
this.logger.error(`Flight polling cron error: ${error.message}`, error.stack);
}
}
// ============================================
// Manual Refresh (coordinator-triggered)
// ============================================
async refreshFlight(flightId: string) {
const flight = await this.prisma.flight.findUnique({
where: { id: flightId },
include: { vip: true },
});
if (!flight) {
throw new NotFoundException(`Flight ${flightId} not found`);
}
if (!this.apiKey) {
return {
message: 'Flight tracking API not configured',
flight,
};
}
const updated = await this.callAviationStackAndUpdate(flight);
return updated;
}
async refreshActiveFlights() {
if (!this.apiKey) {
return { refreshed: 0, skipped: 0, budgetRemaining: 0, message: 'API key not configured' };
}
const budget = await this.getOrCreateBudget();
const remaining = budget.requestLimit - budget.requestsUsed;
// Get active flights that would benefit from refresh
const flights = await this.prisma.flight.findMany({
where: {
trackingPhase: {
in: [PHASE.ACTIVE, PHASE.ARRIVAL_WINDOW, PHASE.DEPARTURE_WINDOW],
},
},
include: { vip: true },
orderBy: { scheduledDeparture: 'asc' },
});
let refreshed = 0;
let skipped = 0;
for (const flight of flights) {
if (refreshed >= remaining) {
skipped += flights.length - refreshed - skipped;
break;
}
try {
await this.callAviationStackAndUpdate(flight);
refreshed++;
} catch (error) {
this.logger.error(`Failed to refresh flight ${flight.flightNumber}: ${error.message}`);
skipped++;
}
}
const updatedBudget = await this.getOrCreateBudget();
return {
refreshed,
skipped,
budgetRemaining: updatedBudget.requestLimit - updatedBudget.requestsUsed,
};
}
// ============================================
// Budget Management
// ============================================
async getBudgetStatus() {
const budget = await this.getOrCreateBudget();
return {
used: budget.requestsUsed,
limit: budget.requestLimit,
remaining: budget.requestLimit - budget.requestsUsed,
month: budget.monthYear,
};
}
private async getOrCreateBudget() {
const monthYear = this.getCurrentMonthYear();
let budget = await this.prisma.flightApiBudget.findUnique({
where: { monthYear },
});
if (!budget) {
budget = await this.prisma.flightApiBudget.create({
data: { monthYear, requestLimit: 100 },
});
}
return budget;
}
private async incrementBudget() {
const monthYear = this.getCurrentMonthYear();
return this.prisma.flightApiBudget.upsert({
where: { monthYear },
update: {
requestsUsed: { increment: 1 },
lastRequestAt: new Date(),
},
create: {
monthYear,
requestsUsed: 1,
requestLimit: 100,
lastRequestAt: new Date(),
},
});
}
private getCurrentMonthYear(): string {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
}
// ============================================
// Phase Calculation
// ============================================
calculateTrackingPhase(flight: Flight): string {
const now = new Date();
const status = flight.status?.toLowerCase();
// Terminal states
if (status === 'landed' || flight.actualArrival) return PHASE.LANDED;
if (STATUS_TO_TERMINAL.includes(status || '')) return PHASE.TERMINAL;
// Active in flight
if (status === 'active') {
// Check if within arrival window
const eta = flight.estimatedArrival || flight.scheduledArrival;
if (eta) {
const minutesToArrival = (new Date(eta).getTime() - now.getTime()) / 60000;
if (minutesToArrival <= 60) return PHASE.ARRIVAL_WINDOW;
}
return PHASE.ACTIVE;
}
// Pre-departure phases based on scheduled departure
const departure = flight.estimatedDeparture || flight.scheduledDeparture;
if (!departure) return PHASE.FAR_OUT;
const hoursUntilDeparture = (new Date(departure).getTime() - now.getTime()) / 3600000;
if (hoursUntilDeparture <= 0) {
// Past scheduled departure but no "active" status from API
// Could be delayed at gate - treat as departure window
return PHASE.DEPARTURE_WINDOW;
}
if (hoursUntilDeparture <= 6) return PHASE.DEPARTURE_WINDOW;
if (hoursUntilDeparture <= 24) return PHASE.PRE_DEPARTURE;
return PHASE.FAR_OUT;
}
// ============================================
// Polling Decision
// ============================================
private shouldPoll(flight: Flight, phase: string): boolean {
const minInterval = MIN_POLL_INTERVAL[phase];
if (!isFinite(minInterval)) return false;
if (!flight.lastPolledAt) return true; // Never polled
const minutesSincePoll = (Date.now() - new Date(flight.lastPolledAt).getTime()) / 60000;
return minutesSincePoll >= minInterval;
}
// ============================================
// AviationStack API Integration
// ============================================
private async callAviationStackAndUpdate(flight: Flight & { vip?: any }): Promise<Flight> {
const flightDate = flight.flightDate
? new Date(flight.flightDate).toISOString().split('T')[0]
: undefined;
try {
const params: any = {
access_key: this.apiKey,
flight_iata: flight.flightNumber,
};
if (flightDate) {
params.flight_date = flightDate;
}
const response = await firstValueFrom(
this.httpService.get(`${this.baseUrl}/flights`, {
params,
timeout: 15000,
}),
);
// Increment budget after successful call
await this.incrementBudget();
const data = response.data as any;
if (data?.error) {
this.logger.warn(`AviationStack API error for ${flight.flightNumber}: ${data.error.message || JSON.stringify(data.error)}`);
// Still update lastPolledAt so we don't spam on errors
return this.prisma.flight.update({
where: { id: flight.id },
data: { lastPolledAt: new Date(), pollCount: { increment: 1 } },
include: { vip: true },
});
}
if (data?.data && data.data.length > 0) {
const apiResult = data.data[0];
const updateData = this.parseAviationStackResponse(apiResult);
// Calculate new phase based on updated data
const tempFlight = { ...flight, ...updateData };
const newPhase = this.calculateTrackingPhase(tempFlight as Flight);
const updated = await this.prisma.flight.update({
where: { id: flight.id },
data: {
...updateData,
trackingPhase: newPhase,
lastPolledAt: new Date(),
pollCount: { increment: 1 },
lastApiResponse: apiResult,
},
include: { vip: true },
});
this.logger.log(
`Updated flight ${flight.flightNumber}: status=${updated.status}, phase=${newPhase}, delay=${updated.arrivalDelay || 0}min`,
);
return updated;
}
// Flight not found in API
this.logger.warn(`Flight ${flight.flightNumber} not found in AviationStack API`);
return this.prisma.flight.update({
where: { id: flight.id },
data: { lastPolledAt: new Date(), pollCount: { increment: 1 } },
include: { vip: true },
});
} catch (error) {
this.logger.error(`AviationStack API call failed for ${flight.flightNumber}: ${error.message}`);
// Still update lastPolledAt on error to prevent rapid retries
return this.prisma.flight.update({
where: { id: flight.id },
data: { lastPolledAt: new Date() },
include: { vip: true },
});
}
}
// ============================================
// Response Parser
// ============================================
private parseAviationStackResponse(apiData: any): Partial<Flight> {
const update: any = {};
// Flight status
if (apiData.flight_status) {
update.status = apiData.flight_status;
}
// Departure info
if (apiData.departure) {
const dep = apiData.departure;
if (dep.terminal) update.departureTerminal = dep.terminal;
if (dep.gate) update.departureGate = dep.gate;
if (dep.delay != null) update.departureDelay = dep.delay;
if (dep.scheduled) update.scheduledDeparture = new Date(dep.scheduled);
if (dep.estimated) update.estimatedDeparture = new Date(dep.estimated);
if (dep.actual) update.actualDeparture = new Date(dep.actual);
// Store departure airport name if we only had IATA code
if (dep.iata && !update.departureAirport) update.departureAirport = dep.iata;
}
// Arrival info
if (apiData.arrival) {
const arr = apiData.arrival;
if (arr.terminal) update.arrivalTerminal = arr.terminal;
if (arr.gate) update.arrivalGate = arr.gate;
if (arr.baggage) update.arrivalBaggage = arr.baggage;
if (arr.delay != null) update.arrivalDelay = arr.delay;
if (arr.scheduled) update.scheduledArrival = new Date(arr.scheduled);
if (arr.estimated) update.estimatedArrival = new Date(arr.estimated);
if (arr.actual) update.actualArrival = new Date(arr.actual);
if (arr.iata && !update.arrivalAirport) update.arrivalAirport = arr.iata;
}
// Airline info
if (apiData.airline) {
if (apiData.airline.name) update.airlineName = apiData.airline.name;
if (apiData.airline.iata) update.airlineIata = apiData.airline.iata;
}
// Aircraft info
if (apiData.aircraft?.iata) {
update.aircraftType = apiData.aircraft.iata;
}
// Live tracking data (may not be available on free tier)
if (apiData.live) {
const live = apiData.live;
if (live.latitude != null) update.liveLatitude = live.latitude;
if (live.longitude != null) update.liveLongitude = live.longitude;
if (live.altitude != null) update.liveAltitude = live.altitude;
if (live.speed_horizontal != null) update.liveSpeed = live.speed_horizontal;
if (live.direction != null) update.liveDirection = live.direction;
if (live.is_ground != null) update.liveIsGround = live.is_ground;
if (live.updated) update.liveUpdatedAt = new Date(live.updated);
}
return update;
}
}

View File

@@ -10,6 +10,7 @@ import {
UseGuards,
} from '@nestjs/common';
import { FlightsService } from './flights.service';
import { FlightTrackingService } from './flight-tracking.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
@@ -19,7 +20,10 @@ import { CreateFlightDto, UpdateFlightDto } from './dto';
@Controller('flights')
@UseGuards(JwtAuthGuard, RolesGuard)
export class FlightsController {
constructor(private readonly flightsService: FlightsService) {}
constructor(
private readonly flightsService: FlightsService,
private readonly flightTrackingService: FlightTrackingService,
) {}
@Post()
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
@@ -33,6 +37,20 @@ export class FlightsController {
return this.flightsService.findAll();
}
// --- Tracking Endpoints (must come before :id param routes) ---
@Get('tracking/budget')
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
getBudgetStatus() {
return this.flightTrackingService.getBudgetStatus();
}
@Post('refresh-active')
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
refreshActiveFlights() {
return this.flightTrackingService.refreshActiveFlights();
}
@Get('status/:flightNumber')
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
getFlightStatus(
@@ -54,6 +72,12 @@ export class FlightsController {
return this.flightsService.findOne(id);
}
@Post(':id/refresh')
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
refreshFlight(@Param('id') id: string) {
return this.flightTrackingService.refreshFlight(id);
}
@Patch(':id')
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
update(@Param('id') id: string, @Body() updateFlightDto: UpdateFlightDto) {

View File

@@ -2,11 +2,12 @@ import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { FlightsController } from './flights.controller';
import { FlightsService } from './flights.service';
import { FlightTrackingService } from './flight-tracking.service';
@Module({
imports: [HttpModule],
controllers: [FlightsController],
providers: [FlightsService],
exports: [FlightsService],
providers: [FlightsService, FlightTrackingService],
exports: [FlightsService, FlightTrackingService],
})
export class FlightsModule {}