======================================== PROJEKTSTRUKTUR ======================================== (Ermittelt via Git unter Berücksichtigung von .gitignore) copyChat.sh llm_context.txt src/app/api/hotels/[id]/prices/route.ts src/app/api/hotels/[id]/scan/route.ts .gitignore Dockerfile README.md docker-compose.yml next.config.js next.config.mjs package-lock.json package.json src/app/api/hotels/[id]/history/route.ts src/app/api/hotels/[id]/review-summary/route.ts src/app/api/hotels/[id]/route.ts src/app/api/hotels/route.ts src/app/api/jobs/trigger-daily-check/route.ts src/app/api/trips/route.ts src/app/api/webhooks/scraper/route.ts src/app/layout.tsx src/app/page.tsx src/lib/db.ts tsconfig.json ======================================== DATEIINHALTE ======================================== --- DATEI: .gitignore --- ``` # ========================================== # 1. Lokale Umgebungs- & Geheimdateien # ========================================== .env .env*.local .env.development.local .env.test.local .env.production.local # ========================================== # 2. Abhängigkeiten & Paketmanager # ========================================== node_modules/ .pnpm-store/ # ========================================== # 3. Next.js Build- & Output-Dateien # ========================================== .next/ out/ build/ dist/ # ========================================== # 4. Logs & temporäre Dateien # ========================================== npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* .pnpm-debug.log* *.log # ========================================== # 5. IDEs & Betriebssystem # ========================================== .DS_Store *.pem .vscode/* !.vscode/settings.json !.vscode/tasks.json !.vscode/launch.json !.vscode/extensions.json .idea/ *.suo *.ntvs* *.njsproj *.sln *.sw? ``` --- DATEI: Dockerfile --- ``` FROM node:20-alpine WORKDIR /app COPY package*.json ./ RUN npm install COPY . . RUN npm run build EXPOSE 3001 ENV PORT=3001 CMD ["npm", "start"] ``` --- DATEI: README.md --- ``` ``` --- DATEI: copyChat.sh --- ``` #!/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" ``` --- DATEI: docker-compose.yml --- ``` services: travel-app: build: . container_name: travel-app restart: always ports: - "3002:3001" env_file: - .env ``` --- DATEI: next.config.js --- ``` /** @type {import('next').NextConfig} */ const nextConfig = { serverExternalPackages: ['bullmq', 'ioredis'], }; module.exports = nextConfig; ``` --- DATEI: next.config.mjs --- ``` /** @type {import('next').NextConfig} */ const nextConfig = { serverExternalPackages: ['bullmq', 'ioredis'], }; export default nextConfig; ``` --- DATEI: package.json --- ``` { "name": "travel-planner-app", "version": "1.0.0", "private": true, "scripts": { "dev": "next dev -p 3001", "build": "next build", "start": "next start -p 3001" }, "dependencies": { "axios": "^1.7.0", "bullmq": "^6.0.9", "ioredis": "^6.0.0", "next": "^14.2.0", "pg": "^8.11.5", "react": "^18.3.0", "react-dom": "^18.3.0" }, "devDependencies": { "@types/node": "^20.0.0", "@types/pg": "^8.11.0", "@types/react": "^18.3.0", "autoprefixer": "^10.4.0", "postcss": "^8.4.0", "tailwindcss": "^3.4.0", "typescript": "^5.0.0" } } ``` --- DATEI: src/app/api/hotels/[id]/history/route.ts --- ``` import { NextRequest, NextResponse } from 'next/server'; import { query } from '@/lib/db'; export async function GET(req: NextRequest, { params }: { params: { id: string } }) { try { const { id } = params; const res = await query( `SELECT * FROM hotel_price_history WHERE hotel_id = $1 ORDER BY tracked_at DESC`, [id] ); // Alle SQL-NUMERIC Strings in echte JS-Numbers umwandeln const parsedHistory = res.rows.map((entry) => ({ ...entry, price_numeric: entry.price_numeric ? parseFloat(entry.price_numeric) : 0, genius_price_numeric: entry.genius_price_numeric ? parseFloat(entry.genius_price_numeric) : null, price_per_night: entry.price_per_night ? parseFloat(entry.price_per_night) : null, price_per_person: entry.price_per_person ? parseFloat(entry.price_per_person) : null, nights: entry.nights ? parseInt(entry.nights, 10) : 1, persons: entry.persons ? parseInt(entry.persons, 10) : 2, })); return NextResponse.json({ success: true, history: parsedHistory }); } catch (e: any) { console.error('Error fetching history:', e); return NextResponse.json({ success: false, error: e.message }, { status: 500 }); } } ``` --- DATEI: src/app/api/hotels/[id]/prices/route.ts --- ``` 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 }); } } ``` --- DATEI: src/app/api/hotels/[id]/review-summary/route.ts --- ``` import { NextRequest, NextResponse } from 'next/server'; 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, summary_count } = await req.json(); if (!id) { return NextResponse.json({ error: 'Hotel-ID fehlt' }, { status: 400 }); } await query( `UPDATE hotels SET review_summary_positive = $1, review_summary_passable = $2, review_summary_negative = $3, review_summary_bathroom = $4, review_summary_count = $5, reviews_updated_at = NOW() 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.' }); } catch (error: any) { console.error('Fehler beim Speichern der Review-Zusammenfassung:', error); return NextResponse.json({ success: false, error: error.message }, { status: 500 }); } } ``` --- DATEI: src/app/api/hotels/[id]/route.ts --- ``` import { NextRequest, NextResponse } from 'next/server'; import { query } from '@/lib/db'; // PATCH: Tracking aktivieren / deaktivieren (is_active) export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) { try { const { id } = params; const { is_active } = await req.json(); await query('UPDATE hotels SET is_active = $1 WHERE id = $2', [is_active, id]); return NextResponse.json({ success: true }); } catch (e: any) { return NextResponse.json({ success: false, error: e.message }, { status: 500 }); } } // DELETE: Hotel entfernen export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) { try { const { id } = params; await query('DELETE FROM hotels WHERE id = $1', [id]); return NextResponse.json({ success: true }); } catch (e: any) { return NextResponse.json({ success: false, error: e.message }, { status: 500 }); } } ``` --- DATEI: src/app/api/hotels/[id]/scan/route.ts --- ``` 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 }); } } ``` --- DATEI: src/app/api/hotels/route.ts --- ``` 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 { 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 (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 (error: any) { console.error('Fehler in POST /api/hotels:', error.message); return NextResponse.json({ success: false, error: error.message }, { status: 500 }); } } ``` --- DATEI: src/app/api/jobs/trigger-daily-check/route.ts --- ``` import { NextResponse } from 'next/server'; import { query } from '@/lib/db'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; export async function POST() { try { 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 }); const res = await query('SELECT id, booking_url FROM hotels WHERE is_active = true'); const activeHotels = res.rows; if (activeHotels.length === 0) { await scrapeQueue.close(); return NextResponse.json({ success: true, message: 'Keine aktiven Hotels zum Scrapen gefunden.', }); } // Für ALLE aktiven Hotels nur den Preis-Scan anwerfen (OHNE n8n / OHNE LLM) for (const hotel of activeHotels) { 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, // Speichert den Preis in Postgres webhookUrl: null, // KEIN n8n -> KOSTENLOS! }); } await scrapeQueue.close(); return NextResponse.json({ success: true, message: `Preis-Scan für ${activeHotels.length} Hotels gestartet.`, }); } catch (error: any) { console.error('Fehler beim Starten des Preis-Scans:', error); return NextResponse.json({ success: false, error: error.message }, { status: 500 }); } } ``` --- DATEI: src/app/api/trips/route.ts --- ``` 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 * 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 { name } = await req.json(); if (!name) { return NextResponse.json({ error: 'Name der Reise erforderlich' }, { status: 400 }); } const res = await query( `INSERT INTO trips (name) VALUES ($1) RETURNING *`, [name] ); return NextResponse.json({ success: true, trip: res.rows[0] }); } catch (error: any) { return NextResponse.json({ success: false, error: error.message }, { status: 500 }); } } ``` --- DATEI: src/app/api/webhooks/scraper/route.ts --- ``` 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') || body.hotelId; if (!hotelId) { return NextResponse.json({ success: false, error: 'hotelId fehlt' }, { status: 400 }); } const numericPrice = parseGermanPrice(body.price); // 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, 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. Stammdaten aktualisieren try { await query( `UPDATE hotels SET name = COALESCE($1, name), rating = COALESCE($2, rating), review_count = COALESCE($3, review_count), address = COALESCE($4, address), image_url = COALESCE($5, image_url), 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, hotelId, parsedPrice: numericPrice }); } catch (error: any) { console.error('Allgemeiner Fehler im Webhook:', error.message); return NextResponse.json({ success: false, error: error.message }, { status: 200 }); } } ``` --- DATEI: src/app/layout.tsx --- ``` import type { Metadata } from 'next'; export const metadata: Metadata = { title: 'Reiseplaner App', description: 'Hotelpreise und Reisen tracken', }; export default function RootLayout({ children, }: { children: React.ReactNode; }) { return (
{children} ); } ``` --- DATEI: src/app/page.tsx --- ``` 'use client'; import { useState, useEffect } from 'react'; interface Trip { id: string; name: string; } interface PriceEntry { id: string; price: number; price_per_night?: number | null; price_per_person?: number | null; nights: number; persons: number; created_at: string; } interface Hotel { id: string; name: string; booking_url: string; is_active: boolean; trip_id?: string | null; trip_name?: string | null; latest_price?: number | null; latest_price_per_night?: number | null; latest_price_per_person?: number | null; latest_nights?: number | null; latest_persons?: number | null; rating?: number | null; review_count?: number | null; image_url?: string | null; address?: string | null; room_size_m2?: number | null; review_summary_positive?: string | null; review_summary_passable?: string | null; review_summary_negative?: string | null; review_summary_bathroom?: string | null; review_summary_count?: number | null; reviews_updated_at?: string | null; checkin_date?: string | null; checkout_date?: string | null; } export default function TravelDashboard() { const [trips, setTrips] = useStateRoutenplanung, Preishistorie & KI-Bewertungen
Entwicklung aller erfassten Preis-Scans
Lade Preishistorie...
) : priceHistory.length === 0 ? (Noch keine Preishistorie vorhanden. Klicke oben auf "Preise aller Hotels aktualisieren".
) : (| Datum & Uhrzeit | Preis | Details |
|---|---|---|
| {new Date(p.created_at).toLocaleString('de-DE')} | {p.price} € | {p.nights || 1} Nächte, {p.persons || 2} Personen |
Check-in/Check-out Datum wird automatisch aus dem Link gelesen.
Lade Daten...
) : currentHotels.length === 0 ? (Keine Hotels für diese Auswahl gefunden.
📍 {hotel.address || 'Keine Adresse hinterlegt'}
{/* ANZEIGE DER DREI PREIS-WERTE */} {totalPrice !== null ? (