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.
This commit is contained in:
Torsten Schulz (local)
2026-03-16 22:17:37 +01:00
parent e1dccb6ff0
commit 43f96b2491
27 changed files with 8198 additions and 996 deletions

View File

@@ -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();
}