708 lines
20 KiB
JavaScript
708 lines
20 KiB
JavaScript
const User = require('../models/User');
|
||
const { geocodeAddress } = require('../utils/geocode');
|
||
const logger = require('../utils/logger');
|
||
|
||
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'), // Exclude version key
|
||
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);
|
||
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 = { ...req.body };
|
||
|
||
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 = { ...req.body };
|
||
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 };
|
||
if (req.query.type) {
|
||
filter.type = 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="stoeberhundefuehrer_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="stoeberhundefuehrer_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;
|
||
|
||
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' });
|
||
}
|
||
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' });
|
||
}
|
||
};
|
||
|
||
module.exports = {
|
||
getAllUsers,
|
||
getUserById,
|
||
createUser,
|
||
updateUser,
|
||
deleteUser,
|
||
restoreUser,
|
||
getDeletedUsers,
|
||
getPublicUsers,
|
||
updateAvailability,
|
||
updateGPS,
|
||
exportUsers,
|
||
importUsers,
|
||
bulkUpdateUsers,
|
||
bulkDeleteUsers,
|
||
uploadUserPhoto,
|
||
deleteUserPhoto
|
||
};
|