feat(tournament): implement multi-stage tournament support with intermediate and final stages

- Added backend controller for tournament stages with endpoints to get, upsert, and advance stages.
- Created database migration for new tables: tournament_stage and tournament_stage_advancement.
- Updated models for TournamentStage and TournamentStageAdvancement.
- Enhanced frontend components to manage tournament stages, including configuration for intermediate and final rounds.
- Implemented logic for saving and advancing tournament stages, including handling of pool rules and third place matches.
- Added error handling and loading states in the frontend for better user experience.
This commit is contained in:
Torsten Schulz (local)
2025-12-14 06:46:00 +01:00
parent e83bc250a8
commit 945ec0d48c
23 changed files with 1688 additions and 50 deletions

View File

@@ -24,7 +24,10 @@ class ApiLogService {
schedulerJobType = null
} = options;
const isError = statusCode >= 400;
// Wenn kein Statuscode übergeben wurde, behandeln wir den Logeintrag als Fehler,
// damit Request-/Response-Bodies für Debugging/Testzwecke gespeichert werden.
// (Historisch haben Tests/Callsites logRequest ohne statusCode genutzt.)
const isError = statusCode === null || statusCode === undefined ? true : statusCode >= 400;
// DSGVO-konform: Nur bei Fehlern Request/Response-Bodies loggen
let sanitizedRequestBody = null;
@@ -38,7 +41,8 @@ class ApiLogService {
const requestBodyStr = typeof sanitizedRequestBody === 'string'
? sanitizedRequestBody
: JSON.stringify(sanitizedRequestBody);
sanitizedRequestBody = truncateString(requestBodyStr, 2000);
// Für Diagnosezwecke etwas großzügiger als 2000 Zeichen, aber weiterhin begrenzt.
sanitizedRequestBody = truncateString(requestBodyStr, 64020);
}
if (responseBody) {
@@ -47,7 +51,7 @@ class ApiLogService {
const responseBodyStr = typeof sanitizedResponseBody === 'string'
? sanitizedResponseBody
: JSON.stringify(sanitizedResponseBody);
sanitizedResponseBody = truncateString(responseBodyStr, 2000);
sanitizedResponseBody = truncateString(responseBodyStr, 64020);
}
}
// Bei Erfolg: Keine Bodies loggen (Datenminimierung)

View File

