From df634e76019ee2c61ebfe845ccc18c02d5a03e72 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Tue, 4 Aug 2026 18:10:40 +0200 Subject: [PATCH 01/19] fix: update stale assertion for unconditional start/finish logging runDueRecurringTransactions() always logs a start/finish marker for observability, even when nothing is due, but the "does nothing" test still asserted logger.info was never called. Pre-existing baseline failure, unrelated to the notification-center work about to start. --- .../recurring-transactions.scheduler.spec.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/myteamwallet_backend/src/recurring-transactions/recurring-transactions.scheduler.spec.ts b/myteamwallet_backend/src/recurring-transactions/recurring-transactions.scheduler.spec.ts index dba8d37..8387bab 100644 --- a/myteamwallet_backend/src/recurring-transactions/recurring-transactions.scheduler.spec.ts +++ b/myteamwallet_backend/src/recurring-transactions/recurring-transactions.scheduler.spec.ts @@ -42,7 +42,12 @@ describe('RecurringTransactionsScheduler', () => { await scheduler.runDueRecurringTransactions(); 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 () => { From 6bda24ec9f74ae4b367789ba2992e2e61e33b3ac Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Tue, 4 Aug 2026 17:10:46 +0200 Subject: [PATCH 02/19] docs: add notification center design spec Design for a team-scoped notification center (bell icon, dropdown, full history page) covering player/role/share-link/invite-link events, decoupled via @nestjs/event-emitter from a central notifications module. --- .../2026-08-04-notification-center-design.md | 210 ++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-04-notification-center-design.md diff --git a/docs/superpowers/specs/2026-08-04-notification-center-design.md b/docs/superpowers/specs/2026-08-04-notification-center-design.md new file mode 100644 index 0000000..d428920 --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-notification-center-design.md @@ -0,0 +1,210 @@ +# Notification Center (Team-Benachrichtigungen) + +Status: approved +Datum: 2026-08-04 + +## Kontext + +TeamWallet protokolliert bereits viele team-relevante Ereignisse (Spieler hinzugefügt/deaktiviert, +Rollenänderung, Einladungslink erstellt/eingelöst) über den globalen `LoggingService` in `LogEntry` +— aber dieses Log ist admin-only, global (kein Team-Bezug, kein `teamId`), und kennt keinen +Lesestatus pro Nutzer. Ein normaler Spieler erfährt aktuell nicht, wenn in seinem Team etwas +passiert (z.B. er selbst deaktiviert wurde oder der Freigabelink rotiert wurde), außer er merkt es +zufällig. + +Ziel: Ein Benachrichtigungscenter (Glocke oben rechts im Header mit Ungelesen-Badge und Dropdown), +das aktiven Team-Mitgliedern mit Login relevante Team-Ereignisse anzeigt, mit Sprung zur +betroffenen Stelle und einer Vollansicht-Seite für die Historie. + +## Entscheidungen aus dem Brainstorming + +- **Abgedeckte Events (v1)**: Spieler hinzugefügt/deaktiviert/reaktiviert, Team-Rolle geändert, + Freigabelink aktiviert/rotiert, Einladungslink erstellt. Das Einlösen eines Einladungslinks selbst + löst **keine** eigene Benachrichtigung aus (der Aufruf ist unauthentifiziert, reine + Token-Validierung, oft nur eine Vorschau ohne tatsächlichen Beitritt) — der tatsächliche Beitritt + wird stattdessen bereits durch das Event "Spieler hinzugefügt" abgedeckt. +- **Empfänger**: alle aktiven Player eines Teams mit verknüpftem User-Account (analog zur + Mitgliedschaftsprüfung in `TeamAccessService`), abzüglich des Verursachers — wer eine Aktion selbst + auslöst, bekommt dafür keine eigene Benachrichtigung. +- **Zustellung**: kein Echtzeit-Push (keine WebSocket/SSE-Infrastruktur im Projekt vorhanden). + Stattdessen Polling des Ungelesen-Zählers alle 30s, passend zum bestehenden HTTP+Signal-Store-Muster + des Frontends. +- **Datenmodell**: Fan-out beim Schreiben (`Notification` + eine `NotificationRecipient`-Zeile pro + Empfänger mit eigenem Lesestatus) statt eines zentralen Events mit Read-Join-Tabelle oder einer + Erweiterung von `LogEntry` — bei den hier üblichen kleinen Teamgrößen (typischerweise < 30 Spieler) + ist der Schreib-Overhead irrelevant, die Leseabfragen (Ungelesen zählen, Liste je Nutzer, als + gelesen markieren) bleiben dafür trivial. +- **Entkopplung**: Domain-Services lösen Business-Logik weiterhin unverändert aus und feuern danach + nur ein Domain-Event über `@nestjs/event-emitter` (`EventEmitter2`) — ein zentrales + `NotificationsModule` lauscht auf diese Events und legt die Benachrichtigungen an. Domain-Services + kennen `NotificationsService` nicht; neue Benachrichtigungstypen erfordern nur einen neuen Listener, + keine Änderung an bestehenden Services. +- **Klick-Verhalten**: Klick auf eine Benachrichtigung navigiert zur betroffenen Stelle (z.B. + Mitgliederliste) und markiert sie als gelesen. +- **Vollansicht**: eigene, team-gescopte Seite mit paginierter Historie zusätzlich zum Dropdown + (letzte 20 Einträge). + +## Architektur / Komponenten + +### 1. Backend: neues Modul `notifications/` + +**Neue Entities** (`notifications/entities/`): + +- `Notification`: `id`, `team` (ManyToOne `Team`), `event` (`NOTIFICATION_EVENT`-String-Union, eigene + Typdatei analog `logging-event.type.ts`), `actorUserId`, `payload` (`text`-Spalte, JSON-serialisiert + — enthält je Event die Felder für Anzeigetext + Deep-Link, z.B. `{ playerId, playerName }`), + `createdAt`. +- `NotificationRecipient`: `id`, `notification` (ManyToOne `Notification`, `onDelete: 'CASCADE'`), + `userId`, `read` (boolean, default `false`), `readAt` (nullable `Date`). Index auf + `(userId, read, createdAt via notification)` bzw. praktisch auf `(userId, notificationId)` und + zusätzlich ein Index auf `notification.team` + `userId` für die gefilterte Team-Ansicht. + +**Domain-Events** (`notifications/events/`): reine Datenklassen, ein File pro Event-Familie — +`player-active-changed.event.ts`, `player-role-changed.event.ts`, `player-created.event.ts`, +`share-link-changed.event.ts`, `invite-link-created.event.ts`. Jede trägt mindestens `teamId`, +`actorUserId`, event-spezifische IDs/Namen für Text und Deep-Link. + +**Emit-Punkte** (jeweils ein zusätzlicher `this.eventEmitter.emit(...)`-Aufruf **nach** erfolgreichem +Abschluss der bestehenden Logik, ohne deren Ablauf/Transaktion zu verändern): + +- `team-members.service.ts` `setActive()` — nach `return this.dataSource.transaction(...)` erfolgreich + resolved hat (Emit außerhalb des Transaktions-Callbacks, damit bei Rollback nie ein Event feuert). +- `team-members.service.ts` `setTeamRole()` — analog. +- `teams.service.ts` Player-Erstellung (Stelle, die aktuell `player_creation` loggt) — analog. +- `public-team-access.service.ts` `setEnabled()` / `rotate()` — hier gibt es aktuell **keine** + Transaktion (nur `repository.save()`), Emit direkt nach erfolgreichem `save()`. Zusätzlich werden + hier neue `LOGEVENT`-Werte `public_access_enabled`, `public_access_rotated` ergänzt (bisher fehlt an + dieser Stelle jegliches Logging) und ein `LoggingService.info()`-Aufruf ergänzt, analog zu den + anderen Services. +- `auth.service.ts` `createTeamInvite()` — nach dem bestehenden `logger.info(...)`-Aufruf, mit dem + echten `actorUserId`-Parameter der Methode (nicht dem im bestehenden Log hart codierten `userId: 0` + — dieser bestehende Log-Aufruf selbst bleibt unverändert, das Event nutzt aber den korrekten Actor). + +**`NotificationsListener`** (`notifications/notifications.listener.ts`): ein `@OnEvent(...)`-Handler +pro Event-Typ, baut Anzeigetext + Deep-Link-Payload und ruft `NotificationsService.create(...)` auf. +Fehler im Handler werden abgefangen und via `LoggingService.error()` protokolliert statt propagiert — +ein Fehler beim Anlegen der Benachrichtigung darf die bereits committete Business-Aktion nicht +nachträglich als fehlgeschlagen erscheinen lassen. + +**`NotificationsService`**: + +- `create(teamId, event, actorUserId, payload)` — ermittelt Empfänger über dasselbe Query-Muster wie + `TeamAccessService`/`PublicTeamAccessService` (aktive `Player` mit `user.id IS NOT NULL` für das + Team, `actorUserId` ausgeschlossen), legt `Notification` + `NotificationRecipient`-Zeilen an. +- `listForUser(userId, teamId, cursor, limit)` — für Dropdown und Vollansicht. +- `getUnreadCount(userId, teamId)`. +- `markRead(recipientId, userId)` — prüft Eigentümerschaft der Recipient-Zeile. +- `markAllRead(userId, teamId)`. + +**`NotificationsController`** (`version: '1'`, `AuthGuard('jwt')` + `TeamAccessService.assertMember`): + +- `GET teams/:teamId/notifications?cursor=&limit=` +- `GET teams/:teamId/notifications/unread-count` +- `PATCH teams/:teamId/notifications/:id/read` +- `PATCH teams/:teamId/notifications/read-all` + +**Retention**: `NotificationRetentionScheduler`, `@Cron(CronExpression.EVERY_DAY_AT_5AM)` (zeitlich +versetzt zu `LogRetentionScheduler` um 4 Uhr), löscht `Notification`-Zeilen älter als +`app.logRetentionDays` (gleiche Config wiederverwendet, kein neuer Config-Wert nötig) — +`NotificationRecipient` fällt per `onDelete: 'CASCADE'` automatisch mit weg. Gleiches +Fehlerbehandlung-Muster wie `LogRetentionScheduler` (try/catch, `logger.info`/`logger.error` mit +`log_retention_cleanup_run`-artigen neuen Events `notification_retention_cleanup_run`/`_fail`). + +**Neue Dependency**: `@nestjs/event-emitter`, registriert via `EventEmitterModule.forRoot()` in +`app.module.ts` (neben dem bestehenden `ScheduleModule.forRoot()`). + +**Registrierung**: `NotificationsModule` in `src/app.module.ts` ergänzen (analog +`CashboxExportModule`), exportiert `NotificationsService`/`EventEmitter2`-Nutzung für die +Domain-Services (bzw. Domain-Services importieren direkt `EventEmitterModule`/`EventEmitter2` aus +`@nestjs/event-emitter`, kein Import von `NotificationsModule` nötig — das ist der Kern der +Entkopplung). + +**Migration**: eine neue TypeORM-Migration in `src/database/migrations` für `notification` und +`notification_recipient` inkl. der oben genannten Indizes. + +**Neue `LOGEVENT`-Werte** in `logging-event.type.ts`: `public_access_enabled`, +`public_access_rotated`, `notification_retention_cleanup_run`, `notification_retention_cleanup_run_fail`. + +### 2. Frontend + +**Bell im Header** (`core/layout/shell/shell.html`/`shell.ts`): `mat-icon-button` mit +`notifications`-Icon, `matBadge` für den Ungelesen-Zähler (ausgeblendet bei 0), positioniert links +neben dem bestehenden Team-Switcher in der `shell-header`-Toolbar, `[matMenuTriggerFor]="notificationMenu"` +— gleiches `MatMenuModule`-Pattern wie der bestehende Team-Switcher. + +**Dropdown** (`mat-menu`): Liste der letzten 20 Benachrichtigungen (Icon je Event-Typ, Text, relative +Zeit via Angular `DatePipe`/eigenes Pipe), "Alle als gelesen markieren"-Button oben, "Alle +anzeigen"-Link unten zur Vollansicht-Seite. Klick auf einen Eintrag: `markRead()` + Router-Navigation +zum Deep-Link (z.B. `/team/:teamId/members` mit Query-Param oder Fragment zum Hervorheben des +betroffenen Spielers, je nach Event-Typ auch andere Zielrouten wie die Team-Einstellungen für +Freigabelink-Events). + +**Vollansicht-Seite** (`features/notifications/notifications.ts/html`, Route +`/team/:teamId/notifications`): einfache paginierte Liste (kein ag-grid nötig, da kein +Admin-Filterbedarf wie bei der Logs-Seite), gleiche Klick-Navigation wie im Dropdown. + +**State**: neuer `NotificationsStore` (Signal-Service im Team-Kontext, analog `MyTeamsStore`) hält +`notifications`- und `unreadCount`-Signals. Pollt `unread-count` alle 30s via `interval()` + +`switchMap`, solange ein Team aktiv ist; die volle Liste wird nur bei Dropdown-Öffnen bzw. +Seitenaufruf der Vollansicht geladen (kein Dauer-Polling der ganzen Liste). + +**Neues Model** (`models/notification.model.ts`): `NotificationEvent`-Union (Frontend-seitiges +Gegenstück zu `NOTIFICATION_EVENT`), `NotificationDto`, mit Mapping-Funktion Event-Typ → Icon/Text/ +Zielroute (zentral an einer Stelle, damit neue Event-Typen nicht über die Komponente verstreut +behandelt werden müssen). + +## Fehlerbehandlung + +- Notification-Erstellung schlägt fehl → wird im `NotificationsListener` abgefangen und geloggt, + bricht die ursprüngliche (bereits erfolgreich abgeschlossene) Aktion nicht nachträglich ab. +- `markRead`/`markAllRead` auf fremde bzw. nicht existente Recipient-Zeile → `NotFoundException` + bzw. stiller No-Op bei `markAllRead` (nichts zu markieren ist kein Fehlerfall). +- Polling-Request schlägt fehl (Netzwerk) → Store behält den letzten bekannten Zählerstand, kein + Fehler-Toast (nicht kritisch genug für eine Nutzerunterbrechung). + +## Testing + +**Backend**: + +- `notifications.service.spec.ts` — Empfänger-Ermittlung (aktive Player mit User, Actor + ausgeschlossen), Fan-out-Erstellung, `listForUser`/`getUnreadCount`-Filterung nach `teamId`+`userId`, + `markRead`-Eigentümerprüfung, `markAllRead`. +- `notifications.listener.spec.ts` — pro Event-Typ: korrekter Aufruf von + `NotificationsService.create` mit erwartetem Payload; Fehler im Service wird abgefangen und geloggt, + nicht weitergeworfen. +- Bestehende Specs von `team-members.service.ts`, `public-team-access.service.ts`, `auth.service.ts` + um Assertions ergänzt, dass das jeweilige Domain-Event nach erfolgreichem Abschluss emittiert wird + (gemockter `EventEmitter2`), und bei Rollback/Fehler **nicht** emittiert wird. +- `notification-retention.scheduler.spec.ts` — analog `log-retention.scheduler.spec.ts`. +- `notifications.http.spec.ts` — Auth/Team-Membership erforderlich, Pagination, `read`/`read-all`. + +**Frontend**: + +- `notifications-store.spec.ts` — Polling-Intervall, Unread-Count-Update, Laden der Liste. +- `notifications-api.spec.ts` — korrekte HTTP-Calls. +- Bell/Dropdown-Komponenten-Spec — Badge-Anzeige bei >0, Klick markiert gelesen + navigiert, + "Alle als gelesen"-Button. +- Vollansicht-Seiten-Spec — Pagination, Klick-Navigation. + +## Bewusst nicht enthalten (YAGNI) + +- Kein Echtzeit-Push (WebSocket/SSE) — Polling reicht für den Anwendungsfall und vermeidet neue + Infrastruktur. +- Keine Benachrichtigung beim reinen Einlösen/Validieren eines Einladungslinks (unauthentifiziert, + kein verlässlicher Actor, oft nur Vorschau ohne Beitritt). +- Keine Benachrichtigungseinstellungen pro Nutzer (z.B. E-Mail-Digest, Stummschalten einzelner + Event-Typen) — alle aktiven Mitglieder mit Login sehen alle abgedeckten Events. +- Keine rollenbasierte Einschränkung der Empfänger (z.B. "nur Manager") — alle aktiven Mitglieder mit + Login. +- Keine Browser-Push-Benachrichtigungen (Service Worker/Web Push) außerhalb der App. + +## Verifikation + +- **Backend-Unit-Tests**: siehe oben, alle grün, `nest build` sauber. +- **Frontend-Unit-Tests**: siehe oben, alle grün, `tsc --noEmit` + `ng build` sauber. +- **Manuell**: Backend + Frontend lokal starten, mit zwei Test-Usern im selben Team: User A + deaktiviert einen Spieler, User B (nicht der deaktivierte Spieler selbst, aber Mitglied) sieht die + Badge-Zahl nach kurzer Zeit (Polling) hochgehen, öffnet das Dropdown, sieht den Eintrag, klickt + darauf → Navigation zur Mitgliederliste + Eintrag als gelesen markiert, Badge sinkt. Gleiches + stichprobenartig für Rollenänderung, Freigabelink-Rotation und Einladungslink-Erstellung + durchspielen. Vollansicht-Seite aufrufen und Pagination über mehrere erzeugte Einträge prüfen. From ecfa847d2a831eaa13281f53bc2332ec1f02b7c2 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Tue, 4 Aug 2026 18:18:20 +0200 Subject: [PATCH 03/19] docs: add notification center implementation plan Detailed task-by-task TDD plan for the notification center feature, derived from the approved design spec. --- .../plans/2026-08-04-notification-center.md | 3287 +++++++++++++++++ 1 file changed, 3287 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-04-notification-center.md diff --git a/docs/superpowers/plans/2026-08-04-notification-center.md b/docs/superpowers/plans/2026-08-04-notification-center.md new file mode 100644 index 0000000..cf77376 --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-notification-center.md @@ -0,0 +1,3287 @@ +# Notification Center Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a team-scoped notification center (bell icon + dropdown + full history page) that tells active team members with a login about player/role/share-link/invite-link events in their team. + +**Architecture:** Domain services emit plain `@nestjs/event-emitter` events after their existing business logic commits successfully; a new, decoupled `NotificationsModule` listens for those events and fans them out into per-recipient `Notification`/`NotificationRecipient` rows (Postgres, TypeORM). The Angular frontend polls an unread-count endpoint from a new `NotificationsStore`, and a bell icon in the app shell shows a `mat-menu` dropdown plus links to a dedicated full-history page. + +**Tech Stack:** NestJS 9 + TypeORM 0.3 + Postgres + Jest (backend), Angular 21 + Angular Material 21 + RxJS + Vitest (frontend). + +## Global Constraints + +- Design spec: `docs/superpowers/specs/2026-08-04-notification-center-design.md` — every requirement in this plan traces back to it. +- Covered events (exactly these six, no more): `player_active_update`, `player_team_role_update`, `player_creation`, `public_access_enabled`, `public_access_rotated`, `user_invite_link_create`. No notification on invite-link *validation* (unauthenticated, no reliable actor) and none on public-access *disable*. +- Recipients: active `Player` rows with a linked `User` for the team, minus the actor who triggered the event. No role-based filtering. +- No real-time push (no WebSocket/SSE) — unread count is polled every 30s from the frontend. +- Domain services must stay decoupled from `NotificationsService`: they only depend on `EventEmitter2` (global module, no explicit import needed) and plain event classes. +- A notification-creation failure must never fail or roll back the business action that triggered it — listener errors are caught and logged, never rethrown. +- Backend tests: Jest, `*.spec.ts`, plain constructor-injection mocking (no `TestingModule`) for services/schedulers, `Test.createTestingModule` + `supertest` only for HTTP-boundary specs (`*.http.spec.ts`). Frontend tests: Vitest with Jasmine-compatible globals (`describe`/`it`/`expect`), mocks built with `vi.fn()` (never `jest.fn()`). +- All new user-facing strings are German, matching the rest of the app. + +--- + +## Task 1: Notification data model (entities + migration) + +**Files:** +- Create: `myteamwallet_backend/src/notifications/model/notification-event.type.ts` +- Create: `myteamwallet_backend/src/notifications/entities/notification.entity.ts` +- Create: `myteamwallet_backend/src/notifications/entities/notification-recipient.entity.ts` +- Create: `myteamwallet_backend/src/database/migrations/1785600000000-AddNotificationTables.ts` +- Create: `myteamwallet_backend/src/database/migrations/AddNotificationTables.spec.ts` + +**Interfaces:** +- Produces: `NOTIFICATION_EVENT` type + `NOTIFICATION_EVENT_VALUES` array (consumed by Tasks 2, 4, 6, 7, 8, 9). `Notification` entity (`id`, `team`, `event`, `actorUserId`, `payload: string`, `createdAt`). `NotificationRecipient` entity (`id`, `notification`, `userId`, `read`, `readAt`). + +- [ ] **Step 1: Write the failing migration spec** + +```typescript +// myteamwallet_backend/src/database/migrations/AddNotificationTables.spec.ts +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"']); + }); +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run (from `myteamwallet_backend`): `npm test -- AddNotificationTables` +Expected: FAIL — `Cannot find module './1785600000000-AddNotificationTables'` + +- [ ] **Step 3: Create the `NOTIFICATION_EVENT` type** + +```typescript +// myteamwallet_backend/src/notifications/model/notification-event.type.ts +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', +]; +``` + +- [ ] **Step 4: Create the entities** + +```typescript +// myteamwallet_backend/src/notifications/entities/notification.entity.ts +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; +} +``` + +```typescript +// myteamwallet_backend/src/notifications/entities/notification-recipient.entity.ts +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; +} +``` + +- [ ] **Step 5: Write the migration** + +```typescript +// myteamwallet_backend/src/database/migrations/1785600000000-AddNotificationTables.ts +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddNotificationTables1785600000000 implements MigrationInterface { + name = 'AddNotificationTables1785600000000'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + await queryRunner.query(`DROP TABLE "notification_recipient"`); + await queryRunner.query(`DROP TABLE "notification"`); + } +} +``` + +- [ ] **Step 6: Run the spec to verify it passes** + +Run: `npm test -- AddNotificationTables` +Expected: PASS (2 tests) + +- [ ] **Step 7: Commit** + +```bash +git add src/notifications/model/notification-event.type.ts src/notifications/entities/notification.entity.ts src/notifications/entities/notification-recipient.entity.ts src/database/migrations/1785600000000-AddNotificationTables.ts src/database/migrations/AddNotificationTables.spec.ts +git commit -m "feat: add notification data model and migration" +``` + +--- + +## Task 2: NotificationsService + +**Files:** +- Create: `myteamwallet_backend/src/notifications/notifications.service.ts` +- Test: `myteamwallet_backend/src/notifications/notifications.service.spec.ts` + +**Interfaces:** +- Consumes: `Notification`, `NotificationRecipient` entities and `NOTIFICATION_EVENT` type (Task 1). `Player` entity (`src/players/entities/player.entity.ts`, existing). +- Produces: `NotificationDto` and `NotificationPage` interfaces, `NotificationsService` with `create({ teamId, event, actorUserId, payload }): Promise`, `listForUser(userId, teamId, page, limit): Promise`, `getUnreadCount(userId, teamId): Promise`, `markRead(recipientId, userId): Promise`, `markAllRead(userId, teamId): Promise` — consumed by Tasks 4 (listener) and 5 (controller). + +- [ ] **Step 1: Write the failing test** + +```typescript +// myteamwallet_backend/src/notifications/notifications.service.spec.ts +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) { + const query: Record = {}; + ['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 () => { + playerRepository.createQueryBuilder.mockReturnValue( + chain({ getRawMany: jest.fn().mockResolvedValue([]) }), + ); + + await service.create({ + teamId: 10, + event: 'player_creation', + actorUserId: 5, + payload: { playerId: 1, playerName: 'Ada Lovelace' }, + }); + + expect(notificationRepository.save).not.toHaveBeenCalled(); + expect(recipientRepository.insert).not.toHaveBeenCalled(); + }); + + it('creates one notification and fans it out to every recipient', async () => { + playerRepository.createQueryBuilder.mockReturnValue( + chain({ getRawMany: jest.fn().mockResolvedValue([{ userId: 7 }, { userId: 8 }]) }), + ); + notificationRepository.save.mockResolvedValue({ id: 99 }); + + await service.create({ + teamId: 10, + event: 'player_creation', + actorUserId: 5, + payload: { playerId: 1, playerName: 'Ada Lovelace' }, + }); + + 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(); + }); + }); +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `npm test -- notifications.service.spec` +Expected: FAIL — `Cannot find module './notifications.service'` + +- [ ] **Step 3: Implement the service** + +```typescript +// myteamwallet_backend/src/notifications/notifications.service.ts +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; + 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, + @InjectRepository(NotificationRecipient) + private readonly recipientRepository: Repository, + @InjectRepository(Player) + private readonly playerRepository: Repository, + ) {} + + async create(params: { + teamId: number; + event: NOTIFICATION_EVENT; + actorUserId: number; + payload: Record; + }): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + }; + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npm test -- notifications.service.spec` +Expected: PASS (10 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/notifications/notifications.service.ts src/notifications/notifications.service.spec.ts +git commit -m "feat: add NotificationsService" +``` + +--- + +## Task 3: Install @nestjs/event-emitter and register it globally + +**Files:** +- Modify: `myteamwallet_backend/package.json` +- Modify: `myteamwallet_backend/src/app.module.ts` + +**Interfaces:** +- Produces: `EventEmitter2` injectable everywhere in the backend (module is `@Global()`), consumed by Tasks 4, 6, 7, 8, 9. + +- [ ] **Step 1: Install the dependency** + +Run (from `myteamwallet_backend`): `npm install @nestjs/event-emitter@^2` + +- [ ] **Step 2: Register `EventEmitterModule.forRoot()`** + +In `src/app.module.ts`, add the import and register it in `imports`, right after `ScheduleModule.forRoot()`: + +```typescript +import { ScheduleModule } from '@nestjs/schedule'; +import { EventEmitterModule } from '@nestjs/event-emitter'; +// ...existing imports... + +@Module({ + imports: [ + ScheduleModule.forRoot(), + EventEmitterModule.forRoot(), + ConfigModule.forRoot({ + // ...unchanged... + }), + // ...unchanged... + ], + providers: [], +}) +export class AppModule {} +``` + +- [ ] **Step 3: Verify the backend still builds** + +Run: `npm run build` +Expected: builds cleanly (no test for a module-registration line — this codebase has no `app.module.spec.ts`; correctness is verified by Task 4's listener test and Task 5's later e2e-style controller test successfully resolving `EventEmitter2` through DI). + +- [ ] **Step 4: Commit** + +```bash +git add package.json package-lock.json src/app.module.ts +git commit -m "chore: add and register @nestjs/event-emitter" +``` + +--- + +## Task 4: Notification domain events, listener, and NotificationsModule + +**Files:** +- Create: `myteamwallet_backend/src/notifications/events/notification-event-names.ts` +- Create: `myteamwallet_backend/src/notifications/events/player-active-changed.event.ts` +- Create: `myteamwallet_backend/src/notifications/events/player-role-changed.event.ts` +- Create: `myteamwallet_backend/src/notifications/events/player-created.event.ts` +- Create: `myteamwallet_backend/src/notifications/events/public-access-changed.event.ts` +- Create: `myteamwallet_backend/src/notifications/events/invite-link-created.event.ts` +- Create: `myteamwallet_backend/src/notifications/notifications.listener.ts` +- Test: `myteamwallet_backend/src/notifications/notifications.listener.spec.ts` +- Create: `myteamwallet_backend/src/notifications/notifications.module.ts` +- Modify: `myteamwallet_backend/src/database/logging/model/logging-event.type.ts` +- Modify: `myteamwallet_backend/src/app.module.ts` + +**Interfaces:** +- Consumes: `NotificationsService` (Task 2), `LoggingService` (existing), `TeamAccessService` (existing, `src/teams/team-access.service.ts`). +- Produces: `NOTIFICATION_EVENT_NAME` constants and event classes (`PlayerActiveChangedEvent`, `PlayerRoleChangedEvent`, `PlayerCreatedEvent`, `PublicAccessEnabledEvent`, `PublicAccessRotatedEvent`, `InviteLinkCreatedEvent`) — consumed by Tasks 6, 7, 8. `NotificationsModule` (imported by `AppModule`). + +- [ ] **Step 1: Write the failing listener test** + +```typescript +// myteamwallet_backend/src/notifications/notifications.listener.spec.ts +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, + }); + }); +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `npm test -- notifications.listener.spec` +Expected: FAIL — event/listener modules don't exist yet + +- [ ] **Step 3: Add `notification_create_fail` to `LOGEVENT`** + +In `src/database/logging/model/logging-event.type.ts`, add `'notification_create_fail'` to both the `LOGEVENT` union and the `LOGEVENT_VALUES` array (alongside the existing `log_retention_cleanup_run_fail` entry). + +- [ ] **Step 4: Create the event classes and name constants** + +```typescript +// myteamwallet_backend/src/notifications/events/notification-event-names.ts +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; +``` + +```typescript +// myteamwallet_backend/src/notifications/events/player-active-changed.event.ts +export class PlayerActiveChangedEvent { + constructor( + public readonly teamId: number, + public readonly actorUserId: number, + public readonly playerId: number, + public readonly playerName: string, + public readonly active: boolean, + ) {} +} +``` + +```typescript +// myteamwallet_backend/src/notifications/events/player-role-changed.event.ts +export class PlayerRoleChangedEvent { + constructor( + public readonly teamId: number, + public readonly actorUserId: number, + public readonly playerId: number, + public readonly playerName: string, + public readonly teamRoleId: number, + ) {} +} +``` + +```typescript +// myteamwallet_backend/src/notifications/events/player-created.event.ts +export class PlayerCreatedEvent { + constructor( + public readonly teamId: number, + public readonly actorUserId: number, + public readonly playerId: number, + public readonly playerName: string, + ) {} +} +``` + +```typescript +// myteamwallet_backend/src/notifications/events/public-access-changed.event.ts +export class PublicAccessEnabledEvent { + constructor( + public readonly teamId: number, + public readonly actorUserId: number, + ) {} +} + +export class PublicAccessRotatedEvent { + constructor( + public readonly teamId: number, + public readonly actorUserId: number, + ) {} +} +``` + +```typescript +// myteamwallet_backend/src/notifications/events/invite-link-created.event.ts +export class InviteLinkCreatedEvent { + constructor( + public readonly teamId: number, + public readonly actorUserId: number, + public readonly teamName: string, + ) {} +} +``` + +- [ ] **Step 5: Implement the listener** + +```typescript +// myteamwallet_backend/src/notifications/notifications.listener.ts +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 { + 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 { + 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 { + return this.safeCreate('player_creation', event.teamId, event.actorUserId, { + playerId: event.playerId, + playerName: event.playerName, + }); + } + + @OnEvent(NOTIFICATION_EVENT_NAME.publicAccessEnabled) + onPublicAccessEnabled(event: PublicAccessEnabledEvent): Promise { + return this.safeCreate('public_access_enabled', event.teamId, event.actorUserId, {}); + } + + @OnEvent(NOTIFICATION_EVENT_NAME.publicAccessRotated) + onPublicAccessRotated(event: PublicAccessRotatedEvent): Promise { + return this.safeCreate('public_access_rotated', event.teamId, event.actorUserId, {}); + } + + @OnEvent(NOTIFICATION_EVENT_NAME.inviteLinkCreated) + onInviteLinkCreated(event: InviteLinkCreatedEvent): Promise { + 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, + ): Promise { + 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, + }); + } + } +} +``` + +- [ ] **Step 6: Run the test to verify it passes** + +Run: `npm test -- notifications.listener.spec` +Expected: PASS (7 tests) + +- [ ] **Step 7: Create the module and register it in `AppModule`** + +```typescript +// myteamwallet_backend/src/notifications/notifications.module.ts +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 { TeamSetting } from 'src/team-settings/entities/team-setting.entity'; +import { User } from 'src/users/entities/user.entity'; +import { TeamAccessService } from 'src/teams/team-access.service'; +import { Notification } from './entities/notification.entity'; +import { NotificationRecipient } from './entities/notification-recipient.entity'; +import { NotificationsListener } from './notifications.listener'; +import { NotificationsService } from './notifications.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Notification, NotificationRecipient, Player, TeamSetting, User]), + LoggingModule, + ], + providers: [NotificationsService, NotificationsListener, TeamAccessService], +}) +export class NotificationsModule {} +``` + +In `src/app.module.ts`, add the import and append `NotificationsModule` to `imports`, after `CashboxExportModule`: + +```typescript +import { NotificationsModule } from './notifications/notifications.module'; +// ... + CashboxExportModule, + NotificationsModule, +``` + +- [ ] **Step 8: Commit** + +```bash +git add src/notifications src/database/logging/model/logging-event.type.ts src/app.module.ts +git commit -m "feat: add notification domain events, listener, and module" +``` + +--- + +## Task 5: NotificationsController + +**Files:** +- Create: `myteamwallet_backend/src/notifications/dto/notification-query.dto.ts` +- Create: `myteamwallet_backend/src/notifications/notifications.controller.ts` +- Test: `myteamwallet_backend/src/notifications/notifications.http.spec.ts` +- Modify: `myteamwallet_backend/src/notifications/notifications.module.ts` + +**Interfaces:** +- Consumes: `NotificationsService` (Task 2), `TeamAccessService.assertMember(userId, teamId): Promise` (existing). +- Produces: `GET teams/:teamId/notifications`, `GET teams/:teamId/notifications/unread-count`, `PATCH teams/:teamId/notifications/:id/read`, `PATCH teams/:teamId/notifications/read-all` — consumed by frontend Task 10 (`NotificationsApi`). + +- [ ] **Step 1: Write the failing HTTP-boundary test** + +```typescript +// myteamwallet_backend/src/notifications/notifications.http.spec.ts +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); + }); +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `npm test -- notifications.http.spec` +Expected: FAIL — `NotificationsController` doesn't exist yet + +- [ ] **Step 3: Implement the DTO and controller** + +```typescript +// myteamwallet_backend/src/notifications/dto/notification-query.dto.ts +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; +} +``` + +```typescript +// myteamwallet_backend/src/notifications/notifications.controller.ts +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); + } +} +``` + +- [ ] **Step 4: Register the controller in `NotificationsModule`** + +```typescript +// myteamwallet_backend/src/notifications/notifications.module.ts +import { NotificationsController } from './notifications.controller'; +// ... +@Module({ + imports: [/* unchanged */], + controllers: [NotificationsController], + providers: [NotificationsService, NotificationsListener, TeamAccessService], +}) +export class NotificationsModule {} +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `npm test -- notifications.http.spec` +Expected: PASS (7 tests) + +- [ ] **Step 6: Commit** + +```bash +git add src/notifications/dto src/notifications/notifications.controller.ts src/notifications/notifications.http.spec.ts src/notifications/notifications.module.ts +git commit -m "feat: add NotificationsController" +``` + +--- + +## Task 6: Wire notification events into player active/role changes + +**Files:** +- Modify: `myteamwallet_backend/src/teams/team-members.service.ts` +- Modify: `myteamwallet_backend/src/teams/team-members.service.spec.ts` + +**Interfaces:** +- Consumes: `EventEmitter2` (Task 3), `NOTIFICATION_EVENT_NAME`, `PlayerActiveChangedEvent`, `PlayerRoleChangedEvent` (Task 4). + +- [ ] **Step 1: Write the failing tests (extend the existing spec)** + +Add an `eventEmitter` mock to `team-members.service.spec.ts`'s `beforeEach` and pass it to the constructor: + +```typescript +// myteamwallet_backend/src/teams/team-members.service.spec.ts — inside beforeEach, alongside the existing mocks + eventEmitter = { emit: jest.fn() }; + service = new TeamMembersService(dataSource, logger, access as any, eventEmitter as any); +``` + +(Declare `let eventEmitter: any;` alongside the other `let` declarations at the top of the `describe` block.) + +Add these new `it()` blocks: + +```typescript + 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, + }), + ); + }); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `npm test -- team-members.service.spec` +Expected: FAIL — `TeamMembersService` doesn't accept a 4th constructor argument yet, and doesn't emit anything + +- [ ] **Step 3: Wire the emits into the service** + +Replace the imports at the top and the constructor of `src/teams/team-members.service.ts`: + +```typescript +import { EventEmitter2 } from '@nestjs/event-emitter'; +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'; +// ...existing imports unchanged... + +@Injectable() +export class TeamMembersService { + constructor( + private readonly dataSource: DataSource, + private readonly logger: LoggingService, + private readonly access: TeamAccessService, + private readonly eventEmitter: EventEmitter2, + ) {} +``` + +Replace the body of `setActive` so it returns whether a real change happened, and emits after the transaction commits: + +```typescript + async setActive( + actorUserId: number, + teamId: number, + playerId: number, + active: boolean, + ): Promise { + await this.access.assertAtLeast( + actorUserId, + teamId, + 'member_manage_min_role', + TeamRolesEnum.captain, + ); + + const result = await this.dataSource.transaction(async (manager) => { + const activeTreasurers = await this.lockActiveTreasurers(manager, teamId); + const playerRepository = manager.getRepository(Player); + const player = await this.findLockedPlayer(playerRepository, playerId, teamId); + + if (player.active === active) return { player, changed: false }; + + const isDeactivation = player.active && !active; + if ( + isDeactivation && + player.teamRole?.id === TeamRolesEnum.treasurer && + activeTreasurers.length <= 1 + ) { + throw new ConflictException( + 'Mindestens ein aktiver Kassenwart muss im Team verbleiben.', + ); + } + + player.active = active; + + if (isDeactivation) { + await this.zeroBalance(manager, player); + } else { + await this.recomputeBalance(manager, player); + } + + await playerRepository.save(player); + await this.log( + manager, + 'player_active_update', + actorUserId, + `teamId=${teamId} playerId=${playerId} active=${active}`, + ); + 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; + } +``` + +Replace the body of `setTeamRole` the same way: + +```typescript + async setTeamRole( + actorUserId: number, + teamId: number, + playerId: number, + teamRoleId: TeamRolesEnum, + ): Promise { + await this.access.assertAtLeast( + actorUserId, + teamId, + 'member_manage_min_role', + TeamRolesEnum.captain, + ); + + const result = await this.dataSource.transaction(async (manager) => { + const activeTreasurers = await this.lockActiveTreasurers(manager, teamId); + const playerRepository = manager.getRepository(Player); + const player = await this.findLockedPlayer(playerRepository, playerId, teamId); + + if (player.teamRole?.id === teamRoleId) return { player, changed: false }; + + const isDemotionFromTreasurer = + player.active && + player.teamRole?.id === TeamRolesEnum.treasurer && + teamRoleId !== TeamRolesEnum.treasurer; + if (isDemotionFromTreasurer && activeTreasurers.length <= 1) { + throw new ConflictException( + 'Mindestens ein aktiver Kassenwart muss im Team verbleiben.', + ); + } + + player.teamRole = { id: teamRoleId } as TeamRole; + await playerRepository.save(player); + await this.log( + manager, + 'player_team_role_update', + actorUserId, + `teamId=${teamId} playerId=${playerId} teamRoleId=${teamRoleId}`, + ); + 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; + } +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `npm test -- team-members.service.spec` +Expected: PASS (all existing tests plus the 3 new ones) + +- [ ] **Step 5: Commit** + +```bash +git add src/teams/team-members.service.ts src/teams/team-members.service.spec.ts +git commit -m "feat: emit notification events on player active/role changes" +``` + +--- + +## Task 7: Wire notification events into share-link enable/rotate + +**Files:** +- Modify: `myteamwallet_backend/src/database/logging/model/logging-event.type.ts` +- Modify: `myteamwallet_backend/src/teams/public-team-access.service.ts` +- Modify: `myteamwallet_backend/src/teams/public-team-access.service.spec.ts` + +**Interfaces:** +- Consumes: `EventEmitter2` (Task 3), `NOTIFICATION_EVENT_NAME`, `PublicAccessEnabledEvent`, `PublicAccessRotatedEvent` (Task 4), `LoggingService` (existing). + +- [ ] **Step 1: Write the failing tests (extend the existing spec)** + +Add `logger`/`eventEmitter` mocks to `public-team-access.service.spec.ts` and pass them to the constructor: + +```typescript +// inside beforeEach, alongside the existing mocks + logger = { info: jest.fn() }; + eventEmitter = { emit: jest.fn() }; + service = new PublicTeamAccessService( + teamRepository as any, + playerRepository as any, + transactionRepository as any, + penaltyRepository as any, + access as any, + logger as any, + eventEmitter as any, + ); +``` + +(Declare `let logger: any;` and `let eventEmitter: any;` alongside the existing `let service: PublicTeamAccessService;`.) + +Add these new `it()` blocks: + +```typescript + 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 }), + ); + }); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `npm test -- public-team-access.service.spec` +Expected: FAIL — constructor arity mismatch, no logging/emit yet + +- [ ] **Step 3: Add the new `LOGEVENT` values** + +In `src/database/logging/model/logging-event.type.ts`, add `'public_access_enabled'` and `'public_access_rotated'` to both the `LOGEVENT` union and the `LOGEVENT_VALUES` array. + +- [ ] **Step 4: Wire the service** + +Update the imports and constructor of `src/teams/public-team-access.service.ts`: + +```typescript +import { EventEmitter2 } from '@nestjs/event-emitter'; +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'; +// ...existing imports unchanged... + +@Injectable() +export class PublicTeamAccessService { + constructor( + @InjectRepository(Team) + private readonly teamRepository: Repository, + @InjectRepository(Player) + private readonly playerRepository: Repository, + @InjectRepository(Transaction) + private readonly transactionRepository: Repository, + @InjectRepository(PenaltyEntity) + private readonly penaltyRepository: Repository, + private readonly access: TeamAccessService, + private readonly logger: LoggingService, + private readonly eventEmitter: EventEmitter2, + ) {} +``` + +Update `setEnabled` to log/emit only when actually enabling: + +```typescript + async setEnabled( + userId: number, + teamId: number, + enabled: boolean, + ): Promise { + await this.access.assertAtLeast( + userId, + teamId, + 'public_access_manage_min_role', + TeamRolesEnum.captain, + ); + const team = await this.loadManagedTeam(teamId); + if (enabled && !team.publicAccessToken) { + team.publicAccessToken = this.createToken(); + } + team.publicAccessEnabled = enabled; + 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); + } +``` + +Update `rotate`: + +```typescript + async rotate(userId: number, teamId: number): Promise { + await this.access.assertAtLeast( + userId, + teamId, + 'public_access_manage_min_role', + TeamRolesEnum.captain, + ); + const team = await this.loadManagedTeam(teamId); + team.publicAccessToken = this.createToken(); + 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); + } +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `npm test -- public-team-access.service.spec` +Expected: PASS (all existing tests plus the 3 new ones) + +- [ ] **Step 6: Commit** + +```bash +git add src/database/logging/model/logging-event.type.ts src/teams/public-team-access.service.ts src/teams/public-team-access.service.spec.ts +git commit -m "feat: log and emit notification events on public-access enable/rotate" +``` + +--- + +## Task 8: Wire notification event into new-player creation + +**Files:** +- Modify: `myteamwallet_backend/src/teams/teams.service.ts` +- Modify: `myteamwallet_backend/src/teams/teams.service.spec.ts` + +**Interfaces:** +- Consumes: `EventEmitter2` (Task 3), `NOTIFICATION_EVENT_NAME`, `PlayerCreatedEvent` (Task 4). + +- [ ] **Step 1: Write the failing test** + +Update the three existing `new TeamsService(...)` call sites in `teams.service.spec.ts` to pass an 11th constructor argument (`eventEmitter as any` at each of the three `beforeEach` locations, e.g. `{ emit: jest.fn() } as any`), and add a new `describe` block: + +```typescript +// myteamwallet_backend/src/teams/teams.service.spec.ts — new describe block +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', + }), + ); + }); +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `npm test -- teams.service.spec` +Expected: FAIL — constructor arity mismatch, no emit yet + +- [ ] **Step 3: Wire the service** + +Update the imports and constructor of `src/teams/teams.service.ts`: + +```typescript +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { NOTIFICATION_EVENT_NAME } from '../notifications/events/notification-event-names'; +import { PlayerCreatedEvent } from '../notifications/events/player-created.event'; +// ...existing imports unchanged... + +@Injectable() +export class TeamsService { + constructor( + @InjectRepository(Team) + private repository: Repository, + @InjectRepository(Player) + private playerRepository: Repository, + @InjectRepository(Transaction) + private transactionsRepository: Repository, + @InjectRepository(TeamRole) + private rolesRepository: Repository, + @InjectRepository(TeamSetting) + private settingsRepository: Repository, + @InjectRepository(TeamWalletTransaction) + private teamWalletTransactionRepository: Repository, + private logger: LoggingService, + private access: TeamAccessService, + @InjectRepository(User) + private usersRepository: Repository, + private dataSource: DataSource, + private eventEmitter: EventEmitter2, + ) {} +``` + +In `createNewPlayer`, after the existing `logger.info({ event: 'player_creation', ... })` call, add: + +```typescript + this.eventEmitter.emit( + NOTIFICATION_EVENT_NAME.playerCreated, + new PlayerCreatedEvent(Number(id), Number(actorUserId), playerSaved.id, `${p.firstName} ${p.lastName}`), + ); +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `npm test -- teams.service.spec` +Expected: PASS (all existing tests plus the new one) + +- [ ] **Step 5: Commit** + +```bash +git add src/teams/teams.service.ts src/teams/teams.service.spec.ts +git commit -m "feat: emit notification event on player creation" +``` + +--- + +## Task 9: Wire notification event into invite-link creation + +**Files:** +- Modify: `myteamwallet_backend/src/auth/auth.service.ts` +- Modify: `myteamwallet_backend/src/auth/auth.service.spec.ts` + +**Interfaces:** +- Consumes: `EventEmitter2` (Task 3), `NOTIFICATION_EVENT_NAME`, `InviteLinkCreatedEvent` (Task 4). + +- [ ] **Step 1: Write the failing test** + +Update the existing `new AuthService(...)` call in `auth.service.spec.ts` to pass an 8th argument, and add a new test: + +```typescript +// myteamwallet_backend/src/auth/auth.service.spec.ts — in the shared beforeEach + eventEmitter = { emit: jest.fn() }; + service = new AuthService( + jwtService, + usersService, + {} as any, + mailService, + logger, + dataSource, + { assertAtLeast: jest.fn() } as any, + eventEmitter as any, + ); +``` + +(Declare `let eventEmitter: any;` alongside the other `let` declarations.) + +```typescript + 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' }), + ); + }); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `npm test -- auth.service.spec` +Expected: FAIL — constructor arity mismatch, no emit yet + +- [ ] **Step 3: Wire the service** + +Update the imports and constructor of `src/auth/auth.service.ts`: + +```typescript +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { NOTIFICATION_EVENT_NAME } from 'src/notifications/events/notification-event-names'; +import { InviteLinkCreatedEvent } from 'src/notifications/events/invite-link-created.event'; +// ...existing imports unchanged... + +@Injectable() +export class AuthService { + constructor( + private jwtService: JwtService, + private usersService: UsersService, + private forgotService: ForgotService, + private mailService: MailService, + private logger: LoggingService, + private dataSource: DataSource, + private teamAccess: TeamAccessService, + private eventEmitter: EventEmitter2, +``` + +In `createTeamInvite`, after the existing `await this.logger.info({...})` call, add: + +```typescript + this.eventEmitter.emit( + NOTIFICATION_EVENT_NAME.inviteLinkCreated, + new InviteLinkCreatedEvent(object.teamId, actorUserId, object.teamName), + ); +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `npm test -- auth.service.spec` +Expected: PASS (all existing tests plus the new one) + +- [ ] **Step 5: Commit** + +```bash +git add src/auth/auth.service.ts src/auth/auth.service.spec.ts +git commit -m "feat: emit notification event on invite-link creation" +``` + +--- + +## Task 10: Notification retention scheduler + +**Files:** +- Modify: `myteamwallet_backend/src/database/logging/model/logging-event.type.ts` +- Create: `myteamwallet_backend/src/notifications/notification-retention.scheduler.ts` +- Test: `myteamwallet_backend/src/notifications/notification-retention.scheduler.spec.ts` +- Modify: `myteamwallet_backend/src/notifications/notifications.module.ts` + +**Interfaces:** +- Consumes: `Notification` entity (Task 1), `app.logRetentionDays` config (existing, reused from `LogRetentionScheduler`). + +- [ ] **Step 1: Write the failing test** + +```typescript +// myteamwallet_backend/src/notifications/notification-retention.scheduler.spec.ts +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(); + }); +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `npm test -- notification-retention.scheduler.spec` +Expected: FAIL — `NotificationRetentionScheduler` doesn't exist yet + +- [ ] **Step 3: Add the new `LOGEVENT` values** + +In `src/database/logging/model/logging-event.type.ts`, add `'notification_retention_cleanup_run'` and `'notification_retention_cleanup_run_fail'` to both the `LOGEVENT` union and the `LOGEVENT_VALUES` array. + +- [ ] **Step 4: Implement the scheduler** + +```typescript +// myteamwallet_backend/src/notifications/notification-retention.scheduler.ts +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, + private readonly configService: ConfigService, + private readonly logger: LoggingService, + ) {} + + @Cron(CronExpression.EVERY_DAY_AT_5AM) + async cleanupOldNotifications(): Promise { + const retentionDays = this.configService.get('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, + }); + } + } +} +``` + +`NotificationRecipient` rows delete automatically via the `onDelete: 'CASCADE'` foreign key from Task 1, so only `Notification` rows need to be deleted here. + +- [ ] **Step 5: Register the scheduler in `NotificationsModule`** + +```typescript +// myteamwallet_backend/src/notifications/notifications.module.ts +import { NotificationRetentionScheduler } from './notification-retention.scheduler'; +// ... +@Module({ + imports: [/* unchanged */], + controllers: [NotificationsController], + providers: [ + NotificationsService, + NotificationsListener, + TeamAccessService, + NotificationRetentionScheduler, + ], +}) +export class NotificationsModule {} +``` + +- [ ] **Step 6: Run the test to verify it passes** + +Run: `npm test -- notification-retention.scheduler.spec` +Expected: PASS (3 tests) + +- [ ] **Step 7: Commit** + +```bash +git add src/database/logging/model/logging-event.type.ts src/notifications/notification-retention.scheduler.ts src/notifications/notification-retention.scheduler.spec.ts src/notifications/notifications.module.ts +git commit -m "feat: add notification retention scheduler" +``` + +--- + +## Task 11: Frontend models, presentation helpers, and API client + +**Files:** +- Create: `myteamwallet_frontend_modern/src/app/models/notification.model.ts` +- Create: `myteamwallet_frontend_modern/src/app/core/notifications/notification-presentation.ts` +- Test: `myteamwallet_frontend_modern/src/app/core/notifications/notification-presentation.spec.ts` +- Create: `myteamwallet_frontend_modern/src/app/core/notifications/notifications-api.ts` +- Test: `myteamwallet_frontend_modern/src/app/core/notifications/notifications-api.spec.ts` + +**Interfaces:** +- Produces: `NotificationEvent`, `NotificationPayload`, `NotificationItem`, `NotificationQuery`, `NotificationPage` types; `notificationLabel`, `notificationIcon`, `notificationTarget` functions; `NotificationsApi` with `loadNotifications`, `loadUnreadCount`, `markRead`, `markAllRead` — all consumed by Tasks 12, 13, 14. + +- [ ] **Step 1: Write the failing presentation-helper test** + +```typescript +// myteamwallet_frontend_modern/src/app/core/notifications/notification-presentation.spec.ts +import { NotificationItem } from '../../models/notification.model'; +import { notificationIcon, notificationLabel, notificationTarget } from './notification-presentation'; + +function item(overrides: Partial): 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', + ]); + }); +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run (from `myteamwallet_frontend_modern`): `ng test -- --run notification-presentation` +Expected: FAIL — modules don't exist yet + +- [ ] **Step 3: Create the model** + +```typescript +// myteamwallet_frontend_modern/src/app/models/notification.model.ts +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; +} +``` + +- [ ] **Step 4: Create the presentation helper** + +```typescript +// myteamwallet_frontend_modern/src/app/core/notifications/notification-presentation.ts +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']; + } +} +``` + +- [ ] **Step 5: Run the presentation test to verify it passes** + +Run: `ng test -- --run notification-presentation` +Expected: PASS (9 tests) + +- [ ] **Step 6: Write the failing API test** + +```typescript +// myteamwallet_frontend_modern/src/app/core/notifications/notifications-api.spec.ts +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(undefined); + }); + + 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(undefined); + }); +}); +``` + +- [ ] **Step 7: Run it to verify it fails** + +Run: `ng test -- --run notifications-api` +Expected: FAIL — `NotificationsApi` doesn't exist yet + +- [ ] **Step 8: Implement the API client** + +```typescript +// myteamwallet_frontend_modern/src/app/core/notifications/notifications-api.ts +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 { + const params = new HttpParams().set('page', query.page).set('limit', query.limit); + return this.http.get(`${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 { + return this.http.patch(`${environment.apiUrl}teams/${teamId}/notifications/${id}/read`, {}); + } + + markAllRead(teamId: number): Observable { + return this.http.patch(`${environment.apiUrl}teams/${teamId}/notifications/read-all`, {}); + } +} +``` + +- [ ] **Step 9: Run the API test to verify it passes** + +Run: `ng test -- --run notifications-api` +Expected: PASS (4 tests) + +- [ ] **Step 10: Commit** + +```bash +git add src/app/models/notification.model.ts src/app/core/notifications/notification-presentation.ts src/app/core/notifications/notification-presentation.spec.ts src/app/core/notifications/notifications-api.ts src/app/core/notifications/notifications-api.spec.ts +git commit -m "feat: add notification model, presentation helpers, and API client" +``` + +--- + +## Task 12: NotificationsStore + +**Files:** +- Create: `myteamwallet_frontend_modern/src/app/core/notifications/notifications-store.ts` +- Test: `myteamwallet_frontend_modern/src/app/core/notifications/notifications-store.spec.ts` + +**Interfaces:** +- Consumes: `NotificationsApi` (Task 11). +- Produces: `NotificationsStore` with `unreadCount: Signal`, `notifications: Signal`, `startPolling(teamId): void`, `loadRecent(teamId): void`, `markRead(teamId, id): void`, `markAllRead(teamId): void` — consumed by Tasks 13, 14. + +- [ ] **Step 1: Write the failing test** + +```typescript +// myteamwallet_frontend_modern/src/app/core/notifications/notifications-store.spec.ts +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; + loadNotifications: ReturnType; + markRead: ReturnType; + markAllRead: ReturnType; + }; + 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); + }); +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `ng test -- --run notifications-store` +Expected: FAIL — `NotificationsStore` doesn't exist yet + +- [ ] **Step 3: Implement the store** + +`interval(POLL_INTERVAL_MS).pipe(startWith(-1), ...)` is used instead of `timer(0, POLL_INTERVAL_MS)` deliberately: `timer`'s zero-delay first tick is still scheduled asynchronously via `setTimeout`, whereas `startWith` re-emits synchronously on subscribe, which is what lets `startPolling()` update `unreadCount()` immediately (both in production, for a fast first paint, and in these synchronous tests). + +```typescript +// myteamwallet_frontend_modern/src/app/core/notifications/notifications-store.ts +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([]); + private readonly loadingSignal = signal(false); + private readonly pollingTeamId = signal(null); + private readonly pollRequests = new Subject(); + + 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); + }); + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `ng test -- --run notifications-store` +Expected: PASS (6 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/app/core/notifications/notifications-store.ts src/app/core/notifications/notifications-store.spec.ts +git commit -m "feat: add NotificationsStore" +``` + +--- + +## Task 13: Bell icon and dropdown in the app shell + +**Files:** +- Modify: `myteamwallet_frontend_modern/src/app/core/layout/shell/shell.ts` +- Modify: `myteamwallet_frontend_modern/src/app/core/layout/shell/shell.html` +- Modify: `myteamwallet_frontend_modern/src/app/core/layout/shell/shell.scss` +- Modify: `myteamwallet_frontend_modern/src/app/core/layout/shell/shell.spec.ts` + +**Interfaces:** +- Consumes: `NotificationsStore` (Task 12), `notificationLabel`/`notificationIcon`/`notificationTarget` (Task 11). + +- [ ] **Step 1: Extend the failing test** + +Add a `NotificationsStore` mock to the shared `providers` array in `shell.spec.ts`'s `beforeEach` (alongside `provideHttpClient()` etc.), and add `Router` to the `@angular/router` import so it can be injected in new tests: + +```typescript +// myteamwallet_frontend_modern/src/app/core/layout/shell/shell.spec.ts +import { ActivatedRoute, ParamMap, Router, convertToParamMap, provideRouter } from '@angular/router'; +import { NotificationsStore } from '../../notifications/notifications-store'; +// ...existing imports unchanged... + +describe('Shell', () => { + let httpMock: HttpTestingController; + let authStore: AuthStore; + let routeParams: BehaviorSubject>; + let notificationsStore: { + unreadCount: ReturnType>; + notifications: ReturnType>; + startPolling: ReturnType; + loadRecent: ReturnType; + markRead: ReturnType; + markAllRead: ReturnType; + }; + + beforeEach(async () => { + localStorage.clear(); + 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({ + imports: [Shell], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + provideRouter([]), + { provide: NotificationsStore, useValue: notificationsStore }, + { + provide: ActivatedRoute, + useValue: { paramMap: routeParams.asObservable() }, + }, + ], + }).compileComponents(); + + httpMock = TestBed.inject(HttpTestingController); + authStore = TestBed.inject(AuthStore); + authStore.setSession('token', { id: 42, email: 'a@b.de', firstName: 'A', lastName: 'B' }); + }); +``` + +Add these new `it()` blocks to the same `describe('Shell', ...)`: + +```typescript + 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); + }); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `ng test -- --run shell.spec` +Expected: FAIL — `Shell` has no `unreadCount`, `onNotificationsMenuOpened`, etc. yet + +- [ ] **Step 3: Update the component** + +```typescript +// myteamwallet_frontend_modern/src/app/core/layout/shell/shell.ts +import { Component, computed, inject, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + ActivatedRoute, + Router, + RouterLink, + RouterLinkActive, + RouterOutlet, +} from '@angular/router'; +import { MatBadgeModule } from '@angular/material/badge'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatToolbarModule } from '@angular/material/toolbar'; +import { AuthStore } from '../../auth/auth-store'; +import { MyTeamsStore } from '../../team/my-teams-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 { NotificationItem } from '../../../models/notification.model'; + +@Component({ + selector: 'app-shell', + imports: [ + RouterOutlet, + RouterLink, + RouterLinkActive, + MatToolbarModule, + MatIconModule, + MatMenuModule, + MatButtonModule, + MatBadgeModule, + ], + templateUrl: './shell.html', + styleUrl: './shell.scss', +}) +export class Shell { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly authStore = inject(AuthStore); + private readonly myTeamsStore = inject(MyTeamsStore); + private readonly teamStore = inject(TeamStore); + private readonly notificationsStore = inject(NotificationsStore); + + protected readonly currentTeam = this.teamStore.team; + protected readonly currentTeamId = signal(null); + protected readonly unreadCount = this.notificationsStore.unreadCount; + protected readonly notifications = this.notificationsStore.notifications; + + protected readonly myTeams = computed(() => { + const seen = new Set(); + const teams: UserTeamReference[] = []; + for (const player of this.myTeamsStore.players()) { + if (!seen.has(player.team.id)) { + seen.add(player.team.id); + teams.push(player.team); + } + } + return teams; + }); + + constructor() { + const userId = this.authStore.currentUser()?.id; + if (userId) { + this.myTeamsStore.ensureLoaded(userId); + } + + this.route.paramMap.pipe(takeUntilDestroyed()).subscribe((params) => { + const raw = params.get('id'); + const id = raw === null ? Number.NaN : Number(raw); + if (Number.isInteger(id) && id > 0) { + this.teamStore.loadTeam(id); + this.currentTeamId.set(id); + this.notificationsStore.startPolling(id); + } + }); + } + + protected switchTeam(teamId: number): void { + 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); + } + } +} +``` + +- [ ] **Step 4: Update the template** + +```html + + + @if (myTeams().length > 1) { + + + @for (team of myTeams(); track team.id) { + + } + + } @else { + {{ currentTeam()?.name ?? 'TeamWallet' }} + } + + + + + +
+ Benachrichtigungen + +
+ @if (notifications().length === 0) { +
Keine Benachrichtigungen
+ } @else { + @for (item of notifications(); track item.id) { + + } + @if (currentTeamId(); as teamId) { + Alle anzeigen + } + } +
+
+ +
+ +
+ + +``` + +- [ ] **Step 5: Update the styles** + +Append to `myteamwallet_frontend_modern/src/app/core/layout/shell/shell.scss`: + +```scss +.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; + } + } +} +``` + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `ng test -- --run shell.spec` +Expected: PASS (all existing tests plus the 5 new ones) + +- [ ] **Step 7: Commit** + +```bash +git add src/app/core/layout/shell/shell.ts src/app/core/layout/shell/shell.html src/app/core/layout/shell/shell.scss src/app/core/layout/shell/shell.spec.ts +git commit -m "feat: add notification bell and dropdown to the app shell" +``` + +--- + +## Task 14: Full notifications history page and route + +**Files:** +- Create: `myteamwallet_frontend_modern/src/app/features/team/notifications/notifications.ts` +- Create: `myteamwallet_frontend_modern/src/app/features/team/notifications/notifications.html` +- Create: `myteamwallet_frontend_modern/src/app/features/team/notifications/notifications.scss` +- Test: `myteamwallet_frontend_modern/src/app/features/team/notifications/notifications.spec.ts` +- Modify: `myteamwallet_frontend_modern/src/app/app.routes.ts` + +**Interfaces:** +- Consumes: `NotificationsApi`, `NotificationsStore` (Tasks 11, 12), `notificationLabel`/`notificationIcon`/`notificationTarget` (Task 11). + +- [ ] **Step 1: Write the failing test** + +```typescript +// myteamwallet_frontend_modern/src/app/features/team/notifications/notifications.spec.ts +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; + let fixture: ComponentFixture; + let api: { loadNotifications: ReturnType }; + let store: { markRead: ReturnType }; + + 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]); + }); +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `ng test -- --run features/team/notifications/notifications.spec` +Expected: FAIL — `Notifications` component doesn't exist yet + +- [ ] **Step 3: Implement the component** + +```typescript +// myteamwallet_frontend_modern/src/app/features/team/notifications/notifications.ts +import { Component, 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 { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly api = inject(NotificationsApi); + private readonly notificationsStore = inject(NotificationsStore); + + protected readonly items = signal([]); + protected readonly loading = signal(false); + protected readonly hasNextPage = signal(false); + + private teamId: number | null = null; + private page = 1; + + constructor() { + const parentRoute = this.route.parent; + if (!parentRoute) return; + + parentRoute.paramMap.pipe(takeUntilDestroyed()).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), + }); + } +} +``` + +```html + +
+

