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

3
server/.env.example Normal file
View File

@@ -0,0 +1,3 @@
PORT=3001
JWT_SECRET=your-super-secret-jwt-key-change-in-production
NODE_ENV=development

2133
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
server/package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "nomad-server",
"version": "2.0.0",
"main": "src/index.js",
"scripts": {
"start": "node --experimental-sqlite src/index.js",
"dev": "nodemon src/index.js"
},
"dependencies": {
"archiver": "^6.0.1",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.4.1",
"express": "^4.18.3",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"node-cron": "^4.2.1",
"node-fetch": "^2.7.0",
"unzipper": "^0.12.3",
"uuid": "^9.0.0"
},
"devDependencies": {
"nodemon": "^3.1.0"
}
}

21
server/reset-admin.js Normal file
View File

@@ -0,0 +1,21 @@
const path = require('path');
const { DatabaseSync } = require('node:sqlite');
const bcrypt = require('bcryptjs');
const dbPath = path.join(__dirname, 'data/travel.db');
const db = new DatabaseSync(dbPath);
const hash = bcrypt.hashSync('admin123', 10);
const existing = db.prepare('SELECT id FROM users WHERE email = ?').get('admin@admin.com');
if (existing) {
db.prepare('UPDATE users SET password_hash = ?, role = ? WHERE email = ?')
.run(hash, 'admin', 'admin@admin.com');
console.log('✓ Admin-Passwort zurückgesetzt: admin@admin.com / admin123');
} else {
db.prepare('INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)')
.run('admin', 'admin@admin.com', hash, 'admin');
console.log('✓ Admin-User erstellt: admin@admin.com / admin123');
}
db.close();

14
server/src/config.js Normal file
View File

@@ -0,0 +1,14 @@
const crypto = require('crypto');
let JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET) {
if (process.env.NODE_ENV === 'production') {
console.error('FATAL: JWT_SECRET environment variable is required in production.');
process.exit(1);
}
JWT_SECRET = crypto.randomBytes(32).toString('hex');
console.warn('WARNING: No JWT_SECRET set — using auto-generated secret. Sessions will reset on server restart.');
}
module.exports = { JWT_SECRET };

361
server/src/db/database.js Normal file
View File

