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

@@ -869,7 +869,8 @@ class MyTischtennisClient {
* @param {string} fedNickname - Federation nickname (e.g., "HeTTV")
* @returns {Promise<Object>} Rankings with player entries (all pages)
*/
async getClubRankings(cookie, clubId, fedNickname, currentRanking = 'yes') {
async getClubRankings(cookie, clubId, fedNickname, currentRanking = 'yes', options = {}) {
const { includeHistoryPlayerIds = false } = options;
const allEntries = [];
let currentPage = 0;
let hasMorePages = true;
@@ -877,8 +878,6 @@ class MyTischtennisClient {
while (hasMorePages) {
const endpoint = `/rankings/andro-rangliste?all-players=on&clubnr=${clubId}&fednickname=${fedNickname}&current-ranking=${currentRanking}&results-per-page=100&page=${currentPage}&_data=routes%2F%24`;
const result = await this.authenticatedRequest(endpoint, cookie, {
method: 'GET'
});
@@ -917,15 +916,39 @@ class MyTischtennisClient {
error: 'Keine entries in blockLoaderData gefunden'
};
}
let historyPlayerIdsByName = null;
if (includeHistoryPlayerIds) {
const htmlEndpoint = `/rankings/andro-rangliste?clubnr=${clubId}&fednickname=${fedNickname}&all-players=on&continent=all&country=all&current-ranking=${currentRanking}&results-per-page=100&page=${currentPage + 1}`;
const htmlResult = await this.authenticatedRequest(htmlEndpoint, cookie, {
method: 'GET',
headers: {
Accept: 'text/html,application/xhtml+xml'
}
});
historyPlayerIdsByName = htmlResult.success
? this.extractHistoryPlayerIdsFromAndroRankingHtml(htmlResult.data)
: new Map();
}
const enrichedEntries = entries.map((entry) => {
const nameKey = this._buildRankingNameKey(entry?.firstname, entry?.lastname);
const historyPlayerId = historyPlayerIdsByName?.get(nameKey) || null;
return {
...entry,
historyPlayerId,
myTischtennisHistoryPlayerId: historyPlayerId
};
});
// Füge Entries hinzu
allEntries.push(...entries);
allEntries.push(...enrichedEntries);
// Prüfe ob es weitere Seiten gibt
// Wenn die aktuelle Seite weniger Einträge hat als das Limit, sind wir am Ende
// Oder wenn wir alle erwarteten Einträge haben
if (entries.length === 0) {
if (enrichedEntries.length === 0) {
hasMorePages = false;
} else if (rankingData.numberOfPages && currentPage >= rankingData.numberOfPages - 1) {
hasMorePages = false;
@@ -946,6 +969,45 @@ class MyTischtennisClient {
}
};
}
extractHistoryPlayerIdsFromAndroRankingHtml(html) {
const result = new Map();
const source = typeof html === 'string' ? html : String(html || '');
const anchorPattern = /href="\/community\/external-profile\?player-id=(P[A-Z0-9]+)"[^>]*>([^<]+)<\/a>/gi;
let match = null;
while ((match = anchorPattern.exec(source)) !== null) {
const playerId = match[1];
const fullName = this._decodeHtmlEntities(match[2] || '');
const key = this._buildRankingFullNameKey(fullName);
if (key && playerId && !result.has(key)) {
result.set(key, playerId);
}
}
return result;
}
_buildRankingNameKey(firstname, lastname) {
return this._buildRankingFullNameKey(`${firstname || ''} ${lastname || ''}`);
}
_buildRankingFullNameKey(name) {
return String(name || '')
.normalize('NFKC')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
}
_decodeHtmlEntities(value) {
return String(value || '')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>');
}
}
export default new MyTischtennisClient();

View File

@@ -93,6 +93,30 @@ const updateRatingsFromMyTischtennis = async (req, res) => {
}
};
const getMemberTtrHistory = async (req, res) => {
try {
const { clubId, memberId } = req.params;
const { authcode: userToken } = req.headers;
const result = await MemberService.getMemberTtrHistory(userToken, clubId, memberId);
res.status(result.status).json(result.response);
} catch (error) {
console.error('[getMemberTtrHistory] - Error:', error);
res.status(500).json({ success: false, error: 'TTR-Historie konnte nicht geladen werden.' });
}
};
const refreshMemberTtrHistory = async (req, res) => {
try {
const { clubId, memberId } = req.params;
const { authcode: userToken } = req.headers;
const result = await MemberService.refreshMemberTtrHistory(userToken, clubId, memberId);
res.status(result.status).json(result.response);
} catch (error) {
console.error('[refreshMemberTtrHistory] - Error:', error);
res.status(500).json({ success: false, error: 'TTR-Historie konnte nicht aktualisiert werden.' });
}
};
const rotateMemberImage = async (req, res) => {
try {
const { clubId, memberId, imageId } = req.params;
@@ -269,6 +293,8 @@ export {
uploadMemberImage,
getMemberImage,
updateRatingsFromMyTischtennis,
getMemberTtrHistory,
refreshMemberTtrHistory,
rotateMemberImage,
transferMembers,
quickUpdateTestMembership,

View File

@@ -0,0 +1,5 @@
-- Add my_tischtennis_history_player_id column
ALTER TABLE member
ADD COLUMN my_tischtennis_history_player_id VARCHAR(255) NULL COMMENT 'TTR history player ID from myTischtennis (e.g. P14EC4981D)';
CREATE INDEX idx_member_my_tischtennis_history_player_id ON member(my_tischtennis_history_player_id);

View File

@@ -166,6 +166,12 @@ const Member = sequelize.define('Member', {
allowNull: true,
comment: 'Player ID from myTischtennis (e.g. NU2705037)',
field: 'my_tischtennis_player_id'
},
myTischtennisHistoryPlayerId: {
type: DataTypes.STRING,
allowNull: true,
comment: 'TTR history player ID from myTischtennis (e.g. P14EC4981D)',
field: 'my_tischtennis_history_player_id'
}
}, {
underscored: true,

View File

@@ -0,0 +1,79 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const MemberTtrHistory = sequelize.define('MemberTtrHistory', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false
},
memberId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'member_id'
},
clubId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'club_id'
},
playerId: {
type: DataTypes.STRING,
allowNull: true,
field: 'player_id'
},
sourceDate: {
type: DataTypes.DATEONLY,
allowNull: false,
field: 'source_date'
},
ttr: {
type: DataTypes.INTEGER,
allowNull: true
},
qttr: {
type: DataTypes.INTEGER,
allowNull: true
},
label: {
type: DataTypes.STRING,
allowNull: true
},
sourceType: {
type: DataTypes.STRING,
allowNull: true,
field: 'source_type'
},
fetchedAt: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
field: 'fetched_at'
},
rawPayload: {
type: DataTypes.JSON,
allowNull: true,
field: 'raw_payload'
}
}, {
underscored: true,
tableName: 'member_ttr_history',
timestamps: true,
indexes: [
{
fields: ['member_id']
},
{
fields: ['club_id']
},
{
fields: ['player_id']
},
{
fields: ['source_date']
}
]
});
export default MemberTtrHistory;

View File

@@ -48,6 +48,7 @@ import ApiLog from './ApiLog.js';
import MemberTransferConfig from './MemberTransferConfig.js';
import MemberContact from './MemberContact.js';
import MemberImage from './MemberImage.js';
import MemberTtrHistory from './MemberTtrHistory.js';
import TrainingGroup from './TrainingGroup.js';
import MemberTrainingGroup from './MemberTrainingGroup.js';
import ClubDisabledPresetGroup from './ClubDisabledPresetGroup.js';
@@ -90,6 +91,9 @@ DiaryNote.belongsTo(Member, { foreignKey: 'memberId' });
Member.hasMany(MemberNote, { as: 'memberNotes', foreignKey: 'memberId' });
MemberNote.belongsTo(Member, { foreignKey: 'memberId' });
Member.hasMany(MemberTtrHistory, { as: 'ttrHistoryEntries', foreignKey: 'memberId' });
MemberTtrHistory.belongsTo(Member, { as: 'member', foreignKey: 'memberId' });
DiaryDate.hasMany(DiaryNote, { as: 'diaryNotes', foreignKey: 'diaryDateId' });
DiaryNote.belongsTo(DiaryDate, { foreignKey: 'diaryDateId' });
@@ -417,6 +421,7 @@ export {
MemberTransferConfig,
MemberContact,
MemberImage,
MemberTtrHistory,
TrainingGroup,
MemberTrainingGroup,
ClubDisabledPresetGroup,

View File

@@ -5,6 +5,8 @@ import {
uploadMemberImage,
getMemberImage,
updateRatingsFromMyTischtennis,
getMemberTtrHistory,
refreshMemberTtrHistory,
rotateMemberImage,
transferMembers,
quickUpdateTestMembership,
@@ -35,6 +37,8 @@ router.get('/gallery/:clubId', authenticate, authorize('members', 'read'), gener
router.post('/set/:id', authenticate, authorize('members', 'write'), setClubMembers);
router.get('/notapproved/:id', authenticate, authorize('members', 'read'), getWaitingApprovals);
router.post('/update-ratings/:id', authenticate, authorize('mytischtennis', 'write'), updateRatingsFromMyTischtennis);
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);
router.post('/transfer/:id', authenticate, authorize('members', 'write'), transferMembers);
router.post('/quick-update-test-membership/:clubId/:memberId', authenticate, authorize('members', 'write'), quickUpdateTestMembership);

View File

@@ -188,18 +188,6 @@ router.put('/submit/:uuid', async (req, res) => {
const { uuid } = req.params;
const reportData = req.body;
console.log('[nuscore submit] request', {
uuid,
wo: reportData?.wo ?? null,
isCompleted: reportData?.isCompleted ?? null,
homePin: reportData?.homePin ?? null,
guestPin: reportData?.guestPin ?? null,
releaseSignatureHome: reportData?.signature?.releaseSignatureHome ?? null,
releaseSignatureGuest: reportData?.signature?.releaseSignatureGuest ?? null,
lineupSignatureHome: reportData?.signature?.lineupSignatureHome ?? null,
lineupSignatureGuest: reportData?.signature?.lineupSignatureGuest ?? null
});
try {
// Hole Cookies für diese UUID (falls vorhanden)
// Versuche zuerst UUID, dann Code als Fallback
@@ -241,17 +229,6 @@ router.put('/submit/:uuid', async (req, res) => {
responseData = { message: responseText };
}
console.log('[nuscore submit] response', {
uuid,
httpStatus: response.status,
resultState: responseData?.resultState ?? null,
validationErrors: responseData?.validationErrors ?? [],
releaseSignatureHome: responseData?.object?.signature?.releaseSignatureHome ?? null,
releaseSignatureGuest: responseData?.object?.signature?.releaseSignatureGuest ?? null,
lineupSignatureHome: responseData?.object?.signature?.lineupSignatureHome ?? null,
lineupSignatureGuest: responseData?.object?.signature?.lineupSignatureGuest ?? null
});
const resultState = responseData?.resultState;
const validationErrors = Array.isArray(responseData?.validationErrors) ? responseData.validationErrors : [];
@@ -320,18 +297,6 @@ router.put('/validate/:uuid', async (req, res) => {
const { uuid } = req.params;
const reportData = req.body;
console.log('[nuscore validate] request', {
uuid,
wo: reportData?.wo ?? null,
isCompleted: reportData?.isCompleted ?? null,
homePin: reportData?.homePin ?? null,
guestPin: reportData?.guestPin ?? null,
releaseSignatureHome: reportData?.signature?.releaseSignatureHome ?? null,
releaseSignatureGuest: reportData?.signature?.releaseSignatureGuest ?? null,
lineupSignatureHome: reportData?.signature?.lineupSignatureHome ?? null,
lineupSignatureGuest: reportData?.signature?.lineupSignatureGuest ?? null
});
try {
// Hole Cookies für diese UUID (falls vorhanden)
// Versuche zuerst UUID, dann Code als Fallback
@@ -373,17 +338,6 @@ router.put('/validate/:uuid', async (req, res) => {
responseData = { message: responseText };
}
console.log('[nuscore validate] response', {
uuid,
httpStatus: response.status,
resultState: responseData?.resultState ?? null,
validationErrors: responseData?.validationErrors ?? [],
releaseSignatureHome: responseData?.object?.signature?.releaseSignatureHome ?? null,
releaseSignatureGuest: responseData?.object?.signature?.releaseSignatureGuest ?? null,
lineupSignatureHome: responseData?.object?.signature?.lineupSignatureHome ?? null,
lineupSignatureGuest: responseData?.object?.signature?.lineupSignatureGuest ?? null
});
// Speichere neue Cookies falls vorhanden
const newCookies = extractCookies(response.headers.raw()['set-cookie']);
if (Object.keys(newCookies).length > 0) {

View File

@@ -13,7 +13,7 @@ import {
DiaryNote, DiaryTag, MemberDiaryTag, DiaryDateTag, DiaryMemberNote, DiaryMemberTag,
PredefinedActivity, PredefinedActivityImage, DiaryDateActivity, DiaryMemberActivity, Match, League, Team, ClubTeam, TeamDocument, Group,
GroupActivity, Tournament, TournamentGroup, TournamentMatch, TournamentResult,
TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember, MyTischtennis, ClickTtAccount, MyTischtennisUpdateHistory, MyTischtennisFetchLog, ApiLog, MemberTransferConfig, MemberContact
TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember, MyTischtennis, ClickTtAccount, MyTischtennisUpdateHistory, MyTischtennisFetchLog, ApiLog, MemberTransferConfig, MemberContact, MemberTtrHistory
} from './models/index.js';
import authRoutes from './routes/authRoutes.js';
import clubRoutes from './routes/clubRoutes.js';
@@ -327,6 +327,7 @@ app.use((err, req, res, next) => {
await safeSync(ApiLog);
await safeSync(MemberTransferConfig);
await safeSync(MemberContact);
await safeSync(MemberTtrHistory);
await safeSync(ClubTeam);
await safeSync(TeamDocument);

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();