amogaddy's picture
Integra World Monitor (AGPL-3.0, self-hosted) nello Space: pagina, menu, e arricchimento notizie per la AI (part 7)
9d2d895 verified
Raw
History Blame Contribute Delete
20.5 kB
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<string, FlightDelaySeverity> = {
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<string, FlightDelayType> = {
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<string, AirportRegion> = {
AIRPORT_REGION_AMERICAS: 'americas',
AIRPORT_REGION_EUROPE: 'europe',
AIRPORT_REGION_APAC: 'apac',
AIRPORT_REGION_MENA: 'mena',
AIRPORT_REGION_AFRICA: 'africa',
};
const SOURCE_MAP: Record<string, FlightDelaySource> = {
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<string, FlightStatus> = {
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<AirportDelayAlert[]>({ name: 'Flight Delays v2', cacheTtlMs: 2 * 60 * 60 * 1000, persistCache: true });
const breakerOps = createCircuitBreaker<AirportOpsSummary[]>({ name: 'Airport Ops', cacheTtlMs: 6 * 60 * 1000, persistCache: true });
const breakerFlights = createCircuitBreaker<FlightInstance[]>({ name: 'Airport Flights', cacheTtlMs: 5 * 60 * 1000, persistCache: false });
const breakerCarrier = createCircuitBreaker<CarrierOps[]>({ name: 'Carrier Ops', cacheTtlMs: 5 * 60 * 1000, persistCache: false });
const breakerStatus = createCircuitBreaker<FlightInstance[]>({ name: 'Flight Status', cacheTtlMs: 6 * 60 * 1000, persistCache: false });
const breakerTrack = createCircuitBreaker<PositionSample[]>({ 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<AviationNewsItem[]>({ 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<GoogleFlightsResult>({ name: 'Google Flights', cacheTtlMs: 0, persistCache: false });
// 5-min client cache (server has 10-min Redis + medium gateway cache)
const breakerGoogleDates = createCircuitBreaker<GoogleDatesResult>({ name: 'Google Dates', cacheTtlMs: 5 * 60 * 1000, persistCache: false });
// ---- Public API ----
export async function fetchFlightDelays(): Promise<AirportDelayAlert[]> {
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<AirportOpsSummary[]> {
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<FlightInstance[]> {
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<CarrierOps[]> {
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<FlightInstance[]> {
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<PositionSample[]> {
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<AviationNewsItem[]> {
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<GoogleFlightsResult> {
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<GoogleDatesResult> {
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 });
}