Compare commits

...

11 Commits

Author SHA1 Message Date
7b83e39e94 Update npm 2026-01-19 16:36:54 +01:00
6ca3228527 Hello 2025-12-27 14:13:53 +01:00
18ec84ff9f Merge remote main with local changes 2025-09-23 14:17:49 +02:00
58b5e6b074 Update 2025-09-23 14:13:24 +02:00
9a704a59f3 Add Wiki Achievement System page 2025-09-23 14:07:07 +02:00
5c01dce9a9 Add Wiki API Referenz page 2025-09-23 14:06:04 +02:00
d60dce87c8 Add Wiki Schnellstart page 2025-09-23 14:05:03 +02:00
ad387a33b4 Add Wiki Home page 2025-09-23 14:04:38 +02:00
5831d1bb91 Viel Push und achivements + AGB 2025-09-16 23:41:34 +02:00
b2fc63e2d0 Diverse änderungen am Push system 2025-09-16 21:00:12 +02:00
69e3985af3 Statistiken und Analytics im dashboard gefixed 2025-09-16 01:40:40 +02:00
35 changed files with 9974 additions and 4282 deletions

4
API.md
View File

@@ -326,8 +326,8 @@ Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
{
"player_id": "uuid",
"location_id": "uuid",
"player_id": "RFIDUID",
"location_id": "Name",
"recorded_time": "01:23.456"
}
```

View File

@@ -89,7 +89,17 @@ class AchievementSystem {
});
});
console.log(`📋 ${result.rows.length} Achievements für Spieler ${playerId} geladen`);
// Count unique achievements and duplicates
const uniqueAchievements = new Set(result.rows.map(row => row.achievement_id));
const totalCount = result.rows.length;
const uniqueCount = uniqueAchievements.size;
const duplicateCount = totalCount - uniqueCount;
if (duplicateCount > 0) {
console.log(`📋 ${totalCount} Achievements für Spieler ${playerId} geladen (${uniqueCount} eindeutige, ${duplicateCount} doppelt)`);
} else {
console.log(`📋 ${totalCount} Achievements für Spieler ${playerId} geladen`);
}
return true;
} catch (error) {
console.error(`❌ Fehler beim Laden der Spieler-Achievements für ${playerId}:`, error);
@@ -375,6 +385,7 @@ class AchievementSystem {
SELECT EXISTS(
SELECT 1 FROM times t
WHERE t.player_id = $1
AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') = CURRENT_DATE
AND EXTRACT(DOW FROM t.created_at AT TIME ZONE 'Europe/Berlin') IN (0, 6)
) as has_played
`;
@@ -384,6 +395,7 @@ class AchievementSystem {
SELECT EXISTS(
SELECT 1 FROM times t
WHERE t.player_id = $1
AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') = CURRENT_DATE
AND EXTRACT(HOUR FROM t.created_at AT TIME ZONE 'Europe/Berlin') < 10
) as has_played
`;
@@ -393,6 +405,7 @@ class AchievementSystem {
SELECT EXISTS(
SELECT 1 FROM times t
WHERE t.player_id = $1
AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') = CURRENT_DATE
AND EXTRACT(HOUR FROM t.created_at AT TIME ZONE 'Europe/Berlin') BETWEEN 14 AND 17
) as has_played
`;
@@ -402,6 +415,7 @@ class AchievementSystem {
SELECT EXISTS(
SELECT 1 FROM times t
WHERE t.player_id = $1
AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') = CURRENT_DATE
AND EXTRACT(HOUR FROM t.created_at AT TIME ZONE 'Europe/Berlin') >= 18
) as has_played
`;
@@ -535,106 +549,138 @@ class AchievementSystem {
}
/**
* Prüft Tageskönig Achievement
* Prüft Tageskönig Achievement pro Standort
*/
async checkDailyBest(playerId, currentDate, newAchievements) {
const achievement = Array.from(this.achievements.values())
.find(a => a.category === 'best_time' && a.condition_type === 'daily_best');
.find(a => a.category === 'time' && a.condition_type === 'best_time_daily_location');
if (!achievement) return;
// Prüfe ob das Achievement heute bereits vergeben wurde
const alreadyEarnedToday = await pool.query(`
SELECT COUNT(*) as count
FROM player_achievements pa
WHERE pa.player_id = $1
AND pa.achievement_id = $2
AND DATE(pa.earned_at AT TIME ZONE 'Europe/Berlin') = CURRENT_DATE
`, [playerId, achievement.id]);
if (parseInt(alreadyEarnedToday.rows[0].count) > 0) return;
// Hole beste Zeit des Spielers heute
const playerResult = await pool.query(`
SELECT MIN(recorded_time) as best_time
// Hole alle Standorte, an denen der Spieler heute gespielt hat
const locationsResult = await pool.query(`
SELECT DISTINCT t.location_id, l.name as location_name
FROM times t
INNER JOIN locations l ON t.location_id = l.id
WHERE t.player_id = $1
AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') = $2
`, [playerId, currentDate]);
// Hole beste Zeit des Tages
const dailyResult = await pool.query(`
SELECT MIN(recorded_time) as best_time
FROM times t
WHERE DATE(t.created_at AT TIME ZONE 'Europe/Berlin') = $1
`, [currentDate]);
for (const location of locationsResult.rows) {
// Prüfe ob das Achievement heute bereits für diesen Standort vergeben wurde
const alreadyEarnedToday = await pool.query(`
SELECT COUNT(*) as count
FROM player_achievements pa
WHERE pa.player_id = $1
AND pa.achievement_id = $2
AND pa.location_id = $3
AND DATE(pa.earned_at AT TIME ZONE 'Europe/Berlin') = CURRENT_DATE
`, [playerId, achievement.id, location.location_id]);
if (parseInt(alreadyEarnedToday.rows[0].count) > 0) continue;
const playerBest = playerResult.rows[0].best_time;
const dailyBest = dailyResult.rows[0].best_time;
// Hole beste Zeit des Spielers heute an diesem Standort
const playerResult = await pool.query(`
SELECT MIN(recorded_time) as best_time
FROM times t
WHERE t.player_id = $1
AND t.location_id = $2
AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') = $3
`, [playerId, location.location_id, currentDate]);
if (playerBest && dailyBest && playerBest === dailyBest) {
await this.awardAchievement(playerId, achievement, 1, newAchievements);
// Hole beste Zeit des Tages an diesem Standort
const dailyResult = await pool.query(`
SELECT MIN(recorded_time) as best_time
FROM times t
WHERE t.location_id = $1
AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') = $2
`, [location.location_id, currentDate]);
const playerBest = playerResult.rows[0].best_time;
const dailyBest = dailyResult.rows[0].best_time;
if (playerBest && dailyBest && playerBest === dailyBest) {
await this.awardAchievement(playerId, achievement, 1, newAchievements, location.location_id);
console.log(`🏆 Tageskönig Achievement vergeben für Standort: ${location.location_name}`);
}
}
}
/**
* Prüft Wochenchampion Achievement
* Prüft Wochenchampion Achievement pro Standort
*/
async checkWeeklyBest(playerId, currentDate, newAchievements) {
const achievement = Array.from(this.achievements.values())
.find(a => a.category === 'best_time' && a.condition_type === 'weekly_best');
.find(a => a.category === 'time' && a.condition_type === 'best_time_weekly_location');
if (!achievement) return;
// Prüfe ob das Achievement diese Woche bereits vergeben wurde
// Berechne Woche
const currentDateObj = new Date(currentDate);
const dayOfWeek = currentDateObj.getDay();
const weekStart = new Date(currentDateObj);
weekStart.setDate(currentDateObj.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1));
const weekStartStr = weekStart.toISOString().split('T')[0];
const alreadyEarnedThisWeek = await pool.query(`
SELECT COUNT(*) as count
FROM player_achievements pa
WHERE pa.player_id = $1
AND pa.achievement_id = $2
AND DATE(pa.earned_at AT TIME ZONE 'Europe/Berlin') >= $3
AND DATE(pa.earned_at AT TIME ZONE 'Europe/Berlin') <= $4
`, [playerId, achievement.id, weekStartStr, currentDate]);
if (parseInt(alreadyEarnedThisWeek.rows[0].count) > 0) return;
// Hole beste Zeit des Spielers diese Woche
const playerResult = await pool.query(`
SELECT MIN(recorded_time) as best_time
// Hole alle Standorte, an denen der Spieler diese Woche gespielt hat
const locationsResult = await pool.query(`
SELECT DISTINCT t.location_id, l.name as location_name
FROM times t
INNER JOIN locations l ON t.location_id = l.id
WHERE t.player_id = $1
AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') >= $2
AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') <= $3
`, [playerId, weekStartStr, currentDate]);
// Hole beste Zeit der Woche
const weeklyResult = await pool.query(`
SELECT MIN(recorded_time) as best_time
FROM times t
WHERE DATE(t.created_at AT TIME ZONE 'Europe/Berlin') >= $1
AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') <= $2
`, [weekStartStr, currentDate]);
for (const location of locationsResult.rows) {
// Prüfe ob das Achievement diese Woche bereits für diesen Standort vergeben wurde
const alreadyEarnedThisWeek = await pool.query(`
SELECT COUNT(*) as count
FROM player_achievements pa
WHERE pa.player_id = $1
AND pa.achievement_id = $2
AND pa.location_id = $3
AND DATE(pa.earned_at AT TIME ZONE 'Europe/Berlin') >= $4
AND DATE(pa.earned_at AT TIME ZONE 'Europe/Berlin') <= $5
`, [playerId, achievement.id, location.location_id, weekStartStr, currentDate]);
if (parseInt(alreadyEarnedThisWeek.rows[0].count) > 0) continue;
const playerBest = playerResult.rows[0].best_time;
const weeklyBest = weeklyResult.rows[0].best_time;
// Hole beste Zeit des Spielers diese Woche an diesem Standort
const playerResult = await pool.query(`
SELECT MIN(recorded_time) as best_time
FROM times t
WHERE t.player_id = $1
AND t.location_id = $2
AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') >= $3
AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') <= $4
`, [playerId, location.location_id, weekStartStr, currentDate]);
if (playerBest && weeklyBest && playerBest === weeklyBest) {
await this.awardAchievement(playerId, achievement, 1, newAchievements);
// Hole beste Zeit der Woche an diesem Standort
const weeklyResult = await pool.query(`
SELECT MIN(recorded_time) as best_time
FROM times t
WHERE t.location_id = $1
AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') >= $2
AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') <= $3
`, [location.location_id, weekStartStr, currentDate]);
const playerBest = playerResult.rows[0].best_time;
const weeklyBest = weeklyResult.rows[0].best_time;
if (playerBest && weeklyBest && playerBest === weeklyBest) {
await this.awardAchievement(playerId, achievement, 1, newAchievements, location.location_id);
console.log(`🏆 Wochenchampion Achievement vergeben für Standort: ${location.location_name}`);
}
}
}
/**
* Prüft Monatsmeister Achievement
* Prüft Monatsmeister Achievement pro Standort
*/
async checkMonthlyBest(playerId, currentDate, newAchievements) {
const achievement = Array.from(this.achievements.values())
.find(a => a.category === 'best_time' && a.condition_type === 'monthly_best');
.find(a => a.category === 'time' && a.condition_type === 'best_time_monthly_location');
if (!achievement) return;
@@ -642,41 +688,57 @@ class AchievementSystem {
const currentDateObj = new Date(currentDate);
const monthStart = new Date(currentDateObj.getFullYear(), currentDateObj.getMonth(), 1);
const monthStartStr = monthStart.toISOString().split('T')[0];
// Prüfe ob das Achievement diesen Monat bereits vergeben wurde
const alreadyEarnedThisMonth = await pool.query(`
SELECT COUNT(*) as count
FROM player_achievements pa
WHERE pa.player_id = $1
AND pa.achievement_id = $2
AND DATE(pa.earned_at AT TIME ZONE 'Europe/Berlin') >= $3
AND DATE(pa.earned_at AT TIME ZONE 'Europe/Berlin') <= $4
`, [playerId, achievement.id, monthStartStr, currentDate]);
if (parseInt(alreadyEarnedThisMonth.rows[0].count) > 0) return;
// Hole beste Zeit des Spielers diesen Monat
const playerResult = await pool.query(`
SELECT MIN(recorded_time) as best_time
// Hole alle Standorte, an denen der Spieler diesen Monat gespielt hat
const locationsResult = await pool.query(`
SELECT DISTINCT t.location_id, l.name as location_name
FROM times t
INNER JOIN locations l ON t.location_id = l.id
WHERE t.player_id = $1
AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') >= $2
AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') <= $3
`, [playerId, monthStartStr, currentDate]);
// Hole beste Zeit des Monats
const monthlyResult = await pool.query(`
SELECT MIN(recorded_time) as best_time
FROM times t
WHERE DATE(t.created_at AT TIME ZONE 'Europe/Berlin') >= $1
AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') <= $2
`, [monthStartStr, currentDate]);
for (const location of locationsResult.rows) {
// Prüfe ob das Achievement diesen Monat bereits für diesen Standort vergeben wurde
const alreadyEarnedThisMonth = await pool.query(`
SELECT COUNT(*) as count
FROM player_achievements pa
WHERE pa.player_id = $1
AND pa.achievement_id = $2
AND pa.location_id = $3
AND DATE(pa.earned_at AT TIME ZONE 'Europe/Berlin') >= $4
AND DATE(pa.earned_at AT TIME ZONE 'Europe/Berlin') <= $5
`, [playerId, achievement.id, location.location_id, monthStartStr, currentDate]);
if (parseInt(alreadyEarnedThisMonth.rows[0].count) > 0) continue;
const playerBest = playerResult.rows[0].best_time;
const monthlyBest = monthlyResult.rows[0].best_time;
// Hole beste Zeit des Spielers diesen Monat an diesem Standort
const playerResult = await pool.query(`
SELECT MIN(recorded_time) as best_time
FROM times t
WHERE t.player_id = $1
AND t.location_id = $2
AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') >= $3
AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') <= $4
`, [playerId, location.location_id, monthStartStr, currentDate]);
if (playerBest && monthlyBest && playerBest === monthlyBest) {
await this.awardAchievement(playerId, achievement, 1, newAchievements);
// Hole beste Zeit des Monats an diesem Standort
const monthlyResult = await pool.query(`
SELECT MIN(recorded_time) as best_time
FROM times t
WHERE t.location_id = $1
AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') >= $2
AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') <= $3
`, [location.location_id, monthStartStr, currentDate]);
const playerBest = playerResult.rows[0].best_time;
const monthlyBest = monthlyResult.rows[0].best_time;
if (playerBest && monthlyBest && playerBest === monthlyBest) {
await this.awardAchievement(playerId, achievement, 1, newAchievements, location.location_id);
console.log(`🏆 Monatsmeister Achievement vergeben für Standort: ${location.location_name}`);
}
}
}
@@ -684,12 +746,12 @@ class AchievementSystem {
* Vergibt ein Achievement an einen Spieler
* Erstellt immer einen neuen Eintrag (keine Updates mehr)
*/
async awardAchievement(playerId, achievement, progress, newAchievements) {
async awardAchievement(playerId, achievement, progress, newAchievements, locationId = null) {
try {
await pool.query(`
INSERT INTO player_achievements (player_id, achievement_id, progress, is_completed, earned_at)
VALUES ($1, $2, $3, true, NOW())
`, [playerId, achievement.id, progress]);
INSERT INTO player_achievements (player_id, achievement_id, progress, is_completed, earned_at, location_id)
VALUES ($1, $2, $3, true, NOW(), $4)
`, [playerId, achievement.id, progress, locationId]);
newAchievements.push({
id: achievement.id,
@@ -697,10 +759,12 @@ class AchievementSystem {
description: achievement.description,
icon: achievement.icon,
points: achievement.points,
progress: progress
progress: progress,
locationId: locationId
});
console.log(`🏆 Achievement vergeben: ${achievement.icon} ${achievement.name} (+${achievement.points} Punkte)`);
const locationText = locationId ? ` (Standort: ${locationId})` : '';
console.log(`🏆 Achievement vergeben: ${achievement.icon} ${achievement.name} (+${achievement.points} Punkte)${locationText}`);
} catch (error) {
console.error(`❌ Fehler beim Vergeben des Achievements ${achievement.name}:`, error);
}

162
lib/push-service.js Normal file
View File

@@ -0,0 +1,162 @@
const webpush = require('web-push');
// VAPID Keys (sollten in Umgebungsvariablen gespeichert werden)
const vapidKeys = {
publicKey: 'BJmNVx0C3XeVxeKGTP9c-Z4HcuZNmdk6QdiLocZgCmb-miCS0ESFO3W2TvJlRhhNAShV63pWA5p36BTVSetyTds',
privateKey: 'HBdRCtmZUAzsWpVjZ2LDaoWliIPHldAb5ExAt8bvDeg'
};
// Configure web-push
webpush.setVapidDetails(
'mailto:admin@reptilfpv.de',
vapidKeys.publicKey,
vapidKeys.privateKey
);
class PushService {
constructor() {
this.subscriptions = new Map(); // Map<playerId, Set<subscription>>
}
// Subscribe user to push notifications
subscribe(userId, subscription) {
if (!this.subscriptions.has(userId)) {
this.subscriptions.set(userId, new Set());
}
this.subscriptions.get(userId).add(subscription);
console.log(`User ${userId} subscribed to push notifications (${this.subscriptions.get(userId).size} total subscriptions)`);
}
// Unsubscribe user from push notifications
unsubscribe(userId) {
this.subscriptions.delete(userId);
console.log(`User ${userId} unsubscribed from push notifications`);
}
// Unsubscribe specific device from push notifications
unsubscribeDevice(userId, endpoint) {
const subscriptions = this.subscriptions.get(userId);
if (!subscriptions) {
console.log(`No subscriptions found for user ${userId}`);
return false;
}
// Find and remove the specific subscription by endpoint
let removed = false;
for (const subscription of subscriptions) {
if (subscription.endpoint === endpoint) {
subscriptions.delete(subscription);
removed = true;
break;
}
}
// If no subscriptions left, remove the user entirely
if (subscriptions.size === 0) {
this.subscriptions.delete(userId);
console.log(`User ${userId} unsubscribed from push notifications (all devices)`);
} else {
console.log(`Device ${endpoint} unsubscribed from push notifications for user ${userId} (${subscriptions.size} devices remaining)`);
}
return removed;
}
// Send push notification to specific user
async sendToUser(userId, payload) {
const subscriptions = this.subscriptions.get(userId);
if (!subscriptions || subscriptions.size === 0) {
console.log(`No subscriptions found for user ${userId}`);
return false;
}
let successCount = 0;
let totalCount = subscriptions.size;
for (const subscription of subscriptions) {
try {
await webpush.sendNotification(subscription, JSON.stringify(payload));
successCount++;
} catch (error) {
console.error(`Error sending push notification to user ${userId}:`, error);
// If subscription is invalid, remove it
if (error.statusCode === 410) {
subscriptions.delete(subscription);
}
}
}
console.log(`Push notification sent to ${successCount}/${totalCount} subscriptions for user ${userId}`);
return successCount > 0;
}
// Send push notification to all subscribed users
async sendToAll(payload) {
const results = [];
for (const [userId, subscription] of this.subscriptions) {
const result = await this.sendToUser(userId, payload);
results.push({ userId, success: result });
}
return results;
}
// Send achievement notification
async sendAchievementNotification(userId, achievementName) {
const payload = {
title: '🏆 Neues Achievement!',
body: `Du hast "${achievementName}" erreicht!`,
icon: '/pictures/icon-192.png',
badge: '/pictures/icon-192.png',
data: {
type: 'achievement',
achievement: achievementName,
timestamp: Date.now()
},
actions: [
{
action: 'view',
title: 'Dashboard öffnen'
}
]
};
return await this.sendToUser(userId, payload);
}
// Send best time notification
async sendBestTimeNotification(userId, timeType, locationName) {
const payload = {
title: `🏁 ${timeType} Bestzeit!`,
body: `Du hast die beste Zeit in ${locationName} erreicht!`,
icon: '/pictures/icon-192.png',
badge: '/pictures/icon-192.png',
data: {
type: 'best_time',
timeType: timeType,
location: locationName,
timestamp: Date.now()
},
actions: [
{
action: 'view',
title: 'Dashboard öffnen'
}
]
};
return await this.sendToUser(userId, payload);
}
// Get subscription count
getSubscriptionCount() {
return this.subscriptions.size;
}
// Get all user IDs with subscriptions
getSubscribedUsers() {
return Array.from(this.subscriptions.keys());
}
}
module.exports = new PushService();

4781
package-lock.json generated

File diff suppressed because it is too large Load Diff

187
pentest/enumerate.py Normal file
View File

@@ -0,0 +1,187 @@
import requests
import uuid
import time
import json
from datetime import datetime
def enumerate_supabase_users():
base_url = "http://localhost:3000/api/v1/public/user-player"
found_users = []
total_requests = 0
print("🔍 STARTE USER ENUMERATION ÜBER SUPABASE USER IDS")
print("=" * 60)
print(f"Zeit: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print(f"Target: {base_url}")
print("=" * 60)
# Teste verschiedene UUID-Patterns
test_uuids = [
str(uuid.uuid4()) for _ in range(1000) # Zufällige UUIDs
]
print(f"📊 Teste {len(test_uuids)} UUIDs...")
print("-" * 60)
for i, uuid_str in enumerate(test_uuids, 1):
try:
response = requests.get(f"{base_url}/{uuid_str}", timeout=5)
total_requests += 1
if response.status_code == 200:
user_data = response.json()
if user_data.get("success"):
found_users.append(user_data["data"])
user = user_data["data"]
print(f"✅ [{i:4d}] USER GEFUNDEN!")
print(f" UUID: {uuid_str}")
print(f" Name: {user['firstname']} {user['lastname']}")
print(f" ID: {user['id']}")
print(f" RFID: {user['rfiduid']}")
print(f" Geburtsdatum: {user['birthdate']}")
print(f" Leaderboard: {user['show_in_leaderboard']}")
print("-" * 60)
else:
if i % 100 == 0: # Fortschritt alle 100 Requests
print(f"⏳ [{i:4d}] Kein User gefunden (Fortschritt: {i}/{len(test_uuids)})")
else:
if i % 100 == 0:
print(f"❌ [{i:4d}] HTTP {response.status_code} (Fortschritt: {i}/{len(test_uuids)})")
except requests.exceptions.RequestException as e:
print(f"🔥 [{i:4d}] Fehler bei UUID {uuid_str}: {e}")
continue
print("\n" + "=" * 60)
print("📈 ENUMERATION ABGESCHLOSSEN")
print("=" * 60)
print(f"Total Requests: {total_requests}")
print(f"Gefundene Users: {len(found_users)}")
print(f"Erfolgsrate: {(len(found_users)/total_requests*100):.2f}%" if total_requests > 0 else "0%")
if found_users:
print("\n🎯 GEFUNDENE USERS:")
print("-" * 60)
for i, user in enumerate(found_users, 1):
print(f"{i}. {user['firstname']} {user['lastname']}")
print(f" ID: {user['id']} | RFID: {user['rfiduid']} | Geburtstag: {user['birthdate']}")
print("-" * 60)
# Speichere Ergebnisse in Datei
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"enumerated_users_{timestamp}.json"
with open(filename, 'w', encoding='utf-8') as f:
json.dump(found_users, f, indent=2, ensure_ascii=False)
print(f"💾 Ergebnisse gespeichert in: {filename}")
else:
print("\n❌ Keine Users gefunden")
print(f"\n⏰ Abgeschlossen um: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
return found_users
def enumerate_rfid_uids(api_key, max_attempts=100):
"""RFID UID Enumeration (benötigt gültigen API-Key)"""
base_url = "http://localhost:3000/api/v1/private/users/find"
found_rfids = []
print("\n🔍 STARTE RFID UID ENUMERATION")
print("=" * 60)
print(f"Zeit: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print(f"Target: {base_url}")
print(f"API-Key: {api_key[:10]}...")
print("=" * 60)
# Generiere RFID UIDs zum Testen
for i in range(1, max_attempts + 1):
# Generiere RFID im Format AA:BB:CC:XX
rfid_uid = f"AA:BB:CC:{i:02X}"
try:
response = requests.post(
base_url,
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
},
json={"uid": rfid_uid},
timeout=5
)
if response.status_code == 200:
data = response.json()
if data.get("success") and data.get("data", {}).get("exists"):
found_rfids.append(data["data"])
user = data["data"]
print(f"✅ [{i:3d}] RFID GEFUNDEN!")
print(f" RFID: {rfid_uid}")
print(f" Name: {user['firstname']} {user['lastname']}")
print(f" Alter: {user['alter']}")
print("-" * 60)
else:
if i % 20 == 0: # Fortschritt alle 20 Requests
print(f"⏳ [{i:3d}] Kein User für RFID {rfid_uid}")
else:
print(f"❌ [{i:3d}] HTTP {response.status_code} für RFID {rfid_uid}")
except requests.exceptions.RequestException as e:
print(f"🔥 [{i:3d}] Fehler bei RFID {rfid_uid}: {e}")
continue
print("\n📈 RFID ENUMERATION ABGESCHLOSSEN")
print(f"Gefundene RFIDs: {len(found_rfids)}")
return found_rfids
def test_admin_login():
"""Teste Admin Login Enumeration"""
base_url = "http://localhost:3000/api/v1/public/login"
# Häufige Admin-Usernamen
admin_usernames = [
"admin", "administrator", "root", "user", "test", "demo",
"admin1", "admin2", "superuser", "manager", "operator"
]
print("\n🔍 TESTE ADMIN LOGIN ENUMERATION")
print("=" * 60)
for username in admin_usernames:
try:
start_time = time.time()
response = requests.post(
base_url,
json={"username": username, "password": "wrongpassword"},
timeout=5
)
end_time = time.time()
response_time = (end_time - start_time) * 1000 # in ms
print(f"👤 {username:12} | Status: {response.status_code:3d} | Zeit: {response_time:6.1f}ms")
if response.status_code == 200:
print(f" ⚠️ MÖGLICHERWEISE GÜLTIGER USERNAME!")
except Exception as e:
print(f"🔥 Fehler bei {username}: {e}")
# Führe Enumeration aus
if __name__ == "__main__":
print("🚨 NINJA SERVER SECURITY AUDIT - USER ENUMERATION")
print("⚠️ WARNUNG: Nur für autorisierte Sicherheitstests!")
print()
# 1. Supabase User ID Enumeration
found_users = enumerate_supabase_users()
# 2. Admin Login Test
test_admin_login()
# 3. RFID Enumeration (nur mit gültigem API-Key)
api_key = input("\n🔑 API-Key für RFID Enumeration eingeben (oder Enter zum Überspringen): ").strip()
if api_key:
enumerate_rfid_uids(api_key, 50) # Teste nur 50 RFIDs
else:
print("⏭️ RFID Enumeration übersprungen")
print("\n🏁 AUDIT ABGESCHLOSSEN")

View File

@@ -0,0 +1,312 @@
import requests
import time
import json
from datetime import datetime
import statistics
def test_admin_login_timing():
"""Detaillierte Timing-Analyse für Admin Login"""
base_url = "http://localhost:3000/api/v1/public/login"
# Erweiterte Liste von Admin-Usernamen
admin_usernames = [
"admin", "administrator", "root", "user", "test", "demo",
"admin1", "admin2", "superuser", "manager", "operator",
"ninja", "parkour", "system", "api", "service",
"backup", "support", "helpdesk", "it", "tech"
]
print("🔍 DETAILLIERTE ADMIN LOGIN TIMING-ANALYSE")
print("=" * 70)
print(f"Zeit: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print(f"Target: {base_url}")
print("=" * 70)
results = []
# Teste jeden Username mehrfach für bessere Statistik
for username in admin_usernames:
times = []
print(f"\n👤 Testing: {username}")
print("-" * 50)
for attempt in range(5): # 5 Versuche pro Username
try:
start_time = time.time()
response = requests.post(
base_url,
json={"username": username, "password": "wrongpassword123"},
timeout=10
)
end_time = time.time()
response_time = (end_time - start_time) * 1000 # in ms
times.append(response_time)
print(f" Attempt {attempt+1}: {response_time:6.1f}ms | Status: {response.status_code}")
# Kleine Pause zwischen Requests
time.sleep(0.1)
except Exception as e:
print(f" Attempt {attempt+1}: ERROR - {e}")
continue
if times:
avg_time = statistics.mean(times)
std_dev = statistics.stdev(times) if len(times) > 1 else 0
min_time = min(times)
max_time = max(times)
results.append({
'username': username,
'avg_time': avg_time,
'std_dev': std_dev,
'min_time': min_time,
'max_time': max_time,
'times': times,
'suspicious': avg_time > 50 # Verdächtig wenn > 50ms
})
print(f" 📊 Stats: Avg={avg_time:.1f}ms, Std={std_dev:.1f}ms, Range={min_time:.1f}-{max_time:.1f}ms")
if avg_time > 50:
print(f" ⚠️ SUSPEKT: Deutlich längere Response-Zeit!")
# Sortiere nach durchschnittlicher Response-Zeit
results.sort(key=lambda x: x['avg_time'], reverse=True)
print("\n" + "=" * 70)
print("📈 TIMING-ANALYSE ERGEBNISSE")
print("=" * 70)
print(f"{'Username':<15} {'Avg(ms)':<8} {'Std(ms)':<8} {'Min(ms)':<8} {'Max(ms)':<8} {'Status'}")
print("-" * 70)
for result in results:
status = "⚠️ SUSPEKT" if result['suspicious'] else "✅ Normal"
print(f"{result['username']:<15} {result['avg_time']:<8.1f} {result['std_dev']:<8.1f} "
f"{result['min_time']:<8.1f} {result['max_time']:<8.1f} {status}")
# Speichere detaillierte Ergebnisse
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"timing_analysis_{timestamp}.json"
with open(filename, 'w', encoding='utf-8') as f:
json.dump(results, f, indent=2, ensure_ascii=False)
print(f"\n💾 Detaillierte Ergebnisse gespeichert in: {filename}")
return results
def test_rfid_creation_enumeration():
"""Teste RFID Enumeration über Spieler-Erstellung"""
base_url = "http://localhost:3000/api/v1/public/players/create-with-rfid"
print("\n🔍 RFID ENUMERATION ÜBER SPIELER-ERSTELLUNG")
print("=" * 70)
print(f"Zeit: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print(f"Target: {base_url}")
print("=" * 70)
found_rfids = []
# Teste verschiedene RFID-Patterns
test_patterns = [
"AA:BB:CC:DD", "AA:BB:CC:DE", "AA:BB:CC:DF",
"11:22:33:44", "11:22:33:45", "11:22:33:46",
"FF:FF:FF:FF", "00:00:00:00", "12:34:56:78",
"AB:CD:EF:12", "DE:AD:BE:EF", "CA:FE:BA:BE"
]
for i, rfid in enumerate(test_patterns, 1):
try:
payload = {
"rfiduid": rfid,
"firstname": "Test",
"lastname": "User",
"birthdate": "1990-01-01"
}
response = requests.post(base_url, json=payload, timeout=5)
print(f"[{i:2d}] RFID: {rfid:<12} | Status: {response.status_code:3d}", end="")
if response.status_code == 400:
try:
data = response.json()
if "existiert bereits" in data.get("message", ""):
print(" | ✅ EXISTIERT!")
if "existingPlayer" in data.get("details", {}):
existing = data["details"]["existingPlayer"]
found_rfids.append({
"rfid": rfid,
"existing_player": existing
})
print(f" → Name: {existing.get('firstname')} {existing.get('lastname')}")
else:
print(" | ❌ Anderer Fehler")
except:
print(" | ❌ JSON Parse Error")
else:
print(" | ⚠️ Unexpected Status")
except Exception as e:
print(f"[{i:2d}] RFID: {rfid:<12} | ERROR: {e}")
print(f"\n📊 RFID Enumeration abgeschlossen: {len(found_rfids)} gefunden")
return found_rfids
def test_leaderboard_data_leak():
"""Teste Leaderboard auf sensible Daten"""
base_url = "http://localhost:3000/api/v1/public/times-with-details"
print("\n🔍 LEADERBOARD DATENLEAK-TEST")
print("=" * 70)
print(f"Zeit: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print(f"Target: {base_url}")
print("=" * 70)
try:
response = requests.get(base_url, timeout=10)
print(f"Status: {response.status_code}")
if response.status_code == 200:
data = response.json()
if isinstance(data, list) and len(data) > 0:
print(f"✅ Leaderboard-Daten gefunden: {len(data)} Einträge")
# Analysiere erste paar Einträge
for i, entry in enumerate(data[:3]):
print(f"\n📋 Eintrag {i+1}:")
if 'player' in entry:
player = entry['player']
print(f" Name: {player.get('firstname')} {player.get('lastname')}")
print(f" RFID: {player.get('rfiduid')}")
print(f" ID: {player.get('id')}")
if 'location' in entry:
location = entry['location']
print(f" Location: {location.get('name')}")
print(f" Koordinaten: {location.get('latitude')}, {location.get('longitude')}")
if 'recorded_time_seconds' in entry:
print(f" Zeit: {entry['recorded_time_seconds']} Sekunden")
# Speichere alle Daten
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"leaderboard_data_{timestamp}.json"
with open(filename, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2, ensure_ascii=False)
print(f"\n💾 Leaderboard-Daten gespeichert in: {filename}")
return data
else:
print("❌ Keine Leaderboard-Daten gefunden")
else:
print(f"❌ Fehler: HTTP {response.status_code}")
except Exception as e:
print(f"🔥 Fehler beim Abrufen der Leaderboard-Daten: {e}")
return None
def test_error_message_analysis():
"""Analysiere Error Messages auf Information Leakage"""
base_url = "http://localhost:3000/api/v1/public/user-player"
print("\n🔍 ERROR MESSAGE ANALYSE")
print("=" * 70)
test_uuids = [
"00000000-0000-0000-0000-000000000000", # Null UUID
"invalid-uuid-format", # Ungültiges Format
"12345678-1234-1234-1234-123456789012", # Gültiges Format, aber wahrscheinlich nicht existent
"../../../etc/passwd", # Path Traversal
"<script>alert('xss')</script>", # XSS Test
"'; DROP TABLE players; --" # SQL Injection Test
]
error_responses = {}
for i, test_input in enumerate(test_uuids, 1):
try:
response = requests.get(f"{base_url}/{test_input}", timeout=5)
status_code = response.status_code
print(f"[{i}] Input: {test_input:<30} | Status: {status_code}")
if status_code not in error_responses:
error_responses[status_code] = []
try:
json_data = response.json()
error_responses[status_code].append({
'input': test_input,
'response': json_data
})
except:
error_responses[status_code].append({
'input': test_input,
'response': response.text[:200] # Erste 200 Zeichen
})
except Exception as e:
print(f"[{i}] Input: {test_input:<30} | ERROR: {e}")
# Analysiere verschiedene Error-Messages
print(f"\n📊 Error-Message Analyse:")
print("-" * 50)
for status_code, responses in error_responses.items():
print(f"Status {status_code}: {len(responses)} Responses")
# Prüfe auf unterschiedliche Error-Messages
unique_messages = set()
for resp in responses:
if isinstance(resp['response'], dict):
message = resp['response'].get('message', 'No message')
else:
message = str(resp['response'])[:100]
unique_messages.add(message)
print(f" Unique messages: {len(unique_messages)}")
for msg in list(unique_messages)[:3]: # Zeige erste 3
print(f" - {msg}")
return error_responses
if __name__ == "__main__":
print("🚨 NINJA SERVER - REALISTISCHE SICHERHEITSTESTS")
print("⚠️ WARNUNG: Nur für autorisierte Sicherheitstests!")
print()
# 1. Admin Login Timing Analysis
timing_results = test_admin_login_timing()
# 2. RFID Enumeration über Spieler-Erstellung
rfid_results = test_rfid_creation_enumeration()
# 3. Leaderboard Datenleak-Test
leaderboard_data = test_leaderboard_data_leak()
# 4. Error Message Analysis
error_analysis = test_error_message_analysis()
print("\n" + "=" * 70)
print("🏁 REALISTISCHE SICHERHEITSTESTS ABGESCHLOSSEN")
print("=" * 70)
# Zusammenfassung
suspicious_users = [r for r in timing_results if r['suspicious']]
print(f"🔍 Timing-Suspicious Users: {len(suspicious_users)}")
print(f"🔍 Gefundene RFIDs: {len(rfid_results)}")
print(f"🔍 Leaderboard-Einträge: {len(leaderboard_data) if leaderboard_data else 0}")
if suspicious_users:
print(f"\n⚠️ SUSPEKTE USERNAMES (Timing):")
for user in suspicious_users:
print(f" - {user['username']}: {user['avg_time']:.1f}ms")
print(f"\n⏰ Abgeschlossen um: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")

245
public/agb.html Normal file
View File

@@ -0,0 +1,245 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Allgemeine Geschäftsbedingungen | NinjaCross</title>
<link rel="stylesheet" href="css/dashboard.css">
<style>
.agb-container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
background: #1e293b;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
margin-top: 20px;
color: #e2e8f0;
}
.agb-header {
text-align: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 2px solid #334155;
}
.agb-header h1 {
color: #00d4ff;
margin-bottom: 10px;
}
.agb-header .subtitle {
color: #94a3b8;
font-size: 16px;
}
.agb-content {
line-height: 1.6;
color: #e2e8f0;
}
.agb-section {
margin-bottom: 25px;
}
.agb-section h2 {
color: #00d4ff;
font-size: 20px;
margin-bottom: 15px;
padding-bottom: 5px;
border-bottom: 1px solid #334155;
}
.agb-section h3 {
color: #e2e8f0;
font-size: 16px;
margin-bottom: 10px;
margin-top: 20px;
}
.agb-section p {
margin-bottom: 12px;
}
.agb-section ul {
margin-left: 20px;
margin-bottom: 15px;
}
.agb-section li {
margin-bottom: 8px;
}
.highlight-box {
background: #0f172a;
border-left: 4px solid #00d4ff;
padding: 15px;
margin: 20px 0;
border-radius: 0 5px 5px 0;
}
.warning-box {
background: #451a03;
border-left: 4px solid #fbbf24;
padding: 15px;
margin: 20px 0;
border-radius: 0 5px 5px 0;
}
.back-button {
display: inline-block;
background: #00d4ff;
color: #0f172a;
padding: 12px 24px;
text-decoration: none;
border-radius: 5px;
margin-top: 30px;
transition: background-color 0.3s;
font-weight: bold;
}
.back-button:hover {
background: #0891b2;
}
.last-updated {
text-align: center;
color: #94a3b8;
font-style: italic;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #334155;
}
</style>
</head>
<body style="background: #0f172a; min-height: 100vh; padding: 20px;">
<div class="agb-container">
<div class="agb-header">
<h1>Allgemeine Geschäftsbedingungen</h1>
<p class="subtitle">NinjaCross Parkour System</p>
</div>
<div class="agb-content">
<div class="highlight-box">
<strong>Wichtig:</strong> Durch die Nutzung des NinjaCross Systems stimmen Sie zu, dass Ihre Daten
(Name, Zeiten, Standorte) im öffentlichen Leaderboard angezeigt werden können.
</div>
<div class="agb-section">
<h2>1. Geltungsbereich</h2>
<p>Diese Allgemeinen Geschäftsbedingungen (AGB) gelten für die Nutzung des NinjaCross Parkour Systems
im Schwimmbad. Mit der Registrierung und Nutzung des Systems erkennen Sie diese AGB als verbindlich an.</p>
</div>
<div class="agb-section">
<h2>2. Datenverarbeitung und Datenschutz</h2>
<h3>2.1 Erhebung von Daten</h3>
<p>Wir erheben folgende personenbezogene Daten:</p>
<ul>
<li>Vor- und Nachname</li>
<li>Geburtsdatum (zur Altersberechnung)</li>
<li>RFID-Kartennummer</li>
<li>Laufzeiten und Standortdaten</li>
<li>Zeitstempel der Aktivitäten</li>
</ul>
<h3>2.2 Verwendung der Daten</h3>
<p>Ihre Daten werden für folgende Zwecke verwendet:</p>
<ul>
<li><strong>Leaderboard-Anzeige:</strong> Name, Zeiten und Standorte werden im öffentlichen Leaderboard angezeigt</li>
<li><strong>Leistungsauswertung:</strong> Erfassung und Bewertung Ihrer Parkour-Zeiten</li>
<li><strong>System-Funktionalität:</strong> Zuordnung von Zeiten zu Ihrem Profil über RFID-Karten</li>
<li><strong>Statistiken:</strong> Anonymisierte Auswertungen für Systemverbesserungen</li>
</ul>
<div class="warning-box">
<strong>Wichtiger Hinweis:</strong> Durch die Annahme dieser AGB stimmen Sie ausdrücklich zu,
dass Ihr Name und Ihre Laufzeiten im öffentlichen Leaderboard sichtbar sind.
</div>
</div>
<div class="agb-section">
<h2>3. Leaderboard und Öffentlichkeit</h2>
<p>Das NinjaCross System verfügt über ein öffentlich zugängliches Leaderboard, das folgende Informationen anzeigt:</p>
<ul>
<li>Vollständiger Name der Teilnehmer</li>
<li>Erreichte Laufzeiten</li>
<li>Standort der Aktivität</li>
<li>Datum und Uhrzeit der Aktivität</li>
</ul>
<p><strong>Durch die Nutzung des Systems erklären Sie sich damit einverstanden, dass diese Daten öffentlich angezeigt werden.</strong></p>
</div>
<div class="agb-section">
<h2>4. Ihre Rechte</h2>
<h3>4.1 Recht auf Auskunft</h3>
<p>Sie haben das Recht, Auskunft über die zu Ihrer Person gespeicherten Daten zu verlangen.</p>
<h3>4.2 Recht auf Löschung</h3>
<p>Sie können jederzeit die Löschung Ihrer Daten und Ihres Profils beantragen.</p>
<h3>4.3 Recht auf Widerspruch</h3>
<p>Sie können der Verarbeitung Ihrer Daten für das Leaderboard widersprechen.
In diesem Fall werden Ihre Daten aus dem öffentlichen Leaderboard entfernt,
aber weiterhin für die Systemfunktionalität verwendet.</p>
</div>
<div class="agb-section">
<h2>5. Haftung und Verantwortung</h2>
<p>Die Teilnahme am NinjaCross System erfolgt auf eigene Gefahr. Wir haften nicht für:</p>
<ul>
<li>Verletzungen während der Nutzung der Parkour-Anlage</li>
<li>Verlust oder Diebstahl der RFID-Karte</li>
<li>Technische Ausfälle des Systems</li>
</ul>
</div>
<div class="agb-section">
<h2>6. Systemregeln</h2>
<p>Bei der Nutzung des Systems sind folgende Regeln zu beachten:</p>
<ul>
<li>Keine Manipulation der Zeiterfassung</li>
<li>Respektvoller Umgang mit anderen Teilnehmern</li>
<li>Beachtung der Sicherheitshinweise der Anlage</li>
<li>Keine Verwendung falscher Identitäten</li>
</ul>
</div>
<div class="agb-section">
<h2>7. Änderungen der AGB</h2>
<p>Wir behalten uns vor, diese AGB zu ändern. Wesentliche Änderungen werden Ihnen
mitgeteilt und erfordern Ihre erneute Zustimmung.</p>
</div>
<div class="agb-section">
<h2>8. Kontakt</h2>
<p>Bei Fragen zu diesen AGB oder zum Datenschutz wenden Sie sich an:</p>
<p>
<strong>NinjaCross Team</strong><br>
Schwimmbad Ulm<br>
E-Mail: info@ninjacross.de<br>
Telefon: 0731-123456
</p>
</div>
</div>
<div style="text-align: center;">
<a href="javascript:history.back()" class="back-button">Zurück</a>
</div>
<div class="last-updated">
<p>Stand: September 2024</p>
<p>Diese AGB sind Teil der Registrierung und gelten ab dem Zeitpunkt der Zustimmung.</p>
</div>
</div>
</body>
</html>

View File

@@ -24,12 +24,28 @@ body {
min-height: 100vh;
}
/* Sticky Header Container */
.sticky-header {
position: sticky;
top: 0;
z-index: 1000;
margin-bottom: 2rem;
padding: 1rem 2rem;
background: rgba(15, 23, 42, 0.95);
backdrop-filter: blur(20px);
border: 1px solid #1e293b;
border-radius: 1rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
/* Language Selector */
.language-selector {
position: fixed;
top: 2rem;
left: 2rem;
z-index: 1000;
display: inline-block;
}
.language-selector select {
@@ -64,6 +80,7 @@ body {
.header-section {
text-align: center;
margin-bottom: 3rem;
margin-top: 1rem;
}
.main-title {
@@ -84,13 +101,10 @@ body {
}
.nav-buttons {
position: fixed;
top: 2rem;
right: 2rem;
display: flex;
gap: 1rem;
align-items: center;
z-index: 1000;
flex-wrap: wrap;
}
.btn {
@@ -137,6 +151,52 @@ body {
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.4);
}
/* Push notification button */
.btn-push {
background: #10b981;
color: white;
position: relative;
}
.btn-push:hover {
background: #059669;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4);
}
.btn-push.active {
background: #ef4444;
}
.btn-push.active:hover {
background: #dc2626;
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4);
}
.btn-push::after {
content: '';
position: absolute;
top: -2px;
right: -2px;
width: 8px;
height: 8px;
background: #fbbf24;
border-radius: 50%;
opacity: 0;
transition: opacity 0.3s ease;
}
.btn-push.active::after {
opacity: 1;
animation: pulse 2s infinite;
}
@keyframes pulse {
0% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.2); opacity: 0.7; }
100% { transform: scale(1); opacity: 1; }
}
/* Logout button */
.btn-logout {
background: #dc2626;
@@ -809,6 +869,19 @@ body {
max-width: 100%;
}
.sticky-header {
flex-direction: column;
gap: 1rem;
padding: 1rem;
margin-bottom: 1rem;
}
.language-selector {
order: -1;
text-align: center;
width: 100%;
}
.main-title {
font-size: 2.5rem;
margin-bottom: 0.5rem;
@@ -819,18 +892,9 @@ body {
}
.nav-buttons {
position: fixed;
top: 1rem;
right: 1rem;
left: 1rem;
flex-direction: column;
gap: 0.5rem;
background: rgba(15, 23, 42, 0.95);
backdrop-filter: blur(20px);
border: 1px solid #1e293b;
border-radius: 1rem;
padding: 1rem;
z-index: 1000;
width: 100%;
}
.user-info {
@@ -954,6 +1018,11 @@ body {
padding: 0.5rem;
}
.sticky-header {
padding: 0.75rem;
margin-bottom: 0.75rem;
}
.main-title {
font-size: 2rem;
}
@@ -963,10 +1032,11 @@ body {
}
.nav-buttons {
top: 0.5rem;
top: 0;
right: 0.5rem;
left: 0.5rem;
padding: 0.75rem;
margin-bottom: 0.75rem;
}
.card {
@@ -1007,10 +1077,15 @@ body {
/* Landscape orientation on mobile */
@media (max-width: 768px) and (orientation: landscape) {
.sticky-header {
flex-direction: row;
justify-content: space-between;
}
.nav-buttons {
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
justify-content: flex-end;
}
.user-info {
@@ -1078,8 +1153,7 @@ body {
/* Better spacing for mobile */
.header-section {
margin-bottom: 2rem;
margin-top: 5rem;
/* Account for fixed nav */
margin-top: 1rem;
}
/* Improve modal usability on mobile */
@@ -1865,4 +1939,43 @@ input:checked+.toggle-slider:before {
.settings-actions .btn {
width: 100%;
}
}
/* PWA Button Styles */
.btn-pwa {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(240, 147, 251, 0.3);
margin-left: 0.5rem;
}
.btn-pwa:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(240, 147, 251, 0.4);
}
.btn-pwa:active {
transform: translateY(0);
}
/* iOS PWA Hint Styles */
#ios-pwa-hint {
animation: slideDown 0.3s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateX(-50%) translateY(-20px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}

View File

@@ -24,29 +24,62 @@ body {
min-height: 100vh;
}
/* Sticky Header Container */
.sticky-header {
position: -webkit-sticky; /* Safari support */
position: sticky;
top: 0;
z-index: 9999; /* Much higher z-index */
margin-bottom: 2rem;
padding: 1rem 2rem;
background: rgba(15, 23, 42, 0.98); /* More opaque */
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px); /* Safari support */
border: 1px solid #1e293b;
border-radius: 1rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
pointer-events: auto;
/* iOS specific fixes */
-webkit-transform: translateZ(0);
transform: translateZ(0);
will-change: transform;
/* Mobile specific fixes */
min-height: 60px; /* Ensure minimum height */
width: 100%;
box-sizing: border-box;
}
/* Language Selector */
.language-selector {
position: fixed;
top: 2rem;
left: 2rem;
z-index: 1000;
display: inline-block;
}
/* Mobile Navigation Buttons */
.mobile-nav-buttons {
position: fixed;
top: 2rem;
right: 2rem;
display: flex;
gap: 1rem;
align-items: center;
z-index: 1000;
flex-wrap: wrap;
}
.mobile-nav-buttons {
display: flex;
gap: 1rem;
align-items: center;
flex-wrap: wrap;
position: relative;
z-index: 10000; /* Even higher z-index */
}
.mobile-nav-buttons .admin-login-btn,
.mobile-nav-buttons .dashboard-btn,
.mobile-nav-buttons .logout-btn {
padding: 0.75rem 1.5rem;
padding: 1rem 2rem; /* Larger padding */
border-radius: 0.5rem;
text-decoration: none;
font-weight: 600;
@@ -55,8 +88,19 @@ body {
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
font-size: 0.9rem;
font-size: 1rem; /* Larger font */
position: relative;
z-index: 10001; /* Highest z-index */
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
user-select: none;
/* Mobile specific */
min-height: 50px; /* Larger touch target */
min-width: 120px; /* Larger touch target */
flex-shrink: 0; /* Don't shrink */
white-space: nowrap; /* Don't wrap text */
}
.mobile-nav-buttons .admin-login-btn {
@@ -612,7 +656,7 @@ body {
padding: 1rem 2rem;
border-radius: 1rem;
box-shadow: 0 10px 25px rgba(0, 212, 255, 0.3);
z-index: 2000;
z-index: 100000; /* Much higher than sticky header (99999) */
opacity: 0;
transform: translateX(-50%) translateY(-20px);
transition: all 0.3s ease;
@@ -723,6 +767,25 @@ body {
max-width: 100%;
}
.sticky-header {
flex-direction: column;
gap: 1rem;
padding: 1rem;
margin-bottom: 1rem;
}
.language-selector {
order: -1;
text-align: center;
width: 100%;
}
.mobile-nav-buttons {
flex-direction: column;
gap: 0.5rem;
width: 100%;
}
.main-title {
font-size: 2.5rem;
margin-bottom: 0.5rem;
@@ -734,7 +797,7 @@ body {
.header-section {
margin-bottom: 2rem;
margin-top: 2rem;
margin-top: 1rem;
}
.dashboard-grid {
@@ -892,12 +955,13 @@ body {
.notification-bubble {
top: 1rem;
top: 5rem; /* Below the sticky header */
left: 1rem;
right: 1rem;
transform: none;
max-width: none;
padding: 1rem;
z-index: 100000; /* Ensure it's above sticky header */
}
.notification-bubble.show {
@@ -915,6 +979,11 @@ body {
padding: 0.5rem;
}
.sticky-header {
padding: 0.75rem;
margin-bottom: 0.75rem;
}
.main-title {
font-size: 2rem;
}
@@ -1000,8 +1069,19 @@ body {
/* Landscape orientation on mobile */
@media (max-width: 768px) and (orientation: landscape) {
.sticky-header {
flex-direction: row;
justify-content: space-between;
}
.mobile-nav-buttons {
flex-direction: row;
flex-wrap: wrap;
justify-content: flex-end;
}
.header-section {
margin-top: 4rem;
margin-top: 1rem;
margin-bottom: 1.5rem;
}
@@ -1041,6 +1121,38 @@ body {
}
}
/* Mobile-specific sticky header fixes */
@media (max-width: 768px) {
.sticky-header {
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
width: 100% !important;
z-index: 99999 !important;
margin-bottom: 0 !important;
border-radius: 0 !important;
padding: 1rem !important;
}
.main-container {
padding-top: 80px !important;
}
.mobile-nav-buttons {
gap: 0.5rem;
}
.mobile-nav-buttons .admin-login-btn,
.mobile-nav-buttons .dashboard-btn,
.mobile-nav-buttons .logout-btn {
padding: 0.75rem 1.5rem;
font-size: 0.9rem;
min-height: 44px;
min-width: 100px;
}
}
/* Touch-friendly improvements for mobile */
@media (max-width: 768px) {
.time-tab {
@@ -1148,4 +1260,30 @@ body {
justify-content: center;
gap: 1.5rem;
}
}
/* iOS Sticky Header Fallback */
@supports not (position: sticky) {
.sticky-header {
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
width: 100% !important;
z-index: 1000 !important;
}
body {
padding-top: 80px !important;
}
}
/* iOS Safari specific fixes */
@supports (-webkit-touch-callout: none) {
.sticky-header {
position: -webkit-sticky;
position: sticky;
-webkit-transform: translateZ(0);
transform: translateZ(0);
}
}

View File

@@ -29,63 +29,345 @@
});
}
// Request notification permission on page load
if ('Notification' in window) {
if (Notification.permission === 'default') {
Notification.requestPermission().then(function(permission) {
if (permission === 'granted') {
console.log('✅ Notification permission granted');
// Subscribe to push notifications
subscribeToPush();
} else {
console.log('❌ Notification permission denied');
}
});
}
}
// Don't automatically request notification permission
// User must click the button to enable push notifications
// Convert VAPID key from base64url to Uint8Array
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/\-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
// Convert ArrayBuffer to Base64 string
function arrayBufferToBase64(buffer) {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return window.btoa(binary);
}
// Push notification state
let pushSubscription = null;
let pushEnabled = false;
// Subscribe to push notifications
async function subscribeToPush() {
try {
console.log('🔔 Starting push subscription...');
const registration = await navigator.serviceWorker.ready;
const vapidPublicKey = 'BJmNVx0C3XeVxeKGTP9c-Z4HcuZNmdk6QdiLocZgCmb-miCS0ESFO3W2TvJlRhhNAShV63pWA5p36BTVSetyTds';
const applicationServerKey = urlBase64ToUint8Array(vapidPublicKey);
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: 'BJmNVx0C3XeVxeKGTP9c-Z4HcuZNmdk6QdiLocZgCmb-miCS0ESFO3W2TvJlRhhNAShV63pWA5p36BTVSetyTds'
applicationServerKey: applicationServerKey
});
// Send subscription to server
await fetch('/api/v1/public/subscribe', {
pushSubscription = subscription;
// Player ID wird automatisch vom Server aus der Session geholt
console.log(`📱 Subscribing to push notifications...`);
// Convert ArrayBuffer keys to Base64 strings
const p256dhKey = subscription.getKey('p256dh');
const authKey = subscription.getKey('auth');
// Convert ArrayBuffer to Base64 URL-safe string
const p256dhString = arrayBufferToBase64(p256dhKey);
const authString = arrayBufferToBase64(authKey);
console.log('📱 Converted keys to Base64 strings');
console.log('📱 p256dh length:', p256dhString.length);
console.log('📱 auth length:', authString.length);
// Get current Supabase user ID
const { data: { session } } = await supabase.auth.getSession();
const supabaseUserId = session?.user?.id || null;
// Send subscription to server with player ID
const response = await fetch('/api/v1/public/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(subscription)
credentials: 'include', // Include cookies for session
body: JSON.stringify({
endpoint: subscription.endpoint,
keys: {
p256dh: p256dhString,
auth: authString
},
supabaseUserId: supabaseUserId
})
});
console.log('✅ Push subscription successful');
const result = await response.json();
if (result.success) {
pushEnabled = true;
updatePushButton();
console.log('✅ Push subscription successful');
// Store player ID for notifications
if (result.playerId) {
localStorage.setItem('pushPlayerId', result.playerId);
}
} else {
throw new Error(result.message);
}
} catch (error) {
console.error('❌ Push subscription failed:', error);
pushEnabled = false;
updatePushButton();
}
}
// Unsubscribe from push notifications
async function unsubscribeFromPush() {
try {
console.log('🔕 Unsubscribing from push notifications...');
// Get player ID from localStorage
const playerId = localStorage.getItem('pushPlayerId');
if (pushSubscription) {
// Store endpoint before unsubscribing
const endpoint = pushSubscription.endpoint;
await pushSubscription.unsubscribe();
pushSubscription = null;
console.log('✅ Local push subscription removed');
// Notify server to remove specific subscription from database
if (playerId && endpoint) {
try {
const response = await fetch('/api/v1/public/unsubscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include', // Include cookies for session
body: JSON.stringify({
playerId: playerId,
endpoint: endpoint
})
});
const result = await response.json();
if (result.success) {
console.log('✅ Server notified - specific subscription removed from database');
} else {
console.warn('⚠️ Server notification failed:', result.message);
}
} catch (error) {
console.warn('⚠️ Failed to notify server:', error);
}
}
}
// Clear stored player ID
localStorage.removeItem('pushPlayerId');
pushEnabled = false;
updatePushButton();
console.log('🔕 Push notifications disabled');
} catch (error) {
console.error('❌ Push unsubscribe failed:', error);
pushEnabled = false;
updatePushButton();
}
}
// Check if user is on iOS
function isIOS() {
return /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
}
// Check if PWA is installed
function isPWAInstalled() {
return window.matchMedia('(display-mode: standalone)').matches ||
window.navigator.standalone === true;
}
// Show iOS PWA installation hint
function showIOSPWAHint() {
if (isIOS() && !isPWAInstalled()) {
const hint = document.createElement('div');
hint.id = 'ios-pwa-hint';
hint.style.cssText = `
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 15px 20px;
border-radius: 10px;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
z-index: 10000;
max-width: 90%;
text-align: center;
font-size: 14px;
line-height: 1.4;
`;
hint.innerHTML = `
<div style="display: flex; align-items: center; gap: 10px;">
<span style="font-size: 20px;">📱</span>
<div>
<strong>Push-Benachrichtigungen für iOS</strong><br>
<small>Für Push-Benachrichtigungen auf iOS: Tippe auf <span style="background: rgba(255,255,255,0.2); padding: 2px 6px; border-radius: 4px;">📤 Teilen</span> → <span style="background: rgba(255,255,255,0.2); padding: 2px 6px; border-radius: 4px;">Zum Home-Bildschirm hinzufügen</span></small>
</div>
<button onclick="this.parentElement.parentElement.remove()" style="background: none; border: none; color: white; font-size: 18px; cursor: pointer; padding: 0; margin-left: 10px;">✕</button>
</div>
`;
document.body.appendChild(hint);
// Auto-remove after 10 seconds
setTimeout(() => {
if (hint.parentNode) {
hint.remove();
}
}, 10000);
}
}
// Toggle push notifications
async function togglePushNotifications() {
if (pushEnabled) {
await unsubscribeFromPush();
} else {
// Check if iOS and not PWA
if (isIOS() && !isPWAInstalled()) {
showIOSPWAHint();
return;
}
// Check notification permission first
if (Notification.permission === 'denied') {
alert('Push-Benachrichtigungen sind blockiert. Bitte erlaube sie in den Browser-Einstellungen.');
return;
}
if (Notification.permission === 'default') {
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
alert('Push-Benachrichtigungen wurden nicht erlaubt.');
return;
}
}
await subscribeToPush();
}
}
// Update push button appearance
function updatePushButton() {
const button = document.getElementById('pushButton');
if (!button) {
console.log('❌ Push button not found in DOM');
return;
}
console.log(`🔔 Updating push button - Status: ${pushEnabled ? 'ENABLED' : 'DISABLED'}`);
if (pushEnabled) {
button.classList.add('active');
button.setAttribute('data-de', '🔕 Push deaktivieren');
button.setAttribute('data-en', '🔕 Disable Push');
button.textContent = '🔕 Push deaktivieren';
console.log('✅ Button updated to: Push deaktivieren (RED)');
} else {
button.classList.remove('active');
button.setAttribute('data-de', '🔔 Push aktivieren');
button.setAttribute('data-en', '🔔 Enable Push');
button.textContent = '🔔 Push aktivieren';
console.log('✅ Button updated to: Push aktivieren (GREEN)');
}
}
// Check existing push subscription on page load
async function checkPushStatus() {
try {
console.log('🔍 Checking push status...');
if (!('serviceWorker' in navigator)) {
console.log('❌ Service Worker not supported');
pushEnabled = false;
updatePushButton();
return;
}
if (!('PushManager' in window)) {
console.log('❌ Push Manager not supported');
pushEnabled = false;
updatePushButton();
return;
}
const registration = await navigator.serviceWorker.ready;
console.log('✅ Service Worker ready');
const subscription = await registration.pushManager.getSubscription();
console.log('📱 Current subscription:', subscription ? 'EXISTS' : 'NONE');
if (subscription) {
pushSubscription = subscription;
pushEnabled = true;
updatePushButton();
console.log('✅ Existing push subscription found and activated');
// Also check if we have a stored player ID
const storedPlayerId = localStorage.getItem('pushPlayerId');
if (storedPlayerId) {
console.log(`📱 Push subscription linked to player: ${storedPlayerId}`);
}
} else {
pushEnabled = false;
updatePushButton();
console.log(' No existing push subscription found');
}
} catch (error) {
console.error('❌ Error checking push status:', error);
pushEnabled = false;
updatePushButton();
}
}
</script>
</head>
<body>
<div class="main-container">
<!-- Language Selector -->
<div class="language-selector">
<select id="languageSelect" onchange="changeLanguage()">
<option value="de" data-flag="🇩🇪">Deutsch</option>
<option value="en" data-flag="🇺🇸">English</option>
</select>
</div>
<div class="nav-buttons">
<div class="user-info">
<div class="user-avatar" id="userAvatar">U</div>
<span id="userEmail">user@example.com</span>
<!-- Sticky Header Container -->
<div class="sticky-header">
<!-- Language Selector -->
<div class="language-selector">
<select id="languageSelect" onchange="changeLanguage()">
<option value="de" data-flag="🇩🇪">Deutsch</option>
<option value="en" data-flag="🇺🇸">English</option>
</select>
</div>
<div class="nav-buttons">
<div class="user-info">
<div class="user-avatar" id="userAvatar">U</div>
<span id="userEmail">user@example.com</span>
</div>
<button class="btn btn-push" id="pushButton" onclick="togglePushNotifications()" data-de="🔔 Push aktivieren" data-en="🔔 Enable Push">🔔 Push aktivieren</button>
<button class="btn btn-pwa" id="pwaButton" onclick="installPWA()" style="display: none;" data-de="📱 App installieren" data-en="📱 Install App">📱 App installieren</button>
<a href="/" class="btn btn-primary" data-de="Zurück zu Zeiten" data-en="Back to Times">Back to Times</a>
<button class="btn btn-logout" onclick="logout()" data-de="Logout" data-en="Logout">Logout</button>
</div>
<a href="/" class="btn btn-primary" data-de="Zurück zu Zeiten" data-en="Back to Times">Back to Times</a>
<button class="btn btn-logout" onclick="logout()" data-de="Logout" data-en="Logout">Logout</button>
</div>
<div class="header-section">
@@ -485,6 +767,21 @@
<input type="date" id="playerBirthdate" class="form-input" style="text-align: center;">
</div>
<!-- AGB Section -->
<div class="agb-section" style="background: #1e293b; border: 1px solid #334155; border-radius: 8px; padding: 15px; margin: 15px 0;">
<div class="agb-checkbox" style="display: flex; align-items: flex-start; gap: 10px; margin-bottom: 10px;">
<input type="checkbox" id="agbAccepted" name="agbAccepted" required style="width: auto; margin: 0; margin-top: 3px;">
<label for="agbAccepted" style="color: #e2e8f0; font-size: 0.85rem; line-height: 1.4; margin: 0; font-weight: normal;">
Ich habe die <a href="/agb.html" target="_blank" style="color: #00d4ff; text-decoration: none; font-weight: bold;">Allgemeinen Geschäftsbedingungen</a>
gelesen und stimme zu, dass mein Name und meine Laufzeiten im öffentlichen Leaderboard angezeigt werden.
</label>
</div>
<div class="agb-warning" style="color: #fbbf24; font-size: 0.8rem; margin-top: 10px;">
⚠️ <strong>Wichtig:</strong> Ohne Zustimmung zu den AGB können Sie das System nutzen,
aber Ihre Zeiten werden nicht im öffentlichen Leaderboard angezeigt.
</div>
</div>
<button class="btn btn-primary" onclick="createRfidPlayerRecord()" style="width: 100%;" data-de="Spieler erstellen" data-en="Create Player">
Spieler erstellen
</button>
@@ -550,6 +847,56 @@
</footer>
<script src="/js/cookie-consent.js"></script>
<script src="/js/dashboard.js?v=1.1"></script>
<script src="/js/dashboard.js?v=1.6"></script>
<script>
// PWA Installation
let deferredPrompt;
// Listen for PWA install prompt
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
const pwaButton = document.getElementById('pwaButton');
if (pwaButton) {
pwaButton.style.display = 'inline-block';
}
});
// Install PWA
async function installPWA() {
if (deferredPrompt) {
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
console.log(`PWA install outcome: ${outcome}`);
deferredPrompt = null;
const pwaButton = document.getElementById('pwaButton');
if (pwaButton) {
pwaButton.style.display = 'none';
}
} else if (isIOS()) {
// Show iOS installation instructions
showIOSPWAHint();
}
}
// Check if PWA is already installed
window.addEventListener('appinstalled', () => {
console.log('PWA was installed');
const pwaButton = document.getElementById('pwaButton');
if (pwaButton) {
pwaButton.style.display = 'none';
}
});
// Initialize dashboard when page loads
document.addEventListener('DOMContentLoaded', function () {
// Show PWA hint for iOS users
if (isIOS() && !isPWAInstalled()) {
setTimeout(showIOSPWAHint, 2000); // Show after 2 seconds
}
});
</script>
</body>
</html>

View File

@@ -22,19 +22,22 @@
</div>
<div class="main-container">
<!-- Language Selector -->
<div class="language-selector">
<select id="languageSelect" onchange="changeLanguage()">
<option value="de" data-flag="🇩🇪">Deutsch</option>
<option value="en" data-flag="🇺🇸">English</option>
</select>
</div>
<!-- Sticky Header Container -->
<div class="sticky-header">
<!-- Language Selector -->
<div class="language-selector">
<select id="languageSelect" onchange="changeLanguage()">
<option value="de" data-flag="🇩🇪">Deutsch</option>
<option value="en" data-flag="🇺🇸">English</option>
</select>
</div>
<!-- Admin Login Button -->
<div class="mobile-nav-buttons">
<a href="/login" class="admin-login-btn" id="adminLoginBtn">🔐 Login</a>
<a href="/dashboard" class="dashboard-btn" id="dashboardBtn" style="display: none;">📊 Dashboard</a>
<button class="logout-btn" id="logoutBtn" onclick="logout()" style="display: none;">🚪 Logout</button>
<!-- Admin Login Button -->
<div class="mobile-nav-buttons">
<a href="/login" class="admin-login-btn" id="adminLoginBtn" onclick="handleLoginClick(event)">🔐 Login</a>
<a href="/dashboard" class="dashboard-btn" id="dashboardBtn" style="display: none;">📊 Dashboard</a>
<button class="logout-btn" id="logoutBtn" onclick="logout()" style="display: none;">🚪 Logout</button>
</div>
</div>
<div class="header-section">
@@ -134,10 +137,126 @@
<!-- External Libraries -->
<script src="https://unpkg.com/@supabase/supabase-js@2"></script>
<script src="https://cdn.socket.io/4.7.2/socket.io.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.8.1/socket.io.min.js"></script>
<!-- Application JavaScript -->
<script src="/js/cookie-consent.js"></script>
<script src="/js/index.js"></script>
<script>
// iOS Detection
function isIOS() {
return /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
}
// Mobile Sticky Header Fix
function initMobileStickyHeader() {
const stickyHeader = document.querySelector('.sticky-header');
if (!stickyHeader) return;
// Check if we're on mobile
const isMobile = window.innerWidth <= 768;
if (isMobile || isIOS()) {
// Force fixed positioning for mobile
stickyHeader.style.position = 'fixed';
stickyHeader.style.top = '0';
stickyHeader.style.left = '0';
stickyHeader.style.right = '0';
stickyHeader.style.width = '100%';
stickyHeader.style.zIndex = '99999';
stickyHeader.style.marginBottom = '0';
stickyHeader.style.borderRadius = '0';
// Add padding to main container
const mainContainer = document.querySelector('.main-container');
if (mainContainer) {
mainContainer.style.paddingTop = '80px';
}
console.log('Mobile sticky header fix applied');
}
}
// iOS Touch Event Fix
function handleLoginClick(event) {
console.log('Login button clicked');
// Prevent default behavior temporarily
event.preventDefault();
// Add visual feedback
const button = event.target;
button.style.transform = 'scale(0.95)';
button.style.opacity = '0.8';
// Reset after short delay
setTimeout(() => {
button.style.transform = '';
button.style.opacity = '';
// Navigate to login page
window.location.href = '/login';
}, 150);
}
// Add touch event listeners for better mobile support
document.addEventListener('DOMContentLoaded', function() {
// Initialize mobile sticky header
initMobileStickyHeader();
const loginBtn = document.getElementById('adminLoginBtn');
if (loginBtn) {
// Make sure button is clickable
loginBtn.style.pointerEvents = 'auto';
loginBtn.style.position = 'relative';
loginBtn.style.zIndex = '100000';
// Add multiple event listeners for maximum compatibility
loginBtn.addEventListener('touchstart', function(e) {
console.log('Touch start on login button');
e.preventDefault();
this.style.transform = 'scale(0.95)';
this.style.opacity = '0.8';
}, { passive: false });
loginBtn.addEventListener('touchend', function(e) {
console.log('Touch end on login button');
e.preventDefault();
e.stopPropagation();
setTimeout(() => {
this.style.transform = '';
this.style.opacity = '';
window.location.href = '/login';
}, 100);
}, { passive: false });
loginBtn.addEventListener('click', function(e) {
console.log('Click on login button');
e.preventDefault();
e.stopPropagation();
window.location.href = '/login';
});
// Add mousedown for desktop
loginBtn.addEventListener('mousedown', function(e) {
console.log('Mouse down on login button');
this.style.transform = 'scale(0.95)';
this.style.opacity = '0.8';
});
loginBtn.addEventListener('mouseup', function(e) {
console.log('Mouse up on login button');
this.style.transform = '';
this.style.opacity = '';
});
}
});
// Re-apply mobile fixes on window resize
window.addEventListener('resize', function() {
setTimeout(initMobileStickyHeader, 100);
});
</script>
</body>
</html>

View File

@@ -400,7 +400,7 @@ function filterData() {
displayAchievements();
} else {
currentPlayers = filteredData;
displayPlayers();
displayPlayersWithAchievements();
}
break;
}
@@ -427,7 +427,7 @@ function refreshData() {
if (currentAchievementMode === 'achievements') {
loadAchievements();
} else {
loadPlayers();
loadPlayersWithAchievements();
}
break;
}
@@ -1372,7 +1372,7 @@ async function toggleAchievementMode() {
currentAchievementMode = 'players';
document.getElementById('dataTitle').textContent = '👥 Spieler-Achievements';
document.getElementById('searchInput').placeholder = 'Spieler durchsuchen...';
await loadPlayers();
await loadPlayersWithAchievements();
} else {
currentAchievementMode = 'achievements';
document.getElementById('dataTitle').textContent = '🏆 Achievement-Verwaltung';
@@ -1382,7 +1382,7 @@ async function toggleAchievementMode() {
}
// Load all players with achievement statistics
async function loadPlayers() {
async function loadPlayersWithAchievements() {
try {
const response = await fetch('/api/v1/admin/achievements/players');
const result = await response.json();
@@ -1390,7 +1390,7 @@ async function loadPlayers() {
if (result.success) {
currentPlayers = result.data;
currentData = result.data; // Set for filtering
displayPlayers();
displayPlayersWithAchievements();
} else {
showError('Fehler beim Laden der Spieler: ' + result.message);
}
@@ -1401,7 +1401,7 @@ async function loadPlayers() {
}
// Display players in table
function displayPlayers() {
function displayPlayersWithAchievements() {
const content = document.getElementById('dataContent');
if (currentPlayers.length === 0) {

View File

@@ -46,6 +46,9 @@ async function initDashboard() {
// Load times section
checkLinkStatusAndLoadTimes();
// Update analytics and statistics cards
updateAnalyticsAndStatisticsCards();
} catch (error) {
console.error('An unexpected error occurred:', error);
@@ -148,6 +151,9 @@ function updateDynamicContent() {
// Update achievement progress text
updateAchievementProgressText();
// Update analytics and statistics cards
updateAnalyticsAndStatisticsCards();
// Reload achievements if they're loaded
if (window.allAchievements && window.allAchievements.length > 0) {
@@ -175,6 +181,114 @@ function loadLanguagePreference() {
}
}
// Update analytics and statistics cards based on link status
function updateAnalyticsAndStatisticsCards() {
const analyticsCard = document.getElementById('analyticsCard');
const statisticsCard = document.getElementById('statisticsCard');
if (!currentPlayerId) {
// User not linked - show appropriate message
if (analyticsCard) {
const isGerman = currentLanguage === 'de';
const message = isGerman ?
'RFID verknüpfen erforderlich' :
'RFID linking required';
const description = isGerman ?
'Verknüpfe deine RFID-Karte, um Analytics zu sehen.' :
'Link your RFID card to view analytics.';
analyticsCard.innerHTML = `
<h3>📊 Analytics</h3>
<div style="background: rgba(239, 68, 68, 0.1); border: 1px solid rgba(239, 68, 68, 0.3); border-radius: 0.5rem; padding: 1rem; margin: 1rem 0;">
<div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem;">
<span style="color: #ef4444; font-weight: 600;">⚠️</span>
<span style="color: #ef4444; font-weight: 600;">${message}</span>
</div>
<div style="font-size: 0.9rem; color: #8892b0;">
${description}
</div>
</div>
<button class="btn btn-primary" style="margin-top: 1rem;" onclick="event.stopPropagation(); showRFIDSettings();" data-de="RFID verknüpfen" data-en="Link RFID">RFID verknüpfen</button>
`;
}
if (statisticsCard) {
const isGerman = currentLanguage === 'de';
const message = isGerman ?
'RFID verknüpfen erforderlich' :
'RFID linking required';
const description = isGerman ?
'Verknüpfe deine RFID-Karte, um Statistiken zu sehen.' :
'Link your RFID card to view statistics.';
statisticsCard.innerHTML = `
<h3>📊 Statistiken</h3>
<div style="background: rgba(239, 68, 68, 0.1); border: 1px solid rgba(239, 68, 68, 0.3); border-radius: 0.5rem; padding: 1rem; margin: 1rem 0;">
<div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem;">
<span style="color: #ef4444; font-weight: 600;">⚠️</span>
<span style="color: #ef4444; font-weight: 600;">${message}</span>
</div>
<div style="font-size: 0.9rem; color: #8892b0;">
${description}
</div>
</div>
<button class="btn btn-primary" style="margin-top: 1rem;" onclick="event.stopPropagation(); showRFIDSettings();" data-de="RFID verknüpfen" data-en="Link RFID">RFID verknüpfen</button>
`;
}
} else {
// User is linked - restore original cards
if (analyticsCard) {
const isGerman = currentLanguage === 'de';
analyticsCard.innerHTML = `
<h3>📊 Analytics</h3>
<div id="analyticsPreview" style="display: none;">
<div class="analytics-stats">
<div class="mini-stat">
<div class="mini-stat-number" id="avgTimeThisWeek">--:--</div>
<div class="mini-stat-label">${isGerman ? 'Durchschnitt diese Woche' : 'Average this week'}</div>
</div>
<div class="mini-stat">
<div class="mini-stat-number" id="improvementThisWeek">+0.0%</div>
<div class="mini-stat-label">${isGerman ? 'Verbesserung' : 'Improvement'}</div>
</div>
<div class="mini-stat">
<div class="mini-stat-number" id="runsThisWeek">0</div>
<div class="mini-stat-label">${isGerman ? 'Läufe diese Woche' : 'Runs this week'}</div>
</div>
</div>
</div>
<p>${isGerman ? 'Verfolge deine Leistung und überwache wichtige Metriken.' : 'Track your performance and monitor important metrics.'}</p>
<button class="btn btn-primary" style="margin-top: 1rem;" onclick="event.stopPropagation(); showAnalytics();" data-de="Analytics öffnen" data-en="Open Analytics">${isGerman ? 'Analytics öffnen' : 'Open Analytics'}</button>
`;
}
if (statisticsCard) {
const isGerman = currentLanguage === 'de';
statisticsCard.innerHTML = `
<h3>📊 Statistiken</h3>
<div id="statisticsPreview" style="display: none;">
<div class="statistics-stats">
<div class="mini-stat">
<div class="mini-stat-number" id="personalBest">--:--</div>
<div class="mini-stat-label">${isGerman ? 'Persönliche Bestzeit' : 'Personal Best'}</div>
</div>
<div class="mini-stat">
<div class="mini-stat-number" id="totalRunsCount">0</div>
<div class="mini-stat-label">${isGerman ? 'Gesamte Läufe' : 'Total Runs'}</div>
</div>
<div class="mini-stat">
<div class="mini-stat-number" id="rankPosition">-</div>
<div class="mini-stat-label">${isGerman ? 'Ranglisten-Position' : 'Ranking Position'}</div>
</div>
</div>
</div>
<p>${isGerman ? 'Detaillierte Statistiken zu deinen Läufen - beste Zeiten, Verbesserungen und Vergleiche.' : 'Detailed statistics about your runs - best times, improvements and comparisons.'}</p>
<button class="btn btn-primary" style="margin-top: 1rem;" onclick="event.stopPropagation(); showStatistics();" data-de="Statistiken öffnen" data-en="Open Statistics">${isGerman ? 'Statistiken öffnen' : 'Open Statistics'}</button>
`;
}
}
}
// Update achievement notifications
function updateAchievementNotifications() {
// This will be called when achievements are displayed
@@ -210,6 +324,9 @@ document.addEventListener('DOMContentLoaded', function () {
loadLanguagePreference();
changeLanguage(); // Apply saved language
initDashboard();
// Push notifications are now handled manually via the button
// No automatic subscription on page load
});
// Modal functions
@@ -501,6 +618,7 @@ async function createRfidPlayerRecord() {
const firstname = document.getElementById('playerFirstname').value.trim();
const lastname = document.getElementById('playerLastname').value.trim();
const birthdate = document.getElementById('playerBirthdate').value;
const agbAccepted = document.getElementById('agbAccepted').checked;
// Validation
if (!rawUid) {
@@ -535,6 +653,14 @@ async function createRfidPlayerRecord() {
return;
}
if (!agbAccepted) {
const agbErrorMsg = currentLanguage === 'de' ?
'Bitte stimme den Allgemeinen Geschäftsbedingungen zu' :
'Please accept the Terms of Service';
showMessage('rfidMessage', agbErrorMsg, 'error');
return;
}
try {
// Format the UID to match database format
const formattedUid = formatRfidUid(rawUid);
@@ -555,7 +681,8 @@ async function createRfidPlayerRecord() {
firstname: firstname,
lastname: lastname,
birthdate: birthdate,
supabase_user_id: currentUser?.id || null
supabase_user_id: currentUser?.id || null,
agb_accepted: agbAccepted
})
});
@@ -572,6 +699,7 @@ async function createRfidPlayerRecord() {
document.getElementById('playerFirstname').value = '';
document.getElementById('playerLastname').value = '';
document.getElementById('playerBirthdate').value = '';
document.getElementById('agbAccepted').checked = false;
// Hide create player section since user is now linked
const createPlayerSection = document.getElementById('createPlayerSection');
@@ -797,6 +925,9 @@ async function loadUserTimesSection(playerData) {
// Initialize achievements for this player
initializeAchievements(playerData.id);
// Update analytics and statistics cards (user is now linked)
updateAnalyticsAndStatisticsCards();
} catch (error) {
console.error('Error loading user times:', error);
@@ -963,6 +1094,11 @@ function convertTimeToSeconds(timeValue) {
// Format time interval to readable format
function formatTime(interval) {
// Handle numeric values (seconds)
if (typeof interval === 'number') {
return formatSeconds(interval);
}
// Postgres interval format: {"hours":0,"minutes":1,"seconds":23.45}
if (typeof interval === 'object') {
const { hours = 0, minutes = 0, seconds = 0 } = interval;
@@ -1014,7 +1150,7 @@ let currentAchievementCategory = 'all';
// Load achievements for the current player
async function loadPlayerAchievements() {
if (!currentPlayerId) {
if (!currentPlayerId || currentPlayerId === 'undefined' || currentPlayerId === 'null') {
showAchievementsNotAvailable();
return;
}
@@ -1212,9 +1348,10 @@ function showAnalytics() {
console.log('Analytics section shown');
} else {
console.error('Analytics section not found');
return;
}
// Load analytics data
// Load analytics data (will show fallback if not linked)
loadAnalyticsData();
}
@@ -1237,30 +1374,33 @@ function showStatistics() {
console.log('Statistics section shown');
} else {
console.error('Statistics section not found');
return;
}
// Load statistics data
// Load statistics data (will show fallback if not linked)
loadStatisticsData();
}
async function loadAnalyticsData() {
try {
if (!currentPlayerId) {
console.error('No player ID available');
if (!currentPlayerId || currentPlayerId === 'undefined' || currentPlayerId === 'null') {
console.error('No player ID available - user not linked');
// Show fallback data when user is not linked
displayAnalyticsFallback();
return;
}
// Load analytics data from API
const response = await fetch(`/api/v1/analytics/player/${currentPlayerId}`);
const response = await fetch(`/api/analytics/player/${currentPlayerId}`);
if (!response.ok) {
throw new Error('Failed to load analytics data');
}
const analyticsData = await response.json();
displayAnalyticsData(analyticsData);
displayAnalyticsData(analyticsData.data);
// Update preview in main card
updateAnalyticsPreview(analyticsData);
updateAnalyticsPreview(analyticsData.data);
} catch (error) {
console.error('Error loading analytics data:', error);
@@ -1271,22 +1411,24 @@ async function loadAnalyticsData() {
async function loadStatisticsData() {
try {
if (!currentPlayerId) {
console.error('No player ID available');
if (!currentPlayerId || currentPlayerId === 'undefined' || currentPlayerId === 'null') {
console.error('No player ID available - user not linked');
// Show fallback data when user is not linked
displayStatisticsFallback();
return;
}
// Load statistics data from API
const response = await fetch(`/api/v1/statistics/player/${currentPlayerId}`);
const response = await fetch(`/api/statistics/player/${currentPlayerId}`);
if (!response.ok) {
throw new Error('Failed to load statistics data');
}
const statisticsData = await response.json();
displayStatisticsData(statisticsData);
displayStatisticsData(statisticsData.data);
// Update preview in main card
updateStatisticsPreview(statisticsData);
updateStatisticsPreview(statisticsData.data);
} catch (error) {
console.error('Error loading statistics data:', error);
@@ -1299,12 +1441,12 @@ function displayAnalyticsData(data) {
// Performance Trends
document.getElementById('avgTimeThisWeekDetail').textContent = formatTime(data.performance.avgTimeThisWeek);
document.getElementById('avgTimeLastWeek').textContent = formatTime(data.performance.avgTimeLastWeek);
document.getElementById('improvementDetail').textContent = data.performance.improvement + '%';
document.getElementById('improvementDetail').textContent = data.performance.improvement.toFixed(2) + '%';
// Activity Stats
document.getElementById('runsToday').textContent = data.activity.runsToday + ' Läufe';
document.getElementById('runsThisWeekDetail').textContent = data.activity.runsThisWeek + ' Läufe';
document.getElementById('avgRunsPerDay').textContent = data.activity.avgRunsPerDay.toFixed(1);
document.getElementById('avgRunsPerDay').textContent = data.activity.avgRunsPerDay.toFixed(2);
// Location Performance
displayLocationPerformance(data.locationPerformance);
@@ -1322,7 +1464,7 @@ function displayStatisticsData(data) {
// Consistency Metrics
document.getElementById('averageTime').textContent = formatTime(data.consistency.averageTime);
document.getElementById('timeDeviation').textContent = formatTime(data.consistency.timeDeviation);
document.getElementById('consistencyScore').textContent = data.consistency.consistencyScore + '%';
document.getElementById('consistencyScore').textContent = data.consistency.consistencyScore.toFixed(2) + '%';
// Ranking Stats
displayRankingStats(data.rankings);
@@ -1397,40 +1539,58 @@ function displayRankingStats(rankings) {
}
function updateAnalyticsPreview(data) {
document.getElementById('avgTimeThisWeek').textContent = formatTime(data.performance.avgTimeThisWeek);
document.getElementById('improvementThisWeek').textContent = data.performance.improvement + '%';
document.getElementById('runsThisWeek').textContent = data.activity.runsThisWeek;
document.getElementById('analyticsPreview').style.display = 'block';
if (data && data.performance && data.activity) {
document.getElementById('avgTimeThisWeek').textContent = formatTime(data.performance.avgTimeThisWeek);
document.getElementById('improvementThisWeek').textContent = data.performance.improvement.toFixed(2) + '%';
document.getElementById('runsThisWeek').textContent = data.activity.runsThisWeek;
document.getElementById('analyticsPreview').style.display = 'block';
} else {
// Hide preview if no data
document.getElementById('analyticsPreview').style.display = 'none';
}
}
function updateStatisticsPreview(data) {
document.getElementById('personalBest').textContent = formatTime(data.personalRecords[0]?.time || 0);
document.getElementById('totalRunsCount').textContent = data.progress.totalRuns;
document.getElementById('rankPosition').textContent = data.rankings[0]?.position || '-';
document.getElementById('statisticsPreview').style.display = 'block';
if (data && data.personalRecords && data.progress) {
document.getElementById('personalBest').textContent = formatTime(data.personalRecords[0]?.time || 0);
document.getElementById('totalRunsCount').textContent = data.progress.totalRuns;
document.getElementById('rankPosition').textContent = data.rankings[0]?.position || '-';
document.getElementById('statisticsPreview').style.display = 'block';
} else {
// Hide preview if no data
document.getElementById('statisticsPreview').style.display = 'none';
}
}
function displayAnalyticsFallback() {
// Show fallback data when API fails
// Show fallback data when API fails or user not linked
const notLinkedMessage = currentLanguage === 'de' ?
'RFID nicht verknüpft - Analytics nicht verfügbar' :
'RFID not linked - Analytics not available';
document.getElementById('avgTimeThisWeekDetail').textContent = '--:--';
document.getElementById('avgTimeLastWeek').textContent = '--:--';
document.getElementById('improvementDetail').textContent = '+0.0%';
document.getElementById('runsToday').textContent = '0 Läufe';
document.getElementById('runsThisWeekDetail').textContent = '0 Läufe';
document.getElementById('avgRunsPerDay').textContent = '0.0';
document.getElementById('locationPerformance').innerHTML = '<p>Daten nicht verfügbar</p>';
document.getElementById('locationPerformance').innerHTML = `<p style="color: #8892b0; text-align: center; padding: 2rem;">${notLinkedMessage}</p>`;
document.getElementById('runsThisMonth').textContent = '0 Läufe';
document.getElementById('runsLastMonth').textContent = '0 Läufe';
document.getElementById('bestTimeThisMonth').textContent = '--:--';
}
function displayStatisticsFallback() {
// Show fallback data when API fails
document.getElementById('personalRecords').innerHTML = '<p>Daten nicht verfügbar</p>';
// Show fallback data when API fails or user not linked
const notLinkedMessage = currentLanguage === 'de' ?
'RFID nicht verknüpft - Statistiken nicht verfügbar' :
'RFID not linked - Statistics not available';
document.getElementById('personalRecords').innerHTML = `<p style="color: #8892b0; text-align: center; padding: 2rem;">${notLinkedMessage}</p>`;
document.getElementById('averageTime').textContent = '--:--';
document.getElementById('timeDeviation').textContent = '--:--';
document.getElementById('consistencyScore').textContent = '0%';
document.getElementById('rankingStats').innerHTML = '<p>Daten nicht verfügbar</p>';
document.getElementById('rankingStats').innerHTML = `<p style="color: #8892b0; text-align: center; padding: 2rem;">${notLinkedMessage}</p>`;
document.getElementById('totalRunsStats').textContent = '0';
document.getElementById('activeDays').textContent = '0';
document.getElementById('locationsVisited').textContent = '0';
@@ -1533,7 +1693,7 @@ function showAchievementsNotAvailable() {
// Check achievements for current player
async function checkPlayerAchievements() {
if (!currentPlayerId) return;
if (!currentPlayerId || currentPlayerId === 'undefined' || currentPlayerId === 'null') return;
try {
const response = await fetch(`/api/achievements/check/${currentPlayerId}?t=${Date.now()}`, {
@@ -1589,13 +1749,47 @@ function showAchievementNotification(newAchievements) {
// Initialize achievements when player is loaded
function initializeAchievements(playerId) {
currentPlayerId = playerId;
loadPlayerAchievements();
if (playerId && playerId !== 'undefined' && playerId !== 'null') {
currentPlayerId = playerId;
loadPlayerAchievements();
} else {
console.warn('Invalid player ID provided to initializeAchievements:', playerId);
currentPlayerId = null;
}
}
// Convert VAPID key from base64url to Uint8Array
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/\-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
// Push notifications are now handled in dashboard.html
// This function has been moved to the HTML file for better integration
// Web Notification Functions
function showWebNotification(title, message, icon = '🏆') {
if ('Notification' in window && Notification.permission === 'granted') {
// Log notification details to console
console.log('🔔 Web Notification sent:', {
title: title,
message: message,
icon: icon,
playerId: currentPlayerId || 'unknown',
pushPlayerId: localStorage.getItem('pushPlayerId') || 'unknown',
timestamp: new Date().toISOString()
});
const notification = new Notification(title, {
body: message,
icon: '/pictures/icon-192.png',
@@ -1617,9 +1811,23 @@ function showWebNotification(title, message, icon = '🏆') {
}
}
// Track which notifications have been sent today
let notificationsSentToday = {
daily: false,
weekly: false,
monthly: false
};
// Check for best time achievements and show notifications
async function checkBestTimeNotifications() {
try {
// Check if push notifications are enabled
const pushPlayerId = localStorage.getItem('pushPlayerId');
if (!pushPlayerId) {
return;
}
const response = await fetch('/api/v1/public/best-times');
const result = await response.json();
@@ -1627,29 +1835,96 @@ async function checkBestTimeNotifications() {
const { daily, weekly, monthly } = result.data;
// Check if current player has best times
if (currentPlayerId) {
if (daily && daily.player_id === currentPlayerId) {
const title = currentLanguage === 'de' ? '🏆 Tageskönig!' : '🏆 Daily King!';
const message = currentLanguage === 'de' ?
`Glückwunsch! Du hast die beste Zeit des Tages mit ${daily.best_time} erreicht!` :
`Congratulations! You achieved the best time of the day with ${daily.best_time}!`;
showWebNotification(title, message, '👑');
if (currentPlayerId && currentPlayerId !== 'undefined' && currentPlayerId !== 'null') {
const now = new Date();
const isEvening = now.getHours() >= 19;
// Check daily best time (only in the evening, only once per day)
if (daily && daily.player_id === currentPlayerId && isEvening) {
// Check if notification was already sent today
const dailyCheck = await fetch(`/api/v1/public/notification-sent/${currentPlayerId}/daily_best`);
const dailyResult = await dailyCheck.json();
if (!dailyResult.wasSent) {
const title = currentLanguage === 'de' ? '🏆 Tageskönig!' : '🏆 Daily King!';
const message = currentLanguage === 'de' ?
`Glückwunsch! Du hast die beste Zeit des Tages mit ${daily.best_time} erreicht!` :
`Congratulations! You achieved the best time of the day with ${daily.best_time}!`;
showWebNotification(title, message, '👑');
// Mark as sent in database
await fetch('/api/v1/public/notification-sent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
playerId: currentPlayerId,
notificationType: 'daily_best'
})
});
}
}
// Check weekly best time (only on Sunday evening, only once per week)
if (weekly && weekly.player_id === currentPlayerId) {
const title = currentLanguage === 'de' ? '🏆 Wochenchampion!' : '🏆 Weekly Champion!';
const message = currentLanguage === 'de' ?
`Fantastisch! Du bist der Wochenchampion mit ${weekly.best_time}!` :
`Fantastic! You are the weekly champion with ${weekly.best_time}!`;
showWebNotification(title, message, '🏆');
const isSunday = now.getDay() === 0; // 0 = Sunday
if (isSunday && isEvening) {
// Check if notification was already sent this week
const weeklyCheck = await fetch(`/api/v1/public/notification-sent/${currentPlayerId}/weekly_best`);
const weeklyResult = await weeklyCheck.json();
if (!weeklyResult.wasSent) {
const title = currentLanguage === 'de' ? '🏆 Wochenchampion!' : '🏆 Weekly Champion!';
const message = currentLanguage === 'de' ?
`Fantastisch! Du bist der Wochenchampion mit ${weekly.best_time}!` :
`Fantastic! You are the weekly champion with ${weekly.best_time}!`;
showWebNotification(title, message, '🏆');
// Mark as sent in database
await fetch('/api/v1/public/notification-sent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
playerId: currentPlayerId,
notificationType: 'weekly_best'
})
});
}
}
}
// Check monthly best time (only on last evening of month at 19:00, only once per month)
if (monthly && monthly.player_id === currentPlayerId) {
const title = currentLanguage === 'de' ? '🏆 Monatsmeister!' : '🏆 Monthly Master!';
const message = currentLanguage === 'de' ?
`Unglaublich! Du bist der Monatsmeister mit ${monthly.best_time}!` :
`Incredible! You are the monthly master with ${monthly.best_time}!`;
showWebNotification(title, message, '🥇');
const lastDayOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0);
const isLastDayOfMonth = now.getDate() === lastDayOfMonth.getDate();
// Only show monthly notification on last day of month at 19:00 or later
if (isLastDayOfMonth && isEvening) {
// Check if notification was already sent this month
const monthlyCheck = await fetch(`/api/v1/public/notification-sent/${currentPlayerId}/monthly_best`);
const monthlyResult = await monthlyCheck.json();
if (!monthlyResult.wasSent) {
const title = currentLanguage === 'de' ? '🏆 Monatsmeister!' : '🏆 Monthly Master!';
const message = currentLanguage === 'de' ?
`Unglaublich! Du bist der Monatsmeister mit ${monthly.best_time}!` :
`Incredible! You are the monthly master with ${monthly.best_time}!`;
showWebNotification(title, message, '🥇');
// Mark as sent in database
await fetch('/api/v1/public/notification-sent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
playerId: currentPlayerId,
notificationType: 'monthly_best'
})
});
}
}
}
}
}
@@ -1661,28 +1936,87 @@ async function checkBestTimeNotifications() {
// Check for new achievements and show notifications
async function checkAchievementNotifications() {
try {
if (!currentPlayerId) return;
// Check if push notifications are enabled
const pushPlayerId = localStorage.getItem('pushPlayerId');
if (!pushPlayerId) {
return;
}
const response = await fetch(`/api/achievements/player/${currentPlayerId}?t=${Date.now()}`);
// Use pushPlayerId for notifications instead of currentPlayerId
if (!pushPlayerId || pushPlayerId === 'undefined' || pushPlayerId === 'null') return;
const response = await fetch(`/api/achievements/player/${pushPlayerId}?t=${Date.now()}`);
const result = await response.json();
if (result.success && result.data) {
const newAchievements = result.data.filter(achievement => {
// Check if achievement was earned in the last 5 minutes
const earnedAt = new Date(achievement.earned_at);
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
return earnedAt > fiveMinutesAgo;
// Only check completed achievements
if (!achievement.is_completed) {
return false;
}
// Check if achievement was earned in the last 10 minutes (extended window)
const earnedAt = achievement.earned_at ? new Date(achievement.earned_at) : null;
const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000);
// If no earned_at date, check if it was completed recently by checking completion_count
if (!earnedAt) {
// For achievements without earned_at, assume they are new if completion_count is 1
// This is a fallback for recently completed achievements
return achievement.completion_count === 1;
}
const isNew = earnedAt > tenMinutesAgo;
return isNew;
});
if (newAchievements.length > 0) {
newAchievements.forEach(achievement => {
const translatedAchievement = translateAchievement(achievement);
showWebNotification(
`🏆 ${translatedAchievement.name}`,
translatedAchievement.description,
achievement.icon || '🏆'
);
});
for (const achievement of newAchievements) {
// Check if notification was already sent for this achievement
const achievementId = achievement.id || achievement.achievement_id;
const locationId = achievement.location_id || '';
if (!achievementId) {
console.warn('Achievement ID is missing for achievement:', achievement);
continue;
}
const achievementCheck = await fetch(`/api/v1/public/notification-sent/${pushPlayerId}/achievement?achievementId=${achievementId}&locationId=${locationId}`);
const achievementResult = await achievementCheck.json();
if (!achievementResult.wasSent) {
const translatedAchievement = translateAchievement(achievement);
showWebNotification(
`🏆 ${translatedAchievement.name}`,
translatedAchievement.description,
achievement.icon || '🏆'
);
// Mark as sent in database
await fetch('/api/v1/public/notification-sent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
playerId: pushPlayerId,
notificationType: 'achievement',
achievementId: achievementId,
locationId: locationId || null
})
});
}
}
}
}
} catch (error) {
@@ -1740,7 +2074,7 @@ function updateLeaderboardSetting() {
async function saveSettings() {
try {
if (!currentPlayerId) {
if (!currentPlayerId || currentPlayerId === 'undefined' || currentPlayerId === 'null') {
console.error('No player ID available');
return;
}
@@ -1821,4 +2155,9 @@ document.addEventListener('DOMContentLoaded', function () {
}
});
}
// Check push notification status on dashboard load
if (typeof checkPushStatus === 'function') {
checkPushStatus();
}
});

View File

@@ -6,7 +6,42 @@ const SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBh
const supabase = window.supabase.createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
// Initialize Socket.IO connection
const socket = io();
let socket;
function setupSocketListeners() {
if (!socket) return;
socket.on('connect', () => {
console.log('🔌 WebSocket connected');
});
socket.on('disconnect', () => {
console.log('🔌 WebSocket disconnected');
});
socket.on('newTime', (data) => {
console.log('🏁 New time received:', data);
showNotification(data);
// Reload data to show the new time
loadData();
});
}
function initializeSocket() {
if (typeof io !== 'undefined') {
socket = io();
setupSocketListeners();
} else {
console.error('Socket.IO library not loaded');
}
}
// Try to initialize immediately, fallback to DOMContentLoaded
if (typeof io !== 'undefined') {
initializeSocket();
} else {
document.addEventListener('DOMContentLoaded', initializeSocket);
}
// Global variable to store locations with coordinates
let locationsData = [];
@@ -63,21 +98,7 @@ function saveLocationSelection(locationId, locationName) {
}
}
// WebSocket Event Handlers
socket.on('connect', () => {
console.log('🔌 WebSocket connected');
});
socket.on('disconnect', () => {
console.log('🔌 WebSocket disconnected');
});
socket.on('newTime', (data) => {
console.log('🏁 New time received:', data);
showNotification(data);
// Reload data to show the new time
loadData();
});
// WebSocket Event Handlers are now in setupSocketListeners() function
// Notification Functions
function showNotification(timeData) {
@@ -95,6 +116,16 @@ function showNotification(timeData) {
notificationTitle.textContent = `🏁 ${newTimeText} ${playerName}!`;
notificationSubtitle.textContent = `${timeString}${locationName}`;
// Ensure notification is above sticky header
notificationBubble.style.zIndex = '100000';
// Check if we're on mobile and adjust position
if (window.innerWidth <= 768) {
notificationBubble.style.top = '5rem'; // Below sticky header on mobile
} else {
notificationBubble.style.top = '2rem'; // Normal position on desktop
}
// Show notification
notificationBubble.classList.remove('hide');
notificationBubble.classList.add('show');
@@ -331,6 +362,16 @@ function showLocationSuccess(locationName, distance) {
notificationTitle.textContent = `📍 ${locationFoundText}`;
notificationSubtitle.textContent = `${locationName} (${distance.toFixed(1)} ${distanceText})`;
// Ensure notification is above sticky header
notificationBubble.style.zIndex = '100000';
// Check if we're on mobile and adjust position
if (window.innerWidth <= 768) {
notificationBubble.style.top = '5rem'; // Below sticky header on mobile
} else {
notificationBubble.style.top = '2rem'; // Normal position on desktop
}
// Show notification
notificationBubble.classList.remove('hide');
notificationBubble.classList.add('show');
@@ -355,6 +396,16 @@ function showLocationError(message) {
notificationTitle.textContent = `${errorText}`;
notificationSubtitle.textContent = message;
// Ensure notification is above sticky header
notificationBubble.style.zIndex = '100000';
// Check if we're on mobile and adjust position
if (window.innerWidth <= 768) {
notificationBubble.style.top = '5rem'; // Below sticky header on mobile
} else {
notificationBubble.style.top = '2rem'; // Normal position on desktop
}
// Show notification
notificationBubble.classList.remove('hide');
notificationBubble.classList.add('show');

View File

@@ -17,28 +17,60 @@ async function checkAuth() {
}
}
// Check if device is iOS
function isIOS() {
return /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
}
// Google OAuth Sign In
async function signInWithGoogle() {
try {
setLoading(true);
clearMessage();
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: `${window.location.origin}/auth/callback`
// iOS-specific handling
if (isIOS()) {
// For iOS, use a different approach with popup
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: `${window.location.origin}/auth/callback`,
queryParams: {
access_type: 'offline',
prompt: 'consent',
},
skipBrowserRedirect: true // Important for iOS
}
});
if (error) {
console.error('Google OAuth error:', error);
showMessage('Fehler bei der Google-Anmeldung: ' + error.message, 'error');
setLoading(false);
return;
}
if (data.url) {
// Open in same window for iOS
window.location.href = data.url;
}
} else {
// Standard handling for other devices
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: `${window.location.origin}/auth/callback`
}
});
if (error) {
console.error('Google OAuth error:', error);
showMessage('Fehler bei der Google-Anmeldung: ' + error.message, 'error');
}
});
if (error) {
console.error('Google OAuth error:', error);
showMessage('Fehler bei der Google-Anmeldung: ' + error.message, 'error');
}
// Note: OAuth redirects the page, so we don't need to handle success here
} catch (error) {
console.error('Google OAuth error:', error);
showMessage('Fehler bei der Google-Anmeldung: ' + error.message, 'error');
} finally {
setLoading(false);
}
}

View File

@@ -31,7 +31,7 @@
"name": "Dashboard",
"short_name": "Dashboard",
"description": "Öffne dein Dashboard",
"url": "/index.html",
"url": "/dashboard.html",
"icons": [
{
"src": "/pictures/icon-192.png",
@@ -39,5 +39,17 @@
}
]
}
]
],
"screenshots": [
{
"src": "/pictures/screenshot-mobile.png",
"sizes": "390x844",
"type": "image/png",
"form_factor": "narrow"
}
],
"related_applications": [],
"edge_side_panel": {
"preferred_width": 400
}
}

View File

@@ -2,22 +2,65 @@
const CACHE_NAME = 'ninjacross-v1';
const urlsToCache = [
'/',
'/index.html',
'/css/leaderboard.css',
'/js/leaderboard.js',
'/pictures/favicon.ico'
'/test-push.html',
'/sw.js'
];
// Install event
self.addEventListener('install', function(event) {
console.log('Service Worker installing...');
event.waitUntil(
caches.open(CACHE_NAME)
.then(function(cache) {
return cache.addAll(urlsToCache);
// Add files one by one to handle failures gracefully
return Promise.allSettled(
urlsToCache.map(url =>
cache.add(url).catch(err => {
console.warn(`Failed to cache ${url}:`, err);
return null; // Continue with other files
})
)
);
})
.then(() => {
console.log('Service Worker installation completed');
// Skip waiting to activate immediately
return self.skipWaiting();
})
.catch(err => {
console.error('Service Worker installation failed:', err);
// Still try to skip waiting
return self.skipWaiting();
})
);
});
// Activate event
self.addEventListener('activate', function(event) {
console.log('Service Worker activating...');
event.waitUntil(
caches.keys().then(function(cacheNames) {
return Promise.all(
cacheNames.map(function(cacheName) {
if (cacheName !== CACHE_NAME) {
return caches.delete(cacheName);
}
})
);
}).then(() => {
// Take control of all clients immediately
return self.clients.claim();
})
);
});
// Listen for skip waiting message
self.addEventListener('message', function(event) {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
// Fetch event
self.addEventListener('fetch', function(event) {
event.respondWith(

View File

@@ -105,6 +105,7 @@
<input type="text" id="testMessage" placeholder="Test-Nachricht" value="Das ist eine Test-Push-Notification!">
<button onclick="sendTestPush()">Test-Push senden</button>
<button onclick="sendTestWebNotification()">Web-Notification senden</button>
<button onclick="sendWindowsNotification()">Windows-Notification senden</button>
</div>
<div>
@@ -120,6 +121,22 @@
<script>
let currentSubscription = null;
// Convert VAPID key from base64url to Uint8Array
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/\-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
function log(message, type = 'info') {
const logDiv = document.getElementById('log');
const timestamp = new Date().toLocaleTimeString();
@@ -155,14 +172,19 @@
async function registerServiceWorker() {
if ('serviceWorker' in navigator) {
try {
log('Service Worker wird registriert...', 'info');
const registration = await navigator.serviceWorker.register('/sw.js');
log('Service Worker erfolgreich registriert', 'success');
log(`SW Scope: ${registration.scope}`);
updateStatus('Service Worker registriert', 'success');
} catch (error) {
log(`Service Worker Registrierung fehlgeschlagen: ${error.message}`, 'error');
log(`Error Details: ${JSON.stringify(error)}`, 'error');
updateStatus(`Service Worker Registrierung fehlgeschlagen: ${error.message}`, 'error');
}
} else {
log('Service Worker nicht unterstützt', 'error');
updateStatus('Service Worker nicht unterstützt', 'error');
}
}
@@ -189,16 +211,115 @@
// Push Subscription Functions
async function subscribeToPush() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
log('Push Subscription gestartet...', 'info');
// Check basic requirements
if (!('serviceWorker' in navigator)) {
log('Service Worker nicht unterstützt', 'error');
updateStatus('Service Worker nicht unterstützt', 'error');
return;
}
if (!('PushManager' in window)) {
log('Push Manager nicht unterstützt', 'error');
updateStatus('Push Manager nicht unterstützt', 'error');
return;
}
// Check notification permission first
if (Notification.permission !== 'granted') {
log('Notification Permission nicht erteilt. Bitte zuerst "Berechtigung anfordern" klicken!', 'error');
updateStatus('Notification Permission erforderlich', 'error');
return;
}
try {
const registration = await navigator.serviceWorker.ready;
log('Service Worker wird geladen...', 'info');
// First check if service worker is already registered
let registration;
const existingRegistrations = await navigator.serviceWorker.getRegistrations();
if (existingRegistrations.length > 0) {
log('Service Worker bereits registriert, verwende bestehende...', 'info');
registration = existingRegistrations[0];
} else {
log('Service Worker nicht registriert, registriere jetzt...', 'info');
registration = await navigator.serviceWorker.register('/sw.js');
log('Service Worker registriert', 'success');
}
// Wait for service worker to be ready with timeout
log('Warte auf Service Worker ready...', 'info');
// Check if service worker is active
if (registration.active) {
log('Service Worker ist bereits aktiv', 'success');
} else if (registration.installing && registration.installing.state) {
log('Service Worker wird installiert, warte...', 'info');
await new Promise((resolve) => {
const installingWorker = registration.installing;
if (installingWorker) {
installingWorker.addEventListener('statechange', () => {
if (installingWorker.state === 'installed') {
resolve();
}
});
} else {
resolve();
}
});
log('Service Worker Installation abgeschlossen', 'success');
} else if (registration.waiting && registration.waiting.state) {
log('Service Worker wartet, aktiviere...', 'info');
registration.waiting.postMessage({ type: 'SKIP_WAITING' });
await new Promise((resolve) => {
const waitingWorker = registration.waiting;
if (waitingWorker) {
waitingWorker.addEventListener('statechange', () => {
if (waitingWorker.state === 'activated') {
resolve();
}
});
} else {
resolve();
}
});
log('Service Worker aktiviert', 'success');
} else {
log('Service Worker Status unbekannt, warte auf ready...', 'info');
try {
await navigator.serviceWorker.ready;
log('Service Worker bereit', 'success');
} catch (error) {
log(`Service Worker ready fehlgeschlagen: ${error.message}`, 'error');
throw error;
}
}
// Convert VAPID key from base64url to ArrayBuffer
const vapidPublicKey = 'BJmNVx0C3XeVxeKGTP9c-Z4HcuZNmdk6QdiLocZgCmb-miCS0ESFO3W2TvJlRhhNAShV63pWA5p36BTVSetyTds';
log('VAPID Key wird konvertiert...', 'info');
let applicationServerKey;
try {
applicationServerKey = urlBase64ToUint8Array(vapidPublicKey);
log('VAPID Key konvertiert', 'success');
} catch (error) {
log(`VAPID Key Konvertierung fehlgeschlagen: ${error.message}`, 'error');
throw error;
}
log('Push Subscription wird erstellt...', 'info');
// Check if push manager is available
if (!registration.pushManager) {
throw new Error('Push Manager nicht verfügbar in diesem Service Worker');
}
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: 'BJmNVx0C3XeVxeKGTP9c-Z4HcuZNmdk6QdiLocZgCmb-miCS0ESFO3W2TvJlRhhNAShV63pWA5p36BTVSetyTds'
applicationServerKey: applicationServerKey
});
currentSubscription = subscription;
@@ -206,6 +327,7 @@
log(`Endpoint: ${subscription.endpoint.substring(0, 50)}...`);
// Send to server
log('Subscription wird an Server gesendet...', 'info');
const response = await fetch('/api/v1/public/subscribe', {
method: 'POST',
headers: {
@@ -214,17 +336,32 @@
body: JSON.stringify(subscription)
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result = await response.json();
if (result.success) {
log('Subscription erfolgreich an Server gesendet', 'success');
log(`Player ID: ${result.playerId || 'anonymous'}`, 'success');
// Store the player ID for later use
if (result.playerId) {
localStorage.setItem('pushPlayerId', result.playerId);
}
updateStatus('Push Subscription erfolgreich!', 'success');
// Store the subscription endpoint for later use
localStorage.setItem('pushSubscriptionEndpoint', subscription.endpoint);
} else {
log(`Server-Fehler: ${result.message}`, 'error');
updateStatus(`Server-Fehler: ${result.message}`, 'error');
}
} catch (error) {
log(`Push Subscription fehlgeschlagen: ${error.message}`, 'error');
log(`Error Details: ${JSON.stringify(error)}`, 'error');
updateStatus(`Push Subscription fehlgeschlagen: ${error.message}`, 'error');
}
}
@@ -243,42 +380,53 @@
}
async function checkSubscription() {
if ('serviceWorker' in navigator) {
try {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
currentSubscription = subscription;
log('Aktive Push Subscription gefunden', 'success');
log(`Endpoint: ${subscription.endpoint.substring(0, 50)}...`);
} else {
log('Keine Push Subscription gefunden', 'warning');
}
} catch (error) {
log(`Subscription Check fehlgeschlagen: ${error.message}`, 'error');
log('Überprüfe Push Subscription...', 'info');
if (!('serviceWorker' in navigator)) {
log('Service Worker nicht unterstützt', 'error');
return;
}
try {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
currentSubscription = subscription;
log('Aktive Push Subscription gefunden', 'success');
log(`Endpoint: ${subscription.endpoint.substring(0, 50)}...`);
updateStatus('Push Subscription aktiv', 'success');
} else {
log('Keine Push Subscription gefunden', 'warning');
updateStatus('Keine Push Subscription gefunden', 'warning');
}
} catch (error) {
log(`Subscription Check fehlgeschlagen: ${error.message}`, 'error');
updateStatus(`Subscription Check fehlgeschlagen: ${error.message}`, 'error');
}
}
// Test Functions
async function sendTestPush() {
const message = document.getElementById('testMessage').value;
log('Test-Push wird gesendet...', 'info');
// First check if we have a subscription
if (!currentSubscription) {
log('Keine Push Subscription gefunden. Bitte zuerst "Push abonnieren" klicken!', 'error');
updateStatus('Keine Push Subscription gefunden', 'error');
return;
}
// Use the stored subscription endpoint as identifier
const storedEndpoint = localStorage.getItem('pushSubscriptionEndpoint');
// Use the stored player ID from subscription
const storedPlayerId = localStorage.getItem('pushPlayerId');
let userId = 'test-user';
if (storedEndpoint) {
// Use the endpoint as a unique identifier
userId = storedEndpoint.split('/').pop().substring(0, 8);
if (storedPlayerId) {
userId = storedPlayerId;
}
log(`Sende Test-Push an Player ID: ${userId}`, 'info');
try {
const response = await fetch('/api/v1/public/test-push', {
method: 'POST',
@@ -295,11 +443,14 @@
if (result.success) {
log('Test-Push erfolgreich gesendet', 'success');
log(`An User ID: ${userId}`, 'success');
updateStatus('Test-Push erfolgreich gesendet!', 'success');
} else {
log(`Test-Push fehlgeschlagen: ${result.message}`, 'error');
updateStatus(`Test-Push fehlgeschlagen: ${result.message}`, 'error');
}
} catch (error) {
log(`Test-Push Fehler: ${error.message}`, 'error');
updateStatus(`Test-Push Fehler: ${error.message}`, 'error');
}
}
@@ -309,7 +460,10 @@
const notification = new Notification('🧪 Test Web Notification', {
body: message,
icon: '/pictures/icon-192.png',
badge: '/pictures/icon-192.png'
badge: '/pictures/icon-192.png',
tag: 'test-notification',
requireInteraction: true,
silent: false
});
notification.onclick = function() {
@@ -317,12 +471,52 @@
notification.close();
};
// Auto-close after 10 seconds
setTimeout(() => {
notification.close();
}, 10000);
log('Web-Notification gesendet', 'success');
} else {
log('Web-Notifications nicht verfügbar oder nicht erlaubt', 'error');
}
}
// Windows Desktop Notification (falls verfügbar)
function sendWindowsNotification() {
if ('Notification' in window && Notification.permission === 'granted') {
const message = document.getElementById('testMessage').value;
// Erstelle eine Windows-ähnliche Notification
const notification = new Notification('🏆 Ninja Cross - Achievement!', {
body: message,
icon: '/pictures/icon-192.png',
badge: '/pictures/icon-192.png',
tag: 'ninja-cross-achievement',
requireInteraction: true,
silent: false,
data: {
type: 'achievement',
timestamp: Date.now()
}
});
notification.onclick = function() {
window.focus();
notification.close();
};
// Auto-close after 15 seconds
setTimeout(() => {
notification.close();
}, 15000);
log('Windows-ähnliche Notification gesendet', 'success');
} else {
log('Web-Notifications nicht verfügbar oder nicht erlaubt', 'error');
}
}
async function getPushStatus() {
try {
const response = await fetch('/api/v1/public/push-status');
@@ -340,11 +534,27 @@
// Initialize
window.addEventListener('load', function() {
console.log('Push Notification Test Seite geladen');
log('Push Notification Test Seite geladen');
// Check if we're on HTTPS
if (location.protocol !== 'https:' && location.hostname !== 'localhost') {
log('WARNUNG: Push Notifications funktionieren nur über HTTPS!', 'error');
updateStatus('HTTPS erforderlich für Push Notifications', 'error');
} else {
log('HTTPS-Verbindung erkannt - Push Notifications möglich', 'success');
}
checkServiceWorker();
checkPermission();
checkSubscription();
});
// Also initialize on DOMContentLoaded as backup
document.addEventListener('DOMContentLoaded', function() {
console.log('DOM Content Loaded');
log('DOM Content Loaded - Initialisierung gestartet');
});
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -97,22 +97,17 @@ router.get('/times-with-details', async (req, res) => {
}
// Get all times with player and location details, ordered by time (fastest first)
// SECURITY: Only return data needed for leaderboard display
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
'lastname', p.lastname
) as player,
json_build_object(
'id', l.id,
'name', l.name,
'latitude', l.latitude,
'longitude', l.longitude
'name', l.name
) as location
FROM times t
LEFT JOIN players p ON t.player_id = p.id

View File

@@ -0,0 +1,285 @@
const { Pool } = require('pg');
const webpush = require('web-push');
// Database connection
const pool = new Pool({
user: process.env.DB_USER || 'postgres',
host: process.env.DB_HOST || 'localhost',
database: process.env.DB_NAME || 'ninjacross',
password: process.env.DB_PASSWORD || 'postgres',
port: process.env.DB_PORT || 5432,
});
// VAPID keys (same as push-service.js)
const vapidKeys = {
publicKey: 'BJmNVx0C3XeVxeKGTP9c-Z4HcuZNmdk6QdiLocZgCmb-miCS0ESFO3W2TvJlRhhNAShV63pWA5p36BTVSetyTds',
privateKey: 'HBdRCtmZUAzsWpVjZ2LDaoWliIPHldAb5ExAt8bvDeg'
};
webpush.setVapidDetails(
'mailto:admin@ninjacross.com',
vapidKeys.publicKey,
vapidKeys.privateKey
);
/**
* Send push notification to a specific player
*/
async function sendPushToPlayer(playerId, title, message, data = {}) {
try {
console.log(`📱 Sending push to player ${playerId}: ${title}`);
const result = await pool.query(`
SELECT endpoint, p256dh, auth
FROM player_subscriptions
WHERE player_id = $1
`, [playerId]);
if (result.rows.length === 0) {
console.log(`❌ No subscription found for player ${playerId}`);
return { success: false, reason: 'No subscription' };
}
// Send to all subscriptions for this player
const results = [];
for (const row of result.rows) {
const subscription = {
endpoint: row.endpoint,
keys: {
p256dh: row.p256dh,
auth: row.auth
}
};
const payload = JSON.stringify({
title: title,
body: message,
icon: '/pictures/favicon.ico',
badge: '/pictures/favicon.ico',
data: {
url: '/dashboard.html',
timestamp: Date.now(),
...data
},
actions: [
{
action: 'view',
title: 'Dashboard öffnen'
}
]
});
try {
await webpush.sendNotification(subscription, payload);
results.push({ success: true });
} catch (error) {
console.error(`❌ Error sending to subscription:`, error);
results.push({ success: false, error: error.message });
}
}
const successCount = results.filter(r => r.success).length;
console.log(`✅ Push notification sent to ${successCount}/${result.rows.length} subscriptions for player ${playerId}`);
return {
success: successCount > 0,
sent: successCount,
total: result.rows.length
};
} catch (error) {
console.error(`❌ Error sending push to player ${playerId}:`, error);
return { success: false, error: error.message };
}
}
/**
* Send achievement notification to player
*/
async function sendAchievementNotification(playerId, achievement) {
const title = `🏆 ${achievement.name}`;
const message = achievement.description;
return await sendPushToPlayer(playerId, title, message, {
type: 'achievement',
achievementId: achievement.id,
points: achievement.points
});
}
/**
* Send best time notification to player
*/
async function sendBestTimeNotification(playerId, timeType, locationName, time) {
const title = `🏁 ${timeType} Bestzeit!`;
const message = `Du hast die beste Zeit in ${locationName} mit ${time} erreicht!`;
return await sendPushToPlayer(playerId, title, message, {
type: 'best_time',
timeType: timeType,
location: locationName,
time: time
});
}
/**
* Send daily summary notification to player
*/
async function sendDailySummaryNotification(playerId, summary) {
const title = `📊 Dein Tagesrückblick`;
const message = `Du hattest ${summary.runs} Läufe heute. Beste Zeit: ${summary.bestTime}`;
return await sendPushToPlayer(playerId, title, message, {
type: 'daily_summary',
runs: summary.runs,
bestTime: summary.bestTime
});
}
/**
* Send weekly summary notification to player
*/
async function sendWeeklySummaryNotification(playerId, summary) {
const title = `📈 Deine Wochenzusammenfassung`;
const message = `Diese Woche: ${summary.runs} Läufe, ${summary.improvement}% Verbesserung!`;
return await sendPushToPlayer(playerId, title, message, {
type: 'weekly_summary',
runs: summary.runs,
improvement: summary.improvement
});
}
/**
* Send notification to all logged in players
*/
async function sendNotificationToAllPlayers(title, message, data = {}) {
try {
console.log(`📢 Sending notification to all players: ${title}`);
const result = await pool.query(`
SELECT ps.player_id, ps.endpoint, ps.p256dh, ps.auth, p.firstname, p.lastname
FROM player_subscriptions ps
JOIN players p ON ps.player_id = p.id
WHERE ps.player_id IS NOT NULL
`);
if (result.rows.length === 0) {
console.log('❌ No player subscriptions found');
return { success: false, reason: 'No subscriptions' };
}
const promises = result.rows.map(async (row) => {
const subscription = {
endpoint: row.endpoint,
keys: {
p256dh: row.p256dh,
auth: row.auth
}
};
const payload = JSON.stringify({
title: title,
body: message,
icon: '/pictures/favicon.ico',
badge: '/pictures/favicon.ico',
data: {
url: '/dashboard.html',
timestamp: Date.now(),
...data
}
});
try {
await webpush.sendNotification(subscription, payload);
return {
playerId: row.player_id,
playerName: `${row.firstname} ${row.lastname}`,
success: true
};
} catch (error) {
console.error(`❌ Error sending to player ${row.player_id}:`, error);
return {
playerId: row.player_id,
playerName: `${row.firstname} ${row.lastname}`,
success: false,
error: error.message
};
}
});
const results = await Promise.all(promises);
const successCount = results.filter(r => r.success).length;
console.log(`✅ Sent notifications to ${successCount}/${results.length} players`);
return {
success: true,
sent: successCount,
total: results.length,
results: results
};
} catch (error) {
console.error('❌ Error sending notifications to all players:', error);
return { success: false, error: error.message };
}
}
/**
* Get player statistics for notifications
*/
async function getPlayerStats(playerId) {
try {
const result = await pool.query(`
SELECT
p.firstname,
p.lastname,
COUNT(t.id) as total_runs,
MIN(t.recorded_time) as best_time,
COUNT(DISTINCT t.location_id) as locations_visited
FROM players p
LEFT JOIN times t ON p.id = t.player_id
WHERE p.id = $1
GROUP BY p.id, p.firstname, p.lastname
`, [playerId]);
if (result.rows.length === 0) {
return null;
}
return result.rows[0];
} catch (error) {
console.error('Error getting player stats:', error);
return null;
}
}
/**
* Check if player has push notifications enabled
*/
async function isPlayerPushEnabled(playerId) {
try {
const result = await pool.query(`
SELECT COUNT(*) as count
FROM player_subscriptions
WHERE player_id = $1
`, [playerId]);
return result.rows[0].count > 0;
} catch (error) {
console.error('Error checking push status:', error);
return false;
}
}
module.exports = {
sendPushToPlayer,
sendAchievementNotification,
sendBestTimeNotification,
sendDailySummaryNotification,
sendWeeklySummaryNotification,
sendNotificationToAllPlayers,
getPlayerStats,
isPlayerPushEnabled
};

View File

@@ -0,0 +1,134 @@
#!/usr/bin/env node
/**
* Fix Player Subscriptions Script
*
* This script fixes the player_subscriptions table by:
* 1. Identifying orphaned subscriptions (UUIDs that don't match any player)
* 2. Optionally migrating them to real player IDs
* 3. Cleaning up invalid subscriptions
*/
const { Pool } = require('pg');
// Database connection
const pool = new Pool({
user: process.env.DB_USER || 'postgres',
host: process.env.DB_HOST || 'localhost',
database: process.env.DB_NAME || 'ninjacross',
password: process.env.DB_PASSWORD || 'postgres',
port: process.env.DB_PORT || 5432,
});
async function fixPlayerSubscriptions() {
console.log('🔧 Starting Player Subscriptions Fix...\n');
try {
// 1. Find orphaned subscriptions (UUIDs that don't match any player)
console.log('📊 Step 1: Finding orphaned subscriptions...');
const orphanedQuery = `
SELECT ps.player_id, ps.created_at, ps.endpoint
FROM player_subscriptions ps
LEFT JOIN players p ON ps.player_id = p.id
WHERE p.id IS NULL
`;
const orphanedResult = await pool.query(orphanedQuery);
console.log(`Found ${orphanedResult.rows.length} orphaned subscriptions`);
if (orphanedResult.rows.length > 0) {
console.log('\n📋 Orphaned subscriptions:');
orphanedResult.rows.forEach((sub, index) => {
console.log(` ${index + 1}. Player ID: ${sub.player_id}`);
console.log(` Created: ${sub.created_at}`);
console.log(` Endpoint: ${sub.endpoint.substring(0, 50)}...`);
});
}
// 2. Find players without subscriptions
console.log('\n📊 Step 2: Finding players without subscriptions...');
const playersWithoutSubsQuery = `
SELECT p.id, p.firstname, p.lastname, p.supabase_user_id
FROM players p
LEFT JOIN player_subscriptions ps ON p.id = ps.player_id
WHERE ps.player_id IS NULL
`;
const playersWithoutSubsResult = await pool.query(playersWithoutSubsQuery);
console.log(`Found ${playersWithoutSubsResult.rows.length} players without subscriptions`);
if (playersWithoutSubsResult.rows.length > 0) {
console.log('\n📋 Players without subscriptions:');
playersWithoutSubsResult.rows.forEach((player, index) => {
console.log(` ${index + 1}. ${player.firstname} ${player.lastname} (${player.id})`);
console.log(` Supabase User ID: ${player.supabase_user_id || 'None'}`);
});
}
// 3. Show current subscription statistics
console.log('\n📊 Step 3: Current subscription statistics...');
const statsQuery = `
SELECT
COUNT(*) as total_subscriptions,
COUNT(DISTINCT ps.player_id) as unique_player_ids,
COUNT(p.id) as linked_to_players,
COUNT(*) - COUNT(p.id) as orphaned_count
FROM player_subscriptions ps
LEFT JOIN players p ON ps.player_id = p.id
`;
const statsResult = await pool.query(statsQuery);
const stats = statsResult.rows[0];
console.log(`Total subscriptions: ${stats.total_subscriptions}`);
console.log(`Unique player IDs: ${stats.unique_player_ids}`);
console.log(`Linked to real players: ${stats.linked_to_players}`);
console.log(`Orphaned subscriptions: ${stats.orphaned_count}`);
// 4. Ask user what to do
console.log('\n🔧 Step 4: What would you like to do?');
console.log('1. Clean up orphaned subscriptions (DELETE)');
console.log('2. Keep orphaned subscriptions (no action)');
console.log('3. Show detailed analysis only');
// For now, just show the analysis
console.log('\n✅ Analysis complete. No changes made.');
console.log('\n💡 Recommendations:');
console.log('- Orphaned subscriptions should be cleaned up');
console.log('- New subscriptions will now use real player IDs');
console.log('- Existing valid subscriptions will continue to work');
} catch (error) {
console.error('❌ Error fixing player subscriptions:', error);
} finally {
await pool.end();
}
}
// Clean up orphaned subscriptions
async function cleanupOrphanedSubscriptions() {
console.log('🧹 Cleaning up orphaned subscriptions...');
try {
const deleteQuery = `
DELETE FROM player_subscriptions
WHERE player_id NOT IN (SELECT id FROM players)
`;
const result = await pool.query(deleteQuery);
console.log(`✅ Deleted ${result.rowCount} orphaned subscriptions`);
} catch (error) {
console.error('❌ Error cleaning up subscriptions:', error);
}
}
// Run the script
if (require.main === module) {
fixPlayerSubscriptions();
}
module.exports = {
fixPlayerSubscriptions,
cleanupOrphanedSubscriptions
};

View File

@@ -28,12 +28,6 @@ async function storePlayerSubscription(playerId, subscription) {
await pool.query(`
INSERT INTO player_subscriptions (player_id, endpoint, p256dh, auth)
VALUES ($1, $2, $3, $4)
ON CONFLICT (player_id)
DO UPDATE SET
endpoint = EXCLUDED.endpoint,
p256dh = EXCLUDED.p256dh,
auth = EXCLUDED.auth,
updated_at = NOW()
`, [
playerId,
subscription.endpoint,

View File

@@ -54,8 +54,15 @@ const io = new Server(server, {
// CORS Configuration - Allow all origins for development
app.use((req, res, next) => {
// Allow all origins
res.setHeader('Access-Control-Allow-Origin', '*');
// Allow specific origins when credentials are needed
const origin = req.headers.origin;
if (origin && (origin.includes('ninja.reptilfpv.de') || origin.includes('localhost') || origin.includes('127.0.0.1'))) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true'); // Allow cookies
} else {
res.setHeader('Access-Control-Allow-Origin', '*');
}
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS, PATCH');
res.setHeader('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization, X-API-Key');
res.setHeader('Access-Control-Max-Age', '86400'); // 24 hours
@@ -81,7 +88,8 @@ app.use(session({
cookie: {
secure: false, // Set to true when using HTTPS
maxAge: 24 * 60 * 60 * 1000, // 24 hours
httpOnly: true // Security: prevent XSS attacks
httpOnly: true, // Security: prevent XSS attacks
sameSite: 'lax' // Allow cookies in cross-origin requests
}
}));

752
wiki/API-Referenz.md Normal file
View File

@@ -0,0 +1,752 @@
# 📡 API Referenz
Vollständige Dokumentation der REST API des Ninja Cross Parkour Systems.
## 📋 Inhaltsverzeichnis
- [🔐 Authentifizierung](#-authentifizierung)
- [🌐 Public API](#-public-api)
- [🔒 Private API](#-private-api)
- [🖥️ Web API](#-web-api)
- [👑 Admin API](#-admin-api)
- [🏆 Achievements API](#-achievements-api)
- [📊 Datenmodelle](#-datenmodelle)
- [❌ Fehlerbehandlung](#-fehlerbehandlung)
## 🔐 Authentifizierung
### API-Key Authentifizierung
Für private Endpoints wird ein API-Key im Authorization Header benötigt:
```http
Authorization: Bearer YOUR_API_KEY_HERE
```
### Session-basierte Authentifizierung
Für Web-Endpoints wird eine Session-basierte Authentifizierung verwendet.
### Admin-Authentifizierung
Für Admin-Endpoints wird eine erweiterte Authentifizierung mit Admin-Rechten benötigt.
## 🌐 Public API
Öffentliche Endpoints ohne Authentifizierung.
### 🔑 Authentifizierung
#### Login
```http
POST /api/v1/public/login
Content-Type: application/json
{
"username": "admin",
"password": "admin123"
}
```
**Response:**
```json
{
"success": true,
"message": "Login erfolgreich",
"user": {
"id": 1,
"username": "admin",
"is_active": true
}
}
```
#### Logout
```http
POST /api/v1/public/logout
```
**Response:**
```json
{
"success": true,
"message": "Logout erfolgreich"
}
```
### 👥 Spieler Management
#### Spieler erstellen
```http
POST /api/v1/public/players
Content-Type: application/json
{
"firstname": "Max",
"lastname": "Mustermann",
"birthdate": "1990-01-01",
"rfiduid": "AA:BB:CC:DD"
}
```
**Response:**
```json
{
"success": true,
"data": {
"id": "uuid",
"firstname": "Max",
"lastname": "Mustermann",
"birthdate": "1990-01-01",
"rfiduid": "AA:BB:CC:DD",
"created_at": "2024-01-01T00:00:00Z"
}
}
```
#### Spieler verknüpfen
```http
POST /api/v1/public/link-player
Content-Type: application/json
{
"rfiduid": "AA:BB:CC:DD",
"supabase_user_id": "uuid-here"
}
```
#### Spieler per RFID verknüpfen
```http
POST /api/v1/public/link-by-rfid
Content-Type: application/json
{
"rfiduid": "AA:BB:CC:DD",
"supabase_user_id": "uuid-here"
}
```
### 📍 Standorte
#### Alle Standorte abrufen
```http
GET /api/v1/public/locations
```
**Response:**
```json
{
"success": true,
"data": [
{
"id": "uuid",
"name": "Standort 1",
"latitude": 48.1351,
"longitude": 11.5820,
"time_threshold": {
"seconds": 120
},
"created_at": "2024-01-01T00:00:00Z"
}
]
}
```
### ⏱️ Zeiten
#### Alle Zeiten abrufen
```http
GET /api/v1/public/times
```
#### Zeiten mit Details abrufen
```http
GET /api/v1/public/times-with-details
```
#### Beste Zeiten abrufen
```http
GET /api/v1/public/best-times
```
#### Benutzer-Zeiten abrufen
```http
GET /api/v1/public/user-times/{supabase_user_id}
```
#### Benutzer-Spieler abrufen
```http
GET /api/v1/public/user-player/{supabase_user_id}
```
### 📊 Statistiken
#### Seitenaufruf verfolgen
```http
POST /api/v1/public/track-page-view
Content-Type: application/json
{
"page": "/dashboard",
"user_agent": "Mozilla/5.0...",
"ip_address": "192.168.1.1"
}
```
### 🔔 Push Notifications
#### Push-Benachrichtigung abonnieren
```http
POST /api/v1/public/subscribe
Content-Type: application/json
{
"endpoint": "https://fcm.googleapis.com/fcm/send/...",
"keys": {
"p256dh": "key-here",
"auth": "auth-key-here"
}
}
```
#### Push-Benachrichtigung testen
```http
POST /api/v1/public/test-push
Content-Type: application/json
{
"title": "Test",
"body": "Test-Nachricht",
"icon": "/icon.png"
}
```
#### Push-Status abrufen
```http
GET /api/v1/public/push-status
```
## 🔒 Private API
Private Endpoints mit API-Key Authentifizierung.
### 🎫 Token Management
#### Token speichern
```http
POST /api/v1/private/save-token
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
{
"token": "GENERATED_TOKEN",
"description": "Beschreibung",
"standorte": "München, Berlin"
}
```
#### Alle Token abrufen
```http
GET /api/v1/private/tokens
Authorization: Bearer YOUR_API_KEY
```
#### Token validieren
```http
POST /api/v1/private/validate-token
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
{
"token": "TOKEN_TO_VALIDATE"
}
```
### 📍 Standort Management
#### Standort erstellen
```http
POST /api/v1/private/create-location
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
{
"name": "München",
"latitude": 48.1351,
"longitude": 11.5820
}
```
#### Alle Standorte abrufen
```http
GET /api/v1/private/locations
Authorization: Bearer YOUR_API_KEY
```
#### Standort-Schwelle aktualisieren
```http
PUT /api/v1/private/locations/{id}/threshold
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
{
"threshold_seconds": 120
}
```
### 👥 Spieler Management
#### Spieler erstellen
```http
POST /api/v1/private/create-player
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
{
"firstname": "Max",
"lastname": "Mustermann",
"birthdate": "1990-01-01",
"rfiduid": "AA:BB:CC:DD"
}
```
#### Benutzer suchen
```http
POST /api/v1/private/users/find
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
{
"search_term": "Max"
}
```
### ⏱️ Zeit Management
#### Zeit erstellen
```http
POST /api/v1/private/create-time
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
{
"player_id": "RFIDUID",
"location_id": "Name",
"recorded_time": "01:23.456"
}
```
## 🖥️ Web API
Web-API-Endpoints für das Frontend.
### 🔑 API-Key generieren
```http
POST /api/v1/web/generate-api-key
Content-Type: application/json
{
"description": "Mein API Key",
"standorte": "München, Berlin"
}
```
### 📍 Standort erstellen
```http
POST /api/v1/web/create-location
Content-Type: application/json
{
"name": "München",
"latitude": 48.1351,
"longitude": 11.5820
}
```
### 🎫 Token speichern
```http
POST /api/v1/web/save-token
Content-Type: application/json
{
"token": "GENERATED_TOKEN",
"description": "Beschreibung",
"standorte": "München, Berlin"
}
```
### 🔍 Session prüfen
```http
GET /api/v1/web/check-session
```
## 👑 Admin API
Admin-API-Endpoints für Verwaltung.
### 📊 Statistiken
#### Admin-Statistiken
```http
GET /api/v1/admin/stats
Authorization: Bearer ADMIN_TOKEN
```
#### Seiten-Statistiken
```http
GET /api/v1/admin/page-stats
Authorization: Bearer ADMIN_TOKEN
```
### 👥 Spieler Verwaltung
#### Alle Spieler abrufen
```http
GET /api/v1/admin/players
Authorization: Bearer ADMIN_TOKEN
```
#### Spieler erstellen
```http
POST /api/v1/admin/players
Authorization: Bearer ADMIN_TOKEN
Content-Type: application/json
{
"firstname": "Max",
"lastname": "Mustermann",
"birthdate": "1990-01-01",
"rfiduid": "AA:BB:CC:DD"
}
```
#### Spieler aktualisieren
```http
PUT /api/v1/admin/players/{id}
Authorization: Bearer ADMIN_TOKEN
Content-Type: application/json
{
"firstname": "Max",
"lastname": "Mustermann",
"birthdate": "1990-01-01",
"rfiduid": "AA:BB:CC:DD"
}
```
#### Spieler löschen
```http
DELETE /api/v1/admin/players/{id}
Authorization: Bearer ADMIN_TOKEN
```
### 🏃‍♂️ Läufe Verwaltung
#### Alle Läufe abrufen
```http
GET /api/v1/admin/runs
Authorization: Bearer ADMIN_TOKEN
```
#### Lauf abrufen
```http
GET /api/v1/admin/runs/{id}
Authorization: Bearer ADMIN_TOKEN
```
#### Lauf erstellen
```http
POST /api/v1/admin/runs
Authorization: Bearer ADMIN_TOKEN
Content-Type: application/json
{
"player_id": "uuid",
"location_id": "uuid",
"recorded_time": "01:23.456"
}
```
#### Lauf aktualisieren
```http
PUT /api/v1/admin/runs/{id}
Authorization: Bearer ADMIN_TOKEN
Content-Type: application/json
{
"recorded_time": "01:20.000"
}
```
#### Lauf löschen
```http
DELETE /api/v1/admin/runs/{id}
Authorization: Bearer ADMIN_TOKEN
```
### 📍 Standort Verwaltung
#### Alle Standorte abrufen
```http
GET /api/v1/admin/locations
Authorization: Bearer ADMIN_TOKEN
```
#### Standort erstellen
```http
POST /api/v1/admin/locations
Authorization: Bearer ADMIN_TOKEN
Content-Type: application/json
{
"name": "München",
"latitude": 48.1351,
"longitude": 11.5820,
"time_threshold": 120
}
```
#### Standort aktualisieren
```http
PUT /api/v1/admin/locations/{id}
Authorization: Bearer ADMIN_TOKEN
Content-Type: application/json
{
"name": "München Updated",
"latitude": 48.1351,
"longitude": 11.5820,
"time_threshold": 120
}
```
#### Standort löschen
```http
DELETE /api/v1/admin/locations/{id}
Authorization: Bearer ADMIN_TOKEN
```
### 👤 Admin-Benutzer Verwaltung
#### Alle Admin-Benutzer abrufen
```http
GET /api/v1/admin/adminusers
Authorization: Bearer ADMIN_TOKEN
```
#### Admin-Benutzer erstellen
```http
POST /api/v1/admin/adminusers
Authorization: Bearer ADMIN_TOKEN
Content-Type: application/json
{
"username": "newadmin",
"password": "securepassword"
}
```
#### Admin-Benutzer aktualisieren
```http
PUT /api/v1/admin/adminusers/{id}
Authorization: Bearer ADMIN_TOKEN
Content-Type: application/json
{
"username": "updatedadmin",
"is_active": true
}
```
#### Admin-Benutzer löschen
```http
DELETE /api/v1/admin/adminusers/{id}
Authorization: Bearer ADMIN_TOKEN
```
## 🏆 Achievements API
Achievement-System Endpoints.
### 🏆 Achievements abrufen
```http
GET /api/achievements
```
**Response:**
```json
{
"success": true,
"data": [
{
"id": "uuid",
"name": "Erster Lauf",
"description": "Absolviere deinen ersten Lauf",
"category": "consistency",
"condition_type": "runs_count",
"condition_value": 1,
"icon": "🏃‍♂️",
"points": 10,
"is_active": true
}
]
}
```
### 👤 Spieler-Achievements
```http
GET /api/achievements/player/{playerId}
```
### 📊 Spieler-Statistiken
```http
GET /api/achievements/player/{playerId}/stats
```
### ✅ Achievement prüfen
```http
POST /api/achievements/check/{playerId}
Content-Type: application/json
{
"achievement_id": "uuid"
}
```
### 📅 Tägliche Prüfung
```http
POST /api/achievements/daily-check
```
### 🏃‍♂️ Beste Zeit prüfen
```http
POST /api/achievements/best-time-check
```
### 🏅 Leaderboard
```http
GET /api/achievements/leaderboard
```
## 📊 Datenmodelle
### Player
```json
{
"id": "string (uuid)",
"firstname": "string",
"lastname": "string",
"birthdate": "string (date)",
"rfiduid": "string (XX:XX:XX:XX)",
"supabase_user_id": "string (uuid)",
"created_at": "string (date-time)"
}
```
### Time
```json
{
"id": "string (uuid)",
"player_id": "string (uuid)",
"location_id": "string (uuid)",
"recorded_time": {
"seconds": "number",
"minutes": "number",
"milliseconds": "number"
},
"created_at": "string (date-time)"
}
```
### Location
```json
{
"id": "string (uuid)",
"name": "string",
"latitude": "number (float)",
"longitude": "number (float)",
"time_threshold": {
"seconds": "number",
"minutes": "number"
},
"created_at": "string (date-time)"
}
```
### Achievement
```json
{
"id": "string (uuid)",
"name": "string",
"description": "string",
"category": "string (consistency|improvement|seasonal|monthly)",
"condition_type": "string",
"condition_value": "integer",
"icon": "string (emoji)",
"points": "integer",
"is_active": "boolean"
}
```
### PlayerAchievement
```json
{
"id": "string (uuid)",
"player_id": "string (uuid)",
"achievement_id": "string (uuid)",
"progress": "integer",
"is_completed": "boolean",
"earned_at": "string (date-time)"
}
```
## ❌ Fehlerbehandlung
### Standard-Fehlerantwort
```json
{
"success": false,
"message": "Fehlermeldung",
"error": "DETAILED_ERROR_INFO"
}
```
### HTTP-Status-Codes
- **200 OK** - Erfolgreiche Anfrage
- **201 Created** - Ressource erfolgreich erstellt
- **400 Bad Request** - Ungültige Anfrage
- **401 Unauthorized** - Nicht authentifiziert
- **403 Forbidden** - Keine Berechtigung
- **404 Not Found** - Ressource nicht gefunden
- **500 Internal Server Error** - Serverfehler
### Häufige Fehlermeldungen
- `"API-Key erforderlich"` - Fehlender oder ungültiger API-Key
- `"Ungültige Anmeldedaten"` - Falsche Login-Daten
- `"Ressource nicht gefunden"` - Angeforderte Ressource existiert nicht
- `"Ungültige Daten"` - Validierungsfehler bei Eingabedaten
- `"Keine Berechtigung"` - Unzureichende Rechte für die Aktion
## 🔧 Entwicklung
### Lokale Entwicklung
```bash
# Server starten
npm run dev
# API-Dokumentation anzeigen
# Swagger UI verfügbar unter: http://localhost:3000/api-docs
```
### Produktionsumgebung
```bash
# Server starten
npm start
# API-Dokumentation: https://ninja.reptilfpv.de/api-docs
```
---
**Version:** 1.0.0
**Base URL:** `https://ninja.reptilfpv.de/api`
**Autor:** Carsten Graf

479
wiki/Achievement-System.md Normal file
View File

@@ -0,0 +1,479 @@
# 🏆 Achievement System
Umfassende Dokumentation des Achievement-Systems für das Ninja Cross Parkour System.
## 📊 System-Übersicht
Das Achievement-System besteht aus:
- **32 verschiedene Achievements** in 4 Kategorien
- **Automatische tägliche Vergabe** am Ende des Tages
- **REST API Endpoints** für Frontend-Integration
- **PostgreSQL Funktionen** für effiziente Verarbeitung
## 🎯 Achievement-Kategorien
### 1. Konsistenz-basierte Achievements
- **Erste Schritte** 👶 - Erste Zeit aufgezeichnet (5 Punkte)
- **Durchhalter** 💪 - 3 Versuche an einem Tag (10 Punkte)
- **Fleißig** 🔥 - 5 Versuche an einem Tag (15 Punkte)
- **Besessen** 😤 - 10 Versuche an einem Tag (25 Punkte)
- **Regelmäßig** 📅 - 5 verschiedene Tage gespielt (20 Punkte)
- **Stammgast** ⭐ - 10 verschiedene Tage gespielt (30 Punkte)
- **Treue** 💎 - 20 verschiedene Tage gespielt (50 Punkte)
- **Veteran** 🏆 - 50 verschiedene Tage gespielt (100 Punkte)
### 2. Verbesserungs-basierte Achievements
- **Fortschritt** 📈 - Persönliche Bestzeit um 5 Sekunden verbessert (15 Punkte)
- **Durchbruch** ⚡ - Persönliche Bestzeit um 10 Sekunden verbessert (25 Punkte)
- **Transformation** 🔄 - Persönliche Bestzeit um 15 Sekunden verbessert (40 Punkte)
- **Perfektionist** ✨ - Persönliche Bestzeit um 20 Sekunden verbessert (60 Punkte)
### 3. Saisonale Achievements
- **Wochenend-Krieger** 🏁 - Am Wochenende gespielt (10 Punkte)
- **Nachmittags-Sportler** ☀️ - Zwischen 14-18 Uhr gespielt (10 Punkte)
- **Frühaufsteher** 🌅 - Vor 10 Uhr gespielt (15 Punkte)
- **Abend-Sportler** 🌙 - Nach 18 Uhr gespielt (10 Punkte)
### 4. Monatliche Achievements
- **Januar-Krieger** ❄️ bis **Dezember-Dynamo** 🎄 (je 20 Punkte)
### 5. Jahreszeiten-Achievements
- **Frühjahrs-Fighter** 🌱 - Im Frühling gespielt (30 Punkte)
- **Sommer-Sportler** ☀️ - Im Sommer gespielt (30 Punkte)
- **Herbst-Held** 🍂 - Im Herbst gespielt (30 Punkte)
- **Winter-Warrior** ❄️ - Im Winter gespielt (30 Punkte)
## 🗄️ Datenbank-Schema
### Tabelle: `achievements`
```sql
- id (uuid, PK)
- name (varchar) - Achievement-Name
- description (text) - Beschreibung
- category (varchar) - Kategorie
- condition_type (varchar) - Bedingungstyp
- condition_value (integer) - Bedingungswert
- icon (varchar) - Emoji-Icon
- points (integer) - Punkte
- is_active (boolean) - Aktiv
- created_at (timestamp)
```
### Tabelle: `player_achievements`
```sql
- id (uuid, PK)
- player_id (uuid, FK) - Verweis auf players.id
- achievement_id (uuid, FK) - Verweis auf achievements.id
- earned_at (timestamp) - Wann erreicht
- progress (integer) - Fortschritt
- is_completed (boolean) - Abgeschlossen
- created_at (timestamp)
```
## 🔧 PostgreSQL Funktionen
### `check_consistency_achievements(player_uuid)`
Überprüft alle Konsistenz-basierten Achievements für einen Spieler.
**Logik:**
- Zählt Gesamtläufe des Spielers
- Zählt Läufe pro Tag
- Zählt verschiedene Spieltage
- Vergibt entsprechende Achievements
### `check_improvement_achievements(player_uuid)`
Überprüft alle Verbesserungs-basierten Achievements für einen Spieler.
**Logik:**
- Ermittelt persönliche Bestzeit
- Berechnet Verbesserung seit erster Zeit
- Vergibt entsprechende Achievements
### `check_seasonal_achievements(player_uuid)`
Überprüft alle saisonalen und monatlichen Achievements für einen Spieler.
**Logik:**
- Prüft Wochentag (Wochenende)
- Prüft Tageszeit (morgens, nachmittags, abends)
- Prüft Monat (Januar bis Dezember)
- Prüft Jahreszeit (Frühling, Sommer, Herbst, Winter)
### `check_all_achievements(player_uuid)`
Führt alle Achievement-Überprüfungen für einen Spieler aus.
## 🚀 API Endpoints
### GET `/api/achievements`
Alle verfügbaren Achievements abrufen.
**Response:**
```json
{
"success": true,
"data": [
{
"id": "uuid",
"name": "Erste Schritte",
"description": "Absolviere deinen ersten Lauf",
"category": "consistency",
"condition_type": "runs_count",
"condition_value": 1,
"icon": "👶",
"points": 5,
"is_active": true
}
]
}
```
### GET `/api/achievements/player/:playerId`
Achievements eines bestimmten Spielers abrufen.
**Response:**
```json
{
"success": true,
"data": [
{
"id": "uuid",
"achievement_id": "uuid",
"name": "Erste Schritte",
"description": "Absolviere deinen ersten Lauf",
"icon": "👶",
"points": 5,
"progress": 1,
"is_completed": true,
"earned_at": "2024-01-01T00:00:00Z"
}
]
}
```
### GET `/api/achievements/player/:playerId/stats`
Achievement-Statistiken eines Spielers abrufen.
**Response:**
```json
{
"success": true,
"data": {
"total_achievements": 32,
"completed_achievements": 5,
"total_points": 150,
"earned_points": 75,
"completion_percentage": 15.6
}
}
```
### POST `/api/achievements/check/:playerId`
Achievements für einen Spieler manuell überprüfen.
**Request:**
```json
{
"achievement_id": "uuid"
}
```
### POST `/api/achievements/daily-check`
Tägliche Achievement-Überprüfung für alle Spieler ausführen.
**Response:**
```json
{
"success": true,
"message": "Daily achievement check completed",
"players_checked": 150,
"achievements_awarded": 25
}
```
### GET `/api/achievements/leaderboard?limit=10`
Bestenliste der Spieler nach Achievement-Punkten.
**Response:**
```json
{
"success": true,
"data": [
{
"player_id": "uuid",
"firstname": "Max",
"lastname": "Mustermann",
"total_points": 500,
"completed_achievements": 15,
"rank": 1
}
]
}
```
## 📅 Automatisierung
### Tägliches Script
```bash
# Manuell ausführen
node scripts/daily_achievements.js
# Cron-Job einrichten
node scripts/setup_cron.js setup
# Cron-Job Status prüfen
node scripts/setup_cron.js status
# Cron-Job entfernen
node scripts/setup_cron.js remove
```
### Cron-Schedule
- **Zeit**: Täglich um 23:59 Uhr
- **Log**: `/var/log/ninjaserver_achievements.log`
### Script-Details
```javascript
// scripts/daily_achievements.js
const { checkAllAchievements } = require('../models/Achievement');
async function dailyCheck() {
try {
// Alle Spieler abrufen
const players = await getAllPlayers();
let totalAwarded = 0;
for (const player of players) {
const awarded = await checkAllAchievements(player.id);
totalAwarded += awarded;
}
console.log(`Daily check completed: ${totalAwarded} achievements awarded`);
} catch (error) {
console.error('Daily check failed:', error);
}
}
```
## 🎮 Frontend-Integration
### Beispiel: Achievement-Liste laden
```javascript
async function loadAchievements(playerId) {
try {
const response = await fetch(`/api/achievements/player/${playerId}`);
const data = await response.json();
if (data.success) {
data.data.forEach(achievement => {
const status = achievement.is_completed ? '✅' : '❌';
console.log(`${achievement.icon} ${achievement.name}: ${status}`);
});
}
} catch (error) {
console.error('Error loading achievements:', error);
}
}
```
### Beispiel: Statistiken anzeigen
```javascript
async function loadStats(playerId) {
try {
const response = await fetch(`/api/achievements/player/${playerId}/stats`);
const data = await response.json();
if (data.success) {
const stats = data.data;
document.getElementById('total-points').textContent = stats.total_points;
document.getElementById('completed').textContent =
`${stats.completed_achievements}/${stats.total_achievements}`;
document.getElementById('percentage').textContent =
`${stats.completion_percentage}%`;
}
} catch (error) {
console.error('Error loading stats:', error);
}
}
```
### Beispiel: Achievement-Animation
```javascript
function showAchievementNotification(achievement) {
const notification = document.createElement('div');
notification.className = 'achievement-notification';
notification.innerHTML = `
<div class="achievement-icon">${achievement.icon}</div>
<div class="achievement-text">
<h3>${achievement.name}</h3>
<p>${achievement.description}</p>
<span class="points">+${achievement.points} Punkte</span>
</div>
`;
document.body.appendChild(notification);
// Animation
setTimeout(() => {
notification.classList.add('show');
}, 100);
// Entfernen nach 5 Sekunden
setTimeout(() => {
notification.remove();
}, 5000);
}
```
## 🔍 Monitoring
### Logs überwachen
```bash
# Live-Logs anzeigen
tail -f /var/log/ninjaserver_achievements.log
# Letzte Ausführung prüfen
grep "Daily achievement check completed" /var/log/ninjaserver_achievements.log | tail -1
# Fehler-Logs anzeigen
grep "ERROR" /var/log/ninjaserver_achievements.log
```
### Datenbank-Status prüfen
```sql
-- Achievement-Statistiken
SELECT
COUNT(*) as total_achievements,
COUNT(CASE WHEN is_active = true THEN 1 END) as active_achievements
FROM achievements;
-- Spieler-Statistiken
SELECT
COUNT(DISTINCT player_id) as players_with_achievements,
COUNT(*) as total_earned_achievements
FROM player_achievements
WHERE is_completed = true;
-- Top-Spieler
SELECT
p.firstname,
p.lastname,
COUNT(pa.id) as achievements,
SUM(a.points) as total_points
FROM players p
JOIN player_achievements pa ON p.id = pa.player_id
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
LIMIT 10;
```
## 🛠️ Wartung
### Neue Achievements hinzufügen
1. Achievement in `achievements` Tabelle einfügen:
```sql
INSERT INTO achievements (name, description, category, condition_type, condition_value, icon, points)
VALUES ('Neues Achievement', 'Beschreibung', 'consistency', 'runs_count', 5, '🏆', 25);
```
2. Logik in entsprechenden PostgreSQL Funktionen erweitern
3. API Endpoints testen
### Achievement deaktivieren
```sql
UPDATE achievements SET is_active = false WHERE name = 'Achievement-Name';
```
### Daten zurücksetzen
```sql
-- Alle Spieler-Achievements löschen
DELETE FROM player_achievements;
-- Achievement-Statistiken zurücksetzen
UPDATE achievements SET created_at = NOW();
```
### Achievement-Import/Export
```bash
# Export
pg_dump -t achievements -t player_achievements ninjaserver > achievements_backup.sql
# Import
psql ninjaserver < achievements_backup.sql
```
## 📈 Performance
### Indizierung
```sql
-- Performance-Indizes
CREATE INDEX CONCURRENTLY idx_player_achievements_player_id
ON player_achievements(player_id);
CREATE INDEX CONCURRENTLY idx_player_achievements_achievement_id
ON player_achievements(achievement_id);
CREATE INDEX CONCURRENTLY idx_player_achievements_completed
ON player_achievements(is_completed) WHERE is_completed = true;
```
### Batch-Processing
- **Effiziente Verarbeitung** aller Spieler in einem Durchgang
- **Transaktionale Sicherheit** für Datenkonsistenz
- **Fehlerbehandlung** für einzelne Spieler
### Caching
- **Achievement-Definitionen** werden gecacht
- **Spieler-Statistiken** werden bei Änderungen neu berechnet
- **Leaderboard** wird periodisch aktualisiert
### Zeitzone-Behandlung
- **Korrekte Zeitzone** (Europe/Berlin) für alle Zeitberechnungen
- **Saisonale Achievements** berücksichtigen lokale Zeit
- **Tägliche Prüfung** erfolgt zur richtigen Zeit
## 🔒 Sicherheit
### API-Schutz
- **Alle Endpoints** über bestehende Authentifizierung
- **Admin-Endpoints** erfordern erweiterte Berechtigung
- **Rate Limiting** für häufige Anfragen
### SQL-Injection
- **Parametrisierte Queries** in allen Funktionen
- **Input-Validierung** vor Datenbankzugriff
- **Escape-Funktionen** für dynamische Inhalte
### Datenvalidierung
- **Eingabe-Validierung** in allen API-Endpoints
- **Typ-Überprüfung** für alle Parameter
- **Bereichs-Validierung** für numerische Werte
### Fehlerbehandlung
- **Umfassende Error-Handling** in allen Funktionen
- **Logging** aller Fehler und Warnungen
- **Graceful Degradation** bei Systemfehlern
## 🎯 Best Practices
### Achievement-Design
- **Klare Bedingungen** für alle Achievements
- **Angemessene Punkte** basierend auf Schwierigkeit
- **Motivierende Beschreibungen** für Spieler
### Performance-Optimierung
- **Batch-Processing** für große Datenmengen
- **Indizierung** für häufige Abfragen
- **Caching** für statische Daten
### Wartbarkeit
- **Modulare Struktur** für einfache Erweiterungen
- **Dokumentation** aller Funktionen
- **Tests** für kritische Komponenten
---
**Erstellt am**: $(date)
**Version**: 1.0.0
**Autor**: Ninja Cross Parkour System

261
wiki/Benutzerhandbuch.md Normal file
View File

@@ -0,0 +1,261 @@
# 📖 Benutzerhandbuch
Anleitung für Endbenutzer des Ninja Cross Parkour Systems.
## 🎯 Übersicht
Das Ninja Cross Parkour System ermöglicht es Schwimmbadbesuchern, ihre Parkour-Zeiten zu messen, zu verfolgen und sich mit anderen zu vergleichen.
## 🚀 Erste Schritte
### 1. Registrierung
1. Öffnen Sie das Web-Interface
2. Klicken Sie auf "Registrieren"
3. Füllen Sie das Formular aus:
- Vorname
- Nachname
- Geburtsdatum
- RFID-Karten-ID (falls vorhanden)
### 2. RFID-Karte verknüpfen
Falls Sie eine RFID-Karte haben:
1. Melden Sie sich an
2. Gehen Sie zu "Mein Profil"
3. Klicken Sie auf "RFID-Karte verknüpfen"
4. Halten Sie Ihre Karte an den Reader
### 3. Erste Zeit messen
1. Wählen Sie einen Standort aus
2. Halten Sie Ihre RFID-Karte an den Start-Reader
3. Laufen Sie den Parkour
4. Halten Sie Ihre Karte an den Ziel-Reader
5. Ihre Zeit wird automatisch aufgezeichnet
## 🏠 Dashboard
### Übersicht
Das Dashboard zeigt:
- **Aktuelle Zeit** - Ihre letzte gemessene Zeit
- **Beste Zeit** - Ihr persönlicher Rekord
- **Achievements** - Ihre Erfolge
- **Statistiken** - Fortschritt und Trends
### Navigation
- **🏠 Home** - Dashboard und Übersicht
- **⏱️ Zeiten** - Alle Ihre gemessenen Zeiten
- **🏆 Achievements** - Erfolge und Fortschritt
- **📊 Statistiken** - Detaillierte Analysen
- **👤 Profil** - Persönliche Einstellungen
## ⏱️ Zeitmessung
### Wie funktioniert es?
1. **Start:** RFID-Karte an Start-Reader halten
2. **Parkour:** Den Parcours absolvieren
3. **Ziel:** RFID-Karte an Ziel-Reader halten
4. **Ergebnis:** Zeit wird automatisch berechnet und gespeichert
### Zeitformat
Zeiten werden im Format `MM:SS.mmm` angezeigt:
- **Minuten:SSekunden.Millisekunden**
- Beispiel: `01:23.456` = 1 Minute, 23 Sekunden, 456 Millisekunden
### Gültige Zeiten
- **Minimum:** 30 Sekunden
- **Maximum:** 10 Minuten
- **Schwelle:** Konfigurierbar pro Standort
## 🏆 Achievement System
### Was sind Achievements?
Achievements sind Erfolge, die Sie durch verschiedene Aktivitäten freischalten können.
### Kategorien
#### 🎯 Konsistenz-basierte Achievements
- **Erste Schritte** 👶 - Erste Zeit aufgezeichnet (5 Punkte)
- **Durchhalter** 💪 - 3 Versuche an einem Tag (10 Punkte)
- **Fleißig** 🔥 - 5 Versuche an einem Tag (15 Punkte)
- **Besessen** 😤 - 10 Versuche an einem Tag (25 Punkte)
- **Regelmäßig** 📅 - 5 verschiedene Tage gespielt (20 Punkte)
- **Stammgast** ⭐ - 10 verschiedene Tage gespielt (30 Punkte)
- **Treue** 💎 - 20 verschiedene Tage gespielt (50 Punkte)
- **Veteran** 🏆 - 50 verschiedene Tage gespielt (100 Punkte)
#### 📈 Verbesserungs-basierte Achievements
- **Fortschritt** 📈 - Persönliche Bestzeit um 5 Sekunden verbessert (15 Punkte)
- **Durchbruch** ⚡ - Persönliche Bestzeit um 10 Sekunden verbessert (25 Punkte)
- **Transformation** 🔄 - Persönliche Bestzeit um 15 Sekunden verbessert (40 Punkte)
- **Perfektionist** ✨ - Persönliche Bestzeit um 20 Sekunden verbessert (60 Punkte)
#### 🌍 Saisonale Achievements
- **Wochenend-Krieger** 🏁 - Am Wochenende gespielt (10 Punkte)
- **Nachmittags-Sportler** ☀️ - Zwischen 14-18 Uhr gespielt (10 Punkte)
- **Frühaufsteher** 🌅 - Vor 10 Uhr gespielt (15 Punkte)
- **Abend-Sportler** 🌙 - Nach 18 Uhr gespielt (10 Punkte)
#### 📅 Monatliche Achievements
- **Januar-Krieger** ❄️ bis **Dezember-Dynamo** 🎄 (je 20 Punkte)
#### 🌸 Jahreszeiten-Achievements
- **Frühjahrs-Fighter** 🌱 - Im Frühling gespielt (30 Punkte)
- **Sommer-Sportler** ☀️ - Im Sommer gespielt (30 Punkte)
- **Herbst-Held** 🍂 - Im Herbst gespielt (30 Punkte)
- **Winter-Warrior** ❄️ - Im Winter gespielt (30 Punkte)
### Achievement-Status
- **✅ Abgeschlossen** - Achievement erreicht
- **🔄 In Bearbeitung** - Fortschritt wird gemacht
- **❌ Nicht freigeschaltet** - Noch nicht begonnen
## 📊 Statistiken
### Persönliche Statistiken
- **Gesamtzeiten** - Anzahl aller gemessenen Zeiten
- **Beste Zeit** - Schnellste gemessene Zeit
- **Durchschnittszeit** - Durchschnittliche Zeit
- **Verbesserung** - Zeitverbesserung seit dem ersten Lauf
- **Aktivitätstage** - Anzahl der Tage mit Aktivität
### Fortschritts-Tracking
- **Wöchentlicher Fortschritt** - Zeiten der letzten 7 Tage
- **Monatlicher Fortschritt** - Zeiten des aktuellen Monats
- **Jährlicher Fortschritt** - Zeiten des aktuellen Jahres
### Vergleiche
- **Persönliche Bestenliste** - Ihre eigenen Top-Zeiten
- **Standort-Vergleich** - Zeiten an verschiedenen Standorten
- **Zeitverlauf** - Entwicklung Ihrer Zeiten über die Zeit
## 🗺️ Standorte
### Verfügbare Standorte
Das System unterstützt mehrere Standorte:
- **Hauptstandort** - Hauptparkour
- **Training** - Übungsbereich
- **Wettkampf** - Wettkampfbereich
### Standort-Informationen
Jeder Standort zeigt:
- **Name** - Standortbezeichnung
- **Schwelle** - Mindestzeit für gültige Zeiten
- **Beste Zeit** - Rekordzeit an diesem Standort
- **Karte** - Geografische Position
## 🔔 Benachrichtigungen
### Push-Benachrichtigungen
Aktivieren Sie Push-Benachrichtigungen für:
- **Neue Rekorde** - Persönliche Bestzeiten
- **Achievements** - Neue Erfolge
- **System-Updates** - Wichtige Ankündigungen
### E-Mail-Benachrichtigungen
Konfigurieren Sie E-Mail-Benachrichtigungen für:
- **Wöchentliche Zusammenfassung** - Ihre Aktivitäten
- **Monatliche Statistiken** - Detaillierte Berichte
- **System-Updates** - Wichtige Änderungen
## 👤 Profil verwalten
### Persönliche Daten
- **Name** - Vor- und Nachname
- **Geburtsdatum** - Für Alterskategorien
- **E-Mail** - Für Benachrichtigungen
- **RFID-Karte** - Verknüpfte Karten
### Einstellungen
- **Zeitzone** - Für korrekte Zeitstempel
- **Sprache** - Interface-Sprache
- **Benachrichtigungen** - Push und E-Mail Einstellungen
- **Datenschutz** - Sichtbarkeit Ihrer Daten
### Datenschutz
- **Öffentliche Profile** - Sichtbar für andere Benutzer
- **Private Profile** - Nur für Sie sichtbar
- **Datenexport** - Ihre Daten herunterladen
- **Konto löschen** - Alle Daten entfernen
## 🏅 Bestenlisten
### Globale Bestenlisten
- **Schnellste Zeiten** - Alle Zeiten aller Benutzer
- **Meiste Achievements** - Benutzer mit den meisten Erfolgen
- **Aktivste Spieler** - Benutzer mit den meisten Läufen
### Kategorien
- **Gesamt** - Alle Altersgruppen
- **Jugend** - Unter 18 Jahren
- **Erwachsene** - 18-65 Jahre
- **Senioren** - Über 65 Jahre
### Zeiträume
- **Heute** - Beste Zeiten des Tages
- **Diese Woche** - Beste Zeiten der Woche
- **Dieser Monat** - Beste Zeiten des Monats
- **Dieses Jahr** - Beste Zeiten des Jahres
- **Alle Zeiten** - Historische Bestenliste
## 🔧 Troubleshooting
### Häufige Probleme
#### RFID-Karte wird nicht erkannt
1. Karte richtig positionieren
2. Reader auf Verschmutzung prüfen
3. Karte auf Beschädigungen prüfen
4. Administrator kontaktieren
#### Zeit wird nicht gespeichert
1. Gültige Zeit prüfen (innerhalb der Schwelle)
2. Standort korrekt ausgewählt
3. Internetverbindung prüfen
4. Seite neu laden
#### Achievements werden nicht vergeben
1. Tägliche Prüfung abwarten
2. Bedingungen erfüllt prüfen
3. System-Status prüfen
4. Administrator kontaktieren
### Support kontaktieren
- **E-Mail** - support@ninjaparkour.de
- **Telefon** - +49 (0) 123 456 789
- **Chat** - Verfügbar im Web-Interface
## 📱 Mobile Nutzung
### Responsive Design
Das System ist für alle Geräte optimiert:
- **Desktop** - Vollständige Funktionalität
- **Tablet** - Touch-optimierte Bedienung
- **Smartphone** - Kompakte Ansicht
### Mobile App (geplant)
- **Native App** - Für iOS und Android
- **Offline-Modus** - Zeiten ohne Internet
- **Push-Benachrichtigungen** - Sofortige Updates
## 🎓 Tipps und Tricks
### Bessere Zeiten erzielen
1. **Regelmäßig trainieren** - Konsistenz ist wichtig
2. **Technik verbessern** - Effiziente Bewegungen
3. **Kondition aufbauen** - Ausdauer trainieren
4. **Mental vorbereiten** - Konzentration und Fokus
### Achievements sammeln
1. **Verschiedene Zeiten** - Morgens, mittags, abends
2. **Wochenenden** - Zusätzliche Aktivität
3. **Konsistent bleiben** - Regelmäßige Teilnahme
4. **Verbesserungen** - Persönliche Bestzeiten brechen
### System optimal nutzen
1. **Profil vollständig** - Alle Daten ausfüllen
2. **Benachrichtigungen aktivieren** - Updates erhalten
3. **Statistiken verfolgen** - Fortschritt beobachten
4. **Community nutzen** - Mit anderen vergleichen
---
**Hinweis:** Bei technischen Problemen wenden Sie sich an den Systemadministrator oder konsultieren Sie die [Troubleshooting](Troubleshooting)-Seite.

588
wiki/Datenbank.md Normal file
View File

@@ -0,0 +1,588 @@
# 🗄️ Datenbank
Dokumentation der PostgreSQL-Datenbank des Ninja Cross Parkour Systems.
## 📋 Inhaltsverzeichnis
- [🏗️ Schema-Übersicht](#-schema-übersicht)
- [📊 Tabellen](#-tabellen)
- [🔗 Beziehungen](#-beziehungen)
- [📈 Indizes](#-indizes)
- [🔧 Funktionen](#-funktionen)
- [📊 Statistiken](#-statistiken)
- [🛠️ Wartung](#-wartung)
## 🏗️ Schema-Übersicht
### Datenbank-Name
`ninjaserver`
### Zeichensatz
`UTF-8`
### Zeitzone
`Europe/Berlin`
### Version
PostgreSQL 12 oder höher
## 📊 Tabellen
### `players` - Spieler
```sql
CREATE TABLE players (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
firstname VARCHAR(50) NOT NULL,
lastname VARCHAR(50) NOT NULL,
birthdate DATE NOT NULL,
rfiduid VARCHAR(20) UNIQUE,
supabase_user_id UUID,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
**Beschreibung:** Speichert alle Spieler-Informationen.
**Felder:**
- `id` - Eindeutige UUID
- `firstname` - Vorname (max. 50 Zeichen)
- `lastname` - Nachname (max. 50 Zeichen)
- `birthdate` - Geburtsdatum
- `rfiduid` - RFID-Karten-ID (eindeutig)
- `supabase_user_id` - Verknüpfung zu Supabase
- `created_at` - Erstellungszeitpunkt
- `updated_at` - Letzte Aktualisierung
### `locations` - Standorte
```sql
CREATE TABLE locations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) UNIQUE NOT NULL,
latitude DECIMAL(10, 8) NOT NULL,
longitude DECIMAL(11, 8) NOT NULL,
time_threshold JSONB DEFAULT '{"seconds": 120}',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
**Beschreibung:** Speichert alle Parkour-Standorte.
**Felder:**
- `id` - Eindeutige UUID
- `name` - Standortname (eindeutig)
- `latitude` - Breitengrad (10,8 Dezimalstellen)
- `longitude` - Längengrad (11,8 Dezimalstellen)
- `time_threshold` - Zeit-Schwelle als JSON
- `created_at` - Erstellungszeitpunkt
- `updated_at` - Letzte Aktualisierung
### `times` - Zeiten
```sql
CREATE TABLE times (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
player_id UUID REFERENCES players(id) ON DELETE CASCADE,
location_id UUID REFERENCES locations(id) ON DELETE CASCADE,
recorded_time JSONB NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
**Beschreibung:** Speichert alle gemessenen Zeiten.
**Felder:**
- `id` - Eindeutige UUID
- `player_id` - Verweis auf Spieler
- `location_id` - Verweis auf Standort
- `recorded_time` - Zeit als JSON (Sekunden, Minuten, Millisekunden)
- `created_at` - Erstellungszeitpunkt
### `achievements` - Achievements
```sql
CREATE TABLE achievements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
description TEXT,
category VARCHAR(50) NOT NULL,
condition_type VARCHAR(50) NOT NULL,
condition_value INTEGER NOT NULL,
icon VARCHAR(10),
points INTEGER DEFAULT 0,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
**Beschreibung:** Definiert alle verfügbaren Achievements.
**Felder:**
- `id` - Eindeutige UUID
- `name` - Achievement-Name
- `description` - Beschreibung
- `category` - Kategorie (consistency, improvement, seasonal, monthly)
- `condition_type` - Bedingungstyp
- `condition_value` - Bedingungswert
- `icon` - Emoji-Icon
- `points` - Punkte
- `is_active` - Aktiv-Status
- `created_at` - Erstellungszeitpunkt
- `updated_at` - Letzte Aktualisierung
### `player_achievements` - Spieler-Achievements
```sql
CREATE TABLE player_achievements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
player_id UUID REFERENCES players(id) ON DELETE CASCADE,
achievement_id UUID REFERENCES achievements(id) ON DELETE CASCADE,
earned_at TIMESTAMP,
progress INTEGER DEFAULT 0,
is_completed BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(player_id, achievement_id)
);
```
**Beschreibung:** Verknüpft Spieler mit ihren Achievements.
**Felder:**
- `id` - Eindeutige UUID
- `player_id` - Verweis auf Spieler
- `achievement_id` - Verweis auf Achievement
- `earned_at` - Zeitpunkt der Verleihung
- `progress` - Fortschritt (0-100)
- `is_completed` - Abgeschlossen-Status
- `created_at` - Erstellungszeitpunkt
### `adminusers` - Admin-Benutzer
```sql
CREATE TABLE adminusers (
id SERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_login TIMESTAMP
);
```
**Beschreibung:** Speichert Admin-Benutzer für das System.
**Felder:**
- `id` - Auto-increment ID
- `username` - Benutzername (eindeutig)
- `password_hash` - Gehashtes Passwort
- `is_active` - Aktiv-Status
- `created_at` - Erstellungszeitpunkt
- `last_login` - Letzter Login
### `api_tokens` - API-Tokens
```sql
CREATE TABLE api_tokens (
id SERIAL PRIMARY KEY,
token VARCHAR(255) UNIQUE NOT NULL,
description TEXT,
standorte TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP,
is_active BOOLEAN DEFAULT true
);
```
**Beschreibung:** Speichert API-Tokens für Authentifizierung.
**Felder:**
- `id` - Auto-increment ID
- `token` - API-Token (eindeutig)
- `description` - Beschreibung
- `standorte` - Zugewiesene Standorte
- `created_at` - Erstellungszeitpunkt
- `expires_at` - Ablaufzeitpunkt
- `is_active` - Aktiv-Status
### `page_views` - Seitenaufrufe
```sql
CREATE TABLE page_views (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
page VARCHAR(255) NOT NULL,
user_agent TEXT,
ip_address INET,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
**Beschreibung:** Verfolgt Seitenaufrufe für Statistiken.
**Felder:**
- `id` - Eindeutige UUID
- `page` - Seitenname
- `user_agent` - Browser-Informationen
- `ip_address` - IP-Adresse
- `created_at` - Zeitpunkt des Aufrufs
## 🔗 Beziehungen
### Foreign Key Constraints
```sql
-- times -> players
ALTER TABLE times
ADD CONSTRAINT fk_times_player
FOREIGN KEY (player_id) REFERENCES players(id) ON DELETE CASCADE;
-- times -> locations
ALTER TABLE times
ADD CONSTRAINT fk_times_location
FOREIGN KEY (location_id) REFERENCES locations(id) ON DELETE CASCADE;
-- player_achievements -> players
ALTER TABLE player_achievements
ADD CONSTRAINT fk_player_achievements_player
FOREIGN KEY (player_id) REFERENCES players(id) ON DELETE CASCADE;
-- player_achievements -> achievements
ALTER TABLE player_achievements
ADD CONSTRAINT fk_player_achievements_achievement
FOREIGN KEY (achievement_id) REFERENCES achievements(id) ON DELETE CASCADE;
```
### Beziehungsdiagramm
```
players (1) -----> (N) times
players (1) -----> (N) player_achievements
locations (1) ---> (N) times
achievements (1) -> (N) player_achievements
```
## 📈 Indizes
### Primäre Indizes
```sql
-- Primärschlüssel (automatisch)
CREATE UNIQUE INDEX idx_players_pkey ON players(id);
CREATE UNIQUE INDEX idx_locations_pkey ON locations(id);
CREATE UNIQUE INDEX idx_times_pkey ON times(id);
CREATE UNIQUE INDEX idx_achievements_pkey ON achievements(id);
CREATE UNIQUE INDEX idx_player_achievements_pkey ON player_achievements(id);
```
### Performance-Indizes
```sql
-- Zeiten-Indizes
CREATE INDEX idx_times_player_id ON times(player_id);
CREATE INDEX idx_times_location_id ON times(location_id);
CREATE INDEX idx_times_created_at ON times(created_at);
CREATE INDEX idx_times_player_created ON times(player_id, created_at DESC);
-- Achievement-Indizes
CREATE INDEX idx_player_achievements_player_id ON player_achievements(player_id);
CREATE INDEX idx_player_achievements_achievement_id ON player_achievements(achievement_id);
CREATE INDEX idx_player_achievements_completed ON player_achievements(is_completed) WHERE is_completed = true;
-- Standort-Indizes
CREATE INDEX idx_locations_name ON locations(name);
CREATE INDEX idx_locations_coordinates ON locations(latitude, longitude);
-- Spieler-Indizes
CREATE INDEX idx_players_rfiduid ON players(rfiduid);
CREATE INDEX idx_players_supabase_user_id ON players(supabase_user_id);
CREATE INDEX idx_players_name ON players(firstname, lastname);
-- API-Token-Indizes
CREATE INDEX idx_api_tokens_token ON api_tokens(token);
CREATE INDEX idx_api_tokens_active ON api_tokens(is_active) WHERE is_active = true;
-- Seitenaufruf-Indizes
CREATE INDEX idx_page_views_page ON page_views(page);
CREATE INDEX idx_page_views_created_at ON page_views(created_at);
```
### Composite-Indizes
```sql
-- Für häufige Abfragen
CREATE INDEX idx_times_player_location_time ON times(player_id, location_id, created_at DESC);
CREATE INDEX idx_player_achievements_player_completed ON player_achievements(player_id, is_completed);
CREATE INDEX idx_achievements_category_active ON achievements(category, is_active);
```
## 🔧 Funktionen
### Achievement-Funktionen
```sql
-- Konsistenz-basierte Achievements prüfen
CREATE OR REPLACE FUNCTION check_consistency_achievements(player_uuid UUID)
RETURNS INTEGER AS $$
DECLARE
awarded_count INTEGER := 0;
total_runs INTEGER;
runs_today INTEGER;
unique_days INTEGER;
BEGIN
-- Gesamtläufe zählen
SELECT COUNT(*) INTO total_runs
FROM times WHERE player_id = player_uuid;
-- Läufe heute zählen
SELECT COUNT(*) INTO runs_today
FROM times
WHERE player_id = player_uuid
AND DATE(created_at) = CURRENT_DATE;
-- Verschiedene Tage zählen
SELECT COUNT(DISTINCT DATE(created_at)) INTO unique_days
FROM times WHERE player_id = player_uuid;
-- Achievements vergeben
-- (Detaillierte Logik hier...)
RETURN awarded_count;
END;
$$ LANGUAGE plpgsql;
-- Verbesserungs-basierte Achievements prüfen
CREATE OR REPLACE FUNCTION check_improvement_achievements(player_uuid UUID)
RETURNS INTEGER AS $$
-- (Implementierung...)
$$ LANGUAGE plpgsql;
-- Saisonale Achievements prüfen
CREATE OR REPLACE FUNCTION check_seasonal_achievements(player_uuid UUID)
RETURNS INTEGER AS $$
-- (Implementierung...)
$$ LANGUAGE plpgsql;
-- Alle Achievements prüfen
CREATE OR REPLACE FUNCTION check_all_achievements(player_uuid UUID)
RETURNS INTEGER AS $$
DECLARE
total_awarded INTEGER := 0;
BEGIN
total_awarded := total_awarded + check_consistency_achievements(player_uuid);
total_awarded := total_awarded + check_improvement_achievements(player_uuid);
total_awarded := total_awarded + check_seasonal_achievements(player_uuid);
RETURN total_awarded;
END;
$$ LANGUAGE plpgsql;
```
### Utility-Funktionen
```sql
-- Beste Zeit eines Spielers ermitteln
CREATE OR REPLACE FUNCTION get_best_time(player_uuid UUID)
RETURNS JSONB AS $$
DECLARE
best_time JSONB;
BEGIN
SELECT recorded_time INTO best_time
FROM times
WHERE player_id = player_uuid
ORDER BY (recorded_time->>'seconds')::INTEGER ASC
LIMIT 1;
RETURN COALESCE(best_time, '{"seconds": 0, "minutes": 0, "milliseconds": 0}');
END;
$$ LANGUAGE plpgsql;
-- Spieler-Statistiken berechnen
CREATE OR REPLACE FUNCTION get_player_stats(player_uuid UUID)
RETURNS JSONB AS $$
DECLARE
stats JSONB;
total_runs INTEGER;
best_time JSONB;
avg_time JSONB;
BEGIN
SELECT COUNT(*) INTO total_runs FROM times WHERE player_id = player_uuid;
SELECT get_best_time(player_uuid) INTO best_time;
-- Durchschnittszeit berechnen
SELECT jsonb_build_object(
'seconds', AVG((recorded_time->>'seconds')::INTEGER),
'minutes', AVG((recorded_time->>'minutes')::INTEGER),
'milliseconds', AVG((recorded_time->>'milliseconds')::INTEGER)
) INTO avg_time
FROM times WHERE player_id = player_uuid;
stats := jsonb_build_object(
'total_runs', total_runs,
'best_time', best_time,
'average_time', avg_time
);
RETURN stats;
END;
$$ LANGUAGE plpgsql;
```
## 📊 Statistiken
### Datenbank-Größe
```sql
-- Gesamtgröße der Datenbank
SELECT pg_size_pretty(pg_database_size('ninjaserver')) as database_size;
-- Größe der einzelnen Tabellen
SELECT
schemaname,
tablename,
pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) as size
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC;
```
### Tabellen-Statistiken
```sql
-- Anzahl der Datensätze pro Tabelle
SELECT
'players' as table_name, COUNT(*) as record_count FROM players
UNION ALL
SELECT 'locations', COUNT(*) FROM locations
UNION ALL
SELECT 'times', COUNT(*) FROM times
UNION ALL
SELECT 'achievements', COUNT(*) FROM achievements
UNION ALL
SELECT 'player_achievements', COUNT(*) FROM player_achievements
UNION ALL
SELECT 'adminusers', COUNT(*) FROM adminusers
UNION ALL
SELECT 'api_tokens', COUNT(*) FROM api_tokens
UNION ALL
SELECT 'page_views', COUNT(*) FROM page_views;
```
### Performance-Statistiken
```sql
-- Langsamste Queries
SELECT
query,
calls,
total_time,
mean_time,
rows
FROM pg_stat_statements
ORDER BY mean_time DESC
LIMIT 10;
-- Index-Nutzung
SELECT
schemaname,
tablename,
indexname,
idx_scan,
idx_tup_read,
idx_tup_fetch
FROM pg_stat_user_indexes
ORDER BY idx_scan DESC;
```
## 🛠️ Wartung
### Backup
```bash
# Vollständiges Backup
pg_dump -h localhost -U username -d ninjaserver > ninjaserver_backup.sql
# Nur Schema
pg_dump -h localhost -U username -d ninjaserver --schema-only > schema_backup.sql
# Nur Daten
pg_dump -h localhost -U username -d ninjaserver --data-only > data_backup.sql
# Komprimiertes Backup
pg_dump -h localhost -U username -d ninjaserver | gzip > ninjaserver_backup.sql.gz
```
### Wiederherstellung
```bash
# Vollständige Wiederherstellung
psql -h localhost -U username -d ninjaserver < ninjaserver_backup.sql
# Schema wiederherstellen
psql -h localhost -U username -d ninjaserver < schema_backup.sql
# Daten wiederherstellen
psql -h localhost -U username -d ninjaserver < data_backup.sql
```
### Wartungsaufgaben
```sql
-- Tabellen analysieren
ANALYZE;
-- Indizes neu aufbauen
REINDEX DATABASE ninjaserver;
-- Vakuum durchführen
VACUUM ANALYZE;
-- Speicher freigeben
VACUUM FULL;
```
### Monitoring
```sql
-- Aktive Verbindungen
SELECT
pid,
usename,
application_name,
client_addr,
state,
query_start,
query
FROM pg_stat_activity
WHERE state = 'active';
-- Locks
SELECT
pid,
mode,
locktype,
relation::regclass,
granted
FROM pg_locks
WHERE NOT granted;
-- Wartende Queries
SELECT
pid,
usename,
application_name,
state,
query_start,
query
FROM pg_stat_activity
WHERE state = 'waiting';
```
### Sicherheit
```sql
-- Benutzerrechte prüfen
SELECT
usename,
usesuper,
usecreatedb,
usebypassrls
FROM pg_user;
-- Tabellenrechte prüfen
SELECT
schemaname,
tablename,
tableowner
FROM pg_tables
WHERE schemaname = 'public';
-- Verbindungslimits
SELECT
usename,
connlimit
FROM pg_user;
```
---
**Hinweis:** Für detaillierte API-Dokumentation siehe [API Referenz](API-Referenz) und für Achievement-Details siehe [Achievement System](Achievement-System).

642
wiki/Deployment.md Normal file
View File

@@ -0,0 +1,642 @@
# 🚀 Deployment
Anleitung für das Deployment des Ninja Cross Parkour Systems in verschiedenen Umgebungen.
## 📋 Inhaltsverzeichnis
- [🏗️ Deployment-Übersicht](#-deployment-übersicht)
- [🔧 Vorbereitung](#-vorbereitung)
- [🐳 Docker-Deployment](#-docker-deployment)
- [☁️ Cloud-Deployment](#-cloud-deployment)
- [🖥️ VPS-Deployment](#-vps-deployment)
- [🔧 Konfiguration](#-konfiguration)
- [📊 Monitoring](#-monitoring)
- [🔄 CI/CD](#-cicd)
## 🏗️ Deployment-Übersicht
### Deployment-Optionen
- **Docker** - Containerisierte Bereitstellung
- **Cloud** - AWS, Azure, Google Cloud
- **VPS** - Virtuelle private Server
- **On-Premise** - Lokale Server
### System-Anforderungen
- **CPU:** 2+ Kerne
- **RAM:** 4+ GB
- **Storage:** 50+ GB SSD
- **Network:** 100+ Mbps
## 🔧 Vorbereitung
### Code vorbereiten
```bash
# Repository klonen
git clone <repository-url>
cd ninjaserver
# Abhängigkeiten installieren
npm install
# Produktions-Build erstellen
npm run build
# Tests ausführen
npm test
```
### Umgebungsvariablen
```bash
# .env.production erstellen
cp .env.example .env.production
# Produktionswerte setzen
NODE_ENV=production
PORT=3000
DB_HOST=production-db-host
DB_PORT=5432
DB_NAME=ninjaserver
DB_USER=ninja_user
DB_PASSWORD=secure_password
JWT_SECRET=your_jwt_secret_here
SESSION_SECRET=your_session_secret_here
```
### Datenbank vorbereiten
```sql
-- Produktionsdatenbank erstellen
CREATE DATABASE ninjaserver;
CREATE USER ninja_user WITH PASSWORD 'secure_password';
GRANT ALL PRIVILEGES ON DATABASE ninjaserver TO ninja_user;
-- Schema initialisieren
\c ninjaserver
\i scripts/init-db.sql
```
## 🐳 Docker-Deployment
### Dockerfile
```dockerfile
# Multi-stage build
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
FROM node:18-alpine AS runtime
# Sicherheitsupdates
RUN apk update && apk upgrade
# Nicht-root Benutzer erstellen
RUN addgroup -g 1001 -S nodejs
RUN adduser -S ninja -u 1001
WORKDIR /app
# Abhängigkeiten kopieren
COPY --from=builder /app/node_modules ./node_modules
COPY . .
# Berechtigungen setzen
RUN chown -R ninja:nodejs /app
USER ninja
# Port freigeben
EXPOSE 3000
# Health Check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:3000/health || exit 1
# Anwendung starten
CMD ["npm", "start"]
```
### Docker Compose
```yaml
version: '3.8'
services:
app:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- DB_HOST=postgres
- DB_PORT=5432
- DB_NAME=ninjaserver
- DB_USER=ninja_user
- DB_PASSWORD=secure_password
depends_on:
- postgres
- redis
volumes:
- ./logs:/app/logs
restart: unless-stopped
postgres:
image: postgres:15-alpine
environment:
- POSTGRES_DB=ninjaserver
- POSTGRES_USER=ninja_user
- POSTGRES_PASSWORD=secure_password
volumes:
- postgres_data:/var/lib/postgresql/data
- ./scripts/init-db.sql:/docker-entrypoint-initdb.d/init-db.sql
ports:
- "5432:5432"
restart: unless-stopped
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
restart: unless-stopped
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
- ./ssl:/etc/nginx/ssl
depends_on:
- app
restart: unless-stopped
volumes:
postgres_data:
redis_data:
```
### Deployment-Skript
```bash
#!/bin/bash
# deploy.sh
set -e
echo "🚀 Starting deployment..."
# Docker Images bauen
echo "📦 Building Docker images..."
docker-compose build
# Alte Container stoppen
echo "🛑 Stopping old containers..."
docker-compose down
# Neue Container starten
echo "▶️ Starting new containers..."
docker-compose up -d
# Health Check
echo "🔍 Checking health..."
sleep 30
curl -f http://localhost:3000/health || exit 1
echo "✅ Deployment completed successfully!"
```
## ☁️ Cloud-Deployment
### AWS Deployment
#### EC2-Instanz
```bash
# EC2-Instanz starten
aws ec2 run-instances \
--image-id ami-0c02fb55956c7d316 \
--instance-type t3.medium \
--key-name ninja-key \
--security-groups ninja-sg \
--user-data file://user-data.sh
```
#### RDS-Datenbank
```bash
# RDS-Instanz erstellen
aws rds create-db-instance \
--db-instance-identifier ninja-db \
--db-instance-class db.t3.micro \
--engine postgres \
--master-username ninja_user \
--master-user-password secure_password \
--allocated-storage 20
```
#### Load Balancer
```bash
# Application Load Balancer erstellen
aws elbv2 create-load-balancer \
--name ninja-alb \
--subnets subnet-12345 subnet-67890 \
--security-groups sg-12345
```
### Azure Deployment
#### App Service
```bash
# App Service erstellen
az webapp create \
--resource-group ninja-rg \
--plan ninja-plan \
--name ninja-app \
--runtime "NODE|18-lts"
```
#### PostgreSQL
```bash
# PostgreSQL-Server erstellen
az postgres flexible-server create \
--resource-group ninja-rg \
--name ninja-db \
--admin-user ninja_user \
--admin-password secure_password \
--sku-name Standard_B1ms
```
### Google Cloud Deployment
#### Cloud Run
```yaml
# cloudbuild.yaml
steps:
- name: 'gcr.io/cloud-builders/docker'
args: ['build', '-t', 'gcr.io/$PROJECT_ID/ninja-app', '.']
- name: 'gcr.io/cloud-builders/docker'
args: ['push', 'gcr.io/$PROJECT_ID/ninja-app']
- name: 'gcr.io/cloud-builders/gcloud'
args: ['run', 'deploy', 'ninja-app', '--image', 'gcr.io/$PROJECT_ID/ninja-app', '--region', 'europe-west1']
```
## 🖥️ VPS-Deployment
### Server-Setup
```bash
#!/bin/bash
# server-setup.sh
# System aktualisieren
sudo apt update && sudo apt upgrade -y
# Node.js installieren
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
sudo apt-get install -y nodejs
# PostgreSQL installieren
sudo apt install postgresql postgresql-contrib -y
# Nginx installieren
sudo apt install nginx -y
# PM2 installieren
sudo npm install -g pm2
# Firewall konfigurieren
sudo ufw allow 22
sudo ufw allow 80
sudo ufw allow 443
sudo ufw enable
```
### Anwendung deployen
```bash
#!/bin/bash
# deploy-vps.sh
# Code aktualisieren
git pull origin main
# Abhängigkeiten installieren
npm install --production
# Datenbank migrieren
npm run migrate
# Anwendung starten
pm2 start ecosystem.config.js
# Nginx konfigurieren
sudo cp nginx.conf /etc/nginx/sites-available/ninja
sudo ln -s /etc/nginx/sites-available/ninja /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
```
### PM2-Konfiguration
```javascript
// ecosystem.config.js
module.exports = {
apps: [{
name: 'ninja-app',
script: 'server.js',
instances: 'max',
exec_mode: 'cluster',
env: {
NODE_ENV: 'production',
PORT: 3000
},
error_file: './logs/err.log',
out_file: './logs/out.log',
log_file: './logs/combined.log',
time: true
}]
};
```
## 🔧 Konfiguration
### Nginx-Konfiguration
```nginx
# nginx.conf
upstream ninja_app {
server 127.0.0.1:3000;
}
server {
listen 80;
server_name ninja.reptilfpv.de;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name ninja.reptilfpv.de;
ssl_certificate /etc/nginx/ssl/cert.pem;
ssl_certificate_key /etc/nginx/ssl/key.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers off;
# Security Headers
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
add_header X-XSS-Protection "1; mode=block";
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Rate Limiting
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
limit_req zone=api burst=20 nodelay;
location / {
proxy_pass http://ninja_app;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
# Static Files
location /static/ {
alias /var/www/ninja/public/;
expires 1y;
add_header Cache-Control "public, immutable";
}
}
```
### SSL-Zertifikat
```bash
# Let's Encrypt
sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d ninja.reptilfpv.de
# Automatische Erneuerung
echo "0 12 * * * /usr/bin/certbot renew --quiet" | sudo crontab -
```
### Datenbank-Backup
```bash
#!/bin/bash
# backup.sh
# Tägliches Backup
pg_dump -h localhost -U ninja_user -d ninjaserver | gzip > backup_$(date +%Y%m%d).sql.gz
# Alte Backups löschen (älter als 30 Tage)
find /backups -name "backup_*.sql.gz" -mtime +30 -delete
# Backup nach S3 hochladen
aws s3 cp backup_$(date +%Y%m%d).sql.gz s3://ninja-backups/
```
## 📊 Monitoring
### Application Monitoring
```javascript
// monitoring.js
const prometheus = require('prom-client');
// Metriken definieren
const httpRequestDuration = new prometheus.Histogram({
name: 'http_request_duration_seconds',
help: 'Duration of HTTP requests in seconds',
labelNames: ['method', 'route', 'status_code']
});
const activeConnections = new prometheus.Gauge({
name: 'active_connections',
help: 'Number of active connections'
});
// Metriken-Endpoint
app.get('/metrics', (req, res) => {
res.set('Content-Type', prometheus.register.contentType);
res.end(prometheus.register.metrics());
});
```
### Log-Monitoring
```bash
# Logstash-Konfiguration
input {
file {
path => "/var/log/ninja/*.log"
type => "ninja-logs"
}
}
filter {
if [type] == "ninja-logs" {
grok {
match => { "message" => "%{TIMESTAMP_ISO8601:timestamp} %{LOGLEVEL:level} %{GREEDYDATA:message}" }
}
}
}
output {
elasticsearch {
hosts => ["localhost:9200"]
index => "ninja-logs-%{+YYYY.MM.dd}"
}
}
```
### Alerting
```yaml
# alertmanager.yml
global:
smtp_smarthost: 'localhost:587'
smtp_from: 'alerts@ninjaparkour.de'
route:
group_by: ['alertname']
group_wait: 10s
group_interval: 10s
repeat_interval: 1h
receiver: 'web.hook'
receivers:
- name: 'web.hook'
email_configs:
- to: 'admin@ninjaparkour.de'
subject: 'Ninja Parkour Alert: {{ .GroupLabels.alertname }}'
body: '{{ range .Alerts }}{{ .Annotations.description }}{{ end }}'
```
## 🔄 CI/CD
### GitHub Actions
```yaml
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
- run: npm ci
- run: npm test
deploy:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Deploy to production
run: |
ssh user@server 'cd /var/www/ninja && git pull && npm install && pm2 restart ninja-app'
```
### GitLab CI
```yaml
# .gitlab-ci.yml
stages:
- test
- deploy
test:
stage: test
script:
- npm ci
- npm test
deploy:
stage: deploy
script:
- ssh user@server 'cd /var/www/ninja && git pull && npm install && pm2 restart ninja-app'
only:
- main
```
### Jenkins Pipeline
```groovy
// Jenkinsfile
pipeline {
agent any
stages {
stage('Test') {
steps {
sh 'npm ci'
sh 'npm test'
}
}
stage('Deploy') {
steps {
sh 'ssh user@server "cd /var/www/ninja && git pull && npm install && pm2 restart ninja-app"'
}
}
}
}
```
## 🔧 Wartung
### Automatische Updates
```bash
#!/bin/bash
# auto-update.sh
# System-Updates
sudo apt update && sudo apt upgrade -y
# Anwendung-Updates
cd /var/www/ninja
git pull origin main
npm install --production
pm2 restart ninja-app
# Datenbank-Updates
npm run migrate
```
### Health Checks
```bash
#!/bin/bash
# health-check.sh
# Anwendung prüfen
curl -f http://localhost:3000/health || exit 1
# Datenbank prüfen
psql -d ninjaserver -c "SELECT NOW();" || exit 1
# Speicher prüfen
if [ $(df / | awk 'NR==2{print $5}' | sed 's/%//') -gt 80 ]; then
echo "Disk space low"
exit 1
fi
```
### Rollback-Strategie
```bash
#!/bin/bash
# rollback.sh
# Vorherige Version wiederherstellen
cd /var/www/ninja
git checkout HEAD~1
npm install --production
pm2 restart ninja-app
# Datenbank-Rollback
psql -d ninjaserver < backup_previous.sql
```
---
**Hinweis:** Diese Deployment-Anleitung sollte an Ihre spezifischen Anforderungen angepasst werden. Testen Sie alle Schritte in einer Staging-Umgebung vor der Produktionsbereitstellung.

589
wiki/Entwicklerhandbuch.md Normal file
View File

@@ -0,0 +1,589 @@
# 🔧 Entwicklerhandbuch
Technische Dokumentation für Entwickler des Ninja Cross Parkour Systems.
## 📋 Inhaltsverzeichnis
- [🏗️ System-Architektur](#-system-architektur)
- [🛠️ Entwicklungsumgebung](#-entwicklungsumgebung)
- [📡 API-Integration](#-api-integration)
- [🗄️ Datenbank-Schema](#-datenbank-schema)
- [🔐 Authentifizierung](#-authentifizierung)
- [🧪 Testing](#-testing)
- [🚀 Deployment](#-deployment)
- [📊 Monitoring](#-monitoring)
- [🔧 Wartung](#-wartung)
## 🏗️ System-Architektur
### Übersicht
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Frontend │ │ Backend │ │ Database │
│ (Web UI) │◄──►│ (Node.js) │◄──►│ (PostgreSQL) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ RFID Reader │ │ API Endpoints │ │ Achievement │
│ (Hardware) │ │ (REST) │ │ System │
└─────────────────┘ └─────────────────┘ └─────────────────┘
```
### Technologie-Stack
- **Backend:** Node.js + Express.js
- **Datenbank:** PostgreSQL
- **Frontend:** HTML5 + CSS3 + JavaScript
- **Authentifizierung:** JWT + bcrypt
- **API:** RESTful API
- **Maps:** Leaflet.js + OpenStreetMap
- **RFID:** Hardware-Integration
### Projektstruktur
```
ninjaserver/
├── server.js # Hauptserver-Datei
├── routes/
│ ├── api.js # API-Routen
│ ├── public.js # Öffentliche Routen
│ ├── private.js # Private Routen
│ ├── web.js # Web-Routen
│ └── admin.js # Admin-Routen
├── middleware/
│ ├── auth.js # Authentifizierung
│ ├── validation.js # Eingabe-Validierung
│ └── logging.js # Logging
├── models/
│ ├── Player.js # Spieler-Modell
│ ├── Time.js # Zeit-Modell
│ ├── Location.js # Standort-Modell
│ └── Achievement.js # Achievement-Modell
├── scripts/
│ ├── init-db.js # Datenbankinitialisierung
│ ├── create-user.js # Benutzer-Erstellung
│ └── daily_achievements.js # Tägliche Achievements
├── public/
│ ├── index.html # Hauptanwendung
│ ├── login.html # Login-Seite
│ ├── css/ # Stylesheets
│ └── js/ # JavaScript
├── test/
│ ├── api.test.js # API-Tests
│ ├── unit.test.js # Unit-Tests
│ └── integration.test.js # Integration-Tests
└── docs/
├── API.md # API-Dokumentation
├── ACHIEVEMENTS.md # Achievement-Dokumentation
└── wiki/ # Wiki-Dokumentation
```
## 🛠️ Entwicklungsumgebung
### Voraussetzungen
- **Node.js** v16 oder höher
- **PostgreSQL** 12 oder höher
- **Git** für Versionskontrolle
- **npm** oder **yarn** für Paketverwaltung
### Setup
```bash
# Repository klonen
git clone <repository-url>
cd ninjaserver
# Abhängigkeiten installieren
npm install
# Umgebungsvariablen konfigurieren
cp .env.example .env
# .env-Datei bearbeiten
# Datenbank initialisieren
npm run init-db
# Entwicklungsserver starten
npm run dev
```
### Entwicklungsskripte
```bash
# Entwicklungsserver mit Auto-Reload
npm run dev
# Tests ausführen
npm test
# Linting
npm run lint
# Datenbank zurücksetzen
npm run reset-db
# API-Dokumentation generieren
npm run docs
```
### IDE-Empfehlungen
- **Visual Studio Code** mit Extensions:
- ES6 code snippets
- PostgreSQL
- REST Client
- GitLens
## 📡 API-Integration
### Authentifizierung
```javascript
// API-Key Authentifizierung
const headers = {
'Authorization': 'Bearer YOUR_API_KEY',
'Content-Type': 'application/json'
};
// Session-basierte Authentifizierung
const session = await authenticateUser(username, password);
```
### API-Client Beispiel
```javascript
class NinjaParkourAPI {
constructor(apiKey, baseURL = 'http://localhost:3000') {
this.apiKey = apiKey;
this.baseURL = baseURL;
}
async request(endpoint, options = {}) {
const url = `${this.baseURL}${endpoint}`;
const config = {
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
...options.headers
},
...options
};
const response = await fetch(url, config);
return response.json();
}
// Spieler erstellen
async createPlayer(playerData) {
return this.request('/api/v1/public/players', {
method: 'POST',
body: JSON.stringify(playerData)
});
}
// Zeit messen
async recordTime(timeData) {
return this.request('/api/v1/private/create-time', {
method: 'POST',
body: JSON.stringify(timeData)
});
}
// Achievements abrufen
async getAchievements(playerId) {
return this.request(`/api/achievements/player/${playerId}`);
}
}
// Verwendung
const api = new NinjaParkourAPI('your-api-key');
const player = await api.createPlayer({
firstname: 'Max',
lastname: 'Mustermann',
birthdate: '1990-01-01',
rfiduid: 'AA:BB:CC:DD'
});
```
### WebSocket Integration
```javascript
// Real-time Updates
const socket = io('http://localhost:3000');
socket.on('timeRecorded', (data) => {
console.log('Neue Zeit:', data);
updateLeaderboard(data);
});
socket.on('achievementEarned', (data) => {
console.log('Neues Achievement:', data);
showNotification(data);
});
```
## 🗄️ Datenbank-Schema
### Tabellen-Übersicht
```sql
-- Spieler
CREATE TABLE players (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
firstname VARCHAR(50) NOT NULL,
lastname VARCHAR(50) NOT NULL,
birthdate DATE NOT NULL,
rfiduid VARCHAR(20) UNIQUE,
supabase_user_id UUID,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Standorte
CREATE TABLE locations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) UNIQUE NOT NULL,
latitude DECIMAL(10, 8) NOT NULL,
longitude DECIMAL(11, 8) NOT NULL,
time_threshold JSONB DEFAULT '{"seconds": 120}',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Zeiten
CREATE TABLE times (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
player_id UUID REFERENCES players(id),
location_id UUID REFERENCES locations(id),
recorded_time JSONB NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Achievements
CREATE TABLE achievements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
description TEXT,
category VARCHAR(50) NOT NULL,
condition_type VARCHAR(50) NOT NULL,
condition_value INTEGER NOT NULL,
icon VARCHAR(10),
points INTEGER DEFAULT 0,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Spieler-Achievements
CREATE TABLE player_achievements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
player_id UUID REFERENCES players(id),
achievement_id UUID REFERENCES achievements(id),
earned_at TIMESTAMP,
progress INTEGER DEFAULT 0,
is_completed BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
### Indizes
```sql
-- Performance-Indizes
CREATE INDEX idx_times_player_id ON times(player_id);
CREATE INDEX idx_times_location_id ON times(location_id);
CREATE INDEX idx_times_created_at ON times(created_at);
CREATE INDEX idx_player_achievements_player_id ON player_achievements(player_id);
CREATE INDEX idx_player_achievements_achievement_id ON player_achievements(achievement_id);
```
### PostgreSQL Funktionen
```sql
-- Achievement-Prüfung
CREATE OR REPLACE FUNCTION check_all_achievements(player_uuid UUID)
RETURNS VOID AS $$
BEGIN
PERFORM check_consistency_achievements(player_uuid);
PERFORM check_improvement_achievements(player_uuid);
PERFORM check_seasonal_achievements(player_uuid);
END;
$$ LANGUAGE plpgsql;
```
## 🔐 Authentifizierung
### API-Key Authentifizierung
```javascript
// Middleware für API-Key
const authenticateAPIKey = (req, res, next) => {
const authHeader = req.headers.authorization;
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'API-Key erforderlich' });
}
// Token validieren
const isValid = validateAPIKey(token);
if (!isValid) {
return res.status(401).json({ error: 'Ungültiger API-Key' });
}
req.apiKey = token;
next();
};
```
### Session-basierte Authentifizierung
```javascript
// Session-Middleware
const authenticateSession = (req, res, next) => {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Nicht authentifiziert' });
}
req.userId = req.session.userId;
next();
};
```
### JWT-Token
```javascript
// JWT-Token generieren
const generateJWT = (user) => {
return jwt.sign(
{ userId: user.id, username: user.username },
process.env.JWT_SECRET,
{ expiresIn: '24h' }
);
};
// JWT-Token validieren
const validateJWT = (token) => {
try {
return jwt.verify(token, process.env.JWT_SECRET);
} catch (error) {
return null;
}
};
```
## 🧪 Testing
### Unit-Tests
```javascript
// test/unit/Player.test.js
const { Player } = require('../../models/Player');
describe('Player Model', () => {
test('should create player with valid data', () => {
const playerData = {
firstname: 'Max',
lastname: 'Mustermann',
birthdate: '1990-01-01',
rfiduid: 'AA:BB:CC:DD'
};
const player = new Player(playerData);
expect(player.firstname).toBe('Max');
expect(player.lastname).toBe('Mustermann');
});
});
```
### Integration-Tests
```javascript
// test/integration/api.test.js
const request = require('supertest');
const app = require('../../server');
describe('API Endpoints', () => {
test('POST /api/v1/public/players', async () => {
const playerData = {
firstname: 'Max',
lastname: 'Mustermann',
birthdate: '1990-01-01',
rfiduid: 'AA:BB:CC:DD'
};
const response = await request(app)
.post('/api/v1/public/players')
.send(playerData)
.expect(201);
expect(response.body.success).toBe(true);
expect(response.body.data.firstname).toBe('Max');
});
});
```
### API-Tests ausführen
```bash
# Alle Tests
npm test
# Unit-Tests
npm run test:unit
# Integration-Tests
npm run test:integration
# Coverage-Report
npm run test:coverage
```
## 🚀 Deployment
### Produktionsumgebung
```bash
# Abhängigkeiten installieren
npm install --production
# Umgebungsvariablen setzen
export NODE_ENV=production
export DB_HOST=production-db-host
export DB_PASSWORD=secure-password
# Server starten
npm start
```
### Docker-Container
```dockerfile
# Dockerfile
FROM node:16-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --production
COPY . .
EXPOSE 3000
CMD ["npm", "start"]
```
### Nginx-Konfiguration
```nginx
server {
listen 80;
server_name ninja.reptilfpv.de;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}
```
### SSL-Zertifikat
```bash
# Let's Encrypt
certbot --nginx -d ninja.reptilfpv.de
```
## 📊 Monitoring
### Logging
```javascript
// Winston Logger
const winston = require('winston');
const logger = winston.createLogger({
level: 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
transports: [
new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
new winston.transports.File({ filename: 'logs/combined.log' })
]
});
```
### Health-Checks
```javascript
// Health-Check Endpoint
app.get('/health', async (req, res) => {
try {
// Datenbank-Verbindung prüfen
await db.query('SELECT 1');
res.json({
status: 'healthy',
timestamp: new Date().toISOString(),
uptime: process.uptime()
});
} catch (error) {
res.status(500).json({
status: 'unhealthy',
error: error.message
});
}
});
```
### Metriken
```javascript
// Prometheus-Metriken
const prometheus = require('prom-client');
const httpRequestDuration = new prometheus.Histogram({
name: 'http_request_duration_seconds',
help: 'Duration of HTTP requests in seconds',
labelNames: ['method', 'route', 'status_code']
});
const activeConnections = new prometheus.Gauge({
name: 'active_connections',
help: 'Number of active connections'
});
```
## 🔧 Wartung
### Datenbank-Backup
```bash
# Backup erstellen
pg_dump -h localhost -U username -d ninjaserver > backup.sql
# Backup wiederherstellen
psql -h localhost -U username -d ninjaserver < backup.sql
```
### Log-Rotation
```bash
# Logrotate-Konfiguration
/var/log/ninjaserver/*.log {
daily
missingok
rotate 30
compress
delaycompress
notifempty
create 644 node node
postrotate
systemctl reload ninjaserver
endscript
}
```
### Performance-Optimierung
```sql
-- Query-Performance analysieren
EXPLAIN ANALYZE SELECT * FROM times
WHERE player_id = 'uuid'
ORDER BY created_at DESC;
-- Indizes hinzufügen
CREATE INDEX CONCURRENTLY idx_times_player_created
ON times(player_id, created_at DESC);
```
### Sicherheits-Updates
```bash
# Abhängigkeiten aktualisieren
npm audit
npm audit fix
# Sicherheits-Updates
npm update
```
---
**Hinweis:** Für detaillierte API-Dokumentation siehe [API Referenz](API-Referenz) und für Achievement-Details siehe [Achievement System](Achievement-System).

380
wiki/FAQ.md Normal file
View File

@@ -0,0 +1,380 @@
# ❓ FAQ - Häufige Fragen
Antworten auf häufig gestellte Fragen zum Ninja Cross Parkour System.
## 🚀 Installation und Setup
### Wie installiere ich das System?
Siehe [Schnellstart](Schnellstart) für eine detaillierte Anleitung. Kurz gesagt:
1. Repository klonen
2. `npm install` ausführen
3. `.env`-Datei konfigurieren
4. `npm run init-db` ausführen
5. `npm start` starten
### Welche Voraussetzungen benötige ich?
- Node.js v16 oder höher
- PostgreSQL 12 oder höher
- npm oder yarn
- Git für die Installation
### Wie konfiguriere ich die Datenbank?
Bearbeiten Sie die `.env`-Datei mit Ihren Datenbankdaten:
```env
DB_HOST=localhost
DB_PORT=5432
DB_NAME=ninjaserver
DB_USER=your_username
DB_PASSWORD=your_password
```
### Wie erstelle ich den ersten Admin-Benutzer?
```bash
npm run create-user
```
Standard-Anmeldedaten: `admin` / `admin123`
## 🔐 Authentifizierung
### Wie funktioniert die API-Authentifizierung?
Das System verwendet API-Keys für die Authentifizierung. Generieren Sie einen Key:
```bash
curl -X POST http://localhost:3000/api/v1/web/generate-api-key \
-H "Content-Type: application/json" \
-d '{"description": "Mein API Key"}'
```
### Wie verwende ich den API-Key?
Fügen Sie den Key in den Authorization Header ein:
```http
Authorization: Bearer YOUR_API_KEY_HERE
```
### Wie lange sind API-Keys gültig?
API-Keys sind standardmäßig unbegrenzt gültig, können aber mit einem Ablaufdatum versehen werden.
### Kann ich mehrere API-Keys haben?
Ja, Sie können beliebig viele API-Keys erstellen, z.B. für verschiedene Anwendungen oder Benutzer.
## 🏃‍♂️ Spieler und Zeiten
### Wie registriere ich einen neuen Spieler?
```bash
curl -X POST http://localhost:3000/api/v1/public/players \
-H "Content-Type: application/json" \
-d '{"firstname": "Max", "lastname": "Mustermann", "birthdate": "1990-01-01", "rfiduid": "AA:BB:CC:DD"}'
```
### Wie verknüpfe ich eine RFID-Karte mit einem Spieler?
```bash
curl -X POST http://localhost:3000/api/v1/public/link-by-rfid \
-H "Content-Type: application/json" \
-d '{"rfiduid": "AA:BB:CC:DD", "supabase_user_id": "uuid-here"}'
```
### Wie messe ich eine Zeit?
```bash
curl -X POST http://localhost:3000/api/v1/private/create-time \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"player_id": "AA:BB:CC:DD", "location_id": "Standort-Name", "recorded_time": "01:23.456"}'
```
### Welches Zeitformat wird verwendet?
Zeiten werden im Format `MM:SS.mmm` gespeichert:
- Minuten:Sekunden.Millisekunden
- Beispiel: `01:23.456` = 1 Minute, 23 Sekunden, 456 Millisekunden
### Was ist eine gültige Zeit?
- **Minimum:** 30 Sekunden
- **Maximum:** 10 Minuten
- **Schwelle:** Konfigurierbar pro Standort (Standard: 120 Sekunden)
## 🏆 Achievements
### Wie funktioniert das Achievement-System?
Das System überprüft automatisch täglich alle Spieler auf neue Achievements. Siehe [Achievement System](Achievement-System) für Details.
### Wann werden Achievements vergeben?
- **Automatisch:** Täglich um 23:59 Uhr
- **Manuell:** Über API-Endpoints
- **Sofort:** Bei bestimmten Aktionen
### Wie viele Achievements gibt es?
Das System hat 32 verschiedene Achievements in 4 Kategorien:
- Konsistenz-basierte (8)
- Verbesserungs-basierte (4)
- Saisonale (4)
- Monatliche (12)
- Jahreszeiten (4)
### Wie rufe ich Achievements ab?
```bash
# Alle Achievements
curl http://localhost:3000/api/achievements
# Spieler-Achievements
curl http://localhost:3000/api/achievements/player/{playerId}
# Spieler-Statistiken
curl http://localhost:3000/api/achievements/player/{playerId}/stats
```
## 📍 Standorte
### Wie erstelle ich einen neuen Standort?
```bash
curl -X POST http://localhost:3000/api/v1/private/create-location \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "München", "latitude": 48.1351, "longitude": 11.5820}'
```
### Wie ändere ich die Zeit-Schwelle eines Standorts?
```bash
curl -X PUT http://localhost:3000/api/v1/private/locations/{id}/threshold \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"threshold_seconds": 120}'
```
### Wie viele Standorte kann ich haben?
Es gibt keine Begrenzung der Anzahl der Standorte.
## 🔧 Technische Fragen
### Wie starte ich den Server?
```bash
# Entwicklung
npm run dev
# Produktion
npm start
```
### Wie überwache ich den Server?
```bash
# Logs anzeigen
tail -f logs/server.log
# Achievement-Logs
tail -f /var/log/ninjaserver_achievements.log
# Datenbank-Status
psql -d ninjaserver -c "SELECT NOW();"
```
### Wie teste ich die API?
```bash
# Test-Skript ausführen
node test-api.js
# Einzelne Endpoints testen
curl http://localhost:3000/api/v1/public/locations
```
### Wie sichere ich die Datenbank?
```bash
# Vollständiges Backup
pg_dump -h localhost -U username -d ninjaserver > backup.sql
# Wiederherstellung
psql -h localhost -U username -d ninjaserver < backup.sql
```
## 🐛 Fehlerbehebung
### "Port 3000 bereits belegt"
```bash
# Port freigeben
sudo lsof -ti:3000 | xargs kill -9
# Oder anderen Port verwenden
PORT=3001 npm start
```
### "Datenbank-Verbindung fehlgeschlagen"
1. PostgreSQL-Service prüfen: `sudo systemctl status postgresql`
2. Datenbank-Credentials in `.env` prüfen
3. Firewall-Einstellungen prüfen
### "API-Key funktioniert nicht"
1. API-Key neu generieren
2. Authorization Header prüfen: `Bearer YOUR_API_KEY`
3. Token-Ablaufzeit prüfen
### "Zeit wird nicht gespeichert"
1. Gültige Zeit prüfen (innerhalb der Schwelle)
2. Standort korrekt ausgewählt
3. Internetverbindung prüfen
4. Seite neu laden
### "Achievements werden nicht vergeben"
1. Tägliche Prüfung abwarten
2. Bedingungen erfüllt prüfen
3. System-Status prüfen
4. Administrator kontaktieren
## 📊 Statistiken und Monitoring
### Wie rufe ich Statistiken ab?
```bash
# Admin-Statistiken
curl -H "Authorization: Bearer ADMIN_TOKEN" http://localhost:3000/api/v1/admin/stats
# Seiten-Statistiken
curl -H "Authorization: Bearer ADMIN_TOKEN" http://localhost:3000/api/v1/admin/page-stats
```
### Wie überwache ich die Performance?
```sql
-- Langsamste Queries
SELECT query, calls, total_time, mean_time
FROM pg_stat_statements
ORDER BY mean_time DESC LIMIT 10;
-- Index-Nutzung
SELECT schemaname, tablename, indexname, idx_scan
FROM pg_stat_user_indexes
ORDER BY idx_scan DESC;
```
### Wie prüfe ich die Datenbank-Größe?
```sql
-- Gesamtgröße
SELECT pg_size_pretty(pg_database_size('ninjaserver'));
-- Tabellen-Größen
SELECT tablename, pg_size_pretty(pg_total_relation_size(tablename)) as size
FROM pg_tables WHERE schemaname = 'public'
ORDER BY pg_total_relation_size(tablename) DESC;
```
## 🔒 Sicherheit
### Wie sichere ich das System?
- Standardpasswörter ändern
- HTTPS in der Produktion verwenden
- Regelmäßige Backups
- API-Keys regelmäßig rotieren
- Firewall konfigurieren
### Wie ändere ich das Admin-Passwort?
```bash
# Neuen Admin-Benutzer erstellen
npm run create-user
# Oder direkt in der Datenbank
UPDATE adminusers SET password_hash = '$2b$10$...' WHERE username = 'admin';
```
### Wie deaktiviere ich einen API-Key?
```sql
UPDATE api_tokens SET is_active = false WHERE token = 'YOUR_TOKEN';
```
## 🌐 Deployment
### Wie deploye ich in die Produktion?
1. Server vorbereiten (Node.js, PostgreSQL)
2. Code deployen
3. Umgebungsvariablen setzen
4. Datenbank initialisieren
5. Nginx konfigurieren
6. SSL-Zertifikat einrichten
### Wie konfiguriere ich Nginx?
```nginx
server {
listen 80;
server_name ninja.reptilfpv.de;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}
```
### Wie richte ich SSL ein?
```bash
# Let's Encrypt
certbot --nginx -d ninja.reptilfpv.de
```
## 📱 Frontend
### Wie integriere ich das System in meine Website?
Verwenden Sie die REST API oder das Web-Interface. Siehe [API Referenz](API-Referenz) für Details.
### Gibt es eine mobile App?
Eine native mobile App ist geplant, aber noch nicht verfügbar. Das Web-Interface ist responsive und funktioniert auf allen Geräten.
### Wie passe ich das Design an?
Bearbeiten Sie die CSS-Dateien im `public/css/` Verzeichnis oder erstellen Sie ein eigenes Frontend mit der API.
## 🔄 Updates und Wartung
### Wie aktualisiere ich das System?
```bash
# Code aktualisieren
git pull origin main
# Abhängigkeiten aktualisieren
npm install
# Datenbank-Migrationen (falls vorhanden)
npm run migrate
# Server neu starten
npm restart
```
### Wie führe ich Wartungsaufgaben durch?
```sql
-- Tabellen analysieren
ANALYZE;
-- Indizes neu aufbauen
REINDEX DATABASE ninjaserver;
-- Vakuum durchführen
VACUUM ANALYZE;
```
### Wie lösche ich alte Daten?
```sql
-- Alte Zeiten löschen (älter als 1 Jahr)
DELETE FROM times WHERE created_at < NOW() - INTERVAL '1 year';
-- Alte Seitenaufrufe löschen (älter als 6 Monate)
DELETE FROM page_views WHERE created_at < NOW() - INTERVAL '6 months';
```
## 📞 Support
### Wo bekomme ich Hilfe?
- 📖 Konsultieren Sie diese Dokumentation
- 🔍 Schauen Sie in [Troubleshooting](Troubleshooting)
- 📧 Kontaktieren Sie den Systemadministrator
- 🐛 Melden Sie Bugs über das Issue-System
### Wie melde ich einen Bug?
1. Beschreiben Sie das Problem
2. Fügen Sie Logs hinzu
3. Geben Sie Schritte zur Reproduktion an
4. Erwähnen Sie Ihre Systemkonfiguration
### Wie schlage ich eine Verbesserung vor?
1. Beschreiben Sie die gewünschte Funktion
2. Erklären Sie den Nutzen
3. Geben Sie Beispiele an
4. Erwähnen Sie mögliche Implementierung
---
**Hinweis:** Diese FAQ wird regelmäßig aktualisiert. Bei Fragen, die hier nicht beantwortet werden, wenden Sie sich an den Support.

92
wiki/Home.md Normal file
View File

@@ -0,0 +1,92 @@
# 🏊‍♂️ Ninja Cross Parkour System Wiki
Willkommen zum **Ninja Cross Parkour System** - einem interaktiven Zeitmessungssystem für das Schwimmbad!
## 📋 Inhaltsverzeichnis
- [🏠 Home](Home) - Diese Seite
- [🚀 Schnellstart](Schnellstart) - Installation und erste Schritte
- [📖 Benutzerhandbuch](Benutzerhandbuch) - Anleitung für Endbenutzer
- [🔧 Entwicklerhandbuch](Entwicklerhandbuch) - Technische Dokumentation
- [📡 API Referenz](API-Referenz) - Vollständige API-Dokumentation
- [🏆 Achievement System](Achievement-System) - Gamification Features
- [🗄️ Datenbank](Datenbank) - Schema und Struktur
- [🔒 Sicherheit](Sicherheit) - Authentifizierung und Berechtigungen
- [🚀 Deployment](Deployment) - Produktionsumgebung
- [❓ FAQ](FAQ) - Häufige Fragen
- [🐛 Troubleshooting](Troubleshooting) - Problembehandlung
## 🎯 Was ist das Ninja Cross Parkour System?
Das **Ninja Cross Parkour System** ist ein innovatives Zeitmessungssystem, das speziell für Schwimmbäder entwickelt wurde. Es ermöglicht es Besuchern, ihre Parkour-Zeiten zu messen, zu verfolgen und sich mit anderen zu vergleichen.
### ✨ Hauptfunktionen
- **⏱️ Präzise Zeitmessung** mit RFID-Technologie
- **🗺️ Interaktive Karte** mit Standortverwaltung
- **🏆 Achievement-System** mit 32 verschiedenen Erfolgen
- **📊 Statistiken** und Bestenlisten
- **🔔 Push-Benachrichtigungen** für neue Rekorde
- **🌐 REST API** für Integrationen
- **📱 Responsive Web-Interface** für alle Geräte
### 🎮 Wie funktioniert es?
1. **Spieler registrieren** sich über das Web-Interface
2. **RFID-Karten** werden mit Spielerprofilen verknüpft
3. **Zeitmessung** erfolgt automatisch beim Start/Stopp
4. **Achievements** werden automatisch vergeben
5. **Statistiken** werden in Echtzeit aktualisiert
## 🏗️ System-Architektur
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Frontend │ │ Backend │ │ Database │
│ (Web UI) │◄──►│ (Node.js) │◄──►│ (PostgreSQL) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ RFID Reader │ │ API Endpoints │ │ Achievement │
│ (Hardware) │ │ (REST) │ │ System │
└─────────────────┘ └─────────────────┘ └─────────────────┘
```
## 🎯 Zielgruppen
### 👥 Endbenutzer (Schwimmbadbesucher)
- Zeitmessung und -verfolgung
- Achievement-Sammlung
- Statistiken und Fortschritt
- Bestenlisten
### 👨‍💼 Administratoren
- Spieler- und Standortverwaltung
- System-Monitoring
- Statistiken und Berichte
- API-Key Management
### 👨‍💻 Entwickler
- API-Integration
- Custom Frontend
- Datenbank-Zugriff
- System-Erweiterungen
## 🚀 Schnellstart
Für einen schnellen Einstieg siehe [Schnellstart](Schnellstart).
## 📞 Support
Bei Fragen oder Problemen:
- 📖 Konsultieren Sie die [FAQ](FAQ)
- 🔍 Schauen Sie in [Troubleshooting](Troubleshooting)
- 📧 Kontaktieren Sie den Systemadministrator
---
**Version:** 1.0.0
**Letzte Aktualisierung:** $(date)
**Autor:** Carsten Graf

220
wiki/Schnellstart.md Normal file
View File

@@ -0,0 +1,220 @@
# 🚀 Schnellstart
Diese Anleitung führt Sie durch die Installation und den ersten Start des Ninja Cross Parkour Systems.
## 📋 Voraussetzungen
### System-Anforderungen
- **Node.js** v16 oder höher
- **PostgreSQL** 12 oder höher
- **npm** oder **yarn**
- **Git** (für Installation)
### Hardware-Anforderungen
- **RFID-Reader** für Zeitmessung
- **RFID-Karten** für Spieler
- **Server** (Linux empfohlen)
- **Netzwerk** für API-Zugriff
## 🔧 Installation
### 1. Repository klonen
```bash
git clone <repository-url>
cd ninjaserver
```
### 2. Abhängigkeiten installieren
```bash
npm install
```
### 3. Umgebungsvariablen konfigurieren
```bash
cp .env.example .env
```
Bearbeiten Sie die `.env`-Datei:
```env
# Datenbank
DB_HOST=localhost
DB_PORT=5432
DB_NAME=ninjaserver
DB_USER=your_username
DB_PASSWORD=your_password
# Server
PORT=3000
NODE_ENV=development
# JWT Secret
JWT_SECRET=your_jwt_secret_here
# Supabase (optional)
SUPABASE_URL=your_supabase_url
SUPABASE_ANON_KEY=your_supabase_key
```
### 4. Datenbank initialisieren
```bash
npm run init-db
```
### 5. Standardbenutzer erstellen
```bash
npm run create-user
```
## 🚀 Server starten
### Entwicklungsumgebung
```bash
npm run dev
```
Server läuft auf: `http://localhost:3000`
### Produktionsumgebung
```bash
npm start
```
## 🔐 Erste Anmeldung
### Web-Interface
1. Öffnen Sie `http://localhost:3000`
2. Melden Sie sich an mit:
- **Benutzername:** `admin`
- **Passwort:** `admin123`
### API-Key generieren
```bash
curl -X POST http://localhost:3000/api/v1/web/generate-api-key \
-H "Content-Type: application/json" \
-d '{"description": "Mein erster API Key", "standorte": "Test"}'
```
## 🎮 Erste Schritte
### 1. Standort erstellen
```bash
curl -X POST http://localhost:3000/api/v1/private/create-location \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "Test-Standort", "latitude": 48.1351, "longitude": 11.5820}'
```
### 2. Spieler erstellen
```bash
curl -X POST http://localhost:3000/api/v1/public/players \
-H "Content-Type: application/json" \
-d '{"firstname": "Max", "lastname": "Mustermann", "birthdate": "1990-01-01", "rfiduid": "AA:BB:CC:DD"}'
```
### 3. Zeit messen
```bash
curl -X POST http://localhost:3000/api/v1/private/create-time \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"player_id": "AA:BB:CC:DD", "location_id": "Test-Standort", "recorded_time": "01:23.456"}'
```
## 🧪 System testen
### API-Test ausführen
```bash
# Test-Skript bearbeiten
nano test-api.js
# API_KEY in der Datei setzen
node test-api.js
```
### Web-Interface testen
1. Öffnen Sie `http://localhost:3000`
2. Melden Sie sich an
3. Erstellen Sie einen Standort
4. Fügen Sie einen Spieler hinzu
5. Messen Sie eine Zeit
## 📊 Monitoring
### Logs überwachen
```bash
# Server-Logs
tail -f logs/server.log
# Achievement-Logs
tail -f /var/log/ninjaserver_achievements.log
```
### Datenbank-Status prüfen
```sql
-- Verbindung testen
SELECT NOW();
-- Tabellen anzeigen
\dt
-- Spieler zählen
SELECT COUNT(*) FROM players;
```
## 🔧 Konfiguration
### Standort-Schwellenwerte
```bash
curl -X PUT http://localhost:3000/api/v1/private/locations/{id}/threshold \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"threshold_seconds": 120}'
```
### Achievement-System aktivieren
```bash
# Tägliche Prüfung einrichten
node scripts/setup_cron.js setup
# Status prüfen
node scripts/setup_cron.js status
```
## 🚨 Häufige Probleme
### Port bereits belegt
```bash
# Port 3000 freigeben
sudo lsof -ti:3000 | xargs kill -9
# Oder anderen Port verwenden
PORT=3001 npm start
```
### Datenbank-Verbindung fehlgeschlagen
1. PostgreSQL-Service prüfen: `sudo systemctl status postgresql`
2. Datenbank-Credentials in `.env` prüfen
3. Firewall-Einstellungen prüfen
### API-Key funktioniert nicht
1. API-Key neu generieren
2. Authorization Header prüfen: `Bearer YOUR_API_KEY`
3. Token-Ablaufzeit prüfen
## 📚 Nächste Schritte
Nach der erfolgreichen Installation:
1. 📖 Lesen Sie das [Benutzerhandbuch](Benutzerhandbuch)
2. 🔧 Konsultieren Sie die [Entwicklerhandbuch](Entwicklerhandbuch)
3. 📡 Schauen Sie in die [API Referenz](API-Referenz)
4. 🏆 Entdecken Sie das [Achievement System](Achievement-System)
## 🆘 Hilfe
Bei Problemen:
- 📖 [FAQ](FAQ) konsultieren
- 🔍 [Troubleshooting](Troubleshooting) durchgehen
- 📧 Support kontaktieren
---
**Tipp:** Verwenden Sie `npm run dev` für die Entwicklung - der Server startet automatisch neu bei Änderungen!

621
wiki/Sicherheit.md Normal file
View File

@@ -0,0 +1,621 @@
# 🔒 Sicherheit
Sicherheitsrichtlinien und Best Practices für das Ninja Cross Parkour System.
## 📋 Inhaltsverzeichnis
- [🛡️ Sicherheitsübersicht](#-sicherheitsübersicht)
- [🔐 Authentifizierung](#-authentifizierung)
- [🔑 Autorisierung](#-autorisierung)
- [🛡️ Datenverschlüsselung](#-datenverschlüsselung)
- [🌐 Netzwerksicherheit](#-netzwerksicherheit)
- [🗄️ Datenbanksicherheit](#-datenbanksicherheit)
- [📱 API-Sicherheit](#-api-sicherheit)
- [🔍 Monitoring](#-monitoring)
- [🚨 Incident Response](#-incident-response)
## 🛡️ Sicherheitsübersicht
### Sicherheitsprinzipien
- **Defense in Depth** - Mehrschichtige Sicherheit
- **Least Privilege** - Minimale Berechtigungen
- **Zero Trust** - Kein Vertrauen ohne Verifikation
- **Security by Design** - Sicherheit von Anfang an
### Bedrohungsmodell
- **Externe Angriffe** - Unbefugter Zugriff von außen
- **Interne Bedrohungen** - Missbrauch durch autorisierte Benutzer
- **Datenlecks** - Unbefugte Offenlegung von Daten
- **Service-Ausfälle** - Verfügbarkeitsprobleme
## 🔐 Authentifizierung
### Passwort-Sicherheit
```javascript
// Passwort-Hashing mit bcrypt
const bcrypt = require('bcrypt');
// Passwort hashen
const saltRounds = 12;
const hashedPassword = await bcrypt.hash(password, saltRounds);
// Passwort verifizieren
const isValid = await bcrypt.compare(password, hashedPassword);
```
### Passwort-Richtlinien
- **Mindestlänge:** 12 Zeichen
- **Komplexität:** Groß-/Kleinbuchstaben, Zahlen, Sonderzeichen
- **Keine Wörterbuchwörter**
- **Regelmäßige Rotation** (alle 90 Tage)
### Multi-Faktor-Authentifizierung (MFA)
```javascript
// TOTP-Implementierung
const speakeasy = require('speakeasy');
// Secret generieren
const secret = speakeasy.generateSecret({
name: 'Ninja Parkour',
account: 'admin@example.com'
});
// Token verifizieren
const verified = speakeasy.totp.verify({
secret: secret.base32,
encoding: 'base32',
token: userToken,
window: 2
});
```
### Session-Management
```javascript
// Sichere Session-Konfiguration
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000, // 24 Stunden
sameSite: 'strict'
}
}));
```
## 🔑 Autorisierung
### Rollenbasierte Zugriffskontrolle (RBAC)
```javascript
// Rollen definieren
const ROLES = {
ADMIN: 'admin',
MODERATOR: 'moderator',
USER: 'user',
GUEST: 'guest'
};
// Berechtigungen definieren
const PERMISSIONS = {
READ_PLAYERS: 'read:players',
WRITE_PLAYERS: 'write:players',
DELETE_PLAYERS: 'delete:players',
READ_TIMES: 'read:times',
WRITE_TIMES: 'write:times',
ADMIN_ACCESS: 'admin:access'
};
// Rollen-Berechtigungen
const rolePermissions = {
[ROLES.ADMIN]: Object.values(PERMISSIONS),
[ROLES.MODERATOR]: [
PERMISSIONS.READ_PLAYERS,
PERMISSIONS.WRITE_PLAYERS,
PERMISSIONS.READ_TIMES,
PERMISSIONS.WRITE_TIMES
],
[ROLES.USER]: [
PERMISSIONS.READ_PLAYERS,
PERMISSIONS.READ_TIMES
]
};
```
### API-Key-Management
```javascript
// API-Key generieren
const generateAPIKey = () => {
return crypto.randomBytes(32).toString('hex');
};
// API-Key validieren
const validateAPIKey = async (apiKey) => {
const token = await db.query(
'SELECT * FROM api_tokens WHERE token = $1 AND is_active = true',
[apiKey]
);
return token.rows.length > 0;
};
// Berechtigungen prüfen
const checkPermission = (userRole, requiredPermission) => {
const userPermissions = rolePermissions[userRole] || [];
return userPermissions.includes(requiredPermission);
};
```
### Middleware für Autorisierung
```javascript
// Authentifizierung prüfen
const requireAuth = (req, res, next) => {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Nicht authentifiziert' });
}
next();
};
// Rolle prüfen
const requireRole = (role) => {
return (req, res, next) => {
if (req.session.role !== role) {
return res.status(403).json({ error: 'Keine Berechtigung' });
}
next();
};
};
// Berechtigung prüfen
const requirePermission = (permission) => {
return (req, res, next) => {
const userRole = req.session.role;
if (!checkPermission(userRole, permission)) {
return res.status(403).json({ error: 'Keine Berechtigung' });
}
next();
};
};
```
## 🛡️ Datenverschlüsselung
### Verschlüsselung im Ruhezustand
```javascript
// Datenbank-Verschlüsselung
const crypto = require('crypto');
// Verschlüsselung
const encrypt = (text, key) => {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipher('aes-256-cbc', key);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
return iv.toString('hex') + ':' + encrypted;
};
// Entschlüsselung
const decrypt = (text, key) => {
const textParts = text.split(':');
const iv = Buffer.from(textParts.shift(), 'hex');
const encryptedText = textParts.join(':');
const decipher = crypto.createDecipher('aes-256-cbc', key);
let decrypted = decipher.update(encryptedText, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
};
```
### Verschlüsselung in Bewegung
```javascript
// HTTPS-Konfiguration
const https = require('https');
const fs = require('fs');
const options = {
key: fs.readFileSync('private-key.pem'),
cert: fs.readFileSync('certificate.pem'),
ciphers: [
'ECDHE-RSA-AES256-GCM-SHA384',
'ECDHE-RSA-AES128-GCM-SHA256',
'ECDHE-RSA-AES256-SHA384',
'ECDHE-RSA-AES128-SHA256'
].join(':'),
honorCipherOrder: true
};
https.createServer(options, app).listen(443);
```
### Passwort-Verschlüsselung
```javascript
// Starke Passwort-Generierung
const generatePassword = (length = 16) => {
const charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*';
let password = '';
for (let i = 0; i < length; i++) {
password += charset.charAt(Math.floor(Math.random() * charset.length));
}
return password;
};
// Passwort-Stärke prüfen
const validatePassword = (password) => {
const minLength = 12;
const hasUpperCase = /[A-Z]/.test(password);
const hasLowerCase = /[a-z]/.test(password);
const hasNumbers = /\d/.test(password);
const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(password);
return password.length >= minLength &&
hasUpperCase &&
hasLowerCase &&
hasNumbers &&
hasSpecialChar;
};
```
## 🌐 Netzwerksicherheit
### Firewall-Konfiguration
```bash
# UFW-Firewall (Ubuntu)
sudo ufw enable
sudo ufw default deny incoming
sudo ufw default allow outgoing
# Erlaubte Ports
sudo ufw allow 22/tcp # SSH
sudo ufw allow 80/tcp # HTTP
sudo ufw allow 443/tcp # HTTPS
sudo ufw allow 3000/tcp # App (nur intern)
# Rate Limiting
sudo ufw limit ssh
```
### DDoS-Schutz
```javascript
// Rate Limiting
const rateLimit = require('express-rate-limit');
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 Minuten
max: 100, // Max 100 Requests pro IP
message: 'Zu viele Anfragen von dieser IP',
standardHeaders: true,
legacyHeaders: false
});
app.use('/api/', limiter);
// Strikte Rate Limits für sensible Endpoints
const strictLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
message: 'Zu viele Login-Versuche'
});
app.use('/api/login', strictLimiter);
```
### CORS-Konfiguration
```javascript
// Sichere CORS-Einstellungen
const cors = require('cors');
const corsOptions = {
origin: (origin, callback) => {
const allowedOrigins = [
'https://ninja.reptilfpv.de',
'https://www.ninja.reptilfpv.de'
];
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Nicht erlaubt durch CORS'));
}
},
credentials: true,
optionsSuccessStatus: 200
};
app.use(cors(corsOptions));
```
### SSL/TLS-Konfiguration
```javascript
// SSL-Redirect
app.use((req, res, next) => {
if (req.header('x-forwarded-proto') !== 'https') {
res.redirect(`https://${req.header('host')}${req.url}`);
} else {
next();
}
});
// Security Headers
const helmet = require('helmet');
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
scriptSrc: ["'self'"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
mediaSrc: ["'self'"],
frameSrc: ["'none'"]
}
},
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true
}
}));
```
## 🗄️ Datenbanksicherheit
### Verbindungssicherheit
```javascript
// Sichere Datenbankverbindung
const dbConfig = {
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: {
rejectUnauthorized: true,
ca: fs.readFileSync('ca-certificate.pem')
},
connectionTimeoutMillis: 5000,
idleTimeoutMillis: 30000,
max: 20
};
```
### SQL-Injection-Schutz
```javascript
// Parametrisierte Queries
const getUser = async (userId) => {
const query = 'SELECT * FROM players WHERE id = $1';
const values = [userId];
const result = await db.query(query, values);
return result.rows[0];
};
// Input-Validierung
const validateInput = (input) => {
if (typeof input !== 'string') {
throw new Error('Invalid input type');
}
// SQL-Injection-Patterns erkennen
const sqlPatterns = [
/(\b(SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|EXEC|UNION|SCRIPT)\b)/gi,
/(\b(OR|AND)\s+\d+\s*=\s*\d+)/gi,
/(\b(OR|AND)\s+['"]\s*=\s*['"])/gi
];
for (const pattern of sqlPatterns) {
if (pattern.test(input)) {
throw new Error('Potential SQL injection detected');
}
}
return input;
};
```
### Datenbank-Berechtigungen
```sql
-- Benutzer mit minimalen Rechten erstellen
CREATE USER ninja_app WITH PASSWORD 'secure_password';
-- Nur notwendige Berechtigungen gewähren
GRANT SELECT, INSERT, UPDATE, DELETE ON players TO ninja_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON times TO ninja_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON locations TO ninja_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON achievements TO ninja_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON player_achievements TO ninja_app;
-- Keine Admin-Rechte
REVOKE ALL ON SCHEMA public FROM ninja_app;
```
## 📱 API-Sicherheit
### Input-Validierung
```javascript
// Joi-Schema für Validierung
const Joi = require('joi');
const playerSchema = Joi.object({
firstname: Joi.string().min(2).max(50).required(),
lastname: Joi.string().min(2).max(50).required(),
birthdate: Joi.date().max('now').required(),
rfiduid: Joi.string().pattern(/^[A-F0-9:]{11}$/).optional()
});
const validatePlayer = (req, res, next) => {
const { error } = playerSchema.validate(req.body);
if (error) {
return res.status(400).json({ error: error.details[0].message });
}
next();
};
```
### API-Versionierung
```javascript
// API-Versionierung
app.use('/api/v1', v1Routes);
app.use('/api/v2', v2Routes);
// Deprecation-Warnungen
app.use('/api/v1', (req, res, next) => {
res.set('X-API-Version', '1.0.0');
res.set('X-API-Deprecated', 'false');
next();
});
```
### Request-Logging
```javascript
// Sicherheitsrelevante Logs
const securityLogger = winston.createLogger({
level: 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
transports: [
new winston.transports.File({ filename: 'logs/security.log' })
]
});
// Login-Versuche loggen
app.post('/api/login', (req, res) => {
const { username, ip } = req.body;
securityLogger.info('Login attempt', { username, ip, timestamp: new Date() });
// ... Login-Logik
});
```
## 🔍 Monitoring
### Sicherheits-Monitoring
```javascript
// Anomalie-Erkennung
const detectAnomalies = (req, res, next) => {
const ip = req.ip;
const userAgent = req.get('User-Agent');
// Verdächtige Patterns erkennen
const suspiciousPatterns = [
/sqlmap/i,
/nikto/i,
/nmap/i,
/masscan/i
];
for (const pattern of suspiciousPatterns) {
if (pattern.test(userAgent)) {
securityLogger.warn('Suspicious user agent detected', { ip, userAgent });
return res.status(403).json({ error: 'Request blocked' });
}
}
next();
};
```
### Log-Analyse
```bash
# Sicherheitsrelevante Logs analysieren
grep "ERROR\|WARN\|SECURITY" logs/server.log | tail -100
# Failed Login-Versuche
grep "Login attempt" logs/security.log | grep "failed"
# Verdächtige Aktivitäten
grep "suspicious\|anomaly\|attack" logs/security.log
```
### Alerting
```javascript
// E-Mail-Benachrichtigungen
const nodemailer = require('nodemailer');
const transporter = nodemailer.createTransporter({
host: 'smtp.gmail.com',
port: 587,
secure: false,
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASS
}
});
const sendSecurityAlert = async (message) => {
await transporter.sendMail({
from: process.env.EMAIL_USER,
to: process.env.ADMIN_EMAIL,
subject: 'Security Alert - Ninja Parkour',
text: message
});
};
```
## 🚨 Incident Response
### Incident-Response-Plan
1. **Erkennung** - Monitoring und Alerts
2. **Bewertung** - Schweregrad bestimmen
3. **Eindämmung** - Schaden begrenzen
4. **Eliminierung** - Bedrohung entfernen
5. **Wiederherstellung** - System reparieren
6. **Lektionen** - Aus Fehlern lernen
### Notfall-Kontakte
- **Systemadministrator:** +49 123 456 789
- **Sicherheitsbeauftragter:** security@ninjaparkour.de
- **Management:** management@ninjaparkour.de
### Backup-Strategie
```bash
# Tägliche Backups
pg_dump -h localhost -U username -d ninjaserver | gzip > backup_$(date +%Y%m%d).sql.gz
# Wöchentliche Vollständige Backups
tar -czf full_backup_$(date +%Y%m%d).tar.gz /var/lib/postgresql/data/
# Backup-Verifizierung
psql -d ninjaserver < backup_$(date +%Y%m%d).sql
```
### Recovery-Prozeduren
```bash
# System wiederherstellen
sudo systemctl stop ninjaserver
psql -d ninjaserver < latest_backup.sql
sudo systemctl start ninjaserver
# Datenbank reparieren
psql -d ninjaserver -c "VACUUM FULL;"
psql -d ninjaserver -c "REINDEX DATABASE ninjaserver;"
```
## 🔧 Sicherheits-Checkliste
### Regelmäßige Überprüfungen
- [ ] Passwörter geändert (alle 90 Tage)
- [ ] API-Keys rotiert (alle 180 Tage)
- [ ] SSL-Zertifikate gültig
- [ ] Sicherheits-Updates installiert
- [ ] Logs überprüft
- [ ] Backups getestet
- [ ] Penetrationstests durchgeführt
### Wöchentliche Aufgaben
- [ ] Sicherheits-Logs analysiert
- [ ] System-Updates geprüft
- [ ] Backup-Status überprüft
- [ ] Performance-Metriken analysiert
### Monatliche Aufgaben
- [ ] Sicherheits-Audit durchgeführt
- [ ] Berechtigungen überprüft
- [ ] Incident-Response-Plan getestet
- [ ] Sicherheitsschulungen durchgeführt
---
**Wichtig:** Diese Sicherheitsrichtlinien müssen regelmäßig überprüft und aktualisiert werden. Bei Sicherheitsvorfällen wenden Sie sich sofort an den Sicherheitsbeauftragten.

687
wiki/Troubleshooting.md Normal file
View File

@@ -0,0 +1,687 @@
# 🐛 Troubleshooting
Problembehandlung und Lösungen für häufige Probleme im Ninja Cross Parkour System.
## 📋 Inhaltsverzeichnis
- [🚨 Kritische Probleme](#-kritische-probleme)
- [🔧 Installation](#-installation)
- [🗄️ Datenbank](#-datenbank)
- [🌐 API](#-api)
- [🏆 Achievements](#-achievements)
- [📊 Performance](#-performance)
- [🔒 Sicherheit](#-sicherheit)
- [📱 Frontend](#-frontend)
## 🚨 Kritische Probleme
### Server startet nicht
**Symptome:**
- `npm start` schlägt fehl
- Port-Fehler
- Abhängigkeitsfehler
**Lösungen:**
```bash
# Port freigeben
sudo lsof -ti:3000 | xargs kill -9
# Abhängigkeiten neu installieren
rm -rf node_modules package-lock.json
npm install
# Anderen Port verwenden
PORT=3001 npm start
# Logs prüfen
tail -f logs/server.log
```
### Datenbank-Verbindung fehlgeschlagen
**Symptome:**
- `ECONNREFUSED` Fehler
- `database connection failed`
- Timeout-Fehler
**Lösungen:**
```bash
# PostgreSQL-Status prüfen
sudo systemctl status postgresql
# PostgreSQL starten
sudo systemctl start postgresql
# Verbindung testen
psql -h localhost -U username -d ninjaserver
# .env-Datei prüfen
cat .env | grep DB_
```
### API antwortet nicht
**Symptome:**
- 500 Internal Server Error
- Timeout-Fehler
- Leere Antworten
**Lösungen:**
```bash
# Server-Status prüfen
curl http://localhost:3000/health
# Logs analysieren
tail -f logs/server.log
# Datenbank-Verbindung prüfen
psql -d ninjaserver -c "SELECT NOW();"
# Speicher prüfen
free -h
df -h
```
## 🔧 Installation
### Node.js-Version inkompatibel
**Symptom:**
```
Error: Node.js version 14.x is not supported. Please use Node.js 16 or higher.
```
**Lösung:**
```bash
# Node.js-Version prüfen
node --version
# Node.js aktualisieren (Ubuntu/Debian)
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
sudo apt-get install -y nodejs
# Node.js aktualisieren (macOS)
brew install node@18
# Node.js aktualisieren (Windows)
# Download von https://nodejs.org/
```
### npm install schlägt fehl
**Symptome:**
- Permission denied
- ENOENT Fehler
- Network timeout
**Lösungen:**
```bash
# Berechtigungen prüfen
sudo chown -R $(whoami) ~/.npm
# Cache leeren
npm cache clean --force
# Registry wechseln
npm config set registry https://registry.npmjs.org/
# Mit sudo installieren (nicht empfohlen)
sudo npm install
```
### PostgreSQL nicht installiert
**Symptom:**
```
Error: connect ECONNREFUSED 127.0.0.1:5432
```
**Lösung:**
```bash
# PostgreSQL installieren (Ubuntu/Debian)
sudo apt update
sudo apt install postgresql postgresql-contrib
# PostgreSQL starten
sudo systemctl start postgresql
sudo systemctl enable postgresql
# Benutzer erstellen
sudo -u postgres createuser --interactive
sudo -u postgres createdb ninjaserver
```
### Umgebungsvariablen fehlen
**Symptom:**
```
Error: Missing required environment variable: DB_HOST
```
**Lösung:**
```bash
# .env-Datei erstellen
cp .env.example .env
# .env-Datei bearbeiten
nano .env
# Beispiel-Inhalt:
DB_HOST=localhost
DB_PORT=5432
DB_NAME=ninjaserver
DB_USER=username
DB_PASSWORD=password
JWT_SECRET=your-secret-key
```
## 🗄️ Datenbank
### Datenbank existiert nicht
**Symptom:**
```
Error: database "ninjaserver" does not exist
```
**Lösung:**
```bash
# Datenbank erstellen
sudo -u postgres createdb ninjaserver
# Oder mit psql
psql -U postgres -c "CREATE DATABASE ninjaserver;"
```
### Tabellen fehlen
**Symptom:**
```
Error: relation "players" does not exist
```
**Lösung:**
```bash
# Datenbank initialisieren
npm run init-db
# Oder manuell
psql -d ninjaserver -f scripts/init-db.js
```
### Verbindungslimit erreicht
**Symptom:**
```
Error: too many connections
```
**Lösung:**
```sql
-- Aktive Verbindungen prüfen
SELECT count(*) FROM pg_stat_activity;
-- Verbindungen beenden
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE state = 'idle' AND query_start < NOW() - INTERVAL '5 minutes';
-- Verbindungslimit erhöhen
ALTER SYSTEM SET max_connections = 200;
SELECT pg_reload_conf();
```
### Datenbank-Corruption
**Symptom:**
```
Error: database is not accepting commands
```
**Lösung:**
```bash
# Datenbank reparieren
pg_ctl stop
pg_ctl start -D /var/lib/postgresql/data
# Vakuum durchführen
psql -d ninjaserver -c "VACUUM FULL;"
# Backup wiederherstellen
psql -d ninjaserver < backup.sql
```
## 🌐 API
### 401 Unauthorized
**Symptom:**
```
{"success": false, "message": "API-Key erforderlich"}
```
**Lösung:**
```bash
# API-Key generieren
curl -X POST http://localhost:3000/api/v1/web/generate-api-key \
-H "Content-Type: application/json" \
-d '{"description": "Test Key"}'
# API-Key verwenden
curl -H "Authorization: Bearer YOUR_API_KEY" \
http://localhost:3000/api/v1/private/locations
```
### 403 Forbidden
**Symptom:**
```
{"success": false, "message": "Keine Berechtigung"}
```
**Lösung:**
```bash
# Admin-Token verwenden
curl -H "Authorization: Bearer ADMIN_TOKEN" \
http://localhost:3000/api/v1/admin/players
# Oder API-Key mit Admin-Rechten generieren
```
### 404 Not Found
**Symptom:**
```
{"success": false, "message": "Ressource nicht gefunden"}
```
**Lösung:**
```bash
# Korrekte URL verwenden
curl http://localhost:3000/api/v1/public/locations
# Endpoint-Liste prüfen
curl http://localhost:3000/api-docs
```
### 500 Internal Server Error
**Symptom:**
```
{"success": false, "message": "Internal Server Error"}
```
**Lösung:**
```bash
# Logs prüfen
tail -f logs/server.log
# Datenbank-Verbindung prüfen
psql -d ninjaserver -c "SELECT NOW();"
# Server neu starten
npm restart
```
### CORS-Fehler
**Symptom:**
```
Access to fetch at 'http://localhost:3000/api' from origin 'http://localhost:8080' has been blocked by CORS policy
```
**Lösung:**
```javascript
// CORS-Middleware konfigurieren
app.use(cors({
origin: ['http://localhost:8080', 'https://yourdomain.com'],
credentials: true
}));
```
## 🏆 Achievements
### Achievements werden nicht vergeben
**Symptom:**
- Spieler erfüllt Bedingungen, aber erhält kein Achievement
- Tägliche Prüfung läuft nicht
**Lösung:**
```bash
# Tägliche Prüfung manuell ausführen
node scripts/daily_achievements.js
# Cron-Job prüfen
node scripts/setup_cron.js status
# Cron-Job einrichten
node scripts/setup_cron.js setup
# Logs prüfen
tail -f /var/log/ninjaserver_achievements.log
```
### Achievement-Daten korrupt
**Symptom:**
- Achievements werden mehrfach vergeben
- Falsche Fortschrittswerte
**Lösung:**
```sql
-- Doppelte Achievements entfernen
DELETE FROM player_achievements
WHERE id NOT IN (
SELECT MIN(id)
FROM player_achievements
GROUP BY player_id, achievement_id
);
-- Fortschritt zurücksetzen
UPDATE player_achievements
SET progress = 0, is_completed = false
WHERE is_completed = false;
```
### Tägliche Prüfung schlägt fehl
**Symptom:**
```
Error: Daily achievement check failed
```
**Lösung:**
```bash
# Logs analysieren
grep "ERROR" /var/log/ninjaserver_achievements.log
# Datenbank-Verbindung prüfen
psql -d ninjaserver -c "SELECT NOW();"
# Berechtigungen prüfen
ls -la /var/log/ninjaserver_achievements.log
# Script manuell ausführen
node scripts/daily_achievements.js
```
## 📊 Performance
### Langsame API-Antworten
**Symptom:**
- API-Antworten dauern > 5 Sekunden
- Timeout-Fehler
**Lösung:**
```sql
-- Indizes prüfen
SELECT schemaname, tablename, indexname, idx_scan
FROM pg_stat_user_indexes
ORDER BY idx_scan DESC;
-- Langsame Queries identifizieren
SELECT query, calls, total_time, mean_time
FROM pg_stat_statements
ORDER BY mean_time DESC LIMIT 10;
-- Indizes hinzufügen
CREATE INDEX CONCURRENTLY idx_times_player_created
ON times(player_id, created_at DESC);
```
### Hohe CPU-Last
**Symptom:**
- Server verbraucht > 80% CPU
- Langsame Antwortzeiten
**Lösung:**
```bash
# Prozesse prüfen
top -p $(pgrep node)
# Speicher prüfen
free -h
# Datenbank-Verbindungen prüfen
psql -d ninjaserver -c "SELECT count(*) FROM pg_stat_activity;"
# Vakuum durchführen
psql -d ninjaserver -c "VACUUM ANALYZE;"
```
### Speicher-Leaks
**Symptom:**
- Speicherverbrauch steigt kontinuierlich
- Server stürzt ab
**Lösung:**
```bash
# Speicher-Usage prüfen
ps aux | grep node
# Server neu starten
pm2 restart ninjaserver
# Oder mit systemd
sudo systemctl restart ninjaserver
```
## 🔒 Sicherheit
### Schwache Passwörter
**Symptom:**
- Standardpasswörter in Produktion
- Sicherheitswarnungen
**Lösung:**
```bash
# Admin-Passwort ändern
npm run create-user
# Oder direkt in der Datenbank
psql -d ninjaserver -c "UPDATE adminusers SET password_hash = '\$2b\$10\$...' WHERE username = 'admin';"
```
### API-Key kompromittiert
**Symptom:**
- Unerwartete API-Aufrufe
- Unbekannte Aktivitäten
**Lösung:**
```sql
-- API-Key deaktivieren
UPDATE api_tokens SET is_active = false WHERE token = 'COMPROMISED_TOKEN';
-- Neuen API-Key generieren
-- (über API oder direkt in der Datenbank)
```
### SQL-Injection
**Symptom:**
- Unerwartete Datenbank-Abfragen
- Fehlerhafte API-Antworten
**Lösung:**
```javascript
// Parametrisierte Queries verwenden
const result = await db.query(
'SELECT * FROM players WHERE id = $1',
[playerId]
);
// Input-Validierung
if (!isValidUUID(playerId)) {
throw new Error('Invalid player ID');
}
```
## 📱 Frontend
### JavaScript-Fehler
**Symptom:**
- Konsole zeigt Fehler
- Funktionen funktionieren nicht
**Lösung:**
```javascript
// Browser-Konsole prüfen
console.error('Error details:', error);
// API-Verbindung testen
fetch('/api/v1/public/locations')
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error('API Error:', error));
```
### CORS-Probleme
**Symptom:**
```
Access to fetch at 'http://localhost:3000/api' from origin 'http://localhost:8080' has been blocked by CORS policy
```
**Lösung:**
```javascript
// CORS-Header setzen
fetch('/api/v1/public/locations', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
mode: 'cors'
});
```
### Session-Probleme
**Symptom:**
- Login funktioniert nicht
- Session wird nicht gespeichert
**Lösung:**
```javascript
// Session-Cookie prüfen
document.cookie
// Login-Request prüfen
fetch('/api/v1/public/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
username: 'admin',
password: 'admin123'
})
});
```
## 🔧 Allgemeine Lösungen
### Logs analysieren
```bash
# Server-Logs
tail -f logs/server.log
# Error-Logs
grep "ERROR" logs/server.log
# Achievement-Logs
tail -f /var/log/ninjaserver_achievements.log
# System-Logs
journalctl -u ninjaserver -f
```
### System-Status prüfen
```bash
# Server-Status
curl http://localhost:3000/health
# Datenbank-Status
psql -d ninjaserver -c "SELECT NOW();"
# Speicher-Status
free -h
df -h
# Prozess-Status
ps aux | grep node
```
### Backup wiederherstellen
```bash
# Vollständiges Backup
psql -d ninjaserver < backup.sql
# Nur Daten
psql -d ninjaserver < data_backup.sql
# Nur Schema
psql -d ninjaserver < schema_backup.sql
```
### System neu starten
```bash
# Mit npm
npm restart
# Mit pm2
pm2 restart ninjaserver
# Mit systemd
sudo systemctl restart ninjaserver
# Mit Docker
docker restart ninjaserver
```
## 📞 Support kontaktieren
Wenn diese Lösungen nicht helfen:
1. **Logs sammeln:**
```bash
# Alle relevanten Logs
tail -n 100 logs/server.log > error_log.txt
tail -n 100 /var/log/ninjaserver_achievements.log >> error_log.txt
```
2. **System-Info sammeln:**
```bash
# System-Informationen
uname -a > system_info.txt
node --version >> system_info.txt
npm --version >> system_info.txt
psql --version >> system_info.txt
```
3. **Problem beschreiben:**
- Was passiert ist
- Wann es passiert ist
- Welche Schritte Sie unternommen haben
- Fehlermeldungen
4. **Support kontaktieren:**
- E-Mail: support@ninjaparkour.de
- Issue-System verwenden
- Logs und System-Info anhängen
---
**Hinweis:** Diese Anleitung wird regelmäßig aktualisiert. Bei neuen Problemen wenden Sie sich an den Support.