feat: add Telegram push notifications for price drops and best prices with test button
This commit is contained in:
46
src/app/api/telegram/test/route.ts
Normal file
46
src/app/api/telegram/test/route.ts
Normal file
@@ -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 = `🤖 <b>Telegram-Benachrichtigungstest</b>
|
||||
|
||||
✅ Die Verbindung zwischen deiner <b>Travel Tracker App</b> 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 });
|
||||
}
|
||||
}
|
||||
@@ -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())`,
|
||||
[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);
|
||||
}
|
||||
|
||||
@@ -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'}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* TELEGRAM TEST BUTTON */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={testTelegramNotification}
|
||||
disabled={isTestingTelegram}
|
||||
title="Sendet eine Test-Nachricht an den konfigurierten Telegram Bot"
|
||||
style={{
|
||||
background: '#0284c7',
|
||||
color: '#ffffff',
|
||||
border: 'none',
|
||||
padding: '0.55rem 0.95rem',
|
||||
borderRadius: '8px',
|
||||
fontWeight: '700',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.82rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.35rem',
|
||||
opacity: isTestingTelegram ? 0.6 : 1,
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
>
|
||||
<span>🔔</span>
|
||||
<span>{isTestingTelegram ? 'Sende...' : 'Telegram Test'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
111
src/lib/telegram.ts
Normal file
111
src/lib/telegram.ts
Normal file
@@ -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 = '📉 <b>PREIS GESUNKEN!</b>';
|
||||
if (isNewBestPrice) {
|
||||
badge = '🔥 <b>NEUER ALLZEIT-TIEFSTPREIS!</b>';
|
||||
}
|
||||
|
||||
let deltaStr = '';
|
||||
if (oldPrice !== null && oldPrice > 0 && oldPrice !== newPrice) {
|
||||
const diff = newPrice - oldPrice;
|
||||
const pct = Math.round(((oldPrice - newPrice) / oldPrice) * 100);
|
||||
deltaStr = `\n📉 <b>Ersparnis:</b> ${Math.abs(diff)} € (${pct}% günstiger! Vorher: ${oldPrice} €)`;
|
||||
}
|
||||
|
||||
let geniusStr = '';
|
||||
if (newGeniusPrice && newGeniusPrice > 0) {
|
||||
geniusStr = `\n💙 <b>Genius-Preis (-10%):</b> <b>${newGeniusPrice} €</b>`;
|
||||
}
|
||||
|
||||
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🗓️ <b>Reisezeitraum:</b> ${formatDate(checkinDate)} – ${formatDate(checkoutDate)}`;
|
||||
if (nights) {
|
||||
dateStr += ` (${nights} ${nights === 1 ? 'Nacht' : 'Nächte'}${persons ? ` · ${persons} Pers.` : ''})`;
|
||||
}
|
||||
}
|
||||
|
||||
const tripStr = tripName ? `\n🌍 <b>Reise:</b> ${tripName}` : '';
|
||||
|
||||
const message = `${badge}
|
||||
|
||||
🏨 <b>${hotelName}</b>${tripStr}
|
||||
💰 <b>Neuer Preis:</b> <b>${newPrice} €</b>${geniusStr}${deltaStr}${dateStr}
|
||||
|
||||
<a href="${bookingUrl}">👉 Direkt zu Booking.com öffnen ↗</a>`;
|
||||
|
||||
return sendTelegramMessage(message);
|
||||
}
|
||||
Reference in New Issue
Block a user