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:
Maurice
2026-07-13 16:17:04 +02:00
parent 2a722a5be0
commit 545295d430
32 changed files with 1025 additions and 44 deletions
@@ -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);
}
+186 -32
View File
@@ -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