feat(MemberTtrHistory): implement TTR history management and UI enhancements

- Added new endpoints in the member controller for retrieving and refreshing TTR history.
- Integrated TTR history functionality into the member service, allowing for seamless data retrieval and updates.
- Updated the member model to include a field for TTR history player ID, enhancing data tracking.
- Enhanced the MembersView to display TTR history with a dedicated dialog for better user interaction.
- Improved the MyTischtennisClient to support fetching historical player IDs, enriching the data provided to users.
- Refactored various components to ensure consistent styling and functionality across the application.
This commit is contained in:
Torsten Schulz (local)
2026-03-18 15:34:10 +01:00
parent 79adad9564
commit 563a7e8dde
16 changed files with 1471 additions and 108 deletions

View File

@@ -65,15 +65,14 @@ class MatchService {
} else {
seasonStartYear = currentYear - 1;
}
const seasonEndYear = seasonStartYear + 1;
const seasonEndYear = seasonStartYear + 1;
return `${seasonStartYear}/${seasonEndYear}`;
}
async importCSV(userToken, clubId, filePath) {
await checkAccess(userToken, clubId);
async importCSV(userToken, clubId, filePath) {
await checkAccess(userToken, clubId);
let seasonString = '';
const matches = [];
try {
try {
const fileStream = fs.createReadStream(filePath)
.pipe(iconv.decodeStream('utf8'))
.pipe(csv({ separator: ';' }));

View File

@@ -3,6 +3,7 @@ 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";
import MemberTtrHistory from "../models/MemberTtrHistory.js";
import Participant from "../models/Participant.js";
import DiaryDate from "../models/DiaryDates.js";
import path from 'path';
@@ -419,7 +420,17 @@ class MemberService {
};
}
// 2. Ranglisten vom Verein abrufen (Logging hinzufügen)
// 2. Mitglieder laden und prüfen, ob wir zusätzlich die TTR-History-ID auflösen müssen
const members = await Member.findAll({ where: { clubId } });
const shouldResolveHistoryPlayerIds = members.some((member) =>
!member.myTischtennisHistoryPlayerId && (
member.myTischtennisPlayerId ||
member.ttr != null ||
member.qttr != null
)
);
// 3. Ranglisten vom Verein abrufen
// TTR (aktuell)
try {
await (await import('./apiLogService.js')).default.logRequest({
@@ -440,7 +451,8 @@ class MemberService {
session.cookie,
effectiveClubId,
effectiveFedNickname,
'yes'
'yes',
{ includeHistoryPlayerIds: shouldResolveHistoryPlayerIds }
);
try {
await (await import('./apiLogService.js')).default.logRequest({
@@ -477,7 +489,8 @@ class MemberService {
session.cookie,
effectiveClubId,
effectiveFedNickname,
'no'
'no',
{ includeHistoryPlayerIds: shouldResolveHistoryPlayerIds }
);
let qttrWarning = null;
try {
@@ -515,9 +528,6 @@ class MemberService {
qttrWarning = rankingsQuarter.error || 'QTTR Abruf fehlgeschlagen';
}
// 3. Alle Mitglieder des Clubs laden
const members = await Member.findAll({ where: { clubId } });
let updated = 0;
const errors = [];
if (qttrWarning) {
@@ -561,6 +571,7 @@ class MemberService {
try {
const oldTtr = member.ttr;
const oldQttr = member.qttr;
const historyPlayerId = this._extractTtrHistoryPlayerId(rankingEntry) || this._extractTtrHistoryPlayerId(rankingQuarterEntry);
if (rankingEntry && typeof rankingEntry.fedRank === 'number') {
member.ttr = rankingEntry.fedRank;
if (member.ttr !== oldTtr) updatedTtr++;
@@ -576,6 +587,9 @@ class MemberService {
if (member.qttr !== oldQttr) updatedQttr++;
}
}
if (historyPlayerId && member.myTischtennisHistoryPlayerId !== historyPlayerId) {
member.myTischtennisHistoryPlayerId = historyPlayerId;
}
await member.save();
updated++;
matched.push({
@@ -583,7 +597,8 @@ class MemberService {
oldTtr: oldTtr,
newTtr: member.ttr,
oldQttr: oldQttr,
newQttr: member.qttr
newQttr: member.qttr,
historyPlayerId: member.myTischtennisHistoryPlayerId || null
});
} catch (error) {
console.error(`[updateRatingsFromMyTischtennis] - Error updating ${firstName} ${lastName}:`, error);
@@ -654,6 +669,444 @@ class MemberService {
return this._updateRatingsInternal(userId, clubId);
}
async getMemberTtrHistory(userToken, clubId, memberId) {
await checkAccess(userToken, clubId);
const member = await Member.findOne({
where: { id: memberId, clubId }
});
if (!member) {
return {
status: 404,
response: { success: false, error: 'Mitglied nicht gefunden.' }
};
}
const history = await MemberTtrHistory.findAll({
where: { memberId: member.id, clubId },
order: [['sourceDate', 'DESC'], ['createdAt', 'DESC']]
});
const entries = history.map(entry => ({
id: entry.id,
sourceDate: entry.sourceDate,
ttr: entry.ttr,
qttr: entry.qttr,
label: entry.label,
sourceType: entry.sourceType,
fetchedAt: entry.fetchedAt
}));
const latestFetchedAt = history.reduce((latest, entry) => {
if (!entry.fetchedAt) {
return latest;
}
const fetchedAt = new Date(entry.fetchedAt);
if (!latest || fetchedAt > latest) {
return fetchedAt;
}
return latest;
}, null);
const resolvedHistoryPlayerId = member.myTischtennisHistoryPlayerId || history.find(entry => entry.playerId)?.playerId || null;
return {
status: 200,
response: {
success: true,
member: {
id: member.id,
firstName: member.firstName,
lastName: member.lastName,
ttr: member.ttr,
qttr: member.qttr,
myTischtennisPlayerId: member.myTischtennisPlayerId || null,
myTischtennisHistoryPlayerId: resolvedHistoryPlayerId
},
history: entries,
meta: {
count: entries.length,
lastFetchedAt: latestFetchedAt ? latestFetchedAt.toISOString() : null,
refreshPolicy: this._buildTtrHistoryRefreshPolicy(latestFetchedAt)
}
}
};
}
async refreshMemberTtrHistory(userToken, clubId, memberId) {
await checkAccess(userToken, clubId);
const user = await getUserByToken(userToken);
const member = await Member.findOne({
where: { id: memberId, clubId }
});
if (!member) {
return {
status: 404,
response: { success: false, error: 'Mitglied nicht gefunden.' }
};
}
const latestHistoryEntry = await MemberTtrHistory.findOne({
where: { memberId: member.id, clubId },
order: [['fetchedAt', 'DESC'], ['createdAt', 'DESC']]
});
const historyPlayerId = member.myTischtennisHistoryPlayerId || latestHistoryEntry?.playerId || null;
const refreshPolicy = this._buildTtrHistoryRefreshPolicy(latestHistoryEntry?.fetchedAt || null);
if (!refreshPolicy.canRefresh) {
return {
status: 200,
response: {
success: true,
skipped: true,
message: refreshPolicy.message,
member: {
id: member.id,
myTischtennisPlayerId: member.myTischtennisPlayerId || null,
myTischtennisHistoryPlayerId: historyPlayerId
},
meta: {
lastFetchedAt: latestHistoryEntry?.fetchedAt ? new Date(latestHistoryEntry.fetchedAt).toISOString() : null,
refreshPolicy
}
}
};
}
if (!historyPlayerId) {
return {
status: 409,
response: {
success: false,
error: 'Für dieses Mitglied ist noch keine myTischtennis-TTR-History-ID vorhanden. Bitte zuerst die myTischtennis-Ranglisten aktualisieren.'
}
};
}
const myTischtennisService = (await import('./myTischtennisService.js')).default;
const myTischtennisClient = (await import('../clients/myTischtennisClient.js')).default;
let session;
try {
session = await myTischtennisService.getSession(user.id);
} catch (sessionError) {
try {
await myTischtennisService.verifyLogin(user.id);
session = await myTischtennisService.getSession(user.id);
} catch (loginError) {
return {
status: 401,
response: {
success: false,
needsReauth: true,
error: 'myTischtennis-Session abgelaufen. Bitte einmal neu einloggen.'
}
};
}
}
const rootEndpoint = `/rankings/ttr-historie?player-id=${encodeURIComponent(historyPlayerId)}&_data=root`;
const historyEndpoint = `/rankings/ttr-historie?player-id=${encodeURIComponent(historyPlayerId)}&show=everything&_data=routes%2F%24`;
const [rootResult, historyResult] = await Promise.all([
myTischtennisClient.authenticatedRequest(rootEndpoint, session.cookie, { method: 'GET' }),
myTischtennisClient.authenticatedRequest(historyEndpoint, session.cookie, { method: 'GET' })
]);
if (!rootResult.success) {
return {
status: 502,
response: {
success: false,
error: rootResult.error || 'myTischtennis-Root-Endpunkt konnte nicht geladen werden.'
}
};
}
if (!historyResult.success) {
return {
status: 502,
response: {
success: false,
error: historyResult.error || 'myTischtennis-TTR-Historie konnte nicht geladen werden.'
}
};
}
const parsedHistory = this._parseMyTischtennisHistoryResponse(historyResult.data);
if (!parsedHistory.success) {
return {
status: 502,
response: {
success: false,
error: parsedHistory.error || 'Die myTischtennis-TTR-Historie konnte nicht verarbeitet werden.'
}
};
}
const fetchedAt = new Date();
const historyEntries = this._mapTtrHistoryEventsToRows({
clubId,
memberId: member.id,
playerId: historyPlayerId,
fetchedAt,
historyData: parsedHistory.historyData
});
await MemberTtrHistory.destroy({
where: { memberId: member.id, clubId }
});
if (historyEntries.length > 0) {
await MemberTtrHistory.bulkCreate(historyEntries);
}
if (member.myTischtennisHistoryPlayerId !== historyPlayerId) {
member.myTischtennisHistoryPlayerId = historyPlayerId;
await member.save();
}
return {
status: 200,
response: {
success: true,
message: `${historyEntries.length} TTR-Historien-Einträge aktualisiert.`,
member: {
id: member.id,
myTischtennisPlayerId: member.myTischtennisPlayerId || null,
myTischtennisHistoryPlayerId: historyPlayerId
},
meta: {
count: historyEntries.length,
lastFetchedAt: fetchedAt.toISOString(),
refreshPolicy: this._buildTtrHistoryRefreshPolicy(fetchedAt)
}
}
};
}
_extractTtrHistoryPlayerId(entry) {
if (!entry || typeof entry !== 'object') {
return null;
}
const directCandidates = [
entry.myTischtennisHistoryPlayerId,
entry.historyPlayerId,
entry.history_player_id,
entry.ttrHistoryPlayerId,
entry.ttr_history_player_id
];
for (const candidate of directCandidates) {
if (typeof candidate === 'string' && /^P[A-Z0-9]+$/i.test(candidate.trim())) {
return candidate.trim();
}
}
const serialized = JSON.stringify(entry);
const urlMatch = serialized.match(/player-id=([P][A-Z0-9]+)/i);
if (urlMatch?.[1]) {
return urlMatch[1];
}
const fieldMatch = serialized.match(/"(?:historyPlayerId|history_player_id|ttrHistoryPlayerId|ttr_history_player_id|playerId|player_id)"\s*:\s*"(P[A-Z0-9]+)"/i);
return fieldMatch?.[1] || null;
}
_getBerlinDateParts(value = new Date()) {
const formatter = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Europe/Berlin',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23'
});
const parts = formatter.formatToParts(value).reduce((acc, part) => {
if (part.type !== 'literal') {
acc[part.type] = part.value;
}
return acc;
}, {});
return {
year: parts.year,
month: parts.month,
day: parts.day,
hour: Number(parts.hour),
minute: Number(parts.minute),
second: Number(parts.second),
dateKey: `${parts.year}-${parts.month}-${parts.day}`
};
}
_buildTtrHistoryRefreshPolicy(lastFetchedAt) {
const now = new Date();
const nowParts = this._getBerlinDateParts(now);
const hour = nowParts.hour;
if (hour < 7) {
return {
canRefresh: false,
reason: 'before_7am',
message: 'Die TTR-Historie wird nur einmal täglich und erst nach 07:00 Uhr aktualisiert.',
nextRefreshDate: nowParts.dateKey
};
}
if (!lastFetchedAt) {
return {
canRefresh: true,
reason: 'no_cache',
message: 'Die TTR-Historie kann jetzt aktualisiert werden.',
nextRefreshDate: nowParts.dateKey
};
}
const fetchedParts = this._getBerlinDateParts(new Date(lastFetchedAt));
if (fetchedParts.dateKey === nowParts.dateKey) {
return {
canRefresh: false,
reason: 'already_refreshed_today',
message: 'Die TTR-Historie wurde heute bereits aktualisiert.',
nextRefreshDate: nowParts.dateKey
};
}
return {
canRefresh: true,
reason: 'stale_cache',
message: 'Die TTR-Historie kann jetzt aktualisiert werden.',
nextRefreshDate: nowParts.dateKey
};
}
_parseMyTischtennisHistoryResponse(rawPayload) {
try {
const parsed = this._splitDeferredJsonPayload(rawPayload);
const deferredData = Object.values(parsed.deferred || {}).find((value) => value && Array.isArray(value.event));
if (!deferredData) {
return {
success: false,
error: 'Keine TTR-Historien-Daten in der myTischtennis-Antwort gefunden.'
};
}
return {
success: true,
root: parsed.root,
historyData: deferredData
};
} catch (error) {
return {
success: false,
error: error.message || 'TTR-Historien-Antwort konnte nicht geparst werden.'
};
}
}
_splitDeferredJsonPayload(rawPayload) {
if (rawPayload && typeof rawPayload === 'object' && !Array.isArray(rawPayload)) {
return { root: rawPayload, deferred: {} };
}
const text = typeof rawPayload === 'string' ? rawPayload : String(rawPayload || '');
const lines = text.split(/\r?\n/);
let root = null;
const deferred = {};
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line) {
continue;
}
if (line.startsWith('data:')) {
const payload = line.slice(5).trim();
if (!payload) {
continue;
}
const parsed = JSON.parse(payload);
Object.assign(deferred, parsed);
continue;
}
if (!root && line.startsWith('{')) {
root = JSON.parse(line);
}
}
return { root, deferred };
}
_mapTtrHistoryEventsToRows({ clubId, memberId, playerId, fetchedAt, historyData }) {
const events = Array.isArray(historyData?.event) ? historyData.event : [];
const rows = [];
const seenKeys = new Set();
for (const event of events) {
const sourceDate = this._normalizeTtrHistoryDate(event?.event_date_time || event?.formattedEventDate);
if (!sourceDate) {
continue;
}
const key = `${event?.event_id || 'event'}:${sourceDate}:${event?.ttr_after ?? ''}`;
if (seenKeys.has(key)) {
continue;
}
seenKeys.add(key);
rows.push({
memberId,
clubId,
playerId,
sourceDate,
ttr: Number.isFinite(Number(event?.ttr_after)) ? Number(event.ttr_after) : null,
qttr: null,
label: event?.event_name || null,
sourceType: event?.type || null,
fetchedAt,
rawPayload: event
});
}
return rows.sort((a, b) => String(b.sourceDate).localeCompare(String(a.sourceDate)));
}
_normalizeTtrHistoryDate(value) {
if (!value) {
return null;
}
if (typeof value === 'string') {
const isoMatch = value.match(/^(\d{4}-\d{2}-\d{2})/);
if (isoMatch?.[1]) {
return isoMatch[1];
}
const germanMatch = value.match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
if (germanMatch) {
return `${germanMatch[3]}-${germanMatch[2]}-${germanMatch[1]}`;
}
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return null;
}
return date.toISOString().slice(0, 10);
}
async rotateMemberImage(userToken, clubId, memberId, imageId, direction) {
try {
await checkAccess(userToken, clubId);
@@ -1335,4 +1788,4 @@ class MemberService {
}
}
export default new MemberService();
export default new MemberService();