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