diff --git a/jest.config.ts b/jest.config.ts index 4837a54..73e8ab1 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -17,6 +17,7 @@ const config: Config = { '/tests/', '/scripts/', '/src/shared/', + '/src/infrastructure/mailer/', 'jest.config.ts', 'main.ts', ], diff --git a/src/api/users/users.module.ts b/src/api/users/users.module.ts index 3786fcc..9f7ebda 100644 --- a/src/api/users/users.module.ts +++ b/src/api/users/users.module.ts @@ -1,6 +1,6 @@ import { Module } from '@nestjs/common'; -import { Providers } from '@src/infrastructure/repositories/providers.enum'; +import { DomainEvent } from '@src/shared/domain/DomainEvent'; // Controllers import { CreateUserController } from './controllers/create-user.controller'; @@ -9,17 +9,43 @@ import { CreateUserController } from './controllers/create-user.controller'; import { CreateUserService } from '@src/application/users/use-cases/create-user.service'; // Repositories -import { UsersRepository } from '@src/infrastructure/repositories/postgres/users.repository'; +import { + USERS_REPOSITORY_PROVIDER, + UsersRepository, +} from '@src/infrastructure/repositories/postgres/users.repository'; + +// Events +import { UserCreatedEvent } from '@src/domain/users/events/user-created.event'; + +// Event Handlers +import { + SEND_EMAIL_CONFIRMATION_HANDLER_PROVIDER, + SendEmailConfirmationHandler, +} from '@src/infrastructure/event-handlers/send-email-confirmation.handler'; @Module({ imports: [], controllers: [CreateUserController], providers: [ + // Services CreateUserService, + + // Repositories { - provide: Providers.USERS_REPOSITORY, + provide: USERS_REPOSITORY_PROVIDER, useClass: UsersRepository, }, + + // Event Handlers + SendEmailConfirmationHandler, + { + provide: SEND_EMAIL_CONFIRMATION_HANDLER_PROVIDER, + useFactory: (handler: SendEmailConfirmationHandler) => { + DomainEvent.register(handler, UserCreatedEvent.name); + return handler; + }, + inject: [SendEmailConfirmationHandler], + }, ], }) export class UsersModule {} diff --git a/src/app.module.ts b/src/app.module.ts index 8b03f56..dc91b87 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -8,6 +8,7 @@ import { DatabaseModule } from './infrastructure/database/database.module'; import { UsersModule } from './api/users/users.module'; import { DomainErrorFilter } from './api/shared/filters/domain-error.filter'; import { ClassValidatorFilter } from './api/shared/filters/class-validator.filter'; +import { QueuesModule } from './infrastructure/queues/queues.module'; @Module({ imports: [ @@ -16,6 +17,7 @@ import { ClassValidatorFilter } from './api/shared/filters/class-validator.filte throttlers: [{ ttl: 60000, limit: 10 }], }), DatabaseModule, + QueuesModule, UsersModule, ], providers: [ diff --git a/src/application/users/use-cases/create-user.service.ts b/src/application/users/use-cases/create-user.service.ts index 4a2a90f..9962b1e 100644 --- a/src/application/users/use-cases/create-user.service.ts +++ b/src/application/users/use-cases/create-user.service.ts @@ -9,12 +9,12 @@ import { DomainEvent } from '@src/shared/domain/DomainEvent'; import { IUsersRepository } from '@src/domain/users/repositories/users-repository.interface'; import { User } from '@src/domain/users/entities/user.entity'; import { CreateUserDto } from '../dtos/create-user.dto'; -import { Providers } from '@src/infrastructure/repositories/providers.enum'; +import { USERS_REPOSITORY_PROVIDER } from '@src/infrastructure/repositories/postgres/users.repository'; @Injectable() export class CreateUserService { constructor( - @Inject(Providers.USERS_REPOSITORY) + @Inject(USERS_REPOSITORY_PROVIDER) private readonly usersRepository: IUsersRepository, ) {} diff --git a/src/domain/users/entities/token.entity.ts b/src/domain/users/entities/token.entity.ts new file mode 100644 index 0000000..b130333 --- /dev/null +++ b/src/domain/users/entities/token.entity.ts @@ -0,0 +1,87 @@ +import { addMinutes } from 'date-fns'; +import { v7 as uuid } from 'uuid'; + +import { Aggregate } from '@src/shared/domain/Aggregate'; + +interface TokenProps { + id: string; + type: TokenType; + expiresAt: Date; + usedAt?: Date | null; + + createdAt: Date; + updatedAt: Date; + deletedAt?: Date; +} + +export enum TokenType { + EMAIL_CONFIRMATION = 'EMAIL_CONFIRMATION', +} + +const TOKEN_EXPIRATION_IN_MINUTES: Record = { + [TokenType.EMAIL_CONFIRMATION]: 7 * 24 * 60, // 7 days +}; + +export class Token extends Aggregate { + private constructor(props: TokenProps) { + super(props); + } + + get value(): string { + return this.props.id; + } + get type(): TokenType { + return this.props.type; + } + get expiresAt(): Date { + return this.props.expiresAt; + } + get usedAt(): Date | undefined { + return this.props.usedAt; + } + get createdAt(): Date { + return this.props.createdAt; + } + get updatedAt(): Date { + return this.props.updatedAt; + } + get deletedAt(): Date | undefined { + return this.props.deletedAt; + } + + public static create(type: TokenType): Token { + const expirationInMinutes = TOKEN_EXPIRATION_IN_MINUTES[type]; + + const tokenProps: TokenProps = { + id: uuid(), + type: type, + expiresAt: addMinutes(new Date(), expirationInMinutes), + createdAt: new Date(), + updatedAt: new Date(), + }; + + return new Token(tokenProps); + } + + public static hydrate(props: TokenProps): Token { + return new Token(props); + } + + get isUsed(): boolean { + return !!this.usedAt; + } + + get isExpired(): boolean { + return this.expiresAt < new Date(); + } + + get isValid(): boolean { + return !this.isUsed && !this.isExpired && !this.deletedAt; + } + + public invalidate(): void { + if (this.isValid) { + this.props.deletedAt = new Date(); + } + } +} diff --git a/src/domain/users/entities/user.entity.ts b/src/domain/users/entities/user.entity.ts index 60d3369..4e56571 100644 --- a/src/domain/users/entities/user.entity.ts +++ b/src/domain/users/entities/user.entity.ts @@ -4,6 +4,7 @@ import { AggregateRoot } from '@src/shared/domain/AggregateRoot'; import { Password } from '../value-objects/password.vo'; import { UserCreatedEvent } from '../events/user-created.event'; import { Email } from '../value-objects/email.vo'; +import { Token, TokenType } from './token.entity'; interface UserCreateProps { email: string; @@ -18,6 +19,7 @@ export interface UserProps { password: Password; confirmedAt?: Date | null; isActive: boolean; + tokens: Token[]; createdAt: Date; updatedAt: Date; deletedAt?: Date | null; @@ -43,6 +45,9 @@ export class User extends AggregateRoot { get isActive(): boolean { return this.props.isActive; } + get tokens(): Token[] { + return this.props.tokens; + } get createdAt(): Date { return this.props.createdAt; } @@ -64,6 +69,7 @@ export class User extends AggregateRoot { name: props.name, password: password, isActive: true, + tokens: [], createdAt: new Date(), updatedAt: new Date(), }; @@ -81,6 +87,16 @@ export class User extends AggregateRoot { return user; } + public createToken(type: TokenType): Token { + this.tokens + .filter((token) => token.type === type) + .forEach((token) => token.invalidate()); + + const token = Token.create(type); + this.tokens.push(token); + return token; + } + public static hydrate(props: UserProps): User { return new User(props); } diff --git a/src/infrastructure/event-handlers/send-email-confirmation.handler.ts b/src/infrastructure/event-handlers/send-email-confirmation.handler.ts new file mode 100644 index 0000000..7556cf4 --- /dev/null +++ b/src/infrastructure/event-handlers/send-email-confirmation.handler.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@nestjs/common'; + +import { IHandle } from '@src/shared/domain/IDomainEvent'; +import { UserCreatedEvent } from '@src/domain/users/events/user-created.event'; +import { QueuesService } from '@src/infrastructure/queues/queues.service'; +import { QueueType } from '@src/infrastructure/queues/queues.enum'; + +export const SEND_EMAIL_CONFIRMATION_HANDLER_PROVIDER = + 'SendConfirmationEmailHandler'; + +@Injectable() +export class SendEmailConfirmationHandler implements IHandle { + constructor(private readonly queuesService: QueuesService) {} + + async handle(event: UserCreatedEvent): Promise { + await this.queuesService.execute({ + userId: event.aggregateId, + queueType: QueueType.EMAIL_CONFIRMATION, + }); + } +} diff --git a/src/infrastructure/mailer/mailer.service.ts b/src/infrastructure/mailer/mailer.service.ts new file mode 100644 index 0000000..822bcc7 --- /dev/null +++ b/src/infrastructure/mailer/mailer.service.ts @@ -0,0 +1,66 @@ +import { Injectable, Logger } from '@nestjs/common'; +import * as nodemailer from 'nodemailer'; +import { Address } from 'nodemailer/lib/mailer'; +import * as hbs from 'nodemailer-express-handlebars'; + +type MailOptions = { + from?: Address; + recipients: Address[]; + subject: string; + template: string; + context?: Record; +}; + +@Injectable() +export class MailerService { + private readonly transporter: nodemailer.Transporter; + + constructor() { + this.transporter = this.createMailTransport(); + } + + async sendMail(mailOptions: MailOptions) { + const { from, recipients, subject, template, context } = mailOptions; + + const options = { + from: from ?? { + name: process.env.MAIL_FROM_NAME, + address: process.env.MAIL_FROM_ADDRESS, + }, + to: recipients, + subject, + template, + context, + }; + + try { + return this.transporter.sendMail(options); + } catch (error) { + Logger.error('Error while sending email', error, MailerService.name); + throw error; + } + } + + private createMailTransport() { + const transporter = nodemailer.createTransport({ + host: process.env.MAIL_HOST, + port: parseInt(process.env.MAIL_PORT), + secure: process.env.MAIL_SECURE.toLowerCase() === 'true', + auth: { + user: process.env.MAIL_USERNAME, + pass: process.env.MAIL_PASSWORD, + }, + }); + + transporter.use( + 'compile', + hbs({ + viewEngine: { defaultLayout: '' }, + viewPath: 'src/infrastructure/mailer/templates', + extName: '.hbs', + }), + ); + + return transporter; + } +} diff --git a/src/infrastructure/mailer/templates/email-confirmation.hbs b/src/infrastructure/mailer/templates/email-confirmation.hbs new file mode 100644 index 0000000..f1ae7e7 --- /dev/null +++ b/src/infrastructure/mailer/templates/email-confirmation.hbs @@ -0,0 +1,10 @@ +

