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,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 {}