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
168 lines
5.2 KiB
JavaScript
168 lines
5.2 KiB
JavaScript
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 { broadcast } = require('../websocket');
|
|
|
|
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) });
|
|
broadcast(tripId, 'file:created', { file: formatFile(file) }, req.headers['x-socket-id']);
|
|
});
|
|
|
|
// 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) });
|
|
broadcast(tripId, 'file:updated', { file: formatFile(updated) }, req.headers['x-socket-id']);
|
|
});
|
|
|
|
// 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 });
|
|
broadcast(tripId, 'file:deleted', { fileId: Number(id) }, req.headers['x-socket-id']);
|
|
});
|
|
|
|
module.exports = router;
|