import { db } from './db/index.js'; import { randomUUID } from 'crypto'; import type { PlanStatus } from './plans.js'; // --- Geo utilities --- function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number { const R = 6371; const dLat = ((lat2 - lat1) * Math.PI) / 180; const dLon = ((lon2 - lon1) * Math.PI) / 180; const a = Math.sin(dLat / 2) ** 2 + Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLon / 2) ** 2; return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); } /** Convert a "YYYY-MM-DDTHH:MM" local datetime in an IANA timezone to a UTC ms timestamp. */ function tzLocalToUtcMs(localDt: string, tz: string): number { const [datePart, timePart] = localDt.split('T'); const [y, mo, d] = datePart.split('-').map(Number); const [h, mi] = timePart.split(':').map(Number); // Treat the local time as UTC first, then correct for the timezone offset. const candidate = Date.UTC(y, mo - 1, d, h, mi); const fmt = new Intl.DateTimeFormat('en-GB', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }); const parts = fmt.formatToParts(new Date(candidate)); const get = (type: string) => Number(parts.find((p) => p.type === type)?.value ?? '0'); const shownH = get('hour'); const shownMi = get('minute'); // Offset = difference between what we intended and what the TZ shows at that UTC moment const offsetMins = (h - shownH) * 60 + (mi - shownMi); return candidate + offsetMins * 60_000; } function durationMinutes( depDatetime: string, depTz: string, arrDatetime: string, arrTz: string ): number | null { try { const depMs = tzLocalToUtcMs(depDatetime, depTz); const arrMs = tzLocalToUtcMs(arrDatetime, arrTz); const mins = Math.round((arrMs - depMs) / 60_000); return mins > 0 ? mins : null; } catch { return null; } } /** Look up the IANA timezone for an airport by its DB id. */ function airportTimezone(airportId: number | null | undefined): string | null { if (!airportId) return null; return ( db.get<{ timezone: string | null }>('SELECT timezone FROM airports WHERE id = ?', [airportId]) ?.timezone ?? null ); } export interface Airport { id: number; iata_code: string | null; icao_code: string | null; name: string; city: string | null; country: string; country_code: string; latitude: number | null; longitude: number | null; timezone: string | null; created_at: string; } export interface Airline { id: number; iata_code: string | null; icao_code: string | null; name: string; country: string | null; country_code: string | null; created_at: string; } export interface FlightBooking { id: string; plan_id: string; confirmation_number: string | null; price: number | null; currency: string; created_at: string; updated_at: string; } export interface FlightSegment { id: string; flight_booking_id: string; departure_date: string; airline_id: number | null; airline_iata: string | null; airline_icao: string | null; airline_name: string | null; flight_number: string | null; position: number; created_at: string; } export interface FlightRoute { id: string; flight_segment_id: string; departure_airport_id: number | null; departure_airport_code: string | null; departure_terminal: string | null; departure_gate: string | null; departure_datetime: string | null; departure_timezone: string | null; arrival_airport_id: number | null; arrival_airport_code: string | null; arrival_terminal: string | null; arrival_gate: string | null; arrival_date: string | null; arrival_datetime: string | null; arrival_timezone: string | null; created_at: string; updated_at: string; } export interface CreateFlightInput { tripId: string; userId: string; parentId?: string; confirmationNumber?: string; price?: number; currency?: string; status?: PlanStatus; segments: Array<{ departureDate: string; airlineId?: number; airlineIata?: string; airlineIcao?: string; airlineName?: string; flightNumber?: string; route?: { departureAirportId?: number; departureAirportCode?: string; departureTerminal?: string; departureGate?: string; departureDatetime?: string; departureTimezone?: string; arrivalAirportId?: number; arrivalAirportCode?: string; arrivalTerminal?: string; arrivalGate?: string; arrivalDate?: string; arrivalDatetime?: string; arrivalTimezone?: string; }; }>; passengerIds?: string[]; } export function searchAirports(query: string): Airport[] { if (!query.trim()) return []; const pattern = `%${query.trim()}%`; return db.all( `SELECT * FROM airports WHERE name LIKE ? COLLATE NOCASE OR iata_code LIKE ? COLLATE NOCASE OR icao_code LIKE ? COLLATE NOCASE OR city LIKE ? COLLATE NOCASE ORDER BY CASE WHEN iata_code LIKE ? COLLATE NOCASE THEN 0 ELSE 1 END, CASE WHEN name LIKE ? COLLATE NOCASE THEN 0 ELSE 1 END LIMIT 20`, [pattern, pattern, pattern, pattern, `${query.trim()}%`, `${query.trim()}%`] ); } export function searchAirlines(query: string): Airline[] { if (!query.trim()) return []; const pattern = `%${query.trim()}%`; return db.all( `SELECT * FROM airlines WHERE name LIKE ? COLLATE NOCASE OR iata_code LIKE ? COLLATE NOCASE OR icao_code LIKE ? COLLATE NOCASE ORDER BY CASE WHEN iata_code LIKE ? COLLATE NOCASE THEN 0 ELSE 1 END, CASE WHEN name LIKE ? COLLATE NOCASE THEN 0 ELSE 1 END LIMIT 20`, [pattern, pattern, pattern, `${query.trim()}%`, `${query.trim()}%`] ); } export function getAirportById(id: number): Airport | undefined { return db.get('SELECT * FROM airports WHERE id = ?', [id]); } export function getAirportByCode(code: string): Airport | undefined { return db.get('SELECT * FROM airports WHERE iata_code = ? OR icao_code = ?', [ code.toUpperCase(), code.toUpperCase() ]); } export function getAirlineById(id: number): Airline | undefined { return db.get('SELECT * FROM airlines WHERE id = ?', [id]); } export function getAirlineByCode(code: string): Airline | undefined { return db.get('SELECT * FROM airlines WHERE iata_code = ? OR icao_code = ?', [ code.toUpperCase(), code.toUpperCase() ]); } export function createFlight(input: CreateFlightInput): FlightBooking { // First, create a plan entry for the flight const planId = randomUUID(); const maxPos = db.get<{ pos: number }>( `SELECT COALESCE(MAX(position), -1) + 1 as pos FROM plans WHERE trip_id = ? AND user_id = ?`, [input.tripId, input.userId] ); const position = maxPos?.pos ?? 0; // Generate a title from the first segment const firstSegment = input.segments[0]; const title = firstSegment ? (() => { const airline = firstSegment.airlineName || firstSegment.airlineIata || 'Flight'; const flightNumber = firstSegment.flightNumber?.trim(); const dep = firstSegment.route?.departureAirportCode?.trim(); const arr = firstSegment.route?.arrivalAirportCode?.trim(); if (flightNumber) return `${airline} ${flightNumber}`; if (dep || arr) return `Flight: ${dep || 'TBD'} -> ${arr || 'TBD'}`; return 'Flight'; })() : 'Flight'; db.run( `INSERT INTO plans (id, trip_id, user_id, type, status, title, parent_id, position) VALUES (?, ?, ?, 'transport', ?, ?, ?, ?)`, [ planId, input.tripId, input.userId, input.status ?? 'idea', title, input.parentId ?? null, position ] ); // Create the flight booking const bookingId = randomUUID(); db.run( `INSERT INTO flight_bookings (id, plan_id, confirmation_number, price, currency) VALUES (?, ?, ?, ?, ?)`, [ bookingId, planId, input.confirmationNumber ?? null, input.price ?? null, input.currency ?? 'USD' ] ); // Create flight segments let segmentPosition = 0; for (const segment of input.segments) { const segmentId = randomUUID(); // Airline data comes fully resolved from the caller — no re-query needed const airlineIata = segment.airlineIata; const airlineIcao = segment.airlineIcao; const airlineName = segment.airlineName; db.run( `INSERT INTO flight_segments (id, flight_booking_id, departure_date, airline_id, airline_iata, airline_icao, airline_name, flight_number, position) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ segmentId, bookingId, segment.departureDate, segment.airlineId ?? null, airlineIata ?? null, airlineIcao ?? null, airlineName ?? null, segment.flightNumber?.trim() || '', segmentPosition++ ] ); // Create route if provided if (segment.route) { const routeId = randomUUID(); db.run( `INSERT INTO flight_routes ( id, flight_segment_id, departure_airport_id, departure_airport_code, departure_terminal, departure_gate, departure_datetime, departure_timezone, arrival_airport_id, arrival_airport_code, arrival_terminal, arrival_gate, arrival_date, arrival_datetime, arrival_timezone ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ routeId, segmentId, segment.route.departureAirportId ?? null, segment.route.departureAirportCode ?? null, segment.route.departureTerminal ?? null, segment.route.departureGate ?? null, segment.route.departureDatetime ?? null, segment.route.departureTimezone ?? airportTimezone(segment.route.departureAirportId) ?? null, segment.route.arrivalAirportId ?? null, segment.route.arrivalAirportCode ?? null, segment.route.arrivalTerminal ?? null, segment.route.arrivalGate ?? null, segment.route.arrivalDate ?? null, segment.route.arrivalDatetime ?? null, segment.route.arrivalTimezone ?? airportTimezone(segment.route.arrivalAirportId) ?? null ] ); } } // Link passengers if provided if (input.passengerIds && input.passengerIds.length > 0) { for (const personId of input.passengerIds) { db.run( `INSERT OR IGNORE INTO flight_booking_passengers (flight_booking_id, person_id) VALUES (?, ?)`, [bookingId, personId] ); } } return db.get('SELECT * FROM flight_bookings WHERE id = ?', [bookingId])!; } function enrichRoute( route: FlightRoute | undefined ): (FlightRoute & { distanceKm: number | null; durationMins: number | null }) | null { if (!route) return null; let distanceKm: number | null = null; let durationMins: number | null = null; if (route.departure_airport_id && route.arrival_airport_id) { const dep = db.get<{ latitude: number | null; longitude: number | null }>( 'SELECT latitude, longitude FROM airports WHERE id = ?', [route.departure_airport_id] ); const arr = db.get<{ latitude: number | null; longitude: number | null }>( 'SELECT latitude, longitude FROM airports WHERE id = ?', [route.arrival_airport_id] ); if (dep?.latitude && dep?.longitude && arr?.latitude && arr?.longitude) { distanceKm = Math.round( haversineKm(dep.latitude, dep.longitude, arr.latitude, arr.longitude) ); } } if ( route.departure_datetime && route.arrival_datetime && route.departure_timezone && route.arrival_timezone ) { durationMins = durationMinutes( route.departure_datetime, route.departure_timezone, route.arrival_datetime, route.arrival_timezone ); } return { ...route, distanceKm, durationMins }; } export function getFlightBookingByPlanId( planId: string, userId: string ): | (FlightBooking & { segments: Array< FlightSegment & { route: (FlightRoute & { distanceKm: number | null; durationMins: number | null }) | null; } >; passengerIds: string[]; }) | undefined { const booking = db.get( `SELECT fb.* FROM flight_bookings fb JOIN plans p ON p.id = fb.plan_id WHERE fb.plan_id = ? AND p.user_id = ?`, [planId, userId] ); if (!booking) return undefined; const segments = db.all( `SELECT * FROM flight_segments WHERE flight_booking_id = ? ORDER BY position ASC`, [booking.id] ); const segmentsWithRoutes = segments.map((segment) => { const route = db.get('SELECT * FROM flight_routes WHERE flight_segment_id = ?', [ segment.id ]); return { ...segment, route: enrichRoute(route) }; }); const passengerIds = db .all<{ person_id: string; }>('SELECT person_id FROM flight_booking_passengers WHERE flight_booking_id = ?', [booking.id]) .map((r) => r.person_id); return { ...booking, segments: segmentsWithRoutes, passengerIds }; } export function getFlightBookingsForTrip( tripId: string, userId: string ): Array< FlightBooking & { segments: Array< FlightSegment & { route: (FlightRoute & { distanceKm: number | null; durationMins: number | null }) | null; } >; passengerIds: string[]; } > { const bookings = db.all( `SELECT fb.* FROM flight_bookings fb JOIN plans p ON p.id = fb.plan_id WHERE p.trip_id = ? AND p.user_id = ? AND p.type = 'transport' ORDER BY p.position ASC, fb.created_at ASC`, [tripId, userId] ); return bookings.map((booking) => { const segments = db.all( `SELECT * FROM flight_segments WHERE flight_booking_id = ? ORDER BY position ASC`, [booking.id] ); const segmentsWithRoutes = segments.map((segment) => { const route = db.get('SELECT * FROM flight_routes WHERE flight_segment_id = ?', [ segment.id ]); return { ...segment, route: enrichRoute(route) }; }); const passengerIds = db .all<{ person_id: string; }>('SELECT person_id FROM flight_booking_passengers WHERE flight_booking_id = ?', [ booking.id ]) .map((r) => r.person_id); return { ...booking, segments: segmentsWithRoutes, passengerIds }; }); } export interface UpdateFlightInput { bookingId: string; userId: string; confirmationNumber?: string; price?: number; currency?: string; status?: PlanStatus; segments: Array<{ departureDate: string; airlineId?: number; airlineIata?: string; airlineIcao?: string; airlineName?: string; flightNumber?: string; route?: { departureAirportId?: number; departureAirportCode?: string; departureTerminal?: string; departureGate?: string; departureDatetime?: string; departureTimezone?: string; arrivalAirportId?: number; arrivalAirportCode?: string; arrivalTerminal?: string; arrivalGate?: string; arrivalDate?: string; arrivalDatetime?: string; arrivalTimezone?: string; }; }>; passengerIds?: string[]; } export function updateFlight(input: UpdateFlightInput): void { // Verify booking belongs to user via plans table const booking = db.get( `SELECT fb.* FROM flight_bookings fb JOIN plans p ON p.id = fb.plan_id WHERE fb.id = ? AND p.user_id = ?`, [input.bookingId, input.userId] ); if (!booking) throw new Error('Flight booking not found or not authorized'); // Derive new title from first segment const firstSegment = input.segments[0]; const title = firstSegment ? (() => { const airline = firstSegment.airlineName || firstSegment.airlineIata || 'Flight'; const flightNumber = firstSegment.flightNumber?.trim(); const dep = firstSegment.route?.departureAirportCode?.trim(); const arr = firstSegment.route?.arrivalAirportCode?.trim(); if (flightNumber) return `${airline} ${flightNumber}`; if (dep || arr) return `Flight: ${dep || 'TBD'} -> ${arr || 'TBD'}`; return 'Flight'; })() : 'Flight'; // Update the plan row (status + title) db.run(`UPDATE plans SET status = ?, title = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, [ input.status ?? 'idea', title, booking.plan_id ]); // Update the booking row db.run( `UPDATE flight_bookings SET confirmation_number = ?, price = ?, currency = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, [ input.confirmationNumber ?? null, input.price ?? null, input.currency ?? 'USD', input.bookingId ] ); // Replace segments and routes wholesale const existingSegments = db.all<{ id: string }>( 'SELECT id FROM flight_segments WHERE flight_booking_id = ?', [input.bookingId] ); for (const seg of existingSegments) { db.run('DELETE FROM flight_routes WHERE flight_segment_id = ?', [seg.id]); } db.run('DELETE FROM flight_segments WHERE flight_booking_id = ?', [input.bookingId]); let segmentPosition = 0; for (const segment of input.segments) { const segmentId = randomUUID(); db.run( `INSERT INTO flight_segments (id, flight_booking_id, departure_date, airline_id, airline_iata, airline_icao, airline_name, flight_number, position) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ segmentId, input.bookingId, segment.departureDate, segment.airlineId ?? null, segment.airlineIata ?? null, segment.airlineIcao ?? null, segment.airlineName ?? null, segment.flightNumber?.trim() || '', segmentPosition++ ] ); if (segment.route) { const routeId = randomUUID(); db.run( `INSERT INTO flight_routes ( id, flight_segment_id, departure_airport_id, departure_airport_code, departure_terminal, departure_gate, departure_datetime, departure_timezone, arrival_airport_id, arrival_airport_code, arrival_terminal, arrival_gate, arrival_date, arrival_datetime, arrival_timezone ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ routeId, segmentId, segment.route.departureAirportId ?? null, segment.route.departureAirportCode ?? null, segment.route.departureTerminal ?? null, segment.route.departureGate ?? null, segment.route.departureDatetime ?? null, segment.route.departureTimezone ?? airportTimezone(segment.route.departureAirportId) ?? null, segment.route.arrivalAirportId ?? null, segment.route.arrivalAirportCode ?? null, segment.route.arrivalTerminal ?? null, segment.route.arrivalGate ?? null, segment.route.arrivalDate ?? null, segment.route.arrivalDatetime ?? null, segment.route.arrivalTimezone ?? airportTimezone(segment.route.arrivalAirportId) ?? null ] ); } } // Replace passengers db.run('DELETE FROM flight_booking_passengers WHERE flight_booking_id = ?', [input.bookingId]); if (input.passengerIds && input.passengerIds.length > 0) { for (const personId of input.passengerIds) { db.run( `INSERT OR IGNORE INTO flight_booking_passengers (flight_booking_id, person_id) VALUES (?, ?)`, [input.bookingId, personId] ); } } }