feat: add player cancellation handling and member deactivation tracking
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 57s
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 57s
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user