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.
This commit is contained in:
Torsten Schulz (local)
2025-11-06 16:12:34 +01:00
parent 5a4553a8a0
commit 9cdbd60a23
8 changed files with 417 additions and 23 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -44,10 +44,43 @@
<label><span>Vorname:</span> <input type="text" v-model="newFirstname"></label>
<label><span>Nachname:</span> <input type="text" v-model="newLastname"></label>
<label><span>Straße:</span> <input type="text" v-model="newStreet"></label>
<label><span>PLZ:</span> <input type="text" v-model="newPostalCode" maxlength="10"></label>
<label><span>Ort:</span> <input type="text" v-model="newCity"></label>
<label><span>Geburtsdatum:</span> <input type="date" v-model="newBirthdate"></label>
<label><span>Telefon-Nr.:</span> <input type="text" v-model="newPhone"></label>
<label><span>Email-Adresse:</span> <input type="email" v-model="newEmail"></label>
<!-- Telefonnummern -->
<div class="contact-section">
<label><span>Telefonnummern:</span></label>
<div v-for="(phone, index) in memberContacts.phones" :key="'phone-' + index" class="contact-item">
<input type="text" v-model="phone.value" placeholder="Telefonnummer" class="contact-input">
<label class="checkbox-inline">
<input type="checkbox" v-model="phone.isParent"> Elternteil
</label>
<input v-if="phone.isParent" type="text" v-model="phone.parentName" placeholder="Name (z.B. Mutter, Vater)" class="parent-name-input">
<label class="checkbox-inline">
<input type="checkbox" v-model="phone.isPrimary"> Primär
</label>
<button type="button" @click="removeContact('phone', index)" class="btn-remove-contact"></button>
</div>
<button type="button" @click="addContact('phone')" class="btn-add-contact">+ Telefonnummer hinzufügen</button>
</div>
<!-- E-Mail-Adressen -->
<div class="contact-section">
<label><span>E-Mail-Adressen:</span></label>
<div v-for="(email, index) in memberContacts.emails" :key="'email-' + index" class="contact-item">
<input type="email" v-model="email.value" placeholder="E-Mail-Adresse" class="contact-input">
<label class="checkbox-inline">
<input type="checkbox" v-model="email.isParent"> Elternteil
</label>
<input v-if="email.isParent" type="text" v-model="email.parentName" placeholder="Name (z.B. Mutter, Vater)" class="parent-name-input">
<label class="checkbox-inline">
<input type="checkbox" v-model="email.isPrimary"> Primär
</label>
<button type="button" @click="removeContact('email', index)" class="btn-remove-contact"></button>
</div>
<button type="button" @click="addContact('email')" class="btn-add-contact">+ E-Mail-Adresse hinzufügen</button>
</div>
<label><span>Geschlecht:</span>
<select v-model="newGender">
<option value="unknown">Unbekannt</option>
@@ -151,11 +184,11 @@
</span>
<span v-else class="no-rating">-</span>
</td>
<td>{{ member.street }}, {{ member.city }}</td>
<td>{{ member.street }}{{ member.postalCode ? ', ' + member.postalCode : '' }}, {{ member.city }}</td>
<td>{{ member.memberFormHandedOver ? '✓' : '' }}</td>
<td>{{ getFormattedBirthdate(member.birthDate) }}</td>
<td>{{ member.phone }}</td>
<td>{{ member.email }}</td>
<td>{{ getFormattedPhoneNumbers(member) }}</td>
<td>{{ getFormattedEmails(member) }}</td>
<td v-if="hasTestMembers">
<span v-if="member.testMembership">{{ member.trainingParticipations || 0 }}</span>
<span v-else>-</span>
@@ -336,10 +369,15 @@ export default {
newFirstname: '',
newLastname: '',
newStreet: '',
newPostalCode: '',
newCity: '',
newBirthdate: '',
newPhone: '',
newEmail: '',
memberContacts: {
phones: [],
emails: []
},
newGender: 'unknown',
newActive: true,
memberToEdit: null,
@@ -553,6 +591,7 @@ export default {
this.newFirstname = '';
this.newLastname = '';
this.newStreet = '';
this.newPostalCode = '';
this.newCity = '';
this.newBirthdate = '';
this.newPhone = '';
@@ -564,6 +603,34 @@ export default {
this.memberImage = null;
this.memberImagePreview = null;
this.newMemberFormHandedOver = false;
this.memberContacts = {
phones: [{ value: '', isParent: false, parentName: '', isPrimary: false }],
emails: [{ value: '', isParent: false, parentName: '', isPrimary: false }]
};
},
addContact(type) {
if (type === 'phone') {
this.memberContacts.phones.push({
value: '',
isParent: false,
parentName: '',
isPrimary: false
});
} else if (type === 'email') {
this.memberContacts.emails.push({
value: '',
isParent: false,
parentName: '',
isPrimary: false
});
}
},
removeContact(type, index) {
if (type === 'phone') {
this.memberContacts.phones.splice(index, 1);
} else if (type === 'email') {
this.memberContacts.emails.splice(index, 1);
}
},
onFileSelected(event) {
const file = event.target.files[0];
@@ -577,20 +644,47 @@ export default {
}
},
async addNewMember() {
// Prepare contacts array
const contacts = [];
this.memberContacts.phones.forEach(phone => {
if (phone.value && phone.value.trim()) {
contacts.push({
type: 'phone',
value: phone.value.trim(),
isParent: phone.isParent || false,
parentName: phone.isParent ? (phone.parentName || null) : null,
isPrimary: phone.isPrimary || false
});
}
});
this.memberContacts.emails.forEach(email => {
if (email.value && email.value.trim()) {
contacts.push({
type: 'email',
value: email.value.trim(),
isParent: email.isParent || false,
parentName: email.isParent ? (email.parentName || null) : null,
isPrimary: email.isPrimary || false
});
}
});
const memberData = {
firstname: this.newFirstname,
lastname: this.newLastname,
street: this.newStreet,
city: this.newCity,
postalCode: this.newPostalCode || null,
birthdate: this.newBirthdate,
phone: this.newPhone,
email: this.newEmail,
phone: this.newPhone, // Keep for backward compatibility
email: this.newEmail, // Keep for backward compatibility
gender: this.newGender,
active: this.newActive,
id: this.memberToEdit ? this.memberToEdit.id : null,
testMembership: this.testMembership,
picsInInternetAllowed: this.newPicsInInternetAllowed,
memberFormHandedOver: this.newMemberFormHandedOver,
contacts: contacts
};
let response;
@@ -627,6 +721,7 @@ export default {
this.newFirstname = member.firstName;
this.newLastname = member.lastName;
this.newStreet = member.street;
this.newPostalCode = member.postalCode || '';
this.newCity = member.city;
this.newPhone = member.phone;
this.newEmail = member.email;
@@ -636,6 +731,38 @@ export default {
this.testMembership = member.testMembership;
this.newPicsInInternetAllowed = member.picsInInternetAllowed;
this.newMemberFormHandedOver = !!member.memberFormHandedOver;
// Load contacts
if (member.contacts && Array.isArray(member.contacts)) {
this.memberContacts.phones = member.contacts
.filter(c => c.type === 'phone')
.map(c => ({
value: c.value || '',
isParent: c.isParent || false,
parentName: c.parentName || '',
isPrimary: c.isPrimary || false
}));
this.memberContacts.emails = member.contacts
.filter(c => c.type === 'email')
.map(c => ({
value: c.value || '',
isParent: c.isParent || false,
parentName: c.parentName || '',
isPrimary: c.isPrimary || false
}));
// If no contacts exist, add empty ones
if (this.memberContacts.phones.length === 0) {
this.memberContacts.phones.push({ value: '', isParent: false, parentName: '', isPrimary: false });
}
if (this.memberContacts.emails.length === 0) {
this.memberContacts.emails.push({ value: '', isParent: false, parentName: '', isPrimary: false });
}
} else {
// Fallback: use old phone/email fields
this.memberContacts.phones = [{ value: member.phone || '', isParent: false, parentName: '', isPrimary: true }];
this.memberContacts.emails = [{ value: member.email || '', isParent: false, parentName: '', isPrimary: true }];
}
try {
const response = await apiClient.get(`/clubmembers/image/${member.id}`, {
responseType: 'blob'
@@ -787,6 +914,76 @@ export default {
if (isNaN(date.getTime())) return '';
return `${String(date.getDate()).padStart(2, '0')}.${String(date.getMonth() + 1).padStart(2, '0')}.${date.getFullYear()}`;
},
getFormattedPhoneNumbers(member) {
if (!member) return '';
// 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) {
if (contact.parentName) {
phoneText += ` (${contact.parentName})`;
} else {
phoneText += ' (Elternteil)';
}
}
phoneNumbers.push(phoneText);
});
}
// Fallback auf altes phone-Feld für Rückwärtskompatibilität
if (phoneNumbers.length === 0 && member.phone) {
phoneNumbers.push(member.phone);
}
return phoneNumbers.length > 0 ? phoneNumbers.join(', ') : '';
},
getFormattedEmails(member) {
if (!member) return '';
// Sammle alle E-Mail-Adressen aus contacts
let emails = [];
if (member.contacts && Array.isArray(member.contacts)) {
const emailContacts = member.contacts
.filter(c => c.type === 'email' && c.value && String(c.value).trim() !== '')
.sort((a, b) => {
// Primäre E-Mail zuerst
if (a.isPrimary && !b.isPrimary) return -1;
if (!a.isPrimary && b.isPrimary) return 1;
return 0;
});
emailContacts.forEach(contact => {
let emailText = contact.value;
if (contact.isParent) {
if (contact.parentName) {
emailText += ` (${contact.parentName})`;
} else {
emailText += ' (Elternteil)';
}
}
emails.push(emailText);
});
}
// Fallback auf altes email-Feld für Rückwärtskompatibilität
if (emails.length === 0 && member.email) {
emails.push(member.email);
}
return emails.length > 0 ? emails.join(', ') : '';
},
labelGender(g) {
const v = (g || 'unknown');
if (v === 'male') return 'Männlich';
@@ -1347,6 +1544,74 @@ table td {
filter: grayscale(0);
}
.contact-section {
margin: 1rem 0;
padding: 1rem;
border: 1px solid #ddd;
border-radius: 4px;
background-color: #f9f9f9;
}
.contact-item {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.contact-input {
flex: 1;
min-width: 200px;
padding: 0.4rem;
border: 1px solid #ccc;
border-radius: 4px;
}
.parent-name-input {
flex: 0 0 150px;
padding: 0.4rem;
border: 1px solid #ccc;
border-radius: 4px;
}
.checkbox-inline {
display: flex;
align-items: center;
gap: 0.25rem;
white-space: nowrap;
font-size: 0.9em;
}
.btn-add-contact {
margin-top: 0.5rem;
padding: 0.4rem 0.8rem;
background-color: #28a745;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
}
.btn-add-contact:hover {
background-color: #218838;
}
.btn-remove-contact {
padding: 0.2rem 0.5rem;
background-color: #dc3545;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
}
.btn-remove-contact:hover {
background-color: #c82333;
}
.warning-icon {
margin-right: 0.25rem;
font-size: 1.1em;