Initial commit — NOMAD (Navigation Organizer for Maps, Activities & Destinations)

Self-hosted travel planner with Express.js, SQLite, React & Tailwind CSS.
This commit is contained in:
Maurice
2026-03-18 23:58:08 +01:00
commit cb1e217bbe
100 changed files with 25545 additions and 0 deletions

View File

@@ -0,0 +1,82 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const { db } = require('../db/database');
const { authenticate, adminOnly } = require('../middleware/auth');
const router = express.Router();
// All admin routes require authentication and admin role
router.use(authenticate, adminOnly);
// GET /api/admin/users
router.get('/users', (req, res) => {
const users = db.prepare(
'SELECT id, username, email, role, maps_api_key, unsplash_api_key, openweather_api_key, created_at, updated_at FROM users ORDER BY created_at DESC'
).all();
res.json({ users });
});
// PUT /api/admin/users/:id
router.put('/users/:id', (req, res) => {
const { username, email, role, password } = req.body;
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
if (!user) return res.status(404).json({ error: 'Benutzer nicht gefunden' });
if (role && !['user', 'admin'].includes(role)) {
return res.status(400).json({ error: 'Ungültige Rolle' });
}
if (username && username !== user.username) {
const conflict = db.prepare('SELECT id FROM users WHERE username = ? AND id != ?').get(username, req.params.id);
if (conflict) return res.status(409).json({ error: 'Benutzername bereits vergeben' });
}
if (email && email !== user.email) {
const conflict = db.prepare('SELECT id FROM users WHERE email = ? AND id != ?').get(email, req.params.id);
if (conflict) return res.status(409).json({ error: 'E-Mail bereits vergeben' });
}
const passwordHash = password ? bcrypt.hashSync(password, 10) : null;
db.prepare(`
UPDATE users SET
username = COALESCE(?, username),
email = COALESCE(?, email),
role = COALESCE(?, role),
password_hash = COALESCE(?, password_hash),
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(username || null, email || null, role || null, passwordHash, req.params.id);
const updated = db.prepare(
'SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?'
).get(req.params.id);
res.json({ user: updated });
});
// DELETE /api/admin/users/:id
router.delete('/users/:id', (req, res) => {
if (parseInt(req.params.id) === req.user.id) {
return res.status(400).json({ error: 'Eigenes Konto kann nicht gelöscht werden' });
}
const user = db.prepare('SELECT id FROM users WHERE id = ?').get(req.params.id);
if (!user) return res.status(404).json({ error: 'Benutzer nicht gefunden' });
db.prepare('DELETE FROM users WHERE id = ?').run(req.params.id);
res.json({ success: true });
});
// GET /api/admin/stats
router.get('/stats', (req, res) => {
const totalUsers = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
const totalTrips = db.prepare('SELECT COUNT(*) as count FROM trips').get().count;
const totalPlaces = db.prepare('SELECT COUNT(*) as count FROM places').get().count;
const totalPhotos = db.prepare('SELECT COUNT(*) as count FROM photos').get().count;
const totalFiles = db.prepare('SELECT COUNT(*) as count FROM trip_files').get().count;
res.json({ totalUsers, totalTrips, totalPlaces, totalPhotos, totalFiles });
});
module.exports = router;

View File

@@ -0,0 +1,235 @@
const express = require('express');
const { db, canAccessTrip } = require('../db/database');
const { authenticate } = require('../middleware/auth');
const router = express.Router({ mergeParams: true });
function verifyTripOwnership(tripId, userId) {
return canAccessTrip(tripId, userId);
}
function getAssignmentWithPlace(assignmentId) {
const a = db.prepare(`
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
p.reservation_status, p.reservation_notes, p.reservation_datetime, p.place_time, p.duration_minutes, p.notes as place_notes,
p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone,
c.name as category_name, c.color as category_color, c.icon as category_icon
FROM day_assignments da
JOIN places p ON da.place_id = p.id
LEFT JOIN categories c ON p.category_id = c.id
WHERE da.id = ?
`).get(assignmentId);
if (!a) return null;
const tags = db.prepare(`
SELECT t.* FROM tags t
JOIN place_tags pt ON t.id = pt.tag_id
WHERE pt.place_id = ?
`).all(a.place_id);
return {
id: a.id,
day_id: a.day_id,
order_index: a.order_index,
notes: a.notes,
created_at: a.created_at,
place: {
id: a.place_id,
name: a.place_name,
description: a.place_description,
lat: a.lat,
lng: a.lng,
address: a.address,
category_id: a.category_id,
price: a.price,
currency: a.place_currency,
reservation_status: a.reservation_status,
reservation_notes: a.reservation_notes,
reservation_datetime: a.reservation_datetime,
place_time: a.place_time,
duration_minutes: a.duration_minutes,
notes: a.place_notes,
image_url: a.image_url,
transport_mode: a.transport_mode,
google_place_id: a.google_place_id,
website: a.website,
phone: a.phone,
category: a.category_id ? {
id: a.category_id,
name: a.category_name,
color: a.category_color,
icon: a.category_icon,
} : null,
tags,
}
};
}
// GET /api/trips/:tripId/days/:dayId/assignments
router.get('/trips/:tripId/days/:dayId/assignments', authenticate, (req, res) => {
const { tripId, dayId } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId);
if (!day) return res.status(404).json({ error: 'Tag nicht gefunden' });
const assignments = db.prepare(`
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
p.reservation_status, p.reservation_notes, p.reservation_datetime, p.place_time, p.duration_minutes, p.notes as place_notes,
p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone,
c.name as category_name, c.color as category_color, c.icon as category_icon
FROM day_assignments da
JOIN places p ON da.place_id = p.id
LEFT JOIN categories c ON p.category_id = c.id
WHERE da.day_id = ?
ORDER BY da.order_index ASC, da.created_at ASC
`).all(dayId);
const result = assignments.map(a => {
const tags = db.prepare(`
SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?
`).all(a.place_id);
return {
id: a.id,
day_id: a.day_id,
order_index: a.order_index,
notes: a.notes,
created_at: a.created_at,
place: {
id: a.place_id,
name: a.place_name,
description: a.place_description,
lat: a.lat,
lng: a.lng,
address: a.address,
category_id: a.category_id,
price: a.price,
currency: a.place_currency,
reservation_status: a.reservation_status,
reservation_notes: a.reservation_notes,
reservation_datetime: a.reservation_datetime,
place_time: a.place_time,
duration_minutes: a.duration_minutes,
notes: a.place_notes,
image_url: a.image_url,
transport_mode: a.transport_mode,
google_place_id: a.google_place_id,
website: a.website,
phone: a.phone,
category: a.category_id ? {
id: a.category_id,
name: a.category_name,
color: a.category_color,
icon: a.category_icon,
} : null,
tags,
}
};
});
res.json({ assignments: result });
});
// POST /api/trips/:tripId/days/:dayId/assignments
router.post('/trips/:tripId/days/:dayId/assignments', authenticate, (req, res) => {
const { tripId, dayId } = req.params;
const { place_id, notes } = req.body;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId);
if (!day) return res.status(404).json({ error: 'Tag nicht gefunden' });
const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(place_id, tripId);
if (!place) return res.status(404).json({ error: 'Ort nicht gefunden' });
// Check for duplicate
const existing = db.prepare('SELECT id FROM day_assignments WHERE day_id = ? AND place_id = ?').get(dayId, place_id);
if (existing) return res.status(409).json({ error: 'Ort ist bereits diesem Tag zugewiesen' });
const maxOrder = db.prepare('SELECT MAX(order_index) as max FROM day_assignments WHERE day_id = ?').get(dayId);
const orderIndex = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
const result = db.prepare(
'INSERT INTO day_assignments (day_id, place_id, order_index, notes) VALUES (?, ?, ?, ?)'
).run(dayId, place_id, orderIndex, notes || null);
const assignment = getAssignmentWithPlace(result.lastInsertRowid);
res.status(201).json({ assignment });
});
// DELETE /api/trips/:tripId/days/:dayId/assignments/:id
router.delete('/trips/:tripId/days/:dayId/assignments/:id', authenticate, (req, res) => {
const { tripId, dayId, id } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
const assignment = db.prepare(
'SELECT da.id FROM day_assignments da JOIN days d ON da.day_id = d.id WHERE da.id = ? AND da.day_id = ? AND d.trip_id = ?'
).get(id, dayId, tripId);
if (!assignment) return res.status(404).json({ error: 'Zuweisung nicht gefunden' });
db.prepare('DELETE FROM day_assignments WHERE id = ?').run(id);
res.json({ success: true });
});
// PUT /api/trips/:tripId/days/:dayId/assignments/reorder
router.put('/trips/:tripId/days/:dayId/assignments/reorder', authenticate, (req, res) => {
const { tripId, dayId } = req.params;
const { orderedIds } = req.body;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId);
if (!day) return res.status(404).json({ error: 'Tag nicht gefunden' });
const update = db.prepare('UPDATE day_assignments SET order_index = ? WHERE id = ? AND day_id = ?');
db.exec('BEGIN');
try {
orderedIds.forEach((id, index) => {
update.run(index, id, dayId);
});
db.exec('COMMIT');
} catch (e) {
db.exec('ROLLBACK');
throw e;
}
res.json({ success: true });
});
// PUT /api/trips/:tripId/assignments/:id/move
router.put('/trips/:tripId/assignments/:id/move', authenticate, (req, res) => {
const { tripId, id } = req.params;
const { new_day_id, order_index } = req.body;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
const assignment = db.prepare(`
SELECT da.* FROM day_assignments da
JOIN days d ON da.day_id = d.id
WHERE da.id = ? AND d.trip_id = ?
`).get(id, tripId);
if (!assignment) return res.status(404).json({ error: 'Zuweisung nicht gefunden' });
const newDay = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(new_day_id, tripId);
if (!newDay) return res.status(404).json({ error: 'Zieltag nicht gefunden' });
db.prepare('UPDATE day_assignments SET day_id = ?, order_index = ? WHERE id = ?').run(new_day_id, order_index || 0, id);
const updated = getAssignmentWithPlace(id);
res.json({ assignment: updated });
});
module.exports = router;

390
server/src/routes/auth.js Normal file
View File

@@ -0,0 +1,390 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { v4: uuid } = require('uuid');
const fetch = require('node-fetch');
const { db } = require('../db/database');
const { authenticate } = require('../middleware/auth');
const router = express.Router();
const { JWT_SECRET } = require('../config');
const avatarDir = path.join(__dirname, '../../uploads/avatars');
if (!fs.existsSync(avatarDir)) fs.mkdirSync(avatarDir, { recursive: true });
const avatarStorage = multer.diskStorage({
destination: (req, file, cb) => cb(null, avatarDir),
filename: (req, file, cb) => cb(null, uuid() + path.extname(file.originalname))
});
const avatarUpload = multer({ storage: avatarStorage, limits: { fileSize: 5 * 1024 * 1024 }, fileFilter: (req, file, cb) => {
if (file.mimetype.startsWith('image/')) cb(null, true);
else cb(new Error('Only images allowed'));
}});
// Simple rate limiter
const loginAttempts = new Map();
function rateLimiter(maxAttempts, windowMs) {
return (req, res, next) => {
const key = req.ip;
const now = Date.now();
const record = loginAttempts.get(key);
if (record && record.count >= maxAttempts && now - record.first < windowMs) {
return res.status(429).json({ error: 'Too many attempts. Please try again later.' });
}
if (!record || now - record.first >= windowMs) {
loginAttempts.set(key, { count: 1, first: now });
} else {
record.count++;
}
next();
};
}
const authLimiter = rateLimiter(10, 15 * 60 * 1000); // 10 attempts per 15 minutes
function avatarUrl(user) {
return user.avatar ? `/uploads/avatars/${user.avatar}` : null;
}
function generateToken(user) {
return jwt.sign(
{ id: user.id, username: user.username, email: user.email, role: user.role },
JWT_SECRET,
{ expiresIn: '24h' }
);
}
// GET /api/auth/app-config (public — no auth needed)
router.get('/app-config', (req, res) => {
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
const setting = db.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get();
const allowRegistration = userCount === 0 || (setting?.value ?? 'true') === 'true';
res.json({ allow_registration: allowRegistration, has_users: userCount > 0 });
});
// POST /api/auth/register
router.post('/register', authLimiter, (req, res) => {
const { username, email, password } = req.body;
// Check if registration is allowed
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
if (userCount > 0) {
const setting = db.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get();
if (setting?.value === 'false') {
return res.status(403).json({ error: 'Registration is disabled. Contact your administrator.' });
}
}
if (!username || !email || !password) {
return res.status(400).json({ error: 'Username, email and password are required' });
}
if (password.length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 characters' });
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return res.status(400).json({ error: 'Invalid email format' });
}
const existingUser = db.prepare('SELECT id FROM users WHERE email = ? OR username = ?').get(email, username);
if (existingUser) {
return res.status(409).json({ error: 'A user with this email or username already exists' });
}
const password_hash = bcrypt.hashSync(password, 10);
// First user becomes admin
const isFirstUser = userCount === 0;
const role = isFirstUser ? 'admin' : 'user';
try {
const result = db.prepare(
'INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)'
).run(username, email, password_hash, role);
const user = { id: result.lastInsertRowid, username, email, role, avatar: null };
const token = generateToken(user);
res.status(201).json({ token, user: { ...user, avatar_url: null } });
} catch (err) {
res.status(500).json({ error: 'Fehler beim Erstellen des Benutzers' });
}
});
// POST /api/auth/login
router.post('/login', authLimiter, (req, res) => {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ error: 'E-Mail und Passwort sind erforderlich' });
}
const user = db.prepare('SELECT * FROM users WHERE email = ?').get(email);
if (!user) {
return res.status(401).json({ error: 'Ungültige E-Mail oder Passwort' });
}
const validPassword = bcrypt.compareSync(password, user.password_hash);
if (!validPassword) {
return res.status(401).json({ error: 'Ungültige E-Mail oder Passwort' });
}
const token = generateToken(user);
const { password_hash, ...userWithoutPassword } = user;
res.json({ token, user: { ...userWithoutPassword, avatar_url: avatarUrl(user) } });
});
// GET /api/auth/me
router.get('/me', authenticate, (req, res) => {
const user = db.prepare(
'SELECT id, username, email, role, maps_api_key, openweather_api_key, avatar, created_at FROM users WHERE id = ?'
).get(req.user.id);
if (!user) {
return res.status(404).json({ error: 'Benutzer nicht gefunden' });
}
res.json({ user: { ...user, avatar_url: avatarUrl(user) } });
});
// PUT /api/auth/me/maps-key
router.put('/me/maps-key', authenticate, (req, res) => {
const { maps_api_key } = req.body;
db.prepare(
'UPDATE users SET maps_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
).run(maps_api_key || null, req.user.id);
res.json({ success: true, maps_api_key: maps_api_key || null });
});
// PUT /api/auth/me/api-keys
router.put('/me/api-keys', authenticate, (req, res) => {
const { maps_api_key, openweather_api_key } = req.body;
db.prepare(
'UPDATE users SET maps_api_key = ?, openweather_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
).run(
maps_api_key !== undefined ? (maps_api_key || null) : req.user.maps_api_key,
openweather_api_key !== undefined ? (openweather_api_key || null) : req.user.openweather_api_key,
req.user.id
);
const updated = db.prepare(
'SELECT id, username, email, role, maps_api_key, openweather_api_key, avatar FROM users WHERE id = ?'
).get(req.user.id);
res.json({ success: true, user: { ...updated, avatar_url: avatarUrl(updated) } });
});
// PUT /api/auth/me/settings
router.put('/me/settings', authenticate, (req, res) => {
const { maps_api_key, openweather_api_key, username, email } = req.body;
const updates = [];
const params = [];
if (maps_api_key !== undefined) { updates.push('maps_api_key = ?'); params.push(maps_api_key || null); }
if (openweather_api_key !== undefined) { updates.push('openweather_api_key = ?'); params.push(openweather_api_key || null); }
if (username !== undefined) { updates.push('username = ?'); params.push(username); }
if (email !== undefined) { updates.push('email = ?'); params.push(email); }
if (updates.length > 0) {
updates.push('updated_at = CURRENT_TIMESTAMP');
params.push(req.user.id);
db.prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).run(...params);
}
const updated = db.prepare(
'SELECT id, username, email, role, maps_api_key, openweather_api_key, avatar FROM users WHERE id = ?'
).get(req.user.id);
res.json({ success: true, user: { ...updated, avatar_url: avatarUrl(updated) } });
});
// GET /api/auth/me/settings
router.get('/me/settings', authenticate, (req, res) => {
const user = db.prepare(
'SELECT maps_api_key, openweather_api_key FROM users WHERE id = ?'
).get(req.user.id);
res.json({ settings: user });
});
// POST /api/auth/avatar — upload avatar
router.post('/avatar', authenticate, avatarUpload.single('avatar'), (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No image uploaded' });
const current = db.prepare('SELECT avatar FROM users WHERE id = ?').get(req.user.id);
if (current && current.avatar) {
const oldPath = path.join(avatarDir, current.avatar);
if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
}
const filename = req.file.filename;
db.prepare('UPDATE users SET avatar = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(filename, req.user.id);
const updated = db.prepare('SELECT id, username, email, role, avatar FROM users WHERE id = ?').get(req.user.id);
res.json({ success: true, avatar_url: avatarUrl(updated) });
});
// DELETE /api/auth/avatar — remove avatar
router.delete('/avatar', authenticate, (req, res) => {
const current = db.prepare('SELECT avatar FROM users WHERE id = ?').get(req.user.id);
if (current && current.avatar) {
const filePath = path.join(avatarDir, current.avatar);
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
}
db.prepare('UPDATE users SET avatar = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(req.user.id);
res.json({ success: true });
});
// GET /api/auth/users — list all users (for sharing/inviting)
router.get('/users', authenticate, (req, res) => {
const users = db.prepare(
'SELECT id, username, avatar FROM users WHERE id != ? ORDER BY username ASC'
).all(req.user.id);
res.json({ users: users.map(u => ({ ...u, avatar_url: avatarUrl(u) })) });
});
// GET /api/auth/validate-keys (admin only)
router.get('/validate-keys', authenticate, async (req, res) => {
const user = db.prepare('SELECT role, maps_api_key, openweather_api_key FROM users WHERE id = ?').get(req.user.id);
if (user?.role !== 'admin') return res.status(403).json({ error: 'Admin access required' });
const result = { maps: false, weather: false };
// Test Google Maps Places API
if (user.maps_api_key) {
try {
const mapsRes = await fetch(
`https://places.googleapis.com/v1/places:searchText`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Goog-Api-Key': user.maps_api_key,
'X-Goog-FieldMask': 'places.displayName',
},
body: JSON.stringify({ textQuery: 'test' }),
}
);
result.maps = mapsRes.status === 200;
} catch (err) {
result.maps = false;
}
}
// Test OpenWeatherMap API
if (user.openweather_api_key) {
try {
const weatherRes = await fetch(
`https://api.openweathermap.org/data/2.5/weather?q=London&appid=${user.openweather_api_key}`
);
result.weather = weatherRes.status === 200;
} catch (err) {
result.weather = false;
}
}
res.json(result);
});
// PUT /api/auth/app-settings (admin only)
router.put('/app-settings', authenticate, (req, res) => {
const user = db.prepare('SELECT role FROM users WHERE id = ?').get(req.user.id);
if (user?.role !== 'admin') return res.status(403).json({ error: 'Admin access required' });
const { allow_registration } = req.body;
if (allow_registration !== undefined) {
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', ?)").run(String(allow_registration));
}
res.json({ success: true });
});
// GET /api/auth/travel-stats — aggregated travel statistics for current user
router.get('/travel-stats', authenticate, (req, res) => {
const userId = req.user.id;
// Get all places from user's trips (owned + shared)
const places = db.prepare(`
SELECT DISTINCT p.address, p.lat, p.lng
FROM places p
JOIN trips t ON p.trip_id = t.id
LEFT JOIN trip_members tm ON t.id = tm.trip_id
WHERE t.user_id = ? OR tm.user_id = ?
`).all(userId, userId);
// Get trip count + total days
const tripStats = db.prepare(`
SELECT COUNT(DISTINCT t.id) as trips,
COUNT(DISTINCT d.id) as days
FROM trips t
LEFT JOIN days d ON t.id = d.id
LEFT JOIN trip_members tm ON t.id = tm.trip_id
WHERE (t.user_id = ? OR tm.user_id = ?) AND t.is_archived = 0
`).get(userId, userId);
// Known country names (EN + DE)
const KNOWN_COUNTRIES = new Set([
'Japan', 'Germany', 'Deutschland', 'France', 'Frankreich', 'Italy', 'Italien', 'Spain', 'Spanien',
'United States', 'USA', 'United Kingdom', 'UK', 'Thailand', 'Australia', 'Australien',
'Canada', 'Kanada', 'Mexico', 'Mexiko', 'Brazil', 'Brasilien', 'China', 'India', 'Indien',
'South Korea', 'Südkorea', 'Indonesia', 'Indonesien', 'Turkey', 'Türkei', 'Türkiye',
'Greece', 'Griechenland', 'Portugal', 'Netherlands', 'Niederlande', 'Belgium', 'Belgien',
'Switzerland', 'Schweiz', 'Austria', 'Österreich', 'Sweden', 'Schweden', 'Norway', 'Norwegen',
'Denmark', 'Dänemark', 'Finland', 'Finnland', 'Poland', 'Polen', 'Czech Republic', 'Tschechien',
'Czechia', 'Hungary', 'Ungarn', 'Croatia', 'Kroatien', 'Romania', 'Rumänien',
'Ireland', 'Irland', 'Iceland', 'Island', 'New Zealand', 'Neuseeland',
'Singapore', 'Singapur', 'Malaysia', 'Vietnam', 'Philippines', 'Philippinen',
'Egypt', 'Ägypten', 'Morocco', 'Marokko', 'South Africa', 'Südafrika', 'Kenya', 'Kenia',
'Argentina', 'Argentinien', 'Chile', 'Colombia', 'Kolumbien', 'Peru',
'Russia', 'Russland', 'United Arab Emirates', 'UAE', 'Vereinigte Arabische Emirate',
'Israel', 'Jordan', 'Jordanien', 'Taiwan', 'Hong Kong', 'Hongkong',
'Cuba', 'Kuba', 'Costa Rica', 'Panama', 'Ecuador', 'Bolivia', 'Bolivien', 'Uruguay', 'Paraguay',
'Luxembourg', 'Luxemburg', 'Malta', 'Cyprus', 'Zypern', 'Estonia', 'Estland',
'Latvia', 'Lettland', 'Lithuania', 'Litauen', 'Slovakia', 'Slowakei', 'Slovenia', 'Slowenien',
'Bulgaria', 'Bulgarien', 'Serbia', 'Serbien', 'Montenegro', 'Albania', 'Albanien',
'Sri Lanka', 'Nepal', 'Cambodia', 'Kambodscha', 'Laos', 'Myanmar', 'Mongolia', 'Mongolei',
'Saudi Arabia', 'Saudi-Arabien', 'Qatar', 'Katar', 'Oman', 'Bahrain', 'Kuwait',
'Tanzania', 'Tansania', 'Ethiopia', 'Äthiopien', 'Nigeria', 'Ghana', 'Tunisia', 'Tunesien',
'Dominican Republic', 'Dominikanische Republik', 'Jamaica', 'Jamaika',
'Ukraine', 'Georgia', 'Georgien', 'Armenia', 'Armenien', 'Pakistan', 'Bangladesh', 'Bangladesch',
'Senegal', 'Mozambique', 'Mosambik', 'Moldova', 'Moldawien', 'Belarus', 'Weißrussland',
]);
// Extract countries from addresses — only accept known country names
const countries = new Set();
const cities = new Set();
const coords = [];
places.forEach(p => {
if (p.lat && p.lng) coords.push({ lat: p.lat, lng: p.lng });
if (p.address) {
const parts = p.address.split(',').map(s => s.trim().replace(/\d{3,}/g, '').trim());
for (const part of parts) {
if (KNOWN_COUNTRIES.has(part)) { countries.add(part); break; }
}
// City: first part that's not the country and looks like a name (Latin chars, > 2 chars)
const cityPart = parts.find(s => !KNOWN_COUNTRIES.has(s) && /^[A-Za-zÀ-ÿ\s-]{2,}$/.test(s));
if (cityPart) cities.add(cityPart);
}
});
res.json({
countries: [...countries],
cities: [...cities],
coords,
totalTrips: tripStats?.trips || 0,
totalDays: tripStats?.days || 0,
totalPlaces: places.length,
});
});
module.exports = router;

