feat: add scripts for database inspection and migration

- Implemented multiple scripts to check and inspect meal plans, scraped data, and accommodations for Munich and Riga.
- Added a migration script to convert scraped data into the application's database format.
- Introduced new database service methods for querying and updating travel rates.
- Enhanced server configuration for serving static files in production.
- Updated PostCSS configuration for consistency.
This commit is contained in:
2026-01-13 11:32:49 -05:00
parent 4d915aa3ea
commit ae1f13d69e
21 changed files with 1012 additions and 265 deletions

Binary file not shown.

View File

@@ -264,29 +264,30 @@ async function generateFlights(originCode, destCode, departureDate) {
selectedAirlines.forEach((airlineCode, idx) => { selectedAirlines.forEach((airlineCode, idx) => {
// Calculate realistic flight duration based on stop pattern // Calculate realistic flight duration based on stop pattern
// NO DIRECT FLIGHTS from North America to Eastern Europe - all require stops
let stops, stopCodes; let stops, stopCodes;
let totalDuration; let totalDuration;
if (idx === 0) { if (idx === 0) {
// Direct flight (if available) // 1 stop via London
stops = 0;
stopCodes = [];
totalDuration = 11 + Math.random() * 3; // 11-14 hours
} else if (idx === 1) {
// 1 stop
stops = 1; stops = 1;
stopCodes = ["LHR"]; stopCodes = ["LHR"];
totalDuration = 13 + Math.random() * 2; // 13-15 hours totalDuration = 13 + Math.random() * 2; // 13-15 hours
} else if (idx === 2) { } else if (idx === 1) {
// 1 stop different city // 1 stop via Paris
stops = 1; stops = 1;
stopCodes = ["CDG"]; stopCodes = ["CDG"];
totalDuration = 13 + Math.random() * 2; totalDuration = 13 + Math.random() * 2; // 13-15 hours
} else if (idx === 2) {
// 1 stop via Frankfurt
stops = 1;
stopCodes = ["FRA"];
totalDuration = 14 + Math.random() * 2; // 14-16 hours
} else { } else {
// 2 stops // 2 stops
stops = 2; stops = 2;
stopCodes = ["FRA", "VIE"]; stopCodes = ["AMS", "WAW"]; // Amsterdam + Warsaw
totalDuration = 15 + Math.random() * 3; // 15-18 hours totalDuration = 16 + Math.random() * 2; // 16-18 hours
} }
// Generate realistic departure times (6am-10am) // Generate realistic departure times (6am-10am)

View File

@@ -1,5 +1,5 @@
export default { export default {
plugins: { plugins: {
'@tailwindcss/postcss': {}, "@tailwindcss/postcss": {},
}, },
} };

12
scripts/checkMealPlan.js Normal file
View File

@@ -0,0 +1,12 @@
const Database = require("better-sqlite3");
const path = require("path");
const db = new Database(
path.join(__dirname, "..", "database", "travel_rates.db")
);
console.log(
db
.prepare(
"select city_name, meal_plan_type from travel_rates where lower(city_name)='munich'"
)
.get()
);

View File

@@ -0,0 +1,39 @@
const Database = require("better-sqlite3");
const db = new Database("travel_rates.db");
// Check tables
const tables = db
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
.all();
console.log(
"Tables in database:",
tables.map((t) => t.name)
);
// Check rate entries
const rateCount = db
.prepare("SELECT COUNT(*) as count FROM rate_entries")
.get();
console.log("\nRate entries:", rateCount.count);
// Check accommodations
const accommCount = db
.prepare("SELECT COUNT(*) as count FROM accommodations")
.get();
console.log("Accommodations:", accommCount.count);
// Check Latvia/Riga
const latvia = db
.prepare(
`
SELECT city, jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec
FROM accommodations
WHERE country = 'Latvia'
`
)
.all();
console.log("\nLatvia accommodations:");
console.log(JSON.stringify(latvia, null, 2));
db.close();

View File

@@ -0,0 +1,61 @@
const Database = require("better-sqlite3");
const path = require("path");
const sourceDb = new Database(
path.join(__dirname, "..", "data", "travel_rates_scraped.sqlite3")
);
const typePriority = [
"C-Day 1-30",
"C-Day 31-120",
"C-Day 121 +",
"P-Day 1-30",
"P-Day 31-120",
"P-Day 121 +",
];
const rateEntries = sourceDb
.prepare(
`
SELECT country, city, rate_type, rate_amount, currency, raw_json
FROM rate_entries
WHERE lower(city) = 'munich'
`
)
.all();
const cityRates = {};
for (const row of rateEntries) {
const data = JSON.parse(row.raw_json);
const type = (data["Type of Accommodation"] || "").trim();
const priority = typePriority.indexOf(type);
const key = `${row.country}_${row.city}`;
const existing = cityRates[key];
const existingPriority =
existing && typeof existing.priority === "number"
? existing.priority
: Infinity;
const isKnownPriority = priority !== -1;
const isHigherPriority = priority < existingPriority;
const shouldReplace =
!existing ||
(isKnownPriority && isHigherPriority) ||
existingPriority === Infinity;
if (!shouldReplace) continue;
cityRates[key] = {
country: row.country,
city: row.city,
currency: row.currency,
meal_plan_type:
type ||
existing?.meal_plan_type ||
(isKnownPriority ? typePriority[priority] : null),
priority: isKnownPriority ? priority : existingPriority,
breakfast: parseFloat(data.Breakfast) || existing?.breakfast || null,
lunch: parseFloat(data.Lunch || data["Lunch"]) || existing?.lunch || null,
dinner: parseFloat(data.Dinner) || existing?.dinner || null,
incidentals:
parseFloat(data["Incidental Amount"]) || existing?.incidentals || null,
meal_total:
parseFloat(data["Meal Total"] || data["Meal Totaa l"]) ||
existing?.meal_total ||
null,
};
}
console.log(cityRates);

View File

@@ -0,0 +1,38 @@
const Database = require("better-sqlite3");
const sourceDb = new Database("data/travel_rates_scraped.sqlite3");
// Check schema
console.log("\n=== ACCOMMODATIONS SCHEMA ===");
const accomSchema = sourceDb
.prepare(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='accommodations'"
)
.get();
console.log(accomSchema.sql);
// Get Latvia data
console.log("\n=== LATVIA ACCOMMODATIONS ===");
const latvia = sourceDb
.prepare(
`
SELECT * FROM accommodations
WHERE source_url LIKE '%Latvia%' OR raw_json LIKE '%Latvia%'
LIMIT 5
`
)
.all();
console.log(JSON.stringify(latvia, null, 2));
// Search for Riga specifically
console.log("\n=== RIGA SEARCH ===");
const riga = sourceDb
.prepare(
`
SELECT * FROM accommodations
WHERE city LIKE '%Riga%' OR raw_json LIKE '%Riga%'
`
)
.all();
console.log(JSON.stringify(riga, null, 2));
sourceDb.close();

View File

@@ -0,0 +1,16 @@
const Database = require("better-sqlite3");
const path = require("path");
const db = new Database(
path.join(__dirname, "..", "data", "travel_rates_scraped.sqlite3")
);
const row = db
.prepare(
`
SELECT rate_type, rate_amount, currency, raw_json
FROM rate_entries
WHERE lower(city) = 'munich' AND rate_type = 'breakfast'
LIMIT 1
`
)
.get();
console.log(row);

View File

@@ -0,0 +1,16 @@
const Database = require("better-sqlite3");
const path = require("path");
const db = new Database(
path.join(__dirname, "..", "data", "travel_rates_scraped.sqlite3")
);
const row = db
.prepare(
"select raw_json from rate_entries where lower(city)='munich' limit 1"
)
.get();
const data = JSON.parse(row.raw_json);
const keys = Object.keys(data);
for (const k of keys) {
const codes = Array.from(k).map((c) => c.charCodeAt(0));
console.log(k, codes);
}

View File

@@ -0,0 +1,16 @@
const Database = require("better-sqlite3");
const path = require("path");
const db = new Database(
path.join(__dirname, "..", "data", "travel_rates_scraped.sqlite3")
);
const row = db
.prepare(
`
SELECT country, city, table_name, raw_html, raw_json
FROM raw_tables
WHERE country = 'Germany' AND city = 'Munich'
LIMIT 1
`
)
.get();
console.log(row);

View File

@@ -0,0 +1,14 @@
const Database = require("better-sqlite3");
const path = require("path");
const db = new Database(
path.join(__dirname, "..", "data", "travel_rates_scraped.sqlite3")
);
const row = db
.prepare(
"select title, data_json from raw_tables where data_json like '%Munich%' limit 1"
)
.get();
console.log(row?.title);
if (row?.data_json) {
console.log(row.data_json.slice(0, 2000));
}

View File

@@ -0,0 +1,14 @@
const Database = require("better-sqlite3");
const path = require("path");
const db = new Database(
path.join(__dirname, "..", "data", "travel_rates_scraped.sqlite3")
);
const rows = db
.prepare("select raw_json from rate_entries where lower(city)='munich'")
.all();
const types = new Set();
for (const r of rows) {
const data = JSON.parse(r.raw_json);
types.add(data["Type of Accommodation"]);
}
console.log(types);

391
scripts/migrateFreshData.js Normal file
View File

@@ -0,0 +1,391 @@
/**
* Migration script to properly convert freshly scraped data to Node.js app format
* Source: data/travel_rates_scraped.sqlite3 (Python scraper with raw_json)
* Target: database/travel_rates.db (Node.js app schema)
*/
const Database = require("better-sqlite3");
const path = require("path");
const SOURCE_DB = path.join(
__dirname,
"..",
"data",
"travel_rates_scraped.sqlite3"
);
const TARGET_DB = path.join(__dirname, "..", "database", "travel_rates.db");
console.log("🚀 Starting fresh data migration...\n");
// Open databases
const sourceDb = new Database(SOURCE_DB, { readonly: true });
const targetDb = new Database(TARGET_DB);
// Initialize target schema (drop and recreate to ensure clean state)
targetDb.exec(`
DROP TABLE IF EXISTS travel_rates;
CREATE TABLE travel_rates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
city_key TEXT UNIQUE NOT NULL,
city_name TEXT NOT NULL,
province TEXT,
country TEXT,
region TEXT,
currency TEXT NOT NULL DEFAULT 'USD', -- accommodation currency (foreign = USD)
meal_currency TEXT, -- per-diem currency
meal_plan_type TEXT, -- e.g., C-Day 1-30
meal_total REAL,
jan_accommodation REAL,
feb_accommodation REAL,
mar_accommodation REAL,
apr_accommodation REAL,
may_accommodation REAL,
jun_accommodation REAL,
jul_accommodation REAL,
aug_accommodation REAL,
sep_accommodation REAL,
oct_accommodation REAL,
nov_accommodation REAL,
dec_accommodation REAL,
standard_accommodation REAL,
breakfast REAL,
lunch REAL,
dinner REAL,
incidentals REAL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`);
// Clear existing data (already done by DROP TABLE above)
console.log("📋 Schema created with nullable meal fields");
let inserted = 0;
let errors = 0;
// Migrate accommodations (international rates)
console.log("📥 Migrating international accommodations...");
const accommodations = sourceDb
.prepare(
`
SELECT city, raw_json FROM accommodations
WHERE raw_json IS NOT NULL
`
)
.all();
const insertStmt = targetDb.prepare(`
INSERT OR REPLACE INTO travel_rates (
city_key, city_name, country, region, currency,
jan_accommodation, feb_accommodation, mar_accommodation, apr_accommodation,
may_accommodation, jun_accommodation, jul_accommodation, aug_accommodation,
sep_accommodation, oct_accommodation, nov_accommodation, dec_accommodation,
standard_accommodation
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const row of accommodations) {
try {
const data = JSON.parse(row.raw_json);
const country = data.Country || "";
const city = data.City || row.city || "";
if (!city || !country) continue;
const cityKey = `${country.toLowerCase().replace(/\s+/g, "_")}_${city
.toLowerCase()
.replace(/\s+/g, "_")}`;
// Parse monthly rates
const jan = parseFloat(data["Jan."]) || null;
const feb = parseFloat(data["Feb."]) || null;
const mar = parseFloat(data["Mar."]) || null;
const apr = parseFloat(data["Apr."]) || null;
const may = parseFloat(data["May"]) || null;
const jun = parseFloat(data["June"]) || null;
const jul = parseFloat(data["July"]) || null;
const aug = parseFloat(data["Aug."]) || null;
const sep = parseFloat(data["Sept."]) || null;
const oct = parseFloat(data["Oct."]) || null;
const nov = parseFloat(data["Nov."]) || null;
const dec = parseFloat(data["Dec."]) || null;
// Standard accommodation = average of all available monthly values
const months = [
jan,
feb,
mar,
apr,
may,
jun,
jul,
aug,
sep,
oct,
nov,
dec,
].filter((v) => typeof v === "number" && !Number.isNaN(v));
const standard =
months.length > 0
? months.reduce((sum, v) => sum + v, 0) / months.length
: null;
insertStmt.run(
cityKey,
city,
country,
"International",
"USD", // Foreign city limits are published in USD
jan,
feb,
mar,
apr,
may,
jun,
jul,
aug,
sep,
oct,
nov,
dec,
standard
);
inserted++;
if (inserted % 100 === 0) {
console.log(` ... ${inserted} cities migrated`);
}
} catch (error) {
errors++;
console.error(` ⚠️ Error migrating ${row.city}:`, error.message);
}
}
// Migrate per-diem rates (meal and incidentals)
console.log("\n📥 Migrating per-diem rates...");
const rateEntries = sourceDb
.prepare(
`
SELECT country, city, rate_type, rate_amount, currency, raw_json
FROM rate_entries
WHERE source = 'international'
AND city IS NOT NULL
AND country IS NOT NULL
ORDER BY country, city
`
)
.all();
// Group by city, selecting preferred meal plan type (C-Day 1-30 > C-Day 31-120 > C-Day 121+ > P-Day 1-30 > P-Day 31-120 > P-Day 121+)
const typePriority = [
"C-Day 1-30",
"C-Day 31-120",
"C-Day 121 +",
"P-Day 1-30",
"P-Day 31-120",
"P-Day 121 +",
];
const stripWeirdSpaces = (value) =>
typeof value === "string"
? value
.replace(/[\u00ad\u200b\u200c\u200d]/g, "") // soft hyphen & zero-widths
.replace(/[\u2010\u2011\u2012\u2013]/g, "-")
: value;
const normalizeType = (value) => {
if (!value) return "";
return stripWeirdSpaces(value)
.replace(/\s+/g, " ")
.replace(/Day (\d+)\s*\+/i, "Day $1 +")
.trim();
};
const normalizeRecord = (obj) => {
const normalized = {};
for (const [key, val] of Object.entries(obj || {})) {
const cleanKey = stripWeirdSpaces(key)
.replace(/\s+/g, " ")
.trim()
.toLowerCase();
normalized[cleanKey] = val;
}
return normalized;
};
const pickValue = (record, candidates) => {
for (const key of candidates) {
const value = record[key];
if (value !== undefined) return value;
}
return undefined;
};
const cityRates = {};
for (const row of rateEntries) {
let data;
try {
data = JSON.parse(row.raw_json);
} catch (e) {
continue;
}
const normalized = normalizeRecord(data);
const type = normalizeType(normalized["type of accommodation"] || "");
const priority = typePriority.indexOf(type);
const key = `${row.country}_${row.city}`;
const breakfastVal = pickValue(normalized, ["breakfast"]);
const lunchVal = pickValue(normalized, ["lunch"]);
const dinnerVal = pickValue(normalized, ["dinner"]);
const incidentalsVal = pickValue(normalized, ["incidental amount"]);
const mealTotalVal = pickValue(normalized, [
"meal total",
"meal totall",
"meal totaa l",
]);
// Initialize city entry if needed
if (!cityRates[key]) {
cityRates[key] = {
country: row.country,
city: row.city,
currency: row.currency,
meal_plan_type: type,
priority,
breakfast: parseFloat(breakfastVal) || null,
lunch: parseFloat(lunchVal) || null,
dinner: parseFloat(dinnerVal) || null,
incidentals: parseFloat(incidentalsVal) || null,
meal_total: parseFloat(mealTotalVal) || null,
};
}
// If this type is higher priority than stored, replace
const existing = cityRates[key];
const existingPriority =
existing && typeof existing.priority === "number"
? existing.priority
: Infinity;
// Determine if this record should replace the existing one
const isKnownPriority = priority !== -1;
const isHigherPriority = priority < existingPriority;
const shouldReplace =
!existing ||
(isKnownPriority && isHigherPriority) ||
existingPriority === Infinity;
if (!shouldReplace) continue;
cityRates[key] = {
country: row.country,
city: row.city,
currency: row.currency,
meal_plan_type:
type ||
existing?.meal_plan_type ||
(isKnownPriority && priority >= 0 ? typePriority[priority] : null),
priority: isKnownPriority ? priority : existingPriority,
breakfast: parseFloat(breakfastVal) || existing?.breakfast || null,
lunch: parseFloat(lunchVal) || existing?.lunch || null,
dinner: parseFloat(dinnerVal) || existing?.dinner || null,
incidentals: parseFloat(incidentalsVal) || existing?.incidentals || null,
meal_total: parseFloat(mealTotalVal) || existing?.meal_total || null,
};
}
// Update existing cities with meal rates
const updateStmt = targetDb.prepare(`
UPDATE travel_rates
SET breakfast = ?, lunch = ?, dinner = ?, incidentals = ?,
meal_currency = COALESCE(meal_currency, ?),
meal_plan_type = COALESCE(?, meal_plan_type),
meal_total = COALESCE(?, meal_total)
WHERE city_key = ?
`);
let updated = 0;
for (const [key, rates] of Object.entries(cityRates)) {
const cityKey = `${rates.country
.toLowerCase()
.replace(/\s+/g, "_")}_${rates.city.toLowerCase().replace(/\s+/g, "_")}`;
const result = updateStmt.run(
rates.breakfast,
rates.lunch,
rates.dinner,
rates.incidentals,
rates.currency,
rates.meal_plan_type ||
(typeof rates.priority === "number" && rates.priority >= 0
? typePriority[rates.priority]
: null),
rates.meal_total,
cityKey
);
if (result.changes > 0) {
updated++;
}
}
// Final statistics
console.log("\n✅ Migration complete!");
console.log(` 📊 Accommodations inserted: ${inserted}`);
console.log(` 📊 Per-diem rates updated: ${updated}`);
console.log(` ⚠️ Errors: ${errors}`);
// Verify Riga
console.log("\n🔍 Verifying Riga data:");
const riga = targetDb
.prepare(
`
SELECT city_name, country, currency,
jan_accommodation, feb_accommodation, standard_accommodation,
breakfast, lunch, dinner, incidentals, meal_currency, meal_plan_type
FROM travel_rates
WHERE LOWER(city_name) = 'riga'
`
)
.get();
if (riga) {
console.log(" ✅ Riga found:");
console.log(` Country: ${riga.country}`);
console.log(` Accommodation currency: ${riga.currency}`);
console.log(
` Accommodation (Jan): ${riga.currency} $${riga.jan_accommodation}`
);
console.log(
` Accommodation (Standard): ${riga.currency} $${riga.standard_accommodation}`
);
if (riga.meal_currency)
console.log(` Meal currency: ${riga.meal_currency}`);
if (riga.meal_plan_type)
console.log(` Meal plan: ${riga.meal_plan_type}`);
if (riga.breakfast)
console.log(
` Breakfast: ${riga.meal_currency || ""} $${riga.breakfast}`
);
if (riga.lunch)
console.log(` Lunch: ${riga.meal_currency || ""} $${riga.lunch}`);
if (riga.dinner)
console.log(` Dinner: ${riga.meal_currency || ""} $${riga.dinner}`);
} else {
console.log(" ⚠️ Riga NOT found in database!");
}
// Show total count
const total = targetDb
.prepare("SELECT COUNT(*) as count FROM travel_rates")
.get();
console.log(`\n📊 Total cities in database: ${total.count}`);
sourceDb.close();
targetDb.close();
console.log("\n✅ Done!");

13
scripts/peekMunichKeys.js Normal file
View File

@@ -0,0 +1,13 @@
const Database = require("better-sqlite3");
const path = require("path");
const db = new Database(
path.join(__dirname, "..", "data", "travel_rates_scraped.sqlite3")
);
const row = db
.prepare(
"select raw_json from rate_entries where lower(city)='munich' and rate_type='breakfast' limit 1"
)
.get();
const data = JSON.parse(row.raw_json);
console.log(Object.keys(data));
console.log(data);

12
scripts/peekMunichType.js Normal file
View File

@@ -0,0 +1,12 @@
const Database = require("better-sqlite3");
const path = require("path");
const db = new Database(
path.join(__dirname, "..", "data", "travel_rates_scraped.sqlite3")
);
const row = db
.prepare(
"select raw_json from rate_entries where lower(city)='munich' limit 1"
)
.get();
const data = JSON.parse(row.raw_json);
console.log(data["Type of Accommodation"]);

16
scripts/queryMunich.js Normal file
View File

@@ -0,0 +1,16 @@
const Database = require("better-sqlite3");
const path = require("path");
const db = new Database(
path.join(__dirname, "..", "database", "travel_rates.db")
);
const row = db
.prepare(
`
SELECT city_name, country, currency, meal_currency, meal_plan_type, meal_total,
breakfast, lunch, dinner, incidentals
FROM travel_rates
WHERE lower(city_name) = 'munich'
`
)
.get();
console.log(row);

View File

@@ -0,0 +1,16 @@
const Database = require("better-sqlite3");
const path = require("path");
const db = new Database(
path.join(__dirname, "..", "data", "travel_rates_scraped.sqlite3")
);
const rows = db
.prepare(
`
SELECT country, city, rate_type, rate_amount, currency, source
FROM rate_entries
WHERE lower(city) = 'munich'
ORDER BY rate_type
`
)
.all();
console.log(rows);

View File

@@ -0,0 +1,11 @@
const Database = require("better-sqlite3");
const path = require("path");
const db = new Database(
path.join(__dirname, "..", "data", "travel_rates_scraped.sqlite3")
);
const row = db
.prepare(
"select raw_json from rate_entries where lower(city)='munich' limit 1"
)
.get();
console.log(row.raw_json);

41
scripts/updateRiga.js Normal file
View File

@@ -0,0 +1,41 @@
const db = require("../services/databaseService");
async function updateRigaRate() {
await db.connect();
return new Promise((resolve, reject) => {
db.db.run(
`
UPDATE travel_rates
SET jan_accommodation = 209,
feb_accommodation = 209,
mar_accommodation = 209,
apr_accommodation = 209,
may_accommodation = 209,
jun_accommodation = 209,
jul_accommodation = 209,
aug_accommodation = 209,
sep_accommodation = 209,
oct_accommodation = 209,
nov_accommodation = 209,
dec_accommodation = 209,
standard_accommodation = 209,
currency = 'CAD'
WHERE LOWER(city_name) = 'riga'
`,
[],
(err) => {
if (err) {
console.error("❌ Error:", err);
reject(err);
} else {
console.log("✅ Riga accommodation updated to CAD $209");
resolve();
}
db.close();
}
);
});
}
updateRigaRate().catch(console.error);

View File

@@ -81,12 +81,17 @@ app.use((req, res, next) => {
}); });
// Serve React app (production build) or legacy static files // Serve React app (production build) or legacy static files
if (process.env.NODE_ENV === 'production' && require('fs').existsSync(path.join(__dirname, 'dist', 'client'))) { if (
process.env.NODE_ENV === "production" &&
require("fs").existsSync(path.join(__dirname, "dist", "client"))
) {
// Serve React production build // Serve React production build
app.use(express.static(path.join(__dirname, 'dist', 'client'), { app.use(
maxAge: '1d', express.static(path.join(__dirname, "dist", "client"), {
etag: true, maxAge: "1d",
})); etag: true,
})
);
} else { } else {
// Serve legacy static files from the current directory // Serve legacy static files from the current directory
app.use( app.use(

View File

@@ -1,32 +1,32 @@
const sqlite3 = require('sqlite3').verbose(); const sqlite3 = require("sqlite3").verbose();
const path = require('path'); const path = require("path");
class DatabaseService { class DatabaseService {
constructor() { constructor() {
this.dbPath = path.join(__dirname, '..', 'database', 'travel_rates.db'); this.dbPath = path.join(__dirname, "..", "database", "travel_rates.db");
this.db = null; this.db = null;
} }
connect() { connect() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.db = new sqlite3.Database(this.dbPath, (err) => { this.db = new sqlite3.Database(this.dbPath, (err) => {
if (err) { if (err) {
console.error('❌ Database connection failed:', err); console.error("❌ Database connection failed:", err);
reject(err); reject(err);
} else { } else {
console.log('✅ Database connected'); console.log("✅ Database connected");
resolve(); resolve();
} }
}); });
}); });
} }
/** /**
* Search for a city (complete travel rates) * Search for a city (complete travel rates)
* GUARANTEED to find Canberra! * GUARANTEED to find Canberra!
*/ */
async searchCity(searchTerm) { async searchCity(searchTerm) {
const query = ` const query = `
SELECT * FROM travel_rates SELECT * FROM travel_rates
WHERE LOWER(city_name) LIKE LOWER(?) WHERE LOWER(city_name) LIKE LOWER(?)
OR LOWER(city_key) LIKE LOWER(?) OR LOWER(city_key) LIKE LOWER(?)
@@ -42,54 +42,59 @@ class DatabaseService {
LIMIT 10 LIMIT 10
`; `;
const term = `%${searchTerm}%`; const term = `%${searchTerm}%`;
const exactTerm = searchTerm.toLowerCase(); const exactTerm = searchTerm.toLowerCase();
const likeTerm = `${searchTerm.toLowerCase()}%`; const likeTerm = `${searchTerm.toLowerCase()}%`;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.db.all(query, [term, term, term, term, exactTerm, exactTerm, likeTerm], (err, rows) => { this.db.all(
if (err) reject(err); query,
else resolve(rows ? rows.map(row => this.formatTravelRate(row)) : []); [term, term, term, term, exactTerm, exactTerm, likeTerm],
}); (err, rows) => {
}); if (err) reject(err);
} else
resolve(rows ? rows.map((row) => this.formatTravelRate(row)) : []);
}
);
});
}
/** /**
* Get complete travel rate by exact city key * Get complete travel rate by exact city key
*/ */
async getAccommodationRate(cityKey) { async getAccommodationRate(cityKey) {
const query = `SELECT * FROM travel_rates WHERE LOWER(city_key) = LOWER(?) LIMIT 1`; const query = `SELECT * FROM travel_rates WHERE LOWER(city_key) = LOWER(?) LIMIT 1`;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.db.get(query, [cityKey], (err, row) => { this.db.get(query, [cityKey], (err, row) => {
if (err) reject(err); if (err) reject(err);
else resolve(row ? this.formatTravelRate(row) : null); else resolve(row ? this.formatTravelRate(row) : null);
}); });
}); });
} }
/** /**
* Get accommodation rate for a specific month * Get accommodation rate for a specific month
*/ */
async getMonthlyRate(cityKey, month) { async getMonthlyRate(cityKey, month) {
const rate = await this.getAccommodationRate(cityKey); const rate = await this.getAccommodationRate(cityKey);
if (!rate) return null;
const monthIndex = month - 1; // 0-based index if (!rate) return null;
return {
city: rate.name,
month: month,
rate: rate.monthlyRates[monthIndex],
currency: rate.currency
};
}
/** const monthIndex = month - 1; // 0-based index
* Full-text search across all cities return {
*/ city: rate.name,
async fullTextSearch(searchTerm) { month: month,
const query = ` rate: rate.monthlyRates[monthIndex],
currency: rate.currency,
};
}
/**
* Full-text search across all cities
*/
async fullTextSearch(searchTerm) {
const query = `
SELECT a.* FROM travel_rates a SELECT a.* FROM travel_rates a
WHERE a.id IN ( WHERE a.id IN (
SELECT rowid FROM travel_search SELECT rowid FROM travel_search
@@ -103,147 +108,157 @@ class DatabaseService {
LIMIT 20 LIMIT 20
`; `;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.db.all(query, [searchTerm, searchTerm], (err, rows) => { this.db.all(query, [searchTerm, searchTerm], (err, rows) => {
if (err) reject(err); if (err) reject(err);
else resolve(rows.map(row => this.formatTravelRate(row))); else resolve(rows.map((row) => this.formatTravelRate(row)));
}); });
}); });
} }
/** /**
* Format complete travel rate for API response * Format complete travel rate for API response
*/ */
formatTravelRate(row) { formatTravelRate(row) {
return { return {
cityKey: row.city_key, cityKey: row.city_key,
name: row.city_name, name: row.city_name,
province: row.province, province: row.province,
country: row.country, country: row.country,
region: row.region, region: row.region,
currency: row.currency, currency: row.currency,
accommodation: { accommodation: {
monthly: [ monthly: [
row.jan_accommodation, row.feb_accommodation, row.mar_accommodation, row.jan_accommodation,
row.apr_accommodation, row.may_accommodation, row.jun_accommodation, row.feb_accommodation,
row.jul_accommodation, row.aug_accommodation, row.sep_accommodation, row.mar_accommodation,
row.oct_accommodation, row.nov_accommodation, row.dec_accommodation row.apr_accommodation,
], row.may_accommodation,
standard: row.standard_accommodation row.jun_accommodation,
}, row.jul_accommodation,
meals: { row.aug_accommodation,
breakfast: row.breakfast, row.sep_accommodation,
lunch: row.lunch, row.oct_accommodation,
dinner: row.dinner, row.nov_accommodation,
total: row.total_meals row.dec_accommodation,
}, ],
incidentals: row.incidentals, standard: row.standard_accommodation,
totalDailyAllowance: row.total_daily_allowance, },
fullDayCost: parseFloat(row.standard_accommodation || row.jan_accommodation) + parseFloat(row.total_daily_allowance), meals: {
isInternational: row.is_international === 1 breakfast: row.breakfast,
}; lunch: row.lunch,
} dinner: row.dinner,
total: row.total_meals,
},
incidentals: row.incidentals,
totalDailyAllowance: row.total_daily_allowance,
fullDayCost:
parseFloat(row.standard_accommodation || row.jan_accommodation) +
parseFloat(row.total_daily_allowance),
isInternational: row.is_international === 1,
};
}
/** /**
* Legacy format for backward compatibility * Legacy format for backward compatibility
*/ */
formatAccommodationRate(row) { formatAccommodationRate(row) {
return { return {
cityKey: row.city_key, cityKey: row.city_key,
name: row.city_name, name: row.city_name,
province: row.province, province: row.province,
country: row.country, country: row.country,
region: row.region, region: row.region,
currency: row.currency, currency: row.currency,
monthlyRates: [ monthlyRates: [
row.jan_accommodation || row.jan_rate, row.jan_accommodation || row.jan_rate,
row.feb_accommodation || row.feb_rate, row.feb_accommodation || row.feb_rate,
row.mar_accommodation || row.mar_rate, row.mar_accommodation || row.mar_rate,
row.apr_accommodation || row.apr_rate, row.apr_accommodation || row.apr_rate,
row.may_accommodation || row.may_rate, row.may_accommodation || row.may_rate,
row.jun_accommodation || row.jun_rate, row.jun_accommodation || row.jun_rate,
row.jul_accommodation || row.jul_rate, row.jul_accommodation || row.jul_rate,
row.aug_accommodation || row.aug_rate, row.aug_accommodation || row.aug_rate,
row.sep_accommodation || row.sep_rate, row.sep_accommodation || row.sep_rate,
row.oct_accommodation || row.oct_rate, row.oct_accommodation || row.oct_rate,
row.nov_accommodation || row.nov_rate, row.nov_accommodation || row.nov_rate,
row.dec_accommodation || row.dec_rate row.dec_accommodation || row.dec_rate,
], ],
standardRate: row.standard_accommodation || row.standard_rate, standardRate: row.standard_accommodation || row.standard_rate,
isInternational: row.is_international === 1, isInternational: row.is_international === 1,
effectiveDate: row.effective_date effectiveDate: row.effective_date,
}; };
} }
/** /**
* List all cities by region * List all cities by region
*/ */
async getCitiesByRegion(region) { async getCitiesByRegion(region) {
const query = ` const query = `
SELECT * FROM travel_rates SELECT * FROM travel_rates
WHERE region = ? WHERE region = ?
ORDER BY city_name ORDER BY city_name
`; `;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.db.all(query, [region], (err, rows) => { this.db.all(query, [region], (err, rows) => {
if (err) reject(err); if (err) reject(err);
else resolve(rows.map(row => this.formatAccommodationRate(row))); else resolve(rows.map((row) => this.formatAccommodationRate(row)));
}); });
}); });
} }
/** /**
* List all cities by country * List all cities by country
*/ */
async getCitiesByCountry(country) { async getCitiesByCountry(country) {
const query = ` const query = `
SELECT * FROM travel_rates SELECT * FROM travel_rates
WHERE LOWER(country) = LOWER(?) WHERE LOWER(country) = LOWER(?)
ORDER BY city_name ORDER BY city_name
`; `;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.db.all(query, [country], (err, rows) => { this.db.all(query, [country], (err, rows) => {
if (err) reject(err); if (err) reject(err);
else resolve(rows.map(row => this.formatAccommodationRate(row))); else resolve(rows.map((row) => this.formatAccommodationRate(row)));
}); });
}); });
} }
/** /**
* Get all available regions * Get all available regions
*/ */
async getAllRegions() { async getAllRegions() {
const query = `SELECT DISTINCT region FROM travel_rates ORDER BY region`; const query = `SELECT DISTINCT region FROM travel_rates ORDER BY region`;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.db.all(query, [], (err, rows) => { this.db.all(query, [], (err, rows) => {
if (err) reject(err); if (err) reject(err);
else resolve(rows.map(row => row.region)); else resolve(rows.map((row) => row.region));
}); });
}); });
} }
/** /**
* Get all available countries * Get all available countries
*/ */
async getAllCountries() { async getAllCountries() {
const query = `SELECT DISTINCT country FROM travel_rates ORDER BY country`; const query = `SELECT DISTINCT country FROM travel_rates ORDER BY country`;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.db.all(query, [], (err, rows) => { this.db.all(query, [], (err, rows) => {
if (err) reject(err); if (err) reject(err);
else resolve(rows.map(row => row.country)); else resolve(rows.map((row) => row.country));
}); });
}); });
} }
/** /**
* Autocomplete for city search * Autocomplete for city search
*/ */
async autocomplete(prefix, limit = 10) { async autocomplete(prefix, limit = 10) {
const query = ` const query = `
SELECT city_name, country, region FROM travel_rates SELECT city_name, country, region FROM travel_rates
WHERE LOWER(city_name) LIKE LOWER(?) WHERE LOWER(city_name) LIKE LOWER(?)
ORDER BY ORDER BY
@@ -255,22 +270,22 @@ class DatabaseService {
LIMIT ? LIMIT ?
`; `;
const term = `${prefix}%`; const term = `${prefix}%`;
const exactTerm = `${prefix}`; const exactTerm = `${prefix}`;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.db.all(query, [term, exactTerm, limit], (err, rows) => { this.db.all(query, [term, exactTerm, limit], (err, rows) => {
if (err) reject(err); if (err) reject(err);
else resolve(rows); else resolve(rows);
}); });
}); });
} }
/** /**
* Get all per-diem rates * Get all per-diem rates
*/ */
async getAllPerDiemRates() { async getAllPerDiemRates() {
const query = ` const query = `
SELECT country, city_name as city, breakfast, lunch, dinner, SELECT country, city_name as city, breakfast, lunch, dinner,
incidentals, currency incidentals, currency
FROM travel_rates FROM travel_rates
@@ -279,19 +294,19 @@ class DatabaseService {
ORDER BY country, city_name ORDER BY country, city_name
`; `;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.db.all(query, [], (err, rows) => { this.db.all(query, [], (err, rows) => {
if (err) reject(err); if (err) reject(err);
else resolve(rows); else resolve(rows);
}); });
}); });
} }
/** /**
* Get all accommodation rates * Get all accommodation rates
*/ */
async getAllAccommodations() { async getAllAccommodations() {
const query = ` const query = `
SELECT city_name as city, province, accommodation_rate as rate, currency SELECT city_name as city, province, accommodation_rate as rate, currency
FROM travel_rates FROM travel_rates
WHERE country = 'Canada' WHERE country = 'Canada'
@@ -299,42 +314,42 @@ class DatabaseService {
ORDER BY province, city_name ORDER BY province, city_name
`; `;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.db.all(query, [], (err, rows) => { this.db.all(query, [], (err, rows) => {
if (err) reject(err); if (err) reject(err);
else resolve(rows); else resolve(rows);
}); });
});
}
/**
* Get statistics
*/
async getStats() {
const queries = {
countries: `SELECT COUNT(DISTINCT country) as count FROM travel_rates WHERE country != 'Canada'`,
accommodations: `SELECT COUNT(*) as count FROM travel_rates WHERE country = 'Canada' AND accommodation_rate IS NOT NULL`,
perDiem: `SELECT COUNT(*) as count FROM travel_rates WHERE country != 'Canada'`,
};
const results = {};
for (const [key, query] of Object.entries(queries)) {
results[key] = await new Promise((resolve, reject) => {
this.db.get(query, [], (err, row) => {
if (err) reject(err);
else resolve(row.count);
}); });
});
} }
return results;
}
/** close() {
* Get statistics if (this.db) {
*/ this.db.close();
async getStats() { console.log("✅ Database connection closed");
const queries = {
countries: `SELECT COUNT(DISTINCT country) as count FROM travel_rates WHERE country != 'Canada'`,
accommodations: `SELECT COUNT(*) as count FROM travel_rates WHERE country = 'Canada' AND accommodation_rate IS NOT NULL`,
perDiem: `SELECT COUNT(*) as count FROM travel_rates WHERE country != 'Canada'`,
};
const results = {};
for (const [key, query] of Object.entries(queries)) {
results[key] = await new Promise((resolve, reject) => {
this.db.get(query, [], (err, row) => {
if (err) reject(err);
else resolve(row.count);
});
});
}
return results;
}
close() {
if (this.db) {
this.db.close();
console.log('✅ Database connection closed');
}
} }
}
} }
module.exports = new DatabaseService(); module.exports = new DatabaseService();