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

View File

@@ -0,0 +1,59 @@
# Airport and Airline Data
This directory should contain OpenFlights data files for importing airports and airlines.
## Download Instructions
1. **Download airports.dat**:
- Visit: https://raw.githubusercontent.com/jpatokal/openflights/master/data/airports.dat
- Save as: `airports.dat` in this directory
2. **Download airlines.dat**:
- Visit: https://raw.githubusercontent.com/jpatokal/openflights/master/data/airlines.dat
- Save as: `airlines.dat` in this directory
## File Format
### airports.dat
CSV format with the following columns:
- Airport ID
- Name
- City
- Country
- IATA code (3-letter)
- ICAO code (4-letter)
- Latitude
- Longitude
- Altitude
- Timezone (offset from UTC)
- DST (Daylight Saving Time)
- Tz database time zone
- Type
- Source
### airlines.dat
CSV format with the following columns:
- Airline ID
- Name
- Alias
- IATA code (2-letter)
- ICAO code (3-letter)
- Callsign
- Country
- Active (Y/N)
## Import
The data will be automatically imported when the database is initialized (on first run or when tables are empty).
If you need to re-import:
1. Delete the database file
2. Restart the application
3. The migration will run and import the data
## Notes
- Only airports with valid IATA/ICAO codes and coordinates are imported
- Only active airlines (Active = 'Y') are imported
- The import process skips invalid or duplicate entries

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,40 @@ import type { Database } from './types.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
// Helper function to parse CSV line with proper quote handling
function parseCSVLine(line: string): string[] {
const fields: string[] = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
const nextChar = line[i + 1];
if (char === '"') {
if (inQuotes && nextChar === '"') {
// Escaped quote
current += '"';
i++; // Skip next quote
} else {
// Toggle quote state
inQuotes = !inQuotes;
}
} else if (char === ',' && !inQuotes) {
// Field separator
fields.push(current.trim());
current = '';
} else {
current += char;
}
}
// Add last field
fields.push(current.trim());
return fields;
}
export function runMigrations(db: Database): void {
db.run(`
CREATE TABLE IF NOT EXISTS trips (
@@ -51,6 +85,7 @@ export function runMigrations(db: Database): void {
)
`);
// Legacy table — superseded by `people` + `trip_travellers`. Kept for migration safety on existing DBs.
db.run(`
CREATE TABLE IF NOT EXISTS travellers (
id TEXT PRIMARY KEY,
@@ -86,11 +121,150 @@ export function runMigrations(db: Database): void {
)
`);
// Airports table
db.run(`
CREATE TABLE IF NOT EXISTS airports (
id INTEGER PRIMARY KEY,
iata_code TEXT UNIQUE,
icao_code TEXT UNIQUE,
name TEXT NOT NULL,
city TEXT,
country TEXT NOT NULL,
country_code TEXT NOT NULL,
latitude REAL,
longitude REAL,
timezone TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
// Airlines table
db.run(`
CREATE TABLE IF NOT EXISTS airlines (
id INTEGER PRIMARY KEY,
iata_code TEXT,
icao_code TEXT,
name TEXT NOT NULL,
country TEXT,
country_code TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
// Flight bookings table - links to a plan
db.run(`
CREATE TABLE IF NOT EXISTS flight_bookings (
id TEXT PRIMARY KEY,
plan_id TEXT NOT NULL REFERENCES plans(id) ON DELETE CASCADE,
confirmation_number TEXT,
price REAL,
currency TEXT DEFAULT 'USD',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
// Flight segments table - individual flights within a booking
db.run(`
CREATE TABLE IF NOT EXISTS flight_segments (
id TEXT PRIMARY KEY,
flight_booking_id TEXT NOT NULL REFERENCES flight_bookings(id) ON DELETE CASCADE,
departure_date TEXT NOT NULL,
airline_id INTEGER REFERENCES airlines(id),
airline_iata TEXT,
airline_icao TEXT,
airline_name TEXT,
flight_number TEXT NOT NULL,
position INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
// Flight routes table - detailed route info for each segment
db.run(`
CREATE TABLE IF NOT EXISTS flight_routes (
id TEXT PRIMARY KEY,
flight_segment_id TEXT NOT NULL REFERENCES flight_segments(id) ON DELETE CASCADE,
departure_airport_id INTEGER REFERENCES airports(id),
departure_airport_code TEXT,
departure_terminal TEXT,
departure_gate TEXT,
departure_datetime TEXT,
departure_timezone TEXT,
arrival_airport_id INTEGER REFERENCES airports(id),
arrival_airport_code TEXT,
arrival_terminal TEXT,
arrival_gate TEXT,
arrival_datetime TEXT,
arrival_timezone TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
// Flight booking passengers - links people to flight bookings
db.run(`
CREATE TABLE IF NOT EXISTS flight_booking_passengers (
flight_booking_id TEXT NOT NULL REFERENCES flight_bookings(id) ON DELETE CASCADE,
person_id TEXT NOT NULL REFERENCES people(id) ON DELETE CASCADE,
PRIMARY KEY (flight_booking_id, person_id)
)
`);
// Lodgings table - links to a plan
db.run(`
CREATE TABLE IF NOT EXISTS lodgings (
id TEXT PRIMARY KEY,
plan_id TEXT NOT NULL REFERENCES plans(id) ON DELETE CASCADE,
name TEXT NOT NULL,
chain TEXT,
check_in_date TEXT,
check_in_time TEXT,
check_in_timezone TEXT,
check_out_date TEXT,
check_out_time TEXT,
check_out_timezone TEXT,
address_line1 TEXT,
address_line2 TEXT,
city_name TEXT,
country TEXT,
country_code TEXT,
postal_code TEXT,
confirmation_number TEXT,
website TEXT,
phone TEXT,
price REAL,
currency TEXT NOT NULL DEFAULT 'USD',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
// Lodging guests - links people to lodgings
db.run(`
CREATE TABLE IF NOT EXISTS lodging_guests (
lodging_id TEXT NOT NULL REFERENCES lodgings(id) ON DELETE CASCADE,
person_id TEXT NOT NULL REFERENCES people(id) ON DELETE CASCADE,
PRIMARY KEY (lodging_id, person_id)
)
`);
// Seed cities table on first run
const row = db.get<{ count: number }>('SELECT COUNT(*) as count FROM cities');
if ((row?.count ?? 0) === 0) {
seedCities(db);
}
// Seed airports and airlines on first run only
const airportsRow = db.get<{ count: number }>('SELECT COUNT(*) as count FROM airports');
if ((airportsRow?.count ?? 0) === 0) {
seedAirports(db);
}
const airlinesRow = db.get<{ count: number }>('SELECT COUNT(*) as count FROM airlines');
if ((airlinesRow?.count ?? 0) === 0) {
seedAirlines(db);
}
}
function seedCities(db: Database): void {
@@ -110,3 +284,504 @@ function seedCities(db: Database): void {
console.error('[db] Failed to seed cities:', e);
}
}
function seedAirports(db: Database): void {
try {
// Try to load from OpenFlights airports.dat file
// Format: Airport ID, Name, City, Country, IATA, ICAO, Latitude, Longitude, Altitude, Timezone, DST, Tz database time zone, Type, Source
const airportsPath = join(__dirname, '../data/airports.dat');
try {
const data = readFileSync(airportsPath, 'utf-8');
const lines = data.split('\n').filter((line) => line.trim());
let imported = 0;
let skipped = 0;
for (const line of lines) {
// Skip comments
if (line.startsWith('#')) continue;
// Parse CSV with proper quote handling
const fields = parseCSVLine(line);
// OpenFlights format: ID, Name, City, Country, IATA, ICAO, Lat, Lon, Alt, TZ, DST, TZ_DB, Type, Source
if (fields.length < 8) continue;
const [idStr, name, city, country, iata, icao, latStr, lonStr, , , , tzDb] = fields;
// Skip if missing essential data
if (!name || !country) {
skipped++;
continue;
}
const lat = latStr ? parseFloat(latStr) : null;
const lon = lonStr ? parseFloat(lonStr) : null;
// Only import airports with valid coordinates and IATA/ICAO codes
if ((!iata && !icao) || lat === null || lon === null || isNaN(lat) || isNaN(lon)) {
skipped++;
continue;
}
// Get country code from country name (simplified - you might want a mapping)
const countryCode = getCountryCode(country);
try {
// Use IATA/ICAO code as unique identifier, let ID auto-increment
db.run(
`INSERT OR IGNORE INTO airports (iata_code, icao_code, name, city, country, country_code, latitude, longitude, timezone)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
iata || null,
icao || null,
name,
city || null,
country,
countryCode,
lat,
lon,
tzDb || null
]
);
imported++;
} catch (err) {
// Skip duplicates or invalid data
skipped++;
}
}
console.log(`[db] Seeded ${imported} airports from airports.dat (${skipped} skipped)`);
return;
} catch (fileError) {
// File doesn't exist, fall back to minimal seed only if table is empty
const airportsRow = db.get<{ count: number }>('SELECT COUNT(*) as count FROM airports');
if ((airportsRow?.count ?? 0) === 0) {
console.log('[db] airports.dat not found, using minimal airport seed');
} else {
console.log('[db] airports.dat not found, skipping seed (table already has data)');
return;
}
}
// Fallback: minimal seed if file doesn't exist
const majorAirports = [
{
iata: 'JFK',
icao: 'KJFK',
name: 'John F. Kennedy International Airport',
city: 'New York',
country: 'United States',
country_code: 'US',
lat: 40.6398,
lon: -73.7789,
tz: 'America/New_York'
},
{
iata: 'LAX',
icao: 'KLAX',
name: 'Los Angeles International Airport',
city: 'Los Angeles',
country: 'United States',
country_code: 'US',
lat: 33.9425,
lon: -118.4081,
tz: 'America/Los_Angeles'
},
{
iata: 'LHR',
icao: 'EGLL',
name: 'London Heathrow Airport',
city: 'London',
country: 'United Kingdom',
country_code: 'GB',
lat: 51.47,
lon: -0.4543,
tz: 'Europe/London'
},
{
iata: 'CDG',
icao: 'LFPG',
name: 'Charles de Gaulle Airport',
city: 'Paris',
country: 'France',
country_code: 'FR',
lat: 49.0097,
lon: 2.5479,
tz: 'Europe/Paris'
},
{
iata: 'DXB',
icao: 'OMDB',
name: 'Dubai International Airport',
city: 'Dubai',
country: 'United Arab Emirates',
country_code: 'AE',
lat: 25.2532,
lon: 55.3657,
tz: 'Asia/Dubai'
},
{
iata: 'SYD',
icao: 'YSSY',
name: 'Sydney Kingsford Smith Airport',
city: 'Sydney',
country: 'Australia',
country_code: 'AU',
lat: -33.9399,
lon: 151.1753,
tz: 'Australia/Sydney'
},
{
iata: 'NRT',
icao: 'RJAA',
name: 'Narita International Airport',
city: 'Tokyo',
country: 'Japan',
country_code: 'JP',
lat: 35.772,
lon: 140.3929,
tz: 'Asia/Tokyo'
},
{
iata: 'SIN',
icao: 'WSSS',
name: 'Singapore Changi Airport',
city: 'Singapore',
country: 'Singapore',
country_code: 'SG',
lat: 1.3644,
lon: 103.9915,
tz: 'Asia/Singapore'
}
];
let id = 1;
for (const airport of majorAirports) {
db.run(
`INSERT OR IGNORE INTO airports (id, iata_code, icao_code, name, city, country, country_code, latitude, longitude, timezone)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
id++,
airport.iata,
airport.icao,
airport.name,
airport.city,
airport.country,
airport.country_code,
airport.lat,
airport.lon,
airport.tz
]
);
}
console.log(`[db] Seeded ${majorAirports.length} airports`);
} catch (e) {
console.error('[db] Failed to seed airports:', e);
}
}
function seedAirlines(db: Database): void {
try {
// Try to load from OpenFlights airlines.dat file
// Format: Airline ID, Name, Alias, IATA, ICAO, Callsign, Country, Active
const airlinesPath = join(__dirname, '../data/airlines.dat');
try {
const data = readFileSync(airlinesPath, 'utf-8');
const lines = data.split('\n').filter((line) => line.trim());
let imported = 0;
let skipped = 0;
for (const line of lines) {
// Skip comments
if (line.startsWith('#')) continue;
// Parse CSV with proper quote handling
const fields = parseCSVLine(line);
// OpenFlights format: ID, Name, Alias, IATA, ICAO, Callsign, Country, Active
if (fields.length < 7) continue;
const [idStr, name, , iata, icao, , country, active] = fields;
// Skip if missing essential data or inactive
if (!name || !country || active !== 'Y') {
skipped++;
continue;
}
// Only import airlines with IATA or ICAO codes
if (!iata && !icao) {
skipped++;
continue;
}
// Get country code from country name
const countryCode = getCountryCode(country);
try {
// Use IATA/ICAO code as unique identifier, let ID auto-increment
db.run(
`INSERT OR IGNORE INTO airlines (iata_code, icao_code, name, country, country_code)
VALUES (?, ?, ?, ?, ?)`,
[iata || null, icao || null, name, country, countryCode]
);
imported++;
} catch (err) {
// Skip duplicates or invalid data
skipped++;
}
}
console.log(`[db] Seeded ${imported} airlines from airlines.dat (${skipped} skipped)`);
return;
} catch (fileError) {
// File doesn't exist, fall back to minimal seed only if table is empty
const airlinesRow = db.get<{ count: number }>('SELECT COUNT(*) as count FROM airlines');
if ((airlinesRow?.count ?? 0) === 0) {
console.log('[db] airlines.dat not found, using minimal airline seed');
} else {
console.log('[db] airlines.dat not found, skipping seed (table already has data)');
return;
}
}
// Fallback: minimal seed if file doesn't exist
const majorAirlines = [
{
iata: 'AA',
icao: 'AAL',
name: 'American Airlines',
country: 'United States',
country_code: 'US'
},
{
iata: 'UA',
icao: 'UAL',
name: 'United Airlines',
country: 'United States',
country_code: 'US'
},
{
iata: 'DL',
icao: 'DAL',
name: 'Delta Air Lines',
country: 'United States',
country_code: 'US'
},
{
iata: 'BA',
icao: 'BAW',
name: 'British Airways',
country: 'United Kingdom',
country_code: 'GB'
},
{ iata: 'AF', icao: 'AFR', name: 'Air France', country: 'France', country_code: 'FR' },
{ iata: 'LH', icao: 'DLH', name: 'Lufthansa', country: 'Germany', country_code: 'DE' },
{
iata: 'EK',
icao: 'UAE',
name: 'Emirates',
country: 'United Arab Emirates',
country_code: 'AE'
},
{ iata: 'QF', icao: 'QFA', name: 'Qantas', country: 'Australia', country_code: 'AU' },
{ iata: 'JL', icao: 'JAL', name: 'Japan Airlines', country: 'Japan', country_code: 'JP' },
{
iata: 'SQ',
icao: 'SIA',
name: 'Singapore Airlines',
country: 'Singapore',
country_code: 'SG'
}
];
let id = 1;
for (const airline of majorAirlines) {
db.run(
`INSERT OR IGNORE INTO airlines (id, iata_code, icao_code, name, country, country_code)
VALUES (?, ?, ?, ?, ?, ?)`,
[id++, airline.iata, airline.icao, airline.name, airline.country, airline.country_code]
);
}
console.log(`[db] Seeded ${majorAirlines.length} airlines`);
} catch (e) {
console.error('[db] Failed to seed airlines:', e);
}
}
// Helper function to map country names to ISO country codes
// This is a simplified mapping - for production, consider using a comprehensive library
function getCountryCode(countryName: string): string {
// Common countries mapping (most frequently used in aviation)
const countryMap: Record<string, string> = {
'United States': 'US',
'United Kingdom': 'GB',
France: 'FR',
Germany: 'DE',
Japan: 'JP',
China: 'CN',
Canada: 'CA',
Australia: 'AU',
Brazil: 'BR',
India: 'IN',
Russia: 'RU',
Mexico: 'MX',
Spain: 'ES',
Italy: 'IT',
Netherlands: 'NL',
Sweden: 'SE',
Norway: 'NO',
Denmark: 'DK',
Finland: 'FI',
Poland: 'PL',
Turkey: 'TR',
'South Korea': 'KR',
Indonesia: 'ID',
Thailand: 'TH',
Malaysia: 'MY',
Philippines: 'PH',
Vietnam: 'VN',
Singapore: 'SG',
'United Arab Emirates': 'AE',
'Saudi Arabia': 'SA',
Israel: 'IL',
'South Africa': 'ZA',
Egypt: 'EG',
Argentina: 'AR',
Chile: 'CL',
Colombia: 'CO',
'New Zealand': 'NZ',
Ireland: 'IE',
Switzerland: 'CH',
Austria: 'AT',
Belgium: 'BE',
Portugal: 'PT',
Greece: 'GR',
'Czech Republic': 'CZ',
Hungary: 'HU',
Romania: 'RO',
Bulgaria: 'BG',
Croatia: 'HR',
Serbia: 'RS',
Ukraine: 'UA',
Belarus: 'BY',
Kazakhstan: 'KZ',
Pakistan: 'PK',
Bangladesh: 'BD',
'Sri Lanka': 'LK',
Myanmar: 'MM',
Cambodia: 'KH',
Laos: 'LA',
Nepal: 'NP',
Afghanistan: 'AF',
Iran: 'IR',
Iraq: 'IQ',
Jordan: 'JO',
Lebanon: 'LB',
Syria: 'SY',
Yemen: 'YE',
Oman: 'OM',
Kuwait: 'KW',
Qatar: 'QA',
Bahrain: 'BH',
Cyprus: 'CY',
Morocco: 'MA',
Algeria: 'DZ',
Tunisia: 'TN',
Libya: 'LY',
Sudan: 'SD',
Ethiopia: 'ET',
Kenya: 'KE',
Tanzania: 'TZ',
Uganda: 'UG',
Rwanda: 'RW',
Ghana: 'GH',
Nigeria: 'NG',
Senegal: 'SN',
'Ivory Coast': 'CI',
Cameroon: 'CM',
Angola: 'AO',
Zambia: 'ZM',
Zimbabwe: 'ZW',
Botswana: 'BW',
Namibia: 'NA',
Mozambique: 'MZ',
Madagascar: 'MG',
Mauritius: 'MU',
Peru: 'PE',
Ecuador: 'EC',
Venezuela: 'VE',
Uruguay: 'UY',
Paraguay: 'PY',
Bolivia: 'BO',
Panama: 'PA',
'Costa Rica': 'CR',
Nicaragua: 'NI',
Honduras: 'HN',
Guatemala: 'GT',
Belize: 'BZ',
'El Salvador': 'SV',
Cuba: 'CU',
Jamaica: 'JM',
Haiti: 'HT',
'Dominican Republic': 'DO',
'Puerto Rico': 'PR',
'Trinidad and Tobago': 'TT',
Barbados: 'BB',
Bahamas: 'BS',
Iceland: 'IS',
Luxembourg: 'LU',
Malta: 'MT',
Albania: 'AL',
'Bosnia and Herzegovina': 'BA',
'North Macedonia': 'MK',
Montenegro: 'ME',
Slovenia: 'SI',
Slovakia: 'SK',
Estonia: 'EE',
Latvia: 'LV',
Lithuania: 'LT',
Moldova: 'MD',
Armenia: 'AM',
Georgia: 'GE',
Azerbaijan: 'AZ',
Kyrgyzstan: 'KG',
Tajikistan: 'TJ',
Turkmenistan: 'TM',
Mongolia: 'MN',
'North Korea': 'KP',
Taiwan: 'TW',
'Hong Kong': 'HK',
Macau: 'MO',
Brunei: 'BN',
'Papua New Guinea': 'PG',
Fiji: 'FJ',
'New Caledonia': 'NC',
'French Polynesia': 'PF',
Samoa: 'WS',
Tonga: 'TO',
Palau: 'PW',
Micronesia: 'FM',
'Marshall Islands': 'MH',
Kiribati: 'KI',
Tuvalu: 'TV',
Nauru: 'NR',
'Cook Islands': 'CK',
Niue: 'NU',
Antarctica: 'AQ'
};
// Try exact match first
if (countryMap[countryName]) {
return countryMap[countryName];
}
// Try case-insensitive match
const normalized = countryName.toLowerCase();
for (const [key, value] of Object.entries(countryMap)) {
if (key.toLowerCase() === normalized) {
return value;
}
}
// Unknown country — return sentinel rather than a misleading partial string
return 'XX';
}

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

245
src/lib/server/lodgings.ts Normal file
View File

@@ -0,0 +1,245 @@
import { db } from './db/index.js';
import { randomUUID } from 'crypto';
import type { PlanStatus } from './plans.js';
export interface Lodging {
id: string;
plan_id: string;
name: string;
chain: string | null;
check_in_date: string | null;
check_in_time: string | null;
check_in_timezone: string | null;
check_out_date: string | null;
check_out_time: string | null;
check_out_timezone: string | null;
address_line1: string | null;
address_line2: string | null;
city_name: string | null;
country: string | null;
country_code: string | null;
postal_code: string | null;
confirmation_number: string | null;
website: string | null;
phone: string | null;
price: number | null;
currency: string;
created_at: string;
updated_at: string;
}
export interface CreateLodgingInput {
tripId: string;
userId: string;
status?: PlanStatus;
name: string;
chain?: string;
checkInDate?: string;
checkInTime?: string;
checkInTimezone?: string;
checkOutDate?: string;
checkOutTime?: string;
checkOutTimezone?: string;
addressLine1?: string;
addressLine2?: string;
cityName?: string;
country?: string;
countryCode?: string;
postalCode?: string;
confirmationNumber?: string;
website?: string;
phone?: string;
price?: number;
currency?: string;
guestIds?: string[];
}
export interface UpdateLodgingInput {
lodgingId: string;
userId: string;
status?: PlanStatus;
name: string;
chain?: string;
checkInDate?: string;
checkInTime?: string;
checkInTimezone?: string;
checkOutDate?: string;
checkOutTime?: string;
checkOutTimezone?: string;
addressLine1?: string;
addressLine2?: string;
cityName?: string;
country?: string;
countryCode?: string;
postalCode?: string;
confirmationNumber?: string;
website?: string;
phone?: string;
price?: number;
currency?: string;
guestIds?: string[];
}
export function createLodging(input: CreateLodgingInput): Lodging {
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;
db.run(
`INSERT INTO plans (id, trip_id, user_id, type, status, title, city_name, country, country_code, position)
VALUES (?, ?, ?, 'lodging', ?, ?, ?, ?, ?, ?)`,
[
planId,
input.tripId,
input.userId,
input.status ?? 'idea',
input.name,
input.cityName ?? null,
input.country ?? null,
input.countryCode ?? null,
position
]
);
const lodgingId = randomUUID();
db.run(
`INSERT INTO lodgings (
id, plan_id, name, chain,
check_in_date, check_in_time, check_in_timezone,
check_out_date, check_out_time, check_out_timezone,
address_line1, address_line2, city_name, country, country_code, postal_code,
confirmation_number, website, phone, price, currency
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
lodgingId,
planId,
input.name,
input.chain ?? null,
input.checkInDate ?? null,
input.checkInTime ?? null,
input.checkInTimezone ?? null,
input.checkOutDate ?? null,
input.checkOutTime ?? null,
input.checkOutTimezone ?? null,
input.addressLine1 ?? null,
input.addressLine2 ?? null,
input.cityName ?? null,
input.country ?? null,
input.countryCode ?? null,
input.postalCode ?? null,
input.confirmationNumber ?? null,
input.website ?? null,
input.phone ?? null,
input.price ?? null,
input.currency ?? 'USD'
]
);
if (input.guestIds && input.guestIds.length > 0) {
for (const personId of input.guestIds) {
db.run(
`INSERT OR IGNORE INTO lodging_guests (lodging_id, person_id) VALUES (?, ?)`,
[lodgingId, personId]
);
}
}
return db.get<Lodging>('SELECT * FROM lodgings WHERE id = ?', [lodgingId])!;
}
export function getLodgingsForTrip(
tripId: string,
userId: string
): Array<Lodging & { guestIds: string[]; planStatus: PlanStatus }> {
const lodgings = db.all<Lodging & { plan_status: string }>(
`SELECT l.*, p.status as plan_status
FROM lodgings l
JOIN plans p ON p.id = l.plan_id
WHERE p.trip_id = ? AND p.user_id = ? AND p.type = 'lodging'
ORDER BY p.position ASC, l.created_at ASC`,
[tripId, userId]
);
return lodgings.map((lodging) => {
const guestIds = db
.all<{ person_id: string }>(
'SELECT person_id FROM lodging_guests WHERE lodging_id = ?',
[lodging.id]
)
.map((r) => r.person_id);
const { plan_status, ...rest } = lodging;
return { ...rest, guestIds, planStatus: plan_status as PlanStatus };
});
}
export function updateLodging(input: UpdateLodgingInput): void {
// Verify ownership via plans table
const lodging = db.get<{ id: string; plan_id: string }>(
`SELECT l.id, l.plan_id FROM lodgings l
JOIN plans p ON p.id = l.plan_id
WHERE l.id = ? AND p.user_id = ?`,
[input.lodgingId, input.userId]
);
if (!lodging) throw new Error('Lodging not found or not authorized');
// Update plans row
db.run(
`UPDATE plans SET status = ?, title = ?, city_name = ?, country = ?, country_code = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
[
input.status ?? 'idea',
input.name,
input.cityName ?? null,
input.country ?? null,
input.countryCode ?? null,
lodging.plan_id
]
);
// Update lodgings row
db.run(
`UPDATE lodgings SET
name = ?, chain = ?,
check_in_date = ?, check_in_time = ?, check_in_timezone = ?,
check_out_date = ?, check_out_time = ?, check_out_timezone = ?,
address_line1 = ?, address_line2 = ?, city_name = ?, country = ?, country_code = ?, postal_code = ?,
confirmation_number = ?, website = ?, phone = ?, price = ?, currency = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
[
input.name,
input.chain ?? null,
input.checkInDate ?? null,
input.checkInTime ?? null,
input.checkInTimezone ?? null,
input.checkOutDate ?? null,
input.checkOutTime ?? null,
input.checkOutTimezone ?? null,
input.addressLine1 ?? null,
input.addressLine2 ?? null,
input.cityName ?? null,
input.country ?? null,
input.countryCode ?? null,
input.postalCode ?? null,
input.confirmationNumber ?? null,
input.website ?? null,
input.phone ?? null,
input.price ?? null,
input.currency ?? 'USD',
input.lodgingId
]
);
// Replace guests
db.run('DELETE FROM lodging_guests WHERE lodging_id = ?', [input.lodgingId]);
if (input.guestIds && input.guestIds.length > 0) {
for (const personId of input.guestIds) {
db.run(
`INSERT OR IGNORE INTO lodging_guests (lodging_id, person_id) VALUES (?, ?)`,
[input.lodgingId, personId]
);
}
}
}

View File

@@ -99,9 +99,43 @@ export function getPlanCountForTrip(tripId: string, userId: string): number {
return row?.count ?? 0;
}
export function searchCities(query: string): City[] {
export interface Country {
name: string;
country_code: string;
}
export function getCountries(query?: string): Country[] {
if (!query?.trim()) {
return db.all<Country>(
`SELECT DISTINCT country as name, country_code FROM cities ORDER BY country`
);
}
const pattern = `%${query.trim()}%`;
return db.all<Country>(
`SELECT DISTINCT country as name, country_code
FROM cities
WHERE country LIKE ? COLLATE NOCASE
ORDER BY country
LIMIT 50`,
[pattern]
);
}
export function searchCities(query: string, countryCode?: string): City[] {
if (!query.trim()) return [];
const pattern = `%${query.trim()}%`;
if (countryCode?.trim()) {
return db.all<City>(
`SELECT id, name, country, country_code, population
FROM cities
WHERE name LIKE ? COLLATE NOCASE AND country_code = ?
ORDER BY
CASE WHEN name LIKE ? COLLATE NOCASE THEN 0 ELSE 1 END,
population DESC NULLS LAST
LIMIT 10`,
[pattern, countryCode.trim().toUpperCase(), `${query.trim()}%`]
);
}
return db.all<City>(
`SELECT id, name, country, country_code, population
FROM cities

View File

@@ -72,7 +72,14 @@ export function getPeopleForUser(userId: string): Person[] {
// Trip assignment
// ---------------------------------------------------------------------------
export function addPersonToTrip(tripId: string, personId: string): void {
export function addPersonToTrip(tripId: string, personId: string, userId: string): void {
// Verify the person belongs to this user before linking
const person = db.get<{ id: string }>(`SELECT id FROM people WHERE id = ? AND user_id = ?`, [
personId,
userId
]);
if (!person) throw new Error('Person not found or not authorized');
const maxPos = db.get<{ pos: number }>(
`SELECT COALESCE(MAX(position), -1) + 1 as pos FROM trip_travellers WHERE trip_id = ?`,
[tripId]
@@ -103,8 +110,8 @@ export function addTraveller(input: {
personId = person.id;
}
// Add the person to the trip
addPersonToTrip(input.tripId, personId);
// Add the person to the trip (ownership verified inside)
addPersonToTrip(input.tripId, personId, input.userId);
}
export function getTravellersForTrip(tripId: string, userId: string): Person[] {