Erlaube das Hinzufügen von Teilnehmern ohne Klasse und normalisiere die Anzahl der Gruppen auf mindestens 1 in der Turnierverwaltung

This commit is contained in:
Torsten Schulz (local)
2025-12-13 12:25:17 +01:00
parent 0c28b12978
commit e83bc250a8
5 changed files with 316 additions and 83 deletions

View File

@@ -36,23 +36,36 @@ export const addTournament = async (req, res) => {
// 3. Teilnehmer hinzufügen - klassengebunden
export const addParticipant = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, classId, participant: participantId } = req.body;
const { clubId, classId, participant: participantId, tournamentId } = req.body;
try {
// Payloads:
// - Mit Klasse (klassengebunden): { clubId, classId, participant }
// - Ohne Klasse (turnierweit): { clubId, tournamentId, participant, classId: null }
if (!participantId) {
return res.status(400).json({ error: 'Teilnehmer-ID ist erforderlich' });
}
if (!classId) {
return res.status(400).json({ error: 'Klasse ist erforderlich' });
// Allow adding a participant either to a specific class (classId) or to the whole tournament (no class)
if (!classId && !tournamentId) {
return res.status(400).json({ error: 'Klasse oder tournamentId ist erforderlich' });
}
await tournamentService.addParticipant(token, clubId, classId, participantId);
// Hole tournamentId über die Klasse
const tournamentClass = await TournamentClass.findByPk(classId);
if (!tournamentClass) {
return res.status(404).json({ error: 'Klasse nicht gefunden' });
// Pass through to service. If classId is present it will be used, otherwise the service should add the participant with classId = null for the given tournamentId
await tournamentService.addParticipant(token, clubId, classId || null, participantId, tournamentId || null);
// Determine tournamentId for response and event emission
let respTournamentId = tournamentId;
if (classId && !respTournamentId) {
const tournamentClass = await TournamentClass.findByPk(classId);
if (!tournamentClass) {
return res.status(404).json({ error: 'Klasse nicht gefunden' });
}
respTournamentId = tournamentClass.tournamentId;
}
const participants = await tournamentService.getParticipants(token, clubId, tournamentClass.tournamentId, classId);
// Fetch updated participants for the (optional) class or whole tournament
const participants = await tournamentService.getParticipants(token, clubId, respTournamentId, classId || null);
// Emit Socket-Event
emitTournamentChanged(clubId, tournamentClass.tournamentId);
if (respTournamentId) emitTournamentChanged(clubId, respTournamentId);
res.status(200).json(participants);
} catch (error) {
console.error('[addParticipant] Error:', error);
@@ -93,7 +106,29 @@ export const createGroups = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, numberOfGroups } = req.body;
try {
await tournamentService.createGroups(token, clubId, tournamentId, numberOfGroups);
// DEBUG: Eingehende Daten sichtbar machen (temporär)
console.log('[tournamentController.createGroups] body:', req.body);
console.log('[tournamentController.createGroups] types:', {
clubId: typeof clubId,
tournamentId: typeof tournamentId,
numberOfGroups: typeof numberOfGroups,
});
// Turniere ohne Klassen: `numberOfGroups: 0` kommt aus der UI (Default) vor.
// Statt „nichts passiert“ normalisieren wir auf mindestens 1 Gruppe.
let normalizedNumberOfGroups = numberOfGroups;
if (normalizedNumberOfGroups !== undefined && normalizedNumberOfGroups !== null) {
const n = Number(normalizedNumberOfGroups);
console.log('[tournamentController.createGroups] parsed numberOfGroups:', n);
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
return res.status(400).json({ error: 'numberOfGroups muss eine ganze Zahl >= 0 sein' });
}
normalizedNumberOfGroups = Math.max(1, n);
}
console.log('[tournamentController.createGroups] normalizedNumberOfGroups:', normalizedNumberOfGroups);
await tournamentService.createGroups(token, clubId, tournamentId, normalizedNumberOfGroups);
// Emit Socket-Event
emitTournamentChanged(clubId, tournamentId);
res.sendStatus(204);

View File

@@ -85,74 +85,113 @@ class TournamentService {
}
// 3. Teilnehmer hinzufügen (kein Duplikat) - klassengebunden
async addParticipant(userToken, clubId, classId, participantId) {
async addParticipant(userToken, clubId, classId, participantId, tournamentId = null) {
await checkAccess(userToken, clubId);
if (!classId) {
throw new Error('Klasse ist erforderlich');
// Normalize case: the third parameter may be either a classId or a tournamentId
if (classId) {
const maybeClass = await TournamentClass.findByPk(classId);
if (!maybeClass) {
const maybeTournament = await Tournament.findByPk(classId);
if (maybeTournament) {
// caller passed tournamentId in this slot
tournamentId = classId;
classId = null;
} else {
throw new Error('Klasse nicht gefunden');
}
}
}
const tournamentClass = await TournamentClass.findByPk(classId);
if (!tournamentClass) {
throw new Error('Klasse nicht gefunden');
// If classId is provided (and resolved to a valid class), do class-specific validations and add
if (classId) {
const tournamentClass = await TournamentClass.findByPk(classId);
if (!tournamentClass) throw new Error('Klasse nicht gefunden');
const tournament = await Tournament.findByPk(tournamentClass.tournamentId);
if (!tournament || tournament.clubId != clubId) {
throw new Error('Turnier nicht gefunden');
}
// Prüfe Geschlecht: Lade Mitglied
const member = await Member.findByPk(participantId);
if (!member) {
throw new Error('Mitglied nicht gefunden');
}
const memberGender = member.gender || 'unknown';
// Validierung: Geschlecht muss zur Klasse passen
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');
}
}
// Validierung: Geburtsjahr muss zur Klasse passen (geboren im Jahr X oder später, also >=)
if (tournamentClass.minBirthYear && member.birthDate) {
// Parse das Geburtsdatum (Format: YYYY-MM-DD oder DD.MM.YYYY)
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 && !isNaN(birthYear)) {
if (birthYear < tournamentClass.minBirthYear) {
throw new Error(`Dieser Teilnehmer ist zu alt für diese Klasse. Erlaubt: geboren ${tournamentClass.minBirthYear} oder später`);
}
}
}
// Prüfe, ob Teilnehmer bereits in dieser Klasse ist
const exists = await TournamentMember.findOne({ where: { classId, clubMemberId: participantId } });
if (exists) {
throw new Error('Teilnehmer bereits hinzugefügt');
}
await TournamentMember.create({
tournamentId: tournamentClass.tournamentId,
classId,
clubMemberId: participantId,
groupId: null
});
return;
}
const tournament = await Tournament.findByPk(tournamentClass.tournamentId);
// If classId is not provided, allow adding participant to the tournament without class
// tournamentId must be provided in this case
if (!tournamentId) {
throw new Error('Klasse oder Turnier-ID ist erforderlich');
}
const tournament = await Tournament.findByPk(tournamentId);
if (!tournament || tournament.clubId != clubId) {
throw new Error('Turnier nicht gefunden');
}
// Prüfe Geschlecht: Lade Mitglied
// Verify member exists
const member = await Member.findByPk(participantId);
if (!member) {
throw new Error('Mitglied nicht gefunden');
}
const memberGender = member.gender || 'unknown';
// Validierung: Geschlecht muss zur Klasse passen
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');
}
// mixed erlaubt alle Geschlechter (male, female, diverse)
}
// Validierung: Geburtsjahr muss zur Klasse passen (geboren im Jahr X oder später, also >=)
if (tournamentClass.minBirthYear && member.birthDate) {
// Parse das Geburtsdatum (Format: YYYY-MM-DD oder DD.MM.YYYY)
let birthYear = null;
if (member.birthDate.includes('-')) {
// Format: YYYY-MM-DD
birthYear = parseInt(member.birthDate.split('-')[0]);
} else if (member.birthDate.includes('.')) {
// Format: DD.MM.YYYY
const parts = member.birthDate.split('.');
if (parts.length === 3) {
birthYear = parseInt(parts[2]);
}
}
if (birthYear && !isNaN(birthYear)) {
// Geboren im Jahr X oder später bedeutet: birthYear >= minBirthYear
if (birthYear < tournamentClass.minBirthYear) {
throw new Error(`Dieser Teilnehmer ist zu alt für diese Klasse. Erlaubt: geboren ${tournamentClass.minBirthYear} oder später`);
}
}
}
// Prüfe, ob Teilnehmer bereits in dieser Klasse ist
const exists = await TournamentMember.findOne({
where: { classId, clubMemberId: participantId }
// Prüfe, ob Teilnehmer bereits im Turnier (ohne Klasse) ist
const existsNoClass = await TournamentMember.findOne({
where: { tournamentId, classId: null, clubMemberId: participantId }
});
if (exists) {
throw new Error('Teilnehmer bereits in dieser Klasse hinzugefügt');
if (existsNoClass) {
throw new Error('Teilnehmer bereits hinzugefügt');
}
await TournamentMember.create({
tournamentId: tournamentClass.tournamentId,
classId,
tournamentId,
classId: null,
clubMemberId: participantId,
groupId: null
});
@@ -197,22 +236,54 @@ class TournamentService {
if (!tournament || tournament.clubId != clubId) {
throw new Error('Turnier nicht gefunden');
}
const desired = numberOfGroups !== null ? numberOfGroups : tournament.numberOfGroups;
// Semantik:
// - null/undefined: nimm den im Turnier gespeicherten Wert
// - 0: UI-Default -> normalisiere auf mindestens 1
// - <0 oder nicht-integer: Fehler
let desired = tournament.numberOfGroups;
if (numberOfGroups !== null && numberOfGroups !== undefined) {
const n = Number(numberOfGroups);
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
throw new Error('numberOfGroups muss eine ganze Zahl >= 0 sein');
}
desired = Math.max(1, n);
}
// DEBUG: Gruppen-Planung sichtbar machen (temporär)
console.log('[tournamentService.createGroups] input:', {
tournamentId,
clubId,
numberOfGroups,
tournamentStoredNumberOfGroups: tournament.numberOfGroups,
desired,
});
const existing = await TournamentGroup.findAll({
where: {
tournamentId,
classId: null // Nur Gruppen ohne Klasse
}
});
console.log('[tournamentService.createGroups] existing groups (classId=null):', existing.map(g => g.id));
// zu viele Gruppen löschen
if (existing.length > desired) {
const toRemove = existing.slice(desired);
console.log('[tournamentService.createGroups] removing groups:', toRemove.map(g => g.id));
await Promise.all(toRemove.map(g => g.destroy()));
}
// fehlende Gruppen anlegen
let createdCount = 0;
for (let i = existing.length; i < desired; i++) {
await TournamentGroup.create({ tournamentId, classId: null });
createdCount++;
}
const finalGroups = await TournamentGroup.findAll({
where: { tournamentId, classId: null },
order: [['id', 'ASC']],
});
console.log('[tournamentService.createGroups] createdCount:', createdCount, 'finalCount:', finalGroups.length, 'finalIds:', finalGroups.map(g => g.id));
}
async createGroupsPerClass(userToken, clubId, tournamentId, groupsPerClass) {
@@ -433,7 +504,9 @@ class TournamentService {
}).length
);
for (let round = 0; round < 10; round++) {
// Wichtig: Gruppengrößen dürfen dabei NICHT aus dem Gleichgewicht geraten.
// Deshalb nur 1:1 Swaps (seeded <-> non-seeded) zwischen Gruppen.
for (let round = 0; round < 20; round++) {
let changed = false;
let maxSeededIdx = 0;
let maxSeededCount = seededCounts[0];
@@ -459,15 +532,30 @@ class TournamentService {
const memberData = memberDataMap.get(item.memberId);
return memberData && memberData.seeded;
});
if (seededInMax.length > 0) {
const itemToMove = seededInMax[0];
groupAssignments[maxSeededIdx] = groupAssignments[maxSeededIdx].filter(item =>
!(item.type === 'member' && item.memberId === itemToMove.memberId)
const nonSeededInMin = groupAssignments[minSeededIdx].filter(item => {
if (item.type !== 'member') return false;
const memberData = memberDataMap.get(item.memberId);
return memberData && !memberData.seeded;
});
if (seededInMax.length > 0 && nonSeededInMin.length > 0) {
const seededToSwap = seededInMax[0];
const nonSeededToSwap = nonSeededInMin[0];
// Swap: seeded wandert in Min, non-seeded wandert in Max
groupAssignments[maxSeededIdx] = groupAssignments[maxSeededIdx].map(item =>
(item.type === 'member' && item.memberId === seededToSwap.memberId) ? nonSeededToSwap : item
);
seededCounts[maxSeededIdx]--;
groupAssignments[minSeededIdx].push(itemToMove);
seededCounts[minSeededIdx]++;
groupAssignments[minSeededIdx] = groupAssignments[minSeededIdx].map(item =>
(item.type === 'member' && item.memberId === nonSeededToSwap.memberId) ? seededToSwap : item
);
seededCounts[maxSeededIdx] -= 1;
seededCounts[minSeededIdx] += 1;
changed = true;
} else {
// Ohne passenden Swap-Kandidaten können wir die Seeded-Verteilung nicht weiter verbessern.
break;
}
}

View File

@@ -9,6 +9,7 @@ vi.mock('../utils/userUtils.js', async () => {
});
import sequelize from '../database.js';
import { Op } from 'sequelize';
import '../models/index.js';
import tournamentService from '../services/tournamentService.js';
@@ -73,4 +74,93 @@ describe('tournamentService', () => {
const matches = await tournamentService.getTournamentMatches('token', club.id, tournament.id);
expect(matches.length).toBeGreaterThan(0);
});
it('erlaubt Teilnehmer ohne Klasse', async () => {
const club = await Club.create({ name: 'Tournament Club' });
const memberA = await createMember(club.id, {
firstName: 'Clara',
lastName: 'C',
email: 'clara@example.com',
gender: 'female',
});
const tournament = await tournamentService.addTournament('token', club.id, 'Sommercup', '2025-06-01');
// ohne Klasse: legacy-Aufruf (3. Argument = tournamentId)
await tournamentService.addParticipant('token', club.id, tournament.id, memberA.id);
await expect(
tournamentService.addParticipant('token', club.id, tournament.id, memberA.id)
).rejects.toThrow('Teilnehmer bereits hinzugefügt');
const participantsNoClass = await tournamentService.getParticipants('token', club.id, tournament.id, null);
expect(participantsNoClass).toHaveLength(1);
expect(participantsNoClass[0].classId).toBe(null);
expect(participantsNoClass[0].clubMemberId).toBe(memberA.id);
});
it('normalisiert numberOfGroups=0 auf mindestens 1 Gruppe', async () => {
const club = await Club.create({ name: 'Tournament Club' });
const tournament = await tournamentService.addTournament('token', club.id, 'Gruppen-Test', '2025-07-01');
await tournamentService.createGroups('token', club.id, tournament.id, 0);
const groups = await TournamentGroup.findAll({ where: { tournamentId: tournament.id } });
expect(groups).toHaveLength(1);
});
it('legt bei numberOfGroups=4 genau 4 Gruppen an (ohne Klassen)', async () => {
const club = await Club.create({ name: 'Tournament Club' });
const tournament = await tournamentService.addTournament('token', club.id, 'Gruppen-4er', '2025-08-01');
await tournamentService.createGroups('token', club.id, tournament.id, 4);
const groups = await TournamentGroup.findAll({ where: { tournamentId: tournament.id } });
expect(groups).toHaveLength(4);
});
it('verteilt bei "zufällig verteilen" möglichst gleichmäßig (Differenz <= 1)', async () => {
const club = await Club.create({ name: 'Tournament Club' });
const tournament = await tournamentService.addTournament('token', club.id, 'Fill-Groups-Balanced', '2025-10-01');
// 10 Teilnehmer, 4 Gruppen => erwartete Größen: 3/3/2/2 (beliebige Reihenfolge)
const members = [];
for (let i = 0; i < 10; i++) {
// createMember Factory braucht eindeutige Emails
members.push(
await createMember(club.id, {
firstName: `P${i}`,
lastName: 'T',
email: `p${i}@example.com`,
gender: i % 2 === 0 ? 'male' : 'female',
})
);
}
for (const m of members) {
await tournamentService.addParticipant('token', club.id, tournament.id, m.id);
}
// Seeded-Balancing triggern: markiere mehrere als gesetzt
// (wir testen hier explizit, dass diese Optimierung die Größen-Balance NICHT kaputt machen darf)
const tmRows = await TournamentMember.findAll({ where: { tournamentId: tournament.id } });
const seededIds = tmRows.slice(0, 5).map(r => r.id);
await TournamentMember.update({ seeded: true }, { where: { id: { [Op.in]: seededIds } } });
await tournamentService.setModus('token', club.id, tournament.id, 'groups', 4, 1);
await tournamentService.createGroups('token', club.id, tournament.id, 4);
await tournamentService.fillGroups('token', club.id, tournament.id);
const groups = await TournamentGroup.findAll({ where: { tournamentId: tournament.id } });
expect(groups).toHaveLength(4);
const membersWithGroups = await TournamentMember.findAll({ where: { tournamentId: tournament.id } });
const countsByGroupId = membersWithGroups.reduce((m, tm) => {
m[tm.groupId] = (m[tm.groupId] || 0) + 1;
return m;
}, {});
const sizes = groups.map(g => countsByGroupId[g.id] || 0);
const min = Math.min(...sizes);
const max = Math.max(...sizes);
expect(max - min).toBeLessThanOrEqual(1);
expect(sizes.reduce((a, b) => a + b, 0)).toBe(10);
});
});

View File

@@ -44,7 +44,13 @@
<div v-else class="groups-per-class">
<label>
{{ $t('tournaments.numberOfGroups') }}:
<input type="number" :value="numberOfGroups" @input="$emit('update:numberOfGroups', parseInt($event.target.value))" min="1" @change="$emit('group-count-change')" />
<input
type="number"
:value="numberOfGroups"
min="1"
@input="$emit('update:numberOfGroups', Math.max(1, parseInt($event.target.value || '1', 10) || 1))"
@change="$emit('group-count-change')"
/>
</label>
</div>

View File

@@ -1045,7 +1045,11 @@ export default {
this.currentTournamentDate = tournament.date || '';
this.currentWinningSets = tournament.winningSets || 3;
this.isGroupTournament = tournament.type === 'groups';
this.numberOfGroups = tournament.numberOfGroups;
// Defensive: Backend/DB kann (historisch/UI-default) 0/null liefern.
// Für gruppenbasierte Turniere ohne Klassen brauchen wir hier aber eine sinnvolle Zahl,
// sonst sendet die UI später wieder `0` an `/tournament/groups`.
const loadedGroups = Number(tournament.numberOfGroups);
this.numberOfGroups = Number.isFinite(loadedGroups) && loadedGroups > 0 ? loadedGroups : 1;
this.advancingPerGroup = tournament.advancingPerGroup;
// Prüfe, ob es einen Trainingstag für das Turnierdatum gibt
@@ -1429,6 +1433,9 @@ export default {
}, {});
const r = await apiClient.post('/tournament/participant', {
clubId: this.currentClub,
// Wenn ohne Klasse hinzugefügt wird, braucht das Backend die Turnier-ID.
// (Bei klassengebundenen Teilnehmern ist sie optional, schadet aber nicht.)
tournamentId: this.selectedDate,
classId: classId,
participant: this.selectedMember
});
@@ -1464,8 +1471,10 @@ export default {
async createGroups() {
try {
// Wenn Klassen vorhanden sind, verwende groupsPerClass
if (this.tournamentClasses.length > 0) {
// Wenn Klassen vorhanden sind, verwende groupsPerClass.
// Achtung: Auch für "Ohne Klasse" (selectedViewClass='__none__') ist das der richtige Pfad,
// sonst fällt die UI in den alten Fallback und sendet `numberOfGroups` (häufig 1).
if (this.tournamentClasses.length > 0 || this.selectedViewClass === '__none__') {
await apiClient.post('/tournament/groups/create', {
clubId: this.currentClub,
tournamentId: this.selectedDate,
@@ -1473,10 +1482,11 @@ export default {
});
} else {
// Fallback: Verwende numberOfGroups wie bisher
const desired = Math.max(1, parseInt(String(this.numberOfGroups), 10) || 1);
await apiClient.put('/tournament/groups', {
clubId: this.currentClub,
tournamentId: this.selectedDate,
numberOfGroups: this.numberOfGroups
numberOfGroups: desired
});
}
await this.loadTournamentData();
@@ -1666,11 +1676,12 @@ export default {
async onModusChange() {
const type = this.isGroupTournament ? 'groups' : 'knockout';
const desired = Math.max(1, parseInt(String(this.numberOfGroups), 10) || 1);
await apiClient.post('/tournament/modus', {
clubId: this.currentClub,
tournamentId: this.selectedDate,
type,
numberOfGroups: this.numberOfGroups,
numberOfGroups: desired,
advancingPerGroup: this.advancingPerGroup
});
await this.loadTournamentData();
@@ -1725,7 +1736,8 @@ export default {
async onGroupCountChange() {
// Wenn Klassen vorhanden sind, speichere groupsPerClass, sonst numberOfGroups
if (this.tournamentClasses.length > 0) {
// Hinweis: Bei "Ohne Klasse" wird groupsPerClass['null'] genutzt, auch wenn Klassen existieren.
if (this.tournamentClasses.length > 0 || this.selectedViewClass === '__none__') {
// Speichere groupsPerClass für die aktuelle Klasse
// Die Werte sind bereits in this.groupsPerClass gespeichert durch den setter von groupsPerClassInput
// Wir müssen nichts speichern, da groupsPerClass nur lokal verwendet wird
@@ -1733,11 +1745,12 @@ export default {
return;
} else {
// Fallback: Verwende numberOfGroups wie bisher
const desired = Math.max(1, parseInt(String(this.numberOfGroups), 10) || 1);
await apiClient.post('/tournament/modus', {
clubId: this.currentClub,
tournamentId: this.selectedDate,
type: this.isGroupTournament ? 'groups' : 'knockout',
numberOfGroups: this.numberOfGroups
numberOfGroups: desired
});
await this.loadTournamentData();
}
@@ -2407,6 +2420,7 @@ export default {
}
await apiClient.post('/tournament/participant', {
clubId: this.currentClub,
tournamentId: this.selectedDate,
classId: classId,
participant: participant.clubMemberId
});