// routes/api.js const express = require('express'); const { Pool } = require('pg'); const bcrypt = require('bcrypt'); const { start } = require('repl'); const pushService = require('../push-service'); const router = express.Router(); // PostgreSQL Pool mit .env Konfiguration const pool = new Pool({ host: process.env.DB_HOST, port: process.env.DB_PORT, database: process.env.DB_NAME, user: process.env.DB_USER, password: process.env.DB_PASSWORD, ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false }); // Fehlerbehandlung für Pool pool.on('error', (err) => { console.error('PostgreSQL Pool Fehler:', err); }); // Helper function to convert time string to seconds function convertTimeToSeconds(timeStr) { if (!timeStr) return 0; // Handle different time formats if (typeof timeStr === 'string') { // Handle MM:SS.mmm format if (/^\d{1,2}:\d{2}\.\d{3}$/.test(timeStr)) { const [minutes, secondsWithMs] = timeStr.split(':'); const [seconds, milliseconds] = secondsWithMs.split('.'); return parseInt(minutes) * 60 + parseInt(seconds) + parseInt(milliseconds) / 1000; } // Handle HH:MM:SS.mmm format if (/^\d{1,2}:\d{2}:\d{2}\.\d{3}$/.test(timeStr)) { const [hours, minutes, secondsWithMs] = timeStr.split(':'); const [seconds, milliseconds] = secondsWithMs.split('.'); return parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseInt(seconds) + parseInt(milliseconds) / 1000; } // Handle HH:MM:SS format if (/^\d{1,2}:\d{2}:\d{2}$/.test(timeStr)) { const [hours, minutes, seconds] = timeStr.split(':'); return parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseInt(seconds); } // Handle MM:SS format if (/^\d{1,2}:\d{2}$/.test(timeStr)) { const [minutes, seconds] = timeStr.split(':'); return parseInt(minutes) * 60 + parseInt(seconds); } } // If it's already a number (seconds) if (typeof timeStr === 'number') { return timeStr; } return 0; } // Helper function to convert PostgreSQL interval to seconds function convertIntervalToSeconds(interval) { if (!interval) return 0; // Handle PostgreSQL interval object format if (typeof interval === 'object' && interval.seconds !== undefined) { return parseFloat(interval.seconds); } // PostgreSQL interval format: "HH:MM:SS" or "MM:SS" or just seconds if (typeof interval === 'string') { const parts = interval.split(':'); if (parts.length === 3) { // HH:MM:SS format const [hours, minutes, seconds] = parts; return parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseFloat(seconds); } else if (parts.length === 2) { // MM:SS format const [minutes, seconds] = parts; return parseInt(minutes) * 60 + parseFloat(seconds); } } // If it's already a number (seconds) if (typeof interval === 'number') { return interval; } return 0; } // Middleware für API-Key Authentifizierung async function requireApiKey(req, res, next) { const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { return res.status(401).json({ success: false, message: 'API-Key erforderlich. Verwenden Sie: Authorization: Bearer YOUR_API_KEY' }); } const apiKey = authHeader.substring(7); // "Bearer " entfernen try { // API-Key in der Datenbank validieren const result = await pool.query( `SELECT id, description, standorte, created_at, expires_at, is_active FROM api_tokens WHERE token = $1 AND is_active = true`, [apiKey] ); if (result.rows.length === 0) { return res.status(401).json({ success: false, message: 'Ungültiger oder inaktiver API-Key' }); } const tokenData = result.rows[0]; // Prüfen ob Token abgelaufen ist if (tokenData.expires_at && new Date() > new Date(tokenData.expires_at)) { return res.status(401).json({ success: false, message: 'API-Key ist abgelaufen' }); } // Token-Daten für weitere Verwendung speichern req.apiToken = tokenData; next(); } catch (error) { console.error('Fehler bei API-Key Validierung:', error); res.status(500).json({ success: false, message: 'Fehler bei der API-Key Validierung' }); } } // ============================================================================ // PUBLIC API ROUTES (/api/v1/public/) // ============================================================================ // Login-Route (bleibt für Web-Interface) router.post('/v1/public/login', async (req, res) => { const { username, password } = req.body; if (!username || !password) { return res.status(400).json({ success: false, message: 'Benutzername und Passwort sind erforderlich' }); } try { const result = await pool.query( 'SELECT id, username, password_hash, access_level FROM adminusers WHERE username = $1 AND is_active = true', [username] ); if (result.rows.length === 0) { return res.status(401).json({ success: false, message: 'Ungültige Anmeldedaten' }); } const user = result.rows[0]; const isValidPassword = await bcrypt.compare(password, user.password_hash); if (!isValidPassword) { return res.status(401).json({ success: false, message: 'Ungültige Anmeldedaten' }); } // Session setzen req.session.userId = user.id; req.session.username = user.username; req.session.accessLevel = user.access_level; // Session speichern req.session.save((err) => { if (err) { console.error('Fehler beim Speichern der Session:', err); return res.status(500).json({ success: false, message: 'Fehler beim Speichern der Session' }); } res.json({ success: true, message: 'Erfolgreich angemeldet', user: { id: user.id, username: user.username, access_level: user.access_level } }); }); } catch (error) { console.error('Fehler bei der Anmeldung:', error); res.status(500).json({ success: false, message: 'Interner Serverfehler bei der Anmeldung' }); } }); // Logout-Route (bleibt für Web-Interface) router.post('/v1/public/logout', (req, res) => { req.session.destroy((err) => { if (err) { return res.status(500).json({ success: false, message: 'Fehler beim Abmelden' }); } res.json({ success: true, message: 'Erfolgreich abgemeldet' }); }); }); // ============================================================================ // PRIVATE API ROUTES (/api/v1/private/) // ============================================================================ // API Endpunkt zum Speichern der Tokens (geschützt mit API-Key) router.post('/v1/private/save-token', requireApiKey, async (req, res) => { const { token, description, standorte } = req.body; // Validierung if (!token) { return res.status(400).json({ success: false, message: 'Token ist erforderlich' }); } try { // Prüfen ob Token bereits existiert const existingToken = await pool.query( 'SELECT id FROM api_tokens WHERE token = $1', [token] ); if (existingToken.rows.length > 0) { return res.status(409).json({ success: false, message: 'Token existiert bereits in der Datenbank' }); } // Token in Datenbank einfügen const result = await pool.query( `INSERT INTO api_tokens (token, description, standorte, expires_at) VALUES ($1, $2, $3, $4) RETURNING id, created_at`, [ token, description, standorte, new Date(Date.now() + 2 * 365 * 24 * 60 * 60 * 1000) // 2 Jahre gültig ] ); res.json({ success: true, message: 'Token erfolgreich als API-Token gespeichert', data: { id: result.rows[0].id, created_at: result.rows[0].created_at } }); } catch (error) { console.error('Fehler beim Speichern des Tokens:', error); // Spezifische Fehlermeldungen if (error.code === '23505') { // Duplicate key res.status(409).json({ success: false, message: 'Token existiert bereits' }); } else if (error.code === 'ECONNREFUSED') { res.status(503).json({ success: false, message: 'Datenbankverbindung fehlgeschlagen' }); } else { res.status(500).json({ success: false, message: 'Interner Serverfehler beim Speichern des Tokens' }); } } }); // API Endpunkt zum Abrufen aller Tokens (geschützt mit API-Key) router.get('/v1/private/tokens', requireApiKey, async (req, res) => { try { const result = await pool.query( `SELECT id, token, description, standorte, created_at, expires_at, is_active FROM api_tokens ORDER BY created_at DESC` ); res.json({ success: true, data: result.rows }); } catch (error) { console.error('Fehler beim Abrufen der Tokens:', error); res.status(500).json({ success: false, message: 'Fehler beim Abrufen der Tokens' }); } }); // API Endpunkt zum Validieren eines Tokens (geschützt mit API-Key) router.post('/v1/private/validate-token', requireApiKey, async (req, res) => { const { token } = req.body; if (!token) { return res.status(400).json({ success: false, message: 'Token ist erforderlich' }); } try { const result = await pool.query( `SELECT id, description, standorte, created_at, expires_at, is_active FROM api_tokens WHERE token = $1 AND is_active = true`, [token] ); if (result.rows.length === 0) { return res.status(401).json({ success: false, message: 'Ungültiger oder inaktiver Token' }); } const tokenData = result.rows[0]; // Prüfen ob Token abgelaufen ist if (tokenData.expires_at && new Date() > new Date(tokenData.expires_at)) { return res.status(401).json({ success: false, message: 'Token ist abgelaufen' }); } res.json({ success: true, message: 'Token ist gültig', data: { id: tokenData.id, description: tokenData.description, standorte: tokenData.standorte, created_at: tokenData.created_at, expires_at: tokenData.expires_at } }); } catch (error) { console.error('Fehler bei Token-Validierung:', error); res.status(500).json({ success: false, message: 'Fehler bei der Token-Validierung' }); } }); // Neue API-Route für Standortverwaltung (geschützt mit API-Key) router.post('/v1/private/create-location', requireApiKey, async (req, res) => { const { name, lat, lon } = req.body; // Validierung if (!name || lat === undefined || lon === undefined) { return res.status(400).json({ success: false, message: 'Name, Breitengrad und Längengrad sind erforderlich' }); } try { // Prüfen ob Standort bereits existiert const existingLocation = await pool.query( 'SELECT id FROM locations WHERE name = $1', [name] ); if (existingLocation.rows.length > 0) { return res.status(409).json({ success: false, message: 'Standort existiert bereits in der Datenbank' }); } // Standort in Datenbank einfügen const result = await pool.query( `INSERT INTO locations (name, latitude, longitude, created_at) VALUES ($1, $2, $3, $4) RETURNING id, created_at`, [name, lat, lon, new Date()] ); res.json({ success: true, message: 'Standort erfolgreich gespeichert', data: { id: result.rows[0].id, name: name, latitude: lat, longitude: lon, created_at: result.rows[0].created_at } }); } catch (error) { console.error('Fehler beim Speichern des Standorts:', error); // Spezifische Fehlermeldungen if (error.code === '23505') { // Duplicate key res.status(409).json({ success: false, message: 'Standort existiert bereits' }); } else if (error.code === 'ECONNREFUSED') { res.status(503).json({ success: false, message: 'Datenbankverbindung fehlgeschlagen' }); } else { res.status(500).json({ success: false, message: 'Interner Serverfehler beim Speichern des Standorts' }); } } }); // API Endpunkt zum Abrufen aller Standorte (geschützt mit API-Key) router.get('/v1/private/locations', requireApiKey, async (req, res) => { try { const result = await pool.query( `SELECT id, name, latitude, longitude, time_threshold, created_at FROM locations ORDER BY created_at DESC` ); res.json({ success: true, data: result.rows }); } catch (error) { console.error('Fehler beim Abrufen der Standorte:', error); res.status(500).json({ success: false, message: 'Fehler beim Abrufen der Standorte' }); } }); // API Endpunkt zum Aktualisieren des Zeit-Schwellenwerts für einen Standort router.put('/v1/private/locations/:id/threshold', requireApiKey, async (req, res) => { const { id } = req.params; const { time_threshold } = req.body; // Validierung if (!time_threshold) { return res.status(400).json({ success: false, message: 'time_threshold ist erforderlich' }); } try { // Prüfen ob Standort existiert const existingLocation = await pool.query( 'SELECT id, name FROM locations WHERE id = $1', [id] ); if (existingLocation.rows.length === 0) { return res.status(404).json({ success: false, message: 'Standort nicht gefunden' }); } // Schwellenwert aktualisieren const result = await pool.query( 'UPDATE locations SET time_threshold = $1 WHERE id = $2 RETURNING id, name, time_threshold', [time_threshold, id] ); res.json({ success: true, message: 'Schwellenwert erfolgreich aktualisiert', data: { id: result.rows[0].id, name: result.rows[0].name, time_threshold: result.rows[0].time_threshold } }); } catch (error) { console.error('Fehler beim Aktualisieren des Schwellenwerts:', error); res.status(500).json({ success: false, message: 'Interner Serverfehler beim Aktualisieren des Schwellenwerts' }); } }); // ============================================================================ // WEB-AUTHENTICATED ROUTES (/api/v1/web/) // ============================================================================ // Neue Route zum Generieren eines API-Keys (nur für authentifizierte Web-Benutzer) router.post('/v1/web/generate-api-key', async (req, res) => { // Diese Route bleibt für das Web-Interface verfügbar // Hier können Sie einen neuen API-Key generieren try { // Generiere einen zufälligen API-Key const crypto = require('crypto'); const apiKey = crypto.randomBytes(32).toString('hex'); // Speichere den API-Key in der Datenbank const result = await pool.query( `INSERT INTO api_tokens (token, description, standorte, expires_at) VALUES ($1, $2, $3, $4) RETURNING id, created_at`, [ apiKey, req.body.description || 'Generierter API-Key', req.body.standorte || '', new Date(Date.now() + 2 * 365 * 24 * 60 * 60 * 1000) // 2 Jahre gültig ] ); res.json({ success: true, message: 'API-Key erfolgreich generiert', data: { id: result.rows[0].id, apiKey: apiKey, created_at: result.rows[0].created_at } }); } catch (error) { console.error('Fehler beim Generieren des API-Keys:', error); res.status(500).json({ success: false, message: 'Fehler beim Generieren des API-Keys' }); } }); // Web-authenticated endpoints for location management (for frontend use) // These endpoints use session authentication instead of API key authentication // Web-authenticated endpoint for creating locations router.post('/v1/web/create-location', async (req, res) => { // Check if user is authenticated via web session if (!req.session || !req.session.userId) { return res.status(401).json({ success: false, message: 'Nicht angemeldet. Bitte melden Sie sich an.' }); } const { name, lat, lon } = req.body; // Validierung if (!name || lat === undefined || lon === undefined) { return res.status(400).json({ success: false, message: 'Name, Breitengrad und Längengrad sind erforderlich' }); } try { // Prüfen ob Standort bereits existiert const existingLocation = await pool.query( 'SELECT id FROM locations WHERE name = $1', [name] ); if (existingLocation.rows.length > 0) { return res.status(409).json({ success: false, message: 'Standort existiert bereits in der Datenbank' }); } // Standort in Datenbank einfügen const result = await pool.query( `INSERT INTO locations (name, latitude, longitude, created_at) VALUES ($1, $2, $3, $4) RETURNING id, created_at`, [name, lat, lon, new Date()] ); res.json({ success: true, message: 'Standort erfolgreich gespeichert', data: { id: result.rows[0].id, name: name, latitude: lat, longitude: lon, created_at: result.rows[0].created_at } }); } catch (error) { console.error('Fehler beim Speichern des Standorts:', error); // Spezifische Fehlermeldungen if (error.code === '23505') { // Duplicate key res.status(409).json({ success: false, message: 'Standort existiert bereits' }); } else if (error.code === 'ECONNREFUSED') { res.status(503).json({ success: false, message: 'Datenbankverbindung fehlgeschlagen' }); } else { res.status(500).json({ success: false, message: 'Interner Serverfehler beim Speichern des Standorts' }); } } }); // Web-authenticated endpoint for saving tokens router.post('/v1/web/save-token', async (req, res) => { // Check if user is authenticated via web session if (!req.session || !req.session.userId) { return res.status(401).json({ success: false, message: 'Nicht angemeldet. Bitte melden Sie sich an.' }); } const { token, description, standorte } = req.body; if (!token) { return res.status(400).json({ success: false, message: 'Token ist erforderlich' }); } try { // Prüfen ob Token bereits existiert const existingToken = await pool.query( 'SELECT id FROM api_tokens WHERE token = $1', [token] ); if (existingToken.rows.length > 0) { return res.status(409).json({ success: false, message: 'Token existiert bereits in der Datenbank' }); } // Token in Datenbank einfügen const result = await pool.query( `INSERT INTO api_tokens (token, description, standorte, created_at) VALUES ($1, $2, $3, $4) RETURNING id, created_at`, [token, description || 'API-Token', standorte || '', new Date()] ); res.json({ success: true, message: 'Token erfolgreich gespeichert', data: { id: result.rows[0].id, token: token, description: description || 'API-Token', standorte: standorte || '', created_at: result.rows[0].created_at } }); } catch (error) { console.error('Fehler beim Speichern des Tokens:', error); res.status(500).json({ success: false, message: 'Interner Serverfehler beim Speichern des Tokens' }); } }); // API Endpunkt für GetLocations (geschützt mit API-Key) router.get('/v1/private/get-locations', requireApiKey, async (req, res) => { try { const result = await pool.query('SELECT * FROM "GetLocations"'); res.json({ success: true, data: result.rows }); } catch (error) { console.error('Fehler beim Abrufen der GetLocations:', error); res.status(500).json({ success: false, message: 'Fehler beim Abrufen der GetLocations' }); } }); // API Entpunkt zum erstellen eines neuen Spielers router.post('/v1/private/create-player', requireApiKey, async (req, res) => { const { firstname, lastname, birthdate, rfiduid } = req.body; // Validierung if (!firstname || !lastname || !birthdate || !rfiduid) { return res.status(400).json({ success: false, message: 'Firstname, Lastname, Birthdate und RFIDUID sind erforderlich' }); } try { // Prüfen ob Spieler bereits existiert const existingPlayer = await pool.query( 'SELECT id FROM players WHERE rfiduid = $1', [rfiduid] ); if (existingPlayer.rows.length > 0) { return res.status(409).json({ success: false, message: 'Spieler existiert bereits in der Datenbank' }); } // Spieler in Datenbank einfügen const result = await pool.query( `INSERT INTO players (firstname, lastname, birthdate, rfiduid, created_at) VALUES ($1, $2, $3, $4, $5) RETURNING id, created_at`, [firstname, lastname, birthdate, rfiduid, new Date()] ); res.json({ success: true, message: 'Spieler erfolgreich gespeichert', data: { id: result.rows[0].id, firstname: firstname, lastname: lastname, birthdate: birthdate, rfiduid: rfiduid, created_at: result.rows[0].created_at } }); } catch (error) { console.error('Fehler beim Speichern des Spielers:', error); res.status(500).json({ success: false, message: 'Interner Serverfehler beim Speichern des Spielers' }); } }); // API Endpunkt zum erstellen einer neuen Zeit mit RFID UID und Location Name router.post('/v1/private/create-time', requireApiKey, async (req, res) => { const { rfiduid, location_name, recorded_time } = req.body; // Validierung if (!rfiduid || !location_name || !recorded_time) { return res.status(400).json({ success: false, message: 'RFIDUID, Location_name und Recorded_time sind erforderlich' }); } try { // Location anhand des Namens finden (inklusive time_threshold) const locationResult = await pool.query( 'SELECT id, name, time_threshold FROM locations WHERE name = $1', [location_name] ); if (locationResult.rows.length === 0) { return res.status(404).json({ success: false, message: `Standort '${location_name}' nicht gefunden` }); } const location = locationResult.rows[0]; const location_id = location.id; // Prüfen ob die Zeit über dem Schwellenwert liegt if (location.time_threshold) { // Konvertiere recorded_time zu Sekunden für Vergleich const recordedTimeSeconds = convertTimeToSeconds(recorded_time); const thresholdSeconds = convertIntervalToSeconds(location.time_threshold); if (recordedTimeSeconds < thresholdSeconds) { // Convert threshold to readable format const thresholdMinutes = Math.floor(thresholdSeconds / 60); const thresholdSecs = Math.floor(thresholdSeconds % 60); const thresholdMs = Math.floor((thresholdSeconds % 1) * 1000); const thresholdDisplay = thresholdMinutes > 0 ? `${thresholdMinutes}:${thresholdSecs.toString().padStart(2, '0')}.${thresholdMs.toString().padStart(3, '0')}` : `${thresholdSecs}.${thresholdMs.toString().padStart(3, '0')}`; return res.status(400).json({ success: false, message: `Zeit ${recorded_time} liegt unter dem Schwellenwert von ${thresholdDisplay} für diesen Standort`, data: { recorded_time: recorded_time, threshold: location.time_threshold, threshold_display: thresholdDisplay, location_name: location_name } }); } } // Finde den Spieler mit dieser RFIDUID const playerResult = await pool.query( 'SELECT id FROM players WHERE rfiduid = $1', [rfiduid] ); const player_id = playerResult.rows.length > 0 ? playerResult.rows[0].id : null; console.log(`🔍 RFIDUID: ${rfiduid}, Player ID: ${player_id}`); // Zeit mit RFIDUID in die Tabelle einfügen const result = await pool.query( `INSERT INTO times (rfiduid, location_id, recorded_time, player_id, created_at) VALUES ($1, $2, $3, $4, $5) RETURNING id, player_id, created_at`, [rfiduid, location_id, recorded_time, player_id, new Date()] ); // Achievement-Überprüfung nach Zeit-Eingabe (JavaScript) if (result.rows[0].player_id) { try { const AchievementSystem = require('../lib/achievementSystem'); const achievementSystem = new AchievementSystem(); const newAchievements = await achievementSystem.checkImmediateAchievements(result.rows[0].player_id); if (newAchievements.length > 0) { console.log(`🏆 ${newAchievements.length} neue Achievements für Spieler ${result.rows[0].player_id}:`); newAchievements.forEach(achievement => { console.log(` ${achievement.icon} ${achievement.name} (+${achievement.points} Punkte)`); }); } } catch (achievementError) { console.error('Fehler bei Achievement-Check:', achievementError); // Achievement-Fehler sollen die Zeit-Eingabe nicht blockieren } } else { console.log('⚠️ Kein Spieler verknüpft, keine Achievement-Prüfung'); } // WebSocket-Event senden für Live-Updates const io = req.app.get('io'); if (io) { // Hole die aktuelle Platzierung für diese Zeit const rankResult = await pool.query( `SELECT COUNT(*) + 1 as rank FROM times t1 WHERE t1.location_id = $1 AND t1.recorded_time < $2`, [location_id, recorded_time] ); const rank = rankResult.rows[0].rank; // Hole Spielerdaten für WebSocket-Event const playerResult = await pool.query( `SELECT firstname, lastname FROM players WHERE id = $1`, [result.rows[0].player_id] ); const player = playerResult.rows[0]; const playerName = player ? `${player.firstname} ${player.lastname}` : 'Unbekannter Spieler'; // Sende WebSocket-Event an alle verbundenen Clients io.emit('newTime', { id: result.rows[0].id, player_id: result.rows[0].player_id, player_name: playerName, rfiduid: rfiduid, location_name: location.name, recorded_time: recorded_time, rank: rank, created_at: result.rows[0].created_at }); } res.json({ success: true, message: 'Zeit erfolgreich gespeichert', data: { id: result.rows[0].id, player_id: result.rows[0].player_id, rfiduid: rfiduid, location_id: location_id, location_name: location.name, recorded_time: recorded_time, created_at: result.rows[0].created_at } }); } catch (error) { console.error('Fehler beim Speichern der Zeit:', error); res.status(500).json({ success: false, message: 'Interner Serverfehler beim Speichern der Zeit' }); } }); // API Endpunkt zum Überprüfen eines Benutzers anhand der RFID UID router.post('/v1/private/users/find', requireApiKey, async (req, res) => { const { uid } = req.body; // Validierung if (!uid) { return res.status(400).json({ success: false, message: 'UID ist erforderlich' }); } try { // Spieler anhand der RFID UID finden const result = await pool.query( 'SELECT rfiduid, firstname, lastname, birthdate FROM players WHERE rfiduid = $1', [uid] ); if (result.rows.length === 0) { // Benutzer nicht gefunden - gibt leere UserData zurück return res.json({ success: true, data: { uid: "", firstname: "", lastname: "", alter: 0, exists: false } }); } const player = result.rows[0]; // Alter aus dem Geburtsdatum berechnen let age = 0; if (player.birthdate) { const today = new Date(); const birthDate = new Date(player.birthdate); age = today.getFullYear() - birthDate.getFullYear(); // Prüfen ob der Geburtstag dieses Jahr noch nicht war const monthDiff = today.getMonth() - birthDate.getMonth(); if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) { age--; } } res.json({ success: true, data: { uid: player.rfiduid, firstname: player.firstname, lastname: player.lastname, alter: age, exists: true } }); } catch (error) { console.error('Fehler beim Überprüfen des Benutzers:', error); res.status(500).json({ success: false, message: 'Interner Serverfehler beim Überprüfen des Benutzers' }); } }); // ============================================================================ // RFID LINKING & USER MANAGEMENT ENDPOINTS (No API Key required for dashboard) // ============================================================================ // Import blacklist module const { checkNameAgainstBlacklist, addToBlacklist, removeFromBlacklist, getBlacklist } = require('../config/blacklist-db'); // Create new player with RFID and blacklist validation (no auth required for dashboard) router.post('/v1/public/players/create-with-rfid', async (req, res) => { const { rfiduid, firstname, lastname, birthdate, supabase_user_id } = req.body; // Validierung if (!rfiduid || !firstname || !lastname || !birthdate) { return res.status(400).json({ success: false, message: 'RFID UID, Vorname, Nachname und Geburtsdatum sind erforderlich' }); } try { // Blacklist-Prüfung mit Levenshtein-Algorithmus const blacklistCheck = await checkNameAgainstBlacklist(firstname, lastname); if (blacklistCheck.isBlocked) { return res.status(400).json({ success: false, message: `Name nicht zulässig: ${blacklistCheck.reason}`, details: blacklistCheck }); } // Prüfen ob RFID UID bereits existiert const existingRfid = await pool.query( 'SELECT id, firstname, lastname FROM players WHERE rfiduid = $1', [rfiduid] ); if (existingRfid.rows.length > 0) { return res.status(409).json({ success: false, message: 'RFID UID existiert bereits', details: { existingPlayer: { firstname: existingRfid.rows[0].firstname, lastname: existingRfid.rows[0].lastname } } }); } // Prüfen ob Supabase User bereits verknüpft ist (falls angegeben) if (supabase_user_id) { const existingUser = await pool.query( 'SELECT id FROM players WHERE supabase_user_id = $1', [supabase_user_id] ); if (existingUser.rows.length > 0) { return res.status(409).json({ success: false, message: 'Dieser Benutzer ist bereits mit einem Spieler verknüpft' }); } } // Geburtsdatum validieren const birthDate = new Date(birthdate); if (isNaN(birthDate.getTime())) { return res.status(400).json({ success: false, message: 'Ungültiges Geburtsdatum' }); } // Alter berechnen const today = new Date(); let age = today.getFullYear() - birthDate.getFullYear(); const monthDiff = today.getMonth() - birthDate.getMonth(); if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) { age--; } // Spieler in Datenbank einfügen const result = await pool.query( `INSERT INTO players (rfiduid, firstname, lastname, birthdate, supabase_user_id, created_at, show_in_leaderboard) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, rfiduid, firstname, lastname, birthdate, created_at`, [rfiduid, firstname, lastname, birthdate, supabase_user_id || null, new Date(), true] ); const newPlayer = result.rows[0]; res.status(201).json({ success: true, message: 'Spieler erfolgreich mit RFID erstellt', data: { id: newPlayer.id, rfiduid: newPlayer.rfiduid, firstname: newPlayer.firstname, lastname: newPlayer.lastname, birthdate: newPlayer.birthdate, age: age, created_at: newPlayer.created_at } }); } catch (error) { console.error('Fehler beim Erstellen des Spielers mit RFID:', error); res.status(500).json({ success: false, message: 'Interner Serverfehler beim Erstellen des Spielers' }); } }); // Create new player with optional Supabase user linking (no auth required for dashboard) router.post('/v1/public/players', async (req, res) => { const { firstname, lastname, birthdate, rfiduid, supabase_user_id } = req.body; // Validierung if (!firstname || !lastname || !birthdate) { return res.status(400).json({ success: false, message: 'Firstname, Lastname und Birthdate sind erforderlich' }); } try { // Prüfen ob RFID UID bereits existiert (falls angegeben) if (rfiduid) { const existingRfid = await pool.query( 'SELECT id FROM players WHERE rfiduid = $1', [rfiduid] ); if (existingRfid.rows.length > 0) { return res.status(409).json({ success: false, message: 'RFID UID existiert bereits' }); } } // Prüfen ob Supabase User bereits verknüpft ist if (supabase_user_id) { const existingUser = await pool.query( 'SELECT id FROM players WHERE supabase_user_id = $1', [supabase_user_id] ); if (existingUser.rows.length > 0) { return res.status(409).json({ success: false, message: 'Dieser Benutzer ist bereits mit einem Spieler verknüpft' }); } } // Spieler in Datenbank einfügen const result = await pool.query( `INSERT INTO players (firstname, lastname, birthdate, rfiduid, supabase_user_id, created_at) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, created_at`, [firstname, lastname, birthdate, rfiduid, supabase_user_id, new Date()] ); res.json({ success: true, message: 'Spieler erfolgreich erstellt', data: { id: result.rows[0].id, firstname: firstname, lastname: lastname, birthdate: birthdate, rfiduid: rfiduid, supabase_user_id: supabase_user_id, created_at: result.rows[0].created_at } }); } catch (error) { console.error('Fehler beim Erstellen des Spielers:', error); res.status(500).json({ success: false, message: 'Interner Serverfehler beim Erstellen des Spielers' }); } }); // Link existing player to Supabase user (no auth required for dashboard) router.post('/v1/public/link-player', async (req, res) => { const { player_id, supabase_user_id } = req.body; // Validierung if (!player_id || !supabase_user_id) { return res.status(400).json({ success: false, message: 'Player ID und Supabase User ID sind erforderlich' }); } try { // Prüfen ob Spieler existiert const playerExists = await pool.query( 'SELECT id, firstname, lastname FROM players WHERE id = $1', [player_id] ); if (playerExists.rows.length === 0) { return res.status(404).json({ success: false, message: 'Spieler nicht gefunden' }); } // Prüfen ob Supabase User bereits verknüpft ist const existingLink = await pool.query( 'SELECT id FROM players WHERE supabase_user_id = $1', [supabase_user_id] ); if (existingLink.rows.length > 0) { return res.status(409).json({ success: false, message: 'Dieser Benutzer ist bereits mit einem Spieler verknüpft' }); } // Verknüpfung erstellen const result = await pool.query( 'UPDATE players SET supabase_user_id = $1 WHERE id = $2 RETURNING id, firstname, lastname, rfiduid', [supabase_user_id, player_id] ); const player = result.rows[0]; // Alle anonymen Zeiten (mit rfiduid aber ohne player_id) mit diesem Spieler verknüpfen if (player.rfiduid) { const updateTimesResult = await pool.query( 'UPDATE times SET player_id = $1 WHERE rfiduid = $2 AND player_id IS NULL', [player_id, player.rfiduid] ); console.log(`✅ ${updateTimesResult.rowCount} anonyme Zeiten mit Spieler ${player_id} verknüpft`); } res.json({ success: true, message: 'Spieler erfolgreich verknüpft', data: { ...player, linked_times_count: player.rfiduid ? (await pool.query('SELECT COUNT(*) FROM times WHERE rfiduid = $1 AND player_id = $2', [player.rfiduid, player_id])).rows[0].count : 0 } }); } catch (error) { console.error('Fehler beim Verknüpfen des Spielers:', error); res.status(500).json({ success: false, message: 'Interner Serverfehler beim Verknüpfen des Spielers' }); } }); // Get user times by Supabase user ID (no auth required for dashboard) router.get('/v1/public/user-times/:supabase_user_id', async (req, res) => { const { supabase_user_id } = req.params; try { // Finde verknüpften Spieler const playerResult = await pool.query( 'SELECT id FROM players WHERE supabase_user_id = $1', [supabase_user_id] ); if (playerResult.rows.length === 0) { return res.json([]); // Noch keine Verknüpfung } const player_id = playerResult.rows[0].id; // Hole alle Zeiten für diesen Spieler mit Location-Namen const timesResult = await pool.query(` SELECT t.id, t.recorded_time, t.created_at, l.name as location_name, l.latitude, l.longitude FROM times t JOIN locations l ON t.location_id = l.id WHERE t.player_id = $1 ORDER BY t.created_at DESC `, [player_id]); res.json({ success: true, data: timesResult.rows }); } catch (error) { console.error('Fehler beim Abrufen der Benutzerzeiten:', error); res.status(500).json({ success: false, message: 'Fehler beim Abrufen der Benutzerzeiten' }); } }); /** * @swagger * /api/v1/public/user-player/{supabase_user_id}: * get: * summary: Spieler-Info anhand der Supabase User ID abrufen * description: Ruft Spieler-Informationen für das Dashboard ab * tags: [Public API] * parameters: * - in: path * name: supabase_user_id * required: true * schema: * type: string * format: uuid * description: Supabase User ID * responses: * 200: * description: Spieler-Informationen * content: * application/json: * schema: * type: object * properties: * success: * type: boolean * example: true * data: * $ref: '#/components/schemas/Player' * 404: * description: Spieler nicht gefunden * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 500: * description: Server-Fehler * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // Get player info by Supabase user ID (no auth required for dashboard) router.get('/v1/public/user-player/:supabase_user_id', async (req, res) => { const { supabase_user_id } = req.params; try { const result = await pool.query( 'SELECT id, firstname, lastname, birthdate, rfiduid, show_in_leaderboard FROM players WHERE supabase_user_id = $1', [supabase_user_id] ); if (result.rows.length === 0) { return res.status(404).json({ success: false, message: 'Kein verknüpfter Spieler gefunden' }); } res.json({ success: true, data: result.rows[0] }); } catch (error) { console.error('Fehler beim Abrufen der Spielerdaten:', error); res.status(500).json({ success: false, message: 'Fehler beim Abrufen der Spielerdaten' }); } }); // Link user by RFID UID (scanned from QR code) router.post('/v1/public/link-by-rfid', async (req, res) => { const { rfiduid, supabase_user_id } = req.body; // Validierung if (!rfiduid || !supabase_user_id) { return res.status(400).json({ success: false, message: 'RFID UID und Supabase User ID sind erforderlich' }); } try { // Prüfen ob Spieler mit dieser RFID UID existiert const playerResult = await pool.query( 'SELECT id, firstname, lastname, rfiduid, supabase_user_id FROM players WHERE rfiduid = $1', [rfiduid] ); if (playerResult.rows.length === 0) { return res.status(404).json({ success: false, message: `Kein Spieler mit RFID UID '${rfiduid}' gefunden. Bitte erstelle zuerst einen Spieler mit dieser RFID UID.` }); } const player = playerResult.rows[0]; // Prüfen ob dieser Spieler bereits mit einem anderen Benutzer verknüpft ist if (player.supabase_user_id && player.supabase_user_id !== supabase_user_id) { return res.status(409).json({ success: false, message: 'Dieser Spieler ist bereits mit einem anderen Benutzer verknüpft' }); } // Prüfen ob dieser Benutzer bereits mit einem anderen Spieler verknüpft ist const existingLink = await pool.query( 'SELECT id, firstname, lastname FROM players WHERE supabase_user_id = $1 AND id != $2', [supabase_user_id, player.id] ); if (existingLink.rows.length > 0) { return res.status(409).json({ success: false, message: `Du bist bereits mit dem Spieler '${existingLink.rows[0].firstname} ${existingLink.rows[0].lastname}' verknüpft. Ein Benutzer kann nur mit einem Spieler verknüpft sein.` }); } // Verknüpfung erstellen (falls noch nicht vorhanden) if (!player.supabase_user_id) { await pool.query( 'UPDATE players SET supabase_user_id = $1 WHERE id = $2', [supabase_user_id, player.id] ); } res.json({ success: true, message: 'RFID erfolgreich verknüpft', data: { id: player.id, firstname: player.firstname, lastname: player.lastname, rfiduid: player.rfiduid } }); } catch (error) { console.error('Fehler beim Verknüpfen per RFID UID:', error); res.status(500).json({ success: false, message: 'Interner Serverfehler beim Verknüpfen per RFID UID' }); } }); // ============================================================================ // ADMIN DASHBOARD ROUTES // ============================================================================ // Middleware für Admin-Authentifizierung function requireAdminAuth(req, res, next) { if (!req.session.userId) { return res.status(401).json({ success: false, message: 'Authentifizierung erforderlich' }); } next(); } // Middleware für Level 2 Zugriff function requireLevel2Access(req, res, next) { if (!req.session.userId || req.session.accessLevel < 2) { return res.status(403).json({ success: false, message: 'Insufficient access level' }); } next(); } // Session-Check für Dashboard router.get('/v1/web/check-session', (req, res) => { if (req.session.userId) { res.json({ success: true, user: { id: req.session.userId, username: req.session.username, access_level: req.session.accessLevel || 1 } }); } else { res.status(401).json({ success: false, message: 'Not authenticated' }); } }); // ============================================================================ // ADMIN DASHBOARD ROUTES (/api/v1/admin/) // ============================================================================ // Blacklist-Verwaltung router.get('/v1/admin/blacklist', requireAdminAuth, async (req, res) => { try { const blacklist = await getBlacklist(); res.json({ success: true, data: blacklist }); } catch (error) { console.error('Error loading blacklist:', error); res.status(500).json({ success: false, message: 'Fehler beim Laden der Blacklist' }); } }); // Blacklist-Eintrag hinzufügen router.post('/v1/admin/blacklist', requireAdminAuth, async (req, res) => { const { term, category } = req.body; if (!term || !category) { return res.status(400).json({ success: false, message: 'Begriff und Kategorie sind erforderlich' }); } try { // Get admin user info for created_by field const adminUser = req.session.username || 'admin'; await addToBlacklist(term, category, adminUser); res.json({ success: true, message: 'Begriff erfolgreich zur Blacklist hinzugefügt' }); } catch (error) { console.error('Error adding to blacklist:', error); res.status(500).json({ success: false, message: 'Fehler beim Hinzufügen zur Blacklist' }); } }); // Blacklist-Eintrag entfernen router.delete('/v1/admin/blacklist', requireAdminAuth, async (req, res) => { const { term, category } = req.body; if (!term || !category) { return res.status(400).json({ success: false, message: 'Begriff und Kategorie sind erforderlich' }); } try { await removeFromBlacklist(term, category); res.json({ success: true, message: 'Begriff erfolgreich aus der Blacklist entfernt' }); } catch (error) { console.error('Error removing from blacklist:', error); res.status(500).json({ success: false, message: 'Fehler beim Entfernen aus der Blacklist' }); } }); // Name gegen Blacklist testen router.post('/v1/admin/blacklist/test', requireAdminAuth, async (req, res) => { const { firstname, lastname } = req.body; if (!firstname || !lastname) { return res.status(400).json({ success: false, message: 'Vorname und Nachname sind erforderlich' }); } try { // Blacklist-Prüfung mit Levenshtein-Algorithmus const blacklistResult = await checkNameAgainstBlacklist(firstname, lastname); res.json({ success: true, data: { blacklist: blacklistResult, isBlocked: blacklistResult.isBlocked, reason: blacklistResult.reason, source: blacklistResult.isBlocked ? 'blacklist' : 'none' } }); } catch (error) { console.error('Error testing name against blacklist:', error); res.status(500).json({ success: false, message: 'Fehler beim Testen des Namens' }); } }); // Blacklist-Statistiken router.get('/v1/admin/blacklist/stats', requireAdminAuth, async (req, res) => { try { const result = await pool.query(` SELECT category, COUNT(*) as count, MAX(created_at) as last_added FROM blacklist_terms GROUP BY category ORDER BY category `); const totalResult = await pool.query('SELECT COUNT(*) as total FROM blacklist_terms'); res.json({ success: true, data: { categories: result.rows, total: parseInt(totalResult.rows[0].total) } }); } catch (error) { console.error('Error loading blacklist stats:', error); res.status(500).json({ success: false, message: 'Fehler beim Laden der Blacklist-Statistiken' }); } }); // LLM-Status prüfen router.get('/v1/admin/llm/status', requireAdminAuth, async (req, res) => { try { const status = await testLLMConnection(); res.json({ success: true, data: status }); } catch (error) { console.error('Error checking LLM status:', error); res.status(500).json({ success: false, message: 'Fehler beim Prüfen des LLM-Status' }); } }); // LLM-Test mit Kontext router.post('/v1/admin/llm/test', requireAdminAuth, async (req, res) => { const { firstname, lastname, context } = req.body; if (!firstname || !lastname) { return res.status(400).json({ success: false, message: 'Vorname und Nachname sind erforderlich' }); } try { const result = await checkNameWithContext(firstname, lastname, context); res.json({ success: true, data: result }); } catch (error) { console.error('Error testing name with LLM:', error); res.status(500).json({ success: false, message: 'Fehler beim Testen mit LLM' }); } }); // Levenshtein-Test (für Entwicklung/Debugging) router.post('/v1/admin/blacklist/levenshtein-test', requireAdminAuth, async (req, res) => { const { firstname, lastname, threshold } = req.body; if (!firstname || !lastname) { return res.status(400).json({ success: false, message: 'Vorname und Nachname sind erforderlich' }); } try { const { checkWithLevenshtein, THRESHOLDS } = require('../config/levenshtein'); const blacklist = await getBlacklist(); const testThreshold = threshold || 0.3; const results = {}; // Teste jede Kategorie for (const [category, terms] of Object.entries(blacklist)) { const levenshteinResult = checkWithLevenshtein(firstname, lastname, terms, testThreshold); results[category] = { hasSimilarTerms: levenshteinResult.hasSimilarTerms, similarTerms: levenshteinResult.similarTerms, threshold: testThreshold, categoryThreshold: THRESHOLDS[category] || 0.3 }; } res.json({ success: true, data: { input: { firstname, lastname }, threshold: testThreshold, results: results } }); } catch (error) { console.error('Error testing Levenshtein:', error); res.status(500).json({ success: false, message: 'Fehler beim Testen der Levenshtein-Distanz' }); } }); // Admin Statistiken router.get('/v1/admin/stats', requireAdminAuth, async (req, res) => { try { const playersResult = await pool.query('SELECT COUNT(*) FROM players'); const runsResult = await pool.query('SELECT COUNT(*) FROM times'); const locationsResult = await pool.query('SELECT COUNT(*) FROM locations'); const adminUsersResult = await pool.query('SELECT COUNT(*) FROM adminusers'); res.json({ success: true, data: { players: parseInt(playersResult.rows[0].count), runs: parseInt(runsResult.rows[0].count), locations: parseInt(locationsResult.rows[0].count), adminUsers: parseInt(adminUsersResult.rows[0].count) } }); } catch (error) { console.error('Error loading admin stats:', error); res.status(500).json({ success: false, message: 'Fehler beim Laden der Statistiken' }); } }); // Admin Spieler-Verwaltung router.get('/v1/admin/players', requireAdminAuth, async (req, res) => { try { const result = await pool.query(` SELECT p.*, COALESCE(CONCAT(p.firstname, ' ', p.lastname), p.firstname, p.lastname) as full_name, CASE WHEN p.supabase_user_id IS NOT NULL THEN true ELSE false END as has_supabase_link FROM players p ORDER BY p.created_at DESC `); res.json({ success: true, data: result.rows }); } catch (error) { console.error('Error loading players:', error); res.status(500).json({ success: false, message: 'Fehler beim Laden der Spieler' }); } }); router.delete('/v1/admin/players/:id', requireAdminAuth, async (req, res) => { const playerId = req.params.id; try { // Erst alle zugehörigen Zeiten löschen await pool.query('DELETE FROM times WHERE player_id = $1', [playerId]); // Dann den Spieler löschen const result = await pool.query('DELETE FROM players WHERE id = $1', [playerId]); if (result.rowCount > 0) { res.json({ success: true, message: 'Spieler erfolgreich gelöscht' }); } else { res.status(404).json({ success: false, message: 'Spieler nicht gefunden' }); } } catch (error) { console.error('Error deleting player:', error); res.status(500).json({ success: false, message: 'Fehler beim Löschen des Spielers' }); } }); // Admin Läufe-Verwaltung router.get('/v1/admin/runs', requireAdminAuth, async (req, res) => { try { const result = await pool.query(` SELECT t.id, t.player_id, t.location_id, t.recorded_time, EXTRACT(EPOCH FROM t.recorded_time) as time_seconds, t.created_at, COALESCE(CONCAT(p.firstname, ' ', p.lastname), p.firstname, p.lastname) as player_name, l.name as location_name FROM times t LEFT JOIN players p ON t.player_id = p.id LEFT JOIN locations l ON t.location_id = l.id ORDER BY t.created_at DESC LIMIT 1000 `); res.json({ success: true, data: result.rows }); } catch (error) { console.error('Error loading runs:', error); res.status(500).json({ success: false, message: 'Fehler beim Laden der Läufe' }); } }); // GET einzelner Lauf router.get('/v1/admin/runs/:id', requireAdminAuth, async (req, res) => { try { const { id } = req.params; const result = await pool.query(` SELECT t.id, t.player_id, t.location_id, t.recorded_time, EXTRACT(EPOCH FROM t.recorded_time) as time_seconds, t.created_at, COALESCE(CONCAT(p.firstname, ' ', p.lastname), p.firstname, p.lastname) as player_name, l.name as location_name FROM times t LEFT JOIN players p ON t.player_id = p.id LEFT JOIN locations l ON t.location_id = l.id WHERE t.id = $1 `, [id]); if (result.rows.length === 0) { return res.status(404).json({ success: false, message: 'Lauf nicht gefunden' }); } res.json({ success: true, data: result.rows[0] }); } catch (error) { console.error('Error loading run:', error); res.status(500).json({ success: false, message: 'Fehler beim Laden des Laufs' }); } }); router.delete('/v1/admin/runs/:id', requireAdminAuth, async (req, res) => { const runId = req.params.id; try { const result = await pool.query('DELETE FROM times WHERE id = $1', [runId]); if (result.rowCount > 0) { res.json({ success: true, message: 'Lauf erfolgreich gelöscht' }); } else { res.status(404).json({ success: false, message: 'Lauf nicht gefunden' }); } } catch (error) { console.error('Error deleting run:', error); res.status(500).json({ success: false, message: 'Fehler beim Löschen des Laufs' }); } }); // Admin Standort-Verwaltung router.get('/v1/admin/locations', requireAdminAuth, async (req, res) => { try { const result = await pool.query('SELECT * FROM locations ORDER BY name'); res.json({ success: true, data: result.rows }); } catch (error) { console.error('Error loading locations:', error); res.status(500).json({ success: false, message: 'Fehler beim Laden der Standorte' }); } }); router.delete('/v1/admin/locations/:id', requireAdminAuth, async (req, res) => { const locationId = req.params.id; try { // Prüfen ob noch Läufe an diesem Standort existieren const timesResult = await pool.query('SELECT COUNT(*) FROM times WHERE location_id = $1', [locationId]); const timesCount = parseInt(timesResult.rows[0].count); if (timesCount > 0) { return res.status(400).json({ success: false, message: `Standort kann nicht gelöscht werden. Es existieren noch ${timesCount} Läufe an diesem Standort.` }); } const result = await pool.query('DELETE FROM locations WHERE id = $1', [locationId]); if (result.rowCount > 0) { res.json({ success: true, message: 'Standort erfolgreich gelöscht' }); } else { res.status(404).json({ success: false, message: 'Standort nicht gefunden' }); } } catch (error) { console.error('Error deleting location:', error); res.status(500).json({ success: false, message: 'Fehler beim Löschen des Standorts' }); } }); // Admin-Benutzer-Verwaltung router.get('/v1/admin/adminusers', requireAdminAuth, async (req, res) => { try { const result = await pool.query(` SELECT id, username, access_level, is_active, created_at, last_login FROM adminusers ORDER BY created_at DESC `); res.json({ success: true, data: result.rows }); } catch (error) { console.error('Error loading admin users:', error); res.status(500).json({ success: false, message: 'Fehler beim Laden der Admin-Benutzer' }); } }); router.delete('/v1/admin/adminusers/:id', requireAdminAuth, async (req, res) => { const userId = req.params.id; // Verhindern, dass sich selbst löscht if (parseInt(userId) === req.session.userId) { return res.status(400).json({ success: false, message: 'Sie können sich nicht selbst löschen' }); } try { const result = await pool.query('DELETE FROM adminusers WHERE id = $1', [userId]); if (result.rowCount > 0) { res.json({ success: true, message: 'Admin-Benutzer erfolgreich gelöscht' }); } else { res.status(404).json({ success: false, message: 'Admin-Benutzer nicht gefunden' }); } } catch (error) { console.error('Error deleting admin user:', error); res.status(500).json({ success: false, message: 'Fehler beim Löschen des Admin-Benutzers' }); } }); // ============================================================================ // PAGE VIEWS TRACKING // ============================================================================ // Track page view router.post('/v1/public/track-page-view', async (req, res) => { try { const { page, userAgent, ipAddress, referer } = req.body; await pool.query(` INSERT INTO page_views (page, user_agent, ip_address, referer) VALUES ($1, $2, $3, $4) `, [page, userAgent, ipAddress, referer]); res.json({ success: true }); } catch (error) { console.error('Error tracking page view:', error); res.status(500).json({ success: false, message: 'Fehler beim Tracking der Seitenaufrufe' }); } }); // ============================================================================ // LEADERBOARD ROUTES (moved from public.js) // ============================================================================ // Public endpoint für Standorte (keine Authentifizierung erforderlich) router.get('/v1/public/locations', async (req, res) => { try { const result = await pool.query('SELECT * FROM "GetLocations"'); res.json({ success: true, data: result.rows }); } catch (error) { console.error('Fehler beim Abrufen der getlocations:', error); res.status(500).json({ success: false, message: 'Fehler beim Abrufen der Standorte' }); } }); /** * @swagger * /api/v1/public/times: * get: * summary: Alle Zeiten mit Standort-Informationen abrufen * description: Ruft alle aufgezeichneten Zeiten mit Standort-Details ab * tags: [Public API] * parameters: * - in: query * name: location * schema: * type: string * description: Filter nach Standort-ID * - in: query * name: limit * schema: * type: integer * default: 100 * description: Maximale Anzahl der Ergebnisse * responses: * 200: * description: Liste aller Zeiten * content: * application/json: * schema: * type: object * properties: * success: * type: boolean * example: true * data: * type: array * items: * type: object * properties: * id: * type: string * format: uuid * player_id: * type: string * format: uuid * location_id: * type: string * format: uuid * recorded_time: * type: object * properties: * seconds: * type: number * minutes: * type: number * milliseconds: * type: number * created_at: * type: string * format: date-time * location_name: * type: string * latitude: * type: number * longitude: * type: number * 500: * description: Server-Fehler * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // Public route to get times for location with parameter router.get('/v1/public/times', async (req, res) => { const { location } = req.query; try { // First, let's check if the view exists and has data const viewCheck = await pool.query('SELECT COUNT(*) as count FROM "GetTimesWithPlayerAndLocation"'); // Check what location names are available const availableLocations = await pool.query('SELECT DISTINCT location_name FROM "GetTimesWithPlayerAndLocation"'); // Now search for the specific location const result = await pool.query('SELECT * FROM "GetTimesWithPlayerAndLocation" WHERE location_name = $1', [location]); res.json({ success: true, data: result.rows, debug: { searchedFor: location, totalRecords: viewCheck.rows[0].count, availableLocations: availableLocations.rows.map(r => r.location_name), foundRecords: result.rows.length } }); } catch (error) { console.error('❌ Fehler beim Abrufen der Zeiten:', error); res.status(500).json({ success: false, message: 'Fehler beim Abrufen der Zeiten', error: error.message }); } }); // Public route to get all times with player and location details for leaderboard router.get('/v1/public/times-with-details', async (req, res) => { try { const { location, period } = req.query; // Build WHERE clause for location filter let locationFilter = ''; if (location && location !== 'all') { locationFilter = `AND l.name ILIKE '%${location}%'`; } // Build WHERE clause for date filter using PostgreSQL timezone functions let dateFilter = ''; if (period === 'today') { // Today in local timezone (UTC+2) dateFilter = `AND DATE(t.created_at AT TIME ZONE 'UTC' AT TIME ZONE 'Europe/Berlin') = CURRENT_DATE`; } else if (period === 'week') { // This week starting from Monday in local timezone dateFilter = `AND DATE(t.created_at AT TIME ZONE 'UTC' AT TIME ZONE 'Europe/Berlin') >= DATE_TRUNC('week', CURRENT_DATE)`; } else if (period === 'month') { // This month starting from 1st in local timezone dateFilter = `AND DATE(t.created_at AT TIME ZONE 'UTC' AT TIME ZONE 'Europe/Berlin') >= DATE_TRUNC('month', CURRENT_DATE)`; } // Get all times with player and location details, ordered by time (fastest first) // Only show times from players who have opted into leaderboard visibility const result = await pool.query(` SELECT t.id, EXTRACT(EPOCH FROM t.recorded_time) as recorded_time_seconds, t.created_at, json_build_object( 'id', p.id, 'firstname', p.firstname, 'lastname', p.lastname, 'rfiduid', p.rfiduid ) as player, json_build_object( 'id', l.id, 'name', l.name, 'latitude', l.latitude, 'longitude', l.longitude ) as location FROM times t LEFT JOIN players p ON t.player_id = p.id LEFT JOIN locations l ON t.location_id = l.id WHERE 1=1 ${locationFilter} ${dateFilter} AND p.show_in_leaderboard = true ORDER BY t.recorded_time ASC LIMIT 50 `); // Convert seconds to minutes:seconds.milliseconds format const formattedResults = result.rows.map(row => { const totalSeconds = parseFloat(row.recorded_time_seconds); const minutes = Math.floor(totalSeconds / 60); const seconds = Math.floor(totalSeconds % 60); const milliseconds = Math.floor((totalSeconds % 1) * 1000); return { ...row, recorded_time: { minutes: minutes, seconds: seconds, milliseconds: milliseconds } }; }); res.json(formattedResults); } catch (error) { console.error('❌ Fehler beim Abrufen der Zeiten mit Details:', error); res.status(500).json({ success: false, message: 'Fehler beim Abrufen der Zeiten mit Details', error: error.message }); } }); // Get page statistics router.get('/v1/admin/page-stats', requireAdminAuth, async (req, res) => { try { // Page views for today, this week, this month const today = new Date(); const startOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate()); const startOfWeek = new Date(today); startOfWeek.setDate(today.getDate() - today.getDay()); startOfWeek.setHours(0, 0, 0, 0); const startOfMonth = new Date(today.getFullYear(), today.getMonth(), 1); // Today's page views const todayViews = await pool.query(` SELECT page, COUNT(*) as count FROM page_views WHERE created_at >= $1 GROUP BY page ORDER BY count DESC `, [startOfDay]); // This week's page views const weekViews = await pool.query(` SELECT page, COUNT(*) as count FROM page_views WHERE created_at >= $1 GROUP BY page ORDER BY count DESC `, [startOfWeek]); // This month's page views const monthViews = await pool.query(` SELECT page, COUNT(*) as count FROM page_views WHERE created_at >= $1 GROUP BY page ORDER BY count DESC `, [startOfMonth]); // Total page views const totalViews = await pool.query(` SELECT page, COUNT(*) as count FROM page_views GROUP BY page ORDER BY count DESC `); // Player/Supabase link statistics const linkStats = await pool.query(` SELECT COUNT(*) as total_players, COUNT(CASE WHEN supabase_user_id IS NOT NULL THEN 1 END) as linked_players, CAST( ROUND( (COUNT(CASE WHEN supabase_user_id IS NOT NULL THEN 1 END)::numeric / COUNT(*)) * 100, 2 ) AS DECIMAL(5,2) ) as link_percentage FROM players `); res.json({ success: true, data: { today: todayViews.rows, week: weekViews.rows, month: monthViews.rows, total: totalViews.rows, linkStats: linkStats.rows[0] } }); } catch (error) { console.error('Error loading page statistics:', error); res.status(500).json({ success: false, message: 'Fehler beim Laden der Seitenstatistiken' }); } }); // ============================================================================ // POST/PUT ROUTES FÜR CRUD-OPERATIONEN // ============================================================================ // Admin Spieler - POST (Hinzufügen) router.post('/v1/admin/players', requireAdminAuth, async (req, res) => { try { const { full_name, rfiduid, supabase_user_id } = req.body; // Name in firstname und lastname aufteilen const nameParts = full_name ? full_name.trim().split(' ') : []; const firstname = nameParts[0] || ''; const lastname = nameParts.slice(1).join(' ') || ''; const result = await pool.query( `INSERT INTO players (firstname, lastname, rfiduid, supabase_user_id, created_at) VALUES ($1, $2, $3, $4, NOW()) RETURNING *`, [firstname, lastname, rfiduid || null, supabase_user_id || null] ); res.json({ success: true, message: 'Spieler erfolgreich hinzugefügt', data: result.rows[0] }); } catch (error) { console.error('Error creating player:', error); res.status(500).json({ success: false, message: 'Fehler beim Hinzufügen des Spielers' }); } }); // Admin Spieler - PUT (Bearbeiten) router.put('/v1/admin/players/:id', requireAdminAuth, async (req, res) => { try { const playerId = req.params.id; const { full_name, rfiduid, supabase_user_id } = req.body; // Name in firstname und lastname aufteilen const nameParts = full_name ? full_name.trim().split(' ') : []; const firstname = nameParts[0] || ''; const lastname = nameParts.slice(1).join(' ') || ''; const result = await pool.query( `UPDATE players SET firstname = $1, lastname = $2, rfiduid = $3, supabase_user_id = $4 WHERE id = $5 RETURNING *`, [firstname, lastname, rfiduid || null, supabase_user_id || null, playerId] ); if (result.rowCount > 0) { res.json({ success: true, message: 'Spieler erfolgreich aktualisiert', data: result.rows[0] }); } else { res.status(404).json({ success: false, message: 'Spieler nicht gefunden' }); } } catch (error) { console.error('Error updating player:', error); res.status(500).json({ success: false, message: 'Fehler beim Aktualisieren des Spielers' }); } }); // Admin Standorte - POST (Hinzufügen) router.post('/v1/admin/locations', requireAdminAuth, async (req, res) => { try { const { name, latitude, longitude, time_threshold } = req.body; const result = await pool.query( `INSERT INTO locations (name, latitude, longitude, time_threshold, created_at) VALUES ($1, $2, $3, $4, NOW()) RETURNING *`, [name, latitude, longitude, time_threshold || null] ); res.json({ success: true, message: 'Standort erfolgreich hinzugefügt', data: result.rows[0] }); } catch (error) { console.error('Error creating location:', error); res.status(500).json({ success: false, message: 'Fehler beim Hinzufügen des Standorts' }); } }); // Admin Standorte - PUT (Bearbeiten) router.put('/v1/admin/locations/:id', requireAdminAuth, async (req, res) => { try { const locationId = req.params.id; const { name, latitude, longitude, time_threshold } = req.body; const result = await pool.query( `UPDATE locations SET name = $1, latitude = $2, longitude = $3, time_threshold = $4 WHERE id = $5 RETURNING *`, [name, latitude, longitude, time_threshold || null, locationId] ); if (result.rowCount > 0) { res.json({ success: true, message: 'Standort erfolgreich aktualisiert', data: result.rows[0] }); } else { res.status(404).json({ success: false, message: 'Standort nicht gefunden' }); } } catch (error) { console.error('Error updating location:', error); res.status(500).json({ success: false, message: 'Fehler beim Aktualisieren des Standorts' }); } }); // Admin Läufe - POST (Hinzufügen) router.post('/v1/admin/runs', requireAdminAuth, async (req, res) => { try { const { player_id, location_id, time_seconds } = req.body; // Prüfen ob Location existiert und Threshold abrufen const locationResult = await pool.query( 'SELECT id, name, time_threshold FROM locations WHERE id = $1', [location_id] ); if (locationResult.rows.length === 0) { return res.status(404).json({ success: false, message: 'Standort nicht gefunden' }); } const location = locationResult.rows[0]; // Prüfen ob die Zeit über dem Schwellenwert liegt if (location.time_threshold) { const thresholdSeconds = convertIntervalToSeconds(location.time_threshold); if (time_seconds < thresholdSeconds) { return res.status(400).json({ success: false, message: `Zeit ${time_seconds}s liegt unter dem Schwellenwert von ${location.time_threshold} für diesen Standort`, data: { recorded_time: `${time_seconds}s`, threshold: location.time_threshold, location_name: location.name } }); } } // Zeit in INTERVAL konvertieren const timeInterval = `${time_seconds} seconds`; const result = await pool.query( `INSERT INTO times (player_id, location_id, recorded_time, created_at) VALUES ($1, $2, $3, NOW()) RETURNING *`, [player_id, location_id, timeInterval] ); // Achievement-Überprüfung nach Zeit-Eingabe (JavaScript) try { const AchievementSystem = require('../lib/achievementSystem'); const achievementSystem = new AchievementSystem(); const newAchievements = await achievementSystem.checkImmediateAchievements(player_id); if (newAchievements.length > 0) { console.log(`🏆 ${newAchievements.length} neue Achievements für Spieler ${player_id}:`); newAchievements.forEach(achievement => { console.log(` ${achievement.icon} ${achievement.name} (+${achievement.points} Punkte)`); }); } } catch (achievementError) { console.error('Fehler bei Achievement-Check:', achievementError); // Achievement-Fehler sollen die Zeit-Eingabe nicht blockieren } res.json({ success: true, message: 'Lauf erfolgreich hinzugefügt', data: result.rows[0] }); } catch (error) { console.error('Error creating run:', error); res.status(500).json({ success: false, message: 'Fehler beim Hinzufügen des Laufs' }); } }); // Admin Läufe - PUT (Bearbeiten) router.put('/v1/admin/runs/:id', requireAdminAuth, async (req, res) => { try { const runId = req.params.id; const { player_id, location_id, time_seconds } = req.body; // Zeit in INTERVAL konvertieren const timeInterval = `${time_seconds} seconds`; const result = await pool.query( `UPDATE times SET player_id = $1, location_id = $2, recorded_time = $3 WHERE id = $4 RETURNING *`, [player_id, location_id, timeInterval, runId] ); if (result.rowCount > 0) { res.json({ success: true, message: 'Lauf erfolgreich aktualisiert', data: result.rows[0] }); } else { res.status(404).json({ success: false, message: 'Lauf nicht gefunden' }); } } catch (error) { console.error('Error updating run:', error); res.status(500).json({ success: false, message: 'Fehler beim Aktualisieren des Laufs' }); } }); // Admin-Benutzer - POST (Hinzufügen) router.post('/v1/admin/adminusers', requireAdminAuth, async (req, res) => { try { const { username, password, access_level } = req.body; // Passwort hashen const saltRounds = 10; const hashedPassword = await bcrypt.hash(password, saltRounds); const result = await pool.query( `INSERT INTO adminusers (username, password_hash, access_level, is_active, created_at) VALUES ($1, $2, $3, true, NOW()) RETURNING id, username, access_level, is_active, created_at`, [username, hashedPassword, access_level] ); res.json({ success: true, message: 'Admin-Benutzer erfolgreich hinzugefügt', data: result.rows[0] }); } catch (error) { console.error('Error creating admin user:', error); if (error.code === '23505') { // Unique constraint violation res.status(400).json({ success: false, message: 'Benutzername bereits vergeben' }); } else { res.status(500).json({ success: false, message: 'Fehler beim Hinzufügen des Admin-Benutzers' }); } } }); // Admin-Benutzer - PUT (Bearbeiten) router.put('/v1/admin/adminusers/:id', requireAdminAuth, async (req, res) => { try { const userId = req.params.id; const { username, password, access_level } = req.body; // Verhindern, dass sich selbst bearbeitet if (parseInt(userId) === req.session.userId) { return res.status(400).json({ success: false, message: 'Sie können sich nicht selbst bearbeiten' }); } let query, params; if (password) { // Passwort hashen const saltRounds = 10; const hashedPassword = await bcrypt.hash(password, saltRounds); query = `UPDATE adminusers SET username = $1, password_hash = $2, access_level = $3 WHERE id = $4 RETURNING id, username, access_level, is_active, created_at`; params = [username, hashedPassword, access_level, userId]; } else { query = `UPDATE adminusers SET username = $1, access_level = $2 WHERE id = $3 RETURNING id, username, access_level, is_active, created_at`; params = [username, access_level, userId]; } const result = await pool.query(query, params); if (result.rowCount > 0) { res.json({ success: true, message: 'Admin-Benutzer erfolgreich aktualisiert', data: result.rows[0] }); } else { res.status(404).json({ success: false, message: 'Admin-Benutzer nicht gefunden' }); } } catch (error) { console.error('Error updating admin user:', error); if (error.code === '23505') { // Unique constraint violation res.status(400).json({ success: false, message: 'Benutzername bereits vergeben' }); } else { res.status(500).json({ success: false, message: 'Fehler beim Aktualisieren des Admin-Benutzers' }); } } }); // ==================== BEST TIMES ENDPOINTS ==================== /** * @swagger * /api/v1/public/best-times: * get: * summary: Beste Zeiten abrufen * description: Ruft die besten Zeiten des Tages, der Woche und des Monats ab * tags: [Times] * responses: * 200: * description: Beste Zeiten erfolgreich abgerufen * content: * application/json: * schema: * type: object * properties: * success: * type: boolean * example: true * data: * type: object * properties: * daily: * type: object * properties: * player_id: * type: string * format: uuid * player_name: * type: string * best_time: * type: string * format: time * weekly: * type: object * properties: * player_id: * type: string * format: uuid * player_name: * type: string * best_time: * type: string * format: time * monthly: * type: object * properties: * player_id: * type: string * format: uuid * player_name: * type: string * best_time: * type: string * format: time * 500: * description: Server-Fehler * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // Get best times (daily, weekly, monthly) router.get('/v1/public/best-times', async (req, res) => { try { const currentDate = new Date(); const today = currentDate.toISOString().split('T')[0]; const weekStart = new Date(currentDate.setDate(currentDate.getDate() - currentDate.getDay())); const monthStart = new Date(currentDate.getFullYear(), currentDate.getMonth(), 1); // Get daily best const dailyResult = await pool.query(` WITH daily_best AS ( SELECT t.player_id, MIN(t.recorded_time) as best_time, CONCAT(p.firstname, ' ', p.lastname) as player_name FROM times t JOIN players p ON t.player_id = p.id WHERE DATE(t.created_at AT TIME ZONE 'Europe/Berlin') = $1 AND p.show_in_leaderboard = true GROUP BY t.player_id, p.firstname, p.lastname ) SELECT player_id, player_name, best_time FROM daily_best WHERE best_time = (SELECT MIN(best_time) FROM daily_best) LIMIT 1 `, [today]); // Get weekly best const weeklyResult = await pool.query(` WITH weekly_best AS ( SELECT t.player_id, MIN(t.recorded_time) as best_time, CONCAT(p.firstname, ' ', p.lastname) as player_name FROM times t JOIN players p ON t.player_id = p.id WHERE DATE(t.created_at AT TIME ZONE 'Europe/Berlin') >= $1 AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') <= $2 AND p.show_in_leaderboard = true GROUP BY t.player_id, p.firstname, p.lastname ) SELECT player_id, player_name, best_time FROM weekly_best WHERE best_time = (SELECT MIN(best_time) FROM weekly_best) LIMIT 1 `, [weekStart.toISOString().split('T')[0], today]); // Get monthly best const monthlyResult = await pool.query(` WITH monthly_best AS ( SELECT t.player_id, MIN(t.recorded_time) as best_time, CONCAT(p.firstname, ' ', p.lastname) as player_name FROM times t JOIN players p ON t.player_id = p.id WHERE DATE(t.created_at AT TIME ZONE 'Europe/Berlin') >= $1 AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') <= $2 AND p.show_in_leaderboard = true GROUP BY t.player_id, p.firstname, p.lastname ) SELECT player_id, player_name, best_time FROM monthly_best WHERE best_time = (SELECT MIN(best_time) FROM monthly_best) LIMIT 1 `, [monthStart.toISOString().split('T')[0], today]); res.json({ success: true, data: { daily: dailyResult.rows[0] || null, weekly: weeklyResult.rows[0] || null, monthly: monthlyResult.rows[0] || null } }); } catch (error) { console.error('Error fetching best times:', error); res.status(500).json({ success: false, message: 'Fehler beim Abrufen der Bestzeiten', error: error.message }); } }); // ==================== PUSH NOTIFICATION ENDPOINTS ==================== // Subscribe to push notifications router.post('/v1/public/subscribe', async (req, res) => { try { const { endpoint, keys } = req.body; const userId = req.session.userId || 'anonymous'; // Generate a UUID for anonymous users or use existing UUID let playerId; if (userId === 'anonymous') { // Generate a random UUID for anonymous users const { v4: uuidv4 } = require('uuid'); playerId = uuidv4(); } else { playerId = userId; } // Store subscription in database await pool.query(` INSERT INTO player_subscriptions (player_id, endpoint, p256dh, auth, created_at) VALUES ($1, $2, $3, $4, NOW()) ON CONFLICT (player_id) DO UPDATE SET endpoint = EXCLUDED.endpoint, p256dh = EXCLUDED.p256dh, auth = EXCLUDED.auth, updated_at = NOW() `, [ playerId, endpoint, keys.p256dh, keys.auth ]); // Also store in push service for immediate use const subscription = { endpoint: endpoint, keys: { p256dh: keys.p256dh, auth: keys.auth } }; pushService.subscribe(playerId, subscription); res.json({ success: true, message: 'Push subscription erfolgreich gespeichert' }); } catch (error) { console.error('Error storing push subscription:', error); res.status(500).json({ success: false, message: 'Fehler beim Speichern der Push Subscription', error: error.message }); } }); // Test push notification endpoint router.post('/v1/public/test-push', async (req, res) => { try { const { userId, message } = req.body; const payload = { title: '🧪 Test Notification', body: message || 'Das ist eine Test-Push-Notification!', icon: '/pictures/icon-192.png', badge: '/pictures/icon-192.png', data: { type: 'test', timestamp: Date.now() } }; const success = await pushService.sendToUser(userId || 'anonymous', payload); res.json({ success: success, message: success ? 'Test-Push gesendet!' : 'Keine Subscription gefunden' }); } catch (error) { console.error('Error sending test push:', error); res.status(500).json({ success: false, message: 'Fehler beim Senden der Test-Push' }); } }); // Get push subscription status router.get('/v1/public/push-status', async (req, res) => { try { const userId = req.session.userId || 'anonymous'; // For anonymous users, we can't check specific subscription // but we can show general stats const hasSubscription = userId !== 'anonymous' ? pushService.subscriptions.has(userId) : false; res.json({ success: true, data: { hasSubscription: hasSubscription, totalSubscriptions: pushService.getSubscriptionCount(), subscribedUsers: pushService.getSubscribedUsers(), isAnonymous: userId === 'anonymous' } }); } catch (error) { console.error('Error getting push status:', error); res.status(500).json({ success: false, message: 'Fehler beim Abrufen des Push-Status' }); } }); // ==================== ANALYTICS HELPER FUNCTIONS ==================== async function getPerformanceTrends(playerId) { const now = new Date(); const startOfWeek = new Date(now.setDate(now.getDate() - now.getDay())); const startOfLastWeek = new Date(startOfWeek.getTime() - 7 * 24 * 60 * 60 * 1000); // This week's average const thisWeekResult = await pool.query(` SELECT AVG(EXTRACT(EPOCH FROM recorded_time)) as avg_seconds FROM times WHERE player_id = $1 AND created_at >= $2 `, [playerId, startOfWeek]); // Last week's average const lastWeekResult = await pool.query(` SELECT AVG(EXTRACT(EPOCH FROM recorded_time)) as avg_seconds FROM times WHERE player_id = $1 AND created_at >= $2 AND created_at < $3 `, [playerId, startOfLastWeek, startOfWeek]); const avgTimeThisWeek = thisWeekResult.rows[0].avg_seconds || 0; const avgTimeLastWeek = lastWeekResult.rows[0].avg_seconds || 0; const improvement = avgTimeLastWeek > 0 ? ((avgTimeLastWeek - avgTimeThisWeek) / avgTimeLastWeek * 100) : 0; return { avgTimeThisWeek: avgTimeThisWeek, avgTimeLastWeek: avgTimeLastWeek, improvement: Math.round(improvement * 10) / 10 }; } async function getActivityStats(playerId) { const now = new Date(); const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const startOfWeek = new Date(now.setDate(now.getDate() - now.getDay())); // Today's runs const todayResult = await pool.query(` SELECT COUNT(*) as count FROM times WHERE player_id = $1 AND created_at >= $2 `, [playerId, startOfDay]); // This week's runs const weekResult = await pool.query(` SELECT COUNT(*) as count FROM times WHERE player_id = $1 AND created_at >= $2 `, [playerId, startOfWeek]); // Average runs per day (last 30 days) const avgResult = await pool.query(` SELECT COUNT(*) / 30.0 as avg_runs FROM times WHERE player_id = $1 AND created_at >= NOW() - INTERVAL '30 days' `, [playerId]); return { runsToday: parseInt(todayResult.rows[0].count), runsThisWeek: parseInt(weekResult.rows[0].count), avgRunsPerDay: parseFloat(avgResult.rows[0].avg_runs) || 0 }; } async function getLocationPerformance(playerId) { const result = await pool.query(` SELECT l.name, MIN(t.recorded_time) as best_time, COUNT(t.id) as runs FROM times t JOIN locations l ON t.location_id = l.id WHERE t.player_id = $1 GROUP BY l.id, l.name ORDER BY best_time ASC LIMIT 10 `, [playerId]); return result.rows.map(row => ({ name: row.name, bestTime: convertTimeToSeconds(row.best_time), runs: parseInt(row.runs) })); } async function getMonthlyStats(playerId) { const now = new Date(); const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); const startOfLastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1); const endOfLastMonth = new Date(now.getFullYear(), now.getMonth(), 0); // This month's runs const thisMonthResult = await pool.query(` SELECT COUNT(*) as count FROM times WHERE player_id = $1 AND created_at >= $2 `, [playerId, startOfMonth]); // Last month's runs const lastMonthResult = await pool.query(` SELECT COUNT(*) as count FROM times WHERE player_id = $1 AND created_at >= $2 AND created_at <= $3 `, [playerId, startOfLastMonth, endOfLastMonth]); // Best time this month const bestTimeResult = await pool.query(` SELECT MIN(recorded_time) as best_time FROM times WHERE player_id = $1 AND created_at >= $2 `, [playerId, startOfMonth]); return { runsThisMonth: parseInt(thisMonthResult.rows[0].count), runsLastMonth: parseInt(lastMonthResult.rows[0].count), bestTimeThisMonth: convertTimeToSeconds(bestTimeResult.rows[0].best_time) || 0 }; } async function getPersonalRecords(playerId) { const result = await pool.query(` SELECT t.recorded_time, l.name as location FROM times t JOIN locations l ON t.location_id = l.id WHERE t.player_id = $1 ORDER BY t.recorded_time ASC LIMIT 5 `, [playerId]); return result.rows.map(row => ({ time: convertTimeToSeconds(row.recorded_time), location: row.location })); } async function getConsistencyMetrics(playerId) { const result = await pool.query(` SELECT AVG(EXTRACT(EPOCH FROM recorded_time)) as avg_seconds, STDDEV(EXTRACT(EPOCH FROM recorded_time)) as stddev_seconds FROM times WHERE player_id = $1 `, [playerId]); const avgSeconds = parseFloat(result.rows[0].avg_seconds) || 0; const stddevSeconds = parseFloat(result.rows[0].stddev_seconds) || 0; // Consistency score: lower deviation = higher consistency const consistencyScore = avgSeconds > 0 ? Math.max(0, Math.min(100, (1 - (stddevSeconds / avgSeconds)) * 100)) : 0; return { averageTime: avgSeconds, timeDeviation: stddevSeconds, consistencyScore: Math.round(consistencyScore) }; } async function getRankingStats(playerId) { // Get player's best time const bestTimeResult = await pool.query(` SELECT MIN(recorded_time) as best_time FROM times WHERE player_id = $1 `, [playerId]); const bestTime = bestTimeResult.rows[0].best_time; if (!bestTime) { return []; } // Get rankings for different categories const rankings = []; // Overall ranking const overallResult = await pool.query(` SELECT COUNT(*) + 1 as position FROM times WHERE recorded_time < $1 `, [bestTime]); const totalPlayersResult = await pool.query(` SELECT COUNT(DISTINCT player_id) as total FROM times `); rankings.push({ category: 'Gesamt', position: parseInt(overallResult.rows[0].position), total: parseInt(totalPlayersResult.rows[0].total) }); return rankings; } async function getProgressStats(playerId) { // Total runs const totalRunsResult = await pool.query(` SELECT COUNT(*) as count FROM times WHERE player_id = $1 `, [playerId]); // Active days const activeDaysResult = await pool.query(` SELECT COUNT(DISTINCT DATE(created_at)) as count FROM times WHERE player_id = $1 `, [playerId]); // Locations visited const locationsResult = await pool.query(` SELECT COUNT(DISTINCT location_id) as count FROM times WHERE player_id = $1 `, [playerId]); return { totalRuns: parseInt(totalRunsResult.rows[0].count), activeDays: parseInt(activeDaysResult.rows[0].count), locationsVisited: parseInt(locationsResult.rows[0].count) }; } // ==================== ANALYTICS ENDPOINTS ==================== /** * @swagger * /api/analytics/player/{playerId}: * get: * summary: Analytics-Daten für einen Spieler abrufen * description: Ruft detaillierte Analytics-Daten für einen bestimmten Spieler ab * tags: [Analytics] * parameters: * - in: path * name: playerId * required: true * schema: * type: string * description: Spieler-ID * responses: * 200: * description: Analytics-Daten erfolgreich abgerufen * content: * application/json: * schema: * type: object * properties: * success: * type: boolean * data: * type: object * 404: * description: Spieler nicht gefunden * 500: * description: Interner Serverfehler */ router.get('/analytics/player/:playerId', async (req, res) => { const { playerId } = req.params; try { // Performance Trends const performanceData = await getPerformanceTrends(playerId); // Activity Stats const activityData = await getActivityStats(playerId); // Location Performance const locationData = await getLocationPerformance(playerId); // Monthly Stats const monthlyData = await getMonthlyStats(playerId); const analyticsData = { performance: performanceData, activity: activityData, locationPerformance: locationData, monthly: monthlyData }; res.json({ success: true, data: analyticsData }); } catch (error) { console.error('Error fetching analytics data:', error); res.status(500).json({ success: false, message: 'Fehler beim Laden der Analytics-Daten' }); } }); // ==================== STATISTICS ENDPOINTS ==================== /** * @swagger * /api/statistics/player/{playerId}: * get: * summary: Statistiken für einen Spieler abrufen * description: Ruft detaillierte Statistiken für einen bestimmten Spieler ab * tags: [Statistics] * parameters: * - in: path * name: playerId * required: true * schema: * type: string * description: Spieler-ID * responses: * 200: * description: Statistiken erfolgreich abgerufen * content: * application/json: * schema: * type: object * properties: * success: * type: boolean * data: * type: object * 404: * description: Spieler nicht gefunden * 500: * description: Interner Serverfehler */ router.get('/statistics/player/:playerId', async (req, res) => { const { playerId } = req.params; try { // Personal Records const personalRecords = await getPersonalRecords(playerId); // Consistency Metrics const consistencyData = await getConsistencyMetrics(playerId); // Ranking Stats const rankingData = await getRankingStats(playerId); // Progress Stats const progressData = await getProgressStats(playerId); const statisticsData = { personalRecords: personalRecords, consistency: consistencyData, rankings: rankingData, progress: progressData }; res.json({ success: true, data: statisticsData }); } catch (error) { console.error('Error fetching statistics data:', error); res.status(500).json({ success: false, message: 'Fehler beim Laden der Statistiken' }); } }); // ==================== ACHIEVEMENT ENDPOINTS ==================== /** * @swagger * /api/achievements: * get: * summary: Alle verfügbaren Achievements abrufen * description: Ruft alle aktiven Achievements im System ab * tags: [Achievements] * responses: * 200: * description: Liste aller Achievements * content: * application/json: * schema: * type: object * properties: * success: * type: boolean * example: true * data: * type: array * items: * $ref: '#/components/schemas/Achievement' * 500: * description: Server-Fehler * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // Get all achievements router.get('/achievements', async (req, res) => { try { const result = await pool.query(` SELECT id, name, name_en, description, description_en, category, icon, points, is_active FROM achievements WHERE is_active = true ORDER BY category, points DESC `); res.json({ success: true, data: result.rows }); } catch (error) { console.error('Error fetching achievements:', error); res.status(500).json({ success: false, message: 'Fehler beim Laden der Achievements' }); } }); /** * @swagger * /api/achievements/player/{playerId}: * get: * summary: Achievements eines Spielers abrufen * description: Ruft alle Achievements für einen bestimmten Spieler ab * tags: [Achievements] * parameters: * - in: path * name: playerId * required: true * schema: * type: string * format: uuid * description: Eindeutige Spieler-ID * responses: * 200: * description: Liste der Spieler-Achievements * content: * application/json: * schema: * type: object * properties: * success: * type: boolean * example: true * data: * type: array * items: * allOf: * - $ref: '#/components/schemas/Achievement' * - $ref: '#/components/schemas/PlayerAchievement' * 500: * description: Server-Fehler * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // Get player achievements router.get('/achievements/player/:playerId', async (req, res) => { try { // Set no-cache headers res.set({ 'Cache-Control': 'no-cache, no-store, must-revalidate', 'Pragma': 'no-cache', 'Expires': '0' }); const { playerId } = req.params; const result = await pool.query(` SELECT a.id, a.name, a.name_en, a.description, a.description_en, a.category, a.icon, a.points, COALESCE(MAX(pa.progress), 0) as progress, COALESCE(COUNT(pa.id) > 0, false) as is_completed, MAX(pa.earned_at) as earned_at, COUNT(pa.id) as completion_count FROM achievements a LEFT JOIN player_achievements pa ON a.id = pa.achievement_id AND pa.player_id = $1 WHERE a.is_active = true GROUP BY a.id, a.name, a.name_en, a.description, a.description_en, a.category, a.icon, a.points ORDER BY COALESCE(COUNT(pa.id) > 0, false) DESC, a.category, a.points DESC `, [playerId]); res.json({ success: true, data: result.rows }); } catch (error) { console.error('Error fetching player achievements:', error); res.status(500).json({ success: false, message: 'Fehler beim Laden der Spieler-Achievements' }); } }); // Get player achievement statistics router.get('/achievements/player/:playerId/stats', async (req, res) => { try { // Set no-cache headers res.set({ 'Cache-Control': 'no-cache, no-store, must-revalidate', 'Pragma': 'no-cache', 'Expires': '0' }); const { playerId } = req.params; // Get total available achievements const totalResult = await pool.query(` SELECT COUNT(*) as total_achievements FROM achievements WHERE is_active = true `); // Get player's earned achievements (count unique achievements, not total completions) const playerResult = await pool.query(` SELECT COUNT(DISTINCT CASE WHEN pa.is_completed = true THEN pa.achievement_id END) as completed_achievements, SUM(CASE WHEN pa.is_completed = true THEN a.points ELSE 0 END) as total_points, COUNT(CASE WHEN pa.is_completed = true AND DATE(pa.earned_at AT TIME ZONE 'Europe/Berlin') = CURRENT_DATE THEN 1 END) as achievements_today FROM achievements a LEFT JOIN player_achievements pa ON a.id = pa.achievement_id AND pa.player_id = $1 WHERE a.is_active = true `, [playerId]); const totalStats = totalResult.rows[0]; const playerStats = playerResult.rows[0]; const totalAchievements = parseInt(totalStats.total_achievements); const completedAchievements = parseInt(playerStats.completed_achievements) || 0; const completionPercentage = totalAchievements > 0 ? Math.round((completedAchievements / totalAchievements) * 100) : 0; res.json({ success: true, data: { total_achievements: totalAchievements, completed_achievements: completedAchievements, total_points: parseInt(playerStats.total_points) || 0, achievements_today: parseInt(playerStats.achievements_today) || 0, completion_percentage: completionPercentage } }); } catch (error) { console.error('Error fetching player achievement stats:', error); res.status(500).json({ success: false, message: 'Fehler beim Laden der Achievement-Statistiken' }); } }); // Check achievements for a specific player router.post('/achievements/check/:playerId', async (req, res) => { try { const { playerId } = req.params; // Verify player exists const playerCheck = await pool.query('SELECT id FROM players WHERE id = $1', [playerId]); if (playerCheck.rows.length === 0) { return res.status(404).json({ success: false, message: 'Spieler nicht gefunden' }); } // Run achievement check using AchievementSystem const AchievementSystem = require('../lib/achievementSystem'); const achievementSystem = new AchievementSystem(); await achievementSystem.loadAchievements(); const newAchievementsFromCheck = await achievementSystem.checkAllAchievements(playerId); // Get newly earned achievements const newAchievements = await pool.query(` SELECT a.name, a.description, a.icon, a.points FROM player_achievements pa INNER JOIN achievements a ON pa.achievement_id = a.id WHERE pa.player_id = $1 AND pa.is_completed = true AND DATE(pa.earned_at AT TIME ZONE 'Europe/Berlin') = CURRENT_DATE ORDER BY pa.earned_at DESC `, [playerId]); res.json({ success: true, message: 'Achievement-Check abgeschlossen', data: { new_achievements: newAchievements.rows, count: newAchievements.rows.length } }); } catch (error) { console.error('Error checking achievements:', error); res.status(500).json({ success: false, message: 'Fehler beim Überprüfen der Achievements' }); } }); // Run daily achievement check for all players router.post('/achievements/daily-check', async (req, res) => { try { // This endpoint runs the daily achievement check const { runDailyAchievements } = require('../scripts/daily_achievements'); await runDailyAchievements(); res.json({ success: true, message: 'Tägliche Achievement-Überprüfung abgeschlossen' }); } catch (error) { console.error('Error running daily achievement check:', error); res.status(500).json({ success: false, message: 'Fehler bei der täglichen Achievement-Überprüfung' }); } }); // Run best-time achievement check (for manual testing or cron jobs) router.post('/achievements/best-time-check', async (req, res) => { try { // This endpoint runs the best-time achievement check const { runBestTimeAchievements } = require('../scripts/best_time_achievements'); await runBestTimeAchievements(); res.json({ success: true, message: 'Best-Time Achievement-Überprüfung abgeschlossen' }); } catch (error) { console.error('Error running best-time achievement check:', error); res.status(500).json({ success: false, message: 'Fehler bei der Best-Time Achievement-Überprüfung' }); } }); // Get achievement leaderboard router.get('/achievements/leaderboard', async (req, res) => { try { const { limit = 10 } = req.query; const result = await pool.query(` SELECT p.firstname, p.lastname, COUNT(pa.id) as completed_achievements, SUM(a.points) as total_points FROM players p INNER JOIN player_achievements pa ON p.id = pa.player_id INNER JOIN achievements a ON pa.achievement_id = a.id WHERE pa.is_completed = true GROUP BY p.id, p.firstname, p.lastname ORDER BY total_points DESC, completed_achievements DESC LIMIT $1 `, [parseInt(limit)]); res.json({ success: true, data: result.rows.map((row, index) => ({ rank: index + 1, name: `${row.firstname} ${row.lastname}`, completed_achievements: parseInt(row.completed_achievements), total_points: parseInt(row.total_points) })) }); } catch (error) { console.error('Error fetching achievement leaderboard:', error); res.status(500).json({ success: false, message: 'Fehler beim Laden der Bestenliste' }); } }); // Update player settings (privacy settings) - Public endpoint for authenticated users router.post('/v1/public/update-player-settings', async (req, res) => { try { const { player_id, show_in_leaderboard } = req.body; if (!player_id) { return res.status(400).json({ success: false, message: 'Player ID ist erforderlich' }); } // Update player settings const updateQuery = ` UPDATE players SET show_in_leaderboard = $1, updated_at = NOW() WHERE id = $2 RETURNING id, firstname, lastname, show_in_leaderboard `; const result = await pool.query(updateQuery, [show_in_leaderboard || false, player_id]); if (result.rows.length === 0) { return res.status(404).json({ success: false, message: 'Spieler nicht gefunden' }); } res.json({ success: true, message: 'Einstellungen erfolgreich aktualisiert', data: result.rows[0] }); } catch (error) { console.error('Error updating player settings:', error); res.status(500).json({ success: false, message: 'Fehler beim Aktualisieren der Einstellungen' }); } }); // Update player settings (privacy settings) - Private endpoint with API key router.post('/v1/private/update-player-settings', requireApiKey, async (req, res) => { try { const { player_id, show_in_leaderboard } = req.body; if (!player_id) { return res.status(400).json({ success: false, message: 'Player ID ist erforderlich' }); } // Update player settings const updateQuery = ` UPDATE players SET show_in_leaderboard = $1, updated_at = NOW() WHERE id = $2 RETURNING id, firstname, lastname, show_in_leaderboard `; const result = await pool.query(updateQuery, [show_in_leaderboard || false, player_id]); if (result.rows.length === 0) { return res.status(404).json({ success: false, message: 'Spieler nicht gefunden' }); } res.json({ success: true, message: 'Einstellungen erfolgreich aktualisiert', data: result.rows[0] }); } catch (error) { console.error('Error updating player settings:', error); res.status(500).json({ success: false, message: 'Fehler beim Aktualisieren der Einstellungen' }); } }); // ==================== ADMIN ACHIEVEMENT MANAGEMENT ==================== /** * @swagger * /api/admin/achievements: * get: * summary: Alle Achievements für Admin-Verwaltung abrufen * description: Ruft alle Achievements (aktiv und inaktiv) für die Admin-Verwaltung ab * tags: [Admin, Achievements] * responses: * 200: * description: Liste aller Achievements * content: * application/json: * schema: * type: object * properties: * success: * type: boolean * example: true * data: * type: array * items: * $ref: '#/components/schemas/Achievement' * 500: * description: Server-Fehler */ // Get all achievements for admin management router.get('/v1/admin/achievements', requireAdminAuth, async (req, res) => { try { const result = await pool.query(` SELECT id, name, name_en, description, description_en, category, condition_type, condition_value, icon, points, is_active, can_be_earned_multiple_times, created_at FROM achievements ORDER BY is_active DESC, category, points DESC `); res.json({ success: true, data: result.rows }); } catch (error) { console.error('Error fetching achievements for admin:', error); res.status(500).json({ success: false, message: 'Fehler beim Laden der Achievements' }); } }); /** * @swagger * /api/admin/achievements: * post: * summary: Neues Achievement erstellen * description: Erstellt ein neues Achievement im System * tags: [Admin, Achievements] * requestBody: * required: true * content: * application/json: * schema: * type: object * required: * - name * - description * - category * - condition_type * - condition_value * properties: * name: * type: string * description: Name des Achievements * name_en: * type: string * description: Englischer Name des Achievements * description: * type: string * description: Beschreibung des Achievements * description_en: * type: string * description: Englische Beschreibung des Achievements * category: * type: string * description: Kategorie des Achievements * condition_type: * type: string * description: Art der Bedingung * condition_value: * type: integer * description: Wert der Bedingung * icon: * type: string * description: Icon für das Achievement * points: * type: integer * description: Punkte für das Achievement * is_active: * type: boolean * description: Ob das Achievement aktiv ist * can_be_earned_multiple_times: * type: boolean * description: Ob das Achievement mehrmals erreicht werden kann * responses: * 201: * description: Achievement erfolgreich erstellt * 400: * description: Ungültige Eingabedaten * 500: * description: Server-Fehler */ // Create new achievement router.post('/v1/admin/achievements', requireAdminAuth, async (req, res) => { try { const { name, name_en, description, description_en, category, condition_type, condition_value, icon, points, is_active, can_be_earned_multiple_times } = req.body; // Validate required fields if (!name || !description || !category || !condition_type || condition_value === undefined) { return res.status(400).json({ success: false, message: 'Name, Beschreibung, Kategorie, Bedingungstyp und Bedingungswert sind erforderlich' }); } const result = await pool.query(` INSERT INTO achievements ( name, name_en, description, description_en, category, condition_type, condition_value, icon, points, is_active, can_be_earned_multiple_times ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING * `, [ name, name_en || null, description, description_en || null, category, condition_type, condition_value, icon || '🏆', points || 10, is_active !== false, can_be_earned_multiple_times || false ]); res.status(201).json({ success: true, message: 'Achievement erfolgreich erstellt', data: result.rows[0] }); } catch (error) { console.error('Error creating achievement:', error); res.status(500).json({ success: false, message: 'Fehler beim Erstellen des Achievements' }); } }); /** * @swagger * /api/admin/achievements/{id}: * put: * summary: Achievement aktualisieren * description: Aktualisiert ein bestehendes Achievement * tags: [Admin, Achievements] * parameters: * - in: path * name: id * required: true * schema: * type: string * format: uuid * description: ID des Achievements * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * name: * type: string * name_en: * type: string * description: * type: string * description_en: * type: string * category: * type: string * condition_type: * type: string * condition_value: * type: integer * icon: * type: string * points: * type: integer * is_active: * type: boolean * can_be_earned_multiple_times: * type: boolean * responses: * 200: * description: Achievement erfolgreich aktualisiert * 404: * description: Achievement nicht gefunden * 500: * description: Server-Fehler */ // Update achievement router.put('/v1/admin/achievements/:id', requireAdminAuth, async (req, res) => { try { const { id } = req.params; const { name, name_en, description, description_en, category, condition_type, condition_value, icon, points, is_active, can_be_earned_multiple_times } = req.body; const result = await pool.query(` UPDATE achievements SET name = COALESCE($2, name), name_en = COALESCE($3, name_en), description = COALESCE($4, description), description_en = COALESCE($5, description_en), category = COALESCE($6, category), condition_type = COALESCE($7, condition_type), condition_value = COALESCE($8, condition_value), icon = COALESCE($9, icon), points = COALESCE($10, points), is_active = COALESCE($11, is_active), can_be_earned_multiple_times = COALESCE($12, can_be_earned_multiple_times) WHERE id = $1 RETURNING * `, [ id, name, name_en, description, description_en, category, condition_type, condition_value, icon, points, is_active, can_be_earned_multiple_times ]); if (result.rows.length === 0) { return res.status(404).json({ success: false, message: 'Achievement nicht gefunden' }); } res.json({ success: true, message: 'Achievement erfolgreich aktualisiert', data: result.rows[0] }); } catch (error) { console.error('Error updating achievement:', error); res.status(500).json({ success: false, message: 'Fehler beim Aktualisieren des Achievements' }); } }); /** * @swagger * /api/admin/achievements/{id}: * delete: * summary: Achievement löschen * description: Löscht ein Achievement (deaktiviert es nur) * tags: [Admin, Achievements] * parameters: * - in: path * name: id * required: true * schema: * type: string * format: uuid * description: ID des Achievements * responses: * 200: * description: Achievement erfolgreich deaktiviert * 404: * description: Achievement nicht gefunden * 500: * description: Server-Fehler */ // Delete (deactivate) achievement router.delete('/v1/admin/achievements/:id', requireAdminAuth, async (req, res) => { try { const { id } = req.params; const result = await pool.query(` UPDATE achievements SET is_active = false WHERE id = $1 RETURNING * `, [id]); if (result.rows.length === 0) { return res.status(404).json({ success: false, message: 'Achievement nicht gefunden' }); } res.json({ success: true, message: 'Achievement erfolgreich deaktiviert', data: result.rows[0] }); } catch (error) { console.error('Error deactivating achievement:', error); res.status(500).json({ success: false, message: 'Fehler beim Deaktivieren des Achievements' }); } }); /** * @swagger * /api/admin/achievements/players: * get: * summary: Alle Spieler mit ihren Achievements abrufen * description: Ruft alle Spieler mit ihren Achievement-Statistiken ab * tags: [Admin, Achievements] * responses: * 200: * description: Liste aller Spieler mit Achievement-Statistiken * content: * application/json: * schema: * type: object * properties: * success: * type: boolean * example: true * data: * type: array * items: * type: object * properties: * id: * type: string * format: uuid * firstname: * type: string * lastname: * type: string * total_achievements: * type: integer * completed_achievements: * type: integer * total_points: * type: integer * completion_percentage: * type: number * 500: * description: Server-Fehler */ // Get all players with achievement statistics router.get('/v1/admin/achievements/players', requireAdminAuth, async (req, res) => { try { const result = await pool.query(` SELECT p.id, p.firstname, p.lastname, COUNT(DISTINCT a.id) as total_achievements, COUNT(DISTINCT CASE WHEN pa.is_completed = true THEN pa.achievement_id END) as completed_achievements, COALESCE(SUM(CASE WHEN pa.is_completed = true THEN a.points ELSE 0 END), 0) as total_points, CASE WHEN COUNT(DISTINCT a.id) > 0 THEN ROUND((COUNT(DISTINCT CASE WHEN pa.is_completed = true THEN pa.achievement_id END)::numeric / COUNT(DISTINCT a.id)) * 100, 2) ELSE 0 END as completion_percentage FROM players p CROSS JOIN achievements a LEFT JOIN player_achievements pa ON a.id = pa.achievement_id AND pa.player_id = p.id WHERE a.is_active = true GROUP BY p.id, p.firstname, p.lastname ORDER BY total_points DESC, completion_percentage DESC, p.lastname, p.firstname `); res.json({ success: true, data: result.rows }); } catch (error) { console.error('Error fetching players with achievements:', error); res.status(500).json({ success: false, message: 'Fehler beim Laden der Spieler-Achievements' }); } }); /** * @swagger * /api/admin/achievements/players/{playerId}: * get: * summary: Achievements eines bestimmten Spielers abrufen * description: Ruft alle Achievements für einen bestimmten Spieler ab * tags: [Admin, Achievements] * parameters: * - in: path * name: playerId * required: true * schema: * type: string * format: uuid * description: ID des Spielers * responses: * 200: * description: Achievements des Spielers * 404: * description: Spieler nicht gefunden * 500: * description: Server-Fehler */ // Get specific player achievements router.get('/v1/admin/achievements/players/:playerId', requireAdminAuth, async (req, res) => { try { const { playerId } = req.params; // Check if player exists const playerCheck = await pool.query('SELECT id, firstname, lastname FROM players WHERE id = $1', [playerId]); if (playerCheck.rows.length === 0) { return res.status(404).json({ success: false, message: 'Spieler nicht gefunden' }); } const result = await pool.query(` SELECT a.id, a.name, a.name_en, a.description, a.description_en, a.category, a.condition_type, a.condition_value, a.icon, a.points, a.is_active, a.can_be_earned_multiple_times, COALESCE(MAX(pa.progress), 0) as progress, COALESCE(COUNT(pa.id) > 0, false) as is_completed, MAX(pa.earned_at) as earned_at, COUNT(pa.id) as completion_count FROM achievements a LEFT JOIN player_achievements pa ON a.id = pa.achievement_id AND pa.player_id = $1 AND pa.is_completed = true WHERE a.is_active = true GROUP BY a.id, a.name, a.name_en, a.description, a.description_en, a.category, a.condition_type, a.condition_value, a.icon, a.points, a.is_active, a.can_be_earned_multiple_times ORDER BY is_completed DESC, a.category, a.points DESC `, [playerId]); res.json({ success: true, player: playerCheck.rows[0], data: result.rows }); } catch (error) { console.error('Error fetching player achievements:', error); res.status(500).json({ success: false, message: 'Fehler beim Laden der Spieler-Achievements' }); } }); /** * @swagger * /api/admin/achievements/players/{playerId}/award: * post: * summary: Achievement an Spieler vergeben * description: Vergibt ein Achievement manuell an einen Spieler * tags: [Admin, Achievements] * parameters: * - in: path * name: playerId * required: true * schema: * type: string * format: uuid * description: ID des Spielers * requestBody: * required: true * content: * application/json: * schema: * type: object * required: * - achievement_id * properties: * achievement_id: * type: string * format: uuid * description: ID des Achievements * progress: * type: integer * description: Fortschritt des Achievements * default: 1 * responses: * 201: * description: Achievement erfolgreich vergeben * 400: * description: Ungültige Eingabedaten * 404: * description: Spieler oder Achievement nicht gefunden * 500: * description: Server-Fehler */ // Award achievement to player router.post('/v1/admin/achievements/players/:playerId/award', requireAdminAuth, async (req, res) => { try { const { playerId } = req.params; const { achievement_id, progress = 1 } = req.body; if (!achievement_id) { return res.status(400).json({ success: false, message: 'Achievement-ID ist erforderlich' }); } // Check if player exists const playerCheck = await pool.query('SELECT id, firstname, lastname FROM players WHERE id = $1', [playerId]); if (playerCheck.rows.length === 0) { return res.status(404).json({ success: false, message: 'Spieler nicht gefunden' }); } // Check if achievement exists const achievementCheck = await pool.query('SELECT id, name FROM achievements WHERE id = $1', [achievement_id]); if (achievementCheck.rows.length === 0) { return res.status(404).json({ success: false, message: 'Achievement nicht gefunden' }); } // Award the achievement const result = await pool.query(` INSERT INTO player_achievements (player_id, achievement_id, progress, is_completed, earned_at) VALUES ($1, $2, $3, true, NOW()) RETURNING * `, [playerId, achievement_id, progress]); res.status(201).json({ success: true, message: `Achievement "${achievementCheck.rows[0].name}" erfolgreich an ${playerCheck.rows[0].firstname} ${playerCheck.rows[0].lastname} vergeben`, data: result.rows[0] }); } catch (error) { console.error('Error awarding achievement:', error); res.status(500).json({ success: false, message: 'Fehler beim Vergeben des Achievements' }); } }); /** * @swagger * /api/admin/achievements/players/{playerId}/revoke: * delete: * summary: Achievement von Spieler entfernen * description: Entfernt ein Achievement von einem Spieler * tags: [Admin, Achievements] * parameters: * - in: path * name: playerId * required: true * schema: * type: string * format: uuid * description: ID des Spielers * requestBody: * required: true * content: * application/json: * schema: * type: object * required: * - achievement_id * properties: * achievement_id: * type: string * format: uuid * description: ID des Achievements * responses: * 200: * description: Achievement erfolgreich entfernt * 404: * description: Spieler oder Achievement nicht gefunden * 500: * description: Server-Fehler */ // Revoke achievement from player router.delete('/v1/admin/achievements/players/:playerId/revoke', requireAdminAuth, async (req, res) => { try { const { playerId } = req.params; const { achievement_id } = req.body; if (!achievement_id) { return res.status(400).json({ success: false, message: 'Achievement-ID ist erforderlich' }); } // Check if player exists const playerCheck = await pool.query('SELECT id, firstname, lastname FROM players WHERE id = $1', [playerId]); if (playerCheck.rows.length === 0) { return res.status(404).json({ success: false, message: 'Spieler nicht gefunden' }); } // Get achievement name for response const achievementCheck = await pool.query('SELECT id, name FROM achievements WHERE id = $1', [achievement_id]); if (achievementCheck.rows.length === 0) { return res.status(404).json({ success: false, message: 'Achievement nicht gefunden' }); } // Remove the achievement const result = await pool.query(` DELETE FROM player_achievements WHERE player_id = $1 AND achievement_id = $2 RETURNING * `, [playerId, achievement_id]); if (result.rows.length === 0) { return res.status(404).json({ success: false, message: 'Spieler hat dieses Achievement nicht' }); } res.json({ success: true, message: `Achievement "${achievementCheck.rows[0].name}" erfolgreich von ${playerCheck.rows[0].firstname} ${playerCheck.rows[0].lastname} entfernt`, data: result.rows[0] }); } catch (error) { console.error('Error revoking achievement:', error); res.status(500).json({ success: false, message: 'Fehler beim Entfernen des Achievements' }); } }); module.exports = { router, requireApiKey };