feat: add player cancellation handling and member deactivation tracking
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 57s

This commit is contained in:
Torsten Schulz (local)
2026-09-22 15:00:21 +02:00
parent 57fe139e67
commit a1f509e23e
11 changed files with 162 additions and 43 deletions

View File

@@ -123,14 +123,15 @@ export const updateMatchPlayers = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { matchId } = req.params;
const { playersReady, playersPlanned, playersPlayed } = req.body;
const { playersReady, playersPlanned, playersPlayed, playersCancelled } = req.body;
const result = await MatchService.updateMatchPlayers(
userToken,
matchId,
playersReady,
playersPlanned,
playersPlayed
playersPlayed,
playersCancelled
);
if (result.clubId) {

View File

@@ -29,6 +29,14 @@ const DiaryDate = sequelize.define('DiaryDate', {
allowNull: false,
defaultValue: false,
field: 'exclude_from_billing',
},
nonDeactivatedMemberCount: {
// Anzahl der zu diesem Zeitpunkt nicht deaktivierten Vereinsmitglieder.
// Dieser Wert ist ein Snapshot und darf nicht aus dem heutigen Mitgliederstand
// abgeleitet werden.
type: DataTypes.INTEGER,
allowNull: true,
field: 'non_deactivated_member_count',
}
}, {
tableName: 'diary_dates',

View File

@@ -127,6 +127,12 @@ const Match = sequelize.define('Match', {
comment: 'Array of member IDs who actually played',
field: 'players_played'
},
playersCancelled: {
type: DataTypes.JSON,
allowNull: true,
comment: 'Array of member IDs who declined to play',
field: 'players_cancelled'
},
fixtureType: {
type: DataTypes.STRING,
allowNull: false,

View File

@@ -189,6 +189,11 @@ const Member = sequelize.define('Member', {
allowNull: false,
default: true,
},
deactivatedAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'deactivated_at',
},
testMembership: {
type: DataTypes.BOOLEAN,
allowNull: false,

View File

@@ -1,6 +1,29 @@
import sequelize from '../database.js';
const migrations = [
{
id: '20260922_add_match_player_cancellations',
async up() {
const [columns] = await sequelize.query("SHOW COLUMNS FROM `match` LIKE 'players_cancelled'");
if (columns.length === 0) {
await sequelize.query('ALTER TABLE `match` ADD COLUMN `players_cancelled` JSON NULL');
}
},
},
{
id: '20260922_add_member_deactivation_and_diary_member_snapshot',
async up() {
const [memberColumns] = await sequelize.query("SHOW COLUMNS FROM `member` LIKE 'deactivated_at'");
if (memberColumns.length === 0) {
await sequelize.query('ALTER TABLE `member` ADD COLUMN `deactivated_at` DATETIME NULL');
}
const [diaryDateColumns] = await sequelize.query("SHOW COLUMNS FROM `diary_dates` LIKE 'non_deactivated_member_count'");
if (diaryDateColumns.length === 0) {
await sequelize.query('ALTER TABLE `diary_dates` ADD COLUMN `non_deactivated_member_count` INT NULL');
}
},
},
{
id: '20260922_add_official_league_table_fields',
async up() {

View File

@@ -1,4 +1,5 @@
import DiaryDate from '../models/DiaryDates.js';
import Member from '../models/Member.js';
import DiaryDateActivity from '../models/DiaryDateActivity.js';
import Club from '../models/Club.js';
import DiaryNote from '../models/DiaryNote.js';
@@ -39,12 +40,16 @@ class DiaryService {
if (trainingStart && trainingEnd && trainingStart >= trainingEnd) {
throw new HttpError('Training start time must be before training end time', 400);
}
// Die heutige Mitgliederliste ist für alte Trainingstage nicht aussagekräftig.
// Deshalb wird die Zahl beim Anlegen des Trainingstags dauerhaft gespeichert.
const nonDeactivatedMemberCount = await Member.count({ where: { clubId, active: true } });
const newDate = await DiaryDate.create({
date: parsedDate,
clubId,
trainingStart: trainingStart || null,
trainingEnd: trainingEnd || null,
excludeFromBilling: Boolean(excludeFromBilling),
nonDeactivatedMemberCount,
});
return newDate;

View File

@@ -25,7 +25,7 @@ class MatchService {
code: match.code, homePin: match.homePin, guestPin: match.guestPin,
homeMatchPoints: match.homeMatchPoints || 0, guestMatchPoints: match.guestMatchPoints || 0,
isCompleted: match.isCompleted || false, pdfUrl: match.pdfUrl,
playersReady: match.playersReady || [], playersPlanned: match.playersPlanned || [], playersPlayed: match.playersPlayed || [],
playersReady: match.playersReady || [], playersPlanned: match.playersPlanned || [], playersPlayed: match.playersPlayed || [], playersCancelled: match.playersCancelled || [],
fixtureType: match.fixtureType || 'league', notes: match.notes || null,
homeTeam: { name: 'Unbekannt' }, guestTeam: { name: 'Unbekannt' },
location: { name: 'Unbekannt', address: '', city: '', zip: '' }, leagueDetails: { name: 'Unbekannt' }
@@ -492,6 +492,7 @@ class MatchService {
playersReady: match.playersReady || [],
playersPlanned: match.playersPlanned || [],
playersPlayed: match.playersPlayed || [],
playersCancelled: match.playersCancelled || [],
fixtureType: match.fixtureType || 'league',
notes: match.notes || null,
homeTeam: { name: 'Unbekannt' },
@@ -600,6 +601,7 @@ class MatchService {
playersReady: match.playersReady || [],
playersPlanned: match.playersPlanned || [],
playersPlayed: match.playersPlayed || [],
playersCancelled: match.playersCancelled || [],
fixtureType: match.fixtureType || 'league',
notes: match.notes || null,
homeTeam: { name: 'Unbekannt' },
@@ -726,7 +728,7 @@ class MatchService {
}
}
async updateMatchPlayers(userToken, matchId, playersReady, playersPlanned, playersPlayed) {
async updateMatchPlayers(userToken, matchId, playersReady, playersPlanned, playersPlayed, playersCancelled) {
// Find the match and verify access
const match = await Match.findByPk(matchId, {
include: [
@@ -757,12 +759,18 @@ class MatchService {
const readyList = normalizeList(playersReady);
const plannedList = normalizeList(playersPlanned);
const playedList = normalizeList(playersPlayed);
const cancelledList = normalizeList(playersCancelled);
// Eine Absage schließt alle anderen Spielstatus aus.
const cancelledIds = new Set((cancelledList !== null ? cancelledList : (match.playersCancelled || [])).map(String));
const excludeCancelled = (list) => (list || []).filter((id) => !cancelledIds.has(String(id)));
// Wenn Listen übergeben wurden, gelten sie als Quelle der Wahrheit
await match.update({
playersReady: readyList !== null ? readyList : (match.playersReady || []),
playersPlanned: plannedList !== null ? plannedList : (match.playersPlanned || []),
playersPlayed: playedList !== null ? playedList : (match.playersPlayed || [])
playersReady: excludeCancelled(readyList !== null ? readyList : (match.playersReady || [])),
playersPlanned: excludeCancelled(plannedList !== null ? plannedList : (match.playersPlanned || [])),
playersPlayed: excludeCancelled(playedList !== null ? playedList : (match.playersPlayed || [])),
playersCancelled: cancelledList !== null ? cancelledList : (match.playersCancelled || [])
});
// Aktualisiertes Match nochmals laden und für WebSocket-Broadcast anreichern (gleiche Struktur wie getMatchesForLeague)
@@ -788,6 +796,7 @@ class MatchService {
playersReady: updated.playersReady || [],
playersPlanned: updated.playersPlanned || [],
playersPlayed: updated.playersPlayed || [],
playersCancelled: updated.playersCancelled || [],
fixtureType: updated.fixtureType || 'league',
notes: updated.notes || null,
homeTeam: { name: 'Unbekannt' },
@@ -818,6 +827,7 @@ class MatchService {
playersReady: updated.playersReady,
playersPlanned: updated.playersPlanned,
playersPlayed: updated.playersPlayed,
playersCancelled: updated.playersCancelled,
match: enriched
};
}

View File

@@ -304,6 +304,7 @@ class MemberService {
const MemberContact = (await import('../models/MemberContact.js')).default;
const normalizedContributionGroupCode = String(contributionGroupCode || '').trim() || null;
if (member) {
const wasActive = member.active;
member.firstName = firstName;
member.lastName = lastName;
member.street = street;
@@ -313,6 +314,11 @@ class MemberService {
member.phone = phone ? standardizePhoneNumber(phone) : phone;
member.email = email;
member.active = active;
if (wasActive && !active) {
member.deactivatedAt = new Date();
} else if (!wasActive && active) {
member.deactivatedAt = null;
}
member.testMembership = testMembership;
member.picsInInternetAllowed = picsInInternetAllowed;
if (gender) member.gender = gender;
@@ -359,6 +365,7 @@ class MemberService {
email: email,
clubId: clubId,
active: active,
deactivatedAt: active ? null : new Date(),
testMembership: testMembership,
picsInInternetAllowed: picsInInternetAllowed,
gender: gender || 'unknown',
@@ -1572,6 +1579,7 @@ class MemberService {
}
member.active = false;
member.deactivatedAt = new Date();
await member.save();
return {

View File

@@ -110,7 +110,7 @@ class TrainingStatsService {
clubId,
date: { [Op.gte]: twelveMonthsAgo }
},
attributes: ['date'],
attributes: ['date', 'nonDeactivatedMemberCount'],
order: [['date', 'DESC']]
});
const trainingDateValues12Months = trainingDates12Months.map(entry => entry.date);
@@ -218,6 +218,11 @@ class TrainingStatsService {
const formattedTrainingDays = trainingDays.map((day) => ({
id: day.id,
date: day.date,
// Alte Trainingstage haben noch keinen Snapshot. Für sie bleibt die bisherige
// Näherung erhalten; alle neu angelegten Trainingstage sind exakt.
nonDeactivatedMemberCount: Number.isInteger(day.nonDeactivatedMemberCount)
? day.nonDeactivatedMemberCount
: members.length,
participantCount: day.participantList
? day.participantList.filter((participant) => !participant.attendanceStatus || participant.attendanceStatus === 'present').length
: 0,
@@ -240,8 +245,12 @@ class TrainingStatsService {
const totalParticipants12Months = formattedTrainingDays.reduce((sum, day) => sum + (day.participantCount || 0), 0);
const averageParticipants12Months = trainingsCount12Months > 0 ? totalParticipants12Months / trainingsCount12Months : 0;
const attendanceRate12Months = (members.length > 0 && trainingsCount12Months > 0)
? (totalParticipants12Months / (members.length * trainingsCount12Months)) * 100
const availableMemberSlots12Months = formattedTrainingDays.reduce(
(sum, day) => sum + Math.max(0, day.nonDeactivatedMemberCount || 0),
0,
);
const attendanceRate12Months = availableMemberSlots12Months > 0
? (totalParticipants12Months / availableMemberSlots12Months) * 100
: 0;
const inactiveMembersCount = stats.filter((member) => member.notInTraining).length;
const bestTrainingDay = formattedTrainingDays.reduce((best, day) => {
@@ -306,6 +315,7 @@ class TrainingStatsService {
totalParticipants12Months,
averageParticipants12Months,
attendanceRate12Months,
availableMemberSlots12Months,
inactiveMembersCount,
bestTrainingDay,
},