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:
465
backend/src/flights/flight-tracking.service.ts
Normal file
465
backend/src/flights/flight-tracking.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
Reference in New Issue
Block a user