diff --git a/backend/clients/myTischtennisClient.js b/backend/clients/myTischtennisClient.js index bed32eca..7f346218 100644 --- a/backend/clients/myTischtennisClient.js +++ b/backend/clients/myTischtennisClient.js @@ -398,6 +398,17 @@ class MyTischtennisClient { if (await captchaHost.count()) { try { await page.waitForTimeout(1200); + const captchaVisualStateBefore = await page.evaluate(() => { + const host = document.querySelector('private-captcha'); + const checkbox = host?.shadowRoot?.querySelector('#pc-checkbox'); + return { + hostClass: host?.className || null, + hostDataState: host?.getAttribute?.('data-state') || null, + checkboxClass: checkbox?.className || null, + checkboxChecked: !!checkbox?.checked, + checkboxAriaChecked: checkbox?.getAttribute?.('aria-checked') || null + }; + }); const interaction = await page.evaluate(() => { const host = document.querySelector('private-captcha'); const checkbox = host?.shadowRoot?.querySelector('#pc-checkbox'); @@ -416,6 +427,58 @@ class MyTischtennisClient { }); console.log('[myTischtennisClient.playwright] evaluate interaction result:', interaction); + // Wait for a visual captcha state change in Shadow DOM (not only hidden fields). + try { + await page.waitForFunction((beforeState) => { + const host = document.querySelector('private-captcha'); + const checkbox = host?.shadowRoot?.querySelector('#pc-checkbox'); + if (!host || !checkbox) return false; + + const current = { + hostClass: host.className || '', + hostDataState: host.getAttribute?.('data-state') || '', + checkboxClass: checkbox.className || '', + checkboxChecked: !!checkbox.checked, + checkboxAriaChecked: checkbox.getAttribute?.('aria-checked') || '' + }; + + const visualChanged = + current.hostClass !== (beforeState?.hostClass || '') + || current.hostDataState !== (beforeState?.hostDataState || '') + || current.checkboxClass !== (beforeState?.checkboxClass || '') + || current.checkboxChecked !== !!beforeState?.checkboxChecked + || current.checkboxAriaChecked !== (beforeState?.checkboxAriaChecked || ''); + + const positiveSignals = [ + current.hostClass, + current.hostDataState, + current.checkboxClass, + String(current.checkboxAriaChecked) + ].join(' ').toLowerCase(); + + return visualChanged && ( + current.checkboxChecked + || current.checkboxAriaChecked === 'true' + || /success|solved|verified|checked|active|complete|done/.test(positiveSignals) + ); + }, captchaVisualStateBefore, { timeout: 30000 }); + + const captchaVisualStateAfter = await page.evaluate(() => { + const host = document.querySelector('private-captcha'); + const checkbox = host?.shadowRoot?.querySelector('#pc-checkbox'); + return { + hostClass: host?.className || null, + hostDataState: host?.getAttribute?.('data-state') || null, + checkboxClass: checkbox?.className || null, + checkboxChecked: !!checkbox?.checked, + checkboxAriaChecked: checkbox?.getAttribute?.('aria-checked') || null + }; + }); + console.log('[myTischtennisClient.playwright] Captcha visual state changed:', captchaVisualStateAfter); + } catch (_visualWaitErr) { + console.warn('[myTischtennisClient.playwright] Captcha visual state did not change in time'); + } + // Wait until hidden captcha fields are populated by site scripts. try { await page.waitForFunction(() => { @@ -424,7 +487,7 @@ class MyTischtennisClient { const captchaValue = (captchaField && captchaField.value ? captchaField.value.trim() : ''); const clickedValue = (clickedField && clickedField.value ? clickedField.value.toLowerCase() : ''); return captchaValue.length > 80 && (clickedValue === 'true' || clickedValue === '1'); - }, { timeout: 15000 }); + }, { timeout: 30000 }); const captchaState = await page.evaluate(() => { const captchaField = document.querySelector('input[name="captcha"]'); const clickedField = document.querySelector('input[name="captcha_clicked"]'); diff --git a/backend/controllers/myTischtennisController.js b/backend/controllers/myTischtennisController.js index 285affdb..665b04a0 100644 --- a/backend/controllers/myTischtennisController.js +++ b/backend/controllers/myTischtennisController.js @@ -1,53 +1,10 @@ import myTischtennisService from '../services/myTischtennisService.js'; +import myTischtennisSessionService from '../services/myTischtennisSessionService.js'; +import myTischtennisProxyService from '../services/myTischtennisProxyService.js'; import HttpError from '../exceptions/HttpError.js'; import axios from 'axios'; import myTischtennisClient from '../clients/myTischtennisClient.js'; -const MYTT_ORIGIN = 'https://www.mytischtennis.de'; -const MYTT_PROXY_PREFIX = '/api/mytischtennis/proxy'; - -function rewriteMytischtennisContent(content) { - if (typeof content !== 'string' || !content) { - return content; - } - - let rewritten = content; - - // Root-relative Build/Fonts über unseren Same-Origin-Proxy laden. - rewritten = rewritten.replace( - /(["'])\/build\//g, - `$1${MYTT_PROXY_PREFIX}/build/` - ); - rewritten = rewritten.replace( - /(["'])\/fonts\//g, - `$1${MYTT_PROXY_PREFIX}/fonts/` - ); - - // Absolute Build/Fonts-URLs ebenfalls auf den Proxy biegen. - rewritten = rewritten.replace( - /https:\/\/www\.mytischtennis\.de\/build\//g, - `${MYTT_PROXY_PREFIX}/build/` - ); - rewritten = rewritten.replace( - /https:\/\/www\.mytischtennis\.de\/fonts\//g, - `${MYTT_PROXY_PREFIX}/fonts/` - ); - - // CSS url(/fonts/...) Fälle. - rewritten = rewritten.replace( - /url\((["']?)\/fonts\//g, - `url($1${MYTT_PROXY_PREFIX}/fonts/` - ); - - // Captcha-Endpunkt muss ebenfalls same-origin über Proxy erreichbar sein. - rewritten = rewritten.replace( - /(["'])\/api\/private-captcha/g, - `$1${MYTT_PROXY_PREFIX}/api/private-captcha` - ); - - return rewritten; -} - class MyTischtennisController { /** * GET /api/mytischtennis/account @@ -317,7 +274,7 @@ class MyTischtennisController { req.userId = userId; // Lade die Login-Seite von mytischtennis.de - const response = await axios.get(`${MYTT_ORIGIN}/login?next=%2F`, { + const response = await axios.get(`${myTischtennisProxyService.getOrigin()}/login?next=%2F`, { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', @@ -353,7 +310,7 @@ class MyTischtennisController { /action="\/login/g, 'action="/api/mytischtennis/login-submit' ); - html = rewriteMytischtennisContent(html); + html = myTischtennisProxyService.rewriteContent(html); // MyTischtennis bootet eine große React-App, die im Proxy-Kontext häufig mit // Runtime-Fehlern abstürzt ("Da ist etwas schiefgelaufen"). Für den iframe-Login @@ -382,7 +339,7 @@ class MyTischtennisController { try { const proxyPath = req.params[0] || ''; const queryString = new URLSearchParams(req.query || {}).toString(); - const targetUrl = `${MYTT_ORIGIN}/${proxyPath}${queryString ? `?${queryString}` : ''}`; + const targetUrl = `${myTischtennisProxyService.getOrigin()}/${proxyPath}${queryString ? `?${queryString}` : ''}`; const upstream = await axios.get(targetUrl, { responseType: 'arraybuffer', @@ -412,7 +369,7 @@ class MyTischtennisController { if (isTextLike) { const asText = Buffer.from(upstream.data).toString('utf-8'); - const rewritten = rewriteMytischtennisContent(asText); + const rewritten = myTischtennisProxyService.rewriteContent(asText); return res.status(upstream.status).send(rewritten); } @@ -555,7 +512,7 @@ class MyTischtennisController { const browserLogin = await myTischtennisClient.loginWithBrowserAutomation(payload.email, payload.password); if (browserLogin.success && browserLogin.cookie) { - await this.saveSessionFromCookie(userId, browserLogin.cookie); + await myTischtennisSessionService.saveSessionFromCookie(userId, browserLogin.cookie); return res.status(200).send( '
Login erfolgreich. Fenster kann geschlossen werden.
' ); @@ -582,7 +539,7 @@ class MyTischtennisController { const authCookie = setCookieHeaders?.find(cookie => cookie.startsWith('sb-10-auth-token=')); if (authCookie && userId) { // Login erfolgreich - speichere Session (nur wenn userId vorhanden) - await this.saveSessionFromCookie(userId, authCookie); + await myTischtennisSessionService.saveSessionFromCookie(userId, authCookie); } // Sende Response weiter @@ -593,49 +550,6 @@ class MyTischtennisController { } } - /** - * Speichere Session-Daten aus Cookie - */ - async saveSessionFromCookie(userId, cookieString) { - try { - const tokenMatch = cookieString.match(/sb-10-auth-token=base64-([^;]+)/); - if (!tokenMatch) { - throw new Error('Token-Format ungültig'); - } - - const base64Token = tokenMatch[1]; - const decodedToken = Buffer.from(base64Token, 'base64').toString('utf-8'); - const tokenData = JSON.parse(decodedToken); - - const MyTischtennis = (await import('../models/MyTischtennis.js')).default; - const myTischtennisAccount = await MyTischtennis.findOne({ where: { userId } }); - - if (myTischtennisAccount) { - myTischtennisAccount.accessToken = tokenData.access_token; - myTischtennisAccount.refreshToken = tokenData.refresh_token; - myTischtennisAccount.expiresAt = tokenData.expires_at; - myTischtennisAccount.cookie = cookieString.split(';')[0].trim(); - myTischtennisAccount.userData = tokenData.user; - myTischtennisAccount.lastLoginSuccess = new Date(); - myTischtennisAccount.lastLoginAttempt = new Date(); - - // Hole Club-Informationen - const myTischtennisClient = (await import('../clients/myTischtennisClient.js')).default; - const profileResult = await myTischtennisClient.getUserProfile(myTischtennisAccount.cookie); - if (profileResult.success) { - myTischtennisAccount.clubId = profileResult.clubId; - myTischtennisAccount.clubName = profileResult.clubName; - myTischtennisAccount.fedNickname = profileResult.fedNickname; - } - - await myTischtennisAccount.save(); - } - } catch (error) { - console.error('Fehler beim Speichern der Session:', error); - throw error; - } - } - /** * POST /api/mytischtennis/extract-session * Extrahiere Session nach Login im iframe diff --git a/backend/controllers/officialTournamentController.js b/backend/controllers/officialTournamentController.js index 62992bd7..bee2d1ee 100644 --- a/backend/controllers/officialTournamentController.js +++ b/backend/controllers/officialTournamentController.js @@ -1,16 +1,5 @@ -import { createRequire } from 'module'; -const require = createRequire(import.meta.url); -const pdfParse = require('pdf-parse/lib/pdf-parse.js'); import { checkAccess } from '../utils/userUtils.js'; -import OfficialTournament from '../models/OfficialTournament.js'; -import OfficialCompetition from '../models/OfficialCompetition.js'; -import OfficialCompetitionMember from '../models/OfficialCompetitionMember.js'; -import Member from '../models/Member.js'; -import { Op } from 'sequelize'; - -// In-Memory Store (einfacher Start); später DB-Modell -const parsedTournaments = new Map(); // key: id, value: { id, clubId, rawText, parsedData } -let seq = 1; +import officialTournamentService from '../services/officialTournamentService.js'; export const uploadTournamentPdf = async (req, res) => { try { @@ -18,45 +7,9 @@ export const uploadTournamentPdf = async (req, res) => { const { clubId } = req.params; await checkAccess(userToken, clubId); if (!req.file || !req.file.buffer) return res.status(400).json({ error: 'No pdf provided' }); - const data = await pdfParse(req.file.buffer); - const parsed = parseTournamentText(data.text); - const t = await OfficialTournament.create({ - clubId, - title: parsed.title || null, - eventDate: parsed.termin || null, - organizer: null, - host: null, - venues: JSON.stringify(parsed.austragungsorte || []), - competitionTypes: JSON.stringify(parsed.konkurrenztypen || []), - registrationDeadlines: JSON.stringify(parsed.meldeschluesse || []), - entryFees: JSON.stringify(parsed.entryFees || {}), - }); - // competitions persistieren - for (const c of parsed.competitions || []) { - // Korrigiere Fehlzuordnung: Wenn die Zeile mit "Stichtag" fälschlich in performanceClass steht - let performanceClass = c.leistungsklasse || c.performanceClass || null; - let cutoffDate = c.stichtag || c.cutoffDate || null; - if (performanceClass && /^stichtag\b/i.test(performanceClass)) { - cutoffDate = performanceClass.replace(/^stichtag\s*:?\s*/i, '').trim(); - performanceClass = null; - } - await OfficialCompetition.create({ - tournamentId: t.id, - ageClassCompetition: c.altersklasseWettbewerb || c.ageClassCompetition || null, - performanceClass, - startTime: c.startzeit || c.startTime || null, - registrationDeadlineDate: c.meldeschlussDatum || c.registrationDeadlineDate || null, - registrationDeadlineOnline: c.meldeschlussOnline || c.registrationDeadlineOnline || null, - cutoffDate, - ttrRelevant: c.ttrRelevant || null, - openTo: c.offenFuer || c.openTo || null, - preliminaryRound: c.vorrunde || c.preliminaryRound || null, - finalRound: c.endrunde || c.finalRound || null, - maxParticipants: c.maxTeilnehmer || c.maxParticipants || null, - entryFee: c.startgeld || c.entryFee || null, - }); - } - res.status(201).json({ id: String(t.id) }); + + const result = await officialTournamentService.uploadTournamentPdf(clubId, req.file.buffer); + res.status(201).json(result); } catch (e) { console.error('[uploadTournamentPdf] Error:', e); res.status(500).json({ error: 'Failed to parse pdf' }); @@ -68,64 +21,10 @@ export const getParsedTournament = async (req, res) => { const { authcode: userToken } = req.headers; const { clubId, id } = req.params; await checkAccess(userToken, clubId); - const t = await OfficialTournament.findOne({ where: { id, clubId } }); - if (!t) return res.status(404).json({ error: 'not found' }); - const comps = await OfficialCompetition.findAll({ where: { tournamentId: id } }); - const entries = await OfficialCompetitionMember.findAll({ where: { tournamentId: id } }); - const competitions = comps.map((c) => { - const j = c.toJSON(); - return { - id: j.id, - tournamentId: j.tournamentId, - ageClassCompetition: j.ageClassCompetition || null, - performanceClass: j.performanceClass || null, - startTime: j.startTime || null, - registrationDeadlineDate: j.registrationDeadlineDate || null, - registrationDeadlineOnline: j.registrationDeadlineOnline || null, - cutoffDate: j.cutoffDate || null, - ttrRelevant: j.ttrRelevant || null, - openTo: j.openTo || null, - preliminaryRound: j.preliminaryRound || null, - finalRound: j.finalRound || null, - maxParticipants: j.maxParticipants || null, - entryFee: j.entryFee || null, - // Legacy Felder zusätzlich, falls Frontend sie noch nutzt - altersklasseWettbewerb: j.ageClassCompetition || null, - leistungsklasse: j.performanceClass || null, - startzeit: j.startTime || null, - meldeschlussDatum: j.registrationDeadlineDate || null, - meldeschlussOnline: j.registrationDeadlineOnline || null, - stichtag: j.cutoffDate || null, - offenFuer: j.openTo || null, - vorrunde: j.preliminaryRound || null, - endrunde: j.finalRound || null, - maxTeilnehmer: j.maxParticipants || null, - startgeld: j.entryFee || null, - }; - }); - res.status(200).json({ - id: String(t.id), - clubId: String(t.clubId), - parsedData: { - title: t.title, - termin: t.eventDate, - austragungsorte: JSON.parse(t.venues || '[]'), - konkurrenztypen: JSON.parse(t.competitionTypes || '[]'), - meldeschluesse: JSON.parse(t.registrationDeadlines || '[]'), - entryFees: JSON.parse(t.entryFees || '{}'), - competitions, - }, - participation: entries.map(e => ({ - id: e.id, - tournamentId: e.tournamentId, - competitionId: e.competitionId, - memberId: e.memberId, - wants: !!e.wants, - registered: !!e.registered, - participated: !!e.participated, - placement: e.placement || null, - })), - }); + + const result = await officialTournamentService.getParsedTournament(clubId, id); + if (!result) return res.status(404).json({ error: 'not found' }); + res.status(200).json(result); } catch (e) { res.status(500).json({ error: 'Failed to fetch parsed tournament' }); } @@ -134,30 +33,14 @@ export const getParsedTournament = async (req, res) => { export const upsertCompetitionMember = async (req, res) => { try { const { authcode: userToken } = req.headers; - const { clubId, id } = req.params; // id = tournamentId + const { clubId, id } = req.params; await checkAccess(userToken, clubId); - const { competitionId, memberId, wants, registered, participated, placement } = req.body; - if (!competitionId || !memberId) return res.status(400).json({ error: 'competitionId and memberId required' }); - const [row] = await OfficialCompetitionMember.findOrCreate({ - where: { competitionId, memberId }, - defaults: { - tournamentId: id, - competitionId, - memberId, - wants: !!wants, - registered: !!registered, - participated: !!participated, - placement: placement || null, - } - }); - row.wants = wants !== undefined ? !!wants : row.wants; - row.registered = registered !== undefined ? !!registered : row.registered; - row.participated = participated !== undefined ? !!participated : row.participated; - if (placement !== undefined) row.placement = placement; - await row.save(); - return res.status(200).json({ success: true, id: row.id }); + + const result = await officialTournamentService.upsertCompetitionMember(id, req.body); + return res.status(200).json(result); } catch (e) { console.error('[upsertCompetitionMember] Error:', e); + if (e?.status) return res.status(e.status).json({ error: e.message }); res.status(500).json({ error: 'Failed to save participation' }); } }; @@ -165,64 +48,14 @@ export const upsertCompetitionMember = async (req, res) => { export const updateParticipantStatus = async (req, res) => { try { const { authcode: userToken } = req.headers; - const { clubId, id } = req.params; // id = tournamentId + const { clubId, id } = req.params; await checkAccess(userToken, clubId); - const { competitionId, memberId, action } = req.body; - - if (!competitionId || !memberId || !action) { - return res.status(400).json({ error: 'competitionId, memberId and action required' }); - } - const [row] = await OfficialCompetitionMember.findOrCreate({ - where: { competitionId, memberId }, - defaults: { - tournamentId: id, - competitionId, - memberId, - wants: false, - registered: false, - participated: false, - placement: null, - } - }); - - // Status-Update basierend auf Aktion - switch (action) { - case 'register': - // Von "möchte teilnehmen" zu "angemeldet" - row.wants = true; - row.registered = true; - row.participated = false; - break; - case 'participate': - // Von "angemeldet" zu "hat gespielt" - row.wants = true; - row.registered = true; - row.participated = true; - break; - case 'reset': - // Zurück zu "möchte teilnehmen" - row.wants = true; - row.registered = false; - row.participated = false; - break; - default: - return res.status(400).json({ error: 'Invalid action. Use: register, participate, or reset' }); - } - - await row.save(); - return res.status(200).json({ - success: true, - id: row.id, - status: { - wants: row.wants, - registered: row.registered, - participated: row.participated, - placement: row.placement - } - }); + const result = await officialTournamentService.updateParticipantStatus(id, req.body); + return res.status(200).json(result); } catch (e) { console.error('[updateParticipantStatus] Error:', e); + if (e?.status) return res.status(e.status).json({ error: e.message }); res.status(500).json({ error: 'Failed to update participant status' }); } }; @@ -232,8 +65,9 @@ export const listOfficialTournaments = async (req, res) => { const { authcode: userToken } = req.headers; const { clubId } = req.params; await checkAccess(userToken, clubId); - const list = await OfficialTournament.findAll({ where: { clubId } }); - res.status(200).json(Array.isArray(list) ? list : []); + + const list = await officialTournamentService.listOfficialTournaments(clubId); + res.status(200).json(list); } catch (e) { console.error('[listOfficialTournaments] Error:', e); const errorMessage = e.message || 'Failed to list tournaments'; @@ -246,99 +80,8 @@ export const listClubParticipations = async (req, res) => { const { authcode: userToken } = req.headers; const { clubId } = req.params; await checkAccess(userToken, clubId); - const tournaments = await OfficialTournament.findAll({ where: { clubId } }); - if (!tournaments || tournaments.length === 0) return res.status(200).json([]); - const tournamentIds = tournaments.map(t => t.id); - - const rows = await OfficialCompetitionMember.findAll({ - where: { tournamentId: { [Op.in]: tournamentIds }, participated: true }, - include: [ - { model: OfficialCompetition, as: 'competition', attributes: ['id', 'tournamentId', 'ageClassCompetition', 'startTime'] }, - { model: OfficialTournament, as: 'tournament', attributes: ['id', 'title', 'eventDate'] }, - { model: Member, as: 'member', attributes: ['id', 'firstName', 'lastName'] }, - ] - }); - - const parseDmy = (s) => { - if (!s) return null; - const m = String(s).match(/(\d{1,2})\.(\d{1,2})\.(\d{4})/); - if (!m) return null; - const d = new Date(Number(m[3]), Number(m[2]) - 1, Number(m[1])); - return isNaN(d.getTime()) ? null : d; - }; - const fmtDmy = (d) => { - const dd = String(d.getDate()).padStart(2, '0'); - const mm = String(d.getMonth() + 1).padStart(2, '0'); - const yyyy = d.getFullYear(); - return `${dd}.${mm}.${yyyy}`; - }; - - const byTournament = new Map(); - for (const r of rows) { - const t = r.tournament; - const c = r.competition; - const m = r.member; - if (!t || !c || !m) continue; - if (!byTournament.has(t.id)) { - byTournament.set(t.id, { - tournamentId: String(t.id), - title: t.title || null, - startDate: null, - endDate: null, - entries: [], - _dates: [], - _eventDate: t.eventDate || null, - }); - } - const bucket = byTournament.get(t.id); - const compDate = parseDmy(c.startTime || '') || null; - if (compDate) bucket._dates.push(compDate); - bucket.entries.push({ - memberId: m.id, - memberName: `${m.firstName || ''} ${m.lastName || ''}`.trim(), - competitionId: c.id, - competitionName: c.ageClassCompetition || '', - placement: r.placement || null, - date: compDate ? fmtDmy(compDate) : null, - }); - } - - const out = []; - for (const t of tournaments) { - const bucket = byTournament.get(t.id) || { - tournamentId: String(t.id), - title: t.title || null, - startDate: null, - endDate: null, - entries: [], - _dates: [], - _eventDate: t.eventDate || null, - }; - // Ableiten Start/Ende - if (bucket._dates.length) { - bucket._dates.sort((a, b) => a - b); - bucket.startDate = fmtDmy(bucket._dates[0]); - bucket.endDate = fmtDmy(bucket._dates[bucket._dates.length - 1]); - } else if (bucket._eventDate) { - const all = String(bucket._eventDate).match(/(\d{1,2}\.\d{1,2}\.\d{4})/g) || []; - if (all.length >= 1) { - const d1 = parseDmy(all[0]); - const d2 = all.length >= 2 ? parseDmy(all[1]) : d1; - if (d1) bucket.startDate = fmtDmy(d1); - if (d2) bucket.endDate = fmtDmy(d2); - } - } - // Sort entries: Mitglied, dann Konkurrenz - bucket.entries.sort((a, b) => { - const mcmp = (a.memberName || '').localeCompare(b.memberName || '', 'de', { sensitivity: 'base' }); - if (mcmp !== 0) return mcmp; - return (a.competitionName || '').localeCompare(b.competitionName || '', 'de', { sensitivity: 'base' }); - }); - delete bucket._dates; - delete bucket._eventDate; - out.push(bucket); - } + const out = await officialTournamentService.listClubParticipations(clubId); res.status(200).json(out); } catch (e) { res.status(500).json({ error: 'Failed to list club participations' }); @@ -350,272 +93,11 @@ export const deleteOfficialTournament = async (req, res) => { const { authcode: userToken } = req.headers; const { clubId, id } = req.params; await checkAccess(userToken, clubId); - const t = await OfficialTournament.findOne({ where: { id, clubId } }); - if (!t) return res.status(404).json({ error: 'not found' }); - await OfficialCompetition.destroy({ where: { tournamentId: id } }); - await OfficialTournament.destroy({ where: { id } }); + + const deleted = await officialTournamentService.deleteOfficialTournament(clubId, id); + if (!deleted) return res.status(404).json({ error: 'not found' }); res.status(204).send(); } catch (e) { res.status(500).json({ error: 'Failed to delete tournament' }); } }; - -function parseTournamentText(text) { - const lines = text.split(/\r?\n/); - const normLines = lines.map(l => l.replace(/\s+/g, ' ').trim()); - - const findTitle = () => { - const idx = normLines.findIndex(l => /Kreiseinzelmeisterschaften/i.test(l)); - return idx >= 0 ? normLines[idx] : null; - }; - - // Neue Funktion: Teilnahmegebühren pro Spielklasse extrahieren - const extractEntryFees = () => { - const entryFees = {}; - - // Verschiedene Patterns für Teilnahmegebühren suchen - const feePatterns = [ - // Pattern 1: "Startgeld: U12: 5€, U14: 7€, U16: 10€" - /startgeld\s*:?\s*(.+)/i, - // Pattern 2: "Teilnahmegebühr: U12: 5€, U14: 7€" - /teilnahmegebühr\s*:?\s*(.+)/i, - // Pattern 3: "Gebühr: U12: 5€, U14: 7€" - /gebühr\s*:?\s*(.+)/i, - // Pattern 4: "Einschreibegebühr: U12: 5€, U14: 7€" - /einschreibegebühr\s*:?\s*(.+)/i, - // Pattern 5: "Anmeldegebühr: U12: 5€, U14: 7€" - /anmeldegebühr\s*:?\s*(.+)/i - ]; - - for (const pattern of feePatterns) { - for (let i = 0; i < normLines.length; i++) { - const line = normLines[i]; - const match = line.match(pattern); - if (match) { - const feeText = match[1]; - - // Extrahiere Gebühren aus dem Text - // Unterstützt verschiedene Formate: - // "U12: 5€, U14: 7€, U16: 10€" - // "U12: 5 Euro, U14: 7 Euro" - // "U12 5€, U14 7€" - // "U12: 5,00€, U14: 7,00€" - const feeMatches = feeText.matchAll(/(U\d+|AK\s*\d+)\s*:?\s*(\d+(?:[,.]\d+)?)\s*(?:€|Euro|EUR)?/gi); - - for (const feeMatch of feeMatches) { - const ageClass = feeMatch[1].toUpperCase().replace(/\s+/g, ''); - const amount = feeMatch[2].replace(',', '.'); - const numericAmount = parseFloat(amount); - - if (!isNaN(numericAmount)) { - entryFees[ageClass] = { - amount: numericAmount, - currency: '€', - rawText: feeMatch[0] - }; - } - } - - // Wenn wir Gebühren gefunden haben, brechen wir ab - if (Object.keys(entryFees).length > 0) { - break; - } - } - } - if (Object.keys(entryFees).length > 0) { - break; - } - } - - return entryFees; - }; - - const extractBlockAfter = (labels, multiline = false) => { - const idx = normLines.findIndex(l => labels.some(lb => l.toLowerCase().startsWith(lb))); - if (idx === -1) return multiline ? [] : null; - const line = normLines[idx]; - const afterColon = line.includes(':') ? line.split(':').slice(1).join(':').trim() : ''; - if (!multiline) { - if (afterColon) return afterColon; - // sonst nächste nicht-leere Zeile - for (let i = idx + 1; i < normLines.length; i++) { - if (normLines[i]) return normLines[i]; - } - return null; - } - // multiline bis zur nächsten Leerzeile oder nächsten bekannten Section - const out = []; - if (afterColon) out.push(afterColon); - for (let i = idx + 1; i < normLines.length; i++) { - const ln = normLines[i]; - if (!ln) break; - if (/^(termin|austragungsort|austragungsorte|konkurrenz|konkurrenzen|konkurrenztypen|meldeschluss|altersklassen|startzeiten)/i.test(ln)) break; - out.push(ln); - } - return out; - }; - - const extractAllMatches = (regex) => { - const results = []; - for (const l of normLines) { - const m = l.match(regex); - if (m) results.push(m); - } - return results; - }; - - const title = findTitle(); - const termin = extractBlockAfter(['termin', 'termin '], false); - const austragungsorte = extractBlockAfter(['austragungsort', 'austragungsorte'], true); - let konkurrenzRaw = extractBlockAfter(['konkurrenz', 'konkurrenzen', 'konkurrenztypen'], true); - if (konkurrenzRaw && !Array.isArray(konkurrenzRaw)) konkurrenzRaw = [konkurrenzRaw]; - const konkurrenztypen = (konkurrenzRaw || []).flatMap(l => l.split(/[;,]/)).map(s => s.trim()).filter(Boolean); - - // Meldeschlüsse mit Position und Zuordnung zu AK ermitteln - const meldeschluesseRaw = []; - for (let i = 0; i < normLines.length; i++) { - const l = normLines[i]; - const m = l.match(/meldeschluss\s*:?\s*(.+)$/i); - if (m) meldeschluesseRaw.push({ line: i, value: m[1].trim() }); - } - - let altersRaw = extractBlockAfter(['altersklassen', 'altersklasse'], true); - if (altersRaw && !Array.isArray(altersRaw)) altersRaw = [altersRaw]; - const altersklassen = (altersRaw || []).flatMap(l => l.split(/[;,]/)).map(s => s.trim()).filter(Boolean); - - // Wettbewerbe/Konkurrenzen parsen (Block ab "3. Konkurrenzen") - const competitions = []; - const konkIdx = normLines.findIndex(l => /^\s*3\.?\s+Konkurrenzen/i.test(l) || /^Konkurrenzen\b/i.test(l)); - // Bestimme Start-Sektionsnummer (z. B. 3 bei "3. Konkurrenzen"), fallback 3 - const startSectionNum = (() => { - if (konkIdx === -1) return 3; - const m = normLines[konkIdx].match(/^\s*(\d+)\./); - return m ? parseInt(m[1], 10) : 3; - })(); - const nextSectionIdx = () => { - for (let i = konkIdx + 1; i < normLines.length; i++) { - const m = normLines[i].match(/^\s*(\d+)\.\s+/); - if (m) { - const num = parseInt(m[1], 10); - if (!Number.isNaN(num) && num > startSectionNum) return i; - } - // Hinweis: Seitenfußzeilen wie "nu.Dokument ..." ignorieren wir, damit mehrseitige Blöcke nicht abbrechen - } - return normLines.length; - }; - if (konkIdx !== -1) { - const endIdx = nextSectionIdx(); - let i = konkIdx + 1; - while (i < endIdx) { - const line = normLines[i]; - if (/^Altersklasse\/Wettbewerb\s*:/i.test(line)) { - const comp = {}; - comp.altersklasseWettbewerb = line.split(':').slice(1).join(':').trim(); - i++; - while (i < endIdx && !/^Altersklasse\/Wettbewerb\s*:/i.test(normLines[i])) { - const ln = normLines[i]; - const m = ln.match(/^([^:]+):\s*(.*)$/); - if (m) { - const key = m[1].trim().toLowerCase(); - const val = m[2].trim(); - if (key.startsWith('leistungsklasse')) comp.leistungsklasse = val; - else if (key === 'startzeit') { - // Erwartet: 20.09.2025 13:30 Uhr -> wir extrahieren Datum+Zeit - const sm = val.match(/(\d{2}\.\d{2}\.\d{4})\s+(\d{1,2}:\d{2})/); - comp.startzeit = sm ? `${sm[1]} ${sm[2]}` : val; - } - else if (key.startsWith('meldeschluss datum')) comp.meldeschlussDatum = val; - else if (key.startsWith('meldeschluss online')) comp.meldeschlussOnline = val; - else if (key === 'stichtag') comp.stichtag = val; - else if (key === 'ttr-relevant') comp.ttrRelevant = val; - else if (key === 'offen für') comp.offenFuer = val; - else if (key.startsWith('austragungssys. vorrunde')) comp.vorrunde = val; - else if (key.startsWith('austragungssys. endrunde')) comp.endrunde = val; - else if (key.startsWith('max. teilnehmerzahl')) comp.maxTeilnehmer = val; - else if (key === 'startgeld') { - comp.startgeld = val; - // Versuche auch spezifische Gebühren für diese Altersklasse zu extrahieren - const ageClassMatch = comp.altersklasseWettbewerb?.match(/(U\d+|AK\s*\d+)/i); - if (ageClassMatch) { - const ageClass = ageClassMatch[1].toUpperCase().replace(/\s+/g, ''); - const feeMatch = val.match(/(\d+(?:[,.]\d+)?)\s*(?:€|Euro|EUR)?/); - if (feeMatch) { - const amount = feeMatch[1].replace(',', '.'); - const numericAmount = parseFloat(amount); - if (!isNaN(numericAmount)) { - comp.entryFeeDetails = { - amount: numericAmount, - currency: '€', - ageClass: ageClass - }; - } - } - } - } - } - i++; - } - competitions.push(comp); - continue; // schon auf nächster Zeile - } - i++; - } - } - - // Altersklassen-Positionen im Text (zur Zuordnung von Meldeschlüssen) - const akPositions = []; - for (let i = 0; i < normLines.length; i++) { - const l = normLines[i]; - const m = l.match(/\b(U\d+|AK\s*\d+)\b/i); - if (m) akPositions.push({ line: i, ak: m[1].toUpperCase().replace(/\s+/g, '') }); - } - - const meldeschluesseByAk = {}; - for (const ms of meldeschluesseRaw) { - // Nächste AK im Umkreis von 3 Zeilen suchen - let best = null; - let bestDist = Infinity; - for (const ak of akPositions) { - const dist = Math.abs(ak.line - ms.line); - if (dist < bestDist && dist <= 3) { best = ak; bestDist = dist; } - } - if (best) { - if (!meldeschluesseByAk[best.ak]) meldeschluesseByAk[best.ak] = new Set(); - meldeschluesseByAk[best.ak].add(ms.value); - } - } - - // Dedup global - const meldeschluesse = Array.from(new Set(meldeschluesseRaw.map(x => x.value))); - // Sets zu Arrays - const meldeschluesseByAkOut = Object.fromEntries(Object.entries(meldeschluesseByAk).map(([k,v]) => [k, Array.from(v)])); - - // Vorhandene einfache Personenerkennung (optional, zu Analysezwecken) - const entries = []; - for (const l of normLines) { - const m = l.match(/^([A-Za-zÄÖÜäöüß\-\s']{3,})(?:\s+\((m|w|d)\))?$/i); - if (m && /\s/.test(m[1])) { - entries.push({ name: m[1].trim(), genderHint: m[2] || null }); - } - } - - // Extrahiere Teilnahmegebühren - const entryFees = extractEntryFees(); - - return { - title, - termin, - austragungsorte, - konkurrenztypen, - meldeschluesse, - meldeschluesseByAk: meldeschluesseByAkOut, - altersklassen, - startzeiten: {}, - competitions, - entries, - entryFees, // Neue: Teilnahmegebühren pro Spielklasse - debug: { normLines }, - }; -} - - diff --git a/backend/controllers/trainingStatsController.js b/backend/controllers/trainingStatsController.js index c268ff73..420642b7 100644 --- a/backend/controllers/trainingStatsController.js +++ b/backend/controllers/trainingStatsController.js @@ -1,173 +1,16 @@ -import { DiaryDate, Member, Participant } from '../models/index.js'; -import { Op } from 'sequelize'; +import trainingStatsService from '../services/trainingStatsService.js'; class TrainingStatsController { async getTrainingStats(req, res) { try { const { clubId } = req.params; - - // Aktuelle Datum für Berechnungen - const now = new Date(); - const twelveMonthsAgo = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate()); - const threeMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 3, now.getDate()); - - // Alle aktiven Mitglieder des spezifischen Vereins laden - const members = await Member.findAll({ - where: { - active: true, - clubId: parseInt(clubId) - } - }); - - // Anzahl der Trainings im jeweiligen Zeitraum berechnen - const trainingsCount12Months = await DiaryDate.count({ - where: { - clubId: parseInt(clubId), - date: { - [Op.gte]: twelveMonthsAgo - } - } - }); - - const trainingsCount3Months = await DiaryDate.count({ - where: { - clubId: parseInt(clubId), - date: { - [Op.gte]: threeMonthsAgo - } - } - }); - - const stats = []; - - for (const member of members) { - // Trainingsteilnahmen der letzten 12 Monate über Participant-Model - const participation12Months = await Participant.count({ - include: [{ - model: DiaryDate, - as: 'diaryDate', - where: { - clubId: parseInt(clubId), - date: { - [Op.gte]: twelveMonthsAgo - } - } - }], - where: { - memberId: member.id - } - }); - - // Trainingsteilnahmen der letzten 3 Monate über Participant-Model - const participation3Months = await Participant.count({ - include: [{ - model: DiaryDate, - as: 'diaryDate', - where: { - clubId: parseInt(clubId), - date: { - [Op.gte]: threeMonthsAgo - } - } - }], - where: { - memberId: member.id - } - }); - - // Trainingsteilnahmen insgesamt über Participant-Model - const participationTotal = await Participant.count({ - include: [{ - model: DiaryDate, - as: 'diaryDate', - where: { - clubId: parseInt(clubId) - } - }], - where: { - memberId: member.id - } - }); - - // Detaillierte Trainingsdaten (absteigend sortiert) über Participant-Model - const trainingDetails = await Participant.findAll({ - include: [{ - model: DiaryDate, - as: 'diaryDate', - where: { - clubId: parseInt(clubId) - } - }], - where: { - memberId: member.id - }, - order: [['diaryDate', 'date', 'DESC']], - limit: 50 // Begrenzen auf die letzten 50 Trainingseinheiten - }); - - // Trainingsteilnahmen für den Member formatieren - const formattedTrainingDetails = trainingDetails.map(participation => ({ - id: participation.id, - date: participation.diaryDate.date, - activityName: 'Training', - startTime: '--:--', - endTime: '--:--' - })); - - // Letztes Training - const lastTrainingDate = trainingDetails.length ? trainingDetails[0].diaryDate.date : null; - const lastTrainingTs = lastTrainingDate ? new Date(lastTrainingDate).getTime() : 0; - - stats.push({ - id: member.id, - firstName: member.firstName, - lastName: member.lastName, - birthDate: member.birthDate, - participation12Months, - participation3Months, - participationTotal, - lastTraining: lastTrainingDate, - lastTrainingTs, - trainingDetails: formattedTrainingDetails - }); - } - - // Nach Gesamtteilnahme absteigend sortieren - stats.sort((a, b) => b.participationTotal - a.participationTotal); - - // Trainingstage mit Teilnehmerzahlen abrufen (letzte 12 Monate, absteigend sortiert) - const trainingDays = await DiaryDate.findAll({ - where: { - clubId: parseInt(clubId), - date: { - [Op.gte]: twelveMonthsAgo - } - }, - include: [{ - model: Participant, - as: 'participantList', - attributes: ['id'] - }], - order: [['date', 'DESC']] - }); - - // Formatiere Trainingstage mit Teilnehmerzahl - const formattedTrainingDays = trainingDays.map(day => ({ - id: day.id, - date: day.date, - participantCount: day.participantList ? day.participantList.length : 0 - })); - - // Zusätzliche Metadaten mit Trainingsanzahl zurückgeben - res.json({ - members: stats, - trainingsCount12Months, - trainingsCount3Months, - trainingDays: formattedTrainingDays - }); - + const stats = await trainingStatsService.getTrainingStats(clubId); + res.json(stats); } catch (error) { console.error('Fehler beim Laden der Trainings-Statistik:', error); + if (error?.status) { + return res.status(error.status).json({ error: error.message }); + } res.status(500).json({ error: 'Fehler beim Laden der Trainings-Statistik' }); } } diff --git a/backend/services/myTischtennisProxyService.js b/backend/services/myTischtennisProxyService.js new file mode 100644 index 00000000..d9a45a48 --- /dev/null +++ b/backend/services/myTischtennisProxyService.js @@ -0,0 +1,52 @@ +const MYTT_ORIGIN = 'https://www.mytischtennis.de'; +const MYTT_PROXY_PREFIX = '/api/mytischtennis/proxy'; + +class MyTischtennisProxyService { + getOrigin() { + return MYTT_ORIGIN; + } + + getProxyPrefix() { + return MYTT_PROXY_PREFIX; + } + + rewriteContent(content) { + if (typeof content !== 'string' || !content) { + return content; + } + + let rewritten = content; + + rewritten = rewritten.replace( + /(["'])\/build\//g, + `$1${MYTT_PROXY_PREFIX}/build/` + ); + rewritten = rewritten.replace( + /(["'])\/fonts\//g, + `$1${MYTT_PROXY_PREFIX}/fonts/` + ); + + rewritten = rewritten.replace( + /https:\/\/www\.mytischtennis\.de\/build\//g, + `${MYTT_PROXY_PREFIX}/build/` + ); + rewritten = rewritten.replace( + /https:\/\/www\.mytischtennis\.de\/fonts\//g, + `${MYTT_PROXY_PREFIX}/fonts/` + ); + + rewritten = rewritten.replace( + /url\((["']?)\/fonts\//g, + `url($1${MYTT_PROXY_PREFIX}/fonts/` + ); + + rewritten = rewritten.replace( + /(["'])\/api\/private-captcha/g, + `$1${MYTT_PROXY_PREFIX}/api/private-captcha` + ); + + return rewritten; + } +} + +export default new MyTischtennisProxyService(); diff --git a/backend/services/myTischtennisSessionService.js b/backend/services/myTischtennisSessionService.js new file mode 100644 index 00000000..25e4b0b5 --- /dev/null +++ b/backend/services/myTischtennisSessionService.js @@ -0,0 +1,39 @@ +import MyTischtennis from '../models/MyTischtennis.js'; +import myTischtennisClient from '../clients/myTischtennisClient.js'; + +class MyTischtennisSessionService { + async saveSessionFromCookie(userId, cookieString) { + const tokenMatch = cookieString.match(/sb-10-auth-token=base64-([^;]+)/); + if (!tokenMatch) { + throw new Error('Token-Format ungültig'); + } + + const base64Token = tokenMatch[1]; + const decodedToken = Buffer.from(base64Token, 'base64').toString('utf-8'); + const tokenData = JSON.parse(decodedToken); + + const myTischtennisAccount = await MyTischtennis.findOne({ where: { userId } }); + if (!myTischtennisAccount) { + return; + } + + myTischtennisAccount.accessToken = tokenData.access_token; + myTischtennisAccount.refreshToken = tokenData.refresh_token; + myTischtennisAccount.expiresAt = tokenData.expires_at; + myTischtennisAccount.cookie = cookieString.split(';')[0].trim(); + myTischtennisAccount.userData = tokenData.user; + myTischtennisAccount.lastLoginSuccess = new Date(); + myTischtennisAccount.lastLoginAttempt = new Date(); + + const profileResult = await myTischtennisClient.getUserProfile(myTischtennisAccount.cookie); + if (profileResult.success) { + myTischtennisAccount.clubId = profileResult.clubId; + myTischtennisAccount.clubName = profileResult.clubName; + myTischtennisAccount.fedNickname = profileResult.fedNickname; + } + + await myTischtennisAccount.save(); + } +} + +export default new MyTischtennisSessionService(); diff --git a/backend/services/officialTournamentParserService.js b/backend/services/officialTournamentParserService.js new file mode 100644 index 00000000..ec3668bd --- /dev/null +++ b/backend/services/officialTournamentParserService.js @@ -0,0 +1,296 @@ +class OfficialTournamentParserService { + static normalizeCompetitionForPersistence(c) { + let performanceClass = c.leistungsklasse || c.performanceClass || null; + let cutoffDate = c.stichtag || c.cutoffDate || null; + + // "Stichtag" kann auch in Leistungsklasse stehen (z. B. "VR Stichtag 01.01.2014 und jünger") + if (!cutoffDate && performanceClass && /\bstichtag\b/i.test(performanceClass)) { + const stichtagMatch = performanceClass.match( + /stichtag\s*:?\s*([0-3]?\d\.[01]?\d\.\d{4}(?:\s*(?:bis|-)\s*[0-3]?\d\.[01]?\d\.\d{4})?)/i + ); + if (stichtagMatch) { + cutoffDate = stichtagMatch[1].trim(); + } + performanceClass = performanceClass + .replace(/\bstichtag\b.*$/i, '') + .replace(/[,:;\-\s]+$/g, '') + .trim() || null; + } + + return { + ...c, + performanceClass, + cutoffDate, + }; + } + + static parseTournamentText(text) { + const lines = text.split(/\r?\n/); + const normLines = lines.map((l) => l.replace(/\s+/g, ' ').trim()); + + const findTitle = () => { + // Bevorzugt die Zeile direkt vor "Ausschreibung" + const ausschreibungIdx = normLines.findIndex((l) => /^Ausschreibung\b/i.test(l)); + if (ausschreibungIdx > 0) { + for (let i = ausschreibungIdx - 1; i >= 0; i--) { + const candidate = normLines[i]; + if (!candidate) continue; + if (/^HTTV\s*\/\s*Kreis/i.test(candidate)) continue; + if (/^\d+\.\s+/.test(candidate)) continue; + if (/^nu\.Dokument/i.test(candidate)) continue; + if (/Ausschreibung\s*\(Fortsetzung\)/i.test(candidate)) continue; + return candidate; + } + } + + // Fallback: typische Turnierbezeichnungen + const typical = normLines.find( + (l) => + /(meisterschaften|ranglistenturnier|pokal|turnier)/i.test(l) && + !/^HTTV\s*\/\s*Kreis/i.test(l) && + !/Ausschreibung/i.test(l) + ); + return typical || null; + }; + + const extractEntryFees = () => { + const entryFees = {}; + + const feePatterns = [ + /startgeld\s*:?\s*(.+)/i, + /teilnahmegebühr\s*:?\s*(.+)/i, + /gebühr\s*:?\s*(.+)/i, + /einschreibegebühr\s*:?\s*(.+)/i, + /anmeldegebühr\s*:?\s*(.+)/i, + ]; + + for (const pattern of feePatterns) { + for (let i = 0; i < normLines.length; i++) { + const line = normLines[i]; + const match = line.match(pattern); + if (!match) continue; + + const feeText = match[1]; + const feeMatches = feeText.matchAll( + /(U\d+|AK\s*\d+)\s*:?\s*(\d+(?:[,.]\d+)?)\s*(?:€|Euro|EUR)?/gi + ); + + for (const feeMatch of feeMatches) { + const ageClass = feeMatch[1].toUpperCase().replace(/\s+/g, ''); + const amount = feeMatch[2].replace(',', '.'); + const numericAmount = parseFloat(amount); + + if (!isNaN(numericAmount)) { + entryFees[ageClass] = { + amount: numericAmount, + currency: '€', + rawText: feeMatch[0], + }; + } + } + + if (Object.keys(entryFees).length > 0) break; + } + if (Object.keys(entryFees).length > 0) break; + } + + return entryFees; + }; + + const extractBlockAfter = (labels, multiline = false) => { + const idx = normLines.findIndex((l) => labels.some((lb) => l.toLowerCase().startsWith(lb))); + if (idx === -1) return multiline ? [] : null; + const line = normLines[idx]; + const afterColon = line.includes(':') ? line.split(':').slice(1).join(':').trim() : ''; + if (!multiline) { + if (afterColon) return afterColon; + for (let i = idx + 1; i < normLines.length; i++) { + if (normLines[i]) return normLines[i]; + } + return null; + } + const out = []; + if (afterColon) out.push(afterColon); + for (let i = idx + 1; i < normLines.length; i++) { + const ln = normLines[i]; + if (!ln) break; + if (/^(termin|austragungsort|austragungsorte|konkurrenz|konkurrenzen|konkurrenztypen|meldeschluss|altersklassen|startzeiten)/i.test(ln)) break; + out.push(ln); + } + return out; + }; + + const title = findTitle(); + const termin = extractBlockAfter(['termin', 'termin '], false); + const austragungsorte = extractBlockAfter(['austragungsort', 'austragungsorte'], true); + let konkurrenzRaw = extractBlockAfter(['konkurrenz', 'konkurrenzen', 'konkurrenztypen'], true); + if (konkurrenzRaw && !Array.isArray(konkurrenzRaw)) konkurrenzRaw = [konkurrenzRaw]; + const konkurrenztypen = (konkurrenzRaw || []) + .flatMap((l) => l.split(/[;,]/)) + .map((s) => s.trim()) + .filter(Boolean); + + const meldeschluesseRaw = []; + for (let i = 0; i < normLines.length; i++) { + const l = normLines[i]; + const m = l.match(/meldeschluss\s*:?\s*(.+)$/i); + if (m) meldeschluesseRaw.push({ line: i, value: m[1].trim() }); + } + + let altersRaw = extractBlockAfter(['altersklassen', 'altersklasse'], true); + if (altersRaw && !Array.isArray(altersRaw)) altersRaw = [altersRaw]; + const altersklassen = (altersRaw || []) + .flatMap((l) => l.split(/[;,]/)) + .map((s) => s.trim()) + .filter(Boolean); + + const competitions = []; + const konkIdx = normLines.findIndex((l) => /^\s*3\.?\s+Konkurrenzen/i.test(l) || /^Konkurrenzen\b/i.test(l)); + const startSectionNum = (() => { + if (konkIdx === -1) return 3; + const m = normLines[konkIdx].match(/^\s*(\d+)\./); + return m ? parseInt(m[1], 10) : 3; + })(); + + const nextSectionIdx = () => { + for (let i = konkIdx + 1; i < normLines.length; i++) { + const m = normLines[i].match(/^\s*(\d+)\.\s+/); + if (!m) continue; + const num = parseInt(m[1], 10); + if (!Number.isNaN(num) && num > startSectionNum) return i; + } + return normLines.length; + }; + + if (konkIdx !== -1) { + const endIdx = nextSectionIdx(); + let i = konkIdx + 1; + while (i < endIdx) { + const line = normLines[i]; + if (!/^Altersklasse\/Wettbewerb\s*:/i.test(line)) { + i++; + continue; + } + + const comp = {}; + comp.altersklasseWettbewerb = line.split(':').slice(1).join(':').trim(); + i++; + let lastParsedField = null; + + while (i < endIdx && !/^Altersklasse\/Wettbewerb\s*:/i.test(normLines[i])) { + const ln = normLines[i]; + const m = ln.match(/^([^:]+):\s*(.*)$/); + + if (m) { + const key = m[1].trim().toLowerCase(); + const val = m[2].trim(); + if (key.startsWith('leistungsklasse')) { + comp.leistungsklasse = val; + lastParsedField = 'leistungsklasse'; + } else if (key === 'startzeit') { + const sm = val.match(/(\d{2}\.\d{2}\.\d{4})\s+(\d{1,2}:\d{2})/); + comp.startzeit = sm ? `${sm[1]} ${sm[2]}` : val; + lastParsedField = 'startzeit'; + } else if (key.startsWith('meldeschluss datum')) { + comp.meldeschlussDatum = val; + lastParsedField = 'meldeschlussDatum'; + } else if (key.startsWith('meldeschluss online')) { + comp.meldeschlussOnline = val; + lastParsedField = 'meldeschlussOnline'; + } else if (key.startsWith('meldeschluss text')) { + comp.meldeschlussText = val; + lastParsedField = 'meldeschlussText'; + } else if (key === 'stichtag') { + comp.stichtag = val; + lastParsedField = 'stichtag'; + } else if (key === 'ttr-relevant') { + comp.ttrRelevant = val; + lastParsedField = 'ttrRelevant'; + } else if (key === 'offen für') { + comp.offenFuer = val; + lastParsedField = 'offenFuer'; + } else if (key.startsWith('austragungssys. vorrunde')) { + comp.vorrunde = val; + lastParsedField = 'vorrunde'; + } else if (key.startsWith('austragungssys. endrunde')) { + comp.endrunde = val; + lastParsedField = 'endrunde'; + } else if (key.startsWith('max. teilnehmerzahl')) { + comp.maxTeilnehmer = val; + lastParsedField = 'maxTeilnehmer'; + } else if (key === 'startgeld') { + comp.startgeld = val; + lastParsedField = 'startgeld'; + } + } else if (lastParsedField && ln) { + const appendableFields = new Set(['leistungsklasse', 'meldeschlussText', 'vorrunde', 'endrunde', 'offenFuer', 'stichtag']); + if (appendableFields.has(lastParsedField)) { + const current = comp[lastParsedField]; + comp[lastParsedField] = current ? `${current} ${ln}`.trim() : ln; + } + } + + i++; + } + + competitions.push(comp); + } + } + + const akPositions = []; + for (let i = 0; i < normLines.length; i++) { + const l = normLines[i]; + const m = l.match(/\b(U\d+|AK\s*\d+)\b/i); + if (m) akPositions.push({ line: i, ak: m[1].toUpperCase().replace(/\s+/g, '') }); + } + + const meldeschluesseByAk = {}; + for (const ms of meldeschluesseRaw) { + let best = null; + let bestDist = Infinity; + for (const ak of akPositions) { + const dist = Math.abs(ak.line - ms.line); + if (dist < bestDist && dist <= 3) { + best = ak; + bestDist = dist; + } + } + if (best) { + if (!meldeschluesseByAk[best.ak]) meldeschluesseByAk[best.ak] = new Set(); + meldeschluesseByAk[best.ak].add(ms.value); + } + } + + const meldeschluesse = Array.from(new Set(meldeschluesseRaw.map((x) => x.value))); + const meldeschluesseByAkOut = Object.fromEntries( + Object.entries(meldeschluesseByAk).map(([k, v]) => [k, Array.from(v)]) + ); + + const entries = []; + for (const l of normLines) { + const m = l.match(/^([A-Za-zÄÖÜäöüß\-\s']{3,})(?:\s+\((m|w|d)\))?$/i); + if (m && /\s/.test(m[1])) { + entries.push({ name: m[1].trim(), genderHint: m[2] || null }); + } + } + + const entryFees = extractEntryFees(); + + return { + title, + termin, + austragungsorte, + konkurrenztypen, + meldeschluesse, + meldeschluesseByAk: meldeschluesseByAkOut, + altersklassen, + startzeiten: {}, + competitions, + entries, + entryFees, + debug: { normLines }, + }; + } +} + +export default OfficialTournamentParserService; diff --git a/backend/services/officialTournamentService.js b/backend/services/officialTournamentService.js new file mode 100644 index 00000000..ed189e11 --- /dev/null +++ b/backend/services/officialTournamentService.js @@ -0,0 +1,307 @@ +import { createRequire } from 'module'; +const require = createRequire(import.meta.url); +const pdfParse = require('pdf-parse/lib/pdf-parse.js'); + +import { Op } from 'sequelize'; +import OfficialTournament from '../models/OfficialTournament.js'; +import OfficialCompetition from '../models/OfficialCompetition.js'; +import OfficialCompetitionMember from '../models/OfficialCompetitionMember.js'; +import Member from '../models/Member.js'; +import OfficialTournamentParserService from './officialTournamentParserService.js'; + +class OfficialTournamentService { + async uploadTournamentPdf(clubId, pdfBuffer) { + const data = await pdfParse(pdfBuffer); + const parsed = OfficialTournamentParserService.parseTournamentText(data.text); + + const tournament = await OfficialTournament.create({ + clubId, + title: parsed.title || null, + eventDate: parsed.termin || null, + organizer: null, + host: null, + venues: JSON.stringify(parsed.austragungsorte || []), + competitionTypes: JSON.stringify(parsed.konkurrenztypen || []), + registrationDeadlines: JSON.stringify(parsed.meldeschluesse || []), + entryFees: JSON.stringify(parsed.entryFees || {}), + }); + + for (const c of parsed.competitions || []) { + const normalizedCompetition = OfficialTournamentParserService.normalizeCompetitionForPersistence(c); + await OfficialCompetition.create({ + tournamentId: tournament.id, + ageClassCompetition: normalizedCompetition.altersklasseWettbewerb || normalizedCompetition.ageClassCompetition || null, + performanceClass: normalizedCompetition.performanceClass || null, + startTime: normalizedCompetition.startzeit || normalizedCompetition.startTime || null, + registrationDeadlineDate: normalizedCompetition.meldeschlussDatum || normalizedCompetition.registrationDeadlineDate || null, + registrationDeadlineOnline: normalizedCompetition.meldeschlussOnline || normalizedCompetition.registrationDeadlineOnline || null, + cutoffDate: normalizedCompetition.cutoffDate || null, + ttrRelevant: normalizedCompetition.ttrRelevant || null, + openTo: normalizedCompetition.offenFuer || normalizedCompetition.openTo || null, + preliminaryRound: normalizedCompetition.vorrunde || normalizedCompetition.preliminaryRound || null, + finalRound: normalizedCompetition.endrunde || normalizedCompetition.finalRound || null, + maxParticipants: normalizedCompetition.maxTeilnehmer || normalizedCompetition.maxParticipants || null, + entryFee: normalizedCompetition.startgeld || normalizedCompetition.entryFee || null, + }); + } + + return { id: String(tournament.id) }; + } + + async getParsedTournament(clubId, id) { + const t = await OfficialTournament.findOne({ where: { id, clubId } }); + if (!t) return null; + + const comps = await OfficialCompetition.findAll({ where: { tournamentId: id } }); + const entries = await OfficialCompetitionMember.findAll({ where: { tournamentId: id } }); + const competitions = comps.map((c) => { + const j = c.toJSON(); + return { + id: j.id, + tournamentId: j.tournamentId, + ageClassCompetition: j.ageClassCompetition || null, + performanceClass: j.performanceClass || null, + startTime: j.startTime || null, + registrationDeadlineDate: j.registrationDeadlineDate || null, + registrationDeadlineOnline: j.registrationDeadlineOnline || null, + cutoffDate: j.cutoffDate || null, + ttrRelevant: j.ttrRelevant || null, + openTo: j.openTo || null, + preliminaryRound: j.preliminaryRound || null, + finalRound: j.finalRound || null, + maxParticipants: j.maxParticipants || null, + entryFee: j.entryFee || null, + altersklasseWettbewerb: j.ageClassCompetition || null, + leistungsklasse: j.performanceClass || null, + startzeit: j.startTime || null, + meldeschlussDatum: j.registrationDeadlineDate || null, + meldeschlussOnline: j.registrationDeadlineOnline || null, + stichtag: j.cutoffDate || null, + offenFuer: j.openTo || null, + vorrunde: j.preliminaryRound || null, + endrunde: j.finalRound || null, + maxTeilnehmer: j.maxParticipants || null, + startgeld: j.entryFee || null, + }; + }); + + return { + id: String(t.id), + clubId: String(t.clubId), + parsedData: { + title: t.title, + termin: t.eventDate, + austragungsorte: JSON.parse(t.venues || '[]'), + konkurrenztypen: JSON.parse(t.competitionTypes || '[]'), + meldeschluesse: JSON.parse(t.registrationDeadlines || '[]'), + entryFees: JSON.parse(t.entryFees || '{}'), + competitions, + }, + participation: entries.map((e) => ({ + id: e.id, + tournamentId: e.tournamentId, + competitionId: e.competitionId, + memberId: e.memberId, + wants: !!e.wants, + registered: !!e.registered, + participated: !!e.participated, + placement: e.placement || null, + })), + }; + } + + async upsertCompetitionMember(tournamentId, payload) { + const { competitionId, memberId, wants, registered, participated, placement } = payload; + if (!competitionId || !memberId) { + const err = new Error('competitionId and memberId required'); + err.status = 400; + throw err; + } + + const [row] = await OfficialCompetitionMember.findOrCreate({ + where: { competitionId, memberId }, + defaults: { + tournamentId, + competitionId, + memberId, + wants: !!wants, + registered: !!registered, + participated: !!participated, + placement: placement || null, + }, + }); + row.wants = wants !== undefined ? !!wants : row.wants; + row.registered = registered !== undefined ? !!registered : row.registered; + row.participated = participated !== undefined ? !!participated : row.participated; + if (placement !== undefined) row.placement = placement; + await row.save(); + return { success: true, id: row.id }; + } + + async updateParticipantStatus(tournamentId, payload) { + const { competitionId, memberId, action } = payload; + if (!competitionId || !memberId || !action) { + const err = new Error('competitionId, memberId and action required'); + err.status = 400; + throw err; + } + + const [row] = await OfficialCompetitionMember.findOrCreate({ + where: { competitionId, memberId }, + defaults: { + tournamentId, + competitionId, + memberId, + wants: false, + registered: false, + participated: false, + placement: null, + }, + }); + + switch (action) { + case 'register': + row.wants = true; + row.registered = true; + row.participated = false; + break; + case 'participate': + row.wants = true; + row.registered = true; + row.participated = true; + break; + case 'reset': + row.wants = true; + row.registered = false; + row.participated = false; + break; + default: { + const err = new Error('Invalid action. Use: register, participate, or reset'); + err.status = 400; + throw err; + } + } + + await row.save(); + return { + success: true, + id: row.id, + status: { + wants: row.wants, + registered: row.registered, + participated: row.participated, + placement: row.placement, + }, + }; + } + + async listOfficialTournaments(clubId) { + const list = await OfficialTournament.findAll({ where: { clubId } }); + return Array.isArray(list) ? list : []; + } + + parseDmy(s) { + if (!s) return null; + const m = String(s).match(/(\d{1,2})\.(\d{1,2})\.(\d{4})/); + if (!m) return null; + const d = new Date(Number(m[3]), Number(m[2]) - 1, Number(m[1])); + return isNaN(d.getTime()) ? null : d; + } + + fmtDmy(d) { + const dd = String(d.getDate()).padStart(2, '0'); + const mm = String(d.getMonth() + 1).padStart(2, '0'); + const yyyy = d.getFullYear(); + return `${dd}.${mm}.${yyyy}`; + } + + async listClubParticipations(clubId) { + const tournaments = await OfficialTournament.findAll({ where: { clubId } }); + if (!tournaments || tournaments.length === 0) return []; + const tournamentIds = tournaments.map((t) => t.id); + + const rows = await OfficialCompetitionMember.findAll({ + where: { tournamentId: { [Op.in]: tournamentIds }, participated: true }, + include: [ + { model: OfficialCompetition, as: 'competition', attributes: ['id', 'tournamentId', 'ageClassCompetition', 'startTime'] }, + { model: OfficialTournament, as: 'tournament', attributes: ['id', 'title', 'eventDate'] }, + { model: Member, as: 'member', attributes: ['id', 'firstName', 'lastName'] }, + ], + }); + + const byTournament = new Map(); + for (const r of rows) { + const t = r.tournament; + const c = r.competition; + const m = r.member; + if (!t || !c || !m) continue; + if (!byTournament.has(t.id)) { + byTournament.set(t.id, { + tournamentId: String(t.id), + title: t.title || null, + startDate: null, + endDate: null, + entries: [], + _dates: [], + _eventDate: t.eventDate || null, + }); + } + const bucket = byTournament.get(t.id); + const compDate = this.parseDmy(c.startTime || '') || null; + if (compDate) bucket._dates.push(compDate); + bucket.entries.push({ + memberId: m.id, + memberName: `${m.firstName || ''} ${m.lastName || ''}`.trim(), + competitionId: c.id, + competitionName: c.ageClassCompetition || '', + placement: r.placement || null, + date: compDate ? this.fmtDmy(compDate) : null, + }); + } + + const out = []; + for (const t of tournaments) { + const bucket = byTournament.get(t.id) || { + tournamentId: String(t.id), + title: t.title || null, + startDate: null, + endDate: null, + entries: [], + _dates: [], + _eventDate: t.eventDate || null, + }; + if (bucket._dates.length) { + bucket._dates.sort((a, b) => a - b); + bucket.startDate = this.fmtDmy(bucket._dates[0]); + bucket.endDate = this.fmtDmy(bucket._dates[bucket._dates.length - 1]); + } else if (bucket._eventDate) { + const all = String(bucket._eventDate).match(/(\d{1,2}\.\d{1,2}\.\d{4})/g) || []; + if (all.length >= 1) { + const d1 = this.parseDmy(all[0]); + const d2 = all.length >= 2 ? this.parseDmy(all[1]) : d1; + if (d1) bucket.startDate = this.fmtDmy(d1); + if (d2) bucket.endDate = this.fmtDmy(d2); + } + } + bucket.entries.sort((a, b) => { + const mcmp = (a.memberName || '').localeCompare(b.memberName || '', 'de', { sensitivity: 'base' }); + if (mcmp !== 0) return mcmp; + return (a.competitionName || '').localeCompare(b.competitionName || '', 'de', { sensitivity: 'base' }); + }); + delete bucket._dates; + delete bucket._eventDate; + out.push(bucket); + } + return out; + } + + async deleteOfficialTournament(clubId, id) { + const t = await OfficialTournament.findOne({ where: { id, clubId } }); + if (!t) return false; + await OfficialCompetition.destroy({ where: { tournamentId: id } }); + await OfficialTournament.destroy({ where: { id } }); + return true; + } +} + +export default new OfficialTournamentService(); diff --git a/backend/services/trainingStatsService.js b/backend/services/trainingStatsService.js new file mode 100644 index 00000000..ed3ffe1e --- /dev/null +++ b/backend/services/trainingStatsService.js @@ -0,0 +1,137 @@ +import { DiaryDate, Member, Participant } from '../models/index.js'; +import { Op } from 'sequelize'; + +class TrainingStatsService { + async getTrainingStats(clubIdRaw) { + const clubId = parseInt(clubIdRaw, 10); + if (!Number.isFinite(clubId)) { + const err = new Error('Ungültige clubId'); + err.status = 400; + throw err; + } + + const now = new Date(); + const twelveMonthsAgo = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate()); + const threeMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 3, now.getDate()); + + const members = await Member.findAll({ + where: { active: true, clubId } + }); + + const trainingsCount12Months = await DiaryDate.count({ + where: { + clubId, + date: { [Op.gte]: twelveMonthsAgo } + } + }); + + const trainingsCount3Months = await DiaryDate.count({ + where: { + clubId, + date: { [Op.gte]: threeMonthsAgo } + } + }); + + const stats = []; + + for (const member of members) { + const participation12Months = await Participant.count({ + include: [{ + model: DiaryDate, + as: 'diaryDate', + where: { + clubId, + date: { [Op.gte]: twelveMonthsAgo } + } + }], + where: { memberId: member.id } + }); + + const participation3Months = await Participant.count({ + include: [{ + model: DiaryDate, + as: 'diaryDate', + where: { + clubId, + date: { [Op.gte]: threeMonthsAgo } + } + }], + where: { memberId: member.id } + }); + + const participationTotal = await Participant.count({ + include: [{ + model: DiaryDate, + as: 'diaryDate', + where: { clubId } + }], + where: { memberId: member.id } + }); + + const trainingDetails = await Participant.findAll({ + include: [{ + model: DiaryDate, + as: 'diaryDate', + where: { clubId } + }], + where: { memberId: member.id }, + order: [['diaryDate', 'date', 'DESC']], + limit: 50 + }); + + const formattedTrainingDetails = trainingDetails.map((participation) => ({ + id: participation.id, + date: participation.diaryDate.date, + activityName: 'Training', + startTime: '--:--', + endTime: '--:--' + })); + + const lastTrainingDate = trainingDetails.length ? trainingDetails[0].diaryDate.date : null; + const lastTrainingTs = lastTrainingDate ? new Date(lastTrainingDate).getTime() : 0; + + stats.push({ + id: member.id, + firstName: member.firstName, + lastName: member.lastName, + birthDate: member.birthDate, + participation12Months, + participation3Months, + participationTotal, + lastTraining: lastTrainingDate, + lastTrainingTs, + trainingDetails: formattedTrainingDetails + }); + } + + stats.sort((a, b) => b.participationTotal - a.participationTotal); + + const trainingDays = await DiaryDate.findAll({ + where: { + clubId, + date: { [Op.gte]: twelveMonthsAgo } + }, + include: [{ + model: Participant, + as: 'participantList', + attributes: ['id'] + }], + order: [['date', 'DESC']] + }); + + const formattedTrainingDays = trainingDays.map((day) => ({ + id: day.id, + date: day.date, + participantCount: day.participantList ? day.participantList.length : 0 + })); + + return { + members: stats, + trainingsCount12Months, + trainingsCount3Months, + trainingDays: formattedTrainingDays + }; + } +} + +export default new TrainingStatsService();