Skip to content

Commit

Permalink
WIP: handle welcome email by command
Browse files Browse the repository at this point in the history
  • Loading branch information
alexeh committed Sep 21, 2024
1 parent e0d5ace commit a2b3ed8
Show file tree
Hide file tree
Showing 6 changed files with 94 additions and 22 deletions.
3 changes: 2 additions & 1 deletion api/src/modules/admin/admin.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -12,7 +13,7 @@ export class AdminController {

@RequiredRoles(ROLES.ADMIN)
@Post('/users')
async createUser(@Body() createUserDto: any): Promise<void> {
async createUser(@Body() createUserDto: CreateUserDto): Promise<void> {
return this.auth.createUser(createUserDto);
}
}
9 changes: 7 additions & 2 deletions api/src/modules/auth/authentication/authentication.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<User> {
const user = await this.usersService.findByEmail(email);
Expand All @@ -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<UserWithAccessToken> {
Expand Down
68 changes: 57 additions & 11 deletions api/src/modules/auth/services/auth.mailer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand All @@ -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<void> {
// 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 = `
<h1>Dear User,</h1>
Expand All @@ -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 = `
<h1>Dear User,</h1>
<br/>
<p>Welcome to the TNC Blue Carbon Cost Tool Platform</p>
<br/>
<p>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</p>
<p><a href="${resetPasswordUrl}" target="_blank" rel="noopener noreferrer">Sign Up Link</a></p>
<br/>
<p>Your one-time password is ${welcomeEmailDto.defaultPassword}</p>
<p>For security reasons, this link will expire after ${passwordRecoveryTokenExpirationHumanReadable(expiresIn)}.</p>
<br/>
<p>Thank you for using the platform. We're committed to ensuring your account's security.</p>
<p>Best regards.</p>`;

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 = (
Expand Down
9 changes: 1 addition & 8 deletions api/src/modules/auth/services/password-recovery.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
Expand All @@ -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));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { User } from '@shared/entities/users/user.entity';

export class SendWelcomeEmailCommand {
constructor(
public readonly user: User,
public readonly plainPassword: string,
) {}
}
Original file line number Diff line number Diff line change
@@ -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<SendWelcomeEmailCommand>
{
constructor(private readonly authMailer: AuthMailer) {}

async execute(command: SendWelcomeEmailCommand): Promise<void> {
const { user, plainPassword } = command;
await this.authMailer.sendWelcomeEmail({
user,
defaultPassword: plainPassword,
});
}
}

0 comments on commit a2b3ed8

Please sign in to comment.