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