From a2b3ed834701089de1a2a9bb34a26d40e821169a Mon Sep 17 00:00:00 2001 From: alexeh Date: Sat, 21 Sep 2024 16:25:43 +0200 Subject: [PATCH] WIP: handle welcome email by command --- api/src/modules/admin/admin.controller.ts | 3 +- .../authentication/authentication.service.ts | 9 ++- api/src/modules/auth/services/auth.mailer.ts | 68 ++++++++++++++++--- .../services/password-recovery.service.ts | 9 +-- .../commands/send-welcome-email.command.ts | 8 +++ .../commands/send-welcome-email.handler.ts | 19 ++++++ 6 files changed, 94 insertions(+), 22 deletions(-) create mode 100644 api/src/modules/notifications/email/commands/send-welcome-email.command.ts create mode 100644 api/src/modules/notifications/email/commands/send-welcome-email.handler.ts diff --git a/api/src/modules/admin/admin.controller.ts b/api/src/modules/admin/admin.controller.ts index 04480c9f..ae2d5d3c 100644 --- a/api/src/modules/admin/admin.controller.ts +++ b/api/src/modules/admin/admin.controller.ts @@ -4,6 +4,7 @@ import { RolesGuard } from '@api/modules/auth/guards/roles.guard'; import { RequiredRoles } from '@api/modules/auth/decorators/roles.decorator'; import { ROLES } from '@api/modules/auth/authorisation/roles.enum'; import { AuthenticationService } from '@api/modules/auth/authentication/authentication.service'; +import { CreateUserDto } from '@shared/schemas/users/create-user.schema'; @Controller('admin') @UseGuards(AuthGuard, RolesGuard) @@ -12,7 +13,7 @@ export class AdminController { @RequiredRoles(ROLES.ADMIN) @Post('/users') - async createUser(@Body() createUserDto: any): Promise { + async createUser(@Body() createUserDto: CreateUserDto): Promise { return this.auth.createUser(createUserDto); } } diff --git a/api/src/modules/auth/authentication/authentication.service.ts b/api/src/modules/auth/authentication/authentication.service.ts index e4139932..2b752281 100644 --- a/api/src/modules/auth/authentication/authentication.service.ts +++ b/api/src/modules/auth/authentication/authentication.service.ts @@ -4,13 +4,14 @@ import { UsersService } from '@api/modules/users/users.service'; import { User } from '@shared/entities/users/user.entity'; import * as bcrypt from 'bcrypt'; import { JwtPayload } from '@api/modules/auth/strategies/jwt.strategy'; -import { EventBus } from '@nestjs/cqrs'; +import { CommandBus, EventBus } from '@nestjs/cqrs'; import { UserSignedUpEvent } from '@api/modules/events/user-events/user-signed-up.event'; import { UserWithAccessToken } from '@shared/dtos/user.dto'; import { TOKEN_TYPE_ENUM } from '@shared/schemas/auth/token-type.schema'; import { ApiConfigService } from '@api/modules/config/app-config.service'; import { CreateUserDto } from '@shared/schemas/users/create-user.schema'; import { randomBytes } from 'node:crypto'; +import { SendWelcomeEmailCommand } from '@api/modules/notifications/email/commands/send-welcome-email.command'; @Injectable() export class AuthenticationService { @@ -19,6 +20,7 @@ export class AuthenticationService { private readonly jwt: JwtService, private readonly apiConfig: ApiConfigService, private readonly eventBus: EventBus, + private readonly commandBus: CommandBus, ) {} async validateUser(email: string, password: string): Promise { const user = await this.usersService.findByEmail(email); @@ -38,8 +40,11 @@ export class AuthenticationService { email, password: passwordHash, partnerName, + isActive: false, }); - this.eventBus.publish(new UserSignedUpEvent(newUser.id, newUser.email)); + void this.commandBus.execute( + new SendWelcomeEmailCommand(newUser, plainTextPassword), + ); } async logIn(user: User): Promise { diff --git a/api/src/modules/auth/services/auth.mailer.ts b/api/src/modules/auth/services/auth.mailer.ts index bb206139..daa8806a 100644 --- a/api/src/modules/auth/services/auth.mailer.ts +++ b/api/src/modules/auth/services/auth.mailer.ts @@ -5,10 +5,11 @@ import { } from '@api/modules/notifications/email/email-service.interface'; import { ApiConfigService } from '@api/modules/config/app-config.service'; import { TOKEN_TYPE_ENUM } from '@shared/schemas/auth/token-type.schema'; +import { JwtService } from '@nestjs/jwt'; +import { User } from '@shared/entities/users/user.entity'; -export type PasswordRecovery = { - email: string; - token: string; +export type PasswordRecoveryDto = { + user: User; origin: string; }; @@ -18,19 +19,17 @@ export class AuthMailer { @Inject(IEmailServiceToken) private readonly emailService: IEmailServiceInterface, private readonly apiConfig: ApiConfigService, + private readonly jwt: JwtService, ) {} async sendPasswordRecoveryEmail( - passwordRecovery: PasswordRecovery, + passwordRecovery: PasswordRecoveryDto, ): Promise { - // TODO: Investigate if it's worth using a template engine to generate the email content, the mail service provider allows it - // TODO: Use a different expiration time, or different secret altogether for password recovery - - const { expiresIn } = this.apiConfig.getJWTConfigByType( + const { token, expiresIn } = await this.signTokenByType( TOKEN_TYPE_ENUM.RESET_PASSWORD, + passwordRecovery.user.id, ); - - const resetPasswordUrl = `${passwordRecovery.origin}/auth/forgot-password/${passwordRecovery.token}`; + const resetPasswordUrl = `${passwordRecovery.origin}/auth/forgot-password/${token}`; const htmlContent: string = `

Dear User,

@@ -47,11 +46,58 @@ export class AuthMailer { await this.emailService.sendMail({ from: 'password-recovery', - to: passwordRecovery.email, + to: passwordRecovery.user.email, subject: 'Recover Password', html: htmlContent, }); } + + async sendWelcomeEmail(welcomeEmailDto: { + user: User; + defaultPassword: string; + }) { + const { token, expiresIn } = await this.signTokenByType( + TOKEN_TYPE_ENUM.EMAIL_CONFIRMATION, + welcomeEmailDto.user.id, + ); + + // TODO: We need to know the URL to confirm the email, we could rely on origin but we would need to pass it through a lot of code. + // probably better to have a config value for this. + const resetPasswordUrl = `TODO/auth/sign-up/${token}`; + + const htmlContent: string = ` +

Dear User,

+
+

Welcome to the TNC Blue Carbon Cost Tool Platform

+
+

Thank you for signing up. We're excited to have you on board. Please active you account by signing up adding a password of your choice

+

Sign Up Link

+
+

Your one-time password is ${welcomeEmailDto.defaultPassword}

+

For security reasons, this link will expire after ${passwordRecoveryTokenExpirationHumanReadable(expiresIn)}.

+
+

Thank you for using the platform. We're committed to ensuring your account's security.

+

Best regards.

`; + + await this.emailService.sendMail({ + from: 'welcome', + to: welcomeEmailDto.user.email, + subject: 'Welcome to TNC Blue Carbon Cost Tool Platform', + html: htmlContent, + }); + } + + private async signTokenByType( + tokenType: TOKEN_TYPE_ENUM, + userId: string, + ): Promise<{ token: string; expiresIn: string }> { + const { secret, expiresIn } = this.apiConfig.getJWTConfigByType(tokenType); + const token = await this.jwt.signAsync( + { id: userId }, + { secret, expiresIn }, + ); + return { token, expiresIn }; + } } const passwordRecoveryTokenExpirationHumanReadable = ( diff --git a/api/src/modules/auth/services/password-recovery.service.ts b/api/src/modules/auth/services/password-recovery.service.ts index e87ec9e9..394d1c04 100644 --- a/api/src/modules/auth/services/password-recovery.service.ts +++ b/api/src/modules/auth/services/password-recovery.service.ts @@ -14,10 +14,8 @@ export class PasswordRecoveryService { logger: Logger = new Logger(PasswordRecoveryService.name); constructor( private readonly users: UsersService, - private readonly jwt: JwtService, private readonly authMailer: AuthMailer, private readonly eventBus: EventBus, - private readonly apiConfig: ApiConfigService, ) {} async requestPasswordRecovery(email: string, origin: string): Promise { @@ -29,13 +27,8 @@ export class PasswordRecoveryService { this.eventBus.publish(new PasswordRecoveryRequestedEvent(email, null)); return; } - const { secret, expiresIn } = this.apiConfig.getJWTConfigByType( - TOKEN_TYPE_ENUM.RESET_PASSWORD, - ); - const token = this.jwt.sign({ id: user.id }, { secret, expiresIn }); await this.authMailer.sendPasswordRecoveryEmail({ - email: user.email, - token, + user, origin, }); this.eventBus.publish(new PasswordRecoveryRequestedEvent(email, user.id)); diff --git a/api/src/modules/notifications/email/commands/send-welcome-email.command.ts b/api/src/modules/notifications/email/commands/send-welcome-email.command.ts new file mode 100644 index 00000000..25d31e69 --- /dev/null +++ b/api/src/modules/notifications/email/commands/send-welcome-email.command.ts @@ -0,0 +1,8 @@ +import { User } from '@shared/entities/users/user.entity'; + +export class SendWelcomeEmailCommand { + constructor( + public readonly user: User, + public readonly plainPassword: string, + ) {} +} diff --git a/api/src/modules/notifications/email/commands/send-welcome-email.handler.ts b/api/src/modules/notifications/email/commands/send-welcome-email.handler.ts new file mode 100644 index 00000000..818f866b --- /dev/null +++ b/api/src/modules/notifications/email/commands/send-welcome-email.handler.ts @@ -0,0 +1,19 @@ +// send-welcome-email.handler.ts +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { SendWelcomeEmailCommand } from './send-welcome-email.command'; +import { AuthMailer } from '@api/modules/auth/services/auth.mailer'; + +@CommandHandler(SendWelcomeEmailCommand) +export class SendWelcomeEmailHandler + implements ICommandHandler +{ + constructor(private readonly authMailer: AuthMailer) {} + + async execute(command: SendWelcomeEmailCommand): Promise { + const { user, plainPassword } = command; + await this.authMailer.sendWelcomeEmail({ + user, + defaultPassword: plainPassword, + }); + } +}