233
server/src/routes/backup.js Normal file
View File

@@ -0,0 +1,233 @@
const express = require('express');
const archiver = require('archiver');
const unzipper = require('unzipper');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { authenticate, adminOnly } = require('../middleware/auth');
const scheduler = require('../scheduler');
const { db, closeDb, reinitialize } = require('../db/database');
const router = express.Router();
// All backup routes require admin
router.use(authenticate, adminOnly);
const dataDir = path.join(__dirname, '../../data');
const backupsDir = path.join(dataDir, 'backups');
const uploadsDir = path.join(__dirname, '../../uploads');
function ensureBackupsDir() {
if (!fs.existsSync(backupsDir)) fs.mkdirSync(backupsDir, { recursive: true });
}
function formatSize(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
// GET /api/backup/list
router.get('/list', (req, res) => {
ensureBackupsDir();
try {
const files = fs.readdirSync(backupsDir)
.filter(f => f.endsWith('.zip'))
.map(filename => {
const filePath = path.join(backupsDir, filename);
const stat = fs.statSync(filePath);
return {
filename,
size: stat.size,
sizeText: formatSize(stat.size),
created_at: stat.birthtime.toISOString(),
};
})
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
res.json({ backups: files });
} catch (err) {
res.status(500).json({ error: 'Fehler beim Laden der Backups' });
}
});
// POST /api/backup/create
router.post('/create', async (req, res) => {
ensureBackupsDir();
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const filename = `backup-${timestamp}.zip`;
const outputPath = path.join(backupsDir, filename);
try {
// Flush WAL to main DB file before archiving so all data is captured
try { db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {}
await new Promise((resolve, reject) => {
const output = fs.createWriteStream(outputPath);
const archive = archiver('zip', { zlib: { level: 9 } });
output.on('close', resolve);
archive.on('error', reject);
archive.pipe(output);
// Add database
const dbPath = path.join(dataDir, 'travel.db');
if (fs.existsSync(dbPath)) {
archive.file(dbPath, { name: 'travel.db' });
}
// Add uploads directory
if (fs.existsSync(uploadsDir)) {
archive.directory(uploadsDir, 'uploads');
}
archive.finalize();
});
const stat = fs.statSync(outputPath);
res.json({
success: true,
backup: {
filename,
size: stat.size,
sizeText: formatSize(stat.size),
created_at: stat.birthtime.toISOString(),
}
});
} catch (err) {
console.error('Backup error:', err);
if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath);
res.status(500).json({ error: 'Fehler beim Erstellen des Backups' });
}
});
// GET /api/backup/download/:filename
router.get('/download/:filename', (req, res) => {
const { filename } = req.params;
// Security: prevent path traversal
if (!/^backup-[\w\-]+\.zip$/.test(filename)) {
return res.status(400).json({ error: 'Invalid filename' });
}
const filePath = path.join(backupsDir, filename);
if (!fs.existsSync(filePath)) {
return res.status(404).json({ error: 'Backup nicht gefunden' });
}
res.download(filePath, filename);
});
// Helper: restore from a zip file path
async function restoreFromZip(zipPath, res) {
const extractDir = path.join(dataDir, `restore-${Date.now()}`);
try {
await fs.createReadStream(zipPath)
.pipe(unzipper.Extract({ path: extractDir }))
.promise();
const extractedDb = path.join(extractDir, 'travel.db');
if (!fs.existsSync(extractedDb)) {
fs.rmSync(extractDir, { recursive: true, force: true });
return res.status(400).json({ error: 'Ungültiges Backup: travel.db nicht gefunden' });
}
// Step 1: close DB connection BEFORE touching the file (required on Windows)
closeDb();
// Step 2: remove WAL/SHM and overwrite DB file
const dbDest = path.join(dataDir, 'travel.db');
for (const ext of ['', '-wal', '-shm']) {
try { fs.unlinkSync(dbDest + ext); } catch (e) {}
}
fs.copyFileSync(extractedDb, dbDest);
// Step 3: restore uploads
const extractedUploads = path.join(extractDir, 'uploads');
if (fs.existsSync(extractedUploads)) {
if (fs.existsSync(uploadsDir)) fs.rmSync(uploadsDir, { recursive: true, force: true });
fs.cpSync(extractedUploads, uploadsDir, { recursive: true });
}
fs.rmSync(extractDir, { recursive: true, force: true });
// Step 4: reopen DB with restored data
reinitialize();
res.json({ success: true });
} catch (err) {
console.error('Restore error:', err);
if (fs.existsSync(extractDir)) fs.rmSync(extractDir, { recursive: true, force: true });
if (!res.headersSent) res.status(500).json({ error: err.message || 'Fehler beim Wiederherstellen' });
}
}
// POST /api/backup/restore/:filename - restore from stored backup
router.post('/restore/:filename', async (req, res) => {
const { filename } = req.params;
if (!/^backup-[\w\-]+\.zip$/.test(filename)) {
return res.status(400).json({ error: 'Invalid filename' });
}
const zipPath = path.join(backupsDir, filename);
if (!fs.existsSync(zipPath)) {
return res.status(404).json({ error: 'Backup nicht gefunden' });
}
await restoreFromZip(zipPath, res);
});
// POST /api/backup/upload-restore - upload a zip and restore
const uploadTmp = multer({
dest: path.join(dataDir, 'tmp/'),
fileFilter: (req, file, cb) => {
if (file.originalname.endsWith('.zip')) cb(null, true);
else cb(new Error('Nur ZIP-Dateien erlaubt'));
},
limits: { fileSize: 500 * 1024 * 1024 },
});
router.post('/upload-restore', uploadTmp.single('backup'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'Keine Datei hochgeladen' });
const zipPath = req.file.path;
await restoreFromZip(zipPath, res);
if (fs.existsSync(zipPath)) fs.unlinkSync(zipPath);
});
// GET /api/backup/auto-settings
router.get('/auto-settings', (req, res) => {
res.json({ settings: scheduler.loadSettings() });
});
// PUT /api/backup/auto-settings
router.put('/auto-settings', (req, res) => {
const { enabled, interval, keep_days } = req.body;
const settings = {
enabled: !!enabled,
interval: scheduler.VALID_INTERVALS.includes(interval) ? interval : 'daily',
keep_days: Number.isInteger(keep_days) && keep_days >= 0 ? keep_days : 7,
};
scheduler.saveSettings(settings);
scheduler.start();
res.json({ settings });
});
// DELETE /api/backup/:filename
router.delete('/:filename', (req, res) => {
const { filename } = req.params;
if (!/^backup-[\w\-]+\.zip$/.test(filename)) {
return res.status(400).json({ error: 'Invalid filename' });
}
const filePath = path.join(backupsDir, filename);
if (!fs.existsSync(filePath)) {
return res.status(404).json({ error: 'Backup nicht gefunden' });
}
fs.unlinkSync(filePath);
res.json({ success: true });
});
module.exports = router;

