From 3f2c474913468990cb94c5eb9ce2e8c4666dfbde Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Fri, 14 Aug 2026 11:25:44 +0200 Subject: [PATCH] feat: implement QTTR status tracking and update mechanism --- backend/controllers/memberController.js | 39 +++++++++++++++++++ ...20260814_add_last_qttr_value_change_at.sql | 1 + backend/models/Club.js | 6 +++ backend/routes/memberRoutes.js | 2 + backend/services/memberService.js | 10 +++++ .../members/MembersOverviewSection.vue | 5 ++- frontend/src/views/MembersView.vue | 26 +++++++++++++ 7 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 backend/migrations/20260814_add_last_qttr_value_change_at.sql diff --git a/backend/controllers/memberController.js b/backend/controllers/memberController.js index 20454997..f71593fa 100755 --- a/backend/controllers/memberController.js +++ b/backend/controllers/memberController.js @@ -15,6 +15,8 @@ import MemberProfileChangeRequest from '../models/MemberProfileChangeRequest.js' import MemberEventResponse from '../models/MemberEventResponse.js'; import Participant from '../models/Participant.js'; import DiaryDate from '../models/DiaryDates.js'; +import Club from '../models/Club.js'; +import myTischtennisService from '../services/myTischtennisService.js'; import { devLog } from '../utils/logger.js'; const getClubMembers = async(req, res) => { @@ -164,6 +166,15 @@ const updateRatingsFromMyTischtennis = async (req, res) => { const { id: clubId } = req.params; const { authcode: userToken } = req.headers; const result = await MemberService.updateRatingsFromMyTischtennis(userToken, clubId); + if (result.status >= 200 && result.status < 300) { + await myTischtennisService.logUpdateAttempt( + req.user.id, + true, + result.response?.message || 'TTR/QTTR manuell aktualisiert', + null, + result.response?.updated || 0 + ); + } res.status(result.status).json(result.response); } catch (error) { console.error('[updateRatingsFromMyTischtennis] - Error:', error); @@ -171,6 +182,33 @@ const updateRatingsFromMyTischtennis = async (req, res) => { } }; +const getNextQttrPublication = (now = new Date()) => { + const year = now.getFullYear(); + const dates = [1, 4, 7, 11].map((month) => new Date(year, month, 11)); + let cutoff = dates.find((date) => date >= new Date(now.getFullYear(), now.getMonth(), now.getDate())); + if (!cutoff) cutoff = new Date(year + 1, 1, 11); + const availableFrom = new Date(cutoff); availableFrom.setDate(availableFrom.getDate() + 3); + const availableUntil = new Date(cutoff); availableUntil.setDate(availableUntil.getDate() + 7); + return { cutoff, availableFrom, availableUntil }; +}; + +const getRatingsStatus = async (req, res) => { + try { + const clubId = Number(req.params.id); + const club = await Club.findByPk(clubId, { attributes: ['lastQttrValueChangeAt'] }); + const next = getNextQttrPublication(); + res.json({ + lastQttrValueChangeAt: club?.lastQttrValueChangeAt || null, + nextQttrCutoff: next.cutoff, + expectedAvailableFrom: next.availableFrom, + expectedAvailableUntil: next.availableUntil, + }); + } catch (error) { + console.error('[getRatingsStatus] - Error:', error); + res.status(500).json({ error: 'QTTR-Status konnte nicht geladen werden.' }); + } +}; + const getMemberTtrHistory = async (req, res) => { try { const { clubId, memberId } = req.params; @@ -630,6 +668,7 @@ export { uploadMemberImage, getMemberImage, updateRatingsFromMyTischtennis, + getRatingsStatus, getMemberTtrHistory, refreshMemberTtrHistory, rotateMemberImage, diff --git a/backend/migrations/20260814_add_last_qttr_value_change_at.sql b/backend/migrations/20260814_add_last_qttr_value_change_at.sql new file mode 100644 index 00000000..e3f19fe8 --- /dev/null +++ b/backend/migrations/20260814_add_last_qttr_value_change_at.sql @@ -0,0 +1 @@ +ALTER TABLE clubs ADD COLUMN last_qttr_value_change_at DATETIME NULL; diff --git a/backend/models/Club.js b/backend/models/Club.js index 45cf0afa..a8904bd9 100755 --- a/backend/models/Club.js +++ b/backend/models/Club.js @@ -30,6 +30,12 @@ const Club = sequelize.define('Club', { field: 'auto_fetch_rankings', comment: 'Enable automatic TTR/QTTR rankings fetch for this club' }, + lastQttrValueChangeAt: { + type: DataTypes.DATE, + allowNull: true, + field: 'last_qttr_value_change_at', + comment: 'Zeitpunkt der letzten echten QTTR-Wertänderung; Erstbefüllungen werden nicht gezählt' + }, countryCode: { type: DataTypes.STRING(2), allowNull: false, diff --git a/backend/routes/memberRoutes.js b/backend/routes/memberRoutes.js index 2017e179..5d123638 100755 --- a/backend/routes/memberRoutes.js +++ b/backend/routes/memberRoutes.js @@ -9,6 +9,7 @@ import { uploadMemberImage, getMemberImage, updateRatingsFromMyTischtennis, + getRatingsStatus, getMemberTtrHistory, refreshMemberTtrHistory, rotateMemberImage, @@ -67,6 +68,7 @@ router.get('/play-interest/:clubId', authenticate, authorize('members', 'read'), router.post('/play-interest/:clubId', authenticate, authorize('members', 'write'), setMemberPlayInterest); router.get('/notapproved/:id', authenticate, authorize('members', 'read'), getWaitingApprovals); router.post('/update-ratings/:id', authenticate, authorize('mytischtennis', 'write'), updateRatingsFromMyTischtennis); +router.get('/ratings-status/:id', authenticate, authorize('mytischtennis', 'read'), getRatingsStatus); router.get('/ttr-history/:clubId/:memberId', authenticate, authorize('members', 'read'), getMemberTtrHistory); router.post('/ttr-history/:clubId/:memberId/refresh', authenticate, authorize('members', 'read'), refreshMemberTtrHistory); router.post('/rotate-image/:clubId/:memberId/:imageId', authenticate, authorize('members', 'write'), rotateMemberImage); diff --git a/backend/services/memberService.js b/backend/services/memberService.js index ac613fea..397a985a 100755 --- a/backend/services/memberService.js +++ b/backend/services/memberService.js @@ -788,6 +788,16 @@ class MemberService { } await member.save(); const ratingChanged = member.ttr !== oldTtr || member.qttr !== oldQttr; + const oldQttrNumber = Number(oldQttr); + const newQttrNumber = Number(member.qttr); + const isRealQttrChange = Number.isFinite(oldQttrNumber) + && oldQttrNumber !== 0 + && Number.isFinite(newQttrNumber) + && newQttrNumber !== oldQttrNumber; + if (isRealQttrChange) { + // Die Erstbefüllung (null/0 → Wert) ist kein neuer QTTR-Stichtag. + await Club.update({ lastQttrValueChangeAt: new Date() }, { where: { id: clubId } }); + } if (ratingChanged) { const changes = []; if (member.ttr !== oldTtr) changes.push(`TTR ${oldTtr ?? '–'} → ${member.ttr ?? '–'}`); diff --git a/frontend/src/components/members/MembersOverviewSection.vue b/frontend/src/components/members/MembersOverviewSection.vue index 9988bf47..2f70daa3 100755 --- a/frontend/src/components/members/MembersOverviewSection.vue +++ b/frontend/src/components/members/MembersOverviewSection.vue @@ -10,7 +10,7 @@ -