feat(clubSettings): enhance club settings with myTischtennis integration

- Added new fields for myTischtennis club ID, federation nickname, and auto-fetch rankings in the club settings.
- Updated the backend to handle the new settings in the updateClubSettings method.
- Implemented automatic ranking updates for clubs based on the new settings in the autoUpdateRatingsService.
- Enhanced the frontend to support the new settings, including validation and user interface updates for better user experience.
This commit is contained in:
Torsten Schulz (local)
2026-03-12 10:25:49 +01:00
parent 595e2eb141
commit ad09a45b17
8 changed files with 198 additions and 118 deletions

View File

@@ -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') {

View File

@@ -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';

View File

@@ -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,

View File

@@ -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 };
}
/**

View File

@@ -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) {

View File

@@ -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}&current=yes`,
path: `/rankings/andro-rangliste?club=${effectiveClubId}&fed=${effectiveFedNickname}&current=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}&current=yes`,
path: `/rankings/andro-rangliste?club=${effectiveClubId}&fed=${effectiveFedNickname}&current=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}&current=no`,
path: `/rankings/andro-rangliste?club=${effectiveClubId}&fed=${effectiveFedNickname}&current=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}&current=no`,
path: `/rankings/andro-rangliste?club=${effectiveClubId}&fed=${effectiveFedNickname}&current=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
}
}

View File

@@ -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",

View File

@@ -51,6 +51,27 @@
<input v-model="associationMemberNumber" class="text-input" :placeholder="$t('clubSettings.associationMemberNumberPlaceholder')" />
</section>
<section v-if="currentClub && !loading" class="card">
<h2>{{ $t('clubSettings.myTischtennisRankings') }}</h2>
<p class="hint">{{ $t('clubSettings.myTischtennisRankingsHint') }}</p>
<div class="rankings-row">
<label class="checkbox-label">
<input type="checkbox" v-model="autoFetchRankings" />
{{ $t('clubSettings.autoFetchRankings') }}
</label>
</div>
<div v-if="autoFetchRankings" class="rankings-fields">
<div class="field-group">
<label>{{ $t('clubSettings.myTischtennisClubId') }}</label>
<input v-model="myTischtennisClubId" class="text-input" :placeholder="$t('clubSettings.myTischtennisClubIdPlaceholder')" />
</div>
<div class="field-group">
<label>{{ $t('clubSettings.myTischtennisFedNickname') }}</label>
<input v-model="myTischtennisFedNickname" class="text-input" :placeholder="$t('clubSettings.myTischtennisFedNicknamePlaceholder')" />
</div>
</div>
</section>
<section v-if="currentClub && !loading" class="card actions-card">
<div class="actions">
<button class="btn btn-primary" @click="save">{{ $t('clubSettings.save') }}</button>
@@ -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); }