Compare commits
22 Commits
020b390953
...
7bfb3d07fc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bfb3d07fc | ||
|
|
35e6c055c0 | ||
|
|
fe523bdce1 | ||
|
|
b40af02e2f | ||
|
|
8ace676abf | ||
|
|
6b3a9d69cc | ||
|
|
a7b087050c | ||
|
|
eb1173c706 | ||
|
|
812061fc6c | ||
|
|
017e6445fa | ||
|
|
d6733eff0d | ||
|
|
639ca651d8 | ||
|
|
8de4c11e24 | ||
|
|
451b5c4e42 | ||
|
|
05f4c2ddf0 | ||
|
|
273c25eccb | ||
|
|
97b0a5c19a | ||
|
|
7410672630 | ||
|
|
ecfa847d2a | ||
|
|
6bda24ec9f | ||
|
|
df634e7601 | ||
|
|
1fe2892ca4 |
3287
docs/superpowers/plans/2026-08-04-notification-center.md
Normal file
3287
docs/superpowers/plans/2026-08-04-notification-center.md
Normal file
File diff suppressed because it is too large
Load Diff
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.
|
||||||
33
myteamwallet_backend/package-lock.json
generated
33
myteamwallet_backend/package-lock.json
generated
@@ -14,6 +14,7 @@
|
|||||||
"@nestjs/common": "9.1.6",
|
"@nestjs/common": "9.1.6",
|
||||||
"@nestjs/config": "2.2.0",
|
"@nestjs/config": "2.2.0",
|
||||||
"@nestjs/core": "9.1.6",
|
"@nestjs/core": "9.1.6",
|
||||||
|
"@nestjs/event-emitter": "^2.1.1",
|
||||||
"@nestjs/jwt": "9.0.0",
|
"@nestjs/jwt": "9.0.0",
|
||||||
"@nestjs/passport": "9.0.0",
|
"@nestjs/passport": "9.0.0",
|
||||||
"@nestjs/platform-express": "9.1.6",
|
"@nestjs/platform-express": "9.1.6",
|
||||||
@@ -3304,6 +3305,19 @@
|
|||||||
"uuid": "dist/bin/uuid"
|
"uuid": "dist/bin/uuid"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@nestjs/event-emitter": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@nestjs/event-emitter/-/event-emitter-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-6L6fBOZTyfFlL7Ih/JDdqlCzZeCW0RjCX28wnzGyg/ncv5F/EOeT1dfopQr1loBRQ3LTgu8OWM7n4zLN4xigsg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"eventemitter2": "6.4.9"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0",
|
||||||
|
"@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@nestjs/jwt": {
|
"node_modules/@nestjs/jwt": {
|
||||||
"version": "9.0.0",
|
"version": "9.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-9.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-9.0.0.tgz",
|
||||||
@@ -7379,6 +7393,12 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eventemitter2": {
|
||||||
|
"version": "6.4.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz",
|
||||||
|
"integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/events": {
|
"node_modules/events": {
|
||||||
"version": "3.3.0",
|
"version": "3.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||||
@@ -20166,6 +20186,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"@nestjs/event-emitter": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@nestjs/event-emitter/-/event-emitter-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-6L6fBOZTyfFlL7Ih/JDdqlCzZeCW0RjCX28wnzGyg/ncv5F/EOeT1dfopQr1loBRQ3LTgu8OWM7n4zLN4xigsg==",
|
||||||
|
"requires": {
|
||||||
|
"eventemitter2": "6.4.9"
|
||||||
|
}
|
||||||
|
},
|
||||||
"@nestjs/jwt": {
|
"@nestjs/jwt": {
|
||||||
"version": "9.0.0",
|
"version": "9.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-9.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-9.0.0.tgz",
|
||||||
@@ -23260,6 +23288,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||||
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="
|
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="
|
||||||
},
|
},
|
||||||
|
"eventemitter2": {
|
||||||
|
"version": "6.4.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz",
|
||||||
|
"integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg=="
|
||||||
|
},
|
||||||
"events": {
|
"events": {
|
||||||
"version": "3.3.0",
|
"version": "3.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"@nestjs/common": "9.1.6",
|
"@nestjs/common": "9.1.6",
|
||||||
"@nestjs/config": "2.2.0",
|
"@nestjs/config": "2.2.0",
|
||||||
"@nestjs/core": "9.1.6",
|
"@nestjs/core": "9.1.6",
|
||||||
|
"@nestjs/event-emitter": "^2.1.1",
|
||||||
"@nestjs/jwt": "9.0.0",
|
"@nestjs/jwt": "9.0.0",
|
||||||
"@nestjs/passport": "9.0.0",
|
"@nestjs/passport": "9.0.0",
|
||||||
"@nestjs/platform-express": "9.1.6",
|
"@nestjs/platform-express": "9.1.6",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
|
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||||
import { UsersModule } from './users/users.module';
|
import { UsersModule } from './users/users.module';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import databaseConfig from './config/database.config';
|
import databaseConfig from './config/database.config';
|
||||||
@@ -25,10 +26,12 @@ import { TranslateModule } from './translate/translate.module';
|
|||||||
import { PenaltyModule } from './penalty/penalty.module';
|
import { PenaltyModule } from './penalty/penalty.module';
|
||||||
import { RecurringTransactionsModule } from './recurring-transactions/recurring-transactions.module';
|
import { RecurringTransactionsModule } from './recurring-transactions/recurring-transactions.module';
|
||||||
import { CashboxExportModule } from './cashbox-export/cashbox-export.module';
|
import { CashboxExportModule } from './cashbox-export/cashbox-export.module';
|
||||||
|
import { NotificationsModule } from './notifications/notifications.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
|
EventEmitterModule.forRoot(),
|
||||||
ConfigModule.forRoot({
|
ConfigModule.forRoot({
|
||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
load: [databaseConfig, authConfig, appConfig, mailConfig],
|
load: [databaseConfig, authConfig, appConfig, mailConfig],
|
||||||
@@ -61,6 +64,7 @@ import { CashboxExportModule } from './cashbox-export/cashbox-export.module';
|
|||||||
PenaltyModule,
|
PenaltyModule,
|
||||||
RecurringTransactionsModule,
|
RecurringTransactionsModule,
|
||||||
CashboxExportModule,
|
CashboxExportModule,
|
||||||
|
NotificationsModule,
|
||||||
],
|
],
|
||||||
providers: [],
|
providers: [],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ describe('AuthService inactive-user enforcement and safe logging', () => {
|
|||||||
let userRepository: any;
|
let userRepository: any;
|
||||||
let service: AuthService;
|
let service: AuthService;
|
||||||
let mailService: any;
|
let mailService: any;
|
||||||
|
let eventEmitter: any;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jwtService = {
|
jwtService = {
|
||||||
@@ -44,6 +45,7 @@ describe('AuthService inactive-user enforcement and safe logging', () => {
|
|||||||
dataSource = {
|
dataSource = {
|
||||||
transaction: jest.fn((work) => work(manager)),
|
transaction: jest.fn((work) => work(manager)),
|
||||||
};
|
};
|
||||||
|
eventEmitter = { emit: jest.fn() };
|
||||||
service = new AuthService(
|
service = new AuthService(
|
||||||
jwtService,
|
jwtService,
|
||||||
usersService,
|
usersService,
|
||||||
@@ -52,6 +54,7 @@ describe('AuthService inactive-user enforcement and safe logging', () => {
|
|||||||
logger,
|
logger,
|
||||||
dataSource,
|
dataSource,
|
||||||
{ assertAtLeast: jest.fn() } as any,
|
{ assertAtLeast: jest.fn() } as any,
|
||||||
|
eventEmitter as any,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -155,6 +158,19 @@ describe('AuthService inactive-user enforcement and safe logging', () => {
|
|||||||
expect(usersService.linkPlayerToUserId).not.toHaveBeenCalled();
|
expect(usersService.linkPlayerToUserId).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('emits an invite-link-created event after issuing the token', async () => {
|
||||||
|
const token = await service.createTeamInvite(
|
||||||
|
{ teamId: 10, teamName: 'Team A' } as any,
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(token.token).toBeDefined();
|
||||||
|
expect(eventEmitter.emit).toHaveBeenCalledWith(
|
||||||
|
'notifications.invite_link.created',
|
||||||
|
expect.objectContaining({ teamId: 10, actorUserId: 5, teamName: 'Team A' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
function user(statusId: StatusEnum) {
|
function user(statusId: StatusEnum) {
|
||||||
return {
|
return {
|
||||||
id: 2,
|
id: 2,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
import { User } from '../users/entities/user.entity';
|
import { User } from '../users/entities/user.entity';
|
||||||
import * as bcrypt from 'bcryptjs';
|
import * as bcrypt from 'bcryptjs';
|
||||||
import { AuthEmailLoginDto } from './dto/auth-email-login.dto';
|
import { AuthEmailLoginDto } from './dto/auth-email-login.dto';
|
||||||
@@ -27,6 +28,8 @@ import { LoggingService } from 'src/database/logging/logging.service';
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { TeamAccessService } from 'src/teams/team-access.service';
|
import { TeamAccessService } from 'src/teams/team-access.service';
|
||||||
import { TeamRolesEnum } from 'src/team-roles/team-roles.enum';
|
import { TeamRolesEnum } from 'src/team-roles/team-roles.enum';
|
||||||
|
import { NOTIFICATION_EVENT_NAME } from 'src/notifications/events/notification-event-names';
|
||||||
|
import { InviteLinkCreatedEvent } from 'src/notifications/events/invite-link-created.event';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
@@ -38,6 +41,7 @@ export class AuthService {
|
|||||||
private logger: LoggingService,
|
private logger: LoggingService,
|
||||||
private dataSource: DataSource,
|
private dataSource: DataSource,
|
||||||
private teamAccess: TeamAccessService,
|
private teamAccess: TeamAccessService,
|
||||||
|
private eventEmitter: EventEmitter2,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async validateLogin(
|
async validateLogin(
|
||||||
@@ -323,6 +327,11 @@ export class AuthService {
|
|||||||
userId: 0,
|
userId: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.eventEmitter.emit(
|
||||||
|
NOTIFICATION_EVENT_NAME.inviteLinkCreated,
|
||||||
|
new InviteLinkCreatedEvent(object.teamId, actorUserId, object.teamName),
|
||||||
|
);
|
||||||
|
|
||||||
return { token };
|
return { token };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,12 @@ export type LOGEVENT =
|
|||||||
| 'cashbox_export_subscription_run'
|
| 'cashbox_export_subscription_run'
|
||||||
| 'cashbox_export_subscription_run_fail'
|
| 'cashbox_export_subscription_run_fail'
|
||||||
| 'log_retention_cleanup_run'
|
| 'log_retention_cleanup_run'
|
||||||
| 'log_retention_cleanup_run_fail';
|
| 'log_retention_cleanup_run_fail'
|
||||||
|
| 'notification_create_fail'
|
||||||
|
| 'notification_retention_cleanup_run'
|
||||||
|
| 'notification_retention_cleanup_run_fail'
|
||||||
|
| 'public_access_enabled'
|
||||||
|
| 'public_access_rotated';
|
||||||
|
|
||||||
export const LOGEVENT_VALUES: LOGEVENT[] = [
|
export const LOGEVENT_VALUES: LOGEVENT[] = [
|
||||||
'user_create',
|
'user_create',
|
||||||
@@ -80,6 +85,11 @@ export const LOGEVENT_VALUES: LOGEVENT[] = [
|
|||||||
'cashbox_export_subscription_run_fail',
|
'cashbox_export_subscription_run_fail',
|
||||||
'log_retention_cleanup_run',
|
'log_retention_cleanup_run',
|
||||||
'log_retention_cleanup_run_fail',
|
'log_retention_cleanup_run_fail',
|
||||||
|
'notification_create_fail',
|
||||||
|
'notification_retention_cleanup_run',
|
||||||
|
'notification_retention_cleanup_run_fail',
|
||||||
|
'public_access_enabled',
|
||||||
|
'public_access_rotated',
|
||||||
];
|
];
|
||||||
|
|
||||||
export type LOGLEVEL = 'FATAL' | 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' | 'TRACE';
|
export type LOGLEVEL = 'FATAL' | 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' | 'TRACE';
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddNotificationTables1785600000000 implements MigrationInterface {
|
||||||
|
name = 'AddNotificationTables1785600000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE "notification" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"teamId" integer NOT NULL,
|
||||||
|
"event" character varying NOT NULL,
|
||||||
|
"actorUserId" integer NOT NULL,
|
||||||
|
"payload" text NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT "PK_notification_id" PRIMARY KEY ("id")
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX "IDX_notification_team_id" ON "notification" ("teamId")`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "notification"
|
||||||
|
ADD CONSTRAINT "FK_notification_team"
|
||||||
|
FOREIGN KEY ("teamId") REFERENCES "team"("id")
|
||||||
|
ON DELETE CASCADE
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE "notification_recipient" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"notificationId" integer NOT NULL,
|
||||||
|
"userId" integer NOT NULL,
|
||||||
|
"read" boolean NOT NULL DEFAULT false,
|
||||||
|
"readAt" TIMESTAMP,
|
||||||
|
CONSTRAINT "PK_notification_recipient_id" PRIMARY KEY ("id")
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX "IDX_notification_recipient_notification_id" ON "notification_recipient" ("notificationId")`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX "IDX_notification_recipient_user_id" ON "notification_recipient" ("userId")`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "notification_recipient"
|
||||||
|
ADD CONSTRAINT "FK_notification_recipient_notification"
|
||||||
|
FOREIGN KEY ("notificationId") REFERENCES "notification"("id")
|
||||||
|
ON DELETE CASCADE
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE "notification_recipient"`);
|
||||||
|
await queryRunner.query(`DROP TABLE "notification"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
describe('AddNotificationTables1785600000000', () => {
|
||||||
|
it('creates the notification and notification_recipient tables with their indexes and foreign keys', async () => {
|
||||||
|
const migrationModule = require('./1785600000000-AddNotificationTables');
|
||||||
|
const migration = new migrationModule.AddNotificationTables1785600000000();
|
||||||
|
const queryRunner = { query: jest.fn() } as any;
|
||||||
|
|
||||||
|
await migration.up(queryRunner);
|
||||||
|
|
||||||
|
const calls: string[] = queryRunner.query.mock.calls.map((c: any) => c[0]);
|
||||||
|
expect(calls).toHaveLength(7);
|
||||||
|
expect(calls.some((sql) => sql.includes('CREATE TABLE "notification"'))).toBe(true);
|
||||||
|
expect(calls.some((sql) => sql.includes('CREATE TABLE "notification_recipient"'))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(calls.some((sql) => sql.includes('IDX_notification_team_id'))).toBe(true);
|
||||||
|
expect(
|
||||||
|
calls.some((sql) => sql.includes('IDX_notification_recipient_notification_id')),
|
||||||
|
).toBe(true);
|
||||||
|
expect(calls.some((sql) => sql.includes('IDX_notification_recipient_user_id'))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(calls.some((sql) => sql.includes('FK_notification_team'))).toBe(true);
|
||||||
|
expect(calls.some((sql) => sql.includes('FK_notification_recipient_notification'))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops both tables on down, recipient first to respect the foreign key', async () => {
|
||||||
|
const migrationModule = require('./1785600000000-AddNotificationTables');
|
||||||
|
const migration = new migrationModule.AddNotificationTables1785600000000();
|
||||||
|
const queryRunner = { query: jest.fn() } as any;
|
||||||
|
|
||||||
|
await migration.down(queryRunner);
|
||||||
|
|
||||||
|
const calls: string[] = queryRunner.query.mock.calls.map((c: any) => c[0]);
|
||||||
|
expect(calls).toEqual(['DROP TABLE "notification_recipient"', 'DROP TABLE "notification"']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsInt, IsOptional, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class NotificationQueryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Column, Entity, Index, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
|
import { EntityHelper } from 'src/utils/entity-helper';
|
||||||
|
import { Notification } from './notification.entity';
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
export class NotificationRecipient extends EntityHelper {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
@Index('IDX_notification_recipient_notification_id')
|
||||||
|
@ManyToOne(() => Notification, { onDelete: 'CASCADE' })
|
||||||
|
notification: Notification;
|
||||||
|
|
||||||
|
@Index('IDX_notification_recipient_user_id')
|
||||||
|
@Column()
|
||||||
|
userId: number;
|
||||||
|
|
||||||
|
@Column({ default: false })
|
||||||
|
read: boolean;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
readAt: Date | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { EntityHelper } from 'src/utils/entity-helper';
|
||||||
|
import { Team } from 'src/teams/entities/team.entity';
|
||||||
|
import { NOTIFICATION_EVENT } from '../model/notification-event.type';
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
export class Notification extends EntityHelper {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
@Index('IDX_notification_team_id')
|
||||||
|
@ManyToOne(() => Team, { eager: false })
|
||||||
|
team: Team;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
event: NOTIFICATION_EVENT;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
actorUserId: number;
|
||||||
|
|
||||||
|
@Column({ type: 'text' })
|
||||||
|
payload: string;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export class InviteLinkCreatedEvent {
|
||||||
|
constructor(
|
||||||
|
public readonly teamId: number,
|
||||||
|
public readonly actorUserId: number,
|
||||||
|
public readonly teamName: string,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export const NOTIFICATION_EVENT_NAME = {
|
||||||
|
playerActiveChanged: 'notifications.player.active_changed',
|
||||||
|
playerRoleChanged: 'notifications.player.role_changed',
|
||||||
|
playerCreated: 'notifications.player.created',
|
||||||
|
publicAccessEnabled: 'notifications.public_access.enabled',
|
||||||
|
publicAccessRotated: 'notifications.public_access.rotated',
|
||||||
|
inviteLinkCreated: 'notifications.invite_link.created',
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export class PlayerActiveChangedEvent {
|
||||||
|
constructor(
|
||||||
|
public readonly teamId: number,
|
||||||
|
public readonly actorUserId: number,
|
||||||
|
public readonly playerId: number,
|
||||||
|
public readonly playerName: string,
|
||||||
|
public readonly active: boolean,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export class PlayerCreatedEvent {
|
||||||
|
constructor(
|
||||||
|
public readonly teamId: number,
|
||||||
|
public readonly actorUserId: number,
|
||||||
|
public readonly playerId: number,
|
||||||
|
public readonly playerName: string,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export class PlayerRoleChangedEvent {
|
||||||
|
constructor(
|
||||||
|
public readonly teamId: number,
|
||||||
|
public readonly actorUserId: number,
|
||||||
|
public readonly playerId: number,
|
||||||
|
public readonly playerName: string,
|
||||||
|
public readonly teamRoleId: number,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export class PublicAccessEnabledEvent {
|
||||||
|
constructor(
|
||||||
|
public readonly teamId: number,
|
||||||
|
public readonly actorUserId: number,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PublicAccessRotatedEvent {
|
||||||
|
constructor(
|
||||||
|
public readonly teamId: number,
|
||||||
|
public readonly actorUserId: number,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export type NOTIFICATION_EVENT =
|
||||||
|
| 'player_active_update'
|
||||||
|
| 'player_team_role_update'
|
||||||
|
| 'player_creation'
|
||||||
|
| 'public_access_enabled'
|
||||||
|
| 'public_access_rotated'
|
||||||
|
| 'user_invite_link_create';
|
||||||
|
|
||||||
|
export const NOTIFICATION_EVENT_VALUES: NOTIFICATION_EVENT[] = [
|
||||||
|
'player_active_update',
|
||||||
|
'player_team_role_update',
|
||||||
|
'player_creation',
|
||||||
|
'public_access_enabled',
|
||||||
|
'public_access_rotated',
|
||||||
|
'user_invite_link_create',
|
||||||
|
];
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { LessThan } from 'typeorm';
|
||||||
|
import { NotificationRetentionScheduler } from './notification-retention.scheduler';
|
||||||
|
|
||||||
|
describe('NotificationRetentionScheduler', () => {
|
||||||
|
const repository = { delete: jest.fn() };
|
||||||
|
const configService = { get: jest.fn() };
|
||||||
|
const logger = { info: jest.fn(), error: jest.fn() };
|
||||||
|
let scheduler: NotificationRetentionScheduler;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
jest.useFakeTimers().setSystemTime(new Date('2026-08-04T12:00:00.000Z'));
|
||||||
|
configService.get.mockReturnValue(365);
|
||||||
|
repository.delete.mockResolvedValue({ affected: 3 });
|
||||||
|
scheduler = new NotificationRetentionScheduler(repository as any, configService as any, logger as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes notifications older than the configured retention window', async () => {
|
||||||
|
await scheduler.cleanupOldNotifications();
|
||||||
|
|
||||||
|
expect(configService.get).toHaveBeenCalledWith('app.logRetentionDays');
|
||||||
|
expect(repository.delete).toHaveBeenCalledWith({
|
||||||
|
createdAt: LessThan(new Date('2025-08-04T12:00:00.000Z')),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs the number of deleted notifications', async () => {
|
||||||
|
repository.delete.mockResolvedValue({ affected: 7 });
|
||||||
|
|
||||||
|
await scheduler.cleanupOldNotifications();
|
||||||
|
|
||||||
|
expect(logger.info).toHaveBeenCalledWith({
|
||||||
|
event: 'notification_retention_cleanup_run',
|
||||||
|
details: 'deletedCount=7 retentionDays=365',
|
||||||
|
userId: -1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs and does not rethrow when the delete fails', async () => {
|
||||||
|
repository.delete.mockRejectedValue(new Error('connection reset'));
|
||||||
|
|
||||||
|
await expect(scheduler.cleanupOldNotifications()).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(logger.error).toHaveBeenCalledWith({
|
||||||
|
event: 'notification_retention_cleanup_run_fail',
|
||||||
|
details: 'connection reset',
|
||||||
|
userId: -1,
|
||||||
|
});
|
||||||
|
expect(logger.info).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { LessThan, Repository } from 'typeorm';
|
||||||
|
import { LoggingService } from 'src/database/logging/logging.service';
|
||||||
|
import { Notification } from './entities/notification.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class NotificationRetentionScheduler {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Notification)
|
||||||
|
private readonly repository: Repository<Notification>,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
private readonly logger: LoggingService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Cron(CronExpression.EVERY_DAY_AT_5AM)
|
||||||
|
async cleanupOldNotifications(): Promise<void> {
|
||||||
|
const retentionDays = this.configService.get<number>('app.logRetentionDays');
|
||||||
|
const cutoff = new Date();
|
||||||
|
cutoff.setUTCDate(cutoff.getUTCDate() - retentionDays);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.repository.delete({ createdAt: LessThan(cutoff) });
|
||||||
|
|
||||||
|
await this.logger.info({
|
||||||
|
event: 'notification_retention_cleanup_run',
|
||||||
|
details: `deletedCount=${result.affected ?? 0} retentionDays=${retentionDays}`,
|
||||||
|
userId: -1,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
await this.logger.error({
|
||||||
|
event: 'notification_retention_cleanup_run_fail',
|
||||||
|
details: errorMessage,
|
||||||
|
userId: -1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Param,
|
||||||
|
ParseIntPipe,
|
||||||
|
Patch,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { TeamAccessService } from '../teams/team-access.service';
|
||||||
|
import { NotificationQueryDto } from './dto/notification-query.dto';
|
||||||
|
import { NotificationsService } from './notifications.service';
|
||||||
|
|
||||||
|
@ApiTags('Notifications')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@Controller({ path: 'teams', version: '1' })
|
||||||
|
export class NotificationsController {
|
||||||
|
constructor(
|
||||||
|
private readonly service: NotificationsService,
|
||||||
|
private readonly access: TeamAccessService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get(':teamId/notifications')
|
||||||
|
async list(
|
||||||
|
@Req() req,
|
||||||
|
@Param('teamId', ParseIntPipe) teamId: number,
|
||||||
|
@Query() query: NotificationQueryDto,
|
||||||
|
) {
|
||||||
|
await this.access.assertMember(Number(req.user.id), teamId);
|
||||||
|
return this.service.listForUser(Number(req.user.id), teamId, query.page ?? 1, query.limit ?? 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':teamId/notifications/unread-count')
|
||||||
|
async unreadCount(@Req() req, @Param('teamId', ParseIntPipe) teamId: number) {
|
||||||
|
await this.access.assertMember(Number(req.user.id), teamId);
|
||||||
|
return { count: await this.service.getUnreadCount(Number(req.user.id), teamId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':teamId/notifications/:id/read')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async markRead(
|
||||||
|
@Req() req,
|
||||||
|
@Param('teamId', ParseIntPipe) teamId: number,
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
) {
|
||||||
|
await this.access.assertMember(Number(req.user.id), teamId);
|
||||||
|
await this.service.markRead(id, Number(req.user.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':teamId/notifications/read-all')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async markAllRead(@Req() req, @Param('teamId', ParseIntPipe) teamId: number) {
|
||||||
|
await this.access.assertMember(Number(req.user.id), teamId);
|
||||||
|
await this.service.markAllRead(Number(req.user.id), teamId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import {
|
||||||
|
INestApplication,
|
||||||
|
UnauthorizedException,
|
||||||
|
ValidationPipe,
|
||||||
|
VersioningType,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import * as request from 'supertest';
|
||||||
|
import validationOptions from '../utils/validation-options';
|
||||||
|
import { NotificationsController } from './notifications.controller';
|
||||||
|
import { NotificationsService } from './notifications.service';
|
||||||
|
import { TeamAccessService } from '../teams/team-access.service';
|
||||||
|
|
||||||
|
describe('notifications HTTP boundary', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
const service = {
|
||||||
|
listForUser: jest.fn(),
|
||||||
|
getUnreadCount: jest.fn(),
|
||||||
|
markRead: jest.fn(),
|
||||||
|
markAllRead: jest.fn(),
|
||||||
|
};
|
||||||
|
const access = { assertMember: jest.fn() };
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const module = await Test.createTestingModule({
|
||||||
|
controllers: [NotificationsController],
|
||||||
|
providers: [
|
||||||
|
{ provide: NotificationsService, useValue: service },
|
||||||
|
{ provide: TeamAccessService, useValue: access },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.overrideGuard(AuthGuard('jwt'))
|
||||||
|
.useValue({
|
||||||
|
canActivate(context) {
|
||||||
|
const httpRequest = context.switchToHttp().getRequest();
|
||||||
|
if (httpRequest.headers.authorization !== 'Bearer user') {
|
||||||
|
throw new UnauthorizedException();
|
||||||
|
}
|
||||||
|
httpRequest.user = { id: 42, role: { id: 2 } };
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.compile();
|
||||||
|
app = module.createNestApplication();
|
||||||
|
app.setGlobalPrefix('api');
|
||||||
|
app.enableVersioning({ type: VersioningType.URI });
|
||||||
|
app.useGlobalPipes(new ValidationPipe(validationOptions));
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => app.close());
|
||||||
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
it('requires authentication', async () => {
|
||||||
|
await request(app.getHttpServer()).get('/api/v1/teams/10/notifications').expect(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists notifications for the authenticated user after checking membership', async () => {
|
||||||
|
access.assertMember.mockResolvedValue(undefined);
|
||||||
|
service.listForUser.mockResolvedValue({ data: [], page: 2, limit: 5, total: 0, hasNextPage: false });
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.get('/api/v1/teams/10/notifications?page=2&limit=5')
|
||||||
|
.set('Authorization', 'Bearer user')
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(access.assertMember).toHaveBeenCalledWith(42, 10);
|
||||||
|
expect(service.listForUser).toHaveBeenCalledWith(42, 10, 2, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to page 1 and limit 20 when not provided', async () => {
|
||||||
|
access.assertMember.mockResolvedValue(undefined);
|
||||||
|
service.listForUser.mockResolvedValue({ data: [], page: 1, limit: 20, total: 0, hasNextPage: false });
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.get('/api/v1/teams/10/notifications')
|
||||||
|
.set('Authorization', 'Bearer user')
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(service.listForUser).toHaveBeenCalledWith(42, 10, 1, 20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the unread count', async () => {
|
||||||
|
access.assertMember.mockResolvedValue(undefined);
|
||||||
|
service.getUnreadCount.mockResolvedValue(4);
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.get('/api/v1/teams/10/notifications/unread-count')
|
||||||
|
.set('Authorization', 'Bearer user')
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body).toEqual({ count: 4 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks a single notification as read', async () => {
|
||||||
|
access.assertMember.mockResolvedValue(undefined);
|
||||||
|
service.markRead.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.patch('/api/v1/teams/10/notifications/7/read')
|
||||||
|
.set('Authorization', 'Bearer user')
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(service.markRead).toHaveBeenCalledWith(7, 42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks all notifications as read', async () => {
|
||||||
|
access.assertMember.mockResolvedValue(undefined);
|
||||||
|
service.markAllRead.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.patch('/api/v1/teams/10/notifications/read-all')
|
||||||
|
.set('Authorization', 'Bearer user')
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(service.markAllRead).toHaveBeenCalledWith(42, 10);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { PlayerActiveChangedEvent } from './events/player-active-changed.event';
|
||||||
|
import { PlayerRoleChangedEvent } from './events/player-role-changed.event';
|
||||||
|
import { PlayerCreatedEvent } from './events/player-created.event';
|
||||||
|
import { PublicAccessEnabledEvent, PublicAccessRotatedEvent } from './events/public-access-changed.event';
|
||||||
|
import { InviteLinkCreatedEvent } from './events/invite-link-created.event';
|
||||||
|
import { NotificationsListener } from './notifications.listener';
|
||||||
|
|
||||||
|
describe('NotificationsListener', () => {
|
||||||
|
const notifications = { create: jest.fn() };
|
||||||
|
const logger = { error: jest.fn() };
|
||||||
|
let listener: NotificationsListener;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
listener = new NotificationsListener(notifications as any, logger as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a player_active_update notification', async () => {
|
||||||
|
await listener.onPlayerActiveChanged(new PlayerActiveChangedEvent(10, 5, 1, 'Ada Lovelace', false));
|
||||||
|
|
||||||
|
expect(notifications.create).toHaveBeenCalledWith({
|
||||||
|
teamId: 10,
|
||||||
|
event: 'player_active_update',
|
||||||
|
actorUserId: 5,
|
||||||
|
payload: { playerId: 1, playerName: 'Ada Lovelace', active: false },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a player_team_role_update notification', async () => {
|
||||||
|
await listener.onPlayerRoleChanged(new PlayerRoleChangedEvent(10, 5, 1, 'Ada Lovelace', 3));
|
||||||
|
|
||||||
|
expect(notifications.create).toHaveBeenCalledWith({
|
||||||
|
teamId: 10,
|
||||||
|
event: 'player_team_role_update',
|
||||||
|
actorUserId: 5,
|
||||||
|
payload: { playerId: 1, playerName: 'Ada Lovelace', teamRoleId: 3 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a player_creation notification', async () => {
|
||||||
|
await listener.onPlayerCreated(new PlayerCreatedEvent(10, 5, 1, 'Ada Lovelace'));
|
||||||
|
|
||||||
|
expect(notifications.create).toHaveBeenCalledWith({
|
||||||
|
teamId: 10,
|
||||||
|
event: 'player_creation',
|
||||||
|
actorUserId: 5,
|
||||||
|
payload: { playerId: 1, playerName: 'Ada Lovelace' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a public_access_enabled notification', async () => {
|
||||||
|
await listener.onPublicAccessEnabled(new PublicAccessEnabledEvent(10, 5));
|
||||||
|
|
||||||
|
expect(notifications.create).toHaveBeenCalledWith({
|
||||||
|
teamId: 10,
|
||||||
|
event: 'public_access_enabled',
|
||||||
|
actorUserId: 5,
|
||||||
|
payload: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a public_access_rotated notification', async () => {
|
||||||
|
await listener.onPublicAccessRotated(new PublicAccessRotatedEvent(10, 5));
|
||||||
|
|
||||||
|
expect(notifications.create).toHaveBeenCalledWith({
|
||||||
|
teamId: 10,
|
||||||
|
event: 'public_access_rotated',
|
||||||
|
actorUserId: 5,
|
||||||
|
payload: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a user_invite_link_create notification', async () => {
|
||||||
|
await listener.onInviteLinkCreated(new InviteLinkCreatedEvent(10, 5, 'Team A'));
|
||||||
|
|
||||||
|
expect(notifications.create).toHaveBeenCalledWith({
|
||||||
|
teamId: 10,
|
||||||
|
event: 'user_invite_link_create',
|
||||||
|
actorUserId: 5,
|
||||||
|
payload: { teamName: 'Team A' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs and swallows errors instead of throwing, so the originating action is unaffected', async () => {
|
||||||
|
notifications.create.mockRejectedValue(new Error('db unavailable'));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
listener.onPlayerCreated(new PlayerCreatedEvent(10, 5, 1, 'Ada Lovelace')),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(logger.error).toHaveBeenCalledWith({
|
||||||
|
event: 'notification_create_fail',
|
||||||
|
details: 'teamId=10 event=player_creation: db unavailable',
|
||||||
|
userId: -1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
|
import { LoggingService } from 'src/database/logging/logging.service';
|
||||||
|
import { NOTIFICATION_EVENT } from './model/notification-event.type';
|
||||||
|
import { NOTIFICATION_EVENT_NAME } from './events/notification-event-names';
|
||||||
|
import { PlayerActiveChangedEvent } from './events/player-active-changed.event';
|
||||||
|
import { PlayerRoleChangedEvent } from './events/player-role-changed.event';
|
||||||
|
import { PlayerCreatedEvent } from './events/player-created.event';
|
||||||
|
import { PublicAccessEnabledEvent, PublicAccessRotatedEvent } from './events/public-access-changed.event';
|
||||||
|
import { InviteLinkCreatedEvent } from './events/invite-link-created.event';
|
||||||
|
import { NotificationsService } from './notifications.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class NotificationsListener {
|
||||||
|
constructor(
|
||||||
|
private readonly notifications: NotificationsService,
|
||||||
|
private readonly logger: LoggingService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@OnEvent(NOTIFICATION_EVENT_NAME.playerActiveChanged)
|
||||||
|
onPlayerActiveChanged(event: PlayerActiveChangedEvent): Promise<void> {
|
||||||
|
return this.safeCreate('player_active_update', event.teamId, event.actorUserId, {
|
||||||
|
playerId: event.playerId,
|
||||||
|
playerName: event.playerName,
|
||||||
|
active: event.active,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnEvent(NOTIFICATION_EVENT_NAME.playerRoleChanged)
|
||||||
|
onPlayerRoleChanged(event: PlayerRoleChangedEvent): Promise<void> {
|
||||||
|
return this.safeCreate('player_team_role_update', event.teamId, event.actorUserId, {
|
||||||
|
playerId: event.playerId,
|
||||||
|
playerName: event.playerName,
|
||||||
|
teamRoleId: event.teamRoleId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnEvent(NOTIFICATION_EVENT_NAME.playerCreated)
|
||||||
|
onPlayerCreated(event: PlayerCreatedEvent): Promise<void> {
|
||||||
|
return this.safeCreate('player_creation', event.teamId, event.actorUserId, {
|
||||||
|
playerId: event.playerId,
|
||||||
|
playerName: event.playerName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnEvent(NOTIFICATION_EVENT_NAME.publicAccessEnabled)
|
||||||
|
onPublicAccessEnabled(event: PublicAccessEnabledEvent): Promise<void> {
|
||||||
|
return this.safeCreate('public_access_enabled', event.teamId, event.actorUserId, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnEvent(NOTIFICATION_EVENT_NAME.publicAccessRotated)
|
||||||
|
onPublicAccessRotated(event: PublicAccessRotatedEvent): Promise<void> {
|
||||||
|
return this.safeCreate('public_access_rotated', event.teamId, event.actorUserId, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnEvent(NOTIFICATION_EVENT_NAME.inviteLinkCreated)
|
||||||
|
onInviteLinkCreated(event: InviteLinkCreatedEvent): Promise<void> {
|
||||||
|
return this.safeCreate('user_invite_link_create', event.teamId, event.actorUserId, {
|
||||||
|
teamName: event.teamName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async safeCreate(
|
||||||
|
event: NOTIFICATION_EVENT,
|
||||||
|
teamId: number,
|
||||||
|
actorUserId: number,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.notifications.create({ teamId, event, actorUserId, payload });
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
await this.logger.error({
|
||||||
|
event: 'notification_create_fail',
|
||||||
|
details: `teamId=${teamId} event=${event}: ${errorMessage}`,
|
||||||
|
userId: -1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { LoggingModule } from 'src/database/logging/logging.module';
|
||||||
|
import { Player } from 'src/players/entities/player.entity';
|
||||||
|
import { TeamsModule } from 'src/teams/teams.module';
|
||||||
|
import { Notification } from './entities/notification.entity';
|
||||||
|
import { NotificationRecipient } from './entities/notification-recipient.entity';
|
||||||
|
import { NotificationsController } from './notifications.controller';
|
||||||
|
import { NotificationsListener } from './notifications.listener';
|
||||||
|
import { NotificationsService } from './notifications.service';
|
||||||
|
import { NotificationRetentionScheduler } from './notification-retention.scheduler';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([Notification, NotificationRecipient, Player]),
|
||||||
|
LoggingModule,
|
||||||
|
TeamsModule,
|
||||||
|
],
|
||||||
|
controllers: [NotificationsController],
|
||||||
|
providers: [NotificationsService, NotificationsListener, NotificationRetentionScheduler],
|
||||||
|
})
|
||||||
|
export class NotificationsModule {}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import { NotFoundException } from '@nestjs/common';
|
||||||
|
import { NotificationsService } from './notifications.service';
|
||||||
|
|
||||||
|
describe('NotificationsService', () => {
|
||||||
|
const notificationRepository = { create: jest.fn(), save: jest.fn() };
|
||||||
|
const recipientRepository = {
|
||||||
|
insert: jest.fn(),
|
||||||
|
createQueryBuilder: jest.fn(),
|
||||||
|
findOne: jest.fn(),
|
||||||
|
save: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
};
|
||||||
|
const playerRepository = { createQueryBuilder: jest.fn() };
|
||||||
|
let service: NotificationsService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
notificationRepository.create.mockImplementation((value) => value);
|
||||||
|
service = new NotificationsService(
|
||||||
|
notificationRepository as any,
|
||||||
|
recipientRepository as any,
|
||||||
|
playerRepository as any,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function chain(overrides: Record<string, jest.Mock>) {
|
||||||
|
const query: Record<string, jest.Mock> = {};
|
||||||
|
['innerJoin', 'innerJoinAndSelect', 'where', 'andWhere', 'select', 'orderBy', 'offset', 'limit']
|
||||||
|
.forEach((method) => (query[method] = jest.fn(() => query)));
|
||||||
|
return Object.assign(query, overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
it('does nothing when the team has no other active members with a login', async () => {
|
||||||
|
const playerQuery = chain({ getRawMany: jest.fn().mockResolvedValue([]) });
|
||||||
|
playerRepository.createQueryBuilder.mockReturnValue(playerQuery);
|
||||||
|
|
||||||
|
await service.create({
|
||||||
|
teamId: 10,
|
||||||
|
event: 'player_creation',
|
||||||
|
actorUserId: 5,
|
||||||
|
payload: { playerId: 1, playerName: 'Ada Lovelace' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(playerQuery.where).toHaveBeenCalledWith('player.teamId = :teamId', { teamId: 10 });
|
||||||
|
expect(playerQuery.andWhere).toHaveBeenCalledWith('player.active = :active', { active: true });
|
||||||
|
expect(playerQuery.andWhere).toHaveBeenCalledWith('player.userId IS NOT NULL');
|
||||||
|
expect(playerQuery.andWhere).toHaveBeenCalledWith('player.userId != :actorUserId', { actorUserId: 5 });
|
||||||
|
|
||||||
|
expect(notificationRepository.save).not.toHaveBeenCalled();
|
||||||
|
expect(recipientRepository.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates one notification and fans it out to every recipient', async () => {
|
||||||
|
const playerQuery = chain({ getRawMany: jest.fn().mockResolvedValue([{ userId: 7 }, { userId: 8 }]) });
|
||||||
|
playerRepository.createQueryBuilder.mockReturnValue(playerQuery);
|
||||||
|
notificationRepository.save.mockResolvedValue({ id: 99 });
|
||||||
|
|
||||||
|
await service.create({
|
||||||
|
teamId: 10,
|
||||||
|
event: 'player_creation',
|
||||||
|
actorUserId: 5,
|
||||||
|
payload: { playerId: 1, playerName: 'Ada Lovelace' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(playerQuery.where).toHaveBeenCalledWith('player.teamId = :teamId', { teamId: 10 });
|
||||||
|
expect(playerQuery.andWhere).toHaveBeenCalledWith('player.active = :active', { active: true });
|
||||||
|
expect(playerQuery.andWhere).toHaveBeenCalledWith('player.userId IS NOT NULL');
|
||||||
|
expect(playerQuery.andWhere).toHaveBeenCalledWith('player.userId != :actorUserId', { actorUserId: 5 });
|
||||||
|
|
||||||
|
expect(notificationRepository.save).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
team: { id: 10 },
|
||||||
|
event: 'player_creation',
|
||||||
|
actorUserId: 5,
|
||||||
|
payload: JSON.stringify({ playerId: 1, playerName: 'Ada Lovelace' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(recipientRepository.insert).toHaveBeenCalledWith([
|
||||||
|
{ notification: { id: 99 }, userId: 7 },
|
||||||
|
{ notification: { id: 99 }, userId: 8 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('listForUser', () => {
|
||||||
|
it('maps recipient rows to notification DTOs with parsed payloads', async () => {
|
||||||
|
const query = chain({
|
||||||
|
getCount: jest.fn().mockResolvedValue(1),
|
||||||
|
getMany: jest.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
userId: 7,
|
||||||
|
read: false,
|
||||||
|
notification: {
|
||||||
|
event: 'player_creation',
|
||||||
|
actorUserId: 5,
|
||||||
|
payload: JSON.stringify({ playerId: 1, playerName: 'Ada Lovelace' }),
|
||||||
|
createdAt: new Date('2026-08-04T10:00:00.000Z'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
recipientRepository.createQueryBuilder.mockReturnValue(query);
|
||||||
|
|
||||||
|
const page = await service.listForUser(7, 10, 1, 20);
|
||||||
|
|
||||||
|
expect(page).toEqual({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
event: 'player_creation',
|
||||||
|
actorUserId: 5,
|
||||||
|
payload: { playerId: 1, playerName: 'Ada Lovelace' },
|
||||||
|
read: false,
|
||||||
|
createdAt: new Date('2026-08-04T10:00:00.000Z'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
page: 1,
|
||||||
|
limit: 20,
|
||||||
|
total: 1,
|
||||||
|
hasNextPage: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getUnreadCount', () => {
|
||||||
|
it('counts only unread recipient rows for the given user and team', async () => {
|
||||||
|
const query = chain({ getCount: jest.fn().mockResolvedValue(3) });
|
||||||
|
recipientRepository.createQueryBuilder.mockReturnValue(query);
|
||||||
|
|
||||||
|
await expect(service.getUnreadCount(7, 10)).resolves.toBe(3);
|
||||||
|
expect(query.where).toHaveBeenCalledWith('recipient.userId = :userId', { userId: 7 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('markRead', () => {
|
||||||
|
it('marks a recipient row as read', async () => {
|
||||||
|
recipientRepository.findOne.mockResolvedValue({ id: 1, userId: 7, read: false, readAt: null });
|
||||||
|
|
||||||
|
await service.markRead(1, 7);
|
||||||
|
|
||||||
|
expect(recipientRepository.save).toHaveBeenCalledWith(expect.objectContaining({ read: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects marking a recipient row that belongs to another user', async () => {
|
||||||
|
recipientRepository.findOne.mockResolvedValue({ id: 1, userId: 999, read: false });
|
||||||
|
|
||||||
|
await expect(service.markRead(1, 7)).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
expect(recipientRepository.save).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects marking a recipient row that does not exist', async () => {
|
||||||
|
recipientRepository.findOne.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.markRead(1, 7)).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('markAllRead', () => {
|
||||||
|
it('marks every unread recipient row for the user and team as read', async () => {
|
||||||
|
const query = chain({ getRawMany: jest.fn().mockResolvedValue([{ id: 1 }, { id: 2 }]) });
|
||||||
|
recipientRepository.createQueryBuilder.mockReturnValue(query);
|
||||||
|
|
||||||
|
await service.markAllRead(7, 10);
|
||||||
|
|
||||||
|
expect(recipientRepository.update).toHaveBeenCalledWith(
|
||||||
|
[1, 2],
|
||||||
|
expect.objectContaining({ read: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when there is nothing unread', async () => {
|
||||||
|
const query = chain({ getRawMany: jest.fn().mockResolvedValue([]) });
|
||||||
|
recipientRepository.createQueryBuilder.mockReturnValue(query);
|
||||||
|
|
||||||
|
await service.markAllRead(7, 10);
|
||||||
|
|
||||||
|
expect(recipientRepository.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
150
myteamwallet_backend/src/notifications/notifications.service.ts
Normal file
150
myteamwallet_backend/src/notifications/notifications.service.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { Player } from 'src/players/entities/player.entity';
|
||||||
|
import { Team } from 'src/teams/entities/team.entity';
|
||||||
|
import { Notification } from './entities/notification.entity';
|
||||||
|
import { NotificationRecipient } from './entities/notification-recipient.entity';
|
||||||
|
import { NOTIFICATION_EVENT } from './model/notification-event.type';
|
||||||
|
|
||||||
|
export interface NotificationDto {
|
||||||
|
id: number;
|
||||||
|
event: NOTIFICATION_EVENT;
|
||||||
|
actorUserId: number;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
read: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationPage {
|
||||||
|
data: NotificationDto[];
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
total: number;
|
||||||
|
hasNextPage: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class NotificationsService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Notification)
|
||||||
|
private readonly notificationRepository: Repository<Notification>,
|
||||||
|
@InjectRepository(NotificationRecipient)
|
||||||
|
private readonly recipientRepository: Repository<NotificationRecipient>,
|
||||||
|
@InjectRepository(Player)
|
||||||
|
private readonly playerRepository: Repository<Player>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async create(params: {
|
||||||
|
teamId: number;
|
||||||
|
event: NOTIFICATION_EVENT;
|
||||||
|
actorUserId: number;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
}): Promise<void> {
|
||||||
|
const recipientUserIds = await this.resolveRecipients(params.teamId, params.actorUserId);
|
||||||
|
if (recipientUserIds.length === 0) return;
|
||||||
|
|
||||||
|
const notification = await this.notificationRepository.save(
|
||||||
|
this.notificationRepository.create({
|
||||||
|
team: { id: params.teamId } as Team,
|
||||||
|
event: params.event,
|
||||||
|
actorUserId: params.actorUserId,
|
||||||
|
payload: JSON.stringify(params.payload),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.recipientRepository.insert(
|
||||||
|
recipientUserIds.map((userId) => ({
|
||||||
|
notification: { id: notification.id } as Notification,
|
||||||
|
userId,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listForUser(
|
||||||
|
userId: number,
|
||||||
|
teamId: number,
|
||||||
|
page: number,
|
||||||
|
limit: number,
|
||||||
|
): Promise<NotificationPage> {
|
||||||
|
const builder = this.recipientRepository
|
||||||
|
.createQueryBuilder('recipient')
|
||||||
|
.innerJoinAndSelect('recipient.notification', 'notification')
|
||||||
|
.where('recipient.userId = :userId', { userId })
|
||||||
|
.andWhere('notification.teamId = :teamId', { teamId })
|
||||||
|
.orderBy('notification.createdAt', 'DESC');
|
||||||
|
|
||||||
|
const total = await builder.getCount();
|
||||||
|
const rows = await builder.offset((page - 1) * limit).limit(limit).getMany();
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: rows.map((row) => this.toDto(row)),
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
hasNextPage: page * limit < total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUnreadCount(userId: number, teamId: number): Promise<number> {
|
||||||
|
return this.recipientRepository
|
||||||
|
.createQueryBuilder('recipient')
|
||||||
|
.innerJoin('recipient.notification', 'notification')
|
||||||
|
.where('recipient.userId = :userId', { userId })
|
||||||
|
.andWhere('notification.teamId = :teamId', { teamId })
|
||||||
|
.andWhere('recipient.read = false')
|
||||||
|
.getCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
async markRead(recipientId: number, userId: number): Promise<void> {
|
||||||
|
const recipient = await this.recipientRepository.findOne({ where: { id: recipientId } });
|
||||||
|
if (!recipient || recipient.userId !== userId) {
|
||||||
|
throw new NotFoundException('Benachrichtigung nicht gefunden.');
|
||||||
|
}
|
||||||
|
if (recipient.read) return;
|
||||||
|
recipient.read = true;
|
||||||
|
recipient.readAt = new Date();
|
||||||
|
await this.recipientRepository.save(recipient);
|
||||||
|
}
|
||||||
|
|
||||||
|
async markAllRead(userId: number, teamId: number): Promise<void> {
|
||||||
|
const rows = await this.recipientRepository
|
||||||
|
.createQueryBuilder('recipient')
|
||||||
|
.innerJoin('recipient.notification', 'notification')
|
||||||
|
.where('recipient.userId = :userId', { userId })
|
||||||
|
.andWhere('notification.teamId = :teamId', { teamId })
|
||||||
|
.andWhere('recipient.read = false')
|
||||||
|
.select('recipient.id', 'id')
|
||||||
|
.getRawMany<{ id: number }>();
|
||||||
|
|
||||||
|
if (rows.length === 0) return;
|
||||||
|
|
||||||
|
await this.recipientRepository.update(rows.map((row) => row.id), {
|
||||||
|
read: true,
|
||||||
|
readAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveRecipients(teamId: number, actorUserId: number): Promise<number[]> {
|
||||||
|
const rows = await this.playerRepository
|
||||||
|
.createQueryBuilder('player')
|
||||||
|
.where('player.teamId = :teamId', { teamId })
|
||||||
|
.andWhere('player.active = :active', { active: true })
|
||||||
|
.andWhere('player.userId IS NOT NULL')
|
||||||
|
.andWhere('player.userId != :actorUserId', { actorUserId })
|
||||||
|
.select('DISTINCT player.userId', 'userId')
|
||||||
|
.getRawMany<{ userId: number }>();
|
||||||
|
return rows.map((row) => row.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDto(recipient: NotificationRecipient): NotificationDto {
|
||||||
|
return {
|
||||||
|
id: recipient.id,
|
||||||
|
event: recipient.notification.event,
|
||||||
|
actorUserId: recipient.notification.actorUserId,
|
||||||
|
payload: JSON.parse(recipient.notification.payload),
|
||||||
|
read: recipient.read,
|
||||||
|
createdAt: recipient.notification.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,7 +42,12 @@ describe('RecurringTransactionsScheduler', () => {
|
|||||||
await scheduler.runDueRecurringTransactions();
|
await scheduler.runDueRecurringTransactions();
|
||||||
|
|
||||||
expect(dataSource.transaction).not.toHaveBeenCalled();
|
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||||
expect(logger.info).not.toHaveBeenCalled();
|
expect(logger.info).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ event: 'scheduled_recurring_transaction_check_start' }),
|
||||||
|
);
|
||||||
|
expect(logger.info).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ event: 'scheduled_recurring_transaction_check_finished' }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('books a transaction for every active player and skips inactive ones', async () => {
|
it('books a transaction for every active player and skips inactive ones', async () => {
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ describe('PublicTeamAccessService', () => {
|
|||||||
const penaltyRepository = { find: jest.fn() };
|
const penaltyRepository = { find: jest.fn() };
|
||||||
const access = { assertMember: jest.fn(), assertAtLeast: jest.fn() };
|
const access = { assertMember: jest.fn(), assertAtLeast: jest.fn() };
|
||||||
let service: PublicTeamAccessService;
|
let service: PublicTeamAccessService;
|
||||||
|
let logger: any;
|
||||||
|
let eventEmitter: any;
|
||||||
|
|
||||||
const managedTeam = {
|
const managedTeam = {
|
||||||
id: 7,
|
id: 7,
|
||||||
@@ -25,12 +27,16 @@ describe('PublicTeamAccessService', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.resetAllMocks();
|
jest.resetAllMocks();
|
||||||
teamRepository.save.mockImplementation(async (team) => team);
|
teamRepository.save.mockImplementation(async (team) => team);
|
||||||
|
logger = { info: jest.fn() };
|
||||||
|
eventEmitter = { emit: jest.fn() };
|
||||||
service = new PublicTeamAccessService(
|
service = new PublicTeamAccessService(
|
||||||
teamRepository as any,
|
teamRepository as any,
|
||||||
playerRepository as any,
|
playerRepository as any,
|
||||||
transactionRepository as any,
|
transactionRepository as any,
|
||||||
penaltyRepository as any,
|
penaltyRepository as any,
|
||||||
access as any,
|
access as any,
|
||||||
|
logger as any,
|
||||||
|
eventEmitter as any,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -82,6 +88,47 @@ describe('PublicTeamAccessService', () => {
|
|||||||
expect(status.token).not.toBe('a'.repeat(64));
|
expect(status.token).not.toBe('a'.repeat(64));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('logs and emits when public access is enabled', async () => {
|
||||||
|
mockManagedTeam();
|
||||||
|
|
||||||
|
await service.setEnabled(4, 7, true);
|
||||||
|
|
||||||
|
expect(logger.info).toHaveBeenCalledWith({
|
||||||
|
event: 'public_access_enabled',
|
||||||
|
details: 'teamId=7',
|
||||||
|
userId: 4,
|
||||||
|
});
|
||||||
|
expect(eventEmitter.emit).toHaveBeenCalledWith(
|
||||||
|
'notifications.public_access.enabled',
|
||||||
|
expect.objectContaining({ teamId: 7, actorUserId: 4 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not log or emit when public access is disabled', async () => {
|
||||||
|
mockManagedTeam({ ...managedTeam, publicAccessEnabled: true, publicAccessToken: 'a'.repeat(64) });
|
||||||
|
|
||||||
|
await service.setEnabled(4, 7, false);
|
||||||
|
|
||||||
|
expect(logger.info).not.toHaveBeenCalled();
|
||||||
|
expect(eventEmitter.emit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs and emits when the token is rotated', async () => {
|
||||||
|
mockManagedTeam({ ...managedTeam, publicAccessEnabled: true, publicAccessToken: 'a'.repeat(64) });
|
||||||
|
|
||||||
|
await service.rotate(4, 7);
|
||||||
|
|
||||||
|
expect(logger.info).toHaveBeenCalledWith({
|
||||||
|
event: 'public_access_rotated',
|
||||||
|
details: 'teamId=7',
|
||||||
|
userId: 4,
|
||||||
|
});
|
||||||
|
expect(eventEmitter.emit).toHaveBeenCalledWith(
|
||||||
|
'notifications.public_access.rotated',
|
||||||
|
expect.objectContaining({ teamId: 7, actorUserId: 4 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('returns only whitelisted public team fields and active players', async () => {
|
it('returns only whitelisted public team fields and active players', async () => {
|
||||||
teamRepository.findOne.mockResolvedValue({
|
teamRepository.findOne.mockResolvedValue({
|
||||||
id: 7,
|
id: 7,
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
import { randomBytes } from 'crypto';
|
import { randomBytes } from 'crypto';
|
||||||
|
import { LoggingService } from '../database/logging/logging.service';
|
||||||
|
import { NOTIFICATION_EVENT_NAME } from '../notifications/events/notification-event-names';
|
||||||
|
import {
|
||||||
|
PublicAccessEnabledEvent,
|
||||||
|
PublicAccessRotatedEvent,
|
||||||
|
} from '../notifications/events/public-access-changed.event';
|
||||||
import { PenaltyEntity } from '../penalty/entities/penalty.entity';
|
import { PenaltyEntity } from '../penalty/entities/penalty.entity';
|
||||||
import { Player } from '../players/entities/player.entity';
|
import { Player } from '../players/entities/player.entity';
|
||||||
import { TeamRolesEnum } from '../team-roles/team-roles.enum';
|
import { TeamRolesEnum } from '../team-roles/team-roles.enum';
|
||||||
@@ -28,6 +35,8 @@ export class PublicTeamAccessService {
|
|||||||
@InjectRepository(PenaltyEntity)
|
@InjectRepository(PenaltyEntity)
|
||||||
private readonly penaltyRepository: Repository<PenaltyEntity>,
|
private readonly penaltyRepository: Repository<PenaltyEntity>,
|
||||||
private readonly access: TeamAccessService,
|
private readonly access: TeamAccessService,
|
||||||
|
private readonly logger: LoggingService,
|
||||||
|
private readonly eventEmitter: EventEmitter2,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getStatus(
|
async getStatus(
|
||||||
@@ -55,6 +64,19 @@ export class PublicTeamAccessService {
|
|||||||
}
|
}
|
||||||
team.publicAccessEnabled = enabled;
|
team.publicAccessEnabled = enabled;
|
||||||
await this.teamRepository.save(team);
|
await this.teamRepository.save(team);
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
await this.logger.info({
|
||||||
|
event: 'public_access_enabled',
|
||||||
|
details: `teamId=${teamId}`,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
this.eventEmitter.emit(
|
||||||
|
NOTIFICATION_EVENT_NAME.publicAccessEnabled,
|
||||||
|
new PublicAccessEnabledEvent(teamId, userId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return this.toStatus(team);
|
return this.toStatus(team);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +90,17 @@ export class PublicTeamAccessService {
|
|||||||
const team = await this.loadManagedTeam(teamId);
|
const team = await this.loadManagedTeam(teamId);
|
||||||
team.publicAccessToken = this.createToken();
|
team.publicAccessToken = this.createToken();
|
||||||
await this.teamRepository.save(team);
|
await this.teamRepository.save(team);
|
||||||
|
|
||||||
|
await this.logger.info({
|
||||||
|
event: 'public_access_rotated',
|
||||||
|
details: `teamId=${teamId}`,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
this.eventEmitter.emit(
|
||||||
|
NOTIFICATION_EVENT_NAME.publicAccessRotated,
|
||||||
|
new PublicAccessRotatedEvent(teamId, userId),
|
||||||
|
);
|
||||||
|
|
||||||
return this.toStatus(team);
|
return this.toStatus(team);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ describe('TeamMembersService', () => {
|
|||||||
let dataSource: any;
|
let dataSource: any;
|
||||||
let logger: any;
|
let logger: any;
|
||||||
let access: any;
|
let access: any;
|
||||||
|
let eventEmitter: any;
|
||||||
let service: TeamMembersService;
|
let service: TeamMembersService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -44,7 +45,8 @@ describe('TeamMembersService', () => {
|
|||||||
dataSource = { transaction: jest.fn((work) => work(manager)) };
|
dataSource = { transaction: jest.fn((work) => work(manager)) };
|
||||||
logger = { info: jest.fn() };
|
logger = { info: jest.fn() };
|
||||||
access = { assertAtLeast: jest.fn(() => Promise.resolve()) };
|
access = { assertAtLeast: jest.fn(() => Promise.resolve()) };
|
||||||
service = new TeamMembersService(dataSource, logger, access as any);
|
eventEmitter = { emit: jest.fn() };
|
||||||
|
service = new TeamMembersService(dataSource, logger, access as any, eventEmitter as any);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('checks the team-manager permission before touching the database', async () => {
|
it('checks the team-manager permission before touching the database', async () => {
|
||||||
@@ -188,6 +190,53 @@ describe('TeamMembersService', () => {
|
|||||||
).rejects.toBeInstanceOf(NotFoundException);
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('emits a player-active-changed event after a real deactivation', async () => {
|
||||||
|
player.balance = 42;
|
||||||
|
treasurers = [player, makePlayer(102, true, TeamRolesEnum.treasurer, 0)];
|
||||||
|
|
||||||
|
await service.setActive(5, teamId, player.id, false);
|
||||||
|
|
||||||
|
expect(eventEmitter.emit).toHaveBeenCalledWith(
|
||||||
|
'notifications.player.active_changed',
|
||||||
|
expect.objectContaining({
|
||||||
|
teamId,
|
||||||
|
actorUserId: 5,
|
||||||
|
playerId: player.id,
|
||||||
|
playerName: 'Pat Player',
|
||||||
|
active: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not emit when the active state is unchanged (idempotent)', async () => {
|
||||||
|
player = makePlayer(101, true, TeamRolesEnum.player, 0);
|
||||||
|
lockedPlayerQuery = chain({ getOne: jest.fn(() => player) });
|
||||||
|
playerRepository.createQueryBuilder = jest.fn((alias: string) =>
|
||||||
|
alias === 'lockedPlayer' ? lockedPlayerQuery : treasurerLockQuery,
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.setActive(5, teamId, player.id, true);
|
||||||
|
|
||||||
|
expect(eventEmitter.emit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits a player-role-changed event after a real role change', async () => {
|
||||||
|
treasurers = [player, makePlayer(102, true, TeamRolesEnum.treasurer, 0)];
|
||||||
|
|
||||||
|
await service.setTeamRole(5, teamId, player.id, TeamRolesEnum.captain);
|
||||||
|
|
||||||
|
expect(eventEmitter.emit).toHaveBeenCalledWith(
|
||||||
|
'notifications.player.role_changed',
|
||||||
|
expect.objectContaining({
|
||||||
|
teamId,
|
||||||
|
actorUserId: 5,
|
||||||
|
playerId: player.id,
|
||||||
|
playerName: 'Pat Player',
|
||||||
|
teamRoleId: TeamRolesEnum.captain,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
function makePlayer(
|
function makePlayer(
|
||||||
id: number,
|
id: number,
|
||||||
active: boolean,
|
active: boolean,
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
import { DataSource, EntityManager, Repository } from 'typeorm';
|
import { DataSource, EntityManager, Repository } from 'typeorm';
|
||||||
import { LoggingService } from '../database/logging/logging.service';
|
import { LoggingService } from '../database/logging/logging.service';
|
||||||
|
import { NOTIFICATION_EVENT_NAME } from '../notifications/events/notification-event-names';
|
||||||
|
import { PlayerActiveChangedEvent } from '../notifications/events/player-active-changed.event';
|
||||||
|
import { PlayerRoleChangedEvent } from '../notifications/events/player-role-changed.event';
|
||||||
import { Player } from '../players/entities/player.entity';
|
import { Player } from '../players/entities/player.entity';
|
||||||
import { TeamRole } from '../team-roles/entities/team-roles.entity';
|
import { TeamRole } from '../team-roles/entities/team-roles.entity';
|
||||||
import { TeamRolesEnum } from '../team-roles/team-roles.enum';
|
import { TeamRolesEnum } from '../team-roles/team-roles.enum';
|
||||||
@@ -18,6 +22,7 @@ export class TeamMembersService {
|
|||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
private readonly logger: LoggingService,
|
private readonly logger: LoggingService,
|
||||||
private readonly access: TeamAccessService,
|
private readonly access: TeamAccessService,
|
||||||
|
private readonly eventEmitter: EventEmitter2,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async setActive(
|
async setActive(
|
||||||
@@ -33,12 +38,12 @@ export class TeamMembersService {
|
|||||||
TeamRolesEnum.captain,
|
TeamRolesEnum.captain,
|
||||||
);
|
);
|
||||||
|
|
||||||
return this.dataSource.transaction(async (manager) => {
|
const result = await this.dataSource.transaction(async (manager) => {
|
||||||
const activeTreasurers = await this.lockActiveTreasurers(manager, teamId);
|
const activeTreasurers = await this.lockActiveTreasurers(manager, teamId);
|
||||||
const playerRepository = manager.getRepository(Player);
|
const playerRepository = manager.getRepository(Player);
|
||||||
const player = await this.findLockedPlayer(playerRepository, playerId, teamId);
|
const player = await this.findLockedPlayer(playerRepository, playerId, teamId);
|
||||||
|
|
||||||
if (player.active === active) return player;
|
if (player.active === active) return { player, changed: false };
|
||||||
|
|
||||||
const isDeactivation = player.active && !active;
|
const isDeactivation = player.active && !active;
|
||||||
if (
|
if (
|
||||||
@@ -66,8 +71,23 @@ export class TeamMembersService {
|
|||||||
actorUserId,
|
actorUserId,
|
||||||
`teamId=${teamId} playerId=${playerId} active=${active}`,
|
`teamId=${teamId} playerId=${playerId} active=${active}`,
|
||||||
);
|
);
|
||||||
return player;
|
return { player, changed: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (result.changed) {
|
||||||
|
this.eventEmitter.emit(
|
||||||
|
NOTIFICATION_EVENT_NAME.playerActiveChanged,
|
||||||
|
new PlayerActiveChangedEvent(
|
||||||
|
teamId,
|
||||||
|
actorUserId,
|
||||||
|
playerId,
|
||||||
|
`${result.player.firstName} ${result.player.lastName}`,
|
||||||
|
active,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.player;
|
||||||
}
|
}
|
||||||
|
|
||||||
async setTeamRole(
|
async setTeamRole(
|
||||||
@@ -83,12 +103,12 @@ export class TeamMembersService {
|
|||||||
TeamRolesEnum.captain,
|
TeamRolesEnum.captain,
|
||||||
);
|
);
|
||||||
|
|
||||||
return this.dataSource.transaction(async (manager) => {
|
const result = await this.dataSource.transaction(async (manager) => {
|
||||||
const activeTreasurers = await this.lockActiveTreasurers(manager, teamId);
|
const activeTreasurers = await this.lockActiveTreasurers(manager, teamId);
|
||||||
const playerRepository = manager.getRepository(Player);
|
const playerRepository = manager.getRepository(Player);
|
||||||
const player = await this.findLockedPlayer(playerRepository, playerId, teamId);
|
const player = await this.findLockedPlayer(playerRepository, playerId, teamId);
|
||||||
|
|
||||||
if (player.teamRole?.id === teamRoleId) return player;
|
if (player.teamRole?.id === teamRoleId) return { player, changed: false };
|
||||||
|
|
||||||
const isDemotionFromTreasurer =
|
const isDemotionFromTreasurer =
|
||||||
player.active &&
|
player.active &&
|
||||||
@@ -108,8 +128,23 @@ export class TeamMembersService {
|
|||||||
actorUserId,
|
actorUserId,
|
||||||
`teamId=${teamId} playerId=${playerId} teamRoleId=${teamRoleId}`,
|
`teamId=${teamId} playerId=${playerId} teamRoleId=${teamRoleId}`,
|
||||||
);
|
);
|
||||||
return player;
|
return { player, changed: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (result.changed) {
|
||||||
|
this.eventEmitter.emit(
|
||||||
|
NOTIFICATION_EVENT_NAME.playerRoleChanged,
|
||||||
|
new PlayerRoleChangedEvent(
|
||||||
|
teamId,
|
||||||
|
actorUserId,
|
||||||
|
playerId,
|
||||||
|
`${result.player.firstName} ${result.player.lastName}`,
|
||||||
|
teamRoleId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.player;
|
||||||
}
|
}
|
||||||
|
|
||||||
// insert() statt save(): umgeht bewusst @BeforeInsert setBalance() auf Transaction,
|
// insert() statt save(): umgeht bewusst @BeforeInsert setBalance() auf Transaction,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ describe('TeamsService#getOverviewStats theoretical balance', () => {
|
|||||||
access as any,
|
access as any,
|
||||||
{} as any,
|
{} as any,
|
||||||
{} as any,
|
{} as any,
|
||||||
|
{ emit: jest.fn() } as any,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -290,6 +291,7 @@ describe('TeamsService#getTeamTransactionsJournal', () => {
|
|||||||
access as any,
|
access as any,
|
||||||
{} as any,
|
{} as any,
|
||||||
{} as any,
|
{} as any,
|
||||||
|
{ emit: jest.fn() } as any,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -410,6 +412,7 @@ describe('TeamsService#createNewTeam', () => {
|
|||||||
{} as any,
|
{} as any,
|
||||||
{} as any,
|
{} as any,
|
||||||
dataSource as any,
|
dataSource as any,
|
||||||
|
{ emit: jest.fn() } as any,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -464,3 +467,50 @@ describe('TeamsService#createNewTeam', () => {
|
|||||||
expect(logger.info).not.toHaveBeenCalled();
|
expect(logger.info).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('TeamsService.createNewPlayer', () => {
|
||||||
|
const repository = { findOneBy: jest.fn() };
|
||||||
|
const playerRepository = { create: jest.fn((value) => value), save: jest.fn() };
|
||||||
|
const rolesRepository = { findOneBy: jest.fn() };
|
||||||
|
const logger = { info: jest.fn() };
|
||||||
|
const access = { assertManager: jest.fn() };
|
||||||
|
const eventEmitter = { emit: jest.fn() };
|
||||||
|
let service: TeamsService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
access.assertManager.mockResolvedValue(undefined);
|
||||||
|
rolesRepository.findOneBy.mockResolvedValue({ id: 1, name: 'player' });
|
||||||
|
repository.findOneBy.mockResolvedValue({ id: 10, name: 'Team A' });
|
||||||
|
playerRepository.save.mockImplementation((value) =>
|
||||||
|
Promise.resolve({ ...value, id: 55 }),
|
||||||
|
);
|
||||||
|
service = new TeamsService(
|
||||||
|
repository as any,
|
||||||
|
playerRepository as any,
|
||||||
|
{} as any,
|
||||||
|
rolesRepository as any,
|
||||||
|
{} as any,
|
||||||
|
{} as any,
|
||||||
|
logger as any,
|
||||||
|
access as any,
|
||||||
|
{} as any,
|
||||||
|
{} as any,
|
||||||
|
eventEmitter as any,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits a player-created event with the new player id and name', async () => {
|
||||||
|
await service.createNewPlayer('10', { firstName: 'Ada', lastName: 'Lovelace', teamRole: undefined }, '5');
|
||||||
|
|
||||||
|
expect(eventEmitter.emit).toHaveBeenCalledWith(
|
||||||
|
'notifications.player.created',
|
||||||
|
expect.objectContaining({
|
||||||
|
teamId: 10,
|
||||||
|
actorUserId: 5,
|
||||||
|
playerId: 55,
|
||||||
|
playerName: 'Ada Lovelace',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
|
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
import { LoggingService } from 'src/database/logging/logging.service';
|
import { LoggingService } from 'src/database/logging/logging.service';
|
||||||
|
import { NOTIFICATION_EVENT_NAME } from '../notifications/events/notification-event-names';
|
||||||
|
import { PlayerCreatedEvent } from '../notifications/events/player-created.event';
|
||||||
import { Player } from 'src/players/entities/player.entity';
|
import { Player } from 'src/players/entities/player.entity';
|
||||||
import { TeamRole } from 'src/team-roles/entities/team-roles.entity';
|
import { TeamRole } from 'src/team-roles/entities/team-roles.entity';
|
||||||
import { CreateTeamSettingDTO } from 'src/team-settings/dto/create-team-setting.dto';
|
import { CreateTeamSettingDTO } from 'src/team-settings/dto/create-team-setting.dto';
|
||||||
@@ -51,6 +54,7 @@ export class TeamsService {
|
|||||||
@InjectRepository(User)
|
@InjectRepository(User)
|
||||||
private usersRepository: Repository<User>,
|
private usersRepository: Repository<User>,
|
||||||
private dataSource: DataSource,
|
private dataSource: DataSource,
|
||||||
|
private eventEmitter: EventEmitter2,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getOverview(teamId: string, actorUserId: string) {
|
async getOverview(teamId: string, actorUserId: string) {
|
||||||
@@ -165,6 +169,11 @@ export class TeamsService {
|
|||||||
details: `Spieler ${playerSaved.id}, ${p.firstName} ${p.lastName} erstellt`,
|
details: `Spieler ${playerSaved.id}, ${p.firstName} ${p.lastName} erstellt`,
|
||||||
userId: Number(id),
|
userId: Number(id),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.eventEmitter.emit(
|
||||||
|
NOTIFICATION_EVENT_NAME.playerCreated,
|
||||||
|
new PlayerCreatedEvent(Number(id), Number(actorUserId), playerSaved.id, `${p.firstName} ${p.lastName}`),
|
||||||
|
);
|
||||||
return playerSaved;
|
return playerSaved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -123,6 +123,11 @@ export const routes: Routes = [
|
|||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./features/team/more/guide/guide').then((m) => m.Guide),
|
import('./features/team/more/guide/guide').then((m) => m.Guide),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'notifications',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/team/notifications/notifications').then((m) => m.Notifications),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,6 +12,46 @@
|
|||||||
} @else {
|
} @else {
|
||||||
<span>{{ currentTeam()?.name ?? 'TeamWallet' }}</span>
|
<span>{{ currentTeam()?.name ?? 'TeamWallet' }}</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<span class="shell-header-spacer"></span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
class="shell-notification-bell"
|
||||||
|
[matMenuTriggerFor]="notificationMenu"
|
||||||
|
(menuOpened)="onNotificationsMenuOpened()"
|
||||||
|
[matBadge]="unreadCount()"
|
||||||
|
[matBadgeHidden]="unreadCount() === 0"
|
||||||
|
matBadgeSize="small"
|
||||||
|
matBadgeColor="warn"
|
||||||
|
aria-label="Benachrichtigungen"
|
||||||
|
>
|
||||||
|
<mat-icon>notifications</mat-icon>
|
||||||
|
</button>
|
||||||
|
<mat-menu #notificationMenu="matMenu" class="shell-notification-menu">
|
||||||
|
<div class="shell-notification-menu__header">
|
||||||
|
<span>Benachrichtigungen</span>
|
||||||
|
<button mat-button (click)="onMarkAllRead()">Alle als gelesen markieren</button>
|
||||||
|
</div>
|
||||||
|
@if (notifications().length === 0) {
|
||||||
|
<div class="shell-notification-menu__empty">Keine Benachrichtigungen</div>
|
||||||
|
} @else {
|
||||||
|
@for (item of notifications(); track item.id) {
|
||||||
|
<button
|
||||||
|
mat-menu-item
|
||||||
|
class="shell-notification-menu__item"
|
||||||
|
[class.shell-notification-menu__item--unread]="!item.read"
|
||||||
|
(click)="onNotificationClick(item)"
|
||||||
|
>
|
||||||
|
<mat-icon>{{ notificationIcon(item) }}</mat-icon>
|
||||||
|
<span>{{ notificationLabel(item) }}</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (currentTeamId(); as teamId) {
|
||||||
|
<a mat-menu-item [routerLink]="['/team', teamId, 'notifications']">Alle anzeigen</a>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</mat-menu>
|
||||||
</mat-toolbar>
|
</mat-toolbar>
|
||||||
|
|
||||||
<main class="shell-content">
|
<main class="shell-content">
|
||||||
|
|||||||
@@ -53,3 +53,37 @@ main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.shell-header-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-notification-bell {
|
||||||
|
color: var(--mat-sys-on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-notification-menu {
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__empty {
|
||||||
|
padding: 1rem;
|
||||||
|
color: var(--mat-sys-on-surface-variant);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
|
||||||
|
&--unread {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,27 +1,55 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { signal } from '@angular/core';
|
||||||
import { BehaviorSubject } from 'rxjs';
|
import { BehaviorSubject } from 'rxjs';
|
||||||
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
|
import { ActivatedRoute, ParamMap, Router, convertToParamMap, provideRouter } from '@angular/router';
|
||||||
import { provideHttpClient } from '@angular/common/http';
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
import { Shell } from './shell';
|
import { Shell } from './shell';
|
||||||
import { environment } from '../../../../environments/environment';
|
import { environment } from '../../../../environments/environment';
|
||||||
import { AuthStore } from '../../auth/auth-store';
|
import { AuthStore } from '../../auth/auth-store';
|
||||||
import { Player } from '../../../models/player.model';
|
import { Player } from '../../../models/player.model';
|
||||||
|
import { NotificationsStore } from '../../notifications/notifications-store';
|
||||||
|
|
||||||
describe('Shell', () => {
|
describe('Shell', () => {
|
||||||
let httpMock: HttpTestingController;
|
let httpMock: HttpTestingController;
|
||||||
let authStore: AuthStore;
|
let authStore: AuthStore;
|
||||||
let routeParams: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
|
let routeParams: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
|
||||||
|
let notificationsStore: {
|
||||||
|
unreadCount: ReturnType<typeof signal<number>>;
|
||||||
|
notifications: ReturnType<typeof signal<any[]>>;
|
||||||
|
startPolling: ReturnType<typeof vi.fn>;
|
||||||
|
loadRecent: ReturnType<typeof vi.fn>;
|
||||||
|
markRead: ReturnType<typeof vi.fn>;
|
||||||
|
markAllRead: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
routeParams = new BehaviorSubject(convertToParamMap({ id: '5' }));
|
routeParams = new BehaviorSubject(convertToParamMap({ id: '5' }));
|
||||||
|
notificationsStore = {
|
||||||
|
unreadCount: signal(3),
|
||||||
|
notifications: signal([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
event: 'player_creation',
|
||||||
|
actorUserId: 9,
|
||||||
|
payload: { playerId: 21, playerName: 'Ada Lovelace' },
|
||||||
|
read: false,
|
||||||
|
createdAt: '2026-08-04T10:00:00.000Z',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
startPolling: vi.fn(),
|
||||||
|
loadRecent: vi.fn(),
|
||||||
|
markRead: vi.fn(),
|
||||||
|
markAllRead: vi.fn(),
|
||||||
|
};
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [Shell],
|
imports: [Shell],
|
||||||
providers: [
|
providers: [
|
||||||
provideHttpClient(),
|
provideHttpClient(),
|
||||||
provideHttpClientTesting(),
|
provideHttpClientTesting(),
|
||||||
provideRouter([]),
|
provideRouter([]),
|
||||||
|
{ provide: NotificationsStore, useValue: notificationsStore },
|
||||||
{
|
{
|
||||||
provide: ActivatedRoute,
|
provide: ActivatedRoute,
|
||||||
useValue: { paramMap: routeParams.asObservable() },
|
useValue: { paramMap: routeParams.asObservable() },
|
||||||
@@ -153,4 +181,58 @@ describe('Shell', () => {
|
|||||||
|
|
||||||
expect(httpMock.match((request) => request.url.includes('/teams/')).length).toBe(0);
|
expect(httpMock.match((request) => request.url.includes('/teams/')).length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('starts polling notifications for the routed team id', () => {
|
||||||
|
const fixture = TestBed.createComponent(Shell);
|
||||||
|
fixture.detectChanges();
|
||||||
|
httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush({ id: 5, name: 'Team A', alias: 'a', balance: 0 });
|
||||||
|
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([]);
|
||||||
|
|
||||||
|
expect(notificationsStore.startPolling).toHaveBeenCalledWith(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes the unread count from the notifications store', () => {
|
||||||
|
const fixture = TestBed.createComponent(Shell);
|
||||||
|
fixture.detectChanges();
|
||||||
|
httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush({ id: 5, name: 'Team A', alias: 'a', balance: 0 });
|
||||||
|
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([]);
|
||||||
|
|
||||||
|
expect((fixture.componentInstance as any).unreadCount()).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads recent notifications when the bell menu is opened', () => {
|
||||||
|
const fixture = TestBed.createComponent(Shell);
|
||||||
|
fixture.detectChanges();
|
||||||
|
httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush({ id: 5, name: 'Team A', alias: 'a', balance: 0 });
|
||||||
|
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([]);
|
||||||
|
|
||||||
|
(fixture.componentInstance as any).onNotificationsMenuOpened();
|
||||||
|
|
||||||
|
expect(notificationsStore.loadRecent).toHaveBeenCalledWith(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks a clicked notification as read and navigates to its target', () => {
|
||||||
|
const fixture = TestBed.createComponent(Shell);
|
||||||
|
fixture.detectChanges();
|
||||||
|
httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush({ id: 5, name: 'Team A', alias: 'a', balance: 0 });
|
||||||
|
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([]);
|
||||||
|
const navigateSpy = vi.spyOn(TestBed.inject(Router), 'navigate');
|
||||||
|
|
||||||
|
const item = notificationsStore.notifications()[0];
|
||||||
|
(fixture.componentInstance as any).onNotificationClick(item);
|
||||||
|
|
||||||
|
expect(notificationsStore.markRead).toHaveBeenCalledWith(5, 1);
|
||||||
|
expect(navigateSpy).toHaveBeenCalledWith(['/team', 5, 'members', 21]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks all notifications as read', () => {
|
||||||
|
const fixture = TestBed.createComponent(Shell);
|
||||||
|
fixture.detectChanges();
|
||||||
|
httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush({ id: 5, name: 'Team A', alias: 'a', balance: 0 });
|
||||||
|
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([]);
|
||||||
|
|
||||||
|
(fixture.componentInstance as any).onMarkAllRead();
|
||||||
|
|
||||||
|
expect(notificationsStore.markAllRead).toHaveBeenCalledWith(5);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, computed, inject } from '@angular/core';
|
import { Component, computed, inject, signal } from '@angular/core';
|
||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
import {
|
import {
|
||||||
ActivatedRoute,
|
ActivatedRoute,
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
RouterLinkActive,
|
RouterLinkActive,
|
||||||
RouterOutlet,
|
RouterOutlet,
|
||||||
} from '@angular/router';
|
} from '@angular/router';
|
||||||
|
import { MatBadgeModule } from '@angular/material/badge';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import { MatMenuModule } from '@angular/material/menu';
|
import { MatMenuModule } from '@angular/material/menu';
|
||||||
@@ -14,7 +15,14 @@ import { MatToolbarModule } from '@angular/material/toolbar';
|
|||||||
import { AuthStore } from '../../auth/auth-store';
|
import { AuthStore } from '../../auth/auth-store';
|
||||||
import { MyTeamsStore } from '../../team/my-teams-store';
|
import { MyTeamsStore } from '../../team/my-teams-store';
|
||||||
import { TeamStore } from '../../team/team-store';
|
import { TeamStore } from '../../team/team-store';
|
||||||
|
import { NotificationsStore } from '../../notifications/notifications-store';
|
||||||
|
import {
|
||||||
|
notificationIcon,
|
||||||
|
notificationLabel,
|
||||||
|
notificationTarget,
|
||||||
|
} from '../../notifications/notification-presentation';
|
||||||
import { UserTeamReference } from '../../../models/user-directory.model';
|
import { UserTeamReference } from '../../../models/user-directory.model';
|
||||||
|
import { NotificationItem } from '../../../models/notification.model';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-shell',
|
selector: 'app-shell',
|
||||||
@@ -26,6 +34,7 @@ import { UserTeamReference } from '../../../models/user-directory.model';
|
|||||||
MatIconModule,
|
MatIconModule,
|
||||||
MatMenuModule,
|
MatMenuModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
|
MatBadgeModule,
|
||||||
],
|
],
|
||||||
templateUrl: './shell.html',
|
templateUrl: './shell.html',
|
||||||
styleUrl: './shell.scss',
|
styleUrl: './shell.scss',
|
||||||
@@ -36,8 +45,12 @@ export class Shell {
|
|||||||
private readonly authStore = inject(AuthStore);
|
private readonly authStore = inject(AuthStore);
|
||||||
private readonly myTeamsStore = inject(MyTeamsStore);
|
private readonly myTeamsStore = inject(MyTeamsStore);
|
||||||
private readonly teamStore = inject(TeamStore);
|
private readonly teamStore = inject(TeamStore);
|
||||||
|
private readonly notificationsStore = inject(NotificationsStore);
|
||||||
|
|
||||||
protected readonly currentTeam = this.teamStore.team;
|
protected readonly currentTeam = this.teamStore.team;
|
||||||
|
protected readonly currentTeamId = signal<number | null>(null);
|
||||||
|
protected readonly unreadCount = this.notificationsStore.unreadCount;
|
||||||
|
protected readonly notifications = this.notificationsStore.notifications;
|
||||||
|
|
||||||
protected readonly myTeams = computed(() => {
|
protected readonly myTeams = computed(() => {
|
||||||
const seen = new Set<number>();
|
const seen = new Set<number>();
|
||||||
@@ -57,17 +70,13 @@ export class Shell {
|
|||||||
this.myTeamsStore.ensureLoaded(userId);
|
this.myTeamsStore.ensureLoaded(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A direct subscription (not `effect()` + `toSignal()`) so the initial
|
|
||||||
// team load happens synchronously during construction, exactly like
|
|
||||||
// `ensureLoaded` above — `ActivatedRoute.paramMap` always replays its
|
|
||||||
// current value synchronously to a new subscriber. This keeps the
|
|
||||||
// component's behavior deterministic and trivial to test: no signal
|
|
||||||
// effect scheduling to wait for.
|
|
||||||
this.route.paramMap.pipe(takeUntilDestroyed()).subscribe((params) => {
|
this.route.paramMap.pipe(takeUntilDestroyed()).subscribe((params) => {
|
||||||
const raw = params.get('id');
|
const raw = params.get('id');
|
||||||
const id = raw === null ? Number.NaN : Number(raw);
|
const id = raw === null ? Number.NaN : Number(raw);
|
||||||
if (Number.isInteger(id) && id > 0) {
|
if (Number.isInteger(id) && id > 0) {
|
||||||
this.teamStore.loadTeam(id);
|
this.teamStore.loadTeam(id);
|
||||||
|
this.currentTeamId.set(id);
|
||||||
|
this.notificationsStore.startPolling(id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -75,4 +84,33 @@ export class Shell {
|
|||||||
protected switchTeam(teamId: number): void {
|
protected switchTeam(teamId: number): void {
|
||||||
void this.router.navigate(['/team', teamId, 'overview']);
|
void this.router.navigate(['/team', teamId, 'overview']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected notificationLabel(item: NotificationItem): string {
|
||||||
|
return notificationLabel(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected notificationIcon(item: NotificationItem): string {
|
||||||
|
return notificationIcon(item.event);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onNotificationsMenuOpened(): void {
|
||||||
|
const teamId = this.currentTeamId();
|
||||||
|
if (teamId !== null) {
|
||||||
|
this.notificationsStore.loadRecent(teamId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onNotificationClick(item: NotificationItem): void {
|
||||||
|
const teamId = this.currentTeamId();
|
||||||
|
if (teamId === null) return;
|
||||||
|
this.notificationsStore.markRead(teamId, item.id);
|
||||||
|
void this.router.navigate(notificationTarget(item, teamId));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onMarkAllRead(): void {
|
||||||
|
const teamId = this.currentTeamId();
|
||||||
|
if (teamId !== null) {
|
||||||
|
this.notificationsStore.markAllRead(teamId);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { NotificationItem } from '../../models/notification.model';
|
||||||
|
import { notificationIcon, notificationLabel, notificationTarget } from './notification-presentation';
|
||||||
|
|
||||||
|
function item(overrides: Partial<NotificationItem>): NotificationItem {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
event: 'player_creation',
|
||||||
|
actorUserId: 9,
|
||||||
|
payload: {},
|
||||||
|
read: false,
|
||||||
|
createdAt: '2026-08-04T10:00:00.000Z',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('notification-presentation', () => {
|
||||||
|
it('describes an active-state change', () => {
|
||||||
|
expect(
|
||||||
|
notificationLabel(item({ event: 'player_active_update', payload: { playerName: 'Ada Lovelace', active: false } })),
|
||||||
|
).toBe('Ada Lovelace wurde deaktiviert');
|
||||||
|
expect(
|
||||||
|
notificationLabel(item({ event: 'player_active_update', payload: { playerName: 'Ada Lovelace', active: true } })),
|
||||||
|
).toBe('Ada Lovelace wurde aktiviert');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('describes a role change', () => {
|
||||||
|
expect(
|
||||||
|
notificationLabel(item({ event: 'player_team_role_update', payload: { playerName: 'Ada Lovelace' } })),
|
||||||
|
).toBe('Team-Rolle von Ada Lovelace wurde geändert');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('describes a new player', () => {
|
||||||
|
expect(
|
||||||
|
notificationLabel(item({ event: 'player_creation', payload: { playerName: 'Ada Lovelace' } })),
|
||||||
|
).toBe('Ada Lovelace wurde zum Team hinzugefügt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('describes share-link events', () => {
|
||||||
|
expect(notificationLabel(item({ event: 'public_access_enabled' }))).toBe('Der Freigabelink wurde aktiviert');
|
||||||
|
expect(notificationLabel(item({ event: 'public_access_rotated' }))).toBe('Der Freigabelink wurde erneuert');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('describes a new invite link', () => {
|
||||||
|
expect(notificationLabel(item({ event: 'user_invite_link_create' }))).toBe(
|
||||||
|
'Ein neuer Einladungslink wurde erstellt',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps each event to an icon', () => {
|
||||||
|
expect(notificationIcon('player_active_update')).toBe('person');
|
||||||
|
expect(notificationIcon('player_team_role_update')).toBe('badge');
|
||||||
|
expect(notificationIcon('player_creation')).toBe('person_add');
|
||||||
|
expect(notificationIcon('public_access_enabled')).toBe('link');
|
||||||
|
expect(notificationIcon('public_access_rotated')).toBe('link');
|
||||||
|
expect(notificationIcon('user_invite_link_create')).toBe('mail');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes player-related notifications to the member detail page', () => {
|
||||||
|
expect(notificationTarget(item({ event: 'player_creation', payload: { playerId: 21 } }), 5)).toEqual([
|
||||||
|
'/team', 5, 'members', 21,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes share-link notifications to the public-access settings page', () => {
|
||||||
|
expect(notificationTarget(item({ event: 'public_access_rotated' }), 5)).toEqual([
|
||||||
|
'/team', 5, 'more', 'public-access',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes invite-link notifications to the invite page', () => {
|
||||||
|
expect(notificationTarget(item({ event: 'user_invite_link_create' }), 5)).toEqual([
|
||||||
|
'/team', 5, 'more', 'invite',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { NotificationEvent, NotificationItem } from '../../models/notification.model';
|
||||||
|
|
||||||
|
export function notificationLabel(item: NotificationItem): string {
|
||||||
|
switch (item.event) {
|
||||||
|
case 'player_active_update':
|
||||||
|
return item.payload.active
|
||||||
|
? `${item.payload.playerName} wurde aktiviert`
|
||||||
|
: `${item.payload.playerName} wurde deaktiviert`;
|
||||||
|
case 'player_team_role_update':
|
||||||
|
return `Team-Rolle von ${item.payload.playerName} wurde geändert`;
|
||||||
|
case 'player_creation':
|
||||||
|
return `${item.payload.playerName} wurde zum Team hinzugefügt`;
|
||||||
|
case 'public_access_enabled':
|
||||||
|
return 'Der Freigabelink wurde aktiviert';
|
||||||
|
case 'public_access_rotated':
|
||||||
|
return 'Der Freigabelink wurde erneuert';
|
||||||
|
case 'user_invite_link_create':
|
||||||
|
return 'Ein neuer Einladungslink wurde erstellt';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notificationIcon(event: NotificationEvent): string {
|
||||||
|
switch (event) {
|
||||||
|
case 'player_active_update':
|
||||||
|
return 'person';
|
||||||
|
case 'player_team_role_update':
|
||||||
|
return 'badge';
|
||||||
|
case 'player_creation':
|
||||||
|
return 'person_add';
|
||||||
|
case 'public_access_enabled':
|
||||||
|
case 'public_access_rotated':
|
||||||
|
return 'link';
|
||||||
|
case 'user_invite_link_create':
|
||||||
|
return 'mail';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notificationTarget(item: NotificationItem, teamId: number): (string | number)[] {
|
||||||
|
switch (item.event) {
|
||||||
|
case 'player_active_update':
|
||||||
|
case 'player_team_role_update':
|
||||||
|
case 'player_creation':
|
||||||
|
return ['/team', teamId, 'members', item.payload.playerId ?? 0];
|
||||||
|
case 'public_access_enabled':
|
||||||
|
case 'public_access_rotated':
|
||||||
|
return ['/team', teamId, 'more', 'public-access'];
|
||||||
|
case 'user_invite_link_create':
|
||||||
|
return ['/team', teamId, 'more', 'invite'];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
import { NotificationsApi } from './notifications-api';
|
||||||
|
|
||||||
|
describe('NotificationsApi', () => {
|
||||||
|
let api: NotificationsApi;
|
||||||
|
let httpMock: HttpTestingController;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [provideHttpClient(), provideHttpClientTesting()],
|
||||||
|
});
|
||||||
|
api = TestBed.inject(NotificationsApi);
|
||||||
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => httpMock.verify());
|
||||||
|
|
||||||
|
it('loads a page of notifications for a team', () => {
|
||||||
|
api.loadNotifications(5, { page: 2, limit: 20 }).subscribe();
|
||||||
|
const request = httpMock.expectOne(`${environment.apiUrl}teams/5/notifications?page=2&limit=20`);
|
||||||
|
expect(request.request.method).toBe('GET');
|
||||||
|
request.flush({ data: [], page: 2, limit: 20, total: 0, hasNextPage: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads the unread count for a team', () => {
|
||||||
|
api.loadUnreadCount(5).subscribe();
|
||||||
|
const request = httpMock.expectOne(`${environment.apiUrl}teams/5/notifications/unread-count`);
|
||||||
|
expect(request.request.method).toBe('GET');
|
||||||
|
request.flush({ count: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks a single notification as read', () => {
|
||||||
|
api.markRead(5, 7).subscribe();
|
||||||
|
const request = httpMock.expectOne(`${environment.apiUrl}teams/5/notifications/7/read`);
|
||||||
|
expect(request.request.method).toBe('PATCH');
|
||||||
|
request.flush(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks all notifications as read', () => {
|
||||||
|
api.markAllRead(5).subscribe();
|
||||||
|
const request = httpMock.expectOne(`${environment.apiUrl}teams/5/notifications/read-all`);
|
||||||
|
expect(request.request.method).toBe('PATCH');
|
||||||
|
request.flush(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
import { NotificationPage, NotificationQuery } from '../../models/notification.model';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class NotificationsApi {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
|
||||||
|
loadNotifications(teamId: number, query: NotificationQuery): Observable<NotificationPage> {
|
||||||
|
const params = new HttpParams().set('page', query.page).set('limit', query.limit);
|
||||||
|
return this.http.get<NotificationPage>(`${environment.apiUrl}teams/${teamId}/notifications`, {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadUnreadCount(teamId: number): Observable<{ count: number }> {
|
||||||
|
return this.http.get<{ count: number }>(
|
||||||
|
`${environment.apiUrl}teams/${teamId}/notifications/unread-count`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
markRead(teamId: number, id: number): Observable<void> {
|
||||||
|
return this.http.patch<void>(`${environment.apiUrl}teams/${teamId}/notifications/${id}/read`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
markAllRead(teamId: number): Observable<void> {
|
||||||
|
return this.http.patch<void>(`${environment.apiUrl}teams/${teamId}/notifications/read-all`, {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
import { NotificationsApi } from './notifications-api';
|
||||||
|
import { NotificationsStore } from './notifications-store';
|
||||||
|
|
||||||
|
describe('NotificationsStore', () => {
|
||||||
|
let api: {
|
||||||
|
loadUnreadCount: ReturnType<typeof vi.fn>;
|
||||||
|
loadNotifications: ReturnType<typeof vi.fn>;
|
||||||
|
markRead: ReturnType<typeof vi.fn>;
|
||||||
|
markAllRead: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
let store: NotificationsStore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
api = {
|
||||||
|
loadUnreadCount: vi.fn().mockReturnValue(of({ count: 0 })),
|
||||||
|
loadNotifications: vi.fn().mockReturnValue(of({ data: [], page: 1, limit: 20, total: 0, hasNextPage: false })),
|
||||||
|
markRead: vi.fn().mockReturnValue(of(undefined)),
|
||||||
|
markAllRead: vi.fn().mockReturnValue(of(undefined)),
|
||||||
|
};
|
||||||
|
TestBed.configureTestingModule({ providers: [{ provide: NotificationsApi, useValue: api }] });
|
||||||
|
store = TestBed.inject(NotificationsStore);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('polls the unread count immediately when polling starts for a team', () => {
|
||||||
|
api.loadUnreadCount.mockReturnValue(of({ count: 4 }));
|
||||||
|
|
||||||
|
store.startPolling(10);
|
||||||
|
|
||||||
|
expect(api.loadUnreadCount).toHaveBeenCalledWith(10);
|
||||||
|
expect(store.unreadCount()).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not start a second poll loop for the same team id', () => {
|
||||||
|
store.startPolling(10);
|
||||||
|
store.startPolling(10);
|
||||||
|
|
||||||
|
expect(api.loadUnreadCount).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches polling to a newly routed team', () => {
|
||||||
|
store.startPolling(10);
|
||||||
|
api.loadUnreadCount.mockReturnValue(of({ count: 7 }));
|
||||||
|
|
||||||
|
store.startPolling(11);
|
||||||
|
|
||||||
|
expect(api.loadUnreadCount).toHaveBeenCalledWith(11);
|
||||||
|
expect(store.unreadCount()).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads the recent notification list', () => {
|
||||||
|
const data = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
event: 'player_creation' as const,
|
||||||
|
actorUserId: 9,
|
||||||
|
payload: { playerId: 21, playerName: 'Ada Lovelace' },
|
||||||
|
read: false,
|
||||||
|
createdAt: '2026-08-04T10:00:00.000Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
api.loadNotifications.mockReturnValue(of({ data, page: 1, limit: 20, total: 1, hasNextPage: false }));
|
||||||
|
|
||||||
|
store.loadRecent(10);
|
||||||
|
|
||||||
|
expect(api.loadNotifications).toHaveBeenCalledWith(10, { page: 1, limit: 20 });
|
||||||
|
expect(store.notifications()).toEqual(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks a notification as read locally and decrements the unread count', () => {
|
||||||
|
api.loadUnreadCount.mockReturnValue(of({ count: 3 }));
|
||||||
|
store.startPolling(10);
|
||||||
|
const data = [
|
||||||
|
{ id: 1, event: 'player_creation' as const, actorUserId: 9, payload: {}, read: false, createdAt: 'x' },
|
||||||
|
];
|
||||||
|
api.loadNotifications.mockReturnValue(of({ data, page: 1, limit: 20, total: 1, hasNextPage: false }));
|
||||||
|
store.loadRecent(10);
|
||||||
|
|
||||||
|
store.markRead(10, 1);
|
||||||
|
|
||||||
|
expect(api.markRead).toHaveBeenCalledWith(10, 1);
|
||||||
|
expect(store.notifications()[0].read).toBe(true);
|
||||||
|
expect(store.unreadCount()).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks all notifications as read locally and zeroes the unread count', () => {
|
||||||
|
api.loadUnreadCount.mockReturnValue(of({ count: 5 }));
|
||||||
|
store.startPolling(10);
|
||||||
|
const data = [
|
||||||
|
{ id: 1, event: 'player_creation' as const, actorUserId: 9, payload: {}, read: false, createdAt: 'x' },
|
||||||
|
];
|
||||||
|
api.loadNotifications.mockReturnValue(of({ data, page: 1, limit: 20, total: 1, hasNextPage: false }));
|
||||||
|
store.loadRecent(10);
|
||||||
|
|
||||||
|
store.markAllRead(10);
|
||||||
|
|
||||||
|
expect(api.markAllRead).toHaveBeenCalledWith(10);
|
||||||
|
expect(store.notifications()[0].read).toBe(true);
|
||||||
|
expect(store.unreadCount()).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { Injectable, inject, signal } from '@angular/core';
|
||||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
|
import { Subject, interval } from 'rxjs';
|
||||||
|
import { startWith, switchMap } from 'rxjs/operators';
|
||||||
|
import { NotificationItem } from '../../models/notification.model';
|
||||||
|
import { NotificationsApi } from './notifications-api';
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 30000;
|
||||||
|
const DROPDOWN_PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class NotificationsStore {
|
||||||
|
private readonly api = inject(NotificationsApi);
|
||||||
|
|
||||||
|
private readonly unreadCountSignal = signal(0);
|
||||||
|
private readonly notificationsSignal = signal<NotificationItem[]>([]);
|
||||||
|
private readonly loadingSignal = signal(false);
|
||||||
|
private readonly pollingTeamId = signal<number | null>(null);
|
||||||
|
private readonly pollRequests = new Subject<number>();
|
||||||
|
|
||||||
|
readonly unreadCount = this.unreadCountSignal.asReadonly();
|
||||||
|
readonly notifications = this.notificationsSignal.asReadonly();
|
||||||
|
readonly loading = this.loadingSignal.asReadonly();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.pollRequests
|
||||||
|
.pipe(
|
||||||
|
switchMap((teamId) =>
|
||||||
|
interval(POLL_INTERVAL_MS).pipe(
|
||||||
|
startWith(-1),
|
||||||
|
switchMap(() => this.api.loadUnreadCount(teamId)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
takeUntilDestroyed(),
|
||||||
|
)
|
||||||
|
.subscribe((result) => this.unreadCountSignal.set(result.count));
|
||||||
|
}
|
||||||
|
|
||||||
|
startPolling(teamId: number): void {
|
||||||
|
if (this.pollingTeamId() === teamId) return;
|
||||||
|
this.pollingTeamId.set(teamId);
|
||||||
|
this.pollRequests.next(teamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadRecent(teamId: number): void {
|
||||||
|
this.loadingSignal.set(true);
|
||||||
|
this.api.loadNotifications(teamId, { page: 1, limit: DROPDOWN_PAGE_SIZE }).subscribe({
|
||||||
|
next: (page) => {
|
||||||
|
this.notificationsSignal.set(page.data);
|
||||||
|
this.loadingSignal.set(false);
|
||||||
|
},
|
||||||
|
error: () => this.loadingSignal.set(false),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
markRead(teamId: number, id: number): void {
|
||||||
|
this.api.markRead(teamId, id).subscribe(() => {
|
||||||
|
this.notificationsSignal.update((items) =>
|
||||||
|
items.map((item) => (item.id === id ? { ...item, read: true } : item)),
|
||||||
|
);
|
||||||
|
this.unreadCountSignal.update((count) => Math.max(0, count - 1));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
markAllRead(teamId: number): void {
|
||||||
|
this.api.markAllRead(teamId).subscribe(() => {
|
||||||
|
this.notificationsSignal.update((items) => items.map((item) => ({ ...item, read: true })));
|
||||||
|
this.unreadCountSignal.set(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<div class="notifications-page">
|
||||||
|
<h1>Benachrichtigungen</h1>
|
||||||
|
|
||||||
|
@if (items().length === 0 && !loading()) {
|
||||||
|
<p class="notifications-page__empty">Keine Benachrichtigungen vorhanden.</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<mat-nav-list>
|
||||||
|
@for (item of items(); track item.id) {
|
||||||
|
<a
|
||||||
|
mat-list-item
|
||||||
|
class="notifications-page__item"
|
||||||
|
[class.notifications-page__item--unread]="!item.read"
|
||||||
|
(click)="onItemClick(item)"
|
||||||
|
>
|
||||||
|
<mat-icon matListItemIcon>{{ notificationIcon(item) }}</mat-icon>
|
||||||
|
<span matListItemTitle>{{ notificationLabel(item) }}</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</mat-nav-list>
|
||||||
|
|
||||||
|
@if (loading()) {
|
||||||
|
<mat-spinner diameter="32" class="notifications-page__spinner" />
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (hasNextPage() && !loading()) {
|
||||||
|
<button mat-button (click)="loadMore()">Weitere laden</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
.notifications-page {
|
||||||
|
padding: 1rem;
|
||||||
|
|
||||||
|
&__empty {
|
||||||
|
color: var(--mat-sys-on-surface-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__item--unread {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__spinner {
|
||||||
|
margin: 1rem auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { ActivatedRoute, ParamMap, Router, convertToParamMap, provideRouter } from '@angular/router';
|
||||||
|
import { BehaviorSubject, of } from 'rxjs';
|
||||||
|
import { Notifications } from './notifications';
|
||||||
|
import { NotificationsApi } from '../../../core/notifications/notifications-api';
|
||||||
|
import { NotificationsStore } from '../../../core/notifications/notifications-store';
|
||||||
|
import { NotificationItem } from '../../../models/notification.model';
|
||||||
|
|
||||||
|
describe('Notifications', () => {
|
||||||
|
let routeParams: BehaviorSubject<ParamMap>;
|
||||||
|
let fixture: ComponentFixture<Notifications>;
|
||||||
|
let api: { loadNotifications: ReturnType<typeof vi.fn> };
|
||||||
|
let store: { markRead: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
const item: NotificationItem = {
|
||||||
|
id: 1,
|
||||||
|
event: 'player_creation',
|
||||||
|
actorUserId: 9,
|
||||||
|
payload: { playerId: 21, playerName: 'Ada Lovelace' },
|
||||||
|
read: false,
|
||||||
|
createdAt: '2026-08-04T10:00:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
routeParams = new BehaviorSubject(convertToParamMap({ id: '5' }));
|
||||||
|
api = { loadNotifications: vi.fn() };
|
||||||
|
store = { markRead: vi.fn() };
|
||||||
|
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [Notifications],
|
||||||
|
providers: [
|
||||||
|
provideRouter([]),
|
||||||
|
{ provide: NotificationsApi, useValue: api },
|
||||||
|
{ provide: NotificationsStore, useValue: store },
|
||||||
|
{ provide: ActivatedRoute, useValue: { parent: { paramMap: routeParams } } },
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(Notifications);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads the first page for the routed team id', () => {
|
||||||
|
api.loadNotifications.mockReturnValue(of({ data: [item], page: 1, limit: 20, total: 1, hasNextPage: false }));
|
||||||
|
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(api.loadNotifications).toHaveBeenCalledWith(5, { page: 1, limit: 20 });
|
||||||
|
expect((fixture.componentInstance as any).items()).toEqual([item]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads the next page and appends results', () => {
|
||||||
|
api.loadNotifications
|
||||||
|
.mockReturnValueOnce(of({ data: [item], page: 1, limit: 20, total: 21, hasNextPage: true }))
|
||||||
|
.mockReturnValueOnce(of({ data: [{ ...item, id: 2 }], page: 2, limit: 20, total: 21, hasNextPage: false }));
|
||||||
|
|
||||||
|
fixture.detectChanges();
|
||||||
|
(fixture.componentInstance as any).loadMore();
|
||||||
|
|
||||||
|
expect(api.loadNotifications).toHaveBeenLastCalledWith(5, { page: 2, limit: 20 });
|
||||||
|
expect((fixture.componentInstance as any).items().length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks a clicked item as read and navigates to its target', () => {
|
||||||
|
api.loadNotifications.mockReturnValue(of({ data: [item], page: 1, limit: 20, total: 1, hasNextPage: false }));
|
||||||
|
fixture.detectChanges();
|
||||||
|
const router = TestBed.inject(Router);
|
||||||
|
const navigateSpy = vi.spyOn(router, 'navigate');
|
||||||
|
|
||||||
|
(fixture.componentInstance as any).onItemClick(item);
|
||||||
|
|
||||||
|
expect(store.markRead).toHaveBeenCalledWith(5, 1);
|
||||||
|
expect(navigateSpy).toHaveBeenCalledWith(['/team', 5, 'members', 21]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { Component, DestroyRef, OnInit, inject, signal } from '@angular/core';
|
||||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatListModule } from '@angular/material/list';
|
||||||
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
|
import { NotificationItem } from '../../../models/notification.model';
|
||||||
|
import { NotificationsApi } from '../../../core/notifications/notifications-api';
|
||||||
|
import { NotificationsStore } from '../../../core/notifications/notifications-store';
|
||||||
|
import {
|
||||||
|
notificationIcon,
|
||||||
|
notificationLabel,
|
||||||
|
notificationTarget,
|
||||||
|
} from '../../../core/notifications/notification-presentation';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-notifications',
|
||||||
|
imports: [MatButtonModule, MatIconModule, MatListModule, MatProgressSpinnerModule],
|
||||||
|
templateUrl: './notifications.html',
|
||||||
|
styleUrl: './notifications.scss',
|
||||||
|
})
|
||||||
|
export class Notifications implements OnInit {
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
private readonly api = inject(NotificationsApi);
|
||||||
|
private readonly notificationsStore = inject(NotificationsStore);
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
|
protected readonly items = signal<NotificationItem[]>([]);
|
||||||
|
protected readonly loading = signal(false);
|
||||||
|
protected readonly hasNextPage = signal(false);
|
||||||
|
|
||||||
|
private teamId: number | null = null;
|
||||||
|
private page = 1;
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
const parentRoute = this.route.parent;
|
||||||
|
if (!parentRoute) return;
|
||||||
|
|
||||||
|
parentRoute.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => {
|
||||||
|
const raw = params.get('id');
|
||||||
|
const id = raw === null ? Number.NaN : Number(raw);
|
||||||
|
if (Number.isInteger(id) && id > 0 && id !== this.teamId) {
|
||||||
|
this.teamId = id;
|
||||||
|
this.page = 1;
|
||||||
|
this.items.set([]);
|
||||||
|
this.hasNextPage.set(false);
|
||||||
|
this.loadPage();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected notificationLabel(item: NotificationItem): string {
|
||||||
|
return notificationLabel(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected notificationIcon(item: NotificationItem): string {
|
||||||
|
return notificationIcon(item.event);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected loadMore(): void {
|
||||||
|
this.page += 1;
|
||||||
|
this.loadPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onItemClick(item: NotificationItem): void {
|
||||||
|
if (this.teamId === null) return;
|
||||||
|
const teamId = this.teamId;
|
||||||
|
this.notificationsStore.markRead(teamId, item.id);
|
||||||
|
this.items.update((current) =>
|
||||||
|
current.map((entry) => (entry.id === item.id ? { ...entry, read: true } : entry)),
|
||||||
|
);
|
||||||
|
void this.router.navigate(notificationTarget(item, teamId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadPage(): void {
|
||||||
|
if (this.teamId === null) return;
|
||||||
|
const teamId = this.teamId;
|
||||||
|
this.loading.set(true);
|
||||||
|
this.api.loadNotifications(teamId, { page: this.page, limit: PAGE_SIZE }).subscribe({
|
||||||
|
next: (result) => {
|
||||||
|
this.items.update((current) => [...current, ...result.data]);
|
||||||
|
this.hasNextPage.set(result.hasNextPage);
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
error: () => this.loading.set(false),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
export type NotificationEvent =
|
||||||
|
| 'player_active_update'
|
||||||
|
| 'player_team_role_update'
|
||||||
|
| 'player_creation'
|
||||||
|
| 'public_access_enabled'
|
||||||
|
| 'public_access_rotated'
|
||||||
|
| 'user_invite_link_create';
|
||||||
|
|
||||||
|
export interface NotificationPayload {
|
||||||
|
playerId?: number;
|
||||||
|
playerName?: string;
|
||||||
|
active?: boolean;
|
||||||
|
teamRoleId?: number;
|
||||||
|
teamName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationItem {
|
||||||
|
id: number;
|
||||||
|
event: NotificationEvent;
|
||||||
|
actorUserId: number;
|
||||||
|
payload: NotificationPayload;
|
||||||
|
read: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationQuery {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationPage {
|
||||||
|
data: NotificationItem[];
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
total: number;
|
||||||
|
hasNextPage: boolean;
|
||||||
|
}
|
||||||
@@ -37,4 +37,9 @@ body {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.shell-notification-menu {
|
||||||
|
min-width: 420px !important;
|
||||||
|
max-width: calc(100vw - 24px);
|
||||||
|
}
|
||||||
/* You can add global styles to this file, and also import other style files */
|
/* You can add global styles to this file, and also import other style files */
|
||||||
|
|||||||
Reference in New Issue
Block a user