mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(airtrail): read and write the passenger list under both its names
AirTrail 3.12.0 renamed the flight's passenger list from `seats` to `passengers` and moved flightReason from the flight onto each passenger. There is no alias in either direction: a 3.12.0 instance returns `passengers` and `null` for `seats`, and its save schema accepts only `passengers`. TREK read `raw.seats` everywhere, so against a 3.12.0 instance it found nothing. The seat never made it into the import, and because a pull rebuilds the metadata from the AirTrail payload, flights that had already been imported lost the seat they had. That is the report. Flights whose sync is switched off were never re-pulled, which is why theirs survived. Two more breaks came from the same rename and were not reported. The flight reason stopped importing for the same reason. And the writeback pushed the seat under `seats`, which the new schema strips, so a seat edited in TREK was quietly dropped on the way out; it did not fail loudly because the spread of the fetched flight carried AirTrail's own `passengers` through untouched. Both shapes are handled rather than switched between, because instances on 3.11 and older are still out there: reads take `passengers ?? seats`, and the save body carries the manifest under both keys. Neither schema is strict, so each version keeps the key it knows and strips the other. The snapshot hash deliberately keeps feeding the manifest in under the same key. An unchanged flight on an older instance therefore hashes exactly as before and does not re-sync, while a 3.12.0 flight hashes differently once and pulls the missing seat back in. There is a pinned-hash test guarding the first half. Verified against real instances rather than the docs: a 3.11.1 and a 3.12.0 container, seat imported from each, a seat edited in TREK written back and read out of AirTrail again, and the pre-fix build failing the same checks on 3.12.0. Closes #1931
This commit is contained in:
@@ -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: '<USER_ID>', 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;
|
||||
}
|
||||
|
||||
@@ -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 '<USER_ID>' 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 '<USER_ID>' 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<AirtrailFlightRaw, 'seats' | 'passengers'>): AirtrailSeat[] {
|
||||
return raw.passengers ?? raw.seats ?? [];
|
||||
}
|
||||
|
||||
export function ownPassenger(raw: Pick<AirtrailFlightRaw, 'seats' | 'passengers'>): 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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<AirtrailFlightRaw> = {}) =>
|
||||
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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<AirtrailFlightRaw> & Record<string, unknown> = {}) =>
|
||||
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: '<USER_ID>', seatNumber: '12A' });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user