feat(myTischtennis): implement asynchronous team data fetching and job status tracking

- Added a new endpoint to start an asynchronous job for fetching team data, allowing for non-blocking operations.
- Implemented job status tracking to retrieve the status of ongoing fetch jobs, enhancing user experience with real-time updates.
- Updated the frontend to initiate async fetch requests and poll for job completion, improving data retrieval efficiency and user feedback.
This commit is contained in:
Torsten Schulz (local)
2026-03-02 13:32:57 +01:00
parent e26bc22e19
commit 3df8f6fd81
3 changed files with 172 additions and 16 deletions

View File

@@ -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

View File

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

View File

@@ -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');