fix(airtrail): gate airtrail update behind a user setting, on airtrail update: rebuild payload from fresh data to prevent any data loss

This commit is contained in:
jubnl
2026-06-18 09:59:14 +02:00
parent 17b4f72be6
commit 66f661e2a1
29 changed files with 283 additions and 21 deletions
+9
View File
@@ -3045,6 +3045,15 @@ function runMigrations(db: Database.Database): void {
'CREATE UNIQUE INDEX IF NOT EXISTS idx_reservations_external ON reservations(external_source, external_id, trip_id)',
);
},
() => {
// Per-user opt-in for writing TREK edits back to AirTrail (#1240). Default
// off: AirTrail is the source of truth and TREK never writes unless asked.
try {
db.exec('ALTER TABLE users ADD COLUMN airtrail_write_enabled INTEGER DEFAULT 0');
} catch (err: any) {
if (!err.message?.includes('duplicate column name')) throw err;
}
},
];
if (currentVersion < migrations.length) {
@@ -43,6 +43,7 @@ export class AirtrailController {
body.url,
body.apiKey,
!!body.allowInsecureTls,
!!body.writeEnabled,
getClientIp(req),
);
if (!result.success) {
@@ -11,7 +11,7 @@ function airportCode(a: AirtrailAirport | null): string | null {
* Airline/aircraft arrive as joined objects ({icao, iata, name, ...}); reduce
* them to a single code (ICAO preferred, matching AirTrail's save shape).
*/
function entityCode(e: AirtrailNamedCode | null | undefined): string | null {
export function entityCode(e: AirtrailNamedCode | null | undefined): string | null {
return e?.icao || e?.iata || null;
}
@@ -12,14 +12,25 @@ interface UserConnRow {
airtrail_url?: string | null;
airtrail_api_key?: string | null;
airtrail_allow_insecure_tls?: number | null;
airtrail_write_enabled?: number | null;
}
function readRow(userId: number): UserConnRow | undefined {
return db
.prepare('SELECT airtrail_url, airtrail_api_key, airtrail_allow_insecure_tls FROM users WHERE id = ?')
.prepare(
'SELECT airtrail_url, airtrail_api_key, airtrail_allow_insecure_tls, airtrail_write_enabled FROM users WHERE id = ?',
)
.get(userId) as UserConnRow | undefined;
}
/** Has this user opted in to TREK writing their flight edits back to AirTrail? (#1240) */
export function isAirtrailWriteEnabled(userId: number): boolean {
const row = db.prepare('SELECT airtrail_write_enabled FROM users WHERE id = ?').get(userId) as
| { airtrail_write_enabled?: number | null }
| undefined;
return !!row?.airtrail_write_enabled;
}
/** Decrypted creds for outbound calls, or null when the user has no connection. */
export function getAirtrailCredentials(userId: number): AirtrailCreds | null {
const row = readRow(userId);
@@ -40,6 +51,7 @@ export function getConnectionSettings(userId: number) {
url: row?.airtrail_url || '',
apiKeyMasked: row?.airtrail_api_key ? KEY_MASK : '',
allowInsecureTls: !!row?.airtrail_allow_insecure_tls,
writeEnabled: !!row?.airtrail_write_enabled,
connected: !!(row?.airtrail_url && row?.airtrail_api_key),
};
}
@@ -49,6 +61,7 @@ export async function saveSettings(
url: string | undefined,
apiKey: string | undefined,
allowInsecureTls: boolean,
writeEnabled: boolean,
clientIp: string | null,
): Promise<{ success: boolean; warning?: string; error?: string }> {
const trimmedUrl = (url || '').trim();
@@ -81,12 +94,12 @@ export async function saveSettings(
if (newKey !== undefined) {
db.prepare(
'UPDATE users SET airtrail_url = ?, airtrail_api_key = ?, airtrail_allow_insecure_tls = ? WHERE id = ?',
).run(trimmedUrl || null, newKey, allowInsecureTls ? 1 : 0, userId);
'UPDATE users SET airtrail_url = ?, airtrail_api_key = ?, airtrail_allow_insecure_tls = ?, airtrail_write_enabled = ? WHERE id = ?',
).run(trimmedUrl || null, newKey, allowInsecureTls ? 1 : 0, writeEnabled ? 1 : 0, userId);
} else {
db.prepare(
'UPDATE users SET airtrail_url = ?, airtrail_allow_insecure_tls = ? WHERE id = ?',
).run(trimmedUrl || null, allowInsecureTls ? 1 : 0, userId);
'UPDATE users SET airtrail_url = ?, airtrail_allow_insecure_tls = ?, airtrail_write_enabled = ? WHERE id = ?',
).run(trimmedUrl || null, allowInsecureTls ? 1 : 0, writeEnabled ? 1 : 0, userId);
// Clearing the URL with no key left makes the connection meaningless — drop the key too.
if (!trimmedUrl) {
db.prepare('UPDATE users SET airtrail_api_key = NULL WHERE id = ?').run(userId);
+36 -13
View File
@@ -13,8 +13,8 @@ import {
listFlights,
saveFlight,
} from './airtrailClient';
import { canonicalHash, mapFlightToReservation } from './airtrailMapper';
import { getAirtrailCredentials } from './airtrailService';
import { canonicalHash, entityCode, mapFlightToReservation } from './airtrailMapper';
import { getAirtrailCredentials, isAirtrailWriteEnabled } from './airtrailService';
/** Global on/off: the addon must be enabled and sync not explicitly turned off. */
export function syncGloballyEnabled(): boolean {
@@ -144,7 +144,16 @@ function splitLocal(dt: string | null | undefined): { date: string | null; time:
return { date: /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : null, time: m ? m[1] : null };
}
function buildSavePayload(reservation: any, existing: AirtrailFlightRaw): AirtrailSavePayload | null {
/**
* Build the POST /flight/save body. AirTrail's save fully overwrites the flight,
* so we start from the flight as AirTrail currently has it (`existing`, the raw
* GET object) and overwrite ONLY the fields TREK manages. Everything else —
* terminal, gate, scheduled/actual times, customFields, track, and any field
* AirTrail may add later — passes through untouched. We deliberately do NOT model
* those fields; spreading the raw object keeps us decoupled from AirTrail's schema
* (#1240).
*/
export function buildSavePayload(reservation: any, existing: AirtrailFlightRaw): AirtrailSavePayload | null {
let meta: Record<string, any>;
try {
meta = reservation.metadata ? JSON.parse(reservation.metadata) : {};
@@ -183,7 +192,14 @@ function buildSavePayload(reservation: any, existing: AirtrailFlightRaw): Airtra
if (ownSeat) ownSeat.seatNumber = seatNumber;
}
// Spread the existing flight first to preserve every AirTrail-owned field, then
// overwrite only what TREK manages. `from`/`to`/`airline`/`aircraft` come back
// from GET as objects but the save shape wants codes — those are exactly the
// keys we override, so the spread never ships an object where a code is wanted.
return {
// Cast so the spread carries through the AirTrail-owned keys we deliberately
// don't model (terminal, gate, scheduled/actual times, customFields, track, …).
...(existing as unknown as Record<string, unknown>),
id: Number(reservation.external_id),
from: fromCode,
to: toCode,
@@ -191,14 +207,18 @@ function buildSavePayload(reservation: any, existing: AirtrailFlightRaw): Airtra
departureTime: dep.time,
arrival: arr.date,
arrivalTime: arr.time,
airline: meta.airline ?? null,
flightNumber: meta.flight_number ?? null,
aircraft: meta.aircraft ?? null,
aircraftReg: meta.aircraft_reg ?? null,
flightReason: meta.flight_reason ?? null,
note: reservation.notes ?? null,
// These are AirTrail-owned details TREK doesn't surface in its edit UI — a TREK
// edit can leave them out of `metadata`. Preserve AirTrail's current value when
// TREK has none rather than nulling it out (#1240). entityCode mirrors the
// import/hash code-selection so a writeback stays a no-op for the hash.
airline: meta.airline ?? entityCode(existing.airline) ?? null,
flightNumber: meta.flight_number ?? existing.flightNumber ?? null,
aircraft: meta.aircraft ?? entityCode(existing.aircraft) ?? null,
aircraftReg: meta.aircraft_reg ?? existing.aircraftReg ?? null,
flightReason: meta.flight_reason ?? existing.flightReason ?? null,
note: reservation.notes ?? existing.note ?? null,
seats,
};
} as AirtrailSavePayload;
}
/**
@@ -219,9 +239,12 @@ export async function pushReservationToAirtrail(reservationId: number, tripId: n
| undefined;
if (!row || !row.sync_enabled) return;
const creds: AirtrailCreds | null = row.external_owner_user_id
? getAirtrailCredentials(row.external_owner_user_id)
: null;
// AirTrail is read-only by default (#1240). Only push when the flight's owner has
// explicitly opted in. A no-op skip (not a detach): the link stays active so the
// inbound, AirTrail-wins pull keeps the reservation up to date.
if (!row.external_owner_user_id || !isAirtrailWriteEnabled(row.external_owner_user_id)) return;
const creds: AirtrailCreds | null = getAirtrailCredentials(row.external_owner_user_id);
if (!creds) {
detach(tripId, row.id); // owner disconnected — cannot push, so stop syncing
return;
@@ -0,0 +1,160 @@
import { describe, it, expect } from 'vitest';
import { buildSavePayload } from '../../../src/services/airtrail/airtrailSync';
import type { AirtrailAirport, AirtrailFlightRaw } from '../../../src/services/airtrail/airtrailClient';
function airport(over: Partial<AirtrailAirport> = {}): AirtrailAirport {
return {
id: 1,
icao: 'KJFK',
iata: 'JFK',
name: 'John F. Kennedy Intl.',
lat: 40.6413,
lon: -73.7781,
tz: 'America/New_York',
country: 'US',
...over,
};
}
/**
* An AirTrail flight as GET returns it, including the fields TREK doesn't model.
* Typed as the raw object (known shape + arbitrary passthrough keys) because the
* push spreads it wholesale rather than mapping each field — see buildSavePayload.
*/
function existingFlight(
over: Partial<AirtrailFlightRaw> & Record<string, unknown> = {},
): AirtrailFlightRaw & Record<string, unknown> {
return {
id: 42,
from: airport(),
to: airport({ id: 2, icao: 'EGLL', iata: 'LHR', name: 'London Heathrow', tz: 'Europe/London' }),
date: '2021-09-01',
datePrecision: 'day',
departure: '2021-09-01T23:00:00.000+00:00',
arrival: '2021-09-02T07:00:00.000+00:00',
airline: { id: 1, icao: 'BAW', iata: 'BA', name: 'British Airways' },
flightNumber: 'BA178',
aircraft: { id: 1, icao: 'B772', name: 'Boeing 777' },
aircraftReg: 'G-VIIL',
flightReason: 'leisure',
note: 'window seat',
seats: [{ userId: 'u1', guestName: null, seat: 'window', seatNumber: '12A', seatClass: 'economy' }],
// AirTrail-owned detail TREK never surfaces — must survive a writeback (#1240).
departureScheduled: '2021-09-01',
departureScheduledTime: '18:45',
arrivalScheduled: '2021-09-02',
arrivalScheduledTime: '08:10',
takeoffActual: '2021-09-01',
takeoffActualTime: '19:12',
landingActual: '2021-09-02',
landingActualTime: '07:55',
departureTerminal: '7',
departureGate: 'B22',
arrivalTerminal: '5',
arrivalGate: 'A10',
customFields: { confirmation: 'ABC123' },
track: [{ lat: 40.6, lon: -73.7 }],
...over,
};
}
/** A linked TREK reservation (the shape getReservationWithJoins returns). */
function reservation(over: Record<string, unknown> = {}): Record<string, unknown> {
return {
external_id: '42',
reservation_time: '2021-09-01T19:00',
reservation_end_time: '2021-09-02T08:00',
notes: 'window seat',
metadata: JSON.stringify({ airline: 'BAW', flight_number: 'BA178', aircraft: 'B772', aircraft_reg: 'G-VIIL', flight_reason: 'leisure', seat: '12A' }),
endpoints: [
{ role: 'from', code: 'JFK' },
{ role: 'to', code: 'LHR' },
],
...over,
};
}
describe('airtrailSync.buildSavePayload', () => {
it('round-trips the AirTrail-owned fields TREK does not model (issue #1240)', () => {
const payload = buildSavePayload(reservation(), existingFlight());
expect(payload).not.toBeNull();
expect(payload).toMatchObject({
departureScheduled: '2021-09-01',
departureScheduledTime: '18:45',
arrivalScheduled: '2021-09-02',
arrivalScheduledTime: '08:10',
takeoffActual: '2021-09-01',
takeoffActualTime: '19:12',
landingActual: '2021-09-02',
landingActualTime: '07:55',
departureTerminal: '7',
departureGate: 'B22',
arrivalTerminal: '5',
arrivalGate: 'A10',
customFields: { confirmation: 'ABC123' },
track: [{ lat: 40.6, lon: -73.7 }],
});
});
it('preserves a non-day date precision instead of resetting it to day', () => {
const payload = buildSavePayload(reservation(), existingFlight({ datePrecision: 'month' }));
expect(payload?.datePrecision).toBe('month');
});
it('still applies the TREK-owned edits on top of the preserved fields', () => {
const payload = buildSavePayload(
reservation({
reservation_time: '2021-09-01T20:30',
notes: 'changed in TREK',
metadata: JSON.stringify({ airline: 'BAW', flight_number: 'BA999', seat: '3C' }),
}),
existingFlight(),
);
expect(payload).toMatchObject({
id: 42,
from: 'JFK',
to: 'LHR',
departure: '2021-09-01',
departureTime: '20:30',
flightNumber: 'BA999',
note: 'changed in TREK',
});
// The user's seat number is pushed onto their own AirTrail seat.
expect(payload?.seats[0].seatNumber).toBe('3C');
// …without disturbing the preserved AirTrail detail.
expect(payload?.departureTerminal).toBe('7');
});
it('preserves AirTrail aircraft/airline/reason when TREK metadata omits them (#1240)', () => {
// A TREK edit can drop these AirTrail-owned fields from metadata; the writeback
// must fall back to AirTrail's current values rather than nulling them.
const payload = buildSavePayload(reservation({ metadata: JSON.stringify({}) }), existingFlight());
expect(payload).toMatchObject({
airline: 'BAW', // entityCode(existing.airline) — icao preferred
aircraft: 'B772',
aircraftReg: 'G-VIIL',
flightReason: 'leisure',
flightNumber: 'BA178',
note: 'window seat',
});
});
it('keeps the existing seat manifest rather than replacing it', () => {
const payload = buildSavePayload(
reservation({ metadata: JSON.stringify({}) }),
existingFlight({
seats: [
{ userId: 'u1', guestName: null, seat: 'window', seatNumber: '12A', seatClass: 'business' },
{ userId: null, guestName: 'Guest', seat: 'aisle', seatNumber: '12B', seatClass: 'business' },
],
}),
);
expect(payload?.seats).toHaveLength(2);
expect(payload?.seats[1]).toMatchObject({ guestName: 'Guest', seatNumber: '12B' });
});
it('returns null when an endpoint code is missing and no fallback exists', () => {
const payload = buildSavePayload(reservation({ endpoints: [] }), existingFlight({ from: airport({ iata: null, icao: null }) }));
expect(payload).toBeNull();
});
});