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:
@@ -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}¤t-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¤t-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(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
}
|
||||
|
||||
export default new MyTischtennisClient();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
|
||||
79
backend/models/MemberTtrHistory.js
Normal file
79
backend/models/MemberTtrHistory.js
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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