feat: add notification domain events, listener, and module

This commit is contained in:
Bastian Wagner
2026-08-04 18:53:48 +02:00
parent 05f4c2ddf0
commit 451b5c4e42
11 changed files with 256 additions and 1 deletions

View File

@@ -26,6 +26,7 @@ import { TranslateModule } from './translate/translate.module';
import { PenaltyModule } from './penalty/penalty.module'; import { PenaltyModule } from './penalty/penalty.module';
import { RecurringTransactionsModule } from './recurring-transactions/recurring-transactions.module'; import { RecurringTransactionsModule } from './recurring-transactions/recurring-transactions.module';
import { CashboxExportModule } from './cashbox-export/cashbox-export.module'; import { CashboxExportModule } from './cashbox-export/cashbox-export.module';
import { NotificationsModule } from './notifications/notifications.module';
@Module({ @Module({
imports: [ imports: [
@@ -63,6 +64,7 @@ import { CashboxExportModule } from './cashbox-export/cashbox-export.module';
PenaltyModule, PenaltyModule,
RecurringTransactionsModule, RecurringTransactionsModule,
CashboxExportModule, CashboxExportModule,
NotificationsModule,
], ],
providers: [], providers: [],
}) })

View File

@@ -38,7 +38,8 @@ export type LOGEVENT =
| 'cashbox_export_subscription_run' | 'cashbox_export_subscription_run'
| 'cashbox_export_subscription_run_fail' | 'cashbox_export_subscription_run_fail'
| 'log_retention_cleanup_run' | 'log_retention_cleanup_run'
| 'log_retention_cleanup_run_fail'; | 'log_retention_cleanup_run_fail'
| 'notification_create_fail';
export const LOGEVENT_VALUES: LOGEVENT[] = [ export const LOGEVENT_VALUES: LOGEVENT[] = [
'user_create', 'user_create',
@@ -80,6 +81,7 @@ export const LOGEVENT_VALUES: LOGEVENT[] = [
'cashbox_export_subscription_run_fail', 'cashbox_export_subscription_run_fail',
'log_retention_cleanup_run', 'log_retention_cleanup_run',
'log_retention_cleanup_run_fail', 'log_retention_cleanup_run_fail',
'notification_create_fail',
]; ];
export type LOGLEVEL = 'FATAL' | 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' | 'TRACE'; export type LOGLEVEL = 'FATAL' | 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' | 'TRACE';

View File

@@ -0,0 +1,7 @@
export class InviteLinkCreatedEvent {
constructor(
public readonly teamId: number,
public readonly actorUserId: number,
public readonly teamName: string,
) {}
}

View File

@@ -0,0 +1,8 @@
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;

View File

@@ -0,0 +1,9 @@
export class PlayerActiveChangedEvent {
constructor(
public readonly teamId: number,
public readonly actorUserId: number,
public readonly playerId: number,
public readonly playerName: string,
public readonly active: boolean,
) {}
}

View File

@@ -0,0 +1,8 @@
export class PlayerCreatedEvent {
constructor(
public readonly teamId: number,
public readonly actorUserId: number,
public readonly playerId: number,
public readonly playerName: string,
) {}
}

View File

@@ -0,0 +1,9 @@
export class PlayerRoleChangedEvent {
constructor(
public readonly teamId: number,
public readonly actorUserId: number,
public readonly playerId: number,
public readonly playerName: string,
public readonly teamRoleId: number,
) {}
}

View File

@@ -0,0 +1,13 @@
export class PublicAccessEnabledEvent {
constructor(
public readonly teamId: number,
public readonly actorUserId: number,
) {}
}
export class PublicAccessRotatedEvent {
constructor(
public readonly teamId: number,
public readonly actorUserId: number,
) {}
}

View File

@@ -0,0 +1,97 @@
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,
});
});
});

View File

@@ -0,0 +1,80 @@
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<void> {
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<void> {
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<void> {
return this.safeCreate('player_creation', event.teamId, event.actorUserId, {
playerId: event.playerId,
playerName: event.playerName,
});
}
@OnEvent(NOTIFICATION_EVENT_NAME.publicAccessEnabled)
onPublicAccessEnabled(event: PublicAccessEnabledEvent): Promise<void> {
return this.safeCreate('public_access_enabled', event.teamId, event.actorUserId, {});
}
@OnEvent(NOTIFICATION_EVENT_NAME.publicAccessRotated)
onPublicAccessRotated(event: PublicAccessRotatedEvent): Promise<void> {
return this.safeCreate('public_access_rotated', event.teamId, event.actorUserId, {});
}
@OnEvent(NOTIFICATION_EVENT_NAME.inviteLinkCreated)
onInviteLinkCreated(event: InviteLinkCreatedEvent): Promise<void> {
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<string, unknown>,
): Promise<void> {
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,
});
}
}
}

View File

@@ -0,0 +1,20 @@
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 {}