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;
}