Files
teamwallet/docs/superpowers/specs/2026-08-04-notification-center-design.md
Bastian Wagner 1fe2892ca4 docs: add notification center design spec
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.
2026-08-04 17:10:46 +02:00

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 + eine NotificationRecipient-Zeile pro Empfänger mit eigenem Lesestatus) statt eines zentralen Events mit Read-Join-Tabelle oder einer Erweiterung von LogEntry — 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 zentrales NotificationsModule lauscht auf diese Events und legt die Benachrichtigungen an. Domain-Services kennen NotificationsService nicht; 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 (ManyToOne Team), event (NOTIFICATION_EVENT-String-Union, eigene Typdatei analog logging-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 (ManyToOne Notification, onDelete: 'CASCADE'), userId, read (boolean, default false), readAt (nullable Date). Index auf (userId, read, createdAt via notification) bzw. praktisch auf (userId, notificationId) und zusätzlich ein Index auf notification.team + userId fü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.ts setActive() — nach return this.dataSource.transaction(...) erfolgreich resolved hat (Emit außerhalb des Transaktions-Callbacks, damit bei Rollback nie ein Event feuert).
  • team-members.service.ts setTeamRole() — analog.
  • teams.service.ts Player-Erstellung (Stelle, die aktuell player_creation loggt) — analog.
  • public-team-access.service.ts setEnabled() / rotate() — hier gibt es aktuell keine Transaktion (nur repository.save()), Emit direkt nach erfolgreichem save(). Zusätzlich werden hier neue LOGEVENT-Werte public_access_enabled, public_access_rotated ergänzt (bisher fehlt an dieser Stelle jegliches Logging) und ein LoggingService.info()-Aufruf ergänzt, analog zu den anderen Services.
  • auth.service.ts createTeamInvite() — nach dem bestehenden logger.info(...)-Aufruf, mit dem echten actorUserId-Parameter der Methode (nicht dem im bestehenden Log hart codierten userId: 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 wie TeamAccessService/PublicTeamAccessService (aktive Player mit user.id IS NOT NULL für das Team, actorUserId ausgeschlossen), legt Notification + 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-count
  • PATCH teams/:teamId/notifications/:id/read
  • PATCH 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 NotificationsListener abgefangen und geloggt, bricht die ursprüngliche (bereits erfolgreich abgeschlossene) Aktion nicht nachträglich ab.
  • markRead/markAllRead auf fremde bzw. nicht existente Recipient-Zeile → NotFoundException bzw. stiller No-Op bei markAllRead (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 nach teamId+userId, markRead-Eigentümerprüfung, markAllRead.
  • notifications.listener.spec.ts — pro Event-Typ: korrekter Aufruf von NotificationsService.create mit 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.ts um Assertions ergänzt, dass das jeweilige Domain-Event nach erfolgreichem Abschluss emittiert wird (gemockter EventEmitter2), und bei Rollback/Fehler nicht emittiert wird.
  • notification-retention.scheduler.spec.ts — analog log-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 build sauber.
  • Frontend-Unit-Tests: siehe oben, alle grün, tsc --noEmit + ng build sauber.
  • 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.