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