From 97b0a5c19aaca1ec4c4acd615a1aee042094266e Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Tue, 4 Aug 2026 18:34:42 +0200 Subject: [PATCH] feat: add NotificationsService --- .../notifications.service.spec.ts | 174 ++++++++++++++++++ .../notifications/notifications.service.ts | 150 +++++++++++++++ 2 files changed, 324 insertions(+) create mode 100644 myteamwallet_backend/src/notifications/notifications.service.spec.ts create mode 100644 myteamwallet_backend/src/notifications/notifications.service.ts diff --git a/myteamwallet_backend/src/notifications/notifications.service.spec.ts b/myteamwallet_backend/src/notifications/notifications.service.spec.ts new file mode 100644 index 0000000..4d0bf1f --- /dev/null +++ b/myteamwallet_backend/src/notifications/notifications.service.spec.ts @@ -0,0 +1,174 @@ +import { NotFoundException } from '@nestjs/common'; +import { NotificationsService } from './notifications.service'; + +describe('NotificationsService', () => { + const notificationRepository = { create: jest.fn(), save: jest.fn() }; + const recipientRepository = { + insert: jest.fn(), + createQueryBuilder: jest.fn(), + findOne: jest.fn(), + save: jest.fn(), + update: jest.fn(), + }; + const playerRepository = { createQueryBuilder: jest.fn() }; + let service: NotificationsService; + + beforeEach(() => { + jest.resetAllMocks(); + notificationRepository.create.mockImplementation((value) => value); + service = new NotificationsService( + notificationRepository as any, + recipientRepository as any, + playerRepository as any, + ); + }); + + function chain(overrides: Record) { + const query: Record = {}; + ['innerJoin', 'innerJoinAndSelect', 'where', 'andWhere', 'select', 'orderBy', 'offset', 'limit'] + .forEach((method) => (query[method] = jest.fn(() => query))); + return Object.assign(query, overrides); + } + + describe('create', () => { + it('does nothing when the team has no other active members with a login', async () => { + playerRepository.createQueryBuilder.mockReturnValue( + chain({ getRawMany: jest.fn().mockResolvedValue([]) }), + ); + + await service.create({ + teamId: 10, + event: 'player_creation', + actorUserId: 5, + payload: { playerId: 1, playerName: 'Ada Lovelace' }, + }); + + expect(notificationRepository.save).not.toHaveBeenCalled(); + expect(recipientRepository.insert).not.toHaveBeenCalled(); + }); + + it('creates one notification and fans it out to every recipient', async () => { + playerRepository.createQueryBuilder.mockReturnValue( + chain({ getRawMany: jest.fn().mockResolvedValue([{ userId: 7 }, { userId: 8 }]) }), + ); + notificationRepository.save.mockResolvedValue({ id: 99 }); + + await service.create({ + teamId: 10, + event: 'player_creation', + actorUserId: 5, + payload: { playerId: 1, playerName: 'Ada Lovelace' }, + }); + + expect(notificationRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + team: { id: 10 }, + event: 'player_creation', + actorUserId: 5, + payload: JSON.stringify({ playerId: 1, playerName: 'Ada Lovelace' }), + }), + ); + expect(recipientRepository.insert).toHaveBeenCalledWith([ + { notification: { id: 99 }, userId: 7 }, + { notification: { id: 99 }, userId: 8 }, + ]); + }); + }); + + describe('listForUser', () => { + it('maps recipient rows to notification DTOs with parsed payloads', async () => { + const query = chain({ + getCount: jest.fn().mockResolvedValue(1), + getMany: jest.fn().mockResolvedValue([ + { + id: 1, + userId: 7, + read: false, + notification: { + event: 'player_creation', + actorUserId: 5, + payload: JSON.stringify({ playerId: 1, playerName: 'Ada Lovelace' }), + createdAt: new Date('2026-08-04T10:00:00.000Z'), + }, + }, + ]), + }); + recipientRepository.createQueryBuilder.mockReturnValue(query); + + const page = await service.listForUser(7, 10, 1, 20); + + expect(page).toEqual({ + data: [ + { + id: 1, + event: 'player_creation', + actorUserId: 5, + payload: { playerId: 1, playerName: 'Ada Lovelace' }, + read: false, + createdAt: new Date('2026-08-04T10:00:00.000Z'), + }, + ], + page: 1, + limit: 20, + total: 1, + hasNextPage: false, + }); + }); + }); + + describe('getUnreadCount', () => { + it('counts only unread recipient rows for the given user and team', async () => { + const query = chain({ getCount: jest.fn().mockResolvedValue(3) }); + recipientRepository.createQueryBuilder.mockReturnValue(query); + + await expect(service.getUnreadCount(7, 10)).resolves.toBe(3); + expect(query.where).toHaveBeenCalledWith('recipient.userId = :userId', { userId: 7 }); + }); + }); + + describe('markRead', () => { + it('marks a recipient row as read', async () => { + recipientRepository.findOne.mockResolvedValue({ id: 1, userId: 7, read: false, readAt: null }); + + await service.markRead(1, 7); + + expect(recipientRepository.save).toHaveBeenCalledWith(expect.objectContaining({ read: true })); + }); + + it('rejects marking a recipient row that belongs to another user', async () => { + recipientRepository.findOne.mockResolvedValue({ id: 1, userId: 999, read: false }); + + await expect(service.markRead(1, 7)).rejects.toBeInstanceOf(NotFoundException); + expect(recipientRepository.save).not.toHaveBeenCalled(); + }); + + it('rejects marking a recipient row that does not exist', async () => { + recipientRepository.findOne.mockResolvedValue(null); + + await expect(service.markRead(1, 7)).rejects.toBeInstanceOf(NotFoundException); + }); + }); + + describe('markAllRead', () => { + it('marks every unread recipient row for the user and team as read', async () => { + const query = chain({ getRawMany: jest.fn().mockResolvedValue([{ id: 1 }, { id: 2 }]) }); + recipientRepository.createQueryBuilder.mockReturnValue(query); + + await service.markAllRead(7, 10); + + expect(recipientRepository.update).toHaveBeenCalledWith( + [1, 2], + expect.objectContaining({ read: true }), + ); + }); + + it('does nothing when there is nothing unread', async () => { + const query = chain({ getRawMany: jest.fn().mockResolvedValue([]) }); + recipientRepository.createQueryBuilder.mockReturnValue(query); + + await service.markAllRead(7, 10); + + expect(recipientRepository.update).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/myteamwallet_backend/src/notifications/notifications.service.ts b/myteamwallet_backend/src/notifications/notifications.service.ts new file mode 100644 index 0000000..7a73978 --- /dev/null +++ b/myteamwallet_backend/src/notifications/notifications.service.ts @@ -0,0 +1,150 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Player } from 'src/players/entities/player.entity'; +import { Team } from 'src/teams/entities/team.entity'; +import { Notification } from './entities/notification.entity'; +import { NotificationRecipient } from './entities/notification-recipient.entity'; +import { NOTIFICATION_EVENT } from './model/notification-event.type'; + +export interface NotificationDto { + id: number; + event: NOTIFICATION_EVENT; + actorUserId: number; + payload: Record; + read: boolean; + createdAt: Date; +} + +export interface NotificationPage { + data: NotificationDto[]; + page: number; + limit: number; + total: number; + hasNextPage: boolean; +} + +@Injectable() +export class NotificationsService { + constructor( + @InjectRepository(Notification) + private readonly notificationRepository: Repository, + @InjectRepository(NotificationRecipient) + private readonly recipientRepository: Repository, + @InjectRepository(Player) + private readonly playerRepository: Repository, + ) {} + + async create(params: { + teamId: number; + event: NOTIFICATION_EVENT; + actorUserId: number; + payload: Record; + }): Promise { + const recipientUserIds = await this.resolveRecipients(params.teamId, params.actorUserId); + if (recipientUserIds.length === 0) return; + + const notification = await this.notificationRepository.save( + this.notificationRepository.create({ + team: { id: params.teamId } as Team, + event: params.event, + actorUserId: params.actorUserId, + payload: JSON.stringify(params.payload), + }), + ); + + await this.recipientRepository.insert( + recipientUserIds.map((userId) => ({ + notification: { id: notification.id } as Notification, + userId, + })), + ); + } + + async listForUser( + userId: number, + teamId: number, + page: number, + limit: number, + ): Promise { + const builder = this.recipientRepository + .createQueryBuilder('recipient') + .innerJoinAndSelect('recipient.notification', 'notification') + .where('recipient.userId = :userId', { userId }) + .andWhere('notification.teamId = :teamId', { teamId }) + .orderBy('notification.createdAt', 'DESC'); + + const total = await builder.getCount(); + const rows = await builder.offset((page - 1) * limit).limit(limit).getMany(); + + return { + data: rows.map((row) => this.toDto(row)), + page, + limit, + total, + hasNextPage: page * limit < total, + }; + } + + async getUnreadCount(userId: number, teamId: number): Promise { + return this.recipientRepository + .createQueryBuilder('recipient') + .innerJoin('recipient.notification', 'notification') + .where('recipient.userId = :userId', { userId }) + .andWhere('notification.teamId = :teamId', { teamId }) + .andWhere('recipient.read = false') + .getCount(); + } + + async markRead(recipientId: number, userId: number): Promise { + const recipient = await this.recipientRepository.findOne({ where: { id: recipientId } }); + if (!recipient || recipient.userId !== userId) { + throw new NotFoundException('Benachrichtigung nicht gefunden.'); + } + if (recipient.read) return; + recipient.read = true; + recipient.readAt = new Date(); + await this.recipientRepository.save(recipient); + } + + async markAllRead(userId: number, teamId: number): Promise { + const rows = await this.recipientRepository + .createQueryBuilder('recipient') + .innerJoin('recipient.notification', 'notification') + .where('recipient.userId = :userId', { userId }) + .andWhere('notification.teamId = :teamId', { teamId }) + .andWhere('recipient.read = false') + .select('recipient.id', 'id') + .getRawMany<{ id: number }>(); + + if (rows.length === 0) return; + + await this.recipientRepository.update(rows.map((row) => row.id), { + read: true, + readAt: new Date(), + }); + } + + private async resolveRecipients(teamId: number, actorUserId: number): Promise { + const rows = await this.playerRepository + .createQueryBuilder('player') + .where('player.teamId = :teamId', { teamId }) + .andWhere('player.active = :active', { active: true }) + .andWhere('player.userId IS NOT NULL') + .andWhere('player.userId != :actorUserId', { actorUserId }) + .select('DISTINCT player.userId', 'userId') + .getRawMany<{ userId: number }>(); + return rows.map((row) => row.userId); + } + + private toDto(recipient: NotificationRecipient): NotificationDto { + return { + id: recipient.id, + event: recipient.notification.event, + actorUserId: recipient.notification.actorUserId, + payload: JSON.parse(recipient.notification.payload), + read: recipient.read, + createdAt: recipient.notification.createdAt, + }; + } +}