AirTrail integration: import flights & two-way sync (#214) (#1158)

* feat(admin): register AirTrail as an integration addon

Off by default; toggle lives in Admin -> Addons with a Plane icon. The
per-user connection (URL + API key) follows in integration settings.

* feat(integrations): add per-user AirTrail connection

Settings -> Integrations gains an AirTrail section: instance URL + Bearer
API key (encrypted at rest via apiKeyCrypto), a self-signed-TLS opt-in and
a test-connection check. Served by a small Nest controller under
/api/integrations/airtrail, gated on the airtrail addon and SSRF-guarded.
The key is per-user, so it only ever returns that user's own flights.

* feat(transport): import flights from AirTrail

Adds an AirTrail Import button next to Manual Transport that lists the
user's AirTrail flights and highlights the ones inside the trip dates.
Selected flights become reservations linked to their AirTrail origin
(external_* columns), deduped against flights already in the trip, then
broadcast to every member. The mapping resolves airports, airport-local
times and flight metadata; the linkage is what the two-way sync rides on.

* feat(transport): badge AirTrail-linked flights as synced

Linked reservations show an 'AirTrail synced' badge, or 'no longer
synced' once the flight is gone from AirTrail.

* feat(transport): keep TREK and AirTrail flights in sync both ways

A scheduled poll reconciles each connected owner's flights: field edits
(detected by snapshot hash, since AirTrail has no updated_at) flow into
the linked reservation and broadcast live; a flight deleted in AirTrail
keeps the TREK row but stops syncing. Editing a linked flight in TREK
pushes back to AirTrail under the importer's credentials, preserving the
existing seat manifest; if the owner disconnected the link detaches so the
poll can't revert the local edit. Deleting in TREK never touches AirTrail.

* i18n(airtrail): add AirTrail strings across all locales

* test(airtrail): cover flight mapping, timezones and snapshot hashing

* fix(airtrail): reduce airline/aircraft objects to codes

The flight list/get response returns airline and aircraft as joined
objects ({icao, iata, name, ...}), not bare codes. Mapping them straight
through produced '[object Object]' titles and stored objects in metadata,
which crashed reservation rendering. Extract the ICAO/IATA code instead,
and title flights by their flight number.

* fix(airtrail): clear error on non-JSON responses, tolerate /api in URL

A misconfigured instance URL made AirTrail serve its SPA/login HTML, and
the raw JSON.parse failure surfaced as 'Unexpected token <'. Surface an
actionable message instead, and strip a pasted trailing /api so the base
URL still resolves.

* feat(transport): sync AirTrail edits on trip open, not just on the poll

Add a per-user on-demand sync (POST /integrations/airtrail/sync) triggered
when a connected user opens a trip, so AirTrail-side edits appear right away
instead of waiting up to a full poll cycle. Lower the background poll from 15
to 5 minutes as a safety net.

* fix(transport): refresh imported AirTrail flights without a reload

loadTrip doesn't fetch reservations, so a freshly imported flight only
appeared after a full page reload — use loadReservations instead. Also show
flight dates in the user's locale format (e.g. 13.06.2026) rather than the
raw ISO string.

* style(settings): align AirTrail connection with the photo-provider layout

Match the Immich section: stacked URL/key fields, a ToggleSwitch for
self-signed TLS, and a Save / Test-connection row with a status badge.

* feat(transport): add a seat field when editing flights

The transport editor only offered a seat field for trains; flights had
none even though imports store metadata.seat. Show and persist a seat for
flights too.

* style(transport): match the AirTrail button height to Manual Transport

* feat(transport): put the flight seat next to flight number and sync it to AirTrail

Move the seat from a standalone row to the per-leg flight details (beside
the flight number), stored per leg in metadata.legs[].seat with the first
leg mirrored to metadata.seat. On push, set the seat number on the user's
own AirTrail seat (the one with a userId), leaving co-passengers untouched;
import/poll read that same seat back.

* refactor(planner): move the AirTrail trip-open sync into useTripPlanner

Page containers must not own state/effects (lint:pages). Same logic,
relocated from the page into its data hook.

* test(db): pin the region-reconciliation test to its schema version

The test re-ran 'the last migration' assuming the reconciliation is last;
it no longer is once later migrations are appended. Pin to version 135 and
re-run from there (the appended migrations are idempotent).
This commit is contained in:
Maurice
2026-06-13 13:11:35 +02:00
committed by GitHub
parent f91721c73e
commit 56655d53b4
72 changed files with 2565 additions and 14 deletions
+1
View File
@@ -7,6 +7,7 @@ export const ADDON_IDS = {
ATLAS: 'atlas',
COLLAB: 'collab',
JOURNEY: 'journey',
AIRTRAIL: 'airtrail',
} as const;
export type AddonId = typeof ADDON_IDS[keyof typeof ADDON_IDS];
+29
View File
@@ -2460,6 +2460,35 @@ function runMigrations(db: Database.Database): void {
if (after && after.region_code === row.region_code) del.run(row.id);
}
},
() => {
// AirTrail integration addon — disabled by default (opt-in). Per-user connection
// lives in Settings → Integrations; this row is only the admin-level global toggle.
try {
db.prepare("INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)")
.run('airtrail', 'AirTrail', 'Sync flights from your self-hosted AirTrail instance', 'integration', 'Plane', 0, 14);
} catch (err: any) {
console.warn('[migrations] Non-fatal migration step failed:', err);
}
},
() => {
// AirTrail per-user connection (mirrors the Immich integration columns).
try { db.exec("ALTER TABLE users ADD COLUMN airtrail_url TEXT"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
try { db.exec("ALTER TABLE users ADD COLUMN airtrail_api_key TEXT"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
try { db.exec("ALTER TABLE users ADD COLUMN airtrail_allow_insecure_tls INTEGER DEFAULT 0"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
},
() => {
// AirTrail flight linkage on reservations (#214) — lets a TREK transport
// remember its AirTrail origin so the two-way sync can match + update it.
// sync_enabled flips to 0 when the AirTrail flight is deleted (row kept).
try { db.exec("ALTER TABLE reservations ADD COLUMN external_source TEXT"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
try { db.exec("ALTER TABLE reservations ADD COLUMN external_id TEXT"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
try { db.exec("ALTER TABLE reservations ADD COLUMN external_owner_user_id INTEGER"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
try { db.exec("ALTER TABLE reservations ADD COLUMN external_synced_at TEXT"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
try { db.exec("ALTER TABLE reservations ADD COLUMN sync_enabled INTEGER DEFAULT 1"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
try { db.exec("ALTER TABLE reservations ADD COLUMN external_hash TEXT"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
// NULLs compare distinct in SQLite, so non-linked reservations don't collide.
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_reservations_external ON reservations(external_source, external_id, trip_id)");
},
];
if (currentVersion < migrations.length) {
+1
View File
@@ -98,6 +98,7 @@ function seedAddons(db: Database.Database): void {
{ id: 'naver_list_import', name: 'Naver List Import', description: 'Import places from shared Naver Maps lists', type: 'trip', icon: 'Link2', enabled: 1, sort_order: 13 },
{ id: 'collab', name: 'Collab', description: 'Notes, polls, and live chat for trip collaboration', type: 'trip', icon: 'Users', enabled: 1, sort_order: 6 },
{ id: 'journey', name: 'Journey', description: 'Trip tracking & travel journal — check-ins, photos, daily stories', type: 'global', icon: 'Compass', enabled: 0, sort_order: 35 },
{ id: 'airtrail', name: 'AirTrail', description: 'Sync flights from your self-hosted AirTrail instance', type: 'integration', icon: 'Plane', enabled: 0, sort_order: 14 },
];
const insertAddon = db.prepare('INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)');
for (const a of defaultAddons) insertAddon.run(a.id, a.name, a.description, a.type, a.icon, a.enabled, a.sort_order);
+1
View File
@@ -79,6 +79,7 @@ const onListen = () => {
scheduler.startDemoReset();
scheduler.startIdempotencyCleanup();
scheduler.startTrekPhotoCacheCleanup();
scheduler.startAirTrailSync();
const { startTokenCleanup } = require('./services/ephemeralTokens');
startTokenCleanup();
import('./websocket').then(({ setupWebSocket }) => {
+4 -2
View File
@@ -25,6 +25,7 @@ import { CollabModule } from './collab/collab.module';
import { FilesModule } from './files/files.module';
import { PhotosModule } from './photos/photos.module';
import { MemoriesModule } from './memories/memories.module';
import { AirtrailModule } from './integrations/airtrail.module';
import { JourneyModule } from './journey/journey.module';
import { ShareModule } from './share/share.module';
import { SettingsModule } from './settings/settings.module';
@@ -41,10 +42,11 @@ import { IdempotencyInterceptor } from './common/idempotency.interceptor';
/**
* Root NestJS module for the incremental migration. Domain modules
* (weather, notifications, ...) get registered here as they are migrated.
* (weather, notifications, integrations, ...) get registered here as they are
* migrated.
*/
@Module({
imports: [DatabaseModule, WeatherModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, JourneyModule, ShareModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, BookingImportModule],
imports: [DatabaseModule, WeatherModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, ShareModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, BookingImportModule],
controllers: [HealthController],
providers: [
HealthService,
@@ -0,0 +1,19 @@
import { CanActivate, HttpException, Injectable } from '@nestjs/common';
import { isAddonEnabled } from '../../services/adminService';
import { ADDON_IDS } from '../../addons';
/**
* Gates the AirTrail integration routes on the global `airtrail` addon. When the
* admin has it disabled the whole group answers 404. Declared before the
* JwtAuthGuard so the addon check wins over the 401 (same ordering as the
* Journey addon gate).
*/
@Injectable()
export class AirtrailAddonGuard implements CanActivate {
canActivate(): boolean {
if (!isAddonEnabled(ADDON_IDS.AIRTRAIL)) {
throw new HttpException({ error: 'AirTrail addon is not enabled' }, 404);
}
return true;
}
}
@@ -0,0 +1,42 @@
import { Body, Controller, Headers, HttpException, Param, Post, UseGuards } from '@nestjs/common';
import type { User } from '../../types';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { AirtrailAddonGuard } from './airtrail-addon.guard';
import { airtrailImportSchema, type AirtrailImport, type AirtrailImportResult } from '@trek/shared';
import { verifyTripAccess } from '../../services/tripAccess';
import { checkPermission } from '../../services/permissions';
import { importAirtrailFlights } from '../../services/airtrail/airtrailImport';
/**
* POST /api/trips/:tripId/reservations/import/airtrail — turn selected AirTrail
* flights into reservations. Trip-scoped (reservation_edit) and addon-gated. The
* flights are re-fetched server-side with the caller's own key.
*/
@Controller('api/trips/:tripId/reservations/import')
@UseGuards(AirtrailAddonGuard, JwtAuthGuard)
export class AirtrailImportController {
private requireEdit(tripId: string, user: User): void {
const trip = verifyTripAccess(tripId, user.id);
if (!trip) throw new HttpException({ error: 'Trip not found' }, 404);
if (!checkPermission('reservation_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id)) {
throw new HttpException({ error: 'No permission' }, 403);
}
}
@Post('airtrail')
async importAirtrail(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Body(new ZodValidationPipe(airtrailImportSchema)) body: AirtrailImport,
@Headers('x-socket-id') socketId?: string,
): Promise<AirtrailImportResult> {
this.requireEdit(tripId, user);
try {
return await importAirtrailFlights(tripId, user.id, body.flightIds, socketId);
} catch (err: any) {
throw new HttpException({ error: err?.message || 'AirTrail import failed' }, err?.status === 400 ? 400 : 502);
}
}
}
@@ -0,0 +1,83 @@
import { Body, Controller, Get, HttpCode, HttpException, Post, Put, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import type { User } from '../../types';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { AirtrailAddonGuard } from './airtrail-addon.guard';
import { getClientIp } from '../../services/auditLog';
import { airtrailSettingsSchema, type AirtrailSettings } from '@trek/shared';
import {
getConnectionSettings,
getConnectionStatus,
getFlightsForPicker,
saveSettings,
testConnection,
} from '../../services/airtrail/airtrailService';
import { runAirtrailSyncForUser } from '../../services/airtrail/airtrailSync';
/**
* /api/integrations/airtrail — per-user AirTrail connection (#214).
*
* `status` and `test` answer 200 even on failure (the service shapes
* `{ connected: false, error }`); `settings` PUT validates with a 400. The API
* key is never echoed — `getSettings` returns it masked. The route group is
* gated on the `airtrail` addon (404 when disabled).
*/
@Controller('api/integrations/airtrail')
@UseGuards(AirtrailAddonGuard, JwtAuthGuard)
export class AirtrailController {
@Get('settings')
getSettings(@CurrentUser() user: User) {
return getConnectionSettings(user.id);
}
@Put('settings')
async putSettings(
@CurrentUser() user: User,
@Body(new ZodValidationPipe(airtrailSettingsSchema)) body: AirtrailSettings,
@Req() req: Request,
) {
const result = await saveSettings(
user.id,
body.url,
body.apiKey,
!!body.allowInsecureTls,
getClientIp(req),
);
if (!result.success) {
throw new HttpException({ error: result.error }, 400);
}
return result.warning ? { success: true, warning: result.warning } : { success: true };
}
@Get('status')
getStatus(@CurrentUser() user: User) {
return getConnectionStatus(user.id);
}
@Get('flights')
async flights(@CurrentUser() user: User) {
try {
return { flights: await getFlightsForPicker(user.id) };
} catch (err: any) {
throw new HttpException({ error: err?.message || 'Could not load AirTrail flights' }, err?.status === 400 ? 400 : 502);
}
}
/** Pull this user's AirTrail edits into their linked reservations on demand. */
@Post('sync')
@HttpCode(200)
sync(@CurrentUser() user: User) {
return runAirtrailSyncForUser(user.id);
}
@Post('test')
@HttpCode(200)
test(
@CurrentUser() user: User,
@Body(new ZodValidationPipe(airtrailSettingsSchema)) body: AirtrailSettings,
) {
return testConnection(user.id, body.url, body.apiKey, !!body.allowInsecureTls);
}
}
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { AirtrailController } from './airtrail.controller';
import { AirtrailImportController } from './airtrail-import.controller';
/**
* AirTrail integration domain. The connection lives under
* /api/integrations/airtrail; the flight import is trip-scoped under
* /api/trips/:tripId/reservations/import/airtrail. Business logic lives in
* services/airtrail/* (plain functions over better-sqlite3).
*/
@Module({
controllers: [AirtrailController, AirtrailImportController],
})
export class AirtrailModule {}
@@ -14,6 +14,7 @@ import type { User } from '../../types';
import { ReservationsService } from './reservations.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { pushReservationToAirtrail } from '../../services/airtrail/airtrailSync';
type ReservationBody = Record<string, unknown> & {
title?: string;
@@ -115,6 +116,11 @@ export class ReservationsController {
const cur = current as { title: string; type?: string };
this.reservations.syncBudgetOnUpdate(tripId, id, body.title ?? '', body.type, cur.title, cur.type, body.create_budget_entry, socketId);
this.reservations.broadcast(tripId, 'reservation:updated', { reservation }, socketId);
// Push a locally-edited AirTrail flight back to AirTrail (fire-and-forget,
// under the importer's credentials — see airtrailSync). #214
if ((reservation as any)?.external_source === 'airtrail' && (reservation as any)?.sync_enabled) {
void pushReservationToAirtrail(Number((reservation as any).id), Number(tripId)).catch(() => {});
}
this.reservations.notifyBookingChange(tripId, user, body.title || cur.title, body.type || cur.type || '');
return { reservation };
}
+27 -1
View File
@@ -334,6 +334,31 @@ function startTrekPhotoCacheCleanup(): void {
});
}
// AirTrail sync: poll connected instances on an interval and reconcile linked
// flights both ways (#214). The per-tick enable gate (addon + setting) lives in
// runAirtrailSync, so toggling the addon takes effect without a restart.
let airtrailSyncTask: ScheduledTask | null = null;
function startAirTrailSync(): void {
if (airtrailSyncTask) { airtrailSyncTask.stop(); airtrailSyncTask = null; }
const { db } = require('./db/database');
const getSetting = (key: string) => (db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)?.value;
const raw = parseInt(getSetting('airtrail_poll_interval_minutes') || '5', 10);
const minutes = Number.isFinite(raw) && raw >= 1 && raw <= 59 ? raw : 5;
const tz = process.env.TZ || 'UTC';
logInfo(`AirTrail sync: scheduled every ${minutes}m`);
airtrailSyncTask = cron.schedule(`*/${minutes} * * * *`, async () => {
try {
const { runAirtrailSync } = require('./services/airtrail/airtrailSync');
await runAirtrailSync();
} catch (err: unknown) {
logError(`AirTrail sync tick failed: ${err instanceof Error ? err.message : err}`);
}
}, { timezone: tz });
}
function stop(): void {
if (currentTask) { currentTask.stop(); currentTask = null; }
if (demoTask) { demoTask.stop(); demoTask = null; }
@@ -341,6 +366,7 @@ function stop(): void {
if (versionCheckTask) { versionCheckTask.stop(); versionCheckTask = null; }
if (idempotencyCleanupTask) { idempotencyCleanupTask.stop(); idempotencyCleanupTask = null; }
if (trekPhotoCacheTask) { trekPhotoCacheTask.stop(); trekPhotoCacheTask = null; }
if (airtrailSyncTask) { airtrailSyncTask.stop(); airtrailSyncTask = null; }
}
export { start, stop, startDemoReset, startTripReminders, startTodoReminders, startVersionCheck, startIdempotencyCleanup, startTrekPhotoCacheCleanup, loadSettings, saveSettings, VALID_INTERVALS };
export { start, stop, startDemoReset, startTripReminders, startTodoReminders, startVersionCheck, startIdempotencyCleanup, startTrekPhotoCacheCleanup, startAirTrailSync, loadSettings, saveSettings, VALID_INTERVALS };
@@ -0,0 +1,197 @@
import { safeFetch } from '../../utils/ssrfGuard';
/**
* Thin HTTP client for the AirTrail REST API (github.com/johanohly/AirTrail).
* This is the ONLY place that talks to a user's AirTrail instance.
*
* Verified against AirTrail source:
* - Auth: `Authorization: Bearer <key>`; a key maps to exactly one user.
* - GET /api/flight/list — defaults to scope=mine. We NEVER send a scope
* param so the key only ever returns its owner's own flights (isolation
* holds even if an admin key is pasted).
* - GET /api/flight/get/{id}
* - POST /api/flight/save — `id` present => update, else create. seats[] is
* required (>=1). A seat with userId '<USER_ID>' is attributed to the key
* owner server-side, so we never need the caller's AirTrail user id.
* - There is no webhook and no updated_at on a flight, so change detection is
* snapshot-hash based (see airtrailSync).
*/
const TIMEOUT_MS = 12000;
export interface AirtrailCreds {
/** Instance origin without a trailing /api. */
baseUrl: string;
apiKey: string;
allowInsecureTls: boolean;
}
export class AirtrailAuthError extends Error {
constructor(message = 'AirTrail rejected the API key') {
super(message);
this.name = 'AirtrailAuthError';
}
}
export class AirtrailRequestError extends Error {
status?: number;
constructor(message: string, status?: number) {
super(message);
this.name = 'AirtrailRequestError';
this.status = status;
}
}
export interface AirtrailAirport {
id: number;
icao: string | null;
iata: string | null;
name: string | null;
lat: number | null;
lon: number | null;
tz: string | null;
country: string | null;
}
export interface AirtrailSeat {
userId: string | null;
guestName: string | null;
seat: string | null;
seatNumber: string | null;
seatClass: string | null;
}
/** Airline/aircraft come back as joined objects (not bare codes) on a flight. */
export interface AirtrailNamedCode {
id?: number;
icao?: string | null;
iata?: string | null;
name?: string | null;
}
/** A flight as returned by list/get (the fields TREK consumes). */
export interface AirtrailFlightRaw {
id: number;
from: AirtrailAirport | null;
to: AirtrailAirport | null;
date: string | null;
datePrecision: string | null;
departure: string | null;
arrival: string | null;
airline: AirtrailNamedCode | null;
flightNumber: string | null;
aircraft: AirtrailNamedCode | null;
aircraftReg: string | null;
flightReason: string | null;
note: string | null;
seats: AirtrailSeat[];
}
/** Write shape accepted by POST /flight/save (airports/airline/aircraft as codes). */
export interface AirtrailSavePayload {
id?: number;
from: string;
to: string;
departure: string;
departureTime?: string | null;
arrival?: string | null;
arrivalTime?: string | null;
datePrecision?: string;
airline?: string | null;
flightNumber?: string | null;
aircraft?: string | null;
aircraftReg?: string | null;
flightReason?: string | null;
note?: string | null;
seats: Array<{
userId: string | null;
guestName: string | null;
seat: string | null;
seatNumber: string | null;
seatClass: string | null;
}>;
}
function apiBase(baseUrl: string): string {
// Tolerate a pasted trailing slash or '/api' suffix so we never build '/api/api'.
const origin = baseUrl.trim().replace(/\/+$/, '').replace(/\/api$/i, '');
return origin + '/api';
}
/**
* Parse a response as JSON, but turn the cryptic "Unexpected token '<'" that a
* misconfigured URL produces (AirTrail serving its SPA / an auth-proxy login
* page) into an actionable message.
*/
async function parseJson<T>(resp: Response): Promise<T> {
const text = await resp.text();
try {
return JSON.parse(text) as T;
} catch {
throw new AirtrailRequestError(
'AirTrail returned a non-JSON response. Check the URL is your AirTrail base URL (e.g. https://airtrail.example.com, without /api) and that the instance is reachable without a separate login.',
);
}
}
async function request(creds: AirtrailCreds, path: string, init: RequestInit): Promise<Response> {
const url = apiBase(creds.baseUrl) + path;
let resp: Response;
try {
resp = await safeFetch(
url,
{
...init,
headers: {
Authorization: `Bearer ${creds.apiKey}`,
Accept: 'application/json',
...(init.headers || {}),
},
signal: AbortSignal.timeout(TIMEOUT_MS) as any,
},
{ rejectUnauthorized: !creds.allowInsecureTls },
);
} catch (err: unknown) {
throw new AirtrailRequestError(err instanceof Error ? err.message : 'Could not reach AirTrail');
}
if (resp.status === 401 || resp.status === 403) {
throw new AirtrailAuthError();
}
return resp;
}
export async function listFlights(creds: AirtrailCreds): Promise<AirtrailFlightRaw[]> {
const resp = await request(creds, '/flight/list', { method: 'GET' });
if (!resp.ok) throw new AirtrailRequestError(`AirTrail list failed (HTTP ${resp.status})`, resp.status);
const data = await parseJson<{ flights?: AirtrailFlightRaw[] }>(resp);
return data.flights ?? [];
}
export async function getFlight(creds: AirtrailCreds, id: number): Promise<AirtrailFlightRaw | null> {
const resp = await request(creds, `/flight/get/${id}`, { method: 'GET' });
if (resp.status === 404) return null;
if (!resp.ok) throw new AirtrailRequestError(`AirTrail get failed (HTTP ${resp.status})`, resp.status);
const data = await parseJson<{ flight?: AirtrailFlightRaw }>(resp);
return data.flight ?? null;
}
export async function saveFlight(creds: AirtrailCreds, payload: AirtrailSavePayload): Promise<{ id?: number }> {
const resp = await request(creds, '/flight/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) {
let msg = `AirTrail save failed (HTTP ${resp.status})`;
try {
const body = (await resp.json()) as { message?: string; errors?: unknown };
if (body?.message) msg = body.message;
else if (body?.errors) msg = JSON.stringify(body.errors);
} catch {
/* keep the generic message */
}
throw new AirtrailRequestError(msg, resp.status);
}
const data = await parseJson<{ id?: number }>(resp);
return { id: data.id };
}
@@ -0,0 +1,132 @@
import type { AirtrailImportResult } from '@trek/shared';
import { db } from '../../db/database';
import { broadcast } from '../../websocket';
import { createReservation } from '../reservationService';
import { getAirtrailCredentials } from './airtrailService';
import { AirtrailRequestError, listFlights } from './airtrailClient';
import { canonicalHash, mapFlightToReservation } from './airtrailMapper';
interface ExistingFlightRow {
id: number;
reservation_time: string | null;
metadata: string | null;
from_code: string | null;
to_code: string | null;
}
function depDate(t: string | null): string | null {
return t && /^\d{4}-\d{2}-\d{2}/.test(t) ? t.slice(0, 10) : null;
}
/** A loose "same physical flight" key: flight number + date, else route + date. */
function softSignature(
date: string | null,
flightNumber: string | null,
fromCode: string | null,
toCode: string | null,
): string | null {
if (!date) return null;
if (flightNumber) return `fn:${flightNumber.toUpperCase()}@${date}`;
if (fromCode && toCode) return `rt:${fromCode.toUpperCase()}-${toCode.toUpperCase()}@${date}`;
return null;
}
/**
* 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.
*
* 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').
* The server re-fetches the flights by id with the caller's own key, so the client
* cannot inject arbitrary flight data.
*/
export async function importAirtrailFlights(
tripId: string | number,
userId: number,
flightIds: string[],
socketId: string | undefined,
): 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 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;
try {
fn = row.metadata ? (JSON.parse(row.metadata).flight_number ?? null) : null;
} catch {
/* malformed metadata — ignore */
}
const sig = softSignature(depDate(row.reservation_time), fn, row.from_code, row.to_code);
if (sig) existingSigs.add(sig);
}
for (const flight of selected) {
const fid = String(flight.id);
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,
);
if (sig && existingSigs.has(sig)) {
result.skipped.push({ flightId: fid, reason: 'already-in-trip', detail: mapped.title });
continue;
}
try {
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 = 1, external_hash = ?, external_synced_at = ? WHERE id = ?`,
).run(fid, userId, canonicalHash(flight), now, reservation.id);
// Carry the linkage on the broadcast payload so members see the badge live.
reservation.external_source = 'airtrail';
reservation.external_id = fid;
reservation.external_owner_user_id = userId;
reservation.sync_enabled = 1;
reservation.external_synced_at = now;
broadcast(tripId, 'reservation:created', { reservation }, socketId);
if (sig) existingSigs.add(sig);
linkedIds.add(fid);
result.imported.push(fid);
} catch (err) {
console.error('[airtrail-import] failed to import flight', fid, err instanceof Error ? err.message : err);
result.skipped.push({ flightId: fid, reason: 'invalid', detail: err instanceof Error ? err.message : undefined });
}
}
return result;
}
@@ -0,0 +1,200 @@
import * as crypto from 'node:crypto';
import type { AirtrailAirport, AirtrailFlightRaw, AirtrailNamedCode } from './airtrailClient';
import type { AirtrailFlight } from '@trek/shared';
/** Preferred display/lookup code for an airport. */
function airportCode(a: AirtrailAirport | null): string | null {
return a?.iata || a?.icao || 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 {
return e?.icao || e?.iata || null;
}
/**
* Local calendar date + clock time for an instant at a given IANA zone.
* AirTrail stores `departure`/`arrival` as instants (ISO w/ offset) plus a local
* `date`; the airport-local wall time is what TREK shows and files days by.
*/
function localParts(iso: string | null, tz: string | null): { date: string | null; time: string | null } {
if (!iso) return { date: null, time: null };
try {
const d = new Date(iso);
if (isNaN(d.getTime())) return { date: null, time: null };
const fmt = new Intl.DateTimeFormat('en-CA', {
timeZone: tz || 'UTC',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
const parts = fmt.formatToParts(d);
const get = (t: string) => parts.find(p => p.type === t)?.value ?? '';
const date = `${get('year')}-${get('month')}-${get('day')}`;
let hh = get('hour');
if (hh === '24') hh = '00'; // some ICU builds emit 24:00 for midnight
const time = `${hh}:${get('minute')}`;
return { date: /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : null, time };
} catch {
return { date: null, time: null };
}
}
/** Raw AirTrail flight → the normalized shape the import picker consumes. */
export function normalizeFlight(raw: AirtrailFlightRaw): AirtrailFlight {
return {
id: String(raw.id),
fromCode: airportCode(raw.from),
fromName: raw.from?.name ?? null,
toCode: airportCode(raw.to),
toName: raw.to?.name ?? null,
date: raw.date ?? null,
departure: raw.departure ?? null,
arrival: raw.arrival ?? null,
airline: entityCode(raw.airline),
flightNumber: raw.flightNumber ?? null,
aircraft: entityCode(raw.aircraft),
seatClass: (raw.seats?.find(s => s.userId) ?? raw.seats?.[0])?.seatClass ?? null,
};
}
export interface MappedEndpoint {
role: 'from' | 'to' | 'stop';
sequence: number;
name: string;
code: string | null;
lat: number;
lng: number;
timezone: string | null;
local_time: string | null;
local_date: string | null;
}
export interface MappedReservation {
title: string;
type: 'flight';
status: 'confirmed';
reservation_time: string | null;
reservation_end_time: string | null;
notes: string | null;
metadata: Record<string, unknown>;
endpoints: MappedEndpoint[];
needs_review: number;
}
function hasCoords(a: AirtrailAirport | null): a is AirtrailAirport & { lat: number; lng: number } {
return !!a && typeof a.lat === 'number' && typeof a.lon === 'number';
}
/** Raw AirTrail flight → the data createReservation() expects (type:'flight'). */
export function mapFlightToReservation(raw: AirtrailFlightRaw): MappedReservation {
const dep = localParts(raw.departure, raw.from?.tz ?? null);
const arr = localParts(raw.arrival, raw.to?.tz ?? null);
const fromCode = airportCode(raw.from);
const toCode = airportCode(raw.to);
const datePrefix = raw.date || dep.date;
const reservation_time = datePrefix ? `${datePrefix}T${dep.time ?? '00:00'}` : null;
const reservation_end_time = arr.date ? `${arr.date}T${arr.time ?? '00:00'}` : null;
const endpoints: MappedEndpoint[] = [];
let needsReview = raw.datePrecision && raw.datePrecision !== 'day' ? 1 : 0;
if (hasCoords(raw.from)) {
endpoints.push({
role: 'from',
sequence: 0,
name: raw.from.name || fromCode || 'Departure',
code: fromCode,
lat: raw.from.lat,
lng: raw.from.lon,
timezone: raw.from.tz,
local_time: dep.time,
local_date: datePrefix,
});
} else {
needsReview = 1;
}
if (hasCoords(raw.to)) {
endpoints.push({
role: 'to',
sequence: 1,
name: raw.to.name || toCode || 'Arrival',
code: toCode,
lat: raw.to.lat,
lng: raw.to.lon,
timezone: raw.to.tz,
local_time: arr.time,
local_date: arr.date,
});
} else {
needsReview = 1;
}
const seat = raw.seats?.find(s => s.userId) ?? raw.seats?.[0];
const airlineCode = entityCode(raw.airline);
const aircraftCode = entityCode(raw.aircraft);
const metadata: Record<string, unknown> = {};
if (airlineCode) metadata.airline = airlineCode;
if (raw.flightNumber) metadata.flight_number = raw.flightNumber;
if (aircraftCode) metadata.aircraft = aircraftCode;
if (raw.aircraftReg) metadata.aircraft_reg = raw.aircraftReg;
if (raw.flightReason) metadata.flight_reason = raw.flightReason;
if (seat?.seatNumber || seat?.seatClass) metadata.seat = seat.seatNumber || seat.seatClass;
// The flight number already carries the airline prefix (e.g. "SAS983"), so it
// makes the clearest title; fall back to the route.
const title = raw.flightNumber?.trim() || `${fromCode || '?'}${toCode || '?'}`;
return {
title,
type: 'flight',
status: 'confirmed',
reservation_time,
reservation_end_time,
notes: raw.note ?? null,
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
* writes from re-triggering a pull. Only fields that can meaningfully change are
* included, in a fixed key order.
*/
export function canonicalHash(raw: AirtrailFlightRaw): string {
const snapshot = {
from: airportCode(raw.from),
to: airportCode(raw.to),
date: raw.date ?? null,
datePrecision: raw.datePrecision ?? 'day',
departure: raw.departure ?? null,
arrival: raw.arrival ?? null,
airline: entityCode(raw.airline),
flightNumber: raw.flightNumber ?? null,
aircraft: entityCode(raw.aircraft),
aircraftReg: raw.aircraftReg ?? null,
flightReason: raw.flightReason ?? null,
note: raw.note ?? null,
seats: (raw.seats ?? [])
.map(s => ({
userId: s.userId ?? null,
guestName: s.guestName ?? null,
seat: s.seat ?? null,
seatNumber: s.seatNumber ?? null,
seatClass: s.seatClass ?? null,
}))
.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))),
};
return crypto.createHash('sha256').update(JSON.stringify(snapshot)).digest('hex');
}
@@ -0,0 +1,153 @@
import type { AirtrailFlight } from '@trek/shared';
import { db } from '../../db/database';
import { maybe_encrypt_api_key, decrypt_api_key } from '../apiKeyCrypto';
import { checkSsrf } from '../../utils/ssrfGuard';
import { writeAudit } from '../auditLog';
import { AirtrailAuthError, AirtrailCreds, AirtrailRequestError, listFlights } from './airtrailClient';
import { normalizeFlight } from './airtrailMapper';
const KEY_MASK = '••••••••';
interface UserConnRow {
airtrail_url?: string | null;
airtrail_api_key?: string | null;
airtrail_allow_insecure_tls?: 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 = ?')
.get(userId) as UserConnRow | undefined;
}
/** Decrypted creds for outbound calls, or null when the user has no connection. */
export function getAirtrailCredentials(userId: number): AirtrailCreds | null {
const row = readRow(userId);
if (!row?.airtrail_url || !row?.airtrail_api_key) return null;
const apiKey = decrypt_api_key(row.airtrail_api_key);
if (!apiKey) return null;
return {
baseUrl: row.airtrail_url,
apiKey,
allowInsecureTls: !!row.airtrail_allow_insecure_tls,
};
}
/** Settings as shown in the UI — the key is never echoed, only masked. */
export function getConnectionSettings(userId: number) {
const row = readRow(userId);
return {
url: row?.airtrail_url || '',
apiKeyMasked: row?.airtrail_api_key ? KEY_MASK : '',
allowInsecureTls: !!row?.airtrail_allow_insecure_tls,
connected: !!(row?.airtrail_url && row?.airtrail_api_key),
};
}
export async function saveSettings(
userId: number,
url: string | undefined,
apiKey: string | undefined,
allowInsecureTls: boolean,
clientIp: string | null,
): Promise<{ success: boolean; warning?: string; error?: string }> {
const trimmedUrl = (url || '').trim();
let warning: string | undefined;
if (trimmedUrl) {
const ssrf = await checkSsrf(trimmedUrl);
// Reject only genuinely unusable URLs (malformed, unresolvable, non-http,
// loopback). Private/LAN instances are the common self-hosted case, so we
// persist them with a warning rather than blocking — the outbound calls
// still need ALLOW_INTERNAL_NETWORK=true to actually reach them.
if (!ssrf.allowed && !ssrf.isPrivate) {
return { success: false, error: ssrf.error ?? 'Invalid AirTrail URL' };
}
if (ssrf.isPrivate) {
writeAudit({
userId,
action: 'airtrail.private_ip_configured',
ip: clientIp,
details: { airtrail_url: trimmedUrl, resolved_ip: ssrf.resolvedIp },
});
warning = `AirTrail URL resolves to a private IP (${ssrf.resolvedIp}). Make sure this is intentional — the server may need ALLOW_INTERNAL_NETWORK=true to reach it.`;
}
}
// Only overwrite the stored key when a genuinely new value is supplied;
// a blank field or the mask means "keep the existing key".
const provided = (apiKey || '').trim();
const newKey = provided && provided !== KEY_MASK ? maybe_encrypt_api_key(provided) : undefined;
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);
} else {
db.prepare(
'UPDATE users SET airtrail_url = ?, airtrail_allow_insecure_tls = ? WHERE id = ?',
).run(trimmedUrl || null, allowInsecureTls ? 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);
}
}
return { success: true, warning };
}
async function probe(creds: AirtrailCreds): Promise<{ connected: boolean; flightCount?: number; error?: string }> {
try {
const flights = await listFlights(creds);
return { connected: true, flightCount: flights.length };
} catch (err: unknown) {
if (err instanceof AirtrailAuthError) return { connected: false, error: 'Invalid API key' };
return { connected: false, error: err instanceof Error ? err.message : 'Connection failed' };
}
}
/** Live check using the stored connection. */
export async function getConnectionStatus(
userId: number,
): Promise<{ connected: boolean; flightCount?: number; error?: string }> {
const creds = getAirtrailCredentials(userId);
if (!creds) return { connected: false, error: 'Not configured' };
return probe(creds);
}
/**
* "Test connection" from the settings form. Uses the typed URL/key when given;
* falls back to the stored key when the key field still shows the mask.
*/
export async function testConnection(
userId: number,
url: string | undefined,
apiKey: string | undefined,
allowInsecureTls: boolean,
): Promise<{ connected: boolean; flightCount?: number; error?: string }> {
const trimmedUrl = (url || '').trim();
const provided = (apiKey || '').trim();
const stored = getAirtrailCredentials(userId);
const effectiveUrl = trimmedUrl || stored?.baseUrl;
const effectiveKey = provided && provided !== KEY_MASK ? provided : stored?.apiKey;
if (!effectiveUrl || !effectiveKey) {
return { connected: false, error: 'URL and API key required' };
}
const ssrf = await checkSsrf(effectiveUrl);
if (!ssrf.allowed && !ssrf.isPrivate) {
return { connected: false, error: ssrf.error ?? 'Invalid AirTrail URL' };
}
return probe({ baseUrl: effectiveUrl, apiKey: effectiveKey, allowInsecureTls });
}
/** The user's AirTrail flights, normalized for the import picker. */
export async function getFlightsForPicker(userId: number): Promise<AirtrailFlight[]> {
const creds = getAirtrailCredentials(userId);
if (!creds) throw new AirtrailRequestError('AirTrail is not connected', 400);
const raw = await listFlights(creds);
return raw.map(normalizeFlight);
}
@@ -0,0 +1,264 @@
import { db } from '../../db/database';
import { logError, logInfo } from '../auditLog';
import { broadcast } from '../../websocket';
import { isAddonEnabled } from '../adminService';
import { ADDON_IDS } from '../../addons';
import { getReservation, getReservationWithJoins, updateReservation } from '../reservationService';
import { getAirtrailCredentials } from './airtrailService';
import {
AirtrailAuthError,
AirtrailCreds,
AirtrailFlightRaw,
AirtrailSavePayload,
getFlight,
listFlights,
saveFlight,
} from './airtrailClient';
import { canonicalHash, mapFlightToReservation } from './airtrailMapper';
/** Global on/off: the addon must be enabled and sync not explicitly turned off. */
export function syncGloballyEnabled(): boolean {
if (!isAddonEnabled(ADDON_IDS.AIRTRAIL)) return false;
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'airtrail_sync_enabled'").get() as
| { value: string }
| undefined;
return row?.value !== 'false';
}
function broadcastUpdated(tripId: number, reservationId: number): void {
try {
const reservation = getReservationWithJoins(reservationId);
if (reservation) broadcast(tripId, 'reservation:updated', { reservation });
} catch {
/* broadcast failure is non-fatal */
}
}
function detach(tripId: number, reservationId: number): void {
db.prepare('UPDATE reservations SET sync_enabled = 0 WHERE id = ?').run(reservationId);
broadcastUpdated(tripId, reservationId);
}
// ── AirTrail → TREK (poll) ───────────────────────────────────────────────────
/**
* Reconcile one owner's linked reservations against their current AirTrail
* flights: apply field changes (detected by snapshot hash, since AirTrail has no
* updated_at) and, when a flight is gone from AirTrail, keep the TREK row but
* stop syncing it. Only already-imported flights are touched — new AirTrail
* flights are never auto-added to a trip. Returns how many rows changed.
*/
async function syncOwner(uid: number): Promise<number> {
const creds = getAirtrailCredentials(uid);
if (!creds) return 0; // owner disconnected — leave their linked rows as-is
let flights: AirtrailFlightRaw[];
try {
flights = await listFlights(creds);
} catch (err) {
if (err instanceof AirtrailAuthError) logError(`AirTrail sync: invalid API key for user ${uid}`);
return 0;
}
const byId = new Map(flights.map(f => [String(f.id), f]));
const linked = db
.prepare(
"SELECT id, trip_id, external_id, external_hash FROM reservations WHERE external_source = 'airtrail' AND sync_enabled = 1 AND external_owner_user_id = ?",
)
.all(uid) as { id: number; trip_id: number; external_id: string; external_hash: string | null }[];
let changed = 0;
for (const row of linked) {
const flight = byId.get(String(row.external_id));
if (!flight) {
detach(row.trip_id, row.id); // deleted in AirTrail → keep row, stop syncing
changed++;
continue;
}
const hash = canonicalHash(flight);
if (hash === row.external_hash) continue;
const current = getReservation(row.id, row.trip_id);
if (!current) continue;
try {
updateReservation(row.id, row.trip_id, mapFlightToReservation(flight) as any, current as any);
db.prepare('UPDATE reservations SET external_hash = ?, external_synced_at = ? WHERE id = ?').run(
hash,
new Date().toISOString(),
row.id,
);
broadcastUpdated(row.trip_id, row.id);
changed++;
} catch (err) {
logError(`AirTrail sync: failed to update reservation ${row.id}: ${err instanceof Error ? err.message : err}`);
}
}
return changed;
}
let running = false;
/** Background poll across every connected owner (scheduler). */
export async function runAirtrailSync(): Promise<void> {
if (running) return;
if (!syncGloballyEnabled()) return;
running = true;
let changed = 0;
try {
const owners = db
.prepare(
"SELECT DISTINCT external_owner_user_id AS uid FROM reservations WHERE external_source = 'airtrail' AND sync_enabled = 1 AND external_owner_user_id IS NOT NULL",
)
.all() as { uid: number }[];
for (const { uid } of owners) changed += await syncOwner(uid);
if (changed > 0) logInfo(`AirTrail sync: applied ${changed} change(s)`);
} catch (err) {
logError(`AirTrail sync failed: ${err instanceof Error ? err.message : err}`);
} finally {
running = false;
}
}
/**
* On-demand sync of just this user's linked flights — called when the user opens
* a trip so AirTrail-side edits show up immediately instead of waiting for the
* background poll.
*/
export async function runAirtrailSyncForUser(userId: number): Promise<{ changed: number }> {
if (!syncGloballyEnabled()) return { changed: 0 };
try {
return { changed: await syncOwner(userId) };
} catch (err) {
logError(`AirTrail sync (user ${userId}) failed: ${err instanceof Error ? err.message : err}`);
return { changed: 0 };
}
}
// ── TREK → AirTrail (push) ───────────────────────────────────────────────────
function splitLocal(dt: string | null | undefined): { date: string | null; time: string | null } {
if (!dt) return { date: null, time: null };
const date = dt.slice(0, 10);
const m = dt.slice(10).match(/(\d{2}:\d{2})/);
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 {
let meta: Record<string, any> = {};
try {
meta = reservation.metadata ? JSON.parse(reservation.metadata) : {};
} catch {
meta = {};
}
const endpoints: any[] = reservation.endpoints || [];
const fromEp = endpoints.find(e => e.role === 'from');
const toEp = endpoints.find(e => e.role === 'to');
const fromCode = fromEp?.code || existing.from?.iata || existing.from?.icao || null;
const toCode = toEp?.code || existing.to?.iata || existing.to?.icao || null;
if (!fromCode || !toCode) return null;
const dep = splitLocal(reservation.reservation_time);
const arr = splitLocal(reservation.reservation_end_time);
if (!dep.date) return null;
// Preserve the existing seat manifest (an update replaces all seats); fall back
// to the key-owner placeholder so AirTrail attributes it to the connecting user.
const seats = (existing.seats ?? []).map(s => ({
userId: s.userId,
guestName: s.guestName,
seat: s.seat,
seatNumber: s.seatNumber,
seatClass: s.seatClass,
}));
if (seats.length === 0) {
seats.push({ userId: '<USER_ID>', guestName: null, seat: null, seatNumber: null, seatClass: null });
}
// Push the seat the user set in TREK onto their own AirTrail seat (the one with
// a userId), leaving any co-passenger seats untouched.
const seatNumber = typeof meta.seat === 'string' && meta.seat.trim() ? meta.seat.trim() : null;
if (seatNumber) {
const ownSeat = seats.find(s => s.userId) ?? seats[0];
if (ownSeat) ownSeat.seatNumber = seatNumber;
}
return {
id: Number(reservation.external_id),
from: fromCode,
to: toCode,
departure: dep.date,
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,
seats,
};
}
/**
* Push a locally-edited linked reservation back to AirTrail using the importer's
* (owner's) credentials — even if a different member made the edit. If the owner
* is gone or the flight no longer exists in AirTrail, the link is detached so the
* next pull's AirTrail-wins policy can't silently revert the local edit.
*/
export async function pushReservationToAirtrail(reservationId: number, tripId: number): Promise<void> {
if (!syncGloballyEnabled()) return;
const row = db
.prepare(
"SELECT id, trip_id, external_id, external_owner_user_id, sync_enabled FROM reservations WHERE id = ? AND external_source = 'airtrail'",
)
.get(reservationId) as
| { id: number; trip_id: number; external_id: string; external_owner_user_id: number | null; sync_enabled: number }
| undefined;
if (!row || !row.sync_enabled) return;
const creds: AirtrailCreds | null = row.external_owner_user_id
? getAirtrailCredentials(row.external_owner_user_id)
: null;
if (!creds) {
detach(tripId, row.id); // owner disconnected — cannot push, so stop syncing
return;
}
let existing: AirtrailFlightRaw | null;
try {
existing = await getFlight(creds, Number(row.external_id));
} catch (err) {
if (err instanceof AirtrailAuthError) detach(tripId, row.id);
else logError(`AirTrail push: get failed for reservation ${row.id}: ${err instanceof Error ? err.message : err}`);
return;
}
if (!existing) {
detach(tripId, row.id); // gone in AirTrail → treat like a remote delete
return;
}
const reservation = getReservationWithJoins(row.id);
if (!reservation) return;
const payload = buildSavePayload(reservation, existing);
if (!payload) return;
try {
await saveFlight(creds, payload);
// Self-write suppression: re-read the saved flight and store its hash so the
// next poll doesn't treat our own write as an inbound change.
const saved = await getFlight(creds, Number(row.external_id));
if (saved) {
db.prepare('UPDATE reservations SET external_hash = ?, external_synced_at = ? WHERE id = ?').run(
canonicalHash(saved),
new Date().toISOString(),
row.id,
);
}
} catch (err) {
logError(`AirTrail push failed for reservation ${row.id}: ${err instanceof Error ? err.message : err}`);
}
}
@@ -30,10 +30,14 @@ function mark(db: Database.Database, userId: number, code: string, name: string,
).run(userId, code, name, country);
}
// Rewind one migration and re-run so only the reconciliation (the last migration) executes.
// The visited_regions reconciliation (#1119) is pinned at schema version 135.
// Migrations added afterwards are appended AFTER it (append-only), so it is no
// longer the last migration. Rewind to just before the reconciliation and
// re-run: the later migrations are idempotent, so only the reconciliation has
// any effect on the seeded rows here.
const RECONCILIATION_VERSION = 135;
function rerunLastMigration(db: Database.Database) {
const version = (db.prepare('SELECT version FROM schema_version').get() as { version: number }).version;
db.prepare('UPDATE schema_version SET version = ?').run(version - 1);
db.prepare('UPDATE schema_version SET version = ?').run(RECONCILIATION_VERSION - 1);
runMigrations(db);
}
@@ -0,0 +1,134 @@
import { describe, it, expect } from 'vitest';
import { canonicalHash, mapFlightToReservation, normalizeFlight } from '../../../src/services/airtrail/airtrailMapper';
import type { AirtrailFlightRaw } from '../../../src/services/airtrail/airtrailClient';
function airport(over: Partial<AirtrailFlightRaw['from']> = {}): NonNullable<AirtrailFlightRaw['from']> {
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,
};
}
function flight(over: Partial<AirtrailFlightRaw> = {}): AirtrailFlightRaw {
return {
id: 42,
from: airport(),
to: airport({ id: 2, icao: 'EGLL', iata: 'LHR', name: 'London Heathrow', lat: 51.4706, lon: -0.4619, tz: 'Europe/London' }),
date: '2021-09-01',
datePrecision: 'day',
departure: '2021-09-01T23:00:00.000+00:00', // 19:00 local at JFK (EDT, UTC-4)
arrival: '2021-09-02T07:00:00.000+00:00', // 08:00 local at LHR (BST, UTC+1)
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' }],
...over,
};
}
describe('airtrailMapper.normalizeFlight', () => {
it('prefers IATA codes and exposes the picker fields', () => {
const n = normalizeFlight(flight());
expect(n).toMatchObject({
id: '42',
fromCode: 'JFK',
toCode: 'LHR',
date: '2021-09-01',
airline: 'BAW',
flightNumber: 'BA178',
seatClass: 'economy',
});
});
it('falls back to ICAO when IATA is missing and tolerates null airports', () => {
const n = normalizeFlight(flight({ from: airport({ iata: null }), to: null }));
expect(n.fromCode).toBe('KJFK');
expect(n.toCode).toBeNull();
expect(n.toName).toBeNull();
});
});
describe('airtrailMapper.mapFlightToReservation', () => {
it('composes airport-local times from the instant + airport tz', () => {
const m = mapFlightToReservation(flight());
// 23:00 UTC at JFK in September is 19:00 EDT; date stays the AirTrail local date.
expect(m.reservation_time).toBe('2021-09-01T19:00');
// 07:00 UTC at LHR in September is 08:00 BST.
expect(m.reservation_end_time).toBe('2021-09-02T08:00');
});
it('builds two endpoints with codes, coords and timezones', () => {
const m = mapFlightToReservation(flight());
expect(m.endpoints).toHaveLength(2);
expect(m.endpoints[0]).toMatchObject({ role: 'from', code: 'JFK', lat: 40.6413, timezone: 'America/New_York', local_date: '2021-09-01', local_time: '19:00' });
expect(m.endpoints[1]).toMatchObject({ role: 'to', code: 'LHR', timezone: 'Europe/London', local_time: '08:00' });
expect(m.needs_review).toBe(0);
});
it('titles from the flight number, else the route', () => {
expect(mapFlightToReservation(flight()).title).toBe('BA178');
expect(mapFlightToReservation(flight({ airline: null, flightNumber: null })).title).toBe('JFK → LHR');
});
it('carries flight metadata', () => {
const m = mapFlightToReservation(flight());
expect(m.metadata).toMatchObject({ airline: 'BAW', flight_number: 'BA178', aircraft: 'B772', aircraft_reg: 'G-VIIL', flight_reason: 'leisure', seat: '12A' });
expect(m.type).toBe('flight');
expect(m.status).toBe('confirmed');
expect(m.notes).toBe('window seat');
});
it('flags needs_review for a non-day date precision', () => {
expect(mapFlightToReservation(flight({ datePrecision: 'month' })).needs_review).toBe(1);
});
it('flags needs_review and drops the endpoint when an airport has no coordinates', () => {
const m = mapFlightToReservation(flight({ from: airport({ lat: null, lon: null }) }));
expect(m.needs_review).toBe(1);
expect(m.endpoints.find(e => e.role === 'from')).toBeUndefined();
expect(m.endpoints.find(e => e.role === 'to')).toBeDefined();
});
it('leaves the end time null for a partial flight with no arrival', () => {
const m = mapFlightToReservation(flight({ arrival: null }));
expect(m.reservation_end_time).toBeNull();
expect(m.reservation_time).toBe('2021-09-01T19:00');
});
});
describe('airtrailMapper.canonicalHash', () => {
it('is stable for the same flight', () => {
expect(canonicalHash(flight())).toBe(canonicalHash(flight()));
});
it('changes when a meaningful field changes', () => {
expect(canonicalHash(flight())).not.toBe(canonicalHash(flight({ flightNumber: 'BA179' })));
expect(canonicalHash(flight())).not.toBe(canonicalHash(flight({ note: 'aisle seat' })));
});
it('is independent of seat ordering', () => {
const a = flight({
seats: [
{ userId: 'u1', guestName: null, seat: null, seatNumber: '1A', seatClass: 'economy' },
{ userId: 'u2', guestName: null, seat: null, seatNumber: '1B', seatClass: 'economy' },
],
});
const b = flight({
seats: [
{ userId: 'u2', guestName: null, seat: null, seatNumber: '1B', seatClass: 'economy' },
{ userId: 'u1', guestName: null, seat: null, seatNumber: '1A', seatClass: 'economy' },
],
});
expect(canonicalHash(a)).toBe(canonicalHash(b));
});
});