diff --git a/api/src/app.module.ts b/api/src/app.module.ts index 34d0c21f..bceb188b 100644 --- a/api/src/app.module.ts +++ b/api/src/app.module.ts @@ -7,9 +7,10 @@ import { AuthModule } from '@api/modules/auth/auth.module'; import { JwtAuthGuard } from '@api/modules/auth/guards/jwt-auth.guard'; import { RolesGuard } from '@api/modules/auth/guards/roles.guard'; import { NotificationsModule } from '@api/modules/notifications/notifications.module'; +import { EventsModule } from '@api/modules/events/events.module'; @Module({ - imports: [ApiConfigModule, AuthModule, NotificationsModule], + imports: [ApiConfigModule, AuthModule, NotificationsModule, EventsModule], controllers: [AppController], providers: [ AppService, diff --git a/api/src/modules/auth/authentication/authentication.service.ts b/api/src/modules/auth/authentication/authentication.service.ts index dbaff07b..6392a865 100644 --- a/api/src/modules/auth/authentication/authentication.service.ts +++ b/api/src/modules/auth/authentication/authentication.service.ts @@ -5,12 +5,15 @@ import { User } from '@shared/entities/users/user.entity'; import * as bcrypt from 'bcrypt'; import { LoginDto } from '@api/modules/auth/dtos/login.dto'; import { JwtPayload } from '@api/modules/auth/strategies/jwt.strategy'; +import { EventBus } from '@nestjs/cqrs'; +import { UserSignedUpEvent } from '@api/modules/events/user-events/user-signed-up.event'; @Injectable() export class AuthenticationService { constructor( private readonly usersService: UsersService, private readonly jwt: JwtService, + private readonly eventBus: EventBus, ) {} async validateUser(email: string, password: string): Promise { const user = await this.usersService.findByEmail(email); @@ -22,10 +25,11 @@ export class AuthenticationService { async signUp(signupDto: LoginDto): Promise { const passwordHash = await bcrypt.hash(signupDto.password, 10); - await this.usersService.createUser({ + const newUser = await this.usersService.createUser({ email: signupDto.email, password: passwordHash, }); + this.eventBus.publish(new UserSignedUpEvent(newUser.id, newUser.email)); } async logIn(user: User): Promise<{ user: User; accessToken: string }> { diff --git a/api/src/modules/auth/services/password-recovery.service.ts b/api/src/modules/auth/services/password-recovery.service.ts index 0a84275c..9fa916e3 100644 --- a/api/src/modules/auth/services/password-recovery.service.ts +++ b/api/src/modules/auth/services/password-recovery.service.ts @@ -2,6 +2,8 @@ import { Injectable, Logger } from '@nestjs/common'; import { UsersService } from '@api/modules/users/users.service'; import { JwtService } from '@nestjs/jwt'; import { AuthMailer } from '@api/modules/auth/services/auth.mailer'; +import { EventBus } from '@nestjs/cqrs'; +import { PasswordRecoveryRequestedEvent } from '@api/modules/events/user-events/password-recovery-requested.event'; @Injectable() export class PasswordRecoveryService { @@ -10,6 +12,7 @@ export class PasswordRecoveryService { private readonly users: UsersService, private readonly jwt: JwtService, private readonly authMailer: AuthMailer, + private readonly eventBus: EventBus, ) {} async recoverPassword(email: string, origin: string): Promise { @@ -18,6 +21,7 @@ export class PasswordRecoveryService { this.logger.warn( `Email ${email} not found when trying to recover password`, ); + this.eventBus.publish(new PasswordRecoveryRequestedEvent(email, null)); return; } const token = this.jwt.sign({ id: user.id }); @@ -26,5 +30,6 @@ export class PasswordRecoveryService { token, origin, }); + this.eventBus.publish(new PasswordRecoveryRequestedEvent(email, user.id)); } } diff --git a/api/src/modules/config/app-config.service.ts b/api/src/modules/config/app-config.service.ts index 83fb3be0..7a5f1520 100644 --- a/api/src/modules/config/app-config.service.ts +++ b/api/src/modules/config/app-config.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { DATABASE_ENTITIES } from '@shared/entities/database.entities'; +import { COMMON_DATABASE_ENTITIES } from '@shared/entities/database.entities'; +import { ApiEventsEntity } from '@api/modules/events/api-events/api-events.entity'; export type JWTConfig = { secret: string; @@ -24,7 +25,7 @@ export class ApiConfigService { username: this.configService.get('DB_USERNAME'), password: this.configService.get('DB_PASSWORD'), database: this.configService.get('DB_NAME'), - entities: DATABASE_ENTITIES, + entities: [...COMMON_DATABASE_ENTITIES, ApiEventsEntity], synchronize: true, ssl: this.isProduction() ? { require: true, rejectUnauthorized: false } diff --git a/api/src/modules/events/api-events/api-events.entity.ts b/api/src/modules/events/api-events/api-events.entity.ts index 87930e99..24c4fbf2 100644 --- a/api/src/modules/events/api-events/api-events.entity.ts +++ b/api/src/modules/events/api-events/api-events.entity.ts @@ -14,6 +14,8 @@ export class ApiEventsEntity { @Column({ type: 'enum', enum: API_EVENT_TYPES, + name: 'event_type', + enumName: 'api_event_types', }) eventType: API_EVENT_TYPES; diff --git a/api/src/modules/events/api-events/email-failed.event.ts b/api/src/modules/events/api-events/email-failed.event.ts new file mode 100644 index 00000000..9b45eb87 --- /dev/null +++ b/api/src/modules/events/api-events/email-failed.event.ts @@ -0,0 +1,7 @@ +// src/events/notification-events/email-failed.event.ts +export class EmailFailedEvent { + constructor( + public readonly email: string, + public readonly errorMessage: string, + ) {} +} diff --git a/api/src/modules/events/api-events/handlers/emai-failed-event.handler.ts b/api/src/modules/events/api-events/handlers/emai-failed-event.handler.ts new file mode 100644 index 00000000..1a2f6517 --- /dev/null +++ b/api/src/modules/events/api-events/handlers/emai-failed-event.handler.ts @@ -0,0 +1,24 @@ +// src/events/notification-events/handlers/email-failed-event.handler.ts +import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; +import { EmailFailedEvent } from '../email-failed.event'; +import { API_EVENT_TYPES } from '@api/modules/events/events.enum'; +import { ApiEventsService } from '@api/modules/events/api-events/api-events.service'; + +@EventsHandler(EmailFailedEvent) +export class EmailFailedEventHandler + implements IEventHandler +{ + constructor(private readonly apiEventsService: ApiEventsService) {} + + async handle(event: EmailFailedEvent): Promise { + console.warn('EmailFailedEventHandler', event); + await this.apiEventsService.create({ + eventType: API_EVENT_TYPES.EMAIL_FAILED, + resourceId: null, + payload: { + email: event.email, + errorMessage: event.errorMessage, + }, + }); + } +} diff --git a/api/src/modules/events/events.enum.ts b/api/src/modules/events/events.enum.ts index 4b176cfe..60c25cb0 100644 --- a/api/src/modules/events/events.enum.ts +++ b/api/src/modules/events/events.enum.ts @@ -2,6 +2,8 @@ export enum API_EVENT_TYPES { USER_SIGNED_UP = 'user.signed_up', USER_PASSWORD_RECOVERY_REQUESTED = 'user.password_recovery_requested', USER_PASSWORD_RECOVERY_REQUESTED_NON_EXISTENT = 'user.password_recovery_requested_non_existent', + + EMAIL_FAILED = 'system.email.failed', // More events to come.... } diff --git a/api/src/modules/events/events.module.ts b/api/src/modules/events/events.module.ts index ad3efe57..d9b8900c 100644 --- a/api/src/modules/events/events.module.ts +++ b/api/src/modules/events/events.module.ts @@ -1,7 +1,18 @@ -import { Module } from '@nestjs/common'; +import { Global, Module } from '@nestjs/common'; import { ApiEventsModule } from './api-events/api-events.module'; +import { CqrsModule } from '@nestjs/cqrs'; +import { UserSignedUpEventHandler } from '@api/modules/events/user-events/handlers/user-signed-up.handler'; +import { PasswordRecoveryRequestedEventHandler } from '@api/modules/events/user-events/handlers/password-recovery-requested.handler'; +import { EmailFailedEventHandler } from '@api/modules/events/api-events/handlers/emai-failed-event.handler'; +@Global() @Module({ - imports: [ApiEventsModule], + imports: [CqrsModule, ApiEventsModule], + providers: [ + UserSignedUpEventHandler, + PasswordRecoveryRequestedEventHandler, + EmailFailedEventHandler, + ], + exports: [CqrsModule], }) export class EventsModule {} diff --git a/api/src/modules/events/user-events/handlers/password-recovery-requested.handler.ts b/api/src/modules/events/user-events/handlers/password-recovery-requested.handler.ts new file mode 100644 index 00000000..a2916ebc --- /dev/null +++ b/api/src/modules/events/user-events/handlers/password-recovery-requested.handler.ts @@ -0,0 +1,26 @@ +// src/events/user-events/handlers/password-recovery-requested.handler.ts +import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; +import { PasswordRecoveryRequestedEvent } from '../password-recovery-requested.event'; +import { ApiEventsService } from '@api/modules/events/api-events/api-events.service'; +import { API_EVENT_TYPES } from '@api/modules/events/events.enum'; + +@EventsHandler(PasswordRecoveryRequestedEvent) +export class PasswordRecoveryRequestedEventHandler + implements IEventHandler +{ + constructor(private readonly apiEventsService: ApiEventsService) {} + + async handle(event: PasswordRecoveryRequestedEvent): Promise { + console.warn('PasswordRecoveryRequestedEventHandler', event); + await this.apiEventsService.create({ + eventType: API_EVENT_TYPES.USER_PASSWORD_RECOVERY_REQUESTED, + resourceId: event.userId, + payload: { + email: event.email, + warning: event.userId + ? null + : `Email ${event.email} not found when trying to recover password`, + }, + }); + } +} diff --git a/api/src/modules/events/user-events/handlers/user-signed-up.handler.ts b/api/src/modules/events/user-events/handlers/user-signed-up.handler.ts new file mode 100644 index 00000000..d15c7d84 --- /dev/null +++ b/api/src/modules/events/user-events/handlers/user-signed-up.handler.ts @@ -0,0 +1,22 @@ +// src/events/user-events/handlers/user-signed-up.handler.ts +import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; +import { UserSignedUpEvent } from '../user-signed-up.event'; +import { ApiEventsService } from '@api/modules/events/api-events/api-events.service'; +import { API_EVENT_TYPES } from '@api/modules/events/events.enum'; + +@EventsHandler(UserSignedUpEvent) +export class UserSignedUpEventHandler + implements IEventHandler +{ + constructor(private readonly apiEventsService: ApiEventsService) {} + + async handle(event: UserSignedUpEvent): Promise { + await this.apiEventsService.create({ + eventType: API_EVENT_TYPES.USER_SIGNED_UP, + resourceId: event.userId, + payload: { + email: event.email, + }, + }); + } +} diff --git a/api/src/modules/events/user-events/password-recovery-requested.event.ts b/api/src/modules/events/user-events/password-recovery-requested.event.ts new file mode 100644 index 00000000..3cf04470 --- /dev/null +++ b/api/src/modules/events/user-events/password-recovery-requested.event.ts @@ -0,0 +1,7 @@ +// src/events/user-events/password-recovery-requested.event.ts +export class PasswordRecoveryRequestedEvent { + constructor( + public readonly email: string, + public readonly userId?: string, + ) {} +} diff --git a/api/src/modules/events/user-events/user-signed-up.event.ts b/api/src/modules/events/user-events/user-signed-up.event.ts new file mode 100644 index 00000000..f07a71df --- /dev/null +++ b/api/src/modules/events/user-events/user-signed-up.event.ts @@ -0,0 +1,7 @@ +// src/events/user-events/user-signed-up.event.ts +export class UserSignedUpEvent { + constructor( + public readonly userId: string, + public readonly email: string, + ) {} +} diff --git a/api/src/modules/notifications/email/nodemailer.email.service.ts b/api/src/modules/notifications/email/nodemailer.email.service.ts index 38b01f98..da5b1c6e 100644 --- a/api/src/modules/notifications/email/nodemailer.email.service.ts +++ b/api/src/modules/notifications/email/nodemailer.email.service.ts @@ -11,6 +11,9 @@ import { SendMailDTO, } from '@api/modules/notifications/email/email-service.interface'; import { ConfigService } from '@nestjs/config'; +import { EventBus } from '@nestjs/cqrs'; +import { EmailFailedEventHandler } from '@api/modules/events/api-events/handlers/emai-failed-event.handler'; +import { EmailFailedEvent } from '@api/modules/events/api-events/email-failed.event'; @Injectable() export class NodemailerEmailService implements IEmailServiceInterface { @@ -18,7 +21,10 @@ export class NodemailerEmailService implements IEmailServiceInterface { private transporter: nodemailer.Transporter; private readonly domain: string; - constructor(private readonly configService: ConfigService) { + constructor( + private readonly configService: ConfigService, + private readonly eventBus: EventBus, + ) { const { accessKeyId, secretAccessKey, region, domain } = this.getMailConfig(); const ses = new aws.SESClient({ @@ -40,6 +46,7 @@ export class NodemailerEmailService implements IEmailServiceInterface { }); } catch (e) { this.logger.error(`Error sending email: ${JSON.stringify(e)}`); + this.eventBus.publish(new EmailFailedEvent(sendMailDTO.to, e.message)); throw new ServiceUnavailableException('Could not send email'); } } diff --git a/shared/entities/database.entities.ts b/shared/entities/database.entities.ts index 8fe7e67e..9b7568eb 100644 --- a/shared/entities/database.entities.ts +++ b/shared/entities/database.entities.ts @@ -1,3 +1,3 @@ import { User } from "@shared/entities/users/user.entity"; -export const DATABASE_ENTITIES = [User]; +export const COMMON_DATABASE_ENTITIES = [User];