mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 14:21:46 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user