Add participant assignment to groups functionality

Implement a new endpoint to assign participants to specific groups within tournaments. This includes the addition of the `assignParticipantToGroup` function in the tournament controller, which handles the assignment logic and emits relevant events. Update the tournament routes to include this new functionality. Enhance the tournament service to manage group assignments and ensure proper statistics are calculated for participants. Additionally, update the frontend to support adding participants, including external ones, and reflect changes in the UI for group assignments.
This commit is contained in:
Torsten Schulz (local)
2025-11-23 17:09:41 +01:00
parent f7a799ea7f
commit e6146b8f5a
5 changed files with 472 additions and 210 deletions

View File

@@ -253,6 +253,28 @@ export const manualAssignGroups = async (req, res) => {
}
};
export const assignParticipantToGroup = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, participantId, groupNumber, isExternal } = req.body;
try {
const groups = await tournamentService.assignParticipantToGroup(
token,
clubId,
tournamentId,
participantId,
groupNumber,
isExternal || false
);
// Emit Socket-Event
emitTournamentChanged(clubId, tournamentId);
res.status(200).json(groups);
} catch (error) {
console.error('Error in assignParticipantToGroup:', error);
res.status(500).json({ error: error.message });
}
};
export const resetGroups = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId } = req.body;

View File

@@ -33,6 +33,7 @@ import {
deleteTournamentClass,
updateParticipantClass,
createGroupsPerClass,
assignParticipantToGroup,
} from '../controllers/tournamentController.js';
import { authenticate } from '../middleware/authMiddleware.js';
@@ -55,12 +56,13 @@ router.post("/match/reopen", authenticate, reopenMatch);
router.post('/match/finish', authenticate, finishMatch);
router.put('/match/:clubId/:tournamentId/:matchId/active', authenticate, setMatchActive);
router.get('/matches/:clubId/:tournamentId', authenticate, getTournamentMatches);
router.put('/:clubId/:tournamentId', authenticate, updateTournament);
router.get('/:clubId/:tournamentId', authenticate, getTournament);
router.get('/:clubId', authenticate, getTournaments);
router.post('/knockout', authenticate, startKnockout);
router.delete("/matches/knockout", authenticate, deleteKnockoutMatches);
router.post('/groups/manual', authenticate, manualAssignGroups);
router.put('/participant/group', authenticate, assignParticipantToGroup); // Muss VOR /:clubId/:tournamentId stehen!
router.put('/:clubId/:tournamentId', authenticate, updateTournament);
router.get('/:clubId/:tournamentId', authenticate, getTournament);
router.get('/:clubId', authenticate, getTournaments);
router.post('/', authenticate, addTournament);
// Externe Teilnehmer

View File

