diff --git a/backend/controllers/memberController.js b/backend/controllers/memberController.js index 53cc7d0..2c10110 100644 --- a/backend/controllers/memberController.js +++ b/backend/controllers/memberController.js @@ -1,4 +1,5 @@ import MemberService from "../services/memberService.js"; +import MemberTransferService from "../services/memberTransferService.js"; import { devLog } from '../utils/logger.js'; const getClubMembers = async(req, res) => { @@ -99,4 +100,36 @@ const rotateMemberImage = async (req, res) => { } }; -export { getClubMembers, getWaitingApprovals, setClubMembers, uploadMemberImage, getMemberImage, updateRatingsFromMyTischtennis, rotateMemberImage }; \ No newline at end of file +const transferMembers = async (req, res) => { + try { + const { id: clubId } = req.params; + const { authcode: userToken } = req.headers; + const config = req.body; + + // Validierung + if (!config.transferEndpoint) { + return res.status(400).json({ + success: false, + error: 'Übertragungs-Endpoint ist erforderlich' + }); + } + + if (!config.transferTemplate) { + return res.status(400).json({ + success: false, + error: 'Übertragungs-Template ist erforderlich' + }); + } + + const result = await MemberTransferService.transferMembers(userToken, clubId, config); + res.status(result.status).json(result.response); + } catch (error) { + console.error('[transferMembers] - Error:', error); + res.status(500).json({ + success: false, + error: 'Fehler bei der Übertragung: ' + error.message + }); + } +}; + +export { getClubMembers, getWaitingApprovals, setClubMembers, uploadMemberImage, getMemberImage, updateRatingsFromMyTischtennis, rotateMemberImage, transferMembers }; \ No newline at end of file diff --git a/backend/routes/memberRoutes.js b/backend/routes/memberRoutes.js index c6b9b7a..86a6a00 100644 --- a/backend/routes/memberRoutes.js +++ b/backend/routes/memberRoutes.js @@ -1,4 +1,4 @@ -import { getClubMembers, getWaitingApprovals, setClubMembers, uploadMemberImage, getMemberImage, updateRatingsFromMyTischtennis, rotateMemberImage } from '../controllers/memberController.js'; +import { getClubMembers, getWaitingApprovals, setClubMembers, uploadMemberImage, getMemberImage, updateRatingsFromMyTischtennis, rotateMemberImage, transferMembers } from '../controllers/memberController.js'; import express from 'express'; import { authenticate } from '../middleware/authMiddleware.js'; import { authorize } from '../middleware/authorizationMiddleware.js'; @@ -16,5 +16,6 @@ router.post('/set/:id', authenticate, authorize('members', 'write'), setClubMemb router.get('/notapproved/:id', authenticate, authorize('members', 'read'), getWaitingApprovals); router.post('/update-ratings/:id', authenticate, authorize('mytischtennis', 'write'), updateRatingsFromMyTischtennis); router.post('/rotate-image/:clubId/:memberId', authenticate, authorize('members', 'write'), rotateMemberImage); +router.post('/transfer/:id', authenticate, authorize('members', 'write'), transferMembers); export default router; diff --git a/backend/services/memberTransferService.js b/backend/services/memberTransferService.js new file mode 100644 index 0000000..657fc81 --- /dev/null +++ b/backend/services/memberTransferService.js @@ -0,0 +1,561 @@ +import axios from 'axios'; +import Member from '../models/Member.js'; +import { checkAccess, getUserByToken } from '../utils/userUtils.js'; +import { encryptData, decryptData } from '../utils/encrypt.js'; +import { devLog } from '../utils/logger.js'; + +class MemberTransferService { + /** + * Überträgt alle Mitglieder mit testMembership = false an einen externen Endpoint + * + * @param {string} userToken - User Token für Authentifizierung + * @param {number} clubId - Club ID + * @param {Object} config - Übertragungskonfiguration + * @param {string} config.loginEndpoint - Login-Endpoint URL + * @param {string} config.loginFormat - Format für Login (json, form-data, x-www-form-urlencoded) + * @param {Object} config.loginCredentials - Login-Daten (z.B. { username: '...', password: '...' }) + * @param {string} config.transferEndpoint - Übertragungs-Endpoint URL + * @param {string} config.transferMethod - HTTP-Methode (POST, PUT, GET) + * @param {string} config.transferFormat - Format für Übertragung (json, xml, form-data, x-www-form-urlencoded) + * @param {string} config.transferTemplate - Template mit Platzhaltern (z.B. '{"name": "{{firstName}} {{lastName}}", "email": "{{email}}"}') + * @param {boolean} config.useBulkMode - Wenn true, werden alle Mitglieder in einem Bulk-Request übertragen (als {members: [...]}) + * @returns {Promise} Ergebnis der Übertragung + */ + async transferMembers(userToken, clubId, config) { + try { + await checkAccess(userToken, clubId); + + // 1. Alle Mitglieder mit testMembership = false laden + const members = await Member.findAll({ + where: { + clubId: clubId, + testMembership: false + } + }); + + if (members.length === 0) { + return { + status: 200, + response: { + success: true, + message: 'Keine Mitglieder zum Übertragen gefunden', + transferred: 0, + errors: [] + } + }; + } + + // 2. Login durchführen + let authToken = null; + let sessionCookie = null; + + if (config.loginEndpoint && config.loginCredentials) { + try { + const loginResult = await this.performLogin( + config.loginEndpoint, + config.loginFormat || 'json', + config.loginCredentials + ); + + if (!loginResult.success) { + return { + status: 401, + response: { + success: false, + message: 'Login fehlgeschlagen', + error: loginResult.error, + transferred: 0, + errors: [] + } + }; + } + + authToken = loginResult.token; + sessionCookie = loginResult.cookie; + } catch (loginError) { + devLog('[transferMembers] Login error:', loginError); + return { + status: 401, + response: { + success: false, + message: 'Login fehlgeschlagen: ' + loginError.message, + transferred: 0, + errors: [] + } + }; + } + } + + // 3. Mitglieder übertragen + const useBulkMode = config.useBulkMode === true; + + if (useBulkMode) { + // Bulk-Import: Alle Mitglieder auf einmal übertragen + try { + const membersArray = []; + const memberErrors = []; + + for (const member of members) { + try { + const memberData = this.replacePlaceholders( + config.transferTemplate, + member + ); + membersArray.push(memberData); + } catch (error) { + devLog(`[transferMembers] Error processing ${member.firstName} ${member.lastName}:`, error); + memberErrors.push({ + member: `${member.firstName} ${member.lastName}`, + error: error.message + }); + } + } + + // Bulk-Objekt erstellen + const bulkData = { + members: membersArray + }; + + const transferResult = await this.transferMember( + config.transferEndpoint, + config.transferMethod || 'POST', + config.transferFormat || 'json', + bulkData, + authToken, + sessionCookie + ); + + if (transferResult.success) { + const summary = transferResult.data?.summary || {}; + const transferred = summary.imported || membersArray.length; + const bulkErrors = transferResult.data?.results?.errors || []; + const bulkDuplicates = transferResult.data?.results?.duplicates || []; + + let message = `${transferred} von ${members.length} Mitglieder(n) erfolgreich übertragen.`; + if (bulkDuplicates.length > 0) { + message += ` ${bulkDuplicates.length} Duplikat(e) gefunden.`; + } + if (bulkErrors.length > 0 || memberErrors.length > 0) { + message += ` ${bulkErrors.length + memberErrors.length} Fehler.`; + } + + return { + status: 200, + response: { + success: true, + message: message, + transferred: transferred, + total: members.length, + duplicates: bulkDuplicates.length, + errors: [...bulkErrors, ...memberErrors], + details: transferResult.data + } + }; + } else { + return { + status: transferResult.status || 500, + response: { + success: false, + message: 'Bulk-Übertragung fehlgeschlagen', + error: transferResult.error, + transferred: 0, + total: members.length, + errors: memberErrors + } + }; + } + } catch (error) { + devLog('[transferMembers] Bulk transfer error:', error); + return { + status: 500, + response: { + success: false, + message: 'Fehler bei der Bulk-Übertragung: ' + error.message, + transferred: 0, + total: members.length, + errors: [] + } + }; + } + } else { + // Einzelne Übertragung: Jedes Mitglied einzeln übertragen + const results = []; + const errors = []; + let successCount = 0; + + for (const member of members) { + try { + const memberData = this.replacePlaceholders( + config.transferTemplate, + member + ); + + const transferResult = await this.transferMember( + config.transferEndpoint, + config.transferMethod || 'POST', + config.transferFormat || 'json', + memberData, + authToken, + sessionCookie + ); + + if (transferResult.success) { + successCount++; + results.push({ + member: `${member.firstName} ${member.lastName}`, + success: true + }); + } else { + errors.push({ + member: `${member.firstName} ${member.lastName}`, + error: transferResult.error + }); + } + } catch (error) { + devLog(`[transferMembers] Error transferring ${member.firstName} ${member.lastName}:`, error); + errors.push({ + member: `${member.firstName} ${member.lastName}`, + error: error.message + }); + } + } + + const message = `${successCount} von ${members.length} Mitglieder(n) erfolgreich übertragen.`; + + return { + status: 200, + response: { + success: true, + message: message, + transferred: successCount, + total: members.length, + results: results, + errors: errors + } + }; + } + } catch (error) { + devLog('[transferMembers] Error:', error); + return { + status: 500, + response: { + success: false, + message: 'Fehler bei der Übertragung: ' + error.message, + transferred: 0, + errors: [error.message] + } + }; + } + } + + /** + * Führt Login am externen Endpoint durch + */ + async performLogin(endpoint, format, credentials) { + try { + let requestConfig = { + url: endpoint, + method: 'POST', + timeout: 10000, + maxRedirects: 5, + validateStatus: (status) => status >= 200 && status < 400 + }; + + // Headers und Daten je nach Format setzen + if (format === 'json' || format === 'application/json') { + requestConfig.headers = { + 'Content-Type': 'application/json' + }; + requestConfig.data = credentials; + } else if (format === 'form-data' || format === 'multipart/form-data') { + const FormData = (await import('form-data')).default; + const formData = new FormData(); + for (const [key, value] of Object.entries(credentials)) { + formData.append(key, value); + } + requestConfig.headers = formData.getHeaders(); + requestConfig.data = formData; + } else if (format === 'x-www-form-urlencoded' || format === 'urlencoded') { + requestConfig.headers = { + 'Content-Type': 'application/x-www-form-urlencoded' + }; + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(credentials)) { + params.append(key, value); + } + requestConfig.data = params.toString(); + } else { + // Default: JSON + requestConfig.headers = { + 'Content-Type': 'application/json' + }; + requestConfig.data = credentials; + } + + const response = await axios(requestConfig); + + // Versuche Token/Cookie aus Response zu extrahieren + // Typische Felder: token, accessToken, authToken, cookie, session + const token = response.data?.token || + response.data?.accessToken || + response.data?.authToken || + response.headers['authorization'] || + response.headers['x-auth-token']; + + const cookie = response.headers['set-cookie']?.[0] || + response.data?.cookie || + response.data?.session; + + return { + success: true, + token: token, + cookie: cookie, + response: response.data + }; + } catch (error) { + devLog('[performLogin] Error:', error); + return { + success: false, + error: error.response?.data?.message || error.message || 'Login fehlgeschlagen' + }; + } + } + + /** + * Überträgt ein einzelnes Mitglied + */ + async transferMember(endpoint, method, format, data, authToken, sessionCookie) { + try { + let requestConfig = { + url: endpoint, + method: method.toUpperCase(), + timeout: 10000, + maxRedirects: 5 + }; + + // Auth-Header setzen falls vorhanden + if (authToken) { + requestConfig.headers = { + ...requestConfig.headers, + 'Authorization': `Bearer ${authToken}`, + 'X-Auth-Token': authToken + }; + } + + // Cookie setzen falls vorhanden + if (sessionCookie) { + requestConfig.headers = { + ...requestConfig.headers, + 'Cookie': sessionCookie + }; + } + + // Daten je nach Format setzen + if (format === 'json' || format === 'application/json') { + requestConfig.headers = { + ...requestConfig.headers, + 'Content-Type': 'application/json' + }; + requestConfig.data = typeof data === 'string' ? JSON.parse(data) : data; + } else if (format === 'xml' || format === 'application/xml' || format === 'text/xml') { + requestConfig.headers = { + ...requestConfig.headers, + 'Content-Type': 'application/xml' + }; + requestConfig.data = data; // XML als String + } else if (format === 'form-data' || format === 'multipart/form-data') { + const FormData = (await import('form-data')).default; + const formData = new FormData(); + + // Wenn data ein Objekt ist, alle Felder hinzufügen + if (typeof data === 'object') { + for (const [key, value] of Object.entries(data)) { + formData.append(key, value); + } + } else { + // Wenn data ein String ist, versuche es zu parsen + try { + const parsed = JSON.parse(data); + for (const [key, value] of Object.entries(parsed)) { + formData.append(key, value); + } + } catch { + // Fallback: als Text hinzufügen + formData.append('data', data); + } + } + + requestConfig.headers = { + ...formData.getHeaders(), + ...requestConfig.headers + }; + requestConfig.data = formData; + } else if (format === 'x-www-form-urlencoded' || format === 'urlencoded') { + requestConfig.headers = { + ...requestConfig.headers, + 'Content-Type': 'application/x-www-form-urlencoded' + }; + + const params = new URLSearchParams(); + if (typeof data === 'object') { + for (const [key, value] of Object.entries(data)) { + params.append(key, value); + } + } else { + // Versuche JSON zu parsen + try { + const parsed = JSON.parse(data); + for (const [key, value] of Object.entries(parsed)) { + params.append(key, value); + } + } catch { + params.append('data', data); + } + } + + requestConfig.data = params.toString(); + } else { + // Default: JSON + requestConfig.headers = { + ...requestConfig.headers, + 'Content-Type': 'application/json' + }; + requestConfig.data = typeof data === 'string' ? JSON.parse(data) : data; + } + + const response = await axios(requestConfig); + + return { + success: response.status >= 200 && response.status < 300, + status: response.status, + data: response.data + }; + } catch (error) { + devLog('[transferMember] Error:', error); + return { + success: false, + error: error.response?.data?.message || error.message || 'Übertragung fehlgeschlagen', + status: error.response?.status + }; + } + } + + /** + * Ersetzt Platzhalter im Template mit Mitgliedsdaten + * Unterstützte Platzhalter: {{firstName}}, {{lastName}}, {{email}}, {{phone}}, + * {{street}}, {{city}}, {{birthDate}}, {{ttr}}, {{qttr}}, {{gender}} + */ + replacePlaceholders(template, member) { + if (!template) { + return {}; + } + + // Wenn Template ein JSON-String ist, parsen + let parsedTemplate; + try { + parsedTemplate = JSON.parse(template); + } catch { + // Wenn kein JSON, als einfachen String behandeln + let result = template; + + // Alle verfügbaren Platzhalter ersetzen + const placeholders = { + '{{firstName}}': member.firstName || '', + '{{lastName}}': member.lastName || '', + '{{email}}': member.email || '', + '{{phone}}': member.phone || '', + '{{street}}': member.street || '', + '{{city}}': member.city || '', + '{{birthDate}}': this.formatBirthDate(member.birthDate) || '', + '{{geburtsdatum}}': this.formatBirthDate(member.birthDate) || '', + '{{address}}': `${member.street || ''}, ${member.city || ''}`.trim().replace(/^,\s*|,\s*$/g, ''), + '{{ttr}}': member.ttr || '', + '{{qttr}}': member.qttr || '', + '{{gender}}': member.gender || '', + '{{fullName}}': `${member.firstName || ''} ${member.lastName || ''}`.trim() + }; + + for (const [placeholder, value] of Object.entries(placeholders)) { + result = result.replace(new RegExp(placeholder.replace(/[{}]/g, '\\$&'), 'g'), value); + } + + return result; + } + + // Wenn es ein Objekt ist, rekursiv Platzhalter ersetzen + const replaceInObject = (obj) => { + if (typeof obj === 'string') { + let result = obj; + const placeholders = { + '{{firstName}}': member.firstName || '', + '{{lastName}}': member.lastName || '', + '{{email}}': member.email || '', + '{{phone}}': member.phone || '', + '{{street}}': member.street || '', + '{{city}}': member.city || '', + '{{birthDate}}': this.formatBirthDate(member.birthDate) || '', + '{{geburtsdatum}}': this.formatBirthDate(member.birthDate) || '', + '{{address}}': `${member.street || ''}, ${member.city || ''}`.trim().replace(/^,\s*|,\s*$/g, ''), + '{{ttr}}': member.ttr || '', + '{{qttr}}': member.qttr || '', + '{{gender}}': member.gender || '', + '{{fullName}}': `${member.firstName || ''} ${member.lastName || ''}`.trim() + }; + + for (const [placeholder, value] of Object.entries(placeholders)) { + result = result.replace(new RegExp(placeholder.replace(/[{}]/g, '\\$&'), 'g'), String(value)); + } + + return result; + } else if (Array.isArray(obj)) { + return obj.map(item => replaceInObject(item)); + } else if (obj && typeof obj === 'object') { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + result[replaceInObject(key)] = replaceInObject(value); + } + return result; + } + return obj; + }; + + return replaceInObject(parsedTemplate); + } + + /** + * Formatiert Geburtsdatum in YYYY-MM-DD Format + */ + formatBirthDate(birthDate) { + if (!birthDate) return ''; + + // Wenn bereits im richtigen Format + if (typeof birthDate === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(birthDate)) { + return birthDate; + } + + // Versuche zu parsen + try { + const date = new Date(birthDate); + if (!isNaN(date.getTime())) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + } + } catch (error) { + // Fallback: Versuche Format dd.mm.yyyy zu parsen + const match = String(birthDate).match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/); + if (match) { + const day = match[1].padStart(2, '0'); + const month = match[2].padStart(2, '0'); + const year = match[3]; + return `${year}-${month}-${day}`; + } + } + + return birthDate || ''; + } +} + +export default new MemberTransferService(); + diff --git a/frontend/src/components/MemberTransferDialog.vue b/frontend/src/components/MemberTransferDialog.vue new file mode 100644 index 0000000..ee58831 --- /dev/null +++ b/frontend/src/components/MemberTransferDialog.vue @@ -0,0 +1,528 @@ + + + + + + diff --git a/frontend/src/views/MembersView.vue b/frontend/src/views/MembersView.vue index 1fc3a81..d0c2d2c 100644 --- a/frontend/src/views/MembersView.vue +++ b/frontend/src/views/MembersView.vue @@ -28,6 +28,9 @@ +
@@ -156,35 +159,7 @@ - - - - - - - - - - - +
@@ -212,28 +187,41 @@ @delete-note="deleteNote" @close="closeNotesModal" /> -
- - - - - - + + + + + + + + + + + +