trip) adding support for lodgings

This commit is contained in:
2026-02-18 23:17:34 -05:00
parent 1068145261
commit 50e216daec
244 changed files with 29012 additions and 11 deletions

599
src/lib/server/flights.ts Normal file
View File

@@ -0,0 +1,599 @@
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;
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_datetime: string | null;
arrival_timezone: string | null;
created_at: string;
updated_at: string;
}
export interface CreateFlightInput {
tripId: 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;
arrivalDatetime?: string;
arrivalTimezone?: string;
};
}>;
passengerIds?: string[];
}
export function searchAirports(query: string): Airport[] {
if (!query.trim()) return [];
const pattern = `%${query.trim()}%`;
return db.all<Airport>(
`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<Airline>(
`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<Airport>('SELECT * FROM airports WHERE id = ?', [id]);
}
export function getAirportByCode(code: string): Airport | undefined {
return db.get<Airport>('SELECT * FROM airports WHERE iata_code = ? OR icao_code = ?', [
code.toUpperCase(),
code.toUpperCase()
]);
}
export function getAirlineById(id: number): Airline | undefined {
return db.get<Airline>('SELECT * FROM airlines WHERE id = ?', [id]);
}
export function getAirlineByCode(code: string): Airline | undefined {
return db.get<Airline>('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
? `${firstSegment.airlineName || firstSegment.airlineIata || 'Flight'} ${firstSegment.flightNumber}`
: 'Flight';
db.run(
`INSERT INTO plans (id, trip_id, user_id, type, status, title, position)
VALUES (?, ?, ?, 'transport', ?, ?, ?)`,
[planId, input.tripId, input.userId, input.status ?? 'idea', title, 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,
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_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.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<FlightBooking>('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<FlightBooking>(
`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<FlightSegment>(
`SELECT * FROM flight_segments
WHERE flight_booking_id = ?
ORDER BY position ASC`,
[booking.id]
);
const segmentsWithRoutes = segments.map((segment) => {
const route = db.get<FlightRoute>('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<FlightBooking>(
`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<FlightSegment>(
`SELECT * FROM flight_segments
WHERE flight_booking_id = ?
ORDER BY position ASC`,
[booking.id]
);
const segmentsWithRoutes = segments.map((segment) => {
const route = db.get<FlightRoute>('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;
arrivalDatetime?: string;
arrivalTimezone?: string;
};
}>;
passengerIds?: string[];
}
export function updateFlight(input: UpdateFlightInput): void {
// Verify booking belongs to user via plans table
const booking = db.get<FlightBooking & { plan_id: string }>(
`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
? `${firstSegment.airlineName || firstSegment.airlineIata || 'Flight'} ${firstSegment.flightNumber}`
: '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,
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_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.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]
);
}
}
}