feat(officialTournament): add auto-registration for tournament participants
- Implemented a new endpoint to automatically register participants for official tournaments using the Click-TT service. - Added a corresponding method in the frontend to trigger the auto-registration process, enhancing user experience by simplifying participant management. - Updated the official tournament controller and routes to support the new functionality.
This commit is contained in:
347
backend/services/clickTtTournamentRegistrationService.js
Normal file
347
backend/services/clickTtTournamentRegistrationService.js
Normal file
@@ -0,0 +1,347 @@
|
||||
import { chromium } from 'playwright';
|
||||
import Club from '../models/Club.js';
|
||||
import ClickTtAccount from '../models/ClickTtAccount.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 HttpError from '../exceptions/HttpError.js';
|
||||
import { checkAccess } from '../utils/userUtils.js';
|
||||
import clickTtPlayerRegistrationService from './clickTtPlayerRegistrationService.js';
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || '')
|
||||
.normalize('NFKC')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function formatGermanDate(value) {
|
||||
if (!value) return '';
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const year = String(date.getFullYear());
|
||||
return `${day}.${month}.${year}`;
|
||||
}
|
||||
|
||||
function parseDmyRange(value) {
|
||||
const matches = String(value || '').match(/(\d{1,2}\.\d{1,2}\.\d{4})/g) || [];
|
||||
return {
|
||||
from: matches[0] || '',
|
||||
to: matches[1] || matches[0] || ''
|
||||
};
|
||||
}
|
||||
|
||||
class ClickTtTournamentRegistrationService {
|
||||
async autoRegisterPendingParticipants({ userToken, userId, clubId, tournamentId }) {
|
||||
await checkAccess(userToken, clubId);
|
||||
|
||||
const club = await Club.findByPk(clubId, {
|
||||
attributes: ['id', 'name', 'associationMemberNumber']
|
||||
});
|
||||
const associationMemberNumber = String(
|
||||
club?.associationMemberNumber ??
|
||||
club?.association_member_number ??
|
||||
club?.dataValues?.associationMemberNumber ??
|
||||
club?.dataValues?.association_member_number ??
|
||||
''
|
||||
).trim();
|
||||
if (!associationMemberNumber) {
|
||||
throw new HttpError(
|
||||
`Für den Verein "${club?.name ?? clubId}" ist keine Verbands-Mitgliedsnummer hinterlegt.`,
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
const account = await ClickTtAccount.findOne({ where: { userId } });
|
||||
if (!account?.username) {
|
||||
throw new HttpError('Kein HTTV-/click-TT-Account für diesen Benutzer hinterlegt', 400);
|
||||
}
|
||||
|
||||
const tournament = await OfficialTournament.findOne({
|
||||
where: { id: tournamentId, clubId },
|
||||
include: [{
|
||||
model: OfficialCompetition,
|
||||
as: 'competitions',
|
||||
required: false,
|
||||
include: [{
|
||||
model: OfficialCompetitionMember,
|
||||
as: 'members',
|
||||
required: false,
|
||||
where: {
|
||||
wants: true,
|
||||
registered: false,
|
||||
participated: false
|
||||
},
|
||||
include: [{
|
||||
model: Member,
|
||||
as: 'member',
|
||||
required: true
|
||||
}]
|
||||
}]
|
||||
}]
|
||||
});
|
||||
|
||||
if (!tournament) {
|
||||
throw new HttpError('Offizielles Turnier nicht gefunden', 404);
|
||||
}
|
||||
|
||||
const competitionGroups = (tournament.competitions || [])
|
||||
.map((competition) => ({
|
||||
competition,
|
||||
entries: (competition.members || [])
|
||||
.filter((entry) => entry.member?.active)
|
||||
}))
|
||||
.filter((group) => group.entries.length > 0);
|
||||
|
||||
if (!competitionGroups.length) {
|
||||
throw new HttpError('Keine offenen Turnieranmeldungen mit Status "möchte teilnehmen" gefunden', 400);
|
||||
}
|
||||
|
||||
const password = account.getPassword?.() || null;
|
||||
const savedStorageState = account.playwrightStorageState ?? null;
|
||||
if (!savedStorageState && !password) {
|
||||
throw new HttpError('Für die Click-TT-Automatisierung wird ein gespeichertes HTTV-/click-TT-Passwort oder eine gültige Browser-Session benötigt', 400);
|
||||
}
|
||||
|
||||
let browser = null;
|
||||
let context = null;
|
||||
let page = null;
|
||||
const trace = [];
|
||||
|
||||
try {
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-dev-shm-usage']
|
||||
});
|
||||
context = savedStorageState
|
||||
? await browser.newContext({ storageState: savedStorageState })
|
||||
: await browser.newContext();
|
||||
page = await context.newPage();
|
||||
clickTtPlayerRegistrationService._attachNetworkLogging(page, trace);
|
||||
|
||||
await clickTtPlayerRegistrationService._openAuthenticatedClickTt(page, {
|
||||
username: account.username,
|
||||
password,
|
||||
trace
|
||||
});
|
||||
await clickTtPlayerRegistrationService._selectClubContext(page, associationMemberNumber, trace);
|
||||
|
||||
const processedCompetitions = [];
|
||||
for (const group of competitionGroups) {
|
||||
await this._openTournamentSearch(page, trace);
|
||||
await this._filterTournamentSearch(page, tournament, trace);
|
||||
await this._openCompetitionRegistration(page, tournament, group.competition, trace);
|
||||
await this._selectCompetitionMembers(page, group.entries, trace);
|
||||
await this._openControlStep(page, trace);
|
||||
await this._verifyControlPage(page, group.entries);
|
||||
await clickTtPlayerRegistrationService._clickByText(page, 'Speichern', trace);
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
for (const entry of group.entries) {
|
||||
entry.registered = true;
|
||||
await entry.save({ fields: ['registered'] });
|
||||
}
|
||||
|
||||
processedCompetitions.push({
|
||||
competitionId: group.competition.id,
|
||||
competitionName: group.competition.ageClassCompetition || group.competition.altersklasseWettbewerb || '',
|
||||
registeredMembers: group.entries.map((entry) => ({
|
||||
memberId: entry.memberId,
|
||||
name: `${entry.member.firstName} ${entry.member.lastName}`.trim(),
|
||||
birthDate: entry.member.birthDate || ''
|
||||
}))
|
||||
});
|
||||
}
|
||||
|
||||
account.playwrightStorageState = await context.storageState();
|
||||
await account.save({ fields: ['playwrightStorageState'] });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `${processedCompetitions.reduce((sum, item) => sum + item.registeredMembers.length, 0)} Teilnehmer wurden automatisch in click-TT angemeldet.`,
|
||||
competitions: processedCompetitions
|
||||
};
|
||||
} catch (error) {
|
||||
throw error instanceof HttpError
|
||||
? error
|
||||
: new HttpError(`Click-TT-Turnieranmeldung fehlgeschlagen: ${error.message || error}`, 500);
|
||||
} finally {
|
||||
if (context) {
|
||||
try { await context.close(); } catch (_err) {}
|
||||
}
|
||||
if (browser) {
|
||||
try { await browser.close(); } catch (_err) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _openTournamentSearch(page, trace) {
|
||||
await clickTtPlayerRegistrationService._dismissConsentOverlays(page, trace);
|
||||
await clickTtPlayerRegistrationService._clickByText(page, 'Turniere', trace);
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
await clickTtPlayerRegistrationService._dismissConsentOverlays(page, trace);
|
||||
}
|
||||
|
||||
async _filterTournamentSearch(page, tournament, trace) {
|
||||
const federationSelect = page.locator('#federationsPopupButton, select[name="0.1.63.7.5.1"]').first();
|
||||
if (await federationSelect.count()) {
|
||||
const federationValue = await federationSelect.evaluate((select) => {
|
||||
const options = Array.from(select.options || []);
|
||||
const match = options.find((option) => /hess|hettv|httv/i.test(String(option.textContent || '')));
|
||||
return match?.value || null;
|
||||
});
|
||||
if (federationValue) {
|
||||
await federationSelect.selectOption(federationValue);
|
||||
}
|
||||
}
|
||||
|
||||
const range = parseDmyRange(tournament.eventDate);
|
||||
const fromInput = page.locator('input[name="queryDateFrom"]').first();
|
||||
if (await fromInput.count() && range.from) {
|
||||
await fromInput.fill(range.from);
|
||||
}
|
||||
const toInput = page.locator('input[name="queryDateTo"]').first();
|
||||
if (await toInput.count() && range.to) {
|
||||
await toInput.fill(range.to);
|
||||
}
|
||||
|
||||
if (await clickTtPlayerRegistrationService._hasTextTarget(page, 'filtern')) {
|
||||
await clickTtPlayerRegistrationService._clickByText(page, 'filtern', trace);
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
}
|
||||
}
|
||||
|
||||
async _openCompetitionRegistration(page, tournament, competition, trace) {
|
||||
const competitionName = String(competition.ageClassCompetition || competition.altersklasseWettbewerb || '').trim();
|
||||
if (!competitionName) {
|
||||
throw new HttpError('Turnierkonkurrenz ohne Bezeichnung gefunden', 500);
|
||||
}
|
||||
|
||||
const href = await page.locator('a').evaluateAll((anchors, criteria) => {
|
||||
const normalize = (value) => String(value || '')
|
||||
.normalize('NFKC')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
const wantedCompetition = normalize(criteria.competitionName);
|
||||
const wantedTournament = normalize(criteria.tournamentTitle);
|
||||
|
||||
for (const anchor of anchors) {
|
||||
const text = normalize(anchor.textContent || '');
|
||||
if (!text.includes(wantedCompetition)) continue;
|
||||
|
||||
const contextText = normalize(anchor.closest('tr, li, div, td')?.textContent || '');
|
||||
if (wantedTournament && contextText && !contextText.includes(wantedTournament) && !text.includes(wantedTournament)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return anchor.getAttribute('href');
|
||||
}
|
||||
|
||||
return null;
|
||||
}, {
|
||||
competitionName,
|
||||
tournamentTitle: tournament.title || ''
|
||||
});
|
||||
|
||||
if (!href) {
|
||||
throw new HttpError(`Click-TT-Turnierkonkurrenz nicht gefunden: ${competitionName}`, 500);
|
||||
}
|
||||
|
||||
clickTtPlayerRegistrationService._trace(trace, 'step', {
|
||||
name: 'click',
|
||||
label: competitionName,
|
||||
selector: `a[href="${href}"]`
|
||||
});
|
||||
await page.locator(`a[href="${href}"]`).first().click();
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
}
|
||||
|
||||
async _selectCompetitionMembers(page, entries, trace) {
|
||||
const unmatched = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const expectedRow = {
|
||||
fullName: normalizeText(`${entry.member.lastName}, ${entry.member.firstName}`),
|
||||
fullNameAlt: normalizeText(`${entry.member.firstName} ${entry.member.lastName}`),
|
||||
birthDate: formatGermanDate(entry.member.birthDate)
|
||||
};
|
||||
|
||||
const matched = await page.locator('tr').evaluateAll((rows, criteria) => {
|
||||
const normalize = (value) => String(value || '')
|
||||
.normalize('NFKC')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
for (let index = 0; index < rows.length; index += 1) {
|
||||
const row = rows[index];
|
||||
const rowText = normalize(row.textContent || '');
|
||||
if (!rowText.includes(criteria.fullName) && !rowText.includes(criteria.fullNameAlt)) continue;
|
||||
if (criteria.birthDate && !rowText.includes(normalize(criteria.birthDate))) continue;
|
||||
return index;
|
||||
}
|
||||
return -1;
|
||||
}, expectedRow);
|
||||
|
||||
if (matched < 0) {
|
||||
unmatched.push(`${entry.member.firstName} ${entry.member.lastName} (${expectedRow.birthDate})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const row = page.locator('tr').nth(matched);
|
||||
const checkbox = row.locator('input[type="checkbox"]:not([disabled])').first();
|
||||
if (await checkbox.count()) {
|
||||
if (!(await checkbox.isChecked().catch(() => false))) {
|
||||
await checkbox.check();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const registerButton = row.locator('input[type="submit"], button, a').filter({ hasText: /anmelden|hinzufügen|melden/i }).first();
|
||||
if (await registerButton.count()) {
|
||||
await registerButton.click();
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
continue;
|
||||
}
|
||||
|
||||
unmatched.push(`${entry.member.firstName} ${entry.member.lastName} (${expectedRow.birthDate})`);
|
||||
}
|
||||
|
||||
if (unmatched.length) {
|
||||
throw new HttpError(`Folgende Teilnehmer konnten auf der Click-TT-Turnierseite nicht ausgewählt werden: ${unmatched.join(', ')}`, 500);
|
||||
}
|
||||
}
|
||||
|
||||
async _openControlStep(page, trace) {
|
||||
if (await clickTtPlayerRegistrationService._hasTextTarget(page, 'Weiter >>')) {
|
||||
await clickTtPlayerRegistrationService._clickByText(page, 'Weiter >>', trace);
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
return;
|
||||
}
|
||||
|
||||
if (normalizeText(await page.locator('body').innerText().catch(() => '')).includes('folgende spieler werden zum turnier angemeldet')) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new HttpError('Click-TT-Kontrollseite für die Turnieranmeldung nicht erreicht', 500);
|
||||
}
|
||||
|
||||
async _verifyControlPage(page, entries) {
|
||||
const bodyText = normalizeText(await page.locator('body').innerText().catch(() => ''));
|
||||
for (const entry of entries) {
|
||||
const name = normalizeText(`${entry.member.lastName}, ${entry.member.firstName}`);
|
||||
const birthDate = formatGermanDate(entry.member.birthDate);
|
||||
if (!bodyText.includes(name) || (birthDate && !bodyText.includes(normalizeText(birthDate)))) {
|
||||
throw new HttpError(`Kontrollseite der Click-TT-Turnieranmeldung enthält nicht den erwarteten Teilnehmer ${entry.member.firstName} ${entry.member.lastName}`, 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new ClickTtTournamentRegistrationService();
|
||||
Reference in New Issue
Block a user