Compare commits
11 Commits
10a150cb39
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b83e39e94 | |||
| 6ca3228527 | |||
| 18ec84ff9f | |||
| 58b5e6b074 | |||
| 9a704a59f3 | |||
| 5c01dce9a9 | |||
| d60dce87c8 | |||
| ad387a33b4 | |||
| 5831d1bb91 | |||
| b2fc63e2d0 | |||
| 69e3985af3 |
4
API.md
4
API.md
@@ -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"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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]);
|
||||
|
||||
const playerBest = playerResult.rows[0].best_time;
|
||||
const dailyBest = dailyResult.rows[0].best_time;
|
||||
if (parseInt(alreadyEarnedToday.rows[0].count) > 0) continue;
|
||||
|
||||
if (playerBest && dailyBest && playerBest === dailyBest) {
|
||||
await this.awardAchievement(playerId, achievement, 1, newAchievements);
|
||||
// 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]);
|
||||
|
||||
// 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]);
|
||||
|
||||
const playerBest = playerResult.rows[0].best_time;
|
||||
const weeklyBest = weeklyResult.rows[0].best_time;
|
||||
if (parseInt(alreadyEarnedThisWeek.rows[0].count) > 0) continue;
|
||||
|
||||
if (playerBest && weeklyBest && playerBest === weeklyBest) {
|
||||
await this.awardAchievement(playerId, achievement, 1, newAchievements);
|
||||
// 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]);
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -643,40 +689,56 @@ class AchievementSystem {
|
||||
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]);
|
||||
|
||||
const playerBest = playerResult.rows[0].best_time;
|
||||
const monthlyBest = monthlyResult.rows[0].best_time;
|
||||
if (parseInt(alreadyEarnedThisMonth.rows[0].count) > 0) continue;
|
||||
|
||||
if (playerBest && monthlyBest && playerBest === monthlyBest) {
|
||||
await this.awardAchievement(playerId, achievement, 1, newAchievements);
|
||||
// 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]);
|
||||
|
||||
// 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
162
lib/push-service.js
Normal 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
4781
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
187
pentest/enumerate.py
Normal file
187
pentest/enumerate.py
Normal 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")
|
||||
312
pentest/realistic_enumeration.py
Normal file
312
pentest/realistic_enumeration.py
Normal 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
245
public/agb.html
Normal 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>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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 */
|
||||
@@ -1866,3 +1940,42 @@ input:checked+.toggle-slider:before {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -1149,3 +1261,29 @@ body {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -47,6 +47,9 @@ async function initDashboard() {
|
||||
// Load times section
|
||||
checkLinkStatusAndLoadTimes();
|
||||
|
||||
// Update analytics and statistics cards
|
||||
updateAnalyticsAndStatisticsCards();
|
||||
|
||||
} catch (error) {
|
||||
console.error('An unexpected error occurred:', error);
|
||||
// window.location.href = '/login';
|
||||
@@ -149,6 +152,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) {
|
||||
displayAchievements();
|
||||
@@ -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');
|
||||
@@ -798,6 +926,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);
|
||||
showTimesNotLinked();
|
||||
@@ -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;
|
||||
|
||||
const response = await fetch(`/api/achievements/player/${currentPlayerId}?t=${Date.now()}`);
|
||||
// Check if push notifications are enabled
|
||||
const pushPlayerId = localStorage.getItem('pushPlayerId');
|
||||
if (!pushPlayerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
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');
|
||||
}
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
53
public/sw.js
53
public/sw.js
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
log('Überprüfe Push Subscription...', 'info');
|
||||
|
||||
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');
|
||||
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>
|
||||
|
||||
656
routes/api.js
656
routes/api.js
@@ -3,7 +3,7 @@ const express = require('express');
|
||||
const { Pool } = require('pg');
|
||||
const bcrypt = require('bcrypt');
|
||||
const { start } = require('repl');
|
||||
const pushService = require('../push-service');
|
||||
const pushService = require('../lib/push-service');
|
||||
const router = express.Router();
|
||||
|
||||
// PostgreSQL Pool mit .env Konfiguration
|
||||
@@ -820,22 +820,40 @@ router.post('/v1/private/create-time', requireApiKey, async (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
let locationResult;
|
||||
let location_id;
|
||||
let location;
|
||||
|
||||
// Location anhand des Namens finden (inklusive time_threshold)
|
||||
const locationResult = await pool.query(
|
||||
'SELECT id, name, time_threshold FROM locations WHERE name = $1',
|
||||
[location_name]
|
||||
);
|
||||
// Prüfen ob location_name eine ID (numerisch oder UUID) oder ein Name ist
|
||||
const isNumericId = /^\d+$/.test(location_name);
|
||||
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(location_name);
|
||||
const isId = isNumericId || isUuid;
|
||||
|
||||
if (isId) {
|
||||
// Direkte ID-Suche (numerisch oder UUID)
|
||||
locationResult = await pool.query(
|
||||
'SELECT id, name, time_threshold FROM locations WHERE id = $1',
|
||||
[location_name]
|
||||
);
|
||||
} else {
|
||||
// Name-basierte Suche
|
||||
locationResult = await pool.query(
|
||||
'SELECT id, name, time_threshold FROM locations WHERE name = $1',
|
||||
[location_name]
|
||||
);
|
||||
}
|
||||
|
||||
if (locationResult.rows.length === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: `Standort '${location_name}' nicht gefunden`
|
||||
message: isId
|
||||
? `Standort mit ID '${location_name}' nicht gefunden`
|
||||
: `Standort '${location_name}' nicht gefunden`
|
||||
});
|
||||
}
|
||||
|
||||
const location = locationResult.rows[0];
|
||||
const location_id = location.id;
|
||||
location = locationResult.rows[0];
|
||||
location_id = location.id;
|
||||
|
||||
// Prüfen ob die Zeit über dem Schwellenwert liegt
|
||||
if (location.time_threshold) {
|
||||
@@ -859,7 +877,8 @@ router.post('/v1/private/create-time', requireApiKey, async (req, res) => {
|
||||
recorded_time: recorded_time,
|
||||
threshold: location.time_threshold,
|
||||
threshold_display: thresholdDisplay,
|
||||
location_name: location_name
|
||||
location_name: location.name,
|
||||
location_id: location.id
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -950,7 +969,8 @@ router.post('/v1/private/create-time', requireApiKey, async (req, res) => {
|
||||
location_id: location_id,
|
||||
location_name: location.name,
|
||||
recorded_time: recorded_time,
|
||||
created_at: result.rows[0].created_at
|
||||
created_at: result.rows[0].created_at,
|
||||
input_type: isId ? (isNumericId ? 'numeric_id' : 'uuid_id') : 'location_name'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1045,7 +1065,7 @@ const { checkNameAgainstBlacklist, addToBlacklist, removeFromBlacklist, getBlack
|
||||
|
||||
// Create new player with RFID and blacklist validation (no auth required for dashboard)
|
||||
router.post('/v1/public/players/create-with-rfid', async (req, res) => {
|
||||
const { rfiduid, firstname, lastname, birthdate, supabase_user_id } = req.body;
|
||||
const { rfiduid, firstname, lastname, birthdate, supabase_user_id, agb_accepted } = req.body;
|
||||
|
||||
// Validierung
|
||||
if (!rfiduid || !firstname || !lastname || !birthdate) {
|
||||
@@ -1119,10 +1139,10 @@ router.post('/v1/public/players/create-with-rfid', async (req, res) => {
|
||||
|
||||
// Spieler in Datenbank einfügen
|
||||
const result = await pool.query(
|
||||
`INSERT INTO players (rfiduid, firstname, lastname, birthdate, supabase_user_id, created_at, show_in_leaderboard)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, rfiduid, firstname, lastname, birthdate, created_at`,
|
||||
[rfiduid, firstname, lastname, birthdate, supabase_user_id || null, new Date(), true]
|
||||
`INSERT INTO players (rfiduid, firstname, lastname, birthdate, supabase_user_id, created_at, show_in_leaderboard, agb_accepted)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, rfiduid, firstname, lastname, birthdate, created_at, agb_accepted`,
|
||||
[rfiduid, firstname, lastname, birthdate, supabase_user_id || null, new Date(), !!agb_accepted, !!agb_accepted]
|
||||
);
|
||||
|
||||
const newPlayer = result.rows[0];
|
||||
@@ -1299,6 +1319,77 @@ router.post('/v1/public/link-player', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Update player details (name and RFID) for logged-in user
|
||||
router.post('/v1/public/update-player-details', async (req, res) => {
|
||||
const { firstname, lastname, rfiduid, birthdate } = req.body;
|
||||
const userId = req.session.userId;
|
||||
|
||||
// Validierung
|
||||
if (!userId || userId === 'anonymous') {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Bitte logge dich ein'
|
||||
});
|
||||
}
|
||||
|
||||
if (!firstname || !lastname) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Vor- und Nachname sind erforderlich'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Finde den Spieler über supabase_user_id
|
||||
const playerResult = await pool.query(
|
||||
'SELECT id FROM players WHERE supabase_user_id = $1',
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (playerResult.rows.length === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Kein Spieler-Profil gefunden'
|
||||
});
|
||||
}
|
||||
|
||||
const playerId = playerResult.rows[0].id;
|
||||
|
||||
// Prüfe ob RFID bereits verwendet wird (falls angegeben)
|
||||
if (rfiduid) {
|
||||
const rfidCheck = await pool.query(
|
||||
'SELECT id FROM players WHERE rfiduid = $1 AND id != $2',
|
||||
[rfiduid, playerId]
|
||||
);
|
||||
|
||||
if (rfidCheck.rows.length > 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'RFID bereits vergeben'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update Spieler-Details
|
||||
const updateResult = await pool.query(
|
||||
'UPDATE players SET firstname = $1, lastname = $2, rfiduid = $3, birthdate = $4, updated_at = NOW() WHERE id = $5 RETURNING *',
|
||||
[firstname, lastname, rfiduid || null, birthdate || null, playerId]
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Spieler-Details erfolgreich aktualisiert',
|
||||
data: updateResult.rows[0]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating player details:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Fehler beim Aktualisieren der Spieler-Details'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Get user times by Supabase user ID (no auth required for dashboard)
|
||||
router.get('/v1/public/user-times/:supabase_user_id', async (req, res) => {
|
||||
const { supabase_user_id } = req.params;
|
||||
@@ -1825,6 +1916,12 @@ router.delete('/v1/admin/players/:id', requireAdminAuth, async (req, res) => {
|
||||
// Erst alle zugehörigen Zeiten löschen
|
||||
await pool.query('DELETE FROM times WHERE player_id = $1', [playerId]);
|
||||
|
||||
// Alle zugehörigen Notifications löschen
|
||||
await pool.query('DELETE FROM sent_notifications WHERE player_id = $1', [playerId]);
|
||||
|
||||
// Alle zugehörigen Player Achievements löschen
|
||||
await pool.query('DELETE FROM player_achievements WHERE player_id = $1', [playerId]);
|
||||
|
||||
// Dann den Spieler löschen
|
||||
const result = await pool.query('DELETE FROM players WHERE id = $1', [playerId]);
|
||||
|
||||
@@ -2212,22 +2309,17 @@ router.get('/v1/public/times-with-details', async (req, res) => {
|
||||
|
||||
// Get all times with player and location details, ordered by time (fastest first)
|
||||
// Only show times from players who have opted into leaderboard visibility
|
||||
// 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
|
||||
@@ -2843,34 +2935,298 @@ router.get('/v1/public/best-times', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== LOGGING ENDPOINT ====================
|
||||
|
||||
// Log endpoint for client-side logging
|
||||
router.post('/v1/public/log', async (req, res) => {
|
||||
try {
|
||||
const { level, message, playerId } = req.body;
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
console.log(`[${timestamp}] [${level?.toUpperCase() || 'INFO'}] [Player: ${playerId || 'unknown'}] ${message}`);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error processing log:', error);
|
||||
res.status(500).json({ success: false });
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== PUSH NOTIFICATION ENDPOINTS ====================
|
||||
|
||||
// Import enhanced player notifications
|
||||
const enhancedNotifications = require('../scripts/enhanced_player_notifications');
|
||||
|
||||
// Send push notification to specific player
|
||||
router.post('/v1/player/push', async (req, res) => {
|
||||
try {
|
||||
const { playerId, title, message, data } = req.body;
|
||||
|
||||
if (!playerId || !title || !message) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Player ID, title und message sind erforderlich'
|
||||
});
|
||||
}
|
||||
|
||||
const result = await enhancedNotifications.sendPushToPlayer(playerId, title, message, data);
|
||||
|
||||
if (result.success) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Push notification gesendet'
|
||||
});
|
||||
} else {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: result.reason || 'Push notification konnte nicht gesendet werden'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error sending push notification:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Fehler beim Senden der Push Notification'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Send achievement notification to player
|
||||
router.post('/v1/player/achievement-push', async (req, res) => {
|
||||
try {
|
||||
const { playerId, achievement } = req.body;
|
||||
|
||||
if (!playerId || !achievement) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Player ID und Achievement sind erforderlich'
|
||||
});
|
||||
}
|
||||
|
||||
const result = await enhancedNotifications.sendAchievementNotification(playerId, achievement);
|
||||
|
||||
if (result.success) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Achievement notification gesendet'
|
||||
});
|
||||
} else {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: result.reason || 'Achievement notification konnte nicht gesendet werden'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error sending achievement notification:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Fehler beim Senden der Achievement Notification'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Send best time notification to player
|
||||
router.post('/v1/player/best-time-push', async (req, res) => {
|
||||
try {
|
||||
const { playerId, timeType, locationName, time } = req.body;
|
||||
|
||||
if (!playerId || !timeType || !locationName || !time) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Player ID, timeType, locationName und time sind erforderlich'
|
||||
});
|
||||
}
|
||||
|
||||
const result = await enhancedNotifications.sendBestTimeNotification(playerId, timeType, locationName, time);
|
||||
|
||||
if (result.success) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Best time notification gesendet'
|
||||
});
|
||||
} else {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: result.reason || 'Best time notification konnte nicht gesendet werden'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error sending best time notification:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Fehler beim Senden der Best Time Notification'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Send notification to all players
|
||||
router.post('/v1/player/broadcast-push', async (req, res) => {
|
||||
try {
|
||||
const { title, message, data } = req.body;
|
||||
|
||||
if (!title || !message) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Title und message sind erforderlich'
|
||||
});
|
||||
}
|
||||
|
||||
const result = await enhancedNotifications.sendNotificationToAllPlayers(title, message, data);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Notification an ${result.sent}/${result.total} Spieler gesendet`,
|
||||
sent: result.sent,
|
||||
total: result.total
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error sending broadcast notification:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Fehler beim Senden der Broadcast Notification'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Get player push status
|
||||
router.get('/v1/player/push-status/:playerId', async (req, res) => {
|
||||
try {
|
||||
const { playerId } = req.params;
|
||||
|
||||
const isEnabled = await enhancedNotifications.isPlayerPushEnabled(playerId);
|
||||
const stats = await enhancedNotifications.getPlayerStats(playerId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
pushEnabled: isEnabled,
|
||||
playerStats: stats
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting player push status:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Fehler beim Abrufen des Push-Status'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Get current player ID for logged in user
|
||||
router.get('/v1/public/current-player', async (req, res) => {
|
||||
try {
|
||||
const userId = req.session.userId;
|
||||
|
||||
if (!userId || userId === 'anonymous') {
|
||||
return res.json({
|
||||
success: false,
|
||||
message: 'No logged in user'
|
||||
});
|
||||
}
|
||||
|
||||
// Get player ID from database
|
||||
const result = await pool.query(
|
||||
'SELECT id FROM players WHERE supabase_user_id = $1',
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (result.rows.length > 0) {
|
||||
res.json({
|
||||
success: true,
|
||||
playerId: result.rows[0].id
|
||||
});
|
||||
} else {
|
||||
res.json({
|
||||
success: false,
|
||||
message: 'No player record found for user'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting current player:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Fehler beim Abrufen der Player-ID'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Subscribe to push notifications
|
||||
router.post('/v1/public/subscribe', async (req, res) => {
|
||||
try {
|
||||
const { endpoint, keys } = req.body;
|
||||
const userId = req.session.userId || 'anonymous';
|
||||
console.log('📱 Push subscription request received:', JSON.stringify(req.body, null, 2));
|
||||
|
||||
// Generate a UUID for anonymous users or use existing UUID
|
||||
let playerId;
|
||||
if (userId === 'anonymous') {
|
||||
// Generate a random UUID for anonymous users
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
playerId = uuidv4();
|
||||
} else {
|
||||
playerId = userId;
|
||||
const { endpoint, keys, playerId: requestPlayerId, supabaseUserId } = req.body;
|
||||
const userId = req.session.userId || supabaseUserId || 'anonymous';
|
||||
|
||||
// Validate required fields
|
||||
if (!endpoint) {
|
||||
console.error('❌ Missing endpoint in request');
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Endpoint ist erforderlich'
|
||||
});
|
||||
}
|
||||
|
||||
// Store subscription in database
|
||||
if (!keys || !keys.p256dh || !keys.auth) {
|
||||
console.error('❌ Missing keys in request:', keys);
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Push-Keys sind erforderlich'
|
||||
});
|
||||
}
|
||||
|
||||
// Get player ID from logged-in user's supabase_user_id or create anonymous player
|
||||
let playerId;
|
||||
if (userId === 'anonymous') {
|
||||
// For anonymous users, create a temporary player profile
|
||||
console.log('📱 Anonymous user detected, creating temporary player profile...');
|
||||
try {
|
||||
const createResult = await pool.query(
|
||||
'INSERT INTO players (supabase_user_id, firstname, lastname, birthdate, created_at) VALUES ($1, $2, $3, $4, NOW()) RETURNING id',
|
||||
[null, 'Anonymer', 'Benutzer', '1990-01-01']
|
||||
);
|
||||
playerId = createResult.rows[0].id;
|
||||
console.log(`📱 Created temporary player profile for anonymous user: ${playerId}`);
|
||||
} catch (error) {
|
||||
console.error('Error creating anonymous player:', error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'Fehler beim Erstellen des temporären Spieler-Profils'
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// For logged in users, get their player ID from database or create one
|
||||
try {
|
||||
const playerResult = await pool.query(
|
||||
'SELECT id FROM players WHERE supabase_user_id = $1',
|
||||
[userId]
|
||||
);
|
||||
if (playerResult.rows.length > 0) {
|
||||
playerId = playerResult.rows[0].id;
|
||||
console.log(`📱 Found existing player ID for user ${userId}: ${playerId}`);
|
||||
} else {
|
||||
// Create new player profile automatically
|
||||
console.log(`📱 No player profile found for user ${userId}, creating new one...`);
|
||||
const createResult = await pool.query(
|
||||
'INSERT INTO players (supabase_user_id, firstname, lastname, birthdate, created_at) VALUES ($1, $2, $3, $4, NOW()) RETURNING id',
|
||||
[userId, 'Unbekannt', 'Benutzer', '1990-01-01']
|
||||
);
|
||||
playerId = createResult.rows[0].id;
|
||||
console.log(`📱 Created new player profile for user ${userId}: ${playerId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error looking up or creating player ID:', error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'Fehler beim Abrufen oder Erstellen der Player-ID'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`📱 Processing subscription for player: ${playerId}`);
|
||||
|
||||
// Store subscription in database with new structure
|
||||
await pool.query(`
|
||||
INSERT INTO player_subscriptions (player_id, endpoint, p256dh, auth, created_at)
|
||||
VALUES ($1, $2, $3, $4, NOW())
|
||||
ON CONFLICT (player_id)
|
||||
DO UPDATE SET
|
||||
endpoint = EXCLUDED.endpoint,
|
||||
p256dh = EXCLUDED.p256dh,
|
||||
auth = EXCLUDED.auth,
|
||||
updated_at = NOW()
|
||||
`, [
|
||||
playerId,
|
||||
endpoint,
|
||||
@@ -2890,7 +3246,9 @@ router.post('/v1/public/subscribe', async (req, res) => {
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Push subscription erfolgreich gespeichert'
|
||||
message: 'Push subscription erfolgreich gespeichert',
|
||||
playerId: playerId,
|
||||
pushId: 'generated' // Will be generated by database
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error storing push subscription:', error);
|
||||
@@ -2902,6 +3260,77 @@ router.post('/v1/public/subscribe', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Unsubscribe from push notifications
|
||||
router.post('/v1/public/unsubscribe', async (req, res) => {
|
||||
try {
|
||||
const { endpoint, playerId } = req.body;
|
||||
const userId = req.session.userId || 'anonymous';
|
||||
|
||||
// Validate required fields
|
||||
if (!endpoint) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Endpoint erforderlich'
|
||||
});
|
||||
}
|
||||
|
||||
// For anonymous users, we need the playerId to be provided
|
||||
if (userId === 'anonymous' && !playerId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Player ID erforderlich für anonyme Benutzer'
|
||||
});
|
||||
}
|
||||
|
||||
// Get player ID from logged-in user
|
||||
let actualPlayerId = playerId;
|
||||
if (!actualPlayerId) {
|
||||
try {
|
||||
const playerResult = await pool.query(
|
||||
'SELECT id FROM players WHERE supabase_user_id = $1',
|
||||
[userId]
|
||||
);
|
||||
if (playerResult.rows.length > 0) {
|
||||
actualPlayerId = playerResult.rows[0].id;
|
||||
} else {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Kein Spieler-Profil gefunden'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error looking up player ID:', error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'Fehler beim Abrufen der Player-ID'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Remove specific subscription from database (by endpoint)
|
||||
const result = await pool.query(`
|
||||
DELETE FROM player_subscriptions
|
||||
WHERE player_id = $1 AND endpoint = $2
|
||||
`, [actualPlayerId, endpoint]);
|
||||
|
||||
// Remove specific subscription from push service
|
||||
pushService.unsubscribeDevice(actualPlayerId, endpoint);
|
||||
|
||||
console.log(`Device ${endpoint} unsubscribed from push notifications for player ${actualPlayerId} (${result.rowCount} subscriptions removed)`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `${result.rowCount} Push subscription(s) erfolgreich entfernt`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error removing push subscription:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Fehler beim Entfernen der Push Subscription'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Test push notification endpoint
|
||||
router.post('/v1/public/test-push', async (req, res) => {
|
||||
try {
|
||||
@@ -2960,6 +3389,99 @@ router.get('/v1/public/push-status', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Check if notification was already sent today
|
||||
router.get('/v1/public/notification-sent/:playerId/:type', async (req, res) => {
|
||||
try {
|
||||
const { playerId, type } = req.params;
|
||||
const { achievementId, locationId } = req.query;
|
||||
|
||||
// Validate required parameters
|
||||
if (!playerId || playerId === 'undefined' || playerId === 'null') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Player ID ist erforderlich'
|
||||
});
|
||||
}
|
||||
|
||||
if (!type) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Notification Type ist erforderlich'
|
||||
});
|
||||
}
|
||||
|
||||
// Validate achievementId if provided
|
||||
if (achievementId && (achievementId === 'undefined' || achievementId === 'null')) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Ungültige Achievement ID'
|
||||
});
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
const result = await pool.query(`
|
||||
SELECT COUNT(*) as count
|
||||
FROM sent_notifications
|
||||
WHERE player_id = $1
|
||||
AND notification_type = $2
|
||||
AND (achievement_id = $3 OR ($3 IS NULL AND achievement_id IS NULL))
|
||||
AND (location_id = $4 OR ($4 IS NULL AND location_id IS NULL))
|
||||
AND DATE(sent_at) = $5
|
||||
`, [playerId, type, achievementId || null, locationId || null, today]);
|
||||
|
||||
const wasSent = parseInt(result.rows[0].count) > 0;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
wasSent: wasSent
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error checking notification status:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Fehler beim Prüfen des Notification-Status'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Mark notification as sent
|
||||
router.post('/v1/public/notification-sent', async (req, res) => {
|
||||
try {
|
||||
const { playerId, notificationType, achievementId, locationId } = req.body;
|
||||
|
||||
// Log notification details to server console
|
||||
const logData = {
|
||||
playerId: playerId,
|
||||
notificationType: notificationType,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Only add achievementId and locationId if they exist
|
||||
if (achievementId) logData.achievementId = achievementId;
|
||||
if (locationId) logData.locationId = locationId;
|
||||
|
||||
console.log('📱 Push Notification marked as sent:', logData);
|
||||
|
||||
await pool.query(`
|
||||
INSERT INTO sent_notifications (player_id, notification_type, achievement_id, location_id)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT DO NOTHING
|
||||
`, [playerId, notificationType, achievementId || null, locationId || null]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Notification als gesendet markiert'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error marking notification as sent:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Fehler beim Markieren der Notification'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== ANALYTICS HELPER FUNCTIONS ====================
|
||||
|
||||
async function getPerformanceTrends(playerId) {
|
||||
@@ -2990,9 +3512,9 @@ async function getPerformanceTrends(playerId) {
|
||||
((avgTimeLastWeek - avgTimeThisWeek) / avgTimeLastWeek * 100) : 0;
|
||||
|
||||
return {
|
||||
avgTimeThisWeek: avgTimeThisWeek,
|
||||
avgTimeLastWeek: avgTimeLastWeek,
|
||||
improvement: Math.round(improvement * 10) / 10
|
||||
avgTimeThisWeek: Math.round(avgTimeThisWeek * 100) / 100,
|
||||
avgTimeLastWeek: Math.round(avgTimeLastWeek * 100) / 100,
|
||||
improvement: Math.round(improvement * 100) / 100
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3028,7 +3550,7 @@ async function getActivityStats(playerId) {
|
||||
return {
|
||||
runsToday: parseInt(todayResult.rows[0].count),
|
||||
runsThisWeek: parseInt(weekResult.rows[0].count),
|
||||
avgRunsPerDay: parseFloat(avgResult.rows[0].avg_runs) || 0
|
||||
avgRunsPerDay: Math.round((parseFloat(avgResult.rows[0].avg_runs) || 0) * 100) / 100
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3048,7 +3570,7 @@ async function getLocationPerformance(playerId) {
|
||||
|
||||
return result.rows.map(row => ({
|
||||
name: row.name,
|
||||
bestTime: convertTimeToSeconds(row.best_time),
|
||||
bestTime: Math.round(convertTimeToSeconds(row.best_time) * 100) / 100,
|
||||
runs: parseInt(row.runs)
|
||||
}));
|
||||
}
|
||||
@@ -3086,7 +3608,7 @@ async function getMonthlyStats(playerId) {
|
||||
return {
|
||||
runsThisMonth: parseInt(thisMonthResult.rows[0].count),
|
||||
runsLastMonth: parseInt(lastMonthResult.rows[0].count),
|
||||
bestTimeThisMonth: convertTimeToSeconds(bestTimeResult.rows[0].best_time) || 0
|
||||
bestTimeThisMonth: Math.round((convertTimeToSeconds(bestTimeResult.rows[0].best_time) || 0) * 100) / 100
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3103,7 +3625,7 @@ async function getPersonalRecords(playerId) {
|
||||
`, [playerId]);
|
||||
|
||||
return result.rows.map(row => ({
|
||||
time: convertTimeToSeconds(row.recorded_time),
|
||||
time: Math.round(convertTimeToSeconds(row.recorded_time) * 100) / 100,
|
||||
location: row.location
|
||||
}));
|
||||
}
|
||||
@@ -3125,9 +3647,9 @@ async function getConsistencyMetrics(playerId) {
|
||||
Math.max(0, Math.min(100, (1 - (stddevSeconds / avgSeconds)) * 100)) : 0;
|
||||
|
||||
return {
|
||||
averageTime: avgSeconds,
|
||||
timeDeviation: stddevSeconds,
|
||||
consistencyScore: Math.round(consistencyScore)
|
||||
averageTime: Math.round(avgSeconds * 100) / 100,
|
||||
timeDeviation: Math.round(stddevSeconds * 100) / 100,
|
||||
consistencyScore: Math.round(consistencyScore * 100) / 100
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4414,4 +4936,34 @@ router.delete('/v1/admin/achievements/players/:playerId/revoke', requireAdminAut
|
||||
}
|
||||
});
|
||||
|
||||
// Health check endpoint for backend connectivity (matches Arduino backendOnline() function)
|
||||
router.get('/v1/private/health', requireApiKey, async (req, res) => {
|
||||
try {
|
||||
// Test database connection
|
||||
const client = await pool.connect();
|
||||
|
||||
// Simple query to test database connectivity
|
||||
await client.query('SELECT 1');
|
||||
client.release();
|
||||
|
||||
// If we reach here, the database is accessible
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Database server connection successful',
|
||||
status: 'online',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Database server connection failed:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Database server connection failed',
|
||||
status: 'offline',
|
||||
error: error.message,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = { router, requireApiKey };
|
||||
|
||||
@@ -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
|
||||
|
||||
285
scripts/enhanced_player_notifications.js
Normal file
285
scripts/enhanced_player_notifications.js
Normal 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
|
||||
};
|
||||
134
scripts/fix_player_subscriptions.js
Normal file
134
scripts/fix_player_subscriptions.js
Normal 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
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
14
server.js
14
server.js
@@ -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
752
wiki/API-Referenz.md
Normal 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
479
wiki/Achievement-System.md
Normal 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
261
wiki/Benutzerhandbuch.md
Normal 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
588
wiki/Datenbank.md
Normal 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
642
wiki/Deployment.md
Normal 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
589
wiki/Entwicklerhandbuch.md
Normal 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
380
wiki/FAQ.md
Normal 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
92
wiki/Home.md
Normal 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
220
wiki/Schnellstart.md
Normal 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
621
wiki/Sicherheit.md
Normal 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
687
wiki/Troubleshooting.md
Normal 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.
|
||||
Reference in New Issue
Block a user