26 Commits

Author SHA1 Message Date
Torsten Schulz (local)
36bf99c013 Add MyTischtennis fetch log functionality and new endpoints
Enhance MyTischtennis integration by introducing fetch log capabilities. Implement new controller methods to retrieve fetch logs and latest successful fetches for users. Update routes to include these new endpoints. Modify the MyTischtennis model to support fetch logs and ensure proper logging of fetch operations in various services. Update frontend components to display fetch statistics, improving user experience and data visibility.
2025-10-14 23:07:57 +02:00
Torsten Schulz (local)
7549fb5730 Implement league table functionality and MyTischtennis integration. Add new endpoints for retrieving and updating league tables in matchController and matchRoutes. Enhance Team model with additional fields for match statistics. Update frontend components to display league tables and allow fetching data from MyTischtennis, improving user experience and data accuracy. 2025-10-14 22:55:39 +02:00
Torsten Schulz (local)
1517d83f6c Refactor backend to enhance MyTischtennis integration. Update package.json to change main entry point to server.js. Modify server.js to improve scheduler service logging. Add new fields to ClubTeam, League, Match, and Member models for MyTischtennis data. Update routes to include new MyTischtennis URL parsing and configuration endpoints. Enhance services for fetching team data and scheduling match results. Improve frontend components for MyTischtennis URL configuration and display match results with scores. 2025-10-14 21:58:21 +02:00
Torsten Schulz (local)
993e12d4a5 Update MyTischtennis functionality to support automatic rating updates. Introduce new autoUpdateRatings field in MyTischtennis model and enhance MyTischtennisController to handle update history retrieval. Integrate node-cron for scheduling daily updates at 6:00 AM. Update frontend components to allow users to enable/disable automatic updates and display last update timestamps. 2025-10-09 00:18:41 +02:00
Torsten Schulz (local)
806cb527d4 Refactor group creation and assignment logic in DiaryView. Update group creation to allow specifying the number of groups, enhance participant assignment with group selection, and improve UI elements for better user experience. 2025-10-08 19:21:15 +02:00
Torsten Schulz (local)
7e9d2d2c4f Update member count calculations in MembersView to exclude inactive test memberships. Adjust activeMembersCount and testMembersCount computed properties for improved accuracy in member statistics. 2025-10-08 18:15:18 +02:00
Torsten Schulz (local)
ec9b92000e Update Member model to allow optional birthDate and enhance QuickAddMemberDialog for better input handling. Refactor DiaryView to remove default birthDate logic and improve member creation process. Adjust MembersView to handle empty birthDate gracefully in formatting. 2025-10-08 18:06:22 +02:00
Torsten Schulz (local)
d110900e85 Implement member statistics dropdown in MembersView. Add computed properties for active, test, and inactive member counts, and introduce a toggle for displaying member info. Enhance styling for the dropdown and member stats for improved user experience. 2025-10-08 17:47:43 +02:00
Torsten Schulz (local)
cd3c3502f6 Add tournament name column to OfficialTournaments view. Update data structure to include tournament names for better clarity in participation details. 2025-10-08 15:44:37 +02:00
Torsten Schulz (local)
ccce9bffac Enhance MembersView by adding a new row style for test memberships. Update the member row class to include 'row-test' for better visual distinction of test members, improving user experience in member management. 2025-10-08 15:00:38 +02:00
Torsten Schulz (local)
f1ba25f9f5 Implement logic to determine the quarter with the highest training data in TrainingStatsView. Update average participation calculation to reflect the best quarter's total participants, enhancing accuracy in participation metrics. 2025-10-08 14:54:57 +02:00
Torsten Schulz (local)
548f51ac54 Refactor participation metrics in TrainingStatsView to use total participant counts instead of member participation estimates. Update methods for calculating average participation across different time periods to enhance accuracy and maintainability. 2025-10-08 14:47:30 +02:00
Torsten Schulz (local)
946e4fce1e Enhance SeasonSelector and TeamManagementView with dialog components for improved user interaction. Introduce new dialog states and helper methods for consistent handling of information and confirmations. Update styles in TrainingStatsView to reflect new participation metrics and improve layout. Refactor document display in TeamManagementView to a table format for better readability. 2025-10-08 14:43:53 +02:00
Torsten Schulz (local)
40dcd0e54c Refactor modals in DiaryView, MembersView, OfficialTournaments, ScheduleView, and TrainingStatsView to use dedicated dialog components for improved maintainability and user experience. Update styles and structure for consistency across the application. 2025-10-08 12:49:42 +02:00
Torsten Schulz (local)
bd338b86df Implementiert InfoDialog und ConfirmDialog in mehreren Komponenten, um die Benutzerinteraktion zu verbessern. Fügt Dialogzustände und Hilfsmethoden hinzu, die eine konsistente Handhabung von Informationen und Bestätigungen ermöglichen. Diese Änderungen erhöhen die Benutzerfreundlichkeit und verbessern die visuelle Rückmeldung in der Anwendung. 2025-10-08 11:46:07 +02:00
Torsten Schulz (local)
1d4aa43b02 Aktualisiert das Styling in App.vue durch Hinzufügen von Padding am unteren Rand für die Statusleiste. Ändert den Hintergrund und die Polsterung in DialogManager.vue, um das visuelle Design zu verbessern und die Benutzeroberfläche zu optimieren. 2025-10-08 11:22:45 +02:00
Torsten Schulz (local)
cc08f4ba43 Verbessert die Lesbarkeit und Struktur des Codes in DiaryView.vue durch Anpassungen der Einrückungen und Formatierungen. Optimiert die Anzeige von Aktivitätsvisualisierungen und aktualisiert die Logik für die Eingabefelder, um die Benutzerfreundlichkeit zu erhöhen. Diese Änderungen tragen zur allgemeinen Verbesserung der Benutzeroberfläche und der Codequalität bei. 2025-10-08 11:14:20 +02:00
Torsten Schulz (local)
d0ccaa9e54 Fügt eine neue Methode hasActivityVisual in DiaryView.vue hinzu, um die Sichtbarkeit von Aktivitätsvisualisierungen zu überprüfen. Aktualisiert die Bedingungen für die Anzeige von Icons, die Bilder oder Zeichnungen darstellen, um die Benutzeroberfläche zu verbessern und die Logik zu optimieren. 2025-10-08 11:00:20 +02:00
Torsten Schulz (local)
dc0eff4e4c Entfernt die PDF-Datei 9_code_list_1759357969975.pdf und implementiert eine Sidebar-Toggle-Funktionalität in App.vue. Die Sidebar kann nun auf mobilen Geräten ein- und ausgeklappt werden, um die Benutzeroberfläche zu optimieren. Zudem wurden Titelattribute zu Navigationslinks hinzugefügt, um die Benutzerfreundlichkeit zu verbessern. Der Vuex-Store wurde aktualisiert, um den Zustand der Sidebar zu speichern und zu verwalten. 2025-10-08 10:52:07 +02:00
Torsten Schulz (local)
db9e404372 Aktualisiert die Logik zum Löschen eines Datums in DiaryView.vue. Die Schaltfläche zum Löschen wird nun nur angezeigt, wenn keine Inhalte (Trainingplan, Teilnehmer, Aktivitäten, Unfälle oder Notizen) vorhanden sind. Fügt eine neue Methode canDeleteCurrentDate hinzu, die diese Überprüfung durchführt, um die Benutzerfreundlichkeit zu verbessern und versehentliche Löschungen zu verhindern. 2025-10-04 02:30:23 +02:00
Torsten Schulz (local)
60ac89636e Ändert das Eingabefeld für den Nachnamen in der Mitgliederregistrierung auf optional und aktualisiert die Validierungslogik entsprechend. Setzt ein Standard-Geburtsdatum für neue Mitglieder auf den 01.01. des aktuellen Jahres minus 10 Jahre, wenn kein Geburtsdatum eingegeben wird. Diese Änderungen verbessern die Benutzerfreundlichkeit und Flexibilität bei der Registrierung neuer Mitglieder. 2025-10-04 02:27:49 +02:00
Torsten Schulz (local)
2b1365339e Fügt die Funktion zum Drehen von Mitgliedsbildern hinzu. Implementiert die Logik zur Bildrotation in MemberService und aktualisiert die entsprechenden Routen und Frontend-Komponenten, um die Benutzeroberfläche für die Bildbearbeitung zu verbessern. Ermöglicht das Drehen von Bildern über die Mitgliederansicht und aktualisiert die Anzeige nach der Bearbeitung. 2025-10-04 01:59:21 +02:00
Torsten Schulz (local)
0cf2351c79 Erweitert die Funktionalität von updateRatingsFromMyTischtennis, um eine automatische Anmeldung bei abgelaufener Session zu ermöglichen. Fügt Fehlerbehandlung für den Login-Prozess hinzu und entfernt die Bestätigungsabfrage vor der Aktualisierung der TTR/QTTR-Werte, um den Benutzerfluss zu verbessern. 2025-10-04 01:49:30 +02:00
Torsten Schulz (local)
5c32fad34e Fügt eine bedingte Navigation in App.vue hinzu, die eine leere Navigationsleiste anzeigt, wenn keine Authentifizierung vorliegt. Diese Änderung verbessert die Benutzeroberfläche und sorgt für eine klarere Struktur der Navigation. 2025-10-04 01:43:37 +02:00
Torsten Schulz (local)
7f0b681e88 Ermöglicht die Bearbeitung von Spielergebnissen in TournamentsView.vue durch klickbare Labels und editierbare Eingabefelder. Fügt Logik zum Speichern und Abbrechen von Änderungen hinzu. Aktualisiert das Styling für Eingabefelder und klickbare Texte, um die Benutzererfahrung zu verbessern. 2025-10-04 01:38:27 +02:00
Torsten Schulz (local)
cc964da9cf Fügt einen Dialog-Manager hinzu, um die Verwaltung von Dialogen zu ermöglichen. Aktualisiert den Vuex-Store mit neuen Mutationen und Aktionen zur Handhabung von Dialogen. Integriert den MatchReportDialog in ScheduleView.vue und ermöglicht das Öffnen von Spielberichten über die Benutzeroberfläche. Verbessert die Benutzererfahrung durch neue Schaltflächen und CSS-Stile für die Dialoge. 2025-10-02 11:44:27 +02:00
119 changed files with 15899 additions and 1325 deletions

View File

View File

@@ -0,0 +1,212 @@
# 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

@@ -0,0 +1,328 @@
# 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);
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) {
alert('Team erfolgreich konfiguriert!');
}
} 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

@@ -58,3 +58,47 @@ 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' });
}
};

View File

@@ -80,4 +80,25 @@ const updateRatingsFromMyTischtennis = async (req, res) => {
}
};
export { getClubMembers, getWaitingApprovals, setClubMembers, uploadMemberImage, getMemberImage, updateRatingsFromMyTischtennis };
const rotateMemberImage = async (req, res) => {
try {
const { clubId, memberId } = 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, 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' });
}
};
export { getClubMembers, getWaitingApprovals, setClubMembers, uploadMemberImage, getMemberImage, updateRatingsFromMyTischtennis, rotateMemberImage };

View File

