Compare commits
1 Commits
mytischten
...
httv
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7be98ffeeb |
@@ -1,212 +0,0 @@
|
||||
# MyTischtennis Automatischer Datenabruf
|
||||
|
||||
## Übersicht
|
||||
|
||||
Dieses System ermöglicht den automatischen Abruf von Spielergebnissen und Statistiken von myTischtennis.de.
|
||||
|
||||
## Scheduler
|
||||
|
||||
### 6:00 Uhr - Rating Updates
|
||||
- **Service:** `autoUpdateRatingsService.js`
|
||||
- **Funktion:** Aktualisiert TTR/QTTR-Werte für Spieler
|
||||
- **TODO:** Implementierung der eigentlichen Rating-Update-Logik
|
||||
|
||||
### 6:30 Uhr - Spielergebnisse
|
||||
- **Service:** `autoFetchMatchResultsService.js`
|
||||
- **Funktion:** Ruft Spielerbilanzen für konfigurierte Teams ab
|
||||
- **Status:** ✅ Grundlegende Implementierung fertig
|
||||
|
||||
## Benötigte Konfiguration
|
||||
|
||||
### 1. MyTischtennis-Account
|
||||
- Account muss in den MyTischtennis-Settings verknüpft sein
|
||||
- Checkbox "Automatische Updates" aktivieren
|
||||
- Passwort speichern (erforderlich für automatische Re-Authentifizierung)
|
||||
|
||||
### 2. League-Konfiguration
|
||||
|
||||
Für jede Liga müssen folgende Felder ausgefüllt werden:
|
||||
|
||||
```sql
|
||||
UPDATE league SET
|
||||
my_tischtennis_group_id = '504417', -- Group ID von myTischtennis
|
||||
association = 'HeTTV', -- Verband (z.B. HeTTV, DTTB)
|
||||
groupname = '1.Kreisklasse' -- Gruppenname für URL
|
||||
WHERE id = 1;
|
||||
```
|
||||
|
||||
**Beispiel-URL:**
|
||||
```
|
||||
https://www.mytischtennis.de/click-tt/HeTTV/25--26/ligen/1.Kreisklasse/gruppe/504417/...
|
||||
^^^^^ ^^^^^^^^^^^^^^ ^^^^^^
|
||||
association groupname group_id
|
||||
```
|
||||
|
||||
### 3. Team-Konfiguration
|
||||
|
||||
Für jedes Team muss die myTischtennis Team-ID gesetzt werden:
|
||||
|
||||
```sql
|
||||
UPDATE club_team SET
|
||||
my_tischtennis_team_id = '2995094' -- Team ID von myTischtennis
|
||||
WHERE id = 1;
|
||||
```
|
||||
|
||||
**Beispiel-URL:**
|
||||
```
|
||||
.../mannschaft/2995094/Harheimer_TC_(J11)/spielerbilanzen/gesamt
|
||||
^^^^^^^
|
||||
team_id
|
||||
```
|
||||
|
||||
### 4. Spieler-Zuordnung (Optional)
|
||||
|
||||
Spieler werden automatisch anhand des Namens zugeordnet. Für genauere Zuordnung kann die myTischtennis Player-ID gesetzt werden:
|
||||
|
||||
```sql
|
||||
UPDATE member SET
|
||||
my_tischtennis_player_id = 'NU2705037' -- Player ID von myTischtennis
|
||||
WHERE id = 1;
|
||||
```
|
||||
|
||||
## Migrationen
|
||||
|
||||
Folgende Migrationen müssen ausgeführt werden:
|
||||
|
||||
```bash
|
||||
# 1. MyTischtennis Auto-Update-Felder
|
||||
mysql -u root -p trainingstagebuch < backend/migrations/add_auto_update_ratings_to_my_tischtennis.sql
|
||||
|
||||
# 2. MyTischtennis Update-History-Tabelle
|
||||
mysql -u root -p trainingstagebuch < backend/migrations/create_my_tischtennis_update_history.sql
|
||||
|
||||
# 3. League MyTischtennis-Felder
|
||||
mysql -u root -p trainingstagebuch < backend/migrations/add_mytischtennis_fields_to_league.sql
|
||||
|
||||
# 4. Team MyTischtennis-ID
|
||||
mysql -u root -p trainingstagebuch < backend/migrations/add_mytischtennis_team_id_to_club_team.sql
|
||||
|
||||
# 5. Member MyTischtennis Player-ID
|
||||
mysql -u root -p trainingstagebuch < backend/migrations/add_mytischtennis_player_id_to_member.sql
|
||||
|
||||
# 6. Match Result-Felder
|
||||
mysql -u root -p trainingstagebuch < backend/migrations/add_match_result_fields.sql
|
||||
```
|
||||
|
||||
## Abgerufene Daten
|
||||
|
||||
Von der myTischtennis API werden folgende Daten abgerufen:
|
||||
|
||||
### Einzelstatistiken
|
||||
- Player ID, Vorname, Nachname
|
||||
- Gewonnene/Verlorene Punkte
|
||||
- Anzahl Spiele
|
||||
- Detaillierte Statistiken nach Gegner-Position
|
||||
|
||||
### Doppelstatistiken
|
||||
- Player IDs, Namen der beiden Spieler
|
||||
- Gewonnene/Verlorene Punkte
|
||||
- Anzahl Spiele
|
||||
|
||||
### Team-Informationen
|
||||
- Teamname, Liga, Saison
|
||||
- Gesamtpunkte (gewonnen/verloren)
|
||||
- Doppel- und Einzelpunkte
|
||||
|
||||
## Implementierungsdetails
|
||||
|
||||
### Datenfluss
|
||||
|
||||
1. **Scheduler** (6:30 Uhr):
|
||||
- `schedulerService.js` triggert `autoFetchMatchResultsService.executeAutomaticFetch()`
|
||||
|
||||
2. **Account-Verarbeitung**:
|
||||
- Lädt alle MyTischtennis-Accounts mit `autoUpdateRatings = true`
|
||||
- Prüft Session-Gültigkeit
|
||||
- Re-Authentifizierung bei abgelaufener Session
|
||||
|
||||
3. **Team-Abfrage**:
|
||||
- Lädt alle Teams mit konfigurierten myTischtennis-IDs
|
||||
- Baut API-URL dynamisch zusammen
|
||||
- Führt authentifizierten GET-Request durch
|
||||
|
||||
4. **Datenverarbeitung**:
|
||||
- Parst JSON-Response
|
||||
- Matched Spieler anhand von ID oder Name
|
||||
- Speichert myTischtennis Player-ID bei Mitgliedern
|
||||
- Loggt Statistiken
|
||||
|
||||
### Player-Matching-Algorithmus
|
||||
|
||||
```javascript
|
||||
1. Suche nach myTischtennis Player-ID (exakte Übereinstimmung)
|
||||
2. Falls nicht gefunden: Suche nach Name (case-insensitive)
|
||||
3. Falls gefunden: Speichere myTischtennis Player-ID für zukünftige Abfragen
|
||||
```
|
||||
|
||||
**Hinweis:** Da Namen verschlüsselt gespeichert werden, müssen für den Namens-Abgleich alle Members geladen und entschlüsselt werden. Dies ist bei großen Datenbanken ineffizient.
|
||||
|
||||
## TODO / Offene Punkte
|
||||
|
||||
### Noch zu implementieren:
|
||||
|
||||
1. **TTR/QTTR Updates** (6:00 Uhr Job):
|
||||
- Endpoint für TTR/QTTR-Daten identifizieren
|
||||
- Daten abrufen und in Member-Tabelle speichern
|
||||
|
||||
2. **Spielergebnis-Details**:
|
||||
- Einzelne Matches mit Satzständen speichern
|
||||
- Tabelle für Match-Historie erstellen
|
||||
|
||||
3. **History-Tabelle für Spielergebnis-Abrufe** (optional):
|
||||
- Ähnlich zu `my_tischtennis_update_history`
|
||||
- Speichert Erfolg/Fehler der Abrufe
|
||||
|
||||
4. **Benachrichtigungen** (optional):
|
||||
- Email/Push bei neuen Ergebnissen
|
||||
- Highlights für besondere Siege
|
||||
|
||||
5. **Performance-Optimierung**:
|
||||
- Caching für Player-Matches
|
||||
- Incremental Updates (nur neue Daten)
|
||||
|
||||
## Manueller Test
|
||||
|
||||
```javascript
|
||||
// Im Node-Backend-Code oder über API-Endpoint:
|
||||
import schedulerService from './services/schedulerService.js';
|
||||
|
||||
// Rating Updates manuell triggern
|
||||
await schedulerService.triggerRatingUpdates();
|
||||
|
||||
// Spielergebnisse manuell abrufen
|
||||
await schedulerService.triggerMatchResultsFetch();
|
||||
```
|
||||
|
||||
## API-Dokumentation
|
||||
|
||||
### MyTischtennis Spielerbilanzen-Endpoint
|
||||
|
||||
**URL-Format:**
|
||||
```
|
||||
https://www.mytischtennis.de/click-tt/{association}/{season}/ligen/{groupname}/gruppe/{groupId}/mannschaft/{teamId}/{teamname}/spielerbilanzen/gesamt?_data=routes%2Fclick-tt%2B%2F%24association%2B%2F%24season%2B%2F%24type%2B%2F%28%24groupname%29.gruppe.%24urlid_.mannschaft.%24teamid.%24teamname%2B%2Fspielerbilanzen.%24filter
|
||||
```
|
||||
|
||||
**Parameter:**
|
||||
- `{association}`: Verband (z.B. "HeTTV")
|
||||
- `{season}`: Saison im Format "25--26"
|
||||
- `{groupname}`: Gruppenname URL-encoded (z.B. "1.Kreisklasse")
|
||||
- `{groupId}`: Gruppen-ID (numerisch, z.B. "504417")
|
||||
- `{teamId}`: Team-ID (numerisch, z.B. "2995094")
|
||||
- `{teamname}`: Teamname URL-encoded mit Underscores (z.B. "Harheimer_TC_(J11)")
|
||||
|
||||
**Response:** JSON mit `data.balancesheet` Array
|
||||
|
||||
## Sicherheit
|
||||
|
||||
- ✅ Automatische Session-Verwaltung
|
||||
- ✅ Re-Authentifizierung bei abgelaufenen Sessions
|
||||
- ✅ Passwörter verschlüsselt gespeichert
|
||||
- ✅ Fehlerbehandlung und Logging
|
||||
- ✅ Graceful Degradation (einzelne Team-Fehler stoppen nicht den gesamten Prozess)
|
||||
|
||||
@@ -1,328 +0,0 @@
|
||||
# MyTischtennis URL Parser
|
||||
|
||||
## Übersicht
|
||||
|
||||
Der URL-Parser ermöglicht es, myTischtennis-Team-URLs automatisch zu parsen und die Konfiguration für automatische Datenabrufe vorzunehmen.
|
||||
|
||||
## Verwendung
|
||||
|
||||
### 1. URL Parsen
|
||||
|
||||
**Endpoint:** `POST /api/mytischtennis/parse-url`
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"url": "https://www.mytischtennis.de/click-tt/HeTTV/25--26/ligen/1.Kreisklasse/gruppe/504417/mannschaft/2995094/Harheimer_TC_(J11)/spielerbilanzen/gesamt"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"association": "HeTTV",
|
||||
"season": "25/26",
|
||||
"type": "ligen",
|
||||
"groupname": "1.Kreisklasse",
|
||||
"groupId": "504417",
|
||||
"teamId": "2995094",
|
||||
"teamname": "Harheimer TC (J11)",
|
||||
"originalUrl": "https://www.mytischtennis.de/click-tt/...",
|
||||
"clubId": "43030",
|
||||
"clubName": "Harheimer TC",
|
||||
"teamName": "Jugend 11",
|
||||
"leagueName": "Jugend 13 1. Kreisklasse",
|
||||
"region": "Frankfurt",
|
||||
"tableRank": 8,
|
||||
"matchesWon": 0,
|
||||
"matchesLost": 3
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Team Automatisch Konfigurieren
|
||||
|
||||
**Endpoint:** `POST /api/mytischtennis/configure-team`
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"url": "https://www.mytischtennis.de/click-tt/HeTTV/25--26/ligen/1.Kreisklasse/gruppe/504417/mannschaft/2995094/Harheimer_TC_(J11)/spielerbilanzen/gesamt",
|
||||
"clubTeamId": 1,
|
||||
"createLeague": false,
|
||||
"createSeason": false
|
||||
}
|
||||
```
|
||||
|
||||
**Parameter:**
|
||||
- `url` (required): Die myTischtennis-URL
|
||||
- `clubTeamId` (required): Die ID des lokalen Club-Teams
|
||||
- `createLeague` (optional): Wenn `true`, wird eine neue League erstellt
|
||||
- `createSeason` (optional): Wenn `true`, wird eine neue Season erstellt
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Team configured successfully",
|
||||
"data": {
|
||||
"team": {
|
||||
"id": 1,
|
||||
"name": "Jugend 11",
|
||||
"myTischtennisTeamId": "2995094"
|
||||
},
|
||||
"league": {
|
||||
"id": 5,
|
||||
"name": "Jugend 13 1. Kreisklasse",
|
||||
"myTischtennisGroupId": "504417",
|
||||
"association": "HeTTV",
|
||||
"groupname": "1.Kreisklasse"
|
||||
},
|
||||
"season": {
|
||||
"id": 2,
|
||||
"name": "25/26"
|
||||
},
|
||||
"parsedData": { ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. URL für Team Abrufen
|
||||
|
||||
**Endpoint:** `GET /api/mytischtennis/team-url/:teamId`
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"url": "https://www.mytischtennis.de/click-tt/HeTTV/25--26/ligen/1.Kreisklasse/gruppe/504417/mannschaft/2995094/Harheimer%20TC%20%28J11%29/spielerbilanzen/gesamt"
|
||||
}
|
||||
```
|
||||
|
||||
## URL-Format
|
||||
|
||||
### Unterstützte URL-Muster
|
||||
|
||||
```
|
||||
https://www.mytischtennis.de/click-tt/{association}/{season}/{type}/{groupname}/gruppe/{groupId}/mannschaft/{teamId}/{teamname}/...
|
||||
```
|
||||
|
||||
**Komponenten:**
|
||||
- `{association}`: Verband (z.B. "HeTTV", "DTTB", "WestD")
|
||||
- `{season}`: Saison im Format "YY--YY" (z.B. "25--26" für 2025/2026)
|
||||
- `{type}`: Typ (meist "ligen")
|
||||
- `{groupname}`: Gruppenname URL-encoded (z.B. "1.Kreisklasse", "Kreisliga")
|
||||
- `{groupId}`: Numerische Gruppen-ID (z.B. "504417")
|
||||
- `{teamId}`: Numerische Team-ID (z.B. "2995094")
|
||||
- `{teamname}`: Teamname URL-encoded mit Underscores (z.B. "Harheimer_TC_(J11)")
|
||||
|
||||
### Beispiel-URLs
|
||||
|
||||
**Spielerbilanzen:**
|
||||
```
|
||||
https://www.mytischtennis.de/click-tt/HeTTV/25--26/ligen/1.Kreisklasse/gruppe/504417/mannschaft/2995094/Harheimer_TC_(J11)/spielerbilanzen/gesamt
|
||||
```
|
||||
|
||||
**Spielplan:**
|
||||
```
|
||||
https://www.mytischtennis.de/click-tt/HeTTV/25--26/ligen/1.Kreisklasse/gruppe/504417/mannschaft/2995094/Harheimer_TC_(J11)/spielplan
|
||||
```
|
||||
|
||||
**Tabelle:**
|
||||
```
|
||||
https://www.mytischtennis.de/click-tt/HeTTV/25--26/ligen/1.Kreisklasse/gruppe/504417/mannschaft/2995094/Harheimer_TC_(J11)/tabelle
|
||||
```
|
||||
|
||||
## Datenfluss
|
||||
|
||||
### Ohne MyTischtennis-Login
|
||||
|
||||
1. URL wird geparst
|
||||
2. Nur URL-Komponenten werden extrahiert
|
||||
3. Zusätzliche Daten (clubName, leagueName, etc.) sind nicht verfügbar
|
||||
|
||||
### Mit MyTischtennis-Login
|
||||
|
||||
1. URL wird geparst
|
||||
2. API-Request an myTischtennis mit Authentication
|
||||
3. Vollständige Team-Daten werden abgerufen
|
||||
4. Alle Felder sind verfügbar
|
||||
|
||||
## Frontend-Integration
|
||||
|
||||
### Vue.js Beispiel
|
||||
|
||||
```javascript
|
||||
<template>
|
||||
<div>
|
||||
<input
|
||||
v-model="myTischtennisUrl"
|
||||
placeholder="MyTischtennis URL einfügen..."
|
||||
@blur="parseUrl"
|
||||
/>
|
||||
|
||||
<div v-if="parsedData">
|
||||
<h3>{{ parsedData.teamname }}</h3>
|
||||
<p>Liga: {{ parsedData.leagueName }}</p>
|
||||
<p>Verband: {{ parsedData.association }}</p>
|
||||
<p>Tabelle: Platz {{ parsedData.tableRank }}</p>
|
||||
|
||||
<button @click="configureTeam">Team konfigurieren</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
myTischtennisUrl: '',
|
||||
parsedData: null
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
async parseUrl() {
|
||||
if (!this.myTischtennisUrl) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/mytischtennis/parse-url', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'userid': this.userId,
|
||||
'authcode': this.authCode
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: this.myTischtennisUrl
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
this.parsedData = result.data;
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Parsen:', error);
|
||||
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
|
||||
|
||||
603
backend/clients/hettvClient.js
Normal file
603
backend/clients/hettvClient.js
Normal file
@@ -0,0 +1,603 @@
|
||||
import axios from 'axios';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const BASE_URL = 'https://ttde-id.liga.nu';
|
||||
const CLICK_TT_BASE = 'https://httv.click-tt.de';
|
||||
|
||||
class HettvClient {
|
||||
constructor() {
|
||||
this.baseURL = BASE_URL;
|
||||
this.client = axios.create({
|
||||
baseURL: this.baseURL,
|
||||
timeout: 15000,
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language': 'de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7'
|
||||
},
|
||||
maxRedirects: 5, // Folge den OAuth2-Redirects
|
||||
validateStatus: (status) => status >= 200 && status < 400
|
||||
});
|
||||
|
||||
// Einfache Cookie-Jar nach Host -> { name: value }
|
||||
this.cookieJar = new Map();
|
||||
this.defaultHeaders = {
|
||||
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:141.0) Gecko/20100101 Firefox/141.0',
|
||||
'Accept-Language': 'de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Login to HeTTV via OAuth2
|
||||
* @param {string} username - HeTTV username (email)
|
||||
* @param {string} password - HeTTV password
|
||||
* @returns {Promise<Object>} Login response with session data
|
||||
*/
|
||||
async login(username, password) {
|
||||
try {
|
||||
console.log('[HettvClient] - Starting login for:', username);
|
||||
|
||||
// Schritt 1: OAuth2-Authorization-Endpoint aufrufen - das sollte zur Login-Seite weiterleiten
|
||||
const oauthParams = new URLSearchParams({
|
||||
'scope': 'nuLiga',
|
||||
'response_type': 'code',
|
||||
'redirect_uri': 'https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaTTDE.woa/wa/oAuthLogin',
|
||||
'state': 'nonce=' + Math.random().toString(36).substring(2, 15),
|
||||
'client_id': 'XtVpGjXKAhz3BZuu'
|
||||
});
|
||||
|
||||
// OAuth2 Start
|
||||
|
||||
// Der OAuth2-Endpoint sollte direkt zur Login-Seite weiterleiten
|
||||
const loginPageResponse = await this.client.get(`/oauth2/authz/ttde?${oauthParams.toString()}`, {
|
||||
maxRedirects: 5, // Folge den Redirects zur Login-Seite
|
||||
validateStatus: (status) => status >= 200 && status < 400,
|
||||
headers: {
|
||||
...this.defaultHeaders
|
||||
}
|
||||
});
|
||||
|
||||
// Login-Seite erreicht
|
||||
|
||||
// Session-Cookie aus der Login-Seite extrahieren
|
||||
const setCookies = loginPageResponse.headers['set-cookie'];
|
||||
if (!setCookies || !Array.isArray(setCookies)) {
|
||||
console.error('[HettvClient] - No cookies from login page');
|
||||
return {
|
||||
success: false,
|
||||
error: 'Keine Session-Cookie von Login-Seite erhalten'
|
||||
};
|
||||
}
|
||||
|
||||
const sessionCookie = setCookies.find(cookie => cookie.startsWith('nusportingress='));
|
||||
if (!sessionCookie) {
|
||||
console.error('[HettvClient] - No nusportingress cookie from login page');
|
||||
return {
|
||||
success: false,
|
||||
error: 'Keine nusportingress Session von Login-Seite erhalten'
|
||||
};
|
||||
}
|
||||
|
||||
// Extrahiere t:formdata aus dem HTML der Login-Seite
|
||||
const htmlContent = loginPageResponse.data;
|
||||
// HTML erhalten
|
||||
|
||||
// Suche nach t:formdata im HTML - verschiedene mögliche Formate
|
||||
let formDataMatch = htmlContent.match(/name="t:formdata"\s+value="([^"]+)"/);
|
||||
|
||||
if (!formDataMatch) {
|
||||
// Versuche andere Formate
|
||||
formDataMatch = htmlContent.match(/name='t:formdata'\s+value='([^']+)'/);
|
||||
}
|
||||
|
||||
if (!formDataMatch) {
|
||||
// Suche nach hidden input mit t:formdata (value vor name)
|
||||
formDataMatch = htmlContent.match(/<input[^>]*value="([^"]+)"[^>]*name="t:formdata"/);
|
||||
}
|
||||
|
||||
if (!formDataMatch) {
|
||||
// Suche nach hidden input mit t:formdata (name vor value)
|
||||
formDataMatch = htmlContent.match(/<input[^>]*name="t:formdata"[^>]*value="([^"]+)"/);
|
||||
}
|
||||
|
||||
if (!formDataMatch) {
|
||||
// Suche nach t:formdata ohne Anführungszeichen
|
||||
formDataMatch = htmlContent.match(/name=t:formdata\s+value=([^\s>]+)/);
|
||||
}
|
||||
|
||||
if (!formDataMatch) {
|
||||
console.error('[HettvClient] - No t:formdata found in login page');
|
||||
console.log('[HettvClient] - HTML snippet:', htmlContent.substring(0, 2000));
|
||||
|
||||
// Debug: Suche nach allen hidden inputs
|
||||
const hiddenInputs = htmlContent.match(/<input[^>]*type="hidden"[^>]*>/g);
|
||||
console.log('[HettvClient] - Hidden inputs found:', hiddenInputs);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Keine t:formdata von Login-Seite erhalten'
|
||||
};
|
||||
}
|
||||
|
||||
const tFormData = formDataMatch[1];
|
||||
// CSRF-Token gefunden
|
||||
|
||||
// Schritt 2: Login mit den korrekten Daten durchführen
|
||||
// Verwende die Session-Cookie für den Login-Request
|
||||
const formData = new URLSearchParams();
|
||||
formData.append('t:submit', '["submit_0","submit_0"]');
|
||||
formData.append('t:ac', 'ttde');
|
||||
formData.append('t:formdata', tFormData);
|
||||
formData.append('username', username);
|
||||
formData.append('password', password);
|
||||
|
||||
const loginResponse = await this.client.post('/oauth2/login.loginform', formData.toString(), {
|
||||
headers: {
|
||||
'Cookie': sessionCookie.split(';')[0],
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
...this.defaultHeaders,
|
||||
'Referer': `${BASE_URL}/oauth2/login.loginform`
|
||||
},
|
||||
maxRedirects: 5,
|
||||
validateStatus: (status) => status >= 200 && status < 400
|
||||
});
|
||||
|
||||
// Login-Antwort erhalten
|
||||
|
||||
// Prüfe ob wir erfolgreich eingeloggt sind
|
||||
// Login-Response geprüft
|
||||
|
||||
// Prüfe den Response-Inhalt um zu sehen ob wir noch auf der Login-Seite sind
|
||||
const responseContent = loginResponse.data;
|
||||
const isLoginPage = responseContent.includes('click-TT ID') &&
|
||||
responseContent.includes('Username') &&
|
||||
responseContent.includes('Password');
|
||||
|
||||
// Login-Page-Erkennung durchgeführt
|
||||
|
||||
if (isLoginPage) {
|
||||
console.log('[HettvClient] - Still on login page, login failed');
|
||||
console.log('[HettvClient] - Response snippet:', responseContent.substring(0, 500));
|
||||
return {
|
||||
success: false,
|
||||
error: 'Login fehlgeschlagen - ungültige Zugangsdaten'
|
||||
};
|
||||
}
|
||||
|
||||
// Prüfe auf OAuth2-Redirect oder Erfolg
|
||||
const hasOAuthRedirect = responseContent.includes('oauth2') ||
|
||||
responseContent.includes('redirect') ||
|
||||
loginResponse.status >= 300;
|
||||
|
||||
// OAuth Redirect erkannt
|
||||
|
||||
// Extrahiere die finale Session-Cookie
|
||||
const finalCookies = loginResponse.headers['set-cookie'];
|
||||
const finalSessionCookie = finalCookies?.find(cookie => cookie.startsWith('nusportingress='));
|
||||
|
||||
const sessionId = (finalSessionCookie || sessionCookie).match(/nusportingress=([^;]+)/)?.[1];
|
||||
|
||||
console.log('[HettvClient] - Login erfolgreich (HeTTV).');
|
||||
|
||||
// Versuche die finale OAuth-Weiterleitung zu httv.click-tt.de aufzurufen, um PHPSESSID zu erhalten
|
||||
let finalUrl = loginResponse.request?.res?.responseUrl;
|
||||
console.log('[HettvClient] - Login finalUrl:', finalUrl);
|
||||
let phpSessIdCookie = null;
|
||||
let finalHtml = null;
|
||||
try {
|
||||
if (finalUrl && finalUrl.includes('oAuthLogin')) {
|
||||
const clickTTClient = axios.create({
|
||||
timeout: 15000,
|
||||
maxRedirects: 0,
|
||||
validateStatus: (status) => status >= 200 && status < 400
|
||||
});
|
||||
|
||||
// Folge der Redirect-Kette manuell, übernehme Cookies
|
||||
let currentUrl = finalUrl;
|
||||
let lastResp = null;
|
||||
let hop = 0;
|
||||
const maxHops = 10;
|
||||
while (hop++ < maxHops && currentUrl) {
|
||||
lastResp = await clickTTClient.get(currentUrl, {
|
||||
headers: {
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
...this.defaultHeaders,
|
||||
'Referer': hop === 1 ? `${BASE_URL}/oauth2/login.loginform` : (lastResp?.request?.res?.responseUrl || currentUrl),
|
||||
'Cookie': this._cookieHeaderForUrl(currentUrl)
|
||||
}
|
||||
});
|
||||
|
||||
this._ingestSetCookiesFromResponse(currentUrl, lastResp.headers['set-cookie']);
|
||||
|
||||
const loc = lastResp.headers['location'];
|
||||
if (loc) {
|
||||
// Absolut vs relativ
|
||||
if (/^https?:\/\//i.test(loc)) {
|
||||
currentUrl = loc;
|
||||
} else {
|
||||
const u = new URL(currentUrl);
|
||||
currentUrl = `${u.origin}${loc}`;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
break; // keine weitere Location => final
|
||||
}
|
||||
|
||||
const clickTTResp = lastResp;
|
||||
finalHtml = typeof clickTTResp.data === 'string' ? clickTTResp.data : '';
|
||||
const ctSetCookies = clickTTResp.headers['set-cookie'];
|
||||
if (Array.isArray(ctSetCookies)) {
|
||||
phpSessIdCookie = ctSetCookies.find(c => c.startsWith('PHPSESSID='))?.split(';')[0] || null;
|
||||
}
|
||||
// Finale click-TT URL ermittelt
|
||||
}
|
||||
} catch (e) {
|
||||
// Finale click-TT Seite konnte nicht geladen werden
|
||||
}
|
||||
|
||||
// Baue kombinierte Cookie-Kette (falls PHPSESSID vorhanden)
|
||||
const baseCookie = (finalSessionCookie || sessionCookie).split(';')[0];
|
||||
const combinedCookie = phpSessIdCookie ? `${baseCookie}; ${phpSessIdCookie}` : baseCookie;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
sessionId: sessionId,
|
||||
cookie: combinedCookie,
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
expiresAt: null,
|
||||
user: {
|
||||
finalUrl: finalUrl || null,
|
||||
htmlSnippet: finalHtml ? finalHtml.substring(0, 2000) : null
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('HeTTV login error:', error.message);
|
||||
console.error('Error details:', error.response?.status, error.response?.statusText);
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || 'Login fehlgeschlagen',
|
||||
status: error.response?.status || 500
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify login credentials
|
||||
* @param {string} username - HeTTV username
|
||||
* @param {string} password - HeTTV password
|
||||
* @returns {Promise<boolean>} True if credentials are valid
|
||||
*/
|
||||
async verifyCredentials(username, password) {
|
||||
const result = await this.login(username, password);
|
||||
return result.success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an authenticated request to click-TT
|
||||
* @param {string} endpoint - API endpoint
|
||||
* @param {string} cookie - JSESSIONID cookie
|
||||
* @param {Object} options - Additional axios options
|
||||
* @returns {Promise<Object>} API response
|
||||
*/
|
||||
async authenticatedRequest(endpoint, cookie, options = {}, finalUrl = null) {
|
||||
try {
|
||||
// Bestimme Basis-URL dynamisch aus finalUrl, falls vorhanden
|
||||
let baseURL = CLICK_TT_BASE;
|
||||
if (finalUrl) {
|
||||
try {
|
||||
const url = new URL(finalUrl);
|
||||
baseURL = url.origin;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
const isAbsolute = /^https?:\/\//i.test(endpoint);
|
||||
const client = axios.create({
|
||||
baseURL: isAbsolute ? undefined : baseURL,
|
||||
timeout: 15000,
|
||||
maxRedirects: 0,
|
||||
validateStatus: (status) => status >= 200 && status < 400
|
||||
});
|
||||
|
||||
// Manuelles Redirect-Following inkl. Cookies/Referer
|
||||
let currentUrl = isAbsolute ? endpoint : `${baseURL}${endpoint.startsWith('/') ? '' : '/'}${endpoint}`;
|
||||
let lastResp = null;
|
||||
const trace = [];
|
||||
let hop = 0;
|
||||
const maxHops = 10;
|
||||
|
||||
console.log(`[HettvClient] - Starting redirect chain from: ${currentUrl}`);
|
||||
|
||||
while (hop++ < maxHops && currentUrl) {
|
||||
console.log(`[HettvClient] - Redirect ${hop}: GET ${currentUrl}`);
|
||||
|
||||
lastResp = await client.request({
|
||||
method: options.method || 'GET',
|
||||
url: currentUrl,
|
||||
data: options.data,
|
||||
headers: {
|
||||
...this.defaultHeaders,
|
||||
...(options.headers || {}),
|
||||
'Cookie': this._mergeCookieHeader(cookie, this._cookieHeaderForUrl(currentUrl)),
|
||||
'Referer': hop === 1 ? (finalUrl || baseURL) : (lastResp?.request?.res?.responseUrl || currentUrl)
|
||||
}
|
||||
});
|
||||
|
||||
this._ingestSetCookiesFromResponse(currentUrl, lastResp.headers['set-cookie']);
|
||||
const loc = lastResp.headers['location'];
|
||||
|
||||
console.log(`[HettvClient] - Response: ${lastResp.status} ${lastResp.statusText}`);
|
||||
console.log(`[HettvClient] - Location header: ${loc || 'none'}`);
|
||||
console.log(`[HettvClient] - Set-Cookie header: ${lastResp.headers['set-cookie'] ? 'present' : 'none'}`);
|
||||
console.log(`[HettvClient] - Content-Type: ${lastResp.headers['content-type'] || 'none'}`);
|
||||
|
||||
// Speichere jede Seite zur Analyse
|
||||
try {
|
||||
const dir = path.resolve(process.cwd(), 'backend', 'uploads');
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
const filename = `hettv_redirect_${hop}_${Date.now()}.html`;
|
||||
const filePath = path.join(dir, filename);
|
||||
const content = typeof lastResp.data === 'string' ? lastResp.data : JSON.stringify(lastResp.data, null, 2);
|
||||
fs.writeFileSync(filePath, content, 'utf8');
|
||||
console.log(`[HettvClient] - Saved page to: ${filename}`);
|
||||
} catch (e) {
|
||||
console.log(`[HettvClient] - Could not save page ${hop}:`, e.message);
|
||||
}
|
||||
|
||||
trace.push({
|
||||
url: currentUrl,
|
||||
status: lastResp.status,
|
||||
location: loc || null
|
||||
});
|
||||
|
||||
if (loc) {
|
||||
const newUrl = /^https?:\/\//i.test(loc) ? loc : `${new URL(currentUrl).origin}${loc}`;
|
||||
console.log(`[HettvClient] - Following redirect to: ${newUrl}`);
|
||||
currentUrl = newUrl;
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`[HettvClient] - Final response: ${lastResp.status} (no more redirects)`);
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: lastResp?.data,
|
||||
trace
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('HeTTV API error:', error.message);
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || 'API-Anfrage fehlgeschlagen',
|
||||
status: error.response?.status || 500
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to main HeTTV page and find Downloads menu
|
||||
* @param {string} cookie - Session cookie
|
||||
* @returns {Promise<Object>} Response with main page content and download links
|
||||
*/
|
||||
async getMainPageWithDownloads(cookie, finalUrl = null) {
|
||||
try {
|
||||
console.log('[HettvClient] - Loading main HeTTV page...');
|
||||
|
||||
// Kandidaten für Einstiegs-URL bestimmen
|
||||
let origin = CLICK_TT_BASE;
|
||||
if (finalUrl) {
|
||||
try { origin = new URL(finalUrl).origin; } catch (_) {}
|
||||
}
|
||||
|
||||
const candidates = [];
|
||||
// Direkt zu HeTTV navigieren
|
||||
candidates.push('http://httv.click-tt.de/');
|
||||
candidates.push('http://httv.click-tt.de/wa/');
|
||||
candidates.push('http://httv.click-tt.de/cgi-bin/WebObjects/nuLigaTTDE.woa/wa/');
|
||||
|
||||
// Wenn wir eine finalUrl haben, verwende diese auch
|
||||
if (finalUrl) {
|
||||
candidates.push(finalUrl);
|
||||
}
|
||||
|
||||
console.log('[HettvClient] - URL candidates:', candidates);
|
||||
|
||||
let mainPageResponse = null;
|
||||
let mainTrace = [];
|
||||
let lastError = null;
|
||||
for (const candidate of candidates) {
|
||||
const resp = await this.authenticatedRequest(candidate, cookie, {}, finalUrl);
|
||||
if (resp.success && typeof resp.data === 'string' && resp.data.length > 0) {
|
||||
mainPageResponse = resp;
|
||||
mainTrace = resp.trace || [];
|
||||
break;
|
||||
}
|
||||
lastError = resp;
|
||||
}
|
||||
|
||||
if (!mainPageResponse) {
|
||||
return lastError || { success: false, error: 'HeTTV Einstiegsseite nicht erreichbar', status: 404 };
|
||||
}
|
||||
|
||||
const htmlContent = mainPageResponse.data;
|
||||
console.log('[HettvClient] - Main page loaded, HTML length:', htmlContent.length);
|
||||
|
||||
// Erkenne Fehlerseite (Session ungültig)
|
||||
if (/click-TT\s*-\s*Fehlerseite/i.test(htmlContent) || /ungültige oder nicht mehr gültige URL/i.test(htmlContent)) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Session ungültig oder abgelaufen',
|
||||
status: 401,
|
||||
data: { htmlSnippet: htmlContent.substring(0, 1000) }
|
||||
};
|
||||
}
|
||||
|
||||
// Speichere HTML zur Analyse
|
||||
let savedFile = null;
|
||||
try {
|
||||
const dir = path.resolve(process.cwd(), 'backend', 'uploads');
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
const filename = `hettv_main_${Date.now()}.html`;
|
||||
const filePath = path.join(dir, filename);
|
||||
fs.writeFileSync(filePath, htmlContent, 'utf8');
|
||||
savedFile = filePath;
|
||||
} catch (e) {
|
||||
// Ignoriere Speicherfehler still, nur für Debug
|
||||
}
|
||||
|
||||
// Suche nach Downloads-Links im HTML
|
||||
const downloadLinks = [];
|
||||
|
||||
// 1) URL-Heuristiken
|
||||
const urlPatterns = [
|
||||
/href="([^"]*download[^"]*)"/gi,
|
||||
/href="([^"]*downloads[^"]*)"/gi,
|
||||
/href="([^"]*Download[^"]*)"/gi,
|
||||
/href="([^"]*Downloads[^"]*)"/gi
|
||||
];
|
||||
|
||||
urlPatterns.forEach(pattern => {
|
||||
let match;
|
||||
while ((match = pattern.exec(htmlContent)) !== null) {
|
||||
const link = match[1];
|
||||
if (link && !downloadLinks.includes(link)) {
|
||||
downloadLinks.push(link);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 2) Linktext-Heuristik: <a ...>Downloads</a>
|
||||
const anchorPattern = /<a[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi;
|
||||
let aMatch;
|
||||
while ((aMatch = anchorPattern.exec(htmlContent)) !== null) {
|
||||
const href = aMatch[1];
|
||||
const text = aMatch[2].replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
if (/\bdownloads?\b/i.test(text)) {
|
||||
if (href && !downloadLinks.includes(href)) {
|
||||
downloadLinks.push(href);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Fallback: Menüpunkte in Navigationen (role="navigation" etc.)
|
||||
if (downloadLinks.length === 0) {
|
||||
const navSectionRegex = /<nav[\s\S]*?<\/nav>/gi;
|
||||
let nav;
|
||||
while ((nav = navSectionRegex.exec(htmlContent)) !== null) {
|
||||
const section = nav[0];
|
||||
let m;
|
||||
anchorPattern.lastIndex = 0;
|
||||
while ((m = anchorPattern.exec(section)) !== null) {
|
||||
const href = m[1];
|
||||
const text = m[2].replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
if (/\bdownloads?\b/i.test(text)) {
|
||||
if (href && !downloadLinks.includes(href)) {
|
||||
downloadLinks.push(href);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[HettvClient] - Found download links:', downloadLinks);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
htmlContent: htmlContent,
|
||||
downloadLinks: downloadLinks,
|
||||
htmlSnippet: htmlContent.substring(0, 2000), // Erste 2000 Zeichen für Analyse
|
||||
savedFile,
|
||||
trace: mainTrace,
|
||||
lastUrl: mainTrace.length ? mainTrace[mainTrace.length - 1].url : null,
|
||||
lastStatus: mainTrace.length ? mainTrace[mainTrace.length - 1].status : null
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('HeTTV main page error:', error.message);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || 'Fehler beim Laden der Hauptseite',
|
||||
status: 500
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a specific download page
|
||||
* @param {string} downloadUrl - URL to the download page
|
||||
* @param {string} cookie - Session cookie
|
||||
* @returns {Promise<Object>} Response with download page content
|
||||
*/
|
||||
async loadDownloadPage(downloadUrl, cookie, finalUrl = null) {
|
||||
try {
|
||||
console.log('[HettvClient] - Loading download page:', downloadUrl);
|
||||
|
||||
const response = await this.authenticatedRequest(downloadUrl, cookie, {}, finalUrl);
|
||||
if (!response.success) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const htmlContent = response.data;
|
||||
console.log('[HettvClient] - Download page loaded, HTML length:', htmlContent.length);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
url: downloadUrl,
|
||||
htmlContent: htmlContent,
|
||||
htmlSnippet: htmlContent.substring(0, 3000) // Erste 3000 Zeichen für Analyse
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('HeTTV download page error:', error.message);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || 'Fehler beim Laden der Download-Seite',
|
||||
status: 500
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// --- Cookie-Helfer ---
|
||||
_ingestSetCookiesFromResponse(currentUrl, setCookies) {
|
||||
if (!Array.isArray(setCookies) || setCookies.length === 0) return;
|
||||
const { host } = new URL(currentUrl);
|
||||
if (!this.cookieJar.has(host)) this.cookieJar.set(host, new Map());
|
||||
const jar = this.cookieJar.get(host);
|
||||
setCookies.forEach((cookieStr) => {
|
||||
const pair = cookieStr.split(';')[0];
|
||||
const eq = pair.indexOf('=');
|
||||
if (eq > 0) {
|
||||
const name = pair.substring(0, eq).trim();
|
||||
const value = pair.substring(eq + 1).trim();
|
||||
jar.set(name, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_cookieHeaderForUrl(currentUrl) {
|
||||
const { host } = new URL(currentUrl);
|
||||
const jar = this.cookieJar.get(host);
|
||||
if (!jar || jar.size === 0) return '';
|
||||
return Array.from(jar.entries()).map(([k, v]) => `${k}=${v}`).join('; ');
|
||||
}
|
||||
|
||||
_mergeCookieHeader(primary, secondary) {
|
||||
const items = [];
|
||||
if (primary) items.push(primary);
|
||||
if (secondary) items.push(secondary);
|
||||
return items.filter(Boolean).join('; ');
|
||||
}
|
||||
}
|
||||
|
||||
export default new HettvClient();
|
||||
|
||||
@@ -149,11 +149,13 @@ class MyTischtennisClient {
|
||||
* @returns {Promise<Object>} User profile with club info
|
||||
*/
|
||||
async getUserProfile(cookie) {
|
||||
console.log('[getUserProfile] - Calling /?_data=root with cookie:', cookie?.substring(0, 50) + '...');
|
||||
|
||||
const result = await this.authenticatedRequest('/?_data=root', cookie, {
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
console.log('[getUserProfile] - Result success:', result.success);
|
||||
|
||||
if (result.success) {
|
||||
console.log('[getUserProfile] - Response structure:', {
|
||||
@@ -167,6 +169,8 @@ class MyTischtennisClient {
|
||||
qttr: result.data?.userProfile?.qttr
|
||||
});
|
||||
|
||||
console.log('[getUserProfile] - Full userProfile.club:', result.data?.userProfile?.club);
|
||||
console.log('[getUserProfile] - Full userProfile.organization:', result.data?.userProfile?.organization);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -195,10 +199,12 @@ class MyTischtennisClient {
|
||||
let currentPage = 0;
|
||||
let hasMorePages = true;
|
||||
|
||||
console.log('[getClubRankings] - Starting to fetch rankings for club', clubId);
|
||||
|
||||
while (hasMorePages) {
|
||||
const endpoint = `/rankings/andro-rangliste?all-players=on&clubnr=${clubId}&fednickname=${fedNickname}&results-per-page=100&page=${currentPage}&_data=routes%2F%24`;
|
||||
|
||||
console.log(`[getClubRankings] - Fetching page ${currentPage}...`);
|
||||
|
||||
const result = await this.authenticatedRequest(endpoint, cookie, {
|
||||
method: 'GET'
|
||||
@@ -239,6 +245,7 @@ class MyTischtennisClient {
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`[getClubRankings] - Page ${currentPage}: Found ${entries.length} entries`);
|
||||
|
||||
// Füge Entries hinzu
|
||||
allEntries.push(...entries);
|
||||
@@ -248,15 +255,19 @@ class MyTischtennisClient {
|
||||
// Oder wenn wir alle erwarteten Einträge haben
|
||||
if (entries.length === 0) {
|
||||
hasMorePages = false;
|
||||
console.log('[getClubRankings] - No more entries, stopping');
|
||||
} else if (rankingData.numberOfPages && currentPage >= rankingData.numberOfPages - 1) {
|
||||
hasMorePages = false;
|
||||
console.log(`[getClubRankings] - Reached last page (${rankingData.numberOfPages})`);
|
||||
} else if (allEntries.length >= rankingData.resultLength) {
|
||||
hasMorePages = false;
|
||||
console.log(`[getClubRankings] - Got all entries (${allEntries.length}/${rankingData.resultLength})`);
|
||||
} else {
|
||||
currentPage++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[getClubRankings] - Total entries fetched: ${allEntries.length}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
import ClubTeamService from '../services/clubTeamService.js';
|
||||
import { getUserByToken } from '../utils/userUtils.js';
|
||||
import { devLog } from '../utils/logger.js';
|
||||
|
||||
export const getClubTeams = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubid: clubId } = req.params;
|
||||
const { seasonid: seasonId } = req.query;
|
||||
|
||||
const user = await getUserByToken(token);
|
||||
|
||||
// Check if user has access to this club
|
||||
const clubTeams = await ClubTeamService.getAllClubTeamsByClub(clubId, seasonId);
|
||||
|
||||
res.status(200).json(clubTeams);
|
||||
} catch (error) {
|
||||
console.error('[getClubTeams] - Error:', error);
|
||||
res.status(500).json({ error: "internalerror" });
|
||||
}
|
||||
};
|
||||
|
||||
export const getClubTeam = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubteamid: clubTeamId } = req.params;
|
||||
const user = await getUserByToken(token);
|
||||
|
||||
const clubTeam = await ClubTeamService.getClubTeamById(clubTeamId);
|
||||
if (!clubTeam) {
|
||||
return res.status(404).json({ error: "notfound" });
|
||||
}
|
||||
|
||||
res.status(200).json(clubTeam);
|
||||
} catch (error) {
|
||||
console.error('[getClubTeam] - Error:', error);
|
||||
res.status(500).json({ error: "internalerror" });
|
||||
}
|
||||
};
|
||||
|
||||
export const createClubTeam = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubid: clubId } = req.params;
|
||||
const { name, leagueId, seasonId } = req.body;
|
||||
|
||||
const user = await getUserByToken(token);
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: "missingname" });
|
||||
}
|
||||
|
||||
const clubTeamData = {
|
||||
name,
|
||||
clubId: parseInt(clubId),
|
||||
leagueId: leagueId ? parseInt(leagueId) : null,
|
||||
seasonId: seasonId ? parseInt(seasonId) : null
|
||||
};
|
||||
|
||||
const newClubTeam = await ClubTeamService.createClubTeam(clubTeamData);
|
||||
|
||||
res.status(201).json(newClubTeam);
|
||||
} catch (error) {
|
||||
console.error('[createClubTeam] - Error:', error);
|
||||
res.status(500).json({ error: "internalerror" });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateClubTeam = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubteamid: clubTeamId } = req.params;
|
||||
const { name, leagueId, seasonId } = req.body;
|
||||
|
||||
const user = await getUserByToken(token);
|
||||
|
||||
const updateData = {};
|
||||
if (name !== undefined) updateData.name = name;
|
||||
if (leagueId !== undefined) updateData.leagueId = leagueId ? parseInt(leagueId) : null;
|
||||
if (seasonId !== undefined) updateData.seasonId = seasonId ? parseInt(seasonId) : null;
|
||||
|
||||
const success = await ClubTeamService.updateClubTeam(clubTeamId, updateData);
|
||||
if (!success) {
|
||||
return res.status(404).json({ error: "notfound" });
|
||||
}
|
||||
|
||||
const updatedClubTeam = await ClubTeamService.getClubTeamById(clubTeamId);
|
||||
res.status(200).json(updatedClubTeam);
|
||||
} catch (error) {
|
||||
console.error('[updateClubTeam] - Error:', error);
|
||||
res.status(500).json({ error: "internalerror" });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteClubTeam = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubteamid: clubTeamId } = req.params;
|
||||
const user = await getUserByToken(token);
|
||||
|
||||
const success = await ClubTeamService.deleteClubTeam(clubTeamId);
|
||||
if (!success) {
|
||||
return res.status(404).json({ error: "notfound" });
|
||||
}
|
||||
|
||||
res.status(200).json({ message: "Club team deleted successfully" });
|
||||
} catch (error) {
|
||||
console.error('[deleteClubTeam] - Error:', error);
|
||||
res.status(500).json({ error: "internalerror" });
|
||||
}
|
||||
};
|
||||
|
||||
export const getLeagues = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubid: clubId } = req.params;
|
||||
const { seasonid: seasonId } = req.query;
|
||||
|
||||
const user = await getUserByToken(token);
|
||||
|
||||
const leagues = await ClubTeamService.getLeaguesByClub(clubId, seasonId);
|
||||
|
||||
res.status(200).json(leagues);
|
||||
} catch (error) {
|
||||
console.error('[getLeagues] - Error:', error);
|
||||
res.status(500).json({ error: "internalerror" });
|
||||
}
|
||||
};
|
||||
@@ -4,8 +4,11 @@ import { devLog } from '../utils/logger.js';
|
||||
|
||||
export const getClubs = async (req, res) => {
|
||||
try {
|
||||
devLog('[getClubs] - get clubs');
|
||||
const clubs = await ClubService.getAllClubs();
|
||||
devLog('[getClubs] - prepare response');
|
||||
res.status(200).json(clubs);
|
||||
devLog('[getClubs] - done');
|
||||
} catch (error) {
|
||||
console.error('[getClubs] - error:', error);
|
||||
res.status(500).json({ error: "internalerror" });
|
||||
@@ -13,20 +16,28 @@ export const getClubs = async (req, res) => {
|
||||
};
|
||||
|
||||
export const addClub = async (req, res) => {
|
||||
devLog('[addClub] - Read out parameters');
|
||||
const { authcode: token } = req.headers;
|
||||
const { name: clubName } = req.body;
|
||||
|
||||
try {
|
||||
devLog('[addClub] - find club by name');
|
||||
const club = await ClubService.findClubByName(clubName);
|
||||
devLog('[addClub] - get user');
|
||||
const user = await getUserByToken(token);
|
||||
devLog('[addClub] - check if club already exists');
|
||||
if (club) {
|
||||
res.status(409).json({ error: "alreadyexists" });
|
||||
return;
|
||||
}
|
||||
|
||||
devLog('[addClub] - create club');
|
||||
const newClub = await ClubService.createClub(clubName);
|
||||
devLog('[addClub] - add user to new club');
|
||||
await ClubService.addUserToClub(user.id, newClub.id);
|
||||
devLog('[addClub] - prepare response');
|
||||
res.status(200).json(newClub);
|
||||
devLog('[addClub] - done');
|
||||
} catch (error) {
|
||||
console.error('[addClub] - error:', error);
|
||||
res.status(500).json({ error: "internalerror" });
|
||||
@@ -34,22 +45,30 @@ export const addClub = async (req, res) => {
|
||||
};
|
||||
|
||||
export const getClub = async (req, res) => {
|
||||
devLog('[getClub] - start');
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubid: clubId } = req.params;
|
||||
devLog('[getClub] - get user');
|
||||
const user = await getUserByToken(token);
|
||||
devLog('[getClub] - get users club');
|
||||
const access = await ClubService.getUserClubAccess(user.id, clubId);
|
||||
devLog('[getClub] - check access');
|
||||
if (access.length === 0 || !access[0].approved) {
|
||||
res.status(403).json({ error: "noaccess", status: access.length === 0 ? "notrequested" : "requested" });
|
||||
return;
|
||||
}
|
||||
|
||||
devLog('[getClub] - get club');
|
||||
const club = await ClubService.findClubById(clubId);
|
||||
devLog('[getClub] - check club exists');
|
||||
if (!club) {
|
||||
return res.status(404).json({ message: 'Club not found' });
|
||||
}
|
||||
|
||||
devLog('[getClub] - set response');
|
||||
res.status(200).json(club);
|
||||
devLog('[getClub] - done');
|
||||
} catch (error) {
|
||||
console.error('[getClub] - error:', error);
|
||||
res.status(500).json({ message: 'Server error' });
|
||||
@@ -62,6 +81,7 @@ export const requestClubAccess = async (req, res) => {
|
||||
|
||||
try {
|
||||
const user = await getUserByToken(token);
|
||||
devLog('[requestClubAccess] - user:', user);
|
||||
|
||||
await ClubService.requestAccessToClub(user.id, clubId);
|
||||
res.status(200).json({});
|
||||
|
||||
@@ -17,6 +17,7 @@ export const createTag = async (req, res) => {
|
||||
const newTag = await DiaryTag.findOrCreate({ where: { name }, defaults: { name } });
|
||||
res.status(201).json(newTag);
|
||||
} catch (error) {
|
||||
devLog('[createTag] - Error:', error);
|
||||
res.status(500).json({ error: 'Error creating tag' });
|
||||
}
|
||||
};
|
||||
|
||||
172
backend/controllers/externalServiceController.js
Normal file
172
backend/controllers/externalServiceController.js
Normal file
@@ -0,0 +1,172 @@
|
||||
import externalServiceService from '../services/externalServiceService.js';
|
||||
import HttpError from '../exceptions/HttpError.js';
|
||||
|
||||
class ExternalServiceController {
|
||||
/**
|
||||
* GET /api/mytischtennis/account?service=mytischtennis
|
||||
* Get current user's external service account
|
||||
*/
|
||||
async getAccount(req, res, next) {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const service = req.query.service || 'mytischtennis';
|
||||
const account = await externalServiceService.getAccount(userId, service);
|
||||
|
||||
if (!account) {
|
||||
return res.status(200).json({ account: null });
|
||||
}
|
||||
|
||||
res.status(200).json({ account });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/mytischtennis/status?service=mytischtennis
|
||||
* Check account configuration status
|
||||
*/
|
||||
async getStatus(req, res, next) {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const service = req.query.service || 'mytischtennis';
|
||||
const status = await externalServiceService.checkAccountStatus(userId, service);
|
||||
res.status(200).json(status);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/mytischtennis/account
|
||||
* Create or update external service account
|
||||
*/
|
||||
async upsertAccount(req, res, next) {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const { email, password, savePassword, userPassword, service = 'mytischtennis' } = req.body;
|
||||
|
||||
if (!email) {
|
||||
throw new HttpError(400, 'E-Mail-Adresse erforderlich');
|
||||
}
|
||||
|
||||
// Wenn ein Passwort gesetzt wird, muss das App-Passwort angegeben werden
|
||||
if (password && !userPassword) {
|
||||
throw new HttpError(400, 'App-Passwort erforderlich zum Setzen des myTischtennis-Passworts');
|
||||
}
|
||||
|
||||
const account = await externalServiceService.upsertAccount(
|
||||
userId,
|
||||
email,
|
||||
password,
|
||||
savePassword || false,
|
||||
userPassword,
|
||||
service
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
message: `${service}-Account erfolgreich gespeichert`,
|
||||
account
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/mytischtennis/account?service=mytischtennis
|
||||
* Delete external service account
|
||||
*/
|
||||
async deleteAccount(req, res, next) {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const service = req.query.service || 'mytischtennis';
|
||||
const deleted = await externalServiceService.deleteAccount(userId, service);
|
||||
|
||||
if (!deleted) {
|
||||
throw new HttpError(404, `Kein ${service}-Account gefunden`);
|
||||
}
|
||||
|
||||
res.status(200).json({ message: `${service}-Account gelöscht` });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/mytischtennis/verify
|
||||
* Verify login credentials
|
||||
*/
|
||||
async verifyLogin(req, res, next) {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const { password, service = 'mytischtennis' } = req.body;
|
||||
|
||||
const result = await externalServiceService.verifyLogin(userId, password, service);
|
||||
|
||||
res.status(200).json({
|
||||
message: 'Login erfolgreich',
|
||||
success: true,
|
||||
accessToken: result.accessToken,
|
||||
expiresAt: result.expiresAt,
|
||||
clubId: result.clubId,
|
||||
clubName: result.clubName
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/mytischtennis/session?service=mytischtennis
|
||||
* Get stored session data for authenticated requests
|
||||
*/
|
||||
async getSession(req, res, next) {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const service = req.query.service || 'mytischtennis';
|
||||
const session = await externalServiceService.getSession(userId, service);
|
||||
|
||||
res.status(200).json({ session });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/external-service/hettv/main-page
|
||||
* Load HeTTV main page and find download links
|
||||
*/
|
||||
async loadHettvMainPage(req, res, next) {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const result = await externalServiceService.loadHettvMainPage(userId);
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/external-service/hettv/download-page
|
||||
* Load specific HeTTV download page
|
||||
*/
|
||||
async loadHettvDownloadPage(req, res, next) {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const { downloadUrl } = req.body;
|
||||
|
||||
if (!downloadUrl) {
|
||||
throw new HttpError(400, 'Download-URL ist erforderlich');
|
||||
}
|
||||
|
||||
const result = await externalServiceService.loadHettvDownloadPage(userId, downloadUrl);
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new ExternalServiceController();
|
||||
|
||||
@@ -25,8 +25,7 @@ export const getLeaguesForCurrentSeason = async (req, res) => {
|
||||
devLog(req.headers, req.params);
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId } = req.params;
|
||||
const { seasonid: seasonId } = req.query;
|
||||
const leagues = await MatchService.getLeaguesForCurrentSeason(userToken, clubId, seasonId);
|
||||
const leagues = await MatchService.getLeaguesForCurrentSeason(userToken, clubId);
|
||||
return res.status(200).json(leagues);
|
||||
} catch (error) {
|
||||
console.error('Error retrieving leagues:', error);
|
||||
@@ -38,8 +37,7 @@ export const getMatchesForLeagues = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId } = req.params;
|
||||
const { seasonid: seasonId } = req.query;
|
||||
const matches = await MatchService.getMatchesForLeagues(userToken, clubId, seasonId);
|
||||
const matches = await MatchService.getMatchesForLeagues(userToken, clubId);
|
||||
return res.status(200).json(matches);
|
||||
} catch (error) {
|
||||
console.error('Error retrieving matches:', error);
|
||||
@@ -58,47 +56,3 @@ export const getMatchesForLeague = async (req, res) => {
|
||||
return res.status(500).json({ error: 'Failed to retrieve matches' });
|
||||
}
|
||||
};
|
||||
|
||||
export const getLeagueTable = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, leagueId } = req.params;
|
||||
const table = await MatchService.getLeagueTable(userToken, clubId, leagueId);
|
||||
return res.status(200).json(table);
|
||||
} catch (error) {
|
||||
console.error('Error retrieving league table:', error);
|
||||
return res.status(500).json({ error: 'Failed to retrieve league table' });
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchLeagueTableFromMyTischtennis = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, leagueId } = req.params;
|
||||
const { userid: userIdOrEmail } = req.headers;
|
||||
|
||||
// Convert email to userId if needed
|
||||
let userId = userIdOrEmail;
|
||||
if (isNaN(userIdOrEmail)) {
|
||||
const User = (await import('../models/User.js')).default;
|
||||
const user = await User.findOne({ where: { email: userIdOrEmail } });
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
userId = user.id;
|
||||
}
|
||||
|
||||
const autoFetchService = (await import('../services/autoFetchMatchResultsService.js')).default;
|
||||
await autoFetchService.fetchAndUpdateLeagueTable(userId, leagueId);
|
||||
|
||||
// Return updated table data
|
||||
const table = await MatchService.getLeagueTable(userToken, clubId, leagueId);
|
||||
return res.status(200).json({
|
||||
message: 'League table updated from MyTischtennis',
|
||||
data: table
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching league table from MyTischtennis:', error);
|
||||
return res.status(500).json({ error: 'Failed to fetch league table from MyTischtennis' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -10,17 +10,24 @@ const getClubMembers = async(req, res) => {
|
||||
}
|
||||
res.status(200).json(await MemberService.getClubMembers(userToken, clubId, showAll));
|
||||
} catch(error) {
|
||||
devLog('[getClubMembers] - Error: ', error);
|
||||
res.status(500).json({ error: 'systemerror' });
|
||||
}
|
||||
}
|
||||
|
||||
const getWaitingApprovals = async(req, res) => {
|
||||
try {
|
||||
devLog('[getWaitingApprovals] - Start');
|
||||
const { id: clubId } = req.params;
|
||||
devLog('[getWaitingApprovals] - get token');
|
||||
const { authcode: userToken } = req.headers;
|
||||
devLog('[getWaitingApprovals] - load for waiting approvals');
|
||||
const waitingApprovals = await MemberService.getApprovalRequests(userToken, clubId);
|
||||
devLog('[getWaitingApprovals] - set response');
|
||||
res.status(200).json(waitingApprovals);
|
||||
devLog('[getWaitingApprovals] - done');
|
||||
} catch(error) {
|
||||
devLog('[getWaitingApprovals] - Error: ', error);
|
||||
res.status(403).json({ error: error });
|
||||
}
|
||||
}
|
||||
@@ -53,6 +60,7 @@ const uploadMemberImage = async (req, res) => {
|
||||
};
|
||||
|
||||
const getMemberImage = async (req, res) => {
|
||||
devLog('[getMemberImage]');
|
||||
try {
|
||||
const { clubId, memberId } = req.params;
|
||||
const { authcode: userToken } = req.headers;
|
||||
@@ -69,6 +77,7 @@ const getMemberImage = async (req, res) => {
|
||||
};
|
||||
|
||||
const updateRatingsFromMyTischtennis = async (req, res) => {
|
||||
devLog('[updateRatingsFromMyTischtennis]');
|
||||
try {
|
||||
const { id: clubId } = req.params;
|
||||
const { authcode: userToken } = req.headers;
|
||||
@@ -80,25 +89,4 @@ const updateRatingsFromMyTischtennis = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
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 };
|
||||
export { getClubMembers, getWaitingApprovals, setClubMembers, uploadMemberImage, getMemberImage, updateRatingsFromMyTischtennis };
|
||||
@@ -6,9 +6,11 @@ const getMemberNotes = async (req, res) => {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { memberId } = req.params;
|
||||
const { clubId } = req.query;
|
||||
devLog('[getMemberNotes]', userToken, memberId, clubId);
|
||||
const notes = await MemberNoteService.getNotesForMember(userToken, clubId, memberId);
|
||||
res.status(200).json(notes);
|
||||
} catch (error) {
|
||||
devLog('[getMemberNotes] - Error: ', error);
|
||||
res.status(500).json({ error: 'systemerror' });
|
||||
}
|
||||
};
|
||||
@@ -17,10 +19,12 @@ const addMemberNote = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { memberId, content, clubId } = req.body;
|
||||
devLog('[addMemberNote]', userToken, memberId, content, clubId);
|
||||
await MemberNoteService.addNoteToMember(userToken, clubId, memberId, content);
|
||||
const notes = await MemberNoteService.getNotesForMember(userToken, clubId, memberId);
|
||||
res.status(201).json(notes);
|
||||
} catch (error) {
|
||||
devLog('[addMemberNote] - Error: ', error);
|
||||
res.status(500).json({ error: 'systemerror' });
|
||||
}
|
||||
};
|
||||
@@ -30,11 +34,13 @@ const deleteMemberNote = async (req, res) => {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { noteId } = req.params;
|
||||
const { clubId } = req.body;
|
||||
devLog('[deleteMemberNote]', userToken, noteId, clubId);
|
||||
const memberId = await MemberNoteService.getMemberIdForNote(noteId); // Member ID ermitteln
|
||||
await MemberNoteService.deleteNoteForMember(userToken, clubId, noteId);
|
||||
const notes = await MemberNoteService.getNotesForMember(userToken, clubId, memberId);
|
||||
res.status(200).json(notes);
|
||||
} catch (error) {
|
||||
devLog('[deleteMemberNote] - Error: ', error);
|
||||
res.status(500).json({ error: 'systemerror' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
import myTischtennisService from '../services/myTischtennisService.js';
|
||||
import HttpError from '../exceptions/HttpError.js';
|
||||
|
||||
class MyTischtennisController {
|
||||
/**
|
||||
* GET /api/mytischtennis/account
|
||||
* Get current user's myTischtennis account
|
||||
*/
|
||||
async getAccount(req, res, next) {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const account = await myTischtennisService.getAccount(userId);
|
||||
|
||||
if (!account) {
|
||||
return res.status(200).json({ account: null });
|
||||
}
|
||||
|
||||
res.status(200).json({ account });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/mytischtennis/status
|
||||
* Check account configuration status
|
||||
*/
|
||||
async getStatus(req, res, next) {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const status = await myTischtennisService.checkAccountStatus(userId);
|
||||
res.status(200).json(status);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/mytischtennis/account
|
||||
* Create or update myTischtennis account
|
||||
*/
|
||||
async upsertAccount(req, res, next) {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const { email, password, savePassword, autoUpdateRatings, userPassword } = req.body;
|
||||
|
||||
if (!email) {
|
||||
throw new HttpError(400, 'E-Mail-Adresse erforderlich');
|
||||
}
|
||||
|
||||
// Wenn ein Passwort gesetzt wird, muss das App-Passwort angegeben werden
|
||||
if (password && !userPassword) {
|
||||
throw new HttpError(400, 'App-Passwort erforderlich zum Setzen des myTischtennis-Passworts');
|
||||
}
|
||||
|
||||
const account = await myTischtennisService.upsertAccount(
|
||||
userId,
|
||||
email,
|
||||
password,
|
||||
savePassword || false,
|
||||
autoUpdateRatings || false,
|
||||
userPassword
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
message: 'myTischtennis-Account erfolgreich gespeichert',
|
||||
account
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/mytischtennis/account
|
||||
* Delete myTischtennis account
|
||||
*/
|
||||
async deleteAccount(req, res, next) {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const deleted = await myTischtennisService.deleteAccount(userId);
|
||||
|
||||
if (!deleted) {
|
||||
throw new HttpError(404, 'Kein myTischtennis-Account gefunden');
|
||||
}
|
||||
|
||||
res.status(200).json({ message: 'myTischtennis-Account gelöscht' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/mytischtennis/verify
|
||||
* Verify login credentials
|
||||
*/
|
||||
async verifyLogin(req, res, next) {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const { password } = req.body;
|
||||
|
||||
const result = await myTischtennisService.verifyLogin(userId, password);
|
||||
|
||||
res.status(200).json({
|
||||
message: 'Login erfolgreich',
|
||||
success: true,
|
||||
accessToken: result.accessToken,
|
||||
expiresAt: result.expiresAt,
|
||||
clubId: result.clubId,
|
||||
clubName: result.clubName
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/mytischtennis/session
|
||||
* Get stored session data for authenticated requests
|
||||
*/
|
||||
async getSession(req, res, next) {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const session = await myTischtennisService.getSession(userId);
|
||||
|
||||
res.status(200).json({ session });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/mytischtennis/update-history
|
||||
* Get update ratings history
|
||||
*/
|
||||
async getUpdateHistory(req, res, next) {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const history = await myTischtennisService.getUpdateHistory(userId);
|
||||
res.status(200).json({ history });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fetch logs for current user
|
||||
*/
|
||||
async getFetchLogs(req, res, next) {
|
||||
try {
|
||||
const { userid: userIdOrEmail } = req.headers;
|
||||
|
||||
// Convert email to userId if needed
|
||||
let userId = userIdOrEmail;
|
||||
if (isNaN(userIdOrEmail)) {
|
||||
const User = (await import('../models/User.js')).default;
|
||||
const user = await User.findOne({ where: { email: userIdOrEmail } });
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
userId = user.id;
|
||||
}
|
||||
|
||||
const fetchLogService = (await import('../services/myTischtennisFetchLogService.js')).default;
|
||||
const logs = await fetchLogService.getFetchLogs(userId, {
|
||||
limit: req.query.limit ? parseInt(req.query.limit) : 50,
|
||||
fetchType: req.query.type
|
||||
});
|
||||
|
||||
res.status(200).json({ logs });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get latest successful fetches for each type
|
||||
*/
|
||||
async getLatestFetches(req, res, next) {
|
||||
try {
|
||||
const { userid: userIdOrEmail } = req.headers;
|
||||
|
||||
// Convert email to userId if needed
|
||||
let userId = userIdOrEmail;
|
||||
if (isNaN(userIdOrEmail)) {
|
||||
const User = (await import('../models/User.js')).default;
|
||||
const user = await User.findOne({ where: { email: userIdOrEmail } });
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
userId = user.id;
|
||||
}
|
||||
|
||||
const fetchLogService = (await import('../services/myTischtennisFetchLogService.js')).default;
|
||||
const latestFetches = await fetchLogService.getLatestSuccessfulFetches(userId);
|
||||
|
||||
res.status(200).json({ latestFetches });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new MyTischtennisController();
|
||||
|
||||
@@ -1,477 +0,0 @@
|
||||
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();
|
||||
@@ -36,6 +36,7 @@ export const uploadPredefinedActivityImage = async (req, res) => {
|
||||
|
||||
// Extrahiere Zeichnungsdaten aus dem Request
|
||||
const drawingData = req.body.drawingData ? JSON.parse(req.body.drawingData) : null;
|
||||
devLog('[uploadPredefinedActivityImage] - drawingData:', drawingData);
|
||||
|
||||
const imageRecord = await PredefinedActivityImage.create({
|
||||
predefinedActivityId: id,
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import SeasonService from '../services/seasonService.js';
|
||||
import { getUserByToken } from '../utils/userUtils.js';
|
||||
import { devLog } from '../utils/logger.js';
|
||||
|
||||
export const getSeasons = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const user = await getUserByToken(token);
|
||||
|
||||
const seasons = await SeasonService.getAllSeasons();
|
||||
|
||||
res.status(200).json(seasons);
|
||||
} catch (error) {
|
||||
console.error('[getSeasons] - Error:', error);
|
||||
res.status(500).json({ error: "internalerror" });
|
||||
}
|
||||
};
|
||||
|
||||
export const getCurrentSeason = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const user = await getUserByToken(token);
|
||||
|
||||
const season = await SeasonService.getOrCreateCurrentSeason();
|
||||
|
||||
res.status(200).json(season);
|
||||
} catch (error) {
|
||||
console.error('[getCurrentSeason] - Error:', error);
|
||||
res.status(500).json({ error: "internalerror" });
|
||||
}
|
||||
};
|
||||
|
||||
export const createSeason = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { season } = req.body;
|
||||
|
||||
const user = await getUserByToken(token);
|
||||
|
||||
if (!season) {
|
||||
return res.status(400).json({ error: "missingseason" });
|
||||
}
|
||||
|
||||
// Validiere Saison-Format (z.B. "2023/2024")
|
||||
const seasonRegex = /^\d{4}\/\d{4}$/;
|
||||
if (!seasonRegex.test(season)) {
|
||||
return res.status(400).json({ error: "invalidseasonformat" });
|
||||
}
|
||||
|
||||
const newSeason = await SeasonService.createSeason(season);
|
||||
|
||||
res.status(201).json(newSeason);
|
||||
} catch (error) {
|
||||
console.error('[createSeason] - Error:', error);
|
||||
if (error.message === 'Season already exists') {
|
||||
res.status(409).json({ error: "alreadyexists" });
|
||||
} else {
|
||||
res.status(500).json({ error: "internalerror" });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const getSeason = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { seasonid: seasonId } = req.params;
|
||||
|
||||
const user = await getUserByToken(token);
|
||||
|
||||
const season = await SeasonService.getSeasonById(seasonId);
|
||||
if (!season) {
|
||||
return res.status(404).json({ error: "notfound" });
|
||||
}
|
||||
|
||||
res.status(200).json(season);
|
||||
} catch (error) {
|
||||
console.error('[getSeason] - Error:', error);
|
||||
res.status(500).json({ error: "internalerror" });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteSeason = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { seasonid: seasonId } = req.params;
|
||||
|
||||
const user = await getUserByToken(token);
|
||||
|
||||
const success = await SeasonService.deleteSeason(seasonId);
|
||||
if (!success) {
|
||||
return res.status(404).json({ error: "notfound" });
|
||||
}
|
||||
|
||||
res.status(200).json({ message: "deleted" });
|
||||
} catch (error) {
|
||||
console.error('[deleteSeason] - Error:', error);
|
||||
if (error.message === 'Season is used by teams' || error.message === 'Season is used by leagues') {
|
||||
res.status(409).json({ error: "seasoninuse" });
|
||||
} else {
|
||||
res.status(500).json({ error: "internalerror" });
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,130 +0,0 @@
|
||||
import TeamService from '../services/teamService.js';
|
||||
import { getUserByToken } from '../utils/userUtils.js';
|
||||
import { devLog } from '../utils/logger.js';
|
||||
|
||||
export const getTeams = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubid: clubId } = req.params;
|
||||
const { seasonid: seasonId } = req.query;
|
||||
|
||||
const user = await getUserByToken(token);
|
||||
|
||||
// Check if user has access to this club
|
||||
const teams = await TeamService.getAllTeamsByClub(clubId, seasonId);
|
||||
|
||||
res.status(200).json(teams);
|
||||
} catch (error) {
|
||||
console.error('[getTeams] - Error:', error);
|
||||
res.status(500).json({ error: "internalerror" });
|
||||
}
|
||||
};
|
||||
|
||||
export const getTeam = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { teamid: teamId } = req.params;
|
||||
|
||||
const user = await getUserByToken(token);
|
||||
|
||||
const team = await TeamService.getTeamById(teamId);
|
||||
if (!team) {
|
||||
return res.status(404).json({ error: "notfound" });
|
||||
}
|
||||
|
||||
res.status(200).json(team);
|
||||
} catch (error) {
|
||||
console.error('[getTeam] - Error:', error);
|
||||
res.status(500).json({ error: "internalerror" });
|
||||
}
|
||||
};
|
||||
|
||||
export const createTeam = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubid: clubId } = req.params;
|
||||
const { name, leagueId, seasonId } = req.body;
|
||||
|
||||
const user = await getUserByToken(token);
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: "missingname" });
|
||||
}
|
||||
|
||||
const teamData = {
|
||||
name,
|
||||
clubId: parseInt(clubId),
|
||||
leagueId: leagueId ? parseInt(leagueId) : null,
|
||||
seasonId: seasonId ? parseInt(seasonId) : null
|
||||
};
|
||||
|
||||
const newTeam = await TeamService.createTeam(teamData);
|
||||
|
||||
res.status(201).json(newTeam);
|
||||
} catch (error) {
|
||||
console.error('[createTeam] - Error:', error);
|
||||
res.status(500).json({ error: "internalerror" });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateTeam = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { teamid: teamId } = req.params;
|
||||
const { name, leagueId, seasonId } = req.body;
|
||||
|
||||
const user = await getUserByToken(token);
|
||||
|
||||
const updateData = {};
|
||||
if (name !== undefined) updateData.name = name;
|
||||
if (leagueId !== undefined) updateData.leagueId = leagueId ? parseInt(leagueId) : null;
|
||||
if (seasonId !== undefined) updateData.seasonId = seasonId ? parseInt(seasonId) : null;
|
||||
|
||||
const success = await TeamService.updateTeam(teamId, updateData);
|
||||
if (!success) {
|
||||
return res.status(404).json({ error: "notfound" });
|
||||
}
|
||||
|
||||
const updatedTeam = await TeamService.getTeamById(teamId);
|
||||
res.status(200).json(updatedTeam);
|
||||
} catch (error) {
|
||||
console.error('[updateTeam] - Error:', error);
|
||||
res.status(500).json({ error: "internalerror" });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteTeam = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { teamid: teamId } = req.params;
|
||||
|
||||
const user = await getUserByToken(token);
|
||||
|
||||
const success = await TeamService.deleteTeam(teamId);
|
||||
if (!success) {
|
||||
return res.status(404).json({ error: "notfound" });
|
||||
}
|
||||
|
||||
res.status(200).json({ message: "deleted" });
|
||||
} catch (error) {
|
||||
console.error('[deleteTeam] - Error:', error);
|
||||
res.status(500).json({ error: "internalerror" });
|
||||
}
|
||||
};
|
||||
|
||||
export const getLeagues = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubid: clubId } = req.params;
|
||||
const { seasonid: seasonId } = req.query;
|
||||
|
||||
const user = await getUserByToken(token);
|
||||
|
||||
const leagues = await TeamService.getLeaguesByClub(clubId, seasonId);
|
||||
|
||||
res.status(200).json(leagues);
|
||||
} catch (error) {
|
||||
console.error('[getLeagues] - Error:', error);
|
||||
res.status(500).json({ error: "internalerror" });
|
||||
}
|
||||
};
|
||||
@@ -1,215 +0,0 @@
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import TeamDocumentService from '../services/teamDocumentService.js';
|
||||
import PDFParserService from '../services/pdfParserService.js';
|
||||
import { getUserByToken } from '../utils/userUtils.js';
|
||||
import { devLog } from '../utils/logger.js';
|
||||
|
||||
// Multer-Konfiguration für Datei-Uploads
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
cb(null, 'uploads/temp/');
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
cb(null, file.fieldname + '-' + uniqueSuffix + path.extname(file.originalname));
|
||||
}
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage: storage,
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024 // 10MB Limit
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
// Erlaube nur PDF, DOC, DOCX, TXT, CSV Dateien
|
||||
const allowedTypes = /pdf|doc|docx|txt|csv/;
|
||||
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
|
||||
const mimetype = allowedTypes.test(file.mimetype);
|
||||
|
||||
if (mimetype && extname) {
|
||||
return cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Nur PDF, DOC, DOCX, TXT und CSV Dateien sind erlaubt!'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const uploadMiddleware = upload.single('document');
|
||||
|
||||
export const uploadDocument = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubteamid: clubTeamId } = req.params;
|
||||
const { documentType } = req.body;
|
||||
|
||||
const user = await getUserByToken(token);
|
||||
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: "nofile" });
|
||||
}
|
||||
|
||||
if (!documentType || !['code_list', 'pin_list'].includes(documentType)) {
|
||||
return res.status(400).json({ error: "invaliddocumenttype" });
|
||||
}
|
||||
|
||||
const document = await TeamDocumentService.uploadDocument(req.file, clubTeamId, documentType);
|
||||
|
||||
res.status(201).json(document);
|
||||
} catch (error) {
|
||||
console.error('[uploadDocument] - Error:', error);
|
||||
|
||||
// Lösche temporäre Datei bei Fehler
|
||||
if (req.file && req.file.path) {
|
||||
try {
|
||||
const fs = await import('fs');
|
||||
fs.unlinkSync(req.file.path);
|
||||
} catch (cleanupError) {
|
||||
console.error('Fehler beim Löschen der temporären Datei:', cleanupError);
|
||||
}
|
||||
}
|
||||
|
||||
if (error.message === 'Club-Team nicht gefunden') {
|
||||
return res.status(404).json({ error: "clubteamnotfound" });
|
||||
}
|
||||
|
||||
res.status(500).json({ error: "internalerror" });
|
||||
}
|
||||
};
|
||||
|
||||
export const getDocuments = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubteamid: clubTeamId } = req.params;
|
||||
|
||||
const user = await getUserByToken(token);
|
||||
|
||||
const documents = await TeamDocumentService.getDocumentsByClubTeam(clubTeamId);
|
||||
|
||||
res.status(200).json(documents);
|
||||
} catch (error) {
|
||||
console.error('[getDocuments] - Error:', error);
|
||||
res.status(500).json({ error: "internalerror" });
|
||||
}
|
||||
};
|
||||
|
||||
export const getDocument = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { documentid: documentId } = req.params;
|
||||
|
||||
const user = await getUserByToken(token);
|
||||
|
||||
const document = await TeamDocumentService.getDocumentById(documentId);
|
||||
if (!document) {
|
||||
return res.status(404).json({ error: "notfound" });
|
||||
}
|
||||
|
||||
res.status(200).json(document);
|
||||
} catch (error) {
|
||||
console.error('[getDocument] - Error:', error);
|
||||
res.status(500).json({ error: "internalerror" });
|
||||
}
|
||||
};
|
||||
|
||||
export const downloadDocument = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { documentid: documentId } = req.params;
|
||||
|
||||
const user = await getUserByToken(token);
|
||||
|
||||
const document = await TeamDocumentService.getDocumentById(documentId);
|
||||
if (!document) {
|
||||
return res.status(404).json({ error: "notfound" });
|
||||
}
|
||||
|
||||
const filePath = await TeamDocumentService.getDocumentPath(documentId);
|
||||
if (!filePath) {
|
||||
return res.status(404).json({ error: "filenotfound" });
|
||||
}
|
||||
|
||||
// Prüfe ob Datei existiert
|
||||
const fs = await import('fs');
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return res.status(404).json({ error: "filenotfound" });
|
||||
}
|
||||
|
||||
// Setze Headers für Inline-Anzeige (PDF-Viewer)
|
||||
res.setHeader('Content-Disposition', `inline; filename="${document.originalFileName}"`);
|
||||
res.setHeader('Content-Type', document.mimeType);
|
||||
|
||||
// Sende die Datei
|
||||
res.sendFile(filePath);
|
||||
} catch (error) {
|
||||
console.error('[downloadDocument] - Error:', error);
|
||||
res.status(500).json({ error: "internalerror" });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteDocument = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { documentid: documentId } = req.params;
|
||||
|
||||
const user = await getUserByToken(token);
|
||||
|
||||
const success = await TeamDocumentService.deleteDocument(documentId);
|
||||
if (!success) {
|
||||
return res.status(404).json({ error: "notfound" });
|
||||
}
|
||||
|
||||
res.status(200).json({ message: "Document deleted successfully" });
|
||||
} catch (error) {
|
||||
console.error('[deleteDocument] - Error:', error);
|
||||
res.status(500).json({ error: "internalerror" });
|
||||
}
|
||||
};
|
||||
|
||||
export const parsePDF = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { documentid: documentId } = req.params;
|
||||
const { leagueid: leagueId } = req.query;
|
||||
|
||||
const user = await getUserByToken(token);
|
||||
|
||||
if (!leagueId) {
|
||||
return res.status(400).json({ error: "missingleagueid" });
|
||||
}
|
||||
|
||||
// Hole Dokument-Informationen
|
||||
const document = await TeamDocumentService.getDocumentById(documentId);
|
||||
if (!document) {
|
||||
return res.status(404).json({ error: "documentnotfound" });
|
||||
}
|
||||
|
||||
// Prüfe ob es eine PDF- oder TXT-Datei ist
|
||||
if (!document.mimeType.includes('pdf') && !document.mimeType.includes('text/plain')) {
|
||||
return res.status(400).json({ error: "notapdfortxt" });
|
||||
}
|
||||
|
||||
// Parse PDF
|
||||
const parseResult = await PDFParserService.parsePDF(document.filePath, document.clubTeam.clubId);
|
||||
|
||||
// Speichere Matches in Datenbank
|
||||
const saveResult = await PDFParserService.saveMatchesToDatabase(parseResult.matches, parseInt(leagueId));
|
||||
|
||||
res.status(200).json({
|
||||
parseResult: {
|
||||
matchesFound: parseResult.matches.length,
|
||||
debugInfo: parseResult.debugInfo,
|
||||
allLines: parseResult.allLines,
|
||||
rawText: parseResult.rawText
|
||||
},
|
||||
saveResult: {
|
||||
created: saveResult.created,
|
||||
updated: saveResult.updated,
|
||||
errors: saveResult.errors
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[parsePDF] - Error:', error);
|
||||
res.status(500).json({ error: "internalerror" });
|
||||
}
|
||||
};
|
||||
@@ -1,13 +0,0 @@
|
||||
-- Migration: Add auto update ratings fields to my_tischtennis table
|
||||
-- Date: 2025-01-27
|
||||
|
||||
-- Add auto_update_ratings column
|
||||
ALTER TABLE my_tischtennis
|
||||
ADD COLUMN auto_update_ratings BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
-- Add last_update_ratings column
|
||||
ALTER TABLE my_tischtennis
|
||||
ADD COLUMN last_update_ratings TIMESTAMP NULL;
|
||||
|
||||
-- Create index for auto_update_ratings for efficient querying
|
||||
CREATE INDEX idx_my_tischtennis_auto_update_ratings ON my_tischtennis(auto_update_ratings);
|
||||
@@ -1,28 +0,0 @@
|
||||
-- Migration: Add match result fields to match table
|
||||
-- Date: 2025-01-27
|
||||
-- For MariaDB
|
||||
|
||||
-- Add myTischtennis meeting ID
|
||||
ALTER TABLE `match`
|
||||
ADD COLUMN my_tischtennis_meeting_id VARCHAR(255) NULL UNIQUE COMMENT 'Meeting ID from myTischtennis (e.g. 15440488)';
|
||||
|
||||
-- Add home match points
|
||||
ALTER TABLE `match`
|
||||
ADD COLUMN home_match_points INT DEFAULT 0 NULL COMMENT 'Match points won by home team';
|
||||
|
||||
-- Add guest match points
|
||||
ALTER TABLE `match`
|
||||
ADD COLUMN guest_match_points INT DEFAULT 0 NULL COMMENT 'Match points won by guest team';
|
||||
|
||||
-- Add is_completed flag
|
||||
ALTER TABLE `match`
|
||||
ADD COLUMN is_completed BOOLEAN NOT NULL DEFAULT FALSE COMMENT 'Whether the match is completed';
|
||||
|
||||
-- Add PDF URL
|
||||
ALTER TABLE `match`
|
||||
ADD COLUMN pdf_url VARCHAR(512) NULL COMMENT 'PDF URL from myTischtennis';
|
||||
|
||||
-- Create indexes
|
||||
CREATE INDEX idx_match_my_tischtennis_meeting_id ON `match`(my_tischtennis_meeting_id);
|
||||
CREATE INDEX idx_match_is_completed ON `match`(is_completed);
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
-- Add matches_tied column to team table
|
||||
ALTER TABLE team
|
||||
ADD COLUMN matches_tied INTEGER NOT NULL DEFAULT 0 AFTER matches_lost;
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
-- Migration: Add myTischtennis fields to league table
|
||||
-- Date: 2025-01-27
|
||||
-- For MariaDB
|
||||
|
||||
-- Add my_tischtennis_group_id column
|
||||
ALTER TABLE league
|
||||
ADD COLUMN my_tischtennis_group_id VARCHAR(255) NULL COMMENT 'Group ID from myTischtennis (e.g. 504417)';
|
||||
|
||||
-- Add association column
|
||||
ALTER TABLE league
|
||||
ADD COLUMN association VARCHAR(255) NULL COMMENT 'Association/Verband (e.g. HeTTV)';
|
||||
|
||||
-- Add groupname column
|
||||
ALTER TABLE league
|
||||
ADD COLUMN groupname VARCHAR(255) NULL COMMENT 'Group name for URL (e.g. 1.Kreisklasse)';
|
||||
|
||||
-- Create index for efficient querying
|
||||
CREATE INDEX idx_league_my_tischtennis_group_id ON league(my_tischtennis_group_id);
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
-- Migration: Add myTischtennis player ID to member table
|
||||
-- Date: 2025-01-27
|
||||
-- For MariaDB
|
||||
|
||||
-- Add my_tischtennis_player_id column
|
||||
ALTER TABLE member
|
||||
ADD COLUMN my_tischtennis_player_id VARCHAR(255) NULL COMMENT 'Player ID from myTischtennis (e.g. NU2705037)';
|
||||
|
||||
-- Create index for efficient querying
|
||||
CREATE INDEX idx_member_my_tischtennis_player_id ON member(my_tischtennis_player_id);
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
-- Migration: Add myTischtennis team ID to club_team table
|
||||
-- Date: 2025-01-27
|
||||
-- For MariaDB
|
||||
|
||||
-- Add my_tischtennis_team_id column
|
||||
ALTER TABLE club_team
|
||||
ADD COLUMN my_tischtennis_team_id VARCHAR(255) NULL COMMENT 'Team ID from myTischtennis (e.g. 2995094)';
|
||||
|
||||
-- Create index for efficient querying
|
||||
CREATE INDEX idx_club_team_my_tischtennis_team_id ON club_team(my_tischtennis_team_id);
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
-- Migration: Add season_id to teams table
|
||||
-- First, add the column as nullable
|
||||
ALTER TABLE `team` ADD COLUMN `season_id` INT NULL;
|
||||
|
||||
-- Get or create current season
|
||||
SET @current_season_id = (
|
||||
SELECT id FROM `season`
|
||||
WHERE season = (
|
||||
CASE
|
||||
WHEN MONTH(CURDATE()) >= 7 THEN CONCAT(YEAR(CURDATE()), '/', YEAR(CURDATE()) + 1)
|
||||
ELSE CONCAT(YEAR(CURDATE()) - 1, '/', YEAR(CURDATE()))
|
||||
END
|
||||
)
|
||||
LIMIT 1
|
||||
);
|
||||
|
||||
-- If no season exists, create it
|
||||
INSERT IGNORE INTO `season` (season) VALUES (
|
||||
CASE
|
||||
WHEN MONTH(CURDATE()) >= 7 THEN CONCAT(YEAR(CURDATE()), '/', YEAR(CURDATE()) + 1)
|
||||
ELSE CONCAT(YEAR(CURDATE()) - 1, '/', YEAR(CURDATE()))
|
||||
END
|
||||
);
|
||||
|
||||
-- Get the season ID again (in case we just created it)
|
||||
SET @current_season_id = (
|
||||
SELECT id FROM `season`
|
||||
WHERE season = (
|
||||
CASE
|
||||
WHEN MONTH(CURDATE()) >= 7 THEN CONCAT(YEAR(CURDATE()), '/', YEAR(CURDATE()) + 1)
|
||||
ELSE CONCAT(YEAR(CURDATE()) - 1, '/', YEAR(CURDATE()))
|
||||
END
|
||||
)
|
||||
LIMIT 1
|
||||
);
|
||||
|
||||
-- Update all existing teams to use the current season
|
||||
UPDATE `team` SET `season_id` = @current_season_id WHERE `season_id` IS NULL;
|
||||
|
||||
-- Now make the column NOT NULL and add the foreign key constraint
|
||||
ALTER TABLE `team` MODIFY COLUMN `season_id` INT NOT NULL;
|
||||
ALTER TABLE `team` ADD CONSTRAINT `team_season_id_foreign_idx`
|
||||
FOREIGN KEY (`season_id`) REFERENCES `season` (`id`)
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -1,11 +0,0 @@
|
||||
-- Migration: Add table fields to team table
|
||||
-- Add fields for league table calculations
|
||||
|
||||
ALTER TABLE team ADD COLUMN matches_played INT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE team ADD COLUMN matches_won INT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE team ADD COLUMN matches_lost INT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE team ADD COLUMN sets_won INT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE team ADD COLUMN sets_lost INT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE team ADD COLUMN points_won INT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE team ADD COLUMN points_lost INT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE team ADD COLUMN table_points INT NOT NULL DEFAULT 0;
|
||||
@@ -1,5 +0,0 @@
|
||||
-- Add table_points_won and table_points_lost columns to team table
|
||||
ALTER TABLE team
|
||||
ADD COLUMN table_points_won INTEGER NOT NULL DEFAULT 0 AFTER table_points,
|
||||
ADD COLUMN table_points_lost INTEGER NOT NULL DEFAULT 0 AFTER table_points_won;
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
-- Create my_tischtennis_fetch_log table for tracking data fetches
|
||||
CREATE TABLE IF NOT EXISTS my_tischtennis_fetch_log (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
fetch_type ENUM('ratings', 'match_results', 'league_table') NOT NULL COMMENT 'Type of data fetch',
|
||||
success BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
message TEXT,
|
||||
error_details TEXT,
|
||||
records_processed INT NOT NULL DEFAULT 0 COMMENT 'Number of records processed',
|
||||
execution_time INT COMMENT 'Execution time in milliseconds',
|
||||
is_automatic BOOLEAN NOT NULL DEFAULT FALSE COMMENT 'Automatic or manual fetch',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
|
||||
INDEX idx_user_fetch_type_created (user_id, fetch_type, created_at),
|
||||
INDEX idx_created_at (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
-- Migration: Create my_tischtennis_update_history table
|
||||
-- Date: 2025-01-27
|
||||
-- For MariaDB
|
||||
|
||||
CREATE TABLE IF NOT EXISTS my_tischtennis_update_history (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
success BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
message TEXT,
|
||||
error_details TEXT,
|
||||
updated_count INT DEFAULT 0,
|
||||
execution_time INT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT fk_my_tischtennis_update_history_user_id
|
||||
FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Create indexes for efficient querying
|
||||
CREATE INDEX idx_my_tischtennis_update_history_user_id ON my_tischtennis_update_history(user_id);
|
||||
CREATE INDEX idx_my_tischtennis_update_history_created_at ON my_tischtennis_update_history(created_at);
|
||||
CREATE INDEX idx_my_tischtennis_update_history_success ON my_tischtennis_update_history(success);
|
||||
@@ -1,8 +0,0 @@
|
||||
-- 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;
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
import Club from './Club.js';
|
||||
import League from './League.js';
|
||||
import Season from './Season.js';
|
||||
|
||||
const ClubTeam = sequelize.define('ClubTeam', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false,
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
clubId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: Club,
|
||||
key: 'id',
|
||||
},
|
||||
onDelete: 'CASCADE',
|
||||
onUpdate: 'CASCADE',
|
||||
},
|
||||
leagueId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: League,
|
||||
key: 'id',
|
||||
},
|
||||
onDelete: 'SET NULL',
|
||||
onUpdate: 'CASCADE',
|
||||
},
|
||||
seasonId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: Season,
|
||||
key: 'id',
|
||||
},
|
||||
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',
|
||||
timestamps: true,
|
||||
});
|
||||
|
||||
export default ClubTeam;
|
||||
132
backend/models/ExternalServiceAccount.js
Normal file
132
backend/models/ExternalServiceAccount.js
Normal file
@@ -0,0 +1,132 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
import { encryptData, decryptData } from '../utils/encrypt.js';
|
||||
|
||||
const ExternalServiceAccount = sequelize.define('ExternalServiceAccount', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false
|
||||
},
|
||||
userId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'user',
|
||||
key: 'id'
|
||||
},
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
service: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: false,
|
||||
defaultValue: 'mytischtennis'
|
||||
},
|
||||
email: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
encryptedPassword: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
field: 'encrypted_password'
|
||||
},
|
||||
savePassword: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false,
|
||||
allowNull: false,
|
||||
field: 'save_password'
|
||||
},
|
||||
accessToken: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
field: 'access_token'
|
||||
},
|
||||
refreshToken: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
field: 'refresh_token'
|
||||
},
|
||||
expiresAt: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
field: 'expires_at'
|
||||
},
|
||||
cookie: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
userData: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true,
|
||||
field: 'user_data'
|
||||
},
|
||||
clubId: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
field: 'club_id'
|
||||
},
|
||||
clubName: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
field: 'club_name'
|
||||
},
|
||||
fedNickname: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
field: 'fed_nickname'
|
||||
},
|
||||
lastLoginAttempt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'last_login_attempt'
|
||||
},
|
||||
lastLoginSuccess: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'last_login_success'
|
||||
}
|
||||
}, {
|
||||
underscored: true,
|
||||
tableName: 'external_service_account',
|
||||
timestamps: true,
|
||||
indexes: [
|
||||
{
|
||||
unique: true,
|
||||
fields: ['user_id', 'service']
|
||||
}
|
||||
],
|
||||
hooks: {
|
||||
beforeSave: async (instance) => {
|
||||
// Wenn savePassword false ist, password auf null setzen
|
||||
if (!instance.savePassword) {
|
||||
instance.encryptedPassword = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Virtuelle Felder für password handling
|
||||
ExternalServiceAccount.prototype.setPassword = function(password) {
|
||||
if (password && this.savePassword) {
|
||||
this.encryptedPassword = encryptData(password);
|
||||
} else {
|
||||
this.encryptedPassword = null;
|
||||
}
|
||||
};
|
||||
|
||||
ExternalServiceAccount.prototype.getPassword = function() {
|
||||
if (this.encryptedPassword) {
|
||||
try {
|
||||
return decryptData(this.encryptedPassword);
|
||||
} catch (error) {
|
||||
console.error('Error decrypting password:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export default ExternalServiceAccount;
|
||||
|
||||
@@ -34,22 +34,6 @@ 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',
|
||||
|
||||
@@ -3,6 +3,7 @@ import sequelize from '../database.js';
|
||||
import Club from './Club.js';
|
||||
import League from './League.js';
|
||||
import Team from './Team.js';
|
||||
import Season from './Season.js';
|
||||
import Location from './Location.js';
|
||||
|
||||
const Match = sequelize.define('Match', {
|
||||
@@ -20,13 +21,21 @@ const Match = sequelize.define('Match', {
|
||||
type: DataTypes.TIME,
|
||||
allowNull: true,
|
||||
},
|
||||
seasonId: {
|
||||
type: DataTypes.INTEGER,
|
||||
references: {
|
||||
model: Season,
|
||||
key: 'id',
|
||||
},
|
||||
allowNull: false,
|
||||
},
|
||||
locationId: {
|
||||
type: DataTypes.INTEGER,
|
||||
references: {
|
||||
model: Location,
|
||||
key: 'id',
|
||||
},
|
||||
allowNull: true,
|
||||
allowNull: false,
|
||||
},
|
||||
homeTeamId: {
|
||||
type: DataTypes.INTEGER,
|
||||
@@ -60,55 +69,6 @@ const Match = sequelize.define('Match', {
|
||||
},
|
||||
allowNull: false,
|
||||
},
|
||||
code: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
comment: 'Spiel-Code aus PDF-Parsing'
|
||||
},
|
||||
homePin: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
comment: 'Pin-Code für Heimteam aus PDF-Parsing'
|
||||
},
|
||||
guestPin: {
|
||||
type: DataTypes.STRING,
|
||||
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',
|
||||
|
||||
@@ -45,9 +45,9 @@ const Member = sequelize.define('Member', {
|
||||
},
|
||||
birthDate: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
allowNull: false,
|
||||
set(value) {
|
||||
const encryptedValue = encryptData(value || '');
|
||||
const encryptedValue = encryptData(value);
|
||||
this.setDataValue('birthDate', encryptedValue);
|
||||
},
|
||||
get() {
|
||||
@@ -137,12 +137,6 @@ 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,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
import { encryptData, decryptData } from '../utils/encrypt.js';
|
||||
|
||||
const MyTischtennis = sequelize.define('MyTischtennis', {
|
||||
const ExternalServiceAccount = sequelize.define('ExternalServiceAccount', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
@@ -12,13 +12,17 @@ const MyTischtennis = sequelize.define('MyTischtennis', {
|
||||
userId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
references: {
|
||||
model: 'user',
|
||||
key: 'id'
|
||||
},
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
service: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: false,
|
||||
defaultValue: 'mytischtennis'
|
||||
},
|
||||
email: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
@@ -34,12 +38,6 @@ 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,
|
||||
@@ -88,16 +86,17 @@ 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,
|
||||
tableName: 'my_tischtennis',
|
||||
tableName: 'external_service_account',
|
||||
timestamps: true,
|
||||
indexes: [
|
||||
{
|
||||
unique: true,
|
||||
fields: ['user_id', 'service']
|
||||
}
|
||||
],
|
||||
hooks: {
|
||||
beforeSave: async (instance) => {
|
||||
// Wenn savePassword false ist, password auf null setzen
|
||||
@@ -109,7 +108,7 @@ const MyTischtennis = sequelize.define('MyTischtennis', {
|
||||
});
|
||||
|
||||
// Virtuelle Felder für password handling
|
||||
MyTischtennis.prototype.setPassword = function(password) {
|
||||
ExternalServiceAccount.prototype.setPassword = function(password) {
|
||||
if (password && this.savePassword) {
|
||||
this.encryptedPassword = encryptData(password);
|
||||
} else {
|
||||
@@ -117,17 +116,17 @@ MyTischtennis.prototype.setPassword = function(password) {
|
||||
}
|
||||
};
|
||||
|
||||
MyTischtennis.prototype.getPassword = function() {
|
||||
ExternalServiceAccount.prototype.getPassword = function() {
|
||||
if (this.encryptedPassword) {
|
||||
try {
|
||||
return decryptData(this.encryptedPassword);
|
||||
} catch (error) {
|
||||
console.error('Error decrypting myTischtennis password:', error);
|
||||
console.error('Error decrypting password:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export default MyTischtennis;
|
||||
export default ExternalServiceAccount;
|
||||
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
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;
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
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;
|
||||
@@ -1,8 +1,6 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
import Club from './Club.js';
|
||||
import League from './League.js';
|
||||
import Season from './Season.js';
|
||||
|
||||
const Team = sequelize.define('Team', {
|
||||
id: {
|
||||
@@ -25,82 +23,6 @@ const Team = sequelize.define('Team', {
|
||||
onDelete: 'CASCADE',
|
||||
onUpdate: 'CASCADE',
|
||||
},
|
||||
leagueId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: League,
|
||||
key: 'id',
|
||||
},
|
||||
onDelete: 'SET NULL',
|
||||
onUpdate: 'CASCADE',
|
||||
},
|
||||
seasonId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: Season,
|
||||
key: 'id',
|
||||
},
|
||||
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',
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
import ClubTeam from './ClubTeam.js';
|
||||
|
||||
const TeamDocument = sequelize.define('TeamDocument', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false,
|
||||
},
|
||||
fileName: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
originalFileName: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
filePath: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
fileSize: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
},
|
||||
mimeType: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
documentType: {
|
||||
type: DataTypes.ENUM('code_list', 'pin_list'),
|
||||
allowNull: false,
|
||||
},
|
||||
clubTeamId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: ClubTeam,
|
||||
key: 'id',
|
||||
},
|
||||
onDelete: 'CASCADE',
|
||||
onUpdate: 'CASCADE',
|
||||
},
|
||||
}, {
|
||||
underscored: true,
|
||||
tableName: 'team_document',
|
||||
timestamps: true,
|
||||
});
|
||||
|
||||
export default TeamDocument;
|
||||
@@ -19,8 +19,6 @@ import DiaryDateActivity from './DiaryDateActivity.js';
|
||||
import Match from './Match.js';
|
||||
import League from './League.js';
|
||||
import Team from './Team.js';
|
||||
import ClubTeam from './ClubTeam.js';
|
||||
import TeamDocument from './TeamDocument.js';
|
||||
import Season from './Season.js';
|
||||
import Location from './Location.js';
|
||||
import Group from './Group.js';
|
||||
@@ -35,9 +33,7 @@ import UserToken from './UserToken.js';
|
||||
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';
|
||||
import ExternalServiceAccount from './ExternalServiceAccount.js';
|
||||
// Official tournaments relations
|
||||
OfficialTournament.hasMany(OfficialCompetition, { foreignKey: 'tournamentId', as: 'competitions' });
|
||||
OfficialCompetition.belongsTo(OfficialTournament, { foreignKey: 'tournamentId', as: 'tournament' });
|
||||
@@ -122,28 +118,8 @@ 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' });
|
||||
|
||||
Season.hasMany(Team, { foreignKey: 'seasonId', as: 'teams' });
|
||||
Team.belongsTo(Season, { foreignKey: 'seasonId', as: 'season' });
|
||||
|
||||
// ClubTeam relationships
|
||||
Club.hasMany(ClubTeam, { foreignKey: 'clubId', as: 'clubTeams' });
|
||||
ClubTeam.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
|
||||
|
||||
League.hasMany(ClubTeam, { foreignKey: 'leagueId', as: 'clubTeams' });
|
||||
ClubTeam.belongsTo(League, { foreignKey: 'leagueId', as: 'league' });
|
||||
|
||||
Season.hasMany(ClubTeam, { foreignKey: 'seasonId', as: 'clubTeams' });
|
||||
ClubTeam.belongsTo(Season, { foreignKey: 'seasonId', as: 'season' });
|
||||
|
||||
// TeamDocument relationships
|
||||
ClubTeam.hasMany(TeamDocument, { foreignKey: 'clubTeamId', as: 'documents' });
|
||||
TeamDocument.belongsTo(ClubTeam, { foreignKey: 'clubTeamId', as: 'clubTeam' });
|
||||
Match.belongsTo(Season, { foreignKey: 'seasonId', as: 'season' });
|
||||
Season.hasMany(Match, { foreignKey: 'seasonId', as: 'matches' });
|
||||
|
||||
Match.belongsTo(Location, { foreignKey: 'locationId', as: 'location' });
|
||||
Location.hasMany(Match, { foreignKey: 'locationId', as: 'matches' });
|
||||
@@ -229,14 +205,8 @@ Member.hasMany(Accident, { foreignKey: 'memberId', as: 'accidents' });
|
||||
Accident.belongsTo(DiaryDate, { foreignKey: 'diaryDateId', as: 'diaryDates' });
|
||||
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' });
|
||||
User.hasMany(ExternalServiceAccount, { foreignKey: 'userId', as: 'externalServiceAccounts' });
|
||||
ExternalServiceAccount.belongsTo(User, { foreignKey: 'userId', as: 'user' });
|
||||
|
||||
export {
|
||||
User,
|
||||
@@ -261,8 +231,6 @@ export {
|
||||
Match,
|
||||
League,
|
||||
Team,
|
||||
ClubTeam,
|
||||
TeamDocument,
|
||||
Group,
|
||||
GroupActivity,
|
||||
Tournament,
|
||||
@@ -275,7 +243,5 @@ export {
|
||||
OfficialTournament,
|
||||
OfficialCompetition,
|
||||
OfficialCompetitionMember,
|
||||
MyTischtennis,
|
||||
MyTischtennisUpdateHistory,
|
||||
MyTischtennisFetchLog,
|
||||
ExternalServiceAccount,
|
||||
};
|
||||
|
||||
16
backend/node_modules/.package-lock.json
generated
vendored
16
backend/node_modules/.package-lock.json
generated
vendored
@@ -2816,15 +2816,6 @@
|
||||
"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",
|
||||
@@ -2851,10 +2842,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "7.0.9",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.9.tgz",
|
||||
"integrity": "sha512-9/Qm0qXIByEP8lEV2qOqcAW7bRpL8CR9jcTwk3NBnHJNmP9fIJ86g2fgmIXqHY+nj55ZEMwWqYAT2QTDpRUYiQ==",
|
||||
"license": "MIT-0",
|
||||
"version": "6.9.14",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.14.tgz",
|
||||
"integrity": "sha512-Dobp/ebDKBvz91sbtRKhcznLThrKxKt97GI2FAlAyy+fk19j73Uz3sBXolVtmcXjaorivqsbbbjDY+Jkt4/bQA==",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
|
||||
7
backend/node_modules/nodemailer/.ncurc.js
generated
vendored
7
backend/node_modules/nodemailer/.ncurc.js
generated
vendored
@@ -1,9 +1,10 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
upgrade: true,
|
||||
reject: [
|
||||
// API changes break existing tests
|
||||
'proxy'
|
||||
'proxy',
|
||||
|
||||
// API changes
|
||||
'eslint'
|
||||
]
|
||||
};
|
||||
|
||||
2
backend/node_modules/nodemailer/.prettierrc.js
generated
vendored
2
backend/node_modules/nodemailer/.prettierrc.js
generated
vendored
@@ -1,5 +1,3 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
printWidth: 160,
|
||||
tabWidth: 4,
|
||||
|
||||
655
backend/node_modules/nodemailer/CHANGELOG.md
generated
vendored
655
backend/node_modules/nodemailer/CHANGELOG.md
generated
vendored
File diff suppressed because it is too large
Load Diff
26
backend/node_modules/nodemailer/CODE_OF_CONDUCT.md
generated
vendored
26
backend/node_modules/nodemailer/CODE_OF_CONDUCT.md
generated
vendored
@@ -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
|
||||
|
||||
|
||||
24
backend/node_modules/nodemailer/README.md
generated
vendored
24
backend/node_modules/nodemailer/README.md
generated
vendored
@@ -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
|
||||
|
||||
84
backend/node_modules/nodemailer/lib/addressparser/index.js
generated
vendored
84
backend/node_modules/nodemailer/lib/addressparser/index.js
generated
vendored
@@ -7,6 +7,7 @@
|
||||
* @return {Object} Address object
|
||||
*/
|
||||
function _handleAddress(tokens) {
|
||||
let token;
|
||||
let isGroup = false;
|
||||
let state = 'text';
|
||||
let address;
|
||||
@@ -15,41 +16,28 @@ function _handleAddress(tokens) {
|
||||
address: [],
|
||||
comment: [],
|
||||
group: [],
|
||||
text: [],
|
||||
textWasQuoted: [] // Track which text tokens came from inside quotes
|
||||
text: []
|
||||
};
|
||||
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++) {
|
||||
let token = tokens[i];
|
||||
let prevToken = i ? tokens[i - 1] : null;
|
||||
token = tokens[i];
|
||||
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') {
|
||||
@@ -58,19 +46,7 @@ function _handleAddress(tokens) {
|
||||
// and so will we
|
||||
token.value = token.value.replace(/^[^<]*<\s*/, '');
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
data[state].push(token.value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,36 +59,16 @@ 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: groupMembers
|
||||
group: data.group.length ? addressparser(data.group.join(',')) : []
|
||||
});
|
||||
} 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--) {
|
||||
// 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]+$/)) {
|
||||
if (data.text[i].match(/^[^@\s]+@[^@\s]+$/)) {
|
||||
data.address = data.text.splice(i, 1);
|
||||
data.textWasQuoted.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -129,13 +85,10 @@ function _handleAddress(tokens) {
|
||||
// still no address
|
||||
if (!data.address.length) {
|
||||
for (i = data.text.length - 1; i >= 0; i--) {
|
||||
// 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;
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -219,12 +172,11 @@ class Tokenizer {
|
||||
* @return {Array} An array of operator|text tokens
|
||||
*/
|
||||
tokenize() {
|
||||
let list = [];
|
||||
|
||||
let chr,
|
||||
list = [];
|
||||
for (let i = 0, len = this.str.length; i < len; i++) {
|
||||
let chr = this.str.charAt(i);
|
||||
let nextChr = i < len - 1 ? this.str.charAt(i + 1) : null;
|
||||
this.checkChar(chr, nextChr);
|
||||
chr = this.str.charAt(i);
|
||||
this.checkChar(chr);
|
||||
}
|
||||
|
||||
this.list.forEach(node => {
|
||||
@@ -242,7 +194,7 @@ class Tokenizer {
|
||||
*
|
||||
* @param {String} chr Character from the address field
|
||||
*/
|
||||
checkChar(chr, nextChr) {
|
||||
checkChar(chr) {
|
||||
if (this.escaped) {
|
||||
// ignore next condition blocks
|
||||
} else if (chr === this.operatorExpecting) {
|
||||
@@ -250,16 +202,10 @@ 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 = {
|
||||
|
||||
25
backend/node_modules/nodemailer/lib/base64/index.js
generated
vendored
25
backend/node_modules/nodemailer/lib/base64/index.js
generated
vendored
@@ -35,12 +35,15 @@ 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');
|
||||
let wrappedLines = str
|
||||
.substr(pos, chunkLength)
|
||||
.replace(new RegExp('.{' + lineLength + '}', 'g'), '$&\r\n')
|
||||
.trim();
|
||||
result.push(wrappedLines);
|
||||
pos += chunkLength;
|
||||
}
|
||||
|
||||
return result.join('');
|
||||
return result.join('\r\n').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,6 +56,7 @@ function wrap(str, lineLength) {
|
||||
class Encoder extends Transform {
|
||||
constructor(options) {
|
||||
super();
|
||||
// init Transform
|
||||
this.options = options || {};
|
||||
|
||||
if (this.options.lineLength !== false) {
|
||||
@@ -94,20 +98,17 @@ 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.substring(lastLF + 1);
|
||||
b64 = b64.substring(0, lastLF + 1);
|
||||
|
||||
if (b64 && !b64.endsWith('\r\n')) {
|
||||
b64 += '\r\n';
|
||||
}
|
||||
this._curLine = b64.substr(lastLF + 1);
|
||||
b64 = b64.substr(0, lastLF + 1);
|
||||
}
|
||||
} else {
|
||||
this._curLine = '';
|
||||
}
|
||||
|
||||
if (b64) {
|
||||
@@ -124,14 +125,16 @@ class Encoder extends Transform {
|
||||
}
|
||||
|
||||
if (this._curLine) {
|
||||
this._curLine = wrap(this._curLine, this.options.lineLength);
|
||||
this.outputBytes += this._curLine.length;
|
||||
this.push(Buffer.from(this._curLine, 'ascii'));
|
||||
this.push(this._curLine, 'ascii');
|
||||
this._curLine = '';
|
||||
}
|
||||
done();
|
||||
}
|
||||
}
|
||||
|
||||
// expose to the world
|
||||
module.exports = {
|
||||
encode,
|
||||
wrap,
|
||||
|
||||
6
backend/node_modules/nodemailer/lib/dkim/index.js
generated
vendored
6
backend/node_modules/nodemailer/lib/dkim/index.js
generated
vendored
@@ -12,7 +12,7 @@ const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const DKIM_ALGO = 'sha256';
|
||||
const MAX_MESSAGE_SIZE = 2 * 1024 * 1024; // buffer messages larger than this to disk
|
||||
const MAX_MESSAGE_SIZE = 128 * 1024; // buffer messages larger than this to disk
|
||||
|
||||
/*
|
||||
// Usage:
|
||||
@@ -42,9 +42,7 @@ 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;
|
||||
|
||||
2
backend/node_modules/nodemailer/lib/dkim/sign.js
generated
vendored
2
backend/node_modules/nodemailer/lib/dkim/sign.js
generated
vendored
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
8
backend/node_modules/nodemailer/lib/fetch/index.js
generated
vendored
8
backend/node_modules/nodemailer/lib/fetch/index.js
generated
vendored
@@ -132,13 +132,7 @@ 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;
|
||||
}
|
||||
|
||||
|
||||
72
backend/node_modules/nodemailer/lib/mail-composer/index.js
generated
vendored
72
backend/node_modules/nodemailer/lib/mail-composer/index.js
generated
vendored
@@ -86,34 +86,20 @@ 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 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
|
||||
}
|
||||
let contentDisposition = attachment.contentDisposition || (isMessageNode || (isImage && attachment.cid) ? 'inline' : 'attachment');
|
||||
|
||||
data = {
|
||||
contentType,
|
||||
contentDisposition,
|
||||
contentTransferEncoding
|
||||
contentTransferEncoding: 'contentTransferEncoding' in attachment ? attachment.contentTransferEncoding : 'base64'
|
||||
};
|
||||
|
||||
if (attachment.filename) {
|
||||
@@ -214,10 +200,7 @@ 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 = {
|
||||
@@ -242,10 +225,7 @@ 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 = {
|
||||
@@ -280,18 +260,14 @@ 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 = {
|
||||
@@ -316,9 +292,7 @@ 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
|
||||
};
|
||||
|
||||
@@ -564,33 +538,9 @@ 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;
|
||||
try {
|
||||
parsedDataUri = parseDataURI(dataUrl);
|
||||
} catch (_err) {
|
||||
return element;
|
||||
if ((element.path || element.href).match(/^data:/)) {
|
||||
parsedDataUri = parseDataURI(element.path || element.href);
|
||||
}
|
||||
|
||||
if (!parsedDataUri) {
|
||||
|
||||
14
backend/node_modules/nodemailer/lib/mailer/index.js
generated
vendored
14
backend/node_modules/nodemailer/lib/mailer/index.js
generated
vendored
@@ -87,11 +87,6 @@ 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);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -241,14 +236,7 @@ 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) {
|
||||
|
||||
3
backend/node_modules/nodemailer/lib/mailer/mail-message.js
generated
vendored
3
backend/node_modules/nodemailer/lib/mailer/mail-message.js
generated
vendored
@@ -64,8 +64,7 @@ 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);
|
||||
}
|
||||
|
||||
4
backend/node_modules/nodemailer/lib/mime-funcs/index.js
generated
vendored
4
backend/node_modules/nodemailer/lib/mime-funcs/index.js
generated
vendored
@@ -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''";
|
||||
line = 'utf-8\x27\x27';
|
||||
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, '');
|
||||
}
|
||||
|
||||
17
backend/node_modules/nodemailer/lib/mime-funcs/mime-types.js
generated
vendored
17
backend/node_modules/nodemailer/lib/mime-funcs/mime-types.js
generated
vendored
@@ -44,7 +44,6 @@ 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'],
|
||||
@@ -1102,10 +1101,7 @@ 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']],
|
||||
@@ -1150,10 +1146,7 @@ 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'],
|
||||
@@ -1294,7 +1287,6 @@ 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'],
|
||||
@@ -1758,10 +1750,7 @@ 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'],
|
||||
|
||||
20
backend/node_modules/nodemailer/lib/mime-node/index.js
generated
vendored
20
backend/node_modules/nodemailer/lib/mime-node/index.js
generated
vendored
@@ -552,11 +552,7 @@ 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';
|
||||
}
|
||||
|
||||
@@ -967,8 +963,8 @@ class MimeNode {
|
||||
setImmediate(() => {
|
||||
try {
|
||||
contentStream.end(content._resolvedValue);
|
||||
} catch (_err) {
|
||||
contentStream.emit('error', _err);
|
||||
} catch (err) {
|
||||
contentStream.emit('error', err);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -999,8 +995,8 @@ class MimeNode {
|
||||
setImmediate(() => {
|
||||
try {
|
||||
contentStream.end(content || '');
|
||||
} catch (_err) {
|
||||
contentStream.emit('error', _err);
|
||||
} catch (err) {
|
||||
contentStream.emit('error', err);
|
||||
}
|
||||
});
|
||||
return contentStream;
|
||||
@@ -1018,6 +1014,7 @@ 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 || '';
|
||||
@@ -1116,6 +1113,7 @@ class MimeNode {
|
||||
.apply(
|
||||
[],
|
||||
[].concat(value || '').map(elm => {
|
||||
// eslint-disable-line prefer-spread
|
||||
elm = (elm || '')
|
||||
.toString()
|
||||
.replace(/\r?\n|\r/g, ' ')
|
||||
@@ -1221,7 +1219,7 @@ class MimeNode {
|
||||
|
||||
try {
|
||||
encodedDomain = punycode.toASCII(domain.toLowerCase());
|
||||
} catch (_err) {
|
||||
} catch (err) {
|
||||
// keep as is?
|
||||
}
|
||||
|
||||
@@ -1284,7 +1282,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;
|
||||
nonLatinLen = (value.match(/[\x00-\x08\x0B\x0C\x0E-\x1F\u0080-\uFFFF]/g) || []).length; // eslint-disable-line no-control-regex
|
||||
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';
|
||||
|
||||
7
backend/node_modules/nodemailer/lib/nodemailer.js
generated
vendored
7
backend/node_modules/nodemailer/lib/nodemailer.js
generated
vendored
@@ -45,13 +45,6 @@ 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);
|
||||
|
||||
12
backend/node_modules/nodemailer/lib/qp/index.js
generated
vendored
12
backend/node_modules/nodemailer/lib/qp/index.js
generated
vendored
@@ -28,10 +28,7 @@ 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;
|
||||
}
|
||||
@@ -93,12 +90,7 @@ 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;
|
||||
|
||||
221
backend/node_modules/nodemailer/lib/ses-transport/index.js
generated
vendored
221
backend/node_modules/nodemailer/lib/ses-transport/index.js
generated
vendored
@@ -4,11 +4,15 @@ 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
|
||||
*/
|
||||
@@ -26,17 +30,119 @@ 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');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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));
|
||||
/**
|
||||
* 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
|
||||
});
|
||||
}
|
||||
return cb(null, false);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,17 +151,13 @@ 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
|
||||
};
|
||||
|
||||
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));
|
||||
}
|
||||
this.connections++;
|
||||
this.rateMessages.push(statObject);
|
||||
|
||||
let envelope = mail.data.envelope || mail.message.getEnvelope();
|
||||
let messageId = mail.message.messageId();
|
||||
@@ -125,29 +227,45 @@ class SESTransport extends EventEmitter {
|
||||
}
|
||||
|
||||
let sesMessage = {
|
||||
Content: {
|
||||
Raw: {
|
||||
// required
|
||||
Data: raw // required
|
||||
}
|
||||
RawMessage: {
|
||||
// required
|
||||
Data: raw // required
|
||||
},
|
||||
FromEmailAddress: fromHeader ? fromHeader : envelope.from,
|
||||
Destination: {
|
||||
ToAddresses: envelope.to
|
||||
}
|
||||
Source: envelope.from,
|
||||
Destinations: envelope.to
|
||||
};
|
||||
|
||||
Object.keys(mail.data.ses || {}).forEach(key => {
|
||||
sesMessage[key] = mail.data.ses[key];
|
||||
});
|
||||
|
||||
this.getRegion((err, region) => {
|
||||
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) => {
|
||||
if (err || !region) {
|
||||
region = 'us-east-1';
|
||||
}
|
||||
|
||||
const command = new this.ses.SendEmailCommand(sesMessage);
|
||||
const sendPromise = this.ses.sesClient.send(command);
|
||||
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();
|
||||
}
|
||||
|
||||
sendPromise
|
||||
.then(data => {
|
||||
@@ -155,7 +273,7 @@ class SESTransport extends EventEmitter {
|
||||
region = 'email';
|
||||
}
|
||||
|
||||
statObject.pending = true;
|
||||
statObject.pending = false;
|
||||
callback(null, {
|
||||
envelope: {
|
||||
from: envelope.from,
|
||||
@@ -191,41 +309,38 @@ 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 && !['InvalidParameterValue', 'MessageRejected'].includes(err.code || err.Code || err.name)) {
|
||||
if (err && (err.code || err.Code) !== 'InvalidParameterValue') {
|
||||
return callback(err);
|
||||
}
|
||||
return callback(null, true);
|
||||
};
|
||||
|
||||
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));
|
||||
});
|
||||
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);
|
||||
}
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
152
backend/node_modules/nodemailer/lib/shared/index.js
generated
vendored
152
backend/node_modules/nodemailer/lib/shared/index.js
generated
vendored
@@ -11,19 +11,11 @@ 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
|
||||
}
|
||||
|
||||
@@ -89,8 +81,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 || {}
|
||||
);
|
||||
@@ -121,27 +113,7 @@ module.exports.resolveHostname = (options, callback) => {
|
||||
if (dnsCache.has(options.host)) {
|
||||
cached = dnsCache.get(options.host);
|
||||
|
||||
// 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) {
|
||||
if (!cached.expires || cached.expires >= Date.now()) {
|
||||
return callback(
|
||||
null,
|
||||
formatDNSValue(cached.value, {
|
||||
@@ -154,11 +126,7 @@ module.exports.resolveHostname = (options, callback) => {
|
||||
resolver(4, options.host, options, (err, addresses) => {
|
||||
if (err) {
|
||||
if (cached) {
|
||||
dnsCache.set(options.host, {
|
||||
value: cached.value,
|
||||
expires: Date.now() + (options.dnsTtl || DNS_TTL)
|
||||
});
|
||||
|
||||
// ignore error, use expired value
|
||||
return callback(
|
||||
null,
|
||||
formatDNSValue(cached.value, {
|
||||
@@ -192,11 +160,7 @@ module.exports.resolveHostname = (options, callback) => {
|
||||
resolver(6, options.host, options, (err, addresses) => {
|
||||
if (err) {
|
||||
if (cached) {
|
||||
dnsCache.set(options.host, {
|
||||
value: cached.value,
|
||||
expires: Date.now() + (options.dnsTtl || DNS_TTL)
|
||||
});
|
||||
|
||||
// ignore error, use expired value
|
||||
return callback(
|
||||
null,
|
||||
formatDNSValue(cached.value, {
|
||||
@@ -231,11 +195,7 @@ module.exports.resolveHostname = (options, callback) => {
|
||||
dns.lookup(options.host, { all: true }, (err, addresses) => {
|
||||
if (err) {
|
||||
if (cached) {
|
||||
dnsCache.set(options.host, {
|
||||
value: cached.value,
|
||||
expires: Date.now() + (options.dnsTtl || DNS_TTL)
|
||||
});
|
||||
|
||||
// ignore error, use expired value
|
||||
return callback(
|
||||
null,
|
||||
formatDNSValue(cached.value, {
|
||||
@@ -286,13 +246,9 @@ module.exports.resolveHostname = (options, callback) => {
|
||||
})
|
||||
);
|
||||
});
|
||||
} catch (_err) {
|
||||
} catch (err) {
|
||||
if (cached) {
|
||||
dnsCache.set(options.host, {
|
||||
value: cached.value,
|
||||
expires: Date.now() + (options.dnsTtl || DNS_TTL)
|
||||
});
|
||||
|
||||
// ignore error, use expired value
|
||||
return callback(
|
||||
null,
|
||||
formatDNSValue(cached.value, {
|
||||
@@ -463,74 +419,52 @@ module.exports.callbackPromise = (resolve, reject) =>
|
||||
};
|
||||
|
||||
module.exports.parseDataURI = uri => {
|
||||
if (typeof uri !== 'string') {
|
||||
return null;
|
||||
let input = uri;
|
||||
let commaPos = input.indexOf(',');
|
||||
if (!commaPos) {
|
||||
return uri;
|
||||
}
|
||||
|
||||
// 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 data = input.substring(commaPos + 1);
|
||||
let metaStr = input.substring('data:'.length, commaPos);
|
||||
|
||||
let encoding;
|
||||
const metaEntries = metaStr.split(';');
|
||||
|
||||
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();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Decode data based on encoding with proper error handling
|
||||
let bufferData;
|
||||
try {
|
||||
if (encoding === 'base64') {
|
||||
bufferData = Buffer.from(data, 'base64');
|
||||
} else {
|
||||
switch (encoding) {
|
||||
case 'base64':
|
||||
data = Buffer.from(data, 'base64');
|
||||
break;
|
||||
case 'utf8':
|
||||
data = Buffer.from(data);
|
||||
break;
|
||||
default:
|
||||
try {
|
||||
bufferData = Buffer.from(decodeURIComponent(data));
|
||||
} catch (_decodeError) {
|
||||
bufferData = Buffer.from(data);
|
||||
data = Buffer.from(decodeURIComponent(data));
|
||||
} catch (err) {
|
||||
data = Buffer.from(data);
|
||||
}
|
||||
}
|
||||
} catch (_bufferError) {
|
||||
bufferData = Buffer.alloc(0);
|
||||
data = Buffer.from(data);
|
||||
}
|
||||
|
||||
return {
|
||||
data: bufferData,
|
||||
encoding: encoding || null,
|
||||
contentType: contentType || 'application/octet-stream',
|
||||
params
|
||||
};
|
||||
return { data, encoding, contentType, params };
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
4
backend/node_modules/nodemailer/lib/smtp-connection/http-proxy-client.js
generated
vendored
4
backend/node_modules/nodemailer/lib/smtp-connection/http-proxy-client.js
generated
vendored
@@ -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]) || '')));
|
||||
|
||||
34
backend/node_modules/nodemailer/lib/smtp-connection/index.js
generated
vendored
34
backend/node_modules/nodemailer/lib/smtp-connection/index.js
generated
vendored
@@ -124,7 +124,7 @@ class SMTPConnection extends EventEmitter {
|
||||
|
||||
/**
|
||||
* The socket connecting to the server
|
||||
* @public
|
||||
* @publick
|
||||
*/
|
||||
this._socket = false;
|
||||
|
||||
@@ -243,8 +243,6 @@ 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 => {
|
||||
@@ -414,8 +412,8 @@ class SMTPConnection extends EventEmitter {
|
||||
|
||||
if (socket && !socket.destroyed) {
|
||||
try {
|
||||
socket[closeMethod]();
|
||||
} catch (_E) {
|
||||
this._socket[closeMethod]();
|
||||
} catch (E) {
|
||||
// just ignore
|
||||
}
|
||||
}
|
||||
@@ -630,15 +628,6 @@ 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();
|
||||
@@ -1294,12 +1283,7 @@ 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;
|
||||
}
|
||||
|
||||
@@ -1481,9 +1465,7 @@ 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];
|
||||
}
|
||||
@@ -1626,7 +1608,7 @@ class SMTPConnection extends EventEmitter {
|
||||
}
|
||||
|
||||
if (!this._envelope.rcptQueue.length) {
|
||||
return callback(this._formatError("Can't send mail - no recipients defined", 'EENVELOPE', false, 'API'));
|
||||
return callback(this._formatError('Can\x27t send mail - no recipients defined', 'EENVELOPE', false, 'API'));
|
||||
} else {
|
||||
this._recipientQueue = [];
|
||||
|
||||
@@ -1682,7 +1664,7 @@ class SMTPConnection extends EventEmitter {
|
||||
});
|
||||
this._sendCommand('DATA');
|
||||
} else {
|
||||
err = this._formatError("Can't send mail - all recipients were rejected", 'EENVELOPE', str, 'RCPT TO');
|
||||
err = this._formatError('Can\x27t send mail - all recipients were rejected', 'EENVELOPE', str, 'RCPT TO');
|
||||
err.rejected = this._envelope.rejected;
|
||||
err.rejectedErrors = this._envelope.rejectedErrors;
|
||||
return callback(err);
|
||||
@@ -1821,7 +1803,7 @@ class SMTPConnection extends EventEmitter {
|
||||
let defaultHostname;
|
||||
try {
|
||||
defaultHostname = os.hostname() || '';
|
||||
} catch (_err) {
|
||||
} catch (err) {
|
||||
// fails on windows 7
|
||||
defaultHostname = 'localhost';
|
||||
}
|
||||
|
||||
4
backend/node_modules/nodemailer/lib/smtp-pool/index.js
generated
vendored
4
backend/node_modules/nodemailer/lib/smtp-pool/index.js
generated
vendored
@@ -406,10 +406,6 @@ class SMTPPool extends EventEmitter {
|
||||
this._continueProcessing();
|
||||
}, 50);
|
||||
} else {
|
||||
if (!this._closed && this.idling && !this._connections.length) {
|
||||
this.emit('clear');
|
||||
}
|
||||
|
||||
this._continueProcessing();
|
||||
}
|
||||
});
|
||||
|
||||
5
backend/node_modules/nodemailer/lib/smtp-pool/pool-resource.js
generated
vendored
5
backend/node_modules/nodemailer/lib/smtp-pool/pool-resource.js
generated
vendored
@@ -23,8 +23,7 @@ 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,
|
||||
@@ -128,7 +127,7 @@ class PoolResource extends EventEmitter {
|
||||
|
||||
try {
|
||||
timer.unref();
|
||||
} catch (_E) {
|
||||
} catch (E) {
|
||||
// Ignore. Happens on envs with non-node timer implementation
|
||||
}
|
||||
});
|
||||
|
||||
2
backend/node_modules/nodemailer/lib/smtp-transport/index.js
generated
vendored
2
backend/node_modules/nodemailer/lib/smtp-transport/index.js
generated
vendored
@@ -197,7 +197,7 @@ class SMTPTransport extends EventEmitter {
|
||||
|
||||
try {
|
||||
timer.unref();
|
||||
} catch (_E) {
|
||||
} catch (E) {
|
||||
// Ignore. Happens on envs with non-node timer implementation
|
||||
}
|
||||
});
|
||||
|
||||
361
backend/node_modules/nodemailer/lib/well-known/services.json
generated
vendored
361
backend/node_modules/nodemailer/lib/well-known/services.json
generated
vendored
@@ -1,129 +1,55 @@
|
||||
{
|
||||
"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
|
||||
},
|
||||
|
||||
"Feishu Mail": {
|
||||
"description": "Feishu Mail (Lark)",
|
||||
"aliases": ["Feishu", "FeishuMail"],
|
||||
"domains": ["www.feishu.cn"],
|
||||
"host": "smtp.feishu.cn",
|
||||
"port": 465,
|
||||
"secure": true
|
||||
},
|
||||
|
||||
"Forward Email": {
|
||||
"description": "Forward Email (email forwarding service)",
|
||||
"aliases": ["FE", "ForwardEmail"],
|
||||
"domains": ["forwardemail.net"],
|
||||
"host": "smtp.forwardemail.net",
|
||||
@@ -131,15 +57,21 @@
|
||||
"secure": true
|
||||
},
|
||||
|
||||
"Feishu Mail": {
|
||||
"aliases": ["Feishu", "FeishuMail"],
|
||||
"domains": ["www.feishu.cn"],
|
||||
"host": "smtp.feishu.cn",
|
||||
"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",
|
||||
@@ -147,38 +79,26 @@
|
||||
"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",
|
||||
@@ -186,7 +106,6 @@
|
||||
},
|
||||
|
||||
"iCloud": {
|
||||
"description": "iCloud Mail",
|
||||
"aliases": ["Me", "Mac"],
|
||||
"domains": ["me.com", "mac.com"],
|
||||
"host": "smtp.mail.me.com",
|
||||
@@ -194,117 +113,72 @@
|
||||
},
|
||||
|
||||
"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,
|
||||
@@ -312,37 +186,30 @@
|
||||
},
|
||||
|
||||
"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,
|
||||
@@ -350,7 +217,6 @@
|
||||
},
|
||||
|
||||
"QQex": {
|
||||
"description": "QQ Enterprise Mail",
|
||||
"aliases": ["QQ Enterprise"],
|
||||
"domains": ["exmail.qq.com"],
|
||||
"host": "smtp.exmail.qq.com",
|
||||
@@ -358,204 +224,89 @@
|
||||
"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",
|
||||
@@ -564,21 +315,11 @@
|
||||
},
|
||||
|
||||
"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,
|
||||
@@ -586,26 +327,28 @@
|
||||
},
|
||||
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
||||
65
backend/node_modules/nodemailer/lib/xoauth2/index.js
generated
vendored
65
backend/node_modules/nodemailer/lib/xoauth2/index.js
generated
vendored
@@ -72,9 +72,6 @@ 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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,61 +82,14 @@ 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);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
let generateCallback = (...args) => {
|
||||
if (args[0]) {
|
||||
this.logger.error(
|
||||
{
|
||||
err,
|
||||
err: args[0],
|
||||
tnx: 'OAUTH2',
|
||||
user: this.options.user,
|
||||
action: 'renew'
|
||||
@@ -158,8 +108,7 @@ class XOAuth2 extends Stream {
|
||||
this.options.user
|
||||
);
|
||||
}
|
||||
// Complete original request
|
||||
callback(err, accessToken);
|
||||
callback(...args);
|
||||
};
|
||||
|
||||
if (this.provisionCallback) {
|
||||
@@ -217,8 +166,8 @@ class XOAuth2 extends Stream {
|
||||
let token;
|
||||
try {
|
||||
token = this.jwtSignRS256(tokenData);
|
||||
} catch (_err) {
|
||||
return callback(new Error("Can't generate token. Check your auth options"));
|
||||
} catch (err) {
|
||||
return callback(new Error('Can\x27t generate token. Check your auth options'));
|
||||
}
|
||||
|
||||
urlOptions = {
|
||||
@@ -232,7 +181,7 @@ class XOAuth2 extends Stream {
|
||||
};
|
||||
} else {
|
||||
if (!this.options.refreshToken) {
|
||||
return callback(new Error("Can't create new access token for user"));
|
||||
return callback(new Error('Can\x27t create new access token for user'));
|
||||
}
|
||||
|
||||
// web app - https://developers.google.com/identity/protocols/OAuth2WebServer
|
||||
|
||||
22
backend/node_modules/nodemailer/package.json
generated
vendored
22
backend/node_modules/nodemailer/package.json
generated
vendored
@@ -1,15 +1,12 @@
|
||||
{
|
||||
"name": "nodemailer",
|
||||
"version": "7.0.9",
|
||||
"version": "6.9.14",
|
||||
"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": {
|
||||
@@ -26,20 +23,19 @@
|
||||
},
|
||||
"homepage": "https://nodemailer.com/",
|
||||
"devDependencies": {
|
||||
"@aws-sdk/client-sesv2": "3.901.0",
|
||||
"@aws-sdk/client-ses": "3.600.0",
|
||||
"bunyan": "1.8.15",
|
||||
"c8": "10.1.3",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"globals": "^16.4.0",
|
||||
"c8": "10.1.2",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-nodemailer": "1.2.0",
|
||||
"eslint-config-prettier": "9.1.0",
|
||||
"libbase64": "1.3.0",
|
||||
"libmime": "5.3.7",
|
||||
"libqp": "2.1.1",
|
||||
"libmime": "5.3.5",
|
||||
"libqp": "2.1.0",
|
||||
"nodemailer-ntlm-auth": "1.0.4",
|
||||
"prettier": "^3.6.2",
|
||||
"proxy": "1.0.2",
|
||||
"proxy-test-server": "1.0.0",
|
||||
"smtp-server": "3.14.0"
|
||||
"smtp-server": "3.13.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
|
||||
19
backend/package-lock.json
generated
19
backend/package-lock.json
generated
@@ -22,8 +22,7 @@
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"mysql2": "^3.10.3",
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "^7.0.9",
|
||||
"nodemailer": "^6.9.14",
|
||||
"pdf-parse": "^1.1.1",
|
||||
"sequelize": "^6.37.3",
|
||||
"sharp": "^0.33.5"
|
||||
@@ -2828,15 +2827,6 @@
|
||||
"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",
|
||||
@@ -2863,10 +2853,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "7.0.9",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.9.tgz",
|
||||
"integrity": "sha512-9/Qm0qXIByEP8lEV2qOqcAW7bRpL8CR9jcTwk3NBnHJNmP9fIJ86g2fgmIXqHY+nj55ZEMwWqYAT2QTDpRUYiQ==",
|
||||
"license": "MIT-0",
|
||||
"version": "6.9.14",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.14.tgz",
|
||||
"integrity": "sha512-Dobp/ebDKBvz91sbtRKhcznLThrKxKt97GI2FAlAyy+fk19j73Uz3sBXolVtmcXjaorivqsbbbjDY+Jkt4/bQA==",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.0.0",
|
||||
"main": "server.js",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"postinstall": "cd ../frontend && npm install && npm run build",
|
||||
@@ -26,8 +26,7 @@
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"mysql2": "^3.10.3",
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "^7.0.9",
|
||||
"nodemailer": "^6.9.14",
|
||||
"pdf-parse": "^1.1.1",
|
||||
"sequelize": "^6.37.3",
|
||||
"sharp": "^0.33.5"
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import express from 'express';
|
||||
import { authenticate } from '../middleware/authMiddleware.js';
|
||||
import {
|
||||
getClubTeams,
|
||||
getClubTeam,
|
||||
createClubTeam,
|
||||
updateClubTeam,
|
||||
deleteClubTeam,
|
||||
getLeagues
|
||||
} from '../controllers/clubTeamController.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Get all club teams for a club
|
||||
router.get('/club/:clubid', authenticate, getClubTeams);
|
||||
|
||||
// Create a new club team
|
||||
router.post('/club/:clubid', authenticate, createClubTeam);
|
||||
|
||||
// Get leagues for a club
|
||||
router.get('/leagues/:clubid', authenticate, getLeagues);
|
||||
|
||||
// Get a specific club team
|
||||
router.get('/:clubteamid', authenticate, getClubTeam);
|
||||
|
||||
// Update a club team
|
||||
router.put('/:clubteamid', authenticate, updateClubTeam);
|
||||
|
||||
// Delete a club team
|
||||
router.delete('/:clubteamid', authenticate, deleteClubTeam);
|
||||
|
||||
export default router;
|
||||
36
backend/routes/externalServiceRoutes.js
Normal file
36
backend/routes/externalServiceRoutes.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import express from 'express';
|
||||
import externalServiceController from '../controllers/externalServiceController.js';
|
||||
import { authenticate } from '../middleware/authMiddleware.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// All routes require authentication
|
||||
router.use(authenticate);
|
||||
|
||||
// GET /api/external-service/account?service=mytischtennis - Get account
|
||||
router.get('/account', externalServiceController.getAccount);
|
||||
|
||||
// GET /api/external-service/status?service=mytischtennis - Check status
|
||||
router.get('/status', externalServiceController.getStatus);
|
||||
|
||||
// POST /api/external-service/account - Create or update account
|
||||
router.post('/account', externalServiceController.upsertAccount);
|
||||
|
||||
// DELETE /api/external-service/account?service=mytischtennis - Delete account
|
||||
router.delete('/account', externalServiceController.deleteAccount);
|
||||
|
||||
// POST /api/external-service/verify - Verify login
|
||||
router.post('/verify', externalServiceController.verifyLogin);
|
||||
|
||||
// GET /api/external-service/session?service=mytischtennis - Get stored session
|
||||
router.get('/session', externalServiceController.getSession);
|
||||
|
||||
// HeTTV specific routes
|
||||
// GET /api/external-service/hettv/main-page - Load HeTTV main page and find downloads
|
||||
router.get('/hettv/main-page', externalServiceController.loadHettvMainPage);
|
||||
|
||||
// POST /api/external-service/hettv/download-page - Load specific HeTTV download page
|
||||
router.post('/hettv/download-page', externalServiceController.loadHettvDownloadPage);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import express from 'express';
|
||||
import { uploadCSV, getLeaguesForCurrentSeason, getMatchesForLeagues, getMatchesForLeague, getLeagueTable, fetchLeagueTableFromMyTischtennis } from '../controllers/matchController.js';
|
||||
import { uploadCSV, getLeaguesForCurrentSeason, getMatchesForLeagues, getMatchesForLeague } from '../controllers/matchController.js';
|
||||
import { authenticate } from '../middleware/authMiddleware.js';
|
||||
import multer from 'multer';
|
||||
|
||||
@@ -11,8 +11,6 @@ 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;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getClubMembers, getWaitingApprovals, setClubMembers, uploadMemberImage, getMemberImage, updateRatingsFromMyTischtennis, rotateMemberImage } from '../controllers/memberController.js';
|
||||
import { getClubMembers, getWaitingApprovals, setClubMembers, uploadMemberImage, getMemberImage, updateRatingsFromMyTischtennis } from '../controllers/memberController.js';
|
||||
import express from 'express';
|
||||
import { authenticate } from '../middleware/authMiddleware.js';
|
||||
import multer from 'multer';
|
||||
@@ -14,6 +14,5 @@ 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;
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
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();
|
||||
|
||||
// All routes require authentication
|
||||
router.use(authenticate);
|
||||
|
||||
// GET /api/mytischtennis/account - Get account
|
||||
router.get('/account', myTischtennisController.getAccount);
|
||||
|
||||
// GET /api/mytischtennis/status - Check status
|
||||
router.get('/status', myTischtennisController.getStatus);
|
||||
|
||||
// POST /api/mytischtennis/account - Create or update account
|
||||
router.post('/account', myTischtennisController.upsertAccount);
|
||||
|
||||
// DELETE /api/mytischtennis/account - Delete account
|
||||
router.delete('/account', myTischtennisController.deleteAccount);
|
||||
|
||||
// POST /api/mytischtennis/verify - Verify login
|
||||
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;
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import express from 'express';
|
||||
import { authenticate } from '../middleware/authMiddleware.js';
|
||||
import {
|
||||
getSeasons,
|
||||
getCurrentSeason,
|
||||
createSeason,
|
||||
getSeason,
|
||||
deleteSeason
|
||||
} from '../controllers/seasonController.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Get all seasons
|
||||
router.get('/', authenticate, getSeasons);
|
||||
|
||||
// Get current season (creates if not exists)
|
||||
router.get('/current', authenticate, getCurrentSeason);
|
||||
|
||||
// Get a specific season
|
||||
router.get('/:seasonid', authenticate, getSeason);
|
||||
|
||||
// Create a new season
|
||||
router.post('/', authenticate, createSeason);
|
||||
|
||||
// Delete a season
|
||||
router.delete('/:seasonid', authenticate, deleteSeason);
|
||||
|
||||
export default router;
|
||||
@@ -1,33 +0,0 @@
|
||||
import express from 'express';
|
||||
import { authenticate } from '../middleware/authMiddleware.js';
|
||||
import {
|
||||
uploadMiddleware,
|
||||
uploadDocument,
|
||||
getDocuments,
|
||||
getDocument,
|
||||
downloadDocument,
|
||||
deleteDocument,
|
||||
parsePDF
|
||||
} from '../controllers/teamDocumentController.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Upload eines Dokuments für ein Club-Team
|
||||
router.post('/club-team/:clubteamid/upload', authenticate, uploadMiddleware, uploadDocument);
|
||||
|
||||
// Alle Dokumente für ein Club-Team abrufen
|
||||
router.get('/club-team/:clubteamid', authenticate, getDocuments);
|
||||
|
||||
// Ein spezifisches Dokument abrufen
|
||||
router.get('/:documentid', authenticate, getDocument);
|
||||
|
||||
// Ein Dokument herunterladen
|
||||
router.get('/:documentid/download', authenticate, downloadDocument);
|
||||
|
||||
// Ein Dokument löschen
|
||||
router.delete('/:documentid', authenticate, deleteDocument);
|
||||
|
||||
// PDF parsen und Matches extrahieren
|
||||
router.post('/:documentid/parse', authenticate, parsePDF);
|
||||
|
||||
export default router;
|
||||
@@ -1,32 +0,0 @@
|
||||
import express from 'express';
|
||||
import { authenticate } from '../middleware/authMiddleware.js';
|
||||
import {
|
||||
getTeams,
|
||||
getTeam,
|
||||
createTeam,
|
||||
updateTeam,
|
||||
deleteTeam,
|
||||
getLeagues
|
||||
} from '../controllers/teamController.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Get all teams for a club
|
||||
router.get('/club/:clubid', authenticate, getTeams);
|
||||
|
||||
// Get leagues for a club
|
||||
router.get('/leagues/:clubid', authenticate, getLeagues);
|
||||
|
||||
// Get a specific team
|
||||
router.get('/:teamid', authenticate, getTeam);
|
||||
|
||||
// Create a new team
|
||||
router.post('/club/:clubid', authenticate, createTeam);
|
||||
|
||||
// Update a team
|
||||
router.put('/:teamid', authenticate, updateTeam);
|
||||
|
||||
// Delete a team
|
||||
router.delete('/:teamid', authenticate, deleteTeam);
|
||||
|
||||
export default router;
|
||||
@@ -6,9 +6,9 @@ import cors from 'cors';
|
||||
import {
|
||||
User, Log, Club, UserClub, Member, DiaryDate, Participant, Activity, MemberNote,
|
||||
DiaryNote, DiaryTag, MemberDiaryTag, DiaryDateTag, DiaryMemberNote, DiaryMemberTag,
|
||||
PredefinedActivity, PredefinedActivityImage, DiaryDateActivity, DiaryMemberActivity, Match, League, Team, ClubTeam, TeamDocument, Group,
|
||||
PredefinedActivity, PredefinedActivityImage, DiaryDateActivity, DiaryMemberActivity, Match, League, Team, Group,
|
||||
GroupActivity, Tournament, TournamentGroup, TournamentMatch, TournamentResult,
|
||||
TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember, MyTischtennis, MyTischtennisUpdateHistory, MyTischtennisFetchLog
|
||||
TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember, MyTischtennis
|
||||
} from './models/index.js';
|
||||
import authRoutes from './routes/authRoutes.js';
|
||||
import clubRoutes from './routes/clubRoutes.js';
|
||||
@@ -34,11 +34,6 @@ import accidentRoutes from './routes/accidentRoutes.js';
|
||||
import trainingStatsRoutes from './routes/trainingStatsRoutes.js';
|
||||
import officialTournamentRoutes from './routes/officialTournamentRoutes.js';
|
||||
import myTischtennisRoutes from './routes/myTischtennisRoutes.js';
|
||||
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;
|
||||
@@ -84,10 +79,6 @@ app.use('/api/accident', accidentRoutes);
|
||||
app.use('/api/training-stats', trainingStatsRoutes);
|
||||
app.use('/api/official-tournaments', officialTournamentRoutes);
|
||||
app.use('/api/mytischtennis', myTischtennisRoutes);
|
||||
app.use('/api/teams', teamRoutes);
|
||||
app.use('/api/club-teams', clubTeamRoutes);
|
||||
app.use('/api/team-documents', teamDocumentRoutes);
|
||||
app.use('/api/seasons', seasonRoutes);
|
||||
|
||||
app.use(express.static(path.join(__dirname, '../frontend/dist')));
|
||||
|
||||
@@ -188,17 +179,9 @@ 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
1778
backend/server.log
File diff suppressed because it is too large
Load Diff
@@ -1,829 +0,0 @@
|
||||
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();
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
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();
|
||||
@@ -1,183 +0,0 @@
|
||||
import ClubTeam from '../models/ClubTeam.js';
|
||||
import League from '../models/League.js';
|
||||
import Season from '../models/Season.js';
|
||||
import SeasonService from './seasonService.js';
|
||||
import { devLog } from '../utils/logger.js';
|
||||
|
||||
class ClubTeamService {
|
||||
/**
|
||||
* Holt alle ClubTeams für einen Verein, optional gefiltert nach Saison.
|
||||
* Wenn keine Saison-ID angegeben ist, wird die aktuelle Saison verwendet.
|
||||
* @param {number} clubId - Die ID des Vereins.
|
||||
* @param {number|null} seasonId - Optionale Saison-ID.
|
||||
* @returns {Promise<Array<ClubTeam>>} Eine Liste von ClubTeams.
|
||||
*/
|
||||
static async getAllClubTeamsByClub(clubId, seasonId = null) {
|
||||
try {
|
||||
|
||||
// Wenn keine Saison angegeben, verwende die aktuelle
|
||||
if (!seasonId) {
|
||||
const currentSeason = await SeasonService.getOrCreateCurrentSeason();
|
||||
seasonId = currentSeason.id;
|
||||
}
|
||||
|
||||
const clubTeams = await ClubTeam.findAll({
|
||||
where: { clubId, seasonId },
|
||||
order: [['name', 'ASC']]
|
||||
});
|
||||
|
||||
// Manuelle Datenanreicherung für Liga und Saison
|
||||
const enrichedClubTeams = [];
|
||||
for (const clubTeam of clubTeams) {
|
||||
const enrichedTeam = {
|
||||
id: clubTeam.id,
|
||||
name: clubTeam.name,
|
||||
clubId: clubTeam.clubId,
|
||||
leagueId: clubTeam.leagueId,
|
||||
seasonId: clubTeam.seasonId,
|
||||
myTischtennisTeamId: clubTeam.myTischtennisTeamId,
|
||||
createdAt: clubTeam.createdAt,
|
||||
updatedAt: clubTeam.updatedAt,
|
||||
league: { name: 'Unbekannt' },
|
||||
season: { season: 'Unbekannt' }
|
||||
};
|
||||
|
||||
// Lade Liga-Daten
|
||||
if (clubTeam.leagueId) {
|
||||
const league = await League.findByPk(clubTeam.leagueId, {
|
||||
attributes: ['id', 'name', 'myTischtennisGroupId', 'association', 'groupname']
|
||||
});
|
||||
if (league) enrichedTeam.league = league;
|
||||
}
|
||||
|
||||
// Lade Saison-Daten
|
||||
if (clubTeam.seasonId) {
|
||||
const season = await Season.findByPk(clubTeam.seasonId, { attributes: ['season'] });
|
||||
if (season) enrichedTeam.season = season;
|
||||
}
|
||||
|
||||
enrichedClubTeams.push(enrichedTeam);
|
||||
}
|
||||
return enrichedClubTeams;
|
||||
} catch (error) {
|
||||
console.error('[ClubTeamService.getAllClubTeamsByClub] - Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt ein ClubTeam anhand seiner ID
|
||||
* @param {number} clubTeamId - Die ID des ClubTeams
|
||||
* @returns {Promise<ClubTeam|null>} Das ClubTeam oder null, wenn nicht gefunden
|
||||
*/
|
||||
static async getClubTeamById(clubTeamId) {
|
||||
try {
|
||||
const clubTeam = await ClubTeam.findByPk(clubTeamId, {
|
||||
include: [
|
||||
{
|
||||
model: League,
|
||||
as: 'league',
|
||||
attributes: ['id', 'name']
|
||||
},
|
||||
{
|
||||
model: Season,
|
||||
as: 'season',
|
||||
attributes: ['id', 'season']
|
||||
}
|
||||
]
|
||||
});
|
||||
return clubTeam;
|
||||
} catch (error) {
|
||||
console.error('[ClubTeamService.getClubTeamById] - Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt ein neues ClubTeam.
|
||||
* Wenn keine Saison-ID angegeben ist, wird die aktuelle Saison zugewiesen.
|
||||
* @param {object} clubTeamData - Die Daten des neuen ClubTeams (name, clubId, optional leagueId, seasonId).
|
||||
* @returns {Promise<ClubTeam>} Das erstellte ClubTeam.
|
||||
*/
|
||||
static async createClubTeam(clubTeamData) {
|
||||
try {
|
||||
|
||||
// Wenn keine Saison angegeben, verwende die aktuelle
|
||||
if (!clubTeamData.seasonId) {
|
||||
const currentSeason = await SeasonService.getOrCreateCurrentSeason();
|
||||
clubTeamData.seasonId = currentSeason.id;
|
||||
}
|
||||
|
||||
const clubTeam = await ClubTeam.create(clubTeamData);
|
||||
return clubTeam;
|
||||
} catch (error) {
|
||||
console.error('[ClubTeamService.createClubTeam] - Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualisiert ein bestehendes ClubTeam.
|
||||
* @param {number} clubTeamId - Die ID des zu aktualisierenden ClubTeams.
|
||||
* @param {object} updateData - Die zu aktualisierenden Daten.
|
||||
* @returns {Promise<boolean>} True, wenn das ClubTeam aktualisiert wurde, sonst false.
|
||||
*/
|
||||
static async updateClubTeam(clubTeamId, updateData) {
|
||||
try {
|
||||
const [updatedRowsCount] = await ClubTeam.update(updateData, {
|
||||
where: { id: clubTeamId }
|
||||
});
|
||||
return updatedRowsCount > 0;
|
||||
} catch (error) {
|
||||
console.error('[ClubTeamService.updateClubTeam] - Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Löscht ein ClubTeam.
|
||||
* @param {number} clubTeamId - Die ID des zu löschenden ClubTeams.
|
||||
* @returns {Promise<boolean>} True, wenn das ClubTeam gelöscht wurde, sonst false.
|
||||
*/
|
||||
static async deleteClubTeam(clubTeamId) {
|
||||
try {
|
||||
const deletedRows = await ClubTeam.destroy({
|
||||
where: { id: clubTeamId }
|
||||
});
|
||||
return deletedRows > 0;
|
||||
} catch (error) {
|
||||
console.error('[ClubTeamService.deleteClubTeam] - Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt alle Ligen für einen Verein, optional gefiltert nach Saison.
|
||||
* Wenn keine Saison-ID angegeben ist, wird die aktuelle Saison verwendet.
|
||||
* @param {number} clubId - Die ID des Vereins.
|
||||
* @param {number|null} seasonId - Optionale Saison-ID.
|
||||
* @returns {Promise<Array<League>>} Eine Liste von Ligen.
|
||||
*/
|
||||
static async getLeaguesByClub(clubId, seasonId = null) {
|
||||
try {
|
||||
|
||||
// Wenn keine Saison angegeben, verwende die aktuelle
|
||||
if (!seasonId) {
|
||||
const currentSeason = await SeasonService.getOrCreateCurrentSeason();
|
||||
seasonId = currentSeason.id;
|
||||
}
|
||||
|
||||
const leagues = await League.findAll({
|
||||
where: { clubId, seasonId },
|
||||
attributes: ['id', 'name', 'seasonId'],
|
||||
order: [['name', 'ASC']]
|
||||
});
|
||||
return leagues;
|
||||
} catch (error) {
|
||||
console.error('[ClubTeamService.getLeaguesByClub] - Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ClubTeamService;
|
||||
@@ -10,7 +10,9 @@ import { devLog } from '../utils/logger.js';
|
||||
class DiaryDateActivityService {
|
||||
|
||||
async createActivity(userToken, clubId, data) {
|
||||
devLog('[DiaryDateActivityService::createActivity] - check user access');
|
||||
await checkAccess(userToken, clubId);
|
||||
devLog('[DiaryDateActivityService::createActivity] - add: ', data);
|
||||
const { activity, ...restData } = data;
|
||||
// Versuche, die PredefinedActivity robust zu finden:
|
||||
// 1) per übergebener ID
|
||||
@@ -58,18 +60,23 @@ class DiaryDateActivityService {
|
||||
});
|
||||
const newOrderId = maxOrderId !== null ? maxOrderId + 1 : 1;
|
||||
restData.orderId = newOrderId;
|
||||
devLog('[DiaryDateActivityService::createActivity] - create diary date activity');
|
||||
return await DiaryDateActivity.create(restData);
|
||||
}
|
||||
|
||||
async updateActivity(userToken, clubId, id, data) {
|
||||
devLog('[DiaryDateActivityService::updateActivity] - check user access');
|
||||
await checkAccess(userToken, clubId);
|
||||
devLog('[DiaryDateActivityService::updateActivity] - load activity', id);
|
||||
const activity = await DiaryDateActivity.findByPk(id);
|
||||
if (!activity) {
|
||||
devLog('[DiaryDateActivityService::updateActivity] - activity not found');
|
||||
throw new Error('Activity not found');
|
||||
}
|
||||
|
||||
// Wenn customActivityName gesendet wird, müssen wir die PredefinedActivity behandeln
|
||||
if (data.customActivityName) {
|
||||
devLog('[DiaryDateActivityService::updateActivity] - handling customActivityName:', data.customActivityName);
|
||||
|
||||
// Suche nach einer existierenden PredefinedActivity mit diesem Namen
|
||||
let predefinedActivity = await PredefinedActivity.findOne({
|
||||
@@ -78,6 +85,7 @@ class DiaryDateActivityService {
|
||||
|
||||
if (!predefinedActivity) {
|
||||
// Erstelle eine neue PredefinedActivity
|
||||
devLog('[DiaryDateActivityService::updateActivity] - creating new PredefinedActivity');
|
||||
predefinedActivity = await PredefinedActivity.create({
|
||||
name: data.customActivityName,
|
||||
description: data.description || '',
|
||||
@@ -92,6 +100,7 @@ class DiaryDateActivityService {
|
||||
delete data.customActivityName;
|
||||
}
|
||||
|
||||
devLog('[DiaryDateActivityService::updateActivity] - update activity', clubId, id, data, JSON.stringify(data));
|
||||
return await activity.update(data);
|
||||
}
|
||||
|
||||
@@ -105,14 +114,22 @@ class DiaryDateActivityService {
|
||||
}
|
||||
|
||||
async updateActivityOrder(userToken, clubId, id, newOrderId) {
|
||||
devLog(`[DiaryDateActivityService::updateActivityOrder] - Start update for activity id: ${id}`);
|
||||
devLog(`[DiaryDateActivityService::updateActivityOrder] - User token: ${userToken}, Club id: ${clubId}, New order id: ${newOrderId}`);
|
||||
devLog('[DiaryDateActivityService::updateActivityOrder] - Checking user access');
|
||||
await checkAccess(userToken, clubId);
|
||||
devLog('[DiaryDateActivityService::updateActivityOrder] - User access confirmed');
|
||||
devLog(`[DiaryDateActivityService::updateActivityOrder] - Finding activity with id: ${id}`);
|
||||
const activity = await DiaryDateActivity.findByPk(id);
|
||||
if (!activity) {
|
||||
console.error('[DiaryDateActivityService::updateActivityOrder] - Activity not found, throwing error');
|
||||
throw new Error('Activity not found');
|
||||
}
|
||||
devLog('[DiaryDateActivityService::updateActivityOrder] - Activity found:', activity);
|
||||
const currentOrderId = activity.orderId;
|
||||
devLog(`[DiaryDateActivityService::updateActivityOrder] - Current order id: ${currentOrderId}`);
|
||||
if (newOrderId < currentOrderId) {
|
||||
devLog(`[DiaryDateActivityService::updateActivityOrder] - Shifting items down. Moving activities with orderId between ${newOrderId} and ${currentOrderId - 1}`);
|
||||
await DiaryDateActivity.increment(
|
||||
{ orderId: 1 },
|
||||
{
|
||||
@@ -122,7 +139,9 @@ class DiaryDateActivityService {
|
||||
},
|
||||
}
|
||||
);
|
||||
devLog(`[DiaryDateActivityService::updateActivityOrder] - Items shifted down`);
|
||||
} else if (newOrderId > currentOrderId) {
|
||||
devLog(`[DiaryDateActivityService::updateActivityOrder] - Shifting items up. Moving activities with orderId between ${currentOrderId + 1} and ${newOrderId}`);
|
||||
await DiaryDateActivity.decrement(
|
||||
{ orderId: 1 },
|
||||
{
|
||||
@@ -132,10 +151,16 @@ class DiaryDateActivityService {
|
||||
},
|
||||
}
|
||||
);
|
||||
devLog(`[DiaryDateActivityService::updateActivityOrder] - Items shifted up`);
|
||||
} else {
|
||||
devLog('[DiaryDateActivityService::updateActivityOrder] - New order id is the same as the current order id. No shift required.');
|
||||
}
|
||||
devLog(`[DiaryDateActivityService::updateActivityOrder] - Setting new order id for activity id: ${id}`);
|
||||
activity.orderId = newOrderId;
|
||||
devLog('[DiaryDateActivityService::updateActivityOrder] - Saving activity with new order id');
|
||||
const savedActivity = await activity.save();
|
||||
devLog('[DiaryDateActivityService::updateActivityOrder] - Activity saved:', savedActivity);
|
||||
devLog(`[DiaryDateActivityService::updateActivityOrder] - Finished update for activity id: ${id}`);
|
||||
return savedActivity;
|
||||
}
|
||||
|
||||
@@ -232,7 +257,9 @@ class DiaryDateActivityService {
|
||||
}
|
||||
|
||||
async addGroupActivity(userToken, clubId, diaryDateId, groupId, activity) {
|
||||
devLog('[DiaryDateActivityService::addGroupActivity] Check user access');
|
||||
await checkAccess(userToken, clubId);
|
||||
devLog('[DiaryDateActivityService::addGroupActivity] Check diary date');
|
||||
const diaryDateActivity = await DiaryDateActivity.findOne({
|
||||
where: {
|
||||
diaryDateId,
|
||||
@@ -245,16 +272,19 @@ class DiaryDateActivityService {
|
||||
console.error('[DiaryDateActivityService::addGroupActivity] Activity not found');
|
||||
throw new Error('Activity not found');
|
||||
}
|
||||
devLog('[DiaryDateActivityService::addGroupActivity] Check group');
|
||||
const group = await Group.findByPk(groupId);
|
||||
if (!group || group.diaryDateId !== diaryDateActivity.diaryDateId) {
|
||||
console.error('[DiaryDateActivityService::addGroupActivity] Group and date don\'t fit');
|
||||
throw new Error('Group isn\'t related to date');
|
||||
}
|
||||
devLog('[DiaryDateActivityService::addGroupActivity] Get predefined activity');
|
||||
const [predefinedActivity, created] = await PredefinedActivity.findOrCreate({
|
||||
where: {
|
||||
name: activity
|
||||
}
|
||||
});
|
||||
devLog('[DiaryDateActivityService::addGroupActivity] Add group activity');
|
||||
devLog(predefinedActivity);
|
||||
const activityData = {
|
||||
diaryDateActivity: diaryDateActivity.id,
|
||||
|
||||
@@ -10,11 +10,14 @@ import HttpError from '../exceptions/HttpError.js';
|
||||
import { devLog } from '../utils/logger.js';
|
||||
class DiaryService {
|
||||
async getDatesForClub(userToken, clubId) {
|
||||
devLog('[DiaryService::getDatesForClub] - Check user access');
|
||||
await checkAccess(userToken, clubId);
|
||||
devLog('[DiaryService::getDatesForClub] - Validate club existence');
|
||||
const club = await Club.findByPk(clubId);
|
||||
if (!club) {
|
||||
throw new HttpError('Club not found', 404);
|
||||
}
|
||||
devLog('[DiaryService::getDatesForClub] - Load diary dates');
|
||||
const dates = await DiaryDate.findAll({
|
||||
where: { clubId },
|
||||
include: [
|
||||
@@ -27,11 +30,14 @@ class DiaryService {
|
||||
}
|
||||
|
||||
async createDateForClub(userToken, clubId, date, trainingStart, trainingEnd) {
|
||||
devLog('[DiaryService::createDateForClub] - Check user access');
|
||||
await checkAccess(userToken, clubId);
|
||||
devLog('[DiaryService::createDateForClub] - Validate club existence');
|
||||
const club = await Club.findByPk(clubId);
|
||||
if (!club) {
|
||||
throw new HttpError('Club not found', 404);
|
||||
}
|
||||
devLog('[DiaryService::createDateForClub] - Validate date');
|
||||
const parsedDate = new Date(date);
|
||||
if (isNaN(parsedDate.getTime())) {
|
||||
throw new HttpError('Invalid date format', 400);
|
||||
@@ -39,6 +45,7 @@ class DiaryService {
|
||||
if (trainingStart && trainingEnd && trainingStart >= trainingEnd) {
|
||||
throw new HttpError('Training start time must be before training end time', 400);
|
||||
}
|
||||
devLog('[DiaryService::createDateForClub] - Create new diary date');
|
||||
const newDate = await DiaryDate.create({
|
||||
date: parsedDate,
|
||||
clubId,
|
||||
@@ -50,7 +57,9 @@ class DiaryService {
|
||||
}
|
||||
|
||||
async updateTrainingTimes(userToken, clubId, dateId, trainingStart, trainingEnd) {
|
||||
devLog('[DiaryService::updateTrainingTimes] - Check user access');
|
||||
await checkAccess(userToken, clubId);
|
||||
devLog('[DiaryService::updateTrainingTimes] - Validate date');
|
||||
const diaryDate = await DiaryDate.findOne({ where: { clubId, id: dateId } });
|
||||
if (!diaryDate) {
|
||||
throw new HttpError('Diary entry not found', 404);
|
||||
@@ -58,6 +67,7 @@ class DiaryService {
|
||||
if (trainingStart && trainingEnd && trainingStart >= trainingEnd) {
|
||||
throw new HttpError('Training start time must be before training end time', 400);
|
||||
}
|
||||
devLog('[DiaryService::updateTrainingTimes] - Update training times');
|
||||
diaryDate.trainingStart = trainingStart || null;
|
||||
diaryDate.trainingEnd = trainingEnd || null;
|
||||
await diaryDate.save();
|
||||
@@ -65,12 +75,14 @@ class DiaryService {
|
||||
}
|
||||
|
||||
async addNoteToDate(userToken, diaryDateId, content) {
|
||||
devLog('[DiaryService::addNoteToDate] - Add note');
|
||||
await checkAccess(userToken, diaryDateId);
|
||||
await DiaryNote.create({ diaryDateId, content });
|
||||
return await DiaryNote.findAll({ where: { diaryDateId }, order: [['createdAt', 'DESC']] });
|
||||
}
|
||||
|
||||
async deleteNoteFromDate(userToken, noteId) {
|
||||
devLog('[DiaryService::deleteNoteFromDate] - Delete note');
|
||||
const note = await DiaryNote.findByPk(noteId);
|
||||
if (!note) {
|
||||
throw new HttpError('Note not found', 404);
|
||||
@@ -81,6 +93,7 @@ class DiaryService {
|
||||
}
|
||||
|
||||
async addTagToDate(userToken, diaryDateId, tagName) {
|
||||
devLog('[DiaryService::addTagToDate] - Add tag');
|
||||
await checkAccess(userToken, diaryDateId);
|
||||
let tag = await DiaryTag.findOne({ where: { name: tagName } });
|
||||
if (!tag) {
|
||||
@@ -93,24 +106,29 @@ class DiaryService {
|
||||
|
||||
async addTagToDiaryDate(userToken, clubId, diaryDateId, tagId) {
|
||||
checkAccess(userToken, clubId);
|
||||
devLog(`[DiaryService::addTagToDiaryDate] - diaryDateId: ${diaryDateId}, tagId: ${tagId}`);
|
||||
const diaryDate = await DiaryDate.findByPk(diaryDateId);
|
||||
if (!diaryDate) {
|
||||
throw new HttpError('DiaryDate not found', 404);
|
||||
}
|
||||
devLog('[DiaryService::addTagToDiaryDate] - Add tag to diary date');
|
||||
const existingEntry = await DiaryDateTag.findOne({
|
||||
where: { diaryDateId, tagId }
|
||||
});
|
||||
if (existingEntry) {
|
||||
return;
|
||||
}
|
||||
devLog('[DiaryService::addTagToDiaryDate] - Tag not found, creating new entry');
|
||||
const tag = await DiaryTag.findByPk(tagId);
|
||||
if (!tag) {
|
||||
throw new HttpError('Tag not found', 404);
|
||||
}
|
||||
devLog('[DiaryService::addTagToDiaryDate] - Add tag to diary date');
|
||||
await DiaryDateTag.create({
|
||||
diaryDateId,
|
||||
tagId
|
||||
});
|
||||
devLog('[DiaryService::addTagToDiaryDate] - Get tags');
|
||||
const tags = await DiaryDateTag.findAll({ where: {
|
||||
diaryDateId: diaryDateId },
|
||||
include: {
|
||||
@@ -123,6 +141,7 @@ class DiaryService {
|
||||
}
|
||||
|
||||
async getDiaryNotesForDateAndMember(diaryDateId, memberId) {
|
||||
devLog('[DiaryService::getDiaryNotesForDateAndMember] - Fetching notes');
|
||||
return await DiaryNote.findAll({
|
||||
where: { diaryDateId, memberId },
|
||||
order: [['createdAt', 'DESC']]
|
||||
@@ -135,15 +154,19 @@ class DiaryService {
|
||||
}
|
||||
|
||||
async removeDateForClub(userToken, clubId, dateId) {
|
||||
devLog('[DiaryService::removeDateForClub] - Check user access');
|
||||
await checkAccess(userToken, clubId);
|
||||
devLog('[DiaryService::removeDateForClub] - Validate date');
|
||||
const diaryDate = await DiaryDate.findOne({ where: { id: dateId, clubId } });
|
||||
if (!diaryDate) {
|
||||
throw new HttpError('Diary entry not found', 404);
|
||||
}
|
||||
devLog('[DiaryService::removeDateForClub] - Check for activities');
|
||||
const activityCount = await DiaryDateActivity.count({ where: { diaryDateId: dateId } });
|
||||
if (activityCount > 0) {
|
||||
throw new HttpError('Cannot delete date with activities', 409);
|
||||
}
|
||||
devLog('[DiaryService::removeDateForClub] - Delete diary date');
|
||||
await diaryDate.destroy();
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
347
backend/services/externalServiceService.js
Normal file
347
backend/services/externalServiceService.js
Normal file
@@ -0,0 +1,347 @@
|
||||
import ExternalServiceAccount from '../models/ExternalServiceAccount.js';
|
||||
import User from '../models/User.js';
|
||||
import myTischtennisClient from '../clients/myTischtennisClient.js';
|
||||
import hettvClient from '../clients/hettvClient.js';
|
||||
import HttpError from '../exceptions/HttpError.js';
|
||||
|
||||
class ExternalServiceService {
|
||||
/**
|
||||
* Get the appropriate client for a service
|
||||
*/
|
||||
getClientForService(service) {
|
||||
switch (service) {
|
||||
case 'mytischtennis':
|
||||
return myTischtennisClient;
|
||||
case 'hettv':
|
||||
return hettvClient;
|
||||
default:
|
||||
throw new HttpError(400, `Unbekannter Service: ${service}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get account for user and service
|
||||
*/
|
||||
async getAccount(userId, service = 'mytischtennis') {
|
||||
const account = await ExternalServiceAccount.findOne({
|
||||
where: { userId, service },
|
||||
attributes: ['id', 'service', 'email', 'savePassword', 'lastLoginAttempt', 'lastLoginSuccess', 'expiresAt', 'userData', 'clubId', 'clubName', 'fedNickname', 'createdAt', 'updatedAt']
|
||||
});
|
||||
return account;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update external service account
|
||||
*/
|
||||
async upsertAccount(userId, email, password, savePassword, userPassword, service = 'mytischtennis') {
|
||||
// Verify user's app password
|
||||
const user = await User.findByPk(userId);
|
||||
if (!user) {
|
||||
throw new HttpError(404, 'Benutzer nicht gefunden');
|
||||
}
|
||||
|
||||
let loginResult = null;
|
||||
|
||||
// Wenn ein Passwort gesetzt/geändert wird, App-Passwort verifizieren
|
||||
if (password) {
|
||||
const isValidPassword = await user.validatePassword(userPassword);
|
||||
if (!isValidPassword) {
|
||||
throw new HttpError(401, 'Ungültiges Passwort');
|
||||
}
|
||||
|
||||
// Login-Versuch beim entsprechenden Service
|
||||
const client = this.getClientForService(service);
|
||||
loginResult = await client.login(email, password);
|
||||
if (!loginResult.success) {
|
||||
throw new HttpError(401, loginResult.error || `${service}-Login fehlgeschlagen. Bitte überprüfen Sie Ihre Zugangsdaten.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Find or create account
|
||||
let account = await ExternalServiceAccount.findOne({ where: { userId, service } });
|
||||
|
||||
const now = new Date();
|
||||
|
||||
if (account) {
|
||||
// Update existing
|
||||
account.email = email;
|
||||
account.savePassword = savePassword;
|
||||
|
||||
if (password && savePassword) {
|
||||
account.setPassword(password);
|
||||
} else if (!savePassword) {
|
||||
account.encryptedPassword = null;
|
||||
}
|
||||
|
||||
if (loginResult && loginResult.success) {
|
||||
account.lastLoginAttempt = now;
|
||||
account.lastLoginSuccess = now;
|
||||
account.accessToken = loginResult.accessToken;
|
||||
account.refreshToken = loginResult.refreshToken;
|
||||
account.expiresAt = loginResult.expiresAt;
|
||||
account.cookie = loginResult.cookie;
|
||||
account.userData = loginResult.user;
|
||||
|
||||
// Hole Club-ID und Federation (nur für myTischtennis)
|
||||
if (service === 'mytischtennis') {
|
||||
console.log('[externalServiceService] - Getting user profile...');
|
||||
const profileResult = await myTischtennisClient.getUserProfile(loginResult.cookie);
|
||||
console.log('[externalServiceService] - Profile result:', {
|
||||
success: profileResult.success,
|
||||
clubId: profileResult.clubId,
|
||||
clubName: profileResult.clubName,
|
||||
fedNickname: profileResult.fedNickname
|
||||
});
|
||||
|
||||
if (profileResult.success) {
|
||||
account.clubId = profileResult.clubId;
|
||||
account.clubName = profileResult.clubName;
|
||||
account.fedNickname = profileResult.fedNickname;
|
||||
console.log('[externalServiceService] - Updated account with club data');
|
||||
} else {
|
||||
console.error('[externalServiceService] - Failed to get profile:', profileResult.error);
|
||||
}
|
||||
}
|
||||
} else if (password) {
|
||||
account.lastLoginAttempt = now;
|
||||
}
|
||||
|
||||
console.log('[externalServiceService] - Speichere Account (update).');
|
||||
try {
|
||||
await account.save();
|
||||
} catch (e) {
|
||||
console.error('[externalServiceService] - Fehler beim Speichern (update):', e.message, e.parent?.sqlMessage);
|
||||
throw e;
|
||||
}
|
||||
} else {
|
||||
// Create new
|
||||
const accountData = {
|
||||
userId,
|
||||
service,
|
||||
email,
|
||||
savePassword,
|
||||
lastLoginAttempt: password ? now : null,
|
||||
lastLoginSuccess: loginResult?.success ? now : null
|
||||
};
|
||||
|
||||
if (loginResult && loginResult.success) {
|
||||
accountData.accessToken = loginResult.accessToken;
|
||||
accountData.refreshToken = loginResult.refreshToken;
|
||||
accountData.expiresAt = loginResult.expiresAt;
|
||||
accountData.cookie = loginResult.cookie;
|
||||
accountData.userData = loginResult.user;
|
||||
|
||||
// Hole Club-/Verbandsdaten nur für myTischtennis
|
||||
if (service === 'mytischtennis') {
|
||||
const profileResult = await myTischtennisClient.getUserProfile(loginResult.cookie);
|
||||
if (profileResult.success) {
|
||||
accountData.clubId = profileResult.clubId;
|
||||
accountData.clubName = profileResult.clubName;
|
||||
accountData.fedNickname = profileResult.fedNickname;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[externalServiceService] - Erstelle Account (create).');
|
||||
try {
|
||||
account = await ExternalServiceAccount.create(accountData);
|
||||
} catch (e) {
|
||||
console.error('[externalServiceService] - Fehler beim Erstellen (create):', e.message, e.parent?.sqlMessage);
|
||||
throw e;
|
||||
}
|
||||
|
||||
if (password && savePassword) {
|
||||
account.setPassword(password);
|
||||
console.log('[externalServiceService] - Speichere Passwort (nach create).');
|
||||
try {
|
||||
await account.save();
|
||||
} catch (e) {
|
||||
console.error('[externalServiceService] - Fehler beim Speichern (nach create):', e.message, e.parent?.sqlMessage);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: account.id,
|
||||
email: account.email,
|
||||
savePassword: account.savePassword,
|
||||
lastLoginAttempt: account.lastLoginAttempt,
|
||||
lastLoginSuccess: account.lastLoginSuccess,
|
||||
expiresAt: account.expiresAt
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete external service account
|
||||
*/
|
||||
async deleteAccount(userId, service = 'mytischtennis') {
|
||||
const deleted = await ExternalServiceAccount.destroy({
|
||||
where: { userId, service }
|
||||
});
|
||||
return deleted > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify login with stored or provided credentials
|
||||
*/
|
||||
async verifyLogin(userId, providedPassword = null, service = 'mytischtennis') {
|
||||
const account = await ExternalServiceAccount.findOne({ where: { userId, service } });
|
||||
|
||||
if (!account) {
|
||||
throw new HttpError(404, `Kein ${service}-Account verknüpft`);
|
||||
}
|
||||
|
||||
let password = providedPassword;
|
||||
|
||||
// Wenn kein Passwort übergeben wurde, versuche gespeichertes Passwort zu verwenden
|
||||
if (!password) {
|
||||
if (!account.savePassword || !account.encryptedPassword) {
|
||||
throw new HttpError(400, 'Kein Passwort gespeichert. Bitte geben Sie Ihr Passwort ein.');
|
||||
}
|
||||
password = account.getPassword();
|
||||
}
|
||||
|
||||
// Login-Versuch
|
||||
const now = new Date();
|
||||
account.lastLoginAttempt = now;
|
||||
const client = this.getClientForService(service);
|
||||
const loginResult = await client.login(account.email, password);
|
||||
|
||||
if (loginResult.success) {
|
||||
account.lastLoginSuccess = now;
|
||||
account.accessToken = loginResult.accessToken;
|
||||
account.refreshToken = loginResult.refreshToken;
|
||||
account.expiresAt = loginResult.expiresAt;
|
||||
account.cookie = loginResult.cookie;
|
||||
account.userData = loginResult.user;
|
||||
|
||||
// Hole Club-/Verbandsdaten nur für myTischtennis
|
||||
if (service === 'mytischtennis') {
|
||||
console.log('[externalServiceService] - Getting myTischtennis user profile...');
|
||||
const profileResult = await myTischtennisClient.getUserProfile(loginResult.cookie);
|
||||
console.log('[externalServiceService] - Profile result:', {
|
||||
success: profileResult.success,
|
||||
clubId: profileResult.clubId,
|
||||
clubName: profileResult.clubName,
|
||||
fedNickname: profileResult.fedNickname
|
||||
});
|
||||
|
||||
if (profileResult.success) {
|
||||
account.clubId = profileResult.clubId;
|
||||
account.clubName = profileResult.clubName;
|
||||
account.fedNickname = profileResult.fedNickname;
|
||||
console.log('[externalServiceService] - Updated account with club data');
|
||||
} else {
|
||||
console.error('[externalServiceService] - Failed to get profile:', profileResult.error);
|
||||
}
|
||||
}
|
||||
|
||||
await account.save();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
accessToken: loginResult.accessToken,
|
||||
refreshToken: loginResult.refreshToken,
|
||||
expiresAt: loginResult.expiresAt,
|
||||
user: loginResult.user,
|
||||
clubId: account.clubId,
|
||||
clubName: account.clubName
|
||||
};
|
||||
} else {
|
||||
await account.save(); // Save lastLoginAttempt
|
||||
throw new HttpError(401, loginResult.error || 'myTischtennis-Login fehlgeschlagen');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if account is configured and ready
|
||||
*/
|
||||
async checkAccountStatus(userId, service = 'mytischtennis') {
|
||||
const account = await ExternalServiceAccount.findOne({ where: { userId, service } });
|
||||
|
||||
return {
|
||||
exists: !!account,
|
||||
hasEmail: !!account?.email,
|
||||
hasPassword: !!(account?.savePassword && account?.encryptedPassword),
|
||||
hasValidSession: !!account?.accessToken && account?.expiresAt > Date.now() / 1000,
|
||||
needsConfiguration: !account || !account.email,
|
||||
needsPassword: !!account && (!account.savePassword || !account.encryptedPassword)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stored session for user (for authenticated API requests)
|
||||
*/
|
||||
async getSession(userId, service = 'mytischtennis') {
|
||||
const account = await ExternalServiceAccount.findOne({ where: { userId, service } });
|
||||
|
||||
if (!account) {
|
||||
throw new HttpError(404, `Kein ${service}-Account verknüpft`);
|
||||
}
|
||||
|
||||
// Check if session is valid
|
||||
if (service === 'hettv') {
|
||||
// HeTTV nutzt Cookie-basierte Session, kein expiresAt verfügbar
|
||||
if (!account.cookie) {
|
||||
throw new HttpError(401, 'Session abgelaufen. Bitte erneut einloggen.');
|
||||
}
|
||||
} else {
|
||||
if (!account.accessToken || !account.expiresAt || account.expiresAt < Date.now() / 1000) {
|
||||
throw new HttpError(401, 'Session abgelaufen. Bitte erneut einloggen.');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken: account.accessToken,
|
||||
refreshToken: account.refreshToken,
|
||||
cookie: account.cookie,
|
||||
expiresAt: account.expiresAt,
|
||||
userData: account.userData
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load HeTTV main page and find download links
|
||||
*/
|
||||
async loadHettvMainPage(userId) {
|
||||
const client = this.getClientForService('hettv');
|
||||
let session = await this.getSession(userId, 'hettv');
|
||||
|
||||
const finalUrl = session.userData?.finalUrl || null;
|
||||
let result = await client.getMainPageWithDownloads(session.cookie, finalUrl);
|
||||
|
||||
// Wenn Session abgelaufen: versuche automatischen Re-Login mit gespeichertem Passwort
|
||||
if (!result.success && result.status === 401) {
|
||||
const account = await ExternalServiceAccount.findOne({ where: { userId, service: 'hettv' } });
|
||||
if (account && account.savePassword && account.encryptedPassword) {
|
||||
const password = account.getPassword();
|
||||
const login = await client.login(account.email, password);
|
||||
if (login.success) {
|
||||
// Session aktualisieren
|
||||
account.cookie = login.cookie;
|
||||
account.userData = login.user;
|
||||
await account.save();
|
||||
// Erneut versuchen
|
||||
result = await client.getMainPageWithDownloads(login.cookie, login.user?.finalUrl || null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load specific HeTTV download page
|
||||
*/
|
||||
async loadHettvDownloadPage(userId, downloadUrl) {
|
||||
const session = await this.getSession(userId, 'hettv');
|
||||
const client = this.getClientForService('hettv');
|
||||
|
||||
const finalUrl = session.userData?.finalUrl || null;
|
||||
return await client.loadDownloadPage(downloadUrl, session.cookie, finalUrl);
|
||||
}
|
||||
}
|
||||
|
||||
export default new ExternalServiceService();
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import League from '../models/League.js';
|
||||
import Season from '../models/Season.js';
|
||||
import SeasonService from './seasonService.js';
|
||||
import { devLog } from '../utils/logger.js';
|
||||
|
||||
class LeagueService {
|
||||
static async getAllLeaguesByClub(clubId, seasonId = null) {
|
||||
try {
|
||||
|
||||
// Wenn keine Saison angegeben, verwende die aktuelle
|
||||
if (!seasonId) {
|
||||
const currentSeason = await SeasonService.getOrCreateCurrentSeason();
|
||||
seasonId = currentSeason.id;
|
||||
}
|
||||
|
||||
const leagues = await League.findAll({
|
||||
where: { clubId, seasonId },
|
||||
include: [
|
||||
{
|
||||
model: Season,
|
||||
as: 'season',
|
||||
attributes: ['id', 'season']
|
||||
}
|
||||
],
|
||||
order: [['name', 'ASC']]
|
||||
});
|
||||
return leagues;
|
||||
} catch (error) {
|
||||
console.error('[LeagueService.getAllLeaguesByClub] - Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async getLeagueById(leagueId) {
|
||||
try {
|
||||
const league = await League.findByPk(leagueId, {
|
||||
include: [
|
||||
{
|
||||
model: Season,
|
||||
as: 'season',
|
||||
attributes: ['id', 'season']
|
||||
}
|
||||
]
|
||||
});
|
||||
return league;
|
||||
} catch (error) {
|
||||
console.error('[LeagueService.getLeagueById] - Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async createLeague(leagueData) {
|
||||
try {
|
||||
|
||||
// Wenn keine Saison angegeben, verwende die aktuelle
|
||||
if (!leagueData.seasonId) {
|
||||
const currentSeason = await SeasonService.getOrCreateCurrentSeason();
|
||||
leagueData.seasonId = currentSeason.id;
|
||||
}
|
||||
|
||||
const league = await League.create(leagueData);
|
||||
return league;
|
||||
} catch (error) {
|
||||
console.error('[LeagueService.createLeague] - Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async updateLeague(leagueId, updateData) {
|
||||
try {
|
||||
const [updatedRowsCount] = await League.update(updateData, {
|
||||
where: { id: leagueId }
|
||||
});
|
||||
return updatedRowsCount > 0;
|
||||
} catch (error) {
|
||||
console.error('[LeagueService.updateLeague] - Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async deleteLeague(leagueId) {
|
||||
try {
|
||||
const deletedRowsCount = await League.destroy({
|
||||
where: { id: leagueId }
|
||||
});
|
||||
return deletedRowsCount > 0;
|
||||
} catch (error) {
|
||||
console.error('[LeagueService.deleteLeague] - Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default LeagueService;
|
||||
@@ -7,55 +7,12 @@ 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';
|
||||
|
||||
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;
|
||||
@@ -65,7 +22,8 @@ class MatchService {
|
||||
seasonStartYear = currentYear - 1;
|
||||
}
|
||||
const seasonEndYear = seasonStartYear + 1;
|
||||
return `${seasonStartYear}/${seasonEndYear}`;
|
||||
const seasonEndYearString = seasonEndYear.toString().slice(-2);
|
||||
return `${seasonStartYear}/${seasonEndYearString}`;
|
||||
}
|
||||
|
||||
async importCSV(userToken, clubId, filePath) {
|
||||
@@ -89,20 +47,8 @@ class MatchService {
|
||||
seasonId: season.id,
|
||||
},
|
||||
});
|
||||
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 homeTeamId = await this.getOrCreateTeamId(row['HeimMannschaft'], clubId);
|
||||
const guestTeamId = await this.getOrCreateTeamId(row['GastMannschaft'], clubId);
|
||||
const [location] = await Location.findOrCreate({
|
||||
where: {
|
||||
name: row['HalleName'],
|
||||
@@ -112,6 +58,7 @@ class MatchService {
|
||||
},
|
||||
});
|
||||
matches.push({
|
||||
seasonId: season.id,
|
||||
date: parsedDate,
|
||||
time: row['Termin'].split(' ')[1],
|
||||
homeTeamId: homeTeamId,
|
||||
@@ -125,14 +72,7 @@ class MatchService {
|
||||
if (seasonString) {
|
||||
season = await Season.findOne({ where: { season: seasonString } });
|
||||
if (season) {
|
||||
// Lösche alle Matches für Ligen dieser Saison
|
||||
const leagues = await League.findAll({
|
||||
where: { seasonId: season.id, clubId }
|
||||
});
|
||||
const leagueIds = leagues.map(league => league.id);
|
||||
if (leagueIds.length > 0) {
|
||||
await Match.destroy({ where: { clubId, leagueId: leagueIds } });
|
||||
}
|
||||
await Match.destroy({ where: { clubId, seasonId: season.id } });
|
||||
}
|
||||
}
|
||||
const result = await Match.bulkCreate(matches);
|
||||
@@ -144,52 +84,48 @@ class MatchService {
|
||||
}
|
||||
}
|
||||
|
||||
async getOrCreateTeamId(teamName, ageClass, clubId, leagueId, seasonId) {
|
||||
// Format team name with age class
|
||||
const formattedTeamName = this.formatTeamNameWithAgeClass(teamName, ageClass);
|
||||
|
||||
devLog(`Team: "${teamName}" + "${ageClass}" -> "${formattedTeamName}"`);
|
||||
|
||||
async getOrCreateTeamId(teamName, clubId) {
|
||||
const [team] = await Team.findOrCreate({
|
||||
where: {
|
||||
name: formattedTeamName,
|
||||
clubId: clubId,
|
||||
leagueId: leagueId,
|
||||
seasonId: seasonId
|
||||
name: teamName,
|
||||
clubId: clubId
|
||||
},
|
||||
defaults: {
|
||||
name: formattedTeamName,
|
||||
clubId: clubId,
|
||||
leagueId: leagueId,
|
||||
seasonId: seasonId
|
||||
name: teamName,
|
||||
clubId: clubId
|
||||
}
|
||||
});
|
||||
return team.id;
|
||||
}
|
||||
|
||||
|
||||
async getLeaguesForCurrentSeason(userToken, clubId, seasonId = null) {
|
||||
async getLeaguesForCurrentSeason(userToken, clubId) {
|
||||
await checkAccess(userToken, clubId);
|
||||
|
||||
// Verwende SeasonService für korrekte Saison-Verwaltung
|
||||
let season;
|
||||
if (!seasonId) {
|
||||
season = await SeasonService.getOrCreateCurrentSeason();
|
||||
} else {
|
||||
season = await SeasonService.getSeasonById(seasonId);
|
||||
if (!season) {
|
||||
throw new Error('Season not found');
|
||||
const seasonString = this.generateSeasonString();
|
||||
const season = await Season.findOne({
|
||||
where: {
|
||||
season: {
|
||||
[Op.like]: `%${seasonString}%`
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!season) {
|
||||
await Season.create({ season: seasonString });
|
||||
throw new Error('Season not found');
|
||||
}
|
||||
|
||||
try {
|
||||
const leagues = await League.findAll({
|
||||
where: {
|
||||
clubId: clubId,
|
||||
seasonId: season.id
|
||||
},
|
||||
include: [{
|
||||
model: Match,
|
||||
as: 'leagueMatches',
|
||||
where: {
|
||||
seasonId: season.id,
|
||||
clubId: clubId
|
||||
},
|
||||
attributes: [],
|
||||
}],
|
||||
attributes: ['id', 'name'],
|
||||
order: [['name', 'ASC']]
|
||||
group: ['League.id'],
|
||||
});
|
||||
return leagues;
|
||||
} catch (error) {
|
||||
@@ -198,73 +134,48 @@ class MatchService {
|
||||
}
|
||||
}
|
||||
|
||||
async getMatchesForLeagues(userToken, clubId, seasonId = null) {
|
||||
async getMatchesForLeagues(userToken, clubId) {
|
||||
await checkAccess(userToken, clubId);
|
||||
|
||||
// Wenn keine Saison angegeben, verwende die aktuelle
|
||||
let season;
|
||||
if (!seasonId) {
|
||||
season = await SeasonService.getOrCreateCurrentSeason();
|
||||
} else {
|
||||
season = await SeasonService.getSeasonById(seasonId);
|
||||
if (!season) {
|
||||
throw new Error('Season not found');
|
||||
const seasonString = this.generateSeasonString();
|
||||
const season = await Season.findOne({
|
||||
where: {
|
||||
season: {
|
||||
[Op.like]: `%${seasonString}%`
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!season) {
|
||||
throw new Error('Season not found');
|
||||
}
|
||||
const matches = await Match.findAll({
|
||||
where: {
|
||||
seasonId: season.id,
|
||||
clubId: clubId,
|
||||
}
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: League,
|
||||
as: 'leagueDetails',
|
||||
attributes: ['name'],
|
||||
},
|
||||
{
|
||||
model: Team,
|
||||
as: 'homeTeam', // Assuming your associations are set correctly
|
||||
attributes: ['name'],
|
||||
},
|
||||
{
|
||||
model: Team,
|
||||
as: 'guestTeam',
|
||||
attributes: ['name'],
|
||||
},
|
||||
{
|
||||
model: Location,
|
||||
as: 'location',
|
||||
attributes: ['name', 'address', 'city', 'zip'],
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Filtere Matches nach Liga-Saison und lade Daten manuell
|
||||
const enrichedMatches = [];
|
||||
for (const match of matches) {
|
||||
// Lade Liga-Daten
|
||||
const league = await League.findByPk(match.leagueId, { attributes: ['name', 'seasonId'] });
|
||||
if (!league || league.seasonId !== season.id) {
|
||||
continue; // Skip matches from other seasons
|
||||
}
|
||||
|
||||
const enrichedMatch = {
|
||||
id: match.id,
|
||||
date: match.date,
|
||||
time: match.time,
|
||||
homeTeamId: match.homeTeamId,
|
||||
guestTeamId: match.guestTeamId,
|
||||
locationId: match.locationId,
|
||||
leagueId: match.leagueId,
|
||||
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: '' },
|
||||
leagueDetails: { name: league.name }
|
||||
};
|
||||
|
||||
if (match.homeTeamId) {
|
||||
const homeTeam = await Team.findByPk(match.homeTeamId, { attributes: ['name'] });
|
||||
if (homeTeam) enrichedMatch.homeTeam = homeTeam;
|
||||
}
|
||||
if (match.guestTeamId) {
|
||||
const guestTeam = await Team.findByPk(match.guestTeamId, { attributes: ['name'] });
|
||||
if (guestTeam) enrichedMatch.guestTeam = guestTeam;
|
||||
}
|
||||
if (match.locationId) {
|
||||
const location = await Location.findByPk(match.locationId, {
|
||||
attributes: ['name', 'address', 'city', 'zip']
|
||||
});
|
||||
if (location) enrichedMatch.location = location;
|
||||
}
|
||||
|
||||
enrichedMatches.push(enrichedMatch);
|
||||
}
|
||||
return enrichedMatches;
|
||||
return matches;
|
||||
}
|
||||
|
||||
async getMatchesForLeague(userToken, clubId, leagueId) {
|
||||
@@ -280,154 +191,36 @@ class MatchService {
|
||||
if (!season) {
|
||||
throw new Error('Season not found');
|
||||
}
|
||||
|
||||
// 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({
|
||||
const matches = await Match.findAll({
|
||||
where: {
|
||||
seasonId: season.id,
|
||||
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}%`
|
||||
include: [
|
||||
{
|
||||
model: League,
|
||||
as: 'leagueDetails',
|
||||
attributes: ['name'],
|
||||
},
|
||||
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 } }
|
||||
]
|
||||
{
|
||||
model: Team,
|
||||
as: 'homeTeam',
|
||||
attributes: ['name'],
|
||||
},
|
||||
{
|
||||
model: Team,
|
||||
as: 'guestTeam',
|
||||
attributes: ['name'],
|
||||
},
|
||||
{
|
||||
model: Location,
|
||||
as: 'location',
|
||||
attributes: ['name', 'address', 'city', 'zip'],
|
||||
}
|
||||
});
|
||||
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) {
|
||||
const enrichedMatch = {
|
||||
id: match.id,
|
||||
date: match.date,
|
||||
time: match.time,
|
||||
homeTeamId: match.homeTeamId,
|
||||
guestTeamId: match.guestTeamId,
|
||||
locationId: match.locationId,
|
||||
leagueId: match.leagueId,
|
||||
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: '' },
|
||||
leagueDetails: { name: 'Unbekannt' }
|
||||
};
|
||||
|
||||
if (match.homeTeamId) {
|
||||
const homeTeam = await Team.findByPk(match.homeTeamId, { attributes: ['name'] });
|
||||
if (homeTeam) enrichedMatch.homeTeam = homeTeam;
|
||||
}
|
||||
if (match.guestTeamId) {
|
||||
const guestTeam = await Team.findByPk(match.guestTeamId, { attributes: ['name'] });
|
||||
if (guestTeam) enrichedMatch.guestTeam = guestTeam;
|
||||
}
|
||||
if (match.locationId) {
|
||||
const location = await Location.findByPk(match.locationId, {
|
||||
attributes: ['name', 'address', 'city', 'zip']
|
||||
});
|
||||
if (location) enrichedMatch.location = location;
|
||||
}
|
||||
if (match.leagueId) {
|
||||
const league = await League.findByPk(match.leagueId, { attributes: ['name'] });
|
||||
if (league) enrichedMatch.leagueDetails = league;
|
||||
}
|
||||
|
||||
enrichedMatches.push(enrichedMatch);
|
||||
}
|
||||
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');
|
||||
}
|
||||
]
|
||||
});
|
||||
return matches;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -9,8 +9,11 @@ import sharp from 'sharp';
|
||||
import { devLog } from '../utils/logger.js';
|
||||
class MemberService {
|
||||
async getApprovalRequests(userToken, clubId) {
|
||||
devLog('[MemberService::getApprovalRequest] - Check user access');
|
||||
await checkAccess(userToken, clubId);
|
||||
devLog('[MemberService::getApprovalRequest] - Load user');
|
||||
const user = await getUserByToken(userToken);
|
||||
devLog('[MemberService::getApprovalRequest] - Load userclub');
|
||||
return await UserClub.findAll({
|
||||
where: {
|
||||
clubId: clubId,
|
||||
@@ -21,7 +24,9 @@ class MemberService {
|
||||
}
|
||||
|
||||
async getClubMembers(userToken, clubId, showAll) {
|
||||
devLog('[getClubMembers] - Check access');
|
||||
await checkAccess(userToken, clubId);
|
||||
devLog('[getClubMembers] - Find members');
|
||||
const where = {
|
||||
clubId: clubId
|
||||
};
|
||||
@@ -40,6 +45,7 @@ class MemberService {
|
||||
});
|
||||
})
|
||||
.then(membersWithImageStatus => {
|
||||
devLog('[getClubMembers] - return members');
|
||||
return membersWithImageStatus;
|
||||
})
|
||||
.catch(error => {
|
||||
@@ -51,11 +57,15 @@ class MemberService {
|
||||
async setClubMember(userToken, clubId, memberId, firstName, lastName, street, city, birthdate, phone, email, active = true, testMembership = false,
|
||||
picsInInternetAllowed = false, gender = 'unknown', ttr = null, qttr = null) {
|
||||
try {
|
||||
devLog('[setClubMembers] - Check access');
|
||||
await checkAccess(userToken, clubId);
|
||||
devLog('[setClubMembers] - set default member');
|
||||
let member = null;
|
||||
devLog('[setClubMembers] - load member if possible');
|
||||
if (memberId) {
|
||||
member = await Member.findOne({ where: { id: memberId } });
|
||||
}
|
||||
devLog('[setClubMembers] - set member');
|
||||
if (member) {
|
||||
member.firstName = firstName;
|
||||
member.lastName = lastName;
|
||||
@@ -89,6 +99,7 @@ class MemberService {
|
||||
qttr: qttr,
|
||||
});
|
||||
}
|
||||
devLog('[setClubMembers] - return response');
|
||||
return {
|
||||
status: 200,
|
||||
response: { result: "success" },
|
||||
@@ -142,50 +153,34 @@ class MemberService {
|
||||
}
|
||||
|
||||
async updateRatingsFromMyTischtennis(userToken, clubId) {
|
||||
devLog('[updateRatingsFromMyTischtennis] - Check access');
|
||||
await checkAccess(userToken, clubId);
|
||||
|
||||
const user = await getUserByToken(userToken);
|
||||
const startTime = Date.now();
|
||||
devLog('[updateRatingsFromMyTischtennis] - User:', user.id);
|
||||
|
||||
const myTischtennisService = (await import('./myTischtennisService.js')).default;
|
||||
const externalServiceService = (await import('./externalServiceService.js')).default;
|
||||
const myTischtennisClient = (await import('../clients/myTischtennisClient.js')).default;
|
||||
const fetchLogService = (await import('./myTischtennisFetchLogService.js')).default;
|
||||
|
||||
try {
|
||||
// 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
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
// 1. myTischtennis-Session abrufen
|
||||
devLog('[updateRatingsFromMyTischtennis] - Get session for user', user.id);
|
||||
const session = await externalServiceService.getSession(user.id, 'mytischtennis');
|
||||
devLog('[updateRatingsFromMyTischtennis] - Session retrieved:', {
|
||||
hasAccessToken: !!session.accessToken,
|
||||
hasCookie: !!session.cookie,
|
||||
expiresAt: session.expiresAt
|
||||
});
|
||||
|
||||
const account = await myTischtennisService.getAccount(user.id);
|
||||
const account = await externalServiceService.getAccount(user.id, 'mytischtennis');
|
||||
devLog('[updateRatingsFromMyTischtennis] - Account data:', {
|
||||
id: account?.id,
|
||||
email: account?.email,
|
||||
clubId: account?.clubId,
|
||||
clubName: account?.clubName,
|
||||
fedNickname: account?.fedNickname,
|
||||
hasSession: !!(account?.accessToken)
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
console.error('[updateRatingsFromMyTischtennis] - No account found!');
|
||||
@@ -222,12 +217,24 @@ class MemberService {
|
||||
}
|
||||
|
||||
// 2. Rangliste vom Verein abrufen
|
||||
devLog('[updateRatingsFromMyTischtennis] - Get club rankings', {
|
||||
clubId: account.clubId,
|
||||
fedNickname: account.fedNickname,
|
||||
hasCookie: !!session.cookie
|
||||
});
|
||||
|
||||
const rankings = await myTischtennisClient.getClubRankings(
|
||||
session.cookie,
|
||||
account.clubId,
|
||||
account.fedNickname
|
||||
);
|
||||
|
||||
devLog('[updateRatingsFromMyTischtennis] - Rankings result:', {
|
||||
success: rankings.success,
|
||||
entriesCount: rankings.entries?.length || 0,
|
||||
error: rankings.error
|
||||
});
|
||||
|
||||
if (!rankings.success) {
|
||||
return {
|
||||
status: 500,
|
||||
@@ -245,7 +252,9 @@ class MemberService {
|
||||
}
|
||||
|
||||
// 3. Alle Mitglieder des Clubs laden
|
||||
devLog('[updateRatingsFromMyTischtennis] - Load club members for clubId:', clubId);
|
||||
const members = await Member.findAll({ where: { clubId } });
|
||||
devLog('[updateRatingsFromMyTischtennis] - Found members:', members.length);
|
||||
|
||||
let updated = 0;
|
||||
const errors = [];
|
||||
@@ -276,6 +285,7 @@ class MemberService {
|
||||
oldTtr: oldTtr,
|
||||
newTtr: rankingEntry.fedRank
|
||||
});
|
||||
devLog(`[updateRatingsFromMyTischtennis] - Updated ${firstName} ${lastName}: TTR ${oldTtr} → ${rankingEntry.fedRank}`);
|
||||
} catch (error) {
|
||||
console.error(`[updateRatingsFromMyTischtennis] - Error updating ${firstName} ${lastName}:`, error);
|
||||
errors.push({
|
||||
@@ -285,9 +295,11 @@ class MemberService {
|
||||
}
|
||||
} else {
|
||||
notFound.push(`${firstName} ${lastName}`);
|
||||
devLog(`[updateRatingsFromMyTischtennis] - Not found in rankings: ${firstName} ${lastName}`);
|
||||
}
|
||||
}
|
||||
|
||||
devLog('[updateRatingsFromMyTischtennis] - Update complete');
|
||||
devLog(`Updated: ${updated}, Not found: ${notFound.length}, Errors: ${errors.length}`);
|
||||
|
||||
let message = `${updated} Mitglied(er) aktualisiert.`;
|
||||
@@ -298,19 +310,6 @@ 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: {
|
||||
@@ -325,20 +324,6 @@ 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: {
|
||||
@@ -349,56 +334,6 @@ 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();
|
||||
@@ -1,129 +0,0 @@
|
||||
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();
|
||||
|
||||
@@ -1,293 +0,0 @@
|
||||
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';
|
||||
|
||||
import { devLog } from '../utils/logger.js';
|
||||
class MyTischtennisService {
|
||||
/**
|
||||
* Get myTischtennis account for user
|
||||
*/
|
||||
async getAccount(userId) {
|
||||
const account = await MyTischtennis.findOne({
|
||||
where: { userId },
|
||||
attributes: ['id', 'userId', 'email', 'savePassword', 'autoUpdateRatings', 'lastLoginAttempt', 'lastLoginSuccess', 'lastUpdateRatings', 'expiresAt', 'userData', 'clubId', 'clubName', 'fedNickname', 'createdAt', 'updatedAt']
|
||||
});
|
||||
return account;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update myTischtennis account
|
||||
*/
|
||||
async upsertAccount(userId, email, password, savePassword, autoUpdateRatings, userPassword) {
|
||||
// Verify user's app password
|
||||
const user = await User.findByPk(userId);
|
||||
if (!user) {
|
||||
throw new HttpError(404, 'Benutzer nicht gefunden');
|
||||
}
|
||||
|
||||
let loginResult = null;
|
||||
|
||||
// Wenn ein Passwort gesetzt/geändert wird, App-Passwort verifizieren
|
||||
if (password) {
|
||||
const isValidPassword = await user.validatePassword(userPassword);
|
||||
if (!isValidPassword) {
|
||||
throw new HttpError(401, 'Ungültiges Passwort');
|
||||
}
|
||||
|
||||
// Login-Versuch bei myTischtennis
|
||||
loginResult = await myTischtennisClient.login(email, password);
|
||||
if (!loginResult.success) {
|
||||
throw new HttpError(401, loginResult.error || 'myTischtennis-Login fehlgeschlagen. Bitte überprüfen Sie Ihre Zugangsdaten.');
|
||||
}
|
||||
}
|
||||
|
||||
// Find or create account
|
||||
let account = await MyTischtennis.findOne({ where: { userId } });
|
||||
|
||||
const now = new Date();
|
||||
|
||||
if (account) {
|
||||
// Update existing
|
||||
account.email = email;
|
||||
account.savePassword = savePassword;
|
||||
account.autoUpdateRatings = autoUpdateRatings;
|
||||
|
||||
if (password && savePassword) {
|
||||
account.setPassword(password);
|
||||
} else if (!savePassword) {
|
||||
account.encryptedPassword = null;
|
||||
}
|
||||
|
||||
if (loginResult && loginResult.success) {
|
||||
account.lastLoginAttempt = now;
|
||||
account.lastLoginSuccess = now;
|
||||
account.accessToken = loginResult.accessToken;
|
||||
account.refreshToken = loginResult.refreshToken;
|
||||
account.expiresAt = loginResult.expiresAt;
|
||||
account.cookie = loginResult.cookie;
|
||||
account.userData = loginResult.user;
|
||||
|
||||
// Hole Club-ID und Federation
|
||||
const profileResult = await myTischtennisClient.getUserProfile(loginResult.cookie);
|
||||
|
||||
if (profileResult.success) {
|
||||
account.clubId = profileResult.clubId;
|
||||
account.clubName = profileResult.clubName;
|
||||
account.fedNickname = profileResult.fedNickname;
|
||||
} else {
|
||||
console.error('[myTischtennisService] - Failed to get profile:', profileResult.error);
|
||||
}
|
||||
} else if (password) {
|
||||
account.lastLoginAttempt = now;
|
||||
}
|
||||
|
||||
await account.save();
|
||||
} else {
|
||||
// Create new
|
||||
const accountData = {
|
||||
userId,
|
||||
email,
|
||||
savePassword,
|
||||
autoUpdateRatings,
|
||||
lastLoginAttempt: password ? now : null,
|
||||
lastLoginSuccess: loginResult?.success ? now : null
|
||||
};
|
||||
|
||||
if (loginResult && loginResult.success) {
|
||||
accountData.accessToken = loginResult.accessToken;
|
||||
accountData.refreshToken = loginResult.refreshToken;
|
||||
accountData.expiresAt = loginResult.expiresAt;
|
||||
accountData.cookie = loginResult.cookie;
|
||||
accountData.userData = loginResult.user;
|
||||
|
||||
// Hole Club-ID
|
||||
const profileResult = await myTischtennisClient.getUserProfile(loginResult.cookie);
|
||||
if (profileResult.success) {
|
||||
accountData.clubId = profileResult.clubId;
|
||||
accountData.clubName = profileResult.clubName;
|
||||
}
|
||||
}
|
||||
|
||||
account = await MyTischtennis.create(accountData);
|
||||
|
||||
if (password && savePassword) {
|
||||
account.setPassword(password);
|
||||
await account.save();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: account.id,
|
||||
email: account.email,
|
||||
savePassword: account.savePassword,
|
||||
autoUpdateRatings: account.autoUpdateRatings,
|
||||
lastLoginAttempt: account.lastLoginAttempt,
|
||||
lastLoginSuccess: account.lastLoginSuccess,
|
||||
lastUpdateRatings: account.lastUpdateRatings,
|
||||
expiresAt: account.expiresAt
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete myTischtennis account
|
||||
*/
|
||||
async deleteAccount(userId) {
|
||||
const deleted = await MyTischtennis.destroy({
|
||||
where: { userId }
|
||||
});
|
||||
return deleted > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify login with stored or provided credentials
|
||||
*/
|
||||
async verifyLogin(userId, providedPassword = null) {
|
||||
const account = await MyTischtennis.findOne({ where: { userId } });
|
||||
|
||||
if (!account) {
|
||||
throw new HttpError(404, 'Kein myTischtennis-Account verknüpft');
|
||||
}
|
||||
|
||||
let password = providedPassword;
|
||||
|
||||
// Wenn kein Passwort übergeben wurde, versuche gespeichertes Passwort zu verwenden
|
||||
if (!password) {
|
||||
if (!account.savePassword || !account.encryptedPassword) {
|
||||
throw new HttpError(400, 'Kein Passwort gespeichert. Bitte geben Sie Ihr Passwort ein.');
|
||||
}
|
||||
password = account.getPassword();
|
||||
}
|
||||
|
||||
// Login-Versuch
|
||||
const now = new Date();
|
||||
account.lastLoginAttempt = now;
|
||||
const loginResult = await myTischtennisClient.login(account.email, password);
|
||||
|
||||
if (loginResult.success) {
|
||||
account.lastLoginSuccess = now;
|
||||
account.accessToken = loginResult.accessToken;
|
||||
account.refreshToken = loginResult.refreshToken;
|
||||
account.expiresAt = loginResult.expiresAt;
|
||||
account.cookie = loginResult.cookie;
|
||||
account.userData = loginResult.user;
|
||||
|
||||
// Hole Club-ID und Federation
|
||||
const profileResult = await myTischtennisClient.getUserProfile(loginResult.cookie);
|
||||
|
||||
if (profileResult.success) {
|
||||
account.clubId = profileResult.clubId;
|
||||
account.clubName = profileResult.clubName;
|
||||
account.fedNickname = profileResult.fedNickname;
|
||||
} else {
|
||||
console.error('[myTischtennisService] - Failed to get profile:', profileResult.error);
|
||||
}
|
||||
|
||||
await account.save();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
accessToken: loginResult.accessToken,
|
||||
refreshToken: loginResult.refreshToken,
|
||||
expiresAt: loginResult.expiresAt,
|
||||
user: loginResult.user,
|
||||
clubId: account.clubId,
|
||||
clubName: account.clubName
|
||||
};
|
||||
} else {
|
||||
await account.save(); // Save lastLoginAttempt
|
||||
throw new HttpError(401, loginResult.error || 'myTischtennis-Login fehlgeschlagen');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if account is configured and ready
|
||||
*/
|
||||
async checkAccountStatus(userId) {
|
||||
const account = await MyTischtennis.findOne({ where: { userId } });
|
||||
|
||||
return {
|
||||
exists: !!account,
|
||||
hasEmail: !!account?.email,
|
||||
hasPassword: !!(account?.savePassword && account?.encryptedPassword),
|
||||
hasValidSession: !!account?.accessToken && account?.expiresAt > Date.now() / 1000,
|
||||
needsConfiguration: !account || !account.email,
|
||||
needsPassword: !!account && (!account.savePassword || !account.encryptedPassword)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stored session for user (for authenticated API requests)
|
||||
*/
|
||||
async getSession(userId) {
|
||||
const account = await MyTischtennis.findOne({ where: { userId } });
|
||||
|
||||
if (!account) {
|
||||
throw new HttpError(404, 'Kein myTischtennis-Account verknüpft');
|
||||
}
|
||||
|
||||
// Check if session is valid
|
||||
if (!account.accessToken || !account.expiresAt || account.expiresAt < Date.now() / 1000) {
|
||||
throw new HttpError(401, 'Session abgelaufen. Bitte erneut einloggen.');
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken: account.accessToken,
|
||||
refreshToken: account.refreshToken,
|
||||
cookie: account.cookie,
|
||||
expiresAt: account.expiresAt,
|
||||
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();
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
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();
|
||||
@@ -1,699 +0,0 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { createRequire } from 'module';
|
||||
const require = createRequire(import.meta.url);
|
||||
const pdfParse = require('pdf-parse/lib/pdf-parse.js');
|
||||
import { Op } from 'sequelize';
|
||||
import Match from '../models/Match.js';
|
||||
import Team from '../models/Team.js';
|
||||
import ClubTeam from '../models/ClubTeam.js';
|
||||
import League from '../models/League.js';
|
||||
import Location from '../models/Location.js';
|
||||
import { devLog } from '../utils/logger.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
class PDFParserService {
|
||||
/**
|
||||
* Parst eine PDF-Datei und extrahiert Spiel-Daten
|
||||
* @param {string} filePath - Pfad zur PDF-Datei
|
||||
* @param {number} clubId - ID des Vereins
|
||||
* @returns {Promise<Object>} Geparste Spiel-Daten
|
||||
*/
|
||||
static async parsePDF(filePath, clubId) {
|
||||
try {
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error('PDF-Datei nicht gefunden');
|
||||
}
|
||||
|
||||
// Bestimme Dateityp basierend auf Dateiendung
|
||||
const fileExtension = path.extname(filePath).toLowerCase();
|
||||
let fileContent;
|
||||
|
||||
if (fileExtension === '.pdf') {
|
||||
// Echte PDF-Parsing
|
||||
const pdfBuffer = fs.readFileSync(filePath);
|
||||
const pdfData = await pdfParse(pdfBuffer);
|
||||
fileContent = pdfData.text;
|
||||
} else {
|
||||
// Fallback für TXT-Dateien (für Tests)
|
||||
fileContent = fs.readFileSync(filePath, 'utf8');
|
||||
}
|
||||
|
||||
// Parse den Text nach Spiel-Daten
|
||||
const parsedData = this.extractMatchData(fileContent, clubId);
|
||||
|
||||
|
||||
return parsedData;
|
||||
} catch (error) {
|
||||
console.error('[PDFParserService.parsePDF] - Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrahiert Spiel-Daten aus dem PDF-Text
|
||||
* @param {string} text - Der extrahierte Text aus der PDF
|
||||
* @param {number} clubId - ID des Vereins
|
||||
* @returns {Object} Geparste Daten mit Matches und Metadaten
|
||||
*/
|
||||
static extractMatchData(text, clubId) {
|
||||
const matches = [];
|
||||
const errors = [];
|
||||
const metadata = {
|
||||
totalLines: 0,
|
||||
parsedMatches: 0,
|
||||
errors: 0
|
||||
};
|
||||
|
||||
try {
|
||||
// Teile Text in Zeilen auf
|
||||
const lines = text.split('\n').map(line => line.trim()).filter(line => line.length > 0);
|
||||
metadata.totalLines = lines.length;
|
||||
|
||||
|
||||
// Verschiedene Parsing-Strategien je nach PDF-Format
|
||||
const strategies = [
|
||||
{ name: 'Standard Format', fn: this.parseStandardFormat },
|
||||
{ name: 'Table Format', fn: this.parseTableFormat },
|
||||
{ name: 'List Format', fn: this.parseListFormat }
|
||||
];
|
||||
|
||||
|
||||
for (const strategy of strategies) {
|
||||
try {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
metadata.errors = errors.length;
|
||||
|
||||
return {
|
||||
matches,
|
||||
errors,
|
||||
metadata,
|
||||
rawText: text.substring(0, 1000), // Erste 1000 Zeichen für Debugging
|
||||
allLines: lines, // Alle Zeilen für Debugging
|
||||
debugInfo: {
|
||||
totalTextLength: text.length,
|
||||
totalLines: lines.length,
|
||||
firstFewLines: lines.slice(0, 10),
|
||||
lastFewLines: lines.slice(-5)
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[PDFParserService.extractMatchData] - Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard-Format Parser (Datum, Zeit, Heimteam, Gastteam, Code, Pins)
|
||||
* @param {Array} lines - Textzeilen
|
||||
* @param {number} clubId - ID des Vereins
|
||||
* @returns {Object} Geparste Matches
|
||||
*/
|
||||
static parseStandardFormat(lines, clubId) {
|
||||
const matches = [];
|
||||
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
// Suche nach Datum-Pattern (dd.mm.yyyy oder dd/mm/yyyy)
|
||||
const dateMatch = line.match(/(\d{1,2})[./](\d{1,2})[./](\d{4})/);
|
||||
if (dateMatch) {
|
||||
|
||||
// Debug: Zeige die gesamte Zeile mit sichtbaren Whitespaces
|
||||
const debugLine = line.replace(/\s/g, (match) => {
|
||||
if (match === ' ') return '·'; // Mittelpunkt für normales Leerzeichen
|
||||
if (match === '\t') return '→'; // Pfeil für Tab
|
||||
if (match === '\n') return '↵'; // Enter-Zeichen
|
||||
if (match === '\r') return '⏎'; // Carriage Return
|
||||
return `[${match.charCodeAt(0)}]`; // Zeichencode für andere Whitespaces
|
||||
});
|
||||
|
||||
try {
|
||||
const [, day, month, year] = dateMatch;
|
||||
const date = new Date(`${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`);
|
||||
|
||||
// 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[1].padStart(2, '0')}:${timeMatch[2]}`;
|
||||
}
|
||||
|
||||
|
||||
// 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+\)/, '');
|
||||
|
||||
// Entferne alle Inhalte in Klammern (z.B. "(J11)")
|
||||
const cleanLine3 = cleanLine2.replace(/\([^)]*\)/g, '');
|
||||
|
||||
// Suche nach Code (12 Zeichen) oder PIN (4 Ziffern) am Ende
|
||||
const codeMatch = cleanLine3.match(/([A-Z0-9]{12})$/);
|
||||
const pinMatch = cleanLine3.match(/(\d{4})$/);
|
||||
|
||||
let code = null;
|
||||
let homePin = null;
|
||||
let guestPin = null;
|
||||
let teamsPart = cleanLine3;
|
||||
|
||||
if (codeMatch) {
|
||||
// Code gefunden (12 Zeichen)
|
||||
code = codeMatch[1];
|
||||
teamsPart = cleanLine3.substring(0, cleanLine3.length - code.length).trim();
|
||||
} else if (pinMatch) {
|
||||
// PIN gefunden (4 Ziffern)
|
||||
const pin = pinMatch[1];
|
||||
teamsPart = cleanLine3.substring(0, cleanLine3.length - pin.length).trim();
|
||||
|
||||
// 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');
|
||||
|
||||
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 {
|
||||
// "Harheimer TC" nicht gefunden → Standardlogik: PIN gehört zum Gastteam
|
||||
guestPin = pin;
|
||||
}
|
||||
}
|
||||
|
||||
if (code || pinMatch) {
|
||||
|
||||
|
||||
// Debug: Zeige Whitespaces als lesbare Zeichen
|
||||
const debugTeamsPart = teamsPart.replace(/\s/g, (match) => {
|
||||
if (match === ' ') return '·'; // Mittelpunkt für normales Leerzeichen
|
||||
if (match === '\t') return '→'; // Pfeil für Tab
|
||||
return `[${match.charCodeAt(0)}]`; // Zeichencode für andere Whitespaces
|
||||
});
|
||||
|
||||
// Neue Strategie: Teile die Zeile durch mehrere Leerzeichen (wie in der Tabelle)
|
||||
// Die Struktur ist: Heimmannschaft Gastmannschaft Code
|
||||
const parts = teamsPart.split(/\s{2,}/); // Mindestens 2 Leerzeichen als Trenner
|
||||
|
||||
|
||||
let homeTeamName = '';
|
||||
let guestTeamName = '';
|
||||
|
||||
if (parts.length >= 2) {
|
||||
homeTeamName = parts[0].trim();
|
||||
guestTeamName = parts[1].trim();
|
||||
|
||||
// Entferne noch verbleibende Klammern aus den Team-Namen
|
||||
homeTeamName = homeTeamName.replace(/\([^)]*\)/g, '').trim();
|
||||
guestTeamName = guestTeamName.replace(/\([^)]*\)/g, '').trim();
|
||||
|
||||
|
||||
// Erkenne römische Ziffern am Ende der Team-Namen
|
||||
// Römische Ziffern: I, II, III, IV, V, VI, VII, VIII, IX, X, XI, XII, etc.
|
||||
const romanNumeralPattern = /\s+(I{1,3}|IV|V|VI{0,3}|IX|X|XI{0,2})$/;
|
||||
|
||||
// Prüfe Heimteam auf römische Ziffern
|
||||
const homeRomanMatch = homeTeamName.match(romanNumeralPattern);
|
||||
if (homeRomanMatch) {
|
||||
const romanNumeral = homeRomanMatch[1];
|
||||
const baseName = homeTeamName.replace(romanNumeralPattern, '').trim();
|
||||
homeTeamName = `${baseName} ${romanNumeral}`;
|
||||
}
|
||||
|
||||
// Prüfe Gastteam auf römische Ziffern
|
||||
const guestRomanMatch = guestTeamName.match(romanNumeralPattern);
|
||||
if (guestRomanMatch) {
|
||||
const romanNumeral = guestRomanMatch[1];
|
||||
const baseName = guestTeamName.replace(romanNumeralPattern, '').trim();
|
||||
guestTeamName = `${baseName} ${romanNumeral}`;
|
||||
}
|
||||
|
||||
} else {
|
||||
// Fallback: Versuche mit einzelnen Leerzeichen zu trennen
|
||||
|
||||
// Strategie 1: Suche nach "Harheimer TC" als Heimteam oder Gastteam
|
||||
if (teamsPart.includes('Harheimer TC')) {
|
||||
const harheimerIndex = teamsPart.indexOf('Harheimer TC');
|
||||
|
||||
// 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
|
||||
const teamSplitMatch = teamsPart.match(/^([A-Za-z0-9\s\-\.]+?)\s+([A-Z][A-Za-z0-9\s\-\.]+)$/);
|
||||
|
||||
if (teamSplitMatch) {
|
||||
homeTeamName = teamSplitMatch[1].trim();
|
||||
guestTeamName = teamSplitMatch[2].trim();
|
||||
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (homeTeamName && guestTeamName) {
|
||||
let debugInfo;
|
||||
if (code) {
|
||||
debugInfo = `code: "${code}"`;
|
||||
} else if (homePin && guestPin) {
|
||||
debugInfo = `homePin: "${homePin}", guestPin: "${guestPin}"`;
|
||||
} else if (homePin) {
|
||||
debugInfo = `homePin: "${homePin}"`;
|
||||
} else if (guestPin) {
|
||||
debugInfo = `guestPin: "${guestPin}"`;
|
||||
}
|
||||
|
||||
console.log(`[PDF Parser] Parsed match: ${homeTeamName} vs ${guestTeamName}, ${debugInfo}`);
|
||||
|
||||
matches.push({
|
||||
date: date,
|
||||
time: time,
|
||||
homeTeamName: homeTeamName,
|
||||
guestTeamName: guestTeamName,
|
||||
code: code,
|
||||
homePin: homePin,
|
||||
guestPin: guestPin,
|
||||
clubId: clubId,
|
||||
rawLine: line
|
||||
});
|
||||
}
|
||||
} else {
|
||||
}
|
||||
} catch (parseError) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { matches };
|
||||
}
|
||||
|
||||
/**
|
||||
* Tabellen-Format Parser
|
||||
* @param {Array} lines - Textzeilen
|
||||
* @param {number} clubId - ID des Vereins
|
||||
* @returns {Object} Geparste Matches
|
||||
*/
|
||||
static parseTableFormat(lines, clubId) {
|
||||
const matches = [];
|
||||
|
||||
// Suche nach Tabellen-Header
|
||||
let headerIndex = -1;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].toLowerCase().includes('datum') &&
|
||||
lines[i].toLowerCase().includes('zeit') &&
|
||||
lines[i].toLowerCase().includes('heim') &&
|
||||
lines[i].toLowerCase().includes('gast')) {
|
||||
headerIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (headerIndex >= 0) {
|
||||
// Parse Tabellen-Zeilen
|
||||
for (let i = headerIndex + 1; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const columns = line.split(/\s{2,}|\t/); // Split bei mehreren Leerzeichen oder Tabs
|
||||
|
||||
if (columns.length >= 4) {
|
||||
try {
|
||||
const dateStr = columns[0];
|
||||
const timeStr = columns[1];
|
||||
const homeTeam = columns[2];
|
||||
const guestTeam = columns[3];
|
||||
const code = columns[4] || null;
|
||||
const homePin = columns[5] || null;
|
||||
const guestPin = columns[6] || null;
|
||||
|
||||
// Parse Datum
|
||||
const dateMatch = dateStr.match(/(\d{1,2})[./](\d{1,2})[./](\d{4})/);
|
||||
if (dateMatch) {
|
||||
const [, day, month, year] = dateMatch;
|
||||
const date = new Date(`${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`);
|
||||
|
||||
matches.push({
|
||||
date: date,
|
||||
time: timeStr || null,
|
||||
homeTeamName: homeTeam.trim(),
|
||||
guestTeamName: guestTeam.trim(),
|
||||
code: code ? code.trim() : null,
|
||||
homePin: homePin ? homePin.trim() : null,
|
||||
guestPin: guestPin ? guestPin.trim() : null,
|
||||
clubId: clubId,
|
||||
rawLine: line
|
||||
});
|
||||
}
|
||||
} catch (parseError) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { matches };
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen-Format Parser
|
||||
* @param {Array} lines - Textzeilen
|
||||
* @param {number} clubId - ID des Vereins
|
||||
* @returns {Object} Geparste Matches
|
||||
*/
|
||||
static parseListFormat(lines, clubId) {
|
||||
const matches = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
// Suche nach Nummerierten Listen (1., 2., etc.)
|
||||
const listMatch = line.match(/^\d+\.\s*(.+)/);
|
||||
if (listMatch) {
|
||||
const content = listMatch[1];
|
||||
|
||||
// Versuche verschiedene Formate zu parsen
|
||||
const patterns = [
|
||||
/(\d{1,2}[./]\d{1,2}[./]\d{4})\s+(\d{1,2}:\d{2})?\s+(.+?)\s+vs?\s+(.+?)(?:\s+code[:\s]*([A-Za-z0-9]+))?(?:\s+home[:\s]*pin[:\s]*([A-Za-z0-9]+))?(?:\s+guest[:\s]*pin[:\s]*([A-Za-z0-9]+))?/i,
|
||||
/(\d{1,2}[./]\d{1,2}[./]\d{4})\s+(\d{1,2}:\d{2})?\s+(.+?)\s+-\s+(.+?)(?:\s+code[:\s]*([A-Za-z0-9]+))?(?:\s+heim[:\s]*pin[:\s]*([A-Za-z0-9]+))?(?:\s+gast[:\s]*pin[:\s]*([A-Za-z0-9]+))?/i
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = content.match(pattern);
|
||||
if (match) {
|
||||
try {
|
||||
const [, dateStr, timeStr, homeTeam, guestTeam, code, homePin, guestPin] = match;
|
||||
|
||||
// Parse Datum
|
||||
const dateMatch = dateStr.match(/(\d{1,2})[./](\d{1,2})[./](\d{4})/);
|
||||
if (dateMatch) {
|
||||
const [, day, month, year] = dateMatch;
|
||||
const date = new Date(`${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`);
|
||||
|
||||
matches.push({
|
||||
date: date,
|
||||
time: timeStr || null,
|
||||
homeTeamName: homeTeam.trim(),
|
||||
guestTeamName: guestTeam.trim(),
|
||||
code: code ? code.trim() : null,
|
||||
homePin: homePin ? homePin.trim() : null,
|
||||
guestPin: guestPin ? guestPin.trim() : null,
|
||||
clubId: clubId,
|
||||
rawLine: line
|
||||
});
|
||||
break; // Erste erfolgreiche Pattern verwenden
|
||||
}
|
||||
} catch (parseError) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { matches };
|
||||
}
|
||||
|
||||
/**
|
||||
* Speichert geparste Matches in der Datenbank
|
||||
* @param {Array} matches - Array von Match-Objekten
|
||||
* @param {number} leagueId - ID der Liga
|
||||
* @returns {Promise<Object>} Ergebnis der Speicherung
|
||||
*/
|
||||
static async saveMatchesToDatabase(matches, leagueId) {
|
||||
try {
|
||||
|
||||
const results = {
|
||||
created: 0,
|
||||
updated: 0,
|
||||
errors: []
|
||||
};
|
||||
|
||||
for (const matchData of matches) {
|
||||
try {
|
||||
let debugInfo;
|
||||
if (matchData.code) {
|
||||
debugInfo = `Code: ${matchData.code}`;
|
||||
} else if (matchData.homePin && matchData.guestPin) {
|
||||
debugInfo = `HomePin: ${matchData.homePin}, GuestPin: ${matchData.guestPin}`;
|
||||
} else if (matchData.homePin) {
|
||||
debugInfo = `HomePin: ${matchData.homePin}`;
|
||||
} else if (matchData.guestPin) {
|
||||
debugInfo = `GuestPin: ${matchData.guestPin}`;
|
||||
}
|
||||
|
||||
// Lade alle Matches für das Datum und die Liga
|
||||
|
||||
// Konvertiere das Datum zu einem Datum ohne Zeit für den Vergleich
|
||||
const dateOnly = new Date(matchData.date.getFullYear(), matchData.date.getMonth(), matchData.date.getDate());
|
||||
const nextDay = new Date(dateOnly);
|
||||
nextDay.setDate(nextDay.getDate() + 1);
|
||||
|
||||
const existingMatches = await Match.findAll({
|
||||
where: {
|
||||
date: {
|
||||
[Op.gte]: dateOnly, // Größer oder gleich dem Datum
|
||||
[Op.lt]: nextDay // Kleiner als der nächste Tag
|
||||
},
|
||||
leagueId: leagueId,
|
||||
...(matchData.time && { time: matchData.time }) // Füge Zeit hinzu wenn vorhanden
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: Team,
|
||||
as: 'homeTeam',
|
||||
attributes: ['id', 'name']
|
||||
},
|
||||
{
|
||||
model: Team,
|
||||
as: 'guestTeam',
|
||||
attributes: ['id', 'name']
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
const timeFilter = matchData.time ? ` and time ${matchData.time}` : '';
|
||||
|
||||
// Debug: Zeige alle gefundenen Matches und lade Teams manuell
|
||||
for (let i = 0; i < existingMatches.length; i++) {
|
||||
const match = existingMatches[i];
|
||||
|
||||
// Lade Teams manuell
|
||||
const homeTeam = await Team.findByPk(match.homeTeamId);
|
||||
const guestTeam = await Team.findByPk(match.guestTeamId);
|
||||
|
||||
|
||||
// Füge die Teams zum Match-Objekt hinzu
|
||||
match.homeTeam = homeTeam;
|
||||
match.guestTeam = guestTeam;
|
||||
}
|
||||
|
||||
// Suche nach dem passenden Match basierend auf Gastmannschaft
|
||||
const matchingMatch = existingMatches.find(match => {
|
||||
if (!match.guestTeam) return false;
|
||||
|
||||
const guestTeamName = match.guestTeam.name.toLowerCase();
|
||||
const searchGuestName = matchData.guestTeamName.toLowerCase();
|
||||
|
||||
// Exakte Übereinstimmung oder Teilstring-Match
|
||||
return guestTeamName === searchGuestName ||
|
||||
guestTeamName.includes(searchGuestName) ||
|
||||
searchGuestName.includes(guestTeamName);
|
||||
});
|
||||
|
||||
if (matchingMatch) {
|
||||
|
||||
// Update das bestehende Match mit Code und Pins
|
||||
// Erstelle Update-Objekt nur mit vorhandenen Feldern
|
||||
const updateData = {};
|
||||
if (matchData.code) {
|
||||
updateData.code = matchData.code;
|
||||
}
|
||||
if (matchData.homePin) {
|
||||
updateData.homePin = matchData.homePin;
|
||||
}
|
||||
if (matchData.guestPin) {
|
||||
updateData.guestPin = matchData.guestPin;
|
||||
}
|
||||
|
||||
await matchingMatch.update(updateData);
|
||||
results.updated++;
|
||||
|
||||
let updateInfo;
|
||||
if (matchData.code) {
|
||||
updateInfo = `code: ${matchData.code}`;
|
||||
} else if (matchData.homePin && matchData.guestPin) {
|
||||
updateInfo = `homePin: ${matchData.homePin}, guestPin: ${matchData.guestPin}`;
|
||||
} else if (matchData.homePin) {
|
||||
updateInfo = `homePin: ${matchData.homePin}`;
|
||||
} else if (matchData.guestPin) {
|
||||
updateInfo = `guestPin: ${matchData.guestPin}`;
|
||||
}
|
||||
|
||||
// Lade das aktualisierte Match neu, um die aktuellen Werte zu zeigen
|
||||
await matchingMatch.reload();
|
||||
const currentValues = [];
|
||||
if (matchingMatch.code) currentValues.push(`code: ${matchingMatch.code}`);
|
||||
if (matchingMatch.homePin) currentValues.push(`homePin: ${matchingMatch.homePin}`);
|
||||
if (matchingMatch.guestPin) currentValues.push(`guestPin: ${matchingMatch.guestPin}`);
|
||||
} else {
|
||||
|
||||
// Fallback: Versuche Teams direkt zu finden
|
||||
let homeTeam = await Team.findOne({
|
||||
where: {
|
||||
name: matchData.homeTeamName,
|
||||
clubId: matchData.clubId
|
||||
}
|
||||
});
|
||||
|
||||
let guestTeam = await Team.findOne({
|
||||
where: {
|
||||
name: matchData.guestTeamName,
|
||||
clubId: matchData.clubId
|
||||
}
|
||||
});
|
||||
|
||||
// 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(', ')}`);
|
||||
|
||||
// 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 (!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}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!homeTeam || !guestTeam) {
|
||||
let errorInfo;
|
||||
if (matchData.code) {
|
||||
errorInfo = `Code: ${matchData.code}`;
|
||||
} else if (matchData.homePin && matchData.guestPin) {
|
||||
errorInfo = `HomePin: ${matchData.homePin}, GuestPin: ${matchData.guestPin}`;
|
||||
} else if (matchData.homePin) {
|
||||
errorInfo = `HomePin: ${matchData.homePin}`;
|
||||
} else if (matchData.guestPin) {
|
||||
errorInfo = `GuestPin: ${matchData.guestPin}`;
|
||||
}
|
||||
results.errors.push(`Teams nicht gefunden: "${matchData.homeTeamName}" oder "${matchData.guestTeamName}" (Datum: ${matchData.date.toISOString().split('T')[0]}, Zeit: ${matchData.time}, ${errorInfo})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Erstelle neues Match (Fallback)
|
||||
await Match.create({
|
||||
date: matchData.date,
|
||||
time: matchData.time,
|
||||
homeTeamId: homeTeam.id,
|
||||
guestTeamId: guestTeam.id,
|
||||
leagueId: leagueId,
|
||||
clubId: matchData.clubId,
|
||||
code: matchData.code,
|
||||
homePin: matchData.homePin,
|
||||
guestPin: matchData.guestPin,
|
||||
locationId: 1 // Default Location, kann später angepasst werden
|
||||
});
|
||||
results.created++;
|
||||
}
|
||||
} catch (matchError) {
|
||||
console.error('[PDFParserService.saveMatchesToDatabase] - Error:', matchError);
|
||||
results.errors.push(`Fehler beim Speichern von Match: ${matchData.rawLine} - ${matchError.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error('[PDFParserService.saveMatchesToDatabase] - Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default PDFParserService;
|
||||
@@ -8,6 +8,7 @@ import { Op } from 'sequelize';
|
||||
import { devLog } from '../utils/logger.js';
|
||||
class PredefinedActivityService {
|
||||
async createPredefinedActivity(data) {
|
||||
devLog('[PredefinedActivityService::createPredefinedActivity] - Creating predefined activity');
|
||||
return await PredefinedActivity.create({
|
||||
name: data.name,
|
||||
code: data.code,
|
||||
@@ -20,8 +21,10 @@ class PredefinedActivityService {
|
||||
}
|
||||
|
||||
async updatePredefinedActivity(id, data) {
|
||||
devLog(`[PredefinedActivityService::updatePredefinedActivity] - Updating predefined activity with id: ${id}`);
|
||||
const activity = await PredefinedActivity.findByPk(id);
|
||||
if (!activity) {
|
||||
devLog('[PredefinedActivityService::updatePredefinedActivity] - Activity not found');
|
||||
throw new Error('Predefined activity not found');
|
||||
}
|
||||
return await activity.update({
|
||||
@@ -36,6 +39,7 @@ class PredefinedActivityService {
|
||||
}
|
||||
|
||||
async getAllPredefinedActivities() {
|
||||
devLog('[PredefinedActivityService::getAllPredefinedActivities] - Fetching all predefined activities');
|
||||
return await PredefinedActivity.findAll({
|
||||
order: [
|
||||
[sequelize.literal('code IS NULL'), 'ASC'], // Non-null codes first
|
||||
@@ -46,8 +50,10 @@ class PredefinedActivityService {
|
||||
}
|
||||
|
||||
async getPredefinedActivityById(id) {
|
||||
devLog(`[PredefinedActivityService::getPredefinedActivityById] - Fetching predefined activity with id: ${id}`);
|
||||
const activity = await PredefinedActivity.findByPk(id);
|
||||
if (!activity) {
|
||||
devLog('[PredefinedActivityService::getPredefinedActivityById] - Activity not found');
|
||||
throw new Error('Predefined activity not found');
|
||||
}
|
||||
return activity;
|
||||
@@ -75,6 +81,7 @@ class PredefinedActivityService {
|
||||
}
|
||||
|
||||
async mergeActivities(sourceId, targetId) {
|
||||
devLog(`[PredefinedActivityService::mergeActivities] - Merge ${sourceId} -> ${targetId}`);
|
||||
if (!sourceId || !targetId) throw new Error('sourceId and targetId are required');
|
||||
if (Number(sourceId) === Number(targetId)) throw new Error('sourceId and targetId must differ');
|
||||
|
||||
@@ -114,6 +121,7 @@ class PredefinedActivityService {
|
||||
}
|
||||
|
||||
async deduplicateActivities() {
|
||||
devLog('[PredefinedActivityService::deduplicateActivities] - Start');
|
||||
const all = await PredefinedActivity.findAll();
|
||||
const nameToActivities = new Map();
|
||||
for (const activity of all) {
|
||||
@@ -135,6 +143,7 @@ class PredefinedActivityService {
|
||||
mergedCount++;
|
||||
}
|
||||
}
|
||||
devLog('[PredefinedActivityService::deduplicateActivities] - Done', { mergedCount, groupCount });
|
||||
return { mergedCount, groupCount };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
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();
|
||||
@@ -1,149 +0,0 @@
|
||||
import Season from '../models/Season.js';
|
||||
import { devLog } from '../utils/logger.js';
|
||||
|
||||
class SeasonService {
|
||||
/**
|
||||
* Ermittelt die aktuelle Saison basierend auf dem aktuellen Datum
|
||||
* @returns {string} Saison im Format "2023/2024"
|
||||
*/
|
||||
static getCurrentSeasonString() {
|
||||
const now = new Date();
|
||||
const currentYear = now.getFullYear();
|
||||
const currentMonth = now.getMonth() + 1; // getMonth() ist 0-basiert
|
||||
|
||||
// Ab 1. Juli: neue Saison beginnt
|
||||
if (currentMonth >= 7) {
|
||||
return `${currentYear}/${currentYear + 1}`;
|
||||
} else {
|
||||
return `${currentYear - 1}/${currentYear}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt oder erstellt die aktuelle Saison
|
||||
* @returns {Promise<Season>} Die aktuelle Saison
|
||||
*/
|
||||
static async getOrCreateCurrentSeason() {
|
||||
try {
|
||||
const currentSeasonString = this.getCurrentSeasonString();
|
||||
|
||||
// Versuche die aktuelle Saison zu finden
|
||||
let season = await Season.findOne({
|
||||
where: { season: currentSeasonString }
|
||||
});
|
||||
|
||||
// Falls nicht vorhanden, erstelle sie
|
||||
if (!season) {
|
||||
season = await Season.create({
|
||||
season: currentSeasonString
|
||||
});
|
||||
}
|
||||
|
||||
return season;
|
||||
} catch (error) {
|
||||
console.error('[SeasonService.getOrCreateCurrentSeason] - Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt alle verfügbaren Saisons
|
||||
* @returns {Promise<Array<Season>>} Alle Saisons sortiert nach Name
|
||||
*/
|
||||
static async getAllSeasons() {
|
||||
try {
|
||||
const seasons = await Season.findAll({
|
||||
order: [['season', 'DESC']] // Neueste zuerst
|
||||
});
|
||||
return seasons;
|
||||
} catch (error) {
|
||||
console.error('[SeasonService.getAllSeasons] - Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt eine neue Saison
|
||||
* @param {string} seasonString - Saison im Format "2023/2024"
|
||||
* @returns {Promise<Season>} Die erstellte Saison
|
||||
*/
|
||||
static async createSeason(seasonString) {
|
||||
try {
|
||||
|
||||
// Prüfe ob Saison bereits existiert
|
||||
const existingSeason = await Season.findOne({
|
||||
where: { season: seasonString }
|
||||
});
|
||||
|
||||
if (existingSeason) {
|
||||
throw new Error('Season already exists');
|
||||
}
|
||||
|
||||
const season = await Season.create({
|
||||
season: seasonString
|
||||
});
|
||||
|
||||
return season;
|
||||
} catch (error) {
|
||||
console.error('[SeasonService.createSeason] - Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt eine Saison nach ID
|
||||
* @param {number} seasonId - Die Saison-ID
|
||||
* @returns {Promise<Season|null>} Die Saison oder null
|
||||
*/
|
||||
static async getSeasonById(seasonId) {
|
||||
try {
|
||||
const season = await Season.findByPk(seasonId);
|
||||
return season;
|
||||
} catch (error) {
|
||||
console.error('[SeasonService.getSeasonById] - Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Löscht eine Saison (nur wenn keine Teams/Ligen damit verknüpft sind)
|
||||
* @param {number} seasonId - Die Saison-ID
|
||||
* @returns {Promise<boolean>} True wenn gelöscht, false wenn nicht möglich
|
||||
*/
|
||||
static async deleteSeason(seasonId) {
|
||||
try {
|
||||
|
||||
// Prüfe ob Saison verwendet wird
|
||||
const season = await Season.findByPk(seasonId, {
|
||||
include: [
|
||||
{ association: 'teams' },
|
||||
{ association: 'leagues' }
|
||||
]
|
||||
});
|
||||
|
||||
if (!season) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Prüfe ob Saison verwendet wird
|
||||
if (season.teams && season.teams.length > 0) {
|
||||
throw new Error('Season is used by teams');
|
||||
}
|
||||
|
||||
if (season.leagues && season.leagues.length > 0) {
|
||||
throw new Error('Season is used by leagues');
|
||||
}
|
||||
|
||||
await Season.destroy({
|
||||
where: { id: seasonId }
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[SeasonService.deleteSeason] - Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default SeasonService;
|
||||
@@ -1,188 +0,0 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import TeamDocument from '../models/TeamDocument.js';
|
||||
import ClubTeam from '../models/ClubTeam.js';
|
||||
import { devLog } from '../utils/logger.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
class TeamDocumentService {
|
||||
/**
|
||||
* Speichert ein hochgeladenes Dokument für ein Club-Team
|
||||
* @param {Object} file - Das hochgeladene File-Objekt (von multer)
|
||||
* @param {number} clubTeamId - Die ID des Club-Teams
|
||||
* @param {string} documentType - Der Typ des Dokuments ('code_list' oder 'pin_list')
|
||||
* @returns {Promise<TeamDocument>} Das erstellte TeamDocument
|
||||
*/
|
||||
static async uploadDocument(file, clubTeamId, documentType) {
|
||||
try {
|
||||
// Prüfe ob das Club-Team existiert
|
||||
const clubTeam = await ClubTeam.findByPk(clubTeamId);
|
||||
if (!clubTeam) {
|
||||
throw new Error('Club-Team nicht gefunden');
|
||||
}
|
||||
|
||||
// Generiere einen eindeutigen Dateinamen
|
||||
const fileExtension = path.extname(file.originalname);
|
||||
const uniqueFileName = `${clubTeamId}_${documentType}_${Date.now()}${fileExtension}`;
|
||||
|
||||
// Zielverzeichnis für Team-Dokumente
|
||||
const uploadDir = path.join(__dirname, '..', 'uploads', 'team-documents');
|
||||
|
||||
// Erstelle Upload-Verzeichnis falls es nicht existiert
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
|
||||
const filePath = path.join(uploadDir, uniqueFileName);
|
||||
|
||||
// Verschiebe die Datei vom temporären Verzeichnis zum finalen Speicherort
|
||||
fs.renameSync(file.path, filePath);
|
||||
|
||||
// Lösche alte Dokumente des gleichen Typs für dieses Team
|
||||
await this.deleteDocumentsByType(clubTeamId, documentType);
|
||||
|
||||
// Erstelle Datenbankeintrag
|
||||
const teamDocument = await TeamDocument.create({
|
||||
fileName: uniqueFileName,
|
||||
originalFileName: file.originalname,
|
||||
filePath: filePath,
|
||||
fileSize: file.size,
|
||||
mimeType: file.mimetype,
|
||||
documentType: documentType,
|
||||
clubTeamId: clubTeamId
|
||||
});
|
||||
|
||||
return teamDocument;
|
||||
} catch (error) {
|
||||
console.error('[TeamDocumentService.uploadDocument] - Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt alle Dokumente für ein Club-Team
|
||||
* @param {number} clubTeamId - Die ID des Club-Teams
|
||||
* @returns {Promise<Array<TeamDocument>>} Liste der Dokumente
|
||||
*/
|
||||
static async getDocumentsByClubTeam(clubTeamId) {
|
||||
try {
|
||||
|
||||
const documents = await TeamDocument.findAll({
|
||||
where: { clubTeamId },
|
||||
order: [['createdAt', 'DESC']]
|
||||
});
|
||||
|
||||
return documents;
|
||||
} catch (error) {
|
||||
console.error('[TeamDocumentService.getDocumentsByClubTeam] - Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt ein spezifisches Dokument
|
||||
* @param {number} documentId - Die ID des Dokuments
|
||||
* @returns {Promise<TeamDocument|null>} Das Dokument oder null
|
||||
*/
|
||||
static async getDocumentById(documentId) {
|
||||
try {
|
||||
|
||||
const document = await TeamDocument.findByPk(documentId, {
|
||||
include: [{
|
||||
model: ClubTeam,
|
||||
as: 'clubTeam',
|
||||
attributes: ['id', 'name', 'clubId']
|
||||
}]
|
||||
});
|
||||
|
||||
return document;
|
||||
} catch (error) {
|
||||
console.error('[TeamDocumentService.getDocumentById] - Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Löscht ein Dokument
|
||||
* @param {number} documentId - Die ID des Dokuments
|
||||
* @returns {Promise<boolean>} True wenn gelöscht, sonst false
|
||||
*/
|
||||
static async deleteDocument(documentId) {
|
||||
try {
|
||||
|
||||
const document = await TeamDocument.findByPk(documentId);
|
||||
if (!document) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Lösche die physische Datei
|
||||
if (fs.existsSync(document.filePath)) {
|
||||
fs.unlinkSync(document.filePath);
|
||||
}
|
||||
|
||||
// Lösche den Datenbankeintrag
|
||||
const deletedRows = await TeamDocument.destroy({
|
||||
where: { id: documentId }
|
||||
});
|
||||
|
||||
return deletedRows > 0;
|
||||
} catch (error) {
|
||||
console.error('[TeamDocumentService.deleteDocument] - Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Löscht alle Dokumente eines bestimmten Typs für ein Club-Team
|
||||
* @param {number} clubTeamId - Die ID des Club-Teams
|
||||
* @param {string} documentType - Der Typ des Dokuments
|
||||
* @returns {Promise<number>} Anzahl der gelöschten Dokumente
|
||||
*/
|
||||
static async deleteDocumentsByType(clubTeamId, documentType) {
|
||||
try {
|
||||
|
||||
const documents = await TeamDocument.findAll({
|
||||
where: { clubTeamId, documentType }
|
||||
});
|
||||
|
||||
let deletedCount = 0;
|
||||
for (const document of documents) {
|
||||
// Lösche die physische Datei
|
||||
if (fs.existsSync(document.filePath)) {
|
||||
fs.unlinkSync(document.filePath);
|
||||
}
|
||||
deletedCount++;
|
||||
}
|
||||
|
||||
// Lösche die Datenbankeinträge
|
||||
const deletedRows = await TeamDocument.destroy({
|
||||
where: { clubTeamId, documentType }
|
||||
});
|
||||
|
||||
return deletedRows;
|
||||
} catch (error) {
|
||||
console.error('[TeamDocumentService.deleteDocumentsByType] - Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt den Dateipfad für ein Dokument
|
||||
* @param {number} documentId - Die ID des Dokuments
|
||||
* @returns {Promise<string|null>} Der Dateipfad oder null
|
||||
*/
|
||||
static async getDocumentPath(documentId) {
|
||||
try {
|
||||
const document = await TeamDocument.findByPk(documentId);
|
||||
return document ? document.filePath : null;
|
||||
} catch (error) {
|
||||
console.error('[TeamDocumentService.getDocumentPath] - Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default TeamDocumentService;
|
||||
@@ -1,132 +0,0 @@
|
||||
import Team from '../models/Team.js';
|
||||
import League from '../models/League.js';
|
||||
import Club from '../models/Club.js';
|
||||
import Season from '../models/Season.js';
|
||||
import SeasonService from './seasonService.js';
|
||||
import { devLog } from '../utils/logger.js';
|
||||
|
||||
class TeamService {
|
||||
static async getAllTeamsByClub(clubId, seasonId = null) {
|
||||
try {
|
||||
|
||||
// Wenn keine Saison angegeben, verwende die aktuelle
|
||||
if (!seasonId) {
|
||||
const currentSeason = await SeasonService.getOrCreateCurrentSeason();
|
||||
seasonId = currentSeason.id;
|
||||
}
|
||||
|
||||
const teams = await Team.findAll({
|
||||
where: { clubId, seasonId },
|
||||
include: [
|
||||
{
|
||||
model: League,
|
||||
as: 'league',
|
||||
attributes: ['id', 'name']
|
||||
},
|
||||
{
|
||||
model: Season,
|
||||
as: 'season',
|
||||
attributes: ['id', 'season']
|
||||
}
|
||||
],
|
||||
order: [['name', 'ASC']]
|
||||
});
|
||||
return teams;
|
||||
} catch (error) {
|
||||
console.error('[TeamService.getAllTeamsByClub] - Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async getTeamById(teamId) {
|
||||
try {
|
||||
const team = await Team.findByPk(teamId, {
|
||||
include: [
|
||||
{
|
||||
model: League,
|
||||
as: 'league',
|
||||
attributes: ['id', 'name']
|
||||
},
|
||||
{
|
||||
model: Club,
|
||||
as: 'club',
|
||||
attributes: ['id', 'name']
|
||||
},
|
||||
{
|
||||
model: Season,
|
||||
as: 'season',
|
||||
attributes: ['id', 'season']
|
||||
}
|
||||
]
|
||||
});
|
||||
return team;
|
||||
} catch (error) {
|
||||
console.error('[TeamService.getTeamById] - Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async createTeam(teamData) {
|
||||
try {
|
||||
|
||||
// Wenn keine Saison angegeben, verwende die aktuelle
|
||||
if (!teamData.seasonId) {
|
||||
const currentSeason = await SeasonService.getOrCreateCurrentSeason();
|
||||
teamData.seasonId = currentSeason.id;
|
||||
}
|
||||
|
||||
const team = await Team.create(teamData);
|
||||
return team;
|
||||
} catch (error) {
|
||||
console.error('[TeamService.createTeam] - Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async updateTeam(teamId, updateData) {
|
||||
try {
|
||||
const [updatedRowsCount] = await Team.update(updateData, {
|
||||
where: { id: teamId }
|
||||
});
|
||||
return updatedRowsCount > 0;
|
||||
} catch (error) {
|
||||
console.error('[TeamService.updateTeam] - Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async deleteTeam(teamId) {
|
||||
try {
|
||||
const deletedRowsCount = await Team.destroy({
|
||||
where: { id: teamId }
|
||||
});
|
||||
return deletedRowsCount > 0;
|
||||
} catch (error) {
|
||||
console.error('[TeamService.deleteTeam] - Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async getLeaguesByClub(clubId, seasonId = null) {
|
||||
try {
|
||||
|
||||
// Wenn keine Saison angegeben, verwende die aktuelle
|
||||
if (!seasonId) {
|
||||
const currentSeason = await SeasonService.getOrCreateCurrentSeason();
|
||||
seasonId = currentSeason.id;
|
||||
}
|
||||
|
||||
const leagues = await League.findAll({
|
||||
where: { clubId, seasonId },
|
||||
attributes: ['id', 'name', 'seasonId'],
|
||||
order: [['name', 'ASC']]
|
||||
});
|
||||
return leagues;
|
||||
} catch (error) {
|
||||
console.error('[TeamService.getLeaguesByClub] - Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default TeamService;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user