From 63a5db1196840260ddbd582357ecead53a97fbe9 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Fri, 24 Jul 2026 07:53:05 +0200 Subject: [PATCH] feat: Implement member self-service features for profile change requests and event responses - Added MemberProfileChangeRequest and MemberEventResponse models with associations. - Created API endpoints for member dashboard, profile change requests, event responses, and training attendance updates. - Developed MemberHomeView component to display member-specific information and actions. - Updated routing to include new member dashboard and profile change request functionalities. - Added SQL migration scripts for new database tables related to member self-service. - Enhanced login functionality to redirect members based on their roles and club associations. --- backend/controllers/memberController.js | 168 +++++++++++++++++- ...0260723_add_member_self_service_phase2.sql | 27 +++ backend/models/MemberEventResponse.js | 17 ++ backend/models/MemberProfileChangeRequest.js | 21 +++ backend/models/index.js | 18 ++ backend/routes/memberRoutes.js | 16 +- backend/server.js | 4 +- docs/manual_sql_migrations.md | 38 ++++ docs/simple-user-plan.md | 30 ++-- frontend/src/router.js | 2 + frontend/src/views/Login.vue | 11 +- frontend/src/views/MemberHomeView.vue | 67 +++++++ 12 files changed, 398 insertions(+), 21 deletions(-) create mode 100644 backend/migrations/20260723_add_member_self_service_phase2.sql create mode 100644 backend/models/MemberEventResponse.js create mode 100644 backend/models/MemberProfileChangeRequest.js create mode 100644 frontend/src/views/MemberHomeView.vue diff --git a/backend/controllers/memberController.js b/backend/controllers/memberController.js index 24f66ac4..24240f67 100755 --- a/backend/controllers/memberController.js +++ b/backend/controllers/memberController.js @@ -1,9 +1,19 @@ +import { Op } from 'sequelize'; import MemberService from "../services/memberService.js"; import MemberTransferService from "../services/memberTransferService.js"; import clickTtPlayerRegistrationService from "../services/clickTtPlayerRegistrationService.js"; import { emitMemberChanged } from '../services/socketService.js'; import Member from '../models/Member.js'; import UserClub from '../models/UserClub.js'; +import CalendarEvent from '../models/CalendarEvent.js'; +import TrainingGroup from '../models/TrainingGroup.js'; +import ClubCommunicationRecipient from '../models/ClubCommunicationRecipient.js'; +import ClubCommunicationThread from '../models/ClubCommunicationThread.js'; +import ClubSepaMandate from '../models/ClubSepaMandate.js'; +import MemberProfileChangeRequest from '../models/MemberProfileChangeRequest.js'; +import MemberEventResponse from '../models/MemberEventResponse.js'; +import Participant from '../models/Participant.js'; +import DiaryDate from '../models/DiaryDates.js'; import { devLog } from '../utils/logger.js'; const getClubMembers = async(req, res) => { @@ -375,6 +385,155 @@ const linkMemberUser = async (req, res) => { } }; +const getApprovedLinkedMember = async (clubId, userId) => { + const clubUser = await UserClub.findOne({ where: { clubId, userId, approved: true } }); + if (!clubUser) return { error: 'Kein Zugriff auf diesen Verein.', status: 403 }; + const member = await Member.findOne({ where: { clubId, userId, active: true } }); + if (!member) return { error: 'Für dieses Konto ist kein aktives Mitgliedsprofil verknüpft.', status: 404 }; + return { member }; +}; + +const getMemberDashboard = async (req, res) => { + try { + const clubId = Number(req.params.clubId); + const userId = Number(req.user?.id); + const access = await getApprovedLinkedMember(clubId, userId); + if (access.error) return res.status(access.status).json({ error: access.error }); + const { member } = access; + const today = new Date().toISOString().slice(0, 10); + const [events, trainingGroups, recipients, sepaMandate, changeRequests, trainingDates] = await Promise.all([ + CalendarEvent.findAll({ + where: { clubId, startDate: { [Op.gte]: today }, status: { [Op.notIn]: ['cancelled', 'archived'] } }, + attributes: ['id', 'title', 'startDate', 'endDate', 'eventType', 'location', 'registrationDeadline'], + order: [['startDate', 'ASC']], limit: 5, + }), + TrainingGroup.findAll({ where: { clubId }, include: [{ model: Member, as: 'members', where: { id: member.id }, attributes: [], through: { attributes: [] } }, { association: 'trainingTimes', required: false }], order: [['name', 'ASC']] }), + ClubCommunicationRecipient.findAll({ where: { clubId, memberId: member.id, deliveryStatus: 'sent' }, include: [{ model: ClubCommunicationThread, as: 'thread', attributes: ['id', 'subject', 'sentAt'] }], order: [['deliveredAt', 'DESC']], limit: 5 }), + ClubSepaMandate.findOne({ where: { clubId, memberId: member.id, status: 'active', revokedAt: null }, attributes: ['id'] }), + MemberProfileChangeRequest.findAll({ where: { clubId, memberId: member.id, status: 'open' }, attributes: ['id', 'requestedChanges', 'createdAt'], order: [['createdAt', 'DESC']] }), + DiaryDate.findAll({ where: { clubId, date: { [Op.gte]: today } }, include: [{ model: Member, as: 'participants', where: { id: member.id }, required: false, through: { attributes: ['attendanceStatus'] } }], attributes: ['id', 'date', 'trainingStart', 'trainingEnd'], order: [['date', 'ASC']], limit: 5 }), + ]); + const responses = await MemberEventResponse.findAll({ where: { clubId, memberId: member.id, eventId: events.map((event) => event.id) } }); + const responseByEvent = new Map(responses.map((response) => [response.eventId, response.status])); + res.json({ + profile: { id: member.id, firstName: member.firstName, lastName: member.lastName, email: member.email, phone: member.phone, street: member.street, postalCode: member.postalCode, city: member.city }, + membership: { status: member.membershipStatus, type: member.membershipType, joinedOn: member.joinedOn, needsSepaMandate: member.needsSepaMandate, hasActiveSepaMandate: Boolean(sepaMandate) }, + openProfileChangeRequests: changeRequests, + upcomingEvents: events.map((event) => ({ ...event.toJSON(), responseStatus: responseByEvent.get(event.id) || null })), + trainingGroups: trainingGroups.map((group) => ({ id: group.id, name: group.name, trainingTimes: group.trainingTimes || [] })), + upcomingTrainingDates: trainingDates.map((date) => ({ id: date.id, date: date.date, trainingStart: date.trainingStart, trainingEnd: date.trainingEnd, attendanceStatus: date.participants?.[0]?.Participant?.attendanceStatus || 'present' })), + messages: recipients.map((recipient) => ({ id: recipient.thread?.id || recipient.id, subject: recipient.thread?.subject || 'Vereinsnachricht', sentAt: recipient.deliveredAt || recipient.thread?.sentAt || null })), + }); + } catch (error) { + console.error('[getMemberDashboard] - Error:', error); + res.status(500).json({ error: 'Persönlicher Bereich konnte nicht geladen werden.' }); + } +}; + +const createProfileChangeRequest = async (req, res) => { + try { + const clubId = Number(req.params.clubId); + const userId = Number(req.user?.id); + const access = await getApprovedLinkedMember(clubId, userId); + if (access.error) return res.status(access.status).json({ error: access.error }); + const allowedFields = ['email', 'phone', 'street', 'postalCode', 'city']; + const requestedChanges = Object.fromEntries(allowedFields + .filter((field) => typeof req.body?.[field] === 'string' && req.body[field].trim() !== '') + .map((field) => [field, req.body[field].trim()])); + if (!Object.keys(requestedChanges).length) return res.status(400).json({ error: 'Bitte mindestens ein gültiges Kontaktfeld angeben.' }); + const request = await MemberProfileChangeRequest.create({ clubId, memberId: access.member.id, requestedByUserId: userId, requestedChanges }); + res.status(201).json({ id: request.id, status: request.status, requestedChanges: request.requestedChanges, createdAt: request.createdAt }); + } catch (error) { + console.error('[createProfileChangeRequest] - Error:', error); + res.status(500).json({ error: 'Änderungsanfrage konnte nicht eingereicht werden.' }); + } +}; + +const respondToCalendarEvent = async (req, res) => { + try { + const clubId = Number(req.params.clubId); + const eventId = Number(req.params.eventId); + const status = req.body?.status; + if (!['attending', 'declined'].includes(status)) return res.status(400).json({ error: 'Ungültige Rückmeldung.' }); + const access = await getApprovedLinkedMember(clubId, Number(req.user?.id)); + if (access.error) return res.status(access.status).json({ error: access.error }); + const event = await CalendarEvent.findOne({ where: { id: eventId, clubId } }); + if (!event) return res.status(404).json({ error: 'Veranstaltung nicht gefunden.' }); + const today = new Date().toISOString().slice(0, 10); + if (event.startDate < today || (event.registrationDeadline && event.registrationDeadline < today)) return res.status(409).json({ error: 'Für diese Veranstaltung sind keine Rückmeldungen mehr möglich.' }); + const [response] = await MemberEventResponse.upsert({ clubId, eventId, memberId: access.member.id, status }, { returning: true }); + res.json({ eventId, status: response.status }); + } catch (error) { + console.error('[respondToCalendarEvent] - Error:', error); + res.status(500).json({ error: 'Rückmeldung konnte nicht gespeichert werden.' }); + } +}; + +const updateOwnTrainingAttendance = async (req, res) => { + try { + const clubId = Number(req.params.clubId); + const diaryDateId = Number(req.params.diaryDateId); + const attendanceStatus = req.body?.attendanceStatus; + if (!['present', 'excused', 'cancelled'].includes(attendanceStatus)) return res.status(400).json({ error: 'Ungültiger Trainingsstatus.' }); + const access = await getApprovedLinkedMember(clubId, Number(req.user?.id)); + if (access.error) return res.status(access.status).json({ error: access.error }); + const diaryDate = await DiaryDate.findOne({ where: { id: diaryDateId, clubId } }); + if (!diaryDate) return res.status(404).json({ error: 'Trainingstag nicht gefunden.' }); + const [participant] = await Participant.findOrCreate({ where: { diaryDateId, memberId: access.member.id }, defaults: { attendanceStatus } }); + if (!participant.isNewRecord) await participant.update({ attendanceStatus }); + res.json({ diaryDateId, attendanceStatus: participant.attendanceStatus }); + } catch (error) { + console.error('[updateOwnTrainingAttendance] - Error:', error); + res.status(500).json({ error: 'Trainingsrückmeldung konnte nicht gespeichert werden.' }); + } +}; + +const getEventResponses = async (req, res) => { + try { + const clubId = Number(req.params.clubId); + const eventId = Number(req.params.eventId); + const responses = await MemberEventResponse.findAll({ where: { clubId, eventId }, include: [{ model: Member, as: 'member', attributes: ['id', 'firstName', 'lastName'] }], order: [['updatedAt', 'DESC']] }); + res.json(responses.map((response) => ({ id: response.id, memberId: response.memberId, status: response.status, updatedAt: response.updatedAt, member: response.member ? { id: response.member.id, firstName: response.member.firstName, lastName: response.member.lastName } : null }))); + } catch (error) { + console.error('[getEventResponses] - Error:', error); + res.status(500).json({ error: 'Rückmeldungen konnten nicht geladen werden.' }); + } +}; + +const getProfileChangeRequests = async (req, res) => { + try { + const clubId = Number(req.params.clubId); + const requests = await MemberProfileChangeRequest.findAll({ + where: { clubId, ...(req.query.status ? { status: req.query.status } : {}) }, + include: [{ model: Member, as: 'member', attributes: ['id', 'firstName', 'lastName'] }], + order: [['createdAt', 'DESC']], + }); + res.json(requests.map((request) => ({ id: request.id, memberId: request.memberId, status: request.status, requestedChanges: request.requestedChanges, reviewNote: request.reviewNote, createdAt: request.createdAt, member: request.member ? { id: request.member.id, firstName: request.member.firstName, lastName: request.member.lastName } : null }))); + } catch (error) { + console.error('[getProfileChangeRequests] - Error:', error); + res.status(500).json({ error: 'Änderungsanfragen konnten nicht geladen werden.' }); + } +}; + +const reviewProfileChangeRequest = async (req, res) => { + try { + const clubId = Number(req.params.clubId); + const requestId = Number(req.params.requestId); + const status = req.body?.status; + const reviewNote = typeof req.body?.reviewNote === 'string' ? req.body.reviewNote.trim() : null; + if (!['approved', 'rejected'].includes(status)) return res.status(400).json({ error: 'Ungültiger Prüfstatus.' }); + const request = await MemberProfileChangeRequest.findOne({ where: { id: requestId, clubId, status: 'open' }, include: [{ model: Member, as: 'member' }] }); + if (!request || !request.member) return res.status(404).json({ error: 'Offene Änderungsanfrage nicht gefunden.' }); + if (status === 'approved') await request.member.update(request.requestedChanges); + await request.update({ status, reviewNote, reviewedByUserId: req.user.id, reviewedAt: new Date() }); + if (status === 'approved') emitMemberChanged(clubId); + res.json({ id: request.id, status: request.status }); + } catch (error) { + console.error('[reviewProfileChangeRequest] - Error:', error); + res.status(500).json({ error: 'Änderungsanfrage konnte nicht geprüft werden.' }); + } +}; + export { getClubMembers, getWaitingApprovals, @@ -397,5 +556,12 @@ export { deleteMemberImage, setPrimaryMemberImage, generateMemberGallery, - linkMemberUser + linkMemberUser, + getMemberDashboard, + createProfileChangeRequest, + respondToCalendarEvent, + updateOwnTrainingAttendance, + getEventResponses, + getProfileChangeRequests, + reviewProfileChangeRequest }; diff --git a/backend/migrations/20260723_add_member_self_service_phase2.sql b/backend/migrations/20260723_add_member_self_service_phase2.sql new file mode 100644 index 00000000..99d1827f --- /dev/null +++ b/backend/migrations/20260723_add_member_self_service_phase2.sql @@ -0,0 +1,27 @@ +CREATE TABLE member_profile_change_requests ( + id INT AUTO_INCREMENT PRIMARY KEY, + club_id INT NOT NULL, + member_id INT NOT NULL, + requested_by_user_id INT NOT NULL, + requested_changes JSON NOT NULL, + status ENUM('open', 'approved', 'rejected') NOT NULL DEFAULT 'open', + review_note TEXT NULL, + reviewed_by_user_id INT NULL, + reviewed_at DATETIME NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + INDEX member_profile_change_requests_club_status (club_id, status), + INDEX member_profile_change_requests_member_status (member_id, status) +); + +CREATE TABLE member_event_responses ( + id INT AUTO_INCREMENT PRIMARY KEY, + club_id INT NOT NULL, + event_id INT NOT NULL, + member_id INT NOT NULL, + status ENUM('attending', 'declined') NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + UNIQUE KEY member_event_responses_event_member (event_id, member_id), + INDEX member_event_responses_club_event (club_id, event_id) +); diff --git a/backend/models/MemberEventResponse.js b/backend/models/MemberEventResponse.js new file mode 100644 index 00000000..480616d3 --- /dev/null +++ b/backend/models/MemberEventResponse.js @@ -0,0 +1,17 @@ +import { DataTypes } from 'sequelize'; +import sequelize from '../database.js'; + +const MemberEventResponse = sequelize.define('MemberEventResponse', { + id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, + clubId: { type: DataTypes.INTEGER, allowNull: false, field: 'club_id' }, + eventId: { type: DataTypes.INTEGER, allowNull: false, field: 'event_id' }, + memberId: { type: DataTypes.INTEGER, allowNull: false, field: 'member_id' }, + status: { type: DataTypes.ENUM('attending', 'declined'), allowNull: false }, +}, { + tableName: 'member_event_responses', + underscored: true, + timestamps: true, + indexes: [{ unique: true, fields: ['event_id', 'member_id'] }, { fields: ['club_id', 'event_id'] }], +}); + +export default MemberEventResponse; diff --git a/backend/models/MemberProfileChangeRequest.js b/backend/models/MemberProfileChangeRequest.js new file mode 100644 index 00000000..d9138f7e --- /dev/null +++ b/backend/models/MemberProfileChangeRequest.js @@ -0,0 +1,21 @@ +import { DataTypes } from 'sequelize'; +import sequelize from '../database.js'; + +const MemberProfileChangeRequest = sequelize.define('MemberProfileChangeRequest', { + id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, + clubId: { type: DataTypes.INTEGER, allowNull: false, field: 'club_id' }, + memberId: { type: DataTypes.INTEGER, allowNull: false, field: 'member_id' }, + requestedByUserId: { type: DataTypes.INTEGER, allowNull: false, field: 'requested_by_user_id' }, + requestedChanges: { type: DataTypes.JSON, allowNull: false, field: 'requested_changes' }, + status: { type: DataTypes.ENUM('open', 'approved', 'rejected'), allowNull: false, defaultValue: 'open' }, + reviewNote: { type: DataTypes.TEXT, allowNull: true, field: 'review_note' }, + reviewedByUserId: { type: DataTypes.INTEGER, allowNull: true, field: 'reviewed_by_user_id' }, + reviewedAt: { type: DataTypes.DATE, allowNull: true, field: 'reviewed_at' }, +}, { + tableName: 'member_profile_change_requests', + underscored: true, + timestamps: true, + indexes: [{ fields: ['club_id', 'status'] }, { fields: ['member_id', 'status'] }], +}); + +export default MemberProfileChangeRequest; diff --git a/backend/models/index.js b/backend/models/index.js index e362fea3..5d5911bf 100755 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -93,6 +93,8 @@ import ClubCommunicationDeliveryLog from './ClubCommunicationDeliveryLog.js'; import ClubCommunicationTemplate from './ClubCommunicationTemplate.js'; import ClubDistributionGroup from './ClubDistributionGroup.js'; import ClubDistributionGroupMember from './ClubDistributionGroupMember.js'; +import MemberProfileChangeRequest from './MemberProfileChangeRequest.js'; +import MemberEventResponse from './MemberEventResponse.js'; // Official tournaments relations OfficialTournament.hasMany(OfficialCompetition, { foreignKey: 'tournamentId', as: 'competitions' }); OfficialCompetition.belongsTo(OfficialTournament, { foreignKey: 'tournamentId', as: 'tournament' }); @@ -500,6 +502,20 @@ ClubSepaMandate.belongsTo(Club, { foreignKey: 'clubId', as: 'club' }); Member.hasMany(ClubSepaMandate, { foreignKey: 'memberId', as: 'sepaMandates' }); ClubSepaMandate.belongsTo(Member, { foreignKey: 'memberId', as: 'member', constraints: false }); +Club.hasMany(MemberProfileChangeRequest, { foreignKey: 'clubId', as: 'memberProfileChangeRequests' }); +MemberProfileChangeRequest.belongsTo(Club, { foreignKey: 'clubId', as: 'club' }); +Member.hasMany(MemberProfileChangeRequest, { foreignKey: 'memberId', as: 'profileChangeRequests' }); +MemberProfileChangeRequest.belongsTo(Member, { foreignKey: 'memberId', as: 'member' }); +User.hasMany(MemberProfileChangeRequest, { foreignKey: 'requestedByUserId', as: 'requestedMemberProfileChanges' }); +MemberProfileChangeRequest.belongsTo(User, { foreignKey: 'requestedByUserId', as: 'requestedByUser' }); + +Club.hasMany(MemberEventResponse, { foreignKey: 'clubId', as: 'memberEventResponses' }); +MemberEventResponse.belongsTo(Club, { foreignKey: 'clubId', as: 'club' }); +CalendarEvent.hasMany(MemberEventResponse, { foreignKey: 'eventId', as: 'memberResponses' }); +MemberEventResponse.belongsTo(CalendarEvent, { foreignKey: 'eventId', as: 'event' }); +Member.hasMany(MemberEventResponse, { foreignKey: 'memberId', as: 'eventResponses' }); +MemberEventResponse.belongsTo(Member, { foreignKey: 'memberId', as: 'member' }); + Club.hasMany(ClubPaymentClaim, { foreignKey: 'clubId', as: 'paymentClaims' }); ClubPaymentClaim.belongsTo(Club, { foreignKey: 'clubId', as: 'club' }); Member.hasMany(ClubPaymentClaim, { foreignKey: 'memberId', as: 'paymentClaims' }); @@ -688,5 +704,7 @@ export { ClubCommunicationDeliveryLog, ClubCommunicationTemplate, ClubDistributionGroup, + MemberProfileChangeRequest, + MemberEventResponse, ClubDistributionGroupMember, }; diff --git a/backend/routes/memberRoutes.js b/backend/routes/memberRoutes.js index efcdf903..9e170cb8 100755 --- a/backend/routes/memberRoutes.js +++ b/backend/routes/memberRoutes.js @@ -20,7 +20,14 @@ import { deleteMemberImage, setPrimaryMemberImage, generateMemberGallery, - linkMemberUser + linkMemberUser, + getMemberDashboard, + createProfileChangeRequest, + respondToCalendarEvent, + updateOwnTrainingAttendance, + getEventResponses, + getProfileChangeRequests, + reviewProfileChangeRequest } from '../controllers/memberController.js'; import express from 'express'; import { authenticate } from '../middleware/authMiddleware.js'; @@ -41,6 +48,13 @@ router.get('/get/:id/:showAll', authenticate, authorize('members', 'read'), getC router.get('/gallery/:clubId', authenticate, authorize('members', 'read'), generateMemberGallery); router.post('/set/:id', authenticate, authorize('members', 'write'), setClubMembers); router.put('/link-user/:clubId/:memberId', authenticate, authorize('members', 'write'), linkMemberUser); +router.get('/dashboard/:clubId', authenticate, getMemberDashboard); +router.post('/profile/:clubId/change-requests', authenticate, createProfileChangeRequest); +router.put('/dashboard/:clubId/events/:eventId/response', authenticate, respondToCalendarEvent); +router.put('/dashboard/:clubId/training/:diaryDateId/attendance', authenticate, updateOwnTrainingAttendance); +router.get('/event-responses/:clubId/:eventId', authenticate, authorize('schedule', 'read'), getEventResponses); +router.get('/profile-change-requests/:clubId', authenticate, authorize('members', 'read'), getProfileChangeRequests); +router.patch('/profile-change-requests/:clubId/:requestId', authenticate, authorize('members', 'write'), reviewProfileChangeRequest); router.get('/sepa/:clubId/:memberId', authenticate, authorize('members', 'read'), getMemberSepaMandate); router.put('/sepa/:clubId/:memberId', authenticate, authorize('members', 'write'), saveMemberSepaMandate); router.get('/play-interest/:clubId', authenticate, authorize('members', 'read'), getMemberPlayInterests); diff --git a/backend/server.js b/backend/server.js index 352d473c..09252c61 100755 --- a/backend/server.js +++ b/backend/server.js @@ -16,7 +16,7 @@ import { TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember, MyTischtennis, ClickTtAccount, MyTischtennisUpdateHistory, MyTischtennisFetchLog, ApiLog, MemberTransferConfig, MemberContact, MemberTtrHistory, MemberPlayInterest, MemberOrder, MemberOrderHistory, MemberGroupPhoto, BillingTemplate, BillingTemplateField, BillingRun, BillingDocument, BillingDocumentValue, BillingUserSetting, FriendlyMatch, TrainingCancellation , FriendlyMatchShared, FriendlyMatchInvitation - , CalendarEvent, ClubVenue, ClubRequest, ClubRequestNote, ClubSepaMandate, ClubPaymentClaim, ClubAccount, ClubAccountTransaction, ClubInvoiceParty, ClubInvoice, ClubInvoiceItem, ClubRole, ClubUserRole, ClubCommunicationThread, ClubCommunicationMessage, ClubCommunicationRecipient, ClubCommunicationDeliveryLog, ClubCommunicationTemplate, ClubDistributionGroup, ClubDistributionGroupMember + , CalendarEvent, ClubVenue, ClubRequest, ClubRequestNote, ClubSepaMandate, ClubPaymentClaim, ClubAccount, ClubAccountTransaction, ClubInvoiceParty, ClubInvoice, ClubInvoiceItem, ClubRole, ClubUserRole, ClubCommunicationThread, ClubCommunicationMessage, ClubCommunicationRecipient, ClubCommunicationDeliveryLog, ClubCommunicationTemplate, ClubDistributionGroup, ClubDistributionGroupMember, MemberProfileChangeRequest, MemberEventResponse } from './models/index.js'; import authRoutes from './routes/authRoutes.js'; import clubRoutes from './routes/clubRoutes.js'; @@ -662,6 +662,8 @@ app.use((err, req, res, next) => { await safeSync(ClubTeam); await safeSync(TrainingCancellation); await safeSync(CalendarEvent); + await safeSync(MemberProfileChangeRequest); + await safeSync(MemberEventResponse); await safeSync(TeamDocument); // Foreign Keys wieder aktivieren diff --git a/docs/manual_sql_migrations.md b/docs/manual_sql_migrations.md index 600f03aa..38af368c 100755 --- a/docs/manual_sql_migrations.md +++ b/docs/manual_sql_migrations.md @@ -46,6 +46,44 @@ Rueckwaertskompatibilitaet: - Bestehende Vorgange behalten eine leere Antwortadresse und verwenden weiterhin die globale Absenderadresse. +## 2026-07-23 + +### Persönlicher Mitgliederbereich: Änderungsanfragen und Terminrückmeldungen + +```sql +CREATE TABLE member_profile_change_requests ( + id INT AUTO_INCREMENT PRIMARY KEY, + club_id INT NOT NULL, + member_id INT NOT NULL, + requested_by_user_id INT NOT NULL, + requested_changes JSON NOT NULL, + status ENUM('open', 'approved', 'rejected') NOT NULL DEFAULT 'open', + review_note TEXT NULL, + reviewed_by_user_id INT NULL, + reviewed_at DATETIME NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + INDEX member_profile_change_requests_club_status (club_id, status), + INDEX member_profile_change_requests_member_status (member_id, status) +); + +CREATE TABLE member_event_responses ( + id INT AUTO_INCREMENT PRIMARY KEY, + club_id INT NOT NULL, + event_id INT NOT NULL, + member_id INT NOT NULL, + status ENUM('attending', 'declined') NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + UNIQUE KEY member_event_responses_event_member (event_id, member_id), + INDEX member_event_responses_club_event (club_id, event_id) +); +``` + +Die Daten enthalten keine Zahlungsdaten. Bestehende Vereins- und +Mitgliedsdaten bleiben unverändert; die neuen Tabellen werden erst durch die +persönlichen Rückmeldungen bzw. Änderungsanfragen befüllt. + ## 2026-03-17 ### `predefined_activities.exclude_from_stats` diff --git a/docs/simple-user-plan.md b/docs/simple-user-plan.md index cd53795c..7720b966 100644 --- a/docs/simple-user-plan.md +++ b/docs/simple-user-plan.md @@ -19,28 +19,28 @@ Finanzen, Rollenverwaltung, Archiv oder interne Vorgänge zu sehen. ### Phase 1: Zugang und persönlicher Einstieg -- [ ] Rollenprofil `member` und Navigation gegen unzulässige Ziele auditieren. -- [ ] Persönliches Dashboard für Termine, Training, Rückmeldungen und Vereinsnachrichten umsetzen. -- [ ] Leere Zustände für fehlende Termine, Nachrichten und Rückmeldungen formulieren. -- [ ] Persönliche Startseite als Standardziel nach Login festlegen. +- [x] Rollenprofil `member` und Navigation gegen unzulässige Ziele auditieren. +- [x] Persönliches Dashboard für Termine, Training, Rückmeldungen und Vereinsnachrichten umsetzen. +- [x] Leere Zustände für fehlende Termine, Nachrichten und Rückmeldungen formulieren. +- [x] Persönliche Startseite als Standardziel nach Login festlegen. Abnahme: -- [ ] Ein Nutzer ohne Verwaltungsrechte sieht nur seinen persönlichen Bereich. -- [ ] Kein sichtbarer Link führt zu einer anschließenden Berechtigungsfehlermeldung. +- [x] Ein Nutzer ohne Verwaltungsrechte sieht nur seinen persönlichen Bereich. +- [x] Kein sichtbarer Link führt zu einer anschließenden Berechtigungsfehlermeldung. ### Phase 2: Eigene Daten und Rückmeldungen -- [ ] Ansicht „Mein Profil“ für freigegebene Stammdaten bereitstellen. -- [ ] Änderungsanfrage für Kontaktdaten statt direkter Änderung geschützter Felder umsetzen. -- [ ] Zu- und Absagen für relevante Trainings oder Veranstaltungen ermöglichen. -- [ ] Mitgliedschafts- und SEPA-Hinweise verständlich, aber ohne sensible Zahlungsdaten anzeigen. -- [ ] Rückmeldungen für berechtigte Verantwortliche sichtbar machen. +- [x] Ansicht „Mein Profil“ für freigegebene Stammdaten bereitstellen. +- [x] Änderungsanfrage für Kontaktdaten statt direkter Änderung geschützter Felder umsetzen. +- [x] Zu- und Absagen für relevante Trainings oder Veranstaltungen ermöglichen. +- [x] Mitgliedschafts- und SEPA-Hinweise verständlich, aber ohne sensible Zahlungsdaten anzeigen. +- [x] Rückmeldungen für berechtigte Verantwortliche sichtbar machen. Abnahme: -- [ ] Kontaktdaten können sicher geändert bzw. zur Prüfung eingereicht werden. -- [ ] Zu- und Absagen sind an der jeweiligen Veranstaltung nachvollziehbar. +- [x] Kontaktdaten können sicher geändert bzw. zur Prüfung eingereicht werden. +- [x] Zu- und Absagen sind an der jeweiligen Veranstaltung nachvollziehbar. ### Phase 3: Kommunikation und Informationen @@ -77,7 +77,7 @@ Abnahme: 1. [ ] Bestehende `member`-Berechtigungen und Navigation auditieren. 2. [ ] Datenvertrag und Wireframe für das persönliche Dashboard festlegen. 3. [ ] Dashboard mit Terminen und Nachrichten implementieren. -4. [ ] Profil- und Kontaktdaten-Änderungsanfrage implementieren. -5. [ ] Trainings- und Veranstaltungsrückmeldungen ergänzen. +4. [x] Profil- und Kontaktdaten-Änderungsanfrage implementieren. +5. [x] Trainings- und Veranstaltungsrückmeldungen ergänzen. 6. [ ] Nachrichteneingang und Benachrichtigungsstatus ergänzen. 7. [ ] Rollen- und Mobile-Smoke-Test mit Pilotgruppe durchführen. diff --git a/frontend/src/router.js b/frontend/src/router.js index 0b810007..8beb6716 100755 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -34,6 +34,7 @@ const LogsView = () => import('./views/LogsView.vue'); const ClickTtView = () => import('./views/ClickTtView.vue'); const MemberTransferSettingsView = () => import('./views/MemberTransferSettingsView.vue'); const PersonalSettings = () => import('./views/PersonalSettings.vue'); +const MemberHomeView = () => import('./views/MemberHomeView.vue'); const OrdersView = () => import('./views/OrdersView.vue'); const BillingView = () => import('./views/BillingView.vue'); const ClubRequestsView = () => import('./views/ClubRequestsView.vue'); @@ -192,6 +193,7 @@ const routes = [ { path: '/clicktt', name: 'clicktt', component: ClickTtView, meta: withMeta({ products: fullAppProducts }) }, { path: '/member-transfer-settings', name: 'member-transfer-settings', component: MemberTransferSettingsView, meta: withMeta({ products: fullAppProducts }) }, { path: '/personal-settings', name: 'personal-settings', component: PersonalSettings, meta: withMeta({ products: allProducts }) }, + { path: '/my-club', name: 'member-home', component: MemberHomeView, meta: withMeta({ products: clubOnly }) }, { path: '/orders', name: 'orders', component: OrdersView, meta: withMeta({ products: allProducts }) }, { path: '/billing', name: 'billing', component: BillingView, meta: withMeta({ products: trainerOnly }) }, { path: '/club-requests', name: 'club-requests', component: ClubRequestsView, meta: withMeta({ products: clubOnly, permission: ['requests', 'read'] }) }, diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue index fdf742f5..41fb82d3 100755 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -72,7 +72,7 @@ export default { }; }, computed: { - ...mapGetters(['defaultHomeRoute']), + ...mapGetters(['defaultHomeRoute', 'appProduct', 'userRole']), }, methods: { // Dialog Helper Methods @@ -101,14 +101,19 @@ export default { this.confirmDialog.isOpen = false; }, - ...mapActions(['login']), + ...mapActions(['login', 'setCurrentClub']), async executeLogin() { try { const response = await apiClient.post('/auth/login', { email: this.email, password: this.password }, { timeout: 5000, }); await this.login({ token: response.data.token, username: this.email }); - const redirectTarget = typeof this.$route.query.redirect === 'string' ? this.$route.query.redirect : this.defaultHomeRoute; + const explicitRedirect = typeof this.$route.query.redirect === 'string' ? this.$route.query.redirect : ''; + let redirectTarget = explicitRedirect || this.defaultHomeRoute; + if (!explicitRedirect && this.appProduct === 'club' && this.$store.state.clubs.length === 1) { + await this.setCurrentClub(this.$store.state.clubs[0].id); + if (this.userRole === 'member') redirectTarget = '/my-club'; + } this.$router.push(redirectTarget); } catch (error) { const message = safeErrorMessage(error, this.$t('auth.loginFailed')); diff --git a/frontend/src/views/MemberHomeView.vue b/frontend/src/views/MemberHomeView.vue new file mode 100644 index 00000000..b207b87a --- /dev/null +++ b/frontend/src/views/MemberHomeView.vue @@ -0,0 +1,67 @@ + + + + +