From 7f0b681e883ebaf81c196274cb6fc23e12fddd2f Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Sat, 4 Oct 2025 01:38:27 +0200 Subject: [PATCH 001/113] =?UTF-8?q?Erm=C3=B6glicht=20die=20Bearbeitung=20v?= =?UTF-8?q?on=20Spielergebnissen=20in=20TournamentsView.vue=20durch=20klic?= =?UTF-8?q?kbare=20Labels=20und=20editierbare=20Eingabefelder.=20F=C3=BCgt?= =?UTF-8?q?=20Logik=20zum=20Speichern=20und=20Abbrechen=20von=20=C3=84nder?= =?UTF-8?q?ungen=20hinzu.=20Aktualisiert=20das=20Styling=20f=C3=BCr=20Eing?= =?UTF-8?q?abefelder=20und=20klickbare=20Texte,=20um=20die=20Benutzererfah?= =?UTF-8?q?rung=20zu=20verbessern.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/views/TournamentsView.vue | 101 +++++++++++++++++++++---- 1 file changed, 87 insertions(+), 14 deletions(-) diff --git a/frontend/src/views/TournamentsView.vue b/frontend/src/views/TournamentsView.vue index 04e567d..ac220f5 100644 --- a/frontend/src/views/TournamentsView.vue +++ b/frontend/src/views/TournamentsView.vue @@ -170,12 +170,24 @@ + + + diff --git a/frontend/src/components/BaseDialog.vue b/frontend/src/components/BaseDialog.vue index 3a46833..b96c78d 100644 --- a/frontend/src/components/BaseDialog.vue +++ b/frontend/src/components/BaseDialog.vue @@ -294,7 +294,7 @@ export default { .dialog-header { background: linear-gradient(135deg, var(--primary-color), var(--primary-hover)); color: white; - padding: 12px 16px; + padding: 4px 16px; display: flex; justify-content: space-between; align-items: center; @@ -353,7 +353,7 @@ export default { /* Footer */ .dialog-footer { - padding: 12px 16px; + padding: 4px 16px; border-top: 1px solid var(--border-color); display: flex; justify-content: flex-end; diff --git a/frontend/src/components/CsvImportDialog.vue b/frontend/src/components/CsvImportDialog.vue new file mode 100644 index 0000000..23ffaad --- /dev/null +++ b/frontend/src/components/CsvImportDialog.vue @@ -0,0 +1,185 @@ + + + + + + diff --git a/frontend/src/components/MemberNotesDialog.vue b/frontend/src/components/MemberNotesDialog.vue new file mode 100644 index 0000000..863bacd --- /dev/null +++ b/frontend/src/components/MemberNotesDialog.vue @@ -0,0 +1,249 @@ + + + + + + diff --git a/frontend/src/components/MemberSelectionDialog.vue b/frontend/src/components/MemberSelectionDialog.vue new file mode 100644 index 0000000..e2dbaf3 --- /dev/null +++ b/frontend/src/components/MemberSelectionDialog.vue @@ -0,0 +1,260 @@ + + + + + + diff --git a/frontend/src/components/QuickAddMemberDialog.vue b/frontend/src/components/QuickAddMemberDialog.vue new file mode 100644 index 0000000..23635be --- /dev/null +++ b/frontend/src/components/QuickAddMemberDialog.vue @@ -0,0 +1,208 @@ + + + + + + diff --git a/frontend/src/components/TagHistoryDialog.vue b/frontend/src/components/TagHistoryDialog.vue new file mode 100644 index 0000000..59012c8 --- /dev/null +++ b/frontend/src/components/TagHistoryDialog.vue @@ -0,0 +1,154 @@ + + + + + + diff --git a/frontend/src/components/TrainingDetailsDialog.vue b/frontend/src/components/TrainingDetailsDialog.vue new file mode 100644 index 0000000..be349ad --- /dev/null +++ b/frontend/src/components/TrainingDetailsDialog.vue @@ -0,0 +1,222 @@ + + + + + + diff --git a/frontend/src/views/DiaryView.vue b/frontend/src/views/DiaryView.vue index 1aa1985..67bc258 100644 --- a/frontend/src/views/DiaryView.vue +++ b/frontend/src/views/DiaryView.vue @@ -289,55 +289,31 @@ - + + - + + -
-
-
- - -
-
- - -
- - -
    -
  • {{ accident.firstName + ' ' + accident.lastName - + - ': ' - + accident.accident}}
  • -
-
-
+ + - + @@ -457,6 +383,10 @@ import InfoDialog from '../components/InfoDialog.vue'; import ConfirmDialog from '../components/ConfirmDialog.vue'; import ImageDialog from '../components/ImageDialog.vue'; import BaseDialog from '../components/BaseDialog.vue'; +import MemberNotesDialog from '../components/MemberNotesDialog.vue'; +import TagHistoryDialog from '../components/TagHistoryDialog.vue'; +import AccidentFormDialog from '../components/AccidentFormDialog.vue'; +import QuickAddMemberDialog from '../components/QuickAddMemberDialog.vue'; export default { name: 'DiaryView', @@ -466,7 +396,11 @@ export default { InfoDialog, ConfirmDialog, ImageDialog, - BaseDialog + BaseDialog, + MemberNotesDialog, + TagHistoryDialog, + AccidentFormDialog, + QuickAddMemberDialog }, data() { return { @@ -2426,4 +2360,99 @@ img { cursor: not-allowed !important; opacity: 0.6 !important; } + +/* Notes Modal Styles */ +.notes-modal-content { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.notes-header-info { + padding: 0.5rem; + background: var(--background-light); + border-radius: 4px; + font-size: 0.9rem; + color: var(--text-muted); +} + +.notes-body { + display: flex; + gap: 1.5rem; +} + +.notes-left { + flex-shrink: 0; +} + +.member-image { + width: 250px; + height: 250px; + object-fit: cover; + border-radius: 8px; + border: 1px solid var(--border-color); +} + +.notes-right { + flex: 1; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.note-textarea { + width: 100%; + padding: 0.5rem; + border: 1px solid var(--border-color); + border-radius: 4px; + font-family: inherit; + resize: vertical; +} + +.notes-list h4 { + margin: 0 0 0.5rem 0; + font-size: 1rem; +} + +.notes-list ul { + list-style: none; + padding: 0; + margin: 0; +} + +.note-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem; + margin-bottom: 0.5rem; + background: var(--background-light); + border-radius: 4px; +} + +.note-content { + flex: 1; +} + +@media (max-width: 768px) { + .notes-body { + flex-direction: column; + } + + .member-image { + width: 100%; + height: auto; + max-height: 300px; + } +} + +/* Render Container */ +.render-container { + display: flex; + justify-content: center; + align-items: center; + padding: 1rem; + background: #f5f5f5; + border-radius: 4px; +} diff --git a/frontend/src/views/MembersView.vue b/frontend/src/views/MembersView.vue index 5037cf5..aacea9c 100644 --- a/frontend/src/views/MembersView.vue +++ b/frontend/src/views/MembersView.vue @@ -138,22 +138,18 @@ @rotate="handleRotate" /> - + + @@ -186,12 +182,16 @@ import PDFGenerator from '../components/PDFGenerator.js'; import InfoDialog from '../components/InfoDialog.vue'; import ConfirmDialog from '../components/ConfirmDialog.vue'; import ImageViewerDialog from '../components/ImageViewerDialog.vue'; +import BaseDialog from '../components/BaseDialog.vue'; +import MemberNotesDialog from '../components/MemberNotesDialog.vue'; export default { name: 'MembersView', components: { InfoDialog, ConfirmDialog, - ImageViewerDialog + ImageViewerDialog, + BaseDialog, + MemberNotesDialog }, computed: { ...mapGetters(['isAuthenticated', 'currentClub']), @@ -790,4 +790,24 @@ table td { .rotate-btn:active { transform: translateY(0); } + +/* Dialog-spezifische Styles */ +.member-notes-content { + min-height: 200px; +} + +.btn-primary { + padding: 0.5rem 1rem; + border: none; + border-radius: 4px; + background: linear-gradient(135deg, var(--primary-color), var(--primary-hover)); + color: white; + font-weight: 600; + cursor: pointer; + transition: opacity 0.2s; +} + +.btn-primary:hover { + opacity: 0.9; +} diff --git a/frontend/src/views/OfficialTournaments.vue b/frontend/src/views/OfficialTournaments.vue index d5d29ed..fd95321 100644 --- a/frontend/src/views/OfficialTournaments.vue +++ b/frontend/src/views/OfficialTournaments.vue @@ -381,44 +381,21 @@ - + + @@ -450,8 +427,16 @@ import PDFGenerator from '../components/PDFGenerator.js'; import InfoDialog from '../components/InfoDialog.vue'; import ConfirmDialog from '../components/ConfirmDialog.vue'; +import BaseDialog from '../components/BaseDialog.vue'; +import MemberSelectionDialog from '../components/MemberSelectionDialog.vue'; export default { name: 'OfficialTournaments', + components: { + InfoDialog, + ConfirmDialog, + BaseDialog, + MemberSelectionDialog + }, data() { return { // Dialog States @@ -1032,6 +1017,32 @@ export default { }, selectAllMembers() { this.selectedMemberIds = this.activeMembers.map(m => m.id); }, deselectAllMembers() { this.selectedMemberIds = []; }, + handleMemberToggle({ memberId, checked }) { + this.selectedMemberIdForDialog = memberId; + if (checked && !this.selectedMemberIds.includes(memberId)) { + this.selectedMemberIds.push(memberId); + } else if (!checked) { + this.selectedMemberIds = this.selectedMemberIds.filter(id => id !== memberId); + } + }, + handleRecommendationToggle({ memberId, key, checked }) { + if (checked) { + if (!this.memberRecommendations[memberId]) { + this.$set ? this.$set(this.memberRecommendations, memberId, new Set()) : (this.memberRecommendations[memberId] = new Set()); + } + this.memberRecommendations[memberId].add(key); + } else { + if (this.memberRecommendations[memberId]) { + this.memberRecommendations[memberId].delete(key); + } + } + }, + getRecommendedKeys() { + if (!this.selectedMemberIdForDialog || !this.memberRecommendations[this.selectedMemberIdForDialog]) { + return []; + } + return Array.from(this.memberRecommendations[this.selectedMemberIdForDialog]); + }, splitDateTime(str) { if (!str) return { date: '–', time: '–' }; const s = String(str); @@ -1464,6 +1475,25 @@ th, td { border-bottom: 1px solid var(--border-color); padding: 0.5rem; text-ali font-weight: 700; color: #28a745; } + +/* Dialog-spezifische Styles */ +.member-dialog-controls { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.member-dialog-layout { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.5rem; +} + +@media (max-width: 768px) { + .member-dialog-layout { + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/views/ScheduleView.vue b/frontend/src/views/ScheduleView.vue index b3a733e..a4f77a0 100644 --- a/frontend/src/views/ScheduleView.vue +++ b/frontend/src/views/ScheduleView.vue @@ -74,17 +74,12 @@ - + + @@ -118,14 +113,18 @@ import MatchReportDialog from '../components/MatchReportDialog.vue'; import InfoDialog from '../components/InfoDialog.vue'; import ConfirmDialog from '../components/ConfirmDialog.vue'; +import BaseDialog from '../components/BaseDialog.vue'; +import CsvImportDialog from '../components/CsvImportDialog.vue'; export default { name: 'ScheduleView', components: { SeasonSelector, - MatchReportDialog - , + MatchReportDialog, InfoDialog, - ConfirmDialog}, + ConfirmDialog, + BaseDialog, + CsvImportDialog + }, computed: { ...mapGetters(['isAuthenticated', 'currentClub', 'clubs', 'currentClubName']), }, @@ -198,8 +197,9 @@ export default { this.showImportModal = false; this.selectedFile = null; }, - onFileSelected(event) { - this.selectedFile = event.target.files[0]; + handleCsvImport(file) { + this.selectedFile = file; + this.importCSV(); }, async importCSV() { if (!this.selectedFile) return; diff --git a/frontend/src/views/TrainingStatsView.vue b/frontend/src/views/TrainingStatsView.vue index c045e2f..65e94d7 100644 --- a/frontend/src/views/TrainingStatsView.vue +++ b/frontend/src/views/TrainingStatsView.vue @@ -113,52 +113,25 @@ - + + + + diff --git a/frontend/src/views/MyTischtennisAccount.vue b/frontend/src/views/MyTischtennisAccount.vue index 26f269f..7c7fdc6 100644 --- a/frontend/src/views/MyTischtennisAccount.vue +++ b/frontend/src/views/MyTischtennisAccount.vue @@ -34,9 +34,20 @@ {{ formatDate(account.lastLoginAttempt) }} +
+ + {{ formatDate(account.lastUpdateRatings) }} +
+ +
+ + {{ account.autoUpdateRatings ? 'Aktiviert' : 'Deaktiviert' }} +
+
+
@@ -66,6 +77,12 @@ @close="closeDialog" @saved="onAccountSaved" /> + + + @@ -93,16 +110,18 @@ +``` + +## 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 0000000000000000000000000000000000000000..f8be61f76bcd02c580d884291d0d80ad4208f3b1 GIT binary patch literal 27910 zcmbTd1y~zjw>}C*i)(RrDI}0Uf`{O)1xm4EL4y`8?p}%%cP&zAffg<94yDDlxNFf% zf9E^re1Ey;xzEirPiFR-cdxzH-m})cYbIgSkdb>1`KZuvh!{!#MSL&nU_ zl95wJL;xTF1_A&;fxkOp9)JJ`0Kmb8BO!rf>1gqHWP$&-fRT}3jZqNiai?PGXzhkz z6ns3V^|vi&WAFAjEWe!nqgokDbEJjkUlp!yk0v|dcxLXWS!?JRJPSDV$s)yHP<@}* zub3Z#$-2o3jlyvgPJ5P)#~p+V%<20$Qy(XHJ|z(bhR&3Y1(8NiyQYk+ zrOhp{2`pJ(Z)^8ppTltY*8QN&W8|&-$3?w!lmsUBPbAu=Z&66Tm5G^Hi^@4EaH5@} zn4&%BW|b#Zx^ZS4wHSX4P^}Bui=LqPR13s5h%9q|#-RNZD~LgR7aKie zd;4}@?++!4{_8X9HFaEwODxyI6RYkc64ZnPlS(}^w&NE${LiN^)yZEvy>uH4XA3aAyD9X?b3Zv4)Kp8D+ zKr>dr$YPtzI}VgBZVagtDxTQX`{&`WGU%33sm21x60ioO(6&(ErZ~I-_)h4K-*K;k zi9TU`lEPC!7fV2`4_?ScAq&)U!qy2E8N*J8(Lit;zhgLIyuxZ|7YE?5qcErh>zSf9 zV+|6an=z8egcdSlD3DKt;W4pWU_hnm6ZpxVgu=&(D3pTh;Xb*{rFi9GJ9$=H99v>; z7>!Ty;qkeO2!deB({*5 zN`bBrV*-OJz=G-hJJEODeYWdd796z` zKv_p-&#R6Y(_$dQC-jOZKZtfi@Vi}Vynf=? z8AYa?wq)`+8HwspwQovk`OKvQIzHUdjp4HjEpl1Kc{V#By_d9=5f>~D5%r8}#KI(m zWTRx&#BH1-B`jOEZhGENT0;C%B;+1gnK;aN+TZ=;7-t7E*RXQ2*s%1!<4v+prA@wN z?M`q>ib@q>oMt=Mq2{V&+h@sQdaHAte%6eq+9Mstrp@X4;&%-%??<*>o(vYBS8p`l zXsQ{H81N}pDIJv0H;Wl9%1NlTzG~I;QwuCNEh74;QRSq#!UoSY8&ojW5xAzpjjD~} zi?WG2kAg=HD(xttDtRgGE2$?R52$Q#Zn$hnY+#UMkgF%}b60Z5>pGXKOjAyGmZOy` z+wR!HY<;F1r>v&grj4dQPa96>AQC_o;6yMrh#rv-PD?*dmj@l%w7=|m3495D=}~9q zK)SEL&obM&tFb%u!+8c=K{La&^K++g_sfpT&V;+T+o@ZVo5^n3R_G4h7|qyCIkF-r zcd1m;ObX~E*eAFJyqeS)Ot929zcLlKX_>s9KVR5gqO_rLj)|id2NhHMP#=O11n*zJ zw(StK5lj=*1>y@=g6XU$J}eHs{*5n~l$K>s^$X*3{BQQ(WxuV5A3lxML^L33J}l(6 z27baubDkgd!MNNMf>Aq(B~T0xKm4tr0AwW)Kq6Zs9uWQ z=iQ);vN?hVKac#T9G|)ySHkCn+4Aumd)vRg#!ma?reo)0jf}q!PVeTBylWQ^nB^B@oO zC4zSZk@(vL-65D^LZN()F7s}PNw*Kq`mmortp}7KE zdA^dv(#%ZF{EWqj#g2tS|DvL^LeHM&4b&&jyq{}rNX9@eS$0^?P>vxcJGv&WEFvww zF1|AEE$JqS9z_G$A-TVR9~%>Ao;Yy^WwvLw@=A~EO+!C&a9$|0FiR()OI=7^BB}Yi z>n6BJiYr6AQQPJt!*)9;_RHY}`()*W-GrnCx>e>N+2Fbrft8Kr{$P6ln*oV;!9!*n zXAXMvtv)yF1aoxfqIp^GL~mS@T~A$0JfMdnbu?|FewSHdcg?lU9zH=AXX}F-hXXm+ zBY&EHJbCxTxc$mFy2X8or7ix>53zLpR`;g$SuHM-e49d->@A-a`Oj)_7N93FS9>Qa6`g{3P5?OXpCg$n-3=2n!k*S zv~*vde-kP8{?b!b-14ISjmiE5@x{A$1w!(3JL@}9I}&u!;uoJ5zqNjbRpzZiu05}h zDPjX-(KtSEq-PXnM5VW8u4k|3oQO{^+0V|;GtX&F;4~E1jbssszCDx=XK0#po86h) z-j}uhp7uqM-yi3}X0PJrSF7ugtApgn`&V#pH(5peU5+KbX^WAhcXmOWM22(LXNn$ zCWl;ltcNxS_|r|iTO0k~)+TF+R>a;QgpC z`bZ6Ryqqi<`Bg35%q+~@%>GKW-Cw)?Bh*(_`8P5t^jFEhu|_dT&qtnXXX(cH+S1y_ zQJnd(sg0S@#zLG~53CAMb%I+WY!tj*EVaGWbj-c&%%K*{k`iK`BAyOT4v(WTdOFxU zx{7#;Gn+l~SWA(|6`?+}xZ*`1w6NJor2W`H(Kw z{6Hua$`26W7Z7;+SS`P|W!o8tLvP&ir2r|LU|b7qLRRIGDLfm^nGw+nAgE z)y(f?VI{`@->3e1@gJYRczZKPYj-niO9{{C?v8ejNDs$U3gU(t^%VJY#K`9B8& zo&$h70sLdHK&2J`VoREdHDPU*11j`d{uly7K)q z|9s|1hrcd7x-R%Xxc-O``Ohjw;{RUfi2YUb-=Kf<>>s&|+`nofiN`AHU)cYa{r~jn z@tXR-d*o^6^ym@bfAHwBtH{49HytF>{{Pjf{~h;le*Hc5p8@o@Zu_5>?*G94Z+iYQ z#q$4)mVZP3kF)k4SI1*4N#Ka_|5x}uHvTJqACrkVv&&<2;{2Z(=5N&h%`i|1;6L++ z@c)uO)XW?n^M|4X@X?5WB@=!1*S41CZhr;;Dnudw0Y7_-{+v#?4j3(gluma6&phhAZPg7&y`%>7xCZkt`V>GoPb{ zrRQVf zfIy%CP!I?Lf`LLnVITwu1;T*RKsZnaC<~N(49dq+TtH9&Bmfo=5)c-E2tWm30@4C- z0T}^V0Xac{AW%?1P*4yg2o@9)6c&UCLIq)h(t>b789`Y=IS>E@1POoyK_CzqBm@!$ zK|oLt3?vPLgJeLmAUQAq3Fp=78ZsGLxo|&(!y|I8DUvr zIS2p(ga|+cAs`4CA_NhJKp;>E3?dDILu4Sb5IHCS3WN$k1)(4)7%Bu6hC&`wEDS0Q zg+pbavQRk~00x8!zyx6+7#Jo56NW)xP#6p*4THmEV6redX@E3PT0mM*8YB&t7Lpd0 zhDbxDVbaplaA_H7S!p>q01ku;zy;wTI2bMj7luRNP&f=O4Tr;J;IeQz8GsB>MnFbT z1|$QP5t0#>fyh8*U^3D&a2XjHSs6K5fGki}Kvqx|Bny@mk`FuO>U95j__gTs&A~%k z0Wpyg5fQ+t+8u|{gndmG$`SSYP~z=teszrs&KEMDe4|?O@=a0OgQBFcb;%O#p$}<> zZ-g^iG8R;){o|G{Kd@ddaBd_q<#z;L zSk#poTT%t$&g~l;GWRRWEF4xCxd1#2RiIf^?ifW2JX9di`%nci(=|WRDzXWrQg?%oda|0Td zh?WM6aMIkd6lu7U&svz0JU2n?Iy=Nkf5g+ps$;Mf?va_Jm~98#8inoTXd-+IMN3;W z414=UIJ0KGh1~wJ*nMm%es=L-m(%TM1KkvCKch5@F1{PPy2sr1jb~D=OU&(zSyBV2 zDDY^unS&lF6@QOZJ_)>%G-|HbM31>|6X@sy9?tivu z_c2k8=MMpUMoz&PL69#7`D~wODRE8d-J7+{4OX7m|4pWhn3`l+O4IHMF=5}0y0*nBv>)YKqY+A7U^IyMHcr9pdUa*L~ zf4IJi8qWn92J@<0l5#x6Lq=W)k`MMX$W#MGQD)^ zllQDbG>n4axEWH_pT~z1YY{0$5~mL;_S<~$Ih53RT}NxyoPsywBYasOFw%`Za3vfo z!%pnk@HF@AgiREic3dBeHI|g*!ErK3aA^66BTb5G?6nvCzTgZ?7}98bjdM;g*Z6Ti zQv;O$X3+>$#6*Vv`}zZW2Ubd*;QXx~}CR0|q#<-V|ifgmFT)Xw748MOj$Hhhu~QQ@abdQ!}zzNFgU32U{d#)}xTLBv4KqzgP|1M&NY4Hcu&`LhLaA)|_ zr!ivFMlZ9Bt7p6|*v)@BLC6R;?n8*iq(}ewYk5ucNg`c* z71o3N{0lYL;}DffG_-@5v(%5f$ao(^rFDefneYjH zmKuG!Zj9Y$%O za*f}(=sP9(P@57;5>~Hja}72jh_i|5CRSqBk(DRGeybTxZ9e8h?F3o<&*F}#>PXM? ze)(g_75GP+z7`zd>$(&P%&|OSPB|567d?|i7CQ_m)|KW4-G=HJi?nbAh<>;J5^x&m zRPdGJ=i~+bm@2W3dsD&-qoHGujU} zb~D{_iNmZBxazs={l zhjwgx9?=3^NrnydaG#}*C|EEqy2FiEU~$vI))xZ{wVq1rCVCQd`6dgW){~URZgz(8 z7Vm3pdXWv13=x~&+W^k6s|Dz4qrkBXqm@n2zX#LBU{flNoH=vWLVv1|72oP;TEQ6^zKA(lh8)sCA_vQ9*_s2$C5 zOJvMzd!nyq;>qNdW`DE~F`VZdvZOZGJ&T-!3_QPP9r8vBml45zsfJ>IY57LdTn(06 zv}7UJZbD48JZ}8_u#-5Sp<7Q3{ZiI>At+mwF5+o`qnc0IlZ~ouev=NR2&tYQ!=!mq z_Wk;GB6%Xc$f#6z&zli?S#8?OxS2oHk;+dcD$jRvX8W@9%>gA`%F<8Td@U1AzE?OYPCxuqq@I%myv zu$|6@eiZNTv0}92cbdcuYMux&Yu%+lv)VfbV^7=9zZ^f5W(w=5;A1g;)BS`>ea3)E zyE!@iUFg7z?)ABU2PQwiDs7Kv-z6n-j@>ekowBbmye^o@a>QEDPMOx};w80&!|t@O zcL!Pn!_(PMhfFQ}A#&@xg&pA+`&keIfM_k#4=1|AOZkO7Z-{lVlh27O< z-E3eGt(>N>K--r+v;0;M_mzxDe!0~V2IY(MQNnlU`o$Crn4#QJby!5eA{-1Bbf((? zi|Kk4NeV9gh`Jj^{`V}>8I(W%__OcZIj>COw8U$UEW7ZYJ~i(A&^Ei|D9K&T*Fe>s zf}7q)_j3TJQg$rD+>H$TTZ#dU12bgyDfaeS;FST!SN)*y4Efh| zGiBP=&KmbPUvgaxo4KfVqC#=(4ygZHF1Z9Qi(glkdFf=T(c90NrZtkrsU_zM9JZvG z1q!EWKfN+~c3y#8*luH2zGnra6t6zl80a5Qu+U-M_7DGlok!`>cNlGW;`ar$`;#}; zvj+7jO1;x|A}gCu%Sc?Ts`?wH<%bv7!3N_*UT8gjnGFfj%}e)kN$VW`rS zYrQs*^0OsQrJueS64{6qwKfXT1PdYF$vF(UfW+8tyK1W<=^?W&K_(ebDW3&3cuJ>0 z5Ytii`pL#6Y+m1H(}2D3P^ZsOD==o!;5(`Cq~)!CR~sh>PgJ#S1=9;as^beG=6cdz z7Q!W*e(aF`($2lulrTg{OrGwE)l4(3c9-Fo*Ij*@DPeNi)uaoccOto&qu2VRYT+D5 zs-O9~!UE?w=hnO5ax6&;=EHgp@vvQak?9en=V?4NLE{g}WFdnzu^G8B1EO}LwAR*B zI9Ix5?`QFu_Dg5kGpFNM4DSMpo*bq&PrRt70j++;;y|k#_tJk$F0*6j)J~~ytUrY1 zoN`4xVr{5%W#;2Mvq$tM%*zTp<%9 zFXfGA`j3(FhUxcPG2c@uc}i`b&hMN>^9vs3bc{z3)I|EYphIdR3lx?i1k%1J2YK}_ zK4>uAPajGSH||#Dh)%ChCs@g;NZwygN~_CvISDC9C~G2?SJ<65-Zfm_6-By=sNI#K zAYb3}{``P&%spY9ShX{|;1>zZBh`91=1!GNvk!Vo^=A}!_$qIfrTQQ2tR$#DipoU~;+q&59dHO~FdwRsA^g?+6mN@Y{iJUY6Dhed~ASrr{qT zc-siF9o#k2F2ZEi>8|4NG?|0tPC%&x%Zop98mm`O>~v-ShKO3(F}iPjTN~s+%QIj7 ztKWs_egj|06x~S|j&y#`MX&36!F*)m-rxW6CyYm3P2%U}v-+XJs)mP=PKz2T@F3PO+u#Dn$1P9vT zcw54=pEeU%(LC!#%jTXKN50h5^2KAO%az`0CfQ+6RzIs%cNvZJ9b%NLcT^Y+pY(9< ze<(Ki>JkV-Fo(ppyw7OJm}GpcL~3gyTq{w-!Us92Lp-129|MH2ILv^?ysHpF4hNf0 z2il+bz<+DOHu9^He1xW90;MV zkzfXD=Xr6w%%Pcm@>uj(r!X>5oKikhkJHL((3-NrxV}Dsx(%1!i|yx^!{1h9yqfdV zo!BhtqGsx@dBmL?%cQ!(c0~vU*#Vk703N?DhAtPYQ%$o+($mgK{pH;&pT5q%#SvNk z$#u2foz+sOUf1Qxg}BiA(lne{B1iriTyk%#>*FaM$dN0x+<@t~!0-ZE*YKDidFu3#>4 zZ#93<)HbG=M#;D47JE-+7M0~$ffB~0o%exZ=lASi6(f8GhL~)m7P9mx_HG>t5fR50 zkcNK57|}ErA(y0Pk;M+%UwuUjExRzAJIiYa6DoX|t4{kf zHsf;hbs9zmQR6)-ZD{dU3a3N`q0>Mz71tt>>3JL4 zZp~BG_v1vJe%C-SMgdT^U*-2{MS}j9En?=*w(2-i`wn(Cl2}h5(QnRyj}9=9Q--O! zv0CCP{-VnjLom`|g6esY97Cw^nK`b!dE<*#sXo$*9(E&5CEbEFKst&rCogp`UGi)a zGm-h!G8K~~`e)8=ZnOf^G929rQQ>E)|2@^%^u1Y1_8H(DE*JXW2FF~JJb*_3QG zwUR32y;JtbLm!Na?>oJ(K3DYqi<8&>ub)%%a$ltrg2RFwyB335+P0~^oV!VXj2jo)*0 zq94Q!#hS84Y`pkGx0~ViEX=ckor`)n{idWfB*(zair(&y(>l3KUKJ6~t)b~lUP#BR zs92{vLGxrIwz$TD_V5X`Abs${tkYrm8}1dHB6XhAdURhECqVvKC1Es&GQ9X0a|B~1 zy{DD8ME%#rLHJ>_=NSxsvFm)05pDePi(rm|YG5LF-0r~M0Nd+)ZjY}*bS1+w1qx=A zy*}$Qc$stc6=PDQt>)MYNL(l7$vDb2mnOYd*s!PR^+Eltvy(CRm&H#EPi>;*heb=& zc5#XmNhUw}Y|i_QcDgd$F4eOas_C$FMKdNVhtKLk~ftX3czl$GIH4lf#(QYa>w>#4>Oax8$aHzF)(9D3#*sdaBL$6g(fdoqL zuPnZlbYhe*6sA_*pB>@^&|5b@lspM?`Ca*RUvmZC^)_b3yScr?fgg1NGDn-p9rzi1 z;vRF+Hy}+a7>Q75DvMxB+L4bVuza3imB~s^Nb){$CtK?*Os78;bFkREO%W-vIxY5n z8;MMjZ%eDyux5){eNyT#mXqteYxgo|iwTq^?4+D#`1B^rz-bn7+p)2>QTI+ceJqjE z?~tHx*tFvac3slPsUC{^IbAmiC_(?!X_dMh@{-Arg*^-*5O2^?S#9g9=wA@IR2}s- zX;h65v=@F=HOZewLI04Uiclhp54PZk?#iFHh3rDs$}1RpXAPUjZhBHQiGS_*3=#FA z1lyp;2I)|~NM##`e0Bn!k%j?uXm#T%EW3UBp6v* zunoU=9t9ZoybUKoUQM%-I|ust-nGosZ*JcbDHeI+g(`D*!Rt;^5Qa0AvIVeoZ*Eo} zDrN!xVBytcdbnM!N|P)(KqPb-^gr{r12vWntty7h0O#1TM#}k)HXKN1Y zC}AAAHAL)`*y5~%&vA303FHZmom17oITZtQi#Mh*60)B?1KXZ`ro@>s4K}nqwy=#= zQPdqJ?ntK@{7$~YVpZTMG=SRuN(kMNx01gMr$&q@UP+$i-4~w;+*rRzUu*h8zOvkH zIe}~@^7F&&nlpw@JvX9168k6)t{=2)gmkT#=(|sI+gEN?6q6kjSEAtUiu`(}9htaQ zBj%S(K&P~%!g2>sH>R20h+bO@Ma%vA=Z4-ha?I3Phr~W}i7mLc&p^Jo&g^?C@u=f6 zg2dQwE7#L)3ZqtW(nvj$X9g`3ai~GqQPFrx>sg+?emL^xv21azCuTsm=UV6H{b@az(e8z(JjgmC*qBV@J`SJn~L3jqL5q|zxpkE z>)KXGJc{k4SMRAuNAGY0cG%lilYB!*tUWBYZj;FU{>V^0#jrh%m0G3onf$yT1bw2q z$PD81&;#OG<&8bTp>X=P;27O+;gs@o!7O{T6E(QIvPETDq%)O?HfiTIRs7(_8-pP^ z

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 aEGkzw3m3P;Wdg)ELX4c7TUK3;{Qm&23?upg literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..07db070bc2a90e3fdbcde53923550f0c85443db9 GIT binary patch literal 6946 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 z

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 aEGkzw3m3P;Wdg)ELX4c7TUK3;{Qm&23?upg literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..07db070bc2a90e3fdbcde53923550f0c85443db9 GIT binary patch literal 6946 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 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 @@ - - + + + + diff --git a/frontend/src/views/MembersView.vue b/frontend/src/views/MembersView.vue index ba052f5..9366b82 100644 --- a/frontend/src/views/MembersView.vue +++ b/frontend/src/views/MembersView.vue @@ -120,6 +120,7 @@ {{ member.email }} + @@ -143,6 +144,13 @@ @confirm="handleConfirmResult(true)" @cancel="handleConfirmResult(false)" /> + + + @@ -205,6 +213,7 @@ import ConfirmDialog from '../components/ConfirmDialog.vue'; import ImageViewerDialog from '../components/ImageViewerDialog.vue'; import BaseDialog from '../components/BaseDialog.vue'; import MemberNotesDialog from '../components/MemberNotesDialog.vue'; +import MemberActivitiesDialog from '../components/MemberActivitiesDialog.vue'; export default { name: 'MembersView', components: { @@ -212,7 +221,8 @@ export default { ConfirmDialog, ImageViewerDialog, BaseDialog, - MemberNotesDialog + MemberNotesDialog, + MemberActivitiesDialog }, computed: { ...mapGetters(['isAuthenticated', 'currentClub']), @@ -271,7 +281,9 @@ export default { showInactiveMembers: false, newPicsInInternetAllowed: false, isUpdatingRatings: false, - showMemberInfo: false + showMemberInfo: false, + showActivitiesModal: false, + selectedMemberForActivities: null } }, async mounted() { @@ -483,6 +495,10 @@ export default { closeNotesModal() { this.showNotesModal = false; }, + openActivitiesModal(member) { + this.selectedMemberForActivities = member; + this.showActivitiesModal = true; + }, openImageModal(imageUrl, memberId) { this.selectedImageUrl = imageUrl; this.selectedMemberId = memberId; @@ -915,4 +931,21 @@ table td { min-width: 2rem; text-align: center; } + +/* Button Styles */ +.btn-activities { + margin-left: 0.5rem; + background-color: #28a745; + color: white; + border: none; + padding: 0.5rem 1rem; + border-radius: 4px; + cursor: pointer; + font-size: 0.9em; + transition: background-color 0.2s ease; +} + +.btn-activities:hover { + background-color: #218838; +} From 2dd5e28cbc20f119e4717ed63dbe04f8693bcdac Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Fri, 17 Oct 2025 08:10:26 +0200 Subject: [PATCH 032/113] Add manual trigger endpoints for scheduler service in sessionRoutes Introduce new POST endpoints for triggering rating updates and fetching match results, along with a GET endpoint for retrieving scheduler status. Enhance error handling and response formatting for better API usability. --- backend/routes/sessionRoutes.js | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/backend/routes/sessionRoutes.js b/backend/routes/sessionRoutes.js index 5c0736b..257130f 100644 --- a/backend/routes/sessionRoutes.js +++ b/backend/routes/sessionRoutes.js @@ -1,9 +1,38 @@ import express from 'express'; import { authenticate } from '../middleware/authMiddleware.js'; import sessionController from '../controllers/sessionController.js'; +import schedulerService from '../services/schedulerService.js'; const router = express.Router(); router.get('/status', authenticate, sessionController.checkSession); +// Manual trigger endpoints for testing +router.post('/trigger-rating-updates', authenticate, async (req, res) => { + try { + const result = await schedulerService.triggerRatingUpdates(); + res.json(result); + } catch (error) { + res.status(500).json({ success: false, message: error.message }); + } +}); + +router.post('/trigger-match-fetch', authenticate, async (req, res) => { + try { + const result = await schedulerService.triggerMatchResultsFetch(); + res.json(result); + } catch (error) { + res.status(500).json({ success: false, message: error.message }); + } +}); + +router.get('/scheduler-status', authenticate, (req, res) => { + const status = schedulerService.getStatus(); + const nextRatingUpdate = schedulerService.getNextRatingUpdateTime(); + res.json({ + ...status, + nextRatingUpdate + }); +}); + export default router; From 56f0ce2f27a5a86c673aac497ceed10a9ddb65f1 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Fri, 17 Oct 2025 09:44:10 +0200 Subject: [PATCH 033/113] Implement permission management and enhance user interface for permissions in the application Add new permission routes and integrate permission checks across various existing routes to ensure proper access control. Update the UserClub model to include role and permissions fields, allowing for more granular user access management. Enhance the frontend by introducing a user dropdown menu for managing permissions and displaying relevant options based on user roles. Improve the overall user experience by implementing permission-based visibility for navigation links and actions throughout the application. --- PERMISSIONS_GUIDE.md | 210 ++++++ PERMISSIONS_MIGRATION.md | 235 +++++++ backend/controllers/clubsController.js | 2 +- backend/controllers/permissionController.js | 152 +++++ backend/middleware/authorizationMiddleware.js | 187 ++++++ .../add_permissions_to_user_club.sql | 17 + .../update_existing_user_club_permissions.sql | 38 ++ backend/models/UserClub.js | 19 + backend/routes/clubRoutes.js | 7 +- backend/routes/diaryRoutes.js | 19 +- backend/routes/matchRoutes.js | 17 +- backend/routes/memberRoutes.js | 15 +- backend/routes/myTischtennisRoutes.js | 11 +- backend/routes/permissionRoutes.js | 30 + backend/routes/predefinedActivityRoutes.js | 21 +- backend/routes/teamRoutes.js | 13 +- backend/scripts/createTestUsers.js | 141 ++++ backend/scripts/migratePermissions.js | 128 ++++ backend/scripts/quickFixOwner.js | 103 +++ backend/server.js | 2 + backend/services/clubService.js | 12 +- backend/services/permissionService.js | 366 ++++++++++ backend/services/schedulerService.js | 2 +- frontend/src/App.vue | 222 ++++-- frontend/src/composables/usePermissions.js | 66 ++ frontend/src/directives/permissions.js | 198 ++++++ frontend/src/main.js | 7 + frontend/src/router.js | 2 + frontend/src/store.js | 66 +- frontend/src/views/PendingApprovalsView.vue | 7 +- frontend/src/views/PermissionsView.vue | 631 ++++++++++++++++++ 31 files changed, 2854 insertions(+), 92 deletions(-) create mode 100644 PERMISSIONS_GUIDE.md create mode 100644 PERMISSIONS_MIGRATION.md create mode 100644 backend/controllers/permissionController.js create mode 100644 backend/middleware/authorizationMiddleware.js create mode 100644 backend/migrations/add_permissions_to_user_club.sql create mode 100644 backend/migrations/update_existing_user_club_permissions.sql create mode 100644 backend/routes/permissionRoutes.js create mode 100644 backend/scripts/createTestUsers.js create mode 100644 backend/scripts/migratePermissions.js create mode 100644 backend/scripts/quickFixOwner.js create mode 100644 backend/services/permissionService.js create mode 100644 frontend/src/composables/usePermissions.js create mode 100644 frontend/src/directives/permissions.js create mode 100644 frontend/src/views/PermissionsView.vue diff --git a/PERMISSIONS_GUIDE.md b/PERMISSIONS_GUIDE.md new file mode 100644 index 0000000..e65be02 --- /dev/null +++ b/PERMISSIONS_GUIDE.md @@ -0,0 +1,210 @@ +# Berechtigungssystem - Dokumentation + +## Übersicht + +Das Trainingstagebuch verfügt nun über ein vollständiges rollenbasiertes Berechtigungssystem (RBAC - Role-Based Access Control). Der Club-Ersteller hat automatisch Admin-Rechte und kann anderen Mitgliedern Rollen und spezifische Berechtigungen zuweisen. + +## Rollen + +### 1. Administrator (admin) +- **Vollzugriff** auf alle Funktionen +- Kann Berechtigungen anderer Benutzer verwalten +- Der Club-Ersteller ist automatisch Administrator und kann nicht degradiert werden + +### 2. Trainer (trainer) +- Kann Trainingseinheiten planen und verwalten +- Kann Mitglieder anlegen und bearbeiten +- Kann Spielpläne einsehen und bearbeiten +- Kann Turniere organisieren +- **Kann nicht**: Einstellungen ändern, Berechtigungen verwalten + +### 3. Mannschaftsführer (team_manager) +- Kann Teams und Spielpläne verwalten +- Kann Spieler für Matches einteilen +- Kann Spielergebnisse eintragen +- **Kann nicht**: Trainingseinheiten planen, Mitglieder verwalten + +### 4. Mitglied (member) +- Nur Lesezugriff auf alle Bereiche +- Kann eigene Daten einsehen +- **Kann nicht**: Daten ändern oder löschen + +## Berechtigungsbereiche + +- **diary**: Trainingstagebuch +- **members**: Mitgliederverwaltung +- **teams**: Team-Management +- **schedule**: Spielpläne +- **tournaments**: Turniere +- **statistics**: Statistiken +- **settings**: Einstellungen +- **permissions**: Berechtigungsverwaltung +- **mytischtennis**: MyTischtennis-Integration (für alle zugänglich) + +## Backend-Integration + +### Migration ausführen + +```sql +mysql -u username -p database_name < backend/migrations/add_permissions_to_user_club.sql +``` + +### Authorization Middleware verwenden + +```javascript +import { authorize, requireAdmin, requireOwner } from '../middleware/authorizationMiddleware.js'; + +// Beispiel: Nur Lesezugriff erforderlich +router.get('/diary/:clubId', authenticate, authorize('diary', 'read'), getDiary); + +// Beispiel: Schreibzugriff erforderlich +router.post('/diary/:clubId', authenticate, authorize('diary', 'write'), createDiary); + +// Beispiel: Admin-Rechte erforderlich +router.put('/settings/:clubId', authenticate, requireAdmin(), updateSettings); + +// Beispiel: Nur Owner +router.delete('/club/:clubId', authenticate, requireOwner(), deleteClub); +``` + +### Permission Service verwenden + +```javascript +import permissionService from '../services/permissionService.js'; + +// Berechtigungen prüfen +const hasPermission = await permissionService.hasPermission(userId, clubId, 'diary', 'write'); + +// Rolle setzen +await permissionService.setUserRole(userId, clubId, 'trainer', adminUserId); + +// Custom Permissions setzen +await permissionService.setCustomPermissions( + userId, + clubId, + { diary: { write: false }, members: { write: true } }, + adminUserId +); +``` + +## Frontend-Integration + +### Composable verwenden + +```vue + +``` + +### Direktiven verwenden + +```vue + +``` + +### Store verwenden + +```javascript +import { useStore } from 'vuex'; + +const store = useStore(); + +// Berechtigungen abrufen +const permissions = store.getters.currentPermissions; +const hasPermission = store.getters.hasPermission('diary', 'write'); +const isOwner = store.getters.isClubOwner; +const userRole = store.getters.userRole; + +// Berechtigungen laden (wird automatisch beim Club-Wechsel gemacht) +await store.dispatch('loadPermissions', clubId); +``` + +## Admin-UI + +Die Berechtigungsverwaltung ist unter `/permissions` verfügbar und nur für Administratoren sichtbar. + +**Funktionen:** +- Übersicht aller Clubmitglieder mit ihren Rollen +- Rollen zuweisen/ändern +- Custom Permissions für einzelne Benutzer definieren +- Erklärung der verfügbaren Rollen + +## MyTischtennis-Integration + +Die MyTischtennis-Einstellungen und -Funktionen sind für **alle Club-Mitglieder** zugänglich, unabhängig von ihrer Rolle. Dies ermöglicht es jedem, die Anbindung einzurichten und Daten abzurufen. + +## Sicherheitshinweise + +1. **Der Club-Ersteller** (Owner) kann nicht degradiert oder gelöscht werden +2. **Owner-Rechte** können nicht übertragen werden +3. **Backend-Validierung** wird immer durchgeführt, auch wenn das Frontend Elemente ausblendet +4. **Alle API-Routen** sind durch Middleware geschützt +5. **Permissions werden gecacht** im localStorage für bessere Performance + +## Beispiel-Szenarien + +### Szenario 1: Trainer hinzufügen +1. Admin öffnet `/permissions` +2. Wählt Benutzer aus +3. Ändert Rolle zu "Trainer" +4. Benutzer kann jetzt Trainingseinheiten planen + +### Szenario 2: Custom Permissions +1. Admin öffnet `/permissions` +2. Wählt Benutzer aus +3. Klickt auf "Anpassen" +4. Setzt individuelle Berechtigungen (z.B. nur Diary-Schreibrecht) +5. Speichert + +### Szenario 3: Neues Mitglied +1. Mitglied registriert sich und fordert Zugang an +2. Admin genehmigt Anfrage (Standardrolle: "member") +3. Mitglied hat Lesezugriff +4. Bei Bedarf kann Admin die Rolle später ändern + +## Troubleshooting + +**Problem**: Berechtigungen werden nicht aktualisiert +- **Lösung**: Seite neu laden oder Club neu auswählen + +**Problem**: "Keine Berechtigung" trotz korrekter Rolle +- **Lösung**: Prüfen, ob Custom Permissions die Rolle überschreiben + +**Problem**: Owner kann keine Änderungen vornehmen +- **Lösung**: Owner sollte automatisch alle Rechte haben. Prüfen Sie die `isOwner`-Flag in der Datenbank + +## API-Endpunkte + +``` +GET /api/permissions/:clubId - Eigene Berechtigungen abrufen +GET /api/permissions/:clubId/members - Alle Mitglieder mit Berechtigungen (Admin) +PUT /api/permissions/:clubId/user/:userId/role - Rolle ändern (Admin) +PUT /api/permissions/:clubId/user/:userId/permissions - Custom Permissions setzen (Admin) +GET /api/permissions/roles/available - Verfügbare Rollen abrufen +GET /api/permissions/structure/all - Berechtigungsstruktur abrufen +``` + + diff --git a/PERMISSIONS_MIGRATION.md b/PERMISSIONS_MIGRATION.md new file mode 100644 index 0000000..08336ce --- /dev/null +++ b/PERMISSIONS_MIGRATION.md @@ -0,0 +1,235 @@ +# Berechtigungssystem - Migrations-Anleitung + +## Übersicht + +Diese Anleitung hilft Ihnen, das neue Berechtigungssystem für bestehende Clubs einzurichten. + +## Schritt 1: Datenbank-Schema erweitern + +Führen Sie zuerst die SQL-Migration aus, um die neuen Spalten hinzuzufügen: + +```bash +mysql -u username -p database_name < backend/migrations/add_permissions_to_user_club.sql +``` + +Dies fügt folgende Spalten zur `user_club` Tabelle hinzu: +- `role` (VARCHAR) - Benutzerrolle (admin, trainer, team_manager, member) +- `permissions` (JSON) - Custom Permissions +- `is_owner` (BOOLEAN) - Markiert den Club-Ersteller + +## Schritt 2: Bestehende Daten migrieren + +Sie haben zwei Optionen: + +### Option A: Node.js Script (Empfohlen) + +Das Script identifiziert automatisch den ersten Benutzer jedes Clubs (nach `createdAt`) und setzt ihn als Owner. + +```bash +cd /home/torsten/Programs/trainingstagebuch/backend +node scripts/migratePermissions.js +``` + +**Ausgabe:** +``` +Starting permissions migration... + +Found 3 club(s) + +--- Club: TTC Beispiel (ID: 1) --- + Members found: 5 + First member (will be owner): admin@example.com + ✓ Updated admin@example.com: role=admin, isOwner=true + ✓ Updated user1@example.com: role=member, isOwner=false + ✓ Updated user2@example.com: role=member, isOwner=false + ... + +✅ Migration completed successfully! + +Summary: +Club Owners (3): + - TTC Beispiel: admin@example.com + - SV Teststadt: owner@test.de + - TSC Demo: demo@example.com + +Role Distribution: + - Admins: 3 + - Members: 12 +``` + +### Option B: SQL Script + +Wenn Sie lieber SQL verwenden möchten: + +```bash +mysql -u username -p database_name < backend/migrations/update_existing_user_club_permissions.sql +``` + +Dieses Script: +1. Setzt `role = 'member'` für alle genehmigten Benutzer ohne Rolle +2. Markiert den Benutzer mit der niedrigsten `user_id` pro Club als Owner + +## Schritt 3: Manuelle Anpassungen (Optional) + +### Falscher Owner? + +Falls das Script den falschen Benutzer als Owner markiert hat, können Sie dies manuell korrigieren: + +```sql +-- Alten Owner zurücksetzen +UPDATE user_club +SET is_owner = 0, role = 'member' +WHERE club_id = 1 AND user_id = 123; + +-- Neuen Owner setzen +UPDATE user_club +SET is_owner = 1, role = 'admin' +WHERE club_id = 1 AND user_id = 456; +``` + +### Weitere Admins ernennen + +```sql +UPDATE user_club +SET role = 'admin' +WHERE club_id = 1 AND user_id = 789; +``` + +### Trainer ernennen + +```sql +UPDATE user_club +SET role = 'trainer' +WHERE club_id = 1 AND user_id = 101; +``` + +## Schritt 4: Verifizierung + +### Backend neu starten + +```bash +# Server neu starten (wenn er läuft) +sudo systemctl restart tt-tagebuch +``` + +### Im Browser testen + +1. Loggen Sie sich ein +2. Wählen Sie einen Club aus +3. Navigieren Sie zu "Berechtigungen" (nur für Admins sichtbar) +4. Überprüfen Sie, dass alle Mitglieder korrekt angezeigt werden + +### SQL Verifizierung + +```sql +-- Alle Club-Mitglieder mit ihren Berechtigungen anzeigen +SELECT + c.name as club_name, + u.email as user_email, + uc.role, + uc.is_owner, + uc.approved +FROM user_club uc +JOIN club c ON c.id = uc.club_id +JOIN user u ON u.id = uc.user_id +WHERE uc.approved = 1 +ORDER BY c.name, uc.is_owner DESC, uc.role, u.email; +``` + +## Troubleshooting + +### Problem: "Keine Berechtigung" trotz Owner-Status + +**Lösung:** Überprüfen Sie in der Datenbank: + +```sql +SELECT role, is_owner, approved +FROM user_club +WHERE user_id = YOUR_USER_ID AND club_id = YOUR_CLUB_ID; +``` + +Sollte sein: `role='admin'`, `is_owner=1`, `approved=1` + +### Problem: Owner kann nicht geändert werden + +Das ist korrekt! Der Owner (Club-Ersteller) kann seine eigenen Rechte nicht verlieren. Dies ist eine Sicherheitsmaßnahme. + +### Problem: Berechtigungen werden nicht geladen + +**Lösung:** +1. Browser-Cache leeren +2. LocalStorage leeren: `localStorage.clear()` in der Browser-Console +3. Neu einloggen + +### Problem: "Lade Mitglieder..." bleibt hängen + +**Mögliche Ursachen:** +1. Migration noch nicht ausgeführt +2. Backend nicht neu gestartet +3. Frontend nicht neu gebaut + +**Lösung:** +```bash +# Backend +cd /home/torsten/Programs/trainingstagebuch/backend +node scripts/migratePermissions.js + +# Frontend +cd /home/torsten/Programs/trainingstagebuch/frontend +npm run build + +# Server neu starten +sudo systemctl restart tt-tagebuch +``` + +## Nach der Migration + +### Neue Clubs + +Bei neuen Clubs wird der Ersteller automatisch als Owner mit Admin-Rechten eingerichtet. Keine manuelle Aktion erforderlich. + +### Neue Mitglieder + +Neue Mitglieder erhalten automatisch die Rolle "member" (Lesezugriff). Admins können die Rolle später ändern. + +### Berechtigungen verwalten + +Admins können über die Web-UI unter `/permissions` Berechtigungen verwalten: +1. Rollen zuweisen (Admin, Trainer, Mannschaftsführer, Mitglied) +2. Custom Permissions definieren (für spezielle Anwendungsfälle) + +## Wichtige Hinweise + +⚠️ **Sicherung erstellen:** +```bash +mysqldump -u username -p database_name > backup_before_permissions_$(date +%Y%m%d).sql +``` + +⚠️ **Owner-Rechte:** +- Der Owner (is_owner=1) kann nicht degradiert oder gelöscht werden +- Jeder Club hat genau einen Owner +- Owner-Rechte können nicht übertragen werden (nur durch direkte DB-Änderung) + +⚠️ **MyTischtennis:** +- MyTischtennis-Funktionen sind für ALLE Mitglieder zugänglich +- Keine Berechtigungsprüfung für MyTischtennis-Endpunkte + +## Rollback (falls nötig) + +Falls Sie das Berechtigungssystem zurücknehmen müssen: + +```sql +-- Spalten entfernen (Achtung: Datenverlust!) +ALTER TABLE user_club +DROP COLUMN role, +DROP COLUMN permissions, +DROP COLUMN is_owner; + +-- Indizes entfernen +DROP INDEX idx_user_club_role ON user_club; +DROP INDEX idx_user_club_owner ON user_club; +``` + +Dann Backend-Code auf vorherige Version zurücksetzen. + + diff --git a/backend/controllers/clubsController.js b/backend/controllers/clubsController.js index 848e288..b8932ef 100644 --- a/backend/controllers/clubsController.js +++ b/backend/controllers/clubsController.js @@ -25,7 +25,7 @@ export const addClub = async (req, res) => { } const newClub = await ClubService.createClub(clubName); - await ClubService.addUserToClub(user.id, newClub.id); + await ClubService.addUserToClub(user.id, newClub.id, true); // true = isOwner res.status(200).json(newClub); } catch (error) { console.error('[addClub] - error:', error); diff --git a/backend/controllers/permissionController.js b/backend/controllers/permissionController.js new file mode 100644 index 0000000..7686023 --- /dev/null +++ b/backend/controllers/permissionController.js @@ -0,0 +1,152 @@ +import permissionService from '../services/permissionService.js'; + +/** + * Get user's permissions for a club + */ +export const getUserPermissions = async (req, res) => { + try { + const { clubId } = req.params; + const userId = req.user.id; + + const permissions = await permissionService.getUserClubPermissions(userId, parseInt(clubId)); + + if (!permissions) { + return res.status(404).json({ error: 'Keine Berechtigungen gefunden' }); + } + + res.json(permissions); + } catch (error) { + console.error('Error getting user permissions:', error); + res.status(500).json({ error: 'Fehler beim Abrufen der Berechtigungen' }); + } +}; + +/** + * Get all club members with their permissions + */ +export const getClubMembersWithPermissions = async (req, res) => { + try { + const { clubId } = req.params; + const userId = req.user.id; + + const members = await permissionService.getClubMembersWithPermissions( + parseInt(clubId), + userId + ); + + res.json(members); + } catch (error) { + console.error('Error getting club members with permissions:', error); + if (error.message === 'Keine Berechtigung zum Anzeigen von Berechtigungen') { + return res.status(403).json({ error: error.message }); + } + res.status(500).json({ error: 'Fehler beim Abrufen der Mitglieder' }); + } +}; + +/** + * Update user role + */ +export const updateUserRole = async (req, res) => { + try { + const { clubId, userId: targetUserId } = req.params; + const { role } = req.body; + const updatingUserId = req.user.id; + + const result = await permissionService.setUserRole( + parseInt(targetUserId), + parseInt(clubId), + role, + updatingUserId + ); + + res.json(result); + } catch (error) { + console.error('Error updating user role:', error); + res.status(400).json({ error: error.message }); + } +}; + +/** + * Update user custom permissions + */ +export const updateUserPermissions = async (req, res) => { + try { + const { clubId, userId: targetUserId } = req.params; + const { permissions } = req.body; + const updatingUserId = req.user.id; + + const result = await permissionService.setCustomPermissions( + parseInt(targetUserId), + parseInt(clubId), + permissions, + updatingUserId + ); + + res.json(result); + } catch (error) { + console.error('Error updating user permissions:', error); + res.status(400).json({ error: error.message }); + } +}; + +/** + * Get available roles + */ +export const getAvailableRoles = async (req, res) => { + try { + const roles = permissionService.getAvailableRoles(); + res.json(roles); + } catch (error) { + console.error('Error getting available roles:', error); + res.status(500).json({ error: 'Fehler beim Abrufen der Rollen' }); + } +}; + +/** + * Get permission structure + */ +export const getPermissionStructure = async (req, res) => { + try { + const structure = permissionService.getPermissionStructure(); + res.json(structure); + } catch (error) { + console.error('Error getting permission structure:', error); + res.status(500).json({ error: 'Fehler beim Abrufen der Berechtigungsstruktur' }); + } +}; + +/** + * Update user status (activate/deactivate) + */ +export const updateUserStatus = async (req, res) => { + try { + const { clubId, userId: targetUserId } = req.params; + const { approved } = req.body; + const updatingUserId = req.user.id; + + const result = await permissionService.setUserStatus( + parseInt(targetUserId), + parseInt(clubId), + approved, + updatingUserId + ); + + res.json(result); + } catch (error) { + console.error('Error updating user status:', error); + res.status(400).json({ error: error.message }); + } +}; + +export default { + getUserPermissions, + getClubMembersWithPermissions, + updateUserRole, + updateUserPermissions, + updateUserStatus, + getAvailableRoles, + getPermissionStructure +}; + + diff --git a/backend/middleware/authorizationMiddleware.js b/backend/middleware/authorizationMiddleware.js new file mode 100644 index 0000000..e9524d4 --- /dev/null +++ b/backend/middleware/authorizationMiddleware.js @@ -0,0 +1,187 @@ +import permissionService from '../services/permissionService.js'; + +/** + * Authorization Middleware + * Checks if user has permission to access a resource + */ + +/** + * Check if user has permission for a specific resource and action + * @param {string} resource - Resource name (diary, members, teams, etc.) + * @param {string} action - Action type (read, write, delete) + * @returns {Function} Express middleware function + */ +export const authorize = (resource, action = 'read') => { + return async (req, res, next) => { + try { + const userId = req.user?.id; + + if (!userId) { + return res.status(401).json({ error: 'Nicht authentifiziert' }); + } + + // Get clubId from various possible sources + const clubId = req.params.clubId || req.params.id || req.body.clubId || req.query.clubId; + + if (!clubId) { + return res.status(400).json({ error: 'Club-ID fehlt' }); + } + + // Check permission + const hasPermission = await permissionService.hasPermission( + userId, + parseInt(clubId), + resource, + action + ); + + if (!hasPermission) { + return res.status(403).json({ + error: 'Keine Berechtigung', + details: `Fehlende Berechtigung: ${resource}.${action}` + }); + } + + // Store permissions in request for later use + const userPermissions = await permissionService.getUserClubPermissions( + userId, + parseInt(clubId) + ); + req.userPermissions = userPermissions; + + next(); + } catch (error) { + console.error('Authorization error:', error); + res.status(500).json({ error: 'Fehler bei der Berechtigungsprüfung' }); + } + }; +}; + +/** + * Check if user is club owner + * @returns {Function} Express middleware function + */ +export const requireOwner = () => { + return async (req, res, next) => { + try { + const userId = req.user?.id; + + if (!userId) { + return res.status(401).json({ error: 'Nicht authentifiziert' }); + } + + const clubId = req.params.clubId || req.params.id || req.body.clubId || req.query.clubId; + + if (!clubId) { + return res.status(400).json({ error: 'Club-ID fehlt' }); + } + + const userPermissions = await permissionService.getUserClubPermissions( + userId, + parseInt(clubId) + ); + + if (!userPermissions || !userPermissions.isOwner) { + return res.status(403).json({ + error: 'Keine Berechtigung', + details: 'Nur der Club-Ersteller hat Zugriff' + }); + } + + req.userPermissions = userPermissions; + next(); + } catch (error) { + console.error('Owner check error:', error); + res.status(500).json({ error: 'Fehler bei der Berechtigungsprüfung' }); + } + }; +}; + +/** + * Check if user is admin (owner or admin role) + * @returns {Function} Express middleware function + */ +export const requireAdmin = () => { + return async (req, res, next) => { + try { + const userId = req.user?.id; + + if (!userId) { + return res.status(401).json({ error: 'Nicht authentifiziert' }); + } + + const clubId = req.params.clubId || req.params.id || req.body.clubId || req.query.clubId; + + if (!clubId) { + return res.status(400).json({ error: 'Club-ID fehlt' }); + } + + const userPermissions = await permissionService.getUserClubPermissions( + userId, + parseInt(clubId) + ); + + if (!userPermissions || (userPermissions.role !== 'admin' && !userPermissions.isOwner)) { + return res.status(403).json({ + error: 'Keine Berechtigung', + details: 'Administrator-Rechte erforderlich' + }); + } + + req.userPermissions = userPermissions; + next(); + } catch (error) { + console.error('Admin check error:', error); + res.status(500).json({ error: 'Fehler bei der Berechtigungsprüfung' }); + } + }; +}; + +/** + * Check if user has any of the specified roles + * @param {string[]} roles - Array of allowed roles + * @returns {Function} Express middleware function + */ +export const requireRole = (roles) => { + return async (req, res, next) => { + try { + const userId = req.user?.id; + + if (!userId) { + return res.status(401).json({ error: 'Nicht authentifiziert' }); + } + + const clubId = req.params.clubId || req.params.id || req.body.clubId || req.query.clubId; + + if (!clubId) { + return res.status(400).json({ error: 'Club-ID fehlt' }); + } + + const userPermissions = await permissionService.getUserClubPermissions( + userId, + parseInt(clubId) + ); + + if (!userPermissions || !roles.includes(userPermissions.role)) { + return res.status(403).json({ + error: 'Keine Berechtigung', + details: `Erforderliche Rolle: ${roles.join(', ')}` + }); + } + + req.userPermissions = userPermissions; + next(); + } catch (error) { + console.error('Role check error:', error); + res.status(500).json({ error: 'Fehler bei der Berechtigungsprüfung' }); + } + }; +}; + +export default { + authorize, + requireOwner, + requireAdmin, + requireRole +}; + diff --git a/backend/migrations/add_permissions_to_user_club.sql b/backend/migrations/add_permissions_to_user_club.sql new file mode 100644 index 0000000..75d488b --- /dev/null +++ b/backend/migrations/add_permissions_to_user_club.sql @@ -0,0 +1,17 @@ +-- Add role and permissions columns to user_club table +ALTER TABLE `user_club` +ADD COLUMN `role` VARCHAR(50) DEFAULT 'member' COMMENT 'User role: admin, trainer, team_manager, member' AFTER `approved`, +ADD COLUMN `permissions` JSON NULL COMMENT 'Specific permissions: {diary: {read: true, write: true}, members: {...}, ...}' AFTER `role`, +ADD COLUMN `is_owner` BOOLEAN DEFAULT FALSE COMMENT 'True if user created the club' AFTER `permissions`; + +-- Create index for faster role lookups +CREATE INDEX `idx_user_club_role` ON `user_club` (`role`); +CREATE INDEX `idx_user_club_owner` ON `user_club` (`is_owner`); + +-- Set existing approved users as members +UPDATE `user_club` SET `role` = 'member' WHERE `approved` = 1 AND `role` IS NULL; + +-- If there's a user who created the club (we need to identify them somehow) +-- For now, we'll need to manually set the owner after migration + + diff --git a/backend/migrations/update_existing_user_club_permissions.sql b/backend/migrations/update_existing_user_club_permissions.sql new file mode 100644 index 0000000..0eee5cb --- /dev/null +++ b/backend/migrations/update_existing_user_club_permissions.sql @@ -0,0 +1,38 @@ +-- Update existing user_club entries with default permissions +-- This migration sets default values for role and is_owner for existing club memberships + +-- Set default role to 'member' for all approved users who don't have a role yet +UPDATE `user_club` +SET `role` = 'member' +WHERE `approved` = 1 + AND (`role` IS NULL OR `role` = ''); + +-- Optionally: Set the first approved user of each club as owner +-- This finds the user with the lowest user_id per club (oldest member) and marks them as owner +UPDATE `user_club` AS uc1 +INNER JOIN ( + SELECT `club_id`, MIN(`user_id`) as `first_user_id` + FROM `user_club` + WHERE `approved` = 1 + GROUP BY `club_id` +) AS uc2 ON uc1.`club_id` = uc2.`club_id` AND uc1.`user_id` = uc2.`first_user_id` +SET + uc1.`is_owner` = 1, + uc1.`role` = 'admin'; + +-- Verify the changes +SELECT + uc.`club_id`, + c.`name` as club_name, + uc.`user_id`, + u.`email` as user_email, + uc.`role`, + uc.`is_owner`, + uc.`approved` +FROM `user_club` uc +LEFT JOIN `club` c ON c.`id` = uc.`club_id` +LEFT JOIN `user` u ON u.`id` = uc.`user_id` +WHERE uc.`approved` = 1 +ORDER BY uc.`club_id`, uc.`is_owner` DESC, uc.`user_id`; + + diff --git a/backend/models/UserClub.js b/backend/models/UserClub.js index e4f5898..b626fa4 100644 --- a/backend/models/UserClub.js +++ b/backend/models/UserClub.js @@ -6,6 +6,7 @@ import Club from './Club.js'; const UserClub = sequelize.define('UserClub', { userId: { type: DataTypes.INTEGER, + primaryKey: true, references: { model: User, key: 'id', @@ -13,6 +14,7 @@ const UserClub = sequelize.define('UserClub', { }, clubId: { type: DataTypes.INTEGER, + primaryKey: true, references: { model: Club, key: 'id', @@ -22,6 +24,23 @@ const UserClub = sequelize.define('UserClub', { type: DataTypes.BOOLEAN, defaultValue: false, }, + role: { + type: DataTypes.STRING(50), + defaultValue: 'member', + allowNull: false, + comment: 'User role: admin, trainer, team_manager, member' + }, + permissions: { + type: DataTypes.JSON, + allowNull: true, + comment: 'Specific permissions: {diary: {read: true, write: true}, members: {...}, ...}' + }, + isOwner: { + type: DataTypes.BOOLEAN, + defaultValue: false, + allowNull: false, + comment: 'True if user created the club' + } }, { underscored: true, tableName: 'user_club', diff --git a/backend/routes/clubRoutes.js b/backend/routes/clubRoutes.js index 024104a..b9c03c5 100644 --- a/backend/routes/clubRoutes.js +++ b/backend/routes/clubRoutes.js @@ -1,5 +1,6 @@ import express from 'express'; import { authenticate } from '../middleware/authMiddleware.js'; +import { authorize } from '../middleware/authorizationMiddleware.js'; import { getClubs, addClub, getClub, requestClubAccess, getPendingApprovals, approveClubAccess, rejectClubAccess } from '../controllers/clubsController.js'; const router = express.Router(); @@ -8,8 +9,8 @@ router.get('/', authenticate, getClubs); router.post('/', authenticate, addClub); router.get('/:clubid', authenticate, getClub); router.get('/request/:clubid', authenticate, requestClubAccess); -router.get('/pending/:clubid', authenticate, getPendingApprovals); -router.post('/approve', authenticate, approveClubAccess); -router.post('/reject', authenticate, rejectClubAccess); +router.get('/pending/:clubid', authenticate, authorize('approvals', 'read'), getPendingApprovals); +router.post('/approve', authenticate, authorize('approvals', 'write'), approveClubAccess); +router.post('/reject', authenticate, authorize('approvals', 'write'), rejectClubAccess); export default router; diff --git a/backend/routes/diaryRoutes.js b/backend/routes/diaryRoutes.js index dacfce2..8f75876 100644 --- a/backend/routes/diaryRoutes.js +++ b/backend/routes/diaryRoutes.js @@ -1,5 +1,6 @@ import express from 'express'; import { authenticate } from '../middleware/authMiddleware.js'; +import { authorize } from '../middleware/authorizationMiddleware.js'; import { getDatesForClub, createDateForClub, @@ -14,14 +15,14 @@ import { const router = express.Router(); -router.post('/note', authenticate, addDiaryNote); -router.delete('/note/:noteId', authenticate, deleteDiaryNote); -router.post('/tag', authenticate, addDiaryTag); -router.post('/tag/:clubId/add-tag', authenticate, addTagToDiaryDate); -router.delete('/:clubId/tag', authenticate, deleteTagFromDiaryDate); -router.get('/:clubId', authenticate, getDatesForClub); -router.post('/:clubId', authenticate, createDateForClub); -router.put('/:clubId', authenticate, updateTrainingTimes); -router.delete('/:clubId/:dateId', authenticate, deleteDateForClub); +router.post('/note', authenticate, authorize('diary', 'write'), addDiaryNote); +router.delete('/note/:noteId', authenticate, authorize('diary', 'delete'), deleteDiaryNote); +router.post('/tag', authenticate, authorize('diary', 'write'), addDiaryTag); +router.post('/tag/:clubId/add-tag', authenticate, authorize('diary', 'write'), addTagToDiaryDate); +router.delete('/:clubId/tag', authenticate, authorize('diary', 'delete'), deleteTagFromDiaryDate); +router.get('/:clubId', authenticate, authorize('diary', 'read'), getDatesForClub); +router.post('/:clubId', authenticate, authorize('diary', 'write'), createDateForClub); +router.put('/:clubId', authenticate, authorize('diary', 'write'), updateTrainingTimes); +router.delete('/:clubId/:dateId', authenticate, authorize('diary', 'delete'), deleteDateForClub); export default router; diff --git a/backend/routes/matchRoutes.js b/backend/routes/matchRoutes.js index de0f6d4..a2bd09e 100644 --- a/backend/routes/matchRoutes.js +++ b/backend/routes/matchRoutes.js @@ -1,20 +1,21 @@ import express from 'express'; import { uploadCSV, getLeaguesForCurrentSeason, getMatchesForLeagues, getMatchesForLeague, getLeagueTable, fetchLeagueTableFromMyTischtennis, updateMatchPlayers, getPlayerMatchStats } from '../controllers/matchController.js'; import { authenticate } from '../middleware/authMiddleware.js'; +import { authorize } from '../middleware/authorizationMiddleware.js'; import multer from 'multer'; const router = express.Router(); const upload = multer({ dest: 'uploads/' }); -router.post('/import', authenticate, upload.single('file'), uploadCSV); -router.get('/leagues/current/:clubId', authenticate, getLeaguesForCurrentSeason); -router.get('/leagues/:clubId/matches/:leagueId', authenticate, getMatchesForLeague); -router.get('/leagues/:clubId/matches', authenticate, getMatchesForLeagues); -router.get('/leagues/:clubId/table/:leagueId', authenticate, getLeagueTable); -router.post('/leagues/:clubId/table/:leagueId/fetch', authenticate, fetchLeagueTableFromMyTischtennis); -router.patch('/:matchId/players', authenticate, updateMatchPlayers); -router.get('/leagues/:clubId/stats/:leagueId', authenticate, getPlayerMatchStats); +router.post('/import', authenticate, authorize('schedule', 'write'), upload.single('file'), uploadCSV); +router.get('/leagues/current/:clubId', authenticate, authorize('schedule', 'read'), getLeaguesForCurrentSeason); +router.get('/leagues/:clubId/matches/:leagueId', authenticate, authorize('schedule', 'read'), getMatchesForLeague); +router.get('/leagues/:clubId/matches', authenticate, authorize('schedule', 'read'), getMatchesForLeagues); +router.get('/leagues/:clubId/table/:leagueId', authenticate, authorize('schedule', 'read'), getLeagueTable); +router.post('/leagues/:clubId/table/:leagueId/fetch', authenticate, authorize('mytischtennis', 'write'), fetchLeagueTableFromMyTischtennis); +router.patch('/:matchId/players', authenticate, authorize('schedule', 'write'), updateMatchPlayers); +router.get('/leagues/:clubId/stats/:leagueId', authenticate, authorize('schedule', 'read'), getPlayerMatchStats); export default router; diff --git a/backend/routes/memberRoutes.js b/backend/routes/memberRoutes.js index ecbab85..c6b9b7a 100644 --- a/backend/routes/memberRoutes.js +++ b/backend/routes/memberRoutes.js @@ -1,6 +1,7 @@ import { getClubMembers, getWaitingApprovals, setClubMembers, uploadMemberImage, getMemberImage, updateRatingsFromMyTischtennis, rotateMemberImage } from '../controllers/memberController.js'; import express from 'express'; import { authenticate } from '../middleware/authMiddleware.js'; +import { authorize } from '../middleware/authorizationMiddleware.js'; import multer from 'multer'; const router = express.Router(); @@ -8,12 +9,12 @@ const router = express.Router(); const storage = multer.memoryStorage(); const upload = multer({ storage: storage }); -router.post('/image/:clubId/:memberId', authenticate, upload.single('image'), uploadMemberImage); -router.get('/image/:clubId/:memberId', authenticate, getMemberImage); -router.get('/get/:id/:showAll', authenticate, getClubMembers); -router.post('/set/:id', authenticate, setClubMembers); -router.get('/notapproved/:id', authenticate, getWaitingApprovals); -router.post('/update-ratings/:id', authenticate, updateRatingsFromMyTischtennis); -router.post('/rotate-image/:clubId/:memberId', authenticate, rotateMemberImage); +router.post('/image/:clubId/:memberId', authenticate, authorize('members', 'write'), upload.single('image'), uploadMemberImage); +router.get('/image/:clubId/:memberId', authenticate, authorize('members', 'read'), getMemberImage); +router.get('/get/:id/:showAll', authenticate, authorize('members', 'read'), getClubMembers); +router.post('/set/:id', authenticate, authorize('members', 'write'), setClubMembers); +router.get('/notapproved/:id', authenticate, authorize('members', 'read'), getWaitingApprovals); +router.post('/update-ratings/:id', authenticate, authorize('mytischtennis', 'write'), updateRatingsFromMyTischtennis); +router.post('/rotate-image/:clubId/:memberId', authenticate, authorize('members', 'write'), rotateMemberImage); export default router; diff --git a/backend/routes/myTischtennisRoutes.js b/backend/routes/myTischtennisRoutes.js index 0e42c65..05f457b 100644 --- a/backend/routes/myTischtennisRoutes.js +++ b/backend/routes/myTischtennisRoutes.js @@ -2,23 +2,24 @@ import express from 'express'; import myTischtennisController from '../controllers/myTischtennisController.js'; import myTischtennisUrlController from '../controllers/myTischtennisUrlController.js'; import { authenticate } from '../middleware/authMiddleware.js'; +import { authorize } from '../middleware/authorizationMiddleware.js'; const router = express.Router(); // All routes require authentication router.use(authenticate); -// GET /api/mytischtennis/account - Get account +// GET /api/mytischtennis/account - Get account (alle dürfen lesen) router.get('/account', myTischtennisController.getAccount); -// GET /api/mytischtennis/status - Check status +// GET /api/mytischtennis/status - Check status (alle dürfen lesen) router.get('/status', myTischtennisController.getStatus); -// POST /api/mytischtennis/account - Create or update account +// POST /api/mytischtennis/account - Create or update account (alle dürfen bearbeiten) router.post('/account', myTischtennisController.upsertAccount); -// DELETE /api/mytischtennis/account - Delete account -router.delete('/account', myTischtennisController.deleteAccount); +// DELETE /api/mytischtennis/account - Delete account (nur Admin) +router.delete('/account', authorize('mytischtennis_admin', 'write'), myTischtennisController.deleteAccount); // POST /api/mytischtennis/verify - Verify login router.post('/verify', myTischtennisController.verifyLogin); diff --git a/backend/routes/permissionRoutes.js b/backend/routes/permissionRoutes.js new file mode 100644 index 0000000..b7e2bf0 --- /dev/null +++ b/backend/routes/permissionRoutes.js @@ -0,0 +1,30 @@ +import express from 'express'; +import { authenticate } from '../middleware/authMiddleware.js'; +import { authorize, requireAdmin } from '../middleware/authorizationMiddleware.js'; +import permissionController from '../controllers/permissionController.js'; + +const router = express.Router(); + +// Get available roles (no club context needed) +router.get('/roles/available', authenticate, permissionController.getAvailableRoles); + +// Get permission structure (no club context needed) +router.get('/structure/all', authenticate, permissionController.getPermissionStructure); + +// Get current user's permissions for a club (no authorization check - needed to load permissions) +router.get('/:clubId', authenticate, permissionController.getUserPermissions); + +// Get all club members with their permissions (admin only) +router.get('/:clubId/members', authenticate, authorize('permissions', 'read'), permissionController.getClubMembersWithPermissions); + +// Update user role (admin only) +router.put('/:clubId/user/:userId/role', authenticate, authorize('permissions', 'write'), permissionController.updateUserRole); + +// Update user permissions (admin only) +router.put('/:clubId/user/:userId/permissions', authenticate, authorize('permissions', 'write'), permissionController.updateUserPermissions); + +// Update user status (admin only) +router.put('/:clubId/user/:userId/status', authenticate, authorize('permissions', 'write'), permissionController.updateUserStatus); + +export default router; + diff --git a/backend/routes/predefinedActivityRoutes.js b/backend/routes/predefinedActivityRoutes.js index ce74def..e590284 100644 --- a/backend/routes/predefinedActivityRoutes.js +++ b/backend/routes/predefinedActivityRoutes.js @@ -10,6 +10,7 @@ import { } from '../controllers/predefinedActivityController.js'; import multer from 'multer'; import { authenticate } from '../middleware/authMiddleware.js'; +import { authorize } from '../middleware/authorizationMiddleware.js'; import { uploadPredefinedActivityImage, deletePredefinedActivityImage } from '../controllers/predefinedActivityImageController.js'; import PredefinedActivityImage from '../models/PredefinedActivityImage.js'; import path from 'path'; @@ -18,16 +19,16 @@ import fs from 'fs'; const router = express.Router(); const upload = multer({ storage: multer.memoryStorage() }); -router.post('/', authenticate, createPredefinedActivity); -router.get('/', authenticate, getAllPredefinedActivities); -router.get('/:id', authenticate, getPredefinedActivityById); -router.put('/:id', authenticate, updatePredefinedActivity); -router.post('/:id/image', authenticate, upload.single('image'), uploadPredefinedActivityImage); -router.put('/:id/image', authenticate, upload.single('image'), uploadPredefinedActivityImage); -router.delete('/:id/image/:imageId', authenticate, deletePredefinedActivityImage); -router.get('/search/query', authenticate, searchPredefinedActivities); -router.post('/merge', authenticate, mergePredefinedActivities); -router.post('/deduplicate', authenticate, deduplicatePredefinedActivities); +router.post('/', authenticate, authorize('predefined_activities', 'write'), createPredefinedActivity); +router.get('/', authenticate, authorize('predefined_activities', 'read'), getAllPredefinedActivities); +router.get('/:id', authenticate, authorize('predefined_activities', 'read'), getPredefinedActivityById); +router.put('/:id', authenticate, authorize('predefined_activities', 'write'), updatePredefinedActivity); +router.post('/:id/image', authenticate, authorize('predefined_activities', 'write'), upload.single('image'), uploadPredefinedActivityImage); +router.put('/:id/image', authenticate, authorize('predefined_activities', 'write'), upload.single('image'), uploadPredefinedActivityImage); +router.delete('/:id/image/:imageId', authenticate, authorize('predefined_activities', 'delete'), deletePredefinedActivityImage); +router.get('/search/query', authenticate, authorize('predefined_activities', 'read'), searchPredefinedActivities); +router.post('/merge', authenticate, authorize('predefined_activities', 'write'), mergePredefinedActivities); +router.post('/deduplicate', authenticate, authorize('predefined_activities', 'write'), deduplicatePredefinedActivities); router.get('/:id/image/:imageId', async (req, res) => { try { const { id, imageId } = req.params; diff --git a/backend/routes/teamRoutes.js b/backend/routes/teamRoutes.js index ea3ee3c..52415d4 100644 --- a/backend/routes/teamRoutes.js +++ b/backend/routes/teamRoutes.js @@ -1,5 +1,6 @@ import express from 'express'; import { authenticate } from '../middleware/authMiddleware.js'; +import { authorize } from '../middleware/authorizationMiddleware.js'; import { getTeams, getTeam, @@ -12,21 +13,21 @@ import { const router = express.Router(); // Get all teams for a club -router.get('/club/:clubid', authenticate, getTeams); +router.get('/club/:clubid', authenticate, authorize('teams', 'read'), getTeams); // Get leagues for a club -router.get('/leagues/:clubid', authenticate, getLeagues); +router.get('/leagues/:clubid', authenticate, authorize('teams', 'read'), getLeagues); // Get a specific team -router.get('/:teamid', authenticate, getTeam); +router.get('/:teamid', authenticate, authorize('teams', 'read'), getTeam); // Create a new team -router.post('/club/:clubid', authenticate, createTeam); +router.post('/club/:clubid', authenticate, authorize('teams', 'write'), createTeam); // Update a team -router.put('/:teamid', authenticate, updateTeam); +router.put('/:teamid', authenticate, authorize('teams', 'write'), updateTeam); // Delete a team -router.delete('/:teamid', authenticate, deleteTeam); +router.delete('/:teamid', authenticate, authorize('teams', 'delete'), deleteTeam); export default router; \ No newline at end of file diff --git a/backend/scripts/createTestUsers.js b/backend/scripts/createTestUsers.js new file mode 100644 index 0000000..ea16bfa --- /dev/null +++ b/backend/scripts/createTestUsers.js @@ -0,0 +1,141 @@ +import User from '../models/User.js'; +import Club from '../models/Club.js'; +import UserClub from '../models/UserClub.js'; +import sequelize from '../database.js'; + +/** + * Create test users with different roles + */ + +const TEST_USERS = [ + { + email: 'admin@test.de', + password: 'test123', + role: 'admin', + isOwner: false + }, + { + email: 'trainer@test.de', + password: 'test123', + role: 'trainer', + isOwner: false + }, + { + email: 'teammanager@test.de', + password: 'test123', + role: 'team_manager', + isOwner: false + }, + { + email: 'tournamentmanager@test.de', + password: 'test123', + role: 'tournament_manager', + isOwner: false + }, + { + email: 'member1@test.de', + password: 'test123', + role: 'member', + isOwner: false + }, + { + email: 'member2@test.de', + password: 'test123', + role: 'member', + isOwner: false + } +]; + +async function createTestUsers() { + console.log('Creating test users...\n'); + + try { + // Get first club (or specify club ID) + const clubs = await Club.findAll({ limit: 1 }); + + if (clubs.length === 0) { + console.error('❌ No clubs found! Please create a club first.'); + process.exit(1); + } + + const club = clubs[0]; + console.log(`Using club: ${club.name} (ID: ${club.id})\n`); + + for (const userData of TEST_USERS) { + console.log(`Creating user: ${userData.email} (${userData.role})...`); + + // Check if user already exists + let user = await User.findOne({ where: { email: userData.email } }); + + if (user) { + console.log(` ⚠️ User already exists, using existing user`); + } else { + // Create user + user = await User.create({ + email: userData.email, + password: userData.password, + isActive: true + }); + console.log(` ✓ User created`); + } + + // Check if user is already in club + let userClub = await UserClub.findOne({ + where: { + userId: user.id, + clubId: club.id + } + }); + + if (userClub) { + console.log(` ⚠️ User already in club, updating role...`); + await userClub.update({ + role: userData.role, + isOwner: userData.isOwner, + approved: true + }); + console.log(` ✓ Updated to role: ${userData.role}`); + } else { + // Add user to club + userClub = await UserClub.create({ + userId: user.id, + clubId: club.id, + role: userData.role, + isOwner: userData.isOwner, + approved: true + }); + console.log(` ✓ Added to club with role: ${userData.role}`); + } + } + + console.log('\n✅ Test users created successfully!\n'); + + // Show summary + console.log('Summary:'); + console.log('========================================'); + console.log(`Club: ${club.name}`); + console.log('\nTest Users:'); + + for (const userData of TEST_USERS) { + console.log(` ${userData.email.padEnd(25)} | ${userData.role.padEnd(15)} | Password: test123`); + } + + console.log('\n========================================'); + console.log('You can now login with any of these users!'); + console.log('All passwords are: test123'); + + } catch (error) { + console.error('❌ Error creating test users:', error); + throw error; + } finally { + await sequelize.close(); + } +} + +// Run +createTestUsers().catch(err => { + console.error('Fatal error:', err); + process.exit(1); +}); + + diff --git a/backend/scripts/migratePermissions.js b/backend/scripts/migratePermissions.js new file mode 100644 index 0000000..96d383b --- /dev/null +++ b/backend/scripts/migratePermissions.js @@ -0,0 +1,128 @@ +import UserClub from '../models/UserClub.js'; +import Club from '../models/Club.js'; +import User from '../models/User.js'; +import sequelize from '../database.js'; + +/** + * Migration script to set up permissions for existing clubs + * This script: + * 1. Sets default role='member' for all approved users without a role + * 2. Identifies and marks the first user (by creation date) of each club as owner + */ + +async function migratePermissions() { + console.log('Starting permissions migration...\n'); + + try { + // Get all clubs + const clubs = await Club.findAll({ + include: [{ + model: UserClub, + include: [{ + model: User, + as: 'user' + }], + where: { + approved: true + }, + order: [['createdAt', 'ASC']] + }] + }); + + console.log(`Found ${clubs.length} club(s)\n`); + + for (const club of clubs) { + console.log(`\n--- Club: ${club.name} (ID: ${club.id}) ---`); + + const userClubs = await UserClub.findAll({ + where: { + clubId: club.id, + approved: true + }, + include: [{ + model: User, + as: 'user' + }], + order: [['createdAt', 'ASC']] + }); + + if (userClubs.length === 0) { + console.log(' No approved members found.'); + continue; + } + + // First user becomes owner + const firstUser = userClubs[0]; + + console.log(` Members found: ${userClubs.length}`); + console.log(` First member (will be owner): ${firstUser.user.email}`); + + for (let i = 0; i < userClubs.length; i++) { + const userClub = userClubs[i]; + const isFirstUser = i === 0; + + // Set role if not set + if (!userClub.role) { + userClub.role = isFirstUser ? 'admin' : 'member'; + } + + // Set owner flag + userClub.isOwner = isFirstUser; + + await userClub.save(); + + console.log(` ✓ Updated ${userClub.user.email}: role=${userClub.role}, isOwner=${userClub.isOwner}`); + } + } + + console.log('\n✅ Migration completed successfully!'); + console.log('\nSummary:'); + + // Show summary + const owners = await UserClub.findAll({ + where: { + isOwner: true + }, + include: [ + { + model: User, + as: 'user' + }, + { + model: Club, + as: 'club' + } + ] + }); + + console.log(`\nClub Owners (${owners.length}):`); + for (const owner of owners) { + console.log(` - ${owner.club.name}: ${owner.user.email}`); + } + + const admins = await UserClub.count({ + where: { role: 'admin' } + }); + const members = await UserClub.count({ + where: { role: 'member' } + }); + + console.log(`\nRole Distribution:`); + console.log(` - Admins: ${admins}`); + console.log(` - Members: ${members}`); + + } catch (error) { + console.error('❌ Migration failed:', error); + throw error; + } finally { + await sequelize.close(); + } +} + +// Run migration +migratePermissions().catch(err => { + console.error('Fatal error:', err); + process.exit(1); +}); + + diff --git a/backend/scripts/quickFixOwner.js b/backend/scripts/quickFixOwner.js new file mode 100644 index 0000000..90d25ff --- /dev/null +++ b/backend/scripts/quickFixOwner.js @@ -0,0 +1,103 @@ +import UserClub from '../models/UserClub.js'; +import Club from '../models/Club.js'; +import User from '../models/User.js'; +import sequelize from '../database.js'; + +/** + * Quick fix: Set first user of each club as owner/admin + * This is a simplified version for immediate use + */ + +async function quickFixOwners() { + console.log('Quick Fix: Setting club owners...\n'); + + try { + const clubs = await Club.findAll(); + + console.log(`Found ${clubs.length} club(s)\n`); + + for (const club of clubs) { + console.log(`Club: ${club.name} (ID: ${club.id})`); + + // Find all approved members, ordered by creation date + const userClubs = await UserClub.findAll({ + where: { + clubId: club.id, + approved: true + }, + include: [{ + model: User, + as: 'user', + attributes: ['id', 'email'] + }], + order: [['createdAt', 'ASC']] + }); + + if (userClubs.length === 0) { + console.log(' ⚠️ No approved members\n'); + continue; + } + + // First user becomes owner + const firstUserClub = userClubs[0]; + + // Reset all users first (remove owner flag) + await UserClub.update( + { isOwner: false }, + { + where: { + clubId: club.id, + approved: true + } + } + ); + + // Set first user as owner and admin + await firstUserClub.update({ + isOwner: true, + role: 'admin' + }); + + console.log(` ✅ Owner: ${firstUserClub.user.email}`); + + // Set role for other members if not set + for (let i = 1; i < userClubs.length; i++) { + const uc = userClubs[i]; + if (!uc.role) { + await uc.update({ role: 'member' }); + console.log(` 👤 Member: ${uc.user.email}`); + } + } + + console.log(''); + } + + console.log('✅ Quick fix completed!\n'); + + // Show all owners + const owners = await UserClub.findAll({ + where: { isOwner: true }, + include: [ + { model: User, as: 'user', attributes: ['email'] }, + { model: Club, as: 'club', attributes: ['name'] } + ] + }); + + console.log('Current Club Owners:'); + for (const owner of owners) { + console.log(` 📍 ${owner.club.name}: ${owner.user.email} (role: ${owner.role})`); + } + + } catch (error) { + console.error('❌ Error:', error); + throw error; + } finally { + await sequelize.close(); + } +} + +quickFixOwners().catch(err => { + console.error('Fatal error:', err); + process.exit(1); +}); + diff --git a/backend/server.js b/backend/server.js index 67d8805..1aadafe 100644 --- a/backend/server.js +++ b/backend/server.js @@ -39,6 +39,7 @@ import clubTeamRoutes from './routes/clubTeamRoutes.js'; import teamDocumentRoutes from './routes/teamDocumentRoutes.js'; import seasonRoutes from './routes/seasonRoutes.js'; import memberActivityRoutes from './routes/memberActivityRoutes.js'; +import permissionRoutes from './routes/permissionRoutes.js'; import schedulerService from './services/schedulerService.js'; const app = express(); @@ -90,6 +91,7 @@ app.use('/api/club-teams', clubTeamRoutes); app.use('/api/team-documents', teamDocumentRoutes); app.use('/api/seasons', seasonRoutes); app.use('/api/member-activities', memberActivityRoutes); +app.use('/api/permissions', permissionRoutes); app.use(express.static(path.join(__dirname, '../frontend/dist'))); diff --git a/backend/services/clubService.js b/backend/services/clubService.js index 0b1ee26..e55d7f6 100644 --- a/backend/services/clubService.js +++ b/backend/services/clubService.js @@ -4,6 +4,7 @@ import User from '../models/User.js'; import Member from '../models/Member.js'; import { Op, fn, where, col } from 'sequelize'; import { checkAccess } from '../utils/userUtils.js'; +import permissionService from './permissionService.js'; class ClubService { async getAllClubs() { @@ -20,8 +21,15 @@ class ClubService { return await Club.create({ name: clubName }); } - async addUserToClub(userId, clubId) { - return await UserClub.create({ userId: userId, clubId: clubId, approved: true }); + async addUserToClub(userId, clubId, isOwner = false) { + const userClub = await UserClub.create({ + userId: userId, + clubId: clubId, + approved: true, + isOwner: isOwner, + role: isOwner ? 'admin' : 'member' + }); + return userClub; } async getUserClubAccess(userId, clubId) { diff --git a/backend/services/permissionService.js b/backend/services/permissionService.js new file mode 100644 index 0000000..3c00aa5 --- /dev/null +++ b/backend/services/permissionService.js @@ -0,0 +1,366 @@ +import UserClub from '../models/UserClub.js'; +import Club from '../models/Club.js'; +import User from '../models/User.js'; + +/** + * Permission Service + * Handles all permission-related logic + */ + +// Default permissions for each role +const ROLE_PERMISSIONS = { + admin: { + diary: { read: true, write: true, delete: true }, + members: { read: true, write: true, delete: true }, + teams: { read: true, write: true, delete: true }, + schedule: { read: true, write: true, delete: true }, + tournaments: { read: true, write: true, delete: true }, + statistics: { read: true, write: true }, + settings: { read: true, write: true }, + permissions: { read: true, write: true }, // Can manage other users' permissions + approvals: { read: true, write: true }, + mytischtennis_admin: { read: true, write: true }, + predefined_activities: { read: true, write: true, delete: true } + }, + trainer: { + diary: { read: true, write: true, delete: true }, + members: { read: true, write: true, delete: false }, + teams: { read: true, write: true, delete: false }, + schedule: { read: true, write: true, delete: false }, + tournaments: { read: true, write: true, delete: false }, + statistics: { read: true, write: false }, + settings: { read: false, write: false }, + permissions: { read: false, write: false }, + approvals: { read: false, write: false }, + mytischtennis_admin: { read: false, write: false }, + predefined_activities: { read: true, write: true, delete: true } + }, + team_manager: { + diary: { read: false, write: false, delete: false }, + members: { read: true, write: false, delete: false }, + teams: { read: true, write: true, delete: false }, + schedule: { read: true, write: true, delete: false }, + tournaments: { read: true, write: false, delete: false }, + statistics: { read: true, write: false }, + settings: { read: false, write: false }, + permissions: { read: false, write: false }, + approvals: { read: false, write: false }, + mytischtennis_admin: { read: false, write: false }, + predefined_activities: { read: false, write: false, delete: false } + }, + tournament_manager: { + diary: { read: false, write: false, delete: false }, + members: { read: true, write: false, delete: false }, + teams: { read: false, write: false, delete: false }, + schedule: { read: false, write: false, delete: false }, + tournaments: { read: true, write: true, delete: false }, + statistics: { read: true, write: false }, + settings: { read: false, write: false }, + permissions: { read: false, write: false }, + approvals: { read: false, write: false }, + mytischtennis_admin: { read: false, write: false }, + predefined_activities: { read: false, write: false, delete: false } + }, + member: { + diary: { read: false, write: false, delete: false }, + members: { read: false, write: false, delete: false }, + teams: { read: false, write: false, delete: false }, + schedule: { read: false, write: false, delete: false }, + tournaments: { read: false, write: false, delete: false }, + statistics: { read: true, write: false }, + settings: { read: false, write: false }, + permissions: { read: false, write: false }, + approvals: { read: false, write: false }, + mytischtennis_admin: { read: false, write: false }, + predefined_activities: { read: false, write: false, delete: false } + } +}; + +class PermissionService { + /** + * Get user's permissions for a specific club + */ + async getUserClubPermissions(userId, clubId) { + const userClub = await UserClub.findOne({ + where: { + userId, + clubId, + approved: true + } + }); + + if (!userClub) { + return null; + } + + // If user is owner, they have full admin rights + if (userClub.isOwner) { + return { + role: 'admin', + isOwner: true, + permissions: ROLE_PERMISSIONS.admin + }; + } + + // Get role from database, fallback to 'member' if null/undefined + const role = userClub.role || 'member'; + + // Get role-based permissions + const rolePermissions = ROLE_PERMISSIONS[role] || ROLE_PERMISSIONS.member; + + // Merge with custom permissions if any + const customPermissions = userClub.permissions || {}; + const mergedPermissions = this.mergePermissions(rolePermissions, customPermissions); + + return { + role: role, + isOwner: false, + permissions: mergedPermissions + }; + } + + /** + * Check if user has specific permission + */ + async hasPermission(userId, clubId, resource, action) { + const userPermissions = await this.getUserClubPermissions(userId, clubId); + + if (!userPermissions) { + return false; + } + + // Owner always has permission + if (userPermissions.isOwner) { + return true; + } + + // MyTischtennis settings are accessible to all approved members + if (resource === 'mytischtennis') { + return true; + } + + const resourcePermissions = userPermissions.permissions[resource]; + if (!resourcePermissions) { + return false; + } + + return resourcePermissions[action] === true; + } + + /** + * Set user role in club + */ + async setUserRole(userId, clubId, role, updatedByUserId) { + // Check if updater has permission + const canManagePermissions = await this.hasPermission(updatedByUserId, clubId, 'permissions', 'write'); + if (!canManagePermissions) { + throw new Error('Keine Berechtigung zum Ändern von Rollen'); + } + + // Check if target user is owner + const targetUserClub = await UserClub.findOne({ + where: { userId, clubId } + }); + + if (!targetUserClub) { + throw new Error('Benutzer ist kein Mitglied dieses Clubs'); + } + + if (targetUserClub.isOwner) { + throw new Error('Die Rolle des Club-Erstellers kann nicht geändert werden'); + } + + // Validate role + if (!ROLE_PERMISSIONS[role]) { + throw new Error('Ungültige Rolle'); + } + + await targetUserClub.update({ role }); + + return { + success: true, + message: 'Rolle erfolgreich aktualisiert' + }; + } + + /** + * Set custom permissions for user + */ + async setCustomPermissions(userId, clubId, customPermissions, updatedByUserId) { + // Check if updater has permission + const canManagePermissions = await this.hasPermission(updatedByUserId, clubId, 'permissions', 'write'); + if (!canManagePermissions) { + throw new Error('Keine Berechtigung zum Ändern von Berechtigungen'); + } + + // Check if target user is owner + const targetUserClub = await UserClub.findOne({ + where: { userId, clubId } + }); + + if (!targetUserClub) { + throw new Error('Benutzer ist kein Mitglied dieses Clubs'); + } + + if (targetUserClub.isOwner) { + throw new Error('Die Berechtigungen des Club-Erstellers können nicht geändert werden'); + } + + await targetUserClub.update({ permissions: customPermissions }); + + return { + success: true, + message: 'Berechtigungen erfolgreich aktualisiert' + }; + } + + /** + * Set user status (activate/deactivate) + */ + async setUserStatus(userId, clubId, approved, updatedByUserId) { + // Check if updater has permission + const canManagePermissions = await this.hasPermission(updatedByUserId, clubId, 'permissions', 'write'); + if (!canManagePermissions) { + throw new Error('Keine Berechtigung zum Ändern des Status'); + } + + // Check if target user is owner + const targetUserClub = await UserClub.findOne({ + where: { userId, clubId } + }); + + if (!targetUserClub) { + throw new Error('Benutzer ist kein Mitglied dieses Clubs'); + } + + if (targetUserClub.isOwner) { + throw new Error('Der Status des Club-Erstellers kann nicht geändert werden'); + } + + await targetUserClub.update({ approved }); + + return { + success: true, + message: approved ? 'Benutzer erfolgreich aktiviert' : 'Benutzer erfolgreich deaktiviert' + }; + } + + /** + * Get all club members with their permissions + */ + async getClubMembersWithPermissions(clubId, requestingUserId) { + // Check if requester has permission to read permissions + const canReadPermissions = await this.hasPermission(requestingUserId, clubId, 'permissions', 'read'); + if (!canReadPermissions) { + throw new Error('Keine Berechtigung zum Anzeigen von Berechtigungen'); + } + + const userClubs = await UserClub.findAll({ + where: { + clubId + }, + include: [{ + model: User, + as: 'user', + attributes: ['id', 'email'] + }] + }); + + return userClubs.map(uc => ({ + userId: uc.userId, + user: uc.user, + role: uc.role, + isOwner: uc.isOwner, + approved: uc.approved, + permissions: uc.permissions, + effectivePermissions: this.getEffectivePermissions(uc) + })); + } + + /** + * Get effective permissions (role + custom) + */ + getEffectivePermissions(userClub) { + if (userClub.isOwner) { + return ROLE_PERMISSIONS.admin; + } + + const rolePermissions = ROLE_PERMISSIONS[userClub.role] || ROLE_PERMISSIONS.member; + const customPermissions = userClub.permissions || {}; + + return this.mergePermissions(rolePermissions, customPermissions); + } + + /** + * Merge role permissions with custom permissions + */ + mergePermissions(rolePermissions, customPermissions) { + const merged = { ...rolePermissions }; + + for (const resource in customPermissions) { + if (!merged[resource]) { + merged[resource] = {}; + } + merged[resource] = { + ...merged[resource], + ...customPermissions[resource] + }; + } + + return merged; + } + + /** + * Mark user as club owner (used when creating a club) + */ + async setClubOwner(userId, clubId) { + const userClub = await UserClub.findOne({ + where: { userId, clubId } + }); + + if (!userClub) { + throw new Error('UserClub relationship not found'); + } + + await userClub.update({ + isOwner: true, + role: 'admin', + approved: true + }); + } + + /** + * Get all available roles + */ + getAvailableRoles() { + return [ + { value: 'admin', label: 'Administrator', description: 'Vollzugriff auf alle Funktionen' }, + { value: 'trainer', label: 'Trainer', description: 'Kann Trainingseinheiten, Mitglieder und Teams verwalten' }, + { value: 'team_manager', label: 'Mannschaftsführer', description: 'Kann Teams und Spielpläne verwalten' }, + { value: 'tournament_manager', label: 'Turnierleiter', description: 'Kann Turniere verwalten' }, + { value: 'member', label: 'Mitglied', description: 'Kann nur Trainings-Statistiken ansehen' } + ]; + } + + /** + * Get permission structure for frontend + */ + getPermissionStructure() { + return { + diary: { label: 'Trainingstagebuch', actions: ['read', 'write', 'delete'] }, + members: { label: 'Mitglieder', actions: ['read', 'write', 'delete'] }, + teams: { label: 'Teams', actions: ['read', 'write', 'delete'] }, + schedule: { label: 'Spielpläne', actions: ['read', 'write', 'delete'] }, + tournaments: { label: 'Turniere', actions: ['read', 'write', 'delete'] }, + statistics: { label: 'Statistiken', actions: ['read', 'write'] }, + settings: { label: 'Einstellungen', actions: ['read', 'write'] }, + permissions: { label: 'Berechtigungsverwaltung', actions: ['read', 'write'] }, + approvals: { label: 'Freigaben (Mitgliedsanträge)', actions: ['read', 'write'] }, + mytischtennis_admin: { label: 'MyTischtennis Admin', actions: ['read', 'write'] }, + predefined_activities: { label: 'Vordefinierte Aktivitäten', actions: ['read', 'write', 'delete'] } + }; + } +} + +export default new PermissionService(); + diff --git a/backend/services/schedulerService.js b/backend/services/schedulerService.js index ddc7bd9..9dd7270 100644 --- a/backend/services/schedulerService.js +++ b/backend/services/schedulerService.js @@ -11,7 +11,7 @@ class SchedulerService { /** * Start the scheduler - */ + */ start() { if (this.isRunning) { devLog('Scheduler is already running'); diff --git a/frontend/src/App.vue b/frontend/src/App.vue index e9766ce..e9f114a 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -7,6 +7,28 @@ Trainingstagebuch +
+ +
+ + 🔗 + myTischtennis-Account + + + 🔐 + Berechtigungen + + + +
+
@@ -179,10 +184,25 @@ export default { selectedClub: null, sessionInterval: null, logoUrl, + userDropdownOpen: false, }; }, computed: { - ...mapGetters(['isAuthenticated', 'currentClub', 'clubs', 'sidebarCollapsed']), + ...mapGetters(['isAuthenticated', 'currentClub', 'clubs', 'sidebarCollapsed', 'username', 'hasPermission', 'isClubOwner', 'userRole']), + canManageApprovals() { + // Nur anzeigen, wenn Permissions geladen sind UND Berechtigung vorhanden + if (!this.currentClub) return false; + + // Owner oder Admin können Freigaben verwalten + return this.isClubOwner || this.userRole === 'admin' || this.hasPermission('approvals', 'read'); + }, + canManagePermissions() { + // Nur anzeigen, wenn Permissions geladen sind UND Berechtigung vorhanden + if (!this.currentClub) return false; + + // Owner oder Admin können Berechtigungen verwalten + return this.isClubOwner || this.userRole === 'admin' || this.hasPermission('permissions', 'read'); + }, }, watch: { selectedClub(newVal) { @@ -211,6 +231,16 @@ export default { }, }, methods: { + toggleUserDropdown(event) { + event.stopPropagation(); + this.userDropdownOpen = !this.userDropdownOpen; + }, + handleClickOutside(event) { + const userMenu = event.target.closest('.user-menu'); + if (!userMenu && this.userDropdownOpen) { + this.userDropdownOpen = false; + } + }, // Dialog Helper Methods async showInfo(title, message, details = '', type = 'info') { this.infoDialog = { @@ -286,6 +316,9 @@ export default { } }, async mounted() { + // Click-outside handler für User-Dropdown + document.addEventListener('click', this.handleClickOutside); + // Nur Daten laden, wenn der Benutzer authentifiziert ist if (this.isAuthenticated) { try { @@ -304,6 +337,7 @@ export default { }, beforeUnmount() { clearInterval(this.sessionInterval); + document.removeEventListener('click', this.handleClickOutside); } }; @@ -325,6 +359,10 @@ export default { position: relative; z-index: 1000; flex-shrink: 0; + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 1.5rem; } .header-content { @@ -344,6 +382,105 @@ export default { /* Schriftgröße bleibt wie in der main.scss definiert */ } +.user-menu { + position: relative; +} + +.user-info { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background-color: rgba(255, 255, 255, 0.1); + border-radius: 20px; + font-size: 0.9rem; + color: white; + border: none; + cursor: pointer; + transition: background-color 0.2s ease; +} + +.user-info:hover { + background-color: rgba(255, 255, 255, 0.2); +} + +.user-icon { + font-size: 1.2rem; +} + +.user-email { + font-weight: 500; +} + +.dropdown-arrow { + font-size: 0.7rem; + margin-left: 0.25rem; + transition: transform 0.2s ease; +} + +.user-dropdown { + position: absolute; + top: calc(100% + 0.5rem); + right: 0; + background: white; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + min-width: 200px; + overflow: hidden; + z-index: 10000; + animation: dropdownFadeIn 0.2s ease; +} + +@keyframes dropdownFadeIn { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.dropdown-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + color: #333; + text-decoration: none; + background: none; + border: none; + width: 100%; + text-align: left; + cursor: pointer; + font-size: 0.9rem; + transition: background-color 0.15s ease; +} + +.dropdown-item:hover { + background-color: #f5f5f5; +} + +.dropdown-icon { + font-size: 1.1rem; +} + +.dropdown-divider { + height: 1px; + background-color: #e0e0e0; + margin: 0.25rem 0; +} + +.logout-item { + color: #dc3545; + font-weight: 500; +} + +.logout-item:hover { + background-color: #fff5f5; +} + .home-link { display: inline-flex; align-items: center; @@ -699,6 +836,15 @@ export default { font-size: 1rem; } + .user-info { + font-size: 0.75rem; + padding: 0.3rem 0.6rem; + } + + .user-email { + display: none; /* Nur Icon auf mobile */ + } + .sidebar-content { padding: 0.5rem; } diff --git a/frontend/src/composables/usePermissions.js b/frontend/src/composables/usePermissions.js new file mode 100644 index 0000000..8900388 --- /dev/null +++ b/frontend/src/composables/usePermissions.js @@ -0,0 +1,66 @@ +import { computed } from 'vue'; +import { useStore } from 'vuex'; + +/** + * Composable for permission checks in Vue components + */ +export function usePermissions() { + const store = useStore(); + + const permissions = computed(() => store.getters.currentPermissions); + const isOwner = computed(() => store.getters.isClubOwner); + const userRole = computed(() => store.getters.userRole); + + /** + * Check if user has specific permission + * @param {string} resource - Resource name (diary, members, teams, etc.) + * @param {string} action - Action type (read, write, delete) + * @returns {boolean} + */ + const can = (resource, action = 'read') => { + return store.getters.hasPermission(resource, action); + }; + + /** + * Check if user can read + */ + const canRead = (resource) => can(resource, 'read'); + + /** + * Check if user can write + */ + const canWrite = (resource) => can(resource, 'write'); + + /** + * Check if user can delete + */ + const canDelete = (resource) => can(resource, 'delete'); + + /** + * Check if user is admin (owner or admin role) + */ + const isAdmin = computed(() => { + return isOwner.value || userRole.value === 'admin'; + }); + + /** + * Check if user has specific role + */ + const hasRole = (role) => { + return userRole.value === role; + }; + + return { + permissions, + isOwner, + isAdmin, + userRole, + can, + canRead, + canWrite, + canDelete, + hasRole + }; +} + + diff --git a/frontend/src/directives/permissions.js b/frontend/src/directives/permissions.js new file mode 100644 index 0000000..7541bad --- /dev/null +++ b/frontend/src/directives/permissions.js @@ -0,0 +1,198 @@ +/** + * Vue directive for permission-based element visibility + * Usage: v-can:resource.action or v-can="'resource.action'" + * + * Examples: + * + * + *
...
+ */ + +const checkPermission = (el, binding, vnode) => { + // Safely access store + if (!vnode.appContext || !vnode.appContext.config || !vnode.appContext.config.globalProperties) { + // Hide by default if store not available (deny by default) + el.style.display = 'none'; + return; + } + + const store = vnode.appContext.config.globalProperties.$store; + + if (!store) { + // Hide by default if store not found (deny by default) + el.style.display = 'none'; + return; + } + + let resource, action; + + // Parse directive value + if (typeof binding.value === 'string') { + // v-can="'diary.write'" + [resource, action] = binding.value.split('.'); + } else if (binding.arg) { + // v-can:diary.write + resource = binding.arg; + action = Object.keys(binding.modifiers)[0] || 'read'; + } else { + console.warn('v-can directive requires resource and action'); + el.style.display = 'none'; + return; + } + + const hasPermission = store.getters.hasPermission(resource, action); + + if (hasPermission) { + el.style.display = ''; + } else { + el.style.display = 'none'; + } +}; + +export const canDirective = { + mounted(el, binding, vnode) { + // Initial check + checkPermission(el, binding, vnode); + + // Set up watcher for permissions changes + if (vnode.appContext && vnode.appContext.config && vnode.appContext.config.globalProperties) { + const store = vnode.appContext.config.globalProperties.$store; + if (store) { + // Watch both permissions and currentClub + el._permissionUnwatch = store.subscribe((mutation) => { + if (mutation.type === 'setPermissions' || mutation.type === 'setClub') { + checkPermission(el, binding, vnode); + } + }); + } + } + }, + + updated(el, binding, vnode) { + checkPermission(el, binding, vnode); + }, + + unmounted(el) { + // Clean up watcher + if (el._permissionUnwatch) { + el._permissionUnwatch(); + delete el._permissionUnwatch; + } + } +}; + +/** + * Directive for admin-only elements + * Usage: v-admin + */ +const checkAdmin = (el, vnode) => { + if (!vnode.appContext || !vnode.appContext.config || !vnode.appContext.config.globalProperties) { + el.style.display = 'none'; + return; + } + + const store = vnode.appContext.config.globalProperties.$store; + + if (!store) { + el.style.display = 'none'; + return; + } + + const isOwner = store.getters.isClubOwner; + const role = store.getters.userRole; + + if (isOwner || role === 'admin') { + el.style.display = ''; + } else { + el.style.display = 'none'; + } +}; + +export const adminDirective = { + mounted(el, binding, vnode) { + checkAdmin(el, vnode); + + if (vnode.appContext && vnode.appContext.config && vnode.appContext.config.globalProperties) { + const store = vnode.appContext.config.globalProperties.$store; + if (store) { + el._adminUnwatch = store.subscribe((mutation) => { + if (mutation.type === 'setPermissions' || mutation.type === 'setClub') { + checkAdmin(el, vnode); + } + }); + } + } + }, + + updated(el, binding, vnode) { + checkAdmin(el, vnode); + }, + + unmounted(el) { + if (el._adminUnwatch) { + el._adminUnwatch(); + delete el._adminUnwatch; + } + } +}; + +/** + * Directive for owner-only elements + * Usage: v-owner + */ +const checkOwner = (el, vnode) => { + if (!vnode.appContext || !vnode.appContext.config || !vnode.appContext.config.globalProperties) { + el.style.display = 'none'; + return; + } + + const store = vnode.appContext.config.globalProperties.$store; + + if (!store) { + el.style.display = 'none'; + return; + } + + const isOwner = store.getters.isClubOwner; + + if (isOwner) { + el.style.display = ''; + } else { + el.style.display = 'none'; + } +}; + +export const ownerDirective = { + mounted(el, binding, vnode) { + checkOwner(el, vnode); + + if (vnode.appContext && vnode.appContext.config && vnode.appContext.config.globalProperties) { + const store = vnode.appContext.config.globalProperties.$store; + if (store) { + el._ownerUnwatch = store.subscribe((mutation) => { + if (mutation.type === 'setPermissions' || mutation.type === 'setClub') { + checkOwner(el, vnode); + } + }); + } + } + }, + + updated(el, binding, vnode) { + checkOwner(el, vnode); + }, + + unmounted(el) { + if (el._ownerUnwatch) { + el._ownerUnwatch(); + delete el._ownerUnwatch; + } + } +}; + +export default { + can: canDirective, + admin: adminDirective, + owner: ownerDirective +}; + diff --git a/frontend/src/main.js b/frontend/src/main.js index 931f116..341a087 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -4,9 +4,16 @@ import router from './router'; import store from './store'; import '@/assets/css/main.scss'; import './assets/css/vue-multiselect.css'; +import permissionDirectives from './directives/permissions.js'; const app = createApp(App); app.config.devtools = true; + +// Register permission directives +app.directive('can', permissionDirectives.can); +app.directive('admin', permissionDirectives.admin); +app.directive('owner', permissionDirectives.owner); + app .use(router) .use(store) diff --git a/frontend/src/router.js b/frontend/src/router.js index 264802f..d38a887 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -15,6 +15,7 @@ import PredefinedActivities from './views/PredefinedActivities.vue'; import OfficialTournaments from './views/OfficialTournaments.vue'; import MyTischtennisAccount from './views/MyTischtennisAccount.vue'; import TeamManagementView from './views/TeamManagementView.vue'; +import PermissionsView from './views/PermissionsView.vue'; import Impressum from './views/Impressum.vue'; import Datenschutz from './views/Datenschutz.vue'; @@ -35,6 +36,7 @@ const routes = [ { path: '/official-tournaments', component: OfficialTournaments }, { path: '/mytischtennis-account', component: MyTischtennisAccount }, { path: '/team-management', component: TeamManagementView }, + { path: '/permissions', component: PermissionsView }, { path: '/impressum', component: Impressum }, { path: '/datenschutz', component: Datenschutz }, ]; diff --git a/frontend/src/store.js b/frontend/src/store.js index 8391a3a..dcdfc06 100644 --- a/frontend/src/store.js +++ b/frontend/src/store.js @@ -14,6 +14,13 @@ const store = createStore({ this.clubs = []; } })(), + permissions: (() => { + try { + return JSON.parse(localStorage.getItem('clubPermissions')) || {}; + } catch (e) { + return {}; + } + })(), // { clubId: { role, isOwner, permissions: {...} } } dialogs: [], // Array von offenen Dialogen dialogCounter: 0, // Zähler für eindeutige Dialog-IDs sidebarCollapsed: (() => { @@ -44,6 +51,17 @@ const store = createStore({ state.clubs = clubs; localStorage.setItem('clubs', JSON.stringify(clubs)); }, + setPermissions(state, { clubId, permissions }) { + state.permissions = { + ...state.permissions, + [clubId]: permissions + }; + localStorage.setItem('clubPermissions', JSON.stringify(state.permissions)); + }, + clearPermissions(state) { + state.permissions = {}; + localStorage.removeItem('clubPermissions'); + }, setSidebarCollapsed(state, collapsed) { state.sidebarCollapsed = collapsed; localStorage.setItem('sidebarCollapsed', collapsed.toString()); @@ -105,12 +123,33 @@ const store = createStore({ logout({ commit }) { commit('clearToken'); commit('clearUsername'); + commit('clearPermissions'); router.push('/login'); // Leitet den Benutzer zur Login-Seite um // window.location.reload() entfernt, um Endlos-Neuladeschleife zu verhindern }, - setCurrentClub({ commit }, club) { + async setCurrentClub({ commit, dispatch }, club) { commit('setClub', club); + // Load permissions for this club + await dispatch('loadPermissions', club); + }, + + async loadPermissions({ commit }, clubId) { + try { + const response = await apiClient.get(`/permissions/${clubId}`); + commit('setPermissions', { clubId, permissions: response.data }); + } catch (error) { + console.error('Error loading permissions:', error); + // Set default permissions (read-only) + commit('setPermissions', { + clubId, + permissions: { + role: 'member', + isOwner: false, + permissions: {} + } + }); + } }, setClubs({ commit }, clubs) { commit('setClubsMutation', clubs); @@ -146,6 +185,31 @@ const store = createStore({ const club = state.clubs.find(club => club.id === parseInt(state.currentClub)); return club ? club.name : ''; }, + // Permission getters + currentPermissions: state => { + if (!state.currentClub) return null; + return state.permissions[state.currentClub] || null; + }, + hasPermission: (state) => (resource, action) => { + if (!state.currentClub) return false; + const perms = state.permissions[state.currentClub]; + if (!perms) return false; + if (perms.isOwner) return true; + if (resource === 'mytischtennis') return true; // MyTischtennis für alle + const resourcePerms = perms.permissions[resource]; + if (!resourcePerms) return false; + return resourcePerms[action] === true; + }, + isClubOwner: state => { + if (!state.currentClub) return false; + const perms = state.permissions[state.currentClub]; + return perms?.isOwner || false; + }, + userRole: state => { + if (!state.currentClub) return null; + const perms = state.permissions[state.currentClub]; + return perms?.role || null; // null wenn nicht geladen, nicht 'member' + }, // Dialog-Getters dialogs: state => state.dialogs, minimizedDialogs: state => state.dialogs.filter(dialog => dialog.isMinimized), diff --git a/frontend/src/views/PendingApprovalsView.vue b/frontend/src/views/PendingApprovalsView.vue index 8cd3249..64a72f2 100644 --- a/frontend/src/views/PendingApprovalsView.vue +++ b/frontend/src/views/PendingApprovalsView.vue @@ -124,7 +124,12 @@ export default { const response = await apiClient.get(`/clubs/pending/${this.currentClub}`); this.pendingUsers = response.data.map(entry => entry.user); } catch (error) { - this.showInfo('Fehler', 'Fehler beim Laden der ausstehenden Anfragen', '', 'error'); + if (error.response?.status === 403) { + await this.showInfo('Keine Berechtigung', 'Sie haben keine Berechtigung, Freigaben zu verwalten.', 'Nur Administratoren können Mitgliedsanfragen bearbeiten.', 'error'); + this.$router.push('/'); + } else { + this.showInfo('Fehler', 'Fehler beim Laden der ausstehenden Anfragen', error.response?.data?.error || '', 'error'); + } } }, async approveUser(userId) { diff --git a/frontend/src/views/PermissionsView.vue b/frontend/src/views/PermissionsView.vue new file mode 100644 index 0000000..19bd988 --- /dev/null +++ b/frontend/src/views/PermissionsView.vue @@ -0,0 +1,631 @@ + + + + + + From 48bbc8015b1126e354704d432e0bc6a74cf37ff2 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Fri, 17 Oct 2025 11:55:43 +0200 Subject: [PATCH 034/113] Enhance permission management by adding caching control and improving permission parsing Implement middleware to disable caching for permission routes, ensuring up-to-date responses. Update permission parsing logic in the backend to handle JSON strings more robustly, preventing errors during permission retrieval. Enhance the frontend PermissionsView with improved UI elements for managing permissions, including reset functionality and better state representation for actions. Ensure that only explicitly set permissions are saved, optimizing data handling. --- backend/routes/permissionRoutes.js | 11 + backend/services/permissionService.js | 49 ++++- frontend/src/views/PermissionsView.vue | 278 +++++++++++++++++++++++-- 3 files changed, 310 insertions(+), 28 deletions(-) diff --git a/backend/routes/permissionRoutes.js b/backend/routes/permissionRoutes.js index b7e2bf0..c77c40c 100644 --- a/backend/routes/permissionRoutes.js +++ b/backend/routes/permissionRoutes.js @@ -5,6 +5,17 @@ import permissionController from '../controllers/permissionController.js'; const router = express.Router(); +// Middleware to disable caching for permission routes +const noCache = (req, res, next) => { + res.set('Cache-Control', 'no-store, no-cache, must-revalidate, private'); + res.set('Pragma', 'no-cache'); + res.set('Expires', '0'); + next(); +}; + +// Apply no-cache to all routes +router.use(noCache); + // Get available roles (no club context needed) router.get('/roles/available', authenticate, permissionController.getAvailableRoles); diff --git a/backend/services/permissionService.js b/backend/services/permissionService.js index 3c00aa5..02855a0 100644 --- a/backend/services/permissionService.js +++ b/backend/services/permissionService.js @@ -26,7 +26,7 @@ const ROLE_PERMISSIONS = { diary: { read: true, write: true, delete: true }, members: { read: true, write: true, delete: false }, teams: { read: true, write: true, delete: false }, - schedule: { read: true, write: true, delete: false }, + schedule: { read: true, write: false, delete: false }, tournaments: { read: true, write: true, delete: false }, statistics: { read: true, write: false }, settings: { read: false, write: false }, @@ -266,15 +266,30 @@ class PermissionService { }] }); - return userClubs.map(uc => ({ - userId: uc.userId, - user: uc.user, - role: uc.role, - isOwner: uc.isOwner, - approved: uc.approved, - permissions: uc.permissions, - effectivePermissions: this.getEffectivePermissions(uc) - })); + return userClubs.map(uc => { + // Parse permissions JSON string to object + let parsedPermissions = null; + if (uc.permissions) { + try { + parsedPermissions = typeof uc.permissions === 'string' + ? JSON.parse(uc.permissions) + : uc.permissions; + } catch (err) { + console.error('Error parsing permissions JSON:', err); + parsedPermissions = null; + } + } + + return { + userId: uc.userId, + user: uc.user, + role: uc.role, + isOwner: uc.isOwner, + approved: uc.approved, + permissions: parsedPermissions, + effectivePermissions: this.getEffectivePermissions(uc) + }; + }); } /** @@ -286,7 +301,19 @@ class PermissionService { } const rolePermissions = ROLE_PERMISSIONS[userClub.role] || ROLE_PERMISSIONS.member; - const customPermissions = userClub.permissions || {}; + + // Parse permissions JSON string to object + let customPermissions = {}; + if (userClub.permissions) { + try { + customPermissions = typeof userClub.permissions === 'string' + ? JSON.parse(userClub.permissions) + : userClub.permissions; + } catch (err) { + console.error('Error parsing permissions JSON in getEffectivePermissions:', err); + customPermissions = {}; + } + } return this.mergePermissions(rolePermissions, customPermissions); } diff --git a/frontend/src/views/PermissionsView.vue b/frontend/src/views/PermissionsView.vue index 19bd988..d38a246 100644 --- a/frontend/src/views/PermissionsView.vue +++ b/frontend/src/views/PermissionsView.vue @@ -102,21 +102,27 @@
-

{{ resource.label }}

+
+

{{ resource.label }}

+ +
- +
+ {{ getActionLabel(action) }} + +
@@ -150,7 +156,7 @@ export default { return !can('permissions', 'write'); }); - const loadData = async () => { + const loadData = async (force = false) => { loading.value = true; error.value = null; @@ -164,7 +170,8 @@ export default { permissionStructure.value = structureResponse.data; // Load members with permissions - const membersResponse = await apiClient.get(`/permissions/${currentClub.value}/members`); + const bust = force ? `?t=${Date.now()}` : ''; + const membersResponse = await apiClient.get(`/permissions/${currentClub.value}/members${bust}`); members.value = membersResponse.data; } catch (err) { console.error('Error loading permissions data:', err); @@ -223,18 +230,37 @@ export default { } }; - const openPermissionsDialog = (member) => { + const openPermissionsDialog = async (member) => { selectedMember.value = member; - // Initialize custom permissions + console.log('Opening dialog for member:', member.user?.email); + console.log('Member permissions from DB:', member.permissions); + + // Load fresh data for this specific member to ensure we have the latest permissions + try { + const membersResponse = await apiClient.get(`/permissions/${currentClub.value}/members?t=${Date.now()}`); + const freshMember = membersResponse.data.find(m => m.userId === member.userId); + if (freshMember) { + selectedMember.value = freshMember; + console.log('Fresh member data:', freshMember.permissions); + } + } catch (err) { + console.error('Error loading fresh member data:', err); + // Continue with existing data if fresh load fails + } + + // Initialize custom permissions from the stored custom permissions, not effective permissions customPermissions.value = {}; for (const resource in permissionStructure.value) { customPermissions.value[resource] = {}; - const effectivePerms = member.effectivePermissions[resource] || {}; + const customPerms = selectedMember.value.permissions || {}; for (const action of permissionStructure.value[resource].actions) { - customPermissions.value[resource][action] = effectivePerms[action] || false; + // Use custom permissions if they exist, otherwise undefined (inherit from role) + customPermissions.value[resource][action] = customPerms[resource]?.[action]; } } + + console.log('Initialized customPermissions:', customPermissions.value); }; const closePermissionsDialog = () => { @@ -244,24 +270,183 @@ export default { const saveCustomPermissions = async () => { try { - await apiClient.put( + // Only send permissions that are explicitly set (not undefined) + const permissionsToSave = {}; + for (const resourceKey in customPermissions.value) { + permissionsToSave[resourceKey] = {}; + for (const action in customPermissions.value[resourceKey]) { + const value = customPermissions.value[resourceKey][action]; + if (value !== undefined) { + permissionsToSave[resourceKey][action] = value; + } + } + // Only include resource if it has at least one permission set + if (Object.keys(permissionsToSave[resourceKey]).length === 0) { + delete permissionsToSave[resourceKey]; + } + } + + console.log('Saving permissions:', permissionsToSave); + + const response = await apiClient.put( `/permissions/${currentClub.value}/user/${selectedMember.value.userId}/permissions`, - { permissions: customPermissions.value } + { permissions: permissionsToSave } ); + console.log('Save response:', response.data); + + // Update local member data immediately + const memberIndex = members.value.findIndex(m => m.userId === selectedMember.value.userId); + if (memberIndex !== -1) { + members.value[memberIndex].permissions = permissionsToSave; + // Recalculate effective permissions + const permService = { getEffectivePermissions: (uc) => { + const rolePerms = getRolePermissions(uc.role); + const customPerms = uc.permissions || {}; + const merged = JSON.parse(JSON.stringify(rolePerms)); + for (const resource in customPerms) { + if (!merged[resource]) merged[resource] = {}; + merged[resource] = { ...merged[resource], ...customPerms[resource] }; + } + return merged; + }}; + members.value[memberIndex].effectivePermissions = permService.getEffectivePermissions(members.value[memberIndex]); + } + closePermissionsDialog(); - await loadData(); + // Hard reload from server to reflect saved values (cache-busting) + await loadData(true); } catch (err) { console.error('Error saving permissions:', err); alert(err.response?.data?.error || 'Fehler beim Speichern der Berechtigungen'); } }; + const togglePermission = (resourceKey, action) => { + const current = customPermissions.value[resourceKey][action]; + const rolePermissions = getRolePermissions(selectedMember.value.role); + const roleValue = rolePermissions[resourceKey]?.[action]; + + // Toggle between: role value -> opposite of role value -> role value + if (current === undefined) { + // Currently using role value, set to opposite + customPermissions.value[resourceKey][action] = !roleValue; + } else { + // Currently overridden, reset to role value + customPermissions.value[resourceKey][action] = undefined; + } + }; + + const resetResource = (resourceKey) => { + for (const action of permissionStructure.value[resourceKey].actions) { + customPermissions.value[resourceKey][action] = undefined; + } + }; + + const resetAll = () => { + for (const resourceKey in permissionStructure.value) { + resetResource(resourceKey); + } + }; + + const stateLabel = (val, resourceKey, action) => { + if (val === true) return 'Erlaubt'; + if (val === false) return 'Verboten'; + + // Show role-based permission + const rolePermissions = getRolePermissions(selectedMember.value.role); + const roleValue = rolePermissions[resourceKey]?.[action]; + return roleValue ? 'Erlaubt' : 'Verboten'; + }; + + const stateClass = (val, resourceKey, action) => { + // If undefined, it's using role value (inherit) + if (val === undefined) { + return 'state-inherit'; + } + + // If explicitly set, show as override + return val === true ? 'state-allow' : 'state-deny'; + }; + const getRoleLabel = (roleValue) => { const role = availableRoles.value.find(r => r.value === roleValue); return role ? role.label : roleValue; }; + const getRolePermissions = (role) => { + // Role permissions mapping (should match backend) + const rolePermissions = { + admin: { + diary: { read: true, write: true, delete: true }, + members: { read: true, write: true, delete: true }, + teams: { read: true, write: true, delete: true }, + schedule: { read: true, write: true, delete: true }, + tournaments: { read: true, write: true, delete: true }, + statistics: { read: true, write: true }, + settings: { read: true, write: true }, + permissions: { read: true, write: true }, + approvals: { read: true, write: true }, + mytischtennis_admin: { read: true, write: true }, + predefined_activities: { read: true, write: true, delete: true } + }, + trainer: { + diary: { read: true, write: true, delete: true }, + members: { read: true, write: true, delete: false }, + teams: { read: true, write: true, delete: false }, + schedule: { read: true, write: false, delete: false }, + tournaments: { read: true, write: true, delete: false }, + statistics: { read: true, write: false }, + settings: { read: false, write: false }, + permissions: { read: false, write: false }, + approvals: { read: false, write: false }, + mytischtennis_admin: { read: false, write: false }, + predefined_activities: { read: true, write: true, delete: true } + }, + team_manager: { + diary: { read: false, write: false, delete: false }, + members: { read: true, write: false, delete: false }, + teams: { read: true, write: true, delete: false }, + schedule: { read: true, write: true, delete: false }, + tournaments: { read: true, write: false, delete: false }, + statistics: { read: true, write: false }, + settings: { read: false, write: false }, + permissions: { read: false, write: false }, + approvals: { read: false, write: false }, + mytischtennis_admin: { read: false, write: false }, + predefined_activities: { read: false, write: false, delete: false } + }, + tournament_manager: { + diary: { read: false, write: false, delete: false }, + members: { read: true, write: false, delete: false }, + teams: { read: false, write: false, delete: false }, + schedule: { read: false, write: false, delete: false }, + tournaments: { read: true, write: true, delete: false }, + statistics: { read: true, write: false }, + settings: { read: false, write: false }, + permissions: { read: false, write: false }, + approvals: { read: false, write: false }, + mytischtennis_admin: { read: false, write: false }, + predefined_activities: { read: false, write: false, delete: false } + }, + member: { + diary: { read: false, write: false, delete: false }, + members: { read: false, write: false, delete: false }, + teams: { read: false, write: false, delete: false }, + schedule: { read: false, write: false, delete: false }, + tournaments: { read: false, write: false, delete: false }, + statistics: { read: true, write: false }, + settings: { read: false, write: false }, + permissions: { read: false, write: false }, + approvals: { read: false, write: false }, + mytischtennis_admin: { read: false, write: false }, + predefined_activities: { read: false, write: false, delete: false } + } + }; + + return rolePermissions[role] || rolePermissions.member; + }; + const getActionLabel = (action) => { const labels = { read: 'Lesen', @@ -297,6 +482,11 @@ export default { openPermissionsDialog, closePermissionsDialog, saveCustomPermissions, + togglePermission, + resetResource, + resetAll, + stateLabel, + stateClass, getRoleLabel, getActionLabel }; @@ -570,6 +760,12 @@ th { padding: 15px; } +.permission-group-header { + display: flex; + justify-content: space-between; + align-items: center; +} + .permission-group h4 { margin: 0 0 12px 0; color: #2c3e50; @@ -582,6 +778,17 @@ th { gap: 10px; } +.permission-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.permission-action-label { + color: #2c3e50; +} + .permission-checkbox { display: flex; align-items: center; @@ -627,5 +834,42 @@ th { .btn-secondary:hover { background-color: #e0e0e0; } + +.btn-reset { + background: #f5f5f5; + border: 1px solid #ddd; + color: #333; + padding: 6px 10px; + border-radius: 4px; + cursor: pointer; +} + +.btn-reset:disabled { + opacity: 0.6; + cursor: default; +} + +.perm-state { + padding: 6px 10px; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.9em; +} + +.state-inherit { + background: #f5f5f5; + color: #333; +} + +.state-allow { + background: #e8f5e9; + color: #2e7d32; +} + +.state-deny { + background: #ffebee; + color: #c62828; +} From f1b37d131fd78de7ff0628ca3549f60c66be61ce Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Fri, 17 Oct 2025 12:48:58 +0200 Subject: [PATCH 035/113] Refactor predefined activity routes to simplify permission checks and enhance manual navigation in the frontend Updated backend predefined activity routes to remove explicit permission checks, allowing for streamlined authentication. Modified frontend App.vue to eliminate automatic redirection to the training-stats page, enabling users to navigate manually. This change improves user experience by providing more control over navigation. --- backend/routes/predefinedActivityRoutes.js | 20 ++++++++++---------- frontend/src/App.vue | 3 +-- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/backend/routes/predefinedActivityRoutes.js b/backend/routes/predefinedActivityRoutes.js index e590284..bf953fa 100644 --- a/backend/routes/predefinedActivityRoutes.js +++ b/backend/routes/predefinedActivityRoutes.js @@ -19,16 +19,16 @@ import fs from 'fs'; const router = express.Router(); const upload = multer({ storage: multer.memoryStorage() }); -router.post('/', authenticate, authorize('predefined_activities', 'write'), createPredefinedActivity); -router.get('/', authenticate, authorize('predefined_activities', 'read'), getAllPredefinedActivities); -router.get('/:id', authenticate, authorize('predefined_activities', 'read'), getPredefinedActivityById); -router.put('/:id', authenticate, authorize('predefined_activities', 'write'), updatePredefinedActivity); -router.post('/:id/image', authenticate, authorize('predefined_activities', 'write'), upload.single('image'), uploadPredefinedActivityImage); -router.put('/:id/image', authenticate, authorize('predefined_activities', 'write'), upload.single('image'), uploadPredefinedActivityImage); -router.delete('/:id/image/:imageId', authenticate, authorize('predefined_activities', 'delete'), deletePredefinedActivityImage); -router.get('/search/query', authenticate, authorize('predefined_activities', 'read'), searchPredefinedActivities); -router.post('/merge', authenticate, authorize('predefined_activities', 'write'), mergePredefinedActivities); -router.post('/deduplicate', authenticate, authorize('predefined_activities', 'write'), deduplicatePredefinedActivities); +router.post('/', authenticate, createPredefinedActivity); +router.get('/', authenticate, getAllPredefinedActivities); +router.get('/:id', authenticate, getPredefinedActivityById); +router.put('/:id', authenticate, updatePredefinedActivity); +router.post('/:id/image', authenticate, upload.single('image'), uploadPredefinedActivityImage); +router.put('/:id/image', authenticate, upload.single('image'), uploadPredefinedActivityImage); +router.delete('/:id/image/:imageId', authenticate, deletePredefinedActivityImage); +router.get('/search/query', authenticate, searchPredefinedActivities); +router.post('/merge', authenticate, mergePredefinedActivities); +router.post('/deduplicate', authenticate, deduplicatePredefinedActivities); router.get('/:id/image/:imageId', async (req, res) => { try { const { id, imageId } = req.params; diff --git a/frontend/src/App.vue b/frontend/src/App.vue index e9f114a..0c2f94e 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -211,9 +211,8 @@ export default { currentClub(newVal) { if (newVal === 'new') { this.$router.push('/createclub'); - } else if (newVal) { - this.$router.push('/training-stats'); } + // Removed automatic redirect to training-stats to allow manual navigation }, isAuthenticated(newVal) { if (newVal) { From 4ff021a85c522d0741802a853d1882d598a56aab Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Mon, 20 Oct 2025 23:36:31 +0200 Subject: [PATCH 036/113] Refactor schedule view to improve PDF generation and enhance element referencing Updated ScheduleView.vue to replace direct DOM querying with Vue's ref system for better maintainability. The 'flex-item' div now uses a reference for PDF generation, improving code clarity and performance. This change aligns with best practices in Vue component design. --- frontend/src/views/ScheduleView.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/views/ScheduleView.vue b/frontend/src/views/ScheduleView.vue index 5d34500..3d0196f 100644 --- a/frontend/src/views/ScheduleView.vue +++ b/frontend/src/views/ScheduleView.vue @@ -20,7 +20,7 @@
  • Keine Ligen für diese Saison gefunden
  • -
    +
    - {{ match.code }} {{ match.code }} - {{ match.homePin }} - {{ match.guestPin }} - @@ -698,19 +698,11 @@ export default { return ''; // Keine besondere Farbe }, - async copyToClipboard(text, type) { + async copyToClipboard(text, type, event) { try { await navigator.clipboard.writeText(text); // Zeige eine kurze Bestätigung - const originalText = event.target.textContent; - event.target.textContent = '✓'; - event.target.style.color = '#4CAF50'; - - setTimeout(() => { - event.target.textContent = originalText; - event.target.style.color = ''; - }, 1000); - + this.showCopyFeedback(event.target, text); } catch (err) { console.error('Fehler beim Kopieren:', err); // Fallback für ältere Browser @@ -720,8 +712,40 @@ export default { textArea.select(); document.execCommand('copy'); document.body.removeChild(textArea); + + // Auch für Fallback eine Bestätigung zeigen + this.showCopyFeedback(event.target, text); } }, + + showCopyFeedback(element, originalText) { + // Speichere die ursprünglichen Styles + const originalStyles = { + textContent: element.textContent, + color: element.style.color, + backgroundColor: element.style.backgroundColor, + borderColor: element.style.borderColor, + fontWeight: element.style.fontWeight + }; + + // Ändere das Element zu einem grünen Häkchen + element.textContent = '✓'; + element.style.color = '#4CAF50'; + element.style.backgroundColor = '#e8f5e8'; + element.style.borderColor = '#4CAF50'; + element.style.fontWeight = 'bold'; + element.style.fontSize = '1.2em'; + + // Nach 1.5 Sekunden zurücksetzen + setTimeout(() => { + element.textContent = originalStyles.textContent; + element.style.color = originalStyles.color; + element.style.backgroundColor = originalStyles.backgroundColor; + element.style.borderColor = originalStyles.borderColor; + element.style.fontWeight = originalStyles.fontWeight; + element.style.fontSize = ''; + }, 1500); + }, openMatchReport(match) { const title = `${match.homeTeam?.name || 'N/A'} vs ${match.guestTeam?.name || 'N/A'} - ${this.selectedLeague}`; From d16f250f80183f55ee279da3c5398bd85a1698af Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Fri, 24 Oct 2025 16:35:03 +0200 Subject: [PATCH 040/113] Refactor match and predefined activity services for improved functionality and user experience Removed unnecessary logging from the MatchService to streamline performance. Enhanced the PredefinedActivityService by implementing a more intelligent search feature that splits queries into individual terms, allowing for more precise filtering of activities. Updated the frontend PredefinedActivities.vue to include a search input with real-time results and a clear search button, improving user interaction and accessibility. --- backend/services/matchService.js | 7 -- backend/services/predefinedActivityService.js | 32 ++++-- frontend/src/views/PredefinedActivities.vue | 98 ++++++++++++++++++- 3 files changed, 120 insertions(+), 17 deletions(-) diff --git a/backend/services/matchService.js b/backend/services/matchService.js index 3655be9..2d32001 100644 --- a/backend/services/matchService.js +++ b/backend/services/matchService.js @@ -291,8 +291,6 @@ class MatchService { } const clubName = club.name; - devLog(`Filtering matches for club: ${clubName}`); - // Find all club teams in this league const clubTeams = await ClubTeam.findAll({ where: { @@ -302,8 +300,6 @@ class MatchService { 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: { @@ -316,7 +312,6 @@ class MatchService { }); 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; @@ -331,10 +326,8 @@ class MatchService { ] } }); - 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 = []; } diff --git a/backend/services/predefinedActivityService.js b/backend/services/predefinedActivityService.js index 24ddb74..69d09e9 100644 --- a/backend/services/predefinedActivityService.js +++ b/backend/services/predefinedActivityService.js @@ -58,20 +58,40 @@ class PredefinedActivityService { if (!q || q.length < 2) { return []; } - return await PredefinedActivity.findAll({ + + // Intelligente Suche: Teile den Query in einzelne Begriffe auf + const searchTerms = q.split(/\s+/).filter(term => term.length > 0); + + if (searchTerms.length === 0) { + return []; + } + + // Hole alle Aktivitäten mit Kürzeln + const allActivities = await PredefinedActivity.findAll({ where: { - [Op.or]: [ - { name: { [Op.like]: `%${q}%` } }, - { code: { [Op.like]: `%${q}%` } }, - ], + code: { [Op.ne]: null } // Nur Aktivitäten mit Kürzel }, order: [ [sequelize.literal('code IS NULL'), 'ASC'], ['code', 'ASC'], ['name', 'ASC'], ], - limit: Math.min(parseInt(limit || 20, 10), 50), + limit: 1000 // Höhere Grenze für Filterung }); + + // Filtere die Ergebnisse, um nur die zu finden, die ALLE Begriffe enthalten + const filteredResults = allActivities.filter(activity => { + const code = (activity.code || '').toLowerCase(); + + // Prüfe, ob alle Suchbegriffe im Kürzel enthalten sind + return searchTerms.every(term => { + const normalizedTerm = term.toLowerCase(); + return code.includes(normalizedTerm); + }); + }); + + // Begrenze die Ergebnisse + return filteredResults.slice(0, Math.min(parseInt(limit || 20, 10), 50)); } async mergeActivities(sourceId, targetId) { diff --git a/frontend/src/views/PredefinedActivities.vue b/frontend/src/views/PredefinedActivities.vue index 1f9ef45..ede7a25 100644 --- a/frontend/src/views/PredefinedActivities.vue +++ b/frontend/src/views/PredefinedActivities.vue @@ -6,10 +6,20 @@
    -
    +
    +
    + + +
    -
    -
    • @@ -167,11 +176,17 @@ export default { selectedDrawingData: null, mergeSourceId: '', mergeTargetId: '', + searchQuery: '', + searchResults: [], + isSearching: false, }; }, computed: { sortedActivities() { - return [...(this.activities || [])].sort((a, b) => { + // Wenn gesucht wird, zeige Suchergebnisse, sonst alle Aktivitäten + const activitiesToSort = this.searchQuery.trim() ? this.searchResults : this.activities; + + return [...(activitiesToSort || [])].sort((a, b) => { const ac = (a.code || '').toLocaleLowerCase('de-DE'); const bc = (b.code || '').toLocaleLowerCase('de-DE'); const aEmpty = ac === ''; @@ -221,6 +236,34 @@ export default { this.confirmDialog.isOpen = false; }, + // Suchfunktionen + async onSearchInput() { + const query = this.searchQuery.trim(); + + if (!query || query.length < 2) { + this.searchResults = []; + return; + } + + this.isSearching = true; + try { + const response = await apiClient.get('/predefined-activities/search/query', { + params: { q: query, limit: 50 } + }); + this.searchResults = response.data || []; + } catch (error) { + console.error('Error searching activities:', error); + this.searchResults = []; + } finally { + this.isSearching = false; + } + }, + + clearSearch() { + this.searchQuery = ''; + this.searchResults = []; + }, + parseDrawingData(value) { if (!value) return null; if (typeof value === 'object') return value; @@ -556,5 +599,52 @@ input[type="text"], input[type="number"], textarea { width: 100%; } color: #333; font-size: 1rem; } + +/* Suchfeld Styles */ +.search-section { + position: relative; + margin: 1rem 0; +} + +.search-input { + width: 100%; + padding: 0.75rem 2.5rem 0.75rem 1rem; + border: 2px solid #ddd; + border-radius: var(--border-radius); + font-size: 1rem; + background: white; + transition: border-color 0.3s ease; +} + +.search-input:focus { + outline: none; + border-color: #007bff; + box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1); +} + +.btn-clear-search { + position: absolute; + right: 0.75rem; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + color: #666; + font-size: 1.2rem; + cursor: pointer; + padding: 0.25rem; + border-radius: 50%; + width: 2rem; + height: 2rem; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; +} + +.btn-clear-search:hover { + background: #f8f9fa; + color: #333; +} From bb2164f6664595433b4b146011e866c8e9ed5365 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Fri, 24 Oct 2025 17:06:10 +0200 Subject: [PATCH 041/113] Add league configuration endpoint and frontend integration for myTischtennis Implemented a new POST endpoint in MyTischtennisUrlController to configure leagues from table URLs, including season creation logic. Updated myTischtennisRoutes to include the new route for league configuration. Enhanced the myTischtennisUrlParserService to support parsing of table URLs and added a method for decoding group names. Updated TeamManagementView.vue to prompt users for league configuration when a table URL is detected, providing feedback upon successful configuration and reloading relevant data. --- .../controllers/myTischtennisUrlController.js | 83 ++++++++++++++++++ backend/routes/myTischtennisRoutes.js | 3 + .../services/myTischtennisUrlParserService.js | 84 +++++++++++++++---- frontend/src/views/TeamManagementView.vue | 82 +++++++++++++++++- 4 files changed, 234 insertions(+), 18 deletions(-) diff --git a/backend/controllers/myTischtennisUrlController.js b/backend/controllers/myTischtennisUrlController.js index a38bd92..802aae9 100644 --- a/backend/controllers/myTischtennisUrlController.js +++ b/backend/controllers/myTischtennisUrlController.js @@ -402,6 +402,89 @@ class MyTischtennisUrlController { next(error); } } + + /** + * Configure league from myTischtennis table URL + * POST /api/mytischtennis/configure-league + * Body: { url: string, createSeason?: boolean } + */ + async configureLeague(req, res, next) { + try { + const { url, createSeason } = req.body; + const userIdOrEmail = req.headers.userid; + + if (!url) { + throw new HttpError(400, 'URL is required'); + } + + // Parse URL + const parsedData = myTischtennisUrlParserService.parseUrl(url); + + if (parsedData.urlType !== 'table') { + throw new HttpError(400, 'URL must be a table URL (not a team URL)'); + } + + // Find or create season + let season = await Season.findOne({ + where: { season: parsedData.season } + }); + + if (!season && createSeason) { + season = await Season.create({ + season: parsedData.season, + startDate: new Date(), + endDate: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000) // 1 Jahr später + }); + } + + // Find or create league + let league = await League.findOne({ + where: { + myTischtennisGroupId: parsedData.groupId, + association: parsedData.association + } + }); + + if (!league) { + league = await League.create({ + name: parsedData.groupnameOriginal, // Verwende die originale URL-kodierte Version + myTischtennisGroupId: parsedData.groupId, + association: parsedData.association, + groupname: parsedData.groupnameOriginal, // Verwende die originale URL-kodierte Version + seasonId: season?.id || null + }); + } else { + // Update existing league - aber nur wenn es sich wirklich geändert hat + if (league.name !== parsedData.groupnameOriginal) { + league.name = parsedData.groupnameOriginal; + league.groupname = parsedData.groupnameOriginal; + } + league.seasonId = season?.id || league.seasonId; + await league.save(); + } + + res.json({ + success: true, + message: 'League configured successfully', + data: { + league: { + id: league.id, + name: league.name, + myTischtennisGroupId: league.myTischtennisGroupId, + association: league.association, + groupname: league.groupname + }, + season: season ? { + id: season.id, + name: season.season + } : null, + parsedData + } + }); + } catch (error) { + next(error); + } + } } export default new MyTischtennisUrlController(); diff --git a/backend/routes/myTischtennisRoutes.js b/backend/routes/myTischtennisRoutes.js index 05f457b..7e25cc2 100644 --- a/backend/routes/myTischtennisRoutes.js +++ b/backend/routes/myTischtennisRoutes.js @@ -42,6 +42,9 @@ router.post('/parse-url', myTischtennisUrlController.parseUrl); // POST /api/mytischtennis/configure-team - Configure team from URL router.post('/configure-team', myTischtennisUrlController.configureTeam); +// POST /api/mytischtennis/configure-league - Configure league from table URL +router.post('/configure-league', myTischtennisUrlController.configureLeague); + // POST /api/mytischtennis/fetch-team-data - Manually fetch team data router.post('/fetch-team-data', myTischtennisUrlController.fetchTeamData); diff --git a/backend/services/myTischtennisUrlParserService.js b/backend/services/myTischtennisUrlParserService.js index 7e3451c..ea60bab 100644 --- a/backend/services/myTischtennisUrlParserService.js +++ b/backend/services/myTischtennisUrlParserService.js @@ -15,14 +15,28 @@ class MyTischtennisUrlParserService { // 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\/([^\/]+)\/([^\/]+)/; + // Try different URL patterns - const match = url.match(pattern); + // Pattern 1: Team URL with mannschaft/{teamId}/{teamname} + // /click-tt/{association}/{season}/{type}/{groupname}/gruppe/{groupId}/mannschaft/{teamId}/{teamname}/... + const teamPattern = /\/click-tt\/([^\/]+)\/([^\/]+)\/([^\/]+)\/([^\/]+)\/gruppe\/([^\/]+)\/mannschaft\/([^\/]+)\/([^\/]+)/; + + // Pattern 2: Table/Group URL without team info + // /click-tt/{association}/{season}/{type}/{groupname}/gruppe/{groupId}/tabelle/gesamt + const tablePattern = /\/click-tt\/([^\/]+)\/([^\/]+)\/([^\/]+)\/([^\/]+)\/gruppe\/([^\/]+)\/tabelle\/([^\/]+)/; + + let match = url.match(teamPattern); + let isTeamUrl = true; if (!match) { - throw new Error('URL format not recognized. Expected format: /click-tt/{association}/{season}/{type}/{groupname}/gruppe/{groupId}/mannschaft/{teamId}/{teamname}/...'); + match = url.match(tablePattern); + isTeamUrl = false; + } + + if (!match) { + throw new Error('URL format not recognized. Expected formats:\n' + + '- Team URL: /click-tt/{association}/{season}/{type}/{groupname}/gruppe/{groupId}/mannschaft/{teamId}/{teamname}/...\n' + + '- Table URL: /click-tt/{association}/{season}/{type}/{groupname}/gruppe/{groupId}/tabelle/gesamt'); } const [ @@ -32,28 +46,38 @@ class MyTischtennisUrlParserService { type, groupnameEncoded, groupId, - teamId, - teamnameEncoded + ...rest ] = 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 groupnameDecoded = this.decodeGroupName(groupnameEncoded); const result = { association, season, seasonShort, // Für API-Calls type, - groupname, + groupname: groupnameDecoded, // Dekodierte Version für Anzeige + groupnameOriginal: groupnameEncoded, // Originale URL-kodierte Version groupId, - teamId, - teamname, - originalUrl: url + originalUrl: url, + urlType: isTeamUrl ? 'team' : 'table' }; + if (isTeamUrl) { + // Team URL: extract team info + const [teamId, teamnameEncoded] = rest; + const teamname = decodeURIComponent(teamnameEncoded).replace(/_/g, ' '); // "Harheimer_TC_(J11)" -> "Harheimer TC (J11)" + result.teamId = teamId; + result.teamname = teamname; + } else { + // Table URL: no team info + result.teamId = null; + result.teamname = null; + } + devLog('Parsed myTischtennis URL:', result); return result; @@ -63,6 +87,23 @@ class MyTischtennisUrlParserService { } } + /** + * Decode group name from URL-encoded format + * "2._Kreisklasse_Gr._2" -> "2. Kreisklasse Gr. 2" + */ + decodeGroupName(encodedName) { + // First decode URI components + let decoded = decodeURIComponent(encodedName); + + // Replace underscores with spaces + decoded = decoded.replace(/_/g, ' '); + + // Clean up multiple spaces + decoded = decoded.replace(/\s+/g, ' '); + + return decoded.trim(); + } + /** * Convert short season format to full format * "25/26" -> "2025/2026" @@ -214,6 +255,10 @@ class MyTischtennisUrlParserService { return false; } } + + isValidUrl(url) { + return this.isValidTeamUrl(url); // Alias for backward compatibility + } /** * Build myTischtennis URL from components @@ -229,16 +274,23 @@ class MyTischtennisUrlParserService { groupname, groupId, teamId, - teamname + teamname, + urlType = 'team' } = 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`; + if (urlType === 'table' || !teamId || !teamname) { + // Build table URL + return `https://www.mytischtennis.de/click-tt/${association}/${seasonStr}/${type}/${groupnameEncoded}/gruppe/${groupId}/tabelle/gesamt`; + } else { + // Build team URL + const teamnameEncoded = encodeURIComponent(teamname.replace(/\s/g, '_')); + return `https://www.mytischtennis.de/click-tt/${association}/${seasonStr}/${type}/${groupnameEncoded}/gruppe/${groupId}/mannschaft/${teamId}/${teamnameEncoded}/spielerbilanzen/gesamt`; + } } } diff --git a/frontend/src/views/TeamManagementView.vue b/frontend/src/views/TeamManagementView.vue index 20b33b0..96c58d3 100644 --- a/frontend/src/views/TeamManagementView.vue +++ b/frontend/src/views/TeamManagementView.vue @@ -841,7 +841,33 @@ export default { }; const configureTeamFromUrl = async () => { - if (!parsedMyTischtennisData.value || !teamToEdit.value) { + if (!parsedMyTischtennisData.value) { + return; + } + + // Für Tabellen-URLs: Biete Liga-Konfiguration an + if (parsedMyTischtennisData.value.urlType === 'table') { + const confirmed = await showConfirm( + 'Liga konfigurieren?', + 'Tabellen-URL erkannt', + `Verband: ${parsedMyTischtennisData.value.association}\nSaison: ${parsedMyTischtennisData.value.season}\nLiga: ${parsedMyTischtennisData.value.groupname}\nGruppen-ID: ${parsedMyTischtennisData.value.groupId}\n\nMöchten Sie diese Liga in der Datenbank konfigurieren? Dies ermöglicht es, Tabellendaten automatisch abzurufen.`, + 'info' + ); + + if (confirmed) { + await configureLeagueFromUrl(); + } + return; + } + + // Für Team-URLs: Normale Konfiguration + if (!teamToEdit.value) { + await showInfo( + 'Team auswählen', + 'Bitte wählen Sie zuerst ein Team aus', + 'Um die MyTischtennis-Konfiguration zu aktivieren, müssen Sie zuerst ein Team aus der Liste auswählen.', + 'warning' + ); return; } @@ -866,8 +892,19 @@ export default { 'success' ); - // Teams neu laden + // Teams und Ligen neu laden await loadTeams(); + await loadLeagues(); + + // Aktuelles Team mit neuen Daten aktualisieren + if (teamToEdit.value) { + const updatedTeam = teams.value.find(t => t.id === teamToEdit.value.id); + if (updatedTeam) { + teamToEdit.value = updatedTeam; + // Team-Dokumente neu laden + await loadTeamDocuments(); + } + } // Parsed Data löschen clearParsedData(); @@ -881,6 +918,46 @@ export default { } }; + const configureLeagueFromUrl = async () => { + if (!parsedMyTischtennisData.value || parsedMyTischtennisData.value.urlType !== 'table') { + return; + } + + configuringTeam.value = true; + myTischtennisError.value = ''; + myTischtennisSuccess.value = ''; + + try { + const response = await apiClient.post('/mytischtennis/configure-league', { + url: myTischtennisUrl.value.trim(), + createSeason: true + }); + + if (response.data.success) { + myTischtennisSuccess.value = 'Liga erfolgreich konfiguriert! Tabellendaten können jetzt automatisch abgerufen werden.'; + await showInfo( + 'Erfolg', + 'Liga erfolgreich konfiguriert!', + `Liga: ${response.data.data.league.name}\nSaison: ${response.data.data.season?.name || 'Nicht erstellt'}\nVerband: ${response.data.data.league.association}\nGruppen-ID: ${response.data.data.league.myTischtennisGroupId}\n\nTabellendaten können jetzt automatisch abgerufen werden.`, + 'success' + ); + + // Teams und Ligen neu laden + await loadTeams(); + await loadLeagues(); + + // Parsed Data löschen + clearParsedData(); + } + } catch (error) { + console.error('Fehler bei der Liga-Konfiguration:', error); + myTischtennisError.value = error.response?.data?.message || 'Liga konnte nicht konfiguriert werden.'; + await showInfo('Fehler', myTischtennisError.value, '', 'error'); + } finally { + configuringTeam.value = false; + } + }; + const clearParsedData = () => { parsedMyTischtennisData.value = null; myTischtennisUrl.value = ''; @@ -1074,6 +1151,7 @@ export default { onSeasonChange, parseMyTischtennisUrl, configureTeamFromUrl, + configureLeagueFromUrl, clearParsedData, getMyTischtennisStatus, fetchTeamDataManually, From 7a35a0a1d3f998b4652b7ffeb54c65f384fad2cc Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Wed, 29 Oct 2025 11:48:24 +0100 Subject: [PATCH 042/113] Update server port and enhance participant management features Changed the server port from 3000 to 3005 for local development. Enhanced the participant management functionality by adding a new endpoint to update participant group assignments, including error handling for non-existent participants. Updated the participant model to include a groupId reference, and modified the participant retrieval logic to include group information. Additionally, improved the frontend API client to accommodate the new backend structure and added filtering options in the MembersView for better user experience. --- backend/controllers/participantController.js | 31 ++- .../add_group_id_to_participants.sql | 9 + backend/models/Participant.js | 11 + backend/routes/participantRoutes.js | 3 +- backend/server.js | 2 +- frontend/src/apiClient.js | 2 +- frontend/src/views/DiaryView.vue | 47 +++- frontend/src/views/Login.vue | 2 +- frontend/src/views/MembersView.vue | 200 +++++++++++++++++- frontend/src/views/PredefinedActivities.vue | 2 +- frontend/src/views/Register.vue | 2 +- 11 files changed, 288 insertions(+), 23 deletions(-) create mode 100644 backend/migrations/add_group_id_to_participants.sql diff --git a/backend/controllers/participantController.js b/backend/controllers/participantController.js index fe5b83d..4787477 100644 --- a/backend/controllers/participantController.js +++ b/backend/controllers/participantController.js @@ -4,7 +4,10 @@ import { devLog } from '../utils/logger.js'; export const getParticipants = async (req, res) => { try { const { dateId } = req.params; - const participants = await Participant.findAll({ where: { diaryDateId: dateId } }); + const participants = await Participant.findAll({ + where: { diaryDateId: dateId }, + attributes: ['id', 'diaryDateId', 'memberId', 'groupId', 'notes', 'createdAt', 'updatedAt'] + }); res.status(200).json(participants); } catch (error) { devLog(error); @@ -12,6 +15,32 @@ export const getParticipants = async (req, res) => { } }; +export const updateParticipantGroup = async (req, res) => { + try { + const { dateId, memberId } = req.params; + const { groupId } = req.body; + + const participant = await Participant.findOne({ + where: { + diaryDateId: dateId, + memberId: memberId + } + }); + + if (!participant) { + return res.status(404).json({ error: 'Teilnehmer nicht gefunden' }); + } + + participant.groupId = groupId || null; + await participant.save(); + + res.status(200).json(participant); + } catch (error) { + devLog(error); + res.status(500).json({ error: 'Fehler beim Aktualisieren der Teilnehmer-Gruppenzuordnung' }); + } +}; + export const addParticipant = async (req, res) => { try { const { diaryDateId, memberId } = req.body; diff --git a/backend/migrations/add_group_id_to_participants.sql b/backend/migrations/add_group_id_to_participants.sql new file mode 100644 index 0000000..2e6fa4c --- /dev/null +++ b/backend/migrations/add_group_id_to_participants.sql @@ -0,0 +1,9 @@ +-- Migration: Add group_id to participants table +-- This allows assigning participants to groups for training organization + +ALTER TABLE participants +ADD COLUMN group_id INTEGER NULL REFERENCES "group"(id) ON DELETE SET NULL ON UPDATE CASCADE; + +-- Add index for better query performance +CREATE INDEX IF NOT EXISTS idx_participants_group_id ON participants(group_id); + diff --git a/backend/models/Participant.js b/backend/models/Participant.js index 5bbcb85..b5438b5 100644 --- a/backend/models/Participant.js +++ b/backend/models/Participant.js @@ -2,6 +2,7 @@ import { DataTypes } from 'sequelize'; import sequelize from '../database.js'; import Member from './Member.js'; import DiaryDate from './DiaryDates.js'; +import Group from './Group.js'; import { encryptData, decryptData } from '../utils/encrypt.js'; const Participant = sequelize.define('Participant', { @@ -27,6 +28,16 @@ const Participant = sequelize.define('Participant', { key: 'id' } }, + groupId: { + type: DataTypes.INTEGER, + allowNull: true, + references: { + model: Group, + key: 'id' + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE' + }, notes: { type: DataTypes.STRING(4096), allowNull: true, diff --git a/backend/routes/participantRoutes.js b/backend/routes/participantRoutes.js index 96979b7..023a67f 100644 --- a/backend/routes/participantRoutes.js +++ b/backend/routes/participantRoutes.js @@ -1,5 +1,5 @@ import express from 'express'; -import { getParticipants, addParticipant, removeParticipant } from '../controllers/participantController.js'; +import { getParticipants, addParticipant, removeParticipant, updateParticipantGroup } from '../controllers/participantController.js'; import { authenticate } from '../middleware/authMiddleware.js'; const router = express.Router(); @@ -7,5 +7,6 @@ const router = express.Router(); router.get('/:dateId', authenticate, getParticipants); router.post('/add', authenticate, addParticipant); router.post('/remove', authenticate, removeParticipant); +router.put('/:dateId/:memberId/group', authenticate, updateParticipantGroup); export default router; diff --git a/backend/server.js b/backend/server.js index 1aadafe..03134a6 100644 --- a/backend/server.js +++ b/backend/server.js @@ -43,7 +43,7 @@ import permissionRoutes from './routes/permissionRoutes.js'; import schedulerService from './services/schedulerService.js'; const app = express(); -const port = process.env.PORT || 3000; +const port = process.env.PORT || 3005; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); diff --git a/frontend/src/apiClient.js b/frontend/src/apiClient.js index e284cf1..75d5f4e 100644 --- a/frontend/src/apiClient.js +++ b/frontend/src/apiClient.js @@ -2,7 +2,7 @@ import axios from 'axios'; import store from './store'; const apiClient = axios.create({ - baseURL: `${import.meta.env.VITE_BACKEND}/api`, + baseURL: `${import.meta.env.VITE_BACKEND || 'http://localhost:3005'}/api`, }); apiClient.interceptors.request.use(config => { diff --git a/frontend/src/views/DiaryView.vue b/frontend/src/views/DiaryView.vue index 7159064..ef0368d 100644 --- a/frontend/src/views/DiaryView.vue +++ b/frontend/src/views/DiaryView.vue @@ -848,6 +848,17 @@ export default { this.participants = response.data.map(participant => participant.memberId); // Map für memberId -> participantId speichern this.participantMapByMemberId = response.data.reduce((map, p) => { map[p.memberId] = p.id; return map; }, {}); + // Map für memberId -> groupId speichern und mit Reaktivität initialisieren + this.memberGroupsMap = {}; + response.data.forEach(p => { + if (p.groupId) { + if (this.$set) { + this.$set(this.memberGroupsMap, p.memberId, p.groupId); + } else { + this.memberGroupsMap[p.memberId] = p.groupId; + } + } + }); }, async loadActivities(dateId) { @@ -1391,12 +1402,19 @@ export default { }, async createGroups() { try { - // Erstelle die gewünschte Anzahl Gruppen mit Namen 1 bis X - for (let i = 1; i <= this.newGroupCount; i++) { + // Bestimme Startnummer basierend auf vorhandenen Gruppen + const existingNumbers = (this.groups || []) + .map(g => parseInt((g.name || '').trim(), 10)) + .filter(n => Number.isFinite(n)); + const startNumber = existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1; + + // Erstelle die gewünschte Anzahl Gruppen mit fortlaufender Nummerierung + for (let i = 0; i < this.newGroupCount; i++) { + const groupNumber = startNumber + i; const form = { clubid: this.currentClub, dateid: this.date.id, - name: i.toString(), + name: groupNumber.toString(), lead: '', // Leiter wird leer gelassen } await apiClient.post('/group', form); @@ -1933,14 +1951,25 @@ export default { async updateMemberGroup(memberId, groupId) { try { - // Hier würde normalerweise ein API-Call gemacht werden - // Für jetzt speichern wir es nur lokal - this.memberGroupsMap[memberId] = groupId || ''; + const selectedGroupId = groupId || ''; - // TODO: API-Call zum Speichern der Teilnehmer-Gruppenzuordnung - // await apiClient.put(`/participants/${this.date.id}/${memberId}/group`, { groupId }); + // Verwende Vue.set für Reaktivität (Vue 2) + if (this.$set) { + this.$set(this.memberGroupsMap, memberId, selectedGroupId); + } else { + // Vue 3 oder Fallback + this.memberGroupsMap = { + ...this.memberGroupsMap, + [memberId]: selectedGroupId + }; + } - console.log(`Teilnehmer ${memberId} wurde Gruppe ${groupId} zugewiesen`); + // API-Call zum Speichern der Teilnehmer-Gruppenzuordnung + await apiClient.put(`/participants/${this.date.id}/${memberId}/group`, { + groupId: selectedGroupId || null + }); + + console.log(`Teilnehmer ${memberId} wurde Gruppe ${selectedGroupId} zugewiesen`); } catch (error) { console.error('Fehler beim Aktualisieren der Teilnehmer-Gruppenzuordnung:', error); this.showInfo('Fehler', 'Fehler beim Aktualisieren der Teilnehmer-Gruppenzuordnung', '', 'error'); diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue index 5b380a4..641e053 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -98,7 +98,7 @@ export default { ...mapActions(['login']), async executeLogin() { try { - const response = await axios.post(`${import.meta.env.VITE_BACKEND}/api/auth/login`, { email: this.email, password: this.password }, { + const response = await axios.post(`${import.meta.env.VITE_BACKEND || 'http://localhost:3005'}/api/auth/login`, { email: this.email, password: this.password }, { timeout: 5000, }); await this.login({ token: response.data.token, username: this.email }); diff --git a/frontend/src/views/MembersView.vue b/frontend/src/views/MembersView.vue index 9366b82..b4e66c1 100644 --- a/frontend/src/views/MembersView.vue +++ b/frontend/src/views/MembersView.vue @@ -68,11 +68,41 @@
    -
    - +
    +
    + +
    + +
    +
    + + +
    + +
    + + +
    + + +
    @@ -90,7 +120,7 @@ - @@ -286,6 +300,10 @@ export default { return true; }); + }, + + hasTestMembers() { + return this.members.some(member => member.testMembership); } }, data() { @@ -396,6 +414,87 @@ export default { this.members.forEach(member => { this.loadMemberImage(member); }); + + // Lade Trainingsteilnahmen für alle Testmitglieder auf einmal über /training-stats + await this.loadTrainingParticipations(); + }, + + async loadTrainingParticipations() { + try { + const response = await apiClient.get(`/training-stats/${this.currentClub}`); + const trainingStats = response.data.members || []; + + console.log('[loadTrainingParticipations] Training Stats geladen:', trainingStats.length, 'Mitglieder'); + + // Erstelle eine Map für schnellen Zugriff: memberId -> participationTotal + // Verwende parseInt, um sicherzustellen, dass IDs als Zahlen verglichen werden + const participationMap = new Map(); + trainingStats.forEach(stat => { + const memberId = parseInt(stat.id, 10); + participationMap.set(memberId, stat.participationTotal || 0); + console.log(`[loadTrainingParticipations] Map gesetzt: ID ${memberId} -> ${stat.participationTotal || 0}`); + }); + + // Setze Trainingsteilnahmen für alle Testmitglieder + const testMembers = this.members.filter(m => m.testMembership); + console.log('[loadTrainingParticipations] Testmitglieder gefunden:', testMembers.length); + + testMembers.forEach(member => { + const memberId = parseInt(member.id, 10); + const count = participationMap.get(memberId); + console.log(`[loadTrainingParticipations] Mitglied ${memberId} (${member.firstName} ${member.lastName}): count=${count}`); + // Nur setzen, wenn ein Wert gefunden wurde (nicht undefined) + if (count !== undefined) { + this.$set(member, 'trainingParticipations', count); + console.log(`[loadTrainingParticipations] Trainingsteilnahmen gesetzt: ${count}`); + } else { + // Fallback: 0 setzen, wenn kein Wert gefunden wurde + this.$set(member, 'trainingParticipations', 0); + console.log(`[loadTrainingParticipations] Kein Wert gefunden, setze 0`); + } + }); + } catch (error) { + console.error('Fehler beim Laden der Trainingsteilnahmen:', error); + // Bei Fehler setze 0 für alle Testmitglieder + this.members.forEach(member => { + if (member.testMembership) { + this.$set(member, 'trainingParticipations', 0); + } + }); + } + }, + + async quickRemoveTestMembership(member) { + try { + const response = await apiClient.post(`/clubmembers/quick-update-test-membership/${this.currentClub}/${member.id}`); + if (response.data.success) { + member.testMembership = false; + member.trainingParticipations = undefined; // Entferne die Anzeige + this.showInfo('Erfolg', response.data.message || 'Testmitgliedschaft entfernt', '', 'success'); + } else { + this.showInfo('Fehler', response.data.error || 'Fehler beim Entfernen der Testmitgliedschaft', '', 'error'); + } + } catch (error) { + console.error('Fehler beim Entfernen der Testmitgliedschaft:', error); + const errorMessage = error.response?.data?.error || error.message || 'Fehler beim Entfernen der Testmitgliedschaft'; + this.showInfo('Fehler', errorMessage, '', 'error'); + } + }, + + async quickMarkFormHandedOver(member) { + try { + const response = await apiClient.post(`/clubmembers/quick-update-member-form/${this.currentClub}/${member.id}`); + if (response.data.success) { + member.memberFormHandedOver = true; + this.showInfo('Erfolg', response.data.message || 'Mitgliedsformular als ausgehändigt markiert', '', 'success'); + } else { + this.showInfo('Fehler', response.data.error || 'Fehler beim Markieren des Formulars', '', 'error'); + } + } catch (error) { + console.error('Fehler beim Markieren des Formulars:', error); + const errorMessage = error.response?.data?.error || error.message || 'Fehler beim Markieren des Formulars'; + this.showInfo('Fehler', errorMessage, '', 'error'); + } }, toggleNewMember() { this.memberFormIsOpen = !this.memberFormIsOpen; @@ -1172,4 +1271,33 @@ table td { .btn-transfer:hover { background-color: #138496; } + +.action-buttons-row { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + align-items: center; +} + +.btn-quick-action { + background-color: #ffc107; + color: #000; + border: none; + padding: 0.4rem 0.8rem; + border-radius: 4px; + cursor: pointer; + font-size: 0.85em; + font-weight: 500; + transition: background-color 0.2s ease; + white-space: nowrap; +} + +.btn-quick-action:hover { + background-color: #e0a800; +} + +.warning-icon { + margin-right: 0.25rem; + font-size: 1.1em; +} diff --git a/frontend/src/views/TeamManagementView.vue b/frontend/src/views/TeamManagementView.vue index 1283023..0a69c22 100644 --- a/frontend/src/views/TeamManagementView.vue +++ b/frontend/src/views/TeamManagementView.vue @@ -8,6 +8,39 @@ :show-current-season="true" /> + +
    +
    + 🔄 Rating-Updates: + + Zuletzt: {{ formatJobDate(schedulerJobs.rating_updates.lastRun) }} + + ({{ schedulerJobs.rating_updates.updatedCount }} aktualisiert) + + ⚠️ Fehler + +
    +
    +
    + 📊 Spielergebnisse: + + Zuletzt: {{ formatJobDate(schedulerJobs.match_results.lastRun) }} + + ({{ schedulerJobs.match_results.fetchedCount }} abgerufen) + + ⚠️ Fehler + +
    +
    +
    + {{ team.teamName }} + + +
    +
    +
    +
    +
    @@ -167,6 +200,25 @@
    + +
    + 🔄 Automatische Jobs +
    +
    + Zuletzt aktualisiert: + {{ formatJobDate(getTeamJobInfo(teamToEdit).lastRun) }} +
    +
    + Status: + ✓ Erfolgreich + ✗ Fehler +
    +
    +
    + Noch keine automatische Aktualisierung +
    +
    +
    @@ -261,6 +313,20 @@ ✗ Nicht konfiguriert
    + + +
    + 🔄 Automatische Jobs: + + + {{ formatJobDate(getTeamJobInfo(team).lastRun) }} + + {{ getTeamJobInfo(team).success ? '✓' : '✗' }} + + + Nie + +
    @@ -401,6 +467,12 @@ export default { const loadingStats = ref(false); const memberById = ref({}); + // Scheduler Jobs Info + const schedulerJobs = ref({ + rating_updates: null, + match_results: null + }); + // Computed const selectedClub = computed(() => store.state.currentClub); const authToken = computed(() => store.state.token); @@ -442,6 +514,9 @@ export default { // Lade alle Team-Dokumente nach dem Laden der Teams await loadAllTeamDocuments(); + + // Aktualisiere Job-Informationen, damit Team-Filterung korrekt funktioniert + await loadSchedulerJobsInfo(); } catch (error) { console.error('Fehler beim Laden der Club-Teams:', error); } @@ -494,12 +569,14 @@ export default { } }; - const editTeam = (team) => { + const editTeam = async (team) => { teamToEdit.value = team; newTeamName.value = team.name; newLeagueId.value = team.leagueId || ''; teamFormIsOpen.value = true; - loadTeamDocuments(); + await loadTeamDocuments(); + // Aktualisiere Job-Informationen, damit Team-spezifische Daten aktuell sind + await loadSchedulerJobsInfo(); }; const deleteTeam = async (team) => { @@ -568,6 +645,31 @@ export default { const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; }; + + const formatJobDate = (dateString) => { + if (!dateString) return 'Nie'; + const date = new Date(dateString); + const now = new Date(); + const diffMs = now - date; + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return 'gerade eben'; + if (diffMins < 60) return `vor ${diffMins} Min.`; + if (diffHours < 24) return `vor ${diffHours} Std.`; + if (diffDays === 1) return 'gestern'; + if (diffDays < 7) return `vor ${diffDays} Tagen`; + + // Format: DD.MM.YYYY HH:MM + return date.toLocaleDateString('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + }; const loadTeamDocuments = async () => { if (!teamToEdit.value) return; @@ -739,10 +841,54 @@ export default { loadLeagues(); }; + // Load scheduler jobs info + const loadSchedulerJobsInfo = async () => { + try { + const clubIdParam = selectedClub.value ? `?clubId=${selectedClub.value}` : ''; + const response = await apiClient.get(`/logs/scheduler/last-executions${clubIdParam}`); + if (response.data.success) { + schedulerJobs.value = response.data.data; + } + } catch (error) { + console.error('Fehler beim Laden der Job-Informationen:', error); + } + }; + + // Filter team details to only show teams from current club + const getFilteredTeamDetails = (teamDetails) => { + if (!teamDetails || !selectedClub.value) return teamDetails; + // Filter by checking if team exists in current club's teams + return teamDetails.filter(team => { + return teams.value.some(t => t.id === team.clubTeamId); + }); + }; + + // Get job info for a specific team + const getTeamJobInfo = (team) => { + if (!team || !team.id || !schedulerJobs.value.match_results?.teamDetails) { + return null; + } + + const teamDetail = schedulerJobs.value.match_results.teamDetails.find( + td => td.clubTeamId === team.id + ); + + if (!teamDetail) { + return null; + } + + return { + lastRun: teamDetail.lastRun, + success: teamDetail.success + }; + }; + // Lifecycle onMounted(() => { // Lade Ligen beim ersten Laden der Seite (ohne Saison-Filter) loadLeagues(); + // Lade Job-Informationen + loadSchedulerJobsInfo(); }); // PDF-Dialog Funktionen @@ -1188,7 +1334,12 @@ export default { getMyTischtennisStatus, fetchTeamDataManually, refreshPlayerStats - ,memberById + ,memberById, + schedulerJobs, + formatJobDate, + loadSchedulerJobsInfo, + getFilteredTeamDetails, + getTeamJobInfo }; } }; @@ -2259,4 +2410,170 @@ export default { color: #2e7d32; font-size: 0.9rem; } + +/* Scheduler Jobs Info */ +.scheduler-jobs-info { + margin-bottom: 1rem; + padding: 0.75rem; + background: var(--background-light); + border-radius: var(--border-radius); + border: 1px solid var(--border-color); + font-size: 0.875rem; +} + +.job-info { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.job-info:last-child { + margin-bottom: 0; +} + +.job-label { + font-weight: 600; + color: var(--text-color); + min-width: 150px; +} + +.job-details { + color: var(--text-muted); + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.job-count { + color: var(--primary-color); + font-weight: 500; +} + +.job-error { + color: #dc3545; + font-weight: 500; +} + +.job-header { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.team-details { + margin-top: 0.5rem; + padding-top: 0.5rem; + border-top: 1px solid var(--border-color); + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.team-detail-item { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8rem; + padding: 0.25rem 0.5rem; + background: white; + border-radius: 3px; +} + +.team-name { + flex: 1; + color: var(--text-color); +} + +.team-status { + font-weight: bold; + font-size: 0.9rem; +} + +.team-status.success { + color: #28a745; +} + +.team-status.error { + color: #dc3545; +} + +/* Team Job Info */ +.team-job-info.compact { + margin-top: 0.5rem; + padding: 0.5rem 0.75rem; + background: var(--background-light); + border-radius: 4px; + border: 1px solid var(--border-color); +} + +.team-job-details { + margin-top: 0.5rem; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.team-job-item { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.85rem; +} + +.team-job-label { + font-weight: 600; + color: var(--text-muted); + min-width: 120px; +} + +.team-job-value { + color: var(--text-color); +} + +.team-job-status { + font-weight: 500; +} + +.team-job-status.success { + color: #28a745; +} + +.team-job-status.error { + color: #dc3545; +} + +.team-job-no-data { + color: var(--text-muted); + font-size: 0.85rem; + font-style: italic; +} + +.team-job-status-row { + margin-top: 0.3rem; + padding-top: 0.3rem; + border-top: 1px solid var(--border-color); +} + +.team-job-status-value { + display: flex; + align-items: center; + gap: 0.3rem; + font-size: 0.8rem; +} + +.team-job-status-icon { + font-weight: bold; + font-size: 0.9rem; +} + +.team-job-status-icon.success { + color: #28a745; +} + +.team-job-status-icon.error { + color: #dc3545; +} From f6b8388819fbf6da1db2ed4a1d26d00a5b546cae Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Thu, 6 Nov 2025 14:46:16 +0100 Subject: [PATCH 078/113] Add quick deactivate member functionality and update routes and UI Implemented a new quickDeactivateMember function in MemberService to handle member deactivation. Updated member routes to include a new endpoint for quick deactivation. Enhanced the MembersView component to support quick deactivation actions with updated UI elements, improving user experience for managing member statuses. --- backend/controllers/memberController.js | 14 ++++- backend/routes/memberRoutes.js | 3 +- backend/services/memberService.js | 25 ++++++++ frontend/src/views/MembersView.vue | 77 +++++++++++++++++-------- 4 files changed, 94 insertions(+), 25 deletions(-) diff --git a/backend/controllers/memberController.js b/backend/controllers/memberController.js index c651565..ac9692f 100644 --- a/backend/controllers/memberController.js +++ b/backend/controllers/memberController.js @@ -124,6 +124,18 @@ const quickUpdateMemberFormHandedOver = async (req, res) => { } }; +const quickDeactivateMember = async (req, res) => { + try { + const { clubId, memberId } = req.params; + const { authcode: userToken } = req.headers; + const result = await MemberService.quickDeactivateMember(userToken, clubId, memberId); + res.status(result.status).json(result.response); + } catch (error) { + console.error('[quickDeactivateMember] - Error:', error); + res.status(500).json({ error: 'Failed to deactivate member' }); + } +}; + const transferMembers = async (req, res) => { try { const { id: clubId } = req.params; @@ -156,4 +168,4 @@ const transferMembers = async (req, res) => { } }; -export { getClubMembers, getWaitingApprovals, setClubMembers, uploadMemberImage, getMemberImage, updateRatingsFromMyTischtennis, rotateMemberImage, transferMembers, quickUpdateTestMembership, quickUpdateMemberFormHandedOver }; \ No newline at end of file +export { getClubMembers, getWaitingApprovals, setClubMembers, uploadMemberImage, getMemberImage, updateRatingsFromMyTischtennis, rotateMemberImage, transferMembers, quickUpdateTestMembership, quickUpdateMemberFormHandedOver, quickDeactivateMember }; \ No newline at end of file diff --git a/backend/routes/memberRoutes.js b/backend/routes/memberRoutes.js index 5549d6f..39ac51d 100644 --- a/backend/routes/memberRoutes.js +++ b/backend/routes/memberRoutes.js @@ -1,4 +1,4 @@ -import { getClubMembers, getWaitingApprovals, setClubMembers, uploadMemberImage, getMemberImage, updateRatingsFromMyTischtennis, rotateMemberImage, transferMembers, quickUpdateTestMembership, quickUpdateMemberFormHandedOver } from '../controllers/memberController.js'; +import { getClubMembers, getWaitingApprovals, setClubMembers, uploadMemberImage, getMemberImage, updateRatingsFromMyTischtennis, rotateMemberImage, transferMembers, quickUpdateTestMembership, quickUpdateMemberFormHandedOver, quickDeactivateMember } from '../controllers/memberController.js'; import express from 'express'; import { authenticate } from '../middleware/authMiddleware.js'; import { authorize } from '../middleware/authorizationMiddleware.js'; @@ -19,5 +19,6 @@ router.post('/rotate-image/:clubId/:memberId', authenticate, authorize('members' router.post('/transfer/:id', authenticate, authorize('members', 'write'), transferMembers); router.post('/quick-update-test-membership/:clubId/:memberId', authenticate, authorize('members', 'write'), quickUpdateTestMembership); router.post('/quick-update-member-form/:clubId/:memberId', authenticate, authorize('members', 'write'), quickUpdateMemberFormHandedOver); +router.post('/quick-deactivate/:clubId/:memberId', authenticate, authorize('members', 'write'), quickDeactivateMember); export default router; diff --git a/backend/services/memberService.js b/backend/services/memberService.js index 3bc2e62..1e534e7 100644 --- a/backend/services/memberService.js +++ b/backend/services/memberService.js @@ -544,6 +544,31 @@ class MemberService { return { status: 500, response: { error: 'Failed to update member form status' } }; } } + + async quickDeactivateMember(userToken, clubId, memberId) { + try { + await checkAccess(userToken, clubId); + const member = await Member.findOne({ where: { id: memberId, clubId: clubId } }); + if (!member) { + return { status: 404, response: { error: 'Member not found in this club' } }; + } + + if (!member.active) { + return { status: 400, response: { error: 'Member is already inactive' } }; + } + + member.active = false; + await member.save(); + + return { + status: 200, + response: { success: true, message: 'Mitglied deaktiviert' } + }; + } catch (error) { + console.error('[quickDeactivateMember] - Error:', error); + return { status: 500, response: { error: 'Failed to deactivate member' } }; + } + } } export default new MemberService(); \ No newline at end of file diff --git a/frontend/src/views/MembersView.vue b/frontend/src/views/MembersView.vue index 3a29d84..6d362b1 100644 --- a/frontend/src/views/MembersView.vue +++ b/frontend/src/views/MembersView.vue @@ -161,15 +161,22 @@ - -
    - - - - +
    + + ✅ + + + 📄 + + + ⛔ + + + 📝 + + + 🏃 +
    @@ -496,6 +503,26 @@ export default { this.showInfo('Fehler', errorMessage, '', 'error'); } }, + + async quickDeactivateMember(member) { + if (!confirm(`Möchten Sie "${member.firstName} ${member.lastName}" wirklich deaktivieren?`)) { + return; + } + + try { + const response = await apiClient.post(`/clubmembers/quick-deactivate/${this.currentClub}/${member.id}`); + if (response.data.success) { + member.active = false; + this.showInfo('Erfolg', response.data.message || 'Mitglied deaktiviert', '', 'success'); + } else { + this.showInfo('Fehler', response.data.error || 'Fehler beim Deaktivieren des Mitglieds', '', 'error'); + } + } catch (error) { + console.error('Fehler beim Deaktivieren des Mitglieds:', error); + const errorMessage = error.response?.data?.error || error.message || 'Fehler beim Deaktivieren des Mitglieds'; + this.showInfo('Fehler', errorMessage, '', 'error'); + } + }, toggleNewMember() { this.memberFormIsOpen = !this.memberFormIsOpen; }, @@ -1272,28 +1299,32 @@ table td { background-color: #138496; } -.action-buttons-row { +.action-icons-row { display: flex; - gap: 0.5rem; + gap: 0.75rem; flex-wrap: wrap; align-items: center; } -.btn-quick-action { - background-color: #ffc107; - color: #000; - border: none; - padding: 0.4rem 0.8rem; - border-radius: 4px; +.action-icon { + font-size: 1.2em; cursor: pointer; - font-size: 0.85em; - font-weight: 500; - transition: background-color 0.2s ease; - white-space: nowrap; + transition: transform 0.2s ease, opacity 0.2s ease; + display: inline-block; + line-height: 1; } -.btn-quick-action:hover { - background-color: #e0a800; +.action-icon:hover { + transform: scale(1.2); + opacity: 0.8; +} + +.action-icon-deactivate { + filter: grayscale(0.3); +} + +.action-icon-deactivate:hover { + filter: grayscale(0); } .warning-icon { From 106c63890e2cb32ecbb5508653f21d3a2087e414 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Thu, 6 Nov 2025 14:58:19 +0100 Subject: [PATCH 079/113] Enhance training participation loading in MembersView with improved ID handling and logging Updated the loadTrainingParticipations method to store participation totals using both string and number keys in the participation map, addressing potential type issues. Enhanced logging to provide detailed insights into the loading process, including responses and member counts, improving debugging capabilities and user experience. --- frontend/src/views/MembersView.vue | 51 ++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/frontend/src/views/MembersView.vue b/frontend/src/views/MembersView.vue index 6d362b1..1d8e86b 100644 --- a/frontend/src/views/MembersView.vue +++ b/frontend/src/views/MembersView.vue @@ -432,40 +432,59 @@ export default { const trainingStats = response.data.members || []; console.log('[loadTrainingParticipations] Training Stats geladen:', trainingStats.length, 'Mitglieder'); + console.log('[loadTrainingParticipations] Response data:', JSON.stringify(response.data, null, 2)); // Erstelle eine Map für schnellen Zugriff: memberId -> participationTotal - // Verwende parseInt, um sicherzustellen, dass IDs als Zahlen verglichen werden + // Speichere sowohl String- als auch Number-Keys, um Typ-Probleme zu vermeiden const participationMap = new Map(); trainingStats.forEach(stat => { - const memberId = parseInt(stat.id, 10); - participationMap.set(memberId, stat.participationTotal || 0); - console.log(`[loadTrainingParticipations] Map gesetzt: ID ${memberId} -> ${stat.participationTotal || 0}`); + const participationTotal = stat.participationTotal || 0; + // Speichere sowohl als String als auch als Number + const idAsString = String(stat.id); + const idAsNumber = typeof stat.id === 'string' ? parseInt(stat.id, 10) : stat.id; + + participationMap.set(idAsString, participationTotal); + participationMap.set(idAsNumber, participationTotal); + console.log(`[loadTrainingParticipations] Map gesetzt: ID "${idAsString}" (String) und ${idAsNumber} (Number) -> ${participationTotal}`); }); + console.log('[loadTrainingParticipations] Participation Map Keys:', Array.from(participationMap.keys())); + // Setze Trainingsteilnahmen für alle Testmitglieder const testMembers = this.members.filter(m => m.testMembership); console.log('[loadTrainingParticipations] Testmitglieder gefunden:', testMembers.length); + console.log('[loadTrainingParticipations] Testmitglieder IDs:', testMembers.map(m => ({ id: m.id, type: typeof m.id, name: `${m.firstName} ${m.lastName}` }))); testMembers.forEach(member => { - const memberId = parseInt(member.id, 10); - const count = participationMap.get(memberId); - console.log(`[loadTrainingParticipations] Mitglied ${memberId} (${member.firstName} ${member.lastName}): count=${count}`); - // Nur setzen, wenn ein Wert gefunden wurde (nicht undefined) - if (count !== undefined) { - this.$set(member, 'trainingParticipations', count); - console.log(`[loadTrainingParticipations] Trainingsteilnahmen gesetzt: ${count}`); - } else { - // Fallback: 0 setzen, wenn kein Wert gefunden wurde - this.$set(member, 'trainingParticipations', 0); - console.log(`[loadTrainingParticipations] Kein Wert gefunden, setze 0`); + // Versuche sowohl String- als auch Number-ID + const idAsString = String(member.id); + const idAsNumber = typeof member.id === 'string' ? parseInt(member.id, 10) : member.id; + + // Versuche zuerst mit der ursprünglichen ID (String oder Number) + let count = participationMap.get(member.id); + + // Falls nicht gefunden, versuche mit konvertierter ID + if (count === undefined) { + count = participationMap.get(idAsString); } + if (count === undefined) { + count = participationMap.get(idAsNumber); + } + + console.log(`[loadTrainingParticipations] Mitglied ${member.id} (type: ${typeof member.id}) (${member.firstName} ${member.lastName}): count=${count}, map has String: ${participationMap.has(idAsString)}, map has Number: ${participationMap.has(idAsNumber)}`); + + // Setze den Wert, wenn gefunden, sonst 0 + const finalCount = count !== undefined ? count : 0; + // In Vue 3 ist $set nicht mehr nötig, direkte Zuweisung funktioniert + member.trainingParticipations = finalCount; + console.log(`[loadTrainingParticipations] Trainingsteilnahmen gesetzt: ${finalCount}`); }); } catch (error) { console.error('Fehler beim Laden der Trainingsteilnahmen:', error); // Bei Fehler setze 0 für alle Testmitglieder this.members.forEach(member => { if (member.testMembership) { - this.$set(member, 'trainingParticipations', 0); + member.trainingParticipations = 0; } }); } From 98637eec004dd90701409a867bce32630eaafb50 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Thu, 6 Nov 2025 15:01:40 +0100 Subject: [PATCH 080/113] Update MembersView styling for action icons to improve layout consistency Modified the action-icons-row CSS to prevent wrapping and ensure icons remain in a single line, enhancing the visual layout and usability of the MembersView component. --- frontend/src/views/MembersView.vue | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/views/MembersView.vue b/frontend/src/views/MembersView.vue index 1d8e86b..78fed35 100644 --- a/frontend/src/views/MembersView.vue +++ b/frontend/src/views/MembersView.vue @@ -1321,8 +1321,9 @@ table td { .action-icons-row { display: flex; gap: 0.75rem; - flex-wrap: wrap; + flex-wrap: nowrap; align-items: center; + white-space: nowrap; } .action-icon { From 5a4553a8a0b830c61d81c841965b747bd5509f1a Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Thu, 6 Nov 2025 16:03:42 +0100 Subject: [PATCH 081/113] Add member_contact table and postal_code column to member table Created a new SQL migration to add a postal_code column to the member table and introduced a member_contact table to store multiple phone numbers and email addresses. Implemented data migration for existing phone numbers and email addresses from the member table to the new member_contact table, ensuring data integrity and improved contact management. --- .../add_member_contact_and_postal_code.sql | 57 ++++++++++++ backend/models/MemberContact.js | 89 +++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 backend/migrations/add_member_contact_and_postal_code.sql create mode 100644 backend/models/MemberContact.js diff --git a/backend/migrations/add_member_contact_and_postal_code.sql b/backend/migrations/add_member_contact_and_postal_code.sql new file mode 100644 index 0000000..d291750 --- /dev/null +++ b/backend/migrations/add_member_contact_and_postal_code.sql @@ -0,0 +1,57 @@ +-- Add postal_code column to member table +ALTER TABLE `member` +ADD COLUMN `postal_code` TEXT NULL COMMENT 'Postal code (PLZ)' AFTER `city`; + +-- Create member_contact table for multiple phone numbers and email addresses +CREATE TABLE IF NOT EXISTS `member_contact` ( + `id` INT NOT NULL AUTO_INCREMENT, + `member_id` INT NOT NULL, + `type` ENUM('phone', 'email') NOT NULL COMMENT 'Type of contact: phone or email', + `value` TEXT NOT NULL, + `is_parent` BOOLEAN NOT NULL DEFAULT FALSE COMMENT 'Whether this contact belongs to a parent', + `parent_name` TEXT NULL COMMENT 'Name of the parent (e.g. "Mutter", "Vater", "Elternteil 1")', + `is_primary` BOOLEAN NOT NULL DEFAULT FALSE COMMENT 'Whether this is the primary contact of this type', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + INDEX `idx_member_id` (`member_id`), + INDEX `idx_type` (`type`), + CONSTRAINT `fk_member_contact_member` + FOREIGN KEY (`member_id`) + REFERENCES `member` (`id`) + ON DELETE CASCADE + ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Migrate existing phone numbers from member.phone to member_contact +INSERT INTO `member_contact` (`member_id`, `type`, `value`, `is_parent`, `parent_name`, `is_primary`, `created_at`, `updated_at`) +SELECT + `id` AS `member_id`, + 'phone' AS `type`, + `phone` AS `value`, + FALSE AS `is_parent`, + NULL AS `parent_name`, + TRUE AS `is_primary`, + NOW() AS `created_at`, + NOW() AS `updated_at` +FROM `member` +WHERE `phone` IS NOT NULL + AND `phone` != '' + AND TRIM(`phone`) != ''; + +-- Migrate existing email addresses from member.email to member_contact +INSERT INTO `member_contact` (`member_id`, `type`, `value`, `is_parent`, `parent_name`, `is_primary`, `created_at`, `updated_at`) +SELECT + `id` AS `member_id`, + 'email' AS `type`, + `email` AS `value`, + FALSE AS `is_parent`, + NULL AS `parent_name`, + TRUE AS `is_primary`, + NOW() AS `created_at`, + NOW() AS `updated_at` +FROM `member` +WHERE `email` IS NOT NULL + AND `email` != '' + AND TRIM(`email`) != ''; + diff --git a/backend/models/MemberContact.js b/backend/models/MemberContact.js new file mode 100644 index 0000000..0425b86 --- /dev/null +++ b/backend/models/MemberContact.js @@ -0,0 +1,89 @@ +import { DataTypes } from 'sequelize'; +import sequelize from '../database.js'; +import Member from './Member.js'; +import { encryptData, decryptData } from '../utils/encrypt.js'; + +const MemberContact = sequelize.define('MemberContact', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false + }, + memberId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { + model: 'member', + key: 'id' + }, + onDelete: 'CASCADE', + field: 'member_id' + }, + type: { + type: DataTypes.ENUM('phone', 'email'), + allowNull: false, + comment: 'Type of contact: phone or email' + }, + value: { + type: DataTypes.STRING, + allowNull: false, + set(value) { + const encryptedValue = encryptData(value); + this.setDataValue('value', encryptedValue); + }, + get() { + const encryptedValue = this.getDataValue('value'); + if (!encryptedValue) return null; + try { + return decryptData(encryptedValue); + } catch (error) { + console.error('[MemberContact] Error decrypting value:', error); + return encryptedValue; // Fallback: return encrypted value if decryption fails + } + } + }, + isParent: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + field: 'is_parent', + comment: 'Whether this contact belongs to a parent' + }, + parentName: { + type: DataTypes.STRING, + allowNull: true, + set(value) { + if (value) { + const encryptedValue = encryptData(value); + this.setDataValue('parentName', encryptedValue); + } else { + this.setDataValue('parentName', null); + } + }, + get() { + const encryptedValue = this.getDataValue('parentName'); + return encryptedValue ? decryptData(encryptedValue) : null; + }, + field: 'parent_name', + comment: 'Name of the parent (e.g. "Mutter", "Vater", "Elternteil 1")' + }, + isPrimary: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + field: 'is_primary', + comment: 'Whether this is the primary contact of this type' + } +}, { + underscored: true, + sequelize, + modelName: 'MemberContact', + tableName: 'member_contact', + timestamps: true +}); + +// Associations are defined in models/index.js to avoid duplicate alias errors + +export default MemberContact; + From 9cdbd60a23dce23633fd038e2d31d4c01fb82441 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Thu, 6 Nov 2025 16:12:34 +0100 Subject: [PATCH 082/113] Enhance member management by adding postal code and contact handling Introduced a postal code field to the member model and implemented a new MemberContact model to manage multiple phone numbers and email addresses. Updated the member service and controller to handle contact data during member creation and updates. Enhanced the MembersView component to support input for multiple contacts, ensuring better organization and accessibility of member information. --- backend/controllers/memberController.js | 10 +- backend/models/Member.js | 16 ++ backend/models/index.js | 5 + backend/server.js | 3 +- backend/services/memberService.js | 76 ++++++- backend/utils/encrypt.js | 16 +- frontend/src/components/PDFGenerator.js | 35 ++- frontend/src/views/MembersView.vue | 279 +++++++++++++++++++++++- 8 files changed, 417 insertions(+), 23 deletions(-) diff --git a/backend/controllers/memberController.js b/backend/controllers/memberController.js index ac9692f..c331b36 100644 --- a/backend/controllers/memberController.js +++ b/backend/controllers/memberController.js @@ -26,16 +26,16 @@ const getWaitingApprovals = async(req, res) => { const setClubMembers = async (req, res) => { try { - const { id: memberId, firstname: firstName, lastname: lastName, street, city, birthdate, phone, email, active, - testMembership, picsInInternetAllowed, gender, ttr, qttr, memberFormHandedOver } = req.body; + const { id: memberId, firstname: firstName, lastname: lastName, street, city, postalCode, birthdate, phone, email, active, + testMembership, picsInInternetAllowed, gender, ttr, qttr, memberFormHandedOver, contacts } = req.body; const { id: clubId } = req.params; const { authcode: userToken } = req.headers; - const addResult = await MemberService.setClubMember(userToken, clubId, memberId, firstName, lastName, street, city, birthdate, - phone, email, active, testMembership, picsInInternetAllowed, gender, ttr, qttr, memberFormHandedOver); + const addResult = await MemberService.setClubMember(userToken, clubId, memberId, firstName, lastName, street, city, postalCode, birthdate, + phone, email, active, testMembership, picsInInternetAllowed, gender, ttr, qttr, memberFormHandedOver, contacts); res.status(addResult.status || 500).json(addResult.response); } catch (error) { console.error('[setClubMembers] - Error:', error); - res.status(500).json({ error: 'Failed to upload image' }); + res.status(500).json({ error: 'Failed to save member' }); } } diff --git a/backend/models/Member.js b/backend/models/Member.js index 6db2f73..e9616b7 100644 --- a/backend/models/Member.js +++ b/backend/models/Member.js @@ -52,6 +52,7 @@ const Member = sequelize.define('Member', { }, get() { const encryptedValue = this.getDataValue('birthDate'); + if (!encryptedValue) return null; return decryptData(encryptedValue); } }, @@ -91,6 +92,21 @@ const Member = sequelize.define('Member', { return decryptData(encryptedValue); } }, + postalCode: { + type: DataTypes.STRING, + allowNull: true, + set(value) { + const encryptedValue = encryptData(value || ''); + this.setDataValue('postalCode', encryptedValue); + }, + get() { + const encryptedValue = this.getDataValue('postalCode'); + if (!encryptedValue) return null; + return decryptData(encryptedValue); + }, + field: 'postal_code', + comment: 'Postal code (PLZ)' + }, email: { type: DataTypes.STRING, allowNull: false, diff --git a/backend/models/index.js b/backend/models/index.js index dbcd4ad..9111d33 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -40,6 +40,7 @@ import MyTischtennisUpdateHistory from './MyTischtennisUpdateHistory.js'; import MyTischtennisFetchLog from './MyTischtennisFetchLog.js'; import ApiLog from './ApiLog.js'; import MemberTransferConfig from './MemberTransferConfig.js'; +import MemberContact from './MemberContact.js'; // Official tournaments relations OfficialTournament.hasMany(OfficialCompetition, { foreignKey: 'tournamentId', as: 'competitions' }); OfficialCompetition.belongsTo(OfficialTournament, { foreignKey: 'tournamentId', as: 'tournament' }); @@ -246,6 +247,9 @@ ApiLog.belongsTo(User, { foreignKey: 'userId', as: 'user' }); Club.hasOne(MemberTransferConfig, { foreignKey: 'clubId', as: 'memberTransferConfig' }); MemberTransferConfig.belongsTo(Club, { foreignKey: 'clubId', as: 'club' }); +Member.hasMany(MemberContact, { foreignKey: 'memberId', as: 'contacts' }); +MemberContact.belongsTo(Member, { foreignKey: 'memberId', as: 'member' }); + export { User, Log, @@ -288,4 +292,5 @@ export { MyTischtennisFetchLog, ApiLog, MemberTransferConfig, + MemberContact, }; diff --git a/backend/server.js b/backend/server.js index 23151cc..9916c17 100644 --- a/backend/server.js +++ b/backend/server.js @@ -8,7 +8,7 @@ import { DiaryNote, DiaryTag, MemberDiaryTag, DiaryDateTag, DiaryMemberNote, DiaryMemberTag, PredefinedActivity, PredefinedActivityImage, DiaryDateActivity, DiaryMemberActivity, Match, League, Team, ClubTeam, TeamDocument, Group, GroupActivity, Tournament, TournamentGroup, TournamentMatch, TournamentResult, - TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember, MyTischtennis, MyTischtennisUpdateHistory, MyTischtennisFetchLog, ApiLog, MemberTransferConfig + TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember, MyTischtennis, MyTischtennisUpdateHistory, MyTischtennisFetchLog, ApiLog, MemberTransferConfig, MemberContact } from './models/index.js'; import authRoutes from './routes/authRoutes.js'; import clubRoutes from './routes/clubRoutes.js'; @@ -205,6 +205,7 @@ app.get('*', (req, res) => { await safeSync(MyTischtennisFetchLog); await safeSync(ApiLog); await safeSync(MemberTransferConfig); + await safeSync(MemberContact); // Start scheduler service schedulerService.start(); diff --git a/backend/services/memberService.js b/backend/services/memberService.js index 1e534e7..5db59f5 100644 --- a/backend/services/memberService.js +++ b/backend/services/memberService.js @@ -29,13 +29,41 @@ class MemberService { if (!showAll || showAll === 'false') { where['active'] = true; } - return await Member.findAll({ where }) + const MemberContact = (await import('../models/MemberContact.js')).default; + return await Member.findAll({ + where, + include: [{ + model: MemberContact, + as: 'contacts', + required: false, + attributes: ['id', 'memberId', 'type', 'value', 'isParent', 'parentName', 'isPrimary', 'createdAt', 'updatedAt'] + }], + raw: false // Ensure we get model instances, not plain objects, so getters are called + }) .then(members => { return members.map(member => { const imagePath = path.join('images', 'members', `${member.id}.jpg`); const hasImage = fs.existsSync(imagePath); + const memberJson = member.toJSON(); + // Ensure contacts are properly serialized - access via model instance to trigger getters + if (member.contacts && Array.isArray(member.contacts)) { + memberJson.contacts = member.contacts.map(contact => { + // Access properties through the model instance to trigger getters + return { + id: contact.id, + memberId: contact.memberId, + type: contact.type, + value: contact.value, // Getter should decrypt this + isParent: contact.isParent, + parentName: contact.parentName, // Getter should decrypt this + isPrimary: contact.isPrimary, + createdAt: contact.createdAt, + updatedAt: contact.updatedAt + }; + }); + } return { - ...member.toJSON(), + ...memberJson, hasImage: hasImage }; }); @@ -49,19 +77,21 @@ class MemberService { }); } - async setClubMember(userToken, clubId, memberId, firstName, lastName, street, city, birthdate, phone, email, active = true, testMembership = false, - picsInInternetAllowed = false, gender = 'unknown', ttr = null, qttr = null, memberFormHandedOver = false) { + async setClubMember(userToken, clubId, memberId, firstName, lastName, street, city, postalCode, birthdate, phone, email, active = true, testMembership = false, + picsInInternetAllowed = false, gender = 'unknown', ttr = null, qttr = null, memberFormHandedOver = false, contacts = []) { try { await checkAccess(userToken, clubId); let member = null; if (memberId) { member = await Member.findOne({ where: { id: memberId } }); } + const MemberContact = (await import('../models/MemberContact.js')).default; if (member) { member.firstName = firstName; member.lastName = lastName; member.street = street; member.city = city; + if (postalCode !== undefined) member.postalCode = postalCode; member.birthDate = birthdate; member.phone = phone; member.email = email; @@ -73,12 +103,32 @@ class MemberService { if (qttr !== undefined) member.qttr = qttr; member.memberFormHandedOver = !!memberFormHandedOver; await member.save(); + + // Update contacts if provided + if (Array.isArray(contacts)) { + // Delete existing contacts + await MemberContact.destroy({ where: { memberId: member.id } }); + // Create new contacts + for (const contact of contacts) { + if (contact.value && contact.type) { + await MemberContact.create({ + memberId: member.id, + type: contact.type, + value: contact.value, + isParent: contact.isParent || false, + parentName: contact.parentName || null, + isPrimary: contact.isPrimary || false + }); + } + } + } } else { - await Member.create({ + const newMember = await Member.create({ firstName: firstName, lastName: lastName, street: street, city: city, + postalCode: postalCode || null, birthDate: birthdate, phone: phone, email: email, @@ -91,6 +141,22 @@ class MemberService { qttr: qttr, memberFormHandedOver: !!memberFormHandedOver, }); + + // Create contacts if provided + if (Array.isArray(contacts)) { + for (const contact of contacts) { + if (contact.value && contact.type) { + await MemberContact.create({ + memberId: newMember.id, + type: contact.type, + value: contact.value, + isParent: contact.isParent || false, + parentName: contact.parentName || null, + isPrimary: contact.isPrimary || false + }); + } + } + } } return { status: 200, diff --git a/backend/utils/encrypt.js b/backend/utils/encrypt.js index 66dd44f..5aab2e3 100644 --- a/backend/utils/encrypt.js +++ b/backend/utils/encrypt.js @@ -24,10 +24,18 @@ function encryptData(data) { } function decryptData(data) { - const decipher = crypto.createDecipheriv('aes-256-cbc', Buffer.from(process.env.ENCRYPTION_KEY, 'hex'), Buffer.alloc(16, 0)); - let decrypted = decipher.update(data, 'hex', 'utf8'); - decrypted += decipher.final('utf8'); - return decrypted; + if (!data || data === null || data === undefined || data === '') { + return null; + } + try { + const decipher = crypto.createDecipheriv('aes-256-cbc', Buffer.from(process.env.ENCRYPTION_KEY, 'hex'), Buffer.alloc(16, 0)); + let decrypted = decipher.update(data, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + return decrypted; + } catch (error) { + console.error('[decryptData] Error decrypting data:', error); + return null; + } } export { createHash, encryptData, decryptData }; \ No newline at end of file diff --git a/frontend/src/components/PDFGenerator.js b/frontend/src/components/PDFGenerator.js index b79338b..5173e68 100644 --- a/frontend/src/components/PDFGenerator.js +++ b/frontend/src/components/PDFGenerator.js @@ -203,7 +203,40 @@ class PDFGenerator { addPhoneListRow(member) { const fullName = `${member.lastName}, ${member.firstName}`; const birthDate = member.birthDate ? new Date(member.birthDate).toLocaleDateString('de-DE') : ''; - const phoneNumber = member.phone || ''; + + // Sammle alle Telefonnummern aus contacts + let phoneNumbers = []; + if (member.contacts && Array.isArray(member.contacts)) { + const phoneContacts = member.contacts + .filter(c => c.type === 'phone' && c.value && String(c.value).trim() !== '') + .sort((a, b) => { + // Primäre Telefonnummer zuerst + if (a.isPrimary && !b.isPrimary) return -1; + if (!a.isPrimary && b.isPrimary) return 1; + return 0; + }); + + phoneContacts.forEach(contact => { + let phoneText = contact.value; + if (contact.isParent) { + // Bei Elternteil-Nummern: Name des Elternteils + Name des Mitglieds + if (contact.parentName) { + phoneText += ` (${contact.parentName} von ${member.firstName} ${member.lastName})`; + } else { + phoneText += ` (Elternteil von ${member.firstName} ${member.lastName})`; + } + } + // Bei eigenen Nummern wird nur die Nummer angezeigt (Name steht bereits in erster Spalte) + phoneNumbers.push(phoneText); + }); + } + + // Fallback auf altes phone-Feld für Rückwärtskompatibilität + if (phoneNumbers.length === 0 && member.phone) { + phoneNumbers.push(member.phone); + } + + const phoneNumber = phoneNumbers.join(', ') || ''; this.pdf.text(fullName, this.margin, this.yPos); this.pdf.text(birthDate, this.margin + 60, this.yPos); diff --git a/frontend/src/views/MembersView.vue b/frontend/src/views/MembersView.vue index 78fed35..2776f9d 100644 --- a/frontend/src/views/MembersView.vue +++ b/frontend/src/views/MembersView.vue @@ -44,10 +44,43 @@ + - - + + +
    + +
    + + + + + +
    + +
    + + +
    + +
    + + + + + +
    + +
    @@ -1292,4 +1318,13 @@ li { .btn-cancel:hover { background-color: #5a6268; } + +.output ul li.active { + font-weight: 600; + color: var(--primary-color, #2b7cff); +} + +.team-league { + color: var(--text-muted, #6c757d); +} diff --git a/frontend/src/views/TournamentsView.vue b/frontend/src/views/TournamentsView.vue index 893433b..3979af6 100644 --- a/frontend/src/views/TournamentsView.vue +++ b/frontend/src/views/TournamentsView.vue @@ -670,20 +670,9 @@ @@ -164,19 +307,30 @@ export default { .image-viewer-content { display: flex; flex-direction: column; + gap: 1.5rem; +} + +.image-main { + display: flex; align-items: center; - gap: 1rem; + justify-content: center; + position: relative; +} + +.image-main.has-images { + min-height: 260px; } .image-container { - width: 100%; + flex: 1; display: flex; justify-content: center; align-items: center; - min-height: 200px; + min-height: 220px; background: #f5f5f5; - border-radius: 4px; + border-radius: 6px; overflow: hidden; + position: relative; } .viewer-image { @@ -193,20 +347,40 @@ export default { font-style: italic; } +.nav-button { + border: none; + background: rgba(0, 0, 0, 0.4); + color: #fff; + width: 36px; + height: 36px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.5rem; + cursor: pointer; + transition: background 0.2s ease; + margin: 0 0.5rem; +} + +.nav-button:hover { + background: rgba(0, 0, 0, 0.6); +} + .image-actions { display: flex; - gap: 0.5rem; flex-wrap: wrap; + gap: 0.75rem; justify-content: center; } .action-btn { - padding: 0.5rem 1rem; + padding: 0.5rem 1.25rem; border: 1px solid var(--border-color); border-radius: 4px; background: white; color: var(--text-color); - font-size: 0.875rem; + font-size: 0.9rem; cursor: pointer; transition: all 0.2s ease; } @@ -217,11 +391,92 @@ export default { color: var(--primary-color); } +.action-btn--danger { + border-color: #dc3545; + color: #dc3545; +} + +.action-btn--danger:hover { + background: #dc354514; +} + +.upload-section { + display: flex; + justify-content: center; +} + +.upload-label { + position: relative; + padding: 0.6rem 1.4rem; + border: 1px dashed var(--border-color); + border-radius: 6px; + cursor: pointer; + color: var(--text-color); + font-size: 0.95rem; + transition: border 0.2s ease, background 0.2s ease; +} + +.upload-label:hover { + border-color: var(--primary-color); + background: var(--primary-light); +} + +.upload-label input { + position: absolute; + opacity: 0; + pointer-events: none; + width: 0; + height: 0; +} + +.thumbnail-strip { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + justify-content: center; +} + +.thumbnail { + width: 80px; + height: 80px; + border-radius: 4px; + overflow: hidden; + cursor: pointer; + position: relative; + border: 2px solid transparent; + transition: transform 0.2s ease, border 0.2s ease; +} + +.thumbnail img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.thumbnail--active { + border-color: var(--primary-color); + transform: scale(1.05); +} + +.thumbnail--primary { + box-shadow: 0 0 0 2px rgba(40, 167, 69, 0.5); +} + +.thumbnail-badge { + position: absolute; + bottom: 4px; + right: 4px; + background: rgba(40, 167, 69, 0.85); + color: #fff; + padding: 2px 6px; + font-size: 0.65rem; + border-radius: 12px; +} + .extra-content { width: 100%; - margin-top: 1rem; - padding-top: 1rem; border-top: 1px solid var(--border-color); + padding-top: 1rem; } .btn-secondary { @@ -240,19 +495,17 @@ export default { background: #5a6268; } -/* Responsive */ @media (max-width: 768px) { .viewer-image { max-height: 50vh; } - - .image-actions { + + .image-main { flex-direction: column; - width: 100%; } - - .action-btn { - width: 100%; + + .nav-button { + position: static; } } diff --git a/frontend/src/views/MembersView.vue b/frontend/src/views/MembersView.vue index 09596e9..3d2667f 100644 --- a/frontend/src/views/MembersView.vue +++ b/frontend/src/views/MembersView.vue @@ -162,10 +162,16 @@