import { readFileSync } from 'fs'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; 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 ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, description TEXT, start_date TEXT, end_date TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ) `); db.run(` CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, username TEXT NOT NULL, full_name TEXT NOT NULL, email TEXT, auth_source TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ) `); db.run(` CREATE TABLE IF NOT EXISTS local_credentials ( user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, password_hash TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ) `); db.run(` CREATE TABLE IF NOT EXISTS cities ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, country TEXT NOT NULL, country_code TEXT NOT NULL, population INTEGER ) `); db.run(` CREATE TABLE IF NOT EXISTS plans ( id TEXT PRIMARY KEY, trip_id TEXT NOT NULL REFERENCES trips(id) ON DELETE CASCADE, user_id TEXT NOT NULL, type TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'idea', parent_id TEXT REFERENCES plans(id) ON DELETE SET NULL, title TEXT NOT NULL, notes TEXT, city_id INTEGER REFERENCES cities(id), city_name TEXT, country TEXT, country_code TEXT, start_date TEXT, end_date TEXT, position INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ) `); // 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, trip_id TEXT NOT NULL REFERENCES trips(id) ON DELETE CASCADE, user_id TEXT NOT NULL, first_name TEXT NOT NULL, last_name TEXT NOT NULL, email TEXT, position INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')) ) `); db.run(` CREATE TABLE IF NOT EXISTS people ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, first_name TEXT NOT NULL, last_name TEXT NOT NULL, email TEXT, is_self INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ) `); db.run(` CREATE TABLE IF NOT EXISTS trip_travellers ( trip_id TEXT NOT NULL REFERENCES trips(id) ON DELETE CASCADE, person_id TEXT NOT NULL REFERENCES people(id) ON DELETE CASCADE, position INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (trip_id, person_id) ) `); // 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) ) `); // Private vehicle transportation details - links to a transport plan db.run(` CREATE TABLE IF NOT EXISTS private_vehicles ( id TEXT PRIMARY KEY, plan_id TEXT NOT NULL REFERENCES plans(id) ON DELETE CASCADE, start_address TEXT NOT NULL, end_address TEXT NOT NULL, departure_date TEXT, departure_time TEXT, departure_timezone TEXT, arrival_date TEXT, arrival_time TEXT, arrival_timezone TEXT, start_plan_id TEXT REFERENCES plans(id) ON DELETE SET NULL, end_plan_id TEXT REFERENCES plans(id) ON DELETE SET NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ) `); for (const col of [ 'departure_date TEXT', 'departure_time TEXT', 'departure_timezone TEXT', 'arrival_date TEXT', 'arrival_time TEXT', 'arrival_timezone TEXT' ]) { try { db.run(`ALTER TABLE private_vehicles ADD COLUMN ${col}`); } catch { /* already exists */ } } // Other transportation details - links to a transport plan db.run(` CREATE TABLE IF NOT EXISTS other_transports ( id TEXT PRIMARY KEY, plan_id TEXT NOT NULL REFERENCES plans(id) ON DELETE CASCADE, start_date TEXT, start_time TEXT, start_timezone TEXT, end_date TEXT, end_time TEXT, end_timezone TEXT, start_location TEXT, end_location TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ) `); for (const col of ['start_location TEXT', 'end_location TEXT']) { try { db.run(`ALTER TABLE other_transports ADD COLUMN ${col}`); } catch { /* already exists */ } } // Activity / restaurant details - links to a plan db.run(` CREATE TABLE IF NOT EXISTS experience_plans ( id TEXT PRIMARY KEY, plan_id TEXT NOT NULL REFERENCES plans(id) ON DELETE CASCADE, booking_id TEXT, total_cost REAL, description TEXT, website TEXT, address TEXT, contact_number TEXT, start_date TEXT, start_time TEXT, start_timezone TEXT, end_date TEXT, end_time TEXT, end_timezone TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ) `); // Checklist master table (packing/todo details come from linked plan.type) db.run(` CREATE TABLE IF NOT EXISTS checklists ( id TEXT PRIMARY KEY, plan_id TEXT NOT NULL REFERENCES plans(id) ON DELETE CASCADE, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ) `); db.run(` CREATE TABLE IF NOT EXISTS checklist_items ( id TEXT PRIMARY KEY, checklist_id TEXT NOT NULL REFERENCES checklists(id) ON DELETE CASCADE, content TEXT NOT NULL, is_checked INTEGER NOT NULL DEFAULT 0, position INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ) `); // 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) ) `); // Package tours table - links to a plan db.run(` CREATE TABLE IF NOT EXISTS package_tours ( id TEXT PRIMARY KEY, plan_id TEXT NOT NULL REFERENCES plans(id) ON DELETE CASCADE, operator_name TEXT NOT NULL, tour_name TEXT, confirmation_number TEXT, start_date TEXT, start_time TEXT, start_timezone TEXT, end_date TEXT, end_time TEXT, end_timezone 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')) ) `); // Migration: add tour_name to existing databases try { db.run(`ALTER TABLE package_tours ADD COLUMN tour_name TEXT`); } catch { /* already exists */ } // Package tour travellers - links people to package tours db.run(` CREATE TABLE IF NOT EXISTS package_tour_travellers ( package_tour_id TEXT NOT NULL REFERENCES package_tours(id) ON DELETE CASCADE, person_id TEXT NOT NULL REFERENCES people(id) ON DELETE CASCADE, PRIMARY KEY (package_tour_id, person_id) ) `); // Tour operators reference table db.run(` CREATE TABLE IF NOT EXISTS tour_operators ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, website TEXT, highlight_color TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ) `); // Predefined tours per tour operator (admin-managed) db.run(` CREATE TABLE IF NOT EXISTS operator_tours ( id INTEGER PRIMARY KEY AUTOINCREMENT, operator_id INTEGER NOT NULL REFERENCES tour_operators(id) ON DELETE CASCADE, name TEXT NOT NULL, description TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ) `); // Migration: add description to existing operator_tours tables try { db.run(`ALTER TABLE operator_tours ADD COLUMN description TEXT`); } catch { /* already exists */ } // Template itinerary days for predefined operator tours db.run(` CREATE TABLE IF NOT EXISTS operator_tour_days ( id INTEGER PRIMARY KEY AUTOINCREMENT, operator_tour_id INTEGER NOT NULL REFERENCES operator_tours(id) ON DELETE CASCADE, day_number INTEGER NOT NULL DEFAULT 1, title TEXT, notes TEXT, position INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ) `); // Template plans attached to operator tour days db.run(` CREATE TABLE IF NOT EXISTS operator_tour_day_plans ( id INTEGER PRIMARY KEY AUTOINCREMENT, operator_tour_day_id INTEGER NOT NULL REFERENCES operator_tour_days(id) ON DELETE CASCADE, type TEXT NOT NULL CHECK (type IN ('transport', 'lodging', 'activity', 'restaurant', 'packing', 'todo')), title TEXT NOT NULL, notes TEXT, position INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ) `); // Migration: expand operator_tour_day_plans `type` CHECK constraint if it still only supports transport/lodging. try { const schema = db.get<{ sql: string }>( `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'operator_tour_day_plans'` ); const sql = schema?.sql?.toLowerCase() ?? ''; if (sql && !sql.includes("'activity'")) { db.run(` CREATE TABLE operator_tour_day_plans__new ( id INTEGER PRIMARY KEY AUTOINCREMENT, operator_tour_day_id INTEGER NOT NULL REFERENCES operator_tour_days(id) ON DELETE CASCADE, type TEXT NOT NULL CHECK (type IN ('transport', 'lodging', 'activity', 'restaurant', 'packing', 'todo')), title TEXT NOT NULL, notes TEXT, position INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), chain TEXT, address_line1 TEXT, address_line2 TEXT, city_name TEXT, country TEXT, country_code TEXT, postal_code TEXT, transport_kind TEXT, start_date TEXT, start_time TEXT, start_timezone TEXT, end_date TEXT, end_time TEXT, end_timezone TEXT, start_location TEXT, end_location TEXT, booking_id TEXT, total_cost REAL, description TEXT, website TEXT, address TEXT, contact_number TEXT, items_json TEXT, is_optional INTEGER NOT NULL DEFAULT 0 ) `); db.run(` INSERT INTO operator_tour_day_plans__new ( id, operator_tour_day_id, type, title, notes, position, created_at, updated_at, chain, address_line1, address_line2, city_name, country, country_code, postal_code, transport_kind, start_date, start_time, start_timezone, end_date, end_time, end_timezone, start_location, end_location, is_optional ) SELECT id, operator_tour_day_id, type, title, notes, position, created_at, updated_at, chain, address_line1, address_line2, city_name, country, country_code, postal_code, transport_kind, start_date, start_time, start_timezone, end_date, end_time, end_timezone, NULL as start_location, NULL as end_location, 0 as is_optional FROM operator_tour_day_plans `); db.run('DROP TABLE operator_tour_day_plans'); db.run('ALTER TABLE operator_tour_day_plans__new RENAME TO operator_tour_day_plans'); } } catch { /* best-effort migration */ } // Lodging-specific fields for operator_tour_day_plans (type=lodging) — same shape as trip lodgings for (const col of [ 'chain TEXT', 'address_line1 TEXT', 'address_line2 TEXT', 'city_name TEXT', 'country TEXT', 'country_code TEXT', 'postal_code TEXT', 'transport_kind TEXT', 'start_date TEXT', 'start_time TEXT', 'start_timezone TEXT', 'end_date TEXT', 'end_time TEXT', 'end_timezone TEXT', 'start_location TEXT', 'end_location TEXT', 'booking_id TEXT', 'total_cost REAL', 'description TEXT', 'website TEXT', 'address TEXT', 'contact_number TEXT', 'items_json TEXT', 'is_optional INTEGER NOT NULL DEFAULT 0' ]) { try { db.run(`ALTER TABLE operator_tour_day_plans ADD COLUMN ${col}`); } catch { /* already exists */ } } try { db.run( `UPDATE operator_tour_day_plans SET transport_kind = 'other' WHERE type = 'transport' AND (transport_kind IS NULL OR TRIM(transport_kind) = '')` ); } catch { /* best-effort migration */ } // Countries table — editable list for admin (used by country selector) db.run(` CREATE TABLE IF NOT EXISTS countries ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, country_code TEXT NOT NULL UNIQUE ) `); // 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 countries from cities if empty const countriesRow = db.get<{ count: number }>('SELECT COUNT(*) as count FROM countries'); if ((countriesRow?.count ?? 0) === 0) { db.run(` INSERT OR IGNORE INTO countries (name, country_code) SELECT DISTINCT country, country_code FROM cities ORDER BY country `); } // 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 { try { const data = readFileSync(join(__dirname, '../data/cities.json'), 'utf-8'); const cities: { name: string; country: string; country_code: string; population: number }[] = JSON.parse(data); let id = 1; db.run('BEGIN'); for (const city of cities) { db.run( 'INSERT OR IGNORE INTO cities (id, name, country, country_code, population) VALUES (?, ?, ?, ?, ?)', [id++, city.name, city.country, city.country_code, city.population] ); } db.run('COMMIT'); console.log(`[db] Seeded ${cities.length} cities`); } catch (e) { try { db.run('ROLLBACK'); } catch { /* ignore rollback failure */ } 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; db.run('BEGIN'); 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++; } } db.run('COMMIT'); console.log(`[db] Seeded ${imported} airports from airports.dat (${skipped} skipped)`); return; } catch (_fileError) { try { db.run('ROLLBACK'); } catch { /* ignore rollback failure */ } // 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; db.run('BEGIN'); 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 ] ); } db.run('COMMIT'); console.log(`[db] Seeded ${majorAirports.length} airports`); } catch (e) { try { db.run('ROLLBACK'); } catch { /* ignore rollback failure */ } 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; db.run('BEGIN'); 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++; } } db.run('COMMIT'); console.log(`[db] Seeded ${imported} airlines from airlines.dat (${skipped} skipped)`); return; } catch (_fileError) { try { db.run('ROLLBACK'); } catch { /* ignore rollback failure */ } // 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; db.run('BEGIN'); 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] ); } db.run('COMMIT'); console.log(`[db] Seeded ${majorAirlines.length} airlines`); } catch (e) { try { db.run('ROLLBACK'); } catch { /* ignore rollback failure */ } 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 = { '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'; }