Compare commits
17 Commits
f522ae9953
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| fad74ddb9a | |||
| 4eb98ba2c7 | |||
| 9bae40b200 | |||
| dea0e1ad88 | |||
| f3b4f1b661 | |||
| 6225924c7b | |||
| 4efce7923a | |||
| 385ddf1092 | |||
| 2b13daf3a0 | |||
| 472d225fda | |||
| 9f5f4911f2 | |||
| 48ba6ae282 | |||
| e2d05e6809 | |||
| 90a7d8d3d2 | |||
| 550da31038 | |||
| 6b80d628f3 | |||
| fe9dfa4519 |
11
.env
11
.env
@@ -1,11 +0,0 @@
|
|||||||
# 1. Datenbank-Verbindung (Ersetze <POSTGRES_IP> und das Passwort)
|
|
||||||
DATABASE_URL=postgres://travel_user:B8NJUD0tu46wPiGpyspoU6D0Cx1NWg@192.168.178.103:5432/travel_data
|
|
||||||
|
|
||||||
# 2. URL deines Scraper-Microservices (Port 3000)
|
|
||||||
SCRAPER_API_URL=http://192.168.178.133:3000/scrape
|
|
||||||
|
|
||||||
# 3. Webhook-URL deiner Web-App für Rückmeldungen (Port 3001)
|
|
||||||
APP_WEBHOOK_URL=http://192.168.178.133:3002/api/webhooks/scraper
|
|
||||||
|
|
||||||
# 4. Port der Web-App
|
|
||||||
PORT=3002
|
|
||||||
58
.gitignore
vendored
Normal file
58
.gitignore
vendored
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# ==========================================
|
||||||
|
# 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?
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
llm_context.txt
|
||||||
|
*.log
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
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.
|
||||||
|
|
||||||
|
|||||||
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"
|
- "3002:3001"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
volumes:
|
||||||
|
# Spiegelt deinen lokalen Quellcode live in den Container:
|
||||||
|
- ./src:/app/src
|
||||||
|
- ./public:/app/public
|
||||||
|
command: npm run dev
|
||||||
3095
llm_context.txt
Normal file
3095
llm_context.txt
Normal file
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.
|
||||||
8
next.config.js
Normal file
8
next.config.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
experimental: {
|
||||||
|
serverComponentsExternalPackages: ['bullmq', 'ioredis'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = nextConfig;
|
||||||
2353
package-lock.json
generated
Normal file
2353
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
16
package.json
16
package.json
@@ -8,19 +8,21 @@
|
|||||||
"start": "next start -p 3001"
|
"start": "next start -p 3001"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"axios": "^1.7.0",
|
||||||
|
"bullmq": "^6.0.9",
|
||||||
|
"ioredis": "^6.0.0",
|
||||||
"next": "^14.2.0",
|
"next": "^14.2.0",
|
||||||
"react": "^18.3.0",
|
|
||||||
"react-dom": "^18.3.0",
|
|
||||||
"pg": "^8.11.5",
|
"pg": "^8.11.5",
|
||||||
"axios": "^1.7.0"
|
"react": "^18.3.0",
|
||||||
|
"react-dom": "^18.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
"@types/react": "^18.3.0",
|
|
||||||
"@types/pg": "^8.11.0",
|
"@types/pg": "^8.11.0",
|
||||||
"typescript": "^5.0.0",
|
"@types/react": "^18.3.0",
|
||||||
"tailwindcss": "^3.4.0",
|
|
||||||
"autoprefixer": "^10.4.0",
|
"autoprefixer": "^10.4.0",
|
||||||
"postcss": "^8.4.0"
|
"postcss": "^8.4.0",
|
||||||
|
"tailwindcss": "^3.4.0",
|
||||||
|
"typescript": "^5.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/app/api/hotels/[id]/prices/route.ts
Normal file
29
src/app/api/hotels/[id]/prices/route.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
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,
|
||||||
|
genius_price_numeric::float AS genius_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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/app/api/hotels/[id]/review-summary/route.ts
Normal file
30
src/app/api/hotels/[id]/review-summary/route.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/app/api/hotels/[id]/scan/route.ts
Normal file
75
src/app/api/hotels/[id]/scan/route.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
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 { 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}`;
|
||||||
|
|
||||||
|
// 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: 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);
|
||||||
|
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
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,21 +1,128 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { query } from '@/lib/db';
|
import { query } from '@/lib/db';
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export const runtime = 'nodejs';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const { trip_id, booking_url, name } = await req.json();
|
const res = await query(`
|
||||||
if (!trip_id || !booking_url) {
|
SELECT
|
||||||
return NextResponse.json({ error: 'trip_id und booking_url sind erforderlich' }, { status: 400 });
|
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.genius_price_numeric::float AS latest_genius_price,
|
||||||
|
ph.nights AS latest_nights,
|
||||||
|
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, 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
|
||||||
|
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);
|
||||||
|
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 });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
const res = await query(
|
}
|
||||||
`INSERT INTO hotels (trip_id, booking_url, name, is_active)
|
|
||||||
VALUES ($1, $2, $3, true) RETURNING *`,
|
export async function POST(req: NextRequest) {
|
||||||
[trip_id, booking_url, name || 'Booking Hotel']
|
try {
|
||||||
);
|
const { name, booking_url, trip_id } = await req.json();
|
||||||
|
if (!booking_url) {
|
||||||
return NextResponse.json({ success: true, hotel: res.rows[0] });
|
return NextResponse.json({ error: 'Booking.com URL erforderlich' }, { status: 400 });
|
||||||
} catch (e: any) {
|
}
|
||||||
return NextResponse.json({ success: false, error: e.message }, { status: 500 });
|
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]
|
||||||
|
);
|
||||||
|
|
||||||
|
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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,36 +1,58 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { query } from '@/lib/db';
|
import { query } from '@/lib/db';
|
||||||
import axios from 'axios';
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export async function POST() {
|
export async function POST() {
|
||||||
try {
|
try {
|
||||||
// 1. Alle aktiven Hotels aus der Datenbank holen
|
const bullmq = typeof window === 'undefined' ? eval('require')('bullmq') : null;
|
||||||
const result = await query(
|
if (!bullmq || !bullmq.Queue) {
|
||||||
'SELECT id, booking_url FROM hotels WHERE is_active = true'
|
throw new Error('BullMQ Queue Klasse konnte nicht geladen werden.');
|
||||||
);
|
|
||||||
const activeHotels = result.rows;
|
|
||||||
|
|
||||||
const scraperUrl = process.env.SCRAPER_API_URL || 'http://localhost:3000/scrape';
|
|
||||||
const webhookUrl = process.env.APP_WEBHOOK_URL || 'http://localhost:3001/api/webhooks/scraper';
|
|
||||||
|
|
||||||
let triggeredCount = 0;
|
|
||||||
|
|
||||||
// 2. Pro Hotel einen Scrape-Job an den Scraper-Microservice feuern
|
|
||||||
for (const hotel of activeHotels) {
|
|
||||||
await axios.post(scraperUrl, {
|
|
||||||
url: hotel.booking_url,
|
|
||||||
webhookUrl: `${webhookUrl}?hotelId=${hotel.id}` // Hotel-ID als URL-Param übergeben
|
|
||||||
});
|
|
||||||
triggeredCount++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: `${triggeredCount} Scrape-Jobs an den Scraper übergeben.`
|
message: `Preis-Scan für ${activeHotels.length} Hotels gestartet.`,
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Trigger Job Error:', error);
|
console.error('Fehler beim Starten des Preis-Scans:', error);
|
||||||
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
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,88 +1,31 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { query } from '@/lib/db';
|
import { query } from '@/lib/db';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const tripsRes = await query('SELECT * FROM trips ORDER BY created_at DESC');
|
const res = await query(`SELECT * FROM trips ORDER BY id ASC`);
|
||||||
const trips = tripsRes.rows;
|
return NextResponse.json({ success: true, trips: res.rows });
|
||||||
|
} catch (error: any) {
|
||||||
const hotelsRes = await query(`
|
return NextResponse.json({ success: false, error: error.message, trips: [] }, { status: 500 });
|
||||||
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 });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { title, destination } = await req.json();
|
const { name } = await req.json();
|
||||||
if (!title) return NextResponse.json({ error: 'Titel ist erforderlich' }, { status: 400 });
|
if (!name) {
|
||||||
|
return NextResponse.json({ error: 'Name der Reise erforderlich' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
const res = await query(
|
const res = await query(
|
||||||
'INSERT INTO trips (title, destination) VALUES ($1, $2) RETURNING *',
|
`INSERT INTO trips (name) VALUES ($1) RETURNING *`,
|
||||||
[title, destination || null]
|
[name]
|
||||||
);
|
);
|
||||||
|
|
||||||
return NextResponse.json({ success: true, trip: res.rows[0] });
|
return NextResponse.json({ success: true, trip: res.rows[0] });
|
||||||
} catch (e: any) {
|
} catch (error: any) {
|
||||||
return NextResponse.json({ success: false, error: e.message }, { status: 500 });
|
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,75 +1,105 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { query } from '@/lib/db';
|
import { query } from '@/lib/db';
|
||||||
|
import { notifyPriceChange } from '@/lib/telegram';
|
||||||
|
|
||||||
|
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) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const body = await req.json().catch(() => ({}));
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const hotelId = searchParams.get('hotelId');
|
const hotelId = searchParams.get('hotelId') || body.hotelId;
|
||||||
const body = await req.json();
|
|
||||||
|
|
||||||
const {
|
|
||||||
price,
|
|
||||||
roomSizeM2,
|
|
||||||
hotelName,
|
|
||||||
rating,
|
|
||||||
reviewCount,
|
|
||||||
address,
|
|
||||||
image,
|
|
||||||
nights = 1,
|
|
||||||
persons = 2,
|
|
||||||
} = body;
|
|
||||||
|
|
||||||
if (!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 => {
|
const numericPrice = parseGermanPrice(body.price);
|
||||||
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 numPrice = parseNum(price);
|
// 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);
|
||||||
|
|
||||||
// Automatisch 10 % Genius Rabatt berechnen
|
// Wenn Genius vorhanden ist: 10% abziehen und runden
|
||||||
let numGenius: number | null = null;
|
const geniusPriceNumeric = (numericPrice !== null && hasGeniusBadge)
|
||||||
if (numPrice !== null) {
|
? Math.round(numericPrice * 0.9)
|
||||||
numGenius = parseFloat((numPrice * 0.90).toFixed(2));
|
: null;
|
||||||
}
|
|
||||||
|
|
||||||
// Ratenberechnung auf Basis des Genius-Preises
|
if (numericPrice !== null && numericPrice > 0) {
|
||||||
const basePrice = numGenius || numPrice;
|
try {
|
||||||
let pricePerNight = null;
|
// Vorherigen Preis und bisherigen Allzeit-Tiefstpreis abfragen
|
||||||
let pricePerPersonPerNight = null;
|
const prevRes = await query(
|
||||||
|
`SELECT
|
||||||
if (basePrice !== null && nights > 0 && persons > 0) {
|
h.name, h.booking_url, h.checkin_date, h.checkout_date,
|
||||||
// 1. Zimmerpreis pro Nacht
|
t.name as trip_name,
|
||||||
pricePerNight = parseFloat((basePrice / nights).toFixed(2));
|
(SELECT price_numeric FROM hotel_price_history WHERE hotel_id = h.id ORDER BY tracked_at DESC, id DESC LIMIT 1) as last_price,
|
||||||
// 2. Preis pro Person & Nacht
|
(SELECT MIN(price_numeric) FROM hotel_price_history WHERE hotel_id = h.id AND price_numeric > 0) as min_price
|
||||||
pricePerPersonPerNight = parseFloat((basePrice / (nights * persons)).toFixed(2));
|
FROM hotels h
|
||||||
}
|
LEFT JOIN trips t ON h.trip_id = t.id
|
||||||
|
WHERE h.id = $1::uuid`,
|
||||||
// 1. In Preishistorie eintragen
|
[hotelId]
|
||||||
if (numPrice !== null) {
|
|
||||||
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,
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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. Hotel 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(
|
await query(
|
||||||
`UPDATE hotels
|
`UPDATE hotels
|
||||||
SET name = COALESCE($1, name),
|
SET name = COALESCE($1, name),
|
||||||
@@ -77,14 +107,33 @@ export async function POST(req: NextRequest) {
|
|||||||
review_count = COALESCE($3, review_count),
|
review_count = COALESCE($3, review_count),
|
||||||
address = COALESCE($4, address),
|
address = COALESCE($4, address),
|
||||||
image_url = COALESCE($5, image_url),
|
image_url = COALESCE($5, image_url),
|
||||||
room_size_m2 = COALESCE($6, room_size_m2)
|
room_size_m2 = COALESCE($6, room_size_m2),
|
||||||
WHERE id = $7`,
|
bathroom_images = CASE
|
||||||
[hotelName, rating, reviewCount, address, image, roomSizeM2, hotelId]
|
-- 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 = $8::uuid`,
|
||||||
|
[
|
||||||
|
body.hotelName || null,
|
||||||
|
body.rating || null,
|
||||||
|
body.reviewCount || null,
|
||||||
|
body.address || null,
|
||||||
|
body.image || null,
|
||||||
|
body.roomSizeM2 || null,
|
||||||
|
bathroomJson,
|
||||||
|
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) {
|
} catch (error: any) {
|
||||||
console.error('Webhook Error:', error);
|
console.error('Allgemeiner Fehler im Webhook:', error.message);
|
||||||
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
return NextResponse.json({ success: false, error: error.message }, { status: 200 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
3003
src/app/page.tsx
3003
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