fix(backend): Seeding beendet nicht mehr den Server, Routing und Shutdown korrigiert

- REGRESSION aus d9fecb6: server.js ruft seed.js seit dem Wechsel von
  exec('node seed.js') auf require('./seed') im selben Prozess auf.
  seedDatabase() endet aber mit mongoose.connection.close() und
  process.exit(0) – auf einer frischen Datenbank hat sich der Server damit
  unmittelbar nach dem Seeding selbst beendet. Verbindungsabbau und Exit
  macht jetzt nur noch der CLI-Aufruf (npm run seed); zusätzlich verbindet
  seed.js nicht erneut, wenn die Verbindung bereits steht.
- Seeding hing an User.countDocuments(). Die Seed-Liste ist bewusst leer,
  die Bedingung war also bei jedem Start wahr, und seed.js hat die Config
  per findOneAndUpdate überschrieben: App-Name, Regeln und Texte des
  Admins gingen bei jedem Neustart verloren. Bedingung hängt jetzt an
  Admin und Config, die Config wird nur noch angelegt, nie überschrieben.
- DELETE /api/users/bulk war nicht erreichbar: die Route stand hinter
  /users/:id, Express hat den Pfad an deleteUser mit id="bulk" gegeben
  (CastError -> 404). Bulk-Routen stehen jetzt davor.
- getAdminActivity rief mongoose.Types.ObjectId() ohne new auf. Seit
  bson 5 ist das eine echte Klasse, der Endpunkt lief immer in einen
  500er. Ungültige IDs liefern jetzt 400 statt 500.
- connectDB beendete den Prozess mit process.exit(1), womit die
  Retry-Schleife in server.js toter Code war. Jetzt wird geworfen.
- geocode.js rief in seinen Signal-Handlern process.exit(0) und hat den
  Graceful Shutdown mittendrin abgeschnitten. Der Cache wird nur noch
  geschrieben; das Beenden gehört server.js, das jetzt auch SIGINT
  behandelt und einen Timeout hat.
- errorHandler stand vor dem Static-/SPA-Fallback und war damit nicht
  mehr letzte Middleware.
- Jagdsprache in seed.js: "Verbreiten Sie den Standort und den Anschuss"
  -> "Verbrechen". Da der Seed die Config anlegt, ist das der Text, den
  die Nutzer sehen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
thomas 2026-08-25 08:09:53 +02:00
parent 4b61db82fd
commit 718d84a545
21 changed files with 425 additions and 128 deletions

View File

@ -10,8 +10,10 @@ const connectDB = async () => {
});
logger.info('MongoDB verbunden');
} catch (error) {
// Nicht process.exit(): der Aufrufer (server.js) implementiert einen Retry.
// Ein Exit hier hat den Retry zu totem Code gemacht.
logger.error('MongoDB Verbindungsfehler:', error.message);
process.exit(1);
throw error;
}
};

View File

