From 08095ce22ef8834a18afa9b5829d4c40bfaa01d3 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Wed, 11 Mar 2026 13:17:59 +0100 Subject: [PATCH] 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. --- backend/controllers/memberController.js | 26 +- backend/routes/clickTtHttpPageRoutes.js | 17 +- backend/routes/memberRoutes.js | 2 + .../clickTtPlayerRegistrationService.js | 347 ++++++++++++++++++ frontend/src/views/MembersView.vue | 54 ++- 5 files changed, 443 insertions(+), 3 deletions(-) create mode 100644 backend/services/clickTtPlayerRegistrationService.js diff --git a/backend/controllers/memberController.js b/backend/controllers/memberController.js index c46b384b..478ae99b 100644 --- a/backend/controllers/memberController.js +++ b/backend/controllers/memberController.js @@ -1,5 +1,6 @@ import MemberService from "../services/memberService.js"; import MemberTransferService from "../services/memberTransferService.js"; +import clickTtPlayerRegistrationService from "../services/clickTtPlayerRegistrationService.js"; import { emitMemberChanged } from '../services/socketService.js'; import { devLog } from '../utils/logger.js'; @@ -207,6 +208,28 @@ const quickDeactivateMember = async (req, res) => { } }; +const requestClickTtPlayerRegistration = async (req, res) => { + try { + const { clubId, memberId } = req.params; + const { authcode: userToken } = req.headers; + const userId = req.user?.id; + const result = await clickTtPlayerRegistrationService.submitExistingPlayerApplication({ + userToken, + userId, + clubId, + memberId + }); + res.status(200).json(result); + } catch (error) { + console.error('[requestClickTtPlayerRegistration] - Error:', error); + res.status(error.statusCode || error.status || 500).json({ + success: false, + error: error.message || 'Click-TT-Antrag konnte nicht eingereicht werden', + trace: Array.isArray(error.trace) ? error.trace : [] + }); + } +}; + const transferMembers = async (req, res) => { try { const { id: clubId } = req.params; @@ -251,7 +274,8 @@ export { quickUpdateTestMembership, quickUpdateMemberFormHandedOver, quickDeactivateMember, + requestClickTtPlayerRegistration, deleteMemberImage, setPrimaryMemberImage, generateMemberGallery -}; \ No newline at end of file +}; diff --git a/backend/routes/clickTtHttpPageRoutes.js b/backend/routes/clickTtHttpPageRoutes.js index 66a6a27e..0da2e308 100644 --- a/backend/routes/clickTtHttpPageRoutes.js +++ b/backend/routes/clickTtHttpPageRoutes.js @@ -336,7 +336,12 @@ function injectProxyNavigationScript(html, proxyBaseUrl, pageBaseUrl, sid) { "var submitControl=event.target&&event.target.closest?event.target.closest('button, input[type=\"submit\"], input[type=\"image\"]'):null;", 'if(submitControl){', 'lastSubmitter=submitControl;', - "try{console.log('[ClickTT Proxy] submit control click',{name:submitControl.name||null,value:submitControl.value||null,type:submitControl.type||submitControl.tagName,formAction:submitControl.getAttribute?submitControl.getAttribute('formaction'):null,text:(submitControl.textContent||'').trim().slice(0,120)});}catch(e){}", + "try{console.log('[ClickTT Proxy] submit control click',{name:submitControl.name||null,value:submitControl.value||null,type:submitControl.type||submitControl.tagName,formAction:submitControl.getAttribute?submitControl.getAttribute('formaction'):null,onclick:submitControl.getAttribute?submitControl.getAttribute('onclick'):null,text:(submitControl.textContent||'').trim().slice(0,120)});}catch(e){}", + 'if(!shouldAllowInlineConfirm(submitControl)){', + 'event.preventDefault();', + 'event.stopPropagation();', + 'return;', + '}', '}', 'if(!anchor||event.defaultPrevented)return;', 'if(!shouldAllowInlineConfirm(anchor)){', @@ -353,6 +358,16 @@ function injectProxyNavigationScript(html, proxyBaseUrl, pageBaseUrl, sid) { 'var form=event.target;', "if(!form||!form.tagName||form.tagName.toLowerCase()!=='form')return;", 'var submitter=event.submitter||lastSubmitter||null;', + 'if(submitter&&!shouldAllowInlineConfirm(submitter)){', + 'event.preventDefault();', + 'event.stopPropagation();', + 'return;', + '}', + 'if(!shouldAllowInlineConfirm(form)){', + 'event.preventDefault();', + 'event.stopPropagation();', + 'return;', + '}', 'var targetUrl=getSubmitTarget(form,submitter);', 'if(!targetUrl||!shouldProxyUrl(targetUrl))return;', 'if(submitter&&submitter.form===form&&submitter.name){', diff --git a/backend/routes/memberRoutes.js b/backend/routes/memberRoutes.js index eb2a2cd9..b327af02 100644 --- a/backend/routes/memberRoutes.js +++ b/backend/routes/memberRoutes.js @@ -10,6 +10,7 @@ import { quickUpdateTestMembership, quickUpdateMemberFormHandedOver, quickDeactivateMember, + requestClickTtPlayerRegistration, deleteMemberImage, setPrimaryMemberImage, generateMemberGallery @@ -39,5 +40,6 @@ router.post('/transfer/:id', authenticate, authorize('members', 'write'), transf router.post('/quick-update-test-membership/:clubId/:memberId', authenticate, authorize('members', 'write'), quickUpdateTestMembership); router.post('/quick-update-member-form/:clubId/:memberId', authenticate, authorize('members', 'write'), quickUpdateMemberFormHandedOver); router.post('/quick-deactivate/:clubId/:memberId', authenticate, authorize('members', 'write'), quickDeactivateMember); +router.post('/clicktt-registration/:clubId/:memberId', authenticate, authorize('members', 'write'), requestClickTtPlayerRegistration); export default router; diff --git a/backend/services/clickTtPlayerRegistrationService.js b/backend/services/clickTtPlayerRegistrationService.js new file mode 100644 index 00000000..051e573b --- /dev/null +++ b/backend/services/clickTtPlayerRegistrationService.js @@ -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(); diff --git a/frontend/src/views/MembersView.vue b/frontend/src/views/MembersView.vue index d13f1960..0ee88a86 100644 --- a/frontend/src/views/MembersView.vue +++ b/frontend/src/views/MembersView.vue @@ -255,6 +255,15 @@ + + {{ clickTtPendingMemberIds.includes(member.id) ? '⏳' : '🏓' }} + 📝 @@ -475,7 +484,8 @@ export default { selectedGroupToAdd: '', showTransferDialog: false, selectedAgeGroup: '', - selectedGender: '' + selectedGender: '', + clickTtPendingMemberIds: [] } }, async mounted() { @@ -647,6 +657,37 @@ export default { this.showInfo(this.$t('messages.error'), errorMessage, '', 'error'); } }, + async requestClickTtRegistration(member) { + if (this.clickTtPendingMemberIds.includes(member.id)) { + return; + } + + const confirmed = await this.showConfirm( + 'Click-TT-Antrag starten', + `Soll fuer ${member.firstName} ${member.lastName} der automatisierte Click-TT-Antrag gestartet werden?`, + 'Aktuell ist nur der Workflow fuer bereits im click-TT-System vorhandene Spieler automatisiert.', + 'info' + ); + if (!confirmed) { + return; + } + + this.clickTtPendingMemberIds = [...this.clickTtPendingMemberIds, member.id]; + try { + const response = await apiClient.post(`/clubmembers/clicktt-registration/${this.currentClub}/${member.id}`); + if (response.data?.success) { + await this.showInfo('Click-TT-Antrag', getSafeMessage(response.data.message, 'Der Click-TT-Antrag wurde erfolgreich eingereicht.'), response.data.finalUrl || '', 'success'); + } else { + await this.showInfo('Click-TT-Antrag', getSafeMessage(response.data?.error, 'Der Click-TT-Antrag konnte nicht eingereicht werden.'), '', 'error'); + } + } catch (error) { + console.error('Click-TT-Antrag fehlgeschlagen', error); + const errorMessage = getSafeErrorMessage(error, 'Der Click-TT-Antrag konnte nicht eingereicht werden.'); + await this.showInfo('Click-TT-Antrag', errorMessage, '', 'error'); + } finally { + this.clickTtPendingMemberIds = this.clickTtPendingMemberIds.filter(id => id !== member.id); + } + }, toggleNewMember() { this.memberFormIsOpen = !this.memberFormIsOpen; }, @@ -1959,6 +2000,17 @@ table td { opacity: 0.8; } +.action-icon-disabled { + opacity: 0.5; + cursor: wait; + pointer-events: none; +} + +.action-icon-disabled:hover { + transform: none; + opacity: 0.5; +} + .action-icon-deactivate { filter: grayscale(0.3); }