import { getRpcBaseUrl } from '@/services/rpc-client'; import type { AirportDelayAlert as ProtoAlert, AirportOpsSummary as ProtoOpsSummary, FlightInstance as ProtoFlight, CarrierOpsSummary as ProtoCarrierOps, PositionSample as ProtoPosition, PriceQuote as ProtoPriceQuote, AviationNewsItem as ProtoAviationNews, CabinClass, GoogleFlightResult as ProtoGoogleFlightResult, DatePriceEntry as ProtoDatePriceEntry } from '@/generated/client/worldmonitor/aviation/v1/service_client'; import { createCircuitBreaker } from '@/utils/circuit-breaker'; import { getHydratedData } from '@/services/bootstrap'; import { AviationServiceClient } from '@/services/generated-rpc-clients'; // ---- Consumer-friendly display types ---- export type FlightDelaySource = 'faa' | 'eurocontrol' | 'computed' | 'aviationstack' | 'notam' | 'unspecified'; export type FlightDelaySeverity = 'normal' | 'minor' | 'moderate' | 'major' | 'severe' | 'unknown'; export type FlightDelayType = 'ground_stop' | 'ground_delay' | 'departure_delay' | 'arrival_delay' | 'general' | 'closure'; export type AirportRegion = 'americas' | 'europe' | 'apac' | 'mena' | 'africa'; export type FlightStatus = 'scheduled' | 'boarding' | 'departed' | 'airborne' | 'landed' | 'arrived' | 'cancelled' | 'diverted' | 'unknown'; export interface AirportDelayAlert { id: string; iata: string; icao: string; name: string; city: string; country: string; lat: number; lon: number; region: AirportRegion; delayType: FlightDelayType; severity: FlightDelaySeverity; avgDelayMinutes: number; delayedFlightsPct?: number; cancelledFlights?: number; totalFlights?: number; reason?: string; source: FlightDelaySource; updatedAt: Date; } export interface AirportOpsSummary { iata: string; icao: string; name: string; delayPct: number; avgDelayMinutes: number; cancellationRate: number; totalFlights: number; closureStatus: boolean; notamFlags: string[]; severity: FlightDelaySeverity; topDelayReasons: string[]; source: string; updatedAt: Date; } export interface FlightInstance { flightNumber: string; date: string; carrier: { iata: string; name: string }; origin: { iata: string; name: string }; destination: { iata: string; name: string }; scheduledDeparture: Date | null; scheduledArrival: Date | null; estimatedDeparture: Date | null; estimatedArrival: Date | null; status: FlightStatus; delayMinutes: number; cancelled: boolean; diverted: boolean; gate: string; terminal: string; aircraftType: string; source: string; } export interface CarrierOps { carrierIata: string; carrierName: string; airport: string; totalFlights: number; delayedCount: number; cancelledCount: number; avgDelayMinutes: number; delayPct: number; cancellationRate: number; updatedAt: Date; } export interface PositionSample { icao24: string; callsign: string; lat: number; lon: number; altitudeFt: number; groundSpeedKts: number; trackDeg: number; onGround: boolean; source: string; observedAt: Date; } export interface PriceQuote { id: string; origin: string; destination: string; departureDate: string; carrierIata: string; carrierName: string; priceAmount: number; currency: string; cabin: string; stops: number; durationMinutes: number; isIndicative: boolean; provider: string; // 'travelpayouts_data' | 'demo' expiresAt: Date | null; // null means no known expiry checkoutRef: string; // empty for cached/demo } /** Returns true if a quote has a known expiry that has passed. */ export function isPriceExpired(q: PriceQuote): boolean { return q.expiresAt !== null && q.expiresAt.getTime() < Date.now(); } export interface AviationNewsItem { id: string; title: string; url: string; sourceName: string; publishedAt: Date; snippet: string; matchedEntities: string[]; } export interface GoogleFlightLeg { airlineCode: string; flightNumber: string; departureAirport: string; arrivalAirport: string; departureDatetime: string; // local ISO datetime, no UTC offset arrivalDatetime: string; durationMinutes: number; } export interface GoogleFlightItinerary { legs: GoogleFlightLeg[]; price: number; durationMinutes: number; stops: number; } export interface GoogleFlightsResult { flights: GoogleFlightItinerary[]; degraded: boolean; error: string; } export interface DatePrice { date: string; // YYYY-MM-DD returnDate: string; // YYYY-MM-DD or '' price: number; } export interface GoogleDatesResult { dates: DatePrice[]; degraded: boolean; error: string; } // ---- Enum maps ---- const SEVERITY_MAP: Record = { FLIGHT_DELAY_SEVERITY_NORMAL: 'normal', FLIGHT_DELAY_SEVERITY_MINOR: 'minor', FLIGHT_DELAY_SEVERITY_MODERATE: 'moderate', FLIGHT_DELAY_SEVERITY_MAJOR: 'major', FLIGHT_DELAY_SEVERITY_SEVERE: 'severe', FLIGHT_DELAY_SEVERITY_UNKNOWN: 'unknown', }; const DELAY_TYPE_MAP: Record = { FLIGHT_DELAY_TYPE_GROUND_STOP: 'ground_stop', FLIGHT_DELAY_TYPE_GROUND_DELAY: 'ground_delay', FLIGHT_DELAY_TYPE_DEPARTURE_DELAY: 'departure_delay', FLIGHT_DELAY_TYPE_ARRIVAL_DELAY: 'arrival_delay', FLIGHT_DELAY_TYPE_GENERAL: 'general', FLIGHT_DELAY_TYPE_CLOSURE: 'closure', }; const REGION_MAP: Record = { AIRPORT_REGION_AMERICAS: 'americas', AIRPORT_REGION_EUROPE: 'europe', AIRPORT_REGION_APAC: 'apac', AIRPORT_REGION_MENA: 'mena', AIRPORT_REGION_AFRICA: 'africa', }; const SOURCE_MAP: Record = { FLIGHT_DELAY_SOURCE_UNSPECIFIED: 'unspecified', FLIGHT_DELAY_SOURCE_FAA: 'faa', FLIGHT_DELAY_SOURCE_EUROCONTROL: 'eurocontrol', FLIGHT_DELAY_SOURCE_COMPUTED: 'computed', FLIGHT_DELAY_SOURCE_AVIATIONSTACK: 'aviationstack', FLIGHT_DELAY_SOURCE_NOTAM: 'notam', }; const FLIGHT_STATUS_MAP: Record = { FLIGHT_INSTANCE_STATUS_SCHEDULED: 'scheduled', FLIGHT_INSTANCE_STATUS_BOARDING: 'boarding', FLIGHT_INSTANCE_STATUS_DEPARTED: 'departed', FLIGHT_INSTANCE_STATUS_AIRBORNE: 'airborne', FLIGHT_INSTANCE_STATUS_LANDED: 'landed', FLIGHT_INSTANCE_STATUS_ARRIVED: 'arrived', FLIGHT_INSTANCE_STATUS_CANCELLED: 'cancelled', FLIGHT_INSTANCE_STATUS_DIVERTED: 'diverted', }; // ---- Normalizers ---- function msToDt(ms: number): Date | null { return ms ? new Date(ms) : null; } function toDisplayAlert(p: ProtoAlert): AirportDelayAlert { return { id: p.id, iata: p.iata, icao: p.icao, name: p.name, city: p.city, country: p.country, lat: p.location?.latitude ?? 0, lon: p.location?.longitude ?? 0, region: REGION_MAP[p.region] ?? 'americas', delayType: DELAY_TYPE_MAP[p.delayType] ?? 'general', severity: SEVERITY_MAP[p.severity] ?? 'normal', avgDelayMinutes: p.avgDelayMinutes, delayedFlightsPct: p.delayedFlightsPct || undefined, cancelledFlights: p.cancelledFlights || undefined, totalFlights: p.totalFlights || undefined, reason: p.reason || undefined, source: SOURCE_MAP[p.source] ?? 'computed', updatedAt: new Date(p.updatedAt), }; } function toDisplayOps(p: ProtoOpsSummary): AirportOpsSummary { return { iata: p.iata, icao: p.icao, name: p.name, delayPct: p.delayPct, avgDelayMinutes: p.avgDelayMinutes, cancellationRate: p.cancellationRate, totalFlights: p.totalFlights, closureStatus: p.closureStatus, notamFlags: p.notamFlags ?? [], severity: SEVERITY_MAP[p.severity] ?? 'normal', topDelayReasons: p.topDelayReasons ?? [], source: p.source, updatedAt: new Date(p.updatedAt), }; } function toDisplayFlight(p: ProtoFlight): FlightInstance { return { flightNumber: p.flightNumber, date: p.date, carrier: { iata: p.operatingCarrier?.iataCode ?? '', name: p.operatingCarrier?.name ?? '' }, origin: { iata: p.origin?.iata ?? '', name: p.origin?.name ?? '' }, destination: { iata: p.destination?.iata ?? '', name: p.destination?.name ?? '' }, scheduledDeparture: msToDt(p.scheduledDeparture), scheduledArrival: msToDt(p.scheduledArrival), estimatedDeparture: msToDt(p.estimatedDeparture || p.scheduledDeparture), estimatedArrival: msToDt(p.estimatedArrival || p.scheduledArrival), status: FLIGHT_STATUS_MAP[p.status ?? ''] ?? 'unknown', delayMinutes: p.delayMinutes, cancelled: p.cancelled, diverted: p.diverted, gate: p.gate, terminal: p.terminal, aircraftType: p.aircraftType, source: p.source, }; } function toDisplayCarrierOps(p: ProtoCarrierOps): CarrierOps { return { carrierIata: p.carrier?.iataCode ?? '', carrierName: p.carrier?.name ?? p.carrier?.iataCode ?? '', airport: p.airport, totalFlights: p.totalFlights, delayedCount: p.delayedCount, cancelledCount: p.cancelledCount, avgDelayMinutes: p.avgDelayMinutes, delayPct: p.delayPct, cancellationRate: p.cancellationRate, updatedAt: new Date(p.updatedAt), }; } function toDisplayPosition(p: ProtoPosition): PositionSample { return { icao24: p.icao24, callsign: p.callsign, lat: p.lat, lon: p.lon, altitudeFt: Math.round(p.altitudeM * 3.281), groundSpeedKts: p.groundSpeedKts, trackDeg: p.trackDeg, onGround: p.onGround, source: p.source, observedAt: new Date(p.observedAt), }; } function toDisplayPriceQuote(p: ProtoPriceQuote): PriceQuote { return { id: p.id, origin: p.origin, destination: p.destination, departureDate: p.departureDate, carrierIata: p.carrier?.iataCode ?? '', carrierName: p.carrier?.name ?? '', priceAmount: p.priceAmount, currency: p.currency?.toUpperCase() || 'USD', cabin: p.cabin?.replace('CABIN_CLASS_', '').replace(/_/g, ' ') ?? 'Economy', stops: p.stops, durationMinutes: p.durationMinutes, isIndicative: p.isIndicative, provider: p.provider || 'demo', expiresAt: p.expiresAt > 0 ? new Date(p.expiresAt) : null, checkoutRef: p.checkoutRef || '', }; } function toDisplayNewsItem(p: ProtoAviationNews): AviationNewsItem { return { id: p.id, title: p.title, url: p.url, sourceName: p.sourceName, publishedAt: new Date(p.publishedAt), snippet: p.snippet, matchedEntities: p.matchedEntities ?? [], }; } function toDisplayGoogleFlight(p: ProtoGoogleFlightResult): GoogleFlightItinerary { return { legs: (p.legs ?? []).map(l => ({ airlineCode: l.airlineCode ?? '', flightNumber: l.flightNumber ?? '', departureAirport: l.departureAirport ?? '', arrivalAirport: l.arrivalAirport ?? '', departureDatetime: l.departureDatetime ?? '', arrivalDatetime: l.arrivalDatetime ?? '', durationMinutes: l.durationMinutes ?? 0, })), price: p.price ?? 0, durationMinutes: p.durationMinutes ?? 0, stops: p.stops ?? 0, }; } function toDisplayDatePrice(p: ProtoDatePriceEntry): DatePrice { return { date: p.date ?? '', returnDate: p.returnDate ?? '', price: p.price ?? 0 }; } // ---- Client + circuit breakers ---- const client = new AviationServiceClient(getRpcBaseUrl(), { fetch: (...args) => globalThis.fetch(...args) }); const breakerDelays = createCircuitBreaker({ name: 'Flight Delays v2', cacheTtlMs: 2 * 60 * 60 * 1000, persistCache: true }); const breakerOps = createCircuitBreaker({ name: 'Airport Ops', cacheTtlMs: 6 * 60 * 1000, persistCache: true }); const breakerFlights = createCircuitBreaker({ name: 'Airport Flights', cacheTtlMs: 5 * 60 * 1000, persistCache: false }); const breakerCarrier = createCircuitBreaker({ name: 'Carrier Ops', cacheTtlMs: 5 * 60 * 1000, persistCache: false }); const breakerStatus = createCircuitBreaker({ name: 'Flight Status', cacheTtlMs: 6 * 60 * 1000, persistCache: false }); const breakerTrack = createCircuitBreaker({ name: 'Track Aircraft', cacheTtlMs: 15 * 1000, persistCache: false }); const breakerPrices = createCircuitBreaker<{ quotes: PriceQuote[]; isDemoMode: boolean; isIndicative: boolean; degraded: boolean; error: string; provider: string }>({ name: 'Flight Prices', cacheTtlMs: 10 * 60 * 1000, persistCache: true }); const breakerNews = createCircuitBreaker({ name: 'Aviation News', cacheTtlMs: 15 * 60 * 1000, persistCache: true }); // No client-side cache for Google Flights search (gateway is no-store, prices change rapidly) const breakerGoogleFlights = createCircuitBreaker({ name: 'Google Flights', cacheTtlMs: 0, persistCache: false }); // 5-min client cache (server has 10-min Redis + medium gateway cache) const breakerGoogleDates = createCircuitBreaker({ name: 'Google Dates', cacheTtlMs: 5 * 60 * 1000, persistCache: false }); // ---- Public API ---- export async function fetchFlightDelays(): Promise { const hydrated = getHydratedData('flightDelays') as { alerts?: ProtoAlert[] } | undefined; if (hydrated?.alerts?.length) return hydrated.alerts.map(toDisplayAlert); return breakerDelays.execute(async () => { const r = await client.listAirportDelays({ region: 'AIRPORT_REGION_UNSPECIFIED', minSeverity: 'FLIGHT_DELAY_SEVERITY_UNSPECIFIED', pageSize: 0, cursor: '' }); return r.alerts.map(toDisplayAlert); }, [], { shouldCache: (r) => r.length > 0 }); } export async function fetchAirportOpsSummary(airports: string[]): Promise { return breakerOps.execute(async () => { const r = await client.getAirportOpsSummary({ airports }); return r.summaries.map(toDisplayOps); }, [], { cacheKey: airports.join(',') }); } export async function fetchAirportFlights(airport: string, direction: 'departure' | 'arrival' | 'both' = 'both', limit = 30): Promise { const dirMap = { departure: 'FLIGHT_DIRECTION_DEPARTURE', arrival: 'FLIGHT_DIRECTION_ARRIVAL', both: 'FLIGHT_DIRECTION_BOTH' } as const; return breakerFlights.execute(async () => { const r = await client.listAirportFlights({ airport, direction: dirMap[direction], limit }); return r.flights.map(toDisplayFlight); }, [], { cacheKey: `${airport}:${direction}:${limit}` }); } export async function fetchCarrierOps(airports: string[]): Promise { return breakerCarrier.execute(async () => { const r = await client.getCarrierOps({ airports, minFlights: 3 }); return r.carriers.map(toDisplayCarrierOps); }, [], { cacheKey: airports.join(',') }); } export async function fetchFlightStatus(flightNumber: string, date?: string, origin?: string): Promise { return breakerStatus.execute(async () => { const r = await client.getFlightStatus({ flightNumber, date: date ?? '', origin: origin ?? '' }); return r.flights.map(toDisplayFlight); }, [], { cacheKey: `${flightNumber}:${date ?? ''}:${origin ?? ''}` }); } export async function fetchAircraftPositions(opts: { icao24?: string; callsign?: string; swLat?: number; swLon?: number; neLat?: number; neLon?: number }): Promise { return breakerTrack.execute(async () => { const r = await client.trackAircraft({ icao24: opts.icao24 ?? '', callsign: opts.callsign ?? '', swLat: opts.swLat ?? 0, swLon: opts.swLon ?? 0, neLat: opts.neLat ?? 0, neLon: opts.neLon ?? 0 }); return r.positions.map(toDisplayPosition); }, [], { cacheKey: `${opts.icao24 ?? ''}:${opts.callsign ?? ''}:${opts.swLat ?? 0}:${opts.swLon ?? 0}:${opts.neLat ?? 0}:${opts.neLon ?? 0}` }); } export async function fetchFlightPrices(opts: { origin: string; destination: string; departureDate: string; returnDate?: string; adults?: number; cabin?: CabinClass; nonstopOnly?: boolean; maxResults?: number; currency?: string; market?: string }): Promise<{ quotes: PriceQuote[]; isDemoMode: boolean; isIndicative: boolean; provider: string; degraded: boolean; error: string }> { const cacheKey = `${opts.origin}:${opts.destination}:${opts.departureDate}:${opts.returnDate ?? ''}:${opts.adults ?? 1}:${opts.cabin ?? 'CABIN_CLASS_ECONOMY'}:${opts.nonstopOnly ?? false}:${opts.maxResults ?? 10}:${opts.currency ?? 'usd'}:${opts.market ?? ''}`; // Fail-closed fallback when the circuit breaker trips: no quotes, // degraded=true, never demo-mode (issue #3756). const fallback = { quotes: [], isDemoMode: false, isIndicative: false, degraded: true, error: 'upstream_error', provider: 'none' }; return breakerPrices.execute(async () => { const resp = await client.searchFlightPrices({ origin: opts.origin, destination: opts.destination, departureDate: opts.departureDate, returnDate: opts.returnDate ?? '', adults: opts.adults ?? 1, cabin: opts.cabin ?? 'CABIN_CLASS_ECONOMY', nonstopOnly: opts.nonstopOnly ?? false, maxResults: opts.maxResults ?? 10, currency: opts.currency ?? 'usd', market: opts.market ?? '', }); return { quotes: resp.quotes.map(toDisplayPriceQuote), isDemoMode: resp.isDemoMode, isIndicative: resp.isIndicative, degraded: resp.degraded, error: resp.error, provider: resp.provider, }; // shouldCache prevents the 10-min IndexedDB cache from pinning a // degraded/empty response after the server-side cause has been fixed // (e.g. operator restores TRAVELPAYOUTS_API_TOKEN after an outage). // evictOnRefreshFailure additionally evicts the stale entry on the // SWR refresh path, so a user who previously cached real quotes // stops seeing them once the upstream starts returning degraded. // Without it, SWR would pin the stale entry indefinitely. Flight // pricing is time-sensitive and the degraded state IS the important // signal; market quotes (and other surfaces that benefit from // resilience-across-blips) leave this opt-in default false. // (#3795 review + review-2 P1.) }, fallback, { cacheKey, shouldCache: (r) => r.quotes.length > 0 && !r.degraded, evictOnRefreshFailure: true, }); } export async function fetchAviationNews(entities: string[], windowHours = 24, maxItems = 20): Promise { const cacheKey = `${entities.join(',')}:${windowHours}:${maxItems}`; return breakerNews.execute(async () => { const r = await client.listAviationNews({ entities, windowHours, maxItems }); return r.items.map(toDisplayNewsItem); }, [], { cacheKey }); } export async function fetchGoogleFlights(opts: { origin: string; destination: string; departureDate: string; returnDate?: string; cabinClass?: string; maxStops?: string; sortBy?: string; passengers?: number; }): Promise { const cacheKey = `${opts.origin}:${opts.destination}:${opts.departureDate}:${opts.returnDate ?? ''}:${opts.cabinClass ?? 'ECONOMY'}:${opts.maxStops ?? ''}:${opts.sortBy ?? ''}:${opts.passengers ?? 1}`; return breakerGoogleFlights.execute(async () => { const r = await client.searchGoogleFlights({ origin: opts.origin, destination: opts.destination, departureDate: opts.departureDate, returnDate: opts.returnDate ?? '', cabinClass: opts.cabinClass ?? 'ECONOMY', maxStops: opts.maxStops ?? '', departureWindow: '', airlines: [], sortBy: opts.sortBy ?? '', passengers: opts.passengers ?? 1, }); return { flights: r.flights.map(toDisplayGoogleFlight), degraded: r.degraded ?? false, error: r.error ?? '' }; }, { flights: [], degraded: true, error: 'Request failed' }, { cacheKey }); } export async function fetchGoogleDates(opts: { origin: string; destination: string; startDate: string; endDate: string; tripDuration?: number; isRoundTrip?: boolean; cabinClass?: string; maxStops?: string; passengers?: number; }): Promise { const cacheKey = `${opts.origin}:${opts.destination}:${opts.startDate}:${opts.endDate}:${opts.tripDuration ?? 0}:${opts.isRoundTrip ?? false}:${opts.cabinClass ?? 'ECONOMY'}:${opts.maxStops ?? ''}:${opts.passengers ?? 1}`; return breakerGoogleDates.execute(async () => { const r = await client.searchGoogleDates({ origin: opts.origin, destination: opts.destination, startDate: opts.startDate, endDate: opts.endDate, tripDuration: opts.tripDuration ?? 0, isRoundTrip: opts.isRoundTrip ?? false, cabinClass: opts.cabinClass ?? 'ECONOMY', maxStops: opts.maxStops ?? '', departureWindow: '', airlines: [], sortByPrice: true, passengers: opts.passengers ?? 1, }); return { dates: r.dates.map(toDisplayDatePrice), degraded: r.degraded ?? false, error: r.error ?? '' }; }, { dates: [], degraded: true, error: 'Request failed' }, { cacheKey }); }