@ -1,5 +1,7 @@
const mongoose = require('mongoose');
const AuditLog = require('../models/AuditLog');
const logger = require('../utils/logger');
const { escapeCell } = require('../utils/csv');
/**
* Get all audit logs with pagination and filtering
@ -99,8 +101,13 @@ const getAdminActivity = async (req, res) => {
try {
const { adminId } = req.params;
if (!mongoose.isValidObjectId(adminId)) {
return res.status(400).json({ success: false, message: 'Ungültige Admin-ID' });
}
const stats = await AuditLog.aggregate([
{ $match: { adminId: require('mongoose').Types.ObjectId(adminId) } },
// ObjectId ist seit bson 5 eine echte Klasse und braucht new.
{ $match: { adminId: new mongoose.Types.ObjectId(adminId) } },
{
$group: {
_id: '$action',
@ -214,13 +221,6 @@ const exportAuditLogs = async (req, res) => {
.limit(10000)
.lean();
const escapeCell = (val) => {
if (val == null) return '';
const str = String(val);
return str.includes(',') || str.includes('"') || str.includes('\n')
? `"${str.replace(/"/g, '""')}"` : str;
};
const header = [
'Zeitstempel', 'Aktion', 'Ressource', 'Ressourcen-Name', 'Admin',
'IP-Adresse', 'Methode', 'Pfad', 'Status-Code', 'Dauer (ms)',

View File

@ -1,11 +1,11 @@
{
"name": "tracking-leaders-backend",
"name": "drohnenfuehrer-backend",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "tracking-leaders-backend",
"name": "drohnenfuehrer-backend",
"version": "1.0.0",
"dependencies": {
"bcryptjs": "^2.4.3",
@ -15,9 +15,12 @@
"express": "^4.18.2",
"express-rate-limit": "^8.2.1",
"express-validator": "^7.3.1",
"helmet": "^8.0.0",
"jsonwebtoken": "^9.0.2",
"mongoose": "^7.5.0",
"winston": "^3.19.0"
"nodemailer": "^6.9.16",
"winston": "^3.19.0",
"winston-daily-rotate-file": "^5.0.0"
},
"devDependencies": {
"jest": "^30.2.0",
@ -2880,6 +2883,15 @@
"integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==",
"license": "MIT"
},
"node_modules/file-stream-rotator": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/file-stream-rotator/-/file-stream-rotator-0.6.1.tgz",
"integrity": "sha512-u+dBid4PvZw17PmDeRcNOtCP9CCK/9lRN2w+r1xIS7yOL9JFrIBKTvrYsxT4P0pGtThYTn++QS5ChHaUov3+zQ==",
"license": "MIT",
"dependencies": {
"moment": "^2.29.1"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@ -3242,6 +3254,18 @@
"node": ">= 0.4"
}
},
"node_modules/helmet": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz",
"integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"url": "https://github.com/sponsors/EvanHahn"
}
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@ -4579,6 +4603,15 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/moment": {
"version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/mongodb": {
"version": "5.9.2",
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-5.9.2.tgz",
@ -4754,6 +4787,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/nodemailer": {
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
"integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/nodemon": {
"version": "3.1.11",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz",
@ -4840,6 +4882,15 @@
"node": ">=0.10.0"
}
},
"node_modules/object-hash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@ -6249,6 +6300,24 @@
"node": ">= 12.0.0"
}
},
"node_modules/winston-daily-rotate-file": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/winston-daily-rotate-file/-/winston-daily-rotate-file-5.0.0.tgz",
"integrity": "sha512-JDjiXXkM5qvwY06733vf09I2wnMXpZEhxEVOSPenZMii+g7pcDcTBt2MRugnoi8BwVSuCT2jfRXBUy+n1Zz/Yw==",
"license": "MIT",
"dependencies": {
"file-stream-rotator": "^0.6.1",
"object-hash": "^3.0.0",
"triple-beam": "^1.4.1",
"winston-transport": "^4.7.0"
},
"engines": {
"node": ">=8"
},
"peerDependencies": {
"winston": "^3"
}
},
"node_modules/winston-transport": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz",

View File

@ -32,6 +32,11 @@ router.get('/users', authenticateToken, getAllUsers);
router.get('/users/export', authenticateToken, auditLog('EXPORT', 'User'), exportUsers);
router.post('/users/import', authenticateToken, auditLog('IMPORT', 'User'), importUsers);
router.get('/users/deleted', authenticateToken, getDeletedUsers);
// Bulk-Operationen MÜSSEN vor /users/:id stehen, sonst schluckt die
// :id-Route den Pfad /users/bulk und die Massen-Löschung läuft ins Leere.
router.patch('/users/bulk', authenticateToken, auditLog('BULK_UPDATE', 'User'), bulkUpdateUsers);
router.delete('/users/bulk', authenticateToken, auditLog('BULK_DELETE', 'User'), bulkDeleteUsers);
router.get('/users/:id', authenticateToken, getUserById);
router.post('/users', authenticateToken, auditLog('CREATE', 'User'), createUser);
router.put('/users/:id', authenticateToken, auditLog('UPDATE', 'User'), updateUser);
@ -45,7 +50,5 @@ router.post('/users/:id/photo', authenticateToken, auditLog('UPDATE', 'User'), u
router.delete('/users/:id/photo', authenticateToken, auditLog('UPDATE', 'User'), deleteUserPhoto);
// Bulk operations
router.patch('/users/bulk', authenticateToken, auditLog('BULK_UPDATE', 'User'), bulkUpdateUsers);
router.delete('/users/bulk', authenticateToken, auditLog('BULK_DELETE', 'User'), bulkDeleteUsers);
module.exports = router;

View File

@ -9,7 +9,10 @@ const users = [];
const seedDatabase = async () => {
try {
await mongoose.connect(config.mongoUri);
// Beim Aufruf aus server.js besteht die Verbindung bereits.
if (mongoose.connection.readyState !== 1) {
await mongoose.connect(config.mongoUri);
}
logger.info('MongoDB verbunden für Seeding...');
@ -51,6 +54,8 @@ const seedDatabase = async () => {
}
// Seed config - always update userTypes + sections + rules
// Config NUR anlegen, niemals ueberschreiben. Vorher hat jeder Neustart
// die im Admin-Panel gepflegten Texte, Regeln und den App-Namen zurueckgesetzt.
const existingConfig = await Config.findOne();
const configData = {
userTypes: [
@ -90,22 +95,23 @@ const seedDatabase = async () => {
await Config.create(configData);
logger.info('✅ Konfiguration erstellt');
} else {
await Config.findOneAndUpdate({}, configData, { new: true });
logger.info('✅ Konfiguration aktualisiert');
logger.info(' Konfiguration existiert bereits, bleibt unveraendert');
}
logger.info('✅ Datenbank-Seeding abgeschlossen');
await mongoose.connection.close();
process.exit(0);
} catch (error) {
logger.error('❌ Fehler beim Seeding:', error);
await mongoose.connection.close();
process.exit(1);
throw error;
}
};
// Verbindung schliessen und den Prozess beenden darf nur der CLI-Aufruf
// (npm run seed). server.js ruft seedDatabase() im selben Prozess auf ein
// process.exit(0) hier hat den frisch gestarteten Server sofort wieder beendet.
if (require.main === module) {
seedDatabase();
seedDatabase()
.then(async () => { await mongoose.connection.close(); process.exit(0); })
.catch(async () => { await mongoose.connection.close(); process.exit(1); });
}
module.exports = seedDatabase;

View File

@ -16,11 +16,17 @@ const connectWithRetry = async () => {
try {
await connectDB();
// Seed database if empty (runs in all environments on first start)
const User = require('./models/User');
const userCount = await User.countDocuments();
if (userCount === 0) {
logger.info('Datenbank ist leer, starte Seeding...');
// Seeding legt Admin-Konto und Grundkonfiguration an. Die Bedingung darf sich
// NICHT an der User-Zahl orientieren: die Seed-Liste ist bewusst leer, dadurch
// lief das Seeding bei jedem Start erneut.
const Admin = require('./models/Admin');
const Config = require('./models/Config');
const [adminCount, configCount] = await Promise.all([
Admin.countDocuments(),
Config.countDocuments()
]);
if (adminCount === 0 || configCount === 0) {
logger.info('Admin oder Konfiguration fehlt, starte Seeding...');
try {
const seed = require('./seed');
await seed();
@ -83,17 +89,24 @@ app.get('/health', async (req, res) => {
});
});
// Error handler (must be last)
app.use(errorHandler);
const PORT = config.port;
const server = app.listen(PORT, () => {
logger.info(`Server läuft auf Port ${PORT} (${config.nodeEnv})`);
});
// Graceful shutdown on SIGTERM (Docker stop / Kubernetes rolling restart)
process.on('SIGTERM', () => {
logger.info('SIGTERM empfangen, fahre Server herunter...');
// Graceful shutdown (Docker stop / Kubernetes rolling restart)
let shuttingDown = false;
const shutdown = (signal) => {
if (shuttingDown) return;
shuttingDown = true;
logger.info(`${signal} empfangen, fahre Server herunter...`);
const forceExit = setTimeout(() => {
logger.warn('Shutdown-Timeout erreicht, beende Prozess hart');
process.exit(1);
}, 10000);
forceExit.unref();
server.close(() => {
logger.info('HTTP-Server geschlossen');
mongoose.connection.close(false).then(() => {
@ -101,9 +114,13 @@ process.on('SIGTERM', () => {
process.exit(0);
}).catch(() => process.exit(1));
});
});
};
// If a frontend build exists, serve it as static files (useful for local testing)
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
// If a frontend build exists, serve it as static files (useful for local testing).
// Muss vor dem errorHandler stehen der gehoert als letztes Middleware registriert.
const path = require('path');
const fs = require('fs');
const buildPath = path.join(__dirname, '..', 'frontend', 'build');
@ -117,3 +134,6 @@ if (fs.existsSync(buildPath)) {
res.sendFile(path.join(buildPath, 'index.html'));
});
}
// Error handler (must be last)
app.use(errorHandler);

View File

@ -45,14 +45,13 @@ const saveCache = async () => {
setInterval(saveCache, CACHE_SAVE_INTERVAL).unref();
// Save on process exit
process.on('SIGINT', async () => {
await saveCache();
process.exit(0);
});
process.on('SIGTERM', async () => {
await saveCache();
process.exit(0);
});
// Cache beim Herunterfahren sichern ohne process.exit(): das Beenden gehört
// dem Shutdown-Handler in server.js, der sonst mittendrin abgeschnitten wird.
const flushOnShutdown = () => {
saveCache().catch(err => logger.error('Failed to flush geocoding cache:', err.message));
};
process.on('SIGINT', flushOnShutdown);
process.on('SIGTERM', flushOnShutdown);
// Initialize cache loading
loadCache().catch(err => logger.error('Cache initialization error:', err));

View File

@ -10,8 +10,10 @@ const connectDB = async () => {
});
logger.info('MongoDB verbunden');
} catch (error) {
// Nicht process.exit(): der Aufrufer (server.js) implementiert einen Retry.
// Ein Exit hier hat den Retry zu totem Code gemacht.
logger.error('MongoDB Verbindungsfehler:', error.message);
process.exit(1);
throw error;
}
};

View File

@ -1,5 +1,7 @@
const mongoose = require('mongoose');
const AuditLog = require('../models/AuditLog');
const logger = require('../utils/logger');
const { escapeCell } = require('../utils/csv');
/**
* Get all audit logs with pagination and filtering
@ -99,8 +101,13 @@ const getAdminActivity = async (req, res) => {
try {
const { adminId } = req.params;
if (!mongoose.isValidObjectId(adminId)) {
return res.status(400).json({ success: false, message: 'Ungültige Admin-ID' });
}
const stats = await AuditLog.aggregate([
{ $match: { adminId: require('mongoose').Types.ObjectId(adminId) } },
// ObjectId ist seit bson 5 eine echte Klasse und braucht new.
{ $match: { adminId: new mongoose.Types.ObjectId(adminId) } },
{
$group: {
_id: '$action',
@ -214,13 +221,6 @@ const exportAuditLogs = async (req, res) => {
.limit(10000)
.lean();
const escapeCell = (val) => {
if (val == null) return '';
const str = String(val);
return str.includes(',') || str.includes('"') || str.includes('\n')
? `"${str.replace(/"/g, '""')}"` : str;
};
const header = [
'Zeitstempel', 'Aktion', 'Ressource', 'Ressourcen-Name', 'Admin',
'IP-Adresse', 'Methode', 'Pfad', 'Status-Code', 'Dauer (ms)',

View File

@ -15,9 +15,12 @@
"express": "^4.18.2",
"express-rate-limit": "^8.2.1",
"express-validator": "^7.3.1",
"helmet": "^8.0.0",
"jsonwebtoken": "^9.0.2",
"mongoose": "^7.5.0",
"winston": "^3.19.0"
"nodemailer": "^6.9.16",
"winston": "^3.19.0",
"winston-daily-rotate-file": "^5.0.0"
},
"devDependencies": {
"jest": "^30.2.0",
@ -2880,6 +2883,15 @@
"integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==",
"license": "MIT"
},
"node_modules/file-stream-rotator": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/file-stream-rotator/-/file-stream-rotator-0.6.1.tgz",
"integrity": "sha512-u+dBid4PvZw17PmDeRcNOtCP9CCK/9lRN2w+r1xIS7yOL9JFrIBKTvrYsxT4P0pGtThYTn++QS5ChHaUov3+zQ==",
"license": "MIT",
"dependencies": {
"moment": "^2.29.1"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@ -3242,6 +3254,18 @@
"node": ">= 0.4"
}
},
"node_modules/helmet": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz",
"integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"url": "https://github.com/sponsors/EvanHahn"
}
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@ -4579,6 +4603,15 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/moment": {
"version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/mongodb": {
"version": "5.9.2",
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-5.9.2.tgz",
@ -4754,6 +4787,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/nodemailer": {
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
"integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/nodemon": {
"version": "3.1.11",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz",
@ -4840,6 +4882,15 @@
"node": ">=0.10.0"
}
},
"node_modules/object-hash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@ -6249,6 +6300,24 @@
"node": ">= 12.0.0"
}
},
"node_modules/winston-daily-rotate-file": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/winston-daily-rotate-file/-/winston-daily-rotate-file-5.0.0.tgz",
"integrity": "sha512-JDjiXXkM5qvwY06733vf09I2wnMXpZEhxEVOSPenZMii+g7pcDcTBt2MRugnoi8BwVSuCT2jfRXBUy+n1Zz/Yw==",
"license": "MIT",
"dependencies": {
"file-stream-rotator": "^0.6.1",
"object-hash": "^3.0.0",
"triple-beam": "^1.4.1",
"winston-transport": "^4.7.0"
},
"engines": {
"node": ">=8"
},
"peerDependencies": {
"winston": "^3"
}
},
"node_modules/winston-transport": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz",

View File

@ -32,6 +32,11 @@ router.get('/users', authenticateToken, getAllUsers);
router.get('/users/export', authenticateToken, auditLog('EXPORT', 'User'), exportUsers);
router.post('/users/import', authenticateToken, auditLog('IMPORT', 'User'), importUsers);
router.get('/users/deleted', authenticateToken, getDeletedUsers);
// Bulk-Operationen MÜSSEN vor /users/:id stehen, sonst schluckt die
// :id-Route den Pfad /users/bulk und die Massen-Löschung läuft ins Leere.
router.patch('/users/bulk', authenticateToken, auditLog('BULK_UPDATE', 'User'), bulkUpdateUsers);
router.delete('/users/bulk', authenticateToken, auditLog('BULK_DELETE', 'User'), bulkDeleteUsers);
router.get('/users/:id', authenticateToken, getUserById);
router.post('/users', authenticateToken, auditLog('CREATE', 'User'), createUser);
router.put('/users/:id', authenticateToken, auditLog('UPDATE', 'User'), updateUser);
@ -45,7 +50,5 @@ router.post('/users/:id/photo', authenticateToken, auditLog('UPDATE', 'User'), u
router.delete('/users/:id/photo', authenticateToken, auditLog('UPDATE', 'User'), deleteUserPhoto);
// Bulk operations
router.patch('/users/bulk', authenticateToken, auditLog('BULK_UPDATE', 'User'), bulkUpdateUsers);
router.delete('/users/bulk', authenticateToken, auditLog('BULK_DELETE', 'User'), bulkDeleteUsers);
module.exports = router;

View File

@ -12,7 +12,10 @@ const users = [];
const seedDatabase = async () => {
try {
await mongoose.connect(config.mongoUri);
// Beim Aufruf aus server.js besteht die Verbindung bereits.
if (mongoose.connection.readyState !== 1) {
await mongoose.connect(config.mongoUri);
}
logger.info('MongoDB verbunden für Seeding...');
@ -54,6 +57,8 @@ const seedDatabase = async () => {
}
// Seed config - always update userTypes + sections + rules
// Config NUR anlegen, niemals ueberschreiben. Vorher hat jeder Neustart
// die im Admin-Panel gepflegten Texte, Regeln und den App-Namen zurueckgesetzt.
const existingConfig = await Config.findOne();
const configData = {
userTypes: [
@ -63,7 +68,7 @@ const seedDatabase = async () => {
{ code: 'LAB', label: 'Labrador' }
],
rules: [
"Verbreiten Sie den Standort und den Anschuss.",
"Verbrechen Sie den Standort und den Anschuss.",
"Vertreten Sie keine Pirschzeichen.",
"Versuchen Sie die Nachsuche möglichst nicht erst mit ungeübten Hunden.",
"Benachrichtigen Sie unverzüglich den Nachsuchenführer und die evtl. betroffenen Revierinhaber der Nachbarjagdbezirke.",
@ -97,22 +102,23 @@ const seedDatabase = async () => {
await Config.create(configData);
logger.info('✅ Konfiguration erstellt');
} else {
await Config.findOneAndUpdate({}, configData, { new: true });
logger.info('✅ Konfiguration aktualisiert');
logger.info(' Konfiguration existiert bereits, bleibt unveraendert');
}
logger.info('✅ Datenbank-Seeding abgeschlossen');
await mongoose.connection.close();
process.exit(0);
} catch (error) {
logger.error('❌ Fehler beim Seeding:', error);
await mongoose.connection.close();
process.exit(1);
throw error;
}
};
// Verbindung schliessen und den Prozess beenden darf nur der CLI-Aufruf
// (npm run seed). server.js ruft seedDatabase() im selben Prozess auf ein
// process.exit(0) hier hat den frisch gestarteten Server sofort wieder beendet.
if (require.main === module) {
seedDatabase();
seedDatabase()
.then(async () => { await mongoose.connection.close(); process.exit(0); })
.catch(async () => { await mongoose.connection.close(); process.exit(1); });
}
module.exports = seedDatabase;

View File

@ -16,11 +16,17 @@ const connectWithRetry = async () => {
try {
await connectDB();
// Seed database if empty (runs in all environments on first start)
const User = require('./models/User');
const userCount = await User.countDocuments();
if (userCount === 0) {
logger.info('Datenbank ist leer, starte Seeding...');
// Seeding legt Admin-Konto und Grundkonfiguration an. Die Bedingung darf sich
// NICHT an der User-Zahl orientieren: die Seed-Liste ist bewusst leer, dadurch
// lief das Seeding bei jedem Start erneut.
const Admin = require('./models/Admin');
const Config = require('./models/Config');
const [adminCount, configCount] = await Promise.all([
Admin.countDocuments(),
Config.countDocuments()
]);
if (adminCount === 0 || configCount === 0) {
logger.info('Admin oder Konfiguration fehlt, starte Seeding...');
try {
const seed = require('./seed');
await seed();
@ -83,17 +89,24 @@ app.get('/health', async (req, res) => {
});
});
// Error handler (must be last)
app.use(errorHandler);
const PORT = config.port;
const server = app.listen(PORT, () => {
logger.info(`Server läuft auf Port ${PORT} (${config.nodeEnv})`);
});
// Graceful shutdown on SIGTERM (Docker stop / Kubernetes rolling restart)
process.on('SIGTERM', () => {
logger.info('SIGTERM empfangen, fahre Server herunter...');
// Graceful shutdown (Docker stop / Kubernetes rolling restart)
let shuttingDown = false;
const shutdown = (signal) => {
if (shuttingDown) return;
shuttingDown = true;
logger.info(`${signal} empfangen, fahre Server herunter...`);
const forceExit = setTimeout(() => {
logger.warn('Shutdown-Timeout erreicht, beende Prozess hart');
process.exit(1);
}, 10000);
forceExit.unref();
server.close(() => {
logger.info('HTTP-Server geschlossen');
mongoose.connection.close(false).then(() => {
@ -101,9 +114,13 @@ process.on('SIGTERM', () => {
process.exit(0);
}).catch(() => process.exit(1));
});
});
};
// If a frontend build exists, serve it as static files (useful for local testing)
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
// If a frontend build exists, serve it as static files (useful for local testing).
// Muss vor dem errorHandler stehen der gehoert als letztes Middleware registriert.
const path = require('path');
const fs = require('fs');
const buildPath = path.join(__dirname, '..', 'frontend', 'build');
@ -117,3 +134,6 @@ if (fs.existsSync(buildPath)) {
res.sendFile(path.join(buildPath, 'index.html'));
});
}
// Error handler (must be last)
app.use(errorHandler);

View File

@ -45,14 +45,13 @@ const saveCache = async () => {
setInterval(saveCache, CACHE_SAVE_INTERVAL).unref();
// Save on process exit
process.on('SIGINT', async () => {
await saveCache();
process.exit(0);
});
process.on('SIGTERM', async () => {
await saveCache();
process.exit(0);
});
// Cache beim Herunterfahren sichern ohne process.exit(): das Beenden gehört
// dem Shutdown-Handler in server.js, der sonst mittendrin abgeschnitten wird.
const flushOnShutdown = () => {
saveCache().catch(err => logger.error('Failed to flush geocoding cache:', err.message));
};
process.on('SIGINT', flushOnShutdown);
process.on('SIGTERM', flushOnShutdown);
// Initialize cache loading
loadCache().catch(err => logger.error('Cache initialization error:', err));

View File

@ -10,8 +10,10 @@ const connectDB = async () => {
});
logger.info('MongoDB verbunden');
} catch (error) {
// Nicht process.exit(): der Aufrufer (server.js) implementiert einen Retry.
// Ein Exit hier hat den Retry zu totem Code gemacht.
logger.error('MongoDB Verbindungsfehler:', error.message);
process.exit(1);
throw error;
}
};

View File

@ -1,5 +1,7 @@
const mongoose = require('mongoose');
const AuditLog = require('../models/AuditLog');
const logger = require('../utils/logger');
const { escapeCell } = require('../utils/csv');
/**
* Get all audit logs with pagination and filtering
@ -99,8 +101,13 @@ const getAdminActivity = async (req, res) => {
try {
const { adminId } = req.params;
if (!mongoose.isValidObjectId(adminId)) {
return res.status(400).json({ success: false, message: 'Ungültige Admin-ID' });
}
const stats = await AuditLog.aggregate([
{ $match: { adminId: require('mongoose').Types.ObjectId(adminId) } },
// ObjectId ist seit bson 5 eine echte Klasse und braucht new.
{ $match: { adminId: new mongoose.Types.ObjectId(adminId) } },
{
$group: {
_id: '$action',
@ -214,13 +221,6 @@ const exportAuditLogs = async (req, res) => {
.limit(10000)
.lean();
const escapeCell = (val) => {
if (val == null) return '';
const str = String(val);
return str.includes(',') || str.includes('"') || str.includes('\n')
? `"${str.replace(/"/g, '""')}"` : str;
};
const header = [
'Zeitstempel', 'Aktion', 'Ressource', 'Ressourcen-Name', 'Admin',
'IP-Adresse', 'Methode', 'Pfad', 'Status-Code', 'Dauer (ms)',

View File

@ -1,11 +1,11 @@
{
"name": "tracking-leaders-backend",
"name": "stoeberhunde-backend",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "tracking-leaders-backend",
"name": "stoeberhunde-backend",
"version": "1.0.0",
"dependencies": {
"bcryptjs": "^2.4.3",
@ -15,9 +15,12 @@
"express": "^4.18.2",
"express-rate-limit": "^8.2.1",
"express-validator": "^7.3.1",
"helmet": "^8.0.0",
"jsonwebtoken": "^9.0.2",
"mongoose": "^7.5.0",
"winston": "^3.19.0"
"nodemailer": "^6.9.16",
"winston": "^3.19.0",
"winston-daily-rotate-file": "^5.0.0"
},
"devDependencies": {
"jest": "^30.2.0",
@ -2880,6 +2883,15 @@
"integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==",
"license": "MIT"
},
"node_modules/file-stream-rotator": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/file-stream-rotator/-/file-stream-rotator-0.6.1.tgz",
"integrity": "sha512-u+dBid4PvZw17PmDeRcNOtCP9CCK/9lRN2w+r1xIS7yOL9JFrIBKTvrYsxT4P0pGtThYTn++QS5ChHaUov3+zQ==",
"license": "MIT",
"dependencies": {
"moment": "^2.29.1"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@ -3242,6 +3254,18 @@
"node": ">= 0.4"
}
},
"node_modules/helmet": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz",
"integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"url": "https://github.com/sponsors/EvanHahn"
}
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@ -4579,6 +4603,15 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/moment": {
"version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/mongodb": {
"version": "5.9.2",
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-5.9.2.tgz",
@ -4754,6 +4787,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/nodemailer": {
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
"integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/nodemon": {
"version": "3.1.11",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz",
@ -4840,6 +4882,15 @@
"node": ">=0.10.0"
}
},
"node_modules/object-hash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@ -6249,6 +6300,24 @@
"node": ">= 12.0.0"
}
},
"node_modules/winston-daily-rotate-file": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/winston-daily-rotate-file/-/winston-daily-rotate-file-5.0.0.tgz",
"integrity": "sha512-JDjiXXkM5qvwY06733vf09I2wnMXpZEhxEVOSPenZMii+g7pcDcTBt2MRugnoi8BwVSuCT2jfRXBUy+n1Zz/Yw==",
"license": "MIT",
"dependencies": {
"file-stream-rotator": "^0.6.1",
"object-hash": "^3.0.0",
"triple-beam": "^1.4.1",
"winston-transport": "^4.7.0"
},
"engines": {
"node": ">=8"
},
"peerDependencies": {
"winston": "^3"
}
},
"node_modules/winston-transport": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz",

View File

@ -32,6 +32,11 @@ router.get('/users', authenticateToken, getAllUsers);
router.get('/users/export', authenticateToken, auditLog('EXPORT', 'User'), exportUsers);
router.post('/users/import', authenticateToken, auditLog('IMPORT', 'User'), importUsers);
router.get('/users/deleted', authenticateToken, getDeletedUsers);
// Bulk-Operationen MÜSSEN vor /users/:id stehen, sonst schluckt die
// :id-Route den Pfad /users/bulk und die Massen-Löschung läuft ins Leere.
router.patch('/users/bulk', authenticateToken, auditLog('BULK_UPDATE', 'User'), bulkUpdateUsers);
router.delete('/users/bulk', authenticateToken, auditLog('BULK_DELETE', 'User'), bulkDeleteUsers);
router.get('/users/:id', authenticateToken, getUserById);
router.post('/users', authenticateToken, auditLog('CREATE', 'User'), createUser);
router.put('/users/:id', authenticateToken, auditLog('UPDATE', 'User'), updateUser);
@ -45,7 +50,5 @@ router.post('/users/:id/photo', authenticateToken, auditLog('UPDATE', 'User'), u
router.delete('/users/:id/photo', authenticateToken, auditLog('UPDATE', 'User'), deleteUserPhoto);
// Bulk operations
router.patch('/users/bulk', authenticateToken, auditLog('BULK_UPDATE', 'User'), bulkUpdateUsers);
router.delete('/users/bulk', authenticateToken, auditLog('BULK_DELETE', 'User'), bulkDeleteUsers);
module.exports = router;

View File

@ -9,7 +9,10 @@ const users = [];
const seedDatabase = async () => {
try {
await mongoose.connect(config.mongoUri);
// Beim Aufruf aus server.js besteht die Verbindung bereits.
if (mongoose.connection.readyState !== 1) {
await mongoose.connect(config.mongoUri);
}
logger.info('MongoDB verbunden für Seeding...');
@ -51,6 +54,8 @@ const seedDatabase = async () => {
}
// Seed config - always update userTypes + sections + rules
// Config NUR anlegen, niemals ueberschreiben. Vorher hat jeder Neustart
// die im Admin-Panel gepflegten Texte, Regeln und den App-Namen zurueckgesetzt.
const existingConfig = await Config.findOne();
const configData = {
userTypes: [
@ -90,22 +95,23 @@ const seedDatabase = async () => {
await Config.create(configData);
logger.info('✅ Konfiguration erstellt');
} else {
await Config.findOneAndUpdate({}, configData, { new: true });
logger.info('✅ Konfiguration aktualisiert');
logger.info(' Konfiguration existiert bereits, bleibt unveraendert');
}
logger.info('✅ Datenbank-Seeding abgeschlossen');
await mongoose.connection.close();
process.exit(0);
} catch (error) {
logger.error('❌ Fehler beim Seeding:', error);
await mongoose.connection.close();
process.exit(1);
throw error;
}
};
// Verbindung schliessen und den Prozess beenden darf nur der CLI-Aufruf
// (npm run seed). server.js ruft seedDatabase() im selben Prozess auf ein
// process.exit(0) hier hat den frisch gestarteten Server sofort wieder beendet.
if (require.main === module) {
seedDatabase();
seedDatabase()
.then(async () => { await mongoose.connection.close(); process.exit(0); })
.catch(async () => { await mongoose.connection.close(); process.exit(1); });
}
module.exports = seedDatabase;

View File

@ -16,11 +16,17 @@ const connectWithRetry = async () => {
try {
await connectDB();
// Seed database if empty (runs in all environments on first start)
const User = require('./models/User');
const userCount = await User.countDocuments();
if (userCount === 0) {
logger.info('Datenbank ist leer, starte Seeding...');
// Seeding legt Admin-Konto und Grundkonfiguration an. Die Bedingung darf sich
// NICHT an der User-Zahl orientieren: die Seed-Liste ist bewusst leer, dadurch
// lief das Seeding bei jedem Start erneut.
const Admin = require('./models/Admin');
const Config = require('./models/Config');
const [adminCount, configCount] = await Promise.all([
Admin.countDocuments(),
Config.countDocuments()
]);
if (adminCount === 0 || configCount === 0) {
logger.info('Admin oder Konfiguration fehlt, starte Seeding...');
try {
const seed = require('./seed');
await seed();
@ -83,17 +89,24 @@ app.get('/health', async (req, res) => {
});
});
// Error handler (must be last)
app.use(errorHandler);
const PORT = config.port;
const server = app.listen(PORT, () => {
logger.info(`Server läuft auf Port ${PORT} (${config.nodeEnv})`);
});
// Graceful shutdown on SIGTERM (Docker stop / Kubernetes rolling restart)
process.on('SIGTERM', () => {
logger.info('SIGTERM empfangen, fahre Server herunter...');
// Graceful shutdown (Docker stop / Kubernetes rolling restart)
let shuttingDown = false;
const shutdown = (signal) => {
if (shuttingDown) return;
shuttingDown = true;
logger.info(`${signal} empfangen, fahre Server herunter...`);
const forceExit = setTimeout(() => {
logger.warn('Shutdown-Timeout erreicht, beende Prozess hart');
process.exit(1);
}, 10000);
forceExit.unref();
server.close(() => {
logger.info('HTTP-Server geschlossen');
mongoose.connection.close(false).then(() => {
@ -101,9 +114,13 @@ process.on('SIGTERM', () => {
process.exit(0);
}).catch(() => process.exit(1));
});
});
};
// If a frontend build exists, serve it as static files (useful for local testing)
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
// If a frontend build exists, serve it as static files (useful for local testing).
// Muss vor dem errorHandler stehen der gehoert als letztes Middleware registriert.
const path = require('path');
const fs = require('fs');
const buildPath = path.join(__dirname, '..', 'frontend', 'build');
@ -117,3 +134,6 @@ if (fs.existsSync(buildPath)) {
res.sendFile(path.join(buildPath, 'index.html'));
});
}
// Error handler (must be last)
app.use(errorHandler);

View File

@ -45,14 +45,13 @@ const saveCache = async () => {
setInterval(saveCache, CACHE_SAVE_INTERVAL).unref();
// Save on process exit
process.on('SIGINT', async () => {
await saveCache();
process.exit(0);
});
process.on('SIGTERM', async () => {
await saveCache();
process.exit(0);
});
// Cache beim Herunterfahren sichern ohne process.exit(): das Beenden gehört
// dem Shutdown-Handler in server.js, der sonst mittendrin abgeschnitten wird.
const flushOnShutdown = () => {
saveCache().catch(err => logger.error('Failed to flush geocoding cache:', err.message));
};
process.on('SIGINT', flushOnShutdown);
process.on('SIGTERM', flushOnShutdown);
// Initialize cache loading
loadCache().catch(err => logger.error('Cache initialization error:', err));