@@ -42,7 +42,7 @@ class MyTischtennisController {
async upsertAccount(req, res, next) {
try {
const userId = req.user.id;
const { email, password, savePassword, userPassword } = req.body;
const { email, password, savePassword, autoUpdateRatings, userPassword } = req.body;
if (!email) {
throw new HttpError(400, 'E-Mail-Adresse erforderlich');
@@ -58,6 +58,7 @@ class MyTischtennisController {
email,
password,
savePassword || false,
autoUpdateRatings || false,
userPassword
);
@@ -127,6 +128,77 @@ class MyTischtennisController {
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);
}
}
}
export default new MyTischtennisController();

View File

@@ -0,0 +1,477 @@
import myTischtennisUrlParserService from '../services/myTischtennisUrlParserService.js';
import myTischtennisService from '../services/myTischtennisService.js';
import autoFetchMatchResultsService from '../services/autoFetchMatchResultsService.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(400, 'URL is required');
}
// Validate URL
if (!myTischtennisUrlParserService.isValidTeamUrl(url)) {
throw new HttpError(400, 'Invalid myTischtennis URL format');
}
// 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) {
console.error('Error fetching additional team data:', 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(400, 'URL and clubTeamId are required');
}
// Get actual user ID
let userId = userIdOrEmail;
if (isNaN(userIdOrEmail)) {
const user = await User.findOne({ where: { email: userIdOrEmail } });
if (!user) {
throw new HttpError(404, 'User not found');
}
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) {
console.error('Error fetching team data:', 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(404, `Season ${completeData.season} not found. Set createSeason=true to create it.`);
}
// Find or create league
const team = await ClubTeam.findByPk(clubTeamId);
if (!team) {
throw new HttpError(404, 'Club team not found');
}
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(400, 'League not found and team has no league assigned. Set createLeague=true to create one.');
}
// 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) {
try {
const { clubTeamId } = req.body;
const userIdOrEmail = req.headers.userid;
if (!clubTeamId) {
throw new HttpError(400, 'clubTeamId is required');
}
// 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(404, 'User not found');
}
userId = user.id;
}
// Get myTischtennis session (similar to memberService.updateRatingsFromMyTischtennis)
console.log('Fetching session for userId:', userId, '(from header:', userIdOrEmail, ')');
let session;
try {
session = await myTischtennisService.getSession(userId);
console.log('Session found:', !!session);
} catch (sessionError) {
console.log('Session invalid, attempting login...', sessionError.message);
// Versuche automatischen Login mit gespeicherten Credentials
try {
await myTischtennisService.verifyLogin(userId);
session = await myTischtennisService.getSession(userId);
console.log('Automatic login successful');
} catch (loginError) {
console.error('Automatic login failed:', loginError.message);
throw new HttpError(401, 'MyTischtennis-Session abgelaufen und automatischer Login fehlgeschlagen. Bitte melden Sie sich in den MyTischtennis-Einstellungen an.');
}
}
// Get account data (for clubId, etc.)
const account = await myTischtennisService.getAccount(userId);
if (!account) {
throw new HttpError(404, 'MyTischtennis-Account nicht verknüpft. Bitte verknüpfen Sie Ihren Account in den MyTischtennis-Einstellungen.');
}
console.log('Using session:', {
email: account.email,
hasCookie: !!session.cookie,
hasAccessToken: !!session.accessToken,
expiresAt: new Date(session.expiresAt * 1000)
});
// Get team with league and season
const team = await ClubTeam.findByPk(clubTeamId, {
include: [
{
model: League,
as: 'league',
include: [
{
model: Season,
as: 'season'
}
]
}
]
});
if (!team) {
throw new HttpError(404, 'Team not found');
}
console.log('Team data:', {
id: team.id,
name: team.name,
myTischtennisTeamId: team.myTischtennisTeamId,
hasLeague: !!team.league,
leagueData: team.league ? {
id: team.league.id,
name: team.league.name,
myTischtennisGroupId: team.league.myTischtennisGroupId,
association: team.league.association,
groupname: team.league.groupname,
hasSeason: !!team.league.season
} : null
});
if (!team.myTischtennisTeamId || !team.league || !team.league.myTischtennisGroupId) {
throw new HttpError(400, 'Team is not configured for myTischtennis');
}
// Fetch data for this specific team
const startTime = Date.now();
let matchResultsSuccess = false;
let tableUpdateSuccess = false;
let matchResultsCount = 0;
let tableUpdateCount = 0;
try {
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
);
matchResultsSuccess = true;
matchResultsCount = result.fetchedCount || 0;
// Log match results fetch
const fetchLogService = (await import('../services/myTischtennisFetchLogService.js')).default;
await fetchLogService.logFetch(
account.userId,
'match_results',
true,
`${matchResultsCount} Spielergebnisse erfolgreich abgerufen`,
{
recordsProcessed: matchResultsCount,
executionTime: Date.now() - startTime,
isAutomatic: false
}
);
} catch (error) {
console.error('Error fetching match results:', error);
const fetchLogService = (await import('../services/myTischtennisFetchLogService.js')).default;
await fetchLogService.logFetch(
account.userId,
'match_results',
false,
'Fehler beim Abrufen der Spielergebnisse',
{
errorDetails: error.message,
executionTime: Date.now() - startTime,
isAutomatic: false
}
);
}
// Also fetch and update league table data
let tableUpdateResult = null;
const tableStartTime = Date.now();
try {
await autoFetchMatchResultsService.fetchAndUpdateLeagueTable(account.userId, team.league.id);
tableUpdateResult = 'League table updated successfully';
tableUpdateSuccess = true;
tableUpdateCount = 1; // One table updated
console.log('✓ League table updated for league:', team.league.id);
// Log league table fetch
const fetchLogService = (await import('../services/myTischtennisFetchLogService.js')).default;
await fetchLogService.logFetch(
account.userId,
'league_table',
true,
'Ligatabelle erfolgreich aktualisiert',
{
recordsProcessed: tableUpdateCount,
executionTime: Date.now() - tableStartTime,
isAutomatic: false
}
);
} catch (error) {
console.error('Error fetching league table data:', error);
tableUpdateResult = 'League table update failed: ' + error.message;
// Log league table fetch failure
const fetchLogService = (await import('../services/myTischtennisFetchLogService.js')).default;
await fetchLogService.logFetch(
account.userId,
'league_table',
false,
'Fehler beim Aktualisieren der Ligatabelle',
{
errorDetails: error.message,
executionTime: Date.now() - tableStartTime,
isAutomatic: false
}
);
// Don't fail the entire request if table update fails
}
res.json({
success: true,
message: `${matchResultsCount} Datensätze abgerufen und verarbeitet`,
data: {
fetchedCount: matchResultsCount,
teamName: team.name,
tableUpdate: tableUpdateResult
}
});
} catch (error) {
console.error('Error in fetchTeamData:', error);
console.error('Error stack:', error.stack);
next(error);
}
}
/**
* 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(404, 'Team not found');
}
if (!team.myTischtennisTeamId || !team.league || !team.league.myTischtennisGroupId) {
throw new HttpError(400, 'Team is not configured for myTischtennis');
}
const url = myTischtennisUrlParserService.buildUrl({
association: team.league.association,
season: team.league.season.name,
groupname: team.league.groupname,
groupId: team.league.myTischtennisGroupId,
teamId: team.myTischtennisTeamId,
teamname: team.name
});
res.json({
success: true,
url
});
} catch (error) {
next(error);
}
}
}
export default new MyTischtennisUrlController();

View File

@@ -0,0 +1,13 @@
-- 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

@@ -0,0 +1,28 @@
-- 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

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

View File

@@ -0,0 +1,19 @@
-- 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

@@ -0,0 +1,11 @@
-- 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

@@ -0,0 +1,11 @@
-- 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

@@ -0,0 +1,11 @@
-- 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

@@ -0,0 +1,5 @@
-- 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

@@ -0,0 +1,20 @@
-- 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

@@ -0,0 +1,23 @@
-- 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

@@ -0,0 +1,8 @@
-- Migration: Make locationId optional in match table
-- Date: 2025-01-27
-- For MariaDB
-- Modify locationId to allow NULL
ALTER TABLE `match`
MODIFY COLUMN location_id INT NULL;

View File

@@ -45,6 +45,12 @@ const ClubTeam = sequelize.define('ClubTeam', {
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
},
myTischtennisTeamId: {
type: DataTypes.STRING,
allowNull: true,
comment: 'Team ID from myTischtennis (e.g. 2995094)',
field: 'my_tischtennis_team_id'
},
}, {
underscored: true,
tableName: 'club_team',

View File

@@ -34,6 +34,22 @@ const League = sequelize.define('League', {
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
},
myTischtennisGroupId: {
type: DataTypes.STRING,
allowNull: true,
comment: 'Group ID from myTischtennis (e.g. 504417)',
field: 'my_tischtennis_group_id'
},
association: {
type: DataTypes.STRING,
allowNull: true,
comment: 'Association/Verband (e.g. HeTTV)',
},
groupname: {
type: DataTypes.STRING,
allowNull: true,
comment: 'Group name for URL (e.g. 1.Kreisklasse)',
},
}, {
underscored: true,
tableName: 'league',

View File

@@ -26,7 +26,7 @@ const Match = sequelize.define('Match', {
model: Location,
key: 'id',
},
allowNull: false,
allowNull: true,
},
homeTeamId: {
type: DataTypes.INTEGER,
@@ -75,6 +75,40 @@ const Match = sequelize.define('Match', {
allowNull: true,
comment: 'Pin-Code für Gastteam aus PDF-Parsing'
},
myTischtennisMeetingId: {
type: DataTypes.STRING,
allowNull: true,
unique: true,
comment: 'Meeting ID from myTischtennis (e.g. 15440488)',
field: 'my_tischtennis_meeting_id'
},
homeMatchPoints: {
type: DataTypes.INTEGER,
allowNull: true,
defaultValue: 0,
comment: 'Match points won by home team',
field: 'home_match_points'
},
guestMatchPoints: {
type: DataTypes.INTEGER,
allowNull: true,
defaultValue: 0,
comment: 'Match points won by guest team',
field: 'guest_match_points'
},
isCompleted: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
comment: 'Whether the match is completed',
field: 'is_completed'
},
pdfUrl: {
type: DataTypes.STRING,
allowNull: true,
comment: 'PDF URL from myTischtennis',
field: 'pdf_url'
},
}, {
underscored: true,
tableName: 'match',

View File

@@ -45,9 +45,9 @@ const Member = sequelize.define('Member', {
},
birthDate: {
type: DataTypes.STRING,
allowNull: false,
allowNull: true,
set(value) {
const encryptedValue = encryptData(value);
const encryptedValue = encryptData(value || '');
this.setDataValue('birthDate', encryptedValue);
},
get() {
@@ -137,6 +137,12 @@ const Member = sequelize.define('Member', {
type: DataTypes.INTEGER,
allowNull: true,
defaultValue: null
},
myTischtennisPlayerId: {
type: DataTypes.STRING,
allowNull: true,
comment: 'Player ID from myTischtennis (e.g. NU2705037)',
field: 'my_tischtennis_player_id'
}
}, {
underscored: true,

View File

@@ -34,6 +34,12 @@ const MyTischtennis = sequelize.define('MyTischtennis', {
allowNull: false,
field: 'save_password'
},
autoUpdateRatings: {
type: DataTypes.BOOLEAN,
defaultValue: false,
allowNull: false,
field: 'auto_update_ratings'
},
accessToken: {
type: DataTypes.TEXT,
allowNull: true,
@@ -82,6 +88,11 @@ const MyTischtennis = sequelize.define('MyTischtennis', {
type: DataTypes.DATE,
allowNull: true,
field: 'last_login_success'
},
lastUpdateRatings: {
type: DataTypes.DATE,
allowNull: true,
field: 'last_update_ratings'
}
}, {
underscored: true,

View File

@@ -0,0 +1,72 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
import User from './User.js';
const MyTischtennisFetchLog = sequelize.define('MyTischtennisFetchLog', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
},
userId: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: User,
key: 'id',
},
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
},
fetchType: {
type: DataTypes.ENUM('ratings', 'match_results', 'league_table'),
allowNull: false,
comment: 'Type of data fetch: ratings, match_results, or league_table'
},
success: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
message: {
type: DataTypes.TEXT,
allowNull: true,
},
errorDetails: {
type: DataTypes.TEXT,
allowNull: true,
},
recordsProcessed: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
comment: 'Number of records processed (e.g., players updated, matches fetched)'
},
executionTime: {
type: DataTypes.INTEGER,
allowNull: true,
comment: 'Execution time in milliseconds'
},
isAutomatic: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
comment: 'Whether this was an automatic or manual fetch'
},
}, {
underscored: true,
tableName: 'my_tischtennis_fetch_log',
timestamps: true,
indexes: [
{
fields: ['user_id', 'fetch_type', 'created_at']
},
{
fields: ['created_at']
}
]
});
export default MyTischtennisFetchLog;

View File

@@ -0,0 +1,63 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const MyTischtennisUpdateHistory = sequelize.define('MyTischtennisUpdateHistory', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false
},
userId: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: 'user',
key: 'id'
},
onDelete: 'CASCADE'
},
success: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
},
message: {
type: DataTypes.TEXT,
allowNull: true
},
errorDetails: {
type: DataTypes.TEXT,
allowNull: true,
field: 'error_details'
},
updatedCount: {
type: DataTypes.INTEGER,
allowNull: true,
defaultValue: 0,
field: 'updated_count'
},
executionTime: {
type: DataTypes.INTEGER,
allowNull: true,
comment: 'Execution time in milliseconds',
field: 'execution_time'
}
}, {
underscored: true,
tableName: 'my_tischtennis_update_history',
timestamps: true,
indexes: [
{
fields: ['user_id']
},
{
fields: ['created_at']
},
{
fields: ['success']
}
]
});
export default MyTischtennisUpdateHistory;

View File

@@ -45,6 +45,62 @@ const Team = sequelize.define('Team', {
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
},
// Tabellenfelder
matchesPlayed: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
},
matchesWon: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
},
matchesLost: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
},
matchesTied: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
},
setsWon: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
},
setsLost: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
},
pointsWon: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
},
pointsLost: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
},
tablePoints: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
},
tablePointsWon: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
},
tablePointsLost: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
},
}, {
underscored: true,
tableName: 'team',

View File

@@ -36,6 +36,8 @@ import OfficialTournament from './OfficialTournament.js';
import OfficialCompetition from './OfficialCompetition.js';
import OfficialCompetitionMember from './OfficialCompetitionMember.js';
import MyTischtennis from './MyTischtennis.js';
import MyTischtennisUpdateHistory from './MyTischtennisUpdateHistory.js';
import MyTischtennisFetchLog from './MyTischtennisFetchLog.js';
// Official tournaments relations
OfficialTournament.hasMany(OfficialCompetition, { foreignKey: 'tournamentId', as: 'competitions' });
OfficialCompetition.belongsTo(OfficialTournament, { foreignKey: 'tournamentId', as: 'tournament' });
@@ -120,6 +122,9 @@ Team.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
Club.hasMany(League, { foreignKey: 'clubId', as: 'leagues' });
League.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
Season.hasMany(League, { foreignKey: 'seasonId', as: 'leagues' });
League.belongsTo(Season, { foreignKey: 'seasonId', as: 'season' });
League.hasMany(Team, { foreignKey: 'leagueId', as: 'teams' });
Team.belongsTo(League, { foreignKey: 'leagueId', as: 'league' });
@@ -227,6 +232,12 @@ DiaryDate.hasMany(Accident, { foreignKey: 'diaryDateId', as: 'accidents' });
User.hasOne(MyTischtennis, { foreignKey: 'userId', as: 'myTischtennis' });
MyTischtennis.belongsTo(User, { foreignKey: 'userId', as: 'user' });
User.hasMany(MyTischtennisUpdateHistory, { foreignKey: 'userId', as: 'updateHistory' });
MyTischtennisUpdateHistory.belongsTo(User, { foreignKey: 'userId', as: 'user' });
User.hasMany(MyTischtennisFetchLog, { foreignKey: 'userId', as: 'fetchLogs' });
MyTischtennisFetchLog.belongsTo(User, { foreignKey: 'userId', as: 'user' });
export {
User,
Log,
@@ -265,4 +276,6 @@ export {
OfficialCompetition,
OfficialCompetitionMember,
MyTischtennis,
MyTischtennisUpdateHistory,
MyTischtennisFetchLog,
};

View File

@@ -2816,6 +2816,15 @@
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz",
"integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA=="
},
"node_modules/node-cron": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
"integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
"license": "ISC",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/node-ensure": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/node-ensure/-/node-ensure-0.0.0.tgz",
@@ -2842,9 +2851,10 @@
}
},
"node_modules/nodemailer": {
"version": "6.9.14",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.14.tgz",
"integrity": "sha512-Dobp/ebDKBvz91sbtRKhcznLThrKxKt97GI2FAlAyy+fk19j73Uz3sBXolVtmcXjaorivqsbbbjDY+Jkt4/bQA==",
"version": "7.0.9",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.9.tgz",
"integrity": "sha512-9/Qm0qXIByEP8lEV2qOqcAW7bRpL8CR9jcTwk3NBnHJNmP9fIJ86g2fgmIXqHY+nj55ZEMwWqYAT2QTDpRUYiQ==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}

View File

@@ -1,10 +1,9 @@
'use strict';
module.exports = {
upgrade: true,
reject: [
// API changes break existing tests
'proxy',
// API changes
'eslint'
'proxy'
]
};

View File

@@ -1,3 +1,5 @@
'use strict';
module.exports = {
printWidth: 160,
tabWidth: 4,

File diff suppressed because it is too large Load Diff

View File

@@ -14,22 +14,22 @@ appearance, race, religion, or sexual identity and orientation.
Examples of behavior that contributes to creating a positive environment
include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
- Using welcoming and inclusive language
- Being respectful of differing viewpoints and experiences
- Gracefully accepting constructive criticism
- Focusing on what is best for the community
- Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or
advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic
address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
- The use of sexualized language or imagery and unwelcome sexual attention or
advances
- Trolling, insulting/derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or electronic
address, without explicit permission
- Other conduct which could reasonably be considered inappropriate in a
professional setting
## Our Responsibilities

View File

@@ -37,36 +37,36 @@ It's either a firewall issue, or your SMTP server blocks authentication attempts
#### I get TLS errors
- If you are running the code on your machine, check your antivirus settings. Antiviruses often mess around with email ports usage. Node.js might not recognize the MITM cert your antivirus is using.
- Latest Node versions allow only TLS versions 1.2 and higher. Some servers might still use TLS 1.1 or lower. Check Node.js docs on how to get correct TLS support for your app. You can change this with [tls.minVersion](https://nodejs.org/dist/latest-v16.x/docs/api/tls.html#tls_tls_createsecurecontext_options) option
- You might have the wrong value for the `secure` option. This should be set to `true` only for port 465. For every other port, it should be `false`. Setting it to `false` does not mean that Nodemailer would not use TLS. Nodemailer would still try to upgrade the connection to use TLS if the server supports it.
- Older Node versions do not fully support the certificate chain of the newest Let's Encrypt certificates. Either set [tls.rejectUnauthorized](https://nodejs.org/dist/latest-v16.x/docs/api/tls.html#tlsconnectoptions-callback) to `false` to skip chain verification or upgrade your Node version
- If you are running the code on your machine, check your antivirus settings. Antiviruses often mess around with email ports usage. Node.js might not recognize the MITM cert your antivirus is using.
- Latest Node versions allow only TLS versions 1.2 and higher. Some servers might still use TLS 1.1 or lower. Check Node.js docs on how to get correct TLS support for your app. You can change this with [tls.minVersion](https://nodejs.org/dist/latest-v16.x/docs/api/tls.html#tls_tls_createsecurecontext_options) option
- You might have the wrong value for the `secure` option. This should be set to `true` only for port 465. For every other port, it should be `false`. Setting it to `false` does not mean that Nodemailer would not use TLS. Nodemailer would still try to upgrade the connection to use TLS if the server supports it.
- Older Node versions do not fully support the certificate chain of the newest Let's Encrypt certificates. Either set [tls.rejectUnauthorized](https://nodejs.org/dist/latest-v16.x/docs/api/tls.html#tlsconnectoptions-callback) to `false` to skip chain verification or upgrade your Node version
```
```js
let configOptions = {
host: "smtp.example.com",
host: 'smtp.example.com',
port: 587,
tls: {
rejectUnauthorized: true,
minVersion: "TLSv1.2"
minVersion: 'TLSv1.2'
}
}
};
```
#### I have issues with DNS / hosts file
Node.js uses [c-ares](https://nodejs.org/en/docs/meta/topics/dependencies/#c-ares) to resolve domain names, not the DNS library provided by the system, so if you have some custom DNS routing set up, it might be ignored. Nodemailer runs [dns.resolve4()](https://nodejs.org/dist/latest-v16.x/docs/api/dns.html#dnsresolve4hostname-options-callback) and [dns.resolve6()](https://nodejs.org/dist/latest-v16.x/docs/api/dns.html#dnsresolve6hostname-options-callback) to resolve hostname into an IP address. If both calls fail, then Nodemailer will fall back to [dns.lookup()](https://nodejs.org/dist/latest-v16.x/docs/api/dns.html#dnslookuphostname-options-callback). If this does not work for you, you can hard code the IP address into the configuration like shown below. In that case, Nodemailer would not perform any DNS lookups.
```
```js
let configOptions = {
host: "1.2.3.4",
host: '1.2.3.4',
port: 465,
secure: true,
tls: {
// must provide server name, otherwise TLS certificate check will fail
servername: "example.com"
servername: 'example.com'
}
}
};
```
#### I have an issue with TypeScript types

View File

@@ -7,7 +7,6 @@
* @return {Object} Address object
*/
function _handleAddress(tokens) {
let token;
let isGroup = false;
let state = 'text';
let address;
@@ -16,28 +15,41 @@ function _handleAddress(tokens) {
address: [],
comment: [],
group: [],
text: []
text: [],
textWasQuoted: [] // Track which text tokens came from inside quotes
};
let i;
let len;
let insideQuotes = false; // Track if we're currently inside a quoted string
// Filter out <addresses>, (comments) and regular text
for (i = 0, len = tokens.length; i < len; i++) {
token = tokens[i];
let token = tokens[i];
let prevToken = i ? tokens[i - 1] : null;
if (token.type === 'operator') {
switch (token.value) {
case '<':
state = 'address';
insideQuotes = false;
break;
case '(':
state = 'comment';
insideQuotes = false;
break;
case ':':
state = 'group';
isGroup = true;
insideQuotes = false;
break;
case '"':
// Track quote state for text tokens
insideQuotes = !insideQuotes;
state = 'text';
break;
default:
state = 'text';
insideQuotes = false;
break;
}
} else if (token.value) {
if (state === 'address') {
@@ -46,7 +58,19 @@ function _handleAddress(tokens) {
// and so will we
token.value = token.value.replace(/^[^<]*<\s*/, '');
}
data[state].push(token.value);
if (prevToken && prevToken.noBreak && data[state].length) {
// join values
data[state][data[state].length - 1] += token.value;
if (state === 'text' && insideQuotes) {
data.textWasQuoted[data.textWasQuoted.length - 1] = true;
}
} else {
data[state].push(token.value);
if (state === 'text') {
data.textWasQuoted.push(insideQuotes);
}
}
}
}
@@ -59,16 +83,36 @@ function _handleAddress(tokens) {
if (isGroup) {
// http://tools.ietf.org/html/rfc2822#appendix-A.1.3
data.text = data.text.join(' ');
// Parse group members, but flatten any nested groups (RFC 5322 doesn't allow nesting)
let groupMembers = [];
if (data.group.length) {
let parsedGroup = addressparser(data.group.join(','));
// Flatten: if any member is itself a group, extract its members into the sequence
parsedGroup.forEach(member => {
if (member.group) {
// Nested group detected - flatten it by adding its members directly
groupMembers = groupMembers.concat(member.group);
} else {
groupMembers.push(member);
}
});
}
addresses.push({
name: data.text || (address && address.name),
group: data.group.length ? addressparser(data.group.join(',')) : []
group: groupMembers
});
} else {
// If no address was found, try to detect one from regular text
if (!data.address.length && data.text.length) {
for (i = data.text.length - 1; i >= 0; i--) {
if (data.text[i].match(/^[^@\s]+@[^@\s]+$/)) {
// Security fix: Do not extract email addresses from quoted strings
// RFC 5321 allows @ inside quoted local-parts like "user@domain"@example.com
// Extracting emails from quoted text leads to misrouting vulnerabilities
if (!data.textWasQuoted[i] && data.text[i].match(/^[^@\s]+@[^@\s]+$/)) {
data.address = data.text.splice(i, 1);
data.textWasQuoted.splice(i, 1);
break;
}
}
@@ -85,10 +129,13 @@ function _handleAddress(tokens) {
// still no address
if (!data.address.length) {
for (i = data.text.length - 1; i >= 0; i--) {
// fixed the regex to parse email address correctly when email address has more than one @
data.text[i] = data.text[i].replace(/\s*\b[^@\s]+@[^\s]+\b\s*/, _regexHandler).trim();
if (data.address.length) {
break;
// Security fix: Do not extract email addresses from quoted strings
if (!data.textWasQuoted[i]) {
// fixed the regex to parse email address correctly when email address has more than one @
data.text[i] = data.text[i].replace(/\s*\b[^@\s]+@[^\s]+\b\s*/, _regexHandler).trim();
if (data.address.length) {
break;
}
}
}
}
@@ -172,11 +219,12 @@ class Tokenizer {
* @return {Array} An array of operator|text tokens
*/
tokenize() {
let chr,
list = [];
let list = [];
for (let i = 0, len = this.str.length; i < len; i++) {
chr = this.str.charAt(i);
this.checkChar(chr);
let chr = this.str.charAt(i);
let nextChr = i < len - 1 ? this.str.charAt(i + 1) : null;
this.checkChar(chr, nextChr);
}
this.list.forEach(node => {
@@ -194,7 +242,7 @@ class Tokenizer {
*
* @param {String} chr Character from the address field
*/
checkChar(chr) {
checkChar(chr, nextChr) {
if (this.escaped) {
// ignore next condition blocks
} else if (chr === this.operatorExpecting) {
@@ -202,10 +250,16 @@ class Tokenizer {
type: 'operator',
value: chr
};
if (nextChr && ![' ', '\t', '\r', '\n', ',', ';'].includes(nextChr)) {
this.node.noBreak = true;
}
this.list.push(this.node);
this.node = null;
this.operatorExpecting = '';
this.escaped = false;
return;
} else if (!this.operatorExpecting && chr in this.operators) {
this.node = {

View File

@@ -35,15 +35,12 @@ function wrap(str, lineLength) {
let pos = 0;
let chunkLength = lineLength * 1024;
while (pos < str.length) {
let wrappedLines = str
.substr(pos, chunkLength)
.replace(new RegExp('.{' + lineLength + '}', 'g'), '$&\r\n')
.trim();
let wrappedLines = str.substr(pos, chunkLength).replace(new RegExp('.{' + lineLength + '}', 'g'), '$&\r\n');
result.push(wrappedLines);
pos += chunkLength;
}
return result.join('\r\n').trim();
return result.join('');
}
/**
@@ -56,7 +53,6 @@ function wrap(str, lineLength) {
class Encoder extends Transform {
constructor(options) {
super();
// init Transform
this.options = options || {};
if (this.options.lineLength !== false) {
@@ -98,17 +94,20 @@ class Encoder extends Transform {
if (this.options.lineLength) {
b64 = wrap(b64, this.options.lineLength);
// remove last line as it is still most probably incomplete
let lastLF = b64.lastIndexOf('\n');
if (lastLF < 0) {
this._curLine = b64;
b64 = '';
} else if (lastLF === b64.length - 1) {
this._curLine = '';
} else {
this._curLine = b64.substr(lastLF + 1);
b64 = b64.substr(0, lastLF + 1);
this._curLine = b64.substring(lastLF + 1);
b64 = b64.substring(0, lastLF + 1);
if (b64 && !b64.endsWith('\r\n')) {
b64 += '\r\n';
}
}
} else {
this._curLine = '';
}
if (b64) {
@@ -125,16 +124,14 @@ class Encoder extends Transform {
}
if (this._curLine) {
this._curLine = wrap(this._curLine, this.options.lineLength);
this.outputBytes += this._curLine.length;
this.push(this._curLine, 'ascii');
this.push(Buffer.from(this._curLine, 'ascii'));
this._curLine = '';
}
done();
}
}
// expose to the world
module.exports = {
encode,
wrap,

View File

@@ -12,7 +12,7 @@ const path = require('path');
const crypto = require('crypto');
const DKIM_ALGO = 'sha256';
const MAX_MESSAGE_SIZE = 128 * 1024; // buffer messages larger than this to disk
const MAX_MESSAGE_SIZE = 2 * 1024 * 1024; // buffer messages larger than this to disk
/*
// Usage:
@@ -42,7 +42,9 @@ class DKIMSigner {
this.chunks = [];
this.chunklen = 0;
this.readPos = 0;
this.cachePath = this.cacheDir ? path.join(this.cacheDir, 'message.' + Date.now() + '-' + crypto.randomBytes(14).toString('hex')) : false;
this.cachePath = this.cacheDir
? path.join(this.cacheDir, 'message.' + Date.now() + '-' + crypto.randomBytes(14).toString('hex'))
: false;
this.cache = false;
this.headers = false;

View File

@@ -41,7 +41,7 @@ module.exports = (headers, hashAlgo, bodyHash, options) => {
signer.update(canonicalizedHeaderData.headers);
try {
signature = signer.sign(options.privateKey, 'base64');
} catch (E) {
} catch (_E) {
return false;
}

View File

@@ -132,7 +132,13 @@ function nmfetch(url, options) {
});
}
if (parsed.protocol === 'https:' && parsed.hostname && parsed.hostname !== reqOptions.host && !net.isIP(parsed.hostname) && !reqOptions.servername) {
if (
parsed.protocol === 'https:' &&
parsed.hostname &&
parsed.hostname !== reqOptions.host &&
!net.isIP(parsed.hostname) &&
!reqOptions.servername
) {
reqOptions.servername = parsed.hostname;
}

View File

@@ -86,20 +86,34 @@ class MailComposer {
let icalEvent, eventObject;
let attachments = [].concat(this.mail.attachments || []).map((attachment, i) => {
let data;
let isMessageNode = /^message\//i.test(attachment.contentType);
if (/^data:/i.test(attachment.path || attachment.href)) {
attachment = this._processDataUrl(attachment);
}
let contentType = attachment.contentType || mimeFuncs.detectMimeType(attachment.filename || attachment.path || attachment.href || 'bin');
let contentType =
attachment.contentType || mimeFuncs.detectMimeType(attachment.filename || attachment.path || attachment.href || 'bin');
let isImage = /^image\//i.test(contentType);
let contentDisposition = attachment.contentDisposition || (isMessageNode || (isImage && attachment.cid) ? 'inline' : 'attachment');
let isMessageNode = /^message\//i.test(contentType);
let contentDisposition =
attachment.contentDisposition || (isMessageNode || (isImage && attachment.cid) ? 'inline' : 'attachment');
let contentTransferEncoding;
if ('contentTransferEncoding' in attachment) {
// also contains `false`, to set
contentTransferEncoding = attachment.contentTransferEncoding;
} else if (isMessageNode) {
contentTransferEncoding = '7bit';
} else {
contentTransferEncoding = 'base64'; // the default
}
data = {
contentType,
contentDisposition,
contentTransferEncoding: 'contentTransferEncoding' in attachment ? attachment.contentTransferEncoding : 'base64'
contentTransferEncoding
};
if (attachment.filename) {
@@ -200,7 +214,10 @@ class MailComposer {
eventObject;
if (this.mail.text) {
if (typeof this.mail.text === 'object' && (this.mail.text.content || this.mail.text.path || this.mail.text.href || this.mail.text.raw)) {
if (
typeof this.mail.text === 'object' &&
(this.mail.text.content || this.mail.text.path || this.mail.text.href || this.mail.text.raw)
) {
text = this.mail.text;
} else {
text = {
@@ -225,7 +242,10 @@ class MailComposer {
}
if (this.mail.amp) {
if (typeof this.mail.amp === 'object' && (this.mail.amp.content || this.mail.amp.path || this.mail.amp.href || this.mail.amp.raw)) {
if (
typeof this.mail.amp === 'object' &&
(this.mail.amp.content || this.mail.amp.path || this.mail.amp.href || this.mail.amp.raw)
) {
amp = this.mail.amp;
} else {
amp = {
@@ -260,14 +280,18 @@ class MailComposer {
}
eventObject.filename = false;
eventObject.contentType = 'text/calendar; charset=utf-8; method=' + (eventObject.method || 'PUBLISH').toString().trim().toUpperCase();
eventObject.contentType =
'text/calendar; charset=utf-8; method=' + (eventObject.method || 'PUBLISH').toString().trim().toUpperCase();
if (!eventObject.headers) {
eventObject.headers = {};
}
}
if (this.mail.html) {
if (typeof this.mail.html === 'object' && (this.mail.html.content || this.mail.html.path || this.mail.html.href || this.mail.html.raw)) {
if (
typeof this.mail.html === 'object' &&
(this.mail.html.content || this.mail.html.path || this.mail.html.href || this.mail.html.raw)
) {
html = this.mail.html;
} else {
html = {
@@ -292,7 +316,9 @@ class MailComposer {
}
data = {
contentType: alternative.contentType || mimeFuncs.detectMimeType(alternative.filename || alternative.path || alternative.href || 'txt'),
contentType:
alternative.contentType ||
mimeFuncs.detectMimeType(alternative.filename || alternative.path || alternative.href || 'txt'),
contentTransferEncoding: alternative.contentTransferEncoding
};
@@ -538,9 +564,33 @@ class MailComposer {
* @return {Object} Parsed element
*/
_processDataUrl(element) {
const dataUrl = element.path || element.href;
// Early validation to prevent ReDoS
if (!dataUrl || typeof dataUrl !== 'string') {
return element;
}
if (!dataUrl.startsWith('data:')) {
return element;
}
if (dataUrl.length > 100000) {
// 100KB limit for data URL string
// Return empty content for excessively long data URLs
return Object.assign({}, element, {
path: false,
href: false,
content: Buffer.alloc(0),
contentType: element.contentType || 'application/octet-stream'
});
}
let parsedDataUri;
if ((element.path || element.href).match(/^data:/)) {
parsedDataUri = parseDataURI(element.path || element.href);
try {
parsedDataUri = parseDataURI(dataUrl);
} catch (_err) {
return element;
}
if (!parsedDataUri) {

View File

@@ -87,6 +87,11 @@ class Mail extends EventEmitter {
this.transporter.on('idle', (...args) => {
this.emit('idle', ...args);
});
// indicates if the sender has became idle and all connections are terminated
this.transporter.on('clear', (...args) => {
this.emit('clear', ...args);
});
}
/**
@@ -236,7 +241,14 @@ class Mail extends EventEmitter {
}
getVersionString() {
return util.format('%s (%s; +%s; %s/%s)', packageData.name, packageData.version, packageData.homepage, this.transporter.name, this.transporter.version);
return util.format(
'%s (%s; +%s; %s/%s)',
packageData.name,
packageData.version,
packageData.homepage,
this.transporter.name,
this.transporter.version
);
}
_processPlugins(step, mail, callback) {

View File

@@ -64,7 +64,8 @@ class MailMessage {
if (this.data.attachments && this.data.attachments.length) {
this.data.attachments.forEach((attachment, i) => {
if (!attachment.filename) {
attachment.filename = (attachment.path || attachment.href || '').split('/').pop().split('?').shift() || 'attachment-' + (i + 1);
attachment.filename =
(attachment.path || attachment.href || '').split('/').pop().split('?').shift() || 'attachment-' + (i + 1);
if (attachment.filename.indexOf('.') < 0) {
attachment.filename += '.' + mimeFuncs.detectExtension(attachment.contentType);
}

View File

@@ -269,7 +269,7 @@ module.exports = {
// first line includes the charset and language info and needs to be encoded
// even if it does not contain any unicode characters
line = 'utf-8\x27\x27';
line = "utf-8''";
let encoded = true;
startPos = 0;
@@ -614,7 +614,7 @@ module.exports = {
try {
// might throw if we try to encode invalid sequences, eg. partial emoji
str = encodeURIComponent(str);
} catch (E) {
} catch (_E) {
// should never run
return str.replace(/[^\x00-\x1F *'()<>@,;:\\"[\]?=\u007F-\uFFFF]+/g, '');
}

View File

@@ -44,6 +44,7 @@ const mimeTypes = new Map([
['application/fractals', 'fif'],
['application/freeloader', 'frl'],
['application/futuresplash', 'spl'],
['application/geo+json', 'geojson'],
['application/gnutar', 'tgz'],
['application/groupwise', 'vew'],
['application/hlp', 'hlp'],
@@ -1101,7 +1102,10 @@ const extensions = new Map([
['bdm', 'application/vnd.syncml.dm+wbxml'],
['bed', 'application/vnd.realvnc.bed'],
['bh2', 'application/vnd.fujitsu.oasysprs'],
['bin', ['application/octet-stream', 'application/mac-binary', 'application/macbinary', 'application/x-macbinary', 'application/x-binary']],
[
'bin',
['application/octet-stream', 'application/mac-binary', 'application/macbinary', 'application/x-macbinary', 'application/x-binary']
],
['bm', 'image/bmp'],
['bmi', 'application/vnd.bmi'],
['bmp', ['image/bmp', 'image/x-windows-bmp']],
@@ -1146,7 +1150,10 @@ const extensions = new Map([
['cii', 'application/vnd.anser-web-certificate-issue-initiation'],
['cil', 'application/vnd.ms-artgalry'],
['cla', 'application/vnd.claymore'],
['class', ['application/octet-stream', 'application/java', 'application/java-byte-code', 'application/java-vm', 'application/x-java-class']],
[
'class',
['application/octet-stream', 'application/java', 'application/java-byte-code', 'application/java-vm', 'application/x-java-class']
],
['clkk', 'application/vnd.crick.clicker.keyboard'],
['clkp', 'application/vnd.crick.clicker.palette'],
['clkt', 'application/vnd.crick.clicker.template'],
@@ -1287,6 +1294,7 @@ const extensions = new Map([
['gac', 'application/vnd.groove-account'],
['gdl', 'model/vnd.gdl'],
['geo', 'application/vnd.dynageo'],
['geojson', 'application/geo+json'],
['gex', 'application/vnd.geometry-explorer'],
['ggb', 'application/vnd.geogebra.file'],
['ggt', 'application/vnd.geogebra.tool'],
@@ -1750,7 +1758,10 @@ const extensions = new Map([
['sbml', 'application/sbml+xml'],
['sc', 'application/vnd.ibm.secure-container'],
['scd', 'application/x-msschedule'],
['scm', ['application/vnd.lotus-screencam', 'video/x-scm', 'text/x-script.guile', 'application/x-lotusscreencam', 'text/x-script.scheme']],
[
'scm',
['application/vnd.lotus-screencam', 'video/x-scm', 'text/x-script.guile', 'application/x-lotusscreencam', 'text/x-script.scheme']
],
['scq', 'application/scvp-cv-request'],
['scs', 'application/scvp-cv-response'],
['sct', 'text/scriptlet'],

View File

@@ -552,7 +552,11 @@ class MimeNode {
this._handleContentType(structured);
if (structured.value.match(/^text\/plain\b/) && typeof this.content === 'string' && /[\u0080-\uFFFF]/.test(this.content)) {
if (
structured.value.match(/^text\/plain\b/) &&
typeof this.content === 'string' &&
/[\u0080-\uFFFF]/.test(this.content)
) {
structured.params.charset = 'utf-8';
}
@@ -963,8 +967,8 @@ class MimeNode {
setImmediate(() => {
try {
contentStream.end(content._resolvedValue);
} catch (err) {
contentStream.emit('error', err);
} catch (_err) {
contentStream.emit('error', _err);
}
});
@@ -995,8 +999,8 @@ class MimeNode {
setImmediate(() => {
try {
contentStream.end(content || '');
} catch (err) {
contentStream.emit('error', err);
} catch (_err) {
contentStream.emit('error', _err);
}
});
return contentStream;
@@ -1014,7 +1018,6 @@ class MimeNode {
return [].concat.apply(
[],
[].concat(addresses).map(address => {
// eslint-disable-line prefer-spread
if (address && address.address) {
address.address = this._normalizeAddress(address.address);
address.name = address.name || '';
@@ -1113,7 +1116,6 @@ class MimeNode {
.apply(
[],
[].concat(value || '').map(elm => {
// eslint-disable-line prefer-spread
elm = (elm || '')
.toString()
.replace(/\r?\n|\r/g, ' ')
@@ -1219,7 +1221,7 @@ class MimeNode {
try {
encodedDomain = punycode.toASCII(domain.toLowerCase());
} catch (err) {
} catch (_err) {
// keep as is?
}
@@ -1282,7 +1284,7 @@ class MimeNode {
// count latin alphabet symbols and 8-bit range symbols + control symbols
// if there are more latin characters, then use quoted-printable
// encoding, otherwise use base64
nonLatinLen = (value.match(/[\x00-\x08\x0B\x0C\x0E-\x1F\u0080-\uFFFF]/g) || []).length; // eslint-disable-line no-control-regex
nonLatinLen = (value.match(/[\x00-\x08\x0B\x0C\x0E-\x1F\u0080-\uFFFF]/g) || []).length;
latinLen = (value.match(/[a-z]/gi) || []).length;
// if there are more latin symbols than binary/unicode, then prefer Q, otherwise B
encoding = nonLatinLen < latinLen ? 'Q' : 'B';

View File

@@ -45,6 +45,13 @@ module.exports.createTransport = function (transporter, defaults) {
} else if (options.jsonTransport) {
transporter = new JSONTransport(options);
} else if (options.SES) {
if (options.SES.ses && options.SES.aws) {
let error = new Error(
'Using legacy SES configuration, expecting @aws-sdk/client-sesv2, see https://nodemailer.com/transports/ses/'
);
error.code = 'LegacyConfig';
throw error;
}
transporter = new SESTransport(options);
} else {
transporter = new SMTPTransport(options);

View File

@@ -28,7 +28,10 @@ function encode(buffer) {
for (let i = 0, len = buffer.length; i < len; i++) {
ord = buffer[i];
// if the char is in allowed range, then keep as is, unless it is a WS in the end of a line
if (checkRanges(ord, ranges) && !((ord === 0x20 || ord === 0x09) && (i === len - 1 || buffer[i + 1] === 0x0a || buffer[i + 1] === 0x0d))) {
if (
checkRanges(ord, ranges) &&
!((ord === 0x20 || ord === 0x09) && (i === len - 1 || buffer[i + 1] === 0x0a || buffer[i + 1] === 0x0d))
) {
result += String.fromCharCode(ord);
continue;
}
@@ -90,7 +93,12 @@ function wrap(str, lineLength) {
}
// ensure that utf-8 sequences are not split
while (line.length > 3 && line.length < len - pos && !line.match(/^(?:=[\da-f]{2}){1,4}$/i) && (match = line.match(/[=][\da-f]{2}$/gi))) {
while (
line.length > 3 &&
line.length < len - pos &&
!line.match(/^(?:=[\da-f]{2}){1,4}$/i) &&
(match = line.match(/[=][\da-f]{2}$/gi))
) {
code = parseInt(match[0].substr(1, 2), 16);
if (code < 128) {
break;

View File

@@ -4,15 +4,11 @@ const EventEmitter = require('events');
const packageData = require('../../package.json');
const shared = require('../shared');
const LeWindows = require('../mime-node/le-windows');
const MimeNode = require('../mime-node');
/**
* Generates a Transport object for AWS SES
*
* Possible options can be the following:
*
* * **sendingRate** optional Number specifying how many messages per second should be delivered to SES
* * **maxConnections** optional Number specifying max number of parallel connections to SES
*
* @constructor
* @param {Object} optional config parameter
*/
@@ -30,119 +26,17 @@ class SESTransport extends EventEmitter {
this.logger = shared.getLogger(this.options, {
component: this.options.component || 'ses-transport'
});
// parallel sending connections
this.maxConnections = Number(this.options.maxConnections) || Infinity;
this.connections = 0;
// max messages per second
this.sendingRate = Number(this.options.sendingRate) || Infinity;
this.sendingRateTTL = null;
this.rateInterval = 1000; // milliseconds
this.rateMessages = [];
this.pending = [];
this.idling = true;
setImmediate(() => {
if (this.idling) {
this.emit('idle');
}
});
}
/**
* Schedules a sending of a message
*
* @param {Object} emailMessage MailComposer object
* @param {Function} callback Callback function to run when the sending is completed
*/
send(mail, callback) {
if (this.connections >= this.maxConnections) {
this.idling = false;
return this.pending.push({
mail,
callback
});
getRegion(cb) {
if (this.ses.sesClient.config && typeof this.ses.sesClient.config.region === 'function') {
// promise
return this.ses.sesClient.config
.region()
.then(region => cb(null, region))
.catch(err => cb(err));
}
if (!this._checkSendingRate()) {
this.idling = false;
return this.pending.push({
mail,
callback
});
}
this._send(mail, (...args) => {
setImmediate(() => callback(...args));
this._sent();
});
}
_checkRatedQueue() {
if (this.connections >= this.maxConnections || !this._checkSendingRate()) {
return;
}
if (!this.pending.length) {
if (!this.idling) {
this.idling = true;
this.emit('idle');
}
return;
}
let next = this.pending.shift();
this._send(next.mail, (...args) => {
setImmediate(() => next.callback(...args));
this._sent();
});
}
_checkSendingRate() {
clearTimeout(this.sendingRateTTL);
let now = Date.now();
let oldest = false;
// delete older messages
for (let i = this.rateMessages.length - 1; i >= 0; i--) {
if (this.rateMessages[i].ts >= now - this.rateInterval && (!oldest || this.rateMessages[i].ts < oldest)) {
oldest = this.rateMessages[i].ts;
}
if (this.rateMessages[i].ts < now - this.rateInterval && !this.rateMessages[i].pending) {
this.rateMessages.splice(i, 1);
}
}
if (this.rateMessages.length < this.sendingRate) {
return true;
}
let delay = Math.max(oldest + 1001, now + 20);
this.sendingRateTTL = setTimeout(() => this._checkRatedQueue(), now - delay);
try {
this.sendingRateTTL.unref();
} catch (E) {
// Ignore. Happens on envs with non-node timer implementation
}
return false;
}
_sent() {
this.connections--;
this._checkRatedQueue();
}
/**
* Returns true if there are free slots in the queue
*/
isIdle() {
return this.idling;
return cb(null, false);
}
/**
@@ -151,13 +45,17 @@ class SESTransport extends EventEmitter {
* @param {Object} emailMessage MailComposer object
* @param {Function} callback Callback function to run when the sending is completed
*/
_send(mail, callback) {
send(mail, callback) {
let statObject = {
ts: Date.now(),
pending: true
};
this.connections++;
this.rateMessages.push(statObject);
let fromHeader = mail.message._headers.find(header => /^from$/i.test(header.key));
if (fromHeader) {
let mimeNode = new MimeNode('text/plain');
fromHeader = mimeNode._convertAddresses(mimeNode._parseAddresses(fromHeader.value));
}
let envelope = mail.data.envelope || mail.message.getEnvelope();
let messageId = mail.message.messageId();
@@ -227,45 +125,29 @@ class SESTransport extends EventEmitter {
}
let sesMessage = {
RawMessage: {
// required
Data: raw // required
Content: {
Raw: {
// required
Data: raw // required
}
},
Source: envelope.from,
Destinations: envelope.to
FromEmailAddress: fromHeader ? fromHeader : envelope.from,
Destination: {
ToAddresses: envelope.to
}
};
Object.keys(mail.data.ses || {}).forEach(key => {
sesMessage[key] = mail.data.ses[key];
});
let ses = (this.ses.aws ? this.ses.ses : this.ses) || {};
let aws = this.ses.aws || {};
let getRegion = cb => {
if (ses.config && typeof ses.config.region === 'function') {
// promise
return ses.config
.region()
.then(region => cb(null, region))
.catch(err => cb(err));
}
return cb(null, (ses.config && ses.config.region) || 'us-east-1');
};
getRegion((err, region) => {
this.getRegion((err, region) => {
if (err || !region) {
region = 'us-east-1';
}
let sendPromise;
if (typeof ses.send === 'function' && aws.SendRawEmailCommand) {
// v3 API
sendPromise = ses.send(new aws.SendRawEmailCommand(sesMessage));
} else {
// v2 API
sendPromise = ses.sendRawEmail(sesMessage).promise();
}
const command = new this.ses.SendEmailCommand(sesMessage);
const sendPromise = this.ses.sesClient.send(command);
sendPromise
.then(data => {
@@ -273,7 +155,7 @@ class SESTransport extends EventEmitter {
region = 'email';
}
statObject.pending = false;
statObject.pending = true;
callback(null, {
envelope: {
from: envelope.from,
@@ -309,38 +191,41 @@ class SESTransport extends EventEmitter {
*/
verify(callback) {
let promise;
let ses = (this.ses.aws ? this.ses.ses : this.ses) || {};
let aws = this.ses.aws || {};
const sesMessage = {
RawMessage: {
// required
Data: 'From: invalid@invalid\r\nTo: invalid@invalid\r\n Subject: Invalid\r\n\r\nInvalid'
},
Source: 'invalid@invalid',
Destinations: ['invalid@invalid']
};
if (!callback) {
promise = new Promise((resolve, reject) => {
callback = shared.callbackPromise(resolve, reject);
});
}
const cb = err => {
if (err && (err.code || err.Code) !== 'InvalidParameterValue') {
if (err && !['InvalidParameterValue', 'MessageRejected'].includes(err.code || err.Code || err.name)) {
return callback(err);
}
return callback(null, true);
};
if (typeof ses.send === 'function' && aws.SendRawEmailCommand) {
// v3 API
sesMessage.RawMessage.Data = Buffer.from(sesMessage.RawMessage.Data);
ses.send(new aws.SendRawEmailCommand(sesMessage), cb);
} else {
// v2 API
ses.sendRawEmail(sesMessage, cb);
}
const sesMessage = {
Content: {
Raw: {
Data: Buffer.from('From: <invalid@invalid>\r\nTo: <invalid@invalid>\r\n Subject: Invalid\r\n\r\nInvalid')
}
},
FromEmailAddress: 'invalid@invalid',
Destination: {
ToAddresses: ['invalid@invalid']
}
};
this.getRegion((err, region) => {
if (err || !region) {
region = 'us-east-1';
}
const command = new this.ses.SendEmailCommand(sesMessage);
const sendPromise = this.ses.sesClient.send(command);
sendPromise.then(data => cb(null, data)).catch(err => cb(err));
});
return promise;
}

View File

@@ -11,11 +11,19 @@ const net = require('net');
const os = require('os');
const DNS_TTL = 5 * 60 * 1000;
const CACHE_CLEANUP_INTERVAL = 30 * 1000; // Minimum 30 seconds between cleanups
const MAX_CACHE_SIZE = 1000; // Maximum number of entries in cache
let lastCacheCleanup = 0;
module.exports._lastCacheCleanup = () => lastCacheCleanup;
module.exports._resetCacheCleanup = () => {
lastCacheCleanup = 0;
};
let networkInterfaces;
try {
networkInterfaces = os.networkInterfaces();
} catch (err) {
} catch (_err) {
// fails on some systems
}
@@ -81,8 +89,8 @@ const formatDNSValue = (value, extra) => {
!value.addresses || !value.addresses.length
? null
: value.addresses.length === 1
? value.addresses[0]
: value.addresses[Math.floor(Math.random() * value.addresses.length)]
? value.addresses[0]
: value.addresses[Math.floor(Math.random() * value.addresses.length)]
},
extra || {}
);
@@ -113,7 +121,27 @@ module.exports.resolveHostname = (options, callback) => {
if (dnsCache.has(options.host)) {
cached = dnsCache.get(options.host);
if (!cached.expires || cached.expires >= Date.now()) {
// Lazy cleanup with time throttling
const now = Date.now();
if (now - lastCacheCleanup > CACHE_CLEANUP_INTERVAL) {
lastCacheCleanup = now;
// Clean up expired entries
for (const [host, entry] of dnsCache.entries()) {
if (entry.expires && entry.expires < now) {
dnsCache.delete(host);
}
}
// If cache is still too large, remove oldest entries
if (dnsCache.size > MAX_CACHE_SIZE) {
const toDelete = Math.floor(MAX_CACHE_SIZE * 0.1); // Remove 10% of entries
const keys = Array.from(dnsCache.keys()).slice(0, toDelete);
keys.forEach(key => dnsCache.delete(key));
}
}
if (!cached.expires || cached.expires >= now) {
return callback(
null,
formatDNSValue(cached.value, {
@@ -126,7 +154,11 @@ module.exports.resolveHostname = (options, callback) => {
resolver(4, options.host, options, (err, addresses) => {
if (err) {
if (cached) {
// ignore error, use expired value
dnsCache.set(options.host, {
value: cached.value,
expires: Date.now() + (options.dnsTtl || DNS_TTL)
});
return callback(
null,
formatDNSValue(cached.value, {
@@ -160,7 +192,11 @@ module.exports.resolveHostname = (options, callback) => {
resolver(6, options.host, options, (err, addresses) => {
if (err) {
if (cached) {
// ignore error, use expired value
dnsCache.set(options.host, {
value: cached.value,
expires: Date.now() + (options.dnsTtl || DNS_TTL)
});
return callback(
null,
formatDNSValue(cached.value, {
@@ -195,7 +231,11 @@ module.exports.resolveHostname = (options, callback) => {
dns.lookup(options.host, { all: true }, (err, addresses) => {
if (err) {
if (cached) {
// ignore error, use expired value
dnsCache.set(options.host, {
value: cached.value,
expires: Date.now() + (options.dnsTtl || DNS_TTL)
});
return callback(
null,
formatDNSValue(cached.value, {
@@ -246,9 +286,13 @@ module.exports.resolveHostname = (options, callback) => {
})
);
});
} catch (err) {
} catch (_err) {
if (cached) {
// ignore error, use expired value
dnsCache.set(options.host, {
value: cached.value,
expires: Date.now() + (options.dnsTtl || DNS_TTL)
});
return callback(
null,
formatDNSValue(cached.value, {
@@ -419,52 +463,74 @@ module.exports.callbackPromise = (resolve, reject) =>
};
module.exports.parseDataURI = uri => {
let input = uri;
let commaPos = input.indexOf(',');
if (!commaPos) {
return uri;
if (typeof uri !== 'string') {
return null;
}
let data = input.substring(commaPos + 1);
let metaStr = input.substring('data:'.length, commaPos);
// Early return for non-data URIs to avoid unnecessary processing
if (!uri.startsWith('data:')) {
return null;
}
// Find the first comma safely - this prevents ReDoS
const commaPos = uri.indexOf(',');
if (commaPos === -1) {
return null;
}
const data = uri.substring(commaPos + 1);
const metaStr = uri.substring('data:'.length, commaPos);
let encoding;
const metaEntries = metaStr.split(';');
let metaEntries = metaStr.split(';');
let lastMetaEntry = metaEntries.length > 1 ? metaEntries[metaEntries.length - 1] : false;
if (lastMetaEntry && lastMetaEntry.indexOf('=') < 0) {
encoding = lastMetaEntry.toLowerCase();
metaEntries.pop();
}
let contentType = metaEntries.shift() || 'application/octet-stream';
let params = {};
for (let entry of metaEntries) {
let sep = entry.indexOf('=');
if (sep >= 0) {
let key = entry.substring(0, sep);
let value = entry.substring(sep + 1);
params[key] = value;
if (metaEntries.length > 0) {
const lastEntry = metaEntries[metaEntries.length - 1].toLowerCase().trim();
// Only recognize valid encoding types to prevent manipulation
if (['base64', 'utf8', 'utf-8'].includes(lastEntry) && lastEntry.indexOf('=') === -1) {
encoding = lastEntry;
metaEntries.pop();
}
}
switch (encoding) {
case 'base64':
data = Buffer.from(data, 'base64');
break;
case 'utf8':
data = Buffer.from(data);
break;
default:
try {
data = Buffer.from(decodeURIComponent(data));
} catch (err) {
data = Buffer.from(data);
const contentType = metaEntries.length > 0 ? metaEntries.shift() : 'application/octet-stream';
const params = {};
for (let i = 0; i < metaEntries.length; i++) {
const entry = metaEntries[i];
const sepPos = entry.indexOf('=');
if (sepPos > 0) {
// Ensure there's a key before the '='
const key = entry.substring(0, sepPos).trim();
const value = entry.substring(sepPos + 1).trim();
if (key) {
params[key] = value;
}
data = Buffer.from(data);
}
}
return { data, encoding, contentType, params };
// Decode data based on encoding with proper error handling
let bufferData;
try {
if (encoding === 'base64') {
bufferData = Buffer.from(data, 'base64');
} else {
try {
bufferData = Buffer.from(decodeURIComponent(data));
} catch (_decodeError) {
bufferData = Buffer.from(data);
}
}
} catch (_bufferError) {
bufferData = Buffer.alloc(0);
}
return {
data: bufferData,
encoding: encoding || null,
contentType: contentType || 'application/octet-stream',
params
};
};
/**

View File

@@ -51,7 +51,7 @@ function httpProxyClient(proxyUrl, destinationPort, destinationHost, callback) {
finished = true;
try {
socket.destroy();
} catch (E) {
} catch (_E) {
// ignore
}
callback(err);
@@ -118,7 +118,7 @@ function httpProxyClient(proxyUrl, destinationPort, destinationHost, callback) {
if (!match || (match[1] || '').charAt(0) !== '2') {
try {
socket.destroy();
} catch (E) {
} catch (_E) {
// ignore
}
return callback(new Error('Invalid response from proxy' + ((match && ': ' + match[1]) || '')));

View File

@@ -124,7 +124,7 @@ class SMTPConnection extends EventEmitter {
/**
* The socket connecting to the server
* @publick
* @public
*/
this._socket = false;
@@ -243,6 +243,8 @@ class SMTPConnection extends EventEmitter {
if (this.options.connection) {
// connection is already opened
this._socket = this.options.connection;
setupConnectionHandlers();
if (this.secureConnection && !this.alreadySecured) {
setImmediate(() =>
this._upgradeConnection(err => {
@@ -412,8 +414,8 @@ class SMTPConnection extends EventEmitter {
if (socket && !socket.destroyed) {
try {
this._socket[closeMethod]();
} catch (E) {
socket[closeMethod]();
} catch (_E) {
// just ignore
}
}
@@ -628,6 +630,15 @@ class SMTPConnection extends EventEmitter {
let startTime = Date.now();
this._setEnvelope(envelope, (err, info) => {
if (err) {
// create passthrough stream to consume to prevent OOM
let stream = new PassThrough();
if (typeof message.pipe === 'function') {
message.pipe(stream);
} else {
stream.write(message);
stream.end();
}
return callback(err);
}
let envelopeTime = Date.now();
@@ -1283,7 +1294,12 @@ class SMTPConnection extends EventEmitter {
if (str.charAt(0) !== '2') {
if (this.options.requireTLS) {
this._onError(new Error('EHLO failed but HELO does not support required STARTTLS. response=' + str), 'ECONNECTION', str, 'EHLO');
this._onError(
new Error('EHLO failed but HELO does not support required STARTTLS. response=' + str),
'ECONNECTION',
str,
'EHLO'
);
return;
}
@@ -1465,7 +1481,9 @@ class SMTPConnection extends EventEmitter {
let challengeString = '';
if (!challengeMatch) {
return callback(this._formatError('Invalid login sequence while waiting for server challenge string', 'EAUTH', str, 'AUTH CRAM-MD5'));
return callback(
this._formatError('Invalid login sequence while waiting for server challenge string', 'EAUTH', str, 'AUTH CRAM-MD5')
);
} else {
challengeString = challengeMatch[1];
}
@@ -1608,7 +1626,7 @@ class SMTPConnection extends EventEmitter {
}
if (!this._envelope.rcptQueue.length) {
return callback(this._formatError('Can\x27t send mail - no recipients defined', 'EENVELOPE', false, 'API'));
return callback(this._formatError("Can't send mail - no recipients defined", 'EENVELOPE', false, 'API'));
} else {
this._recipientQueue = [];
@@ -1664,7 +1682,7 @@ class SMTPConnection extends EventEmitter {
});
this._sendCommand('DATA');
} else {
err = this._formatError('Can\x27t send mail - all recipients were rejected', 'EENVELOPE', str, 'RCPT TO');
err = this._formatError("Can't send mail - all recipients were rejected", 'EENVELOPE', str, 'RCPT TO');
err.rejected = this._envelope.rejected;
err.rejectedErrors = this._envelope.rejectedErrors;
return callback(err);
@@ -1803,7 +1821,7 @@ class SMTPConnection extends EventEmitter {
let defaultHostname;
try {
defaultHostname = os.hostname() || '';
} catch (err) {
} catch (_err) {
// fails on windows 7
defaultHostname = 'localhost';
}

View File

@@ -406,6 +406,10 @@ class SMTPPool extends EventEmitter {
this._continueProcessing();
}, 50);
} else {
if (!this._closed && this.idling && !this._connections.length) {
this.emit('clear');
}
this._continueProcessing();
}
});

View File

@@ -23,7 +23,8 @@ class PoolResource extends EventEmitter {
switch ((this.options.auth.type || '').toString().toUpperCase()) {
case 'OAUTH2': {
let oauth2 = new XOAuth2(this.options.auth, this.logger);
oauth2.provisionCallback = (this.pool.mailer && this.pool.mailer.get('oauth2_provision_cb')) || oauth2.provisionCallback;
oauth2.provisionCallback =
(this.pool.mailer && this.pool.mailer.get('oauth2_provision_cb')) || oauth2.provisionCallback;
this.auth = {
type: 'OAUTH2',
user: this.options.auth.user,
@@ -127,7 +128,7 @@ class PoolResource extends EventEmitter {
try {
timer.unref();
} catch (E) {
} catch (_E) {
// Ignore. Happens on envs with non-node timer implementation
}
});

View File

@@ -197,7 +197,7 @@ class SMTPTransport extends EventEmitter {
try {
timer.unref();
} catch (E) {
} catch (_E) {
// Ignore. Happens on envs with non-node timer implementation
}
});

View File

@@ -1,63 +1,120 @@
{
"1und1": {
"description": "1&1 Mail (German hosting provider)",
"host": "smtp.1und1.de",
"port": 465,
"secure": true,
"authMethod": "LOGIN"
},
"126": {
"description": "126 Mail (NetEase)",
"host": "smtp.126.com",
"port": 465,
"secure": true
},
"163": {
"description": "163 Mail (NetEase)",
"host": "smtp.163.com",
"port": 465,
"secure": true
},
"Aliyun": {
"description": "Alibaba Cloud Mail",
"domains": ["aliyun.com"],
"host": "smtp.aliyun.com",
"port": 465,
"secure": true
},
"AliyunQiye": {
"description": "Alibaba Cloud Enterprise Mail",
"host": "smtp.qiye.aliyun.com",
"port": 465,
"secure": true
},
"AOL": {
"description": "AOL Mail",
"domains": ["aol.com"],
"host": "smtp.aol.com",
"port": 587
},
"Aruba": {
"description": "Aruba PEC (Italian email provider)",
"domains": ["aruba.it", "pec.aruba.it"],
"aliases": ["Aruba PEC"],
"host": "smtps.aruba.it",
"port": 465,
"secure": true,
"authMethod": "LOGIN"
},
"Bluewin": {
"description": "Bluewin (Swiss email provider)",
"host": "smtpauths.bluewin.ch",
"domains": ["bluewin.ch"],
"port": 465
},
"BOL": {
"description": "BOL Mail (Brazilian provider)",
"domains": ["bol.com.br"],
"host": "smtp.bol.com.br",
"port": 587,
"requireTLS": true
},
"DebugMail": {
"description": "DebugMail (email testing service)",
"host": "debugmail.io",
"port": 25
},
"Disroot": {
"description": "Disroot (privacy-focused provider)",
"domains": ["disroot.org"],
"host": "disroot.org",
"port": 587,
"secure": false,
"authMethod": "LOGIN"
},
"DynectEmail": {
"description": "Dyn Email Delivery",
"aliases": ["Dynect"],
"host": "smtp.dynect.net",
"port": 25
},
"ElasticEmail": {
"description": "Elastic Email",
"aliases": ["Elastic Email"],
"host": "smtp.elasticemail.com",
"port": 465,
"secure": true
},
"Ethereal": {
"description": "Ethereal Email (email testing service)",
"aliases": ["ethereal.email"],
"host": "smtp.ethereal.email",
"port": 587
},
"FastMail": {
"description": "FastMail",
"domains": ["fastmail.fm"],
"host": "smtp.fastmail.com",
"port": 465,
"secure": true
},
"Forward Email": {
"aliases": ["FE", "ForwardEmail"],
"domains": ["forwardemail.net"],
"host": "smtp.forwardemail.net",
"port": 465,
"secure": true
},
"Feishu Mail": {
"description": "Feishu Mail (Lark)",
"aliases": ["Feishu", "FeishuMail"],
"domains": ["www.feishu.cn"],
"host": "smtp.feishu.cn",
@@ -65,13 +122,24 @@
"secure": true
},
"Forward Email": {
"description": "Forward Email (email forwarding service)",
"aliases": ["FE", "ForwardEmail"],
"domains": ["forwardemail.net"],
"host": "smtp.forwardemail.net",
"port": 465,
"secure": true
},
"GandiMail": {
"description": "Gandi Mail",
"aliases": ["Gandi", "Gandi Mail"],
"host": "mail.gandi.net",
"port": 587
},
"Gmail": {
"description": "Gmail",
"aliases": ["Google Mail"],
"domains": ["gmail.com", "googlemail.com"],
"host": "smtp.gmail.com",
@@ -79,26 +147,38 @@
"secure": true
},
"GMX": {
"description": "GMX Mail",
"domains": ["gmx.com", "gmx.net", "gmx.de"],
"host": "mail.gmx.com",
"port": 587
},
"Godaddy": {
"description": "GoDaddy Email (US)",
"host": "smtpout.secureserver.net",
"port": 25
},
"GodaddyAsia": {
"description": "GoDaddy Email (Asia)",
"host": "smtp.asia.secureserver.net",
"port": 25
},
"GodaddyEurope": {
"description": "GoDaddy Email (Europe)",
"host": "smtp.europe.secureserver.net",
"port": 25
},
"hot.ee": {
"description": "Hot.ee (Estonian email provider)",
"host": "mail.hot.ee"
},
"Hotmail": {
"description": "Outlook.com / Hotmail",
"aliases": ["Outlook", "Outlook.com", "Hotmail.com"],
"domains": ["hotmail.com", "outlook.com"],
"host": "smtp-mail.outlook.com",
@@ -106,6 +186,7 @@
},
"iCloud": {
"description": "iCloud Mail",
"aliases": ["Me", "Mac"],
"domains": ["me.com", "mac.com"],
"host": "smtp.mail.me.com",
@@ -113,72 +194,117 @@
},
"Infomaniak": {
"description": "Infomaniak Mail (Swiss hosting provider)",
"host": "mail.infomaniak.com",
"domains": ["ik.me", "ikmail.com", "etik.com"],
"port": 587
},
"KolabNow": {
"description": "KolabNow (secure email service)",
"domains": ["kolabnow.com"],
"aliases": ["Kolab"],
"host": "smtp.kolabnow.com",
"port": 465,
"secure": true,
"authMethod": "LOGIN"
},
"Loopia": {
"description": "Loopia (Swedish hosting provider)",
"host": "mailcluster.loopia.se",
"port": 465
},
"Loops": {
"description": "Loops",
"host": "smtp.loops.so",
"port": 587
},
"mail.ee": {
"description": "Mail.ee (Estonian email provider)",
"host": "smtp.mail.ee"
},
"Mail.ru": {
"description": "Mail.ru",
"host": "smtp.mail.ru",
"port": 465,
"secure": true
},
"Mailcatch.app": {
"description": "Mailcatch (email testing service)",
"host": "sandbox-smtp.mailcatch.app",
"port": 2525
},
"Maildev": {
"description": "MailDev (local email testing)",
"port": 1025,
"ignoreTLS": true
},
"MailerSend": {
"description": "MailerSend",
"host": "smtp.mailersend.net",
"port": 587
},
"Mailgun": {
"description": "Mailgun",
"host": "smtp.mailgun.org",
"port": 465,
"secure": true
},
"Mailjet": {
"description": "Mailjet",
"host": "in.mailjet.com",
"port": 587
},
"Mailosaur": {
"description": "Mailosaur (email testing service)",
"host": "mailosaur.io",
"port": 25
},
"Mailtrap": {
"description": "Mailtrap",
"host": "live.smtp.mailtrap.io",
"port": 587
},
"Mandrill": {
"description": "Mandrill (by Mailchimp)",
"host": "smtp.mandrillapp.com",
"port": 587
},
"Naver": {
"description": "Naver Mail (Korean email provider)",
"host": "smtp.naver.com",
"port": 587
},
"OhMySMTP": {
"description": "OhMySMTP (email delivery service)",
"host": "smtp.ohmysmtp.com",
"port": 587,
"secure": false
},
"One": {
"description": "One.com Email",
"host": "send.one.com",
"port": 465,
"secure": true
},
"OpenMailBox": {
"description": "OpenMailBox",
"aliases": ["OMB", "openmailbox.org"],
"host": "smtp.openmailbox.org",
"port": 465,
@@ -186,30 +312,37 @@
},
"Outlook365": {
"description": "Microsoft 365 / Office 365",
"host": "smtp.office365.com",
"port": 587,
"secure": false
},
"OhMySMTP": {
"host": "smtp.ohmysmtp.com",
"port": 587,
"secure": false
},
"Postmark": {
"description": "Postmark",
"aliases": ["PostmarkApp"],
"host": "smtp.postmarkapp.com",
"port": 2525
},
"Proton": {
"description": "Proton Mail",
"aliases": ["ProtonMail", "Proton.me", "Protonmail.com", "Protonmail.ch"],
"domains": ["proton.me", "protonmail.com", "pm.me", "protonmail.ch"],
"host": "smtp.protonmail.ch",
"port": 587,
"requireTLS": true
},
"qiye.aliyun": {
"description": "Alibaba Mail Enterprise Edition",
"host": "smtp.mxhichina.com",
"port": "465",
"secure": true
},
"QQ": {
"description": "QQ Mail",
"domains": ["qq.com"],
"host": "smtp.qq.com",
"port": 465,
@@ -217,6 +350,7 @@
},
"QQex": {
"description": "QQ Enterprise Mail",
"aliases": ["QQ Enterprise"],
"domains": ["exmail.qq.com"],
"host": "smtp.exmail.qq.com",
@@ -224,89 +358,204 @@
"secure": true
},
"Resend": {
"description": "Resend",
"host": "smtp.resend.com",
"port": 465,
"secure": true
},
"Runbox": {
"description": "Runbox (Norwegian email provider)",
"domains": ["runbox.com"],
"host": "smtp.runbox.com",
"port": 465,
"secure": true
},
"SendCloud": {
"description": "SendCloud (Chinese email delivery)",
"host": "smtp.sendcloud.net",
"port": 2525
},
"SendGrid": {
"description": "SendGrid",
"host": "smtp.sendgrid.net",
"port": 587
},
"SendinBlue": {
"description": "Brevo (formerly Sendinblue)",
"aliases": ["Brevo"],
"host": "smtp-relay.brevo.com",
"port": 587
},
"SendPulse": {
"description": "SendPulse",
"host": "smtp-pulse.com",
"port": 465,
"secure": true
},
"SES": {
"description": "AWS SES US East (N. Virginia)",
"host": "email-smtp.us-east-1.amazonaws.com",
"port": 465,
"secure": true
},
"SES-US-EAST-1": {
"host": "email-smtp.us-east-1.amazonaws.com",
"port": 465,
"secure": true
},
"SES-US-WEST-2": {
"host": "email-smtp.us-west-2.amazonaws.com",
"port": 465,
"secure": true
},
"SES-EU-WEST-1": {
"host": "email-smtp.eu-west-1.amazonaws.com",
"port": 465,
"secure": true
},
"SES-AP-SOUTH-1": {
"host": "email-smtp.ap-south-1.amazonaws.com",
"port": 465,
"secure": true
},
"SES-AP-NORTHEAST-1": {
"description": "AWS SES Asia Pacific (Tokyo)",
"host": "email-smtp.ap-northeast-1.amazonaws.com",
"port": 465,
"secure": true
},
"SES-AP-NORTHEAST-2": {
"description": "AWS SES Asia Pacific (Seoul)",
"host": "email-smtp.ap-northeast-2.amazonaws.com",
"port": 465,
"secure": true
},
"SES-AP-NORTHEAST-3": {
"description": "AWS SES Asia Pacific (Osaka)",
"host": "email-smtp.ap-northeast-3.amazonaws.com",
"port": 465,
"secure": true
},
"SES-AP-SOUTH-1": {
"description": "AWS SES Asia Pacific (Mumbai)",
"host": "email-smtp.ap-south-1.amazonaws.com",
"port": 465,
"secure": true
},
"SES-AP-SOUTHEAST-1": {
"description": "AWS SES Asia Pacific (Singapore)",
"host": "email-smtp.ap-southeast-1.amazonaws.com",
"port": 465,
"secure": true
},
"SES-AP-SOUTHEAST-2": {
"description": "AWS SES Asia Pacific (Sydney)",
"host": "email-smtp.ap-southeast-2.amazonaws.com",
"port": 465,
"secure": true
},
"SES-CA-CENTRAL-1": {
"description": "AWS SES Canada (Central)",
"host": "email-smtp.ca-central-1.amazonaws.com",
"port": 465,
"secure": true
},
"SES-EU-CENTRAL-1": {
"description": "AWS SES Europe (Frankfurt)",
"host": "email-smtp.eu-central-1.amazonaws.com",
"port": 465,
"secure": true
},
"SES-EU-NORTH-1": {
"description": "AWS SES Europe (Stockholm)",
"host": "email-smtp.eu-north-1.amazonaws.com",
"port": 465,
"secure": true
},
"SES-EU-WEST-1": {
"description": "AWS SES Europe (Ireland)",
"host": "email-smtp.eu-west-1.amazonaws.com",
"port": 465,
"secure": true
},
"SES-EU-WEST-2": {
"description": "AWS SES Europe (London)",
"host": "email-smtp.eu-west-2.amazonaws.com",
"port": 465,
"secure": true
},
"SES-EU-WEST-3": {
"description": "AWS SES Europe (Paris)",
"host": "email-smtp.eu-west-3.amazonaws.com",
"port": 465,
"secure": true
},
"SES-SA-EAST-1": {
"description": "AWS SES South America (São Paulo)",
"host": "email-smtp.sa-east-1.amazonaws.com",
"port": 465,
"secure": true
},
"SES-US-EAST-1": {
"description": "AWS SES US East (N. Virginia)",
"host": "email-smtp.us-east-1.amazonaws.com",
"port": 465,
"secure": true
},
"SES-US-EAST-2": {
"description": "AWS SES US East (Ohio)",
"host": "email-smtp.us-east-2.amazonaws.com",
"port": 465,
"secure": true
},
"SES-US-GOV-EAST-1": {
"description": "AWS SES GovCloud (US-East)",
"host": "email-smtp.us-gov-east-1.amazonaws.com",
"port": 465,
"secure": true
},
"SES-US-GOV-WEST-1": {
"description": "AWS SES GovCloud (US-West)",
"host": "email-smtp.us-gov-west-1.amazonaws.com",
"port": 465,
"secure": true
},
"SES-US-WEST-1": {
"description": "AWS SES US West (N. California)",
"host": "email-smtp.us-west-1.amazonaws.com",
"port": 465,
"secure": true
},
"SES-US-WEST-2": {
"description": "AWS SES US West (Oregon)",
"host": "email-smtp.us-west-2.amazonaws.com",
"port": 465,
"secure": true
},
"Seznam": {
"description": "Seznam Email (Czech email provider)",
"aliases": ["Seznam Email"],
"domains": ["seznam.cz", "email.cz", "post.cz", "spoluzaci.cz"],
"host": "smtp.seznam.cz",
"port": 465,
"secure": true
},
"SMTP2GO": {
"description": "SMTP2GO",
"host": "mail.smtp2go.com",
"port": 2525
},
"Sparkpost": {
"description": "SparkPost",
"aliases": ["SparkPost", "SparkPost Mail"],
"domains": ["sparkpost.com"],
"host": "smtp.sparkpostmail.com",
@@ -315,11 +564,21 @@
},
"Tipimail": {
"description": "Tipimail (email delivery service)",
"host": "smtp.tipimail.com",
"port": 587
},
"Tutanota": {
"description": "Tutanota (Tuta Mail)",
"domains": ["tutanota.com", "tuta.com", "tutanota.de", "tuta.io"],
"host": "smtp.tutanota.com",
"port": 465,
"secure": true
},
"Yahoo": {
"description": "Yahoo Mail",
"domains": ["yahoo.com"],
"host": "smtp.mail.yahoo.com",
"port": 465,
@@ -327,28 +586,26 @@
},
"Yandex": {
"description": "Yandex Mail",
"domains": ["yandex.ru"],
"host": "smtp.yandex.ru",
"port": 465,
"secure": true
},
"Zimbra": {
"description": "Zimbra Mail Server",
"aliases": ["Zimbra Collaboration"],
"host": "smtp.zimbra.com",
"port": 587,
"requireTLS": true
},
"Zoho": {
"description": "Zoho Mail",
"host": "smtp.zoho.com",
"port": 465,
"secure": true,
"authMethod": "LOGIN"
},
"126": {
"host": "smtp.126.com",
"port": 465,
"secure": true
},
"163": {
"host": "smtp.163.com",
"port": 465,
"secure": true
}
}

View File

@@ -72,6 +72,9 @@ class XOAuth2 extends Stream {
let timeout = Math.max(Number(this.options.timeout) || 0, 0);
this.expires = (timeout && Date.now() + timeout * 1000) || 0;
}
this.renewing = false; // Track if renewal is in progress
this.renewalQueue = []; // Queue for pending requests during renewal
}
/**
@@ -82,14 +85,61 @@ class XOAuth2 extends Stream {
*/
getToken(renew, callback) {
if (!renew && this.accessToken && (!this.expires || this.expires > Date.now())) {
this.logger.debug(
{
tnx: 'OAUTH2',
user: this.options.user,
action: 'reuse'
},
'Reusing existing access token for %s',
this.options.user
);
return callback(null, this.accessToken);
}
let generateCallback = (...args) => {
if (args[0]) {
// check if it is possible to renew, if not, return the current token or error
if (!this.provisionCallback && !this.options.refreshToken && !this.options.serviceClient) {
if (this.accessToken) {
this.logger.debug(
{
tnx: 'OAUTH2',
user: this.options.user,
action: 'reuse'
},
'Reusing existing access token (no refresh capability) for %s',
this.options.user
);
return callback(null, this.accessToken);
}
this.logger.error(
{
tnx: 'OAUTH2',
user: this.options.user,
action: 'renew'
},
'Cannot renew access token for %s: No refresh mechanism available',
this.options.user
);
return callback(new Error("Can't create new access token for user"));
}
// If renewal already in progress, queue this request instead of starting another
if (this.renewing) {
return this.renewalQueue.push({ renew, callback });
}
this.renewing = true;
// Handles token renewal completion - processes queued requests and cleans up
const generateCallback = (err, accessToken) => {
this.renewalQueue.forEach(item => item.callback(err, accessToken));
this.renewalQueue = [];
this.renewing = false;
if (err) {
this.logger.error(
{
err: args[0],
err,
tnx: 'OAUTH2',
user: this.options.user,
action: 'renew'
@@ -108,7 +158,8 @@ class XOAuth2 extends Stream {
this.options.user
);
}
callback(...args);
// Complete original request
callback(err, accessToken);
};
if (this.provisionCallback) {
@@ -166,8 +217,8 @@ class XOAuth2 extends Stream {
let token;
try {
token = this.jwtSignRS256(tokenData);
} catch (err) {
return callback(new Error('Can\x27t generate token. Check your auth options'));
} catch (_err) {
return callback(new Error("Can't generate token. Check your auth options"));
}
urlOptions = {
@@ -181,7 +232,7 @@ class XOAuth2 extends Stream {
};
} else {
if (!this.options.refreshToken) {
return callback(new Error('Can\x27t create new access token for user'));
return callback(new Error("Can't create new access token for user"));
}
// web app - https://developers.google.com/identity/protocols/OAuth2WebServer

View File

@@ -1,12 +1,15 @@
{
"name": "nodemailer",
"version": "6.9.14",
"version": "7.0.9",
"description": "Easy as cake e-mail sending from your Node.js applications",
"main": "lib/nodemailer.js",
"scripts": {
"test": "node --test --test-concurrency=1 test/**/*.test.js test/**/*-test.js",
"test:coverage": "c8 node --test --test-concurrency=1 test/**/*.test.js test/**/*-test.js",
"format": "prettier --write \"**/*.{js,json,md}\"",
"format:check": "prettier --check \"**/*.{js,json,md}\"",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"update": "rm -rf node_modules/ package-lock.json && ncu -u && npm install"
},
"repository": {
@@ -23,19 +26,20 @@
},
"homepage": "https://nodemailer.com/",
"devDependencies": {
"@aws-sdk/client-ses": "3.600.0",
"@aws-sdk/client-sesv2": "3.901.0",
"bunyan": "1.8.15",
"c8": "10.1.2",
"eslint": "8.57.0",
"eslint-config-nodemailer": "1.2.0",
"eslint-config-prettier": "9.1.0",
"c8": "10.1.3",
"eslint": "^9.37.0",
"eslint-config-prettier": "^10.1.8",
"globals": "^16.4.0",
"libbase64": "1.3.0",
"libmime": "5.3.5",
"libqp": "2.1.0",
"libmime": "5.3.7",
"libqp": "2.1.1",
"nodemailer-ntlm-auth": "1.0.4",
"prettier": "^3.6.2",
"proxy": "1.0.2",
"proxy-test-server": "1.0.0",
"smtp-server": "3.13.4"
"smtp-server": "3.14.0"
},
"engines": {
"node": ">=6.0.0"

View File

@@ -22,7 +22,8 @@
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.10.3",
"nodemailer": "^6.9.14",
"node-cron": "^4.2.1",
"nodemailer": "^7.0.9",
"pdf-parse": "^1.1.1",
"sequelize": "^6.37.3",
"sharp": "^0.33.5"
@@ -2827,6 +2828,15 @@
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz",
"integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA=="
},
"node_modules/node-cron": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
"integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
"license": "ISC",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/node-ensure": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/node-ensure/-/node-ensure-0.0.0.tgz",
@@ -2853,9 +2863,10 @@
}
},
"node_modules/nodemailer": {
"version": "6.9.14",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.14.tgz",
"integrity": "sha512-Dobp/ebDKBvz91sbtRKhcznLThrKxKt97GI2FAlAyy+fk19j73Uz3sBXolVtmcXjaorivqsbbbjDY+Jkt4/bQA==",
"version": "7.0.9",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.9.tgz",
"integrity": "sha512-9/Qm0qXIByEP8lEV2qOqcAW7bRpL8CR9jcTwk3NBnHJNmP9fIJ86g2fgmIXqHY+nj55ZEMwWqYAT2QTDpRUYiQ==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}

View File

@@ -1,7 +1,7 @@
{
"name": "backend",
"version": "1.0.0",
"main": "index.js",
"main": "server.js",
"type": "module",
"scripts": {
"postinstall": "cd ../frontend && npm install && npm run build",
@@ -26,7 +26,8 @@
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.10.3",
"nodemailer": "^6.9.14",
"node-cron": "^4.2.1",
"nodemailer": "^7.0.9",
"pdf-parse": "^1.1.1",
"sequelize": "^6.37.3",
"sharp": "^0.33.5"

View File

@@ -1,5 +1,5 @@
import express from 'express';
import { uploadCSV, getLeaguesForCurrentSeason, getMatchesForLeagues, getMatchesForLeague } from '../controllers/matchController.js';
import { uploadCSV, getLeaguesForCurrentSeason, getMatchesForLeagues, getMatchesForLeague, getLeagueTable, fetchLeagueTableFromMyTischtennis } from '../controllers/matchController.js';
import { authenticate } from '../middleware/authMiddleware.js';
import multer from 'multer';
@@ -11,6 +11,8 @@ router.post('/import', authenticate, upload.single('file'), uploadCSV);
router.get('/leagues/current/:clubId', authenticate, getLeaguesForCurrentSeason);
router.get('/leagues/:clubId/matches/:leagueId', authenticate, getMatchesForLeague);
router.get('/leagues/:clubId/matches', authenticate, getMatchesForLeagues);
router.get('/leagues/:clubId/table/:leagueId', authenticate, getLeagueTable);
router.post('/leagues/:clubId/table/:leagueId/fetch', authenticate, fetchLeagueTableFromMyTischtennis);
export default router;

View File

@@ -1,4 +1,4 @@
import { getClubMembers, getWaitingApprovals, setClubMembers, uploadMemberImage, getMemberImage, updateRatingsFromMyTischtennis } from '../controllers/memberController.js';
import { getClubMembers, getWaitingApprovals, setClubMembers, uploadMemberImage, getMemberImage, updateRatingsFromMyTischtennis, rotateMemberImage } from '../controllers/memberController.js';
import express from 'express';
import { authenticate } from '../middleware/authMiddleware.js';
import multer from 'multer';
@@ -14,5 +14,6 @@ router.get('/get/:id/:showAll', authenticate, getClubMembers);
router.post('/set/:id', authenticate, setClubMembers);
router.get('/notapproved/:id', authenticate, getWaitingApprovals);
router.post('/update-ratings/:id', authenticate, updateRatingsFromMyTischtennis);
router.post('/rotate-image/:clubId/:memberId', authenticate, rotateMemberImage);
export default router;

View File

@@ -1,5 +1,6 @@
import express from 'express';
import myTischtennisController from '../controllers/myTischtennisController.js';
import myTischtennisUrlController from '../controllers/myTischtennisUrlController.js';
import { authenticate } from '../middleware/authMiddleware.js';
const router = express.Router();
@@ -25,5 +26,26 @@ router.post('/verify', myTischtennisController.verifyLogin);
// GET /api/mytischtennis/session - Get stored session
router.get('/session', myTischtennisController.getSession);
// GET /api/mytischtennis/update-history - Get update ratings history
router.get('/update-history', myTischtennisController.getUpdateHistory);
// GET /api/mytischtennis/fetch-logs - Get fetch logs
router.get('/fetch-logs', myTischtennisController.getFetchLogs);
// GET /api/mytischtennis/latest-fetches - Get latest successful fetches
router.get('/latest-fetches', myTischtennisController.getLatestFetches);
// POST /api/mytischtennis/parse-url - Parse myTischtennis URL
router.post('/parse-url', myTischtennisUrlController.parseUrl);
// POST /api/mytischtennis/configure-team - Configure team from URL
router.post('/configure-team', myTischtennisUrlController.configureTeam);
// POST /api/mytischtennis/fetch-team-data - Manually fetch team data
router.post('/fetch-team-data', myTischtennisUrlController.fetchTeamData);
// GET /api/mytischtennis/team-url/:teamId - Get myTischtennis URL for team
router.get('/team-url/:teamId', myTischtennisUrlController.getTeamUrl);
export default router;

View File

@@ -8,7 +8,7 @@ import {
DiaryNote, DiaryTag, MemberDiaryTag, DiaryDateTag, DiaryMemberNote, DiaryMemberTag,
PredefinedActivity, PredefinedActivityImage, DiaryDateActivity, DiaryMemberActivity, Match, League, Team, ClubTeam, TeamDocument, Group,
GroupActivity, Tournament, TournamentGroup, TournamentMatch, TournamentResult,
TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember, MyTischtennis
TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember, MyTischtennis, MyTischtennisUpdateHistory, MyTischtennisFetchLog
} from './models/index.js';
import authRoutes from './routes/authRoutes.js';
import clubRoutes from './routes/clubRoutes.js';
@@ -38,6 +38,7 @@ import teamRoutes from './routes/teamRoutes.js';
import clubTeamRoutes from './routes/clubTeamRoutes.js';
import teamDocumentRoutes from './routes/teamDocumentRoutes.js';
import seasonRoutes from './routes/seasonRoutes.js';
import schedulerService from './services/schedulerService.js';
const app = express();
const port = process.env.PORT || 3000;
@@ -187,9 +188,17 @@ app.get('*', (req, res) => {
await safeSync(Accident);
await safeSync(UserToken);
await safeSync(MyTischtennis);
await safeSync(MyTischtennisUpdateHistory);
await safeSync(MyTischtennisFetchLog);
// Start scheduler service
schedulerService.start();
app.listen(port, () => {
console.log(`Server is running on http://localhost:${port}`);
console.log('Scheduler service started:');
console.log(' - Rating updates: 6:00 AM daily');
console.log(' - Match results fetch: 6:30 AM daily');
});
} catch (err) {
console.error('Unable to synchronize the database:', err);

1778
backend/server.log Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,829 @@
import myTischtennisService from './myTischtennisService.js';
import myTischtennisClient from '../clients/myTischtennisClient.js';
import MyTischtennis from '../models/MyTischtennis.js';
import ClubTeam from '../models/ClubTeam.js';
import League from '../models/League.js';
import Season from '../models/Season.js';
import Member from '../models/Member.js';
import Match from '../models/Match.js';
import Team from '../models/Team.js';
import { Op } from 'sequelize';
import { devLog } from '../utils/logger.js';
class AutoFetchMatchResultsService {
/**
* Execute automatic match results fetching for all users with enabled auto-updates
*/
async executeAutomaticFetch() {
devLog('Starting automatic match results fetch...');
try {
// Find all users with auto-updates enabled
const accounts = await MyTischtennis.findAll({
where: {
autoUpdateRatings: true, // Nutze das gleiche Flag
savePassword: true // Must have saved password
},
attributes: ['id', 'userId', 'email', 'encryptedPassword', 'accessToken', 'expiresAt', 'cookie']
});
devLog(`Found ${accounts.length} accounts with auto-updates enabled for match results`);
if (accounts.length === 0) {
devLog('No accounts found with auto-updates enabled');
return;
}
// Process each account
for (const account of accounts) {
await this.processAccount(account);
}
devLog('Automatic match results fetch completed');
} catch (error) {
console.error('Error in automatic match results fetch:', error);
}
}
/**
* Process a single account for match results fetching
*/
async processAccount(account) {
const startTime = Date.now();
let success = false;
let message = '';
let errorDetails = null;
let fetchedCount = 0;
try {
devLog(`Processing match results for account ${account.email} (User ID: ${account.userId})`);
// Check if session is still valid
if (!account.accessToken || !account.expiresAt || account.expiresAt < Date.now() / 1000) {
devLog(`Session expired for ${account.email}, attempting re-login`);
// Try to re-login with stored password
const password = account.getPassword();
if (!password) {
throw new Error('No stored password available for re-login');
}
const loginResult = await myTischtennisClient.login(account.email, password);
if (!loginResult.success) {
throw new Error(`Re-login failed: ${loginResult.error}`);
}
// Update session data
account.accessToken = loginResult.accessToken;
account.refreshToken = loginResult.refreshToken;
account.expiresAt = loginResult.expiresAt;
account.cookie = loginResult.cookie;
await account.save();
devLog(`Successfully re-logged in for ${account.email}`);
}
// Perform match results fetch
const fetchResult = await this.fetchMatchResults(account);
fetchedCount = fetchResult.fetchedCount || 0;
success = true;
message = `Successfully fetched ${fetchedCount} match results`;
devLog(`Fetched ${fetchedCount} match results for ${account.email}`);
} catch (error) {
success = false;
message = 'Match results fetch failed';
errorDetails = error.message;
console.error(`Error fetching match results for ${account.email}:`, error);
}
const executionTime = Date.now() - startTime;
// TODO: Log the attempt to a dedicated match results history table
devLog(`Match results fetch for ${account.email}: ${success ? 'SUCCESS' : 'FAILED'} (${executionTime}ms)`);
}
/**
* Fetch match results for a specific account
*/
async fetchMatchResults(account) {
devLog(`Fetching match results for ${account.email}`);
let totalFetched = 0;
try {
// Get all teams for this user's clubs that have myTischtennis IDs configured
const teams = await ClubTeam.findAll({
where: {
myTischtennisTeamId: {
[Op.ne]: null
}
},
include: [
{
model: League,
as: 'league',
where: {
myTischtennisGroupId: {
[Op.ne]: null
},
association: {
[Op.ne]: null
}
},
include: [
{
model: Season,
as: 'season'
}
]
}
]
});
devLog(`Found ${teams.length} teams with myTischtennis configuration`);
// Fetch results for each team
for (const team of teams) {
try {
const result = await this.fetchTeamResults(account, team);
totalFetched += result.fetchedCount;
} catch (error) {
console.error(`Error fetching results for team ${team.name}:`, error);
}
}
return {
success: true,
fetchedCount: totalFetched
};
} catch (error) {
console.error('Error in fetchMatchResults:', error);
throw error;
}
}
/**
* Fetch results for a specific team
*/
async fetchTeamResults(account, team) {
const league = team.league;
const season = league.season;
// Build the myTischtennis URL
// Convert full season (e.g. "2025/2026") to short format (e.g. "25/26") for API
const seasonFull = season.season; // e.g. "2025/2026"
const seasonParts = seasonFull.split('/');
const seasonShort = seasonParts.length === 2
? `${seasonParts[0].slice(-2)}/${seasonParts[1].slice(-2)}`
: seasonFull;
const seasonStr = seasonShort.replace('/', '--'); // e.g. "25/26" -> "25--26"
const teamnameEncoded = encodeURIComponent(team.name.replace(/\s/g, '_'));
devLog(`=== FETCH TEAM RESULTS ===`);
devLog(`Team name (from ClubTeam): ${team.name}`);
devLog(`Team name encoded: ${teamnameEncoded}`);
devLog(`MyTischtennis Team ID: ${team.myTischtennisTeamId}`);
let totalProcessed = 0;
try {
// 1. Fetch player statistics (Spielerbilanzen)
const playerStatsUrl = `https://www.mytischtennis.de/click-tt/${league.association}/${seasonStr}/ligen/${league.groupname}/gruppe/${league.myTischtennisGroupId}/mannschaft/${team.myTischtennisTeamId}/${teamnameEncoded}/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`;
devLog(`Fetching player stats from: ${playerStatsUrl}`);
const playerStatsResponse = await fetch(playerStatsUrl, {
headers: {
'Cookie': account.cookie || '',
'Authorization': `Bearer ${account.accessToken}`,
'Accept': 'application/json'
}
});
if (playerStatsResponse.ok) {
const playerStatsData = await playerStatsResponse.json();
// Log complete response for debugging
console.log('=== PLAYER STATS RESPONSE START ===');
console.log(JSON.stringify(playerStatsData, null, 2));
console.log('=== PLAYER STATS RESPONSE END ===');
const playerCount = await this.processTeamData(team, playerStatsData);
totalProcessed += playerCount;
devLog(`Processed ${playerCount} player statistics`);
}
// Note: Match results are already included in the player stats response above
// in tableData.meetings_excerpt.meetings, so we don't need a separate call
// Also fetch and update league table data for this team
if (account.userId) {
try {
await this.fetchAndUpdateLeagueTable(account.userId, team.leagueId);
devLog(`✓ League table updated for league ${team.leagueId}`);
} catch (error) {
console.error(`Error updating league table for league ${team.leagueId}:`, error);
// Don't fail the entire process if table update fails
}
} else {
devLog(`Skipping league table update - no userId available`);
}
return {
success: true,
fetchedCount: totalProcessed
};
} catch (error) {
console.error(`Error fetching team results for ${team.name}:`, error);
throw error;
}
}
/**
* Process and store team data from myTischtennis
*/
async processTeamData(team, data) {
// TODO: Implement data processing and storage
// This would typically involve:
// 1. Extract player statistics from data.data.balancesheet
// 2. Match players with local Member records (by player_id or name)
// 3. Update or create match statistics
// 4. Store historical data for tracking changes
devLog(`Processing data for team ${team.name}`);
if (!data.data || !data.data.balancesheet || !Array.isArray(data.data.balancesheet)) {
devLog('No balancesheet data found');
return 0;
}
let processedCount = 0;
for (const teamData of data.data.balancesheet) {
// Process single player statistics
if (teamData.single_player_statistics) {
for (const playerStat of teamData.single_player_statistics) {
devLog(`Player: ${playerStat.player_firstname} ${playerStat.player_lastname} (ID: ${playerStat.player_id})`);
devLog(` Points won: ${playerStat.points_won}, Points lost: ${playerStat.points_lost}`);
// Try to match player with local Member
const member = await this.matchPlayer(
playerStat.player_id,
playerStat.player_firstname,
playerStat.player_lastname
);
if (member) {
devLog(` Matched with local member: ${member.firstName} ${member.lastName} (ID: ${member.id})`);
// Update player statistics (TTR/QTTR would be fetched from different endpoint)
// For now, we just ensure the myTischtennis ID is stored
if (!member.myTischtennisPlayerId) {
member.myTischtennisPlayerId = playerStat.player_id;
await member.save();
devLog(` Updated myTischtennis Player ID for ${member.firstName} ${member.lastName}`);
}
} else {
devLog(` No local member found for ${playerStat.player_firstname} ${playerStat.player_lastname}`);
}
processedCount++;
}
}
// Process double player statistics
if (teamData.double_player_statistics) {
for (const doubleStat of teamData.double_player_statistics) {
devLog(`Double: ${doubleStat.firstname_player_1} ${doubleStat.lastname_player_1} / ${doubleStat.firstname_player_2} ${doubleStat.lastname_player_2}`);
devLog(` Points won: ${doubleStat.points_won}, Points lost: ${doubleStat.points_lost}`);
// TODO: Store double statistics
processedCount++;
}
}
}
// Also process meetings from the player stats response
if (data.data && data.data.balancesheet && data.data.balancesheet[0]) {
const teamData = data.data.balancesheet[0];
// Check for meetings_excerpt in the tableData section
if (data.tableData && data.tableData.meetings_excerpt && data.tableData.meetings_excerpt.meetings) {
devLog('Found meetings_excerpt in tableData, processing matches...');
const meetingsProcessed = await this.processMatchResults(team, { data: data.tableData });
devLog(`Processed ${meetingsProcessed} matches from player stats response`);
}
}
return processedCount;
}
/**
* Process match results from schedule/table data
*/
async processMatchResults(team, data) {
devLog(`Processing match results for team ${team.name}`);
// Handle different response structures from different endpoints
const meetingsExcerpt = data.data?.meetings_excerpt || data.tableData?.meetings_excerpt;
if (!meetingsExcerpt) {
devLog('No meetings_excerpt data found in response');
return 0;
}
let processedCount = 0;
// Handle both response structures:
// 1. With meetings property: meetings_excerpt.meetings (array of date objects)
// 2. Direct array: meetings_excerpt (array of date objects)
const meetings = meetingsExcerpt.meetings || meetingsExcerpt;
if (!Array.isArray(meetings) || meetings.length === 0) {
devLog('No meetings array found or empty');
return 0;
}
devLog(`Found ${meetings.length} items in meetings array`);
// Check if meetings is an array of date objects or an array of match objects
const firstItem = meetings[0];
const isDateGrouped = firstItem && typeof firstItem === 'object' && !firstItem.meeting_id;
if (isDateGrouped) {
// Format 1: Array of date objects (Spielerbilanzen, Spielplan)
devLog('Processing date-grouped meetings...');
for (const dateGroup of meetings) {
for (const [date, matchList] of Object.entries(dateGroup)) {
for (const match of matchList) {
devLog(`Match: ${match.team_home} vs ${match.team_away}`);
devLog(` Date: ${match.date}`);
devLog(` Status: ${match.state} (${match.is_meeting_complete ? 'complete' : 'incomplete'})`);
devLog(` Result: ${match.matches_won}:${match.matches_lost}`);
devLog(` Meeting ID: ${match.meeting_id}`);
try {
await this.storeMatchResult(team, match, false);
processedCount++;
} catch (error) {
console.error(`Error storing match result for meeting ${match.meeting_id}:`, error);
}
}
}
}
} else {
// Format 2: Flat array of match objects (Tabelle)
devLog('Processing flat meetings array...');
for (const match of meetings) {
devLog(`Match: ${match.team_home} vs ${match.team_away}`);
devLog(` Date: ${match.date}`);
devLog(` Status: ${match.state} (${match.is_meeting_complete ? 'complete' : 'incomplete'})`);
devLog(` Result: ${match.matches_won}:${match.matches_lost}`);
devLog(` Meeting ID: ${match.meeting_id}`);
try {
await this.storeMatchResult(team, match, false);
processedCount++;
} catch (error) {
console.error(`Error storing match result for meeting ${match.meeting_id}:`, error);
}
}
}
devLog(`Processed ${processedCount} matches in league ${team.leagueId}`);
return processedCount;
}
/**
* Store or update match result in database
*/
async storeMatchResult(ourClubTeam, matchData, isHomeTeam) {
// Parse match points from myTischtennis data
// matchData.matches_won/lost are ALWAYS from the perspective of team_home in myTischtennis
// So we need to assign them correctly based on whether WE are home or guest
const mtHomePoints = parseInt(matchData.matches_won) || 0;
const mtGuestPoints = parseInt(matchData.matches_lost) || 0;
// If matchData has team_home and team_away, we can determine our role
// But isHomeTeam parameter tells us if WE (ourClubTeam) are playing at home
const homeMatchPoints = mtHomePoints;
const guestMatchPoints = mtGuestPoints;
devLog(`Match points from myTischtennis: ${mtHomePoints}:${mtGuestPoints} (from team_home perspective)`);
// Find existing match by meeting ID OR by date and team names
devLog(`Searching for existing match with meeting ID: ${matchData.meeting_id}`);
let match = await Match.findOne({
where: { myTischtennisMeetingId: matchData.meeting_id }
});
if (match) {
devLog(`Found match by meeting ID: ${match.id}`);
}
// If not found by meeting ID, try to find by date and teams
if (!match) {
devLog(`No match found by meeting ID, searching by date and teams...`);
const matchDate = new Date(matchData.date);
const startOfDay = new Date(matchDate.setHours(0, 0, 0, 0));
const endOfDay = new Date(matchDate.setHours(23, 59, 59, 999));
devLog(`Searching matches on ${matchData.date} in league ${ourClubTeam.leagueId}`);
const potentialMatches = await Match.findAll({
where: {
date: {
[Op.between]: [startOfDay, endOfDay]
},
leagueId: ourClubTeam.leagueId
},
include: [
{ model: Team, as: 'homeTeam' },
{ model: Team, as: 'guestTeam' }
]
});
devLog(`Found ${potentialMatches.length} potential matches on this date`);
// Find by team names
for (const m of potentialMatches) {
devLog(` Checking match ${m.id}: ${m.homeTeam?.name} vs ${m.guestTeam?.name}`);
devLog(` Against: ${matchData.team_home} vs ${matchData.team_away}`);
const homeNameMatch = m.homeTeam?.name === matchData.team_home ||
m.homeTeam?.name.includes(matchData.team_home) ||
matchData.team_home.includes(m.homeTeam?.name);
const guestNameMatch = m.guestTeam?.name === matchData.team_away ||
m.guestTeam?.name.includes(matchData.team_away) ||
matchData.team_away.includes(m.guestTeam?.name);
devLog(` Home match: ${homeNameMatch}, Guest match: ${guestNameMatch}`);
if (homeNameMatch && guestNameMatch) {
match = m;
devLog(` ✓ Found existing match by date and teams: ${match.id}`);
break;
}
}
if (!match) {
devLog(`No existing match found, will create new one`);
}
}
if (match) {
// Update existing match
// IMPORTANT: Check if the teams are in the same order as in myTischtennis
// Load the match with team associations to compare
const matchWithTeams = await Match.findByPk(match.id, {
include: [
{ model: Team, as: 'homeTeam' },
{ model: Team, as: 'guestTeam' }
]
});
// Compare team names to determine if we need to swap points
const dbHomeTeamName = matchWithTeams.homeTeam?.name || '';
const dbGuestTeamName = matchWithTeams.guestTeam?.name || '';
const mtHomeTeamName = matchData.team_home;
const mtGuestTeamName = matchData.team_away;
// Check if teams are in the same order
const teamsMatch = (
dbHomeTeamName === mtHomeTeamName ||
dbHomeTeamName.includes(mtHomeTeamName) ||
mtHomeTeamName.includes(dbHomeTeamName)
);
let finalHomePoints, finalGuestPoints;
if (teamsMatch) {
// Teams are in same order
finalHomePoints = homeMatchPoints;
finalGuestPoints = guestMatchPoints;
devLog(`Teams in same order: ${dbHomeTeamName} = ${mtHomeTeamName}`);
} else {
// Teams are swapped - need to swap points!
finalHomePoints = guestMatchPoints;
finalGuestPoints = homeMatchPoints;
devLog(`Teams are SWAPPED! DB: ${dbHomeTeamName} vs ${dbGuestTeamName}, MyTT: ${mtHomeTeamName} vs ${mtGuestTeamName}`);
devLog(`Swapping points: ${homeMatchPoints}:${guestMatchPoints}${finalHomePoints}:${finalGuestPoints}`);
}
const updateData = {
homeMatchPoints: finalHomePoints,
guestMatchPoints: finalGuestPoints,
isCompleted: matchData.is_meeting_complete,
pdfUrl: matchData.pdf_url,
myTischtennisMeetingId: matchData.meeting_id // Store meeting ID for future updates
};
await match.update(updateData);
devLog(`Updated existing match ${match.id} (Meeting ${matchData.meeting_id}): ${finalHomePoints}:${finalGuestPoints} (${matchData.is_meeting_complete ? 'complete' : 'incomplete'})`);
} else {
// Create new match
devLog(`Creating new match for meeting ${matchData.meeting_id}`);
try {
// Find or create home and guest teams based on myTischtennis team IDs
const homeTeam = await this.findOrCreateTeam(
matchData.team_home,
matchData.team_home_id,
ourClubTeam
);
const guestTeam = await this.findOrCreateTeam(
matchData.team_away,
matchData.team_away_id,
ourClubTeam
);
// Extract time from date
const matchDate = new Date(matchData.date);
const time = `${String(matchDate.getHours()).padStart(2, '0')}:${String(matchDate.getMinutes()).padStart(2, '0')}:00`;
// Create match (points are already correctly set from matchData)
match = await Match.create({
date: matchData.date,
time: time,
locationId: null, // Location is not provided by myTischtennis
homeTeamId: homeTeam.id,
guestTeamId: guestTeam.id,
leagueId: ourClubTeam.leagueId,
clubId: ourClubTeam.clubId,
myTischtennisMeetingId: matchData.meeting_id,
homeMatchPoints: homeMatchPoints,
guestMatchPoints: guestMatchPoints,
isCompleted: matchData.is_meeting_complete,
pdfUrl: matchData.pdf_url
});
devLog(`Created new match ${match.id}: ${matchData.team_home} vs ${matchData.team_away} (${homeMatchPoints}:${guestMatchPoints}, ${matchData.is_meeting_complete ? 'complete' : 'incomplete'})`);
} catch (error) {
console.error(`Error creating match for meeting ${matchData.meeting_id}:`, error);
devLog(` Home: ${matchData.team_home} (myTT ID: ${matchData.team_home_id})`);
devLog(` Guest: ${matchData.team_away} (myTT ID: ${matchData.team_away_id})`);
}
}
return match;
}
/**
* Find or create a Team in the team table
* All teams (own and opponents) are stored in the team table
*/
async findOrCreateTeam(teamName, myTischtennisTeamId, ourClubTeam) {
devLog(`Finding team: ${teamName} (myTT ID: ${myTischtennisTeamId})`);
// Search in team table for all teams in this league
const allTeamsInLeague = await Team.findAll({
where: {
leagueId: ourClubTeam.leagueId,
seasonId: ourClubTeam.seasonId
}
});
devLog(` Searching in ${allTeamsInLeague.length} teams in league ${ourClubTeam.leagueId}`);
// Try exact match first
let team = allTeamsInLeague.find(t => t.name === teamName);
if (team) {
devLog(` ✓ Found team by exact name: ${team.name} (ID: ${team.id})`);
return team;
}
// If not found, try fuzzy match
team = allTeamsInLeague.find(t =>
t.name.includes(teamName) ||
teamName.includes(t.name)
);
if (team) {
devLog(` ✓ Found team by fuzzy match: ${team.name} (ID: ${team.id})`);
return team;
}
// Team not found - create it
team = await Team.create({
name: teamName,
clubId: ourClubTeam.clubId,
leagueId: ourClubTeam.leagueId,
seasonId: ourClubTeam.seasonId
});
devLog(` ✓ Created new team: ${team.name} (ID: ${team.id})`);
return team;
}
/**
* Match a myTischtennis player with a local Member
*/
async matchPlayer(playerId, firstName, lastName) {
// First, try to find by myTischtennis Player ID
if (playerId) {
const member = await Member.findOne({
where: { myTischtennisPlayerId: playerId }
});
if (member) {
return member;
}
}
// If not found, try to match by name (fuzzy matching)
// Note: Since names are encrypted, we need to get all members and decrypt
// This is not efficient for large databases, but works for now
const allMembers = await Member.findAll();
for (const member of allMembers) {
const memberFirstName = member.firstName?.toLowerCase().trim();
const memberLastName = member.lastName?.toLowerCase().trim();
const searchFirstName = firstName?.toLowerCase().trim();
const searchLastName = lastName?.toLowerCase().trim();
if (memberFirstName === searchFirstName && memberLastName === searchLastName) {
return member;
}
}
return null;
}
/**
* Get all accounts with auto-fetch enabled (for manual execution)
*/
async getAutoFetchAccounts() {
return await MyTischtennis.findAll({
where: {
autoUpdateRatings: true
},
attributes: ['userId', 'email', 'autoUpdateRatings']
});
}
/**
* Fetch and update league table data from MyTischtennis
* @param {number} userId - User ID
* @param {number} leagueId - League ID
*/
async fetchAndUpdateLeagueTable(userId, leagueId) {
try {
devLog(`Fetching league table for user ${userId}, league ${leagueId}`);
// Get user's MyTischtennis account
const myTischtennisAccount = await MyTischtennis.findOne({
where: { userId }
});
if (!myTischtennisAccount) {
throw new Error('MyTischtennis account not found');
}
// Get league info
const league = await League.findByPk(leagueId, {
include: [{ model: Season, as: 'season' }]
});
if (!league) {
throw new Error('League not found');
}
// Login to MyTischtennis if needed
let session = await myTischtennisService.getSession(userId);
if (!session || !session.isValid) {
if (!myTischtennisAccount.savePassword) {
throw new Error('MyTischtennis account not connected or session expired');
}
devLog('Session expired, re-logging in...');
await myTischtennisService.verifyLogin(userId);
session = await myTischtennisService.getSession(userId);
}
// Convert full season (e.g. "2025/2026") to short format (e.g. "25/26") and then to URL format (e.g. "25--26")
const seasonFull = league.season.season; // e.g. "2025/2026"
const seasonParts = seasonFull.split('/');
const seasonShort = seasonParts.length === 2
? `${seasonParts[0].slice(-2)}/${seasonParts[1].slice(-2)}`
: seasonFull;
const seasonStr = seasonShort.replace('/', '--'); // e.g. "25/26" -> "25--26"
// Fetch table data from MyTischtennis
const tableUrl = `https://www.mytischtennis.de/click-tt/${league.association}/${seasonStr}/ligen/${league.groupname}/gruppe/${league.myTischtennisGroupId}/tabelle/gesamt?_data=routes%2Fclick-tt%2B%2F%24association%2B%2F%24season%2B%2F%24type%2B%2F%24groupname.gruppe.%24urlid%2B%2Ftabelle.%24filter`;
console.log(`[fetchAndUpdateLeagueTable] Fetching table from URL: ${tableUrl}`);
const response = await myTischtennisClient.authenticatedRequest(tableUrl, session.cookie, {
method: 'GET'
});
if (!response.success) {
throw new Error(`Failed to fetch table data: ${response.error}`);
}
const tableData = await this.parseTableData(JSON.stringify(response.data), leagueId);
// Update teams with table data
await this.updateTeamsWithTableData(tableData, leagueId);
devLog(`✓ Updated league table for league ${leagueId} with ${tableData.length} teams`);
} catch (error) {
console.error(`Error fetching league table for league ${leagueId}:`, error);
throw error;
}
}
/**
* Parse table data from MyTischtennis response
* @param {string} jsonResponse - JSON response from MyTischtennis
* @param {number} leagueId - League ID
* @returns {Array} Parsed table data
*/
async parseTableData(jsonResponse, leagueId) {
devLog('Parsing table data from MyTischtennis response...');
try {
const data = JSON.parse(jsonResponse);
if (!data.data || !data.data.league_table) {
devLog('No league table data found in response');
return [];
}
const leagueTable = data.data.league_table;
const parsedData = [];
for (const teamData of leagueTable) {
parsedData.push({
teamName: teamData.team_name,
matchesPlayed: teamData.matches_won + teamData.matches_lost,
matchesWon: teamData.matches_won,
matchesLost: teamData.matches_lost,
matchesTied: teamData.meetings_tie || 0, // Unentschiedene Begegnungen
setsWon: teamData.sets_won,
setsLost: teamData.sets_lost,
pointsWon: teamData.games_won, // MyTischtennis uses "games" for Ballpunkte
pointsLost: teamData.games_lost,
tablePointsWon: teamData.points_won, // Liga-Tabellenpunkte gewonnen
tablePointsLost: teamData.points_lost, // Liga-Tabellenpunkte verloren
tableRank: teamData.table_rank
});
}
devLog(`Parsed ${parsedData.length} teams from MyTischtennis table data`);
return parsedData;
} catch (error) {
console.error('Error parsing MyTischtennis table data:', error);
return [];
}
}
/**
* Update teams with table data
* @param {Array} tableData - Parsed table data
* @param {number} leagueId - League ID
*/
async updateTeamsWithTableData(tableData, leagueId) {
for (const teamData of tableData) {
try {
// Find team by name in this league
const team = await Team.findOne({
where: {
leagueId: leagueId,
name: { [Op.like]: `%${teamData.teamName}%` }
}
});
if (team) {
await team.update({
matchesPlayed: teamData.matchesPlayed || 0,
matchesWon: teamData.matchesWon || 0,
matchesLost: teamData.matchesLost || 0,
matchesTied: teamData.matchesTied || 0,
setsWon: teamData.setsWon || 0,
setsLost: teamData.setsLost || 0,
pointsWon: teamData.pointsWon || 0,
pointsLost: teamData.pointsLost || 0,
tablePoints: (teamData.tablePointsWon || 0), // Legacy field (keep for compatibility)
tablePointsWon: teamData.tablePointsWon || 0,
tablePointsLost: teamData.tablePointsLost || 0
});
devLog(` ✓ Updated team ${team.name} with table data`);
} else {
devLog(` ⚠ Team not found: ${teamData.teamName}`);
}
} catch (error) {
console.error(`Error updating team ${teamData.teamName}:`, error);
}
}
}
}
export default new AutoFetchMatchResultsService();

View File

@@ -0,0 +1,141 @@
import myTischtennisService from './myTischtennisService.js';
import myTischtennisClient from '../clients/myTischtennisClient.js';
import MyTischtennis from '../models/MyTischtennis.js';
import { devLog } from '../utils/logger.js';
class AutoUpdateRatingsService {
/**
* Execute automatic rating updates for all users with enabled auto-updates
*/
async executeAutomaticUpdates() {
devLog('Starting automatic rating updates...');
try {
// Find all users with auto-updates enabled
const accounts = await MyTischtennis.findAll({
where: {
autoUpdateRatings: true,
savePassword: true // Must have saved password
},
attributes: ['id', 'userId', 'email', 'encryptedPassword', 'accessToken', 'expiresAt', 'cookie']
});
devLog(`Found ${accounts.length} accounts with auto-updates enabled`);
if (accounts.length === 0) {
devLog('No accounts found with auto-updates enabled');
return;
}
// Process each account
for (const account of accounts) {
await this.processAccount(account);
}
devLog('Automatic rating updates completed');
} catch (error) {
console.error('Error in automatic rating updates:', error);
}
}
/**
* Process a single account for rating updates
*/
async processAccount(account) {
const startTime = Date.now();
let success = false;
let message = '';
let errorDetails = null;
let updatedCount = 0;
try {
devLog(`Processing account ${account.email} (User ID: ${account.userId})`);
// Check if session is still valid
if (!account.accessToken || !account.expiresAt || account.expiresAt < Date.now() / 1000) {
devLog(`Session expired for ${account.email}, attempting re-login`);
// Try to re-login with stored password
const password = account.getPassword();
if (!password) {
throw new Error('No stored password available for re-login');
}
const loginResult = await myTischtennisClient.login(account.email, password);
if (!loginResult.success) {
throw new Error(`Re-login failed: ${loginResult.error}`);
}
// Update session data
account.accessToken = loginResult.accessToken;
account.refreshToken = loginResult.refreshToken;
account.expiresAt = loginResult.expiresAt;
account.cookie = loginResult.cookie;
await account.save();
devLog(`Successfully re-logged in for ${account.email}`);
}
// Perform rating update
const updateResult = await this.updateRatings(account);
updatedCount = updateResult.updatedCount || 0;
success = true;
message = `Successfully updated ${updatedCount} ratings`;
devLog(`Updated ${updatedCount} ratings for ${account.email}`);
} catch (error) {
success = false;
message = 'Update failed';
errorDetails = error.message;
console.error(`Error updating ratings for ${account.email}:`, error);
}
const executionTime = Date.now() - startTime;
// Log the attempt
await myTischtennisService.logUpdateAttempt(
account.userId,
success,
message,
errorDetails,
updatedCount,
executionTime
);
}
/**
* Update ratings for a specific account
*/
async updateRatings(account) {
// TODO: Implement actual rating update logic
// This would typically involve:
// 1. Fetching current ratings from myTischtennis
// 2. Comparing with local data
// 3. Updating local member ratings
devLog(`Updating ratings for ${account.email}`);
// For now, simulate an update
await new Promise(resolve => setTimeout(resolve, 1000));
return {
success: true,
updatedCount: Math.floor(Math.random() * 10) // Simulate some updates
};
}
/**
* Get all accounts with auto-updates enabled (for manual execution)
*/
async getAutoUpdateAccounts() {
return await MyTischtennis.findAll({
where: {
autoUpdateRatings: true
},
attributes: ['userId', 'email', 'autoUpdateRatings', 'lastUpdateRatings']
});
}
}
export default new AutoUpdateRatingsService();

View File

@@ -35,6 +35,7 @@ class ClubTeamService {
clubId: clubTeam.clubId,
leagueId: clubTeam.leagueId,
seasonId: clubTeam.seasonId,
myTischtennisTeamId: clubTeam.myTischtennisTeamId,
createdAt: clubTeam.createdAt,
updatedAt: clubTeam.updatedAt,
league: { name: 'Unbekannt' },
@@ -43,7 +44,9 @@ class ClubTeamService {
// Lade Liga-Daten
if (clubTeam.leagueId) {
const league = await League.findByPk(clubTeam.leagueId, { attributes: ['name'] });
const league = await League.findByPk(clubTeam.leagueId, {
attributes: ['id', 'name', 'myTischtennisGroupId', 'association', 'groupname']
});
if (league) enrichedTeam.league = league;
}

View File

@@ -7,6 +7,8 @@ import Season from '../models/Season.js';
import Location from '../models/Location.js';
import League from '../models/League.js';
import Team from '../models/Team.js';
import ClubTeam from '../models/ClubTeam.js';
import Club from '../models/Club.js';
import SeasonService from './seasonService.js';
import { checkAccess } from '../utils/userUtils.js';
import { Op } from 'sequelize';
@@ -14,6 +16,46 @@ import { Op } from 'sequelize';
import { devLog } from '../utils/logger.js';
class MatchService {
/**
* Format team name with age class suffix
* @param {string} teamName - Base team name (e.g. "Harheimer TC")
* @param {string} ageClass - Age class (e.g. "Jugend 11", "Senioren", "Frauen", "Erwachsene")
* @returns {string} Formatted team name (e.g. "Harheimer TC (J11)")
*/
formatTeamNameWithAgeClass(teamName, ageClass) {
if (!ageClass || ageClass.trim() === '' || ageClass === 'Erwachsene') {
return teamName;
}
// Parse age class
const ageClassLower = ageClass.toLowerCase().trim();
// Senioren = S
if (ageClassLower.includes('senioren')) {
return `${teamName} (S)`;
}
// Frauen = F
if (ageClassLower.includes('frauen')) {
return `${teamName} (F)`;
}
// Jugend XX = JXX
const jugendMatch = ageClass.match(/jugend\s+(\d+)/i);
if (jugendMatch) {
return `${teamName} (J${jugendMatch[1]})`;
}
// Mädchen XX = MXX
const maedchenMatch = ageClass.match(/m[aä]dchen\s+(\d+)/i);
if (maedchenMatch) {
return `${teamName} (M${maedchenMatch[1]})`;
}
// Default: return as is
return teamName;
}
generateSeasonString(date = new Date()) {
const currentYear = date.getFullYear();
let seasonStartYear;
@@ -47,8 +89,20 @@ class MatchService {
seasonId: season.id,
},
});
const homeTeamId = await this.getOrCreateTeamId(row['HeimMannschaft'], clubId);
const guestTeamId = await this.getOrCreateTeamId(row['GastMannschaft'], clubId);
const homeTeamId = await this.getOrCreateTeamId(
row['HeimMannschaft'],
row['HeimMannschaftAltersklasse'],
clubId,
league.id,
season.id
);
const guestTeamId = await this.getOrCreateTeamId(
row['GastMannschaft'],
row['GastMannschaftAltersklasse'],
clubId,
league.id,
season.id
);
const [location] = await Location.findOrCreate({
where: {
name: row['HalleName'],
@@ -90,15 +144,24 @@ class MatchService {
}
}
async getOrCreateTeamId(teamName, clubId) {
async getOrCreateTeamId(teamName, ageClass, clubId, leagueId, seasonId) {
// Format team name with age class
const formattedTeamName = this.formatTeamNameWithAgeClass(teamName, ageClass);
devLog(`Team: "${teamName}" + "${ageClass}" -> "${formattedTeamName}"`);
const [team] = await Team.findOrCreate({
where: {
name: teamName,
clubId: clubId
name: formattedTeamName,
clubId: clubId,
leagueId: leagueId,
seasonId: seasonId
},
defaults: {
name: teamName,
clubId: clubId
name: formattedTeamName,
clubId: clubId,
leagueId: leagueId,
seasonId: seasonId
}
});
return team.id;
@@ -174,6 +237,10 @@ class MatchService {
code: match.code,
homePin: match.homePin,
guestPin: match.guestPin,
homeMatchPoints: match.homeMatchPoints || 0,
guestMatchPoints: match.guestMatchPoints || 0,
isCompleted: match.isCompleted || false,
pdfUrl: match.pdfUrl,
homeTeam: { name: 'Unbekannt' },
guestTeam: { name: 'Unbekannt' },
location: { name: 'Unbekannt', address: '', city: '', zip: '' },
@@ -213,13 +280,61 @@ class MatchService {
if (!season) {
throw new Error('Season not found');
}
const matches = await Match.findAll({
// Get club name from database
const club = await Club.findByPk(clubId, { attributes: ['name'] });
if (!club) {
throw new Error('Club not found');
}
const clubName = club.name;
devLog(`Filtering matches for club: ${clubName}`);
// Find all club teams in this league
const clubTeams = await ClubTeam.findAll({
where: {
clubId: clubId,
leagueId: leagueId
}
},
attributes: ['id', 'name']
});
devLog(`Club teams in league ${leagueId}: ${clubTeams.map(ct => ct.name).join(', ')}`);
// Find all Team entries that contain our club name
const ownTeams = await Team.findAll({
where: {
name: {
[Op.like]: `${clubName}%`
},
leagueId: leagueId
},
attributes: ['id', 'name']
});
const ownTeamIds = ownTeams.map(t => t.id);
devLog(`Own team IDs in this league: ${ownTeamIds.join(', ')} (${ownTeams.map(t => t.name).join(', ')})`);
// Load matches
let matches;
if (ownTeamIds.length > 0) {
// Load only matches where one of our teams is involved
matches = await Match.findAll({
where: {
leagueId: leagueId,
[Op.or]: [
{ homeTeamId: { [Op.in]: ownTeamIds } },
{ guestTeamId: { [Op.in]: ownTeamIds } }
]
}
});
devLog(`Found ${matches.length} matches for our teams`);
} else {
// No own teams found - show nothing
devLog('No own teams found in this league, showing no matches');
matches = [];
}
// Lade Team- und Location-Daten manuell
const enrichedMatches = [];
for (const match of matches) {
@@ -234,6 +349,10 @@ class MatchService {
code: match.code,
homePin: match.homePin,
guestPin: match.guestPin,
homeMatchPoints: match.homeMatchPoints || 0,
guestMatchPoints: match.guestMatchPoints || 0,
isCompleted: match.isCompleted || false,
pdfUrl: match.pdfUrl,
homeTeam: { name: 'Unbekannt' },
guestTeam: { name: 'Unbekannt' },
location: { name: 'Unbekannt', address: '', city: '', zip: '' },
@@ -264,6 +383,53 @@ class MatchService {
return enrichedMatches;
}
/**
* Get league table for a specific league
* @param {string} userToken - User authentication token
* @param {string} clubId - Club ID
* @param {string} leagueId - League ID
* @returns {Array} League table data
*/
async getLeagueTable(userToken, clubId, leagueId) {
await checkAccess(userToken, clubId);
try {
// Get all teams in this league
const teams = await Team.findAll({
where: {
leagueId: leagueId
},
attributes: [
'id', 'name', 'matchesPlayed', 'matchesWon', 'matchesLost', 'matchesTied',
'setsWon', 'setsLost', 'pointsWon', 'pointsLost', 'tablePoints', 'tablePointsWon', 'tablePointsLost'
],
order: [
['tablePointsWon', 'DESC'], // Highest table points first
['matchesWon', 'DESC'], // Then by matches won
['setsWon', 'DESC'] // Then by sets won
]
});
// Format table data
const tableData = teams.map(team => {
return {
teamId: team.id,
teamName: team.name,
setsWon: team.setsWon,
setsLost: team.setsLost,
matchPoints: team.matchesWon + ':' + team.matchesLost,
tablePoints: team.tablePointsWon + ':' + team.tablePointsLost, // Tabellenpunkte (points_won:points_lost)
pointRatio: team.pointsWon + ':' + team.pointsLost // Ballpunkte (games_won:games_lost)
};
});
return tableData;
} catch (error) {
console.error('Error getting league table:', error);
throw new Error('Failed to get league table');
}
}
}
export default new MatchService();

View File

@@ -145,13 +145,45 @@ class MemberService {
await checkAccess(userToken, clubId);
const user = await getUserByToken(userToken);
const startTime = Date.now();
const myTischtennisService = (await import('./myTischtennisService.js')).default;
const myTischtennisClient = (await import('../clients/myTischtennisClient.js')).default;
const fetchLogService = (await import('./myTischtennisFetchLogService.js')).default;
try {
// 1. myTischtennis-Session abrufen
const session = await myTischtennisService.getSession(user.id);
// 1. myTischtennis-Session abrufen oder Login durchführen
let session;
try {
session = await myTischtennisService.getSession(user.id);
} catch (sessionError) {
console.log('[updateRatingsFromMyTischtennis] - Session invalid, attempting login...', sessionError.message);
// Versuche automatischen Login mit gespeicherten Credentials
try {
await myTischtennisService.verifyLogin(user.id);
const freshSession = await myTischtennisService.getSession(user.id);
session = {
cookie: freshSession.cookie,
accessToken: freshSession.accessToken,
refreshToken: freshSession.refreshToken,
expiresAt: freshSession.expiresAt,
userData: freshSession.userData
};
console.log('[updateRatingsFromMyTischtennis] - Automatic login successful');
} catch (loginError) {
console.error('[updateRatingsFromMyTischtennis] - Automatic login failed:', loginError.message);
return {
status: 401,
response: {
message: 'Session abgelaufen und automatischer Login fehlgeschlagen. Bitte einmal in myTischtennis einloggen.',
updated: 0,
errors: [loginError.message],
needsReauth: true
}
};
}
}
const account = await myTischtennisService.getAccount(user.id);
@@ -266,6 +298,19 @@ class MemberService {
message += ` ${errors.length} Fehler beim Speichern.`;
}
// Log successful ratings fetch
await fetchLogService.logFetch(
user.id,
'ratings',
true,
message,
{
recordsProcessed: updated,
executionTime: Date.now() - startTime,
isAutomatic: false
}
);
return {
status: 200,
response: {
@@ -280,6 +325,20 @@ class MemberService {
};
} catch (error) {
console.error('[updateRatingsFromMyTischtennis] - Error:', error);
// Log failed ratings fetch
await fetchLogService.logFetch(
user.id,
'ratings',
false,
'Fehler beim Aktualisieren der Wertungen',
{
errorDetails: error.message,
executionTime: Date.now() - startTime,
isAutomatic: false
}
);
return {
status: 500,
response: {
@@ -290,6 +349,56 @@ class MemberService {
};
}
}
async rotateMemberImage(userToken, clubId, memberId, direction) {
try {
await checkAccess(userToken, clubId);
const member = await Member.findOne({ where: { id: memberId, clubId: clubId } });
if (!member) {
return { status: 404, response: { success: false, error: 'Member not found in this club' } };
}
const imagePath = path.join('images', 'members', `${memberId}.jpg`);
if (!fs.existsSync(imagePath)) {
return { status: 404, response: { success: false, error: 'Image not found' } };
}
// Read the image
const imageBuffer = await fs.promises.readFile(imagePath);
// Calculate rotation angle (-90 for left, +90 for right)
const rotationAngle = direction === 'left' ? -90 : 90;
// Rotate the image
const rotatedBuffer = await sharp(imageBuffer)
.rotate(rotationAngle)
.jpeg({ quality: 80 })
.toBuffer();
// Save the rotated image
await fs.promises.writeFile(imagePath, rotatedBuffer);
return {
status: 200,
response: {
success: true,
message: `Bild um ${rotationAngle}° gedreht`,
direction: direction,
rotation: rotationAngle
}
};
} catch (error) {
console.error('[rotateMemberImage] - Error:', error);
return {
status: 500,
response: {
success: false,
error: 'Fehler beim Drehen des Bildes: ' + error.message
}
};
}
}
}
export default new MemberService();

View File

@@ -0,0 +1,129 @@
import MyTischtennisFetchLog from '../models/MyTischtennisFetchLog.js';
import { devLog } from '../utils/logger.js';
import { Op } from 'sequelize';
import sequelize from '../database.js';
class MyTischtennisFetchLogService {
/**
* Log a fetch attempt
*/
async logFetch(userId, fetchType, success, message, options = {}) {
try {
await MyTischtennisFetchLog.create({
userId,
fetchType,
success,
message,
errorDetails: options.errorDetails || null,
recordsProcessed: options.recordsProcessed || 0,
executionTime: options.executionTime || null,
isAutomatic: options.isAutomatic || false
});
devLog(`[FetchLog] ${fetchType} - ${success ? 'SUCCESS' : 'FAILED'} - User ${userId}`);
} catch (error) {
console.error('Error logging fetch:', error);
// Don't throw - logging failures shouldn't break the main operation
}
}
/**
* Get fetch logs for a user
*/
async getFetchLogs(userId, options = {}) {
try {
const where = { userId };
if (options.fetchType) {
where.fetchType = options.fetchType;
}
if (options.success !== undefined) {
where.success = options.success;
}
const logs = await MyTischtennisFetchLog.findAll({
where,
order: [['createdAt', 'DESC']],
limit: options.limit || 50,
attributes: [
'id', 'fetchType', 'success', 'message', 'errorDetails',
'recordsProcessed', 'executionTime', 'isAutomatic', 'createdAt'
]
});
return logs;
} catch (error) {
console.error('Error getting fetch logs:', error);
throw error;
}
}
/**
* Get latest successful fetch for each type
*/
async getLatestSuccessfulFetches(userId) {
try {
const fetchTypes = ['ratings', 'match_results', 'league_table'];
const results = {};
for (const fetchType of fetchTypes) {
const latestFetch = await MyTischtennisFetchLog.findOne({
where: {
userId,
fetchType,
success: true
},
order: [['createdAt', 'DESC']],
attributes: ['createdAt', 'recordsProcessed', 'executionTime']
});
results[fetchType] = latestFetch ? {
lastFetch: latestFetch.createdAt,
recordsProcessed: latestFetch.recordsProcessed,
executionTime: latestFetch.executionTime
} : null;
}
return results;
} catch (error) {
console.error('Error getting latest successful fetches:', error);
throw error;
}
}
/**
* Get fetch statistics
*/
async getFetchStatistics(userId, days = 30) {
try {
const since = new Date();
since.setDate(since.getDate() - days);
const stats = await MyTischtennisFetchLog.findAll({
where: {
userId,
createdAt: {
[Op.gte]: since
}
},
attributes: [
'fetchType',
[sequelize.fn('COUNT', sequelize.col('id')), 'totalFetches'],
[sequelize.fn('SUM', sequelize.literal('CASE WHEN success = true THEN 1 ELSE 0 END')), 'successfulFetches'],
[sequelize.fn('SUM', sequelize.col('records_processed')), 'totalRecordsProcessed'],
[sequelize.fn('AVG', sequelize.col('execution_time')), 'avgExecutionTime']
],
group: ['fetchType']
});
return stats;
} catch (error) {
console.error('Error getting fetch statistics:', error);
throw error;
}
}
}
export default new MyTischtennisFetchLogService();

View File

@@ -1,4 +1,5 @@
import MyTischtennis from '../models/MyTischtennis.js';
import MyTischtennisUpdateHistory from '../models/MyTischtennisUpdateHistory.js';
import User from '../models/User.js';
import myTischtennisClient from '../clients/myTischtennisClient.js';
import HttpError from '../exceptions/HttpError.js';
@@ -11,7 +12,7 @@ class MyTischtennisService {
async getAccount(userId) {
const account = await MyTischtennis.findOne({
where: { userId },
attributes: ['id', 'email', 'savePassword', 'lastLoginAttempt', 'lastLoginSuccess', 'expiresAt', 'userData', 'clubId', 'clubName', 'fedNickname', 'createdAt', 'updatedAt']
attributes: ['id', 'userId', 'email', 'savePassword', 'autoUpdateRatings', 'lastLoginAttempt', 'lastLoginSuccess', 'lastUpdateRatings', 'expiresAt', 'userData', 'clubId', 'clubName', 'fedNickname', 'createdAt', 'updatedAt']
});
return account;
}
@@ -19,7 +20,7 @@ class MyTischtennisService {
/**
* Create or update myTischtennis account
*/
async upsertAccount(userId, email, password, savePassword, userPassword) {
async upsertAccount(userId, email, password, savePassword, autoUpdateRatings, userPassword) {
// Verify user's app password
const user = await User.findByPk(userId);
if (!user) {
@@ -51,6 +52,7 @@ class MyTischtennisService {
// Update existing
account.email = email;
account.savePassword = savePassword;
account.autoUpdateRatings = autoUpdateRatings;
if (password && savePassword) {
account.setPassword(password);
@@ -88,6 +90,7 @@ class MyTischtennisService {
userId,
email,
savePassword,
autoUpdateRatings,
lastLoginAttempt: password ? now : null,
lastLoginSuccess: loginResult?.success ? now : null
};
@@ -119,8 +122,10 @@ class MyTischtennisService {
id: account.id,
email: account.email,
savePassword: account.savePassword,
autoUpdateRatings: account.autoUpdateRatings,
lastLoginAttempt: account.lastLoginAttempt,
lastLoginSuccess: account.lastLoginSuccess,
lastUpdateRatings: account.lastUpdateRatings,
expiresAt: account.expiresAt
};
}
@@ -235,6 +240,53 @@ class MyTischtennisService {
userData: account.userData
};
}
/**
* Get update ratings history for user
*/
async getUpdateHistory(userId) {
const history = await MyTischtennisUpdateHistory.findAll({
where: { userId },
order: [['createdAt', 'DESC']],
limit: 50 // Letzte 50 Einträge
});
return history.map(entry => ({
id: entry.id,
success: entry.success,
message: entry.message,
errorDetails: entry.errorDetails,
updatedCount: entry.updatedCount,
executionTime: entry.executionTime,
createdAt: entry.createdAt
}));
}
/**
* Log update ratings attempt
*/
async logUpdateAttempt(userId, success, message, errorDetails = null, updatedCount = 0, executionTime = null) {
try {
await MyTischtennisUpdateHistory.create({
userId,
success,
message,
errorDetails,
updatedCount,
executionTime
});
// Update lastUpdateRatings in main table
if (success) {
await MyTischtennis.update(
{ lastUpdateRatings: new Date() },
{ where: { userId } }
);
}
} catch (error) {
console.error('Error logging update attempt:', error);
}
}
}
export default new MyTischtennisService();

View File

@@ -0,0 +1,245 @@
import { devLog } from '../utils/logger.js';
class MyTischtennisUrlParserService {
/**
* Parse myTischtennis URL and extract configuration data
*
* Example URL:
* https://www.mytischtennis.de/click-tt/HeTTV/25--26/ligen/1.Kreisklasse/gruppe/504417/mannschaft/2995094/Harheimer_TC_(J11)/spielerbilanzen/gesamt
*
* @param {string} url - The myTischtennis URL
* @returns {Object} Parsed configuration data
*/
parseUrl(url) {
try {
// Remove trailing slash if present
url = url.trim().replace(/\/$/, '');
// Extract parts using regex
// Pattern: /click-tt/{association}/{season}/{type}/{groupname}/gruppe/{groupId}/mannschaft/{teamId}/{teamname}/...
const pattern = /\/click-tt\/([^\/]+)\/([^\/]+)\/([^\/]+)\/([^\/]+)\/gruppe\/([^\/]+)\/mannschaft\/([^\/]+)\/([^\/]+)/;
const match = url.match(pattern);
if (!match) {
throw new Error('URL format not recognized. Expected format: /click-tt/{association}/{season}/{type}/{groupname}/gruppe/{groupId}/mannschaft/{teamId}/{teamname}/...');
}
const [
,
association,
seasonRaw,
type,
groupnameEncoded,
groupId,
teamId,
teamnameEncoded
] = match;
// Decode and process values
const seasonShort = seasonRaw.replace('--', '/'); // "25--26" -> "25/26"
const season = this.convertToFullSeason(seasonShort); // "25/26" -> "2025/2026"
const groupname = decodeURIComponent(groupnameEncoded);
const teamname = decodeURIComponent(teamnameEncoded).replace(/_/g, ' '); // "Harheimer_TC_(J11)" -> "Harheimer TC (J11)"
const result = {
association,
season,
seasonShort, // Für API-Calls
type,
groupname,
groupId,
teamId,
teamname,
originalUrl: url
};
devLog('Parsed myTischtennis URL:', result);
return result;
} catch (error) {
console.error('Error parsing myTischtennis URL:', error);
throw error;
}
}
/**
* Convert short season format to full format
* "25/26" -> "2025/2026"
* "24/25" -> "2024/2025"
*/
convertToFullSeason(seasonShort) {
const parts = seasonShort.split('/');
if (parts.length !== 2) {
return seasonShort;
}
const year1 = parseInt(parts[0]);
const year2 = parseInt(parts[1]);
// Determine century based on year1
// If year1 < 50, assume 20xx, otherwise 19xx
const century1 = year1 < 50 ? 2000 : 1900;
const century2 = year2 < 50 ? 2000 : 1900;
const fullYear1 = century1 + year1;
const fullYear2 = century2 + year2;
return `${fullYear1}/${fullYear2}`;
}
/**
* Convert full season format to short format
* "2025/2026" -> "25/26"
* "2024/2025" -> "24/25"
*/
convertToShortSeason(seasonFull) {
const parts = seasonFull.split('/');
if (parts.length !== 2) {
return seasonFull;
}
const year1 = parseInt(parts[0]);
const year2 = parseInt(parts[1]);
const shortYear1 = String(year1).slice(-2);
const shortYear2 = String(year2).slice(-2);
return `${shortYear1}/${shortYear2}`;
}
/**
* Fetch additional team data from myTischtennis
*
* @param {Object} parsedUrl - Parsed URL data from parseUrl()
* @param {string} cookie - Authentication cookie
* @param {string} accessToken - Access token
* @returns {Object} Additional team data
*/
async fetchTeamData(parsedUrl, cookie, accessToken) {
try {
const { association, seasonShort, type, groupname, groupId, teamId, teamname } = parsedUrl;
const seasonStr = seasonShort.replace('/', '--');
const teamnameEncoded = encodeURIComponent(teamname.replace(/\s/g, '_'));
// Build the API URL
const apiUrl = `https://www.mytischtennis.de/click-tt/${association}/${seasonStr}/${type}/${encodeURIComponent(groupname)}/gruppe/${groupId}/mannschaft/${teamId}/${teamnameEncoded}/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`;
devLog(`Fetching team data from: ${apiUrl}`);
const response = await fetch(apiUrl, {
headers: {
'Cookie': cookie || '',
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/json',
'User-Agent': 'Mozilla/5.0'
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
// Extract additional information
const teamData = {
clubId: null,
clubName: null,
teamName: null,
leagueName: null,
leagueShortName: null,
region: null,
tableRank: null,
matchesWon: null,
matchesLost: null
};
if (data.data && data.data.head_infos) {
const headInfos = data.data.head_infos;
teamData.clubId = data.data.balancesheet?.[0]?.club_id || null;
teamData.clubName = headInfos.club_name;
teamData.teamName = headInfos.team_name;
teamData.leagueName = headInfos.league_name;
teamData.region = headInfos.region;
teamData.tableRank = headInfos.team_table_rank;
teamData.matchesWon = headInfos.team_matches_won;
teamData.matchesLost = headInfos.team_matches_lost;
}
devLog('Fetched team data:', teamData);
return {
...parsedUrl,
...teamData,
fullData: data
};
} catch (error) {
console.error('Error fetching team data:', error);
throw error;
}
}
/**
* Complete configuration from URL
* Combines URL parsing and data fetching
*
* @param {string} url - The myTischtennis URL
* @param {string} cookie - Authentication cookie (optional)
* @param {string} accessToken - Access token (optional)
* @returns {Object} Complete configuration data
*/
async getCompleteConfig(url, cookie = null, accessToken = null) {
const parsedUrl = this.parseUrl(url);
if (cookie && accessToken) {
return await this.fetchTeamData(parsedUrl, cookie, accessToken);
}
return parsedUrl;
}
/**
* Validate if URL is a valid myTischtennis team URL
*
* @param {string} url - The URL to validate
* @returns {boolean} True if valid
*/
isValidTeamUrl(url) {
try {
this.parseUrl(url);
return true;
} catch {
return false;
}
}
/**
* Build myTischtennis URL from components
*
* @param {Object} config - Configuration object
* @returns {string} The constructed URL
*/
buildUrl(config) {
const {
association,
season,
type = 'ligen',
groupname,
groupId,
teamId,
teamname
} = config;
// Convert full season to short format for URL
const seasonShort = this.convertToShortSeason(season);
const seasonStr = seasonShort.replace('/', '--');
const teamnameEncoded = encodeURIComponent(teamname.replace(/\s/g, '_'));
const groupnameEncoded = encodeURIComponent(groupname);
return `https://www.mytischtennis.de/click-tt/${association}/${seasonStr}/${type}/${groupnameEncoded}/gruppe/${groupId}/mannschaft/${teamId}/${teamnameEncoded}/spielerbilanzen/gesamt`;
}
}
export default new MyTischtennisUrlParserService();

View File

@@ -88,11 +88,21 @@ class PDFParserService {
const result = strategy.fn(lines, clubId);
if (result.matches.length > 0) {
console.log(`[PDF Parser] Using strategy: ${strategy.name}, found ${result.matches.length} matches`);
if (result.matches.length > 0) {
console.log(`[PDF Parser] First match sample:`, {
homeTeamName: result.matches[0].homeTeamName,
guestTeamName: result.matches[0].guestTeamName,
date: result.matches[0].date,
rawLine: result.matches[0].rawLine
});
}
matches.push(...result.matches);
metadata.parsedMatches += result.matches.length;
break; // Erste erfolgreiche Strategie verwenden
}
} catch (strategyError) {
console.log(`[PDF Parser] Strategy ${strategy.name} failed:`, strategyError.message);
errors.push(`Strategy ${strategy.name} failed: ${strategyError.message}`);
}
}
@@ -148,16 +158,21 @@ class PDFParserService {
const [, day, month, year] = dateMatch;
const date = new Date(`${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`);
// Suche nach Zeit-Pattern direkt nach dem Datum (hh:mm) - Format: Wt.dd.mm.yyyyhh:MM
const timeMatch = line.match(/(\d{1,2})[./](\d{1,2})[./](\d{4})(\d{1,2}):(\d{2})/);
// Suche nach Zeit-Pattern (hh:mm) - kann direkt nach Datum oder mit Leerzeichen sein
const timeMatch = line.match(/(\d{1,2}):(\d{2})/);
let time = null;
if (timeMatch) {
time = `${timeMatch[4].padStart(2, '0')}:${timeMatch[5]}`;
time = `${timeMatch[1].padStart(2, '0')}:${timeMatch[2]}`;
}
// Entferne Datum und Zeit vom Anfang der Zeile
const cleanLine = line.replace(/^[A-Za-z]{2}\.(\d{1,2})[./](\d{1,2})[./](\d{4})(\d{1,2}):(\d{2})\s*/, '');
// Entferne Datum (mit optionalem Wochentag) und Zeit vom Anfang der Zeile
// Format: "Sa. 06.09.2025 10:00" oder "06.09.2025 10:00"
const cleanLine = line
.replace(/^[A-Za-z]{2,3}\.\s*/, '') // Entferne Wochentag (z.B. "Sa. ", "Mo. ", "Fre. ")
.replace(/^\d{1,2}[./]\d{1,2}[./]\d{4}/, '') // Entferne Datum
.replace(/^\s*\d{1,2}:\d{2}/, '') // Entferne Zeit
.trim();
// Entferne Nummerierung am Anfang (z.B. "(1)")
const cleanLine2 = cleanLine.replace(/^\(\d+\)/, '');
@@ -183,18 +198,26 @@ class PDFParserService {
const pin = pinMatch[1];
teamsPart = cleanLine3.substring(0, cleanLine3.length - pin.length).trim();
// PIN gehört zu dem Team, das direkt vor der PIN steht
// Analysiere die Position der PIN in der ursprünglichen Zeile
const pinIndex = cleanLine3.lastIndexOf(pin);
const teamsPartIndex = cleanLine3.indexOf(teamsPart);
// Die PIN gehört immer zu "Harheimer TC"
// Prüfe, ob "Harheimer TC" am Anfang oder am Ende steht
const harheimerIndex = teamsPart.indexOf('Harheimer TC');
// Wenn PIN direkt nach dem Teams-Part steht, gehört sie zur Heimmannschaft
// Wenn PIN zwischen den Teams steht, gehört sie zur Gastmannschaft
if (pinIndex === teamsPartIndex + teamsPart.length) {
// PIN steht direkt nach den Teams -> Heimmannschaft
homePin = pin;
if (harheimerIndex >= 0) {
// "Harheimer TC" gefunden
let beforeHarheimer = teamsPart.substring(0, harheimerIndex).trim();
// Entferne führende Spielnummer (z.B. "1", "2", etc.)
beforeHarheimer = beforeHarheimer.replace(/^\d+/, '').trim();
if (beforeHarheimer && beforeHarheimer.length > 0) {
// Es gibt einen Team-Namen vor "Harheimer TC" → Harheimer ist Gastteam → guestPin
guestPin = pin;
} else {
// "Harheimer TC" steht am Anfang (nur Spielnummer davor) → Harheimer ist Heimteam → homePin
homePin = pin;
}
} else {
// PIN steht zwischen den Teams -> Gastmannschaft
// "Harheimer TC" nicht gefunden → Standardlogik: PIN gehört zum Gastteam
guestPin = pin;
}
}
@@ -249,14 +272,41 @@ class PDFParserService {
} else {
// Fallback: Versuche mit einzelnen Leerzeichen zu trennen
// Strategie 1: Suche nach "Harheimer TC" als Heimteam
// Strategie 1: Suche nach "Harheimer TC" als Heimteam oder Gastteam
if (teamsPart.includes('Harheimer TC')) {
const harheimerIndex = teamsPart.indexOf('Harheimer TC');
homeTeamName = 'Harheimer TC';
guestTeamName = teamsPart.substring(harheimerIndex + 'Harheimer TC'.length).trim();
// Entferne Klammern aus Gastteam
guestTeamName = guestTeamName.replace(/\([^)]*\)/g, '').trim();
// Prüfe, ob "Harheimer TC" am Anfang oder am Ende steht
let beforeHarheimer = teamsPart.substring(0, harheimerIndex).trim();
let afterHarheimer = teamsPart.substring(harheimerIndex + 'Harheimer TC'.length).trim();
// Entferne Spielnummern aus beiden Teilen
beforeHarheimer = beforeHarheimer.replace(/^\d+/, '').trim();
afterHarheimer = afterHarheimer.replace(/^\d+/, '').trim();
if (beforeHarheimer && !afterHarheimer) {
// "Harheimer TC" ist am Ende → Harheimer ist Gastteam
guestTeamName = 'Harheimer TC';
homeTeamName = beforeHarheimer
.replace(/\([^)]*\)/g, '') // Entferne Klammern
.trim();
} else if (!beforeHarheimer && afterHarheimer) {
// "Harheimer TC" ist am Anfang → Harheimer ist Heimteam
homeTeamName = 'Harheimer TC';
guestTeamName = afterHarheimer
.replace(/\([^)]*\)/g, '') // Entferne Klammern
.trim();
} else if (beforeHarheimer && afterHarheimer) {
// "Harheimer TC" ist in der Mitte → verwende Position als Hinweis
// Normalerweise: Heimteam zuerst, dann Gastteam
homeTeamName = beforeHarheimer
.replace(/\([^)]*\)/g, '') // Entferne Klammern
.trim();
guestTeamName = 'Harheimer TC';
} else {
// Nur "Harheimer TC" ohne andere Teams → ungültig
continue;
}
} else {
// Strategie 2: Suche nach Großbuchstaben am Anfang des zweiten Teams
@@ -284,6 +334,8 @@ class PDFParserService {
debugInfo = `guestPin: "${guestPin}"`;
}
console.log(`[PDF Parser] Parsed match: ${homeTeamName} vs ${guestTeamName}, ${debugInfo}`);
matches.push({
date: date,
time: time,
@@ -554,40 +606,49 @@ class PDFParserService {
} else {
// Fallback: Versuche Teams direkt zu finden
const homeTeam = await Team.findOne({
let homeTeam = await Team.findOne({
where: {
name: matchData.homeTeamName,
clubId: matchData.clubId
}
});
const guestTeam = await Team.findOne({
let guestTeam = await Team.findOne({
where: {
name: matchData.guestTeamName,
clubId: matchData.clubId
}
});
// Debug: Zeige alle verfügbaren Teams für diesen Club
// If exact match failed, try fuzzy matching
if (!homeTeam || !guestTeam) {
const allTeams = await Team.findAll({
where: { clubId: matchData.clubId },
attributes: ['id', 'name']
});
console.log(`[PDF Parser] Available teams in club: ${allTeams.map(t => t.name).join(', ')}`);
// Versuche Fuzzy-Matching für Team-Namen
const homeTeamFuzzy = allTeams.find(t =>
t.name.toLowerCase().includes(matchData.homeTeamName.toLowerCase()) ||
matchData.homeTeamName.toLowerCase().includes(t.name.toLowerCase())
);
const guestTeamFuzzy = allTeams.find(t =>
t.name.toLowerCase().includes(matchData.guestTeamName.toLowerCase()) ||
matchData.guestTeamName.toLowerCase().includes(t.name.toLowerCase())
);
if (homeTeamFuzzy) {
// Fuzzy-Matching für Team-Namen
if (!homeTeam) {
homeTeam = allTeams.find(t =>
t.name.toLowerCase().includes(matchData.homeTeamName.toLowerCase()) ||
matchData.homeTeamName.toLowerCase().includes(t.name.toLowerCase())
);
if (homeTeam) {
console.log(`[PDF Parser] Found home team via fuzzy match: "${matchData.homeTeamName}" → "${homeTeam.name}"`);
}
}
if (guestTeamFuzzy) {
if (!guestTeam) {
guestTeam = allTeams.find(t =>
t.name.toLowerCase().includes(matchData.guestTeamName.toLowerCase()) ||
matchData.guestTeamName.toLowerCase().includes(t.name.toLowerCase())
);
if (guestTeam) {
console.log(`[PDF Parser] Found guest team via fuzzy match: "${matchData.guestTeamName}" → "${guestTeam.name}"`);
}
}
}

View File

@@ -0,0 +1,140 @@
import cron from 'node-cron';
import autoUpdateRatingsService from './autoUpdateRatingsService.js';
import autoFetchMatchResultsService from './autoFetchMatchResultsService.js';
import { devLog } from '../utils/logger.js';
class SchedulerService {
constructor() {
this.jobs = new Map();
this.isRunning = false;
}
/**
* Start the scheduler
*/
start() {
if (this.isRunning) {
devLog('Scheduler is already running');
return;
}
devLog('Starting scheduler service...');
// Schedule automatic rating updates at 6:00 AM daily
const ratingUpdateJob = cron.schedule('0 6 * * *', async () => {
devLog('Executing scheduled rating updates...');
try {
await autoUpdateRatingsService.executeAutomaticUpdates();
} catch (error) {
console.error('Error in scheduled rating updates:', error);
}
}, {
scheduled: false, // Don't start automatically
timezone: 'Europe/Berlin'
});
this.jobs.set('ratingUpdates', ratingUpdateJob);
ratingUpdateJob.start();
// Schedule automatic match results fetching at 6:30 AM daily
const matchResultsJob = cron.schedule('30 6 * * *', async () => {
devLog('Executing scheduled match results fetch...');
try {
await autoFetchMatchResultsService.executeAutomaticFetch();
} catch (error) {
console.error('Error in scheduled match results fetch:', error);
}
}, {
scheduled: false, // Don't start automatically
timezone: 'Europe/Berlin'
});
this.jobs.set('matchResults', matchResultsJob);
matchResultsJob.start();
this.isRunning = true;
devLog('Scheduler service started successfully');
devLog('Rating updates scheduled for 6:00 AM daily (Europe/Berlin timezone)');
devLog('Match results fetch scheduled for 6:30 AM daily (Europe/Berlin timezone)');
}
/**
* Stop the scheduler
*/
stop() {
if (!this.isRunning) {
devLog('Scheduler is not running');
return;
}
devLog('Stopping scheduler service...');
for (const [name, job] of this.jobs) {
job.stop();
devLog(`Stopped job: ${name}`);
}
this.jobs.clear();
this.isRunning = false;
devLog('Scheduler service stopped');
}
/**
* Get scheduler status
*/
getStatus() {
return {
isRunning: this.isRunning,
jobs: Array.from(this.jobs.keys()),
timezone: 'Europe/Berlin'
};
}
/**
* Manually trigger rating updates (for testing)
*/
async triggerRatingUpdates() {
devLog('Manually triggering rating updates...');
try {
await autoUpdateRatingsService.executeAutomaticUpdates();
return { success: true, message: 'Rating updates completed successfully' };
} catch (error) {
console.error('Error in manual rating updates:', error);
return { success: false, message: error.message };
}
}
/**
* Manually trigger match results fetch (for testing)
*/
async triggerMatchResultsFetch() {
devLog('Manually triggering match results fetch...');
try {
await autoFetchMatchResultsService.executeAutomaticFetch();
return { success: true, message: 'Match results fetch completed successfully' };
} catch (error) {
console.error('Error in manual match results fetch:', error);
return { success: false, message: error.message };
}
}
/**
* Get next scheduled execution time for rating updates
*/
getNextRatingUpdateTime() {
const job = this.jobs.get('ratingUpdates');
if (!job || !this.isRunning) {
return null;
}
// Get next execution time (this is a simplified approach)
const now = new Date();
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(6, 0, 0, 0);
return tomorrow;
}
}
export default new SchedulerService();

View File

@@ -13,6 +13,7 @@
"html2canvas": "^1.4.1",
"jspdf": "^2.5.2",
"jspdf-autotable": "^5.0.2",
"node-cron": "^4.2.1",
"sortablejs": "^1.15.3",
"vue": "^3.2.13",
"vue-multiselect": "^3.0.0",
@@ -2545,6 +2546,15 @@
"license": "MIT",
"optional": true
},
"node_modules/node-cron": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
"integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
"license": "ISC",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",

View File

@@ -13,6 +13,7 @@
"html2canvas": "^1.4.1",
"jspdf": "^2.5.2",
"jspdf-autotable": "^5.0.2",
"node-cron": "^4.2.1",
"sortablejs": "^1.15.3",
"vue": "^3.2.13",
"vue-multiselect": "^3.0.0",

View File

@@ -10,7 +10,11 @@
</header>
<div class="app-container">
<aside v-if="isAuthenticated" class="sidebar">
<aside v-if="isAuthenticated" class="sidebar" :class="{ 'sidebar-collapsed': sidebarCollapsed }">
<button class="sidebar-toggle" @click="toggleSidebar">
<span v-if="sidebarCollapsed"></span>
<span v-else></span>
</button>
<div class="sidebar-content">
<div class="club-selector card">
<h3 class="card-title">Verein auswählen</h3>
@@ -29,19 +33,19 @@
<nav v-if="selectedClub" class="nav-menu">
<div class="nav-section">
<h4 class="nav-title">Verwaltung</h4>
<a href="/members" class="nav-link">
<a href="/members" class="nav-link" title="Mitglieder">
<span class="nav-icon">👥</span>
Mitglieder
</a>
<a href="/diary" class="nav-link">
<a href="/diary" class="nav-link" title="Tagebuch">
<span class="nav-icon">📝</span>
Tagebuch
</a>
<a href="/pending-approvals" class="nav-link">
<a href="/pending-approvals" class="nav-link" title="Freigaben">
<span class="nav-icon"></span>
Freigaben
</a>
<a href="/training-stats" class="nav-link">
<a href="/training-stats" class="nav-link" title="Trainings-Statistik">
<span class="nav-icon">📊</span>
Trainings-Statistik
</a>
@@ -49,33 +53,34 @@
<div class="nav-section">
<h4 class="nav-title">Organisation</h4>
<a href="/schedule" class="nav-link">
<a href="/schedule" class="nav-link" title="Spielpläne">
<span class="nav-icon">📅</span>
Spielpläne
</a>
<a href="/tournaments" class="nav-link">
<a href="/tournaments" class="nav-link" title="Interne Turniere">
<span class="nav-icon">🏆</span>
Interne Turniere
</a>
<a href="/official-tournaments" class="nav-link">
<a href="/official-tournaments" class="nav-link" title="Offizielle Turniere">
<span class="nav-icon">📄</span>
Offizielle Turniere
</a>
<a href="/predefined-activities" class="nav-link">
<a href="/predefined-activities" class="nav-link" title="Vordefinierte Aktivitäten">
<span class="nav-icon"></span>
Vordefinierte Aktivitäten
</a>
<a href="/team-management" class="nav-link">
<a href="/team-management" class="nav-link" title="Team-Verwaltung">
<span class="nav-icon">👥</span>
Team-Verwaltung
</a>
</div>
</nav>
<nav v-else class="nav-menu"></nav>
<nav class="sidebar-footer">
<div class="nav-section">
<h4 class="nav-title">Einstellungen</h4>
<a href="/mytischtennis-account" class="nav-link">
<a href="/mytischtennis-account" class="nav-link" title="myTischtennis-Account">
<span class="nav-icon">🔗</span>
myTischtennis-Account
</a>
@@ -83,12 +88,13 @@
</nav>
<div class="sidebar-footer">
<button @click="logout()" class="btn-secondary logout-btn">
<button @click="logout()" class="btn-secondary logout-btn" title="Ausloggen">
<span class="nav-icon">🚪</span>
Ausloggen
</button>
</div>
</div>
</aside>
<div v-else class="auth-nav">
@@ -102,6 +108,10 @@
<router-view class="content fade-in"></router-view>
</main>
</div>
<!-- Dialog Manager -->
<DialogManager />
<footer class="app-footer">
<div class="footer-content">
<router-link to="/impressum" class="footer-link">Impressum</router-link>
@@ -110,24 +120,69 @@
</div>
</footer>
</div>
<!-- Info Dialog -->
<InfoDialog
v-model="infoDialog.isOpen"
:title="infoDialog.title"
:message="infoDialog.message"
:details="infoDialog.details"
:type="infoDialog.type"
/>
<!-- Confirm Dialog -->
<ConfirmDialog
v-model="confirmDialog.isOpen"
:title="confirmDialog.title"
:message="confirmDialog.message"
:details="confirmDialog.details"
:type="confirmDialog.type"
@confirm="handleConfirmResult(true)"
@cancel="handleConfirmResult(false)"
/>
</template>
<script>
import { mapGetters, mapActions } from 'vuex';
import apiClient from './apiClient.js';
import logoUrl from './assets/logo.png';
import DialogManager from './components/DialogManager.vue';
import InfoDialog from './components/InfoDialog.vue';
import ConfirmDialog from './components/ConfirmDialog.vue';
export default {
name: 'App',
components: {
DialogManager
,
InfoDialog,
ConfirmDialog},
data() {
return {
// Dialog States
infoDialog: {
isOpen: false,
title: '',
message: '',
details: '',
type: 'info'
},
confirmDialog: {
isOpen: false,
title: '',
message: '',
details: '',
type: 'info',
resolveCallback: null
},
selectedClub: null,
sessionInterval: null,
logoUrl,
};
},
computed: {
...mapGetters(['isAuthenticated', 'currentClub', 'clubs']),
...mapGetters(['isAuthenticated', 'currentClub', 'clubs', 'sidebarCollapsed']),
},
watch: {
selectedClub(newVal) {
@@ -156,7 +211,39 @@ export default {
},
},
methods: {
...mapActions(['setCurrentClub', 'setClubs', 'logout']),
// Dialog Helper Methods
async showInfo(title, message, details = '', type = 'info') {
this.infoDialog = {
isOpen: true,
title,
message,
details,
type
};
},
async showConfirm(title, message, details = '', type = 'info') {
return new Promise((resolve) => {
this.confirmDialog = {
isOpen: true,
title,
message,
details,
type,
resolveCallback: resolve
};
});
},
handleConfirmResult(confirmed) {
if (this.confirmDialog.resolveCallback) {
this.confirmDialog.resolveCallback(confirmed);
this.confirmDialog.resolveCallback = null;
}
this.confirmDialog.isOpen = false;
},
...mapActions(['setCurrentClub', 'setClubs', 'logout', 'toggleSidebar']),
async loadUserData() {
try {
@@ -452,6 +539,7 @@ export default {
overflow-y: auto;
background: var(--background-light);
min-height: 0;
padding-bottom: 32px; /* Platz für Statusleiste (24px + 8px padding) */
}
/* Footer */
@@ -520,14 +608,83 @@ export default {
}
}
/* Toggle-Button für Sidebar (nur auf mobil sichtbar) */
.sidebar-toggle {
display: none;
}
@media (max-width: 480px) {
.sidebar {
width: 100%;
position: fixed;
top: 3rem;
left: 0;
height: calc(100vh - 3rem);
z-index: 999;
transition: width 0.3s ease;
}
.sidebar.sidebar-collapsed {
width: 60px;
}
.sidebar:not(.sidebar-collapsed) {
width: 240px;
}
.sidebar-toggle {
display: block;
position: absolute;
top: 10px;
right: 10px;
background: var(--primary-color);
color: white;
border: none;
width: 32px;
height: 32px;
border-radius: 50%;
cursor: pointer;
z-index: 1000;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.sidebar-toggle:active {
transform: scale(0.95);
}
/* Im kollabierten Zustand: nur Icons zeigen */
.sidebar-collapsed .nav-link span:not(.nav-icon),
.sidebar-collapsed .nav-link text,
.sidebar-collapsed .nav-title,
.sidebar-collapsed .card-title,
.sidebar-collapsed .logout-btn span:not(.nav-icon),
.sidebar-collapsed .club-selector,
.sidebar-collapsed .select-group,
.sidebar-collapsed .btn-primary {
display: none !important;
}
/* Alle Text-Inhalte in nav-links verstecken */
.sidebar-collapsed .nav-link {
justify-content: center;
padding: 0.75rem 0.5rem;
font-size: 0;
}
.sidebar-collapsed .nav-icon {
font-size: 1.5rem;
margin: 0;
}
.sidebar-collapsed .logout-btn {
justify-content: center;
}
.sidebar-collapsed .sidebar-footer {
border-top: none;
}
.content {
@@ -547,8 +704,13 @@ export default {
}
.main-content {
margin-left: 0;
margin-left: 60px;
overflow-y: auto;
transition: margin-left 0.3s ease;
}
.sidebar:not(.sidebar-collapsed) ~ .main-content {
margin-left: 240px;
}
}

View File

@@ -0,0 +1,221 @@
<template>
<BaseDialog
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
title="Unfall melden"
size="medium"
@close="handleClose"
>
<form @submit.prevent="handleSubmit" class="accident-form">
<div class="form-group">
<label for="memberId">Mitglied:</label>
<select id="memberId" v-model="localAccident.memberId" class="form-select">
<option value="">Bitte wählen</option>
<option
v-for="member in availableMembers"
:key="member.id"
:value="member.id"
>
{{ member.firstName }} {{ member.lastName }}
</option>
</select>
</div>
<div class="form-group">
<label for="accident">Unfall:</label>
<textarea
id="accident"
v-model="localAccident.accident"
required
class="form-textarea"
placeholder="Beschreibung des Unfalls..."
></textarea>
</div>
<div v-if="accidents.length > 0" class="accidents-list">
<h4>Gemeldete Unfälle</h4>
<ul>
<li v-for="accident in accidents" :key="accident.id" class="accident-item">
<strong>{{ accident.firstName }} {{ accident.lastName }}:</strong> {{ accident.accident }}
</li>
</ul>
</div>
</form>
<template #footer>
<button type="button" @click="handleClose" class="btn-secondary">Schließen</button>
<button type="button" @click="handleSubmit" class="btn-primary" :disabled="!isValid">Eintragen</button>
</template>
</BaseDialog>
</template>
<script>
import BaseDialog from './BaseDialog.vue';
export default {
name: 'AccidentFormDialog',
components: {
BaseDialog
},
props: {
modelValue: {
type: Boolean,
default: false
},
accident: {
type: Object,
default: () => ({ memberId: '', accident: '' })
},
members: {
type: Array,
default: () => []
},
participants: {
type: Array,
default: () => []
},
accidents: {
type: Array,
default: () => []
}
},
emits: ['update:modelValue', 'close', 'submit', 'update:accident'],
data() {
return {
localAccident: { ...this.accident }
};
},
computed: {
availableMembers() {
return this.members.filter(m => this.participants.indexOf(m.id) >= 0);
},
isValid() {
return this.localAccident.memberId && this.localAccident.accident && this.localAccident.accident.trim() !== '';
}
},
watch: {
accident: {
handler(newVal) {
this.localAccident = { ...newVal };
},
deep: true
},
localAccident: {
handler(newVal) {
this.$emit('update:accident', newVal);
},
deep: true
}
},
methods: {
handleClose() {
this.$emit('update:modelValue', false);
this.$emit('close');
},
handleSubmit() {
if (this.isValid) {
this.$emit('submit', this.localAccident);
}
}
}
};
</script>
<style scoped>
.accident-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group label {
font-weight: 600;
color: var(--text-color);
}
.form-select,
.form-textarea {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--border-color);
border-radius: 4px;
font-family: inherit;
}
.form-textarea {
resize: vertical;
min-height: 100px;
}
.form-select:focus,
.form-textarea:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px var(--primary-light);
}
.accidents-list {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color);
}
.accidents-list h4 {
margin: 0 0 0.5rem 0;
font-size: 1rem;
}
.accidents-list ul {
list-style: none;
padding: 0;
margin: 0;
}
.accident-item {
padding: 0.5rem;
margin-bottom: 0.5rem;
background: var(--background-light);
border-radius: 4px;
font-size: 0.9rem;
}
.btn-primary,
.btn-secondary {
padding: 0.5rem 1.5rem;
border: none;
border-radius: 4px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-primary {
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
color: white;
}
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #5a6268;
}
</style>

View File

@@ -0,0 +1,379 @@
<template>
<div
v-if="isVisible"
:class="['base-dialog', { 'modal-dialog': isModal, 'non-modal-dialog': !isModal }]"
@click.self="handleOverlayClick"
>
<div
:class="['dialog-container', sizeClass]"
:style="dialogStyle"
@mousedown="handleMouseDown"
>
<!-- Header -->
<div
class="dialog-header"
:class="{ draggable: isDraggable }"
@mousedown="startDrag"
>
<h3 class="dialog-title">{{ title }}</h3>
<slot name="header-actions"></slot>
<div class="dialog-controls">
<button
v-if="minimizable"
@click="$emit('minimize')"
class="control-btn minimize-btn"
title="Minimieren"
>
</button>
<button
v-if="closable"
@click="handleClose"
class="control-btn close-btn"
title="Schließen"
>
×
</button>
</div>
</div>
<!-- Content -->
<div class="dialog-body">
<slot></slot>
</div>
<!-- Footer (optional) -->
<div v-if="$slots.footer" class="dialog-footer">
<slot name="footer"></slot>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'BaseDialog',
props: {
// Sichtbarkeit
modelValue: {
type: Boolean,
default: false
},
// Dialog-Typ
isModal: {
type: Boolean,
default: true
},
// Titel
title: {
type: String,
required: true
},
// Größe: 'small', 'medium', 'large', 'fullscreen'
size: {
type: String,
default: 'medium',
validator: (value) => ['small', 'medium', 'large', 'fullscreen'].includes(value)
},
// Position für nicht-modale Dialoge
position: {
type: Object,
default: () => ({ x: 100, y: 100 })
},
// z-Index
zIndex: {
type: Number,
default: 1000
},
// Funktionalität
closable: {
type: Boolean,
default: true
},
minimizable: {
type: Boolean,
default: false
},
draggable: {
type: Boolean,
default: true
},
// Schließen bei Overlay-Klick (nur bei modalen Dialogen)
closeOnOverlay: {
type: Boolean,
default: true
}
},
data() {
return {
localPosition: { ...this.position },
isDragging: false,
dragStartX: 0,
dragStartY: 0
};
},
computed: {
isVisible() {
return this.modelValue;
},
sizeClass() {
return `dialog-${this.size}`;
},
dialogStyle() {
const style = {
zIndex: this.zIndex
};
if (!this.isModal) {
style.left = `${this.localPosition.x}px`;
style.top = `${this.localPosition.y}px`;
}
return style;
},
isDraggable() {
return this.draggable && !this.isModal;
}
},
methods: {
handleClose() {
this.$emit('update:modelValue', false);
this.$emit('close');
},
handleOverlayClick() {
if (this.isModal && this.closeOnOverlay) {
this.handleClose();
}
},
handleMouseDown() {
if (!this.isModal) {
this.$emit('focus');
}
},
startDrag(event) {
if (!this.isDraggable) return;
this.isDragging = true;
this.dragStartX = event.clientX - this.localPosition.x;
this.dragStartY = event.clientY - this.localPosition.y;
document.addEventListener('mousemove', this.onDrag);
document.addEventListener('mouseup', this.stopDrag);
event.preventDefault();
},
onDrag(event) {
if (!this.isDragging) return;
this.localPosition.x = event.clientX - this.dragStartX;
this.localPosition.y = event.clientY - this.dragStartY;
this.$emit('update:position', { ...this.localPosition });
},
stopDrag() {
this.isDragging = false;
document.removeEventListener('mousemove', this.onDrag);
document.removeEventListener('mouseup', this.stopDrag);
}
},
watch: {
position: {
handler(newPos) {
this.localPosition = { ...newPos };
},
deep: true
}
},
beforeUnmount() {
if (this.isDragging) {
this.stopDrag();
}
}
};
</script>
<style scoped>
/* Base Dialog Wrapper */
.base-dialog {
position: fixed;
pointer-events: none;
}
/* Modal Dialog (mit Overlay) */
.modal-dialog {
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
pointer-events: auto;
}
/* Non-Modal Dialog (ohne Overlay) */
.non-modal-dialog {
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1000;
pointer-events: none;
}
/* Dialog Container */
.dialog-container {
background: white;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
pointer-events: auto;
display: flex;
flex-direction: column;
overflow: hidden;
max-height: 90vh;
}
/* Modal Dialog Container */
.modal-dialog .dialog-container {
position: relative;
}
/* Non-Modal Dialog Container */
.non-modal-dialog .dialog-container {
position: absolute;
}
/* Größen */
.dialog-small {
width: 400px;
max-width: 90vw;
}
.dialog-medium {
width: 600px;
max-width: 90vw;
}
.dialog-large {
width: 900px;
max-width: 90vw;
}
.dialog-fullscreen {
width: 90vw;
height: 90vh;
max-width: none;
max-height: 90vh;
}
/* Header */
.dialog-header {
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
color: white;
padding: 4px 16px;
display: flex;
justify-content: space-between;
align-items: center;
user-select: none;
flex-shrink: 0;
}
.dialog-header.draggable {
cursor: move;
}
.dialog-title {
margin: 0;
font-size: 1rem;
font-weight: 600;
flex: 1;
}
/* Controls */
.dialog-controls {
display: flex;
gap: 8px;
margin-left: 12px;
}
.control-btn {
width: 24px;
height: 24px;
border: none;
border-radius: 4px;
background: rgba(255, 255, 255, 0.2);
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: bold;
transition: background-color 0.2s ease;
}
.control-btn:hover {
background: rgba(255, 255, 255, 0.3);
}
.close-btn:hover {
background: #dc3545;
}
/* Body */
.dialog-body {
flex: 1;
padding: 16px;
overflow-y: auto;
}
/* Footer */
.dialog-footer {
padding: 4px 16px;
border-top: 1px solid var(--border-color);
display: flex;
justify-content: flex-end;
gap: 8px;
flex-shrink: 0;
}
/* Responsive Design */
@media (max-width: 768px) {
.dialog-small,
.dialog-medium,
.dialog-large {
width: 95vw;
max-width: 95vw;
}
.dialog-fullscreen {
width: 95vw;
height: 95vh;
}
}
</style>

View File

@@ -0,0 +1,209 @@
<template>
<BaseDialog
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
:title="title"
:is-modal="true"
size="small"
:closable="true"
:close-on-overlay="false"
@close="handleCancel"
>
<!-- Content -->
<div class="confirm-content">
<div v-if="icon" class="confirm-icon" :class="`icon-${type}`">
{{ icon }}
</div>
<div class="confirm-message">
{{ message }}
</div>
<div v-if="details" class="confirm-details">
{{ details }}
</div>
</div>
<!-- Footer mit Aktionen -->
<template #footer>
<button
v-if="showCancel"
@click="handleCancel"
class="btn-secondary"
>
{{ cancelText }}
</button>
<button
@click="handleConfirm"
:class="confirmButtonClass"
>
{{ confirmText }}
</button>
</template>
</BaseDialog>
</template>
<script>
import BaseDialog from './BaseDialog.vue';
export default {
name: 'ConfirmDialog',
components: {
BaseDialog
},
props: {
modelValue: {
type: Boolean,
default: false
},
title: {
type: String,
default: 'Bestätigung'
},
message: {
type: String,
required: true
},
details: {
type: String,
default: ''
},
type: {
type: String,
default: 'info',
validator: (value) => ['info', 'warning', 'danger', 'success'].includes(value)
},
confirmText: {
type: String,
default: 'OK'
},
cancelText: {
type: String,
default: 'Abbrechen'
},
showCancel: {
type: Boolean,
default: true
}
},
computed: {
icon() {
const icons = {
info: '',
warning: '⚠️',
danger: '⛔',
success: '✅'
};
return icons[this.type] || icons.info;
},
confirmButtonClass() {
const classes = {
info: 'btn-primary',
warning: 'btn-warning',
danger: 'btn-danger',
success: 'btn-primary'
};
return classes[this.type] || 'btn-primary';
}
},
methods: {
handleConfirm() {
this.$emit('confirm');
this.$emit('update:modelValue', false);
},
handleCancel() {
this.$emit('cancel');
this.$emit('update:modelValue', false);
}
}
};
</script>
<style scoped>
.confirm-content {
text-align: center;
padding: 1rem 0;
}
.confirm-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.icon-info {
color: #17a2b8;
}
.icon-warning {
color: #ffc107;
}
.icon-danger {
color: #dc3545;
}
.icon-success {
color: var(--primary-color);
}
.confirm-message {
font-size: 1rem;
font-weight: 500;
color: var(--text-color);
margin-bottom: 0.5rem;
}
.confirm-details {
font-size: 0.875rem;
color: var(--text-muted);
margin-top: 0.5rem;
}
.btn-secondary,
.btn-primary,
.btn-warning,
.btn-danger {
padding: 0.5rem 1.5rem;
border: none;
border-radius: 4px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #5a6268;
}
.btn-primary {
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
color: white;
}
.btn-primary:hover {
opacity: 0.9;
}
.btn-warning {
background: #ffc107;
color: #212529;
}
.btn-warning:hover {
background: #e0a800;
}
.btn-danger {
background: #dc3545;
color: white;
}
.btn-danger:hover {
background: #c82333;
}
</style>

View File

@@ -0,0 +1,185 @@
<template>
<BaseDialog
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
title="Spielplan importieren"
size="small"
@close="handleClose"
>
<form @submit.prevent="handleSubmit" class="import-form">
<div class="form-group">
<label for="csvFile">CSV-Datei hochladen:</label>
<input
type="file"
id="csvFile"
@change="handleFileChange"
accept=".csv"
required
class="file-input"
ref="fileInput"
/>
<div v-if="selectedFile" class="file-info">
<span class="file-icon">📄</span>
<span class="file-name">{{ selectedFile.name }}</span>
<span class="file-size">({{ formatFileSize(selectedFile.size) }})</span>
</div>
</div>
</form>
<template #footer>
<button @click="handleClose" class="btn-secondary">Abbrechen</button>
<button @click="handleSubmit" class="btn-primary" :disabled="!selectedFile">Importieren</button>
</template>
</BaseDialog>
</template>
<script>
import BaseDialog from './BaseDialog.vue';
export default {
name: 'CsvImportDialog',
components: {
BaseDialog
},
props: {
modelValue: {
type: Boolean,
default: false
}
},
emits: ['update:modelValue', 'close', 'import'],
data() {
return {
selectedFile: null
};
},
methods: {
handleClose() {
this.selectedFile = null;
if (this.$refs.fileInput) {
this.$refs.fileInput.value = '';
}
this.$emit('update:modelValue', false);
this.$emit('close');
},
handleFileChange(event) {
this.selectedFile = event.target.files && event.target.files[0] ? event.target.files[0] : null;
},
handleSubmit() {
if (this.selectedFile) {
this.$emit('import', this.selectedFile);
}
},
formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
},
watch: {
modelValue(newVal) {
if (!newVal) {
// Dialog geschlossen - Reset
this.selectedFile = null;
}
}
}
};
</script>
<style scoped>
.import-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group label {
font-weight: 600;
color: var(--text-color);
}
.file-input {
padding: 0.5rem;
border: 1px solid var(--border-color);
border-radius: 4px;
cursor: pointer;
}
.file-input::-webkit-file-upload-button {
padding: 0.375rem 0.75rem;
border: 1px solid var(--border-color);
border-radius: 4px;
background: white;
cursor: pointer;
margin-right: 0.5rem;
}
.file-input::-webkit-file-upload-button:hover {
background: var(--background-light);
}
.file-info {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
background: var(--background-light);
border-radius: 4px;
font-size: 0.875rem;
}
.file-icon {
font-size: 1.5rem;
}
.file-name {
flex: 1;
font-weight: 500;
}
.file-size {
color: var(--text-muted);
}
.btn-primary,
.btn-secondary {
padding: 0.5rem 1.5rem;
border: none;
border-radius: 4px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-primary {
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
color: white;
}
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #5a6268;
}
</style>

View File

@@ -0,0 +1,348 @@
# Dialog-Komponenten Übersicht
## 📋 Alle verfügbaren Dialog-Komponenten
### Basis-Komponenten
#### 1. **BaseDialog.vue**
Basis-Template für alle Dialoge (modal und nicht-modal).
**Props:** `modelValue`, `isModal`, `title`, `size`, `position`, `zIndex`, `closable`, `minimizable`, `draggable`, `closeOnOverlay`
**Verwendung:**
```vue
<BaseDialog v-model="isOpen" title="Titel" size="medium">
Content
</BaseDialog>
```
---
### Informations-Dialoge
#### 2. **InfoDialog.vue**
Einfache Informationsmeldungen mit OK-Button.
**Props:** `modelValue`, `title`, `message`, `details`, `type` (info/success/warning/error), `icon`, `okText`
**Verwendung:**
```vue
<InfoDialog
v-model="showInfo"
title="Erfolg"
message="Gespeichert!"
type="success"
/>
```
#### 3. **ConfirmDialog.vue**
Bestätigungsdialoge mit OK/Abbrechen.
**Props:** `modelValue`, `title`, `message`, `details`, `type` (info/warning/danger/success), `confirmText`, `cancelText`, `showCancel`
**Events:** `@confirm`, `@cancel`
**Verwendung:**
```vue
<ConfirmDialog
v-model="showConfirm"
title="Löschen?"
message="Wirklich löschen?"
type="danger"
@confirm="handleDelete"
@cancel="handleCancel"
/>
```
---
### Bild-Dialoge
#### 4. **ImageDialog.vue**
Einfache Bildanzeige.
**Props:** `modelValue`, `title`, `imageUrl`
**Verwendung:**
```vue
<ImageDialog
v-model="showImage"
title="Bild"
:image-url="imageUrl"
/>
```
#### 5. **ImageViewerDialog.vue**
Erweiterte Bildanzeige mit Aktionen (Drehen, Zoom).
**Props:** `modelValue`, `title`, `imageUrl`, `memberId`, `showActions`, `allowRotate`, `allowZoom`
**Events:** `@rotate`
**Verwendung:**
```vue
<ImageViewerDialog
v-model="showImage"
:image-url="imageUrl"
:member-id="memberId"
:allow-rotate="true"
@rotate="handleRotate"
/>
```
---
### Spezifische Dialoge
#### 6. **MemberNotesDialog.vue**
Notizen-Verwaltung für Mitglieder mit Bild, Tags und Notizliste.
**Props:** `modelValue`, `member`, `notes`, `selectedTags`, `availableTags`, `noteContent`
**Events:** `@add-note`, `@delete-note`, `@add-tag`, `@remove-tag`
**Verwendung:**
```vue
<MemberNotesDialog
v-model="showNotes"
:member="selectedMember"
:notes="notes"
v-model:note-content="newNote"
v-model:selected-tags="tags"
:available-tags="allTags"
@add-note="addNote"
@delete-note="deleteNote"
/>
```
#### 7. **TagHistoryDialog.vue**
Tag-Historie für Mitglieder.
**Props:** `modelValue`, `member`, `tagHistory`, `selectedTags`, `activityTags`
**Events:** `@select-tag`
**Verwendung:**
```vue
<TagHistoryDialog
v-model="showHistory"
:member="member"
:tag-history="history"
v-model:selected-tags="tags"
:activity-tags="allTags"
@select-tag="handleSelectTag"
/>
```
#### 8. **AccidentFormDialog.vue**
Unfall-Meldungs-Formular.
**Props:** `modelValue`, `accident`, `members`, `participants`, `accidents`
**Events:** `@submit`
**Verwendung:**
```vue
<AccidentFormDialog
v-model="showAccident"
v-model:accident="accidentData"
:members="members"
:participants="participants"
:accidents="reportedAccidents"
@submit="saveAccident"
/>
```
#### 9. **QuickAddMemberDialog.vue**
Schnelles Hinzufügen von Mitgliedern.
**Props:** `modelValue`, `member`
**Events:** `@submit`
**Verwendung:**
```vue
<QuickAddMemberDialog
v-model="showQuickAdd"
v-model:member="newMember"
@submit="createMember"
/>
```
#### 10. **CsvImportDialog.vue**
CSV-Datei-Import mit Dateiauswahl.
**Props:** `modelValue`
**Events:** `@import`
**Verwendung:**
```vue
<CsvImportDialog
v-model="showImport"
@import="handleImport"
/>
```
#### 11. **TrainingDetailsDialog.vue**
Trainings-Details und Statistiken für Mitglieder.
**Props:** `modelValue`, `member`
**Verwendung:**
```vue
<TrainingDetailsDialog
v-model="showDetails"
:member="selectedMember"
/>
```
#### 12. **MemberSelectionDialog.vue**
Mitglieder-Auswahl mit Empfehlungen (für PDF-Generierung).
**Props:** `modelValue`, `members`, `selectedIds`, `activeMemberId`, `recommendations`, `recommendedKeys`, `showRecommendations`
**Events:** `@select-all`, `@deselect-all`, `@toggle-member`, `@toggle-recommendation`, `@generate-pdf`
**Verwendung:**
```vue
<MemberSelectionDialog
v-model="showSelection"
:members="members"
v-model:selected-ids="selectedIds"
:recommendations="recommendations"
@generate-pdf="generatePdf"
/>
```
---
## 🎨 Dialog-Typen und Icons
| Typ | Icon | Farbe | Verwendung |
|-----|------|-------|-----------|
| `info` | | Blau | Informationen |
| `success` | ✅ | Grün | Erfolgsmeldungen |
| `warning` | ⚠️ | Gelb | Warnungen |
| `error` | ⛔ | Rot | Fehler |
| `danger` | ⛔ | Rot | Gefährliche Aktionen (Löschen) |
---
## 📏 Dialog-Größen
| Größe | Breite | Verwendung |
|-------|--------|-----------|
| `small` | 400px | Einfache Meldungen, Bestätigungen |
| `medium` | 600px | Standard-Formulare, Notizen |
| `large` | 900px | Komplexe Inhalte, Listen, Details |
| `fullscreen` | 90vw × 90vh | Maximale Fläche |
---
## 🔧 Composables
### useDialog()
```javascript
import { useDialog } from '@/composables/useDialog.js';
const { isOpen, open, close, toggle } = useDialog();
```
### useConfirm()
```javascript
import { useConfirm } from '@/composables/useDialog.js';
const { confirm } = useConfirm();
const result = await confirm({
title: 'Löschen?',
message: 'Wirklich löschen?',
type: 'danger'
});
```
### useInfo()
```javascript
import { useInfo } from '@/composables/useDialog.js';
const { showInfo } = useInfo();
await showInfo({
title: 'Erfolg',
message: 'Gespeichert!',
type: 'success'
});
```
---
## 🎯 Migration Guide
### Von JavaScript-Alert/Confirm:
**Vorher:**
```javascript
alert('Fehler!');
if (confirm('Löschen?')) { ... }
```
**Nachher:**
```javascript
// In setup() oder data():
const infoDialog = ref({ isOpen: false, title: '', message: '', details: '', type: 'info' });
const confirmDialog = ref({ isOpen: false, title: '', message: '', details: '', type: 'info', resolveCallback: null });
// Methods:
async showInfo(title, message, details = '', type = 'info') {
this.infoDialog = { isOpen: true, title, message, details, type };
}
async showConfirm(title, message, details = '', type = 'info') {
return new Promise((resolve) => {
this.confirmDialog = { isOpen: true, title, message, details, type, resolveCallback: resolve };
});
}
// Verwenden:
this.showInfo('Fehler', 'Fehler!', '', 'error');
const confirmed = await this.showConfirm('Bestätigung', 'Löschen?', '', 'danger');
if (confirmed) { ... }
```
### Von Inline-Modal:
**Vorher:**
```vue
<div v-if="showModal" class="modal-overlay">
<div class="modal">
<span class="close" @click="showModal = false">&times;</span>
<h3>Titel</h3>
<!-- Content -->
</div>
</div>
```
**Nachher:**
```vue
<BaseDialog v-model="showModal" title="Titel">
<!-- Content -->
</BaseDialog>
```
---
## ✅ Best Practices
1. **v-model verwenden** für Sichtbarkeit
2. **Slots nutzen** für flexible Inhalte (#footer, #header-actions)
3. **Events emittieren** statt direkte Manipulation
4. **Props validieren** mit `validator` Funktionen
5. **Responsive Design** berücksichtigen
6. **Memory Management** bei Blob URLs (revokeObjectURL)
7. **Eigene Komponenten** für wiederverwendbare Dialoge erstellen
---
## 📚 Weitere Informationen
Siehe `DIALOG_TEMPLATES.md` für detaillierte API-Dokumentation und erweiterte Beispiele.

View File

@@ -0,0 +1,414 @@
# Dialog-Templates Dokumentation
## Übersicht
Das Dialog-System bietet wiederverwendbare Templates für modale und nicht-modale Dialoge.
## Komponenten
### 1. BaseDialog.vue
Die Basis-Komponente für alle Dialoge. Unterstützt sowohl modale als auch nicht-modale Dialoge.
#### Props
| Prop | Typ | Default | Beschreibung |
|------|-----|---------|--------------|
| `modelValue` | Boolean | `false` | v-model Binding für Sichtbarkeit |
| `isModal` | Boolean | `true` | Modaler Dialog (mit Overlay) oder nicht-modal |
| `title` | String | - | Dialog-Titel (erforderlich) |
| `size` | String | `'medium'` | Größe: `'small'`, `'medium'`, `'large'`, `'fullscreen'` |
| `position` | Object | `{ x: 100, y: 100 }` | Position für nicht-modale Dialoge |
| `zIndex` | Number | `1000` | z-Index des Dialogs |
| `closable` | Boolean | `true` | Schließen-Button anzeigen |
| `minimizable` | Boolean | `false` | Minimieren-Button anzeigen |
| `draggable` | Boolean | `true` | Dialog verschiebbar (nur nicht-modal) |
| `closeOnOverlay` | Boolean | `true` | Bei Klick auf Overlay schließen (nur modal) |
#### Events
| Event | Parameter | Beschreibung |
|-------|-----------|--------------|
| `update:modelValue` | Boolean | Wird beim Öffnen/Schließen gefeuert |
| `close` | - | Wird beim Schließen gefeuert |
| `minimize` | - | Wird beim Minimieren gefeuert |
| `focus` | - | Wird bei Klick auf nicht-modalen Dialog gefeuert |
| `update:position` | Object | Neue Position nach Verschieben |
#### Slots
| Slot | Beschreibung |
|------|--------------|
| `default` | Dialog-Inhalt |
| `header-actions` | Zusätzliche Aktionen im Header |
| `footer` | Dialog-Footer (optional) |
#### Beispiel: Modaler Dialog
```vue
<template>
<BaseDialog
v-model="isOpen"
title="Mein Dialog"
size="medium"
@close="handleClose"
>
<p>Dialog-Inhalt hier</p>
<template #footer>
<button @click="isOpen = false">Schließen</button>
</template>
</BaseDialog>
</template>
<script>
import { ref } from 'vue';
import BaseDialog from '@/components/BaseDialog.vue';
export default {
components: { BaseDialog },
setup() {
const isOpen = ref(false);
const handleClose = () => {
console.log('Dialog geschlossen');
};
return { isOpen, handleClose };
}
};
</script>
```
#### Beispiel: Nicht-modaler Dialog
```vue
<template>
<BaseDialog
v-model="isOpen"
title="Nicht-modaler Dialog"
:is-modal="false"
:position="position"
@update:position="position = $event"
:draggable="true"
:minimizable="true"
size="medium"
>
<p>Dieser Dialog kann verschoben werden!</p>
</BaseDialog>
</template>
<script>
import { ref, reactive } from 'vue';
import BaseDialog from '@/components/BaseDialog.vue';
export default {
components: { BaseDialog },
setup() {
const isOpen = ref(false);
const position = reactive({ x: 100, y: 100 });
return { isOpen, position };
}
};
</script>
```
## 2. ConfirmDialog.vue
Spezialisierter Dialog für Bestätigungen.
#### Props
| Prop | Typ | Default | Beschreibung |
|------|-----|---------|--------------|
| `modelValue` | Boolean | `false` | v-model Binding |
| `title` | String | `'Bestätigung'` | Dialog-Titel |
| `message` | String | - | Hauptnachricht (erforderlich) |
| `details` | String | `''` | Zusätzliche Details |
| `type` | String | `'info'` | Typ: `'info'`, `'warning'`, `'danger'`, `'success'` |
| `confirmText` | String | `'OK'` | Text für Bestätigen-Button |
| `cancelText` | String | `'Abbrechen'` | Text für Abbrechen-Button |
| `showCancel` | Boolean | `true` | Abbrechen-Button anzeigen |
#### Events
| Event | Beschreibung |
|-------|--------------|
| `confirm` | Wird bei Bestätigung gefeuert |
| `cancel` | Wird bei Abbruch gefeuert |
#### Beispiel
```vue
<template>
<ConfirmDialog
v-model="showConfirm"
title="Löschen bestätigen"
message="Möchten Sie diesen Eintrag wirklich löschen?"
type="danger"
confirm-text="Löschen"
@confirm="handleDelete"
@cancel="handleCancel"
/>
</template>
<script>
import { ref } from 'vue';
import ConfirmDialog from '@/components/ConfirmDialog.vue';
export default {
components: { ConfirmDialog },
setup() {
const showConfirm = ref(false);
const handleDelete = () => {
console.log('Gelöscht');
};
const handleCancel = () => {
console.log('Abgebrochen');
};
return { showConfirm, handleDelete, handleCancel };
}
};
</script>
```
## 3. ImageDialog.vue
Einfacher Dialog zur Anzeige von Bildern.
#### Props
| Prop | Typ | Default | Beschreibung |
|------|-----|---------|--------------|
| `modelValue` | Boolean | `false` | v-model Binding |
| `title` | String | `'Bild'` | Dialog-Titel |
| `imageUrl` | String | `''` | URL des anzuzeigenden Bildes |
#### Events
| Event | Beschreibung |
|-------|--------------|
| `close` | Wird beim Schließen gefeuert |
#### Beispiel
```vue
<template>
<ImageDialog
v-model="showImage"
title="Aktivitätsbild"
:image-url="imageUrl"
/>
</template>
<script>
import { ref } from 'vue';
import ImageDialog from '@/components/ImageDialog.vue';
export default {
components: { ImageDialog },
setup() {
const showImage = ref(false);
const imageUrl = ref('');
return { showImage, imageUrl };
}
};
</script>
```
## 4. ImageViewerDialog.vue
Erweiterter Bild-Dialog mit Aktionen (Drehen, Zoom).
#### Props
| Prop | Typ | Default | Beschreibung |
|------|-----|---------|--------------|
| `modelValue` | Boolean | `false` | v-model Binding |
| `title` | String | `'Bild'` | Dialog-Titel |
| `imageUrl` | String | `''` | URL des anzuzeigenden Bildes |
| `memberId` | Number/String | `null` | ID des zugehörigen Members (optional) |
| `showActions` | Boolean | `true` | Aktions-Buttons anzeigen |
| `allowRotate` | Boolean | `true` | Drehen-Buttons anzeigen |
| `allowZoom` | Boolean | `false` | Zoom-Button anzeigen |
#### Events
| Event | Parameter | Beschreibung |
|-------|-----------|--------------|
| `close` | - | Wird beim Schließen gefeuert |
| `rotate` | Object | Wird beim Drehen gefeuert: `{ direction, memberId, rotation }` |
#### Beispiel
```vue
<template>
<ImageViewerDialog
v-model="showImageModal"
title="Mitgliedsbild"
:image-url="selectedImageUrl"
:member-id="selectedMemberId"
:allow-rotate="true"
@rotate="handleRotate"
/>
</template>
<script>
import { ref } from 'vue';
import ImageViewerDialog from '@/components/ImageViewerDialog.vue';
import apiClient from '@/apiClient.js';
export default {
components: { ImageViewerDialog },
setup() {
const showImageModal = ref(false);
const selectedImageUrl = ref('');
const selectedMemberId = ref(null);
const handleRotate = async (event) => {
const { direction, memberId } = event;
// API-Aufruf zum Drehen des Bildes
await apiClient.post(`/members/${memberId}/rotate`, { direction });
};
return { showImageModal, selectedImageUrl, selectedMemberId, handleRotate };
}
};
</script>
```
## 5. Composables
### useDialog()
Einfaches Composable für Dialog-Verwaltung.
```javascript
import { useDialog } from '@/composables/useDialog.js';
const { isOpen, open, close, toggle } = useDialog();
// Dialog öffnen
open();
// Dialog schließen
close();
// Dialog umschalten
toggle();
```
## useConfirm()
Promise-basiertes Composable für Bestätigungsdialoge.
```javascript
import { useConfirm } from '@/composables/useDialog.js';
const { isOpen, config, confirm, handleConfirm, handleCancel } = useConfirm();
// Im Template:
<ConfirmDialog
v-model="isOpen"
:title="config.title"
:message="config.message"
:type="config.type"
@confirm="handleConfirm"
@cancel="handleCancel"
/>
// In Methoden:
async function deleteItem() {
const confirmed = await confirm({
title: 'Löschen bestätigen',
message: 'Wirklich löschen?',
type: 'danger'
});
if (confirmed) {
// Löschvorgang durchführen
}
}
```
## Dialog-Größen
| Größe | Breite | Beschreibung |
|-------|--------|--------------|
| `small` | 400px | Kleine Dialoge (z.B. Bestätigungen) |
| `medium` | 600px | Standard-Dialoge |
| `large` | 900px | Große Dialoge mit viel Inhalt |
| `fullscreen` | 90vw x 90vh | Fast Fullscreen |
## Best Practices
### Modale Dialoge verwenden für:
- Wichtige Benutzer-Entscheidungen
- Formulare, die Fokus erfordern
- Warnungen und Fehler
- Prozesse, die nicht unterbrochen werden sollten
### Nicht-modale Dialoge verwenden für:
- Zusätzliche Informationen
- Tools und Paletten
- Mehrere gleichzeitige Arbeitsschritte
- Drag & Drop-Workflows
### Tipps
1. **Minimieren-Funktion**: Nur bei nicht-modalen Dialogen sinnvoll
2. **closeOnOverlay**: Bei wichtigen Formularen auf `false` setzen
3. **z-Index**: Bei mehreren nicht-modalen Dialogen unterschiedliche z-Indices verwenden
4. **Footer-Slot**: Für Aktions-Buttons verwenden
5. **header-actions**: Für kontextspezifische Header-Aktionen
## Styling
Die Dialoge verwenden CSS-Variablen für konsistentes Styling:
- `--primary-color`: Primärfarbe für Header und Buttons
- `--primary-hover`: Hover-Farbe
- `--border-color`: Rahmenfarbe
- `--text-color`: Textfarbe
- `--text-muted`: Gedämpfte Textfarbe
## Migration bestehender Dialoge
### Alt (individueller Dialog):
```vue
<div v-if="showDialog" class="modal-overlay" @click.self="closeDialog">
<div class="modal">
<div class="modal-header">
<h3>Titel</h3>
<button @click="closeDialog">×</button>
</div>
<div class="modal-body">
Inhalt
</div>
</div>
</div>
```
### Neu (BaseDialog):
```vue
<BaseDialog v-model="showDialog" title="Titel">
Inhalt
</BaseDialog>
```
## Beispiele ansehen
Die Datei `DialogExamples.vue` enthält vollständige Beispiele für alle Dialog-Typen.
Route hinzufügen in `router.js`:
```javascript
import DialogExamples from './components/DialogExamples.vue';
{ path: '/dialog-examples', component: DialogExamples }
```

View File

@@ -0,0 +1,469 @@
<template>
<div class="dialog-examples">
<h2>Dialog-Beispiele</h2>
<div class="example-section">
<h3>Modale Dialoge</h3>
<div class="button-group">
<button @click="openSimpleModal" class="btn-primary">Einfacher Modal</button>
<button @click="openLargeModal" class="btn-primary">Großer Modal</button>
<button @click="openFullscreenModal" class="btn-primary">Fullscreen Modal</button>
</div>
</div>
<div class="example-section">
<h3>Nicht-modale Dialoge</h3>
<div class="button-group">
<button @click="openNonModalDialog" class="btn-primary">Nicht-modaler Dialog</button>
<button @click="openMultipleDialogs" class="btn-primary">Mehrere Dialoge</button>
</div>
</div>
<div class="example-section">
<h3>Informations-Dialoge</h3>
<div class="button-group">
<button @click="showInfoDialog" class="btn-primary">Info</button>
<button @click="showSuccessDialog" class="btn-success">Erfolg</button>
<button @click="showWarningDialog" class="btn-warning">Warnung</button>
<button @click="showErrorDialog" class="btn-danger">Fehler</button>
</div>
</div>
<div class="example-section">
<h3>Bestätigungs-Dialoge</h3>
<div class="button-group">
<button @click="showInfoConfirm" class="btn-primary">Info</button>
<button @click="showWarningConfirm" class="btn-warning">Warnung</button>
<button @click="showDangerConfirm" class="btn-danger">Löschen</button>
</div>
</div>
<div class="example-section">
<h3>Composable-Verwendung</h3>
<div class="button-group">
<button @click="composableDialog.open()" class="btn-primary">useDialog Beispiel</button>
<button @click="showComposableConfirm" class="btn-primary">useConfirm Beispiel</button>
</div>
</div>
<!-- Einfacher Modal Dialog -->
<BaseDialog
v-model="simpleModal.isOpen"
title="Einfacher Modal Dialog"
size="medium"
>
<p>Dies ist ein einfacher modaler Dialog mit mittlerer Größe.</p>
<p>Klicken Sie außerhalb oder auf das X, um zu schließen.</p>
</BaseDialog>
<!-- Großer Modal Dialog -->
<BaseDialog
v-model="largeModal.isOpen"
title="Großer Modal Dialog"
size="large"
>
<p>Dies ist ein großer modaler Dialog.</p>
<p>Er bietet mehr Platz für Inhalte.</p>
<div style="height: 400px; background: #f5f5f5; margin-top: 1rem; padding: 1rem;">
Scroll-Bereich für viel Inhalt...
</div>
</BaseDialog>
<!-- Fullscreen Modal Dialog -->
<BaseDialog
v-model="fullscreenModal.isOpen"
title="Fullscreen Modal Dialog"
size="fullscreen"
>
<p>Dies ist ein Fullscreen-Dialog.</p>
<p>Er nimmt fast den gesamten Bildschirm ein (90vw x 90vh).</p>
</BaseDialog>
<!-- Nicht-modaler Dialog -->
<BaseDialog
v-model="nonModal.isOpen"
title="Nicht-modaler Dialog"
:is-modal="false"
:position="nonModal.position"
@update:position="nonModal.position = $event"
size="medium"
:draggable="true"
:minimizable="true"
@minimize="handleMinimize('nonModal')"
>
<p>Dies ist ein nicht-modaler Dialog.</p>
<p>Sie können ihn verschieben und mehrere gleichzeitig öffnen!</p>
<template #footer>
<button @click="nonModal.isOpen = false" class="btn-secondary">Schließen</button>
</template>
</BaseDialog>
<!-- Zweiter nicht-modaler Dialog -->
<BaseDialog
v-model="nonModal2.isOpen"
title="Zweiter nicht-modaler Dialog"
:is-modal="false"
:position="nonModal2.position"
@update:position="nonModal2.position = $event"
size="small"
:draggable="true"
:z-index="1001"
>
<p>Noch ein nicht-modaler Dialog!</p>
</BaseDialog>
<!-- Informations-Dialoge -->
<InfoDialog
v-model="infoDialog.isOpen"
title="Information"
message="Dies ist eine Informationsmeldung."
type="info"
@ok="infoDialog.isOpen = false"
/>
<InfoDialog
v-model="successDialog.isOpen"
title="Erfolg"
message="Der Vorgang wurde erfolgreich abgeschlossen!"
details="Alle Änderungen wurden gespeichert."
type="success"
@ok="successDialog.isOpen = false"
/>
<InfoDialog
v-model="warningDialog.isOpen"
title="Warnung"
message="Bitte beachten Sie folgende Hinweise."
details="Einige Felder sind möglicherweise nicht vollständig ausgefüllt."
type="warning"
@ok="warningDialog.isOpen = false"
/>
<InfoDialog
v-model="errorDialog.isOpen"
title="Fehler"
message="Ein Fehler ist aufgetreten."
details="Bitte versuchen Sie es später erneut."
type="error"
@ok="errorDialog.isOpen = false"
/>
<!-- Bestätigungs-Dialoge -->
<ConfirmDialog
v-model="infoConfirm.isOpen"
title="Information"
message="Dies ist eine Informationsmeldung."
type="info"
:show-cancel="false"
@confirm="infoConfirm.isOpen = false"
/>
<ConfirmDialog
v-model="warningConfirm.isOpen"
title="Warnung"
message="Sind Sie sicher, dass Sie fortfahren möchten?"
details="Diese Aktion kann nicht rückgängig gemacht werden."
type="warning"
@confirm="handleWarningConfirm"
@cancel="warningConfirm.isOpen = false"
/>
<ConfirmDialog
v-model="dangerConfirm.isOpen"
title="Löschen bestätigen"
message="Möchten Sie diesen Eintrag wirklich löschen?"
type="danger"
confirm-text="Löschen"
@confirm="handleDelete"
@cancel="dangerConfirm.isOpen = false"
/>
<!-- Composable Dialog -->
<BaseDialog
v-model="composableDialog.isOpen"
title="Dialog mit useDialog Composable"
size="medium"
>
<p>Dieser Dialog verwendet das useDialog Composable.</p>
<p>Das macht die Verwaltung einfacher!</p>
<template #footer>
<button @click="composableDialog.close()" class="btn-primary">Schließen</button>
</template>
</BaseDialog>
<!-- Composable Confirm Dialog -->
<ConfirmDialog
v-model="confirmComposable.isOpen"
:title="confirmComposable.config.title"
:message="confirmComposable.config.message"
:type="confirmComposable.config.type"
@confirm="confirmComposable.handleConfirm"
@cancel="confirmComposable.handleCancel"
/>
<!-- Minimierte Dialoge Anzeige -->
<div v-if="minimizedDialogs.length > 0" class="minimized-section">
<h4>Minimierte Dialoge:</h4>
<button
v-for="(dialog, index) in minimizedDialogs"
:key="index"
@click="restoreDialog(dialog)"
class="minimized-btn"
>
{{ dialog }}
</button>
</div>
</div>
</template>
<script>
import { reactive, ref } from 'vue';
import BaseDialog from './BaseDialog.vue';
import ConfirmDialog from './ConfirmDialog.vue';
import InfoDialog from './InfoDialog.vue';
import { useDialog, useConfirm } from '../composables/useDialog.js';
export default {
name: 'DialogExamples',
components: {
BaseDialog,
ConfirmDialog,
InfoDialog
},
setup() {
// Modale Dialoge
const simpleModal = reactive({ isOpen: false });
const largeModal = reactive({ isOpen: false });
const fullscreenModal = reactive({ isOpen: false });
// Nicht-modale Dialoge
const nonModal = reactive({
isOpen: false,
position: { x: 100, y: 100 }
});
const nonModal2 = reactive({
isOpen: false,
position: { x: 600, y: 150 }
});
// Informations-Dialoge
const infoDialog = reactive({ isOpen: false });
const successDialog = reactive({ isOpen: false });
const warningDialog = reactive({ isOpen: false });
const errorDialog = reactive({ isOpen: false });
// Bestätigungs-Dialoge
const infoConfirm = reactive({ isOpen: false });
const warningConfirm = reactive({ isOpen: false });
const dangerConfirm = reactive({ isOpen: false });
// Composables
const composableDialog = useDialog();
const confirmComposable = useConfirm();
// Minimierte Dialoge
const minimizedDialogs = ref([]);
return {
simpleModal,
largeModal,
fullscreenModal,
nonModal,
nonModal2,
infoDialog,
successDialog,
warningDialog,
errorDialog,
infoConfirm,
warningConfirm,
dangerConfirm,
composableDialog,
confirmComposable,
minimizedDialogs
};
},
methods: {
openSimpleModal() {
this.simpleModal.isOpen = true;
},
openLargeModal() {
this.largeModal.isOpen = true;
},
openFullscreenModal() {
this.fullscreenModal.isOpen = true;
},
openNonModalDialog() {
this.nonModal.isOpen = true;
},
openMultipleDialogs() {
this.nonModal.isOpen = true;
this.nonModal2.isOpen = true;
},
showInfoConfirm() {
this.infoConfirm.isOpen = true;
},
showWarningConfirm() {
this.warningConfirm.isOpen = true;
},
showDangerConfirm() {
this.dangerConfirm.isOpen = true;
},
async showComposableConfirm() {
const result = await this.confirmComposable.confirm({
title: 'useConfirm Beispiel',
message: 'Möchten Sie fortfahren?',
details: 'Dies ist ein Beispiel für das useConfirm Composable.',
type: 'info'
});
if (result) {
alert('Bestätigt!');
} else {
alert('Abgebrochen!');
}
},
handleWarningConfirm() {
console.log('Warnung bestätigt');
this.warningConfirm.isOpen = false;
},
handleDelete() {
console.log('Eintrag gelöscht');
this.dangerConfirm.isOpen = false;
},
handleMinimize(dialogName) {
this.minimizedDialogs.push(dialogName);
this[dialogName].isOpen = false;
},
restoreDialog(dialogName) {
this[dialogName].isOpen = true;
const index = this.minimizedDialogs.indexOf(dialogName);
if (index > -1) {
this.minimizedDialogs.splice(index, 1);
}
}
}
};
</script>
<style scoped>
.dialog-examples {
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
}
.example-section {
margin-bottom: 2rem;
padding: 1rem;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.example-section h3 {
margin-top: 0;
margin-bottom: 1rem;
color: var(--primary-color);
}
.button-group {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.btn-primary,
.btn-secondary,
.btn-warning,
.btn-danger {
padding: 0.5rem 1.5rem;
border: none;
border-radius: 4px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-primary {
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
color: white;
}
.btn-primary:hover {
opacity: 0.9;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #5a6268;
}
.btn-warning {
background: #ffc107;
color: #212529;
}
.btn-warning:hover {
background: #e0a800;
}
.btn-danger {
background: #dc3545;
color: white;
}
.btn-danger:hover {
background: #c82333;
}
.minimized-section {
position: fixed;
bottom: 2rem;
right: 2rem;
background: white;
padding: 1rem;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.minimized-section h4 {
margin-top: 0;
margin-bottom: 0.5rem;
font-size: 0.875rem;
}
.minimized-btn {
display: block;
margin-bottom: 0.5rem;
padding: 0.5rem 1rem;
background: var(--primary-color);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
}
.minimized-btn:hover {
background: var(--primary-hover);
}
</style>

View File

@@ -0,0 +1,370 @@
<template>
<div class="dialog-manager">
<!-- Minimierte Dialoge in der Fußzeile -->
<div class="minimized-dialogs">
<button
v-for="dialog in minimizedDialogs"
:key="dialog.id"
@click="restoreDialog(dialog.id)"
class="minimized-dialog-button"
:title="dialog.title"
>
{{ dialog.title }}
</button>
<div v-if="minimizedDialogs.length === 0" class="no-minimized-dialogs">
Keine minimierten Dialoge
</div>
</div>
<!-- Aktive Dialoge -->
<div
v-for="dialog in activeDialogs"
:key="dialog.id"
class="dialog-window"
:style="{
left: dialog.position.x + 'px',
top: dialog.position.y + 'px',
zIndex: dialog.zIndex
}"
@mousedown="bringToFront(dialog.id)"
>
<div class="dialog-header" @mousedown="startDrag(dialog.id, $event)">
<h3 class="dialog-title">{{ dialog.title }}</h3>
<div class="dialog-header-actions">
<component
v-if="dialog.headerActions"
:is="getDialogComponent(dialog.headerActions.component)"
v-bind="dialog.headerActions.props"
:dialog-id="dialog.id"
@action="handleHeaderAction"
/>
</div>
<div class="dialog-controls">
<button @click="minimizeDialog(dialog.id)" class="control-btn minimize-btn" title="Minimieren"></button>
<button @click="closeDialog(dialog.id)" class="control-btn close-btn" title="Schließen">×</button>
</div>
</div>
<div class="dialog-content">
<component :is="getDialogComponent(dialog.component)" v-bind="dialog.props" @close="() => closeDialog(dialog.id)" />
</div>
</div>
</div>
</template>
<script>
import { mapGetters, mapActions } from 'vuex';
import MatchReportDialog from './MatchReportDialog.vue';
import MatchReportHeaderActions from './MatchReportHeaderActions.vue';
export default {
components: {
MatchReportDialog,
MatchReportHeaderActions
},
name: 'DialogManager',
computed: {
...mapGetters(['dialogs', 'minimizedDialogs', 'activeDialogs'])
},
methods: {
...mapActions(['closeDialog', 'minimizeDialog', 'restoreDialog', 'bringDialogToFront']),
getDialogComponent(componentName) {
const components = {
'MatchReportDialog': MatchReportDialog,
'MatchReportHeaderActions': MatchReportHeaderActions
};
return components[componentName] || null;
},
bringToFront(dialogId) {
this.bringDialogToFront(dialogId);
},
startDrag(dialogId, event) {
// Drag & Drop deaktiviert, da Dialog jetzt fest positioniert ist
// Dialog wird immer zentriert angezeigt
this.bringToFront(dialogId);
},
handleHeaderAction(action) {
// Finde den Dialog und die entsprechende Komponente
const dialog = this.dialogs.find(d => d.id === action.dialogId);
if (!dialog) return;
// Behandle die verschiedenen Action-Types
if (action.type === 'insertPin') {
// PIN ins iframe einfügen
this.insertPinIntoIframe(action.match);
}
},
insertPinIntoIframe(match) {
console.log('🔍 PIN-Einfügen gestartet für Match:', match);
console.log('📌 Verfügbare PINs:', {
homePin: match.homePin,
guestPin: match.guestPin
});
// Versuche direkten Zugriff auf die MatchReportDialog-Komponente
const matchReportDialogs = document.querySelectorAll('.match-report-dialog');
console.log('🖼️ Gefundene MatchReportDialogs:', matchReportDialogs.length);
if (matchReportDialogs.length > 0) {
// Versuche die insertPinManually Methode aufzurufen
console.log('🎯 Versuche direkten Zugriff auf MatchReportDialog');
// Finde das iframe im aktuellen Dialog
const iframe = matchReportDialogs[matchReportDialogs.length - 1].querySelector('iframe');
if (iframe) {
console.log('✅ Iframe gefunden, versuche PIN-Einfügung');
try {
const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
if (iframeDoc) {
console.log('✅ Direkter DOM-Zugriff möglich');
// Suche nach PIN-Feldern
const pinSelectors = [
'input[type="password"][placeholder*="Vereins"]',
'input[type="password"][placeholder*="Spiel-Pin"]',
'input[type="password"][placeholder*="PIN"]',
'input[type="password"]',
'input[placeholder*="Vereins"]',
'input[placeholder*="Spiel-Pin"]'
];
let pinField = null;
for (const selector of pinSelectors) {
pinField = iframeDoc.querySelector(selector);
if (pinField) {
console.log(`✅ PIN-Feld gefunden mit Selektor: ${selector}`);
break;
}
}
if (pinField && match.homePin) {
console.log('📝 Füge PIN ein:', match.homePin);
pinField.value = match.homePin;
pinField.dispatchEvent(new Event('input', { bubbles: true }));
pinField.dispatchEvent(new Event('change', { bubbles: true }));
pinField.dispatchEvent(new Event('blur', { bubbles: true }));
console.log('✅ PIN erfolgreich eingefügt');
return;
} else {
console.log('❌ PIN-Feld nicht gefunden');
console.log('🔍 Verfügbare Input-Felder:', iframeDoc.querySelectorAll('input'));
}
}
} catch (error) {
console.log('🚫 Cross-Origin-Zugriff blockiert (erwartet)');
}
}
}
// Fallback: PostMessage verwenden
const iframes = document.querySelectorAll('iframe');
if (iframes.length > 0) {
const iframe = iframes[iframes.length - 1]; // Neuestes iframe
const message = {
action: 'fillPin',
pin: match.homePin,
timestamp: Date.now(),
source: 'trainingstagebuch'
};
console.log('📤 Sende PostMessage:', message);
const origins = ['https://ttde-apps.liga.nu', 'https://liga.nu', '*'];
origins.forEach(origin => {
try {
iframe.contentWindow.postMessage(message, origin);
console.log(`📤 PostMessage an ${origin} gesendet`);
} catch (e) {
console.log(`❌ PostMessage an ${origin} fehlgeschlagen:`, e.message);
}
});
}
console.log('💡 Alternative: Verwenden Sie den "📋 PIN kopieren" Button');
console.log('📋 PIN zum Kopieren:', match.homePin || match.guestPin);
},
handlePostMessage(event) {
console.log('📨 PostMessage empfangen:', event);
console.log('- Origin:', event.origin);
console.log('- Data:', event.data);
// Nur Nachrichten von nuscore verarbeiten
if (event.origin !== 'https://ttde-apps.liga.nu' && event.origin !== 'https://liga.nu') {
console.log('🚫 Nachricht von unbekannter Origin ignoriert');
return;
}
// Hier können wir auf Antworten von nuscore reagieren
if (event.data && event.data.action) {
console.log('🎯 Action empfangen:', event.data.action);
if (event.data.action === 'pinFilled') {
console.log('✅ PIN wurde erfolgreich eingefügt');
} else if (event.data.action === 'pinError') {
console.log('❌ Fehler beim PIN-Einfügen:', event.data.error);
}
}
}
},
mounted() {
// Event-Listener für PostMessage-Antworten
window.addEventListener('message', this.handlePostMessage);
},
beforeUnmount() {
// Event-Listener entfernen
window.removeEventListener('message', this.handlePostMessage);
}
};
</script>
<style scoped>
.dialog-manager {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 1000;
}
.dialog-window {
position: absolute;
width: 90vw;
height: 90vh;
background: white;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
pointer-events: auto;
display: flex;
flex-direction: column;
overflow: hidden;
left: 5vw !important;
top: 5vh !important;
}
.dialog-header {
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
color: white;
padding: 12px 16px;
display: flex;
justify-content: space-between;
align-items: center;
cursor: move;
user-select: none;
}
.dialog-title {
margin: 0;
font-size: 1rem;
font-weight: 600;
flex: 1;
}
.dialog-header-actions {
display: flex;
align-items: center;
}
.dialog-controls {
display: flex;
gap: 8px;
}
.control-btn {
width: 24px;
height: 24px;
border: none;
border-radius: 4px;
background: rgba(255, 255, 255, 0.2);
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: bold;
transition: background-color 0.2s ease;
}
.control-btn:hover {
background: rgba(255, 255, 255, 0.3);
}
.close-btn:hover {
background: #dc3545;
}
.dialog-content {
flex: 1;
padding: 16px;
overflow-y: auto;
}
.minimized-dialogs {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(135deg, rgba(160, 112, 64, 0.95), rgba(128, 75, 41, 0.95));
padding: 4px 16px;
display: flex;
gap: 8px;
z-index: 2000;
pointer-events: auto;
min-height: 24px;
align-items: center;
}
.no-minimized-dialogs {
color: rgba(255, 255, 255, 0.6);
font-size: 0.875rem;
font-style: italic;
}
.minimized-dialog-button {
background: var(--primary-color);
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
transition: background-color 0.2s ease;
white-space: nowrap;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
}
.minimized-dialog-button:hover {
background: var(--primary-hover);
}
/* Responsive Design */
@media (max-width: 768px) {
.dialog-window {
width: 95vw;
height: 95vh;
left: 2.5vw !important;
top: 2.5vh !important;
}
.minimized-dialogs {
flex-wrap: wrap;
}
.minimized-dialog-button {
max-width: 150px;
}
}
</style>

View File

@@ -0,0 +1,125 @@
<template>
<BaseDialog
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
:title="title"
:is-modal="true"
size="large"
:closable="true"
:close-on-overlay="true"
@close="handleClose"
>
<!-- Image Content -->
<div class="image-dialog-content">
<img
v-if="imageUrl"
:src="imageUrl"
:alt="title"
class="dialog-image"
/>
<div v-else class="no-image">
Kein Bild verfügbar
</div>
<!-- Optionale zusätzliche Inhalte -->
<div v-if="$slots.default" class="image-extra-content">
<slot></slot>
</div>
</div>
<!-- Footer mit optionalen Aktionen -->
<template #footer>
<slot name="actions">
<button @click="handleClose" class="btn-secondary">
Schließen
</button>
</slot>
</template>
</BaseDialog>
</template>
<script>
import BaseDialog from './BaseDialog.vue';
export default {
name: 'ImageDialog',
components: {
BaseDialog
},
props: {
modelValue: {
type: Boolean,
default: false
},
title: {
type: String,
default: 'Bild'
},
imageUrl: {
type: String,
default: ''
}
},
emits: ['update:modelValue', 'close'],
methods: {
handleClose() {
this.$emit('update:modelValue', false);
this.$emit('close');
}
}
};
</script>
<style scoped>
.image-dialog-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.dialog-image {
max-width: 100%;
max-height: 70vh;
object-fit: contain;
border-radius: 4px;
}
.no-image {
padding: 2rem;
text-align: center;
color: var(--text-muted);
font-style: italic;
}
.image-extra-content {
width: 100%;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color);
}
.btn-secondary {
padding: 0.5rem 1.5rem;
border: none;
border-radius: 4px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #5a6268;
}
/* Responsive */
@media (max-width: 768px) {
.dialog-image {
max-height: 60vh;
}
}
</style>

View File

@@ -0,0 +1,259 @@
<template>
<BaseDialog
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
:title="title"
:is-modal="true"
size="large"
:closable="true"
:close-on-overlay="true"
@close="handleClose"
>
<!-- Image Content -->
<div class="image-viewer-content">
<div class="image-container">
<img
v-if="imageUrl"
:src="imageUrl"
:alt="title"
class="viewer-image"
:style="imageStyle"
/>
<div v-else class="no-image">
Kein Bild verfügbar
</div>
</div>
<!-- Bild-Aktionen -->
<div v-if="showActions && imageUrl" class="image-actions">
<button
v-if="allowRotate"
@click="rotateLeft"
class="action-btn"
title="90° links drehen"
>
Links drehen
</button>
<button
v-if="allowRotate"
@click="rotateRight"
class="action-btn"
title="90° rechts drehen"
>
Rechts drehen
</button>
<button
v-if="allowZoom"
@click="resetZoom"
class="action-btn"
title="Zoom zurücksetzen"
>
🔍 Zoom zurücksetzen
</button>
</div>
<!-- Zusätzliche Inhalte -->
<div v-if="$slots.default" class="extra-content">
<slot></slot>
</div>
</div>
<!-- Footer -->
<template #footer>
<slot name="footer">
<button @click="handleClose" class="btn-secondary">
Schließen
</button>
</slot>
</template>
</BaseDialog>
</template>
<script>
import BaseDialog from './BaseDialog.vue';
export default {
name: 'ImageViewerDialog',
components: {
BaseDialog
},
props: {
modelValue: {
type: Boolean,
default: false
},
title: {
type: String,
default: 'Bild'
},
imageUrl: {
type: String,
default: ''
},
memberId: {
type: [Number, String],
default: null
},
showActions: {
type: Boolean,
default: true
},
allowRotate: {
type: Boolean,
default: true
},
allowZoom: {
type: Boolean,
default: false
}
},
emits: ['update:modelValue', 'close', 'rotate'],
data() {
return {
scale: 1
};
},
computed: {
imageStyle() {
return {
transform: `scale(${this.scale})`
};
}
},
methods: {
handleClose() {
this.scale = 1;
this.$emit('update:modelValue', false);
this.$emit('close');
},
rotateLeft() {
// Emit rotate event - das Bild wird auf dem Server gedreht
// und dann neu geladen, daher keine lokale Rotation
this.$emit('rotate', {
direction: 'left',
memberId: this.memberId
});
},
rotateRight() {
// Emit rotate event - das Bild wird auf dem Server gedreht
// und dann neu geladen, daher keine lokale Rotation
this.$emit('rotate', {
direction: 'right',
memberId: this.memberId
});
},
resetZoom() {
this.scale = 1;
}
},
watch: {
modelValue(newVal) {
if (!newVal) {
// Dialog geschlossen - Reset
this.scale = 1;
}
}
}
};
</script>
<style scoped>
.image-viewer-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.image-container {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
background: #f5f5f5;
border-radius: 4px;
overflow: hidden;
}
.viewer-image {
max-width: 100%;
max-height: 60vh;
object-fit: contain;
transition: transform 0.3s ease;
}
.no-image {
padding: 2rem;
text-align: center;
color: var(--text-muted);
font-style: italic;
}
.image-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: center;
}
.action-btn {
padding: 0.5rem 1rem;
border: 1px solid var(--border-color);
border-radius: 4px;
background: white;
color: var(--text-color);
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s ease;
}
.action-btn:hover {
background: var(--primary-light);
border-color: var(--primary-color);
color: var(--primary-color);
}
.extra-content {
width: 100%;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color);
}
.btn-secondary {
padding: 0.5rem 1.5rem;
border: none;
border-radius: 4px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #5a6268;
}
/* Responsive */
@media (max-width: 768px) {
.viewer-image {
max-height: 50vh;
}
.image-actions {
flex-direction: column;
width: 100%;
}
.action-btn {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,229 @@
<template>
<BaseDialog
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
:title="title"
:is-modal="true"
:size="size"
:closable="true"
:close-on-overlay="closeOnOverlay"
@close="handleClose"
>
<!-- Content -->
<div class="info-content">
<div v-if="icon" class="info-icon" :class="`icon-${type}`">
{{ computedIcon }}
</div>
<div class="info-message">
{{ message }}
</div>
<div v-if="details" class="info-details">
{{ details }}
</div>
<div v-if="$slots.default" class="info-extra">
<slot></slot>
</div>
</div>
<!-- Footer mit OK-Button -->
<template #footer>
<button
@click="handleOk"
:class="buttonClass"
>
{{ okText }}
</button>
</template>
</BaseDialog>
</template>
<script>
import BaseDialog from './BaseDialog.vue';
export default {
name: 'InfoDialog',
components: {
BaseDialog
},
props: {
modelValue: {
type: Boolean,
default: false
},
title: {
type: String,
default: 'Information'
},
message: {
type: String,
required: true
},
details: {
type: String,
default: ''
},
type: {
type: String,
default: 'info',
validator: (value) => ['info', 'success', 'warning', 'error'].includes(value)
},
icon: {
type: [String, Boolean],
default: true
},
okText: {
type: String,
default: 'OK'
},
size: {
type: String,
default: 'small'
},
closeOnOverlay: {
type: Boolean,
default: true
}
},
computed: {
computedIcon() {
// Wenn ein eigenes Icon übergeben wurde
if (typeof this.icon === 'string') {
return this.icon;
}
// Wenn icon=false, kein Icon
if (this.icon === false) {
return null;
}
// Standard-Icons je nach Typ
const icons = {
info: '',
success: '✅',
warning: '⚠️',
error: '⛔'
};
return icons[this.type] || icons.info;
},
buttonClass() {
const classes = {
info: 'btn-primary',
success: 'btn-success',
warning: 'btn-warning',
error: 'btn-danger'
};
return classes[this.type] || 'btn-primary';
}
},
methods: {
handleOk() {
this.$emit('ok');
this.handleClose();
},
handleClose() {
this.$emit('update:modelValue', false);
this.$emit('close');
}
}
};
</script>
<style scoped>
.info-content {
text-align: center;
padding: 1rem 0;
}
.info-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.icon-info {
color: #17a2b8;
}
.icon-success {
color: var(--primary-color);
}
.icon-warning {
color: #ffc107;
}
.icon-error {
color: #dc3545;
}
.info-message {
font-size: 1rem;
font-weight: 500;
color: var(--text-color);
margin-bottom: 0.5rem;
}
.info-details {
font-size: 0.875rem;
color: var(--text-muted);
margin-top: 0.5rem;
line-height: 1.5;
}
.info-extra {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color);
text-align: left;
}
.btn-primary,
.btn-success,
.btn-warning,
.btn-danger {
padding: 0.5rem 1.5rem;
border: none;
border-radius: 4px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
min-width: 100px;
}
.btn-primary {
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
color: white;
}
.btn-primary:hover {
opacity: 0.9;
}
.btn-success {
background: var(--primary-color);
color: white;
}
.btn-success:hover {
background: var(--primary-hover);
}
.btn-warning {
background: #ffc107;
color: #212529;
}
.btn-warning:hover {
background: #e0a800;
}
.btn-danger {
background: #dc3545;
color: white;
}
.btn-danger:hover {
background: #c82333;
}
</style>

View File

@@ -0,0 +1,425 @@
<template>
<div class="match-report-dialog">
<div class="report-content">
<iframe
ref="reportIframe"
:src="reportUrl"
width="100%"
height="100%"
frameborder="0"
class="report-iframe"
@load="onIframeLoad"
></iframe>
</div>
</div>
</template>
<script>
export default {
name: 'MatchReportDialog',
props: {
match: {
type: Object,
required: true
}
},
computed: {
reportUrl() {
// Verschiedene URL-Parameter versuchen, die nuscore möglicherweise unterstützt
const baseUrl = 'https://ttde-apps.liga.nu/nuliga/nuscore-tt/meetings-list';
const params = new URLSearchParams();
// Verschiedene Parameter-Namen versuchen
params.set('code', this.match.code);
params.set('gamecode', this.match.code);
params.set('spielcode', this.match.code);
params.set('matchcode', this.match.code);
return `${baseUrl}?${params.toString()}`;
}
},
mounted() {
// Event-Listener für PostMessage-Antworten
window.addEventListener('message', this.handlePostMessage);
},
beforeUnmount() {
// Event-Listener entfernen
window.removeEventListener('message', this.handlePostMessage);
// URL-Überwachung stoppen
if (this.urlCheckInterval) {
clearInterval(this.urlCheckInterval);
}
},
methods: {
formatDate(dateString) {
if (!dateString) return 'N/A';
const date = new Date(dateString);
return date.toLocaleDateString('de-DE', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
},
onIframeLoad() {
console.log('🔄 Iframe geladen, URL:', this.$refs.reportIframe?.src);
// Warte kurz, damit das iframe vollständig geladen ist
setTimeout(() => {
this.injectContentScript();
}, 2000);
// Überwache URL-Änderungen im iframe
this.startUrlMonitoring();
},
injectContentScript() {
try {
const iframe = this.$refs.reportIframe;
if (!iframe || !iframe.contentWindow) {
console.log('Iframe noch nicht bereit für Content Script');
return;
}
// Content Script als String definieren
const contentScript = `
(function() {
console.log('Content Script geladen');
// Warte bis die Seite vollständig geladen ist
function waitForElement(selector, callback) {
const element = document.querySelector(selector);
if (element) {
callback(element);
} else {
setTimeout(() => waitForElement(selector, callback), 100);
}
}
// Suche nach dem Input-Feld
waitForElement('#gamecode', function(input) {
console.log('Input-Feld gefunden:', input);
// Code einfügen
input.value = '${this.match.code}';
// Events auslösen
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
input.dispatchEvent(new Event('blur', { bubbles: true }));
console.log('Code eingefügt:', '${this.match.code}');
// Suche nach dem Button und klicke ihn
setTimeout(() => {
const button = document.querySelector('button.btn-primary');
if (button) {
console.log('Button gefunden, klicke ihn');
button.click();
} else {
console.log('Button nicht gefunden');
}
}, 500);
});
})();
`;
// Script in das iframe injizieren
const script = iframe.contentDocument.createElement('script');
script.textContent = contentScript;
iframe.contentDocument.head.appendChild(script);
console.log('Content Script injiziert');
} catch (error) {
console.log('Fehler beim Injizieren des Content Scripts:', error);
// Fallback zu PostMessage
this.tryPostMessage();
}
},
fillGameCode() {
try {
const iframe = this.$refs.reportIframe;
if (!iframe || !iframe.contentWindow) {
console.log('Iframe noch nicht bereit');
return;
}
// Versuche, das Input-Feld zu finden und zu füllen
const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
if (iframeDoc) {
const gameCodeInput = iframeDoc.getElementById('gamecode');
if (gameCodeInput) {
// Code in das Input-Feld einfügen
gameCodeInput.value = this.match.code;
// Event auslösen, damit Angular die Änderung erkennt
gameCodeInput.dispatchEvent(new Event('input', { bubbles: true }));
gameCodeInput.dispatchEvent(new Event('change', { bubbles: true }));
console.log('Spielcode erfolgreich eingefügt:', this.match.code);
// Optional: Automatisch den Button klicken
setTimeout(() => {
this.clickLoadButton(iframeDoc);
}, 500);
} else {
console.log('Input-Feld mit ID "gamecode" nicht gefunden');
}
} else {
console.log('Kein Zugriff auf iframe-Dokument (Cross-Origin)');
// Fallback: PostMessage verwenden
this.tryPostMessage();
}
} catch (error) {
console.log('Fehler beim Zugriff auf iframe:', error);
// Fallback: PostMessage verwenden
this.tryPostMessage();
}
},
clickLoadButton(iframeDoc) {
try {
// Suche nach dem Button (verschiedene Selektoren)
const buttonSelectors = [
'button.btn-primary',
'button[type="button"]',
'button:contains("Laden")'
];
let loadButton = null;
for (const selector of buttonSelectors) {
loadButton = iframeDoc.querySelector(selector);
if (loadButton) break;
}
if (loadButton) {
loadButton.click();
console.log('Laden-Button erfolgreich geklickt');
} else {
console.log('Laden-Button nicht gefunden');
}
} catch (error) {
console.log('Fehler beim Klicken des Buttons:', error);
}
},
tryPostMessage() {
try {
const iframe = this.$refs.reportIframe;
if (iframe && iframe.contentWindow) {
// Sende Nachricht an das iframe
iframe.contentWindow.postMessage({
action: 'fillGameCode',
code: this.match.code
}, 'https://ttde-apps.liga.nu');
console.log('PostMessage gesendet mit Code:', this.match.code);
}
} catch (error) {
console.log('Fehler beim Senden der PostMessage:', error);
}
},
startUrlMonitoring() {
console.log('🔍 Starte URL-Überwachung für iframe');
console.log('💡 Hinweis: PIN-Einfügung funktioniert am besten nach der Code-Eingabe und Weiterleitung');
console.log('📋 Verwenden Sie den "📌 PIN einfügen" Button nach der Weiterleitung zur Meeting-Seite');
// Einfache Überwachung ohne Cross-Origin-Zugriff
this.urlCheckInterval = setInterval(() => {
const iframe = this.$refs.reportIframe;
if (iframe) {
console.log('🔗 Iframe aktiv, bereit für PIN-Einfügung');
}
}, 10000); // Alle 10 Sekunden
// Stoppe Überwachung nach 60 Sekunden
setTimeout(() => {
if (this.urlCheckInterval) {
clearInterval(this.urlCheckInterval);
console.log('⏰ URL-Überwachung beendet (Timeout)');
}
}, 60000);
},
extractMeetingId(url) {
const match = url.match(/\/meeting\/([a-f0-9-]+)\//);
return match ? match[1] : null;
},
attemptPinInsertionAfterRedirect() {
console.log('🎯 Versuche PIN-Einfügung (manuell ausgelöst)');
const iframe = this.$refs.reportIframe;
if (!iframe || !this.match.homePin) {
console.log('❌ Iframe oder PIN nicht verfügbar');
return;
}
try {
const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
// Suche nach PIN-Feldern auf der Meeting-Seite
const pinSelectors = [
'input[type="password"][placeholder*="Vereins"]',
'input[type="password"][placeholder*="Spiel-Pin"]',
'input[type="password"][placeholder*="PIN"]',
'input[type="password"]',
'input[placeholder*="Vereins"]',
'input[placeholder*="Spiel-Pin"]'
];
let pinField = null;
for (const selector of pinSelectors) {
pinField = iframeDoc.querySelector(selector);
if (pinField) {
console.log(`✅ PIN-Feld gefunden mit Selektor: ${selector}`);
break;
}
}
if (pinField) {
console.log('📝 Füge PIN ein:', this.match.homePin);
pinField.value = this.match.homePin;
pinField.dispatchEvent(new Event('input', { bubbles: true }));
pinField.dispatchEvent(new Event('change', { bubbles: true }));
pinField.dispatchEvent(new Event('blur', { bubbles: true }));
console.log('✅ PIN erfolgreich eingefügt');
} else {
console.log('❌ PIN-Feld nicht gefunden');
console.log('🔍 Verfügbare Input-Felder:', iframeDoc.querySelectorAll('input'));
}
} catch (error) {
console.log('🚫 Cross-Origin-Zugriff blockiert (erwartet)');
// Fallback: PostMessage
const message = {
action: 'fillPin',
pin: this.match.homePin,
timestamp: Date.now(),
source: 'trainingstagebuch'
};
console.log('📤 Sende PostMessage:', message);
iframe.contentWindow.postMessage(message, '*');
}
},
// Methode, die vom DialogManager aufgerufen werden kann
insertPinManually() {
console.log('🎯 PIN-Einfügung manuell ausgelöst');
this.attemptPinInsertionAfterRedirect();
},
handlePostMessage(event) {
// Nur Nachrichten von nuscore verarbeiten
if (event.origin !== 'https://ttde-apps.liga.nu') {
return;
}
console.log('PostMessage empfangen:', event.data);
// Hier können wir auf Antworten von nuscore reagieren
if (event.data.action === 'codeFilled') {
console.log('Code wurde erfolgreich eingefügt');
}
}
}
};
</script>
<style scoped>
.match-report-dialog {
display: flex;
flex-direction: column;
height: 100%;
margin: 0;
padding: 0;
}
.match-info {
background: #f8f9fa;
padding: 16px;
border-radius: 8px;
margin-bottom: 16px;
}
.match-info h4 {
margin: 0 0 12px 0;
color: #007bff;
font-size: 1.1rem;
}
.match-info p {
margin: 4px 0;
font-size: 0.9rem;
color: #555;
}
.code-display {
font-family: 'Courier New', monospace;
background: #e3f2fd;
padding: 2px 6px;
border-radius: 4px;
margin-right: 8px;
}
.copy-btn {
background: none;
border: none;
cursor: pointer;
font-size: 1.2rem;
padding: 2px 4px;
border-radius: 4px;
transition: background-color 0.2s ease;
}
.copy-btn:hover {
background: #f0f0f0;
}
.instructions {
background: #f8f9fa;
padding: 12px;
border-radius: 6px;
margin-top: 12px;
border-left: 4px solid #007bff;
}
.instructions p {
margin: 0 0 8px 0;
font-weight: 600;
color: #007bff;
}
.instructions ol {
margin: 0;
padding-left: 20px;
}
.instructions li {
margin: 4px 0;
font-size: 0.9rem;
color: #555;
}
.report-content {
flex: 1;
margin: 0;
padding: 0;
overflow: hidden;
}
.report-iframe {
width: 100%;
height: 100%;
border: none;
margin: 0;
padding: 0;
}
</style>

View File

@@ -0,0 +1,107 @@
<template>
<div class="match-report-header-actions">
<!-- <button @click="insertPin" class="header-action-btn" title="PIN automatisch einfügen">
📌 PIN einfügen
</button>-->
<button @click="copyPin" class="header-action-btn copy-button" title="PIN in Zwischenablage kopieren">
📋 PIN kopieren
</button>
</div>
</template>
<script>
export default {
name: 'MatchReportHeaderActions',
props: {
dialogId: {
type: Number,
required: true
},
match: {
type: Object,
required: true
}
},
methods: {
insertPin() {
this.$emit('action', {
type: 'insertPin',
dialogId: this.dialogId,
match: this.match
});
},
async copyPin() {
const pin = this.match.homePin || this.match.guestPin;
if (!pin) {
console.warn('⚠️ Keine PIN verfügbar zum Kopieren');
return;
}
try {
await navigator.clipboard.writeText(pin);
console.log('✅ PIN erfolgreich kopiert:', pin);
// Visuelles Feedback
const button = event.target;
const originalText = button.textContent;
button.textContent = '✅ Kopiert!';
button.style.backgroundColor = '#28a745';
setTimeout(() => {
button.textContent = originalText;
button.style.backgroundColor = '';
}, 2000);
} catch (error) {
console.error('❌ Fehler beim Kopieren der PIN:', error);
// Fallback: Text-Auswahl
const textArea = document.createElement('textarea');
textArea.value = pin;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
console.log('✅ PIN über Fallback kopiert:', pin);
}
}
}
};
</script>
<style scoped>
.match-report-header-actions {
display: flex;
gap: 8px;
margin-right: 16px;
}
.header-action-btn {
background: rgba(255, 255, 255, 0.2);
color: white;
border: 1px solid rgba(255, 255, 255, 0.3);
padding: 4px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
transition: background-color 0.2s ease;
white-space: nowrap;
margin-right: 8px;
}
.header-action-btn:hover {
background: rgba(255, 255, 255, 0.3);
}
.copy-button {
background: rgba(0, 123, 255, 0.2);
border-color: rgba(0, 123, 255, 0.3);
}
.copy-button:hover {
background: rgba(0, 123, 255, 0.3);
}
</style>

View File

@@ -0,0 +1,249 @@
<template>
<BaseDialog
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
:title="`Notizen für ${member ? member.firstName + ' ' + member.lastName : ''}`"
size="large"
@close="handleClose"
>
<div v-if="member" class="notes-modal-content">
<div class="notes-header-info">
Telefon-Nr.: {{ member.phone }}
</div>
<div class="notes-body">
<div class="notes-left">
<img v-if="member.imageUrl" :src="member.imageUrl" alt="Mitgliedsbild"
class="member-image" />
</div>
<div class="notes-right">
<div class="form-group">
<label>Tags</label>
<multiselect
v-model="localSelectedTags"
:options="availableTags"
placeholder="Tags auswählen"
label="name"
track-by="id"
multiple
:close-on-select="false"
@tag="$emit('add-tag', $event)"
@remove="$emit('remove-tag', $event)"
:allow-empty="false"
/>
</div>
<div class="form-group">
<label>Neue Notiz</label>
<textarea v-model="localNoteContent" placeholder="Neue Notiz" rows="4" class="note-textarea"></textarea>
<button @click="handleAddNote" class="btn-primary">Hinzufügen</button>
</div>
<div class="notes-list">
<h4>Notizen</h4>
<ul>
<li v-for="note in notes" :key="note.id" class="note-item">
<button @click="$emit('delete-note', note.id)" class="trash-btn">🗑</button>
<span class="note-content">{{ note.content }}</span>
</li>
</ul>
</div>
</div>
</div>
</div>
</BaseDialog>
</template>
<script>
import BaseDialog from './BaseDialog.vue';
import Multiselect from 'vue-multiselect';
export default {
name: 'MemberNotesDialog',
components: {
BaseDialog,
Multiselect
},
props: {
modelValue: {
type: Boolean,
default: false
},
member: {
type: Object,
default: null
},
notes: {
type: Array,
default: () => []
},
selectedTags: {
type: Array,
default: () => []
},
availableTags: {
type: Array,
default: () => []
},
noteContent: {
type: String,
default: ''
}
},
emits: ['update:modelValue', 'close', 'add-note', 'delete-note', 'add-tag', 'remove-tag', 'update:noteContent', 'update:selectedTags'],
data() {
return {
localNoteContent: this.noteContent,
localSelectedTags: this.selectedTags
};
},
watch: {
noteContent(newVal) {
this.localNoteContent = newVal;
},
selectedTags(newVal) {
this.localSelectedTags = newVal;
},
localNoteContent(newVal) {
this.$emit('update:noteContent', newVal);
},
localSelectedTags(newVal) {
this.$emit('update:selectedTags', newVal);
}
},
methods: {
handleClose() {
this.$emit('update:modelValue', false);
this.$emit('close');
},
handleAddNote() {
this.$emit('add-note', this.localNoteContent);
this.localNoteContent = '';
}
}
};
</script>
<style scoped>
.notes-modal-content {
display: flex;
flex-direction: column;
gap: 1rem;
}
.notes-header-info {
padding: 0.5rem;
background: var(--background-light);
border-radius: 4px;
font-size: 0.9rem;
color: var(--text-muted);
}
.notes-body {
display: flex;
gap: 1.5rem;
}
.notes-left {
flex-shrink: 0;
}
.member-image {
width: 250px;
height: 250px;
object-fit: cover;
border-radius: 8px;
border: 1px solid var(--border-color);
}
.notes-right {
flex: 1;
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group label {
font-weight: 600;
color: var(--text-color);
}
.note-textarea {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--border-color);
border-radius: 4px;
font-family: inherit;
resize: vertical;
}
.notes-list h4 {
margin: 0 0 0.5rem 0;
font-size: 1rem;
}
.notes-list ul {
list-style: none;
padding: 0;
margin: 0;
}
.note-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
margin-bottom: 0.5rem;
background: var(--background-light);
border-radius: 4px;
}
.note-content {
flex: 1;
}
.trash-btn {
background: none;
border: none;
cursor: pointer;
font-size: 1.2rem;
padding: 0.25rem;
transition: transform 0.2s;
}
.trash-btn:hover {
transform: scale(1.2);
}
.btn-primary {
align-self: flex-start;
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
color: white;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
.btn-primary:hover {
opacity: 0.9;
}
@media (max-width: 768px) {
.notes-body {
flex-direction: column;
}
.member-image {
width: 100%;
height: auto;
max-height: 300px;
}
}
</style>

View File

@@ -0,0 +1,260 @@
<template>
<BaseDialog
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
title="Mitglieder auswählen"
size="large"
:close-on-overlay="false"
@close="handleClose"
>
<div class="member-selection-content">
<div class="controls-bar">
<button class="btn-secondary" @click="$emit('select-all')">Alle auswählen</button>
<button class="btn-secondary" @click="$emit('deselect-all')">Alle abwählen</button>
</div>
<div class="selection-layout">
<div class="members-column">
<h4>Mitglieder</h4>
<div class="checkbox-list">
<label v-for="m in members" :key="m.id" class="checkbox-item">
<input
type="checkbox"
:value="m.id"
:checked="selectedIds.includes(m.id)"
@change="handleMemberToggle(m.id, $event.target.checked)"
/>
<span :class="{ active: activeMemberId === m.id }">
{{ m.firstName }} {{ m.lastName }}
</span>
</label>
</div>
</div>
<div class="recommendations-column" v-if="activeMember && showRecommendations">
<h4>Empfehlungen</h4>
<div v-if="recommendations && recommendations.length" class="checkbox-list">
<label v-for="rec in recommendations" :key="rec.key" class="checkbox-item">
<input
type="checkbox"
:checked="isRecommended(rec.key)"
@change="handleRecommendationToggle(rec.key, $event.target.checked)"
/>
<span>{{ rec.name }} {{ rec.date }} {{ rec.time }}</span>
</label>
</div>
<div v-else class="no-data">
<em>Keine passenden Empfehlungen gefunden.</em>
</div>
</div>
</div>
</div>
<template #footer>
<button class="btn-secondary" @click="handleClose">Schließen</button>
<button
class="btn-primary"
:disabled="selectedIds.length === 0"
@click="$emit('generate-pdf')"
>
PDF erzeugen
</button>
</template>
</BaseDialog>
</template>
<script>
import BaseDialog from './BaseDialog.vue';
export default {
name: 'MemberSelectionDialog',
components: {
BaseDialog
},
props: {
modelValue: {
type: Boolean,
default: false
},
members: {
type: Array,
default: () => []
},
selectedIds: {
type: Array,
default: () => []
},
activeMemberId: {
type: [Number, String],
default: null
},
recommendations: {
type: Array,
default: () => []
},
recommendedKeys: {
type: Array,
default: () => []
},
showRecommendations: {
type: Boolean,
default: true
}
},
emits: [
'update:modelValue',
'close',
'select-all',
'deselect-all',
'toggle-member',
'toggle-recommendation',
'generate-pdf',
'update:selectedIds',
'update:activeMemberId'
],
computed: {
activeMember() {
return this.members.find(m => m.id === this.activeMemberId);
}
},
methods: {
handleClose() {
this.$emit('update:modelValue', false);
this.$emit('close');
},
handleMemberToggle(memberId, checked) {
this.$emit('update:activeMemberId', memberId);
this.$emit('toggle-member', { memberId, checked });
},
handleRecommendationToggle(key, checked) {
this.$emit('toggle-recommendation', { memberId: this.activeMemberId, key, checked });
},
isRecommended(key) {
return this.recommendedKeys.includes(key);
}
}
};
</script>
<style scoped>
.member-selection-content {
display: flex;
flex-direction: column;
gap: 1rem;
}
.controls-bar {
display: flex;
gap: 0.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-color);
}
.selection-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
min-height: 400px;
}
.members-column,
.recommendations-column {
display: flex;
flex-direction: column;
}
.members-column h4,
.recommendations-column h4 {
margin: 0 0 0.75rem 0;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--primary-color);
color: var(--primary-color);
}
.checkbox-list {
flex: 1;
overflow-y: auto;
max-height: 500px;
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 0.5rem;
}
.checkbox-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
cursor: pointer;
border-radius: 4px;
transition: background-color 0.2s;
}
.checkbox-item:hover {
background: var(--background-light);
}
.checkbox-item input[type="checkbox"] {
cursor: pointer;
}
.checkbox-item span {
flex: 1;
}
.checkbox-item span.active {
font-weight: 600;
color: var(--primary-color);
}
.no-data {
padding: 2rem;
text-align: center;
color: var(--text-muted);
}
.btn-primary,
.btn-secondary {
padding: 0.5rem 1.5rem;
border: none;
border-radius: 4px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-primary {
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
color: white;
}
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #5a6268;
}
@media (max-width: 768px) {
.selection-layout {
grid-template-columns: 1fr;
}
.checkbox-list {
max-height: 300px;
}
}
</style>

View File

@@ -41,6 +41,24 @@
</p>
</div>
<div class="form-group checkbox-group">
<label>
<input
type="checkbox"
v-model="formData.autoUpdateRatings"
:disabled="!formData.savePassword"
/>
<span>Automatische Update-Ratings aktivieren</span>
</label>
<p class="hint">
Täglich um 6:00 Uhr werden automatisch die neuesten Ratings von myTischtennis abgerufen.
<strong>Erfordert gespeichertes Passwort.</strong>
</p>
<p v-if="!formData.savePassword" class="warning">
Für automatische Updates muss das myTischtennis-Passwort gespeichert werden.
</p>
</div>
<div class="form-group" v-if="formData.password">
<label for="app-password">Ihr App-Passwort zur Bestätigung:</label>
<input
@@ -90,6 +108,7 @@ export default {
email: this.account?.email || '',
password: '',
savePassword: this.account?.savePassword || false,
autoUpdateRatings: this.account?.autoUpdateRatings || false,
userPassword: ''
},
saving: false,
@@ -108,6 +127,11 @@ export default {
return false;
}
// Automatische Updates erfordern gespeichertes Passwort
if (this.formData.autoUpdateRatings && !this.formData.savePassword) {
return false;
}
return true;
}
},
@@ -121,7 +145,8 @@ export default {
try {
const payload = {
email: this.formData.email,
savePassword: this.formData.savePassword
savePassword: this.formData.savePassword,
autoUpdateRatings: this.formData.autoUpdateRatings
};
// Nur password und userPassword hinzufügen, wenn ein Passwort eingegeben wurde
@@ -243,6 +268,13 @@ export default {
font-style: italic;
}
.warning {
margin-top: 0.5rem;
font-size: 0.875rem;
color: #dc3545;
font-weight: 600;
}
.error-message {
padding: 0.75rem;
background-color: #f8d7da;

View File

@@ -0,0 +1,228 @@
<template>
<div class="modal-overlay" @click.self="$emit('close')">
<div class="modal">
<div class="modal-header">
<h3>Update-Ratings History</h3>
</div>
<div class="modal-body">
<div v-if="loading" class="loading">
Lade History...
</div>
<div v-else-if="history.length === 0" class="no-history">
<p>Noch keine automatischen Updates durchgeführt.</p>
</div>
<div v-else class="history-list">
<div v-for="entry in history" :key="entry.id" class="history-entry">
<div class="history-header">
<span class="history-date">{{ formatDate(entry.createdAt) }}</span>
<span class="history-status" :class="entry.success ? 'success' : 'error'">
{{ entry.success ? 'Erfolgreich' : 'Fehlgeschlagen' }}
</span>
</div>
<div v-if="entry.message" class="history-message">
{{ entry.message }}
</div>
<div v-if="entry.errorDetails" class="history-error">
{{ entry.errorDetails }}
</div>
<div v-if="entry.updatedCount !== undefined" class="history-stats">
{{ entry.updatedCount }} Ratings aktualisiert
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" @click="$emit('close')">
Schließen
</button>
</div>
</div>
</div>
</template>
<script>
import apiClient from '../apiClient.js';
export default {
name: 'MyTischtennisHistoryDialog',
data() {
return {
loading: true,
history: []
};
},
async mounted() {
await this.loadHistory();
},
methods: {
async loadHistory() {
try {
this.loading = true;
const response = await apiClient.get('/mytischtennis/update-history');
this.history = response.data.history || [];
} catch (error) {
console.error('Fehler beim Laden der History:', error);
this.history = [];
} finally {
this.loading = false;
}
},
formatDate(dateString) {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleString('de-DE', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
}
};
</script>
<style scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal {
background: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
max-width: 800px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.modal-header {
padding: 1.5rem;
border-bottom: 1px solid #dee2e6;
}
.modal-header h3 {
margin: 0;
color: #495057;
}
.modal-body {
padding: 1.5rem;
flex: 1;
}
.modal-footer {
padding: 1rem 1.5rem;
border-top: 1px solid #dee2e6;
display: flex;
justify-content: flex-end;
gap: 1rem;
}
.loading {
text-align: center;
padding: 2rem;
color: #6c757d;
}
.no-history {
text-align: center;
padding: 2rem;
color: #6c757d;
}
.history-list {
max-height: 400px;
overflow-y: auto;
}
.history-entry {
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 1rem;
margin-bottom: 1rem;
background: #f8f9fa;
}
.history-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.history-date {
font-weight: 600;
color: #495057;
}
.history-status {
padding: 0.25rem 0.5rem;
border-radius: 3px;
font-size: 0.875rem;
font-weight: 600;
}
.history-status.success {
background-color: #d4edda;
color: #155724;
}
.history-status.error {
background-color: #f8d7da;
color: #721c24;
}
.history-message {
margin-top: 0.5rem;
color: #495057;
font-size: 0.875rem;
}
.history-error {
margin-top: 0.5rem;
color: #dc3545;
font-size: 0.875rem;
font-style: italic;
}
.history-stats {
margin-top: 0.5rem;
color: #28a745;
font-size: 0.875rem;
font-weight: 600;
}
.btn-secondary {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.2s ease;
background-color: #6c757d;
color: white;
}
.btn-secondary:hover {
background-color: #545b62;
}
</style>

View File

@@ -0,0 +1,210 @@
<template>
<BaseDialog
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
title="Neues Mitglied hinzufügen"
size="medium"
:close-on-overlay="false"
@close="handleClose"
>
<div class="quick-add-form">
<div class="form-row">
<div class="form-group">
<label for="firstName">Vorname:</label>
<input
type="text"
id="firstName"
:value="localMember.firstName"
@input="updateMember('firstName', $event.target.value)"
required
class="form-input"
placeholder="Vorname"
/>
</div>
<div class="form-group">
<label for="lastName">Nachname (optional):</label>
<input
type="text"
id="lastName"
:value="localMember.lastName"
@input="updateMember('lastName', $event.target.value)"
class="form-input"
placeholder="Nachname"
/>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="birthDate">Geburtsdatum (optional):</label>
<input
type="date"
id="birthDate"
:value="localMember.birthDate"
@input="updateMember('birthDate', $event.target.value)"
class="form-input"
/>
</div>
<div class="form-group">
<label for="gender">Geschlecht:</label>
<select id="gender" :value="localMember.gender" @change="updateMember('gender', $event.target.value)" class="form-select">
<option value="">Bitte wählen</option>
<option value="male">Männlich</option>
<option value="female">Weiblich</option>
<option value="diverse">Divers</option>
</select>
</div>
</div>
</div>
<template #footer>
<button class="btn-secondary" @click="handleClose">Abbrechen</button>
<button
class="btn-primary"
@click="handleSubmit"
:disabled="!isValid"
>
Erstellen & Hinzufügen
</button>
</template>
</BaseDialog>
</template>
<script>
import BaseDialog from './BaseDialog.vue';
export default {
name: 'QuickAddMemberDialog',
components: {
BaseDialog
},
props: {
modelValue: {
type: Boolean,
default: false
},
member: {
type: Object,
default: () => ({ firstName: '', lastName: '', birthDate: '', gender: '' })
}
},
emits: ['update:modelValue', 'close', 'submit', 'update:member'],
data() {
return {
localMember: { ...this.member }
};
},
computed: {
isValid() {
return this.localMember.firstName && this.localMember.firstName.trim() !== '';
}
},
watch: {
member: {
handler(newVal) {
this.localMember = { ...newVal };
},
deep: true
}
},
methods: {
updateMember(field, value) {
this.localMember[field] = value;
this.$emit('update:member', { ...this.localMember });
},
handleClose() {
this.$emit('update:modelValue', false);
this.$emit('close');
},
handleSubmit() {
if (this.isValid) {
this.$emit('submit', this.localMember);
}
}
}
};
</script>
<style scoped>
.quick-add-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-row {
display: flex;
gap: 1rem;
}
.form-group {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group label {
font-weight: 600;
color: var(--text-color);
font-size: 0.9rem;
}
.form-input,
.form-select {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--border-color);
border-radius: 4px;
font-family: inherit;
font-size: 0.9rem;
}
.form-input:focus,
.form-select:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px var(--primary-light);
}
.btn-primary,
.btn-secondary {
padding: 0.5rem 1.5rem;
border: none;
border-radius: 4px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-primary {
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
color: white;
}
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #5a6268;
}
@media (max-width: 768px) {
.form-row {
flex-direction: column;
}
}
</style>

View File

@@ -40,6 +40,27 @@
</div>
</div>
</div>
<!-- Info Dialog -->
<InfoDialog
v-model="infoDialog.isOpen"
:title="infoDialog.title"
:message="infoDialog.message"
:details="infoDialog.details"
:type="infoDialog.type"
/>
<!-- Confirm Dialog -->
<ConfirmDialog
v-model="confirmDialog.isOpen"
:title="confirmDialog.title"
:message="confirmDialog.message"
:details="confirmDialog.details"
:type="confirmDialog.type"
@confirm="handleConfirmResult(true)"
@cancel="handleConfirmResult(false)"
/>
</template>
<script>
@@ -47,8 +68,14 @@ import { ref, computed, onMounted, watch } from 'vue';
import { useStore } from 'vuex';
import apiClient from '../apiClient.js';
import InfoDialog from './InfoDialog.vue';
import ConfirmDialog from './ConfirmDialog.vue';
export default {
name: 'SeasonSelector',
components: {
InfoDialog,
ConfirmDialog
},
props: {
modelValue: {
type: [String, Number],
@@ -63,6 +90,23 @@ export default {
setup(props, { emit }) {
const store = useStore();
// Dialog States
const infoDialog = ref({
isOpen: false,
title: '',
message: '',
details: '',
type: 'info'
});
const confirmDialog = ref({
isOpen: false,
title: '',
message: '',
details: '',
type: 'info',
resolveCallback: null
});
// Reactive data
const seasons = ref([]);
const selectedSeasonId = ref(props.modelValue);
@@ -124,12 +168,12 @@ export default {
// Formular zurücksetzen
newSeasonString.value = '';
showNewSeasonForm.value = false;
} catch (error) {
console.error('Fehler beim Erstellen der Saison:', error);
if (error.response?.data?.error === 'alreadyexists') {
alert('Diese Saison existiert bereits!');
} catch (err) {
console.error('Fehler beim Erstellen der Saison:', err);
if (err.response?.data?.error === 'alreadyexists') {
showInfo('Hinweis', 'Diese Saison existiert bereits!', '', 'warning');
} else {
alert('Fehler beim Erstellen der Saison');
showInfo('Fehler', 'Fehler beim Erstellen der Saison', '', 'error');
}
}
};
@@ -139,6 +183,38 @@ export default {
showNewSeasonForm.value = false;
};
// Dialog Helper Methods
const showInfo = async (title, message, details = '', type = 'info') => {
infoDialog.value = {
isOpen: true,
title,
message,
details,
type
};
};
const showConfirm = async (title, message, details = '', type = 'info') => {
return new Promise((resolve) => {
confirmDialog.value = {
isOpen: true,
title,
message,
details,
type,
resolveCallback: resolve
};
});
};
const handleConfirmResult = (confirmed) => {
if (confirmDialog.value.resolveCallback) {
confirmDialog.value.resolveCallback(confirmed);
confirmDialog.value.resolveCallback = null;
}
confirmDialog.value.isOpen = false;
};
// Watch for prop changes
watch(() => props.modelValue, (newValue) => {
selectedSeasonId.value = newValue;
@@ -150,6 +226,11 @@ export default {
});
return {
infoDialog,
confirmDialog,
showInfo,
showConfirm,
handleConfirmResult,
seasons,
selectedSeasonId,
showNewSeasonForm,

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