feat(tournament): add mini championship functionality and enhance tournament class handling

- Introduced addMiniChampionship method in tournamentService to create tournaments with predefined classes for mini championships.
- Updated getTournaments method to filter tournaments based on type, including support for mini championships.
- Enhanced TournamentClass model to include maxBirthYear for age class restrictions.
- Modified tournamentController and tournamentRoutes to support new mini championship endpoint.
- Updated frontend components to manage mini championship creation and display, including localization for new terms.
This commit is contained in:
Torsten Schulz (local)
2026-01-30 22:58:41 +01:00
parent 6cdcbfe0db
commit 85c26bc80d
10 changed files with 265 additions and 104 deletions

View File

@@ -40,12 +40,13 @@ export const resetPool = async (req, res) => {
}
};
// 1. Alle Turniere eines Vereins
// 1. Alle Turniere eines Vereins (query: type = 'internal' | 'external' | 'mini')
export const getTournaments = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId } = req.params;
const type = req.query.type || null;
try {
const tournaments = await tournamentService.getTournaments(token, clubId);
const tournaments = await tournamentService.getTournaments(token, clubId, type);
res.status(200).json(tournaments);
} catch (error) {
console.error(error);
@@ -64,7 +65,6 @@ export const addTournament = async (req, res) => {
const { clubId, tournamentName, date, winningSets, allowsExternal } = req.body;
try {
const tournament = await tournamentService.addTournament(token, clubId, tournamentName, date, winningSets, allowsExternal);
// Emit Socket-Event
if (clubId && tournament && tournament.id) {
emitTournamentChanged(clubId, tournament.id);
}
@@ -75,6 +75,22 @@ export const addTournament = async (req, res) => {
}
};
// Minimeisterschaft anlegen (Turnier + 6 Klassen)
export const addMiniChampionship = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentName, date, year, winningSets } = req.body;
try {
const tournament = await tournamentService.addMiniChampionship(token, clubId, tournamentName, date, year, winningSets);
if (clubId && tournament && tournament.id) {
emitTournamentChanged(clubId, tournament.id);
}
res.status(201).json(tournament);
} catch (error) {
console.error('[addMiniChampionship] Error:', error);
res.status(500).json({ error: error.message });
}
};
// 3. Teilnehmer hinzufügen - klassengebunden
export const addParticipant = async (req, res) => {
const { authcode: token } = req.headers;
@@ -599,9 +615,9 @@ export const getTournamentClasses = async (req, res) => {
export const addTournamentClass = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId } = req.params;
const { name, isDoubles, gender, minBirthYear } = req.body;
const { name, isDoubles, gender, minBirthYear, maxBirthYear } = req.body;
try {
const tournamentClass = await tournamentService.addTournamentClass(token, clubId, tournamentId, name, isDoubles, gender, minBirthYear);
const tournamentClass = await tournamentService.addTournamentClass(token, clubId, tournamentId, name, isDoubles, gender, minBirthYear, maxBirthYear);
emitTournamentChanged(clubId, tournamentId);
res.status(200).json(tournamentClass);
} catch (error) {
@@ -613,11 +629,9 @@ export const addTournamentClass = async (req, res) => {
export const updateTournamentClass = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, classId } = req.params;
const { name, sortOrder, isDoubles, gender, minBirthYear } = req.body;
const { name, sortOrder, isDoubles, gender, minBirthYear, maxBirthYear } = req.body;
try {
console.log('[updateTournamentClass] Request body:', { name, sortOrder, isDoubles, gender, minBirthYear });
const tournamentClass = await tournamentService.updateTournamentClass(token, clubId, tournamentId, classId, name, sortOrder, isDoubles, gender, minBirthYear);
console.log('[updateTournamentClass] Updated class:', JSON.stringify(tournamentClass.toJSON(), null, 2));
const tournamentClass = await tournamentService.updateTournamentClass(token, clubId, tournamentId, classId, name, sortOrder, isDoubles, gender, minBirthYear, maxBirthYear);
emitTournamentChanged(clubId, tournamentId);
res.status(200).json(tournamentClass);
} catch (error) {

View File

@@ -0,0 +1,9 @@
-- Minimeisterschaften: Turnier-Jahr und Alters-Obergrenze pro Klasse
-- tournament.mini_championship_year: Jahr der Minimeisterschaft (z.B. 2025); nur gesetzt bei Minimeisterschaften
-- tournament_class.max_birth_year: Geboren im Jahr X oder früher (<=); für Altersklassen 12/10
ALTER TABLE `tournament`
ADD COLUMN `mini_championship_year` INT NULL AFTER `allows_external`;
ALTER TABLE `tournament_class`
ADD COLUMN `max_birth_year` INT NULL AFTER `min_birth_year`;

View File

@@ -39,6 +39,12 @@ const Tournament = sequelize.define('Tournament', {
allowNull: false,
defaultValue: false,
},
miniChampionshipYear: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'mini_championship_year',
comment: 'Jahr der Minimeisterschaft; nur gesetzt bei Minimeisterschaften'
},
}, {
underscored: true,
tableName: 'tournament',

View File

@@ -44,6 +44,13 @@ const TournamentClass = sequelize.define('TournamentClass', {
defaultValue: null,
field: 'min_birth_year',
comment: 'Geboren im Jahr X oder später (>=)'
},
maxBirthYear: {
type: DataTypes.INTEGER,
allowNull: true,
defaultValue: null,
field: 'max_birth_year',
comment: 'Geboren im Jahr X oder früher (<=); für Altersklassen 12/10'
}
}, {
underscored: true,

View File

@@ -2,6 +2,7 @@ import express from 'express';
import {
getTournaments,
addTournament,
addMiniChampionship,
updateTournament,
addParticipant,
getParticipants,
@@ -105,6 +106,9 @@ router.get('/stages', authenticate, getStages);
router.put('/stages', authenticate, upsertStages);
router.post('/stages/advance', authenticate, advanceStage);
// Minimeisterschaft anlegen (vor :clubId, damit 'mini' nicht als clubId matcht)
router.post('/mini', authenticate, addMiniChampionship);
// Muss NACH allen festen Pfaden stehen, sonst matcht z.B. '/stages' als clubId='stages'
router.get('/:clubId', authenticate, getTournaments);
router.post('/', authenticate, addTournament);

View File

@@ -678,13 +678,19 @@ class TournamentService {
};
}
// 1. Turniere listen
async getTournaments(userToken, clubId) {
// 1. Turniere listen (type: 'internal' | 'external' | 'mini' optional bei 'mini' nur Minimeisterschaften)
async getTournaments(userToken, clubId, type = null) {
await checkAccess(userToken, clubId);
const where = { clubId };
if (type === 'mini') {
where.miniChampionshipYear = { [Op.ne]: null };
} else if (type === 'internal' || type === 'external') {
where.miniChampionshipYear = { [Op.is]: null };
}
const tournaments = await Tournament.findAll({
where: { clubId },
where,
order: [['date', 'DESC']],
attributes: ['id', 'name', 'date', 'allowsExternal']
attributes: ['id', 'name', 'date', 'allowsExternal', 'miniChampionshipYear']
});
return JSON.parse(JSON.stringify(tournaments));
}
@@ -708,6 +714,54 @@ class TournamentService {
return JSON.parse(JSON.stringify(t));
}
/**
* Minimeisterschaft anlegen: Turnier + 6 vorkonfigurierte Klassen (Jungen/Mädchen 12, 10, 8).
* Jahr Y: 12 = in Y 11 oder 12 Jahre (Geburtsjahr Y-12 oder Y-11), 10 = 9/10 (Y-10, Y-9), 8 = 8 oder jünger (≥ Y-8).
*/
async addMiniChampionship(userToken, clubId, tournamentName, date, year, winningSets = 3) {
await checkAccess(userToken, clubId);
const existing = await Tournament.findOne({ where: { clubId, date } });
if (existing) {
throw new Error('Ein Turnier mit diesem Datum existiert bereits');
}
const Y = Number(year);
if (!Number.isFinite(Y) || Y < 2000 || Y > 2100) {
throw new Error('Ungültiges Jahr für die Minimeisterschaft');
}
const t = await Tournament.create({
name: tournamentName,
date,
clubId: +clubId,
bestOfEndroundSize: 0,
type: '',
winningSets: winningSets || 3,
allowsExternal: false,
miniChampionshipYear: Y
});
const classes = [
{ name: 'Jungen 12', gender: 'male', minBirthYear: Y - 12, maxBirthYear: Y - 11 },
{ name: 'Jungen 10', gender: 'male', minBirthYear: Y - 10, maxBirthYear: Y - 9 },
{ name: 'Jungen 8', gender: 'male', minBirthYear: Y - 8, maxBirthYear: null },
{ name: 'Mädchen 12', gender: 'female', minBirthYear: Y - 12, maxBirthYear: Y - 11 },
{ name: 'Mädchen 10', gender: 'female', minBirthYear: Y - 10, maxBirthYear: Y - 9 },
{ name: 'Mädchen 8', gender: 'female', minBirthYear: Y - 8, maxBirthYear: null },
];
for (let i = 0; i < classes.length; i++) {
await TournamentClass.create({
tournamentId: t.id,
name: classes[i].name,
sortOrder: i + 1,
isDoubles: false,
gender: classes[i].gender,
minBirthYear: classes[i].minBirthYear,
maxBirthYear: classes[i].maxBirthYear
});
}
return JSON.parse(JSON.stringify(await Tournament.findByPk(t.id, {
attributes: ['id', 'name', 'date', 'allowsExternal', 'miniChampionshipYear']
})));
}
// 3. Teilnehmer hinzufügen (kein Duplikat) - klassengebunden
async addParticipant(userToken, clubId, classId, participantId, tournamentId = null) {
await checkAccess(userToken, clubId);
@@ -756,9 +810,8 @@ class TournamentService {
}
}
// Validierung: Geburtsjahr muss zur Klasse passen (geboren im Jahr X oder später, also >=)
if (tournamentClass.minBirthYear && member.birthDate) {
// Parse das Geburtsdatum (Format: YYYY-MM-DD oder DD.MM.YYYY)
// Validierung: Geburtsjahr muss zur Klasse passen (minBirthYear <= birthYear <= maxBirthYear)
if (member.birthDate) {
let birthYear = null;
if (member.birthDate.includes('-')) {
birthYear = parseInt(member.birthDate.split('-')[0]);
@@ -768,10 +821,13 @@ class TournamentService {
birthYear = parseInt(parts[2]);
}
}
if (birthYear && !isNaN(birthYear)) {
if (birthYear < tournamentClass.minBirthYear) {
if (birthYear != null && !isNaN(birthYear)) {
if (tournamentClass.minBirthYear != null && birthYear < tournamentClass.minBirthYear) {
throw new Error(`Dieser Teilnehmer ist zu alt für diese Klasse. Erlaubt: geboren ${tournamentClass.minBirthYear} oder später`);
}
if (tournamentClass.maxBirthYear != null && birthYear > tournamentClass.maxBirthYear) {
throw new Error(`Dieser Teilnehmer ist zu jung für diese Klasse. Erlaubt: geboren ${tournamentClass.maxBirthYear} oder früher`);
}
}
}
@@ -3258,11 +3314,13 @@ class TournamentService {
}
}
if (birthYear && !isNaN(birthYear)) {
// Geboren im Jahr X oder später bedeutet: birthYear >= minBirthYear
if (birthYear < tournamentClass.minBirthYear) {
if (birthYear != null && !isNaN(birthYear)) {
if (tournamentClass.minBirthYear != null && birthYear < tournamentClass.minBirthYear) {
throw new Error(`Dieser Teilnehmer ist zu alt für diese Klasse. Erlaubt: geboren ${tournamentClass.minBirthYear} oder später`);
}
if (tournamentClass.maxBirthYear != null && birthYear > tournamentClass.maxBirthYear) {
throw new Error(`Dieser Teilnehmer ist zu jung für diese Klasse. Erlaubt: geboren ${tournamentClass.maxBirthYear} oder früher`);
}
}
}
@@ -3337,13 +3395,12 @@ class TournamentService {
});
}
async addTournamentClass(userToken, clubId, tournamentId, name, isDoubles = false, gender = null, minBirthYear = null) {
async addTournamentClass(userToken, clubId, tournamentId, name, isDoubles = false, gender = null, minBirthYear = null, maxBirthYear = null) {
await checkAccess(userToken, clubId);
const tournament = await Tournament.findByPk(tournamentId);
if (!tournament || tournament.clubId != clubId) {
throw new Error('Turnier nicht gefunden');
}
// Finde die höchste sortOrder
const maxSortOrder = await TournamentClass.max('sortOrder', {
where: { tournamentId }
}) || 0;
@@ -3353,11 +3410,12 @@ class TournamentService {
sortOrder: maxSortOrder + 1,
isDoubles: isDoubles || false,
gender: gender || null,
minBirthYear: minBirthYear || null
minBirthYear: minBirthYear ?? null,
maxBirthYear: maxBirthYear ?? null
});
}
async updateTournamentClass(userToken, clubId, tournamentId, classId, name, sortOrder, isDoubles, gender, minBirthYear) {
async updateTournamentClass(userToken, clubId, tournamentId, classId, name, sortOrder, isDoubles, gender, minBirthYear, maxBirthYear) {
await checkAccess(userToken, clubId);
const tournament = await Tournament.findByPk(tournamentId);
if (!tournament || tournament.clubId != clubId) {
@@ -3369,37 +3427,15 @@ class TournamentService {
if (!tournamentClass) {
throw new Error('Klasse nicht gefunden');
}
console.log('[updateTournamentClass] Before update:', {
id: tournamentClass.id,
name: tournamentClass.name,
isDoubles: tournamentClass.isDoubles,
gender: tournamentClass.gender,
minBirthYear: tournamentClass.minBirthYear
});
console.log('[updateTournamentClass] New values:', { name, sortOrder, isDoubles, gender, minBirthYear });
// Verwende update() statt direkter Zuweisung für bessere Kontrolle
const updateData = {};
if (name !== undefined) updateData.name = name;
if (sortOrder !== undefined) updateData.sortOrder = sortOrder;
if (isDoubles !== undefined) updateData.isDoubles = isDoubles;
if (gender !== undefined) updateData.gender = gender;
if (minBirthYear !== undefined) updateData.minBirthYear = minBirthYear;
console.log('[updateTournamentClass] Update data:', updateData);
if (maxBirthYear !== undefined) updateData.maxBirthYear = maxBirthYear;
await tournamentClass.update(updateData);
// Lade die aktualisierte Instanz neu, um sicherzustellen, dass wir die aktuellen DB-Werte haben
await tournamentClass.reload();
console.log('[updateTournamentClass] After update and reload:', {
id: tournamentClass.id,
name: tournamentClass.name,
isDoubles: tournamentClass.isDoubles,
gender: tournamentClass.gender,
minBirthYear: tournamentClass.minBirthYear
});
return tournamentClass;
}