diff --git a/backend/controllers/officialTournamentController.js b/backend/controllers/officialTournamentController.js index f840c732..2eaa7884 100644 --- a/backend/controllers/officialTournamentController.js +++ b/backend/controllers/officialTournamentController.js @@ -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' + }); + } +}; diff --git a/backend/routes/officialTournamentRoutes.js b/backend/routes/officialTournamentRoutes.js index a61a18aa..2177e8b5 100644 --- a/backend/routes/officialTournamentRoutes.js +++ b/backend/routes/officialTournamentRoutes.js @@ -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; - diff --git a/backend/services/clickTtTournamentRegistrationService.js b/backend/services/clickTtTournamentRegistrationService.js new file mode 100644 index 00000000..e5e29abc --- /dev/null +++ b/backend/services/clickTtTournamentRegistrationService.js @@ -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(); diff --git a/frontend/src/views/OfficialTournaments.vue b/frontend/src/views/OfficialTournaments.vue index 99f315f7..f9a28be5 100644 --- a/frontend/src/views/OfficialTournaments.vue +++ b/frontend/src/views/OfficialTournaments.vue @@ -125,6 +125,9 @@
+
@@ -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 } -