trip) adding support for lodgings
This commit is contained in:
59
src/lib/server/data/README.md
Normal file
59
src/lib/server/data/README.md
Normal 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
|
||||
|
||||
6162
src/lib/server/data/airlines.dat
Normal file
6162
src/lib/server/data/airlines.dat
Normal file
File diff suppressed because it is too large
Load Diff
7698
src/lib/server/data/airports.dat
Normal file
7698
src/lib/server/data/airports.dat
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
599
src/lib/server/flights.ts
Normal 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
245
src/lib/server/lodgings.ts
Normal 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]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
Reference in New Issue
Block a user