From 4eb98ba2c72a0ba21512450de809b1e356fc4eff Mon Sep 17 00:00:00 2001 From: gitea Date: Fri, 21 Aug 2026 22:41:17 +0000 Subject: [PATCH] feat: add Telegram push notifications for price drops and best prices with test button --- src/app/api/telegram/test/route.ts | 46 +++++++++++ src/app/api/webhooks/scraper/route.ts | 44 +++++++++- src/app/page.tsx | 46 +++++++++++ src/lib/telegram.ts | 111 ++++++++++++++++++++++++++ 4 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 src/app/api/telegram/test/route.ts create mode 100644 src/lib/telegram.ts diff --git a/src/app/api/telegram/test/route.ts b/src/app/api/telegram/test/route.ts new file mode 100644 index 0000000..1036080 --- /dev/null +++ b/src/app/api/telegram/test/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { sendTelegramMessage } from '@/lib/telegram'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function POST(req: NextRequest) { + try { + const botToken = process.env.TELEGRAM_BOT_TOKEN; + const chatId = process.env.TELEGRAM_CHAT_ID; + + if (!botToken || !chatId) { + return NextResponse.json({ + success: false, + error: 'TELEGRAM_BOT_TOKEN oder TELEGRAM_CHAT_ID ist in .env nicht gesetzt.', + configured: false, + }, { status: 400 }); + } + + const testMessage = `🤖 Telegram-Benachrichtigungstest + +✅ Die Verbindung zwischen deiner Travel Tracker App und dem Telegram Bot funktioniert einwandfrei! + +🔔 Sobald ein Hotelpreis sinkt oder ein neuer Bestpreis erreicht wird, erhältst du hier automatisch Push-Nachrichten.`; + + const res = await sendTelegramMessage(testMessage); + + if (res.success) { + return NextResponse.json({ + success: true, + message: 'Test-Nachricht wurde erfolgreich an Telegram gesendet!', + chatId: chatId.slice(0, 4) + '***', + }); + } else { + return NextResponse.json({ + success: false, + error: res.error || 'Fehler beim Senden an Telegram API', + }, { status: 500 }); + } + } catch (error: any) { + return NextResponse.json({ + success: false, + error: error.message, + }, { status: 500 }); + } +} diff --git a/src/app/api/webhooks/scraper/route.ts b/src/app/api/webhooks/scraper/route.ts index c6ce98b..77890d8 100644 --- a/src/app/api/webhooks/scraper/route.ts +++ b/src/app/api/webhooks/scraper/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { query } from '@/lib/db'; +import { notifyPriceChange } from '@/lib/telegram'; function parseGermanPrice(priceStr: any): number | null { if (!priceStr) return null; @@ -42,11 +43,52 @@ export async function POST(req: NextRequest) { if (numericPrice !== null && numericPrice > 0) { try { + // Vorherigen Preis und bisherigen Allzeit-Tiefstpreis abfragen + const prevRes = await query( + `SELECT + h.name, h.booking_url, h.checkin_date, h.checkout_date, + t.name as trip_name, + (SELECT price_numeric FROM hotel_price_history WHERE hotel_id = h.id ORDER BY tracked_at DESC, id DESC LIMIT 1) as last_price, + (SELECT MIN(price_numeric) FROM hotel_price_history WHERE hotel_id = h.id AND price_numeric > 0) as min_price + FROM hotels h + LEFT JOIN trips t ON h.trip_id = t.id + WHERE h.id = $1::uuid`, + [hotelId] + ); + + const hotelRecord = prevRes.rows[0] || null; + const previousPrice = hotelRecord?.last_price ? Math.round(Number(hotelRecord.last_price)) : null; + const allTimeBestPrice = hotelRecord?.min_price ? Math.round(Number(hotelRecord.min_price)) : null; + await query( `INSERT INTO hotel_price_history (hotel_id, price_numeric, genius_price_numeric, price_raw, currency, nights, persons, tracked_at) - VALUES ($1::uuid, $2, $3, $4, 'EUR', $5, $6, NOW())`, + VALUES ($1::uuid, $2, $3, $4, 'EUR', $5, $6, NOW())`, [hotelId, numericPrice, geniusPriceNumeric, String(body.price || ''), body.nights || 1, body.persons || 2] ); + + // Telegram Push-Benachrichtigung bei Preissenkung oder neuem Allzeit-Bestpreis + if (hotelRecord && previousPrice !== null && previousPrice > 0) { + const isPriceDrop = numericPrice < previousPrice; + const isNewBestPrice = allTimeBestPrice !== null ? (numericPrice < allTimeBestPrice) : true; + + if (isPriceDrop || isNewBestPrice) { + notifyPriceChange({ + hotelName: hotelRecord.name || body.hotelName || 'Hotel', + tripName: hotelRecord.trip_name || null, + oldPrice: previousPrice, + newPrice: numericPrice, + newGeniusPrice: geniusPriceNumeric, + allTimeBestPrice: allTimeBestPrice, + isNewBestPrice, + isPriceDrop, + checkinDate: hotelRecord.checkin_date || body.checkin || null, + checkoutDate: hotelRecord.checkout_date || body.checkout || null, + nights: body.nights || null, + persons: body.persons || null, + bookingUrl: hotelRecord.booking_url || body.url || '' + }).catch(e => console.error('[Telegram] Fehler beim Senden der Benachrichtigung:', e.message)); + } + } } catch (err: any) { console.error('DB Insert Error (hotel_price_history):', err.message); } diff --git a/src/app/page.tsx b/src/app/page.tsx index 31962cb..8dd2069 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -136,6 +136,26 @@ export default function TravelDashboard() { // Preishistorie Zeitraum Filter (7 Tage vs Gesamter Verlauf) const [priceHistoryRange, setPriceHistoryRange] = useState<'7d' | 'all'>('7d'); + // Telegram Test Status + const [isTestingTelegram, setIsTestingTelegram] = useState(false); + + const testTelegramNotification = async () => { + setIsTestingTelegram(true); + try { + const res = await fetch('/api/telegram/test', { method: 'POST' }); + const data = await res.json(); + if (data.success) { + alert('✅ Telegram-Test erfolgreich! Nachricht wurde an deinen Chat gesendet.'); + } else { + alert(`❌ Telegram-Test fehlgeschlagen:\n${data.error || 'Unbekannter Fehler'}\n\nBitte prüfe TELEGRAM_BOT_TOKEN und TELEGRAM_CHAT_ID in deiner .env Datei.`); + } + } catch (err: any) { + alert('Fehler beim Testen von Telegram: ' + err.message); + } finally { + setIsTestingTelegram(false); + } + }; + // Scraper Debug-Modus Toggle const [isDebugMode, setIsDebugMode] = useState(false); @@ -920,6 +940,32 @@ export default function TravelDashboard() { {isDebugMode ? 'AN' : 'AUS'} + + {/* TELEGRAM TEST BUTTON */} + diff --git a/src/lib/telegram.ts b/src/lib/telegram.ts new file mode 100644 index 0000000..653ad64 --- /dev/null +++ b/src/lib/telegram.ts @@ -0,0 +1,111 @@ +export interface PriceNotificationPayload { + hotelName: string; + tripName?: string | null; + oldPrice: number | null; + newPrice: number; + newGeniusPrice?: number | null; + allTimeBestPrice?: number | null; + isNewBestPrice: boolean; + isPriceDrop: boolean; + checkinDate?: string | null; + checkoutDate?: string | null; + nights?: number | null; + persons?: number | null; + bookingUrl: string; +} + +export async function sendTelegramMessage(htmlText: string): Promise<{ success: boolean; error?: string }> { + const botToken = process.env.TELEGRAM_BOT_TOKEN; + const chatId = process.env.TELEGRAM_CHAT_ID; + + if (!botToken || !chatId) { + console.log('[Telegram] Keine TELEGRAM_BOT_TOKEN oder TELEGRAM_CHAT_ID in .env gesetzt – überspringe Benachrichtigung.'); + return { success: false, error: 'Telegram Bot Token oder Chat ID nicht konfiguriert.' }; + } + + try { + const url = `https://api.telegram.org/bot${botToken}/sendMessage`; + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chat_id: chatId, + text: htmlText, + parse_mode: 'HTML', + disable_web_page_preview: false, + }), + }); + + const data = await response.json(); + if (!data.ok) { + console.error('[Telegram] API Fehler:', data.description); + return { success: false, error: data.description }; + } + + console.log('[Telegram] ✅ Nachricht erfolgreich versendet!'); + return { success: true }; + } catch (err: any) { + console.error('[Telegram] Netzwerk- oder Request-Fehler:', err.message); + return { success: false, error: err.message }; + } +} + +export async function notifyPriceChange(payload: PriceNotificationPayload): Promise<{ success: boolean; error?: string }> { + const { + hotelName, + tripName, + oldPrice, + newPrice, + newGeniusPrice, + isNewBestPrice, + isPriceDrop, + checkinDate, + checkoutDate, + nights, + persons, + bookingUrl + } = payload; + + let badge = '📉 PREIS GESUNKEN!'; + if (isNewBestPrice) { + badge = '🔥 NEUER ALLZEIT-TIEFSTPREIS!'; + } + + let deltaStr = ''; + if (oldPrice !== null && oldPrice > 0 && oldPrice !== newPrice) { + const diff = newPrice - oldPrice; + const pct = Math.round(((oldPrice - newPrice) / oldPrice) * 100); + deltaStr = `\n📉 Ersparnis: ${Math.abs(diff)} € (${pct}% günstiger! Vorher: ${oldPrice} €)`; + } + + let geniusStr = ''; + if (newGeniusPrice && newGeniusPrice > 0) { + geniusStr = `\n💙 Genius-Preis (-10%): ${newGeniusPrice} €`; + } + + let dateStr = ''; + if (checkinDate && checkoutDate) { + const formatDate = (d: string) => { + try { + return new Date(d).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); + } catch { + return d; + } + }; + dateStr = `\n🗓️ Reisezeitraum: ${formatDate(checkinDate)} – ${formatDate(checkoutDate)}`; + if (nights) { + dateStr += ` (${nights} ${nights === 1 ? 'Nacht' : 'Nächte'}${persons ? ` · ${persons} Pers.` : ''})`; + } + } + + const tripStr = tripName ? `\n🌍 Reise: ${tripName}` : ''; + + const message = `${badge} + +🏨 ${hotelName}${tripStr} +💰 Neuer Preis: ${newPrice} €${geniusStr}${deltaStr}${dateStr} + +👉 Direkt zu Booking.com öffnen ↗`; + + return sendTelegramMessage(message); +}