feat: implement TTR preview functionality with caching and UI integration
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 58s
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 58s
This commit is contained in:
@@ -2,6 +2,29 @@ import MatchService from '../services/matchService.js';
|
||||
import fs from 'fs';
|
||||
import { emitScheduleMatchUpdated } from '../services/socketService.js';
|
||||
import { devLog } from '../utils/logger.js';
|
||||
import Match from '../models/Match.js';
|
||||
import Member from '../models/Member.js';
|
||||
import Club from '../models/Club.js';
|
||||
import { checkAccess } from '../utils/userUtils.js';
|
||||
import myTischtennisService from '../services/myTischtennisService.js';
|
||||
import myTischtennisClient from '../clients/myTischtennisClient.js';
|
||||
import { Op } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const normalizeTtrName = (value) => String(value || '')
|
||||
.normalize('NFKD').replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^\p{L}\p{N}]+/gu, ' ').trim().toLocaleLowerCase('de-DE');
|
||||
|
||||
const playerName = (player) => [player?.firstname || player?.firstName, player?.lastname || player?.lastName]
|
||||
.filter(Boolean).join(' ').trim();
|
||||
|
||||
const findClubForTeam = (clubs, teamName) => {
|
||||
const teamKey = normalizeTtrName(teamName);
|
||||
return clubs.find((club) => {
|
||||
const clubKey = normalizeTtrName(club.name);
|
||||
return clubKey && (teamKey === clubKey || teamKey.startsWith(`${clubKey} `) || clubKey.startsWith(`${teamKey} `));
|
||||
}) || null;
|
||||
};
|
||||
export const uploadCSV = async (req, res) => {
|
||||
try {
|
||||
const clubId = req.body?.clubId ?? req.query?.clubId;
|
||||
@@ -184,3 +207,90 @@ export const getMatchPlayers = async (req, res) => {
|
||||
return res.status(500).json({ error: 'Failed to retrieve match players' });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves the current TTR snapshots for a nuScore lineup. Local member data
|
||||
* is preferred. Missing opponent data is fetched from the club ranking when
|
||||
* the opposing club is also known in the database.
|
||||
*/
|
||||
export const getTtrPreviewPlayers = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const match = await Match.findByPk(req.params.matchId);
|
||||
if (!match) return res.status(404).json({ error: 'Spiel nicht gefunden' });
|
||||
await checkAccess(userToken, match.clubId);
|
||||
|
||||
// The product uses German match dates; do not roll the cache over at
|
||||
// UTC midnight while a late-evening match is still in progress.
|
||||
const snapshotDate = new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Berlin' }).format(new Date());
|
||||
const [cachedRows] = await sequelize.query(
|
||||
'SELECT payload FROM match_ttr_preview_cache WHERE match_id = :matchId AND snapshot_date = :snapshotDate LIMIT 1',
|
||||
{ replacements: { matchId: match.id, snapshotDate } }
|
||||
);
|
||||
if (cachedRows.length) {
|
||||
const payload = typeof cachedRows[0].payload === 'string' ? JSON.parse(cachedRows[0].payload) : cachedRows[0].payload;
|
||||
return res.json({ ...payload, cached: true, snapshotDate });
|
||||
}
|
||||
|
||||
const sides = ['home', 'guest'];
|
||||
const lineup = Object.fromEntries(sides.map((side) => [side, Array.isArray(req.body?.[`${side}Players`]) ? req.body[`${side}Players`] : []]));
|
||||
const allPlayers = sides.flatMap((side) => lineup[side].map((player) => ({ side, player })));
|
||||
const members = await Member.findAll({ where: { ttr: { [Op.ne]: null } } });
|
||||
const byExternalId = new Map();
|
||||
const byName = new Map();
|
||||
members.forEach((member) => {
|
||||
if (member.myTischtennisPlayerId) byExternalId.set(String(member.myTischtennisPlayerId), member);
|
||||
const key = normalizeTtrName(`${member.firstName} ${member.lastName}`);
|
||||
if (key && !byName.has(key)) byName.set(key, member);
|
||||
else if (key) byName.set(key, null);
|
||||
});
|
||||
|
||||
const resolved = new Map();
|
||||
for (const { side, player } of allPlayers) {
|
||||
const id = player?.nuLigaPersonId || player?.personId || player?.id;
|
||||
const member = (id && byExternalId.get(String(id))) || byName.get(normalizeTtrName(playerName(player)));
|
||||
if (member?.ttr != null) {
|
||||
resolved.set(`${side}:${normalizeTtrName(playerName(player))}`, {
|
||||
ttr: Number(member.ttr), adjustmentConstant: Number(member.ttrAdjustmentConstant || 16), source: 'database'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const clubs = await Club.findAll({ where: { associationMemberNumber: { [Op.ne]: null }, myTischtennisFedNickname: { [Op.ne]: null } } });
|
||||
let session = null;
|
||||
for (const side of sides) {
|
||||
const pending = lineup[side].filter((player) => !resolved.has(`${side}:${normalizeTtrName(playerName(player))}`));
|
||||
if (!pending.length) continue;
|
||||
const club = findClubForTeam(clubs, req.body?.[`${side}TeamName`]);
|
||||
if (!club) continue;
|
||||
try {
|
||||
session ||= await myTischtennisService.getSession(req.user.id);
|
||||
const rankings = await myTischtennisClient.getClubRankings(session.cookie, club.associationMemberNumber, club.myTischtennisFedNickname);
|
||||
if (!rankings.success) continue;
|
||||
const rankingByName = new Map(rankings.entries.map((entry) => [normalizeTtrName(`${entry.firstname} ${entry.lastname}`), entry]));
|
||||
pending.forEach((player) => {
|
||||
const entry = rankingByName.get(normalizeTtrName(playerName(player)));
|
||||
if (Number.isFinite(Number(entry?.fedRank))) {
|
||||
resolved.set(`${side}:${normalizeTtrName(playerName(player))}`, { ttr: Number(entry.fedRank), adjustmentConstant: 16, source: 'myTischtennis' });
|
||||
}
|
||||
});
|
||||
} catch (_error) {
|
||||
// Keep the player explicitly unresolved; the report remains usable.
|
||||
}
|
||||
}
|
||||
|
||||
const payload = { players: allPlayers.map(({ side, player }) => {
|
||||
const value = resolved.get(`${side}:${normalizeTtrName(playerName(player))}`);
|
||||
return { side, name: playerName(player), nuLigaPersonId: player?.nuLigaPersonId || null, ttr: value?.ttr ?? null, adjustmentConstant: value?.adjustmentConstant ?? null, source: value?.source || 'unavailable' };
|
||||
}) };
|
||||
await sequelize.query(
|
||||
`INSERT INTO match_ttr_preview_cache (match_id, snapshot_date, payload, created_at, updated_at)
|
||||
VALUES (:matchId, :snapshotDate, :payload, NOW(), NOW())`,
|
||||
{ replacements: { matchId: match.id, snapshotDate, payload: JSON.stringify(payload) } }
|
||||
);
|
||||
return res.json({ ...payload, cached: false, snapshotDate });
|
||||
} catch (error) {
|
||||
console.error('[getTtrPreviewPlayers]', error);
|
||||
return res.status(error.statusCode || 500).json({ error: error.message || 'TTR-Werte konnten nicht geladen werden' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE IF NOT EXISTS match_ttr_preview_cache (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
match_id INT NOT NULL,
|
||||
snapshot_date DATE NOT NULL,
|
||||
payload JSON NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uniq_match_ttr_preview_day (match_id, snapshot_date),
|
||||
CONSTRAINT fk_match_ttr_preview_match FOREIGN KEY (match_id) REFERENCES `match`(id) ON DELETE CASCADE
|
||||
);
|
||||
@@ -1,5 +1,5 @@
|
||||
import express from 'express';
|
||||
import { uploadCSV, createManualMatch, getLeaguesForCurrentSeason, getMatchesForLeagues, getMatchesForLeague, getLeagueTable, fetchLeagueTableFromMyTischtennis, updateMatchPlayers, getPlayerMatchStats, getMatchPlayers } from '../controllers/matchController.js';
|
||||
import { uploadCSV, createManualMatch, getLeaguesForCurrentSeason, getMatchesForLeagues, getMatchesForLeague, getLeagueTable, fetchLeagueTableFromMyTischtennis, updateMatchPlayers, getPlayerMatchStats, getMatchPlayers, getTtrPreviewPlayers } from '../controllers/matchController.js';
|
||||
import { authenticate } from '../middleware/authMiddleware.js';
|
||||
import { authorize } from '../middleware/authorizationMiddleware.js';
|
||||
import multer from 'multer';
|
||||
@@ -17,6 +17,7 @@ router.get('/leagues/:clubId/table/:leagueId', authenticate, authorize('schedule
|
||||
router.post('/leagues/:clubId/table/:leagueId/fetch', authenticate, authorize('mytischtennis', 'write'), fetchLeagueTableFromMyTischtennis);
|
||||
router.get('/:clubId/players', authenticate, authorize('schedule', 'read'), getMatchPlayers);
|
||||
router.patch('/:matchId/players', authenticate, authorize('schedule', 'write'), updateMatchPlayers);
|
||||
router.post('/:matchId/ttr-preview', authenticate, authorize('schedule', 'read'), getTtrPreviewPlayers);
|
||||
router.get('/leagues/:clubId/stats/:leagueId', authenticate, authorize('schedule', 'read'), getPlayerMatchStats);
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const migrations = [
|
||||
{
|
||||
id: '20261005_create_match_ttr_preview_cache',
|
||||
async up() {
|
||||
await sequelize.query(`
|
||||
CREATE TABLE IF NOT EXISTS match_ttr_preview_cache (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
match_id INT NOT NULL,
|
||||
snapshot_date DATE NOT NULL,
|
||||
payload JSON NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uniq_match_ttr_preview_day (match_id, snapshot_date),
|
||||
CONSTRAINT fk_match_ttr_preview_match FOREIGN KEY (match_id) REFERENCES \`match\`(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '20261005_add_hidden_test_user_flags',
|
||||
async up() {
|
||||
|
||||
Reference in New Issue
Block a user