feat(socket): implement match report submission and schedule update events

- Added WebSocket events for match report submission and schedule updates, enhancing real-time communication between clients and the server.
- Updated matchController to emit schedule updates when match players are modified.
- Enhanced nuscoreApiRoutes to emit match report submissions with relevant data for other clients.
- Implemented socket service methods for handling incoming match report submissions and schedule updates in the frontend.
- Updated MatchReportApiDialog and ScheduleView components to handle new WebSocket events, ensuring data synchronization across clients.
This commit is contained in:
Torsten Schulz (local)
2026-02-26 17:07:54 +01:00
parent 0ee9e486b5
commit b3bbca3887
7 changed files with 275 additions and 8 deletions

View File

@@ -1,6 +1,6 @@
import MatchService from '../services/matchService.js';
import fs from 'fs';
import { emitScheduleMatchUpdated } from '../services/socketService.js';
import { devLog } from '../utils/logger.js';
export const uploadCSV = async (req, res) => {
try {
@@ -116,7 +116,11 @@ export const updateMatchPlayers = async (req, res) => {
playersPlanned,
playersPlayed
);
if (result.clubId) {
emitScheduleMatchUpdated(result.clubId, result.id, result.match || null);
}
return res.status(200).json({
message: 'Match players updated successfully',
data: result

View File

@@ -1,5 +1,6 @@
import express from 'express';
import fetch from 'node-fetch';
import { emitMatchReportSubmitted } from '../services/socketService.js';
const router = express.Router();
@@ -256,6 +257,12 @@ router.put('/submit/:uuid', async (req, res) => {
'Cache-Control': 'no-cache, no-store, must-revalidate'
});
const clubId = reportData.clubId;
const matchCode = reportData.gameCode || reportData.code;
if (clubId && (matchCode || uuid)) {
emitMatchReportSubmitted(clubId, matchCode || uuid, reportData);
}
res.json({
success: true,
data: responseData,
@@ -271,6 +278,17 @@ router.put('/submit/:uuid', async (req, res) => {
}
});
// Nur Broadcast: aktueller Spielberichtsentwurf (z. B. Satzergebnisse) an andere Clients senden, ohne bei nuscore zu speichern
router.post('/broadcast-draft', (req, res) => {
const { clubId, gameCode, matchData } = req.body || {};
if (!clubId || !(gameCode ?? matchData?.gameCode ?? matchData?.code)) {
return res.status(400).json({ error: 'clubId und gameCode erforderlich' });
}
const code = String(gameCode ?? matchData?.gameCode ?? matchData?.code ?? '');
emitMatchReportSubmitted(clubId, code, matchData || null);
res.json({ ok: true });
});
// Validate Meeting Report API-Endpunkt (für Zwischenspeicherung)
router.put('/validate/:uuid', async (req, res) => {
const { uuid } = req.params;

View File

@@ -467,12 +467,56 @@ class MatchService {
playersPlanned: plannedList !== null ? plannedList : (match.playersPlanned || []),
playersPlayed: playedList !== null ? playedList : (match.playersPlayed || [])
});
// Aktualisiertes Match nochmals laden und für WebSocket-Broadcast anreichern (gleiche Struktur wie getMatchesForLeague)
const updated = await Match.findByPk(matchId);
const enriched = {
id: updated.id,
date: updated.date,
time: updated.time,
homeTeamId: updated.homeTeamId,
guestTeamId: updated.guestTeamId,
locationId: updated.locationId,
leagueId: updated.leagueId,
code: updated.code,
homePin: updated.homePin,
guestPin: updated.guestPin,
homeMatchPoints: updated.homeMatchPoints || 0,
guestMatchPoints: updated.guestMatchPoints || 0,
isCompleted: updated.isCompleted || false,
pdfUrl: updated.pdfUrl,
playersReady: updated.playersReady || [],
playersPlanned: updated.playersPlanned || [],
playersPlayed: updated.playersPlayed || [],
homeTeam: { name: 'Unbekannt' },
guestTeam: { name: 'Unbekannt' },
location: { name: 'Unbekannt', address: '', city: '', zip: '' },
leagueDetails: { name: 'Unbekannt' }
};
if (updated.homeTeamId) {
const homeTeam = await Team.findByPk(updated.homeTeamId, { attributes: ['name'] });
if (homeTeam) enriched.homeTeam = homeTeam;
}
if (updated.guestTeamId) {
const guestTeam = await Team.findByPk(updated.guestTeamId, { attributes: ['name'] });
if (guestTeam) enriched.guestTeam = guestTeam;
}
if (updated.locationId) {
const location = await Location.findByPk(updated.locationId, { attributes: ['name', 'address', 'city', 'zip'] });
if (location) enriched.location = location;
}
if (updated.leagueId) {
const league = await League.findByPk(updated.leagueId, { attributes: ['name'] });
if (league) enriched.leagueDetails = league;
}
return {
id: match.id,
playersReady: match.playersReady,
playersPlanned: match.playersPlanned,
playersPlayed: match.playersPlayed
id: updated.id,
clubId: updated.clubId,
playersReady: updated.playersReady,
playersPlanned: updated.playersPlanned,
playersPlayed: updated.playersPlayed,
match: enriched
};
}

View File

@@ -225,3 +225,13 @@ export const emitTournamentChanged = (clubId, tournamentId) => {
emitToClub(clubId, 'tournament:changed', { tournamentId });
};
// Event wenn Spielerauswahl (Bereit/Geplant/Gespielt) für ein Match geändert wurde (match = vollständiges angereichertes Match-Objekt)
export const emitScheduleMatchUpdated = (clubId, matchId, match = null) => {
emitToClub(clubId, 'schedule:match:updated', { clubId, matchId, match });
};
// Event wenn Spielbericht (nuscore) abgesendet wurde matchData = vollständiges Objekt für andere Clients
export const emitMatchReportSubmitted = (clubId, matchCode, matchData = null) => {
emitToClub(clubId, 'schedule:match-report:submitted', { clubId, matchCode, matchData });
};

View File

@@ -696,6 +696,7 @@
<script>
import CryptoJS from 'crypto-js';
import apiClient, { backendBaseUrl } from '../apiClient';
import { onMatchReportSubmitted, offMatchReportSubmitted } from '../services/socketService.js';
export default {
name: 'MatchReportDialog',
@@ -732,6 +733,7 @@ export default {
errors: [],
// Aktive Zelle der schwebenden Satz-Tastatur: { matchIndex, setIndex } oder null
editingSetCell: null,
broadcastDraftTimer: null,
// Abschluss-Felder
protestText: '',
finalHomePin: '',
@@ -863,6 +865,25 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
await this.loadData();
this.initializeResults();
this.initializeFinalPins();
this._matchReportSubmittedHandler = (payload) => {
if (!payload?.matchCode || !this.match?.code || String(payload.matchCode) !== String(this.match.code)) {
return;
}
if (payload.matchData && this.meetingDetails) {
this.applyReceivedMatchData(payload.matchData);
} else {
this.loadData();
}
};
onMatchReportSubmitted(this._matchReportSubmittedHandler);
},
beforeUnmount() {
if (this._matchReportSubmittedHandler) {
offMatchReportSubmitted(this._matchReportSubmittedHandler);
}
if (this.broadcastDraftTimer) {
clearTimeout(this.broadcastDraftTimer);
}
},
watch: {
teamNotAppeared(newValue, oldValue) {
@@ -1461,6 +1482,11 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
this.updateMatchData(matchData);
console.log('✅ Match-Daten aktualisiert');
// Für WebSocket-Broadcast: clubId und gameCode mitsenden
const clubId = this.$store?.getters?.currentClub;
if (clubId) matchData.clubId = String(clubId);
if (this.match?.code) matchData.gameCode = this.match.code;
// Sende die Daten an den Backend-Endpunkt
console.log('📤 Sende Spielbericht an Backend...');
const uuid = this.meetingData.nuLigaMeetingUuid;
@@ -2097,6 +2123,68 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
this.isGuestLineupCertified = true;
}
},
/** Entwurf für Broadcast: meetingDetails-Form mit aktuellen Satzergebnissen aus results. */
getDraftMatchData() {
if (!this.meetingDetails || !Array.isArray(this.meetingDetails.matches)) return null;
const matches = this.meetingDetails.matches.map((m, i) => {
const r = this.results[i];
const out = { ...m };
if (r && Array.isArray(r.sets)) {
for (let s = 0; s < 5; s++) {
const setStr = r.sets[s];
if (setStr && String(setStr).includes(':')) {
const parts = String(setStr).split(':').map(x => parseInt(x, 10) || 0);
out[`set${s + 1}A`] = parts[0];
out[`set${s + 1}B`] = parts[1];
}
}
}
return out;
});
return {
matches,
homePin: this.meetingDetails.homePin,
guestPin: this.meetingDetails.guestPin,
startDate: this.meetingData?.startDate ?? this.match?.startDate,
endDate: this.meetingData?.endDate ?? this.match?.endDate
};
},
async broadcastDraft() {
const clubId = this.$store?.getters?.currentClub;
const gameCode = this.match?.code;
const matchData = this.getDraftMatchData();
if (!clubId || !gameCode || !matchData) return;
try {
await apiClient.post('/nuscore/broadcast-draft', { clubId: String(clubId), gameCode, matchData });
} catch (e) {
// still, no hard error for draft broadcast
}
},
broadcastDraftDebounced() {
if (this.broadcastDraftTimer) clearTimeout(this.broadcastDraftTimer);
this.broadcastDraftTimer = setTimeout(() => {
this.broadcastDraftTimer = null;
this.broadcastDraft();
}, 500);
},
/**
* Übernimmt per WebSocket empfangene Match-Daten (von anderem Gerät) in den Dialog.
* Aktualisiert meetingDetails und meetingData, dann Ergebnisse und Aufstellungs-Bestätigung.
*/
applyReceivedMatchData(matchData) {
if (!matchData || !this.meetingDetails) return;
if (Array.isArray(matchData.matches)) {
this.meetingDetails.matches = matchData.matches;
}
if (matchData.homePin != null) this.meetingDetails.homePin = matchData.homePin;
if (matchData.guestPin != null) this.meetingDetails.guestPin = matchData.guestPin;
if (matchData.startDate != null && this.meetingData) this.meetingData.startDate = matchData.startDate;
if (matchData.endDate != null && this.meetingData) this.meetingData.endDate = matchData.endDate;
this.populateResultsFromMeetingDetails();
this.applyLineupCertificationFromMeetingDetails();
},
resolveSide(label, side) {
// label z.B. "A1 B2" oder "DA1 DB1"
try {
@@ -2244,6 +2332,7 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
// Synchronisiere zurück ins Match-Objekt
this.syncResultsToMatch();
this.broadcastDraftDebounced();
},
appendToSet(matchIndex, setIndex, char) {
@@ -2274,6 +2363,7 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
const current = sets[setIndex] || '';
if (current.length >= 6) return;
this.results[matchIndex].sets[setIndex] = current + char;
this.broadcastDraftDebounced();
},
setKeyboardBackspace() {
if (!this.editingSetCell || !this.results[this.editingSetCell.matchIndex]) return;
@@ -2281,6 +2371,7 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
const sets = this.results[matchIndex].sets || [];
const current = sets[setIndex] || '';
this.results[matchIndex].sets[setIndex] = current.slice(0, -1);
this.broadcastDraftDebounced();
},
setKeyboardClear() {
if (!this.editingSetCell || !this.results[this.editingSetCell.matchIndex]) return;
@@ -2289,6 +2380,7 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
this.$set(this.results[matchIndex], 'sets', ['', '', '', '', '']);
}
this.results[matchIndex].sets[setIndex] = '';
this.broadcastDraftDebounced();
},
setKeyboardOk() {
if (!this.editingSetCell) return;
@@ -2300,6 +2392,7 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
const m = this.results[idx];
m.completed = false;
m.result = '1:0';
this.broadcastDraftDebounced();
},
async loadClubSettings() {
try {
@@ -2405,6 +2498,7 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
// Aktualisiere die Anzeige
this.$forceUpdate();
this.broadcastDraftDebounced();
},
// Berechne gewonnene Sätze für einen Spieler in einem Match

View File

@@ -308,6 +308,18 @@ export const onTournamentChanged = (callback) => {
}
};
export const onScheduleMatchUpdated = (callback) => {
if (socket) {
socket.on('schedule:match:updated', callback);
}
};
export const onMatchReportSubmitted = (callback) => {
if (socket) {
socket.on('schedule:match-report:submitted', callback);
}
};
// Event-Listener entfernen
export const offParticipantAdded = (callback) => {
if (socket) {
@@ -399,3 +411,15 @@ export const offTournamentChanged = (callback) => {
}
};
export const offScheduleMatchUpdated = (callback) => {
if (socket) {
socket.off('schedule:match:updated', callback);
}
};
export const offMatchReportSubmitted = (callback) => {
if (socket) {
socket.off('schedule:match-report:submitted', callback);
}
};

View File

@@ -303,6 +303,14 @@ import InfoDialog from '../components/InfoDialog.vue';
import ConfirmDialog from '../components/ConfirmDialog.vue';
import BaseDialog from '../components/BaseDialog.vue';
import CsvImportDialog from '../components/CsvImportDialog.vue';
import {
connectSocket,
disconnectSocket,
onScheduleMatchUpdated,
offScheduleMatchUpdated,
onMatchReportSubmitted,
offMatchReportSubmitted
} from '../services/socketService.js';
export default {
name: 'ScheduleView',
components: {
@@ -316,6 +324,18 @@ export default {
computed: {
...mapGetters(['isAuthenticated', 'currentClub', 'clubs', 'currentClubName']),
},
watch: {
currentClub(newVal) {
offScheduleMatchUpdated(this.handleScheduleMatchUpdated);
offMatchReportSubmitted(this.handleMatchReportSubmitted);
disconnectSocket();
if (newVal) {
connectSocket(newVal);
onScheduleMatchUpdated(this.handleScheduleMatchUpdated);
onMatchReportSubmitted(this.handleMatchReportSubmitted);
}
},
},
data() {
return {
// Dialog States
@@ -1016,13 +1036,66 @@ export default {
this.fetchingTable = false;
}
},
refreshScheduleData() {
if (!this.selectedLeague) return;
if (this.selectedTeam) {
this.loadMatchesForTeam(this.selectedTeam);
} else if (this.selectedLeague === this.$t('schedule.overallSchedule')) {
this.loadAllMatches();
} else if (this.selectedLeague === this.$t('schedule.adultSchedule')) {
this.loadAdultMatches();
}
},
handleScheduleMatchUpdated(payload) {
if (payload?.match && payload.matchId != null) {
const idx = this.matches.findIndex(m => m.id === payload.matchId);
if (idx !== -1) {
this.matches.splice(idx, 1, payload.match);
return;
}
}
this.refreshScheduleData();
},
handleMatchReportSubmitted(payload) {
if (payload?.matchData && (payload.matchCode != null || payload.matchData?.gameCode || payload.matchData?.code)) {
const code = String(payload.matchCode ?? payload.matchData?.gameCode ?? payload.matchData?.code ?? '');
const idx = this.matches.findIndex(m => m.code === code);
if (idx !== -1) {
const m = { ...this.matches[idx] };
const d = payload.matchData;
if (d.homeMatches != null) m.homeMatchPoints = d.homeMatches;
if (d.guestMatches != null) m.guestMatchPoints = d.guestMatches;
if (typeof d.isCompleted === 'boolean') m.isCompleted = d.isCompleted;
if (d.startDate) m.startDate = d.startDate;
if (d.endDate) m.endDate = d.endDate;
this.matches.splice(idx, 1, m);
return;
}
}
this.refreshScheduleData();
},
},
async created() {
// Teams werden geladen, sobald eine Saison ausgewählt ist
// Die SeasonSelector-Komponente wählt automatisch die aktuelle Saison aus
// und ruft anschließend onSeasonChange auf, was loadTeams() ausführt
this.loadTeams();
}
},
mounted() {
if (this.currentClub) {
connectSocket(this.currentClub);
onScheduleMatchUpdated(this.handleScheduleMatchUpdated);
onMatchReportSubmitted(this.handleMatchReportSubmitted);
}
},
beforeUnmount() {
offScheduleMatchUpdated(this.handleScheduleMatchUpdated);
offMatchReportSubmitted(this.handleMatchReportSubmitted);
disconnectSocket();
},
};
</script>