From ecfa847d2a831eaa13281f53bc2332ec1f02b7c2 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Tue, 4 Aug 2026 18:18:20 +0200 Subject: [PATCH] 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.