diff --git a/backend/controllers/clubsController.js b/backend/controllers/clubsController.js index 9ce43a54..76d8b2e3 100644 --- a/backend/controllers/clubsController.js +++ b/backend/controllers/clubsController.js @@ -60,8 +60,14 @@ export const updateClubSettings = async (req, res) => { try { const { authcode: token } = req.headers; const { clubid } = req.params; - const { greetingText, associationMemberNumber } = req.body; - const updated = await ClubService.updateClubSettings(token, clubid, { greetingText, associationMemberNumber }); + const { greetingText, associationMemberNumber, myTischtennisClubId, myTischtennisFedNickname, autoFetchRankings } = req.body; + const updated = await ClubService.updateClubSettings(token, clubid, { + greetingText, + associationMemberNumber, + myTischtennisClubId, + myTischtennisFedNickname, + autoFetchRankings + }); res.status(200).json(updated); } catch (error) { if (error.message === 'noaccess') { diff --git a/backend/migrations/20260315_add_club_rankings_settings.sql b/backend/migrations/20260315_add_club_rankings_settings.sql new file mode 100644 index 00000000..c0a8eb09 --- /dev/null +++ b/backend/migrations/20260315_add_club_rankings_settings.sql @@ -0,0 +1,10 @@ +-- Migration: Add myTischtennis rankings settings to clubs table +-- Enables per-club configuration of TTR/QTTR rankings fetch (club ID, federation, enable flag) + +ALTER TABLE clubs + ADD COLUMN IF NOT EXISTS my_tischtennis_club_id VARCHAR(50) NULL + COMMENT 'myTischtennis club number for rankings (e.g. 43030)', + ADD COLUMN IF NOT EXISTS my_tischtennis_fed_nickname VARCHAR(50) NULL + COMMENT 'Federation short name for rankings (e.g. HeTTV)', + ADD COLUMN IF NOT EXISTS auto_fetch_rankings BOOLEAN NOT NULL DEFAULT FALSE + COMMENT 'Enable automatic TTR/QTTR rankings fetch for this club'; diff --git a/backend/models/Club.js b/backend/models/Club.js index dba014b8..9e44c3ff 100644 --- a/backend/models/Club.js +++ b/backend/models/Club.js @@ -17,6 +17,25 @@ const Club = sequelize.define('Club', { allowNull: true, field: 'association_member_number' }, + myTischtennisClubId: { + type: DataTypes.STRING, + allowNull: true, + field: 'my_tischtennis_club_id', + comment: 'myTischtennis club number for rankings (e.g. 43030)' + }, + myTischtennisFedNickname: { + type: DataTypes.STRING, + allowNull: true, + field: 'my_tischtennis_fed_nickname', + comment: 'Federation short name for rankings (e.g. HeTTV)' + }, + autoFetchRankings: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + field: 'auto_fetch_rankings', + comment: 'Enable automatic TTR/QTTR rankings fetch for this club' + } }, { tableName: 'clubs', underscored: true, diff --git a/backend/services/autoUpdateRatingsService.js b/backend/services/autoUpdateRatingsService.js index 2c359cff..a1e1d459 100644 --- a/backend/services/autoUpdateRatingsService.js +++ b/backend/services/autoUpdateRatingsService.js @@ -2,38 +2,35 @@ import myTischtennisService from './myTischtennisService.js'; import myTischtennisFetchLogService from './myTischtennisFetchLogService.js'; import memberService from './memberService.js'; import MyTischtennis from '../models/MyTischtennis.js'; -import { devLog } from '../utils/logger.js'; +import Club from '../models/Club.js'; import UserClub from '../models/UserClub.js'; +import { Op } from 'sequelize'; +import { devLog } from '../utils/logger.js'; class AutoUpdateRatingsService { /** - * Execute automatic rating updates for all users with enabled auto-updates + * Execute automatic rating updates for all clubs with autoFetchRankings enabled. + * For each club, finds a user with MyTischtennis account and updates that club's members. */ async executeAutomaticUpdates() { devLog('Starting automatic rating updates...'); try { - // Find all users with auto-updates enabled - const accounts = await MyTischtennis.findAll({ - where: { - autoUpdateRatings: true, - savePassword: true - } - // No attributes restriction — all fields needed for session handling and re-login + const clubs = await Club.findAll({ + where: { autoFetchRankings: true } }); - devLog(`Found ${accounts.length} accounts with auto-updates enabled`); + devLog(`Found ${clubs.length} clubs with autoFetchRankings enabled`); - if (accounts.length === 0) { - devLog('No accounts found with auto-updates enabled'); - return; + if (clubs.length === 0) { + devLog('No clubs found with autoFetchRankings enabled'); + return { success: true, totalUpdated: 0, summaries: [] }; } - // Process each account and collect summaries const summaries = []; - for (const account of accounts) { - const summary = await this.processAccount(account); - summaries.push({ userId: account.userId, ...summary }); + for (const club of clubs) { + const summary = await this.processClub(club); + summaries.push({ clubId: club.id, clubName: club.name, ...summary }); } devLog('Automatic rating updates completed'); @@ -41,28 +38,51 @@ class AutoUpdateRatingsService { return { success: true, totalUpdated, summaries }; } catch (error) { console.error('Error in automatic rating updates:', error); + throw error; } } /** - * Process a single account for rating updates + * Process a single club for rating updates. + * Finds a user with MyTischtennis account (autoUpdateRatings, savePassword) and club access. */ - async processAccount(account) { + async processClub(club) { const startTime = Date.now(); let success = false; let message = ''; let errorDetails = null; let updatedCount = 0; + let userId = null; try { - devLog(`Processing account ${account.email} (User ID: ${account.userId})`); + const userClubs = await UserClub.findAll({ + where: { clubId: club.id, approved: true }, + attributes: ['userId'] + }); + + const userIds = userClubs.map((uc) => uc.userId); + if (userIds.length === 0) { + throw new Error(`Keine freigeschalteten Benutzer für Verein "${club.name}" gefunden`); + } + + const account = await MyTischtennis.findOne({ + where: { + userId: { [Op.in]: userIds }, + autoUpdateRatings: true, + savePassword: true + } + }); + + if (!account) { + throw new Error(`Kein myTischtennis-Account mit Auto-Update für Verein "${club.name}" gefunden`); + } + + userId = account.userId; + devLog(`Processing club ${club.name} (Club ID: ${club.id}) via User ${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 via verifyLogin (incl. Playwright fallback)`); - // verifyLogin handles CAPTCHA via Playwright and persists the session to DB. + devLog(`Session expired, attempting re-login via verifyLogin (incl. Playwright fallback)`); await myTischtennisService.verifyLogin(account.userId); - // Reload the account to get the fresh session data written by verifyLogin. const refreshed = await MyTischtennis.findOne({ where: { userId: account.userId } }); if (!refreshed?.cookie) { throw new Error('Re-login via verifyLogin did not produce a valid session'); @@ -71,91 +91,36 @@ class AutoUpdateRatingsService { account.refreshToken = refreshed.refreshToken; account.expiresAt = refreshed.expiresAt; account.cookie = refreshed.cookie; - devLog(`Successfully re-logged in for ${account.email}`); + devLog(`Successfully re-logged in`); } - // Perform rating update - const updateResult = await this.updateRatings(account); - updatedCount = updateResult.updatedCount || 0; - + const result = await memberService.updateRatingsFromMyTischtennisByUserId(account.userId, club.id); + updatedCount = result?.response?.updated ?? 0; success = true; message = `Successfully updated ${updatedCount} ratings`; - devLog(`Updated ${updatedCount} ratings for ${account.email}`); + devLog(`Updated ${updatedCount} ratings for club ${club.name}`); } catch (error) { success = false; message = 'Update failed'; errorDetails = error.message; - console.error(`Error updating ratings for ${account.email}:`, error); + console.error(`Error updating ratings for club ${club.name}:`, error); } const executionTime = Date.now() - startTime; - // Log the attempt to MyTischtennisFetchLog - await myTischtennisFetchLogService.logFetch( - account.userId, - 'ratings', - success, - message, - { - errorDetails, - recordsProcessed: updatedCount, - executionTime, - isAutomatic: true - } - ); - - // Also log to update history (for backwards compatibility) - await myTischtennisService.logUpdateAttempt( - account.userId, - success, - message, - errorDetails, - updatedCount, - executionTime - ); - - // Return summary for scheduler - return { success, message, updatedCount, errorDetails, executionTime }; - } - - /** - * Update ratings for a specific account - */ - async updateRatings(account) { - devLog(`Updating ratings for ${account.email}`); - - try { - // Ermittle einen freigeschalteten Vereinszugang für den Benutzer - const userClub = await UserClub.findOne({ - where: { - userId: account.userId, - approved: true - }, - order: [['createdAt', 'ASC']], - attributes: ['clubId'] - }); - - if (!userClub) { - throw new Error('Kein freigeschalteter Vereinszugang gefunden'); - } - - const clubId = userClub.clubId; - - // Verwende den Service-Aufruf, der mit userId/clubId arbeitet - const result = await memberService.updateRatingsFromMyTischtennisByUserId( - account.userId, - clubId + if (userId) { + await myTischtennisFetchLogService.logFetch( + userId, + 'ratings', + success, + message, + { errorDetails, recordsProcessed: updatedCount, executionTime, isAutomatic: true, clubId: club.id } ); - - return { - success: true, - updatedCount: result.updatedCount || 0 - }; - } catch (error) { - devLog(`Error updating ratings: ${error.message}`); - throw error; + await myTischtennisService.logUpdateAttempt(userId, success, message, errorDetails, updatedCount, executionTime); } + + return { success, message, updatedCount, errorDetails, executionTime }; } /** diff --git a/backend/services/clubService.js b/backend/services/clubService.js index 68c3d85e..c35af8a3 100644 --- a/backend/services/clubService.js +++ b/backend/services/clubService.js @@ -67,13 +67,23 @@ class ClubService { }); } - async updateClubSettings(userToken, clubId, { greetingText, associationMemberNumber }) { + async updateClubSettings(userToken, clubId, { + greetingText, + associationMemberNumber, + myTischtennisClubId, + myTischtennisFedNickname, + autoFetchRankings + }) { await checkAccess(userToken, clubId); const club = await Club.findByPk(clubId); if (!club) { throw new Error('clubnotfound'); } - return await club.update({ greetingText, associationMemberNumber }); + const updates = { greetingText, associationMemberNumber }; + if (myTischtennisClubId !== undefined) updates.myTischtennisClubId = myTischtennisClubId || null; + if (myTischtennisFedNickname !== undefined) updates.myTischtennisFedNickname = myTischtennisFedNickname || null; + if (autoFetchRankings !== undefined) updates.autoFetchRankings = !!autoFetchRankings; + return await club.update(updates); } async approveUserClubAccess(userToken, clubId, toApproveUserId) { diff --git a/backend/services/memberService.js b/backend/services/memberService.js index 3c55a0a1..f908a8ab 100644 --- a/backend/services/memberService.js +++ b/backend/services/memberService.js @@ -1,4 +1,5 @@ import UserClub from "../models/UserClub.js"; +import Club from "../models/Club.js"; import { checkAccess, getUserByToken, hasUserClubAccess } from "../utils/userUtils.js"; import Member from "../models/Member.js"; import MemberImage from "../models/MemberImage.js"; @@ -375,23 +376,44 @@ class MemberService { } }; } - - if (!account.clubId || !account.fedNickname) { + + const club = await Club.findByPk(clubId); + if (!club) { + return { + status: 404, + response: { message: 'Verein nicht gefunden.', updated: 0, errors: [] } + }; + } + + if (!club.autoFetchRankings) { + return { + status: 200, + response: { + message: 'Ranglisten-Abruf für diesen Verein ist deaktiviert.', + updated: 0, + errors: [] + } + }; + } + + const effectiveClubId = (club.myTischtennisClubId || '').trim() || account.clubId; + const effectiveFedNickname = (club.myTischtennisFedNickname || '').trim() || account.fedNickname; + + if (!effectiveClubId || !effectiveFedNickname) { console.error('[updateRatingsFromMyTischtennis] - Missing clubId or fedNickname:', { - clubId: account.clubId, - fedNickname: account.fedNickname + clubId: effectiveClubId, + fedNickname: effectiveFedNickname, + fromClub: !!(club.myTischtennisClubId && club.myTischtennisFedNickname) }); return { status: 400, response: { - message: 'Club-ID oder Verbandskürzel nicht verfügbar. Bitte einmal einloggen.', + message: 'Club-ID oder Verbandskürzel nicht verfügbar. Bitte in den Vereinseinstellungen hinterlegen oder einmal in myTischtennis einloggen.', updated: 0, errors: [], debug: { - hasClubId: !!account.clubId, - hasFedNickname: !!account.fedNickname, - clubId: account.clubId, - fedNickname: account.fedNickname + hasClubId: !!effectiveClubId, + hasFedNickname: !!effectiveFedNickname } } }; @@ -403,7 +425,7 @@ class MemberService { await (await import('./apiLogService.js')).default.logRequest({ userId, method: 'GET', - path: `/rankings/andro-rangliste?club=${account.clubId}&fed=${account.fedNickname}¤t=yes`, + path: `/rankings/andro-rangliste?club=${effectiveClubId}&fed=${effectiveFedNickname}¤t=yes`, statusCode: null, requestBody: null, responseBody: null, @@ -416,15 +438,15 @@ class MemberService { const ttrStart = Date.now(); const rankingsCurrent = await myTischtennisClient.getClubRankings( session.cookie, - account.clubId, - account.fedNickname, + effectiveClubId, + effectiveFedNickname, 'yes' ); try { await (await import('./apiLogService.js')).default.logRequest({ userId, method: 'GET', - path: `/rankings/andro-rangliste?club=${account.clubId}&fed=${account.fedNickname}¤t=yes`, + path: `/rankings/andro-rangliste?club=${effectiveClubId}&fed=${effectiveFedNickname}¤t=yes`, statusCode: rankingsCurrent.success ? 200 : 500, requestBody: null, responseBody: JSON.stringify(rankingsCurrent), @@ -440,7 +462,7 @@ class MemberService { await (await import('./apiLogService.js')).default.logRequest({ userId, method: 'GET', - path: `/rankings/andro-rangliste?club=${account.clubId}&fed=${account.fedNickname}¤t=no`, + path: `/rankings/andro-rangliste?club=${effectiveClubId}&fed=${effectiveFedNickname}¤t=no`, statusCode: null, requestBody: null, responseBody: null, @@ -453,8 +475,8 @@ class MemberService { const qttrStart = Date.now(); const rankingsQuarter = await myTischtennisClient.getClubRankings( session.cookie, - account.clubId, - account.fedNickname, + effectiveClubId, + effectiveFedNickname, 'no' ); let qttrWarning = null; @@ -462,7 +484,7 @@ class MemberService { await (await import('./apiLogService.js')).default.logRequest({ userId, method: 'GET', - path: `/rankings/andro-rangliste?club=${account.clubId}&fed=${account.fedNickname}¤t=no`, + path: `/rankings/andro-rangliste?club=${effectiveClubId}&fed=${effectiveFedNickname}¤t=no`, statusCode: rankingsQuarter.success ? 200 : 500, requestBody: null, responseBody: JSON.stringify(rankingsQuarter), @@ -481,8 +503,8 @@ class MemberService { updated: 0, errors: [], debug: { - clubId: account.clubId, - fedNickname: account.fedNickname, + clubId: effectiveClubId, + fedNickname: effectiveFedNickname, rankingsError: rankingsCurrent.error } } diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 0c8b7add..079be8e4 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -737,7 +737,14 @@ "saved": "Gespeichert", "saveFailed": "Speichern fehlgeschlagen", "loadFailed": "Einstellungen konnten nicht geladen werden", - "noClubSelected": "Bitte wählen Sie zuerst einen Verein aus." + "noClubSelected": "Bitte wählen Sie zuerst einen Verein aus.", + "myTischtennisRankings": "myTischtennis TTR/QTTR-Ranglisten", + "myTischtennisRankingsHint": "Automatischer Abruf der Vereins-Rangliste für TTR- und QTTR-Updates der Mitglieder.", + "autoFetchRankings": "Ranglisten automatisch abrufen", + "myTischtennisClubId": "myTischtennis Vereinsnummer", + "myTischtennisClubIdPlaceholder": "z. B. 43030", + "myTischtennisFedNickname": "Verbandskürzel", + "myTischtennisFedNicknamePlaceholder": "z. B. HeTTV" }, "predefinedActivities": { "title": "Vordefinierte Aktivitäten", diff --git a/frontend/src/views/ClubSettings.vue b/frontend/src/views/ClubSettings.vue index 21422cf9..9e22f39b 100644 --- a/frontend/src/views/ClubSettings.vue +++ b/frontend/src/views/ClubSettings.vue @@ -51,6 +51,27 @@ +
+

{{ $t('clubSettings.myTischtennisRankings') }}

+

{{ $t('clubSettings.myTischtennisRankingsHint') }}

+
+ +
+
+
+ + +
+
+ + +
+
+
+
@@ -91,6 +112,9 @@ export default { activeTab: 'settings', greeting: '', associationMemberNumber: '', + myTischtennisClubId: '', + myTischtennisFedNickname: '', + autoFetchRankings: false, saved: false, loading: false, loadError: null, @@ -112,6 +136,9 @@ export default { if (!this.currentClub) { this.greeting = ''; this.associationMemberNumber = ''; + this.myTischtennisClubId = ''; + this.myTischtennisFedNickname = ''; + this.autoFetchRankings = false; this.loadError = null; return; } @@ -122,10 +149,16 @@ export default { const club = response.data; this.greeting = club?.greetingText ?? ''; this.associationMemberNumber = club?.associationMemberNumber ?? ''; + this.myTischtennisClubId = club?.myTischtennisClubId ?? ''; + this.myTischtennisFedNickname = club?.myTischtennisFedNickname ?? ''; + this.autoFetchRankings = !!club?.autoFetchRankings; } catch (e) { this.loadError = this.$t('clubSettings.loadFailed'); this.greeting = ''; this.associationMemberNumber = ''; + this.myTischtennisClubId = ''; + this.myTischtennisFedNickname = ''; + this.autoFetchRankings = false; } finally { this.loading = false; } @@ -139,6 +172,9 @@ export default { await apiClient.put(`/clubs/${this.currentClub}/settings`, { greetingText: this.greeting, associationMemberNumber: this.associationMemberNumber, + myTischtennisClubId: this.myTischtennisClubId || null, + myTischtennisFedNickname: this.myTischtennisFedNickname || null, + autoFetchRankings: this.autoFetchRankings, }); this.saved = true; setTimeout(() => (this.saved = false), 1500); @@ -174,6 +210,11 @@ export default { font-size: 14px; } .text-input { width: 100%; border: 1px solid #ddd; border-radius: 6px; padding: 8px; font-size: 14px; } +.rankings-row { margin-bottom: 12px; } +.rankings-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 12px; } +.field-group label { display: block; margin-bottom: 4px; font-weight: 500; color: #333; } +.checkbox-label { display: flex; align-items: center; gap: 8px; cursor: pointer; } +.checkbox-label input[type="checkbox"] { width: auto; } .actions { display: flex; align-items: center; gap: 10px; margin-top: 10px; } .btn.btn-primary { background: var(--primary-color); color: #fff; border: none; padding: 8px 12px; border-radius: 6px; cursor: pointer; } .btn.btn-primary:hover { background: var(--primary-hover); }