diff --git a/backend/controllers/myTischtennisUrlController.js b/backend/controllers/myTischtennisUrlController.js index 72df2b10..4ecf6c22 100644 --- a/backend/controllers/myTischtennisUrlController.js +++ b/backend/controllers/myTischtennisUrlController.js @@ -3,14 +3,132 @@ import myTischtennisService from '../services/myTischtennisService.js'; import MemberService from '../services/memberService.js'; import autoFetchMatchResultsService from '../services/autoFetchMatchResultsService.js'; import apiLogService from '../services/apiLogService.js'; +import axios from 'axios'; import ClubTeam from '../models/ClubTeam.js'; import League from '../models/League.js'; import Season from '../models/Season.js'; import User from '../models/User.js'; import HttpError from '../exceptions/HttpError.js'; import { devLog } from '../utils/logger.js'; +import { randomUUID } from 'crypto'; + +const teamDataFetchJobs = new Map(); +const TEAM_DATA_JOB_TTL_MS = 60 * 60 * 1000; + +const cleanupFinishedTeamDataJobs = () => { + const now = Date.now(); + for (const [jobId, job] of teamDataFetchJobs.entries()) { + if (job.finishedAt && (now - job.finishedAt) > TEAM_DATA_JOB_TTL_MS) { + teamDataFetchJobs.delete(jobId); + } + } +}; class MyTischtennisUrlController { + async startFetchTeamDataJob(req, res, next) { + try { + const { clubTeamId } = req.body || {}; + if (!clubTeamId) { + throw new HttpError('clubTeamId is required', 400); + } + + cleanupFinishedTeamDataJobs(); + + const jobId = randomUUID(); + const startedAt = Date.now(); + teamDataFetchJobs.set(jobId, { + jobId, + status: 'queued', + startedAt, + updatedAt: startedAt, + finishedAt: null, + clubTeamId, + result: null, + error: null + }); + + const authHeaders = { + authcode: req.headers.authcode, + userid: req.headers.userid + }; + const internalPort = process.env.PORT || 3050; + const internalUrl = `http://127.0.0.1:${internalPort}/api/mytischtennis/fetch-team-data`; + + // Background execution; response is returned immediately. + (async () => { + const job = teamDataFetchJobs.get(jobId); + if (!job) return; + job.status = 'running'; + job.updatedAt = Date.now(); + + try { + const response = await axios.post( + internalUrl, + { clubTeamId }, + { + headers: authHeaders, + timeout: 10 * 60 * 1000, + validateStatus: () => true + } + ); + + if (response.status >= 200 && response.status < 300 && response.data?.success) { + job.status = 'completed'; + job.result = response.data; + job.error = null; + } else { + job.status = 'failed'; + job.result = null; + job.error = response.data?.error || response.data?.message || `Job failed with status ${response.status}`; + } + } catch (error) { + job.status = 'failed'; + job.result = null; + job.error = error?.message || String(error); + } finally { + job.updatedAt = Date.now(); + job.finishedAt = Date.now(); + } + })(); + + return res.status(202).json({ + success: true, + jobId, + status: 'queued' + }); + } catch (error) { + next(error); + } + } + + async getFetchTeamDataJobStatus(req, res, next) { + try { + const { jobId } = req.params; + cleanupFinishedTeamDataJobs(); + const job = teamDataFetchJobs.get(jobId); + + if (!job) { + throw new HttpError('Job not found', 404); + } + + return res.status(200).json({ + success: true, + job: { + jobId: job.jobId, + status: job.status, + startedAt: job.startedAt, + updatedAt: job.updatedAt, + finishedAt: job.finishedAt, + clubTeamId: job.clubTeamId, + result: job.result, + error: job.error + } + }); + } catch (error) { + next(error); + } + } + /** * Parse myTischtennis URL and return configuration data * POST /api/mytischtennis/parse-url diff --git a/backend/routes/myTischtennisRoutes.js b/backend/routes/myTischtennisRoutes.js index bbad74b0..c67864c5 100644 --- a/backend/routes/myTischtennisRoutes.js +++ b/backend/routes/myTischtennisRoutes.js @@ -64,6 +64,12 @@ router.post('/configure-league', myTischtennisUrlController.configureLeague); // POST /api/mytischtennis/fetch-team-data - Manually fetch team data router.post('/fetch-team-data', myTischtennisUrlController.fetchTeamData); +// POST /api/mytischtennis/fetch-team-data/async - Start async manual fetch +router.post('/fetch-team-data/async', myTischtennisUrlController.startFetchTeamDataJob); + +// GET /api/mytischtennis/fetch-team-data/jobs/:jobId - Get async fetch job status +router.get('/fetch-team-data/jobs/:jobId', myTischtennisUrlController.getFetchTeamDataJobStatus); + // GET /api/mytischtennis/team-url/:teamId - Get myTischtennis URL for team router.get('/team-url/:teamId', myTischtennisUrlController.getTeamUrl); diff --git a/frontend/src/views/TeamManagementView.vue b/frontend/src/views/TeamManagementView.vue index 10369450..4459015b 100644 --- a/frontend/src/views/TeamManagementView.vue +++ b/frontend/src/views/TeamManagementView.vue @@ -1176,20 +1176,50 @@ export default { myTischtennisSuccess.value = ''; try { - const response = await apiClient.post('/mytischtennis/fetch-team-data', { + const startResponse = await apiClient.post('/mytischtennis/fetch-team-data/async', { clubTeamId: teamToEdit.value.id - }, { timeout: 30000 }); - - if (response.data && response.data.success) { - const successMessage = getSafeMessage(response.data.message, 'Teamdaten erfolgreich abgerufen.'); + }); + + const jobId = startResponse?.data?.jobId; + if (!jobId) { + throw new Error('Async-Job konnte nicht gestartet werden.'); + } + + const maxPollAttempts = 120; // ~4 Minuten bei 2s Intervall + let completedResponse = null; + for (let attempt = 0; attempt < maxPollAttempts; attempt++) { + await new Promise((resolve) => setTimeout(resolve, 2000)); + const pollResponse = await apiClient.get(`/mytischtennis/fetch-team-data/jobs/${jobId}`, { + timeout: 15000 + }); + const job = pollResponse?.data?.job; + if (!job) continue; + + if (job.status === 'completed') { + completedResponse = job.result; + break; + } + + if (job.status === 'failed') { + const failedMsg = getSafeMessage(job.error, 'Daten konnten nicht abgerufen werden.'); + throw new Error(failedMsg); + } + } + + if (!completedResponse) { + throw new Error('Zeitüberschreitung beim Abruf (Async-Job läuft zu lange).'); + } + + if (completedResponse && completedResponse.success) { + const successMessage = getSafeMessage(completedResponse.message, 'Teamdaten erfolgreich abgerufen.'); myTischtennisSuccess.value = successMessage; - const teamName = getSafeMessage(response.data.data?.teamName, teamToEdit.value?.name || 'Unbekanntes Team'); - const fetchedCount = getSafeMessage(String(response.data.data?.fetchedCount ?? ''), '0'); + const teamName = getSafeMessage(completedResponse.data?.teamName, teamToEdit.value?.name || 'Unbekanntes Team'); + const fetchedCount = getSafeMessage(String(completedResponse.data?.fetchedCount ?? ''), '0'); let detailsMessage = `Team: ${teamName}\nAbgerufene Datensätze: ${fetchedCount}`; - if (response.data.data?.tableUpdate) { - const tableUpdate = getSafeMessage(response.data.data.tableUpdate); + if (completedResponse.data?.tableUpdate) { + const tableUpdate = getSafeMessage(completedResponse.data.tableUpdate); if (tableUpdate) { detailsMessage += `\n\nTabellenaktualisierung:\n${tableUpdate}`; } @@ -1201,23 +1231,25 @@ export default { detailsMessage, 'success' ); - } else if (response.data && response.data.success === false) { - const errorTitle = response.data.needsMyTischtennisReauth ? 'Login bei myTischtennis erforderlich' : 'Fehler'; - const errorMessage = getSafeMessage(response.data.error, 'Daten konnten nicht abgerufen werden.'); - const details = response.data.debug ? getSafeMessage(JSON.stringify(response.data.debug, null, 2)) : ''; + } else if (completedResponse && completedResponse.success === false) { + const errorTitle = completedResponse.needsMyTischtennisReauth ? 'Login bei myTischtennis erforderlich' : 'Fehler'; + const errorMessage = getSafeMessage(completedResponse.error, 'Daten konnten nicht abgerufen werden.'); + const details = completedResponse.debug ? getSafeMessage(JSON.stringify(completedResponse.debug, null, 2)) : ''; await showInfo( errorTitle, errorMessage, details, - response.data.needsMyTischtennisReauth ? 'warning' : 'error' + completedResponse.needsMyTischtennisReauth ? 'warning' : 'error' ); myTischtennisError.value = errorMessage; } } catch (error) { console.error('Fehler beim Abrufen der Team-Daten:', error); - const isTimeout = error?.code === 'ECONNABORTED'; + const isTimeout = error?.code === 'ECONNABORTED' || /Zeitüberschreitung/i.test(String(error?.message || '')); const errData = error?.response?.data || {}; - const errorMsg = isTimeout ? 'Zeitüberschreitung beim Abruf (Timeout).' : getSafeMessage(errData.message || errData.error, 'Daten konnten nicht abgerufen werden.'); + const errorMsg = isTimeout + ? 'Zeitüberschreitung beim Abruf (Timeout).' + : getSafeMessage(error?.message || errData.message || errData.error, 'Daten konnten nicht abgerufen werden.'); const details = errData.debug ? getSafeMessage(JSON.stringify(errData.debug, null, 2)) : ''; myTischtennisError.value = errorMsg; await showInfo('Fehler', errorMsg, details, isTimeout ? 'warning' : 'error');