v2.1.0 — Real-time collaboration, performance & security overhaul
Real-Time Collaboration (WebSocket): - WebSocket server with JWT auth and trip-based rooms - Live sync for all CRUD operations (places, assignments, days, notes, budget, packing, reservations, files) - Socket-based exclusion to prevent duplicate updates - Auto-reconnect with exponential backoff - Assignment move sync between days Performance: - 16 database indexes on all foreign key columns - N+1 query fix in places, assignments and days endpoints - Marker clustering (react-leaflet-cluster) with configurable radius - List virtualization (react-window) for places sidebar - useMemo for filtered places - SQLite WAL mode + busy_timeout for concurrent writes - Weather API: server-side cache (1h forecast, 15min current) + client sessionStorage - Google Places photos: persisted to DB after first fetch - Google Details: 3-tier cache (memory → sessionStorage → API) Security: - CORS auto-configuration (production: same-origin, dev: open) - API keys removed from /auth/me response - Admin-only endpoint for reading API keys - Path traversal prevention in cover image deletion - JWT secret persisted to file (survives restarts) - Avatar upload file extension whitelist - API key fallback: normal users use admin's key without exposure - Case-insensitive email login Dark Mode: - Fixed hardcoded colors across PackingList, Budget, ReservationModal, ReservationsPanel - Mobile map buttons and sidebar sheets respect dark mode - Cluster markers always dark UI/UX: - Redesigned login page with animated planes, stars and feature cards - Admin: create user functionality with CustomSelect - Mobile: day-picker popup for assigning places to days - Mobile: touch-friendly reorder buttons (32px targets) - Mobile: responsive text (shorter labels on small screens) - Packing list: index-based category colors - i18n: translated date picker placeholder, fixed German labels - Default map tile: CartoDB Light
This commit is contained in:
24
server/package-lock.json
generated
24
server/package-lock.json
generated
@@ -18,7 +18,8 @@
|
||||
"node-cron": "^4.2.1",
|
||||
"node-fetch": "^2.7.0",
|
||||
"unzipper": "^0.12.3",
|
||||
"uuid": "^9.0.0"
|
||||
"uuid": "^9.0.0",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.0"
|
||||
@@ -2106,6 +2107,27 @@
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
"node-cron": "^4.2.1",
|
||||
"node-fetch": "^2.7.0",
|
||||
"unzipper": "^0.12.3",
|
||||
"uuid": "^9.0.0"
|
||||
"uuid": "^9.0.0",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.0"
|
||||
|
||||
@@ -1,10 +1,28 @@
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
let JWT_SECRET = process.env.JWT_SECRET;
|
||||
|
||||
if (!JWT_SECRET) {
|
||||
JWT_SECRET = crypto.randomBytes(32).toString('hex');
|
||||
console.warn('WARNING: No JWT_SECRET set — using auto-generated secret. Sessions will reset on server restart. Set JWT_SECRET for persistent sessions.');
|
||||
// Try to read a persisted secret from disk
|
||||
const dataDir = path.resolve(__dirname, '../data');
|
||||
const secretFile = path.join(dataDir, '.jwt_secret');
|
||||
|
||||
try {
|
||||
JWT_SECRET = fs.readFileSync(secretFile, 'utf8').trim();
|
||||
} catch {
|
||||
// File doesn't exist yet — generate and persist a new secret
|
||||
JWT_SECRET = crypto.randomBytes(32).toString('hex');
|
||||
try {
|
||||
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
||||
fs.writeFileSync(secretFile, JWT_SECRET, { mode: 0o600 });
|
||||
console.log('Generated and saved JWT secret to', secretFile);
|
||||
} catch (writeErr) {
|
||||
console.warn('WARNING: Could not persist JWT secret to disk:', writeErr.message);
|
||||
console.warn('Sessions will reset on server restart. Set JWT_SECRET env var for persistent sessions.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { JWT_SECRET };
|
||||
|
||||
@@ -21,6 +21,7 @@ function initDb() {
|
||||
|
||||
_db = new DatabaseSync(dbPath);
|
||||
_db.exec('PRAGMA journal_mode = WAL');
|
||||
_db.exec('PRAGMA busy_timeout = 5000');
|
||||
_db.exec('PRAGMA foreign_keys = ON');
|
||||
|
||||
// Create all tables
|
||||
@@ -212,6 +213,26 @@ function initDb() {
|
||||
);
|
||||
`);
|
||||
|
||||
// Create indexes for performance
|
||||
_db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_places_trip_id ON places(trip_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_places_category_id ON places(category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_days_trip_id ON days(trip_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_day_assignments_day_id ON day_assignments(day_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_day_assignments_place_id ON day_assignments(place_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_place_tags_place_id ON place_tags(place_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_place_tags_tag_id ON place_tags(tag_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_trip_members_trip_id ON trip_members(trip_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_trip_members_user_id ON trip_members(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_packing_items_trip_id ON packing_items(trip_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_budget_items_trip_id ON budget_items(trip_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_reservations_trip_id ON reservations(trip_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_trip_files_trip_id ON trip_files(trip_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_day_notes_day_id ON day_notes(day_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_photos_trip_id ON photos(trip_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
`);
|
||||
|
||||
// Migrations
|
||||
const migrations = [
|
||||
`ALTER TABLE users ADD COLUMN unsplash_api_key TEXT`,
|
||||
|
||||
@@ -22,13 +22,24 @@ const tmpDir = path.join(__dirname, '../data/tmp');
|
||||
const allowedOrigins = process.env.ALLOWED_ORIGINS
|
||||
? process.env.ALLOWED_ORIGINS.split(',')
|
||||
: null;
|
||||
|
||||
let corsOrigin;
|
||||
if (allowedOrigins) {
|
||||
// Explicit whitelist from env var
|
||||
corsOrigin = (origin, callback) => {
|
||||
if (!origin || allowedOrigins.includes(origin)) callback(null, true);
|
||||
else callback(new Error('Not allowed by CORS'));
|
||||
};
|
||||
} else if (process.env.NODE_ENV === 'production') {
|
||||
// Production: same-origin only (Express serves the static client)
|
||||
corsOrigin = false;
|
||||
} else {
|
||||
// Development: allow all origins (needed for Vite dev server)
|
||||
corsOrigin = true;
|
||||
}
|
||||
|
||||
app.use(cors({
|
||||
origin: allowedOrigins
|
||||
? (origin, callback) => {
|
||||
if (!origin || allowedOrigins.includes(origin)) callback(null, true);
|
||||
else callback(new Error('Not allowed by CORS'));
|
||||
}
|
||||
: true,
|
||||
origin: corsOrigin,
|
||||
credentials: true
|
||||
}));
|
||||
app.use(express.json());
|
||||
@@ -101,10 +112,12 @@ app.use((err, req, res, next) => {
|
||||
const scheduler = require('./scheduler');
|
||||
|
||||
const PORT = process.env.PORT || 3001;
|
||||
app.listen(PORT, () => {
|
||||
const server = app.listen(PORT, () => {
|
||||
console.log(`NOMAD API running on port ${PORT}`);
|
||||
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
|
||||
scheduler.start();
|
||||
const { setupWebSocket } = require('./websocket');
|
||||
setupWebSocket(server);
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
|
||||
@@ -11,11 +11,42 @@ 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'
|
||||
'SELECT id, username, email, role, created_at, updated_at FROM users ORDER BY created_at DESC'
|
||||
).all();
|
||||
res.json({ users });
|
||||
});
|
||||
|
||||
// POST /api/admin/users
|
||||
router.post('/users', (req, res) => {
|
||||
const { username, email, password, role } = req.body;
|
||||
|
||||
if (!username?.trim() || !email?.trim() || !password?.trim()) {
|
||||
return res.status(400).json({ error: 'Benutzername, E-Mail und Passwort sind erforderlich' });
|
||||
}
|
||||
|
||||
if (role && !['user', 'admin'].includes(role)) {
|
||||
return res.status(400).json({ error: 'Ungültige Rolle' });
|
||||
}
|
||||
|
||||
const existingUsername = db.prepare('SELECT id FROM users WHERE username = ?').get(username.trim());
|
||||
if (existingUsername) return res.status(409).json({ error: 'Benutzername bereits vergeben' });
|
||||
|
||||
const existingEmail = db.prepare('SELECT id FROM users WHERE email = ?').get(email.trim());
|
||||
if (existingEmail) return res.status(409).json({ error: 'E-Mail bereits vergeben' });
|
||||
|
||||
const passwordHash = bcrypt.hashSync(password.trim(), 10);
|
||||
|
||||
const result = db.prepare(
|
||||
'INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)'
|
||||
).run(username.trim(), email.trim(), passwordHash, role || 'user');
|
||||
|
||||
const user = db.prepare(
|
||||
'SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?'
|
||||
).get(result.lastInsertRowid);
|
||||
|
||||
res.status(201).json({ user });
|
||||
});
|
||||
|
||||
// PUT /api/admin/users/:id
|
||||
router.put('/users/:id', (req, res) => {
|
||||
const { username, email, role, password } = req.body;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const express = require('express');
|
||||
const { db, canAccessTrip } = require('../db/database');
|
||||
const { authenticate } = require('../middleware/auth');
|
||||
const { broadcast } = require('../websocket');
|
||||
|
||||
const router = express.Router({ mergeParams: true });
|
||||
|
||||
@@ -90,11 +91,23 @@ router.get('/trips/:tripId/days/:dayId/assignments', authenticate, (req, res) =>
|
||||
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);
|
||||
// Batch-load all tags for all places in one query to avoid N+1
|
||||
const placeIds = [...new Set(assignments.map(a => a.place_id))];
|
||||
const tagsByPlaceId = {};
|
||||
if (placeIds.length > 0) {
|
||||
const placeholders = placeIds.map(() => '?').join(',');
|
||||
const allTags = db.prepare(`
|
||||
SELECT t.*, pt.place_id FROM tags t
|
||||
JOIN place_tags pt ON t.id = pt.tag_id
|
||||
WHERE pt.place_id IN (${placeholders})
|
||||
`).all(...placeIds);
|
||||
for (const tag of allTags) {
|
||||
if (!tagsByPlaceId[tag.place_id]) tagsByPlaceId[tag.place_id] = [];
|
||||
tagsByPlaceId[tag.place_id].push({ id: tag.id, name: tag.name, color: tag.color, created_at: tag.created_at });
|
||||
}
|
||||
}
|
||||
|
||||
const result = assignments.map(a => {
|
||||
return {
|
||||
id: a.id,
|
||||
day_id: a.day_id,
|
||||
@@ -128,7 +141,7 @@ router.get('/trips/:tripId/days/:dayId/assignments', authenticate, (req, res) =>
|
||||
color: a.category_color,
|
||||
icon: a.category_icon,
|
||||
} : null,
|
||||
tags,
|
||||
tags: tagsByPlaceId[a.place_id] || [],
|
||||
}
|
||||
};
|
||||
});
|
||||
@@ -150,7 +163,6 @@ router.post('/trips/:tripId/days/:dayId/assignments', authenticate, (req, res) =
|
||||
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' });
|
||||
|
||||
@@ -163,6 +175,7 @@ router.post('/trips/:tripId/days/:dayId/assignments', authenticate, (req, res) =
|
||||
|
||||
const assignment = getAssignmentWithPlace(result.lastInsertRowid);
|
||||
res.status(201).json({ assignment });
|
||||
broadcast(tripId, 'assignment:created', { assignment }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
// DELETE /api/trips/:tripId/days/:dayId/assignments/:id
|
||||
@@ -180,6 +193,7 @@ router.delete('/trips/:tripId/days/:dayId/assignments/:id', authenticate, (req,
|
||||
|
||||
db.prepare('DELETE FROM day_assignments WHERE id = ?').run(id);
|
||||
res.json({ success: true });
|
||||
broadcast(tripId, 'assignment:deleted', { assignmentId: Number(id), dayId: Number(dayId) }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
// PUT /api/trips/:tripId/days/:dayId/assignments/reorder
|
||||
@@ -205,6 +219,7 @@ router.put('/trips/:tripId/days/:dayId/assignments/reorder', authenticate, (req,
|
||||
throw e;
|
||||
}
|
||||
res.json({ success: true });
|
||||
broadcast(tripId, 'assignment:reordered', { dayId: Number(dayId), orderedIds }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
// PUT /api/trips/:tripId/assignments/:id/move
|
||||
@@ -226,10 +241,12 @@ router.put('/trips/:tripId/assignments/:id/move', authenticate, (req, res) => {
|
||||
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' });
|
||||
|
||||
const oldDayId = assignment.day_id;
|
||||
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 });
|
||||
broadcast(tripId, 'assignment:moved', { assignment: updated, oldDayId: Number(oldDayId), newDayId: Number(new_day_id) }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -19,9 +19,13 @@ const avatarStorage = multer.diskStorage({
|
||||
destination: (req, file, cb) => cb(null, avatarDir),
|
||||
filename: (req, file, cb) => cb(null, uuid() + path.extname(file.originalname))
|
||||
});
|
||||
const ALLOWED_AVATAR_EXTS = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
|
||||
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'));
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
if (!file.mimetype.startsWith('image/') || !ALLOWED_AVATAR_EXTS.includes(ext)) {
|
||||
return cb(new Error('Only .jpg, .jpeg, .png, .gif, .webp images are allowed'));
|
||||
}
|
||||
cb(null, true);
|
||||
}});
|
||||
|
||||
// Simple rate limiter
|
||||
@@ -90,7 +94,7 @@ router.post('/register', authLimiter, (req, res) => {
|
||||
return res.status(400).json({ error: 'Invalid email format' });
|
||||
}
|
||||
|
||||
const existingUser = db.prepare('SELECT id FROM users WHERE email = ? OR username = ?').get(email, username);
|
||||
const existingUser = db.prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?) OR LOWER(username) = LOWER(?)').get(email, username);
|
||||
if (existingUser) {
|
||||
return res.status(409).json({ error: 'A user with this email or username already exists' });
|
||||
}
|
||||
@@ -123,7 +127,7 @@ router.post('/login', authLimiter, (req, res) => {
|
||||
return res.status(400).json({ error: 'E-Mail und Passwort sind erforderlich' });
|
||||
}
|
||||
|
||||
const user = db.prepare('SELECT * FROM users WHERE email = ?').get(email);
|
||||
const user = db.prepare('SELECT * FROM users WHERE LOWER(email) = LOWER(?)').get(email);
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Ungültige E-Mail oder Passwort' });
|
||||
}
|
||||
@@ -134,15 +138,15 @@ router.post('/login', authLimiter, (req, res) => {
|
||||
}
|
||||
|
||||
const token = generateToken(user);
|
||||
const { password_hash, ...userWithoutPassword } = user;
|
||||
const { password_hash, maps_api_key, openweather_api_key, unsplash_api_key, ...userWithoutSensitive } = user;
|
||||
|
||||
res.json({ token, user: { ...userWithoutPassword, avatar_url: avatarUrl(user) } });
|
||||
res.json({ token, user: { ...userWithoutSensitive, 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 = ?'
|
||||
'SELECT id, username, email, role, avatar, created_at FROM users WHERE id = ?'
|
||||
).get(req.user.id);
|
||||
|
||||
if (!user) {
|
||||
@@ -207,13 +211,14 @@ router.put('/me/settings', authenticate, (req, res) => {
|
||||
res.json({ success: true, user: { ...updated, avatar_url: avatarUrl(updated) } });
|
||||
});
|
||||
|
||||
// GET /api/auth/me/settings
|
||||
// GET /api/auth/me/settings (admin only — returns API keys)
|
||||
router.get('/me/settings', authenticate, (req, res) => {
|
||||
const user = db.prepare(
|
||||
'SELECT maps_api_key, openweather_api_key FROM users WHERE id = ?'
|
||||
'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' });
|
||||
|
||||
res.json({ settings: user });
|
||||
res.json({ settings: { maps_api_key: user.maps_api_key, openweather_api_key: user.openweather_api_key } });
|
||||
});
|
||||
|
||||
// POST /api/auth/avatar — upload avatar
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const express = require('express');
|
||||
const { db, canAccessTrip } = require('../db/database');
|
||||
const { authenticate } = require('../middleware/auth');
|
||||
const { broadcast } = require('../websocket');
|
||||
|
||||
const router = express.Router({ mergeParams: true });
|
||||
|
||||
@@ -50,6 +51,7 @@ router.post('/', authenticate, (req, res) => {
|
||||
|
||||
const item = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(result.lastInsertRowid);
|
||||
res.status(201).json({ item });
|
||||
broadcast(tripId, 'budget:created', { item }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
// PUT /api/trips/:tripId/budget/:id
|
||||
@@ -86,6 +88,7 @@ router.put('/:id', authenticate, (req, res) => {
|
||||
|
||||
const updated = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(id);
|
||||
res.json({ item: updated });
|
||||
broadcast(tripId, 'budget:updated', { item: updated }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
// DELETE /api/trips/:tripId/budget/:id
|
||||
@@ -100,6 +103,7 @@ router.delete('/:id', authenticate, (req, res) => {
|
||||
|
||||
db.prepare('DELETE FROM budget_items WHERE id = ?').run(id);
|
||||
res.json({ success: true });
|
||||
broadcast(tripId, 'budget:deleted', { itemId: Number(id) }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const express = require('express');
|
||||
const { db, canAccessTrip } = require('../db/database');
|
||||
const { authenticate } = require('../middleware/auth');
|
||||
const { broadcast } = require('../websocket');
|
||||
|
||||
const router = express.Router({ mergeParams: true });
|
||||
|
||||
@@ -37,6 +38,7 @@ router.post('/', authenticate, (req, res) => {
|
||||
|
||||
const note = db.prepare('SELECT * FROM day_notes WHERE id = ?').get(result.lastInsertRowid);
|
||||
res.status(201).json({ note });
|
||||
broadcast(tripId, 'dayNote:created', { dayId: Number(dayId), note }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
// PUT /api/trips/:tripId/days/:dayId/notes/:id
|
||||
@@ -60,6 +62,7 @@ router.put('/:id', authenticate, (req, res) => {
|
||||
|
||||
const updated = db.prepare('SELECT * FROM day_notes WHERE id = ?').get(id);
|
||||
res.json({ note: updated });
|
||||
broadcast(tripId, 'dayNote:updated', { dayId: Number(dayId), note: updated }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
// DELETE /api/trips/:tripId/days/:dayId/notes/:id
|
||||
@@ -72,6 +75,7 @@ router.delete('/:id', authenticate, (req, res) => {
|
||||
|
||||
db.prepare('DELETE FROM day_notes WHERE id = ?').run(id);
|
||||
res.json({ success: true });
|
||||
broadcast(tripId, 'dayNote:deleted', { noteId: Number(id), dayId: Number(dayId) }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const express = require('express');
|
||||
const { db, canAccessTrip } = require('../db/database');
|
||||
const { authenticate } = require('../middleware/auth');
|
||||
const { broadcast } = require('../websocket');
|
||||
|
||||
const router = express.Router({ mergeParams: true });
|
||||
|
||||
@@ -79,12 +80,99 @@ router.get('/', authenticate, (req, res) => {
|
||||
|
||||
const days = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(tripId);
|
||||
|
||||
if (days.length === 0) {
|
||||
return res.json({ days: [] });
|
||||
}
|
||||
|
||||
const dayIds = days.map(d => d.id);
|
||||
const dayPlaceholders = dayIds.map(() => '?').join(',');
|
||||
|
||||
// Load ALL assignments for all days in one query
|
||||
const allAssignments = 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 IN (${dayPlaceholders})
|
||||
ORDER BY da.order_index ASC, da.created_at ASC
|
||||
`).all(...dayIds);
|
||||
|
||||
// Batch-load ALL tags for all places across all assignments
|
||||
const placeIds = [...new Set(allAssignments.map(a => a.place_id))];
|
||||
const tagsByPlaceId = {};
|
||||
if (placeIds.length > 0) {
|
||||
const placePlaceholders = placeIds.map(() => '?').join(',');
|
||||
const allTags = db.prepare(`
|
||||
SELECT t.*, pt.place_id FROM tags t
|
||||
JOIN place_tags pt ON t.id = pt.tag_id
|
||||
WHERE pt.place_id IN (${placePlaceholders})
|
||||
`).all(...placeIds);
|
||||
for (const tag of allTags) {
|
||||
if (!tagsByPlaceId[tag.place_id]) tagsByPlaceId[tag.place_id] = [];
|
||||
tagsByPlaceId[tag.place_id].push({ id: tag.id, name: tag.name, color: tag.color, created_at: tag.created_at });
|
||||
}
|
||||
}
|
||||
|
||||
// Group assignments by day_id
|
||||
const assignmentsByDayId = {};
|
||||
for (const a of allAssignments) {
|
||||
if (!assignmentsByDayId[a.day_id]) assignmentsByDayId[a.day_id] = [];
|
||||
assignmentsByDayId[a.day_id].push({
|
||||
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: tagsByPlaceId[a.place_id] || [],
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Load ALL day_notes for all days in one query
|
||||
const allNotes = db.prepare(
|
||||
`SELECT * FROM day_notes WHERE day_id IN (${dayPlaceholders}) ORDER BY sort_order ASC, created_at ASC`
|
||||
).all(...dayIds);
|
||||
const notesByDayId = {};
|
||||
for (const note of allNotes) {
|
||||
if (!notesByDayId[note.day_id]) notesByDayId[note.day_id] = [];
|
||||
notesByDayId[note.day_id].push(note);
|
||||
}
|
||||
|
||||
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),
|
||||
assignments: assignmentsByDayId[day.id] || [],
|
||||
notes_items: notesByDayId[day.id] || [],
|
||||
}));
|
||||
|
||||
res.json({ days: daysWithAssignments });
|
||||
@@ -110,7 +198,9 @@ router.post('/', authenticate, (req, res) => {
|
||||
|
||||
const day = db.prepare('SELECT * FROM days WHERE id = ?').get(result.lastInsertRowid);
|
||||
|
||||
res.status(201).json({ day: { ...day, assignments: [] } });
|
||||
const dayResult = { ...day, assignments: [] };
|
||||
res.status(201).json({ day: dayResult });
|
||||
broadcast(tripId, 'day:created', { day: dayResult }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
// PUT /api/trips/:tripId/days/:id
|
||||
@@ -131,7 +221,9 @@ router.put('/:id', authenticate, (req, res) => {
|
||||
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) } });
|
||||
const dayWithAssignments = { ...updatedDay, assignments: getAssignmentsForDay(id) };
|
||||
res.json({ day: dayWithAssignments });
|
||||
broadcast(tripId, 'day:updated', { day: dayWithAssignments }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
// DELETE /api/trips/:tripId/days/:id
|
||||
@@ -150,6 +242,7 @@ router.delete('/:id', authenticate, (req, res) => {
|
||||
|
||||
db.prepare('DELETE FROM days WHERE id = ?').run(id);
|
||||
res.json({ success: true });
|
||||
broadcast(tripId, 'day:deleted', { dayId: Number(id) }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -5,6 +5,7 @@ const fs = require('fs');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { db, canAccessTrip } = require('../db/database');
|
||||
const { authenticate } = require('../middleware/auth');
|
||||
const { broadcast } = require('../websocket');
|
||||
|
||||
const router = express.Router({ mergeParams: true });
|
||||
|
||||
@@ -106,6 +107,7 @@ router.post('/', authenticate, upload.single('file'), (req, res) => {
|
||||
WHERE f.id = ?
|
||||
`).get(result.lastInsertRowid);
|
||||
res.status(201).json({ file: formatFile(file) });
|
||||
broadcast(tripId, 'file:created', { file: formatFile(file) }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
// PUT /api/trips/:tripId/files/:id
|
||||
@@ -139,6 +141,7 @@ router.put('/:id', authenticate, (req, res) => {
|
||||
WHERE f.id = ?
|
||||
`).get(id);
|
||||
res.json({ file: formatFile(updated) });
|
||||
broadcast(tripId, 'file:updated', { file: formatFile(updated) }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
// DELETE /api/trips/:tripId/files/:id
|
||||
@@ -158,6 +161,7 @@ router.delete('/:id', authenticate, (req, res) => {
|
||||
|
||||
db.prepare('DELETE FROM trip_files WHERE id = ?').run(id);
|
||||
res.json({ success: true });
|
||||
broadcast(tripId, 'file:deleted', { fileId: Number(id) }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -5,6 +5,14 @@ const { authenticate } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Get API key: user's own key, or fall back to any admin's key
|
||||
function getMapsKey(userId) {
|
||||
const user = db.prepare('SELECT maps_api_key FROM users WHERE id = ?').get(userId);
|
||||
if (user?.maps_api_key) return user.maps_api_key;
|
||||
const admin = db.prepare("SELECT maps_api_key FROM users WHERE role = 'admin' AND maps_api_key IS NOT NULL AND maps_api_key != '' LIMIT 1").get();
|
||||
return admin?.maps_api_key || null;
|
||||
}
|
||||
|
||||
// In-memory photo cache: placeId → { photoUrl, attribution, fetchedAt }
|
||||
const photoCache = new Map();
|
||||
const PHOTO_TTL = 12 * 60 * 60 * 1000; // 12 hours
|
||||
@@ -15,8 +23,8 @@ router.post('/search', authenticate, async (req, res) => {
|
||||
|
||||
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) {
|
||||
const apiKey = getMapsKey(req.user.id);
|
||||
if (!apiKey) {
|
||||
return res.status(400).json({ error: 'Google Maps API-Schlüssel nicht konfiguriert. Bitte in den Einstellungen hinzufügen.' });
|
||||
}
|
||||
|
||||
@@ -25,7 +33,7 @@ router.post('/search', authenticate, async (req, res) => {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Goog-Api-Key': user.maps_api_key,
|
||||
'X-Goog-Api-Key': apiKey,
|
||||
'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' }),
|
||||
@@ -59,8 +67,8 @@ router.post('/search', authenticate, async (req, res) => {
|
||||
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) {
|
||||
const apiKey = getMapsKey(req.user.id);
|
||||
if (!apiKey) {
|
||||
return res.status(400).json({ error: 'Google Maps API-Schlüssel nicht konfiguriert' });
|
||||
}
|
||||
|
||||
@@ -69,7 +77,7 @@ router.get('/details/:placeId', authenticate, async (req, res) => {
|
||||
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-Api-Key': apiKey,
|
||||
'X-Goog-FieldMask': 'id,displayName,formattedAddress,location,rating,userRatingCount,websiteUri,nationalPhoneNumber,regularOpeningHours,googleMapsUri,reviews,editorialSummary',
|
||||
},
|
||||
});
|
||||
@@ -121,8 +129,8 @@ router.get('/place-photo/:placeId', authenticate, async (req, res) => {
|
||||
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) {
|
||||
const apiKey = getMapsKey(req.user.id);
|
||||
if (!apiKey) {
|
||||
return res.status(400).json({ error: 'Google Maps API-Schlüssel nicht konfiguriert' });
|
||||
}
|
||||
|
||||
@@ -130,12 +138,17 @@ router.get('/place-photo/:placeId', authenticate, async (req, res) => {
|
||||
// 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-Api-Key': apiKey,
|
||||
'X-Goog-FieldMask': 'photos',
|
||||
},
|
||||
});
|
||||
const details = await detailsRes.json();
|
||||
|
||||
if (!detailsRes.ok) {
|
||||
console.error('Google Places photo details error:', details.error?.message || detailsRes.status);
|
||||
return res.status(404).json({ error: 'Foto konnte nicht abgerufen werden' });
|
||||
}
|
||||
|
||||
if (!details.photos?.length) {
|
||||
return res.status(404).json({ error: 'Kein Foto verfügbar' });
|
||||
}
|
||||
@@ -146,7 +159,7 @@ router.get('/place-photo/:placeId', authenticate, async (req, res) => {
|
||||
|
||||
// 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`
|
||||
`https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=600&key=${apiKey}&skipHttpRedirect=true`
|
||||
);
|
||||
const mediaData = await mediaRes.json();
|
||||
const photoUrl = mediaData.photoUri;
|
||||
@@ -156,6 +169,17 @@ router.get('/place-photo/:placeId', authenticate, async (req, res) => {
|
||||
}
|
||||
|
||||
photoCache.set(placeId, { photoUrl, attribution, fetchedAt: Date.now() });
|
||||
|
||||
// Persist the photo URL to all places with this google_place_id so future
|
||||
// loads serve image_url directly without hitting the Google API again.
|
||||
try {
|
||||
db.prepare(
|
||||
'UPDATE places SET image_url = ?, updated_at = CURRENT_TIMESTAMP WHERE google_place_id = ? AND (image_url IS NULL OR image_url = ?)'
|
||||
).run(photoUrl, placeId, '');
|
||||
} catch (dbErr) {
|
||||
console.error('Failed to persist photo URL to database:', dbErr);
|
||||
}
|
||||
|
||||
res.json({ photoUrl, attribution });
|
||||
} catch (err) {
|
||||
console.error('Place photo error:', err);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const express = require('express');
|
||||
const { db, canAccessTrip } = require('../db/database');
|
||||
const { authenticate } = require('../middleware/auth');
|
||||
const { broadcast } = require('../websocket');
|
||||
|
||||
const router = express.Router({ mergeParams: true });
|
||||
|
||||
@@ -41,6 +42,7 @@ router.post('/', authenticate, (req, res) => {
|
||||
|
||||
const item = db.prepare('SELECT * FROM packing_items WHERE id = ?').get(result.lastInsertRowid);
|
||||
res.status(201).json({ item });
|
||||
broadcast(tripId, 'packing:created', { item }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
// PUT /api/trips/:tripId/packing/:id
|
||||
@@ -70,6 +72,7 @@ router.put('/:id', authenticate, (req, res) => {
|
||||
|
||||
const updated = db.prepare('SELECT * FROM packing_items WHERE id = ?').get(id);
|
||||
res.json({ item: updated });
|
||||
broadcast(tripId, 'packing:updated', { item: updated }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
// DELETE /api/trips/:tripId/packing/:id
|
||||
@@ -84,6 +87,7 @@ router.delete('/:id', authenticate, (req, res) => {
|
||||
|
||||
db.prepare('DELETE FROM packing_items WHERE id = ?').run(id);
|
||||
res.json({ success: true });
|
||||
broadcast(tripId, 'packing:deleted', { itemId: Number(id) }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
// PUT /api/trips/:tripId/packing/reorder
|
||||
|
||||
@@ -2,6 +2,7 @@ const express = require('express');
|
||||
const fetch = require('node-fetch');
|
||||
const { db, getPlaceWithTags, canAccessTrip } = require('../db/database');
|
||||
const { authenticate } = require('../middleware/auth');
|
||||
const { broadcast } = require('../websocket');
|
||||
|
||||
const router = express.Router({ mergeParams: true });
|
||||
|
||||
@@ -47,13 +48,26 @@ router.get('/', authenticate, (req, res) => {
|
||||
|
||||
const places = db.prepare(query).all(...params);
|
||||
|
||||
const placesWithTags = places.map(p => {
|
||||
const tags = db.prepare(`
|
||||
SELECT t.* FROM tags t
|
||||
// Load all tags for these places in a single query to avoid N+1
|
||||
const placeIds = places.map(p => p.id);
|
||||
const tagsByPlaceId = {};
|
||||
if (placeIds.length > 0) {
|
||||
const placeholders = placeIds.map(() => '?').join(',');
|
||||
const allTags = db.prepare(`
|
||||
SELECT t.*, pt.place_id FROM tags t
|
||||
JOIN place_tags pt ON t.id = pt.tag_id
|
||||
WHERE pt.place_id = ?
|
||||
`).all(p.id);
|
||||
WHERE pt.place_id IN (${placeholders})
|
||||
`).all(...placeIds);
|
||||
|
||||
for (const tag of allTags) {
|
||||
const pid = tag.place_id;
|
||||
delete tag.place_id;
|
||||
if (!tagsByPlaceId[pid]) tagsByPlaceId[pid] = [];
|
||||
tagsByPlaceId[pid].push(tag);
|
||||
}
|
||||
}
|
||||
|
||||
const placesWithTags = places.map(p => {
|
||||
return {
|
||||
...p,
|
||||
category: p.category_id ? {
|
||||
@@ -62,7 +76,7 @@ router.get('/', authenticate, (req, res) => {
|
||||
color: p.category_color,
|
||||
icon: p.category_icon,
|
||||
} : null,
|
||||
tags,
|
||||
tags: tagsByPlaceId[p.id] || [],
|
||||
};
|
||||
});
|
||||
|
||||
@@ -113,6 +127,7 @@ router.post('/', authenticate, (req, res) => {
|
||||
|
||||
const place = getPlaceWithTags(placeId);
|
||||
res.status(201).json({ place });
|
||||
broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
// GET /api/trips/:tripId/places/:id
|
||||
@@ -258,6 +273,7 @@ router.put('/:id', authenticate, (req, res) => {
|
||||
|
||||
const place = getPlaceWithTags(id);
|
||||
res.json({ place });
|
||||
broadcast(tripId, 'place:updated', { place }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
// DELETE /api/trips/:tripId/places/:id
|
||||
@@ -276,6 +292,7 @@ router.delete('/:id', authenticate, (req, res) => {
|
||||
|
||||
db.prepare('DELETE FROM places WHERE id = ?').run(id);
|
||||
res.json({ success: true });
|
||||
broadcast(tripId, 'place:deleted', { placeId: Number(id) }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const express = require('express');
|
||||
const { db, canAccessTrip } = require('../db/database');
|
||||
const { authenticate } = require('../middleware/auth');
|
||||
const { broadcast } = require('../websocket');
|
||||
|
||||
const router = express.Router({ mergeParams: true });
|
||||
|
||||
@@ -62,6 +63,7 @@ router.post('/', authenticate, (req, res) => {
|
||||
`).get(result.lastInsertRowid);
|
||||
|
||||
res.status(201).json({ reservation });
|
||||
broadcast(tripId, 'reservation:created', { reservation }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
// PUT /api/trips/:tripId/reservations/:id
|
||||
@@ -109,6 +111,7 @@ router.put('/:id', authenticate, (req, res) => {
|
||||
`).get(id);
|
||||
|
||||
res.json({ reservation: updated });
|
||||
broadcast(tripId, 'reservation:updated', { reservation: updated }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
// DELETE /api/trips/:tripId/reservations/:id
|
||||
@@ -123,6 +126,7 @@ router.delete('/:id', authenticate, (req, res) => {
|
||||
|
||||
db.prepare('DELETE FROM reservations WHERE id = ?').run(id);
|
||||
res.json({ success: true });
|
||||
broadcast(tripId, 'reservation:deleted', { reservationId: Number(id) }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -5,6 +5,7 @@ const fs = require('fs');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { db, canAccessTrip, isOwner } = require('../db/database');
|
||||
const { authenticate } = require('../middleware/auth');
|
||||
const { broadcast } = require('../websocket');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -134,6 +135,7 @@ router.put('/:id', authenticate, (req, res) => {
|
||||
|
||||
const updatedTrip = db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId: req.user.id, tripId: req.params.id });
|
||||
res.json({ trip: updatedTrip });
|
||||
broadcast(req.params.id, 'trip:updated', { trip: updatedTrip }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
// POST /api/trips/:id/cover
|
||||
@@ -147,7 +149,11 @@ router.post('/:id/cover', authenticate, uploadCover.single('cover'), (req, res)
|
||||
|
||||
if (trip.cover_image) {
|
||||
const oldPath = path.join(__dirname, '../../', trip.cover_image.replace(/^\//, ''));
|
||||
if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
|
||||
const resolvedPath = path.resolve(oldPath);
|
||||
const uploadsDir = path.resolve(__dirname, '../../uploads');
|
||||
if (resolvedPath.startsWith(uploadsDir) && fs.existsSync(resolvedPath)) {
|
||||
fs.unlinkSync(resolvedPath);
|
||||
}
|
||||
}
|
||||
|
||||
const coverUrl = `/uploads/covers/${req.file.filename}`;
|
||||
@@ -159,8 +165,10 @@ router.post('/:id/cover', authenticate, uploadCover.single('cover'), (req, res)
|
||||
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' });
|
||||
const deletedTripId = Number(req.params.id);
|
||||
db.prepare('DELETE FROM trips WHERE id = ?').run(req.params.id);
|
||||
res.json({ success: true });
|
||||
broadcast(deletedTripId, 'trip:deleted', { id: deletedTripId }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
// ── Member Management ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -5,6 +5,33 @@ const { authenticate } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// --------------- In-memory weather cache ---------------
|
||||
const weatherCache = new Map();
|
||||
|
||||
const TTL_FORECAST_MS = 60 * 60 * 1000; // 1 hour
|
||||
const TTL_CURRENT_MS = 15 * 60 * 1000; // 15 minutes
|
||||
|
||||
function cacheKey(lat, lng, date, units) {
|
||||
const rlat = parseFloat(lat).toFixed(2);
|
||||
const rlng = parseFloat(lng).toFixed(2);
|
||||
return `${rlat}_${rlng}_${date || 'current'}_${units}`;
|
||||
}
|
||||
|
||||
function getCached(key) {
|
||||
const entry = weatherCache.get(key);
|
||||
if (!entry) return null;
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
weatherCache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return entry.data;
|
||||
}
|
||||
|
||||
function setCache(key, data, ttlMs) {
|
||||
weatherCache.set(key, { data, expiresAt: Date.now() + ttlMs });
|
||||
}
|
||||
// -------------------------------------------------------
|
||||
|
||||
function formatItem(item) {
|
||||
return {
|
||||
temp: Math.round(item.main.temp),
|
||||
@@ -24,16 +51,28 @@ router.get('/', authenticate, async (req, res) => {
|
||||
return res.status(400).json({ error: 'Breiten- und Längengrad sind erforderlich' });
|
||||
}
|
||||
|
||||
// User's own key, or fall back to admin's key
|
||||
let key = null;
|
||||
const user = db.prepare('SELECT openweather_api_key FROM users WHERE id = ?').get(req.user.id);
|
||||
if (!user || !user.openweather_api_key) {
|
||||
if (user?.openweather_api_key) {
|
||||
key = user.openweather_api_key;
|
||||
} else {
|
||||
const admin = db.prepare("SELECT openweather_api_key FROM users WHERE role = 'admin' AND openweather_api_key IS NOT NULL AND openweather_api_key != '' LIMIT 1").get();
|
||||
key = admin?.openweather_api_key || null;
|
||||
}
|
||||
if (!key) {
|
||||
return res.status(400).json({ error: 'Kein API-Schlüssel konfiguriert' });
|
||||
}
|
||||
|
||||
const key = user.openweather_api_key;
|
||||
const ck = cacheKey(lat, lng, date, units);
|
||||
|
||||
try {
|
||||
// If a date is requested, try the 5-day forecast first
|
||||
if (date) {
|
||||
// Check cache
|
||||
const cached = getCached(ck);
|
||||
if (cached) return res.json(cached);
|
||||
|
||||
const targetDate = new Date(date);
|
||||
const now = new Date();
|
||||
const diffDays = (targetDate - now) / (1000 * 60 * 60 * 24);
|
||||
@@ -58,7 +97,9 @@ router.get('/', authenticate, async (req, res) => {
|
||||
const hour = new Date(item.dt * 1000).getHours();
|
||||
return hour >= 11 && hour <= 14;
|
||||
}) || filtered[0];
|
||||
return res.json(formatItem(midday));
|
||||
const result = formatItem(midday);
|
||||
setCache(ck, result, TTL_FORECAST_MS);
|
||||
return res.json(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +108,9 @@ router.get('/', authenticate, async (req, res) => {
|
||||
}
|
||||
|
||||
// No date — return current weather
|
||||
const cached = getCached(ck);
|
||||
if (cached) return res.json(cached);
|
||||
|
||||
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();
|
||||
@@ -75,7 +119,9 @@ router.get('/', authenticate, async (req, res) => {
|
||||
return res.status(response.status).json({ error: data.message || 'OpenWeatherMap API Fehler' });
|
||||
}
|
||||
|
||||
res.json(formatItem(data));
|
||||
const result = formatItem(data);
|
||||
setCache(ck, result, TTL_CURRENT_MS);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error('Weather error:', err);
|
||||
res.status(500).json({ error: 'Fehler beim Abrufen der Wetterdaten' });
|
||||
|
||||
144
server/src/websocket.js
Normal file
144
server/src/websocket.js
Normal file
@@ -0,0 +1,144 @@
|
||||
const { WebSocketServer } = require('ws');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { JWT_SECRET } = require('./config');
|
||||
const { db, canAccessTrip } = require('./db/database');
|
||||
|
||||
// Room management: tripId → Set<WebSocket>
|
||||
const rooms = new Map();
|
||||
|
||||
// Track which rooms each socket is in
|
||||
const socketRooms = new WeakMap();
|
||||
|
||||
// Track user info per socket
|
||||
const socketUser = new WeakMap();
|
||||
|
||||
// Track unique socket ID
|
||||
const socketId = new WeakMap();
|
||||
let nextSocketId = 1;
|
||||
|
||||
let wss;
|
||||
|
||||
function setupWebSocket(server) {
|
||||
wss = new WebSocketServer({ server, path: '/ws' });
|
||||
|
||||
// Heartbeat: ping every 30s, terminate if no pong
|
||||
const heartbeat = setInterval(() => {
|
||||
wss.clients.forEach((ws) => {
|
||||
if (ws.isAlive === false) return ws.terminate();
|
||||
ws.isAlive = false;
|
||||
ws.ping();
|
||||
});
|
||||
}, 30000);
|
||||
|
||||
wss.on('close', () => clearInterval(heartbeat));
|
||||
|
||||
wss.on('connection', (ws, req) => {
|
||||
// Extract token from query param
|
||||
const url = new URL(req.url, 'http://localhost');
|
||||
const token = url.searchParams.get('token');
|
||||
|
||||
if (!token) {
|
||||
ws.close(4001, 'Authentication required');
|
||||
return;
|
||||
}
|
||||
|
||||
let user;
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
user = db.prepare(
|
||||
'SELECT id, username, email, role FROM users WHERE id = ?'
|
||||
).get(decoded.id);
|
||||
if (!user) {
|
||||
ws.close(4001, 'User not found');
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
ws.close(4001, 'Invalid or expired token');
|
||||
return;
|
||||
}
|
||||
|
||||
ws.isAlive = true;
|
||||
const sid = nextSocketId++;
|
||||
socketId.set(ws, sid);
|
||||
socketUser.set(ws, user);
|
||||
socketRooms.set(ws, new Set());
|
||||
ws.send(JSON.stringify({ type: 'welcome', socketId: sid }));
|
||||
|
||||
ws.on('pong', () => { ws.isAlive = true; });
|
||||
|
||||
ws.on('message', (data) => {
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(data.toString());
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'join' && msg.tripId) {
|
||||
const tripId = Number(msg.tripId);
|
||||
// Verify the user has access to this trip
|
||||
if (!canAccessTrip(tripId, user.id)) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Access denied' }));
|
||||
return;
|
||||
}
|
||||
// Add to room
|
||||
if (!rooms.has(tripId)) rooms.set(tripId, new Set());
|
||||
rooms.get(tripId).add(ws);
|
||||
socketRooms.get(ws).add(tripId);
|
||||
ws.send(JSON.stringify({ type: 'joined', tripId }));
|
||||
}
|
||||
|
||||
if (msg.type === 'leave' && msg.tripId) {
|
||||
const tripId = Number(msg.tripId);
|
||||
leaveRoom(ws, tripId);
|
||||
ws.send(JSON.stringify({ type: 'left', tripId }));
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
// Clean up all rooms this socket was in
|
||||
const myRooms = socketRooms.get(ws);
|
||||
if (myRooms) {
|
||||
for (const tripId of myRooms) {
|
||||
leaveRoom(ws, tripId);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
console.log('WebSocket server attached at /ws');
|
||||
}
|
||||
|
||||
function leaveRoom(ws, tripId) {
|
||||
const room = rooms.get(tripId);
|
||||
if (room) {
|
||||
room.delete(ws);
|
||||
if (room.size === 0) rooms.delete(tripId);
|
||||
}
|
||||
const myRooms = socketRooms.get(ws);
|
||||
if (myRooms) myRooms.delete(tripId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast an event to all sockets in a trip room, optionally excluding a user.
|
||||
* @param {number} tripId
|
||||
* @param {string} eventType e.g. 'place:created'
|
||||
* @param {object} payload the data to send
|
||||
* @param {number} [excludeUserId] don't send to this user (the one who triggered the change)
|
||||
*/
|
||||
function broadcast(tripId, eventType, payload, excludeSid) {
|
||||
tripId = Number(tripId);
|
||||
const room = rooms.get(tripId);
|
||||
if (!room || room.size === 0) return;
|
||||
|
||||
const excludeNum = excludeSid ? Number(excludeSid) : null;
|
||||
|
||||
for (const ws of room) {
|
||||
if (ws.readyState !== 1) continue; // WebSocket.OPEN === 1
|
||||
// Exclude the specific socket that triggered the change
|
||||
if (excludeNum && socketId.get(ws) === excludeNum) continue;
|
||||
ws.send(JSON.stringify({ type: eventType, tripId, ...payload }));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { setupWebSocket, broadcast };
|
||||
Reference in New Issue
Block a user