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,70 @@
import FriendlyMatchService from '../services/friendlyMatchService.js';
import { emitScheduleMatchUpdated } from '../services/socketService.js';
function userTokenFrom(req) {
return req.headers.authcode;
}
export const listFriendlyMatches = async (req, res) => {
try {
const matches = await FriendlyMatchService.list(userTokenFrom(req), req.params.clubId);
res.status(200).json(matches);
} catch (error) {
console.error('[listFriendlyMatches] Error:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Freundschaftsspiele konnten nicht geladen werden' });
}
};
export const createFriendlyMatch = async (req, res) => {
try {
const match = await FriendlyMatchService.create(userTokenFrom(req), req.params.clubId, req.body);
emitScheduleMatchUpdated(req.params.clubId, match.id, match);
res.status(201).json(match);
} catch (error) {
console.error('[createFriendlyMatch] Error:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Freundschaftsspiel konnte nicht erstellt werden' });
}
};
export const updateFriendlyMatch = async (req, res) => {
try {
const match = await FriendlyMatchService.update(userTokenFrom(req), req.params.clubId, req.params.matchId, req.body);
emitScheduleMatchUpdated(req.params.clubId, match.id, match);
res.status(200).json(match);
} catch (error) {
console.error('[updateFriendlyMatch] Error:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Freundschaftsspiel konnte nicht gespeichert werden' });
}
};
export const deleteFriendlyMatch = async (req, res) => {
try {
const result = await FriendlyMatchService.remove(userTokenFrom(req), req.params.clubId, req.params.matchId);
emitScheduleMatchUpdated(req.params.clubId, Number(req.params.matchId), null);
res.status(200).json(result);
} catch (error) {
console.error('[deleteFriendlyMatch] Error:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Freundschaftsspiel konnte nicht gelöscht werden' });
}
};
export const updateFriendlyMatchPlayers = async (req, res) => {
try {
const match = await FriendlyMatchService.updatePlayers(userTokenFrom(req), req.params.clubId, req.params.matchId, req.body);
emitScheduleMatchUpdated(req.params.clubId, match.id, match);
res.status(200).json({ message: 'Teilnehmer gespeichert', data: match });
} catch (error) {
console.error('[updateFriendlyMatchPlayers] Error:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Teilnehmer konnten nicht gespeichert werden' });
}
};
export const getFriendlyMatchMembers = async (req, res) => {
try {
const members = await FriendlyMatchService.members(userTokenFrom(req), req.params.clubId);
res.status(200).json(members);
} catch (error) {
console.error('[getFriendlyMatchMembers] Error:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Mitglieder konnten nicht geladen werden' });
}
};

View File

@@ -0,0 +1,2 @@
ALTER TABLE `friendly_match`
ADD COLUMN IF NOT EXISTS `result_details` JSON NULL AFTER `guest_participants`;

View File

