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