Freundschaftsspiele korrigiert
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 52s

This commit is contained in:
Torsten Schulz (local)
2026-06-06 12:42:17 +02:00
parent 5727404f88
commit 5194d4582f
22 changed files with 1527 additions and 177 deletions

View File

@@ -0,0 +1,58 @@
import ClubVenue from '../models/ClubVenue.js';
import { checkAccess } from '../utils/userUtils.js';
import HttpError from '../exceptions/HttpError.js';
const clean = (value) => String(value || '').trim();
const cleanNullable = (value) => clean(value) || null;
class ClubVenueService {
async list(userToken, clubId) {
await checkAccess(userToken, clubId);
return ClubVenue.findAll({
where: { clubId },
order: [['sortOrder', 'ASC'], ['name', 'ASC'], ['id', 'ASC']],
});
}
async create(userToken, clubId, payload = {}) {
await checkAccess(userToken, clubId);
const name = clean(payload.name);
if (!name) throw new HttpError('Name ist erforderlich', 400);
const maxSortOrder = await ClubVenue.max('sortOrder', { where: { clubId } }) || 0;
return ClubVenue.create({
clubId,
name,
address: cleanNullable(payload.address),
zip: cleanNullable(payload.zip),
city: cleanNullable(payload.city),
sortOrder: Number.isFinite(Number(payload.sortOrder)) ? Number(payload.sortOrder) : maxSortOrder + 1,
});
}
async update(userToken, clubId, venueId, payload = {}) {
await checkAccess(userToken, clubId);
const venue = await ClubVenue.findOne({ where: { id: venueId, clubId } });
if (!venue) throw new HttpError('Spiellokal nicht gefunden', 404);
const updates = {};
if (payload.name !== undefined) {
const name = clean(payload.name);
if (!name) throw new HttpError('Name ist erforderlich', 400);
updates.name = name;
}
if (payload.address !== undefined) updates.address = cleanNullable(payload.address);
if (payload.zip !== undefined) updates.zip = cleanNullable(payload.zip);
if (payload.city !== undefined) updates.city = cleanNullable(payload.city);
if (payload.sortOrder !== undefined && Number.isFinite(Number(payload.sortOrder))) updates.sortOrder = Number(payload.sortOrder);
return venue.update(updates);
}
async delete(userToken, clubId, venueId) {
await checkAccess(userToken, clubId);
const venue = await ClubVenue.findOne({ where: { id: venueId, clubId } });
if (!venue) throw new HttpError('Spiellokal nicht gefunden', 404);
await venue.destroy();
return { success: true };
}
}
export default new ClubVenueService();

View File

