Compare commits
12 Commits
mytischten
...
dd93755e6b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd93755e6b | ||
|
|
27665a45df | ||
|
|
637bacf70f | ||
|
|
d5fe531664 | ||
|
|
3df8f6fd81 | ||
|
|
e26bc22e19 | ||
|
|
985c9074bd | ||
|
|
d33e9a94cf | ||
|
|
6ab6319256 | ||
|
|
e1e8b5f4a4 | ||
|
|
cf8cf17dc7 | ||
|
|
12bba26ff1 |
@@ -352,11 +352,62 @@ class MyTischtennisClient {
|
||||
* Browser-based fallback login for CAPTCHA flows.
|
||||
* @param {string} email
|
||||
* @param {string} password
|
||||
* @returns {Promise<Object>} Login response with token and session data
|
||||
* @param {Object} [options]
|
||||
* @param {Object} [options.savedStorageState] - Playwright storage state from a previous session.
|
||||
* If provided and the stored auth cookie is still valid, returns immediately without a new login.
|
||||
* @returns {Promise<Object>} Login response with token, session data, and `storageState` for persistence.
|
||||
*/
|
||||
async loginWithBrowserAutomation(email, password) {
|
||||
async loginWithBrowserAutomation(email, password, options = {}) {
|
||||
const { savedStorageState } = options;
|
||||
let browser = null;
|
||||
let context = null;
|
||||
|
||||
// --- Fast path: restore a saved Playwright session ---
|
||||
if (savedStorageState) {
|
||||
try {
|
||||
browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'] });
|
||||
context = await browser.newContext({ storageState: savedStorageState });
|
||||
const cookies = await context.cookies('https://www.mytischtennis.de');
|
||||
const authCookie = cookies.find((c) => c.name === 'sb-10-auth-token' || /^sb-\d+-auth-token$/.test(c.name));
|
||||
if (authCookie?.value) {
|
||||
const tokenMatch = String(authCookie.value).match(/^base64-(.+)$/);
|
||||
if (tokenMatch) {
|
||||
const tokenData = JSON.parse(Buffer.from(tokenMatch[1], 'base64').toString('utf-8'));
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
// Accept if not expired (with 5-minute safety buffer)
|
||||
if (tokenData.expires_at && tokenData.expires_at > nowSec + 300) {
|
||||
console.log('[myTischtennisClient.playwright] Restored session from saved state (no CAPTCHA needed)');
|
||||
const storageState = await context.storageState();
|
||||
await context.close();
|
||||
await browser.close();
|
||||
browser = null; context = null;
|
||||
return {
|
||||
success: true,
|
||||
accessToken: tokenData.access_token,
|
||||
refreshToken: tokenData.refresh_token,
|
||||
expiresAt: tokenData.expires_at,
|
||||
expiresIn: tokenData.expires_in,
|
||||
user: tokenData.user,
|
||||
cookie: `sb-10-auth-token=${authCookie.value}`,
|
||||
storageState,
|
||||
restoredFromCache: true
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
// Cookie absent or expired → close and fall through to full login
|
||||
console.log('[myTischtennisClient.playwright] Saved session expired or invalid, starting full login');
|
||||
await context.close();
|
||||
await browser.close();
|
||||
browser = null; context = null;
|
||||
} catch (restoreErr) {
|
||||
console.warn('[myTischtennisClient.playwright] Session restore failed, starting full login:', restoreErr.message);
|
||||
try { if (context) await context.close(); } catch (_e) { /* ignore */ }
|
||||
try { if (browser) await browser.close(); } catch (_e) { /* ignore */ }
|
||||
browser = null; context = null;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[myTischtennisClient.playwright] Start browser login flow');
|
||||
browser = await chromium.launch({
|
||||
@@ -365,39 +416,84 @@ class MyTischtennisClient {
|
||||
});
|
||||
context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
await page.goto(`${this.baseURL}/login?next=%2F`, { waitUntil: 'domcontentloaded', timeout: 45000 });
|
||||
console.log('[myTischtennisClient.playwright] Page loaded');
|
||||
|
||||
// Best-effort: Consent/overlay dialogs that can block form interaction.
|
||||
const consentSelectors = [
|
||||
'#onetrust-accept-btn-handler',
|
||||
'button:has-text("Alle akzeptieren")',
|
||||
'button:has-text("Akzeptieren")',
|
||||
'button:has-text("Einverstanden")'
|
||||
];
|
||||
for (const selector of consentSelectors) {
|
||||
try {
|
||||
const button = page.locator(selector).first();
|
||||
if (await button.count()) {
|
||||
await button.click({ timeout: 1500 });
|
||||
console.log('[myTischtennisClient.playwright] Consent dialog accepted');
|
||||
break;
|
||||
// Helper: click the CMP/consent "Akzeptieren" button if visible.
|
||||
// Tries multiple selectors to cover different CMP implementations.
|
||||
const acceptConsentDialog = async (waitMs = 0) => {
|
||||
if (waitMs > 0) await page.waitForTimeout(waitMs);
|
||||
const consentSelectors = [
|
||||
'#onetrust-accept-btn-handler',
|
||||
'button:has-text("Alle akzeptieren")',
|
||||
'button:has-text("Akzeptieren")',
|
||||
'button:has-text("Einverstanden")',
|
||||
'button:has-text("Zustimmen")',
|
||||
'[data-testid="accept-button"]',
|
||||
'.cmp-accept-all',
|
||||
'.accept-all-btn'
|
||||
];
|
||||
for (const selector of consentSelectors) {
|
||||
try {
|
||||
const button = page.locator(selector).first();
|
||||
if (await button.count()) {
|
||||
await button.click({ timeout: 2500 });
|
||||
console.log('[myTischtennisClient.playwright] Consent dialog accepted via:', selector);
|
||||
await page.waitForTimeout(800);
|
||||
return true;
|
||||
}
|
||||
} catch (_e) {
|
||||
// try next selector
|
||||
}
|
||||
} catch (_e) {
|
||||
// ignore and try next selector
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Visit the homepage first so the browser receives and stores the correct CMP
|
||||
// consent cookies (the TCF v2 format cannot be guessed and set manually).
|
||||
// After accepting consent here, the login page will not show the banner again.
|
||||
try {
|
||||
await page.goto(this.baseURL, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
const acceptedOnHome = await acceptConsentDialog(0);
|
||||
if (!acceptedOnHome) await acceptConsentDialog(2500);
|
||||
console.log('[myTischtennisClient.playwright] Homepage visited, consent handled');
|
||||
} catch (_homeErr) {
|
||||
console.log('[myTischtennisClient.playwright] Homepage pre-visit failed (continuing):', _homeErr.message);
|
||||
}
|
||||
|
||||
await page.goto(`${this.baseURL}/login?next=%2F`, { waitUntil: 'domcontentloaded', timeout: 45000 });
|
||||
console.log('[myTischtennisClient.playwright] Login page loaded');
|
||||
|
||||
// Second consent attempt in case it re-appears on the login page.
|
||||
const consentOnLogin = await acceptConsentDialog(0);
|
||||
if (!consentOnLogin) await acceptConsentDialog(1500);
|
||||
|
||||
// Fill credentials
|
||||
await page.locator('input[name="email"]').first().fill(email, { timeout: 10000 });
|
||||
await page.locator('input[name="password"]').first().fill(password, { timeout: 10000 });
|
||||
console.log('[myTischtennisClient.playwright] Credentials filled');
|
||||
|
||||
// Try to interact with private-captcha if present.
|
||||
// Try to interact with private-captcha if present (it may render with delay).
|
||||
try {
|
||||
await page.waitForSelector('private-captcha', { timeout: 8000 });
|
||||
} catch (_e) {
|
||||
// ignore: captcha host might not be present in all flows
|
||||
}
|
||||
const captchaHost = page.locator('private-captcha').first();
|
||||
if (await captchaHost.count()) {
|
||||
const hasCaptchaHost = (await captchaHost.count()) > 0;
|
||||
let captchaReadyDetected = !hasCaptchaHost;
|
||||
if (hasCaptchaHost) {
|
||||
try {
|
||||
await page.waitForTimeout(1200);
|
||||
const captchaVisualStateBefore = await page.evaluate(() => {
|
||||
const host = document.querySelector('private-captcha');
|
||||
const checkbox = host?.shadowRoot?.querySelector('#pc-checkbox');
|
||||
return {
|
||||
hostClass: host?.className || null,
|
||||
hostDataState: host?.getAttribute?.('data-state') || null,
|
||||
checkboxClass: checkbox?.className || null,
|
||||
checkboxChecked: !!checkbox?.checked,
|
||||
checkboxAriaChecked: checkbox?.getAttribute?.('aria-checked') || null
|
||||
};
|
||||
});
|
||||
const interaction = await page.evaluate(() => {
|
||||
const host = document.querySelector('private-captcha');
|
||||
const checkbox = host?.shadowRoot?.querySelector('#pc-checkbox');
|
||||
@@ -424,7 +520,7 @@ class MyTischtennisClient {
|
||||
const captchaValue = (captchaField && captchaField.value ? captchaField.value.trim() : '');
|
||||
const clickedValue = (clickedField && clickedField.value ? clickedField.value.toLowerCase() : '');
|
||||
return captchaValue.length > 80 && (clickedValue === 'true' || clickedValue === '1');
|
||||
}, { timeout: 15000 });
|
||||
}, { timeout: 20000 });
|
||||
const captchaState = await page.evaluate(() => {
|
||||
const captchaField = document.querySelector('input[name="captcha"]');
|
||||
const clickedField = document.querySelector('input[name="captcha_clicked"]');
|
||||
@@ -434,10 +530,40 @@ class MyTischtennisClient {
|
||||
};
|
||||
});
|
||||
console.log('[myTischtennisClient.playwright] Captcha value ready:', captchaState);
|
||||
captchaReadyDetected = true;
|
||||
} catch (_waitErr) {
|
||||
// Keep going; some flows still succeed without explicit hidden field update.
|
||||
console.warn('[myTischtennisClient.playwright] Captcha value not ready in time');
|
||||
}
|
||||
|
||||
// Optional diagnostic only: visual state change should never block submit.
|
||||
try {
|
||||
await page.waitForFunction((beforeState) => {
|
||||
const host = document.querySelector('private-captcha');
|
||||
const checkbox = host?.shadowRoot?.querySelector('#pc-checkbox');
|
||||
if (!host || !checkbox) return false;
|
||||
|
||||
const current = {
|
||||
hostClass: host.className || '',
|
||||
hostDataState: host.getAttribute?.('data-state') || '',
|
||||
checkboxClass: checkbox.className || '',
|
||||
checkboxChecked: !!checkbox.checked,
|
||||
checkboxAriaChecked: checkbox.getAttribute?.('aria-checked') || ''
|
||||
};
|
||||
|
||||
const visualChanged =
|
||||
current.hostClass !== (beforeState?.hostClass || '')
|
||||
|| current.hostDataState !== (beforeState?.hostDataState || '')
|
||||
|| current.checkboxClass !== (beforeState?.checkboxClass || '')
|
||||
|| current.checkboxChecked !== !!beforeState?.checkboxChecked
|
||||
|| current.checkboxAriaChecked !== (beforeState?.checkboxAriaChecked || '');
|
||||
|
||||
return visualChanged;
|
||||
}, captchaVisualStateBefore, { timeout: 1500 });
|
||||
console.log('[myTischtennisClient.playwright] Captcha visual state changed');
|
||||
} catch (_visualWaitErr) {
|
||||
// no-op: widget often keeps "ready" class despite solved token
|
||||
}
|
||||
} catch (captchaError) {
|
||||
console.warn('[myTischtennisClient.playwright] Captcha interaction warning:', captchaError?.message || captchaError);
|
||||
}
|
||||
@@ -451,10 +577,64 @@ class MyTischtennisClient {
|
||||
}
|
||||
});
|
||||
|
||||
// Before submit, ensure CAPTCHA fields are actually ready if captcha widget exists.
|
||||
if (hasCaptchaHost) {
|
||||
const isCaptchaReadyNow = await page.evaluate(() => {
|
||||
const captchaField = document.querySelector('input[name="captcha"]');
|
||||
const clickedField = document.querySelector('input[name="captcha_clicked"]');
|
||||
const captchaValue = (captchaField && captchaField.value ? captchaField.value.trim() : '');
|
||||
const clickedValue = (clickedField && clickedField.value ? clickedField.value.toLowerCase() : '');
|
||||
return captchaValue.length > 80 && (clickedValue === 'true' || clickedValue === '1');
|
||||
});
|
||||
captchaReadyDetected = captchaReadyDetected || isCaptchaReadyNow;
|
||||
|
||||
if (!isCaptchaReadyNow) {
|
||||
try {
|
||||
await page.waitForFunction(() => {
|
||||
const captchaField = document.querySelector('input[name="captcha"]');
|
||||
const clickedField = document.querySelector('input[name="captcha_clicked"]');
|
||||
const captchaValue = (captchaField && captchaField.value ? captchaField.value.trim() : '');
|
||||
const clickedValue = (clickedField && clickedField.value ? clickedField.value.toLowerCase() : '');
|
||||
return captchaValue.length > 80 && (clickedValue === 'true' || clickedValue === '1');
|
||||
}, { timeout: 12000 });
|
||||
captchaReadyDetected = true;
|
||||
} catch (_captchaNotReadyErr) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Playwright-Login fehlgeschlagen: CAPTCHA wurde im Browser nicht als gelöst erkannt'
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Human-like pause only after captcha was actually solved (2-6s).
|
||||
if (captchaReadyDetected) {
|
||||
const postCaptchaDelayMs = 2000 + Math.floor(Math.random() * 4001);
|
||||
await page.waitForTimeout(postCaptchaDelayMs);
|
||||
console.log('[myTischtennisClient.playwright] Waited after solved captcha:', postCaptchaDelayMs);
|
||||
}
|
||||
|
||||
// Ensure login intent is present and click the explicit login submit button.
|
||||
await page.evaluate(() => {
|
||||
const form = document.querySelector('form[action*="/login"]');
|
||||
if (!form) return;
|
||||
let intentField = form.querySelector('input[name="intent"]');
|
||||
if (!intentField) {
|
||||
intentField = document.createElement('input');
|
||||
intentField.setAttribute('type', 'hidden');
|
||||
intentField.setAttribute('name', 'intent');
|
||||
form.appendChild(intentField);
|
||||
}
|
||||
intentField.setAttribute('value', 'login');
|
||||
});
|
||||
|
||||
// Submit form
|
||||
const submitButton = page.locator('button[type="submit"], input[type="submit"]').first();
|
||||
if (await submitButton.count()) {
|
||||
await submitButton.click({ timeout: 15000, noWaitAfter: true });
|
||||
const loginSubmitButton = page.locator('button[type="submit"][name="intent"][value="login"]').first();
|
||||
const genericSubmitButton = page.locator('button[type="submit"], input[type="submit"]').first();
|
||||
if (await loginSubmitButton.count()) {
|
||||
await loginSubmitButton.click({ timeout: 15000, noWaitAfter: true });
|
||||
} else if (await genericSubmitButton.count()) {
|
||||
await genericSubmitButton.click({ timeout: 15000, noWaitAfter: true });
|
||||
} else {
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
@@ -462,25 +642,73 @@ class MyTischtennisClient {
|
||||
|
||||
// Wait for auth cookie after submit (polling avoids timing races).
|
||||
let authCookieObj = null;
|
||||
const maxAttempts = 20;
|
||||
let detectedSubmitError = null;
|
||||
const pollIntervalMs = 500;
|
||||
const maxAttempts = 40; // ~20s max wait after submit
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
const cookies = await context.cookies();
|
||||
authCookieObj = cookies.find((c) => c.name === 'sb-10-auth-token');
|
||||
authCookieObj = cookies.find((c) => c.name === 'sb-10-auth-token')
|
||||
|| cookies.find((c) => /^sb-\d+-auth-token$/.test(c.name))
|
||||
|| cookies.find((c) => c.name.includes('auth-token'));
|
||||
if (authCookieObj?.value) {
|
||||
console.log('[myTischtennisClient.playwright] Auth cookie detected:', authCookieObj.name);
|
||||
break;
|
||||
}
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Periodically: dismiss consent banner (may reappear after submit redirect)
|
||||
// and probe page text to fail fast on known error strings.
|
||||
if (attempt % 4 === 0) {
|
||||
try { await acceptConsentDialog(0); } catch (_e) { /* ignore */ }
|
||||
try {
|
||||
const textContent = await page.locator('body').innerText({ timeout: 600 });
|
||||
if (textContent?.includes('Captcha-Bestätigung fehlgeschlagen')) {
|
||||
detectedSubmitError = 'Captcha-Bestätigung fehlgeschlagen';
|
||||
break;
|
||||
}
|
||||
if (textContent?.includes('Captcha-Bestätigung ist erforderlich')) {
|
||||
detectedSubmitError = 'Captcha-Bestätigung ist erforderlich';
|
||||
break;
|
||||
}
|
||||
if (textContent?.includes('Ungültige E-Mail oder Passwort')) {
|
||||
detectedSubmitError = 'Ungültige E-Mail oder Passwort';
|
||||
break;
|
||||
}
|
||||
} catch (_readBodyErr) {
|
||||
// ignore text read errors during polling
|
||||
}
|
||||
}
|
||||
|
||||
await page.waitForTimeout(pollIntervalMs);
|
||||
}
|
||||
if (!authCookieObj || !authCookieObj.value) {
|
||||
let errorText = null;
|
||||
let failureDiagnostics = null;
|
||||
try {
|
||||
const textContent = await page.locator('body').innerText({ timeout: 1000 });
|
||||
if (textContent?.includes('Captcha-Bestätigung fehlgeschlagen')) {
|
||||
errorText = 'Captcha-Bestätigung fehlgeschlagen';
|
||||
}
|
||||
if (!errorText && textContent?.includes('Passwort')) {
|
||||
errorText = 'Login vermutlich fehlgeschlagen (Passwort oder CAPTCHA)';
|
||||
}
|
||||
|
||||
const currentUrl = page.url();
|
||||
const allCookies = await context.cookies();
|
||||
const cookieNames = allCookies.map((c) => c.name);
|
||||
failureDiagnostics = {
|
||||
url: currentUrl,
|
||||
cookieNames,
|
||||
bodyPreview: String(textContent || '').slice(0, 320)
|
||||
};
|
||||
} catch (_e) {
|
||||
// ignore text read errors
|
||||
}
|
||||
if (!errorText && detectedSubmitError) {
|
||||
errorText = detectedSubmitError;
|
||||
}
|
||||
if (failureDiagnostics) {
|
||||
console.warn('[myTischtennisClient.playwright] Login failure diagnostics:', failureDiagnostics);
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: errorText
|
||||
@@ -509,6 +737,11 @@ class MyTischtennisClient {
|
||||
}
|
||||
|
||||
const cookie = `sb-10-auth-token=${authCookieObj.value}`;
|
||||
|
||||
// Persist the full browser storage state so future calls can skip the CAPTCHA flow.
|
||||
let storageState = null;
|
||||
try { storageState = await context.storageState(); } catch (_e) { /* ignore */ }
|
||||
|
||||
console.log('[myTischtennisClient.playwright] Browser login successful');
|
||||
return {
|
||||
success: true,
|
||||
@@ -517,13 +750,21 @@ class MyTischtennisClient {
|
||||
expiresAt: tokenData.expires_at,
|
||||
expiresIn: tokenData.expires_in,
|
||||
user: tokenData.user,
|
||||
cookie
|
||||
cookie,
|
||||
storageState
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[myTischtennisClient.playwright] Browser login failed:', error?.message || error);
|
||||
const rawMessage = String(error?.message || error || 'Playwright-Login fehlgeschlagen');
|
||||
const isMissingBrowserExecutable = /Executable doesn't exist|download new browsers|playwright install/i.test(rawMessage);
|
||||
const normalizedError = isMissingBrowserExecutable
|
||||
? 'Playwright-Browser ist auf dem Server nicht installiert. Bitte "npx playwright install chromium" ausführen.'
|
||||
: rawMessage;
|
||||
console.error('[myTischtennisClient.playwright] Browser login failed:', normalizedError);
|
||||
return {
|
||||
success: false,
|
||||
error: error?.message || 'Playwright-Login fehlgeschlagen'
|
||||
error: normalizedError,
|
||||
requiresSetup: isMissingBrowserExecutable,
|
||||
status: isMissingBrowserExecutable ? 503 : 400
|
||||
};
|
||||
} finally {
|
||||
if (context) {
|
||||
|
||||
@@ -1,53 +1,10 @@
|
||||
import myTischtennisService from '../services/myTischtennisService.js';
|
||||
import myTischtennisSessionService from '../services/myTischtennisSessionService.js';
|
||||
import myTischtennisProxyService from '../services/myTischtennisProxyService.js';
|
||||
import HttpError from '../exceptions/HttpError.js';
|
||||
import axios from 'axios';
|
||||
import myTischtennisClient from '../clients/myTischtennisClient.js';
|
||||
|
||||
const MYTT_ORIGIN = 'https://www.mytischtennis.de';
|
||||
const MYTT_PROXY_PREFIX = '/api/mytischtennis/proxy';
|
||||
|
||||
function rewriteMytischtennisContent(content) {
|
||||
if (typeof content !== 'string' || !content) {
|
||||
return content;
|
||||
}
|
||||
|
||||
let rewritten = content;
|
||||
|
||||
// Root-relative Build/Fonts über unseren Same-Origin-Proxy laden.
|
||||
rewritten = rewritten.replace(
|
||||
/(["'])\/build\//g,
|
||||
`$1${MYTT_PROXY_PREFIX}/build/`
|
||||
);
|
||||
rewritten = rewritten.replace(
|
||||
/(["'])\/fonts\//g,
|
||||
`$1${MYTT_PROXY_PREFIX}/fonts/`
|
||||
);
|
||||
|
||||
// Absolute Build/Fonts-URLs ebenfalls auf den Proxy biegen.
|
||||
rewritten = rewritten.replace(
|
||||
/https:\/\/www\.mytischtennis\.de\/build\//g,
|
||||
`${MYTT_PROXY_PREFIX}/build/`
|
||||
);
|
||||
rewritten = rewritten.replace(
|
||||
/https:\/\/www\.mytischtennis\.de\/fonts\//g,
|
||||
`${MYTT_PROXY_PREFIX}/fonts/`
|
||||
);
|
||||
|
||||
// CSS url(/fonts/...) Fälle.
|
||||
rewritten = rewritten.replace(
|
||||
/url\((["']?)\/fonts\//g,
|
||||
`url($1${MYTT_PROXY_PREFIX}/fonts/`
|
||||
);
|
||||
|
||||
// Captcha-Endpunkt muss ebenfalls same-origin über Proxy erreichbar sein.
|
||||
rewritten = rewritten.replace(
|
||||
/(["'])\/api\/private-captcha/g,
|
||||
`$1${MYTT_PROXY_PREFIX}/api/private-captcha`
|
||||
);
|
||||
|
||||
return rewritten;
|
||||
}
|
||||
|
||||
class MyTischtennisController {
|
||||
/**
|
||||
* GET /api/mytischtennis/account
|
||||
@@ -317,7 +274,7 @@ class MyTischtennisController {
|
||||
req.userId = userId;
|
||||
|
||||
// Lade die Login-Seite von mytischtennis.de
|
||||
const response = await axios.get(`${MYTT_ORIGIN}/login?next=%2F`, {
|
||||
const response = await axios.get(`${myTischtennisProxyService.getOrigin()}/login?next=%2F`, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
||||
@@ -353,7 +310,7 @@ class MyTischtennisController {
|
||||
/action="\/login/g,
|
||||
'action="/api/mytischtennis/login-submit'
|
||||
);
|
||||
html = rewriteMytischtennisContent(html);
|
||||
html = myTischtennisProxyService.rewriteContent(html);
|
||||
|
||||
// MyTischtennis bootet eine große React-App, die im Proxy-Kontext häufig mit
|
||||
// Runtime-Fehlern abstürzt ("Da ist etwas schiefgelaufen"). Für den iframe-Login
|
||||
@@ -382,7 +339,7 @@ class MyTischtennisController {
|
||||
try {
|
||||
const proxyPath = req.params[0] || '';
|
||||
const queryString = new URLSearchParams(req.query || {}).toString();
|
||||
const targetUrl = `${MYTT_ORIGIN}/${proxyPath}${queryString ? `?${queryString}` : ''}`;
|
||||
const targetUrl = `${myTischtennisProxyService.getOrigin()}/${proxyPath}${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
const upstream = await axios.get(targetUrl, {
|
||||
responseType: 'arraybuffer',
|
||||
@@ -412,7 +369,7 @@ class MyTischtennisController {
|
||||
|
||||
if (isTextLike) {
|
||||
const asText = Buffer.from(upstream.data).toString('utf-8');
|
||||
const rewritten = rewriteMytischtennisContent(asText);
|
||||
const rewritten = myTischtennisProxyService.rewriteContent(asText);
|
||||
return res.status(upstream.status).send(rewritten);
|
||||
}
|
||||
|
||||
@@ -555,7 +512,7 @@ class MyTischtennisController {
|
||||
const browserLogin = await myTischtennisClient.loginWithBrowserAutomation(payload.email, payload.password);
|
||||
|
||||
if (browserLogin.success && browserLogin.cookie) {
|
||||
await this.saveSessionFromCookie(userId, browserLogin.cookie);
|
||||
await myTischtennisSessionService.saveSessionFromCookie(userId, browserLogin.cookie);
|
||||
return res.status(200).send(
|
||||
'<!doctype html><html><body><p>Login erfolgreich. Fenster kann geschlossen werden.</p></body></html>'
|
||||
);
|
||||
@@ -582,7 +539,7 @@ class MyTischtennisController {
|
||||
const authCookie = setCookieHeaders?.find(cookie => cookie.startsWith('sb-10-auth-token='));
|
||||
if (authCookie && userId) {
|
||||
// Login erfolgreich - speichere Session (nur wenn userId vorhanden)
|
||||
await this.saveSessionFromCookie(userId, authCookie);
|
||||
await myTischtennisSessionService.saveSessionFromCookie(userId, authCookie);
|
||||
}
|
||||
|
||||
// Sende Response weiter
|
||||
@@ -593,49 +550,6 @@ class MyTischtennisController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Speichere Session-Daten aus Cookie
|
||||
*/
|
||||
async saveSessionFromCookie(userId, cookieString) {
|
||||
try {
|
||||
const tokenMatch = cookieString.match(/sb-10-auth-token=base64-([^;]+)/);
|
||||
if (!tokenMatch) {
|
||||
throw new Error('Token-Format ungültig');
|
||||
}
|
||||
|
||||
const base64Token = tokenMatch[1];
|
||||
const decodedToken = Buffer.from(base64Token, 'base64').toString('utf-8');
|
||||
const tokenData = JSON.parse(decodedToken);
|
||||
|
||||
const MyTischtennis = (await import('../models/MyTischtennis.js')).default;
|
||||
const myTischtennisAccount = await MyTischtennis.findOne({ where: { userId } });
|
||||
|
||||
if (myTischtennisAccount) {
|
||||
myTischtennisAccount.accessToken = tokenData.access_token;
|
||||
myTischtennisAccount.refreshToken = tokenData.refresh_token;
|
||||
myTischtennisAccount.expiresAt = tokenData.expires_at;
|
||||
myTischtennisAccount.cookie = cookieString.split(';')[0].trim();
|
||||
myTischtennisAccount.userData = tokenData.user;
|
||||
myTischtennisAccount.lastLoginSuccess = new Date();
|
||||
myTischtennisAccount.lastLoginAttempt = new Date();
|
||||
|
||||
// Hole Club-Informationen
|
||||
const myTischtennisClient = (await import('../clients/myTischtennisClient.js')).default;
|
||||
const profileResult = await myTischtennisClient.getUserProfile(myTischtennisAccount.cookie);
|
||||
if (profileResult.success) {
|
||||
myTischtennisAccount.clubId = profileResult.clubId;
|
||||
myTischtennisAccount.clubName = profileResult.clubName;
|
||||
myTischtennisAccount.fedNickname = profileResult.fedNickname;
|
||||
}
|
||||
|
||||
await myTischtennisAccount.save();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Speichern der Session:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/mytischtennis/extract-session
|
||||
* Extrahiere Session nach Login im iframe
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
import { createRequire } from 'module';
|
||||
const require = createRequire(import.meta.url);
|
||||
const pdfParse = require('pdf-parse/lib/pdf-parse.js');
|
||||
import { checkAccess } from '../utils/userUtils.js';
|
||||
import OfficialTournament from '../models/OfficialTournament.js';
|
||||
import OfficialCompetition from '../models/OfficialCompetition.js';
|
||||
import OfficialCompetitionMember from '../models/OfficialCompetitionMember.js';
|
||||
import Member from '../models/Member.js';
|
||||
import { Op } from 'sequelize';
|
||||
|
||||
// In-Memory Store (einfacher Start); später DB-Modell
|
||||
const parsedTournaments = new Map(); // key: id, value: { id, clubId, rawText, parsedData }
|
||||
let seq = 1;
|
||||
import officialTournamentService from '../services/officialTournamentService.js';
|
||||
|
||||
export const uploadTournamentPdf = async (req, res) => {
|
||||
try {
|
||||
@@ -18,45 +7,9 @@ export const uploadTournamentPdf = async (req, res) => {
|
||||
const { clubId } = req.params;
|
||||
await checkAccess(userToken, clubId);
|
||||
if (!req.file || !req.file.buffer) return res.status(400).json({ error: 'No pdf provided' });
|
||||
const data = await pdfParse(req.file.buffer);
|
||||
const parsed = parseTournamentText(data.text);
|
||||
const t = await OfficialTournament.create({
|
||||
clubId,
|
||||
title: parsed.title || null,
|
||||
eventDate: parsed.termin || null,
|
||||
organizer: null,
|
||||
host: null,
|
||||
venues: JSON.stringify(parsed.austragungsorte || []),
|
||||
competitionTypes: JSON.stringify(parsed.konkurrenztypen || []),
|
||||
registrationDeadlines: JSON.stringify(parsed.meldeschluesse || []),
|
||||
entryFees: JSON.stringify(parsed.entryFees || {}),
|
||||
});
|
||||
// competitions persistieren
|
||||
for (const c of parsed.competitions || []) {
|
||||
// Korrigiere Fehlzuordnung: Wenn die Zeile mit "Stichtag" fälschlich in performanceClass steht
|
||||
let performanceClass = c.leistungsklasse || c.performanceClass || null;
|
||||
let cutoffDate = c.stichtag || c.cutoffDate || null;
|
||||
if (performanceClass && /^stichtag\b/i.test(performanceClass)) {
|
||||
cutoffDate = performanceClass.replace(/^stichtag\s*:?\s*/i, '').trim();
|
||||
performanceClass = null;
|
||||
}
|
||||
await OfficialCompetition.create({
|
||||
tournamentId: t.id,
|
||||
ageClassCompetition: c.altersklasseWettbewerb || c.ageClassCompetition || null,
|
||||
performanceClass,
|
||||
startTime: c.startzeit || c.startTime || null,
|
||||
registrationDeadlineDate: c.meldeschlussDatum || c.registrationDeadlineDate || null,
|
||||
registrationDeadlineOnline: c.meldeschlussOnline || c.registrationDeadlineOnline || null,
|
||||
cutoffDate,
|
||||
ttrRelevant: c.ttrRelevant || null,
|
||||
openTo: c.offenFuer || c.openTo || null,
|
||||
preliminaryRound: c.vorrunde || c.preliminaryRound || null,
|
||||
finalRound: c.endrunde || c.finalRound || null,
|
||||
maxParticipants: c.maxTeilnehmer || c.maxParticipants || null,
|
||||
entryFee: c.startgeld || c.entryFee || null,
|
||||
});
|
||||
}
|
||||
res.status(201).json({ id: String(t.id) });
|
||||
|
||||
const result = await officialTournamentService.uploadTournamentPdf(clubId, req.file.buffer);
|
||||
res.status(201).json(result);
|
||||
} catch (e) {
|
||||
console.error('[uploadTournamentPdf] Error:', e);
|
||||
res.status(500).json({ error: 'Failed to parse pdf' });
|
||||
@@ -68,64 +21,10 @@ export const getParsedTournament = async (req, res) => {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, id } = req.params;
|
||||
await checkAccess(userToken, clubId);
|
||||
const t = await OfficialTournament.findOne({ where: { id, clubId } });
|
||||
if (!t) return res.status(404).json({ error: 'not found' });
|
||||
const comps = await OfficialCompetition.findAll({ where: { tournamentId: id } });
|
||||
const entries = await OfficialCompetitionMember.findAll({ where: { tournamentId: id } });
|
||||
const competitions = comps.map((c) => {
|
||||
const j = c.toJSON();
|
||||
return {
|
||||
id: j.id,
|
||||
tournamentId: j.tournamentId,
|
||||
ageClassCompetition: j.ageClassCompetition || null,
|
||||
performanceClass: j.performanceClass || null,
|
||||
startTime: j.startTime || null,
|
||||
registrationDeadlineDate: j.registrationDeadlineDate || null,
|
||||
registrationDeadlineOnline: j.registrationDeadlineOnline || null,
|
||||
cutoffDate: j.cutoffDate || null,
|
||||
ttrRelevant: j.ttrRelevant || null,
|
||||
openTo: j.openTo || null,
|
||||
preliminaryRound: j.preliminaryRound || null,
|
||||
finalRound: j.finalRound || null,
|
||||
maxParticipants: j.maxParticipants || null,
|
||||
entryFee: j.entryFee || null,
|
||||
// Legacy Felder zusätzlich, falls Frontend sie noch nutzt
|
||||
altersklasseWettbewerb: j.ageClassCompetition || null,
|
||||
leistungsklasse: j.performanceClass || null,
|
||||
startzeit: j.startTime || null,
|
||||
meldeschlussDatum: j.registrationDeadlineDate || null,
|
||||
meldeschlussOnline: j.registrationDeadlineOnline || null,
|
||||
stichtag: j.cutoffDate || null,
|
||||
offenFuer: j.openTo || null,
|
||||
vorrunde: j.preliminaryRound || null,
|
||||
endrunde: j.finalRound || null,
|
||||
maxTeilnehmer: j.maxParticipants || null,
|
||||
startgeld: j.entryFee || null,
|
||||
};
|
||||
});
|
||||
res.status(200).json({
|
||||
id: String(t.id),
|
||||
clubId: String(t.clubId),
|
||||
parsedData: {
|
||||
title: t.title,
|
||||
termin: t.eventDate,
|
||||
austragungsorte: JSON.parse(t.venues || '[]'),
|
||||
konkurrenztypen: JSON.parse(t.competitionTypes || '[]'),
|
||||
meldeschluesse: JSON.parse(t.registrationDeadlines || '[]'),
|
||||
entryFees: JSON.parse(t.entryFees || '{}'),
|
||||
competitions,
|
||||
},
|
||||
participation: entries.map(e => ({
|
||||
id: e.id,
|
||||
tournamentId: e.tournamentId,
|
||||
competitionId: e.competitionId,
|
||||
memberId: e.memberId,
|
||||
wants: !!e.wants,
|
||||
registered: !!e.registered,
|
||||
participated: !!e.participated,
|
||||
placement: e.placement || null,
|
||||
})),
|
||||
});
|
||||
|
||||
const result = await officialTournamentService.getParsedTournament(clubId, id);
|
||||
if (!result) return res.status(404).json({ error: 'not found' });
|
||||
res.status(200).json(result);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: 'Failed to fetch parsed tournament' });
|
||||
}
|
||||
@@ -134,30 +33,14 @@ export const getParsedTournament = async (req, res) => {
|
||||
export const upsertCompetitionMember = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, id } = req.params; // id = tournamentId
|
||||
const { clubId, id } = req.params;
|
||||
await checkAccess(userToken, clubId);
|
||||
const { competitionId, memberId, wants, registered, participated, placement } = req.body;
|
||||
if (!competitionId || !memberId) return res.status(400).json({ error: 'competitionId and memberId required' });
|
||||
const [row] = await OfficialCompetitionMember.findOrCreate({
|
||||
where: { competitionId, memberId },
|
||||
defaults: {
|
||||
tournamentId: id,
|
||||
competitionId,
|
||||
memberId,
|
||||
wants: !!wants,
|
||||
registered: !!registered,
|
||||
participated: !!participated,
|
||||
placement: placement || null,
|
||||
}
|
||||
});
|
||||
row.wants = wants !== undefined ? !!wants : row.wants;
|
||||
row.registered = registered !== undefined ? !!registered : row.registered;
|
||||
row.participated = participated !== undefined ? !!participated : row.participated;
|
||||
if (placement !== undefined) row.placement = placement;
|
||||
await row.save();
|
||||
return res.status(200).json({ success: true, id: row.id });
|
||||
|
||||
const result = await officialTournamentService.upsertCompetitionMember(id, req.body);
|
||||
return res.status(200).json(result);
|
||||
} catch (e) {
|
||||
console.error('[upsertCompetitionMember] Error:', e);
|
||||
if (e?.status) return res.status(e.status).json({ error: e.message });
|
||||
res.status(500).json({ error: 'Failed to save participation' });
|
||||
}
|
||||
};
|
||||
@@ -165,64 +48,14 @@ export const upsertCompetitionMember = async (req, res) => {
|
||||
export const updateParticipantStatus = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, id } = req.params; // id = tournamentId
|
||||
const { clubId, id } = req.params;
|
||||
await checkAccess(userToken, clubId);
|
||||
const { competitionId, memberId, action } = req.body;
|
||||
|
||||
if (!competitionId || !memberId || !action) {
|
||||
return res.status(400).json({ error: 'competitionId, memberId and action required' });
|
||||
}
|
||||
|
||||
const [row] = await OfficialCompetitionMember.findOrCreate({
|
||||
where: { competitionId, memberId },
|
||||
defaults: {
|
||||
tournamentId: id,
|
||||
competitionId,
|
||||
memberId,
|
||||
wants: false,
|
||||
registered: false,
|
||||
participated: false,
|
||||
placement: null,
|
||||
}
|
||||
});
|
||||
|
||||
// Status-Update basierend auf Aktion
|
||||
switch (action) {
|
||||
case 'register':
|
||||
// Von "möchte teilnehmen" zu "angemeldet"
|
||||
row.wants = true;
|
||||
row.registered = true;
|
||||
row.participated = false;
|
||||
break;
|
||||
case 'participate':
|
||||
// Von "angemeldet" zu "hat gespielt"
|
||||
row.wants = true;
|
||||
row.registered = true;
|
||||
row.participated = true;
|
||||
break;
|
||||
case 'reset':
|
||||
// Zurück zu "möchte teilnehmen"
|
||||
row.wants = true;
|
||||
row.registered = false;
|
||||
row.participated = false;
|
||||
break;
|
||||
default:
|
||||
return res.status(400).json({ error: 'Invalid action. Use: register, participate, or reset' });
|
||||
}
|
||||
|
||||
await row.save();
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
id: row.id,
|
||||
status: {
|
||||
wants: row.wants,
|
||||
registered: row.registered,
|
||||
participated: row.participated,
|
||||
placement: row.placement
|
||||
}
|
||||
});
|
||||
const result = await officialTournamentService.updateParticipantStatus(id, req.body);
|
||||
return res.status(200).json(result);
|
||||
} catch (e) {
|
||||
console.error('[updateParticipantStatus] Error:', e);
|
||||
if (e?.status) return res.status(e.status).json({ error: e.message });
|
||||
res.status(500).json({ error: 'Failed to update participant status' });
|
||||
}
|
||||
};
|
||||
@@ -232,8 +65,9 @@ export const listOfficialTournaments = async (req, res) => {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId } = req.params;
|
||||
await checkAccess(userToken, clubId);
|
||||
const list = await OfficialTournament.findAll({ where: { clubId } });
|
||||
res.status(200).json(Array.isArray(list) ? list : []);
|
||||
|
||||
const list = await officialTournamentService.listOfficialTournaments(clubId);
|
||||
res.status(200).json(list);
|
||||
} catch (e) {
|
||||
console.error('[listOfficialTournaments] Error:', e);
|
||||
const errorMessage = e.message || 'Failed to list tournaments';
|
||||
@@ -246,99 +80,8 @@ export const listClubParticipations = async (req, res) => {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId } = req.params;
|
||||
await checkAccess(userToken, clubId);
|
||||
const tournaments = await OfficialTournament.findAll({ where: { clubId } });
|
||||
if (!tournaments || tournaments.length === 0) return res.status(200).json([]);
|
||||
const tournamentIds = tournaments.map(t => t.id);
|
||||
|
||||
const rows = await OfficialCompetitionMember.findAll({
|
||||
where: { tournamentId: { [Op.in]: tournamentIds }, participated: true },
|
||||
include: [
|
||||
{ model: OfficialCompetition, as: 'competition', attributes: ['id', 'tournamentId', 'ageClassCompetition', 'startTime'] },
|
||||
{ model: OfficialTournament, as: 'tournament', attributes: ['id', 'title', 'eventDate'] },
|
||||
{ model: Member, as: 'member', attributes: ['id', 'firstName', 'lastName'] },
|
||||
]
|
||||
});
|
||||
|
||||
const parseDmy = (s) => {
|
||||
if (!s) return null;
|
||||
const m = String(s).match(/(\d{1,2})\.(\d{1,2})\.(\d{4})/);
|
||||
if (!m) return null;
|
||||
const d = new Date(Number(m[3]), Number(m[2]) - 1, Number(m[1]));
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
};
|
||||
const fmtDmy = (d) => {
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const yyyy = d.getFullYear();
|
||||
return `${dd}.${mm}.${yyyy}`;
|
||||
};
|
||||
|
||||
const byTournament = new Map();
|
||||
for (const r of rows) {
|
||||
const t = r.tournament;
|
||||
const c = r.competition;
|
||||
const m = r.member;
|
||||
if (!t || !c || !m) continue;
|
||||
if (!byTournament.has(t.id)) {
|
||||
byTournament.set(t.id, {
|
||||
tournamentId: String(t.id),
|
||||
title: t.title || null,
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
entries: [],
|
||||
_dates: [],
|
||||
_eventDate: t.eventDate || null,
|
||||
});
|
||||
}
|
||||
const bucket = byTournament.get(t.id);
|
||||
const compDate = parseDmy(c.startTime || '') || null;
|
||||
if (compDate) bucket._dates.push(compDate);
|
||||
bucket.entries.push({
|
||||
memberId: m.id,
|
||||
memberName: `${m.firstName || ''} ${m.lastName || ''}`.trim(),
|
||||
competitionId: c.id,
|
||||
competitionName: c.ageClassCompetition || '',
|
||||
placement: r.placement || null,
|
||||
date: compDate ? fmtDmy(compDate) : null,
|
||||
});
|
||||
}
|
||||
|
||||
const out = [];
|
||||
for (const t of tournaments) {
|
||||
const bucket = byTournament.get(t.id) || {
|
||||
tournamentId: String(t.id),
|
||||
title: t.title || null,
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
entries: [],
|
||||
_dates: [],
|
||||
_eventDate: t.eventDate || null,
|
||||
};
|
||||
// Ableiten Start/Ende
|
||||
if (bucket._dates.length) {
|
||||
bucket._dates.sort((a, b) => a - b);
|
||||
bucket.startDate = fmtDmy(bucket._dates[0]);
|
||||
bucket.endDate = fmtDmy(bucket._dates[bucket._dates.length - 1]);
|
||||
} else if (bucket._eventDate) {
|
||||
const all = String(bucket._eventDate).match(/(\d{1,2}\.\d{1,2}\.\d{4})/g) || [];
|
||||
if (all.length >= 1) {
|
||||
const d1 = parseDmy(all[0]);
|
||||
const d2 = all.length >= 2 ? parseDmy(all[1]) : d1;
|
||||
if (d1) bucket.startDate = fmtDmy(d1);
|
||||
if (d2) bucket.endDate = fmtDmy(d2);
|
||||
}
|
||||
}
|
||||
// Sort entries: Mitglied, dann Konkurrenz
|
||||
bucket.entries.sort((a, b) => {
|
||||
const mcmp = (a.memberName || '').localeCompare(b.memberName || '', 'de', { sensitivity: 'base' });
|
||||
if (mcmp !== 0) return mcmp;
|
||||
return (a.competitionName || '').localeCompare(b.competitionName || '', 'de', { sensitivity: 'base' });
|
||||
});
|
||||
delete bucket._dates;
|
||||
delete bucket._eventDate;
|
||||
out.push(bucket);
|
||||
}
|
||||
|
||||
const out = await officialTournamentService.listClubParticipations(clubId);
|
||||
res.status(200).json(out);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: 'Failed to list club participations' });
|
||||
@@ -350,272 +93,11 @@ export const deleteOfficialTournament = async (req, res) => {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, id } = req.params;
|
||||
await checkAccess(userToken, clubId);
|
||||
const t = await OfficialTournament.findOne({ where: { id, clubId } });
|
||||
if (!t) return res.status(404).json({ error: 'not found' });
|
||||
await OfficialCompetition.destroy({ where: { tournamentId: id } });
|
||||
await OfficialTournament.destroy({ where: { id } });
|
||||
|
||||
const deleted = await officialTournamentService.deleteOfficialTournament(clubId, id);
|
||||
if (!deleted) return res.status(404).json({ error: 'not found' });
|
||||
res.status(204).send();
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: 'Failed to delete tournament' });
|
||||
}
|
||||
};
|
||||
|
||||
function parseTournamentText(text) {
|
||||
const lines = text.split(/\r?\n/);
|
||||
const normLines = lines.map(l => l.replace(/\s+/g, ' ').trim());
|
||||
|
||||
const findTitle = () => {
|
||||
const idx = normLines.findIndex(l => /Kreiseinzelmeisterschaften/i.test(l));
|
||||
return idx >= 0 ? normLines[idx] : null;
|
||||
};
|
||||
|
||||
// Neue Funktion: Teilnahmegebühren pro Spielklasse extrahieren
|
||||
const extractEntryFees = () => {
|
||||
const entryFees = {};
|
||||
|
||||
// Verschiedene Patterns für Teilnahmegebühren suchen
|
||||
const feePatterns = [
|
||||
// Pattern 1: "Startgeld: U12: 5€, U14: 7€, U16: 10€"
|
||||
/startgeld\s*:?\s*(.+)/i,
|
||||
// Pattern 2: "Teilnahmegebühr: U12: 5€, U14: 7€"
|
||||
/teilnahmegebühr\s*:?\s*(.+)/i,
|
||||
// Pattern 3: "Gebühr: U12: 5€, U14: 7€"
|
||||
/gebühr\s*:?\s*(.+)/i,
|
||||
// Pattern 4: "Einschreibegebühr: U12: 5€, U14: 7€"
|
||||
/einschreibegebühr\s*:?\s*(.+)/i,
|
||||
// Pattern 5: "Anmeldegebühr: U12: 5€, U14: 7€"
|
||||
/anmeldegebühr\s*:?\s*(.+)/i
|
||||
];
|
||||
|
||||
for (const pattern of feePatterns) {
|
||||
for (let i = 0; i < normLines.length; i++) {
|
||||
const line = normLines[i];
|
||||
const match = line.match(pattern);
|
||||
if (match) {
|
||||
const feeText = match[1];
|
||||
|
||||
// Extrahiere Gebühren aus dem Text
|
||||
// Unterstützt verschiedene Formate:
|
||||
// "U12: 5€, U14: 7€, U16: 10€"
|
||||
// "U12: 5 Euro, U14: 7 Euro"
|
||||
// "U12 5€, U14 7€"
|
||||
// "U12: 5,00€, U14: 7,00€"
|
||||
const feeMatches = feeText.matchAll(/(U\d+|AK\s*\d+)\s*:?\s*(\d+(?:[,.]\d+)?)\s*(?:€|Euro|EUR)?/gi);
|
||||
|
||||
for (const feeMatch of feeMatches) {
|
||||
const ageClass = feeMatch[1].toUpperCase().replace(/\s+/g, '');
|
||||
const amount = feeMatch[2].replace(',', '.');
|
||||
const numericAmount = parseFloat(amount);
|
||||
|
||||
if (!isNaN(numericAmount)) {
|
||||
entryFees[ageClass] = {
|
||||
amount: numericAmount,
|
||||
currency: '€',
|
||||
rawText: feeMatch[0]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Wenn wir Gebühren gefunden haben, brechen wir ab
|
||||
if (Object.keys(entryFees).length > 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Object.keys(entryFees).length > 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return entryFees;
|
||||
};
|
||||
|
||||
const extractBlockAfter = (labels, multiline = false) => {
|
||||
const idx = normLines.findIndex(l => labels.some(lb => l.toLowerCase().startsWith(lb)));
|
||||
if (idx === -1) return multiline ? [] : null;
|
||||
const line = normLines[idx];
|
||||
const afterColon = line.includes(':') ? line.split(':').slice(1).join(':').trim() : '';
|
||||
if (!multiline) {
|
||||
if (afterColon) return afterColon;
|
||||
// sonst nächste nicht-leere Zeile
|
||||
for (let i = idx + 1; i < normLines.length; i++) {
|
||||
if (normLines[i]) return normLines[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// multiline bis zur nächsten Leerzeile oder nächsten bekannten Section
|
||||
const out = [];
|
||||
if (afterColon) out.push(afterColon);
|
||||
for (let i = idx + 1; i < normLines.length; i++) {
|
||||
const ln = normLines[i];
|
||||
if (!ln) break;
|
||||
if (/^(termin|austragungsort|austragungsorte|konkurrenz|konkurrenzen|konkurrenztypen|meldeschluss|altersklassen|startzeiten)/i.test(ln)) break;
|
||||
out.push(ln);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const extractAllMatches = (regex) => {
|
||||
const results = [];
|
||||
for (const l of normLines) {
|
||||
const m = l.match(regex);
|
||||
if (m) results.push(m);
|
||||
}
|
||||
return results;
|
||||
};
|
||||
|
||||
const title = findTitle();
|
||||
const termin = extractBlockAfter(['termin', 'termin '], false);
|
||||
const austragungsorte = extractBlockAfter(['austragungsort', 'austragungsorte'], true);
|
||||
let konkurrenzRaw = extractBlockAfter(['konkurrenz', 'konkurrenzen', 'konkurrenztypen'], true);
|
||||
if (konkurrenzRaw && !Array.isArray(konkurrenzRaw)) konkurrenzRaw = [konkurrenzRaw];
|
||||
const konkurrenztypen = (konkurrenzRaw || []).flatMap(l => l.split(/[;,]/)).map(s => s.trim()).filter(Boolean);
|
||||
|
||||
// Meldeschlüsse mit Position und Zuordnung zu AK ermitteln
|
||||
const meldeschluesseRaw = [];
|
||||
for (let i = 0; i < normLines.length; i++) {
|
||||
const l = normLines[i];
|
||||
const m = l.match(/meldeschluss\s*:?\s*(.+)$/i);
|
||||
if (m) meldeschluesseRaw.push({ line: i, value: m[1].trim() });
|
||||
}
|
||||
|
||||
let altersRaw = extractBlockAfter(['altersklassen', 'altersklasse'], true);
|
||||
if (altersRaw && !Array.isArray(altersRaw)) altersRaw = [altersRaw];
|
||||
const altersklassen = (altersRaw || []).flatMap(l => l.split(/[;,]/)).map(s => s.trim()).filter(Boolean);
|
||||
|
||||
// Wettbewerbe/Konkurrenzen parsen (Block ab "3. Konkurrenzen")
|
||||
const competitions = [];
|
||||
const konkIdx = normLines.findIndex(l => /^\s*3\.?\s+Konkurrenzen/i.test(l) || /^Konkurrenzen\b/i.test(l));
|
||||
// Bestimme Start-Sektionsnummer (z. B. 3 bei "3. Konkurrenzen"), fallback 3
|
||||
const startSectionNum = (() => {
|
||||
if (konkIdx === -1) return 3;
|
||||
const m = normLines[konkIdx].match(/^\s*(\d+)\./);
|
||||
return m ? parseInt(m[1], 10) : 3;
|
||||
})();
|
||||
const nextSectionIdx = () => {
|
||||
for (let i = konkIdx + 1; i < normLines.length; i++) {
|
||||
const m = normLines[i].match(/^\s*(\d+)\.\s+/);
|
||||
if (m) {
|
||||
const num = parseInt(m[1], 10);
|
||||
if (!Number.isNaN(num) && num > startSectionNum) return i;
|
||||
}
|
||||
// Hinweis: Seitenfußzeilen wie "nu.Dokument ..." ignorieren wir, damit mehrseitige Blöcke nicht abbrechen
|
||||
}
|
||||
return normLines.length;
|
||||
};
|
||||
if (konkIdx !== -1) {
|
||||
const endIdx = nextSectionIdx();
|
||||
let i = konkIdx + 1;
|
||||
while (i < endIdx) {
|
||||
const line = normLines[i];
|
||||
if (/^Altersklasse\/Wettbewerb\s*:/i.test(line)) {
|
||||
const comp = {};
|
||||
comp.altersklasseWettbewerb = line.split(':').slice(1).join(':').trim();
|
||||
i++;
|
||||
while (i < endIdx && !/^Altersklasse\/Wettbewerb\s*:/i.test(normLines[i])) {
|
||||
const ln = normLines[i];
|
||||
const m = ln.match(/^([^:]+):\s*(.*)$/);
|
||||
if (m) {
|
||||
const key = m[1].trim().toLowerCase();
|
||||
const val = m[2].trim();
|
||||
if (key.startsWith('leistungsklasse')) comp.leistungsklasse = val;
|
||||
else if (key === 'startzeit') {
|
||||
// Erwartet: 20.09.2025 13:30 Uhr -> wir extrahieren Datum+Zeit
|
||||
const sm = val.match(/(\d{2}\.\d{2}\.\d{4})\s+(\d{1,2}:\d{2})/);
|
||||
comp.startzeit = sm ? `${sm[1]} ${sm[2]}` : val;
|
||||
}
|
||||
else if (key.startsWith('meldeschluss datum')) comp.meldeschlussDatum = val;
|
||||
else if (key.startsWith('meldeschluss online')) comp.meldeschlussOnline = val;
|
||||
else if (key === 'stichtag') comp.stichtag = val;
|
||||
else if (key === 'ttr-relevant') comp.ttrRelevant = val;
|
||||
else if (key === 'offen für') comp.offenFuer = val;
|
||||
else if (key.startsWith('austragungssys. vorrunde')) comp.vorrunde = val;
|
||||
else if (key.startsWith('austragungssys. endrunde')) comp.endrunde = val;
|
||||
else if (key.startsWith('max. teilnehmerzahl')) comp.maxTeilnehmer = val;
|
||||
else if (key === 'startgeld') {
|
||||
comp.startgeld = val;
|
||||
// Versuche auch spezifische Gebühren für diese Altersklasse zu extrahieren
|
||||
const ageClassMatch = comp.altersklasseWettbewerb?.match(/(U\d+|AK\s*\d+)/i);
|
||||
if (ageClassMatch) {
|
||||
const ageClass = ageClassMatch[1].toUpperCase().replace(/\s+/g, '');
|
||||
const feeMatch = val.match(/(\d+(?:[,.]\d+)?)\s*(?:€|Euro|EUR)?/);
|
||||
if (feeMatch) {
|
||||
const amount = feeMatch[1].replace(',', '.');
|
||||
const numericAmount = parseFloat(amount);
|
||||
if (!isNaN(numericAmount)) {
|
||||
comp.entryFeeDetails = {
|
||||
amount: numericAmount,
|
||||
currency: '€',
|
||||
ageClass: ageClass
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
i++;
|
||||
}
|
||||
competitions.push(comp);
|
||||
continue; // schon auf nächster Zeile
|
||||
}
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
// Altersklassen-Positionen im Text (zur Zuordnung von Meldeschlüssen)
|
||||
const akPositions = [];
|
||||
for (let i = 0; i < normLines.length; i++) {
|
||||
const l = normLines[i];
|
||||
const m = l.match(/\b(U\d+|AK\s*\d+)\b/i);
|
||||
if (m) akPositions.push({ line: i, ak: m[1].toUpperCase().replace(/\s+/g, '') });
|
||||
}
|
||||
|
||||
const meldeschluesseByAk = {};
|
||||
for (const ms of meldeschluesseRaw) {
|
||||
// Nächste AK im Umkreis von 3 Zeilen suchen
|
||||
let best = null;
|
||||
let bestDist = Infinity;
|
||||
for (const ak of akPositions) {
|
||||
const dist = Math.abs(ak.line - ms.line);
|
||||
if (dist < bestDist && dist <= 3) { best = ak; bestDist = dist; }
|
||||
}
|
||||
if (best) {
|
||||
if (!meldeschluesseByAk[best.ak]) meldeschluesseByAk[best.ak] = new Set();
|
||||
meldeschluesseByAk[best.ak].add(ms.value);
|
||||
}
|
||||
}
|
||||
|
||||
// Dedup global
|
||||
const meldeschluesse = Array.from(new Set(meldeschluesseRaw.map(x => x.value)));
|
||||
// Sets zu Arrays
|
||||
const meldeschluesseByAkOut = Object.fromEntries(Object.entries(meldeschluesseByAk).map(([k,v]) => [k, Array.from(v)]));
|
||||
|
||||
// Vorhandene einfache Personenerkennung (optional, zu Analysezwecken)
|
||||
const entries = [];
|
||||
for (const l of normLines) {
|
||||
const m = l.match(/^([A-Za-zÄÖÜäöüß\-\s']{3,})(?:\s+\((m|w|d)\))?$/i);
|
||||
if (m && /\s/.test(m[1])) {
|
||||
entries.push({ name: m[1].trim(), genderHint: m[2] || null });
|
||||
}
|
||||
}
|
||||
|
||||
// Extrahiere Teilnahmegebühren
|
||||
const entryFees = extractEntryFees();
|
||||
|
||||
return {
|
||||
title,
|
||||
termin,
|
||||
austragungsorte,
|
||||
konkurrenztypen,
|
||||
meldeschluesse,
|
||||
meldeschluesseByAk: meldeschluesseByAkOut,
|
||||
altersklassen,
|
||||
startzeiten: {},
|
||||
competitions,
|
||||
entries,
|
||||
entryFees, // Neue: Teilnahmegebühren pro Spielklasse
|
||||
debug: { normLines },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,173 +1,16 @@
|
||||
import { DiaryDate, Member, Participant } from '../models/index.js';
|
||||
import { Op } from 'sequelize';
|
||||
import trainingStatsService from '../services/trainingStatsService.js';
|
||||
|
||||
class TrainingStatsController {
|
||||
async getTrainingStats(req, res) {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
|
||||
// Aktuelle Datum für Berechnungen
|
||||
const now = new Date();
|
||||
const twelveMonthsAgo = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
|
||||
const threeMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 3, now.getDate());
|
||||
|
||||
// Alle aktiven Mitglieder des spezifischen Vereins laden
|
||||
const members = await Member.findAll({
|
||||
where: {
|
||||
active: true,
|
||||
clubId: parseInt(clubId)
|
||||
}
|
||||
});
|
||||
|
||||
// Anzahl der Trainings im jeweiligen Zeitraum berechnen
|
||||
const trainingsCount12Months = await DiaryDate.count({
|
||||
where: {
|
||||
clubId: parseInt(clubId),
|
||||
date: {
|
||||
[Op.gte]: twelveMonthsAgo
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const trainingsCount3Months = await DiaryDate.count({
|
||||
where: {
|
||||
clubId: parseInt(clubId),
|
||||
date: {
|
||||
[Op.gte]: threeMonthsAgo
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const stats = [];
|
||||
|
||||
for (const member of members) {
|
||||
// Trainingsteilnahmen der letzten 12 Monate über Participant-Model
|
||||
const participation12Months = await Participant.count({
|
||||
include: [{
|
||||
model: DiaryDate,
|
||||
as: 'diaryDate',
|
||||
where: {
|
||||
clubId: parseInt(clubId),
|
||||
date: {
|
||||
[Op.gte]: twelveMonthsAgo
|
||||
}
|
||||
}
|
||||
}],
|
||||
where: {
|
||||
memberId: member.id
|
||||
}
|
||||
});
|
||||
|
||||
// Trainingsteilnahmen der letzten 3 Monate über Participant-Model
|
||||
const participation3Months = await Participant.count({
|
||||
include: [{
|
||||
model: DiaryDate,
|
||||
as: 'diaryDate',
|
||||
where: {
|
||||
clubId: parseInt(clubId),
|
||||
date: {
|
||||
[Op.gte]: threeMonthsAgo
|
||||
}
|
||||
}
|
||||
}],
|
||||
where: {
|
||||
memberId: member.id
|
||||
}
|
||||
});
|
||||
|
||||
// Trainingsteilnahmen insgesamt über Participant-Model
|
||||
const participationTotal = await Participant.count({
|
||||
include: [{
|
||||
model: DiaryDate,
|
||||
as: 'diaryDate',
|
||||
where: {
|
||||
clubId: parseInt(clubId)
|
||||
}
|
||||
}],
|
||||
where: {
|
||||
memberId: member.id
|
||||
}
|
||||
});
|
||||
|
||||
// Detaillierte Trainingsdaten (absteigend sortiert) über Participant-Model
|
||||
const trainingDetails = await Participant.findAll({
|
||||
include: [{
|
||||
model: DiaryDate,
|
||||
as: 'diaryDate',
|
||||
where: {
|
||||
clubId: parseInt(clubId)
|
||||
}
|
||||
}],
|
||||
where: {
|
||||
memberId: member.id
|
||||
},
|
||||
order: [['diaryDate', 'date', 'DESC']],
|
||||
limit: 50 // Begrenzen auf die letzten 50 Trainingseinheiten
|
||||
});
|
||||
|
||||
// Trainingsteilnahmen für den Member formatieren
|
||||
const formattedTrainingDetails = trainingDetails.map(participation => ({
|
||||
id: participation.id,
|
||||
date: participation.diaryDate.date,
|
||||
activityName: 'Training',
|
||||
startTime: '--:--',
|
||||
endTime: '--:--'
|
||||
}));
|
||||
|
||||
// Letztes Training
|
||||
const lastTrainingDate = trainingDetails.length ? trainingDetails[0].diaryDate.date : null;
|
||||
const lastTrainingTs = lastTrainingDate ? new Date(lastTrainingDate).getTime() : 0;
|
||||
|
||||
stats.push({
|
||||
id: member.id,
|
||||
firstName: member.firstName,
|
||||
lastName: member.lastName,
|
||||
birthDate: member.birthDate,
|
||||
participation12Months,
|
||||
participation3Months,
|
||||
participationTotal,
|
||||
lastTraining: lastTrainingDate,
|
||||
lastTrainingTs,
|
||||
trainingDetails: formattedTrainingDetails
|
||||
});
|
||||
}
|
||||
|
||||
// Nach Gesamtteilnahme absteigend sortieren
|
||||
stats.sort((a, b) => b.participationTotal - a.participationTotal);
|
||||
|
||||
// Trainingstage mit Teilnehmerzahlen abrufen (letzte 12 Monate, absteigend sortiert)
|
||||
const trainingDays = await DiaryDate.findAll({
|
||||
where: {
|
||||
clubId: parseInt(clubId),
|
||||
date: {
|
||||
[Op.gte]: twelveMonthsAgo
|
||||
}
|
||||
},
|
||||
include: [{
|
||||
model: Participant,
|
||||
as: 'participantList',
|
||||
attributes: ['id']
|
||||
}],
|
||||
order: [['date', 'DESC']]
|
||||
});
|
||||
|
||||
// Formatiere Trainingstage mit Teilnehmerzahl
|
||||
const formattedTrainingDays = trainingDays.map(day => ({
|
||||
id: day.id,
|
||||
date: day.date,
|
||||
participantCount: day.participantList ? day.participantList.length : 0
|
||||
}));
|
||||
|
||||
// Zusätzliche Metadaten mit Trainingsanzahl zurückgeben
|
||||
res.json({
|
||||
members: stats,
|
||||
trainingsCount12Months,
|
||||
trainingsCount3Months,
|
||||
trainingDays: formattedTrainingDays
|
||||
});
|
||||
|
||||
const stats = await trainingStatsService.getTrainingStats(clubId);
|
||||
res.json(stats);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Trainings-Statistik:', error);
|
||||
if (error?.status) {
|
||||
return res.status(error.status).json({ error: error.message });
|
||||
}
|
||||
res.status(500).json({ error: 'Fehler beim Laden der Trainings-Statistik' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,6 +199,30 @@ const MyTischtennis = sequelize.define('MyTischtennis', {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'last_update_ratings'
|
||||
},
|
||||
playwrightStorageState: {
|
||||
// Encrypted JSON blob: full Playwright browser storage state (cookies + localStorage).
|
||||
// Allows restoring a previous login session without a new CAPTCHA challenge.
|
||||
type: DataTypes.TEXT('long'),
|
||||
allowNull: true,
|
||||
field: 'playwright_storage_state',
|
||||
set(value) {
|
||||
if (value === null || value === undefined) {
|
||||
this.setDataValue('playwrightStorageState', null);
|
||||
} else {
|
||||
const jsonString = typeof value === 'string' ? value : JSON.stringify(value);
|
||||
this.setDataValue('playwrightStorageState', encryptData(jsonString));
|
||||
}
|
||||
},
|
||||
get() {
|
||||
const encrypted = this.getDataValue('playwrightStorageState');
|
||||
if (!encrypted) return null;
|
||||
try {
|
||||
return JSON.parse(decryptData(encrypted));
|
||||
} catch (_e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, {
|
||||
underscored: true,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -23,10 +23,10 @@ class AutoFetchMatchResultsService {
|
||||
// Find all users with auto-updates enabled
|
||||
const accounts = await MyTischtennis.findAll({
|
||||
where: {
|
||||
autoUpdateRatings: true, // Nutze das gleiche Flag
|
||||
savePassword: true // Must have saved password
|
||||
},
|
||||
attributes: ['id', 'userId', 'email', 'savePassword', 'encryptedPassword', 'accessToken', 'expiresAt', 'cookie']
|
||||
autoUpdateRatings: true,
|
||||
savePassword: true
|
||||
}
|
||||
// No attributes restriction — all fields needed for session handling and re-login
|
||||
});
|
||||
|
||||
devLog(`Found ${accounts.length} accounts with auto-updates enabled for match results`);
|
||||
@@ -68,30 +68,18 @@ class AutoFetchMatchResultsService {
|
||||
|
||||
// Check if session is still valid
|
||||
if (!account.accessToken || !account.expiresAt || account.expiresAt < Date.now() / 1000) {
|
||||
devLog(`Session expired for ${account.email}, attempting re-login`);
|
||||
|
||||
// Try to re-login with stored password
|
||||
const password = account.getPassword();
|
||||
if (!password) {
|
||||
throw new Error('No stored password available for re-login');
|
||||
devLog(`Session expired for ${account.email}, attempting re-login via verifyLogin (incl. Playwright fallback)`);
|
||||
// verifyLogin handles CAPTCHA via Playwright and persists the session to DB.
|
||||
await myTischtennisService.verifyLogin(account.userId);
|
||||
// Reload the account to get the fresh session data written by verifyLogin.
|
||||
const refreshed = await MyTischtennis.findOne({ where: { userId: account.userId } });
|
||||
if (!refreshed?.cookie) {
|
||||
throw new Error('Re-login via verifyLogin did not produce a valid session');
|
||||
}
|
||||
|
||||
const loginResult = await myTischtennisClient.login(account.email, password);
|
||||
if (!loginResult.success) {
|
||||
if (loginResult.requiresCaptcha) {
|
||||
throw new Error(`Re-login failed: CAPTCHA erforderlich. Bitte loggen Sie sich einmal direkt auf mytischtennis.de ein, um das CAPTCHA zu lösen.`);
|
||||
}
|
||||
throw new Error(`Re-login failed: ${loginResult.error}`);
|
||||
}
|
||||
|
||||
// Update session data
|
||||
account.accessToken = loginResult.accessToken;
|
||||
account.refreshToken = loginResult.refreshToken;
|
||||
account.expiresAt = loginResult.expiresAt;
|
||||
account.cookie = loginResult.cookie;
|
||||
account.savePassword = true; // ensure flag persists when saving
|
||||
await account.save();
|
||||
|
||||
account.accessToken = refreshed.accessToken;
|
||||
account.refreshToken = refreshed.refreshToken;
|
||||
account.expiresAt = refreshed.expiresAt;
|
||||
account.cookie = refreshed.cookie;
|
||||
devLog(`Successfully re-logged in for ${account.email}`);
|
||||
}
|
||||
|
||||
|
||||
52
backend/services/myTischtennisProxyService.js
Normal file
52
backend/services/myTischtennisProxyService.js
Normal file
@@ -0,0 +1,52 @@
|
||||
const MYTT_ORIGIN = 'https://www.mytischtennis.de';
|
||||
const MYTT_PROXY_PREFIX = '/api/mytischtennis/proxy';
|
||||
|
||||
class MyTischtennisProxyService {
|
||||
getOrigin() {
|
||||
return MYTT_ORIGIN;
|
||||
}
|
||||
|
||||
getProxyPrefix() {
|
||||
return MYTT_PROXY_PREFIX;
|
||||
}
|
||||
|
||||
rewriteContent(content) {
|
||||
if (typeof content !== 'string' || !content) {
|
||||
return content;
|
||||
}
|
||||
|
||||
let rewritten = content;
|
||||
|
||||
rewritten = rewritten.replace(
|
||||
/(["'])\/build\//g,
|
||||
`$1${MYTT_PROXY_PREFIX}/build/`
|
||||
);
|
||||
rewritten = rewritten.replace(
|
||||
/(["'])\/fonts\//g,
|
||||
`$1${MYTT_PROXY_PREFIX}/fonts/`
|
||||
);
|
||||
|
||||
rewritten = rewritten.replace(
|
||||
/https:\/\/www\.mytischtennis\.de\/build\//g,
|
||||
`${MYTT_PROXY_PREFIX}/build/`
|
||||
);
|
||||
rewritten = rewritten.replace(
|
||||
/https:\/\/www\.mytischtennis\.de\/fonts\//g,
|
||||
`${MYTT_PROXY_PREFIX}/fonts/`
|
||||
);
|
||||
|
||||
rewritten = rewritten.replace(
|
||||
/url\((["']?)\/fonts\//g,
|
||||
`url($1${MYTT_PROXY_PREFIX}/fonts/`
|
||||
);
|
||||
|
||||
rewritten = rewritten.replace(
|
||||
/(["'])\/api\/private-captcha/g,
|
||||
`$1${MYTT_PROXY_PREFIX}/api/private-captcha`
|
||||
);
|
||||
|
||||
return rewritten;
|
||||
}
|
||||
}
|
||||
|
||||
export default new MyTischtennisProxyService();
|
||||
@@ -60,7 +60,12 @@ class MyTischtennisService {
|
||||
loginResult = await myTischtennisClient.login(email, password);
|
||||
if (!loginResult.success && loginResult.requiresCaptcha) {
|
||||
console.log('[myTischtennisService.upsertAccount] CAPTCHA-Fehler, versuche Playwright-Fallback...');
|
||||
const playwrightResult = await myTischtennisClient.loginWithBrowserAutomation(email, password);
|
||||
// Load saved browser state so we can skip the CAPTCHA if the previous session is still valid.
|
||||
const existingAccount = await MyTischtennis.findOne({ where: { userId } });
|
||||
const savedStorageState = existingAccount?.playwrightStorageState ?? null;
|
||||
const playwrightResult = await myTischtennisClient.loginWithBrowserAutomation(
|
||||
email, password, { savedStorageState }
|
||||
);
|
||||
if (playwrightResult.success) {
|
||||
loginResult = playwrightResult;
|
||||
} else {
|
||||
@@ -122,6 +127,11 @@ class MyTischtennisService {
|
||||
}
|
||||
|
||||
await account.save();
|
||||
|
||||
// Save Playwright storage state separately so a missing DB column never breaks the main save.
|
||||
if (loginResult?.success && loginResult.storageState) {
|
||||
await this._savePlaywrightStorageState(account, loginResult.storageState);
|
||||
}
|
||||
} else {
|
||||
// Create new
|
||||
const accountData = {
|
||||
@@ -155,6 +165,10 @@ class MyTischtennisService {
|
||||
account.setPassword(password);
|
||||
await account.save();
|
||||
}
|
||||
|
||||
if (loginResult?.success && loginResult.storageState) {
|
||||
await this._savePlaywrightStorageState(account, loginResult.storageState);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -242,25 +256,33 @@ class MyTischtennisService {
|
||||
if (!effectiveLoginResult.success && effectiveLoginResult.requiresCaptcha) {
|
||||
console.log('[myTischtennisService.verifyLogin] CAPTCHA-Fehler, versuche Playwright-Fallback...');
|
||||
try {
|
||||
const playwrightResult = await myTischtennisClient.loginWithBrowserAutomation(account.email, password);
|
||||
const savedStorageState = account.playwrightStorageState ?? null;
|
||||
const playwrightResult = await myTischtennisClient.loginWithBrowserAutomation(
|
||||
account.email, password, { savedStorageState }
|
||||
);
|
||||
if (playwrightResult.success) {
|
||||
effectiveLoginResult = playwrightResult;
|
||||
} else {
|
||||
console.warn('[myTischtennisService.verifyLogin] Playwright-Fallback fehlgeschlagen:', playwrightResult.error);
|
||||
const isSetupError = !!playwrightResult.requiresSetup || playwrightResult.status === 503;
|
||||
effectiveLoginResult = {
|
||||
success: false,
|
||||
error: playwrightResult.error || 'Playwright-Fallback fehlgeschlagen',
|
||||
requiresCaptcha: true,
|
||||
status: 400
|
||||
requiresCaptcha: !isSetupError,
|
||||
status: isSetupError ? 503 : 400
|
||||
};
|
||||
}
|
||||
} catch (playwrightError) {
|
||||
console.warn('[myTischtennisService.verifyLogin] Playwright-Fallback Exception:', playwrightError?.message || playwrightError);
|
||||
const rawMessage = String(playwrightError?.message || playwrightError || '');
|
||||
const isSetupError = /Executable doesn't exist|download new browsers|playwright install/i.test(rawMessage);
|
||||
effectiveLoginResult = {
|
||||
success: false,
|
||||
error: `Playwright-Fallback Exception: ${playwrightError?.message || 'Unbekannter Fehler'}`,
|
||||
requiresCaptcha: true,
|
||||
status: 400
|
||||
error: isSetupError
|
||||
? 'Playwright-Browser ist auf dem Server nicht installiert. Bitte "npx playwright install chromium" ausführen.'
|
||||
: `Playwright-Fallback Exception: ${playwrightError?.message || 'Unbekannter Fehler'}`,
|
||||
requiresCaptcha: !isSetupError,
|
||||
status: isSetupError ? 503 : 400
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -290,6 +312,11 @@ class MyTischtennisService {
|
||||
}
|
||||
|
||||
await account.save();
|
||||
|
||||
// Save Playwright storage state separately so a missing DB column never breaks the main save.
|
||||
if (effectiveLoginResult.storageState) {
|
||||
await this._savePlaywrightStorageState(account, effectiveLoginResult.storageState);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -410,6 +437,22 @@ class MyTischtennisService {
|
||||
console.error('Error logging update attempt:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the Playwright browser storage state to the account.
|
||||
* Runs in its own try-catch so a missing DB column never blocks the main session save.
|
||||
*/
|
||||
async _savePlaywrightStorageState(account, storageState) {
|
||||
try {
|
||||
account.playwrightStorageState = storageState;
|
||||
await account.save({ fields: ['playwrightStorageState'] });
|
||||
console.log('[myTischtennisService] Playwright storage state saved (session cached for future logins)');
|
||||
} catch (err) {
|
||||
// Column likely missing on this server — run:
|
||||
// ALTER TABLE my_tischtennis ADD COLUMN playwright_storage_state LONGTEXT NULL;
|
||||
console.warn('[myTischtennisService] Could not save playwright_storage_state (DB column missing?):', err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new MyTischtennisService();
|
||||
|
||||
39
backend/services/myTischtennisSessionService.js
Normal file
39
backend/services/myTischtennisSessionService.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import MyTischtennis from '../models/MyTischtennis.js';
|
||||
import myTischtennisClient from '../clients/myTischtennisClient.js';
|
||||
|
||||
class MyTischtennisSessionService {
|
||||
async saveSessionFromCookie(userId, cookieString) {
|
||||
const tokenMatch = cookieString.match(/sb-10-auth-token=base64-([^;]+)/);
|
||||
if (!tokenMatch) {
|
||||
throw new Error('Token-Format ungültig');
|
||||
}
|
||||
|
||||
const base64Token = tokenMatch[1];
|
||||
const decodedToken = Buffer.from(base64Token, 'base64').toString('utf-8');
|
||||
const tokenData = JSON.parse(decodedToken);
|
||||
|
||||
const myTischtennisAccount = await MyTischtennis.findOne({ where: { userId } });
|
||||
if (!myTischtennisAccount) {
|
||||
return;
|
||||
}
|
||||
|
||||
myTischtennisAccount.accessToken = tokenData.access_token;
|
||||
myTischtennisAccount.refreshToken = tokenData.refresh_token;
|
||||
myTischtennisAccount.expiresAt = tokenData.expires_at;
|
||||
myTischtennisAccount.cookie = cookieString.split(';')[0].trim();
|
||||
myTischtennisAccount.userData = tokenData.user;
|
||||
myTischtennisAccount.lastLoginSuccess = new Date();
|
||||
myTischtennisAccount.lastLoginAttempt = new Date();
|
||||
|
||||
const profileResult = await myTischtennisClient.getUserProfile(myTischtennisAccount.cookie);
|
||||
if (profileResult.success) {
|
||||
myTischtennisAccount.clubId = profileResult.clubId;
|
||||
myTischtennisAccount.clubName = profileResult.clubName;
|
||||
myTischtennisAccount.fedNickname = profileResult.fedNickname;
|
||||
}
|
||||
|
||||
await myTischtennisAccount.save();
|
||||
}
|
||||
}
|
||||
|
||||
export default new MyTischtennisSessionService();
|
||||
296
backend/services/officialTournamentParserService.js
Normal file
296
backend/services/officialTournamentParserService.js
Normal file
@@ -0,0 +1,296 @@
|
||||
class OfficialTournamentParserService {
|
||||
static normalizeCompetitionForPersistence(c) {
|
||||
let performanceClass = c.leistungsklasse || c.performanceClass || null;
|
||||
let cutoffDate = c.stichtag || c.cutoffDate || null;
|
||||
|
||||
// "Stichtag" kann auch in Leistungsklasse stehen (z. B. "VR Stichtag 01.01.2014 und jünger")
|
||||
if (!cutoffDate && performanceClass && /\bstichtag\b/i.test(performanceClass)) {
|
||||
const stichtagMatch = performanceClass.match(
|
||||
/stichtag\s*:?\s*([0-3]?\d\.[01]?\d\.\d{4}(?:\s*(?:bis|-)\s*[0-3]?\d\.[01]?\d\.\d{4})?)/i
|
||||
);
|
||||
if (stichtagMatch) {
|
||||
cutoffDate = stichtagMatch[1].trim();
|
||||
}
|
||||
performanceClass = performanceClass
|
||||
.replace(/\bstichtag\b.*$/i, '')
|
||||
.replace(/[,:;\-\s]+$/g, '')
|
||||
.trim() || null;
|
||||
}
|
||||
|
||||
return {
|
||||
...c,
|
||||
performanceClass,
|
||||
cutoffDate,
|
||||
};
|
||||
}
|
||||
|
||||
static parseTournamentText(text) {
|
||||
const lines = text.split(/\r?\n/);
|
||||
const normLines = lines.map((l) => l.replace(/\s+/g, ' ').trim());
|
||||
|
||||
const findTitle = () => {
|
||||
// Bevorzugt die Zeile direkt vor "Ausschreibung"
|
||||
const ausschreibungIdx = normLines.findIndex((l) => /^Ausschreibung\b/i.test(l));
|
||||
if (ausschreibungIdx > 0) {
|
||||
for (let i = ausschreibungIdx - 1; i >= 0; i--) {
|
||||
const candidate = normLines[i];
|
||||
if (!candidate) continue;
|
||||
if (/^HTTV\s*\/\s*Kreis/i.test(candidate)) continue;
|
||||
if (/^\d+\.\s+/.test(candidate)) continue;
|
||||
if (/^nu\.Dokument/i.test(candidate)) continue;
|
||||
if (/Ausschreibung\s*\(Fortsetzung\)/i.test(candidate)) continue;
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: typische Turnierbezeichnungen
|
||||
const typical = normLines.find(
|
||||
(l) =>
|
||||
/(meisterschaften|ranglistenturnier|pokal|turnier)/i.test(l) &&
|
||||
!/^HTTV\s*\/\s*Kreis/i.test(l) &&
|
||||
!/Ausschreibung/i.test(l)
|
||||
);
|
||||
return typical || null;
|
||||
};
|
||||
|
||||
const extractEntryFees = () => {
|
||||
const entryFees = {};
|
||||
|
||||
const feePatterns = [
|
||||
/startgeld\s*:?\s*(.+)/i,
|
||||
/teilnahmegebühr\s*:?\s*(.+)/i,
|
||||
/gebühr\s*:?\s*(.+)/i,
|
||||
/einschreibegebühr\s*:?\s*(.+)/i,
|
||||
/anmeldegebühr\s*:?\s*(.+)/i,
|
||||
];
|
||||
|
||||
for (const pattern of feePatterns) {
|
||||
for (let i = 0; i < normLines.length; i++) {
|
||||
const line = normLines[i];
|
||||
const match = line.match(pattern);
|
||||
if (!match) continue;
|
||||
|
||||
const feeText = match[1];
|
||||
const feeMatches = feeText.matchAll(
|
||||
/(U\d+|AK\s*\d+)\s*:?\s*(\d+(?:[,.]\d+)?)\s*(?:€|Euro|EUR)?/gi
|
||||
);
|
||||
|
||||
for (const feeMatch of feeMatches) {
|
||||
const ageClass = feeMatch[1].toUpperCase().replace(/\s+/g, '');
|
||||
const amount = feeMatch[2].replace(',', '.');
|
||||
const numericAmount = parseFloat(amount);
|
||||
|
||||
if (!isNaN(numericAmount)) {
|
||||
entryFees[ageClass] = {
|
||||
amount: numericAmount,
|
||||
currency: '€',
|
||||
rawText: feeMatch[0],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(entryFees).length > 0) break;
|
||||
}
|
||||
if (Object.keys(entryFees).length > 0) break;
|
||||
}
|
||||
|
||||
return entryFees;
|
||||
};
|
||||
|
||||
const extractBlockAfter = (labels, multiline = false) => {
|
||||
const idx = normLines.findIndex((l) => labels.some((lb) => l.toLowerCase().startsWith(lb)));
|
||||
if (idx === -1) return multiline ? [] : null;
|
||||
const line = normLines[idx];
|
||||
const afterColon = line.includes(':') ? line.split(':').slice(1).join(':').trim() : '';
|
||||
if (!multiline) {
|
||||
if (afterColon) return afterColon;
|
||||
for (let i = idx + 1; i < normLines.length; i++) {
|
||||
if (normLines[i]) return normLines[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const out = [];
|
||||
if (afterColon) out.push(afterColon);
|
||||
for (let i = idx + 1; i < normLines.length; i++) {
|
||||
const ln = normLines[i];
|
||||
if (!ln) break;
|
||||
if (/^(termin|austragungsort|austragungsorte|konkurrenz|konkurrenzen|konkurrenztypen|meldeschluss|altersklassen|startzeiten)/i.test(ln)) break;
|
||||
out.push(ln);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const title = findTitle();
|
||||
const termin = extractBlockAfter(['termin', 'termin '], false);
|
||||
const austragungsorte = extractBlockAfter(['austragungsort', 'austragungsorte'], true);
|
||||
let konkurrenzRaw = extractBlockAfter(['konkurrenz', 'konkurrenzen', 'konkurrenztypen'], true);
|
||||
if (konkurrenzRaw && !Array.isArray(konkurrenzRaw)) konkurrenzRaw = [konkurrenzRaw];
|
||||
const konkurrenztypen = (konkurrenzRaw || [])
|
||||
.flatMap((l) => l.split(/[;,]/))
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const meldeschluesseRaw = [];
|
||||
for (let i = 0; i < normLines.length; i++) {
|
||||
const l = normLines[i];
|
||||
const m = l.match(/meldeschluss\s*:?\s*(.+)$/i);
|
||||
if (m) meldeschluesseRaw.push({ line: i, value: m[1].trim() });
|
||||
}
|
||||
|
||||
let altersRaw = extractBlockAfter(['altersklassen', 'altersklasse'], true);
|
||||
if (altersRaw && !Array.isArray(altersRaw)) altersRaw = [altersRaw];
|
||||
const altersklassen = (altersRaw || [])
|
||||
.flatMap((l) => l.split(/[;,]/))
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const competitions = [];
|
||||
const konkIdx = normLines.findIndex((l) => /^\s*3\.?\s+Konkurrenzen/i.test(l) || /^Konkurrenzen\b/i.test(l));
|
||||
const startSectionNum = (() => {
|
||||
if (konkIdx === -1) return 3;
|
||||
const m = normLines[konkIdx].match(/^\s*(\d+)\./);
|
||||
return m ? parseInt(m[1], 10) : 3;
|
||||
})();
|
||||
|
||||
const nextSectionIdx = () => {
|
||||
for (let i = konkIdx + 1; i < normLines.length; i++) {
|
||||
const m = normLines[i].match(/^\s*(\d+)\.\s+/);
|
||||
if (!m) continue;
|
||||
const num = parseInt(m[1], 10);
|
||||
if (!Number.isNaN(num) && num > startSectionNum) return i;
|
||||
}
|
||||
return normLines.length;
|
||||
};
|
||||
|
||||
if (konkIdx !== -1) {
|
||||
const endIdx = nextSectionIdx();
|
||||
let i = konkIdx + 1;
|
||||
while (i < endIdx) {
|
||||
const line = normLines[i];
|
||||
if (!/^Altersklasse\/Wettbewerb\s*:/i.test(line)) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const comp = {};
|
||||
comp.altersklasseWettbewerb = line.split(':').slice(1).join(':').trim();
|
||||
i++;
|
||||
let lastParsedField = null;
|
||||
|
||||
while (i < endIdx && !/^Altersklasse\/Wettbewerb\s*:/i.test(normLines[i])) {
|
||||
const ln = normLines[i];
|
||||
const m = ln.match(/^([^:]+):\s*(.*)$/);
|
||||
|
||||
if (m) {
|
||||
const key = m[1].trim().toLowerCase();
|
||||
const val = m[2].trim();
|
||||
if (key.startsWith('leistungsklasse')) {
|
||||
comp.leistungsklasse = val;
|
||||
lastParsedField = 'leistungsklasse';
|
||||
} else if (key === 'startzeit') {
|
||||
const sm = val.match(/(\d{2}\.\d{2}\.\d{4})\s+(\d{1,2}:\d{2})/);
|
||||
comp.startzeit = sm ? `${sm[1]} ${sm[2]}` : val;
|
||||
lastParsedField = 'startzeit';
|
||||
} else if (key.startsWith('meldeschluss datum')) {
|
||||
comp.meldeschlussDatum = val;
|
||||
lastParsedField = 'meldeschlussDatum';
|
||||
} else if (key.startsWith('meldeschluss online')) {
|
||||
comp.meldeschlussOnline = val;
|
||||
lastParsedField = 'meldeschlussOnline';
|
||||
} else if (key.startsWith('meldeschluss text')) {
|
||||
comp.meldeschlussText = val;
|
||||
lastParsedField = 'meldeschlussText';
|
||||
} else if (key === 'stichtag') {
|
||||
comp.stichtag = val;
|
||||
lastParsedField = 'stichtag';
|
||||
} else if (key === 'ttr-relevant') {
|
||||
comp.ttrRelevant = val;
|
||||
lastParsedField = 'ttrRelevant';
|
||||
} else if (key === 'offen für') {
|
||||
comp.offenFuer = val;
|
||||
lastParsedField = 'offenFuer';
|
||||
} else if (key.startsWith('austragungssys. vorrunde')) {
|
||||
comp.vorrunde = val;
|
||||
lastParsedField = 'vorrunde';
|
||||
} else if (key.startsWith('austragungssys. endrunde')) {
|
||||
comp.endrunde = val;
|
||||
lastParsedField = 'endrunde';
|
||||
} else if (key.startsWith('max. teilnehmerzahl')) {
|
||||
comp.maxTeilnehmer = val;
|
||||
lastParsedField = 'maxTeilnehmer';
|
||||
} else if (key === 'startgeld') {
|
||||
comp.startgeld = val;
|
||||
lastParsedField = 'startgeld';
|
||||
}
|
||||
} else if (lastParsedField && ln) {
|
||||
const appendableFields = new Set(['leistungsklasse', 'meldeschlussText', 'vorrunde', 'endrunde', 'offenFuer', 'stichtag']);
|
||||
if (appendableFields.has(lastParsedField)) {
|
||||
const current = comp[lastParsedField];
|
||||
comp[lastParsedField] = current ? `${current} ${ln}`.trim() : ln;
|
||||
}
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
competitions.push(comp);
|
||||
}
|
||||
}
|
||||
|
||||
const akPositions = [];
|
||||
for (let i = 0; i < normLines.length; i++) {
|
||||
const l = normLines[i];
|
||||
const m = l.match(/\b(U\d+|AK\s*\d+)\b/i);
|
||||
if (m) akPositions.push({ line: i, ak: m[1].toUpperCase().replace(/\s+/g, '') });
|
||||
}
|
||||
|
||||
const meldeschluesseByAk = {};
|
||||
for (const ms of meldeschluesseRaw) {
|
||||
let best = null;
|
||||
let bestDist = Infinity;
|
||||
for (const ak of akPositions) {
|
||||
const dist = Math.abs(ak.line - ms.line);
|
||||
if (dist < bestDist && dist <= 3) {
|
||||
best = ak;
|
||||
bestDist = dist;
|
||||
}
|
||||
}
|
||||
if (best) {
|
||||
if (!meldeschluesseByAk[best.ak]) meldeschluesseByAk[best.ak] = new Set();
|
||||
meldeschluesseByAk[best.ak].add(ms.value);
|
||||
}
|
||||
}
|
||||
|
||||
const meldeschluesse = Array.from(new Set(meldeschluesseRaw.map((x) => x.value)));
|
||||
const meldeschluesseByAkOut = Object.fromEntries(
|
||||
Object.entries(meldeschluesseByAk).map(([k, v]) => [k, Array.from(v)])
|
||||
);
|
||||
|
||||
const entries = [];
|
||||
for (const l of normLines) {
|
||||
const m = l.match(/^([A-Za-zÄÖÜäöüß\-\s']{3,})(?:\s+\((m|w|d)\))?$/i);
|
||||
if (m && /\s/.test(m[1])) {
|
||||
entries.push({ name: m[1].trim(), genderHint: m[2] || null });
|
||||
}
|
||||
}
|
||||
|
||||
const entryFees = extractEntryFees();
|
||||
|
||||
return {
|
||||
title,
|
||||
termin,
|
||||
austragungsorte,
|
||||
konkurrenztypen,
|
||||
meldeschluesse,
|
||||
meldeschluesseByAk: meldeschluesseByAkOut,
|
||||
altersklassen,
|
||||
startzeiten: {},
|
||||
competitions,
|
||||
entries,
|
||||
entryFees,
|
||||
debug: { normLines },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default OfficialTournamentParserService;
|
||||
307
backend/services/officialTournamentService.js
Normal file
307
backend/services/officialTournamentService.js
Normal file
@@ -0,0 +1,307 @@
|
||||
import { createRequire } from 'module';
|
||||
const require = createRequire(import.meta.url);
|
||||
const pdfParse = require('pdf-parse/lib/pdf-parse.js');
|
||||
|
||||
import { Op } from 'sequelize';
|
||||
import OfficialTournament from '../models/OfficialTournament.js';
|
||||
import OfficialCompetition from '../models/OfficialCompetition.js';
|
||||
import OfficialCompetitionMember from '../models/OfficialCompetitionMember.js';
|
||||
import Member from '../models/Member.js';
|
||||
import OfficialTournamentParserService from './officialTournamentParserService.js';
|
||||
|
||||
class OfficialTournamentService {
|
||||
async uploadTournamentPdf(clubId, pdfBuffer) {
|
||||
const data = await pdfParse(pdfBuffer);
|
||||
const parsed = OfficialTournamentParserService.parseTournamentText(data.text);
|
||||
|
||||
const tournament = await OfficialTournament.create({
|
||||
clubId,
|
||||
title: parsed.title || null,
|
||||
eventDate: parsed.termin || null,
|
||||
organizer: null,
|
||||
host: null,
|
||||
venues: JSON.stringify(parsed.austragungsorte || []),
|
||||
competitionTypes: JSON.stringify(parsed.konkurrenztypen || []),
|
||||
registrationDeadlines: JSON.stringify(parsed.meldeschluesse || []),
|
||||
entryFees: JSON.stringify(parsed.entryFees || {}),
|
||||
});
|
||||
|
||||
for (const c of parsed.competitions || []) {
|
||||
const normalizedCompetition = OfficialTournamentParserService.normalizeCompetitionForPersistence(c);
|
||||
await OfficialCompetition.create({
|
||||
tournamentId: tournament.id,
|
||||
ageClassCompetition: normalizedCompetition.altersklasseWettbewerb || normalizedCompetition.ageClassCompetition || null,
|
||||
performanceClass: normalizedCompetition.performanceClass || null,
|
||||
startTime: normalizedCompetition.startzeit || normalizedCompetition.startTime || null,
|
||||
registrationDeadlineDate: normalizedCompetition.meldeschlussDatum || normalizedCompetition.registrationDeadlineDate || null,
|
||||
registrationDeadlineOnline: normalizedCompetition.meldeschlussOnline || normalizedCompetition.registrationDeadlineOnline || null,
|
||||
cutoffDate: normalizedCompetition.cutoffDate || null,
|
||||
ttrRelevant: normalizedCompetition.ttrRelevant || null,
|
||||
openTo: normalizedCompetition.offenFuer || normalizedCompetition.openTo || null,
|
||||
preliminaryRound: normalizedCompetition.vorrunde || normalizedCompetition.preliminaryRound || null,
|
||||
finalRound: normalizedCompetition.endrunde || normalizedCompetition.finalRound || null,
|
||||
maxParticipants: normalizedCompetition.maxTeilnehmer || normalizedCompetition.maxParticipants || null,
|
||||
entryFee: normalizedCompetition.startgeld || normalizedCompetition.entryFee || null,
|
||||
});
|
||||
}
|
||||
|
||||
return { id: String(tournament.id) };
|
||||
}
|
||||
|
||||
async getParsedTournament(clubId, id) {
|
||||
const t = await OfficialTournament.findOne({ where: { id, clubId } });
|
||||
if (!t) return null;
|
||||
|
||||
const comps = await OfficialCompetition.findAll({ where: { tournamentId: id } });
|
||||
const entries = await OfficialCompetitionMember.findAll({ where: { tournamentId: id } });
|
||||
const competitions = comps.map((c) => {
|
||||
const j = c.toJSON();
|
||||
return {
|
||||
id: j.id,
|
||||
tournamentId: j.tournamentId,
|
||||
ageClassCompetition: j.ageClassCompetition || null,
|
||||
performanceClass: j.performanceClass || null,
|
||||
startTime: j.startTime || null,
|
||||
registrationDeadlineDate: j.registrationDeadlineDate || null,
|
||||
registrationDeadlineOnline: j.registrationDeadlineOnline || null,
|
||||
cutoffDate: j.cutoffDate || null,
|
||||
ttrRelevant: j.ttrRelevant || null,
|
||||
openTo: j.openTo || null,
|
||||
preliminaryRound: j.preliminaryRound || null,
|
||||
finalRound: j.finalRound || null,
|
||||
maxParticipants: j.maxParticipants || null,
|
||||
entryFee: j.entryFee || null,
|
||||
altersklasseWettbewerb: j.ageClassCompetition || null,
|
||||
leistungsklasse: j.performanceClass || null,
|
||||
startzeit: j.startTime || null,
|
||||
meldeschlussDatum: j.registrationDeadlineDate || null,
|
||||
meldeschlussOnline: j.registrationDeadlineOnline || null,
|
||||
stichtag: j.cutoffDate || null,
|
||||
offenFuer: j.openTo || null,
|
||||
vorrunde: j.preliminaryRound || null,
|
||||
endrunde: j.finalRound || null,
|
||||
maxTeilnehmer: j.maxParticipants || null,
|
||||
startgeld: j.entryFee || null,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
id: String(t.id),
|
||||
clubId: String(t.clubId),
|
||||
parsedData: {
|
||||
title: t.title,
|
||||
termin: t.eventDate,
|
||||
austragungsorte: JSON.parse(t.venues || '[]'),
|
||||
konkurrenztypen: JSON.parse(t.competitionTypes || '[]'),
|
||||
meldeschluesse: JSON.parse(t.registrationDeadlines || '[]'),
|
||||
entryFees: JSON.parse(t.entryFees || '{}'),
|
||||
competitions,
|
||||
},
|
||||
participation: entries.map((e) => ({
|
||||
id: e.id,
|
||||
tournamentId: e.tournamentId,
|
||||
competitionId: e.competitionId,
|
||||
memberId: e.memberId,
|
||||
wants: !!e.wants,
|
||||
registered: !!e.registered,
|
||||
participated: !!e.participated,
|
||||
placement: e.placement || null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async upsertCompetitionMember(tournamentId, payload) {
|
||||
const { competitionId, memberId, wants, registered, participated, placement } = payload;
|
||||
if (!competitionId || !memberId) {
|
||||
const err = new Error('competitionId and memberId required');
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const [row] = await OfficialCompetitionMember.findOrCreate({
|
||||
where: { competitionId, memberId },
|
||||
defaults: {
|
||||
tournamentId,
|
||||
competitionId,
|
||||
memberId,
|
||||
wants: !!wants,
|
||||
registered: !!registered,
|
||||
participated: !!participated,
|
||||
placement: placement || null,
|
||||
},
|
||||
});
|
||||
row.wants = wants !== undefined ? !!wants : row.wants;
|
||||
row.registered = registered !== undefined ? !!registered : row.registered;
|
||||
row.participated = participated !== undefined ? !!participated : row.participated;
|
||||
if (placement !== undefined) row.placement = placement;
|
||||
await row.save();
|
||||
return { success: true, id: row.id };
|
||||
}
|
||||
|
||||
async updateParticipantStatus(tournamentId, payload) {
|
||||
const { competitionId, memberId, action } = payload;
|
||||
if (!competitionId || !memberId || !action) {
|
||||
const err = new Error('competitionId, memberId and action required');
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const [row] = await OfficialCompetitionMember.findOrCreate({
|
||||
where: { competitionId, memberId },
|
||||
defaults: {
|
||||
tournamentId,
|
||||
competitionId,
|
||||
memberId,
|
||||
wants: false,
|
||||
registered: false,
|
||||
participated: false,
|
||||
placement: null,
|
||||
},
|
||||
});
|
||||
|
||||
switch (action) {
|
||||
case 'register':
|
||||
row.wants = true;
|
||||
row.registered = true;
|
||||
row.participated = false;
|
||||
break;
|
||||
case 'participate':
|
||||
row.wants = true;
|
||||
row.registered = true;
|
||||
row.participated = true;
|
||||
break;
|
||||
case 'reset':
|
||||
row.wants = true;
|
||||
row.registered = false;
|
||||
row.participated = false;
|
||||
break;
|
||||
default: {
|
||||
const err = new Error('Invalid action. Use: register, participate, or reset');
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
await row.save();
|
||||
return {
|
||||
success: true,
|
||||
id: row.id,
|
||||
status: {
|
||||
wants: row.wants,
|
||||
registered: row.registered,
|
||||
participated: row.participated,
|
||||
placement: row.placement,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async listOfficialTournaments(clubId) {
|
||||
const list = await OfficialTournament.findAll({ where: { clubId } });
|
||||
return Array.isArray(list) ? list : [];
|
||||
}
|
||||
|
||||
parseDmy(s) {
|
||||
if (!s) return null;
|
||||
const m = String(s).match(/(\d{1,2})\.(\d{1,2})\.(\d{4})/);
|
||||
if (!m) return null;
|
||||
const d = new Date(Number(m[3]), Number(m[2]) - 1, Number(m[1]));
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
fmtDmy(d) {
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const yyyy = d.getFullYear();
|
||||
return `${dd}.${mm}.${yyyy}`;
|
||||
}
|
||||
|
||||
async listClubParticipations(clubId) {
|
||||
const tournaments = await OfficialTournament.findAll({ where: { clubId } });
|
||||
if (!tournaments || tournaments.length === 0) return [];
|
||||
const tournamentIds = tournaments.map((t) => t.id);
|
||||
|
||||
const rows = await OfficialCompetitionMember.findAll({
|
||||
where: { tournamentId: { [Op.in]: tournamentIds }, participated: true },
|
||||
include: [
|
||||
{ model: OfficialCompetition, as: 'competition', attributes: ['id', 'tournamentId', 'ageClassCompetition', 'startTime'] },
|
||||
{ model: OfficialTournament, as: 'tournament', attributes: ['id', 'title', 'eventDate'] },
|
||||
{ model: Member, as: 'member', attributes: ['id', 'firstName', 'lastName'] },
|
||||
],
|
||||
});
|
||||
|
||||
const byTournament = new Map();
|
||||
for (const r of rows) {
|
||||
const t = r.tournament;
|
||||
const c = r.competition;
|
||||
const m = r.member;
|
||||
if (!t || !c || !m) continue;
|
||||
if (!byTournament.has(t.id)) {
|
||||
byTournament.set(t.id, {
|
||||
tournamentId: String(t.id),
|
||||
title: t.title || null,
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
entries: [],
|
||||
_dates: [],
|
||||
_eventDate: t.eventDate || null,
|
||||
});
|
||||
}
|
||||
const bucket = byTournament.get(t.id);
|
||||
const compDate = this.parseDmy(c.startTime || '') || null;
|
||||
if (compDate) bucket._dates.push(compDate);
|
||||
bucket.entries.push({
|
||||
memberId: m.id,
|
||||
memberName: `${m.firstName || ''} ${m.lastName || ''}`.trim(),
|
||||
competitionId: c.id,
|
||||
competitionName: c.ageClassCompetition || '',
|
||||
placement: r.placement || null,
|
||||
date: compDate ? this.fmtDmy(compDate) : null,
|
||||
});
|
||||
}
|
||||
|
||||
const out = [];
|
||||
for (const t of tournaments) {
|
||||
const bucket = byTournament.get(t.id) || {
|
||||
tournamentId: String(t.id),
|
||||
title: t.title || null,
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
entries: [],
|
||||
_dates: [],
|
||||
_eventDate: t.eventDate || null,
|
||||
};
|
||||
if (bucket._dates.length) {
|
||||
bucket._dates.sort((a, b) => a - b);
|
||||
bucket.startDate = this.fmtDmy(bucket._dates[0]);
|
||||
bucket.endDate = this.fmtDmy(bucket._dates[bucket._dates.length - 1]);
|
||||
} else if (bucket._eventDate) {
|
||||
const all = String(bucket._eventDate).match(/(\d{1,2}\.\d{1,2}\.\d{4})/g) || [];
|
||||
if (all.length >= 1) {
|
||||
const d1 = this.parseDmy(all[0]);
|
||||
const d2 = all.length >= 2 ? this.parseDmy(all[1]) : d1;
|
||||
if (d1) bucket.startDate = this.fmtDmy(d1);
|
||||
if (d2) bucket.endDate = this.fmtDmy(d2);
|
||||
}
|
||||
}
|
||||
bucket.entries.sort((a, b) => {
|
||||
const mcmp = (a.memberName || '').localeCompare(b.memberName || '', 'de', { sensitivity: 'base' });
|
||||
if (mcmp !== 0) return mcmp;
|
||||
return (a.competitionName || '').localeCompare(b.competitionName || '', 'de', { sensitivity: 'base' });
|
||||
});
|
||||
delete bucket._dates;
|
||||
delete bucket._eventDate;
|
||||
out.push(bucket);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async deleteOfficialTournament(clubId, id) {
|
||||
const t = await OfficialTournament.findOne({ where: { id, clubId } });
|
||||
if (!t) return false;
|
||||
await OfficialCompetition.destroy({ where: { tournamentId: id } });
|
||||
await OfficialTournament.destroy({ where: { id } });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export default new OfficialTournamentService();
|
||||
137
backend/services/trainingStatsService.js
Normal file
137
backend/services/trainingStatsService.js
Normal file
@@ -0,0 +1,137 @@
|
||||
import { DiaryDate, Member, Participant } from '../models/index.js';
|
||||
import { Op } from 'sequelize';
|
||||
|
||||
class TrainingStatsService {
|
||||
async getTrainingStats(clubIdRaw) {
|
||||
const clubId = parseInt(clubIdRaw, 10);
|
||||
if (!Number.isFinite(clubId)) {
|
||||
const err = new Error('Ungültige clubId');
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const twelveMonthsAgo = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
|
||||
const threeMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 3, now.getDate());
|
||||
|
||||
const members = await Member.findAll({
|
||||
where: { active: true, clubId }
|
||||
});
|
||||
|
||||
const trainingsCount12Months = await DiaryDate.count({
|
||||
where: {
|
||||
clubId,
|
||||
date: { [Op.gte]: twelveMonthsAgo }
|
||||
}
|
||||
});
|
||||
|
||||
const trainingsCount3Months = await DiaryDate.count({
|
||||
where: {
|
||||
clubId,
|
||||
date: { [Op.gte]: threeMonthsAgo }
|
||||
}
|
||||
});
|
||||
|
||||
const stats = [];
|
||||
|
||||
for (const member of members) {
|
||||
const participation12Months = await Participant.count({
|
||||
include: [{
|
||||
model: DiaryDate,
|
||||
as: 'diaryDate',
|
||||
where: {
|
||||
clubId,
|
||||
date: { [Op.gte]: twelveMonthsAgo }
|
||||
}
|
||||
}],
|
||||
where: { memberId: member.id }
|
||||
});
|
||||
|
||||
const participation3Months = await Participant.count({
|
||||
include: [{
|
||||
model: DiaryDate,
|
||||
as: 'diaryDate',
|
||||
where: {
|
||||
clubId,
|
||||
date: { [Op.gte]: threeMonthsAgo }
|
||||
}
|
||||
}],
|
||||
where: { memberId: member.id }
|
||||
});
|
||||
|
||||
const participationTotal = await Participant.count({
|
||||
include: [{
|
||||
model: DiaryDate,
|
||||
as: 'diaryDate',
|
||||
where: { clubId }
|
||||
}],
|
||||
where: { memberId: member.id }
|
||||
});
|
||||
|
||||
const trainingDetails = await Participant.findAll({
|
||||
include: [{
|
||||
model: DiaryDate,
|
||||
as: 'diaryDate',
|
||||
where: { clubId }
|
||||
}],
|
||||
where: { memberId: member.id },
|
||||
order: [['diaryDate', 'date', 'DESC']],
|
||||
limit: 50
|
||||
});
|
||||
|
||||
const formattedTrainingDetails = trainingDetails.map((participation) => ({
|
||||
id: participation.id,
|
||||
date: participation.diaryDate.date,
|
||||
activityName: 'Training',
|
||||
startTime: '--:--',
|
||||
endTime: '--:--'
|
||||
}));
|
||||
|
||||
const lastTrainingDate = trainingDetails.length ? trainingDetails[0].diaryDate.date : null;
|
||||
const lastTrainingTs = lastTrainingDate ? new Date(lastTrainingDate).getTime() : 0;
|
||||
|
||||
stats.push({
|
||||
id: member.id,
|
||||
firstName: member.firstName,
|
||||
lastName: member.lastName,
|
||||
birthDate: member.birthDate,
|
||||
participation12Months,
|
||||
participation3Months,
|
||||
participationTotal,
|
||||
lastTraining: lastTrainingDate,
|
||||
lastTrainingTs,
|
||||
trainingDetails: formattedTrainingDetails
|
||||
});
|
||||
}
|
||||
|
||||
stats.sort((a, b) => b.participationTotal - a.participationTotal);
|
||||
|
||||
const trainingDays = await DiaryDate.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
date: { [Op.gte]: twelveMonthsAgo }
|
||||
},
|
||||
include: [{
|
||||
model: Participant,
|
||||
as: 'participantList',
|
||||
attributes: ['id']
|
||||
}],
|
||||
order: [['date', 'DESC']]
|
||||
});
|
||||
|
||||
const formattedTrainingDays = trainingDays.map((day) => ({
|
||||
id: day.id,
|
||||
date: day.date,
|
||||
participantCount: day.participantList ? day.participantList.length : 0
|
||||
}));
|
||||
|
||||
return {
|
||||
members: stats,
|
||||
trainingsCount12Months,
|
||||
trainingsCount3Months,
|
||||
trainingDays: formattedTrainingDays
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default new TrainingStatsService();
|
||||
@@ -170,9 +170,14 @@ export default {
|
||||
this.error = null;
|
||||
this.saving = true;
|
||||
try {
|
||||
await apiClient.post('/mytischtennis/verify', {
|
||||
const response = await apiClient.post('/mytischtennis/verify', {
|
||||
password: this.formData.password
|
||||
});
|
||||
if (response.status >= 400 || response?.data?.success === false) {
|
||||
const requestError = new Error('myTischtennis verify failed');
|
||||
requestError.response = response;
|
||||
throw requestError;
|
||||
}
|
||||
this.$emit('logged-in');
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Login:', error);
|
||||
@@ -203,7 +208,12 @@ export default {
|
||||
payload.userPassword = this.formData.userPassword;
|
||||
}
|
||||
|
||||
await apiClient.post('/mytischtennis/account', payload);
|
||||
const response = await apiClient.post('/mytischtennis/account', payload);
|
||||
if (response.status >= 400 || response?.data?.success === false) {
|
||||
const requestError = new Error('myTischtennis account save failed');
|
||||
requestError.response = response;
|
||||
throw requestError;
|
||||
}
|
||||
this.$emit('saved');
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Speichern:', error);
|
||||
|
||||
@@ -233,7 +233,12 @@ export default {
|
||||
};
|
||||
try {
|
||||
// 1-Klick-Re-Login: zuerst gespeicherte Session/Passwort serverseitig verwenden
|
||||
await apiClient.post('/mytischtennis/verify', {});
|
||||
const response = await apiClient.post('/mytischtennis/verify', {});
|
||||
if (response.status >= 400 || response?.data?.success === false) {
|
||||
const requestError = new Error('myTischtennis verify failed');
|
||||
requestError.response = response;
|
||||
throw requestError;
|
||||
}
|
||||
await this.loadAccount();
|
||||
this.loginFeedback = {
|
||||
type: 'success',
|
||||
|
||||
@@ -1176,20 +1176,51 @@ 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 +1232,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');
|
||||
|
||||
Reference in New Issue
Block a user