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:
@@ -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'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
|
||||
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();
|
||||
@@ -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>
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user