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