diff --git a/backend/controllers/matchController.js b/backend/controllers/matchController.js index 47cd4f08..ecdff700 100755 --- a/backend/controllers/matchController.js +++ b/backend/controllers/matchController.js @@ -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' }); + } +}; diff --git a/backend/migrations/20261005_create_match_ttr_preview_cache.sql b/backend/migrations/20261005_create_match_ttr_preview_cache.sql new file mode 100644 index 00000000..0d7e47c0 --- /dev/null +++ b/backend/migrations/20261005_create_match_ttr_preview_cache.sql @@ -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 +); diff --git a/backend/routes/matchRoutes.js b/backend/routes/matchRoutes.js index 027c9566..940e53cb 100755 --- a/backend/routes/matchRoutes.js +++ b/backend/routes/matchRoutes.js @@ -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); diff --git a/backend/scripts/runMigrations.js b/backend/scripts/runMigrations.js index 0bd27fb1..e8cbff2e 100644 --- a/backend/scripts/runMigrations.js +++ b/backend/scripts/runMigrations.js @@ -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() { diff --git a/frontend/src/components/MatchReportApiDialog.vue b/frontend/src/components/MatchReportApiDialog.vue index e5a8e037..a0487089 100755 --- a/frontend/src/components/MatchReportApiDialog.vue +++ b/frontend/src/components/MatchReportApiDialog.vue @@ -178,6 +178,11 @@ 🔒 + +