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.
This commit is contained in:
210
docs/superpowers/specs/2026-08-04-notification-center-design.md
Normal file
210
docs/superpowers/specs/2026-08-04-notification-center-design.md
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
# 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.
|
||||||
Reference in New Issue
Block a user