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