# 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.