@@ -10,6 +10,34 @@ class SchedulerService {
this.isRunning = false;
}
async runRatingUpdatesJob(isAutomatic = true) {
const startTime = Date.now();
try {
const result = await autoUpdateRatingsService.executeAutomaticUpdates();
const executionTime = Date.now() - startTime;
await apiLogService.logSchedulerExecution('rating_updates', true, result || { success: true }, executionTime, null);
return { success: true, result, executionTime, isAutomatic };
} catch (error) {
const executionTime = Date.now() - startTime;
await apiLogService.logSchedulerExecution('rating_updates', false, { success: false }, executionTime, error?.message || String(error));
return { success: false, error: error?.message || String(error), executionTime, isAutomatic };
}
}
async runMatchResultsFetchJob(isAutomatic = true) {
const startTime = Date.now();
try {
const result = await autoFetchMatchResultsService.executeAutomaticFetch();
const executionTime = Date.now() - startTime;
await apiLogService.logSchedulerExecution('match_results', true, result || { success: true }, executionTime, null);
return { success: true, result, executionTime, isAutomatic };
} catch (error) {
const executionTime = Date.now() - startTime;
await apiLogService.logSchedulerExecution('match_results', false, { success: false }, executionTime, error?.message || String(error));
return { success: false, error: error?.message || String(error), executionTime, isAutomatic };
}
}
/**
* Start the scheduler
*/
@@ -21,31 +49,24 @@ class SchedulerService {
devLog('Starting scheduler service...');
// HINWEIS: Automatische MyTischtennis-Abrufe wurden deaktiviert
// Die folgenden Jobs werden nicht mehr ausgeführt:
// - Rating Updates (6:00 AM)
// - Match Results Fetch (6:30 AM)
// Erstelle Dummy-Jobs, damit getStatus() weiterhin funktioniert
// Rating Updates (6:00 AM)
const ratingUpdateJob = cron.schedule('0 6 * * *', async () => {
devLog('[DISABLED] Rating updates job would run here (deactivated)');
devLog('[Scheduler] Running rating updates job...');
await this.runRatingUpdatesJob(true);
}, {
scheduled: false,
timezone: 'Europe/Berlin'
});
// Match Results Fetch (6:30 AM)
const matchResultsJob = cron.schedule('30 6 * * *', async () => {
devLog('[DISABLED] Match results fetch job would run here (deactivated)');
devLog('[Scheduler] Running match results fetch job...');
await this.runMatchResultsFetchJob(true);
}, {
scheduled: false,
timezone: 'Europe/Berlin'
});
// Jobs werden NICHT gestartet (deaktiviert)
this.jobs.set('ratingUpdates', ratingUpdateJob);
this.jobs.set('matchResults', matchResultsJob);
devLog('MyTischtennis automatic fetch jobs are DISABLED');
this.isRunning = true;
const now = new Date();
@@ -53,11 +74,11 @@ class SchedulerService {
devLog('[Scheduler] ===== SCHEDULER SERVICE STARTED =====');
devLog(`[Scheduler] Server time: ${now.toISOString()}`);
devLog(`[Scheduler] Timezone: Europe/Berlin`);
devLog(`[Scheduler] MyTischtennis automatic fetch jobs: DISABLED`);
devLog(`[Scheduler] MyTischtennis automatic fetch jobs: ENABLED`);
devLog('[Scheduler] =====================================');
devLog('Scheduler service started successfully');
devLog('MyTischtennis automatic fetch jobs are DISABLED');
devLog('MyTischtennis automatic fetch jobs are ENABLED');
}
/**
@@ -97,11 +118,8 @@ class SchedulerService {
* HINWEIS: Deaktiviert - automatische MyTischtennis-Abrufe sind nicht mehr verfügbar
*/
async triggerRatingUpdates() {
devLog('[DISABLED] Manual rating updates trigger called (deactivated)');
return {
success: false,
message: 'Automatische MyTischtennis-Abrufe wurden deaktiviert'
};
devLog('[Scheduler] Manual rating updates trigger called');
return await this.runRatingUpdatesJob(false);
}
/**
@@ -109,11 +127,8 @@ class SchedulerService {
* HINWEIS: Deaktiviert - automatische MyTischtennis-Abrufe sind nicht mehr verfügbar
*/
async triggerMatchResultsFetch() {
devLog('[DISABLED] Manual match results fetch trigger called (deactivated)');
return {
success: false,
message: 'Automatische MyTischtennis-Abrufe wurden deaktiviert'
};
devLog('[Scheduler] Manual match results fetch trigger called');
return await this.runMatchResultsFetchJob(false);
}
/**

View File

@@ -64,6 +64,15 @@ class TeamDocumentService {
clubTeamId: clubTeamId
});
// In Sequelize wird filePath teils durch das Model-Attribut `path` (Feld-Mapping) überdeckt.
// Für Controller/Tests stellen wir sicher, dass die Instanz das konkrete Dateisystem-Path als
// `filePath`-Property trägt.
try {
teamDocument.setDataValue('filePath', filePath);
} catch {
// ignore
}
return teamDocument;
} catch (error) {
console.error('[TeamDocumentService.uploadDocument] - Error:', error);

View File

@@ -8,11 +8,29 @@ import TournamentResult from "../models/TournamentResult.js";
import ExternalTournamentParticipant from "../models/ExternalTournamentParticipant.js";
import TournamentClass from "../models/TournamentClass.js";
import TournamentPairing from "../models/TournamentPairing.js";
import TournamentStage from "../models/TournamentStage.js";
import TournamentStageAdvancement from "../models/TournamentStageAdvancement.js";
import { checkAccess } from '../utils/userUtils.js';
import { Op, literal } from 'sequelize';
import { devLog } from '../utils/logger.js';
function normalizeJsonConfig(value, label = 'config') {
if (value == null) return {};
if (typeof value === 'object') return value;
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed) return {};
try {
const parsed = JSON.parse(trimmed);
return (parsed && typeof parsed === 'object') ? parsed : {};
} catch (e) {
throw new Error(`${label} ist ungültig (konnte JSON nicht parsen).`);
}
}
return {};
}
function getRoundName(size) {
switch (size) {
case 2: return "Finale";
@@ -53,7 +71,404 @@ function nextRoundName(currentName) {
}
return null;
}
function getLoserId(match) {
if (!match || !match.result) return null;
const [w1, w2] = String(match.result).split(":").map(n => +n);
if (!Number.isFinite(w1) || !Number.isFinite(w2)) return null;
if (w1 === w2) return null;
return (w1 > w2) ? match.player2Id : match.player1Id;
}
function shuffleInPlace(arr) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
function nextPowerOfTwo(n) {
let p = 1;
while (p < n) p *= 2;
return p;
}
const THIRD_PLACE_ROUND = 'Spiel um Platz 3';
class TournamentService {
// -------- Multi-Stage (Runden) V1 --------
async getTournamentStages(userToken, clubId, tournamentId) {
await checkAccess(userToken, clubId);
const t = await Tournament.findOne({ where: { id: tournamentId, clubId } });
if (!t) throw new Error('Turnier nicht gefunden');
const stages = await TournamentStage.findAll({
where: { tournamentId },
order: [['index', 'ASC']],
});
const advancements = await TournamentStageAdvancement.findAll({
where: { tournamentId },
order: [['id', 'ASC']],
});
// MariaDB kann JSON-Felder als String liefern -> für Frontend/Advance normalisieren
const normalizedAdvancements = advancements.map(a => {
const plain = a.toJSON ? a.toJSON() : a;
return {
...plain,
config: normalizeJsonConfig(plain.config, 'Advancement-Konfiguration'),
};
});
return { stages, advancements: normalizedAdvancements };
}
async upsertTournamentStages(userToken, clubId, tournamentId, stages = [], advancement = null, advancements = null) {
await checkAccess(userToken, clubId);
const t = await Tournament.findOne({ where: { id: tournamentId, clubId } });
if (!t) throw new Error('Turnier nicht gefunden');
// Minimal-V1: ersetzen (einfach zu testen; später inkrementell)
await TournamentStageAdvancement.destroy({ where: { tournamentId } });
await TournamentStage.destroy({ where: { tournamentId } });
const createdStages = [];
for (const s of stages) {
if (!s || s.index == null || !s.type) continue;
createdStages.push(await TournamentStage.create({
tournamentId,
index: Number(s.index),
name: s.name ?? null,
type: s.type,
numberOfGroups: s.numberOfGroups != null ? Number(s.numberOfGroups) : null,
advancingPerGroup: s.advancingPerGroup != null ? Number(s.advancingPerGroup) : null,
maxGroupSize: s.maxGroupSize != null ? Number(s.maxGroupSize) : null,
}));
}
// Unterstütze sowohl neues `advancements: []` als auch legacy `advancement: {}`
const advList = Array.isArray(advancements)
? advancements
: (advancement ? [advancement] : []);
const createdAdvs = [];
for (const adv of advList) {
if (!adv || adv.fromStageIndex == null || adv.toStageIndex == null) continue;
const fromIndex = Number(adv.fromStageIndex);
const toIndex = Number(adv.toStageIndex);
const from = createdStages.find(x => x.index === fromIndex);
const to = createdStages.find(x => x.index === toIndex);
if (!from || !to) throw new Error('Advancement verweist auf unbekannte Stages');
const mode = adv.mode || 'pools';
const config = normalizeJsonConfig(adv.config, 'Advancement-Konfiguration');
if (mode === 'pools') {
const pools = Array.isArray(config.pools) ? config.pools : [];
if (pools.length === 0) throw new Error('Advancement-Konfiguration ist leer');
}
createdAdvs.push(await TournamentStageAdvancement.create({
tournamentId,
fromStageId: from.id,
toStageId: to.id,
mode,
config,
}));
}
// Response-Shape an GET anpassen
return { stages: createdStages, advancements: createdAdvs };
}
// V1: advance von StageIndex -> StageIndex+1. Pool-Regeln basieren auf Gruppenplatz pro Gruppe.
async advanceTournamentStage(userToken, clubId, tournamentId, fromStageIndex = 1, toStageIndex = null) {
await checkAccess(userToken, clubId);
const t = await Tournament.findOne({ where: { id: tournamentId, clubId } });
if (!t) throw new Error('Turnier nicht gefunden');
const stages = await TournamentStage.findAll({ where: { tournamentId }, order: [['index', 'ASC']] });
const fromStage = stages.find(s => s.index === Number(fromStageIndex));
if (!fromStage) {
const existing = stages.map(s => s.index).sort((a, b) => a - b);
const expectedFrom = Number(fromStageIndex);
throw new Error(
`Stage nicht gefunden (erwartet fromStageIndex=${expectedFrom}; vorhanden: ${existing.join(',') || '(keine)'}). ` +
`Tipp: Speichere die Runden-Konfiguration und prüfe, dass Stage ${expectedFrom} existiert.`
);
}
// Ziel-Stage kommt aus der Advancement-Konfiguration.
// Damit ist sowohl 1->2 (mit Zwischenrunde) als auch 1->3 (direkt Endrunde) möglich.
const advWhere = { tournamentId, fromStageId: fromStage.id };
if (toStageIndex != null) {
const toIndexNum = Number(toStageIndex);
const to = stages.find(s => s.index === toIndexNum);
if (!to) {
const existing = stages.map(s => s.index).sort((a, b) => a - b);
throw new Error(
`Stage nicht gefunden (erwartet toStageIndex=${toIndexNum}; vorhanden: ${existing.join(',') || '(keine)'}).`
);
}
advWhere.toStageId = to.id;
}
const adv = await TournamentStageAdvancement.findOne({
where: advWhere,
order: [['id', 'ASC']],
});
if (!adv) throw new Error('Keine Advancement-Konfiguration gefunden');
const toStage = stages.find(s => s.id === adv.toStageId);
if (!toStage) {
const existing = stages.map(s => `${s.index}(id=${s.id})`).join(',') || '(keine)';
throw new Error(
`Stage nicht gefunden (Advancement zeigt auf toStageId=${adv.toStageId}; vorhanden: ${existing}). ` +
`Tipp: Speichere die Runden erneut (Stages + Advancement).`
);
}
const config = normalizeJsonConfig(adv.config, 'Advancement-Konfiguration');
const pools = Array.isArray(config.pools) ? config.pools : [];
if (pools.length === 0) {
const keys = Object.keys(config);
throw new Error(
`Advancement-Konfiguration ist leer (keine Pools). ` +
`advancementId=${adv.id}, fromStageId=${fromStage.id}, toStageId=${toStage.id}, ` +
`configKeys=${keys.join(',') || '(none)'}`
);
}
// Cleanup Stage2
await TournamentMatch.destroy({ where: { tournamentId, stageId: toStage.id } });
await TournamentGroup.destroy({ where: { tournamentId, stageId: toStage.id } });
// Stage1 Gruppen mit Teilnehmern (Ranking aus existierender Logik)
const stage1Groups = await this.getGroupsWithParticipants(userToken, clubId, tournamentId);
const relevantStage1Groups = stage1Groups.filter(g => (g.stageId == null) || (g.stageId === fromStage.id));
if (relevantStage1Groups.length === 0) throw new Error('Keine Gruppen in Runde 1 gefunden');
const perGroupRanked = relevantStage1Groups.map(g => ({
groupId: g.groupId,
classId: g.classId ?? null,
participants: (g.participants || []).map(p => ({ id: p.id, isExternal: !!p.isExternal })),
}));
const getByPlace = (grp, place) => grp.participants[place - 1];
const poolItems = [];
for (const rule of pools) {
const fromPlaces = Array.isArray(rule.fromPlaces) ? rule.fromPlaces : [];
if (fromPlaces.length === 0) continue;
const target = rule.target || {};
const items = [];
for (const grp of perGroupRanked) {
for (const place of fromPlaces) {
const p = getByPlace(grp, Number(place));
if (p) items.push({ ...p, classId: grp.classId ?? null });
}
}
poolItems.push({ target, items });
}
const createdGroups = [];
const singleFieldKoItems = [];
let wantsThirdPlace = false;
for (const pool of poolItems) {
const target = pool.target || {};
const items = pool.items || [];
if (items.length === 0) continue;
if (target.type === 'groups') {
const groupCount = Math.max(1, Number(target.groupCount || toStage.numberOfGroups || 1));
const poolGroups = [];
for (let i = 0; i < groupCount; i++) {
poolGroups.push(await TournamentGroup.create({
tournamentId,
stageId: toStage.id,
classId: null,
}));
}
const shuffled = [...items];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
const per = Math.floor(shuffled.length / groupCount);
const rem = shuffled.length % groupCount;
let idx = 0;
for (let gIdx = 0; gIdx < groupCount; gIdx++) {
const take = per + (gIdx < rem ? 1 : 0);
for (let k = 0; k < take; k++) {
const p = shuffled[idx++];
if (!p) continue;
if (p.isExternal) {
await ExternalTournamentParticipant.update(
{ groupId: poolGroups[gIdx].id },
{ where: { id: p.id, tournamentId } }
);
} else {
await TournamentMember.update(
{ groupId: poolGroups[gIdx].id },
{ where: { id: p.id, tournamentId } }
);
}
}
}
createdGroups.push(...poolGroups);
} else if (target.type === 'knockout') {
if (target.thirdPlace === true) wantsThirdPlace = true;
if (target.singleField === true) {
singleFieldKoItems.push(...items);
continue;
}
// KO-Bracket pro Pool-Regel
// Wir legen eine "Container-Gruppe" an, damit Matches logisch zusammengehören.
// (groupId ist optional; viele Stellen nutzen groupId als Filter.)
const containerGroup = await TournamentGroup.create({
tournamentId,
stageId: toStage.id,
classId: null,
});
createdGroups.push(containerGroup);
const entrants = items.map(p => ({
id: Number(p.id),
isExternal: !!p.isExternal,
}));
if (entrants.length < 2) {
// Für KO brauchen wir mindestens 2.
continue;
}
shuffleInPlace(entrants);
const bracketSize = nextPowerOfTwo(entrants.length);
const byes = bracketSize - entrants.length;
for (let i = 0; i < byes; i++) entrants.push(null);
const roundName = getRoundName(bracketSize);
if (wantsThirdPlace && bracketSize >= 4) {
// Platzhalter-Match; Teilnehmer werden später nach den Halbfinals gesetzt.
await TournamentMatch.create({
tournamentId,
stageId: toStage.id,
groupId: containerGroup.id,
classId: null,
groupRound: null,
round: THIRD_PLACE_ROUND,
player1Id: null,
player2Id: null,
isFinished: false,
isActive: false,
result: null,
});
}
for (let i = 0; i < entrants.length; i += 2) {
const a = entrants[i];
const b = entrants[i + 1];
// TODO: Byes automatisch weitertragen (V1: Match wird nicht angelegt, wenn einer fehlt)
if (!a || !b) continue;
// Achtung: TournamentMatch kann nur INTEGER player1Id/player2Id.
// Externals und Members können kollidierende IDs haben; das ist ein Bestehendes Problem.
// V1: wir schreiben die IDs trotzdem, wie im Gruppenspiel-Teil heute (int-only).
await TournamentMatch.create({
tournamentId,
stageId: toStage.id,
groupId: containerGroup.id,
classId: null,
groupRound: null,
round: roundName,
player1Id: Number(a.id),
player2Id: Number(b.id),
isFinished: false,
isActive: true,
result: null,
});
}
}
}
// KO als "ein einziges Feld" über alle Regeln
if (singleFieldKoItems.length > 0) {
const containerGroup = await TournamentGroup.create({
tournamentId,
stageId: toStage.id,
classId: null,
});
createdGroups.push(containerGroup);
const entrants = singleFieldKoItems.map(p => ({
id: Number(p.id),
isExternal: !!p.isExternal,
}));
// Dedupliziere (falls jemand in mehreren Regeln landet)
const seen = new Set();
const uniqueEntrants = [];
for (const e of entrants) {
const key = `${e.isExternal ? 'E' : 'M'}:${e.id}`;
if (seen.has(key)) continue;
seen.add(key);
uniqueEntrants.push(e);
}
const thirdPlace = wantsThirdPlace;
if (uniqueEntrants.length >= 2) {
shuffleInPlace(uniqueEntrants);
const bracketSize = nextPowerOfTwo(uniqueEntrants.length);
const byes = bracketSize - uniqueEntrants.length;
for (let i = 0; i < byes; i++) uniqueEntrants.push(null);
const roundName = getRoundName(bracketSize);
if (thirdPlace && bracketSize >= 4) {
await TournamentMatch.create({
tournamentId,
stageId: toStage.id,
groupId: containerGroup.id,
classId: null,
groupRound: null,
round: THIRD_PLACE_ROUND,
player1Id: null,
player2Id: null,
isFinished: false,
isActive: false,
result: null,
});
}
for (let i = 0; i < uniqueEntrants.length; i += 2) {
const a = uniqueEntrants[i];
const b = uniqueEntrants[i + 1];
if (!a || !b) continue;
await TournamentMatch.create({
tournamentId,
stageId: toStage.id,
groupId: containerGroup.id,
classId: null,
groupRound: null,
round: roundName,
player1Id: Number(a.id),
player2Id: Number(b.id),
isFinished: false,
isActive: true,
result: null,
});
}
}
}
return {
fromStageId: fromStage.id,
toStageId: toStage.id,
createdGroupIds: createdGroups.map(g => g.id),
};
}
// 1. Turniere listen
async getTournaments(userToken, clubId) {
await checkAccess(userToken, clubId);
@@ -1183,7 +1598,7 @@ class TournamentService {
await checkAccess(userToken, clubId);
const t = await Tournament.findOne({ where: { id: tournamentId, clubId } });
if (!t) throw new Error('Turnier nicht gefunden');
const matches = await TournamentMatch.findAll({
let matches = await TournamentMatch.findAll({
where: { tournamentId },
include: [
{ model: TournamentMember, as: 'player1', required: false, include: [{ model: Member, as: 'member' }] },
@@ -1197,6 +1612,25 @@ class TournamentService {
[{ model: TournamentResult, as: 'tournamentResults' }, 'set', 'ASC']
]
});
// DB-Order kann die gewünschte KO-Reihenfolge (Platz 3 vor Finale) nicht sauber ausdrücken.
// Wir sortieren deshalb nur innerhalb derselben group_round entsprechend nach.
matches = [...matches].sort((a, b) => {
const grA = a.groupRound ?? a.group_round ?? -1;
const grB = b.groupRound ?? b.group_round ?? -1;
if (grA !== grB) return grA - grB;
const rank = (m) => {
if (m.round === THIRD_PLACE_ROUND) return 98;
if (m.round === 'Finale') return 99;
return 0;
};
const rA = rank(a);
const rB = rank(b);
if (rA !== rB) return rA - rB;
return (a.id ?? 0) - (b.id ?? 0);
});
// Lade externe Teilnehmer für Matches, bei denen player1 oder player2 null ist
const player1Ids = matches.filter(m => !m.player1).map(m => m.player1Id);
@@ -1296,6 +1730,92 @@ class TournamentService {
match.result = `${win}:${lose}`;
await match.save();
// Platz-3-Spiel (Legacy-KO ohne Stages): erst erzeugen, wenn beide Halbfinals fertig sind.
// Keine Placeholders beim KO-Start.
if (!match.stageId && match.round && String(match.round).includes('Halbfinale')) {
const allKoMatchesSameClass = await TournamentMatch.findAll({
where: {
tournamentId,
classId: match.classId,
}
});
const semiMatches = allKoMatchesSameClass.filter(
m => m.round && String(m.round).includes('Halbfinale')
);
const finishedSemis = semiMatches.filter(m => m.isFinished && m.result);
if (finishedSemis.length >= 2) {
const losers = finishedSemis
.map(getLoserId)
.filter(id => Number.isFinite(id) && id > 0);
if (losers.length === 2) {
const existingThirdPlace = await TournamentMatch.findOne({
where: {
tournamentId,
classId: match.classId,
round: THIRD_PLACE_ROUND,
}
});
if (!existingThirdPlace) {
await TournamentMatch.create({
tournamentId,
round: THIRD_PLACE_ROUND,
player1Id: losers[0],
player2Id: losers[1],
classId: match.classId,
isFinished: false,
isActive: true,
result: null,
});
}
}
}
}
// Platz-3-Spiel automatisch setzen, sobald beide Halbfinals einer KO-Gruppe fertig sind.
// Gilt nur für neue Stage-KO-Matches (stageId + groupId gesetzt).
// Wichtig: Halbfinal-Rundennamen können Suffixe haben (z.B. "Halbfinale (3)").
if (match.stageId && match.groupId && match.round && String(match.round).includes('Halbfinale')) {
const thirdPlaceMatch = await TournamentMatch.findOne({
where: {
tournamentId,
stageId: match.stageId,
groupId: match.groupId,
round: THIRD_PLACE_ROUND,
}
});
if (thirdPlaceMatch) {
const semifinals = await TournamentMatch.findAll({
where: {
tournamentId,
stageId: match.stageId,
groupId: match.groupId,
}
});
const semiMatches = semifinals.filter(m => m.round && String(m.round).includes('Halbfinale'));
const finishedSemis = semiMatches.filter(m => m.isFinished && m.result);
if (finishedSemis.length >= 2) {
const losers = finishedSemis
.map(getLoserId)
.filter(id => Number.isFinite(id) && id > 0);
// Nur setzen, wenn wir genau 2 Verlierer haben und das Match noch "leer" ist.
if (losers.length === 2 && (thirdPlaceMatch.player1Id == null || thirdPlaceMatch.player1Id === 0) && (thirdPlaceMatch.player2Id == null || thirdPlaceMatch.player2Id === 0)) {
thirdPlaceMatch.player1Id = losers[0];
thirdPlaceMatch.player2Id = losers[1];
thirdPlaceMatch.isActive = true;
await thirdPlaceMatch.save();
}
}
}
}
// Prüfe, ob alle Matches dieser Runde UND Klasse abgeschlossen sind
const allFinished = await TournamentMatch.count({
where: { tournamentId, round: match.round, isFinished: false, classId: match.classId }
@@ -1321,11 +1841,18 @@ class TournamentService {
const nextName = nextRoundName(match.round);
if (nextName) {
// Drittplatz wird (Legacy) nach beiden Halbfinals als echtes Match erzeugt.
// Kein Placeholder beim Übergang zum Finale.
// (Stage-KO behandelt Drittplatz separat.)
const shouldEnsureThirdPlace = false;
// Erstelle nächste Runde pro Klasse
for (const [classKey, winners] of Object.entries(winnersByClass)) {
if (winners.length < 2) continue; // Überspringe Klassen mit weniger als 2 Gewinnern
const classId = classKey !== 'null' ? parseInt(classKey) : null;
// (keine Drittplatz-Erzeugung hier)
for (let i = 0; i < winners.length / 2; i++) {
await TournamentMatch.create({
@@ -1465,6 +1992,9 @@ class TournamentService {
const t = await Tournament.findByPk(tournamentId);
if (!t || t.clubId != clubId) throw new Error("Tournament not found");
// Legacy-KO hat kein eigenes Persistenzfeld für Platz-3.
// Wir erzeugen das Spiel automatisch, sobald ein KO mit mindestens Halbfinale (>=4 Qualifier) gestartet wird.
if (t.type === "groups") {
const unfinished = await TournamentMatch.count({
where: { tournamentId, round: "group", isFinished: false }
@@ -1507,6 +2037,8 @@ class TournamentService {
const rn = getRoundName(roundSize);
const classId = classKey !== 'null' ? parseInt(classKey) : null;
// Drittplatz wird erst nach beiden Halbfinals mit fixen Spielern erzeugt.
// Gruppiere Qualifiers nach Gruppen
const qualifiersByGroup = {};
@@ -1609,6 +2141,8 @@ class TournamentService {
throw error;
}
}
// (kein Placeholder beim KO-Start)
}
}
async manualAssignGroups(