feat(myTischtennis): enhance CAPTCHA handling and refactor controller logic

- Added visual state tracking for CAPTCHA elements in MyTischtennisClient to improve interaction reliability.
- Increased timeout for CAPTCHA field population to ensure proper handling during login.
- Refactored MyTischtennisController to utilize myTischtennisProxyService for content rewriting and session management, streamlining the login process.
- Removed deprecated content rewriting logic, enhancing code maintainability and clarity.
This commit is contained in:
Torsten Schulz (local)
2026-03-02 09:05:43 +01:00
parent 12bba26ff1
commit cf8cf17dc7
9 changed files with 933 additions and 800 deletions

View File

@@ -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"]');

View File

@@ -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(
'<!doctype html><html><body><p>Login erfolgreich. Fenster kann geschlossen werden.</p></body></html>'
);
@@ -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

View File

@@ -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 },
};
}

View File

@@ -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' });
}
}

View File

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

View File

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

View File

@@ -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;

View File

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

View File

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