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
+2 -2
View File
@@ -744,8 +744,8 @@ export const airtrailApi = {
sync: (): Promise<{ changed: number }> => apiClient.post('/integrations/airtrail/sync').then(r => r.data),
// flights + import are added with the trip-planner import (P2)
flights: () => apiClient.get('/integrations/airtrail/flights').then(r => r.data),
import: (tripId: number, flightIds: string[]) =>
apiClient.post(`/trips/${tripId}/reservations/import/airtrail`, { flightIds }).then(r => r.data),
import: (tripId: number, flightIds: string[], connections?: string[][]) =>
apiClient.post(`/trips/${tripId}/reservations/import/airtrail`, connections?.length ? { flightIds, connections } : { flightIds }).then(r => r.data),
}
export const journeyApi = {
@@ -0,0 +1,202 @@
// FE-PLANNER-AIRTRAIL-001 to FE-PLANNER-AIRTRAIL-008
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildTrip, buildReservation } from '../../../tests/helpers/factories';
import type { AirtrailFlight } from '@trek/shared';
import AirTrailImportModal, { detectConnections } from './AirTrailImportModal';
const flight = (over: Partial<AirtrailFlight> = {}): AirtrailFlight => ({
id: '101',
fromCode: 'BRU',
fromName: 'Brussels',
toCode: 'HEL',
toName: 'Helsinki-Vantaa',
date: '2026-08-01',
departure: '2026-08-01T06:00:00.000+00:00',
arrival: '2026-08-01T09:30:00.000+00:00',
airline: 'Finnair',
flightNumber: 'AY1502',
aircraft: null,
seatClass: 'economy',
...over,
});
const legHelJfk = (): AirtrailFlight =>
flight({
id: '102',
fromCode: 'HEL',
fromName: 'Helsinki-Vantaa',
toCode: 'JFK',
toName: 'John F. Kennedy Intl.',
departure: '2026-08-01T11:00:00.000+00:00',
arrival: '2026-08-01T19:00:00.000+00:00',
flightNumber: 'AY15',
});
const unrelatedFlight = (): AirtrailFlight =>
flight({
id: '103',
fromCode: 'LHR',
fromName: 'London Heathrow',
toCode: 'JFK',
toName: 'John F. Kennedy Intl.',
date: '2026-08-05',
departure: '2026-08-05T10:00:00.000+00:00',
arrival: '2026-08-05T18:00:00.000+00:00',
flightNumber: 'BA117',
});
describe('detectConnections (#1535)', () => {
it('FE-PLANNER-AIRTRAIL-001: chains flights that connect at the same airport within 24h', () => {
const chains = detectConnections([legHelJfk(), flight(), unrelatedFlight()]);
expect(chains).toHaveLength(1);
expect(chains[0].map(f => f.id)).toEqual(['101', '102']);
});
it('FE-PLANNER-AIRTRAIL-002: does not chain when the layover exceeds 24h', () => {
const late = { ...legHelJfk(), date: '2026-08-03', departure: '2026-08-03T11:00:00.000+00:00', arrival: '2026-08-03T19:00:00.000+00:00' };
expect(detectConnections([flight(), late])).toHaveLength(0);
});
it('FE-PLANNER-AIRTRAIL-003: skips flights without instants instead of guessing', () => {
expect(detectConnections([flight({ departure: null, arrival: null }), legHelJfk()])).toHaveLength(0);
});
it('FE-PLANNER-AIRTRAIL-009: does not chain an out-and-back return into a bogus connection', () => {
const returnFlight = flight({
id: '105',
fromCode: 'HEL',
fromName: 'Helsinki-Vantaa',
toCode: 'BRU',
toName: 'Brussels',
departure: '2026-08-01T18:00:00.000+00:00',
arrival: '2026-08-01T21:30:00.000+00:00',
flightNumber: 'AY1503',
});
expect(detectConnections([flight(), returnFlight])).toHaveLength(0);
});
});
describe('AirTrailImportModal', () => {
const defaultProps = { isOpen: true, onClose: vi.fn(), tripId: 1 };
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useTripStore, {
trip: buildTrip({ id: 1, start_date: '2026-08-01', end_date: '2026-08-10' }),
reservations: [],
});
server.use(
http.get('/api/integrations/airtrail/flights', () =>
HttpResponse.json({ flights: [flight(), legHelJfk(), unrelatedFlight()] }),
),
http.get('/api/trips/1/reservations', () => HttpResponse.json({ reservations: [] })),
);
});
it('FE-PLANNER-AIRTRAIL-004: offers to join a detected connection, on by default', async () => {
render(<AirTrailImportModal {...defaultProps} />);
const joinRow = await screen.findByText(/one flight with a layover in HEL/i);
expect(joinRow).toBeInTheDocument();
// No join offer for the unrelated flight.
expect(screen.queryByText(/layover in JFK/i)).not.toBeInTheDocument();
});
it('FE-PLANNER-AIRTRAIL-005: sends the chain as a connection on import', async () => {
const user = userEvent.setup();
let body: any = null;
server.use(
http.post('/api/trips/1/reservations/import/airtrail', async ({ request }) => {
body = await request.json();
return HttpResponse.json({ imported: body.flightIds, skipped: [] });
}),
);
render(<AirTrailImportModal {...defaultProps} />);
await screen.findByText(/one flight with a layover in HEL/i);
await user.click(screen.getByRole('button', { name: /Import 3/i }));
await waitFor(() => expect(body).not.toBeNull());
expect([...body.flightIds].sort()).toEqual(['101', '102', '103']);
expect(body.connections).toEqual([['101', '102']]);
});
it('FE-PLANNER-AIRTRAIL-006: sends no connection when the join is toggled off', async () => {
const user = userEvent.setup();
let body: any = null;
server.use(
http.post('/api/trips/1/reservations/import/airtrail', async ({ request }) => {
body = await request.json();
return HttpResponse.json({ imported: body.flightIds, skipped: [] });
}),
);
render(<AirTrailImportModal {...defaultProps} />);
await user.click(await screen.findByText(/one flight with a layover in HEL/i));
await user.click(screen.getByRole('button', { name: /Import 3/i }));
await waitFor(() => expect(body).not.toBeNull());
expect(body.connections).toBeUndefined();
});
it('FE-PLANNER-AIRTRAIL-007: re-enabling the join selects all of its legs', async () => {
const user = userEvent.setup();
render(<AirTrailImportModal {...defaultProps} />);
const joinRow = await screen.findByText(/one flight with a layover in HEL/i);
// Deselect one member — the join reads unchecked, one click brings both back.
await user.click(screen.getByText('Finnair AY15'));
expect(screen.getByRole('button', { name: /Import 2/i })).toBeInTheDocument();
await user.click(joinRow);
expect(screen.getByRole('button', { name: /Import 3/i })).toBeInTheDocument();
});
it('FE-PLANNER-AIRTRAIL-010: a surviving sub-chain keeps its join offer when a later leg was already imported', async () => {
const legJfkLax = flight({
id: '106',
fromCode: 'JFK',
fromName: 'John F. Kennedy Intl.',
toCode: 'LAX',
toName: 'Los Angeles Intl.',
departure: '2026-08-01T21:00:00.000+00:00',
arrival: '2026-08-02T00:30:00.000+00:00',
flightNumber: 'AY99',
});
seedStore(useTripStore, {
trip: buildTrip({ id: 1, start_date: '2026-08-01', end_date: '2026-08-10' }),
reservations: [
buildReservation({ type: 'flight', external_source: 'airtrail', external_id: '106' }) as any,
],
});
server.use(
http.get('/api/integrations/airtrail/flights', () =>
HttpResponse.json({ flights: [flight(), legHelJfk(), legJfkLax] }),
),
);
render(<AirTrailImportModal {...defaultProps} />);
// BRU→HEL→JFK still connects even though JFK→LAX is gone from the pool.
expect(await screen.findByText(/one flight with a layover in HEL/i)).toBeInTheDocument();
expect(screen.getByText(/^Imported$/)).toBeInTheDocument();
});
it('FE-PLANNER-AIRTRAIL-008: legs of a joined import are marked imported via metadata.airtrail_ids', async () => {
seedStore(useTripStore, {
trip: buildTrip({ id: 1, start_date: '2026-08-01', end_date: '2026-08-10' }),
reservations: [
buildReservation({
type: 'flight',
external_source: 'airtrail',
external_id: '101',
metadata: JSON.stringify({ airtrail_ids: ['101', '102'] }),
}) as any,
],
});
render(<AirTrailImportModal {...defaultProps} />);
await screen.findByText('Finnair AY1502');
// Both legs disabled — including the one whose id only lives in the metadata.
expect(screen.getAllByText(/^Imported$/)).toHaveLength(2);
// A chain with an imported member cannot be joined again.
expect(screen.queryByText(/one flight with a layover/i)).not.toBeInTheDocument();
});
});
@@ -7,6 +7,7 @@ import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import { airtrailApi, reservationsApi } from '../../api/client'
import { useTripStore } from '../../store/tripStore'
import { parseReservationMetadata } from '../../utils/flightLegs'
interface AirTrailImportModalProps {
isOpen: boolean
@@ -15,6 +16,40 @@ interface AirTrailImportModalProps {
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
}
/**
* Ordered chains of connecting flights — each arrives where the next departs,
* onward within 24 h — that the picker offers to import as ONE multi-leg
* booking with the connection as a layover stop (#1535). A flight landing back
* at the chain's origin is a return, not a connection, so a same-day
* out-and-back never gets a join offer. Same rules the server re-validates
* with.
*/
export function detectConnections(flights: AirtrailFlight[]): AirtrailFlight[][] {
const sorted = flights
.filter(f => f.departure && f.arrival && f.fromCode && f.toCode)
.sort((a, b) => Date.parse(a.departure!) - Date.parse(b.departure!))
const chains: AirtrailFlight[][] = []
let chain: AirtrailFlight[] = []
for (const f of sorted) {
const prev = chain[chain.length - 1]
if (prev) {
const gap = Date.parse(f.departure!) - Date.parse(prev.arrival!)
if (
prev.toCode!.toUpperCase() === f.fromCode!.toUpperCase() &&
f.toCode!.toUpperCase() !== chain[0].fromCode!.toUpperCase() &&
gap >= 0 && gap <= 24 * 3600 * 1000
) {
chain.push(f)
continue
}
}
if (chain.length > 1) chains.push(chain)
chain = [f]
}
if (chain.length > 1) chains.push(chain)
return chains
}
/** Locale-aware date (e.g. de → 13.06.2026, en-US → 06/13/2026). */
function fmtDate(d: string | null, locale: string): string {
if (!d) return ''
@@ -44,11 +79,16 @@ export default function AirTrailImportModal({ isOpen, onClose, tripId, pushUndo
const [flights, setFlights] = useState<AirtrailFlight[]>([])
const [selected, setSelected] = useState<Set<string>>(() => new Set())
// AirTrail flight ids already linked to a reservation in this trip.
// AirTrail flight ids already linked to a reservation in this trip. A joined
// multi-leg import carries only its first leg in external_id — the other legs
// sit in metadata.airtrail_ids (#1535).
const importedIds = useMemo(() => {
const set = new Set<string>()
for (const r of reservations) {
if (r.external_source === 'airtrail' && r.external_id) set.add(String(r.external_id))
if (r.external_source !== 'airtrail') continue
if (r.external_id) set.add(String(r.external_id))
const ids = parseReservationMetadata(r).airtrail_ids
if (Array.isArray(ids)) for (const id of ids) set.add(String(id))
}
return set
}, [reservations])
@@ -56,10 +96,27 @@ export default function AirTrailImportModal({ isOpen, onClose, tripId, pushUndo
const inRange = (f: AirtrailFlight): boolean =>
!!(f.date && trip?.start_date && trip?.end_date && f.date >= trip.start_date && f.date <= trip.end_date)
// Detected connection chains that can still be joined. Already-imported
// flights are excluded BEFORE detection so a surviving sub-chain (e.g. the
// first two legs when the third was imported earlier) still gets its offer.
// Joining is on by default; joinOff remembers the chains the user opted out of.
const chains = useMemo(
() => detectConnections(flights.filter(f => !importedIds.has(f.id))),
[flights, importedIds],
)
const chainKey = (chain: AirtrailFlight[]) => chain.map(f => f.id).join('+')
const chainOf = useMemo(() => {
const map = new Map<string, AirtrailFlight[]>()
for (const chain of chains) for (const f of chain) map.set(f.id, chain)
return map
}, [chains])
const [joinOff, setJoinOff] = useState<Set<string>>(() => new Set())
useEffect(() => {
if (!isOpen) return
setError('')
setSelected(new Set())
setJoinOff(new Set())
setLoading(true)
airtrailApi
.flights()
@@ -94,6 +151,28 @@ export default function AirTrailImportModal({ isOpen, onClose, tripId, pushUndo
})
}
const chainJoined = (chain: AirtrailFlight[]) =>
!joinOff.has(chainKey(chain)) && chain.every(f => selected.has(f.id))
const toggleJoin = (chain: AirtrailFlight[]) => {
const key = chainKey(chain)
if (chainJoined(chain)) {
setJoinOff(prev => new Set(prev).add(key))
} else {
// Turning the join on implies wanting all of its legs.
setJoinOff(prev => {
const next = new Set(prev)
next.delete(key)
return next
})
setSelected(prev => {
const next = new Set(prev)
for (const f of chain) next.add(f.id)
return next
})
}
}
const handleClose = () => { onClose() }
const handleImport = async () => {
@@ -102,7 +181,8 @@ export default function AirTrailImportModal({ isOpen, onClose, tripId, pushUndo
setImporting(true)
setError('')
try {
const result: AirtrailImportResult = await airtrailApi.import(tripId, ids)
const connections = chains.filter(chainJoined).map(chain => chain.map(f => f.id))
const result: AirtrailImportResult = await airtrailApi.import(tripId, ids, connections)
await loadReservations(tripId)
const imported = result.imported ?? []
@@ -175,6 +255,58 @@ export default function AirTrailImportModal({ isOpen, onClose, tripId, pushUndo
)
}
const renderChain = (chain: AirtrailFlight[]) => {
const joined = chainJoined(chain)
const stops = chain.slice(0, -1).map(f => f.toCode ?? f.toName ?? '?').join(', ')
return (
<div key={chainKey(chain)} style={{ border: '1px solid var(--border-primary)', borderRadius: 12, padding: '8px 8px 0', marginBottom: 8 }}>
{chain.map(renderFlight)}
<button
onClick={() => toggleJoin(chain)}
className="bg-transparent"
style={{
display: 'flex', alignItems: 'center', gap: 10, width: '100%', textAlign: 'left',
border: 'none', borderTop: '1px solid var(--border-faint)', borderRadius: 0,
padding: '9px 4px 10px', cursor: 'pointer', fontFamily: 'inherit',
}}
>
<span style={{
flexShrink: 0, width: 16, height: 16, borderRadius: 5,
border: `1.5px solid ${joined ? 'var(--accent)' : 'var(--border-primary)'}`,
background: joined ? 'var(--accent)' : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{joined && <Check size={11} color="var(--accent-text)" strokeWidth={3} />}
</span>
<span style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-primary)' }}>
{t('reservations.airtrail.joinConnection', { stops })}
</span>
</button>
</div>
)
}
// A chain renders once as a group, at the position of its first listed leg —
// its other legs are swallowed wherever else they would appear. Computed per
// section up front so a chain straddling the trip range can't leave the other
// section holding nothing but its header.
const sectionItems = useMemo(() => {
const rendered = new Set<string>()
const build = (list: AirtrailFlight[]) =>
list.flatMap((f): Array<{ key: string; chain?: AirtrailFlight[]; flight?: AirtrailFlight }> => {
const chain = chainOf.get(f.id)
if (!chain) return [{ key: f.id, flight: f }]
const key = chain.map(c => c.id).join('+')
if (rendered.has(key)) return []
rendered.add(key)
return [{ key, chain }]
})
return { during: build(during), others: build(others) }
}, [during, others, chainOf])
const renderItem = (item: { chain?: AirtrailFlight[]; flight?: AirtrailFlight }) =>
item.chain ? renderChain(item.chain) : renderFlight(item.flight!)
return ReactDOM.createPortal(
<div
className="bg-[rgba(0,0,0,0.4)]"
@@ -213,21 +345,21 @@ export default function AirTrailImportModal({ isOpen, onClose, tripId, pushUndo
</div>
)}
{!loading && during.length > 0 && (
{!loading && sectionItems.during.length > 0 && (
<>
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', margin: '2px 0 8px' }}>
{t('reservations.airtrail.duringTrip')}
</div>
{during.map(renderFlight)}
{sectionItems.during.map(renderItem)}
</>
)}
{!loading && others.length > 0 && (
{!loading && sectionItems.others.length > 0 && (
<>
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-faint)', margin: `${during.length > 0 ? 14 : 2}px 0 8px` }}>
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-faint)', margin: `${sectionItems.during.length > 0 ? 14 : 2}px 0 8px` }}>
{t('reservations.airtrail.otherFlights')}
</div>
{others.map(renderFlight)}
{sectionItems.others.map(renderItem)}
</>
)}
@@ -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
@@ -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()));
});
});
@@ -0,0 +1,18 @@
import { airtrailImportSchema } from './airtrail.schema';
import { describe, it, expect } from 'vitest';
describe('airtrailImportSchema', () => {
it('requires at least one flight id', () => {
expect(airtrailImportSchema.safeParse({ flightIds: ['101'] }).success).toBe(true);
expect(airtrailImportSchema.safeParse({ flightIds: [] }).success).toBe(false);
expect(airtrailImportSchema.safeParse({}).success).toBe(false);
});
it('carries connection chains through (#1535) — each with at least two legs', () => {
const parsed = airtrailImportSchema.parse({ flightIds: ['101', '102'], connections: [['101', '102']] });
expect(parsed.connections).toEqual([['101', '102']]);
expect(airtrailImportSchema.parse({ flightIds: ['101'] }).connections).toBeUndefined();
expect(airtrailImportSchema.safeParse({ flightIds: ['101'], connections: [['101']] }).success).toBe(false);
});
});
+7
View File
@@ -68,6 +68,13 @@ export type AirtrailFlight = z.infer<typeof airtrailFlightSchema>;
export const airtrailImportSchema = z.object({
flightIds: z.array(z.string()).min(1, 'Select at least one flight'),
/**
* Chains of selected flight ids to import as ONE multi-leg booking each, with
* the connection airports as layover stops (#1535). The server re-validates
* that each chain actually connects; one that doesn't falls back to
* individual imports.
*/
connections: z.array(z.array(z.string()).min(2)).optional(),
});
export type AirtrailImport = z.infer<typeof airtrailImportSchema>;
+1
View File
@@ -160,6 +160,7 @@ const reservations: TranslationStrings = {
'reservations.airtrail.otherFlights': 'رحلات أخرى',
'reservations.airtrail.empty': 'لم يتم العثور على أي رحلات في حساب AirTrail الخاص بك.',
'reservations.airtrail.importCta': 'استيراد {count}',
'reservations.airtrail.joinConnection': 'الاستيراد كرحلة واحدة مع توقف في {stops}',
'reservations.costsLabel': 'Costs',
'reservations.createExpense': 'Create expense',
'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.',
+1
View File
@@ -160,6 +160,7 @@ const reservations: TranslationStrings = {
'reservations.airtrail.otherFlights': 'Outros voos',
'reservations.airtrail.empty': 'Nenhum voo encontrado na sua conta do AirTrail.',
'reservations.airtrail.importCta': 'Importar {count}',
'reservations.airtrail.joinConnection': 'Importar como um único voo com escala em {stops}',
'reservations.costsLabel': 'Costs',
'reservations.createExpense': 'Create expense',
'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.',
+1
View File
@@ -160,6 +160,7 @@ const reservations: TranslationStrings = {
'reservations.airtrail.otherFlights': 'Ostatní lety',
'reservations.airtrail.empty': 'Ve vašem účtu AirTrail nebyly nalezeny žádné lety.',
'reservations.airtrail.importCta': 'Importovat {count}',
'reservations.airtrail.joinConnection': 'Importovat jako jeden let s přestupem v {stops}',
'reservations.costsLabel': 'Costs',
'reservations.createExpense': 'Create expense',
'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.',
+1
View File
@@ -165,6 +165,7 @@ const reservations: TranslationStrings = {
'reservations.airtrail.otherFlights': 'Weitere Flüge',
'reservations.airtrail.empty': 'Keine Flüge in Ihrem AirTrail-Konto gefunden.',
'reservations.airtrail.importCta': '{count} importieren',
'reservations.airtrail.joinConnection': 'Als einen Flug mit Zwischenstopp in {stops} importieren',
'reservations.costsLabel': 'Kosten',
'reservations.createExpense': 'Ausgabe erstellen',
'reservations.createExpenseHint': 'Speichert die Buchung und öffnet dann den Kosten-Editor.',
+1
View File
@@ -161,6 +161,7 @@ const reservations: TranslationStrings = {
'reservations.airtrail.otherFlights': 'Other flights',
'reservations.airtrail.empty': 'No flights found in your AirTrail account.',
'reservations.airtrail.importCta': 'Import {count}',
'reservations.airtrail.joinConnection': 'Import as one flight with a layover in {stops}',
'reservations.costsLabel': 'Costs',
'reservations.createExpense': 'Create expense',
'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.',
+1
View File
@@ -163,6 +163,7 @@ const reservations: TranslationStrings = {
'reservations.airtrail.otherFlights': 'Otros vuelos',
'reservations.airtrail.empty': 'No se encontraron vuelos en tu cuenta de AirTrail.',
'reservations.airtrail.importCta': 'Importar {count}',
'reservations.airtrail.joinConnection': 'Importar como un solo vuelo con escala en {stops}',
'reservations.costsLabel': 'Costs',
'reservations.createExpense': 'Create expense',
'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.',
+1
View File
@@ -166,6 +166,7 @@ const reservations: TranslationStrings = {
'reservations.airtrail.otherFlights': 'Autres vols',
'reservations.airtrail.empty': 'Aucun vol trouvé dans votre compte AirTrail.',
'reservations.airtrail.importCta': 'Importer {count}',
'reservations.airtrail.joinConnection': 'Importer comme un seul vol avec escale à {stops}',
'reservations.costsLabel': 'Costs',
'reservations.createExpense': 'Create expense',
'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.',
+1
View File
@@ -164,6 +164,7 @@ const reservations: TranslationStrings = {
'reservations.airtrail.otherFlights': 'Άλλες πτήσεις',
'reservations.airtrail.empty': 'Δεν βρέθηκαν πτήσεις στον λογαριασμό σας στο AirTrail.',
'reservations.airtrail.importCta': 'Εισαγωγή {count}',
'reservations.airtrail.joinConnection': 'Εισαγωγή ως μία πτήση με ενδιάμεση στάση σε {stops}',
'reservations.costsLabel': 'Costs',
'reservations.createExpense': 'Create expense',
'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.',
+1
View File
@@ -164,6 +164,7 @@ const reservations: TranslationStrings = {
'reservations.airtrail.otherFlights': 'Egyéb járatok',
'reservations.airtrail.empty': 'Nem található járat az AirTrail-fiókodban.',
'reservations.airtrail.importCta': '{count} importálása',
'reservations.airtrail.joinConnection': 'Importálás egyetlen járatként, átszállással itt: {stops}',
'reservations.costsLabel': 'Costs',
'reservations.createExpense': 'Create expense',
'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.',
+1
View File
@@ -160,6 +160,7 @@ const reservations: TranslationStrings = {
'reservations.airtrail.otherFlights': 'Penerbangan lain',
'reservations.airtrail.empty': 'Tidak ada penerbangan ditemukan di akun AirTrail-mu.',
'reservations.airtrail.importCta': 'Impor {count}',
'reservations.airtrail.joinConnection': 'Impor sebagai satu penerbangan dengan transit di {stops}',
'reservations.costsLabel': 'Costs',
'reservations.createExpense': 'Create expense',
'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.',
+1
View File
@@ -161,6 +161,7 @@ const reservations: TranslationStrings = {
'reservations.airtrail.otherFlights': 'Altri voli',
'reservations.airtrail.empty': 'Nessun volo trovato nel tuo account AirTrail.',
'reservations.airtrail.importCta': 'Importa {count}',
'reservations.airtrail.joinConnection': 'Importa come un unico volo con scalo a {stops}',
'reservations.costsLabel': 'Costs',
'reservations.createExpense': 'Create expense',
'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.',
+1
View File
@@ -160,6 +160,7 @@ const reservations: TranslationStrings = {
'reservations.airtrail.otherFlights': 'その他のフライト',
'reservations.airtrail.empty': 'AirTrail アカウントにフライトが見つかりませんでした。',
'reservations.airtrail.importCta': '{count} 件をインポート',
'reservations.airtrail.joinConnection': '{stops}での乗り継ぎを含む1つのフライトとしてインポート',
'reservations.costsLabel': 'Costs',
'reservations.createExpense': 'Create expense',
'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.',
+1
View File
@@ -160,6 +160,7 @@ const reservations: TranslationStrings = {
'reservations.airtrail.otherFlights': '기타 항공편',
'reservations.airtrail.empty': 'AirTrail 계정에서 항공편을 찾을 수 없습니다.',
'reservations.airtrail.importCta': '{count}개 가져오기',
'reservations.airtrail.joinConnection': '{stops} 경유 항공편 하나로 가져오기',
'reservations.costsLabel': 'Costs',
'reservations.createExpense': 'Create expense',
'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.',
+1
View File
@@ -162,6 +162,7 @@ const reservations: TranslationStrings = {
'reservations.airtrail.otherFlights': 'Andere vluchten',
'reservations.airtrail.empty': 'Geen vluchten gevonden in je AirTrail-account.',
'reservations.airtrail.importCta': '{count} importeren',
'reservations.airtrail.joinConnection': 'Importeren als één vlucht met een tussenstop in {stops}',
'reservations.costsLabel': 'Kosten',
'reservations.createExpense': 'Kostenpost aanmaken',
'reservations.createExpenseHint': 'Boeking opslaan en daarna de Onkosteneditor openen.',
+1
View File
@@ -161,6 +161,7 @@ const reservations: TranslationStrings = {
'reservations.airtrail.otherFlights': 'Inne loty',
'reservations.airtrail.empty': 'Nie znaleziono lotów na Twoim koncie AirTrail.',
'reservations.airtrail.importCta': 'Importuj {count}',
'reservations.airtrail.joinConnection': 'Importuj jako jeden lot z przesiadką w {stops}',
'reservations.costsLabel': 'Costs',
'reservations.createExpense': 'Create expense',
'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.',
+1
View File
@@ -160,6 +160,7 @@ const reservations: TranslationStrings = {
'reservations.airtrail.otherFlights': 'Другие рейсы',
'reservations.airtrail.empty': 'В вашей учётной записи AirTrail не найдено рейсов.',
'reservations.airtrail.importCta': 'Импортировать {count}',
'reservations.airtrail.joinConnection': 'Импортировать как один рейс с пересадкой в {stops}',
'reservations.costsLabel': 'Costs',
'reservations.createExpense': 'Create expense',
'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.',
+1
View File
@@ -161,6 +161,7 @@ const reservations: TranslationStrings = {
'reservations.airtrail.otherFlights': 'Övriga flygningar',
'reservations.airtrail.empty': 'Inga flygningar hittades i ditt AirTrail-konto.',
'reservations.airtrail.importCta': 'Importera {count}',
'reservations.airtrail.joinConnection': 'Importera som en flygning med mellanlandning i {stops}',
'reservations.costsLabel': 'Kostnader',
'reservations.createExpense': 'Skapa utgift',
'reservations.createExpenseHint': 'Sparar bokningen och öppnar sedan kostnadsredigeraren.',
+1
View File
@@ -162,6 +162,7 @@ const reservations: TranslationStrings = {
'reservations.airtrail.otherFlights': 'Diğer uçuşlar',
'reservations.airtrail.empty': 'AirTrail hesabınızda uçuş bulunamadı.',
'reservations.airtrail.importCta': '{count} içe aktar',
'reservations.airtrail.joinConnection': '{stops} aktarmalı tek uçuş olarak içe aktar',
'reservations.costsLabel': 'Costs',
'reservations.createExpense': 'Create expense',
'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.',
+1
View File
@@ -160,6 +160,7 @@ const reservations: TranslationStrings = {
'reservations.airtrail.otherFlights': 'Інші рейси',
'reservations.airtrail.empty': 'У вашому акаунті AirTrail не знайдено рейсів.',
'reservations.airtrail.importCta': 'Імпортувати {count}',
'reservations.airtrail.joinConnection': 'Імпортувати як один рейс із пересадкою в {stops}',
'reservations.costsLabel': 'Costs',
'reservations.createExpense': 'Create expense',
'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.',
+1
View File
@@ -163,6 +163,7 @@ const reservations: TranslationStrings = {
'reservations.airtrail.otherFlights': 'Các chuyến bay khác',
'reservations.airtrail.empty': 'Không tìm thấy chuyến bay nào trong tài khoản AirTrail của bạn.',
'reservations.airtrail.importCta': 'Nhập {count}',
'reservations.airtrail.joinConnection': 'Nhập thành một chuyến bay với điểm dừng tại {stops}',
'reservations.costsLabel': 'Chi phí',
'reservations.createExpense': 'Tạo chi phí',
'reservations.createExpenseHint': 'Lưu đặt chỗ, sau đó mở trình chỉnh sửa Chi phí.',
+1
View File
@@ -159,6 +159,7 @@ const reservations: TranslationStrings = {
'reservations.airtrail.otherFlights': '其他航班',
'reservations.airtrail.empty': '在你的 AirTrail 帳戶中找不到任何航班。',
'reservations.airtrail.importCta': '匯入 {count}',
'reservations.airtrail.joinConnection': '匯入為一個航班(在{stops}轉機)',
'reservations.costsLabel': 'Costs',
'reservations.createExpense': 'Create expense',
'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.',
+1
View File
@@ -159,6 +159,7 @@ const reservations: TranslationStrings = {
'reservations.airtrail.otherFlights': '其他航班',
'reservations.airtrail.empty': '您的 AirTrail 账户中未找到航班。',
'reservations.airtrail.importCta': '导入 {count}',
'reservations.airtrail.joinConnection': '导入为一个航班(在{stops}中转)',
'reservations.costsLabel': '费用',
'reservations.createExpense': '创建支出',
'reservations.createExpenseHint': '先保存预订,然后打开费用编辑器。',