1 Commits
main ... httv

684 changed files with 28097 additions and 95998 deletions

3
.gitignore vendored
View File

@@ -6,6 +6,3 @@ frontend/.env
backend/.env
backend/images/*
backend/backend-debug.log
backend/*.log
backend/.env.local

View File

@@ -1,86 +0,0 @@
# Server-Prüfung: i18n-Fixes
## Lokale Prüfung (bereits durchgeführt)
✅ Alle Dateien sind lokal korrekt:
- `TeamManagementView.vue` - Alle `$t()` durch `t()` ersetzt, `t` im return Statement
- `PermissionsView.vue` - Alle `$t()` durch `t()` ersetzt, `t` im return Statement
- `LogsView.vue` - Alle `$t()` durch `t()` ersetzt, `t` im return Statement
- `SeasonSelector.vue` - Bereits korrekt
## Server-Prüfung
### 1. Prüfskript auf den Server kopieren
```bash
# Vom lokalen Rechner aus:
scp check-i18n-fixes.sh rv2756:/var/www/tt-tagebuch.de/
```
### 2. Auf dem Server ausführen
```bash
# Auf dem Server:
cd /var/www/tt-tagebuch.de
chmod +x check-i18n-fixes.sh
./check-i18n-fixes.sh
```
### 3. Falls Dateien nicht aktualisiert sind
```bash
# Auf dem Server:
cd /var/www/tt-tagebuch.de
git pull origin main
cd backend
npm install # Erstellt automatisch den Frontend-Build (via postinstall script)
```
### 4. Backend neu starten (falls nötig)
```bash
# Falls als systemd-Service:
sudo systemctl restart tt-tagebuch
# Oder falls als PM2-Prozess:
pm2 restart tt-tagebuch-backend
```
## Erwartete Ergebnisse
Das Prüfskript sollte folgende Ausgabe zeigen:
```
1. TeamManagementView.vue:
✓ Enthält 'const t = (key, params) => i18n.global.t'
✓ Enthält keine $t() Aufrufe mehr
✓ 't' ist im return Statement enthalten
2. PermissionsView.vue:
✓ Enthält 'const t = (key, params) => i18n.global.t'
✓ Enthält keine $t() Aufrufe mehr
✓ 't' ist im return Statement enthalten
3. LogsView.vue:
✓ Enthält 'const t = (key, params) => i18n.global.t'
✓ Enthält keine $t() Aufrufe mehr
✓ 't' ist im return Statement enthalten
4. SeasonSelector.vue:
✓ Enthält 'const t = (key, params) => i18n.global.t'
✓ Enthält keine $t() Aufrufe mehr
```
## Commits, die auf den Server müssen
Die folgenden Commits müssen auf dem Server sein:
- `b0e610f` - Fix: Replace all $t() calls with t() in PermissionsView and LogsView templates
- `0285c05` - Fix: Replace all $t() calls with t() in TeamManagementView template
- `5d4f2eb` - Update localization handling in TeamManagementView
Prüfe mit:
```bash
git log --oneline -5
```

View File

@@ -1,191 +0,0 @@
# Deployment-Anleitung: Socket.IO mit SSL
Socket.IO läuft jetzt direkt auf HTTPS-Port 3051 (nicht über Apache-Proxy).
## Schritte nach dem Deployment
### 1. Firewall-Port öffnen
```bash
# UFW (Ubuntu Firewall)
sudo ufw allow 3051/tcp
```
### 2. Apache-Konfiguration aktualisieren
```bash
sudo cp /var/www/tt-tagebuch.de/apache.conf.example /etc/apache2/sites-available/tt-tagebuch.de-le-ssl.conf
sudo systemctl restart apache2
```
### 3. systemd-Service konfigurieren (als www-data)
**WICHTIG:** Der Service sollte als `www-data` laufen, nicht als `nobody`!
```bash
# Service-Datei installieren
sudo cp /var/www/tt-tagebuch.de/tt-tagebuch.service /etc/systemd/system/
sudo systemctl daemon-reload
```
Die Service-Datei konfiguriert:
- User: `www-data` (Standard-Webserver-Benutzer)
- Group: `www-data`
- Port: 3050 (HTTP) und 3051 (HTTPS)
### 4. SSL-Zertifikat-Berechtigungen setzen
**WICHTIG:** Der Node.js-Prozess muss Zugriff auf die SSL-Zertifikate haben!
```bash
cd /var/www/tt-tagebuch.de/backend
chmod +x scripts/fixCertPermissions.sh
sudo ./scripts/fixCertPermissions.sh
```
Dieses Skript:
- Erstellt die Gruppe `ssl-cert` (falls nicht vorhanden)
- Fügt den Service-Benutzer (`www-data`) zur Gruppe hinzu
- Setzt die Berechtigungen für die Zertifikate
### 5. Backend neu starten
**WICHTIG:** Der Backend-Server muss neu gestartet werden, damit der HTTPS-Server auf Port 3051 läuft!
```bash
# Falls als systemd-Service:
sudo systemctl restart tt-tagebuch
# Oder falls als PM2-Prozess:
pm2 restart tt-tagebuch-backend
```
### 6. Prüfen, ob HTTPS-Server läuft
```bash
# Prüfe, ob Port 3051 geöffnet ist
sudo netstat -tlnp | grep 3051
# Oder:
sudo ss -tlnp | grep 3051
# Prüfe Backend-Logs
sudo journalctl -u tt-tagebuch -f
# Oder bei PM2:
pm2 logs tt-tagebuch-backend
```
Du solltest folgende Meldung sehen:
```
🚀 HTTPS-Server für Socket.IO läuft auf Port 3051
```
### 7. Diagnose-Skript ausführen
```bash
cd /var/www/tt-tagebuch.de/backend
node scripts/checkSocketIOServer.js
```
Dieses Skript prüft:
- Ob SSL-Zertifikate existieren
- Ob Port 3051 geöffnet ist
- Ob der Server erreichbar ist
### 8. Testen
Im Browser sollte Socket.IO jetzt direkt zu `wss://tt-tagebuch.de:3051` verbinden.
## Troubleshooting
### Port 3051 ist nicht erreichbar
1. **Prüfe Firewall:**
```bash
sudo ufw status
sudo ufw allow 3051/tcp
```
2. **Prüfe, ob der Server läuft:**
```bash
sudo netstat -tlnp | grep 3051
sudo ss -tlnp | grep 3051
```
3. **Prüfe Backend-Logs auf Fehler:**
```bash
sudo journalctl -u tt-tagebuch -n 50
# Oder:
pm2 logs tt-tagebuch-backend --lines 50
```
4. **Prüfe, ob HTTPS-Server gestartet wurde:**
- Suche in den Logs nach: `🚀 HTTPS-Server für Socket.IO läuft auf Port 3051`
- Falls nicht vorhanden, prüfe auf Fehler: `⚠️ HTTPS-Server konnte nicht gestartet werden`
### SSL-Zertifikat-Fehler / Berechtigungsfehler
**Fehler:** `EACCES: permission denied, open '/etc/letsencrypt/live/tt-tagebuch.de/privkey.pem'`
**Lösung:**
```bash
cd /var/www/tt-tagebuch.de/backend
chmod +x scripts/fixCertPermissions.sh
sudo ./scripts/fixCertPermissions.sh
sudo systemctl restart tt-tagebuch
```
Stelle sicher, dass die Zertifikate existieren:
```bash
ls -la /etc/letsencrypt/live/tt-tagebuch.de/
```
Falls die Zertifikate nicht existieren:
```bash
sudo certbot certonly --standalone -d tt-tagebuch.de
```
### Service läuft als "nobody"
**Problem:** Der Service läuft als `nobody`, was zu eingeschränkt ist.
**Lösung:**
1. Installiere die Service-Datei (siehe Schritt 3)
2. Führe das Berechtigungs-Skript aus (siehe Schritt 4)
3. Starte den Service neu
```bash
# Prüfe aktuellen Service-User
sudo systemctl show -p User tt-tagebuch.service
# Installiere Service-Datei
sudo cp /var/www/tt-tagebuch.de/tt-tagebuch.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl restart tt-tagebuch
# Prüfe, ob jetzt als www-data läuft
sudo systemctl show -p User tt-tagebuch.service
```
### Frontend verbindet nicht
1. **Prüfe Browser-Konsole auf Fehler**
2. **Prüfe, ob `import.meta.env.PROD` korrekt gesetzt ist:**
- In Produktion sollte die Socket.IO-URL `https://tt-tagebuch.de:3051` sein
- In Entwicklung sollte sie `http://localhost:3005` sein
3. **Prüfe, ob die Socket.IO-URL korrekt ist:**
- Öffne Browser-Entwicklertools → Network
- Suche nach WebSocket-Verbindungen
- Die URL sollte `wss://tt-tagebuch.de:3051/socket.io/...` sein
### Server lauscht nur auf localhost
Der Server sollte auf `0.0.0.0` lauschen (nicht nur auf `localhost`).
Dies ist bereits in der Konfiguration eingestellt:
```javascript
httpsServer.listen(httpsPort, '0.0.0.0', () => {
console.log(`🚀 HTTPS-Server für Socket.IO läuft auf Port ${httpsPort}`);
});
```
Falls der Server trotzdem nicht erreichbar ist, prüfe die Backend-Logs.

View File

@@ -1,342 +0,0 @@
# DSGVO-Konformitäts-Checkliste für Trainingstagebuch
## Status: ⚠️ PRÜFUNG ERFORDERLICH
Diese Checkliste dokumentiert den aktuellen Stand der DSGVO-Konformität der Anwendung.
---
## 1. Datenschutzerklärung ✅ / ⚠️
### Status: ⚠️ Teilweise vorhanden, muss aktualisiert werden
**Vorhanden:**
- ✅ Datenschutzerklärung vorhanden (`/datenschutz`)
- ✅ Impressum vorhanden (`/impressum`)
- ✅ Verlinkung im Footer
**Fehlend/Verbesserungsbedarf:**
- ⚠️ MyTischtennis-Integration nicht erwähnt (Drittlandübermittlung?)
- ⚠️ Logging von API-Requests nicht erwähnt
- ⚠️ Verschlüsselung von Mitgliederdaten nicht erwähnt
- ⚠️ Speicherdauer für Logs nicht konkretisiert
- ⚠️ Keine Informationen zu automatischer Löschung
---
## 2. Einwilligungen ⚠️
### Status: ⚠️ Teilweise vorhanden
**Vorhanden:**
-`picsInInternetAllowed` bei Mitgliedern (Einwilligung für Fotos im Internet)
- ✅ MyTischtennis: `savePassword` und `autoUpdateRatings` (Einwilligungen)
**Fehlend/Verbesserungsbedarf:**
- ⚠️ Keine explizite Einwilligung bei Registrierung zur Datenschutzerklärung
- ⚠️ Keine Einwilligung für Logging von API-Requests
- ⚠️ Keine Einwilligung für Datenübertragung an MyTischtennis.de
- ⚠️ Keine Möglichkeit, Einwilligungen zu widerrufen (außer manuell)
---
## 3. Löschrechte (Art. 17 DSGVO) ⚠️
### Status: ⚠️ Teilweise implementiert
**Vorhanden:**
- ✅ DELETE-Endpunkte für viele Ressourcen (Member, Tournament, etc.)
- ✅ MyTischtennis-Account kann gelöscht werden
**Fehlend/Verbesserungsbedarf:**
-**KRITISCH:** Kein Endpunkt zum vollständigen Löschen eines User-Accounts
-**KRITISCH:** Keine automatische Löschung aller zugehörigen Daten (Cascade-Delete)
- ❌ Keine Löschung von Logs nach Ablauf der Speicherdauer
- ⚠️ Keine Anonymisierung statt Löschung (falls gesetzliche Aufbewahrungspflichten bestehen)
- ⚠️ Keine Bestätigung vor Löschung kritischer Daten
**Empfehlung:**
- Implementiere `/api/user/delete` Endpunkt
- Implementiere automatische Löschung aller zugehörigen Daten:
- UserClub-Einträge
- MyTischtennis-Account
- Alle Logs (nach Anonymisierung)
- Alle Mitglieder, die nur diesem User zugeordnet sind
- Implementiere automatische Löschung von Logs nach 90 Tagen
---
## 4. Auskunftsrechte (Art. 15 DSGVO) ❌
### Status: ❌ Nicht implementiert
**Fehlend:**
-**KRITISCH:** Kein Endpunkt zur Auskunft über gespeicherte Daten
- ❌ Keine Übersicht über alle personenbezogenen Daten eines Users
- ❌ Keine Übersicht über alle Mitgliederdaten
- ❌ Keine Übersicht über Logs, die einen User betreffen
**Empfehlung:**
- Implementiere `/api/user/data-export` Endpunkt
- Exportiere alle Daten in strukturiertem Format (JSON)
- Inkludiere:
- User-Daten
- Vereinszugehörigkeiten
- Mitgliederdaten (falls User Zugriff hat)
- Logs
- MyTischtennis-Daten
---
## 5. Datenportabilität (Art. 20 DSGVO) ❌
### Status: ❌ Nicht implementiert
**Fehlend:**
-**KRITISCH:** Kein Export in maschinenlesbarem Format
- ❌ Keine JSON/XML-Export-Funktion
- ⚠️ PDF-Export für Trainingstage vorhanden, aber nicht für alle Daten
**Empfehlung:**
- Implementiere `/api/user/data-export` mit JSON-Format
- Implementiere Export für:
- Alle eigenen Daten
- Alle Mitgliederdaten (falls berechtigt)
- Alle Trainingsdaten
- Alle Turnierdaten
---
## 6. Verschlüsselung ✅ / ⚠️
### Status: ✅ Gut implementiert
**Vorhanden:**
- ✅ AES-256-CBC Verschlüsselung für Mitgliederdaten:
- firstName, lastName
- birthDate
- phone, street, city, postalCode
- email
- notes (Participant)
- ✅ Passwörter werden mit bcrypt gehasht
- ✅ HTTPS für alle Verbindungen
**Verbesserungsbedarf:**
- ⚠️ Verschlüsselungsschlüssel sollte in separater, sicherer Konfiguration sein
-**BEHOBEN:** MyTischtennis-Daten werden jetzt vollständig verschlüsselt (E-Mail, Zugriffstoken, Refresh-Token, Cookie, Benutzerdaten, Vereinsinformationen)
- ⚠️ Keine Verschlüsselung für Logs (können personenbezogene Daten enthalten)
---
## 7. Logging ⚠️
### Status: ⚠️ Verbesserungsbedarf
**Vorhanden:**
- ✅ Aktivitäts-Logging (`log` Tabelle) - protokolliert wichtige Aktionen
- ✅ Server-Logs - Standard-Server-Logs für Fehlerbehebung
-**ENTFERNT:** API-Logging für MyTischtennis-Requests wurde deaktiviert
**Probleme:**
-**BEHOBEN:** API-Logging für MyTischtennis-Requests wurde komplett entfernt (keine personenbezogenen Daten mehr in API-Logs)
- ⚠️ Keine automatische Löschung von Aktivitätslogs (noch zu implementieren)
-**BEHOBEN:** In Datenschutzerklärung dokumentiert, was geloggt wird
**Empfehlung:**
- ⚠️ Implementiere automatische Löschung von Aktivitätslogs nach angemessener Frist (noch ausstehend)
---
## 8. MyTischtennis-Integration ⚠️
### Status: ⚠️ Verbesserungsbedarf
**Vorhanden:**
- ✅ Verschlüsselung von Passwörtern
- ✅ Einwilligungen (`savePassword`, `autoUpdateRatings`)
- ✅ DELETE-Endpunkt für Account
**Probleme:**
-**BEHOBEN:** Drittlandübermittlung in Datenschutzerklärung erwähnt
- ⚠️ Keine explizite Einwilligung für Datenübertragung an MyTischtennis.de
-**BEHOBEN:** Informationen über Datenschutz bei MyTischtennis.de in Datenschutzerklärung
-**BEHOBEN:** Alle MyTischtennis-Daten werden jetzt verschlüsselt gespeichert
**Empfehlung:**
- Aktualisiere Datenschutzerklärung:
- Erwähne MyTischtennis-Integration
- Erkläre, welche Daten übertragen werden
- Verweise auf Datenschutzerklärung von MyTischtennis.de
- Erkläre Rechtsgrundlage (Einwilligung)
- Implementiere explizite Einwilligung bei Einrichtung der Integration
- Verschlüssele auch Zugriffstoken
---
## 9. Cookies & Local Storage ✅
### Status: ✅ Konform
**Vorhanden:**
- ✅ Nur technisch notwendige Cookies/Storage:
- Session-Token (Session Storage)
- Username, Clubs, Permissions (Local Storage)
- ✅ Keine Tracking-Cookies
- ✅ Keine Werbe-Cookies
- ✅ Dokumentiert in Datenschutzerklärung
**Hinweis:**
- Local Storage wird für persistente Daten verwendet (Clubs, Permissions)
- Dies ist technisch notwendig und DSGVO-konform
---
## 10. Berechtigungssystem ✅
### Status: ✅ Gut implementiert
**Vorhanden:**
- ✅ Rollenbasierte Zugriffe (Admin, Trainer, Mannschaftsführer, Mitglied)
- ✅ Individuelle Berechtigungen pro Ressource
- ✅ Transparente Zugriffskontrolle
- ✅ Logging von Aktivitäten
**Hinweis:**
- Berechtigungssystem ist DSGVO-konform
- Ermöglicht Datenminimierung (Zugriff nur auf notwendige Daten)
---
## 11. Datenminimierung ⚠️
### Status: ⚠️ Teilweise konform
**Vorhanden:**
- ✅ Nur notwendige Daten werden gespeichert
- ✅ Berechtigungssystem ermöglicht minimale Datenzugriffe
**Verbesserungsbedarf:**
- ⚠️ Logs enthalten möglicherweise zu viele Daten (Request/Response-Bodies)
- ⚠️ Keine automatische Löschung alter Daten
- ⚠️ Keine Option, Daten zu anonymisieren statt zu löschen
---
## 12. Technische und organisatorische Maßnahmen (TOM) ✅ / ⚠️
### Status: ✅ Gut, aber verbesserungsbedürftig
**Vorhanden:**
- ✅ Verschlüsselung sensibler Daten
- ✅ HTTPS für alle Verbindungen
- ✅ Passwort-Hashing (bcrypt)
- ✅ Authentifizierung und Autorisierung
- ✅ Berechtigungssystem
**Verbesserungsbedarf:**
- ⚠️ Keine Dokumentation der TOM
- ⚠️ Keine regelmäßigen Sicherheitsupdates dokumentiert
- ⚠️ Keine Backup-Strategie dokumentiert
- ⚠️ Keine Notfallpläne dokumentiert
---
## 13. Auftragsverarbeitung ⚠️
### Status: ⚠️ Nicht dokumentiert
**Fehlend:**
- ⚠️ Keine Informationen über Hosting-Provider
- ⚠️ Keine Informationen über Auftragsverarbeitungsverträge (AVV)
- ⚠️ Keine Informationen über Subunternehmer
**Empfehlung:**
- Dokumentiere alle Auftragsverarbeiter (Hosting, etc.)
- Erwähne in Datenschutzerklärung, dass AVV abgeschlossen wurden
---
## 14. Betroffenenrechte - Umsetzung ❌
### Status: ❌ Nicht vollständig implementiert
**Fehlend:**
-**KRITISCH:** Kein Endpunkt für Auskunft (Art. 15)
-**KRITISCH:** Kein Endpunkt für Löschung (Art. 17)
-**KRITISCH:** Kein Endpunkt für Datenexport (Art. 20)
- ❌ Kein Endpunkt für Berichtigung (Art. 16) - teilweise vorhanden über normale Edit-Endpunkte
- ❌ Kein Endpunkt für Einschränkung (Art. 18)
- ❌ Kein Endpunkt für Widerspruch (Art. 21)
**Empfehlung:**
- Implementiere zentrale Endpunkte für alle Betroffenenrechte:
- `GET /api/user/rights/information` - Auskunft
- `DELETE /api/user/rights/deletion` - Löschung
- `GET /api/user/rights/export` - Datenexport
- `PUT /api/user/rights/restriction` - Einschränkung
- `POST /api/user/rights/objection` - Widerspruch
---
## 15. Kontakt für Datenschutz ✅
### Status: ✅ Vorhanden
**Vorhanden:**
- ✅ E-Mail-Adresse in Datenschutzerklärung: tsschulz@tsschulz.de
- ✅ Vollständige Anschrift im Impressum
---
## Zusammenfassung
### ✅ Gut implementiert:
1. Verschlüsselung sensibler Daten
2. HTTPS
3. Berechtigungssystem
4. Cookies/Local Storage (nur technisch notwendig)
5. Datenschutzerklärung vorhanden
### ⚠️ Verbesserungsbedarf:
1. Datenschutzerklärung aktualisieren (MyTischtennis, Logging)
2. Logging von personenbezogenen Daten reduzieren/anonymisieren
3. Automatische Löschung von Logs implementieren
4. MyTischtennis-Integration in Datenschutzerklärung erwähnen
### ❌ Kritisch - Muss implementiert werden:
1. **Löschrechte-API** (Art. 17 DSGVO)
2. **Auskunftsrechte-API** (Art. 15 DSGVO)
3. **Datenexport-API** (Art. 20 DSGVO)
4. **Automatische Löschung von Logs** nach Retention-Periode
---
## Prioritäten
### Sofort (vor Live-Betrieb):
1. Datenschutzerklärung aktualisieren
2. Löschrechte-API implementieren
3. Auskunftsrechte-API implementieren
4. Datenexport-API implementieren
### Kurzfristig (innerhalb 1 Monat):
1. Automatische Löschung von Logs implementieren
2. Logging von personenbezogenen Daten reduzieren/anonymisieren
3. MyTischtennis-Integration in Datenschutzerklärung dokumentieren
### Mittelfristig (innerhalb 3 Monate):
1. Einwilligungsmanagement implementieren
2. TOM dokumentieren
3. Auftragsverarbeitung dokumentieren
---
## Nächste Schritte
1. ✅ Diese Checkliste erstellen
2. ⏳ Datenschutzerklärung aktualisieren
3. ⏳ Löschrechte-API implementieren
4. ⏳ Auskunftsrechte-API implementieren
5. ⏳ Datenexport-API implementieren
6. ⏳ Logging verbessern

View File

@@ -1,210 +0,0 @@
# Berechtigungssystem - Dokumentation
## Übersicht
Das Trainingstagebuch verfügt nun über ein vollständiges rollenbasiertes Berechtigungssystem (RBAC - Role-Based Access Control). Der Club-Ersteller hat automatisch Admin-Rechte und kann anderen Mitgliedern Rollen und spezifische Berechtigungen zuweisen.
## Rollen
### 1. Administrator (admin)
- **Vollzugriff** auf alle Funktionen
- Kann Berechtigungen anderer Benutzer verwalten
- Der Club-Ersteller ist automatisch Administrator und kann nicht degradiert werden
### 2. Trainer (trainer)
- Kann Trainingseinheiten planen und verwalten
- Kann Mitglieder anlegen und bearbeiten
- Kann Spielpläne einsehen und bearbeiten
- Kann Turniere organisieren
- **Kann nicht**: Einstellungen ändern, Berechtigungen verwalten
### 3. Mannschaftsführer (team_manager)
- Kann Teams und Spielpläne verwalten
- Kann Spieler für Matches einteilen
- Kann Spielergebnisse eintragen
- **Kann nicht**: Trainingseinheiten planen, Mitglieder verwalten
### 4. Mitglied (member)
- Nur Lesezugriff auf alle Bereiche
- Kann eigene Daten einsehen
- **Kann nicht**: Daten ändern oder löschen
## Berechtigungsbereiche
- **diary**: Trainingstagebuch
- **members**: Mitgliederverwaltung
- **teams**: Team-Management
- **schedule**: Spielpläne
- **tournaments**: Turniere
- **statistics**: Statistiken
- **settings**: Einstellungen
- **permissions**: Berechtigungsverwaltung
- **mytischtennis**: MyTischtennis-Integration (für alle zugänglich)
## Backend-Integration
### Migration ausführen
```sql
mysql -u username -p database_name < backend/migrations/add_permissions_to_user_club.sql
```
### Authorization Middleware verwenden
```javascript
import { authorize, requireAdmin, requireOwner } from '../middleware/authorizationMiddleware.js';
// Beispiel: Nur Lesezugriff erforderlich
router.get('/diary/:clubId', authenticate, authorize('diary', 'read'), getDiary);
// Beispiel: Schreibzugriff erforderlich
router.post('/diary/:clubId', authenticate, authorize('diary', 'write'), createDiary);
// Beispiel: Admin-Rechte erforderlich
router.put('/settings/:clubId', authenticate, requireAdmin(), updateSettings);
// Beispiel: Nur Owner
router.delete('/club/:clubId', authenticate, requireOwner(), deleteClub);
```
### Permission Service verwenden
```javascript
import permissionService from '../services/permissionService.js';
// Berechtigungen prüfen
const hasPermission = await permissionService.hasPermission(userId, clubId, 'diary', 'write');
// Rolle setzen
await permissionService.setUserRole(userId, clubId, 'trainer', adminUserId);
// Custom Permissions setzen
await permissionService.setCustomPermissions(
userId,
clubId,
{ diary: { write: false }, members: { write: true } },
adminUserId
);
```
## Frontend-Integration
### Composable verwenden
```vue
<script setup>
import { usePermissions } from '@/composables/usePermissions.js';
const { can, canWrite, canDelete, isAdmin, isOwner, userRole } = usePermissions();
// Beispiel
if (can('diary', 'write')) {
// Zeige Bearbeitungsbutton
}
</script>
```
### Direktiven verwenden
```vue
<template>
<!-- Nur anzeigen, wenn Schreibrechte für diary vorhanden -->
<button v-can:diary.write>Bearbeiten</button>
<!-- Nur anzeigen, wenn Löschrechte für members vorhanden -->
<button v-can:members.delete>Löschen</button>
<!-- Alternative Syntax -->
<div v-can="'diary.write'">Inhalt nur für Berechtigte</div>
<!-- Nur für Admins -->
<div v-admin>Admin-Bereich</div>
<!-- Nur für Owner -->
<div v-owner>Owner-Bereich</div>
</template>
```
### Store verwenden
```javascript
import { useStore } from 'vuex';
const store = useStore();
// Berechtigungen abrufen
const permissions = store.getters.currentPermissions;
const hasPermission = store.getters.hasPermission('diary', 'write');
const isOwner = store.getters.isClubOwner;
const userRole = store.getters.userRole;
// Berechtigungen laden (wird automatisch beim Club-Wechsel gemacht)
await store.dispatch('loadPermissions', clubId);
```
## Admin-UI
Die Berechtigungsverwaltung ist unter `/permissions` verfügbar und nur für Administratoren sichtbar.
**Funktionen:**
- Übersicht aller Clubmitglieder mit ihren Rollen
- Rollen zuweisen/ändern
- Custom Permissions für einzelne Benutzer definieren
- Erklärung der verfügbaren Rollen
## MyTischtennis-Integration
Die MyTischtennis-Einstellungen und -Funktionen sind für **alle Club-Mitglieder** zugänglich, unabhängig von ihrer Rolle. Dies ermöglicht es jedem, die Anbindung einzurichten und Daten abzurufen.
## Sicherheitshinweise
1. **Der Club-Ersteller** (Owner) kann nicht degradiert oder gelöscht werden
2. **Owner-Rechte** können nicht übertragen werden
3. **Backend-Validierung** wird immer durchgeführt, auch wenn das Frontend Elemente ausblendet
4. **Alle API-Routen** sind durch Middleware geschützt
5. **Permissions werden gecacht** im localStorage für bessere Performance
## Beispiel-Szenarien
### Szenario 1: Trainer hinzufügen
1. Admin öffnet `/permissions`
2. Wählt Benutzer aus
3. Ändert Rolle zu "Trainer"
4. Benutzer kann jetzt Trainingseinheiten planen
### Szenario 2: Custom Permissions
1. Admin öffnet `/permissions`
2. Wählt Benutzer aus
3. Klickt auf "Anpassen"
4. Setzt individuelle Berechtigungen (z.B. nur Diary-Schreibrecht)
5. Speichert
### Szenario 3: Neues Mitglied
1. Mitglied registriert sich und fordert Zugang an
2. Admin genehmigt Anfrage (Standardrolle: "member")
3. Mitglied hat Lesezugriff
4. Bei Bedarf kann Admin die Rolle später ändern
## Troubleshooting
**Problem**: Berechtigungen werden nicht aktualisiert
- **Lösung**: Seite neu laden oder Club neu auswählen
**Problem**: "Keine Berechtigung" trotz korrekter Rolle
- **Lösung**: Prüfen, ob Custom Permissions die Rolle überschreiben
**Problem**: Owner kann keine Änderungen vornehmen
- **Lösung**: Owner sollte automatisch alle Rechte haben. Prüfen Sie die `isOwner`-Flag in der Datenbank
## API-Endpunkte
```
GET /api/permissions/:clubId - Eigene Berechtigungen abrufen
GET /api/permissions/:clubId/members - Alle Mitglieder mit Berechtigungen (Admin)
PUT /api/permissions/:clubId/user/:userId/role - Rolle ändern (Admin)
PUT /api/permissions/:clubId/user/:userId/permissions - Custom Permissions setzen (Admin)
GET /api/permissions/roles/available - Verfügbare Rollen abrufen
GET /api/permissions/structure/all - Berechtigungsstruktur abrufen
```

View File

@@ -1,235 +0,0 @@
# Berechtigungssystem - Migrations-Anleitung
## Übersicht
Diese Anleitung hilft Ihnen, das neue Berechtigungssystem für bestehende Clubs einzurichten.
## Schritt 1: Datenbank-Schema erweitern
Führen Sie zuerst die SQL-Migration aus, um die neuen Spalten hinzuzufügen:
```bash
mysql -u username -p database_name < backend/migrations/add_permissions_to_user_club.sql
```
Dies fügt folgende Spalten zur `user_club` Tabelle hinzu:
- `role` (VARCHAR) - Benutzerrolle (admin, trainer, team_manager, member)
- `permissions` (JSON) - Custom Permissions
- `is_owner` (BOOLEAN) - Markiert den Club-Ersteller
## Schritt 2: Bestehende Daten migrieren
Sie haben zwei Optionen:
### Option A: Node.js Script (Empfohlen)
Das Script identifiziert automatisch den ersten Benutzer jedes Clubs (nach `createdAt`) und setzt ihn als Owner.
```bash
cd /home/torsten/Programs/trainingstagebuch/backend
node scripts/migratePermissions.js
```
**Ausgabe:**
```
Starting permissions migration...
Found 3 club(s)
--- Club: TTC Beispiel (ID: 1) ---
Members found: 5
First member (will be owner): admin@example.com
✓ Updated admin@example.com: role=admin, isOwner=true
✓ Updated user1@example.com: role=member, isOwner=false
✓ Updated user2@example.com: role=member, isOwner=false
...
✅ Migration completed successfully!
Summary:
Club Owners (3):
- TTC Beispiel: admin@example.com
- SV Teststadt: owner@test.de
- TSC Demo: demo@example.com
Role Distribution:
- Admins: 3
- Members: 12
```
### Option B: SQL Script
Wenn Sie lieber SQL verwenden möchten:
```bash
mysql -u username -p database_name < backend/migrations/update_existing_user_club_permissions.sql
```
Dieses Script:
1. Setzt `role = 'member'` für alle genehmigten Benutzer ohne Rolle
2. Markiert den Benutzer mit der niedrigsten `user_id` pro Club als Owner
## Schritt 3: Manuelle Anpassungen (Optional)
### Falscher Owner?
Falls das Script den falschen Benutzer als Owner markiert hat, können Sie dies manuell korrigieren:
```sql
-- Alten Owner zurücksetzen
UPDATE user_club
SET is_owner = 0, role = 'member'
WHERE club_id = 1 AND user_id = 123;
-- Neuen Owner setzen
UPDATE user_club
SET is_owner = 1, role = 'admin'
WHERE club_id = 1 AND user_id = 456;
```
### Weitere Admins ernennen
```sql
UPDATE user_club
SET role = 'admin'
WHERE club_id = 1 AND user_id = 789;
```
### Trainer ernennen
```sql
UPDATE user_club
SET role = 'trainer'
WHERE club_id = 1 AND user_id = 101;
```
## Schritt 4: Verifizierung
### Backend neu starten
```bash
# Server neu starten (wenn er läuft)
sudo systemctl restart tt-tagebuch
```
### Im Browser testen
1. Loggen Sie sich ein
2. Wählen Sie einen Club aus
3. Navigieren Sie zu "Berechtigungen" (nur für Admins sichtbar)
4. Überprüfen Sie, dass alle Mitglieder korrekt angezeigt werden
### SQL Verifizierung
```sql
-- Alle Club-Mitglieder mit ihren Berechtigungen anzeigen
SELECT
c.name as club_name,
u.email as user_email,
uc.role,
uc.is_owner,
uc.approved
FROM user_club uc
JOIN club c ON c.id = uc.club_id
JOIN user u ON u.id = uc.user_id
WHERE uc.approved = 1
ORDER BY c.name, uc.is_owner DESC, uc.role, u.email;
```
## Troubleshooting
### Problem: "Keine Berechtigung" trotz Owner-Status
**Lösung:** Überprüfen Sie in der Datenbank:
```sql
SELECT role, is_owner, approved
FROM user_club
WHERE user_id = YOUR_USER_ID AND club_id = YOUR_CLUB_ID;
```
Sollte sein: `role='admin'`, `is_owner=1`, `approved=1`
### Problem: Owner kann nicht geändert werden
Das ist korrekt! Der Owner (Club-Ersteller) kann seine eigenen Rechte nicht verlieren. Dies ist eine Sicherheitsmaßnahme.
### Problem: Berechtigungen werden nicht geladen
**Lösung:**
1. Browser-Cache leeren
2. LocalStorage leeren: `localStorage.clear()` in der Browser-Console
3. Neu einloggen
### Problem: "Lade Mitglieder..." bleibt hängen
**Mögliche Ursachen:**
1. Migration noch nicht ausgeführt
2. Backend nicht neu gestartet
3. Frontend nicht neu gebaut
**Lösung:**
```bash
# Backend
cd /home/torsten/Programs/trainingstagebuch/backend
node scripts/migratePermissions.js
# Frontend
cd /home/torsten/Programs/trainingstagebuch/frontend
npm run build
# Server neu starten
sudo systemctl restart tt-tagebuch
```
## Nach der Migration
### Neue Clubs
Bei neuen Clubs wird der Ersteller automatisch als Owner mit Admin-Rechten eingerichtet. Keine manuelle Aktion erforderlich.
### Neue Mitglieder
Neue Mitglieder erhalten automatisch die Rolle "member" (Lesezugriff). Admins können die Rolle später ändern.
### Berechtigungen verwalten
Admins können über die Web-UI unter `/permissions` Berechtigungen verwalten:
1. Rollen zuweisen (Admin, Trainer, Mannschaftsführer, Mitglied)
2. Custom Permissions definieren (für spezielle Anwendungsfälle)
## Wichtige Hinweise
⚠️ **Sicherung erstellen:**
```bash
mysqldump -u username -p database_name > backup_before_permissions_$(date +%Y%m%d).sql
```
⚠️ **Owner-Rechte:**
- Der Owner (is_owner=1) kann nicht degradiert oder gelöscht werden
- Jeder Club hat genau einen Owner
- Owner-Rechte können nicht übertragen werden (nur durch direkte DB-Änderung)
⚠️ **MyTischtennis:**
- MyTischtennis-Funktionen sind für ALLE Mitglieder zugänglich
- Keine Berechtigungsprüfung für MyTischtennis-Endpunkte
## Rollback (falls nötig)
Falls Sie das Berechtigungssystem zurücknehmen müssen:
```sql
-- Spalten entfernen (Achtung: Datenverlust!)
ALTER TABLE user_club
DROP COLUMN role,
DROP COLUMN permissions,
DROP COLUMN is_owner;
-- Indizes entfernen
DROP INDEX idx_user_club_role ON user_club;
DROP INDEX idx_user_club_owner ON user_club;
```
Dann Backend-Code auf vorherige Version zurücksetzen.

View File

@@ -1,69 +0,0 @@
# Server Node.js Upgrade-Anleitung
## Problem
Der Server verwendet Node.js 20.17.0, aber Vite 7.2.4 benötigt Node.js 20.19+ oder 22.12+.
## Lösung 1: Node.js auf dem Server upgraden (Empfohlen)
### Option A: Node.js 20.19+ installieren
```bash
# Auf dem Server:
# Mit nvm (falls installiert):
nvm install 20.19.0
nvm use 20.19.0
nvm alias default 20.19.0
# Oder mit NodeSource Repository:
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y nodejs=20.19.0-1nodesource1
# Prüfe Version:
node --version # Sollte 20.19.0 oder höher sein
```
### Option B: Node.js 22.12+ installieren (LTS)
```bash
# Auf dem Server:
# Mit nvm:
nvm install 22.12.0
nvm use 22.12.0
nvm alias default 22.12.0
# Oder mit NodeSource Repository:
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y nodejs
# Prüfe Version:
node --version # Sollte 22.12.0 oder höher sein
```
### Nach dem Upgrade
```bash
cd /var/www/tt-tagebuch.de/backend
npm install # Erstellt automatisch den Frontend-Build
sudo systemctl restart tt-tagebuch
```
## Lösung 2: Vite auf Version 6 downgraden (Temporär)
Falls Node.js nicht upgradet werden kann, wurde Vite bereits auf Version 6.0.0 downgraded.
```bash
cd /var/www/tt-tagebuch.de/backend
npm install # Erstellt automatisch den Frontend-Build
sudo systemctl restart tt-tagebuch
```
**Hinweis:** Vite 6 funktioniert mit Node.js 20.17.0, aber Vite 7 bietet bessere Performance und Features.
## Empfehlung
**Node.js upgraden** ist die bessere Lösung, da:
- Vite 7 bessere Performance bietet
- Zukünftige Updates einfacher sind
- Node.js 20.19+ oder 22.12+ LTS-Versionen sind

View File

@@ -1,109 +0,0 @@
# Sitemap für Google Search Console einreichen
## Aktuelle Sitemap
Die Sitemap ist verfügbar unter: `https://tt-tagebuch.de/sitemap.xml`
Sie enthält folgende öffentliche Seiten:
- `/` (Home) - Priorität: 1.0
- `/register` (Registrierung) - Priorität: 0.8
- `/login` (Anmeldung) - Priorität: 0.7
- `/impressum` (Impressum) - Priorität: 0.3
- `/datenschutz` (Datenschutz) - Priorität: 0.3
## Sitemap aktualisieren
### Automatisch (empfohlen)
```bash
./update-sitemap.sh
```
Das Skript aktualisiert automatisch das `lastmod`-Datum auf das heutige Datum.
### Manuell
Die Sitemap-Datei befindet sich in: `frontend/public/sitemap.xml`
Nach Änderungen:
1. Frontend neu bauen: `cd frontend && npm run build`
2. Backend neu starten (falls nötig)
## Sitemap in Google Search Console einreichen
### Schritt 1: Google Search Console öffnen
1. Gehe zu: https://search.google.com/search-console
2. Wähle die Property für `tt-tagebuch.de` aus
### Schritt 2: Sitemap hinzufügen
1. Klicke im linken Menü auf **"Sitemaps"**
2. Im Feld **"Neue Sitemap hinzufügen"** eingeben:
```
sitemap.xml
```
Oder die vollständige URL:
```
https://tt-tagebuch.de/sitemap.xml
```
3. Klicke auf **"Senden"**
### Schritt 3: Status prüfen
- Google wird die Sitemap innerhalb weniger Minuten verarbeiten
- Der Status wird angezeigt:
- ✅ **Erfolgreich**: Sitemap wurde erfolgreich verarbeitet
- ⚠️ **Warnung**: Sitemap wurde verarbeitet, aber es gibt Warnungen
- ❌ **Fehler**: Sitemap konnte nicht verarbeitet werden
### Schritt 4: Indexierung anfordern
Nach dem Einreichen der Sitemap kannst du auch einzelne URLs zur Indexierung anfordern:
1. Gehe zu **"URL-Prüfung"**
2. Gib die URL ein: `https://tt-tagebuch.de/`
3. Klicke auf **"Indexierung anfordern"**
## Sitemap testen
### Online-Tools
- Google Sitemap Tester: https://www.xml-sitemaps.com/validate-xml-sitemap.html
- Sitemap Validator: https://validator.w3.org/
### Per Kommandozeile
```bash
# Sitemap abrufen
curl https://tt-tagebuch.de/sitemap.xml
# XML-Validierung (falls xmllint installiert ist)
curl -s https://tt-tagebuch.de/sitemap.xml | xmllint --noout -
```
## Wichtige Hinweise
1. **robots.txt**: Die Sitemap ist bereits in der `robots.txt` referenziert:
```
Sitemap: https://tt-tagebuch.de/sitemap.xml
```
2. **lastmod-Datum**: Wird automatisch beim Ausführen von `update-sitemap.sh` aktualisiert
3. **Nur öffentliche Seiten**: Die Sitemap enthält nur öffentlich zugängliche Seiten. Geschützte Seiten (die eine Anmeldung erfordern) sind nicht enthalten.
4. **Prioritäten**:
- Homepage: 1.0 (höchste Priorität)
- Registrierung/Login: 0.7-0.8 (wichtig für neue Nutzer)
- Rechtliche Seiten: 0.3 (niedrige Priorität, ändern sich selten)
## Troubleshooting
### Sitemap wird nicht gefunden
- Prüfe, ob die Sitemap unter `https://tt-tagebuch.de/sitemap.xml` erreichbar ist
- Stelle sicher, dass das Frontend gebaut wurde: `cd frontend && npm run build`
- Prüfe die Apache-Konfiguration (sollte statische Dateien aus `/var/www/tt-tagebuch.de` servieren)
### Sitemap wird nicht indexiert
- Warte einige Stunden/Tage - Google braucht Zeit zum Crawlen
- Prüfe in der Search Console, ob es Fehler gibt
- Stelle sicher, dass die URLs in der Sitemap erreichbar sind
- Prüfe, ob die `robots.txt` die Seiten nicht blockiert
### Sitemap enthält Fehler
- Validiere die XML-Struktur mit einem XML-Validator
- Prüfe, ob alle URLs korrekt sind (keine 404-Fehler)
- Stelle sicher, dass alle URLs HTTPS verwenden (nicht HTTP)

View File

@@ -1,22 +0,0 @@
# Apache-Konfiguration für tt-tagebuch.de - HTTP (Port 80)
#
# Diese Datei kopieren nach: /etc/apache2/sites-available/tt-tagebuch.de.conf
# Dann aktivieren mit: sudo a2ensite tt-tagebuch.de.conf
# Und neu starten: sudo systemctl restart apache2
#
# WICHTIG: Folgende Module müssen aktiviert sein:
# sudo a2enmod rewrite
# sudo systemctl restart apache2
# HTTP: www.tt-tagebuch.de -> HTTPS: tt-tagebuch.de
<VirtualHost *:80>
ServerName www.tt-tagebuch.de
Redirect permanent / https://tt-tagebuch.de/
</VirtualHost>
# HTTP: tt-tagebuch.de -> HTTPS: tt-tagebuch.de
<VirtualHost *:80>
ServerName tt-tagebuch.de
Redirect permanent / https://tt-tagebuch.de/
</VirtualHost>

View File

@@ -1,60 +0,0 @@
# Apache-Konfiguration für tt-tagebuch.de - HTTPS (Port 443)
#
# Diese Datei kopieren nach: /etc/apache2/sites-available/tt-tagebuch.de-le-ssl.conf
# Dann aktivieren mit: sudo a2ensite tt-tagebuch.de-le-ssl.conf
# Und neu starten: sudo systemctl restart apache2
#
# WICHTIG: Folgende Module müssen aktiviert sein:
# sudo a2enmod proxy
# sudo a2enmod proxy_http
# sudo a2enmod proxy_wstunnel
# sudo a2enmod rewrite
# sudo a2enmod headers
# sudo systemctl restart apache2
# HTTPS: www.tt-tagebuch.de -> HTTPS: tt-tagebuch.de (301-Weiterleitung)
<VirtualHost *:443>
ServerName www.tt-tagebuch.de
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/tt-tagebuch.de/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/tt-tagebuch.de/privkey.pem
Include /etc/letsencrypt/options-ssl-apache.conf
Redirect permanent / https://tt-tagebuch.de/
</VirtualHost>
# HTTPS: tt-tagebuch.de - Hauptkonfiguration (non-www)
<VirtualHost *:443>
ServerName tt-tagebuch.de
DocumentRoot /var/www/tt-tagebuch.de
<Directory /var/www/tt-tagebuch.de>
Options Indexes FollowSymLinks
AllowOverride All
Require all granted
</Directory>
ErrorLog ${APACHE_LOG_DIR}/tt-tagebuch.de_error.log
CustomLog ${APACHE_LOG_DIR}/tt-tagebuch.de_access.log combined
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/tt-tagebuch.de/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/tt-tagebuch.de/privkey.pem
Include /etc/letsencrypt/options-ssl-apache.conf
ProxyRequests Off
# HINWEIS: Socket.IO läuft jetzt direkt auf HTTPS-Port 3051 (nicht über Apache-Proxy)
# Siehe backend/SOCKET_IO_SSL_SETUP.md für Details
# API-Routen
ProxyPass /api http://localhost:3050/api
ProxyPassReverse /api http://localhost:3050/api
# Alle anderen Anfragen an den Backend-Server (für Frontend)
ProxyPass / http://localhost:3050/
ProxyPassReverse / http://localhost:3050/
</VirtualHost>

View File

@@ -1,89 +0,0 @@
# Apache-Konfiguration für tt-tagebuch.de
#
# HINWEIS: Diese Datei ist eine kombinierte Referenz.
# Für die tatsächliche Konfiguration werden zwei separate Dateien verwendet:
#
# 1. apache-http.conf.example -> /etc/apache2/sites-available/tt-tagebuch.de.conf
# (HTTP, Port 80 - Weiterleitung zu HTTPS)
#
# 2. apache-https.conf.example -> /etc/apache2/sites-available/tt-tagebuch.de-le-ssl.conf
# (HTTPS, Port 443 - Hauptkonfiguration)
#
# Oder verwende das Update-Skript: ./update-apache-config.sh
#
# WICHTIG: Folgende Module müssen aktiviert sein:
# sudo a2enmod proxy
# sudo a2enmod proxy_http
# sudo a2enmod proxy_wstunnel
# sudo a2enmod rewrite
# sudo a2enmod headers
# sudo systemctl restart apache2
# ============================================
# HTTP (Port 80) - Weiterleitung zu HTTPS
# ============================================
# HTTP: www.tt-tagebuch.de -> HTTPS: tt-tagebuch.de
<VirtualHost *:80>
ServerName www.tt-tagebuch.de
Redirect permanent / https://tt-tagebuch.de/
</VirtualHost>
# HTTP: tt-tagebuch.de -> HTTPS: tt-tagebuch.de
<VirtualHost *:80>
ServerName tt-tagebuch.de
Redirect permanent / https://tt-tagebuch.de/
</VirtualHost>
# ============================================
# HTTPS (Port 443) - Weiterleitung www -> non-www
# ============================================
# HTTPS: www.tt-tagebuch.de -> HTTPS: tt-tagebuch.de (301-Weiterleitung)
<VirtualHost *:443>
ServerName www.tt-tagebuch.de
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/tt-tagebuch.de/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/tt-tagebuch.de/privkey.pem
Include /etc/letsencrypt/options-ssl-apache.conf
Redirect permanent / https://tt-tagebuch.de/
</VirtualHost>
# ============================================
# HTTPS (Port 443) - Hauptkonfiguration (non-www)
# ============================================
<VirtualHost *:443>
ServerName tt-tagebuch.de
DocumentRoot /var/www/tt-tagebuch.de
<Directory /var/www/tt-tagebuch.de>
Options Indexes FollowSymLinks
AllowOverride All
Require all granted
</Directory>
ErrorLog ${APACHE_LOG_DIR}/tt-tagebuch.de_error.log
CustomLog ${APACHE_LOG_DIR}/tt-tagebuch.de_access.log combined
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/tt-tagebuch.de/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/tt-tagebuch.de/privkey.pem
Include /etc/letsencrypt/options-ssl-apache.conf
ProxyRequests Off
# HINWEIS: Socket.IO läuft jetzt direkt auf HTTPS-Port 3051 (nicht über Apache-Proxy)
# Siehe backend/SOCKET_IO_SSL_SETUP.md für Details
# API-Routen
ProxyPass /api http://localhost:3050/api
ProxyPassReverse /api http://localhost:3050/api
# Alle anderen Anfragen an den Backend-Server (für Frontend)
ProxyPass / http://localhost:3050/
ProxyPassReverse / http://localhost:3050/
</VirtualHost>

View File

@@ -1,212 +0,0 @@
# MyTischtennis Automatischer Datenabruf
## Übersicht
Dieses System ermöglicht den automatischen Abruf von Spielergebnissen und Statistiken von myTischtennis.de.
## Scheduler
### 6:00 Uhr - Rating Updates
- **Service:** `autoUpdateRatingsService.js`
- **Funktion:** Aktualisiert TTR/QTTR-Werte für Spieler
- **TODO:** Implementierung der eigentlichen Rating-Update-Logik
### 6:30 Uhr - Spielergebnisse
- **Service:** `autoFetchMatchResultsService.js`
- **Funktion:** Ruft Spielerbilanzen für konfigurierte Teams ab
- **Status:** ✅ Grundlegende Implementierung fertig
## Benötigte Konfiguration
### 1. MyTischtennis-Account
- Account muss in den MyTischtennis-Settings verknüpft sein
- Checkbox "Automatische Updates" aktivieren
- Passwort speichern (erforderlich für automatische Re-Authentifizierung)
### 2. League-Konfiguration
Für jede Liga müssen folgende Felder ausgefüllt werden:
```sql
UPDATE league SET
my_tischtennis_group_id = '504417', -- Group ID von myTischtennis
association = 'HeTTV', -- Verband (z.B. HeTTV, DTTB)
groupname = '1.Kreisklasse' -- Gruppenname für URL
WHERE id = 1;
```
**Beispiel-URL:**
```
https://www.mytischtennis.de/click-tt/HeTTV/25--26/ligen/1.Kreisklasse/gruppe/504417/...
^^^^^ ^^^^^^^^^^^^^^ ^^^^^^
association groupname group_id
```
### 3. Team-Konfiguration
Für jedes Team muss die myTischtennis Team-ID gesetzt werden:
```sql
UPDATE club_team SET
my_tischtennis_team_id = '2995094' -- Team ID von myTischtennis
WHERE id = 1;
```
**Beispiel-URL:**
```
.../mannschaft/2995094/Harheimer_TC_(J11)/spielerbilanzen/gesamt
^^^^^^^
team_id
```
### 4. Spieler-Zuordnung (Optional)
Spieler werden automatisch anhand des Namens zugeordnet. Für genauere Zuordnung kann die myTischtennis Player-ID gesetzt werden:
```sql
UPDATE member SET
my_tischtennis_player_id = 'NU2705037' -- Player ID von myTischtennis
WHERE id = 1;
```
## Migrationen
Folgende Migrationen müssen ausgeführt werden:
```bash
# 1. MyTischtennis Auto-Update-Felder
mysql -u root -p trainingstagebuch < backend/migrations/add_auto_update_ratings_to_my_tischtennis.sql
# 2. MyTischtennis Update-History-Tabelle
mysql -u root -p trainingstagebuch < backend/migrations/create_my_tischtennis_update_history.sql
# 3. League MyTischtennis-Felder
mysql -u root -p trainingstagebuch < backend/migrations/add_mytischtennis_fields_to_league.sql
# 4. Team MyTischtennis-ID
mysql -u root -p trainingstagebuch < backend/migrations/add_mytischtennis_team_id_to_club_team.sql
# 5. Member MyTischtennis Player-ID
mysql -u root -p trainingstagebuch < backend/migrations/add_mytischtennis_player_id_to_member.sql
# 6. Match Result-Felder
mysql -u root -p trainingstagebuch < backend/migrations/add_match_result_fields.sql
```
## Abgerufene Daten
Von der myTischtennis API werden folgende Daten abgerufen:
### Einzelstatistiken
- Player ID, Vorname, Nachname
- Gewonnene/Verlorene Punkte
- Anzahl Spiele
- Detaillierte Statistiken nach Gegner-Position
### Doppelstatistiken
- Player IDs, Namen der beiden Spieler
- Gewonnene/Verlorene Punkte
- Anzahl Spiele
### Team-Informationen
- Teamname, Liga, Saison
- Gesamtpunkte (gewonnen/verloren)
- Doppel- und Einzelpunkte
## Implementierungsdetails
### Datenfluss
1. **Scheduler** (6:30 Uhr):
- `schedulerService.js` triggert `autoFetchMatchResultsService.executeAutomaticFetch()`
2. **Account-Verarbeitung**:
- Lädt alle MyTischtennis-Accounts mit `autoUpdateRatings = true`
- Prüft Session-Gültigkeit
- Re-Authentifizierung bei abgelaufener Session
3. **Team-Abfrage**:
- Lädt alle Teams mit konfigurierten myTischtennis-IDs
- Baut API-URL dynamisch zusammen
- Führt authentifizierten GET-Request durch
4. **Datenverarbeitung**:
- Parst JSON-Response
- Matched Spieler anhand von ID oder Name
- Speichert myTischtennis Player-ID bei Mitgliedern
- Loggt Statistiken
### Player-Matching-Algorithmus
```javascript
1. Suche nach myTischtennis Player-ID (exakte Übereinstimmung)
2. Falls nicht gefunden: Suche nach Name (case-insensitive)
3. Falls gefunden: Speichere myTischtennis Player-ID für zukünftige Abfragen
```
**Hinweis:** Da Namen verschlüsselt gespeichert werden, müssen für den Namens-Abgleich alle Members geladen und entschlüsselt werden. Dies ist bei großen Datenbanken ineffizient.
## TODO / Offene Punkte
### Noch zu implementieren:
1. **TTR/QTTR Updates** (6:00 Uhr Job):
- Endpoint für TTR/QTTR-Daten identifizieren
- Daten abrufen und in Member-Tabelle speichern
2. **Spielergebnis-Details**:
- Einzelne Matches mit Satzständen speichern
- Tabelle für Match-Historie erstellen
3. **History-Tabelle für Spielergebnis-Abrufe** (optional):
- Ähnlich zu `my_tischtennis_update_history`
- Speichert Erfolg/Fehler der Abrufe
4. **Benachrichtigungen** (optional):
- Email/Push bei neuen Ergebnissen
- Highlights für besondere Siege
5. **Performance-Optimierung**:
- Caching für Player-Matches
- Incremental Updates (nur neue Daten)
## Manueller Test
```javascript
// Im Node-Backend-Code oder über API-Endpoint:
import schedulerService from './services/schedulerService.js';
// Rating Updates manuell triggern
await schedulerService.triggerRatingUpdates();
// Spielergebnisse manuell abrufen
await schedulerService.triggerMatchResultsFetch();
```
## API-Dokumentation
### MyTischtennis Spielerbilanzen-Endpoint
**URL-Format:**
```
https://www.mytischtennis.de/click-tt/{association}/{season}/ligen/{groupname}/gruppe/{groupId}/mannschaft/{teamId}/{teamname}/spielerbilanzen/gesamt?_data=routes%2Fclick-tt%2B%2F%24association%2B%2F%24season%2B%2F%24type%2B%2F%28%24groupname%29.gruppe.%24urlid_.mannschaft.%24teamid.%24teamname%2B%2Fspielerbilanzen.%24filter
```
**Parameter:**
- `{association}`: Verband (z.B. "HeTTV")
- `{season}`: Saison im Format "25--26"
- `{groupname}`: Gruppenname URL-encoded (z.B. "1.Kreisklasse")
- `{groupId}`: Gruppen-ID (numerisch, z.B. "504417")
- `{teamId}`: Team-ID (numerisch, z.B. "2995094")
- `{teamname}`: Teamname URL-encoded mit Underscores (z.B. "Harheimer_TC_(J11)")
**Response:** JSON mit `data.balancesheet` Array
## Sicherheit
- ✅ Automatische Session-Verwaltung
- ✅ Re-Authentifizierung bei abgelaufenen Sessions
- ✅ Passwörter verschlüsselt gespeichert
- ✅ Fehlerbehandlung und Logging
- ✅ Graceful Degradation (einzelne Team-Fehler stoppen nicht den gesamten Prozess)

View File

@@ -1,332 +0,0 @@
# MyTischtennis URL Parser
## Übersicht
Der URL-Parser ermöglicht es, myTischtennis-Team-URLs automatisch zu parsen und die Konfiguration für automatische Datenabrufe vorzunehmen.
## Verwendung
### 1. URL Parsen
**Endpoint:** `POST /api/mytischtennis/parse-url`
**Request:**
```json
{
"url": "https://www.mytischtennis.de/click-tt/HeTTV/25--26/ligen/1.Kreisklasse/gruppe/504417/mannschaft/2995094/Harheimer_TC_(J11)/spielerbilanzen/gesamt"
}
```
**Response:**
```json
{
"success": true,
"data": {
"association": "HeTTV",
"season": "25/26",
"type": "ligen",
"groupname": "1.Kreisklasse",
"groupId": "504417",
"teamId": "2995094",
"teamname": "Harheimer TC (J11)",
"originalUrl": "https://www.mytischtennis.de/click-tt/...",
"clubId": "43030",
"clubName": "Harheimer TC",
"teamName": "Jugend 11",
"leagueName": "Jugend 13 1. Kreisklasse",
"region": "Frankfurt",
"tableRank": 8,
"matchesWon": 0,
"matchesLost": 3
}
}
```
### 2. Team Automatisch Konfigurieren
**Endpoint:** `POST /api/mytischtennis/configure-team`
**Request:**
```json
{
"url": "https://www.mytischtennis.de/click-tt/HeTTV/25--26/ligen/1.Kreisklasse/gruppe/504417/mannschaft/2995094/Harheimer_TC_(J11)/spielerbilanzen/gesamt",
"clubTeamId": 1,
"createLeague": false,
"createSeason": false
}
```
**Parameter:**
- `url` (required): Die myTischtennis-URL
- `clubTeamId` (required): Die ID des lokalen Club-Teams
- `createLeague` (optional): Wenn `true`, wird eine neue League erstellt
- `createSeason` (optional): Wenn `true`, wird eine neue Season erstellt
**Response:**
```json
{
"success": true,
"message": "Team configured successfully",
"data": {
"team": {
"id": 1,
"name": "Jugend 11",
"myTischtennisTeamId": "2995094"
},
"league": {
"id": 5,
"name": "Jugend 13 1. Kreisklasse",
"myTischtennisGroupId": "504417",
"association": "HeTTV",
"groupname": "1.Kreisklasse"
},
"season": {
"id": 2,
"name": "25/26"
},
"parsedData": { ... }
}
}
```
### 3. URL für Team Abrufen
**Endpoint:** `GET /api/mytischtennis/team-url/:teamId`
**Response:**
```json
{
"success": true,
"url": "https://www.mytischtennis.de/click-tt/HeTTV/25--26/ligen/1.Kreisklasse/gruppe/504417/mannschaft/2995094/Harheimer%20TC%20%28J11%29/spielerbilanzen/gesamt"
}
```
## URL-Format
### Unterstützte URL-Muster
```
https://www.mytischtennis.de/click-tt/{association}/{season}/{type}/{groupname}/gruppe/{groupId}/mannschaft/{teamId}/{teamname}/...
```
**Komponenten:**
- `{association}`: Verband (z.B. "HeTTV", "DTTB", "WestD")
- `{season}`: Saison im Format "YY--YY" (z.B. "25--26" für 2025/2026)
- `{type}`: Typ (meist "ligen")
- `{groupname}`: Gruppenname URL-encoded (z.B. "1.Kreisklasse", "Kreisliga")
- `{groupId}`: Numerische Gruppen-ID (z.B. "504417")
- `{teamId}`: Numerische Team-ID (z.B. "2995094")
- `{teamname}`: Teamname URL-encoded mit Underscores (z.B. "Harheimer_TC_(J11)")
### Beispiel-URLs
**Spielerbilanzen:**
```
https://www.mytischtennis.de/click-tt/HeTTV/25--26/ligen/1.Kreisklasse/gruppe/504417/mannschaft/2995094/Harheimer_TC_(J11)/spielerbilanzen/gesamt
```
**Spielplan:**
```
https://www.mytischtennis.de/click-tt/HeTTV/25--26/ligen/1.Kreisklasse/gruppe/504417/mannschaft/2995094/Harheimer_TC_(J11)/spielplan
```
**Tabelle:**
```
https://www.mytischtennis.de/click-tt/HeTTV/25--26/ligen/1.Kreisklasse/gruppe/504417/mannschaft/2995094/Harheimer_TC_(J11)/tabelle
```
## Datenfluss
### Ohne MyTischtennis-Login
1. URL wird geparst
2. Nur URL-Komponenten werden extrahiert
3. Zusätzliche Daten (clubName, leagueName, etc.) sind nicht verfügbar
### Mit MyTischtennis-Login
1. URL wird geparst
2. API-Request an myTischtennis mit Authentication
3. Vollständige Team-Daten werden abgerufen
4. Alle Felder sind verfügbar
## Frontend-Integration
### Vue.js Beispiel
```javascript
<template>
<div>
<input
v-model="myTischtennisUrl"
placeholder="MyTischtennis URL einfügen..."
@blur="parseUrl"
/>
<div v-if="parsedData">
<h3>{{ parsedData.teamname }}</h3>
<p>Liga: {{ parsedData.leagueName }}</p>
<p>Verband: {{ parsedData.association }}</p>
<p>Tabelle: Platz {{ parsedData.tableRank }}</p>
<button @click="configureTeam">Team konfigurieren</button>
</div>
</div>
</template>
<script>
export default {
data() {
return {
myTischtennisUrl: '',
parsedData: null
};
},
methods: {
async parseUrl() {
if (!this.myTischtennisUrl) return;
try {
const response = await fetch('/api/mytischtennis/parse-url', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'userid': this.userId,
'authcode': this.authCode
},
body: JSON.stringify({
url: this.myTischtennisUrl
})
});
const result = await response.json();
this.parsedData = result.data;
} catch (error) {
console.error('Fehler beim Parsen:', error);
// Hinweis: Im Frontend stattdessen InfoDialog/ConfirmDialog verwenden
// alert('URL konnte nicht geparst werden');
}
},
async configureTeam() {
try {
const response = await fetch('/api/mytischtennis/configure-team', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'userid': this.userId,
'authcode': this.authCode
},
body: JSON.stringify({
url: this.myTischtennisUrl,
clubTeamId: this.selectedTeamId,
createLeague: false,
createSeason: true
})
});
const result = await response.json();
if (result.success) {
// In der Anwendung bitte InfoDialog nutzen
// alert('Team erfolgreich konfiguriert!');
} else {
// alert('Team konnte nicht konfiguriert werden');
}
} catch (error) {
console.error('Fehler bei Konfiguration:', error);
// alert('Team konnte nicht konfiguriert werden');
}
}
}
};
</script>
```
## Workflow
### Empfohlener Workflow für Benutzer
1. **MyTischtennis-URL kopieren:**
- Auf myTischtennis.de zum Team navigieren
- URL aus Adresszeile kopieren
2. **URL in Trainingstagebuch einfügen:**
- Zu Team-Verwaltung navigieren
- URL einfügen
- Automatisches Parsen
3. **Konfiguration überprüfen:**
- Geparste Daten werden angezeigt
- Benutzer kann Daten überprüfen und bei Bedarf anpassen
4. **Team konfigurieren:**
- Auf "Konfigurieren" klicken
- System speichert alle benötigten IDs
- Automatischer Datenabruf ist ab sofort aktiv
## Fehlerbehandlung
### Häufige Fehler
**"Invalid myTischtennis URL format"**
- URL entspricht nicht dem erwarteten Format
- Lösung: Vollständige URL von der Spielerbilanzen-Seite kopieren
**"Season not found"**
- Saison existiert noch nicht in der Datenbank
- Lösung: `createSeason: true` setzen
**"Team has no league assigned"**
- Team hat keine verknüpfte Liga
- Lösung: `createLeague: true` setzen oder Liga manuell zuweisen
**"HTTP 401: Unauthorized"**
- MyTischtennis-Login abgelaufen oder nicht vorhanden
- Lösung: In MyTischtennis-Settings erneut anmelden
## Sicherheit
- ✅ Alle Endpoints erfordern Authentifizierung
- ✅ UserID wird aus Header-Parameter gelesen
- ✅ MyTischtennis-Credentials werden sicher gespeichert
- ✅ Keine sensiblen Daten in URLs
## Technische Details
### Service: `myTischtennisUrlParserService`
**Methoden:**
- `parseUrl(url)` - Parst URL und extrahiert Komponenten
- `fetchTeamData(parsedUrl, cookie, accessToken)` - Ruft zusätzliche Daten ab
- `getCompleteConfig(url, cookie, accessToken)` - Kombination aus Parsen + Abrufen
- `isValidTeamUrl(url)` - Validiert URL-Format
- `buildUrl(config)` - Baut URL aus Komponenten
### Controller: `myTischtennisUrlController`
**Endpoints:**
- `POST /api/mytischtennis/parse-url` - URL parsen
- `POST /api/mytischtennis/configure-team` - Team konfigurieren
- `GET /api/mytischtennis/team-url/:teamId` - URL abrufen
## Zukünftige Erweiterungen
### Geplante Features
1. **Bulk-Import:**
- Mehrere URLs gleichzeitig importieren
- Alle Teams einer Liga auf einmal konfigurieren
2. **Auto-Discovery:**
- Automatisches Finden aller Teams eines Vereins
- Vorschläge für ähnliche Teams
3. **Validierung:**
- Prüfung, ob Team bereits konfiguriert ist
- Warnung bei Duplikaten
4. **History:**
- Speichern der URL-Konfigurationen
- Versionierung bei Änderungen

View File

@@ -1,140 +0,0 @@
# Socket.IO mit SSL direkt betreiben (Alternative zu Apache-Proxy)
Falls die Apache-WebSocket-Proxy-Konfiguration nicht funktioniert, kann Socket.IO direkt mit SSL betrieben werden.
## Voraussetzungen
1. SSL-Zertifikat (z.B. von Let's Encrypt)
2. Port in der Firewall öffnen (z.B. 3051)
3. Socket.IO-Server auf HTTPS konfigurieren
## Backend-Konfiguration
### 1. Socket.IO auf HTTPS umstellen
Ändere `backend/server.js`:
```javascript
import https from 'https';
import fs from 'fs';
// SSL-Zertifikat laden
const httpsOptions = {
key: fs.readFileSync('/etc/letsencrypt/live/tt-tagebuch.de/privkey.pem'),
cert: fs.readFileSync('/etc/letsencrypt/live/tt-tagebuch.de/fullchain.pem')
};
// HTTPS-Server erstellen
const httpsServer = https.createServer(httpsOptions, app);
// Socket.IO initialisieren
initializeSocketIO(httpsServer);
// HTTPS-Server starten
const httpsPort = process.env.HTTPS_PORT || 3051;
httpsServer.listen(httpsPort, () => {
console.log(`🚀 HTTPS-Server läuft auf Port ${httpsPort}`);
});
// HTTP-Server für API (optional, falls API weiterhin über HTTP laufen soll)
const httpServer = createServer(app);
const httpPort = process.env.PORT || 3005;
httpServer.listen(httpPort, () => {
console.log(`🚀 HTTP-Server läuft auf Port ${httpPort}`);
});
```
### 2. Frontend-Konfiguration
Ändere `frontend/src/services/socketService.js`:
```javascript
import { io } from 'socket.io-client';
import { backendBaseUrl } from '../apiClient.js';
let socket = null;
export const connectSocket = (clubId) => {
// Verwende HTTPS-URL für Socket.IO
const socketUrl = backendBaseUrl.replace('http://', 'https://').replace(':3005', ':3051');
if (socket && socket.connected) {
// Wenn bereits verbunden, verlasse den alten Club-Raum und trete dem neuen bei
if (socket.currentClubId) {
socket.emit('leave-club', socket.currentClubId);
}
} else {
// Neue Verbindung erstellen
socket = io(socketUrl, {
path: '/socket.io/',
transports: ['websocket', 'polling'],
reconnection: true,
reconnectionDelay: 1000,
reconnectionAttempts: 5,
timeout: 20000,
upgrade: true,
forceNew: false,
secure: true // Wichtig für HTTPS
});
socket.on('connect', () => {
console.log('Socket.IO verbunden');
if (socket.currentClubId) {
socket.emit('join-club', socket.currentClubId);
}
});
socket.on('disconnect', () => {
console.log('Socket.IO getrennt');
});
socket.on('connect_error', (error) => {
console.error('Socket.IO Verbindungsfehler:', error);
});
}
// Club-Raum beitreten
if (clubId) {
socket.emit('join-club', clubId);
socket.currentClubId = clubId;
}
return socket;
};
export const disconnectSocket = () => {
if (socket) {
socket.disconnect();
socket = null;
}
};
export const getSocket = () => socket;
```
### 3. Firewall-Port öffnen
```bash
# UFW (Ubuntu Firewall)
sudo ufw allow 3051/tcp
# Oder iptables
sudo iptables -A INPUT -p tcp --dport 3051 -j ACCEPT
```
### 4. Apache-Konfiguration anpassen
Entferne die Socket.IO-Proxy-Konfiguration aus Apache, da Socket.IO jetzt direkt erreichbar ist.
## Vorteile
- Einfacher zu konfigurieren
- Keine Apache-Proxy-Probleme
- Direkte WebSocket-Verbindung
## Nachteile
- Separater Port muss geöffnet sein
- Zwei Ports (HTTP für API, HTTPS für Socket.IO)
- CORS-Konfiguration muss angepasst werden

View File

@@ -9,18 +9,15 @@ const dbConfig = {
database: process.env.DB_NAME || 'trainingsdiary'
};
const report = [];
async function cleanupKeys() {
let connection;
try {
report.push('🔌 Verbinde mit der Datenbank...');
console.log('🔌 Verbinde mit der Datenbank...');
connection = await mysql.createConnection(dbConfig);
// 1. Status vor dem Cleanup
report.push('');
report.push('📊 STATUS VOR DEM CLEANUP:');
console.log('\n📊 STATUS VOR DEM CLEANUP:');
const [tablesBefore] = await connection.execute(`
SELECT
TABLE_NAME,
@@ -32,60 +29,57 @@ async function cleanupKeys() {
`, [dbConfig.database]);
tablesBefore.forEach(table => {
report.push(` ${table.TABLE_NAME}: ${table.key_count} Keys`);
console.log(` ${table.TABLE_NAME}: ${table.key_count} Keys`);
});
// 2. Alle INDEX der Problem-Tabellen anzeigen
const problemTables = ['member', 'diary_tags', 'season'];
for (const tableName of problemTables) {
report.push('');
report.push(`🔍 INDEX für Tabelle '${tableName}':`);
console.log(`\n🔍 INDEX für Tabelle '${tableName}':`);
try {
const [indexes] = await connection.execute(`SHOW INDEX FROM \`${tableName}\``);
if (indexes.length === 0) {
report.push(` Keine INDEX gefunden für Tabelle '${tableName}'`);
console.log(` Keine INDEX gefunden für Tabelle '${tableName}'`);
continue;
}
indexes.forEach(index => {
report.push(` - ${index.Key_name} (${index.Column_name}) - ${index.Non_unique === 0 ? 'UNIQUE' : 'NON-UNIQUE'}`);
console.log(` - ${index.Key_name} (${index.Column_name}) - ${index.Non_unique === 0 ? 'UNIQUE' : 'NON-UNIQUE'}`);
});
// 3. Überflüssige INDEX entfernen (alle außer PRIMARY und UNIQUE)
report.push('');
report.push(`🗑️ Entferne überflüssige INDEX aus '${tableName}':`);
console.log(`\n🗑️ Entferne überflüssige INDEX aus '${tableName}':`);
for (const index of indexes) {
// Behalte PRIMARY KEY und UNIQUE constraints
if (index.Key_name === 'PRIMARY' || index.Non_unique === 0) {
report.push(` ✅ Behalte: ${index.Key_name} (${index.Column_name})`);
console.log(` ✅ Behalte: ${index.Key_name} (${index.Column_name})`);
continue;
}
// Entferne alle anderen INDEX
try {
await connection.execute(`DROP INDEX \`${index.Key_name}\` ON \`${tableName}\``);
report.push(` ❌ Entfernt: ${index.Key_name} (${index.Column_name})`);
console.log(` ❌ Entfernt: ${index.Key_name} (${index.Column_name})`);
} catch (error) {
if (error.code === 'ER_CANT_DROP_FIELD_OR_KEY') {
report.push(` ⚠️ Kann nicht entfernen: ${index.Key_name} (${index.Column_name}) - ${error.message}`);
console.log(` ⚠️ Kann nicht entfernen: ${index.Key_name} (${index.Column_name}) - ${error.message}`);
} else {
report.push(` ❌ Fehler beim Entfernen von ${index.Key_name}: ${error.message}`);
console.log(` ❌ Fehler beim Entfernen von ${index.Key_name}: ${error.message}`);
}
}
}
} catch (error) {
report.push(` ⚠️ Fehler beim Zugriff auf Tabelle '${tableName}': ${error.message}`);
console.log(` ⚠️ Fehler beim Zugriff auf Tabelle '${tableName}': ${error.message}`);
}
}
// 4. Status nach dem Cleanup
report.push('');
report.push('📊 STATUS NACH DEM CLEANUP:');
console.log('\n📊 STATUS NACH DEM CLEANUP:');
const [tablesAfter] = await connection.execute(`
SELECT
TABLE_NAME,
@@ -102,7 +96,7 @@ async function cleanupKeys() {
const diff = beforeCount - table.key_count;
const status = table.key_count <= 5 ? '✅' : table.key_count <= 10 ? '⚠️' : '❌';
report.push(` ${status} ${table.TABLE_NAME}: ${table.key_count} Keys (${diff > 0 ? `-${diff}` : `+${Math.abs(diff)}`})`);
console.log(` ${status} ${table.TABLE_NAME}: ${table.key_count} Keys (${diff > 0 ? `-${diff}` : `+${Math.abs(diff)}`})`);
});
// 5. Gesamtanzahl der Keys
@@ -112,20 +106,18 @@ async function cleanupKeys() {
WHERE TABLE_SCHEMA = ?
`, [dbConfig.database]);
report.push('');
report.push(`📈 GESAMTANZAHL KEYS: ${totalKeys[0].total_keys}`);
console.log(`\n📈 GESAMTANZAHL KEYS: ${totalKeys[0].total_keys}`);
// 6. Zusammenfassung
report.push('');
report.push('🎯 ZUSAMMENFASSUNG:');
console.log('\n🎯 ZUSAMMENFASSUNG:');
const problemTablesAfter = tablesAfter.filter(t => t.key_count > 10);
if (problemTablesAfter.length === 0) {
report.push(' ✅ Alle Tabellen haben jetzt weniger als 10 Keys!');
console.log(' ✅ Alle Tabellen haben jetzt weniger als 10 Keys!');
} else {
report.push(' ⚠️ Folgende Tabellen haben immer noch zu viele Keys:');
console.log(' ⚠️ Folgende Tabellen haben immer noch zu viele Keys:');
problemTablesAfter.forEach(table => {
report.push(` - ${table.TABLE_NAME}: ${table.key_count} Keys`);
console.log(` - ${table.TABLE_NAME}: ${table.key_count} Keys`);
});
}
@@ -134,18 +126,15 @@ async function cleanupKeys() {
} finally {
if (connection) {
await connection.end();
report.push('');
report.push('🔌 Datenbankverbindung geschlossen.');
console.log('\n🔌 Datenbankverbindung geschlossen.');
}
}
}
// Script ausführen
report.push('🚀 Starte intelligentes INDEX-Cleanup...');
console.log('🚀 Starte intelligentes INDEX-Cleanup...\n');
cleanupKeys().then(() => {
report.push('');
report.push('✨ Cleanup abgeschlossen!');
process.stdout.write(`${report.join('\n')}\n`);
console.log('\n✨ Cleanup abgeschlossen!');
process.exit(0);
}).catch(error => {
console.error('\n💥 Fehler beim Cleanup:', error);

View File

@@ -7,23 +7,41 @@ import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const report = [];
// Umgebungsvariablen aus dem Root-Verzeichnis laden
const envPath = path.join(__dirname, '..', '.env');
//const envPath = path.join(__dirname, '..', '.env');
//console.log('🔍 Lade .env-Datei von:', envPath);
dotenv.config();
report.push('Environment variables loaded');
// Debug: Zeige geladene Umgebungsvariablen
console.log('🔍 Geladene Umgebungsvariablen:');
console.log(' DB_HOST:', process.env.DB_HOST);
console.log(' DB_USER:', process.env.DB_USER);
console.log(' DB_NAME:', process.env.DB_NAME);
console.log(' DB_PASSWORD:', process.env.DB_PASSWORD ? '***gesetzt***' : 'nicht gesetzt');
// Datenbankverbindung
const dbConfig = {
host: process.env.DB_HOST || 'localhost',
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '',
database: process.env.DB_NAME || 'trainingsdiary'
};
console.log('🔍 Datenbankverbindung:');
console.log(' Host:', dbConfig.host);
console.log(' User:', dbConfig.user);
console.log(' Database:', dbConfig.database);
console.log(' Password:', dbConfig.password ? '***gesetzt***' : 'nicht gesetzt');
async function cleanupKeys() {
let connection;
try {
report.push('🔌 Verbinde mit der Datenbank...');
console.log('🔌 Verbinde mit der Datenbank...');
connection = await mysql.createConnection(dbConfig);
// 1. Status vor dem Cleanup
report.push('');
report.push('📊 STATUS VOR DEM CLEANUP:');
console.log('\n📊 STATUS VOR DEM CLEANUP:');
const [tablesBefore] = await connection.execute(`
SELECT
TABLE_NAME,
@@ -35,60 +53,57 @@ async function cleanupKeys() {
`, [dbConfig.database]);
tablesBefore.forEach(table => {
report.push(` ${table.TABLE_NAME}: ${table.key_count} Keys`);
console.log(` ${table.TABLE_NAME}: ${table.key_count} Keys`);
});
// 2. Alle INDEX der Problem-Tabellen anzeigen
const problemTables = ['member', 'diary_tags', 'season'];
for (const tableName of problemTables) {
report.push('');
report.push(`🔍 INDEX für Tabelle '${tableName}':`);
console.log(`\n🔍 INDEX für Tabelle '${tableName}':`);
try {
const [indexes] = await connection.execute(`SHOW INDEX FROM \`${tableName}\``);
if (indexes.length === 0) {
report.push(` Keine INDEX gefunden für Tabelle '${tableName}'`);
console.log(` Keine INDEX gefunden für Tabelle '${tableName}'`);
continue;
}
indexes.forEach(index => {
report.push(` - ${index.Key_name} (${index.Column_name}) - ${index.Non_unique === 0 ? 'UNIQUE' : 'NON-UNIQUE'}`);
console.log(` - ${index.Key_name} (${index.Column_name}) - ${index.Non_unique === 0 ? 'UNIQUE' : 'NON-UNIQUE'}`);
});
// 3. Überflüssige INDEX entfernen (alle außer PRIMARY und UNIQUE)
report.push('');
report.push(`🗑️ Entferne überflüssige INDEX aus '${tableName}':`);
console.log(`\n🗑️ Entferne überflüssige INDEX aus '${tableName}':`);
for (const index of indexes) {
// Behalte PRIMARY KEY und UNIQUE constraints
if (index.Key_name === 'PRIMARY' || index.Non_unique === 0) {
report.push(` ✅ Behalte: ${index.Key_name} (${index.Column_name})`);
console.log(` ✅ Behalte: ${index.Key_name} (${index.Column_name})`);
continue;
}
// Entferne alle anderen INDEX
try {
await connection.execute(`DROP INDEX \`${index.Key_name}\` ON \`${tableName}\``);
report.push(` ❌ Entfernt: ${index.Key_name} (${index.Column_name})`);
console.log(` ❌ Entfernt: ${index.Key_name} (${index.Column_name})`);
} catch (error) {
if (error.code === 'ER_CANT_DROP_FIELD_OR_KEY') {
report.push(` ⚠️ Kann nicht entfernen: ${index.Key_name} (${index.Column_name}) - ${error.message}`);
console.log(` ⚠️ Kann nicht entfernen: ${index.Key_name} (${index.Column_name}) - ${error.message}`);
} else {
report.push(` ❌ Fehler beim Entfernen von ${index.Key_name}: ${error.message}`);
console.log(` ❌ Fehler beim Entfernen von ${index.Key_name}: ${error.message}`);
}
}
}
} catch (error) {
report.push(` ⚠️ Fehler beim Zugriff auf Tabelle '${tableName}': ${error.message}`);
console.log(` ⚠️ Fehler beim Zugriff auf Tabelle '${tableName}': ${error.message}`);
}
}
// 4. Status nach dem Cleanup
report.push('');
report.push('📊 STATUS NACH DEM CLEANUP:');
console.log('\n📊 STATUS NACH DEM CLEANUP:');
const [tablesAfter] = await connection.execute(`
SELECT
TABLE_NAME,
@@ -105,7 +120,7 @@ async function cleanupKeys() {
const diff = beforeCount - table.key_count;
const status = table.key_count <= 5 ? '✅' : table.key_count <= 10 ? '⚠️' : '❌';
report.push(` ${status} ${table.TABLE_NAME}: ${table.key_count} Keys (${diff > 0 ? `-${diff}` : `+${Math.abs(diff)}`})`);
console.log(` ${status} ${table.TABLE_NAME}: ${table.key_count} Keys (${diff > 0 ? `-${diff}` : `+${Math.abs(diff)}`})`);
});
// 5. Gesamtanzahl der Keys
@@ -115,20 +130,18 @@ async function cleanupKeys() {
WHERE TABLE_SCHEMA = ?
`, [dbConfig.database]);
report.push('');
report.push(`📈 GESAMTANZAHL KEYS: ${totalKeys[0].total_keys}`);
console.log(`\n📈 GESAMTANZAHL KEYS: ${totalKeys[0].total_keys}`);
// 6. Zusammenfassung
report.push('');
report.push('🎯 ZUSAMMENFASSUNG:');
console.log('\n🎯 ZUSAMMENFASSUNG:');
const problemTablesAfter = tablesAfter.filter(t => t.key_count > 10);
if (problemTablesAfter.length === 0) {
report.push(' ✅ Alle Tabellen haben jetzt weniger als 10 Keys!');
console.log(' ✅ Alle Tabellen haben jetzt weniger als 10 Keys!');
} else {
report.push(' ⚠️ Folgende Tabellen haben immer noch zu viele Keys:');
console.log(' ⚠️ Folgende Tabellen haben immer noch zu viele Keys:');
problemTablesAfter.forEach(table => {
report.push(` - ${table.TABLE_NAME}: ${table.key_count} Keys`);
console.log(` - ${table.TABLE_NAME}: ${table.key_count} Keys`);
});
}
@@ -137,18 +150,15 @@ async function cleanupKeys() {
} finally {
if (connection) {
await connection.end();
report.push('');
report.push('🔌 Datenbankverbindung geschlossen.');
console.log('\n🔌 Datenbankverbindung geschlossen.');
}
}
}
// Script ausführen
report.push('🚀 Starte intelligentes INDEX-Cleanup...');
console.log('🚀 Starte intelligentes INDEX-Cleanup...\n');
cleanupKeys().then(() => {
report.push('');
report.push('✨ Cleanup abgeschlossen!');
process.stdout.write(`${report.join('\n')}\n`);
console.log('\n✨ Cleanup abgeschlossen!');
process.exit(0);
}).catch(error => {
console.error('\n💥 Fehler beim Cleanup:', error);

View File

@@ -0,0 +1,603 @@
import axios from 'axios';
import fs from 'fs';
import path from 'path';
const BASE_URL = 'https://ttde-id.liga.nu';
const CLICK_TT_BASE = 'https://httv.click-tt.de';
class HettvClient {
constructor() {
this.baseURL = BASE_URL;
this.client = axios.create({
baseURL: this.baseURL,
timeout: 15000,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7'
},
maxRedirects: 5, // Folge den OAuth2-Redirects
validateStatus: (status) => status >= 200 && status < 400
});
// Einfache Cookie-Jar nach Host -> { name: value }
this.cookieJar = new Map();
this.defaultHeaders = {
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:141.0) Gecko/20100101 Firefox/141.0',
'Accept-Language': 'de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7'
};
}
/**
* Login to HeTTV via OAuth2
* @param {string} username - HeTTV username (email)
* @param {string} password - HeTTV password
* @returns {Promise<Object>} Login response with session data
*/
async login(username, password) {
try {
console.log('[HettvClient] - Starting login for:', username);
// Schritt 1: OAuth2-Authorization-Endpoint aufrufen - das sollte zur Login-Seite weiterleiten
const oauthParams = new URLSearchParams({
'scope': 'nuLiga',
'response_type': 'code',
'redirect_uri': 'https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaTTDE.woa/wa/oAuthLogin',
'state': 'nonce=' + Math.random().toString(36).substring(2, 15),
'client_id': 'XtVpGjXKAhz3BZuu'
});
// OAuth2 Start
// Der OAuth2-Endpoint sollte direkt zur Login-Seite weiterleiten
const loginPageResponse = await this.client.get(`/oauth2/authz/ttde?${oauthParams.toString()}`, {
maxRedirects: 5, // Folge den Redirects zur Login-Seite
validateStatus: (status) => status >= 200 && status < 400,
headers: {
...this.defaultHeaders
}
});
// Login-Seite erreicht
// Session-Cookie aus der Login-Seite extrahieren
const setCookies = loginPageResponse.headers['set-cookie'];
if (!setCookies || !Array.isArray(setCookies)) {
console.error('[HettvClient] - No cookies from login page');
return {
success: false,
error: 'Keine Session-Cookie von Login-Seite erhalten'
};
}
const sessionCookie = setCookies.find(cookie => cookie.startsWith('nusportingress='));
if (!sessionCookie) {
console.error('[HettvClient] - No nusportingress cookie from login page');
return {
success: false,
error: 'Keine nusportingress Session von Login-Seite erhalten'
};
}
// Extrahiere t:formdata aus dem HTML der Login-Seite
const htmlContent = loginPageResponse.data;
// HTML erhalten
// Suche nach t:formdata im HTML - verschiedene mögliche Formate
let formDataMatch = htmlContent.match(/name="t:formdata"\s+value="([^"]+)"/);
if (!formDataMatch) {
// Versuche andere Formate
formDataMatch = htmlContent.match(/name='t:formdata'\s+value='([^']+)'/);
}
if (!formDataMatch) {
// Suche nach hidden input mit t:formdata (value vor name)
formDataMatch = htmlContent.match(/<input[^>]*value="([^"]+)"[^>]*name="t:formdata"/);
}
if (!formDataMatch) {
// Suche nach hidden input mit t:formdata (name vor value)
formDataMatch = htmlContent.match(/<input[^>]*name="t:formdata"[^>]*value="([^"]+)"/);
}
if (!formDataMatch) {
// Suche nach t:formdata ohne Anführungszeichen
formDataMatch = htmlContent.match(/name=t:formdata\s+value=([^\s>]+)/);
}
if (!formDataMatch) {
console.error('[HettvClient] - No t:formdata found in login page');
console.log('[HettvClient] - HTML snippet:', htmlContent.substring(0, 2000));
// Debug: Suche nach allen hidden inputs
const hiddenInputs = htmlContent.match(/<input[^>]*type="hidden"[^>]*>/g);
console.log('[HettvClient] - Hidden inputs found:', hiddenInputs);
return {
success: false,
error: 'Keine t:formdata von Login-Seite erhalten'
};
}
const tFormData = formDataMatch[1];
// CSRF-Token gefunden
// Schritt 2: Login mit den korrekten Daten durchführen
// Verwende die Session-Cookie für den Login-Request
const formData = new URLSearchParams();
formData.append('t:submit', '["submit_0","submit_0"]');
formData.append('t:ac', 'ttde');
formData.append('t:formdata', tFormData);
formData.append('username', username);
formData.append('password', password);
const loginResponse = await this.client.post('/oauth2/login.loginform', formData.toString(), {
headers: {
'Cookie': sessionCookie.split(';')[0],
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
...this.defaultHeaders,
'Referer': `${BASE_URL}/oauth2/login.loginform`
},
maxRedirects: 5,
validateStatus: (status) => status >= 200 && status < 400
});
// Login-Antwort erhalten
// Prüfe ob wir erfolgreich eingeloggt sind
// Login-Response geprüft
// Prüfe den Response-Inhalt um zu sehen ob wir noch auf der Login-Seite sind
const responseContent = loginResponse.data;
const isLoginPage = responseContent.includes('click-TT ID') &&
responseContent.includes('Username') &&
responseContent.includes('Password');
// Login-Page-Erkennung durchgeführt
if (isLoginPage) {
console.log('[HettvClient] - Still on login page, login failed');
console.log('[HettvClient] - Response snippet:', responseContent.substring(0, 500));
return {
success: false,
error: 'Login fehlgeschlagen - ungültige Zugangsdaten'
};
}
// Prüfe auf OAuth2-Redirect oder Erfolg
const hasOAuthRedirect = responseContent.includes('oauth2') ||
responseContent.includes('redirect') ||
loginResponse.status >= 300;
// OAuth Redirect erkannt
// Extrahiere die finale Session-Cookie
const finalCookies = loginResponse.headers['set-cookie'];
const finalSessionCookie = finalCookies?.find(cookie => cookie.startsWith('nusportingress='));
const sessionId = (finalSessionCookie || sessionCookie).match(/nusportingress=([^;]+)/)?.[1];
console.log('[HettvClient] - Login erfolgreich (HeTTV).');
// Versuche die finale OAuth-Weiterleitung zu httv.click-tt.de aufzurufen, um PHPSESSID zu erhalten
let finalUrl = loginResponse.request?.res?.responseUrl;
console.log('[HettvClient] - Login finalUrl:', finalUrl);
let phpSessIdCookie = null;
let finalHtml = null;
try {
if (finalUrl && finalUrl.includes('oAuthLogin')) {
const clickTTClient = axios.create({
timeout: 15000,
maxRedirects: 0,
validateStatus: (status) => status >= 200 && status < 400
});
// Folge der Redirect-Kette manuell, übernehme Cookies
let currentUrl = finalUrl;
let lastResp = null;
let hop = 0;
const maxHops = 10;
while (hop++ < maxHops && currentUrl) {
lastResp = await clickTTClient.get(currentUrl, {
headers: {
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
...this.defaultHeaders,
'Referer': hop === 1 ? `${BASE_URL}/oauth2/login.loginform` : (lastResp?.request?.res?.responseUrl || currentUrl),
'Cookie': this._cookieHeaderForUrl(currentUrl)
}
});
this._ingestSetCookiesFromResponse(currentUrl, lastResp.headers['set-cookie']);
const loc = lastResp.headers['location'];
if (loc) {
// Absolut vs relativ
if (/^https?:\/\//i.test(loc)) {
currentUrl = loc;
} else {
const u = new URL(currentUrl);
currentUrl = `${u.origin}${loc}`;
}
continue;
}
break; // keine weitere Location => final
}
const clickTTResp = lastResp;
finalHtml = typeof clickTTResp.data === 'string' ? clickTTResp.data : '';
const ctSetCookies = clickTTResp.headers['set-cookie'];
if (Array.isArray(ctSetCookies)) {
phpSessIdCookie = ctSetCookies.find(c => c.startsWith('PHPSESSID='))?.split(';')[0] || null;
}
// Finale click-TT URL ermittelt
}
} catch (e) {
// Finale click-TT Seite konnte nicht geladen werden
}
// Baue kombinierte Cookie-Kette (falls PHPSESSID vorhanden)
const baseCookie = (finalSessionCookie || sessionCookie).split(';')[0];
const combinedCookie = phpSessIdCookie ? `${baseCookie}; ${phpSessIdCookie}` : baseCookie;
return {
success: true,
sessionId: sessionId,
cookie: combinedCookie,
accessToken: null,
refreshToken: null,
expiresAt: null,
user: {
finalUrl: finalUrl || null,
htmlSnippet: finalHtml ? finalHtml.substring(0, 2000) : null
}
};
} catch (error) {
console.error('HeTTV login error:', error.message);
console.error('Error details:', error.response?.status, error.response?.statusText);
return {
success: false,
error: error.response?.data?.message || 'Login fehlgeschlagen',
status: error.response?.status || 500
};
}
}
/**
* Verify login credentials
* @param {string} username - HeTTV username
* @param {string} password - HeTTV password
* @returns {Promise<boolean>} True if credentials are valid
*/
async verifyCredentials(username, password) {
const result = await this.login(username, password);
return result.success;
}
/**
* Make an authenticated request to click-TT
* @param {string} endpoint - API endpoint
* @param {string} cookie - JSESSIONID cookie
* @param {Object} options - Additional axios options
* @returns {Promise<Object>} API response
*/
async authenticatedRequest(endpoint, cookie, options = {}, finalUrl = null) {
try {
// Bestimme Basis-URL dynamisch aus finalUrl, falls vorhanden
let baseURL = CLICK_TT_BASE;
if (finalUrl) {
try {
const url = new URL(finalUrl);
baseURL = url.origin;
} catch (_) {}
}
const isAbsolute = /^https?:\/\//i.test(endpoint);
const client = axios.create({
baseURL: isAbsolute ? undefined : baseURL,
timeout: 15000,
maxRedirects: 0,
validateStatus: (status) => status >= 200 && status < 400
});
// Manuelles Redirect-Following inkl. Cookies/Referer
let currentUrl = isAbsolute ? endpoint : `${baseURL}${endpoint.startsWith('/') ? '' : '/'}${endpoint}`;
let lastResp = null;
const trace = [];
let hop = 0;
const maxHops = 10;
console.log(`[HettvClient] - Starting redirect chain from: ${currentUrl}`);
while (hop++ < maxHops && currentUrl) {
console.log(`[HettvClient] - Redirect ${hop}: GET ${currentUrl}`);
lastResp = await client.request({
method: options.method || 'GET',
url: currentUrl,
data: options.data,
headers: {
...this.defaultHeaders,
...(options.headers || {}),
'Cookie': this._mergeCookieHeader(cookie, this._cookieHeaderForUrl(currentUrl)),
'Referer': hop === 1 ? (finalUrl || baseURL) : (lastResp?.request?.res?.responseUrl || currentUrl)
}
});
this._ingestSetCookiesFromResponse(currentUrl, lastResp.headers['set-cookie']);
const loc = lastResp.headers['location'];
console.log(`[HettvClient] - Response: ${lastResp.status} ${lastResp.statusText}`);
console.log(`[HettvClient] - Location header: ${loc || 'none'}`);
console.log(`[HettvClient] - Set-Cookie header: ${lastResp.headers['set-cookie'] ? 'present' : 'none'}`);
console.log(`[HettvClient] - Content-Type: ${lastResp.headers['content-type'] || 'none'}`);
// Speichere jede Seite zur Analyse
try {
const dir = path.resolve(process.cwd(), 'backend', 'uploads');
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const filename = `hettv_redirect_${hop}_${Date.now()}.html`;
const filePath = path.join(dir, filename);
const content = typeof lastResp.data === 'string' ? lastResp.data : JSON.stringify(lastResp.data, null, 2);
fs.writeFileSync(filePath, content, 'utf8');
console.log(`[HettvClient] - Saved page to: ${filename}`);
} catch (e) {
console.log(`[HettvClient] - Could not save page ${hop}:`, e.message);
}
trace.push({
url: currentUrl,
status: lastResp.status,
location: loc || null
});
if (loc) {
const newUrl = /^https?:\/\//i.test(loc) ? loc : `${new URL(currentUrl).origin}${loc}`;
console.log(`[HettvClient] - Following redirect to: ${newUrl}`);
currentUrl = newUrl;
continue;
}
console.log(`[HettvClient] - Final response: ${lastResp.status} (no more redirects)`);
break;
}
return {
success: true,
data: lastResp?.data,
trace
};
} catch (error) {
console.error('HeTTV API error:', error.message);
return {
success: false,
error: error.response?.data?.message || 'API-Anfrage fehlgeschlagen',
status: error.response?.status || 500
};
}
}
/**
* Navigate to main HeTTV page and find Downloads menu
* @param {string} cookie - Session cookie
* @returns {Promise<Object>} Response with main page content and download links
*/
async getMainPageWithDownloads(cookie, finalUrl = null) {
try {
console.log('[HettvClient] - Loading main HeTTV page...');
// Kandidaten für Einstiegs-URL bestimmen
let origin = CLICK_TT_BASE;
if (finalUrl) {
try { origin = new URL(finalUrl).origin; } catch (_) {}
}
const candidates = [];
// Direkt zu HeTTV navigieren
candidates.push('http://httv.click-tt.de/');
candidates.push('http://httv.click-tt.de/wa/');
candidates.push('http://httv.click-tt.de/cgi-bin/WebObjects/nuLigaTTDE.woa/wa/');
// Wenn wir eine finalUrl haben, verwende diese auch
if (finalUrl) {
candidates.push(finalUrl);
}
console.log('[HettvClient] - URL candidates:', candidates);
let mainPageResponse = null;
let mainTrace = [];
let lastError = null;
for (const candidate of candidates) {
const resp = await this.authenticatedRequest(candidate, cookie, {}, finalUrl);
if (resp.success && typeof resp.data === 'string' && resp.data.length > 0) {
mainPageResponse = resp;
mainTrace = resp.trace || [];
break;
}
lastError = resp;
}
if (!mainPageResponse) {
return lastError || { success: false, error: 'HeTTV Einstiegsseite nicht erreichbar', status: 404 };
}
const htmlContent = mainPageResponse.data;
console.log('[HettvClient] - Main page loaded, HTML length:', htmlContent.length);
// Erkenne Fehlerseite (Session ungültig)
if (/click-TT\s*-\s*Fehlerseite/i.test(htmlContent) || /ungültige oder nicht mehr gültige URL/i.test(htmlContent)) {
return {
success: false,
error: 'Session ungültig oder abgelaufen',
status: 401,
data: { htmlSnippet: htmlContent.substring(0, 1000) }
};
}
// Speichere HTML zur Analyse
let savedFile = null;
try {
const dir = path.resolve(process.cwd(), 'backend', 'uploads');
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const filename = `hettv_main_${Date.now()}.html`;
const filePath = path.join(dir, filename);
fs.writeFileSync(filePath, htmlContent, 'utf8');
savedFile = filePath;
} catch (e) {
// Ignoriere Speicherfehler still, nur für Debug
}
// Suche nach Downloads-Links im HTML
const downloadLinks = [];
// 1) URL-Heuristiken
const urlPatterns = [
/href="([^"]*download[^"]*)"/gi,
/href="([^"]*downloads[^"]*)"/gi,
/href="([^"]*Download[^"]*)"/gi,
/href="([^"]*Downloads[^"]*)"/gi
];
urlPatterns.forEach(pattern => {
let match;
while ((match = pattern.exec(htmlContent)) !== null) {
const link = match[1];
if (link && !downloadLinks.includes(link)) {
downloadLinks.push(link);
}
}
});
// 2) Linktext-Heuristik: <a ...>Downloads</a>
const anchorPattern = /<a[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi;
let aMatch;
while ((aMatch = anchorPattern.exec(htmlContent)) !== null) {
const href = aMatch[1];
const text = aMatch[2].replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
if (/\bdownloads?\b/i.test(text)) {
if (href && !downloadLinks.includes(href)) {
downloadLinks.push(href);
}
}
}
// 3) Fallback: Menüpunkte in Navigationen (role="navigation" etc.)
if (downloadLinks.length === 0) {
const navSectionRegex = /<nav[\s\S]*?<\/nav>/gi;
let nav;
while ((nav = navSectionRegex.exec(htmlContent)) !== null) {
const section = nav[0];
let m;
anchorPattern.lastIndex = 0;
while ((m = anchorPattern.exec(section)) !== null) {
const href = m[1];
const text = m[2].replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
if (/\bdownloads?\b/i.test(text)) {
if (href && !downloadLinks.includes(href)) {
downloadLinks.push(href);
}
}
}
}
}
console.log('[HettvClient] - Found download links:', downloadLinks);
return {
success: true,
data: {
htmlContent: htmlContent,
downloadLinks: downloadLinks,
htmlSnippet: htmlContent.substring(0, 2000), // Erste 2000 Zeichen für Analyse
savedFile,
trace: mainTrace,
lastUrl: mainTrace.length ? mainTrace[mainTrace.length - 1].url : null,
lastStatus: mainTrace.length ? mainTrace[mainTrace.length - 1].status : null
}
};
} catch (error) {
console.error('HeTTV main page error:', error.message);
return {
success: false,
error: error.message || 'Fehler beim Laden der Hauptseite',
status: 500
};
}
}
/**
* Load a specific download page
* @param {string} downloadUrl - URL to the download page
* @param {string} cookie - Session cookie
* @returns {Promise<Object>} Response with download page content
*/
async loadDownloadPage(downloadUrl, cookie, finalUrl = null) {
try {
console.log('[HettvClient] - Loading download page:', downloadUrl);
const response = await this.authenticatedRequest(downloadUrl, cookie, {}, finalUrl);
if (!response.success) {
return response;
}
const htmlContent = response.data;
console.log('[HettvClient] - Download page loaded, HTML length:', htmlContent.length);
return {
success: true,
data: {
url: downloadUrl,
htmlContent: htmlContent,
htmlSnippet: htmlContent.substring(0, 3000) // Erste 3000 Zeichen für Analyse
}
};
} catch (error) {
console.error('HeTTV download page error:', error.message);
return {
success: false,
error: error.message || 'Fehler beim Laden der Download-Seite',
status: 500
};
}
}
// --- Cookie-Helfer ---
_ingestSetCookiesFromResponse(currentUrl, setCookies) {
if (!Array.isArray(setCookies) || setCookies.length === 0) return;
const { host } = new URL(currentUrl);
if (!this.cookieJar.has(host)) this.cookieJar.set(host, new Map());
const jar = this.cookieJar.get(host);
setCookies.forEach((cookieStr) => {
const pair = cookieStr.split(';')[0];
const eq = pair.indexOf('=');
if (eq > 0) {
const name = pair.substring(0, eq).trim();
const value = pair.substring(eq + 1).trim();
jar.set(name, value);
}
});
}
_cookieHeaderForUrl(currentUrl) {
const { host } = new URL(currentUrl);
const jar = this.cookieJar.get(host);
if (!jar || jar.size === 0) return '';
return Array.from(jar.entries()).map(([k, v]) => `${k}=${v}`).join('; ');
}
_mergeCookieHeader(primary, secondary) {
const items = [];
if (primary) items.push(primary);
if (secondary) items.push(secondary);
return items.filter(Boolean).join('; ');
}
}
export default new HettvClient();

View File

@@ -17,146 +17,19 @@ class MyTischtennisClient {
});
}
/**
* Get login page to extract XSRF token and CAPTCHA token
* @returns {Promise<Object>} Object with xsrfToken, captchaToken, and captchaClicked flag
*/
async getLoginPage() {
try {
const response = await this.client.get('/login?next=%2F');
const html = response.data;
// Extract XSRF token from hidden input
const xsrfMatch = html.match(/<input[^>]*name="xsrf"[^>]*value="([^"]+)"/);
const xsrfToken = xsrfMatch ? xsrfMatch[1] : null;
// Extract CAPTCHA token from hidden input (if present)
const captchaMatch = html.match(/<input[^>]*name="captcha"[^>]*value="([^"]+)"/);
const captchaToken = captchaMatch ? captchaMatch[1] : null;
// Check if captcha_clicked is true or false
const captchaClickedMatch = html.match(/<input[^>]*name="captcha_clicked"[^>]*value="([^"]+)"/);
const captchaClicked = captchaClickedMatch ? captchaClickedMatch[1] === 'true' : false;
// Check if CAPTCHA is required (look for private-captcha element or captcha input)
const requiresCaptcha = html.includes('private-captcha') || html.includes('name="captcha"');
console.log('[myTischtennisClient.getLoginPage]', {
hasXsrfToken: !!xsrfToken,
hasCaptchaToken: !!captchaToken,
captchaClicked,
requiresCaptcha
});
return {
success: true,
xsrfToken,
captchaToken,
captchaClicked,
requiresCaptcha
};
} catch (error) {
console.error('Error fetching login page:', error.message);
return {
success: false,
error: error.message
};
}
}
/**
* Login to myTischtennis API
* @param {string} email - myTischtennis email (not username!)
* @param {string} password - myTischtennis password
* @param {string} captchaToken - Optional CAPTCHA token if required
* @param {string} xsrfToken - Optional XSRF token (will be fetched if not provided)
* @returns {Promise<Object>} Login response with token and session data
*/
async login(email, password, captchaToken = null, xsrfToken = null) {
async login(email, password) {
try {
let loginPage = null;
let captchaClicked = false;
// If XSRF token not provided, fetch login page to get it
if (!xsrfToken) {
loginPage = await this.getLoginPage();
if (!loginPage.success) {
return {
success: false,
error: 'Konnte Login-Seite nicht abrufen: ' + loginPage.error
};
}
xsrfToken = loginPage.xsrfToken;
// If CAPTCHA token not provided but found in HTML, use it
if (!captchaToken && loginPage.captchaToken) {
captchaToken = loginPage.captchaToken;
captchaClicked = loginPage.captchaClicked;
console.log('[myTischtennisClient.login] CAPTCHA-Token aus HTML extrahiert, captcha_clicked:', captchaClicked);
}
// If CAPTCHA is required but no token found yet, wait and try to get it again
// Das CAPTCHA-System löst das Puzzle im Hintergrund via JavaScript, daher kann es einen Moment dauern
// Wir müssen mehrmals versuchen, da das Token erst generiert wird, nachdem das JavaScript gelaufen ist
if (loginPage.requiresCaptcha && !captchaToken) {
console.log('[myTischtennisClient.login] CAPTCHA erforderlich, aber noch kein Token gefunden. Warte und versuche erneut...');
// Versuche bis zu 5 Mal, das CAPTCHA-Token zu erhalten
let maxRetries = 5;
let retryCount = 0;
let foundToken = false;
while (retryCount < maxRetries && !foundToken) {
// Warte 2-4 Sekunden zwischen den Versuchen
const waitMs = Math.floor(Math.random() * 2000) + 2000; // 2000-4000ms
console.log(`[myTischtennisClient.login] Versuch ${retryCount + 1}/${maxRetries}: Warte ${waitMs}ms...`);
await new Promise(resolve => setTimeout(resolve, waitMs));
// Versuche erneut, die Login-Seite abzurufen, um das gelöste CAPTCHA-Token zu erhalten
const retryLoginPage = await this.getLoginPage();
if (retryLoginPage.success && retryLoginPage.captchaToken) {
captchaToken = retryLoginPage.captchaToken;
captchaClicked = retryLoginPage.captchaClicked;
xsrfToken = retryLoginPage.xsrfToken || xsrfToken; // Aktualisiere XSRF-Token falls nötig
foundToken = true;
console.log(`[myTischtennisClient.login] CAPTCHA-Token nach ${retryCount + 1} Versuchen gefunden, captcha_clicked:`, captchaClicked);
} else {
retryCount++;
}
}
if (!foundToken) {
// Wenn nach allen Versuchen kein Token gefunden wurde, Fehler zurückgeben
console.log('[myTischtennisClient.login] CAPTCHA-Token konnte nach mehreren Versuchen nicht gefunden werden');
return {
success: false,
error: 'CAPTCHA erforderlich. Bitte lösen Sie das CAPTCHA auf der MyTischtennis-Website.',
requiresCaptcha: true
};
}
}
// Zufällige Verzögerung von 2-5 Sekunden zwischen Laden des Forms und Absenden
// Simuliert menschliches Verhalten und gibt dem CAPTCHA-System Zeit
const delayMs = Math.floor(Math.random() * 3000) + 2000; // 2000-5000ms
console.log(`[myTischtennisClient] Warte ${delayMs}ms vor Login-Request (simuliert menschliches Verhalten)`);
await new Promise(resolve => setTimeout(resolve, delayMs));
}
// Create form data
const formData = new URLSearchParams();
formData.append('email', email);
formData.append('password', password);
formData.append('intent', 'login');
if (xsrfToken) {
formData.append('xsrf', xsrfToken);
}
if (captchaToken) {
formData.append('captcha', captchaToken);
formData.append('captcha_clicked', captchaClicked ? 'true' : 'false');
}
const response = await this.client.post(
'/login?next=%2F&_data=routes%2F_auth%2B%2Flogin',
@@ -213,36 +86,11 @@ class MyTischtennisClient {
cookie: authCookie.split(';')[0] // Just the cookie value without attributes
};
} catch (error) {
const statusCode = error.response?.status || 500;
const responseData = error.response?.data;
// Check if response contains CAPTCHA error
let errorMessage = error.response?.data?.message || error.message || 'Login fehlgeschlagen';
let requiresCaptcha = false;
// Check for CAPTCHA-related errors in response
if (typeof responseData === 'string') {
if (responseData.includes('Captcha') || responseData.includes('CAPTCHA') ||
responseData.includes('captcha') || responseData.includes('Captcha-Bestätigung')) {
requiresCaptcha = true;
errorMessage = 'CAPTCHA erforderlich. Bitte lösen Sie das CAPTCHA auf der MyTischtennis-Website.';
}
} else if (responseData && typeof responseData === 'object') {
// Check for CAPTCHA errors in JSON response or HTML
const dataString = JSON.stringify(responseData);
if (dataString.includes('Captcha') || dataString.includes('CAPTCHA') ||
dataString.includes('captcha') || dataString.includes('Captcha-Bestätigung')) {
requiresCaptcha = true;
errorMessage = 'CAPTCHA erforderlich. Bitte lösen Sie das CAPTCHA auf der MyTischtennis-Website.';
}
}
console.error('MyTischtennis login error:', errorMessage, `(Status: ${statusCode})`, requiresCaptcha ? '(CAPTCHA erforderlich)' : '');
console.error('MyTischtennis login error:', error.message);
return {
success: false,
error: errorMessage,
status: statusCode,
requiresCaptcha
error: error.response?.data?.message || 'Login fehlgeschlagen',
status: error.response?.status || 500
};
}
}
@@ -301,13 +149,28 @@ class MyTischtennisClient {
* @returns {Promise<Object>} User profile with club info
*/
async getUserProfile(cookie) {
console.log('[getUserProfile] - Calling /?_data=root with cookie:', cookie?.substring(0, 50) + '...');
const result = await this.authenticatedRequest('/?_data=root', cookie, {
method: 'GET'
});
console.log('[getUserProfile] - Result success:', result.success);
if (result.success) {
console.log('[getUserProfile] - Response structure:', {
hasUserProfile: !!result.data?.userProfile,
hasClub: !!result.data?.userProfile?.club,
hasOrganization: !!result.data?.userProfile?.organization,
clubnr: result.data?.userProfile?.club?.clubnr,
clubName: result.data?.userProfile?.club?.name,
orgShort: result.data?.userProfile?.organization?.short,
ttr: result.data?.userProfile?.ttr,
qttr: result.data?.userProfile?.qttr
});
console.log('[getUserProfile] - Full userProfile.club:', result.data?.userProfile?.club);
console.log('[getUserProfile] - Full userProfile.organization:', result.data?.userProfile?.organization);
return {
success: true,
@@ -331,15 +194,17 @@ class MyTischtennisClient {
* @param {string} fedNickname - Federation nickname (e.g., "HeTTV")
* @returns {Promise<Object>} Rankings with player entries (all pages)
*/
async getClubRankings(cookie, clubId, fedNickname, currentRanking = 'yes') {
async getClubRankings(cookie, clubId, fedNickname) {
const allEntries = [];
let currentPage = 0;
let hasMorePages = true;
console.log('[getClubRankings] - Starting to fetch rankings for club', clubId);
while (hasMorePages) {
const endpoint = `/rankings/andro-rangliste?all-players=on&clubnr=${clubId}&fednickname=${fedNickname}&current-ranking=${currentRanking}&results-per-page=100&page=${currentPage}&_data=routes%2F%24`;
const endpoint = `/rankings/andro-rangliste?all-players=on&clubnr=${clubId}&fednickname=${fedNickname}&results-per-page=100&page=${currentPage}&_data=routes%2F%24`;
console.log(`[getClubRankings] - Fetching page ${currentPage}...`);
const result = await this.authenticatedRequest(endpoint, cookie, {
method: 'GET'
@@ -380,6 +245,7 @@ class MyTischtennisClient {
};
}
console.log(`[getClubRankings] - Page ${currentPage}: Found ${entries.length} entries`);
// Füge Entries hinzu
allEntries.push(...entries);
@@ -389,15 +255,19 @@ class MyTischtennisClient {
// Oder wenn wir alle erwarteten Einträge haben
if (entries.length === 0) {
hasMorePages = false;
console.log('[getClubRankings] - No more entries, stopping');
} else if (rankingData.numberOfPages && currentPage >= rankingData.numberOfPages - 1) {
hasMorePages = false;
console.log(`[getClubRankings] - Reached last page (${rankingData.numberOfPages})`);
} else if (allEntries.length >= rankingData.resultLength) {
hasMorePages = false;
console.log(`[getClubRankings] - Got all entries (${allEntries.length}/${rankingData.resultLength})`);
} else {
currentPage++;
}
}
console.log(`[getClubRankings] - Total entries fetched: ${allEntries.length}`);
return {
success: true,

View File

@@ -1,41 +1,17 @@
import dotenv from 'dotenv';
import path from 'path';
import { fileURLToPath } from 'url';
// Ensure .env is loaded from the backend folder (not dependent on process.cwd())
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
dotenv.config({ path: path.resolve(__dirname, '.env') });
dotenv.config();
const isTestEnv = process.env.NODE_ENV === 'test';
const baseConfig = {
export const development = {
username: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || 'hitomisan',
database: process.env.DB_NAME || 'trainingdiary',
host: process.env.DB_HOST || 'localhost',
dialect: process.env.DB_DIALECT || 'mysql',
host: process.env.DB_HOST,
dialect: process.env.DB_DIALECT,
define: {
freezeTableName: true,
underscored: true,
underscoredAll: true,
},
logging: false,
storage: process.env.DB_STORAGE,
};
if (isTestEnv) {
baseConfig.username = 'sqlite';
baseConfig.password = '';
baseConfig.database = 'sqlite';
baseConfig.host = 'localhost';
baseConfig.dialect = 'sqlite';
baseConfig.storage = process.env.DB_STORAGE || ':memory:';
}
if (baseConfig.dialect === 'sqlite' && !baseConfig.storage) {
baseConfig.storage = ':memory:';
}
export const development = baseConfig;

View File

@@ -1,125 +0,0 @@
# Fehlercode-System - Verwendungsanleitung
## Übersicht
Das Fehlercode-System ersetzt hardcodierte deutsche Fehlermeldungen durch strukturierte Fehlercodes, die im Frontend übersetzt werden.
## Backend-Verwendung
### 1. Fehlercode verwenden
```javascript
import HttpError from '../exceptions/HttpError.js';
import { ERROR_CODES, createError } from '../constants/errorCodes.js';
// Einfacher Fehlercode ohne Parameter
throw new HttpError(createError(ERROR_CODES.USER_NOT_FOUND), 404);
// Fehlercode mit Parametern
throw new HttpError(
createError(ERROR_CODES.MEMBER_NOT_FOUND, { memberId: 123 }),
404
);
// Oder direkt:
throw new HttpError(
{ code: ERROR_CODES.MEMBER_NOT_FOUND, params: { memberId: 123 } },
404
);
```
### 2. Legacy-Format (wird weiterhin unterstützt)
```javascript
// Alte Variante funktioniert noch:
throw new HttpError('Benutzer nicht gefunden', 404);
```
## Frontend-Verwendung
### 1. Fehlermeldungen automatisch übersetzen
Die `getSafeErrorMessage`-Funktion erkennt automatisch Fehlercodes:
```javascript
import { getSafeErrorMessage } from '../utils/errorMessages.js';
// In einer Vue-Komponente (Options API)
try {
await apiClient.post('/api/endpoint', data);
} catch (error) {
const message = getSafeErrorMessage(error, this.$t('errors.ERROR_UNKNOWN_ERROR'), this.$t);
await this.showInfo(this.$t('messages.error'), message, '', 'error');
}
// In einer Vue-Komponente (Composition API)
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
try {
await apiClient.post('/api/endpoint', data);
} catch (error) {
const message = getSafeErrorMessage(error, t('errors.ERROR_UNKNOWN_ERROR'), t);
await showInfo(t('messages.error'), message, '', 'error');
}
```
### 2. Dialog-Utils mit Übersetzung
```javascript
import { buildInfoConfig, safeErrorMessage } from '../utils/dialogUtils.js';
// Mit Übersetzungsfunktion
this.infoDialog = buildInfoConfig({
title: this.$t('messages.error'),
message: safeErrorMessage(error, this.$t('errors.ERROR_UNKNOWN_ERROR'), this.$t),
type: 'error'
}, this.$t);
```
## API-Response-Format
### Neues Format (mit Fehlercode):
```json
{
"success": false,
"code": "ERROR_MEMBER_NOT_FOUND",
"params": {
"memberId": 123
},
"error": "ERROR_MEMBER_NOT_FOUND" // Für Rückwärtskompatibilität
}
```
### Legacy-Format (wird weiterhin unterstützt):
```json
{
"success": false,
"message": "Mitglied nicht gefunden",
"error": "Mitglied nicht gefunden"
}
```
## Übersetzungen hinzufügen
1. **Backend**: Fehlercode in `backend/constants/errorCodes.js` definieren
2. **Frontend**: Übersetzung in `frontend/src/i18n/locales/de.json` unter `errors` hinzufügen
Beispiel:
```json
{
"errors": {
"ERROR_MEMBER_NOT_FOUND": "Mitglied nicht gefunden.",
"ERROR_MEMBER_NOT_FOUND_WITH_ID": "Mitglied mit ID {memberId} nicht gefunden."
}
}
```
## Migration bestehender Fehler
1. Hardcodierte Fehlermeldung identifizieren
2. Passenden Fehlercode in `errorCodes.js` finden oder erstellen
3. Backend-Code anpassen: `throw new HttpError(createError(ERROR_CODES.XXX), status)`
4. Übersetzung in `de.json` hinzufügen
5. Frontend-Code muss nicht geändert werden (automatische Erkennung)

View File

@@ -1,121 +0,0 @@
/**
* Fehlercodes für die API
* Diese Codes werden an das Frontend gesendet und dort übersetzt
*
* Format: { code: string, params?: object }
*
* Beispiel:
* - { code: 'ERROR_USER_NOT_FOUND' }
* - { code: 'ERROR_MEMBER_NOT_FOUND', params: { memberId: 123 } }
* - { code: 'ERROR_VALIDATION_FAILED', params: { field: 'email', value: 'invalid' } }
*/
export const ERROR_CODES = {
// Allgemeine Fehler
INTERNAL_SERVER_ERROR: 'ERROR_INTERNAL_SERVER_ERROR',
UNKNOWN_ERROR: 'ERROR_UNKNOWN_ERROR',
VALIDATION_FAILED: 'ERROR_VALIDATION_FAILED',
NOT_FOUND: 'ERROR_NOT_FOUND',
UNAUTHORIZED: 'ERROR_UNAUTHORIZED',
FORBIDDEN: 'ERROR_FORBIDDEN',
BAD_REQUEST: 'ERROR_BAD_REQUEST',
// Authentifizierung
USER_NOT_FOUND: 'ERROR_USER_NOT_FOUND',
INVALID_PASSWORD: 'ERROR_INVALID_PASSWORD',
LOGIN_FAILED: 'ERROR_LOGIN_FAILED',
SESSION_EXPIRED: 'ERROR_SESSION_EXPIRED',
// MyTischtennis
MYTISCHTENNIS_USER_NOT_FOUND: 'ERROR_MYTISCHTENNIS_USER_NOT_FOUND',
MYTISCHTENNIS_INVALID_PASSWORD: 'ERROR_MYTISCHTENNIS_INVALID_PASSWORD',
MYTISCHTENNIS_LOGIN_FAILED: 'ERROR_MYTISCHTENNIS_LOGIN_FAILED',
MYTISCHTENNIS_ACCOUNT_NOT_LINKED: 'ERROR_MYTISCHTENNIS_ACCOUNT_NOT_LINKED',
MYTISCHTENNIS_PASSWORD_NOT_SAVED: 'ERROR_MYTISCHTENNIS_PASSWORD_NOT_SAVED',
MYTISCHTENNIS_SESSION_EXPIRED: 'ERROR_MYTISCHTENNIS_SESSION_EXPIRED',
MYTISCHTENNIS_NO_PASSWORD_SAVED: 'ERROR_MYTISCHTENNIS_NO_PASSWORD_SAVED',
MYTISCHTENNIS_CAPTCHA_REQUIRED: 'ERROR_MYTISCHTENNIS_CAPTCHA_REQUIRED',
// Mitglieder
MEMBER_NOT_FOUND: 'ERROR_MEMBER_NOT_FOUND',
MEMBER_ALREADY_EXISTS: 'ERROR_MEMBER_ALREADY_EXISTS',
MEMBER_FIRSTNAME_REQUIRED: 'ERROR_MEMBER_FIRSTNAME_REQUIRED',
MEMBER_LASTNAME_REQUIRED: 'ERROR_MEMBER_LASTNAME_REQUIRED',
// Gruppen
GROUP_NOT_FOUND: 'ERROR_GROUP_NOT_FOUND',
GROUP_NAME_REQUIRED: 'ERROR_GROUP_NAME_REQUIRED',
GROUP_ALREADY_EXISTS: 'ERROR_GROUP_ALREADY_EXISTS',
GROUP_INVALID_PRESET_TYPE: 'ERROR_GROUP_INVALID_PRESET_TYPE',
GROUP_CANNOT_RENAME_PRESET: 'ERROR_GROUP_CANNOT_RENAME_PRESET',
// Turniere
TOURNAMENT_NOT_FOUND: 'ERROR_TOURNAMENT_NOT_FOUND',
TOURNAMENT_NO_DATE: 'ERROR_TOURNAMENT_NO_DATE',
TOURNAMENT_CLASS_NAME_REQUIRED: 'ERROR_TOURNAMENT_CLASS_NAME_REQUIRED',
TOURNAMENT_NO_PARTICIPANTS: 'ERROR_TOURNAMENT_NO_PARTICIPANTS',
TOURNAMENT_NO_VALID_PARTICIPANTS: 'ERROR_TOURNAMENT_NO_VALID_PARTICIPANTS',
TOURNAMENT_NO_TRAINING_DAY: 'ERROR_TOURNAMENT_NO_TRAINING_DAY',
TOURNAMENT_PDF_GENERATION_FAILED: 'ERROR_TOURNAMENT_PDF_GENERATION_FAILED',
TOURNAMENT_SELECT_FIRST: 'ERROR_TOURNAMENT_SELECT_FIRST',
// Trainingstagebuch
DIARY_DATE_NOT_FOUND: 'ERROR_DIARY_DATE_NOT_FOUND',
DIARY_DATE_UPDATED: 'ERROR_DIARY_DATE_UPDATED',
DIARY_NO_PARTICIPANTS: 'ERROR_DIARY_NO_PARTICIPANTS',
DIARY_PDF_GENERATION_FAILED: 'ERROR_DIARY_PDF_GENERATION_FAILED',
DIARY_IMAGE_LOAD_FAILED: 'ERROR_DIARY_IMAGE_LOAD_FAILED',
DIARY_STATS_LOAD_FAILED: 'ERROR_DIARY_STATS_LOAD_FAILED',
DIARY_NO_EXERCISE_DATA: 'ERROR_DIARY_NO_EXERCISE_DATA',
DIARY_ACTIVITY_PARTICIPANTS_UPDATE_FAILED: 'ERROR_DIARY_ACTIVITY_PARTICIPANTS_UPDATE_FAILED',
DIARY_GROUP_ASSIGNMENT_UPDATED: 'SUCCESS_DIARY_GROUP_ASSIGNMENT_UPDATED',
DIARY_GROUP_ASSIGNMENT_UPDATE_FAILED: 'ERROR_DIARY_GROUP_ASSIGNMENT_UPDATE_FAILED',
DIARY_ASSIGN_ALL_PARTICIPANTS_FAILED: 'ERROR_DIARY_ASSIGN_ALL_PARTICIPANTS_FAILED',
DIARY_ASSIGN_GROUP_FAILED: 'ERROR_DIARY_ASSIGN_GROUP_FAILED',
DIARY_PARTICIPANT_ASSIGN_FAILED: 'ERROR_DIARY_PARTICIPANT_ASSIGN_FAILED',
DIARY_PARTICIPANT_GROUP_ASSIGNMENT_UPDATE_FAILED: 'ERROR_DIARY_PARTICIPANT_GROUP_ASSIGNMENT_UPDATE_FAILED',
DIARY_MEMBER_CREATED: 'SUCCESS_DIARY_MEMBER_CREATED',
DIARY_MEMBER_CREATE_FAILED: 'ERROR_DIARY_MEMBER_CREATE_FAILED',
// Team Management
TEAM_NOT_LINKED_TO_LEAGUE: 'ERROR_TEAM_NOT_LINKED_TO_LEAGUE',
TEAM_LINK_TO_LEAGUE_REQUIRED: 'ERROR_TEAM_LINK_TO_LEAGUE_REQUIRED',
TEAM_PDF_LOAD_FAILED: 'ERROR_TEAM_PDF_LOAD_FAILED',
TEAM_STATS_LOAD_FAILED: 'ERROR_TEAM_STATS_LOAD_FAILED',
// Aktivitäten
ACTIVITY_IMAGE_DELETE_FAILED: 'ERROR_ACTIVITY_IMAGE_DELETE_FAILED',
// Offizielle Turniere
OFFICIAL_TOURNAMENT_PDF_UPLOAD_SUCCESS: 'SUCCESS_OFFICIAL_TOURNAMENT_PDF_UPLOAD',
OFFICIAL_TOURNAMENT_PDF_UPLOAD_FAILED: 'ERROR_OFFICIAL_TOURNAMENT_PDF_UPLOAD',
// Vereine
CLUB_NOT_FOUND: 'ERROR_CLUB_NOT_FOUND',
CLUB_ALREADY_EXISTS: 'ERROR_CLUB_ALREADY_EXISTS',
CLUB_NAME_REQUIRED: 'ERROR_CLUB_NAME_REQUIRED',
CLUB_NAME_TOO_SHORT: 'ERROR_CLUB_NAME_TOO_SHORT',
// Mitglieder-Übertragung
MEMBER_TRANSFER_BULK_FAILED: 'ERROR_MEMBER_TRANSFER_BULK_FAILED',
// Training
TRAINING_STATS_LOAD_FAILED: 'ERROR_TRAINING_STATS_LOAD_FAILED',
// Logs
LOG_NOT_FOUND: 'ERROR_LOG_NOT_FOUND',
};
/**
* Erstellt ein Fehler-Objekt mit Code und optionalen Parametern
* @param {string} code - Fehlercode aus ERROR_CODES
* @param {object} params - Optionale Parameter für die Fehlermeldung
* @returns {object} Fehler-Objekt mit code und params
*/
export function createError(code, params = null) {
return {
code,
...(params && { params })
};
}

View File

@@ -1,85 +0,0 @@
import apiLogService from '../services/apiLogService.js';
import HttpError from '../exceptions/HttpError.js';
class ApiLogController {
/**
* GET /api/logs
* Get API logs with optional filters
*/
async getLogs(req, res, next) {
try {
const {
userId,
logType,
method,
path,
statusCode,
startDate,
endDate,
limit = 100,
offset = 0
} = req.query;
const result = await apiLogService.getLogs({
userId: userId ? parseInt(userId) : null,
logType,
method,
path,
statusCode: statusCode ? parseInt(statusCode) : null,
startDate,
endDate,
limit: parseInt(limit),
offset: parseInt(offset)
});
res.json({
success: true,
data: result
});
} catch (error) {
next(error);
}
}
/**
* GET /api/logs/:id
* Get a single log entry by ID
*/
async getLogById(req, res, next) {
try {
const { id } = req.params;
const log = await apiLogService.getLogById(parseInt(id));
if (!log) {
throw new HttpError('Log entry not found', 404);
}
res.json({
success: true,
data: log
});
} catch (error) {
next(error);
}
}
/**
* GET /api/logs/scheduler/last-executions
* Get last execution info for scheduler jobs
*/
async getLastSchedulerExecutions(req, res, next) {
try {
const { clubId } = req.query;
const results = await apiLogService.getLastSchedulerExecutions(clubId ? parseInt(clubId) : null);
res.json({
success: true,
data: results
});
} catch (error) {
next(error);
}
}
}
export default new ApiLogController();

View File

@@ -1,128 +0,0 @@
import ClubTeamService from '../services/clubTeamService.js';
import { getUserByToken } from '../utils/userUtils.js';
import { devLog } from '../utils/logger.js';
export const getClubTeams = async (req, res) => {
try {
const { authcode: token } = req.headers;
const { clubid: clubId } = req.params;
const { seasonid: seasonId } = req.query;
const user = await getUserByToken(token);
// Check if user has access to this club
const clubTeams = await ClubTeamService.getAllClubTeamsByClub(clubId, seasonId);
res.status(200).json(clubTeams);
} catch (error) {
console.error('[getClubTeams] - Error:', error);
res.status(500).json({ error: "internalerror" });
}
};
export const getClubTeam = async (req, res) => {
try {
const { authcode: token } = req.headers;
const { clubteamid: clubTeamId } = req.params;
const user = await getUserByToken(token);
const clubTeam = await ClubTeamService.getClubTeamById(clubTeamId);
if (!clubTeam) {
return res.status(404).json({ error: "notfound" });
}
res.status(200).json(clubTeam);
} catch (error) {
console.error('[getClubTeam] - Error:', error);
res.status(500).json({ error: "internalerror" });
}
};
export const createClubTeam = async (req, res) => {
try {
const { authcode: token } = req.headers;
const { clubid: clubId } = req.params;
const { name, leagueId, seasonId } = req.body;
const user = await getUserByToken(token);
if (!name) {
return res.status(400).json({ error: "missingname" });
}
const clubTeamData = {
name,
clubId: parseInt(clubId),
leagueId: leagueId ? parseInt(leagueId) : null,
seasonId: seasonId ? parseInt(seasonId) : null
};
const newClubTeam = await ClubTeamService.createClubTeam(clubTeamData);
res.status(201).json(newClubTeam);
} catch (error) {
console.error('[createClubTeam] - Error:', error);
res.status(500).json({ error: "internalerror" });
}
};
export const updateClubTeam = async (req, res) => {
try {
const { authcode: token } = req.headers;
const { clubteamid: clubTeamId } = req.params;
const { name, leagueId, seasonId } = req.body;
const user = await getUserByToken(token);
const updateData = {};
if (name !== undefined) updateData.name = name;
if (leagueId !== undefined) updateData.leagueId = leagueId ? parseInt(leagueId) : null;
if (seasonId !== undefined) updateData.seasonId = seasonId ? parseInt(seasonId) : null;
const success = await ClubTeamService.updateClubTeam(clubTeamId, updateData);
if (!success) {
return res.status(404).json({ error: "notfound" });
}
const updatedClubTeam = await ClubTeamService.getClubTeamById(clubTeamId);
res.status(200).json(updatedClubTeam);
} catch (error) {
console.error('[updateClubTeam] - Error:', error);
res.status(500).json({ error: "internalerror" });
}
};
export const deleteClubTeam = async (req, res) => {
try {
const { authcode: token } = req.headers;
const { clubteamid: clubTeamId } = req.params;
const user = await getUserByToken(token);
const success = await ClubTeamService.deleteClubTeam(clubTeamId);
if (!success) {
return res.status(404).json({ error: "notfound" });
}
res.status(200).json({ message: "Club team deleted successfully" });
} catch (error) {
console.error('[deleteClubTeam] - Error:', error);
res.status(500).json({ error: "internalerror" });
}
};
export const getLeagues = async (req, res) => {
try {
const { authcode: token } = req.headers;
const { clubid: clubId } = req.params;
const { seasonid: seasonId } = req.query;
const user = await getUserByToken(token);
const leagues = await ClubTeamService.getLeaguesByClub(clubId, seasonId);
res.status(200).json(leagues);
} catch (error) {
console.error('[getLeagues] - Error:', error);
res.status(500).json({ error: "internalerror" });
}
};

View File

@@ -4,8 +4,11 @@ import { devLog } from '../utils/logger.js';
export const getClubs = async (req, res) => {
try {
devLog('[getClubs] - get clubs');
const clubs = await ClubService.getAllClubs();
devLog('[getClubs] - prepare response');
res.status(200).json(clubs);
devLog('[getClubs] - done');
} catch (error) {
console.error('[getClubs] - error:', error);
res.status(500).json({ error: "internalerror" });
@@ -13,20 +16,28 @@ export const getClubs = async (req, res) => {
};
export const addClub = async (req, res) => {
devLog('[addClub] - Read out parameters');
const { authcode: token } = req.headers;
const { name: clubName } = req.body;
try {
devLog('[addClub] - find club by name');
const club = await ClubService.findClubByName(clubName);
devLog('[addClub] - get user');
const user = await getUserByToken(token);
devLog('[addClub] - check if club already exists');
if (club) {
res.status(409).json({ error: "alreadyexists" });
return;
}
devLog('[addClub] - create club');
const newClub = await ClubService.createClub(clubName);
await ClubService.addUserToClub(user.id, newClub.id, true); // true = isOwner
devLog('[addClub] - add user to new club');
await ClubService.addUserToClub(user.id, newClub.id);
devLog('[addClub] - prepare response');
res.status(200).json(newClub);
devLog('[addClub] - done');
} catch (error) {
console.error('[addClub] - error:', error);
res.status(500).json({ error: "internalerror" });
@@ -34,53 +45,43 @@ export const addClub = async (req, res) => {
};
export const getClub = async (req, res) => {
devLog('[getClub] - start');
try {
const { authcode: token } = req.headers;
const { clubid: clubId } = req.params;
devLog('[getClub] - get user');
const user = await getUserByToken(token);
devLog('[getClub] - get users club');
const access = await ClubService.getUserClubAccess(user.id, clubId);
devLog('[getClub] - check access');
if (access.length === 0 || !access[0].approved) {
res.status(403).json({ error: "noaccess", status: access.length === 0 ? "notrequested" : "requested" });
return;
}
devLog('[getClub] - get club');
const club = await ClubService.findClubById(clubId);
devLog('[getClub] - check club exists');
if (!club) {
return res.status(404).json({ message: 'Club not found' });
}
devLog('[getClub] - set response');
res.status(200).json(club);
devLog('[getClub] - done');
} catch (error) {
console.error('[getClub] - error:', error);
res.status(500).json({ message: 'Server error' });
}
};
export const updateClubSettings = async (req, res) => {
try {
const { authcode: token } = req.headers;
const { clubid } = req.params;
const { greetingText, associationMemberNumber } = req.body;
const updated = await ClubService.updateClubSettings(token, clubid, { greetingText, associationMemberNumber });
res.status(200).json(updated);
} catch (error) {
if (error.message === 'noaccess') {
res.status(403).json({ error: 'noaccess' });
} else if (error.message === 'clubnotfound') {
res.status(404).json({ error: 'clubnotfound' });
} else {
console.error('[updateClubSettings] - error:', error);
res.status(500).json({ error: 'internalerror' });
}
}
};
export const requestClubAccess = async (req, res) => {
const { authcode: token } = req.headers;
const { clubid: clubId } = req.params;
try {
const user = await getUserByToken(token);
devLog('[requestClubAccess] - user:', user);
await ClubService.requestAccessToClub(user.id, clubId);
res.status(200).json({});

View File

@@ -1,7 +1,7 @@
import diaryService from '../services/diaryService.js';
import HttpError from '../exceptions/HttpError.js';
import { devLog } from '../utils/logger.js';
import { emitDiaryDateUpdated, emitDiaryTagAdded, emitDiaryTagRemoved } from '../services/socketService.js';
const getDatesForClub = async (req, res) => {
try {
const { clubId } = req.params;
@@ -43,10 +43,6 @@ const updateTrainingTimes = async (req, res) => {
throw new HttpError('notallfieldsfilled', 400);
}
const updatedDate = await diaryService.updateTrainingTimes(userToken, clubId, dateId, trainingStart, trainingEnd);
// Emit Socket-Event
emitDiaryDateUpdated(clubId, dateId, { trainingStart, trainingEnd });
res.status(200).json(updatedDate);
} catch (error) {
console.error('[updateTrainingTimes] - Error:', error);
@@ -83,14 +79,6 @@ const addDiaryTag = async (req, res) => {
const { authcode: userToken } = req.headers;
const { diaryDateId, tagName } = req.body;
const tags = await diaryService.addTagToDate(userToken, diaryDateId, tagName);
// Hole clubId für Event
const { DiaryDate } = await import('../models/index.js');
const diaryDate = await DiaryDate.findByPk(diaryDateId);
if (diaryDate?.clubId && tags && tags.length > 0) {
emitDiaryTagAdded(diaryDate.clubId, diaryDateId, tags[tags.length - 1]);
}
res.status(201).json(tags);
} catch (error) {
console.error('[addDiaryTag] - Error:', error);
@@ -107,12 +95,6 @@ const addTagToDiaryDate = async (req, res) => {
return res.status(400).json({ message: 'diaryDateId and tagId are required.' });
}
const result = await diaryService.addTagToDiaryDate(userToken, clubId, diaryDateId, tagId);
// Emit Socket-Event
if (result && result.tag) {
emitDiaryTagAdded(clubId, diaryDateId, result.tag);
}
res.status(200).json(result);
} catch (error) {
console.error('[addTagToDiaryDate] - Error:', error);
@@ -124,20 +106,8 @@ const deleteTagFromDiaryDate = async (req, res) => {
try {
const { tagId } = req.query;
const { authcode: userToken } = req.headers;
const { clubId } = req.params;
// Hole diaryDateId vor dem Löschen
const { DiaryDateTag } = await import('../models/index.js');
const diaryDateTag = await DiaryDateTag.findByPk(tagId);
const diaryDateId = diaryDateTag?.diaryDateId;
const { clubId } = req.params;
await diaryService.removeTagFromDiaryDate(userToken, clubId, tagId);
// Emit Socket-Event
if (diaryDateId) {
emitDiaryTagRemoved(clubId, diaryDateId, tagId);
}
res.status(200).json({ message: 'Tag deleted' });
} catch (error) {
console.error('[deleteTag] - Error:', error);

View File

@@ -1,6 +1,4 @@
import diaryDateActivityService from '../services/diaryDateActivityService.js';
import { emitActivityChanged } from '../services/socketService.js';
import DiaryDate from '../models/DiaryDates.js';
import { devLog } from '../utils/logger.js';
export const createDiaryDateActivity = async (req, res) => {
@@ -16,13 +14,6 @@ export const createDiaryDateActivity = async (req, res) => {
orderId,
isTimeblock,
});
// Emit Socket-Event
const diaryDate = await DiaryDate.findByPk(diaryDateId);
if (diaryDate?.clubId) {
emitActivityChanged(diaryDate.clubId, diaryDateId);
}
res.status(201).json(activityItem);
} catch (error) {
devLog(error);
@@ -43,15 +34,6 @@ export const updateDiaryDateActivity = async (req, res) => {
orderId,
groupId, // Pass groupId to the service
});
// Emit Socket-Event
if (updatedActivity?.diaryDateId) {
const diaryDate = await DiaryDate.findByPk(updatedActivity.diaryDateId);
if (diaryDate?.clubId) {
emitActivityChanged(diaryDate.clubId, updatedActivity.diaryDateId);
}
}
res.status(200).json(updatedActivity);
} catch (error) {
res.status(500).json({ error: 'Error updating activity' });
@@ -62,22 +44,7 @@ export const deleteDiaryDateActivity = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId, id } = req.params;
// Hole diaryDateId vor dem Löschen
const DiaryDateActivity = (await import('../models/DiaryDateActivity.js')).default;
const activity = await DiaryDateActivity.findByPk(id);
const diaryDateId = activity?.diaryDateId;
await diaryDateActivityService.deleteActivity(userToken, clubId, id);
// Emit Socket-Event
if (diaryDateId) {
const diaryDate = await DiaryDate.findByPk(diaryDateId);
if (diaryDate?.clubId) {
emitActivityChanged(diaryDate.clubId, diaryDateId);
}
}
res.status(200).json({ message: 'Activity deleted' });
} catch (error) {
res.status(500).json({ error: 'Error deleting activity' });
@@ -90,15 +57,6 @@ export const updateDiaryDateActivityOrder = async (req, res) => {
const { clubId, id } = req.params;
const { orderId } = req.body;
const updatedActivity = await diaryDateActivityService.updateActivityOrder(userToken, clubId, id, orderId);
// Emit Socket-Event
if (updatedActivity?.diaryDateId) {
const diaryDate = await DiaryDate.findByPk(updatedActivity.diaryDateId);
if (diaryDate?.clubId) {
emitActivityChanged(diaryDate.clubId, updatedActivity.diaryDateId);
}
}
res.status(200).json(updatedActivity);
} catch (error) {
devLog(error);
@@ -121,80 +79,11 @@ export const getDiaryDateActivities = async (req, res) => {
export const addGroupActivity = async(req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId, diaryDateId, groupId, activity, predefinedActivityId, timeblockId } = req.body;
const activityItem = await diaryDateActivityService.addGroupActivity(userToken, clubId, diaryDateId, groupId, activity, predefinedActivityId, timeblockId);
// Emit Socket-Event
const diaryDate = await DiaryDate.findByPk(diaryDateId);
if (diaryDate?.clubId) {
emitActivityChanged(diaryDate.clubId, diaryDateId);
}
const { clubId, diaryDateId, groupId, activity } = req.body;
const activityItem = await diaryDateActivityService.addGroupActivity(userToken, clubId, diaryDateId, groupId, activity);
res.status(201).json(activityItem);
} catch (error) {
devLog(error);
res.status(500).json({ error: 'Error adding group activity' });
}
}
export const updateGroupActivity = async(req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId, groupActivityId } = req.params;
const { predefinedActivityId } = req.body;
const activityItem = await diaryDateActivityService.updateGroupActivity(userToken, clubId, groupActivityId, predefinedActivityId);
// Emit Socket-Event
const GroupActivity = (await import('../models/GroupActivity.js')).default;
const DiaryDateActivity = (await import('../models/DiaryDateActivity.js')).default;
const groupActivity = await GroupActivity.findByPk(groupActivityId);
let diaryDateId = null;
if (groupActivity?.diaryDateActivity) {
const activity = await DiaryDateActivity.findByPk(groupActivity.diaryDateActivity);
diaryDateId = activity?.diaryDateId;
}
if (diaryDateId) {
const diaryDate = await DiaryDate.findByPk(diaryDateId);
if (diaryDate?.clubId) {
emitActivityChanged(diaryDate.clubId, diaryDateId);
}
}
res.status(200).json(activityItem);
} catch (error) {
devLog(error);
res.status(500).json({ error: 'Error updating group activity' });
}
}
export const deleteGroupActivity = async(req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId, groupActivityId } = req.params;
// Hole diaryDateId vor dem Löschen
const GroupActivity = (await import('../models/GroupActivity.js')).default;
const DiaryDateActivity = (await import('../models/DiaryDateActivity.js')).default;
const groupActivity = await GroupActivity.findByPk(groupActivityId);
let diaryDateId = null;
if (groupActivity?.diaryDateActivity) {
const activity = await DiaryDateActivity.findByPk(groupActivity.diaryDateActivity);
diaryDateId = activity?.diaryDateId;
}
await diaryDateActivityService.deleteGroupActivity(userToken, clubId, groupActivityId);
// Emit Socket-Event
if (diaryDateId) {
const diaryDate = await DiaryDate.findByPk(diaryDateId);
if (diaryDate?.clubId) {
emitActivityChanged(diaryDate.clubId, diaryDateId);
}
}
res.status(200).json({ message: 'Group activity deleted' });
} catch (error) {
devLog(error);
res.status(500).json({ error: 'Error deleting group activity' });
}
}

View File

@@ -1,9 +1,6 @@
import DiaryMemberActivity from '../models/DiaryMemberActivity.js';
import DiaryDateActivity from '../models/DiaryDateActivity.js';
import DiaryDates from '../models/DiaryDates.js';
import Participant from '../models/Participant.js';
import { checkAccess } from '../utils/userUtils.js';
import { emitActivityMemberAdded, emitActivityMemberRemoved } from '../services/socketService.js';
export const getMembersForActivity = async (req, res) => {
try {
@@ -22,44 +19,20 @@ export const addMembersToActivity = async (req, res) => {
const { authcode: userToken } = req.headers;
const { clubId, diaryDateActivityId } = req.params;
const { participantIds } = req.body; // array of participant ids
await checkAccess(userToken, clubId);
if (!participantIds || !Array.isArray(participantIds)) {
console.error('[addMembersToActivity] Invalid participantIds:', participantIds);
return res.status(400).json({ error: 'participantIds must be an array' });
}
const validParticipants = await Participant.findAll({ where: { id: participantIds } });
const validIds = new Set(validParticipants.map(p => p.id));
const created = [];
// Hole clubId und dateId für Events (falls nicht aus params verfügbar)
const activity = await DiaryDateActivity.findByPk(diaryDateActivityId);
const diaryDate = activity ? await DiaryDates.findByPk(activity.diaryDateId) : null;
const eventClubId = diaryDate?.clubId || clubId;
const dateId = diaryDate?.id || null;
for (const pid of participantIds) {
if (!validIds.has(pid)) {
continue;
}
if (!validIds.has(pid)) continue;
const existing = await DiaryMemberActivity.findOne({ where: { diaryDateActivityId, participantId: pid } });
if (!existing) {
const rec = await DiaryMemberActivity.create({ diaryDateActivityId, participantId: pid });
created.push(rec);
// Emit Socket-Event
if (eventClubId && dateId) {
emitActivityMemberAdded(eventClubId, diaryDateActivityId, pid, dateId);
}
} else {
}
}
res.status(201).json(created);
} catch (e) {
console.error('[addMembersToActivity] Error:', e);
res.status(500).json({ error: 'Error adding members to activity' });
}
};
@@ -69,19 +42,7 @@ export const removeMemberFromActivity = async (req, res) => {
const { authcode: userToken } = req.headers;
const { clubId, diaryDateActivityId, participantId } = req.params;
await checkAccess(userToken, clubId);
// Hole dateId für Event
const activity = await DiaryDateActivity.findByPk(diaryDateActivityId);
const diaryDate = activity ? await DiaryDates.findByPk(activity.diaryDateId) : null;
const dateId = diaryDate?.id || null;
await DiaryMemberActivity.destroy({ where: { diaryDateActivityId, participantId } });
// Emit Socket-Event
if (dateId) {
emitActivityMemberRemoved(clubId, diaryDateActivityId, participantId, dateId);
}
res.status(200).json({ ok: true });
} catch (e) {
res.status(500).json({ error: 'Error removing member from activity' });

View File

@@ -1,6 +1,5 @@
import { DiaryNote, DiaryTag, DiaryDate } from '../models/index.js';
import { DiaryNote, DiaryTag } from '../models/index.js';
import diaryService from '../services/diaryService.js';
import { emitDiaryNoteAdded, emitDiaryNoteDeleted } from '../services/socketService.js';
export const getNotes = async (req, res) => {
try {
@@ -19,41 +18,17 @@ export const getNotes = async (req, res) => {
export const createNote = async (req, res) => {
try {
const { memberId, diaryDateId, content, tags } = req.body;
if (!memberId || !diaryDateId || !content) {
return res.status(400).json({ error: 'memberId, diaryDateId und content sind erforderlich.' });
}
const newNote = await DiaryNote.create({ memberId, diaryDateId, content });
// Hole DiaryDate für clubId
const diaryDate = await DiaryDate.findByPk(diaryDateId);
if (Array.isArray(tags) && tags.length > 0 && typeof newNote.addTags === 'function') {
const { memberId, content, tags } = req.body;
const newNote = await DiaryNote.create({ memberId, content });
if (tags && tags.length > 0) {
const tagInstances = await DiaryTag.findAll({ where: { id: tags } });
await newNote.addTags(tagInstances);
const noteWithTags = await DiaryNote.findByPk(newNote.id, {
include: [{ model: DiaryTag, as: 'tags', required: false }],
});
// Emit Socket-Event
if (diaryDate?.clubId) {
emitDiaryNoteAdded(diaryDate.clubId, diaryDateId, noteWithTags ?? newNote);
}
return res.status(201).json(noteWithTags ?? newNote);
}
// Emit Socket-Event
if (diaryDate?.clubId) {
emitDiaryNoteAdded(diaryDate.clubId, diaryDateId, newNote);
}
res.status(201).json(newNote);
const noteWithTags = await DiaryNote.findByPk(newNote.id, {
include: [{ model: DiaryTag, as: 'tags' }],
});
res.status(201).json(noteWithTags);
} catch (error) {
console.error('[createNote] - Error:', error);
res.status(500).json({ error: 'Error creating note' });
}
};
@@ -61,25 +36,7 @@ export const createNote = async (req, res) => {
export const deleteNote = async (req, res) => {
try {
const { noteId } = req.params;
// Hole Note für diaryDateId vor dem Löschen
const note = await DiaryNote.findByPk(noteId);
const diaryDateId = note?.diaryDateId;
// Hole DiaryDate für clubId
let clubId = null;
if (diaryDateId) {
const diaryDate = await DiaryDate.findByPk(diaryDateId);
clubId = diaryDate?.clubId;
}
await DiaryNote.destroy({ where: { id: noteId } });
// Emit Socket-Event
if (clubId && diaryDateId) {
emitDiaryNoteDeleted(clubId, diaryDateId, noteId);
}
res.status(200).json({ message: 'Note deleted' });
} catch (error) {
res.status(500).json({ error: 'Error deleting note' });

View File

@@ -1,5 +1,6 @@
import { DiaryTag, DiaryDateTag } from '../models/index.js';
import { devLog } from '../utils/logger.js';
export const getTags = async (req, res) => {
try {
const tags = await DiaryTag.findAll();
@@ -12,13 +13,11 @@ export const getTags = async (req, res) => {
export const createTag = async (req, res) => {
try {
const { name } = req.body;
if (!name) {
return res.status(400).json({ error: 'Der Name des Tags ist erforderlich.' });
}
const [tag, created] = await DiaryTag.findOrCreate({ where: { name }, defaults: { name } });
res.status(created ? 201 : 200).json(tag);
devLog(name);
const newTag = await DiaryTag.findOrCreate({ where: { name }, defaults: { name } });
res.status(201).json(newTag);
} catch (error) {
devLog('[createTag] - Error:', error);
res.status(500).json({ error: 'Error creating tag' });
}
};
@@ -26,14 +25,9 @@ export const createTag = async (req, res) => {
export const deleteTag = async (req, res) => {
try {
const { tagId } = req.params;
await DiaryDateTag.destroy({ where: { tagId } });
const deleted = await DiaryTag.destroy({ where: { id: tagId } });
if (!deleted) {
return res.status(404).json({ error: 'Tag nicht gefunden' });
}
const { authcode: userToken } = req.headers;
const { clubId } = req.params;
await diaryService.removeTagFromDiaryDate(userToken, clubId, tagId);
res.status(200).json({ message: 'Tag deleted' });
} catch (error) {
console.error('[deleteTag] - Error:', error);

View File

@@ -0,0 +1,172 @@
import externalServiceService from '../services/externalServiceService.js';
import HttpError from '../exceptions/HttpError.js';
class ExternalServiceController {
/**
* GET /api/mytischtennis/account?service=mytischtennis
* Get current user's external service account
*/
async getAccount(req, res, next) {
try {
const userId = req.user.id;
const service = req.query.service || 'mytischtennis';
const account = await externalServiceService.getAccount(userId, service);
if (!account) {
return res.status(200).json({ account: null });
}
res.status(200).json({ account });
} catch (error) {
next(error);
}
}
/**
* GET /api/mytischtennis/status?service=mytischtennis
* Check account configuration status
*/
async getStatus(req, res, next) {
try {
const userId = req.user.id;
const service = req.query.service || 'mytischtennis';
const status = await externalServiceService.checkAccountStatus(userId, service);
res.status(200).json(status);
} catch (error) {
next(error);
}
}
/**
* POST /api/mytischtennis/account
* Create or update external service account
*/
async upsertAccount(req, res, next) {
try {
const userId = req.user.id;
const { email, password, savePassword, userPassword, service = 'mytischtennis' } = req.body;
if (!email) {
throw new HttpError(400, 'E-Mail-Adresse erforderlich');
}
// Wenn ein Passwort gesetzt wird, muss das App-Passwort angegeben werden
if (password && !userPassword) {
throw new HttpError(400, 'App-Passwort erforderlich zum Setzen des myTischtennis-Passworts');
}
const account = await externalServiceService.upsertAccount(
userId,
email,
password,
savePassword || false,
userPassword,
service
);
res.status(200).json({
message: `${service}-Account erfolgreich gespeichert`,
account
});
} catch (error) {
next(error);
}
}
/**
* DELETE /api/mytischtennis/account?service=mytischtennis
* Delete external service account
*/
async deleteAccount(req, res, next) {
try {
const userId = req.user.id;
const service = req.query.service || 'mytischtennis';
const deleted = await externalServiceService.deleteAccount(userId, service);
if (!deleted) {
throw new HttpError(404, `Kein ${service}-Account gefunden`);
}
res.status(200).json({ message: `${service}-Account gelöscht` });
} catch (error) {
next(error);
}
}
/**
* POST /api/mytischtennis/verify
* Verify login credentials
*/
async verifyLogin(req, res, next) {
try {
const userId = req.user.id;
const { password, service = 'mytischtennis' } = req.body;
const result = await externalServiceService.verifyLogin(userId, password, service);
res.status(200).json({
message: 'Login erfolgreich',
success: true,
accessToken: result.accessToken,
expiresAt: result.expiresAt,
clubId: result.clubId,
clubName: result.clubName
});
} catch (error) {
next(error);
}
}
/**
* GET /api/mytischtennis/session?service=mytischtennis
* Get stored session data for authenticated requests
*/
async getSession(req, res, next) {
try {
const userId = req.user.id;
const service = req.query.service || 'mytischtennis';
const session = await externalServiceService.getSession(userId, service);
res.status(200).json({ session });
} catch (error) {
next(error);
}
}
/**
* GET /api/external-service/hettv/main-page
* Load HeTTV main page and find download links
*/
async loadHettvMainPage(req, res, next) {
try {
const userId = req.user.id;
const result = await externalServiceService.loadHettvMainPage(userId);
res.status(200).json(result);
} catch (error) {
next(error);
}
}
/**
* POST /api/external-service/hettv/download-page
* Load specific HeTTV download page
*/
async loadHettvDownloadPage(req, res, next) {
try {
const userId = req.user.id;
const { downloadUrl } = req.body;
if (!downloadUrl) {
throw new HttpError(400, 'Download-URL ist erforderlich');
}
const result = await externalServiceService.loadHettvDownloadPage(userId, downloadUrl);
res.status(200).json(result);
} catch (error) {
next(error);
}
}
}
export default new ExternalServiceController();

View File

@@ -1,7 +1,5 @@
import HttpError from '../exceptions/HttpError.js';
import groupService from '../services/groupService.js';
import { emitActivityChanged, emitGroupChanged } from '../services/socketService.js';
import DiaryDate from '../models/DiaryDates.js';
import { devLog } from '../utils/logger.js';
const addGroup = async(req, res) => {
@@ -9,15 +7,6 @@ const addGroup = async(req, res) => {
const { authcode: userToken } = req.headers;
const { clubid: clubId, dateid: dateId, name, lead } = req.body;
const result = await groupService.addGroup(userToken, clubId, dateId, name, lead);
// Emit Socket-Event für Gruppen-Änderungen
if (dateId) {
const diaryDate = await DiaryDate.findByPk(dateId);
if (diaryDate?.clubId) {
emitGroupChanged(diaryDate.clubId, dateId);
}
}
res.status(201).json(result);
} catch (error) {
console.error('[addGroup] - Error:', error);
@@ -44,15 +33,6 @@ const changeGroup = async(req, res) => {
const { groupId } = req.params;
const { clubid: clubId, dateid: dateId, name, lead } = req.body;
const result = await groupService.changeGroup(userToken, groupId, clubId, dateId, name, lead);
// Emit Socket-Event für Gruppen-Änderungen
if (dateId) {
const diaryDate = await DiaryDate.findByPk(dateId);
if (diaryDate?.clubId) {
emitGroupChanged(diaryDate.clubId, dateId);
}
}
res.status(200).json(result);
} catch (error) {
console.error('[changeGroup] - Error:', error);
@@ -60,27 +40,4 @@ const changeGroup = async(req, res) => {
}
}
const deleteGroup = async(req, res) => {
try {
const { authcode: userToken } = req.headers;
const { groupId } = req.params;
const { clubid: clubId, dateid: dateId } = req.body;
const result = await groupService.deleteGroup(userToken, groupId, clubId, dateId);
// Emit Socket-Events für Gruppen- und Aktivitäts-Änderungen (Gruppen werden in Aktivitäten verwendet)
if (dateId) {
const diaryDate = await DiaryDate.findByPk(dateId);
if (diaryDate?.clubId) {
emitGroupChanged(diaryDate.clubId, dateId);
emitActivityChanged(diaryDate.clubId, dateId);
}
}
res.status(200).json(result);
} catch (error) {
console.error('[deleteGroup] - Error:', error);
res.status(error.statusCode || 500).json({ error: error.message });
}
}
export { addGroup, getGroups, changeGroup, deleteGroup};
export { addGroup, getGroups, changeGroup};

View File

@@ -25,8 +25,7 @@ export const getLeaguesForCurrentSeason = async (req, res) => {
devLog(req.headers, req.params);
const { authcode: userToken } = req.headers;
const { clubId } = req.params;
const { seasonid: seasonId } = req.query;
const leagues = await MatchService.getLeaguesForCurrentSeason(userToken, clubId, seasonId);
const leagues = await MatchService.getLeaguesForCurrentSeason(userToken, clubId);
return res.status(200).json(leagues);
} catch (error) {
console.error('Error retrieving leagues:', error);
@@ -38,8 +37,7 @@ export const getMatchesForLeagues = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId } = req.params;
const { seasonid: seasonId } = req.query;
const matches = await MatchService.getMatchesForLeagues(userToken, clubId, seasonId);
const matches = await MatchService.getMatchesForLeagues(userToken, clubId);
return res.status(200).json(matches);
} catch (error) {
console.error('Error retrieving matches:', error);
@@ -58,90 +56,3 @@ export const getMatchesForLeague = async (req, res) => {
return res.status(500).json({ error: 'Failed to retrieve matches' });
}
};
export const getLeagueTable = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId, leagueId } = req.params;
const table = await MatchService.getLeagueTable(userToken, clubId, leagueId);
return res.status(200).json(table);
} catch (error) {
console.error('Error retrieving league table:', error);
return res.status(500).json({ error: 'Failed to retrieve league table' });
}
};
export const fetchLeagueTableFromMyTischtennis = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId, leagueId } = req.params;
const { userid: userIdOrEmail } = req.headers;
// Convert email to userId if needed
let userId = userIdOrEmail;
if (isNaN(userIdOrEmail)) {
const User = (await import('../models/User.js')).default;
const user = await User.findOne({ where: { email: userIdOrEmail } });
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
userId = user.id;
}
const autoFetchService = (await import('../services/autoFetchMatchResultsService.js')).default;
await autoFetchService.fetchAndUpdateLeagueTable(userId, leagueId);
// Return updated table data
const table = await MatchService.getLeagueTable(userToken, clubId, leagueId);
return res.status(200).json({
message: 'League table updated from MyTischtennis',
data: table
});
} catch (error) {
console.error('Error fetching league table from MyTischtennis:', error);
return res.status(500).json({ error: 'Failed to fetch league table from MyTischtennis' });
}
};
export const updateMatchPlayers = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { matchId } = req.params;
const { playersReady, playersPlanned, playersPlayed } = req.body;
const result = await MatchService.updateMatchPlayers(
userToken,
matchId,
playersReady,
playersPlanned,
playersPlayed
);
return res.status(200).json({
message: 'Match players updated successfully',
data: result
});
} catch (error) {
console.error('Error updating match players:', error);
return res.status(error.statusCode || 500).json({
error: error.message || 'Failed to update match players'
});
}
};
export const getPlayerMatchStats = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId, leagueId } = req.params;
const { seasonid: seasonId } = req.query;
const stats = await MatchService.getPlayerMatchStats(userToken, clubId, leagueId, seasonId);
return res.status(200).json(stats);
} catch (error) {
console.error('Error retrieving player match stats:', error);
return res.status(error.statusCode || 500).json({
error: error.message || 'Failed to retrieve player match stats'
});
}
};

View File

@@ -1,531 +0,0 @@
import { checkAccess } from '../utils/userUtils.js';
import DiaryMemberActivity from '../models/DiaryMemberActivity.js';
import DiaryDateActivity from '../models/DiaryDateActivity.js';
import DiaryDates from '../models/DiaryDates.js';
import Participant from '../models/Participant.js';
import PredefinedActivity from '../models/PredefinedActivity.js';
import GroupActivity from '../models/GroupActivity.js';
import { Op } from 'sequelize';
export const getMemberActivities = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId, memberId } = req.params;
const { period } = req.query; // 'month', '3months', '6months', 'year', 'all'
await checkAccess(userToken, clubId);
// Calculate date range based on period
const now = new Date();
let startDate = null;
switch (period) {
case 'month':
startDate = new Date(now.getFullYear(), now.getMonth() - 1, now.getDate());
break;
case '3months':
startDate = new Date(now.getFullYear(), now.getMonth() - 3, now.getDate());
break;
case '6months':
startDate = new Date(now.getFullYear(), now.getMonth() - 6, now.getDate());
break;
case 'year':
startDate = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
break;
case 'all':
default:
startDate = null;
break;
}
// Get participant ID for this member
const participants = await Participant.findAll({
where: { memberId: memberId }
});
if (participants.length === 0) {
return res.status(200).json([]);
}
const participantIds = participants.map(p => p.id);
// Sammle alle Gruppen-IDs, zu denen der Member gehört
const memberGroupIds = new Set();
participants.forEach(p => {
if (p.groupId !== null && p.groupId !== undefined) {
memberGroupIds.add(p.groupId);
}
});
// 1. Get all diary member activities explicitly assigned to this member
const memberActivities = await DiaryMemberActivity.findAll({
where: {
participantId: participantIds
},
include: [
{
model: Participant,
as: 'participant',
attributes: ['id', 'groupId', 'diaryDateId']
},
{
model: DiaryDateActivity,
as: 'activity',
include: [
{
model: DiaryDates,
as: 'diaryDate',
where: startDate ? {
date: {
[Op.gte]: startDate
}
} : {}
},
{
model: PredefinedActivity,
as: 'predefinedActivity'
},
{
model: GroupActivity,
as: 'groupActivities',
required: false
}
]
}
],
order: [[{ model: DiaryDateActivity, as: 'activity' }, { model: DiaryDates, as: 'diaryDate' }, 'date', 'DESC']]
});
// 2. Get all group activities for groups the member belongs to
const groupActivities = [];
if (memberGroupIds.size > 0) {
// Suche direkt nach GroupActivity-Einträgen für die Gruppen des Members
const groupActivitiesData = await GroupActivity.findAll({
where: {
groupId: {
[Op.in]: Array.from(memberGroupIds)
}
},
include: [
{
model: DiaryDateActivity,
as: 'activityGroupActivity',
include: [
{
model: DiaryDates,
as: 'diaryDate',
where: startDate ? {
date: {
[Op.gte]: startDate
}
} : {}
},
{
model: PredefinedActivity,
as: 'predefinedActivity',
required: false
}
]
},
{
model: PredefinedActivity,
as: 'groupPredefinedActivity',
required: false
}
]
});
// Erstelle virtuelle DiaryMemberActivity-Objekte für Gruppen-Aktivitäten
for (const groupActivity of groupActivitiesData) {
if (!groupActivity.activityGroupActivity || !groupActivity.activityGroupActivity.diaryDate) {
continue; // Überspringe, wenn keine DiaryDateActivity oder kein DiaryDate vorhanden
}
const activity = groupActivity.activityGroupActivity;
const diaryDateId = activity.diaryDateId;
// Finde alle relevanten Participants für dieses DiaryDate
const relevantParticipants = participants.filter(p =>
p.diaryDateId === diaryDateId &&
p.groupId === groupActivity.groupId
);
for (const participant of relevantParticipants) {
// Verwende die PredefinedActivity aus GroupActivity, falls vorhanden
// Sonst die aus DiaryDateActivity
const predefinedActivity = groupActivity.groupPredefinedActivity || activity.predefinedActivity;
if (predefinedActivity) {
// Erstelle ein modifiziertes Activity-Objekt
const modifiedActivity = {
...activity.toJSON(),
predefinedActivity: predefinedActivity
};
groupActivities.push({
activity: modifiedActivity,
participant: participant,
id: null // Virtuell, nicht in DB
});
}
}
}
}
// Filter: explizite Zuordnungen sollen nur dann zählen, wenn
// - der Participant keine Gruppe hat UND die Aktivität KEINE Gruppenbindung hat, oder
// - die Aktivität keine Gruppenbindung hat, oder
// - es eine Gruppenbindung gibt, die zur Gruppe des Participants passt.
const filteredMemberActivities = memberActivities.filter((ma) => {
if (!ma?.participant || !ma?.activity) {
return false;
}
const participantGroupId = ma.participant.groupId;
const groupActivitiesForActivity = ma.activity.groupActivities || [];
// Participant ohne Gruppe -> nur Aktivitäten ohne Gruppenbindung zählen
if (participantGroupId === null || participantGroupId === undefined) {
return !groupActivitiesForActivity.length;
}
// Keine Gruppenbindung -> immer zählen
if (!groupActivitiesForActivity.length) {
return true;
}
// Gruppenbindung vorhanden -> nur zählen, wenn die Gruppe passt
return groupActivitiesForActivity.some((ga) => Number(ga.groupId) === Number(participantGroupId));
});
// 3. Kombiniere beide Listen und entferne Duplikate
// Ein Duplikat liegt vor, wenn dieselbe Aktivität für denselben Participant bereits explizit zugeordnet ist
const explicitActivityKeys = new Set();
filteredMemberActivities.forEach(ma => {
if (ma.activity && ma.activity.id && ma.participant && ma.participant.id) {
// Erstelle einen eindeutigen Schlüssel: activityId-participantId
const key = `${ma.activity.id}-${ma.participant.id}`;
explicitActivityKeys.add(key);
}
});
// Filtere Gruppen-Aktivitäten, die bereits explizit zugeordnet sind
const uniqueGroupActivities = groupActivities.filter(ga => {
if (!ga.activity || !ga.activity.id || !ga.participant || !ga.participant.id) {
return false;
}
const key = `${ga.activity.id}-${ga.participant.id}`;
return !explicitActivityKeys.has(key);
});
// Kombiniere beide Listen
const allActivities = [...filteredMemberActivities, ...uniqueGroupActivities];
// Group activities by name and count occurrences
// Verwende einen Set pro Aktivität, um eindeutige Datum-Aktivität-Kombinationen zu tracken
const activityMap = new Map();
for (const ma of allActivities) {
if (!ma.activity || !ma.activity.predefinedActivity || !ma.participant) {
continue;
}
const activity = ma.activity.predefinedActivity;
const activityName = activity.name;
const activityCode = activity.code || activity.name; // Verwende Code falls vorhanden, sonst Name
const date = ma.activity.diaryDate?.date;
if (!date) {
continue; // Überspringe Einträge ohne Datum
}
// Verwende Code als Key, falls vorhanden, sonst Name
const key = activityCode;
if (!activityMap.has(key)) {
activityMap.set(key, {
name: activityName, // Vollständiger Name für Tooltip
code: activityCode, // Code/Kürzel für Anzeige
uniqueDates: new Set(), // Set für eindeutige Daten
dates: []
});
}
const activityData = activityMap.get(key);
// Konvertiere Datum zu String für Set-Vergleich (nur Datum, keine Zeit)
const dateString = date instanceof Date
? date.toISOString().split('T')[0]
: new Date(date).toISOString().split('T')[0];
// Füge Datum nur hinzu, wenn es noch nicht vorhanden ist
if (!activityData.uniqueDates.has(dateString)) {
activityData.uniqueDates.add(dateString);
activityData.dates.push(date);
}
}
// Konvertiere Sets zu Arrays und setze count basierend auf eindeutigen Daten
activityMap.forEach((activityData, key) => {
activityData.count = activityData.uniqueDates.size;
// Sortiere Daten (neueste zuerst)
activityData.dates.sort((a, b) => {
const dateA = new Date(a);
const dateB = new Date(b);
return dateB - dateA;
});
// Entferne uniqueDates, da es nicht an Frontend gesendet werden muss
delete activityData.uniqueDates;
});
// Convert map to array and sort by count
const activities = Array.from(activityMap.values())
.sort((a, b) => b.count - a.count);
return res.status(200).json(activities);
} catch (error) {
console.error('Error fetching member activities:', error);
return res.status(500).json({ error: 'Failed to fetch member activities' });
}
};
export const getMemberLastParticipations = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId, memberId } = req.params;
const { limit = 3 } = req.query;
await checkAccess(userToken, clubId);
// Get participant ID for this member
const participants = await Participant.findAll({
where: { memberId: memberId }
});
if (participants.length === 0) {
return res.status(200).json([]);
}
const participantIds = participants.map(p => p.id);
// Sammle alle Gruppen-IDs, zu denen der Member gehört
const memberGroupIds = new Set();
participants.forEach(p => {
if (p.groupId !== null && p.groupId !== undefined) {
memberGroupIds.add(p.groupId);
}
});
// 1. Get last participations explicitly assigned to this member
const memberActivities = await DiaryMemberActivity.findAll({
where: {
participantId: participantIds
},
include: [
{
model: Participant,
as: 'participant',
attributes: ['id', 'groupId', 'diaryDateId']
},
{
model: DiaryDateActivity,
as: 'activity',
include: [
{
model: DiaryDates,
as: 'diaryDate'
},
{
model: PredefinedActivity,
as: 'predefinedActivity'
},
{
model: GroupActivity,
as: 'groupActivities',
required: false
}
]
}
],
order: [[{ model: DiaryDateActivity, as: 'activity' }, { model: DiaryDates, as: 'diaryDate' }, 'date', 'DESC']],
limit: parseInt(limit) * 10 // Get more to filter by group
});
// Siehe getMemberActivities(): nur zählen, wenn Gruppenbindung passt (oder keine existiert)
const filteredMemberActivities = memberActivities.filter((ma) => {
if (!ma?.participant || !ma?.activity) {
return false;
}
const participantGroupId = ma.participant.groupId;
const groupActivitiesForActivity = ma.activity.groupActivities || [];
if (!groupActivitiesForActivity.length) {
return true;
}
return groupActivitiesForActivity.some((ga) => Number(ga.groupId) === Number(participantGroupId));
});
// 2. Get all group activities for groups the member belongs to
const groupActivities = [];
if (memberGroupIds.size > 0) {
// Suche direkt nach GroupActivity-Einträgen für die Gruppen des Members
const groupActivitiesData = await GroupActivity.findAll({
where: {
groupId: {
[Op.in]: Array.from(memberGroupIds)
}
},
include: [
{
model: DiaryDateActivity,
as: 'activityGroupActivity',
include: [
{
model: DiaryDates,
as: 'diaryDate'
},
{
model: PredefinedActivity,
as: 'predefinedActivity',
required: false
}
]
},
{
model: PredefinedActivity,
as: 'groupPredefinedActivity',
required: false
}
],
order: [[{ model: DiaryDateActivity, as: 'activityGroupActivity' }, { model: DiaryDates, as: 'diaryDate' }, 'date', 'DESC']],
limit: parseInt(limit) * 10 // Get more to filter
});
// Erstelle virtuelle DiaryMemberActivity-Objekte für Gruppen-Aktivitäten
for (const groupActivity of groupActivitiesData) {
if (!groupActivity.activityGroupActivity || !groupActivity.activityGroupActivity.diaryDate) {
continue; // Überspringe, wenn keine DiaryDateActivity oder kein DiaryDate vorhanden
}
const activity = groupActivity.activityGroupActivity;
const diaryDateId = activity.diaryDateId;
// Finde alle relevanten Participants für dieses DiaryDate
const relevantParticipants = participants.filter(p =>
p.diaryDateId === diaryDateId &&
p.groupId === groupActivity.groupId
);
for (const participant of relevantParticipants) {
// Verwende die PredefinedActivity aus GroupActivity, falls vorhanden
// Sonst die aus DiaryDateActivity
const predefinedActivity = groupActivity.groupPredefinedActivity || activity.predefinedActivity;
if (predefinedActivity) {
// Erstelle ein modifiziertes Activity-Objekt
const modifiedActivity = {
...activity.toJSON(),
predefinedActivity: predefinedActivity
};
groupActivities.push({
activity: modifiedActivity,
participant: participant,
id: null // Virtuell, nicht in DB
});
}
}
}
}
// 3. Kombiniere beide Listen und entferne Duplikate
// Ein Duplikat liegt vor, wenn dieselbe Aktivität für denselben Participant bereits explizit zugeordnet ist
const explicitActivityKeys = new Set();
filteredMemberActivities.forEach(ma => {
if (ma.activity && ma.activity.id && ma.participant && ma.participant.id) {
// Erstelle einen eindeutigen Schlüssel: activityId-participantId
const key = `${ma.activity.id}-${ma.participant.id}`;
explicitActivityKeys.add(key);
}
});
// Filtere Gruppen-Aktivitäten, die bereits explizit zugeordnet sind
const uniqueGroupActivities = groupActivities.filter(ga => {
if (!ga.activity || !ga.activity.id || !ga.participant || !ga.participant.id) {
return false;
}
const key = `${ga.activity.id}-${ga.participant.id}`;
return !explicitActivityKeys.has(key);
});
// Kombiniere beide Listen
const allActivities = [...filteredMemberActivities, ...uniqueGroupActivities];
// Gruppiere nach Datum
const participationsByDate = new Map();
allActivities
.filter(ma => {
if (!ma.activity || !ma.activity.predefinedActivity || !ma.activity.diaryDate || !ma.participant) {
return false;
}
return true;
})
.forEach(ma => {
const date = ma.activity.diaryDate.date;
const diaryDateId = ma.activity.diaryDate.id;
const activity = ma.activity.predefinedActivity;
const activityName = activity.name;
const activityCode = activity.code || activity.name;
if (!participationsByDate.has(date)) {
participationsByDate.set(date, {
date: date,
diaryDateId: diaryDateId,
activities: []
});
}
const dateEntry = participationsByDate.get(date);
// Füge Aktivität nur hinzu, wenn sie noch nicht vorhanden ist (vermeide Duplikate)
// Speichere sowohl code als auch name
const activityEntry = {
code: activityCode,
name: activityName
};
if (!dateEntry.activities.find(a => (a.code || a.name) === activityCode)) {
dateEntry.activities.push(activityEntry);
}
});
// Sortiere nach Datum (neueste zuerst) und nehme die letzten N Daten
const sortedDates = Array.from(participationsByDate.values())
.sort((a, b) => {
const dateA = new Date(a.date);
const dateB = new Date(b.date);
return dateB - dateA;
})
.slice(0, parseInt(limit));
// Formatiere für das Frontend: Flache Liste mit Datum und Aktivität
const participations = [];
sortedDates.forEach(dateEntry => {
dateEntry.activities.forEach(activity => {
participations.push({
id: null, // Virtuell
activityName: activity.code || activity.name, // Code für Anzeige
activityFullName: activity.name, // Vollständiger Name für Tooltip
date: dateEntry.date,
diaryDateId: dateEntry.diaryDateId
});
});
});
return res.status(200).json(participations);
} catch (error) {
console.error('Error fetching member last participations:', error);
return res.status(500).json({ error: 'Failed to fetch member last participations' });
}
};

View File

@@ -1,48 +1,49 @@
import MemberService from "../services/memberService.js";
import MemberTransferService from "../services/memberTransferService.js";
import { emitMemberChanged } from '../services/socketService.js';
import { devLog } from '../utils/logger.js';
const getClubMembers = async(req, res) => {
try {
const { authcode: userToken } = req.headers;
const { id: clubId } = req.params;
const showAll = req.params.showAll ?? 'false';
const { id: clubId, showAll } = req.params;
if (showAll === null) {
showAll = false;
}
res.status(200).json(await MemberService.getClubMembers(userToken, clubId, showAll));
} catch(error) {
devLog('[getClubMembers] - Error: ', error);
res.status(500).json({ error: 'systemerror' });
}
}
const getWaitingApprovals = async(req, res) => {
try {
devLog('[getWaitingApprovals] - Start');
const { id: clubId } = req.params;
devLog('[getWaitingApprovals] - get token');
const { authcode: userToken } = req.headers;
devLog('[getWaitingApprovals] - load for waiting approvals');
const waitingApprovals = await MemberService.getApprovalRequests(userToken, clubId);
devLog('[getWaitingApprovals] - set response');
res.status(200).json(waitingApprovals);
devLog('[getWaitingApprovals] - done');
} catch(error) {
devLog('[getWaitingApprovals] - Error: ', error);
res.status(403).json({ error: error });
}
}
const setClubMembers = async (req, res) => {
try {
const { id: memberId, firstname: firstName, lastname: lastName, street, city, postalCode, birthdate, phone, email, active,
testMembership, picsInInternetAllowed, gender, ttr, qttr, memberFormHandedOver, contacts } = req.body;
const { id: memberId, firstname: firstName, lastname: lastName, street, city, birthdate, phone, email, active,
testMembership, picsInInternetAllowed, gender, ttr, qttr } = req.body;
const { id: clubId } = req.params;
const { authcode: userToken } = req.headers;
const addResult = await MemberService.setClubMember(userToken, clubId, memberId, firstName, lastName, street, city, postalCode, birthdate,
phone, email, active, testMembership, picsInInternetAllowed, gender, ttr, qttr, memberFormHandedOver, contacts);
// Emit Socket-Event wenn Member erfolgreich erstellt/aktualisiert wurde
if (addResult.status === 200) {
emitMemberChanged(clubId);
}
const addResult = await MemberService.setClubMember(userToken, clubId, memberId, firstName, lastName, street, city, birthdate,
phone, email, active, testMembership, picsInInternetAllowed, gender, ttr, qttr);
res.status(addResult.status || 500).json(addResult.response);
} catch (error) {
console.error('[setClubMembers] - Error:', error);
res.status(500).json({ error: 'Failed to save member' });
res.status(500).json({ error: 'Failed to upload image' });
}
}
@@ -50,12 +51,8 @@ const uploadMemberImage = async (req, res) => {
try {
const { clubId, memberId } = req.params;
const { authcode: userToken } = req.headers;
const makePrimary =
req.body?.makePrimary === true ||
req.body?.makePrimary === 'true' ||
req.query?.makePrimary === 'true';
const result = await MemberService.uploadMemberImage(userToken, clubId, memberId, req.file.buffer, { makePrimary });
res.status(result.status).json(result.response ?? { success: false, error: 'Unknown upload result' });
const result = await MemberService.uploadMemberImage(userToken, clubId, memberId, req.file.buffer);
res.status(result.status).json(result.message ? { message: result.message } : { error: result.error });
} catch (error) {
console.error('[uploadMemberImage] - Error:', error);
res.status(500).json({ error: 'Failed to upload image' });
@@ -63,12 +60,11 @@ const uploadMemberImage = async (req, res) => {
};
const getMemberImage = async (req, res) => {
devLog('[getMemberImage]');
try {
const { clubId, memberId, imageId } = req.params;
const { clubId, memberId } = req.params;
const { authcode: userToken } = req.headers;
// Support "latest" as imageId to get the latest image
const actualImageId = imageId === 'latest' ? null : (imageId || null);
const result = await MemberService.getMemberImage(userToken, clubId, memberId, actualImageId);
const result = await MemberService.getMemberImage(userToken, clubId, memberId);
if (result.status === 200) {
res.sendFile(result.imagePath);
} else {
@@ -81,6 +77,7 @@ const getMemberImage = async (req, res) => {
};
const updateRatingsFromMyTischtennis = async (req, res) => {
devLog('[updateRatingsFromMyTischtennis]');
try {
const { id: clubId } = req.params;
const { authcode: userToken } = req.headers;
@@ -92,166 +89,4 @@ const updateRatingsFromMyTischtennis = async (req, res) => {
}
};
const rotateMemberImage = async (req, res) => {
try {
const { clubId, memberId, imageId } = req.params;
const { direction } = req.body;
const { authcode: userToken } = req.headers;
if (!direction || !['left', 'right'].includes(direction)) {
return res.status(400).json({
success: false,
error: 'Ungültige Drehrichtung. Verwenden Sie "left" oder "right".'
});
}
const result = await MemberService.rotateMemberImage(userToken, clubId, memberId, imageId, direction);
res.status(result.status).json(result.response);
} catch (error) {
console.error('[rotateMemberImage] - Error:', error);
res.status(500).json({ success: false, error: 'Failed to rotate image' });
}
};
const deleteMemberImage = async (req, res) => {
try {
const { clubId, memberId, imageId } = req.params;
const { authcode: userToken } = req.headers;
const result = await MemberService.deleteMemberImage(userToken, clubId, memberId, imageId);
res.status(result.status).json(result.response);
} catch (error) {
console.error('[deleteMemberImage] - Error:', error);
res.status(500).json({ success: false, error: 'Failed to delete image' });
}
};
const generateMemberGallery = async (req, res) => {
try {
const { clubId } = req.params;
const { authcode: userToken } = req.headers;
const size = parseInt(req.query.size) || 200; // Default: 200x200
const format = req.query.format || 'image'; // 'image' or 'json'
// Bei format=json wird kein Bild erstellt, nur die Mitgliederliste zurückgegeben
const createImage = format !== 'json';
const result = await MemberService.generateMemberGallery(userToken, clubId, size, createImage);
if (result.status === 200) {
if (format === 'json') {
// Return member information for interactive gallery (ohne Bild zu erstellen)
return res.status(200).json({
members: result.galleryEntries.map(entry => ({
memberId: entry.memberId,
firstName: entry.firstName,
lastName: entry.lastName,
fullName: entry.fullName
}))
});
}
res.setHeader('Content-Type', 'image/png');
res.setHeader('Cache-Control', 'no-store');
return res.status(200).send(result.buffer);
}
return res.status(result.status).json({ error: result.error || 'Galerie konnte nicht erstellt werden' });
} catch (error) {
console.error('[generateMemberGallery] - Error:', error);
res.status(500).json({ error: 'Failed to generate member gallery' });
}
};
const setPrimaryMemberImage = async (req, res) => {
try {
const { clubId, memberId, imageId } = req.params;
const { authcode: userToken } = req.headers;
const result = await MemberService.setPrimaryMemberImage(userToken, clubId, memberId, imageId);
res.status(result.status).json(result.response);
} catch (error) {
console.error('[setPrimaryMemberImage] - Error:', error);
res.status(500).json({ success: false, error: 'Failed to update primary image' });
}
};
const quickUpdateTestMembership = async (req, res) => {
try {
const { clubId, memberId } = req.params;
const { authcode: userToken } = req.headers;
const result = await MemberService.quickUpdateTestMembership(userToken, clubId, memberId);
res.status(result.status).json(result.response);
} catch (error) {
console.error('[quickUpdateTestMembership] - Error:', error);
res.status(500).json({ error: 'Failed to update test membership' });
}
};
const quickUpdateMemberFormHandedOver = async (req, res) => {
try {
const { clubId, memberId } = req.params;
const { authcode: userToken } = req.headers;
const result = await MemberService.quickUpdateMemberFormHandedOver(userToken, clubId, memberId);
res.status(result.status).json(result.response);
} catch (error) {
console.error('[quickUpdateMemberFormHandedOver] - Error:', error);
res.status(500).json({ error: 'Failed to update member form status' });
}
};
const quickDeactivateMember = async (req, res) => {
try {
const { clubId, memberId } = req.params;
const { authcode: userToken } = req.headers;
const result = await MemberService.quickDeactivateMember(userToken, clubId, memberId);
res.status(result.status).json(result.response);
} catch (error) {
console.error('[quickDeactivateMember] - Error:', error);
res.status(500).json({ error: 'Failed to deactivate member' });
}
};
const transferMembers = async (req, res) => {
try {
const { id: clubId } = req.params;
const { authcode: userToken } = req.headers;
const config = req.body;
// Validierung
if (!config.transferEndpoint) {
return res.status(400).json({
success: false,
error: 'Übertragungs-Endpoint ist erforderlich'
});
}
if (!config.transferTemplate) {
return res.status(400).json({
success: false,
error: 'Übertragungs-Template ist erforderlich'
});
}
const result = await MemberTransferService.transferMembers(userToken, clubId, config);
res.status(result.status).json(result.response);
} catch (error) {
console.error('[transferMembers] - Error:', error);
res.status(500).json({
success: false,
error: 'Fehler bei der Übertragung: ' + error.message
});
}
};
export {
getClubMembers,
getWaitingApprovals,
setClubMembers,
uploadMemberImage,
getMemberImage,
updateRatingsFromMyTischtennis,
rotateMemberImage,
transferMembers,
quickUpdateTestMembership,
quickUpdateMemberFormHandedOver,
quickDeactivateMember,
deleteMemberImage,
setPrimaryMemberImage,
generateMemberGallery
};
export { getClubMembers, getWaitingApprovals, setClubMembers, uploadMemberImage, getMemberImage, updateRatingsFromMyTischtennis };

View File

@@ -1,5 +1,4 @@
import MemberNoteService from "../services/memberNoteService.js";
import MemberNote from '../models/MemberNote.js';
import { devLog } from '../utils/logger.js';
const getMemberNotes = async (req, res) => {
@@ -7,9 +6,11 @@ const getMemberNotes = async (req, res) => {
const { authcode: userToken } = req.headers;
const { memberId } = req.params;
const { clubId } = req.query;
devLog('[getMemberNotes]', userToken, memberId, clubId);
const notes = await MemberNoteService.getNotesForMember(userToken, clubId, memberId);
res.status(200).json(notes);
} catch (error) {
devLog('[getMemberNotes] - Error: ', error);
res.status(500).json({ error: 'systemerror' });
}
};
@@ -18,11 +19,12 @@ const addMemberNote = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { memberId, content, clubId } = req.body;
devLog('[addMemberNote]', userToken, memberId, content, clubId);
await MemberNoteService.addNoteToMember(userToken, clubId, memberId, content);
const notes = await MemberNoteService.getNotesForMember(userToken, clubId, memberId);
res.status(201).json(notes);
} catch (error) {
console.error('[addMemberNote] - Error:', error);
devLog('[addMemberNote] - Error: ', error);
res.status(500).json({ error: 'systemerror' });
}
};
@@ -32,16 +34,13 @@ const deleteMemberNote = async (req, res) => {
const { authcode: userToken } = req.headers;
const { noteId } = req.params;
const { clubId } = req.body;
const note = await MemberNote.findByPk(noteId);
if (!note) {
return res.status(404).json({ error: 'notfound' });
}
const memberId = note.memberId;
devLog('[deleteMemberNote]', userToken, noteId, clubId);
const memberId = await MemberNoteService.getMemberIdForNote(noteId); // Member ID ermitteln
await MemberNoteService.deleteNoteForMember(userToken, clubId, noteId);
const notes = await MemberNoteService.getNotesForMember(userToken, clubId, memberId);
res.status(200).json(notes);
} catch (error) {
console.error('[deleteMemberNote] - Error:', error);
devLog('[deleteMemberNote] - Error: ', error);
res.status(500).json({ error: 'systemerror' });
}
};

View File

@@ -1,51 +0,0 @@
import MemberTransferConfigService from '../services/memberTransferConfigService.js';
export const getConfig = async (req, res) => {
try {
const { id: clubId } = req.params;
const { authcode: userToken } = req.headers;
const result = await MemberTransferConfigService.getConfig(userToken, clubId);
res.status(result.status).json(result.response);
} catch (error) {
console.error('[getConfig] Error:', error);
res.status(500).json({
success: false,
error: 'Fehler beim Laden der Konfiguration'
});
}
};
export const saveConfig = async (req, res) => {
try {
const { id: clubId } = req.params;
const { authcode: userToken } = req.headers;
const configData = req.body;
const result = await MemberTransferConfigService.saveConfig(userToken, clubId, configData);
res.status(result.status).json(result.response);
} catch (error) {
console.error('[saveConfig] Error:', error);
res.status(500).json({
success: false,
error: 'Fehler beim Speichern der Konfiguration'
});
}
};
export const deleteConfig = async (req, res) => {
try {
const { id: clubId } = req.params;
const { authcode: userToken } = req.headers;
const result = await MemberTransferConfigService.deleteConfig(userToken, clubId);
res.status(result.status).json(result.response);
} catch (error) {
console.error('[deleteConfig] Error:', error);
res.status(500).json({
success: false,
error: 'Fehler beim Löschen der Konfiguration'
});
}
};

View File

@@ -1,492 +0,0 @@
import myTischtennisService from '../services/myTischtennisService.js';
import HttpError from '../exceptions/HttpError.js';
import axios from 'axios';
class MyTischtennisController {
/**
* GET /api/mytischtennis/account
* Get current user's myTischtennis account
*/
async getAccount(req, res, next) {
try {
const userId = req.user.id;
const account = await myTischtennisService.getAccount(userId);
if (!account) {
return res.status(200).json({ account: null });
}
res.status(200).json({ account });
} catch (error) {
next(error);
}
}
/**
* GET /api/mytischtennis/status
* Check account configuration status
*/
async getStatus(req, res, next) {
try {
const userId = req.user.id;
const status = await myTischtennisService.checkAccountStatus(userId);
res.status(200).json(status);
} catch (error) {
next(error);
}
}
/**
* POST /api/mytischtennis/account
* Create or update myTischtennis account
*/
async upsertAccount(req, res, next) {
try {
const userId = req.user.id;
const { email, password, savePassword, autoUpdateRatings, userPassword } = req.body;
if (!email) {
throw new HttpError('E-Mail-Adresse erforderlich', 400);
}
// Wenn ein Passwort gesetzt wird, muss das App-Passwort angegeben werden
if (password && !userPassword) {
throw new HttpError('App-Passwort erforderlich zum Setzen des myTischtennis-Passworts', 400);
}
const account = await myTischtennisService.upsertAccount(
userId,
email,
password,
savePassword || false,
autoUpdateRatings || false,
userPassword
);
res.status(200).json({
message: 'myTischtennis-Account erfolgreich gespeichert',
account
});
} catch (error) {
next(error);
}
}
/**
* DELETE /api/mytischtennis/account
* Delete myTischtennis account
*/
async deleteAccount(req, res, next) {
try {
const userId = req.user.id;
const deleted = await myTischtennisService.deleteAccount(userId);
if (!deleted) {
throw new HttpError('Kein myTischtennis-Account gefunden', 404);
}
res.status(200).json({ message: 'myTischtennis-Account gelöscht' });
} catch (error) {
next(error);
}
}
/**
* POST /api/mytischtennis/verify
* Verify login credentials
*/
async verifyLogin(req, res, next) {
try {
const userId = req.user.id;
const { password } = req.body;
const result = await myTischtennisService.verifyLogin(userId, password);
res.status(200).json({
message: 'Login erfolgreich',
success: true,
accessToken: result.accessToken,
expiresAt: result.expiresAt,
clubId: result.clubId,
clubName: result.clubName
});
} catch (error) {
next(error);
}
}
/**
* GET /api/mytischtennis/session
* Get stored session data for authenticated requests
*/
async getSession(req, res, next) {
try {
const userId = req.user.id;
const session = await myTischtennisService.getSession(userId);
res.status(200).json({ session });
} catch (error) {
next(error);
}
}
/**
* GET /api/mytischtennis/update-history
* Get update ratings history
*/
async getUpdateHistory(req, res, next) {
try {
const userId = req.user.id;
const history = await myTischtennisService.getUpdateHistory(userId);
res.status(200).json({ history });
} catch (error) {
next(error);
}
}
/**
* Get fetch logs for current user
*/
async getFetchLogs(req, res, next) {
try {
const { userid: userIdOrEmail } = req.headers;
// Convert email to userId if needed
let userId = userIdOrEmail;
if (isNaN(userIdOrEmail)) {
const User = (await import('../models/User.js')).default;
const user = await User.findOne({ where: { email: userIdOrEmail } });
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
userId = user.id;
}
const fetchLogService = (await import('../services/myTischtennisFetchLogService.js')).default;
const logs = await fetchLogService.getFetchLogs(userId, {
limit: req.query.limit ? parseInt(req.query.limit) : 50,
fetchType: req.query.type
});
res.status(200).json({ logs });
} catch (error) {
next(error);
}
}
/**
* Get latest successful fetches for each type
*/
async getLatestFetches(req, res, next) {
try {
const { userid: userIdOrEmail } = req.headers;
// Convert email to userId if needed
let userId = userIdOrEmail;
if (isNaN(userIdOrEmail)) {
const User = (await import('../models/User.js')).default;
const user = await User.findOne({ where: { email: userIdOrEmail } });
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
userId = user.id;
}
const fetchLogService = (await import('../services/myTischtennisFetchLogService.js')).default;
const latestFetches = await fetchLogService.getLatestSuccessfulFetches(userId);
res.status(200).json({ latestFetches });
} catch (error) {
next(error);
}
}
/**
* GET /api/mytischtennis/login-page
* Proxy für Login-Seite (für iframe)
* Lädt die Login-Seite von mytischtennis.de und modifiziert sie, sodass Form-Submissions über unseren Proxy gehen
* Authentifizierung ist optional - Token kann als Query-Parameter übergeben werden
*/
async getLoginPage(req, res, next) {
try {
// Versuche, userId aus Token zu bekommen (optional)
let userId = null;
const token = req.query.token || req.headers['authorization']?.split(' ')[1] || req.headers['authcode'];
if (token) {
try {
const jwt = (await import('jsonwebtoken')).default;
const decoded = jwt.verify(token, process.env.JWT_SECRET);
userId = decoded.userId;
} catch (err) {
// Token ungültig - ignorieren
}
}
// Speichere userId im Request für submitLogin
req.userId = userId;
// Lade die Login-Seite von mytischtennis.de
const response = await axios.get('https://www.mytischtennis.de/login?next=%2F', {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7'
},
maxRedirects: 5,
validateStatus: () => true // Akzeptiere alle Status-Codes
});
// Setze Cookies aus der Response
const setCookieHeaders = response.headers['set-cookie'];
if (setCookieHeaders) {
res.setHeader('Set-Cookie', setCookieHeaders);
}
// Modifiziere HTML: Ändere Form-Action auf unseren Proxy
let html = response.data;
if (typeof html === 'string') {
// Füge Token als Hidden-Input hinzu, damit submitLogin die userId bekommt
const tokenInput = userId ? `<input type="hidden" name="__token" value="${token}" />` : '';
// Ersetze Form-Action URLs und füge Token-Input hinzu
html = html.replace(
/(<form[^>]*action="[^"]*\/login[^"]*"[^>]*>)/g,
`$1${tokenInput}`
);
html = html.replace(
/action="([^"]*\/login[^"]*)"/g,
'action="/api/mytischtennis/login-submit"'
);
// Ersetze auch relative URLs
html = html.replace(
/action="\/login/g,
'action="/api/mytischtennis/login-submit'
);
}
// Setze Content-Type
res.setHeader('Content-Type', response.headers['content-type'] || 'text/html; charset=utf-8');
// Sende den modifizierten HTML-Inhalt
res.status(response.status).send(html);
} catch (error) {
console.error('Fehler beim Laden der Login-Seite:', error);
next(error);
}
}
/**
* POST /api/mytischtennis/login-submit
* Proxy für Login-Form-Submission
* Leitet den Login-Request durch, damit Cookies im Backend-Kontext bleiben
* Authentifizierung ist optional - iframe kann keinen Token mitsenden
*/
async submitLogin(req, res, next) {
try {
// Versuche, userId aus Token zu bekommen (aus Query-Parameter oder Hidden-Input)
let userId = null;
const token = req.query.token || req.body.__token || req.headers['authorization']?.split(' ')[1] || req.headers['authcode'];
if (token) {
try {
const jwt = (await import('jsonwebtoken')).default;
const decoded = jwt.verify(token, process.env.JWT_SECRET);
userId = decoded.userId;
} catch (err) {
// Token ungültig - ignorieren
}
}
// Entferne __token aus req.body, damit es nicht an mytischtennis.de gesendet wird
if (req.body.__token) {
delete req.body.__token;
}
// Hole Cookies aus dem Request
const cookies = req.headers.cookie || '';
// Leite den Login-Request an mytischtennis.de weiter
const response = await axios.post(
'https://www.mytischtennis.de/login?next=%2F&_data=routes%2F_auth%2B%2Flogin',
req.body, // Form-Daten
{
headers: {
'Cookie': cookies,
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept': '*/*',
'Referer': 'https://www.mytischtennis.de/login?next=%2F'
},
maxRedirects: 0,
validateStatus: () => true
}
);
// Setze Cookies aus der Response
const setCookieHeaders = response.headers['set-cookie'];
if (setCookieHeaders) {
res.setHeader('Set-Cookie', setCookieHeaders);
}
// Setze andere relevante Headers
if (response.headers['content-type']) {
res.setHeader('Content-Type', response.headers['content-type']);
}
if (response.headers['location']) {
res.setHeader('Location', response.headers['location']);
}
// Prüfe, ob Login erfolgreich war (durch Prüfung der Cookies)
const authCookie = setCookieHeaders?.find(cookie => cookie.startsWith('sb-10-auth-token='));
if (authCookie && userId) {
// Login erfolgreich - speichere Session (nur wenn userId vorhanden)
await this.saveSessionFromCookie(userId, authCookie);
}
// Sende Response weiter
res.status(response.status).send(response.data);
} catch (error) {
console.error('Fehler beim Login-Submit:', error);
next(error);
}
}
/**
* Speichere Session-Daten aus Cookie
*/
async saveSessionFromCookie(userId, cookieString) {
try {
const tokenMatch = cookieString.match(/sb-10-auth-token=base64-([^;]+)/);
if (!tokenMatch) {
throw new Error('Token-Format ungültig');
}
const base64Token = tokenMatch[1];
const decodedToken = Buffer.from(base64Token, 'base64').toString('utf-8');
const tokenData = JSON.parse(decodedToken);
const MyTischtennis = (await import('../models/MyTischtennis.js')).default;
const myTischtennisAccount = await MyTischtennis.findOne({ where: { userId } });
if (myTischtennisAccount) {
myTischtennisAccount.accessToken = tokenData.access_token;
myTischtennisAccount.refreshToken = tokenData.refresh_token;
myTischtennisAccount.expiresAt = tokenData.expires_at;
myTischtennisAccount.cookie = cookieString.split(';')[0].trim();
myTischtennisAccount.userData = tokenData.user;
myTischtennisAccount.lastLoginSuccess = new Date();
myTischtennisAccount.lastLoginAttempt = new Date();
// Hole Club-Informationen
const myTischtennisClient = (await import('../clients/myTischtennisClient.js')).default;
const profileResult = await myTischtennisClient.getUserProfile(myTischtennisAccount.cookie);
if (profileResult.success) {
myTischtennisAccount.clubId = profileResult.clubId;
myTischtennisAccount.clubName = profileResult.clubName;
myTischtennisAccount.fedNickname = profileResult.fedNickname;
}
await myTischtennisAccount.save();
}
} catch (error) {
console.error('Fehler beim Speichern der Session:', error);
throw error;
}
}
/**
* POST /api/mytischtennis/extract-session
* Extrahiere Session nach Login im iframe
* Versucht, die Session-Daten aus den Cookies zu extrahieren
* Authentifizierung ist optional - iframe kann keinen Token mitsenden
*/
async extractSession(req, res, next) {
try {
// Versuche, userId aus Token zu bekommen (optional)
let userId = req.user?.id;
// Falls kein Token vorhanden, versuche userId aus Account zu bekommen (falls E-Mail bekannt)
if (!userId) {
// Kann nicht ohne Authentifizierung arbeiten - Session kann nicht gespeichert werden
return res.status(401).json({
error: 'Authentifizierung erforderlich zum Speichern der Session'
});
}
// Hole die Cookies aus dem Request
const cookies = req.headers.cookie || '';
// Versuche, die Session zu verifizieren, indem wir einen Request mit den Cookies machen
const response = await axios.get('https://www.mytischtennis.de/?_data=root', {
headers: {
'Cookie': cookies,
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept': 'application/json'
},
validateStatus: () => true
});
// Prüfe, ob wir eingeloggt sind (durch Prüfung der Response)
if (response.status === 200 && response.data?.userProfile) {
// Session erfolgreich - speichere die Daten
const account = await myTischtennisService.getAccount(userId);
if (!account) {
throw new HttpError('Kein myTischtennis-Account verknüpft', 404);
}
// Extrahiere Cookie-String
const cookieString = cookies.split(';').find(c => c.trim().startsWith('sb-10-auth-token='));
if (!cookieString) {
throw new HttpError('Kein Auth-Token in Cookies gefunden', 400);
}
// Parse Token aus Cookie
const tokenMatch = cookieString.match(/sb-10-auth-token=base64-([^;]+)/);
if (!tokenMatch) {
throw new HttpError('Token-Format ungültig', 400);
}
const base64Token = tokenMatch[1];
const decodedToken = Buffer.from(base64Token, 'base64').toString('utf-8');
const tokenData = JSON.parse(decodedToken);
// Aktualisiere Account mit Session-Daten
const MyTischtennis = (await import('../models/MyTischtennis.js')).default;
const myTischtennisAccount = await MyTischtennis.findOne({ where: { userId } });
if (myTischtennisAccount) {
myTischtennisAccount.accessToken = tokenData.access_token;
myTischtennisAccount.refreshToken = tokenData.refresh_token;
myTischtennisAccount.expiresAt = tokenData.expires_at;
myTischtennisAccount.cookie = cookieString.trim();
myTischtennisAccount.userData = tokenData.user;
myTischtennisAccount.lastLoginSuccess = new Date();
myTischtennisAccount.lastLoginAttempt = new Date();
// Hole Club-Informationen
const myTischtennisClient = (await import('../clients/myTischtennisClient.js')).default;
const profileResult = await myTischtennisClient.getUserProfile(cookieString.trim());
if (profileResult.success) {
myTischtennisAccount.clubId = profileResult.clubId;
myTischtennisAccount.clubName = profileResult.clubName;
myTischtennisAccount.fedNickname = profileResult.fedNickname;
}
await myTischtennisAccount.save();
}
res.status(200).json({
success: true,
message: 'Session erfolgreich extrahiert und gespeichert'
});
} else {
throw new HttpError('Nicht eingeloggt oder Session ungültig', 401);
}
} catch (error) {
console.error('Fehler beim Extrahieren der Session:', error);
next(error);
}
}
}
export default new MyTischtennisController();

View File

@@ -1,601 +0,0 @@
import myTischtennisUrlParserService from '../services/myTischtennisUrlParserService.js';
import myTischtennisService from '../services/myTischtennisService.js';
import MemberService from '../services/memberService.js';
import autoFetchMatchResultsService from '../services/autoFetchMatchResultsService.js';
import apiLogService from '../services/apiLogService.js';
import ClubTeam from '../models/ClubTeam.js';
import League from '../models/League.js';
import Season from '../models/Season.js';
import User from '../models/User.js';
import HttpError from '../exceptions/HttpError.js';
import { devLog } from '../utils/logger.js';
class MyTischtennisUrlController {
/**
* Parse myTischtennis URL and return configuration data
* POST /api/mytischtennis/parse-url
* Body: { url: string }
*/
async parseUrl(req, res, next) {
try {
const { url } = req.body;
if (!url) {
throw new HttpError('URL is required', 400);
}
// Validate URL
if (!myTischtennisUrlParserService.isValidTeamUrl(url)) {
throw new HttpError('Invalid myTischtennis URL format', 400);
}
// Parse URL
const parsedData = myTischtennisUrlParserService.parseUrl(url);
// Try to fetch additional data if user is authenticated
const userIdOrEmail = req.headers.userid;
let completeData = parsedData;
if (userIdOrEmail) {
// Get actual user ID
let userId = userIdOrEmail;
if (isNaN(userIdOrEmail)) {
const user = await User.findOne({ where: { email: userIdOrEmail } });
if (user) userId = user.id;
}
try {
const account = await myTischtennisService.getAccount(userId);
if (account && account.accessToken) {
completeData = await myTischtennisUrlParserService.fetchTeamData(
parsedData,
account.cookie,
account.accessToken
);
}
} catch (error) {
// Continue with parsed data only
}
}
res.json({
success: true,
data: completeData
});
} catch (error) {
next(error);
}
}
/**
* Configure team from myTischtennis URL
* POST /api/mytischtennis/configure-team
* Body: { url: string, clubTeamId: number, createLeague?: boolean, createSeason?: boolean }
*/
async configureTeam(req, res, next) {
try {
const { url, clubTeamId, createLeague, createSeason } = req.body;
const userIdOrEmail = req.headers.userid;
if (!url || !clubTeamId) {
throw new HttpError('URL and clubTeamId are required', 400);
}
// Get actual user ID
let userId = userIdOrEmail;
if (isNaN(userIdOrEmail)) {
const user = await User.findOne({ where: { email: userIdOrEmail } });
if (!user) {
throw new HttpError('User not found', 404);
}
userId = user.id;
}
// Parse URL
const parsedData = myTischtennisUrlParserService.parseUrl(url);
// Try to fetch additional data
let completeData = parsedData;
const account = await myTischtennisService.getAccount(userId);
if (account && account.accessToken) {
try {
completeData = await myTischtennisUrlParserService.fetchTeamData(
parsedData,
account.cookie,
account.accessToken
);
} catch (error) {
}
}
// Find or create season
let season = await Season.findOne({
where: { season: completeData.season }
});
if (!season && createSeason) {
season = await Season.create({
season: completeData.season
});
}
if (!season) {
throw new HttpError(`Season ${completeData.season} not found. Set createSeason=true to create it.`, 404);
}
// Find or create league
const team = await ClubTeam.findByPk(clubTeamId);
if (!team) {
throw new HttpError('Club team not found', 404);
}
let league;
// First, try to find existing league by name and season
const leagueName = completeData.leagueName || completeData.groupname;
league = await League.findOne({
where: {
name: leagueName,
seasonId: season.id,
clubId: team.clubId
}
});
if (league) {
devLog(`Found existing league: ${league.name} (ID: ${league.id})`);
// Update myTischtennis fields
await league.update({
myTischtennisGroupId: completeData.groupId,
association: completeData.association,
groupname: completeData.groupname
});
} else if (team.leagueId) {
// Team has a league assigned, update it
league = await League.findByPk(team.leagueId);
if (league) {
devLog(`Updating team's existing league: ${league.name} (ID: ${league.id})`);
await league.update({
name: leagueName,
myTischtennisGroupId: completeData.groupId,
association: completeData.association,
groupname: completeData.groupname
});
}
} else if (createLeague) {
// Create new league
devLog(`Creating new league: ${leagueName}`);
league = await League.create({
name: leagueName,
seasonId: season.id,
clubId: team.clubId,
myTischtennisGroupId: completeData.groupId,
association: completeData.association,
groupname: completeData.groupname
});
} else {
throw new HttpError('League not found and team has no league assigned. Set createLeague=true to create one.', 400);
}
// Update team
await team.update({
myTischtennisTeamId: completeData.teamId,
leagueId: league.id,
seasonId: season.id
});
res.json({
success: true,
message: 'Team configured successfully',
data: {
team: {
id: team.id,
name: team.name,
myTischtennisTeamId: completeData.teamId
},
league: {
id: league.id,
name: league.name,
myTischtennisGroupId: completeData.groupId,
association: completeData.association,
groupname: completeData.groupname
},
season: {
id: season.id,
name: season.season
},
parsedData: completeData
}
});
} catch (error) {
next(error);
}
}
/**
* Manually fetch team data from myTischtennis
* POST /api/mytischtennis/fetch-team-data
* Body: { clubTeamId: number }
*/
async fetchTeamData(req, res, next) {
// Define outside of try/catch so catch has access
let account = null;
let team = null;
let myTischtennisUrl = null;
let requestStartTime = null;
try {
const { clubTeamId } = req.body;
const userIdOrEmail = req.headers.userid;
if (!clubTeamId) {
throw new HttpError('clubTeamId is required', 400);
}
if (!userIdOrEmail) {
throw new HttpError('User-ID fehlt. Bitte melden Sie sich an.', 401);
}
// Get actual user ID (userid header might be email address)
let userId = userIdOrEmail;
if (isNaN(userIdOrEmail)) {
// It's an email, find the user
const user = await User.findOne({ where: { email: userIdOrEmail } });
if (!user) {
throw new HttpError('User not found', 404);
}
userId = user.id;
}
// Get myTischtennis session (similar to memberService.updateRatingsFromMyTischtennis)
let session;
try {
session = await myTischtennisService.getSession(userId);
} catch (sessionError) {
// Versuche automatischen Login mit gespeicherten Credentials
try {
// Check if account exists and has password
const accountCheck = await myTischtennisService.getAccount(userId);
if (!accountCheck) {
throw new Error('MyTischtennis-Account nicht gefunden');
}
if (!accountCheck.encryptedPassword) {
throw new Error('Kein Passwort gespeichert. Bitte melden Sie sich in den MyTischtennis-Einstellungen an und speichern Sie Ihr Passwort.');
}
await myTischtennisService.verifyLogin(userId);
session = await myTischtennisService.getSession(userId);
} catch (loginError) {
const errorMessage = loginError.message || 'Automatischer Login fehlgeschlagen';
throw new HttpError(`MyTischtennis-Session abgelaufen und automatischer Login fehlgeschlagen: ${errorMessage}. Bitte melden Sie sich in den MyTischtennis-Einstellungen an.`, 401);
}
}
// Get account data (for clubId, etc.)
account = await myTischtennisService.getAccount(userId);
if (!account) {
throw new HttpError('MyTischtennis-Account nicht verknüpft. Bitte verknüpfen Sie Ihren Account in den MyTischtennis-Einstellungen.', 404);
}
// Get team with league and season
team = await ClubTeam.findByPk(clubTeamId, {
include: [
{
model: League,
as: 'league',
include: [
{
model: Season,
as: 'season'
}
]
}
]
});
if (!team) {
throw new HttpError(`Team mit ID ${clubTeamId} nicht gefunden`, 404);
}
// Verbesserte Validierung mit detaillierten Fehlermeldungen
if (!team.myTischtennisTeamId) {
throw new HttpError(`Team "${team.name}" (interne ID: ${team.id}) ist nicht für myTischtennis konfiguriert: myTischtennisTeamId fehlt. Bitte konfigurieren Sie das Team zuerst über die MyTischtennis-URL.`, 400);
}
if (!team.league) {
throw new HttpError('Team ist keiner Liga zugeordnet. Bitte ordnen Sie das Team einer Liga zu.', 400);
}
if (!team.league.myTischtennisGroupId) {
throw new HttpError('Liga ist nicht für myTischtennis konfiguriert: myTischtennisGroupId fehlt. Bitte konfigurieren Sie die Liga zuerst über die MyTischtennis-URL.', 400);
}
// Validate season before proceeding
if (!team.league.season || !team.league.season.season) {
throw new HttpError('Liga ist keiner Saison zugeordnet. Bitte ordnen Sie die Liga einer Saison zu.', 400);
}
// Build the URL that will be used - do this early so we can log it even if errors occur
const seasonFull = team.league.season.season;
const seasonParts = seasonFull.split('/');
const seasonShort = seasonParts.length === 2
? `${seasonParts[0].slice(-2)}/${seasonParts[1].slice(-2)}`
: seasonFull;
const seasonStr = seasonShort.replace('/', '--');
const teamnameEncoded = encodeURIComponent(team.name.replace(/\s/g, '_'));
myTischtennisUrl = `https://www.mytischtennis.de/click-tt/${team.league.association}/${seasonStr}/ligen/${team.league.groupname}/gruppe/${team.league.myTischtennisGroupId}/mannschaft/${team.myTischtennisTeamId}/${teamnameEncoded}/spielerbilanzen/gesamt`;
// Log the request to myTischtennis BEFORE making the call
// This ensures we always see what WILL BE sent, even if the call fails
requestStartTime = Date.now();
try {
await apiLogService.logRequest({
userId: account.userId,
method: 'GET',
path: myTischtennisUrl.replace('https://www.mytischtennis.de', ''),
statusCode: null,
requestBody: JSON.stringify({
url: myTischtennisUrl,
myTischtennisTeamId: team.myTischtennisTeamId,
clubTeamId: team.id,
teamName: team.name,
leagueName: team.league.name,
association: team.league.association,
groupId: team.league.myTischtennisGroupId,
groupname: team.league.groupname,
season: seasonFull
}),
responseBody: null,
executionTime: null,
errorMessage: 'Request wird ausgeführt...',
logType: 'api_request',
schedulerJobType: 'mytischtennis_fetch'
});
} catch (logError) {
// Silent fail - logging errors shouldn't break the request
}
// Fetch data for this specific team
// Note: fetchTeamResults will also log and update with actual response
const result = await autoFetchMatchResultsService.fetchTeamResults(
{
userId: account.userId,
email: account.email,
cookie: session.cookie,
accessToken: session.accessToken,
expiresAt: session.expiresAt,
getPassword: () => null // Not needed for manual fetch
},
team
);
// Also fetch and update league table data
let tableUpdateResult = null;
try {
await autoFetchMatchResultsService.fetchAndUpdateLeagueTable(account.userId, team.league.id);
tableUpdateResult = 'League table updated successfully';
} catch (error) {
tableUpdateResult = 'League table update failed: ' + error.message;
// Don't fail the entire request if table update fails
}
// Additionally update (Q)TTR ratings for the club
let ratingsUpdate = null;
try {
// Use already resolved userId instead of authcode to avoid header dependency
const ratingsResult = await MemberService.updateRatingsFromMyTischtennisByUserId(userId, team.clubId);
ratingsUpdate = ratingsResult?.response?.message || `Ratings update status: ${ratingsResult?.status}`;
} catch (ratingsErr) {
ratingsUpdate = 'Ratings update failed: ' + (ratingsErr.message || String(ratingsErr));
}
res.json({
success: true,
message: `${result.fetchedCount} Datensätze abgerufen und verarbeitet`,
data: {
fetchedCount: result.fetchedCount,
teamName: team.name,
tableUpdate: tableUpdateResult,
ratingsUpdate
}
});
} catch (error) {
// Update log with error information if we got far enough to build the URL
if (myTischtennisUrl && account && team) {
const requestExecutionTime = requestStartTime ? (Date.now() - requestStartTime) : null;
try {
await apiLogService.logRequest({
userId: account.userId,
method: 'GET',
path: myTischtennisUrl.replace('https://www.mytischtennis.de', ''),
statusCode: 0,
requestBody: JSON.stringify({
url: myTischtennisUrl,
myTischtennisTeamId: team.myTischtennisTeamId,
clubTeamId: team.id,
teamName: team.name,
leagueName: team.league?.name,
association: team.league?.association,
groupname: team.league?.groupname,
groupId: team.league?.myTischtennisGroupId
}),
responseBody: null,
executionTime: requestExecutionTime,
errorMessage: error.message || String(error),
logType: 'api_request',
schedulerJobType: 'mytischtennis_fetch'
});
} catch (logError) {
// Silent fail - logging errors shouldn't break the request
}
}
// Normalize HTTP status code (guard against strings)
const rawCode = error && (error.statusCode != null ? error.statusCode : error.status);
const parsed = Number(rawCode);
const status = Number.isInteger(parsed) && parsed >= 100 && parsed <= 599 ? parsed : 500;
const debug = {
message: error.message || String(error),
name: error.name,
stack: (process.env.NODE_ENV === 'dev' || process.env.NODE_ENV === 'development') ? (error.stack || null) : undefined,
team: team ? { id: team.id, name: team.name } : null,
league: team && team.league ? { id: team.league.id, name: team.league.name, association: team.league.association, groupId: team.league.myTischtennisGroupId, groupname: team.league.groupname } : null,
url: typeof myTischtennisUrl !== 'undefined' ? myTischtennisUrl : null
};
try {
if (!res.headersSent) {
// Spezieller Fall: myTischtennis-Reauth nötig → nicht 401 an FE senden, um App-Logout zu vermeiden
const isMyTischtennisAuthIssue = status === 401 && /MyTischtennis-Session abgelaufen|Automatischer Login fehlgeschlagen|Passwort gespeichert/i.test(debug.message || '');
if (isMyTischtennisAuthIssue) {
return res.status(200).json({ success: false, error: debug.message, debug, needsMyTischtennisReauth: true });
}
res.status(status).json({ success: false, error: debug.message, debug });
}
} catch (writeErr) {
// Fallback, falls Headers schon gesendet wurden
// eslint-disable-next-line no-console
console.error('[fetchTeamData] Response write failed:', writeErr);
}
}
}
/**
* Get myTischtennis URL for a team
* GET /api/mytischtennis/team-url/:teamId
*/
async getTeamUrl(req, res, next) {
try {
const { teamId } = req.params;
const team = await ClubTeam.findByPk(teamId, {
include: [
{
model: League,
as: 'league',
include: [
{
model: Season,
as: 'season'
}
]
}
]
});
if (!team) {
throw new HttpError('Team not found', 404);
}
if (!team.myTischtennisTeamId || !team.league || !team.league.myTischtennisGroupId) {
throw new HttpError('Team is not configured for myTischtennis', 400);
}
const url = myTischtennisUrlParserService.buildUrl({
association: team.league.association,
season: team.league.season?.season,
groupname: team.league.groupname,
groupId: team.league.myTischtennisGroupId,
teamId: team.myTischtennisTeamId,
teamname: team.name
});
res.json({
success: true,
url
});
} catch (error) {
next(error);
}
}
/**
* Configure league from myTischtennis table URL
* POST /api/mytischtennis/configure-league
* Body: { url: string, createSeason?: boolean }
*/
async configureLeague(req, res, next) {
try {
const { url, createSeason } = req.body;
const userIdOrEmail = req.headers.userid;
if (!url) {
throw new HttpError('URL is required', 400);
}
// Parse URL
const parsedData = myTischtennisUrlParserService.parseUrl(url);
if (parsedData.urlType !== 'table') {
throw new HttpError('URL must be a table URL (not a team URL)', 400);
}
// Find or create season
let season = await Season.findOne({
where: { season: parsedData.season }
});
if (!season && createSeason) {
season = await Season.create({
season: parsedData.season,
startDate: new Date(),
endDate: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000) // 1 Jahr später
});
}
// Find or create league
let league = await League.findOne({
where: {
myTischtennisGroupId: parsedData.groupId,
association: parsedData.association
}
});
if (!league) {
league = await League.create({
name: parsedData.groupnameOriginal, // Verwende die originale URL-kodierte Version
myTischtennisGroupId: parsedData.groupId,
association: parsedData.association,
groupname: parsedData.groupnameOriginal, // Verwende die originale URL-kodierte Version
seasonId: season?.id || null
});
} else {
// Update existing league - aber nur wenn es sich wirklich geändert hat
if (league.name !== parsedData.groupnameOriginal) {
league.name = parsedData.groupnameOriginal;
league.groupname = parsedData.groupnameOriginal;
}
league.seasonId = season?.id || league.seasonId;
await league.save();
}
res.json({
success: true,
message: 'League configured successfully',
data: {
league: {
id: league.id,
name: league.name,
myTischtennisGroupId: league.myTischtennisGroupId,
association: league.association,
groupname: league.groupname
},
season: season ? {
id: season.id,
name: season.season
} : null,
parsedData
}
});
} catch (error) {
next(error);
}
}
}
export default new MyTischtennisUrlController();

View File

@@ -233,11 +233,9 @@ export const listOfficialTournaments = async (req, res) => {
const { clubId } = req.params;
await checkAccess(userToken, clubId);
const list = await OfficialTournament.findAll({ where: { clubId } });
res.status(200).json(Array.isArray(list) ? list : []);
res.status(200).json(list);
} catch (e) {
console.error('[listOfficialTournaments] Error:', e);
const errorMessage = e.message || 'Failed to list tournaments';
res.status(e.statusCode || 500).json({ error: errorMessage });
res.status(500).json({ error: 'Failed to list tournaments' });
}
};

View File

@@ -1,14 +1,10 @@
import Participant from '../models/Participant.js';
import DiaryDates from '../models/DiaryDates.js';
import { devLog } from '../utils/logger.js';
import { emitParticipantAdded, emitParticipantRemoved, emitParticipantUpdated } from '../services/socketService.js';
export const getParticipants = async (req, res) => {
try {
const { dateId } = req.params;
const participants = await Participant.findAll({
where: { diaryDateId: dateId },
attributes: ['id', 'diaryDateId', 'memberId', 'groupId', 'notes', 'createdAt', 'updatedAt']
});
const participants = await Participant.findAll({ where: { diaryDateId: dateId } });
res.status(200).json(participants);
} catch (error) {
devLog(error);
@@ -16,66 +12,10 @@ export const getParticipants = async (req, res) => {
}
};
export const updateParticipantGroup = async (req, res) => {
try {
const { dateId, memberId } = req.params;
const { groupId } = req.body;
const participant = await Participant.findOne({
where: {
diaryDateId: dateId,
memberId: memberId
},
include: [{
model: DiaryDates,
as: 'diaryDate',
attributes: ['clubId']
}]
});
if (!participant) {
return res.status(404).json({ error: 'Teilnehmer nicht gefunden' });
}
participant.groupId = groupId || null;
await participant.save();
// Lade den Participant erneut aus der DB, um sicherzustellen, dass wir den aktuellen Wert haben
const updatedParticipant = await Participant.findOne({
where: {
diaryDateId: dateId,
memberId: memberId
},
include: [{
model: DiaryDates,
as: 'diaryDate',
attributes: ['clubId']
}]
});
// Emit Socket-Event mit dem aktualisierten Participant
if (updatedParticipant?.diaryDate?.clubId) {
emitParticipantUpdated(updatedParticipant.diaryDate.clubId, dateId, updatedParticipant);
}
res.status(200).json(updatedParticipant || participant);
} catch (error) {
devLog(error);
res.status(500).json({ error: 'Fehler beim Aktualisieren der Teilnehmer-Gruppenzuordnung' });
}
};
export const addParticipant = async (req, res) => {
try {
const { diaryDateId, memberId } = req.body;
const participant = await Participant.create({ diaryDateId, memberId });
// Hole DiaryDate für clubId
const diaryDate = await DiaryDates.findByPk(diaryDateId);
if (diaryDate?.clubId) {
emitParticipantAdded(diaryDate.clubId, diaryDateId, participant);
}
res.status(201).json(participant);
} catch (error) {
devLog(error);
@@ -86,18 +26,7 @@ export const addParticipant = async (req, res) => {
export const removeParticipant = async (req, res) => {
try {
const { diaryDateId, memberId } = req.body;
// Hole DiaryDate für clubId vor dem Löschen
const diaryDate = await DiaryDates.findByPk(diaryDateId);
const clubId = diaryDate?.clubId;
await Participant.destroy({ where: { diaryDateId, memberId } });
// Emit Socket-Event
if (clubId) {
emitParticipantRemoved(clubId, diaryDateId, memberId);
}
res.status(200).json({ message: 'Teilnehmer entfernt' });
} catch (error) {
devLog(error);

View File

@@ -1,167 +0,0 @@
import permissionService from '../services/permissionService.js';
/**
* Get user's permissions for a club
*/
export const getUserPermissions = async (req, res) => {
try {
const { clubId } = req.params;
const userId = req.user.id;
// Validierung: clubId muss eine gültige Zahl sein
const parsedClubId = parseInt(clubId, 10);
if (isNaN(parsedClubId) || parsedClubId <= 0) {
return res.status(400).json({ error: 'Ungültige Club-ID' });
}
const permissions = await permissionService.getUserClubPermissions(userId, parsedClubId);
if (!permissions) {
return res.status(404).json({ error: 'Keine Berechtigungen gefunden' });
}
res.json(permissions);
} catch (error) {
console.error('Error getting user permissions:', error);
res.status(500).json({ error: 'Fehler beim Abrufen der Berechtigungen' });
}
};
/**
* Get all club members with their permissions
*/
export const getClubMembersWithPermissions = async (req, res) => {
try {
const { clubId } = req.params;
const userId = req.user.id;
const members = await permissionService.getClubMembersWithPermissions(
parseInt(clubId),
userId
);
res.json(members);
} catch (error) {
console.error('Error getting club members with permissions:', error);
if (error.message === 'Keine Berechtigung zum Anzeigen von Berechtigungen') {
return res.status(403).json({ error: error.message });
}
res.status(500).json({ error: 'Fehler beim Abrufen der Mitglieder' });
}
};
/**
* Update user role
*/
export const updateUserRole = async (req, res) => {
try {
const { clubId, userId: targetUserId } = req.params;
const { role } = req.body;
const updatingUserId = req.user.id;
const result = await permissionService.setUserRole(
parseInt(targetUserId),
parseInt(clubId),
role,
updatingUserId
);
res.json(result);
} catch (error) {
console.error('Error updating user role:', error);
if (error.message && error.message.toLowerCase().includes('keine berechtigung')) {
return res.status(403).json({ error: error.message });
}
res.status(400).json({ error: error.message });
}
};
/**
* Update user custom permissions
*/
export const updateUserPermissions = async (req, res) => {
try {
const { clubId, userId: targetUserId } = req.params;
const { permissions } = req.body;
const updatingUserId = req.user.id;
const result = await permissionService.setCustomPermissions(
parseInt(targetUserId),
parseInt(clubId),
permissions,
updatingUserId
);
res.json(result);
} catch (error) {
console.error('Error updating user permissions:', error);
if (error.message && error.message.toLowerCase().includes('keine berechtigung')) {
return res.status(403).json({ error: error.message });
}
res.status(400).json({ error: error.message });
}
};
/**
* Get available roles
*/
export const getAvailableRoles = async (req, res) => {
try {
const roles = permissionService.getAvailableRoles();
res.json(roles);
} catch (error) {
console.error('Error getting available roles:', error);
res.status(500).json({ error: 'Fehler beim Abrufen der Rollen' });
}
};
/**
* Get permission structure
*/
export const getPermissionStructure = async (req, res) => {
try {
const structure = permissionService.getPermissionStructure();
res.json(structure);
} catch (error) {
console.error('Error getting permission structure:', error);
res.status(500).json({ error: 'Fehler beim Abrufen der Berechtigungsstruktur' });
}
};
/**
* Update user status (activate/deactivate)
*/
export const updateUserStatus = async (req, res) => {
try {
const { clubId, userId: targetUserId } = req.params;
const { approved } = req.body;
const updatingUserId = req.user.id;
const result = await permissionService.setUserStatus(
parseInt(targetUserId),
parseInt(clubId),
approved,
updatingUserId
);
res.json(result);
} catch (error) {
console.error('Error updating user status:', error);
if (error.message && error.message.toLowerCase().includes('keine berechtigung')) {
return res.status(403).json({ error: error.message });
}
res.status(400).json({ error: error.message });
}
};
export default {
getUserPermissions,
getClubMembersWithPermissions,
updateUserRole,
updateUserPermissions,
updateUserStatus,
getAvailableRoles,
getPermissionStructure
};

View File

@@ -36,6 +36,7 @@ export const uploadPredefinedActivityImage = async (req, res) => {
// Extrahiere Zeichnungsdaten aus dem Request
const drawingData = req.body.drawingData ? JSON.parse(req.body.drawingData) : null;
devLog('[uploadPredefinedActivityImage] - drawingData:', drawingData);
const imageRecord = await PredefinedActivityImage.create({
predefinedActivityId: id,

View File

@@ -1,103 +0,0 @@
import SeasonService from '../services/seasonService.js';
import { getUserByToken } from '../utils/userUtils.js';
import { devLog } from '../utils/logger.js';
export const getSeasons = async (req, res) => {
try {
const { authcode: token } = req.headers;
const user = await getUserByToken(token);
const seasons = await SeasonService.getAllSeasons();
res.status(200).json(seasons);
} catch (error) {
console.error('[getSeasons] - Error:', error);
res.status(500).json({ error: "internalerror" });
}
};
export const getCurrentSeason = async (req, res) => {
try {
const { authcode: token } = req.headers;
const user = await getUserByToken(token);
const season = await SeasonService.getOrCreateCurrentSeason();
res.status(200).json(season);
} catch (error) {
console.error('[getCurrentSeason] - Error:', error);
res.status(500).json({ error: "internalerror" });
}
};
export const createSeason = async (req, res) => {
try {
const { authcode: token } = req.headers;
const { season } = req.body;
const user = await getUserByToken(token);
if (!season) {
return res.status(400).json({ error: "missingseason" });
}
// Validiere Saison-Format (z.B. "2023/2024")
const seasonRegex = /^\d{4}\/\d{4}$/;
if (!seasonRegex.test(season)) {
return res.status(400).json({ error: "invalidseasonformat" });
}
const newSeason = await SeasonService.createSeason(season);
res.status(201).json(newSeason);
} catch (error) {
console.error('[createSeason] - Error:', error);
if (error.message === 'Season already exists') {
res.status(409).json({ error: "alreadyexists" });
} else {
res.status(500).json({ error: "internalerror" });
}
}
};
export const getSeason = async (req, res) => {
try {
const { authcode: token } = req.headers;
const { seasonid: seasonId } = req.params;
const user = await getUserByToken(token);
const season = await SeasonService.getSeasonById(seasonId);
if (!season) {
return res.status(404).json({ error: "notfound" });
}
res.status(200).json(season);
} catch (error) {
console.error('[getSeason] - Error:', error);
res.status(500).json({ error: "internalerror" });
}
};
export const deleteSeason = async (req, res) => {
try {
const { authcode: token } = req.headers;
const { seasonid: seasonId } = req.params;
const user = await getUserByToken(token);
const success = await SeasonService.deleteSeason(seasonId);
if (!success) {
return res.status(404).json({ error: "notfound" });
}
res.status(200).json({ message: "deleted" });
} catch (error) {
console.error('[deleteSeason] - Error:', error);
if (error.message === 'Season is used by teams' || error.message === 'Season is used by leagues') {
res.status(409).json({ error: "seasoninuse" });
} else {
res.status(500).json({ error: "internalerror" });
}
}
};

View File

@@ -1,130 +0,0 @@
import TeamService from '../services/teamService.js';
import { getUserByToken } from '../utils/userUtils.js';
import { devLog } from '../utils/logger.js';
export const getTeams = async (req, res) => {
try {
const { authcode: token } = req.headers;
const { clubid: clubId } = req.params;
const { seasonid: seasonId } = req.query;
const user = await getUserByToken(token);
// Check if user has access to this club
const teams = await TeamService.getAllTeamsByClub(clubId, seasonId);
res.status(200).json(teams);
} catch (error) {
console.error('[getTeams] - Error:', error);
res.status(500).json({ error: "internalerror" });
}
};
export const getTeam = async (req, res) => {
try {
const { authcode: token } = req.headers;
const { teamid: teamId } = req.params;
const user = await getUserByToken(token);
const team = await TeamService.getTeamById(teamId);
if (!team) {
return res.status(404).json({ error: "notfound" });
}
res.status(200).json(team);
} catch (error) {
console.error('[getTeam] - Error:', error);
res.status(500).json({ error: "internalerror" });
}
};
export const createTeam = async (req, res) => {
try {
const { authcode: token } = req.headers;
const { clubid: clubId } = req.params;
const { name, leagueId, seasonId } = req.body;
const user = await getUserByToken(token);
if (!name) {
return res.status(400).json({ error: "missingname" });
}
const teamData = {
name,
clubId: parseInt(clubId),
leagueId: leagueId ? parseInt(leagueId) : null,
seasonId: seasonId ? parseInt(seasonId) : null
};
const newTeam = await TeamService.createTeam(teamData);
res.status(201).json(newTeam);
} catch (error) {
console.error('[createTeam] - Error:', error);
res.status(500).json({ error: "internalerror" });
}
};
export const updateTeam = async (req, res) => {
try {
const { authcode: token } = req.headers;
const { teamid: teamId } = req.params;
const { name, leagueId, seasonId } = req.body;
const user = await getUserByToken(token);
const updateData = {};
if (name !== undefined) updateData.name = name;
if (leagueId !== undefined) updateData.leagueId = leagueId ? parseInt(leagueId) : null;
if (seasonId !== undefined) updateData.seasonId = seasonId ? parseInt(seasonId) : null;
const success = await TeamService.updateTeam(teamId, updateData);
if (!success) {
return res.status(404).json({ error: "notfound" });
}
const updatedTeam = await TeamService.getTeamById(teamId);
res.status(200).json(updatedTeam);
} catch (error) {
console.error('[updateTeam] - Error:', error);
res.status(500).json({ error: "internalerror" });
}
};
export const deleteTeam = async (req, res) => {
try {
const { authcode: token } = req.headers;
const { teamid: teamId } = req.params;
const user = await getUserByToken(token);
const success = await TeamService.deleteTeam(teamId);
if (!success) {
return res.status(404).json({ error: "notfound" });
}
res.status(200).json({ message: "deleted" });
} catch (error) {
console.error('[deleteTeam] - Error:', error);
res.status(500).json({ error: "internalerror" });
}
};
export const getLeagues = async (req, res) => {
try {
const { authcode: token } = req.headers;
const { clubid: clubId } = req.params;
const { seasonid: seasonId } = req.query;
const user = await getUserByToken(token);
const leagues = await TeamService.getLeaguesByClub(clubId, seasonId);
res.status(200).json(leagues);
} catch (error) {
console.error('[getLeagues] - Error:', error);
res.status(500).json({ error: "internalerror" });
}
};

View File

@@ -1,223 +0,0 @@
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import TeamDocumentService from '../services/teamDocumentService.js';
import PDFParserService from '../services/pdfParserService.js';
import { getUserByToken } from '../utils/userUtils.js';
import { devLog } from '../utils/logger.js';
// Multer-Konfiguration für Datei-Uploads
const storage = multer.diskStorage({
destination: (req, file, cb) => {
try {
fs.mkdirSync('uploads/temp', { recursive: true });
} catch (mkdirError) {
console.error('[multer] - Failed to ensure temp upload directory exists:', mkdirError);
}
cb(null, 'uploads/temp/');
},
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, file.fieldname + '-' + uniqueSuffix + path.extname(file.originalname));
}
});
const upload = multer({
storage: storage,
limits: {
fileSize: 10 * 1024 * 1024 // 10MB Limit
},
fileFilter: (req, file, cb) => {
// Erlaube nur PDF, DOC, DOCX, TXT, CSV Dateien
const allowedExtensions = ['.pdf', '.doc', '.docx', '.txt', '.csv'];
const allowedMimePatterns = ['pdf', 'msword', 'wordprocessingml.document', 'text/plain', 'csv', 'excel'];
const extensionValid = allowedExtensions.includes(path.extname(file.originalname).toLowerCase());
const mimetypeValid = allowedMimePatterns.some((pattern) => file.mimetype && file.mimetype.toLowerCase().includes(pattern));
if (extensionValid && mimetypeValid) {
return cb(null, true);
}
cb(new Error('Nur PDF, DOC, DOCX, TXT und CSV Dateien sind erlaubt!'));
}
});
export const uploadMiddleware = upload.single('document');
export const uploadDocument = async (req, res) => {
try {
const { authcode: token } = req.headers;
const { clubteamid: clubTeamId } = req.params;
const { documentType } = req.body;
const user = await getUserByToken(token);
if (!req.file) {
return res.status(400).json({ error: "nofile" });
}
if (!documentType || !['code_list', 'pin_list'].includes(documentType)) {
return res.status(400).json({ error: "invaliddocumenttype" });
}
const document = await TeamDocumentService.uploadDocument(req.file, clubTeamId, documentType);
res.status(201).json(document);
} catch (error) {
console.error('[uploadDocument] - Error:', error);
// Lösche temporäre Datei bei Fehler
if (req.file && req.file.path) {
try {
const fs = await import('fs');
fs.unlinkSync(req.file.path);
} catch (cleanupError) {
console.error('Fehler beim Löschen der temporären Datei:', cleanupError);
}
}
if (error.message === 'Club-Team nicht gefunden') {
return res.status(404).json({ error: "clubteamnotfound" });
}
res.status(500).json({ error: "internalerror" });
}
};
export const getDocuments = async (req, res) => {
try {
const { authcode: token } = req.headers;
const { clubteamid: clubTeamId } = req.params;
const user = await getUserByToken(token);
const documents = await TeamDocumentService.getDocumentsByClubTeam(clubTeamId);
res.status(200).json(documents);
} catch (error) {
console.error('[getDocuments] - Error:', error);
res.status(500).json({ error: "internalerror" });
}
};
export const getDocument = async (req, res) => {
try {
const { authcode: token } = req.headers;
const { documentid: documentId } = req.params;
const user = await getUserByToken(token);
const document = await TeamDocumentService.getDocumentById(documentId);
if (!document) {
return res.status(404).json({ error: "notfound" });
}
res.status(200).json(document);
} catch (error) {
console.error('[getDocument] - Error:', error);
res.status(500).json({ error: "internalerror" });
}
};
export const downloadDocument = async (req, res) => {
try {
const { authcode: token } = req.headers;
const { documentid: documentId } = req.params;
const user = await getUserByToken(token);
const document = await TeamDocumentService.getDocumentById(documentId);
if (!document) {
return res.status(404).json({ error: "notfound" });
}
const filePath = await TeamDocumentService.getDocumentPath(documentId);
if (!filePath) {
return res.status(404).json({ error: "filenotfound" });
}
// Prüfe ob Datei existiert
const fs = await import('fs');
if (!fs.existsSync(filePath)) {
return res.status(404).json({ error: "filenotfound" });
}
// Setze Headers für Inline-Anzeige (PDF-Viewer)
res.setHeader('Content-Disposition', `inline; filename="${document.originalFileName}"`);
res.setHeader('Content-Type', document.mimeType);
// Sende die Datei
res.sendFile(filePath);
} catch (error) {
console.error('[downloadDocument] - Error:', error);
res.status(500).json({ error: "internalerror" });
}
};
export const deleteDocument = async (req, res) => {
try {
const { authcode: token } = req.headers;
const { documentid: documentId } = req.params;
const user = await getUserByToken(token);
const success = await TeamDocumentService.deleteDocument(documentId);
if (!success) {
return res.status(404).json({ error: "notfound" });
}
res.status(200).json({ message: "Document deleted successfully" });
} catch (error) {
console.error('[deleteDocument] - Error:', error);
res.status(500).json({ error: "internalerror" });
}
};
export const parsePDF = async (req, res) => {
try {
const { authcode: token } = req.headers;
const { documentid: documentId } = req.params;
const { leagueid: leagueId } = req.query;
const user = await getUserByToken(token);
if (!leagueId) {
return res.status(400).json({ error: "missingleagueid" });
}
// Hole Dokument-Informationen
const document = await TeamDocumentService.getDocumentById(documentId);
if (!document) {
return res.status(404).json({ error: "documentnotfound" });
}
// Prüfe ob es eine PDF- oder TXT-Datei ist
if (!document.mimeType.includes('pdf') && !document.mimeType.includes('text/plain')) {
return res.status(400).json({ error: "notapdfortxt" });
}
// Parse PDF
const parseResult = await PDFParserService.parsePDF(document.filePath, document.clubTeam.clubId);
// Speichere Matches in Datenbank
const saveResult = await PDFParserService.saveMatchesToDatabase(parseResult.matches, parseInt(leagueId));
res.status(200).json({
parseResult: {
matchesFound: parseResult.matches.length,
debugInfo: parseResult.debugInfo,
allLines: parseResult.allLines,
rawText: parseResult.rawText
},
saveResult: {
created: saveResult.created,
updated: saveResult.updated,
errors: saveResult.errors
}
});
} catch (error) {
console.error('[parsePDF] - Error:', error);
res.status(500).json({ error: "internalerror" });
}
};

View File

@@ -1,8 +1,5 @@
// controllers/tournamentController.js
import tournamentService from "../services/tournamentService.js";
import { emitTournamentChanged } from '../services/socketService.js';
import TournamentClass from '../models/TournamentClass.js';
import HttpError from '../exceptions/HttpError.js';
// 1. Alle Turniere eines Vereins
export const getTournaments = async (req, res) => {
@@ -13,11 +10,6 @@ export const getTournaments = async (req, res) => {
res.status(200).json(tournaments);
} catch (error) {
console.error(error);
if (error instanceof HttpError) {
res.set('x-debug-tournament-clubid', String(clubId));
res.set('x-debug-tournament-clubid-num', String(Number(clubId)));
return res.status(error.statusCode || 500).json({ error: error.message });
}
res.status(500).json({ error: error.message });
}
};
@@ -25,66 +17,36 @@ export const getTournaments = async (req, res) => {
// 2. Neues Turnier anlegen
export const addTournament = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentName, date, winningSets, allowsExternal } = req.body;
const { clubId, tournamentName, date } = req.body;
try {
const tournament = await tournamentService.addTournament(token, clubId, tournamentName, date, winningSets, allowsExternal);
// Emit Socket-Event
if (clubId && tournament && tournament.id) {
emitTournamentChanged(clubId, tournament.id);
}
const tournament = await tournamentService.addTournament(token, clubId, tournamentName, date);
res.status(201).json(tournament);
} catch (error) {
console.error('[addTournament] Error:', error);
console.error(error);
res.status(500).json({ error: error.message });
}
};
// 3. Teilnehmer hinzufügen - klassengebunden
// 3. Teilnehmer hinzufügen
export const addParticipant = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, classId, participant: participantId, tournamentId } = req.body;
const { clubId, tournamentId, participant: participantId } = req.body;
try {
// Payloads:
// - Mit Klasse (klassengebunden): { clubId, classId, participant }
// - Ohne Klasse (turnierweit): { clubId, tournamentId, participant, classId: null }
if (!participantId) {
return res.status(400).json({ error: 'Teilnehmer-ID ist erforderlich' });
}
// Allow adding a participant either to a specific class (classId) or to the whole tournament (no class)
if (!classId && !tournamentId) {
return res.status(400).json({ error: 'Klasse oder tournamentId ist erforderlich' });
}
// Pass through to service. If classId is present it will be used, otherwise the service should add the participant with classId = null for the given tournamentId
await tournamentService.addParticipant(token, clubId, classId || null, participantId, tournamentId || null);
// Determine tournamentId for response and event emission
let respTournamentId = tournamentId;
if (classId && !respTournamentId) {
const tournamentClass = await TournamentClass.findByPk(classId);
if (!tournamentClass) {
return res.status(404).json({ error: 'Klasse nicht gefunden' });
}
respTournamentId = tournamentClass.tournamentId;
}
// Fetch updated participants for the (optional) class or whole tournament
const participants = await tournamentService.getParticipants(token, clubId, respTournamentId, classId || null);
// Emit Socket-Event
if (respTournamentId) emitTournamentChanged(clubId, respTournamentId);
await tournamentService.addParticipant(token, clubId, tournamentId, participantId);
const participants = await tournamentService.getParticipants(token, clubId, tournamentId);
res.status(200).json(participants);
} catch (error) {
console.error('[addParticipant] Error:', error);
console.error(error);
res.status(500).json({ error: error.message });
}
};
// 4. Teilnehmerliste abrufen - nach Klasse oder Turnier
// 4. Teilnehmerliste abrufen
export const getParticipants = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, classId } = req.body;
const { clubId, tournamentId } = req.body;
try {
const participants = await tournamentService.getParticipants(token, clubId, tournamentId, classId || null);
const participants = await tournamentService.getParticipants(token, clubId, tournamentId);
res.status(200).json(participants);
} catch (error) {
console.error(error);
@@ -98,8 +60,6 @@ export const setModus = async (req, res) => {
const { clubId, tournamentId, type, numberOfGroups, advancingPerGroup } = req.body;
try {
await tournamentService.setModus(token, clubId, tournamentId, type, numberOfGroups, advancingPerGroup);
// Emit Socket-Event
emitTournamentChanged(clubId, tournamentId);
res.sendStatus(204);
} catch (error) {
console.error(error);
@@ -110,48 +70,9 @@ export const setModus = async (req, res) => {
// 6. Gruppen-Strukturen anlegen (leere Gruppen)
export const createGroups = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, numberOfGroups } = req.body;
const { clubId, tournamentId } = req.body;
try {
// DEBUG: Eingehende Daten sichtbar machen (temporär)
console.log('[tournamentController.createGroups] body:', req.body);
console.log('[tournamentController.createGroups] types:', {
clubId: typeof clubId,
tournamentId: typeof tournamentId,
numberOfGroups: typeof numberOfGroups,
});
// Turniere ohne Klassen: `numberOfGroups: 0` kommt aus der UI (Default) vor.
// Statt „nichts passiert“ normalisieren wir auf mindestens 1 Gruppe.
let normalizedNumberOfGroups = numberOfGroups;
if (normalizedNumberOfGroups !== undefined && normalizedNumberOfGroups !== null) {
const n = Number(normalizedNumberOfGroups);
console.log('[tournamentController.createGroups] parsed numberOfGroups:', n);
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
return res.status(400).json({ error: 'numberOfGroups muss eine ganze Zahl >= 0 sein' });
}
normalizedNumberOfGroups = Math.max(1, n);
}
console.log('[tournamentController.createGroups] normalizedNumberOfGroups:', normalizedNumberOfGroups);
await tournamentService.createGroups(token, clubId, tournamentId, normalizedNumberOfGroups);
// Emit Socket-Event
emitTournamentChanged(clubId, tournamentId);
res.sendStatus(204);
} catch (error) {
console.error(error);
res.status(500).json({ error: error.message });
}
};
// 6b. Gruppen-Strukturen pro Klasse anlegen
export const createGroupsPerClass = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, groupsPerClass } = req.body;
try {
await tournamentService.createGroupsPerClass(token, clubId, tournamentId, groupsPerClass);
// Emit Socket-Event
emitTournamentChanged(clubId, tournamentId);
await tournamentService.createGroups(token, clubId, tournamentId);
res.sendStatus(204);
} catch (error) {
console.error(error);
@@ -165,8 +86,6 @@ export const fillGroups = async (req, res) => {
const { clubId, tournamentId } = req.body;
try {
const updatedMembers = await tournamentService.fillGroups(token, clubId, tournamentId);
// Emit Socket-Event
emitTournamentChanged(clubId, tournamentId);
res.status(200).json(updatedMembers);
} catch (error) {
console.error(error);
@@ -174,21 +93,6 @@ export const fillGroups = async (req, res) => {
}
};
// 7b. Gruppenspiele erstellen ohne Gruppenzuordnungen zu ändern
export const createGroupMatches = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, classId } = req.body;
try {
await tournamentService.createGroupMatches(token, clubId, tournamentId, classId);
// Emit Socket-Event
emitTournamentChanged(clubId, tournamentId);
res.sendStatus(204);
} catch (error) {
console.error(error);
res.status(500).json({ error: error.message });
}
};
// 8. Gruppen mit ihren Teilnehmern abfragen
export const getGroups = async (req, res) => {
const { authcode: token } = req.headers;
@@ -215,23 +119,6 @@ export const getTournament = async (req, res) => {
}
};
// Update Turnier
export const updateTournament = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId } = req.params;
const { name, date, winningSets } = req.body;
try {
const tournament = await tournamentService.updateTournament(token, clubId, tournamentId, name, date, winningSets);
// Emit Socket-Event
emitTournamentChanged(clubId, tournamentId);
res.status(200).json(tournament);
} catch (error) {
console.error('[updateTournament] Error:', error);
const status = error.message.includes('existiert bereits') ? 400 : 500;
res.status(status).json({ error: error.message });
}
};
// 10. Alle Spiele eines Turniers abfragen
export const getTournamentMatches = async (req, res) => {
const { authcode: token } = req.headers;
@@ -251,8 +138,6 @@ export const addMatchResult = async (req, res) => {
const { clubId, tournamentId, matchId, set, result } = req.body;
try {
await tournamentService.addMatchResult(token, clubId, tournamentId, matchId, set, result);
// Emit Socket-Event
emitTournamentChanged(clubId, tournamentId);
res.status(200).json({ message: "Result added successfully" });
} catch (error) {
console.error(error);
@@ -266,8 +151,6 @@ export const finishMatch = async (req, res) => {
const { clubId, tournamentId, matchId } = req.body;
try {
await tournamentService.finishMatch(token, clubId, tournamentId, matchId);
// Emit Socket-Event
emitTournamentChanged(clubId, tournamentId);
res.status(200).json({ message: "Match finished successfully" });
} catch (error) {
console.error(error);
@@ -281,8 +164,6 @@ export const startKnockout = async (req, res) => {
try {
await tournamentService.startKnockout(token, clubId, tournamentId);
// Emit Socket-Event
emitTournamentChanged(clubId, tournamentId);
res.status(200).json({ message: "K.o.-Runde erfolgreich gestartet" });
} catch (error) {
const status = /Gruppenmodus|Zu wenige Qualifikanten/.test(error.message) ? 400 : 500;
@@ -309,8 +190,6 @@ export const manualAssignGroups = async (req, res) => {
numberOfGroups, // neu
maxGroupSize // neu
);
// Emit Socket-Event
emitTournamentChanged(clubId, tournamentId);
res.status(200).json(groupsWithParts);
} catch (error) {
console.error('Error in manualAssignGroups:', error);
@@ -318,35 +197,11 @@ export const manualAssignGroups = async (req, res) => {
}
};
export const assignParticipantToGroup = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, participantId, groupNumber, isExternal } = req.body;
try {
const groups = await tournamentService.assignParticipantToGroup(
token,
clubId,
tournamentId,
participantId,
groupNumber,
isExternal || false
);
// Emit Socket-Event
emitTournamentChanged(clubId, tournamentId);
res.status(200).json(groups);
} catch (error) {
console.error('Error in assignParticipantToGroup:', error);
res.status(500).json({ error: error.message });
}
};
export const resetGroups = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId } = req.body;
try {
await tournamentService.resetGroups(token, clubId, tournamentId);
// Emit Socket-Event
emitTournamentChanged(clubId, tournamentId);
res.sendStatus(204);
} catch (err) {
console.error(err);
@@ -356,11 +211,9 @@ export const resetGroups = async (req, res) => {
export const resetMatches = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, classId } = req.body;
const { clubId, tournamentId } = req.body;
try {
await tournamentService.resetMatches(token, clubId, tournamentId, classId || null);
// Emit Socket-Event
emitTournamentChanged(clubId, tournamentId);
await tournamentService.resetMatches(token, clubId, tournamentId);
res.sendStatus(204);
} catch (err) {
console.error(err);
@@ -374,8 +227,6 @@ export const removeParticipant = async (req, res) => {
try {
await tournamentService.removeParticipant(token, clubId, tournamentId, participantId);
const participants = await tournamentService.getParticipants(token, clubId, tournamentId);
// Emit Socket-Event
emitTournamentChanged(clubId, tournamentId);
res.status(200).json(participants);
} catch (err) {
console.error(err);
@@ -383,21 +234,6 @@ export const removeParticipant = async (req, res) => {
}
};
export const updateParticipantSeeded = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, participantId } = req.params;
const { seeded } = req.body;
try {
await tournamentService.updateParticipantSeeded(token, clubId, tournamentId, participantId, seeded);
// Emit Socket-Event
emitTournamentChanged(clubId, tournamentId);
res.status(200).json({ message: 'Gesetzt-Status aktualisiert' });
} catch (err) {
console.error('[updateParticipantSeeded] Error:', err);
res.status(500).json({ error: err.message });
}
};
export const deleteMatchResult = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, matchId, set } = req.body;
@@ -409,8 +245,6 @@ export const deleteMatchResult = async (req, res) => {
matchId,
set
);
// Emit Socket-Event
emitTournamentChanged(clubId, tournamentId);
res.status(200).json({ message: 'Einzelsatz gelöscht' });
} catch (error) {
console.error('Error in deleteMatchResult:', error);
@@ -424,8 +258,6 @@ export const reopenMatch = async (req, res) => {
const { clubId, tournamentId, matchId } = req.body;
try {
await tournamentService.reopenMatch(token, clubId, tournamentId, matchId);
// Emit Socket-Event
emitTournamentChanged(clubId, tournamentId);
// Gib optional das aktualisierte Match zurück
res.status(200).json({ message: "Match reopened" });
} catch (error) {
@@ -436,210 +268,13 @@ export const reopenMatch = async (req, res) => {
export const deleteKnockoutMatches = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, classId } = req.body;
const { clubId, tournamentId } = req.body;
try {
await tournamentService.resetKnockout(token, clubId, tournamentId, classId);
// Emit Socket-Event
emitTournamentChanged(clubId, tournamentId);
await tournamentService.resetKnockout(token, clubId, tournamentId);
res.status(200).json({ message: "K.o.-Runde gelöscht" });
} catch (error) {
console.error("Error in deleteKnockoutMatches:", error);
res.status(500).json({ error: error.message });
}
};
export const setMatchActive = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, matchId } = req.params;
const { isActive } = req.body;
try {
await tournamentService.setMatchActive(token, clubId, tournamentId, matchId, isActive);
// Emit Socket-Event
emitTournamentChanged(clubId, tournamentId);
res.status(200).json({ message: 'Match-Status aktualisiert' });
} catch (err) {
console.error('[setMatchActive] Error:', err);
res.status(500).json({ error: err.message });
}
};
// Externe Teilnehmer hinzufügen
export const addExternalParticipant = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, classId, firstName, lastName, club, birthDate, gender } = req.body;
try {
await tournamentService.addExternalParticipant(token, clubId, classId, firstName, lastName, club, birthDate, gender);
emitTournamentChanged(clubId, tournamentId);
res.status(200).json({ message: 'Externer Teilnehmer hinzugefügt' });
} catch (error) {
console.error('[addExternalParticipant] Error:', error);
res.status(500).json({ error: error.message });
}
};
// Externe Teilnehmer abrufen - nach Klasse oder Turnier
export const getExternalParticipants = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, classId } = req.body;
try {
const participants = await tournamentService.getExternalParticipants(token, clubId, tournamentId, classId || null);
res.status(200).json(participants);
} catch (error) {
console.error('[getExternalParticipants] Error:', error);
res.status(500).json({ error: error.message });
}
};
// Externe Teilnehmer löschen
export const removeExternalParticipant = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, participantId } = req.body;
try {
await tournamentService.removeExternalParticipant(token, clubId, tournamentId, participantId);
emitTournamentChanged(clubId, tournamentId);
res.status(200).json({ message: 'Externer Teilnehmer entfernt' });
} catch (error) {
console.error('[removeExternalParticipant] Error:', error);
res.status(500).json({ error: error.message });
}
};
// Gesetzt-Status für externe Teilnehmer aktualisieren
export const updateExternalParticipantSeeded = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, participantId } = req.params;
const { seeded } = req.body;
try {
await tournamentService.updateExternalParticipantSeeded(token, clubId, tournamentId, participantId, seeded);
emitTournamentChanged(clubId, tournamentId);
res.status(200).json({ message: 'Gesetzt-Status aktualisiert' });
} catch (error) {
console.error('[updateExternalParticipantSeeded] Error:', error);
res.status(500).json({ error: error.message });
}
};
// Tournament Classes
export const getTournamentClasses = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId } = req.params;
try {
const classes = await tournamentService.getTournamentClasses(token, clubId, tournamentId);
res.status(200).json(classes);
} catch (error) {
console.error('[getTournamentClasses] Error:', error);
res.status(500).json({ error: error.message });
}
};
export const addTournamentClass = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId } = req.params;
const { name, isDoubles, gender, minBirthYear } = req.body;
try {
const tournamentClass = await tournamentService.addTournamentClass(token, clubId, tournamentId, name, isDoubles, gender, minBirthYear);
emitTournamentChanged(clubId, tournamentId);
res.status(200).json(tournamentClass);
} catch (error) {
console.error('[addTournamentClass] Error:', error);
res.status(500).json({ error: error.message });
}
};
export const updateTournamentClass = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, classId } = req.params;
const { name, sortOrder, isDoubles, gender, minBirthYear } = req.body;
try {
console.log('[updateTournamentClass] Request body:', { name, sortOrder, isDoubles, gender, minBirthYear });
const tournamentClass = await tournamentService.updateTournamentClass(token, clubId, tournamentId, classId, name, sortOrder, isDoubles, gender, minBirthYear);
console.log('[updateTournamentClass] Updated class:', JSON.stringify(tournamentClass.toJSON(), null, 2));
emitTournamentChanged(clubId, tournamentId);
res.status(200).json(tournamentClass);
} catch (error) {
console.error('[updateTournamentClass] Error:', error);
res.status(500).json({ error: error.message });
}
};
export const deleteTournamentClass = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, classId } = req.params;
try {
await tournamentService.deleteTournamentClass(token, clubId, tournamentId, classId);
emitTournamentChanged(clubId, tournamentId);
res.status(200).json({ message: 'Klasse gelöscht' });
} catch (error) {
console.error('[deleteTournamentClass] Error:', error);
res.status(500).json({ error: error.message });
}
};
export const updateParticipantClass = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, participantId } = req.params;
const { classId, isExternal } = req.body;
try {
await tournamentService.updateParticipantClass(token, clubId, tournamentId, participantId, classId, isExternal);
emitTournamentChanged(clubId, tournamentId);
res.status(200).json({ message: 'Klasse aktualisiert' });
} catch (error) {
console.error('[updateParticipantClass] Error:', error);
res.status(500).json({ error: error.message });
}
};
// Tournament Pairings
export const getPairings = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, classId } = req.params;
try {
const pairings = await tournamentService.getPairings(token, clubId, tournamentId, classId);
res.status(200).json(pairings);
} catch (error) {
console.error('[getPairings] Error:', error);
res.status(500).json({ error: error.message });
}
};
export const createPairing = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, classId } = req.params;
const { player1Type, player1Id, player2Type, player2Id, seeded, groupId } = req.body;
try {
const pairing = await tournamentService.createPairing(token, clubId, tournamentId, classId, player1Type, player1Id, player2Type, player2Id, seeded, groupId);
emitTournamentChanged(clubId, tournamentId);
res.status(200).json(pairing);
} catch (error) {
console.error('[createPairing] Error:', error);
res.status(500).json({ error: error.message });
}
};
export const updatePairing = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, pairingId } = req.params;
const { player1Type, player1Id, player2Type, player2Id, seeded, groupId } = req.body;
try {
const pairing = await tournamentService.updatePairing(token, clubId, tournamentId, pairingId, player1Type, player1Id, player2Type, player2Id, seeded, groupId);
emitTournamentChanged(clubId, tournamentId);
res.status(200).json(pairing);
} catch (error) {
console.error('[updatePairing] Error:', error);
res.status(500).json({ error: error.message });
}
};
export const deletePairing = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, pairingId } = req.params;
try {
await tournamentService.deletePairing(token, clubId, tournamentId, pairingId);
emitTournamentChanged(clubId, tournamentId);
res.status(200).json({ message: 'Paarung gelöscht' });
} catch (error) {
console.error('[deletePairing] Error:', error);
res.status(500).json({ error: error.message });
}
};

View File

@@ -1,70 +0,0 @@
import tournamentService from '../services/tournamentService.js';
import HttpError from '../exceptions/HttpError.js';
export const getStages = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId } = req.query;
try {
if (clubId == null || tournamentId == null) {
return res.status(400).json({ error: 'clubId und tournamentId sind erforderlich.' });
}
const data = await tournamentService.getTournamentStages(token, Number(clubId), Number(tournamentId));
res.status(200).json(data);
} catch (error) {
console.error(error);
if (error instanceof HttpError) {
// Debug-Hilfe: zeigt, welche IDs tatsächlich am Endpoint ankamen (ohne sensible Daten)
res.set('x-debug-stages-clubid', String(clubId));
res.set('x-debug-stages-tournamentid', String(tournamentId));
res.set('x-debug-stages-clubid-num', String(Number(clubId)));
return res.status(error.statusCode || 500).json({ error: error.message });
}
res.status(500).json({ error: error.message });
}
};
export const upsertStages = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, stages, advancement, advancements } = req.body;
try {
const data = await tournamentService.upsertTournamentStages(
token,
Number(clubId),
Number(tournamentId),
stages,
advancement,
advancements
);
res.status(200).json(data);
} catch (error) {
console.error(error);
if (error instanceof HttpError) {
res.set('x-debug-stages-clubid', String(clubId));
res.set('x-debug-stages-tournamentid', String(tournamentId));
res.set('x-debug-stages-clubid-num', String(Number(clubId)));
return res.status(error.statusCode || 500).json({ error: error.message });
}
res.status(500).json({ error: error.message });
}
};
export const advanceStage = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, fromStageIndex, toStageIndex } = req.body;
try {
const data = await tournamentService.advanceTournamentStage(
token,
Number(clubId),
Number(tournamentId),
Number(fromStageIndex || 1),
(toStageIndex == null ? null : Number(toStageIndex))
);
res.status(200).json(data);
} catch (error) {
console.error(error);
if (error instanceof HttpError) {
return res.status(error.statusCode || 500).json({ error: error.message });
}
res.status(500).json({ error: error.message });
}
};

View File

@@ -1,128 +0,0 @@
import trainingGroupService from '../services/trainingGroupService.js';
import { getSafeErrorMessage } from '../utils/errorUtils.js';
export const getTrainingGroups = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId } = req.params;
const groups = await trainingGroupService.getTrainingGroups(userToken, clubId);
res.status(200).json(groups);
} catch (error) {
console.error('[getTrainingGroups] - Error:', error);
const msg = getSafeErrorMessage(error, 'Fehler beim Laden der Trainingsgruppen');
res.status(error.statusCode || 500).json({ error: msg });
}
};
export const createTrainingGroup = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId } = req.params;
const { name, sortOrder } = req.body;
const group = await trainingGroupService.createTrainingGroup(userToken, clubId, name, sortOrder);
res.status(201).json(group);
} catch (error) {
console.error('[createTrainingGroup] - Error:', error);
const msg = getSafeErrorMessage(error, 'Fehler beim Erstellen der Trainingsgruppe');
res.status(error.statusCode || 500).json({ error: msg });
}
};
export const updateTrainingGroup = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId, groupId } = req.params;
const { name, sortOrder } = req.body;
const group = await trainingGroupService.updateTrainingGroup(userToken, clubId, groupId, name, sortOrder);
res.status(200).json(group);
} catch (error) {
console.error('[updateTrainingGroup] - Error:', error);
const msg = getSafeErrorMessage(error, 'Fehler beim Aktualisieren der Trainingsgruppe');
res.status(error.statusCode || 500).json({ error: msg });
}
};
export const deleteTrainingGroup = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId, groupId } = req.params;
await trainingGroupService.deleteTrainingGroup(userToken, clubId, groupId);
res.status(200).json({ success: true });
} catch (error) {
console.error('[deleteTrainingGroup] - Error:', error);
const msg = getSafeErrorMessage(error, 'Fehler beim Löschen der Trainingsgruppe');
res.status(error.statusCode || 500).json({ error: msg });
}
};
export const addMemberToGroup = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId, groupId, memberId } = req.params;
const memberGroup = await trainingGroupService.addMemberToGroup(userToken, clubId, groupId, memberId);
res.status(201).json(memberGroup);
} catch (error) {
console.error('[addMemberToGroup] - Error:', error);
const msg = getSafeErrorMessage(error, 'Fehler beim Hinzufügen des Mitglieds zur Gruppe');
res.status(error.statusCode || 500).json({ error: msg });
}
};
export const removeMemberFromGroup = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId, groupId, memberId } = req.params;
await trainingGroupService.removeMemberFromGroup(userToken, clubId, groupId, memberId);
res.status(200).json({ success: true });
} catch (error) {
console.error('[removeMemberFromGroup] - Error:', error);
const msg = getSafeErrorMessage(error, 'Fehler beim Entfernen des Mitglieds aus der Gruppe');
res.status(error.statusCode || 500).json({ error: msg });
}
};
export const getMemberGroups = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId, memberId } = req.params;
const groups = await trainingGroupService.getMemberGroups(userToken, clubId, memberId);
res.status(200).json(groups);
} catch (error) {
console.error('[getMemberGroups] - Error:', error);
const msg = getSafeErrorMessage(error, 'Fehler beim Laden der Gruppen des Mitglieds');
res.status(error.statusCode || 500).json({ error: msg });
}
};
export const ensurePresetGroups = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId } = req.params;
const groups = await trainingGroupService.ensurePresetGroups(userToken, clubId);
res.status(200).json({
message: 'Preset-Gruppen wurden erstellt/überprüft',
groups: groups.length
});
} catch (error) {
console.error('[ensurePresetGroups] - Error:', error);
const msg = getSafeErrorMessage(error, 'Fehler beim Erstellen der Preset-Gruppen');
res.status(error.statusCode || 500).json({ error: msg });
}
};
export const enablePresetGroup = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId, presetType } = req.params;
const group = await trainingGroupService.enablePresetGroup(userToken, clubId, presetType);
res.status(200).json({
message: 'Preset-Gruppe wurde aktiviert',
group
});
} catch (error) {
console.error('[enablePresetGroup] - Error:', error);
const msg = getSafeErrorMessage(error, 'Fehler beim Aktivieren der Preset-Gruppe');
res.status(error.statusCode || 500).json({ error: msg });
}
};

View File

@@ -1,80 +0,0 @@
import trainingTimeService from '../services/trainingTimeService.js';
import { getSafeErrorMessage } from '../utils/errorUtils.js';
export const getTrainingTimes = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId } = req.params;
const groups = await trainingTimeService.getTrainingTimes(userToken, clubId);
res.status(200).json(groups);
} catch (error) {
console.error('[getTrainingTimes] - Error:', error);
const msg = getSafeErrorMessage(error, 'Fehler beim Laden der Trainingszeiten');
res.status(error.statusCode || 500).json({ error: msg });
}
};
export const createTrainingTime = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId } = req.params;
const { trainingGroupId, weekday, startTime, endTime } = req.body;
if (!trainingGroupId || weekday === undefined || !startTime || !endTime) {
return res.status(400).json({ error: 'Alle Felder müssen ausgefüllt sein' });
}
const trainingTime = await trainingTimeService.createTrainingTime(
userToken,
clubId,
trainingGroupId,
weekday,
startTime,
endTime
);
res.status(201).json(trainingTime);
} catch (error) {
console.error('[createTrainingTime] - Error:', error);
const msg = getSafeErrorMessage(error, 'Fehler beim Erstellen der Trainingszeit');
res.status(error.statusCode || 500).json({ error: msg });
}
};
export const updateTrainingTime = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId, timeId } = req.params;
const { weekday, startTime, endTime } = req.body;
const trainingTime = await trainingTimeService.updateTrainingTime(
userToken,
clubId,
timeId,
weekday,
startTime,
endTime
);
res.status(200).json(trainingTime);
} catch (error) {
console.error('[updateTrainingTime] - Error:', error);
const msg = getSafeErrorMessage(error, 'Fehler beim Aktualisieren der Trainingszeit');
res.status(error.statusCode || 500).json({ error: msg });
}
};
export const deleteTrainingTime = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId, timeId } = req.params;
const result = await trainingTimeService.deleteTrainingTime(userToken, clubId, timeId);
res.status(200).json(result);
} catch (error) {
console.error('[deleteTrainingTime] - Error:', error);
const msg = getSafeErrorMessage(error, 'Fehler beim Löschen der Trainingszeit');
res.status(error.statusCode || 500).json({ error: msg });
}
};

View File

@@ -10,8 +10,7 @@ const sequelize = new Sequelize(
host: development.host,
dialect: development.dialect,
define: development.define,
logging: development.logging ?? false,
storage: development.storage,
logging: false, // SQL-Logging deaktivieren
}
);

View File

@@ -1,54 +1,10 @@
/**
* HttpError mit Unterstützung für Fehlercodes
*
* Verwendung:
* - new HttpError('Fehlermeldung', 400) - Legacy, wird weiterhin unterstützt
* - new HttpError({ code: 'ERROR_USER_NOT_FOUND' }, 404) - Mit Fehlercode
* - new HttpError({ code: 'ERROR_MEMBER_NOT_FOUND', params: { memberId: 123 } }, 404) - Mit Parametern
*/
class HttpError extends Error {
constructor(messageOrError, statusCode) {
// Unterstützung für beide Formate:
// 1. Legacy: new HttpError('Fehlermeldung', 400)
// 2. Neu: new HttpError({ code: 'ERROR_CODE', params: {...} }, 400)
if (typeof messageOrError === 'string') {
// Legacy-Format
super(messageOrError);
this.errorCode = null;
this.errorParams = null;
} else if (messageOrError && typeof messageOrError === 'object' && messageOrError.code) {
// Neues Format mit Fehlercode
super(messageOrError.code); // Für Stack-Trace
this.errorCode = messageOrError.code;
this.errorParams = messageOrError.params || null;
} else {
// Fallback
super('Unknown error');
this.errorCode = null;
this.errorParams = null;
}
constructor(message, statusCode) {
super(message);
this.name = this.constructor.name;
this.statusCode = statusCode || 500;
this.statusCode = statusCode;
Error.captureStackTrace(this, this.constructor);
}
/**
* Gibt das Fehler-Objekt für die API-Antwort zurück
* @returns {object} Fehler-Objekt mit code und optional params
*/
toJSON() {
if (this.errorCode) {
return {
code: this.errorCode,
...(this.errorParams && { params: this.errorParams })
};
}
// Legacy: Gib die Nachricht zurück
return {
message: this.message
};
}
}
export default HttpError;

View File

@@ -6,9 +6,6 @@ export const authenticate = async (req, res, next) => {
if (!token) {
token = req.headers['authcode'];
}
if (!token) {
token = req.query?.authcode || req.query?.token || null;
}
if (!token) {
return res.status(401).json({ error: 'Unauthorized: Token fehlt' });
}

View File

@@ -1,215 +0,0 @@
import permissionService from '../services/permissionService.js';
/**
* Authorization Middleware
* Checks if user has permission to access a resource
*/
/**
* Check if user has permission for a specific resource and action
* @param {string} resource - Resource name (diary, members, teams, etc.)
* @param {string} action - Action type (read, write, delete)
* @returns {Function} Express middleware function
*/
export const authorize = (resource, action = 'read') => {
return async (req, res, next) => {
try {
const userId = req.user?.id;
if (!userId) {
return res.status(401).json({ error: 'Nicht authentifiziert' });
}
// Get clubId from various possible sources
const clubId =
req.params.clubId ??
req.params.clubid ??
req.params.id ??
req.body.clubId ??
req.body.clubid ??
req.query.clubId ??
req.query.clubid;
if (!clubId) {
return res.status(400).json({ error: 'Club-ID fehlt' });
}
// Check permission
const hasPermission = await permissionService.hasPermission(
userId,
parseInt(clubId),
resource,
action
);
if (!hasPermission) {
return res.status(403).json({
error: 'Keine Berechtigung',
details: `Fehlende Berechtigung: ${resource}.${action}`
});
}
// Store permissions in request for later use
const userPermissions = await permissionService.getUserClubPermissions(
userId,
parseInt(clubId)
);
req.userPermissions = userPermissions;
next();
} catch (error) {
console.error('Authorization error:', error);
res.status(500).json({ error: 'Fehler bei der Berechtigungsprüfung' });
}
};
};
/**
* Check if user is club owner
* @returns {Function} Express middleware function
*/
export const requireOwner = () => {
return async (req, res, next) => {
try {
const userId = req.user?.id;
if (!userId) {
return res.status(401).json({ error: 'Nicht authentifiziert' });
}
const clubId =
req.params.clubId ??
req.params.clubid ??
req.params.id ??
req.body.clubId ??
req.body.clubid ??
req.query.clubId ??
req.query.clubid;
if (!clubId) {
return res.status(400).json({ error: 'Club-ID fehlt' });
}
const userPermissions = await permissionService.getUserClubPermissions(
userId,
parseInt(clubId)
);
if (!userPermissions || !userPermissions.isOwner) {
return res.status(403).json({
error: 'Keine Berechtigung',
details: 'Nur der Club-Ersteller hat Zugriff'
});
}
req.userPermissions = userPermissions;
next();
} catch (error) {
console.error('Owner check error:', error);
res.status(500).json({ error: 'Fehler bei der Berechtigungsprüfung' });
}
};
};
/**
* Check if user is admin (owner or admin role)
* @returns {Function} Express middleware function
*/
export const requireAdmin = () => {
return async (req, res, next) => {
try {
const userId = req.user?.id;
if (!userId) {
return res.status(401).json({ error: 'Nicht authentifiziert' });
}
const clubId =
req.params.clubId ??
req.params.clubid ??
req.params.id ??
req.body.clubId ??
req.body.clubid ??
req.query.clubId ??
req.query.clubid;
if (!clubId) {
return res.status(400).json({ error: 'Club-ID fehlt' });
}
const userPermissions = await permissionService.getUserClubPermissions(
userId,
parseInt(clubId)
);
if (!userPermissions || (userPermissions.role !== 'admin' && !userPermissions.isOwner)) {
return res.status(403).json({
error: 'Keine Berechtigung',
details: 'Administrator-Rechte erforderlich'
});
}
req.userPermissions = userPermissions;
next();
} catch (error) {
console.error('Admin check error:', error);
res.status(500).json({ error: 'Fehler bei der Berechtigungsprüfung' });
}
};
};
/**
* Check if user has any of the specified roles
* @param {string[]} roles - Array of allowed roles
* @returns {Function} Express middleware function
*/
export const requireRole = (roles) => {
return async (req, res, next) => {
try {
const userId = req.user?.id;
if (!userId) {
return res.status(401).json({ error: 'Nicht authentifiziert' });
}
const clubId =
req.params.clubId ??
req.params.clubid ??
req.params.id ??
req.body.clubId ??
req.body.clubid ??
req.query.clubId ??
req.query.clubid;
if (!clubId) {
return res.status(400).json({ error: 'Club-ID fehlt' });
}
const userPermissions = await permissionService.getUserClubPermissions(
userId,
parseInt(clubId)
);
if (!userPermissions || !roles.includes(userPermissions.role)) {
return res.status(403).json({
error: 'Keine Berechtigung',
details: `Erforderliche Rolle: ${roles.join(', ')}`
});
}
req.userPermissions = userPermissions;
next();
} catch (error) {
console.error('Role check error:', error);
res.status(500).json({ error: 'Fehler bei der Berechtigungsprüfung' });
}
};
};
export default {
authorize,
requireOwner,
requireAdmin,
requireRole
};

View File

@@ -1,13 +0,0 @@
/**
* Middleware to log all API requests and responses
* Should be added early in the middleware chain, but after authentication
*
* HINWEIS: Logging wurde deaktiviert - keine API-Requests werden mehr geloggt
* (früher wurden nur MyTischtennis-Requests geloggt, dies wurde entfernt)
*/
export const requestLoggingMiddleware = async (req, res, next) => {
// Logging wurde deaktiviert - keine API-Requests werden mehr geloggt
// (früher wurden nur MyTischtennis-Requests geloggt, dies wurde entfernt)
next();
};

View File

@@ -1,17 +0,0 @@
-- Create table for storing multiple images per member
CREATE TABLE IF NOT EXISTS `member_image` (
`id` INT NOT NULL AUTO_INCREMENT,
`member_id` INT NOT NULL,
`file_name` VARCHAR(255) NOT NULL,
`sort_order` INT NOT NULL DEFAULT 0,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_member_image_member_id` (`member_id`),
CONSTRAINT `fk_member_image_member`
FOREIGN KEY (`member_id`)
REFERENCES `member` (`id`)
ON DELETE CASCADE
ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -1,58 +0,0 @@
-- Adds multi-stage tournaments (rounds) support
-- MariaDB/MySQL compatible migration (manual execution)
-- 1) New table: tournament_stage
CREATE TABLE IF NOT EXISTS tournament_stage (
id INT NOT NULL AUTO_INCREMENT,
tournament_id INT NOT NULL,
stage_index INT NOT NULL,
name VARCHAR(255) NULL,
type VARCHAR(32) NOT NULL, -- 'groups' | 'knockout'
number_of_groups INT NULL,
advancing_per_group INT NULL,
max_group_size INT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
CONSTRAINT fk_tournament_stage_tournament
FOREIGN KEY (tournament_id) REFERENCES tournament(id)
ON DELETE CASCADE
) ENGINE=InnoDB;
CREATE INDEX idx_tournament_stage_tournament_id ON tournament_stage (tournament_id);
CREATE UNIQUE INDEX uq_tournament_stage_tournament_id_index ON tournament_stage (tournament_id, stage_index);
-- 2) New table: tournament_stage_advancement
CREATE TABLE IF NOT EXISTS tournament_stage_advancement (
id INT NOT NULL AUTO_INCREMENT,
tournament_id INT NOT NULL,
from_stage_id INT NOT NULL,
to_stage_id INT NOT NULL,
mode VARCHAR(32) NOT NULL DEFAULT 'pools',
config JSON NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
CONSTRAINT fk_tournament_stage_adv_tournament
FOREIGN KEY (tournament_id) REFERENCES tournament(id)
ON DELETE CASCADE,
CONSTRAINT fk_tournament_stage_adv_from
FOREIGN KEY (from_stage_id) REFERENCES tournament_stage(id)
ON DELETE CASCADE,
CONSTRAINT fk_tournament_stage_adv_to
FOREIGN KEY (to_stage_id) REFERENCES tournament_stage(id)
ON DELETE CASCADE
) ENGINE=InnoDB;
CREATE INDEX idx_tournament_stage_adv_tournament_id ON tournament_stage_advancement (tournament_id);
CREATE INDEX idx_tournament_stage_adv_from_stage_id ON tournament_stage_advancement (from_stage_id);
CREATE INDEX idx_tournament_stage_adv_to_stage_id ON tournament_stage_advancement (to_stage_id);
-- 3) Add stage_id to tournament_group and tournament_match
-- MariaDB has no IF NOT EXISTS for columns; run each ALTER once.
-- If you rerun, comment out the ALTERs or check INFORMATION_SCHEMA first.
ALTER TABLE tournament_group ADD COLUMN stage_id INT NULL;
ALTER TABLE tournament_match ADD COLUMN stage_id INT NULL;
CREATE INDEX idx_tournament_group_tournament_stage ON tournament_group (tournament_id, stage_id);
CREATE INDEX idx_tournament_match_tournament_stage ON tournament_match (tournament_id, stage_id);

View File

@@ -1,16 +0,0 @@
-- Allow NULL placeholders for KO (e.g. "Spiel um Platz 3")
-- MariaDB/MySQL manual migration
--
-- Background: We create placeholder matches with player1_id/player2_id = NULL.
-- Some prod DBs still have NOT NULL on these columns.
-- 1) Make player columns nullable
ALTER TABLE tournament_match MODIFY COLUMN player1_id INT NULL;
ALTER TABLE tournament_match MODIFY COLUMN player2_id INT NULL;
-- 2) (Optional) If you have foreign keys to tournament_member/external participant IDs,
-- ensure they also allow NULL. (Not adding here because not all installations have FKs.)
-- 3) Verify
-- SHOW COLUMNS FROM tournament_match LIKE 'player1_id';
-- SHOW COLUMNS FROM tournament_match LIKE 'player2_id';

View File

@@ -1,77 +0,0 @@
# Liste aller Tabellen im Trainingstagebuch-Projekt
## Basis-Tabellen
1. `user` - Benutzer
2. `user_club` - Verknüpfung Benutzer ↔ Verein
3. `user_token` - Authentifizierungs-Tokens
4. `clubs` - Vereine
5. `log` - System-Logs
## Mitglieder-Verwaltung
6. `member` - Mitglieder
7. `member_contact` - Kontaktdaten der Mitglieder (Telefon, E-Mail)
8. `member_image` - Bilder der Mitglieder
9. `member_notes` - Notizen zu Mitgliedern
10. `member_transfer_config` - Konfiguration für Mitgliederübertragung
## Trainingsgruppen (NEU)
11. `training_group` - Trainingsgruppen
12. `member_training_group` - Verknüpfung Mitglied ↔ Trainingsgruppe
13. `club_disabled_preset_groups` - Deaktivierte Preset-Gruppen pro Verein
14. `training_times` - Trainingszeiten pro Gruppe (NEU)
## Tagebuch
15. `diary_dates` - Trainingstage
16. `participants` - Teilnehmer an Trainingstagen
17. `activities` - Aktivitäten
18. `diary_notes` - Notizen zu Trainingstagen
19. `diary_tags` - Tags für Tagebuch
20. `member_diary_tags` - Verknüpfung Mitglied ↔ Tagebuch-Tag
21. `diary_date_tags` - Verknüpfung Trainingstag ↔ Tag
22. `diary_member_notes` - Notizen zu Mitgliedern an Trainingstagen
23. `diary_member_tags` - Tags für Mitglieder an Trainingstagen
24. `diary_date_activities` - Aktivitäten an Trainingstagen
25. `diary_member_activities` - Verknüpfung Teilnehmer ↔ Aktivität
26. `group` - Gruppen (für Trainingsplan)
27. `group_activity` - Gruppenaktivitäten
## Vordefinierte Aktivitäten
28. `predefined_activities` - Vordefinierte Aktivitäten
29. `predefined_activity_images` - Bilder zu vordefinierten Aktivitäten
## Unfälle
30. `accident` - Unfälle
## Teams & Ligen
31. `season` - Saisons
32. `league` - Ligen
33. `team` - Teams
34. `club_team` - Verknüpfung Verein ↔ Team
35. `team_document` - Dokumente zu Teams
36. `match` - Spiele
37. `location` - Spielorte
## Turniere
38. `tournament` - Turniere
39. `tournament_class` - Turnierklassen
40. `tournament_group` - Turniergruppen
41. `tournament_member` - Teilnehmer an Turnieren
42. `tournament_match` - Spiele in Turnieren
43. `tournament_result` - Ergebnisse von Turnierspielen
44. `external_tournament_participant` - Externe Teilnehmer an Turnieren
## Offizielle Turniere (myTischtennis)
45. `official_tournaments` - Offizielle Turniere
46. `official_competitions` - Wettbewerbe in offiziellen Turnieren
47. `official_competition_members` - Teilnehmer an offiziellen Wettbewerben
## myTischtennis Integration
48. `my_tischtennis` - myTischtennis-Verbindungen
49. `my_tischtennis_update_history` - Update-Historie
50. `my_tischtennis_fetch_log` - Fetch-Logs
## API & Logging
51. `api_log` - API-Logs
## Gesamt: 51 Tabellen

View File

@@ -1,22 +0,0 @@
-- Migration: Add 'allows_external' column to tournament table
-- Date: 2025-01-15
-- For MariaDB/MySQL
SET @dbname = DATABASE();
SET @tablename = 'tournament';
SET @columnname = 'allows_external';
SET @preparedStatement = (SELECT IF(
(
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE
(TABLE_SCHEMA = @dbname)
AND (TABLE_NAME = @tablename)
AND (COLUMN_NAME = @columnname)
) > 0,
'SELECT 1',
CONCAT('ALTER TABLE `', @tablename, '` ADD COLUMN `', @columnname, '` TINYINT(1) NOT NULL DEFAULT 0 AFTER `winning_sets`')
));
PREPARE alterIfNotExists FROM @preparedStatement;
EXECUTE alterIfNotExists;
DEALLOCATE PREPARE alterIfNotExists;

View File

@@ -1,13 +0,0 @@
-- Migration: Add auto update ratings fields to my_tischtennis table
-- Date: 2025-01-27
-- Add auto_update_ratings column
ALTER TABLE my_tischtennis
ADD COLUMN auto_update_ratings BOOLEAN NOT NULL DEFAULT FALSE;
-- Add last_update_ratings column
ALTER TABLE my_tischtennis
ADD COLUMN last_update_ratings TIMESTAMP NULL;
-- Create index for auto_update_ratings for efficient querying
CREATE INDEX idx_my_tischtennis_auto_update_ratings ON my_tischtennis(auto_update_ratings);

View File

@@ -1,27 +0,0 @@
-- Migration: Add 'class_id' column to external_tournament_participant table
-- Date: 2025-01-15
-- For MariaDB/MySQL
SET @dbname = DATABASE();
SET @tablename = 'external_tournament_participant';
SET @columnname = 'class_id';
-- Check if column exists
SET @column_exists = (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE
(TABLE_SCHEMA = @dbname)
AND (TABLE_NAME = @tablename)
AND (COLUMN_NAME = @columnname)
);
-- Add column if it doesn't exist
SET @sql = IF(@column_exists = 0,
'ALTER TABLE `external_tournament_participant` ADD COLUMN `class_id` INT(11) NULL AFTER `seeded`',
'SELECT 1 AS column_already_exists'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@@ -1,27 +0,0 @@
-- Migration: Add 'class_id' column to tournament_group table
-- Date: 2025-01-15
-- For MariaDB/MySQL
SET @dbname = DATABASE();
SET @tablename = 'tournament_group';
SET @columnname = 'class_id';
-- Check if column exists
SET @column_exists = (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE
(TABLE_SCHEMA = @dbname)
AND (TABLE_NAME = @tablename)
AND (COLUMN_NAME = @columnname)
);
-- Add column if it doesn't exist
SET @sql = IF(@column_exists = 0,
'ALTER TABLE `tournament_group` ADD COLUMN `class_id` INT(11) NULL AFTER `tournament_id`',
'SELECT 1 AS column_already_exists'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@@ -1,27 +0,0 @@
-- Migration: Add 'class_id' column to tournament_match table
-- Date: 2025-01-16
-- For MariaDB/MySQL
SET @dbname = DATABASE();
SET @tablename = 'tournament_match';
SET @columnname = 'class_id';
-- Check if column exists
SET @column_exists = (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE
(TABLE_SCHEMA = @dbname)
AND (TABLE_NAME = @tablename)
AND (COLUMN_NAME = @columnname)
);
-- Add column if it doesn't exist
SET @sql = IF(@column_exists = 0,
'ALTER TABLE `tournament_match` ADD COLUMN `class_id` INT(11) NULL AFTER `group_id`',
'SELECT 1 AS column_already_exists'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@@ -1,22 +0,0 @@
-- Migration: Add 'class_id' column to tournament_member table
-- Date: 2025-01-15
-- For MariaDB/MySQL
SET @dbname = DATABASE();
SET @tablename = 'tournament_member';
SET @columnname = 'class_id';
SET @preparedStatement = (SELECT IF(
(
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE
(TABLE_SCHEMA = @dbname)
AND (TABLE_NAME = @tablename)
AND (COLUMN_NAME = @columnname)
) > 0,
'SELECT 1',
CONCAT('ALTER TABLE `', @tablename, '` ADD COLUMN `', @columnname, '` INT(11) NULL AFTER `seeded`')
));
PREPARE alterIfNotExists FROM @preparedStatement;
EXECUTE alterIfNotExists;
DEALLOCATE PREPARE alterIfNotExists;

View File

@@ -1,8 +0,0 @@
-- Migration: Geschlecht zu externen Turnierteilnehmern hinzufügen
-- Datum: 2025-01-XX
ALTER TABLE `external_tournament_participant`
ADD COLUMN `gender` ENUM('male', 'female', 'diverse', 'unknown') NULL DEFAULT 'unknown' AFTER `birth_date`;

View File

@@ -1,8 +0,0 @@
-- Migration: Geschlecht zu Turnierklassen hinzufügen
-- Datum: 2025-01-XX
ALTER TABLE `tournament_class`
ADD COLUMN `gender` ENUM('male', 'female', 'mixed') NULL DEFAULT NULL AFTER `is_doubles`;

View File

@@ -1,5 +0,0 @@
ALTER TABLE clubs
ADD COLUMN IF NOT EXISTS greeting_text TEXT NULL,
ADD COLUMN IF NOT EXISTS association_member_number VARCHAR(255) NULL;

View File

@@ -1,9 +0,0 @@
-- Migration: Add group_id to participants table
-- This allows assigning participants to groups for training organization
ALTER TABLE participants
ADD COLUMN group_id INTEGER NULL REFERENCES "group"(id) ON DELETE SET NULL ON UPDATE CASCADE;
-- Add index for better query performance
CREATE INDEX IF NOT EXISTS idx_participants_group_id ON participants(group_id);

View File

@@ -1,22 +0,0 @@
-- Migration: Add 'is_active' column to tournament_match table
-- Date: 2025-01-14
-- For MariaDB/MySQL
SET @dbname = DATABASE();
SET @tablename = 'tournament_match';
SET @columnname = 'is_active';
SET @preparedStatement = (SELECT IF(
(
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE
(TABLE_SCHEMA = @dbname)
AND (TABLE_NAME = @tablename)
AND (COLUMN_NAME = @columnname)
) > 0,
'SELECT 1',
CONCAT('ALTER TABLE `', @tablename, '` ADD COLUMN `', @columnname, '` TINYINT(1) NOT NULL DEFAULT 0 AFTER `is_finished`')
));
PREPARE alterIfNotExists FROM @preparedStatement;
EXECUTE alterIfNotExists;
DEALLOCATE PREPARE alterIfNotExists;

View File

@@ -1,7 +0,0 @@
-- Migration: Add is_doubles column to tournament_class table
-- Date: 2025-01-23
-- For MariaDB/MySQL
ALTER TABLE `tournament_class`
ADD COLUMN `is_doubles` TINYINT(1) NOT NULL DEFAULT 0 AFTER `sort_order`;

View File

@@ -1,28 +0,0 @@
-- Migration: Add match result fields to match table
-- Date: 2025-01-27
-- For MariaDB
-- Add myTischtennis meeting ID
ALTER TABLE `match`
ADD COLUMN my_tischtennis_meeting_id VARCHAR(255) NULL UNIQUE COMMENT 'Meeting ID from myTischtennis (e.g. 15440488)';
-- Add home match points
ALTER TABLE `match`
ADD COLUMN home_match_points INT DEFAULT 0 NULL COMMENT 'Match points won by home team';
-- Add guest match points
ALTER TABLE `match`
ADD COLUMN guest_match_points INT DEFAULT 0 NULL COMMENT 'Match points won by guest team';
-- Add is_completed flag
ALTER TABLE `match`
ADD COLUMN is_completed BOOLEAN NOT NULL DEFAULT FALSE COMMENT 'Whether the match is completed';
-- Add PDF URL
ALTER TABLE `match`
ADD COLUMN pdf_url VARCHAR(512) NULL COMMENT 'PDF URL from myTischtennis';
-- Create indexes
CREATE INDEX idx_match_my_tischtennis_meeting_id ON `match`(my_tischtennis_meeting_id);
CREATE INDEX idx_match_is_completed ON `match`(is_completed);

View File

@@ -1,4 +0,0 @@
-- Add matches_tied column to team table
ALTER TABLE team
ADD COLUMN matches_tied INTEGER NOT NULL DEFAULT 0 AFTER matches_lost;

View File

@@ -1,27 +0,0 @@
-- Migration: Geburtsjahr-Beschränkung zu Turnierklassen hinzufügen
-- Datum: 2025-01-XX
-- Beschreibung: Fügt max_birth_year Feld hinzu für "geboren im Jahr X oder früher" (<=)
-- For MariaDB/MySQL
SET @dbname = DATABASE();
SET @tablename = 'tournament_class';
SET @columnname = 'max_birth_year';
-- Check if column exists
SET @column_exists = (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE
(TABLE_SCHEMA = @dbname)
AND (TABLE_NAME = @tablename)
AND (COLUMN_NAME = @columnname)
);
-- Add column if it doesn't exist
SET @sql = IF(@column_exists = 0,
'ALTER TABLE `tournament_class` ADD COLUMN `max_birth_year` INT(11) NULL DEFAULT NULL AFTER `gender`',
'SELECT 1 AS column_already_exists'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@@ -1,57 +0,0 @@
-- Add postal_code column to member table
ALTER TABLE `member`
ADD COLUMN `postal_code` TEXT NULL COMMENT 'Postal code (PLZ)' AFTER `city`;
-- Create member_contact table for multiple phone numbers and email addresses
CREATE TABLE IF NOT EXISTS `member_contact` (
`id` INT NOT NULL AUTO_INCREMENT,
`member_id` INT NOT NULL,
`type` ENUM('phone', 'email') NOT NULL COMMENT 'Type of contact: phone or email',
`value` TEXT NOT NULL,
`is_parent` BOOLEAN NOT NULL DEFAULT FALSE COMMENT 'Whether this contact belongs to a parent',
`parent_name` TEXT NULL COMMENT 'Name of the parent (e.g. "Mutter", "Vater", "Elternteil 1")',
`is_primary` BOOLEAN NOT NULL DEFAULT FALSE COMMENT 'Whether this is the primary contact of this type',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_member_id` (`member_id`),
INDEX `idx_type` (`type`),
CONSTRAINT `fk_member_contact_member`
FOREIGN KEY (`member_id`)
REFERENCES `member` (`id`)
ON DELETE CASCADE
ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Migrate existing phone numbers from member.phone to member_contact
INSERT INTO `member_contact` (`member_id`, `type`, `value`, `is_parent`, `parent_name`, `is_primary`, `created_at`, `updated_at`)
SELECT
`id` AS `member_id`,
'phone' AS `type`,
`phone` AS `value`,
FALSE AS `is_parent`,
NULL AS `parent_name`,
TRUE AS `is_primary`,
NOW() AS `created_at`,
NOW() AS `updated_at`
FROM `member`
WHERE `phone` IS NOT NULL
AND `phone` != ''
AND TRIM(`phone`) != '';
-- Migrate existing email addresses from member.email to member_contact
INSERT INTO `member_contact` (`member_id`, `type`, `value`, `is_parent`, `parent_name`, `is_primary`, `created_at`, `updated_at`)
SELECT
`id` AS `member_id`,
'email' AS `type`,
`email` AS `value`,
FALSE AS `is_parent`,
NULL AS `parent_name`,
TRUE AS `is_primary`,
NOW() AS `created_at`,
NOW() AS `updated_at`
FROM `member`
WHERE `email` IS NOT NULL
AND `email` != ''
AND TRIM(`email`) != '';

View File

@@ -1,19 +0,0 @@
-- Migration: Add myTischtennis fields to league table
-- Date: 2025-01-27
-- For MariaDB
-- Add my_tischtennis_group_id column
ALTER TABLE league
ADD COLUMN my_tischtennis_group_id VARCHAR(255) NULL COMMENT 'Group ID from myTischtennis (e.g. 504417)';
-- Add association column
ALTER TABLE league
ADD COLUMN association VARCHAR(255) NULL COMMENT 'Association/Verband (e.g. HeTTV)';
-- Add groupname column
ALTER TABLE league
ADD COLUMN groupname VARCHAR(255) NULL COMMENT 'Group name for URL (e.g. 1.Kreisklasse)';
-- Create index for efficient querying
CREATE INDEX idx_league_my_tischtennis_group_id ON league(my_tischtennis_group_id);

View File

@@ -1,11 +0,0 @@
-- Migration: Add myTischtennis player ID to member table
-- Date: 2025-01-27
-- For MariaDB
-- Add my_tischtennis_player_id column
ALTER TABLE member
ADD COLUMN my_tischtennis_player_id VARCHAR(255) NULL COMMENT 'Player ID from myTischtennis (e.g. NU2705037)';
-- Create index for efficient querying
CREATE INDEX idx_member_my_tischtennis_player_id ON member(my_tischtennis_player_id);

View File

@@ -1,11 +0,0 @@
-- Migration: Add myTischtennis team ID to club_team table
-- Date: 2025-01-27
-- For MariaDB
-- Add my_tischtennis_team_id column
ALTER TABLE club_team
ADD COLUMN my_tischtennis_team_id VARCHAR(255) NULL COMMENT 'Team ID from myTischtennis (e.g. 2995094)';
-- Create index for efficient querying
CREATE INDEX idx_club_team_my_tischtennis_team_id ON club_team(my_tischtennis_team_id);

View File

@@ -1,29 +0,0 @@
-- Migration: Add name column to tournament table
-- Date: 2025-01-13
-- For MariaDB/MySQL
-- Add name column if it doesn't exist
-- Check if column exists and add it if not
SET @dbname = DATABASE();
SET @tablename = 'tournament';
SET @columnname = 'name';
SET @preparedStatement = (SELECT IF(
(
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE
(TABLE_SCHEMA = @dbname)
AND (TABLE_NAME = @tablename)
AND (COLUMN_NAME = @columnname)
) > 0,
'SELECT 1',
CONCAT('ALTER TABLE `', @tablename, '` ADD COLUMN `', @columnname, '` VARCHAR(255) NOT NULL DEFAULT "" AFTER `id`')
));
PREPARE alterIfNotExists FROM @preparedStatement;
EXECUTE alterIfNotExists;
DEALLOCATE PREPARE alterIfNotExists;
-- Update existing tournaments: set name to formatted date if name is empty
UPDATE `tournament`
SET `name` = DATE_FORMAT(`date`, '%d.%m.%Y')
WHERE `name` = '' OR `name` IS NULL;

View File

@@ -1,17 +0,0 @@
-- Add role and permissions columns to user_club table
ALTER TABLE `user_club`
ADD COLUMN `role` VARCHAR(50) DEFAULT 'member' COMMENT 'User role: admin, trainer, team_manager, member' AFTER `approved`,
ADD COLUMN `permissions` JSON NULL COMMENT 'Specific permissions: {diary: {read: true, write: true}, members: {...}, ...}' AFTER `role`,
ADD COLUMN `is_owner` BOOLEAN DEFAULT FALSE COMMENT 'True if user created the club' AFTER `permissions`;
-- Create index for faster role lookups
CREATE INDEX `idx_user_club_role` ON `user_club` (`role`);
CREATE INDEX `idx_user_club_owner` ON `user_club` (`is_owner`);
-- Set existing approved users as members
UPDATE `user_club` SET `role` = 'member' WHERE `approved` = 1 AND `role` IS NULL;
-- If there's a user who created the club (we need to identify them somehow)
-- For now, we'll need to manually set the owner after migration

View File

@@ -1,8 +0,0 @@
-- Add player tracking fields to match table
-- These fields store arrays of member IDs for different participation states
ALTER TABLE `match`
ADD COLUMN `players_ready` JSON NULL COMMENT 'Array of member IDs who are ready to play' AFTER `pdf_url`,
ADD COLUMN `players_planned` JSON NULL COMMENT 'Array of member IDs who are planned to play' AFTER `players_ready`,
ADD COLUMN `players_played` JSON NULL COMMENT 'Array of member IDs who actually played' AFTER `players_planned`;

View File

@@ -1,44 +0,0 @@
-- Migration: Add season_id to teams table
-- First, add the column as nullable
ALTER TABLE `team` ADD COLUMN `season_id` INT NULL;
-- Get or create current season
SET @current_season_id = (
SELECT id FROM `season`
WHERE season = (
CASE
WHEN MONTH(CURDATE()) >= 7 THEN CONCAT(YEAR(CURDATE()), '/', YEAR(CURDATE()) + 1)
ELSE CONCAT(YEAR(CURDATE()) - 1, '/', YEAR(CURDATE()))
END
)
LIMIT 1
);
-- If no season exists, create it
INSERT IGNORE INTO `season` (season) VALUES (
CASE
WHEN MONTH(CURDATE()) >= 7 THEN CONCAT(YEAR(CURDATE()), '/', YEAR(CURDATE()) + 1)
ELSE CONCAT(YEAR(CURDATE()) - 1, '/', YEAR(CURDATE()))
END
);
-- Get the season ID again (in case we just created it)
SET @current_season_id = (
SELECT id FROM `season`
WHERE season = (
CASE
WHEN MONTH(CURDATE()) >= 7 THEN CONCAT(YEAR(CURDATE()), '/', YEAR(CURDATE()) + 1)
ELSE CONCAT(YEAR(CURDATE()) - 1, '/', YEAR(CURDATE()))
END
)
LIMIT 1
);
-- Update all existing teams to use the current season
UPDATE `team` SET `season_id` = @current_season_id WHERE `season_id` IS NULL;
-- Now make the column NOT NULL and add the foreign key constraint
ALTER TABLE `team` MODIFY COLUMN `season_id` INT NOT NULL;
ALTER TABLE `team` ADD CONSTRAINT `team_season_id_foreign_idx`
FOREIGN KEY (`season_id`) REFERENCES `season` (`id`)
ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -1,24 +0,0 @@
-- Migration: Add seeded column to tournament_member table
-- Date: 2025-01-13
-- For MariaDB/MySQL
-- Add seeded column if it doesn't exist
-- Check if column exists and add it if not
SET @dbname = DATABASE();
SET @tablename = 'tournament_member';
SET @columnname = 'seeded';
SET @preparedStatement = (SELECT IF(
(
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE
(TABLE_SCHEMA = @dbname)
AND (TABLE_NAME = @tablename)
AND (COLUMN_NAME = @columnname)
) > 0,
'SELECT 1',
CONCAT('ALTER TABLE `', @tablename, '` ADD COLUMN `', @columnname, '` TINYINT(1) NOT NULL DEFAULT 0 AFTER `club_member_id`')
));
PREPARE alterIfNotExists FROM @preparedStatement;
EXECUTE alterIfNotExists;
DEALLOCATE PREPARE alterIfNotExists;

View File

@@ -1,11 +0,0 @@
-- Migration: Add table fields to team table
-- Add fields for league table calculations
ALTER TABLE team ADD COLUMN matches_played INT NOT NULL DEFAULT 0;
ALTER TABLE team ADD COLUMN matches_won INT NOT NULL DEFAULT 0;
ALTER TABLE team ADD COLUMN matches_lost INT NOT NULL DEFAULT 0;
ALTER TABLE team ADD COLUMN sets_won INT NOT NULL DEFAULT 0;
ALTER TABLE team ADD COLUMN sets_lost INT NOT NULL DEFAULT 0;
ALTER TABLE team ADD COLUMN points_won INT NOT NULL DEFAULT 0;
ALTER TABLE team ADD COLUMN points_lost INT NOT NULL DEFAULT 0;
ALTER TABLE team ADD COLUMN table_points INT NOT NULL DEFAULT 0;

View File

@@ -1,5 +0,0 @@
-- Add table_points_won and table_points_lost columns to team table
ALTER TABLE team
ADD COLUMN table_points_won INTEGER NOT NULL DEFAULT 0 AFTER table_points,
ADD COLUMN table_points_lost INTEGER NOT NULL DEFAULT 0 AFTER table_points_won;

View File

@@ -1,22 +0,0 @@
-- Migration: Add 'winning_sets' column to tournament table
-- Date: 2025-01-14
-- For MariaDB/MySQL
SET @dbname = DATABASE();
SET @tablename = 'tournament';
SET @columnname = 'winning_sets';
SET @preparedStatement = (SELECT IF(
(
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE
(TABLE_SCHEMA = @dbname)
AND (TABLE_NAME = @tablename)
AND (COLUMN_NAME = @columnname)
) > 0,
'SELECT 1',
CONCAT('ALTER TABLE `', @tablename, '` ADD COLUMN `', @columnname, '` INT NOT NULL DEFAULT 3 AFTER `advancing_per_group`')
));
PREPARE alterIfNotExists FROM @preparedStatement;
EXECUTE alterIfNotExists;
DEALLOCATE PREPARE alterIfNotExists;

View File

@@ -1,41 +0,0 @@
-- Migration: Change 'ttr' column to 'birth_date' in external_tournament_participant table
-- Date: 2025-01-15
-- For MariaDB/MySQL
SET @dbname = DATABASE();
SET @tablename = 'external_tournament_participant';
SET @oldcolumnname = 'ttr';
SET @newcolumnname = 'birth_date';
-- Check if old column exists
SET @preparedStatement = (SELECT IF(
(
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE
(TABLE_SCHEMA = @dbname)
AND (TABLE_NAME = @tablename)
AND (COLUMN_NAME = @oldcolumnname)
) > 0,
CONCAT('ALTER TABLE `', @tablename, '` CHANGE COLUMN `', @oldcolumnname, '` `', @newcolumnname, '` VARCHAR(255) NULL AFTER `club`'),
'SELECT 1'
));
PREPARE alterIfExists FROM @preparedStatement;
EXECUTE alterIfExists;
DEALLOCATE PREPARE alterIfExists;
-- If old column didn't exist, check if new column exists and add it if not
SET @preparedStatement = (SELECT IF(
(
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE
(TABLE_SCHEMA = @dbname)
AND (TABLE_NAME = @tablename)
AND (COLUMN_NAME = @newcolumnname)
) > 0,
'SELECT 1',
CONCAT('ALTER TABLE `', @tablename, '` ADD COLUMN `', @newcolumnname, '` VARCHAR(255) NULL AFTER `club`')
));
PREPARE alterIfNotExists FROM @preparedStatement;
EXECUTE alterIfNotExists;
DEALLOCATE PREPARE alterIfNotExists;

View File

@@ -1,62 +0,0 @@
-- Diagnose-Skript: Prüfe Seasons und Teams auf dem Server
-- Führe diese Queries auf dem Server aus, um das Problem zu identifizieren
-- 1. Prüfe, ob die season-Tabelle existiert und Daten enthält
SELECT '=== SEASONS ===' as info;
SELECT * FROM `season` ORDER BY `id` DESC;
-- 2. Prüfe, ob die club_team-Tabelle existiert und welche season_id verwendet wird
SELECT '=== CLUB_TEAMS ===' as info;
SELECT
id,
name,
club_id,
season_id,
league_id,
created_at,
updated_at
FROM `club_team`
ORDER BY `id`;
-- 3. Prüfe, ob es Teams gibt, die auf nicht-existierende Seasons verweisen
SELECT '=== TEAMS MIT FEHLENDEN SEASONS ===' as info;
SELECT
ct.id,
ct.name,
ct.season_id,
s.season
FROM `club_team` ct
LEFT JOIN `season` s ON ct.season_id = s.id
WHERE s.id IS NULL;
-- 4. Prüfe, ob es Teams gibt, die keine season_id haben
SELECT '=== TEAMS OHNE SEASON_ID ===' as info;
SELECT
id,
name,
club_id,
season_id
FROM `club_team`
WHERE season_id IS NULL;
-- 5. Prüfe die Struktur der club_team-Tabelle
SELECT '=== CLUB_TEAM TABELLENSTRUKTUR ===' as info;
DESCRIBE `club_team`;
-- 6. Prüfe die Struktur der season-Tabelle
SELECT '=== SEASON TABELLENSTRUKTUR ===' as info;
DESCRIBE `season`;
-- 7. Prüfe Foreign Key Constraints
SELECT '=== FOREIGN KEYS ===' as info;
SELECT
CONSTRAINT_NAME,
TABLE_NAME,
COLUMN_NAME,
REFERENCED_TABLE_NAME,
REFERENCED_COLUMN_NAME
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = DATABASE()
AND (TABLE_NAME = 'club_team' OR TABLE_NAME = 'season')
AND REFERENCED_TABLE_NAME IS NOT NULL;

View File

@@ -1,30 +0,0 @@
-- Vereinfachtes Diagnose-Skript: Prüfe nur die wichtigsten Punkte
-- 1. Gibt es Seasons in der Datenbank?
SELECT 'SEASONS:' as check_type, COUNT(*) as count FROM `season`;
SELECT * FROM `season` ORDER BY `id` DESC;
-- 2. Gibt es Teams in der Datenbank?
SELECT 'CLUB_TEAMS:' as check_type, COUNT(*) as count FROM `club_team`;
SELECT id, name, club_id, season_id, league_id FROM `club_team` ORDER BY `id`;
-- 3. Haben alle Teams eine season_id?
SELECT 'TEAMS OHNE SEASON_ID:' as check_type, COUNT(*) as count
FROM `club_team` WHERE season_id IS NULL;
-- 4. Verweisen alle Teams auf existierende Seasons?
SELECT 'TEAMS MIT FEHLENDEN SEASONS:' as check_type, COUNT(*) as count
FROM `club_team` ct
LEFT JOIN `season` s ON ct.season_id = s.id
WHERE s.id IS NULL;
-- 5. Welche season_id verwenden die Teams?
SELECT 'SEASON_ID VERWENDUNG:' as check_type, season_id, COUNT(*) as team_count
FROM `club_team`
GROUP BY season_id;
-- 6. Welche Seasons existieren?
SELECT 'EXISTIERENDE SEASONS:' as check_type, id, season
FROM `season`
ORDER BY id;

View File

@@ -1,26 +0,0 @@
-- Migration: Create api_log table for comprehensive request/response and execution logging
CREATE TABLE IF NOT EXISTS api_log (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NULL,
method VARCHAR(10) NOT NULL COMMENT 'HTTP method (GET, POST, PUT, DELETE, etc.)',
path VARCHAR(500) NOT NULL COMMENT 'Request path',
status_code INT NULL COMMENT 'HTTP status code',
request_body TEXT NULL COMMENT 'Request body (truncated if too long)',
response_body TEXT NULL COMMENT 'Response body (truncated if too long)',
execution_time INT NULL COMMENT 'Execution time in milliseconds',
error_message TEXT NULL COMMENT 'Error message if request failed',
ip_address VARCHAR(45) NULL COMMENT 'Client IP address',
user_agent VARCHAR(500) NULL COMMENT 'User agent string',
log_type ENUM('api_request', 'scheduler', 'cron_job', 'manual') NOT NULL DEFAULT 'api_request' COMMENT 'Type of log entry',
scheduler_job_type VARCHAR(50) NULL COMMENT 'Type of scheduler job (rating_updates, match_results, etc.)',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE SET NULL ON UPDATE CASCADE,
INDEX idx_api_log_user_id (user_id, created_at),
INDEX idx_api_log_path (path, created_at),
INDEX idx_api_log_log_type (log_type, created_at),
INDEX idx_api_log_created_at (created_at),
INDEX idx_api_log_status_code (status_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -1,17 +0,0 @@
-- Migration: Create club_disabled_preset_groups table
-- Date: 2025-01-16
-- For MariaDB/MySQL
-- Stores which preset groups are disabled for each club
CREATE TABLE IF NOT EXISTS `club_disabled_preset_groups` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`club_id` INT(11) NOT NULL,
`preset_type` ENUM('anfaenger', 'fortgeschrittene', 'erwachsene', 'nachwuchs', 'leistungsgruppe') NOT NULL,
`created_at` DATETIME NOT NULL,
`updated_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `unique_club_preset_type` (`club_id`, `preset_type`),
KEY `club_id` (`club_id`),
CONSTRAINT `club_disabled_preset_groups_ibfk_1` FOREIGN KEY (`club_id`) REFERENCES `clubs` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -1,22 +0,0 @@
-- Migration: Create external_tournament_participant table
-- Date: 2025-01-15
-- For MariaDB/MySQL
CREATE TABLE IF NOT EXISTS `external_tournament_participant` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`tournament_id` INT(11) NOT NULL,
`group_id` INT(11) NULL,
`first_name` VARCHAR(255) NOT NULL,
`last_name` VARCHAR(255) NOT NULL,
`club` VARCHAR(255) NULL,
`birth_date` VARCHAR(255) NULL,
`seeded` TINYINT(1) NOT NULL DEFAULT 0,
`created_at` DATETIME NOT NULL,
`updated_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
INDEX `idx_tournament_id` (`tournament_id`),
INDEX `idx_group_id` (`group_id`),
CONSTRAINT `fk_external_participant_tournament` FOREIGN KEY (`tournament_id`) REFERENCES `tournament` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `fk_external_participant_group` FOREIGN KEY (`group_id`) REFERENCES `tournament_group` (`id`) ON DELETE SET NULL ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -1,20 +0,0 @@
-- Create my_tischtennis_fetch_log table for tracking data fetches
CREATE TABLE IF NOT EXISTS my_tischtennis_fetch_log (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
fetch_type ENUM('ratings', 'match_results', 'league_table') NOT NULL COMMENT 'Type of data fetch',
success BOOLEAN NOT NULL DEFAULT FALSE,
message TEXT,
error_details TEXT,
records_processed INT NOT NULL DEFAULT 0 COMMENT 'Number of records processed',
execution_time INT COMMENT 'Execution time in milliseconds',
is_automatic BOOLEAN NOT NULL DEFAULT FALSE COMMENT 'Automatic or manual fetch',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE ON UPDATE CASCADE,
INDEX idx_user_fetch_type_created (user_id, fetch_type, created_at),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -1,23 +0,0 @@
-- Migration: Create my_tischtennis_update_history table
-- Date: 2025-01-27
-- For MariaDB
CREATE TABLE IF NOT EXISTS my_tischtennis_update_history (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
success BOOLEAN NOT NULL DEFAULT FALSE,
message TEXT,
error_details TEXT,
updated_count INT DEFAULT 0,
execution_time INT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_my_tischtennis_update_history_user_id
FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Create indexes for efficient querying
CREATE INDEX idx_my_tischtennis_update_history_user_id ON my_tischtennis_update_history(user_id);
CREATE INDEX idx_my_tischtennis_update_history_created_at ON my_tischtennis_update_history(created_at);
CREATE INDEX idx_my_tischtennis_update_history_success ON my_tischtennis_update_history(success);

View File

@@ -1,16 +0,0 @@
-- Migration: Create tournament_class table
-- Date: 2025-01-15
-- For MariaDB/MySQL
CREATE TABLE IF NOT EXISTS `tournament_class` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`tournament_id` INT(11) NOT NULL,
`name` VARCHAR(255) NOT NULL,
`sort_order` INT(11) NOT NULL DEFAULT 0,
`created_at` DATETIME NOT NULL,
`updated_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
KEY `tournament_id` (`tournament_id`),
CONSTRAINT `tournament_class_ibfk_1` FOREIGN KEY (`tournament_id`) REFERENCES `tournament` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -1,33 +0,0 @@
-- Migration: Create tournament_pairing table
-- Date: 2025-01-23
-- For MariaDB/MySQL
CREATE TABLE IF NOT EXISTS `tournament_pairing` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`tournament_id` INT(11) NOT NULL,
`class_id` INT(11) NOT NULL,
`group_id` INT(11) NULL,
`member1_id` INT(11) NULL,
`external1_id` INT(11) NULL,
`member2_id` INT(11) NULL,
`external2_id` INT(11) NULL,
`seeded` TINYINT(1) NOT NULL DEFAULT 0,
`created_at` DATETIME NOT NULL,
`updated_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
KEY `tournament_id` (`tournament_id`),
KEY `class_id` (`class_id`),
KEY `group_id` (`group_id`),
KEY `member1_id` (`member1_id`),
KEY `member2_id` (`member2_id`),
KEY `external1_id` (`external1_id`),
KEY `external2_id` (`external2_id`),
CONSTRAINT `tournament_pairing_ibfk_1` FOREIGN KEY (`tournament_id`) REFERENCES `tournament` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `tournament_pairing_ibfk_2` FOREIGN KEY (`class_id`) REFERENCES `tournament_class` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `tournament_pairing_ibfk_3` FOREIGN KEY (`group_id`) REFERENCES `tournament_group` (`id`) ON DELETE SET NULL ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -1,36 +0,0 @@
-- Migration: Create training_group and member_training_group tables
-- Date: 2025-01-16
-- For MariaDB/MySQL
-- Create training_group table
CREATE TABLE IF NOT EXISTS `training_group` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`club_id` INT(11) NOT NULL,
`name` VARCHAR(255) NOT NULL,
`is_preset` TINYINT(1) NOT NULL DEFAULT 0,
`preset_type` ENUM('anfaenger', 'fortgeschrittene', 'erwachsene', 'nachwuchs', 'leistungsgruppe') NULL,
`sort_order` INT(11) NOT NULL DEFAULT 0,
`created_at` DATETIME NOT NULL,
`updated_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
KEY `club_id` (`club_id`),
CONSTRAINT `training_group_ibfk_1` FOREIGN KEY (`club_id`) REFERENCES `clubs` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Create member_training_group junction table
CREATE TABLE IF NOT EXISTS `member_training_group` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`member_id` INT(11) NOT NULL,
`training_group_id` INT(11) NOT NULL,
`created_at` DATETIME NOT NULL,
`updated_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `unique_member_group` (`member_id`, `training_group_id`),
KEY `member_id` (`member_id`),
KEY `training_group_id` (`training_group_id`),
CONSTRAINT `member_training_group_ibfk_1` FOREIGN KEY (`member_id`) REFERENCES `member` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `member_training_group_ibfk_2` FOREIGN KEY (`training_group_id`) REFERENCES `training_group` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -1,19 +0,0 @@
-- Migration: Create training_times table
-- Date: 2025-01-16
-- For MariaDB/MySQL
-- Stores training times for training groups
CREATE TABLE IF NOT EXISTS `training_times` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`training_group_id` INT(11) NOT NULL,
`weekday` TINYINT(1) NOT NULL COMMENT '0 = Sunday, 1 = Monday, ..., 6 = Saturday',
`start_time` TIME NOT NULL,
`end_time` TIME NOT NULL,
`sort_order` INT(11) NOT NULL DEFAULT 0 COMMENT 'Order for displaying multiple times on the same weekday',
`created_at` DATETIME NOT NULL,
`updated_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
KEY `training_group_id` (`training_group_id`),
CONSTRAINT `training_times_ibfk_1` FOREIGN KEY (`training_group_id`) REFERENCES `training_group` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -1,92 +0,0 @@
-- Fix-Skript: Behebt häufige Probleme mit Seasons und Teams
-- Führe dieses Skript auf dem Server aus, wenn die Diagnose Probleme zeigt
-- 1. Stelle sicher, dass die season-Tabelle existiert und die richtige Struktur hat
-- (Falls die Tabelle nicht existiert, wird sie erstellt)
CREATE TABLE IF NOT EXISTS `season` (
`id` INT NOT NULL AUTO_INCREMENT,
`season` VARCHAR(255) NOT NULL UNIQUE,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 2. Stelle sicher, dass die club_team-Tabelle die season_id-Spalte hat
-- (Falls die Spalte nicht existiert, wird sie hinzugefügt)
ALTER TABLE `club_team`
ADD COLUMN IF NOT EXISTS `season_id` INT NULL;
-- 3. Erstelle die Seasons, falls sie fehlen
INSERT IGNORE INTO `season` (`season`) VALUES ('2024/2025');
INSERT IGNORE INTO `season` (`season`) VALUES ('2025/2026');
-- 4. Aktualisiere Teams ohne season_id auf die aktuelle Saison
-- (Verwendet die neueste Saison basierend auf dem aktuellen Datum)
UPDATE `club_team`
SET `season_id` = (
SELECT `id` FROM `season`
WHERE `season` = (
CASE
WHEN MONTH(CURDATE()) >= 7 THEN CONCAT(YEAR(CURDATE()), '/', YEAR(CURDATE()) + 1)
ELSE CONCAT(YEAR(CURDATE()) - 1, '/', YEAR(CURDATE()))
END
)
LIMIT 1
)
WHERE `season_id` IS NULL;
-- 5. Falls keine aktuelle Saison existiert, erstelle sie
INSERT IGNORE INTO `season` (`season`) VALUES (
CASE
WHEN MONTH(CURDATE()) >= 7 THEN CONCAT(YEAR(CURDATE()), '/', YEAR(CURDATE()) + 1)
ELSE CONCAT(YEAR(CURDATE()) - 1, '/', YEAR(CURDATE()))
END
);
-- 6. Aktualisiere Teams mit ungültigen season_id auf die aktuelle Saison
UPDATE `club_team` ct
LEFT JOIN `season` s ON ct.season_id = s.id
SET ct.season_id = (
SELECT `id` FROM `season`
WHERE `season` = (
CASE
WHEN MONTH(CURDATE()) >= 7 THEN CONCAT(YEAR(CURDATE()), '/', YEAR(CURDATE()) + 1)
ELSE CONCAT(YEAR(CURDATE()) - 1, '/', YEAR(CURDATE()))
END
)
LIMIT 1
)
WHERE s.id IS NULL;
-- 7. Füge Foreign Key Constraint hinzu, falls er fehlt
-- (Hinweis: MySQL/MariaDB unterstützt "IF NOT EXISTS" nicht für Constraints,
-- daher müssen wir prüfen, ob der Constraint bereits existiert)
SET @constraint_exists = (
SELECT COUNT(*)
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'club_team'
AND CONSTRAINT_NAME = 'club_team_season_id_foreign_idx'
AND REFERENCED_TABLE_NAME = 'season'
);
SET @sql = IF(@constraint_exists = 0,
'ALTER TABLE `club_team` ADD CONSTRAINT `club_team_season_id_foreign_idx` FOREIGN KEY (`season_id`) REFERENCES `season` (`id`) ON DELETE CASCADE ON UPDATE CASCADE',
'SELECT "Foreign key constraint already exists" as message'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 8. Zeige die Ergebnisse
SELECT '=== ERGEBNIS ===' as info;
SELECT
ct.id,
ct.name,
ct.season_id,
s.season
FROM `club_team` ct
LEFT JOIN `season` s ON ct.season_id = s.id
ORDER BY ct.id;

Some files were not shown because too many files have changed in this diff Show More