Benachrichtigungen

+ + @if (items().length === 0 && !loading()) { +

Keine Benachrichtigungen vorhanden.

+ } + + + @for (item of items(); track item.id) { + + {{ notificationIcon(item) }} + {{ notificationLabel(item) }} + + } + + + @if (loading()) { + + } + + @if (hasNextPage() && !loading()) { + + } +
+``` + +```scss +// myteamwallet_frontend_modern/src/app/features/team/notifications/notifications.scss +.notifications-page { + padding: 1rem; + + &__empty { + color: var(--mat-sys-on-surface-variant); + } + + &__item--unread { + font-weight: 600; + } + + &__spinner { + margin: 1rem auto; + } +} +``` + +- [ ] **Step 4: Register the route** + +In `myteamwallet_frontend_modern/src/app/app.routes.ts`, add a new child route inside the `team/:id` children array (after `more/guide`): + +```typescript + { + path: 'notifications', + loadComponent: () => + import('./features/team/notifications/notifications').then((m) => m.Notifications), + }, +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `ng test -- --run features/team/notifications/notifications.spec` +Expected: PASS (3 tests) + +- [ ] **Step 6: Commit** + +```bash +git add src/app/features/team/notifications src/app/app.routes.ts +git commit -m "feat: add full notifications history page and route" +``` + +--- + +## Final verification + +- [ ] **Backend:** from `myteamwallet_backend`, run `npm test` (all specs green) and `npm run build` (clean). +- [ ] **Frontend:** from `myteamwallet_frontend_modern`, run `ng test -- --run` (all specs green) and `ng build` (clean). +- [ ] **Manual smoke test:** start both backend and frontend locally, log in as two different users who are both active members of the same team. As user A, deactivate user B's player (or rotate the team's share link). As user B, confirm the bell badge count increases within ~30s, open the dropdown, see the new entry, click it, confirm it navigates to the right page and the badge count decreases. Open "Alle anzeigen" and confirm the full history page paginates correctly once more than 20 notifications exist for that team. From 741067263042b9905de1a7c010195553f752adfb Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Tue, 4 Aug 2026 18:26:50 +0200 Subject: [PATCH 04/19] feat: add notification data model and migration --- .../1785600000000-AddNotificationTables.ts | 56 +++++++++++++++++++ .../migrations/AddNotificationTables.spec.ts | 38 +++++++++++++ .../entities/notification-recipient.entity.ts | 23 ++++++++ .../entities/notification.entity.ts | 33 +++++++++++ .../model/notification-event.type.ts | 16 ++++++ 5 files changed, 166 insertions(+) create mode 100644 myteamwallet_backend/src/database/migrations/1785600000000-AddNotificationTables.ts create mode 100644 myteamwallet_backend/src/database/migrations/AddNotificationTables.spec.ts create mode 100644 myteamwallet_backend/src/notifications/entities/notification-recipient.entity.ts create mode 100644 myteamwallet_backend/src/notifications/entities/notification.entity.ts create mode 100644 myteamwallet_backend/src/notifications/model/notification-event.type.ts diff --git a/myteamwallet_backend/src/database/migrations/1785600000000-AddNotificationTables.ts b/myteamwallet_backend/src/database/migrations/1785600000000-AddNotificationTables.ts new file mode 100644 index 0000000..c8c0329 --- /dev/null +++ b/myteamwallet_backend/src/database/migrations/1785600000000-AddNotificationTables.ts @@ -0,0 +1,56 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddNotificationTables1785600000000 implements MigrationInterface { + name = 'AddNotificationTables1785600000000'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + await queryRunner.query(`DROP TABLE "notification_recipient"`); + await queryRunner.query(`DROP TABLE "notification"`); + } +} diff --git a/myteamwallet_backend/src/database/migrations/AddNotificationTables.spec.ts b/myteamwallet_backend/src/database/migrations/AddNotificationTables.spec.ts new file mode 100644 index 0000000..e50f1ea --- /dev/null +++ b/myteamwallet_backend/src/database/migrations/AddNotificationTables.spec.ts @@ -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"']); + }); +}); diff --git a/myteamwallet_backend/src/notifications/entities/notification-recipient.entity.ts b/myteamwallet_backend/src/notifications/entities/notification-recipient.entity.ts new file mode 100644 index 0000000..9c2fde0 --- /dev/null +++ b/myteamwallet_backend/src/notifications/entities/notification-recipient.entity.ts @@ -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; +} diff --git a/myteamwallet_backend/src/notifications/entities/notification.entity.ts b/myteamwallet_backend/src/notifications/entities/notification.entity.ts new file mode 100644 index 0000000..510b0bb --- /dev/null +++ b/myteamwallet_backend/src/notifications/entities/notification.entity.ts @@ -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; +} diff --git a/myteamwallet_backend/src/notifications/model/notification-event.type.ts b/myteamwallet_backend/src/notifications/model/notification-event.type.ts new file mode 100644 index 0000000..9522fff --- /dev/null +++ b/myteamwallet_backend/src/notifications/model/notification-event.type.ts @@ -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', +]; From 97b0a5c19aaca1ec4c4acd615a1aee042094266e Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Tue, 4 Aug 2026 18:34:42 +0200 Subject: [PATCH 05/19] feat: add NotificationsService --- .../notifications.service.spec.ts | 174 ++++++++++++++++++ .../notifications/notifications.service.ts | 150 +++++++++++++++ 2 files changed, 324 insertions(+) create mode 100644 myteamwallet_backend/src/notifications/notifications.service.spec.ts create mode 100644 myteamwallet_backend/src/notifications/notifications.service.ts diff --git a/myteamwallet_backend/src/notifications/notifications.service.spec.ts b/myteamwallet_backend/src/notifications/notifications.service.spec.ts new file mode 100644 index 0000000..4d0bf1f --- /dev/null +++ b/myteamwallet_backend/src/notifications/notifications.service.spec.ts @@ -0,0 +1,174 @@ +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) { + const query: Record = {}; + ['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 () => { + playerRepository.createQueryBuilder.mockReturnValue( + chain({ getRawMany: jest.fn().mockResolvedValue([]) }), + ); + + await service.create({ + teamId: 10, + event: 'player_creation', + actorUserId: 5, + payload: { playerId: 1, playerName: 'Ada Lovelace' }, + }); + + expect(notificationRepository.save).not.toHaveBeenCalled(); + expect(recipientRepository.insert).not.toHaveBeenCalled(); + }); + + it('creates one notification and fans it out to every recipient', async () => { + playerRepository.createQueryBuilder.mockReturnValue( + chain({ getRawMany: jest.fn().mockResolvedValue([{ userId: 7 }, { userId: 8 }]) }), + ); + notificationRepository.save.mockResolvedValue({ id: 99 }); + + await service.create({ + teamId: 10, + event: 'player_creation', + actorUserId: 5, + payload: { playerId: 1, playerName: 'Ada Lovelace' }, + }); + + 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(); + }); + }); +}); diff --git a/myteamwallet_backend/src/notifications/notifications.service.ts b/myteamwallet_backend/src/notifications/notifications.service.ts new file mode 100644 index 0000000..7a73978 --- /dev/null +++ b/myteamwallet_backend/src/notifications/notifications.service.ts @@ -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; + 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, + @InjectRepository(NotificationRecipient) + private readonly recipientRepository: Repository, + @InjectRepository(Player) + private readonly playerRepository: Repository, + ) {} + + async create(params: { + teamId: number; + event: NOTIFICATION_EVENT; + actorUserId: number; + payload: Record; + }): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + }; + } +} From 273c25eccb0d06b6e1866eb3833457881ab2af56 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Tue, 4 Aug 2026 18:42:03 +0200 Subject: [PATCH 06/19] test: assert recipient-filter query clauses in NotificationsService.create --- .../notifications.service.spec.ts | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/myteamwallet_backend/src/notifications/notifications.service.spec.ts b/myteamwallet_backend/src/notifications/notifications.service.spec.ts index 4d0bf1f..08e3ef0 100644 --- a/myteamwallet_backend/src/notifications/notifications.service.spec.ts +++ b/myteamwallet_backend/src/notifications/notifications.service.spec.ts @@ -32,9 +32,8 @@ describe('NotificationsService', () => { describe('create', () => { it('does nothing when the team has no other active members with a login', async () => { - playerRepository.createQueryBuilder.mockReturnValue( - chain({ getRawMany: jest.fn().mockResolvedValue([]) }), - ); + const playerQuery = chain({ getRawMany: jest.fn().mockResolvedValue([]) }); + playerRepository.createQueryBuilder.mockReturnValue(playerQuery); await service.create({ teamId: 10, @@ -43,14 +42,18 @@ describe('NotificationsService', () => { 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 () => { - playerRepository.createQueryBuilder.mockReturnValue( - chain({ getRawMany: jest.fn().mockResolvedValue([{ userId: 7 }, { userId: 8 }]) }), - ); + const playerQuery = chain({ getRawMany: jest.fn().mockResolvedValue([{ userId: 7 }, { userId: 8 }]) }); + playerRepository.createQueryBuilder.mockReturnValue(playerQuery); notificationRepository.save.mockResolvedValue({ id: 99 }); await service.create({ @@ -60,6 +63,11 @@ describe('NotificationsService', () => { 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 }, From 05f4c2ddf08e3d932ac1afba29fea00d0a81c1b7 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Tue, 4 Aug 2026 18:46:14 +0200 Subject: [PATCH 07/19] chore: add and register @nestjs/event-emitter --- myteamwallet_backend/package-lock.json | 33 ++++++++++++++++++++++++++ myteamwallet_backend/package.json | 1 + myteamwallet_backend/src/app.module.ts | 2 ++ 3 files changed, 36 insertions(+) diff --git a/myteamwallet_backend/package-lock.json b/myteamwallet_backend/package-lock.json index 002fc50..6dc0a02 100644 --- a/myteamwallet_backend/package-lock.json +++ b/myteamwallet_backend/package-lock.json @@ -14,6 +14,7 @@ "@nestjs/common": "9.1.6", "@nestjs/config": "2.2.0", "@nestjs/core": "9.1.6", + "@nestjs/event-emitter": "^2.1.1", "@nestjs/jwt": "9.0.0", "@nestjs/passport": "9.0.0", "@nestjs/platform-express": "9.1.6", @@ -3304,6 +3305,19 @@ "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": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-9.0.0.tgz", @@ -7379,6 +7393,12 @@ "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": { "version": "3.3.0", "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": { "version": "9.0.0", "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", "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": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", diff --git a/myteamwallet_backend/package.json b/myteamwallet_backend/package.json index bbbf207..5a46660 100644 --- a/myteamwallet_backend/package.json +++ b/myteamwallet_backend/package.json @@ -33,6 +33,7 @@ "@nestjs/common": "9.1.6", "@nestjs/config": "2.2.0", "@nestjs/core": "9.1.6", + "@nestjs/event-emitter": "^2.1.1", "@nestjs/jwt": "9.0.0", "@nestjs/passport": "9.0.0", "@nestjs/platform-express": "9.1.6", diff --git a/myteamwallet_backend/src/app.module.ts b/myteamwallet_backend/src/app.module.ts index d3e4f0a..5a32493 100644 --- a/myteamwallet_backend/src/app.module.ts +++ b/myteamwallet_backend/src/app.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { ScheduleModule } from '@nestjs/schedule'; +import { EventEmitterModule } from '@nestjs/event-emitter'; import { UsersModule } from './users/users.module'; import { AuthModule } from './auth/auth.module'; import databaseConfig from './config/database.config'; @@ -29,6 +30,7 @@ import { CashboxExportModule } from './cashbox-export/cashbox-export.module'; @Module({ imports: [ ScheduleModule.forRoot(), + EventEmitterModule.forRoot(), ConfigModule.forRoot({ isGlobal: true, load: [databaseConfig, authConfig, appConfig, mailConfig], From 451b5c4e421c8d659cd40632bbd2d727bce42243 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Tue, 4 Aug 2026 18:53:48 +0200 Subject: [PATCH 08/19] feat: add notification domain events, listener, and module --- myteamwallet_backend/src/app.module.ts | 2 + .../logging/model/logging-event.type.ts | 4 +- .../events/invite-link-created.event.ts | 7 ++ .../events/notification-event-names.ts | 8 ++ .../events/player-active-changed.event.ts | 9 ++ .../events/player-created.event.ts | 8 ++ .../events/player-role-changed.event.ts | 9 ++ .../events/public-access-changed.event.ts | 13 +++ .../notifications.listener.spec.ts | 97 +++++++++++++++++++ .../notifications/notifications.listener.ts | 80 +++++++++++++++ .../src/notifications/notifications.module.ts | 20 ++++ 11 files changed, 256 insertions(+), 1 deletion(-) create mode 100644 myteamwallet_backend/src/notifications/events/invite-link-created.event.ts create mode 100644 myteamwallet_backend/src/notifications/events/notification-event-names.ts create mode 100644 myteamwallet_backend/src/notifications/events/player-active-changed.event.ts create mode 100644 myteamwallet_backend/src/notifications/events/player-created.event.ts create mode 100644 myteamwallet_backend/src/notifications/events/player-role-changed.event.ts create mode 100644 myteamwallet_backend/src/notifications/events/public-access-changed.event.ts create mode 100644 myteamwallet_backend/src/notifications/notifications.listener.spec.ts create mode 100644 myteamwallet_backend/src/notifications/notifications.listener.ts create mode 100644 myteamwallet_backend/src/notifications/notifications.module.ts diff --git a/myteamwallet_backend/src/app.module.ts b/myteamwallet_backend/src/app.module.ts index 5a32493..26bd8fb 100644 --- a/myteamwallet_backend/src/app.module.ts +++ b/myteamwallet_backend/src/app.module.ts @@ -26,6 +26,7 @@ import { TranslateModule } from './translate/translate.module'; import { PenaltyModule } from './penalty/penalty.module'; import { RecurringTransactionsModule } from './recurring-transactions/recurring-transactions.module'; import { CashboxExportModule } from './cashbox-export/cashbox-export.module'; +import { NotificationsModule } from './notifications/notifications.module'; @Module({ imports: [ @@ -63,6 +64,7 @@ import { CashboxExportModule } from './cashbox-export/cashbox-export.module'; PenaltyModule, RecurringTransactionsModule, CashboxExportModule, + NotificationsModule, ], providers: [], }) diff --git a/myteamwallet_backend/src/database/logging/model/logging-event.type.ts b/myteamwallet_backend/src/database/logging/model/logging-event.type.ts index f413d68..6b75200 100644 --- a/myteamwallet_backend/src/database/logging/model/logging-event.type.ts +++ b/myteamwallet_backend/src/database/logging/model/logging-event.type.ts @@ -38,7 +38,8 @@ export type LOGEVENT = | 'cashbox_export_subscription_run' | 'cashbox_export_subscription_run_fail' | 'log_retention_cleanup_run' - | 'log_retention_cleanup_run_fail'; + | 'log_retention_cleanup_run_fail' + | 'notification_create_fail'; export const LOGEVENT_VALUES: LOGEVENT[] = [ 'user_create', @@ -80,6 +81,7 @@ export const LOGEVENT_VALUES: LOGEVENT[] = [ 'cashbox_export_subscription_run_fail', 'log_retention_cleanup_run', 'log_retention_cleanup_run_fail', + 'notification_create_fail', ]; export type LOGLEVEL = 'FATAL' | 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' | 'TRACE'; diff --git a/myteamwallet_backend/src/notifications/events/invite-link-created.event.ts b/myteamwallet_backend/src/notifications/events/invite-link-created.event.ts new file mode 100644 index 0000000..ca31b95 --- /dev/null +++ b/myteamwallet_backend/src/notifications/events/invite-link-created.event.ts @@ -0,0 +1,7 @@ +export class InviteLinkCreatedEvent { + constructor( + public readonly teamId: number, + public readonly actorUserId: number, + public readonly teamName: string, + ) {} +} diff --git a/myteamwallet_backend/src/notifications/events/notification-event-names.ts b/myteamwallet_backend/src/notifications/events/notification-event-names.ts new file mode 100644 index 0000000..2259818 --- /dev/null +++ b/myteamwallet_backend/src/notifications/events/notification-event-names.ts @@ -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; diff --git a/myteamwallet_backend/src/notifications/events/player-active-changed.event.ts b/myteamwallet_backend/src/notifications/events/player-active-changed.event.ts new file mode 100644 index 0000000..3fe9115 --- /dev/null +++ b/myteamwallet_backend/src/notifications/events/player-active-changed.event.ts @@ -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, + ) {} +} diff --git a/myteamwallet_backend/src/notifications/events/player-created.event.ts b/myteamwallet_backend/src/notifications/events/player-created.event.ts new file mode 100644 index 0000000..ef129c1 --- /dev/null +++ b/myteamwallet_backend/src/notifications/events/player-created.event.ts @@ -0,0 +1,8 @@ +export class PlayerCreatedEvent { + constructor( + public readonly teamId: number, + public readonly actorUserId: number, + public readonly playerId: number, + public readonly playerName: string, + ) {} +} diff --git a/myteamwallet_backend/src/notifications/events/player-role-changed.event.ts b/myteamwallet_backend/src/notifications/events/player-role-changed.event.ts new file mode 100644 index 0000000..688d1c9 --- /dev/null +++ b/myteamwallet_backend/src/notifications/events/player-role-changed.event.ts @@ -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, + ) {} +} diff --git a/myteamwallet_backend/src/notifications/events/public-access-changed.event.ts b/myteamwallet_backend/src/notifications/events/public-access-changed.event.ts new file mode 100644 index 0000000..e72a5bd --- /dev/null +++ b/myteamwallet_backend/src/notifications/events/public-access-changed.event.ts @@ -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, + ) {} +} diff --git a/myteamwallet_backend/src/notifications/notifications.listener.spec.ts b/myteamwallet_backend/src/notifications/notifications.listener.spec.ts new file mode 100644 index 0000000..305a0a7 --- /dev/null +++ b/myteamwallet_backend/src/notifications/notifications.listener.spec.ts @@ -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, + }); + }); +}); diff --git a/myteamwallet_backend/src/notifications/notifications.listener.ts b/myteamwallet_backend/src/notifications/notifications.listener.ts new file mode 100644 index 0000000..2e79297 --- /dev/null +++ b/myteamwallet_backend/src/notifications/notifications.listener.ts @@ -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 { + 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 { + 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 { + return this.safeCreate('player_creation', event.teamId, event.actorUserId, { + playerId: event.playerId, + playerName: event.playerName, + }); + } + + @OnEvent(NOTIFICATION_EVENT_NAME.publicAccessEnabled) + onPublicAccessEnabled(event: PublicAccessEnabledEvent): Promise { + return this.safeCreate('public_access_enabled', event.teamId, event.actorUserId, {}); + } + + @OnEvent(NOTIFICATION_EVENT_NAME.publicAccessRotated) + onPublicAccessRotated(event: PublicAccessRotatedEvent): Promise { + return this.safeCreate('public_access_rotated', event.teamId, event.actorUserId, {}); + } + + @OnEvent(NOTIFICATION_EVENT_NAME.inviteLinkCreated) + onInviteLinkCreated(event: InviteLinkCreatedEvent): Promise { + 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, + ): Promise { + 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, + }); + } + } +} diff --git a/myteamwallet_backend/src/notifications/notifications.module.ts b/myteamwallet_backend/src/notifications/notifications.module.ts new file mode 100644 index 0000000..c403855 --- /dev/null +++ b/myteamwallet_backend/src/notifications/notifications.module.ts @@ -0,0 +1,20 @@ +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 { TeamSetting } from 'src/team-settings/entities/team-setting.entity'; +import { User } from 'src/users/entities/user.entity'; +import { TeamAccessService } from 'src/teams/team-access.service'; +import { Notification } from './entities/notification.entity'; +import { NotificationRecipient } from './entities/notification-recipient.entity'; +import { NotificationsListener } from './notifications.listener'; +import { NotificationsService } from './notifications.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Notification, NotificationRecipient, Player, TeamSetting, User]), + LoggingModule, + ], + providers: [NotificationsService, NotificationsListener, TeamAccessService], +}) +export class NotificationsModule {} From 8de4c11e24a7232895f5adc8b2cc1ceec3141945 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Tue, 4 Aug 2026 19:01:02 +0200 Subject: [PATCH 09/19] refactor: import TeamsModule instead of duplicating TeamAccessService --- .../src/notifications/notifications.module.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/myteamwallet_backend/src/notifications/notifications.module.ts b/myteamwallet_backend/src/notifications/notifications.module.ts index c403855..03bd1e0 100644 --- a/myteamwallet_backend/src/notifications/notifications.module.ts +++ b/myteamwallet_backend/src/notifications/notifications.module.ts @@ -2,9 +2,7 @@ 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 { TeamSetting } from 'src/team-settings/entities/team-setting.entity'; -import { User } from 'src/users/entities/user.entity'; -import { TeamAccessService } from 'src/teams/team-access.service'; +import { TeamsModule } from 'src/teams/teams.module'; import { Notification } from './entities/notification.entity'; import { NotificationRecipient } from './entities/notification-recipient.entity'; import { NotificationsListener } from './notifications.listener'; @@ -12,9 +10,10 @@ import { NotificationsService } from './notifications.service'; @Module({ imports: [ - TypeOrmModule.forFeature([Notification, NotificationRecipient, Player, TeamSetting, User]), + TypeOrmModule.forFeature([Notification, NotificationRecipient, Player]), LoggingModule, + TeamsModule, ], - providers: [NotificationsService, NotificationsListener, TeamAccessService], + providers: [NotificationsService, NotificationsListener], }) export class NotificationsModule {} From 639ca651d848504cf1388edbad88376675046a20 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Tue, 4 Aug 2026 19:05:40 +0200 Subject: [PATCH 10/19] feat: add NotificationsController --- .../dto/notification-query.dto.ts | 16 +++ .../notifications/notifications.controller.ts | 62 +++++++++ .../notifications/notifications.http.spec.ts | 119 ++++++++++++++++++ .../src/notifications/notifications.module.ts | 2 + 4 files changed, 199 insertions(+) create mode 100644 myteamwallet_backend/src/notifications/dto/notification-query.dto.ts create mode 100644 myteamwallet_backend/src/notifications/notifications.controller.ts create mode 100644 myteamwallet_backend/src/notifications/notifications.http.spec.ts diff --git a/myteamwallet_backend/src/notifications/dto/notification-query.dto.ts b/myteamwallet_backend/src/notifications/dto/notification-query.dto.ts new file mode 100644 index 0000000..cf0169a --- /dev/null +++ b/myteamwallet_backend/src/notifications/dto/notification-query.dto.ts @@ -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; +} diff --git a/myteamwallet_backend/src/notifications/notifications.controller.ts b/myteamwallet_backend/src/notifications/notifications.controller.ts new file mode 100644 index 0000000..46b4920 --- /dev/null +++ b/myteamwallet_backend/src/notifications/notifications.controller.ts @@ -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); + } +} diff --git a/myteamwallet_backend/src/notifications/notifications.http.spec.ts b/myteamwallet_backend/src/notifications/notifications.http.spec.ts new file mode 100644 index 0000000..ef3ca66 --- /dev/null +++ b/myteamwallet_backend/src/notifications/notifications.http.spec.ts @@ -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); + }); +}); diff --git a/myteamwallet_backend/src/notifications/notifications.module.ts b/myteamwallet_backend/src/notifications/notifications.module.ts index 03bd1e0..4db0483 100644 --- a/myteamwallet_backend/src/notifications/notifications.module.ts +++ b/myteamwallet_backend/src/notifications/notifications.module.ts @@ -5,6 +5,7 @@ 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'; @@ -14,6 +15,7 @@ import { NotificationsService } from './notifications.service'; LoggingModule, TeamsModule, ], + controllers: [NotificationsController], providers: [NotificationsService, NotificationsListener], }) export class NotificationsModule {} From d6733eff0d75ed804e8868cb2285e5ee9ae2998f Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Tue, 4 Aug 2026 19:13:42 +0200 Subject: [PATCH 11/19] feat: emit notification events on player active/role changes --- .../src/teams/team-members.service.spec.ts | 51 ++++++++++++++++++- .../src/teams/team-members.service.ts | 47 ++++++++++++++--- 2 files changed, 91 insertions(+), 7 deletions(-) diff --git a/myteamwallet_backend/src/teams/team-members.service.spec.ts b/myteamwallet_backend/src/teams/team-members.service.spec.ts index 956614f..2c84646 100644 --- a/myteamwallet_backend/src/teams/team-members.service.spec.ts +++ b/myteamwallet_backend/src/teams/team-members.service.spec.ts @@ -18,6 +18,7 @@ describe('TeamMembersService', () => { let dataSource: any; let logger: any; let access: any; + let eventEmitter: any; let service: TeamMembersService; beforeEach(() => { @@ -44,7 +45,8 @@ describe('TeamMembersService', () => { dataSource = { transaction: jest.fn((work) => work(manager)) }; logger = { info: jest.fn() }; 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 () => { @@ -188,6 +190,53 @@ describe('TeamMembersService', () => { ).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( id: number, active: boolean, diff --git a/myteamwallet_backend/src/teams/team-members.service.ts b/myteamwallet_backend/src/teams/team-members.service.ts index b373975..6d3ff02 100644 --- a/myteamwallet_backend/src/teams/team-members.service.ts +++ b/myteamwallet_backend/src/teams/team-members.service.ts @@ -1,6 +1,10 @@ import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { DataSource, EntityManager, Repository } from 'typeorm'; 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 { TeamRole } from '../team-roles/entities/team-roles.entity'; import { TeamRolesEnum } from '../team-roles/team-roles.enum'; @@ -18,6 +22,7 @@ export class TeamMembersService { private readonly dataSource: DataSource, private readonly logger: LoggingService, private readonly access: TeamAccessService, + private readonly eventEmitter: EventEmitter2, ) {} async setActive( @@ -33,12 +38,12 @@ export class TeamMembersService { TeamRolesEnum.captain, ); - return this.dataSource.transaction(async (manager) => { + const result = await this.dataSource.transaction(async (manager) => { const activeTreasurers = await this.lockActiveTreasurers(manager, teamId); const playerRepository = manager.getRepository(Player); 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; if ( @@ -66,8 +71,23 @@ export class TeamMembersService { actorUserId, `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( @@ -83,12 +103,12 @@ export class TeamMembersService { TeamRolesEnum.captain, ); - return this.dataSource.transaction(async (manager) => { + const result = await this.dataSource.transaction(async (manager) => { const activeTreasurers = await this.lockActiveTreasurers(manager, teamId); const playerRepository = manager.getRepository(Player); 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 = player.active && @@ -108,8 +128,23 @@ export class TeamMembersService { actorUserId, `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, From 017e6445fa33999aabc4ccd64143e65f0765372e Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Tue, 4 Aug 2026 19:21:01 +0200 Subject: [PATCH 12/19] feat: log and emit notification events on public-access enable/rotate --- .../logging/model/logging-event.type.ts | 6 ++- .../teams/public-team-access.service.spec.ts | 47 +++++++++++++++++++ .../src/teams/public-team-access.service.ts | 33 +++++++++++++ 3 files changed, 85 insertions(+), 1 deletion(-) diff --git a/myteamwallet_backend/src/database/logging/model/logging-event.type.ts b/myteamwallet_backend/src/database/logging/model/logging-event.type.ts index 6b75200..f2cd9a0 100644 --- a/myteamwallet_backend/src/database/logging/model/logging-event.type.ts +++ b/myteamwallet_backend/src/database/logging/model/logging-event.type.ts @@ -39,7 +39,9 @@ export type LOGEVENT = | 'cashbox_export_subscription_run_fail' | 'log_retention_cleanup_run' | 'log_retention_cleanup_run_fail' - | 'notification_create_fail'; + | 'notification_create_fail' + | 'public_access_enabled' + | 'public_access_rotated'; export const LOGEVENT_VALUES: LOGEVENT[] = [ 'user_create', @@ -82,6 +84,8 @@ export const LOGEVENT_VALUES: LOGEVENT[] = [ 'log_retention_cleanup_run', 'log_retention_cleanup_run_fail', 'notification_create_fail', + 'public_access_enabled', + 'public_access_rotated', ]; export type LOGLEVEL = 'FATAL' | 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' | 'TRACE'; diff --git a/myteamwallet_backend/src/teams/public-team-access.service.spec.ts b/myteamwallet_backend/src/teams/public-team-access.service.spec.ts index 9d820a8..bb3ce9d 100644 --- a/myteamwallet_backend/src/teams/public-team-access.service.spec.ts +++ b/myteamwallet_backend/src/teams/public-team-access.service.spec.ts @@ -13,6 +13,8 @@ describe('PublicTeamAccessService', () => { const penaltyRepository = { find: jest.fn() }; const access = { assertMember: jest.fn(), assertAtLeast: jest.fn() }; let service: PublicTeamAccessService; + let logger: any; + let eventEmitter: any; const managedTeam = { id: 7, @@ -25,12 +27,16 @@ describe('PublicTeamAccessService', () => { beforeEach(() => { jest.resetAllMocks(); teamRepository.save.mockImplementation(async (team) => team); + logger = { info: jest.fn() }; + eventEmitter = { emit: jest.fn() }; service = new PublicTeamAccessService( teamRepository as any, playerRepository as any, transactionRepository as any, penaltyRepository 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)); }); + 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 () => { teamRepository.findOne.mockResolvedValue({ id: 7, diff --git a/myteamwallet_backend/src/teams/public-team-access.service.ts b/myteamwallet_backend/src/teams/public-team-access.service.ts index 368e620..905ea03 100644 --- a/myteamwallet_backend/src/teams/public-team-access.service.ts +++ b/myteamwallet_backend/src/teams/public-team-access.service.ts @@ -1,6 +1,13 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { EventEmitter2 } from '@nestjs/event-emitter'; 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 { Player } from '../players/entities/player.entity'; import { TeamRolesEnum } from '../team-roles/team-roles.enum'; @@ -28,6 +35,8 @@ export class PublicTeamAccessService { @InjectRepository(PenaltyEntity) private readonly penaltyRepository: Repository, private readonly access: TeamAccessService, + private readonly logger: LoggingService, + private readonly eventEmitter: EventEmitter2, ) {} async getStatus( @@ -55,6 +64,19 @@ export class PublicTeamAccessService { } team.publicAccessEnabled = enabled; 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); } @@ -68,6 +90,17 @@ export class PublicTeamAccessService { const team = await this.loadManagedTeam(teamId); team.publicAccessToken = this.createToken(); 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); } From 812061fc6caa344ae37c28f90330d2d27762dfdd Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Tue, 4 Aug 2026 19:27:45 +0200 Subject: [PATCH 13/19] feat: emit notification event on player creation --- .../src/teams/teams.service.spec.ts | 50 +++++++++++++++++++ .../src/teams/teams.service.ts | 9 ++++ 2 files changed, 59 insertions(+) diff --git a/myteamwallet_backend/src/teams/teams.service.spec.ts b/myteamwallet_backend/src/teams/teams.service.spec.ts index 59d302f..b5ed243 100644 --- a/myteamwallet_backend/src/teams/teams.service.spec.ts +++ b/myteamwallet_backend/src/teams/teams.service.spec.ts @@ -31,6 +31,7 @@ describe('TeamsService#getOverviewStats theoretical balance', () => { access as any, {} as any, {} as any, + { emit: jest.fn() } as any, ); }); @@ -290,6 +291,7 @@ describe('TeamsService#getTeamTransactionsJournal', () => { access as any, {} as any, {} as any, + { emit: jest.fn() } as any, ); }); @@ -410,6 +412,7 @@ describe('TeamsService#createNewTeam', () => { {} as any, {} as any, dataSource as any, + { emit: jest.fn() } as any, ); }); @@ -464,3 +467,50 @@ describe('TeamsService#createNewTeam', () => { 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', + }), + ); + }); +}); diff --git a/myteamwallet_backend/src/teams/teams.service.ts b/myteamwallet_backend/src/teams/teams.service.ts index 2a5a38f..c7464cb 100644 --- a/myteamwallet_backend/src/teams/teams.service.ts +++ b/myteamwallet_backend/src/teams/teams.service.ts @@ -1,6 +1,9 @@ import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { EventEmitter2 } from '@nestjs/event-emitter'; 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 { TeamRole } from 'src/team-roles/entities/team-roles.entity'; import { CreateTeamSettingDTO } from 'src/team-settings/dto/create-team-setting.dto'; @@ -51,6 +54,7 @@ export class TeamsService { @InjectRepository(User) private usersRepository: Repository, private dataSource: DataSource, + private eventEmitter: EventEmitter2, ) {} async getOverview(teamId: string, actorUserId: string) { @@ -165,6 +169,11 @@ export class TeamsService { details: `Spieler ${playerSaved.id}, ${p.firstName} ${p.lastName} erstellt`, userId: Number(id), }); + + this.eventEmitter.emit( + NOTIFICATION_EVENT_NAME.playerCreated, + new PlayerCreatedEvent(Number(id), Number(actorUserId), playerSaved.id, `${p.firstName} ${p.lastName}`), + ); return playerSaved; } From eb1173c70621c7b5817a7cd3cf730ce1bac4f8cc Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Tue, 4 Aug 2026 19:33:39 +0200 Subject: [PATCH 14/19] feat: emit notification event on invite-link creation --- .../src/auth/auth.service.spec.ts | 16 ++++++++++++++++ myteamwallet_backend/src/auth/auth.service.ts | 9 +++++++++ 2 files changed, 25 insertions(+) diff --git a/myteamwallet_backend/src/auth/auth.service.spec.ts b/myteamwallet_backend/src/auth/auth.service.spec.ts index cd23f38..f6b61bc 100644 --- a/myteamwallet_backend/src/auth/auth.service.spec.ts +++ b/myteamwallet_backend/src/auth/auth.service.spec.ts @@ -14,6 +14,7 @@ describe('AuthService inactive-user enforcement and safe logging', () => { let userRepository: any; let service: AuthService; let mailService: any; + let eventEmitter: any; beforeEach(() => { jwtService = { @@ -44,6 +45,7 @@ describe('AuthService inactive-user enforcement and safe logging', () => { dataSource = { transaction: jest.fn((work) => work(manager)), }; + eventEmitter = { emit: jest.fn() }; service = new AuthService( jwtService, usersService, @@ -52,6 +54,7 @@ describe('AuthService inactive-user enforcement and safe logging', () => { logger, dataSource, { 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(); }); + 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) { return { id: 2, diff --git a/myteamwallet_backend/src/auth/auth.service.ts b/myteamwallet_backend/src/auth/auth.service.ts index 4c39b65..f87f4ec 100644 --- a/myteamwallet_backend/src/auth/auth.service.ts +++ b/myteamwallet_backend/src/auth/auth.service.ts @@ -6,6 +6,7 @@ import { UnauthorizedException, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { User } from '../users/entities/user.entity'; import * as bcrypt from 'bcryptjs'; 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 { TeamAccessService } from 'src/teams/team-access.service'; 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() export class AuthService { @@ -38,6 +41,7 @@ export class AuthService { private logger: LoggingService, private dataSource: DataSource, private teamAccess: TeamAccessService, + private eventEmitter: EventEmitter2, ) {} async validateLogin( @@ -323,6 +327,11 @@ export class AuthService { userId: 0, }); + this.eventEmitter.emit( + NOTIFICATION_EVENT_NAME.inviteLinkCreated, + new InviteLinkCreatedEvent(object.teamId, actorUserId, object.teamName), + ); + return { token }; } From a7b087050c5ee543768f0e8b4fdac13f4c37e939 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Tue, 4 Aug 2026 19:41:44 +0200 Subject: [PATCH 15/19] feat: add notification retention scheduler --- .../logging/model/logging-event.type.ts | 4 ++ .../notification-retention.scheduler.spec.ts | 55 +++++++++++++++++++ .../notification-retention.scheduler.ts | 41 ++++++++++++++ .../src/notifications/notifications.module.ts | 3 +- 4 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 myteamwallet_backend/src/notifications/notification-retention.scheduler.spec.ts create mode 100644 myteamwallet_backend/src/notifications/notification-retention.scheduler.ts diff --git a/myteamwallet_backend/src/database/logging/model/logging-event.type.ts b/myteamwallet_backend/src/database/logging/model/logging-event.type.ts index f2cd9a0..5603f75 100644 --- a/myteamwallet_backend/src/database/logging/model/logging-event.type.ts +++ b/myteamwallet_backend/src/database/logging/model/logging-event.type.ts @@ -40,6 +40,8 @@ export type LOGEVENT = | 'log_retention_cleanup_run' | 'log_retention_cleanup_run_fail' | 'notification_create_fail' + | 'notification_retention_cleanup_run' + | 'notification_retention_cleanup_run_fail' | 'public_access_enabled' | 'public_access_rotated'; @@ -84,6 +86,8 @@ export const LOGEVENT_VALUES: LOGEVENT[] = [ 'log_retention_cleanup_run', 'log_retention_cleanup_run_fail', 'notification_create_fail', + 'notification_retention_cleanup_run', + 'notification_retention_cleanup_run_fail', 'public_access_enabled', 'public_access_rotated', ]; diff --git a/myteamwallet_backend/src/notifications/notification-retention.scheduler.spec.ts b/myteamwallet_backend/src/notifications/notification-retention.scheduler.spec.ts new file mode 100644 index 0000000..3539c15 --- /dev/null +++ b/myteamwallet_backend/src/notifications/notification-retention.scheduler.spec.ts @@ -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(); + }); +}); diff --git a/myteamwallet_backend/src/notifications/notification-retention.scheduler.ts b/myteamwallet_backend/src/notifications/notification-retention.scheduler.ts new file mode 100644 index 0000000..890f0d0 --- /dev/null +++ b/myteamwallet_backend/src/notifications/notification-retention.scheduler.ts @@ -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, + private readonly configService: ConfigService, + private readonly logger: LoggingService, + ) {} + + @Cron(CronExpression.EVERY_DAY_AT_5AM) + async cleanupOldNotifications(): Promise { + const retentionDays = this.configService.get('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, + }); + } + } +} diff --git a/myteamwallet_backend/src/notifications/notifications.module.ts b/myteamwallet_backend/src/notifications/notifications.module.ts index 4db0483..37df3f7 100644 --- a/myteamwallet_backend/src/notifications/notifications.module.ts +++ b/myteamwallet_backend/src/notifications/notifications.module.ts @@ -8,6 +8,7 @@ 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: [ @@ -16,6 +17,6 @@ import { NotificationsService } from './notifications.service'; TeamsModule, ], controllers: [NotificationsController], - providers: [NotificationsService, NotificationsListener], + providers: [NotificationsService, NotificationsListener, NotificationRetentionScheduler], }) export class NotificationsModule {} From 6b3a9d69cc6489b8b9c6354072fc9a67d4174f94 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Tue, 4 Aug 2026 19:51:11 +0200 Subject: [PATCH 16/19] feat: add notification model, presentation helpers, and API client --- .../notification-presentation.spec.ts | 75 +++++++++++++++++++ .../notification-presentation.ts | 50 +++++++++++++ .../notifications/notifications-api.spec.ts | 48 ++++++++++++ .../core/notifications/notifications-api.ts | 31 ++++++++ .../src/app/models/notification.model.ts | 37 +++++++++ 5 files changed, 241 insertions(+) create mode 100644 myteamwallet_frontend_modern/src/app/core/notifications/notification-presentation.spec.ts create mode 100644 myteamwallet_frontend_modern/src/app/core/notifications/notification-presentation.ts create mode 100644 myteamwallet_frontend_modern/src/app/core/notifications/notifications-api.spec.ts create mode 100644 myteamwallet_frontend_modern/src/app/core/notifications/notifications-api.ts create mode 100644 myteamwallet_frontend_modern/src/app/models/notification.model.ts diff --git a/myteamwallet_frontend_modern/src/app/core/notifications/notification-presentation.spec.ts b/myteamwallet_frontend_modern/src/app/core/notifications/notification-presentation.spec.ts new file mode 100644 index 0000000..7ce37c2 --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/core/notifications/notification-presentation.spec.ts @@ -0,0 +1,75 @@ +import { NotificationItem } from '../../models/notification.model'; +import { notificationIcon, notificationLabel, notificationTarget } from './notification-presentation'; + +function item(overrides: Partial): 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', + ]); + }); +}); diff --git a/myteamwallet_frontend_modern/src/app/core/notifications/notification-presentation.ts b/myteamwallet_frontend_modern/src/app/core/notifications/notification-presentation.ts new file mode 100644 index 0000000..d745dee --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/core/notifications/notification-presentation.ts @@ -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']; + } +} diff --git a/myteamwallet_frontend_modern/src/app/core/notifications/notifications-api.spec.ts b/myteamwallet_frontend_modern/src/app/core/notifications/notifications-api.spec.ts new file mode 100644 index 0000000..31e1e76 --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/core/notifications/notifications-api.spec.ts @@ -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); + }); +}); diff --git a/myteamwallet_frontend_modern/src/app/core/notifications/notifications-api.ts b/myteamwallet_frontend_modern/src/app/core/notifications/notifications-api.ts new file mode 100644 index 0000000..d8d73b1 --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/core/notifications/notifications-api.ts @@ -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 { + const params = new HttpParams().set('page', query.page).set('limit', query.limit); + return this.http.get(`${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 { + return this.http.patch(`${environment.apiUrl}teams/${teamId}/notifications/${id}/read`, {}); + } + + markAllRead(teamId: number): Observable { + return this.http.patch(`${environment.apiUrl}teams/${teamId}/notifications/read-all`, {}); + } +} diff --git a/myteamwallet_frontend_modern/src/app/models/notification.model.ts b/myteamwallet_frontend_modern/src/app/models/notification.model.ts new file mode 100644 index 0000000..6a833fb --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/models/notification.model.ts @@ -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; +} From 8ace676abf02e4bec6ed1f0c914a6eda0770421d Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Tue, 4 Aug 2026 20:01:30 +0200 Subject: [PATCH 17/19] feat: add NotificationsStore --- .../notifications/notifications-store.spec.ts | 102 ++++++++++++++++++ .../core/notifications/notifications-store.ts | 71 ++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 myteamwallet_frontend_modern/src/app/core/notifications/notifications-store.spec.ts create mode 100644 myteamwallet_frontend_modern/src/app/core/notifications/notifications-store.ts diff --git a/myteamwallet_frontend_modern/src/app/core/notifications/notifications-store.spec.ts b/myteamwallet_frontend_modern/src/app/core/notifications/notifications-store.spec.ts new file mode 100644 index 0000000..172e0bf --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/core/notifications/notifications-store.spec.ts @@ -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; + loadNotifications: ReturnType; + markRead: ReturnType; + markAllRead: ReturnType; + }; + 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); + }); +}); diff --git a/myteamwallet_frontend_modern/src/app/core/notifications/notifications-store.ts b/myteamwallet_frontend_modern/src/app/core/notifications/notifications-store.ts new file mode 100644 index 0000000..413a64d --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/core/notifications/notifications-store.ts @@ -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([]); + private readonly loadingSignal = signal(false); + private readonly pollingTeamId = signal(null); + private readonly pollRequests = new Subject(); + + 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); + }); + } +} From b40af02e2f1fc13e5d478f6a929e1095aa17c034 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Tue, 4 Aug 2026 20:09:07 +0200 Subject: [PATCH 18/19] feat: add notification bell and dropdown to the app shell --- .../src/app/core/layout/shell/shell.html | 40 +++++++++ .../src/app/core/layout/shell/shell.scss | 34 ++++++++ .../src/app/core/layout/shell/shell.spec.ts | 84 ++++++++++++++++++- .../src/app/core/layout/shell/shell.ts | 52 ++++++++++-- 4 files changed, 202 insertions(+), 8 deletions(-) diff --git a/myteamwallet_frontend_modern/src/app/core/layout/shell/shell.html b/myteamwallet_frontend_modern/src/app/core/layout/shell/shell.html index 8d6a51f..6012249 100644 --- a/myteamwallet_frontend_modern/src/app/core/layout/shell/shell.html +++ b/myteamwallet_frontend_modern/src/app/core/layout/shell/shell.html @@ -12,6 +12,46 @@ } @else { {{ currentTeam()?.name ?? 'TeamWallet' }} } + + + + + +
+ Benachrichtigungen + +
+ @if (notifications().length === 0) { +
Keine Benachrichtigungen
+ } @else { + @for (item of notifications(); track item.id) { + + } + @if (currentTeamId(); as teamId) { + Alle anzeigen + } + } +
diff --git a/myteamwallet_frontend_modern/src/app/core/layout/shell/shell.scss b/myteamwallet_frontend_modern/src/app/core/layout/shell/shell.scss index 73dec71..7ac94b5 100644 --- a/myteamwallet_frontend_modern/src/app/core/layout/shell/shell.scss +++ b/myteamwallet_frontend_modern/src/app/core/layout/shell/shell.scss @@ -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; + } + } +} diff --git a/myteamwallet_frontend_modern/src/app/core/layout/shell/shell.spec.ts b/myteamwallet_frontend_modern/src/app/core/layout/shell/shell.spec.ts index cdf0d95..4e5d2cd 100644 --- a/myteamwallet_frontend_modern/src/app/core/layout/shell/shell.spec.ts +++ b/myteamwallet_frontend_modern/src/app/core/layout/shell/shell.spec.ts @@ -1,27 +1,55 @@ import { TestBed } from '@angular/core/testing'; +import { signal } from '@angular/core'; 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 { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { Shell } from './shell'; import { environment } from '../../../../environments/environment'; import { AuthStore } from '../../auth/auth-store'; import { Player } from '../../../models/player.model'; +import { NotificationsStore } from '../../notifications/notifications-store'; describe('Shell', () => { let httpMock: HttpTestingController; let authStore: AuthStore; let routeParams: BehaviorSubject>; + let notificationsStore: { + unreadCount: ReturnType>; + notifications: ReturnType>; + startPolling: ReturnType; + loadRecent: ReturnType; + markRead: ReturnType; + markAllRead: ReturnType; + }; beforeEach(async () => { localStorage.clear(); 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({ imports: [Shell], providers: [ provideHttpClient(), provideHttpClientTesting(), provideRouter([]), + { provide: NotificationsStore, useValue: notificationsStore }, { provide: ActivatedRoute, useValue: { paramMap: routeParams.asObservable() }, @@ -153,4 +181,58 @@ describe('Shell', () => { 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); + }); }); diff --git a/myteamwallet_frontend_modern/src/app/core/layout/shell/shell.ts b/myteamwallet_frontend_modern/src/app/core/layout/shell/shell.ts index ea0a1f7..7246f6a 100644 --- a/myteamwallet_frontend_modern/src/app/core/layout/shell/shell.ts +++ b/myteamwallet_frontend_modern/src/app/core/layout/shell/shell.ts @@ -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 { ActivatedRoute, @@ -7,6 +7,7 @@ import { RouterLinkActive, RouterOutlet, } from '@angular/router'; +import { MatBadgeModule } from '@angular/material/badge'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatMenuModule } from '@angular/material/menu'; @@ -14,7 +15,14 @@ import { MatToolbarModule } from '@angular/material/toolbar'; import { AuthStore } from '../../auth/auth-store'; import { MyTeamsStore } from '../../team/my-teams-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 { NotificationItem } from '../../../models/notification.model'; @Component({ selector: 'app-shell', @@ -26,6 +34,7 @@ import { UserTeamReference } from '../../../models/user-directory.model'; MatIconModule, MatMenuModule, MatButtonModule, + MatBadgeModule, ], templateUrl: './shell.html', styleUrl: './shell.scss', @@ -36,8 +45,12 @@ export class Shell { private readonly authStore = inject(AuthStore); private readonly myTeamsStore = inject(MyTeamsStore); private readonly teamStore = inject(TeamStore); + private readonly notificationsStore = inject(NotificationsStore); protected readonly currentTeam = this.teamStore.team; + protected readonly currentTeamId = signal(null); + protected readonly unreadCount = this.notificationsStore.unreadCount; + protected readonly notifications = this.notificationsStore.notifications; protected readonly myTeams = computed(() => { const seen = new Set(); @@ -57,17 +70,13 @@ export class Shell { 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) => { const raw = params.get('id'); const id = raw === null ? Number.NaN : Number(raw); if (Number.isInteger(id) && id > 0) { this.teamStore.loadTeam(id); + this.currentTeamId.set(id); + this.notificationsStore.startPolling(id); } }); } @@ -75,4 +84,33 @@ export class Shell { protected switchTeam(teamId: number): void { 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); + } + } } From fe523bdce1f37fc97719343d54924da63bde0e97 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Tue, 4 Aug 2026 20:26:06 +0200 Subject: [PATCH 19/19] feat: add full notifications history page and route --- .../src/app/app.routes.ts | 5 + .../team/notifications/notifications.html | 29 ++++++ .../team/notifications/notifications.scss | 15 +++ .../team/notifications/notifications.spec.ts | 74 +++++++++++++++ .../team/notifications/notifications.ts | 92 +++++++++++++++++++ 5 files changed, 215 insertions(+) create mode 100644 myteamwallet_frontend_modern/src/app/features/team/notifications/notifications.html create mode 100644 myteamwallet_frontend_modern/src/app/features/team/notifications/notifications.scss create mode 100644 myteamwallet_frontend_modern/src/app/features/team/notifications/notifications.spec.ts create mode 100644 myteamwallet_frontend_modern/src/app/features/team/notifications/notifications.ts diff --git a/myteamwallet_frontend_modern/src/app/app.routes.ts b/myteamwallet_frontend_modern/src/app/app.routes.ts index fdf4306..c2b0d30 100644 --- a/myteamwallet_frontend_modern/src/app/app.routes.ts +++ b/myteamwallet_frontend_modern/src/app/app.routes.ts @@ -123,6 +123,11 @@ export const routes: Routes = [ loadComponent: () => import('./features/team/more/guide/guide').then((m) => m.Guide), }, + { + path: 'notifications', + loadComponent: () => + import('./features/team/notifications/notifications').then((m) => m.Notifications), + }, ], }, { diff --git a/myteamwallet_frontend_modern/src/app/features/team/notifications/notifications.html b/myteamwallet_frontend_modern/src/app/features/team/notifications/notifications.html new file mode 100644 index 0000000..c2e5252 --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/features/team/notifications/notifications.html @@ -0,0 +1,29 @@ +
+

Benachrichtigungen

+ + @if (items().length === 0 && !loading()) { +

Keine Benachrichtigungen vorhanden.

+ } + + + @for (item of items(); track item.id) { + + {{ notificationIcon(item) }} + {{ notificationLabel(item) }} + + } + + + @if (loading()) { + + } + + @if (hasNextPage() && !loading()) { + + } +
diff --git a/myteamwallet_frontend_modern/src/app/features/team/notifications/notifications.scss b/myteamwallet_frontend_modern/src/app/features/team/notifications/notifications.scss new file mode 100644 index 0000000..ff6fc09 --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/features/team/notifications/notifications.scss @@ -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; + } +} diff --git a/myteamwallet_frontend_modern/src/app/features/team/notifications/notifications.spec.ts b/myteamwallet_frontend_modern/src/app/features/team/notifications/notifications.spec.ts new file mode 100644 index 0000000..1208efe --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/features/team/notifications/notifications.spec.ts @@ -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; + let fixture: ComponentFixture; + let api: { loadNotifications: ReturnType }; + let store: { markRead: ReturnType }; + + 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]); + }); +}); diff --git a/myteamwallet_frontend_modern/src/app/features/team/notifications/notifications.ts b/myteamwallet_frontend_modern/src/app/features/team/notifications/notifications.ts new file mode 100644 index 0000000..9098357 --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/features/team/notifications/notifications.ts @@ -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([]); + 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), + }); + } +}