All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 2m22s
1167 lines
32 KiB
TypeScript
1167 lines
32 KiB
TypeScript
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<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';
|
|
}
|