@@ -0,0 +1,30 @@
CREATE TABLE IF NOT EXISTS `friendly_match` (
`id` INT NOT NULL AUTO_INCREMENT,
`club_id` INT NOT NULL,
`date` DATE NOT NULL,
`time` TIME NULL,
`home_team_name` VARCHAR(255) NOT NULL,
`guest_team_name` VARCHAR(255) NOT NULL,
`location_name` VARCHAR(255) NULL,
`location_address` VARCHAR(255) NULL,
`location_city` VARCHAR(255) NULL,
`location_zip` VARCHAR(32) NULL,
`match_system` VARCHAR(120) NOT NULL DEFAULT 'Braunschweiger System',
`singles_count` INT NOT NULL DEFAULT 12,
`doubles_count` INT NOT NULL DEFAULT 4,
`winning_sets` INT NOT NULL DEFAULT 3,
`home_match_points` INT NOT NULL DEFAULT 0,
`guest_match_points` INT NOT NULL DEFAULT 0,
`is_completed` TINYINT(1) NOT NULL DEFAULT 0,
`home_participants` JSON NULL,
`guest_participants` JSON NULL,
`result_details` JSON NULL,
`players_ready` JSON NULL,
`players_planned` JSON NULL,
`players_played` JSON NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_friendly_match_club_date` (`club_id`, `date`),
KEY `idx_friendly_match_completed` (`club_id`, `is_completed`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,132 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const FriendlyMatch = sequelize.define('FriendlyMatch', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
},
clubId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'club_id',
},
date: {
type: DataTypes.DATEONLY,
allowNull: false,
},
time: {
type: DataTypes.TIME,
allowNull: true,
},
homeTeamName: {
type: DataTypes.STRING(255),
allowNull: false,
field: 'home_team_name',
},
guestTeamName: {
type: DataTypes.STRING(255),
allowNull: false,
field: 'guest_team_name',
},
locationName: {
type: DataTypes.STRING(255),
allowNull: true,
field: 'location_name',
},
locationAddress: {
type: DataTypes.STRING(255),
allowNull: true,
field: 'location_address',
},
locationCity: {
type: DataTypes.STRING(255),
allowNull: true,
field: 'location_city',
},
locationZip: {
type: DataTypes.STRING(32),
allowNull: true,
field: 'location_zip',
},
matchSystem: {
type: DataTypes.STRING(120),
allowNull: false,
defaultValue: 'Braunschweiger System',
field: 'match_system',
},
singlesCount: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 12,
field: 'singles_count',
},
doublesCount: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 4,
field: 'doubles_count',
},
winningSets: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 3,
field: 'winning_sets',
},
homeMatchPoints: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
field: 'home_match_points',
},
guestMatchPoints: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
field: 'guest_match_points',
},
isCompleted: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
field: 'is_completed',
},
homeParticipants: {
type: DataTypes.JSON,
allowNull: true,
field: 'home_participants',
},
guestParticipants: {
type: DataTypes.JSON,
allowNull: true,
field: 'guest_participants',
},
resultDetails: {
type: DataTypes.JSON,
allowNull: true,
field: 'result_details',
},
playersReady: {
type: DataTypes.JSON,
allowNull: true,
field: 'players_ready',
},
playersPlanned: {
type: DataTypes.JSON,
allowNull: true,
field: 'players_planned',
},
playersPlayed: {
type: DataTypes.JSON,
allowNull: true,
field: 'players_played',
},
}, {
tableName: 'friendly_match',
underscored: true,
timestamps: true,
});
export default FriendlyMatch;

View File

@@ -57,6 +57,7 @@ import BillingRun from './BillingRun.js';
import BillingDocument from './BillingDocument.js';
import BillingDocumentValue from './BillingDocumentValue.js';
import BillingUserSetting from './BillingUserSetting.js';
import FriendlyMatch from './FriendlyMatch.js';
import MemberTtrHistory from './MemberTtrHistory.js';
import MemberPlayInterest from './MemberPlayInterest.js';
import ClickTtAccount from './ClickTtAccount.js';
@@ -451,6 +452,7 @@ export {
BillingDocument,
BillingDocumentValue,
BillingUserSetting,
FriendlyMatch,
MemberTtrHistory,
MemberPlayInterest,
ClickTtAccount,

View File

@@ -0,0 +1,22 @@
import express from 'express';
import {
createFriendlyMatch,
deleteFriendlyMatch,
getFriendlyMatchMembers,
listFriendlyMatches,
updateFriendlyMatch,
updateFriendlyMatchPlayers,
} from '../controllers/friendlyMatchController.js';
import { authenticate } from '../middleware/authMiddleware.js';
import { authorize } from '../middleware/authorizationMiddleware.js';
const router = express.Router();
router.get('/:clubId', authenticate, authorize('schedule', 'read'), listFriendlyMatches);
router.post('/:clubId', authenticate, authorize('schedule', 'write'), createFriendlyMatch);
router.put('/:clubId/:matchId', authenticate, authorize('schedule', 'write'), updateFriendlyMatch);
router.delete('/:clubId/:matchId', authenticate, authorize('schedule', 'write'), deleteFriendlyMatch);
router.patch('/:clubId/:matchId/players', authenticate, authorize('schedule', 'write'), updateFriendlyMatchPlayers);
router.get('/:clubId/members/list', authenticate, authorize('schedule', 'read'), getFriendlyMatchMembers);
export default router;

View File

@@ -14,7 +14,7 @@ import {
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, MemberPlayInterest,
MemberOrder, MemberOrderHistory, MemberGroupPhoto, BillingTemplate, BillingTemplateField, BillingRun, BillingDocument, BillingDocumentValue, BillingUserSetting, TrainingCancellation
MemberOrder, MemberOrderHistory, MemberGroupPhoto, BillingTemplate, BillingTemplateField, BillingRun, BillingDocument, BillingDocumentValue, BillingUserSetting, FriendlyMatch, TrainingCancellation
, CalendarEvent
} from './models/index.js';
import authRoutes from './routes/authRoutes.js';
@@ -60,6 +60,7 @@ import trainingCancellationRoutes from './routes/trainingCancellationRoutes.js';
import memberOrderRoutes from './routes/memberOrderRoutes.js';
import memberGroupPhotoRoutes from './routes/memberGroupPhotoRoutes.js';
import billingRoutes from './routes/billingRoutes.js';
import friendlyMatchRoutes from './routes/friendlyMatchRoutes.js';
import calendarRoutes from './routes/calendarRoutes.js';
import calendarEventRoutes from './routes/calendarEventRoutes.js';
import schedulerService from './services/schedulerService.js';
@@ -311,6 +312,7 @@ app.use('/api/training-cancellations', trainingCancellationRoutes);
app.use('/api/member-orders', memberOrderRoutes);
app.use('/api/member-group-photos', memberGroupPhotoRoutes);
app.use('/api/billing', billingRoutes);
app.use('/api/friendly-matches', friendlyMatchRoutes);
app.use('/api/calendar', calendarRoutes);
app.use('/api/calendar-events', calendarEventRoutes);
@@ -566,6 +568,7 @@ app.use((err, req, res, next) => {
await safeSync(BillingDocument);
await safeSync(BillingDocumentValue);
await safeSync(BillingUserSetting);
await safeSync(FriendlyMatch);
await safeSync(ClubTeam);
await safeSync(TrainingCancellation);
await safeSync(CalendarEvent);

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();