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:
3
server/.env.example
Normal file
3
server/.env.example
Normal 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
2133
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
server/package.json
Normal file
25
server/package.json
Normal 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
21
server/reset-admin.js
Normal 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
14
server/src/config.js
Normal 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
361
server/src/db/database.js
Normal 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
108
server/src/index.js
Normal 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;
|
||||
56
server/src/middleware/auth.js
Normal file
56
server/src/middleware/auth.js
Normal 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 };
|
||||
82
server/src/routes/admin.js
Normal file
82
server/src/routes/admin.js
Normal 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;
|
||||
235
server/src/routes/assignments.js
Normal file
235
server/src/routes/assignments.js
Normal 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
390
server/src/routes/auth.js
Normal 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
233
server/src/routes/backup.js
Normal 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
105
server/src/routes/budget.js
Normal 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;
|
||||
58
server/src/routes/categories.js
Normal file
58
server/src/routes/categories.js
Normal 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;
|
||||
77
server/src/routes/dayNotes.js
Normal file
77
server/src/routes/dayNotes.js
Normal 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
155
server/src/routes/days.js
Normal 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
163
server/src/routes/files.js
Normal 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
166
server/src/routes/maps.js
Normal 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;
|
||||
108
server/src/routes/packing.js
Normal file
108
server/src/routes/packing.js
Normal 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
165
server/src/routes/photos.js
Normal 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
281
server/src/routes/places.js
Normal 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;
|
||||
128
server/src/routes/reservations.js
Normal file
128
server/src/routes/reservations.js
Normal 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;
|
||||
65
server/src/routes/settings.js
Normal file
65
server/src/routes/settings.js
Normal 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
52
server/src/routes/tags.js
Normal 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
235
server/src/routes/trips.js
Normal 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;
|
||||
85
server/src/routes/weather.js
Normal file
85
server/src/routes/weather.js
Normal 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
105
server/src/scheduler.js
Normal 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 };
|
||||
Reference in New Issue
Block a user