feat(tournament): implement class merging and pool management features

- Added mergeClassesIntoPool and resetPool functions in tournamentService to handle merging classes into a common pool and resetting pool assignments.
- Introduced new API routes for merging and resetting pools in tournamentRoutes.
- Enhanced TournamentGroupsTab component with UI for merging classes, including selection for source and target classes, strategy options, and out-of-competition settings.
- Updated localization files to include new strings related to class merging functionality.
- Modified TournamentTab to handle merge pool events and manage API interactions for merging classes.
This commit is contained in:
Torsten Schulz (local)
2026-01-07 12:10:33 +01:00
parent e94a12cd20
commit fea84e210a
10 changed files with 315 additions and 2 deletions

View File

@@ -4,6 +4,42 @@ import { emitTournamentChanged } from '../services/socketService.js';
import TournamentClass from '../models/TournamentClass.js';
import HttpError from '../exceptions/HttpError.js';
// Pools (zusammengelegte Gruppenphasen)
export const mergeClassesIntoPool = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, sourceClassId, targetClassId, strategy, outOfCompetitionForSource } = req.body;
try {
await tournamentService.mergeClassesIntoPool(
token,
clubId,
tournamentId,
sourceClassId,
targetClassId,
strategy, // 'singleGroup' | 'distribute'
!!outOfCompetitionForSource
);
// Broadcast
emitTournamentChanged(clubId, tournamentId);
res.status(200).json({ success: true });
} catch (error) {
console.error('[mergeClassesIntoPool] Error:', error);
res.status(500).json({ error: error.message });
}
};
export const resetPool = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, poolId } = req.body;
try {
await tournamentService.resetPool(token, clubId, tournamentId, poolId);
emitTournamentChanged(clubId, tournamentId);
res.status(200).json({ success: true });
} catch (error) {
console.error('[resetPool] Error:', error);
res.status(500).json({ error: error.message });
}
};
// 1. Alle Turniere eines Vereins
export const getTournaments = async (req, res) => {
const { authcode: token } = req.headers;

View File

@@ -0,0 +1,11 @@
-- Add pool_id to tournament_group for pooled group phases
ALTER TABLE `tournament_group`
ADD COLUMN `pool_id` INT NULL AFTER `class_id`;
-- Add out_of_competition flags
ALTER TABLE `tournament_member`
ADD COLUMN `out_of_competition` TINYINT(1) NOT NULL DEFAULT 0 AFTER `class_id`;
ALTER TABLE `external_tournament_participant`
ADD COLUMN `out_of_competition` TINYINT(1) NOT NULL DEFAULT 0 AFTER `class_id`;

View File

@@ -83,6 +83,11 @@ const ExternalTournamentParticipant = sequelize.define('ExternalTournamentPartic
classId: {
type: DataTypes.INTEGER,
allowNull: true
},
outOfCompetition: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
}
}, {
underscored: true,

View File

@@ -20,6 +20,10 @@ const TournamentGroup = sequelize.define('TournamentGroup', {
type: DataTypes.INTEGER,
allowNull: true
},
poolId: {
type: DataTypes.INTEGER,
allowNull: true
},
}, {
underscored: true,
tableName: 'tournament_group',

View File

@@ -25,6 +25,11 @@ const TournamentMember = sequelize.define('TournamentMember', {
classId: {
type: DataTypes.INTEGER,
allowNull: true
},
outOfCompetition: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
}
}, {
underscored: true,

View File

@@ -40,6 +40,7 @@ import {
updatePairing,
deletePairing,
} from '../controllers/tournamentController.js';
import { mergeClassesIntoPool, resetPool } from '../controllers/tournamentController.js';
import {
getStages,
upsertStages,
@@ -60,6 +61,9 @@ router.put('/groups', authenticate, createGroups);
router.post('/groups/create', authenticate, createGroupsPerClass);
router.post('/groups', authenticate, fillGroups);
router.post('/matches/create', authenticate, createGroupMatches);
// Pools
router.post('/pools/merge', authenticate, mergeClassesIntoPool);
router.post('/pools/reset', authenticate, resetPool);
router.get('/groups', authenticate, getGroups);
router.post('/match/result', authenticate, addMatchResult);
router.delete('/match/result', authenticate, deleteMatchResult);

View File

@@ -96,6 +96,117 @@ function nextPowerOfTwo(n) {
const THIRD_PLACE_ROUND = 'Spiel um Platz 3';
class TournamentService {
/**
* Klassen in einem gemeinsamen Pool zusammenführen.
* strategy: 'singleGroup' (alle A-Spieler in eine neue Gruppe der Zielklasse) |
* 'distribute' (A-Spieler gleichmäßig auf Ziel-Gruppen verteilen)
*/
async mergeClassesIntoPool(userToken, clubId, tournamentId, sourceClassId, targetClassId, strategy = 'distribute', outOfCompetitionForSource = false) {
await checkAccess(userToken, clubId);
const tournament = await Tournament.findByPk(tournamentId);
if (!tournament || tournament.clubId != clubId) {
throw new Error('Turnier nicht gefunden');
}
if (!sourceClassId || !targetClassId) {
throw new Error('sourceClassId und targetClassId sind erforderlich');
}
if (String(sourceClassId) === String(targetClassId)) {
throw new Error('Quelle und Zielklasse dürfen nicht identisch sein');
}
// Erzeuge eine neue poolId (einfacher Ansatz)
const maxExisting = await TournamentGroup.max('poolId', { where: { tournamentId } });
const poolId = Number.isFinite(Number(maxExisting)) ? Number(maxExisting) + 1 : 1;
// Lade Ziel-Gruppen
const targetGroups = await TournamentGroup.findAll({
where: { tournamentId, classId: targetClassId },
order: [['id', 'ASC']]
});
if (!targetGroups || targetGroups.length === 0) {
throw new Error('Zielklasse hat keine Gruppen. Bitte zuerst Gruppen anlegen.');
}
// Markiere Zielgruppen mit poolId
for (const g of targetGroups) {
g.poolId = poolId;
await g.save();
}
// Lade Teilnehmer der Quellklasse (intern + extern)
const sourceInternals = await TournamentMember.findAll({ where: { tournamentId, classId: sourceClassId } });
const sourceExternals = await ExternalTournamentParticipant.findAll({ where: { tournamentId, classId: sourceClassId } });
// Optional: „außer Konkurrenz“ für Source markieren
if (outOfCompetitionForSource) {
await Promise.all([
...sourceInternals.map(m => { m.outOfCompetition = true; return m.save(); }),
...sourceExternals.map(e => { e.outOfCompetition = true; return e.save(); })
]);
}
if (strategy === 'singleGroup') {
// Lege eine zusätzliche Gruppe in der Zielklasse an, nur für Source-Spieler
// Bestimme groupNumber: max + 1
const maxGroupNumber = await TournamentGroup.max('id', { where: { tournamentId, classId: targetClassId } });
const newGroup = await TournamentGroup.create({
stageId: null,
tournamentId,
classId: targetClassId,
poolId
});
// Weisen wir allen Source-Spielern diese neue Gruppe zu
for (const m of sourceInternals) {
m.groupId = newGroup.id;
m.classId = targetClassId; // bleiben sie in Quelle? Für Pool reicht Zielgruppen-Zuordnung; Klasse lassen wir unverändert
await m.save();
}
for (const e of sourceExternals) {
e.groupId = newGroup.id;
e.classId = targetClassId;
await e.save();
}
} else {
// distribute: Gleichmäßig über bestehende Zielgruppen verteilen
const cyclic = [...targetGroups];
let idx = 0;
const assignNext = () => {
const g = cyclic[idx % cyclic.length];
idx++;
return g;
};
for (const m of sourceInternals) {
const g = assignNext();
m.groupId = g.id;
// Klasse beim Teilnehmer unverändert lassen; Gruppenspiele laufen trotzdem im Pool
await m.save();
}
for (const e of sourceExternals) {
const g = assignNext();
e.groupId = g.id;
await e.save();
}
}
// Optional: vorhandene Gruppenspiele neu erstellen, damit die Verteilung greift
await this.createGroupMatches(userToken, clubId, tournamentId, null);
return { poolId };
}
/**
* Pool zurücksetzen: Entfernt poolId von Gruppen des Pools.
* (Vereinfachter Reset Teilnehmer bleiben in Gruppen; Matches kann der Nutzer löschen.)
*/
async resetPool(userToken, clubId, tournamentId, poolId) {
await checkAccess(userToken, clubId);
if (!poolId) throw new Error('poolId ist erforderlich');
const groups = await TournamentGroup.findAll({ where: { tournamentId, poolId } });
for (const g of groups) {
g.poolId = null;
await g.save();
}
}
// -------- Multi-Stage (Runden) V1 --------
async getTournamentStages(userToken, clubId, tournamentId) {
await checkAccess(userToken, clubId);