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 { 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: [],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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