feat: emit notification events on player active/role changes

This commit is contained in:
Bastian Wagner
2026-08-04 19:13:42 +02:00
parent 639ca651d8
commit d6733eff0d
2 changed files with 91 additions and 7 deletions

View File

@@ -18,6 +18,7 @@ describe('TeamMembersService', () => {
let dataSource: any; let dataSource: any;
let logger: any; let logger: any;
let access: any; let access: any;
let eventEmitter: any;
let service: TeamMembersService; let service: TeamMembersService;
beforeEach(() => { beforeEach(() => {
@@ -44,7 +45,8 @@ describe('TeamMembersService', () => {
dataSource = { transaction: jest.fn((work) => work(manager)) }; dataSource = { transaction: jest.fn((work) => work(manager)) };
logger = { info: jest.fn() }; logger = { info: jest.fn() };
access = { assertAtLeast: jest.fn(() => Promise.resolve()) }; access = { assertAtLeast: jest.fn(() => Promise.resolve()) };
service = new TeamMembersService(dataSource, logger, access as any); eventEmitter = { emit: jest.fn() };
service = new TeamMembersService(dataSource, logger, access as any, eventEmitter as any);
}); });
it('checks the team-manager permission before touching the database', async () => { it('checks the team-manager permission before touching the database', async () => {
@@ -188,6 +190,53 @@ describe('TeamMembersService', () => {
).rejects.toBeInstanceOf(NotFoundException); ).rejects.toBeInstanceOf(NotFoundException);
}); });
it('emits a player-active-changed event after a real deactivation', async () => {
player.balance = 42;
treasurers = [player, makePlayer(102, true, TeamRolesEnum.treasurer, 0)];
await service.setActive(5, teamId, player.id, false);
expect(eventEmitter.emit).toHaveBeenCalledWith(
'notifications.player.active_changed',
expect.objectContaining({
teamId,
actorUserId: 5,
playerId: player.id,
playerName: 'Pat Player',
active: false,
}),
);
});
it('does not emit when the active state is unchanged (idempotent)', async () => {
player = makePlayer(101, true, TeamRolesEnum.player, 0);
lockedPlayerQuery = chain({ getOne: jest.fn(() => player) });
playerRepository.createQueryBuilder = jest.fn((alias: string) =>
alias === 'lockedPlayer' ? lockedPlayerQuery : treasurerLockQuery,
);
await service.setActive(5, teamId, player.id, true);
expect(eventEmitter.emit).not.toHaveBeenCalled();
});
it('emits a player-role-changed event after a real role change', async () => {
treasurers = [player, makePlayer(102, true, TeamRolesEnum.treasurer, 0)];
await service.setTeamRole(5, teamId, player.id, TeamRolesEnum.captain);
expect(eventEmitter.emit).toHaveBeenCalledWith(
'notifications.player.role_changed',
expect.objectContaining({
teamId,
actorUserId: 5,
playerId: player.id,
playerName: 'Pat Player',
teamRoleId: TeamRolesEnum.captain,
}),
);
});
function makePlayer( function makePlayer(
id: number, id: number,
active: boolean, active: boolean,

View File

@@ -1,6 +1,10 @@
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { DataSource, EntityManager, Repository } from 'typeorm'; import { DataSource, EntityManager, Repository } from 'typeorm';
import { LoggingService } from '../database/logging/logging.service'; import { LoggingService } from '../database/logging/logging.service';
import { NOTIFICATION_EVENT_NAME } from '../notifications/events/notification-event-names';
import { PlayerActiveChangedEvent } from '../notifications/events/player-active-changed.event';
import { PlayerRoleChangedEvent } from '../notifications/events/player-role-changed.event';
import { Player } from '../players/entities/player.entity'; import { Player } from '../players/entities/player.entity';
import { TeamRole } from '../team-roles/entities/team-roles.entity'; import { TeamRole } from '../team-roles/entities/team-roles.entity';
import { TeamRolesEnum } from '../team-roles/team-roles.enum'; import { TeamRolesEnum } from '../team-roles/team-roles.enum';
@@ -18,6 +22,7 @@ export class TeamMembersService {
private readonly dataSource: DataSource, private readonly dataSource: DataSource,
private readonly logger: LoggingService, private readonly logger: LoggingService,
private readonly access: TeamAccessService, private readonly access: TeamAccessService,
private readonly eventEmitter: EventEmitter2,
) {} ) {}
async setActive( async setActive(
@@ -33,12 +38,12 @@ export class TeamMembersService {
TeamRolesEnum.captain, TeamRolesEnum.captain,
); );
return this.dataSource.transaction(async (manager) => { const result = await this.dataSource.transaction(async (manager) => {
const activeTreasurers = await this.lockActiveTreasurers(manager, teamId); const activeTreasurers = await this.lockActiveTreasurers(manager, teamId);
const playerRepository = manager.getRepository(Player); const playerRepository = manager.getRepository(Player);
const player = await this.findLockedPlayer(playerRepository, playerId, teamId); const player = await this.findLockedPlayer(playerRepository, playerId, teamId);
if (player.active === active) return player; if (player.active === active) return { player, changed: false };
const isDeactivation = player.active && !active; const isDeactivation = player.active && !active;
if ( if (
@@ -66,8 +71,23 @@ export class TeamMembersService {
actorUserId, actorUserId,
`teamId=${teamId} playerId=${playerId} active=${active}`, `teamId=${teamId} playerId=${playerId} active=${active}`,
); );
return player; return { player, changed: true };
}); });
if (result.changed) {
this.eventEmitter.emit(
NOTIFICATION_EVENT_NAME.playerActiveChanged,
new PlayerActiveChangedEvent(
teamId,
actorUserId,
playerId,
`${result.player.firstName} ${result.player.lastName}`,
active,
),
);
}
return result.player;
} }
async setTeamRole( async setTeamRole(
@@ -83,12 +103,12 @@ export class TeamMembersService {
TeamRolesEnum.captain, TeamRolesEnum.captain,
); );
return this.dataSource.transaction(async (manager) => { const result = await this.dataSource.transaction(async (manager) => {
const activeTreasurers = await this.lockActiveTreasurers(manager, teamId); const activeTreasurers = await this.lockActiveTreasurers(manager, teamId);
const playerRepository = manager.getRepository(Player); const playerRepository = manager.getRepository(Player);
const player = await this.findLockedPlayer(playerRepository, playerId, teamId); const player = await this.findLockedPlayer(playerRepository, playerId, teamId);
if (player.teamRole?.id === teamRoleId) return player; if (player.teamRole?.id === teamRoleId) return { player, changed: false };
const isDemotionFromTreasurer = const isDemotionFromTreasurer =
player.active && player.active &&
@@ -108,8 +128,23 @@ export class TeamMembersService {
actorUserId, actorUserId,
`teamId=${teamId} playerId=${playerId} teamRoleId=${teamRoleId}`, `teamId=${teamId} playerId=${playerId} teamRoleId=${teamRoleId}`,
); );
return player; return { player, changed: true };
}); });
if (result.changed) {
this.eventEmitter.emit(
NOTIFICATION_EVENT_NAME.playerRoleChanged,
new PlayerRoleChangedEvent(
teamId,
actorUserId,
playerId,
`${result.player.firstName} ${result.player.lastName}`,
teamRoleId,
),
);
}
return result.player;
} }
// insert() statt save(): umgeht bewusst @BeforeInsert setBalance() auf Transaction, // insert() statt save(): umgeht bewusst @BeforeInsert setBalance() auf Transaction,