From 9cdbd60a23dce23633fd038e2d31d4c01fb82441 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Thu, 6 Nov 2025 16:12:34 +0100 Subject: [PATCH] Enhance member management by adding postal code and contact handling Introduced a postal code field to the member model and implemented a new MemberContact model to manage multiple phone numbers and email addresses. Updated the member service and controller to handle contact data during member creation and updates. Enhanced the MembersView component to support input for multiple contacts, ensuring better organization and accessibility of member information. --- backend/controllers/memberController.js | 10 +- backend/models/Member.js | 16 ++ backend/models/index.js | 5 + backend/server.js | 3 +- backend/services/memberService.js | 76 ++++++- backend/utils/encrypt.js | 16 +- frontend/src/components/PDFGenerator.js | 35 ++- frontend/src/views/MembersView.vue | 279 +++++++++++++++++++++++- 8 files changed, 417 insertions(+), 23 deletions(-) diff --git a/backend/controllers/memberController.js b/backend/controllers/memberController.js index ac9692f..c331b36 100644 --- a/backend/controllers/memberController.js +++ b/backend/controllers/memberController.js @@ -26,16 +26,16 @@ const getWaitingApprovals = async(req, res) => { const setClubMembers = async (req, res) => { try { - const { id: memberId, firstname: firstName, lastname: lastName, street, city, birthdate, phone, email, active, - testMembership, picsInInternetAllowed, gender, ttr, qttr, memberFormHandedOver } = req.body; + const { id: memberId, firstname: firstName, lastname: lastName, street, city, postalCode, birthdate, phone, email, active, + testMembership, picsInInternetAllowed, gender, ttr, qttr, memberFormHandedOver, contacts } = req.body; const { id: clubId } = req.params; const { authcode: userToken } = req.headers; - const addResult = await MemberService.setClubMember(userToken, clubId, memberId, firstName, lastName, street, city, birthdate, - phone, email, active, testMembership, picsInInternetAllowed, gender, ttr, qttr, memberFormHandedOver); + const addResult = await MemberService.setClubMember(userToken, clubId, memberId, firstName, lastName, street, city, postalCode, birthdate, + phone, email, active, testMembership, picsInInternetAllowed, gender, ttr, qttr, memberFormHandedOver, contacts); res.status(addResult.status || 500).json(addResult.response); } catch (error) { console.error('[setClubMembers] - Error:', error); - res.status(500).json({ error: 'Failed to upload image' }); + res.status(500).json({ error: 'Failed to save member' }); } } diff --git a/backend/models/Member.js b/backend/models/Member.js index 6db2f73..e9616b7 100644 --- a/backend/models/Member.js +++ b/backend/models/Member.js @@ -52,6 +52,7 @@ const Member = sequelize.define('Member', { }, get() { const encryptedValue = this.getDataValue('birthDate'); + if (!encryptedValue) return null; return decryptData(encryptedValue); } }, @@ -91,6 +92,21 @@ const Member = sequelize.define('Member', { return decryptData(encryptedValue); } }, + postalCode: { + type: DataTypes.STRING, + allowNull: true, + set(value) { + const encryptedValue = encryptData(value || ''); + this.setDataValue('postalCode', encryptedValue); + }, + get() { + const encryptedValue = this.getDataValue('postalCode'); + if (!encryptedValue) return null; + return decryptData(encryptedValue); + }, + field: 'postal_code', + comment: 'Postal code (PLZ)' + }, email: { type: DataTypes.STRING, allowNull: false, diff --git a/backend/models/index.js b/backend/models/index.js index dbcd4ad..9111d33 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -40,6 +40,7 @@ import MyTischtennisUpdateHistory from './MyTischtennisUpdateHistory.js'; import MyTischtennisFetchLog from './MyTischtennisFetchLog.js'; import ApiLog from './ApiLog.js'; import MemberTransferConfig from './MemberTransferConfig.js'; +import MemberContact from './MemberContact.js'; // Official tournaments relations OfficialTournament.hasMany(OfficialCompetition, { foreignKey: 'tournamentId', as: 'competitions' }); OfficialCompetition.belongsTo(OfficialTournament, { foreignKey: 'tournamentId', as: 'tournament' }); @@ -246,6 +247,9 @@ ApiLog.belongsTo(User, { foreignKey: 'userId', as: 'user' }); Club.hasOne(MemberTransferConfig, { foreignKey: 'clubId', as: 'memberTransferConfig' }); MemberTransferConfig.belongsTo(Club, { foreignKey: 'clubId', as: 'club' }); +Member.hasMany(MemberContact, { foreignKey: 'memberId', as: 'contacts' }); +MemberContact.belongsTo(Member, { foreignKey: 'memberId', as: 'member' }); + export { User, Log, @@ -288,4 +292,5 @@ export { MyTischtennisFetchLog, ApiLog, MemberTransferConfig, + MemberContact, }; diff --git a/backend/server.js b/backend/server.js index 23151cc..9916c17 100644 --- a/backend/server.js +++ b/backend/server.js @@ -8,7 +8,7 @@ import { DiaryNote, DiaryTag, MemberDiaryTag, DiaryDateTag, DiaryMemberNote, DiaryMemberTag, PredefinedActivity, PredefinedActivityImage, DiaryDateActivity, DiaryMemberActivity, Match, League, Team, ClubTeam, TeamDocument, Group, GroupActivity, Tournament, TournamentGroup, TournamentMatch, TournamentResult, - TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember, MyTischtennis, MyTischtennisUpdateHistory, MyTischtennisFetchLog, ApiLog, MemberTransferConfig + TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember, MyTischtennis, MyTischtennisUpdateHistory, MyTischtennisFetchLog, ApiLog, MemberTransferConfig, MemberContact } from './models/index.js'; import authRoutes from './routes/authRoutes.js'; import clubRoutes from './routes/clubRoutes.js'; @@ -205,6 +205,7 @@ app.get('*', (req, res) => { await safeSync(MyTischtennisFetchLog); await safeSync(ApiLog); await safeSync(MemberTransferConfig); + await safeSync(MemberContact); // Start scheduler service schedulerService.start(); diff --git a/backend/services/memberService.js b/backend/services/memberService.js index 1e534e7..5db59f5 100644 --- a/backend/services/memberService.js +++ b/backend/services/memberService.js @@ -29,13 +29,41 @@ class MemberService { if (!showAll || showAll === 'false') { where['active'] = true; } - return await Member.findAll({ where }) + const MemberContact = (await import('../models/MemberContact.js')).default; + return await Member.findAll({ + where, + include: [{ + model: MemberContact, + as: 'contacts', + required: false, + attributes: ['id', 'memberId', 'type', 'value', 'isParent', 'parentName', 'isPrimary', 'createdAt', 'updatedAt'] + }], + raw: false // Ensure we get model instances, not plain objects, so getters are called + }) .then(members => { return members.map(member => { const imagePath = path.join('images', 'members', `${member.id}.jpg`); const hasImage = fs.existsSync(imagePath); + const memberJson = member.toJSON(); + // Ensure contacts are properly serialized - access via model instance to trigger getters + if (member.contacts && Array.isArray(member.contacts)) { + memberJson.contacts = member.contacts.map(contact => { + // Access properties through the model instance to trigger getters + return { + id: contact.id, + memberId: contact.memberId, + type: contact.type, + value: contact.value, // Getter should decrypt this + isParent: contact.isParent, + parentName: contact.parentName, // Getter should decrypt this + isPrimary: contact.isPrimary, + createdAt: contact.createdAt, + updatedAt: contact.updatedAt + }; + }); + } return { - ...member.toJSON(), + ...memberJson, hasImage: hasImage }; }); @@ -49,19 +77,21 @@ class MemberService { }); } - async setClubMember(userToken, clubId, memberId, firstName, lastName, street, city, birthdate, phone, email, active = true, testMembership = false, - picsInInternetAllowed = false, gender = 'unknown', ttr = null, qttr = null, memberFormHandedOver = false) { + async setClubMember(userToken, clubId, memberId, firstName, lastName, street, city, postalCode, birthdate, phone, email, active = true, testMembership = false, + picsInInternetAllowed = false, gender = 'unknown', ttr = null, qttr = null, memberFormHandedOver = false, contacts = []) { try { await checkAccess(userToken, clubId); let member = null; if (memberId) { member = await Member.findOne({ where: { id: memberId } }); } + const MemberContact = (await import('../models/MemberContact.js')).default; if (member) { member.firstName = firstName; member.lastName = lastName; member.street = street; member.city = city; + if (postalCode !== undefined) member.postalCode = postalCode; member.birthDate = birthdate; member.phone = phone; member.email = email; @@ -73,12 +103,32 @@ class MemberService { if (qttr !== undefined) member.qttr = qttr; member.memberFormHandedOver = !!memberFormHandedOver; await member.save(); + + // Update contacts if provided + if (Array.isArray(contacts)) { + // Delete existing contacts + await MemberContact.destroy({ where: { memberId: member.id } }); + // Create new contacts + for (const contact of contacts) { + if (contact.value && contact.type) { + await MemberContact.create({ + memberId: member.id, + type: contact.type, + value: contact.value, + isParent: contact.isParent || false, + parentName: contact.parentName || null, + isPrimary: contact.isPrimary || false + }); + } + } + } } else { - await Member.create({ + const newMember = await Member.create({ firstName: firstName, lastName: lastName, street: street, city: city, + postalCode: postalCode || null, birthDate: birthdate, phone: phone, email: email, @@ -91,6 +141,22 @@ class MemberService { qttr: qttr, memberFormHandedOver: !!memberFormHandedOver, }); + + // Create contacts if provided + if (Array.isArray(contacts)) { + for (const contact of contacts) { + if (contact.value && contact.type) { + await MemberContact.create({ + memberId: newMember.id, + type: contact.type, + value: contact.value, + isParent: contact.isParent || false, + parentName: contact.parentName || null, + isPrimary: contact.isPrimary || false + }); + } + } + } } return { status: 200, diff --git a/backend/utils/encrypt.js b/backend/utils/encrypt.js index 66dd44f..5aab2e3 100644 --- a/backend/utils/encrypt.js +++ b/backend/utils/encrypt.js @@ -24,10 +24,18 @@ function encryptData(data) { } function decryptData(data) { - const decipher = crypto.createDecipheriv('aes-256-cbc', Buffer.from(process.env.ENCRYPTION_KEY, 'hex'), Buffer.alloc(16, 0)); - let decrypted = decipher.update(data, 'hex', 'utf8'); - decrypted += decipher.final('utf8'); - return decrypted; + if (!data || data === null || data === undefined || data === '') { + return null; + } + try { + const decipher = crypto.createDecipheriv('aes-256-cbc', Buffer.from(process.env.ENCRYPTION_KEY, 'hex'), Buffer.alloc(16, 0)); + let decrypted = decipher.update(data, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + return decrypted; + } catch (error) { + console.error('[decryptData] Error decrypting data:', error); + return null; + } } export { createHash, encryptData, decryptData }; \ No newline at end of file diff --git a/frontend/src/components/PDFGenerator.js b/frontend/src/components/PDFGenerator.js index b79338b..5173e68 100644 --- a/frontend/src/components/PDFGenerator.js +++ b/frontend/src/components/PDFGenerator.js @@ -203,7 +203,40 @@ class PDFGenerator { addPhoneListRow(member) { const fullName = `${member.lastName}, ${member.firstName}`; const birthDate = member.birthDate ? new Date(member.birthDate).toLocaleDateString('de-DE') : ''; - const phoneNumber = member.phone || ''; + + // Sammle alle Telefonnummern aus contacts + let phoneNumbers = []; + if (member.contacts && Array.isArray(member.contacts)) { + const phoneContacts = member.contacts + .filter(c => c.type === 'phone' && c.value && String(c.value).trim() !== '') + .sort((a, b) => { + // Primäre Telefonnummer zuerst + if (a.isPrimary && !b.isPrimary) return -1; + if (!a.isPrimary && b.isPrimary) return 1; + return 0; + }); + + phoneContacts.forEach(contact => { + let phoneText = contact.value; + if (contact.isParent) { + // Bei Elternteil-Nummern: Name des Elternteils + Name des Mitglieds + if (contact.parentName) { + phoneText += ` (${contact.parentName} von ${member.firstName} ${member.lastName})`; + } else { + phoneText += ` (Elternteil von ${member.firstName} ${member.lastName})`; + } + } + // Bei eigenen Nummern wird nur die Nummer angezeigt (Name steht bereits in erster Spalte) + phoneNumbers.push(phoneText); + }); + } + + // Fallback auf altes phone-Feld für Rückwärtskompatibilität + if (phoneNumbers.length === 0 && member.phone) { + phoneNumbers.push(member.phone); + } + + const phoneNumber = phoneNumbers.join(', ') || ''; this.pdf.text(fullName, this.margin, this.yPos); this.pdf.text(birthDate, this.margin + 60, this.yPos); diff --git a/frontend/src/views/MembersView.vue b/frontend/src/views/MembersView.vue index 78fed35..2776f9d 100644 --- a/frontend/src/views/MembersView.vue +++ b/frontend/src/views/MembersView.vue @@ -44,10 +44,43 @@ + - - + + +
+ +
+ + + + + +
+ +
+ + +
+ +
+ + + + + +
+ +