mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
56655d53b4
* 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).
265 lines
9.8 KiB
TypeScript
265 lines
9.8 KiB
TypeScript
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}`);
|
|
}
|
|
}
|