diff --git a/backend/controllers/memberController.js b/backend/controllers/memberController.js index 24240f67..20454997 100755 --- a/backend/controllers/memberController.js +++ b/backend/controllers/memberController.js @@ -9,6 +9,7 @@ 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 ClubCommunicationMessage from '../models/ClubCommunicationMessage.js'; import ClubSepaMandate from '../models/ClubSepaMandate.js'; import MemberProfileChangeRequest from '../models/MemberProfileChangeRequest.js'; import MemberEventResponse from '../models/MemberEventResponse.js'; @@ -534,6 +535,90 @@ const reviewProfileChangeRequest = async (req, res) => { } }; +const getMemberInbox = 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 recipients = await ClubCommunicationRecipient.findAll({ + where: { clubId, memberId: access.member.id, deliveryStatus: 'sent' }, + include: [{ + model: ClubCommunicationThread, + as: 'thread', + required: true, + where: { status: 'sent' }, + attributes: ['id', 'subject', 'threadType', 'replyTo', 'sentAt'], + include: [{ model: ClubCommunicationMessage, as: 'messages', required: false, attributes: ['id', 'body', 'direction', 'messageType', 'createdByUserId', 'createdAt'] }], + }], + order: [['deliveredAt', 'DESC']], + }); + const items = recipients.map((recipient) => { + const thread = recipient.thread; + const messages = (thread?.messages || []) + .filter((message) => message.messageType === 'message' && (message.direction === 'outbound' || (message.direction === 'inbound' && Number(message.createdByUserId) === userId))) + .sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt)) + .map((message) => ({ id: message.id, body: message.body, direction: message.direction, createdAt: message.createdAt })); + const latestOutbound = [...messages].reverse().find((message) => message.direction === 'outbound'); + return { + recipientId: recipient.id, + threadId: thread.id, + subject: thread.subject, + threadType: thread.threadType, + sentAt: recipient.deliveredAt || thread.sentAt, + readAt: recipient.readAt, + canAskQuestion: Boolean(thread.replyTo), + preview: latestOutbound?.body || '', + messages, + }; + }); + res.json({ items, unreadCount: items.filter((item) => !item.readAt).length }); + } catch (error) { + console.error('[getMemberInbox] - Error:', error); + res.status(500).json({ error: 'Nachrichten konnten nicht geladen werden.' }); + } +}; + +const markInboxItemRead = async (req, res) => { + try { + const clubId = Number(req.params.clubId); + const recipientId = Number(req.params.recipientId); + const access = await getApprovedLinkedMember(clubId, Number(req.user?.id)); + if (access.error) return res.status(access.status).json({ error: access.error }); + const recipient = await ClubCommunicationRecipient.findOne({ where: { id: recipientId, clubId, memberId: access.member.id, deliveryStatus: 'sent' } }); + if (!recipient) return res.status(404).json({ error: 'Nachricht nicht gefunden.' }); + if (!recipient.readAt) await recipient.update({ readAt: new Date() }); + res.json({ recipientId, readAt: recipient.readAt }); + } catch (error) { + console.error('[markInboxItemRead] - Error:', error); + res.status(500).json({ error: 'Lesestatus konnte nicht gespeichert werden.' }); + } +}; + +const askInboxQuestion = async (req, res) => { + try { + const clubId = Number(req.params.clubId); + const recipientId = Number(req.params.recipientId); + const userId = Number(req.user?.id); + const body = String(req.body?.body || '').trim(); + if (!body) return res.status(400).json({ error: 'Bitte formuliere deine Rückfrage.' }); + if (body.length > 4000) return res.status(400).json({ error: 'Die Rückfrage ist zu lang.' }); + const access = await getApprovedLinkedMember(clubId, userId); + if (access.error) return res.status(access.status).json({ error: access.error }); + const recipient = await ClubCommunicationRecipient.findOne({ + where: { id: recipientId, clubId, memberId: access.member.id, deliveryStatus: 'sent' }, + include: [{ model: ClubCommunicationThread, as: 'thread', required: true, attributes: ['id', 'status', 'replyTo'] }], + }); + if (!recipient?.thread || recipient.thread.status !== 'sent' || !recipient.thread.replyTo) return res.status(409).json({ error: 'Für diese Nachricht ist keine Rückfrage vorgesehen.' }); + const message = await ClubCommunicationMessage.create({ threadId: recipient.thread.id, clubId, body, direction: 'inbound', messageType: 'message', createdByUserId: userId }); + if (!recipient.readAt) await recipient.update({ readAt: new Date() }); + res.status(201).json({ id: message.id, body: message.body, direction: message.direction, createdAt: message.createdAt }); + } catch (error) { + console.error('[askInboxQuestion] - Error:', error); + res.status(500).json({ error: 'Rückfrage konnte nicht gesendet werden.' }); + } +}; + export { getClubMembers, getWaitingApprovals, @@ -563,5 +648,8 @@ export { updateOwnTrainingAttendance, getEventResponses, getProfileChangeRequests, - reviewProfileChangeRequest + reviewProfileChangeRequest, + getMemberInbox, + markInboxItemRead, + askInboxQuestion }; diff --git a/backend/migrations/20260724_add_member_inbox_read_status.sql b/backend/migrations/20260724_add_member_inbox_read_status.sql new file mode 100644 index 00000000..f1d0ea8e --- /dev/null +++ b/backend/migrations/20260724_add_member_inbox_read_status.sql @@ -0,0 +1,3 @@ +ALTER TABLE club_communication_recipients + ADD COLUMN read_at DATETIME NULL AFTER delivered_at, + ADD INDEX club_communication_recipients_member_read (club_id, member_id, read_at); diff --git a/backend/models/ClubCommunicationRecipient.js b/backend/models/ClubCommunicationRecipient.js index 355d3cb1..5560b596 100755 --- a/backend/models/ClubCommunicationRecipient.js +++ b/backend/models/ClubCommunicationRecipient.js @@ -44,6 +44,11 @@ const ClubCommunicationRecipient = sequelize.define('ClubCommunicationRecipient' allowNull: true, field: 'delivered_at', }, + readAt: { + type: DataTypes.DATE, + allowNull: true, + field: 'read_at', + }, lastAttemptAt: { type: DataTypes.DATE, allowNull: true, diff --git a/backend/routes/memberRoutes.js b/backend/routes/memberRoutes.js index 9e170cb8..2017e179 100755 --- a/backend/routes/memberRoutes.js +++ b/backend/routes/memberRoutes.js @@ -27,7 +27,10 @@ import { updateOwnTrainingAttendance, getEventResponses, getProfileChangeRequests, - reviewProfileChangeRequest + reviewProfileChangeRequest, + getMemberInbox, + markInboxItemRead, + askInboxQuestion } from '../controllers/memberController.js'; import express from 'express'; import { authenticate } from '../middleware/authMiddleware.js'; @@ -55,6 +58,9 @@ router.put('/dashboard/:clubId/training/:diaryDateId/attendance', authenticate, 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('/inbox/:clubId', authenticate, getMemberInbox); +router.patch('/inbox/:clubId/:recipientId/read', authenticate, markInboxItemRead); +router.post('/inbox/:clubId/:recipientId/questions', authenticate, askInboxQuestion); 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/services/clubCommunicationService.js b/backend/services/clubCommunicationService.js index ab86b014..74391e70 100755 --- a/backend/services/clubCommunicationService.js +++ b/backend/services/clubCommunicationService.js @@ -648,6 +648,7 @@ class ClubCommunicationService { await recipient.update({ deliveryStatus: 'sent', deliveredAt: now, + readAt: null, lastAttemptAt: now, attemptCount: nextAttemptNo, retryable: false, diff --git a/docs/manual_sql_migrations.md b/docs/manual_sql_migrations.md index 38af368c..88b80dc9 100755 --- a/docs/manual_sql_migrations.md +++ b/docs/manual_sql_migrations.md @@ -84,6 +84,20 @@ 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-07-24 + +### `club_communication_recipients.read_at` + +Persönlicher Gelesen-Status für Nachrichten. Er wird ausschließlich für das +jeweilige Empfänger-Mitglied erfasst; die Kommunikationsverwaltung kann daraus +keine Kontakt- oder Zahlungsdaten ableiten. + +```sql +ALTER TABLE club_communication_recipients + ADD COLUMN read_at DATETIME NULL AFTER delivered_at, + ADD INDEX club_communication_recipients_member_read (club_id, member_id, read_at); +``` + ## 2026-03-17 ### `predefined_activities.exclude_from_stats` diff --git a/docs/member-pilot-checklist.md b/docs/member-pilot-checklist.md new file mode 100644 index 00000000..af9143ef --- /dev/null +++ b/docs/member-pilot-checklist.md @@ -0,0 +1,46 @@ +# Pilot-Checkliste: persönlicher Mitgliederbereich + +## Ziel und Rahmen + +Mit 3–5 echten Mitgliedern auf unterschiedlichen Geräten prüfen. Keine +Produktivdaten, Bankdaten oder Screenshots von Nachrichten an unbeteiligte +Personen weitergeben. + +## Rollen- und Rechte-Smoke-Test + +| Rolle | Erwartetes Ergebnis | +| --- | --- | +| Mitglied | Kann ausschließlich `/my-club` und eigene Daten, Termine, Nachrichten sowie Rückmeldungen nutzen. Kein Zugriff auf Finanzen, Archiv, Rollen, Mitgliederlisten oder fremde Nachrichten. | +| Trainer | Sieht die erlaubten Trainings-/Terminwerkzeuge und Rückmeldungen entsprechend den Rollenrechten; kein Zugriff auf Finanz- und Rollenverwaltung ohne zusätzliche Rechte. | +| Vorstand | Kann die Verwaltungsansichten, Profiländerungsanfragen und Kommunikationsvorgänge entsprechend seiner Rechte nutzen. | + +Pro Rolle testen: + +1. Login, Verein auswählen und Direktaufruf eines fremden/geschützten Pfads. +2. Eigenes Profil, Termin-Zusage/Absage und Nachrichteneingang öffnen. +3. Für ein Mitglied prüfen, dass die API keine fremde Nachricht oder Rückfrage + zurückgibt (abweichende `recipientId` ausprobieren). + +## Mobile Hauptabläufe + +Auf mindestens einem Gerät mit 360–430 px Breite testen: + +1. Erster Login: Onboarding verstehen und schließen. +2. Profiländerung einreichen. +3. Termin zu- oder absagen. +4. Nachricht öffnen, als gelesen markieren und Rückfrage senden. +5. Seite bei gedrosselter Verbindung neu laden: Lade-, Fehler- und + Wiederholen-Zustände müssen verständlich sein. + +## Feedbackbogen + +Für jede Person erfassen: + +- Gerät/Browser und Verbindungsart. +- Konnte die Person Profil, Terminrückmeldung und Nachricht ohne Hilfe finden? +- Unklare Begriffe oder fehlende Informationen. +- Fehlermeldung, Zeitpunkt und reproduzierbarer Ablauf. +- Freigabe für breiteren Rollout: ja / nein / mit Nachbesserung. + +Nach dem Pilot die Ergebnisse im Vereinsvorgang dokumentieren und nur dann die +letzte Phase-4-Checkbox im [Plan](./simple-user-plan.md) abhaken. diff --git a/docs/simple-user-plan.md b/docs/simple-user-plan.md index 7720b966..2f88b90c 100644 --- a/docs/simple-user-plan.md +++ b/docs/simple-user-plan.md @@ -44,23 +44,30 @@ Abnahme: ### Phase 3: Kommunikation und Informationen -- [ ] Persönlichen Nachrichteneingang bereitstellen. -- [ ] Vereinsankündigungen nach Datum sortiert anzeigen. -- [ ] Gelesen-/ungelesen-Status und einen klaren Rückfrageweg ergänzen. -- [ ] Datenschutz- und Sichtbarkeitsregeln für Nachrichten und Kontaktinformationen prüfen. +- [x] Persönlichen Nachrichteneingang bereitstellen. +- [x] Vereinsankündigungen nach Datum sortiert anzeigen. +- [x] Gelesen-/ungelesen-Status und einen klaren Rückfrageweg ergänzen. +- [x] Datenschutz- und Sichtbarkeitsregeln für Nachrichten und Kontaktinformationen prüfen. Abnahme: -- [ ] Neue Vereinsinformationen sind ohne Verwaltungsmenüs auffindbar. -- [ ] Persönliche Nachrichten sind ausschließlich für den Empfänger sichtbar. +- [x] Neue Vereinsinformationen sind ohne Verwaltungsmenüs auffindbar. +- [x] Persönliche Nachrichten sind ausschließlich für den Empfänger sichtbar. ### Phase 4: Mobil, Qualität und Einführung - [ ] Mobilansicht und langsame Verbindungen prüfen. - [ ] Berechtigungstest mit Mitglied, Trainer und Vorstand durchführen. -- [ ] Onboarding für ersten Login, Profilprüfung und Rückmeldungen erstellen. +- [x] Onboarding für ersten Login, Profilprüfung und Rückmeldungen erstellen. - [ ] Pilot mit wenigen echten Mitgliedern durchführen und Feedback auswerten. +Vorbereitet: + +- Die persönliche Startseite stapelt ihre Inhalte auf kleinen Bildschirmen; + Lade-, Fehler- und Wiederholen-Zustände sind vorhanden. +- Eine konkrete [Pilot- und Rollen-Checkliste](./member-pilot-checklist.md) + dokumentiert die noch mit echten Konten und Geräten auszuführenden Prüfungen. + Abnahme: - [ ] Die Testrollen sehen jeweils nur die vorgesehenen Daten und Aktionen. @@ -74,10 +81,10 @@ Abnahme: ## Erste umsetzbare Tickets -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. +1. [x] Bestehende `member`-Berechtigungen und Navigation auditieren. +2. [x] Datenvertrag und Wireframe für das persönliche Dashboard festlegen. +3. [x] Dashboard mit Terminen und Nachrichten implementieren. 4. [x] Profil- und Kontaktdaten-Änderungsanfrage implementieren. 5. [x] Trainings- und Veranstaltungsrückmeldungen ergänzen. -6. [ ] Nachrichteneingang und Benachrichtigungsstatus ergänzen. +6. [x] Nachrichteneingang und Benachrichtigungsstatus ergänzen. 7. [ ] Rollen- und Mobile-Smoke-Test mit Pilotgruppe durchführen. diff --git a/frontend/src/views/MemberHomeView.vue b/frontend/src/views/MemberHomeView.vue index b207b87a..50eef46d 100644 --- a/frontend/src/views/MemberHomeView.vue +++ b/frontend/src/views/MemberHomeView.vue @@ -6,8 +6,26 @@

{{ profile ? 'Hier findest du deine nächsten Vereinstermine.' : 'Dein Konto ist noch keinem Mitgliedsprofil zugeordnet.' }}

Persönliche Einstellungen +
+
+

Erste Schritte

+

Gut angekommen?

+

Mit diesen drei Dingen bist du im Vereinsbereich schnell auf dem Laufenden.

+
+
    +
  1. Profil prüfenKontaktdaten aktuell halten
  2. +
  3. Termine zu- oder absagenDamit der Verein planen kann
  4. +
  5. Nachrichten lesenWichtige Infos nicht verpassen
  6. +
+ +

{{ error }}

Mitgliedsprofil verbinden

Bitte wende dich an die Vereinsverwaltung, damit dein Benutzerkonto mit deinem Mitgliedsprofil verknüpft wird.

+
+

Meine Nachrichten {{ unreadCount }}

Persönliche Nachrichten und Vereinsankündigungen.

+

{{ inboxError }}

Nachrichten werden geladen…

Du hast aktuell keine Vereinsnachrichten.

+
{{ selectedInboxItem.threadType === 'broadcast' ? 'Vereinsankündigung' : 'Nachricht' }} · {{ formatDate(selectedInboxItem.sentAt) }}

{{ selectedInboxItem.subject }}

{{ message.body }}

{{ message.direction === 'inbound' ? 'Deine Rückfrage' : 'Vereinsnachricht' }} · {{ formatDate(message.createdAt) }}

{{ questionError }}

Für diese Nachricht ist keine direkte Rückfrage vorgesehen.

+

Nächste Termine

Kalender öffnen

Deine Termine werden geladen…

@@ -37,15 +55,16 @@