Design for a team-scoped notification center (bell icon, dropdown, full history page) covering player/role/share-link/invite-link events, decoupled via @nestjs/event-emitter from a central notifications module.
13 KiB
Notification Center (Team-Benachrichtigungen)
Status: approved Datum: 2026-08-04
Kontext
TeamWallet protokolliert bereits viele team-relevante Ereignisse (Spieler hinzugefügt/deaktiviert,
Rollenänderung, Einladungslink erstellt/eingelöst) über den globalen LoggingService in LogEntry
— aber dieses Log ist admin-only, global (kein Team-Bezug, kein teamId), und kennt keinen
Lesestatus pro Nutzer. Ein normaler Spieler erfährt aktuell nicht, wenn in seinem Team etwas
passiert (z.B. er selbst deaktiviert wurde oder der Freigabelink rotiert wurde), außer er merkt es
zufällig.
Ziel: Ein Benachrichtigungscenter (Glocke oben rechts im Header mit Ungelesen-Badge und Dropdown), das aktiven Team-Mitgliedern mit Login relevante Team-Ereignisse anzeigt, mit Sprung zur betroffenen Stelle und einer Vollansicht-Seite für die Historie.
Entscheidungen aus dem Brainstorming
- Abgedeckte Events (v1): Spieler hinzugefügt/deaktiviert/reaktiviert, Team-Rolle geändert, Freigabelink aktiviert/rotiert, Einladungslink erstellt. Das Einlösen eines Einladungslinks selbst löst keine eigene Benachrichtigung aus (der Aufruf ist unauthentifiziert, reine Token-Validierung, oft nur eine Vorschau ohne tatsächlichen Beitritt) — der tatsächliche Beitritt wird stattdessen bereits durch das Event "Spieler hinzugefügt" abgedeckt.
- Empfänger: alle aktiven Player eines Teams mit verknüpftem User-Account (analog zur
Mitgliedschaftsprüfung in
TeamAccessService), abzüglich des Verursachers — wer eine Aktion selbst auslöst, bekommt dafür keine eigene Benachrichtigung. - Zustellung: kein Echtzeit-Push (keine WebSocket/SSE-Infrastruktur im Projekt vorhanden). Stattdessen Polling des Ungelesen-Zählers alle 30s, passend zum bestehenden HTTP+Signal-Store-Muster des Frontends.
- Datenmodell: Fan-out beim Schreiben (
Notification+ eineNotificationRecipient-Zeile pro Empfänger mit eigenem Lesestatus) statt eines zentralen Events mit Read-Join-Tabelle oder einer Erweiterung vonLogEntry— bei den hier üblichen kleinen Teamgrößen (typischerweise < 30 Spieler) ist der Schreib-Overhead irrelevant, die Leseabfragen (Ungelesen zählen, Liste je Nutzer, als gelesen markieren) bleiben dafür trivial. - Entkopplung: Domain-Services lösen Business-Logik weiterhin unverändert aus und feuern danach
nur ein Domain-Event über
@nestjs/event-emitter(EventEmitter2) — ein zentralesNotificationsModulelauscht auf diese Events und legt die Benachrichtigungen an. Domain-Services kennenNotificationsServicenicht; neue Benachrichtigungstypen erfordern nur einen neuen Listener, keine Änderung an bestehenden Services. - Klick-Verhalten: Klick auf eine Benachrichtigung navigiert zur betroffenen Stelle (z.B. Mitgliederliste) und markiert sie als gelesen.
- Vollansicht: eigene, team-gescopte Seite mit paginierter Historie zusätzlich zum Dropdown (letzte 20 Einträge).
Architektur / Komponenten
1. Backend: neues Modul notifications/
Neue Entities (notifications/entities/):
Notification:id,team(ManyToOneTeam),event(NOTIFICATION_EVENT-String-Union, eigene Typdatei analoglogging-event.type.ts),actorUserId,payload(text-Spalte, JSON-serialisiert — enthält je Event die Felder für Anzeigetext + Deep-Link, z.B.{ playerId, playerName }),createdAt.NotificationRecipient:id,notification(ManyToOneNotification,onDelete: 'CASCADE'),userId,read(boolean, defaultfalse),readAt(nullableDate). Index auf(userId, read, createdAt via notification)bzw. praktisch auf(userId, notificationId)und zusätzlich ein Index aufnotification.team+userIdfür die gefilterte Team-Ansicht.
Domain-Events (notifications/events/): reine Datenklassen, ein File pro Event-Familie —
player-active-changed.event.ts, player-role-changed.event.ts, player-created.event.ts,
share-link-changed.event.ts, invite-link-created.event.ts. Jede trägt mindestens teamId,
actorUserId, event-spezifische IDs/Namen für Text und Deep-Link.
Emit-Punkte (jeweils ein zusätzlicher this.eventEmitter.emit(...)-Aufruf nach erfolgreichem
Abschluss der bestehenden Logik, ohne deren Ablauf/Transaktion zu verändern):
team-members.service.tssetActive()— nachreturn this.dataSource.transaction(...)erfolgreich resolved hat (Emit außerhalb des Transaktions-Callbacks, damit bei Rollback nie ein Event feuert).team-members.service.tssetTeamRole()— analog.teams.service.tsPlayer-Erstellung (Stelle, die aktuellplayer_creationloggt) — analog.public-team-access.service.tssetEnabled()/rotate()— hier gibt es aktuell keine Transaktion (nurrepository.save()), Emit direkt nach erfolgreichemsave(). Zusätzlich werden hier neueLOGEVENT-Wertepublic_access_enabled,public_access_rotatedergänzt (bisher fehlt an dieser Stelle jegliches Logging) und einLoggingService.info()-Aufruf ergänzt, analog zu den anderen Services.auth.service.tscreateTeamInvite()— nach dem bestehendenlogger.info(...)-Aufruf, mit dem echtenactorUserId-Parameter der Methode (nicht dem im bestehenden Log hart codiertenuserId: 0— dieser bestehende Log-Aufruf selbst bleibt unverändert, das Event nutzt aber den korrekten Actor).
NotificationsListener (notifications/notifications.listener.ts): ein @OnEvent(...)-Handler
pro Event-Typ, baut Anzeigetext + Deep-Link-Payload und ruft NotificationsService.create(...) auf.
Fehler im Handler werden abgefangen und via LoggingService.error() protokolliert statt propagiert —
ein Fehler beim Anlegen der Benachrichtigung darf die bereits committete Business-Aktion nicht
nachträglich als fehlgeschlagen erscheinen lassen.
NotificationsService:
create(teamId, event, actorUserId, payload)— ermittelt Empfänger über dasselbe Query-Muster wieTeamAccessService/PublicTeamAccessService(aktivePlayermituser.id IS NOT NULLfür das Team,actorUserIdausgeschlossen), legtNotification+NotificationRecipient-Zeilen an.listForUser(userId, teamId, cursor, limit)— für Dropdown und Vollansicht.getUnreadCount(userId, teamId).markRead(recipientId, userId)— prüft Eigentümerschaft der Recipient-Zeile.markAllRead(userId, teamId).
NotificationsController (version: '1', AuthGuard('jwt') + TeamAccessService.assertMember):
GET teams/:teamId/notifications?cursor=&limit=GET teams/:teamId/notifications/unread-countPATCH teams/:teamId/notifications/:id/readPATCH teams/:teamId/notifications/read-all
Retention: NotificationRetentionScheduler, @Cron(CronExpression.EVERY_DAY_AT_5AM) (zeitlich
versetzt zu LogRetentionScheduler um 4 Uhr), löscht Notification-Zeilen älter als
app.logRetentionDays (gleiche Config wiederverwendet, kein neuer Config-Wert nötig) —
NotificationRecipient fällt per onDelete: 'CASCADE' automatisch mit weg. Gleiches
Fehlerbehandlung-Muster wie LogRetentionScheduler (try/catch, logger.info/logger.error mit
log_retention_cleanup_run-artigen neuen Events notification_retention_cleanup_run/_fail).
Neue Dependency: @nestjs/event-emitter, registriert via EventEmitterModule.forRoot() in
app.module.ts (neben dem bestehenden ScheduleModule.forRoot()).
Registrierung: NotificationsModule in src/app.module.ts ergänzen (analog
CashboxExportModule), exportiert NotificationsService/EventEmitter2-Nutzung für die
Domain-Services (bzw. Domain-Services importieren direkt EventEmitterModule/EventEmitter2 aus
@nestjs/event-emitter, kein Import von NotificationsModule nötig — das ist der Kern der
Entkopplung).
Migration: eine neue TypeORM-Migration in src/database/migrations für notification und
notification_recipient inkl. der oben genannten Indizes.
Neue LOGEVENT-Werte in logging-event.type.ts: public_access_enabled,
public_access_rotated, notification_retention_cleanup_run, notification_retention_cleanup_run_fail.
2. Frontend
Bell im Header (core/layout/shell/shell.html/shell.ts): mat-icon-button mit
notifications-Icon, matBadge für den Ungelesen-Zähler (ausgeblendet bei 0), positioniert links
neben dem bestehenden Team-Switcher in der shell-header-Toolbar, [matMenuTriggerFor]="notificationMenu"
— gleiches MatMenuModule-Pattern wie der bestehende Team-Switcher.
Dropdown (mat-menu): Liste der letzten 20 Benachrichtigungen (Icon je Event-Typ, Text, relative
Zeit via Angular DatePipe/eigenes Pipe), "Alle als gelesen markieren"-Button oben, "Alle
anzeigen"-Link unten zur Vollansicht-Seite. Klick auf einen Eintrag: markRead() + Router-Navigation
zum Deep-Link (z.B. /team/:teamId/members mit Query-Param oder Fragment zum Hervorheben des
betroffenen Spielers, je nach Event-Typ auch andere Zielrouten wie die Team-Einstellungen für
Freigabelink-Events).
Vollansicht-Seite (features/notifications/notifications.ts/html, Route
/team/:teamId/notifications): einfache paginierte Liste (kein ag-grid nötig, da kein
Admin-Filterbedarf wie bei der Logs-Seite), gleiche Klick-Navigation wie im Dropdown.
State: neuer NotificationsStore (Signal-Service im Team-Kontext, analog MyTeamsStore) hält
notifications- und unreadCount-Signals. Pollt unread-count alle 30s via interval() +
switchMap, solange ein Team aktiv ist; die volle Liste wird nur bei Dropdown-Öffnen bzw.
Seitenaufruf der Vollansicht geladen (kein Dauer-Polling der ganzen Liste).
Neues Model (models/notification.model.ts): NotificationEvent-Union (Frontend-seitiges
Gegenstück zu NOTIFICATION_EVENT), NotificationDto, mit Mapping-Funktion Event-Typ → Icon/Text/
Zielroute (zentral an einer Stelle, damit neue Event-Typen nicht über die Komponente verstreut
behandelt werden müssen).
Fehlerbehandlung
- Notification-Erstellung schlägt fehl → wird im
NotificationsListenerabgefangen und geloggt, bricht die ursprüngliche (bereits erfolgreich abgeschlossene) Aktion nicht nachträglich ab. markRead/markAllReadauf fremde bzw. nicht existente Recipient-Zeile →NotFoundExceptionbzw. stiller No-Op beimarkAllRead(nichts zu markieren ist kein Fehlerfall).- Polling-Request schlägt fehl (Netzwerk) → Store behält den letzten bekannten Zählerstand, kein Fehler-Toast (nicht kritisch genug für eine Nutzerunterbrechung).
Testing
Backend:
notifications.service.spec.ts— Empfänger-Ermittlung (aktive Player mit User, Actor ausgeschlossen), Fan-out-Erstellung,listForUser/getUnreadCount-Filterung nachteamId+userId,markRead-Eigentümerprüfung,markAllRead.notifications.listener.spec.ts— pro Event-Typ: korrekter Aufruf vonNotificationsService.createmit erwartetem Payload; Fehler im Service wird abgefangen und geloggt, nicht weitergeworfen.- Bestehende Specs von
team-members.service.ts,public-team-access.service.ts,auth.service.tsum Assertions ergänzt, dass das jeweilige Domain-Event nach erfolgreichem Abschluss emittiert wird (gemockterEventEmitter2), und bei Rollback/Fehler nicht emittiert wird. notification-retention.scheduler.spec.ts— analoglog-retention.scheduler.spec.ts.notifications.http.spec.ts— Auth/Team-Membership erforderlich, Pagination,read/read-all.
Frontend:
notifications-store.spec.ts— Polling-Intervall, Unread-Count-Update, Laden der Liste.notifications-api.spec.ts— korrekte HTTP-Calls.- Bell/Dropdown-Komponenten-Spec — Badge-Anzeige bei >0, Klick markiert gelesen + navigiert, "Alle als gelesen"-Button.
- Vollansicht-Seiten-Spec — Pagination, Klick-Navigation.
Bewusst nicht enthalten (YAGNI)
- Kein Echtzeit-Push (WebSocket/SSE) — Polling reicht für den Anwendungsfall und vermeidet neue Infrastruktur.
- Keine Benachrichtigung beim reinen Einlösen/Validieren eines Einladungslinks (unauthentifiziert, kein verlässlicher Actor, oft nur Vorschau ohne Beitritt).
- Keine Benachrichtigungseinstellungen pro Nutzer (z.B. E-Mail-Digest, Stummschalten einzelner Event-Typen) — alle aktiven Mitglieder mit Login sehen alle abgedeckten Events.
- Keine rollenbasierte Einschränkung der Empfänger (z.B. "nur Manager") — alle aktiven Mitglieder mit Login.
- Keine Browser-Push-Benachrichtigungen (Service Worker/Web Push) außerhalb der App.
Verifikation
- Backend-Unit-Tests: siehe oben, alle grün,
nest buildsauber. - Frontend-Unit-Tests: siehe oben, alle grün,
tsc --noEmit+ng buildsauber. - Manuell: Backend + Frontend lokal starten, mit zwei Test-Usern im selben Team: User A deaktiviert einen Spieler, User B (nicht der deaktivierte Spieler selbst, aber Mitglied) sieht die Badge-Zahl nach kurzer Zeit (Polling) hochgehen, öffnet das Dropdown, sieht den Eintrag, klickt darauf → Navigation zur Mitgliederliste + Eintrag als gelesen markiert, Badge sinkt. Gleiches stichprobenartig für Rollenänderung, Freigabelink-Rotation und Einladungslink-Erstellung durchspielen. Vollansicht-Seite aufrufen und Pagination über mehrere erzeugte Einträge prüfen.