diff --git a/backend/controllers/friendlyMatchController.js b/backend/controllers/friendlyMatchController.js new file mode 100644 index 00000000..3dd03986 --- /dev/null +++ b/backend/controllers/friendlyMatchController.js @@ -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' }); + } +}; diff --git a/backend/migrations/20260518_add_result_details_to_friendly_match.sql b/backend/migrations/20260518_add_result_details_to_friendly_match.sql new file mode 100644 index 00000000..69174ce1 --- /dev/null +++ b/backend/migrations/20260518_add_result_details_to_friendly_match.sql @@ -0,0 +1,2 @@ +ALTER TABLE `friendly_match` + ADD COLUMN IF NOT EXISTS `result_details` JSON NULL AFTER `guest_participants`; diff --git a/backend/migrations/20260518_create_friendly_match.sql b/backend/migrations/20260518_create_friendly_match.sql new file mode 100644 index 00000000..37b2a498 --- /dev/null +++ b/backend/migrations/20260518_create_friendly_match.sql @@ -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; diff --git a/backend/models/FriendlyMatch.js b/backend/models/FriendlyMatch.js new file mode 100644 index 00000000..b30e68b2 --- /dev/null +++ b/backend/models/FriendlyMatch.js @@ -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; diff --git a/backend/models/index.js b/backend/models/index.js index 786e662b..ade07375 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -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, diff --git a/backend/routes/friendlyMatchRoutes.js b/backend/routes/friendlyMatchRoutes.js new file mode 100644 index 00000000..a4898f11 --- /dev/null +++ b/backend/routes/friendlyMatchRoutes.js @@ -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; diff --git a/backend/server.js b/backend/server.js index 4baa145e..5f686122 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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); diff --git a/backend/services/friendlyMatchService.js b/backend/services/friendlyMatchService.js new file mode 100644 index 00000000..c7839cbc --- /dev/null +++ b/backend/services/friendlyMatchService.js @@ -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(); diff --git a/frontend/src/App.vue b/frontend/src/App.vue index ed238d73..6bcb4c31 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -113,6 +113,10 @@ 📅 {{ $t('navigation.schedule') }} + + 🤝 + Freundschaftsspiele +