Compare commits
2 Commits
550da31038
...
e2d05e6809
| Author | SHA1 | Date | |
|---|---|---|---|
| e2d05e6809 | |||
| 90a7d8d3d2 |
9
.gitignore
vendored
9
.gitignore
vendored
@@ -47,3 +47,12 @@ pnpm-debug.log*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.gitignore
|
||||
llm_context.txt
|
||||
*.log
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
|
||||
80
copyChat.sh
Executable file
80
copyChat.sh
Executable file
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
OUTPUT_FILE="${1:-llm_context.txt}"
|
||||
ROOT_DIR="${2:-.}"
|
||||
|
||||
# Absolute Pfade auflösen
|
||||
OUTPUT_PATH="$(cd "$(dirname "$OUTPUT_FILE")" 2>/dev/null && pwd)/$(basename "$OUTPUT_FILE")"
|
||||
|
||||
cd "$ROOT_DIR" || exit 1
|
||||
|
||||
# Ausgabedatei initialisieren / leeren
|
||||
> "$OUTPUT_PATH"
|
||||
|
||||
echo "========================================" >> "$OUTPUT_PATH"
|
||||
echo "PROJEKTSTRUKTUR" >> "$OUTPUT_PATH"
|
||||
echo "========================================" >> "$OUTPUT_PATH"
|
||||
|
||||
# Prüfen, ob Git verfügbar und das Verzeichnis ein Git-Repo ist
|
||||
IS_GIT_REPO=false
|
||||
if command -v git &> /dev/null && git rev-parse --is-inside-work-tree &> /dev/null; then
|
||||
IS_GIT_REPO=true
|
||||
fi
|
||||
|
||||
if [ "$IS_GIT_REPO" = true ]; then
|
||||
echo "(Ermittelt via Git unter Berücksichtigung von .gitignore)" >> "$OUTPUT_PATH"
|
||||
# Zeigt alle getrackten + ungetrackten Dateien an, ignoriert .gitignore-Treffer
|
||||
git ls-files -co --exclude-standard >> "$OUTPUT_PATH"
|
||||
else
|
||||
if command -v tree &> /dev/null; then
|
||||
tree -I 'node_modules|.git|venv|.venv|__pycache__|dist|build|.next|target' >> "$OUTPUT_PATH"
|
||||
else
|
||||
find . -maxdepth 3 -not -path '*/.*' -not -path '*/node_modules*' -not -path '*/venv*' >> "$OUTPUT_PATH"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo -e "\n========================================" >> "$OUTPUT_PATH"
|
||||
echo "DATEIINHALTE" >> "$OUTPUT_PATH"
|
||||
echo "========================================" >> "$OUTPUT_PATH"
|
||||
|
||||
# Dateiliste ermitteln
|
||||
get_file_list() {
|
||||
if [ "$IS_GIT_REPO" = true ]; then
|
||||
# -c = cached/tracked, -o = others/untracked, --exclude-standard = .gitignore beachten
|
||||
git ls-files -co --exclude-standard
|
||||
else
|
||||
find . -type f \
|
||||
-not -path '*/.git/*' \
|
||||
-not -path '*/node_modules/*' \
|
||||
-not -path '*/venv/*' \
|
||||
-not -path '*/.venv/*' \
|
||||
-not -path '*/__pycache__/*' \
|
||||
-not -path '*/dist/*' \
|
||||
-not -path '*/build/*' \
|
||||
-not -path '*/.next/*'
|
||||
fi
|
||||
}
|
||||
|
||||
# Dateien verarbeiten
|
||||
get_file_list | sort | while IFS= read -r file; do
|
||||
# Überspringe leere Pfade, die Zieldatei selbst oder typische Binär-Endungen
|
||||
[ -z "$file" ] && continue
|
||||
[ "$file" = "$OUTPUT_FILE" ] && continue
|
||||
[ "$(realpath "$file" 2>/dev/null)" = "$OUTPUT_PATH" ] && continue
|
||||
|
||||
case "$file" in
|
||||
*.png|*.jpg|*.jpeg|*.gif|*.ico|*.pdf|*.zip|*.tar.gz|*.lock|package-lock.json|yarn.lock)
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
|
||||
# Prüfen, ob die Datei existiert und lesbarer Text ist
|
||||
if [ -f "$file" ] && (grep -Iq . "$file" 2>/dev/null || [ ! -s "$file" ]); then
|
||||
echo -e "\n--- DATEI: $file ---" >> "$OUTPUT_PATH"
|
||||
echo '```' >> "$OUTPUT_PATH"
|
||||
cat "$file" >> "$OUTPUT_PATH"
|
||||
echo -e "\n"'```' >> "$OUTPUT_PATH"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Fertig! Projektkontext liegt in: $OUTPUT_PATH"
|
||||
@@ -7,3 +7,8 @@ services:
|
||||
- "3002:3001"
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
# Spiegelt deinen lokalen Quellcode live in den Container:
|
||||
- ./src:/app/src
|
||||
- ./public:/app/public
|
||||
command: npm run dev
|
||||
1404
llm_context.txt
Normal file
1404
llm_context.txt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,8 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
serverExternalPackages: ['bullmq', 'ioredis'],
|
||||
experimental: {
|
||||
serverComponentsExternalPackages: ['bullmq', 'ioredis'],
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
serverExternalPackages: ['bullmq', 'ioredis'],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
147
src/app/api/attractions/[id]/route.ts
Normal file
147
src/app/api/attractions/[id]/route.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { query } from '@/lib/db';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
async function parseGoogleMapsUrl(inputUrl: string) {
|
||||
let targetUrl = inputUrl.trim();
|
||||
if (targetUrl.includes('goo.gl') || targetUrl.includes('maps.app.goo.gl')) {
|
||||
try {
|
||||
const res = await fetch(targetUrl, {
|
||||
method: 'GET',
|
||||
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' },
|
||||
redirect: 'follow',
|
||||
});
|
||||
targetUrl = res.url || targetUrl;
|
||||
} catch (err) {}
|
||||
}
|
||||
let lat: number | null = null;
|
||||
let lon: number | null = null;
|
||||
const coordMatch = targetUrl.match(/@(-?\d+\.\d+),(-?\d+\.\d+)/);
|
||||
if (coordMatch) {
|
||||
lat = parseFloat(coordMatch[1]);
|
||||
lon = parseFloat(coordMatch[2]);
|
||||
}
|
||||
return { lat, lon };
|
||||
}
|
||||
|
||||
async function geocode(address: string) {
|
||||
if (!address || address.startsWith('http')) return null;
|
||||
const search = async (q: string) => {
|
||||
try {
|
||||
const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(q)}&format=json&limit=1`;
|
||||
const res = await fetch(url, { headers: { 'User-Agent': 'TravelPlannerApp/1.0' } });
|
||||
const data = await res.json();
|
||||
if (data && data.length > 0) return { lat: parseFloat(data[0].lat), lon: parseFloat(data[0].lon) };
|
||||
} catch (err) {}
|
||||
return null;
|
||||
};
|
||||
|
||||
let coords = await search(address);
|
||||
if (!coords && address.includes(',')) {
|
||||
const parts = address.split(',').map(p => p.trim());
|
||||
await sleep(1100);
|
||||
coords = await search(`${parts[parts.length - 2]}, ${parts[parts.length - 1]}`);
|
||||
}
|
||||
return coords;
|
||||
}
|
||||
|
||||
async function getDriveInfo(origin: { lat: number; lon: number }, dest: { lat: number; lon: number }) {
|
||||
try {
|
||||
const url = `http://router.project-osrm.org/route/v1/driving/${origin.lon},${origin.lat};${dest.lon},${dest.lat}?overview=false`;
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
if (data.code === 'Ok' && data.routes?.length > 0) {
|
||||
return {
|
||||
distanceKm: Math.round((data.routes[0].distance / 1000) * 10) / 10,
|
||||
durationMin: Math.round(data.routes[0].duration / 60),
|
||||
};
|
||||
}
|
||||
} catch (err) {}
|
||||
return { distanceKm: null, durationMin: null };
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const { id } = params;
|
||||
const { name, address, category_icon, notes, hotel_ids, trip_id } = await req.json();
|
||||
|
||||
let sightCoords: { lat: number; lon: number } | null = null;
|
||||
if (address.includes('google.com/maps') || address.includes('goo.gl')) {
|
||||
const parsed = await parseGoogleMapsUrl(address);
|
||||
if (parsed.lat && parsed.lon) sightCoords = { lat: parsed.lat, lon: parsed.lon };
|
||||
}
|
||||
if (!sightCoords) {
|
||||
sightCoords = await geocode(address || name);
|
||||
}
|
||||
|
||||
const res = await query(
|
||||
`UPDATE hotel_attractions
|
||||
SET name = $1,
|
||||
address = $2,
|
||||
category_icon = $3,
|
||||
notes = $4,
|
||||
trip_id = $5,
|
||||
lat = COALESCE($6, lat),
|
||||
lon = COALESCE($7, lon)
|
||||
WHERE id = $8
|
||||
RETURNING *`,
|
||||
[
|
||||
name,
|
||||
address,
|
||||
category_icon || '🏛️',
|
||||
notes || null,
|
||||
trip_id || null,
|
||||
sightCoords?.lat || null,
|
||||
sightCoords?.lon || null,
|
||||
id,
|
||||
]
|
||||
);
|
||||
|
||||
await query('DELETE FROM hotel_attraction_assignments WHERE attraction_id = $1', [id]);
|
||||
|
||||
const selectedHotelIds: string[] = Array.isArray(hotel_ids) ? hotel_ids : [];
|
||||
for (const hId of selectedHotelIds) {
|
||||
let distanceKm: number | null = null;
|
||||
let durationMin: number | null = null;
|
||||
|
||||
const hotelRes = await query('SELECT address, name FROM hotels WHERE id = $1', [hId]);
|
||||
if (hotelRes.rows.length > 0) {
|
||||
const hotel = hotelRes.rows[0];
|
||||
await sleep(1100);
|
||||
let hCoords = await geocode(hotel.address);
|
||||
if (!hCoords && hotel.name) {
|
||||
await sleep(1100);
|
||||
hCoords = await geocode(hotel.name);
|
||||
}
|
||||
|
||||
if (hCoords && sightCoords) {
|
||||
const drive = await getDriveInfo(hCoords, sightCoords);
|
||||
distanceKm = drive.distanceKm;
|
||||
durationMin = drive.durationMin;
|
||||
}
|
||||
}
|
||||
|
||||
await query(
|
||||
`INSERT INTO hotel_attraction_assignments (attraction_id, hotel_id, distance_km, duration_minutes)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
[id, hId, distanceKm, durationMin]
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, attraction: res.rows[0] });
|
||||
} catch (e: any) {
|
||||
return NextResponse.json({ success: false, error: e.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
await query('DELETE FROM hotel_attractions WHERE id = $1', [params.id]);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (e: any) {
|
||||
return NextResponse.json({ success: false, error: e.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
230
src/app/api/attractions/route.ts
Normal file
230
src/app/api/attractions/route.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { query } from '@/lib/db';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
async function parseGoogleMapsUrl(inputUrl: string) {
|
||||
let targetUrl = inputUrl.trim();
|
||||
|
||||
if (targetUrl.includes('goo.gl') || targetUrl.includes('maps.app.goo.gl')) {
|
||||
try {
|
||||
const res = await fetch(targetUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
},
|
||||
redirect: 'follow',
|
||||
});
|
||||
targetUrl = res.url || targetUrl;
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Auflösen des Google Maps Links:', err);
|
||||
}
|
||||
}
|
||||
|
||||
let extractedName: string | null = null;
|
||||
let lat: number | null = null;
|
||||
let lon: number | null = null;
|
||||
|
||||
const placeMatch = targetUrl.match(/\/place\/([^/@?]+)/);
|
||||
if (placeMatch && placeMatch[1]) {
|
||||
extractedName = decodeURIComponent(placeMatch[1].replace(/\+/g, ' '));
|
||||
}
|
||||
|
||||
const coordMatch = targetUrl.match(/@(-?\d+\.\d+),(-?\d+\.\d+)/);
|
||||
if (coordMatch) {
|
||||
lat = parseFloat(coordMatch[1]);
|
||||
lon = parseFloat(coordMatch[2]);
|
||||
} else {
|
||||
const dataCoordMatch = targetUrl.match(/!3d(-?\d+\.\d+)!4d(-?\d+\.\d+)/);
|
||||
if (dataCoordMatch) {
|
||||
lat = parseFloat(dataCoordMatch[1]);
|
||||
lon = parseFloat(dataCoordMatch[2]);
|
||||
} else {
|
||||
const queryCoordMatch = targetUrl.match(/[?&](?:q|query|ll)=(-?\d+\.\d+),(-?\d+\.\d+)/);
|
||||
if (queryCoordMatch) {
|
||||
lat = parseFloat(queryCoordMatch[1]);
|
||||
lon = parseFloat(queryCoordMatch[2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { name: extractedName, lat, lon, resolvedUrl: targetUrl };
|
||||
}
|
||||
|
||||
// Geocoding mit intelligentem Adress-Fallback
|
||||
async function geocode(address: string): Promise<{ lat: number; lon: number } | null> {
|
||||
if (!address || address.startsWith('http')) return null;
|
||||
|
||||
const search = async (q: string) => {
|
||||
try {
|
||||
const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(q)}&format=json&limit=1`;
|
||||
const res = await fetch(url, {
|
||||
headers: { 'User-Agent': 'TravelPlannerApp/1.0 (Homelab Private Usage)' },
|
||||
});
|
||||
if (res.status === 429) {
|
||||
await sleep(1500);
|
||||
return null;
|
||||
}
|
||||
const data = await res.json();
|
||||
if (data && data.length > 0) {
|
||||
return { lat: parseFloat(data[0].lat), lon: parseFloat(data[0].lon) };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Geocoding Fehler bei "${q}":`, err);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// 1. Exakter Adresstext
|
||||
let coords = await search(address);
|
||||
if (coords) return coords;
|
||||
|
||||
// 2. Fallback: Ort und Land aus der Komma-Kette filtern
|
||||
const parts = address.split(',').map(p => p.trim());
|
||||
if (parts.length > 2) {
|
||||
await sleep(1100);
|
||||
const cityFallback = `${parts[parts.length - 2]}, ${parts[parts.length - 1]}`;
|
||||
coords = await search(cityFallback);
|
||||
}
|
||||
|
||||
return coords;
|
||||
}
|
||||
|
||||
// OSRM Routing-Berechnung
|
||||
async function getDriveInfo(origin: { lat: number; lon: number }, dest: { lat: number; lon: number }) {
|
||||
try {
|
||||
const url = `http://router.project-osrm.org/route/v1/driving/${origin.lon},${origin.lat};${dest.lon},${dest.lat}?overview=false`;
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
if (data.code === 'Ok' && data.routes && data.routes.length > 0) {
|
||||
const distanceKm = Math.round((data.routes[0].distance / 1000) * 10) / 10;
|
||||
const durationMin = Math.round(data.routes[0].duration / 60);
|
||||
return { distanceKm, durationMin };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('OSRM Routing Fehler:', err);
|
||||
}
|
||||
return { distanceKm: null, durationMin: null };
|
||||
}
|
||||
|
||||
// GET: Alle Sehenswürdigkeiten laden
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const tripId = searchParams.get('tripId');
|
||||
|
||||
let sql = `
|
||||
SELECT
|
||||
a.*,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'hotel_id', haa.hotel_id,
|
||||
'distance_km', haa.distance_km,
|
||||
'duration_minutes', haa.duration_minutes
|
||||
)
|
||||
) FILTER (WHERE haa.id IS NOT NULL),
|
||||
'[]'
|
||||
) AS assignments
|
||||
FROM hotel_attractions a
|
||||
LEFT JOIN hotel_attraction_assignments haa ON a.id = haa.attraction_id
|
||||
WHERE 1=1
|
||||
`;
|
||||
const params: any[] = [];
|
||||
|
||||
if (tripId && tripId !== 'ALL') {
|
||||
params.push(tripId);
|
||||
sql += ` AND a.trip_id = $${params.length}`;
|
||||
}
|
||||
|
||||
sql += ' GROUP BY a.id ORDER BY a.created_at ASC';
|
||||
const res = await query(sql, params);
|
||||
return NextResponse.json({ success: true, attractions: res.rows });
|
||||
} catch (e: any) {
|
||||
return NextResponse.json({ success: false, error: e.message, attractions: [] }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST: Neues Ausflugsziel anlegen
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { trip_id, hotel_ids, name, address, category_icon, notes } = await req.json();
|
||||
|
||||
if (!address) {
|
||||
return NextResponse.json({ error: 'Google Maps Link oder Adresse erforderlich' }, { status: 400 });
|
||||
}
|
||||
|
||||
let finalName = name?.trim() || '';
|
||||
let finalAddress = address.trim();
|
||||
let sightCoords: { lat: number; lon: number } | null = null;
|
||||
|
||||
if (finalAddress.includes('google.com/maps') || finalAddress.includes('goo.gl')) {
|
||||
const parsed = await parseGoogleMapsUrl(finalAddress);
|
||||
if (parsed.name && !finalName) finalName = parsed.name;
|
||||
if (parsed.lat && parsed.lon) sightCoords = { lat: parsed.lat, lon: parsed.lon };
|
||||
}
|
||||
|
||||
if (!sightCoords) {
|
||||
sightCoords = await geocode(finalAddress || finalName);
|
||||
}
|
||||
if (!finalName) finalName = finalAddress;
|
||||
|
||||
// 1. In hotel_attractions anlegen
|
||||
const insertRes = await query(
|
||||
`INSERT INTO hotel_attractions (trip_id, name, address, category_icon, notes, lat, lon)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING *`,
|
||||
[
|
||||
trip_id || null,
|
||||
finalName,
|
||||
finalAddress,
|
||||
category_icon || '🏛️',
|
||||
notes || null,
|
||||
sightCoords?.lat || null,
|
||||
sightCoords?.lon || null,
|
||||
]
|
||||
);
|
||||
|
||||
const newAttraction = insertRes.rows[0];
|
||||
|
||||
// 2. Für alle ausgewählten Hotels Distanz berechnen (mit Pause gegen 429)
|
||||
const selectedHotelIds: string[] = Array.isArray(hotel_ids) ? hotel_ids : [];
|
||||
for (const hId of selectedHotelIds) {
|
||||
let distanceKm: number | null = null;
|
||||
let durationMin: number | null = null;
|
||||
|
||||
const hotelRes = await query('SELECT address, name FROM hotels WHERE id = $1', [hId]);
|
||||
if (hotelRes.rows.length > 0) {
|
||||
const hotel = hotelRes.rows[0];
|
||||
|
||||
await sleep(1100); // Fair-Use Rate-Limit für Nominatim einhalten
|
||||
let hCoords = await geocode(hotel.address);
|
||||
if (!hCoords && hotel.name) {
|
||||
await sleep(1100);
|
||||
hCoords = await geocode(hotel.name);
|
||||
}
|
||||
|
||||
if (hCoords && sightCoords) {
|
||||
const drive = await getDriveInfo(hCoords, sightCoords);
|
||||
distanceKm = drive.distanceKm;
|
||||
durationMin = drive.durationMin;
|
||||
}
|
||||
}
|
||||
|
||||
await query(
|
||||
`INSERT INTO hotel_attraction_assignments (attraction_id, hotel_id, distance_km, duration_minutes)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (attraction_id, hotel_id) DO UPDATE
|
||||
SET distance_km = EXCLUDED.distance_km, duration_minutes = EXCLUDED.duration_minutes`,
|
||||
[newAttraction.id, hId, distanceKm, durationMin]
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, attraction: newAttraction });
|
||||
} catch (e: any) {
|
||||
console.error('Fehler beim Anlegen:', e);
|
||||
return NextResponse.json({ success: false, error: e.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
161
src/app/api/hotels/[id]/attractions/route.ts
Normal file
161
src/app/api/hotels/[id]/attractions/route.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { query } from '@/lib/db';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
// Google Maps URL auflösen und Name + Koordinaten extrahieren
|
||||
async function parseGoogleMapsUrl(inputUrl: string) {
|
||||
let targetUrl = inputUrl.trim();
|
||||
|
||||
// Kurzlinks (maps.app.goo.gl oder goo.gl) auflösen
|
||||
if (targetUrl.includes('goo.gl') || targetUrl.includes('maps.app.goo.gl')) {
|
||||
try {
|
||||
const res = await fetch(targetUrl, { method: 'HEAD', redirect: 'follow' });
|
||||
targetUrl = res.url || targetUrl;
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Auflösen des Google Maps Kurzlinks:', err);
|
||||
}
|
||||
}
|
||||
|
||||
let extractedName: string | null = null;
|
||||
let lat: number | null = null;
|
||||
let lon: number | null = null;
|
||||
|
||||
// 1. Name extrahieren aus /place/Ortsname/
|
||||
const placeMatch = targetUrl.match(/\/place\/([^/@?]+)/);
|
||||
if (placeMatch && placeMatch[1]) {
|
||||
extractedName = decodeURIComponent(placeMatch[1].replace(/\+/g, ' '));
|
||||
}
|
||||
|
||||
// 2. Koordinaten aus @lat,lon extrahieren
|
||||
const coordMatch = targetUrl.match(/@(-?\d+\.\d+),(-?\d+\.\d+)/);
|
||||
if (coordMatch) {
|
||||
lat = parseFloat(coordMatch[1]);
|
||||
lon = parseFloat(coordMatch[2]);
|
||||
} else {
|
||||
// Alternativ-Format: !3dLAT!4dLON
|
||||
const dataCoordMatch = targetUrl.match(/!3d(-?\d+\.\d+)!4d(-?\d+\.\d+)/);
|
||||
if (dataCoordMatch) {
|
||||
lat = parseFloat(dataCoordMatch[1]);
|
||||
lon = parseFloat(dataCoordMatch[2]);
|
||||
}
|
||||
}
|
||||
|
||||
return { name: extractedName, lat, lon, resolvedUrl: targetUrl };
|
||||
}
|
||||
|
||||
// Fallback Nominatim Geocoding
|
||||
async function geocode(address: string): Promise<{ lat: number; lon: number } | null> {
|
||||
try {
|
||||
const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(address)}&format=json&limit=1`;
|
||||
const res = await fetch(url, {
|
||||
headers: { 'User-Agent': 'TravelPlannerApp/1.0 (Homelab Private Usage)' },
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data && data.length > 0) {
|
||||
return { lat: parseFloat(data[0].lat), lon: parseFloat(data[0].lon) };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Geocoding Fehler:', err);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// OSRM Fahrzeit- & Distanzberechnung
|
||||
async function getDriveInfo(origin: { lat: number; lon: number }, dest: { lat: number; lon: number }) {
|
||||
try {
|
||||
const url = `http://router.project-osrm.org/route/v1/driving/${origin.lon},${origin.lat};${dest.lon},${dest.lat}?overview=false`;
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
if (data.code === 'Ok' && data.routes && data.routes.length > 0) {
|
||||
const distanceKm = Math.round((data.routes[0].distance / 1000) * 10) / 10;
|
||||
const durationMin = Math.round(data.routes[0].duration / 60);
|
||||
return { distanceKm, durationMin };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('OSRM Routing Fehler:', err);
|
||||
}
|
||||
return { distanceKm: null, durationMin: null };
|
||||
}
|
||||
|
||||
// GET: Alle Sehenswürdigkeiten eines Hotels abrufen
|
||||
export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const res = await query(
|
||||
`SELECT * FROM hotel_attractions
|
||||
WHERE hotel_id = $1
|
||||
ORDER BY distance_km ASC NULLS LAST, created_at ASC`,
|
||||
[params.id]
|
||||
);
|
||||
return NextResponse.json({ success: true, attractions: res.rows });
|
||||
} catch (e: any) {
|
||||
return NextResponse.json({ success: false, error: e.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST: Google Maps Link oder Adresse verarbeiten & Route berechnen
|
||||
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const hotelId = params.id;
|
||||
const { name, input_value, category_icon, notes } = await req.json();
|
||||
|
||||
if (!input_value) {
|
||||
return NextResponse.json({ error: 'Google Maps Link oder Adresse ist erforderlich' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 1. Hotel-Koordinaten ermitteln
|
||||
const hotelRes = await query('SELECT address, name FROM hotels WHERE id = $1', [hotelId]);
|
||||
if (hotelRes.rows.length === 0) {
|
||||
return NextResponse.json({ error: 'Hotel nicht gefunden' }, { status: 404 });
|
||||
}
|
||||
const hotel = hotelRes.rows[0];
|
||||
const hotelCoords = await geocode(hotel.address || hotel.name);
|
||||
|
||||
let finalName = name?.trim() || '';
|
||||
let finalAddress = input_value.trim();
|
||||
let sightCoords: { lat: number; lon: number } | null = null;
|
||||
|
||||
// 2. Google Maps URL prüfen & parsen
|
||||
if (input_value.includes('google.com/maps') || input_value.includes('goo.gl')) {
|
||||
const parsedMaps = await parseGoogleMapsUrl(input_value);
|
||||
if (parsedMaps.name && !finalName) {
|
||||
finalName = parsedMaps.name;
|
||||
}
|
||||
if (parsedMaps.lat && parsedMaps.lon) {
|
||||
sightCoords = { lat: parsedMaps.lat, lon: parsedMaps.lon };
|
||||
}
|
||||
}
|
||||
|
||||
// Falls keine Koordinaten aus dem Link gelesen werden konnten: Fallback auf Geocoding
|
||||
if (!sightCoords) {
|
||||
sightCoords = await geocode(finalAddress || finalName);
|
||||
}
|
||||
|
||||
if (!finalName) {
|
||||
finalName = finalAddress;
|
||||
}
|
||||
|
||||
// 3. Fahrzeit & Distanz via OSRM berechnen
|
||||
let distanceKm: number | null = null;
|
||||
let durationMin: number | null = null;
|
||||
|
||||
if (hotelCoords && sightCoords) {
|
||||
const drive = await getDriveInfo(hotelCoords, sightCoords);
|
||||
distanceKm = drive.distanceKm;
|
||||
durationMin = drive.durationMin;
|
||||
}
|
||||
|
||||
// 4. In Postgres speichern
|
||||
const insertRes = await query(
|
||||
`INSERT INTO hotel_attractions (hotel_id, name, address, category_icon, notes, distance_km, duration_minutes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING *`,
|
||||
[hotelId, finalName, finalAddress, category_icon || '🏛️', notes || null, distanceKm, durationMin]
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true, attraction: insertRes.rows[0] });
|
||||
} catch (e: any) {
|
||||
console.error('Fehler beim Anlegen der Sehenswürdigkeit:', e);
|
||||
return NextResponse.json({ success: false, error: e.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
30
src/app/api/hotels/[id]/prices/route.ts
Normal file
30
src/app/api/hotels/[id]/prices/route.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { query } from '@/lib/db';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const { id } = params;
|
||||
|
||||
const res = await query(
|
||||
`SELECT
|
||||
id,
|
||||
price_numeric::float AS price,
|
||||
price_per_night::float AS price_per_night,
|
||||
price_per_person::float AS price_per_person,
|
||||
nights,
|
||||
persons,
|
||||
tracked_at AS created_at
|
||||
FROM hotel_price_history
|
||||
WHERE hotel_id::text = $1::text
|
||||
ORDER BY tracked_at ASC`,
|
||||
[id]
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true, prices: res.rows });
|
||||
} catch (error: any) {
|
||||
console.error('Fehler beim Laden der Preishistorie:', error.message);
|
||||
return NextResponse.json({ success: false, error: error.message, prices: [] }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { query } from '@/lib/db';
|
||||
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const { id } = params;
|
||||
const { summary_positive, summary_passable, summary_negative, summary_bathroom } = await req.json();
|
||||
const { summary_positive, summary_passable, summary_negative, summary_bathroom, summary_count } = await req.json();
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'Hotel-ID fehlt' }, { status: 400 });
|
||||
@@ -16,9 +16,10 @@ export async function POST(req: NextRequest, { params }: { params: { id: string
|
||||
review_summary_passable = $2,
|
||||
review_summary_negative = $3,
|
||||
review_summary_bathroom = $4,
|
||||
review_summary_count = $5,
|
||||
reviews_updated_at = NOW()
|
||||
WHERE id = $5`,
|
||||
[summary_positive, summary_passable, summary_negative, summary_bathroom || null, id]
|
||||
WHERE id = $6`,
|
||||
[summary_positive, summary_passable, summary_negative, summary_bathroom || null, summary_count || null, id]
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true, message: 'Review-Zusammenfassungen gespeichert.' });
|
||||
|
||||
64
src/app/api/hotels/[id]/scan/route.ts
Normal file
64
src/app/api/hotels/[id]/scan/route.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { query } from '@/lib/db';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const { id } = params;
|
||||
|
||||
const bullmq = typeof window === 'undefined' ? eval('require')('bullmq') : null;
|
||||
if (!bullmq || !bullmq.Queue) {
|
||||
throw new Error('BullMQ Queue Klasse konnte nicht geladen werden.');
|
||||
}
|
||||
|
||||
const { Queue } = bullmq;
|
||||
|
||||
const connection = {
|
||||
host: process.env.REDIS_HOST || '172.17.0.1',
|
||||
port: parseInt(process.env.REDIS_PORT || '6379', 10),
|
||||
connectTimeout: 5000,
|
||||
maxRetriesPerRequest: 3,
|
||||
};
|
||||
|
||||
const scrapeQueue = new Queue('scrape-jobs', { connection });
|
||||
|
||||
// Nur dieses eine Hotel laden
|
||||
const res = await query('SELECT id, booking_url, name FROM hotels WHERE id = $1', [id]);
|
||||
if (res.rows.length === 0) {
|
||||
await scrapeQueue.close();
|
||||
return NextResponse.json({ error: 'Hotel nicht gefunden' }, { status: 404 });
|
||||
}
|
||||
|
||||
const hotel = res.rows[0];
|
||||
|
||||
const n8nBaseUrl = process.env.APP_WEBHOOK_URL || 'https://n8n.prinz-pilaw.de/webhook-test/scrape-reviews';
|
||||
const n8nWebhookUrl = n8nBaseUrl.includes('?')
|
||||
? `${n8nBaseUrl}&hotelId=${hotel.id}`
|
||||
: `${n8nBaseUrl}?hotelId=${hotel.id}`;
|
||||
|
||||
const appWebhookUrl = `http://172.17.0.1:3002/api/webhooks/scraper?hotelId=${hotel.id}`;
|
||||
|
||||
// Nur DIESEN einzelnen Job in die Queue schreiben
|
||||
await scrapeQueue.add('scrape-hotel', {
|
||||
hotelId: hotel.id,
|
||||
url: hotel.booking_url,
|
||||
appWebhookUrl,
|
||||
webhookUrl: n8nWebhookUrl,
|
||||
});
|
||||
|
||||
await scrapeQueue.close();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Scrape-Job für "${hotel.name}" erfolgreich gestartet.`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Fehler beim Starten des Einzel-Scrapes:', error);
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,70 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { query } from '@/lib/db';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const res = await query(`
|
||||
SELECT
|
||||
h.*,
|
||||
t.name AS trip_name,
|
||||
ph.price_numeric::float AS latest_price,
|
||||
ph.price_per_night::float AS latest_price_per_night,
|
||||
ph.price_per_person::float AS latest_price_per_person,
|
||||
ph.nights AS latest_nights,
|
||||
ph.persons AS latest_persons
|
||||
FROM hotels h
|
||||
LEFT JOIN trips t ON h.trip_id = t.id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT price_numeric, price_per_night, price_per_person, nights, persons
|
||||
FROM hotel_price_history
|
||||
WHERE hotel_id::text = h.id::text
|
||||
ORDER BY tracked_at DESC NULLS LAST, id DESC
|
||||
LIMIT 1
|
||||
) ph ON true
|
||||
ORDER BY h.checkin_date ASC NULLS LAST, h.id ASC
|
||||
`);
|
||||
|
||||
return NextResponse.json({ success: true, hotels: res.rows });
|
||||
} catch (error: any) {
|
||||
console.error('Fehler in GET /api/hotels:', error.message);
|
||||
try {
|
||||
const fallback = await query(`SELECT * FROM hotels ORDER BY id ASC`);
|
||||
return NextResponse.json({ success: true, hotels: fallback.rows });
|
||||
} catch (fallbackErr: any) {
|
||||
return NextResponse.json({ success: false, error: error.message, hotels: [] }, { status: 500 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { trip_id, booking_url, name } = await req.json();
|
||||
if (!trip_id || !booking_url) {
|
||||
return NextResponse.json({ error: 'trip_id und booking_url sind erforderlich' }, { status: 400 });
|
||||
const { name, booking_url, trip_id } = await req.json();
|
||||
|
||||
if (!booking_url) {
|
||||
return NextResponse.json({ error: 'Booking.com URL erforderlich' }, { status: 400 });
|
||||
}
|
||||
|
||||
let checkin_date: string | null = null;
|
||||
let checkout_date: string | null = null;
|
||||
|
||||
try {
|
||||
const parsedUrl = new URL(booking_url);
|
||||
checkin_date = parsedUrl.searchParams.get('checkin');
|
||||
checkout_date = parsedUrl.searchParams.get('checkout');
|
||||
} catch (e) {}
|
||||
|
||||
const res = await query(
|
||||
`INSERT INTO hotels (trip_id, booking_url, name, is_active)
|
||||
VALUES ($1, $2, $3, true) RETURNING *`,
|
||||
[trip_id, booking_url, name || 'Booking Hotel']
|
||||
`INSERT INTO hotels (name, booking_url, checkin_date, checkout_date, trip_id, is_active)
|
||||
VALUES ($1, $2, $3, $4, $5, true)
|
||||
RETURNING *`,
|
||||
[name || 'Neues Hotel', booking_url, checkin_date, checkout_date, trip_id || null]
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true, hotel: res.rows[0] });
|
||||
} catch (e: any) {
|
||||
return NextResponse.json({ success: false, error: e.message }, { status: 500 });
|
||||
} catch (error: any) {
|
||||
console.error('Fehler in POST /api/hotels:', error.message);
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,21 +33,15 @@ export async function POST() {
|
||||
});
|
||||
}
|
||||
|
||||
const n8nBaseUrl = process.env.APP_WEBHOOK_URL || 'https://n8n.prinz-pilaw.de/webhook-test/scrape-reviews';
|
||||
|
||||
// Für ALLE aktiven Hotels nur den Preis-Scan anwerfen (OHNE n8n / OHNE LLM)
|
||||
for (const hotel of activeHotels) {
|
||||
const n8nWebhookUrl = n8nBaseUrl.includes('?')
|
||||
? `${n8nBaseUrl}&hotelId=${hotel.id}`
|
||||
: `${n8nBaseUrl}?hotelId=${hotel.id}`;
|
||||
|
||||
// Direkt-URL zur Next.js App für Preishistorie
|
||||
const appWebhookUrl = `http://172.17.0.1:3002/api/webhooks/scraper?hotelId=${hotel.id}`;
|
||||
|
||||
await scrapeQueue.add('scrape-hotel', {
|
||||
hotelId: hotel.id,
|
||||
url: hotel.booking_url,
|
||||
appWebhookUrl, // Für Preishistorie in Postgres
|
||||
webhookUrl: n8nWebhookUrl, // Für n8n KI-Reviews
|
||||
appWebhookUrl, // Speichert den Preis in Postgres
|
||||
webhookUrl: null, // KEIN n8n -> KOSTENLOS!
|
||||
});
|
||||
}
|
||||
|
||||
@@ -55,13 +49,10 @@ export async function POST() {
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `${activeHotels.length} Scrape-Jobs erfolgreich gestartet.`,
|
||||
message: `Preis-Scan für ${activeHotels.length} Hotels gestartet.`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Fehler beim Starten der Jobs:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
console.error('Fehler beim Starten des Preis-Scans:', error);
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,88 +1,31 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { query } from '@/lib/db';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const tripsRes = await query('SELECT * FROM trips ORDER BY created_at DESC');
|
||||
const trips = tripsRes.rows;
|
||||
|
||||
const hotelsRes = await query(`
|
||||
SELECT h.*,
|
||||
ph.price_numeric as latest_price,
|
||||
ph.genius_price_numeric as latest_genius_price,
|
||||
ph.price_per_night,
|
||||
ph.price_per_person,
|
||||
ph.nights,
|
||||
ph.persons,
|
||||
ph.tracked_at as last_tracked
|
||||
FROM hotels h
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT * FROM hotel_price_history
|
||||
WHERE hotel_id = h.id
|
||||
ORDER BY tracked_at DESC LIMIT 1
|
||||
) ph ON true
|
||||
ORDER BY h.created_at DESC
|
||||
`);
|
||||
|
||||
// Zahlen-Typen parsen & fehlende Raten bei älteren Einträgen live berechnen
|
||||
const parsedHotels = hotelsRes.rows.map((h) => {
|
||||
const latest_price = h.latest_price ? parseFloat(h.latest_price) : null;
|
||||
let latest_genius_price = h.latest_genius_price ? parseFloat(h.latest_genius_price) : null;
|
||||
if (!latest_genius_price && latest_price) {
|
||||
latest_genius_price = parseFloat((latest_price * 0.90).toFixed(2));
|
||||
}
|
||||
|
||||
const nights = h.nights ? parseInt(h.nights, 10) : 1;
|
||||
const persons = h.persons ? parseInt(h.persons, 10) : 2;
|
||||
|
||||
const activePrice = latest_genius_price || latest_price;
|
||||
|
||||
// Fallback-Berechnung falls DB-Feld NULL ist
|
||||
let price_per_night = h.price_per_night ? parseFloat(h.price_per_night) : null;
|
||||
if (!price_per_night && activePrice && nights > 0) {
|
||||
price_per_night = parseFloat((activePrice / nights).toFixed(2));
|
||||
}
|
||||
|
||||
let price_per_person = h.price_per_person ? parseFloat(h.price_per_person) : null;
|
||||
if (!price_per_person && activePrice && nights > 0 && persons > 0) {
|
||||
price_per_person = parseFloat((activePrice / (nights * persons)).toFixed(2));
|
||||
}
|
||||
|
||||
return {
|
||||
...h,
|
||||
rating: h.rating ? parseFloat(h.rating) : null,
|
||||
latest_price,
|
||||
latest_genius_price,
|
||||
price_per_night,
|
||||
price_per_person,
|
||||
nights,
|
||||
persons,
|
||||
};
|
||||
});
|
||||
|
||||
const tripsWithHotels = trips.map((trip) => ({
|
||||
...trip,
|
||||
hotels: parsedHotels.filter((h) => h.trip_id === trip.id),
|
||||
}));
|
||||
|
||||
return NextResponse.json({ success: true, trips: tripsWithHotels });
|
||||
} catch (e: any) {
|
||||
console.error('Error in GET /api/trips:', e);
|
||||
return NextResponse.json({ success: false, error: e.message }, { status: 500 });
|
||||
const res = await query(`SELECT * FROM trips ORDER BY id ASC`);
|
||||
return NextResponse.json({ success: true, trips: res.rows });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ success: false, error: error.message, trips: [] }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { title, destination } = await req.json();
|
||||
if (!title) return NextResponse.json({ error: 'Titel ist erforderlich' }, { status: 400 });
|
||||
const { name } = await req.json();
|
||||
if (!name) {
|
||||
return NextResponse.json({ error: 'Name der Reise erforderlich' }, { status: 400 });
|
||||
}
|
||||
|
||||
const res = await query(
|
||||
'INSERT INTO trips (title, destination) VALUES ($1, $2) RETURNING *',
|
||||
[title, destination || null]
|
||||
`INSERT INTO trips (name) VALUES ($1) RETURNING *`,
|
||||
[name]
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true, trip: res.rows[0] });
|
||||
} catch (e: any) {
|
||||
return NextResponse.json({ success: false, error: e.message }, { status: 500 });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,75 +1,52 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { query } from '@/lib/db';
|
||||
|
||||
function parseGermanPrice(priceStr: any): number | null {
|
||||
if (!priceStr) return null;
|
||||
if (typeof priceStr === 'number') return Math.round(priceStr);
|
||||
|
||||
let cleaned = String(priceStr).replace(/[^0-9.,]/g, '').trim();
|
||||
if (!cleaned) return null;
|
||||
|
||||
if (cleaned.includes('.') && !cleaned.includes(',')) {
|
||||
cleaned = cleaned.replace(/\./g, '');
|
||||
} else if (cleaned.includes('.') && cleaned.includes(',')) {
|
||||
cleaned = cleaned.replace(/\./g, '').replace(',', '.');
|
||||
} else if (cleaned.includes(',')) {
|
||||
cleaned = cleaned.replace(',', '.');
|
||||
}
|
||||
|
||||
const parsed = parseFloat(cleaned);
|
||||
return isNaN(parsed) ? null : Math.round(parsed);
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const { searchParams } = new URL(req.url);
|
||||
const hotelId = searchParams.get('hotelId');
|
||||
const body = await req.json();
|
||||
|
||||
const {
|
||||
price,
|
||||
roomSizeM2,
|
||||
hotelName,
|
||||
rating,
|
||||
reviewCount,
|
||||
address,
|
||||
image,
|
||||
nights = 1,
|
||||
persons = 2,
|
||||
} = body;
|
||||
const hotelId = searchParams.get('hotelId') || body.hotelId;
|
||||
|
||||
if (!hotelId) {
|
||||
return NextResponse.json({ error: 'hotelId fehlt' }, { status: 400 });
|
||||
return NextResponse.json({ success: false, error: 'hotelId fehlt' }, { status: 400 });
|
||||
}
|
||||
|
||||
const parseNum = (val: any): number | null => {
|
||||
if (typeof val === 'number') return val;
|
||||
if (!val || typeof val !== 'string') return null;
|
||||
const match = val.replace(',', '.').match(/\d+(\.\d+)?/);
|
||||
return match ? parseFloat(match[0]) : null;
|
||||
};
|
||||
const numericPrice = parseGermanPrice(body.price);
|
||||
|
||||
const numPrice = parseNum(price);
|
||||
|
||||
// Automatisch 10 % Genius Rabatt berechnen
|
||||
let numGenius: number | null = null;
|
||||
if (numPrice !== null) {
|
||||
numGenius = parseFloat((numPrice * 0.90).toFixed(2));
|
||||
}
|
||||
|
||||
// Ratenberechnung auf Basis des Genius-Preises
|
||||
const basePrice = numGenius || numPrice;
|
||||
let pricePerNight = null;
|
||||
let pricePerPersonPerNight = null;
|
||||
|
||||
if (basePrice !== null && nights > 0 && persons > 0) {
|
||||
// 1. Zimmerpreis pro Nacht
|
||||
pricePerNight = parseFloat((basePrice / nights).toFixed(2));
|
||||
// 2. Preis pro Person & Nacht
|
||||
pricePerPersonPerNight = parseFloat((basePrice / (nights * persons)).toFixed(2));
|
||||
}
|
||||
|
||||
// 1. In Preishistorie eintragen
|
||||
if (numPrice !== null) {
|
||||
// 1. In hotel_price_history eintragen mit echten Spalten
|
||||
if (numericPrice !== null && numericPrice > 0) {
|
||||
try {
|
||||
await query(
|
||||
`INSERT INTO hotel_price_history
|
||||
(hotel_id, price_numeric, genius_price_numeric, price_raw, price_per_night, price_per_person, nights, persons)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
[
|
||||
hotelId,
|
||||
numPrice,
|
||||
numGenius,
|
||||
price ? String(price) : null,
|
||||
pricePerNight,
|
||||
pricePerPersonPerNight,
|
||||
nights,
|
||||
persons,
|
||||
]
|
||||
`INSERT INTO hotel_price_history (hotel_id, price_numeric, price_raw, currency, nights, persons, tracked_at)
|
||||
VALUES ($1::uuid, $2, $3, 'EUR', $4, $5, NOW())`,
|
||||
[hotelId, numericPrice, String(body.price || ''), body.nights || 1, body.persons || 2]
|
||||
);
|
||||
} catch (err: any) {
|
||||
console.error('DB Insert Error (hotel_price_history):', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Hotel Stammdaten aktualisieren
|
||||
// 2. Stammdaten aktualisieren
|
||||
try {
|
||||
await query(
|
||||
`UPDATE hotels
|
||||
SET name = COALESCE($1, name),
|
||||
@@ -77,14 +54,26 @@ export async function POST(req: NextRequest) {
|
||||
review_count = COALESCE($3, review_count),
|
||||
address = COALESCE($4, address),
|
||||
image_url = COALESCE($5, image_url),
|
||||
room_size_m2 = COALESCE($6, room_size_m2)
|
||||
WHERE id = $7`,
|
||||
[hotelName, rating, reviewCount, address, image, roomSizeM2, hotelId]
|
||||
room_size_m2 = COALESCE($6, room_size_m2),
|
||||
updated_at = NOW()
|
||||
WHERE id = $7::uuid`,
|
||||
[
|
||||
body.hotelName || null,
|
||||
body.rating || null,
|
||||
body.reviewCount || null,
|
||||
body.address || null,
|
||||
body.image || null,
|
||||
body.roomSizeM2 || null,
|
||||
hotelId
|
||||
]
|
||||
);
|
||||
} catch (err: any) {
|
||||
console.error('DB Update Error (hotels):', err.message);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
return NextResponse.json({ success: true, hotelId, parsedPrice: numericPrice });
|
||||
} catch (error: any) {
|
||||
console.error('Webhook Error:', error);
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||
console.error('Allgemeiner Fehler im Webhook:', error.message);
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 200 });
|
||||
}
|
||||
}
|
||||
|
||||
1261
src/app/page.tsx
1261
src/app/page.tsx
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user