feat(ClubTeam): enhance club team management with lineup features and member eligibility

- Added teamGender and teamAgeGroup fields to ClubTeam model for better categorization.
- Updated create and update club team endpoints to handle new fields and default values.
- Implemented getClubTeamLineup and updateClubTeamLineup functions for managing team lineups.
- Enhanced member management with adultReleaseApproved and adultReserveApproved fields in Member model.
- Updated frontend views to support new lineup features and member eligibility flags.
- Improved localization for new terms related to team management and member eligibility across multiple languages.
This commit is contained in:
Torsten Schulz (local)
2026-03-31 13:44:28 +02:00
parent cb7830571b
commit 5eff1d63aa
28 changed files with 1325 additions and 72 deletions

View File

@@ -42,7 +42,7 @@ export const createClubTeam = async (req, res) => {
try {
const { authcode: token } = req.headers;
const { clubid: clubId } = req.params;
const { name, leagueId, seasonId } = req.body;
const { name, leagueId, seasonId, teamGender, teamAgeGroup } = req.body;
const user = await getUserByToken(token);
@@ -54,7 +54,9 @@ export const createClubTeam = async (req, res) => {
name,
clubId: parseInt(clubId),
leagueId: leagueId ? parseInt(leagueId) : null,
seasonId: seasonId ? parseInt(seasonId) : null
seasonId: seasonId ? parseInt(seasonId) : null,
teamGender: teamGender || 'open',
teamAgeGroup: teamAgeGroup || 'adult'
};
const newClubTeam = await ClubTeamService.createClubTeam(clubTeamData);
@@ -70,7 +72,7 @@ export const updateClubTeam = async (req, res) => {
try {
const { authcode: token } = req.headers;
const { clubteamid: clubTeamId } = req.params;
const { name, leagueId, seasonId } = req.body;
const { name, leagueId, seasonId, teamGender, teamAgeGroup } = req.body;
const user = await getUserByToken(token);
@@ -78,6 +80,8 @@ export const updateClubTeam = async (req, res) => {
if (name !== undefined) updateData.name = name;
if (leagueId !== undefined) updateData.leagueId = leagueId ? parseInt(leagueId) : null;
if (seasonId !== undefined) updateData.seasonId = seasonId ? parseInt(seasonId) : null;
if (teamGender !== undefined) updateData.teamGender = teamGender || 'open';
if (teamAgeGroup !== undefined) updateData.teamAgeGroup = teamAgeGroup || 'adult';
const success = await ClubTeamService.updateClubTeam(clubTeamId, updateData);
if (!success) {
@@ -126,3 +130,47 @@ export const getLeagues = async (req, res) => {
res.status(500).json({ error: "internalerror" });
}
};
export const getClubTeamLineup = async (req, res) => {
try {
const { authcode: token } = req.headers;
const { clubteamid: clubTeamId } = req.params;
const lineupHalf = req.query.half === 'second_half' ? 'second_half' : 'first_half';
await getUserByToken(token);
const clubTeam = await ClubTeamService.getClubTeamById(clubTeamId);
if (!clubTeam) {
return res.status(404).json({ error: "notfound" });
}
const lineup = await ClubTeamService.getTeamLineup(clubTeamId, lineupHalf);
res.status(200).json(lineup);
} catch (error) {
console.error('[getClubTeamLineup] - Error:', error);
res.status(500).json({ error: "internalerror" });
}
};
export const updateClubTeamLineup = async (req, res) => {
try {
const { authcode: token } = req.headers;
const { clubteamid: clubTeamId } = req.params;
const { assignments, lineupHalf: requestLineupHalf } = req.body;
const lineupHalf = requestLineupHalf === 'second_half' || req.query.half === 'second_half' ? 'second_half' : 'first_half';
await getUserByToken(token);
const clubTeam = await ClubTeamService.getClubTeamById(clubTeamId);
if (!clubTeam) {
return res.status(404).json({ error: "notfound" });
}
const lineup = await ClubTeamService.replaceTeamLineup(clubTeamId, assignments, lineupHalf);
res.status(200).json(lineup);
} catch (error) {
console.error('[updateClubTeamLineup] - Error:', error);
if (error?.code === 'TEAM_LINEUP_TABLE_MISSING') {
return res.status(500).json({ error: 'teamlineuptablemissing' });
}
res.status(500).json({ error: "internalerror" });
}
};

View File

@@ -29,11 +29,11 @@ const getWaitingApprovals = async(req, res) => {
const setClubMembers = async (req, res) => {
try {
const { id: memberId, firstname: firstName, lastname: lastName, street, city, postalCode, birthdate, phone, email, active,
testMembership, picsInInternetAllowed, gender, ttr, qttr, memberFormHandedOver, contacts } = req.body;
testMembership, picsInInternetAllowed, gender, ttr, qttr, memberFormHandedOver, adultReleaseApproved, adultReserveApproved, contacts } = req.body;
const { id: clubId } = req.params;
const { authcode: userToken } = req.headers;
const addResult = await MemberService.setClubMember(userToken, clubId, memberId, firstName, lastName, street, city, postalCode, birthdate,
phone, email, active, testMembership, picsInInternetAllowed, gender, ttr, qttr, memberFormHandedOver, contacts);
phone, email, active, testMembership, picsInInternetAllowed, gender, ttr, qttr, memberFormHandedOver, adultReleaseApproved, adultReserveApproved, contacts);
// Emit Socket-Event wenn Member erfolgreich erstellt/aktualisiert wurde
if (addResult.status === 200) {

View File

@@ -51,6 +51,18 @@ const ClubTeam = sequelize.define('ClubTeam', {
comment: 'Team ID from myTischtennis (e.g. 2995094)',
field: 'my_tischtennis_team_id'
},
teamGender: {
type: DataTypes.ENUM('open', 'female'),
allowNull: false,
defaultValue: 'open',
field: 'team_gender'
},
teamAgeGroup: {
type: DataTypes.ENUM('adult', 'J19', 'J17', 'J15', 'J13', 'J11'),
allowNull: false,
defaultValue: 'adult',
field: 'team_age_group'
},
}, {
underscored: true,
tableName: 'club_team',

View File

@@ -0,0 +1,47 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const ClubTeamMember = sequelize.define('ClubTeamMember', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
},
clubTeamId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'club_team_id',
},
memberId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'member_id',
},
lineupHalf: {
type: DataTypes.ENUM('first_half', 'second_half'),
allowNull: false,
defaultValue: 'first_half',
field: 'lineup_half',
},
position: {
type: DataTypes.INTEGER,
allowNull: false,
},
}, {
underscored: true,
tableName: 'club_team_member',
timestamps: true,
indexes: [
{
unique: true,
fields: ['club_team_id', 'lineup_half', 'member_id'],
},
{
unique: true,
fields: ['club_team_id', 'lineup_half', 'position'],
}
]
});
export default ClubTeamMember;

View File

@@ -161,6 +161,20 @@ const Member = sequelize.define('Member', {
field: 'member_form_handed_over',
comment: 'Mitgliedsformular ausgehändigt'
},
adultReleaseApproved: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
field: 'adult_release_approved',
comment: 'Jugendspieler mit Freigabe fuer Erwachsene'
},
adultReserveApproved: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
field: 'adult_reserve_approved',
comment: 'Jugendspieler als Ersatz bei Erwachsenen zugelassen'
},
myTischtennisPlayerId: {
type: DataTypes.STRING,
allowNull: true,

View File

@@ -20,6 +20,7 @@ import Match from './Match.js';
import League from './League.js';
import Team from './Team.js';
import ClubTeam from './ClubTeam.js';
import ClubTeamMember from './ClubTeamMember.js';
import TeamDocument from './TeamDocument.js';
import Season from './Season.js';
import Location from './Location.js';
@@ -168,6 +169,11 @@ ClubTeam.belongsTo(League, { foreignKey: 'leagueId', as: 'league' });
Season.hasMany(ClubTeam, { foreignKey: 'seasonId', as: 'clubTeams' });
ClubTeam.belongsTo(Season, { foreignKey: 'seasonId', as: 'season' });
ClubTeam.hasMany(ClubTeamMember, { foreignKey: 'clubTeamId', as: 'lineupEntries' });
ClubTeamMember.belongsTo(ClubTeam, { foreignKey: 'clubTeamId', as: 'clubTeam' });
Member.hasMany(ClubTeamMember, { foreignKey: 'memberId', as: 'clubTeamAssignments' });
ClubTeamMember.belongsTo(Member, { foreignKey: 'memberId', as: 'member' });
// TeamDocument relationships
ClubTeam.hasMany(TeamDocument, { foreignKey: 'clubTeamId', as: 'documents' });
TeamDocument.belongsTo(ClubTeam, { foreignKey: 'clubTeamId', as: 'clubTeam' });
@@ -406,6 +412,7 @@ export {
League,
Team,
ClubTeam,
ClubTeamMember,
TeamDocument,
Group,
GroupActivity,

View File

@@ -6,7 +6,9 @@ import {
createClubTeam,
updateClubTeam,
deleteClubTeam,
getLeagues
getLeagues,
getClubTeamLineup,
updateClubTeamLineup
} from '../controllers/clubTeamController.js';
const router = express.Router();
@@ -23,6 +25,10 @@ router.get('/leagues/:clubid', authenticate, getLeagues);
// Get a specific club team
router.get('/:clubteamid', authenticate, getClubTeam);
// Get/save lineup for a specific club team
router.get('/:clubteamid/lineup', authenticate, getClubTeamLineup);
router.put('/:clubteamid/lineup', authenticate, updateClubTeamLineup);
// Update a club team
router.put('/:clubteamid', authenticate, updateClubTeam);

View File

@@ -11,7 +11,7 @@ import { initializeSocketIO } from './services/socketService.js';
import {
User, Log, Club, UserClub, Member, DiaryDate, Participant, Activity, MemberNote,
DiaryNote, DiaryTag, MemberDiaryTag, DiaryDateTag, DiaryMemberNote, DiaryMemberTag,
PredefinedActivity, PredefinedActivityImage, DiaryDateActivity, DiaryMemberActivity, Match, League, Team, ClubTeam, TeamDocument, Group,
PredefinedActivity, PredefinedActivityImage, DiaryDateActivity, DiaryMemberActivity, Match, League, Team, ClubTeam, ClubTeamMember, TeamDocument, Group,
GroupActivity, Tournament, TournamentGroup, TournamentMatch, TournamentResult,
TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember, MyTischtennis, ClickTtAccount, MyTischtennisUpdateHistory, MyTischtennisFetchLog, ApiLog, MemberTransferConfig, MemberContact, MemberTtrHistory
, MemberOrder, MemberOrderHistory

View File

@@ -1,10 +1,16 @@
import ClubTeam from '../models/ClubTeam.js';
import ClubTeamMember from '../models/ClubTeamMember.js';
import League from '../models/League.js';
import Member from '../models/Member.js';
import Season from '../models/Season.js';
import SeasonService from './seasonService.js';
import { devLog } from '../utils/logger.js';
class ClubTeamService {
static isMissingTeamLineupTable(error) {
return error?.original?.code === 'ER_NO_SUCH_TABLE'
&& String(error?.original?.sqlMessage || '').includes('club_team_member');
}
/**
* Holt alle ClubTeams für einen Verein, optional gefiltert nach Saison.
* Wenn keine Saison-ID angegeben ist, wird die aktuelle Saison verwendet.
@@ -36,6 +42,8 @@ class ClubTeamService {
leagueId: clubTeam.leagueId,
seasonId: clubTeam.seasonId,
myTischtennisTeamId: clubTeam.myTischtennisTeamId,
teamGender: clubTeam.teamGender,
teamAgeGroup: clubTeam.teamAgeGroup,
createdAt: clubTeam.createdAt,
updatedAt: clubTeam.updatedAt,
league: { name: 'Unbekannt' },
@@ -178,6 +186,63 @@ class ClubTeamService {
throw error;
}
}
static async getTeamLineup(clubTeamId, lineupHalf = 'first_half') {
try {
return await ClubTeamMember.findAll({
where: { clubTeamId, lineupHalf },
include: [
{
model: Member,
as: 'member'
}
],
order: [['position', 'ASC']]
});
} catch (error) {
if (this.isMissingTeamLineupTable(error)) {
return [];
}
console.error('[ClubTeamService.getTeamLineup] - Error:', error);
throw error;
}
}
static async replaceTeamLineup(clubTeamId, assignments, lineupHalf = 'first_half') {
try {
await ClubTeamMember.destroy({
where: { clubTeamId, lineupHalf }
});
if (!Array.isArray(assignments) || assignments.length === 0) {
return [];
}
const normalizedAssignments = assignments
.filter((entry) => entry && entry.memberId)
.map((entry, index) => ({
clubTeamId,
lineupHalf,
memberId: Number(entry.memberId),
position: Number(entry.position) || (index + 1)
}));
if (normalizedAssignments.length === 0) {
return [];
}
await ClubTeamMember.bulkCreate(normalizedAssignments);
return await this.getTeamLineup(clubTeamId, lineupHalf);
} catch (error) {
if (this.isMissingTeamLineupTable(error)) {
const tableMissingError = new Error('teamlineuptablemissing');
tableMissingError.code = 'TEAM_LINEUP_TABLE_MISSING';
throw tableMissingError;
}
console.error('[ClubTeamService.replaceTeamLineup] - Error:', error);
throw error;
}
}
}
export default ClubTeamService;

View File

@@ -106,7 +106,7 @@ class MemberService {
}
async setClubMember(userToken, clubId, memberId, firstName, lastName, street, city, postalCode, birthdate, phone, email, active = true, testMembership = false,
picsInInternetAllowed = false, gender = 'unknown', ttr = null, qttr = null, memberFormHandedOver = false, contacts = []) {
picsInInternetAllowed = false, gender = 'unknown', ttr = null, qttr = null, memberFormHandedOver = false, adultReleaseApproved = false, adultReserveApproved = false, contacts = []) {
try {
await checkAccess(userToken, clubId);
let member = null;
@@ -130,6 +130,8 @@ class MemberService {
if (ttr !== undefined) member.ttr = ttr;
if (qttr !== undefined) member.qttr = qttr;
member.memberFormHandedOver = !!memberFormHandedOver;
member.adultReleaseApproved = !!adultReleaseApproved;
member.adultReserveApproved = !!adultReserveApproved;
await member.save();
// Update contacts if provided
@@ -173,6 +175,8 @@ class MemberService {
ttr: ttr,
qttr: qttr,
memberFormHandedOver: !!memberFormHandedOver,
adultReleaseApproved: !!adultReleaseApproved,
adultReserveApproved: !!adultReserveApproved,
});
// Create contacts if provided