diff --git a/backend/controllers/tournamentController.js b/backend/controllers/tournamentController.js index ab06d55..e452ca7 100644 --- a/backend/controllers/tournamentController.js +++ b/backend/controllers/tournamentController.js @@ -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); diff --git a/backend/services/tournamentService.js b/backend/services/tournamentService.js index d33c8e0..d349546 100644 --- a/backend/services/tournamentService.js +++ b/backend/services/tournamentService.js @@ -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; } } diff --git a/backend/tests/tournamentService.test.js b/backend/tests/tournamentService.test.js index 89c4c40..4f80869 100644 --- a/backend/tests/tournamentService.test.js +++ b/backend/tests/tournamentService.test.js @@ -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); + }); }); diff --git a/frontend/src/components/tournament/TournamentGroupsTab.vue b/frontend/src/components/tournament/TournamentGroupsTab.vue index e5cbf0c..5af259d 100644 --- a/frontend/src/components/tournament/TournamentGroupsTab.vue +++ b/frontend/src/components/tournament/TournamentGroupsTab.vue @@ -44,7 +44,13 @@
diff --git a/frontend/src/views/TournamentTab.vue b/frontend/src/views/TournamentTab.vue index a9ed556..c6ef800 100644 --- a/frontend/src/views/TournamentTab.vue +++ b/frontend/src/views/TournamentTab.vue @@ -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 });