diff --git a/server/.env.example b/server/.env.example index 46eb3f377..5c476f694 100644 --- a/server/.env.example +++ b/server/.env.example @@ -71,6 +71,7 @@ DEMO_MODE=false # Demo mode: resets data hourly # TREK_PLACES_ENABLED=false # Whether place search asks the TREK Places API at https://places.liketrek.com: suggestions, the full search, the category buttons on the map, the Road trip search along the route, import geocoding, offline downloads, and the OpenStreetMap ways and horse routes Tours reads. It is on by default and has no switch in the admin panel, because whether searches leave the instance is a deployment decision. Set false to search the way it did before 4.3.0 (OpenStreetMap, or your Google/Amap key) and send nothing to that host. (default: true) # TREK_PLACES_URL=https://places.liketrek.com # Base URL of a copy of the Places API you run yourself; it has to answer the same /v1 API. Read from configuration rather than typed by a user, so an address on your LAN or Docker network works without ALLOW_INTERNAL_NETWORK. (default: https://places.liketrek.com) # TREK_PLACES_CONTRIBUTE=admin # Whether an admin may let this installation send place details its users typed in (website, phone, e-mail, opening hours, description, picture) to the TREK Places API, so every installation gets better details. admin shows the choice in the admin panel, where it stays off until an admin agrees; off takes the choice away and nothing is ever sent. Nothing is sent outside NODE_ENV=production, in demo mode, with a custom TREK_PLACES_URL or with TREK_SERVICES=off. (default: admin) +# FLIGHT_LIVE_ENABLED=false # Whether booked flights show their live status (position, departure, landing, estimated arrival) from the free, keyless ADS-B feeds adsb.lol and, on a self-hosted install, adsb.fi. Only the flight's callsign is sent, never who is flying or where to. When set it overrides the switch in the admin panel: false turns it off, true keeps it on. Unset, the admin panel decides. (default: true) # TREK_SERVICES=auto # How this installation identifies itself to the TREK services. On first use it registers itself in the background and keeps a key in the database; nothing to click, no account. off sends no key, and searches use the shared pool. (default: auto) # TREK_SERVICES_KEY= # A key issued to you for this installation. Wins over the stored one and turns self-registration off; meant for hosted and pre-provisioned instances. (default: none) # TREK_SERVICES_HOSTS=places.liketrek.com,photon.liketrek.com,valhalla.liketrek.com # Comma-separated hosts that receive the installation key over https. No other host ever sees it. (default: places.liketrek.com,photon.liketrek.com,valhalla.liketrek.com) diff --git a/server/assets/airlines-icao.json b/server/assets/airlines-icao.json new file mode 100644 index 000000000..a0e92d2a6 --- /dev/null +++ b/server/assets/airlines-icao.json @@ -0,0 +1 @@ +{"0A":"GNT","0B":"BMS","0D":"DWT","0E":"NWC","0J":"JCS","0V":"VFC","1A":"AGT","1I":"AMB","1L":"OSY","1T":"RNX","1Z":"APD","2A":"AWA","2B":"AWT","2C":"CMA","2D":"DYA","2F":"ACN","2G":"AGU","2I":"CSB","2J":"VBW","2K":"GLG","2L":"OAW","2M":"MDV","2N":"NTJ","2O":"RNE","2P":"GAP","2Q":"SNC","2R":"RLB","2S":"STW","2T":"BMA","2U":"FKH","2W":"WFL","2Y":"MYU","2Z":"PTB","3B":"BCV","3F":"FIE","3G":"WCM","3J":"WZP","3K":"JSA","3L":"ADY","3N":"URG","3O":"MAC","3R":"RGR","3S":"BOX","3T":"TQQ","3U":"CSC","3V":"TAY","3W":"MWI","3Z":"TVP","4A":"AKL","4B":"TUP","4C":"ARE","4D":"DIR","4F":"ECE","4G":"GZP","4H":"DRC","4K":"AAS","4L":"LAU","4M":"MGH","4N":"ANT","4R":"HHI","4S":"RSX","4T":"BHP","4U":"GWI","4V":"SQY","4W":"AJK","4Y":"OCN","4Z":"LNK","5A":"AIP","5C":"ICL","5D":"SLI","5F":"FIA","5G":"SSV","5J":"CEB","5K":"HFY","5N":"AUL","5O":"FPO","5P":"UZP","5Q":"HES","5R":"RUC","5T":"AKT","5U":"TGU","5V":"UKW","5W":"AEU","5X":"UPS","5Y":"GTI","5Z":"KEM","6A":"AMW","6B":"BLX","6D":"TVQ","6E":"IGO","6G":"RER","6H":"ISR","6I":"MMD","6J":"SNJ","6K":"TAH","6M":"MXM","6N":"NRD","6O":"OBS","6P":"PRX","6Q":"SAW","6R":"DRU","6S":"SGQ","6T":"LGT","6U":"ACX","6V":"VGA","6W":"FBS","6Y":"ART","6Z":"UKS","7A":"XRC","7B":"BNE","7C":"JJA","7D":"AYS","7E":"AWU","7F":"FAB","7G":"SFJ","7H":"RVF","7I":"TLR","7K":"KGL","7L":"AZG","7N":"CNA","7O":"TVL","7P":"PST","7Q":"AUV","7R":"RLU","7S":"RCT","7T":"AGV","7W":"WRC","7Y":"MYP","7Z":"EZR","8A":"BMM","8B":"TNU","8C":"ATN","8D":"EXV","8E":"BRG","8F":"STP","8G":"DTL","8H":"HFR","8I":"IPM","8J":"KMV","8K":"KMI","8L":"LKE","8M":"MMA","8N":"NKF","8O":"YWZ","8P":"PCO","8Q":"OHY","8R":"AIA","8S":"GTR","8U":"AAW","8V":"ACP","8Y":"AAV","8Z":"CGA","9A":"EZX","9C":"CQH","9D":"NMG","9E":"EDV","9G":"SPQ","9H":"CGN","9I":"LLR","9K":"KAP","9L":"CJC","9P":"FJL","9Q":"PBA","9R":"NSE","9S":"SOO","9T":"RUN","9V":"ROI","9W":"JAI","9X":"FDY","9Y":"FGE","A0":"EFW","A2":"AWG","A3":"AEE","A4":"AZO","A5":"HOP","A6":"OTC","A7":"WIN","A8":"AMF","A9":"TGZ","AA":"AAL","AB":"BNZ","AC":"ACA","AD":"AZU","AE":"MDA","AF":"AFR","AG":"ARU","AH":"DAH","AI":"AIC","AJ":"NIG","AK":"AXM","AL":"APP","AM":"AMX","AP":"LAV","AQ":"JYH","AR":"ARG","AS":"ASA","AT":"RAM","AU":"CJL","AV":"AVA","AW":"VNE","AX":"LOF","AY":"FIN","AZ":"ITY","B0":"DJT","B2":"BRU","B3":"BTN","B4":"BCF","B5":"BBT","B6":"JBU","B7":"UIA","B8":"ERT","B9":"IRB","BA":"BAW","BB":"SBS","BC":"SKY","BD":"BGN","BF":"BBD","BG":"BBC","BI":"RBA","BJ":"LBT","BK":"OKA","BL":"PIC","BM":"MNS","BN":"LWG","BO":"BOU","BP":"BOT","BQ":"SWU","BR":"EVA","BS":"UBG","BT":"BTI","BU":"DBP","BV":"BPA","BW":"BWA","BX":"ABL","BY":"TOM","BZ":"BBG","C2":"CEL","C3":"TDR","C5":"UCA","C6":"MFX","C7":"RLE","C8":"ICV","CA":"CCA","CB":"KII","CC":"ABD","CD":"CND","CE":"CLG","CF":"CYZ","CG":"TOK","CH":"BMJ","CI":"CAL","CJ":"CFE","CK":"CKK","CM":"CMP","CN":"GDC","CO":"COA","CP":"CPZ","CR":"ABC","CS":"CSW","CT":"AAE","CU":"CUB","CV":"CLX","CW":"CCC","CX":"CPA","CY":"CYP","CZ":"CSN","D0":"DHK","D2":"SSF","D3":"DAO","D4":"GEL","D5":"DAE","D6":"ILN","D7":"XAX","D8":"NSZ","D9":"DNV","DA":"DAC","DB":"BZH","DD":"NOK","DE":"CFG","DG":"SRQ","DH":"DVA","DI":"MBU","DJ":"SRR","DK":"VKG","DL":"DAL","DM":"DWI","DN":"DNA","DO":"SHH","DP":"FCA","DR":"RLH","DS":"EZS","DT":"DTA","DU":"HMS","DV":"VSV","DW":"UCR","DX":"DTR","DY":"NOZ","DZ":"EPA","E0":"ESS","E3":"EMU","E4":"ENT","E5":"RBG","E6":"EWL","E7":"ESF","E8":"ELG","E9":"CXS","EA":"EAI","EB":"PLM","EC":"EJU","ED":"ABQ","EE":"EST","EG":"ERN","EH":"AKX","EI":"EIN","EJ":"NEA","EK":"UAE","EL":"ELB","EM":"CFS","EN":"DLA","EO":"KAR","EP":"IRC","EQ":"TAE","ER":"DHL","ES":"DHX","ET":"ETH","EU":"UEA","EV":"ASQ","EW":"EWG","EX":"AVR","EY":"ETD","EZ":"SUS","F0":"FJR","F2":"FLM","F3":"FAD","F4":"NBK","F5":"ATG","F7":"BBO","F8":"FLE","F9":"FFT","FA":"SFR","FB":"LZB","FC":"WBA","FD":"AIQ","FE":"FEA","FG":"AFG","FH":"FHY","FI":"ICE","FJ":"FJI","FK":"WTA","FL":"TRS","FM":"CSH","FN":"FJW","FO":"FBZ","FP":"FRE","FQ":"CWN","FR":"RYR","FS":"AYN","FT":"FEG","FU":"FZA","FV":"SDM","FW":"IBX","FX":"FDX","FY":"FFM","FZ":"FDB","G0":"GHB","G2":"GBG","G3":"GLO","G4":"AAY","G5":"HXA","G6":"GXA","G7":"GJS","G8":"GOW","G9":"ABY","GA":"GIA","GB":"ABX","GC":"GNR","GD":"AHA","GE":"GBB","GF":"GFA","GG":"GUY","GH":"GLP","GI":"LHA","GJ":"CDC","GK":"JJP","GL":"GRL","GM":"SVK","GO":"KZU","GP":"PTP","GQ":"BSY","GR":"AUR","GS":"GCR","GT":"CGH","GV":"GUN","GW":"GJT","GX":"CBG","GY":"CGZ","GZ":"RAR","H2":"SKU","H3":"HLJ","H4":"HYS","H5":"RSY","H6":"BUC","H7":"HYM","H8":"SKX","H9":"HIM","HA":"HAL","HB":"HGB","HC":"SZN","HD":"ADO","HE":"HGO","HF":"VRE","HG":"NLY","HH":"QNT","HJ":"TMN","HK":"FSC","HM":"SEY","HN":"HST","HO":"DKH","HP":"APF","HR":"HHN","HT":"CTJ","HU":"CHH","HV":"TRA","HW":"NWL","HX":"CRK","HY":"UZB","HZ":"SOZ","I2":"IBS","I3":"TBZ","I4":"FWA","I6":"SEQ","I7":"IOA","I8":"IZA","I9":"HLF","IA":"IAW","IB":"IBE","IC":"GOA","ID":"BTK","IE":"SOL","IF":"FBA","IH":"MZA","II":"CSQ","IJ":"SJO","IK":"ITX","IM":"MNJ","IN":"LKN","IO":"IAE","IP":"JOL","IQ":"QAZ","IR":"IRA","IS":"SHI","IT":"TTW","IU":"SJV","IV":"GPX","IW":"WON","IX":"AXB","IY":"IYE","IZ":"AIZ","J2":"AHY","J3":"PLR","J4":"BDR","J6":"AOC","J7":"ABS","J8":"BVT","J9":"JZR","JA":"JAT","JB":"JBA","JC":"JEX","JD":"CBJ","JE":"MNO","JF":"JAA","JG":"JDL","JH":"NES","JI":"MRJ","JJ":"TAM","JK":"ACL","JL":"JAL","JM":"JMA","JN":"NLV","JO":"JAZ","JP":"ADR","JQ":"JST","JR":"JOY","JS":"KOR","JT":"LNI","JU":"ASL","JV":"BLS","JW":"APW","JX":"SJX","JZ":"JAP","K2":"ELO","K4":"CKS","K5":"WAK","K6":"KHV","K7":"KBZ","K8":"ZAK","KA":"ANK","KB":"DRK","KC":"KZR","KD":"WGN","KE":"KAL","KF":"BLF","KG":"BNX","KH":"KHK","KI":"SSJ","KJ":"AIH","KK":"NGN","KL":"KLM","KM":"KMM","KN":"CUA","KO":"AER","KP":"SKK","KQ":"KQA","KR":"KME","KS":"KON","KT":"KTC","KU":"KAC","KV":"MVD","KW":"WAN","KX":"CAY","KY":"KNA","KZ":"NCA","L2":"LYC","L3":"JOS","L4":"SSX","L5":"LTR","L6":"MAI","L7":"LAE","L9":"LWI","LA":"LAN","LB":"LLB","LC":"VLO","LD":"AHK","LE":"STB","LF":"VTE","LG":"LGL","LH":"DLH","LI":"LIA","LJ":"JNA","LK":"LXR","LL":"LVL","LM":"LOG","LN":"LAA","LO":"LOT","LP":"LPE","LQ":"MKR","LR":"LRC","LS":"EXS","LT":"SNG","LU":"LXP","LV":"MEG","LW":"JMR","LX":"SWR","LY":"ELY","M0":"MNG","M2":"MHV","M3":"TUS","M4":"MSA","M5":"KEN","M6":"AJT","M7":"MAA","M8":"MSJ","M9":"MSI","MA":"MAH","MB":"MNB","MC":"RCH","MD":"MDG","ME":"MEA","MF":"CXA","MG":"EZA","MH":"MAS","MI":"FHM","MK":"MAU","ML":"ETC","MM":"APJ","MN":"CAW","MO":"CAV","MP":"MPH","MQ":"ENY","MR":"MML","MS":"MSR","MU":"CES","MV":"MAR","MX":"MXY","MY":"MWA","MZ":"AHX","N0":"NBT","N2":"DAG","N3":"VOS","N4":"NWS","N5":"KGZ","N7":"FCM","N8":"HGK","N9":"NVR","NA":"NAO","NB":"BNL","NC":"NAC","ND":"NDA","NE":"NMA","NF":"AVN","NG":"LDA","NH":"ANA","NI":"PGA","NJ":"NGB","NL":"AEH","NM":"NZM","NN":"MOV","NO":"NOS","NP":"NIA","NQ":"AJX","NR":"MAV","NS":"HBH","NT":"IBB","NU":"JTA","NV":"KRU","NX":"AMU","NY":"FXI","NZ":"ANZ","O3":"CSS","O6":"ONE","O7":"OZJ","O9":"NOV","OA":"OAL","OB":"BOV","OC":"ORC","OD":"MXD","OE":"FOE","OF":"FIF","OG":"SDG","OH":"JIA","OJ":"FJM","OK":"CSA","OL":"PAO","OM":"MGL","ON":"RON","OO":"SKW","OP":"DIG","OQ":"CQN","OR":"TFL","OS":"AUA","OT":"PEL","OU":"CTN","OV":"OMS","OW":"SEW","OX":"OEW","OY":"ANS","OZ":"AAR","P0":"PFZ","P3":"PTR","P4":"APO","P5":"RPB","P6":"PVG","P7":"ESL","P9":"PVN","PB":"PVL","PC":"PGT","PD":"POE","PE":"PEV","PF":"PRW","PG":"BKP","PH":"PHA","PJ":"SPM","PK":"PIA","PL":"ASE","PM":"CNF","PN":"CHB","PO":"PAC","PR":"PAL","PS":"AUI","PT":"PDT","PU":"PUA","PV":"SBU","PW":"PRF","PX":"ANG","PY":"SLM","PZ":"LAP","Q2":"DQA","Q3":"MBN","Q4":"TLK","Q5":"MLA","Q6":"VOC","Q7":"DHA","Q8":"PEC","Q9":"GWG","QB":"QSM","QC":"CRC","QD":"QCL","QF":"QFA","QG":"CTV","QH":"BAV","QI":"CIM","QK":"JZA","QL":"LER","QM":"AML","QN":"SKP","QO":"OGN","QP":"AKJ","QQ":"UTY","QR":"QTR","QS":"TVS","QT":"TPA","QU":"UTN","QV":"LAO","QW":"QDA","QX":"QXE","QY":"BCS","QZ":"AWQ","R0":"RPK","R2":"ORB","R3":"SYL","R4":"RAN","R5":"JAV","R6":"DNU","R7":"OCA","R8":"KGA","R9":"CAM","RA":"RNA","RB":"SYR","RC":"FLI","RD":"RYN","RE":"STK","RF":"EOK","RH":"HKC","RI":"OEY","RJ":"RJA","RK":"RUK","RL":"ABG","RM":"NGT","RN":"SZL","RO":"ROT","RP":"CHQ","RQ":"KMF","RR":"RYS","RS":"ASV","RU":"ABW","RV":"ROU","RW":"RYL","RX":"RXI","RY":"CJX","S0":"OKS","S2":"RSH","S3":"BBR","S4":"RZO","S5":"TCF","S6":"KSZ","S7":"SBI","S8":"SDA","S9":"FLG","SA":"SAA","SB":"ACI","SC":"CDG","SD":"SUD","SE":"XLF","SF":"DTH","SG":"SEJ","SI":"BCI","SJ":"SJY","SK":"SAS","SL":"TLM","SM":"MSC","SN":"BEL","SP":"SAT","SQ":"SIA","SR":"SDR","SS":"CRL","ST":"GMI","SU":"AFL","SV":"SVA","SX":"TOR","SY":"SCX","SZ":"SMR","T0":"TPU","T2":"TCG","T3":"EZE","T4":"TIB","T5":"TUA","T6":"ATX","T7":"TJT","T8":"TVR","T9":"VTU","TA":"TAI","TB":"JAF","TC":"ATC","TE":"IGA","TF":"BRX","TG":"THA","TH":"RMY","TI":"TWI","TK":"THY","TL":"ANO","TM":"LAM","TN":"THT","TO":"TVF","TP":"TAP","TQ":"TDM","TR":"TGW","TS":"TSC","TT":"TGG","TU":"TAR","TV":"TBA","TW":"TWB","TX":"FWI","TY":"IWD","TZ":"TDS","U0":"ULS","U2":"EZY","U4":"BHA","U5":"GWY","U6":"SVR","U7":"UCG","U8":"CYF","U9":"TAK","UA":"UAL","UB":"UBA","UC":"LCO","UD":"UBD","UE":"NAS","UF":"PER","UG":"TUX","UI":"AUK","UJ":"LMU","UL":"ALK","UM":"AZW","UN":"TSO","UO":"HKE","UP":"BHS","UQ":"CUH","UR":"UGD","US":"AWE","UT":"UTA","UU":"REU","UW":"UTP","UX":"AEA","UY":"SPD","UZ":"BRQ","V0":"VCV","V2":"TWN","V3":"KRP","V4":"VEC","V5":"DAP","V7":"VOE","V8":"VAS","V9":"BTC","VA":"VOZ","VB":"VIV","VC":"VAL","VD":"KPA","VE":"EFY","VF":"TKJ","VH":"ALV","VI":"VDA","VJ":"VJC","VK":"FOO","VL":"LHX","VM":"NGL","VN":"HVN","VO":"UVL","VP":"VQI","VQ":"NVQ","VR":"TCV","VS":"VIR","VT":"VTA","VU":"VAG","VV":"VPE","VX":"VRD","VY":"VLG","VZ":"TVJ","W1":"WDL","W2":"CWA","W3":"ARA","W4":"WMT","W5":"IRM","W6":"WZZ","W8":"CJT","W9":"WUK","WA":"KLC","WB":"RWD","WC":"ISV","WD":"MWM","WE":"PTA","WF":"WIF","WG":"SWG","WH":"WEB","WI":"WHT","WJ":"JES","WK":"EDW","WL":"WAL","WN":"SWA","WO":"JTE","WQ":"SWQ","WR":"WEN","WS":"WJA","WT":"SWT","WU":"WAU","WV":"SWV","WW":"KXP","WX":"BCY","WY":"OMA","WZ":"RWZ","X3":"TUI","X5":"OVA","X6":"CHZ","X7":"CHG","X9":"NVD","XC":"CAI","XE":"JSX","XF":"VLK","XG":"SXD","XJ":"TAX","XK":"CCM","XL":"LNE","XM":"IMX","XN":"XAR","XP":"VXP","XQ":"SXS","XR":"CXI","XS":"SIT","XT":"IDX","XW":"NCT","XY":"KNE","XZ":"AEZ","Y2":"CEY","Y3":"UZU","Y4":"VOI","Y5":"GMR","Y6":"BTV","Y7":"TYA","Y8":"YZR","Y9":"DAT","YE":"ANR","YG":"HYT","YH":"WCW","YI":"OYA","YK":"AVJ","YL":"LLM","YM":"MGX","YP":"APZ","YS":"RAE","YU":"MMZ","YV":"ASH","YW":"ANE","YX":"RPA","YZ":"ALY","Z0":"UBT","Z2":"APG","Z3":"SMJ","Z5":"GMG","Z7":"CMS","Z8":"AZN","Z9":"BEK","ZA":"SWM","ZB":"ABN","ZD":"EWR","ZE":"ESR","ZF":"AZV","ZG":"TZP","ZH":"CSZ","ZK":"GLA","ZL":"RXA","ZN":"AZB","ZO":"CYN","ZP":"AZQ","ZQ":"GER","ZS":"SMY","ZT":"AWC","ZW":"AWI","ZX":"GGN","ZY":"SHY"} \ No newline at end of file diff --git a/server/scripts/build-airlines.mjs b/server/scripts/build-airlines.mjs new file mode 100644 index 000000000..2e4980d9a --- /dev/null +++ b/server/scripts/build-airlines.mjs @@ -0,0 +1,115 @@ +#!/usr/bin/env node +// Build server/assets/airlines-icao.json, the IATA airline code -> ICAO callsign prefix table the +// live flight status uses to turn a booked flight number ("LH 716") into the callsign an aircraft +// transmits ("DLH716"). +// +// Source: VRS standing data (github.com/vradarserver/standing-data, CC0): airlines/schema-01 for +// the codes and the file list of routes/schema-01 for which airline still flies. +// +// Only airlines with both codes are kept. IATA reuses its two-character codes, so one IATA code +// often names several ICAO airlines (LH: Lufthansa and Lufthansa Cargo; U2: easyJet and United +// Feeder Service). The airline list carries no "active" flag, so the rule is: the ICAO airline +// with the most route data in the standing data wins (the summed size of its routes files), since +// routes are only recorded for callsigns seen flying. A shared code where no candidate has any +// route data is left out rather than guessed; a booking with it is then simply not tracked. +import fs from 'node:fs'; +import https from 'node:https'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const OUT = path.join(__dirname, '..', 'assets', 'airlines-icao.json'); +const AIRLINES = 'https://raw.githubusercontent.com/vradarserver/standing-data/main/airlines/schema-01/airlines.csv'; +const TREE = 'https://api.github.com/repos/vradarserver/standing-data/git/trees/main?recursive=1'; + +function fetchText(url) { + return new Promise((resolve, reject) => { + https + .get(url, { headers: { 'User-Agent': 'trek-build-airlines', Accept: '*/*' } }, (res) => { + if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode} for ${url}`)); + let data = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => { + data += chunk; + }); + res.on('end', () => resolve(data)); + }) + .on('error', reject); + }); +} + +/** One CSV line into its fields: optional double quotes, "" as an escaped quote. */ +function parseLine(line) { + const fields = []; + let cur = ''; + let inQuotes = false; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (inQuotes) { + if (ch === '"' && line[i + 1] === '"') { + cur += '"'; + i++; + } else if (ch === '"') inQuotes = false; + else cur += ch; + } else if (ch === '"') inQuotes = true; + else if (ch === ',') { + fields.push(cur); + cur = ''; + } else cur += ch; + } + fields.push(cur); + return fields; +} + +const csv = (await fetchText(AIRLINES)).replace(/^/, ''); +const tree = JSON.parse(await fetchText(TREE)); +if (tree.truncated) throw new Error('The standing-data tree came back truncated; the route sizes would be incomplete'); + +// Bytes of route data per ICAO code: routes/schema-01//-all.csv or -.csv. +const routeBytes = new Map(); +for (const entry of tree.tree) { + const match = /^routes\/schema-01\/[A-Z0-9]\/([A-Z]{3})-(?:all|\d)\.csv$/.exec(entry.path); + if (!match) continue; + routeBytes.set(match[1], (routeBytes.get(match[1]) ?? 0) + (entry.size ?? 0)); +} + +const rows = csv.split(/\r?\n/); +const header = parseLine(rows[0]); +const ICAO = header.indexOf('ICAO'); +const IATA = header.indexOf('IATA'); +const candidates = new Map(); +for (const line of rows.slice(1)) { + if (!line.trim()) continue; + const fields = parseLine(line); + const icao = fields[ICAO]?.trim().toUpperCase(); + const iata = fields[IATA]?.trim().toUpperCase(); + if (!icao || !iata || !/^[A-Z]{3}$/.test(icao) || !/^(?=.*[A-Z])[A-Z0-9]{2}$/.test(iata)) continue; + const list = candidates.get(iata) ?? []; + if (!list.includes(icao)) list.push(icao); + candidates.set(iata, list); +} + +const table = {}; +let shared = 0; +let dropped = 0; +for (const iata of [...candidates.keys()].sort()) { + const list = candidates.get(iata); + if (list.length === 1) { + table[iata] = list[0]; + continue; + } + shared++; + const ranked = list + .map((icao) => ({ icao, bytes: routeBytes.get(icao) ?? 0 })) + .sort((a, b) => b.bytes - a.bytes || a.icao.localeCompare(b.icao)); + if (ranked[0].bytes === 0) { + dropped++; + continue; + } + table[iata] = ranked[0].icao; +} + +fs.writeFileSync(OUT, JSON.stringify(table)); +console.log( + `Wrote ${Object.keys(table).length} airline codes to ${OUT}; ${shared} shared IATA codes resolved by route data, ${dropped} left out without any`, +); diff --git a/server/scripts/repository-owners.json b/server/scripts/repository-owners.json index 4dc4058ea..fee86efd4 100644 --- a/server/scripts/repository-owners.json +++ b/server/scripts/repository-owners.json @@ -40,6 +40,7 @@ "DocumentSyncItems": "doc-sync", "FerrataTiles": "ferratas", "FileLinks": "files", + "FlightLiveStates": "flight-live", "GoogleApiUsage": "google-quota", "GooglePlacePhotoMeta": "place-photos", "HiddenCountries": "atlas", diff --git a/server/src/app-config/derive.ts b/server/src/app-config/derive.ts index 0ebb84bff..7c51d27d0 100644 --- a/server/src/app-config/derive.ts +++ b/server/src/app-config/derive.ts @@ -111,6 +111,12 @@ export function deriveSession(raw: RawEnv) { }; } +/** A boolean variable as the stored settings spell it, or undefined when unset or not a boolean. */ +function flagText(raw: string | undefined): 'true' | 'false' | undefined { + const value = parseBool(raw); + return value === undefined ? undefined : value ? 'true' : 'false'; +} + /** * Whether this instance is centrally administered: somebody other than its * admin user owns the configuration, the credentials and the upgrade schedule. @@ -312,6 +318,12 @@ export function deriveIntegrations(raw: RawEnv) { * request whose prompt plus cap overruns its context (#2677). */ llmMaxOutputTokens: positiveIntOr(raw.LLM_MAX_OUTPUT_TOKENS, 0) || undefined, + /** + * The operator's word on live flight status, as the admin setting spells it + * ('true'/'false'), or undefined when unset or not a boolean: then the admin + * switch (`flight_live_enabled`) decides. + */ + flightLiveEnabled: flagText(raw.FLIGHT_LIVE_ENABLED), // TRANSIT_API_URL, KITINERARY_EXTRACTOR_PATH and PATH are boot-stable and // read only by Nest providers, so the transitConfig and kitineraryConfig // tokens own them (boot-derive.ts). diff --git a/server/src/app-config/env.schema.ts b/server/src/app-config/env.schema.ts index e3a3ac42f..9f1532ff3 100644 --- a/server/src/app-config/env.schema.ts +++ b/server/src/app-config/env.schema.ts @@ -185,6 +185,7 @@ export const envSchema = z.object({ GOOGLE_LIST_DATA_BASE: url, TREK_PLACES_URL: url, TREK_PLACES_ENABLED: boolStr, + FLIGHT_LIVE_ENABLED: boolStr, TREK_PLACES_CONTRIBUTE: oneOf(['off', 'admin']), TREK_SERVICES: oneOf(['auto', 'off']), TREK_SERVICES_KEY: optionalWith(isServiceKeyShape, 'must be a TREK services key (tk1...)'), diff --git a/server/src/db/entities/FlightLiveStates.entity.ts b/server/src/db/entities/FlightLiveStates.entity.ts new file mode 100644 index 000000000..9df431a0f --- /dev/null +++ b/server/src/db/entities/FlightLiveStates.entity.ts @@ -0,0 +1,65 @@ +import { EntityRepositoryType, type Opt, PrimaryKeyProp, defineEntity, p } from '@mikro-orm/core'; +import { FlightLiveStatesRepository } from '../repositories/FlightLiveStates.repository'; +import { DbTimestampType } from '../types'; + +export class FlightLiveStates { + [EntityRepositoryType]?: FlightLiveStatesRepository; + [PrimaryKeyProp]?: ['callsign', 'service_date', 'origin', 'destination']; + callsign!: string; + service_date!: string; + origin!: string; + destination!: string; + phase: string & Opt = 'scheduled'; + departed_at?: string | null; + landed_at?: string | null; + lat?: number | null; + lng?: number | null; + altitude_ft?: number | null; + ground_speed_kt?: number | null; + track_deg?: number | null; + vertical_rate_fpm?: number | null; + on_ground?: number | null; + registration?: string | null; + aircraft_type?: string | null; + source?: string | null; + first_seen_at?: string | null; + last_seen_at?: string | null; + fetched_at?: string | null; + next_poll_at?: string | null; + fail_count: number & Opt = 0; + departed_notified: number & Opt = 0; + landed_notified: number & Opt = 0; + delay_notified: number & Opt = 0; +} + +export const FlightLiveStatesSchema = defineEntity({ + class: FlightLiveStates, + repository: () => FlightLiveStatesRepository, + properties: { + callsign: p.text().primary(), + service_date: p.text().primary(), + origin: p.text().primary(), + destination: p.text().primary(), + phase: p.text().default('scheduled'), + departed_at: p.type(DbTimestampType).nullable(), + landed_at: p.type(DbTimestampType).nullable(), + lat: p.double().nullable(), + lng: p.double().nullable(), + altitude_ft: p.double().nullable(), + ground_speed_kt: p.double().nullable(), + track_deg: p.double().nullable(), + vertical_rate_fpm: p.double().nullable(), + on_ground: p.integer().nullable(), + registration: p.text().nullable(), + aircraft_type: p.text().nullable(), + source: p.text().nullable(), + first_seen_at: p.type(DbTimestampType).nullable(), + last_seen_at: p.type(DbTimestampType).nullable(), + fetched_at: p.type(DbTimestampType).nullable(), + next_poll_at: p.type(DbTimestampType).nullable(), + fail_count: p.integer().default(0), + departed_notified: p.integer().default(0), + landed_notified: p.integer().default(0), + delay_notified: p.integer().default(0), + }, +}); diff --git a/server/src/db/entities/index.ts b/server/src/db/entities/index.ts index 4b5112f54..584436d9a 100644 --- a/server/src/db/entities/index.ts +++ b/server/src/db/entities/index.ts @@ -40,6 +40,7 @@ import { DocumentProvidersSchema } from './DocumentProviders.entity'; import { DocumentSyncItemsSchema } from './DocumentSyncItems.entity'; import { FerrataTilesSchema } from './FerrataTiles.entity'; import { FileLinksSchema } from './FileLinks.entity'; +import { FlightLiveStatesSchema } from './FlightLiveStates.entity'; import { GoogleApiUsageSchema } from './GoogleApiUsage.entity'; import { GooglePlacePhotoMetaSchema } from './GooglePlacePhotoMeta.entity'; import { HiddenCountriesSchema } from './HiddenCountries.entity'; @@ -209,6 +210,7 @@ export const ALL_ENTITIES: readonly EntitySchema[] = [ DocumentSyncItemsSchema, FerrataTilesSchema, FileLinksSchema, + FlightLiveStatesSchema, GoogleApiUsageSchema, GooglePlacePhotoMetaSchema, HiddenCountriesSchema, diff --git a/server/src/db/kysely/FlightLiveStates.table.ts b/server/src/db/kysely/FlightLiveStates.table.ts new file mode 100644 index 000000000..0fdccea32 --- /dev/null +++ b/server/src/db/kysely/FlightLiveStates.table.ts @@ -0,0 +1,31 @@ +// Generated by scripts/generate-db-types.ts from the migrated schema. Do not edit by hand: +// change the migration, then run `npm run gen:db-types`. +import type { InsertOptional } from './columns'; + +export interface FlightLiveStatesTable { + callsign: string; + service_date: string; + origin: string; + destination: string; + phase: InsertOptional; + departed_at: InsertOptional; + landed_at: InsertOptional; + lat: InsertOptional; + lng: InsertOptional; + altitude_ft: InsertOptional; + ground_speed_kt: InsertOptional; + track_deg: InsertOptional; + vertical_rate_fpm: InsertOptional; + on_ground: InsertOptional; + registration: InsertOptional; + aircraft_type: InsertOptional; + source: InsertOptional; + first_seen_at: InsertOptional; + last_seen_at: InsertOptional; + fetched_at: InsertOptional; + next_poll_at: InsertOptional; + fail_count: InsertOptional; + departed_notified: InsertOptional; + landed_notified: InsertOptional; + delay_notified: InsertOptional; +} diff --git a/server/src/db/kysely/db.ts b/server/src/db/kysely/db.ts index 9738a7588..88fd33366 100644 --- a/server/src/db/kysely/db.ts +++ b/server/src/db/kysely/db.ts @@ -43,6 +43,7 @@ import type { DocumentProvidersTable } from './DocumentProviders.table'; import type { DocumentSyncItemsTable } from './DocumentSyncItems.table'; import type { FerrataTilesTable } from './FerrataTiles.table'; import type { FileLinksTable } from './FileLinks.table'; +import type { FlightLiveStatesTable } from './FlightLiveStates.table'; import type { GoogleApiUsageTable } from './GoogleApiUsage.table'; import type { GooglePlacePhotoMetaTable } from './GooglePlacePhotoMeta.table'; import type { HiddenCountriesTable } from './HiddenCountries.table'; @@ -218,6 +219,7 @@ export interface DB { document_sync_items: DocumentSyncItemsTable; ferrata_tiles: FerrataTilesTable; file_links: FileLinksTable; + flight_live_states: FlightLiveStatesTable; google_api_usage: GoogleApiUsageTable; google_place_photo_meta: GooglePlacePhotoMetaTable; hidden_countries: HiddenCountriesTable; diff --git a/server/src/db/migrations/Migration20200101046400_a_booked_flight_shows_where_it_is.ts b/server/src/db/migrations/Migration20200101046400_a_booked_flight_shows_where_it_is.ts new file mode 100644 index 000000000..56dcc23aa --- /dev/null +++ b/server/src/db/migrations/Migration20200101046400_a_booked_flight_shows_where_it_is.ts @@ -0,0 +1,51 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Live flight status: what the open ADS-B feeds last said about one flight on + * one day, keyed by the callsign it transmits, its local departure date and + * its two airports. A through flight keeps one flight number over two sectors + * (FRA to BOM to SIN as LH 754 on one day), and each sector has a state of its own. + * One row serves every trip that booked the same flight, so the feeds are asked + * once per flight, not once per traveller. Next to the derived state sit the + * poll's bookkeeping (when to ask next, how often it failed) and whether each + * notification already went out, so a departure is announced once. + * + * No foreign key: the row describes a flight in the air, not a booking, and is + * found again from the bookings by its key. + */ +export class Migration20200101046400_a_booked_flight_shows_where_it_is extends Migration { + override name = 'Migration20200101046400_a_booked_flight_shows_where_it_is'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS flight_live_states ( + callsign TEXT NOT NULL, + service_date TEXT NOT NULL, + origin TEXT NOT NULL, + destination TEXT NOT NULL, + phase TEXT NOT NULL DEFAULT 'scheduled', + departed_at DATETIME, + landed_at DATETIME, + lat REAL, + lng REAL, + altitude_ft REAL, + ground_speed_kt REAL, + track_deg REAL, + vertical_rate_fpm REAL, + on_ground INTEGER, + registration TEXT, + aircraft_type TEXT, + source TEXT, + first_seen_at DATETIME, + last_seen_at DATETIME, + fetched_at DATETIME, + next_poll_at DATETIME, + fail_count INTEGER NOT NULL DEFAULT 0, + departed_notified INTEGER NOT NULL DEFAULT 0, + landed_notified INTEGER NOT NULL DEFAULT 0, + delay_notified INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (callsign, service_date, origin, destination) + ) + `); + } +} diff --git a/server/src/db/repositories/FlightLiveStates.repository.ts b/server/src/db/repositories/FlightLiveStates.repository.ts new file mode 100644 index 000000000..164258440 --- /dev/null +++ b/server/src/db/repositories/FlightLiveStates.repository.ts @@ -0,0 +1,210 @@ +import type { FlightLiveStates } from '../entities/FlightLiveStates.entity'; +import type { DB } from '../kysely/db'; +import type { DbTimestamp } from '../types'; +import { TrekRepository } from './_shared/trek-repository'; + +/** A flight booking as the live flight status reads it; the metadata is the stored text. */ +export interface FlightBookingDbRow { + id: number; + trip_id: number; + trip_title: string; + metadata: string | null; + day_id: number | null; + end_day_id: number | null; + reservation_time: string | null; + reservation_end_time: string | null; +} + +interface FlightEndpointDbRow { + reservation_id: number; + role: string; + sequence: number; + name: string; + code: string | null; + lat: number; + lng: number; + timezone: string | null; + local_date: string | null; + local_time: string | null; +} + +/** + * One flight state, every column; timestamps in the canonical UTC text. `T` + * is `string` for a row read back and `DbTimestamp` for one being written. + */ +interface FlightLiveStateFields { + callsign: string; + service_date: string; + origin: string; + destination: string; + phase: string; + departed_at: T | null; + landed_at: T | null; + lat: number | null; + lng: number | null; + altitude_ft: number | null; + ground_speed_kt: number | null; + track_deg: number | null; + vertical_rate_fpm: number | null; + on_ground: number | null; + registration: string | null; + aircraft_type: string | null; + source: string | null; + first_seen_at: T | null; + last_seen_at: T | null; + fetched_at: T | null; + next_poll_at: T | null; + fail_count: number; + departed_notified: number; + landed_notified: number; + delay_notified: number; +} + +export type FlightLiveStateRow = FlightLiveStateFields; +export type FlightLiveStateWrite = FlightLiveStateFields; + +const BOOKING_COLUMNS = [ + 'reservations.id', + 'reservations.trip_id', + 'reservations.metadata', + 'reservations.day_id', + 'reservations.end_day_id', + 'reservations.reservation_time', + 'reservations.reservation_end_time', +] as const; + +const ENDPOINT_COLUMNS = [ + 'reservation_id', + 'role', + 'sequence', + 'name', + 'code', + 'lat', + 'lng', + 'timezone', + 'local_date', + 'local_time', +] as const; + +/** + * `flight_live_states`: what the open ADS-B feeds last said about a flight on + * a day, one row per (callsign, service date, origin, destination) whichever + * trips booked it. + * + * The flight bookings, their endpoints, the days they name and their + * travellers are read here too, through Kysely: the live status only reads + * them, and the reservations domain has no read of flights across all trips. + */ +export class FlightLiveStatesRepository extends TrekRepository { + /** The flight bookings of one trip. */ + async listFlightBookingsForTrip(trip_id: number): Promise { + return await this.kysely>() + .selectFrom('reservations') + .innerJoin('trips', 'trips.id', 'reservations.trip_id') + .select([...BOOKING_COLUMNS, 'trips.title as trip_title']) + .where('reservations.trip_id', '=', trip_id) + .where('reservations.type', '=', 'flight') + .orderBy('reservations.id') + .execute(); + } + + /** + * The flight bookings of every trip that is not archived and whose dates + * (where it has any) reach into `from_date`..`to_date`: the candidates for a + * poll. Which leg is really in its window the caller works out from the + * schedule. + */ + async listFlightBookingsBetween(from_date: string, to_date: string): Promise { + return await this.kysely>() + .selectFrom('reservations') + .innerJoin('trips', 'trips.id', 'reservations.trip_id') + .select([...BOOKING_COLUMNS, 'trips.title as trip_title']) + .where('reservations.type', '=', 'flight') + .where((eb) => eb.or([eb('trips.is_archived', 'is', null), eb('trips.is_archived', '=', 0)])) + .where((eb) => eb.or([eb('trips.end_date', 'is', null), eb('trips.end_date', '>=', from_date)])) + .where((eb) => eb.or([eb('trips.start_date', 'is', null), eb('trips.start_date', '<=', to_date)])) + .orderBy('reservations.id') + .execute(); + } + + /** The endpoints of the given bookings, in their order. */ + async listEndpoints(reservation_ids: readonly number[]): Promise { + if (reservation_ids.length === 0) return []; + return await this.kysely>() + .selectFrom('reservation_endpoints') + .select([...ENDPOINT_COLUMNS]) + .where('reservation_id', 'in', [...reservation_ids]) + .orderBy('reservation_id') + .orderBy('sequence') + .execute(); + } + + /** The date of each given trip day that has one. */ + async listDayDates(day_ids: readonly number[]): Promise> { + if (day_ids.length === 0) return []; + const rows = await this.kysely>() + .selectFrom('days') + .select(['id', 'date']) + .where('id', 'in', [...day_ids]) + .execute(); + return rows.flatMap((row) => (row.date ? [{ id: row.id, date: row.date }] : [])); + } + + /** + * The users put on a booking as its travellers who still belong to its trip: + * the owner or a member. Someone removed from the trip stays on the + * traveller list until it is edited, and must hear nothing more of it. + */ + async listTravelerIds(reservation_id: number): Promise { + const rows = await this.kysely>() + .selectFrom('reservation_travelers as rt') + .innerJoin('reservations as r', 'r.id', 'rt.reservation_id') + .innerJoin('trips as t', 't.id', 'r.trip_id') + .select('rt.user_id') + .where('rt.reservation_id', '=', reservation_id) + .where((eb) => + eb.or([ + eb('t.user_id', '=', eb.ref('rt.user_id')), + eb.exists( + eb + .selectFrom('trip_members as tm') + .select('tm.id') + .whereRef('tm.trip_id', '=', 'r.trip_id') + .whereRef('tm.user_id', '=', 'rt.user_id'), + ), + ]), + ) + .orderBy('rt.user_id') + .execute(); + return rows.map((row) => row.user_id); + } + + /** Every stored state of the given callsigns, whatever their day. */ + async findForCallsigns(callsigns: readonly string[]): Promise { + if (callsigns.length === 0) return []; + return await this.kysely>() + .selectFrom('flight_live_states') + .selectAll() + .where('callsign', 'in', [...callsigns]) + .execute(); + } + + /** Writes a flight's whole state, replacing the one stored under its key. */ + async saveState(row: FlightLiveStateWrite): Promise { + const { callsign, service_date, origin, destination, ...changes } = row; + await this.kysely>() + .insertInto('flight_live_states') + .values({ callsign, service_date, origin, destination, ...changes }) + .onConflict((oc) => oc.columns(['callsign', 'service_date', 'origin', 'destination']).doUpdateSet(changes)) + .execute(); + } + + /** Drops the states of flights that departed before `service_date`; answers how many went. */ + async deleteBefore(service_date: string): Promise { + const result = await this.kysely>() + .deleteFrom('flight_live_states') + .where('service_date', '<', service_date) + .executeTakeFirst(); + return Number(result.numDeletedRows); + } +} diff --git a/server/src/nest/app.module.ts b/server/src/nest/app.module.ts index 3527bf6a0..bb79f4d46 100644 --- a/server/src/nest/app.module.ts +++ b/server/src/nest/app.module.ts @@ -46,6 +46,7 @@ import { DocSyncModule } from './doc-sync/doc-sync.module'; import { FeedsModule } from './feeds/feeds.module'; import { FerratasModule } from './ferratas/ferratas.module'; import { FilesModule } from './files/files.module'; +import { FlightLiveModule } from './flight-live/flight-live.module'; import { GeoModule } from './geo/geo.module'; import { GoogleQuotaModule } from './google-quota/google-quota.module'; import { HealthModule } from './health/health.module'; @@ -178,6 +179,7 @@ import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'; PhotosModule, MemoriesModule, AirtrailModule, + FlightLiveModule, DawarichModule, JourneyModule, JourneyRecordingsModule, diff --git a/server/src/nest/auth/auth.service.ts b/server/src/nest/auth/auth.service.ts index d2cfaae22..b6aa46ac9 100644 --- a/server/src/nest/auth/auth.service.ts +++ b/server/src/nest/auth/auth.service.ts @@ -44,7 +44,7 @@ import { EphemeralTokenService } from '../auth-core/ephemeral-token.service'; // every consumer of this module — a nest→mcp→nest module cycle. import { revokeUserSessions } from '../../mcp/sessionManager'; import { UserCleanupService } from './user-cleanup.service'; -import { splitManagedKeys } from '../common/managed'; +import { envLockedKeys, splitManagedKeys, withEnvLocks } from '../common/managed'; import { emitUserDeleted } from '../../plugin-user-lifecycle'; import { verifyJwtAndLoadUser } from '../auth-core/jwt-verify'; import { User } from '../../types'; @@ -711,7 +711,7 @@ export class AuthService { const value = await readAppSetting(this.appSettings, key); if (value !== null) result[key] = MASKED_ADMIN_SETTING_KEYS.has(key) ? '••••••••' : value; } - return { data: result }; + return { data: withEnvLocks(result) }; } async updateAppSettings( @@ -754,7 +754,7 @@ export class AuthService { // the admin does own, so a managed install skips those names and keeps the // rest of the save working. Reported, never thrown: the settings tab sends // the whole form in one request. - const { blocked } = splitManagedKeys(body as Record, readEnv().managed.enabled); + const blocked = [...splitManagedKeys(body, readEnv().managed.enabled).blocked, ...envLockedKeys(body)]; await this.uow.transactional(async () => { // the whole form lands, or none of it for (const key of ADMIN_FORM_SETTING_KEYS) { diff --git a/server/src/nest/common/app-settings.registry.ts b/server/src/nest/common/app-settings.registry.ts index 7a192ea15..248c23283 100644 --- a/server/src/nest/common/app-settings.registry.ts +++ b/server/src/nest/common/app-settings.registry.ts @@ -93,6 +93,8 @@ export const APP_SETTINGS = { // 'auto' (what every install had before Amap existed), 'google', 'amap' or // 'openstreetmap'; the maps domain validates it (isPlacesProviderChoice). places_provider: { schema: text, default: 'auto', adminForm: true, publicConfig: true, managedLocked: true }, + // Live flight status from the keyless ADS-B feeds (flight-live). On by default; FLIGHT_LIVE_ENABLED wins over it. + flight_live_enabled: { schema: flag, default: 'true', env: (e) => e.integrations.flightLiveEnabled, adminForm: true }, // Read by name elsewhere. oidc_issuer: { schema: text, default: null, env: (e) => e.oidc.issuer, publicConfig: true }, diff --git a/server/src/nest/common/managed.ts b/server/src/nest/common/managed.ts index 45a22f36a..14efac592 100644 --- a/server/src/nest/common/managed.ts +++ b/server/src/nest/common/managed.ts @@ -1,4 +1,6 @@ +import { readEnv } from '../../app-config'; import { RuntimeEnvService } from '../app-config/runtime-env.service'; +import { APP_SETTINGS, type AppSettingDef, type AppSettingKey } from './app-settings.registry'; import { byCodeUnit } from './compare'; import { SetMetadata } from '@nestjs/common'; @@ -144,6 +146,7 @@ export const MANAGED_CUSTOMER_KEYS = [ 'dark_mode', 'default_currency', 'distance_unit', + 'flight_live_enabled', 'map_provider', 'map_tile_url', 'mapbox_3d_enabled', @@ -192,3 +195,37 @@ export function splitManagedKeys>( } return { allowed, blocked: blocked.sort(byCodeUnit) }; } + +/** + * Admin form switches an environment variable holds once it is set. The + * variable wins over the stored value anyway (resolveAppSetting), so the form + * shows the variable's value and a save leaves the stored one alone instead of + * pretending to change what still applies. SMTP keeps its old behaviour. + */ +const ENV_LOCKED_ADMIN_KEYS: readonly AppSettingKey[] = ['flight_live_enabled']; + +/** The value the environment pins a switch to, or null while it is unset. */ +function envValue(key: AppSettingKey): string | null { + return (APP_SETTINGS[key] as AppSettingDef).env?.(readEnv()) || null; +} + +/** The env-held switches the write body sends, which the save skips and reports like the operator's keys. */ +export function envLockedKeys(body: Record): string[] { + return ENV_LOCKED_ADMIN_KEYS.filter((key) => body[key] !== undefined && envValue(key) !== null); +} + +/** + * The admin form's values with every env-held switch set to the value that + * applies, and `env_override_` beside it so the form can show the switch + * locked (the shape of `env_override_oidc_only` in the app config). + */ +export function withEnvLocks(values: Record): Record { + const out = { ...values }; + for (const key of ENV_LOCKED_ADMIN_KEYS) { + const value = envValue(key); + if (value === null) continue; + out[key] = value; + out[`env_override_${key}`] = value; + } + return out; +} diff --git a/server/src/nest/flight-live/flight-live-poll.service.ts b/server/src/nest/flight-live/flight-live-poll.service.ts new file mode 100644 index 000000000..60ffbabdf --- /dev/null +++ b/server/src/nest/flight-live/flight-live-poll.service.ts @@ -0,0 +1,328 @@ +import { FlightLiveStates } from '../../db/entities/FlightLiveStates.entity'; +import type { + FlightLiveStatesRepository, + FlightLiveStateWrite, +} from '../../db/repositories/FlightLiveStates.repository'; +import { dbNow, type DbTimestamp } from '../../db/types'; +import { logDebug, logError } from '../audit/audit-log.logger'; +import { NotificationsService, type NotificationPayload } from '../notifications/notifications.service'; +import { RealtimeService } from '../realtime/realtime.service'; +import { + advance, + closeIfOver, + departureDelay, + emptyState, + failed, + inWatchWindow, + nextPollAt, + pickAircraft, + toLiveLeg, + watchWindow, + type LiveState, +} from './flight-live.derive'; +import { FlightLiveFeedService, type FeedLookup } from './flight-live.feeds'; +import { FlightLiveService, legKey, type TripLeg } from './flight-live.service'; +import { InjectRepository } from '@mikro-orm/nestjs'; +import { Injectable } from '@nestjs/common'; + +type NotifEventType = NotificationPayload['event']; + +const DAY_MS = 24 * 60 * 60 * 1000; +/** A delay worth telling the travellers about, in minutes. */ +const DELAY_NOTICE_MIN = 15; +/** A client's refresh brings a flight forward only when it is due within this anyway. */ +const REFRESH_AHEAD_MS = 2 * 60 * 1000; +/** At most this many feed requests in one tick (a minute); the hourly budget is the real limit. */ +const MAX_REQUESTS_PER_TICK = 20; +/** States of flights that departed longer ago are dropped. */ +const KEEP_DAYS = 30; + +interface PollResult { + asked: number; + changed: number; + skipped: 'disabled' | 'running' | null; +} + +function ts(ms: number | null): DbTimestamp | null { + return ms === null ? null : dbNow(new Date(ms)); +} + +function toWrite(state: LiveState): FlightLiveStateWrite { + const p = state.position; + return { + callsign: state.callsign, + service_date: state.service_date, + origin: state.origin, + destination: state.destination, + phase: state.phase, + departed_at: ts(state.departed_at), + landed_at: ts(state.landed_at), + lat: p?.lat ?? null, + lng: p?.lng ?? null, + altitude_ft: p?.altitude_ft ?? null, + ground_speed_kt: p?.ground_speed_kt ?? null, + track_deg: p?.track_deg ?? null, + vertical_rate_fpm: p?.vertical_rate_fpm ?? null, + on_ground: p ? (p.on_ground ? 1 : 0) : null, + registration: state.registration, + aircraft_type: state.aircraft_type, + source: state.source, + first_seen_at: ts(state.first_seen_at), + last_seen_at: ts(state.last_seen_at), + fetched_at: ts(state.fetched_at), + next_poll_at: ts(state.next_poll_at), + fail_count: state.fail_count, + departed_notified: state.departed_notified ? 1 : 0, + landed_notified: state.landed_notified ? 1 : 0, + delay_notified: state.delay_notified ? 1 : 0, + }; +} + +/** What a client sees of a state; a poll that changed none of it is not broadcast. */ +function visible(state: LiveState): string { + return JSON.stringify([ + state.phase, + state.departed_at, + state.landed_at, + state.position, + state.registration, + state.aircraft_type, + state.last_seen_at, + ]); +} + +/** The local date `days` away from `now`'s UTC date. */ +function utcDate(now: number, days: number): string { + return new Date(now + days * DAY_MS).toISOString().slice(0, 10); +} + +/** + * The poll behind live flight status, one tick a minute (FlightLiveJob). + * + * It reads every flight leg in its watch window across all trips, asks the + * feed once per flight (callsign and departure day) that is due by its + * cadence or that a client asked to refresh, within the instance's budget, + * stores what changed, sends it to every trip holding the flight and tells + * the travellers when the flight departs, lands or runs late. + */ +@Injectable() +export class FlightLivePollService { + private running = false; + private lastPurgeDay: string | null = null; + /** Waits between two requests; a test hands in one that does not. */ + sleep: (ms: number) => Promise = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + + constructor( + @InjectRepository(FlightLiveStates) private readonly states: FlightLiveStatesRepository, + private readonly live: FlightLiveService, + private readonly feeds: FlightLiveFeedService, + private readonly notifications: NotificationsService, + private readonly realtime: RealtimeService, + ) {} + + /** + * One tick. A second call while one runs returns at once. + * + * @txIndependent every flight's state is one write of its own, with network + * I/O between them; a tick cut short keeps what it stored. + */ + async poll(clock: () => number = Date.now): Promise { + if (this.running) return { asked: 0, changed: 0, skipped: 'running' }; + this.running = true; + try { + if (!(await this.live.isEnabled())) return { asked: 0, changed: 0, skipped: 'disabled' }; + await this.purge(clock()); + return await this.pollDue(clock); + } finally { + this.running = false; + } + } + + private async purge(now: number): Promise { + const today = utcDate(now, 0); + if (this.lastPurgeDay === today) return; + this.lastPurgeDay = today; + const dropped = await this.states.deleteBefore(utcDate(now, -KEEP_DAYS)); + if (dropped > 0) logDebug(`[flight-live] dropped ${dropped} old flight states`); + } + + /** + * @txIndependent one write per flight, with a feed request and the notices + * between them; a flight stored stays stored if a later one fails. + */ + private async pollDue(clock: () => number): Promise { + const now = clock(); + const bookings = await this.states.listFlightBookingsBetween(utcDate(now, -2), utcDate(now, 2)); + const loaded = await this.live.loadLegs(bookings); + const stored = await this.live.statesFor(loaded); + await this.closeEnded(loaded, stored, now); + const groups = new Map(); + for (const leg of loaded.filter((l) => inWatchWindow(l, now))) { + groups.set(legKey(leg), [...(groups.get(legKey(leg)) ?? []), leg]); + } + const requested = this.live.takeRefreshRequests(now); + const due = [...groups.keys()] + .map((key) => ({ key, state: stored.get(key) ?? null })) + .filter(({ key, state }) => { + if (state?.phase === 'landed') return false; + if (!state || state.next_poll_at === null) return true; + // A client's ask may bring a flight forward a little, never past its cadence or a back-off. + const ahead = requested.has(key) ? REFRESH_AHEAD_MS : 0; + return state.next_poll_at - ahead <= now; + }) + .sort( + (a, b) => + Number(requested.has(b.key)) - Number(requested.has(a.key)) || + (a.state?.next_poll_at ?? 0) - (b.state?.next_poll_at ?? 0), + ); + + const queue = due.slice(0, MAX_REQUESTS_PER_TICK).map(({ key, state }) => ({ legs: groups.get(key) ?? [], state })); + const result: PollResult = { asked: 0, changed: 0, skipped: null }; + await this.pollEach(queue, clock, result); + return result; + } + + /** + * Closes every flight whose window ended while it was still taxiing or in + * the air: stored as unknown with no next poll, and sent to its trips. + * + * @txIndependent one write per closed flight; each stands on its own. + */ + private async closeEnded(legs: readonly TripLeg[], stored: Map, now: number): Promise { + const ended = new Map(); + for (const leg of legs) { + const span = watchWindow(leg); + if (span !== null && now > span.end) ended.set(legKey(leg), [...(ended.get(legKey(leg)) ?? []), leg]); + } + await [...ended].reduce(async (previous, [key, flightLegs]) => { + await previous; + const state = stored.get(key); + const route = flightLegs[0]; + if (!state || !route) return; + const closed = closeIfOver(state, route, now); + if (closed === state) return; + stored.set(key, closed); + await this.states.saveState(toWrite(closed)); + this.broadcast(flightLegs, closed, now); + }, Promise.resolve()); + } + + /** + * Asks about the flights one after the other, waiting out the feed spacing + * between them, until the queue or the budget runs out. + * + * @txIndependent one write per flight (pollDue). + */ + private async pollEach( + queue: ReadonlyArray<{ legs: TripLeg[]; state: LiveState | null }>, + clock: () => number, + result: PollResult, + ): Promise { + const [head, ...rest] = queue; + const route = head?.legs[0]; + if (!head || !route) return; + const wait = this.feeds.waitMs(clock()); + if (wait > 0) await this.sleep(wait); + if (!this.feeds.canAsk(clock())) return; + const prev = + head.state ?? emptyState(route.callsign, route.service_date, route.origin.code, route.destination.code); + const answer = await this.feeds.lookup(route.callsign, clock); + // Spelled out: without strictNullChecks `ok` alone does not narrow the union. + if (!answer.ok && (answer as Extract).reason === 'budget') return; + result.asked++; + const at = clock(); + let next = answer.ok ? advance(prev, pickAircraft(answer.aircraft, route), route, at, answer.source) : failed(prev); + next = closeIfOver({ ...next, next_poll_at: nextPollAt(next, route, at) }, route, at); + const notices = this.dueNotices(prev, next, head.legs, at); + next = { ...next, ...notices.flags }; + await this.states.saveState(toWrite(next)); + if (visible(prev) !== visible(next)) { + result.changed++; + this.broadcast(head.legs, next, at); + } + await this.announce(notices.events, head.legs, next, at); + await this.pollEach(rest, clock, result); + } + + /** The notices this poll owes the travellers, each at most once per flight, and the flags that record them. */ + private dueNotices( + prev: LiveState, + next: LiveState, + legs: readonly TripLeg[], + now: number, + ): { events: NotifEventType[]; flags: Pick } { + const events: NotifEventType[] = []; + const flags = { + departed_notified: prev.departed_notified, + landed_notified: prev.landed_notified, + delay_notified: prev.delay_notified, + }; + const delays = legs.map((leg) => departureDelay(leg, next, now)).filter((d): d is number => d !== null); + if (!flags.delay_notified && delays.some((d) => d >= DELAY_NOTICE_MIN)) { + events.push('flight_delayed'); + flags.delay_notified = true; + } + if (!flags.departed_notified && next.phase === 'airborne') { + events.push('flight_departed'); + flags.departed_notified = true; + } + if (!flags.landed_notified && next.phase === 'landed') { + events.push('flight_landed'); + flags.landed_notified = true; + // Seen only once it was down: departing now would be news from the past. + flags.departed_notified = true; + } + return { events, flags }; + } + + /** The changed flight to every trip holding one of its legs, as the client reads it. */ + private broadcast(legs: readonly TripLeg[], state: LiveState, now: number): void { + const byTrip = new Map(); + for (const leg of legs) byTrip.set(leg.trip_id, [...(byTrip.get(leg.trip_id) ?? []), leg]); + for (const [tripId, tripLegs] of byTrip) { + this.realtime.broadcast(tripId, 'flight:live', { flights: tripLegs.map((leg) => toLiveLeg(leg, state, now)) }); + } + } + + /** + * Each notice to the travellers of each booking holding the flight, or to + * the whole trip when nobody is put on the booking. + * + * @txStandalone a notice stands on its own; the state that records it is already stored. + */ + private async announce( + events: readonly NotifEventType[], + legs: readonly TripLeg[], + state: LiveState, + now: number, + ): Promise { + if (events.length === 0) return; + // One leg after the other, as every other sender in the codebase does: a notice writes its rows. + await legs.reduce(async (previous, leg) => { + await previous; + const params: Record = { + flight: leg.flight_number, + from: leg.origin.code, + to: leg.destination.code, + trip: leg.trip_title, + tripId: String(leg.trip_id), + delay: String(Math.max(0, departureDelay(leg, state, now) ?? 0)), + }; + try { + const travelers = await this.states.listTravelerIds(leg.reservation_id); + const targets: Array<{ scope: 'trip' | 'user'; targetId: number }> = + travelers.length === 0 + ? [{ scope: 'trip', targetId: leg.trip_id }] + : travelers.map((userId) => ({ scope: 'user', targetId: userId })); + const sends = events.flatMap((event) => targets.map((target) => ({ event, ...target }))); + await sends.reduce(async (sent, payload) => { + await sent; + await this.notifications.send({ ...payload, actorId: null, params }); + }, Promise.resolve()); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + logError(`[flight-live] notice for ${leg.callsign}: ${reason}`); + } + }, Promise.resolve()); + } +} diff --git a/server/src/nest/flight-live/flight-live.budget.ts b/server/src/nest/flight-live/flight-live.budget.ts new file mode 100644 index 000000000..7f101301c --- /dev/null +++ b/server/src/nest/flight-live/flight-live.budget.ts @@ -0,0 +1,50 @@ +/** + * How often this instance may ask the open ADS-B feeds: at most one request a + * second and 200 an hour across every flight, whichever trip asked. The feeds + * are run by volunteers on donated capacity; a planner instance has no reason + * to come near what they allow. A 429 or a run of failures pauses a source for + * a quarter of an hour. + * + * Process state on purpose: TREK runs as one process, and the job that spends + * the budget holds a scheduler lease, so a second process does not tick at the + * same time. + */ +export const BUDGET_PER_HOUR = 200; +const MIN_SPACING_MS = 1000; +const BACKOFF_MS = 15 * 60 * 1000; +const HOUR_MS = 60 * 60 * 1000; + +export class FeedBudget { + private readonly sent: number[] = []; + private readonly pausedUntil = new Map(); + + /** Takes one request from the budget when there is one left now. */ + tryTake(now: number): boolean { + while (this.sent.length > 0 && now - this.sent[0] >= HOUR_MS) this.sent.shift(); + const last = this.sent[this.sent.length - 1]; + if (last !== undefined && now - last < MIN_SPACING_MS) return false; + if (this.sent.length >= BUDGET_PER_HOUR) return false; + this.sent.push(now); + return true; + } + + /** How long until the spacing lets the next request go, in ms; 0 when it may go now. */ + waitMs(now: number): number { + const last = this.sent[this.sent.length - 1]; + return last === undefined ? 0 : Math.max(0, MIN_SPACING_MS - (now - last)); + } + + /** Whether the hourly budget is used up. */ + exhausted(now: number): boolean { + return this.sent.filter((at) => now - at < HOUR_MS).length >= BUDGET_PER_HOUR; + } + + pause(source: string, now: number): void { + this.pausedUntil.set(source, now + BACKOFF_MS); + } + + isPaused(source: string, now: number): boolean { + const until = this.pausedUntil.get(source); + return until !== undefined && now < until; + } +} diff --git a/server/src/nest/flight-live/flight-live.callsign.ts b/server/src/nest/flight-live/flight-live.callsign.ts new file mode 100644 index 000000000..0de59e816 --- /dev/null +++ b/server/src/nest/flight-live/flight-live.callsign.ts @@ -0,0 +1,79 @@ +import { logWarn } from '../audit/audit-log.logger'; + +import fs from 'node:fs'; +import path from 'node:path'; + +/** + * From the flight number a traveller booked to the callsign the aircraft + * transmits: "LH 716", "LH0716" and "lh716" all fly as DLH716. + * + * The IATA to ICAO table is server/assets/airlines-icao.json, built by + * scripts/build-airlines.mjs from the VRS standing data (CC0); where IATA hands + * one code to several airlines the one with the most recorded routes wins (the + * script says why). Read once and kept, like the airports. + */ + +let table: ReadonlyMap | null = null; + +/** The IATA airline code to ICAO callsign prefix table, empty when the asset is missing. */ +export function airlineTable(): ReadonlyMap { + if (table) return table; + const file = path.join(__dirname, '..', '..', '..', 'assets', 'airlines-icao.json'); + if (!fs.existsSync(file)) { + logWarn('[flight-live] airlines-icao.json missing: run `node scripts/build-airlines.mjs`'); + table = new Map(); + return table; + } + const raw = JSON.parse(fs.readFileSync(file, 'utf8')) as Record; + table = new Map(Object.entries(raw).filter((entry): entry is [string, string] => typeof entry[1] === 'string')); + return table; +} + +const ICAO_FORM = /^([A-Z]{3})(\d{1,4}[A-Z]?)$/; +/** Two characters with at least one letter, then the number: LH716, U21234, 4U9520. */ +const IATA_FORM = /^([A-Z][A-Z0-9]|[0-9][A-Z])(\d{1,4}[A-Z]?)$/; +const NUMBER_ONLY = /^(\d{1,4}[A-Z]?)$/; + +/** "0716" -> "716", "0007A" -> "7A", "0" stays "0". */ +function trimNumber(number: string): string { + return number.replace(/^0+(?=\d)/, ''); +} + +/** The ICAO prefix an airline field names, when it is a code at all ("LH", "DLH"); a name is not looked up. */ +function prefixOfAirline(airline: string, airlines: ReadonlyMap): string | null { + const code = airline.trim().toUpperCase(); + if (/^[A-Z]{3}$/.test(code)) return code; + if (/^(?:[A-Z][A-Z0-9]|[0-9][A-Z])$/.test(code)) return airlines.get(code) ?? null; + return null; +} + +/** + * The ICAO callsign of a booked flight, or null when it cannot be told. + * + * A number already in ICAO form (three letters and digits) is taken as it is. + * A number without an airline code borrows one from `airline` when that holds + * a code. Spaces and dashes are dropped and leading zeros of the number go, + * as the transponder sends them. + */ +export function toCallsign( + flightNumber: string | null | undefined, + airline?: string | null, + airlines: ReadonlyMap = airlineTable(), +): string | null { + if (typeof flightNumber !== 'string') return null; + const compact = flightNumber.toUpperCase().replace(/[\s-]/g, ''); + if (!compact) return null; + const icao = ICAO_FORM.exec(compact); + if (icao) return icao[1] + trimNumber(icao[2]); + const iata = IATA_FORM.exec(compact); + if (iata) { + const prefix = airlines.get(iata[1]); + return prefix ? prefix + trimNumber(iata[2]) : null; + } + const bare = NUMBER_ONLY.exec(compact); + if (bare && typeof airline === 'string') { + const prefix = prefixOfAirline(airline, airlines); + return prefix ? prefix + trimNumber(bare[1]) : null; + } + return null; +} diff --git a/server/src/nest/flight-live/flight-live.controller.ts b/server/src/nest/flight-live/flight-live.controller.ts new file mode 100644 index 000000000..faf28e4f3 --- /dev/null +++ b/server/src/nest/flight-live/flight-live.controller.ts @@ -0,0 +1,30 @@ +import type { TripAccess } from '../../db/repositories/Trips.repository'; +import { JwtAuthGuard } from '../auth-core/jwt-auth.guard'; +import { ResponseContract } from '../common/response-contract'; +import { TripAccessGuard } from '../permissions/trip-access.guard'; +import { Trip } from '../permissions/trip.decorator'; +import { FlightLiveService } from './flight-live.service'; +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { tripFlightLiveResponseSchema, type TripFlightLiveResponse } from '@trek/shared'; + +/** + * GET /api/trips/:tripId/flights/live: every flight leg of the trip with its + * schedule in UTC and what the open ADS-B feeds last said about it. + * + * Trip-scoped like reading the bookings: TripAccessGuard answers 404 for a trip + * the caller cannot reach, and reading needs no permission beyond access. The + * read never asks a feed; an in-window leg whose answer is older than two + * minutes is put up for the poll's next tick. With the admin switch off it + * answers `{ enabled: false, attribution: null, flights: [] }`. + */ +@Controller('api/trips/:tripId/flights') +@UseGuards(JwtAuthGuard, TripAccessGuard) +export class FlightLiveController { + constructor(private readonly live: FlightLiveService) {} + + @Get('live') + @ResponseContract(tripFlightLiveResponseSchema) + async list(@Trip() trip: TripAccess): Promise { + return await this.live.tripFlights(trip.id); + } +} diff --git a/server/src/nest/flight-live/flight-live.derive.ts b/server/src/nest/flight-live/flight-live.derive.ts new file mode 100644 index 000000000..7dedc1cc6 --- /dev/null +++ b/server/src/nest/flight-live/flight-live.derive.ts @@ -0,0 +1,389 @@ +import { haversineKm } from '../common/geo'; +import type { ScheduledLeg } from './flight-live.schedule'; +import type { FlightLiveLeg, FlightLivePhase, FlightLivePosition, FlightLiveSource } from '@trek/shared'; + +/** + * What the positions of an aircraft say about a booked flight, worked out + * without the airline: the phase, wheels-up and touchdown, the delays and the + * arrival estimate, and when to ask the feed again. Pure, every clock passed + * in as epoch milliseconds. + */ + +const MIN = 60 * 1000; +const HOUR = 60 * MIN; +const KT_TO_KMH = 1.852; + +/** The watch window around a leg: from three hours before departure to two after arrival. */ +const WATCH_BEFORE_MS = 3 * HOUR; +const WATCH_AFTER_MS = 2 * HOUR; +/** Without a scheduled arrival the window closes this long after departure. */ +const ASSUMED_FLIGHT_MS = 16 * HOUR; +/** A callsign not heard this long after the scheduled departure is reported unknown. */ +const UNKNOWN_AFTER_MS = 2 * HOUR; +/** An airborne aircraft not heard for longer is shown as stale. */ +const STALE_AFTER_MS = 10 * MIN; +/** Approach, landing and taxi on top of the great-circle flight time. */ +const APPROACH_BUFFER_MS = 8 * MIN; + +/** One aircraft as the feed reported it, already validated. */ +export interface FeedAircraft { + hex: string; + flight: string | null; + registration: string | null; + type: string | null; + /** Feet; 'ground' when the transponder says it is on the ground. */ + alt_baro: number | 'ground' | null; + alt_geom: number | null; + gs: number | null; + track: number | null; + baro_rate: number | null; + geom_rate: number | null; + lat: number | null; + lon: number | null; + /** Seconds since the feed last heard the aircraft, and since its last position. */ + seen: number | null; + seen_pos: number | null; +} + +/** The state of one flight (callsign, service date) between polls; times are epoch ms. */ +export interface LiveState { + callsign: string; + service_date: string; + /** IATA codes of the sector: one flight number can fly two sectors on one day. */ + origin: string; + destination: string; + phase: FlightLivePhase; + departed_at: number | null; + landed_at: number | null; + position: FlightLivePosition | null; + registration: string | null; + aircraft_type: string | null; + source: FlightLiveSource | null; + first_seen_at: number | null; + last_seen_at: number | null; + fetched_at: number | null; + next_poll_at: number | null; + fail_count: number; + departed_notified: boolean; + landed_notified: boolean; + delay_notified: boolean; +} + +export function emptyState(callsign: string, service_date: string, origin: string, destination: string): LiveState { + return { + callsign, + service_date, + origin, + destination, + phase: 'scheduled', + departed_at: null, + landed_at: null, + position: null, + registration: null, + aircraft_type: null, + source: null, + first_seen_at: null, + last_seen_at: null, + fetched_at: null, + next_poll_at: null, + fail_count: 0, + departed_notified: false, + landed_notified: false, + delay_notified: false, + }; +} + +type Route = Pick; +type Schedule = Pick; +type WatchWindow = { start: number; end: number }; + +function kmTo(lat: number, lng: number, airport: { lat: number; lng: number }): number { + return haversineKm(lat, lng, airport.lat, airport.lng); +} + +function finite(value: number | null | undefined): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +/** The leg's watch window, or null without a scheduled departure. */ +export function watchWindow(leg: Schedule): WatchWindow | null { + if (leg.scheduled_departure === null) return null; + const arrival = leg.scheduled_arrival ?? leg.scheduled_departure + ASSUMED_FLIGHT_MS; + return { start: leg.scheduled_departure - WATCH_BEFORE_MS, end: arrival + WATCH_AFTER_MS }; +} + +export function inWatchWindow(leg: Schedule, now: number): boolean { + const span = watchWindow(leg); + return span !== null && now >= span.start && now <= span.end; +} + +/** + * The aircraft that flies this leg among those the feed answered for the + * callsign: the one with a position nearest the great circle between the two + * airports (least detour over the direct distance). A callsign is reused by + * the next leg of a rotation, so two can be in the air at once. + */ +export function pickAircraft(aircraft: readonly FeedAircraft[], route: Route): FeedAircraft | null { + const total = kmTo(route.origin.lat, route.origin.lng, route.destination); + let best: FeedAircraft | null = null; + let bestScore = Number.POSITIVE_INFINITY; + for (const ac of aircraft) { + const lat = finite(ac.lat); + const lng = finite(ac.lon); + const score = + lat === null || lng === null + ? Number.MAX_VALUE + : kmTo(lat, lng, route.origin) + kmTo(lat, lng, route.destination) - total; + if (best === null || score < bestScore) { + best = ac; + bestScore = score; + } + } + return best; +} + +/** The aircraft's report as a position at its own time; null without coordinates. */ +export function observe( + ac: FeedAircraft, + now: number, +): { position: FlightLivePosition; positionAt: number; heardAt: number } | null { + const lat = finite(ac.lat); + const lng = finite(ac.lon); + if (lat === null || lng === null) return null; + const onGround = ac.alt_baro === 'ground'; + const baro = typeof ac.alt_baro === 'number' ? finite(ac.alt_baro) : null; + const altitude = onGround ? 0 : (baro ?? finite(ac.alt_geom)); + return { + position: { + lat, + lng, + altitude_ft: altitude, + ground_speed_kt: finite(ac.gs), + track_deg: finite(ac.track), + vertical_rate_fpm: finite(ac.baro_rate) ?? finite(ac.geom_rate), + on_ground: onGround, + }, + positionAt: now - Math.max(0, finite(ac.seen_pos) ?? finite(ac.seen) ?? 0) * 1000, + heardAt: now - Math.max(0, finite(ac.seen) ?? 0) * 1000, + }; +} + +/** + * The next state of a flight after one answer from the feed. `aircraft` is the + * picked one, or null when the feed knew nothing of the callsign. + * + * - On the ground within 30 km of the destination after having flown, or + * after the scheduled departure: landed. + * - On the ground at the origin: taxiing. + * - In the air: airborne; wheels-up is the first airborne report while still + * below 10,000 ft within 60 km of the origin, else it stays unknown. + * - Lost below 5,000 ft within 40 km of the destination and not heard for ten + * minutes: landed at its last report. + * - Never heard two hours after the scheduled departure: unknown. + */ +export function advance( + prev: LiveState, + aircraft: FeedAircraft | null, + route: Route, + now: number, + source: FlightLiveSource, +): LiveState { + const next: LiveState = { ...prev, fetched_at: now, source, fail_count: 0 }; + if (prev.phase === 'landed') return next; + const seen = aircraft ? observe(aircraft, now) : null; + + if (seen && aircraft) { + const { position, positionAt, heardAt } = seen; + const fromOrigin = kmTo(position.lat, position.lng, route.origin); + const toDestination = kmTo(position.lat, position.lng, route.destination); + const hasFlown = prev.phase === 'airborne' || prev.departed_at !== null; + next.position = position; + next.registration = aircraft.registration ?? prev.registration; + next.aircraft_type = aircraft.type ?? prev.aircraft_type; + next.first_seen_at = prev.first_seen_at ?? heardAt; + next.last_seen_at = heardAt; + if (position.on_ground) { + // Down at the destination after the scheduled departure counts even unseen in the air (no coverage at + // the origin); down anywhere else after flying is a diversion. + const departureDue = route.scheduled_departure !== null && now >= route.scheduled_departure; + const atDestination = toDestination <= 30 && (hasFlown || departureDue); + if (atDestination || (hasFlown && fromOrigin > 30)) { + next.phase = 'landed'; + next.landed_at = prev.landed_at ?? positionAt; + } else { + next.phase = 'taxiing'; + } + } else { + if (prev.phase !== 'airborne' && prev.departed_at === null) { + const low = position.altitude_ft !== null && position.altitude_ft < 10000; + next.departed_at = low && fromOrigin <= 60 ? positionAt : null; + } + next.phase = 'airborne'; + } + return next; + } + + if (prev.phase === 'airborne' && prev.position && prev.last_seen_at !== null) { + const { lat, lng, altitude_ft } = prev.position; + const lostLow = altitude_ft !== null && altitude_ft < 5000 && kmTo(lat, lng, route.destination) <= 40; + if (lostLow && now - prev.last_seen_at >= STALE_AFTER_MS) { + next.phase = 'landed'; + next.landed_at = prev.last_seen_at; + return next; + } + } + if ( + prev.first_seen_at === null && + route.scheduled_departure !== null && + now >= route.scheduled_departure + UNKNOWN_AFTER_MS + ) { + next.phase = 'unknown'; + } + return next; +} + +/** Whether the leg's watch window is over. */ +function windowOver(leg: Schedule, now: number): boolean { + const span = watchWindow(leg); + return span !== null && now > span.end; +} + +/** + * A flight still taxiing or in the air when its window closes went quiet + * without a landing anyone saw (out of coverage, switched off): it is closed + * as unknown, never left in the air, and asked about no more. + */ +export function closeIfOver(state: LiveState, route: Schedule, now: number): LiveState { + if (!windowOver(route, now) || (state.phase !== 'airborne' && state.phase !== 'taxiing')) return state; + return { ...state, phase: 'unknown', next_poll_at: null }; +} + +/** A failed ask: the state stays, the failure is counted. */ +export function failed(prev: LiveState): LiveState { + return { ...prev, fail_count: prev.fail_count + 1 }; +} + +/** + * When to ask the feed next about this flight, or null to stop: landed, or + * past its window. + * + * - unknown: every 15 minutes until the window closes; + * - airborne: every 3 minutes, every 6 when more than two hours of flying + * from both airports at its ground speed; + * - more than 40 minutes before departure: every 10 minutes; + * - around departure until airborne: every 2 minutes; + * - three failures in a row: 15 minutes. + */ +export function nextPollAt(state: LiveState, route: Route, now: number): number | null { + if (state.phase === 'landed') return null; + const span = watchWindow(route); + if (!span || now > span.end) return null; + if (state.fail_count >= 3 || state.phase === 'unknown') return now + 15 * MIN; + if (state.phase === 'airborne') { + const at = state.position; + const gs = at?.ground_speed_kt ?? null; + if (at && gs !== null && gs > 0) { + const kmh = gs * KT_TO_KMH; + const hoursFrom = (airport: { lat: number; lng: number }) => kmTo(at.lat, at.lng, airport) / kmh; + if (hoursFrom(route.origin) > 2 && hoursFrom(route.destination) > 2) return now + 6 * MIN; + } + return now + 3 * MIN; + } + if (route.scheduled_departure !== null && now < route.scheduled_departure - 40 * MIN) { + return Math.min(now + 10 * MIN, Math.max(now + 2 * MIN, route.scheduled_departure - 40 * MIN)); + } + return now + 2 * MIN; +} + +function iso(ms: number | null): string | null { + return ms === null ? null : new Date(ms).toISOString(); +} + +function minutes(ms: number): number { + return Math.round(ms / MIN); +} + +/** + * The leg as the client reads it: the schedule, and what the stored state + * says on top of it. Without a state the schedule alone answers: scheduled + * (the client tells a past one from its times), unknown late in the window. + * After the window nothing is in the air or taxiing any more: such a state + * reads as unknown. + */ +export function toLiveLeg(leg: ScheduledLeg, state: LiveState | null, now: number): FlightLiveLeg { + const total = kmTo(leg.origin.lat, leg.origin.lng, leg.destination); + const span = watchWindow(leg); + let phase: FlightLivePhase = state?.phase ?? 'scheduled'; + if (!state && leg.scheduled_departure !== null && span && now <= span.end) { + if (now >= leg.scheduled_departure + UNKNOWN_AFTER_MS) phase = 'unknown'; + } + if ((phase === 'airborne' || phase === 'taxiing') && windowOver(leg, now)) phase = 'unknown'; + + const position = state?.position ?? null; + const remaining = position + ? kmTo(position.lat, position.lng, leg.destination) + : phase === 'landed' + ? 0 + : phase === 'scheduled' || phase === 'taxiing' + ? total + : null; + const progress = remaining === null || total <= 0 ? null : Math.min(1, Math.max(0, 1 - remaining / total)); + + const gs = position?.ground_speed_kt ?? null; + const estimate = + phase === 'airborne' && position && remaining !== null && gs !== null && gs > 150 + ? (state?.last_seen_at ?? now) + (remaining / (gs * KT_TO_KMH)) * HOUR + APPROACH_BUFFER_MS + : null; + const departedAt = state?.departed_at ?? null; + const landedAt = state?.landed_at ?? null; + const arrivalBasis = landedAt ?? estimate; + const lastSeen = state?.last_seen_at ?? null; + + return { + reservation_id: leg.reservation_id, + leg_index: leg.leg_index, + callsign: leg.callsign, + flight_number: leg.flight_number, + service_date: leg.service_date, + origin: leg.origin, + destination: leg.destination, + phase, + scheduled_departure: iso(leg.scheduled_departure), + scheduled_arrival: iso(leg.scheduled_arrival), + departed_at: iso(departedAt), + landed_at: iso(landedAt), + estimated_arrival: iso(estimate === null ? null : Math.round(estimate)), + departure_delay_min: + departedAt !== null && leg.scheduled_departure !== null ? minutes(departedAt - leg.scheduled_departure) : null, + arrival_delay_min: + arrivalBasis !== null && leg.scheduled_arrival !== null ? minutes(arrivalBasis - leg.scheduled_arrival) : null, + progress, + distance_total_km: Math.round(total), + distance_remaining_km: remaining === null ? null : Math.round(remaining), + position, + aircraft: + state && (state.registration || state.aircraft_type) + ? { registration: state.registration, type: state.aircraft_type } + : null, + last_seen_at: iso(lastSeen), + fetched_at: iso(state?.fetched_at ?? null), + stale: phase === 'airborne' && lastSeen !== null && now - lastSeen > STALE_AFTER_MS, + source: state?.source ?? null, + }; +} + +/** + * How late the leg is running at departure, in minutes, when there is + * evidence of it; else null. Wheels-up says it outright. An aircraft still + * heard on the ground after the scheduled time is late by at least the time it + * was last heard, but only while that report is recent: one gone quiet at the + * gate says nothing about now. + */ +export function departureDelay(leg: ScheduledLeg, state: LiveState, now: number): number | null { + const scheduled = leg.scheduled_departure; + if (scheduled === null) return null; + if (state.departed_at !== null) return minutes(state.departed_at - scheduled); + const heard = state.last_seen_at; + if (state.phase !== 'taxiing' || heard === null || heard <= scheduled) return null; + return now - heard < STALE_AFTER_MS ? minutes(heard - scheduled) : null; +} diff --git a/server/src/nest/flight-live/flight-live.feeds.ts b/server/src/nest/flight-live/flight-live.feeds.ts new file mode 100644 index 000000000..87ea5982b --- /dev/null +++ b/server/src/nest/flight-live/flight-live.feeds.ts @@ -0,0 +1,186 @@ +import { readCappedJson } from '../../utils/cappedFetch'; +import { RuntimeEnvService } from '../app-config/runtime-env.service'; +import { logWarn } from '../audit/audit-log.logger'; +import { FeedBudget } from './flight-live.budget'; +import type { FeedAircraft } from './flight-live.derive'; +import { Injectable } from '@nestjs/common'; +import type { FlightLiveSource } from '@trek/shared'; + +import { z } from 'zod'; + +/** + * The two keyless ADS-B feeds TREK reads live flights from. adsb.lol first; + * adsb.fi only when this is not a managed install, because adsb.fi's terms + * rule out commercial use. Only the callsign goes out, never a user, a trip + * or a route. Both answer the readsb "v2" shape. + */ + +const FEED_TIMEOUT_MS = 8000; +/** A callsign's answer is one or two aircraft, a few hundred bytes each. */ +const MAX_FEED_BYTES = 256 * 1024; +/** Failures in a row before a source is paused like a 429. */ +const FAILURES_BEFORE_PAUSE = 3; + +export const FEED_ATTRIBUTION: Record = { + 'adsb.lol': 'Live positions: adsb.lol (ODbL)', + 'adsb.fi': 'Live positions: adsb.fi', +}; + +const FEED_URL: Record string> = { + 'adsb.lol': (callsign) => `https://api.adsb.lol/v2/callsign/${callsign}`, + 'adsb.fi': (callsign) => `https://opendata.adsb.fi/api/v2/callsign/${callsign}`, +}; + +const num = z.number().nullish(); +const str = z.string().nullish(); + +/** The fields of one aircraft TREK reads; everything else in the answer is dropped. */ +const aircraftSchema = z.object({ + hex: z.string(), + flight: str, + r: str, + t: str, + alt_baro: z.union([z.number(), z.literal('ground')]).nullish(), + alt_geom: num, + gs: num, + track: num, + baro_rate: num, + geom_rate: num, + lat: num, + lon: num, + seen: num, + seen_pos: num, +}); + +const feedAnswerSchema = z.object({ + ac: z.array(aircraftSchema).max(50).nullish(), + now: z.number(), +}); + +const CALLSIGN_RE = /^[A-Z0-9]{3,8}$/; + +export type FeedLookup = + | { ok: true; source: FlightLiveSource; now: number; aircraft: FeedAircraft[] } + | { ok: false; reason: 'budget' | 'unavailable' }; + +type SourceAnswer = + { ok: true; now: number; aircraft: FeedAircraft[] } | { ok: false; status: number | null; reason: string }; + +function toAircraft(ac: z.infer): FeedAircraft { + return { + hex: ac.hex, + flight: ac.flight?.trim() || null, + registration: ac.r?.trim() || null, + type: ac.t?.trim() || null, + alt_baro: ac.alt_baro ?? null, + alt_geom: ac.alt_geom ?? null, + gs: ac.gs ?? null, + track: ac.track ?? null, + baro_rate: ac.baro_rate ?? null, + geom_rate: ac.geom_rate ?? null, + lat: ac.lat ?? null, + lon: ac.lon ?? null, + seen: ac.seen ?? null, + seen_pos: ac.seen_pos ?? null, + }; +} + +@Injectable() +export class FlightLiveFeedService { + private readonly budget = new FeedBudget(); + private readonly failures = new Map(); + /** Waits out the spacing before a fallback request; a test hands in one that does not. */ + sleep: (ms: number) => Promise = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + + constructor(private readonly env: RuntimeEnvService) {} + + /** The sources this install may ask, in order. */ + sources(): FlightLiveSource[] { + return this.env.isManaged() ? ['adsb.lol'] : ['adsb.lol', 'adsb.fi']; + } + + /** The credit line of the first source this install asks. */ + attribution(): string { + return FEED_ATTRIBUTION[this.sources()[0]]; + } + + /** Whether one more request fits in the budget right now. */ + canAsk(now: number = Date.now()): boolean { + return this.budget.waitMs(now) === 0 && !this.budget.exhausted(now); + } + + /** How long until the spacing lets the next request go. */ + waitMs(now: number = Date.now()): number { + return this.budget.waitMs(now); + } + + /** + * The aircraft flying under a callsign, from the first source that answers. + * Each request spends the budget; a source that answered 429, or failed + * three times in a row, is paused for a quarter of an hour. + */ + async lookup(callsign: string, now: () => number = Date.now): Promise { + if (!CALLSIGN_RE.test(callsign)) return { ok: false, reason: 'unavailable' }; + return await this.lookupFrom(this.sources(), callsign, now, false); + } + + /** The first of the sources that answers, one after the other; `asked` says whether one was asked already. */ + private async lookupFrom( + sources: readonly FlightLiveSource[], + callsign: string, + now: () => number, + asked: boolean, + ): Promise { + const [source, ...rest] = sources; + if (source === undefined) return { ok: false, reason: asked ? 'unavailable' : 'budget' }; + if (this.budget.isPaused(source, now())) return await this.lookupFrom(rest, callsign, now, asked); + const wait = this.budget.waitMs(now()); + if (wait > 0) await this.sleep(wait); + if (!this.budget.tryTake(now())) return { ok: false, reason: asked ? 'unavailable' : 'budget' }; + const answer = await this.ask(source, callsign); + if (answer.ok) { + this.failures.set(source, 0); + return { ok: true, source, now: answer.now, aircraft: answer.aircraft }; + } + // Spelled out: the server builds without strictNullChecks, where `ok` alone does not narrow the union. + const failure = answer as Extract; + const count = (this.failures.get(source) ?? 0) + 1; + this.failures.set(source, count); + if (failure.status === 429 || count >= FAILURES_BEFORE_PAUSE) { + this.budget.pause(source, now()); + this.failures.set(source, 0); + } + logWarn(`[flight-live] ${source} for ${callsign}: ${failure.reason}`); + return await this.lookupFrom(rest, callsign, now, true); + } + + private async ask(source: FlightLiveSource, callsign: string): Promise { + let response: Response; + try { + response = await fetch(FEED_URL[source](encodeURIComponent(callsign)), { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(FEED_TIMEOUT_MS), + }); + } catch (err) { + return { ok: false, status: null, reason: err instanceof Error ? err.message : String(err) }; + } + if (!response.ok) { + await response.body?.cancel().catch(() => undefined); + return { ok: false, status: response.status, reason: `HTTP ${response.status}` }; + } + let body: unknown; + try { + body = await readCappedJson(response, MAX_FEED_BYTES); + } catch (err) { + return { ok: false, status: null, reason: err instanceof Error ? err.message : String(err) }; + } + if (body === undefined) return { ok: false, status: null, reason: 'unreadable or oversized answer' }; + const parsed = feedAnswerSchema.safeParse(body); + if (!parsed.success) return { ok: false, status: null, reason: 'unexpected answer shape' }; + const wanted = callsign.toUpperCase(); + const aircraft = (parsed.data.ac ?? []) + .map(toAircraft) + .filter((ac) => ac.flight === null || ac.flight.toUpperCase() === wanted); + return { ok: true, now: parsed.data.now, aircraft }; + } +} diff --git a/server/src/nest/flight-live/flight-live.job.ts b/server/src/nest/flight-live/flight-live.job.ts new file mode 100644 index 000000000..9b221146e --- /dev/null +++ b/server/src/nest/flight-live/flight-live.job.ts @@ -0,0 +1,33 @@ +import { logDebug, logError } from '../audit/audit-log.logger'; +import { CronRegistrarService } from '../scheduling/cron-registrar.service'; +import { FlightLivePollService } from './flight-live-poll.service'; +import { Injectable, type OnApplicationBootstrap } from '@nestjs/common'; + +/** + * Every minute, the flights in their watch window asked of the ADS-B feeds + * by their cadence (FlightLivePollService). The registrar runs the tick in a + * request context under the job's lease and never starts it twice; the poll + * keeps a guard of its own for a tick that outlasts its minute. + */ +@Injectable() +export class FlightLiveJob implements OnApplicationBootstrap { + constructor( + private readonly registrar: CronRegistrarService, + private readonly poll: FlightLivePollService, + ) {} + + onApplicationBootstrap(): void { + if (!this.registrar.isEnabled()) return; + this.registrar.register('flight-live', '* * * * *', () => this.tick()); + } + + /** @txIndependent each flight's state is a write of its own (FlightLivePollService.poll). */ + async tick(): Promise { + try { + const result = await this.poll.poll(); + if (result.asked > 0) logDebug(`[flight-live] asked ${result.asked}, ${result.changed} changed`); + } catch (err: unknown) { + logError(`[flight-live] poll failed: ${err instanceof Error ? err.message : String(err)}`); + } + } +} diff --git a/server/src/nest/flight-live/flight-live.mcp.ts b/server/src/nest/flight-live/flight-live.mcp.ts new file mode 100644 index 000000000..5366fb5fb --- /dev/null +++ b/server/src/nest/flight-live/flight-live.mcp.ts @@ -0,0 +1,32 @@ +import { noAccess } from '../../mcp/tools/_shared'; +import { McpController, ok, Tool, TOOL_ANNOTATIONS_READONLY, type McpContext } from '../../nest-mcp'; +import { TripAccessService } from '../trip-membership/trip-access.service'; +import { FlightLiveService } from './flight-live.service'; +import { idSchema, tripFlightLiveResponseSchema } from '@trek/shared'; + +/** + * get_trip_flight_status, the twin of GET /api/trips/:tripId/flights/live: + * the same service method behind the same check (the caller must see the + * trip; reading the bookings needs nothing more). The answer is parsed + * against the REST route's response contract, so both surfaces say the same. + */ +@McpController() +export class FlightLiveMcp { + constructor( + private readonly live: FlightLiveService, + private readonly access: TripAccessService, + ) {} + + @Tool({ + name: 'get_trip_flight_status', + description: + "Get the live status of a trip's booked flights, one entry per flight leg with a known flight number and both airports: the schedule in UTC with each airport's time zone, the phase (scheduled, taxiing, airborne, landed, unknown), observed wheels-up and touchdown, an estimated arrival with the delays worked out from it, progress and distances along the great circle, the last position with altitude and speed, and the aircraft's registration and type. Positions come from free open ADS-B feeds, so gates, terminals and cancellations are not known, and every delay and arrival time is an estimate. `enabled` is false when the admin switched live flight status off; `attribution` is the credit line to show with the data.", + inputSchema: { tripId: idSchema }, + annotations: TOOL_ANNOTATIONS_READONLY, + access: { group: 'reservations', mode: 'read' }, + }) + async getTripFlightStatus({ tripId }: { tripId: number }, ctx: McpContext) { + if (!(await this.access.findAccessible(tripId, ctx.userId))) return noAccess(); + return ok(tripFlightLiveResponseSchema.parse(await this.live.tripFlights(tripId))); + } +} diff --git a/server/src/nest/flight-live/flight-live.module.ts b/server/src/nest/flight-live/flight-live.module.ts new file mode 100644 index 000000000..d25e79ce2 --- /dev/null +++ b/server/src/nest/flight-live/flight-live.module.ts @@ -0,0 +1,38 @@ +import { FlightLiveStates } from '../../db/entities/FlightLiveStates.entity'; +import { AirportsModule } from '../airports/airports.module'; +import { AppConfigModule } from '../app-config/app-config.module'; +import { NotificationsModule } from '../notifications/notifications.module'; +import { PermissionsModule } from '../permissions/permissions.module'; +import { SchedulingModule } from '../scheduling/scheduling.module'; +import { SettingsModule } from '../settings/settings.module'; +import { TripMembershipModule } from '../trip-membership/trip-membership.module'; +import { FlightLivePollService } from './flight-live-poll.service'; +import { FlightLiveController } from './flight-live.controller'; +import { FlightLiveFeedService } from './flight-live.feeds'; +import { FlightLiveJob } from './flight-live.job'; +import { FlightLiveMcp } from './flight-live.mcp'; +import { FlightLiveService } from './flight-live.service'; +import { MikroOrmModule } from '@mikro-orm/nestjs'; +import { Module } from '@nestjs/common'; + +/** + * Live flight status (flight-live): where a booked flight is, from the keyless + * ADS-B feeds. The REST read and its MCP twin, the minute poll on the + * scheduler, and the notices when a flight departs, lands or runs late. + * Registered in AppModule. + */ +@Module({ + imports: [ + MikroOrmModule.forFeature([FlightLiveStates]), + AppConfigModule, + AirportsModule, + NotificationsModule, + PermissionsModule, + SchedulingModule, + SettingsModule, + TripMembershipModule, + ], + controllers: [FlightLiveController], + providers: [FlightLiveService, FlightLivePollService, FlightLiveFeedService, FlightLiveJob, FlightLiveMcp], +}) +export class FlightLiveModule {} diff --git a/server/src/nest/flight-live/flight-live.schedule.ts b/server/src/nest/flight-live/flight-live.schedule.ts new file mode 100644 index 000000000..f69957106 --- /dev/null +++ b/server/src/nest/flight-live/flight-live.schedule.ts @@ -0,0 +1,249 @@ +import { wallClockInstant } from '../common/timezoneService'; +import { toCallsign } from './flight-live.callsign'; +import type { FlightLiveAirport } from '@trek/shared'; + +/** + * The legs of a booked flight with their schedule in UTC, out of what the + * planner stores: the leg chain in `metadata.legs` (client/src/utils/flightLegs.ts + * is the model, mirrored here rule for rule), the endpoints with their local + * date, time and zone, and the trip days a leg names. Pure: the caller reads + * the rows and hands in the airport lookup. + */ + +/** A flight booking as the repository reads it. */ +export interface FlightBookingRow { + id: number; + trip_id: number; + metadata: Record; + day_id: number | null; + end_day_id: number | null; + /** Local "YYYY-MM-DDTHH:MM" of the first departure, when the form stored one. */ + reservation_time: string | null; + reservation_end_time: string | null; +} + +export interface FlightEndpointRow { + reservation_id: number; + role: string; + sequence: number; + name: string; + code: string | null; + lat: number; + lng: number; + timezone: string | null; + local_date: string | null; + local_time: string | null; +} + +/** What the bundled airport dataset knows about a code. */ +export interface AirportFacts { + name: string; + city: string; + lat: number; + lng: number; + tz: string; +} + +type AirportLookup = (iata: string) => AirportFacts | null; + +/** One leg that can be tracked: a callsign, both airports and the local day it departs. */ +export interface ScheduledLeg { + reservation_id: number; + trip_id: number; + leg_index: number; + callsign: string; + flight_number: string; + service_date: string; + origin: FlightLiveAirport; + destination: FlightLiveAirport; + /** Epoch ms, null when the booking holds no time for that end. */ + scheduled_departure: number | null; + scheduled_arrival: number | null; +} + +interface RawLeg { + from: string | null; + to: string | null; + airline: string | null; + flight_number: string | null; + dep_day_id: number | null; + dep_time: string | null; + arr_day_id: number | null; + arr_time: string | null; +} + +const DAY_MS = 24 * 60 * 60 * 1000; + +function str(value: unknown): string | null { + return typeof value === 'string' && value.trim() !== '' ? value.trim() : null; +} + +function int(value: unknown): number | null { + return typeof value === 'number' && Number.isInteger(value) ? value : null; +} + +/** "10:30", "10:30:00" -> "10:30"; anything else is no time. */ +function hhmm(value: string | null): string | null { + return value && /^\d{2}:\d{2}/.test(value) ? value.slice(0, 5) : null; +} + +/** The date and the time of a stored "YYYY-MM-DDTHH:MM". */ +function splitLocal(iso: string | null): { date: string | null; time: string | null } { + if (!iso) return { date: null, time: null }; + const [date, time] = iso.split('T'); + return { date: /^\d{4}-\d{2}-\d{2}$/.test(date ?? '') ? date : null, time: hhmm(time ?? null) }; +} + +/** "Paris Charles de Gaulle (CDG)" -> "Paris Charles de Gaulle", as the client strips it. */ +function stripCode(name: string): string { + const trimmed = name.trimEnd(); + return /\([A-Z]{3}\)$/.test(trimmed) ? trimmed.slice(0, -5).trimEnd() : name; +} + +function ordered(endpoints: readonly FlightEndpointRow[]): FlightEndpointRow[] { + return endpoints.slice().sort((a, b) => a.sequence - b.sequence); +} + +/** The legs as the client's getFlightLegs reads them: `metadata.legs`, else one leg from the endpoints. */ +function rawLegs(row: FlightBookingRow, endpoints: readonly FlightEndpointRow[]): RawLeg[] { + const meta = row.metadata; + if (Array.isArray(meta.legs) && meta.legs.length > 0) { + return meta.legs.map((value): RawLeg => { + const leg = value && typeof value === 'object' ? (value as Record) : {}; + return { + from: str(leg.from), + to: str(leg.to), + airline: str(leg.airline), + flight_number: str(leg.flight_number), + dep_day_id: int(leg.dep_day_id), + dep_time: hhmm(str(leg.dep_time)), + arr_day_id: int(leg.arr_day_id), + arr_time: hhmm(str(leg.arr_time)), + }; + }); + } + const first = endpoints[0]; + const last = endpoints[endpoints.length - 1]; + const from = first?.code ?? str(meta.departure_airport); + const to = last?.code ?? str(meta.arrival_airport); + if (!from && !to) return []; + return [ + { + from, + to, + airline: str(meta.airline), + flight_number: str(meta.flight_number), + dep_day_id: row.day_id, + dep_time: hhmm(first?.local_time ?? null), + arr_day_id: row.end_day_id ?? row.day_id, + arr_time: hhmm(last?.local_time ?? null), + }, + ]; +} + +/** An airport of the leg from its endpoint, else from the dataset; null when its place is unknown. */ +function airportOf( + code: string | null, + endpoint: FlightEndpointRow | undefined, + lookup: AirportLookup, +): FlightLiveAirport | null { + const iata = code ? code.slice(0, 3).toUpperCase() : null; + if (!iata || !/^[A-Z0-9]{3}$/.test(iata)) return null; + const known = lookup(iata); + const usable = endpoint && Number.isFinite(endpoint.lat) && Number.isFinite(endpoint.lng) ? endpoint : undefined; + if (!usable && !known) return null; + return { + code: iata, + name: usable ? stripCode(usable.name) : (known?.name ?? null), + lat: usable ? usable.lat : (known as AirportFacts).lat, + lng: usable ? usable.lng : (known as AirportFacts).lng, + timezone: usable?.timezone ?? known?.tz ?? null, + }; +} + +/** The endpoint at the leg's end of the chain when it names that airport, else the first one that does. */ +function endpointFor( + endpoints: readonly FlightEndpointRow[], + index: number, + code: string | null, +): FlightEndpointRow | undefined { + if (!code) return undefined; + const iata = code.slice(0, 3).toUpperCase(); + const atIndex = endpoints[index]; + if (atIndex && atIndex.code?.toUpperCase() === iata) return atIndex; + return endpoints.find((e) => e.code?.toUpperCase() === iata); +} + +/** The instant a local date and time read in a zone; null when any of the three is missing. */ +export function localToUtc(date: string | null, time: string | null, zone: string | null): number | null { + if (!date || !time || !zone) return null; + return wallClockInstant(date, time, zone); +} + +/** + * Every leg of one flight booking that can be tracked: it has a flight number + * that maps to a callsign, both airports are known and its departure day is + * known. The schedule is null where the booking holds no time; an arrival + * without its own date falls on the departure day, or the next one when that + * would put it before the departure. + */ +export function legsOfBooking( + row: FlightBookingRow, + endpointRows: readonly FlightEndpointRow[], + dayDates: ReadonlyMap, + lookup: AirportLookup, + airlines?: ReadonlyMap, +): ScheduledLeg[] { + const endpoints = ordered(endpointRows); + const legs = rawLegs(row, endpoints); + const firstEnd = endpoints[0]; + const lastEnd = endpoints[endpoints.length - 1]; + const start = splitLocal(row.reservation_time); + const end = splitLocal(row.reservation_end_time); + const single = !(Array.isArray(row.metadata.legs) && row.metadata.legs.length > 0); + const out: ScheduledLeg[] = []; + + legs.forEach((leg, index) => { + const callsign = toCallsign(leg.flight_number, leg.airline, airlines); + if (!callsign || !leg.flight_number) return; + const fromEnd = endpointFor(endpoints, index, leg.from); + const toEnd = endpointFor(endpoints, index + 1, leg.to); + const origin = airportOf(leg.from, fromEnd, lookup); + const destination = airportOf(leg.to, toEnd, lookup); + if (!origin || !destination) return; + + const isFirst = index === 0; + const isLast = index === legs.length - 1; + const depDayDate = leg.dep_day_id !== null ? (dayDates.get(leg.dep_day_id) ?? null) : null; + const arrDayDate = leg.arr_day_id !== null ? (dayDates.get(leg.arr_day_id) ?? null) : null; + const firstDate = isFirst ? (firstEnd?.local_date ?? start.date) : null; + const lastDate = isLast ? (lastEnd?.local_date ?? end.date) : null; + // A single leg's endpoints carry its own local dates; a chain's legs name their days. + const depDate = (single && isFirst ? firstEnd?.local_date : null) ?? depDayDate ?? firstDate; + const depTime = leg.dep_time ?? (isFirst ? start.time : null); + if (!depDate) return; + const arrDateGiven = (single && isLast ? lastEnd?.local_date : null) ?? arrDayDate ?? lastDate; + const arrTime = leg.arr_time ?? (isLast ? end.time : null); + + const departure = localToUtc(depDate, depTime, origin.timezone); + let arrival = localToUtc(arrDateGiven ?? depDate, arrTime, destination.timezone); + if (arrival !== null && departure !== null && arrival <= departure) { + arrival = arrDateGiven ? null : arrival + DAY_MS; + if (arrival !== null && arrival <= departure) arrival = null; + } + + out.push({ + reservation_id: row.id, + trip_id: row.trip_id, + leg_index: index, + callsign, + flight_number: leg.flight_number, + service_date: depDate, + origin, + destination, + scheduled_departure: departure, + scheduled_arrival: arrival, + }); + }); + return out; +} diff --git a/server/src/nest/flight-live/flight-live.service.ts b/server/src/nest/flight-live/flight-live.service.ts new file mode 100644 index 000000000..26d8f75eb --- /dev/null +++ b/server/src/nest/flight-live/flight-live.service.ts @@ -0,0 +1,206 @@ +import { FlightLiveStates } from '../../db/entities/FlightLiveStates.entity'; +import { RESERVATION_METADATA } from '../../db/json-columns'; +import type { + FlightBookingDbRow, + FlightLiveStateRow, + FlightLiveStatesRepository, +} from '../../db/repositories/FlightLiveStates.repository'; +import { parseDbTimestamp } from '../../db/types'; +import { decodeJson } from '../../utils/json-column'; +import { AirportsService } from '../airports/airports.service'; +import { SettingsService } from '../settings/settings.service'; +import { emptyState, inWatchWindow, toLiveLeg, type LiveState } from './flight-live.derive'; +import { FEED_ATTRIBUTION, FlightLiveFeedService } from './flight-live.feeds'; +import { legsOfBooking, type FlightEndpointRow, type ScheduledLeg } from './flight-live.schedule'; +import { InjectRepository } from '@mikro-orm/nestjs'; +import { Injectable } from '@nestjs/common'; +import { + flightLivePhaseSchema, + flightLiveSourceSchema, + type FlightLiveLeg, + type FlightLivePosition, + type TripFlightLiveResponse, +} from '@trek/shared'; + +/** A stored answer older than this is refreshed when a client asks for an in-window leg. */ +const REFRESH_AFTER_MS = 2 * 60 * 1000; +/** A callsign is put up for a refresh at most once a minute, however many clients ask. */ +const REFRESH_SPACING_MS = 60 * 1000; + +/** The key a flight state is stored under: one per callsign, local departure day and sector. */ +export function stateKey(callsign: string, serviceDate: string, origin: string, destination: string): string { + return `${callsign}|${serviceDate}|${origin}|${destination}`; +} + +/** The key of the flight a leg flies. */ +export function legKey(leg: Pick): string { + return stateKey(leg.callsign, leg.service_date, leg.origin.code, leg.destination.code); +} + +/** States the poll will not ask about soon: a client asking again must not get around that. */ +function backingOff(state: LiveState | null): boolean { + return state !== null && (state.phase === 'unknown' || state.fail_count >= 3); +} + +function ms(ts: string | null): number | null { + return parseDbTimestamp(ts)?.getTime() ?? null; +} + +/** A stored row as the derivation reads it; a value the column should not hold falls back. */ +export function rowToState(row: FlightLiveStateRow): LiveState { + const phase = flightLivePhaseSchema.safeParse(row.phase); + const source = flightLiveSourceSchema.safeParse(row.source); + const position: FlightLivePosition | null = + row.lat !== null && row.lng !== null + ? { + lat: row.lat, + lng: row.lng, + altitude_ft: row.altitude_ft, + ground_speed_kt: row.ground_speed_kt, + track_deg: row.track_deg, + vertical_rate_fpm: row.vertical_rate_fpm, + on_ground: row.on_ground === 1, + } + : null; + return { + ...emptyState(row.callsign, row.service_date, row.origin, row.destination), + phase: phase.success ? phase.data : 'scheduled', + departed_at: ms(row.departed_at), + landed_at: ms(row.landed_at), + position, + registration: row.registration, + aircraft_type: row.aircraft_type, + source: source.success ? source.data : null, + first_seen_at: ms(row.first_seen_at), + last_seen_at: ms(row.last_seen_at), + fetched_at: ms(row.fetched_at), + next_poll_at: ms(row.next_poll_at), + fail_count: row.fail_count, + departed_notified: row.departed_notified === 1, + landed_notified: row.landed_notified === 1, + delay_notified: row.delay_notified === 1, + }; +} + +/** A flight leg with the trip it belongs to, for the poll's notices. */ +export interface TripLeg extends ScheduledLeg { + trip_title: string; +} + +/** + * Live flight status for the trips' flight bookings: the read the planner and + * the MCP tool share, and the leg loading the poll job builds on. + * + * The positions come from the open ADS-B feeds (FlightLiveFeedService), asked + * only by the poll job (FlightLivePollService) within a leg's watch window and + * kept in `flight_live_states`; a client read never asks a feed itself. A read + * that finds an in-window leg's answer older than two minutes puts the + * callsign up for the job's next tick instead. + */ +@Injectable() +export class FlightLiveService { + /** When each flight was last put up for a refresh, for the once-a-minute throttle. */ + private readonly lastRequested = new Map(); + /** The flights waiting for the poll's next tick. */ + private readonly pending = new Set(); + + constructor( + @InjectRepository(FlightLiveStates) private readonly states: FlightLiveStatesRepository, + private readonly airports: AirportsService, + private readonly settings: SettingsService, + private readonly feeds: FlightLiveFeedService, + ) {} + + /** The admin switch, `flight_live_enabled`, with FLIGHT_LIVE_ENABLED over it; anything but 'false' is on. */ + async isEnabled(): Promise { + return (await this.settings.getResolvedAppSetting('flight_live_enabled')) !== 'false'; + } + + /** + * Every flight leg of the trip that has a callsign and two known airports, + * at any date, with its schedule and what the feeds last said about it. + * The caller has checked that the user may see the trip. + */ + async tripFlights(tripId: number, now: number = Date.now()): Promise { + if (!(await this.isEnabled())) return { enabled: false, attribution: null, flights: [] }; + const legs = await this.loadLegs(await this.states.listFlightBookingsForTrip(tripId)); + const states = await this.statesFor(legs); + const flights: FlightLiveLeg[] = legs.map((leg) => { + const state = states.get(legKey(leg)) ?? null; + if (inWatchWindow(leg, now) && state?.phase !== 'landed' && !backingOff(state)) { + const fetched = state?.fetched_at ?? null; + if (fetched === null || now - fetched > REFRESH_AFTER_MS) this.requestRefresh(legKey(leg), now); + } + return toLiveLeg(leg, state, now); + }); + return { enabled: true, attribution: this.attributionFor(flights), flights }; + } + + /** The credit line for what the answer shows: the sources its legs were read from, else the first source. */ + private attributionFor(flights: readonly FlightLiveLeg[]): string { + const sources = [...new Set(flights.flatMap((f) => (f.source ? [f.source] : [])))].sort(); + if (sources.length === 0) return this.feeds.attribution(); + return sources.map((source) => FEED_ATTRIBUTION[source]).join('; '); + } + + /** Puts a flight (its {@link legKey}) up for the poll's next tick, at most once a minute. */ + requestRefresh(key: string, now: number): void { + const last = this.lastRequested.get(key); + if (last !== undefined && now - last < REFRESH_SPACING_MS) return; + this.lastRequested.set(key, now); + this.pending.add(key); + } + + /** The flights clients asked to refresh since the last call, handed over once. */ + takeRefreshRequests(now: number): Set { + const wanted = new Set(this.pending); + this.pending.clear(); + for (const [key, at] of this.lastRequested) { + if (now - at >= REFRESH_SPACING_MS) this.lastRequested.delete(key); + } + return wanted; + } + + /** The stored states of the legs' flights, by {@link stateKey}. */ + async statesFor(legs: readonly ScheduledLeg[]): Promise> { + const rows = await this.states.findForCallsigns([...new Set(legs.map((leg) => leg.callsign))]); + const key = (row: FlightLiveStateRow) => stateKey(row.callsign, row.service_date, row.origin, row.destination); + return new Map(rows.map((row) => [key(row), rowToState(row)])); + } + + /** The trackable legs of the bookings, each with its trip's title. */ + async loadLegs(bookings: readonly FlightBookingDbRow[]): Promise { + if (bookings.length === 0) return []; + const endpoints = await this.states.listEndpoints(bookings.map((b) => b.id)); + const byBooking = new Map(); + for (const endpoint of endpoints) { + const list = byBooking.get(endpoint.reservation_id) ?? []; + list.push(endpoint); + byBooking.set(endpoint.reservation_id, list); + } + const decoded = bookings.map((row) => ({ + row, + metadata: decodeJson(RESERVATION_METADATA, row.metadata, `reservation ${row.id}`), + })); + const dayIds = new Set(); + for (const { row, metadata } of decoded) { + if (row.day_id !== null) dayIds.add(row.day_id); + if (row.end_day_id !== null) dayIds.add(row.end_day_id); + const legs = Array.isArray(metadata.legs) ? (metadata.legs as unknown[]) : []; + for (const leg of legs) { + if (!leg || typeof leg !== 'object') continue; + const { dep_day_id: dep, arr_day_id: arr } = leg as Record; + if (typeof dep === 'number') dayIds.add(dep); + if (typeof arr === 'number') dayIds.add(arr); + } + } + const dayDates = new Map((await this.states.listDayDates([...dayIds])).map((d) => [d.id, d.date])); + const lookup = (code: string) => this.airports.findByIata(code); + return decoded.flatMap(({ row, metadata }) => + legsOfBooking({ ...row, metadata }, byBooking.get(row.id) ?? [], dayDates, lookup).map((leg) => ({ + ...leg, + trip_title: row.trip_title, + })), + ); + } +} diff --git a/server/src/nest/notifications/notification-events.ts b/server/src/nest/notifications/notification-events.ts index cac2d32a5..2116eb380 100644 --- a/server/src/nest/notifications/notification-events.ts +++ b/server/src/nest/notifications/notification-events.ts @@ -30,6 +30,9 @@ export const INAPP_CHANNEL = 'inapp'; export type NotifEventType = | 'trip_invite' | 'booking_change' + | 'flight_departed' + | 'flight_delayed' + | 'flight_landed' | 'trip_reminder' | 'todo_due' | 'vacay_invite' @@ -49,6 +52,9 @@ export type NotifEventType = export const ALL_EVENT_TYPES: NotifEventType[] = [ 'trip_invite', 'booking_change', + 'flight_departed', + 'flight_delayed', + 'flight_landed', 'trip_reminder', 'todo_due', 'vacay_invite', diff --git a/server/src/nest/notifications/notification-preferences.service.ts b/server/src/nest/notifications/notification-preferences.service.ts index 7ea777d05..59074261b 100644 --- a/server/src/nest/notifications/notification-preferences.service.ts +++ b/server/src/nest/notifications/notification-preferences.service.ts @@ -5,7 +5,7 @@ import { NotificationChannelPreferences } from '../../db/entities/NotificationCh import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository'; import type { NotificationChannelPreferencesRepository } from '../../db/repositories/NotificationChannelPreferences.repository'; import { AddonsService } from '../addons/addons.service'; -import { readAppSetting } from '../common/app-settings.registry'; +import { readAppSetting, resolveAppSetting } from '../common/app-settings.registry'; import { UnitOfWork } from '../database/unit-of-work'; import { listChannels } from './channel-registry'; import { MailerService } from './mailer/mailer.service'; @@ -131,6 +131,10 @@ export class NotificationPreferencesService { return photoProviders.includes('synologyphotos'); case 'wanderer_source_hint': return this.addons.isWandererAvailable(); + case 'flight_departed': + case 'flight_delayed': + case 'flight_landed': + return (await resolveAppSetting(this.appSettings, 'flight_live_enabled')) !== 'false'; default: return true; } diff --git a/server/src/nest/notifications/notifications.service.ts b/server/src/nest/notifications/notifications.service.ts index 4edd66f3c..e4260cf7a 100644 --- a/server/src/nest/notifications/notifications.service.ts +++ b/server/src/nest/notifications/notifications.service.ts @@ -173,6 +173,28 @@ const EVENT_NOTIFICATION_CONFIG: Record = { navigateTextKey: 'notif.action.view_trip', navigateTarget: (p) => (p.tripId ? `/trips/${p.tripId}` : null), }, + // Live flight status (flight-live): the poll saw the flight leave, land or run late. + flight_departed: { + inAppType: 'navigate', + titleKey: 'notif.flight_departed.title', + textKey: 'notif.flight_departed.text', + navigateTextKey: 'notif.action.view_trip', + navigateTarget: (p) => (p.tripId ? `/trips/${p.tripId}` : null), + }, + flight_delayed: { + inAppType: 'navigate', + titleKey: 'notif.flight_delayed.title', + textKey: 'notif.flight_delayed.text', + navigateTextKey: 'notif.action.view_trip', + navigateTarget: (p) => (p.tripId ? `/trips/${p.tripId}` : null), + }, + flight_landed: { + inAppType: 'navigate', + titleKey: 'notif.flight_landed.title', + textKey: 'notif.flight_landed.text', + navigateTextKey: 'notif.action.view_trip', + navigateTarget: (p) => (p.tripId ? `/trips/${p.tripId}` : null), + }, trip_reminder: { inAppType: 'navigate', titleKey: 'notif.trip_reminder.title', diff --git a/server/src/nest/notifications/transports/ntfy.service.ts b/server/src/nest/notifications/transports/ntfy.service.ts index aa9e247a5..7eb1c41bd 100644 --- a/server/src/nest/notifications/transports/ntfy.service.ts +++ b/server/src/nest/notifications/transports/ntfy.service.ts @@ -23,6 +23,9 @@ export interface NtfyConfig { const NTFY_EVENT_META: Partial> = { trip_invite: { priority: 4, tags: ['loudspeaker'] }, booking_change: { priority: 3, tags: ['calendar'] }, + flight_departed: { priority: 3, tags: ['airplane'] }, + flight_delayed: { priority: 4, tags: ['airplane', 'hourglass'] }, + flight_landed: { priority: 3, tags: ['airplane'] }, trip_reminder: { priority: 4, tags: ['bell', 'alarm_clock'] }, vacay_invite: { priority: 4, tags: ['palm_tree'] }, todo_due: { priority: 4, tags: ['ballot_box_with_check'] }, diff --git a/server/src/nest/settings/settings.service.ts b/server/src/nest/settings/settings.service.ts index 493826e7b..213634476 100644 --- a/server/src/nest/settings/settings.service.ts +++ b/server/src/nest/settings/settings.service.ts @@ -3,7 +3,7 @@ import { AppSettings } from '../../db/entities/AppSettings.entity'; import { Settings } from '../../db/entities/Settings.entity'; import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository'; import type { SettingsRepository } from '../../db/repositories/Settings.repository'; -import { readAppSetting, type AppSettingKey } from '../common/app-settings.registry'; +import { readAppSetting, resolveAppSetting, type AppSettingKey } from '../common/app-settings.registry'; import { decrypt_api_key, maybe_encrypt_api_key } from '../common/crypto/apiKeyCrypto'; import { UnitOfWork } from '../database/unit-of-work'; import { TripMembershipService } from '../trip-membership/trip-membership.service'; @@ -401,6 +401,11 @@ export class SettingsService { return await readAppSetting(this.appSettings, key); } + /** The environment value of a registered instance setting when one is set, else its stored text, else null. */ + async getResolvedAppSetting(key: AppSettingKey): Promise { + return await resolveAppSetting(this.appSettings, key); + } + /** Writes a registered instance setting only while it has no row; the first of several processes keeps it. */ async insertAppSettingIfAbsent(key: AppSettingKey, value: string): Promise { await this.appSettings.insertIfAbsent(key, value); diff --git a/server/tests/e2e/flight-live.e2e.test.ts b/server/tests/e2e/flight-live.e2e.test.ts new file mode 100644 index 000000000..d0e758c7d --- /dev/null +++ b/server/tests/e2e/flight-live.e2e.test.ts @@ -0,0 +1,154 @@ +/** + * Live flight status e2e: GET /api/trips/:tripId/flights/live through the + * real JwtAuthGuard and TripAccessGuard over a migrated snapshot db. The + * read never asks a feed, so fetch is a spy that must stay unused; the + * response contract is checked on every 200 (NODE_ENV=test). + */ +import { AppSettings } from '../../src/db/entities/AppSettings.entity'; +import { AppConfigModule } from '../../src/nest/app-config/app-config.module'; +import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; +import { FlightLiveModule } from '../../src/nest/flight-live/flight-live.module'; +import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; +import { createSnapshotTestDb } from '../helpers/db-mock'; +import { makeReservation, makeReservationEndpoint } from '../helpers/factories/reservations'; +import { deleteRows, insertRow } from '../helpers/factories/rows'; +import { makeTrip } from '../helpers/factories/trips'; +import { makeUser } from '../helpers/factories/users'; +import { createTestMikroOrmModule } from '../helpers/test-orm'; +import { TestUnitOfWorkModule } from '../helpers/test-uow'; +import { sessionCookie } from './harness'; +import { MikroORM } from '@mikro-orm/core'; +import { Test } from '@nestjs/testing'; +import { tripFlightLiveResponseSchema } from '@trek/shared'; + +import cookieParser from 'cookie-parser'; +import type { Server } from 'http'; +import request from 'supertest'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; + +const db = createSnapshotTestDb(); + +describe('Flight live e2e (real guards + snapshot SQLite)', () => { + let server: Server; + let app: Awaited>; + let ownerId: number; + let strangerId: number; + let tripId: number; + let flightId: number; + + async function build() { + const moduleRef = await Test.createTestingModule({ + imports: [ + await TestUnitOfWorkModule.forRoot(db), + await createTestMikroOrmModule(db), + AppConfigModule, + RealtimeModule, + FlightLiveModule, + ], + }).compile(); + const nest = moduleRef.createNestApplication(); + nest.use(cookieParser()); + nest.useGlobalFilters(new TrekExceptionFilter()); + await nest.init(); + return nest; + } + + beforeAll(async () => { + app = await build(); + server = app.getHttpServer(); + const orm = app.get(MikroORM); + const { user: owner } = await makeUser(orm); + const { user: stranger } = await makeUser(orm); + ownerId = owner.id; + strangerId = stranger.id; + tripId = (await makeTrip(orm, owner.id, { title: 'Japan' })).id; + const flight = await makeReservation(orm, tripId, { + title: 'FRA to HND', + type: 'flight', + metadata: JSON.stringify({ flight_number: 'LH 716', airline: 'Lufthansa' }), + }); + flightId = flight.id; + await makeReservationEndpoint(orm, flight.id, { + role: 'from', + sequence: 0, + name: 'Frankfurt (FRA)', + code: 'FRA', + lat: 50.0333, + lng: 8.5706, + timezone: 'Europe/Berlin', + local_date: '2030-07-01', + local_time: '13:40', + }); + await makeReservationEndpoint(orm, flight.id, { + role: 'to', + sequence: 1, + name: 'Tokyo Haneda (HND)', + code: 'HND', + lat: 35.5523, + lng: 139.7798, + timezone: 'Asia/Tokyo', + local_date: '2030-07-02', + local_time: '08:15', + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + afterAll(async () => { + await app.close(); + db.close(); + }); + + const url = (id: number = tripId) => `/api/trips/${id}/flights/live`; + + it('FLE2E-001: 401 without a session cookie', async () => { + const res = await request(server).get(url()); + expect(res.status).toBe(401); + }); + + it('FLE2E-002: 404 "Trip not found" for a trip the caller cannot reach', async () => { + const res = await request(server).get(url()).set('Cookie', sessionCookie(strangerId)); + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'Trip not found' }); + }); + + it('FLE2E-003: 200 with every flight leg and its schedule in UTC, without asking a feed', async () => { + const fetcher = vi.fn(); + vi.stubGlobal('fetch', fetcher); + const res = await request(server).get(url()).set('Cookie', sessionCookie(ownerId)); + expect(res.status).toBe(200); + expect(tripFlightLiveResponseSchema.parse(res.body)).toEqual(res.body); + expect(res.body).toMatchObject({ enabled: true, attribution: 'Live positions: adsb.lol (ODbL)' }); + expect(res.body.flights).toEqual([ + expect.objectContaining({ + reservation_id: flightId, + leg_index: 0, + callsign: 'DLH716', + flight_number: 'LH 716', + service_date: '2030-07-01', + origin: { code: 'FRA', name: 'Frankfurt', lat: 50.0333, lng: 8.5706, timezone: 'Europe/Berlin' }, + destination: { code: 'HND', name: 'Tokyo Haneda', lat: 35.5523, lng: 139.7798, timezone: 'Asia/Tokyo' }, + phase: 'scheduled', + scheduled_departure: '2030-07-01T11:40:00.000Z', + scheduled_arrival: '2030-07-01T23:15:00.000Z', + position: null, + source: null, + }), + ]); + expect(fetcher).not.toHaveBeenCalled(); + }); + + it('FLE2E-004: switched off by the admin it answers enabled false and no flights', async () => { + const orm = app.get(MikroORM); + await insertRow(orm, AppSettings, { key: 'flight_live_enabled', value: 'false' }); + try { + const res = await request(server).get(url()).set('Cookie', sessionCookie(ownerId)); + expect(res.status).toBe(200); + expect(res.body).toEqual({ enabled: false, attribution: null, flights: [] }); + } finally { + await deleteRows(orm, AppSettings, { key: 'flight_live_enabled' }); + } + }); +}); diff --git a/server/tests/unit/app-config/derive.test.ts b/server/tests/unit/app-config/derive.test.ts index f5805f98f..00540ab95 100644 --- a/server/tests/unit/app-config/derive.test.ts +++ b/server/tests/unit/app-config/derive.test.ts @@ -234,6 +234,13 @@ describe('derivePlugins', () => { }); describe('deriveIntegrations', () => { + it('reads FLIGHT_LIVE_ENABLED as the setting spells it, unset or unparseable as no word at all', () => { + expect(deriveIntegrations({}).flightLiveEnabled).toBeUndefined(); + expect(deriveIntegrations({ FLIGHT_LIVE_ENABLED: 'off' }).flightLiveEnabled).toBe('false'); + expect(deriveIntegrations({ FLIGHT_LIVE_ENABLED: ' YES ' }).flightLiveEnabled).toBe('true'); + expect(deriveIntegrations({ FLIGHT_LIVE_ENABLED: 'maybe' }).flightLiveEnabled).toBeUndefined(); + }); + it('pins unsplash trim, nominatim trim + default, overpass timeout', () => { expect(deriveIntegrations({ UNSPLASH_ACCESS_KEY: ' key ' }).unsplashAccessKey).toBe('key'); expect(deriveIntegrations({}).nominatimUrl).toBe('https://nominatim.openstreetmap.org'); diff --git a/server/tests/unit/db/entities-index.test.ts b/server/tests/unit/db/entities-index.test.ts index 7536689be..32295b7b5 100644 --- a/server/tests/unit/db/entities-index.test.ts +++ b/server/tests/unit/db/entities-index.test.ts @@ -63,8 +63,8 @@ describe('ALL_ENTITIES', () => { * `entity-schema-parity.test.ts` can't see — it iterates the metadata map, * which de-duplicates by class name). */ - it('ENT-003: exactly 167 entities, every class name unique', () => { - expect(ALL_ENTITIES.length).toBe(167); + it('ENT-003: exactly 168 entities, every class name unique', () => { + expect(ALL_ENTITIES.length).toBe(168); const names = ALL_ENTITIES.map((s) => s.meta.className); expect(new Set(names).size).toBe(names.length); }); diff --git a/server/tests/unit/db/entity-class-fields.test.ts b/server/tests/unit/db/entity-class-fields.test.ts index 8f4a790ea..d7973d94b 100644 --- a/server/tests/unit/db/entity-class-fields.test.ts +++ b/server/tests/unit/db/entity-class-fields.test.ts @@ -61,7 +61,9 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; // (an attached file is only ever written whole, by the turn it was attached in). // + storage_reclaim_marks: category, key, first_seen_at, size (a mark is only ever written whole, by the reclaim pass). // + storage_release_queue: category, key, due_at (a queued release is only ever written whole, by the release queue). -const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 611; +// + flight_live_states: callsign, service_date, origin, destination (a flight's state is always found and +// written by its key). +const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 615; describe('entity class fields never carry a NaN initialiser (task-4-review-shape.md, Important 1)', () => { it('CLASSFIELD-001: new X() has no own property whose value is NaN, across all 133 entities', () => { diff --git a/server/tests/unit/db/legacy-baseline.test.ts b/server/tests/unit/db/legacy-baseline.test.ts index a0c735b84..6d396b33b 100644 --- a/server/tests/unit/db/legacy-baseline.test.ts +++ b/server/tests/unit/db/legacy-baseline.test.ts @@ -107,6 +107,7 @@ const UNNUMBERED = [ 'Migration20200101046370_a_deleted_chat_message_keeps_no_text', 'Migration20200101046380_ireland_gained_its_counties', 'Migration20200101046390_a_trip_can_keep_its_costs_as_totals_only', + 'Migration20200101046400_a_booked_flight_shows_where_it_is', ]; // The last step the positional runner ever had; planLegacyBaseline refuses a // schema_version past it. diff --git a/server/tests/unit/db/repositories/FlightLiveStates.repository.test.ts b/server/tests/unit/db/repositories/FlightLiveStates.repository.test.ts new file mode 100644 index 000000000..29bfc9600 --- /dev/null +++ b/server/tests/unit/db/repositories/FlightLiveStates.repository.test.ts @@ -0,0 +1,207 @@ +/** + * FlightLiveStatesRepository: the stored state of a flight per callsign and + * day, and the reads of flight bookings, endpoints, days and travellers the + * live flight status builds its legs from. FLREPO-001 to FLREPO-007. + */ +import { FlightLiveStates } from '../../../../src/db/entities/FlightLiveStates.entity'; +import type { + FlightLiveStatesRepository, + FlightLiveStateWrite, +} from '../../../../src/db/repositories/FlightLiveStates.repository'; +import type { DbTimestamp } from '../../../../src/db/types'; +import { createSnapshotTestDb } from '../../../helpers/db-mock'; +import { + addReservationTraveler, + makeReservation, + makeReservationEndpoint, +} from '../../../helpers/factories/reservations'; +import { findRows } from '../../../helpers/factories/rows'; +import { addTripMember, makeDay, makeTrip } from '../../../helpers/factories/trips'; +import { makeUser } from '../../../helpers/factories/users'; +import { resetTestDb } from '../../../helpers/test-db'; +import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; + +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +const testDb = createSnapshotTestDb(); +let t: TestOrm; +let repo: FlightLiveStatesRepository; + +beforeAll(async () => { + t = await createTestOrm(testDb); + repo = t.repo(FlightLiveStates); +}); +beforeEach(() => { + resetTestDb(testDb); + t.clear(); +}); +afterAll(async () => { + await t.close(); + testDb.close(); +}); + +function state(overrides: Partial = {}): FlightLiveStateWrite { + return { + callsign: 'DLH716', + service_date: '2026-07-01', + origin: 'FRA', + destination: 'HND', + phase: 'airborne', + departed_at: '2026-07-01 11:52:00' as DbTimestamp, + landed_at: null, + lat: 55.1, + lng: 60.2, + altitude_ft: 36000, + ground_speed_kt: 480, + track_deg: 51, + vertical_rate_fpm: 0, + on_ground: 0, + registration: 'D-AIXA', + aircraft_type: 'A359', + source: 'adsb.lol', + first_seen_at: '2026-07-01 11:40:00' as DbTimestamp, + last_seen_at: '2026-07-01 14:00:00' as DbTimestamp, + fetched_at: '2026-07-01 14:00:01' as DbTimestamp, + next_poll_at: '2026-07-01 14:03:01' as DbTimestamp, + fail_count: 0, + departed_notified: 1, + landed_notified: 0, + delay_notified: 0, + ...overrides, + }; +} + +describe('FlightLiveStatesRepository: states', () => { + it('FLREPO-001: writes a whole state and replaces it under the same key', async () => { + await repo.saveState(state()); + await repo.saveState(state({ phase: 'landed', landed_at: '2026-07-01 23:10:00' as DbTimestamp, lat: 35.5 })); + await repo.saveState(state({ service_date: '2026-07-02', phase: 'scheduled' })); + await repo.saveState(state({ callsign: 'BAW12' })); + + const rows = await repo.findForCallsigns(['DLH716']); + expect(rows.map((r) => [r.service_date, r.phase, r.landed_at, r.lat]).sort()).toEqual([ + ['2026-07-01', 'landed', '2026-07-01 23:10:00', 35.5], + ['2026-07-02', 'scheduled', null, 55.1], + ]); + expect(rows.find((r) => r.service_date === '2026-07-01')).toMatchObject({ + registration: 'D-AIXA', + departed_notified: 1, + next_poll_at: '2026-07-01 14:03:01', + }); + expect(await repo.findForCallsigns([])).toEqual([]); + }); + + it('FLREPO-001b: two sectors of one flight number on one day are two states', async () => { + await repo.saveState(state({ callsign: 'DLH754', origin: 'FRA', destination: 'BOM' })); + await repo.saveState(state({ callsign: 'DLH754', origin: 'BOM', destination: 'SIN', phase: 'scheduled' })); + const rows = await repo.findForCallsigns(['DLH754']); + expect(rows.map((r) => [r.origin, r.destination, r.phase]).sort()).toEqual([ + ['BOM', 'SIN', 'scheduled'], + ['FRA', 'BOM', 'airborne'], + ]); + }); + + it('FLREPO-002: drops the states of flights that departed before a day', async () => { + await repo.saveState(state({ service_date: '2026-05-30' })); + await repo.saveState(state({ service_date: '2026-06-01' })); + expect(await repo.deleteBefore('2026-06-01')).toBe(1); + expect((await findRows(t, FlightLiveStates)).map((r) => r.service_date)).toEqual(['2026-06-01']); + }); +}); + +describe('FlightLiveStatesRepository: bookings', () => { + it('FLREPO-003: the flight bookings of one trip with the trip title, nothing of other types or trips', async () => { + const { user } = await makeUser(t); + const trip = await makeTrip(t, user.id, { title: 'Japan' }); + const other = await makeTrip(t, user.id); + const flight = await makeReservation(t, trip.id, { + title: 'FRA to HND', + type: 'flight', + metadata: '{"flight_number":"LH 716"}', + reservation_time: '2026-07-01T13:40', + }); + await makeReservation(t, trip.id, { title: 'Hotel', type: 'hotel' }); + await makeReservation(t, other.id, { title: 'Elsewhere', type: 'flight' }); + + expect(await repo.listFlightBookingsForTrip(trip.id)).toEqual([ + { + id: flight.id, + trip_id: trip.id, + trip_title: 'Japan', + metadata: '{"flight_number":"LH 716"}', + day_id: null, + end_day_id: null, + reservation_time: '2026-07-01T13:40', + reservation_end_time: null, + }, + ]); + }); + + it('FLREPO-004: the poll candidates skip archived trips and trips whose dates lie outside the span', async () => { + const { user } = await makeUser(t); + const current = await makeTrip(t, user.id, { start_date: '2026-06-28', end_date: '2026-07-05' }); + const undated = await makeTrip(t, user.id); + const past = await makeTrip(t, user.id, { start_date: '2026-05-01', end_date: '2026-05-03' }); + const future = await makeTrip(t, user.id, { start_date: '2026-09-01', end_date: '2026-09-03' }); + const archived = await makeTrip(t, user.id, { start_date: '2026-06-28', end_date: '2026-07-05', is_archived: 1 }); + await [current, undated, past, future, archived].reduce(async (previous, trip) => { + await previous; + await makeReservation(t, trip.id, { title: 'Flight', type: 'flight' }); + }, Promise.resolve()); + const rows = await repo.listFlightBookingsBetween('2026-06-29', '2026-07-03'); + expect(rows.map((r) => r.trip_id).sort((a, b) => a - b)).toEqual([current.id, undated.id].sort((a, b) => a - b)); + }); + + it('FLREPO-005: the endpoints of the bookings in their order, and none for no bookings', async () => { + const { user } = await makeUser(t); + const trip = await makeTrip(t, user.id); + const flight = await makeReservation(t, trip.id, { title: 'Flight', type: 'flight' }); + await makeReservationEndpoint(t, flight.id, { role: 'to', sequence: 1, code: 'HND', name: 'Haneda (HND)' }); + await makeReservationEndpoint(t, flight.id, { + role: 'from', + sequence: 0, + code: 'FRA', + name: 'Frankfurt (FRA)', + timezone: 'Europe/Berlin', + local_date: '2026-07-01', + local_time: '13:40', + }); + const endpoints = await repo.listEndpoints([flight.id]); + expect(endpoints.map((e) => [e.sequence, e.code])).toEqual([ + [0, 'FRA'], + [1, 'HND'], + ]); + expect(endpoints[0]).toMatchObject({ + reservation_id: flight.id, + role: 'from', + timezone: 'Europe/Berlin', + local_date: '2026-07-01', + local_time: '13:40', + }); + expect(await repo.listEndpoints([])).toEqual([]); + }); + + it('FLREPO-006: the dates of trip days, leaving out a day without one', async () => { + const { user } = await makeUser(t); + const trip = await makeTrip(t, user.id); + const dated = await makeDay(t, trip.id, { day_number: 1, date: '2026-07-01' }); + const undated = await makeDay(t, trip.id, { day_number: 2, date: null }); + expect(await repo.listDayDates([dated.id, undated.id])).toEqual([{ id: dated.id, date: '2026-07-01' }]); + expect(await repo.listDayDates([])).toEqual([]); + }); + + it('FLREPO-007: the travellers put on a booking who still belong to the trip, never a removed member', async () => { + const { user } = await makeUser(t); + const { user: friend } = await makeUser(t); + const { user: removed } = await makeUser(t); + const trip = await makeTrip(t, user.id); + await addTripMember(t, trip.id, friend.id); + const flight = await makeReservation(t, trip.id, { title: 'Flight', type: 'flight' }); + expect(await repo.listTravelerIds(flight.id)).toEqual([]); + await addReservationTraveler(t, flight.id, friend.id); + await addReservationTraveler(t, flight.id, user.id); + // On the traveller list from before, but no longer on the trip. + await addReservationTraveler(t, flight.id, removed.id); + expect(await repo.listTravelerIds(flight.id)).toEqual([user.id, friend.id].sort((a, b) => a - b)); + }); +}); diff --git a/server/tests/unit/nest/app-settings.registry.test.ts b/server/tests/unit/nest/app-settings.registry.test.ts index 23142c6a3..f22148cd2 100644 --- a/server/tests/unit/nest/app-settings.registry.test.ts +++ b/server/tests/unit/nest/app-settings.registry.test.ts @@ -50,6 +50,7 @@ describe('the derived key lists', () => { 'webauthn_rp_id', 'webauthn_origins', 'places_provider', + 'flight_live_enabled', ]); }); @@ -133,6 +134,7 @@ describe('reading', () => { oidc_client_secret: ['OIDC_CLIENT_SECRET', 'oidc-env-secret'], oidc_display_name: ['OIDC_DISPLAY_NAME', 'Company SSO'], oidc_discovery_url: ['OIDC_DISCOVERY_URL', 'https://idp.env.example/.well-known/openid-configuration'], + flight_live_enabled: ['FLIGHT_LIVE_ENABLED', 'false'], }; const withEnv = (Object.entries(APP_SETTINGS) as Array<[string, AppSettingDef]>) .filter(([, def]) => def.env) diff --git a/server/tests/unit/nest/auth.service.test.ts b/server/tests/unit/nest/auth.service.test.ts index eb113ea38..80304c4dc 100644 --- a/server/tests/unit/nest/auth.service.test.ts +++ b/server/tests/unit/nest/auth.service.test.ts @@ -1063,6 +1063,23 @@ describe('deleteAccount', () => { }); describe('updateAppSettings', () => { + it('AUTH-DB-070b: FLIGHT_LIVE_ENABLED holds the switch: the form shows it as env_override_*, a save skips it', async () => { + const { user } = createAdmin(testDb); + vi.stubEnv('FLIGHT_LIVE_ENABLED', 'false'); + try { + await appSetting('flight_live_enabled', 'true'); + const read = await asLegacyResult(svc.getAppSettings(user.id)); + expect(read.data).toMatchObject({ flight_live_enabled: 'false', env_override_flight_live_enabled: 'false' }); + + await deleteRows(orm, AppSettings, { key: 'flight_live_enabled' }); + const saved = await asLegacyResult(svc.updateAppSettings(user.id, { flight_live_enabled: 'true' })); + expect(saved).toMatchObject({ success: true, managedKeys: ['flight_live_enabled'] }); + expect(await findRow(orm, AppSettings, { key: 'flight_live_enabled' })).toBeNull(); + } finally { + vi.unstubAllEnvs(); + } + }); + it('AUTH-DB-071: 403 for non-admin', async () => { const { user } = createUser(testDb); expect((await asLegacyResult(svc.updateAppSettings(user.id, {}))).status).toBe(403); diff --git a/server/tests/unit/nest/flight-live/flight-live-poll.service.test.ts b/server/tests/unit/nest/flight-live/flight-live-poll.service.test.ts new file mode 100644 index 000000000..41f84bee4 --- /dev/null +++ b/server/tests/unit/nest/flight-live/flight-live-poll.service.test.ts @@ -0,0 +1,383 @@ +/** + * FlightLivePollService: the minute tick behind live flight status. The poll + * comes out of FlightLiveModule over the migrated snapshot; the feed and the + * notifications are stubs, broadcasts land on the FakeRealtimeService. + */ +import { AppSettings } from '../../../../src/db/entities/AppSettings.entity'; +import { FlightLiveStates } from '../../../../src/db/entities/FlightLiveStates.entity'; +import { FlightLivePollService } from '../../../../src/nest/flight-live/flight-live-poll.service'; +import type { FeedAircraft } from '../../../../src/nest/flight-live/flight-live.derive'; +import { FlightLiveFeedService, type FeedLookup } from '../../../../src/nest/flight-live/flight-live.feeds'; +import { FlightLiveJob } from '../../../../src/nest/flight-live/flight-live.job'; +import { FlightLiveModule } from '../../../../src/nest/flight-live/flight-live.module'; +import { FlightLiveService } from '../../../../src/nest/flight-live/flight-live.service'; +import { NotificationsService } from '../../../../src/nest/notifications/notifications.service'; +import { CronRegistrarService } from '../../../../src/nest/scheduling/cron-registrar.service'; +import { createSnapshotTestDb } from '../../../helpers/db-mock'; +import { + addReservationTraveler, + makeReservation, + makeReservationEndpoint, +} from '../../../helpers/factories/reservations'; +import { findRow, findRows, insertRow } from '../../../helpers/factories/rows'; +import { makeDay, makeTrip } from '../../../helpers/factories/trips'; +import { makeUser } from '../../../helpers/factories/users'; +import { resetTestDb } from '../../../helpers/test-db'; +import { createTestModule, type TestModule } from '../../../helpers/test-module'; +import { MikroORM } from '@mikro-orm/core'; +import { flightLiveLegSchema } from '@trek/shared'; + +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const db = createSnapshotTestDb(); +let t: TestModule; +let poll: FlightLivePollService; +let orm: MikroORM; + +const feed = { + lookup: vi.fn<(callsign: string, clock?: () => number) => Promise>(), + attribution: vi.fn(() => 'Live positions: adsb.lol (ODbL)'), + sources: vi.fn(() => ['adsb.lol']), + canAsk: vi.fn(() => true), + waitMs: vi.fn(() => 0), +}; +const notifications = { send: vi.fn(async () => ({ attempted: 1, delivered: 1 })) }; + +const MIN = 60_000; +/** LH 100 leaves Frankfurt at 12:00 local (10:00 UTC) on 1 July 2026 and lands in Berlin at 13:10. */ +const DEP = Date.parse('2026-07-01T10:00:00Z'); +const ARR = Date.parse('2026-07-01T11:10:00Z'); + +beforeAll(async () => { + t = await createTestModule({ + db, + imports: [FlightLiveModule], + overrides: [ + { provide: FlightLiveFeedService, useValue: feed }, + { provide: NotificationsService, useValue: notifications }, + ], + }); + poll = t.get(FlightLivePollService); + poll.sleep = async () => undefined; + orm = t.get(MikroORM); +}); +beforeEach(() => { + resetTestDb(db); + vi.clearAllMocks(); + t.realtime.broadcastMock.mockClear(); +}); +afterAll(async () => { + await t.close(); + db.close(); +}); + +async function seedFlight(title = 'Berlin') { + const { user } = await makeUser(orm); + const trip = await makeTrip(orm, user.id, { title }); + const flight = await makeReservation(orm, trip.id, { + title: 'FRA to BER', + type: 'flight', + metadata: JSON.stringify({ flight_number: 'LH 100' }), + }); + const end = { lat: 52.3667, lng: 13.5033, timezone: 'Europe/Berlin', local_date: '2026-07-01' }; + await makeReservationEndpoint(orm, flight.id, { + role: 'from', + sequence: 0, + name: 'Frankfurt (FRA)', + code: 'FRA', + lat: 50.0333, + lng: 8.5706, + timezone: 'Europe/Berlin', + local_date: '2026-07-01', + local_time: '12:00', + }); + await makeReservationEndpoint(orm, flight.id, { + role: 'to', + sequence: 1, + name: 'Berlin (BER)', + code: 'BER', + local_time: '13:10', + ...end, + }); + return { user, trip, flight }; +} + +function aircraft(overrides: Partial = {}): FeedAircraft { + return { + hex: '3c6444', + flight: 'DLH100', + registration: 'D-AINA', + type: 'A20N', + alt_baro: 4000, + alt_geom: null, + gs: 220, + track: 60, + baro_rate: 2000, + geom_rate: null, + lat: 50.1, + lon: 8.7, + seen: 0, + seen_pos: 0, + ...overrides, + }; +} + +const seen = (...list: FeedAircraft[]): FeedLookup => ({ ok: true, source: 'adsb.lol', now: 0, aircraft: list }); +const at = (ms: number) => () => ms; +const events = () => notifications.send.mock.calls.map((call) => (call as unknown as [{ event: string }])[0].event); + +describe('FlightLivePollService.poll', () => { + it('FLPOLL-001: switched off, it asks nothing', async () => { + await seedFlight(); + await insertRow(orm, AppSettings, { key: 'flight_live_enabled', value: 'false' }); + expect(await poll.poll(at(DEP))).toEqual({ asked: 0, changed: 0, skipped: 'disabled' }); + expect(feed.lookup).not.toHaveBeenCalled(); + }); + + it('FLPOLL-002: outside its window a flight is not asked about', async () => { + await seedFlight(); + expect(await poll.poll(at(DEP - 4 * 60 * MIN))).toEqual({ asked: 0, changed: 0, skipped: null }); + expect(feed.lookup).not.toHaveBeenCalled(); + }); + + it('FLPOLL-003: wheels-up is stored, sent to the trip and told to the whole trip without travellers', async () => { + const { trip, flight } = await seedFlight(); + feed.lookup.mockResolvedValueOnce(seen(aircraft())); + const now = DEP + 5 * MIN; + expect(await poll.poll(at(now))).toEqual({ asked: 1, changed: 1, skipped: null }); + expect(feed.lookup).toHaveBeenCalledWith('DLH100', expect.any(Function)); + + const row = await findRow(orm, FlightLiveStates, { callsign: 'DLH100', service_date: '2026-07-01' }); + expect(row).toMatchObject({ + phase: 'airborne', + departed_at: '2026-07-01 10:05:00', + departed_notified: 1, + delay_notified: 0, + source: 'adsb.lol', + next_poll_at: '2026-07-01 10:08:00', + }); + + expect(t.realtime.broadcastMock).toHaveBeenCalledTimes(1); + const [tripId, event, payload] = t.realtime.broadcastMock.mock.calls[0]; + expect([tripId, event]).toEqual([trip.id, 'flight:live']); + const legs = (payload as { flights: unknown[] }).flights; + expect(legs).toHaveLength(1); + expect(flightLiveLegSchema.parse(legs[0])).toMatchObject({ reservation_id: flight.id, phase: 'airborne' }); + + expect(notifications.send).toHaveBeenCalledTimes(1); + expect(notifications.send).toHaveBeenCalledWith({ + event: 'flight_departed', + actorId: null, + scope: 'trip', + targetId: trip.id, + params: { flight: 'LH 100', from: 'FRA', to: 'BER', trip: 'Berlin', tripId: String(trip.id), delay: '5' }, + }); + }); + + it('FLPOLL-004: a late departure is told once, to the travellers of each booking, next to wheels-up', async () => { + const { user, flight } = await seedFlight(); + const second = await seedFlight('Berlin too'); + await addReservationTraveler(orm, flight.id, user.id); + feed.lookup.mockResolvedValueOnce(seen(aircraft())); + await poll.poll(at(DEP + 25 * MIN)); + + expect(events().sort()).toEqual(['flight_delayed', 'flight_delayed', 'flight_departed', 'flight_departed']); + expect(notifications.send).toHaveBeenCalledWith( + expect.objectContaining({ event: 'flight_delayed', scope: 'user', targetId: user.id }), + ); + expect(notifications.send).toHaveBeenCalledWith( + expect.objectContaining({ event: 'flight_delayed', scope: 'trip', targetId: second.trip.id }), + ); + expect(t.realtime.broadcastMock).toHaveBeenCalledTimes(2); + + notifications.send.mockClear(); + feed.lookup.mockResolvedValueOnce(seen(aircraft({ alt_baro: 30000, lat: 51.2, lon: 11, gs: 450 }))); + expect(await poll.poll(at(DEP + 30 * MIN))).toMatchObject({ asked: 1 }); + expect(notifications.send).not.toHaveBeenCalled(); + }); + + it('FLPOLL-005: landed is told once and the flight is asked about no more', async () => { + const { trip } = await seedFlight(); + feed.lookup.mockResolvedValueOnce(seen(aircraft())); + await poll.poll(at(DEP + 2 * MIN)); + feed.lookup.mockResolvedValueOnce(seen(aircraft({ alt_baro: 'ground', lat: 52.37, lon: 13.5, gs: 15 }))); + await poll.poll(at(ARR)); + expect(events()).toEqual(['flight_departed', 'flight_landed']); + expect(await findRow(orm, FlightLiveStates, { callsign: 'DLH100' })).toMatchObject({ + phase: 'landed', + landed_at: '2026-07-01 11:10:00', + landed_notified: 1, + next_poll_at: null, + }); + feed.lookup.mockClear(); + expect(await poll.poll(at(ARR + 10 * MIN))).toMatchObject({ asked: 0 }); + expect(feed.lookup).not.toHaveBeenCalled(); + expect(t.realtime.broadcastMock.mock.calls.every((call) => call[0] === trip.id)).toBe(true); + }); + + it('FLPOLL-006: first seen on the ground at the destination is a landing without a departure notice', async () => { + await seedFlight(); + feed.lookup.mockResolvedValueOnce(seen(aircraft({ alt_baro: 'ground', lat: 52.37, lon: 13.5 }))); + await poll.poll(at(ARR)); + expect(events()).toEqual(['flight_landed']); + expect(await findRow(orm, FlightLiveStates, { callsign: 'DLH100' })).toMatchObject({ + phase: 'landed', + departed_notified: 1, + landed_notified: 1, + delay_notified: 0, + }); + }); + + it('FLPOLL-007: a failed ask counts and backs off, nothing is broadcast', async () => { + await seedFlight(); + feed.lookup.mockResolvedValue({ ok: false, reason: 'unavailable' }); + await poll.poll(at(DEP - 30 * MIN)); + await poll.poll(at(DEP - 28 * MIN)); + await poll.poll(at(DEP - 26 * MIN)); + expect(await findRow(orm, FlightLiveStates, { callsign: 'DLH100' })).toMatchObject({ + phase: 'scheduled', + fail_count: 3, + next_poll_at: '2026-07-01 09:49:00', + }); + expect(t.realtime.broadcastMock).not.toHaveBeenCalled(); + feed.lookup.mockReset(); + }); + + it('FLPOLL-008: an empty budget ends the tick before anything is stored', async () => { + await seedFlight(); + feed.lookup.mockResolvedValueOnce({ ok: false, reason: 'budget' }); + expect(await poll.poll(at(DEP))).toEqual({ asked: 0, changed: 0, skipped: null }); + expect(await findRows(orm, FlightLiveStates)).toEqual([]); + feed.canAsk.mockReturnValueOnce(false); + expect(await poll.poll(at(DEP))).toEqual({ asked: 0, changed: 0, skipped: null }); + expect(feed.lookup).toHaveBeenCalledTimes(1); + }); + + it('FLPOLL-009: a flight not due waits, unless a client asked for it; the feed spacing is waited out', async () => { + const { trip } = await seedFlight(); + feed.lookup.mockResolvedValue(seen(aircraft({ alt_baro: 'ground', lat: 50.035, lon: 8.56, gs: 5 }))); + await poll.poll(at(DEP - 60 * MIN)); + expect(feed.lookup).toHaveBeenCalledTimes(1); + await poll.poll(at(DEP - 58 * MIN)); + expect(feed.lookup).toHaveBeenCalledTimes(1); + + // Asked for seven minutes before its next poll: the cadence holds. + const live = t.get(FlightLiveService); + await live.tripFlights(trip.id, DEP - 57 * MIN); + await poll.poll(at(DEP - 57 * MIN)); + expect(feed.lookup).toHaveBeenCalledTimes(1); + + // Asked within two minutes of it: brought forward. + await live.tripFlights(trip.id, DEP - 51.5 * MIN); + const sleep = vi.fn(async () => undefined); + poll.sleep = sleep; + feed.waitMs.mockReturnValueOnce(400); + await poll.poll(at(DEP - 51.5 * MIN)); + expect(feed.lookup).toHaveBeenCalledTimes(2); + expect(sleep).toHaveBeenCalledWith(400); + poll.sleep = async () => undefined; + feed.lookup.mockReset(); + }); + + it('FLPOLL-010: a tick while one runs returns at once, and old states go once a day', async () => { + const old = { callsign: 'BAW1', service_date: '2026-01-01', origin: 'LHR', destination: 'JFK' }; + await insertRow(orm, FlightLiveStates, old); + // An earlier case already purged today; the guard is per day, so forget it. + (poll as unknown as { lastPurgeDay: string | null }).lastPurgeDay = null; + let release: () => void = () => undefined; + feed.lookup.mockImplementationOnce( + () => + new Promise((resolve) => { + release = () => resolve({ ok: false, reason: 'budget' }); + }), + ); + await seedFlight(); + const first = poll.poll(at(DEP + 10 * MIN)); + await vi.waitFor(() => expect(feed.lookup).toHaveBeenCalled()); + expect(await poll.poll(at(DEP + 10 * MIN))).toEqual({ asked: 0, changed: 0, skipped: 'running' }); + release(); + await first; + expect(await findRows(orm, FlightLiveStates, { callsign: 'BAW1' })).toEqual([]); + }); +}); + +describe('FlightLivePollService: closing and sectors', () => { + it('FLPOLL-011: a flight still in the air when its window ends is closed as unknown and asked about no more', async () => { + const { trip } = await seedFlight(); + feed.lookup.mockResolvedValueOnce(seen(aircraft({ alt_baro: 30000, lat: 51.2, lon: 11, gs: 450 }))); + await poll.poll(at(DEP + 20 * MIN)); + t.realtime.broadcastMock.mockClear(); + feed.lookup.mockClear(); + + await poll.poll(at(ARR + 121 * MIN)); + expect(feed.lookup).not.toHaveBeenCalled(); + expect(await findRow(orm, FlightLiveStates, { callsign: 'DLH100' })).toMatchObject({ + phase: 'unknown', + next_poll_at: null, + }); + expect(t.realtime.broadcastMock).toHaveBeenCalledTimes(1); + const [tripId, , payload] = t.realtime.broadcastMock.mock.calls[0]; + expect(tripId).toBe(trip.id); + expect((payload as { flights: Array<{ phase: string }> }).flights[0].phase).toBe('unknown'); + + t.realtime.broadcastMock.mockClear(); + await poll.poll(at(ARR + 125 * MIN)); + expect(t.realtime.broadcastMock).not.toHaveBeenCalled(); + }); + + it('FLPOLL-012: two sectors of one through flight keep a state each', async () => { + const { user } = await makeUser(orm); + const trip = await makeTrip(orm, user.id, { title: 'There and back' }); + const day = await makeDay(orm, trip.id, { day_number: 1, date: '2026-07-01' }); + const legs = [ + { from: 'FRA', to: 'BER', flight_number: 'LH 100', dep_day_id: day.id, dep_time: '12:00' }, + { from: 'BER', to: 'FRA', flight_number: 'LH 100', dep_day_id: day.id, dep_time: '15:00' }, + ]; + const flight = await makeReservation(orm, trip.id, { + title: 'Through flight', + type: 'flight', + metadata: JSON.stringify({ legs }), + }); + const fra = { name: 'Frankfurt (FRA)', code: 'FRA', lat: 50.0333, lng: 8.5706, timezone: 'Europe/Berlin' }; + const ber = { name: 'Berlin (BER)', code: 'BER', lat: 52.3667, lng: 13.5033, timezone: 'Europe/Berlin' }; + await makeReservationEndpoint(orm, flight.id, { role: 'from', sequence: 0, ...fra, local_date: '2026-07-01' }); + await makeReservationEndpoint(orm, flight.id, { role: 'stop', sequence: 1, ...ber, local_date: '2026-07-01' }); + await makeReservationEndpoint(orm, flight.id, { role: 'to', sequence: 2, ...fra, local_date: '2026-07-01' }); + feed.lookup.mockResolvedValue(seen(aircraft())); + expect(await poll.poll(at(DEP + 5 * MIN))).toMatchObject({ asked: 2 }); + const rows = await findRows(orm, FlightLiveStates, { callsign: 'DLH100' }); + // Seen climbing out of Frankfurt: wheels-up for the first sector, nothing claimed for the second. + expect(rows.map((r) => [r.origin, r.destination, r.departed_at]).sort()).toEqual([ + ['BER', 'FRA', null], + ['FRA', 'BER', '2026-07-01 10:05:00'], + ]); + feed.lookup.mockReset(); + }); +}); + +describe('FlightLiveJob', () => { + it('FLJOB-001: registers nothing under test and runs one poll per tick, a failure logged not thrown', async () => { + const job = t.get(FlightLiveJob); + const registrar = t.get(CronRegistrarService); + const register = vi.spyOn(registrar, 'register'); + job.onApplicationBootstrap(); + expect(register).not.toHaveBeenCalled(); + + const run = vi.spyOn(poll, 'poll').mockResolvedValueOnce({ asked: 2, changed: 1, skipped: null }); + await job.tick(); + run.mockRejectedValueOnce(new Error('boom')); + await expect(job.tick()).resolves.toBeUndefined(); + expect(run).toHaveBeenCalledTimes(2); + run.mockRestore(); + }); + + it('FLJOB-002: registers the minute tick where crons run', () => { + const job = t.get(FlightLiveJob); + const registrar = t.get(CronRegistrarService); + vi.spyOn(registrar, 'isEnabled').mockReturnValueOnce(true); + const register = vi.spyOn(registrar, 'register').mockReturnValueOnce(true); + job.onApplicationBootstrap(); + expect(register).toHaveBeenCalledWith('flight-live', '* * * * *', expect.any(Function)); + register.mockRestore(); + }); +}); diff --git a/server/tests/unit/nest/flight-live/flight-live.callsign.test.ts b/server/tests/unit/nest/flight-live/flight-live.callsign.test.ts new file mode 100644 index 000000000..a041f7d61 --- /dev/null +++ b/server/tests/unit/nest/flight-live/flight-live.callsign.test.ts @@ -0,0 +1,61 @@ +/** + * From a booked flight number to the callsign an aircraft transmits: the + * spellings a traveller types, the ICAO form taken as it is, an airline code + * borrowed from the airline field, and what cannot be told. + */ +import { airlineTable, toCallsign } from '../../../../src/nest/flight-live/flight-live.callsign'; + +import { describe, expect, it } from 'vitest'; + +const TABLE = new Map([ + ['LH', 'DLH'], + ['U2', 'EZY'], + ['4U', 'GWI'], +]); + +describe('toCallsign', () => { + it('FLCS-001: "LH 716", "LH0716", "lh716" and "LH-716" all fly as DLH716', () => { + for (const booked of ['LH 716', 'LH0716', 'lh716', 'LH-716', ' LH 0716 ']) { + expect(toCallsign(booked, null, TABLE)).toBe('DLH716'); + } + }); + + it('FLCS-002: codes with a digit map like any other, and a letter suffix stays', () => { + expect(toCallsign('U2 1234', null, TABLE)).toBe('EZY1234'); + expect(toCallsign('4U9520', null, TABLE)).toBe('GWI9520'); + expect(toCallsign('LH 7A', null, TABLE)).toBe('DLH7A'); + }); + + it('FLCS-003: a number already in ICAO form is taken as it is, its zeros trimmed', () => { + expect(toCallsign('DLH716', null, TABLE)).toBe('DLH716'); + expect(toCallsign('BAW0012', null, TABLE)).toBe('BAW12'); + expect(toCallsign('EZY0', null, TABLE)).toBe('EZY0'); + }); + + it('FLCS-004: a bare number borrows the airline code, never an airline name', () => { + expect(toCallsign('716', 'LH', TABLE)).toBe('DLH716'); + expect(toCallsign('0716', 'dlh', TABLE)).toBe('DLH716'); + expect(toCallsign('716', 'Lufthansa', TABLE)).toBeNull(); + expect(toCallsign('716', 'XX', TABLE)).toBeNull(); + expect(toCallsign('716', null, TABLE)).toBeNull(); + }); + + it('FLCS-005: an unknown code, an empty or odd number and no number at all answer null', () => { + expect(toCallsign('XX 716', null, TABLE)).toBeNull(); + expect(toCallsign('', null, TABLE)).toBeNull(); + expect(toCallsign(' ', null, TABLE)).toBeNull(); + expect(toCallsign('LH 12345', null, TABLE)).toBeNull(); + expect(toCallsign('Lufthansa', null, TABLE)).toBeNull(); + expect(toCallsign(null, 'LH', TABLE)).toBeNull(); + expect(toCallsign(undefined, 'LH', TABLE)).toBeNull(); + }); + + it('FLCS-006: the bundled table maps the big carriers, once read and then kept', () => { + const table = airlineTable(); + expect(table.get('LH')).toBe('DLH'); + expect(table.get('BA')).toBe('BAW'); + expect(table.get('U2')).toBe('EZY'); + expect(airlineTable()).toBe(table); + expect(toCallsign('LH 400')).toBe('DLH400'); + }); +}); diff --git a/server/tests/unit/nest/flight-live/flight-live.controller.test.ts b/server/tests/unit/nest/flight-live/flight-live.controller.test.ts new file mode 100644 index 000000000..3d056c729 --- /dev/null +++ b/server/tests/unit/nest/flight-live/flight-live.controller.test.ts @@ -0,0 +1,132 @@ +/** + * GET /api/trips/:tripId/flights/live and its MCP twin get_trip_flight_status + * over a stub service: the route's guards and contract, the tool's scope and + * input, and parity: both ask the same service method for the same trip + * after the same access check, and answer the same body. + */ +import type { McpContext, McpTextResult, ToolOptions } from '../../../../src/nest-mcp'; +import { getEntry, type ClassRef } from '../../../../src/nest-mcp/metadata'; +import { JwtAuthGuard } from '../../../../src/nest/auth-core/jwt-auth.guard'; +import { RESPONSE_CONTRACT_KEY } from '../../../../src/nest/common/response-contract'; +import { FlightLiveController } from '../../../../src/nest/flight-live/flight-live.controller'; +import { FlightLiveMcp } from '../../../../src/nest/flight-live/flight-live.mcp'; +import type { FlightLiveService } from '../../../../src/nest/flight-live/flight-live.service'; +import { TripAccessGuard } from '../../../../src/nest/permissions/trip-access.guard'; +import type { TripAccessService } from '../../../../src/nest/trip-membership/trip-access.service'; +import { RequestMethod } from '@nestjs/common'; +import { GUARDS_METADATA, METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants'; +import { tripFlightLiveResponseSchema, type TripFlightLiveResponse } from '@trek/shared'; + +import { describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; + +const ANSWER: TripFlightLiveResponse = { + enabled: true, + attribution: 'Live positions: adsb.lol (ODbL)', + flights: [ + { + reservation_id: 10, + leg_index: 0, + callsign: 'DLH100', + flight_number: 'LH 100', + service_date: '2026-07-01', + origin: { code: 'FRA', name: 'Frankfurt', lat: 50.03, lng: 8.57, timezone: 'Europe/Berlin' }, + destination: { code: 'BER', name: 'Berlin', lat: 52.36, lng: 13.5, timezone: 'Europe/Berlin' }, + phase: 'scheduled', + scheduled_departure: '2026-07-01T10:00:00.000Z', + scheduled_arrival: '2026-07-01T11:10:00.000Z', + departed_at: null, + landed_at: null, + estimated_arrival: null, + departure_delay_min: null, + arrival_delay_min: null, + progress: 0, + distance_total_km: 431, + distance_remaining_km: 431, + position: null, + aircraft: null, + last_seen_at: null, + fetched_at: null, + stale: false, + source: null, + }, + ], +}; + +function service() { + return { tripFlights: vi.fn().mockResolvedValue(ANSWER) }; +} + +describe('FlightLiveController', () => { + it('FLCTL-001: GET api/trips/:tripId/flights/live sits behind the session and the trip, on the response contract', () => { + expect(Reflect.getMetadata(PATH_METADATA, FlightLiveController)).toBe('api/trips/:tripId/flights'); + expect(Reflect.getMetadata(GUARDS_METADATA, FlightLiveController)).toEqual([JwtAuthGuard, TripAccessGuard]); + const handler = FlightLiveController.prototype.list; + expect(Reflect.getMetadata(METHOD_METADATA, handler)).toBe(RequestMethod.GET); + expect(Reflect.getMetadata(PATH_METADATA, handler)).toBe('live'); + expect(Reflect.getMetadata(RESPONSE_CONTRACT_KEY, handler)).toBe(tripFlightLiveResponseSchema); + }); + + it('FLCTL-002: asks the service for the trip the guard resolved and answers what it said', async () => { + const live = service(); + const controller = new FlightLiveController(live as unknown as FlightLiveService); + await expect(controller.list({ id: 3, user_id: 1, currency: null })).resolves.toBe(ANSWER); + expect(live.tripFlights).toHaveBeenCalledWith(3); + }); +}); + +const ctx = { userId: 7, scopes: null, isStaticToken: false } as McpContext; + +function toolOptions(): ToolOptions { + const entry = getEntry(FlightLiveMcp as unknown as ClassRef, 'getTripFlightStatus'); + if (!entry || entry.kind !== 'tool') throw new Error('get_trip_flight_status is not registered'); + return entry.options; +} + +function makeMcp(accessible: boolean) { + const live = service(); + const trip = accessible ? { id: 3, user_id: 1, currency: null } : undefined; + const access = { findAccessible: vi.fn().mockResolvedValue(trip) }; + const mcp = new FlightLiveMcp(live as unknown as FlightLiveService, access as unknown as TripAccessService); + return { mcp, live, access }; +} + +describe('FlightLiveMcp', () => { + it('FLMCP-001: get_trip_flight_status is read only, in the bookings scope like reading them', () => { + const options = toolOptions(); + expect(options.name).toBe('get_trip_flight_status'); + expect(options.annotations?.readOnlyHint).toBe(true); + expect(options.access).toEqual({ group: 'reservations', mode: 'read' }); + const input = z.object(options.inputSchema as z.ZodRawShape); + expect(input.safeParse({ tripId: 3 }).success).toBe(true); + expect(input.safeParse({ tripId: 0 }).success).toBe(false); + expect(input.safeParse({}).success).toBe(false); + }); + + it('FLMCP-002: parity: the same service method for the same trip answers the same body as the route', async () => { + const { mcp, live, access } = makeMcp(true); + const result = (await mcp.getTripFlightStatus({ tripId: 3 }, ctx)) as McpTextResult; + expect(access.findAccessible).toHaveBeenCalledWith(3, 7); + expect(live.tripFlights).toHaveBeenCalledWith(3); + expect(result.isError).toBeUndefined(); + const restBody = await new FlightLiveController(live as unknown as FlightLiveService).list({ + id: 3, + user_id: 1, + currency: null, + }); + expect(JSON.parse(result.content[0].text)).toEqual(restBody); + }); + + it('FLMCP-003: a trip the caller cannot see is refused before anything is read', async () => { + const { mcp, live } = makeMcp(false); + const result = (await mcp.getTripFlightStatus({ tripId: 9 }, ctx)) as McpTextResult; + expect(result).toEqual({ content: [{ type: 'text', text: 'Trip not found or access denied.' }], isError: true }); + expect(live.tripFlights).not.toHaveBeenCalled(); + }); + + it('FLMCP-004: an answer off the contract is not passed on', async () => { + const { mcp, live } = makeMcp(true); + live.tripFlights.mockResolvedValueOnce({ enabled: 'yes' } as unknown as TripFlightLiveResponse); + await expect(mcp.getTripFlightStatus({ tripId: 3 }, ctx)).rejects.toThrow(); + }); +}); diff --git a/server/tests/unit/nest/flight-live/flight-live.derive.test.ts b/server/tests/unit/nest/flight-live/flight-live.derive.test.ts new file mode 100644 index 000000000..cf15e0267 --- /dev/null +++ b/server/tests/unit/nest/flight-live/flight-live.derive.test.ts @@ -0,0 +1,339 @@ +/** + * What positions say about a booked flight, without the airline: the phase, + * wheels-up and touchdown, the delays and the arrival estimate, the watch + * window and how often the feed is asked. Every clock is passed in. + */ +import { + advance, + closeIfOver, + departureDelay, + emptyState, + failed, + inWatchWindow, + nextPollAt, + observe, + pickAircraft, + toLiveLeg, + watchWindow, + type FeedAircraft, + type LiveState, +} from '../../../../src/nest/flight-live/flight-live.derive'; +import type { ScheduledLeg } from '../../../../src/nest/flight-live/flight-live.schedule'; +import { flightLiveLegSchema } from '@trek/shared'; + +import { describe, expect, it } from 'vitest'; + +const MIN = 60_000; +const DEP = Date.parse('2026-07-01T10:00:00Z'); +const ARR = Date.parse('2026-07-01T11:10:00Z'); + +const LEG: ScheduledLeg = { + reservation_id: 10, + trip_id: 3, + leg_index: 0, + callsign: 'DLH100', + flight_number: 'LH 100', + service_date: '2026-07-01', + origin: { code: 'FRA', name: 'Frankfurt', lat: 50.0333, lng: 8.5706, timezone: 'Europe/Berlin' }, + destination: { code: 'TXL', name: 'Berlin', lat: 52.3667, lng: 13.5033, timezone: 'Europe/Berlin' }, + scheduled_departure: DEP, + scheduled_arrival: ARR, +}; + +function ac(overrides: Partial = {}): FeedAircraft { + return { + hex: '3c6444', + flight: 'DLH100', + registration: 'D-AINA', + type: 'A20N', + alt_baro: 'ground', + alt_geom: null, + gs: 12, + track: 90, + baro_rate: null, + geom_rate: null, + lat: 50.035, + lon: 8.56, + seen: 1, + seen_pos: 2, + ...overrides, + }; +} + +const start = emptyState('DLH100', '2026-07-01', 'FRA', 'TXL'); + +describe('the watch window', () => { + it('FLDER-001: runs from three hours before departure to two after arrival, or 16 h of flight without one', () => { + expect(watchWindow(LEG)).toEqual({ start: DEP - 180 * MIN, end: ARR + 120 * MIN }); + expect(watchWindow({ scheduled_departure: DEP, scheduled_arrival: null })).toEqual({ + start: DEP - 180 * MIN, + end: DEP + 18 * 60 * MIN, + }); + expect(watchWindow({ scheduled_departure: null, scheduled_arrival: ARR })).toBeNull(); + expect(inWatchWindow(LEG, DEP - 181 * MIN)).toBe(false); + expect(inWatchWindow(LEG, DEP - 179 * MIN)).toBe(true); + expect(inWatchWindow(LEG, ARR + 121 * MIN)).toBe(false); + expect(inWatchWindow({ scheduled_departure: null, scheduled_arrival: null }, DEP)).toBe(false); + }); +}); + +describe('pickAircraft and observe', () => { + it('FLDER-002: picks the aircraft nearest the route, one without a position last', () => { + const near = ac({ hex: 'near', lat: 51.2, lon: 11 }); + const far = ac({ hex: 'far', lat: 40, lon: -3 }); + const blind = ac({ hex: 'blind', lat: null, lon: null }); + expect(pickAircraft([blind, far, near], LEG)?.hex).toBe('near'); + expect(pickAircraft([blind], LEG)?.hex).toBe('blind'); + expect(pickAircraft([], LEG)).toBeNull(); + }); + + it('FLDER-003: reads a report at its own time, on the ground at zero feet, the geometric height as a fallback', () => { + const now = DEP; + expect(observe(ac(), now)).toEqual({ + position: { + lat: 50.035, + lng: 8.56, + altitude_ft: 0, + ground_speed_kt: 12, + track_deg: 90, + vertical_rate_fpm: null, + on_ground: true, + }, + positionAt: now - 2000, + heardAt: now - 1000, + }); + const air = observe(ac({ alt_baro: null, alt_geom: 3200, geom_rate: 1500, seen_pos: null, seen: null }), now); + expect(air?.position.altitude_ft).toBe(3200); + expect(air?.position.vertical_rate_fpm).toBe(1500); + expect(air?.positionAt).toBe(now); + expect(observe(ac({ lat: null }), now)).toBeNull(); + }); +}); + +describe('advance', () => { + it('FLDER-004: on the ground at the origin is taxiing, first and last heard set', () => { + const next = advance(start, ac(), LEG, DEP - 10 * MIN, 'adsb.lol'); + expect(next).toMatchObject({ + phase: 'taxiing', + departed_at: null, + first_seen_at: DEP - 10 * MIN - 1000, + last_seen_at: DEP - 10 * MIN - 1000, + registration: 'D-AINA', + aircraft_type: 'A20N', + source: 'adsb.lol', + fetched_at: DEP - 10 * MIN, + fail_count: 0, + }); + }); + + it('FLDER-005: the first airborne report low near the origin is wheels-up; a later one keeps it', () => { + const now = DEP + 12 * MIN; + const climbing = advance(start, ac({ alt_baro: 4000, lat: 50.1, lon: 8.7, gs: 220 }), LEG, now, 'adsb.lol'); + expect(climbing.phase).toBe('airborne'); + expect(climbing.departed_at).toBe(now - 2000); + const high = ac({ alt_baro: 36000, lat: 51.2, lon: 11, gs: 450 }); + const cruising = advance(climbing, high, LEG, now + 10 * MIN, 'adsb.fi'); + expect(cruising.departed_at).toBe(now - 2000); + expect(cruising.source).toBe('adsb.fi'); + }); + + it('FLDER-006: first seen high or far from the origin leaves wheels-up unknown', () => { + const high = advance(start, ac({ alt_baro: 30000, lat: 50.1, lon: 8.7 }), LEG, DEP + 30 * MIN, 'adsb.lol'); + expect(high).toMatchObject({ phase: 'airborne', departed_at: null }); + const far = advance(start, ac({ alt_baro: 8000, lat: 51.5, lon: 12 }), LEG, DEP + 30 * MIN, 'adsb.lol'); + expect(far.departed_at).toBeNull(); + }); + + it('FLDER-007: back on the ground near the destination after flying is landed, once', () => { + const flying = advance(start, ac({ alt_baro: 5000, lat: 50.1, lon: 8.7 }), LEG, DEP + 10 * MIN, 'adsb.lol'); + const down = advance(flying, ac({ lat: 52.37, lon: 13.5 }), LEG, ARR, 'adsb.lol'); + expect(down.phase).toBe('landed'); + expect(down.landed_at).toBe(ARR - 2000); + const later = advance(down, ac({ lat: 52.37, lon: 13.5 }), LEG, ARR + 10 * MIN, 'adsb.lol'); + expect(later.landed_at).toBe(ARR - 2000); + expect(later.position).toEqual(down.position); + }); + + it('FLDER-008: on the ground somewhere else after flying is a landing too, a diversion', () => { + const flying = { ...start, phase: 'airborne' as const }; + expect(advance(flying, ac({ lat: 48.35, lon: 11.79 }), LEG, ARR, 'adsb.lol').phase).toBe('landed'); + expect(advance(start, ac({ lat: 48.35, lon: 11.79 }), LEG, ARR, 'adsb.lol').phase).toBe('taxiing'); + }); + + it('FLDER-008b: first seen down at the destination is landed after the departure time, not before', () => { + const atBerlin = ac({ lat: 52.37, lon: 13.5 }); + expect(advance(start, atBerlin, LEG, ARR, 'adsb.lol')).toMatchObject({ phase: 'landed', landed_at: ARR - 2000 }); + expect(advance(start, atBerlin, LEG, DEP - 30 * MIN, 'adsb.lol').phase).toBe('taxiing'); + }); + + it('FLDER-009: lost low near the destination and silent ten minutes is landed at its last report', () => { + const lastSeen = ARR - 15 * MIN; + const position = { + lat: 52.3, + lng: 13.4, + altitude_ft: 2500, + ground_speed_kt: 160, + track_deg: 80, + vertical_rate_fpm: -700, + on_ground: false, + }; + const flying: LiveState = { ...start, phase: 'airborne', first_seen_at: DEP, last_seen_at: lastSeen, position }; + expect(advance(flying, null, LEG, lastSeen + 5 * MIN, 'adsb.lol').phase).toBe('airborne'); + const down = advance(flying, null, LEG, lastSeen + 10 * MIN, 'adsb.lol'); + expect(down).toMatchObject({ phase: 'landed', landed_at: lastSeen }); + const high = { ...flying, position: { ...position, altitude_ft: 20000 } }; + expect(advance(high, null, LEG, lastSeen + 30 * MIN, 'adsb.lol').phase).toBe('airborne'); + }); + + it('FLDER-010: never heard two hours after departure is unknown; earlier it stays scheduled', () => { + expect(advance(start, null, LEG, DEP + 119 * MIN, 'adsb.lol').phase).toBe('scheduled'); + expect(advance(start, null, LEG, DEP + 120 * MIN, 'adsb.lol').phase).toBe('unknown'); + const seen = { ...start, first_seen_at: DEP - 30 * MIN, phase: 'taxiing' as const }; + expect(advance(seen, null, LEG, DEP + 180 * MIN, 'adsb.lol').phase).toBe('taxiing'); + }); + + it('FLDER-011: a failure counts and keeps the state', () => { + const once = failed({ ...start, phase: 'taxiing' }); + expect(once).toMatchObject({ phase: 'taxiing', fail_count: 1 }); + expect(failed(once).fail_count).toBe(2); + }); +}); + +describe('nextPollAt', () => { + it('FLDER-012: every 10 minutes until 40 before departure, then every 2 until airborne', () => { + expect(nextPollAt(start, LEG, DEP - 170 * MIN)).toBe(DEP - 160 * MIN); + expect(nextPollAt(start, LEG, DEP - 45 * MIN)).toBe(DEP - 40 * MIN); + expect(nextPollAt(start, LEG, DEP - 41 * MIN)).toBe(DEP - 39 * MIN); + expect(nextPollAt({ ...start, phase: 'taxiing' }, LEG, DEP + 5 * MIN)).toBe(DEP + 7 * MIN); + }); + + it('FLDER-013: airborne every 3 minutes, every 6 when more than two hours from both airports', () => { + const position = { + lat: 51.2, + lng: 11, + altitude_ft: 36000, + ground_speed_kt: 450, + track_deg: 60, + vertical_rate_fpm: 0, + on_ground: false, + }; + const flying: LiveState = { ...start, phase: 'airborne', position }; + expect(nextPollAt(flying, LEG, DEP + 30 * MIN)).toBe(DEP + 33 * MIN); + const longHaul = { ...LEG, destination: { ...LEG.destination, lat: 35.55, lng: 139.78 }, scheduled_arrival: null }; + const midway = { ...flying, position: { ...position, lat: 60, lng: 70 } }; + expect(nextPollAt(midway, longHaul, DEP + 300 * MIN)).toBe(DEP + 306 * MIN); + expect(nextPollAt({ ...flying, position: null }, LEG, DEP + 30 * MIN)).toBe(DEP + 33 * MIN); + }); + + it('FLDER-014: unknown or failing every 15 minutes; landed or past the window stops', () => { + expect(nextPollAt({ ...start, phase: 'unknown' }, LEG, DEP + 130 * MIN)).toBe(DEP + 145 * MIN); + expect(nextPollAt({ ...start, fail_count: 3 }, LEG, DEP)).toBe(DEP + 15 * MIN); + expect(nextPollAt({ ...start, phase: 'landed' }, LEG, ARR)).toBeNull(); + expect(nextPollAt(start, LEG, ARR + 121 * MIN)).toBeNull(); + expect(nextPollAt(start, { ...LEG, scheduled_departure: null }, DEP)).toBeNull(); + }); +}); + +describe('toLiveLeg and departureDelay', () => { + it('FLDER-015: without a state the schedule answers: scheduled, unknown late in the window, scheduled after it', () => { + const before = toLiveLeg(LEG, null, DEP - 24 * 60 * MIN); + expect(flightLiveLegSchema.parse(before)).toEqual(before); + expect(before).toMatchObject({ + phase: 'scheduled', + scheduled_departure: '2026-07-01T10:00:00.000Z', + scheduled_arrival: '2026-07-01T11:10:00.000Z', + progress: 0, + distance_remaining_km: before.distance_total_km, + position: null, + aircraft: null, + source: null, + stale: false, + }); + expect(before.distance_total_km).toBeGreaterThan(400); + expect(toLiveLeg(LEG, null, DEP + 130 * MIN).phase).toBe('unknown'); + // Past its window the client tells a past flight from its times; nothing claims a landing nobody saw. + const after = toLiveLeg(LEG, null, ARR + 3 * 60 * MIN); + expect(after).toMatchObject({ phase: 'scheduled', progress: 0, landed_at: null }); + expect(after.distance_remaining_km).toBe(after.distance_total_km); + }); + + it('FLDER-016: airborne answers its estimate from ground speed with the approach buffer and the delays', () => { + const now = DEP + 40 * MIN; + const position = { + lat: 52.3667, + lng: 10.5, + altitude_ft: 36000, + ground_speed_kt: 405, + track_deg: 90, + vertical_rate_fpm: 0, + on_ground: false, + }; + const state: LiveState = { + ...start, + phase: 'airborne', + departed_at: DEP + 20 * MIN, + first_seen_at: DEP, + last_seen_at: now, + fetched_at: now, + registration: 'D-AINA', + source: 'adsb.lol', + position, + }; + const live = toLiveLeg(LEG, state, now); + expect(flightLiveLegSchema.parse(live)).toEqual(live); + expect(live.departure_delay_min).toBe(20); + expect(live.departed_at).toBe('2026-07-01T10:20:00.000Z'); + expect(live.aircraft).toEqual({ registration: 'D-AINA', type: null }); + expect(live.progress).toBeGreaterThan(0.3); + expect(live.progress).toBeLessThan(1); + // About 204 km left at 750 km/h is 16 minutes, plus 8 for the approach. + const eta = Date.parse(live.estimated_arrival ?? ''); + expect(Math.round((eta - now) / MIN)).toBe(24); + expect(live.arrival_delay_min).toBe(Math.round((eta - ARR) / MIN)); + expect(live.stale).toBe(false); + expect(toLiveLeg(LEG, state, now + 11 * MIN).stale).toBe(true); + const slow = { ...state, position: { ...position, ground_speed_kt: 120 } }; + expect(toLiveLeg(LEG, slow, now).estimated_arrival).toBeNull(); + }); + + it('FLDER-017: landed answers its touchdown and the arrival delay from it', () => { + const state: LiveState = { ...start, phase: 'landed', landed_at: ARR + 25 * MIN, fetched_at: ARR + 30 * MIN }; + const live = toLiveLeg(LEG, state, ARR + 30 * MIN); + expect(live).toMatchObject({ + phase: 'landed', + landed_at: '2026-07-01T11:35:00.000Z', + arrival_delay_min: 25, + distance_remaining_km: 0, + progress: 1, + estimated_arrival: null, + }); + }); + + it('FLDER-018: the departure delay comes from wheels-up, or from a recent report still at the gate past the time', () => { + expect(departureDelay(LEG, { ...start, departed_at: DEP + 17 * MIN }, DEP + 30 * MIN)).toBe(17); + const atGate = { ...start, phase: 'taxiing' as const, last_seen_at: DEP + 16 * MIN }; + expect(departureDelay(LEG, atGate, DEP + 17 * MIN)).toBe(16); + // Gone quiet on the ground: the last report says nothing about now. + expect(departureDelay(LEG, atGate, DEP + 26 * MIN)).toBeNull(); + // Heard before the scheduled time: not late yet, however late it is now. + expect(departureDelay(LEG, { ...atGate, last_seen_at: DEP - 5 * MIN }, DEP + 2 * MIN)).toBeNull(); + expect(departureDelay(LEG, { ...start, phase: 'taxiing' }, DEP + 16 * MIN)).toBeNull(); + expect(departureDelay(LEG, start, DEP + 60 * MIN)).toBeNull(); + expect(departureDelay({ ...LEG, scheduled_departure: null }, { ...start, departed_at: DEP }, DEP)).toBeNull(); + }); + + it('FLDER-019: after its window nothing is shown taxiing or in the air, and the state closes as unknown', () => { + const after = ARR + 121 * MIN; + const flying: LiveState = { ...start, phase: 'airborne', last_seen_at: ARR - 30 * MIN, next_poll_at: ARR }; + expect(toLiveLeg(LEG, flying, after)).toMatchObject({ phase: 'unknown', stale: false }); + expect(toLiveLeg(LEG, { ...flying, phase: 'taxiing' }, after).phase).toBe('unknown'); + expect(toLiveLeg(LEG, flying, ARR).phase).toBe('airborne'); + + expect(closeIfOver(flying, LEG, after)).toMatchObject({ phase: 'unknown', next_poll_at: null }); + expect(closeIfOver(flying, LEG, ARR)).toBe(flying); + const landed: LiveState = { ...start, phase: 'landed', landed_at: ARR }; + expect(closeIfOver(landed, LEG, after)).toBe(landed); + expect(closeIfOver(start, LEG, after)).toBe(start); + expect(closeIfOver(flying, { scheduled_departure: null, scheduled_arrival: null }, after)).toBe(flying); + }); +}); diff --git a/server/tests/unit/nest/flight-live/flight-live.feeds.test.ts b/server/tests/unit/nest/flight-live/flight-live.feeds.test.ts new file mode 100644 index 000000000..de4d5e69d --- /dev/null +++ b/server/tests/unit/nest/flight-live/flight-live.feeds.test.ts @@ -0,0 +1,213 @@ +/** + * The two keyless ADS-B feeds and the budget that spaces them: what goes out + * (the callsign, nothing else), what an answer has to look like, and how a + * bad, oversized, slow or refusing source is handled. fetch is stubbed. + */ +import { AppConfigModule } from '../../../../src/nest/app-config/app-config.module'; +import { BUDGET_PER_HOUR, FeedBudget } from '../../../../src/nest/flight-live/flight-live.budget'; +import { FlightLiveFeedService } from '../../../../src/nest/flight-live/flight-live.feeds'; +import { createSnapshotTestDb } from '../../../helpers/db-mock'; +import { createTestModule, type TestModule } from '../../../helpers/test-module'; + +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../../src/nest/audit/audit-log.logger', async (importOriginal) => ({ + ...(await importOriginal()), + logWarn: vi.fn(), +})); + +const db = createSnapshotTestDb(); +let t: TestModule; +let feeds: FlightLiveFeedService; +let clock = 1_000_000; +const now = () => clock; + +const AIRCRAFT = { + hex: '3c6444', + flight: 'DLH716 ', + r: 'D-AIXA', + t: 'A359', + alt_baro: 36000, + alt_geom: 36500, + gs: 480.2, + track: 51.3, + baro_rate: 0, + geom_rate: 64, + lat: 55.1, + lon: 60.2, + seen: 0.4, + seen_pos: 1.1, + category: 'A5', + squawk: '1000', +}; + +function answer(body: unknown, status = 200) { + return new Response(typeof body === 'string' ? body : JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +beforeEach(async () => { + clock += 60 * 60 * 1000; + t = await createTestModule({ db, imports: [AppConfigModule], providers: [FlightLiveFeedService] }); + feeds = t.get(FlightLiveFeedService); + feeds.sleep = async () => undefined; +}); +afterEach(async () => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + await t.close(); +}); +afterAll(() => db.close()); + +describe('FeedBudget', () => { + it('FLFEED-001: one request a second and 200 an hour, a paused source for a quarter of an hour', () => { + const budget = new FeedBudget(); + expect(budget.tryTake(0)).toBe(true); + expect(budget.tryTake(500)).toBe(false); + expect(budget.waitMs(500)).toBe(500); + expect(budget.tryTake(1000)).toBe(true); + for (let i = 2; i < BUDGET_PER_HOUR; i++) expect(budget.tryTake(i * 1000)).toBe(true); + expect(budget.exhausted(BUDGET_PER_HOUR * 1000)).toBe(true); + expect(budget.tryTake(BUDGET_PER_HOUR * 1000)).toBe(false); + expect(budget.tryTake(60 * 60 * 1000 + 500)).toBe(true); + expect(new FeedBudget().waitMs(0)).toBe(0); + + budget.pause('adsb.lol', 0); + expect(budget.isPaused('adsb.lol', 14 * 60 * 1000)).toBe(true); + expect(budget.isPaused('adsb.lol', 15 * 60 * 1000)).toBe(false); + expect(budget.isPaused('adsb.fi', 0)).toBe(false); + }); +}); + +describe('FlightLiveFeedService', () => { + it('FLFEED-002: asks adsb.lol for the callsign alone and reads the aircraft it answers', async () => { + const fetcher = vi.fn(async () => answer({ ac: [AIRCRAFT], now: 1_700_000_000_000, total: 1 })); + vi.stubGlobal('fetch', fetcher); + const result = await feeds.lookup('DLH716', now); + expect(fetcher).toHaveBeenCalledTimes(1); + const [url, init] = fetcher.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe('https://api.adsb.lol/v2/callsign/DLH716'); + expect(init.signal).toBeInstanceOf(AbortSignal); + expect(init).not.toHaveProperty('body'); + expect(result).toEqual({ + ok: true, + source: 'adsb.lol', + now: 1_700_000_000_000, + aircraft: [ + { + hex: '3c6444', + flight: 'DLH716', + registration: 'D-AIXA', + type: 'A359', + alt_baro: 36000, + alt_geom: 36500, + gs: 480.2, + track: 51.3, + baro_rate: 0, + geom_rate: 64, + lat: 55.1, + lon: 60.2, + seen: 0.4, + seen_pos: 1.1, + }, + ], + }); + expect(feeds.attribution()).toBe('Live positions: adsb.lol (ODbL)'); + }); + + it('FLFEED-003: keeps only the aircraft flying the callsign, and an answer without any is empty', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValueOnce(answer({ ac: [AIRCRAFT, { ...AIRCRAFT, hex: 'x', flight: 'DLH7160' }], now: 1 })) + .mockResolvedValueOnce(answer({ now: 2 })), + ); + const first = await feeds.lookup('DLH716', now); + expect(first).toMatchObject({ ok: true, aircraft: [expect.objectContaining({ hex: '3c6444' })] }); + expect((first as { aircraft: unknown[] }).aircraft).toHaveLength(1); + clock += 2000; + expect(await feeds.lookup('DLH716', now)).toEqual({ ok: true, source: 'adsb.lol', now: 2, aircraft: [] }); + }); + + it('FLFEED-004: falls back to adsb.fi on a self-hosted install when adsb.lol fails', async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce(answer('not json at all')) + .mockResolvedValueOnce(answer({ ac: [AIRCRAFT], now: 5 })); + vi.stubGlobal('fetch', fetcher); + let calls = 0; + const result = await feeds.lookup('DLH716', () => clock + 1000 * calls++); + expect(result).toMatchObject({ ok: true, source: 'adsb.fi' }); + expect(fetcher.mock.calls[1]?.[0]).toBe('https://opendata.adsb.fi/api/v2/callsign/DLH716'); + }); + + it('FLFEED-005: a managed install never asks adsb.fi', async () => { + vi.stubEnv('TREK_MANAGED', 'true'); + const fetcher = vi.fn(async () => answer({ unexpected: true })); + vi.stubGlobal('fetch', fetcher); + let calls = 0; + expect(await feeds.lookup('DLH716', () => clock + 1000 * calls++)).toEqual({ ok: false, reason: 'unavailable' }); + expect(fetcher).toHaveBeenCalledTimes(1); + expect(feeds.sources()).toEqual(['adsb.lol']); + }); + + it('FLFEED-006: an oversized body, a timeout and a bad shape are failures, never data', async () => { + vi.stubEnv('TREK_MANAGED', 'true'); + const huge = 'x'.repeat(300 * 1024); + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValueOnce(answer(huge)) + .mockRejectedValueOnce(new DOMException('The operation timed out.', 'TimeoutError')) + .mockResolvedValueOnce(answer({ ac: [{ hex: 1 }], now: 1 })), + ); + const failure = { ok: false, reason: 'unavailable' }; + clock += 2000; + expect(await feeds.lookup('DLH716', now)).toEqual(failure); + clock += 2000; + expect(await feeds.lookup('DLH716', now)).toEqual(failure); + clock += 2000; + expect(await feeds.lookup('DLH716', now)).toEqual(failure); + // Three failures in a row pause adsb.lol: nothing is asked and nothing is spent. + clock += 2000; + expect(await feeds.lookup('DLH716', now)).toEqual({ ok: false, reason: 'budget' }); + }); + + it('FLFEED-007: a 429 pauses the source at once and its body is let go', async () => { + vi.stubEnv('TREK_MANAGED', 'true'); + const cancel = vi.fn(async () => undefined); + const limited = { ok: false, status: 429, body: { cancel } } as unknown as Response; + const fetcher = vi.fn(async () => limited); + vi.stubGlobal('fetch', fetcher); + expect(await feeds.lookup('DLH716', now)).toEqual({ ok: false, reason: 'unavailable' }); + expect(cancel).toHaveBeenCalled(); + clock += 5 * 60 * 1000; + expect(await feeds.lookup('DLH716', now)).toEqual({ ok: false, reason: 'budget' }); + expect(fetcher).toHaveBeenCalledTimes(1); + clock += 11 * 60 * 1000; + vi.stubGlobal( + 'fetch', + vi.fn(async () => answer({ ac: [], now: 9 })), + ); + expect(await feeds.lookup('DLH716', now)).toMatchObject({ ok: true, source: 'adsb.lol' }); + }); + + it('FLFEED-008: the spacing between requests is held, and a malformed callsign never leaves', async () => { + const fetcher = vi.fn(async () => answer({ ac: [], now: 1 })); + vi.stubGlobal('fetch', fetcher); + expect(feeds.canAsk(clock)).toBe(true); + await feeds.lookup('DLH716', now); + expect(feeds.canAsk(clock)).toBe(false); + expect(feeds.waitMs(clock)).toBe(1000); + expect(await feeds.lookup('DLH716', now)).toEqual({ ok: false, reason: 'budget' }); + clock += 1000; + expect(await feeds.lookup('DLH716/../x', now)).toEqual({ ok: false, reason: 'unavailable' }); + expect(await feeds.lookup('dlh716', now)).toEqual({ ok: false, reason: 'unavailable' }); + expect(fetcher).toHaveBeenCalledTimes(1); + }); +}); diff --git a/server/tests/unit/nest/flight-live/flight-live.schedule.test.ts b/server/tests/unit/nest/flight-live/flight-live.schedule.test.ts new file mode 100644 index 000000000..35ad67545 --- /dev/null +++ b/server/tests/unit/nest/flight-live/flight-live.schedule.test.ts @@ -0,0 +1,247 @@ +/** + * The legs of a flight booking with their schedule in UTC, read the way the + * client's flightLegs.ts reads them: a single leg from the endpoints and the + * flat metadata, a chain from `metadata.legs` with the days it names, and + * the local clocks turned into instants with each airport's own zone. + */ +import { + legsOfBooking, + localToUtc, + type AirportFacts, + type FlightBookingRow, + type FlightEndpointRow, +} from '../../../../src/nest/flight-live/flight-live.schedule'; + +import { describe, expect, it } from 'vitest'; + +const AIRLINES = new Map([ + ['LH', 'DLH'], + ['NH', 'ANA'], +]); + +const AIRPORTS: Record = { + FRA: { name: 'Frankfurt am Main Airport', city: 'Frankfurt', lat: 50.0333, lng: 8.5706, tz: 'Europe/Berlin' }, + MUC: { name: 'Munich Airport', city: 'Munich', lat: 48.3538, lng: 11.7861, tz: 'Europe/Berlin' }, + HND: { name: 'Tokyo Haneda Airport', city: 'Tokyo', lat: 35.5523, lng: 139.7798, tz: 'Asia/Tokyo' }, + JFK: { + name: 'John F Kennedy International Airport', + city: 'New York', + lat: 40.6398, + lng: -73.7789, + tz: 'America/New_York', + }, +}; +const lookup = (code: string) => AIRPORTS[code] ?? null; + +function booking(overrides: Partial = {}): FlightBookingRow { + return { + id: 10, + trip_id: 3, + metadata: {}, + day_id: null, + end_day_id: null, + reservation_time: null, + reservation_end_time: null, + ...overrides, + }; +} + +function endpoint(overrides: Partial): FlightEndpointRow { + return { + reservation_id: 10, + role: 'from', + sequence: 0, + name: 'Frankfurt (FRA)', + code: 'FRA', + lat: 50.0333, + lng: 8.5706, + timezone: 'Europe/Berlin', + local_date: null, + local_time: null, + ...overrides, + }; +} + +const NO_DAYS = new Map(); + +describe('localToUtc', () => { + it('FLSCH-001: reads a local clock in its zone, summer time included, and needs all three parts', () => { + expect(localToUtc('2026-07-01', '10:30', 'Europe/Berlin')).toBe(Date.parse('2026-07-01T08:30:00Z')); + expect(localToUtc('2026-01-15', '10:30', 'Europe/Berlin')).toBe(Date.parse('2026-01-15T09:30:00Z')); + expect(localToUtc('2026-07-01', '10:30', 'Asia/Tokyo')).toBe(Date.parse('2026-07-01T01:30:00Z')); + expect(localToUtc(null, '10:30', 'Europe/Berlin')).toBeNull(); + expect(localToUtc('2026-07-01', null, 'Europe/Berlin')).toBeNull(); + expect(localToUtc('2026-07-01', '10:30', null)).toBeNull(); + expect(localToUtc('2026-07-01', '10:30', 'Not/AZone')).toBeNull(); + }); +}); + +describe('legsOfBooking', () => { + it('FLSCH-002: a single leg from its endpoints, with each end in its own zone', () => { + const legs = legsOfBooking( + booking({ metadata: { flight_number: 'LH 716', airline: 'Lufthansa' } }), + [ + endpoint({ local_date: '2026-07-01', local_time: '13:40' }), + endpoint({ + role: 'to', + sequence: 1, + name: 'Tokyo Haneda (HND)', + code: 'HND', + lat: 35.5523, + lng: 139.7798, + timezone: 'Asia/Tokyo', + local_date: '2026-07-02', + local_time: '08:15', + }), + ], + NO_DAYS, + lookup, + AIRLINES, + ); + expect(legs).toEqual([ + { + reservation_id: 10, + trip_id: 3, + leg_index: 0, + callsign: 'DLH716', + flight_number: 'LH 716', + service_date: '2026-07-01', + origin: { code: 'FRA', name: 'Frankfurt', lat: 50.0333, lng: 8.5706, timezone: 'Europe/Berlin' }, + destination: { code: 'HND', name: 'Tokyo Haneda', lat: 35.5523, lng: 139.7798, timezone: 'Asia/Tokyo' }, + scheduled_departure: Date.parse('2026-07-01T11:40:00Z'), + scheduled_arrival: Date.parse('2026-07-01T23:15:00Z'), + }, + ]); + }); + + it('FLSCH-003: a chain of legs takes its days and times from metadata.legs and its airports from the dataset', () => { + const days = new Map([ + [1, '2026-07-01'], + [2, '2026-07-02'], + ]); + const legs = legsOfBooking( + booking({ + metadata: { + legs: [ + { + from: 'MUC', + to: 'FRA', + flight_number: 'LH 101', + dep_day_id: 1, + dep_time: '07:00', + arr_day_id: 1, + arr_time: '08:00', + }, + { + from: 'FRA', + to: 'HND', + airline: 'NH', + flight_number: '204', + dep_day_id: 1, + dep_time: '21:00', + arr_time: '16:00', + }, + ], + }, + }), + [], + days, + lookup, + AIRLINES, + ); + expect(legs.map((l) => [l.leg_index, l.callsign, l.service_date, l.origin.code, l.destination.code])).toEqual([ + [0, 'DLH101', '2026-07-01', 'MUC', 'FRA'], + [1, 'ANA204', '2026-07-01', 'FRA', 'HND'], + ]); + expect(legs[0].origin).toEqual({ + code: 'MUC', + name: 'Munich Airport', + lat: 48.3538, + lng: 11.7861, + timezone: 'Europe/Berlin', + }); + expect(legs[0].scheduled_departure).toBe(Date.parse('2026-07-01T05:00:00Z')); + expect(legs[0].scheduled_arrival).toBe(Date.parse('2026-07-01T06:00:00Z')); + // No arrival day: 16:00 Tokyo on the departure day is before 21:00 Frankfurt, so it lands the next day. + expect(legs[1].scheduled_departure).toBe(Date.parse('2026-07-01T19:00:00Z')); + expect(legs[1].scheduled_arrival).toBe(Date.parse('2026-07-02T07:00:00Z')); + }); + + it('FLSCH-004: a leg without a mappable number, a known airport or a departure day is left out', () => { + const days = new Map([[1, '2026-07-01']]); + const legs = legsOfBooking( + booking({ + metadata: { + legs: [ + { from: 'FRA', to: 'HND', flight_number: 'Lufthansa', dep_day_id: 1 }, + { from: 'FRA', to: 'XYZ', flight_number: 'LH 1', dep_day_id: 1 }, + { from: 'FRA', to: 'HND', flight_number: 'LH 2', dep_day_id: 99 }, + { from: 'FRA', to: 'HND', flight_number: 'LH 3', dep_day_id: 1 }, + 'not a leg', + ], + }, + }), + [], + days, + lookup, + AIRLINES, + ); + expect(legs.map((l) => [l.leg_index, l.callsign, l.scheduled_departure, l.scheduled_arrival])).toEqual([ + [3, 'DLH3', null, null], + ]); + }); + + it('FLSCH-005: the flat metadata and the booking times stand in for missing endpoints', () => { + const legs = legsOfBooking( + booking({ + metadata: { departure_airport: 'JFK', arrival_airport: 'FRA', flight_number: 'LH0401' }, + reservation_time: '2026-03-10T17:30', + reservation_end_time: '2026-03-11T07:45', + }), + [], + NO_DAYS, + lookup, + AIRLINES, + ); + expect(legs).toHaveLength(1); + expect(legs[0].callsign).toBe('DLH401'); + expect(legs[0].service_date).toBe('2026-03-10'); + expect(legs[0].origin.name).toBe('John F Kennedy International Airport'); + expect(legs[0].scheduled_departure).toBe(Date.parse('2026-03-10T21:30:00Z')); + expect(legs[0].scheduled_arrival).toBe(Date.parse('2026-03-11T06:45:00Z')); + }); + + it('FLSCH-006: a booking day stands in for an endpoint without a date, and an arrival before departure on its own date is dropped', () => { + const days = new Map([ + [5, '2026-07-01'], + [6, '2026-07-01'], + ]); + const legs = legsOfBooking( + booking({ metadata: { flight_number: 'LH 716' }, day_id: 5, end_day_id: 6 }), + [ + endpoint({ local_time: '13:40:00' }), + endpoint({ + role: 'to', + sequence: 1, + code: 'HND', + name: 'Haneda', + lat: 35.55, + lng: 139.78, + timezone: null, + local_time: '08:15', + }), + ], + days, + lookup, + AIRLINES, + ); + expect(legs[0].service_date).toBe('2026-07-01'); + expect(legs[0].destination.timezone).toBe('Asia/Tokyo'); + expect(legs[0].scheduled_departure).toBe(Date.parse('2026-07-01T11:40:00Z')); + expect(legs[0].scheduled_arrival).toBeNull(); + }); + + it('FLSCH-007: a booking with no airports at all has no legs', () => { + expect(legsOfBooking(booking({ metadata: { flight_number: 'LH 1' } }), [], NO_DAYS, lookup, AIRLINES)).toEqual([]); + }); +}); diff --git a/server/tests/unit/nest/flight-live/flight-live.service.test.ts b/server/tests/unit/nest/flight-live/flight-live.service.test.ts new file mode 100644 index 000000000..29b5ccbc2 --- /dev/null +++ b/server/tests/unit/nest/flight-live/flight-live.service.test.ts @@ -0,0 +1,223 @@ +/** + * FlightLiveService: the trip read the REST route and the MCP tool share. + * The service comes out of FlightLiveModule over the migrated snapshot, with + * the real airports, settings and repository; only the feed is a stub, and + * the read must never ask it. + */ +import { AppSettings } from '../../../../src/db/entities/AppSettings.entity'; +import { FlightLiveStates } from '../../../../src/db/entities/FlightLiveStates.entity'; +import type { FlightLiveStateRow } from '../../../../src/db/repositories/FlightLiveStates.repository'; +import { FlightLiveFeedService } from '../../../../src/nest/flight-live/flight-live.feeds'; +import { FlightLiveModule } from '../../../../src/nest/flight-live/flight-live.module'; +import { FlightLiveService, rowToState, stateKey } from '../../../../src/nest/flight-live/flight-live.service'; +import { createSnapshotTestDb } from '../../../helpers/db-mock'; +import { makeReservation, makeReservationEndpoint } from '../../../helpers/factories/reservations'; +import { deleteRows, insertRow } from '../../../helpers/factories/rows'; +import { makeTrip } from '../../../helpers/factories/trips'; +import { makeUser } from '../../../helpers/factories/users'; +import { resetTestDb } from '../../../helpers/test-db'; +import { createTestModule, type TestModule } from '../../../helpers/test-module'; +import { MikroORM } from '@mikro-orm/core'; +import { tripFlightLiveResponseSchema } from '@trek/shared'; + +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const db = createSnapshotTestDb(); +let t: TestModule; +let service: FlightLiveService; +let orm: MikroORM; +const feed = { + lookup: vi.fn(), + attribution: vi.fn(() => 'Live positions: adsb.lol (ODbL)'), + sources: vi.fn(() => ['adsb.lol']), + canAsk: vi.fn(() => true), + waitMs: vi.fn(() => 0), +}; + +const MIN = 60_000; +/** 12:00 in Frankfurt on 1 July 2026; the flight lands in Berlin at 13:10. */ +const DEP = Date.parse('2026-07-01T10:00:00Z'); + +beforeAll(async () => { + t = await createTestModule({ + db, + imports: [FlightLiveModule], + overrides: [{ provide: FlightLiveFeedService, useValue: feed }], + }); + service = t.get(FlightLiveService); + orm = t.get(MikroORM); +}); +beforeEach(() => { + resetTestDb(db); + vi.clearAllMocks(); +}); +afterAll(async () => { + await t.close(); + db.close(); +}); + +/** A trip with one flight LH 100 from Frankfurt to Berlin on 1 July 2026. */ +async function seedFlight(flightNumber = 'LH 100') { + const { user } = await makeUser(orm); + const trip = await makeTrip(orm, user.id, { title: 'Berlin' }); + const flight = await makeReservation(orm, trip.id, { + title: 'FRA to BER', + type: 'flight', + metadata: JSON.stringify({ flight_number: flightNumber, airline: 'Lufthansa' }), + }); + await makeReservationEndpoint(orm, flight.id, { + role: 'from', + sequence: 0, + name: 'Frankfurt (FRA)', + code: 'FRA', + lat: 50.0333, + lng: 8.5706, + timezone: 'Europe/Berlin', + local_date: '2026-07-01', + local_time: '12:00', + }); + await makeReservationEndpoint(orm, flight.id, { + role: 'to', + sequence: 1, + name: 'Berlin Brandenburg (BER)', + code: 'BER', + lat: 52.3667, + lng: 13.5033, + timezone: 'Europe/Berlin', + local_date: '2026-07-01', + local_time: '13:10', + }); + return { user, trip, flight }; +} + +function storedRow(overrides: Partial = {}): FlightLiveStateRow { + return { + callsign: 'DLH100', + service_date: '2026-07-01', + origin: 'FRA', + destination: 'BER', + phase: 'airborne', + departed_at: '2026-07-01 10:12:00', + landed_at: null, + lat: 51.2, + lng: 11, + altitude_ft: 36000, + ground_speed_kt: 420, + track_deg: 60, + vertical_rate_fpm: 0, + on_ground: 0, + registration: 'D-AINA', + aircraft_type: 'A20N', + source: 'adsb.fi', + first_seen_at: '2026-07-01 09:50:00', + last_seen_at: '2026-07-01 10:30:00', + fetched_at: '2026-07-01 10:30:00', + next_poll_at: '2026-07-01 10:33:00', + fail_count: 0, + departed_notified: 1, + landed_notified: 0, + delay_notified: 0, + ...overrides, + }; +} + +describe('FlightLiveService.tripFlights', () => { + it('FLSVC-001: switched off, it answers nothing and reads no booking', async () => { + const { trip } = await seedFlight(); + await insertRow(orm, AppSettings, { key: 'flight_live_enabled', value: 'false' }); + expect(await service.isEnabled()).toBe(false); + expect(await service.tripFlights(trip.id, DEP)).toEqual({ enabled: false, attribution: null, flights: [] }); + }); + + it('FLSVC-002: a flight days ahead comes back with its schedule in UTC, and nothing is put up for a poll', async () => { + const { trip, flight } = await seedFlight(); + const answer = await service.tripFlights(trip.id, DEP - 3 * 24 * 60 * MIN); + expect(tripFlightLiveResponseSchema.parse(answer)).toEqual(answer); + expect(answer).toMatchObject({ enabled: true, attribution: 'Live positions: adsb.lol (ODbL)' }); + expect(answer.flights).toHaveLength(1); + expect(answer.flights[0]).toMatchObject({ + reservation_id: flight.id, + leg_index: 0, + callsign: 'DLH100', + flight_number: 'LH 100', + service_date: '2026-07-01', + origin: { code: 'FRA', name: 'Frankfurt', timezone: 'Europe/Berlin' }, + destination: { code: 'BER', name: 'Berlin Brandenburg', timezone: 'Europe/Berlin' }, + phase: 'scheduled', + scheduled_departure: '2026-07-01T10:00:00.000Z', + scheduled_arrival: '2026-07-01T11:10:00.000Z', + source: null, + }); + expect(service.takeRefreshRequests(DEP)).toEqual(new Set()); + expect(feed.lookup).not.toHaveBeenCalled(); + }); + + it('FLSVC-003: an in-window flight without a fresh answer is put up for the next tick, once a minute', async () => { + const { trip } = await seedFlight(); + const key = stateKey('DLH100', '2026-07-01', 'FRA', 'BER'); + await service.tripFlights(trip.id, DEP - 30 * MIN); + await service.tripFlights(trip.id, DEP - 29.5 * MIN); + expect(service.takeRefreshRequests(DEP - 29.4 * MIN)).toEqual(new Set([key])); + // Within the minute of the first ask: nothing new to hand over. + await service.tripFlights(trip.id, DEP - 29.2 * MIN); + expect(service.takeRefreshRequests(DEP - 29.1 * MIN)).toEqual(new Set()); + await service.tripFlights(trip.id, DEP - 28.5 * MIN); + expect(service.takeRefreshRequests(DEP - 28.5 * MIN)).toEqual(new Set([key])); + expect(feed.lookup).not.toHaveBeenCalled(); + }); + + it('FLSVC-004: a stored state answers on top of the schedule, with the credit of its source', async () => { + const { trip } = await seedFlight(); + await insertRow(orm, FlightLiveStates, storedRow()); + const now = Date.parse('2026-07-01T10:31:00Z'); + const answer = await service.tripFlights(trip.id, now); + expect(answer.attribution).toBe('Live positions: adsb.fi'); + expect(answer.flights[0]).toMatchObject({ + phase: 'airborne', + departed_at: '2026-07-01T10:12:00.000Z', + departure_delay_min: 12, + aircraft: { registration: 'D-AINA', type: 'A20N' }, + last_seen_at: '2026-07-01T10:30:00.000Z', + source: 'adsb.fi', + stale: false, + }); + // Fetched a minute ago: fresh enough, nothing to refresh. + expect(service.takeRefreshRequests(now)).toEqual(new Set()); + }); + + it('FLSVC-004b: a flight the poll backs off from, or has given up on, is not put up by a client', async () => { + const { trip } = await seedFlight(); + const now = Date.parse('2026-07-01T10:31:00Z'); + await insertRow(orm, FlightLiveStates, storedRow({ phase: 'unknown', fetched_at: '2026-07-01 10:20:00' })); + await service.tripFlights(trip.id, now); + expect(service.takeRefreshRequests(now)).toEqual(new Set()); + await deleteRows(orm, FlightLiveStates); + await insertRow(orm, FlightLiveStates, storedRow({ fail_count: 3, fetched_at: '2026-07-01 10:20:00' })); + await service.tripFlights(trip.id, now + 2 * MIN); + expect(service.takeRefreshRequests(now + 2 * MIN)).toEqual(new Set()); + }); + + it('FLSVC-005: a flight number that maps to no callsign is not listed', async () => { + const { trip } = await seedFlight('Lufthansa'); + expect((await service.tripFlights(trip.id, DEP)).flights).toEqual([]); + const { user } = await makeUser(orm); + const empty = await makeTrip(orm, user.id); + expect(await service.tripFlights(empty.id, DEP)).toMatchObject({ enabled: true, flights: [] }); + }); +}); + +describe('rowToState', () => { + it('FLSVC-006: reads the stored text back as instants, and an unknown phase or source falls back', () => { + const state = rowToState(storedRow()); + expect(state).toMatchObject({ + phase: 'airborne', + departed_at: Date.parse('2026-07-01T10:12:00Z'), + source: 'adsb.fi', + departed_notified: true, + landed_notified: false, + position: { lat: 51.2, lng: 11, on_ground: false }, + }); + const odd = rowToState(storedRow({ phase: 'cruising', source: 'radar', lat: null, on_ground: 1 })); + expect(odd).toMatchObject({ phase: 'scheduled', source: null, position: null }); + }); +}); diff --git a/server/tests/unit/nest/managed-keys.test.ts b/server/tests/unit/nest/managed-keys.test.ts index 92880ee5b..9aee86f36 100644 --- a/server/tests/unit/nest/managed-keys.test.ts +++ b/server/tests/unit/nest/managed-keys.test.ts @@ -15,12 +15,14 @@ import { MANAGED_LOCKED_SETTING_KEYS, MANAGED_LOCKED_PROFILE_KEYS, MANAGED_CUSTOMER_KEYS, + envLockedKeys, isManagedLockedKey, splitManagedKeys, + withEnvLocks, } from '../../../src/nest/common/managed'; import { DEFAULTABLE_USER_SETTING_KEYS } from '../../../src/nest/settings/settings.service'; -import { describe, it, expect } from 'vitest'; +import { afterEach, describe, it, expect, vi } from 'vitest'; const SOURCE_KEYS: string[] = [...ADMIN_FORM_SETTING_KEYS, ...DEFAULTABLE_USER_SETTING_KEYS]; const locked = new Set(MANAGED_LOCKED_SETTING_KEYS); @@ -129,3 +131,28 @@ describe('splitManagedKeys', () => { expect(blocked).toEqual([]); }); }); + +describe('env-held admin switches', () => { + afterEach(() => vi.unstubAllEnvs()); + + it('MANAGED-ENV-001: unset, the form answers the stored value and a save writes it', () => { + vi.stubEnv('FLIGHT_LIVE_ENABLED', ''); + expect(withEnvLocks({ flight_live_enabled: 'true', require_mfa: 'false' })).toEqual({ + flight_live_enabled: 'true', + require_mfa: 'false', + }); + expect(envLockedKeys({ flight_live_enabled: 'false' })).toEqual([]); + }); + + it('MANAGED-ENV-002: set, the form shows the variable and env_override_flight_live_enabled, and a save skips it', () => { + vi.stubEnv('FLIGHT_LIVE_ENABLED', 'off'); + expect(withEnvLocks({ flight_live_enabled: 'true', require_mfa: 'false' })).toEqual({ + flight_live_enabled: 'false', + env_override_flight_live_enabled: 'false', + require_mfa: 'false', + }); + expect(withEnvLocks({})).toEqual({ flight_live_enabled: 'false', env_override_flight_live_enabled: 'false' }); + expect(envLockedKeys({ flight_live_enabled: 'true', require_mfa: 'true' })).toEqual(['flight_live_enabled']); + expect(envLockedKeys({ require_mfa: 'true' })).toEqual([]); + }); +}); diff --git a/server/tests/unit/nest/notification-preferences.service.test.ts b/server/tests/unit/nest/notification-preferences.service.test.ts index 7de7544c0..c10ce7e95 100644 --- a/server/tests/unit/nest/notification-preferences.service.test.ts +++ b/server/tests/unit/nest/notification-preferences.service.test.ts @@ -156,14 +156,15 @@ describe('getPreferencesMatrix', () => { expect(event_types).not.toContain('version_available'); // +1 for plugin_notification: users can mute host-mediated plugin notifications. // +1 for wanderer_source_hint: the hints of the user's own wanderer watch (WAN-02). - expect(event_types.length).toBe(13); + // +3 for flight_departed, flight_delayed and flight_landed (live flight status, on by default). + expect(event_types.length).toBe(16); }); it('NPREF-005 — user scope excludes version_available for everyone including admins', async () => { const { user } = createAdmin(testDb); const { event_types } = await getPreferencesMatrix(user.id, 'admin', 'user'); expect(event_types).not.toContain('version_available'); - expect(event_types.length).toBe(13); + expect(event_types.length).toBe(16); }); it('NPREF-005b — admin scope returns the admin-scoped events', async () => { @@ -481,4 +482,13 @@ describe('listed events follow the features that are on', () => { Object.assign(features, all); } }); + + it('NPREF-LIST-002: the flight events are listed only while live flight status is on', async () => { + const flights = ['flight_departed', 'flight_delayed', 'flight_landed']; + const { user } = createUser(testDb); + expect((await svc.getPreferencesMatrix(user.id, 'user')).event_types).toEqual(expect.arrayContaining(flights)); + setAppSetting(testDb, 'flight_live_enabled', 'false'); + const { event_types } = await svc.getPreferencesMatrix(user.id, 'user'); + for (const hidden of flights) expect(event_types).not.toContain(hidden); + }); }); diff --git a/server/tests/unit/nest/notifications.service.test.ts b/server/tests/unit/nest/notifications.service.test.ts index e207c8b9c..8ac812d03 100644 --- a/server/tests/unit/nest/notifications.service.test.ts +++ b/server/tests/unit/nest/notifications.service.test.ts @@ -608,6 +608,30 @@ describe('send() — in-app notification content', () => { ); expect(notifs.every((n) => n.title_key === 'notif.wanderer_source_hint.title')).toBe(true); }); + + it('NOTIF-FLT-001 the three live flight notices lead to their trip, and nowhere without one', async () => { + const { user } = createUser(testDb); + setNotificationChannels(testDb, 'none'); + const params = { flight: 'LH 716', from: 'FRA', to: 'HND', trip: 'Japan', delay: '25' }; + const events = ['flight_departed', 'flight_delayed', 'flight_landed'] as const; + await events.reduce(async (previous, event) => { + await previous; + await send({ event, actorId: null, scope: 'user', targetId: user.id, params: { ...params, tripId: '7' } }); + await send({ event, actorId: null, scope: 'user', targetId: user.id, params }); + }, Promise.resolve()); + + const notifs = await getInAppNotifications(user.id); + expect(notifs.map((n) => [n.title_key, n.text_key, n.navigate_target])).toEqual( + expect.arrayContaining([ + ['notif.flight_departed.title', 'notif.flight_departed.text', '/trips/7'], + ['notif.flight_delayed.title', 'notif.flight_delayed.text', '/trips/7'], + ['notif.flight_landed.title', 'notif.flight_landed.text', '/trips/7'], + ['notif.flight_departed.title', 'notif.flight_departed.text', null], + ['notif.flight_delayed.title', 'notif.flight_delayed.text', null], + ['notif.flight_landed.title', 'notif.flight_landed.text', null], + ]), + ); + }); }); // ───────────────────────────────────────────────────────────────────────────── diff --git a/server/vitest.config.ts b/server/vitest.config.ts index 8958a7646..45325825c 100644 --- a/server/vitest.config.ts +++ b/server/vitest.config.ts @@ -116,6 +116,8 @@ export default defineConfig({ // scripts/coverage-thresholds.mjs after the first full run. 'src/nest/ferratas/**/*.ts': { statements: 80, branches: 80, functions: 80, lines: 80 }, 'src/nest/files/**/*.ts': { statements: 97, branches: 95, functions: 99, lines: 98 }, + // New with live flight status; at the floor until the first full run regenerates it. + 'src/nest/flight-live/**/*.ts': { statements: 80, branches: 80, functions: 80, lines: 80 }, 'src/nest/geo/**/*.ts': { statements: 99, branches: 95, functions: 99, lines: 99 }, // google-quota, managed, mcp-shared, mcp-transport, public-api, // receipt-scan, route-usage, scheduling, school-holidays, tokens and tours diff --git a/shared/scripts/i18n-untranslated.mjs b/shared/scripts/i18n-untranslated.mjs index 259ff4070..4506733ec 100644 --- a/shared/scripts/i18n-untranslated.mjs +++ b/shared/scripts/i18n-untranslated.mjs @@ -83,7 +83,7 @@ export const BRAND_NAMES = [ ]; /** Unit symbols, short codes rather than words: "{count} km", "{minutes} min". */ -export const UNITS = ['km', 'mi', 'ft', 'min']; +export const UNITS = ['km', 'mi', 'ft', 'min', 'mph']; /** * Key cap labels, printed on the keyboard in Latin letters whatever the diff --git a/shared/src/flight-live/flight-live.schema.spec.ts b/shared/src/flight-live/flight-live.schema.spec.ts new file mode 100644 index 000000000..ce5cc6c68 --- /dev/null +++ b/shared/src/flight-live/flight-live.schema.spec.ts @@ -0,0 +1,51 @@ +import { flightLiveLegSchema, tripFlightLiveResponseSchema } from './flight-live.schema'; + +import { describe, it, expect } from 'vitest'; + +const leg = { + reservation_id: 6, + leg_index: 0, + callsign: 'DLH400', + flight_number: 'LH 400', + service_date: '2026-06-11', + origin: { code: 'FRA', name: 'Frankfurt', lat: 50.03, lng: 8.57, timezone: 'Europe/Berlin' }, + destination: { code: 'JFK', name: 'New York JFK', lat: 40.64, lng: -73.78, timezone: 'America/New_York' }, + phase: 'scheduled', + scheduled_departure: '2026-06-11T11:50:00.000Z', + scheduled_arrival: '2026-06-11T20:35:00.000Z', + departed_at: null, + landed_at: null, + estimated_arrival: null, + departure_delay_min: null, + arrival_delay_min: null, + progress: null, + distance_total_km: 6200, + distance_remaining_km: null, + position: null, + aircraft: null, + last_seen_at: null, + fetched_at: null, + stale: false, + source: null, +}; + +describe('flightLiveLegSchema', () => { + it('accepts a leg that has not shown up yet', () => { + expect(flightLiveLegSchema.parse(leg).phase).toBe('scheduled'); + }); + + it('rejects a progress outside 0 to 1', () => { + expect(flightLiveLegSchema.safeParse({ ...leg, progress: 1.2 }).success).toBe(false); + }); + + it('rejects an unknown phase', () => { + expect(flightLiveLegSchema.safeParse({ ...leg, phase: 'boarding' }).success).toBe(false); + }); +}); + +describe('tripFlightLiveResponseSchema', () => { + it('carries the switch, the credit line and the legs', () => { + const parsed = tripFlightLiveResponseSchema.parse({ enabled: true, attribution: 'adsb.lol', flights: [leg] }); + expect(parsed.flights).toHaveLength(1); + }); +}); diff --git a/shared/src/flight-live/flight-live.schema.ts b/shared/src/flight-live/flight-live.schema.ts new file mode 100644 index 000000000..d943989d4 --- /dev/null +++ b/shared/src/flight-live/flight-live.schema.ts @@ -0,0 +1,105 @@ +import { z } from 'zod'; + +/** + * Live state of a booked flight leg, read from open ADS-B feeds without any key + * (adsb.lol, adsb.fi). These feeds only know where an aircraft is, so everything + * an airline would publish (gate, terminal, belt, cancellation) is out of reach; + * the delays and the arrival estimate are worked out from the positions and are + * marked as estimates in the UI. + * + * Times cross the wire as ISO 8601 UTC strings. A leg is addressed by its + * reservation and its index in the leg chain (client/src/utils/flightLegs.ts), + * so a multi-leg booking gets one state per segment. + */ + +/** + * - scheduled: departure is still ahead and the aircraft has not shown up. + * - taxiing: the aircraft is seen on the ground at the departure airport. + * - airborne: in the air. + * - landed: seen on the ground at, or lost low over, the destination. + * - unknown: inside the watch window but the callsign never showed up (an + * airline flying under another callsign, a codeshare, no coverage). + */ +export const flightLivePhaseSchema = z.enum(['scheduled', 'taxiing', 'airborne', 'landed', 'unknown']); +export type FlightLivePhase = z.infer; + +export const flightLiveSourceSchema = z.enum(['adsb.lol', 'adsb.fi']); +export type FlightLiveSource = z.infer; + +export const flightLivePositionSchema = z.object({ + lat: z.number(), + lng: z.number(), + altitude_ft: z.number().nullable(), + ground_speed_kt: z.number().nullable(), + /** Degrees clockwise from north, for turning the aircraft icon. */ + track_deg: z.number().nullable(), + vertical_rate_fpm: z.number().nullable(), + on_ground: z.boolean(), +}); +export type FlightLivePosition = z.infer; + +export const flightLiveAirportSchema = z.object({ + /** IATA code, e.g. "FRA". */ + code: z.string(), + name: z.string().nullable(), + lat: z.number(), + lng: z.number(), + /** IANA zone the schedule is local to, e.g. "Europe/Berlin". */ + timezone: z.string().nullable(), +}); +export type FlightLiveAirport = z.infer; + +export const flightLiveLegSchema = z.object({ + reservation_id: z.number().int(), + leg_index: z.number().int().min(0), + /** ICAO callsign as transmitted, e.g. "DLH400". */ + callsign: z.string(), + /** The booked flight number as the user knows it, e.g. "LH 400". */ + flight_number: z.string(), + /** Local departure date of the leg, YYYY-MM-DD. */ + service_date: z.string(), + /** Both ends of the leg, so the client can show local times and draw the arc. */ + origin: flightLiveAirportSchema, + destination: flightLiveAirportSchema, + phase: flightLivePhaseSchema, + scheduled_departure: z.string().nullable(), + scheduled_arrival: z.string().nullable(), + /** Observed wheels-up, or null while unknown. */ + departed_at: z.string().nullable(), + /** Observed touchdown, or null while unknown. */ + landed_at: z.string().nullable(), + /** Arrival estimate from remaining distance and ground speed; null on the ground. */ + estimated_arrival: z.string().nullable(), + /** Minutes late (negative when early) against the schedule; null until observed or estimated. */ + departure_delay_min: z.number().int().nullable(), + arrival_delay_min: z.number().int().nullable(), + /** Share of the great-circle distance flown, 0 to 1. */ + progress: z.number().min(0).max(1).nullable(), + distance_total_km: z.number().nullable(), + distance_remaining_km: z.number().nullable(), + position: flightLivePositionSchema.nullable(), + aircraft: z + .object({ + registration: z.string().nullable(), + /** ICAO type designator, e.g. "A20N". */ + type: z.string().nullable(), + }) + .nullable(), + /** When the feed last heard the aircraft. */ + last_seen_at: z.string().nullable(), + /** When TREK last asked the feed. */ + fetched_at: z.string().nullable(), + /** True when the last position is too old to trust, e.g. over an ocean. */ + stale: z.boolean(), + source: flightLiveSourceSchema.nullable(), +}); +export type FlightLiveLeg = z.infer; + +export const tripFlightLiveResponseSchema = z.object({ + /** False when the admin switched live flight data off; the client then hides it. */ + enabled: z.boolean(), + /** Credit line the feed's terms ask for, shown next to live data. */ + attribution: z.string().nullable(), + flights: z.array(flightLiveLegSchema), +}); +export type TripFlightLiveResponse = z.infer; diff --git a/shared/src/i18n/ar/externalNotifications.ts b/shared/src/i18n/ar/externalNotifications.ts index a8596dc0e..d46a1a164 100644 --- a/shared/src/i18n/ar/externalNotifications.ts +++ b/shared/src/i18n/ar/externalNotifications.ts @@ -17,6 +17,18 @@ const ar: NotificationLocale = { title: `حجز جديد: ${p.booking}`, body: `${p.actor} أضاف حجز "${p.booking}" (${p.type}) إلى "${p.trip}".`, }), + flight_departed: (p) => ({ + title: `أقلعت الرحلة ${p.flight}`, + body: `الرحلة ${p.flight} من ${p.from} إلى ${p.to} في الجو.`, + }), + flight_delayed: (p) => ({ + title: `الرحلة ${p.flight} متأخرة`, + body: `الرحلة ${p.flight} من ${p.from} إلى ${p.to} متأخرة نحو ${p.delay} دقيقة عن الموعد.`, + }), + flight_landed: (p) => ({ + title: `هبطت الرحلة ${p.flight}`, + body: `هبطت الرحلة ${p.flight} القادمة من ${p.from} في ${p.to}.`, + }), trip_reminder: (p) => ({ title: `تذكير: ${p.trip}`, body: `رحلتك "${p.trip}" تقترب!`, diff --git a/shared/src/i18n/ar/notif.ts b/shared/src/i18n/ar/notif.ts index 8f3aa1a29..2bf8a97a3 100644 --- a/shared/src/i18n/ar/notif.ts +++ b/shared/src/i18n/ar/notif.ts @@ -9,6 +9,12 @@ const notif: TranslationStrings = { 'notif.trip_invite.text': '{actor} دعاك إلى {trip}', 'notif.booking_change.title': 'تم تحديث الحجز', 'notif.booking_change.text': '{actor} حدّث حجزاً في {trip}', + 'notif.flight_departed.title': 'أقلعت الرحلة', + 'notif.flight_departed.text': 'الرحلة {flight} من {from} إلى {to} في الجو', + 'notif.flight_delayed.title': 'الرحلة متأخرة', + 'notif.flight_delayed.text': 'الرحلة {flight} من {from} إلى {to} متأخرة نحو {delay} دقيقة', + 'notif.flight_landed.title': 'هبطت الرحلة', + 'notif.flight_landed.text': 'هبطت الرحلة {flight} القادمة من {from} في {to}', 'notif.trip_reminder.title': 'تذكير بالرحلة', 'notif.trip_reminder.text': 'رحلتك {trip} تقترب!', 'notif.todo_due.title': 'مهمة مستحقة', diff --git a/shared/src/i18n/ar/settings.ts b/shared/src/i18n/ar/settings.ts index 2ab45fd04..ebecdf98e 100644 --- a/shared/src/i18n/ar/settings.ts +++ b/shared/src/i18n/ar/settings.ts @@ -106,6 +106,9 @@ const settings: TranslationStrings = { 'settings.notifications': 'الإشعارات', 'settings.notifyTripInvite': 'دعوات الرحلات', 'settings.notifyBookingChange': 'تغييرات الحجز', + 'settings.notifyFlightDeparted': 'إقلاع الرحلات', + 'settings.notifyFlightDelayed': 'تأخر الرحلات', + 'settings.notifyFlightLanded': 'هبوط الرحلات', 'settings.notifyTripReminder': 'تذكيرات الرحلات', 'settings.notifyTodoDue': 'مهمة مستحقة', 'settings.notifyVacayInvite': 'دعوات دمج الإجازات', diff --git a/shared/src/i18n/az/externalNotifications.ts b/shared/src/i18n/az/externalNotifications.ts index bf147fc79..0f4fb001c 100644 --- a/shared/src/i18n/az/externalNotifications.ts +++ b/shared/src/i18n/az/externalNotifications.ts @@ -17,6 +17,18 @@ const az: NotificationLocale = { title: `Yeni rezervasiya: ${p.booking}`, body: `${p.actor} “${p.trip}” səyahətinə yeni ${p.type} rezervasiyası — “${p.booking}” əlavə etdi.`, }), + flight_departed: (p) => ({ + title: `${p.flight} havaya qalxdı`, + body: `${p.from} hava limanından ${p.to} hava limanına ${p.flight} reysi havadadır.`, + }), + flight_delayed: (p) => ({ + title: `${p.flight} gecikir`, + body: `${p.from} hava limanından ${p.to} hava limanına ${p.flight} reysi təxminən ${p.delay} dəqiqə gecikir.`, + }), + flight_landed: (p) => ({ + title: `${p.flight} endi`, + body: `${p.from} hava limanından gələn ${p.flight} reysi ${p.to} hava limanına endi.`, + }), trip_reminder: (p) => ({ title: `Səyahət xatırlatması: ${p.trip}`, body: `“${p.trip}” səyahətiniz yaxınlaşır!`, diff --git a/shared/src/i18n/az/notif.ts b/shared/src/i18n/az/notif.ts index 72cae0bb6..20130e006 100644 --- a/shared/src/i18n/az/notif.ts +++ b/shared/src/i18n/az/notif.ts @@ -9,6 +9,12 @@ const notif: TranslationStrings = { 'notif.trip_invite.text': '{actor} sizi {trip} səyahətinə dəvət etdi', 'notif.booking_change.title': 'Rezervasiya yeniləndi', 'notif.booking_change.text': '{actor} {trip} səyahətindəki rezervasiyanı yenilədi', + 'notif.flight_departed.title': 'Reys havaya qalxdı', + 'notif.flight_departed.text': '{from} hava limanından {to} hava limanına {flight} reysi havadadır', + 'notif.flight_delayed.title': 'Reys gecikir', + 'notif.flight_delayed.text': '{from} hava limanından {to} hava limanına {flight} reysi təxminən {delay} dəq gecikir', + 'notif.flight_landed.title': 'Reys endi', + 'notif.flight_landed.text': '{from} hava limanından gələn {flight} reysi {to} hava limanına endi', 'notif.trip_reminder.title': 'Səyahət xatırlatması', 'notif.trip_reminder.text': '{trip} səyahətiniz yaxınlaşır!', 'notif.todo_due.title': 'Tapşırığın son tarixi', diff --git a/shared/src/i18n/az/settings.ts b/shared/src/i18n/az/settings.ts index dd7fbc8e1..5eb3a68cd 100644 --- a/shared/src/i18n/az/settings.ts +++ b/shared/src/i18n/az/settings.ts @@ -141,6 +141,9 @@ const settings: TranslationStrings = { 'settings.notifications': 'Bildirişlər', 'settings.notifyTripInvite': 'Səyahət dəvətləri', 'settings.notifyBookingChange': 'Rezervasiya dəyişiklikləri', + 'settings.notifyFlightDeparted': 'Reyslərin uçuşu', + 'settings.notifyFlightDelayed': 'Reyslərin gecikməsi', + 'settings.notifyFlightLanded': 'Reyslərin enişi', 'settings.notifyTripReminder': 'Səyahət xatırlatmaları', 'settings.notifyTodoDue': 'Son tarixi yaxınlaşan tapşırıqlar', 'settings.notifyVacayInvite': 'Vacay birləşdirmə dəvətləri', diff --git a/shared/src/i18n/br/externalNotifications.ts b/shared/src/i18n/br/externalNotifications.ts index 353cdb916..819ee8c9e 100644 --- a/shared/src/i18n/br/externalNotifications.ts +++ b/shared/src/i18n/br/externalNotifications.ts @@ -17,6 +17,18 @@ const br: NotificationLocale = { title: `Nova reserva: ${p.booking}`, body: `${p.actor} adicionou uma reserva "${p.booking}" (${p.type}) em "${p.trip}".`, }), + flight_departed: (p) => ({ + title: `${p.flight} decolou`, + body: `${p.flight} de ${p.from} para ${p.to} está no ar.`, + }), + flight_delayed: (p) => ({ + title: `${p.flight} está atrasado`, + body: `${p.flight} de ${p.from} para ${p.to} está cerca de ${p.delay} minutos atrasado.`, + }), + flight_landed: (p) => ({ + title: `${p.flight} pousou`, + body: `${p.flight} vindo de ${p.from} pousou em ${p.to}.`, + }), trip_reminder: (p) => ({ title: `Lembrete: ${p.trip}`, body: `Sua viagem "${p.trip}" está chegando!`, diff --git a/shared/src/i18n/br/notif.ts b/shared/src/i18n/br/notif.ts index 21e4f9a83..b4b067077 100644 --- a/shared/src/i18n/br/notif.ts +++ b/shared/src/i18n/br/notif.ts @@ -9,6 +9,12 @@ const notif: TranslationStrings = { 'notif.trip_invite.text': '{actor} convidou você para {trip}', 'notif.booking_change.title': 'Reserva atualizada', 'notif.booking_change.text': '{actor} atualizou uma reserva em {trip}', + 'notif.flight_departed.title': 'Voo decolou', + 'notif.flight_departed.text': '{flight} de {from} para {to} está no ar', + 'notif.flight_delayed.title': 'Voo atrasado', + 'notif.flight_delayed.text': '{flight} de {from} para {to} está cerca de {delay} min atrasado', + 'notif.flight_landed.title': 'Voo pousou', + 'notif.flight_landed.text': '{flight} vindo de {from} pousou em {to}', 'notif.trip_reminder.title': 'Lembrete de viagem', 'notif.trip_reminder.text': 'Sua viagem {trip} está chegando!', 'notif.todo_due.title': 'Tarefa com vencimento', diff --git a/shared/src/i18n/br/settings.ts b/shared/src/i18n/br/settings.ts index 849f61593..721110dc6 100644 --- a/shared/src/i18n/br/settings.ts +++ b/shared/src/i18n/br/settings.ts @@ -113,6 +113,9 @@ const settings: TranslationStrings = { 'settings.notifications': 'Notificações', 'settings.notifyTripInvite': 'Convites de viagem', 'settings.notifyBookingChange': 'Alterações de reserva', + 'settings.notifyFlightDeparted': 'Decolagens', + 'settings.notifyFlightDelayed': 'Atrasos de voos', + 'settings.notifyFlightLanded': 'Pousos', 'settings.notifyTripReminder': 'Lembretes de viagem', 'settings.notifyTodoDue': 'Tarefa com vencimento', 'settings.notifyVacayInvite': 'Convites de fusão Vacay', diff --git a/shared/src/i18n/ca/externalNotifications.ts b/shared/src/i18n/ca/externalNotifications.ts index 67cfc72e6..dc55272b5 100644 --- a/shared/src/i18n/ca/externalNotifications.ts +++ b/shared/src/i18n/ca/externalNotifications.ts @@ -17,6 +17,18 @@ const ca: NotificationLocale = { title: `Reserva nova: ${p.booking}`, body: `${p.actor} va afegir una reserva "${p.booking}" (${p.type}) a "${p.trip}".`, }), + flight_departed: (p) => ({ + title: `${p.flight} ha enlairat`, + body: `${p.flight} de ${p.from} a ${p.to} és a l'aire.`, + }), + flight_delayed: (p) => ({ + title: `${p.flight} va amb retard`, + body: `${p.flight} de ${p.from} a ${p.to} porta uns ${p.delay} minuts de retard.`, + }), + flight_landed: (p) => ({ + title: `${p.flight} ha aterrat`, + body: `${p.flight} des de ${p.from} ha aterrat a ${p.to}.`, + }), trip_reminder: (p) => ({ title: `Recordatori: ${p.trip}`, body: `El teu viatge "${p.trip}" s'apropa!`, diff --git a/shared/src/i18n/ca/notif.ts b/shared/src/i18n/ca/notif.ts index bb7f894bb..6312d858b 100644 --- a/shared/src/i18n/ca/notif.ts +++ b/shared/src/i18n/ca/notif.ts @@ -9,6 +9,12 @@ const notif: TranslationStrings = { 'notif.trip_invite.text': '{actor} et va convidar a {trip}', 'notif.booking_change.title': 'Reserva actualitzada', 'notif.booking_change.text': '{actor} va actualitzar una reserva a {trip}', + 'notif.flight_departed.title': 'Vol enlairat', + 'notif.flight_departed.text': "{flight} de {from} a {to} és a l'aire", + 'notif.flight_delayed.title': 'Vol amb retard', + 'notif.flight_delayed.text': '{flight} de {from} a {to} porta uns {delay} min de retard', + 'notif.flight_landed.title': 'Vol aterrat', + 'notif.flight_landed.text': '{flight} des de {from} ha aterrat a {to}', 'notif.trip_reminder.title': 'Recordatori del viatge', 'notif.trip_reminder.text': "El teu viatge {trip} s'apropa!", 'notif.todo_due.title': 'Tasca pendent', diff --git a/shared/src/i18n/ca/settings.ts b/shared/src/i18n/ca/settings.ts index 67a1b18a4..9d8e7ebfc 100644 --- a/shared/src/i18n/ca/settings.ts +++ b/shared/src/i18n/ca/settings.ts @@ -94,6 +94,9 @@ const settings: TranslationStrings = { 'settings.notifications': 'Notificacions', 'settings.notifyTripInvite': 'Invitacions de viatge', 'settings.notifyBookingChange': 'Canvis en reserves', + 'settings.notifyFlightDeparted': 'Enlairaments', + 'settings.notifyFlightDelayed': 'Retards de vols', + 'settings.notifyFlightLanded': 'Aterratges', 'settings.notifyTripReminder': 'Recordatoris de viatge', 'settings.notifyTodoDue': 'Tasca propera', 'settings.notifyVacayInvite': 'Invitacions de fusió Vacay', diff --git a/shared/src/i18n/cs/externalNotifications.ts b/shared/src/i18n/cs/externalNotifications.ts index fd923b658..b00df273c 100644 --- a/shared/src/i18n/cs/externalNotifications.ts +++ b/shared/src/i18n/cs/externalNotifications.ts @@ -17,6 +17,18 @@ const cs: NotificationLocale = { title: `Nová rezervace: ${p.booking}`, body: `${p.actor} přidal rezervaci "${p.booking}" (${p.type}) k "${p.trip}".`, }), + flight_departed: (p) => ({ + title: `${p.flight} odletěl`, + body: `${p.flight} z ${p.from} do ${p.to} je ve vzduchu.`, + }), + flight_delayed: (p) => ({ + title: `${p.flight} má zpoždění`, + body: `${p.flight} z ${p.from} do ${p.to} má zhruba ${p.delay} minut zpoždění.`, + }), + flight_landed: (p) => ({ + title: `${p.flight} přistál`, + body: `${p.flight} z ${p.from} přistál v ${p.to}.`, + }), trip_reminder: (p) => ({ title: `Připomínka výletu: ${p.trip}`, body: `Váš výlet "${p.trip}" se blíží!`, diff --git a/shared/src/i18n/cs/notif.ts b/shared/src/i18n/cs/notif.ts index 8ee8c5d18..3e2525d11 100644 --- a/shared/src/i18n/cs/notif.ts +++ b/shared/src/i18n/cs/notif.ts @@ -9,6 +9,12 @@ const notif: TranslationStrings = { 'notif.trip_invite.text': '{actor} vás pozval na {trip}', 'notif.booking_change.title': 'Rezervace aktualizována', 'notif.booking_change.text': '{actor} aktualizoval rezervaci v {trip}', + 'notif.flight_departed.title': 'Let odletěl', + 'notif.flight_departed.text': '{flight} z {from} do {to} je ve vzduchu', + 'notif.flight_delayed.title': 'Let má zpoždění', + 'notif.flight_delayed.text': '{flight} z {from} do {to} má zhruba {delay} min zpoždění', + 'notif.flight_landed.title': 'Let přistál', + 'notif.flight_landed.text': '{flight} z {from} přistál v {to}', 'notif.trip_reminder.title': 'Připomínka výletu', 'notif.trip_reminder.text': 'Váš výlet {trip} se blíží!', 'notif.todo_due.title': 'Úkol se blíží', diff --git a/shared/src/i18n/cs/settings.ts b/shared/src/i18n/cs/settings.ts index ec4c8746f..5a6e99ae3 100644 --- a/shared/src/i18n/cs/settings.ts +++ b/shared/src/i18n/cs/settings.ts @@ -108,6 +108,9 @@ const settings: TranslationStrings = { 'settings.notifications': 'Oznámení', 'settings.notifyTripInvite': 'Pozvánky na cesty', 'settings.notifyBookingChange': 'Změny rezervací', + 'settings.notifyFlightDeparted': 'Odlety', + 'settings.notifyFlightDelayed': 'Zpoždění letů', + 'settings.notifyFlightLanded': 'Přistání', 'settings.notifyTripReminder': 'Připomínky cest', 'settings.notifyTodoDue': 'Úkol se blíží', 'settings.notifyVacayInvite': 'Pozvánky k propojení Vacay', diff --git a/shared/src/i18n/de/externalNotifications.ts b/shared/src/i18n/de/externalNotifications.ts index b063e75f6..6005d992b 100644 --- a/shared/src/i18n/de/externalNotifications.ts +++ b/shared/src/i18n/de/externalNotifications.ts @@ -17,6 +17,18 @@ const de: NotificationLocale = { title: `Neue Buchung: ${p.booking}`, body: `${p.actor} hat eine neue Buchung "${p.booking}" (${p.type}) zu "${p.trip}" hinzugefügt.`, }), + flight_departed: (p) => ({ + title: `${p.flight} ist gestartet`, + body: `${p.flight} von ${p.from} nach ${p.to} ist in der Luft.`, + }), + flight_delayed: (p) => ({ + title: `${p.flight} hat Verspätung`, + body: `${p.flight} von ${p.from} nach ${p.to} liegt etwa ${p.delay} Minuten hinter dem Flugplan.`, + }), + flight_landed: (p) => ({ + title: `${p.flight} ist gelandet`, + body: `${p.flight} aus ${p.from} ist in ${p.to} gelandet.`, + }), trip_reminder: (p) => ({ title: `Reiseerinnerung: ${p.trip}`, body: `Deine Reise "${p.trip}" steht bald an!`, diff --git a/shared/src/i18n/de/notif.ts b/shared/src/i18n/de/notif.ts index 24f229883..0861716fb 100644 --- a/shared/src/i18n/de/notif.ts +++ b/shared/src/i18n/de/notif.ts @@ -9,6 +9,12 @@ const notif: TranslationStrings = { 'notif.trip_invite.text': '{actor} hat dich zu {trip} eingeladen', 'notif.booking_change.title': 'Buchung aktualisiert', 'notif.booking_change.text': '{actor} hat eine Buchung in {trip} aktualisiert', + 'notif.flight_departed.title': 'Flug gestartet', + 'notif.flight_departed.text': '{flight} von {from} nach {to} ist in der Luft', + 'notif.flight_delayed.title': 'Flug verspätet', + 'notif.flight_delayed.text': '{flight} von {from} nach {to} hat etwa {delay} Min. Verspätung', + 'notif.flight_landed.title': 'Flug gelandet', + 'notif.flight_landed.text': '{flight} aus {from} ist in {to} gelandet', 'notif.trip_reminder.title': 'Reiseerinnerung', 'notif.trip_reminder.text': 'Deine Reise {trip} steht bald an!', 'notif.todo_due.title': 'Aufgabe fällig', diff --git a/shared/src/i18n/de/settings.ts b/shared/src/i18n/de/settings.ts index cf9b30035..cb798c059 100644 --- a/shared/src/i18n/de/settings.ts +++ b/shared/src/i18n/de/settings.ts @@ -113,6 +113,9 @@ const settings: TranslationStrings = { 'settings.notifications': 'Mitteilungen', 'settings.notifyTripInvite': 'Trip-Einladungen', 'settings.notifyBookingChange': 'Buchungsänderungen', + 'settings.notifyFlightDeparted': 'Abflüge', + 'settings.notifyFlightDelayed': 'Flugverspätungen', + 'settings.notifyFlightLanded': 'Landungen', 'settings.notifyTripReminder': 'Trip-Erinnerungen', 'settings.notifyTodoDue': 'Aufgabe bald fällig', 'settings.notifyVacayInvite': 'Vacay Fusion-Einladungen', diff --git a/shared/src/i18n/en/externalNotifications.ts b/shared/src/i18n/en/externalNotifications.ts index b2a84c04d..a1f42608e 100644 --- a/shared/src/i18n/en/externalNotifications.ts +++ b/shared/src/i18n/en/externalNotifications.ts @@ -17,6 +17,18 @@ const en: NotificationLocale = { title: `New booking: ${p.booking}`, body: `${p.actor} added a new ${p.type} "${p.booking}" to "${p.trip}".`, }), + flight_departed: (p) => ({ + title: `${p.flight} departed`, + body: `${p.flight} from ${p.from} to ${p.to} is in the air.`, + }), + flight_delayed: (p) => ({ + title: `${p.flight} is running late`, + body: `${p.flight} from ${p.from} to ${p.to} is running about ${p.delay} minutes behind schedule.`, + }), + flight_landed: (p) => ({ + title: `${p.flight} has landed`, + body: `${p.flight} from ${p.from} has landed in ${p.to}.`, + }), trip_reminder: (p) => ({ title: `Trip reminder: ${p.trip}`, body: `Your trip "${p.trip}" is coming up soon!`, diff --git a/shared/src/i18n/en/notif.ts b/shared/src/i18n/en/notif.ts index 237f1e027..2395ae0c1 100644 --- a/shared/src/i18n/en/notif.ts +++ b/shared/src/i18n/en/notif.ts @@ -9,6 +9,12 @@ const notif: TranslationStrings = { 'notif.trip_invite.text': '{actor} invited you to {trip}', 'notif.booking_change.title': 'Booking Updated', 'notif.booking_change.text': '{actor} updated a booking in {trip}', + 'notif.flight_departed.title': 'Flight departed', + 'notif.flight_departed.text': '{flight} from {from} to {to} is in the air', + 'notif.flight_delayed.title': 'Flight running late', + 'notif.flight_delayed.text': '{flight} from {from} to {to} is about {delay} min behind schedule', + 'notif.flight_landed.title': 'Flight landed', + 'notif.flight_landed.text': '{flight} from {from} has landed in {to}', 'notif.trip_reminder.title': 'Trip Reminder', 'notif.trip_reminder.text': 'Your trip {trip} is coming up soon!', 'notif.todo_due.title': 'To-do due', diff --git a/shared/src/i18n/en/settings.ts b/shared/src/i18n/en/settings.ts index ee2e9249f..3d7195da2 100644 --- a/shared/src/i18n/en/settings.ts +++ b/shared/src/i18n/en/settings.ts @@ -122,6 +122,9 @@ const settings: TranslationStrings = { 'settings.notifications': 'Notifications', 'settings.notifyTripInvite': 'Trip invitations', 'settings.notifyBookingChange': 'Booking changes', + 'settings.notifyFlightDeparted': 'Flight departures', + 'settings.notifyFlightDelayed': 'Flight delays', + 'settings.notifyFlightLanded': 'Flight landings', 'settings.notifyTripReminder': 'Trip reminders', 'settings.notifyTodoDue': 'Todo due soon', 'settings.notifyVacayInvite': 'Vacay fusion invitations', diff --git a/shared/src/i18n/es/externalNotifications.ts b/shared/src/i18n/es/externalNotifications.ts index 28296d588..7cd999637 100644 --- a/shared/src/i18n/es/externalNotifications.ts +++ b/shared/src/i18n/es/externalNotifications.ts @@ -17,6 +17,18 @@ const es: NotificationLocale = { title: `Nueva reserva: ${p.booking}`, body: `${p.actor} añadió una reserva "${p.booking}" (${p.type}) a "${p.trip}".`, }), + flight_departed: (p) => ({ + title: `${p.flight} ha despegado`, + body: `${p.flight} de ${p.from} a ${p.to} está en el aire.`, + }), + flight_delayed: (p) => ({ + title: `${p.flight} va con retraso`, + body: `${p.flight} de ${p.from} a ${p.to} lleva unos ${p.delay} minutos de retraso.`, + }), + flight_landed: (p) => ({ + title: `${p.flight} ha aterrizado`, + body: `${p.flight} desde ${p.from} ha aterrizado en ${p.to}.`, + }), trip_reminder: (p) => ({ title: `Recordatorio: ${p.trip}`, body: `¡Tu viaje "${p.trip}" se acerca!`, diff --git a/shared/src/i18n/es/notif.ts b/shared/src/i18n/es/notif.ts index 60afce7ef..e3358e41e 100644 --- a/shared/src/i18n/es/notif.ts +++ b/shared/src/i18n/es/notif.ts @@ -9,6 +9,12 @@ const notif: TranslationStrings = { 'notif.trip_invite.text': '{actor} te invitó a {trip}', 'notif.booking_change.title': 'Reserva actualizada', 'notif.booking_change.text': '{actor} actualizó una reserva en {trip}', + 'notif.flight_departed.title': 'Vuelo despegado', + 'notif.flight_departed.text': '{flight} de {from} a {to} está en el aire', + 'notif.flight_delayed.title': 'Vuelo con retraso', + 'notif.flight_delayed.text': '{flight} de {from} a {to} lleva unos {delay} min de retraso', + 'notif.flight_landed.title': 'Vuelo aterrizado', + 'notif.flight_landed.text': '{flight} desde {from} ha aterrizado en {to}', 'notif.trip_reminder.title': 'Recordatorio de viaje', 'notif.trip_reminder.text': '¡Tu viaje {trip} se acerca!', 'notif.todo_due.title': 'Tarea pendiente', diff --git a/shared/src/i18n/es/settings.ts b/shared/src/i18n/es/settings.ts index cffcec68a..21cc33fc1 100644 --- a/shared/src/i18n/es/settings.ts +++ b/shared/src/i18n/es/settings.ts @@ -112,6 +112,9 @@ const settings: TranslationStrings = { 'settings.notifications': 'Notificaciones', 'settings.notifyTripInvite': 'Invitaciones de viaje', 'settings.notifyBookingChange': 'Cambios en reservas', + 'settings.notifyFlightDeparted': 'Despegues', + 'settings.notifyFlightDelayed': 'Retrasos de vuelos', + 'settings.notifyFlightLanded': 'Aterrizajes', 'settings.notifyTripReminder': 'Recordatorios de viaje', 'settings.notifyTodoDue': 'Tarea próxima', 'settings.notifyVacayInvite': 'Invitaciones de fusión Vacay', diff --git a/shared/src/i18n/et/externalNotifications.ts b/shared/src/i18n/et/externalNotifications.ts index 1ec2c9e12..1e5a904b4 100644 --- a/shared/src/i18n/et/externalNotifications.ts +++ b/shared/src/i18n/et/externalNotifications.ts @@ -18,6 +18,18 @@ const et: NotificationLocale = { title: `Uus broneering: ${p.booking}`, body: `${p.actor} lisas reisile „${p.trip}” uue broneeringu „${p.booking}” (tüüp: ${reservations[`reservations.type.${p.type}`] ?? (p.type === 'booking' ? 'broneering' : p.type)}).`, }), + flight_departed: (p) => ({ + title: `${p.flight} tõusis õhku`, + body: `Lend ${p.flight} lennujaamast ${p.from} lennujaama ${p.to} on õhus.`, + }), + flight_delayed: (p) => ({ + title: `${p.flight} hilineb`, + body: `Lend ${p.flight} lennujaamast ${p.from} lennujaama ${p.to} hilineb umbes ${p.delay} minutit.`, + }), + flight_landed: (p) => ({ + title: `${p.flight} maandus`, + body: `Lend ${p.flight} lennujaamast ${p.from} maandus lennujaamas ${p.to}.`, + }), trip_reminder: (p) => ({ title: `Reisi meeldetuletus: ${p.trip}`, body: `Sinu reis „${p.trip}” algab peagi!`, diff --git a/shared/src/i18n/et/notif.ts b/shared/src/i18n/et/notif.ts index 269e2a06d..2703d55df 100644 --- a/shared/src/i18n/et/notif.ts +++ b/shared/src/i18n/et/notif.ts @@ -9,6 +9,12 @@ const notif: TranslationStrings = { 'notif.trip_invite.text': '{actor} kutsus sind reisile {trip}', 'notif.booking_change.title': 'Broneering uuendatud', 'notif.booking_change.text': '{actor} uuendas reisi {trip} broneeringut', + 'notif.flight_departed.title': 'Lend tõusis õhku', + 'notif.flight_departed.text': 'Lend {flight} lennujaamast {from} lennujaama {to} on õhus', + 'notif.flight_delayed.title': 'Lend hilineb', + 'notif.flight_delayed.text': 'Lend {flight} lennujaamast {from} lennujaama {to} hilineb umbes {delay} min', + 'notif.flight_landed.title': 'Lend maandus', + 'notif.flight_landed.text': 'Lend {flight} lennujaamast {from} maandus lennujaamas {to}', 'notif.trip_reminder.title': 'Reisimeeldetuletus', 'notif.trip_reminder.text': 'Sinu reis {trip} algab peagi!', 'notif.todo_due.title': 'Ülesande tähtaeg', diff --git a/shared/src/i18n/et/settings.ts b/shared/src/i18n/et/settings.ts index dd48d4730..d95988661 100644 --- a/shared/src/i18n/et/settings.ts +++ b/shared/src/i18n/et/settings.ts @@ -127,6 +127,9 @@ const settings: TranslationStrings = { 'settings.notifications': 'Teavitused', 'settings.notifyTripInvite': 'Reisikutsed', 'settings.notifyBookingChange': 'Broneeringute muudatused', + 'settings.notifyFlightDeparted': 'Väljalennud', + 'settings.notifyFlightDelayed': 'Lendude hilinemised', + 'settings.notifyFlightLanded': 'Maandumised', 'settings.notifyTripReminder': 'Reisimeeldetuletused', 'settings.notifyTodoDue': 'Ülesande tähtaeg läheneb', 'settings.notifyVacayInvite': 'Vacay ühendamiskutsed', diff --git a/shared/src/i18n/externalNotifications/types.ts b/shared/src/i18n/externalNotifications/types.ts index fe8aed831..6225df747 100644 --- a/shared/src/i18n/externalNotifications/types.ts +++ b/shared/src/i18n/externalNotifications/types.ts @@ -24,6 +24,9 @@ export interface PasswordResetStrings { export type NotificationEventKey = | 'trip_invite' | 'booking_change' + | 'flight_departed' + | 'flight_delayed' + | 'flight_landed' | 'trip_reminder' | 'todo_due' | 'vacay_invite' diff --git a/shared/src/i18n/fr/externalNotifications.ts b/shared/src/i18n/fr/externalNotifications.ts index 42d812e5c..e0c5200af 100644 --- a/shared/src/i18n/fr/externalNotifications.ts +++ b/shared/src/i18n/fr/externalNotifications.ts @@ -17,6 +17,18 @@ const fr: NotificationLocale = { title: `Nouvelle réservation : ${p.booking}`, body: `${p.actor} a ajouté une réservation "${p.booking}" (${p.type}) à "${p.trip}".`, }), + flight_departed: (p) => ({ + title: `${p.flight} a décollé`, + body: `${p.flight} de ${p.from} à ${p.to} est en vol.`, + }), + flight_delayed: (p) => ({ + title: `${p.flight} est en retard`, + body: `${p.flight} de ${p.from} à ${p.to} a environ ${p.delay} minutes de retard.`, + }), + flight_landed: (p) => ({ + title: `${p.flight} a atterri`, + body: `${p.flight} en provenance de ${p.from} a atterri à ${p.to}.`, + }), trip_reminder: (p) => ({ title: `Rappel de voyage : ${p.trip}`, body: `Votre voyage "${p.trip}" approche !`, diff --git a/shared/src/i18n/fr/notif.ts b/shared/src/i18n/fr/notif.ts index 7f47ed4f6..1a60b9079 100644 --- a/shared/src/i18n/fr/notif.ts +++ b/shared/src/i18n/fr/notif.ts @@ -9,6 +9,12 @@ const notif: TranslationStrings = { 'notif.trip_invite.text': '{actor} vous a invité à {trip}', 'notif.booking_change.title': 'Réservation mise à jour', 'notif.booking_change.text': '{actor} a mis à jour une réservation dans {trip}', + 'notif.flight_departed.title': 'Vol parti', + 'notif.flight_departed.text': '{flight} de {from} à {to} est en vol', + 'notif.flight_delayed.title': 'Vol en retard', + 'notif.flight_delayed.text': '{flight} de {from} à {to} a environ {delay} min de retard', + 'notif.flight_landed.title': 'Vol arrivé', + 'notif.flight_landed.text': '{flight} en provenance de {from} a atterri à {to}', 'notif.trip_reminder.title': 'Rappel de voyage', 'notif.trip_reminder.text': 'Votre voyage {trip} approche !', 'notif.todo_due.title': 'Tâche à échéance', diff --git a/shared/src/i18n/fr/settings.ts b/shared/src/i18n/fr/settings.ts index ef1a4a418..be5ae4a95 100644 --- a/shared/src/i18n/fr/settings.ts +++ b/shared/src/i18n/fr/settings.ts @@ -114,6 +114,9 @@ const settings: TranslationStrings = { 'settings.notifications': 'Notifications', // same-as-en 'settings.notifyTripInvite': 'Invitations de voyage', 'settings.notifyBookingChange': 'Modifications de réservation', + 'settings.notifyFlightDeparted': 'Décollages', + 'settings.notifyFlightDelayed': 'Retards de vol', + 'settings.notifyFlightLanded': 'Atterrissages', 'settings.notifyTripReminder': 'Rappels de voyage', 'settings.notifyTodoDue': 'Tâche à échéance', 'settings.notifyVacayInvite': 'Invitations de fusion Vacay', diff --git a/shared/src/i18n/gr/externalNotifications.ts b/shared/src/i18n/gr/externalNotifications.ts index 8ef80ae30..108c0fff0 100644 --- a/shared/src/i18n/gr/externalNotifications.ts +++ b/shared/src/i18n/gr/externalNotifications.ts @@ -17,6 +17,18 @@ const gr: NotificationLocale = { title: `Νέα κράτηση: ${p.booking}`, body: `Ο/Η ${p.actor} πρόσθεσε μια νέα κράτηση "${p.booking}" (${p.type}) στο "${p.trip}".`, }), + flight_departed: (p) => ({ + title: `Η πτήση ${p.flight} αναχώρησε`, + body: `Η πτήση ${p.flight} από ${p.from} προς ${p.to} είναι στον αέρα.`, + }), + flight_delayed: (p) => ({ + title: `Η πτήση ${p.flight} έχει καθυστέρηση`, + body: `Η πτήση ${p.flight} από ${p.from} προς ${p.to} έχει καθυστέρηση περίπου ${p.delay} λεπτών.`, + }), + flight_landed: (p) => ({ + title: `Η πτήση ${p.flight} προσγειώθηκε`, + body: `Η πτήση ${p.flight} από ${p.from} προσγειώθηκε στο ${p.to}.`, + }), trip_reminder: (p) => ({ title: `Υπενθύμιση ταξιδιού: ${p.trip}`, body: `Το ταξίδι σας "${p.trip}" πλησιάζει!`, diff --git a/shared/src/i18n/gr/notif.ts b/shared/src/i18n/gr/notif.ts index ef8ae2d50..80f314cc4 100644 --- a/shared/src/i18n/gr/notif.ts +++ b/shared/src/i18n/gr/notif.ts @@ -9,6 +9,12 @@ const notif: TranslationStrings = { 'notif.trip_invite.text': 'Ο/Η {actor} σας προσκάλεσε στο {trip}', 'notif.booking_change.title': 'Η Κράτηση Ενημερώθηκε', 'notif.booking_change.text': 'Ο/Η {actor} ενημέρωσε μια κράτηση στο {trip}', + 'notif.flight_departed.title': 'Η πτήση αναχώρησε', + 'notif.flight_departed.text': 'Η πτήση {flight} από {from} προς {to} είναι στον αέρα', + 'notif.flight_delayed.title': 'Καθυστέρηση πτήσης', + 'notif.flight_delayed.text': 'Η πτήση {flight} από {from} προς {to} έχει καθυστέρηση περίπου {delay} λεπτών', + 'notif.flight_landed.title': 'Η πτήση προσγειώθηκε', + 'notif.flight_landed.text': 'Η πτήση {flight} από {from} προσγειώθηκε στο {to}', 'notif.trip_reminder.title': 'Υπενθύμιση Ταξιδιού', 'notif.trip_reminder.text': 'Το ταξίδι σας {trip} πλησιάζει!', 'notif.todo_due.title': 'Εργασία προς λήξη', diff --git a/shared/src/i18n/gr/settings.ts b/shared/src/i18n/gr/settings.ts index de5da6426..6cb78ea2e 100644 --- a/shared/src/i18n/gr/settings.ts +++ b/shared/src/i18n/gr/settings.ts @@ -119,6 +119,9 @@ const settings: TranslationStrings = { 'settings.notifications': 'Ειδοποιήσεις', 'settings.notifyTripInvite': 'Προσκλήσεις ταξιδιού', 'settings.notifyBookingChange': 'Αλλαγές κρατήσεων', + 'settings.notifyFlightDeparted': 'Αναχωρήσεις πτήσεων', + 'settings.notifyFlightDelayed': 'Καθυστερήσεις πτήσεων', + 'settings.notifyFlightLanded': 'Προσγειώσεις πτήσεων', 'settings.notifyTripReminder': 'Υπενθυμίσεις ταξιδιού', 'settings.notifyTodoDue': 'Εκκρεμότητα λήγει σύντομα', 'settings.notifyVacayInvite': 'Προσκλήσεις Vacay fusion', diff --git a/shared/src/i18n/he/externalNotifications.ts b/shared/src/i18n/he/externalNotifications.ts index 5f0702d66..b96109e16 100644 --- a/shared/src/i18n/he/externalNotifications.ts +++ b/shared/src/i18n/he/externalNotifications.ts @@ -17,6 +17,18 @@ const he: NotificationLocale = { title: `הזמנה חדשה: ${p.booking}`, body: `${p.actor} הוסיף/ה ${p.type} חדש/ה "${p.booking}" אל "${p.trip}".`, }), + flight_departed: (p) => ({ + title: `טיסה ${p.flight} המריאה`, + body: `טיסה ${p.flight} מ-${p.from} ל-${p.to} באוויר.`, + }), + flight_delayed: (p) => ({ + title: `טיסה ${p.flight} מתעכבת`, + body: `טיסה ${p.flight} מ-${p.from} ל-${p.to} מאחרת בכ-${p.delay} דקות.`, + }), + flight_landed: (p) => ({ + title: `טיסה ${p.flight} נחתה`, + body: `טיסה ${p.flight} מ-${p.from} נחתה ב-${p.to}.`, + }), trip_reminder: (p) => ({ title: `תזכורת לטיול: ${p.trip}`, body: `הטיול "${p.trip}" מתקרב!`, diff --git a/shared/src/i18n/he/notif.ts b/shared/src/i18n/he/notif.ts index 402509de4..90ae30df3 100644 --- a/shared/src/i18n/he/notif.ts +++ b/shared/src/i18n/he/notif.ts @@ -9,6 +9,12 @@ const notif: TranslationStrings = { 'notif.trip_invite.text': '{actor} הזמין/ה אתכם אל {trip}', 'notif.booking_change.title': 'הזמנה עודכנה', 'notif.booking_change.text': '{actor} עדכן/ה הזמנה ב-{trip}', + 'notif.flight_departed.title': 'הטיסה המריאה', + 'notif.flight_departed.text': 'טיסה {flight} מ-{from} ל-{to} באוויר', + 'notif.flight_delayed.title': 'הטיסה מתעכבת', + 'notif.flight_delayed.text': 'טיסה {flight} מ-{from} ל-{to} מאחרת בכ-{delay} דקות', + 'notif.flight_landed.title': 'הטיסה נחתה', + 'notif.flight_landed.text': 'טיסה {flight} מ-{from} נחתה ב-{to}', 'notif.trip_reminder.title': 'תזכורת לטיול', 'notif.trip_reminder.text': 'הטיול {trip} מתקרב!', 'notif.todo_due.title': 'משימה לביצוע', diff --git a/shared/src/i18n/he/settings.ts b/shared/src/i18n/he/settings.ts index 499cb7862..12646ffb9 100644 --- a/shared/src/i18n/he/settings.ts +++ b/shared/src/i18n/he/settings.ts @@ -120,6 +120,9 @@ const settings: TranslationStrings = { 'settings.notifications': 'התראות', 'settings.notifyTripInvite': 'הזמנות לטיולים', 'settings.notifyBookingChange': 'שינויים בהזמנות', + 'settings.notifyFlightDeparted': 'המראות', + 'settings.notifyFlightDelayed': 'עיכובי טיסות', + 'settings.notifyFlightLanded': 'נחיתות', 'settings.notifyTripReminder': 'תזכורות לטיולים', 'settings.notifyTodoDue': 'משימות שמועדן מתקרב', 'settings.notifyVacayInvite': 'הזמנות לאיחוד חופשות', diff --git a/shared/src/i18n/hu/externalNotifications.ts b/shared/src/i18n/hu/externalNotifications.ts index e8da4e084..241293628 100644 --- a/shared/src/i18n/hu/externalNotifications.ts +++ b/shared/src/i18n/hu/externalNotifications.ts @@ -17,6 +17,18 @@ const hu: NotificationLocale = { title: `Új foglalás: ${p.booking}`, body: `${p.actor} hozzáadott egy "${p.booking}" (${p.type}) foglalást a(z) "${p.trip}" utazáshoz.`, }), + flight_departed: (p) => ({ + title: `A(z) ${p.flight} felszállt`, + body: `A(z) ${p.flight} járat ${p.from} és ${p.to} között a levegőben van.`, + }), + flight_delayed: (p) => ({ + title: `A(z) ${p.flight} késik`, + body: `A(z) ${p.flight} járat ${p.from} és ${p.to} között körülbelül ${p.delay} percet késik.`, + }), + flight_landed: (p) => ({ + title: `A(z) ${p.flight} leszállt`, + body: `A(z) ${p.flight} járat ${p.from} felől leszállt itt: ${p.to}.`, + }), trip_reminder: (p) => ({ title: `Utazás emlékeztető: ${p.trip}`, body: `A(z) "${p.trip}" utazás hamarosan kezdődik!`, diff --git a/shared/src/i18n/hu/notif.ts b/shared/src/i18n/hu/notif.ts index 855dbddf4..b542fc2c5 100644 --- a/shared/src/i18n/hu/notif.ts +++ b/shared/src/i18n/hu/notif.ts @@ -9,6 +9,12 @@ const notif: TranslationStrings = { 'notif.trip_invite.text': '{actor} meghívott a(z) {trip} utazásra', 'notif.booking_change.title': 'Foglalás frissítve', 'notif.booking_change.text': '{actor} frissített egy foglalást a(z) {trip} utazásban', + 'notif.flight_departed.title': 'Járat felszállt', + 'notif.flight_departed.text': 'A(z) {flight} járat {from} és {to} között a levegőben van', + 'notif.flight_delayed.title': 'Járat késik', + 'notif.flight_delayed.text': 'A(z) {flight} járat {from} és {to} között körülbelül {delay} percet késik', + 'notif.flight_landed.title': 'Járat leszállt', + 'notif.flight_landed.text': 'A(z) {flight} járat {from} felől leszállt itt: {to}', 'notif.trip_reminder.title': 'Utazás emlékeztető', 'notif.trip_reminder.text': 'A(z) {trip} utazás hamarosan kezdődik!', 'notif.todo_due.title': 'Teendő esedékes', diff --git a/shared/src/i18n/hu/settings.ts b/shared/src/i18n/hu/settings.ts index 63b92a090..15cb17128 100644 --- a/shared/src/i18n/hu/settings.ts +++ b/shared/src/i18n/hu/settings.ts @@ -112,6 +112,9 @@ const settings: TranslationStrings = { 'settings.notifications': 'Értesítések', 'settings.notifyTripInvite': 'Utazási meghívók', 'settings.notifyBookingChange': 'Foglalási változások', + 'settings.notifyFlightDeparted': 'Felszállások', + 'settings.notifyFlightDelayed': 'Járatkésések', + 'settings.notifyFlightLanded': 'Leszállások', 'settings.notifyTripReminder': 'Utazási emlékeztetők', 'settings.notifyTodoDue': 'Teendő esedékes', 'settings.notifyVacayInvite': 'Vacay összevonási meghívók', diff --git a/shared/src/i18n/id/externalNotifications.ts b/shared/src/i18n/id/externalNotifications.ts index 4080081f6..64493c014 100644 --- a/shared/src/i18n/id/externalNotifications.ts +++ b/shared/src/i18n/id/externalNotifications.ts @@ -16,6 +16,18 @@ const id: NotificationLocale = { title: `Pemesanan baru: ${p.booking}`, body: `${p.actor} menambahkan "${p.booking}" (${p.type}) baru ke "${p.trip}".`, }), + flight_departed: (p) => ({ + title: `${p.flight} telah lepas landas`, + body: `${p.flight} dari ${p.from} ke ${p.to} sedang mengudara.`, + }), + flight_delayed: (p) => ({ + title: `${p.flight} terlambat`, + body: `${p.flight} dari ${p.from} ke ${p.to} terlambat sekitar ${p.delay} menit dari jadwal.`, + }), + flight_landed: (p) => ({ + title: `${p.flight} telah mendarat`, + body: `${p.flight} dari ${p.from} telah mendarat di ${p.to}.`, + }), trip_reminder: (p) => ({ title: `Pengingat perjalanan: ${p.trip}`, body: `Perjalanan Anda "${p.trip}" akan segera tiba!`, diff --git a/shared/src/i18n/id/notif.ts b/shared/src/i18n/id/notif.ts index 57f5c482a..1d6402425 100644 --- a/shared/src/i18n/id/notif.ts +++ b/shared/src/i18n/id/notif.ts @@ -9,6 +9,12 @@ const notif: TranslationStrings = { 'notif.trip_invite.text': '{actor} mengundangmu ke {trip}', 'notif.booking_change.title': 'Pemesanan Diperbarui', 'notif.booking_change.text': '{actor} memperbarui pemesanan di {trip}', + 'notif.flight_departed.title': 'Penerbangan lepas landas', + 'notif.flight_departed.text': '{flight} dari {from} ke {to} sedang mengudara', + 'notif.flight_delayed.title': 'Penerbangan terlambat', + 'notif.flight_delayed.text': '{flight} dari {from} ke {to} terlambat sekitar {delay} menit', + 'notif.flight_landed.title': 'Penerbangan mendarat', + 'notif.flight_landed.text': '{flight} dari {from} telah mendarat di {to}', 'notif.trip_reminder.title': 'Pengingat Perjalanan', 'notif.trip_reminder.text': 'Perjalananmu {trip} akan segera dimulai!', 'notif.todo_due.title': 'Tugas jatuh tempo', diff --git a/shared/src/i18n/id/settings.ts b/shared/src/i18n/id/settings.ts index 02ac15099..ba0bbc20b 100644 --- a/shared/src/i18n/id/settings.ts +++ b/shared/src/i18n/id/settings.ts @@ -109,6 +109,9 @@ const settings: TranslationStrings = { 'settings.notifications': 'Notifikasi', 'settings.notifyTripInvite': 'Undangan perjalanan', 'settings.notifyBookingChange': 'Perubahan pemesanan', + 'settings.notifyFlightDeparted': 'Penerbangan lepas landas', + 'settings.notifyFlightDelayed': 'Penerbangan terlambat', + 'settings.notifyFlightLanded': 'Penerbangan mendarat', 'settings.notifyTripReminder': 'Pengingat perjalanan', 'settings.notifyTodoDue': 'Tugas jatuh tempo', 'settings.notifyVacayInvite': 'Undangan Vacay fusion', diff --git a/shared/src/i18n/it/externalNotifications.ts b/shared/src/i18n/it/externalNotifications.ts index 5e2ef747e..cc2d70623 100644 --- a/shared/src/i18n/it/externalNotifications.ts +++ b/shared/src/i18n/it/externalNotifications.ts @@ -17,6 +17,18 @@ const it: NotificationLocale = { title: `Nuova prenotazione: ${p.booking}`, body: `${p.actor} ha aggiunto una prenotazione "${p.booking}" (${p.type}) a "${p.trip}".`, }), + flight_departed: (p) => ({ + title: `${p.flight} è decollato`, + body: `${p.flight} da ${p.from} a ${p.to} è in volo.`, + }), + flight_delayed: (p) => ({ + title: `${p.flight} è in ritardo`, + body: `${p.flight} da ${p.from} a ${p.to} ha circa ${p.delay} minuti di ritardo.`, + }), + flight_landed: (p) => ({ + title: `${p.flight} è atterrato`, + body: `${p.flight} da ${p.from} è atterrato a ${p.to}.`, + }), trip_reminder: (p) => ({ title: `Promemoria viaggio: ${p.trip}`, body: `Il tuo viaggio "${p.trip}" si avvicina!`, diff --git a/shared/src/i18n/it/notif.ts b/shared/src/i18n/it/notif.ts index 519573425..90b54fa7e 100644 --- a/shared/src/i18n/it/notif.ts +++ b/shared/src/i18n/it/notif.ts @@ -9,6 +9,12 @@ const notif: TranslationStrings = { 'notif.trip_invite.text': '{actor} ti ha invitato a {trip}', 'notif.booking_change.title': 'Prenotazione aggiornata', 'notif.booking_change.text': '{actor} ha aggiornato una prenotazione in {trip}', + 'notif.flight_departed.title': 'Volo decollato', + 'notif.flight_departed.text': '{flight} da {from} a {to} è in volo', + 'notif.flight_delayed.title': 'Volo in ritardo', + 'notif.flight_delayed.text': '{flight} da {from} a {to} ha circa {delay} min di ritardo', + 'notif.flight_landed.title': 'Volo atterrato', + 'notif.flight_landed.text': '{flight} da {from} è atterrato a {to}', 'notif.trip_reminder.title': 'Promemoria viaggio', 'notif.trip_reminder.text': 'Il tuo viaggio {trip} si avvicina!', 'notif.todo_due.title': 'Attività in scadenza', diff --git a/shared/src/i18n/it/settings.ts b/shared/src/i18n/it/settings.ts index 54e91a3e9..7825c8272 100644 --- a/shared/src/i18n/it/settings.ts +++ b/shared/src/i18n/it/settings.ts @@ -111,6 +111,9 @@ const settings: TranslationStrings = { 'settings.notifications': 'Notifiche', 'settings.notifyTripInvite': 'Inviti di viaggio', 'settings.notifyBookingChange': 'Modifiche alle prenotazioni', + 'settings.notifyFlightDeparted': 'Decolli', + 'settings.notifyFlightDelayed': 'Ritardi dei voli', + 'settings.notifyFlightLanded': 'Atterraggi', 'settings.notifyTripReminder': 'Promemoria di viaggio', 'settings.notifyTodoDue': 'Attività in scadenza', 'settings.notifyVacayInvite': 'Inviti fusione Vacay', diff --git a/shared/src/i18n/ja/externalNotifications.ts b/shared/src/i18n/ja/externalNotifications.ts index 91aa8a30f..8b44b6492 100644 --- a/shared/src/i18n/ja/externalNotifications.ts +++ b/shared/src/i18n/ja/externalNotifications.ts @@ -16,6 +16,18 @@ const ja: NotificationLocale = { title: `新しい予約:${p.booking}`, body: `${p.actor}が「${p.trip}」に「${p.booking}」(${p.type})を追加しました。`, }), + flight_departed: (p) => ({ + title: `${p.flight}が出発しました`, + body: `${p.from}発${p.to}行きの${p.flight}は飛行中です。`, + }), + flight_delayed: (p) => ({ + title: `${p.flight}に遅れ`, + body: `${p.from}発${p.to}行きの${p.flight}は予定より約${p.delay}分遅れています。`, + }), + flight_landed: (p) => ({ + title: `${p.flight}が着陸しました`, + body: `${p.from}発の${p.flight}が${p.to}に着陸しました。`, + }), trip_reminder: (p) => ({ title: `旅行リマインダー:${p.trip}`, body: `「${p.trip}」の旅行が近づいています!`, diff --git a/shared/src/i18n/ja/notif.ts b/shared/src/i18n/ja/notif.ts index db1ac53c6..af2b83ede 100644 --- a/shared/src/i18n/ja/notif.ts +++ b/shared/src/i18n/ja/notif.ts @@ -9,6 +9,12 @@ const notif: TranslationStrings = { 'notif.trip_invite.text': '{actor}が「{trip}」に招待しました', 'notif.booking_change.title': '予約が更新されました', 'notif.booking_change.text': '{actor}が「{trip}」の予約を更新しました', + 'notif.flight_departed.title': 'フライトが出発しました', + 'notif.flight_departed.text': '{from}発{to}行きの{flight}は飛行中です', + 'notif.flight_delayed.title': 'フライトの遅れ', + 'notif.flight_delayed.text': '{from}発{to}行きの{flight}は約{delay}分遅れています', + 'notif.flight_landed.title': 'フライトが着陸しました', + 'notif.flight_landed.text': '{from}発の{flight}が{to}に着陸しました', 'notif.trip_reminder.title': '旅行リマインド', 'notif.trip_reminder.text': '旅行「{trip}」がまもなく始まります!', 'notif.todo_due.title': 'ToDoの期限', diff --git a/shared/src/i18n/ja/settings.ts b/shared/src/i18n/ja/settings.ts index 2dcd158c4..ec17fe270 100644 --- a/shared/src/i18n/ja/settings.ts +++ b/shared/src/i18n/ja/settings.ts @@ -109,6 +109,9 @@ const settings: TranslationStrings = { 'settings.notifications': '通知', 'settings.notifyTripInvite': '旅行の招待', 'settings.notifyBookingChange': '予約の変更', + 'settings.notifyFlightDeparted': 'フライトの出発', + 'settings.notifyFlightDelayed': 'フライトの遅延', + 'settings.notifyFlightLanded': 'フライトの着陸', 'settings.notifyTripReminder': '旅行リマインダー', 'settings.notifyTodoDue': 'ToDoの期限', 'settings.notifyVacayInvite': 'Vacay fusion の招待', diff --git a/shared/src/i18n/ko/externalNotifications.ts b/shared/src/i18n/ko/externalNotifications.ts index b2542fd8f..929745bb2 100644 --- a/shared/src/i18n/ko/externalNotifications.ts +++ b/shared/src/i18n/ko/externalNotifications.ts @@ -16,6 +16,18 @@ const ko: NotificationLocale = { title: `새 예약: ${p.booking}`, body: `${p.actor}이(가) "${p.trip}"에 "${p.booking}" (${p.type}) 예약을 추가했습니다.`, }), + flight_departed: (p) => ({ + title: `${p.flight} 출발`, + body: `${p.from}발 ${p.to}행 ${p.flight}편이 비행 중입니다.`, + }), + flight_delayed: (p) => ({ + title: `${p.flight} 지연`, + body: `${p.from}발 ${p.to}행 ${p.flight}편이 예정보다 약 ${p.delay}분 늦어지고 있습니다.`, + }), + flight_landed: (p) => ({ + title: `${p.flight} 착륙`, + body: `${p.from}발 ${p.flight}편이 ${p.to}에 착륙했습니다.`, + }), trip_reminder: (p) => ({ title: `여행 알림: ${p.trip}`, body: `"${p.trip}" 여행이 곧 시작됩니다!`, diff --git a/shared/src/i18n/ko/notif.ts b/shared/src/i18n/ko/notif.ts index ad7fe65e3..cc4a4bdfb 100644 --- a/shared/src/i18n/ko/notif.ts +++ b/shared/src/i18n/ko/notif.ts @@ -9,6 +9,12 @@ const notif: TranslationStrings = { 'notif.trip_invite.text': '{actor}이(가) {trip}에 초대했습니다', 'notif.booking_change.title': '예약 업데이트됨', 'notif.booking_change.text': '{actor}이(가) {trip}의 예약을 업데이트했습니다', + 'notif.flight_departed.title': '항공편 출발', + 'notif.flight_departed.text': '{from}발 {to}행 {flight}편이 비행 중입니다', + 'notif.flight_delayed.title': '항공편 지연', + 'notif.flight_delayed.text': '{from}발 {to}행 {flight}편이 약 {delay}분 지연되고 있습니다', + 'notif.flight_landed.title': '항공편 착륙', + 'notif.flight_landed.text': '{from}발 {flight}편이 {to}에 착륙했습니다', 'notif.trip_reminder.title': '여행 리마인더', 'notif.trip_reminder.text': '여행 {trip}이(가) 곧 시작됩니다!', 'notif.todo_due.title': '할 일 마감', diff --git a/shared/src/i18n/ko/settings.ts b/shared/src/i18n/ko/settings.ts index 079cdefd4..f8a513c99 100644 --- a/shared/src/i18n/ko/settings.ts +++ b/shared/src/i18n/ko/settings.ts @@ -110,6 +110,9 @@ const settings: TranslationStrings = { 'settings.notifications': '알림', 'settings.notifyTripInvite': '여행 초대', 'settings.notifyBookingChange': '예약 변경', + 'settings.notifyFlightDeparted': '항공편 출발', + 'settings.notifyFlightDelayed': '항공편 지연', + 'settings.notifyFlightLanded': '항공편 착륙', 'settings.notifyTripReminder': '여행 리마인더', 'settings.notifyTodoDue': '할 일 마감 임박', 'settings.notifyVacayInvite': 'Vacay 퓨전 초대', diff --git a/shared/src/i18n/nl/externalNotifications.ts b/shared/src/i18n/nl/externalNotifications.ts index 982920923..976b7b80e 100644 --- a/shared/src/i18n/nl/externalNotifications.ts +++ b/shared/src/i18n/nl/externalNotifications.ts @@ -17,6 +17,18 @@ const nl: NotificationLocale = { title: `Nieuwe boeking: ${p.booking}`, body: `${p.actor} heeft een boeking "${p.booking}" (${p.type}) toegevoegd aan "${p.trip}".`, }), + flight_departed: (p) => ({ + title: `${p.flight} is vertrokken`, + body: `${p.flight} van ${p.from} naar ${p.to} is in de lucht.`, + }), + flight_delayed: (p) => ({ + title: `${p.flight} heeft vertraging`, + body: `${p.flight} van ${p.from} naar ${p.to} loopt ongeveer ${p.delay} minuten achter op schema.`, + }), + flight_landed: (p) => ({ + title: `${p.flight} is geland`, + body: `${p.flight} uit ${p.from} is geland in ${p.to}.`, + }), trip_reminder: (p) => ({ title: `Reisherinnering: ${p.trip}`, body: `Je reis "${p.trip}" komt eraan!`, diff --git a/shared/src/i18n/nl/notif.ts b/shared/src/i18n/nl/notif.ts index 2a3316f39..0e235b539 100644 --- a/shared/src/i18n/nl/notif.ts +++ b/shared/src/i18n/nl/notif.ts @@ -9,6 +9,12 @@ const notif: TranslationStrings = { 'notif.trip_invite.text': '{actor} heeft je uitgenodigd voor {trip}', 'notif.booking_change.title': 'Boeking bijgewerkt', 'notif.booking_change.text': '{actor} heeft een boeking bijgewerkt in {trip}', + 'notif.flight_departed.title': 'Vlucht vertrokken', + 'notif.flight_departed.text': '{flight} van {from} naar {to} is in de lucht', + 'notif.flight_delayed.title': 'Vlucht vertraagd', + 'notif.flight_delayed.text': '{flight} van {from} naar {to} heeft ongeveer {delay} min vertraging', + 'notif.flight_landed.title': 'Vlucht geland', + 'notif.flight_landed.text': '{flight} uit {from} is geland in {to}', 'notif.trip_reminder.title': 'Reisherinnering', 'notif.trip_reminder.text': 'Je reis {trip} komt eraan!', 'notif.todo_due.title': 'Taak verloopt', diff --git a/shared/src/i18n/nl/settings.ts b/shared/src/i18n/nl/settings.ts index e61195646..57a1a13f1 100644 --- a/shared/src/i18n/nl/settings.ts +++ b/shared/src/i18n/nl/settings.ts @@ -111,6 +111,9 @@ const settings: TranslationStrings = { 'settings.notifications': 'Meldingen', 'settings.notifyTripInvite': 'Reisuitnodigingen', 'settings.notifyBookingChange': 'Boekingswijzigingen', + 'settings.notifyFlightDeparted': 'Vertrekken', + 'settings.notifyFlightDelayed': 'Vluchtvertragingen', + 'settings.notifyFlightLanded': 'Landingen', 'settings.notifyTripReminder': 'Reisherinneringen', 'settings.notifyTodoDue': 'Taak verloopt', 'settings.notifyVacayInvite': 'Vacay-fusieuitnodigingen', diff --git a/shared/src/i18n/pl/externalNotifications.ts b/shared/src/i18n/pl/externalNotifications.ts index ffb6a4046..c36873fda 100644 --- a/shared/src/i18n/pl/externalNotifications.ts +++ b/shared/src/i18n/pl/externalNotifications.ts @@ -17,6 +17,18 @@ const pl: NotificationLocale = { title: `Nowa rezerwacja: ${p.booking}`, body: `${p.actor} dodał rezerwację "${p.booking}" (${p.type}) do "${p.trip}".`, }), + flight_departed: (p) => ({ + title: `${p.flight} wystartował`, + body: `${p.flight} z ${p.from} do ${p.to} jest w powietrzu.`, + }), + flight_delayed: (p) => ({ + title: `${p.flight} jest opóźniony`, + body: `${p.flight} z ${p.from} do ${p.to} ma około ${p.delay} minut opóźnienia.`, + }), + flight_landed: (p) => ({ + title: `${p.flight} wylądował`, + body: `${p.flight} z ${p.from} wylądował w ${p.to}.`, + }), trip_reminder: (p) => ({ title: `Przypomnienie o podróży: ${p.trip}`, body: `Twoja podróż "${p.trip}" zbliża się!`, diff --git a/shared/src/i18n/pl/notif.ts b/shared/src/i18n/pl/notif.ts index 6ed836fe0..e354b9d51 100644 --- a/shared/src/i18n/pl/notif.ts +++ b/shared/src/i18n/pl/notif.ts @@ -9,6 +9,12 @@ const notif: TranslationStrings = { 'notif.trip_invite.text': '{actor} zaprosił Cię do {trip}', 'notif.booking_change.title': 'Rezerwacja zaktualizowana', 'notif.booking_change.text': '{actor} zaktualizował rezerwację w {trip}', + 'notif.flight_departed.title': 'Lot wystartował', + 'notif.flight_departed.text': '{flight} z {from} do {to} jest w powietrzu', + 'notif.flight_delayed.title': 'Lot opóźniony', + 'notif.flight_delayed.text': '{flight} z {from} do {to} ma około {delay} min opóźnienia', + 'notif.flight_landed.title': 'Lot wylądował', + 'notif.flight_landed.text': '{flight} z {from} wylądował w {to}', 'notif.trip_reminder.title': 'Przypomnienie o podróży', 'notif.trip_reminder.text': 'Twoja podróż {trip} zbliża się!', 'notif.todo_due.title': 'Zadanie z terminem', diff --git a/shared/src/i18n/pl/settings.ts b/shared/src/i18n/pl/settings.ts index fd08bb455..746e90303 100644 --- a/shared/src/i18n/pl/settings.ts +++ b/shared/src/i18n/pl/settings.ts @@ -109,6 +109,9 @@ const settings: TranslationStrings = { 'settings.notifications': 'Powiadomienia', 'settings.notifyTripInvite': 'Zaproszenia do podróży', 'settings.notifyBookingChange': 'Zmiany w rezerwacjach', + 'settings.notifyFlightDeparted': 'Starty', + 'settings.notifyFlightDelayed': 'Opóźnienia lotów', + 'settings.notifyFlightLanded': 'Lądowania', 'settings.notifyTripReminder': 'Przypomnienia o podróżach', 'settings.notifyTodoDue': 'Zadanie z terminem', 'settings.notifyVacayInvite': 'Zaproszenia do połączenia kalendarzy', diff --git a/shared/src/i18n/ru/externalNotifications.ts b/shared/src/i18n/ru/externalNotifications.ts index 2ef91da5e..993e21530 100644 --- a/shared/src/i18n/ru/externalNotifications.ts +++ b/shared/src/i18n/ru/externalNotifications.ts @@ -17,6 +17,18 @@ const ru: NotificationLocale = { title: `Новое бронирование: ${p.booking}`, body: `${p.actor} добавил бронирование "${p.booking}" (${p.type}) в "${p.trip}".`, }), + flight_departed: (p) => ({ + title: `${p.flight} вылетел`, + body: `${p.flight} из ${p.from} в ${p.to} в воздухе.`, + }), + flight_delayed: (p) => ({ + title: `${p.flight} задерживается`, + body: `${p.flight} из ${p.from} в ${p.to} отстаёт от расписания примерно на ${p.delay} мин.`, + }), + flight_landed: (p) => ({ + title: `${p.flight} приземлился`, + body: `${p.flight} из ${p.from} приземлился в ${p.to}.`, + }), trip_reminder: (p) => ({ title: `Напоминание: ${p.trip}`, body: `Ваша поездка "${p.trip}" скоро начнётся!`, diff --git a/shared/src/i18n/ru/notif.ts b/shared/src/i18n/ru/notif.ts index e587759b8..9113e62a8 100644 --- a/shared/src/i18n/ru/notif.ts +++ b/shared/src/i18n/ru/notif.ts @@ -9,6 +9,12 @@ const notif: TranslationStrings = { 'notif.trip_invite.text': '{actor} пригласил вас в {trip}', 'notif.booking_change.title': 'Бронирование обновлено', 'notif.booking_change.text': '{actor} обновил бронирование в {trip}', + 'notif.flight_departed.title': 'Рейс вылетел', + 'notif.flight_departed.text': '{flight} из {from} в {to} в воздухе', + 'notif.flight_delayed.title': 'Рейс задерживается', + 'notif.flight_delayed.text': '{flight} из {from} в {to} задерживается примерно на {delay} мин', + 'notif.flight_landed.title': 'Рейс приземлился', + 'notif.flight_landed.text': '{flight} из {from} приземлился в {to}', 'notif.trip_reminder.title': 'Напоминание о поездке', 'notif.trip_reminder.text': 'Ваша поездка {trip} скоро начнётся!', 'notif.todo_due.title': 'Задача к сроку', diff --git a/shared/src/i18n/ru/settings.ts b/shared/src/i18n/ru/settings.ts index 226296e09..917431949 100644 --- a/shared/src/i18n/ru/settings.ts +++ b/shared/src/i18n/ru/settings.ts @@ -110,6 +110,9 @@ const settings: TranslationStrings = { 'settings.notifications': 'Уведомления', 'settings.notifyTripInvite': 'Приглашения в поездку', 'settings.notifyBookingChange': 'Изменения бронирований', + 'settings.notifyFlightDeparted': 'Вылеты', + 'settings.notifyFlightDelayed': 'Задержки рейсов', + 'settings.notifyFlightLanded': 'Посадки', 'settings.notifyTripReminder': 'Напоминания о поездке', 'settings.notifyTodoDue': 'Задача к сроку', 'settings.notifyVacayInvite': 'Приглашения слияния Vacay', diff --git a/shared/src/i18n/sk/externalNotifications.ts b/shared/src/i18n/sk/externalNotifications.ts index 562d3ca96..826e5e51b 100644 --- a/shared/src/i18n/sk/externalNotifications.ts +++ b/shared/src/i18n/sk/externalNotifications.ts @@ -17,6 +17,18 @@ const sk: NotificationLocale = { title: `Nová rezervácia: ${p.booking}`, body: `${p.actor} pridal(a) novú rezerváciu ${p.type} "${p.booking}" do "${p.trip}".`, }), + flight_departed: (p) => ({ + title: `${p.flight} odletel`, + body: `${p.flight} z ${p.from} do ${p.to} je vo vzduchu.`, + }), + flight_delayed: (p) => ({ + title: `${p.flight} mešká`, + body: `${p.flight} z ${p.from} do ${p.to} mešká približne ${p.delay} minút.`, + }), + flight_landed: (p) => ({ + title: `${p.flight} pristál`, + body: `${p.flight} z ${p.from} pristál v ${p.to}.`, + }), trip_reminder: (p) => ({ title: `Pripomienka cesty: ${p.trip}`, body: `Vaša cesta "${p.trip}" sa čoskoro blíži!`, diff --git a/shared/src/i18n/sk/notif.ts b/shared/src/i18n/sk/notif.ts index 522ba42dc..2b3588c73 100644 --- a/shared/src/i18n/sk/notif.ts +++ b/shared/src/i18n/sk/notif.ts @@ -9,6 +9,12 @@ const notif: TranslationStrings = { 'notif.trip_invite.text': '{actor} vás pozval na {trip}', 'notif.booking_change.title': 'Rezervácia aktualizovaná', 'notif.booking_change.text': '{actor} aktualizoval rezerváciu vo {trip}', + 'notif.flight_departed.title': 'Let odletel', + 'notif.flight_departed.text': '{flight} z {from} do {to} je vo vzduchu', + 'notif.flight_delayed.title': 'Let mešká', + 'notif.flight_delayed.text': '{flight} z {from} do {to} mešká približne {delay} min', + 'notif.flight_landed.title': 'Let pristál', + 'notif.flight_landed.text': '{flight} z {from} pristál v {to}', 'notif.trip_reminder.title': 'Pripomienka výletu', 'notif.trip_reminder.text': 'Váš výlet {trip} sa čoskoro blíži!', 'notif.todo_due.title': 'Úloha sa blíži', diff --git a/shared/src/i18n/sk/settings.ts b/shared/src/i18n/sk/settings.ts index 60db81974..d595473ad 100644 --- a/shared/src/i18n/sk/settings.ts +++ b/shared/src/i18n/sk/settings.ts @@ -123,6 +123,9 @@ const settings: TranslationStrings = { 'settings.notifications': 'Oznámenia', 'settings.notifyTripInvite': 'Pozvánky na cesty', 'settings.notifyBookingChange': 'Zmeny rezervácií', + 'settings.notifyFlightDeparted': 'Odlety', + 'settings.notifyFlightDelayed': 'Meškania letov', + 'settings.notifyFlightLanded': 'Pristátia', 'settings.notifyTripReminder': 'Pripomienky ciest', 'settings.notifyTodoDue': 'Úloha sa blíži', 'settings.notifyVacayInvite': 'Pozvánky na prepojenie Vacay', diff --git a/shared/src/i18n/sv/externalNotifications.ts b/shared/src/i18n/sv/externalNotifications.ts index 25a926303..501aa4590 100644 --- a/shared/src/i18n/sv/externalNotifications.ts +++ b/shared/src/i18n/sv/externalNotifications.ts @@ -17,6 +17,18 @@ const en: NotificationLocale = { title: `Ny bokning: ${p.booking}`, body: `${p.actor} la till en ny ${p.type} "${p.booking}" till "${p.trip}".`, }), + flight_departed: (p) => ({ + title: `${p.flight} har lyft`, + body: `${p.flight} från ${p.from} till ${p.to} är i luften.`, + }), + flight_delayed: (p) => ({ + title: `${p.flight} är försenat`, + body: `${p.flight} från ${p.from} till ${p.to} ligger cirka ${p.delay} minuter efter tidtabellen.`, + }), + flight_landed: (p) => ({ + title: `${p.flight} har landat`, + body: `${p.flight} från ${p.from} har landat i ${p.to}.`, + }), trip_reminder: (p) => ({ title: `Resepåminnelse: ${p.trip}`, body: `Din resa "${p.trip}" kommer snart!`, diff --git a/shared/src/i18n/sv/notif.ts b/shared/src/i18n/sv/notif.ts index 3160eede7..385a1b6c4 100644 --- a/shared/src/i18n/sv/notif.ts +++ b/shared/src/i18n/sv/notif.ts @@ -9,6 +9,12 @@ const notif: TranslationStrings = { 'notif.trip_invite.text': '{actor} bjöd in dig till {trip}', 'notif.booking_change.title': 'Bokningen har uppdaterats', 'notif.booking_change.text': '{actor} uppdaterat en bokning i {trip}', + 'notif.flight_departed.title': 'Flyget har lyft', + 'notif.flight_departed.text': '{flight} från {from} till {to} är i luften', + 'notif.flight_delayed.title': 'Flyget är försenat', + 'notif.flight_delayed.text': '{flight} från {from} till {to} är cirka {delay} min försenat', + 'notif.flight_landed.title': 'Flyget har landat', + 'notif.flight_landed.text': '{flight} från {from} har landat i {to}', 'notif.trip_reminder.title': 'Påminnelse om resan', 'notif.trip_reminder.text': 'Din resa {trip} kommer snart!', 'notif.todo_due.title': 'Uppgift förfaller', diff --git a/shared/src/i18n/sv/settings.ts b/shared/src/i18n/sv/settings.ts index c6bbd930a..8dbeca4a7 100644 --- a/shared/src/i18n/sv/settings.ts +++ b/shared/src/i18n/sv/settings.ts @@ -116,6 +116,9 @@ const settings: TranslationStrings = { 'settings.notifications': 'Meddelanden', 'settings.notifyTripInvite': 'Inbjudningar till resor', 'settings.notifyBookingChange': 'Ändringar i bokningen', + 'settings.notifyFlightDeparted': 'Avgångar', + 'settings.notifyFlightDelayed': 'Förseningar', + 'settings.notifyFlightLanded': 'Landningar', 'settings.notifyTripReminder': 'Påminnelser inför resan', 'settings.notifyTodoDue': 'Att göra ska göras snart', 'settings.notifyVacayInvite': 'Vacay samanslagnings inbjudningar', diff --git a/shared/src/i18n/th/externalNotifications.ts b/shared/src/i18n/th/externalNotifications.ts index 89da087c3..175e94f9f 100644 --- a/shared/src/i18n/th/externalNotifications.ts +++ b/shared/src/i18n/th/externalNotifications.ts @@ -16,6 +16,18 @@ const th: NotificationLocale = { title: `การจองใหม่: ${p.booking}`, body: `${p.actor} เพิ่ม${p.type}ใหม่ "${p.booking}" ลงใน "${p.trip}"`, }), + flight_departed: (p) => ({ + title: `เที่ยวบิน ${p.flight} ออกเดินทางแล้ว`, + body: `เที่ยวบิน ${p.flight} จาก ${p.from} ไป ${p.to} อยู่บนอากาศแล้ว`, + }), + flight_delayed: (p) => ({ + title: `เที่ยวบิน ${p.flight} ล่าช้า`, + body: `เที่ยวบิน ${p.flight} จาก ${p.from} ไป ${p.to} ล่าช้ากว่ากำหนดประมาณ ${p.delay} นาที`, + }), + flight_landed: (p) => ({ + title: `เที่ยวบิน ${p.flight} ลงจอดแล้ว`, + body: `เที่ยวบิน ${p.flight} จาก ${p.from} ลงจอดที่ ${p.to} แล้ว`, + }), trip_reminder: (p) => ({ title: `แจ้งเตือนทริป: ${p.trip}`, body: `ทริป "${p.trip}" ของคุณกำลังจะมาถึงเร็ว ๆ นี้!`, diff --git a/shared/src/i18n/th/notif.ts b/shared/src/i18n/th/notif.ts index 1b7f1c626..1707e1def 100644 --- a/shared/src/i18n/th/notif.ts +++ b/shared/src/i18n/th/notif.ts @@ -9,6 +9,12 @@ const notif: TranslationStrings = { 'notif.trip_invite.text': '{actor} เชิญคุณเข้าร่วม {trip}', 'notif.booking_change.title': 'อัปเดตการจองแล้ว', 'notif.booking_change.text': '{actor} อัปเดตการจองใน {trip}', + 'notif.flight_departed.title': 'เที่ยวบินออกเดินทางแล้ว', + 'notif.flight_departed.text': 'เที่ยวบิน {flight} จาก {from} ไป {to} อยู่บนอากาศแล้ว', + 'notif.flight_delayed.title': 'เที่ยวบินล่าช้า', + 'notif.flight_delayed.text': 'เที่ยวบิน {flight} จาก {from} ไป {to} ล่าช้าประมาณ {delay} นาที', + 'notif.flight_landed.title': 'เที่ยวบินลงจอดแล้ว', + 'notif.flight_landed.text': 'เที่ยวบิน {flight} จาก {from} ลงจอดที่ {to} แล้ว', 'notif.trip_reminder.title': 'การแจ้งเตือนการเดินทาง', 'notif.trip_reminder.text': 'การเดินทาง {trip} ของคุณกำลังจะมาถึงเร็วๆ นี้!', 'notif.todo_due.title': 'งานที่ถึงกำหนด', diff --git a/shared/src/i18n/th/settings.ts b/shared/src/i18n/th/settings.ts index bb8e516fc..670499379 100644 --- a/shared/src/i18n/th/settings.ts +++ b/shared/src/i18n/th/settings.ts @@ -108,6 +108,9 @@ const settings: TranslationStrings = { 'settings.notifications': 'การแจ้งเตือน', 'settings.notifyTripInvite': 'คำเชิญการเดินทาง', 'settings.notifyBookingChange': 'การเปลี่ยนแปลงการจอง', + 'settings.notifyFlightDeparted': 'เที่ยวบินออกเดินทาง', + 'settings.notifyFlightDelayed': 'เที่ยวบินล่าช้า', + 'settings.notifyFlightLanded': 'เที่ยวบินลงจอด', 'settings.notifyTripReminder': 'การแจ้งเตือนการเดินทาง', 'settings.notifyTodoDue': 'สิ่งที่ต้องทำจะครบกำหนดเร็วๆ นี้', 'settings.notifyVacayInvite': 'คำเชิญรวมปฏิทินวันลา', diff --git a/shared/src/i18n/tr/externalNotifications.ts b/shared/src/i18n/tr/externalNotifications.ts index ea8efea84..f389086c0 100644 --- a/shared/src/i18n/tr/externalNotifications.ts +++ b/shared/src/i18n/tr/externalNotifications.ts @@ -17,6 +17,18 @@ const tr: NotificationLocale = { title: `Yeni rezervasyon: ${p.booking}`, body: `${p.actor}, "${p.trip}" seyahatine "${p.booking}" (${p.type}) rezervasyonu ekledi.`, }), + flight_departed: (p) => ({ + title: `${p.flight} kalktı`, + body: `${p.from} çıkışlı ${p.to} varışlı ${p.flight} havada.`, + }), + flight_delayed: (p) => ({ + title: `${p.flight} rötarlı`, + body: `${p.from} çıkışlı ${p.to} varışlı ${p.flight} yaklaşık ${p.delay} dakika rötarlı.`, + }), + flight_landed: (p) => ({ + title: `${p.flight} indi`, + body: `${p.from} çıkışlı ${p.flight} ${p.to} varış noktasına indi.`, + }), trip_reminder: (p) => ({ title: `Seyahat hatırlatıcısı: ${p.trip}`, body: `"${p.trip}" seyahatiniz yaklaşıyor!`, diff --git a/shared/src/i18n/tr/notif.ts b/shared/src/i18n/tr/notif.ts index 4ba1c41fd..702da40ca 100644 --- a/shared/src/i18n/tr/notif.ts +++ b/shared/src/i18n/tr/notif.ts @@ -9,6 +9,12 @@ const notif: TranslationStrings = { 'notif.trip_invite.text': '{actor} sizi {trip} seyahatine davet etti', 'notif.booking_change.title': 'Rezervasyon Güncellendi', 'notif.booking_change.text': '{actor}, {trip} içindeki bir rezervasyonu güncelledi', + 'notif.flight_departed.title': 'Uçuş kalktı', + 'notif.flight_departed.text': '{from} çıkışlı {to} varışlı {flight} havada', + 'notif.flight_delayed.title': 'Uçuş rötarlı', + 'notif.flight_delayed.text': '{from} çıkışlı {to} varışlı {flight} yaklaşık {delay} dk rötarlı', + 'notif.flight_landed.title': 'Uçuş indi', + 'notif.flight_landed.text': '{from} çıkışlı {flight} {to} varış noktasına indi', 'notif.trip_reminder.title': 'Seyahat Hatırlatıcısı', 'notif.trip_reminder.text': '{trip} Seyahatiniz yaklaşıyor!', 'notif.todo_due.title': 'Yapılacak vadesi', diff --git a/shared/src/i18n/tr/settings.ts b/shared/src/i18n/tr/settings.ts index 98c8581f0..3bb4f2da5 100644 --- a/shared/src/i18n/tr/settings.ts +++ b/shared/src/i18n/tr/settings.ts @@ -113,6 +113,9 @@ const settings: TranslationStrings = { 'settings.notifications': 'Bildirimler', 'settings.notifyTripInvite': 'Seyahat davetleri', 'settings.notifyBookingChange': 'Rezervasyon değişiklikleri', + 'settings.notifyFlightDeparted': 'Kalkışlar', + 'settings.notifyFlightDelayed': 'Rötarlar', + 'settings.notifyFlightLanded': 'İnişler', 'settings.notifyTripReminder': 'Seyahat hatırlatıcıları', 'settings.notifyTodoDue': 'Yapılacak vadesi yakında', 'settings.notifyVacayInvite': 'Vacay birleştirme davetleri', diff --git a/shared/src/i18n/uk/externalNotifications.ts b/shared/src/i18n/uk/externalNotifications.ts index d942d198b..a0923c280 100644 --- a/shared/src/i18n/uk/externalNotifications.ts +++ b/shared/src/i18n/uk/externalNotifications.ts @@ -17,6 +17,18 @@ const uk: NotificationLocale = { title: `Нове бронювання: ${p.booking}`, body: `${p.actor} додав бронювання "${p.booking}" (${p.type}) до "${p.trip}".`, }), + flight_departed: (p) => ({ + title: `${p.flight} вилетів`, + body: `${p.flight} з ${p.from} до ${p.to} у повітрі.`, + }), + flight_delayed: (p) => ({ + title: `${p.flight} затримується`, + body: `${p.flight} з ${p.from} до ${p.to} відстає від розкладу приблизно на ${p.delay} хв.`, + }), + flight_landed: (p) => ({ + title: `${p.flight} приземлився`, + body: `${p.flight} з ${p.from} приземлився в ${p.to}.`, + }), trip_reminder: (p) => ({ title: `Нагадування про подорож: ${p.trip}`, body: `Ваша подорож "${p.trip}" наближається!`, diff --git a/shared/src/i18n/uk/notif.ts b/shared/src/i18n/uk/notif.ts index f2acac419..05ef8175a 100644 --- a/shared/src/i18n/uk/notif.ts +++ b/shared/src/i18n/uk/notif.ts @@ -9,6 +9,12 @@ const notif: TranslationStrings = { 'notif.trip_invite.text': '{actor} запросив вас до {trip}', 'notif.booking_change.title': 'Бронювання оновлено', 'notif.booking_change.text': '{actor} оновив бронювання в {trip}', + 'notif.flight_departed.title': 'Рейс вилетів', + 'notif.flight_departed.text': '{flight} з {from} до {to} у повітрі', + 'notif.flight_delayed.title': 'Рейс затримується', + 'notif.flight_delayed.text': '{flight} з {from} до {to} затримується приблизно на {delay} хв', + 'notif.flight_landed.title': 'Рейс приземлився', + 'notif.flight_landed.text': '{flight} з {from} приземлився в {to}', 'notif.trip_reminder.title': 'Нагадування про поїздку', 'notif.trip_reminder.text': 'Ваша поїздка {trip} скоро почнеться!', 'notif.todo_due.title': 'Завдання до терміну', diff --git a/shared/src/i18n/uk/settings.ts b/shared/src/i18n/uk/settings.ts index a5acdc113..8f16d78ef 100644 --- a/shared/src/i18n/uk/settings.ts +++ b/shared/src/i18n/uk/settings.ts @@ -112,6 +112,9 @@ const settings: TranslationStrings = { 'settings.notifications': 'Сповіщення', 'settings.notifyTripInvite': 'Запрошення до поїздки', 'settings.notifyBookingChange': 'Зміни бронювань', + 'settings.notifyFlightDeparted': 'Вильоти', + 'settings.notifyFlightDelayed': 'Затримки рейсів', + 'settings.notifyFlightLanded': 'Посадки', 'settings.notifyTripReminder': 'Нагадування про поїздку', 'settings.notifyTodoDue': 'Завдання до терміну', 'settings.notifyVacayInvite': "Запрошення об'єднання Vacay", diff --git a/shared/src/i18n/vi/externalNotifications.ts b/shared/src/i18n/vi/externalNotifications.ts index edde8ddff..4ea51f358 100644 --- a/shared/src/i18n/vi/externalNotifications.ts +++ b/shared/src/i18n/vi/externalNotifications.ts @@ -16,6 +16,18 @@ const en: NotificationLocale = { title: `Đặt chổ mới: ${p.booking}`, body: `${p.actor} đã thêm mới một ${p.type} "${p.booking}" vào "${p.trip}".`, }), + flight_departed: (p) => ({ + title: `${p.flight} đã cất cánh`, + body: `Chuyến bay ${p.flight} từ ${p.from} đến ${p.to} đang bay.`, + }), + flight_delayed: (p) => ({ + title: `${p.flight} bị trễ`, + body: `Chuyến bay ${p.flight} từ ${p.from} đến ${p.to} đang trễ khoảng ${p.delay} phút so với lịch.`, + }), + flight_landed: (p) => ({ + title: `${p.flight} đã hạ cánh`, + body: `Chuyến bay ${p.flight} từ ${p.from} đã hạ cánh tại ${p.to}.`, + }), trip_reminder: (p) => ({ title: `Nhắc nhở chuyến đi: ${p.trip}`, body: `Hành trình "${p.trip}" sắp bất đầu!`, diff --git a/shared/src/i18n/vi/notif.ts b/shared/src/i18n/vi/notif.ts index ad7aea0d2..f5a2f7494 100644 --- a/shared/src/i18n/vi/notif.ts +++ b/shared/src/i18n/vi/notif.ts @@ -9,6 +9,12 @@ const notif: TranslationStrings = { 'notif.trip_invite.text': '{actor} đã mời bạn tham gia {trip}', 'notif.booking_change.title': 'Đã cập nhật đặt chỗ', 'notif.booking_change.text': '{actor} đã cập nhật lượt đặt chỗ trong {trip}', + 'notif.flight_departed.title': 'Chuyến bay đã cất cánh', + 'notif.flight_departed.text': 'Chuyến bay {flight} từ {from} đến {to} đang bay', + 'notif.flight_delayed.title': 'Chuyến bay bị trễ', + 'notif.flight_delayed.text': 'Chuyến bay {flight} từ {from} đến {to} trễ khoảng {delay} phút', + 'notif.flight_landed.title': 'Chuyến bay đã hạ cánh', + 'notif.flight_landed.text': 'Chuyến bay {flight} từ {from} đã hạ cánh tại {to}', 'notif.trip_reminder.title': 'Nhắc nhở chuyến đi', 'notif.trip_reminder.text': 'Chuyến đi {trip} của bạn sắp diễn ra!', 'notif.todo_due.title': 'Việc cần làm đến hạn', diff --git a/shared/src/i18n/vi/settings.ts b/shared/src/i18n/vi/settings.ts index 7eaf84a47..b0bdde1da 100644 --- a/shared/src/i18n/vi/settings.ts +++ b/shared/src/i18n/vi/settings.ts @@ -117,6 +117,9 @@ const settings: TranslationStrings = { 'settings.notifications': 'Thông báo', 'settings.notifyTripInvite': 'Lời mời chuyến đi', 'settings.notifyBookingChange': 'Thay đổi đặt chỗ', + 'settings.notifyFlightDeparted': 'Chuyến bay cất cánh', + 'settings.notifyFlightDelayed': 'Chuyến bay bị trễ', + 'settings.notifyFlightLanded': 'Chuyến bay hạ cánh', 'settings.notifyTripReminder': 'Lời nhắc chuyến đi', 'settings.notifyTodoDue': 'Việc cần làm sắp đến hạn', 'settings.notifyVacayInvite': 'Lời mời kết hợp Vacay', diff --git a/shared/src/i18n/zh-TW/externalNotifications.ts b/shared/src/i18n/zh-TW/externalNotifications.ts index 073080327..9f917bd1c 100644 --- a/shared/src/i18n/zh-TW/externalNotifications.ts +++ b/shared/src/i18n/zh-TW/externalNotifications.ts @@ -17,6 +17,18 @@ const zhTW: NotificationLocale = { title: `新預訂:${p.booking}`, body: `${p.actor} 在「${p.trip}」中新增了預訂「${p.booking}」(${p.type})。`, }), + flight_departed: (p) => ({ + title: `${p.flight}已起飛`, + body: `從${p.from}飛往${p.to}的${p.flight}已在空中。`, + }), + flight_delayed: (p) => ({ + title: `${p.flight}延誤`, + body: `從${p.from}飛往${p.to}的${p.flight}比預定晚約${p.delay}分鐘。`, + }), + flight_landed: (p) => ({ + title: `${p.flight}已降落`, + body: `從${p.from}出發的${p.flight}已降落在${p.to}。`, + }), trip_reminder: (p) => ({ title: `行程提醒:${p.trip}`, body: `您的行程「${p.trip}」即將開始!`, diff --git a/shared/src/i18n/zh-TW/notif.ts b/shared/src/i18n/zh-TW/notif.ts index d65628691..9cbda8727 100644 --- a/shared/src/i18n/zh-TW/notif.ts +++ b/shared/src/i18n/zh-TW/notif.ts @@ -9,6 +9,12 @@ const notif: TranslationStrings = { 'notif.trip_invite.text': '{actor} 邀請你加入 {trip}', 'notif.booking_change.title': '預訂已更新', 'notif.booking_change.text': '{actor} 更新了 {trip} 中的預訂', + 'notif.flight_departed.title': '航班已起飛', + 'notif.flight_departed.text': '從{from}飛往{to}的{flight}已在空中', + 'notif.flight_delayed.title': '航班延誤', + 'notif.flight_delayed.text': '從{from}飛往{to}的{flight}晚點約{delay}分鐘', + 'notif.flight_landed.title': '航班已降落', + 'notif.flight_landed.text': '從{from}出發的{flight}已降落在{to}', 'notif.trip_reminder.title': '旅行提醒', 'notif.trip_reminder.text': '你的旅行 {trip} 即將開始!', 'notif.todo_due.title': '待辦事項即將到期', diff --git a/shared/src/i18n/zh-TW/settings.ts b/shared/src/i18n/zh-TW/settings.ts index 1156d4fdc..f6aeac2f3 100644 --- a/shared/src/i18n/zh-TW/settings.ts +++ b/shared/src/i18n/zh-TW/settings.ts @@ -104,6 +104,9 @@ const settings: TranslationStrings = { 'settings.notifications': '通知', 'settings.notifyTripInvite': '旅行邀請', 'settings.notifyBookingChange': '預訂變更', + 'settings.notifyFlightDeparted': '航班起飛', + 'settings.notifyFlightDelayed': '航班延誤', + 'settings.notifyFlightLanded': '航班降落', 'settings.notifyTripReminder': '旅行提醒', 'settings.notifyTodoDue': '待辦事項即將到期', 'settings.notifyVacayInvite': 'Vacay 融合邀請', diff --git a/shared/src/i18n/zh/externalNotifications.ts b/shared/src/i18n/zh/externalNotifications.ts index 9627fc73a..41262a4b0 100644 --- a/shared/src/i18n/zh/externalNotifications.ts +++ b/shared/src/i18n/zh/externalNotifications.ts @@ -17,6 +17,18 @@ const zh: NotificationLocale = { title: `新预订:${p.booking}`, body: `${p.actor} 在"${p.trip}"中添加了预订"${p.booking}"(${p.type})。`, }), + flight_departed: (p) => ({ + title: `${p.flight}已起飞`, + body: `从${p.from}飞往${p.to}的${p.flight}已在空中。`, + }), + flight_delayed: (p) => ({ + title: `${p.flight}延误`, + body: `从${p.from}飞往${p.to}的${p.flight}比计划晚约${p.delay}分钟。`, + }), + flight_landed: (p) => ({ + title: `${p.flight}已降落`, + body: `从${p.from}出发的${p.flight}已降落在${p.to}。`, + }), trip_reminder: (p) => ({ title: `旅行提醒:${p.trip}`, body: `你的旅行"${p.trip}"即将开始!`, diff --git a/shared/src/i18n/zh/notif.ts b/shared/src/i18n/zh/notif.ts index 21a0ef709..8f51f7e29 100644 --- a/shared/src/i18n/zh/notif.ts +++ b/shared/src/i18n/zh/notif.ts @@ -9,6 +9,12 @@ const notif: TranslationStrings = { 'notif.trip_invite.text': '{actor} 邀请您加入 {trip}', 'notif.booking_change.title': '预订已更新', 'notif.booking_change.text': '{actor} 更新了 {trip} 中的预订', + 'notif.flight_departed.title': '航班已起飞', + 'notif.flight_departed.text': '从{from}飞往{to}的{flight}已在空中', + 'notif.flight_delayed.title': '航班延误', + 'notif.flight_delayed.text': '从{from}飞往{to}的{flight}晚点约{delay}分钟', + 'notif.flight_landed.title': '航班已降落', + 'notif.flight_landed.text': '从{from}出发的{flight}已降落在{to}', 'notif.trip_reminder.title': '旅行提醒', 'notif.trip_reminder.text': '您的旅行 {trip} 即将开始!', 'notif.todo_due.title': '待办事项即将到期', diff --git a/shared/src/i18n/zh/settings.ts b/shared/src/i18n/zh/settings.ts index 4f36bcb88..9ed2da653 100644 --- a/shared/src/i18n/zh/settings.ts +++ b/shared/src/i18n/zh/settings.ts @@ -103,6 +103,9 @@ const settings: TranslationStrings = { 'settings.notifications': '通知', 'settings.notifyTripInvite': '旅行邀请', 'settings.notifyBookingChange': '预订变更', + 'settings.notifyFlightDeparted': '航班起飞', + 'settings.notifyFlightDelayed': '航班延误', + 'settings.notifyFlightLanded': '航班降落', 'settings.notifyTripReminder': '旅行提醒', 'settings.notifyTodoDue': '待办事项即将到期', 'settings.notifyVacayInvite': '假期合并邀请', diff --git a/shared/src/index.ts b/shared/src/index.ts index 2b76fbe4e..e9433cfd7 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -22,6 +22,7 @@ export * from './paddle/paddle-regions.schema'; export * from './paddle/paddle-route.schema'; export * from './public-api/public-api.schema'; export * from './airport/airport.schema'; +export * from './flight-live/flight-live.schema'; export * from './config/config.schema'; export * from './system-notice/system-notice.schema'; export * from './maps/maps.schema'; diff --git a/shared/src/realtime/events.schema.spec.ts b/shared/src/realtime/events.schema.spec.ts index a30cba7cd..874a237a9 100644 --- a/shared/src/realtime/events.schema.spec.ts +++ b/shared/src/realtime/events.schema.spec.ts @@ -69,6 +69,44 @@ const FIXTURES: Record> = { 'reservation:deleted': { reservationId: 6 }, 'reservation:positions': { positions: [{ id: 6, position: 0 }], day_id: 2 }, 'reservation:travelers-updated': { reservationId: 6, travelers: [{ user_id: 1 }] }, + 'flight:live': { + flights: [ + { + reservation_id: 6, + leg_index: 0, + callsign: 'DLH400', + flight_number: 'LH 400', + service_date: '2026-06-11', + origin: { code: 'FRA', name: 'Frankfurt', lat: 50.03, lng: 8.57, timezone: 'Europe/Berlin' }, + destination: { code: 'JFK', name: 'New York JFK', lat: 40.64, lng: -73.78, timezone: 'America/New_York' }, + phase: 'airborne', + scheduled_departure: '2026-06-11T11:50:00.000Z', + scheduled_arrival: '2026-06-11T20:35:00.000Z', + departed_at: '2026-06-11T12:14:00.000Z', + landed_at: null, + estimated_arrival: '2026-06-11T20:51:00.000Z', + departure_delay_min: 24, + arrival_delay_min: 16, + progress: 0.42, + distance_total_km: 6200, + distance_remaining_km: 3596, + position: { + lat: 55.1, + lng: -20.4, + altitude_ft: 37000, + ground_speed_kt: 470, + track_deg: 285, + vertical_rate_fpm: 0, + on_ground: false, + }, + aircraft: { registration: 'D-AIXA', type: 'A359' }, + last_seen_at: '2026-06-11T15:40:00.000Z', + fetched_at: '2026-06-11T15:40:10.000Z', + stale: false, + source: 'adsb.lol', + }, + ], + }, 'accommodation:created': { accommodation: { id: 8 } }, 'accommodation:updated': { accommodation: { id: 8 } }, 'accommodation:deleted': { accommodationId: 8 }, @@ -177,9 +215,9 @@ describe('@trek/shared realtime event registry', () => { // notification:read-all, trips:changed, settings:updated, tags:changed, // atlas:changed, journey:updated, journey:deleted). Registered first, the // writes that send them follow. - expect(TREK_WS_TRIP_EVENT_NAMES).toHaveLength(79); + expect(TREK_WS_TRIP_EVENT_NAMES).toHaveLength(80); expect(TREK_WS_USER_EVENT_NAMES).toHaveLength(41); - expect(TREK_WS_EVENT_NAMES).toHaveLength(120); + expect(TREK_WS_EVENT_NAMES).toHaveLength(121); }); it('WSEVT-REG-002: every name is domain:action shaped and outside the reserved plugin: namespace', () => { diff --git a/shared/src/realtime/events.schema.ts b/shared/src/realtime/events.schema.ts index 94c774526..e43d8c8d7 100644 --- a/shared/src/realtime/events.schema.ts +++ b/shared/src/realtime/events.schema.ts @@ -1,3 +1,4 @@ +import { flightLiveLegSchema } from '../flight-live/flight-live.schema'; import { roadtripDayBoundaryListSchema } from '../roadtrip/day-boundary.schema'; import { roadtripPreferencesSchema } from '../roadtrip/preferences.schema'; @@ -215,6 +216,8 @@ export const TREK_WS_EVENTS = { scope: 'trip', payload: z.object({ reservationId: id, travelers: z.array(z.unknown()) }), }, + // Live flight states of the trip that changed in the last poll (flight-live job). + 'flight:live': { scope: 'trip', payload: z.object({ flights: z.array(flightLiveLegSchema) }) }, // ── Accommodations ─────────────────────────────────────────────────────── // DRIFT: `{ accommodation }` vs empty refetch ping `{}` (reservation cascade diff --git a/wiki/MCP-Tools-and-Resources.md b/wiki/MCP-Tools-and-Resources.md index ed536c15e..69fd5dcb2 100644 --- a/wiki/MCP-Tools-and-Resources.md +++ b/wiki/MCP-Tools-and-Resources.md @@ -151,6 +151,7 @@ Requires `reservations:read` or `reservations:write` scope. | `link_hotel_accommodation` | Set or update a hotel reservation's check-in/out day links and place. | | `list_reservations` | The bookings of one trip with their readable details (`details`: airline, flight or train number, platform, seat, the legs of a stopover booking, check-in and check-out times, price, and `has_leg_reference` when a leg holds a booking reference of its own, never the reference itself) in place of the raw metadata. `kinds` keeps `transport` or `booking`, `dayId` the bookings on that day. Requires `reservations:read`. | | `list_upcoming_reservations` | The next bookings across all of the user's trips, soonest first, for "what is coming up?" when no particular trip is in question. Hotel stays appear as their check-in and check-out moments. Cancelled bookings, planned public-transit journeys and archived trips are left out. `limit` defaults to 6, at most 50. Requires `reservations:read`. | +| `get_trip_flight_status` | The live status of a trip's booked flights, one entry per flight leg with a known flight number and both airports: the schedule in UTC with each airport's time zone, the phase (`scheduled`, `taxiing`, `airborne`, `landed`, `unknown`), observed wheels-up and touchdown, an estimated arrival with the delays worked out from it, progress and distances, the last position and the aircraft. Positions come from the free ADS-B feeds adsb.lol and, on a self-hosted install, adsb.fi; only the callsign is sent to them. Gates and cancellations are not known, and delays and arrival times are estimates. `enabled` is false when an admin switched live flight status off. Requires `reservations:read`. | ### Budget