const User = require('../models/User'); const { geocodeAddress } = require('../utils/geocode'); const logger = require('../utils/logger'); const config = require('../config/env'); const ALLOWED_USER_FIELDS = ['name', 'type', 'address', 'phone', 'landline', 'email', 'available', 'gps', 'notes']; const MAX_PHOTO_BYTES = 2 * 1024 * 1024; // 2 MB base64 const getAllUsers = async (req, res) => { try { // Pagination parameters const page = Math.max(1, parseInt(req.query.page) || 1); const limit = Math.min(100, Math.max(1, parseInt(req.query.limit) || 50)); // Max 100 items per page const skip = (page - 1) * limit; // Optional filters const filter = {}; if (req.query.available !== undefined) { filter.available = req.query.available === 'true'; } if (req.query.type) { filter.type = req.query.type; } if (req.query.search) { // Text search using the text index filter.$text = { $search: req.query.search }; } // Execute query with pagination const [users, total] = await Promise.all([ User.find(filter) .sort(req.query.search ? { score: { $meta: 'textScore' } } : { name: 1 }) .skip(skip) .limit(limit) .select('-__v -passwordHash'), User.countDocuments(filter) ]); res.json({ success: true, data: users, pagination: { page, limit, total, pages: Math.ceil(total / limit), hasMore: page < Math.ceil(total / limit) } }); } catch (error) { logger.error('Fehler beim Abrufen der Benutzer:', error); res.status(500).json({ success: false, message: 'Fehler beim Abrufen der Benutzer' }); } }; const getUserById = async (req, res) => { try { const user = await User.findById(req.params.id).select('-passwordHash -__v'); if (!user) { return res.status(404).json({ success: false, message: 'Benutzer nicht gefunden' }); } res.json({ success: true, data: user }); } catch (error) { logger.error('Fehler beim Abrufen des Benutzers:', error); res.status(500).json({ success: false, message: 'Fehler beim Abrufen des Benutzers' }); } }; const createUser = async (req, res) => { try { const payload = Object.fromEntries( Object.entries(req.body).filter(([k]) => ALLOWED_USER_FIELDS.includes(k)) ); let geoWarning = null; if (!payload.gps && payload.address) { const coords = await geocodeAddress(payload.address); if (coords) { payload.gps = coords; } else { geoWarning = 'Adresse konnte nicht geokodiert werden. GPS kann manuell über die Karte gesetzt werden.'; logger.warn(`Geocoding fehlgeschlagen für Adresse: ${payload.address}`); } } const user = new User(payload); await user.save(); res.status(201).json({ success: true, data: user, ...(geoWarning && { warning: geoWarning }) }); } catch (error) { logger.error('Fehler beim Erstellen des Benutzers:', error); res.status(400).json({ success: false, message: error.message || 'Fehler beim Erstellen des Benutzers' }); } }; const updateUser = async (req, res) => { try { const existing = await User.findById(req.params.id); if (!existing) { return res.status(404).json({ success: false, message: 'Benutzer nicht gefunden' }); } const updateData = Object.fromEntries( Object.entries(req.body).filter(([k]) => ALLOWED_USER_FIELDS.includes(k)) ); const addressChanged = typeof updateData.address === 'string' && updateData.address.trim() !== existing.address; const hasGpsInRequest = updateData.gps && updateData.gps.lat !== undefined && updateData.gps.lng !== undefined; let geoWarning = null; if (addressChanged && !hasGpsInRequest) { const coords = await geocodeAddress(updateData.address); if (coords) { updateData.gps = coords; } else { geoWarning = 'Adresse konnte nicht geokodiert werden. GPS kann manuell über die Karte gesetzt werden.'; logger.warn(`Geocoding fehlgeschlagen für Adresse: ${updateData.address}`); } } const user = await User.findByIdAndUpdate( req.params.id, updateData, { new: true, runValidators: true } ); if (!user) { return res.status(404).json({ success: false, message: 'Benutzer nicht gefunden' }); } res.json({ success: true, data: user, ...(geoWarning && { warning: geoWarning }) }); } catch (error) { logger.error('Fehler beim Aktualisieren des Benutzers:', error); res.status(400).json({ success: false, message: error.message || 'Fehler beim Aktualisieren des Benutzers' }); } }; const deleteUser = async (req, res) => { try { // Soft delete - mark as deleted instead of removing from database const user = await User.findByIdAndUpdate( req.params.id, { deleted: true, deletedAt: new Date(), deletedBy: req.user?.id || null }, { new: true } ); if (!user) { return res.status(404).json({ success: false, message: 'Benutzer nicht gefunden' }); } logger.info(`User soft-deleted: ${user.name} (ID: ${user._id}) by admin ${req.user?.username || 'unknown'}`); res.json({ success: true, message: 'Benutzer erfolgreich gelöscht', data: user }); } catch (error) { logger.error('Fehler beim Löschen des Benutzers:', error); res.status(500).json({ success: false, message: 'Fehler beim Löschen des Benutzers' }); } }; const restoreUser = async (req, res) => { try { // Restore soft-deleted user const user = await User.findOneAndUpdate( { _id: req.params.id, deleted: true }, { deleted: false, deletedAt: null, deletedBy: null }, { new: true } ).setOptions({ includeDeleted: true }); if (!user) { return res.status(404).json({ success: false, message: 'Gelöschter Benutzer nicht gefunden' }); } logger.info(`User restored: ${user.name} (ID: ${user._id}) by admin ${req.user?.username || 'unknown'}`); res.json({ success: true, message: 'Benutzer erfolgreich wiederhergestellt', data: user }); } catch (error) { logger.error('Fehler beim Wiederherstellen des Benutzers:', error); res.status(500).json({ success: false, message: 'Fehler beim Wiederherstellen des Benutzers' }); } }; const getDeletedUsers = async (req, res) => { try { // Get only deleted users const users = await User.find({ deleted: true }) .sort({ deletedAt: -1 }) .populate('deletedBy', 'username') .setOptions({ includeDeleted: true }); res.json({ success: true, data: users, count: users.length }); } catch (error) { logger.error('Fehler beim Abrufen gelöschter Benutzer:', error); res.status(500).json({ success: false, message: 'Fehler beim Abrufen gelöschter Benutzer' }); } }; const getPublicUsers = async (req, res) => { try { // Pagination parameters const page = Math.max(1, parseInt(req.query.page) || 1); const limit = Math.min(100, Math.max(1, parseInt(req.query.limit) || 50)); const skip = (page - 1) * limit; // Filter for available users only + optional type filter const filter = { available: true }; const allowedTypes = config.userTypes || []; if (req.query.type) { if (allowedTypes.length === 0 || allowedTypes.includes(req.query.type)) { filter.type = String(req.query.type); } } if (req.query.search) { filter.$text = { $search: req.query.search }; } // Execute query with pagination const [users, total] = await Promise.all([ User.find(filter) .sort(req.query.search ? { score: { $meta: 'textScore' } } : { name: 1 }) .skip(skip) .limit(limit) .select('name type available gps'), User.countDocuments(filter) ]); res.json({ success: true, data: users, pagination: { page, limit, total, pages: Math.ceil(total / limit), hasMore: page < Math.ceil(total / limit) } }); } catch (error) { logger.error('Fehler beim Abrufen der öffentlichen Benutzer:', error); res.status(500).json({ success: false, message: 'Fehler beim Abrufen der öffentlichen Benutzer' }); } }; const updateAvailability = async (req, res) => { try { const { id } = req.params; const { available } = req.body; if (typeof available !== 'boolean') { return res.status(400).json({ success: false, message: 'Verfügbarkeit muss ein Boolean-Wert sein' }); } const user = await User.findByIdAndUpdate( id, { available }, { new: true, runValidators: true } ); if (!user) { return res.status(404).json({ success: false, message: 'Benutzer nicht gefunden' }); } res.json({ success: true, data: user }); } catch (error) { logger.error('Fehler beim Aktualisieren der Verfügbarkeit:', error); res.status(500).json({ success: false, message: 'Fehler beim Aktualisieren der Verfügbarkeit' }); } }; const updateGPS = async (req, res) => { try { const { id } = req.params; const { lat, lng } = req.body; // Validate GPS coordinates if (lat === undefined || lng === undefined) { return res.status(400).json({ success: false, message: 'Latitude und Longitude sind erforderlich' }); } const latNum = parseFloat(lat); const lngNum = parseFloat(lng); if (isNaN(latNum) || isNaN(lngNum)) { return res.status(400).json({ success: false, message: 'Ungültige GPS-Koordinaten' }); } if (latNum < -90 || latNum > 90 || lngNum < -180 || lngNum > 180) { return res.status(400).json({ success: false, message: 'GPS-Koordinaten außerhalb des gültigen Bereichs' }); } const user = await User.findByIdAndUpdate( id, { gps: { lat: latNum, lng: lngNum } }, { new: true, runValidators: true } ); if (!user) { return res.status(404).json({ success: false, message: 'Benutzer nicht gefunden' }); } res.json({ success: true, data: user }); } catch (error) { logger.error('Fehler beim Aktualisieren der GPS-Koordinaten:', error); res.status(500).json({ success: false, message: 'Fehler beim Aktualisieren der GPS-Koordinaten' }); } }; const exportUsers = async (req, res) => { try { const format = req.query.format || 'json'; const users = await User.find({ deleted: { $ne: true } }) .sort({ name: 1 }) .select('-__v -passwordHash -deleted -deletedAt -deletedBy'); if (format === 'csv') { const escapeCell = (val) => { if (val == null) return ''; const str = String(val); return str.includes(',') || str.includes('"') || str.includes('\n') ? `"${str.replace(/"/g, '""')}"` : str; }; const csv = [ ['Name', 'Adresse', 'Telefon', 'Festnetz', 'E-Mail', 'Typ', 'Verfügbar', 'Latitude', 'Longitude'].join(','), ...users.map(user => [ escapeCell(user.name), escapeCell(user.address), escapeCell(user.phone), escapeCell(user.landline), escapeCell(user.email), escapeCell(user.type), user.available ? 'Ja' : 'Nein', user.gps?.lat ?? '', user.gps?.lng ?? '' ].join(',')) ].join('\n'); res.setHeader('Content-Type', 'text/csv; charset=utf-8'); res.setHeader('Content-Disposition', `attachment; filename="drohnenfuehrer_export_${new Date().toISOString().slice(0, 10)}.csv"`); return res.send('\uFEFF' + csv); // BOM für korrekte UTF-8-Darstellung in Excel } // JSON format – sauberes Import-Format ohne MongoDB-interne Felder const exportData = users.map(u => ({ name: u.name, address: u.address, phone: u.phone, landline: u.landline || null, email: u.email || null, type: u.type, available: u.available, ...(u.gps?.lat != null && u.gps?.lng != null ? { gps: { lat: u.gps.lat, lng: u.gps.lng } } : {}) })); res.setHeader('Content-Disposition', `attachment; filename="drohnenfuehrer_export_${new Date().toISOString().slice(0, 10)}.json"`); res.json({ exportedAt: new Date().toISOString(), count: exportData.length, users: exportData }); } catch (error) { logger.error('Fehler beim Exportieren der Benutzer:', error); res.status(500).json({ success: false, message: 'Fehler beim Exportieren der Benutzer' }); } }; /** * Import users from a previously exported JSON file. * Accepts { users: [...] } – same format as the JSON export. * Existing users (matched by name + address) are skipped unless ?overwrite=true. */ const importUsers = async (req, res) => { try { const { users: importList } = req.body; const overwrite = req.query.overwrite === 'true'; if (!Array.isArray(importList) || importList.length === 0) { return res.status(400).json({ success: false, message: 'Kein gültiges Import-Format. Erwartet: { users: [...] }' }); } if (importList.length > 500) { return res.status(400).json({ success: false, message: 'Zu viele Einträge. Maximal 500 Benutzer pro Import erlaubt.' }); } const results = { imported: 0, skipped: 0, errors: [] }; for (const entry of importList) { const { name, address, phone, type } = entry; if (!name || !address || !phone || !type) { results.errors.push({ entry: entry.name || '(unbekannt)', reason: 'Pflichtfelder fehlen (name, address, phone, type)' }); continue; } try { const existing = await User.findOne({ name: name.trim(), deleted: { $ne: true } }); if (existing && !overwrite) { results.skipped++; continue; } const payload = { name: name.trim(), address: address.trim(), phone: phone.trim(), landline: entry.landline || null, email: entry.email || null, type: type.trim(), available: entry.available !== undefined ? Boolean(entry.available) : true }; if (entry.gps?.lat != null && entry.gps?.lng != null) { payload.gps = { lat: Number(entry.gps.lat), lng: Number(entry.gps.lng) }; } else { // GPS aus Adresse ermitteln const coords = await geocodeAddress(payload.address); if (coords) payload.gps = coords; } if (existing && overwrite) { await User.findByIdAndUpdate(existing._id, payload, { runValidators: true }); } else { await User.create(payload); } results.imported++; } catch (err) { results.errors.push({ entry: entry.name || '(unbekannt)', reason: err.message }); } } logger.info(`Import abgeschlossen: ${results.imported} importiert, ${results.skipped} übersprungen, ${results.errors.length} Fehler`); res.json({ success: true, message: `Import abgeschlossen: ${results.imported} importiert, ${results.skipped} übersprungen, ${results.errors.length} Fehler`, results }); } catch (error) { logger.error('Fehler beim Importieren der Benutzer:', error); res.status(500).json({ success: false, message: 'Fehler beim Importieren der Benutzer' }); } }; /** * Bulk update users - update multiple users at once */ const bulkUpdateUsers = async (req, res) => { try { const { ids, updates } = req.body; if (!ids || !Array.isArray(ids) || ids.length === 0) { return res.status(400).json({ success: false, message: 'IDs-Array ist erforderlich' }); } if (!updates || typeof updates !== 'object') { return res.status(400).json({ success: false, message: 'Updates-Objekt ist erforderlich' }); } // Prevent updating sensitive fields const allowedFields = ['available', 'type', 'phone', 'address']; const updateFields = {}; for (const [key, value] of Object.entries(updates)) { if (allowedFields.includes(key)) { updateFields[key] = value; } } if (Object.keys(updateFields).length === 0) { return res.status(400).json({ success: false, message: 'Keine gültigen Update-Felder angegeben' }); } // Perform bulk update const result = await User.updateMany( { _id: { $in: ids } }, { $set: updateFields } ); logger.info(`Bulk update: ${result.modifiedCount} users updated by admin ${req.user?.username || 'unknown'}`); res.json({ success: true, message: `${result.modifiedCount} Benutzer erfolgreich aktualisiert`, data: { matched: result.matchedCount, modified: result.modifiedCount } }); } catch (error) { logger.error('Fehler beim Bulk-Update:', error); res.status(500).json({ success: false, message: 'Fehler beim Bulk-Update' }); } }; /** * Bulk delete users - soft delete multiple users at once */ const bulkDeleteUsers = async (req, res) => { try { const { ids } = req.body; if (!ids || !Array.isArray(ids) || ids.length === 0) { return res.status(400).json({ success: false, message: 'IDs-Array ist erforderlich' }); } // Soft delete all users const result = await User.updateMany( { _id: { $in: ids } }, { $set: { deleted: true, deletedAt: new Date(), deletedBy: req.user?.id || null } } ); logger.info(`Bulk delete: ${result.modifiedCount} users deleted by admin ${req.user?.username || 'unknown'}`); res.json({ success: true, message: `${result.modifiedCount} Benutzer erfolgreich gelöscht`, data: { matched: result.matchedCount, modified: result.modifiedCount } }); } catch (error) { logger.error('Fehler beim Bulk-Delete:', error); res.status(500).json({ success: false, message: 'Fehler beim Bulk-Delete' }); } }; const uploadUserPhoto = async (req, res) => { try { const { photo } = req.body; const ALLOWED_PHOTO_TYPES = [ 'data:image/jpeg;base64,', 'data:image/png;base64,', 'data:image/webp;base64,', 'data:image/gif;base64,' ]; if (!photo || typeof photo !== 'string' || !ALLOWED_PHOTO_TYPES.some(t => photo.startsWith(t))) { return res.status(400).json({ success: false, message: 'Ungültiges Bildformat. Erlaubt: JPEG, PNG, WebP, GIF' }); } if (photo.length > MAX_PHOTO_BYTES) { return res.status(413).json({ success: false, message: 'Bild zu groß. Maximal 2 MB erlaubt.' }); } const user = await User.findOne({ _id: req.params.id, deleted: { $ne: true } }); if (!user) { return res.status(404).json({ success: false, message: 'Benutzer nicht gefunden' }); } user.photo = photo; await user.save(); res.json({ success: true, data: { photo: user.photo } }); } catch (error) { logger.error('Fehler beim Foto-Upload:', error); res.status(500).json({ success: false, message: 'Fehler beim Foto-Upload' }); } }; const deleteUserPhoto = async (req, res) => { try { const user = await User.findOne({ _id: req.params.id, deleted: { $ne: true } }); if (!user) { return res.status(404).json({ success: false, message: 'Benutzer nicht gefunden' }); } user.photo = null; await user.save(); res.json({ success: true }); } catch (error) { logger.error('Fehler beim Löschen des Fotos:', error); res.status(500).json({ success: false, message: 'Fehler beim Löschen des Fotos' }); } }; /** * GET /api/public/geocode?postalcode=XXXXX * Proxies postal code lookup through the backend geocoder (rate-limited, cached, correct User-Agent). */ const getGeocodeByPostalCode = async (req, res) => { const { postalcode } = req.query; if (!postalcode || String(postalcode).trim().length < 4) { return res.status(400).json({ success: false, message: 'Bitte eine gültige Postleitzahl angeben (min. 4 Zeichen)' }); } const sanitized = String(postalcode).replace(/[^0-9]/g, '').slice(0, 10); if (!sanitized) { return res.status(400).json({ success: false, message: 'Ungültige Postleitzahl' }); } try { const coords = await geocodeAddress(`${sanitized}, Deutschland`); if (!coords) { return res.status(404).json({ success: false, message: 'Postleitzahl nicht gefunden' }); } res.json({ success: true, data: coords }); } catch (error) { logger.error('Geocoding-Fehler für PLZ:', error); res.status(500).json({ success: false, message: 'Geocoding-Fehler' }); } }; module.exports = { getAllUsers, getUserById, createUser, updateUser, deleteUser, restoreUser, getDeletedUsers, getPublicUsers, updateAvailability, updateGPS, exportUsers, importUsers, bulkUpdateUsers, bulkDeleteUsers, uploadUserPhoto, deleteUserPhoto, getGeocodeByPostalCode };