@@ -497,6 +497,12 @@ class TournamentService {
]
});
// Lade alle Gruppen-Matches mit Results für Rankings
const groupMatches = await TournamentMatch.findAll({
where: { tournamentId, round: 'group', isFinished: true },
include: [{ model: TournamentResult, as: 'tournamentResults' }]
});
// Gruppiere nach Klassen und nummeriere Gruppen pro Klasse
const groupsByClass = {};
groups.forEach(g => {
@@ -510,25 +516,156 @@ class TournamentService {
const result = [];
for (const [classKey, classGroups] of Object.entries(groupsByClass)) {
classGroups.forEach((g, idx) => {
const internalParticipants = (g.tournamentGroupMembers || []).map(m => ({
id: m.id,
name: `${m.member.firstName} ${m.member.lastName}`,
seeded: m.seeded || false,
isExternal: false
}));
// Berechne Rankings für diese Gruppe
const stats = {};
const externalParticipants = (g.externalGroupMembers || []).map(m => ({
id: m.id,
name: `${m.firstName} ${m.lastName}`,
seeded: m.seeded || false,
isExternal: true
}));
// Interne Teilnehmer
for (const tm of g.tournamentGroupMembers || []) {
stats[tm.id] = {
id: tm.id,
name: `${tm.member.firstName} ${tm.member.lastName}`,
seeded: tm.seeded || false,
isExternal: false,
points: 0,
setsWon: 0,
setsLost: 0,
pointsWon: 0,
pointsLost: 0,
pointRatio: 0
};
}
// Externe Teilnehmer
for (const ext of g.externalGroupMembers || []) {
stats[ext.id] = {
id: ext.id,
name: `${ext.firstName} ${ext.lastName}`,
seeded: ext.seeded || false,
isExternal: true,
points: 0,
setsWon: 0,
setsLost: 0,
pointsWon: 0,
pointsLost: 0,
pointRatio: 0
};
}
// Berechne Statistiken aus Matches
for (const m of groupMatches.filter(m => m.groupId === g.id)) {
if (!stats[m.player1Id] || !stats[m.player2Id]) continue;
const [s1, s2] = m.result.split(':').map(n => parseInt(n, 10));
if (s1 > s2) {
stats[m.player1Id].points += 1; // Sieger bekommt +1
stats[m.player2Id].points -= 1; // Verlierer bekommt -1
} else if (s2 > s1) {
stats[m.player2Id].points += 1; // Sieger bekommt +1
stats[m.player1Id].points -= 1; // Verlierer bekommt -1
}
stats[m.player1Id].setsWon += s1;
stats[m.player1Id].setsLost += s2;
stats[m.player2Id].setsWon += s2;
stats[m.player2Id].setsLost += s1;
// Berechne gespielte Punkte aus tournamentResults
if (m.tournamentResults && m.tournamentResults.length > 0) {
let p1Points = 0, p2Points = 0;
for (const r of m.tournamentResults) {
p1Points += r.pointsPlayer1 || 0;
p2Points += r.pointsPlayer2 || 0;
}
stats[m.player1Id].pointsWon += p1Points;
stats[m.player1Id].pointsLost += p2Points;
stats[m.player2Id].pointsWon += p2Points;
stats[m.player2Id].pointsLost += p1Points;
}
}
// Berechne Punktverhältnis und absolute Differenz für jeden Spieler
Object.values(stats).forEach(s => {
const totalPoints = s.pointsWon + s.pointsLost;
s.pointRatio = totalPoints > 0 ? s.pointsWon / totalPoints : 0;
s.setDiff = s.setsWon - s.setsLost;
s.pointsDiff = s.pointsWon - s.pointsLost; // Absolute Differenz der gespielten Punkte
});
// Sortiere nach: Punkte -> Satzverhältnis -> mehr gewonnene Sätze -> absolute Punktdifferenz -> mehr erzielte Spielpunkte -> direkter Vergleich
const ranked = Object.values(stats).sort((a, b) => {
// 1. Beste Punkte
if (b.points !== a.points) return b.points - a.points;
// 2. Besseres Satzverhältnis
if (b.setDiff !== a.setDiff) return b.setDiff - a.setDiff;
// 3. Bei Satzgleichheit: Wer mehr Sätze gewonnen hat
if (b.setsWon !== a.setsWon) return b.setsWon - a.setsWon;
// 4. Bessere absolute Differenz der gespielten Punkte (höhere Differenz zuerst)
if (b.pointsDiff !== a.pointsDiff) return b.pointsDiff - a.pointsDiff;
// 5. Bei Spielpunktgleichheit: Wer mehr Spielpunkte erzielt hat
if (b.pointsWon !== a.pointsWon) return b.pointsWon - a.pointsWon;
// 6. Direkter Vergleich (Sieger weiter oben)
const directMatch = groupMatches.find(m =>
m.groupId === g.id &&
((m.player1Id === a.id && m.player2Id === b.id) ||
(m.player1Id === b.id && m.player2Id === a.id))
);
if (directMatch) {
const [s1, s2] = directMatch.result.split(':').map(n => parseInt(n, 10));
const aWon = (directMatch.player1Id === a.id && s1 > s2) ||
(directMatch.player2Id === a.id && s2 > s1);
if (aWon) return -1; // a hat gewonnen -> a kommt weiter oben
return 1; // b hat gewonnen -> b kommt weiter oben
}
// Fallback: Alphabetisch nach Name
return a.name.localeCompare(b.name);
});
// Weise Positionen zu, wobei Spieler mit identischen Werten den gleichen Platz bekommen
let currentPosition = 1;
const participantsWithPosition = ranked.map((p, i) => {
if (i > 0) {
const prev = ranked[i - 1];
// Prüfe, ob alle Sortierkriterien identisch sind
const pointsEqual = prev.points === p.points;
const setDiffEqual = prev.setDiff === p.setDiff;
const setsWonEqual = prev.setsWon === p.setsWon;
const pointsDiffEqual = prev.pointsDiff === p.pointsDiff;
const pointsWonEqual = prev.pointsWon === p.pointsWon;
if (pointsEqual && setDiffEqual && setsWonEqual && pointsDiffEqual && pointsWonEqual) {
// Prüfe direkten Vergleich
const directMatch = groupMatches.find(m =>
m.groupId === g.id &&
((m.player1Id === prev.id && m.player2Id === p.id) ||
(m.player1Id === p.id && m.player2Id === prev.id))
);
if (!directMatch || directMatch.result.split(':').map(n => +n)[0] === directMatch.result.split(':').map(n => +n)[1]) {
// Gleicher Platz wie Vorgänger (unentschieden oder kein direktes Match)
return {
...p,
position: currentPosition,
setDiff: p.setDiff,
pointsDiff: p.pointsDiff
};
}
}
}
// Neuer Platz
currentPosition = i + 1;
return {
...p,
position: currentPosition,
setDiff: p.setDiff,
pointsDiff: p.pointsDiff
};
});
result.push({
groupId: g.id,
classId: g.classId,
groupNumber: idx + 1, // Nummer innerhalb der Klasse
participants: [...internalParticipants, ...externalParticipants]
participants: participantsWithPosition
});
});
}
@@ -752,7 +889,8 @@ class TournamentService {
]
});
const groupMatches = await TournamentMatch.findAll({
where: { tournamentId, round: "group", isFinished: true }
where: { tournamentId, round: "group", isFinished: true },
include: [{ model: TournamentResult, as: "tournamentResults" }]
});
const qualifiers = [];
@@ -760,11 +898,11 @@ class TournamentService {
const stats = {};
// Interne Teilnehmer
for (const tm of g.tournamentGroupMembers || []) {
stats[tm.id] = { member: tm, points: 0, setsWon: 0, setsLost: 0, isExternal: false };
stats[tm.id] = { member: tm, points: 0, setsWon: 0, setsLost: 0, pointsWon: 0, pointsLost: 0, isExternal: false };
}
// Externe Teilnehmer
for (const ext of g.externalGroupMembers || []) {
stats[ext.id] = { member: ext, points: 0, setsWon: 0, setsLost: 0, isExternal: true };
stats[ext.id] = { member: ext, points: 0, setsWon: 0, setsLost: 0, pointsWon: 0, pointsLost: 0, isExternal: true };
}
for (const m of groupMatches.filter(m => m.groupId === g.id)) {
if (!stats[m.player1Id] || !stats[m.player2Id]) continue;
@@ -780,13 +918,55 @@ class TournamentService {
stats[m.player1Id].setsLost += p2;
stats[m.player2Id].setsWon += p2;
stats[m.player2Id].setsLost += p1;
// Berechne gespielte Punkte aus tournamentResults
if (m.tournamentResults && m.tournamentResults.length > 0) {
let p1Points = 0, p2Points = 0;
for (const r of m.tournamentResults) {
p1Points += r.pointsPlayer1 || 0;
p2Points += r.pointsPlayer2 || 0;
}
stats[m.player1Id].pointsWon += p1Points;
stats[m.player1Id].pointsLost += p2Points;
stats[m.player2Id].pointsWon += p2Points;
stats[m.player2Id].pointsLost += p1Points;
}
}
// Berechne Punktverhältnis und absolute Differenz für jeden Spieler
Object.values(stats).forEach(s => {
const totalPoints = s.pointsWon + s.pointsLost;
s.pointRatio = totalPoints > 0 ? s.pointsWon / totalPoints : 0;
s.pointsDiff = s.pointsWon - s.pointsLost; // Absolute Differenz der gespielten Punkte
});
const ranked = Object.values(stats).sort((a, b) => {
const diffA = a.setsWon - a.setsLost;
const diffB = b.setsWon - b.setsLost;
// 1. Beste Punkte
if (b.points !== a.points) return b.points - a.points;
// 2. Besseres Satzverhältnis
if (diffB !== diffA) return diffB - diffA;
// 3. Bei Satzgleichheit: Wer mehr Sätze gewonnen hat
if (b.setsWon !== a.setsWon) return b.setsWon - a.setsWon;
// 4. Bessere absolute Differenz der gespielten Punkte (höhere Differenz zuerst)
if (b.pointsDiff !== a.pointsDiff) return b.pointsDiff - a.pointsDiff;
// 5. Bei Spielpunktgleichheit: Wer mehr Spielpunkte erzielt hat
if (b.pointsWon !== a.pointsWon) return b.pointsWon - a.pointsWon;
// 6. Direkter Vergleich (Sieger weiter oben)
const directMatch = groupMatches.find(m =>
m.groupId === g.id &&
((m.player1Id === a.member.id && m.player2Id === b.member.id) ||
(m.player1Id === b.member.id && m.player2Id === a.member.id))
);
if (directMatch) {
const [s1, s2] = directMatch.result.split(":").map(n => parseInt(n, 10));
const aWon = (directMatch.player1Id === a.member.id && s1 > s2) ||
(directMatch.player2Id === a.member.id && s2 > s1);
if (aWon) return -1; // a hat gewonnen -> a kommt weiter oben
return 1; // b hat gewonnen -> b kommt weiter oben
}
// Fallback: Nach ID
return a.member.id - b.member.id;
});
// Füge classId zur Gruppe hinzu
@@ -903,8 +1083,25 @@ class TournamentService {
throw new Error('Keine Teilnehmer zum Verteilen');
}
// 2) Bestimme, wie viele Gruppen wir anlegen
let groupCount;
// 2) Hole bestehende Gruppen und erstelle nur neue, wenn nötig
const existingGroups = await TournamentGroup.findAll({
where: { tournamentId },
order: [['id', 'ASC']]
});
// Berechne die maximale groupNumber aus den assignments
const maxGroupNumber = assignments.reduce((max, a) => {
if (a.groupNumber != null && a.groupNumber > max) {
return a.groupNumber;
}
return max;
}, 0);
// Erstelle nur neue Gruppen, wenn die maximale groupNumber größer ist als die Anzahl der bestehenden Gruppen
const existingGroupCount = existingGroups.length;
let groupCount = Math.max(existingGroupCount, maxGroupNumber);
// Wenn numberOfGroups oder maxGroupSize gesetzt sind, verwende diese
if (numberOfGroups != null) {
groupCount = Number(numberOfGroups);
if (isNaN(groupCount) || groupCount < 1) {
@@ -915,21 +1112,17 @@ class TournamentService {
if (isNaN(sz) || sz < 1) {
throw new Error('Ungültige maximale Gruppengröße');
}
groupCount = Math.ceil(totalMembers / sz);
} else {
// Fallback auf im Turnier gespeicherte Anzahl
groupCount = tournament.numberOfGroups;
if (!groupCount || groupCount < 1) {
throw new Error('Anzahl Gruppen nicht definiert');
}
groupCount = Math.max(groupCount, Math.ceil(totalMembers / sz));
}
console.log(`[manualAssignGroups] Existing groups: ${existingGroupCount}, maxGroupNumber: ${maxGroupNumber}, target groupCount: ${groupCount}`);
// 3) Alte Gruppen löschen und neue anlegen
await TournamentGroup.destroy({ where: { tournamentId } });
const createdGroups = [];
for (let i = 0; i < groupCount; i++) {
// 3) Erstelle nur fehlende Gruppen
const createdGroups = [...existingGroups];
for (let i = existingGroupCount; i < groupCount; i++) {
const grp = await TournamentGroup.create({ tournamentId });
createdGroups.push(grp);
console.log(`[manualAssignGroups] Created new group ${i + 1} with id ${grp.id}`);
}
// 4) Mapping von UINummer (1…groupCount) auf reale DBID
@@ -937,77 +1130,142 @@ class TournamentService {
createdGroups.forEach((grp, idx) => {
groupMap[idx + 1] = grp.id;
});
console.log(`[manualAssignGroups] Group mapping:`, groupMap);
// 5) Teilnehmer updaten (sowohl interne als auch externe)
await Promise.all(
assignments.map(async ({ participantId, groupNumber }) => {
const dbGroupId = groupMap[groupNumber];
if (!dbGroupId) {
assignments.map(async ({ participantId, groupNumber, isExternal }) => {
console.log(`[manualAssignGroups] Processing assignment: participantId=${participantId}, groupNumber=${groupNumber}, isExternal=${isExternal}`);
console.log(`[manualAssignGroups] Group map:`, JSON.stringify(groupMap));
// Wenn groupNumber null ist, entferne den Teilnehmer aus der Gruppe (groupId = null)
const dbGroupId = groupNumber != null ? groupMap[groupNumber] : null;
console.log(`[manualAssignGroups] Calculated dbGroupId: ${dbGroupId} (from groupNumber ${groupNumber})`);
if (groupNumber != null && !dbGroupId) {
throw new Error(`Ungültige GruppenNummer: ${groupNumber}`);
}
// Prüfe zuerst, ob es ein interner Teilnehmer ist
const internalMember = await TournamentMember.findOne({
console.log(`[manualAssignGroups] Updating participant ${participantId}, isExternal: ${isExternal}, groupNumber: ${groupNumber}, dbGroupId: ${dbGroupId}`);
// Prüfe zuerst, ob es ein interner Teilnehmer ist (wenn isExternal nicht explizit true ist)
if (isExternal !== true) {
const internalMember = await TournamentMember.findOne({
where: { id: participantId, tournamentId }
});
if (internalMember) {
// Interner Teilnehmer
console.log(`[manualAssignGroups] Updating internal member id=${participantId} (memberId=${internalMember.memberId}) to groupId ${dbGroupId}`);
await TournamentMember.update(
{ groupId: dbGroupId },
{ where: { id: participantId, tournamentId } }
);
return;
}
}
// Versuche externen Teilnehmer
const externalMember = await ExternalTournamentParticipant.findOne({
where: { id: participantId, tournamentId }
});
if (internalMember) {
// Interner Teilnehmer
return TournamentMember.update(
if (externalMember) {
// Externer Teilnehmer
console.log(`[manualAssignGroups] Updating external member id=${participantId} to groupId ${dbGroupId}`);
await ExternalTournamentParticipant.update(
{ groupId: dbGroupId },
{ where: { id: participantId, tournamentId } }
);
} else {
// Versuche externen Teilnehmer
const externalMember = await ExternalTournamentParticipant.findOne({
where: { id: participantId, tournamentId }
});
if (externalMember) {
// Externer Teilnehmer
return ExternalTournamentParticipant.update(
{ groupId: dbGroupId },
{ where: { id: participantId, tournamentId } }
);
} else {
throw new Error(`Teilnehmer mit ID ${participantId} nicht gefunden`);
}
throw new Error(`Teilnehmer mit ID ${participantId} nicht gefunden`);
}
})
);
// 6) Ergebnis zurückliefern wie getGroupsWithParticipants
const groups = await TournamentGroup.findAll({
// 6) Ergebnis zurückliefern wie getGroupsWithParticipants (mit vollständigen Rankings)
// Verwende die gleiche Methode wie getGroupsWithParticipants, um konsistente Daten zu liefern
return await this.getGroupsWithParticipants(userToken, clubId, tournamentId);
}
/**
* Ordne einen einzelnen Teilnehmer einer Gruppe zu
* @param {string} userToken - User authentication token
* @param {number} clubId - Club ID
* @param {number} tournamentId - Tournament ID
* @param {number} participantId - Participant ID
* @param {number|null} groupNumber - Group number (1-based) or null to remove from group
* @param {boolean} isExternal - Whether the participant is external
* @returns {Promise<Object>} Updated groups with participants
*/
async assignParticipantToGroup(userToken, clubId, tournamentId, participantId, groupNumber, isExternal = false) {
console.log(`[assignParticipantToGroup] Called with: participantId=${participantId}, groupNumber=${groupNumber}, isExternal=${isExternal}, tournamentId=${tournamentId}, clubId=${clubId}`);
await checkAccess(userToken, clubId);
const tournament = await Tournament.findByPk(tournamentId);
if (!tournament || tournament.clubId != clubId) {
throw new Error('Turnier nicht gefunden');
}
// Hole bestehende Gruppen
const existingGroups = await TournamentGroup.findAll({
where: { tournamentId },
include: [{
model: TournamentMember,
as: 'tournamentGroupMembers',
include: [{ model: Member, as: 'member', attributes: ['id', 'firstName', 'lastName'] }]
}, {
model: ExternalTournamentParticipant,
as: 'externalGroupMembers'
}],
order: [['id', 'ASC']]
});
console.log(`[assignParticipantToGroup] Found ${existingGroups.length} existing groups`);
return groups.map(g => {
const internalParticipants = (g.tournamentGroupMembers || []).map(m => ({
id: m.id,
name: `${m.member.firstName} ${m.member.lastName}`,
isExternal: false
}));
// Berechne dbGroupId
let dbGroupId = null;
if (groupNumber != null) {
// Stelle sicher, dass genug Gruppen existieren
if (groupNumber > existingGroups.length) {
console.log(`[assignParticipantToGroup] Creating ${groupNumber - existingGroups.length} new groups`);
// Erstelle fehlende Gruppen
for (let i = existingGroups.length; i < groupNumber; i++) {
const grp = await TournamentGroup.create({ tournamentId });
existingGroups.push(grp);
}
}
// Mapping von groupNumber (1-based) zu groupId
dbGroupId = existingGroups[groupNumber - 1].id;
console.log(`[assignParticipantToGroup] Mapped groupNumber ${groupNumber} to dbGroupId ${dbGroupId}`);
}
// Aktualisiere den Teilnehmer
if (isExternal) {
const externalMember = await ExternalTournamentParticipant.findOne({
where: { id: participantId, tournamentId }
});
const externalParticipants = (g.externalGroupMembers || []).map(m => ({
id: m.id,
name: `${m.firstName} ${m.lastName}`,
isExternal: true
}));
if (!externalMember) {
throw new Error(`Externer Teilnehmer mit ID ${participantId} nicht gefunden`);
}
return {
groupId: g.id,
participants: [...internalParticipants, ...externalParticipants]
};
});
console.log(`[assignParticipantToGroup] Updating external member ${participantId} to groupId ${dbGroupId}`);
await ExternalTournamentParticipant.update(
{ groupId: dbGroupId },
{ where: { id: participantId, tournamentId } }
);
} else {
const internalMember = await TournamentMember.findOne({
where: { id: participantId, tournamentId }
});
if (!internalMember) {
throw new Error(`Interner Teilnehmer mit ID ${participantId} nicht gefunden`);
}
console.log(`[assignParticipantToGroup] Updating internal member ${participantId} (memberId=${internalMember.memberId}) to groupId ${dbGroupId}`);
await TournamentMember.update(
{ groupId: dbGroupId },
{ where: { id: participantId, tournamentId } }
);
}
console.log(`[assignParticipantToGroup] Successfully updated participant, loading groups with participants...`);
// Lade aktualisierte Gruppen mit Teilnehmern zurück
return await this.getGroupsWithParticipants(userToken, clubId, tournamentId);
}
// services/tournamentService.js