Spaces:
Sleeping
Sleeping
File size: 5,167 Bytes
57a889c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 | 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;
}
|