feat: Implement friendly match management features
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 44s

- Added backend support for managing friendly matches including listing, creating, updating, and deleting matches.
- Introduced a new database table `friendly_match` with relevant fields for match details.
- Created a service layer to handle business logic related to friendly matches.
- Developed API routes for friendly match operations with appropriate authentication and authorization.
- Added a Vue component for managing participants in friendly matches, allowing selection of members and manual entry of names.
- Updated existing tournament editor screens to integrate friendly match functionalities.
This commit is contained in:
Torsten Schulz (local)
2026-05-18 00:43:42 +02:00
parent 040e758044
commit 5dfdcb63bc
16 changed files with 1551 additions and 87 deletions

View File

@@ -0,0 +1,214 @@
import FriendlyMatch from '../models/FriendlyMatch.js';
import Member from '../models/Member.js';
import { checkAccess } from '../utils/userUtils.js';
import HttpError from '../exceptions/HttpError.js';
function cleanString(value, fallback = '') {
const text = String(value ?? '').trim();
return text || fallback;
}
function cleanOptionalString(value) {
const text = String(value ?? '').trim();
return text || null;
}
function normalizeParticipantList(list) {
if (typeof list === 'string') {
try {
list = JSON.parse(list);
} catch (error) {
return [];
}
}
if (!Array.isArray(list)) return [];
return list
.map((entry) => {
const type = entry?.type === 'member' ? 'member' : 'manual';
if (type === 'member') {
const memberId = Number.parseInt(entry?.memberId, 10);
if (!Number.isInteger(memberId)) return null;
return { type, memberId };
}
const firstName = cleanString(entry?.firstName);
const lastName = cleanString(entry?.lastName);
if (!firstName && !lastName) return null;
return { type, firstName, lastName };
})
.filter(Boolean);
}
function normalizeArrayValue(value) {
if (Array.isArray(value)) return value;
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : [];
} catch (error) {
return [];
}
}
return [];
}
function normalizeIdList(list) {
if (typeof list === 'string') {
try {
list = JSON.parse(list);
} catch (error) {
return null;
}
}
if (!Array.isArray(list)) return null;
const seen = new Set();
const result = [];
for (const value of list) {
const id = Number.parseInt(value, 10);
if (!Number.isInteger(id) || seen.has(id)) continue;
seen.add(id);
result.push(id);
}
return result;
}
function toScheduleRow(match) {
return {
id: match.id,
friendlyMatchId: match.id,
isFriendly: true,
date: match.date,
time: match.time,
homeTeam: { name: match.homeTeamName },
guestTeam: { name: match.guestTeamName },
location: {
name: match.locationName || 'N/A',
address: match.locationAddress || '',
city: match.locationCity || '',
zip: match.locationZip || '',
},
leagueDetails: { name: 'Freundschaftsspiel' },
homeMatchPoints: match.homeMatchPoints || 0,
guestMatchPoints: match.guestMatchPoints || 0,
isCompleted: match.isCompleted || false,
matchSystem: match.matchSystem,
singlesCount: match.singlesCount,
doublesCount: match.doublesCount,
winningSets: match.winningSets,
homeParticipants: normalizeParticipantList(match.homeParticipants),
guestParticipants: normalizeParticipantList(match.guestParticipants),
resultDetails: normalizeArrayValue(match.resultDetails),
playersReady: normalizeArrayValue(match.playersReady),
playersPlanned: normalizeArrayValue(match.playersPlanned),
playersPlayed: normalizeArrayValue(match.playersPlayed),
};
}
class FriendlyMatchService {
async list(userToken, clubId) {
await checkAccess(userToken, clubId);
const matches = await FriendlyMatch.findAll({
where: { clubId },
order: [['date', 'ASC'], ['time', 'ASC'], ['id', 'ASC']],
});
return matches.map(toScheduleRow);
}
async create(userToken, clubId, payload = {}) {
await checkAccess(userToken, clubId);
const homeTeamName = cleanString(payload.homeTeamName);
const guestTeamName = cleanString(payload.guestTeamName);
const date = cleanString(payload.date);
if (!homeTeamName || !guestTeamName || !date) {
throw new HttpError('Datum, Heimteam und Gastteam sind Pflichtfelder.', 400);
}
const match = await FriendlyMatch.create({
clubId,
date,
time: cleanOptionalString(payload.time),
homeTeamName,
guestTeamName,
locationName: cleanOptionalString(payload.locationName),
locationAddress: cleanOptionalString(payload.locationAddress),
locationCity: cleanOptionalString(payload.locationCity),
locationZip: cleanOptionalString(payload.locationZip),
matchSystem: cleanString(payload.matchSystem, 'Braunschweiger System'),
singlesCount: Number.parseInt(payload.singlesCount, 10) || 12,
doublesCount: Number.parseInt(payload.doublesCount, 10) || 4,
winningSets: Number.parseInt(payload.winningSets, 10) || 3,
homeMatchPoints: Number.parseInt(payload.homeMatchPoints, 10) || 0,
guestMatchPoints: Number.parseInt(payload.guestMatchPoints, 10) || 0,
isCompleted: Boolean(payload.isCompleted),
homeParticipants: normalizeParticipantList(payload.homeParticipants),
guestParticipants: normalizeParticipantList(payload.guestParticipants),
resultDetails: Array.isArray(payload.resultDetails) ? payload.resultDetails : [],
playersReady: [],
playersPlanned: [],
playersPlayed: [],
});
return toScheduleRow(match);
}
async update(userToken, clubId, matchId, payload = {}) {
await checkAccess(userToken, clubId);
const match = await FriendlyMatch.findOne({ where: { id: matchId, clubId } });
if (!match) throw new HttpError('Freundschaftsspiel nicht gefunden.', 404);
const updates = {};
for (const field of ['date', 'time', 'homeTeamName', 'guestTeamName', 'locationName', 'locationAddress', 'locationCity', 'locationZip', 'matchSystem']) {
if (Object.prototype.hasOwnProperty.call(payload, field)) {
updates[field] = field === 'date' || field === 'homeTeamName' || field === 'guestTeamName' || field === 'matchSystem'
? cleanString(payload[field])
: cleanOptionalString(payload[field]);
}
}
for (const field of ['singlesCount', 'doublesCount', 'winningSets', 'homeMatchPoints', 'guestMatchPoints']) {
if (Object.prototype.hasOwnProperty.call(payload, field)) {
updates[field] = Number.parseInt(payload[field], 10) || 0;
}
}
if (Object.prototype.hasOwnProperty.call(payload, 'isCompleted')) updates.isCompleted = Boolean(payload.isCompleted);
if (Object.prototype.hasOwnProperty.call(payload, 'homeParticipants')) updates.homeParticipants = normalizeParticipantList(payload.homeParticipants);
if (Object.prototype.hasOwnProperty.call(payload, 'guestParticipants')) updates.guestParticipants = normalizeParticipantList(payload.guestParticipants);
if (Object.prototype.hasOwnProperty.call(payload, 'resultDetails')) {
updates.resultDetails = Array.isArray(payload.resultDetails) ? payload.resultDetails : [];
}
await match.update(updates);
return toScheduleRow(match);
}
async remove(userToken, clubId, matchId) {
await checkAccess(userToken, clubId);
const deleted = await FriendlyMatch.destroy({ where: { id: matchId, clubId } });
if (!deleted) throw new HttpError('Freundschaftsspiel nicht gefunden.', 404);
return { success: true };
}
async updatePlayers(userToken, clubId, matchId, payload = {}) {
await checkAccess(userToken, clubId);
const match = await FriendlyMatch.findOne({ where: { id: matchId, clubId } });
if (!match) throw new HttpError('Freundschaftsspiel nicht gefunden.', 404);
const ready = normalizeIdList(payload.playersReady);
const planned = normalizeIdList(payload.playersPlanned);
const played = normalizeIdList(payload.playersPlayed);
await match.update({
playersReady: ready ?? (match.playersReady || []),
playersPlanned: planned ?? (match.playersPlanned || []),
playersPlayed: played ?? (match.playersPlayed || []),
});
return toScheduleRow(match);
}
async members(userToken, clubId) {
await checkAccess(userToken, clubId);
return Member.findAll({
where: { clubId, active: true },
attributes: ['id', 'firstName', 'lastName', 'gender'],
order: [['lastName', 'ASC'], ['firstName', 'ASC']],
});
}
}
export default new FriendlyMatchService();