feat: add NotificationsService

This commit is contained in:
Bastian Wagner
2026-08-04 18:34:42 +02:00
parent 7410672630
commit 97b0a5c19a
2 changed files with 324 additions and 0 deletions

View File

@@ -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<string, jest.Mock>) {
const query: Record<string, jest.Mock> = {};
['innerJoin', 'innerJoinAndSelect', 'where', 'andWhere', 'select', 'orderBy', 'offset', 'limit']
.forEach((method) => (query[method] = jest.fn(() => query)));
return Object.assign(query, overrides);
}
describe('create', () => {
it('does nothing when the team has no other active members with a login', async () => {
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();
});
});
});

View File

@@ -0,0 +1,150 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Player } from 'src/players/entities/player.entity';
import { Team } from 'src/teams/entities/team.entity';
import { Notification } from './entities/notification.entity';
import { NotificationRecipient } from './entities/notification-recipient.entity';
import { NOTIFICATION_EVENT } from './model/notification-event.type';
export interface NotificationDto {
id: number;
event: NOTIFICATION_EVENT;
actorUserId: number;
payload: Record<string, unknown>;
read: boolean;
createdAt: Date;
}
export interface NotificationPage {
data: NotificationDto[];
page: number;
limit: number;
total: number;
hasNextPage: boolean;
}
@Injectable()
export class NotificationsService {
constructor(
@InjectRepository(Notification)
private readonly notificationRepository: Repository<Notification>,
@InjectRepository(NotificationRecipient)
private readonly recipientRepository: Repository<NotificationRecipient>,
@InjectRepository(Player)
private readonly playerRepository: Repository<Player>,
) {}
async create(params: {
teamId: number;
event: NOTIFICATION_EVENT;
actorUserId: number;
payload: Record<string, unknown>;
}): Promise<void> {
const recipientUserIds = await this.resolveRecipients(params.teamId, params.actorUserId);
if (recipientUserIds.length === 0) return;
const notification = await this.notificationRepository.save(
this.notificationRepository.create({
team: { id: params.teamId } as Team,
event: params.event,
actorUserId: params.actorUserId,
payload: JSON.stringify(params.payload),
}),
);
await this.recipientRepository.insert(
recipientUserIds.map((userId) => ({
notification: { id: notification.id } as Notification,
userId,
})),
);
}
async listForUser(
userId: number,
teamId: number,
page: number,
limit: number,
): Promise<NotificationPage> {
const builder = this.recipientRepository
.createQueryBuilder('recipient')
.innerJoinAndSelect('recipient.notification', 'notification')
.where('recipient.userId = :userId', { userId })
.andWhere('notification.teamId = :teamId', { teamId })
.orderBy('notification.createdAt', 'DESC');
const total = await builder.getCount();
const rows = await builder.offset((page - 1) * limit).limit(limit).getMany();
return {
data: rows.map((row) => this.toDto(row)),
page,
limit,
total,
hasNextPage: page * limit < total,
};
}
async getUnreadCount(userId: number, teamId: number): Promise<number> {
return this.recipientRepository
.createQueryBuilder('recipient')
.innerJoin('recipient.notification', 'notification')
.where('recipient.userId = :userId', { userId })
.andWhere('notification.teamId = :teamId', { teamId })
.andWhere('recipient.read = false')
.getCount();
}
async markRead(recipientId: number, userId: number): Promise<void> {
const recipient = await this.recipientRepository.findOne({ where: { id: recipientId } });
if (!recipient || recipient.userId !== userId) {
throw new NotFoundException('Benachrichtigung nicht gefunden.');
}
if (recipient.read) return;
recipient.read = true;
recipient.readAt = new Date();
await this.recipientRepository.save(recipient);
}
async markAllRead(userId: number, teamId: number): Promise<void> {
const rows = await this.recipientRepository
.createQueryBuilder('recipient')
.innerJoin('recipient.notification', 'notification')
.where('recipient.userId = :userId', { userId })
.andWhere('notification.teamId = :teamId', { teamId })
.andWhere('recipient.read = false')
.select('recipient.id', 'id')
.getRawMany<{ id: number }>();
if (rows.length === 0) return;
await this.recipientRepository.update(rows.map((row) => row.id), {
read: true,
readAt: new Date(),
});
}
private async resolveRecipients(teamId: number, actorUserId: number): Promise<number[]> {
const rows = await this.playerRepository
.createQueryBuilder('player')
.where('player.teamId = :teamId', { teamId })
.andWhere('player.active = :active', { active: true })
.andWhere('player.userId IS NOT NULL')
.andWhere('player.userId != :actorUserId', { actorUserId })
.select('DISTINCT player.userId', 'userId')
.getRawMany<{ userId: number }>();
return rows.map((row) => row.userId);
}
private toDto(recipient: NotificationRecipient): NotificationDto {
return {
id: recipient.id,
event: recipient.notification.event,
actorUserId: recipient.notification.actorUserId,
payload: JSON.parse(recipient.notification.payload),
read: recipient.read,
createdAt: recipient.notification.createdAt,
};
}
}