mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
feat(airtrail): import connecting flights as one multi-leg booking
AirTrail flights always imported as separate single-leg reservations, so a layover could not be expressed and the connection country ended up counted as visited in Atlas (#1535). The import picker now detects connection chains among the listed flights — each leg departing from the airport the previous one landed at, onward within 24 hours, and never back to the origin (an out-and-back is a return, not a connection) — and offers to import each chain as one flight with layover stops, on by default. The joined booking keeps per-leg airline, flight number, times and seat in metadata.legs, files every leg on its own trip day, and mirrors the first/last leg flat, exactly like the manual multi-leg form. With the connection stored as a stop endpoint, the existing Atlas role filter excludes the layover country on its own. AirTrail has no multi-leg flight a joined booking could round-trip to, so it imports detached from live sync, with every source flight id recorded in metadata.airtrail_ids — the picker and the server-side dedupe both treat those legs as imported, per leg, even across trip members. The server re-validates each requested chain and falls back to individual imports when it does not actually connect.
This commit is contained in:
@@ -34,7 +34,7 @@ export class AirtrailImportController {
|
||||
): Promise<AirtrailImportResult> {
|
||||
this.requireEdit(tripId, user);
|
||||
try {
|
||||
return await importAirtrailFlights(tripId, user.id, body.flightIds, socketId);
|
||||
return await importAirtrailFlights(tripId, user.id, body.flightIds, socketId, body.connections ?? []);
|
||||
} catch (err: any) {
|
||||
throw new HttpException({ error: err?.message || 'AirTrail import failed' }, err?.status === 400 ? 400 : 502);
|
||||
}
|
||||
|
||||
@@ -4,14 +4,20 @@ import { broadcast } from '../../websocket';
|
||||
import { createReservation } from '../reservationService';
|
||||
import { getAirtrailCredentials } from './airtrailService';
|
||||
import { AirtrailRequestError, listFlights } from './airtrailClient';
|
||||
import { canonicalHash, mapFlightToReservation } from './airtrailMapper';
|
||||
import type { AirtrailFlightRaw } from './airtrailClient';
|
||||
import { canonicalHash, mapFlightToReservation, mapFlightsToMultiLegReservation, normalizeFlight } from './airtrailMapper';
|
||||
|
||||
interface ExistingFlightRow {
|
||||
id: number;
|
||||
reservation_time: string | null;
|
||||
metadata: string | null;
|
||||
from_code: string | null;
|
||||
to_code: string | null;
|
||||
}
|
||||
|
||||
interface EndpointRow {
|
||||
reservation_id: number;
|
||||
code: string | null;
|
||||
local_date: string | null;
|
||||
sequence: number;
|
||||
}
|
||||
|
||||
function depDate(t: string | null): string | null {
|
||||
@@ -31,10 +37,58 @@ function softSignature(
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The signature a single AirTrail flight would get as its own reservation. */
|
||||
function flightSignature(flight: AirtrailFlightRaw): string | null {
|
||||
const mapped = mapFlightToReservation(flight);
|
||||
return softSignature(
|
||||
depDate(mapped.reservation_time),
|
||||
(mapped.metadata.flight_number as string) ?? null,
|
||||
mapped.endpoints.find(e => e.role === 'from')?.code ?? null,
|
||||
mapped.endpoints.find(e => e.role === 'to')?.code ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Order a requested join group by departure and verify it forms a real
|
||||
* connection chain: each flight leaves the airport the previous one arrived at,
|
||||
* onward in time and within 24 h — the same window the booking import applies
|
||||
* to same-PNR legs. A leg landing back at the chain's origin is a return
|
||||
* flight, never a connection. Flights without usable instants can't prove any
|
||||
* of this, so they don't chain. Returns the ordered chain, or null when the
|
||||
* flights don't chain; the members then import individually, exactly as if no
|
||||
* join had been requested.
|
||||
*/
|
||||
function orderConnectionChain(group: AirtrailFlightRaw[]): AirtrailFlightRaw[] | null {
|
||||
if (group.length < 2) return null;
|
||||
const norm = group.map(raw => ({ raw, n: normalizeFlight(raw) }));
|
||||
const depMs = (n: (typeof norm)[number]['n']): number => (n.departure ? Date.parse(n.departure) : NaN);
|
||||
if (norm.some(x => Number.isNaN(depMs(x.n)))) return null;
|
||||
norm.sort((a, b) => depMs(a.n) - depMs(b.n));
|
||||
const origin = norm[0].n.fromCode;
|
||||
for (let i = 1; i < norm.length; i++) {
|
||||
const prev = norm[i - 1].n;
|
||||
const next = norm[i].n;
|
||||
if (!prev.toCode || !next.fromCode || prev.toCode.toUpperCase() !== next.fromCode.toUpperCase()) return null;
|
||||
if (origin && next.toCode && next.toCode.toUpperCase() === origin.toUpperCase()) return null;
|
||||
const arrMs = prev.arrival ? Date.parse(prev.arrival) : NaN;
|
||||
if (Number.isNaN(arrMs)) return null;
|
||||
const gap = depMs(next) - arrMs;
|
||||
if (gap < 0 || gap > 24 * 3600 * 1000) return null;
|
||||
}
|
||||
return norm.map(x => x.raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import the given AirTrail flights into a trip as reservations (type:'flight'),
|
||||
* recording the AirTrail linkage for two-way sync and broadcasting each one live.
|
||||
*
|
||||
* `connections` names chains of selected flights to import as ONE multi-leg
|
||||
* reservation each, with the connection airports as layover stops (#1535). A
|
||||
* joined booking is created detached from live sync (sync_enabled = 0): AirTrail
|
||||
* has no multi-leg flight entity it could round-trip to, and a pull would
|
||||
* flatten the layover again. All member ids are kept in metadata.airtrail_ids
|
||||
* so none of the legs is offered for re-import.
|
||||
*
|
||||
* Dedup: a flight already linked to this trip is skipped ('already-imported'); a
|
||||
* flight that looks like one already in the trip — e.g. the same flight another
|
||||
* member already imported from their own AirTrail — is skipped ('already-in-trip').
|
||||
@@ -46,58 +100,158 @@ export async function importAirtrailFlights(
|
||||
userId: number,
|
||||
flightIds: string[],
|
||||
socketId: string | undefined,
|
||||
connections: string[][] = [],
|
||||
): Promise<AirtrailImportResult> {
|
||||
const creds = getAirtrailCredentials(userId);
|
||||
if (!creds) throw new AirtrailRequestError('AirTrail is not connected', 400);
|
||||
|
||||
const wanted = new Set(flightIds.map(String));
|
||||
const selected = (await listFlights(creds)).filter(f => wanted.has(String(f.id)));
|
||||
const byId = new Map(selected.map(f => [String(f.id), f]));
|
||||
|
||||
const result: AirtrailImportResult = { imported: [], skipped: [] };
|
||||
|
||||
const linkedIds = new Set(
|
||||
(db.prepare("SELECT external_id FROM reservations WHERE trip_id = ? AND external_source = 'airtrail'").all(tripId) as {
|
||||
external_id: string | null;
|
||||
}[])
|
||||
.map(r => r.external_id)
|
||||
.filter((v): v is string => !!v),
|
||||
);
|
||||
|
||||
const existing = db
|
||||
.prepare(
|
||||
`SELECT r.id, r.reservation_time, r.metadata,
|
||||
(SELECT code FROM reservation_endpoints WHERE reservation_id = r.id AND role = 'from' LIMIT 1) AS from_code,
|
||||
(SELECT code FROM reservation_endpoints WHERE reservation_id = r.id AND role = 'to' LIMIT 1) AS to_code
|
||||
FROM reservations r WHERE r.trip_id = ? AND r.type = 'flight'`,
|
||||
)
|
||||
.all(tripId) as ExistingFlightRow[];
|
||||
|
||||
const existingSigs = new Set<string>();
|
||||
for (const row of existing) {
|
||||
let fn: string | null = null;
|
||||
// Every AirTrail id already linked to this trip: the external_id column plus
|
||||
// the metadata.airtrail_ids of joined multi-leg imports.
|
||||
const linkedIds = new Set<string>();
|
||||
const linkedRows = db
|
||||
.prepare("SELECT external_id, metadata FROM reservations WHERE trip_id = ? AND external_source = 'airtrail'")
|
||||
.all(tripId) as { external_id: string | null; metadata: string | null }[];
|
||||
for (const row of linkedRows) {
|
||||
if (row.external_id) linkedIds.add(row.external_id);
|
||||
try {
|
||||
fn = row.metadata ? (JSON.parse(row.metadata).flight_number ?? null) : null;
|
||||
const ids = row.metadata ? JSON.parse(row.metadata).airtrail_ids : null;
|
||||
if (Array.isArray(ids)) for (const id of ids) linkedIds.add(String(id));
|
||||
} catch {
|
||||
/* malformed metadata — ignore */
|
||||
}
|
||||
const sig = softSignature(depDate(row.reservation_time), fn, row.from_code, row.to_code);
|
||||
if (sig) existingSigs.add(sig);
|
||||
}
|
||||
|
||||
const existing = db
|
||||
.prepare("SELECT r.id, r.reservation_time, r.metadata FROM reservations r WHERE r.trip_id = ? AND r.type = 'flight'")
|
||||
.all(tripId) as ExistingFlightRow[];
|
||||
const endpointsByReservation = new Map<number, EndpointRow[]>();
|
||||
const endpointRows = db
|
||||
.prepare(
|
||||
`SELECT e.reservation_id, e.code, e.local_date, e.sequence
|
||||
FROM reservation_endpoints e JOIN reservations r ON r.id = e.reservation_id
|
||||
WHERE r.trip_id = ? AND r.type = 'flight' ORDER BY e.sequence`,
|
||||
)
|
||||
.all(tripId) as EndpointRow[];
|
||||
for (const ep of endpointRows) {
|
||||
const list = endpointsByReservation.get(ep.reservation_id);
|
||||
if (list) list.push(ep);
|
||||
else endpointsByReservation.set(ep.reservation_id, [ep]);
|
||||
}
|
||||
|
||||
const days = db.prepare('SELECT id, date FROM days WHERE trip_id = ?').all(tripId) as { id: number; date: string | null }[];
|
||||
const dayIdByDate = new Map<string, number>();
|
||||
const dayDateById = new Map<number, string>();
|
||||
for (const day of days) {
|
||||
if (!day.date) continue;
|
||||
if (!dayIdByDate.has(day.date)) dayIdByDate.set(day.date, day.id);
|
||||
dayDateById.set(day.id, day.date);
|
||||
}
|
||||
const resolveDayId = (date: string | null): number | null => (date ? (dayIdByDate.get(date) ?? null) : null);
|
||||
|
||||
const existingSigs = new Set<string>();
|
||||
for (const row of existing) {
|
||||
let meta: Record<string, any> = {};
|
||||
try {
|
||||
meta = row.metadata ? JSON.parse(row.metadata) : {};
|
||||
} catch {
|
||||
/* malformed metadata — ignore */
|
||||
}
|
||||
const eps = endpointsByReservation.get(row.id) ?? [];
|
||||
const legs: any[] | null = Array.isArray(meta.legs) ? meta.legs : null;
|
||||
if (legs && legs.length > 1) {
|
||||
// One signature per leg. The leg's departure date comes from its own
|
||||
// dep_day_id when set; the positional endpoint fallback is only sound
|
||||
// while no endpoint was dropped (e.g. for missing coordinates) — a
|
||||
// misaligned date would produce a WRONG signature, worse than none.
|
||||
const aligned = eps.length === legs.length + 1;
|
||||
legs.forEach((leg, i) => {
|
||||
const legDate = (typeof leg?.dep_day_id === 'number' ? dayDateById.get(leg.dep_day_id) : null)
|
||||
?? (aligned ? eps[i]?.local_date : null)
|
||||
?? null;
|
||||
const sig = softSignature(legDate, leg?.flight_number ?? null, leg?.from ?? null, leg?.to ?? null);
|
||||
if (sig) existingSigs.add(sig);
|
||||
});
|
||||
} else {
|
||||
const from = eps[0]?.code ?? null;
|
||||
const to = eps.length > 1 ? eps[eps.length - 1].code : null;
|
||||
const sig = softSignature(depDate(row.reservation_time), meta.flight_number ?? null, from, to);
|
||||
if (sig) existingSigs.add(sig);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the requested joins into validated chains. A group that doesn't
|
||||
// chain, overlaps another, or contains an already-linked/duplicate flight
|
||||
// degrades to individual imports rather than failing the whole request.
|
||||
const groupedIds = new Set<string>();
|
||||
const chains: AirtrailFlightRaw[][] = [];
|
||||
for (const ids of connections) {
|
||||
const unique = [...new Set(ids.map(String))];
|
||||
const members = unique.map(id => byId.get(id)).filter((f): f is AirtrailFlightRaw => !!f);
|
||||
const chain = members.length === unique.length && !unique.some(id => groupedIds.has(id))
|
||||
? orderConnectionChain(members)
|
||||
: null;
|
||||
if (!chain) {
|
||||
console.warn('[airtrail-import] join group is not a connection chain — importing flights individually');
|
||||
continue;
|
||||
}
|
||||
if (unique.some(id => linkedIds.has(id)) || chain.some(f => {
|
||||
const sig = flightSignature(f);
|
||||
return !!sig && existingSigs.has(sig);
|
||||
})) {
|
||||
continue; // a member already exists in the trip — let the single path sort it out
|
||||
}
|
||||
chains.push(chain);
|
||||
for (const id of unique) groupedIds.add(id);
|
||||
}
|
||||
|
||||
for (const chain of chains) {
|
||||
const ids = chain.map(f => String(f.id));
|
||||
try {
|
||||
const mapped = mapFlightsToMultiLegReservation(chain, resolveDayId);
|
||||
const { reservation } = createReservation(tripId, mapped as any);
|
||||
const now = new Date().toISOString();
|
||||
db.prepare(
|
||||
`UPDATE reservations SET external_source = 'airtrail', external_id = ?, external_owner_user_id = ?,
|
||||
sync_enabled = 0, external_synced_at = ? WHERE id = ?`,
|
||||
).run(ids[0], userId, now, reservation.id);
|
||||
|
||||
reservation.external_source = 'airtrail';
|
||||
reservation.external_id = ids[0];
|
||||
reservation.external_owner_user_id = userId;
|
||||
reservation.sync_enabled = 0;
|
||||
reservation.external_synced_at = now;
|
||||
|
||||
broadcast(tripId, 'reservation:created', { reservation }, socketId);
|
||||
for (const f of chain) {
|
||||
const sig = flightSignature(f);
|
||||
if (sig) existingSigs.add(sig);
|
||||
}
|
||||
ids.forEach(id => linkedIds.add(id));
|
||||
result.imported.push(...ids);
|
||||
} catch (err) {
|
||||
console.error('[airtrail-import] failed to import connection', ids.join('+'), err instanceof Error ? err.message : err);
|
||||
for (const id of ids) {
|
||||
result.skipped.push({ flightId: id, reason: 'invalid', detail: err instanceof Error ? err.message : undefined });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const flight of selected) {
|
||||
const fid = String(flight.id);
|
||||
if (groupedIds.has(fid)) continue;
|
||||
if (linkedIds.has(fid)) {
|
||||
result.skipped.push({ flightId: fid, reason: 'already-imported' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const mapped = mapFlightToReservation(flight);
|
||||
const sig = softSignature(
|
||||
depDate(mapped.reservation_time),
|
||||
(mapped.metadata.flight_number as string) ?? null,
|
||||
mapped.endpoints.find(e => e.role === 'from')?.code ?? null,
|
||||
mapped.endpoints.find(e => e.role === 'to')?.code ?? null,
|
||||
);
|
||||
const sig = flightSignature(flight);
|
||||
if (sig && existingSigs.has(sig)) {
|
||||
result.skipped.push({ flightId: fid, reason: 'already-in-trip', detail: mapped.title });
|
||||
continue;
|
||||
|
||||
@@ -185,6 +185,131 @@ export function mapFlightToReservation(raw: AirtrailFlightRaw): MappedReservatio
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A chain of connecting AirTrail flights → ONE multi-leg reservation (#1535).
|
||||
*
|
||||
* Endpoints follow the manual multi-leg form: from → stop… → to, where a stop is
|
||||
* the connection airport and carries the departure of the leg LEAVING it (the
|
||||
* last endpoint carries the arrival). Per-leg airline/flight number/times/seat
|
||||
* live in metadata.legs; the flat metadata mirrors the first/last leg so legacy
|
||||
* readers keep working. Every source flight id rides along in
|
||||
* metadata.airtrail_ids so the import dedupe recognizes each leg — the
|
||||
* reservation itself is created detached from live sync, because AirTrail has no
|
||||
* multi-leg flight entity a merged booking could round-trip to.
|
||||
*
|
||||
* `resolveDayId` maps a local calendar date to the trip's day id: the day
|
||||
* planner files each leg by its own dep_day_id/arr_day_id, so an overnight
|
||||
* connection must not inherit the whole booking's first day for every leg.
|
||||
*/
|
||||
export function mapFlightsToMultiLegReservation(
|
||||
flights: AirtrailFlightRaw[],
|
||||
resolveDayId: (date: string | null) => number | null = () => null,
|
||||
): MappedReservation {
|
||||
if (flights.length === 1) return mapFlightToReservation(flights[0]);
|
||||
|
||||
let needsReview = flights.some(f => f.datePrecision && f.datePrecision !== 'day') ? 1 : 0;
|
||||
const endpoints: MappedEndpoint[] = [];
|
||||
const legs: Record<string, unknown>[] = [];
|
||||
|
||||
const first = flights[0];
|
||||
const firstDep = localParts(first.departureScheduled ?? first.departure, first.from?.tz ?? null);
|
||||
const firstDate = first.date || firstDep.date;
|
||||
if (hasCoords(first.from)) {
|
||||
endpoints.push({
|
||||
role: 'from',
|
||||
sequence: 0,
|
||||
name: first.from.name || airportCode(first.from) || 'Departure',
|
||||
code: airportCode(first.from),
|
||||
lat: first.from.lat,
|
||||
lng: first.from.lon,
|
||||
timezone: first.from.tz,
|
||||
local_time: firstDep.time,
|
||||
local_date: firstDate,
|
||||
});
|
||||
} else {
|
||||
needsReview = 1;
|
||||
}
|
||||
|
||||
flights.forEach((f, i) => {
|
||||
const isLast = i === flights.length - 1;
|
||||
const dep = localParts(f.departureScheduled ?? f.departure, f.from?.tz ?? null);
|
||||
const arr = localParts(f.arrivalScheduled ?? f.arrival, f.to?.tz ?? null);
|
||||
|
||||
if (hasCoords(f.to)) {
|
||||
// The connection airport's endpoint carries the ONWARD departure, matching
|
||||
// what the manual form stores for a stop; only the final arrival keeps its
|
||||
// own arrival time.
|
||||
const next = isLast ? null : flights[i + 1];
|
||||
const nextDep = next ? localParts(next.departureScheduled ?? next.departure, next.from?.tz ?? null) : null;
|
||||
endpoints.push({
|
||||
role: isLast ? 'to' : 'stop',
|
||||
sequence: 0,
|
||||
name: f.to.name || airportCode(f.to) || (isLast ? 'Arrival' : 'Stop'),
|
||||
code: airportCode(f.to),
|
||||
lat: f.to.lat,
|
||||
lng: f.to.lon,
|
||||
timezone: f.to.tz,
|
||||
local_time: isLast ? arr.time : (nextDep?.time ?? null),
|
||||
local_date: isLast ? arr.date : (next?.date || nextDep?.date || null),
|
||||
});
|
||||
} else {
|
||||
needsReview = 1;
|
||||
}
|
||||
|
||||
const seat = f.seats?.find(s => s.userId) ?? f.seats?.[0];
|
||||
const airline = entityName(f.airline);
|
||||
legs.push({
|
||||
from: airportCode(f.from),
|
||||
to: airportCode(f.to),
|
||||
...(airline ? { airline } : {}),
|
||||
...(f.flightNumber ? { flight_number: f.flightNumber } : {}),
|
||||
dep_day_id: resolveDayId(f.date || dep.date),
|
||||
dep_time: dep.time,
|
||||
arr_day_id: resolveDayId(arr.date),
|
||||
arr_time: arr.time,
|
||||
...(seat?.seatNumber ? { seat: seat.seatNumber } : {}),
|
||||
});
|
||||
});
|
||||
endpoints.forEach((e, i) => { e.sequence = i; });
|
||||
|
||||
const last = flights[flights.length - 1];
|
||||
const lastArr = localParts(last.arrivalScheduled ?? last.arrival, last.to?.tz ?? null);
|
||||
const reservation_time = firstDep.date && firstDep.time ? `${firstDep.date}T${firstDep.time}` : (firstDate ?? null);
|
||||
const reservation_end_time = lastArr.date && lastArr.time ? `${lastArr.date}T${lastArr.time}` : null;
|
||||
|
||||
const fromCode = airportCode(first.from);
|
||||
const toCode = airportCode(last.to);
|
||||
const airlineName = entityName(first.airline);
|
||||
const airlineCode = entityCode(first.airline);
|
||||
const aircraftCode = entityCode(first.aircraft);
|
||||
const metadata: Record<string, unknown> = {
|
||||
legs,
|
||||
airtrail_ids: flights.map(f => String(f.id)),
|
||||
};
|
||||
if (airlineName) metadata.airline = airlineName;
|
||||
if (airlineCode) metadata.airline_code = airlineCode;
|
||||
if (first.flightNumber) metadata.flight_number = first.flightNumber;
|
||||
if (aircraftCode) metadata.aircraft = aircraftCode;
|
||||
if (fromCode) metadata.departure_airport = fromCode;
|
||||
if (toCode) metadata.arrival_airport = toCode;
|
||||
if (legs[0]?.seat) metadata.seat = legs[0].seat;
|
||||
|
||||
const route = [fromCode, ...flights.map(f => airportCode(f.to))].filter(Boolean).join(' → ');
|
||||
const notes = [...new Set(flights.map(f => f.note?.trim()).filter((n): n is string => !!n))].join('\n') || null;
|
||||
|
||||
return {
|
||||
title: route || first.flightNumber?.trim() || 'Flight',
|
||||
type: 'flight',
|
||||
status: 'confirmed',
|
||||
reservation_time,
|
||||
reservation_end_time,
|
||||
notes,
|
||||
metadata,
|
||||
endpoints,
|
||||
needs_review: needsReview,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable snapshot hash of an AirTrail flight, used by the sync engine to detect
|
||||
* remote changes (AirTrail exposes no updated_at/etag) and to suppress TREK's own
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
/**
|
||||
* Import pipeline against the real test DB — only the AirTrail client and the
|
||||
* per-user credentials are mocked. Covers the joined multi-leg import (#1535):
|
||||
* one reservation per connection chain, detached from live sync, with every
|
||||
* member id recorded for dedupe; plus the fallbacks when a requested join
|
||||
* doesn't actually chain.
|
||||
*/
|
||||
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||
vi.mock('../../../src/services/airtrail/airtrailService', () => ({
|
||||
getAirtrailCredentials: vi.fn(() => ({ baseUrl: 'https://at.example', apiKey: 'k', allowInsecureTls: false })),
|
||||
}));
|
||||
vi.mock('../../../src/services/airtrail/airtrailClient', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../../src/services/airtrail/airtrailClient')>();
|
||||
return { ...actual, listFlights: vi.fn() };
|
||||
});
|
||||
|
||||
import { db } from '../../../src/db/database';
|
||||
import { createUser, createTrip } from '../../helpers/factories';
|
||||
import { listFlights } from '../../../src/services/airtrail/airtrailClient';
|
||||
import type { AirtrailAirport, AirtrailFlightRaw } from '../../../src/services/airtrail/airtrailClient';
|
||||
import { importAirtrailFlights } from '../../../src/services/airtrail/airtrailImport';
|
||||
|
||||
const BRU: AirtrailAirport = { id: 1, icao: 'EBBR', iata: 'BRU', name: 'Brussels', lat: 50.9014, lon: 4.4844, tz: 'Europe/Brussels', country: 'BE' };
|
||||
const HEL: AirtrailAirport = { id: 2, icao: 'EFHK', iata: 'HEL', name: 'Helsinki-Vantaa', lat: 60.3172, lon: 24.9633, tz: 'Europe/Helsinki', country: 'FI' };
|
||||
const JFK: AirtrailAirport = { id: 3, icao: 'KJFK', iata: 'JFK', name: 'John F. Kennedy Intl.', lat: 40.6413, lon: -73.7781, tz: 'America/New_York', country: 'US' };
|
||||
const LHR: AirtrailAirport = { id: 4, icao: 'EGLL', iata: 'LHR', name: 'London Heathrow', lat: 51.4706, lon: -0.4619, tz: 'Europe/London', country: 'GB' };
|
||||
|
||||
function rawFlight(over: Partial<AirtrailFlightRaw> = {}): AirtrailFlightRaw {
|
||||
return {
|
||||
id: 101,
|
||||
from: BRU,
|
||||
to: HEL,
|
||||
date: '2026-08-01',
|
||||
datePrecision: 'day',
|
||||
departure: '2026-08-01T06:00:00.000+00:00',
|
||||
arrival: '2026-08-01T09:30:00.000+00:00',
|
||||
departureScheduled: null,
|
||||
arrivalScheduled: null,
|
||||
airline: { id: 1, icao: 'FIN', iata: 'AY', name: 'Finnair' },
|
||||
flightNumber: 'AY1502',
|
||||
aircraft: null,
|
||||
aircraftReg: null,
|
||||
flightReason: 'leisure',
|
||||
note: null,
|
||||
seats: [{ userId: 'u1', guestName: null, seat: 'window', seatNumber: '12A', seatClass: 'economy' }],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const legBruHel = () => rawFlight();
|
||||
const legHelJfk = () =>
|
||||
rawFlight({
|
||||
id: 102,
|
||||
from: HEL,
|
||||
to: JFK,
|
||||
departure: '2026-08-01T11:00:00.000+00:00',
|
||||
arrival: '2026-08-01T19:00:00.000+00:00',
|
||||
flightNumber: 'AY15',
|
||||
});
|
||||
/** No connection to the BRU→HEL leg — departs LHR. */
|
||||
const legLhrJfk = () =>
|
||||
rawFlight({
|
||||
id: 103,
|
||||
from: LHR,
|
||||
to: JFK,
|
||||
departure: '2026-08-02T10:00:00.000+00:00',
|
||||
arrival: '2026-08-02T18:00:00.000+00:00',
|
||||
flightNumber: 'BA117',
|
||||
});
|
||||
|
||||
function tripReservations(tripId: number) {
|
||||
return db.prepare('SELECT * FROM reservations WHERE trip_id = ? ORDER BY id').all(tripId) as any[];
|
||||
}
|
||||
|
||||
function endpointsOf(reservationId: number) {
|
||||
return db
|
||||
.prepare('SELECT role, code, sequence FROM reservation_endpoints WHERE reservation_id = ? ORDER BY sequence')
|
||||
.all(reservationId) as { role: string; code: string | null; sequence: number }[];
|
||||
}
|
||||
|
||||
let tripId: number;
|
||||
let userId: number;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
const { user } = createUser(db);
|
||||
userId = user.id;
|
||||
tripId = createTrip(db, userId, { start_date: '2026-08-01', end_date: '2026-08-05' }).id;
|
||||
});
|
||||
|
||||
describe('importAirtrailFlights connection joining (#1535)', () => {
|
||||
it('imports a connection chain as ONE multi-leg reservation, detached from live sync', async () => {
|
||||
(listFlights as any).mockResolvedValue([legBruHel(), legHelJfk()]);
|
||||
|
||||
// Deliberately unordered — the server orders the chain by departure itself.
|
||||
const result = await importAirtrailFlights(tripId, userId, ['101', '102'], undefined, [['102', '101']]);
|
||||
expect([...result.imported].sort()).toEqual(['101', '102']);
|
||||
expect(result.skipped).toEqual([]);
|
||||
|
||||
const rows = tripReservations(tripId);
|
||||
expect(rows).toHaveLength(1);
|
||||
const r = rows[0];
|
||||
expect(r.type).toBe('flight');
|
||||
expect(r.external_source).toBe('airtrail');
|
||||
expect(r.external_id).toBe('101');
|
||||
expect(r.sync_enabled).toBe(0); // AirTrail has no multi-leg entity to round-trip to
|
||||
|
||||
const meta = JSON.parse(r.metadata);
|
||||
expect(meta.airtrail_ids).toEqual(['101', '102']);
|
||||
expect(meta.legs).toHaveLength(2);
|
||||
expect(endpointsOf(r.id).map(e => [e.role, e.code])).toEqual([
|
||||
['from', 'BRU'],
|
||||
['stop', 'HEL'],
|
||||
['to', 'JFK'],
|
||||
]);
|
||||
|
||||
// Each leg is filed on its own trip day so the day planner renders the
|
||||
// legs where they belong (both flights are on Aug 1 here).
|
||||
const day1 = db.prepare("SELECT id FROM days WHERE trip_id = ? AND date = '2026-08-01'").get(tripId) as { id: number };
|
||||
expect(meta.legs[0]).toMatchObject({ dep_day_id: day1.id, arr_day_id: day1.id });
|
||||
expect(meta.legs[1]).toMatchObject({ dep_day_id: day1.id, arr_day_id: day1.id });
|
||||
});
|
||||
|
||||
it('resolves overnight-connection legs to their own days', async () => {
|
||||
const overnightLeg2 = {
|
||||
...legHelJfk(),
|
||||
date: '2026-08-02',
|
||||
departure: '2026-08-02T07:00:00.000+00:00',
|
||||
arrival: '2026-08-02T15:00:00.000+00:00',
|
||||
};
|
||||
(listFlights as any).mockResolvedValue([legBruHel(), overnightLeg2]);
|
||||
|
||||
await importAirtrailFlights(tripId, userId, ['101', '102'], undefined, [['101', '102']]);
|
||||
const [r] = tripReservations(tripId);
|
||||
const dayId = (d: string) => (db.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ?').get(tripId, d) as { id: number }).id;
|
||||
const legs = JSON.parse(r.metadata).legs;
|
||||
expect(legs[0].dep_day_id).toBe(dayId('2026-08-01'));
|
||||
expect(legs[1].dep_day_id).toBe(dayId('2026-08-02'));
|
||||
expect(legs[1].arr_day_id).toBe(dayId('2026-08-02'));
|
||||
});
|
||||
|
||||
it('refuses to join an out-and-back return as a connection', async () => {
|
||||
const returnFlight = {
|
||||
...legBruHel(),
|
||||
id: 104,
|
||||
from: HEL,
|
||||
to: BRU,
|
||||
departure: '2026-08-01T18:00:00.000+00:00',
|
||||
arrival: '2026-08-01T21:30:00.000+00:00',
|
||||
flightNumber: 'AY1503',
|
||||
};
|
||||
(listFlights as any).mockResolvedValue([legBruHel(), returnFlight]);
|
||||
|
||||
const result = await importAirtrailFlights(tripId, userId, ['101', '104'], undefined, [['101', '104']]);
|
||||
expect([...result.imported].sort()).toEqual(['101', '104']);
|
||||
expect(tripReservations(tripId)).toHaveLength(2); // two singles, no bogus BRU→HEL→BRU booking
|
||||
});
|
||||
|
||||
it('skips every member of a joined booking on a later import attempt', async () => {
|
||||
(listFlights as any).mockResolvedValue([legBruHel(), legHelJfk()]);
|
||||
await importAirtrailFlights(tripId, userId, ['101', '102'], undefined, [['101', '102']]);
|
||||
|
||||
// Only leg 2 carries no external_id of its own — it must still be recognized
|
||||
// via metadata.airtrail_ids.
|
||||
const again = await importAirtrailFlights(tripId, userId, ['102'], undefined);
|
||||
expect(again.imported).toEqual([]);
|
||||
expect(again.skipped).toEqual([{ flightId: '102', reason: 'already-imported' }]);
|
||||
expect(tripReservations(tripId)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('recognizes a joined leg imported by another member via its per-leg signature', async () => {
|
||||
(listFlights as any).mockResolvedValue([legBruHel(), legHelJfk()]);
|
||||
await importAirtrailFlights(tripId, userId, ['101', '102'], undefined, [['101', '102']]);
|
||||
|
||||
// The same physical HEL→JFK flight from another member's AirTrail carries a
|
||||
// different id there — the flight-number@date signature must catch it.
|
||||
const { user: other } = createUser(db);
|
||||
(listFlights as any).mockResolvedValue([{ ...legHelJfk(), id: 999 }]);
|
||||
const result = await importAirtrailFlights(tripId, other.id, ['999'], undefined);
|
||||
expect(result.imported).toEqual([]);
|
||||
expect(result.skipped).toEqual([{ flightId: '999', reason: 'already-in-trip', detail: expect.any(String) }]);
|
||||
expect(tripReservations(tripId)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('falls back to individual imports when the requested join does not chain', async () => {
|
||||
(listFlights as any).mockResolvedValue([legBruHel(), legLhrJfk()]);
|
||||
|
||||
const result = await importAirtrailFlights(tripId, userId, ['101', '103'], undefined, [['101', '103']]);
|
||||
expect([...result.imported].sort()).toEqual(['101', '103']);
|
||||
|
||||
const rows = tripReservations(tripId);
|
||||
expect(rows).toHaveLength(2);
|
||||
for (const r of rows) {
|
||||
expect(r.sync_enabled).toBe(1); // plain imports keep live sync
|
||||
expect(endpointsOf(r.id)).toHaveLength(2);
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to individual imports when the layover exceeds 24 h', async () => {
|
||||
const lateLeg2 = { ...legHelJfk(), departure: '2026-08-03T11:00:00.000+00:00', arrival: '2026-08-03T19:00:00.000+00:00', date: '2026-08-03' };
|
||||
(listFlights as any).mockResolvedValue([legBruHel(), lateLeg2]);
|
||||
|
||||
const result = await importAirtrailFlights(tripId, userId, ['101', '102'], undefined, [['101', '102']]);
|
||||
expect([...result.imported].sort()).toEqual(['101', '102']);
|
||||
expect(tripReservations(tripId)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('imports singles exactly as before when no join is requested', async () => {
|
||||
(listFlights as any).mockResolvedValue([legBruHel()]);
|
||||
|
||||
const result = await importAirtrailFlights(tripId, userId, ['101'], undefined);
|
||||
expect(result.imported).toEqual(['101']);
|
||||
|
||||
const [r] = tripReservations(tripId);
|
||||
expect(r.external_id).toBe('101');
|
||||
expect(r.sync_enabled).toBe(1);
|
||||
expect(r.external_hash).toBeTruthy();
|
||||
expect(JSON.parse(r.metadata).airtrail_ids).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { canonicalHash, mapFlightToReservation, normalizeFlight } from '../../../src/services/airtrail/airtrailMapper';
|
||||
import { canonicalHash, mapFlightToReservation, mapFlightsToMultiLegReservation, normalizeFlight } from '../../../src/services/airtrail/airtrailMapper';
|
||||
import type { AirtrailFlightRaw } from '../../../src/services/airtrail/airtrailClient';
|
||||
|
||||
function airport(over: Partial<AirtrailFlightRaw['from']> = {}): NonNullable<AirtrailFlightRaw['from']> {
|
||||
@@ -211,3 +211,101 @@ describe('airtrailMapper.canonicalHash', () => {
|
||||
expect(canonicalHash(a)).toBe(canonicalHash(b));
|
||||
});
|
||||
});
|
||||
|
||||
describe('airtrailMapper.mapFlightsToMultiLegReservation (#1535)', () => {
|
||||
const BRU = () => airport({ id: 10, icao: 'EBBR', iata: 'BRU', name: 'Brussels', lat: 50.9014, lon: 4.4844, tz: 'Europe/Brussels' });
|
||||
const HEL = (over: Partial<AirtrailFlightRaw['from']> = {}) =>
|
||||
airport({ id: 11, icao: 'EFHK', iata: 'HEL', name: 'Helsinki-Vantaa', lat: 60.3172, lon: 24.9633, tz: 'Europe/Helsinki', ...over });
|
||||
|
||||
const leg1 = (over: Partial<AirtrailFlightRaw> = {}) =>
|
||||
flight({
|
||||
id: 101,
|
||||
from: BRU(),
|
||||
to: HEL(),
|
||||
departure: null,
|
||||
arrival: null,
|
||||
departureScheduled: '2021-09-01T06:00:00.000+00:00', // 08:00 CEST in Brussels
|
||||
arrivalScheduled: '2021-09-01T09:30:00.000+00:00', // 12:30 EEST in Helsinki
|
||||
airline: { id: 2, icao: 'FIN', iata: 'AY', name: 'Finnair' },
|
||||
flightNumber: 'AY1502',
|
||||
note: 'first hop',
|
||||
seats: [{ userId: 'u1', guestName: null, seat: 'window', seatNumber: '12A', seatClass: 'economy' }],
|
||||
...over,
|
||||
});
|
||||
const leg2 = (over: Partial<AirtrailFlightRaw> = {}) =>
|
||||
flight({
|
||||
id: 102,
|
||||
from: HEL(),
|
||||
to: airport(), // JFK
|
||||
departure: null,
|
||||
arrival: null,
|
||||
departureScheduled: '2021-09-01T11:00:00.000+00:00', // 14:00 EEST in Helsinki
|
||||
arrivalScheduled: '2021-09-01T19:00:00.000+00:00', // 15:00 EDT at JFK
|
||||
airline: { id: 2, icao: 'FIN', iata: 'AY', name: 'Finnair' },
|
||||
flightNumber: 'AY15',
|
||||
note: null,
|
||||
seats: [{ userId: 'u1', guestName: null, seat: 'aisle', seatNumber: '22C', seatClass: 'economy' }],
|
||||
...over,
|
||||
});
|
||||
|
||||
it('builds one from → stop → to chain, the stop carrying the onward departure', () => {
|
||||
const m = mapFlightsToMultiLegReservation([leg1(), leg2()]);
|
||||
expect(m.endpoints.map(e => [e.role, e.code, e.sequence])).toEqual([
|
||||
['from', 'BRU', 0],
|
||||
['stop', 'HEL', 1],
|
||||
['to', 'JFK', 2],
|
||||
]);
|
||||
// The connection endpoint stores the departure of the leg LEAVING it, like
|
||||
// the manual multi-leg form; only the final endpoint keeps its arrival.
|
||||
expect(m.endpoints[1].local_time).toBe('14:00');
|
||||
expect(m.endpoints[1].local_date).toBe('2021-09-01');
|
||||
expect(m.endpoints[2].local_time).toBe('15:00');
|
||||
expect(m.reservation_time).toBe('2021-09-01T08:00');
|
||||
expect(m.reservation_end_time).toBe('2021-09-01T15:00');
|
||||
expect(m.title).toBe('BRU → HEL → JFK');
|
||||
});
|
||||
|
||||
it('stores per-leg details in metadata.legs and mirrors the first/last leg flat', () => {
|
||||
const m = mapFlightsToMultiLegReservation([leg1(), leg2()]);
|
||||
expect(m.metadata.legs).toEqual([
|
||||
{ from: 'BRU', to: 'HEL', airline: 'Finnair', flight_number: 'AY1502', dep_day_id: null, dep_time: '08:00', arr_day_id: null, arr_time: '12:30', seat: '12A' },
|
||||
{ from: 'HEL', to: 'JFK', airline: 'Finnair', flight_number: 'AY15', dep_day_id: null, dep_time: '14:00', arr_day_id: null, arr_time: '15:00', seat: '22C' },
|
||||
]);
|
||||
expect(m.metadata).toMatchObject({
|
||||
airline: 'Finnair',
|
||||
airline_code: 'FIN',
|
||||
flight_number: 'AY1502',
|
||||
departure_airport: 'BRU',
|
||||
arrival_airport: 'JFK',
|
||||
seat: '12A',
|
||||
airtrail_ids: ['101', '102'],
|
||||
});
|
||||
expect(m.notes).toBe('first hop');
|
||||
});
|
||||
|
||||
it('flags needs_review but keeps the rest of the chain when the connection airport has no coordinates', () => {
|
||||
const m = mapFlightsToMultiLegReservation([leg1({ to: HEL({ lat: null, lon: null }) }), leg2({ from: HEL({ lat: null, lon: null }) })]);
|
||||
expect(m.needs_review).toBe(1);
|
||||
expect(m.endpoints.map(e => [e.role, e.code])).toEqual([
|
||||
['from', 'BRU'],
|
||||
['to', 'JFK'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('files each leg on its own day via the resolver — an overnight connection must not inherit day 1', () => {
|
||||
const overnight = leg2({
|
||||
date: '2021-09-02',
|
||||
departureScheduled: '2021-09-02T06:00:00.000+00:00', // 09:00 EEST next morning
|
||||
arrivalScheduled: '2021-09-02T14:00:00.000+00:00', // 10:00 EDT
|
||||
});
|
||||
const dayIds: Record<string, number> = { '2021-09-01': 11, '2021-09-02': 12 };
|
||||
const m = mapFlightsToMultiLegReservation([leg1(), overnight], d => (d ? (dayIds[d] ?? null) : null));
|
||||
const legs = m.metadata.legs as any[];
|
||||
expect(legs[0]).toMatchObject({ dep_day_id: 11, arr_day_id: 11 });
|
||||
expect(legs[1]).toMatchObject({ dep_day_id: 12, arr_day_id: 12 });
|
||||
});
|
||||
|
||||
it('falls back to the single-flight mapping for a one-flight chain', () => {
|
||||
expect(mapFlightsToMultiLegReservation([flight()])).toEqual(mapFlightToReservation(flight()));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user