@@ -0,0 +1,361 @@
const { DatabaseSync } = require('node:sqlite');
const path = require('path');
const fs = require('fs');
const bcrypt = require('bcryptjs');
const dataDir = path.join(__dirname, '../../data');
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
const dbPath = path.join(dataDir, 'travel.db');
let _db = null;
function initDb() {
if (_db) {
try { _db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {}
try { _db.close(); } catch (e) {}
_db = null;
}
_db = new DatabaseSync(dbPath);
_db.exec('PRAGMA journal_mode = WAL');
_db.exec('PRAGMA foreign_keys = ON');
// Create all tables
_db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
maps_api_key TEXT,
unsplash_api_key TEXT,
openweather_api_key TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
key TEXT NOT NULL,
value TEXT,
UNIQUE(user_id, key)
);
CREATE TABLE IF NOT EXISTS trips (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT,
start_date TEXT,
end_date TEXT,
currency TEXT DEFAULT 'EUR',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS days (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
day_number INTEGER NOT NULL,
date TEXT,
notes TEXT,
UNIQUE(trip_id, day_number)
);
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
color TEXT DEFAULT '#6366f1',
icon TEXT DEFAULT '📍',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
color TEXT DEFAULT '#10b981',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS places (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
lat REAL,
lng REAL,
address TEXT,
category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
price REAL,
currency TEXT,
reservation_status TEXT DEFAULT 'none',
reservation_notes TEXT,
reservation_datetime TEXT,
place_time TEXT,
duration_minutes INTEGER DEFAULT 60,
notes TEXT,
image_url TEXT,
google_place_id TEXT,
website TEXT,
phone TEXT,
transport_mode TEXT DEFAULT 'walking',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS place_tags (
place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (place_id, tag_id)
);
CREATE TABLE IF NOT EXISTS day_assignments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE,
order_index INTEGER DEFAULT 0,
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS packing_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
name TEXT NOT NULL,
checked INTEGER DEFAULT 0,
category TEXT,
sort_order INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS photos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
day_id INTEGER REFERENCES days(id) ON DELETE SET NULL,
place_id INTEGER REFERENCES places(id) ON DELETE SET NULL,
filename TEXT NOT NULL,
original_name TEXT NOT NULL,
file_size INTEGER,
mime_type TEXT,
caption TEXT,
taken_at TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS trip_files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
place_id INTEGER REFERENCES places(id) ON DELETE SET NULL,
filename TEXT NOT NULL,
original_name TEXT NOT NULL,
file_size INTEGER,
mime_type TEXT,
description TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS reservations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
day_id INTEGER REFERENCES days(id) ON DELETE SET NULL,
place_id INTEGER REFERENCES places(id) ON DELETE SET NULL,
title TEXT NOT NULL,
reservation_time TEXT,
location TEXT,
confirmation_number TEXT,
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS trip_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
invited_by INTEGER REFERENCES users(id),
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(trip_id, user_id)
);
CREATE TABLE IF NOT EXISTS day_notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
text TEXT NOT NULL,
time TEXT,
icon TEXT DEFAULT '📝',
sort_order REAL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT
);
CREATE TABLE IF NOT EXISTS budget_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
category TEXT NOT NULL DEFAULT 'Sonstiges',
name TEXT NOT NULL,
total_price REAL NOT NULL DEFAULT 0,
persons INTEGER DEFAULT NULL,
days INTEGER DEFAULT NULL,
note TEXT,
sort_order INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`);
// Migrations
const migrations = [
`ALTER TABLE users ADD COLUMN unsplash_api_key TEXT`,
`ALTER TABLE users ADD COLUMN openweather_api_key TEXT`,
`ALTER TABLE places ADD COLUMN duration_minutes INTEGER DEFAULT 60`,
`ALTER TABLE places ADD COLUMN notes TEXT`,
`ALTER TABLE places ADD COLUMN image_url TEXT`,
`ALTER TABLE places ADD COLUMN transport_mode TEXT DEFAULT 'walking'`,
`ALTER TABLE days ADD COLUMN title TEXT`,
`ALTER TABLE reservations ADD COLUMN status TEXT DEFAULT 'pending'`,
`ALTER TABLE trip_files ADD COLUMN reservation_id INTEGER REFERENCES reservations(id) ON DELETE SET NULL`,
`ALTER TABLE reservations ADD COLUMN type TEXT DEFAULT 'other'`,
`ALTER TABLE trips ADD COLUMN cover_image TEXT`,
`ALTER TABLE day_notes ADD COLUMN icon TEXT DEFAULT '📝'`,
`ALTER TABLE trips ADD COLUMN is_archived INTEGER DEFAULT 0`,
`ALTER TABLE categories ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE SET NULL`,
`ALTER TABLE users ADD COLUMN avatar TEXT`,
];
// Recreate budget_items to allow NULL persons (SQLite can't ALTER NOT NULL)
try {
const hasNotNull = _db.prepare("SELECT sql FROM sqlite_master WHERE name = 'budget_items'").get()
if (hasNotNull?.sql?.includes('NOT NULL DEFAULT 1')) {
_db.exec(`
CREATE TABLE budget_items_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
category TEXT NOT NULL DEFAULT 'Sonstiges',
name TEXT NOT NULL,
total_price REAL NOT NULL DEFAULT 0,
persons INTEGER DEFAULT NULL,
days INTEGER DEFAULT NULL,
note TEXT,
sort_order INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO budget_items_new SELECT * FROM budget_items;
DROP TABLE budget_items;
ALTER TABLE budget_items_new RENAME TO budget_items;
`)
}
} catch (e) { /* table doesn't exist yet or already migrated */ }
for (const sql of migrations) {
try { _db.exec(sql); } catch (e) { /* column already exists */ }
}
// First registered user becomes admin — no default admin seed needed
// Seed: default categories
try {
const existingCats = _db.prepare('SELECT COUNT(*) as count FROM categories').get();
if (existingCats.count === 0) {
const defaultCategories = [
{ name: 'Hotel', color: '#3b82f6', icon: '🏨' },
{ name: 'Restaurant', color: '#ef4444', icon: '🍽️' },
{ name: 'Sehenswürdigkeit', color: '#8b5cf6', icon: '🏛️' },
{ name: 'Shopping', color: '#f59e0b', icon: '🛍️' },
{ name: 'Transport', color: '#6b7280', icon: '🚌' },
{ name: 'Aktivität', color: '#10b981', icon: '🎯' },
{ name: 'Bar/Café', color: '#f97316', icon: '☕' },
{ name: 'Strand', color: '#06b6d4', icon: '🏖️' },
{ name: 'Natur', color: '#84cc16', icon: '🌿' },
{ name: 'Sonstiges', color: '#6366f1', icon: '📍' },
];
const insertCat = _db.prepare('INSERT INTO categories (name, color, icon) VALUES (?, ?, ?)');
for (const cat of defaultCategories) insertCat.run(cat.name, cat.color, cat.icon);
console.log('Default categories seeded');
}
} catch (err) {
console.error('Error seeding categories:', err.message);
}
}
// Initialize on module load
initDb();
// Proxy so all route modules always use the current _db instance
// without needing a server restart after reinitialize()
const db = new Proxy({}, {
get(_, prop) {
const val = _db[prop];
return typeof val === 'function' ? val.bind(_db) : val;
},
set(_, prop, val) {
_db[prop] = val;
return true;
},
});
function closeDb() {
if (_db) {
try { _db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {}
try { _db.close(); } catch (e) {}
_db = null;
console.log('[DB] Database connection closed');
}
}
function reinitialize() {
console.log('[DB] Reinitializing database connection after restore...');
// initDb handles close + reopen, but if closeDb was already called, _db is null
if (_db) closeDb();
initDb();
console.log('[DB] Database reinitialized successfully');
}
function getPlaceWithTags(placeId) {
const place = _db.prepare(`
SELECT 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.id = ?
`).get(placeId);
if (!place) 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(placeId);
return {
...place,
category: place.category_id ? {
id: place.category_id,
name: place.category_name,
color: place.category_color,
icon: place.category_icon,
} : null,
tags,
};
}
function canAccessTrip(tripId, userId) {
return _db.prepare(`
SELECT t.id, t.user_id FROM trips t
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)
`).get(userId, tripId, userId);
}
function isOwner(tripId, userId) {
return !!_db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId);
}
module.exports = { db, closeDb, reinitialize, getPlaceWithTags, canAccessTrip, isOwner };

108
server/src/index.js Normal file
View File

@@ -0,0 +1,108 @@
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const path = require('path');
const fs = require('fs');
const app = express();
// Create upload directories on startup
const uploadsDir = path.join(__dirname, '../uploads');
const photosDir = path.join(uploadsDir, 'photos');
const filesDir = path.join(uploadsDir, 'files');
const coversDir = path.join(uploadsDir, 'covers');
const backupsDir = path.join(__dirname, '../data/backups');
const tmpDir = path.join(__dirname, '../data/tmp');
[uploadsDir, photosDir, filesDir, coversDir, backupsDir, tmpDir].forEach(dir => {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
});
// Middleware
const allowedOrigins = process.env.ALLOWED_ORIGINS
? process.env.ALLOWED_ORIGINS.split(',')
: ['http://localhost:5173', 'http://localhost:3000'];
app.use(cors({
origin: (origin, callback) => {
if (!origin || allowedOrigins.includes(origin)) callback(null, true);
else callback(new Error('Not allowed by CORS'));
},
credentials: true
}));
app.use(express.json());
// Security headers
app.use((req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('X-XSS-Protection', '1; mode=block');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
next();
});
app.use(express.urlencoded({ extended: true }));
// Serve uploaded files
app.use('/uploads', express.static(path.join(__dirname, '../uploads')));
// Routes
const authRoutes = require('./routes/auth');
const tripsRoutes = require('./routes/trips');
const daysRoutes = require('./routes/days');
const placesRoutes = require('./routes/places');
const assignmentsRoutes = require('./routes/assignments');
const packingRoutes = require('./routes/packing');
const tagsRoutes = require('./routes/tags');
const categoriesRoutes = require('./routes/categories');
const adminRoutes = require('./routes/admin');
const mapsRoutes = require('./routes/maps');
const filesRoutes = require('./routes/files');
const reservationsRoutes = require('./routes/reservations');
const dayNotesRoutes = require('./routes/dayNotes');
const weatherRoutes = require('./routes/weather');
const settingsRoutes = require('./routes/settings');
const budgetRoutes = require('./routes/budget');
const backupRoutes = require('./routes/backup');
app.use('/api/auth', authRoutes);
app.use('/api/trips', tripsRoutes);
app.use('/api/trips/:tripId/days', daysRoutes);
app.use('/api/trips/:tripId/places', placesRoutes);
app.use('/api/trips/:tripId/packing', packingRoutes);
app.use('/api/trips/:tripId/files', filesRoutes);
app.use('/api/trips/:tripId/budget', budgetRoutes);
app.use('/api/trips/:tripId/reservations', reservationsRoutes);
app.use('/api/trips/:tripId/days/:dayId/notes', dayNotesRoutes);
app.use('/api', assignmentsRoutes);
app.use('/api/tags', tagsRoutes);
app.use('/api/categories', categoriesRoutes);
app.use('/api/admin', adminRoutes);
app.use('/api/maps', mapsRoutes);
app.use('/api/weather', weatherRoutes);
app.use('/api/settings', settingsRoutes);
app.use('/api/backup', backupRoutes);
// Serve static files in production
if (process.env.NODE_ENV === 'production') {
const publicPath = path.join(__dirname, '../public');
app.use(express.static(publicPath));
app.get('*', (req, res) => {
res.sendFile(path.join(publicPath, 'index.html'));
});
}
// Global error handler
app.use((err, req, res, next) => {
console.error('Unhandled error:', err);
res.status(500).json({ error: 'Internal server error' });
});
const scheduler = require('./scheduler');
const PORT = process.env.PORT || 3001;
app.listen(PORT, () => {
console.log(`NOMAD API running on port ${PORT}`);
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
scheduler.start();
});
module.exports = app;

View File

@@ -0,0 +1,56 @@
const jwt = require('jsonwebtoken');
const { db } = require('../db/database');
const { JWT_SECRET } = require('../config');
const authenticate = (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'Access token required' });
}
try {
const decoded = jwt.verify(token, JWT_SECRET);
const user = db.prepare(
'SELECT id, username, email, role, maps_api_key, unsplash_api_key, openweather_api_key FROM users WHERE id = ?'
).get(decoded.id);
if (!user) {
return res.status(401).json({ error: 'User not found' });
}
req.user = user;
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
};
const optionalAuth = (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
req.user = null;
return next();
}
try {
const decoded = jwt.verify(token, JWT_SECRET);
const user = db.prepare(
'SELECT id, username, email, role, maps_api_key, unsplash_api_key, openweather_api_key FROM users WHERE id = ?'
).get(decoded.id);
req.user = user || null;
} catch (err) {
req.user = null;
}
next();
};
const adminOnly = (req, res, next) => {
if (!req.user || req.user.role !== 'admin') {
return res.status(403).json({ error: 'Admin access required' });
}
next();
};
module.exports = { authenticate, optionalAuth, adminOnly };

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;

105
server/src/scheduler.js Normal file
View File

@@ -0,0 +1,105 @@
const cron = require('node-cron');
const archiver = require('archiver');
const path = require('path');
const fs = require('fs');
const dataDir = path.join(__dirname, '../data');
const backupsDir = path.join(dataDir, 'backups');
const uploadsDir = path.join(__dirname, '../uploads');
const settingsFile = path.join(dataDir, 'backup-settings.json');
const CRON_EXPRESSIONS = {
hourly: '0 * * * *',
daily: '0 2 * * *',
weekly: '0 2 * * 0',
monthly: '0 2 1 * *',
};
const VALID_INTERVALS = Object.keys(CRON_EXPRESSIONS);
let currentTask = null;
function loadSettings() {
try {
if (fs.existsSync(settingsFile)) {
return JSON.parse(fs.readFileSync(settingsFile, 'utf8'));
}
} catch (e) {}
return { enabled: false, interval: 'daily', keep_days: 7 };
}
function saveSettings(settings) {
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
fs.writeFileSync(settingsFile, JSON.stringify(settings, null, 2));
}
async function runBackup() {
if (!fs.existsSync(backupsDir)) fs.mkdirSync(backupsDir, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const filename = `auto-backup-${timestamp}.zip`;
const outputPath = path.join(backupsDir, filename);
try {
// Flush WAL to main DB file before archiving
try { const { db } = require('./db/database'); 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);
const dbPath = path.join(dataDir, 'travel.db');
if (fs.existsSync(dbPath)) archive.file(dbPath, { name: 'travel.db' });
if (fs.existsSync(uploadsDir)) archive.directory(uploadsDir, 'uploads');
archive.finalize();
});
console.log(`[Auto-Backup] Erstellt: ${filename}`);
} catch (err) {
console.error('[Auto-Backup] Fehler:', err.message);
if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath);
return;
}
const settings = loadSettings();
if (settings.keep_days > 0) {
cleanupOldBackups(settings.keep_days);
}
}
function cleanupOldBackups(keepDays) {
try {
const cutoff = Date.now() - keepDays * 24 * 60 * 60 * 1000;
const files = fs.readdirSync(backupsDir).filter(f => f.endsWith('.zip'));
for (const file of files) {
const filePath = path.join(backupsDir, file);
const stat = fs.statSync(filePath);
if (stat.birthtimeMs < cutoff) {
fs.unlinkSync(filePath);
console.log(`[Auto-Backup] Altes Backup gelöscht: ${file}`);
}
}
} catch (err) {
console.error('[Auto-Backup] Bereinigungsfehler:', err.message);
}
}
function start() {
if (currentTask) {
currentTask.stop();
currentTask = null;
}
const settings = loadSettings();
if (!settings.enabled) {
console.log('[Auto-Backup] Deaktiviert');
return;
}
const expression = CRON_EXPRESSIONS[settings.interval] || CRON_EXPRESSIONS.daily;
currentTask = cron.schedule(expression, runBackup);
console.log(`[Auto-Backup] Geplant: ${settings.interval} (${expression}), Aufbewahrung: ${settings.keep_days === 0 ? 'immer' : settings.keep_days + ' Tage'}`);
}
module.exports = { start, loadSettings, saveSettings, VALID_INTERVALS };