feat: add notification domain events, listener, and module
This commit is contained in:
@@ -26,6 +26,7 @@ import { TranslateModule } from './translate/translate.module';
|
||||
import { PenaltyModule } from './penalty/penalty.module';
|
||||
import { RecurringTransactionsModule } from './recurring-transactions/recurring-transactions.module';
|
||||
import { CashboxExportModule } from './cashbox-export/cashbox-export.module';
|
||||
import { NotificationsModule } from './notifications/notifications.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -63,6 +64,7 @@ import { CashboxExportModule } from './cashbox-export/cashbox-export.module';
|
||||
PenaltyModule,
|
||||
RecurringTransactionsModule,
|
||||
CashboxExportModule,
|
||||
NotificationsModule,
|
||||
],
|
||||
providers: [],
|
||||
})
|
||||
|
||||
@@ -38,7 +38,8 @@ export type LOGEVENT =
|
||||
| 'cashbox_export_subscription_run'
|
||||
| 'cashbox_export_subscription_run_fail'
|
||||
| 'log_retention_cleanup_run'
|
||||
| 'log_retention_cleanup_run_fail';
|
||||
| 'log_retention_cleanup_run_fail'
|
||||
| 'notification_create_fail';
|
||||
|
||||
export const LOGEVENT_VALUES: LOGEVENT[] = [
|
||||
'user_create',
|
||||
@@ -80,6 +81,7 @@ export const LOGEVENT_VALUES: LOGEVENT[] = [
|
||||
'cashbox_export_subscription_run_fail',
|
||||
'log_retention_cleanup_run',
|
||||
'log_retention_cleanup_run_fail',
|
||||
'notification_create_fail',
|
||||
];
|
||||
|
||||
export type LOGLEVEL = 'FATAL' | 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' | 'TRACE';
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export class InviteLinkCreatedEvent {
|
||||
constructor(
|
||||
public readonly teamId: number,
|
||||
public readonly actorUserId: number,
|
||||
public readonly teamName: string,
|
||||
) {}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export class PlayerCreatedEvent {
|
||||
constructor(
|
||||
public readonly teamId: number,
|
||||
public readonly actorUserId: number,
|
||||
public readonly playerId: number,
|
||||
public readonly playerName: string,
|
||||
) {}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
Reference in New Issue
Block a user