Achivement System
This commit is contained in:
217
ACHIEVEMENTS.md
Normal file
217
ACHIEVEMENTS.md
Normal file
@@ -0,0 +1,217 @@
|
||||
# 🏆 Ninja Cross Parkour Achievement System
|
||||
|
||||
Ein umfassendes Achievement-System für das Ninja Cross Parkour im Schwimmbad.
|
||||
|
||||
## 📊 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.
|
||||
|
||||
### `check_improvement_achievements(player_uuid)`
|
||||
Überprüft alle Verbesserungs-basierten Achievements für einen Spieler.
|
||||
|
||||
### `check_seasonal_achievements(player_uuid)`
|
||||
Überprüft alle saisonalen und monatlichen Achievements für einen Spieler.
|
||||
|
||||
### `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.
|
||||
|
||||
### GET `/api/achievements/player/:playerId`
|
||||
Achievements eines bestimmten Spielers abrufen.
|
||||
|
||||
### GET `/api/achievements/player/:playerId/stats`
|
||||
Achievement-Statistiken eines Spielers abrufen.
|
||||
|
||||
### POST `/api/achievements/check/:playerId`
|
||||
Achievements für einen Spieler manuell überprüfen.
|
||||
|
||||
### POST `/api/achievements/daily-check`
|
||||
Tägliche Achievement-Überprüfung für alle Spieler ausführen.
|
||||
|
||||
### GET `/api/achievements/leaderboard?limit=10`
|
||||
Bestenliste der Spieler nach Achievement-Punkten.
|
||||
|
||||
## 📅 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`
|
||||
|
||||
## 🎮 Frontend-Integration
|
||||
|
||||
### Beispiel: Achievement-Liste laden
|
||||
```javascript
|
||||
fetch('/api/achievements/player/PLAYER_ID')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
data.data.forEach(achievement => {
|
||||
console.log(`${achievement.icon} ${achievement.name}: ${achievement.is_completed ? '✅' : '❌'}`);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Beispiel: Statistiken anzeigen
|
||||
```javascript
|
||||
fetch('/api/achievements/player/PLAYER_ID/stats')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
console.log(`Punkte: ${data.data.total_points}`);
|
||||
console.log(`Abgeschlossen: ${data.data.completed_achievements}/${data.data.total_achievements}`);
|
||||
});
|
||||
```
|
||||
|
||||
## 🔍 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
|
||||
```
|
||||
|
||||
### 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;
|
||||
```
|
||||
|
||||
## 🛠️ Wartung
|
||||
|
||||
### Neue Achievements hinzufügen
|
||||
1. Achievement in `achievements` Tabelle einfügen
|
||||
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();
|
||||
```
|
||||
|
||||
## 📈 Performance
|
||||
|
||||
- **Indizierung**: Automatische Indizes auf `player_id` und `achievement_id`
|
||||
- **Batch-Processing**: Effiziente Verarbeitung aller Spieler
|
||||
- **Caching**: Achievements werden nur bei Änderungen neu berechnet
|
||||
- **Timezone**: Korrekte Zeitzone-Behandlung (Europe/Berlin)
|
||||
|
||||
## 🔒 Sicherheit
|
||||
|
||||
- **API-Schutz**: Alle Endpoints über bestehende Authentifizierung
|
||||
- **SQL-Injection**: Parametrisierte Queries
|
||||
- **Datenvalidierung**: Eingabe-Validierung in allen Funktionen
|
||||
- **Fehlerbehandlung**: Umfassende Error-Handling
|
||||
|
||||
---
|
||||
|
||||
**Erstellt am**: $(date)
|
||||
**Version**: 1.0.0
|
||||
**Autor**: Ninja Cross Parkour System
|
||||
415
package-lock.json
generated
415
package-lock.json
generated
@@ -16,12 +16,58 @@
|
||||
"express": "^4.18.2",
|
||||
"express-session": "^1.17.3",
|
||||
"pg": "^8.11.3",
|
||||
"socket.io": "^4.8.1"
|
||||
"socket.io": "^4.8.1",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"swagger-ui-express": "^5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@apidevtools/json-schema-ref-parser": {
|
||||
"version": "9.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.1.2.tgz",
|
||||
"integrity": "sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jsdevtools/ono": "^7.1.3",
|
||||
"@types/json-schema": "^7.0.6",
|
||||
"call-me-maybe": "^1.0.1",
|
||||
"js-yaml": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@apidevtools/openapi-schemas": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz",
|
||||
"integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@apidevtools/swagger-methods": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz",
|
||||
"integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@apidevtools/swagger-parser": {
|
||||
"version": "10.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz",
|
||||
"integrity": "sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@apidevtools/json-schema-ref-parser": "^9.0.6",
|
||||
"@apidevtools/openapi-schemas": "^2.0.4",
|
||||
"@apidevtools/swagger-methods": "^3.0.2",
|
||||
"@jsdevtools/ono": "^7.1.3",
|
||||
"call-me-maybe": "^1.0.1",
|
||||
"z-schema": "^5.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openapi-types": ">=7"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
||||
@@ -370,6 +416,12 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@jsdevtools/ono": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz",
|
||||
"integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz",
|
||||
@@ -472,6 +524,13 @@
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@scarf/scarf": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
|
||||
"integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@socket.io/component-emitter": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||
@@ -493,6 +552,12 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz",
|
||||
@@ -876,6 +941,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/call-me-maybe": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz",
|
||||
"integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/callsites": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||
@@ -972,6 +1043,15 @@
|
||||
"color-support": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz",
|
||||
"integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
@@ -1137,6 +1217,18 @@
|
||||
"integrity": "sha512-RQ809ykTfJ+dgj9bftdeL2vRVxASAuGU+I9LEx9Ij5TXU5HrgAQVmzi72VA+mkzscE12uzlRv5/tWWv9R9J1SA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/doctrine": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
|
||||
"integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"esutils": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.5.0",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz",
|
||||
@@ -2153,6 +2245,26 @@
|
||||
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.get": {
|
||||
"version": "4.4.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
|
||||
"integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==",
|
||||
"deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isequal": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
|
||||
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
|
||||
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.mergewith": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz",
|
||||
"integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "7.18.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",
|
||||
@@ -2496,6 +2608,13 @@
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/openapi-types": {
|
||||
"version": "12.1.3",
|
||||
"resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz",
|
||||
"integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/pac-proxy-agent": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz",
|
||||
@@ -3603,6 +3722,83 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/swagger-jsdoc": {
|
||||
"version": "6.2.8",
|
||||
"resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz",
|
||||
"integrity": "sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"commander": "6.2.0",
|
||||
"doctrine": "3.0.0",
|
||||
"glob": "7.1.6",
|
||||
"lodash.mergewith": "^4.6.2",
|
||||
"swagger-parser": "^10.0.3",
|
||||
"yaml": "2.0.0-1"
|
||||
},
|
||||
"bin": {
|
||||
"swagger-jsdoc": "bin/swagger-jsdoc.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/swagger-jsdoc/node_modules/glob": {
|
||||
"version": "7.1.6",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
|
||||
"integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
|
||||
"deprecated": "Glob versions prior to v9 are no longer supported",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "^3.0.4",
|
||||
"once": "^1.3.0",
|
||||
"path-is-absolute": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/swagger-parser": {
|
||||
"version": "10.0.3",
|
||||
"resolved": "https://registry.npmjs.org/swagger-parser/-/swagger-parser-10.0.3.tgz",
|
||||
"integrity": "sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@apidevtools/swagger-parser": "10.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/swagger-ui-dist": {
|
||||
"version": "5.28.1",
|
||||
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.28.1.tgz",
|
||||
"integrity": "sha512-IvPrtNi8MvjiuDgoSmPYgg27Lvu38fnLD1OSd8Y103xXsPAqezVNnNeHnVCZ/d+CMXJblflGaIyHxAYIF3O71w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@scarf/scarf": "=1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/swagger-ui-express": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz",
|
||||
"integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"swagger-ui-dist": ">=5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= v0.10.32"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": ">=4.0.0 || >=5.0.0-beta"
|
||||
}
|
||||
},
|
||||
"node_modules/tar": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
|
||||
@@ -3768,6 +3964,15 @@
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/validator": {
|
||||
"version": "13.15.15",
|
||||
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.15.tgz",
|
||||
"integrity": "sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
@@ -3883,6 +4088,15 @@
|
||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.0.0-1",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz",
|
||||
"integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "17.7.2",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||
@@ -3920,6 +4134,36 @@
|
||||
"fd-slicer": "~1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/z-schema": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.5.tgz",
|
||||
"integrity": "sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash.get": "^4.4.2",
|
||||
"lodash.isequal": "^4.5.0",
|
||||
"validator": "^13.7.0"
|
||||
},
|
||||
"bin": {
|
||||
"z-schema": "bin/z-schema"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"commander": "^9.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/z-schema/node_modules/commander": {
|
||||
"version": "9.5.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz",
|
||||
"integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": "^12.20.0 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
@@ -3940,6 +4184,40 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@apidevtools/json-schema-ref-parser": {
|
||||
"version": "9.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.1.2.tgz",
|
||||
"integrity": "sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg==",
|
||||
"requires": {
|
||||
"@jsdevtools/ono": "^7.1.3",
|
||||
"@types/json-schema": "^7.0.6",
|
||||
"call-me-maybe": "^1.0.1",
|
||||
"js-yaml": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"@apidevtools/openapi-schemas": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz",
|
||||
"integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ=="
|
||||
},
|
||||
"@apidevtools/swagger-methods": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz",
|
||||
"integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg=="
|
||||
},
|
||||
"@apidevtools/swagger-parser": {
|
||||
"version": "10.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz",
|
||||
"integrity": "sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==",
|
||||
"requires": {
|
||||
"@apidevtools/json-schema-ref-parser": "^9.0.6",
|
||||
"@apidevtools/openapi-schemas": "^2.0.4",
|
||||
"@apidevtools/swagger-methods": "^3.0.2",
|
||||
"@jsdevtools/ono": "^7.1.3",
|
||||
"call-me-maybe": "^1.0.1",
|
||||
"z-schema": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"@babel/code-frame": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
||||
@@ -4182,6 +4460,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"@jsdevtools/ono": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz",
|
||||
"integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg=="
|
||||
},
|
||||
"@mapbox/node-pre-gyp": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz",
|
||||
@@ -4258,6 +4541,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"@scarf/scarf": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
|
||||
"integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ=="
|
||||
},
|
||||
"@socket.io/component-emitter": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||
@@ -4276,6 +4564,11 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="
|
||||
},
|
||||
"@types/node": {
|
||||
"version": "24.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz",
|
||||
@@ -4537,6 +4830,11 @@
|
||||
"get-intrinsic": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"call-me-maybe": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz",
|
||||
"integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ=="
|
||||
},
|
||||
"callsites": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||
@@ -4600,6 +4898,11 @@
|
||||
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
|
||||
"integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg=="
|
||||
},
|
||||
"commander": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz",
|
||||
"integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q=="
|
||||
},
|
||||
"concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
@@ -4711,6 +5014,14 @@
|
||||
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1475386.tgz",
|
||||
"integrity": "sha512-RQ809ykTfJ+dgj9bftdeL2vRVxASAuGU+I9LEx9Ij5TXU5HrgAQVmzi72VA+mkzscE12uzlRv5/tWWv9R9J1SA=="
|
||||
},
|
||||
"doctrine": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
|
||||
"integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
|
||||
"requires": {
|
||||
"esutils": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"dotenv": {
|
||||
"version": "16.5.0",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz",
|
||||
@@ -5399,6 +5710,21 @@
|
||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="
|
||||
},
|
||||
"lodash.get": {
|
||||
"version": "4.4.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
|
||||
"integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ=="
|
||||
},
|
||||
"lodash.isequal": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
|
||||
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ=="
|
||||
},
|
||||
"lodash.mergewith": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz",
|
||||
"integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ=="
|
||||
},
|
||||
"lru-cache": {
|
||||
"version": "7.18.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",
|
||||
@@ -5618,6 +5944,12 @@
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"openapi-types": {
|
||||
"version": "12.1.3",
|
||||
"resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz",
|
||||
"integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==",
|
||||
"peer": true
|
||||
},
|
||||
"pac-proxy-agent": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz",
|
||||
@@ -6363,6 +6695,58 @@
|
||||
"has-flag": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"swagger-jsdoc": {
|
||||
"version": "6.2.8",
|
||||
"resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz",
|
||||
"integrity": "sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ==",
|
||||
"requires": {
|
||||
"commander": "6.2.0",
|
||||
"doctrine": "3.0.0",
|
||||
"glob": "7.1.6",
|
||||
"lodash.mergewith": "^4.6.2",
|
||||
"swagger-parser": "^10.0.3",
|
||||
"yaml": "2.0.0-1"
|
||||
},
|
||||
"dependencies": {
|
||||
"glob": {
|
||||
"version": "7.1.6",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
|
||||
"integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
|
||||
"requires": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "^3.0.4",
|
||||
"once": "^1.3.0",
|
||||
"path-is-absolute": "^1.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"swagger-parser": {
|
||||
"version": "10.0.3",
|
||||
"resolved": "https://registry.npmjs.org/swagger-parser/-/swagger-parser-10.0.3.tgz",
|
||||
"integrity": "sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg==",
|
||||
"requires": {
|
||||
"@apidevtools/swagger-parser": "10.0.3"
|
||||
}
|
||||
},
|
||||
"swagger-ui-dist": {
|
||||
"version": "5.28.1",
|
||||
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.28.1.tgz",
|
||||
"integrity": "sha512-IvPrtNi8MvjiuDgoSmPYgg27Lvu38fnLD1OSd8Y103xXsPAqezVNnNeHnVCZ/d+CMXJblflGaIyHxAYIF3O71w==",
|
||||
"requires": {
|
||||
"@scarf/scarf": "=1.4.0"
|
||||
}
|
||||
},
|
||||
"swagger-ui-express": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz",
|
||||
"integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==",
|
||||
"requires": {
|
||||
"swagger-ui-dist": ">=5.0.0"
|
||||
}
|
||||
},
|
||||
"tar": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
|
||||
@@ -6491,6 +6875,11 @@
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="
|
||||
},
|
||||
"validator": {
|
||||
"version": "13.15.15",
|
||||
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.15.tgz",
|
||||
"integrity": "sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A=="
|
||||
},
|
||||
"vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
@@ -6562,6 +6951,11 @@
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
|
||||
},
|
||||
"yaml": {
|
||||
"version": "2.0.0-1",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz",
|
||||
"integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ=="
|
||||
},
|
||||
"yargs": {
|
||||
"version": "17.7.2",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||
@@ -6590,6 +6984,25 @@
|
||||
"fd-slicer": "~1.1.0"
|
||||
}
|
||||
},
|
||||
"z-schema": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.5.tgz",
|
||||
"integrity": "sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==",
|
||||
"requires": {
|
||||
"commander": "^9.4.1",
|
||||
"lodash.get": "^4.4.2",
|
||||
"lodash.isequal": "^4.5.0",
|
||||
"validator": "^13.7.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"commander": {
|
||||
"version": "9.5.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz",
|
||||
"integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==",
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
"express": "^4.18.2",
|
||||
"express-session": "^1.17.3",
|
||||
"pg": "^8.11.3",
|
||||
"socket.io": "^4.8.1"
|
||||
"socket.io": "^4.8.1",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"swagger-ui-express": "^5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1"
|
||||
|
||||
@@ -1026,3 +1026,351 @@ body {
|
||||
gap: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== ACHIEVEMENT STYLES ==================== */
|
||||
|
||||
.achievements-section {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.achievements-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.achievements-header h2 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #ffd700, #ff6b35);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.achievements-header p {
|
||||
color: #8892b0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Achievement Stats */
|
||||
.achievement-stats {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.achievement-stat {
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
border: 1px solid #2a2a3e;
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
min-width: 150px;
|
||||
flex: 1;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.achievement-stat .stat-number {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #00d4ff;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.achievement-stat .stat-label {
|
||||
color: #8892b0;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Achievement Categories */
|
||||
.achievement-categories {
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
border: 1px solid #2a2a3e;
|
||||
border-radius: 1rem;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.category-tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 2rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.category-tab {
|
||||
background: transparent;
|
||||
border: 1px solid #2a2a3e;
|
||||
color: #8892b0;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.category-tab:hover {
|
||||
background: #2a2a3e;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.category-tab.active {
|
||||
background: linear-gradient(135deg, #00d4ff, #ff6b35);
|
||||
border-color: transparent;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* Achievements Grid */
|
||||
.achievements-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.achievement-card {
|
||||
background: linear-gradient(135deg, #0f1419 0%, #1a1a2e 100%);
|
||||
border: 1px solid #2a2a3e;
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.achievement-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: #00d4ff;
|
||||
box-shadow: 0 8px 25px rgba(0, 212, 255, 0.1);
|
||||
}
|
||||
|
||||
.achievement-card.completed {
|
||||
border-color: #10b981;
|
||||
background: linear-gradient(135deg, #064e3b 0%, #1a1a2e 100%);
|
||||
}
|
||||
|
||||
.achievement-card.completed:hover {
|
||||
border-color: #10b981;
|
||||
box-shadow: 0 8px 25px rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
|
||||
.achievement-card.incomplete {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.achievement-card.incomplete:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.achievement-icon {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.achievement-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.achievement-name {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.achievement-description {
|
||||
color: #8892b0;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.achievement-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.achievement-points {
|
||||
color: #ffd700;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.achievement-progress {
|
||||
color: #00d4ff;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.achievement-status {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
/* Achievement Loading States */
|
||||
.achievements-loading {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #8892b0;
|
||||
}
|
||||
|
||||
.achievements-not-available {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
}
|
||||
|
||||
.not-available-content {
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.not-available-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.not-available-content h3 {
|
||||
color: #ffffff;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.not-available-content p {
|
||||
color: #8892b0;
|
||||
margin-bottom: 2rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* No Achievements State */
|
||||
.no-achievements {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #8892b0;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.no-achievements-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.no-achievements h3 {
|
||||
color: #ffffff;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
/* Achievement Notifications */
|
||||
.achievement-notification {
|
||||
position: fixed;
|
||||
top: 2rem;
|
||||
right: 2rem;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
border: 1px solid #10b981;
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 8px 25px rgba(16, 185, 129, 0.2);
|
||||
z-index: 1000;
|
||||
max-width: 350px;
|
||||
animation: slideInRight 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.notification-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.notification-icon {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.notification-text h4 {
|
||||
color: #ffffff;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.notification-text p {
|
||||
color: #8892b0;
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.notification-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #8892b0;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin-left: auto;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.notification-close:hover {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* Mobile Responsiveness for Achievements */
|
||||
@media (max-width: 768px) {
|
||||
.achievements-header h2 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.achievement-stats {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.achievement-stat {
|
||||
width: 100%;
|
||||
max-width: 250px;
|
||||
}
|
||||
|
||||
.category-tabs {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.category-tab {
|
||||
width: 100%;
|
||||
max-width: 200px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.achievements-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.achievement-notification {
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
left: 1rem;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +120,67 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Achievements Section -->
|
||||
<div class="achievements-section">
|
||||
<div class="achievements-header">
|
||||
<h2>🏆 Meine Achievements</h2>
|
||||
<p>Sammele Punkte und erreiche neue Meilensteine!</p>
|
||||
</div>
|
||||
|
||||
<!-- Achievement Stats -->
|
||||
<div class="achievement-stats" id="achievementStats" style="display: none;">
|
||||
<div class="stat-card achievement-stat">
|
||||
<div class="stat-number" id="totalPoints">0</div>
|
||||
<div class="stat-label">Gesamtpunkte</div>
|
||||
</div>
|
||||
<div class="stat-card achievement-stat">
|
||||
<div class="stat-number" id="completedAchievements">0</div>
|
||||
<div class="stat-label">Abgeschlossen</div>
|
||||
</div>
|
||||
<div class="stat-card achievement-stat">
|
||||
<div class="stat-number" id="achievementsToday">0</div>
|
||||
<div class="stat-label">Heute erreicht</div>
|
||||
</div>
|
||||
<div class="stat-card achievement-stat">
|
||||
<div class="stat-number" id="completionPercentage">0%</div>
|
||||
<div class="stat-label">Fortschritt</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Achievement Categories -->
|
||||
<div class="achievement-categories" id="achievementCategories" style="display: none;">
|
||||
<div class="category-tabs">
|
||||
<button class="category-tab active" onclick="showAchievementCategory('all')" data-category="all">Alle</button>
|
||||
<button class="category-tab" onclick="showAchievementCategory('consistency')" data-category="consistency">Konsistenz</button>
|
||||
<button class="category-tab" onclick="showAchievementCategory('improvement')" data-category="improvement">Verbesserung</button>
|
||||
<button class="category-tab" onclick="showAchievementCategory('seasonal')" data-category="seasonal">Saisonal</button>
|
||||
<button class="category-tab" onclick="showAchievementCategory('monthly')" data-category="monthly">Monatlich</button>
|
||||
</div>
|
||||
|
||||
<div class="achievements-grid" id="achievementsGrid">
|
||||
<!-- Achievements will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Achievement Loading State -->
|
||||
<div id="achievementsLoading" class="achievements-loading" style="display: none;">
|
||||
<div class="spinner"></div>
|
||||
<p>Lade deine Achievements...</p>
|
||||
</div>
|
||||
|
||||
<!-- Achievement Not Available State -->
|
||||
<div id="achievementsNotAvailable" class="achievements-not-available" style="display: none;">
|
||||
<div class="not-available-content">
|
||||
<div class="not-available-icon">🏆</div>
|
||||
<h3>Achievements noch nicht verfügbar</h3>
|
||||
<p>Um Achievements zu sammeln, musst du zuerst deine RFID-Karte mit deinem Account verknüpfen und einige Läufe absolvieren.</p>
|
||||
<button class="btn btn-primary" onclick="showRFIDSettings()">
|
||||
🏷️ RFID jetzt verknüpfen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ async function initDashboard() {
|
||||
if (error) {
|
||||
console.error('Error checking authentication:', error);
|
||||
// Temporarily show dashboard for testing
|
||||
currentUser = { id: 'test-user', email: 'admin@speedrun-arena.com' };
|
||||
currentUser = { id: '9966cffd-2088-423c-b852-0ca7996cda97', email: 'admin@speedrun-arena.com' };
|
||||
displayUserInfo({ email: 'admin@speedrun-arena.com' });
|
||||
showDashboard();
|
||||
// Check times section
|
||||
@@ -38,7 +38,7 @@ async function initDashboard() {
|
||||
displayUserInfo(session.user);
|
||||
} else {
|
||||
// Fallback if no user data
|
||||
currentUser = { id: 'test-user', email: 'admin@speedrun-arena.com' };
|
||||
currentUser = { id: '9966cffd-2088-423c-b852-0ca7996cda97', email: 'admin@speedrun-arena.com' };
|
||||
displayUserInfo({ email: 'admin@speedrun-arena.com' });
|
||||
}
|
||||
showDashboard();
|
||||
@@ -132,7 +132,7 @@ async function checkLinkStatusAndLoadTimes() {
|
||||
|
||||
try {
|
||||
// Check if user has a linked player
|
||||
const response = await fetch(`/api/user-player/${currentUser.id}`);
|
||||
const response = await fetch(`/api/v1/public/user-player/${currentUser.id}`);
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
@@ -363,7 +363,13 @@ async function loadUserTimesSection(playerData) {
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/public/user-times/${currentUser.id}`);
|
||||
const times = await response.json();
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.message || 'Failed to load times');
|
||||
}
|
||||
|
||||
const times = result.data || result;
|
||||
|
||||
// Update stats
|
||||
updateTimesStats(times, playerData);
|
||||
@@ -376,6 +382,9 @@ async function loadUserTimesSection(playerData) {
|
||||
document.getElementById('timesNotLinked').style.display = 'none';
|
||||
document.getElementById('timesDisplay').style.display = 'block';
|
||||
|
||||
// Initialize achievements for this player
|
||||
initializeAchievements(playerData.id);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading user times:', error);
|
||||
showTimesNotLinked();
|
||||
@@ -577,6 +586,251 @@ function showMessage(containerId, message, type) {
|
||||
}
|
||||
|
||||
// Initialize when DOM is loaded
|
||||
// ==================== ACHIEVEMENT FUNCTIONS ====================
|
||||
|
||||
// Global variables for achievements
|
||||
let currentPlayerId = null;
|
||||
let allAchievements = [];
|
||||
let playerAchievements = [];
|
||||
let currentAchievementCategory = 'all';
|
||||
|
||||
// Load achievements for the current player
|
||||
async function loadPlayerAchievements() {
|
||||
if (!currentPlayerId) {
|
||||
showAchievementsNotAvailable();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Show loading state
|
||||
document.getElementById('achievementsLoading').style.display = 'block';
|
||||
document.getElementById('achievementStats').style.display = 'none';
|
||||
document.getElementById('achievementCategories').style.display = 'none';
|
||||
document.getElementById('achievementsNotAvailable').style.display = 'none';
|
||||
|
||||
// Load player achievements
|
||||
const response = await fetch(`/api/achievements/player/${currentPlayerId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load achievements');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
playerAchievements = result.data;
|
||||
|
||||
// Load achievement statistics
|
||||
await loadAchievementStats();
|
||||
|
||||
// Show achievements
|
||||
displayAchievementStats();
|
||||
displayAchievements();
|
||||
|
||||
// Hide loading state
|
||||
document.getElementById('achievementsLoading').style.display = 'none';
|
||||
document.getElementById('achievementStats').style.display = 'flex';
|
||||
document.getElementById('achievementCategories').style.display = 'block';
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading achievements:', error);
|
||||
document.getElementById('achievementsLoading').style.display = 'none';
|
||||
showAchievementsNotAvailable();
|
||||
}
|
||||
}
|
||||
|
||||
// Load achievement statistics
|
||||
async function loadAchievementStats() {
|
||||
try {
|
||||
const response = await fetch(`/api/achievements/player/${currentPlayerId}/stats`);
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
window.achievementStats = result.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading achievement stats:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Display achievement statistics
|
||||
function displayAchievementStats() {
|
||||
if (!window.achievementStats) return;
|
||||
|
||||
const stats = window.achievementStats;
|
||||
|
||||
document.getElementById('totalPoints').textContent = stats.total_points;
|
||||
document.getElementById('completedAchievements').textContent = `${stats.completed_achievements}/${stats.total_achievements}`;
|
||||
document.getElementById('achievementsToday').textContent = stats.achievements_today;
|
||||
document.getElementById('completionPercentage').textContent = `${stats.completion_percentage}%`;
|
||||
}
|
||||
|
||||
// Display achievements in grid
|
||||
function displayAchievements() {
|
||||
const achievementsGrid = document.getElementById('achievementsGrid');
|
||||
|
||||
if (playerAchievements.length === 0) {
|
||||
achievementsGrid.innerHTML = `
|
||||
<div class="no-achievements">
|
||||
<div class="no-achievements-icon">🏆</div>
|
||||
<h3>Noch keine Achievements</h3>
|
||||
<p>Starte deine ersten Läufe, um Achievements zu sammeln!</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter achievements by category
|
||||
let filteredAchievements = playerAchievements;
|
||||
if (currentAchievementCategory !== 'all') {
|
||||
filteredAchievements = playerAchievements.filter(achievement =>
|
||||
achievement.category === currentAchievementCategory
|
||||
);
|
||||
}
|
||||
|
||||
// Generate achievement cards
|
||||
const achievementCards = filteredAchievements.map(achievement => {
|
||||
const isCompleted = achievement.is_completed;
|
||||
const progress = achievement.progress || 0;
|
||||
const earnedAt = achievement.earned_at;
|
||||
|
||||
let progressText = '';
|
||||
if (isCompleted) {
|
||||
progressText = earnedAt ?
|
||||
`Erreicht am ${new Date(earnedAt).toLocaleDateString('de-DE')}` :
|
||||
'Abgeschlossen';
|
||||
} else if (progress > 0) {
|
||||
// Show progress for incomplete achievements
|
||||
const conditionValue = getAchievementConditionValue(achievement.name);
|
||||
if (conditionValue) {
|
||||
progressText = `${progress}/${conditionValue}`;
|
||||
}
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="achievement-card ${isCompleted ? 'completed' : 'incomplete'}"
|
||||
onclick="showAchievementDetails('${achievement.id}')">
|
||||
<div class="achievement-icon">${achievement.icon}</div>
|
||||
<div class="achievement-content">
|
||||
<h4 class="achievement-name">${achievement.name}</h4>
|
||||
<p class="achievement-description">${achievement.description}</p>
|
||||
<div class="achievement-meta">
|
||||
<span class="achievement-points">+${achievement.points} Punkte</span>
|
||||
${progressText ? `<span class="achievement-progress">${progressText}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="achievement-status">
|
||||
${isCompleted ? '✅' : '⏳'}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
achievementsGrid.innerHTML = achievementCards;
|
||||
}
|
||||
|
||||
// Get achievement condition value for progress display
|
||||
function getAchievementConditionValue(achievementName) {
|
||||
const conditionMap = {
|
||||
'Erste Schritte': 1,
|
||||
'Durchhalter': 3,
|
||||
'Fleißig': 5,
|
||||
'Besessen': 10,
|
||||
'Regelmäßig': 5,
|
||||
'Stammgast': 10,
|
||||
'Treue': 20,
|
||||
'Veteran': 50,
|
||||
'Fortschritt': 5,
|
||||
'Durchbruch': 10,
|
||||
'Transformation': 15,
|
||||
'Perfektionist': 20
|
||||
};
|
||||
return conditionMap[achievementName] || null;
|
||||
}
|
||||
|
||||
// Show achievement category
|
||||
function showAchievementCategory(category) {
|
||||
currentAchievementCategory = category;
|
||||
|
||||
// Update active tab
|
||||
document.querySelectorAll('.category-tab').forEach(tab => {
|
||||
tab.classList.remove('active');
|
||||
});
|
||||
document.querySelector(`[data-category="${category}"]`).classList.add('active');
|
||||
|
||||
// Display filtered achievements
|
||||
displayAchievements();
|
||||
}
|
||||
|
||||
// Show achievement details (placeholder for future modal)
|
||||
function showAchievementDetails(achievementId) {
|
||||
const achievement = playerAchievements.find(a => a.id === achievementId);
|
||||
if (achievement) {
|
||||
console.log('Achievement details:', achievement);
|
||||
// TODO: Implement achievement details modal
|
||||
}
|
||||
}
|
||||
|
||||
// Show achievements not available state
|
||||
function showAchievementsNotAvailable() {
|
||||
document.getElementById('achievementsLoading').style.display = 'none';
|
||||
document.getElementById('achievementStats').style.display = 'none';
|
||||
document.getElementById('achievementCategories').style.display = 'none';
|
||||
document.getElementById('achievementsNotAvailable').style.display = 'block';
|
||||
}
|
||||
|
||||
// Check achievements for current player
|
||||
async function checkPlayerAchievements() {
|
||||
if (!currentPlayerId) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/achievements/check/${currentPlayerId}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
if (result.data.count > 0) {
|
||||
// Show notification for new achievements
|
||||
showAchievementNotification(result.data.new_achievements);
|
||||
// Reload achievements
|
||||
await loadPlayerAchievements();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking achievements:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Show achievement notification
|
||||
function showAchievementNotification(newAchievements) {
|
||||
// Create notification element
|
||||
const notification = document.createElement('div');
|
||||
notification.className = 'achievement-notification';
|
||||
notification.innerHTML = `
|
||||
<div class="notification-content">
|
||||
<div class="notification-icon">🏆</div>
|
||||
<div class="notification-text">
|
||||
<h4>Neue Achievements erreicht!</h4>
|
||||
<p>Du hast ${newAchievements.length} neue Achievement${newAchievements.length > 1 ? 's' : ''} erhalten!</p>
|
||||
</div>
|
||||
<button class="notification-close" onclick="this.parentElement.parentElement.remove()">×</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add to page
|
||||
document.body.appendChild(notification);
|
||||
|
||||
// Auto-remove after 5 seconds
|
||||
setTimeout(() => {
|
||||
if (notification.parentElement) {
|
||||
notification.remove();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Initialize achievements when player is loaded
|
||||
function initializeAchievements(playerId) {
|
||||
currentPlayerId = playerId;
|
||||
loadPlayerAchievements();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Add cookie settings button functionality
|
||||
const cookieSettingsBtn = document.getElementById('cookie-settings-footer');
|
||||
|
||||
406
routes/api.js
406
routes/api.js
@@ -886,6 +886,15 @@ router.post('/v1/private/create-time', requireApiKey, async (req, res) => {
|
||||
[player_id, location_id, recorded_time, new Date()]
|
||||
);
|
||||
|
||||
// Achievement-Überprüfung nach Zeit-Eingabe
|
||||
try {
|
||||
await pool.query('SELECT check_all_achievements($1)', [player_id]);
|
||||
console.log(`✅ Achievement-Check für Spieler ${player_id} ausgeführt`);
|
||||
} catch (achievementError) {
|
||||
console.error('Fehler bei Achievement-Check:', achievementError);
|
||||
// Achievement-Fehler sollen die Zeit-Eingabe nicht blockieren
|
||||
}
|
||||
|
||||
|
||||
|
||||
// WebSocket-Event senden für Live-Updates
|
||||
@@ -1202,7 +1211,10 @@ router.get('/v1/public/user-times/:supabase_user_id', async (req, res) => {
|
||||
ORDER BY t.created_at DESC
|
||||
`, [player_id]);
|
||||
|
||||
res.json(timesResult.rows);
|
||||
res.json({
|
||||
success: true,
|
||||
data: timesResult.rows
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Abrufen der Benutzerzeiten:', error);
|
||||
@@ -1213,6 +1225,47 @@ router.get('/v1/public/user-times/:supabase_user_id', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/public/user-player/{supabase_user_id}:
|
||||
* get:
|
||||
* summary: Spieler-Info anhand der Supabase User ID abrufen
|
||||
* description: Ruft Spieler-Informationen für das Dashboard ab
|
||||
* tags: [Public API]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: supabase_user_id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* format: uuid
|
||||
* description: Supabase User ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Spieler-Informationen
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* $ref: '#/components/schemas/Player'
|
||||
* 404:
|
||||
* description: Spieler nicht gefunden
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 500:
|
||||
* description: Server-Fehler
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
// Get player info by Supabase user ID (no auth required for dashboard)
|
||||
router.get('/v1/public/user-player/:supabase_user_id', async (req, res) => {
|
||||
const { supabase_user_id } = req.params;
|
||||
@@ -1685,6 +1738,75 @@ router.get('/v1/public/locations', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/public/times:
|
||||
* get:
|
||||
* summary: Alle Zeiten mit Standort-Informationen abrufen
|
||||
* description: Ruft alle aufgezeichneten Zeiten mit Standort-Details ab
|
||||
* tags: [Public API]
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: location
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Filter nach Standort-ID
|
||||
* - in: query
|
||||
* name: limit
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 100
|
||||
* description: Maximale Anzahl der Ergebnisse
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Liste aller Zeiten
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: string
|
||||
* format: uuid
|
||||
* player_id:
|
||||
* type: string
|
||||
* format: uuid
|
||||
* location_id:
|
||||
* type: string
|
||||
* format: uuid
|
||||
* recorded_time:
|
||||
* type: object
|
||||
* properties:
|
||||
* seconds:
|
||||
* type: number
|
||||
* minutes:
|
||||
* type: number
|
||||
* milliseconds:
|
||||
* type: number
|
||||
* created_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* location_name:
|
||||
* type: string
|
||||
* latitude:
|
||||
* type: number
|
||||
* longitude:
|
||||
* type: number
|
||||
* 500:
|
||||
* description: Server-Fehler
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
// Public route to get times for location with parameter
|
||||
router.get('/v1/public/times', async (req, res) => {
|
||||
const { location } = req.query;
|
||||
@@ -2022,6 +2144,15 @@ router.post('/v1/admin/runs', requireAdminAuth, async (req, res) => {
|
||||
[player_id, location_id, timeInterval]
|
||||
);
|
||||
|
||||
// Achievement-Überprüfung nach Zeit-Eingabe
|
||||
try {
|
||||
await pool.query('SELECT check_all_achievements($1)', [player_id]);
|
||||
console.log(`✅ Achievement-Check für Spieler ${player_id} ausgeführt`);
|
||||
} catch (achievementError) {
|
||||
console.error('Fehler bei Achievement-Check:', achievementError);
|
||||
// Achievement-Fehler sollen die Zeit-Eingabe nicht blockieren
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Lauf erfolgreich hinzugefügt',
|
||||
@@ -2169,4 +2300,277 @@ router.put('/v1/admin/adminusers/:id', requireAdminAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== ACHIEVEMENT ENDPOINTS ====================
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/achievements:
|
||||
* get:
|
||||
* summary: Alle verfügbaren Achievements abrufen
|
||||
* description: Ruft alle aktiven Achievements im System ab
|
||||
* tags: [Achievements]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Liste aller Achievements
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/Achievement'
|
||||
* 500:
|
||||
* description: Server-Fehler
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
// Get all achievements
|
||||
router.get('/achievements', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT id, name, description, category, icon, points, is_active
|
||||
FROM achievements
|
||||
WHERE is_active = true
|
||||
ORDER BY category, points DESC
|
||||
`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.rows
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching achievements:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Fehler beim Laden der Achievements'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/achievements/player/{playerId}:
|
||||
* get:
|
||||
* summary: Achievements eines Spielers abrufen
|
||||
* description: Ruft alle Achievements für einen bestimmten Spieler ab
|
||||
* tags: [Achievements]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: playerId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* format: uuid
|
||||
* description: Eindeutige Spieler-ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Liste der Spieler-Achievements
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: array
|
||||
* items:
|
||||
* allOf:
|
||||
* - $ref: '#/components/schemas/Achievement'
|
||||
* - $ref: '#/components/schemas/PlayerAchievement'
|
||||
* 500:
|
||||
* description: Server-Fehler
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
// Get player achievements
|
||||
router.get('/achievements/player/:playerId', async (req, res) => {
|
||||
try {
|
||||
const { playerId } = req.params;
|
||||
|
||||
const result = await pool.query(`
|
||||
SELECT
|
||||
a.id,
|
||||
a.name,
|
||||
a.description,
|
||||
a.category,
|
||||
a.icon,
|
||||
a.points,
|
||||
pa.progress,
|
||||
pa.is_completed,
|
||||
pa.earned_at
|
||||
FROM achievements a
|
||||
LEFT JOIN player_achievements pa ON a.id = pa.achievement_id AND pa.player_id = $1
|
||||
WHERE a.is_active = true
|
||||
ORDER BY
|
||||
pa.is_completed DESC,
|
||||
a.category,
|
||||
a.points DESC
|
||||
`, [playerId]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.rows
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching player achievements:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Fehler beim Laden der Spieler-Achievements'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Get player achievement statistics
|
||||
router.get('/achievements/player/:playerId/stats', async (req, res) => {
|
||||
try {
|
||||
const { playerId } = req.params;
|
||||
|
||||
const result = await pool.query(`
|
||||
SELECT
|
||||
COUNT(pa.id) as total_achievements,
|
||||
COUNT(CASE WHEN pa.is_completed = true THEN 1 END) as completed_achievements,
|
||||
SUM(CASE WHEN pa.is_completed = true THEN a.points ELSE 0 END) as total_points,
|
||||
COUNT(CASE WHEN pa.is_completed = true AND DATE(pa.earned_at AT TIME ZONE 'Europe/Berlin') = CURRENT_DATE THEN 1 END) as achievements_today
|
||||
FROM achievements a
|
||||
LEFT JOIN player_achievements pa ON a.id = pa.achievement_id AND pa.player_id = $1
|
||||
WHERE a.is_active = true
|
||||
`, [playerId]);
|
||||
|
||||
const stats = result.rows[0];
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
total_achievements: parseInt(stats.total_achievements),
|
||||
completed_achievements: parseInt(stats.completed_achievements),
|
||||
total_points: parseInt(stats.total_points) || 0,
|
||||
achievements_today: parseInt(stats.achievements_today),
|
||||
completion_percentage: stats.total_achievements > 0 ?
|
||||
Math.round((stats.completed_achievements / stats.total_achievements) * 100) : 0
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching player achievement stats:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Fehler beim Laden der Achievement-Statistiken'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Check achievements for a specific player
|
||||
router.post('/achievements/check/:playerId', async (req, res) => {
|
||||
try {
|
||||
const { playerId } = req.params;
|
||||
|
||||
// Verify player exists
|
||||
const playerCheck = await pool.query('SELECT id FROM players WHERE id = $1', [playerId]);
|
||||
if (playerCheck.rows.length === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Spieler nicht gefunden'
|
||||
});
|
||||
}
|
||||
|
||||
// Run achievement check
|
||||
await pool.query('SELECT check_all_achievements($1)', [playerId]);
|
||||
|
||||
// Get newly earned achievements
|
||||
const newAchievements = await pool.query(`
|
||||
SELECT a.name, a.description, a.icon, a.points
|
||||
FROM player_achievements pa
|
||||
INNER JOIN achievements a ON pa.achievement_id = a.id
|
||||
WHERE pa.player_id = $1
|
||||
AND pa.is_completed = true
|
||||
AND DATE(pa.earned_at AT TIME ZONE 'Europe/Berlin') = CURRENT_DATE
|
||||
ORDER BY pa.earned_at DESC
|
||||
`, [playerId]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Achievement-Check abgeschlossen',
|
||||
data: {
|
||||
new_achievements: newAchievements.rows,
|
||||
count: newAchievements.rows.length
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error checking achievements:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Fehler beim Überprüfen der Achievements'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Run daily achievement check for all players
|
||||
router.post('/achievements/daily-check', async (req, res) => {
|
||||
try {
|
||||
// This endpoint runs the daily achievement check
|
||||
const { runDailyAchievements } = require('../scripts/daily_achievements');
|
||||
|
||||
await runDailyAchievements();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Tägliche Achievement-Überprüfung abgeschlossen'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error running daily achievement check:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Fehler bei der täglichen Achievement-Überprüfung'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Get achievement leaderboard
|
||||
router.get('/achievements/leaderboard', async (req, res) => {
|
||||
try {
|
||||
const { limit = 10 } = req.query;
|
||||
|
||||
const result = await pool.query(`
|
||||
SELECT
|
||||
p.firstname,
|
||||
p.lastname,
|
||||
COUNT(pa.id) as completed_achievements,
|
||||
SUM(a.points) as total_points
|
||||
FROM players p
|
||||
INNER JOIN player_achievements pa ON p.id = pa.player_id
|
||||
INNER JOIN achievements a ON pa.achievement_id = a.id
|
||||
WHERE pa.is_completed = true
|
||||
GROUP BY p.id, p.firstname, p.lastname
|
||||
ORDER BY total_points DESC, completed_achievements DESC
|
||||
LIMIT $1
|
||||
`, [parseInt(limit)]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.rows.map((row, index) => ({
|
||||
rank: index + 1,
|
||||
name: `${row.firstname} ${row.lastname}`,
|
||||
completed_achievements: parseInt(row.completed_achievements),
|
||||
total_points: parseInt(row.total_points)
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching achievement leaderboard:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Fehler beim Laden der Bestenliste'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = { router, requireApiKey };
|
||||
|
||||
110
scripts/daily_achievements.js
Normal file
110
scripts/daily_achievements.js
Normal file
@@ -0,0 +1,110 @@
|
||||
const { Pool } = require('pg');
|
||||
require('dotenv').config();
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false
|
||||
});
|
||||
|
||||
async function runDailyAchievements() {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
console.log('🎯 Starting daily achievement check...');
|
||||
|
||||
// Get all players who have played today
|
||||
const playersResult = await client.query(`
|
||||
SELECT DISTINCT p.id, p.firstname, p.lastname
|
||||
FROM players p
|
||||
INNER JOIN times t ON p.id = t.player_id
|
||||
WHERE DATE(t.created_at AT TIME ZONE 'Europe/Berlin') = CURRENT_DATE
|
||||
`);
|
||||
|
||||
console.log(`Found ${playersResult.rows.length} players who played today`);
|
||||
|
||||
let totalAchievements = 0;
|
||||
|
||||
// Check achievements for each player
|
||||
for (const player of playersResult.rows) {
|
||||
console.log(`Checking achievements for ${player.firstname} ${player.lastname}...`);
|
||||
|
||||
// Run achievement check function
|
||||
await client.query('SELECT check_all_achievements($1)', [player.id]);
|
||||
|
||||
// Count new achievements earned today
|
||||
const newAchievementsResult = await client.query(`
|
||||
SELECT COUNT(*) as count
|
||||
FROM player_achievements pa
|
||||
INNER JOIN achievements a ON pa.achievement_id = a.id
|
||||
WHERE pa.player_id = $1
|
||||
AND pa.is_completed = true
|
||||
AND DATE(pa.earned_at AT TIME ZONE 'Europe/Berlin') = CURRENT_DATE
|
||||
`, [player.id]);
|
||||
|
||||
const newAchievements = parseInt(newAchievementsResult.rows[0].count);
|
||||
totalAchievements += newAchievements;
|
||||
|
||||
if (newAchievements > 0) {
|
||||
console.log(` ✅ ${newAchievements} new achievements earned!`);
|
||||
|
||||
// Get details of new achievements
|
||||
const achievementsResult = await client.query(`
|
||||
SELECT a.name, a.description, a.icon, a.points
|
||||
FROM player_achievements pa
|
||||
INNER JOIN achievements a ON pa.achievement_id = a.id
|
||||
WHERE pa.player_id = $1
|
||||
AND pa.is_completed = true
|
||||
AND DATE(pa.earned_at AT TIME ZONE 'Europe/Berlin') = CURRENT_DATE
|
||||
ORDER BY pa.earned_at DESC
|
||||
`, [player.id]);
|
||||
|
||||
achievementsResult.rows.forEach(achievement => {
|
||||
console.log(` ${achievement.icon} ${achievement.name} (+${achievement.points} points)`);
|
||||
});
|
||||
} else {
|
||||
console.log(` ℹ️ No new achievements`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n🎉 Daily achievement check completed!`);
|
||||
console.log(`Total new achievements earned: ${totalAchievements}`);
|
||||
|
||||
// Log summary statistics
|
||||
const statsResult = await client.query(`
|
||||
SELECT
|
||||
COUNT(DISTINCT pa.player_id) as players_with_achievements,
|
||||
COUNT(pa.id) as total_achievements_earned,
|
||||
SUM(a.points) as total_points_earned
|
||||
FROM player_achievements pa
|
||||
INNER JOIN achievements a ON pa.achievement_id = a.id
|
||||
WHERE pa.is_completed = true
|
||||
`);
|
||||
|
||||
const stats = statsResult.rows[0];
|
||||
console.log(`\n📊 Overall Statistics:`);
|
||||
console.log(`Players with achievements: ${stats.players_with_achievements}`);
|
||||
console.log(`Total achievements earned: ${stats.total_achievements_earned}`);
|
||||
console.log(`Total points earned: ${stats.total_points_earned || 0}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error running daily achievements:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// Run if called directly
|
||||
if (require.main === module) {
|
||||
runDailyAchievements()
|
||||
.then(() => {
|
||||
console.log('✅ Daily achievements script completed successfully');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('❌ Daily achievements script failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { runDailyAchievements };
|
||||
86
scripts/setup_cron.js
Normal file
86
scripts/setup_cron.js
Normal file
@@ -0,0 +1,86 @@
|
||||
const { exec } = require('child_process');
|
||||
const path = require('path');
|
||||
|
||||
// Cron job setup for daily achievements
|
||||
const cronJob = {
|
||||
// Run daily at 23:59 (end of day)
|
||||
schedule: '59 23 * * *',
|
||||
command: `cd ${__dirname} && node daily_achievements.js >> /var/log/ninjaserver_achievements.log 2>&1`,
|
||||
description: 'Daily achievement check for Ninja Cross Parkour'
|
||||
};
|
||||
|
||||
function setupCronJob() {
|
||||
console.log('🕐 Setting up daily achievement cron job...');
|
||||
|
||||
// Create cron job entry
|
||||
const cronEntry = `${cronJob.schedule} ${cronJob.command}`;
|
||||
|
||||
// Add to crontab
|
||||
exec(`(crontab -l 2>/dev/null; echo "${cronEntry}") | crontab -`, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
console.error('❌ Error setting up cron job:', error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (stderr) {
|
||||
console.error('⚠️ Cron job warning:', stderr);
|
||||
}
|
||||
|
||||
console.log('✅ Cron job setup successfully!');
|
||||
console.log(`📅 Schedule: ${cronJob.schedule}`);
|
||||
console.log(`🔧 Command: ${cronJob.command}`);
|
||||
console.log('📝 Logs will be written to: /var/log/ninjaserver_achievements.log');
|
||||
|
||||
// Show current crontab
|
||||
exec('crontab -l', (error, stdout, stderr) => {
|
||||
if (!error) {
|
||||
console.log('\n📋 Current crontab:');
|
||||
console.log(stdout);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function removeCronJob() {
|
||||
console.log('🗑️ Removing daily achievement cron job...');
|
||||
|
||||
exec('crontab -l | grep -v "daily_achievements.js" | crontab -', (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
console.error('❌ Error removing cron job:', error);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('✅ Cron job removed successfully!');
|
||||
});
|
||||
}
|
||||
|
||||
// Command line interface
|
||||
if (require.main === module) {
|
||||
const command = process.argv[2];
|
||||
|
||||
switch (command) {
|
||||
case 'setup':
|
||||
setupCronJob();
|
||||
break;
|
||||
case 'remove':
|
||||
removeCronJob();
|
||||
break;
|
||||
case 'status':
|
||||
exec('crontab -l | grep daily_achievements', (error, stdout, stderr) => {
|
||||
if (stdout) {
|
||||
console.log('✅ Cron job is active:');
|
||||
console.log(stdout);
|
||||
} else {
|
||||
console.log('❌ No cron job found');
|
||||
}
|
||||
});
|
||||
break;
|
||||
default:
|
||||
console.log('Usage: node setup_cron.js [setup|remove|status]');
|
||||
console.log(' setup - Add daily achievement cron job');
|
||||
console.log(' remove - Remove daily achievement cron job');
|
||||
console.log(' status - Check if cron job is active');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { setupCronJob, removeCronJob };
|
||||
@@ -21,6 +21,8 @@ const path = require('path');
|
||||
const session = require('express-session');
|
||||
const { createServer } = require('http');
|
||||
const { Server } = require('socket.io');
|
||||
const swaggerUi = require('swagger-ui-express');
|
||||
const swaggerSpecs = require('./swagger');
|
||||
require('dotenv').config();
|
||||
|
||||
// Route Imports
|
||||
@@ -82,6 +84,12 @@ function requireWebAuth(req, res, next) {
|
||||
// ROUTE SETUP
|
||||
// ============================================================================
|
||||
|
||||
// Swagger API Documentation
|
||||
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpecs, {
|
||||
customCss: '.swagger-ui .topbar { display: none }',
|
||||
customSiteTitle: 'Ninja Cross Parkour API Documentation'
|
||||
}));
|
||||
|
||||
// Unified API Routes (all under /api/v1/)
|
||||
// - /api/v1/public/* - Public routes (no authentication)
|
||||
// - /api/v1/private/* - API-Key protected routes
|
||||
|
||||
285
swagger.js
Normal file
285
swagger.js
Normal file
@@ -0,0 +1,285 @@
|
||||
const swaggerJsdoc = require('swagger-jsdoc');
|
||||
|
||||
const options = {
|
||||
definition: {
|
||||
openapi: '3.0.0',
|
||||
info: {
|
||||
title: 'Ninja Cross Parkour API',
|
||||
version: '1.0.0',
|
||||
description: 'API für das Ninja Cross Parkour System im Schwimmbad',
|
||||
contact: {
|
||||
name: 'Ninja Cross Parkour',
|
||||
email: 'admin@ninjacross.com'
|
||||
},
|
||||
license: {
|
||||
name: 'MIT',
|
||||
url: 'https://opensource.org/licenses/MIT'
|
||||
}
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
url: 'https://ninja.reptilfpv.de',
|
||||
description: 'Production server'
|
||||
}
|
||||
],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
ApiKeyAuth: {
|
||||
type: 'apiKey',
|
||||
in: 'header',
|
||||
name: 'X-API-Key',
|
||||
description: 'API Key für Authentifizierung'
|
||||
},
|
||||
BearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
description: 'JWT Token für Supabase Authentifizierung'
|
||||
}
|
||||
},
|
||||
schemas: {
|
||||
Player: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
description: 'Eindeutige Spieler-ID'
|
||||
},
|
||||
firstname: {
|
||||
type: 'string',
|
||||
description: 'Vorname des Spielers'
|
||||
},
|
||||
lastname: {
|
||||
type: 'string',
|
||||
description: 'Nachname des Spielers'
|
||||
},
|
||||
birthdate: {
|
||||
type: 'string',
|
||||
format: 'date',
|
||||
description: 'Geburtsdatum des Spielers'
|
||||
},
|
||||
rfiduid: {
|
||||
type: 'string',
|
||||
description: 'RFID UID der Karte (Format: XX:XX:XX:XX)'
|
||||
},
|
||||
supabase_user_id: {
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
description: 'Supabase User ID für Verknüpfung'
|
||||
},
|
||||
created_at: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
description: 'Erstellungsdatum'
|
||||
}
|
||||
}
|
||||
},
|
||||
Time: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
description: 'Eindeutige Zeit-ID'
|
||||
},
|
||||
player_id: {
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
description: 'ID des Spielers'
|
||||
},
|
||||
location_id: {
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
description: 'ID des Standorts'
|
||||
},
|
||||
recorded_time: {
|
||||
type: 'object',
|
||||
description: 'Aufgezeichnete Zeit als Intervall',
|
||||
properties: {
|
||||
seconds: { type: 'number' },
|
||||
minutes: { type: 'number' },
|
||||
milliseconds: { type: 'number' }
|
||||
}
|
||||
},
|
||||
created_at: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
description: 'Erstellungsdatum'
|
||||
}
|
||||
}
|
||||
},
|
||||
Location: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
description: 'Eindeutige Standort-ID'
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'Name des Standorts'
|
||||
},
|
||||
latitude: {
|
||||
type: 'number',
|
||||
format: 'float',
|
||||
description: 'Breitengrad'
|
||||
},
|
||||
longitude: {
|
||||
type: 'number',
|
||||
format: 'float',
|
||||
description: 'Längengrad'
|
||||
},
|
||||
time_threshold: {
|
||||
type: 'object',
|
||||
description: 'Zeitschwelle für den Standort',
|
||||
properties: {
|
||||
seconds: { type: 'number' },
|
||||
minutes: { type: 'number' }
|
||||
}
|
||||
},
|
||||
created_at: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
description: 'Erstellungsdatum'
|
||||
}
|
||||
}
|
||||
},
|
||||
Achievement: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
description: 'Eindeutige Achievement-ID'
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'Name des Achievements'
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
description: 'Beschreibung des Achievements'
|
||||
},
|
||||
category: {
|
||||
type: 'string',
|
||||
enum: ['consistency', 'improvement', 'seasonal', 'monthly'],
|
||||
description: 'Kategorie des Achievements'
|
||||
},
|
||||
condition_type: {
|
||||
type: 'string',
|
||||
description: 'Typ der Bedingung'
|
||||
},
|
||||
condition_value: {
|
||||
type: 'integer',
|
||||
description: 'Wert der Bedingung'
|
||||
},
|
||||
icon: {
|
||||
type: 'string',
|
||||
description: 'Emoji-Icon für das Achievement'
|
||||
},
|
||||
points: {
|
||||
type: 'integer',
|
||||
description: 'Punkte für das Achievement'
|
||||
},
|
||||
is_active: {
|
||||
type: 'boolean',
|
||||
description: 'Ob das Achievement aktiv ist'
|
||||
}
|
||||
}
|
||||
},
|
||||
PlayerAchievement: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
description: 'Eindeutige Player-Achievement-ID'
|
||||
},
|
||||
player_id: {
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
description: 'ID des Spielers'
|
||||
},
|
||||
achievement_id: {
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
description: 'ID des Achievements'
|
||||
},
|
||||
progress: {
|
||||
type: 'integer',
|
||||
description: 'Aktueller Fortschritt'
|
||||
},
|
||||
is_completed: {
|
||||
type: 'boolean',
|
||||
description: 'Ob das Achievement abgeschlossen ist'
|
||||
},
|
||||
earned_at: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
description: 'Wann das Achievement erreicht wurde'
|
||||
}
|
||||
}
|
||||
},
|
||||
Error: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: {
|
||||
type: 'boolean',
|
||||
example: false
|
||||
},
|
||||
message: {
|
||||
type: 'string',
|
||||
description: 'Fehlermeldung'
|
||||
}
|
||||
}
|
||||
},
|
||||
Success: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: {
|
||||
type: 'boolean',
|
||||
example: true
|
||||
},
|
||||
message: {
|
||||
type: 'string',
|
||||
description: 'Erfolgsmeldung'
|
||||
},
|
||||
data: {
|
||||
type: 'object',
|
||||
description: 'Daten der Antwort'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
tags: [
|
||||
{
|
||||
name: 'Public API',
|
||||
description: 'Öffentliche API-Endpoints ohne Authentifizierung'
|
||||
},
|
||||
{
|
||||
name: 'Private API',
|
||||
description: 'Private API-Endpoints mit API-Key Authentifizierung'
|
||||
},
|
||||
{
|
||||
name: 'Web API',
|
||||
description: 'Web-API-Endpoints für das Frontend'
|
||||
},
|
||||
{
|
||||
name: 'Admin API',
|
||||
description: 'Admin-API-Endpoints für Verwaltung'
|
||||
},
|
||||
{
|
||||
name: 'Achievements',
|
||||
description: 'Achievement-System Endpoints'
|
||||
}
|
||||
]
|
||||
},
|
||||
apis: ['./routes/api.js'] // Pfad zu deiner API-Datei
|
||||
};
|
||||
|
||||
const specs = swaggerJsdoc(options);
|
||||
|
||||
module.exports = specs;
|
||||
Reference in New Issue
Block a user