From e0778e52f22726d48e6aaffc7c7711a7b5c93484 Mon Sep 17 00:00:00 2001 From: Danilo Augusto <34426848+daniloamsilva@users.noreply.github.com> Date: Thu, 27 Nov 2025 23:10:52 +0000 Subject: [PATCH 01/25] feat: add send email confirmation feature --- src/api/users/users.module.ts | 32 +++++++++- src/app.module.ts | 2 + .../users/use-cases/create-user.service.ts | 4 +- .../send-email-confirmation.handler.ts | 21 +++++++ src/infrastructure/mailer/mailer.module.ts | 9 +++ src/infrastructure/mailer/mailer.service.ts | 59 +++++++++++++++++++ .../mailer/templates/email-confirmation.hbs | 10 ++++ .../consumers/email-confirmation.consumer.ts | 56 ++++++++++++++++++ src/infrastructure/queues/queues.enum.ts | 3 + src/infrastructure/queues/queues.model.ts | 40 +++++++++++++ src/infrastructure/queues/queues.service.ts | 30 ++++++++++ .../repositories/postgres/users.repository.ts | 2 + .../repositories/providers.enum.ts | 3 - tests/integration/test.setup.ts | 4 +- 14 files changed, 265 insertions(+), 10 deletions(-) create mode 100644 src/infrastructure/event-handlers/send-email-confirmation.handler.ts create mode 100644 src/infrastructure/mailer/mailer.module.ts create mode 100644 src/infrastructure/mailer/mailer.service.ts create mode 100644 src/infrastructure/mailer/templates/email-confirmation.hbs create mode 100644 src/infrastructure/queues/consumers/email-confirmation.consumer.ts create mode 100644 src/infrastructure/queues/queues.enum.ts create mode 100644 src/infrastructure/queues/queues.model.ts create mode 100644 src/infrastructure/queues/queues.service.ts delete mode 100644 src/infrastructure/repositories/providers.enum.ts diff --git a/src/api/users/users.module.ts b/src/api/users/users.module.ts index 3786fcc..ad4480f 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_CONFIRMATION_EMAIL_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_CONFIRMATION_EMAIL_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..c7b8421 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.model'; @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/infrastructure/event-handlers/send-email-confirmation.handler.ts b/src/infrastructure/event-handlers/send-email-confirmation.handler.ts new file mode 100644 index 0000000..8978a6d --- /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_CONFIRMATION_EMAIL_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.CONFIRMATION_EMAIL, + }); + } +} diff --git a/src/infrastructure/mailer/mailer.module.ts b/src/infrastructure/mailer/mailer.module.ts new file mode 100644 index 0000000..46a907e --- /dev/null +++ b/src/infrastructure/mailer/mailer.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; + +import { MailerService } from './mailer.service'; +@Global() +@Module({ + providers: [MailerService], + exports: [MailerService], +}) +export class MailerModuler {} diff --git a/src/infrastructure/mailer/mailer.service.ts b/src/infrastructure/mailer/mailer.service.ts new file mode 100644 index 0000000..4f4d86d --- /dev/null +++ b/src/infrastructure/mailer/mailer.service.ts @@ -0,0 +1,59 @@ +import { Injectable } 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 { + 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.mailTransport().sendMail(options); + } catch (error) { + console.error('Error while sending email', error); + } + } + + private mailTransport() { + 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/queues/consumers/email-confirmation.consumer.ts b/src/infrastructure/queues/consumers/email-confirmation.consumer.ts new file mode 100644 index 0000000..f56da20 --- /dev/null +++ b/src/infrastructure/queues/consumers/email-confirmation.consumer.ts @@ -0,0 +1,56 @@ +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'; + +@Processor(QueueType.CONFIRMATION_EMAIL) +export class EmailConfirmationConsumer extends WorkerHost { + constructor( + private readonly mailerService: MailerService, + @Inject(USERS_REPOSITORY_PROVIDER) + private readonly usersRepository: IUsersRepository, + // @Inject(Providers.TOKENS_REPOSITORY) + // private readonly tokensRepository: TokensRepositoryInterface, + ) { + super(); + } + + async process(job: Job<{ userId: string }>): Promise { + const user = await this.usersRepository.findById(job.data.userId); + // const token = await this.tokensRepository.save( + // new Token({ + // userId: user.id, + // type: TokenType.CONFIRMATION_EMAIL, + // createdAt: new Date(), + // updatedAt: new Date(), + // }), + // ); + + const token = { + value: 'token-placeholder', + }; + + 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, + }, + }); + } catch (error) { + Logger.error( + 'Error sending confirmation email:', + error, + EmailConfirmationConsumer.name, + ); + } + } +} diff --git a/src/infrastructure/queues/queues.enum.ts b/src/infrastructure/queues/queues.enum.ts new file mode 100644 index 0000000..2c92b50 --- /dev/null +++ b/src/infrastructure/queues/queues.enum.ts @@ -0,0 +1,3 @@ +export enum QueueType { + CONFIRMATION_EMAIL = 'confirmation_email', +} diff --git a/src/infrastructure/queues/queues.model.ts b/src/infrastructure/queues/queues.model.ts new file mode 100644 index 0000000..68d96d5 --- /dev/null +++ b/src/infrastructure/queues/queues.model.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..972ff30 --- /dev/null +++ b/src/infrastructure/queues/queues.service.ts @@ -0,0 +1,30 @@ +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.CONFIRMATION_EMAIL) + private readonly confirmationEmailQueue: Queue, + ) { + this.queueMap = new Map([ + [QueueType.CONFIRMATION_EMAIL, 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(`task-${queueType}-${Date.now()}`, { userId }); + } +} diff --git a/src/infrastructure/repositories/postgres/users.repository.ts b/src/infrastructure/repositories/postgres/users.repository.ts index 0f4a16d..2364f89 100644 --- a/src/infrastructure/repositories/postgres/users.repository.ts +++ b/src/infrastructure/repositories/postgres/users.repository.ts @@ -5,6 +5,8 @@ import { DatabaseService } from '@src/infrastructure/database/database.service'; import { User } from '@src/domain/users/entities/user.entity'; import { UserMapper } from '../mappers/user.mapper'; +export const USERS_REPOSITORY_PROVIDER = 'IUsersRepository'; + @Injectable() export class UsersRepository implements IUsersRepository { constructor( 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/tests/integration/test.setup.ts b/tests/integration/test.setup.ts index 0e2eee8..543a8d6 100644 --- a/tests/integration/test.setup.ts +++ b/tests/integration/test.setup.ts @@ -2,7 +2,7 @@ import { Test } from '@nestjs/testing'; import { AppModule } from '@src/app.module'; import { DatabaseService } from '@src/infrastructure/database/database.service'; -import { Providers } from '@src/infrastructure/repositories/providers.enum'; +import { USERS_REPOSITORY_PROVIDER } from '@src/infrastructure/repositories/postgres/users.repository'; export class TestSetup { static async setup() { @@ -19,7 +19,7 @@ export class TestSetup { return { app, - usersRepository: module.get(Providers.USERS_REPOSITORY), + usersRepository: module.get(USERS_REPOSITORY_PROVIDER), }; } } From 67dbfb8e166167311e661f2a191a4ce6fac260c2 Mon Sep 17 00:00:00 2001 From: Danilo Augusto <34426848+daniloamsilva@users.noreply.github.com> Date: Thu, 27 Nov 2025 23:54:07 +0000 Subject: [PATCH 02/25] test: add send email queue tests --- src/api/users/users.module.ts | 4 ++-- .../send-email-confirmation.handler.ts | 4 ++-- .../consumers/email-confirmation.consumer.ts | 4 ++-- src/infrastructure/queues/queues.enum.ts | 2 +- src/infrastructure/queues/queues.service.ts | 4 ++-- .../create-user.controller.test.ts | 11 +++++++++- tests/integration/test.setup.ts | 10 ++++++++- tests/mocks/models/queue.module.ts | 21 +++++++++++++++++++ 8 files changed, 49 insertions(+), 11 deletions(-) create mode 100644 tests/mocks/models/queue.module.ts diff --git a/src/api/users/users.module.ts b/src/api/users/users.module.ts index ad4480f..9f7ebda 100644 --- a/src/api/users/users.module.ts +++ b/src/api/users/users.module.ts @@ -19,7 +19,7 @@ import { UserCreatedEvent } from '@src/domain/users/events/user-created.event'; // Event Handlers import { - SEND_CONFIRMATION_EMAIL_HANDLER_PROVIDER, + SEND_EMAIL_CONFIRMATION_HANDLER_PROVIDER, SendEmailConfirmationHandler, } from '@src/infrastructure/event-handlers/send-email-confirmation.handler'; @@ -39,7 +39,7 @@ import { // Event Handlers SendEmailConfirmationHandler, { - provide: SEND_CONFIRMATION_EMAIL_HANDLER_PROVIDER, + provide: SEND_EMAIL_CONFIRMATION_HANDLER_PROVIDER, useFactory: (handler: SendEmailConfirmationHandler) => { DomainEvent.register(handler, UserCreatedEvent.name); return handler; diff --git a/src/infrastructure/event-handlers/send-email-confirmation.handler.ts b/src/infrastructure/event-handlers/send-email-confirmation.handler.ts index 8978a6d..7556cf4 100644 --- a/src/infrastructure/event-handlers/send-email-confirmation.handler.ts +++ b/src/infrastructure/event-handlers/send-email-confirmation.handler.ts @@ -5,7 +5,7 @@ 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_CONFIRMATION_EMAIL_HANDLER_PROVIDER = +export const SEND_EMAIL_CONFIRMATION_HANDLER_PROVIDER = 'SendConfirmationEmailHandler'; @Injectable() @@ -15,7 +15,7 @@ export class SendEmailConfirmationHandler implements IHandle { async handle(event: UserCreatedEvent): Promise { await this.queuesService.execute({ userId: event.aggregateId, - queueType: QueueType.CONFIRMATION_EMAIL, + queueType: QueueType.EMAIL_CONFIRMATION, }); } } diff --git a/src/infrastructure/queues/consumers/email-confirmation.consumer.ts b/src/infrastructure/queues/consumers/email-confirmation.consumer.ts index f56da20..723ca81 100644 --- a/src/infrastructure/queues/consumers/email-confirmation.consumer.ts +++ b/src/infrastructure/queues/consumers/email-confirmation.consumer.ts @@ -7,7 +7,7 @@ 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'; -@Processor(QueueType.CONFIRMATION_EMAIL) +@Processor(QueueType.EMAIL_CONFIRMATION) export class EmailConfirmationConsumer extends WorkerHost { constructor( private readonly mailerService: MailerService, @@ -24,7 +24,7 @@ export class EmailConfirmationConsumer extends WorkerHost { // const token = await this.tokensRepository.save( // new Token({ // userId: user.id, - // type: TokenType.CONFIRMATION_EMAIL, + // type: TokenType.EMAIL_CONFIRMATION, // createdAt: new Date(), // updatedAt: new Date(), // }), diff --git a/src/infrastructure/queues/queues.enum.ts b/src/infrastructure/queues/queues.enum.ts index 2c92b50..565bd3e 100644 --- a/src/infrastructure/queues/queues.enum.ts +++ b/src/infrastructure/queues/queues.enum.ts @@ -1,3 +1,3 @@ export enum QueueType { - CONFIRMATION_EMAIL = 'confirmation_email', + EMAIL_CONFIRMATION = 'email_confirmation', } diff --git a/src/infrastructure/queues/queues.service.ts b/src/infrastructure/queues/queues.service.ts index 972ff30..404b49e 100644 --- a/src/infrastructure/queues/queues.service.ts +++ b/src/infrastructure/queues/queues.service.ts @@ -9,11 +9,11 @@ export class QueuesService { private readonly queueMap: Map; constructor( - @InjectQueue(QueueType.CONFIRMATION_EMAIL) + @InjectQueue(QueueType.EMAIL_CONFIRMATION) private readonly confirmationEmailQueue: Queue, ) { this.queueMap = new Map([ - [QueueType.CONFIRMATION_EMAIL, this.confirmationEmailQueue], + [QueueType.EMAIL_CONFIRMATION, this.confirmationEmailQueue], ]); } 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 543a8d6..16212de 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 { QueuesModule } from '@src/infrastructure/queues/queues.model'; +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(USERS_REPOSITORY_PROVIDER), + emailConfirmationQueue: module.get( + getQueueToken(QueueType.EMAIL_CONFIRMATION), + ), }; } } 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 {} From 41182cb0139338253ef793a5789e5d91101be467 Mon Sep 17 00:00:00 2001 From: Danilo Augusto <34426848+daniloamsilva@users.noreply.github.com> Date: Sat, 29 Nov 2025 00:01:18 +0000 Subject: [PATCH 03/25] feat: create token entity --- src/domain/users/entities/token.entity.ts | 53 +++++++++++++++++++ src/shared/domain/Aggregate.ts | 10 ++++ .../users/entities/token.entity.test.ts | 51 ++++++++++++++++++ 3 files changed, 114 insertions(+) create mode 100644 src/domain/users/entities/token.entity.ts create mode 100644 src/shared/domain/Aggregate.ts create mode 100644 tests/unit/domain/users/entities/token.entity.test.ts diff --git a/src/domain/users/entities/token.entity.ts b/src/domain/users/entities/token.entity.ts new file mode 100644 index 0000000..924b32a --- /dev/null +++ b/src/domain/users/entities/token.entity.ts @@ -0,0 +1,53 @@ +import { addMinutes } from 'date-fns'; + +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 deletedAt(): Date | undefined { + return this.props.deletedAt; + } + + public static create(type: TokenType): Token { + const experionInMinutes = TOKEN_EXPIRATION_IN_MINUTES[type]; + + const tokenProps: TokenProps = { + id: crypto.randomUUID(), + type: type, + expiresAt: addMinutes(new Date(), experionInMinutes), + createdAt: new Date(), + updatedAt: new Date(), + }; + + return new Token(tokenProps); + } + + public static hydrate(props: TokenProps): Token { + return new Token(props); + } +} 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/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..5fff6ba --- /dev/null +++ b/tests/unit/domain/users/entities/token.entity.test.ts @@ -0,0 +1,51 @@ +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'), + }), + }), + ); + }); +}); From d5bb9c1541450a7bea23b3e1478ab5d319d408ba Mon Sep 17 00:00:00 2001 From: Danilo Augusto <34426848+daniloamsilva@users.noreply.github.com> Date: Sat, 29 Nov 2025 16:55:57 +0000 Subject: [PATCH 04/25] feat: attach tokens user aggregate root --- src/domain/users/entities/token.entity.ts | 19 +++++++ src/domain/users/entities/user.entity.ts | 6 +++ .../consumers/email-confirmation.consumer.ts | 17 ++----- .../repositories/mappers/token.mapper.ts | 43 ++++++++++++++++ .../repositories/mappers/user.mapper.ts | 11 +++- tests/mocks/factories/token.factory.ts | 39 +++++++++++++++ tests/mocks/factories/user.factory.ts | 5 ++ .../use-cases/create-user.service.test.ts | 1 + .../domain/users/entities/user.entity.test.ts | 7 ++- .../email-confirmation.consumer.test.ts | 50 +++++++++++++++++++ 10 files changed, 181 insertions(+), 17 deletions(-) create mode 100644 src/infrastructure/repositories/mappers/token.mapper.ts create mode 100644 tests/mocks/factories/token.factory.ts create mode 100644 tests/unit/infrastructure/queues/consumers/email-confirmation.consumer.test.ts diff --git a/src/domain/users/entities/token.entity.ts b/src/domain/users/entities/token.entity.ts index 924b32a..1b4de94 100644 --- a/src/domain/users/entities/token.entity.ts +++ b/src/domain/users/entities/token.entity.ts @@ -29,6 +29,21 @@ export class Token extends Aggregate { 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; } @@ -50,4 +65,8 @@ export class Token extends Aggregate { public static hydrate(props: TokenProps): Token { return new Token(props); } + + get isUsed(): boolean { + return !!this.props.usedAt; + } } diff --git a/src/domain/users/entities/user.entity.ts b/src/domain/users/entities/user.entity.ts index 60d3369..8e25414 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 } 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(), }; diff --git a/src/infrastructure/queues/consumers/email-confirmation.consumer.ts b/src/infrastructure/queues/consumers/email-confirmation.consumer.ts index 723ca81..9d98f40 100644 --- a/src/infrastructure/queues/consumers/email-confirmation.consumer.ts +++ b/src/infrastructure/queues/consumers/email-confirmation.consumer.ts @@ -13,26 +13,15 @@ export class EmailConfirmationConsumer extends WorkerHost { private readonly mailerService: MailerService, @Inject(USERS_REPOSITORY_PROVIDER) private readonly usersRepository: IUsersRepository, - // @Inject(Providers.TOKENS_REPOSITORY) - // private readonly tokensRepository: TokensRepositoryInterface, ) { super(); } async process(job: Job<{ userId: string }>): Promise { const user = await this.usersRepository.findById(job.data.userId); - // const token = await this.tokensRepository.save( - // new Token({ - // userId: user.id, - // type: TokenType.EMAIL_CONFIRMATION, - // createdAt: new Date(), - // updatedAt: new Date(), - // }), - // ); - - const token = { - value: 'token-placeholder', - }; + const token = user.tokens.find( + (t) => t.type === 'EMAIL_CONFIRMATION' && !t.isUsed, + ); try { await this.mailerService.sendMail({ diff --git a/src/infrastructure/repositories/mappers/token.mapper.ts b/src/infrastructure/repositories/mappers/token.mapper.ts new file mode 100644 index 0000000..8f222c2 --- /dev/null +++ b/src/infrastructure/repositories/mappers/token.mapper.ts @@ -0,0 +1,43 @@ +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; +} + +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 || null, + created_at: toPersist.createdAt, + updated_at: toPersist.updatedAt, + deleted_at: 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 || undefined, + createdAt: raw.created_at, + updatedAt: raw.updated_at, + deletedAt: 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..06a94c8 100644 --- a/src/infrastructure/repositories/mappers/user.mapper.ts +++ b/src/infrastructure/repositories/mappers/user.mapper.ts @@ -1,6 +1,7 @@ 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 } from './token.mapper'; interface UserDbRow { id: string; @@ -9,6 +10,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 +28,10 @@ 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 })), + ), }; } @@ -40,9 +46,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/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..e259b54 100644 --- a/tests/mocks/factories/user.factory.ts +++ b/tests/mocks/factories/user.factory.ts @@ -1,8 +1,10 @@ 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'; +import { TokenFactory } from './token.factory'; interface UserFactoryProps { id: string; @@ -11,6 +13,7 @@ interface UserFactoryProps { password: Password; confirmedAt?: Date | null; isActive: boolean; + tokens?: Token[]; createdAt: Date; updatedAt: Date; deletedAt?: Date | null; @@ -35,6 +38,7 @@ export class UserFactory { override?.password ?? Password.fromHash(faker.string.alphanumeric(60)); const confirmedAt = override?.confirmedAt ?? null; + const tokens = override?.tokens ?? [TokenFactory.create()]; const createdAt = override?.createdAt ?? faker.date.past(); const updatedAt = override?.updatedAt ?? createdAt; const deletedAt = override?.deletedAt ?? null; @@ -48,6 +52,7 @@ export class UserFactory { password, confirmedAt, isActive: true, + tokens, createdAt, updatedAt, deletedAt, 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/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..3aae083 --- /dev/null +++ b/tests/unit/infrastructure/queues/consumers/email-confirmation.consumer.test.ts @@ -0,0 +1,50 @@ +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'; +import { TokenType } from '@src/domain/users/entities/token.entity'; +import { TokenFactory } from '@tests/mocks/factories/token.factory'; + +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 be able to send a confirmation email', async () => { + const token = TokenFactory.create({ type: TokenType.EMAIL_CONFIRMATION }); + const user = UserFactory.create({ + tokens: [token], + }); + await usersRepository.save(user); + const job = { data: { userId: user.id } }; + + await emailConfirmationConsumer.process(job as Job); + + 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, + }, + }); + }); +}); From ab2c615e4f0834c06ef9a949240d3f2037048512 Mon Sep 17 00:00:00 2001 From: Danilo Augusto <34426848+daniloamsilva@users.noreply.github.com> Date: Sat, 29 Nov 2025 23:30:05 +0000 Subject: [PATCH 05/25] feat: create migration for tokens table --- jest.config.ts | 1 + ...3692_create-updated-at-function-trigger.js | 15 +++++++ .../1734736313693_create-users-table.js | 26 ++--------- .../1764436999962_create-tokens-table.js | 36 ++++++++++++++++ .../repositories/postgres/users.repository.ts | 43 +++++++++++++++++++ 5 files changed, 98 insertions(+), 23 deletions(-) create mode 100644 src/infrastructure/migrations/1734736313692_create-updated-at-function-trigger.js create mode 100644 src/infrastructure/migrations/1764436999962_create-tokens-table.js 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/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/repositories/postgres/users.repository.ts b/src/infrastructure/repositories/postgres/users.repository.ts index 2364f89..5bb5c40 100644 --- a/src/infrastructure/repositories/postgres/users.repository.ts +++ b/src/infrastructure/repositories/postgres/users.repository.ts @@ -4,6 +4,7 @@ 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'; @@ -38,6 +39,31 @@ export class UsersRepository implements IUsersRepository { ]; await this.database.query(query, values); + await this.saveTokens(data.tokens); + } + + private async saveTokens(tokens: TokenDbRow[]): Promise { + const query = ` + INSERT INTO tokens (id, user_id, type, expires_at, used_at) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (id) DO UPDATE SET + user_id = $2, + type = $3, + expires_at = $4, + used_at = $5; + `; + + for (const token of tokens) { + const values = [ + token.id, + token.user_id, + token.type, + token.expires_at, + token.used_at, + ]; + + await this.database.query(query, values); + } } async findById(id: string): Promise { @@ -56,6 +82,7 @@ export class UsersRepository implements IUsersRepository { } const row = rows[0]; + row.tokens = await this.findTokensByUserId(row.id); return UserMapper.toDomain(row); } @@ -76,6 +103,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; + } } From 246100e8f1af1a8be0e667ce3c76089a2db59154 Mon Sep 17 00:00:00 2001 From: Danilo Augusto <34426848+daniloamsilva@users.noreply.github.com> Date: Sun, 30 Nov 2025 16:03:01 +0000 Subject: [PATCH 06/25] feat: enhance token management with validity checks and email confirmation logic --- src/domain/users/entities/token.entity.ts | 16 +- src/domain/users/entities/user.entity.ts | 14 +- .../consumers/email-confirmation.consumer.ts | 6 +- tests/mocks/factories/user.factory.ts | 3 +- .../users/entities/token.entity.test.ts | 145 ++++++++++++++++++ .../email-confirmation.consumer.test.ts | 9 +- 6 files changed, 182 insertions(+), 11 deletions(-) diff --git a/src/domain/users/entities/token.entity.ts b/src/domain/users/entities/token.entity.ts index 1b4de94..e5fc005 100644 --- a/src/domain/users/entities/token.entity.ts +++ b/src/domain/users/entities/token.entity.ts @@ -67,6 +67,20 @@ export class Token extends Aggregate { } get isUsed(): boolean { - return !!this.props.usedAt; + 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 8e25414..4b87bba 100644 --- a/src/domain/users/entities/user.entity.ts +++ b/src/domain/users/entities/user.entity.ts @@ -4,7 +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 } from './token.entity'; +import { Token, TokenType } from './token.entity'; interface UserCreateProps { email: string; @@ -87,6 +87,18 @@ export class User extends AggregateRoot { return user; } + public createToken(type: TokenType): Token { + this.tokens.forEach((token) => { + if (token.type === type) { + 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/queues/consumers/email-confirmation.consumer.ts b/src/infrastructure/queues/consumers/email-confirmation.consumer.ts index 9d98f40..e065b54 100644 --- a/src/infrastructure/queues/consumers/email-confirmation.consumer.ts +++ b/src/infrastructure/queues/consumers/email-confirmation.consumer.ts @@ -6,6 +6,7 @@ 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 { @@ -19,8 +20,11 @@ export class EmailConfirmationConsumer extends WorkerHost { async process(job: Job<{ userId: string }>): Promise { const user = await this.usersRepository.findById(job.data.userId); + user.createToken(TokenType.EMAIL_CONFIRMATION); + await this.usersRepository.save(user); + const token = user.tokens.find( - (t) => t.type === 'EMAIL_CONFIRMATION' && !t.isUsed, + (t) => t.type === TokenType.EMAIL_CONFIRMATION, ); try { diff --git a/tests/mocks/factories/user.factory.ts b/tests/mocks/factories/user.factory.ts index e259b54..e2adcc0 100644 --- a/tests/mocks/factories/user.factory.ts +++ b/tests/mocks/factories/user.factory.ts @@ -4,7 +4,6 @@ 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'; -import { TokenFactory } from './token.factory'; interface UserFactoryProps { id: string; @@ -38,7 +37,7 @@ export class UserFactory { override?.password ?? Password.fromHash(faker.string.alphanumeric(60)); const confirmedAt = override?.confirmedAt ?? null; - const tokens = override?.tokens ?? [TokenFactory.create()]; + const tokens = override?.tokens ?? []; const createdAt = override?.createdAt ?? faker.date.past(); const updatedAt = override?.updatedAt ?? createdAt; const deletedAt = override?.deletedAt ?? null; diff --git a/tests/unit/domain/users/entities/token.entity.test.ts b/tests/unit/domain/users/entities/token.entity.test.ts index 5fff6ba..48207e8 100644 --- a/tests/unit/domain/users/entities/token.entity.test.ts +++ b/tests/unit/domain/users/entities/token.entity.test.ts @@ -48,4 +48,149 @@ describe('TokenEntity', () => { }), ); }); + + 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/infrastructure/queues/consumers/email-confirmation.consumer.test.ts b/tests/unit/infrastructure/queues/consumers/email-confirmation.consumer.test.ts index 3aae083..486447f 100644 --- a/tests/unit/infrastructure/queues/consumers/email-confirmation.consumer.test.ts +++ b/tests/unit/infrastructure/queues/consumers/email-confirmation.consumer.test.ts @@ -5,8 +5,6 @@ import { EmailConfirmationConsumer } from '@src/infrastructure/queues/consumers/ 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'; -import { TokenType } from '@src/domain/users/entities/token.entity'; -import { TokenFactory } from '@tests/mocks/factories/token.factory'; describe('EmailConfirmationConsumer', () => { let emailConfirmationConsumer: EmailConfirmationConsumer; @@ -27,15 +25,14 @@ describe('EmailConfirmationConsumer', () => { }); it('should be able to send a confirmation email', async () => { - const token = TokenFactory.create({ type: TokenType.EMAIL_CONFIRMATION }); - const user = UserFactory.create({ - tokens: [token], - }); + 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}`, From bb3a323907aae157d5afd278bf4a8d40b37a2c27 Mon Sep 17 00:00:00 2001 From: Danilo Augusto Date: Wed, 3 Dec 2025 22:45:19 -0300 Subject: [PATCH 07/25] feat: add user existence check in email confirmation process --- .../queues/consumers/email-confirmation.consumer.ts | 5 +++++ .../queues/consumers/email-confirmation.consumer.test.ts | 8 ++++++++ 2 files changed, 13 insertions(+) diff --git a/src/infrastructure/queues/consumers/email-confirmation.consumer.ts b/src/infrastructure/queues/consumers/email-confirmation.consumer.ts index e065b54..d3606b8 100644 --- a/src/infrastructure/queues/consumers/email-confirmation.consumer.ts +++ b/src/infrastructure/queues/consumers/email-confirmation.consumer.ts @@ -20,6 +20,11 @@ export class EmailConfirmationConsumer extends WorkerHost { 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`); + } + user.createToken(TokenType.EMAIL_CONFIRMATION); await this.usersRepository.save(user); diff --git a/tests/unit/infrastructure/queues/consumers/email-confirmation.consumer.test.ts b/tests/unit/infrastructure/queues/consumers/email-confirmation.consumer.test.ts index 486447f..f175b14 100644 --- a/tests/unit/infrastructure/queues/consumers/email-confirmation.consumer.test.ts +++ b/tests/unit/infrastructure/queues/consumers/email-confirmation.consumer.test.ts @@ -24,6 +24,14 @@ describe('EmailConfirmationConsumer', () => { ); }); + 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); From 5b6bc354aa5ed1e298b3140cf47561b0f0f4aac5 Mon Sep 17 00:00:00 2001 From: Danilo Augusto Date: Wed, 3 Dec 2025 23:04:38 -0300 Subject: [PATCH 08/25] feat: optimize token saving logic with batch insert and early return for empty tokens --- .../repositories/postgres/users.repository.ts | 39 ++++++++++++------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/src/infrastructure/repositories/postgres/users.repository.ts b/src/infrastructure/repositories/postgres/users.repository.ts index 5bb5c40..758d026 100644 --- a/src/infrastructure/repositories/postgres/users.repository.ts +++ b/src/infrastructure/repositories/postgres/users.repository.ts @@ -43,27 +43,38 @@ export class UsersRepository implements IUsersRepository { } private async saveTokens(tokens: TokenDbRow[]): Promise { - const query = ` - INSERT INTO tokens (id, user_id, type, expires_at, used_at) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (id) DO UPDATE SET - user_id = $2, - type = $3, - expires_at = $4, - used_at = $5; - `; + if (tokens.length === 0) { + return; + } + + const values = []; + const placeholders = []; - for (const token of tokens) { - const values = [ + tokens.forEach((token, index) => { + const offset = index * 5; + placeholders.push( + `($${offset + 1}, $${offset + 2}, $${offset + 3}, $${offset + 4}, $${offset + 5})`, + ); + values.push( token.id, token.user_id, token.type, token.expires_at, token.used_at, - ]; + ); + }); - await this.database.query(query, values); - } + const query = ` + INSERT INTO tokens (id, user_id, type, expires_at, used_at) + 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; + `; + + await this.database.query(query, values); } async findById(id: string): Promise { From 5a1448d46e96a8f9f72fc834877fdcd913f88044 Mon Sep 17 00:00:00 2001 From: Danilo Augusto Date: Wed, 3 Dec 2025 23:16:07 -0300 Subject: [PATCH 09/25] refact: remove unused mailer module --- src/infrastructure/mailer/mailer.module.ts | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 src/infrastructure/mailer/mailer.module.ts diff --git a/src/infrastructure/mailer/mailer.module.ts b/src/infrastructure/mailer/mailer.module.ts deleted file mode 100644 index 46a907e..0000000 --- a/src/infrastructure/mailer/mailer.module.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Global, Module } from '@nestjs/common'; - -import { MailerService } from './mailer.service'; -@Global() -@Module({ - providers: [MailerService], - exports: [MailerService], -}) -export class MailerModuler {} From 11e61b6f204b8668e3b7c0cb354933a8437865b1 Mon Sep 17 00:00:00 2001 From: Danilo Augusto Date: Wed, 3 Dec 2025 23:18:35 -0300 Subject: [PATCH 10/25] refact: update variable name --- src/domain/users/entities/token.entity.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/domain/users/entities/token.entity.ts b/src/domain/users/entities/token.entity.ts index e5fc005..7b8e413 100644 --- a/src/domain/users/entities/token.entity.ts +++ b/src/domain/users/entities/token.entity.ts @@ -49,12 +49,12 @@ export class Token extends Aggregate { } public static create(type: TokenType): Token { - const experionInMinutes = TOKEN_EXPIRATION_IN_MINUTES[type]; + const expirationInMinutes = TOKEN_EXPIRATION_IN_MINUTES[type]; const tokenProps: TokenProps = { id: crypto.randomUUID(), type: type, - expiresAt: addMinutes(new Date(), experionInMinutes), + expiresAt: addMinutes(new Date(), expirationInMinutes), createdAt: new Date(), updatedAt: new Date(), }; From 384bd9f5a5c7c3bfdcc20b7c7841d0b4684cc80e Mon Sep 17 00:00:00 2001 From: Danilo Augusto Date: Wed, 3 Dec 2025 23:23:38 -0300 Subject: [PATCH 11/25] feat: enhance token saving logic to include deleted_at field --- .../repositories/postgres/users.repository.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/infrastructure/repositories/postgres/users.repository.ts b/src/infrastructure/repositories/postgres/users.repository.ts index 758d026..c1f05bb 100644 --- a/src/infrastructure/repositories/postgres/users.repository.ts +++ b/src/infrastructure/repositories/postgres/users.repository.ts @@ -47,13 +47,21 @@ export class UsersRepository implements IUsersRepository { return; } + const columns = [ + 'id', + 'user_id', + 'type', + 'expires_at', + 'used_at', + 'deleted_at', + ]; const values = []; const placeholders = []; tokens.forEach((token, index) => { - const offset = index * 5; + const offset = index * columns.length; placeholders.push( - `($${offset + 1}, $${offset + 2}, $${offset + 3}, $${offset + 4}, $${offset + 5})`, + `($${offset + 1}, $${offset + 2}, $${offset + 3}, $${offset + 4}, $${offset + 5}, $${offset + 6})`, ); values.push( token.id, @@ -61,17 +69,19 @@ export class UsersRepository implements IUsersRepository { token.type, token.expires_at, token.used_at, + token.deleted_at, ); }); const query = ` - INSERT INTO tokens (id, user_id, type, expires_at, used_at) + 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; + used_at = EXCLUDED.used_at, + deleted_at = EXCLUDED.deleted_at; `; await this.database.query(query, values); From c92ae29d9b9e031af11e80bff656972ab4391e92 Mon Sep 17 00:00:00 2001 From: Danilo Augusto Date: Wed, 3 Dec 2025 23:29:26 -0300 Subject: [PATCH 12/25] feat: refactor email confirmation token creation logic --- .../queues/consumers/email-confirmation.consumer.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/infrastructure/queues/consumers/email-confirmation.consumer.ts b/src/infrastructure/queues/consumers/email-confirmation.consumer.ts index d3606b8..b6ff02c 100644 --- a/src/infrastructure/queues/consumers/email-confirmation.consumer.ts +++ b/src/infrastructure/queues/consumers/email-confirmation.consumer.ts @@ -25,13 +25,9 @@ export class EmailConfirmationConsumer extends WorkerHost { throw Error(`User with ID ${job.data.userId} not found`); } - user.createToken(TokenType.EMAIL_CONFIRMATION); + const token = user.createToken(TokenType.EMAIL_CONFIRMATION); await this.usersRepository.save(user); - const token = user.tokens.find( - (t) => t.type === TokenType.EMAIL_CONFIRMATION, - ); - try { await this.mailerService.sendMail({ recipients: [{ address: user.email.value, name: user.name }], From 0a051e0bb9a300424ba41a7e20534b959525c7a0 Mon Sep 17 00:00:00 2001 From: Danilo Augusto Date: Wed, 3 Dec 2025 23:32:33 -0300 Subject: [PATCH 13/25] feat: replace queues.model with queues.module and update imports --- src/app.module.ts | 2 +- src/infrastructure/queues/{queues.model.ts => queues.module.ts} | 0 tests/integration/test.setup.ts | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename src/infrastructure/queues/{queues.model.ts => queues.module.ts} (100%) diff --git a/src/app.module.ts b/src/app.module.ts index c7b8421..dc91b87 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -8,7 +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.model'; +import { QueuesModule } from './infrastructure/queues/queues.module'; @Module({ imports: [ diff --git a/src/infrastructure/queues/queues.model.ts b/src/infrastructure/queues/queues.module.ts similarity index 100% rename from src/infrastructure/queues/queues.model.ts rename to src/infrastructure/queues/queues.module.ts diff --git a/tests/integration/test.setup.ts b/tests/integration/test.setup.ts index 16212de..78f0bca 100644 --- a/tests/integration/test.setup.ts +++ b/tests/integration/test.setup.ts @@ -3,7 +3,7 @@ import { getQueueToken } from '@nestjs/bullmq'; import { AppModule } from '@src/app.module'; import { DatabaseService } from '@src/infrastructure/database/database.service'; -import { QueuesModule } from '@src/infrastructure/queues/queues.model'; +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'; From e1cb4ceb5736cd7587c64dca3ed8d5dd28eb1e00 Mon Sep 17 00:00:00 2001 From: Danilo Augusto Date: Wed, 3 Dec 2025 23:39:21 -0300 Subject: [PATCH 14/25] feat: enhance error logging in MailerService --- src/infrastructure/mailer/mailer.service.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/infrastructure/mailer/mailer.service.ts b/src/infrastructure/mailer/mailer.service.ts index 4f4d86d..30af5e3 100644 --- a/src/infrastructure/mailer/mailer.service.ts +++ b/src/infrastructure/mailer/mailer.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import * as nodemailer from 'nodemailer'; import { Address } from 'nodemailer/lib/mailer'; import * as hbs from 'nodemailer-express-handlebars'; @@ -30,7 +30,8 @@ export class MailerService { try { return this.mailTransport().sendMail(options); } catch (error) { - console.error('Error while sending email', error); + Logger.error('Error while sending email', error, MailerService.name); + throw error; } } From 3a688d6ba8efbe55763abe0fa5078c96c377c0da Mon Sep 17 00:00:00 2001 From: Danilo Augusto Date: Wed, 3 Dec 2025 23:48:09 -0300 Subject: [PATCH 15/25] feat: export ToPersist type from token.mapper for improved type usage in user.mapper --- .../repositories/mappers/token.mapper.ts | 2 +- .../repositories/mappers/user.mapper.ts | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/infrastructure/repositories/mappers/token.mapper.ts b/src/infrastructure/repositories/mappers/token.mapper.ts index 8f222c2..914a66c 100644 --- a/src/infrastructure/repositories/mappers/token.mapper.ts +++ b/src/infrastructure/repositories/mappers/token.mapper.ts @@ -11,7 +11,7 @@ export interface TokenDbRow { deleted_at: Date | null; } -type ToPersist = Token & { userId: string }; +export type ToPersist = Token & { userId: string }; export class TokenMapper { public static toPersistence(toPersist: ToPersist): TokenDbRow { diff --git a/src/infrastructure/repositories/mappers/user.mapper.ts b/src/infrastructure/repositories/mappers/user.mapper.ts index 06a94c8..a6421ea 100644 --- a/src/infrastructure/repositories/mappers/user.mapper.ts +++ b/src/infrastructure/repositories/mappers/user.mapper.ts @@ -1,7 +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 } from './token.mapper'; +import { + TokenDbRow, + TokenMapper, + ToPersist as TokenToPersist, +} from './token.mapper'; interface UserDbRow { id: string; @@ -30,7 +34,10 @@ export class UserMapper { deleted_at: user.deletedAt, tokens: user.tokens.map((token) => - TokenMapper.toPersistence(Object.assign(token, { userId: user.id })), + TokenMapper.toPersistence({ + ...token, + userId: user.id, + } as TokenToPersist), ), }; } From bb437fd3eba8bb71f0c2ed6958805094f0f0d531 Mon Sep 17 00:00:00 2001 From: Danilo Augusto Date: Wed, 3 Dec 2025 23:51:41 -0300 Subject: [PATCH 16/25] feat: improve null handling for used_at and deleted_at fields in TokenMapper --- src/infrastructure/repositories/mappers/token.mapper.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/infrastructure/repositories/mappers/token.mapper.ts b/src/infrastructure/repositories/mappers/token.mapper.ts index 914a66c..6d570f1 100644 --- a/src/infrastructure/repositories/mappers/token.mapper.ts +++ b/src/infrastructure/repositories/mappers/token.mapper.ts @@ -20,10 +20,11 @@ export class TokenMapper { user_id: toPersist.userId, type: toPersist.type, expires_at: toPersist.expiresAt, - used_at: toPersist.usedAt || null, + used_at: toPersist.usedAt !== undefined ? toPersist.usedAt : null, created_at: toPersist.createdAt, updated_at: toPersist.updatedAt, - deleted_at: toPersist.deletedAt || null, + deleted_at: + toPersist.deletedAt !== undefined ? toPersist.deletedAt : null, }; } @@ -32,10 +33,10 @@ export class TokenMapper { id: raw.id, type: raw.type as TokenType, expiresAt: raw.expires_at, - usedAt: raw.used_at || undefined, + usedAt: raw.used_at !== null ? raw.used_at : undefined, createdAt: raw.created_at, updatedAt: raw.updated_at, - deletedAt: raw.deleted_at || undefined, + deletedAt: raw.deleted_at !== null ? raw.deleted_at : undefined, }; return Token.hydrate(tokenProps); From 5bd53b33c73a7fde1e0d1edc2288f7abd62564f4 Mon Sep 17 00:00:00 2001 From: Danilo Augusto <34426848+daniloamsilva@users.noreply.github.com> Date: Fri, 5 Dec 2025 22:00:54 +0000 Subject: [PATCH 17/25] feat: simplify token persistence logic in UserMapper --- src/infrastructure/repositories/mappers/user.mapper.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/infrastructure/repositories/mappers/user.mapper.ts b/src/infrastructure/repositories/mappers/user.mapper.ts index a6421ea..8ecf83f 100644 --- a/src/infrastructure/repositories/mappers/user.mapper.ts +++ b/src/infrastructure/repositories/mappers/user.mapper.ts @@ -34,10 +34,9 @@ export class UserMapper { deleted_at: user.deletedAt, tokens: user.tokens.map((token) => - TokenMapper.toPersistence({ - ...token, - userId: user.id, - } as TokenToPersist), + TokenMapper.toPersistence( + Object.assign(token, { userId: user.id }) as TokenToPersist, + ), ), }; } From 8d86fd3412f8f9a1513814bdd72c3c14511b8a62 Mon Sep 17 00:00:00 2001 From: Danilo Augusto <34426848+daniloamsilva@users.noreply.github.com> Date: Fri, 5 Dec 2025 22:03:34 +0000 Subject: [PATCH 18/25] feat: update job addition logic in QueuesService for improved jobId formatting --- src/infrastructure/queues/queues.service.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/infrastructure/queues/queues.service.ts b/src/infrastructure/queues/queues.service.ts index 404b49e..a1ce16e 100644 --- a/src/infrastructure/queues/queues.service.ts +++ b/src/infrastructure/queues/queues.service.ts @@ -25,6 +25,10 @@ export class QueuesService { throw new UnprocessableEntityException(`Queue not found: ${queueType}`); } - await queue.add(`task-${queueType}-${Date.now()}`, { userId }); + await queue.add( + `${queueType}`, + { userId }, + { jobId: `${queueType}-${userId}-${Date.now()}` }, + ); } } From 3e3f102180f0ce9f96955b6d08c341c2ccc51749 Mon Sep 17 00:00:00 2001 From: Danilo Augusto <34426848+daniloamsilva@users.noreply.github.com> Date: Fri, 5 Dec 2025 22:10:27 +0000 Subject: [PATCH 19/25] feat: refactor MailerService to use a class-level transporter for sending emails --- src/infrastructure/mailer/mailer.service.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/infrastructure/mailer/mailer.service.ts b/src/infrastructure/mailer/mailer.service.ts index 30af5e3..b05e634 100644 --- a/src/infrastructure/mailer/mailer.service.ts +++ b/src/infrastructure/mailer/mailer.service.ts @@ -13,6 +13,12 @@ type mailOptions = { @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; @@ -28,14 +34,14 @@ export class MailerService { }; try { - return this.mailTransport().sendMail(options); + return this.transporter.sendMail(options); } catch (error) { Logger.error('Error while sending email', error, MailerService.name); throw error; } } - private mailTransport() { + private createMailTransport() { const transporter = nodemailer.createTransport({ host: process.env.MAIL_HOST, port: parseInt(process.env.MAIL_PORT), From e336164c4e563bbfd7aa292ee1971f0930438007 Mon Sep 17 00:00:00 2001 From: Danilo Augusto <34426848+daniloamsilva@users.noreply.github.com> Date: Fri, 5 Dec 2025 22:52:26 +0000 Subject: [PATCH 20/25] feat: optimize token invalidation logic in createToken method --- src/domain/users/entities/user.entity.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/domain/users/entities/user.entity.ts b/src/domain/users/entities/user.entity.ts index 4b87bba..4e56571 100644 --- a/src/domain/users/entities/user.entity.ts +++ b/src/domain/users/entities/user.entity.ts @@ -88,11 +88,9 @@ export class User extends AggregateRoot { } public createToken(type: TokenType): Token { - this.tokens.forEach((token) => { - if (token.type === type) { - token.invalidate(); - } - }); + this.tokens + .filter((token) => token.type === type) + .forEach((token) => token.invalidate()); const token = Token.create(type); this.tokens.push(token); From c441ff3b386f01016c4e8ce976e5787570b47c33 Mon Sep 17 00:00:00 2001 From: Danilo Augusto <34426848+daniloamsilva@users.noreply.github.com> Date: Fri, 5 Dec 2025 22:53:37 +0000 Subject: [PATCH 21/25] feat: correct type definition for mailOptions in MailerService --- src/infrastructure/mailer/mailer.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/infrastructure/mailer/mailer.service.ts b/src/infrastructure/mailer/mailer.service.ts index b05e634..822bcc7 100644 --- a/src/infrastructure/mailer/mailer.service.ts +++ b/src/infrastructure/mailer/mailer.service.ts @@ -3,7 +3,7 @@ import * as nodemailer from 'nodemailer'; import { Address } from 'nodemailer/lib/mailer'; import * as hbs from 'nodemailer-express-handlebars'; -type mailOptions = { +type MailOptions = { from?: Address; recipients: Address[]; subject: string; @@ -19,7 +19,7 @@ export class MailerService { this.transporter = this.createMailTransport(); } - async sendMail(mailOptions: mailOptions) { + async sendMail(mailOptions: MailOptions) { const { from, recipients, subject, template, context } = mailOptions; const options = { From f32aad6de52a1ac30670f6ca9db7b41a7e547eec Mon Sep 17 00:00:00 2001 From: Danilo Augusto <34426848+daniloamsilva@users.noreply.github.com> Date: Fri, 5 Dec 2025 23:01:00 +0000 Subject: [PATCH 22/25] feat: ensure error is thrown after logging in EmailConfirmationConsumer --- .../queues/consumers/email-confirmation.consumer.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/infrastructure/queues/consumers/email-confirmation.consumer.ts b/src/infrastructure/queues/consumers/email-confirmation.consumer.ts index b6ff02c..f31d731 100644 --- a/src/infrastructure/queues/consumers/email-confirmation.consumer.ts +++ b/src/infrastructure/queues/consumers/email-confirmation.consumer.ts @@ -45,6 +45,7 @@ export class EmailConfirmationConsumer extends WorkerHost { error, EmailConfirmationConsumer.name, ); + throw error; } } } From aa47ebc4991da928e708c0a165708478d79c68ce Mon Sep 17 00:00:00 2001 From: Danilo Augusto <34426848+daniloamsilva@users.noreply.github.com> Date: Fri, 5 Dec 2025 23:03:47 +0000 Subject: [PATCH 23/25] feat: refactor AggregateRoot to extend Aggregate and remove unnecessary constructor --- src/shared/domain/AggregateRoot.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) 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]); } From fe9951d7fa59cbcad68289a3010a46f5ff32ff4f Mon Sep 17 00:00:00 2001 From: Danilo Augusto <34426848+daniloamsilva@users.noreply.github.com> Date: Fri, 5 Dec 2025 23:06:13 +0000 Subject: [PATCH 24/25] feat: move user save operation after sending confirmation email --- .../queues/consumers/email-confirmation.consumer.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/infrastructure/queues/consumers/email-confirmation.consumer.ts b/src/infrastructure/queues/consumers/email-confirmation.consumer.ts index f31d731..33a302f 100644 --- a/src/infrastructure/queues/consumers/email-confirmation.consumer.ts +++ b/src/infrastructure/queues/consumers/email-confirmation.consumer.ts @@ -26,7 +26,6 @@ export class EmailConfirmationConsumer extends WorkerHost { } const token = user.createToken(TokenType.EMAIL_CONFIRMATION); - await this.usersRepository.save(user); try { await this.mailerService.sendMail({ @@ -39,6 +38,8 @@ export class EmailConfirmationConsumer extends WorkerHost { from: process.env.MAIL_FROM_NAME, }, }); + + await this.usersRepository.save(user); } catch (error) { Logger.error( 'Error sending confirmation email:', From 3d4fd705be898e102b897c09296bb17298067e88 Mon Sep 17 00:00:00 2001 From: Danilo Augusto <34426848+daniloamsilva@users.noreply.github.com> Date: Fri, 5 Dec 2025 23:27:11 +0000 Subject: [PATCH 25/25] feat: replace crypto.randomUUID with uuid package for token ID generation --- src/domain/users/entities/token.entity.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/domain/users/entities/token.entity.ts b/src/domain/users/entities/token.entity.ts index 7b8e413..b130333 100644 --- a/src/domain/users/entities/token.entity.ts +++ b/src/domain/users/entities/token.entity.ts @@ -1,4 +1,5 @@ import { addMinutes } from 'date-fns'; +import { v7 as uuid } from 'uuid'; import { Aggregate } from '@src/shared/domain/Aggregate'; @@ -52,7 +53,7 @@ export class Token extends Aggregate { const expirationInMinutes = TOKEN_EXPIRATION_IN_MINUTES[type]; const tokenProps: TokenProps = { - id: crypto.randomUUID(), + id: uuid(), type: type, expiresAt: addMinutes(new Date(), expirationInMinutes), createdAt: new Date(),