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:
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
|
||||
70
backend/controllers/tournamentStagesController.js
Normal file
70
backend/controllers/tournamentStagesController.js
Normal 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 });
|
||||
}
|
||||
};
|
||||
58
backend/migrations/20251213_add_tournament_stages.sql
Normal file
58
backend/migrations/20251213_add_tournament_stages.sql
Normal 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);
|
||||
@@ -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';
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
46
backend/models/TournamentStage.js
Normal file
46
backend/models/TournamentStage.js
Normal 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;
|
||||
40
backend/models/TournamentStageAdvancement.js
Normal file
40
backend/models/TournamentStageAdvancement.js
Normal 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;
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -24,7 +24,10 @@ class ApiLogService {
|
||||
schedulerJobType = null
|
||||
} = options;
|
||||
|
||||
const isError = statusCode >= 400;
|
||||
// Wenn kein Statuscode übergeben wurde, behandeln wir den Logeintrag als Fehler,
|
||||
// damit Request-/Response-Bodies für Debugging/Testzwecke gespeichert werden.
|
||||
// (Historisch haben Tests/Callsites logRequest ohne statusCode genutzt.)
|
||||
const isError = statusCode === null || statusCode === undefined ? true : statusCode >= 400;
|
||||
|
||||
// DSGVO-konform: Nur bei Fehlern Request/Response-Bodies loggen
|
||||
let sanitizedRequestBody = null;
|
||||
@@ -38,7 +41,8 @@ class ApiLogService {
|
||||
const requestBodyStr = typeof sanitizedRequestBody === 'string'
|
||||
? sanitizedRequestBody
|
||||
: JSON.stringify(sanitizedRequestBody);
|
||||
sanitizedRequestBody = truncateString(requestBodyStr, 2000);
|
||||
// Für Diagnosezwecke etwas großzügiger als 2000 Zeichen, aber weiterhin begrenzt.
|
||||
sanitizedRequestBody = truncateString(requestBodyStr, 64020);
|
||||
}
|
||||
|
||||
if (responseBody) {
|
||||
@@ -47,7 +51,7 @@ class ApiLogService {
|
||||
const responseBodyStr = typeof sanitizedResponseBody === 'string'
|
||||
? sanitizedResponseBody
|
||||
: JSON.stringify(sanitizedResponseBody);
|
||||
sanitizedResponseBody = truncateString(responseBodyStr, 2000);
|
||||
sanitizedResponseBody = truncateString(responseBodyStr, 64020);
|
||||
}
|
||||
}
|
||||
// Bei Erfolg: Keine Bodies loggen (Datenminimierung)
|
||||
|
||||
@@ -10,6 +10,34 @@ class SchedulerService {
|
||||
this.isRunning = false;
|
||||
}
|
||||
|
||||
async runRatingUpdatesJob(isAutomatic = true) {
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
const result = await autoUpdateRatingsService.executeAutomaticUpdates();
|
||||
const executionTime = Date.now() - startTime;
|
||||
await apiLogService.logSchedulerExecution('rating_updates', true, result || { success: true }, executionTime, null);
|
||||
return { success: true, result, executionTime, isAutomatic };
|
||||
} catch (error) {
|
||||
const executionTime = Date.now() - startTime;
|
||||
await apiLogService.logSchedulerExecution('rating_updates', false, { success: false }, executionTime, error?.message || String(error));
|
||||
return { success: false, error: error?.message || String(error), executionTime, isAutomatic };
|
||||
}
|
||||
}
|
||||
|
||||
async runMatchResultsFetchJob(isAutomatic = true) {
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
const result = await autoFetchMatchResultsService.executeAutomaticFetch();
|
||||
const executionTime = Date.now() - startTime;
|
||||
await apiLogService.logSchedulerExecution('match_results', true, result || { success: true }, executionTime, null);
|
||||
return { success: true, result, executionTime, isAutomatic };
|
||||
} catch (error) {
|
||||
const executionTime = Date.now() - startTime;
|
||||
await apiLogService.logSchedulerExecution('match_results', false, { success: false }, executionTime, error?.message || String(error));
|
||||
return { success: false, error: error?.message || String(error), executionTime, isAutomatic };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the scheduler
|
||||
*/
|
||||
@@ -21,31 +49,24 @@ class SchedulerService {
|
||||
|
||||
devLog('Starting scheduler service...');
|
||||
|
||||
// HINWEIS: Automatische MyTischtennis-Abrufe wurden deaktiviert
|
||||
// Die folgenden Jobs werden nicht mehr ausgeführt:
|
||||
// - Rating Updates (6:00 AM)
|
||||
// - Match Results Fetch (6:30 AM)
|
||||
|
||||
// Erstelle Dummy-Jobs, damit getStatus() weiterhin funktioniert
|
||||
// Rating Updates (6:00 AM)
|
||||
const ratingUpdateJob = cron.schedule('0 6 * * *', async () => {
|
||||
devLog('[DISABLED] Rating updates job would run here (deactivated)');
|
||||
devLog('[Scheduler] Running rating updates job...');
|
||||
await this.runRatingUpdatesJob(true);
|
||||
}, {
|
||||
scheduled: false,
|
||||
timezone: 'Europe/Berlin'
|
||||
});
|
||||
|
||||
// Match Results Fetch (6:30 AM)
|
||||
const matchResultsJob = cron.schedule('30 6 * * *', async () => {
|
||||
devLog('[DISABLED] Match results fetch job would run here (deactivated)');
|
||||
devLog('[Scheduler] Running match results fetch job...');
|
||||
await this.runMatchResultsFetchJob(true);
|
||||
}, {
|
||||
scheduled: false,
|
||||
timezone: 'Europe/Berlin'
|
||||
});
|
||||
|
||||
// Jobs werden NICHT gestartet (deaktiviert)
|
||||
this.jobs.set('ratingUpdates', ratingUpdateJob);
|
||||
this.jobs.set('matchResults', matchResultsJob);
|
||||
|
||||
devLog('MyTischtennis automatic fetch jobs are DISABLED');
|
||||
|
||||
this.isRunning = true;
|
||||
const now = new Date();
|
||||
@@ -53,11 +74,11 @@ class SchedulerService {
|
||||
devLog('[Scheduler] ===== SCHEDULER SERVICE STARTED =====');
|
||||
devLog(`[Scheduler] Server time: ${now.toISOString()}`);
|
||||
devLog(`[Scheduler] Timezone: Europe/Berlin`);
|
||||
devLog(`[Scheduler] MyTischtennis automatic fetch jobs: DISABLED`);
|
||||
devLog(`[Scheduler] MyTischtennis automatic fetch jobs: ENABLED`);
|
||||
devLog('[Scheduler] =====================================');
|
||||
|
||||
devLog('Scheduler service started successfully');
|
||||
devLog('MyTischtennis automatic fetch jobs are DISABLED');
|
||||
devLog('MyTischtennis automatic fetch jobs are ENABLED');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,11 +118,8 @@ class SchedulerService {
|
||||
* HINWEIS: Deaktiviert - automatische MyTischtennis-Abrufe sind nicht mehr verfügbar
|
||||
*/
|
||||
async triggerRatingUpdates() {
|
||||
devLog('[DISABLED] Manual rating updates trigger called (deactivated)');
|
||||
return {
|
||||
success: false,
|
||||
message: 'Automatische MyTischtennis-Abrufe wurden deaktiviert'
|
||||
};
|
||||
devLog('[Scheduler] Manual rating updates trigger called');
|
||||
return await this.runRatingUpdatesJob(false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,11 +127,8 @@ class SchedulerService {
|
||||
* HINWEIS: Deaktiviert - automatische MyTischtennis-Abrufe sind nicht mehr verfügbar
|
||||
*/
|
||||
async triggerMatchResultsFetch() {
|
||||
devLog('[DISABLED] Manual match results fetch trigger called (deactivated)');
|
||||
return {
|
||||
success: false,
|
||||
message: 'Automatische MyTischtennis-Abrufe wurden deaktiviert'
|
||||
};
|
||||
devLog('[Scheduler] Manual match results fetch trigger called');
|
||||
return await this.runMatchResultsFetchJob(false);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -64,6 +64,15 @@ class TeamDocumentService {
|
||||
clubTeamId: clubTeamId
|
||||
});
|
||||
|
||||
// In Sequelize wird filePath teils durch das Model-Attribut `path` (Feld-Mapping) überdeckt.
|
||||
// Für Controller/Tests stellen wir sicher, dass die Instanz das konkrete Dateisystem-Path als
|
||||
// `filePath`-Property trägt.
|
||||
try {
|
||||
teamDocument.setDataValue('filePath', filePath);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return teamDocument;
|
||||
} catch (error) {
|
||||
console.error('[TeamDocumentService.uploadDocument] - Error:', error);
|
||||
|
||||
@@ -8,11 +8,29 @@ import TournamentResult from "../models/TournamentResult.js";
|
||||
import ExternalTournamentParticipant from "../models/ExternalTournamentParticipant.js";
|
||||
import TournamentClass from "../models/TournamentClass.js";
|
||||
import TournamentPairing from "../models/TournamentPairing.js";
|
||||
import TournamentStage from "../models/TournamentStage.js";
|
||||
import TournamentStageAdvancement from "../models/TournamentStageAdvancement.js";
|
||||
import { checkAccess } from '../utils/userUtils.js';
|
||||
import { Op, literal } from 'sequelize';
|
||||
|
||||
|
||||
import { devLog } from '../utils/logger.js';
|
||||
|
||||
function normalizeJsonConfig(value, label = 'config') {
|
||||
if (value == null) return {};
|
||||
if (typeof value === 'object') return value;
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
return (parsed && typeof parsed === 'object') ? parsed : {};
|
||||
} catch (e) {
|
||||
throw new Error(`${label} ist ungültig (konnte JSON nicht parsen).`);
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
function getRoundName(size) {
|
||||
switch (size) {
|
||||
case 2: return "Finale";
|
||||
@@ -53,7 +71,404 @@ function nextRoundName(currentName) {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getLoserId(match) {
|
||||
if (!match || !match.result) return null;
|
||||
const [w1, w2] = String(match.result).split(":").map(n => +n);
|
||||
if (!Number.isFinite(w1) || !Number.isFinite(w2)) return null;
|
||||
if (w1 === w2) return null;
|
||||
return (w1 > w2) ? match.player2Id : match.player1Id;
|
||||
}
|
||||
|
||||
function shuffleInPlace(arr) {
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
function nextPowerOfTwo(n) {
|
||||
let p = 1;
|
||||
while (p < n) p *= 2;
|
||||
return p;
|
||||
}
|
||||
|
||||
const THIRD_PLACE_ROUND = 'Spiel um Platz 3';
|
||||
class TournamentService {
|
||||
// -------- Multi-Stage (Runden) V1 --------
|
||||
async getTournamentStages(userToken, clubId, tournamentId) {
|
||||
await checkAccess(userToken, clubId);
|
||||
const t = await Tournament.findOne({ where: { id: tournamentId, clubId } });
|
||||
if (!t) throw new Error('Turnier nicht gefunden');
|
||||
|
||||
const stages = await TournamentStage.findAll({
|
||||
where: { tournamentId },
|
||||
order: [['index', 'ASC']],
|
||||
});
|
||||
const advancements = await TournamentStageAdvancement.findAll({
|
||||
where: { tournamentId },
|
||||
order: [['id', 'ASC']],
|
||||
});
|
||||
|
||||
// MariaDB kann JSON-Felder als String liefern -> für Frontend/Advance normalisieren
|
||||
const normalizedAdvancements = advancements.map(a => {
|
||||
const plain = a.toJSON ? a.toJSON() : a;
|
||||
return {
|
||||
...plain,
|
||||
config: normalizeJsonConfig(plain.config, 'Advancement-Konfiguration'),
|
||||
};
|
||||
});
|
||||
|
||||
return { stages, advancements: normalizedAdvancements };
|
||||
}
|
||||
|
||||
async upsertTournamentStages(userToken, clubId, tournamentId, stages = [], advancement = null, advancements = null) {
|
||||
await checkAccess(userToken, clubId);
|
||||
const t = await Tournament.findOne({ where: { id: tournamentId, clubId } });
|
||||
if (!t) throw new Error('Turnier nicht gefunden');
|
||||
|
||||
// Minimal-V1: ersetzen (einfach zu testen; später inkrementell)
|
||||
await TournamentStageAdvancement.destroy({ where: { tournamentId } });
|
||||
await TournamentStage.destroy({ where: { tournamentId } });
|
||||
|
||||
const createdStages = [];
|
||||
for (const s of stages) {
|
||||
if (!s || s.index == null || !s.type) continue;
|
||||
createdStages.push(await TournamentStage.create({
|
||||
tournamentId,
|
||||
index: Number(s.index),
|
||||
name: s.name ?? null,
|
||||
type: s.type,
|
||||
numberOfGroups: s.numberOfGroups != null ? Number(s.numberOfGroups) : null,
|
||||
advancingPerGroup: s.advancingPerGroup != null ? Number(s.advancingPerGroup) : null,
|
||||
maxGroupSize: s.maxGroupSize != null ? Number(s.maxGroupSize) : null,
|
||||
}));
|
||||
}
|
||||
|
||||
// Unterstütze sowohl neues `advancements: []` als auch legacy `advancement: {}`
|
||||
const advList = Array.isArray(advancements)
|
||||
? advancements
|
||||
: (advancement ? [advancement] : []);
|
||||
|
||||
const createdAdvs = [];
|
||||
for (const adv of advList) {
|
||||
if (!adv || adv.fromStageIndex == null || adv.toStageIndex == null) continue;
|
||||
|
||||
const fromIndex = Number(adv.fromStageIndex);
|
||||
const toIndex = Number(adv.toStageIndex);
|
||||
const from = createdStages.find(x => x.index === fromIndex);
|
||||
const to = createdStages.find(x => x.index === toIndex);
|
||||
if (!from || !to) throw new Error('Advancement verweist auf unbekannte Stages');
|
||||
|
||||
const mode = adv.mode || 'pools';
|
||||
const config = normalizeJsonConfig(adv.config, 'Advancement-Konfiguration');
|
||||
if (mode === 'pools') {
|
||||
const pools = Array.isArray(config.pools) ? config.pools : [];
|
||||
if (pools.length === 0) throw new Error('Advancement-Konfiguration ist leer');
|
||||
}
|
||||
|
||||
createdAdvs.push(await TournamentStageAdvancement.create({
|
||||
tournamentId,
|
||||
fromStageId: from.id,
|
||||
toStageId: to.id,
|
||||
mode,
|
||||
config,
|
||||
}));
|
||||
}
|
||||
|
||||
// Response-Shape an GET anpassen
|
||||
return { stages: createdStages, advancements: createdAdvs };
|
||||
}
|
||||
|
||||
// V1: advance von StageIndex -> StageIndex+1. Pool-Regeln basieren auf Gruppenplatz pro Gruppe.
|
||||
async advanceTournamentStage(userToken, clubId, tournamentId, fromStageIndex = 1, toStageIndex = null) {
|
||||
await checkAccess(userToken, clubId);
|
||||
const t = await Tournament.findOne({ where: { id: tournamentId, clubId } });
|
||||
if (!t) throw new Error('Turnier nicht gefunden');
|
||||
|
||||
const stages = await TournamentStage.findAll({ where: { tournamentId }, order: [['index', 'ASC']] });
|
||||
const fromStage = stages.find(s => s.index === Number(fromStageIndex));
|
||||
if (!fromStage) {
|
||||
const existing = stages.map(s => s.index).sort((a, b) => a - b);
|
||||
const expectedFrom = Number(fromStageIndex);
|
||||
throw new Error(
|
||||
`Stage nicht gefunden (erwartet fromStageIndex=${expectedFrom}; vorhanden: ${existing.join(',') || '(keine)'}). ` +
|
||||
`Tipp: Speichere die Runden-Konfiguration und prüfe, dass Stage ${expectedFrom} existiert.`
|
||||
);
|
||||
}
|
||||
|
||||
// Ziel-Stage kommt aus der Advancement-Konfiguration.
|
||||
// Damit ist sowohl 1->2 (mit Zwischenrunde) als auch 1->3 (direkt Endrunde) möglich.
|
||||
const advWhere = { tournamentId, fromStageId: fromStage.id };
|
||||
if (toStageIndex != null) {
|
||||
const toIndexNum = Number(toStageIndex);
|
||||
const to = stages.find(s => s.index === toIndexNum);
|
||||
if (!to) {
|
||||
const existing = stages.map(s => s.index).sort((a, b) => a - b);
|
||||
throw new Error(
|
||||
`Stage nicht gefunden (erwartet toStageIndex=${toIndexNum}; vorhanden: ${existing.join(',') || '(keine)'}).`
|
||||
);
|
||||
}
|
||||
advWhere.toStageId = to.id;
|
||||
}
|
||||
|
||||
const adv = await TournamentStageAdvancement.findOne({
|
||||
where: advWhere,
|
||||
order: [['id', 'ASC']],
|
||||
});
|
||||
if (!adv) throw new Error('Keine Advancement-Konfiguration gefunden');
|
||||
|
||||
const toStage = stages.find(s => s.id === adv.toStageId);
|
||||
if (!toStage) {
|
||||
const existing = stages.map(s => `${s.index}(id=${s.id})`).join(',') || '(keine)';
|
||||
throw new Error(
|
||||
`Stage nicht gefunden (Advancement zeigt auf toStageId=${adv.toStageId}; vorhanden: ${existing}). ` +
|
||||
`Tipp: Speichere die Runden erneut (Stages + Advancement).`
|
||||
);
|
||||
}
|
||||
|
||||
const config = normalizeJsonConfig(adv.config, 'Advancement-Konfiguration');
|
||||
const pools = Array.isArray(config.pools) ? config.pools : [];
|
||||
if (pools.length === 0) {
|
||||
const keys = Object.keys(config);
|
||||
throw new Error(
|
||||
`Advancement-Konfiguration ist leer (keine Pools). ` +
|
||||
`advancementId=${adv.id}, fromStageId=${fromStage.id}, toStageId=${toStage.id}, ` +
|
||||
`configKeys=${keys.join(',') || '(none)'}`
|
||||
);
|
||||
}
|
||||
|
||||
// Cleanup Stage2
|
||||
await TournamentMatch.destroy({ where: { tournamentId, stageId: toStage.id } });
|
||||
await TournamentGroup.destroy({ where: { tournamentId, stageId: toStage.id } });
|
||||
|
||||
// Stage1 Gruppen mit Teilnehmern (Ranking aus existierender Logik)
|
||||
const stage1Groups = await this.getGroupsWithParticipants(userToken, clubId, tournamentId);
|
||||
const relevantStage1Groups = stage1Groups.filter(g => (g.stageId == null) || (g.stageId === fromStage.id));
|
||||
if (relevantStage1Groups.length === 0) throw new Error('Keine Gruppen in Runde 1 gefunden');
|
||||
|
||||
const perGroupRanked = relevantStage1Groups.map(g => ({
|
||||
groupId: g.groupId,
|
||||
classId: g.classId ?? null,
|
||||
participants: (g.participants || []).map(p => ({ id: p.id, isExternal: !!p.isExternal })),
|
||||
}));
|
||||
|
||||
const getByPlace = (grp, place) => grp.participants[place - 1];
|
||||
|
||||
const poolItems = [];
|
||||
for (const rule of pools) {
|
||||
const fromPlaces = Array.isArray(rule.fromPlaces) ? rule.fromPlaces : [];
|
||||
if (fromPlaces.length === 0) continue;
|
||||
const target = rule.target || {};
|
||||
|
||||
const items = [];
|
||||
for (const grp of perGroupRanked) {
|
||||
for (const place of fromPlaces) {
|
||||
const p = getByPlace(grp, Number(place));
|
||||
if (p) items.push({ ...p, classId: grp.classId ?? null });
|
||||
}
|
||||
}
|
||||
poolItems.push({ target, items });
|
||||
}
|
||||
|
||||
const createdGroups = [];
|
||||
const singleFieldKoItems = [];
|
||||
let wantsThirdPlace = false;
|
||||
for (const pool of poolItems) {
|
||||
const target = pool.target || {};
|
||||
const items = pool.items || [];
|
||||
if (items.length === 0) continue;
|
||||
|
||||
if (target.type === 'groups') {
|
||||
const groupCount = Math.max(1, Number(target.groupCount || toStage.numberOfGroups || 1));
|
||||
const poolGroups = [];
|
||||
for (let i = 0; i < groupCount; i++) {
|
||||
poolGroups.push(await TournamentGroup.create({
|
||||
tournamentId,
|
||||
stageId: toStage.id,
|
||||
classId: null,
|
||||
}));
|
||||
}
|
||||
|
||||
const shuffled = [...items];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
|
||||
const per = Math.floor(shuffled.length / groupCount);
|
||||
const rem = shuffled.length % groupCount;
|
||||
let idx = 0;
|
||||
for (let gIdx = 0; gIdx < groupCount; gIdx++) {
|
||||
const take = per + (gIdx < rem ? 1 : 0);
|
||||
for (let k = 0; k < take; k++) {
|
||||
const p = shuffled[idx++];
|
||||
if (!p) continue;
|
||||
if (p.isExternal) {
|
||||
await ExternalTournamentParticipant.update(
|
||||
{ groupId: poolGroups[gIdx].id },
|
||||
{ where: { id: p.id, tournamentId } }
|
||||
);
|
||||
} else {
|
||||
await TournamentMember.update(
|
||||
{ groupId: poolGroups[gIdx].id },
|
||||
{ where: { id: p.id, tournamentId } }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createdGroups.push(...poolGroups);
|
||||
} else if (target.type === 'knockout') {
|
||||
if (target.thirdPlace === true) wantsThirdPlace = true;
|
||||
if (target.singleField === true) {
|
||||
singleFieldKoItems.push(...items);
|
||||
continue;
|
||||
}
|
||||
// KO-Bracket pro Pool-Regel
|
||||
// Wir legen eine "Container-Gruppe" an, damit Matches logisch zusammengehören.
|
||||
// (groupId ist optional; viele Stellen nutzen groupId als Filter.)
|
||||
const containerGroup = await TournamentGroup.create({
|
||||
tournamentId,
|
||||
stageId: toStage.id,
|
||||
classId: null,
|
||||
});
|
||||
createdGroups.push(containerGroup);
|
||||
|
||||
const entrants = items.map(p => ({
|
||||
id: Number(p.id),
|
||||
isExternal: !!p.isExternal,
|
||||
}));
|
||||
if (entrants.length < 2) {
|
||||
// Für KO brauchen wir mindestens 2.
|
||||
continue;
|
||||
}
|
||||
|
||||
shuffleInPlace(entrants);
|
||||
const bracketSize = nextPowerOfTwo(entrants.length);
|
||||
const byes = bracketSize - entrants.length;
|
||||
for (let i = 0; i < byes; i++) entrants.push(null);
|
||||
|
||||
const roundName = getRoundName(bracketSize);
|
||||
if (wantsThirdPlace && bracketSize >= 4) {
|
||||
// Platzhalter-Match; Teilnehmer werden später nach den Halbfinals gesetzt.
|
||||
await TournamentMatch.create({
|
||||
tournamentId,
|
||||
stageId: toStage.id,
|
||||
groupId: containerGroup.id,
|
||||
classId: null,
|
||||
groupRound: null,
|
||||
round: THIRD_PLACE_ROUND,
|
||||
player1Id: null,
|
||||
player2Id: null,
|
||||
isFinished: false,
|
||||
isActive: false,
|
||||
result: null,
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < entrants.length; i += 2) {
|
||||
const a = entrants[i];
|
||||
const b = entrants[i + 1];
|
||||
|
||||
// TODO: Byes automatisch weitertragen (V1: Match wird nicht angelegt, wenn einer fehlt)
|
||||
if (!a || !b) continue;
|
||||
|
||||
// Achtung: TournamentMatch kann nur INTEGER player1Id/player2Id.
|
||||
// Externals und Members können kollidierende IDs haben; das ist ein Bestehendes Problem.
|
||||
// V1: wir schreiben die IDs trotzdem, wie im Gruppenspiel-Teil heute (int-only).
|
||||
await TournamentMatch.create({
|
||||
tournamentId,
|
||||
stageId: toStage.id,
|
||||
groupId: containerGroup.id,
|
||||
classId: null,
|
||||
groupRound: null,
|
||||
round: roundName,
|
||||
player1Id: Number(a.id),
|
||||
player2Id: Number(b.id),
|
||||
isFinished: false,
|
||||
isActive: true,
|
||||
result: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// KO als "ein einziges Feld" über alle Regeln
|
||||
if (singleFieldKoItems.length > 0) {
|
||||
const containerGroup = await TournamentGroup.create({
|
||||
tournamentId,
|
||||
stageId: toStage.id,
|
||||
classId: null,
|
||||
});
|
||||
createdGroups.push(containerGroup);
|
||||
|
||||
const entrants = singleFieldKoItems.map(p => ({
|
||||
id: Number(p.id),
|
||||
isExternal: !!p.isExternal,
|
||||
}));
|
||||
|
||||
// Dedupliziere (falls jemand in mehreren Regeln landet)
|
||||
const seen = new Set();
|
||||
const uniqueEntrants = [];
|
||||
for (const e of entrants) {
|
||||
const key = `${e.isExternal ? 'E' : 'M'}:${e.id}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
uniqueEntrants.push(e);
|
||||
}
|
||||
|
||||
const thirdPlace = wantsThirdPlace;
|
||||
if (uniqueEntrants.length >= 2) {
|
||||
shuffleInPlace(uniqueEntrants);
|
||||
const bracketSize = nextPowerOfTwo(uniqueEntrants.length);
|
||||
const byes = bracketSize - uniqueEntrants.length;
|
||||
for (let i = 0; i < byes; i++) uniqueEntrants.push(null);
|
||||
|
||||
const roundName = getRoundName(bracketSize);
|
||||
if (thirdPlace && bracketSize >= 4) {
|
||||
await TournamentMatch.create({
|
||||
tournamentId,
|
||||
stageId: toStage.id,
|
||||
groupId: containerGroup.id,
|
||||
classId: null,
|
||||
groupRound: null,
|
||||
round: THIRD_PLACE_ROUND,
|
||||
player1Id: null,
|
||||
player2Id: null,
|
||||
isFinished: false,
|
||||
isActive: false,
|
||||
result: null,
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < uniqueEntrants.length; i += 2) {
|
||||
const a = uniqueEntrants[i];
|
||||
const b = uniqueEntrants[i + 1];
|
||||
if (!a || !b) continue;
|
||||
await TournamentMatch.create({
|
||||
tournamentId,
|
||||
stageId: toStage.id,
|
||||
groupId: containerGroup.id,
|
||||
classId: null,
|
||||
groupRound: null,
|
||||
round: roundName,
|
||||
player1Id: Number(a.id),
|
||||
player2Id: Number(b.id),
|
||||
isFinished: false,
|
||||
isActive: true,
|
||||
result: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
fromStageId: fromStage.id,
|
||||
toStageId: toStage.id,
|
||||
createdGroupIds: createdGroups.map(g => g.id),
|
||||
};
|
||||
}
|
||||
|
||||
// 1. Turniere listen
|
||||
async getTournaments(userToken, clubId) {
|
||||
await checkAccess(userToken, clubId);
|
||||
@@ -1183,7 +1598,7 @@ class TournamentService {
|
||||
await checkAccess(userToken, clubId);
|
||||
const t = await Tournament.findOne({ where: { id: tournamentId, clubId } });
|
||||
if (!t) throw new Error('Turnier nicht gefunden');
|
||||
const matches = await TournamentMatch.findAll({
|
||||
let matches = await TournamentMatch.findAll({
|
||||
where: { tournamentId },
|
||||
include: [
|
||||
{ model: TournamentMember, as: 'player1', required: false, include: [{ model: Member, as: 'member' }] },
|
||||
@@ -1197,6 +1612,25 @@ class TournamentService {
|
||||
[{ model: TournamentResult, as: 'tournamentResults' }, 'set', 'ASC']
|
||||
]
|
||||
});
|
||||
|
||||
// DB-Order kann die gewünschte KO-Reihenfolge (Platz 3 vor Finale) nicht sauber ausdrücken.
|
||||
// Wir sortieren deshalb nur innerhalb derselben group_round entsprechend nach.
|
||||
matches = [...matches].sort((a, b) => {
|
||||
const grA = a.groupRound ?? a.group_round ?? -1;
|
||||
const grB = b.groupRound ?? b.group_round ?? -1;
|
||||
if (grA !== grB) return grA - grB;
|
||||
|
||||
const rank = (m) => {
|
||||
if (m.round === THIRD_PLACE_ROUND) return 98;
|
||||
if (m.round === 'Finale') return 99;
|
||||
return 0;
|
||||
};
|
||||
|
||||
const rA = rank(a);
|
||||
const rB = rank(b);
|
||||
if (rA !== rB) return rA - rB;
|
||||
return (a.id ?? 0) - (b.id ?? 0);
|
||||
});
|
||||
|
||||
// Lade externe Teilnehmer für Matches, bei denen player1 oder player2 null ist
|
||||
const player1Ids = matches.filter(m => !m.player1).map(m => m.player1Id);
|
||||
@@ -1296,6 +1730,92 @@ class TournamentService {
|
||||
match.result = `${win}:${lose}`;
|
||||
await match.save();
|
||||
|
||||
// Platz-3-Spiel (Legacy-KO ohne Stages): erst erzeugen, wenn beide Halbfinals fertig sind.
|
||||
// Keine Placeholders beim KO-Start.
|
||||
if (!match.stageId && match.round && String(match.round).includes('Halbfinale')) {
|
||||
const allKoMatchesSameClass = await TournamentMatch.findAll({
|
||||
where: {
|
||||
tournamentId,
|
||||
classId: match.classId,
|
||||
}
|
||||
});
|
||||
|
||||
const semiMatches = allKoMatchesSameClass.filter(
|
||||
m => m.round && String(m.round).includes('Halbfinale')
|
||||
);
|
||||
const finishedSemis = semiMatches.filter(m => m.isFinished && m.result);
|
||||
|
||||
if (finishedSemis.length >= 2) {
|
||||
const losers = finishedSemis
|
||||
.map(getLoserId)
|
||||
.filter(id => Number.isFinite(id) && id > 0);
|
||||
|
||||
if (losers.length === 2) {
|
||||
const existingThirdPlace = await TournamentMatch.findOne({
|
||||
where: {
|
||||
tournamentId,
|
||||
classId: match.classId,
|
||||
round: THIRD_PLACE_ROUND,
|
||||
}
|
||||
});
|
||||
|
||||
if (!existingThirdPlace) {
|
||||
await TournamentMatch.create({
|
||||
tournamentId,
|
||||
round: THIRD_PLACE_ROUND,
|
||||
player1Id: losers[0],
|
||||
player2Id: losers[1],
|
||||
classId: match.classId,
|
||||
isFinished: false,
|
||||
isActive: true,
|
||||
result: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Platz-3-Spiel automatisch setzen, sobald beide Halbfinals einer KO-Gruppe fertig sind.
|
||||
// Gilt nur für neue Stage-KO-Matches (stageId + groupId gesetzt).
|
||||
// Wichtig: Halbfinal-Rundennamen können Suffixe haben (z.B. "Halbfinale (3)").
|
||||
if (match.stageId && match.groupId && match.round && String(match.round).includes('Halbfinale')) {
|
||||
const thirdPlaceMatch = await TournamentMatch.findOne({
|
||||
where: {
|
||||
tournamentId,
|
||||
stageId: match.stageId,
|
||||
groupId: match.groupId,
|
||||
round: THIRD_PLACE_ROUND,
|
||||
}
|
||||
});
|
||||
|
||||
if (thirdPlaceMatch) {
|
||||
const semifinals = await TournamentMatch.findAll({
|
||||
where: {
|
||||
tournamentId,
|
||||
stageId: match.stageId,
|
||||
groupId: match.groupId,
|
||||
}
|
||||
});
|
||||
|
||||
const semiMatches = semifinals.filter(m => m.round && String(m.round).includes('Halbfinale'));
|
||||
|
||||
const finishedSemis = semiMatches.filter(m => m.isFinished && m.result);
|
||||
if (finishedSemis.length >= 2) {
|
||||
const losers = finishedSemis
|
||||
.map(getLoserId)
|
||||
.filter(id => Number.isFinite(id) && id > 0);
|
||||
|
||||
// Nur setzen, wenn wir genau 2 Verlierer haben und das Match noch "leer" ist.
|
||||
if (losers.length === 2 && (thirdPlaceMatch.player1Id == null || thirdPlaceMatch.player1Id === 0) && (thirdPlaceMatch.player2Id == null || thirdPlaceMatch.player2Id === 0)) {
|
||||
thirdPlaceMatch.player1Id = losers[0];
|
||||
thirdPlaceMatch.player2Id = losers[1];
|
||||
thirdPlaceMatch.isActive = true;
|
||||
await thirdPlaceMatch.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prüfe, ob alle Matches dieser Runde UND Klasse abgeschlossen sind
|
||||
const allFinished = await TournamentMatch.count({
|
||||
where: { tournamentId, round: match.round, isFinished: false, classId: match.classId }
|
||||
@@ -1321,11 +1841,18 @@ class TournamentService {
|
||||
|
||||
const nextName = nextRoundName(match.round);
|
||||
if (nextName) {
|
||||
// Drittplatz wird (Legacy) nach beiden Halbfinals als echtes Match erzeugt.
|
||||
// Kein Placeholder beim Übergang zum Finale.
|
||||
// (Stage-KO behandelt Drittplatz separat.)
|
||||
const shouldEnsureThirdPlace = false;
|
||||
|
||||
// Erstelle nächste Runde pro Klasse
|
||||
for (const [classKey, winners] of Object.entries(winnersByClass)) {
|
||||
if (winners.length < 2) continue; // Überspringe Klassen mit weniger als 2 Gewinnern
|
||||
|
||||
const classId = classKey !== 'null' ? parseInt(classKey) : null;
|
||||
|
||||
// (keine Drittplatz-Erzeugung hier)
|
||||
|
||||
for (let i = 0; i < winners.length / 2; i++) {
|
||||
await TournamentMatch.create({
|
||||
@@ -1465,6 +1992,9 @@ class TournamentService {
|
||||
const t = await Tournament.findByPk(tournamentId);
|
||||
if (!t || t.clubId != clubId) throw new Error("Tournament not found");
|
||||
|
||||
// Legacy-KO hat kein eigenes Persistenzfeld für Platz-3.
|
||||
// Wir erzeugen das Spiel automatisch, sobald ein KO mit mindestens Halbfinale (>=4 Qualifier) gestartet wird.
|
||||
|
||||
if (t.type === "groups") {
|
||||
const unfinished = await TournamentMatch.count({
|
||||
where: { tournamentId, round: "group", isFinished: false }
|
||||
@@ -1507,6 +2037,8 @@ class TournamentService {
|
||||
|
||||
const rn = getRoundName(roundSize);
|
||||
const classId = classKey !== 'null' ? parseInt(classKey) : null;
|
||||
|
||||
// Drittplatz wird erst nach beiden Halbfinals mit fixen Spielern erzeugt.
|
||||
|
||||
// Gruppiere Qualifiers nach Gruppen
|
||||
const qualifiersByGroup = {};
|
||||
@@ -1609,6 +2141,8 @@ class TournamentService {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// (kein Placeholder beim KO-Start)
|
||||
}
|
||||
}
|
||||
async manualAssignGroups(
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user