feat(memberController, memberRoutes, MembersView): implement Click-TT player registration feature

- Added a new endpoint for Click-TT player registration in memberController, allowing submission of existing player applications.
- Integrated the new endpoint into memberRoutes for handling requests.
- Updated MembersView to include a button for initiating Click-TT registration, with user confirmation and loading state management.
- Enhanced UI feedback for registration status, improving user experience during the application process.
This commit is contained in:
Torsten Schulz (local)
2026-03-11 13:17:59 +01:00
parent 9c30cd181c
commit 08095ce22e
5 changed files with 443 additions and 3 deletions

View File

@@ -0,0 +1,347 @@
import { chromium } from 'playwright';
import Member from '../models/Member.js';
import MyTischtennis from '../models/MyTischtennis.js';
import { checkAccess } from '../utils/userUtils.js';
import HttpError from '../exceptions/HttpError.js';
const CLICKTT_ENTRY_URL = 'https://httv.click-tt.de/';
const TRACE_LIMIT = 250;
function formatGermanDate(value) {
if (!value) return '';
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return '';
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const year = String(date.getFullYear());
return `${day}.${month}.${year}`;
}
function sanitizePageText(text) {
return String(text || '').replace(/\s+/g, ' ').trim().slice(0, 500);
}
function sanitizePayload(payload) {
return String(payload || '')
.replace(/(password|passwd|pwd|token|secret|captcha)=([^&\s]+)/gi, '$1=[redacted]')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 1500);
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
class ClickTtPlayerRegistrationService {
async submitExistingPlayerApplication({ userToken, userId, clubId, memberId }) {
await checkAccess(userToken, clubId);
const member = await Member.findOne({
where: { id: memberId, clubId },
include: [{ association: 'contacts', required: false }]
});
if (!member) {
throw new HttpError('Mitglied nicht gefunden', 404);
}
const account = await MyTischtennis.findOne({ where: { userId } });
if (!account?.email) {
throw new HttpError('Kein myTischtennis-/TTDE-Account für diesen Benutzer hinterlegt', 400);
}
const password = account.getPassword?.() || null;
const savedStorageState = account.playwrightStorageState ?? null;
if (!savedStorageState && !password) {
throw new HttpError('Für die Click-TT-Automatisierung wird ein gespeichertes myTischtennis-Passwort oder eine gültige Browser-Session benötigt', 400);
}
const email = account.email;
const memberJson = member.toJSON();
const primaryEmail = this._getPrimaryContactValue(memberJson, 'email') || memberJson.email || '';
if (!primaryEmail) {
throw new HttpError('Für den Antrag wird eine E-Mail-Adresse beim Mitglied benötigt', 400);
}
let browser = null;
let context = null;
let page = null;
const trace = [];
try {
browser = await chromium.launch({
headless: true,
args: ['--no-sandbox', '--disable-dev-shm-usage']
});
context = savedStorageState
? await browser.newContext({ storageState: savedStorageState })
: await browser.newContext();
page = await context.newPage();
this._attachNetworkLogging(page, trace);
await this._openAuthenticatedClickTt(page, { email, password, trace });
await this._clickByText(page, 'Spielberechtigungen beantragen', trace);
await this._fillSearchForm(page, memberJson);
await this._clickByText(page, 'Personen suchen', trace);
await page.waitForLoadState('domcontentloaded');
await this._abortIfConfirmFlow(page);
await this._fillApplicationForm(page, memberJson, primaryEmail);
await this._clickByText(page, 'Weiter >>', trace);
await page.waitForLoadState('domcontentloaded');
await this._clickByText(page, 'Speichern', trace);
await page.waitForLoadState('domcontentloaded');
await this._clickByText(page, 'Einreichen', trace);
await page.waitForLoadState('domcontentloaded');
const finalText = sanitizePageText(await page.locator('body').innerText());
const storageState = await context.storageState();
account.playwrightStorageState = storageState;
await account.save({ fields: ['playwrightStorageState'] });
this._trace(trace, 'final', {
url: page.url(),
text: finalText
});
return {
success: true,
message: `Spielberechtigung fuer ${memberJson.firstName} ${memberJson.lastName} wurde in click-TT eingereicht.`,
finalUrl: page.url(),
finalText,
trace
};
} catch (error) {
let diagnostics = {};
try {
diagnostics = {
url: page?.url?.() || null,
text: sanitizePageText(await page?.locator?.('body')?.innerText?.()),
traceTail: trace.slice(-25)
};
} catch (_err) {
diagnostics = {};
}
this._trace(trace, 'error', {
message: error?.message || String(error),
url: diagnostics.url || null
});
const message = error instanceof HttpError
? error.message
: `Click-TT-Automatisierung fehlgeschlagen: ${error.message || error}`;
const wrappedError = new HttpError(`${message}${diagnostics.url ? ` (Seite: ${diagnostics.url})` : ''}${diagnostics.text ? ` - ${diagnostics.text}` : ''}`, error.statusCode || error.status || 500);
wrappedError.trace = diagnostics.traceTail || [];
throw wrappedError;
} finally {
if (context) {
try {
await context.close();
} catch (_err) {
// ignore
}
}
if (browser) {
try {
await browser.close();
} catch (_err) {
// ignore
}
}
}
}
_getPrimaryContactValue(member, type) {
const contacts = Array.isArray(member.contacts) ? member.contacts : [];
const matching = contacts.filter(contact => contact?.type === type && contact?.value);
const primary = matching.find(contact => contact.isPrimary);
return (primary || matching[0] || {}).value || '';
}
_trace(trace, type, data = {}) {
const entry = {
ts: new Date().toISOString(),
type,
...data
};
trace.push(entry);
if (trace.length > TRACE_LIMIT) {
trace.shift();
}
console.log(`[ClickTT Playwright] ${type}`, JSON.stringify(entry));
}
_attachNetworkLogging(page, trace) {
page.on('framenavigated', (frame) => {
if (frame === page.mainFrame()) {
this._trace(trace, 'navigate', { url: frame.url() });
}
});
page.on('request', (request) => {
const resourceType = request.resourceType();
const method = request.method();
if (!['document', 'xhr', 'fetch'].includes(resourceType) && method === 'GET') {
return;
}
this._trace(trace, 'request', {
method,
resourceType,
url: request.url(),
postData: sanitizePayload(request.postData() || '')
});
});
page.on('response', async (response) => {
const request = response.request();
const resourceType = request.resourceType();
const method = request.method();
if (!['document', 'xhr', 'fetch'].includes(resourceType) && method === 'GET') {
return;
}
let bodySnippet = '';
if (resourceType === 'document') {
try {
bodySnippet = sanitizePageText(await response.text());
} catch (_err) {
bodySnippet = '';
}
}
this._trace(trace, 'response', {
method,
resourceType,
url: response.url(),
status: response.status(),
bodySnippet
});
});
}
async _openAuthenticatedClickTt(page, { email, password, trace }) {
this._trace(trace, 'step', { name: 'open-entry', url: CLICKTT_ENTRY_URL });
await page.goto(CLICKTT_ENTRY_URL, { waitUntil: 'domcontentloaded', timeout: 45000 });
const directLoginOrAuth = page.locator('input[name="email"], input[name="password"]').first();
const loginLinkCandidates = [
page.getByRole('link', { name: /login|anmelden/i }).first(),
page.locator('a[href*="oauth2/authz"], a[href*="oAuthLogin"], a[href*="login"]').first()
];
if (!(await directLoginOrAuth.count())) {
for (const locator of loginLinkCandidates) {
if (await locator.count()) {
this._trace(trace, 'step', { name: 'click-login-entry' });
await locator.click();
await page.waitForLoadState('domcontentloaded');
break;
}
}
}
const needsLogin = await page.locator('input[name="email"], input[name="password"]').count();
if (!needsLogin) {
this._trace(trace, 'step', { name: 'session-restored', url: page.url() });
await page.waitForURL(/click-tt\.de/, { timeout: 45000 });
return;
}
if (!password) {
throw new HttpError('Die gespeicherte Click-TT-/TTDE-Session ist abgelaufen und es ist kein Passwort gespeichert', 400);
}
this._trace(trace, 'step', { name: 'fill-login-form' });
await page.locator('input[name="email"]').first().fill(email);
await page.locator('input[name="password"]').first().fill(password);
const submitLocator = page.locator('button[type="submit"], input[type="submit"]').first();
this._trace(trace, 'step', { name: 'submit-login' });
await submitLocator.click();
await page.waitForURL(/click-tt\.de/, { timeout: 60000 });
}
async _fillSearchForm(page, member) {
await this._fillFirstAvailable(page, [
'input[name*=".1"]',
'input[type="text"]'
], member.lastName);
await this._fillNthTextInput(page, 1, member.firstName);
await this._fillNthTextInput(page, 2, formatGermanDate(member.birthDate));
}
async _fillApplicationForm(page, member, email) {
const birthDate = formatGermanDate(member.birthDate);
const textInputs = page.locator('input[type="text"]');
const inputCount = await textInputs.count();
const fillIfEmpty = async (index, value) => {
if (!value || index >= inputCount) return;
const locator = textInputs.nth(index);
const currentValue = (await locator.inputValue().catch(() => '')).trim();
if (!currentValue) {
await locator.fill(value);
}
};
await fillIfEmpty(0, member.lastName);
await fillIfEmpty(1, member.firstName);
await fillIfEmpty(3, birthDate);
await fillIfEmpty(5, member.street || '');
await fillIfEmpty(6, member.postalCode || '');
await fillIfEmpty(7, member.city || '');
await fillIfEmpty(13, email);
}
async _abortIfConfirmFlow(page) {
const confirmLocator = page.locator('[onclick*="confirm("]').first();
if (await confirmLocator.count()) {
const text = sanitizePageText(await confirmLocator.innerText().catch(() => '') || await confirmLocator.getAttribute('value').catch(() => '') || '');
throw new HttpError(`Der Antrag befindet sich im noch nicht automatisierten click-TT-Confirm-/Neuanlage-Flow${text ? `: ${text}` : ''}`, 409);
}
}
async _fillFirstAvailable(page, selectors, value) {
for (const selector of selectors) {
const locator = page.locator(selector).first();
if (await locator.count()) {
await locator.fill(value);
return true;
}
}
return false;
}
async _fillNthTextInput(page, index, value) {
if (!value) return false;
const locator = page.locator('input[type="text"]').nth(index);
if (await locator.count()) {
await locator.fill(value);
return true;
}
return false;
}
async _clickByText(page, text, trace) {
const escaped = escapeRegExp(text);
const selectors = [
`input[type="submit"][value*="${text}"]`,
`input[type="button"][value*="${text}"]`,
`button:has-text("${text}")`,
`a:has-text("${text}")`,
`text=/${escaped}/i`
];
for (const selector of selectors) {
const locator = page.locator(selector).first();
if (await locator.count()) {
this._trace(trace, 'step', {
name: 'click',
label: text,
selector
});
await locator.click();
return true;
}
}
throw new HttpError(`Click-TT-Element nicht gefunden: ${text}`, 500);
}
}
export default new ClickTtPlayerRegistrationService();