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:
@@ -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: ';' }));
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user