Add member transfer functionality to memberController and update routes and UI

Implemented a new transferMembers function in memberController to handle member transfers, including validation for transfer endpoint and template. Updated memberRoutes to include a new route for member transfers. Enhanced MembersView with a button to open a transfer dialog and integrated a MemberTransferDialog component for user interaction during the transfer process.
This commit is contained in:
Torsten Schulz (local)
2025-11-05 14:33:09 +01:00
parent 5bdcd946cf
commit 5bba9522b3
5 changed files with 1207 additions and 54 deletions

View File

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

View File

@@ -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;

View File

@@ -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<Object>} 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();