diff --git a/.cursor/commands/oldfkchecks.md b/.cursor/commands/oldfkchecks.md new file mode 100644 index 0000000..e69de29 diff --git a/backend/MYTISCHTENNIS_AUTO_FETCH_README.md b/backend/MYTISCHTENNIS_AUTO_FETCH_README.md new file mode 100644 index 0000000..bf68d3c --- /dev/null +++ b/backend/MYTISCHTENNIS_AUTO_FETCH_README.md @@ -0,0 +1,212 @@ +# MyTischtennis Automatischer Datenabruf + +## Übersicht + +Dieses System ermöglicht den automatischen Abruf von Spielergebnissen und Statistiken von myTischtennis.de. + +## Scheduler + +### 6:00 Uhr - Rating Updates +- **Service:** `autoUpdateRatingsService.js` +- **Funktion:** Aktualisiert TTR/QTTR-Werte für Spieler +- **TODO:** Implementierung der eigentlichen Rating-Update-Logik + +### 6:30 Uhr - Spielergebnisse +- **Service:** `autoFetchMatchResultsService.js` +- **Funktion:** Ruft Spielerbilanzen für konfigurierte Teams ab +- **Status:** ✅ Grundlegende Implementierung fertig + +## Benötigte Konfiguration + +### 1. MyTischtennis-Account +- Account muss in den MyTischtennis-Settings verknüpft sein +- Checkbox "Automatische Updates" aktivieren +- Passwort speichern (erforderlich für automatische Re-Authentifizierung) + +### 2. League-Konfiguration + +Für jede Liga müssen folgende Felder ausgefüllt werden: + +```sql +UPDATE league SET + my_tischtennis_group_id = '504417', -- Group ID von myTischtennis + association = 'HeTTV', -- Verband (z.B. HeTTV, DTTB) + groupname = '1.Kreisklasse' -- Gruppenname für URL +WHERE id = 1; +``` + +**Beispiel-URL:** +``` +https://www.mytischtennis.de/click-tt/HeTTV/25--26/ligen/1.Kreisklasse/gruppe/504417/... + ^^^^^ ^^^^^^^^^^^^^^ ^^^^^^ + association groupname group_id +``` + +### 3. Team-Konfiguration + +Für jedes Team muss die myTischtennis Team-ID gesetzt werden: + +```sql +UPDATE club_team SET + my_tischtennis_team_id = '2995094' -- Team ID von myTischtennis +WHERE id = 1; +``` + +**Beispiel-URL:** +``` +.../mannschaft/2995094/Harheimer_TC_(J11)/spielerbilanzen/gesamt + ^^^^^^^ + team_id +``` + +### 4. Spieler-Zuordnung (Optional) + +Spieler werden automatisch anhand des Namens zugeordnet. Für genauere Zuordnung kann die myTischtennis Player-ID gesetzt werden: + +```sql +UPDATE member SET + my_tischtennis_player_id = 'NU2705037' -- Player ID von myTischtennis +WHERE id = 1; +``` + +## Migrationen + +Folgende Migrationen müssen ausgeführt werden: + +```bash +# 1. MyTischtennis Auto-Update-Felder +mysql -u root -p trainingstagebuch < backend/migrations/add_auto_update_ratings_to_my_tischtennis.sql + +# 2. MyTischtennis Update-History-Tabelle +mysql -u root -p trainingstagebuch < backend/migrations/create_my_tischtennis_update_history.sql + +# 3. League MyTischtennis-Felder +mysql -u root -p trainingstagebuch < backend/migrations/add_mytischtennis_fields_to_league.sql + +# 4. Team MyTischtennis-ID +mysql -u root -p trainingstagebuch < backend/migrations/add_mytischtennis_team_id_to_club_team.sql + +# 5. Member MyTischtennis Player-ID +mysql -u root -p trainingstagebuch < backend/migrations/add_mytischtennis_player_id_to_member.sql + +# 6. Match Result-Felder +mysql -u root -p trainingstagebuch < backend/migrations/add_match_result_fields.sql +``` + +## Abgerufene Daten + +Von der myTischtennis API werden folgende Daten abgerufen: + +### Einzelstatistiken +- Player ID, Vorname, Nachname +- Gewonnene/Verlorene Punkte +- Anzahl Spiele +- Detaillierte Statistiken nach Gegner-Position + +### Doppelstatistiken +- Player IDs, Namen der beiden Spieler +- Gewonnene/Verlorene Punkte +- Anzahl Spiele + +### Team-Informationen +- Teamname, Liga, Saison +- Gesamtpunkte (gewonnen/verloren) +- Doppel- und Einzelpunkte + +## Implementierungsdetails + +### Datenfluss + +1. **Scheduler** (6:30 Uhr): + - `schedulerService.js` triggert `autoFetchMatchResultsService.executeAutomaticFetch()` + +2. **Account-Verarbeitung**: + - Lädt alle MyTischtennis-Accounts mit `autoUpdateRatings = true` + - Prüft Session-Gültigkeit + - Re-Authentifizierung bei abgelaufener Session + +3. **Team-Abfrage**: + - Lädt alle Teams mit konfigurierten myTischtennis-IDs + - Baut API-URL dynamisch zusammen + - Führt authentifizierten GET-Request durch + +4. **Datenverarbeitung**: + - Parst JSON-Response + - Matched Spieler anhand von ID oder Name + - Speichert myTischtennis Player-ID bei Mitgliedern + - Loggt Statistiken + +### Player-Matching-Algorithmus + +```javascript +1. Suche nach myTischtennis Player-ID (exakte Übereinstimmung) +2. Falls nicht gefunden: Suche nach Name (case-insensitive) +3. Falls gefunden: Speichere myTischtennis Player-ID für zukünftige Abfragen +``` + +**Hinweis:** Da Namen verschlüsselt gespeichert werden, müssen für den Namens-Abgleich alle Members geladen und entschlüsselt werden. Dies ist bei großen Datenbanken ineffizient. + +## TODO / Offene Punkte + +### Noch zu implementieren: + +1. **TTR/QTTR Updates** (6:00 Uhr Job): + - Endpoint für TTR/QTTR-Daten identifizieren + - Daten abrufen und in Member-Tabelle speichern + +2. **Spielergebnis-Details**: + - Einzelne Matches mit Satzständen speichern + - Tabelle für Match-Historie erstellen + +3. **History-Tabelle für Spielergebnis-Abrufe** (optional): + - Ähnlich zu `my_tischtennis_update_history` + - Speichert Erfolg/Fehler der Abrufe + +4. **Benachrichtigungen** (optional): + - Email/Push bei neuen Ergebnissen + - Highlights für besondere Siege + +5. **Performance-Optimierung**: + - Caching für Player-Matches + - Incremental Updates (nur neue Daten) + +## Manueller Test + +```javascript +// Im Node-Backend-Code oder über API-Endpoint: +import schedulerService from './services/schedulerService.js'; + +// Rating Updates manuell triggern +await schedulerService.triggerRatingUpdates(); + +// Spielergebnisse manuell abrufen +await schedulerService.triggerMatchResultsFetch(); +``` + +## API-Dokumentation + +### MyTischtennis Spielerbilanzen-Endpoint + +**URL-Format:** +``` +https://www.mytischtennis.de/click-tt/{association}/{season}/ligen/{groupname}/gruppe/{groupId}/mannschaft/{teamId}/{teamname}/spielerbilanzen/gesamt?_data=routes%2Fclick-tt%2B%2F%24association%2B%2F%24season%2B%2F%24type%2B%2F%28%24groupname%29.gruppe.%24urlid_.mannschaft.%24teamid.%24teamname%2B%2Fspielerbilanzen.%24filter +``` + +**Parameter:** +- `{association}`: Verband (z.B. "HeTTV") +- `{season}`: Saison im Format "25--26" +- `{groupname}`: Gruppenname URL-encoded (z.B. "1.Kreisklasse") +- `{groupId}`: Gruppen-ID (numerisch, z.B. "504417") +- `{teamId}`: Team-ID (numerisch, z.B. "2995094") +- `{teamname}`: Teamname URL-encoded mit Underscores (z.B. "Harheimer_TC_(J11)") + +**Response:** JSON mit `data.balancesheet` Array + +## Sicherheit + +- ✅ Automatische Session-Verwaltung +- ✅ Re-Authentifizierung bei abgelaufenen Sessions +- ✅ Passwörter verschlüsselt gespeichert +- ✅ Fehlerbehandlung und Logging +- ✅ Graceful Degradation (einzelne Team-Fehler stoppen nicht den gesamten Prozess) + diff --git a/backend/MYTISCHTENNIS_URL_PARSER_README.md b/backend/MYTISCHTENNIS_URL_PARSER_README.md new file mode 100644 index 0000000..a54eff9 --- /dev/null +++ b/backend/MYTISCHTENNIS_URL_PARSER_README.md @@ -0,0 +1,328 @@ +# MyTischtennis URL Parser + +## Übersicht + +Der URL-Parser ermöglicht es, myTischtennis-Team-URLs automatisch zu parsen und die Konfiguration für automatische Datenabrufe vorzunehmen. + +## Verwendung + +### 1. URL Parsen + +**Endpoint:** `POST /api/mytischtennis/parse-url` + +**Request:** +```json +{ + "url": "https://www.mytischtennis.de/click-tt/HeTTV/25--26/ligen/1.Kreisklasse/gruppe/504417/mannschaft/2995094/Harheimer_TC_(J11)/spielerbilanzen/gesamt" +} +``` + +**Response:** +```json +{ + "success": true, + "data": { + "association": "HeTTV", + "season": "25/26", + "type": "ligen", + "groupname": "1.Kreisklasse", + "groupId": "504417", + "teamId": "2995094", + "teamname": "Harheimer TC (J11)", + "originalUrl": "https://www.mytischtennis.de/click-tt/...", + "clubId": "43030", + "clubName": "Harheimer TC", + "teamName": "Jugend 11", + "leagueName": "Jugend 13 1. Kreisklasse", + "region": "Frankfurt", + "tableRank": 8, + "matchesWon": 0, + "matchesLost": 3 + } +} +``` + +### 2. Team Automatisch Konfigurieren + +**Endpoint:** `POST /api/mytischtennis/configure-team` + +**Request:** +```json +{ + "url": "https://www.mytischtennis.de/click-tt/HeTTV/25--26/ligen/1.Kreisklasse/gruppe/504417/mannschaft/2995094/Harheimer_TC_(J11)/spielerbilanzen/gesamt", + "clubTeamId": 1, + "createLeague": false, + "createSeason": false +} +``` + +**Parameter:** +- `url` (required): Die myTischtennis-URL +- `clubTeamId` (required): Die ID des lokalen Club-Teams +- `createLeague` (optional): Wenn `true`, wird eine neue League erstellt +- `createSeason` (optional): Wenn `true`, wird eine neue Season erstellt + +**Response:** +```json +{ + "success": true, + "message": "Team configured successfully", + "data": { + "team": { + "id": 1, + "name": "Jugend 11", + "myTischtennisTeamId": "2995094" + }, + "league": { + "id": 5, + "name": "Jugend 13 1. Kreisklasse", + "myTischtennisGroupId": "504417", + "association": "HeTTV", + "groupname": "1.Kreisklasse" + }, + "season": { + "id": 2, + "name": "25/26" + }, + "parsedData": { ... } + } +} +``` + +### 3. URL für Team Abrufen + +**Endpoint:** `GET /api/mytischtennis/team-url/:teamId` + +**Response:** +```json +{ + "success": true, + "url": "https://www.mytischtennis.de/click-tt/HeTTV/25--26/ligen/1.Kreisklasse/gruppe/504417/mannschaft/2995094/Harheimer%20TC%20%28J11%29/spielerbilanzen/gesamt" +} +``` + +## URL-Format + +### Unterstützte URL-Muster + +``` +https://www.mytischtennis.de/click-tt/{association}/{season}/{type}/{groupname}/gruppe/{groupId}/mannschaft/{teamId}/{teamname}/... +``` + +**Komponenten:** +- `{association}`: Verband (z.B. "HeTTV", "DTTB", "WestD") +- `{season}`: Saison im Format "YY--YY" (z.B. "25--26" für 2025/2026) +- `{type}`: Typ (meist "ligen") +- `{groupname}`: Gruppenname URL-encoded (z.B. "1.Kreisklasse", "Kreisliga") +- `{groupId}`: Numerische Gruppen-ID (z.B. "504417") +- `{teamId}`: Numerische Team-ID (z.B. "2995094") +- `{teamname}`: Teamname URL-encoded mit Underscores (z.B. "Harheimer_TC_(J11)") + +### Beispiel-URLs + +**Spielerbilanzen:** +``` +https://www.mytischtennis.de/click-tt/HeTTV/25--26/ligen/1.Kreisklasse/gruppe/504417/mannschaft/2995094/Harheimer_TC_(J11)/spielerbilanzen/gesamt +``` + +**Spielplan:** +``` +https://www.mytischtennis.de/click-tt/HeTTV/25--26/ligen/1.Kreisklasse/gruppe/504417/mannschaft/2995094/Harheimer_TC_(J11)/spielplan +``` + +**Tabelle:** +``` +https://www.mytischtennis.de/click-tt/HeTTV/25--26/ligen/1.Kreisklasse/gruppe/504417/mannschaft/2995094/Harheimer_TC_(J11)/tabelle +``` + +## Datenfluss + +### Ohne MyTischtennis-Login + +1. URL wird geparst +2. Nur URL-Komponenten werden extrahiert +3. Zusätzliche Daten (clubName, leagueName, etc.) sind nicht verfügbar + +### Mit MyTischtennis-Login + +1. URL wird geparst +2. API-Request an myTischtennis mit Authentication +3. Vollständige Team-Daten werden abgerufen +4. Alle Felder sind verfügbar + +## Frontend-Integration + +### Vue.js Beispiel + +```javascript + + + +``` + +## 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 + diff --git a/backend/controllers/myTischtennisUrlController.js b/backend/controllers/myTischtennisUrlController.js new file mode 100644 index 0000000..3b97b2e --- /dev/null +++ b/backend/controllers/myTischtennisUrlController.js @@ -0,0 +1,394 @@ +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 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 + ); + + res.json({ + success: true, + message: `${result.fetchedCount} Datensätze abgerufen und verarbeitet`, + data: { + fetchedCount: result.fetchedCount, + teamName: team.name + } + }); + } 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(); diff --git a/backend/migrations/add_match_result_fields.sql b/backend/migrations/add_match_result_fields.sql new file mode 100644 index 0000000..1671acd --- /dev/null +++ b/backend/migrations/add_match_result_fields.sql @@ -0,0 +1,28 @@ +-- Migration: Add match result fields to match table +-- Date: 2025-01-27 +-- For MariaDB + +-- Add myTischtennis meeting ID +ALTER TABLE `match` +ADD COLUMN my_tischtennis_meeting_id VARCHAR(255) NULL UNIQUE COMMENT 'Meeting ID from myTischtennis (e.g. 15440488)'; + +-- Add home match points +ALTER TABLE `match` +ADD COLUMN home_match_points INT DEFAULT 0 NULL COMMENT 'Match points won by home team'; + +-- Add guest match points +ALTER TABLE `match` +ADD COLUMN guest_match_points INT DEFAULT 0 NULL COMMENT 'Match points won by guest team'; + +-- Add is_completed flag +ALTER TABLE `match` +ADD COLUMN is_completed BOOLEAN NOT NULL DEFAULT FALSE COMMENT 'Whether the match is completed'; + +-- Add PDF URL +ALTER TABLE `match` +ADD COLUMN pdf_url VARCHAR(512) NULL COMMENT 'PDF URL from myTischtennis'; + +-- Create indexes +CREATE INDEX idx_match_my_tischtennis_meeting_id ON `match`(my_tischtennis_meeting_id); +CREATE INDEX idx_match_is_completed ON `match`(is_completed); + diff --git a/backend/migrations/add_mytischtennis_fields_to_league.sql b/backend/migrations/add_mytischtennis_fields_to_league.sql new file mode 100644 index 0000000..949fbb2 --- /dev/null +++ b/backend/migrations/add_mytischtennis_fields_to_league.sql @@ -0,0 +1,19 @@ +-- Migration: Add myTischtennis fields to league table +-- Date: 2025-01-27 +-- For MariaDB + +-- Add my_tischtennis_group_id column +ALTER TABLE league +ADD COLUMN my_tischtennis_group_id VARCHAR(255) NULL COMMENT 'Group ID from myTischtennis (e.g. 504417)'; + +-- Add association column +ALTER TABLE league +ADD COLUMN association VARCHAR(255) NULL COMMENT 'Association/Verband (e.g. HeTTV)'; + +-- Add groupname column +ALTER TABLE league +ADD COLUMN groupname VARCHAR(255) NULL COMMENT 'Group name for URL (e.g. 1.Kreisklasse)'; + +-- Create index for efficient querying +CREATE INDEX idx_league_my_tischtennis_group_id ON league(my_tischtennis_group_id); + diff --git a/backend/migrations/add_mytischtennis_player_id_to_member.sql b/backend/migrations/add_mytischtennis_player_id_to_member.sql new file mode 100644 index 0000000..35bf42d --- /dev/null +++ b/backend/migrations/add_mytischtennis_player_id_to_member.sql @@ -0,0 +1,11 @@ +-- Migration: Add myTischtennis player ID to member table +-- Date: 2025-01-27 +-- For MariaDB + +-- Add my_tischtennis_player_id column +ALTER TABLE member +ADD COLUMN my_tischtennis_player_id VARCHAR(255) NULL COMMENT 'Player ID from myTischtennis (e.g. NU2705037)'; + +-- Create index for efficient querying +CREATE INDEX idx_member_my_tischtennis_player_id ON member(my_tischtennis_player_id); + diff --git a/backend/migrations/add_mytischtennis_team_id_to_club_team.sql b/backend/migrations/add_mytischtennis_team_id_to_club_team.sql new file mode 100644 index 0000000..56812e6 --- /dev/null +++ b/backend/migrations/add_mytischtennis_team_id_to_club_team.sql @@ -0,0 +1,11 @@ +-- Migration: Add myTischtennis team ID to club_team table +-- Date: 2025-01-27 +-- For MariaDB + +-- Add my_tischtennis_team_id column +ALTER TABLE club_team +ADD COLUMN my_tischtennis_team_id VARCHAR(255) NULL COMMENT 'Team ID from myTischtennis (e.g. 2995094)'; + +-- Create index for efficient querying +CREATE INDEX idx_club_team_my_tischtennis_team_id ON club_team(my_tischtennis_team_id); + diff --git a/backend/migrations/make_location_optional_in_match.sql b/backend/migrations/make_location_optional_in_match.sql new file mode 100644 index 0000000..cb7e62a --- /dev/null +++ b/backend/migrations/make_location_optional_in_match.sql @@ -0,0 +1,8 @@ +-- Migration: Make locationId optional in match table +-- Date: 2025-01-27 +-- For MariaDB + +-- Modify locationId to allow NULL +ALTER TABLE `match` +MODIFY COLUMN location_id INT NULL; + diff --git a/backend/models/ClubTeam.js b/backend/models/ClubTeam.js index fb84cc1..412268e 100644 --- a/backend/models/ClubTeam.js +++ b/backend/models/ClubTeam.js @@ -45,6 +45,12 @@ const ClubTeam = sequelize.define('ClubTeam', { onDelete: 'CASCADE', onUpdate: 'CASCADE', }, + myTischtennisTeamId: { + type: DataTypes.STRING, + allowNull: true, + comment: 'Team ID from myTischtennis (e.g. 2995094)', + field: 'my_tischtennis_team_id' + }, }, { underscored: true, tableName: 'club_team', diff --git a/backend/models/League.js b/backend/models/League.js index 12c8ac7..54b0e6b 100644 --- a/backend/models/League.js +++ b/backend/models/League.js @@ -34,6 +34,22 @@ const League = sequelize.define('League', { onDelete: 'CASCADE', onUpdate: 'CASCADE', }, + myTischtennisGroupId: { + type: DataTypes.STRING, + allowNull: true, + comment: 'Group ID from myTischtennis (e.g. 504417)', + field: 'my_tischtennis_group_id' + }, + association: { + type: DataTypes.STRING, + allowNull: true, + comment: 'Association/Verband (e.g. HeTTV)', + }, + groupname: { + type: DataTypes.STRING, + allowNull: true, + comment: 'Group name for URL (e.g. 1.Kreisklasse)', + }, }, { underscored: true, tableName: 'league', diff --git a/backend/models/Match.js b/backend/models/Match.js index ee385e1..b05d57b 100644 --- a/backend/models/Match.js +++ b/backend/models/Match.js @@ -26,7 +26,7 @@ const Match = sequelize.define('Match', { model: Location, key: 'id', }, - allowNull: false, + allowNull: true, }, homeTeamId: { type: DataTypes.INTEGER, @@ -75,6 +75,40 @@ const Match = sequelize.define('Match', { allowNull: true, comment: 'Pin-Code für Gastteam aus PDF-Parsing' }, + myTischtennisMeetingId: { + type: DataTypes.STRING, + allowNull: true, + unique: true, + comment: 'Meeting ID from myTischtennis (e.g. 15440488)', + field: 'my_tischtennis_meeting_id' + }, + homeMatchPoints: { + type: DataTypes.INTEGER, + allowNull: true, + defaultValue: 0, + comment: 'Match points won by home team', + field: 'home_match_points' + }, + guestMatchPoints: { + type: DataTypes.INTEGER, + allowNull: true, + defaultValue: 0, + comment: 'Match points won by guest team', + field: 'guest_match_points' + }, + isCompleted: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + comment: 'Whether the match is completed', + field: 'is_completed' + }, + pdfUrl: { + type: DataTypes.STRING, + allowNull: true, + comment: 'PDF URL from myTischtennis', + field: 'pdf_url' + }, }, { underscored: true, tableName: 'match', diff --git a/backend/models/Member.js b/backend/models/Member.js index a415712..7ca45ba 100644 --- a/backend/models/Member.js +++ b/backend/models/Member.js @@ -137,6 +137,12 @@ const Member = sequelize.define('Member', { type: DataTypes.INTEGER, allowNull: true, defaultValue: null + }, + myTischtennisPlayerId: { + type: DataTypes.STRING, + allowNull: true, + comment: 'Player ID from myTischtennis (e.g. NU2705037)', + field: 'my_tischtennis_player_id' } }, { underscored: true, diff --git a/backend/models/index.js b/backend/models/index.js index 86ec207..b82ca12 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -121,6 +121,9 @@ Team.belongsTo(Club, { foreignKey: 'clubId', as: 'club' }); Club.hasMany(League, { foreignKey: 'clubId', as: 'leagues' }); League.belongsTo(Club, { foreignKey: 'clubId', as: 'club' }); +Season.hasMany(League, { foreignKey: 'seasonId', as: 'leagues' }); +League.belongsTo(Season, { foreignKey: 'seasonId', as: 'season' }); + League.hasMany(Team, { foreignKey: 'leagueId', as: 'teams' }); Team.belongsTo(League, { foreignKey: 'leagueId', as: 'league' }); diff --git a/backend/package.json b/backend/package.json index 9a6c9fa..2ea54d8 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,7 +1,7 @@ { "name": "backend", "version": "1.0.0", - "main": "index.js", + "main": "server.js", "type": "module", "scripts": { "postinstall": "cd ../frontend && npm install && npm run build", diff --git a/backend/routes/myTischtennisRoutes.js b/backend/routes/myTischtennisRoutes.js index 1d02f3b..226bb5c 100644 --- a/backend/routes/myTischtennisRoutes.js +++ b/backend/routes/myTischtennisRoutes.js @@ -1,5 +1,6 @@ import express from 'express'; import myTischtennisController from '../controllers/myTischtennisController.js'; +import myTischtennisUrlController from '../controllers/myTischtennisUrlController.js'; import { authenticate } from '../middleware/authMiddleware.js'; const router = express.Router(); @@ -28,5 +29,17 @@ router.get('/session', myTischtennisController.getSession); // GET /api/mytischtennis/update-history - Get update ratings history router.get('/update-history', myTischtennisController.getUpdateHistory); +// 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; diff --git a/backend/server.js b/backend/server.js index ac74e26..905d702 100644 --- a/backend/server.js +++ b/backend/server.js @@ -195,7 +195,9 @@ app.get('*', (req, res) => { app.listen(port, () => { console.log(`Server is running on http://localhost:${port}`); - console.log('Scheduler service started - Rating updates scheduled for 6:00 AM daily'); + 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); diff --git a/backend/server.log b/backend/server.log new file mode 100644 index 0000000..f2bf87f --- /dev/null +++ b/backend/server.log @@ -0,0 +1,1778 @@ + +> backend@1.0.0 dev +> nodemon server.js + +[nodemon] 3.1.4 +[nodemon] to restart at any time, enter `rs` +[nodemon] watching path(s): *.* +[nodemon] watching extensions: js,mjs,cjs,json +[nodemon] starting `node server.js` +Starting scheduler service... +Scheduler service started successfully +Rating updates scheduled for 6:00 AM daily (Europe/Berlin timezone) +Match results fetch scheduled for 6:30 AM daily (Europe/Berlin timezone) +Server is running on http://localhost:3000 +Scheduler service started: + - Rating updates: 6:00 AM daily + - Match results fetch: 6:30 AM daily +Fetching session for userId: 1 (from header: tsschulz@gmx.net ) +Session found: true +Using session: { + email: 'tsschulz@gmx.net', + hasCookie: true, + hasAccessToken: true, + expiresAt: 2025-10-14T18:58:15.000Z +} +Team data: { + id: 1, + name: 'J11', + myTischtennisTeamId: '2995094', + hasLeague: true, + leagueData: { + id: 5, + name: '1.Kreisklasse', + myTischtennisGroupId: '504417', + association: 'HeTTV', + groupname: '1.Kreisklasse', + hasSeason: true + } +} +=== FETCH TEAM RESULTS === +Team name (from ClubTeam): J11 +Team name encoded: J11 +MyTischtennis Team ID: 2995094 +Fetching player stats from: https://www.mytischtennis.de/click-tt/HeTTV/25--26/ligen/1.Kreisklasse/gruppe/504417/mannschaft/2995094/J11/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 +=== PLAYER STATS RESPONSE START === +{ + "data": { + "head_infos": { + "region": "Frankfurt", + "season": "25/26", + "club_name": "Harheimer TC", + "team_name": "Jugend 11", + "league_name": "Jugend 13 1. Kreisklasse ", + "club_image_url": null, + "team_table_rank": 8, + "team_matches_won": 0, + "team_matches_lost": 3, + "organization_short": "HeTTV" + }, + "balancesheet": [ + { + "club_id": "43030", + "team_id": "2995094", + "group_id": "504417", + "club_name": "Harheimer TC", + "team_name": "Jugend 11", + "league_name": "Jugend 13 1. Kreisklasse ", + "team_total_points_won": 1, + "team_double_points_won": 0, + "team_single_points_won": 1, + "team_total_points_lost": 29, + "team_double_points_lost": 3, + "team_single_points_lost": 26, + "double_player_statistics": [ + { + "points_won": "0", + "id_player_1": "NU2707420", + "id_player_2": "NU2742419", + "points_lost": "1", + "meeting_count": "1", + "lastname_player_1": "Völker", + "lastname_player_2": "Rusu Cara", + "firstname_player_1": "Emilian", + "firstname_player_2": "Daniel" + }, + { + "points_won": "0", + "id_player_1": "NU2705037", + "id_player_2": "NU2707420", + "points_lost": "1", + "meeting_count": "1", + "lastname_player_1": "Wolf", + "lastname_player_2": "Völker", + "firstname_player_1": "Timo", + "firstname_player_2": "Emilian" + }, + { + "points_won": "0", + "id_player_1": "NU2705037", + "id_player_2": "NU2742427", + "points_lost": "1", + "meeting_count": "1", + "lastname_player_1": "Wolf", + "lastname_player_2": "Koch", + "firstname_player_1": "Timo", + "firstname_player_2": "Joschua" + } + ], + "single_player_statistics": [ + { + "player_id": "NU2705037", + "points_won": "1", + "player_rank": "1", + "points_lost": "5", + "team_number": "1", + "meeting_count": "2", + "player_lastname": "Wolf", + "player_firstname": "Timo", + "single_statistics": [ + { + "points_won": "1", + "points_lost": "1", + "opponent_rank": "1" + }, + { + "points_won": "0", + "points_lost": "2", + "opponent_rank": "2" + }, + { + "points_won": "0", + "points_lost": "2", + "opponent_rank": "3" + } + ] + }, + { + "player_id": "NU2707420", + "points_won": "0", + "player_rank": "2", + "points_lost": "5", + "team_number": "1", + "meeting_count": "2", + "player_lastname": "Völker", + "player_firstname": "Emilian", + "single_statistics": [ + { + "points_won": "0", + "points_lost": "2", + "opponent_rank": "1" + }, + { + "points_won": "0", + "points_lost": "2", + "opponent_rank": "2" + }, + { + "points_won": "0", + "points_lost": "1", + "opponent_rank": "3" + } + ] + }, + { + "player_id": "NU2742420", + "points_won": "0", + "player_rank": "3", + "points_lost": "2", + "team_number": "1", + "meeting_count": "1", + "player_lastname": "Rusu Cara", + "player_firstname": "Lukas", + "single_statistics": [ + { + "points_won": "0", + "points_lost": "1", + "opponent_rank": "1" + }, + { + "points_won": "0", + "points_lost": "1", + "opponent_rank": "2" + } + ] + }, + { + "player_id": "NU2742419", + "points_won": "0", + "player_rank": "4", + "points_lost": "2", + "team_number": "1", + "meeting_count": "1", + "player_lastname": "Rusu Cara", + "player_firstname": "Daniel", + "single_statistics": [ + { + "points_won": "0", + "points_lost": "1", + "opponent_rank": "1" + }, + { + "points_won": "0", + "points_lost": "1", + "opponent_rank": "3" + } + ] + }, + { + "player_id": "NU2742427", + "points_won": "0", + "player_rank": "5", + "points_lost": "7", + "team_number": "1", + "meeting_count": "3", + "player_lastname": "Koch", + "player_firstname": "Joschua", + "single_statistics": [ + { + "points_won": "0", + "points_lost": "2", + "opponent_rank": "1" + }, + { + "points_won": "0", + "points_lost": "2", + "opponent_rank": "2" + }, + { + "points_won": "0", + "points_lost": "2", + "opponent_rank": "3" + }, + { + "points_won": "0", + "points_lost": "1", + "opponent_rank": "4" + } + ] + }, + { + "player_id": "NU2742422", + "points_won": "0", + "player_rank": "6", + "points_lost": "2", + "team_number": "1", + "meeting_count": "1", + "player_lastname": "Swyter", + "player_firstname": "Fred", + "single_statistics": [ + { + "points_won": "0", + "points_lost": "0", + "opponent_rank": "1" + }, + { + "points_won": "0", + "points_lost": "1", + "opponent_rank": "2" + }, + { + "points_won": "0", + "points_lost": "1", + "opponent_rank": "3" + } + ] + } + ] + } + ] + }, + "season": "25--26", + "season_filter": "entire", + "association": "HeTTV", + "groupname": "1.Kreisklasse", + "urlid": "504417", + "teamid": "2995094", + "teamname": "J11", + "tableData": { + "table": [ + { + "club_id": "43009", + "team_id": "2995465", + "sets_won": 83, + "tendency": "steady", + "games_won": 1063, + "sets_lost": 19, + "team_name": "Eintracht Frankfurt V", + "games_lost": 696, + "points_won": 6, + "table_rank": 1, + "matches_won": 26, + "points_lost": 0, + "matches_lost": 4, + "meetings_tie": 0, + "meetings_won": 3, + "meetings_lost": 0, + "sets_relation": "+64", + "games_relation": "+367", + "meetings_count": 3, + "rise_fall_state": null, + "matches_relation": "+22" + }, + { + "club_id": "43009", + "team_id": "2995463", + "sets_won": 65, + "tendency": "steady", + "games_won": 995, + "sets_lost": 42, + "team_name": "Eintracht Frankfurt IV", + "games_lost": 842, + "points_won": 4, + "table_rank": 2, + "matches_won": 20, + "points_lost": 2, + "matches_lost": 10, + "meetings_tie": 0, + "meetings_won": 2, + "meetings_lost": 1, + "sets_relation": "+23", + "games_relation": "+153", + "meetings_count": 3, + "rise_fall_state": null, + "matches_relation": "+10" + }, + { + "club_id": "43041", + "team_id": "2985175", + "sets_won": 49, + "tendency": "steady", + "games_won": 739, + "sets_lost": 24, + "team_name": "TSG Oberrad II", + "games_lost": 612, + "points_won": 3, + "table_rank": 3, + "matches_won": 15, + "points_lost": 1, + "matches_lost": 5, + "meetings_tie": 1, + "meetings_won": 1, + "meetings_lost": 0, + "sets_relation": "+25", + "games_relation": "+127", + "meetings_count": 2, + "rise_fall_state": null, + "matches_relation": "+10" + }, + { + "club_id": "43009", + "team_id": "2995468", + "sets_won": 54, + "tendency": "steady", + "games_won": 1129, + "sets_lost": 84, + "team_name": "Eintracht Frankfurt II (J11)", + "games_lost": 1268, + "points_won": 3, + "table_rank": 4, + "matches_won": 14, + "points_lost": 5, + "matches_lost": 26, + "meetings_tie": 1, + "meetings_won": 1, + "meetings_lost": 2, + "sets_relation": "-30", + "games_relation": "-139", + "meetings_count": 4, + "rise_fall_state": null, + "matches_relation": "-12" + }, + { + "club_id": "43048", + "team_id": "2994011", + "sets_won": 43, + "tendency": "steady", + "games_won": 779, + "sets_lost": 37, + "team_name": "TV Seckbach 1875", + "games_lost": 749, + "points_won": 2, + "table_rank": 5, + "matches_won": 10, + "points_lost": 2, + "matches_lost": 10, + "meetings_tie": 2, + "meetings_won": 0, + "meetings_lost": 0, + "sets_relation": "+6", + "games_relation": "+30", + "meetings_count": 2, + "rise_fall_state": null, + "matches_relation": "0" + }, + { + "club_id": "43039", + "team_id": "2987226", + "sets_won": 35, + "tendency": "steady", + "games_won": 664, + "sets_lost": 38, + "team_name": "TSG Nieder-Erlenbach", + "games_lost": 708, + "points_won": 2, + "table_rank": 6, + "matches_won": 10, + "points_lost": 2, + "matches_lost": 10, + "meetings_tie": 2, + "meetings_won": 0, + "meetings_lost": 0, + "sets_relation": "-3", + "games_relation": "-44", + "meetings_count": 2, + "rise_fall_state": null, + "matches_relation": "0" + }, + { + "club_id": "43037", + "team_id": "2994841", + "sets_won": 49, + "tendency": "steady", + "games_won": 819, + "sets_lost": 53, + "team_name": "TV Niederrad III", + "games_lost": 846, + "points_won": 2, + "table_rank": 7, + "matches_won": 14, + "points_lost": 4, + "matches_lost": 16, + "meetings_tie": 0, + "meetings_won": 1, + "meetings_lost": 2, + "sets_relation": "-4", + "games_relation": "-27", + "meetings_count": 3, + "rise_fall_state": null, + "matches_relation": "-2" + }, + { + "club_id": "43030", + "team_id": "2995094", + "sets_won": 8, + "tendency": "steady", + "games_won": 597, + "sets_lost": 89, + "team_name": "Harheimer TC (J11)", + "games_lost": 1064, + "points_won": 0, + "table_rank": 8, + "matches_won": 1, + "points_lost": 6, + "matches_lost": 29, + "meetings_tie": 0, + "meetings_won": 0, + "meetings_lost": 3, + "sets_relation": "-81", + "games_relation": "-467", + "meetings_count": 3, + "rise_fall_state": null, + "matches_relation": "-28" + } + ], + "head_infos": { + "region": "Frankfurt", + "season": "25/26", + "club_name": "Harheimer TC", + "team_name": "Jugend 11", + "league_name": "Jugend 13 1. Kreisklasse ", + "club_image_url": null, + "team_table_rank": 8, + "team_matches_won": 0, + "team_matches_lost": 3, + "organization_short": "HeTTV" + }, + "no_meetings": false, + "meetings_excerpt": { + "remarks": null, + "meetings": [ + { + "2025-09-06": [ + { + "date": "2025-09-06T08:00:00.000+00:00", + "live": false, + "state": "done", + "pdf_url": "https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaDokumentTTDE.woa/wa/nuDokument?dokument=MeetingReportTTFOP&meeting=15440505&historicalDataToken=aO7Y2yagd%2F75qvlgcWCKIA%3D%3D", + "league_id": "504417", + "team_away": "Harheimer TC (J11)", + "team_home": "TSG Oberrad II", + "meeting_id": "15440505", + "round_name": null, + "round_type": "0", + "hall_number": "1", + "is_letter_h": false, + "is_letter_t": false, + "is_letter_u": false, + "is_letter_v": false, + "is_letter_w": false, + "is_letter_z": false, + "league_name": "Jugend 13 1. Kreisklasse ", + "matches_won": "10", + "is_confirmed": true, + "is_letter_na": false, + "is_letter_w2": false, + "matches_lost": "0", + "team_away_id": "2995094", + "team_home_id": "2985175", + "letter_h_info": null, + "letter_w_info": null, + "letter_z_info": null, + "original_date": null, + "letter_na_info": null, + "league_short_name": "1. Kreisklasse", + "team_away_club_id": "43030", + "team_home_club_id": "43041", + "is_meeting_complete": true, + "league_org_short_name": "HeTTV", + "nu_score_live_enabled": false, + "is_provisionally_recorded": false + } + ] + }, + { + "2025-09-28": [ + { + "date": "2025-09-28T08:30:00.000+00:00", + "live": false, + "state": "done", + "pdf_url": "https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaDokumentTTDE.woa/wa/nuDokument?dokument=MeetingReportTTFOP&meeting=15440495&historicalDataToken=aO7Y2yagd%2F75qvlgcWCKIA%3D%3D", + "league_id": "504417", + "team_away": "Harheimer TC (J11)", + "team_home": "TV Niederrad III", + "meeting_id": "15440495", + "round_name": null, + "round_type": "0", + "hall_number": "2", + "is_letter_h": false, + "is_letter_t": false, + "is_letter_u": false, + "is_letter_v": false, + "is_letter_w": false, + "is_letter_z": false, + "league_name": "Jugend 13 1. Kreisklasse ", + "matches_won": "9", + "is_confirmed": true, + "is_letter_na": false, + "is_letter_w2": false, + "matches_lost": "1", + "team_away_id": "2995094", + "team_home_id": "2994841", + "letter_h_info": null, + "letter_w_info": null, + "letter_z_info": null, + "original_date": null, + "letter_na_info": null, + "league_short_name": "1. Kreisklasse", + "team_away_club_id": "43030", + "team_home_club_id": "43037", + "is_meeting_complete": true, + "league_org_short_name": "HeTTV", + "nu_score_live_enabled": false, + "is_provisionally_recorded": false + } + ] + }, + { + "2025-09-30": [ + { + "date": "2025-09-30T16:00:00.000+00:00", + "live": false, + "state": "done", + "pdf_url": "https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaDokumentTTDE.woa/wa/nuDokument?dokument=MeetingReportTTFOP&meeting=15440488&historicalDataToken=aO7Y2yagd%2F75qvlgcWCKIA%3D%3D", + "league_id": "504417", + "team_away": "Eintracht Frankfurt V", + "team_home": "Harheimer TC (J11)", + "meeting_id": "15440488", + "round_name": null, + "round_type": "0", + "hall_number": "1", + "is_letter_h": false, + "is_letter_t": false, + "is_letter_u": false, + "is_letter_v": false, + "is_letter_w": false, + "is_letter_z": false, + "league_name": "Jugend 13 1. Kreisklasse ", + "matches_won": "0", + "is_confirmed": true, + "is_letter_na": false, + "is_letter_w2": false, + "matches_lost": "10", + "team_away_id": "2995465", + "team_home_id": "2995094", + "letter_h_info": null, + "letter_w_info": null, + "letter_z_info": null, + "original_date": null, + "letter_na_info": null, + "league_short_name": "1. Kreisklasse", + "team_away_club_id": "43009", + "team_home_club_id": "43030", + "is_meeting_complete": true, + "league_org_short_name": "HeTTV", + "nu_score_live_enabled": false, + "is_provisionally_recorded": false + } + ] + }, + { + "2025-10-25": [ + { + "date": "2025-10-25T11:15:00.000+00:00", + "live": false, + "state": "scheduled", + "pdf_url": "https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaDokumentTTDE.woa/wa/nuDokument?dokument=MeetingReportTTFOP&meeting=15440502&historicalDataToken=aO7Y2yagd%2F75qvlgcWCKIA%3D%3D", + "league_id": "504417", + "team_away": "Harheimer TC (J11)", + "team_home": "Eintracht Frankfurt IV", + "meeting_id": "15440502", + "round_name": null, + "round_type": "0", + "hall_number": "1", + "is_letter_h": false, + "is_letter_t": false, + "is_letter_u": false, + "is_letter_v": false, + "is_letter_w": false, + "is_letter_z": false, + "league_name": "Jugend 13 1. Kreisklasse ", + "matches_won": "0", + "is_confirmed": false, + "is_letter_na": false, + "is_letter_w2": false, + "matches_lost": "0", + "team_away_id": "2995094", + "team_home_id": "2995463", + "letter_h_info": null, + "letter_w_info": null, + "letter_z_info": null, + "original_date": null, + "letter_na_info": null, + "league_short_name": "1. Kreisklasse", + "team_away_club_id": "43030", + "team_home_club_id": "43009", + "is_meeting_complete": false, + "league_org_short_name": "HeTTV", + "nu_score_live_enabled": false, + "is_provisionally_recorded": false + } + ] + }, + { + "2025-11-11": [ + { + "date": "2025-11-11T17:00:00.000+00:00", + "live": false, + "state": "scheduled", + "pdf_url": "https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaDokumentTTDE.woa/wa/nuDokument?dokument=MeetingReportTTFOP&meeting=15440500&historicalDataToken=aO7Y2yagd%2F75qvlgcWCKIA%3D%3D", + "league_id": "504417", + "team_away": "TSG Nieder-Erlenbach", + "team_home": "Harheimer TC (J11)", + "meeting_id": "15440500", + "round_name": null, + "round_type": "0", + "hall_number": "1", + "is_letter_h": false, + "is_letter_t": false, + "is_letter_u": false, + "is_letter_v": false, + "is_letter_w": false, + "is_letter_z": false, + "league_name": "Jugend 13 1. Kreisklasse ", + "matches_won": "0", + "is_confirmed": false, + "is_letter_na": false, + "is_letter_w2": false, + "matches_lost": "0", + "team_away_id": "2987226", + "team_home_id": "2995094", + "letter_h_info": null, + "letter_w_info": null, + "letter_z_info": null, + "original_date": null, + "letter_na_info": null, + "league_short_name": "1. Kreisklasse", + "team_away_club_id": "43039", + "team_home_club_id": "43030", + "is_meeting_complete": false, + "league_org_short_name": "HeTTV", + "nu_score_live_enabled": false, + "is_provisionally_recorded": false + } + ] + }, + { + "2025-11-29": [ + { + "date": "2025-11-29T12:15:00.000+00:00", + "live": false, + "state": "scheduled", + "pdf_url": "https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaDokumentTTDE.woa/wa/nuDokument?dokument=MeetingReportTTFOP&meeting=15440508&historicalDataToken=aO7Y2yagd%2F75qvlgcWCKIA%3D%3D", + "league_id": "504417", + "team_away": "Harheimer TC (J11)", + "team_home": "Eintracht Frankfurt II (J11)", + "meeting_id": "15440508", + "round_name": null, + "round_type": "0", + "hall_number": "1", + "is_letter_h": false, + "is_letter_t": false, + "is_letter_u": false, + "is_letter_v": false, + "is_letter_w": false, + "is_letter_z": false, + "league_name": "Jugend 13 1. Kreisklasse ", + "matches_won": "0", + "is_confirmed": false, + "is_letter_na": false, + "is_letter_w2": false, + "matches_lost": "0", + "team_away_id": "2995094", + "team_home_id": "2995468", + "letter_h_info": null, + "letter_w_info": null, + "letter_z_info": null, + "original_date": null, + "letter_na_info": null, + "league_short_name": "1. Kreisklasse", + "team_away_club_id": "43030", + "team_home_club_id": "43009", + "is_meeting_complete": false, + "league_org_short_name": "HeTTV", + "nu_score_live_enabled": false, + "is_provisionally_recorded": false + } + ] + }, + { + "2025-12-09": [ + { + "date": "2025-12-09T17:00:00.000+00:00", + "live": false, + "state": "scheduled", + "pdf_url": "https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaDokumentTTDE.woa/wa/nuDokument?dokument=MeetingReportTTFOP&meeting=15440497&historicalDataToken=aO7Y2yagd%2F75qvlgcWCKIA%3D%3D", + "league_id": "504417", + "team_away": "TV Seckbach 1875", + "team_home": "Harheimer TC (J11)", + "meeting_id": "15440497", + "round_name": null, + "round_type": "0", + "hall_number": "1", + "is_letter_h": false, + "is_letter_t": false, + "is_letter_u": false, + "is_letter_v": false, + "is_letter_w": false, + "is_letter_z": false, + "league_name": "Jugend 13 1. Kreisklasse ", + "matches_won": "0", + "is_confirmed": false, + "is_letter_na": false, + "is_letter_w2": false, + "matches_lost": "0", + "team_away_id": "2994011", + "team_home_id": "2995094", + "letter_h_info": null, + "letter_w_info": null, + "letter_z_info": null, + "original_date": null, + "letter_na_info": null, + "league_short_name": "1. Kreisklasse", + "team_away_club_id": "43048", + "team_home_club_id": "43030", + "is_meeting_complete": false, + "league_org_short_name": "HeTTV", + "nu_score_live_enabled": false, + "is_provisionally_recorded": false + } + ] + }, + { + "2026-02-06": [ + { + "date": "2026-02-06T17:00:00.000+00:00", + "live": false, + "state": "scheduled", + "pdf_url": "https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaDokumentTTDE.woa/wa/nuDokument?dokument=MeetingReportTTFOP&meeting=15440515&historicalDataToken=aO7Y2yagd%2F75qvlgcWCKIA%3D%3D", + "league_id": "504417", + "team_away": "Harheimer TC (J11)", + "team_home": "TV Seckbach 1875", + "meeting_id": "15440515", + "round_name": null, + "round_type": "1", + "hall_number": "1", + "is_letter_h": false, + "is_letter_t": false, + "is_letter_u": false, + "is_letter_v": false, + "is_letter_w": false, + "is_letter_z": false, + "league_name": "Jugend 13 1. Kreisklasse ", + "matches_won": "0", + "is_confirmed": false, + "is_letter_na": false, + "is_letter_w2": false, + "matches_lost": "0", + "team_away_id": "2995094", + "team_home_id": "2994011", + "letter_h_info": null, + "letter_w_info": null, + "letter_z_info": null, + "original_date": null, + "letter_na_info": null, + "league_short_name": "1. Kreisklasse", + "team_away_club_id": "43030", + "team_home_club_id": "43048", + "is_meeting_complete": false, + "league_org_short_name": "HeTTV", + "nu_score_live_enabled": false, + "is_provisionally_recorded": false + } + ] + }, + { + "2026-02-16": [ + { + "date": "2026-02-16T17:00:00.000+00:00", + "live": false, + "state": "scheduled", + "pdf_url": "https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaDokumentTTDE.woa/wa/nuDokument?dokument=MeetingReportTTFOP&meeting=15440522&historicalDataToken=aO7Y2yagd%2F75qvlgcWCKIA%3D%3D", + "league_id": "504417", + "team_away": "Harheimer TC (J11)", + "team_home": "TSG Nieder-Erlenbach", + "meeting_id": "15440522", + "round_name": null, + "round_type": "1", + "hall_number": "1", + "is_letter_h": false, + "is_letter_t": false, + "is_letter_u": false, + "is_letter_v": false, + "is_letter_w": false, + "is_letter_z": false, + "league_name": "Jugend 13 1. Kreisklasse ", + "matches_won": "0", + "is_confirmed": false, + "is_letter_na": false, + "is_letter_w2": false, + "matches_lost": "0", + "team_away_id": "2995094", + "team_home_id": "2987226", + "letter_h_info": null, + "letter_w_info": null, + "letter_z_info": null, + "original_date": null, + "letter_na_info": null, + "league_short_name": "1. Kreisklasse", + "team_away_club_id": "43030", + "team_home_club_id": "43039", + "is_meeting_complete": false, + "league_org_short_name": "HeTTV", + "nu_score_live_enabled": false, + "is_provisionally_recorded": false + } + ] + }, + { + "2026-02-24": [ + { + "date": "2026-02-24T17:00:00.000+00:00", + "live": false, + "state": "scheduled", + "pdf_url": "https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaDokumentTTDE.woa/wa/nuDokument?dokument=MeetingReportTTFOP&meeting=15440524&historicalDataToken=aO7Y2yagd%2F75qvlgcWCKIA%3D%3D", + "league_id": "504417", + "team_away": "TV Niederrad III", + "team_home": "Harheimer TC (J11)", + "meeting_id": "15440524", + "round_name": null, + "round_type": "1", + "hall_number": "1", + "is_letter_h": false, + "is_letter_t": false, + "is_letter_u": false, + "is_letter_v": false, + "is_letter_w": false, + "is_letter_z": false, + "league_name": "Jugend 13 1. Kreisklasse ", + "matches_won": "0", + "is_confirmed": false, + "is_letter_na": false, + "is_letter_w2": false, + "matches_lost": "0", + "team_away_id": "2994841", + "team_home_id": "2995094", + "letter_h_info": null, + "letter_w_info": null, + "letter_z_info": null, + "original_date": null, + "letter_na_info": null, + "league_short_name": "1. Kreisklasse", + "team_away_club_id": "43037", + "team_home_club_id": "43030", + "is_meeting_complete": false, + "league_org_short_name": "HeTTV", + "nu_score_live_enabled": false, + "is_provisionally_recorded": false + } + ] + }, + { + "2026-03-10": [ + { + "date": "2026-03-10T17:00:00.000+00:00", + "live": false, + "state": "scheduled", + "pdf_url": "https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaDokumentTTDE.woa/wa/nuDokument?dokument=MeetingReportTTFOP&meeting=15440532&historicalDataToken=aO7Y2yagd%2F75qvlgcWCKIA%3D%3D", + "league_id": "504417", + "team_away": "TSG Oberrad II", + "team_home": "Harheimer TC (J11)", + "meeting_id": "15440532", + "round_name": null, + "round_type": "1", + "hall_number": "1", + "is_letter_h": false, + "is_letter_t": false, + "is_letter_u": false, + "is_letter_v": false, + "is_letter_w": false, + "is_letter_z": false, + "league_name": "Jugend 13 1. Kreisklasse ", + "matches_won": "0", + "is_confirmed": false, + "is_letter_na": false, + "is_letter_w2": false, + "matches_lost": "0", + "team_away_id": "2985175", + "team_home_id": "2995094", + "letter_h_info": null, + "letter_w_info": null, + "letter_z_info": null, + "original_date": null, + "letter_na_info": null, + "league_short_name": "1. Kreisklasse", + "team_away_club_id": "43041", + "team_home_club_id": "43030", + "is_meeting_complete": false, + "league_org_short_name": "HeTTV", + "nu_score_live_enabled": false, + "is_provisionally_recorded": false + } + ] + }, + { + "2026-03-24": [ + { + "date": "2026-03-24T17:00:00.000+00:00", + "live": false, + "state": "scheduled", + "pdf_url": "https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaDokumentTTDE.woa/wa/nuDokument?dokument=MeetingReportTTFOP&meeting=15440537&historicalDataToken=aO7Y2yagd%2F75qvlgcWCKIA%3D%3D", + "league_id": "504417", + "team_away": "Eintracht Frankfurt IV", + "team_home": "Harheimer TC (J11)", + "meeting_id": "15440537", + "round_name": null, + "round_type": "1", + "hall_number": "1", + "is_letter_h": false, + "is_letter_t": false, + "is_letter_u": false, + "is_letter_v": false, + "is_letter_w": false, + "is_letter_z": false, + "league_name": "Jugend 13 1. Kreisklasse ", + "matches_won": "0", + "is_confirmed": false, + "is_letter_na": false, + "is_letter_w2": false, + "matches_lost": "0", + "team_away_id": "2995463", + "team_home_id": "2995094", + "letter_h_info": null, + "letter_w_info": null, + "letter_z_info": null, + "original_date": null, + "letter_na_info": null, + "league_short_name": "1. Kreisklasse", + "team_away_club_id": "43009", + "team_home_club_id": "43030", + "is_meeting_complete": false, + "league_org_short_name": "HeTTV", + "nu_score_live_enabled": false, + "is_provisionally_recorded": false + } + ] + }, + { + "2026-04-18": [ + { + "date": "2026-04-18T08:00:00.000+00:00", + "live": false, + "state": "scheduled", + "pdf_url": "https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaDokumentTTDE.woa/wa/nuDokument?dokument=MeetingReportTTFOP&meeting=15440520&historicalDataToken=aO7Y2yagd%2F75qvlgcWCKIA%3D%3D", + "league_id": "504417", + "team_away": "Harheimer TC (J11)", + "team_home": "Eintracht Frankfurt V", + "meeting_id": "15440520", + "round_name": null, + "round_type": "1", + "hall_number": "1", + "is_letter_h": false, + "is_letter_t": false, + "is_letter_u": false, + "is_letter_v": false, + "is_letter_w": false, + "is_letter_z": false, + "league_name": "Jugend 13 1. Kreisklasse ", + "matches_won": "0", + "is_confirmed": false, + "is_letter_na": false, + "is_letter_w2": false, + "matches_lost": "0", + "team_away_id": "2995094", + "team_home_id": "2995465", + "letter_h_info": null, + "letter_w_info": null, + "letter_z_info": null, + "original_date": null, + "letter_na_info": null, + "league_short_name": "1. Kreisklasse", + "team_away_club_id": "43030", + "team_home_club_id": "43009", + "is_meeting_complete": false, + "league_org_short_name": "HeTTV", + "nu_score_live_enabled": false, + "is_provisionally_recorded": false + } + ] + }, + { + "2026-04-21": [ + { + "date": "2026-04-21T16:00:00.000+00:00", + "live": false, + "state": "scheduled", + "pdf_url": "https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaDokumentTTDE.woa/wa/nuDokument?dokument=MeetingReportTTFOP&meeting=15440518&historicalDataToken=aO7Y2yagd%2F75qvlgcWCKIA%3D%3D", + "league_id": "504417", + "team_away": "Eintracht Frankfurt II (J11)", + "team_home": "Harheimer TC (J11)", + "meeting_id": "15440518", + "round_name": null, + "round_type": "1", + "hall_number": "1", + "is_letter_h": false, + "is_letter_t": false, + "is_letter_u": false, + "is_letter_v": false, + "is_letter_w": false, + "is_letter_z": false, + "league_name": "Jugend 13 1. Kreisklasse ", + "matches_won": "0", + "is_confirmed": false, + "is_letter_na": false, + "is_letter_w2": false, + "matches_lost": "0", + "team_away_id": "2995468", + "team_home_id": "2995094", + "letter_h_info": null, + "letter_w_info": null, + "letter_z_info": null, + "original_date": null, + "letter_na_info": null, + "league_short_name": "1. Kreisklasse", + "team_away_club_id": "43009", + "team_home_club_id": "43030", + "is_meeting_complete": false, + "league_org_short_name": "HeTTV", + "nu_score_live_enabled": false, + "is_provisionally_recorded": false + } + ] + } + ], + "pdf_version_url": "https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaDokumentTTDE.woa/wa/nuDokument?dokument=ScheduleReportFOP&group=504417", + "pdf_materials_url": null + } + }, + "tableError": null +} +=== PLAYER STATS RESPONSE END === +Processing data for team J11 +Player: Timo Wolf (ID: NU2705037) + Points won: 1, Points lost: 5 + No local member found for Timo Wolf +Player: Emilian Völker (ID: NU2707420) + Points won: 0, Points lost: 5 + No local member found for Emilian Völker +Player: Lukas Rusu Cara (ID: NU2742420) + Points won: 0, Points lost: 2 + No local member found for Lukas Rusu Cara +Player: Daniel Rusu Cara (ID: NU2742419) + Points won: 0, Points lost: 2 + No local member found for Daniel Rusu Cara +Player: Joschua Koch (ID: NU2742427) + Points won: 0, Points lost: 7 + Matched with local member: Joschua Koch (ID: 2) +Player: Fred Swyter (ID: NU2742422) + Points won: 0, Points lost: 2 + No local member found for Fred Swyter +Double: Emilian Völker / Daniel Rusu Cara + Points won: 0, Points lost: 1 +Double: Timo Wolf / Emilian Völker + Points won: 0, Points lost: 1 +Double: Timo Wolf / Joschua Koch + Points won: 0, Points lost: 1 +Processed 9 player statistics +Fetching match results from: https://www.mytischtennis.de/click-tt/HeTTV/25--26/ligen/1.Kreisklasse/gruppe/504417/tabelle/gesamt?_data=routes%2Fclick-tt%2B%2F%24association%2B%2F%24season%2B%2F%24type%2B%2F%24groupname.gruppe.%24urlid%2B%2F_layout +=== MATCH RESULTS RESPONSE START === +{ + "seasonStatus": "PUBLIC", + "seasonType": "", + "data": { + "head_infos": { + "season": "25/26", + "play_mode": "Braunschweiger System", + "league_name": "Jugend 13 1. Kreisklasse ", + "championship": "K43 25/26", + "gender_age_group": "Jugend 13", + "organization_short": "HeTTV" + }, + "no_meetings": false, + "season_list": [ + { + "name": "04/05", + "full_name": "2004/05" + }, + { + "name": "05/06", + "full_name": "2005/06" + }, + { + "name": "06/07", + "full_name": "2006/07" + }, + { + "name": "P 07/08", + "full_name": "Pokal 2007/08" + }, + { + "name": "07/08", + "full_name": "2007/08" + }, + { + "name": "P 08/09", + "full_name": "Pokal 2008/09" + }, + { + "name": "08/09", + "full_name": "2008/09" + }, + { + "name": "09/10", + "full_name": "2009/10" + }, + { + "name": "P 09/10", + "full_name": "Pokal 2009/10" + }, + { + "name": "P 10/11", + "full_name": "Pokal 2010/11" + }, + { + "name": "10/11", + "full_name": "2010/11" + }, + { + "name": "11/12", + "full_name": "2011/12" + }, + { + "name": "P 11/12", + "full_name": "Pokal 2011/12" + }, + { + "name": "P 12/13", + "full_name": "Pokal 2012/13" + }, + { + "name": "12/13", + "full_name": "2012/13" + }, + { + "name": "13/14", + "full_name": "2013/14" + }, + { + "name": "P 13/14", + "full_name": "Pokal 2013/14" + }, + { + "name": "14/15", + "full_name": "2014/15" + }, + { + "name": "P 14/15", + "full_name": "Pokal 2014/15" + }, + { + "name": "15/16", + "full_name": "2015/16" + }, + { + "name": "P 15/16", + "full_name": "Pokal 2015/16" + }, + { + "name": "16/17", + "full_name": "2016/17" + }, + { + "name": "P 16/17", + "full_name": "Pokal 2016/17" + }, + { + "name": "17/18", + "full_name": "2017/18" + }, + { + "name": "P 17/18", + "full_name": "Pokal 2017/18" + }, + { + "name": "P 18/19", + "full_name": "Pokal 2018/19" + }, + { + "name": "18/19", + "full_name": "2018/19" + }, + { + "name": "P 19/20", + "full_name": "Pokal 2019/20" + }, + { + "name": "19/20", + "full_name": "2019/20" + }, + { + "name": "P 20/21", + "full_name": "Pokal 2020/21" + }, + { + "name": "20/21", + "full_name": "2020/21" + }, + { + "name": "21/22", + "full_name": "2021/22" + }, + { + "name": "P 21/22", + "full_name": "Pokal 2021/22" + }, + { + "name": "P 22/23", + "full_name": "Pokal 2022/23" + }, + { + "name": "22/23", + "full_name": "2022/23" + }, + { + "name": "P 23/24", + "full_name": "Pokal 2023/24" + }, + { + "name": "23/24", + "full_name": "2023/24" + }, + { + "name": "P 24/25", + "full_name": "Pokal 2024/25" + }, + { + "name": "24/25", + "full_name": "2024/25" + }, + { + "name": "P 25/26", + "full_name": "Pokal 2025/26" + }, + { + "name": "25/26", + "full_name": "2025/26" + } + ], + "meetings_excerpt": { + "remarks": null, + "meetings": [ + { + "2025-09-29": [ + { + "date": "2025-09-29T16:00:00.000+00:00", + "live": false, + "state": "done", + "pdf_url": "https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaDokumentTTDE.woa/wa/nuDokument?dokument=MeetingReportTTFOP&meeting=15440510&historicalDataToken=0uRtkSxKI%2BEjfDka6HCGIA%3D%3D", + "league_id": "504417", + "team_away": "Eintracht Frankfurt II (J11)", + "team_home": "TSG Nieder-Erlenbach", + "meeting_id": "15440510", + "round_name": null, + "round_type": "0", + "hall_number": "1", + "is_letter_h": false, + "is_letter_t": false, + "is_letter_u": false, + "is_letter_v": false, + "is_letter_w": false, + "is_letter_z": false, + "league_name": "Jugend 13 1. Kreisklasse ", + "matches_won": "5", + "is_confirmed": true, + "is_letter_na": false, + "is_letter_w2": false, + "matches_lost": "5", + "team_away_id": "2995468", + "team_home_id": "2987226", + "letter_h_info": null, + "letter_w_info": null, + "letter_z_info": null, + "original_date": null, + "letter_na_info": null, + "league_short_name": "1. Kreisklasse", + "team_away_club_id": "43009", + "team_home_club_id": "43039", + "is_meeting_complete": true, + "league_org_short_name": "HeTTV", + "nu_score_live_enabled": false, + "is_provisionally_recorded": false + } + ] + }, + { + "2025-09-30": [ + { + "date": "2025-09-30T16:00:00.000+00:00", + "live": false, + "state": "done", + "pdf_url": "https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaDokumentTTDE.woa/wa/nuDokument?dokument=MeetingReportTTFOP&meeting=15440488&historicalDataToken=0uRtkSxKI%2BEjfDka6HCGIA%3D%3D", + "league_id": "504417", + "team_away": "Eintracht Frankfurt V", + "team_home": "Harheimer TC (J11)", + "meeting_id": "15440488", + "round_name": null, + "round_type": "0", + "hall_number": "1", + "is_letter_h": false, + "is_letter_t": false, + "is_letter_u": false, + "is_letter_v": false, + "is_letter_w": false, + "is_letter_z": false, + "league_name": "Jugend 13 1. Kreisklasse ", + "matches_won": "0", + "is_confirmed": true, + "is_letter_na": false, + "is_letter_w2": false, + "matches_lost": "10", + "team_away_id": "2995465", + "team_home_id": "2995094", + "letter_h_info": null, + "letter_w_info": null, + "letter_z_info": null, + "original_date": null, + "letter_na_info": null, + "league_short_name": "1. Kreisklasse", + "team_away_club_id": "43009", + "team_home_club_id": "43030", + "is_meeting_complete": true, + "league_org_short_name": "HeTTV", + "nu_score_live_enabled": false, + "is_provisionally_recorded": false + } + ] + }, + { + "2025-10-02": [ + { + "date": "2025-10-02T16:00:00.000+00:00", + "live": false, + "state": "done", + "pdf_url": "https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaDokumentTTDE.woa/wa/nuDokument?dokument=MeetingReportTTFOP&meeting=15440487&historicalDataToken=0uRtkSxKI%2BEjfDka6HCGIA%3D%3D", + "league_id": "504417", + "team_away": "TV Niederrad III", + "team_home": "Eintracht Frankfurt II (J11)", + "meeting_id": "15440487", + "round_name": null, + "round_type": "0", + "hall_number": "1", + "is_letter_h": false, + "is_letter_t": false, + "is_letter_u": false, + "is_letter_v": true, + "is_letter_w": false, + "is_letter_z": false, + "league_name": "Jugend 13 1. Kreisklasse ", + "matches_won": "6", + "is_confirmed": true, + "is_letter_na": false, + "is_letter_w2": false, + "matches_lost": "4", + "team_away_id": "2994841", + "team_home_id": "2995468", + "letter_h_info": null, + "letter_w_info": null, + "letter_z_info": null, + "original_date": "2025-09-27T11:15:00.000+00:00", + "letter_na_info": null, + "league_short_name": "1. Kreisklasse", + "team_away_club_id": "43037", + "team_home_club_id": "43009", + "is_meeting_complete": true, + "league_org_short_name": "HeTTV", + "nu_score_live_enabled": false, + "is_provisionally_recorded": false + } + ] + }, + { + "2025-10-05": [ + { + "date": "2025-10-05T08:30:00.000+00:00", + "live": false, + "state": "done", + "pdf_url": "https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaDokumentTTDE.woa/wa/nuDokument?dokument=MeetingReportTTFOP&meeting=15440494&historicalDataToken=0uRtkSxKI%2BEjfDka6HCGIA%3D%3D", + "league_id": "504417", + "team_away": "Eintracht Frankfurt IV", + "team_home": "TV Niederrad III", + "meeting_id": "15440494", + "round_name": null, + "round_type": "0", + "hall_number": "2", + "is_letter_h": false, + "is_letter_t": false, + "is_letter_u": false, + "is_letter_v": false, + "is_letter_w": false, + "is_letter_z": false, + "league_name": "Jugend 13 1. Kreisklasse ", + "matches_won": "1", + "is_confirmed": true, + "is_letter_na": false, + "is_letter_w2": false, + "matches_lost": "9", + "team_away_id": "2995463", + "team_home_id": "2994841", + "letter_h_info": null, + "letter_w_info": null, + "letter_z_info": null, + "original_date": null, + "letter_na_info": null, + "league_short_name": "1. Kreisklasse", + "team_away_club_id": "43009", + "team_home_club_id": "43037", + "is_meeting_complete": true, + "league_org_short_name": "HeTTV", + "nu_score_live_enabled": false, + "is_provisionally_recorded": false + } + ] + }, + { + "2025-10-24": [ + { + "date": "2025-10-24T16:00:00.000+00:00", + "live": false, + "state": "scheduled", + "pdf_url": "https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaDokumentTTDE.woa/wa/nuDokument?dokument=MeetingReportTTFOP&meeting=15440489&historicalDataToken=0uRtkSxKI%2BEjfDka6HCGIA%3D%3D", + "league_id": "504417", + "team_away": "TV Niederrad III", + "team_home": "TV Seckbach 1875", + "meeting_id": "15440489", + "round_name": null, + "round_type": "0", + "hall_number": "1", + "is_letter_h": false, + "is_letter_t": false, + "is_letter_u": false, + "is_letter_v": false, + "is_letter_w": false, + "is_letter_z": false, + "league_name": "Jugend 13 1. Kreisklasse ", + "matches_won": "0", + "is_confirmed": false, + "is_letter_na": false, + "is_letter_w2": false, + "matches_lost": "0", + "team_away_id": "2994841", + "team_home_id": "2994011", + "letter_h_info": null, + "letter_w_info": null, + "letter_z_info": null, + "original_date": null, + "letter_na_info": null, + "league_short_name": "1. Kreisklasse", + "team_away_club_id": "43037", + "team_home_club_id": "43048", + "is_meeting_complete": false, + "league_org_short_name": "HeTTV", + "nu_score_live_enabled": false, + "is_provisionally_recorded": false + } + ] + }, + { + "2025-10-25": [ + { + "date": "2025-10-25T08:00:00.000+00:00", + "live": false, + "state": "scheduled", + "pdf_url": "https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaDokumentTTDE.woa/wa/nuDokument?dokument=MeetingReportTTFOP&meeting=15440486&historicalDataToken=0uRtkSxKI%2BEjfDka6HCGIA%3D%3D", + "league_id": "504417", + "team_away": "TSG Nieder-Erlenbach", + "team_home": "Eintracht Frankfurt V", + "meeting_id": "15440486", + "round_name": null, + "round_type": "0", + "hall_number": "1", + "is_letter_h": false, + "is_letter_t": false, + "is_letter_u": false, + "is_letter_v": false, + "is_letter_w": false, + "is_letter_z": false, + "league_name": "Jugend 13 1. Kreisklasse ", + "matches_won": "0", + "is_confirmed": false, + "is_letter_na": false, + "is_letter_w2": false, + "matches_lost": "0", + "team_away_id": "2987226", + "team_home_id": "2995465", + "letter_h_info": null, + "letter_w_info": null, + "letter_z_info": null, + "original_date": null, + "letter_na_info": null, + "league_short_name": "1. Kreisklasse", + "team_away_club_id": "43039", + "team_home_club_id": "43009", + "is_meeting_complete": false, + "league_org_short_name": "HeTTV", + "nu_score_live_enabled": false, + "is_provisionally_recorded": false + }, + { + "date": "2025-10-25T11:15:00.000+00:00", + "live": false, + "state": "scheduled", + "pdf_url": "https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaDokumentTTDE.woa/wa/nuDokument?dokument=MeetingReportTTFOP&meeting=15440512&historicalDataToken=0uRtkSxKI%2BEjfDka6HCGIA%3D%3D", + "league_id": "504417", + "team_away": "TSG Oberrad II", + "team_home": "Eintracht Frankfurt II (J11)", + "meeting_id": "15440512", + "round_name": null, + "round_type": "0", + "hall_number": "1", + "is_letter_h": false, + "is_letter_t": false, + "is_letter_u": false, + "is_letter_v": false, + "is_letter_w": false, + "is_letter_z": false, + "league_name": "Jugend 13 1. Kreisklasse ", + "matches_won": "0", + "is_confirmed": false, + "is_letter_na": false, + "is_letter_w2": false, + "matches_lost": "0", + "team_away_id": "2985175", + "team_home_id": "2995468", + "letter_h_info": null, + "letter_w_info": null, + "letter_z_info": null, + "original_date": null, + "letter_na_info": null, + "league_short_name": "1. Kreisklasse", + "team_away_club_id": "43041", + "team_home_club_id": "43009", + "is_meeting_complete": false, + "league_org_short_name": "HeTTV", + "nu_score_live_enabled": false, + "is_provisionally_recorded": false + }, + { + "date": "2025-10-25T11:15:00.000+00:00", + "live": false, + "state": "scheduled", + "pdf_url": "https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaDokumentTTDE.woa/wa/nuDokument?dokument=MeetingReportTTFOP&meeting=15440502&historicalDataToken=0uRtkSxKI%2BEjfDka6HCGIA%3D%3D", + "league_id": "504417", + "team_away": "Harheimer TC (J11)", + "team_home": "Eintracht Frankfurt IV", + "meeting_id": "15440502", + "round_name": null, + "round_type": "0", + "hall_number": "1", + "is_letter_h": false, + "is_letter_t": false, + "is_letter_u": false, + "is_letter_v": false, + "is_letter_w": false, + "is_letter_z": false, + "league_name": "Jugend 13 1. Kreisklasse ", + "matches_won": "0", + "is_confirmed": false, + "is_letter_na": false, + "is_letter_w2": false, + "matches_lost": "0", + "team_away_id": "2995094", + "team_home_id": "2995463", + "letter_h_info": null, + "letter_w_info": null, + "letter_z_info": null, + "original_date": null, + "letter_na_info": null, + "league_short_name": "1. Kreisklasse", + "team_away_club_id": "43030", + "team_home_club_id": "43009", + "is_meeting_complete": false, + "league_org_short_name": "HeTTV", + "nu_score_live_enabled": false, + "is_provisionally_recorded": false + } + ] + }, + { + "2025-11-11": [ + { + "date": "2025-11-11T17:00:00.000+00:00", + "live": false, + "state": "scheduled", + "pdf_url": "https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaDokumentTTDE.woa/wa/nuDokument?dokument=MeetingReportTTFOP&meeting=15440500&historicalDataToken=0uRtkSxKI%2BEjfDka6HCGIA%3D%3D", + "league_id": "504417", + "team_away": "TSG Nieder-Erlenbach", + "team_home": "Harheimer TC (J11)", + "meeting_id": "15440500", + "round_name": null, + "round_type": "0", + "hall_number": "1", + "is_letter_h": false, + "is_letter_t": false, + "is_letter_u": false, + "is_letter_v": false, + "is_letter_w": false, + "is_letter_z": false, + "league_name": "Jugend 13 1. Kreisklasse ", + "matches_won": "0", + "is_confirmed": false, + "is_letter_na": false, + "is_letter_w2": false, + "matches_lost": "0", + "team_away_id": "2987226", + "team_home_id": "2995094", + "letter_h_info": null, + "letter_w_info": null, + "letter_z_info": null, + "original_date": null, + "letter_na_info": null, + "league_short_name": "1. Kreisklasse", + "team_away_club_id": "43039", + "team_home_club_id": "43030", + "is_meeting_complete": false, + "league_org_short_name": "HeTTV", + "nu_score_live_enabled": false, + "is_provisionally_recorded": false + } + ] + }, + { + "2025-11-12": [ + { + "date": "2025-11-12T16:45:00.000+00:00", + "live": false, + "state": "scheduled", + "pdf_url": "https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaDokumentTTDE.woa/wa/nuDokument?dokument=MeetingReportTTFOP&meeting=15440501&historicalDataToken=0uRtkSxKI%2BEjfDka6HCGIA%3D%3D", + "league_id": "504417", + "team_away": "Eintracht Frankfurt V", + "team_home": "TSG Oberrad II", + "meeting_id": "15440501", + "round_name": null, + "round_type": "0", + "hall_number": "1", + "is_letter_h": false, + "is_letter_t": false, + "is_letter_u": false, + "is_letter_v": true, + "is_letter_w": false, + "is_letter_z": false, + "league_name": "Jugend 13 1. Kreisklasse ", + "matches_won": "0", + "is_confirmed": false, + "is_letter_na": false, + "is_letter_w2": false, + "matches_lost": "0", + "team_away_id": "2995465", + "team_home_id": "2985175", + "letter_h_info": null, + "letter_w_info": null, + "letter_z_info": null, + "original_date": "2025-11-15T09:00:00.000+00:00", + "letter_na_info": null, + "league_short_name": "1. Kreisklasse", + "team_away_club_id": "43009", + "team_home_club_id": "43041", + "is_meeting_complete": false, + "league_org_short_name": "HeTTV", + "nu_score_live_enabled": false, + "is_provisionally_recorded": false + } + ] + } + ], + "round_type": "mixed", + "pdf_version_url": "https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaDokumentTTDE.woa/wa/nuDokument?dokument=ScheduleReportFOP&group=504417", + "pdf_materials_url": null + } + }, + "association": "HeTTV", + "season": "25/26", + "error": null +} +=== MATCH RESULTS RESPONSE END === +Processing match results for team J11 +Match: Harheimer TC (J11) vs Eintracht Frankfurt V + Date: 2025-09-30T16:00:00.000+00:00 + Status: done (complete) + Result: 0:10 + Meeting ID: 15440488 +Match points from myTischtennis: 0:10 (from team_home perspective) +Searching for existing match with meeting ID: 15440488 +Found match by meeting ID: 22 +Teams in same order: Harheimer TC (J11) = Harheimer TC (J11) +Updated existing match 22 (Meeting 15440488): 0:10 (complete) +Match: Eintracht Frankfurt IV vs Harheimer TC (J11) + Date: 2025-10-25T11:15:00.000+00:00 + Status: scheduled (incomplete) + Result: 0:0 + Meeting ID: 15440502 +Match points from myTischtennis: 0:0 (from team_home perspective) +Searching for existing match with meeting ID: 15440502 +Found match by meeting ID: 31 +Teams in same order: Eintracht Frankfurt IV (J13) = Eintracht Frankfurt IV +Updated existing match 31 (Meeting 15440502): 0:0 (incomplete) +Match: Harheimer TC (J11) vs TSG Nieder-Erlenbach + Date: 2025-11-11T17:00:00.000+00:00 + Status: scheduled (incomplete) + Result: 0:0 + Meeting ID: 15440500 +Match points from myTischtennis: 0:0 (from team_home perspective) +Searching for existing match with meeting ID: 15440500 +Found match by meeting ID: 41 +Teams in same order: Harheimer TC (J11) = Harheimer TC (J11) +Updated existing match 41 (Meeting 15440500): 0:0 (incomplete) +Found 3 matches for team J11 +Processed 3 match results +[nodemon] restarting due to changes... +[nodemon] starting `node server.js` +[nodemon] restarting due to changes... +[nodemon] starting `node server.js` +Starting scheduler service... +Scheduler service started successfully +Rating updates scheduled for 6:00 AM daily (Europe/Berlin timezone) +Match results fetch scheduled for 6:30 AM daily (Europe/Berlin timezone) +Server is running on http://localhost:3000 +Scheduler service started: + - Rating updates: 6:00 AM daily + - Match results fetch: 6:30 AM daily diff --git a/backend/services/autoFetchMatchResultsService.js b/backend/services/autoFetchMatchResultsService.js new file mode 100644 index 0000000..0fc3aa4 --- /dev/null +++ b/backend/services/autoFetchMatchResultsService.js @@ -0,0 +1,657 @@ +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 + + 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'] + }); + } +} + +export default new AutoFetchMatchResultsService(); + diff --git a/backend/services/clubTeamService.js b/backend/services/clubTeamService.js index b851c03..0cb92eb 100644 --- a/backend/services/clubTeamService.js +++ b/backend/services/clubTeamService.js @@ -35,6 +35,7 @@ class ClubTeamService { clubId: clubTeam.clubId, leagueId: clubTeam.leagueId, seasonId: clubTeam.seasonId, + myTischtennisTeamId: clubTeam.myTischtennisTeamId, createdAt: clubTeam.createdAt, updatedAt: clubTeam.updatedAt, league: { name: 'Unbekannt' }, @@ -43,7 +44,9 @@ class ClubTeamService { // Lade Liga-Daten if (clubTeam.leagueId) { - const league = await League.findByPk(clubTeam.leagueId, { attributes: ['name'] }); + const league = await League.findByPk(clubTeam.leagueId, { + attributes: ['id', 'name', 'myTischtennisGroupId', 'association', 'groupname'] + }); if (league) enrichedTeam.league = league; } diff --git a/backend/services/matchService.js b/backend/services/matchService.js index 3ab1f08..b2e0610 100644 --- a/backend/services/matchService.js +++ b/backend/services/matchService.js @@ -7,6 +7,8 @@ import Season from '../models/Season.js'; import Location from '../models/Location.js'; import League from '../models/League.js'; import Team from '../models/Team.js'; +import ClubTeam from '../models/ClubTeam.js'; +import Club from '../models/Club.js'; import SeasonService from './seasonService.js'; import { checkAccess } from '../utils/userUtils.js'; import { Op } from 'sequelize'; @@ -14,6 +16,46 @@ import { Op } from 'sequelize'; import { devLog } from '../utils/logger.js'; class MatchService { + /** + * Format team name with age class suffix + * @param {string} teamName - Base team name (e.g. "Harheimer TC") + * @param {string} ageClass - Age class (e.g. "Jugend 11", "Senioren", "Frauen", "Erwachsene") + * @returns {string} Formatted team name (e.g. "Harheimer TC (J11)") + */ + formatTeamNameWithAgeClass(teamName, ageClass) { + if (!ageClass || ageClass.trim() === '' || ageClass === 'Erwachsene') { + return teamName; + } + + // Parse age class + const ageClassLower = ageClass.toLowerCase().trim(); + + // Senioren = S + if (ageClassLower.includes('senioren')) { + return `${teamName} (S)`; + } + + // Frauen = F + if (ageClassLower.includes('frauen')) { + return `${teamName} (F)`; + } + + // Jugend XX = JXX + const jugendMatch = ageClass.match(/jugend\s+(\d+)/i); + if (jugendMatch) { + return `${teamName} (J${jugendMatch[1]})`; + } + + // Mädchen XX = MXX + const maedchenMatch = ageClass.match(/m[aä]dchen\s+(\d+)/i); + if (maedchenMatch) { + return `${teamName} (M${maedchenMatch[1]})`; + } + + // Default: return as is + return teamName; + } + generateSeasonString(date = new Date()) { const currentYear = date.getFullYear(); let seasonStartYear; @@ -47,8 +89,20 @@ class MatchService { seasonId: season.id, }, }); - const homeTeamId = await this.getOrCreateTeamId(row['HeimMannschaft'], clubId); - const guestTeamId = await this.getOrCreateTeamId(row['GastMannschaft'], clubId); + const homeTeamId = await this.getOrCreateTeamId( + row['HeimMannschaft'], + row['HeimMannschaftAltersklasse'], + clubId, + league.id, + season.id + ); + const guestTeamId = await this.getOrCreateTeamId( + row['GastMannschaft'], + row['GastMannschaftAltersklasse'], + clubId, + league.id, + season.id + ); const [location] = await Location.findOrCreate({ where: { name: row['HalleName'], @@ -90,15 +144,24 @@ class MatchService { } } - async getOrCreateTeamId(teamName, clubId) { + async getOrCreateTeamId(teamName, ageClass, clubId, leagueId, seasonId) { + // Format team name with age class + const formattedTeamName = this.formatTeamNameWithAgeClass(teamName, ageClass); + + devLog(`Team: "${teamName}" + "${ageClass}" -> "${formattedTeamName}"`); + const [team] = await Team.findOrCreate({ where: { - name: teamName, - clubId: clubId + name: formattedTeamName, + clubId: clubId, + leagueId: leagueId, + seasonId: seasonId }, defaults: { - name: teamName, - clubId: clubId + name: formattedTeamName, + clubId: clubId, + leagueId: leagueId, + seasonId: seasonId } }); return team.id; @@ -174,6 +237,10 @@ class MatchService { code: match.code, homePin: match.homePin, guestPin: match.guestPin, + homeMatchPoints: match.homeMatchPoints || 0, + guestMatchPoints: match.guestMatchPoints || 0, + isCompleted: match.isCompleted || false, + pdfUrl: match.pdfUrl, homeTeam: { name: 'Unbekannt' }, guestTeam: { name: 'Unbekannt' }, location: { name: 'Unbekannt', address: '', city: '', zip: '' }, @@ -213,13 +280,61 @@ class MatchService { if (!season) { throw new Error('Season not found'); } - const matches = await Match.findAll({ + + // Get club name from database + const club = await Club.findByPk(clubId, { attributes: ['name'] }); + if (!club) { + throw new Error('Club not found'); + } + const clubName = club.name; + + devLog(`Filtering matches for club: ${clubName}`); + + // Find all club teams in this league + const clubTeams = await ClubTeam.findAll({ where: { clubId: clubId, leagueId: leagueId - } + }, + attributes: ['id', 'name'] }); + devLog(`Club teams in league ${leagueId}: ${clubTeams.map(ct => ct.name).join(', ')}`); + + // Find all Team entries that contain our club name + const ownTeams = await Team.findAll({ + where: { + name: { + [Op.like]: `${clubName}%` + }, + leagueId: leagueId + }, + attributes: ['id', 'name'] + }); + + const ownTeamIds = ownTeams.map(t => t.id); + devLog(`Own team IDs in this league: ${ownTeamIds.join(', ')} (${ownTeams.map(t => t.name).join(', ')})`); + + // Load matches + let matches; + if (ownTeamIds.length > 0) { + // Load only matches where one of our teams is involved + matches = await Match.findAll({ + where: { + leagueId: leagueId, + [Op.or]: [ + { homeTeamId: { [Op.in]: ownTeamIds } }, + { guestTeamId: { [Op.in]: ownTeamIds } } + ] + } + }); + devLog(`Found ${matches.length} matches for our teams`); + } else { + // No own teams found - show nothing + devLog('No own teams found in this league, showing no matches'); + matches = []; + } + // Lade Team- und Location-Daten manuell const enrichedMatches = []; for (const match of matches) { @@ -234,6 +349,10 @@ class MatchService { code: match.code, homePin: match.homePin, guestPin: match.guestPin, + homeMatchPoints: match.homeMatchPoints || 0, + guestMatchPoints: match.guestMatchPoints || 0, + isCompleted: match.isCompleted || false, + pdfUrl: match.pdfUrl, homeTeam: { name: 'Unbekannt' }, guestTeam: { name: 'Unbekannt' }, location: { name: 'Unbekannt', address: '', city: '', zip: '' }, diff --git a/backend/services/memberService.js b/backend/services/memberService.js index cc1fe1a..10dab03 100644 --- a/backend/services/memberService.js +++ b/backend/services/memberService.js @@ -159,7 +159,7 @@ class MemberService { // Versuche automatischen Login mit gespeicherten Credentials try { - const loginResult = await myTischtennisService.verifyLogin(user.id); + await myTischtennisService.verifyLogin(user.id); const freshSession = await myTischtennisService.getSession(user.id); session = { cookie: freshSession.cookie, diff --git a/backend/services/myTischtennisUrlParserService.js b/backend/services/myTischtennisUrlParserService.js new file mode 100644 index 0000000..7e3451c --- /dev/null +++ b/backend/services/myTischtennisUrlParserService.js @@ -0,0 +1,245 @@ +import { devLog } from '../utils/logger.js'; + +class MyTischtennisUrlParserService { + /** + * Parse myTischtennis URL and extract configuration data + * + * Example URL: + * https://www.mytischtennis.de/click-tt/HeTTV/25--26/ligen/1.Kreisklasse/gruppe/504417/mannschaft/2995094/Harheimer_TC_(J11)/spielerbilanzen/gesamt + * + * @param {string} url - The myTischtennis URL + * @returns {Object} Parsed configuration data + */ + parseUrl(url) { + try { + // Remove trailing slash if present + url = url.trim().replace(/\/$/, ''); + + // Extract parts using regex + // Pattern: /click-tt/{association}/{season}/{type}/{groupname}/gruppe/{groupId}/mannschaft/{teamId}/{teamname}/... + const pattern = /\/click-tt\/([^\/]+)\/([^\/]+)\/([^\/]+)\/([^\/]+)\/gruppe\/([^\/]+)\/mannschaft\/([^\/]+)\/([^\/]+)/; + + const match = url.match(pattern); + + if (!match) { + throw new Error('URL format not recognized. Expected format: /click-tt/{association}/{season}/{type}/{groupname}/gruppe/{groupId}/mannschaft/{teamId}/{teamname}/...'); + } + + const [ + , + association, + seasonRaw, + type, + groupnameEncoded, + groupId, + teamId, + teamnameEncoded + ] = match; + + // Decode and process values + const seasonShort = seasonRaw.replace('--', '/'); // "25--26" -> "25/26" + const season = this.convertToFullSeason(seasonShort); // "25/26" -> "2025/2026" + const groupname = decodeURIComponent(groupnameEncoded); + const teamname = decodeURIComponent(teamnameEncoded).replace(/_/g, ' '); // "Harheimer_TC_(J11)" -> "Harheimer TC (J11)" + + const result = { + association, + season, + seasonShort, // Für API-Calls + type, + groupname, + groupId, + teamId, + teamname, + originalUrl: url + }; + + devLog('Parsed myTischtennis URL:', result); + + return result; + } catch (error) { + console.error('Error parsing myTischtennis URL:', error); + throw error; + } + } + + /** + * Convert short season format to full format + * "25/26" -> "2025/2026" + * "24/25" -> "2024/2025" + */ + convertToFullSeason(seasonShort) { + const parts = seasonShort.split('/'); + if (parts.length !== 2) { + return seasonShort; + } + + const year1 = parseInt(parts[0]); + const year2 = parseInt(parts[1]); + + // Determine century based on year1 + // If year1 < 50, assume 20xx, otherwise 19xx + const century1 = year1 < 50 ? 2000 : 1900; + const century2 = year2 < 50 ? 2000 : 1900; + + const fullYear1 = century1 + year1; + const fullYear2 = century2 + year2; + + return `${fullYear1}/${fullYear2}`; + } + + /** + * Convert full season format to short format + * "2025/2026" -> "25/26" + * "2024/2025" -> "24/25" + */ + convertToShortSeason(seasonFull) { + const parts = seasonFull.split('/'); + if (parts.length !== 2) { + return seasonFull; + } + + const year1 = parseInt(parts[0]); + const year2 = parseInt(parts[1]); + + const shortYear1 = String(year1).slice(-2); + const shortYear2 = String(year2).slice(-2); + + return `${shortYear1}/${shortYear2}`; + } + + /** + * Fetch additional team data from myTischtennis + * + * @param {Object} parsedUrl - Parsed URL data from parseUrl() + * @param {string} cookie - Authentication cookie + * @param {string} accessToken - Access token + * @returns {Object} Additional team data + */ + async fetchTeamData(parsedUrl, cookie, accessToken) { + try { + const { association, seasonShort, type, groupname, groupId, teamId, teamname } = parsedUrl; + + const seasonStr = seasonShort.replace('/', '--'); + const teamnameEncoded = encodeURIComponent(teamname.replace(/\s/g, '_')); + + // Build the API URL + const apiUrl = `https://www.mytischtennis.de/click-tt/${association}/${seasonStr}/${type}/${encodeURIComponent(groupname)}/gruppe/${groupId}/mannschaft/${teamId}/${teamnameEncoded}/spielerbilanzen/gesamt?_data=routes%2Fclick-tt%2B%2F%24association%2B%2F%24season%2B%2F%24type%2B%2F%28%24groupname%29.gruppe.%24urlid_.mannschaft.%24teamid.%24teamname%2B%2Fspielerbilanzen.%24filter`; + + devLog(`Fetching team data from: ${apiUrl}`); + + const response = await fetch(apiUrl, { + headers: { + 'Cookie': cookie || '', + 'Authorization': `Bearer ${accessToken}`, + 'Accept': 'application/json', + 'User-Agent': 'Mozilla/5.0' + } + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + + // Extract additional information + const teamData = { + clubId: null, + clubName: null, + teamName: null, + leagueName: null, + leagueShortName: null, + region: null, + tableRank: null, + matchesWon: null, + matchesLost: null + }; + + if (data.data && data.data.head_infos) { + const headInfos = data.data.head_infos; + teamData.clubId = data.data.balancesheet?.[0]?.club_id || null; + teamData.clubName = headInfos.club_name; + teamData.teamName = headInfos.team_name; + teamData.leagueName = headInfos.league_name; + teamData.region = headInfos.region; + teamData.tableRank = headInfos.team_table_rank; + teamData.matchesWon = headInfos.team_matches_won; + teamData.matchesLost = headInfos.team_matches_lost; + } + + devLog('Fetched team data:', teamData); + + return { + ...parsedUrl, + ...teamData, + fullData: data + }; + } catch (error) { + console.error('Error fetching team data:', error); + throw error; + } + } + + /** + * Complete configuration from URL + * Combines URL parsing and data fetching + * + * @param {string} url - The myTischtennis URL + * @param {string} cookie - Authentication cookie (optional) + * @param {string} accessToken - Access token (optional) + * @returns {Object} Complete configuration data + */ + async getCompleteConfig(url, cookie = null, accessToken = null) { + const parsedUrl = this.parseUrl(url); + + if (cookie && accessToken) { + return await this.fetchTeamData(parsedUrl, cookie, accessToken); + } + + return parsedUrl; + } + + /** + * Validate if URL is a valid myTischtennis team URL + * + * @param {string} url - The URL to validate + * @returns {boolean} True if valid + */ + isValidTeamUrl(url) { + try { + this.parseUrl(url); + return true; + } catch { + return false; + } + } + + /** + * Build myTischtennis URL from components + * + * @param {Object} config - Configuration object + * @returns {string} The constructed URL + */ + buildUrl(config) { + const { + association, + season, + type = 'ligen', + groupname, + groupId, + teamId, + teamname + } = config; + + // Convert full season to short format for URL + const seasonShort = this.convertToShortSeason(season); + const seasonStr = seasonShort.replace('/', '--'); + const teamnameEncoded = encodeURIComponent(teamname.replace(/\s/g, '_')); + const groupnameEncoded = encodeURIComponent(groupname); + + return `https://www.mytischtennis.de/click-tt/${association}/${seasonStr}/${type}/${groupnameEncoded}/gruppe/${groupId}/mannschaft/${teamId}/${teamnameEncoded}/spielerbilanzen/gesamt`; + } +} + +export default new MyTischtennisUrlParserService(); diff --git a/backend/services/pdfParserService.js b/backend/services/pdfParserService.js index 6be9e57..44fdeb2 100644 --- a/backend/services/pdfParserService.js +++ b/backend/services/pdfParserService.js @@ -88,11 +88,21 @@ class PDFParserService { const result = strategy.fn(lines, clubId); if (result.matches.length > 0) { + console.log(`[PDF Parser] Using strategy: ${strategy.name}, found ${result.matches.length} matches`); + if (result.matches.length > 0) { + console.log(`[PDF Parser] First match sample:`, { + homeTeamName: result.matches[0].homeTeamName, + guestTeamName: result.matches[0].guestTeamName, + date: result.matches[0].date, + rawLine: result.matches[0].rawLine + }); + } matches.push(...result.matches); metadata.parsedMatches += result.matches.length; break; // Erste erfolgreiche Strategie verwenden } } catch (strategyError) { + console.log(`[PDF Parser] Strategy ${strategy.name} failed:`, strategyError.message); errors.push(`Strategy ${strategy.name} failed: ${strategyError.message}`); } } @@ -148,16 +158,21 @@ class PDFParserService { const [, day, month, year] = dateMatch; const date = new Date(`${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`); - // Suche nach Zeit-Pattern direkt nach dem Datum (hh:mm) - Format: Wt.dd.mm.yyyyhh:MM - const timeMatch = line.match(/(\d{1,2})[./](\d{1,2})[./](\d{4})(\d{1,2}):(\d{2})/); + // Suche nach Zeit-Pattern (hh:mm) - kann direkt nach Datum oder mit Leerzeichen sein + const timeMatch = line.match(/(\d{1,2}):(\d{2})/); let time = null; if (timeMatch) { - time = `${timeMatch[4].padStart(2, '0')}:${timeMatch[5]}`; + time = `${timeMatch[1].padStart(2, '0')}:${timeMatch[2]}`; } - // Entferne Datum und Zeit vom Anfang der Zeile - const cleanLine = line.replace(/^[A-Za-z]{2}\.(\d{1,2})[./](\d{1,2})[./](\d{4})(\d{1,2}):(\d{2})\s*/, ''); + // Entferne Datum (mit optionalem Wochentag) und Zeit vom Anfang der Zeile + // Format: "Sa. 06.09.2025 10:00" oder "06.09.2025 10:00" + const cleanLine = line + .replace(/^[A-Za-z]{2,3}\.\s*/, '') // Entferne Wochentag (z.B. "Sa. ", "Mo. ", "Fre. ") + .replace(/^\d{1,2}[./]\d{1,2}[./]\d{4}/, '') // Entferne Datum + .replace(/^\s*\d{1,2}:\d{2}/, '') // Entferne Zeit + .trim(); // Entferne Nummerierung am Anfang (z.B. "(1)") const cleanLine2 = cleanLine.replace(/^\(\d+\)/, ''); @@ -183,18 +198,26 @@ class PDFParserService { const pin = pinMatch[1]; teamsPart = cleanLine3.substring(0, cleanLine3.length - pin.length).trim(); - // PIN gehört zu dem Team, das direkt vor der PIN steht - // Analysiere die Position der PIN in der ursprünglichen Zeile - const pinIndex = cleanLine3.lastIndexOf(pin); - const teamsPartIndex = cleanLine3.indexOf(teamsPart); + // Die PIN gehört immer zu "Harheimer TC" + // Prüfe, ob "Harheimer TC" am Anfang oder am Ende steht + const harheimerIndex = teamsPart.indexOf('Harheimer TC'); - // Wenn PIN direkt nach dem Teams-Part steht, gehört sie zur Heimmannschaft - // Wenn PIN zwischen den Teams steht, gehört sie zur Gastmannschaft - if (pinIndex === teamsPartIndex + teamsPart.length) { - // PIN steht direkt nach den Teams -> Heimmannschaft - homePin = pin; + if (harheimerIndex >= 0) { + // "Harheimer TC" gefunden + let beforeHarheimer = teamsPart.substring(0, harheimerIndex).trim(); + + // Entferne führende Spielnummer (z.B. "1", "2", etc.) + beforeHarheimer = beforeHarheimer.replace(/^\d+/, '').trim(); + + if (beforeHarheimer && beforeHarheimer.length > 0) { + // Es gibt einen Team-Namen vor "Harheimer TC" → Harheimer ist Gastteam → guestPin + guestPin = pin; + } else { + // "Harheimer TC" steht am Anfang (nur Spielnummer davor) → Harheimer ist Heimteam → homePin + homePin = pin; + } } else { - // PIN steht zwischen den Teams -> Gastmannschaft + // "Harheimer TC" nicht gefunden → Standardlogik: PIN gehört zum Gastteam guestPin = pin; } } @@ -249,14 +272,41 @@ class PDFParserService { } else { // Fallback: Versuche mit einzelnen Leerzeichen zu trennen - // Strategie 1: Suche nach "Harheimer TC" als Heimteam + // Strategie 1: Suche nach "Harheimer TC" als Heimteam oder Gastteam if (teamsPart.includes('Harheimer TC')) { const harheimerIndex = teamsPart.indexOf('Harheimer TC'); - homeTeamName = 'Harheimer TC'; - guestTeamName = teamsPart.substring(harheimerIndex + 'Harheimer TC'.length).trim(); - // Entferne Klammern aus Gastteam - guestTeamName = guestTeamName.replace(/\([^)]*\)/g, '').trim(); + // Prüfe, ob "Harheimer TC" am Anfang oder am Ende steht + let beforeHarheimer = teamsPart.substring(0, harheimerIndex).trim(); + let afterHarheimer = teamsPart.substring(harheimerIndex + 'Harheimer TC'.length).trim(); + + // Entferne Spielnummern aus beiden Teilen + beforeHarheimer = beforeHarheimer.replace(/^\d+/, '').trim(); + afterHarheimer = afterHarheimer.replace(/^\d+/, '').trim(); + + if (beforeHarheimer && !afterHarheimer) { + // "Harheimer TC" ist am Ende → Harheimer ist Gastteam + guestTeamName = 'Harheimer TC'; + homeTeamName = beforeHarheimer + .replace(/\([^)]*\)/g, '') // Entferne Klammern + .trim(); + } else if (!beforeHarheimer && afterHarheimer) { + // "Harheimer TC" ist am Anfang → Harheimer ist Heimteam + homeTeamName = 'Harheimer TC'; + guestTeamName = afterHarheimer + .replace(/\([^)]*\)/g, '') // Entferne Klammern + .trim(); + } else if (beforeHarheimer && afterHarheimer) { + // "Harheimer TC" ist in der Mitte → verwende Position als Hinweis + // Normalerweise: Heimteam zuerst, dann Gastteam + homeTeamName = beforeHarheimer + .replace(/\([^)]*\)/g, '') // Entferne Klammern + .trim(); + guestTeamName = 'Harheimer TC'; + } else { + // Nur "Harheimer TC" ohne andere Teams → ungültig + continue; + } } else { // Strategie 2: Suche nach Großbuchstaben am Anfang des zweiten Teams @@ -284,6 +334,8 @@ class PDFParserService { debugInfo = `guestPin: "${guestPin}"`; } + console.log(`[PDF Parser] Parsed match: ${homeTeamName} vs ${guestTeamName}, ${debugInfo}`); + matches.push({ date: date, time: time, @@ -554,40 +606,49 @@ class PDFParserService { } else { // Fallback: Versuche Teams direkt zu finden - const homeTeam = await Team.findOne({ + let homeTeam = await Team.findOne({ where: { name: matchData.homeTeamName, clubId: matchData.clubId } }); - const guestTeam = await Team.findOne({ + let guestTeam = await Team.findOne({ where: { name: matchData.guestTeamName, clubId: matchData.clubId } }); - // Debug: Zeige alle verfügbaren Teams für diesen Club + // If exact match failed, try fuzzy matching if (!homeTeam || !guestTeam) { const allTeams = await Team.findAll({ where: { clubId: matchData.clubId }, attributes: ['id', 'name'] }); + console.log(`[PDF Parser] Available teams in club: ${allTeams.map(t => t.name).join(', ')}`); - // Versuche Fuzzy-Matching für Team-Namen - const homeTeamFuzzy = allTeams.find(t => - t.name.toLowerCase().includes(matchData.homeTeamName.toLowerCase()) || - matchData.homeTeamName.toLowerCase().includes(t.name.toLowerCase()) - ); - const guestTeamFuzzy = allTeams.find(t => - t.name.toLowerCase().includes(matchData.guestTeamName.toLowerCase()) || - matchData.guestTeamName.toLowerCase().includes(t.name.toLowerCase()) - ); - - if (homeTeamFuzzy) { + // Fuzzy-Matching für Team-Namen + if (!homeTeam) { + homeTeam = allTeams.find(t => + t.name.toLowerCase().includes(matchData.homeTeamName.toLowerCase()) || + matchData.homeTeamName.toLowerCase().includes(t.name.toLowerCase()) + ); + + if (homeTeam) { + console.log(`[PDF Parser] Found home team via fuzzy match: "${matchData.homeTeamName}" → "${homeTeam.name}"`); + } } - if (guestTeamFuzzy) { + + if (!guestTeam) { + guestTeam = allTeams.find(t => + t.name.toLowerCase().includes(matchData.guestTeamName.toLowerCase()) || + matchData.guestTeamName.toLowerCase().includes(t.name.toLowerCase()) + ); + + if (guestTeam) { + console.log(`[PDF Parser] Found guest team via fuzzy match: "${matchData.guestTeamName}" → "${guestTeam.name}"`); + } } } diff --git a/backend/services/schedulerService.js b/backend/services/schedulerService.js index eb0ca0f..59d4c13 100644 --- a/backend/services/schedulerService.js +++ b/backend/services/schedulerService.js @@ -1,5 +1,6 @@ import cron from 'node-cron'; import autoUpdateRatingsService from './autoUpdateRatingsService.js'; +import autoFetchMatchResultsService from './autoFetchMatchResultsService.js'; import { devLog } from '../utils/logger.js'; class SchedulerService { @@ -35,9 +36,26 @@ class SchedulerService { 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)'); } /** @@ -86,6 +104,20 @@ class SchedulerService { } } + /** + * 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 */ diff --git a/backend/uploads/team-documents/10_code_list_1760455939125.pdf b/backend/uploads/team-documents/10_code_list_1760455939125.pdf new file mode 100644 index 0000000..f8be61f Binary files /dev/null and b/backend/uploads/team-documents/10_code_list_1760455939125.pdf differ diff --git a/backend/uploads/team-documents/10_pin_list_1760455950179.pdf b/backend/uploads/team-documents/10_pin_list_1760455950179.pdf new file mode 100644 index 0000000..07db070 Binary files /dev/null and b/backend/uploads/team-documents/10_pin_list_1760455950179.pdf differ diff --git a/backend/uploads/team-documents/1_code_list_1760470543608.pdf b/backend/uploads/team-documents/1_code_list_1760470543608.pdf new file mode 100644 index 0000000..f8be61f Binary files /dev/null and b/backend/uploads/team-documents/1_code_list_1760470543608.pdf differ diff --git a/backend/uploads/team-documents/1_pin_list_1760471862054.pdf b/backend/uploads/team-documents/1_pin_list_1760471862054.pdf new file mode 100644 index 0000000..07db070 Binary files /dev/null and b/backend/uploads/team-documents/1_pin_list_1760471862054.pdf differ diff --git a/frontend/src/views/ScheduleView.vue b/frontend/src/views/ScheduleView.vue index a4f77a0..9ad872a 100644 --- a/frontend/src/views/ScheduleView.vue +++ b/frontend/src/views/ScheduleView.vue @@ -34,6 +34,7 @@ Uhrzeit Heimmannschaft Gastmannschaft + Ergebnis Altersklasse Code Heim-PIN @@ -47,6 +48,12 @@ {{ match.time ? match.time.toString().slice(0, 5) + ' Uhr' : 'N/A' }} + + + {{ match.homeMatchPoints }}:{{ match.guestMatchPoints }} + + + {{ match.leagueDetails?.name || 'N/A' }} @@ -157,6 +164,34 @@ export default { }; }, methods: { + getResultClass(match) { + if (!match.isCompleted) { + return ''; + } + + // Check if our club's team won or lost + const isOurTeamHome = this.isOurTeam(match.homeTeam?.name); + const isOurTeamGuest = this.isOurTeam(match.guestTeam?.name); + + if (isOurTeamHome) { + // We are home team + return match.homeMatchPoints > match.guestMatchPoints ? 'completed won' : 'completed lost'; + } else if (isOurTeamGuest) { + // We are guest team + return match.guestMatchPoints > match.homeMatchPoints ? 'completed won' : 'completed lost'; + } + + return 'completed'; + }, + + isOurTeam(teamName) { + if (!teamName || !this.currentClubName) { + return false; + } + // Check if team name starts with our club name + return teamName.startsWith(this.currentClubName); + }, + // Dialog Helper Methods async showInfo(title, message, details = '', type = 'info') { this.infoDialog = { @@ -512,6 +547,36 @@ td { white-space: nowrap; } +.result-cell { + text-align: center; + font-weight: 600; +} + +.result-score { + font-size: 1.1em; +} + +.result-pending { + color: var(--text-muted); + font-style: italic; +} + +.result-cell.completed.won { + background-color: #f0f9f0; +} + +.result-cell.completed.won .result-score { + color: #28a745; +} + +.result-cell.completed.lost { + background-color: #fff5f5; +} + +.result-cell.completed.lost .result-score { + color: #dc3545; +} + .hover-info { margin-top: 10px; background-color: #eef; diff --git a/frontend/src/views/TeamManagementView.vue b/frontend/src/views/TeamManagementView.vue index 6c5d788..f91e31d 100644 --- a/frontend/src/views/TeamManagementView.vue +++ b/frontend/src/views/TeamManagementView.vue @@ -57,6 +57,62 @@ + +
+
+

🏓 MyTischtennis Integration

+
+
+ + ✓ Vollständig konfiguriert + + + ⚠ {{ getMyTischtennisStatus(teamToEdit).missing }} + + + ✗ Nicht konfiguriert + +
+ +
+
+
+ +
+ ⏳ Konfiguriere automatisch... +
+
+ + +
+ ⚠️ {{ myTischtennisError }} +
+ + +
+ ✅ {{ myTischtennisSuccess }} +
+
+
@@ -162,6 +218,20 @@ Erstellt: {{ formatDate(team.createdAt) }}
+ + +
+ 🏓 MyTischtennis: + + ✓ Vollständig konfiguriert + + + ⚠ Teilweise konfiguriert + + + ✗ Nicht konfiguriert + +
@@ -210,8 +280,6 @@ - - +