@@ -71,6 +71,21 @@ function normalizeIdList(list) {
return result;
}
function isMatchLocked(match) {
const dateText = cleanString(match?.date);
if (!dateText) return false;
const [year, month, day] = dateText.split('-').map((value) => Number.parseInt(value, 10));
if (!year || !month || !day) return false;
const endsAt = new Date(year, month - 1, day, 23, 59, 59, 999);
return endsAt.getTime() <= Date.now();
}
function assertMatchEditable(match) {
if (isMatchLocked(match)) {
throw new HttpError('Der Termin ist verstrichen. Das Freundschaftsspiel ist nur noch sichtbar.', 409);
}
}
function toScheduleRow(match) {
return {
id: match.id,
@@ -100,6 +115,7 @@ function toScheduleRow(match) {
playersReady: normalizeArrayValue(match.playersReady),
playersPlanned: normalizeArrayValue(match.playersPlanned),
playersPlayed: normalizeArrayValue(match.playersPlayed),
isLocked: isMatchLocked(match),
};
}
@@ -153,6 +169,7 @@ class FriendlyMatchService {
await checkAccess(userToken, clubId);
const match = await FriendlyMatch.findOne({ where: { id: matchId, clubId } });
if (!match) throw new HttpError('Freundschaftsspiel nicht gefunden.', 404);
assertMatchEditable(match);
const updates = {};
for (const field of ['date', 'time', 'homeTeamName', 'guestTeamName', 'locationName', 'locationAddress', 'locationCity', 'locationZip', 'matchSystem']) {
@@ -189,6 +206,7 @@ class FriendlyMatchService {
await checkAccess(userToken, clubId);
const match = await FriendlyMatch.findOne({ where: { id: matchId, clubId } });
if (!match) throw new HttpError('Freundschaftsspiel nicht gefunden.', 404);
assertMatchEditable(match);
const ready = normalizeIdList(payload.playersReady);
const planned = normalizeIdList(payload.playersPlanned);

View File

@@ -4,6 +4,7 @@ import FriendlyMatchInvitation from '../models/FriendlyMatchInvitation.js';
import UserClub from '../models/UserClub.js';
import User from '../models/User.js';
import Club from '../models/Club.js';
import Member from '../models/Member.js';
import HttpError from '../exceptions/HttpError.js';
import { checkAccess, getUserByToken } from '../utils/userUtils.js';
import { sendFriendlyMatchInvitationEmail } from './emailService.js';
@@ -66,7 +67,41 @@ function isClubInvolved(clubId, match) {
return Number(match.homeClubId) === id || Number(match.guestClubId) === id;
}
function toSharedScheduleRow(match) {
function isSharedMatchLocked(match) {
const dateText = cleanString(match?.date);
if (!dateText) return false;
const [year, month, day] = dateText.split('-').map((value) => Number.parseInt(value, 10));
if (!year || !month || !day) return false;
const endsAt = new Date(year, month - 1, day, 23, 59, 59, 999);
return endsAt.getTime() <= Date.now();
}
function assertSharedMatchEditable(match) {
if (isSharedMatchLocked(match)) {
throw new HttpError('Der Termin ist verstrichen. Das Freundschaftsspiel ist nur noch sichtbar.', 409);
}
}
function canShowOpponentMembers(match) {
return String(match?.status || '') === 'active' && !isSharedMatchLocked(match);
}
function toSharedScheduleRow(match, viewerClubId = null) {
const hideOpponent = viewerClubId != null && !canShowOpponentMembers(match);
const viewerIsHome = Number(viewerClubId) === Number(match.homeClubId);
const rawHomeParticipants = normalizeArrayValue(match.homeParticipants);
const rawGuestParticipants = normalizeArrayValue(match.guestParticipants);
const rawResultDetails = normalizeArrayValue(match.resultDetails);
const homeParticipants = hideOpponent && !viewerIsHome ? [] : rawHomeParticipants;
const guestParticipants = hideOpponent && viewerIsHome ? [] : rawGuestParticipants;
const resultDetails = hideOpponent
? rawResultDetails.map((row) => ({
...row,
homeName: viewerIsHome ? row?.homeName : '',
guestName: viewerIsHome ? '' : row?.guestName,
}))
: rawResultDetails;
return {
id: match.id,
friendlyMatchId: match.id,
@@ -92,13 +127,14 @@ function toSharedScheduleRow(match) {
singlesCount: match.singlesCount,
doublesCount: match.doublesCount,
winningSets: match.winningSets,
homeParticipants: normalizeArrayValue(match.homeParticipants),
guestParticipants: normalizeArrayValue(match.guestParticipants),
resultDetails: normalizeArrayValue(match.resultDetails),
homeParticipants,
guestParticipants,
resultDetails,
playersReady: normalizeArrayValue(match.playersReady),
playersPlanned: normalizeArrayValue(match.playersPlanned),
playersPlayed: normalizeArrayValue(match.playersPlayed),
status: match.status,
isLocked: isSharedMatchLocked(match),
matchName: match.matchName,
createdFromInvitationId: match.createdFromInvitationId,
};
@@ -143,7 +179,7 @@ class FriendlyMatchSharedService {
const out = matches
.map((match) => {
const row = toSharedScheduleRow(match);
const row = toSharedScheduleRow(match, clubId);
const combined = normalizeTextForSearch([
row.matchName,
row.homeTeam?.name,
@@ -180,7 +216,7 @@ class FriendlyMatchSharedService {
},
order: [['date', 'ASC'], ['startTime', 'ASC'], ['id', 'ASC']],
});
return matches.map(toSharedScheduleRow);
return matches.map((match) => toSharedScheduleRow(match, clubId));
}
async getSharedById(userToken, clubId, matchId) {
@@ -189,7 +225,7 @@ class FriendlyMatchSharedService {
if (!match || !isClubInvolved(clubId, match)) {
throw new HttpError('Gemeinsames Freundschaftsspiel nicht gefunden.', 404);
}
return toSharedScheduleRow(match);
return toSharedScheduleRow(match, clubId);
}
async updateShared(userToken, clubId, matchId, payload = {}) {
@@ -199,8 +235,13 @@ class FriendlyMatchSharedService {
if (!match || !isClubInvolved(clubId, match)) {
throw new HttpError('Gemeinsames Freundschaftsspiel nicht gefunden.', 404);
}
assertSharedMatchEditable(match);
const updates = {};
if (Object.prototype.hasOwnProperty.call(payload, 'time') && !Object.prototype.hasOwnProperty.call(payload, 'startTime')) {
payload.startTime = payload.time;
}
for (const field of ['date', 'startTime', 'matchName', 'homeTeamName', 'guestTeamName', 'locationName', 'locationAddress', 'locationCity', 'locationZip', 'matchSystem', 'status']) {
if (Object.prototype.hasOwnProperty.call(payload, field)) {
updates[field] = ['date', 'homeTeamName', 'guestTeamName', 'matchSystem', 'status'].includes(field)
@@ -229,7 +270,28 @@ class FriendlyMatchSharedService {
}
await match.update(updates);
return toSharedScheduleRow(match);
return toSharedScheduleRow(match, clubId);
}
async membersForSide(userToken, clubId, matchId, side) {
await checkAccess(userToken, clubId);
const match = await FriendlyMatchShared.findByPk(matchId);
if (!match || !isClubInvolved(clubId, match)) {
throw new HttpError('Gemeinsames Freundschaftsspiel nicht gefunden.', 404);
}
const requestedSide = side === 'guest' ? 'guest' : 'home';
const targetClubId = requestedSide === 'guest' ? match.guestClubId : match.homeClubId;
const isOpponent = Number(targetClubId) !== Number(clubId);
if (isOpponent && !canShowOpponentMembers(match)) {
return [];
}
return Member.findAll({
where: { clubId: targetClubId, active: true },
attributes: ['id', 'firstName', 'lastName', 'gender', 'clubId'],
order: [['lastName', 'ASC'], ['firstName', 'ASC']],
});
}
async updateSharedPlayers(userToken, clubId, matchId, payload = {}) {
@@ -239,18 +301,34 @@ class FriendlyMatchSharedService {
if (!match || !isClubInvolved(clubId, match)) {
throw new HttpError('Gemeinsames Freundschaftsspiel nicht gefunden.', 404);
}
assertSharedMatchEditable(match);
const ready = normalizeIdList(payload.playersReady);
const planned = normalizeIdList(payload.playersPlanned);
const played = normalizeIdList(payload.playersPlayed);
const currentClubMembers = await Member.findAll({
where: { clubId },
attributes: ['id'],
});
const currentClubMemberIds = new Set(currentClubMembers.map((member) => Number(member.id)));
const mergeForCurrentClub = (existingValue, nextValue) => {
if (nextValue == null) return normalizeArrayValue(existingValue);
const existing = normalizeArrayValue(existingValue)
.map((id) => Number.parseInt(id, 10))
.filter((id) => Number.isInteger(id));
const preservedOtherClub = existing.filter((id) => !currentClubMemberIds.has(id));
const nextCurrentClub = nextValue.filter((id) => currentClubMemberIds.has(id));
return [...new Set([...preservedOtherClub, ...nextCurrentClub])];
};
await match.update({
playersReady: ready ?? (match.playersReady || []),
playersPlanned: planned ?? (match.playersPlanned || []),
playersPlayed: played ?? (match.playersPlayed || []),
playersReady: mergeForCurrentClub(match.playersReady, ready),
playersPlanned: mergeForCurrentClub(match.playersPlanned, planned),
playersPlayed: mergeForCurrentClub(match.playersPlayed, played),
});
return toSharedScheduleRow(match);
return toSharedScheduleRow(match, clubId);
}
async removeShared(userToken, clubId, matchId) {
@@ -367,7 +445,7 @@ class FriendlyMatchSharedService {
return {
invitation: toInvitationDto(invitation),
sharedMatch: toSharedScheduleRow(shared),
sharedMatch: toSharedScheduleRow(shared, clubId),
};
}