diff --git a/backend/controllers/tournamentController.js b/backend/controllers/tournamentController.js index 669e764..46b1b2a 100644 --- a/backend/controllers/tournamentController.js +++ b/backend/controllers/tournamentController.js @@ -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; diff --git a/backend/migrations/20260107_add_pool_and_out_of_competition.sql b/backend/migrations/20260107_add_pool_and_out_of_competition.sql new file mode 100644 index 0000000..aca68ba --- /dev/null +++ b/backend/migrations/20260107_add_pool_and_out_of_competition.sql @@ -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`; + diff --git a/backend/models/ExternalTournamentParticipant.js b/backend/models/ExternalTournamentParticipant.js index 49dbeb7..fb7592a 100644 --- a/backend/models/ExternalTournamentParticipant.js +++ b/backend/models/ExternalTournamentParticipant.js @@ -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, diff --git a/backend/models/TournamentGroup.js b/backend/models/TournamentGroup.js index 2c47f29..77bf254 100644 --- a/backend/models/TournamentGroup.js +++ b/backend/models/TournamentGroup.js @@ -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', diff --git a/backend/models/TournamentMember.js b/backend/models/TournamentMember.js index a8a2c5a..a979445 100644 --- a/backend/models/TournamentMember.js +++ b/backend/models/TournamentMember.js @@ -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, diff --git a/backend/routes/tournamentRoutes.js b/backend/routes/tournamentRoutes.js index f1a409a..2543476 100644 --- a/backend/routes/tournamentRoutes.js +++ b/backend/routes/tournamentRoutes.js @@ -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); diff --git a/backend/services/tournamentService.js b/backend/services/tournamentService.js index 45810f8..62c33cd 100644 --- a/backend/services/tournamentService.js +++ b/backend/services/tournamentService.js @@ -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); diff --git a/frontend/src/components/tournament/TournamentGroupsTab.vue b/frontend/src/components/tournament/TournamentGroupsTab.vue index 8f71735..d31809a 100644 --- a/frontend/src/components/tournament/TournamentGroupsTab.vue +++ b/frontend/src/components/tournament/TournamentGroupsTab.vue @@ -57,6 +57,45 @@ + + +