diff --git a/server/src/nest/integrations/airtrail-sync.helpers.ts b/server/src/nest/integrations/airtrail-sync.helpers.ts index 50e1220bc..a174cbf79 100644 --- a/server/src/nest/integrations/airtrail-sync.helpers.ts +++ b/server/src/nest/integrations/airtrail-sync.helpers.ts @@ -1,5 +1,6 @@ import { entityCode } from './airtrail.mapper'; -import type { AirtrailFlightRaw, AirtrailSavePayload } from './airtrail.client'; +import { flightPassengers, ownPassenger } from './airtrail.client'; +import type { AirtrailFlightRaw, AirtrailPassengerWrite, AirtrailSavePayload } from './airtrail.client'; /** * The pure half of the AirTrail push: turning a TREK reservation plus the flight @@ -43,26 +44,37 @@ export function buildSavePayload(reservation: any, existing: AirtrailFlightRaw): const arr = splitLocal(reservation.reservation_end_time); if (!dep.date) return null; - // Preserve the existing seat manifest (an update replaces all seats); fall back - // to the key-owner placeholder so AirTrail attributes it to the connecting user. - const seats = (existing.seats ?? []).map((s) => ({ + // Preserve the existing passenger manifest (an update replaces all of them); + // fall back to the key-owner placeholder so AirTrail attributes it to the + // connecting user. 3.12.0 renamed the list from seats to passengers, so read + // whichever one this instance sent. + const seats: AirtrailPassengerWrite[] = flightPassengers(existing).map((s) => ({ userId: s.userId, guestName: s.guestName, seat: s.seat, seatNumber: s.seatNumber, seatClass: s.seatClass, + ...(s.flightReason !== undefined ? { flightReason: s.flightReason } : {}), })); if (seats.length === 0) { seats.push({ userId: '', guestName: null, seat: null, seatNumber: null, seatClass: null }); } - // Push the seat the user set in TREK onto their own AirTrail seat (the one with - // a userId), leaving any co-passenger seats untouched. + // Push the seat the user set in TREK onto their own AirTrail entry (the one + // with a userId), leaving any co-passenger seats untouched. const seatNumber = typeof meta.seat === 'string' && meta.seat.trim() ? meta.seat.trim() : null; - if (seatNumber) { - const ownSeat = seats.find((s) => s.userId) ?? seats[0]; - if (ownSeat) ownSeat.seatNumber = seatNumber; - } + const ownSeat = seats.find((s) => s.userId) ?? seats[0]; + if (seatNumber && ownSeat) ownSeat.seatNumber = seatNumber; + + // The reason lives on the flight up to 3.11.x and on the passenger from 3.12.0. + // Resolve it once from whichever place this instance kept it, then write it to + // both — the version that does not know a key drops it. + const reason = + (meta.flight_reason as string | undefined) ?? + existing.flightReason ?? + ownPassenger(existing)?.flightReason ?? + null; + if (ownSeat) ownSeat.flightReason = reason; // Spread the existing flight first to preserve every AirTrail-owned field, then // overwrite only what TREK manages. `from`/`to`/`airline`/`aircraft` come back @@ -95,8 +107,11 @@ export function buildSavePayload(reservation: any, existing: AirtrailFlightRaw): flightNumber: meta.flight_number ?? existing.flightNumber ?? null, aircraft: meta.aircraft ?? entityCode(existing.aircraft) ?? null, aircraftReg: meta.aircraft_reg ?? existing.aircraftReg ?? null, - flightReason: meta.flight_reason ?? existing.flightReason ?? null, + // Flight-level up to 3.11.x, per-passenger from 3.12.0. Write both; each + // version keeps the one it knows and strips the other. + flightReason: reason, note: reservation.notes ?? existing.note ?? null, seats, + passengers: seats, } as AirtrailSavePayload; } diff --git a/server/src/nest/integrations/airtrail.client.ts b/server/src/nest/integrations/airtrail.client.ts index 10930af53..80e18235e 100644 --- a/server/src/nest/integrations/airtrail.client.ts +++ b/server/src/nest/integrations/airtrail.client.ts @@ -11,9 +11,15 @@ import { safeFetch } from '../../utils/ssrfGuard'; * param so the key only ever returns its owner's own flights (isolation * holds even if an admin key is pasted). * - GET /api/flight/get/{id} - * - POST /api/flight/save — `id` present => update, else create. seats[] is - * required (>=1). A seat with userId '' is attributed to the key - * owner server-side, so we never need the caller's AirTrail user id. + * - POST /api/flight/save — `id` present => update, else create. The + * passenger list is required (>=1). An entry with userId '' is + * attributed to the key owner server-side, so we never need the caller's + * AirTrail user id. + * - AirTrail 3.12.0 renamed that list from `seats` to `passengers` and moved + * flightReason from the flight onto each passenger, with no alias on either + * side (verified against running 3.11.1 and 3.12.0 instances: each returns + * its own key and null for the other). TREK reads and writes both shapes so + * one build works against either version. * - There is no webhook and no updated_at on a flight, so change detection is * snapshot-hash based (see airtrailSync). */ @@ -60,6 +66,8 @@ export interface AirtrailSeat { seat: string | null; seatNumber: string | null; seatClass: string | null; + /** Per-passenger since 3.12.0; absent on older instances. */ + flightReason?: string | null; } /** Airline/aircraft come back as joined objects (not bare codes) on a flight. */ @@ -85,9 +93,26 @@ export interface AirtrailFlightRaw { flightNumber: string | null; aircraft: AirtrailNamedCode | null; aircraftReg: string | null; - flightReason: string | null; + /** Flight-level up to 3.11.x; gone in 3.12.0, where it sits on the passenger. */ + flightReason?: string | null; note: string | null; - seats: AirtrailSeat[]; + /** 3.11.x and older. */ + seats?: AirtrailSeat[]; + /** 3.12.0 and newer. */ + passengers?: AirtrailSeat[]; +} + +/** + * The passenger list under whichever name this instance uses, and the entry + * belonging to the key owner (the one carrying a userId). + */ +export function flightPassengers(raw: Pick): AirtrailSeat[] { + return raw.passengers ?? raw.seats ?? []; +} + +export function ownPassenger(raw: Pick): AirtrailSeat | undefined { + const people = flightPassengers(raw); + return people.find((s) => s.userId) ?? people[0]; } /** Write shape accepted by POST /flight/save (airports/airline/aircraft as codes). */ @@ -110,13 +135,21 @@ export interface AirtrailSavePayload { aircraftReg?: string | null; flightReason?: string | null; note?: string | null; - seats: Array<{ - userId: string | null; - guestName: string | null; - seat: string | null; - seatNumber: string | null; - seatClass: string | null; - }>; + /** + * Sent under both names on purpose. Neither schema is strict, so each version + * validates the key it knows and strips the other one. + */ + seats: AirtrailPassengerWrite[]; + passengers: AirtrailPassengerWrite[]; +} + +export interface AirtrailPassengerWrite { + userId: string | null; + guestName: string | null; + seat: string | null; + seatNumber: string | null; + seatClass: string | null; + flightReason?: string | null; } function apiBase(baseUrl: string): string { diff --git a/server/src/nest/integrations/airtrail.mapper.ts b/server/src/nest/integrations/airtrail.mapper.ts index 122002be5..ce1bdba6f 100644 --- a/server/src/nest/integrations/airtrail.mapper.ts +++ b/server/src/nest/integrations/airtrail.mapper.ts @@ -1,4 +1,5 @@ import { localParts } from '../common/timezoneService'; +import { flightPassengers, ownPassenger } from './airtrail.client'; import type { AirtrailAirport, AirtrailFlightRaw, AirtrailNamedCode } from './airtrail.client'; import type { AirtrailFlight } from '@trek/shared'; @@ -40,7 +41,7 @@ export function normalizeFlight(raw: AirtrailFlightRaw): AirtrailFlight { airline: entityName(raw.airline), flightNumber: raw.flightNumber ?? null, aircraft: entityCode(raw.aircraft), - seatClass: (raw.seats?.find((s) => s.userId) ?? raw.seats?.[0])?.seatClass ?? null, + seatClass: ownPassenger(raw)?.seatClass ?? null, }; } @@ -124,7 +125,7 @@ export function mapFlightToReservation(raw: AirtrailFlightRaw): MappedReservatio needsReview = 1; } - const seat = raw.seats?.find((s) => s.userId) ?? raw.seats?.[0]; + const seat = ownPassenger(raw); const airlineName = entityName(raw.airline); const airlineCode = entityCode(raw.airline); const aircraftCode = entityCode(raw.aircraft); @@ -136,7 +137,9 @@ export function mapFlightToReservation(raw: AirtrailFlightRaw): MappedReservatio if (raw.flightNumber) metadata.flight_number = raw.flightNumber; if (aircraftCode) metadata.aircraft = aircraftCode; if (raw.aircraftReg) metadata.aircraft_reg = raw.aircraftReg; - if (raw.flightReason) metadata.flight_reason = raw.flightReason; + // 3.12.0 moved the reason onto the passenger; older instances keep it on the flight. + const flightReason = raw.flightReason ?? seat?.flightReason ?? null; + if (flightReason) metadata.flight_reason = flightReason; if (seat?.seatNumber) metadata.seat = seat.seatNumber; // The flight number already carries the airline prefix (e.g. "SAS983"), so it @@ -227,7 +230,7 @@ export function mapFlightsToMultiLegReservation( needsReview = 1; } - const seat = f.seats?.find((s) => s.userId) ?? f.seats?.[0]; + const seat = ownPassenger(f); const airline = entityName(f.airline); legs.push({ from: airportCode(f.from), @@ -304,9 +307,9 @@ export function canonicalHash(raw: AirtrailFlightRaw): string { flightNumber: raw.flightNumber ?? null, aircraft: entityCode(raw.aircraft), aircraftReg: raw.aircraftReg ?? null, - flightReason: raw.flightReason ?? null, + flightReason: raw.flightReason ?? ownPassenger(raw)?.flightReason ?? null, note: raw.note ?? null, - seats: (raw.seats ?? []) + seats: flightPassengers(raw) .map((s) => ({ userId: s.userId ?? null, guestName: s.guestName ?? null, diff --git a/server/tests/unit/services/airtrailMapper.test.ts b/server/tests/unit/services/airtrailMapper.test.ts index 4501c5918..689a20367 100644 --- a/server/tests/unit/services/airtrailMapper.test.ts +++ b/server/tests/unit/services/airtrailMapper.test.ts @@ -309,3 +309,68 @@ describe('airtrailMapper.mapFlightsToMultiLegReservation (#1535)', () => { expect(mapFlightsToMultiLegReservation([flight()])).toEqual(mapFlightToReservation(flight())); }); }); + +// AirTrail 3.12.0 renamed the passenger list from `seats` to `passengers` and +// moved flightReason from the flight onto each passenger, with no alias either +// way (#1931). The shapes below are the ones a real 3.12.0 and a real 3.11.1 +// return: each sends its own key and null for the other. +describe('airtrailMapper — AirTrail 3.12.0 passengers', () => { + const newShape = (over: Partial = {}) => + flight({ + seats: null as never, + flightReason: null, + passengers: [{ userId: 'u1', guestName: null, seat: 'window', seatNumber: '12A', seatClass: 'economy', flightReason: 'business' }], + ...over, + }); + + it('reads the seat off passengers', () => { + expect(mapFlightToReservation(newShape()).metadata.seat).toBe('12A'); + }); + + it('reads the seat class off passengers', () => { + expect(normalizeFlight(newShape()).seatClass).toBe('economy'); + }); + + it('takes the flight reason off the passenger now that the flight has none', () => { + expect(mapFlightToReservation(newShape()).metadata.flight_reason).toBe('business'); + }); + + it('prefers the entry belonging to the key owner over a co-passenger', () => { + const m = mapFlightToReservation(newShape({ + passengers: [ + { userId: null, guestName: 'Plus One', seat: null, seatNumber: '30C', seatClass: null }, + { userId: 'u1', guestName: null, seat: 'window', seatNumber: '12A', seatClass: 'economy' }, + ], + })); + expect(m.metadata.seat).toBe('12A'); + }); + + it('still reads the old shape, so an older instance keeps working', () => { + const m = mapFlightToReservation(flight()); + expect(m.metadata.seat).toBe('12A'); + expect(m.metadata.flight_reason).toBe('leisure'); + }); + + it('carries the per-leg seat through the multi-leg mapping', () => { + const legs = mapFlightsToMultiLegReservation([ + newShape({ id: 1 }), + newShape({ id: 2, passengers: [{ userId: 'u1', guestName: null, seat: null, seatNumber: '3F', seatClass: null }] }), + ]).metadata.legs as any[]; + expect(legs.map(l => l.seat)).toEqual(['12A', '3F']); + }); + + // The snapshot hash decides whether a pull re-imports a flight. Feeding it from + // either shape under the same key keeps an unchanged old-shape flight hashing + // exactly as before, so upgrading TREK must not re-sync every flight. + it('leaves the hash of an unchanged old-shape flight alone', () => { + // Pinned to what the pre-#1931 snapshot produced for this fixture. If this + // ever has to be re-pinned, every existing AirTrail flight re-syncs once. + expect(canonicalHash(flight())).toBe('81d00fb3b22dffa5575221f73c959f5762e50e20e9c5cf7ecc5a485a1845b358'); + }); + + it('hashes the new shape as the same flight as the old one', () => { + const asOld = flight({ flightReason: 'business' }); + const asNew = newShape(); + expect(canonicalHash(asNew)).toBe(canonicalHash(asOld)); + }); +}); diff --git a/server/tests/unit/services/airtrailSync.test.ts b/server/tests/unit/services/airtrailSync.test.ts index 25f253d10..f1089a6d9 100644 --- a/server/tests/unit/services/airtrailSync.test.ts +++ b/server/tests/unit/services/airtrailSync.test.ts @@ -183,3 +183,64 @@ describe('airtrailSync.buildSavePayload', () => { expect(payload).toBeNull(); }); }); + +// AirTrail 3.12.0 renamed the passenger list from `seats` to `passengers`, with +// no alias on the save endpoint either (#1931). Neither schema is strict, so the +// payload carries both names and each version keeps the one it knows. +describe('airtrailSync.buildSavePayload — AirTrail 3.12.0 passengers', () => { + const newShape = (over: Partial & Record = {}) => + existingFlight({ + seats: undefined, + flightReason: undefined, + passengers: [{ userId: 'u1', guestName: null, seat: 'window', seatNumber: '12A', seatClass: 'economy', flightReason: 'leisure' }], + ...over, + }); + + const own = (list: unknown) => (list as Array<{ userId: string | null }>).find(s => s.userId); + + it('sends the manifest under both names', () => { + const payload = buildSavePayload(reservation(), newShape()) as SavePayloadWithPassthrough; + expect(payload.passengers).toEqual(payload.seats); + expect(own(payload.passengers)).toMatchObject({ seatNumber: '12A', seatClass: 'economy' }); + }); + + it('reads the existing manifest off passengers and pushes the TREK seat onto it', () => { + const res = reservation({ metadata: JSON.stringify({ seat: '22F' }) }); + const payload = buildSavePayload(res, newShape()) as SavePayloadWithPassthrough; + expect(own(payload.passengers)).toMatchObject({ seatNumber: '22F' }); + expect(own(payload.seats)).toMatchObject({ seatNumber: '22F' }); + // A co-passenger's seat is not TREK's to touch. + const withGuest = buildSavePayload(res, newShape({ + passengers: [ + { userId: null, guestName: 'Plus One', seat: null, seatNumber: '30C', seatClass: null }, + { userId: 'u1', guestName: null, seat: 'window', seatNumber: '12A', seatClass: 'economy' }, + ], + })) as SavePayloadWithPassthrough; + const guest = (withGuest.passengers as Array<{ guestName: string | null; seatNumber: string | null }>).find(s => s.guestName); + expect(guest?.seatNumber).toBe('30C'); + }); + + it('writes the flight reason both on the flight and on the passenger', () => { + const payload = buildSavePayload(reservation(), newShape()) as SavePayloadWithPassthrough; + expect(payload.flightReason).toBe('leisure'); + expect(own(payload.passengers)).toMatchObject({ flightReason: 'leisure' }); + }); + + it('recovers the reason from the passenger when the flight no longer carries one', () => { + const res = reservation({ metadata: JSON.stringify({ seat: '12A' }) }); + const payload = buildSavePayload(res, newShape()) as SavePayloadWithPassthrough; + expect(payload.flightReason).toBe('leisure'); + }); + + it('still fills the manifest from the old shape', () => { + const payload = buildSavePayload(reservation(), existingFlight()) as SavePayloadWithPassthrough; + expect(own(payload.passengers)).toMatchObject({ seatNumber: '12A' }); + expect(own(payload.seats)).toMatchObject({ seatNumber: '12A' }); + }); + + it('falls back to the key-owner placeholder when the flight has no manifest at all', () => { + const payload = buildSavePayload(reservation(), newShape({ passengers: [] })) as SavePayloadWithPassthrough; + expect(payload.passengers).toHaveLength(1); + expect(own(payload.passengers)).toMatchObject({ userId: '', seatNumber: '12A' }); + }); +});