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:
52
backend/services/myTischtennisProxyService.js
Normal file
52
backend/services/myTischtennisProxyService.js
Normal 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();
|
||||
39
backend/services/myTischtennisSessionService.js
Normal file
39
backend/services/myTischtennisSessionService.js
Normal 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();
|
||||
296
backend/services/officialTournamentParserService.js
Normal file
296
backend/services/officialTournamentParserService.js
Normal 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;
|
||||
307
backend/services/officialTournamentService.js
Normal file
307
backend/services/officialTournamentService.js
Normal 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();
|
||||
137
backend/services/trainingStatsService.js
Normal file
137
backend/services/trainingStatsService.js
Normal 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();
|
||||
Reference in New Issue
Block a user