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

@@ -171,10 +171,36 @@ export const getMemberActivities = async (req, res) => {
}
}
// Filter: explizite Zuordnungen sollen nur dann zählen, wenn
// - der Participant keine Gruppe hat UND die Aktivität KEINE Gruppenbindung hat, oder
// - die Aktivität keine Gruppenbindung hat, oder
// - es eine Gruppenbindung gibt, die zur Gruppe des Participants passt.
const filteredMemberActivities = memberActivities.filter((ma) => {
if (!ma?.participant || !ma?.activity) {
return false;
}
const participantGroupId = ma.participant.groupId;
const groupActivitiesForActivity = ma.activity.groupActivities || [];
// Participant ohne Gruppe -> nur Aktivitäten ohne Gruppenbindung zählen
if (participantGroupId === null || participantGroupId === undefined) {
return !groupActivitiesForActivity.length;
}
// Keine Gruppenbindung -> immer zählen
if (!groupActivitiesForActivity.length) {
return true;
}
// Gruppenbindung vorhanden -> nur zählen, wenn die Gruppe passt
return groupActivitiesForActivity.some((ga) => Number(ga.groupId) === Number(participantGroupId));
});
// 3. Kombiniere beide Listen und entferne Duplikate
// Ein Duplikat liegt vor, wenn dieselbe Aktivität für denselben Participant bereits explizit zugeordnet ist
const explicitActivityKeys = new Set();
memberActivities.forEach(ma => {
filteredMemberActivities.forEach(ma => {
if (ma.activity && ma.activity.id && ma.participant && ma.participant.id) {
// Erstelle einen eindeutigen Schlüssel: activityId-participantId
const key = `${ma.activity.id}-${ma.participant.id}`;
@@ -192,7 +218,7 @@ export const getMemberActivities = async (req, res) => {
});
// Kombiniere beide Listen
const allActivities = [...memberActivities, ...uniqueGroupActivities];
const allActivities = [...filteredMemberActivities, ...uniqueGroupActivities];
// Group activities by name and count occurrences
// Verwende einen Set pro Aktivität, um eindeutige Datum-Aktivität-Kombinationen zu tracken
@@ -323,6 +349,22 @@ export const getMemberLastParticipations = async (req, res) => {
order: [[{ model: DiaryDateActivity, as: 'activity' }, { model: DiaryDates, as: 'diaryDate' }, 'date', 'DESC']],
limit: parseInt(limit) * 10 // Get more to filter by group
});
// Siehe getMemberActivities(): nur zählen, wenn Gruppenbindung passt (oder keine existiert)
const filteredMemberActivities = memberActivities.filter((ma) => {
if (!ma?.participant || !ma?.activity) {
return false;
}
const participantGroupId = ma.participant.groupId;
const groupActivitiesForActivity = ma.activity.groupActivities || [];
if (!groupActivitiesForActivity.length) {
return true;
}
return groupActivitiesForActivity.some((ga) => Number(ga.groupId) === Number(participantGroupId));
});
// 2. Get all group activities for groups the member belongs to
const groupActivities = [];
@@ -399,7 +441,7 @@ export const getMemberLastParticipations = async (req, res) => {
// 3. Kombiniere beide Listen und entferne Duplikate
// Ein Duplikat liegt vor, wenn dieselbe Aktivität für denselben Participant bereits explizit zugeordnet ist
const explicitActivityKeys = new Set();
memberActivities.forEach(ma => {
filteredMemberActivities.forEach(ma => {
if (ma.activity && ma.activity.id && ma.participant && ma.participant.id) {
// Erstelle einen eindeutigen Schlüssel: activityId-participantId
const key = `${ma.activity.id}-${ma.participant.id}`;
@@ -417,7 +459,7 @@ export const getMemberLastParticipations = async (req, res) => {
});
// Kombiniere beide Listen
const allActivities = [...memberActivities, ...uniqueGroupActivities];
const allActivities = [...filteredMemberActivities, ...uniqueGroupActivities];
// Gruppiere nach Datum
const participationsByDate = new Map();

View File

@@ -2,6 +2,7 @@
import tournamentService from "../services/tournamentService.js";
import { emitTournamentChanged } from '../services/socketService.js';
import TournamentClass from '../models/TournamentClass.js';
import HttpError from '../exceptions/HttpError.js';
// 1. Alle Turniere eines Vereins
export const getTournaments = async (req, res) => {
@@ -12,6 +13,11 @@ export const getTournaments = async (req, res) => {
res.status(200).json(tournaments);
} catch (error) {
console.error(error);
if (error instanceof HttpError) {
res.set('x-debug-tournament-clubid', String(clubId));
res.set('x-debug-tournament-clubid-num', String(Number(clubId)));
return res.status(error.statusCode || 500).json({ error: error.message });
}
res.status(500).json({ error: error.message });
}
};

View File

@@ -0,0 +1,70 @@
import tournamentService from '../services/tournamentService.js';
import HttpError from '../exceptions/HttpError.js';
export const getStages = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId } = req.query;
try {
if (clubId == null || tournamentId == null) {
return res.status(400).json({ error: 'clubId und tournamentId sind erforderlich.' });
}
const data = await tournamentService.getTournamentStages(token, Number(clubId), Number(tournamentId));
res.status(200).json(data);
} catch (error) {
console.error(error);
if (error instanceof HttpError) {
// Debug-Hilfe: zeigt, welche IDs tatsächlich am Endpoint ankamen (ohne sensible Daten)
res.set('x-debug-stages-clubid', String(clubId));
res.set('x-debug-stages-tournamentid', String(tournamentId));
res.set('x-debug-stages-clubid-num', String(Number(clubId)));
return res.status(error.statusCode || 500).json({ error: error.message });
}
res.status(500).json({ error: error.message });
}
};
export const upsertStages = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, stages, advancement, advancements } = req.body;
try {
const data = await tournamentService.upsertTournamentStages(
token,
Number(clubId),
Number(tournamentId),
stages,
advancement,
advancements
);
res.status(200).json(data);
} catch (error) {
console.error(error);
if (error instanceof HttpError) {
res.set('x-debug-stages-clubid', String(clubId));
res.set('x-debug-stages-tournamentid', String(tournamentId));
res.set('x-debug-stages-clubid-num', String(Number(clubId)));
return res.status(error.statusCode || 500).json({ error: error.message });
}
res.status(500).json({ error: error.message });
}
};
export const advanceStage = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, fromStageIndex, toStageIndex } = req.body;
try {
const data = await tournamentService.advanceTournamentStage(
token,
Number(clubId),
Number(tournamentId),
Number(fromStageIndex || 1),
(toStageIndex == null ? null : Number(toStageIndex))
);
res.status(200).json(data);
} catch (error) {
console.error(error);
if (error instanceof HttpError) {
return res.status(error.statusCode || 500).json({ error: error.message });
}
res.status(500).json({ error: error.message });
}
};

View File

@@ -0,0 +1,58 @@
-- Adds multi-stage tournaments (rounds) support
-- MariaDB/MySQL compatible migration (manual execution)
-- 1) New table: tournament_stage
CREATE TABLE IF NOT EXISTS tournament_stage (
id INT NOT NULL AUTO_INCREMENT,
tournament_id INT NOT NULL,
stage_index INT NOT NULL,
name VARCHAR(255) NULL,
type VARCHAR(32) NOT NULL, -- 'groups' | 'knockout'
number_of_groups INT NULL,
advancing_per_group INT NULL,
max_group_size INT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
CONSTRAINT fk_tournament_stage_tournament
FOREIGN KEY (tournament_id) REFERENCES tournament(id)
ON DELETE CASCADE
) ENGINE=InnoDB;
CREATE INDEX idx_tournament_stage_tournament_id ON tournament_stage (tournament_id);
CREATE UNIQUE INDEX uq_tournament_stage_tournament_id_index ON tournament_stage (tournament_id, stage_index);
-- 2) New table: tournament_stage_advancement
CREATE TABLE IF NOT EXISTS tournament_stage_advancement (
id INT NOT NULL AUTO_INCREMENT,
tournament_id INT NOT NULL,
from_stage_id INT NOT NULL,
to_stage_id INT NOT NULL,
mode VARCHAR(32) NOT NULL DEFAULT 'pools',
config JSON NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
CONSTRAINT fk_tournament_stage_adv_tournament
FOREIGN KEY (tournament_id) REFERENCES tournament(id)
ON DELETE CASCADE,
CONSTRAINT fk_tournament_stage_adv_from
FOREIGN KEY (from_stage_id) REFERENCES tournament_stage(id)
ON DELETE CASCADE,
CONSTRAINT fk_tournament_stage_adv_to
FOREIGN KEY (to_stage_id) REFERENCES tournament_stage(id)
ON DELETE CASCADE
) ENGINE=InnoDB;
CREATE INDEX idx_tournament_stage_adv_tournament_id ON tournament_stage_advancement (tournament_id);
CREATE INDEX idx_tournament_stage_adv_from_stage_id ON tournament_stage_advancement (from_stage_id);
CREATE INDEX idx_tournament_stage_adv_to_stage_id ON tournament_stage_advancement (to_stage_id);
-- 3) Add stage_id to tournament_group and tournament_match
-- MariaDB has no IF NOT EXISTS for columns; run each ALTER once.
-- If you rerun, comment out the ALTERs or check INFORMATION_SCHEMA first.
ALTER TABLE tournament_group ADD COLUMN stage_id INT NULL;
ALTER TABLE tournament_match ADD COLUMN stage_id INT NULL;
CREATE INDEX idx_tournament_group_tournament_stage ON tournament_group (tournament_id, stage_id);
CREATE INDEX idx_tournament_match_tournament_stage ON tournament_match (tournament_id, stage_id);

