diff --git a/myteamwallet_backend/src/app.module.ts b/myteamwallet_backend/src/app.module.ts index 5a32493..26bd8fb 100644 --- a/myteamwallet_backend/src/app.module.ts +++ b/myteamwallet_backend/src/app.module.ts @@ -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: [], }) diff --git a/myteamwallet_backend/src/database/logging/model/logging-event.type.ts b/myteamwallet_backend/src/database/logging/model/logging-event.type.ts index f413d68..6b75200 100644 --- a/myteamwallet_backend/src/database/logging/model/logging-event.type.ts +++ b/myteamwallet_backend/src/database/logging/model/logging-event.type.ts @@ -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'; diff --git a/myteamwallet_backend/src/notifications/events/invite-link-created.event.ts b/myteamwallet_backend/src/notifications/events/invite-link-created.event.ts new file mode 100644 index 0000000..ca31b95 --- /dev/null +++ b/myteamwallet_backend/src/notifications/events/invite-link-created.event.ts @@ -0,0 +1,7 @@ +export class InviteLinkCreatedEvent { + constructor( + public readonly teamId: number, + public readonly actorUserId: number, + public readonly teamName: string, + ) {} +} diff --git a/myteamwallet_backend/src/notifications/events/notification-event-names.ts b/myteamwallet_backend/src/notifications/events/notification-event-names.ts new file mode 100644 index 0000000..2259818 --- /dev/null +++ b/myteamwallet_backend/src/notifications/events/notification-event-names.ts @@ -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; diff --git a/myteamwallet_backend/src/notifications/events/player-active-changed.event.ts b/myteamwallet_backend/src/notifications/events/player-active-changed.event.ts new file mode 100644 index 0000000..3fe9115 --- /dev/null +++ b/myteamwallet_backend/src/notifications/events/player-active-changed.event.ts @@ -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, + ) {} +} diff --git a/myteamwallet_backend/src/notifications/events/player-created.event.ts b/myteamwallet_backend/src/notifications/events/player-created.event.ts new file mode 100644 index 0000000..ef129c1 --- /dev/null +++ b/myteamwallet_backend/src/notifications/events/player-created.event.ts @@ -0,0 +1,8 @@ +export class PlayerCreatedEvent { + constructor( + public readonly teamId: number, + public readonly actorUserId: number, + public readonly playerId: number, + public readonly playerName: string, + ) {} +} diff --git a/myteamwallet_backend/src/notifications/events/player-role-changed.event.ts b/myteamwallet_backend/src/notifications/events/player-role-changed.event.ts new file mode 100644 index 0000000..688d1c9 --- /dev/null +++ b/myteamwallet_backend/src/notifications/events/player-role-changed.event.ts @@ -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, + ) {} +} diff --git a/myteamwallet_backend/src/notifications/events/public-access-changed.event.ts b/myteamwallet_backend/src/notifications/events/public-access-changed.event.ts new file mode 100644 index 0000000..e72a5bd --- /dev/null +++ b/myteamwallet_backend/src/notifications/events/public-access-changed.event.ts @@ -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, + ) {} +} diff --git a/myteamwallet_backend/src/notifications/notifications.listener.spec.ts b/myteamwallet_backend/src/notifications/notifications.listener.spec.ts new file mode 100644 index 0000000..305a0a7 --- /dev/null +++ b/myteamwallet_backend/src/notifications/notifications.listener.spec.ts @@ -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, + }); + }); +}); diff --git a/myteamwallet_backend/src/notifications/notifications.listener.ts b/myteamwallet_backend/src/notifications/notifications.listener.ts new file mode 100644 index 0000000..2e79297 --- /dev/null +++ b/myteamwallet_backend/src/notifications/notifications.listener.ts @@ -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 { + 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 { + 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 { + return this.safeCreate('player_creation', event.teamId, event.actorUserId, { + playerId: event.playerId, + playerName: event.playerName, + }); + } + + @OnEvent(NOTIFICATION_EVENT_NAME.publicAccessEnabled) + onPublicAccessEnabled(event: PublicAccessEnabledEvent): Promise { + return this.safeCreate('public_access_enabled', event.teamId, event.actorUserId, {}); + } + + @OnEvent(NOTIFICATION_EVENT_NAME.publicAccessRotated) + onPublicAccessRotated(event: PublicAccessRotatedEvent): Promise { + return this.safeCreate('public_access_rotated', event.teamId, event.actorUserId, {}); + } + + @OnEvent(NOTIFICATION_EVENT_NAME.inviteLinkCreated) + onInviteLinkCreated(event: InviteLinkCreatedEvent): Promise { + 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, + ): Promise { + 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, + }); + } + } +} diff --git a/myteamwallet_backend/src/notifications/notifications.module.ts b/myteamwallet_backend/src/notifications/notifications.module.ts new file mode 100644 index 0000000..c403855 --- /dev/null +++ b/myteamwallet_backend/src/notifications/notifications.module.ts @@ -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 {}