diff --git a/ACHIEVEMENTS.md b/ACHIEVEMENTS.md new file mode 100644 index 0000000..2c812c4 --- /dev/null +++ b/ACHIEVEMENTS.md @@ -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 diff --git a/package-lock.json b/package-lock.json index c2fd6f2..4ca5d08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index f160caa..e99d48c 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/public/css/dashboard.css b/public/css/dashboard.css index 2f016e0..c1f1aad 100644 --- a/public/css/dashboard.css +++ b/public/css/dashboard.css @@ -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; + } +} diff --git a/public/dashboard.html b/public/dashboard.html index 1bd8e2e..518ed75 100644 --- a/public/dashboard.html +++ b/public/dashboard.html @@ -120,6 +120,67 @@ + + +
+
+

🏆 Meine Achievements

+

Sammele Punkte und erreiche neue Meilensteine!

+
+ + + + + + + + + + + + +
diff --git a/public/js/dashboard.js b/public/js/dashboard.js index 3b87f9a..aa890d6 100644 --- a/public/js/dashboard.js +++ b/public/js/dashboard.js @@ -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 = ` +
+
🏆
+

Noch keine Achievements

+

Starte deine ersten Läufe, um Achievements zu sammeln!

+
+ `; + 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 ` +
+
${achievement.icon}
+
+

${achievement.name}

+

${achievement.description}

+
+ +${achievement.points} Punkte + ${progressText ? `${progressText}` : ''} +
+
+
+ ${isCompleted ? '✅' : '⏳'} +
+
+ `; + }).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 = ` +
+
🏆
+
+

Neue Achievements erreicht!

+

Du hast ${newAchievements.length} neue Achievement${newAchievements.length > 1 ? 's' : ''} erhalten!

+
+ +
+ `; + + // 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'); diff --git a/routes/api.js b/routes/api.js index 6235f5c..d7c300e 100644 --- a/routes/api.js +++ b/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 }; diff --git a/scripts/daily_achievements.js b/scripts/daily_achievements.js new file mode 100644 index 0000000..55b2376 --- /dev/null +++ b/scripts/daily_achievements.js @@ -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 }; diff --git a/scripts/setup_cron.js b/scripts/setup_cron.js new file mode 100644 index 0000000..400eb80 --- /dev/null +++ b/scripts/setup_cron.js @@ -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 }; diff --git a/server.js b/server.js index 3e85b44..c48fac4 100644 --- a/server.js +++ b/server.js @@ -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 diff --git a/swagger.js b/swagger.js new file mode 100644 index 0000000..04c8b82 --- /dev/null +++ b/swagger.js @@ -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;