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`); });