From 945ec0d48cd745e4cfb2c8e69759cf9d4cc4008a Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Sun, 14 Dec 2025 06:46:00 +0100 Subject: [PATCH] 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. --- .../controllers/memberActivityController.js | 50 +- backend/controllers/tournamentController.js | 6 + .../controllers/tournamentStagesController.js | 70 +++ .../20251213_add_tournament_stages.sql | 58 ++ ...allow_null_players_in_tournament_match.sql | 16 + backend/models/TournamentGroup.js | 4 + backend/models/TournamentMatch.js | 8 +- backend/models/TournamentStage.js | 46 ++ backend/models/TournamentStageAdvancement.js | 40 ++ backend/models/index.js | 9 + backend/routes/tournamentRoutes.js | 17 +- backend/services/apiLogService.js | 10 +- backend/services/schedulerService.js | 65 ++- backend/services/teamDocumentService.js | 9 + backend/services/tournamentService.js | 536 +++++++++++++++++- backend/tests/myTischtennisService.test.js | 2 +- backend/tests/schedulerService.test.js | 8 +- backend/tests/tournamentService.test.js | 234 ++++++++ backend/utils/userUtils.js | 26 +- .../tournament/TournamentConfigTab.vue | 509 +++++++++++++++++ .../tournament/TournamentGroupsTab.vue | 10 +- .../tournament/TournamentResultsTab.vue | 1 + frontend/src/views/TournamentTab.vue | 4 + 23 files changed, 1688 insertions(+), 50 deletions(-) create mode 100644 backend/controllers/tournamentStagesController.js create mode 100644 backend/migrations/20251213_add_tournament_stages.sql create mode 100644 backend/migrations/20251214_allow_null_players_in_tournament_match.sql create mode 100644 backend/models/TournamentStage.js create mode 100644 backend/models/TournamentStageAdvancement.js diff --git a/backend/controllers/memberActivityController.js b/backend/controllers/memberActivityController.js index 13a5681..3d3f21d 100644 --- a/backend/controllers/memberActivityController.js +++ b/backend/controllers/memberActivityController.js @@ -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(); diff --git a/backend/controllers/tournamentController.js b/backend/controllers/tournamentController.js index e452ca7..c4df81e 100644 --- a/backend/controllers/tournamentController.js +++ b/backend/controllers/tournamentController.js @@ -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 }); } }; diff --git a/backend/controllers/tournamentStagesController.js b/backend/controllers/tournamentStagesController.js new file mode 100644 index 0000000..89df901 --- /dev/null +++ b/backend/controllers/tournamentStagesController.js @@ -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 }); + } +}; diff --git a/backend/migrations/20251213_add_tournament_stages.sql b/backend/migrations/20251213_add_tournament_stages.sql new file mode 100644 index 0000000..2b18847 --- /dev/null +++ b/backend/migrations/20251213_add_tournament_stages.sql @@ -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); diff --git a/backend/migrations/20251214_allow_null_players_in_tournament_match.sql b/backend/migrations/20251214_allow_null_players_in_tournament_match.sql new file mode 100644 index 0000000..92fdd86 --- /dev/null +++ b/backend/migrations/20251214_allow_null_players_in_tournament_match.sql @@ -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'; diff --git a/backend/models/TournamentGroup.js b/backend/models/TournamentGroup.js index 4a4d2f2..2c47f29 100644 --- a/backend/models/TournamentGroup.js +++ b/backend/models/TournamentGroup.js @@ -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 diff --git a/backend/models/TournamentMatch.js b/backend/models/TournamentMatch.js index 23243c3..f2fbb92 100644 --- a/backend/models/TournamentMatch.js +++ b/backend/models/TournamentMatch.js @@ -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, diff --git a/backend/models/TournamentStage.js b/backend/models/TournamentStage.js new file mode 100644 index 0000000..cf36b8c --- /dev/null +++ b/backend/models/TournamentStage.js @@ -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; diff --git a/backend/models/TournamentStageAdvancement.js b/backend/models/TournamentStageAdvancement.js new file mode 100644 index 0000000..7f9b61d --- /dev/null +++ b/backend/models/TournamentStageAdvancement.js @@ -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; diff --git a/backend/models/index.js b/backend/models/index.js index 9a8dcef..fdc8b4b 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -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', diff --git a/backend/routes/tournamentRoutes.js b/backend/routes/tournamentRoutes.js index ece09eb..a6e98f8 100644 --- a/backend/routes/tournamentRoutes.js +++ b/backend/routes/tournamentRoutes.js @@ -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; diff --git a/backend/services/apiLogService.js b/backend/services/apiLogService.js index f949288..3021cee 100644 --- a/backend/services/apiLogService.js +++ b/backend/services/apiLogService.js @@ -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) diff --git a/backend/services/schedulerService.js b/backend/services/schedulerService.js index 7f71948..5d4b346 100644 --- a/backend/services/schedulerService.js +++ b/backend/services/schedulerService.js @@ -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); } /** diff --git a/backend/services/teamDocumentService.js b/backend/services/teamDocumentService.js index 691a485..0d7634a 100644 --- a/backend/services/teamDocumentService.js +++ b/backend/services/teamDocumentService.js @@ -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); diff --git a/backend/services/tournamentService.js b/backend/services/tournamentService.js index d349546..36a11ec 100644 --- a/backend/services/tournamentService.js +++ b/backend/services/tournamentService.js @@ -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( diff --git a/backend/tests/myTischtennisService.test.js b/backend/tests/myTischtennisService.test.js index 107f244..6c05343 100644 --- a/backend/tests/myTischtennisService.test.js +++ b/backend/tests/myTischtennisService.test.js @@ -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'); }); diff --git a/backend/tests/schedulerService.test.js b/backend/tests/schedulerService.test.js index ccb7be0..aa30f5f 100644 --- a/backend/tests/schedulerService.test.js +++ b/backend/tests/schedulerService.test.js @@ -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 () => { diff --git a/backend/tests/tournamentService.test.js b/backend/tests/tournamentService.test.js index 4f80869..4ed108f 100644 --- a/backend/tests/tournamentService.test.js +++ b/backend/tests/tournamentService.test.js @@ -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(); + }); }); diff --git a/backend/utils/userUtils.js b/backend/utils/userUtils.js index 7730311..337793f 100644 --- a/backend/utils/userUtils.js +++ b/backend/utils/userUtils.js @@ -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) { diff --git a/frontend/src/components/tournament/TournamentConfigTab.vue b/frontend/src/components/tournament/TournamentConfigTab.vue index 6f813c2..0347b9f 100644 --- a/frontend/src/components/tournament/TournamentConfigTab.vue +++ b/frontend/src/components/tournament/TournamentConfigTab.vue @@ -47,11 +47,152 @@ @update:newClassGender="$emit('update:newClassGender', $event)" @update:newClassMinBirthYear="$emit('update:newClassMinBirthYear', $event)" /> + +
+

Zwischenrunde & Endrunde

+
+ Lade Zwischenrunden … +
+
+

+ Zwischenrunde ist optional. Wenn du sie aktivierst, gibt es danach immer eine Endrunde. + KO-Endrunde wird als ein einziges Feld erzeugt. +

+ +
+ + +
+ Zwischenrunde (Runde 2) + + + + +
+
+ Weiterkommen: Vorrunde → Zwischenrunde (1→2) + +
+
+ Noch keine Regeln. Beispiel: Plätze 1 & 2 -> obere Runde-2-Gruppen. +
+
+ + +
+ + + +
+
+
+
+ +
+ Endrunde (Runde {{ stageConfig.useIntermediateStage ? 3 : 2 }}) + + + + +
+
+ Weiterkommen: {{ stageConfig.useIntermediateStage ? 'Zwischenrunde → Endrunde (2→3)' : 'Vorrunde → Endrunde (1→3)' }} + +
+
+ Beispiel: Plätze 1 & 2 -> Endrunde. +
+
+ +
+ + + +
+
+
+
+ +
+ + + + +
+
+ {{ stageConfig.error }} +
+
+ {{ stageConfig.success }} +
+
+
+
diff --git a/frontend/src/components/tournament/TournamentGroupsTab.vue b/frontend/src/components/tournament/TournamentGroupsTab.vue index 5af259d..a9f2e45 100644 --- a/frontend/src/components/tournament/TournamentGroupsTab.vue +++ b/frontend/src/components/tournament/TournamentGroupsTab.vue @@ -58,7 +58,7 @@ -
+

{{ $t('tournaments.groupsOverview') }}

-
+
+ +
+
@@ -199,6 +204,7 @@ export default { 'randomize-groups', 'reset-groups', 'reset-matches', + 'create-matches', 'highlight-match' ], methods: { diff --git a/frontend/src/components/tournament/TournamentResultsTab.vue b/frontend/src/components/tournament/TournamentResultsTab.vue index bc0f533..8fc8afd 100644 --- a/frontend/src/components/tournament/TournamentResultsTab.vue +++ b/frontend/src/components/tournament/TournamentResultsTab.vue @@ -356,6 +356,7 @@ export default { }; }, getPlayerName(p) { + if (!p) return 'TBD'; if (p.member) { return p.member.firstName + ' ' + p.member.lastName; } else { diff --git a/frontend/src/views/TournamentTab.vue b/frontend/src/views/TournamentTab.vue index c6ef800..d1543d6 100644 --- a/frontend/src/views/TournamentTab.vue +++ b/frontend/src/views/TournamentTab.vue @@ -71,6 +71,8 @@ @@ -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/);