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:
561
backend/services/memberTransferService.js
Normal file
561
backend/services/memberTransferService.js
Normal 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();
|
||||
|
||||
Reference in New Issue
Block a user