From 6bda24ec9f74ae4b367789ba2992e2e61e33b3ac Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Tue, 4 Aug 2026 17:10:46 +0200 Subject: [PATCH] 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-notification-center-design.md | 210 ++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-04-notification-center-design.md diff --git a/docs/superpowers/specs/2026-08-04-notification-center-design.md b/docs/superpowers/specs/2026-08-04-notification-center-design.md new file mode 100644 index 0000000..d428920 --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-notification-center-design.md @@ -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.