From 43f96b2491a5852d6be064f0c7e8cfd5b5eac3b6 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Mon, 16 Mar 2026 22:17:37 +0100 Subject: [PATCH] feat(tournamentService): add validation for stage advancements and enhance tournament configuration - Implemented a new function `validateStageAdvancements` to ensure the integrity of stage advancements in tournaments, including checks for valid indices, types, and configurations. - Enhanced the `isGroupAdvancementReady` function to verify group readiness based on match completion and participant positions. - Updated the tournament management interface to include new validation logic, improving the overall robustness of tournament setup. - Refactored tournament-related components to improve user experience and ensure accurate data handling across the application. --- backend/services/tournamentService.js | 215 +- frontend/src/App.vue | 8 +- .../tournament/TournamentClassSelector.vue | 29 +- .../tournament/TournamentConfigTab.vue | 2295 +++++++++++++++-- .../tournament/TournamentGroupsTab.vue | 313 ++- .../tournament/TournamentParticipantsTab.vue | 934 +++++-- .../tournament/TournamentPlacementsTab.vue | 190 +- .../tournament/TournamentResultsTab.vue | 210 +- frontend/src/i18n/locales/de-CH.json | 222 +- frontend/src/i18n/locales/de-extended.json | 59 +- frontend/src/i18n/locales/de.json | 511 ++-- frontend/src/i18n/locales/en-AU.json | 222 +- frontend/src/i18n/locales/en-GB.json | 222 +- frontend/src/i18n/locales/en-US.json | 222 +- frontend/src/i18n/locales/es.json | 194 +- frontend/src/i18n/locales/fil.json | 194 +- frontend/src/i18n/locales/fr.json | 194 +- frontend/src/i18n/locales/it.json | 194 +- frontend/src/i18n/locales/ja.json | 194 +- frontend/src/i18n/locales/pl.json | 194 +- frontend/src/i18n/locales/th.json | 194 +- frontend/src/i18n/locales/tl.json | 194 +- frontend/src/i18n/locales/zh.json | 194 +- frontend/src/router.js | 2 + frontend/src/views/OfficialTournaments.vue | 377 ++- frontend/src/views/TournamentTab.vue | 1263 ++++++++- frontend/src/views/TournamentsView.vue | 154 +- 27 files changed, 8198 insertions(+), 996 deletions(-) diff --git a/backend/services/tournamentService.js b/backend/services/tournamentService.js index 37e351f0..a383f3e7 100644 --- a/backend/services/tournamentService.js +++ b/backend/services/tournamentService.js @@ -31,6 +31,129 @@ function normalizeJsonConfig(value, label = 'config') { } return {}; } + +function validateStageAdvancements(stages = [], advList = []) { + const normalizedStages = (Array.isArray(stages) ? stages : []) + .filter(stage => stage && stage.index != null && stage.type) + .map(stage => ({ + index: Number(stage.index), + type: stage.type, + numberOfGroups: stage.numberOfGroups != null ? Number(stage.numberOfGroups) : null, + })); + + const stageByIndex = new Map(normalizedStages.map(stage => [stage.index, stage])); + + for (const stage of normalizedStages) { + if (!Number.isInteger(stage.index) || stage.index < 1) { + throw new Error('Stage-Index ist ungültig.'); + } + if (!['groups', 'knockout'].includes(stage.type)) { + throw new Error(`Stage ${stage.index} hat einen ungültigen Typ.`); + } + if (stage.type === 'groups' && (!Number.isInteger(stage.numberOfGroups) || stage.numberOfGroups < 1)) { + throw new Error(`Stage ${stage.index} benötigt mindestens eine Gruppe.`); + } + } + + for (const adv of (Array.isArray(advList) ? advList : [])) { + if (!adv || adv.fromStageIndex == null || adv.toStageIndex == null) continue; + const fromIndex = Number(adv.fromStageIndex); + const toIndex = Number(adv.toStageIndex); + const fromStage = stageByIndex.get(fromIndex); + const toStage = stageByIndex.get(toIndex); + + if (!fromStage || !toStage) { + throw new Error('Advancement verweist auf unbekannte Stages.'); + } + if (toIndex <= fromIndex) { + throw new Error(`Advancement ${fromIndex}→${toIndex} ist ungültig.`); + } + + const config = normalizeJsonConfig(adv.config, 'Advancement-Konfiguration'); + const pools = Array.isArray(config.pools) ? config.pools : []; + if (pools.length === 0) { + throw new Error(`Advancement ${fromIndex}→${toIndex} benötigt mindestens eine Regel.`); + } + + const seenPlaces = new Map(); + for (const [ruleIndex, rule] of pools.entries()) { + const fromPlaces = Array.isArray(rule?.fromPlaces) ? rule.fromPlaces.map(Number) : []; + if (fromPlaces.length === 0) { + throw new Error(`Regel ${ruleIndex + 1} in ${fromIndex}→${toIndex} hat keine Plätze.`); + } + if (!fromPlaces.every(place => Number.isInteger(place) && place > 0)) { + throw new Error(`Regel ${ruleIndex + 1} in ${fromIndex}→${toIndex} hat ungültige Plätze.`); + } + if (new Set(fromPlaces).size !== fromPlaces.length) { + throw new Error(`Regel ${ruleIndex + 1} in ${fromIndex}→${toIndex} enthält doppelte Plätze.`); + } + for (const place of fromPlaces) { + if (seenPlaces.has(place)) { + throw new Error(`Platz ${place} ist in ${fromIndex}→${toIndex} mehrfach vergeben.`); + } + seenPlaces.set(place, ruleIndex + 1); + } + + const target = rule?.target && typeof rule.target === 'object' ? rule.target : {}; + const targetType = target.type; + if (targetType !== toStage.type) { + throw new Error(`Regel ${ruleIndex + 1} in ${fromIndex}→${toIndex} passt nicht zur Zielrunde.`); + } + + const canEstimateQualifiers = fromStage.type === 'groups' && Number.isInteger(fromStage.numberOfGroups) && fromStage.numberOfGroups > 0; + const qualifierCount = canEstimateQualifiers ? fromStage.numberOfGroups * fromPlaces.length : null; + + if (toStage.type === 'groups') { + const targetGroupCount = Number(target.groupCount); + if (!Number.isInteger(targetGroupCount) || targetGroupCount < 1) { + throw new Error(`Regel ${ruleIndex + 1} in ${fromIndex}→${toIndex} benötigt Zielgruppen.`); + } + if (Number.isInteger(toStage.numberOfGroups) && toStage.numberOfGroups > 0 && targetGroupCount !== toStage.numberOfGroups) { + throw new Error(`Regel ${ruleIndex + 1} in ${fromIndex}→${toIndex} hat eine falsche Zielgruppenanzahl.`); + } + if (qualifierCount != null && qualifierCount < targetGroupCount) { + throw new Error(`Regel ${ruleIndex + 1} in ${fromIndex}→${toIndex} hat zu wenige Qualifizierte für die Zielgruppen.`); + } + } + + if (toStage.type === 'knockout' && qualifierCount != null && qualifierCount < 2) { + throw new Error(`Regel ${ruleIndex + 1} in ${fromIndex}→${toIndex} liefert zu wenige Qualifizierte für ein K.-o.-Feld.`); + } + } + } +} + +function isGroupAdvancementReady(group, groupMatches = [], requiredPlaces = []) { + const participants = Array.isArray(group?.participants) ? group.participants : []; + if (participants.length < 2) { + return true; + } + const matchesForGroup = groupMatches.filter(match => Number(match.groupId) === Number(group.groupId ?? group.id)); + if (matchesForGroup.length === 0 || matchesForGroup.some(match => !match.isFinished)) { + return false; + } + const positions = participants + .map(participant => Number(participant.position || 0)) + .filter(position => Number.isInteger(position) && position > 0); + if (positions.length !== participants.length) { + return false; + } + if (new Set(positions).size !== positions.length) { + return false; + } + if (requiredPlaces.length > 0) { + const maxRequiredPlace = Math.max(...requiredPlaces); + if (positions.some(position => position <= 0) || participants.length < maxRequiredPlace) { + return false; + } + for (let place = 1; place <= maxRequiredPlace; place++) { + if (!positions.includes(place)) { + return false; + } + } + } + return true; +} function getRoundName(size) { switch (size) { case 2: return "Finale"; @@ -262,6 +385,8 @@ class TournamentService { ? advancements : (advancement ? [advancement] : []); + validateStageAdvancements(stages, advList); + const createdAdvs = []; for (const adv of advList) { if (!adv || adv.fromStageIndex == null || adv.toStageIndex == null) continue; @@ -358,6 +483,14 @@ class TournamentService { const stage1Groups = await this.getGroupsWithParticipants(userToken, clubId, tournamentId); const relevantStage1Groups = stage1Groups.filter(g => (g.stageId == null) || (g.stageId === fromStage.id)); if (relevantStage1Groups.length === 0) throw new Error('Keine Gruppen in Runde 1 gefunden'); + const sourceGroupIds = relevantStage1Groups.map(group => Number(group.groupId ?? group.id)).filter(Number.isFinite); + const sourceGroupMatches = await TournamentMatch.findAll({ + where: { + tournamentId, + round: 'group', + groupId: sourceGroupIds + } + }); const perGroupRanked = relevantStage1Groups.map(g => ({ groupId: g.groupId, @@ -396,6 +529,15 @@ class TournamentService { } const items = []; + const requiredPlaces = fromPlaces.map(place => Number(place)).filter(Number.isInteger); + const unreadyGroups = relevantStage1Groups.filter(group => + (group.classId ?? null) === classId && + !isGroupAdvancementReady(group, sourceGroupMatches, requiredPlaces) + ); + if (unreadyGroups.length > 0) { + const label = classId == null ? 'ohne Klasse' : `Klasse ${classId}`; + throw new Error(`Runde ${fromStage.index} ist für ${label} noch nicht startklar. Es fehlen fertige Gruppenspiele oder eindeutige Platzierungen.`); + } for (const grp of perGroupRanked) { if ((grp.classId ?? null) !== classId) continue; for (const place of fromPlaces) { @@ -3603,8 +3745,9 @@ Ve // 2. Neues Turnier anlegen if (!tournament || tournament.clubId != clubId) { throw new Error('Turnier nicht gefunden'); } + let tournamentClass = null; if (classId !== null) { - const tournamentClass = await TournamentClass.findOne({ + tournamentClass = await TournamentClass.findOne({ where: { id: classId, tournamentId } }); if (!tournamentClass) { @@ -3618,6 +3761,39 @@ Ve // 2. Neues Turnier anlegen if (!participant) { throw new Error('Externer Teilnehmer nicht gefunden'); } + if (tournamentClass) { + const participantGender = participant.gender || 'unknown'; + if (tournamentClass.gender) { + if (tournamentClass.gender === 'male' && participantGender !== 'male') { + throw new Error('Dieser Teilnehmer kann nicht in einer männlichen Klasse spielen'); + } + if (tournamentClass.gender === 'female' && participantGender !== 'female') { + throw new Error('Dieser Teilnehmer kann nicht in einer weiblichen Klasse spielen'); + } + if (tournamentClass.gender === 'mixed' && participantGender === 'unknown') { + throw new Error('Teilnehmer mit unbekanntem Geschlecht können nicht in einer Mixed-Klasse spielen'); + } + } + if ((tournamentClass.minBirthYear != null || tournamentClass.maxBirthYear != null) && participant.birthDate) { + let birthYear = null; + if (participant.birthDate.includes('-')) { + birthYear = parseInt(participant.birthDate.split('-')[0]); + } else if (participant.birthDate.includes('.')) { + const parts = participant.birthDate.split('.'); + if (parts.length === 3) { + birthYear = parseInt(parts[2]); + } + } + if (birthYear != null && !isNaN(birthYear)) { + if (tournamentClass.minBirthYear != null && birthYear < tournamentClass.minBirthYear) { + throw new Error(`Dieser Teilnehmer ist zu alt für diese Klasse. Erlaubt: geboren ${tournamentClass.minBirthYear} oder später`); + } + if (tournamentClass.maxBirthYear != null && birthYear > tournamentClass.maxBirthYear) { + throw new Error(`Dieser Teilnehmer ist zu jung für diese Klasse. Erlaubt: geboren ${tournamentClass.maxBirthYear} oder früher`); + } + } + } + } participant.classId = classId; await participant.save(); } else { @@ -3627,6 +3803,43 @@ Ve // 2. Neues Turnier anlegen if (!participant) { throw new Error('Teilnehmer nicht gefunden'); } + if (tournamentClass) { + const member = await Member.findByPk(participant.clubMemberId); + if (!member) { + throw new Error('Mitglied nicht gefunden'); + } + const memberGender = member.gender || 'unknown'; + if (tournamentClass.gender) { + if (tournamentClass.gender === 'male' && memberGender !== 'male') { + throw new Error('Dieser Teilnehmer kann nicht in einer männlichen Klasse spielen'); + } + if (tournamentClass.gender === 'female' && memberGender !== 'female') { + throw new Error('Dieser Teilnehmer kann nicht in einer weiblichen Klasse spielen'); + } + if (tournamentClass.gender === 'mixed' && memberGender === 'unknown') { + throw new Error('Teilnehmer mit unbekanntem Geschlecht können nicht in einer Mixed-Klasse spielen'); + } + } + if ((tournamentClass.minBirthYear != null || tournamentClass.maxBirthYear != null) && member.birthDate) { + let birthYear = null; + if (member.birthDate.includes('-')) { + birthYear = parseInt(member.birthDate.split('-')[0]); + } else if (member.birthDate.includes('.')) { + const parts = member.birthDate.split('.'); + if (parts.length === 3) { + birthYear = parseInt(parts[2]); + } + } + if (birthYear != null && !isNaN(birthYear)) { + if (tournamentClass.minBirthYear != null && birthYear < tournamentClass.minBirthYear) { + throw new Error(`Dieser Teilnehmer ist zu alt für diese Klasse. Erlaubt: geboren ${tournamentClass.minBirthYear} oder später`); + } + if (tournamentClass.maxBirthYear != null && birthYear > tournamentClass.maxBirthYear) { + throw new Error(`Dieser Teilnehmer ist zu jung für diese Klasse. Erlaubt: geboren ${tournamentClass.maxBirthYear} oder früher`); + } + } + } + } participant.classId = classId; await participant.save(); } diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 4fe247a4..bc90c72b 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -96,9 +96,13 @@