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

211 lines
13 KiB
Markdown

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