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:
Torsten Schulz (local)
2026-03-11 20:47:44 +01:00
parent 36ed320893
commit 2f82886ad6
4 changed files with 417 additions and 3 deletions

View File

@@ -1,5 +1,6 @@
import { checkAccess } from '../utils/userUtils.js';
import officialTournamentService from '../services/officialTournamentService.js';
import clickTtTournamentRegistrationService from '../services/clickTtTournamentRegistrationService.js';
export const updateOfficialTournament = async (req, res) => {
try {
@@ -116,3 +117,26 @@ export const deleteOfficialTournament = async (req, res) => {
res.status(500).json({ error: 'Failed to delete tournament' });
}
};
export const autoRegisterOfficialTournamentParticipants = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId, id: tournamentId } = req.params;
const userId = req.user?.id;
const result = await clickTtTournamentRegistrationService.autoRegisterPendingParticipants({
userToken,
userId,
clubId,
tournamentId
});
res.status(200).json(result);
} catch (e) {
console.error('[autoRegisterOfficialTournamentParticipants] Error:', e);
res.status(e.statusCode || e.status || 500).json({
success: false,
error: e.message || 'Teilnehmer konnten nicht automatisch in click-TT angemeldet werden'
});
}
};

View File

@@ -1,7 +1,17 @@
import express from 'express';
import multer from 'multer';
import { authenticate } from '../middleware/authMiddleware.js';
import { uploadTournamentPdf, getParsedTournament, listOfficialTournaments, deleteOfficialTournament, updateOfficialTournament, upsertCompetitionMember, listClubParticipations, updateParticipantStatus } from '../controllers/officialTournamentController.js';
import {
uploadTournamentPdf,
getParsedTournament,
listOfficialTournaments,
deleteOfficialTournament,
updateOfficialTournament,
upsertCompetitionMember,
listClubParticipations,
updateParticipantStatus,
autoRegisterOfficialTournamentParticipants
} from '../controllers/officialTournamentController.js';
const router = express.Router();
const upload = multer({ storage: multer.memoryStorage() });
@@ -16,7 +26,7 @@ router.patch('/:clubId/:id', updateOfficialTournament);
router.delete('/:clubId/:id', deleteOfficialTournament);
router.post('/:clubId/:id/participation', upsertCompetitionMember);
router.post('/:clubId/:id/status', updateParticipantStatus);
router.post('/:clubId/:id/auto-register', autoRegisterOfficialTournamentParticipants);
export default router;

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

View File

@@ -125,6 +125,9 @@
<div class="top-actions">
<button class="btn-secondary" @click="openMemberDialog" :disabled="!parsed || !activeMembers.length">{{ $t('officialTournaments.selectMembers') }}</button>
<button class="btn-primary" :disabled="!selectedMemberIds.length" @click="generateMembersPdf">{{ $t('officialTournaments.pdfForSelectedMembers') }}</button>
<button class="btn-primary" :disabled="!uploadedId || !pendingAutoRegistrationCount || autoRegistering" @click="autoRegisterParticipants">
{{ autoRegistering ? 'Anmeldung läuft...' : 'Automatisch anmelden' }}
</button>
</div>
<div class="tabs">
<button :class="['tab', activeTab==='competitions' ? 'active' : '']" @click="activeTab='competitions'" :title="$t('officialTournaments.showCompetitions')">{{ $t('officialTournaments.competitions') }}</button>
@@ -444,6 +447,7 @@ export default {
participationRange: 'all',
editingTournamentId: null,
editingTitle: '',
autoRegistering: false,
};
},
computed: {
@@ -564,6 +568,11 @@ export default {
}
groups.sort((a, b) => this.collator.compare(a.memberName, b.memberName));
return groups;
},
pendingAutoRegistrationCount() {
return Object.values(this.participationMap || {}).filter((entry) =>
entry?.wants && !entry?.registered && !entry?.participated
).length;
}
},
methods: {
@@ -869,6 +878,31 @@ export default {
await this.showInfo('Fehler', message, '', 'error');
}
},
async autoRegisterParticipants() {
if (!this.uploadedId || !this.pendingAutoRegistrationCount || this.autoRegistering) return;
this.autoRegistering = true;
try {
const response = await apiClient.post(
`/official-tournaments/${this.currentClub}/${this.uploadedId}/auto-register`
);
await this.reload();
await this.showInfo(
'Erfolg',
response?.data?.message || 'Die gewünschten Teilnehmer wurden automatisch in click-TT angemeldet.',
'',
'success'
);
} catch (error) {
await this.showInfo(
'Fehler',
getSafeMessage(error) || 'Die automatische click-TT-Anmeldung ist fehlgeschlagen.',
getSafeErrorMessage(error),
'error'
);
} finally {
this.autoRegistering = false;
}
},
async reload() {
if (!this.uploadedId) return;
const t = await apiClient.get(`/official-tournaments/${this.currentClub}/${this.uploadedId}`);
@@ -1534,4 +1568,3 @@ th, td { border-bottom: 1px solid var(--border-color); padding: 0.5rem; text-ali
}
</style>