augmenttoolkit / server /src /services /airtrail /airtrailSync.ts
Leon4gr45's picture
Upload folder using huggingface_hub (part 5)
57a889c verified
Raw
History Blame Contribute Delete
10.1 kB
import { ADDON_IDS } from '../../addons';
import { db } from '../../db/database';
import { broadcast } from '../../websocket';
import { isAddonEnabled } from '../adminService';
import { logError, logInfo } from '../auditLog';
import { getReservation, getReservationWithJoins, updateReservation } from '../reservationService';
import {
AirtrailAuthError,
AirtrailCreds,
AirtrailFlightRaw,
AirtrailSavePayload,
getFlight,
listFlights,
saveFlight,
} from './airtrailClient';
import { canonicalHash, mapFlightToReservation } from './airtrailMapper';
import { getAirtrailCredentials } from './airtrailService';
/** 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}`);
}
}