105
server/src/routes/budget.js Normal file
View File

@@ -0,0 +1,105 @@
const express = require('express');
const { db, canAccessTrip } = require('../db/database');
const { authenticate } = require('../middleware/auth');
const router = express.Router({ mergeParams: true });
function verifyTripOwnership(tripId, userId) {
return canAccessTrip(tripId, userId);
}
// GET /api/trips/:tripId/budget
router.get('/', authenticate, (req, res) => {
const { tripId } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
const items = db.prepare(
'SELECT * FROM budget_items WHERE trip_id = ? ORDER BY category ASC, created_at ASC'
).all(tripId);
res.json({ items });
});
// POST /api/trips/:tripId/budget
router.post('/', authenticate, (req, res) => {
const { tripId } = req.params;
const { category, name, total_price, persons, days, note } = req.body;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
if (!name) return res.status(400).json({ error: 'Name ist erforderlich' });
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM budget_items WHERE trip_id = ?').get(tripId);
const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
const result = db.prepare(
'INSERT INTO budget_items (trip_id, category, name, total_price, persons, days, note, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
).run(
tripId,
category || 'Sonstiges',
name,
total_price || 0,
persons != null ? persons : null,
days !== undefined && days !== null ? days : null,
note || null,
sortOrder
);
const item = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json({ item });
});
// PUT /api/trips/:tripId/budget/:id
router.put('/:id', authenticate, (req, res) => {
const { tripId, id } = req.params;
const { category, name, total_price, persons, days, note, sort_order } = req.body;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
const item = db.prepare('SELECT * FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!item) return res.status(404).json({ error: 'Budget-Eintrag nicht gefunden' });
db.prepare(`
UPDATE budget_items SET
category = COALESCE(?, category),
name = COALESCE(?, name),
total_price = CASE WHEN ? IS NOT NULL THEN ? ELSE total_price END,
persons = CASE WHEN ? IS NOT NULL THEN ? ELSE persons END,
days = CASE WHEN ? THEN ? ELSE days END,
note = CASE WHEN ? THEN ? ELSE note END,
sort_order = CASE WHEN ? IS NOT NULL THEN ? ELSE sort_order END
WHERE id = ?
`).run(
category || null,
name || null,
total_price !== undefined ? 1 : null, total_price !== undefined ? total_price : 0,
persons !== undefined ? 1 : null, persons !== undefined ? persons : null,
days !== undefined ? 1 : 0, days !== undefined ? days : null,
note !== undefined ? 1 : 0, note !== undefined ? note : null,
sort_order !== undefined ? 1 : null, sort_order !== undefined ? sort_order : 0,
id
);
const updated = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(id);
res.json({ item: updated });
});
// DELETE /api/trips/:tripId/budget/:id
router.delete('/:id', authenticate, (req, res) => {
const { tripId, id } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
const item = db.prepare('SELECT id FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!item) return res.status(404).json({ error: 'Budget-Eintrag nicht gefunden' });
db.prepare('DELETE FROM budget_items WHERE id = ?').run(id);
res.json({ success: true });
});
module.exports = router;

View File

@@ -0,0 +1,58 @@
const express = require('express');
const { db } = require('../db/database');
const { authenticate, adminOnly } = require('../middleware/auth');
const router = express.Router();
// GET /api/categories - public to all authenticated users
router.get('/', authenticate, (req, res) => {
const categories = db.prepare(
'SELECT * FROM categories ORDER BY name ASC'
).all();
res.json({ categories });
});
// POST /api/categories - admin only
router.post('/', authenticate, adminOnly, (req, res) => {
const { name, color, icon } = req.body;
if (!name) return res.status(400).json({ error: 'Kategoriename ist erforderlich' });
const result = db.prepare(
'INSERT INTO categories (name, color, icon, user_id) VALUES (?, ?, ?, ?)'
).run(name, color || '#6366f1', icon || '📍', req.user.id);
const category = db.prepare('SELECT * FROM categories WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json({ category });
});
// PUT /api/categories/:id - admin only
router.put('/:id', authenticate, adminOnly, (req, res) => {
const { name, color, icon } = req.body;
const category = db.prepare('SELECT * FROM categories WHERE id = ?').get(req.params.id);
if (!category) return res.status(404).json({ error: 'Kategorie nicht gefunden' });
db.prepare(`
UPDATE categories SET
name = COALESCE(?, name),
color = COALESCE(?, color),
icon = COALESCE(?, icon)
WHERE id = ?
`).run(name || null, color || null, icon || null, req.params.id);
const updated = db.prepare('SELECT * FROM categories WHERE id = ?').get(req.params.id);
res.json({ category: updated });
});
// DELETE /api/categories/:id - admin only
router.delete('/:id', authenticate, adminOnly, (req, res) => {
const category = db.prepare('SELECT * FROM categories WHERE id = ?').get(req.params.id);
if (!category) return res.status(404).json({ error: 'Kategorie nicht gefunden' });
db.prepare('DELETE FROM categories WHERE id = ?').run(req.params.id);
res.json({ success: true });
});
module.exports = router;

View File

@@ -0,0 +1,77 @@
const express = require('express');
const { db, canAccessTrip } = require('../db/database');
const { authenticate } = require('../middleware/auth');
const router = express.Router({ mergeParams: true });
function verifyAccess(tripId, userId) {
return canAccessTrip(tripId, userId);
}
// GET /api/trips/:tripId/days/:dayId/notes
router.get('/', authenticate, (req, res) => {
const { tripId, dayId } = req.params;
if (!verifyAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Reise nicht gefunden' });
const notes = db.prepare(
'SELECT * FROM day_notes WHERE day_id = ? AND trip_id = ? ORDER BY sort_order ASC, created_at ASC'
).all(dayId, tripId);
res.json({ notes });
});
// POST /api/trips/:tripId/days/:dayId/notes
router.post('/', authenticate, (req, res) => {
const { tripId, dayId } = req.params;
if (!verifyAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Reise nicht gefunden' });
const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId);
if (!day) return res.status(404).json({ error: 'Tag nicht gefunden' });
const { text, time, icon, sort_order } = req.body;
if (!text?.trim()) return res.status(400).json({ error: 'Text erforderlich' });
const result = db.prepare(
'INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order) VALUES (?, ?, ?, ?, ?, ?)'
).run(dayId, tripId, text.trim(), time || null, icon || '📝', sort_order ?? 9999);
const note = db.prepare('SELECT * FROM day_notes WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json({ note });
});
// PUT /api/trips/:tripId/days/:dayId/notes/:id
router.put('/:id', authenticate, (req, res) => {
const { tripId, dayId, id } = req.params;
if (!verifyAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Reise nicht gefunden' });
const note = db.prepare('SELECT * FROM day_notes WHERE id = ? AND day_id = ? AND trip_id = ?').get(id, dayId, tripId);
if (!note) return res.status(404).json({ error: 'Notiz nicht gefunden' });
const { text, time, icon, sort_order } = req.body;
db.prepare(
'UPDATE day_notes SET text = ?, time = ?, icon = ?, sort_order = ? WHERE id = ?'
).run(
text !== undefined ? text.trim() : note.text,
time !== undefined ? time : note.time,
icon !== undefined ? icon : note.icon,
sort_order !== undefined ? sort_order : note.sort_order,
id
);
const updated = db.prepare('SELECT * FROM day_notes WHERE id = ?').get(id);
res.json({ note: updated });
});
// DELETE /api/trips/:tripId/days/:dayId/notes/:id
router.delete('/:id', authenticate, (req, res) => {
const { tripId, dayId, id } = req.params;
if (!verifyAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Reise nicht gefunden' });
const note = db.prepare('SELECT id FROM day_notes WHERE id = ? AND day_id = ? AND trip_id = ?').get(id, dayId, tripId);
if (!note) return res.status(404).json({ error: 'Notiz nicht gefunden' });
db.prepare('DELETE FROM day_notes WHERE id = ?').run(id);
res.json({ success: true });
});
module.exports = router;

155
server/src/routes/days.js Normal file
View File

@@ -0,0 +1,155 @@
const express = require('express');
const { db, canAccessTrip } = require('../db/database');
const { authenticate } = require('../middleware/auth');
const router = express.Router({ mergeParams: true });
function verifyTripOwnership(tripId, userId) {
return canAccessTrip(tripId, userId);
}
function getAssignmentsForDay(dayId) {
const assignments = db.prepare(`
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
p.reservation_status, p.reservation_notes, p.reservation_datetime, p.place_time, p.duration_minutes, p.notes as place_notes,
p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone,
c.name as category_name, c.color as category_color, c.icon as category_icon
FROM day_assignments da
JOIN places p ON da.place_id = p.id
LEFT JOIN categories c ON p.category_id = c.id
WHERE da.day_id = ?
ORDER BY da.order_index ASC, da.created_at ASC
`).all(dayId);
return assignments.map(a => {
const tags = db.prepare(`
SELECT t.* FROM tags t
JOIN place_tags pt ON t.id = pt.tag_id
WHERE pt.place_id = ?
`).all(a.place_id);
return {
id: a.id,
day_id: a.day_id,
order_index: a.order_index,
notes: a.notes,
created_at: a.created_at,
place: {
id: a.place_id,
name: a.place_name,
description: a.place_description,
lat: a.lat,
lng: a.lng,
address: a.address,
category_id: a.category_id,
price: a.price,
currency: a.place_currency,
reservation_status: a.reservation_status,
reservation_notes: a.reservation_notes,
reservation_datetime: a.reservation_datetime,
place_time: a.place_time,
duration_minutes: a.duration_minutes,
notes: a.place_notes,
image_url: a.image_url,
transport_mode: a.transport_mode,
google_place_id: a.google_place_id,
website: a.website,
phone: a.phone,
category: a.category_id ? {
id: a.category_id,
name: a.category_name,
color: a.category_color,
icon: a.category_icon,
} : null,
tags,
}
};
});
}
// GET /api/trips/:tripId/days
router.get('/', authenticate, (req, res) => {
const { tripId } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) {
return res.status(404).json({ error: 'Reise nicht gefunden' });
}
const days = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(tripId);
const daysWithAssignments = days.map(day => ({
...day,
assignments: getAssignmentsForDay(day.id),
notes_items: db.prepare(
'SELECT * FROM day_notes WHERE day_id = ? ORDER BY sort_order ASC, created_at ASC'
).all(day.id),
}));
res.json({ days: daysWithAssignments });
});
// POST /api/trips/:tripId/days
router.post('/', authenticate, (req, res) => {
const { tripId } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) {
return res.status(404).json({ error: 'Reise nicht gefunden' });
}
const { date, notes } = req.body;
const maxDay = db.prepare('SELECT MAX(day_number) as max FROM days WHERE trip_id = ?').get(tripId);
const dayNumber = (maxDay.max || 0) + 1;
const result = db.prepare(
'INSERT INTO days (trip_id, day_number, date, notes) VALUES (?, ?, ?, ?)'
).run(tripId, dayNumber, date || null, notes || null);
const day = db.prepare('SELECT * FROM days WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json({ day: { ...day, assignments: [] } });
});
// PUT /api/trips/:tripId/days/:id
router.put('/:id', authenticate, (req, res) => {
const { tripId, id } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) {
return res.status(404).json({ error: 'Reise nicht gefunden' });
}
const day = db.prepare('SELECT * FROM days WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!day) {
return res.status(404).json({ error: 'Tag nicht gefunden' });
}
const { notes, title } = req.body;
db.prepare('UPDATE days SET notes = ?, title = ? WHERE id = ?').run(notes || null, title !== undefined ? title : day.title, id);
const updatedDay = db.prepare('SELECT * FROM days WHERE id = ?').get(id);
res.json({ day: { ...updatedDay, assignments: getAssignmentsForDay(id) } });
});
// DELETE /api/trips/:tripId/days/:id
router.delete('/:id', authenticate, (req, res) => {
const { tripId, id } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) {
return res.status(404).json({ error: 'Reise nicht gefunden' });
}
const day = db.prepare('SELECT * FROM days WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!day) {
return res.status(404).json({ error: 'Tag nicht gefunden' });
}
db.prepare('DELETE FROM days WHERE id = ?').run(id);
res.json({ success: true });
});
module.exports = router;

163
server/src/routes/files.js Normal file
View File

@@ -0,0 +1,163 @@
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { v4: uuidv4 } = require('uuid');
const { db, canAccessTrip } = require('../db/database');
const { authenticate } = require('../middleware/auth');
const router = express.Router({ mergeParams: true });
const filesDir = path.join(__dirname, '../../uploads/files');
const storage = multer.diskStorage({
destination: (req, file, cb) => {
if (!fs.existsSync(filesDir)) fs.mkdirSync(filesDir, { recursive: true });
cb(null, filesDir);
},
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, `${uuidv4()}${ext}`);
},
});
const upload = multer({
storage,
limits: { fileSize: 50 * 1024 * 1024 }, // 50MB
fileFilter: (req, file, cb) => {
const allowed = [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/plain',
'text/csv',
];
if (allowed.includes(file.mimetype) || file.mimetype.startsWith('image/')) {
cb(null, true);
} else {
cb(new Error('Dateityp nicht erlaubt'));
}
},
});
function verifyTripOwnership(tripId, userId) {
return canAccessTrip(tripId, userId);
}
function formatFile(file) {
return {
...file,
url: `/uploads/files/${file.filename}`,
};
}
// GET /api/trips/:tripId/files
router.get('/', authenticate, (req, res) => {
const { tripId } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
const files = db.prepare(`
SELECT f.*, r.title as reservation_title
FROM trip_files f
LEFT JOIN reservations r ON f.reservation_id = r.id
WHERE f.trip_id = ?
ORDER BY f.created_at DESC
`).all(tripId);
res.json({ files: files.map(formatFile) });
});
// POST /api/trips/:tripId/files
router.post('/', authenticate, upload.single('file'), (req, res) => {
const { tripId } = req.params;
const { place_id, description, reservation_id } = req.body;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) {
if (req.file) fs.unlinkSync(req.file.path);
return res.status(404).json({ error: 'Reise nicht gefunden' });
}
if (!req.file) {
return res.status(400).json({ error: 'Keine Datei hochgeladen' });
}
const result = db.prepare(`
INSERT INTO trip_files (trip_id, place_id, reservation_id, filename, original_name, file_size, mime_type, description)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
tripId,
place_id || null,
reservation_id || null,
req.file.filename,
req.file.originalname,
req.file.size,
req.file.mimetype,
description || null
);
const file = db.prepare(`
SELECT f.*, r.title as reservation_title
FROM trip_files f
LEFT JOIN reservations r ON f.reservation_id = r.id
WHERE f.id = ?
`).get(result.lastInsertRowid);
res.status(201).json({ file: formatFile(file) });
});
// PUT /api/trips/:tripId/files/:id
router.put('/:id', authenticate, (req, res) => {
const { tripId, id } = req.params;
const { description, place_id, reservation_id } = req.body;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!file) return res.status(404).json({ error: 'Datei nicht gefunden' });
db.prepare(`
UPDATE trip_files SET
description = COALESCE(?, description),
place_id = ?,
reservation_id = ?
WHERE id = ?
`).run(
description !== undefined ? description : file.description,
place_id !== undefined ? (place_id || null) : file.place_id,
reservation_id !== undefined ? (reservation_id || null) : file.reservation_id,
id
);
const updated = db.prepare(`
SELECT f.*, r.title as reservation_title
FROM trip_files f
LEFT JOIN reservations r ON f.reservation_id = r.id
WHERE f.id = ?
`).get(id);
res.json({ file: formatFile(updated) });
});
// DELETE /api/trips/:tripId/files/:id
router.delete('/:id', authenticate, (req, res) => {
const { tripId, id } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!file) return res.status(404).json({ error: 'Datei nicht gefunden' });
const filePath = path.join(filesDir, file.filename);
if (fs.existsSync(filePath)) {
try { fs.unlinkSync(filePath); } catch (e) { console.error('Error deleting file:', e); }
}
db.prepare('DELETE FROM trip_files WHERE id = ?').run(id);
res.json({ success: true });
});
module.exports = router;

166
server/src/routes/maps.js Normal file
View File

@@ -0,0 +1,166 @@
const express = require('express');
const fetch = require('node-fetch');
const { db } = require('../db/database');
const { authenticate } = require('../middleware/auth');
const router = express.Router();
// In-memory photo cache: placeId → { photoUrl, attribution, fetchedAt }
const photoCache = new Map();
const PHOTO_TTL = 12 * 60 * 60 * 1000; // 12 hours
// POST /api/maps/search
router.post('/search', authenticate, async (req, res) => {
const { query } = req.body;
if (!query) return res.status(400).json({ error: 'Suchanfrage ist erforderlich' });
const user = db.prepare('SELECT maps_api_key FROM users WHERE id = ?').get(req.user.id);
if (!user || !user.maps_api_key) {
return res.status(400).json({ error: 'Google Maps API-Schlüssel nicht konfiguriert. Bitte in den Einstellungen hinzufügen.' });
}
try {
const response = await fetch('https://places.googleapis.com/v1/places:searchText', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Goog-Api-Key': user.maps_api_key,
'X-Goog-FieldMask': 'places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.websiteUri,places.nationalPhoneNumber,places.types',
},
body: JSON.stringify({ textQuery: query, languageCode: req.query.lang || 'en' }),
});
const data = await response.json();
if (!response.ok) {
return res.status(response.status).json({ error: data.error?.message || 'Google Places API Fehler' });
}
const places = (data.places || []).map(p => ({
google_place_id: p.id,
name: p.displayName?.text || '',
address: p.formattedAddress || '',
lat: p.location?.latitude || null,
lng: p.location?.longitude || null,
rating: p.rating || null,
website: p.websiteUri || null,
phone: p.nationalPhoneNumber || null,
}));
res.json({ places });
} catch (err) {
console.error('Maps search error:', err);
res.status(500).json({ error: 'Fehler bei der Google Places Suche' });
}
});
// GET /api/maps/details/:placeId
router.get('/details/:placeId', authenticate, async (req, res) => {
const { placeId } = req.params;
const user = db.prepare('SELECT maps_api_key FROM users WHERE id = ?').get(req.user.id);
if (!user || !user.maps_api_key) {
return res.status(400).json({ error: 'Google Maps API-Schlüssel nicht konfiguriert' });
}
try {
const lang = req.query.lang || 'de'
const response = await fetch(`https://places.googleapis.com/v1/places/${placeId}?languageCode=${lang}`, {
method: 'GET',
headers: {
'X-Goog-Api-Key': user.maps_api_key,
'X-Goog-FieldMask': 'id,displayName,formattedAddress,location,rating,userRatingCount,websiteUri,nationalPhoneNumber,regularOpeningHours,googleMapsUri,reviews,editorialSummary',
},
});
const data = await response.json();
if (!response.ok) {
return res.status(response.status).json({ error: data.error?.message || 'Google Places API Fehler' });
}
const place = {
google_place_id: data.id,
name: data.displayName?.text || '',
address: data.formattedAddress || '',
lat: data.location?.latitude || null,
lng: data.location?.longitude || null,
rating: data.rating || null,
rating_count: data.userRatingCount || null,
website: data.websiteUri || null,
phone: data.nationalPhoneNumber || null,
opening_hours: data.regularOpeningHours?.weekdayDescriptions || null,
open_now: data.regularOpeningHours?.openNow ?? null,
google_maps_url: data.googleMapsUri || null,
summary: data.editorialSummary?.text || null,
reviews: (data.reviews || []).slice(0, 5).map(r => ({
author: r.authorAttribution?.displayName || null,
rating: r.rating || null,
text: r.text?.text || null,
time: r.relativePublishTimeDescription || null,
photo: r.authorAttribution?.photoUri || null,
})),
};
res.json({ place });
} catch (err) {
console.error('Maps details error:', err);
res.status(500).json({ error: 'Fehler beim Abrufen der Ortsdetails' });
}
});
// GET /api/maps/place-photo/:placeId
// Proxies a Google Places photo (hides API key from client). Returns { photoUrl, attribution }.
router.get('/place-photo/:placeId', authenticate, async (req, res) => {
const { placeId } = req.params;
// Check TTL cache
const cached = photoCache.get(placeId);
if (cached && Date.now() - cached.fetchedAt < PHOTO_TTL) {
return res.json({ photoUrl: cached.photoUrl, attribution: cached.attribution });
}
const user = db.prepare('SELECT maps_api_key FROM users WHERE id = ?').get(req.user.id);
if (!user?.maps_api_key) {
return res.status(400).json({ error: 'Google Maps API-Schlüssel nicht konfiguriert' });
}
try {
// Fetch place details to get photo reference
const detailsRes = await fetch(`https://places.googleapis.com/v1/places/${placeId}`, {
headers: {
'X-Goog-Api-Key': user.maps_api_key,
'X-Goog-FieldMask': 'photos',
},
});
const details = await detailsRes.json();
if (!details.photos?.length) {
return res.status(404).json({ error: 'Kein Foto verfügbar' });
}
const photo = details.photos[0];
const photoName = photo.name;
const attribution = photo.authorAttributions?.[0]?.displayName || null;
// Fetch the media URL (skipHttpRedirect returns JSON with photoUri)
const mediaRes = await fetch(
`https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=600&key=${user.maps_api_key}&skipHttpRedirect=true`
);
const mediaData = await mediaRes.json();
const photoUrl = mediaData.photoUri;
if (!photoUrl) {
return res.status(404).json({ error: 'Foto-URL nicht verfügbar' });
}
photoCache.set(placeId, { photoUrl, attribution, fetchedAt: Date.now() });
res.json({ photoUrl, attribution });
} catch (err) {
console.error('Place photo error:', err);
res.status(500).json({ error: 'Fehler beim Abrufen des Fotos' });
}
});
module.exports = router;

