Files
Reiseplanuing/server.js

540 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
require('dotenv').config();
const express = require('express');
const axios = require('axios');
const cors = require('cors');
const path = require('path');
const app = express();
app.use(cors());
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));
const PORT = process.env.PORT || 3000;
// ─────────────────────────────────────────────
// IATA-LOOKUP-TABELLE
// ─────────────────────────────────────────────
const IATA_LOOKUP = {
// Deutschland
'stuttgart': 'STR', 'frankfurt': 'FRA', 'münchen': 'MUC', 'munich': 'MUC',
'berlin': 'BER', 'hamburg': 'HAM', 'düsseldorf': 'DUS', 'dusseldorf': 'DUS',
'köln': 'CGN', 'koeln': 'CGN', 'cologne': 'CGN', 'hannover': 'HAJ',
'nürnberg': 'NUE', 'nuremberg': 'NUE', 'leipzig': 'LEJ', 'bremen': 'BRE',
'dresden': 'DRS', 'karlsruhe': 'FKB', 'friedrichshafen': 'FDH',
'bad saulgau': 'FDH', 'ravensburg': 'FDH', 'biberach': 'FDH', 'sigmaringen': 'FDH',
'saarbrücken': 'SCN', 'erfurt': 'ERF', 'paderborn': 'PAD',
'münster': 'FMO', 'rostock': 'RLG', 'dortmund': 'DTM', 'weeze': 'NRN',
// Österreich
'wien': 'VIE', 'vienna': 'VIE', 'graz': 'GRZ', 'linz': 'LNZ',
'salzburg': 'SZG', 'innsbruck': 'INN', 'klagenfurt': 'KLU',
// Schweiz
'zürich': 'ZRH', 'zurich': 'ZRH', 'genf': 'GVA', 'geneva': 'GVA',
'basel': 'BSL', 'bern': 'BRN',
// Polen
'warschau': 'WAW', 'warsaw': 'WAW', 'warszawa': 'WAW',
'krakau': 'KRK', 'krakow': 'KRK', 'kraków': 'KRK',
'breslau': 'WRO', 'wroclaw': 'WRO', 'wrocław': 'WRO',
'danzig': 'GDN', 'gdansk': 'GDN', 'gdańsk': 'GDN',
'posen': 'POZ', 'poznan': 'POZ', 'poznań': 'POZ',
'katowice': 'KTW', 'lodz': 'LCJ', 'łódź': 'LCJ',
'rzeszow': 'RZE', 'rzeszów': 'RZE', 'bydgoszcz': 'BZG',
'szczecin': 'SZZ', 'lublin': 'LUZ',
// Tschechien
'prag': 'PRG', 'prague': 'PRG', 'praha': 'PRG',
'brünn': 'BRQ', 'brno': 'BRQ', 'ostrava': 'OSR',
// Ungarn
'budapest': 'BUD', 'debrecen': 'DEB',
// Slowakei
'bratislava': 'BTS', 'košice': 'KSC', 'kosice': 'KSC',
// Rumänien
'bukarest': 'OTP', 'bucharest': 'OTP', 'bucurești': 'OTP',
'cluj': 'CLJ', 'timisoara': 'TSR', 'timișoara': 'TSR',
// Niederlande
'amsterdam': 'AMS', 'eindhoven': 'EIN', 'rotterdam': 'RTM',
// Belgien
'brüssel': 'BRU', 'brussels': 'BRU', 'bruxelles': 'BRU',
'lüttich': 'LGG', 'liege': 'LGG', 'liège': 'LGG',
// Frankreich
'paris': 'CDG', 'lyon': 'LYS', 'marseille': 'MRS',
'nizza': 'NCE', 'nice': 'NCE', 'toulouse': 'TLS',
'bordeaux': 'BOD', 'strasbourg': 'SXB', 'straßburg': 'SXB',
'lille': 'LIL', 'nantes': 'NTE', 'montpellier': 'MPL',
// Spanien
'madrid': 'MAD', 'barcelona': 'BCN', 'sevilla': 'SVQ', 'seville': 'SVQ',
'valencia': 'VLC', 'bilbao': 'BIO', 'málaga': 'AGP', 'malaga': 'AGP',
'palma': 'PMI', 'teneriffa': 'TFS', 'tenerife': 'TFS',
'alicante': 'ALC', 'zaragoza': 'ZAZ',
// Italien
'rom': 'FCO', 'rome': 'FCO', 'roma': 'FCO',
'mailand': 'MXP', 'milan': 'MXP', 'milano': 'MXP',
'venedig': 'VCE', 'venice': 'VCE', 'venezia': 'VCE',
'florenz': 'FLR', 'florence': 'FLR', 'firenze': 'FLR',
'neapel': 'NAP', 'naples': 'NAP', 'napoli': 'NAP',
'bologna': 'BLQ', 'turin': 'TRN', 'torino': 'TRN',
'catania': 'CTA', 'palermo': 'PMO', 'bari': 'BRI', 'verona': 'VRN',
// UK
'london': 'LHR', 'manchester': 'MAN', 'birmingham': 'BHX',
'edinburgh': 'EDI', 'glasgow': 'GLA', 'bristol': 'BRS',
'newcastle': 'NCL', 'leeds': 'LBA', 'liverpool': 'LPL',
// Skandinavien
'stockholm': 'ARN', 'oslo': 'OSL', 'kopenhagen': 'CPH', 'copenhagen': 'CPH',
'helsinki': 'HEL', 'göteborg': 'GOT', 'gothenburg': 'GOT',
'bergen': 'BGO', 'stavanger': 'SVG', 'tampere': 'TMP',
// Osteuropa
'kiev': 'KBP', 'kiew': 'KBP', 'kyiv': 'KBP',
'riga': 'RIX', 'tallinn': 'TLL', 'vilnius': 'VNO', 'minsk': 'MSQ',
'sofia': 'SOF', 'zagreb': 'ZAG', 'belgrad': 'BEG', 'belgrade': 'BEG',
'sarajevo': 'SJJ', 'laibach': 'LJU', 'ljubljana': 'LJU',
'skopje': 'SKP', 'tirana': 'TIA', 'chisinau': 'KIV',
'thessaloniki': 'SKG', 'athen': 'ATH', 'athens': 'ATH', 'athina': 'ATH',
// Türkei
'istanbul': 'IST', 'ankara': 'ESB', 'izmir': 'ADB', 'antalya': 'AYT',
// Russland
'moskau': 'SVO', 'moscow': 'SVO', 'moskva': 'SVO',
'st. petersburg': 'LED', 'saint petersburg': 'LED', 'sankt petersburg': 'LED',
// Naher Osten
'dubai': 'DXB', 'abu dhabi': 'AUH', 'doha': 'DOH',
'riad': 'RUH', 'riyadh': 'RUH', 'kuwait': 'KWI',
'beirut': 'BEY', 'tel aviv': 'TLV', 'amman': 'AMM', 'muscat': 'MCT',
// Asien
'peking': 'PEK', 'beijing': 'PEK', 'shanghai': 'PVG',
'tokio': 'NRT', 'tokyo': 'NRT', 'osaka': 'KIX',
'seoul': 'ICN', 'singapur': 'SIN', 'singapore': 'SIN',
'hongkong': 'HKG', 'hong kong': 'HKG',
'bangkok': 'BKK', 'mumbai': 'BOM', 'delhi': 'DEL',
'jakarta': 'CGK', 'manila': 'MNL', 'kuala lumpur': 'KUL',
'taipei': 'TPE', 'colombo': 'CMB', 'karachi': 'KHI',
// Amerika
'new york': 'JFK', 'los angeles': 'LAX', 'chicago': 'ORD',
'miami': 'MIA', 'san francisco': 'SFO', 'boston': 'BOS',
'washington': 'IAD', 'toronto': 'YYZ', 'montreal': 'YUL',
'vancouver': 'YVR', 'mexico city': 'MEX', 'mexiko stadt': 'MEX',
'sao paulo': 'GRU', 'rio de janeiro': 'GIG',
'buenos aires': 'EZE', 'bogota': 'BOG', 'lima': 'LIM', 'santiago': 'SCL',
// Afrika / Ozeanien
'kairo': 'CAI', 'cairo': 'CAI',
'johannesburg': 'JNB', 'kapstadt': 'CPT', 'cape town': 'CPT',
'nairobi': 'NBO', 'casablanca': 'CMN', 'addis abeba': 'ADD',
'sydney': 'SYD', 'melbourne': 'MEL', 'auckland': 'AKL',
'brisbane': 'BNE', 'perth': 'PER',
};
function getIataCode(cityInput) {
if (!cityInput) return null;
const normalized = cityInput.toLowerCase().trim().split(',')[0].trim();
return IATA_LOOKUP[normalized] || null;
}
// ─────────────────────────────────────────────
// HILFSFUNKTIONEN
// ─────────────────────────────────────────────
// Geocoding: Adresse → lat/lng + Stadtname (Google Maps oder Nominatim)
async function geocodeAddress(address) {
if (process.env.GOOGLE_MAPS_API_KEY && !process.env.GOOGLE_MAPS_API_KEY.startsWith('dein_')) {
try {
const res = await axios.get('https://maps.googleapis.com/maps/api/geocode/json', {
params: { address, key: process.env.GOOGLE_MAPS_API_KEY },
});
const result = res.data.results?.[0];
if (result) {
const loc = result.geometry.location;
const components = result.address_components || [];
const cityComp =
components.find((c) => c.types.includes('locality')) ||
components.find((c) => c.types.includes('administrative_area_level_1'));
return {
lat: loc.lat,
lng: loc.lng,
city: cityComp?.long_name || address.split(',')[0].trim(),
};
}
} catch (_) {}
}
// Nominatim Fallback
const queries = [
address,
address
.replace(/Polen/gi, 'Poland').replace(/Warschau/gi, 'Warsaw')
.replace(/Deutschland/gi, 'Germany').replace(/Österreich/gi, 'Austria')
.replace(/Schweiz/gi, 'Switzerland').replace(/Frankreich/gi, 'France')
.replace(/München/gi, 'Munich').replace(/Wien/gi, 'Vienna')
.replace(/Prag/gi, 'Prague').replace(/Mailand/gi, 'Milan')
.replace(/Brüssel/gi, 'Brussels').replace(/Lissabon/gi, 'Lisbon')
.replace(/Köln/gi, 'Cologne').replace(/Athen/gi, 'Athens'),
address.split(',').slice(-2).join(',').trim(),
address.split(',')[0].trim(),
];
for (const q of queries) {
if (!q) continue;
try {
const res = await axios.get('https://nominatim.openstreetmap.org/search', {
params: { q, format: 'json', limit: 1, addressdetails: 1 },
headers: { 'User-Agent': 'TravelDesk/1.0' },
timeout: 5000,
});
if (res.data?.length > 0) {
const item = res.data[0];
const city =
item.address?.city ||
item.address?.municipality ||
item.address?.town ||
item.address?.village ||
item.address?.state ||
address.split(',')[0].trim();
return { lat: parseFloat(item.lat), lng: parseFloat(item.lon), city };
}
} catch (_) {}
}
// Letzter Fallback: Stadtname direkt aus Input
const city = address.split(',')[0].trim();
throw new Error(`Adresse nicht gefunden: ${city}`);
}
// Distanz (Google Routes API oder Haversine-Fallback)
async function getDistanceMeters(originLat, originLng, destLat, destLng) {
if (process.env.GOOGLE_MAPS_API_KEY && !process.env.GOOGLE_MAPS_API_KEY.startsWith('dein_')) {
try {
const res = await axios.post(
'https://routes.googleapis.com/distanceMatrix/v2:computeRouteMatrix',
{
origins: [{ waypoint: { location: { latLng: { latitude: originLat, longitude: originLng } } } }],
destinations: [{ waypoint: { location: { latLng: { latitude: destLat, longitude: destLng } } } }],
travelMode: 'WALK',
},
{
headers: {
'X-Goog-Api-Key': process.env.GOOGLE_MAPS_API_KEY,
'X-Goog-FieldMask': 'originIndex,destinationIndex,duration,distanceMeters',
'Content-Type': 'application/json',
},
}
);
const entry = res.data?.[0];
if (entry?.distanceMeters) return entry.distanceMeters;
} catch (_) {}
}
// Haversine-Fallback (Luftlinie)
const R = 6371000;
const φ1 = (originLat * Math.PI) / 180;
const φ2 = (destLat * Math.PI) / 180;
const Δφ = ((destLat - originLat) * Math.PI) / 180;
const Δλ = ((destLng - originLng) * Math.PI) / 180;
const a =
Math.sin(Δφ / 2) ** 2 +
Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) ** 2;
return Math.round(R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)));
}
// IATA via GPT-Fallback
async function getIataViaGpt(cityName) {
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey || apiKey.startsWith('dein_')) return null;
try {
const res = await axios.post(
'https://api.openai.com/v1/chat/completions',
{
model: 'gpt-4o-mini',
max_tokens: 5,
temperature: 0,
messages: [
{
role: 'system',
content:
'Du bist ein Flughafen-Experte. Antworte IMMER nur mit einem einzigen 3-buchstabigen IATA-Code für den nächstgelegenen Flughafen (auch Regionalflughafen). Nichts anderes.',
},
{ role: 'user', content: `IATA-Code des nächstgelegenen Flughafens für: ${cityName}` },
],
},
{ headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' } }
);
const code = res.data.choices?.[0]?.message?.content?.trim().toUpperCase();
if (/^[A-Z]{3}$/.test(code)) return code;
} catch (_) {}
return null;
}
// ─────────────────────────────────────────────
// DEEP-LINK-GENERATOREN
// ─────────────────────────────────────────────
function generateFlightLink({ originIATA, destIATA, departDate, returnDate, adults, directOnly }) {
if (!originIATA || !destIATA) {
return `https://flights.booking.com/`;
}
const type = returnDate ? 'ROUNDTRIP' : 'ONEWAY';
let url =
`https://flights.booking.com/flights/${originIATA}-${destIATA}/` +
`?type=${type}` +
`&from=${originIATA}` +
`&to=${destIATA}` +
`&cabinClass=ECONOMY` +
`&sort=CHEAPEST` +
`&depart=${departDate}` +
`&adults=${adults}` +
`&locale=de-de` +
`&salesCurrency=EUR` +
`&customerCurrency=EUR`;
if (returnDate) url += `&return=${returnDate}`;
if (directOnly) url += `&stops=0`;
return url;
}
function generateHotelLink({ cityName, checkin, checkout, adults, refundableOnly }) {
const [cy, cm, cd] = checkin.split('-');
const [oy, om, od] = checkout.split('-');
let url =
`https://www.booking.com/searchresults.de.html` +
`?ss=${encodeURIComponent(cityName)}` +
`&checkin_year=${cy}` +
`&checkin_month=${parseInt(cm)}` +
`&checkin_monthday=${parseInt(cd)}` +
`&checkout_year=${oy}` +
`&checkout_month=${parseInt(om)}` +
`&checkout_monthday=${parseInt(od)}` +
`&group_adults=${adults}` +
`&group_children=0` +
`&no_rooms=1` +
`&order=price`;
if (refundableOnly) url += `&nflt=fc%3D1`;
return url;
}
function generateCarLink({ cityName, iataCode, pickupDate, dropoffDate, lat, lng, airportName }) {
const pu = new Date(pickupDate);
const doDate = new Date(dropoffDate);
const displayName = airportName || (iataCode ? `Flughafen ${iataCode}` : cityName);
const params = new URLSearchParams({
preflang: 'de',
prefcurrency: 'EUR',
driversAge: '30',
locationName: displayName,
locationIata: iataCode || '',
dropLocationName: displayName,
dropLocationIata: iataCode || '',
puDay: pu.getUTCDate(),
puMonth: pu.getUTCMonth() + 1,
puYear: pu.getUTCFullYear(),
puHour: '10',
puMinute: '0',
doDay: doDate.getUTCDate(),
doMonth: doDate.getUTCMonth() + 1,
doYear: doDate.getUTCFullYear(),
doHour: '10',
doMinute: '0',
ftsType: 'A',
dropFtsType: 'A',
});
if (lat && lng) {
params.set('coordinates', `${lat},${lng}`);
params.set('dropCoordinates', `${lat},${lng}`);
}
return `https://cars.booking.com/search-results?${params.toString()}`;
}
// ─────────────────────────────────────────────
// HAUPT-ENDPOINT: POST /api/search
// ─────────────────────────────────────────────
app.post('/api/search', async (req, res) => {
const {
origin,
destStreet = '',
destHouseNumber = '',
destPostalCode = '',
destCity = '',
destCountry = '',
checkin,
checkout,
adults = 1,
directFlightsOnly = false,
refundableOnly = true,
hotelAddress = '',
specialRequirements = '',
} = req.body;
// Adresse aus Einzelfeldern zusammensetzen
const streetPart = [destStreet.trim(), destHouseNumber.trim()].filter(Boolean).join(' ');
const destination = [streetPart, destPostalCode.trim(), destCity.trim(), destCountry.trim()]
.filter(Boolean).join(', ');
const nights = Math.round((new Date(checkout) - new Date(checkin)) / 86400000);
// ── Schritt 1: Fabrikadresse geocoden ─────
let destCoords = null;
// Stadt direkt aus dem Formularfeld kein Parsing nötig
let destinationCity = destCity.trim() || destination.split(',')[0].trim();
try {
destCoords = await geocodeAddress(destination);
// Geocoded city nur als Fallback wenn destCity leer
if (!destCity.trim()) destinationCity = destCoords.city;
} catch (_) {}
// ── Schritt 2: IATA-Codes ermitteln ───────
const originCity = origin.split(',')[0].trim();
let originIATA = getIataCode(originCity);
let destIATA = getIataCode(destinationCity) || getIataCode(destination.split(',')[0].trim());
let iataFallbackUsed = false;
let iataError = null;
if (!originIATA) {
originIATA = await getIataViaGpt(originCity);
if (originIATA) iataFallbackUsed = true;
}
if (!destIATA) {
destIATA = await getIataViaGpt(destinationCity);
if (destIATA) iataFallbackUsed = true;
}
if (!originIATA || !destIATA) {
iataError = {
origin: !originIATA ? originCity : null,
dest: !destIATA ? destinationCity : null,
};
}
// ── Schritt 3: Mietwagen-Logik ─────────────
let carNeeded = null;
let carMessage = 'Hotel noch nicht gebucht? Buche zuerst ein Hotel und trage die Adresse ein für automatischen Check.';
let distanceMeters = null;
let hotelAddressUsed = '';
if (hotelAddress && hotelAddress.trim()) {
hotelAddressUsed = hotelAddress.trim();
if (destCoords) {
try {
const hotelCoords = await geocodeAddress(hotelAddress);
distanceMeters = await getDistanceMeters(
hotelCoords.lat, hotelCoords.lng,
destCoords.lat, destCoords.lng
);
if (distanceMeters < 1500) {
carNeeded = false;
carMessage = `✅ Kein Mietwagen nötig dein Hotel ist nur ${distanceMeters}m von der Fabrik entfernt (fußläufig).`;
} else {
carNeeded = true;
carMessage = `🚗 Mietwagen empfohlen dein Hotel ist ${(distanceMeters / 1000).toFixed(1)} km von der Fabrik entfernt.`;
}
} catch (_) {
carNeeded = null;
carMessage = 'Hotel-Adresse konnte nicht gefunden werden. Bitte manuell prüfen.';
}
} else {
carMessage = 'Fabrikadresse konnte nicht geocoded werden Mietwagen bitte manuell prüfen.';
}
}
// ── Schritt 4: Deep-Links generieren ──────
const flightLink = generateFlightLink({
originIATA, destIATA, departDate: checkin,
returnDate: checkout, adults, directOnly: directFlightsOnly,
});
const hotelLink = generateHotelLink({
cityName: destinationCity, checkin, checkout, adults, refundableOnly,
});
// Flughafen-Koordinaten für Mietwagen (Abholung am Flughafen, nicht an der Fabrik)
let airportCoords = null;
let airportName = null;
if (destIATA) {
try {
const r = await geocodeAddress(`${destIATA} airport`);
airportCoords = r;
airportName = `Flughafen ${destIATA}`;
} catch (_) {}
}
const carLink = generateCarLink({
cityName: destinationCity, iataCode: destIATA,
pickupDate: checkin, dropoffDate: checkout,
lat: airportCoords?.lat ?? destCoords?.lat,
lng: airportCoords?.lng ?? destCoords?.lng,
airportName,
});
res.json({
flightLink,
hotelLink,
carLink,
originIATA,
destIATA,
iataFallbackUsed,
iataError,
carNeeded,
carMessage,
distanceMeters,
hotelAddressUsed,
meta: {
origin: originCity,
destination,
destinationCity,
checkin,
checkout,
nights,
adults,
},
});
});
// ─────────────────────────────────────────────
// ENDPOINT: POST /api/check-distance
// ─────────────────────────────────────────────
app.post('/api/check-distance', async (req, res) => {
const { hotelAddress, fabrikAddress } = req.body;
if (!hotelAddress || !fabrikAddress) {
return res.status(400).json({ error: 'Beide Adressen werden benötigt.' });
}
try {
const [hotelCoords, fabrikCoords] = await Promise.all([
geocodeAddress(hotelAddress),
geocodeAddress(fabrikAddress),
]);
const distanceMeters = await getDistanceMeters(
hotelCoords.lat, hotelCoords.lng,
fabrikCoords.lat, fabrikCoords.lng
);
const carNeeded = distanceMeters >= 1500;
const destinationCity = fabrikCoords.city;
const carLink = generateCarLink({
cityName: destinationCity,
pickupDate: req.body.checkin || new Date().toISOString().split('T')[0],
dropoffDate: req.body.checkout || new Date().toISOString().split('T')[0],
lat: fabrikCoords.lat, lng: fabrikCoords.lng,
});
res.json({
distanceMeters,
carNeeded,
carMessage: carNeeded
? `🚗 Mietwagen empfohlen ${(distanceMeters / 1000).toFixed(1)} km zur Fabrik`
: `✅ Kein Mietwagen nötig ${distanceMeters}m (fußläufig)`,
carLink,
});
} catch (e) {
res.status(400).json({
error: 'Adresse nicht gefunden. Bitte prüfe die Schreibweise.',
});
}
});
// ─────────────────────────────────────────────
// SERVER STARTEN
// ─────────────────────────────────────────────
app.listen(PORT, () => {
console.log(`\n✅ TravelDesk läuft auf http://localhost:${PORT}\n`);
});