Olá {{name}}

+

Clique neste link para verificar seu endereço de e-mail.

+

{{confirmationLink}}

+

Se você não solicitou a verificação deste endereço, ignore este e-mail.

+

Obrigado,

+

Equipe da {{from}}

\ No newline at end of file diff --git a/src/infrastructure/migrations/1734736313692_create-updated-at-function-trigger.js b/src/infrastructure/migrations/1734736313692_create-updated-at-function-trigger.js new file mode 100644 index 0000000..08cbac6 --- /dev/null +++ b/src/infrastructure/migrations/1734736313692_create-updated-at-function-trigger.js @@ -0,0 +1,15 @@ +exports.up = (pgm) => { + pgm.sql(` + CREATE OR REPLACE FUNCTION trigger_set_timestamp() + RETURNS TRIGGER AS $$ + BEGIN + NEW.updated_at = NOW(); + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + `); +}; + +exports.down = (pgm) => { + pgm.sql(`DROP FUNCTION trigger_set_timestamp();`); +}; diff --git a/src/infrastructure/migrations/1734736313693_create-users-table.js b/src/infrastructure/migrations/1734736313693_create-users-table.js index 04af5f2..16eedd3 100644 --- a/src/infrastructure/migrations/1734736313693_create-users-table.js +++ b/src/infrastructure/migrations/1734736313693_create-users-table.js @@ -18,16 +18,6 @@ exports.up = (pgm) => { ON users (email) WHERE deleted_at IS NULL; `); - pgm.sql(` - CREATE OR REPLACE FUNCTION trigger_set_timestamp() - RETURNS TRIGGER AS $$ - BEGIN - NEW.updated_at = NOW(); - RETURN NEW; - END; - $$ LANGUAGE plpgsql; - `); - pgm.sql(` CREATE TRIGGER set_timestamp BEFORE UPDATE ON users @@ -37,19 +27,9 @@ exports.up = (pgm) => { }; exports.down = (pgm) => { - pgm.sql(` - DROP TRIGGER set_timestamp ON users; - `); - - pgm.sql(` - DROP FUNCTION trigger_set_timestamp(); - `); + pgm.sql(`DROP TRIGGER set_timestamp ON users;`); - pgm.sql(` - DROP INDEX idx_users_email_unique; - `); + pgm.sql(`DROP INDEX idx_users_email_unique;`); - pgm.sql(` - DROP TABLE users; - `); + pgm.sql(`DROP TABLE users;`); }; diff --git a/src/infrastructure/migrations/1764436999962_create-tokens-table.js b/src/infrastructure/migrations/1764436999962_create-tokens-table.js new file mode 100644 index 0000000..5821857 --- /dev/null +++ b/src/infrastructure/migrations/1764436999962_create-tokens-table.js @@ -0,0 +1,36 @@ +exports.up = (pgm) => { + pgm.sql(` + CREATE TABLE tokens ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL, + type TEXT NOT NULL, + expires_at TIMESTAMP NOT NULL, + used_at TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP, + + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + `); + + pgm.sql(` + CREATE INDEX idx_tokens_user_id + ON tokens (user_id); + `); + + pgm.sql(` + CREATE TRIGGER set_timestamp + BEFORE UPDATE ON tokens + FOR EACH ROW + EXECUTE PROCEDURE trigger_set_timestamp(); + `); +}; + +exports.down = (pgm) => { + pgm.sql(`DROP TRIGGER set_timestamp ON tokens;`); + + pgm.sql(`DROP INDEX idx_tokens_user_id;`); + + pgm.sql(`DROP TABLE tokens;`); +}; diff --git a/src/infrastructure/queues/consumers/email-confirmation.consumer.ts b/src/infrastructure/queues/consumers/email-confirmation.consumer.ts new file mode 100644 index 0000000..33a302f --- /dev/null +++ b/src/infrastructure/queues/consumers/email-confirmation.consumer.ts @@ -0,0 +1,52 @@ +import { Inject, Logger } from '@nestjs/common'; +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { Job } from 'bullmq'; + +import { QueueType } from '../queues.enum'; +import { MailerService } from '@src/infrastructure/mailer/mailer.service'; +import { IUsersRepository } from '@src/domain/users/repositories/users-repository.interface'; +import { USERS_REPOSITORY_PROVIDER } from '@src/infrastructure/repositories/postgres/users.repository'; +import { TokenType } from '@src/domain/users/entities/token.entity'; + +@Processor(QueueType.EMAIL_CONFIRMATION) +export class EmailConfirmationConsumer extends WorkerHost { + constructor( + private readonly mailerService: MailerService, + @Inject(USERS_REPOSITORY_PROVIDER) + private readonly usersRepository: IUsersRepository, + ) { + super(); + } + + async process(job: Job<{ userId: string }>): Promise { + const user = await this.usersRepository.findById(job.data.userId); + + if (!user) { + throw Error(`User with ID ${job.data.userId} not found`); + } + + const token = user.createToken(TokenType.EMAIL_CONFIRMATION); + + try { + await this.mailerService.sendMail({ + recipients: [{ address: user.email.value, name: user.name }], + subject: `Verifique seu e-mail da ${process.env.MAIL_FROM_NAME}`, + template: 'email-confirmation', + context: { + name: user.name, + confirmationLink: `${process.env.APP_URL}/confirm-email?token=${token.value}`, + from: process.env.MAIL_FROM_NAME, + }, + }); + + await this.usersRepository.save(user); + } catch (error) { + Logger.error( + 'Error sending confirmation email:', + error, + EmailConfirmationConsumer.name, + ); + throw error; + } + } +} diff --git a/src/infrastructure/queues/queues.enum.ts b/src/infrastructure/queues/queues.enum.ts new file mode 100644 index 0000000..565bd3e --- /dev/null +++ b/src/infrastructure/queues/queues.enum.ts @@ -0,0 +1,3 @@ +export enum QueueType { + EMAIL_CONFIRMATION = 'email_confirmation', +} diff --git a/src/infrastructure/queues/queues.module.ts b/src/infrastructure/queues/queues.module.ts new file mode 100644 index 0000000..68d96d5 --- /dev/null +++ b/src/infrastructure/queues/queues.module.ts @@ -0,0 +1,40 @@ +import { Global, Module } from '@nestjs/common'; +import { BullModule } from '@nestjs/bullmq'; + +import { QueueType } from './queues.enum'; +import { EmailConfirmationConsumer } from './consumers/email-confirmation.consumer'; +import { + USERS_REPOSITORY_PROVIDER, + UsersRepository, +} from '../repositories/postgres/users.repository'; +import { QueuesService } from './queues.service'; +import { MailerService } from '../mailer/mailer.service'; + +@Global() +@Module({ + imports: [ + BullModule.forRoot({ + connection: { + host: process.env.REDIS_HOST, + port: parseInt(process.env.REDIS_PORT), + password: process.env.REDIS_PASSWORD, + }, + }), + BullModule.registerQueue( + ...Object.values(QueueType).map((queue) => ({ + name: queue, + })), + ), + ], + providers: [ + QueuesService, + EmailConfirmationConsumer, + MailerService, + { + provide: USERS_REPOSITORY_PROVIDER, + useClass: UsersRepository, + }, + ], + exports: [BullModule, QueuesService], +}) +export class QueuesModule {} diff --git a/src/infrastructure/queues/queues.service.ts b/src/infrastructure/queues/queues.service.ts new file mode 100644 index 0000000..a1ce16e --- /dev/null +++ b/src/infrastructure/queues/queues.service.ts @@ -0,0 +1,34 @@ +import { Injectable, UnprocessableEntityException } from '@nestjs/common'; +import { InjectQueue } from '@nestjs/bullmq'; +import { Queue } from 'bullmq'; + +import { QueueType } from './queues.enum'; + +@Injectable() +export class QueuesService { + private readonly queueMap: Map; + + constructor( + @InjectQueue(QueueType.EMAIL_CONFIRMATION) + private readonly confirmationEmailQueue: Queue, + ) { + this.queueMap = new Map([ + [QueueType.EMAIL_CONFIRMATION, this.confirmationEmailQueue], + ]); + } + + async execute(data: { userId: string; queueType: QueueType }) { + const { userId, queueType } = data; + const queue = this.queueMap.get(queueType); + + if (!queue) { + throw new UnprocessableEntityException(`Queue not found: ${queueType}`); + } + + await queue.add( + `${queueType}`, + { userId }, + { jobId: `${queueType}-${userId}-${Date.now()}` }, + ); + } +} diff --git a/src/infrastructure/repositories/mappers/token.mapper.ts b/src/infrastructure/repositories/mappers/token.mapper.ts new file mode 100644 index 0000000..6d570f1 --- /dev/null +++ b/src/infrastructure/repositories/mappers/token.mapper.ts @@ -0,0 +1,44 @@ +import { Token, TokenType } from '@src/domain/users/entities/token.entity'; + +export interface TokenDbRow { + id: string; + user_id: string; + type: string; + expires_at: Date; + used_at: Date | null; + created_at: Date; + updated_at: Date; + deleted_at: Date | null; +} + +export type ToPersist = Token & { userId: string }; + +export class TokenMapper { + public static toPersistence(toPersist: ToPersist): TokenDbRow { + return { + id: toPersist.value, + user_id: toPersist.userId, + type: toPersist.type, + expires_at: toPersist.expiresAt, + used_at: toPersist.usedAt !== undefined ? toPersist.usedAt : null, + created_at: toPersist.createdAt, + updated_at: toPersist.updatedAt, + deleted_at: + toPersist.deletedAt !== undefined ? toPersist.deletedAt : null, + }; + } + + public static toDomain(raw: TokenDbRow): Token { + const tokenProps = { + id: raw.id, + type: raw.type as TokenType, + expiresAt: raw.expires_at, + usedAt: raw.used_at !== null ? raw.used_at : undefined, + createdAt: raw.created_at, + updatedAt: raw.updated_at, + deletedAt: raw.deleted_at !== null ? raw.deleted_at : undefined, + }; + + return Token.hydrate(tokenProps); + } +} diff --git a/src/infrastructure/repositories/mappers/user.mapper.ts b/src/infrastructure/repositories/mappers/user.mapper.ts index d3ac297..8ecf83f 100644 --- a/src/infrastructure/repositories/mappers/user.mapper.ts +++ b/src/infrastructure/repositories/mappers/user.mapper.ts @@ -1,6 +1,11 @@ import { User, UserProps } from '@src/domain/users/entities/user.entity'; import { Email } from '@src/domain/users/value-objects/email.vo'; import { Password } from '@src/domain/users/value-objects/password.vo'; +import { + TokenDbRow, + TokenMapper, + ToPersist as TokenToPersist, +} from './token.mapper'; interface UserDbRow { id: string; @@ -9,6 +14,7 @@ interface UserDbRow { password: string; confirmed_at: Date | null; is_active: boolean; + tokens: TokenDbRow[]; created_at: Date; updated_at: Date; deleted_at: Date | null; @@ -26,6 +32,12 @@ export class UserMapper { created_at: user.createdAt, updated_at: user.updatedAt, deleted_at: user.deletedAt, + + tokens: user.tokens.map((token) => + TokenMapper.toPersistence( + Object.assign(token, { userId: user.id }) as TokenToPersist, + ), + ), }; } @@ -40,9 +52,10 @@ export class UserMapper { createdAt: raw.created_at, updatedAt: raw.updated_at, deletedAt: raw.deleted_at, + + tokens: raw.tokens?.map((tokenRow) => TokenMapper.toDomain(tokenRow)), }; - const user = User.hydrate(userProps); - return user; + return User.hydrate(userProps); } } diff --git a/src/infrastructure/repositories/postgres/users.repository.ts b/src/infrastructure/repositories/postgres/users.repository.ts index 0f4a16d..c1f05bb 100644 --- a/src/infrastructure/repositories/postgres/users.repository.ts +++ b/src/infrastructure/repositories/postgres/users.repository.ts @@ -4,6 +4,9 @@ import { IUsersRepository } from '@src/domain/users/repositories/users-repositor import { DatabaseService } from '@src/infrastructure/database/database.service'; import { User } from '@src/domain/users/entities/user.entity'; import { UserMapper } from '../mappers/user.mapper'; +import { TokenDbRow } from '../mappers/token.mapper'; + +export const USERS_REPOSITORY_PROVIDER = 'IUsersRepository'; @Injectable() export class UsersRepository implements IUsersRepository { @@ -35,6 +38,52 @@ export class UsersRepository implements IUsersRepository { data.is_active, ]; + await this.database.query(query, values); + await this.saveTokens(data.tokens); + } + + private async saveTokens(tokens: TokenDbRow[]): Promise { + if (tokens.length === 0) { + return; + } + + const columns = [ + 'id', + 'user_id', + 'type', + 'expires_at', + 'used_at', + 'deleted_at', + ]; + const values = []; + const placeholders = []; + + tokens.forEach((token, index) => { + const offset = index * columns.length; + placeholders.push( + `($${offset + 1}, $${offset + 2}, $${offset + 3}, $${offset + 4}, $${offset + 5}, $${offset + 6})`, + ); + values.push( + token.id, + token.user_id, + token.type, + token.expires_at, + token.used_at, + token.deleted_at, + ); + }); + + const query = ` + INSERT INTO tokens (${columns.join(', ')}) + VALUES ${placeholders.join(', ')} + ON CONFLICT (id) DO UPDATE SET + user_id = EXCLUDED.user_id, + type = EXCLUDED.type, + expires_at = EXCLUDED.expires_at, + used_at = EXCLUDED.used_at, + deleted_at = EXCLUDED.deleted_at; + `; + await this.database.query(query, values); } @@ -54,6 +103,7 @@ export class UsersRepository implements IUsersRepository { } const row = rows[0]; + row.tokens = await this.findTokensByUserId(row.id); return UserMapper.toDomain(row); } @@ -74,6 +124,22 @@ export class UsersRepository implements IUsersRepository { } const row = rows[0]; + row.tokens = await this.findTokensByUserId(row.id); + return UserMapper.toDomain(row); } + + private async findTokensByUserId(userId: string): Promise { + const rows = await this.database.query( + ` + SELECT id, type, expires_at, used_at, created_at, updated_at, deleted_at + FROM tokens + WHERE user_id = $1 + AND deleted_at IS NULL; + `, + [userId], + ); + + return rows; + } } diff --git a/src/infrastructure/repositories/providers.enum.ts b/src/infrastructure/repositories/providers.enum.ts deleted file mode 100644 index d24c1f0..0000000 --- a/src/infrastructure/repositories/providers.enum.ts +++ /dev/null @@ -1,3 +0,0 @@ -export enum Providers { - USERS_REPOSITORY = 'IUsersRepository', -} diff --git a/src/shared/domain/Aggregate.ts b/src/shared/domain/Aggregate.ts new file mode 100644 index 0000000..0e9d9db --- /dev/null +++ b/src/shared/domain/Aggregate.ts @@ -0,0 +1,10 @@ +import { Entity } from './Entity'; + +export abstract class Aggregate extends Entity { + protected props: T; + + constructor(props: T & { id: string }) { + super(props.id); + this.props = props; + } +} diff --git a/src/shared/domain/AggregateRoot.ts b/src/shared/domain/AggregateRoot.ts index fbf689b..8179db1 100644 --- a/src/shared/domain/AggregateRoot.ts +++ b/src/shared/domain/AggregateRoot.ts @@ -1,15 +1,9 @@ -import { Entity } from './Entity'; +import { Aggregate } from './Aggregate'; import { IDomainEvent } from './IDomainEvent'; -export abstract class AggregateRoot extends Entity { - protected props: T; +export abstract class AggregateRoot extends Aggregate { private _domainEvents: IDomainEvent[] = []; - constructor(props: T & { id: string }) { - super(props.id); - this.props = props; - } - get domainEvents(): readonly IDomainEvent[] { return Object.freeze([...this._domainEvents]); } diff --git a/tests/integration/api/users/controllers/create-user.controller.test.ts b/tests/integration/api/users/controllers/create-user.controller.test.ts index ba66a06..a5fd3e1 100644 --- a/tests/integration/api/users/controllers/create-user.controller.test.ts +++ b/tests/integration/api/users/controllers/create-user.controller.test.ts @@ -1,6 +1,7 @@ import * as request from 'supertest'; import { HttpStatus } from '@nestjs/common'; import { INestApplication } from '@nestjs/common'; +import { Queue } from 'bullmq'; import { TestSetup } from '@tests/integration/test.setup'; import { IUsersRepository } from '@src/domain/users/repositories/users-repository.interface'; @@ -9,9 +10,11 @@ import { UserFactory } from '@tests/mocks/factories/user.factory'; describe('CreateUserController', () => { let app: INestApplication; let usersRepository: IUsersRepository; + let emailConfirmationQueue: Queue; beforeAll(async () => { - ({ app, usersRepository } = await TestSetup.setup()); + ({ app, usersRepository, emailConfirmationQueue } = + await TestSetup.setup()); }); afterAll(async () => { @@ -72,6 +75,11 @@ describe('CreateUserController', () => { }); it('should be able to create a new user successfully', async () => { + const emailConfirmationQueueAddSpy = jest.spyOn( + emailConfirmationQueue, + 'add', + ); + const response = await request(app.getHttpServer()).post('/users').send({ name: 'John Doe', email: 'johndoe@email.com', @@ -81,5 +89,6 @@ describe('CreateUserController', () => { expect(response.status).toBe(HttpStatus.CREATED); expect(response.body.message).toBe('User created successfully'); + expect(emailConfirmationQueueAddSpy).toHaveBeenCalled(); }); }); diff --git a/tests/integration/test.setup.ts b/tests/integration/test.setup.ts index 0e2eee8..78f0bca 100644 --- a/tests/integration/test.setup.ts +++ b/tests/integration/test.setup.ts @@ -1,25 +1,33 @@ import { Test } from '@nestjs/testing'; +import { getQueueToken } from '@nestjs/bullmq'; import { AppModule } from '@src/app.module'; import { DatabaseService } from '@src/infrastructure/database/database.service'; -import { Providers } from '@src/infrastructure/repositories/providers.enum'; +import { QueuesModule } from '@src/infrastructure/queues/queues.module'; +import { QueuesModule as MockQueuesModule } from '../mocks/models/queue.module'; +import { USERS_REPOSITORY_PROVIDER } from '@src/infrastructure/repositories/postgres/users.repository'; +import { QueueType } from '@src/infrastructure/queues/queues.enum'; export class TestSetup { static async setup() { const module = await Test.createTestingModule({ imports: [AppModule], }) + .overrideModule(QueuesModule) + .useModule(MockQueuesModule) .overrideProvider(DatabaseService) .useValue(new DatabaseService(true)) .compile(); const app = module.createNestApplication(); - await app.init(); return { app, - usersRepository: module.get(Providers.USERS_REPOSITORY), + usersRepository: module.get(USERS_REPOSITORY_PROVIDER), + emailConfirmationQueue: module.get( + getQueueToken(QueueType.EMAIL_CONFIRMATION), + ), }; } } diff --git a/tests/mocks/factories/token.factory.ts b/tests/mocks/factories/token.factory.ts new file mode 100644 index 0000000..40c6bb0 --- /dev/null +++ b/tests/mocks/factories/token.factory.ts @@ -0,0 +1,39 @@ +import { fakerPT_BR as faker } from '@faker-js/faker'; + +import { Token, TokenType } from '@src/domain/users/entities/token.entity'; + +interface TokenFactoryProps { + id: string; + type: TokenType; + expiresAt: Date; + usedAt?: Date | null; + createdAt: Date; + updatedAt: Date; + deletedAt?: Date | null; +} + +export class TokenFactory { + static create(override?: Partial): Token { + const type = override?.type ?? TokenType.EMAIL_CONFIRMATION; + const expiresAt = override?.expiresAt ?? faker.date.future(); + const usedAt = override?.usedAt ?? null; + const createdAt = override?.createdAt ?? faker.date.past(); + const updatedAt = override?.updatedAt ?? createdAt; + const deletedAt = override?.deletedAt ?? null; + + return Token.hydrate( + Object.assign( + { + id: faker.string.uuid(), + type, + expiresAt, + usedAt, + createdAt, + updatedAt, + deletedAt, + }, + override, + ), + ); + } +} diff --git a/tests/mocks/factories/user.factory.ts b/tests/mocks/factories/user.factory.ts index a6eaafb..e2adcc0 100644 --- a/tests/mocks/factories/user.factory.ts +++ b/tests/mocks/factories/user.factory.ts @@ -1,5 +1,6 @@ import { fakerPT_BR as faker } from '@faker-js/faker'; +import { Token } from '@src/domain/users/entities/token.entity'; import { User } from '@src/domain/users/entities/user.entity'; import { Email } from '@src/domain/users/value-objects/email.vo'; import { Password } from '@src/domain/users/value-objects/password.vo'; @@ -11,6 +12,7 @@ interface UserFactoryProps { password: Password; confirmedAt?: Date | null; isActive: boolean; + tokens?: Token[]; createdAt: Date; updatedAt: Date; deletedAt?: Date | null; @@ -35,6 +37,7 @@ export class UserFactory { override?.password ?? Password.fromHash(faker.string.alphanumeric(60)); const confirmedAt = override?.confirmedAt ?? null; + const tokens = override?.tokens ?? []; const createdAt = override?.createdAt ?? faker.date.past(); const updatedAt = override?.updatedAt ?? createdAt; const deletedAt = override?.deletedAt ?? null; @@ -48,6 +51,7 @@ export class UserFactory { password, confirmedAt, isActive: true, + tokens, createdAt, updatedAt, deletedAt, diff --git a/tests/mocks/models/queue.module.ts b/tests/mocks/models/queue.module.ts new file mode 100644 index 0000000..40aaf0c --- /dev/null +++ b/tests/mocks/models/queue.module.ts @@ -0,0 +1,21 @@ +import { getQueueToken } from '@nestjs/bullmq'; +import { Global, Module } from '@nestjs/common'; + +import { QueueType } from '@src/infrastructure/queues/queues.enum'; +import { QueuesService } from '@src/infrastructure/queues/queues.service'; + +@Global() +@Module({ + providers: [ + QueuesService, + ...Object.values(QueueType).map((queue) => ({ + provide: getQueueToken(queue), + useValue: { add: jest.fn() }, + })), + ], + exports: [ + QueuesService, + ...Object.values(QueueType).map((queue) => getQueueToken(queue)), + ], +}) +export class QueuesModule {} diff --git a/tests/unit/application/users/use-cases/create-user.service.test.ts b/tests/unit/application/users/use-cases/create-user.service.test.ts index 3919aca..982261c 100644 --- a/tests/unit/application/users/use-cases/create-user.service.test.ts +++ b/tests/unit/application/users/use-cases/create-user.service.test.ts @@ -83,6 +83,7 @@ describe('CreateUserService', () => { }), name: 'John Doe', isActive: true, + tokens: [], password: expect.any(Password), createdAt: expect.any(Date), updatedAt: expect.any(Date), diff --git a/tests/unit/domain/users/entities/token.entity.test.ts b/tests/unit/domain/users/entities/token.entity.test.ts new file mode 100644 index 0000000..48207e8 --- /dev/null +++ b/tests/unit/domain/users/entities/token.entity.test.ts @@ -0,0 +1,196 @@ +import { Token, TokenType } from '@src/domain/users/entities/token.entity'; + +describe('TokenEntity', () => { + it('should create a new token with valid data', () => { + const token = Token.create(TokenType.EMAIL_CONFIRMATION); + + expect(token).toEqual( + expect.objectContaining({ + props: expect.objectContaining({ + id: expect.any(String), + type: TokenType.EMAIL_CONFIRMATION, + expiresAt: expect.any(Date), + createdAt: expect.any(Date), + updatedAt: expect.any(Date), + }), + }), + ); + expect(token.deletedAt).toBeUndefined(); + }); + + it('should generate unique IDs for different tokens', () => { + const token1 = Token.create(TokenType.EMAIL_CONFIRMATION); + const token2 = Token.create(TokenType.EMAIL_CONFIRMATION); + + expect(token1.id).not.toBe(token2.id); + }); + + it('should hydrate a token from given properties', () => { + const props = { + id: 'token-id', + type: TokenType.EMAIL_CONFIRMATION, + expiresAt: new Date('2024-12-31T23:59:59Z'), + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-01T00:00:00Z'), + }; + + const token = Token.hydrate(props); + + expect(token).toEqual( + expect.objectContaining({ + props: expect.objectContaining({ + id: 'token-id', + type: TokenType.EMAIL_CONFIRMATION, + expiresAt: new Date('2024-12-31T23:59:59Z'), + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-01T00:00:00Z'), + }), + }), + ); + }); + + it('should return true if token has been used', () => { + const props = { + id: 'token-id', + type: TokenType.EMAIL_CONFIRMATION, + expiresAt: new Date('2024-12-31T23:59:59Z'), + usedAt: new Date(), + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-01T00:00:00Z'), + }; + + const token = Token.hydrate(props); + + expect(token.isUsed).toBe(true); + }); + + it('should return false if token has not been used', () => { + const props = { + id: 'token-id', + type: TokenType.EMAIL_CONFIRMATION, + expiresAt: new Date('2024-12-31T23:59:59Z'), + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-01T00:00:00Z'), + }; + + const token = Token.hydrate(props); + + expect(token.isUsed).toBe(false); + }); + + it('should return true if token has expired', () => { + const props = { + id: 'token-id', + type: TokenType.EMAIL_CONFIRMATION, + expiresAt: new Date('2022-12-31T23:59:59Z'), + createdAt: new Date('2022-01-01T00:00:00Z'), + updatedAt: new Date('2022-01-01T00:00:00Z'), + }; + + const token = Token.hydrate(props); + + expect(token.isExpired).toBe(true); + }); + + it('should return false if token has not expired', () => { + const props = { + id: 'token-id', + type: TokenType.EMAIL_CONFIRMATION, + expiresAt: new Date('2999-12-31T23:59:59Z'), + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-01T00:00:00Z'), + }; + + const token = Token.hydrate(props); + + expect(token.isExpired).toBe(false); + }); + + it('should return true if token is not used, not expired and not deleted', () => { + const props = { + id: 'token-id', + type: TokenType.EMAIL_CONFIRMATION, + expiresAt: new Date('2999-12-31T23:59:59Z'), + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-01T00:00:00Z'), + }; + + const token = Token.hydrate(props); + + expect(token.isValid).toBe(true); + }); + + it('should return false if token is used', () => { + const props = { + id: 'token-id', + type: TokenType.EMAIL_CONFIRMATION, + expiresAt: new Date('2999-12-31T23:59:59Z'), + usedAt: new Date(), + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-01T00:00:00Z'), + }; + + const token = Token.hydrate(props); + + expect(token.isValid).toBe(false); + }); + + it('should return false if token is expired', () => { + const props = { + id: 'token-id', + type: TokenType.EMAIL_CONFIRMATION, + expiresAt: new Date('2022-12-31T23:59:59Z'), + createdAt: new Date('2022-01-01T00:00:00Z'), + updatedAt: new Date('2022-01-01T00:00:00Z'), + }; + + const token = Token.hydrate(props); + + expect(token.isValid).toBe(false); + }); + + it('should return false if token is deleted', () => { + const props = { + id: 'token-id', + type: TokenType.EMAIL_CONFIRMATION, + expiresAt: new Date('2999-12-31T23:59:59Z'), + deletedAt: new Date(), + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-01T00:00:00Z'), + }; + + const token = Token.hydrate(props); + + expect(token.isValid).toBe(false); + }); + + it('should set deletedAt when token is valid', () => { + const props = { + id: 'token-id', + type: TokenType.EMAIL_CONFIRMATION, + expiresAt: new Date('2999-12-31T23:59:59Z'), + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-01T00:00:00Z'), + }; + + const token = Token.hydrate(props); + token.invalidate(); + + expect(token.deletedAt).toBeInstanceOf(Date); + }); + + it('should not set deletedAt when token is already invalid', () => { + const props = { + id: 'token-id', + type: TokenType.EMAIL_CONFIRMATION, + expiresAt: new Date('2022-12-31T23:59:59Z'), + createdAt: new Date('2022-01-01T00:00:00Z'), + updatedAt: new Date('2022-01-01T00:00:00Z'), + }; + + const token = Token.hydrate(props); + token.invalidate(); + + expect(token.deletedAt).toBeUndefined(); + }); +}); diff --git a/tests/unit/domain/users/entities/user.entity.test.ts b/tests/unit/domain/users/entities/user.entity.test.ts index 6e0ca38..e816571 100644 --- a/tests/unit/domain/users/entities/user.entity.test.ts +++ b/tests/unit/domain/users/entities/user.entity.test.ts @@ -23,6 +23,7 @@ describe('UserEntity', () => { name: 'Test User', password: expect.any(Password), isActive: true, + tokens: [], createdAt: expect.any(Date), updatedAt: expect.any(Date), }), @@ -77,9 +78,10 @@ describe('UserEntity', () => { name: 'Test User', password: password, isActive: true, + confirmedAt: new Date('2024-01-01'), + tokens: [], createdAt: new Date('2024-01-01'), updatedAt: new Date('2024-01-02'), - confirmedAt: new Date('2024-01-01'), deletedAt: null, }; @@ -97,6 +99,7 @@ describe('UserEntity', () => { props: { value: '$2b$10$hashedpassword' }, }), isActive: true, + tokens: [], createdAt: new Date('2024-01-01'), updatedAt: new Date('2024-01-02'), confirmedAt: new Date('2024-01-01'), @@ -117,6 +120,7 @@ describe('UserEntity', () => { name: 'Test User', password: password, isActive: false, + tokens: [], createdAt: new Date('2024-01-01'), updatedAt: new Date('2024-01-02'), }; @@ -135,6 +139,7 @@ describe('UserEntity', () => { props: { value: '$2b$10$hashedpassword' }, }), isActive: false, + tokens: [], createdAt: new Date('2024-01-01'), updatedAt: new Date('2024-01-02'), }), diff --git a/tests/unit/infrastructure/queues/consumers/email-confirmation.consumer.test.ts b/tests/unit/infrastructure/queues/consumers/email-confirmation.consumer.test.ts new file mode 100644 index 0000000..f175b14 --- /dev/null +++ b/tests/unit/infrastructure/queues/consumers/email-confirmation.consumer.test.ts @@ -0,0 +1,55 @@ +import { Job } from 'bullmq'; + +import { MailerService } from '@src/infrastructure/mailer/mailer.service'; +import { EmailConfirmationConsumer } from '@src/infrastructure/queues/consumers/email-confirmation.consumer'; +import { UserFactory } from '@tests/mocks/factories/user.factory'; +import { IUsersRepository } from '@src/domain/users/repositories/users-repository.interface'; +import { InMemoryUsersRepository } from '@tests/mocks/repositories/users-repository'; + +describe('EmailConfirmationConsumer', () => { + let emailConfirmationConsumer: EmailConfirmationConsumer; + let mailerService: MailerService; + let usersRepository: IUsersRepository; + + beforeEach(() => { + usersRepository = new InMemoryUsersRepository(); + + mailerService = { + sendMail: jest.fn(), + } as unknown as MailerService; + + emailConfirmationConsumer = new EmailConfirmationConsumer( + mailerService, + usersRepository, + ); + }); + + it('should not be able to send a confirmation email if user does not exist', async () => { + const job = { data: { userId: 'non-existing-user-id' } }; + + await expect(emailConfirmationConsumer.process(job as Job)).rejects.toThrow( + `User with ID ${job.data.userId} not found`, + ); + }); + + it('should be able to send a confirmation email', async () => { + const user = UserFactory.create(); + await usersRepository.save(user); + const job = { data: { userId: user.id } }; + + await emailConfirmationConsumer.process(job as Job); + + const token = user.tokens.find((t) => t.type === 'EMAIL_CONFIRMATION'); + + expect(mailerService.sendMail).toHaveBeenCalledWith({ + recipients: [{ address: user.email.value, name: user.name }], + subject: `Verifique seu e-mail da ${process.env.MAIL_FROM_NAME}`, + template: 'email-confirmation', + context: { + name: user.name, + confirmationLink: `${process.env.APP_URL}/confirm-email?token=${token.value}`, + from: process.env.MAIL_FROM_NAME, + }, + }); + }); +});