first commit

This commit is contained in:
Timo
2026-03-16 15:36:42 +01:00
commit 738d725aea
1141 changed files with 139091 additions and 0 deletions

524
server.js Normal file
View File

@@ -0,0 +1,524 @@
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 }) {
const pu = new Date(pickupDate);
const doDate = new Date(dropoffDate);
const params = new URLSearchParams({
preflang: 'de',
prefcurrency: 'EUR',
driversAge: '30',
locationName: iataCode ? `Flughafen ${cityName}` : cityName,
locationIata: iataCode || '',
dropLocationName: iataCode ? `Flughafen ${cityName}` : cityName,
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,
});
const carLink = generateCarLink({
cityName: destinationCity, iataCode: destIATA,
pickupDate: checkin, dropoffDate: checkout,
lat: destCoords?.lat, lng: destCoords?.lng,
});
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`);
});