Compare commits
12 Commits
e2d05e6809
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| fad74ddb9a | |||
| 4eb98ba2c7 | |||
| 9bae40b200 | |||
| dea0e1ad88 | |||
| f3b4f1b661 | |||
| 6225924c7b | |||
| 4efce7923a | |||
| 385ddf1092 | |||
| 2b13daf3a0 | |||
| 472d225fda | |||
| 9f5f4911f2 | |||
| 48ba6ae282 |
21
AGENTS.md
Normal file
21
AGENTS.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Engineering & Scraping Guidelines
|
||||
|
||||
## 1. Git Commit & Repository Hygiene
|
||||
- **Conventional Commits:** Write clear, concise commit messages prefixed with standard types (e.g., `feat:`, `fix:`, `refactor:`, `docs:`).
|
||||
- **Exclude Transient Artifacts:** Never stage or commit generated debug files (e.g., `src/*.html`, `src/*.png`, large logs) to version control. Always maintain `.gitignore` to keep git packs lean and prevent remote unpacker errors on Gitea/GitHub.
|
||||
|
||||
## 2. Web Scraping & DOM Inspection Strategy
|
||||
- **Rendered DOM Dumping for Debugging:** When debugging extraction failures on dynamic or anti-bot protected pages:
|
||||
- Capture the post-render HTML via `await page.content()` and save it to `src/hotel-debug.html`.
|
||||
- Capture a synchronized screenshot (`src/hotel-debug.png`).
|
||||
- Inspect the raw rendered DOM to verify if the page was served as standard SSR, an A/B test SPA variant, or intercepted by a challenge (e.g. AWS WAF).
|
||||
- **Multi-Tier Extraction Hierarchy:** Always implement fallbacks in this order:
|
||||
1. **Deep JSON-LD Parsing:** Traverse all `<script type="application/ld+json">` tags, including `@graph` arrays and varied accommodation schemas (`Hotel`, `Resort`, `Apartment`, `Hostel`, `LodgingBusiness`, etc.).
|
||||
2. **DOM Selectors:** Fallback to standard test IDs, classes, and element hierarchy (`data-testid`, header titles, score components).
|
||||
3. **Meta Tags:** Fallback to OpenGraph and Twitter meta tags (`og:title`, `og:image`, `og:description`).
|
||||
4. **URL Slug Extraction:** As an ultimate safety net, derive clean human-readable names from the URL path if the HTML body was obstructed.
|
||||
- **Anti-Bot & WAF Handling:**
|
||||
- Sanitize input URLs by stripping transient tokens (`chal_t`, `force_referer`, session IDs).
|
||||
- Emulate full browser properties (`window.chrome`, `navigator.plugins`, `navigator.languages`).
|
||||
- Actively detect WAF challenge interstitial pages and wait for client-side JavaScript token computation and automatic redirection before evaluating the DOM.
|
||||
|
||||
63
README.md
63
README.md
@@ -0,0 +1,63 @@
|
||||
# 📍 Travel Dashboard & Hotel Ranking
|
||||
|
||||
A Next.js web application for comparing hotels, planning travel stops, tracking historical Booking.com prices (including Genius discounts), and rating accommodations with friends using German school grades (1–6).
|
||||
|
||||
---
|
||||
|
||||
## 🌟 Key Features
|
||||
|
||||
- **Trip Management & Hotel Assignment:** Organize hotels across multiple trips or unassigned stops. When adding a hotel, you can directly choose which trip to assign it to.
|
||||
- **Automated Metadata & Price Scraping:** Adding a Booking.com URL automatically triggers a background BullMQ job that fetches the hotel name, star rating, address, hero image, room size, and prices.
|
||||
- **On-Demand Stammdaten Refresh:** Each hotel card features a **"🔄 Stammdaten"** button to perform a fast re-scan of metadata and prices on demand.
|
||||
- **AI Review Summaries:** Integrates with local LLMs (via Ollama/n8n) to summarize up to 80+ guest reviews into categorized highlights (Positive, Neutral, Negative, Bathroom inspection).
|
||||
- **Bathroom Photos & Google Maps Inspection:** Upload manual bathroom photos or inspect Google Maps imagery to assess room cleanliness.
|
||||
- **Price History & Genius Tracking:** Records price changes over time with automatic detection of Genius discount badges (-10%).
|
||||
- **Sights & Attractions:** Manage points of interest and calculate distances/durations from each hotel.
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Architecture & Stack
|
||||
|
||||
- **Frontend & API:** Next.js (App Router, TypeScript, React)
|
||||
- **Database:** PostgreSQL
|
||||
- **Task Queue:** BullMQ / Redis (shared with `scraper-service`)
|
||||
- **Containerization:** Docker & Docker Compose
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
### Prerequisites
|
||||
- Docker & Docker Compose
|
||||
- Running PostgreSQL & Redis instances
|
||||
|
||||
### Environment Variables (`.env`)
|
||||
```env
|
||||
DATABASE_URL=postgresql://user:password@host:5432/travel_db
|
||||
REDIS_HOST=172.17.0.1
|
||||
REDIS_PORT=6379
|
||||
APP_WEBHOOK_URL=https://n8n.your-domain.de/webhook/scrape-reviews
|
||||
```
|
||||
|
||||
### Running with Docker Compose
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
The app will be accessible at `http://localhost:3002`.
|
||||
|
||||
---
|
||||
|
||||
## 📡 API Endpoints
|
||||
|
||||
- `GET /api/hotels` — List all hotels, current prices, and ratings.
|
||||
- `POST /api/hotels` — Create a new hotel and enqueue background metadata scraping.
|
||||
- `PATCH /api/hotels/[id]` — Update hotel properties (e.g. active/parked status).
|
||||
- `POST /api/hotels/[id]/scan` — Trigger on-demand scraping:
|
||||
- `?mode=stammdaten` (or `mode=quick`): Fast scan for name, address, rating, price, and images.
|
||||
- Default: Full review scan with n8n/LLM review summary.
|
||||
- `?debug=true`: Generates raw HTML and screenshot dumps in `scraper-service`.
|
||||
- `POST /api/jobs/trigger-daily-check` — Enqueue batch price updates for all active hotels.
|
||||
- `POST /api/webhooks/scraper` — Webhook receiver for scraped metadata from `scraper-service`.
|
||||
- `GET /api/trips` / `POST /api/trips` — Manage trip entities.
|
||||
- `GET /api/attractions` / `POST /api/attractions` — Manage central sights and hotel assignments.
|
||||
|
||||
|
||||
2237
llm_context.txt
2237
llm_context.txt
File diff suppressed because it is too large
Load Diff
5
next-env.d.ts
vendored
Normal file
5
next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||
@@ -6,11 +6,11 @@ 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,
|
||||
genius_price_numeric::float AS genius_price,
|
||||
price_per_night::float AS price_per_night,
|
||||
price_per_person::float AS price_per_person,
|
||||
nights,
|
||||
@@ -21,7 +21,6 @@ export async function GET(req: NextRequest, { params }: { params: { id: string }
|
||||
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);
|
||||
|
||||
27
src/app/api/hotels/[id]/ratings/route.ts
Normal file
27
src/app/api/hotels/[id]/ratings/route.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { query } from '@/lib/db';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
// POST: Bewertung speichern oder aktualisieren
|
||||
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const { member_id, grade, comment } = await req.json();
|
||||
if (!member_id || !grade) {
|
||||
return NextResponse.json({ error: 'Mitreisender und Note erforderlich' }, { status: 400 });
|
||||
}
|
||||
|
||||
const res = await query(
|
||||
`INSERT INTO hotel_ratings (hotel_id, member_id, grade, comment, updated_at)
|
||||
VALUES ($1, $2, $3, $4, NOW())
|
||||
ON CONFLICT (hotel_id, member_id)
|
||||
DO UPDATE SET grade = EXCLUDED.grade, comment = EXCLUDED.comment, updated_at = NOW()
|
||||
RETURNING *`,
|
||||
[params.id, member_id, grade, comment ? comment.trim() : null]
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true, rating: res.rows[0] });
|
||||
} catch (e: any) {
|
||||
return NextResponse.json({ success: false, error: e.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -36,26 +36,37 @@ export async function POST(
|
||||
|
||||
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 { searchParams } = new URL(req.url);
|
||||
const mode = searchParams.get('mode');
|
||||
const isQuick = mode === 'stammdaten' || mode === 'quick';
|
||||
const isDebug = searchParams.get('debug') === 'true' || searchParams.get('debug') === '1';
|
||||
|
||||
let n8nWebhookUrl: string | null = null;
|
||||
if (!isQuick) {
|
||||
const n8nBaseUrl = process.env.APP_WEBHOOK_URL || 'https://n8n.prinz-pilaw.de/webhook-test/scrape-reviews';
|
||||
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
|
||||
// Job in die Queue schreiben
|
||||
await scrapeQueue.add('scrape-hotel', {
|
||||
hotelId: hotel.id,
|
||||
url: hotel.booking_url,
|
||||
appWebhookUrl,
|
||||
webhookUrl: n8nWebhookUrl,
|
||||
debug: isDebug,
|
||||
});
|
||||
|
||||
await scrapeQueue.close();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Scrape-Job für "${hotel.name}" erfolgreich gestartet.`,
|
||||
message: isQuick
|
||||
? `Stammdaten-Scan für "${hotel.name}" erfolgreich gestartet.`
|
||||
: `KI-Review-Job für "${hotel.name}" erfolgreich gestartet.`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Fehler beim Starten des Einzel-Scrapes:', error);
|
||||
|
||||
113
src/app/api/hotels/bulk/route.ts
Normal file
113
src/app/api/hotels/bulk/route.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
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) {
|
||||
try {
|
||||
const { urls, bulk_text, trip_id } = await req.json();
|
||||
|
||||
let rawUrls: string[] = [];
|
||||
if (Array.isArray(urls)) {
|
||||
rawUrls = urls;
|
||||
} else if (typeof bulk_text === 'string') {
|
||||
rawUrls = bulk_text
|
||||
.split(/[\r\n,\s]+/)
|
||||
.map(u => u.trim())
|
||||
.filter(u => u.length > 0);
|
||||
}
|
||||
|
||||
// Filter valid Booking.com URLs
|
||||
const validUrls = Array.from(
|
||||
new Set(
|
||||
rawUrls.filter(u => {
|
||||
try {
|
||||
const parsed = new URL(u);
|
||||
return parsed.hostname.includes('booking.com') && parsed.pathname.includes('/hotel/');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
if (validUrls.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Keine gültigen Booking.com Hotel-Links gefunden.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const insertedHotels = [];
|
||||
const bullmq = typeof window === 'undefined' ? eval('require')('bullmq') : null;
|
||||
let scrapeQueue: any = null;
|
||||
|
||||
if (bullmq && bullmq.Queue) {
|
||||
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,
|
||||
};
|
||||
scrapeQueue = new Queue('scrape-jobs', { connection });
|
||||
}
|
||||
|
||||
for (const bookingUrl of validUrls) {
|
||||
let checkin_date: string | null = null;
|
||||
let checkout_date: string | null = null;
|
||||
let hotelName = 'Neues Hotel';
|
||||
|
||||
try {
|
||||
const parsed = new URL(bookingUrl);
|
||||
checkin_date = parsed.searchParams.get('checkin');
|
||||
checkout_date = parsed.searchParams.get('checkout');
|
||||
const slugMatch = parsed.pathname.match(/\/hotel\/[a-z]{2}\/([^.]+)/i);
|
||||
if (slugMatch && slugMatch[1]) {
|
||||
hotelName = slugMatch[1]
|
||||
.replace(/-/g, ' ')
|
||||
.replace(/\b\w/g, c => c.toUpperCase());
|
||||
}
|
||||
} catch {}
|
||||
|
||||
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 *`,
|
||||
[hotelName, bookingUrl, checkin_date, checkout_date, trip_id || null]
|
||||
);
|
||||
|
||||
const newHotel = res.rows[0];
|
||||
insertedHotels.push(newHotel);
|
||||
|
||||
if (scrapeQueue) {
|
||||
try {
|
||||
const appWebhookUrl = `http://172.17.0.1:3002/api/webhooks/scraper?hotelId=${newHotel.id}`;
|
||||
await scrapeQueue.add('scrape-hotel', {
|
||||
hotelId: newHotel.id,
|
||||
url: newHotel.booking_url,
|
||||
appWebhookUrl,
|
||||
webhookUrl: null,
|
||||
});
|
||||
} catch (queueErr: any) {
|
||||
console.error(`Fehler beim Einreihen von Hotel ${newHotel.id}:`, queueErr.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (scrapeQueue) {
|
||||
await scrapeQueue.close().catch(() => {});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `${insertedHotels.length} Hotel(s) erfolgreich importiert und zur Erfassung eingereiht!`,
|
||||
count: insertedHotels.length,
|
||||
hotels: insertedHotels,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Fehler in POST /api/hotels/bulk:', error.message);
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { query } from '@/lib/db';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET() {
|
||||
@@ -12,20 +13,52 @@ export async function GET() {
|
||||
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.genius_price_numeric::float AS latest_genius_price,
|
||||
ph.nights AS latest_nights,
|
||||
ph.persons AS latest_persons
|
||||
ph.persons AS latest_persons,
|
||||
bp.best_price,
|
||||
bp.best_genius_price,
|
||||
hr.avg_grade,
|
||||
hr.total_votes,
|
||||
hr.ratings_json
|
||||
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
|
||||
SELECT price_numeric, genius_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
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
MIN(price_numeric)::float AS best_price,
|
||||
MIN(genius_price_numeric)::float AS best_genius_price
|
||||
FROM hotel_price_history
|
||||
WHERE hotel_id::text = h.id::text AND price_numeric > 0
|
||||
) bp ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
ROUND(AVG(hr_sub.grade), 1)::float AS avg_grade,
|
||||
COUNT(hr_sub.id)::int AS total_votes,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'member_id', hr_sub.member_id,
|
||||
'name', tm.name,
|
||||
'color', tm.color,
|
||||
'grade', hr_sub.grade,
|
||||
'comment', hr_sub.comment
|
||||
)
|
||||
ORDER BY hr_sub.updated_at DESC
|
||||
),
|
||||
'[]'
|
||||
) AS ratings_json
|
||||
FROM hotel_ratings hr_sub
|
||||
JOIN trip_members tm ON hr_sub.member_id = tm.id
|
||||
WHERE hr_sub.hotel_id = h.id
|
||||
) hr ON true
|
||||
`);
|
||||
|
||||
return NextResponse.json({ success: true, hotels: res.rows });
|
||||
} catch (error: any) {
|
||||
console.error('Fehler in GET /api/hotels:', error.message);
|
||||
@@ -41,20 +74,16 @@ export async function GET() {
|
||||
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)
|
||||
@@ -62,7 +91,36 @@ export async function POST(req: NextRequest) {
|
||||
[name || 'Neues Hotel', booking_url, checkin_date, checkout_date, trip_id || null]
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true, hotel: res.rows[0] });
|
||||
const newHotel = res.rows[0];
|
||||
|
||||
// Trigger background scraper job to fetch hotel details (name, rating, price, images, address, etc.)
|
||||
try {
|
||||
const bullmq = typeof window === 'undefined' ? eval('require')('bullmq') : null;
|
||||
if (bullmq && bullmq.Queue) {
|
||||
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 appWebhookUrl = `http://172.17.0.1:3002/api/webhooks/scraper?hotelId=${newHotel.id}`;
|
||||
|
||||
await scrapeQueue.add('scrape-hotel', {
|
||||
hotelId: newHotel.id,
|
||||
url: newHotel.booking_url,
|
||||
appWebhookUrl,
|
||||
webhookUrl: null,
|
||||
});
|
||||
|
||||
await scrapeQueue.close();
|
||||
}
|
||||
} catch (queueErr: any) {
|
||||
console.error('Fehler beim Einreihen des Scrape-Jobs nach Hotel-Erstellung:', queueErr.message);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, hotel: newHotel });
|
||||
} catch (error: any) {
|
||||
console.error('Fehler in POST /api/hotels:', error.message);
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
55
src/app/api/trips/[id]/members/route.ts
Normal file
55
src/app/api/trips/[id]/members/route.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { query } from '@/lib/db';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
// GET: Alle Mitreisenden eines Trips laden
|
||||
export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const res = await query(
|
||||
`SELECT * FROM trip_members WHERE trip_id = $1 ORDER BY created_at ASC`,
|
||||
[params.id]
|
||||
);
|
||||
return NextResponse.json({ success: true, members: res.rows });
|
||||
} catch (e: any) {
|
||||
return NextResponse.json({ success: false, error: e.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST: Neuen Mitreisenden hinzufügen
|
||||
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const { name, color } = await req.json();
|
||||
if (!name) {
|
||||
return NextResponse.json({ error: 'Name ist erforderlich' }, { status: 400 });
|
||||
}
|
||||
|
||||
const defaultColors = ['#2563eb', '#db2777', '#059669', '#d97706', '#7c3aed', '#0891b2'];
|
||||
const randomColor = color || defaultColors[Math.floor(Math.random() * defaultColors.length)];
|
||||
|
||||
const res = await query(
|
||||
`INSERT INTO trip_members (trip_id, name, color)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING *`,
|
||||
[params.id, name.trim(), randomColor]
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true, member: res.rows[0] });
|
||||
} catch (e: any) {
|
||||
return NextResponse.json({ success: false, error: e.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE: Mitreisenden entfernen
|
||||
export async function DELETE(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const memberId = searchParams.get('memberId');
|
||||
if (!memberId) return NextResponse.json({ error: 'memberId fehlt' }, { status: 400 });
|
||||
|
||||
await query(`DELETE FROM trip_members WHERE id = $1`, [memberId]);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (e: any) {
|
||||
return NextResponse.json({ success: false, error: e.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;
|
||||
@@ -32,21 +33,73 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const numericPrice = parseGermanPrice(body.price);
|
||||
|
||||
// 1. In hotel_price_history eintragen mit echten Spalten
|
||||
// Prüfen, ob das Genius-Badge vom Scraper erkannt wurde (z.B. body.hasGenius oder body.isGenius)
|
||||
const hasGeniusBadge = Boolean(body.hasGenius || body.isGenius || body.genius);
|
||||
|
||||
// Wenn Genius vorhanden ist: 10% abziehen und runden
|
||||
const geniusPriceNumeric = (numericPrice !== null && hasGeniusBadge)
|
||||
? Math.round(numericPrice * 0.9)
|
||||
: null;
|
||||
|
||||
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]
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Stammdaten aktualisieren
|
||||
// 2. Stammdaten & Bad-Bilder aktualisieren
|
||||
try {
|
||||
const bathroomJson = (Array.isArray(body.bathroomImages) && body.bathroomImages.length > 0)
|
||||
? JSON.stringify(body.bathroomImages)
|
||||
: null;
|
||||
|
||||
await query(
|
||||
`UPDATE hotels
|
||||
SET name = COALESCE($1, name),
|
||||
@@ -55,8 +108,14 @@ export async function POST(req: NextRequest) {
|
||||
address = COALESCE($4, address),
|
||||
image_url = COALESCE($5, image_url),
|
||||
room_size_m2 = COALESCE($6, room_size_m2),
|
||||
bathroom_images = CASE
|
||||
-- Nur befüllen, wenn bisher noch gar keine Bilder hinterlegt sind:
|
||||
WHEN (bathroom_images IS NULL OR jsonb_array_length(bathroom_images) = 0) AND $7::jsonb IS NOT NULL
|
||||
THEN $7::jsonb
|
||||
ELSE bathroom_images
|
||||
END,
|
||||
updated_at = NOW()
|
||||
WHERE id = $7::uuid`,
|
||||
WHERE id = $8::uuid`,
|
||||
[
|
||||
body.hotelName || null,
|
||||
body.rating || null,
|
||||
@@ -64,6 +123,7 @@ export async function POST(req: NextRequest) {
|
||||
body.address || null,
|
||||
body.image || null,
|
||||
body.roomSizeM2 || null,
|
||||
bathroomJson,
|
||||
hotelId
|
||||
]
|
||||
);
|
||||
|
||||
2903
src/app/page.tsx
2903
src/app/page.tsx
File diff suppressed because it is too large
Load Diff
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);
|
||||
}
|
||||
66
travel-app/src/app/api/hotels/[id]/bathroom-images/route.ts
Normal file
66
travel-app/src/app/api/hotels/[id]/bathroom-images/route.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { query } from '@/lib/db';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
// POST: Neues Bad-Bild (URL) hinzufügen
|
||||
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const { id } = params;
|
||||
const { image_url } = await req.json();
|
||||
|
||||
if (!image_url) {
|
||||
return NextResponse.json({ success: false, error: 'Keine Bild-URL angegeben' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Google Maps Link intelligent auf echte Bild-URL bereinigen
|
||||
let cleanUrl = image_url.trim();
|
||||
const embeddedMatch = cleanUrl.match(/!6s(https%3A%2F%2F[^!]+|https:\/\/[^!]+)/);
|
||||
if (embeddedMatch) {
|
||||
cleanUrl = decodeURIComponent(embeddedMatch[1]);
|
||||
}
|
||||
if (cleanUrl.includes('googleusercontent.com')) {
|
||||
cleanUrl = cleanUrl.replace(/=w\d+-h\d+[^=]*/, '=w1200-h800-k-no');
|
||||
}
|
||||
|
||||
const res = await query(
|
||||
`UPDATE hotels
|
||||
SET bathroom_images = COALESCE(bathroom_images, '[]'::jsonb) || jsonb_build_array($1::text)
|
||||
WHERE id = $2::uuid
|
||||
RETURNING bathroom_images`,
|
||||
[cleanUrl, id]
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true, bathroom_images: res.rows[0]?.bathroom_images });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE: Ein bestimmtes Bad-Bild löschen
|
||||
export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const { id } = params;
|
||||
const { image_url } = await req.json();
|
||||
|
||||
if (!image_url) {
|
||||
return NextResponse.json({ success: false, error: 'Keine Bild-URL zum Löschen angegeben' }, { status: 400 });
|
||||
}
|
||||
|
||||
const res = await query(
|
||||
`UPDATE hotels
|
||||
SET bathroom_images = (
|
||||
SELECT COALESCE(jsonb_agg(elem), '[]'::jsonb)
|
||||
FROM jsonb_array_elements_text(COALESCE(bathroom_images, '[]'::jsonb)) AS elem
|
||||
WHERE elem <> $1
|
||||
)
|
||||
WHERE id = $2::uuid
|
||||
RETURNING bathroom_images`,
|
||||
[image_url, id]
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true, bathroom_images: res.rows[0]?.bathroom_images });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user