View File

@@ -0,0 +1,16 @@
-- Allow NULL placeholders for KO (e.g. "Spiel um Platz 3")
-- MariaDB/MySQL manual migration
--
-- Background: We create placeholder matches with player1_id/player2_id = NULL.
-- Some prod DBs still have NOT NULL on these columns.
-- 1) Make player columns nullable
ALTER TABLE tournament_match MODIFY COLUMN player1_id INT NULL;
ALTER TABLE tournament_match MODIFY COLUMN player2_id INT NULL;
-- 2) (Optional) If you have foreign keys to tournament_member/external participant IDs,
-- ensure they also allow NULL. (Not adding here because not all installations have FKs.)
-- 3) Verify
-- SHOW COLUMNS FROM tournament_match LIKE 'player1_id';
-- SHOW COLUMNS FROM tournament_match LIKE 'player2_id';

View File

@@ -8,6 +8,10 @@ const TournamentGroup = sequelize.define('TournamentGroup', {
autoIncrement: true,
allowNull: false
},
stageId: {
type: DataTypes.INTEGER,
allowNull: true,
},
tournamentId : {
type: DataTypes.INTEGER,
allowNull: false

View File

@@ -5,6 +5,10 @@ import Tournament from './Tournament.js';
import TournamentGroup from './TournamentGroup.js';
const TournamentMatch = sequelize.define('TournamentMatch', {
stageId: {
type: DataTypes.INTEGER,
allowNull: true,
},
tournamentId: {
type: DataTypes.INTEGER,
allowNull: false,
@@ -39,11 +43,11 @@ const TournamentMatch = sequelize.define('TournamentMatch', {
},
player1Id: {
type: DataTypes.INTEGER,
allowNull: false,
allowNull: true,
},
player2Id: {
type: DataTypes.INTEGER,
allowNull: false,
allowNull: true,
},
isFinished: {
type: DataTypes.BOOLEAN,

View File

@@ -0,0 +1,46 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const TournamentStage = sequelize.define('TournamentStage', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
},
tournamentId: {
type: DataTypes.INTEGER,
allowNull: false,
},
index: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'stage_index',
},
name: {
type: DataTypes.STRING,
allowNull: true,
},
type: {
type: DataTypes.STRING,
allowNull: false, // 'groups' | 'knockout'
},
numberOfGroups: {
type: DataTypes.INTEGER,
allowNull: true,
},
advancingPerGroup: {
type: DataTypes.INTEGER,
allowNull: true,
},
maxGroupSize: {
type: DataTypes.INTEGER,
allowNull: true,
},
}, {
underscored: true,
tableName: 'tournament_stage',
timestamps: true,
});
export default TournamentStage;

View File

@@ -0,0 +1,40 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const TournamentStageAdvancement = sequelize.define('TournamentStageAdvancement', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
},
tournamentId: {
type: DataTypes.INTEGER,
allowNull: false,
},
fromStageId: {
type: DataTypes.INTEGER,
allowNull: false,
},
toStageId: {
type: DataTypes.INTEGER,
allowNull: false,
},
mode: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: 'pools',
},
config: {
// JSON: { pools: [{ fromPlaces:[1,2], target:{ type:'groups', groupCount:2 }}, ...] }
type: DataTypes.JSON,
allowNull: false,
defaultValue: {},
},
}, {
underscored: true,
tableName: 'tournament_stage_advancement',
timestamps: true,
});
export default TournamentStageAdvancement;

View File

@@ -33,6 +33,8 @@ import TournamentMatch from './TournamentMatch.js';
import TournamentResult from './TournamentResult.js';
import ExternalTournamentParticipant from './ExternalTournamentParticipant.js';
import TournamentPairing from './TournamentPairing.js';
import TournamentStage from './TournamentStage.js';
import TournamentStageAdvancement from './TournamentStageAdvancement.js';
import Accident from './Accident.js';
import UserToken from './UserToken.js';
import OfficialTournament from './OfficialTournament.js';
@@ -192,6 +194,13 @@ Club.hasMany(Tournament, { foreignKey: 'clubId', as: 'tournaments' });
TournamentGroup.belongsTo(Tournament, { foreignKey: 'tournamentId', as: 'tournaments' });
Tournament.hasMany(TournamentGroup, { foreignKey: 'tournamentId', as: 'tournamentGroups' });
// Tournament Stages
TournamentStage.belongsTo(Tournament, { foreignKey: 'tournamentId', as: 'tournament' });
Tournament.hasMany(TournamentStage, { foreignKey: 'tournamentId', as: 'stages' });
TournamentStageAdvancement.belongsTo(Tournament, { foreignKey: 'tournamentId', as: 'tournament' });
Tournament.hasMany(TournamentStageAdvancement, { foreignKey: 'tournamentId', as: 'stageAdvancements' });
TournamentMember.belongsTo(TournamentGroup, {
foreignKey: 'groupId',
targetKey: 'id',

View File

@@ -39,6 +39,11 @@ import {
updatePairing,
deletePairing,
} from '../controllers/tournamentController.js';
import {
getStages,
upsertStages,
advanceStage,
} from '../controllers/tournamentStagesController.js';
import { authenticate } from '../middleware/authMiddleware.js';
const router = express.Router();
@@ -66,9 +71,6 @@ 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
router.post('/external-participant', authenticate, addExternalParticipant);
router.post('/external-participants', authenticate, getExternalParticipants);
@@ -88,4 +90,13 @@ router.post('/pairing/:clubId/:tournamentId/:classId', authenticate, createPairi
router.put('/pairing/:clubId/:tournamentId/:pairingId', authenticate, updatePairing);
router.delete('/pairing/:clubId/:tournamentId/:pairingId', authenticate, deletePairing);
// Tournament Stages (mehrere Runden)
router.get('/stages', authenticate, getStages);
router.put('/stages', authenticate, upsertStages);
router.post('/stages/advance', authenticate, advanceStage);
// Muss NACH allen festen Pfaden stehen, sonst matcht z.B. '/stages' als clubId='stages'
router.get('/:clubId', authenticate, getTournaments);
router.post('/', authenticate, addTournament);
export default router;

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(

View File

@@ -36,7 +36,7 @@ describe('myTischtennisService', () => {
const stored = await MyTischtennis.findOne({ where: { userId } });
expect(stored.savePassword).toBe(true);
expect(Number(stored.clubId)).toBe(123);
expect(clientMock.getUserProfile).toHaveBeenCalled();
expect(clientMock.login).toHaveBeenCalledWith('user@example.com', 'pass');
});

View File

@@ -91,12 +91,12 @@ describe('schedulerService', () => {
it('triggert manuelle Updates und Fetches', async () => {
const ratings = await schedulerService.triggerRatingUpdates();
expect(ratings.success).toBe(true);
expect(autoUpdateMock).toHaveBeenCalled();
expect(ratings.success).toBe(true);
expect(autoUpdateMock).toHaveBeenCalled();
const matches = await schedulerService.triggerMatchResultsFetch();
expect(matches.success).toBe(true);
expect(autoFetchMock).toHaveBeenCalled();
expect(matches.success).toBe(true);
expect(autoFetchMock).toHaveBeenCalled();
});
it('führt geplante Jobs aus und protokolliert Ergebnisse', async () => {

View File

@@ -17,6 +17,7 @@ import Tournament from '../models/Tournament.js';
import TournamentGroup from '../models/TournamentGroup.js';
import TournamentMember from '../models/TournamentMember.js';
import TournamentMatch from '../models/TournamentMatch.js';
import TournamentStage from '../models/TournamentStage.js';
import Club from '../models/Club.js';
import { createMember } from './utils/factories.js';
@@ -163,4 +164,237 @@ describe('tournamentService', () => {
expect(max - min).toBeLessThanOrEqual(1);
expect(sizes.reduce((a, b) => a + b, 0)).toBe(10);
});
it('füllt beim KO mit thirdPlace=true das Platz-3-Spiel nach beiden Halbfinals', async () => {
const club = await Club.create({ name: 'Tournament Club' });
const tournament = await tournamentService.addTournament('token', club.id, 'KO-3rd', '2025-11-01');
const members = [];
for (let i = 0; i < 4; i++) {
members.push(
await createMember(club.id, {
firstName: `K${i}`,
lastName: 'O',
email: `ko3rd_${i}@example.com`,
gender: i % 2 === 0 ? 'male' : 'female',
})
);
}
for (const m of members) {
await tournamentService.addParticipant('token', club.id, tournament.id, m.id);
}
// Wir gehen den Stage-Flow, damit stageId+groupId gesetzt ist.
await tournamentService.upsertTournamentStages(
'token',
club.id,
tournament.id,
[
{ index: 1, type: 'groups', name: 'Vorrunde', numberOfGroups: 1 },
{ index: 2, type: 'knockout', name: 'Endrunde', numberOfGroups: null },
],
null,
[
{
fromStageIndex: 1,
toStageIndex: 2,
mode: 'pools',
config: {
pools: [
{
fromPlaces: [1, 2, 3, 4],
target: { type: 'knockout', singleField: true, thirdPlace: true }
}
]
}
}
]
);
// Vorrunde-Gruppen+Member-Gruppenzuordnung vorbereiten.
await tournamentService.createGroups('token', club.id, tournament.id, 1);
await tournamentService.fillGroups('token', club.id, tournament.id);
// Endrunde (KO) erstellen
await tournamentService.advanceTournamentStage('token', club.id, tournament.id, 1, 2);
const stage2 = await TournamentStage.findOne({ where: { tournamentId: tournament.id, index: 2 } });
expect(stage2).toBeTruthy();
// Third-Place Match muss als Placeholder existieren
const third = await TournamentMatch.findOne({
where: { tournamentId: tournament.id, stageId: stage2.id, round: 'Spiel um Platz 3' }
});
expect(third).toBeTruthy();
expect(third.player1Id).toBe(null);
expect(third.player2Id).toBe(null);
// Beide Halbfinals abschließen
const semis = await TournamentMatch.findAll({
where: { tournamentId: tournament.id, stageId: stage2.id },
order: [['id', 'ASC']]
});
const semiMatches = semis.filter(m => String(m.round || '').includes('Halbfinale'));
expect(semiMatches.length).toBe(2);
// Sieger jeweils als Player1 setzen (3 Gewinnsätze simulieren; winningSets default = 3)
for (const sm of semiMatches) {
await tournamentService.addMatchResult('token', club.id, tournament.id, sm.id, 1, '11:1');
await tournamentService.addMatchResult('token', club.id, tournament.id, sm.id, 2, '11:1');
await tournamentService.addMatchResult('token', club.id, tournament.id, sm.id, 3, '11:1');
}
const thirdAfter = await TournamentMatch.findOne({
where: { tournamentId: tournament.id, stageId: stage2.id, round: 'Spiel um Platz 3' }
});
expect(thirdAfter).toBeTruthy();
expect(thirdAfter.isActive).toBe(true);
expect(thirdAfter.player1Id).toBeTruthy();
expect(thirdAfter.player2Id).toBeTruthy();
expect(thirdAfter.player1Id).not.toBe(thirdAfter.player2Id);
});
it('Legacy-KO: legt Platz-3 an und befüllt es nach beiden Halbfinals', async () => {
const club = await Club.create({ name: 'Tournament Club' });
const tournament = await tournamentService.addTournament('token', club.id, 'Legacy-KO-3rd', '2025-11-15');
// Legacy-KO erzeugt Platz-3 automatisch, sobald ein KO ab Halbfinale gestartet wird.
// Legacy-startKnockout aktiviert Qualifier-Ermittlung über Gruppen-Logik.
// Dafür erstellen wir 2 Gruppen und spielen je 1 Match fertig.
await tournamentService.setModus('token', club.id, tournament.id, 'groups', 2, 2);
const members = [];
for (let i = 0; i < 4; i++) {
members.push(
await createMember(club.id, {
firstName: `L${i}`,
lastName: 'KO',
email: `legacy_ko3rd_${i}@example.com`,
gender: i % 2 === 0 ? 'male' : 'female',
})
);
}
for (const m of members) {
await tournamentService.addParticipant('token', club.id, tournament.id, m.id);
}
await tournamentService.createGroups('token', club.id, tournament.id, 2);
await tournamentService.fillGroups('token', club.id, tournament.id);
// Pro Gruppe 1 Match beenden, damit Qualifier (Platz 1) ermittelt werden können.
// Die Round-Robin-Gruppenspiele werden beim `fillGroups()` bereits angelegt.
const groupMatches = await TournamentMatch.findAll({
where: { tournamentId: tournament.id, round: 'group' },
order: [['id', 'ASC']]
});
expect(groupMatches.length).toBeGreaterThanOrEqual(2);
// Beende alle Gruppenspiele, damit pro Gruppe die Top-2 zuverlässig bestimmbar sind
for (const gm of groupMatches) {
await tournamentService.addMatchResult('token', club.id, tournament.id, gm.id, 1, '11:1');
await tournamentService.addMatchResult('token', club.id, tournament.id, gm.id, 2, '11:1');
await tournamentService.addMatchResult('token', club.id, tournament.id, gm.id, 3, '11:1');
}
// Direkt KO starten (ohne Stages)
await tournamentService.startKnockout('token', club.id, tournament.id);
const semisAll = await TournamentMatch.findAll({
where: { tournamentId: tournament.id },
order: [['id', 'ASC']]
});
const semiMatches = semisAll.filter(m => String(m.round || '').includes('Halbfinale'));
expect(semiMatches.length).toBe(2);
// Beide Halbfinals beenden -> Platz-3 muss befüllt werden
for (const sm of semiMatches) {
await tournamentService.addMatchResult('token', club.id, tournament.id, sm.id, 1, '11:1');
await tournamentService.addMatchResult('token', club.id, tournament.id, sm.id, 2, '11:1');
await tournamentService.addMatchResult('token', club.id, tournament.id, sm.id, 3, '11:1');
}
const thirdAfter = await TournamentMatch.findOne({
where: { tournamentId: tournament.id, round: 'Spiel um Platz 3' }
});
expect(thirdAfter).toBeTruthy();
expect(thirdAfter.isActive).toBe(true);
expect(thirdAfter.player1Id).toBeTruthy();
expect(thirdAfter.player2Id).toBeTruthy();
expect(thirdAfter.player1Id).not.toBe(thirdAfter.player2Id);
});
it('Legacy-KO: Platz-3 entsteht erst nach beiden Halbfinals (ohne Placeholder)', async () => {
const club = await Club.create({ name: 'Tournament Club' });
const tournament = await tournamentService.addTournament('token', club.id, 'Legacy-KO-3rd-late', '2025-11-16');
// Gruppen nötig, damit startKnockout Qualifier ermitteln kann
await tournamentService.setModus('token', club.id, tournament.id, 'groups', 2, 2);
const members = [];
for (let i = 0; i < 4; i++) {
members.push(
await createMember(club.id, {
firstName: `LL${i}`,
lastName: 'KO',
email: `legacy_ko3rd_late_${i}@example.com`,
gender: i % 2 === 0 ? 'male' : 'female',
})
);
}
for (const m of members) {
await tournamentService.addParticipant('token', club.id, tournament.id, m.id);
}
await tournamentService.createGroups('token', club.id, tournament.id, 2);
await tournamentService.fillGroups('token', club.id, tournament.id);
// Alle Gruppenspiele beenden
const groupMatches = await TournamentMatch.findAll({
where: { tournamentId: tournament.id, round: 'group' },
order: [['id', 'ASC']]
});
expect(groupMatches.length).toBeGreaterThanOrEqual(2);
for (const gm of groupMatches) {
await tournamentService.addMatchResult('token', club.id, tournament.id, gm.id, 1, '11:1');
await tournamentService.addMatchResult('token', club.id, tournament.id, gm.id, 2, '11:1');
await tournamentService.addMatchResult('token', club.id, tournament.id, gm.id, 3, '11:1');
}
// KO starten
await tournamentService.startKnockout('token', club.id, tournament.id);
// Vor Halbfinal-Ende darf es kein Platz-3-Spiel geben
const thirdBefore = await TournamentMatch.findOne({
where: { tournamentId: tournament.id, round: 'Spiel um Platz 3' }
});
expect(thirdBefore).toBeNull();
// Beide Halbfinals beenden -> dabei wird Finale erzeugt. Dabei muss jetzt auch Platz-3 wieder entstehen.
const koMatches = await TournamentMatch.findAll({
where: { tournamentId: tournament.id },
order: [['id', 'ASC']]
});
const semiMatches = koMatches.filter(m => String(m.round || '').includes('Halbfinale'));
expect(semiMatches.length).toBe(2);
for (const sm of semiMatches) {
await tournamentService.addMatchResult('token', club.id, tournament.id, sm.id, 1, '11:1');
await tournamentService.addMatchResult('token', club.id, tournament.id, sm.id, 2, '11:1');
await tournamentService.addMatchResult('token', club.id, tournament.id, sm.id, 3, '11:1');
}
const thirdAfter = await TournamentMatch.findOne({
where: { tournamentId: tournament.id, round: 'Spiel um Platz 3' }
});
expect(thirdAfter).toBeTruthy();
expect(thirdAfter.player1Id).toBeTruthy();
expect(thirdAfter.player2Id).toBeTruthy();
expect(thirdAfter.player1Id).not.toBe(thirdAfter.player2Id);
const finalAfter = await TournamentMatch.findOne({
where: { tournamentId: tournament.id, round: 'Finale' }
});
expect(finalAfter).toBeTruthy();
});
});

View File

@@ -10,8 +10,17 @@ config(); // sorgt dafür, dass process.env.JWT_SECRET geladen wird
export const getUserByToken = async (token) => {
try {
if (!token) {
throw new HttpError('Token fehlt', 401);
}
// 1. JWT validieren
const payload = jwt.verify(token, process.env.JWT_SECRET);
let payload;
try {
payload = jwt.verify(token, process.env.JWT_SECRET);
} catch (e) {
throw new HttpError('Unauthorized: Invalid credentials', 401);
}
// 2. Token-Eintrag prüfen (existiert und nicht abgelaufen)
const stored = await UserToken.findOne({
@@ -21,7 +30,7 @@ export const getUserByToken = async (token) => {
}
});
if (!stored) {
throw new HttpError('Token abgelaufen oder ungültig', 401);
throw new HttpError('Unauthorized: Invalid credentials', 401);
}
// 3. User laden
@@ -35,8 +44,8 @@ export const getUserByToken = async (token) => {
console.error(err);
// Falls es ein HttpError ist, einfach weiterwerfen
if (err instanceof HttpError) throw err;
// ansonsten pauschal „noaccess“
throw new HttpError('noaccess', 403);
// ansonsten: nicht maskieren, sondern als Unauthorized behandeln
throw new HttpError('Unauthorized: Invalid credentials', 401);
}
};
@@ -59,8 +68,15 @@ export const hasUserClubAccess = async (userId, clubId) => {
export const checkAccess = async (userToken, clubId) => {
try {
const user = await getUserByToken(userToken);
const hasAccess = await hasUserClubAccess(user.id, clubId);
const hasAccess = await hasUserClubAccess(user.id, clubId);
if (!hasAccess) {
// Debug-Hilfe: keine Tokens loggen, nur userId/clubId.
// (Wir loggen das immer, weil auf Servern NODE_ENV teils nicht gesetzt ist.)
console.warn('[checkAccess] noaccess:', {
userId: user?.id,
clubId,
clubIdType: typeof clubId,
});
throw new HttpError('noaccess', 403);
}
} catch (error) {

View File

@@ -47,11 +47,152 @@
@update:newClassGender="$emit('update:newClassGender', $event)"
@update:newClassMinBirthYear="$emit('update:newClassMinBirthYear', $event)"
/>
<div class="stage-config" style="margin-top: 1.5rem;">
<h3>Zwischenrunde & Endrunde</h3>
<div v-if="stageConfig.loading" style="opacity: 0.8;">
Lade Zwischenrunden …
</div>
<div v-else>
<p style="margin: 0.25rem 0 1rem; opacity: 0.8;">
Zwischenrunde ist optional. Wenn du sie aktivierst, gibt es danach immer eine Endrunde.
KO-Endrunde wird als <strong>ein einziges Feld</strong> erzeugt.
</p>
<div style="display: grid; gap: 0.75rem; max-width: 720px;">
<label class="checkbox-item" style="margin: 0.25rem 0;">
<input type="checkbox" v-model="stageConfig.useIntermediateStage" />
<span>Zwischenrunde verwenden</span>
</label>
<div v-if="stageConfig.useIntermediateStage" style="border: 1px solid #eee; border-radius: 6px; padding: 0.75rem;">
<strong>Zwischenrunde (Runde 2)</strong>
<label>
Runde 2 Modus:
<select v-model="stageConfig.stage2Type">
<option value="groups">Gruppen</option>
<option value="knockout">KO</option>
</select>
</label>
<label v-if="stageConfig.stage2Type === 'groups'">
Anzahl Gruppen in Runde 2 (pro Pool):
<input type="number" min="1" v-model.number="stageConfig.stage2GroupCount" />
</label>
<div class="pool-config" style="border: 1px solid #ddd; border-radius: 6px; padding: 0.75rem;">
<div style="display:flex; align-items:center; justify-content: space-between; gap: 1rem;">
<strong>Weiterkommen: Vorrunde → Zwischenrunde (1→2)</strong>
<button class="btn-secondary" @click="addPoolRule('12')" type="button">Pool-Regel hinzufügen</button>
</div>
<div v-if="stageConfig.pools12.length === 0" style="margin-top: 0.5rem; opacity: 0.8;">
Noch keine Regeln. Beispiel: Plätze 1 & 2 -> obere Runde-2-Gruppen.
</div>
<div v-for="(rule, idx) in stageConfig.pools12" :key="`12-${idx}`" style="display:grid; gap:0.5rem; margin-top:0.75rem; padding-top:0.75rem; border-top: 1px solid #eee;">
<label>
Plätze aus jeder Gruppe (z.B. 1,2):
<input type="text" v-model="rule.fromPlacesText" />
</label>
<div style="display:flex; gap: 0.75rem; flex-wrap: wrap; align-items: end;">
<label>
Ziel:
<select v-model="rule.targetType">
<option value="groups">Gruppen</option>
<option value="knockout">KO</option>
</select>
</label>
<label v-if="rule.targetType === 'groups'">
Ziel-Gruppenanzahl:
<input type="number" min="1" v-model.number="rule.targetGroupCount" />
</label>
<button class="btn-danger" type="button" @click="removePoolRule(idx)">Entfernen</button>
</div>
</div>
</div>
</div>
<div style="border: 1px solid #eee; border-radius: 6px; padding: 0.75rem;">
<strong>Endrunde (Runde {{ stageConfig.useIntermediateStage ? 3 : 2 }})</strong>
<label style="margin-top: 0.5rem;">
Endrunde Modus:
<select v-model="stageConfig.finalStageType">
<option value="groups">Gruppen</option>
<option value="knockout">KO</option>
</select>
</label>
<label v-if="stageConfig.finalStageType === 'knockout'" style="margin-top: 0.5rem; display:block;">
<input
type="checkbox"
:checked="stageConfig.finalStageThirdPlace"
@change="onThirdPlaceToggle($event.target.checked)"
@click.stop
/>
Platz 3 ausspielen
</label>
<label v-if="stageConfig.finalStageType === 'groups'">
Anzahl Gruppen in Endrunde:
<input type="number" min="1" v-model.number="stageConfig.finalStageGroupCount" />
</label>
<div class="pool-config" style="border: 1px solid #ddd; border-radius: 6px; padding: 0.75rem; margin-top: 0.75rem;">
<div style="display:flex; align-items:center; justify-content: space-between; gap: 1rem;">
<strong>Weiterkommen: {{ stageConfig.useIntermediateStage ? 'Zwischenrunde → Endrunde (2→3)' : 'Vorrunde → Endrunde (1→3)' }}</strong>
<button class="btn-secondary" @click="addPoolRule('final')" type="button">Pool-Regel hinzufügen</button>
</div>
<div v-if="finalPools.length === 0" style="margin-top: 0.5rem; opacity: 0.8;">
Beispiel: Plätze 1 & 2 -> Endrunde.
</div>
<div v-for="(rule, idx) in finalPools" :key="`final-${idx}`" style="display:grid; gap:0.5rem; margin-top:0.75rem; padding-top:0.75rem; border-top: 1px solid #eee;">
<label>
Plätze aus jeder Gruppe (z.B. 1,2):
<input type="text" v-model="rule.fromPlacesText" />
</label>
<div style="display:flex; gap: 0.75rem; flex-wrap: wrap; align-items: end;">
<label>
Ziel:
<select v-model="rule.targetType">
<option value="groups">Gruppen</option>
<option value="knockout">KO</option>
</select>
</label>
<label v-if="rule.targetType === 'groups'">
Ziel-Gruppenanzahl:
<input type="number" min="1" v-model.number="rule.targetGroupCount" />
</label>
<button class="btn-danger" type="button" @click="removePoolRule(idx, 'final')">Entfernen</button>
</div>
</div>
</div>
</div>
<div style="display:flex; gap: 0.75rem; flex-wrap: wrap;">
<button class="btn-primary" type="button" @click="onSaveClick">Runden speichern</button>
<button v-if="!stageConfig.useIntermediateStage" class="btn-secondary" type="button" @click="advanceStage(1, 3)">
Endrunde aus Vorrunde erstellen
</button>
<button v-else class="btn-secondary" type="button" @click="advanceStage(1, 2)">
Zwischenrunde aus Vorrunde erstellen
</button>
<button v-if="stageConfig.useIntermediateStage" class="btn-secondary" type="button" @click="advanceStage(2, 3)">
Endrunde aus Zwischenrunde erstellen
</button>
</div>
<div v-if="stageConfig.error" style="color: #b00020; white-space: pre-wrap;">
{{ stageConfig.error }}
</div>
<div v-if="stageConfig.success" style="color: #1b5e20;">
{{ stageConfig.success }}
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import TournamentClassList from './TournamentClassList.vue';
import apiClient from '../../apiClient';
export default {
name: 'TournamentConfigTab',
@@ -59,6 +200,14 @@ export default {
TournamentClassList
},
props: {
clubId: {
type: [Number, String],
required: true
},
tournamentId: {
type: [Number, String],
required: true
},
tournamentName: {
type: String,
required: true
@@ -120,6 +269,36 @@ export default {
default: null
}
},
data() {
return {
stageConfig: {
loading: false,
useIntermediateStage: true,
stage2Type: 'groups',
stage2GroupCount: 2,
pools12: [],
poolsFinal: [],
finalStageType: 'knockout',
finalStageThirdPlace: false,
finalStageGroupCount: 1,
error: null,
success: null,
},
};
},
computed: {
finalPools() {
return this.stageConfig.poolsFinal;
}
},
watch: {
tournamentId: {
immediate: true,
handler() {
this.loadStageConfig();
}
}
},
emits: [
'update:tournamentName',
'update:tournamentDate',
@@ -142,6 +321,336 @@ export default {
'update:newClassGender',
'update:newClassMinBirthYear'
]
,
methods: {
onSaveClick() {
this.saveStageConfig();
},
async onThirdPlaceToggle(checked) {
// UI sofort aktualisieren
this.stageConfig.finalStageThirdPlace = checked === true;
this.stageConfig.error = null;
this.stageConfig.success = null;
if (!this.clubId || !this.tournamentId) {
this.stageConfig.error = 'Kann nicht speichern: clubId oder tournamentId fehlt.';
return;
}
// Unabhängig von Pool-Regeln speichern:
// wir laden die aktuelle Stage-Konfiguration und patchen nur target.thirdPlace.
try {
const getRes = await apiClient.get('/tournament/stages', {
params: {
clubId: Number(this.clubId),
tournamentId: Number(this.tournamentId),
}
});
if (getRes.status >= 400) throw new Error(getRes.data?.error || 'Fehler beim Laden');
if (!Array.isArray(getRes.data?.stages) || !Array.isArray(getRes.data?.advancements)) {
throw new Error('Fehlerhafte Antwort vom Server (stages/advancements fehlen).');
}
let stages = Array.isArray(getRes.data?.stages) ? getRes.data.stages : [];
let advancements = Array.isArray(getRes.data?.advancements) ? getRes.data.advancements : [];
// Wenn noch keine Konfiguration existiert, können wir das Flag nicht "isoliert" speichern.
// Dann erzeugen wir eine minimale Stage+Advancement-Konfiguration (mit Default-Poolregel),
// damit thirdPlace überhaupt persistiert werden kann.
if (stages.length === 0 && advancements.length === 0) {
const built = this.buildPayload();
stages = built.stages;
advancements = built.advancements;
// Falls der Nutzer noch keine Pool-Regeln angelegt hat, legen wir eine Default-Regel an,
// damit der Backend-Validator nicht abbricht.
for (const adv of advancements) {
const pools = adv?.config?.pools;
if (!Array.isArray(pools) || pools.length === 0) {
adv.config = adv.config || {};
adv.config.pools = [{
fromPlaces: [1, 2],
target: { type: 'knockout', singleField: true, thirdPlace: checked === true },
}];
}
}
}
const patchedAdvancements = advancements.map(a => {
// Wenn wir gerade initial erzeugen (adv hat fromStageIndex/toStageIndex), patchen wir direkt.
if (a && a.fromStageIndex != null && a.toStageIndex != null) {
const isFinal = (Number(a.fromStageIndex) === 1 && Number(a.toStageIndex) === 3)
|| (Number(a.fromStageIndex) === 2 && Number(a.toStageIndex) === 3);
if (!isFinal) return a;
const cfg = a?.config && typeof a.config === 'object' ? a.config : {};
const pools = Array.isArray(cfg.pools) ? cfg.pools : [];
return {
...a,
config: {
...cfg,
pools: pools.map(p => {
const target = p?.target && typeof p.target === 'object' ? p.target : {};
if (target.type === 'knockout') {
return { ...p, target: { ...target, thirdPlace: checked === true } };
}
return p;
})
}
};
}
// Existing DB-shape: patch nur Final-Übergang (1->3 / 2->3) via stageId mapping
// Nur Final-Übergang patchen: (1->3) oder (2->3)
const stageById = new Map(stages.map(s => [Number(s.id), s]));
const fromIdx = Number(stageById.get(Number(a?.fromStageId))?.index);
const toIdx = Number(stageById.get(Number(a?.toStageId))?.index);
const isFinal = (fromIdx === 1 && toIdx === 3) || (fromIdx === 2 && toIdx === 3);
if (!isFinal) return a;
const cfg = a?.config && typeof a.config === 'object' ? a.config : {};
const pools = Array.isArray(cfg.pools) ? cfg.pools : [];
return {
...a,
config: {
...cfg,
pools: pools.map(p => {
const target = p?.target && typeof p.target === 'object' ? p.target : {};
if (target.type === 'knockout') {
return { ...p, target: { ...target, thirdPlace: checked === true } };
}
return p;
})
}
};
});
const putRes = await apiClient.put('/tournament/stages', {
clubId: Number(this.clubId),
tournamentId: Number(this.tournamentId),
stages,
advancements: patchedAdvancements,
});
if (putRes.status >= 400) throw new Error(putRes.data?.error || 'Fehler beim Speichern');
await this.loadStageConfig();
this.stageConfig.success = 'Gespeichert.';
} catch (e) {
this.stageConfig.error = e?.message || String(e);
}
},
async loadStageConfig() {
if (!this.clubId || !this.tournamentId) return;
this.stageConfig.loading = true;
this.stageConfig.error = null;
this.stageConfig.success = null;
try {
const res = await apiClient.get('/tournament/stages', {
params: {
clubId: Number(this.clubId),
tournamentId: Number(this.tournamentId),
}
});
if (res.status >= 400) throw new Error(res.data?.error || 'Fehler beim Laden der Zwischenrunden');
const stages = Array.isArray(res.data?.stages) ? res.data.stages : [];
const advancements = Array.isArray(res.data?.advancements) ? res.data.advancements : [];
// Zwischenrunde optional: wenn Stage 2 fehlt, gehen wir von direkter Endrunde aus.
const stage2 = stages.find(s => Number(s.index) === 2);
this.stageConfig.useIntermediateStage = !!stage2;
if (stage2) {
this.stageConfig.stage2Type = stage2.type || 'groups';
this.stageConfig.stage2GroupCount = stage2.numberOfGroups || 2;
}
const stage3 = stages.find(s => Number(s.index) === 3);
if (stage3) {
this.stageConfig.finalStageType = stage3.type || 'knockout';
this.stageConfig.finalStageGroupCount = stage3.numberOfGroups || 1;
} else {
// Fallback, wenn bisher nur 1->2 existierte
this.stageConfig.finalStageType = 'knockout';
this.stageConfig.finalStageGroupCount = 1;
}
const adv12 = advancements.find(a => Number(a?.fromStageId) && Number(a?.toStageId) && Number(stages.find(s => s.id === a.fromStageId)?.index) === 1 && Number(stages.find(s => s.id === a.toStageId)?.index) === 2) || null;
const advFinal = advancements.find(a => {
const fromIdx = Number(stages.find(s => s.id === a.fromStageId)?.index);
const toIdx = Number(stages.find(s => s.id === a.toStageId)?.index);
return (fromIdx === 1 && toIdx === 3) || (fromIdx === 2 && toIdx === 3);
}) || null;
const pools12 = Array.isArray(adv12?.config?.pools) ? adv12.config.pools : [];
this.stageConfig.pools12 = pools12.map(p => ({
fromPlacesText: Array.isArray(p.fromPlaces) ? p.fromPlaces.join(',') : '',
targetType: p?.target?.type || 'groups',
targetGroupCount: p?.target?.groupCount || this.stageConfig.stage2GroupCount || 2,
}));
const poolsFinal = Array.isArray(advFinal?.config?.pools) ? advFinal.config.pools : [];
this.stageConfig.poolsFinal = poolsFinal.map(p => ({
fromPlacesText: Array.isArray(p.fromPlaces) ? p.fromPlaces.join(',') : '',
targetType: p?.target?.type || this.stageConfig.finalStageType || 'knockout',
targetGroupCount: p?.target?.groupCount || this.stageConfig.finalStageGroupCount || 1,
}));
// KO-Flag gilt für die gesamte Endrunde: true, sobald irgendeine Final-KO-Regel thirdPlace=true hat.
this.stageConfig.finalStageThirdPlace = poolsFinal.some(p => p?.target?.type === 'knockout' && p?.target?.thirdPlace === true);
} catch (e) {
this.stageConfig.error = e?.message || String(e);
} finally {
this.stageConfig.loading = false;
}
},
addPoolRule(which = '12') {
const isFinal = which === 'final';
const targetArray = isFinal ? this.stageConfig.poolsFinal : this.stageConfig.pools12;
targetArray.push({
fromPlacesText: '1,2',
targetType: 'groups',
targetGroupCount: isFinal
? (this.stageConfig.finalStageGroupCount || 1)
: (this.stageConfig.stage2GroupCount || 2),
});
},
removePoolRule(idx, which = '12') {
const targetArray = (which === 'final') ? this.stageConfig.poolsFinal : this.stageConfig.pools12;
targetArray.splice(idx, 1);
},
buildPoolsPayload(rules, defaultGroupCount, knockoutSingleField = false, knockoutThirdPlace = false) {
return (rules || [])
.map(r => {
const fromPlaces = String(r.fromPlacesText || '')
.split(',')
.map(x => Number(String(x).trim()))
.filter(n => Number.isFinite(n) && n > 0);
return {
fromPlaces,
target: r.targetType === 'knockout'
? { type: 'knockout', singleField: knockoutSingleField, thirdPlace: knockoutThirdPlace }
: { type: 'groups', groupCount: Math.max(1, Number(r.targetGroupCount || defaultGroupCount || 1)) }
};
})
.filter(p => p.fromPlaces.length > 0);
},
buildPayload() {
const pools12 = this.stageConfig.useIntermediateStage
? this.buildPoolsPayload(this.stageConfig.pools12, this.stageConfig.stage2GroupCount || 2, false)
: [];
const poolsFinal = this.buildPoolsPayload(
this.stageConfig.poolsFinal,
this.stageConfig.finalStageGroupCount || 1,
true,
this.stageConfig.finalStageThirdPlace === true
);
const stages = [
{ index: 1, type: 'groups', name: 'Vorrunde' },
];
const advancements = [];
if (this.stageConfig.useIntermediateStage) {
stages.push({
index: 2,
type: this.stageConfig.stage2Type,
name: 'Zwischenrunde',
numberOfGroups: this.stageConfig.stage2Type === 'groups'
? Math.max(1, Number(this.stageConfig.stage2GroupCount || 1))
: null,
});
advancements.push({
fromStageIndex: 1,
toStageIndex: 2,
mode: 'pools',
config: { pools: pools12 },
});
stages.push({
index: 3,
type: this.stageConfig.finalStageType,
name: 'Endrunde',
numberOfGroups: this.stageConfig.finalStageType === 'groups'
? Math.max(1, Number(this.stageConfig.finalStageGroupCount || 1))
: null,
});
advancements.push({
fromStageIndex: 2,
toStageIndex: 3,
mode: 'pools',
config: { pools: poolsFinal },
});
} else {
stages.push({
index: 3,
type: this.stageConfig.finalStageType,
name: 'Endrunde',
numberOfGroups: this.stageConfig.finalStageType === 'groups'
? Math.max(1, Number(this.stageConfig.finalStageGroupCount || 1))
: null,
});
advancements.push({
fromStageIndex: 1,
toStageIndex: 3,
mode: 'pools',
config: { pools: poolsFinal },
});
}
return { stages, advancements };
},
async saveStageConfig() {
this.stageConfig.error = null;
this.stageConfig.success = null;
try {
const { stages, advancements } = this.buildPayload();
// Validierung: Für jeden Übergang müssen Pools vorhanden sein
for (const adv of advancements) {
const hasPools = Array.isArray(adv?.config?.pools) && adv.config.pools.length > 0;
if (!hasPools) {
const label = `${adv.fromStageIndex}${adv.toStageIndex}`;
throw new Error(`Bitte mindestens eine Pool-Regel für ${label} anlegen (z.B. Plätze 1,2).`);
}
}
const res = await apiClient.put('/tournament/stages', {
clubId: Number(this.clubId),
tournamentId: Number(this.tournamentId),
stages,
advancements,
});
if (res.status >= 400) throw new Error(res.data?.error || 'Fehler beim Speichern');
await this.loadStageConfig();
this.stageConfig.success = 'Gespeichert.';
} catch (e) {
this.stageConfig.error = e?.message || String(e);
}
},
async advanceStage(fromStageIndex, toStageIndex) {
this.stageConfig.error = null;
this.stageConfig.success = null;
try {
const res = await apiClient.post('/tournament/stages/advance', {
clubId: Number(this.clubId),
tournamentId: Number(this.tournamentId),
fromStageIndex: Number(fromStageIndex),
toStageIndex: Number(toStageIndex),
});
if (res.status >= 400) throw new Error(res.data?.error || 'Fehler beim Erstellen der Runde');
this.stageConfig.success = `Runde ${toStageIndex} wurde erstellt.`;
} catch (e) {
this.stageConfig.error = e?.message || String(e);
}
}
}
};
</script>

View File

@@ -58,7 +58,7 @@
<button @click="$emit('randomize-groups')">{{ $t('tournaments.randomizeGroups') }}</button>
<button @click="$emit('reset-groups')">{{ $t('tournaments.resetGroups') }}</button>
</section>
<section v-if="groups.length" class="groups-overview">
<section v-if="groups.length" class="groups-overview">
<h3>{{ $t('tournaments.groupsOverview') }}</h3>
<template v-for="(classGroups, classId) in groupsByClass" :key="classId">
<template v-if="shouldShowClass(classId === 'null' ? null : parseInt(classId))">
@@ -116,7 +116,12 @@
</div>
</template>
</template>
<div class="reset-controls" style="margin-top:1rem">
<div v-if="!matches.some(m => m.round === 'group')" class="reset-controls" style="margin-top:1rem">
<button @click="$emit('create-matches')" class="btn-primary">
Gruppenspiele berechnen
</button>
</div>
<div v-if="matches.some(m => m.round === 'group')" class="reset-controls" style="margin-top:1rem">
<button @click="$emit('reset-matches')" class="trash-btn">
🗑 {{ $t('tournaments.resetGroupMatches') }}
</button>
@@ -199,6 +204,7 @@ export default {
'randomize-groups',
'reset-groups',
'reset-matches',
'create-matches',
'highlight-match'
],
methods: {

View File

@@ -356,6 +356,7 @@ export default {
};
},
getPlayerName(p) {
if (!p) return 'TBD';
if (p.member) {
return p.member.firstName + ' ' + p.member.lastName;
} else {

View File

@@ -71,6 +71,8 @@
<!-- Tab: Konfiguration -->
<TournamentConfigTab
v-if="activeTab === 'config'"
:club-id="currentClub"
:tournament-id="selectedDate"
:tournament-name="currentTournamentName"
:tournament-date="currentTournamentDate"
:winning-sets="currentWinningSets"
@@ -173,6 +175,7 @@
@randomize-groups="randomizeGroups()"
@reset-groups="resetGroups()"
@reset-matches="resetMatches()"
@create-matches="startMatches()"
@highlight-match="highlightMatch"
/>
@@ -371,6 +374,7 @@ export default {
if (roundName.includes('Achtelfinale')) return 0;
if (roundName.includes('Viertelfinale')) return 1;
if (roundName.includes('Halbfinale')) return 2;
if (roundName.includes('Spiel um Platz 3')) return 2.5;
if (roundName.includes('Finale')) return 3;
// Für Runden wie "6-Runde", "8-Runde" etc. - extrahiere die Zahl
const numberMatch = roundName.match(/(\d+)-Runde/);