View File

@@ -0,0 +1,108 @@
const express = require('express');
const { db, canAccessTrip } = require('../db/database');
const { authenticate } = require('../middleware/auth');
const router = express.Router({ mergeParams: true });
function verifyTripOwnership(tripId, userId) {
return canAccessTrip(tripId, userId);
}
// GET /api/trips/:tripId/packing
router.get('/', authenticate, (req, res) => {
const { tripId } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
const items = db.prepare(
'SELECT * FROM packing_items WHERE trip_id = ? ORDER BY sort_order ASC, created_at ASC'
).all(tripId);
res.json({ items });
});
// POST /api/trips/:tripId/packing
router.post('/', authenticate, (req, res) => {
const { tripId } = req.params;
const { name, category, checked } = req.body;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
if (!name) return res.status(400).json({ error: 'Artikelname ist erforderlich' });
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_items WHERE trip_id = ?').get(tripId);
const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
const result = db.prepare(
'INSERT INTO packing_items (trip_id, name, checked, category, sort_order) VALUES (?, ?, ?, ?, ?)'
).run(tripId, name, checked ? 1 : 0, category || 'Allgemein', sortOrder);
const item = db.prepare('SELECT * FROM packing_items WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json({ item });
});
// PUT /api/trips/:tripId/packing/:id
router.put('/:id', authenticate, (req, res) => {
const { tripId, id } = req.params;
const { name, checked, category } = req.body;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
const item = db.prepare('SELECT * FROM packing_items WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!item) return res.status(404).json({ error: 'Artikel nicht gefunden' });
db.prepare(`
UPDATE packing_items SET
name = COALESCE(?, name),
checked = CASE WHEN ? IS NOT NULL THEN ? ELSE checked END,
category = COALESCE(?, category)
WHERE id = ?
`).run(
name || null,
checked !== undefined ? 1 : null,
checked ? 1 : 0,
category || null,
id
);
const updated = db.prepare('SELECT * FROM packing_items WHERE id = ?').get(id);
res.json({ item: updated });
});
// DELETE /api/trips/:tripId/packing/:id
router.delete('/:id', authenticate, (req, res) => {
const { tripId, id } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
const item = db.prepare('SELECT id FROM packing_items WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!item) return res.status(404).json({ error: 'Artikel nicht gefunden' });
db.prepare('DELETE FROM packing_items WHERE id = ?').run(id);
res.json({ success: true });
});
// PUT /api/trips/:tripId/packing/reorder
router.put('/reorder', authenticate, (req, res) => {
const { tripId } = req.params;
const { orderedIds } = req.body;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
const update = db.prepare('UPDATE packing_items SET sort_order = ? WHERE id = ? AND trip_id = ?');
const updateMany = db.transaction((ids) => {
ids.forEach((id, index) => {
update.run(index, id, tripId);
});
});
updateMany(orderedIds);
res.json({ success: true });
});
module.exports = router;

165
server/src/routes/photos.js Normal file
View File

@@ -0,0 +1,165 @@
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { v4: uuidv4 } = require('uuid');
const { db, canAccessTrip } = require('../db/database');
const { authenticate } = require('../middleware/auth');
const router = express.Router({ mergeParams: true });
const photosDir = path.join(__dirname, '../../uploads/photos');
const storage = multer.diskStorage({
destination: (req, file, cb) => {
if (!fs.existsSync(photosDir)) fs.mkdirSync(photosDir, { recursive: true });
cb(null, photosDir);
},
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, `${uuidv4()}${ext}`);
},
});
const upload = multer({
storage,
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
fileFilter: (req, file, cb) => {
if (file.mimetype.startsWith('image/')) {
cb(null, true);
} else {
cb(new Error('Nur Bilddateien sind erlaubt'));
}
},
});
function formatPhoto(photo) {
return {
...photo,
url: `/uploads/photos/${photo.filename}`,
};
}
// GET /api/trips/:tripId/photos
router.get('/', authenticate, (req, res) => {
const { tripId } = req.params;
const { day_id, place_id } = req.query;
const trip = canAccessTrip(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
let query = 'SELECT * FROM photos WHERE trip_id = ?';
const params = [tripId];
if (day_id) {
query += ' AND day_id = ?';
params.push(day_id);
}
if (place_id) {
query += ' AND place_id = ?';
params.push(place_id);
}
query += ' ORDER BY created_at DESC';
const photos = db.prepare(query).all(...params);
res.json({ photos: photos.map(formatPhoto) });
});
// POST /api/trips/:tripId/photos
router.post('/', authenticate, upload.array('photos', 20), (req, res) => {
const { tripId } = req.params;
const { day_id, place_id, caption } = req.body;
const trip = canAccessTrip(tripId, req.user.id);
if (!trip) {
// Delete uploaded files on auth failure
if (req.files) req.files.forEach(f => fs.unlinkSync(f.path));
return res.status(404).json({ error: 'Reise nicht gefunden' });
}
if (!req.files || req.files.length === 0) {
return res.status(400).json({ error: 'Keine Dateien hochgeladen' });
}
const insertPhoto = db.prepare(`
INSERT INTO photos (trip_id, day_id, place_id, filename, original_name, file_size, mime_type, caption)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
const photos = [];
db.exec('BEGIN');
try {
for (const file of req.files) {
const result = insertPhoto.run(
tripId,
day_id || null,
place_id || null,
file.filename,
file.originalname,
file.size,
file.mimetype,
caption || null
);
const photo = db.prepare('SELECT * FROM photos WHERE id = ?').get(result.lastInsertRowid);
photos.push(formatPhoto(photo));
}
db.exec('COMMIT');
} catch (e) {
db.exec('ROLLBACK');
throw e;
}
res.status(201).json({ photos });
});
// PUT /api/trips/:tripId/photos/:id
router.put('/:id', authenticate, (req, res) => {
const { tripId, id } = req.params;
const { caption, day_id, place_id } = req.body;
const trip = canAccessTrip(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
const photo = db.prepare('SELECT * FROM photos WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!photo) return res.status(404).json({ error: 'Foto nicht gefunden' });
db.prepare(`
UPDATE photos SET
caption = COALESCE(?, caption),
day_id = ?,
place_id = ?
WHERE id = ?
`).run(
caption !== undefined ? caption : photo.caption,
day_id !== undefined ? (day_id || null) : photo.day_id,
place_id !== undefined ? (place_id || null) : photo.place_id,
id
);
const updated = db.prepare('SELECT * FROM photos WHERE id = ?').get(id);
res.json({ photo: formatPhoto(updated) });
});
// DELETE /api/trips/:tripId/photos/:id
router.delete('/:id', authenticate, (req, res) => {
const { tripId, id } = req.params;
const trip = canAccessTrip(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
const photo = db.prepare('SELECT * FROM photos WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!photo) return res.status(404).json({ error: 'Foto nicht gefunden' });
// Delete file
const filePath = path.join(photosDir, photo.filename);
if (fs.existsSync(filePath)) {
try { fs.unlinkSync(filePath); } catch (e) { console.error('Error deleting photo file:', e); }
}
db.prepare('DELETE FROM photos WHERE id = ?').run(id);
res.json({ success: true });
});
module.exports = router;

281
server/src/routes/places.js Normal file
View File

@@ -0,0 +1,281 @@
const express = require('express');
const fetch = require('node-fetch');
const { db, getPlaceWithTags, canAccessTrip } = require('../db/database');
const { authenticate } = require('../middleware/auth');
const router = express.Router({ mergeParams: true });
function verifyTripOwnership(tripId, userId) {
return canAccessTrip(tripId, userId);
}
// GET /api/trips/:tripId/places
router.get('/', authenticate, (req, res) => {
const { tripId } = req.params;
const { search, category, tag } = req.query;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) {
return res.status(404).json({ error: 'Reise nicht gefunden' });
}
let query = `
SELECT DISTINCT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
FROM places p
LEFT JOIN categories c ON p.category_id = c.id
WHERE p.trip_id = ?
`;
const params = [tripId];
if (search) {
query += ' AND (p.name LIKE ? OR p.address LIKE ? OR p.description LIKE ?)';
const searchParam = `%${search}%`;
params.push(searchParam, searchParam, searchParam);
}
if (category) {
query += ' AND p.category_id = ?';
params.push(category);
}
if (tag) {
query += ' AND p.id IN (SELECT place_id FROM place_tags WHERE tag_id = ?)';
params.push(tag);
}
query += ' ORDER BY p.created_at DESC';
const places = db.prepare(query).all(...params);
const placesWithTags = places.map(p => {
const tags = db.prepare(`
SELECT t.* FROM tags t
JOIN place_tags pt ON t.id = pt.tag_id
WHERE pt.place_id = ?
`).all(p.id);
return {
...p,
category: p.category_id ? {
id: p.category_id,
name: p.category_name,
color: p.category_color,
icon: p.category_icon,
} : null,
tags,
};
});
res.json({ places: placesWithTags });
});
// POST /api/trips/:tripId/places
router.post('/', authenticate, (req, res) => {
const { tripId } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) {
return res.status(404).json({ error: 'Reise nicht gefunden' });
}
const {
name, description, lat, lng, address, category_id, price, currency,
reservation_status, reservation_notes, reservation_datetime, place_time,
duration_minutes, notes, image_url, google_place_id, website, phone,
transport_mode, tags = []
} = req.body;
if (!name) {
return res.status(400).json({ error: 'Ortsname ist erforderlich' });
}
const result = db.prepare(`
INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, currency,
reservation_status, reservation_notes, reservation_datetime, place_time,
duration_minutes, notes, image_url, google_place_id, website, phone, transport_mode)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
tripId, name, description || null, lat || null, lng || null, address || null,
category_id || null, price || null, currency || null,
reservation_status || 'none', reservation_notes || null, reservation_datetime || null,
place_time || null, duration_minutes || 60, notes || null, image_url || null,
google_place_id || null, website || null, phone || null, transport_mode || 'walking'
);
const placeId = result.lastInsertRowid;
if (tags && tags.length > 0) {
const insertTag = db.prepare('INSERT OR IGNORE INTO place_tags (place_id, tag_id) VALUES (?, ?)');
for (const tagId of tags) {
insertTag.run(placeId, tagId);
}
}
const place = getPlaceWithTags(placeId);
res.status(201).json({ place });
});
// GET /api/trips/:tripId/places/:id
router.get('/:id', authenticate, (req, res) => {
const { tripId, id } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) {
return res.status(404).json({ error: 'Reise nicht gefunden' });
}
const placeCheck = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!placeCheck) {
return res.status(404).json({ error: 'Ort nicht gefunden' });
}
const place = getPlaceWithTags(id);
res.json({ place });
});
// GET /api/trips/:tripId/places/:id/image - fetch image from Unsplash
router.get('/:id/image', authenticate, async (req, res) => {
const { tripId, id } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) {
return res.status(404).json({ error: 'Reise nicht gefunden' });
}
const place = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!place) {
return res.status(404).json({ error: 'Ort nicht gefunden' });
}
const user = db.prepare('SELECT unsplash_api_key FROM users WHERE id = ?').get(req.user.id);
if (!user || !user.unsplash_api_key) {
return res.status(400).json({ error: 'Kein Unsplash API-Schlüssel konfiguriert' });
}
try {
const query = encodeURIComponent(place.name + (place.address ? ' ' + place.address : ''));
const response = await fetch(
`https://api.unsplash.com/search/photos?query=${query}&per_page=5&client_id=${user.unsplash_api_key}`
);
const data = await response.json();
if (!response.ok) {
return res.status(response.status).json({ error: data.errors?.[0] || 'Unsplash API Fehler' });
}
const photos = (data.results || []).map(p => ({
id: p.id,
url: p.urls?.regular,
thumb: p.urls?.thumb,
description: p.description || p.alt_description,
photographer: p.user?.name,
link: p.links?.html,
}));
res.json({ photos });
} catch (err) {
console.error('Unsplash error:', err);
res.status(500).json({ error: 'Fehler beim Suchen des Bildes' });
}
});
// PUT /api/trips/:tripId/places/:id
router.put('/:id', authenticate, (req, res) => {
const { tripId, id } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) {
return res.status(404).json({ error: 'Reise nicht gefunden' });
}
const existingPlace = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!existingPlace) {
return res.status(404).json({ error: 'Ort nicht gefunden' });
}
const {
name, description, lat, lng, address, category_id, price, currency,
reservation_status, reservation_notes, reservation_datetime, place_time,
duration_minutes, notes, image_url, google_place_id, website, phone,
transport_mode, tags
} = req.body;
db.prepare(`
UPDATE places SET
name = COALESCE(?, name),
description = ?,
lat = ?,
lng = ?,
address = ?,
category_id = ?,
price = ?,
currency = COALESCE(?, currency),
reservation_status = COALESCE(?, reservation_status),
reservation_notes = ?,
reservation_datetime = ?,
place_time = ?,
duration_minutes = COALESCE(?, duration_minutes),
notes = ?,
image_url = ?,
google_place_id = ?,
website = ?,
phone = ?,
transport_mode = COALESCE(?, transport_mode),
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(
name || null,
description !== undefined ? description : existingPlace.description,
lat !== undefined ? lat : existingPlace.lat,
lng !== undefined ? lng : existingPlace.lng,
address !== undefined ? address : existingPlace.address,
category_id !== undefined ? category_id : existingPlace.category_id,
price !== undefined ? price : existingPlace.price,
currency || null,
reservation_status || null,
reservation_notes !== undefined ? reservation_notes : existingPlace.reservation_notes,
reservation_datetime !== undefined ? reservation_datetime : existingPlace.reservation_datetime,
place_time !== undefined ? place_time : existingPlace.place_time,
duration_minutes || null,
notes !== undefined ? notes : existingPlace.notes,
image_url !== undefined ? image_url : existingPlace.image_url,
google_place_id !== undefined ? google_place_id : existingPlace.google_place_id,
website !== undefined ? website : existingPlace.website,
phone !== undefined ? phone : existingPlace.phone,
transport_mode || null,
id
);
if (tags !== undefined) {
db.prepare('DELETE FROM place_tags WHERE place_id = ?').run(id);
if (tags.length > 0) {
const insertTag = db.prepare('INSERT OR IGNORE INTO place_tags (place_id, tag_id) VALUES (?, ?)');
for (const tagId of tags) {
insertTag.run(id, tagId);
}
}
}
const place = getPlaceWithTags(id);
res.json({ place });
});
// DELETE /api/trips/:tripId/places/:id
router.delete('/:id', authenticate, (req, res) => {
const { tripId, id } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) {
return res.status(404).json({ error: 'Reise nicht gefunden' });
}
const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!place) {
return res.status(404).json({ error: 'Ort nicht gefunden' });
}
db.prepare('DELETE FROM places WHERE id = ?').run(id);
res.json({ success: true });
});
module.exports = router;

View File

@@ -0,0 +1,128 @@
const express = require('express');
const { db, canAccessTrip } = require('../db/database');
const { authenticate } = require('../middleware/auth');
const router = express.Router({ mergeParams: true });
function verifyTripOwnership(tripId, userId) {
return canAccessTrip(tripId, userId);
}
// GET /api/trips/:tripId/reservations
router.get('/', authenticate, (req, res) => {
const { tripId } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
const reservations = db.prepare(`
SELECT r.*, d.day_number, p.name as place_name
FROM reservations r
LEFT JOIN days d ON r.day_id = d.id
LEFT JOIN places p ON r.place_id = p.id
WHERE r.trip_id = ?
ORDER BY r.reservation_time ASC, r.created_at ASC
`).all(tripId);
res.json({ reservations });
});
// POST /api/trips/:tripId/reservations
router.post('/', authenticate, (req, res) => {
const { tripId } = req.params;
const { title, reservation_time, location, confirmation_number, notes, day_id, place_id, status, type } = req.body;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
if (!title) return res.status(400).json({ error: 'Titel ist erforderlich' });
const result = db.prepare(`
INSERT INTO reservations (trip_id, day_id, place_id, title, reservation_time, location, confirmation_number, notes, status, type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
tripId,
day_id || null,
place_id || null,
title,
reservation_time || null,
location || null,
confirmation_number || null,
notes || null,
status || 'pending',
type || 'other'
);
const reservation = db.prepare(`
SELECT r.*, d.day_number, p.name as place_name
FROM reservations r
LEFT JOIN days d ON r.day_id = d.id
LEFT JOIN places p ON r.place_id = p.id
WHERE r.id = ?
`).get(result.lastInsertRowid);
res.status(201).json({ reservation });
});
// PUT /api/trips/:tripId/reservations/:id
router.put('/:id', authenticate, (req, res) => {
const { tripId, id } = req.params;
const { title, reservation_time, location, confirmation_number, notes, day_id, place_id, status, type } = req.body;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
const reservation = db.prepare('SELECT * FROM reservations WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!reservation) return res.status(404).json({ error: 'Reservierung nicht gefunden' });
db.prepare(`
UPDATE reservations SET
title = COALESCE(?, title),
reservation_time = ?,
location = ?,
confirmation_number = ?,
notes = ?,
day_id = ?,
place_id = ?,
status = COALESCE(?, status),
type = COALESCE(?, type)
WHERE id = ?
`).run(
title || null,
reservation_time !== undefined ? (reservation_time || null) : reservation.reservation_time,
location !== undefined ? (location || null) : reservation.location,
confirmation_number !== undefined ? (confirmation_number || null) : reservation.confirmation_number,
notes !== undefined ? (notes || null) : reservation.notes,
day_id !== undefined ? (day_id || null) : reservation.day_id,
place_id !== undefined ? (place_id || null) : reservation.place_id,
status || null,
type || null,
id
);
const updated = db.prepare(`
SELECT r.*, d.day_number, p.name as place_name
FROM reservations r
LEFT JOIN days d ON r.day_id = d.id
LEFT JOIN places p ON r.place_id = p.id
WHERE r.id = ?
`).get(id);
res.json({ reservation: updated });
});
// DELETE /api/trips/:tripId/reservations/:id
router.delete('/:id', authenticate, (req, res) => {
const { tripId, id } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
const reservation = db.prepare('SELECT id FROM reservations WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!reservation) return res.status(404).json({ error: 'Reservierung nicht gefunden' });
db.prepare('DELETE FROM reservations WHERE id = ?').run(id);
res.json({ success: true });
});
module.exports = router;

View File

@@ -0,0 +1,65 @@
const express = require('express');
const { db } = require('../db/database');
const { authenticate } = require('../middleware/auth');
const router = express.Router();
// GET /api/settings - return all settings for user
router.get('/', authenticate, (req, res) => {
const rows = db.prepare('SELECT key, value FROM settings WHERE user_id = ?').all(req.user.id);
const settings = {};
for (const row of rows) {
try {
settings[row.key] = JSON.parse(row.value);
} catch {
settings[row.key] = row.value;
}
}
res.json({ settings });
});
// PUT /api/settings - upsert single setting
router.put('/', authenticate, (req, res) => {
const { key, value } = req.body;
if (!key) return res.status(400).json({ error: 'Schlüssel ist erforderlich' });
const serialized = typeof value === 'object' ? JSON.stringify(value) : String(value !== undefined ? value : '');
db.prepare(`
INSERT INTO settings (user_id, key, value) VALUES (?, ?, ?)
ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value
`).run(req.user.id, key, serialized);
res.json({ success: true, key, value });
});
// POST /api/settings/bulk - upsert multiple settings
router.post('/bulk', authenticate, (req, res) => {
const { settings } = req.body;
if (!settings || typeof settings !== 'object') {
return res.status(400).json({ error: 'Einstellungen-Objekt ist erforderlich' });
}
const upsert = db.prepare(`
INSERT INTO settings (user_id, key, value) VALUES (?, ?, ?)
ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value
`);
try {
db.exec('BEGIN');
for (const [key, value] of Object.entries(settings)) {
const serialized = typeof value === 'object' ? JSON.stringify(value) : String(value !== undefined ? value : '');
upsert.run(req.user.id, key, serialized);
}
db.exec('COMMIT');
} catch (err) {
db.exec('ROLLBACK');
return res.status(500).json({ error: 'Fehler beim Speichern der Einstellungen', detail: err.message });
}
res.json({ success: true, updated: Object.keys(settings).length });
});
module.exports = router;

52
server/src/routes/tags.js Normal file
View File

@@ -0,0 +1,52 @@
const express = require('express');
const { db } = require('../db/database');
const { authenticate } = require('../middleware/auth');
const router = express.Router();
// GET /api/tags
router.get('/', authenticate, (req, res) => {
const tags = db.prepare(
'SELECT * FROM tags WHERE user_id = ? ORDER BY name ASC'
).all(req.user.id);
res.json({ tags });
});
// POST /api/tags
router.post('/', authenticate, (req, res) => {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'Tag-Name ist erforderlich' });
const result = db.prepare(
'INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)'
).run(req.user.id, name, color || '#10b981');
const tag = db.prepare('SELECT * FROM tags WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json({ tag });
});
// PUT /api/tags/:id
router.put('/:id', authenticate, (req, res) => {
const { name, color } = req.body;
const tag = db.prepare('SELECT * FROM tags WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id);
if (!tag) return res.status(404).json({ error: 'Tag nicht gefunden' });
db.prepare('UPDATE tags SET name = COALESCE(?, name), color = COALESCE(?, color) WHERE id = ?')
.run(name || null, color || null, req.params.id);
const updated = db.prepare('SELECT * FROM tags WHERE id = ?').get(req.params.id);
res.json({ tag: updated });
});
// DELETE /api/tags/:id
router.delete('/:id', authenticate, (req, res) => {
const tag = db.prepare('SELECT * FROM tags WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id);
if (!tag) return res.status(404).json({ error: 'Tag nicht gefunden' });
db.prepare('DELETE FROM tags WHERE id = ?').run(req.params.id);
res.json({ success: true });
});
module.exports = router;

235
server/src/routes/trips.js Normal file
View File

@@ -0,0 +1,235 @@
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { v4: uuidv4 } = require('uuid');
const { db, canAccessTrip, isOwner } = require('../db/database');
const { authenticate } = require('../middleware/auth');
const router = express.Router();
const coversDir = path.join(__dirname, '../../uploads/covers');
const coverStorage = multer.diskStorage({
destination: (req, file, cb) => {
if (!fs.existsSync(coversDir)) fs.mkdirSync(coversDir, { recursive: true });
cb(null, coversDir);
},
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, `${uuidv4()}${ext}`);
},
});
const uploadCover = multer({
storage: coverStorage,
limits: { fileSize: 20 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
if (file.mimetype.startsWith('image/')) cb(null, true);
else cb(new Error('Nur Bilder erlaubt'));
},
});
const TRIP_SELECT = `
SELECT t.*,
(SELECT COUNT(*) FROM days d WHERE d.trip_id = t.id) as day_count,
(SELECT COUNT(*) FROM places p WHERE p.trip_id = t.id) as place_count,
CASE WHEN t.user_id = :userId THEN 1 ELSE 0 END as is_owner,
u.username as owner_username,
(SELECT COUNT(*) FROM trip_members tm WHERE tm.trip_id = t.id) as shared_count
FROM trips t
JOIN users u ON u.id = t.user_id
`;
function generateDays(tripId, startDate, endDate) {
db.prepare('DELETE FROM days WHERE trip_id = ?').run(tripId);
if (!startDate || !endDate) {
const insert = db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, NULL)');
for (let i = 1; i <= 7; i++) insert.run(tripId, i);
return;
}
const start = new Date(startDate);
const end = new Date(endDate);
const numDays = Math.min(Math.floor((end - start) / 86400000) + 1, 90);
const insert = db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)');
for (let i = 0; i < numDays; i++) {
const d = new Date(start);
d.setDate(start.getDate() + i);
insert.run(tripId, i + 1, d.toISOString().split('T')[0]);
}
}
// GET /api/trips — active or archived, includes shared trips
router.get('/', authenticate, (req, res) => {
const archived = req.query.archived === '1' ? 1 : 0;
const userId = req.user.id;
const trips = db.prepare(`
${TRIP_SELECT}
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId
WHERE (t.user_id = :userId OR m.user_id IS NOT NULL) AND t.is_archived = :archived
ORDER BY t.created_at DESC
`).all({ userId, archived });
res.json({ trips });
});
// POST /api/trips
router.post('/', authenticate, (req, res) => {
const { title, description, start_date, end_date, currency } = req.body;
if (!title) return res.status(400).json({ error: 'Titel ist erforderlich' });
if (start_date && end_date && new Date(end_date) < new Date(start_date))
return res.status(400).json({ error: 'Enddatum muss nach dem Startdatum liegen' });
const result = db.prepare(`
INSERT INTO trips (user_id, title, description, start_date, end_date, currency)
VALUES (?, ?, ?, ?, ?, ?)
`).run(req.user.id, title, description || null, start_date || null, end_date || null, currency || 'EUR');
const tripId = result.lastInsertRowid;
generateDays(tripId, start_date, end_date);
const trip = db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId: req.user.id, tripId });
res.status(201).json({ trip });
});
// GET /api/trips/:id
router.get('/:id', authenticate, (req, res) => {
const userId = req.user.id;
const trip = db.prepare(`
${TRIP_SELECT}
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId
WHERE t.id = :tripId AND (t.user_id = :userId OR m.user_id IS NOT NULL)
`).get({ userId, tripId: req.params.id });
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
res.json({ trip });
});
// PUT /api/trips/:id — all members can edit; archive/cover owner-only
router.put('/:id', authenticate, (req, res) => {
const access = canAccessTrip(req.params.id, req.user.id);
if (!access) return res.status(404).json({ error: 'Reise nicht gefunden' });
const ownerOnly = req.body.is_archived !== undefined || req.body.cover_image !== undefined;
if (ownerOnly && !isOwner(req.params.id, req.user.id))
return res.status(403).json({ error: 'Nur der Eigentümer kann diese Einstellung ändern' });
const trip = db.prepare('SELECT * FROM trips WHERE id = ?').get(req.params.id);
const { title, description, start_date, end_date, currency, is_archived, cover_image } = req.body;
if (start_date && end_date && new Date(end_date) < new Date(start_date))
return res.status(400).json({ error: 'Enddatum muss nach dem Startdatum liegen' });
const newTitle = title || trip.title;
const newDesc = description !== undefined ? description : trip.description;
const newStart = start_date !== undefined ? start_date : trip.start_date;
const newEnd = end_date !== undefined ? end_date : trip.end_date;
const newCurrency = currency || trip.currency;
const newArchived = is_archived !== undefined ? (is_archived ? 1 : 0) : trip.is_archived;
const newCover = cover_image !== undefined ? cover_image : trip.cover_image;
db.prepare(`
UPDATE trips SET title=?, description=?, start_date=?, end_date=?,
currency=?, is_archived=?, cover_image=?, updated_at=CURRENT_TIMESTAMP
WHERE id=?
`).run(newTitle, newDesc, newStart || null, newEnd || null, newCurrency, newArchived, newCover, req.params.id);
if (newStart !== trip.start_date || newEnd !== trip.end_date)
generateDays(req.params.id, newStart, newEnd);
const updatedTrip = db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId: req.user.id, tripId: req.params.id });
res.json({ trip: updatedTrip });
});
// POST /api/trips/:id/cover
router.post('/:id/cover', authenticate, uploadCover.single('cover'), (req, res) => {
if (!isOwner(req.params.id, req.user.id))
return res.status(403).json({ error: 'Nur der Eigentümer kann das Titelbild ändern' });
const trip = db.prepare('SELECT * FROM trips WHERE id = ?').get(req.params.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
if (!req.file) return res.status(400).json({ error: 'Kein Bild hochgeladen' });
if (trip.cover_image) {
const oldPath = path.join(__dirname, '../../', trip.cover_image.replace(/^\//, ''));
if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
}
const coverUrl = `/uploads/covers/${req.file.filename}`;
db.prepare('UPDATE trips SET cover_image=?, updated_at=CURRENT_TIMESTAMP WHERE id=?').run(coverUrl, req.params.id);
res.json({ cover_image: coverUrl });
});
// DELETE /api/trips/:id — owner only
router.delete('/:id', authenticate, (req, res) => {
if (!isOwner(req.params.id, req.user.id))
return res.status(403).json({ error: 'Nur der Eigentümer kann die Reise löschen' });
db.prepare('DELETE FROM trips WHERE id = ?').run(req.params.id);
res.json({ success: true });
});
// ── Member Management ────────────────────────────────────────────────────────
// GET /api/trips/:id/members
router.get('/:id/members', authenticate, (req, res) => {
if (!canAccessTrip(req.params.id, req.user.id))
return res.status(404).json({ error: 'Reise nicht gefunden' });
const trip = db.prepare('SELECT user_id FROM trips WHERE id = ?').get(req.params.id);
const members = db.prepare(`
SELECT u.id, u.username, u.email, u.avatar,
CASE WHEN u.id = ? THEN 'owner' ELSE 'member' END as role,
m.added_at,
ib.username as invited_by_username
FROM trip_members m
JOIN users u ON u.id = m.user_id
LEFT JOIN users ib ON ib.id = m.invited_by
WHERE m.trip_id = ?
ORDER BY m.added_at ASC
`).all(trip.user_id, req.params.id);
const owner = db.prepare('SELECT id, username, email, avatar FROM users WHERE id = ?').get(trip.user_id);
res.json({
owner: { ...owner, role: 'owner', avatar_url: owner.avatar ? `/uploads/avatars/${owner.avatar}` : null },
members: members.map(m => ({ ...m, avatar_url: m.avatar ? `/uploads/avatars/${m.avatar}` : null })),
current_user_id: req.user.id,
});
});
// POST /api/trips/:id/members — add by email or username
router.post('/:id/members', authenticate, (req, res) => {
if (!canAccessTrip(req.params.id, req.user.id))
return res.status(404).json({ error: 'Reise nicht gefunden' });
const { identifier } = req.body; // email or username
if (!identifier) return res.status(400).json({ error: 'E-Mail oder Benutzername erforderlich' });
const target = db.prepare(
'SELECT id, username, email, avatar FROM users WHERE email = ? OR username = ?'
).get(identifier.trim(), identifier.trim());
if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden' });
const trip = db.prepare('SELECT user_id FROM trips WHERE id = ?').get(req.params.id);
if (target.id === trip.user_id)
return res.status(400).json({ error: 'Der Eigentümer der Reise ist bereits Mitglied' });
const existing = db.prepare('SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(req.params.id, target.id);
if (existing) return res.status(400).json({ error: 'Benutzer hat bereits Zugriff' });
db.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(req.params.id, target.id, req.user.id);
res.status(201).json({ member: { ...target, role: 'member', avatar_url: target.avatar ? `/uploads/avatars/${target.avatar}` : null } });
});
// DELETE /api/trips/:id/members/:userId — owner removes anyone; member removes self
router.delete('/:id/members/:userId', authenticate, (req, res) => {
if (!canAccessTrip(req.params.id, req.user.id))
return res.status(404).json({ error: 'Reise nicht gefunden' });
const targetId = parseInt(req.params.userId);
const isSelf = targetId === req.user.id;
if (!isSelf && !isOwner(req.params.id, req.user.id))
return res.status(403).json({ error: 'Keine Berechtigung' });
db.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(req.params.id, targetId);
res.json({ success: true });
});
module.exports = router;

View File

@@ -0,0 +1,85 @@
const express = require('express');
const fetch = require('node-fetch');
const { db } = require('../db/database');
const { authenticate } = require('../middleware/auth');
const router = express.Router();
function formatItem(item) {
return {
temp: Math.round(item.main.temp),
feels_like: Math.round(item.main.feels_like),
humidity: item.main.humidity,
main: item.weather[0]?.main || '',
description: item.weather[0]?.description || '',
icon: item.weather[0]?.icon || '',
};
}
// GET /api/weather?lat=&lng=&date=&units=metric
router.get('/', authenticate, async (req, res) => {
const { lat, lng, date, units = 'metric' } = req.query;
if (!lat || !lng) {
return res.status(400).json({ error: 'Breiten- und Längengrad sind erforderlich' });
}
const user = db.prepare('SELECT openweather_api_key FROM users WHERE id = ?').get(req.user.id);
if (!user || !user.openweather_api_key) {
return res.status(400).json({ error: 'Kein API-Schlüssel konfiguriert' });
}
const key = user.openweather_api_key;
try {
// If a date is requested, try the 5-day forecast first
if (date) {
const targetDate = new Date(date);
const now = new Date();
const diffDays = (targetDate - now) / (1000 * 60 * 60 * 24);
// Within 5-day forecast window
if (diffDays >= -1 && diffDays <= 5) {
const url = `https://api.openweathermap.org/data/2.5/forecast?lat=${lat}&lon=${lng}&appid=${key}&units=${units}&lang=de`;
const response = await fetch(url);
const data = await response.json();
if (!response.ok) {
return res.status(response.status).json({ error: data.message || 'OpenWeatherMap API Fehler' });
}
const filtered = (data.list || []).filter(item => {
const itemDate = new Date(item.dt * 1000);
return itemDate.toDateString() === targetDate.toDateString();
});
if (filtered.length > 0) {
const midday = filtered.find(item => {
const hour = new Date(item.dt * 1000).getHours();
return hour >= 11 && hour <= 14;
}) || filtered[0];
return res.json(formatItem(midday));
}
}
// Outside forecast window — no data available
return res.json({ error: 'no_forecast' });
}
// No date — return current weather
const url = `https://api.openweathermap.org/data/2.5/weather?lat=${lat}&lon=${lng}&appid=${key}&units=${units}&lang=de`;
const response = await fetch(url);
const data = await response.json();
if (!response.ok) {
return res.status(response.status).json({ error: data.message || 'OpenWeatherMap API Fehler' });
}
res.json(formatItem(data));
} catch (err) {
console.error('Weather error:', err);
res.status(500).json({ error: 'Fehler beim Abrufen der Wetterdaten' });
}
});
module.exports = router;