Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
e0778e5
feat: add send email confirmation feature
daniloamsilva Nov 27, 2025
67dbfb8
test: add send email queue tests
daniloamsilva Nov 27, 2025
41182cb
feat: create token entity
daniloamsilva Nov 29, 2025
d5bb9c1
feat: attach tokens user aggregate root
daniloamsilva Nov 29, 2025
ab2c615
feat: create migration for tokens table
daniloamsilva Nov 29, 2025
246100e
feat: enhance token management with validity checks and email confirm…
daniloamsilva Nov 30, 2025
bb3a323
feat: add user existence check in email confirmation process
daniloamsilva Dec 4, 2025
5b6bc35
feat: optimize token saving logic with batch insert and early return …
daniloamsilva Dec 4, 2025
5a1448d
refact: remove unused mailer module
daniloamsilva Dec 4, 2025
11e61b6
refact: update variable name
daniloamsilva Dec 4, 2025
384bd9f
feat: enhance token saving logic to include deleted_at field
daniloamsilva Dec 4, 2025
c92ae29
feat: refactor email confirmation token creation logic
daniloamsilva Dec 4, 2025
0a051e0
feat: replace queues.model with queues.module and update imports
daniloamsilva Dec 4, 2025
e1cb4ce
feat: enhance error logging in MailerService
daniloamsilva Dec 4, 2025
3a688d6
feat: export ToPersist type from token.mapper for improved type usage…
daniloamsilva Dec 4, 2025
bb437fd
feat: improve null handling for used_at and deleted_at fields in Toke…
daniloamsilva Dec 4, 2025
5bd53b3
feat: simplify token persistence logic in UserMapper
daniloamsilva Dec 5, 2025
8d86fd3
feat: update job addition logic in QueuesService for improved jobId f…
daniloamsilva Dec 5, 2025
3e3f102
feat: refactor MailerService to use a class-level transporter for sen…
daniloamsilva Dec 5, 2025
e336164
feat: optimize token invalidation logic in createToken method
daniloamsilva Dec 5, 2025
c441ff3
feat: correct type definition for mailOptions in MailerService
daniloamsilva Dec 5, 2025
f32aad6
feat: ensure error is thrown after logging in EmailConfirmationConsumer
daniloamsilva Dec 5, 2025
aa47ebc
feat: refactor AggregateRoot to extend Aggregate and remove unnecessa…
daniloamsilva Dec 5, 2025
fe9951d
feat: move user save operation after sending confirmation email
daniloamsilva Dec 5, 2025
3d4fd70
feat: replace crypto.randomUUID with uuid package for token ID genera…
daniloamsilva Dec 5, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const config: Config = {
'/tests/',
'/scripts/',
'/src/shared/',
'/src/infrastructure/mailer/',
'jest.config.ts',
'main.ts',
],
Expand Down
32 changes: 29 additions & 3 deletions src/api/users/users.module.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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],
},
Comment on lines +40 to +48

Copilot AI Dec 5, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SendEmailConfirmationHandler is registered twice in the providers array (lines 40 and 41-48). This creates two instances of the handler, which is redundant. The handler should only be registered once using the factory provider that also handles the domain event registration. Remove line 40.

Copilot uses AI. Check for mistakes.
],
})
export class UsersModule {}
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -16,6 +17,7 @@ import { ClassValidatorFilter } from './api/shared/filters/class-validator.filte
throttlers: [{ ttl: 60000, limit: 10 }],
}),
DatabaseModule,
QueuesModule,
UsersModule,
],
providers: [
Expand Down
4 changes: 2 additions & 2 deletions src/application/users/use-cases/create-user.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {}

Expand Down
87 changes: 87 additions & 0 deletions src/domain/users/entities/token.entity.ts
Original file line number Diff line number Diff line change
@@ -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, number> = {
[TokenType.EMAIL_CONFIRMATION]: 7 * 24 * 60, // 7 days
};

export class Token extends Aggregate<TokenProps> {
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();
}
}
}
16 changes: 16 additions & 0 deletions src/domain/users/entities/user.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -18,6 +19,7 @@ export interface UserProps {
password: Password;
confirmedAt?: Date | null;
isActive: boolean;
tokens: Token[];
createdAt: Date;
updatedAt: Date;
deletedAt?: Date | null;
Expand All @@ -43,6 +45,9 @@ export class User extends AggregateRoot<UserProps> {
get isActive(): boolean {
return this.props.isActive;
}
get tokens(): Token[] {
return this.props.tokens;
}
get createdAt(): Date {
return this.props.createdAt;
}
Expand All @@ -64,6 +69,7 @@ export class User extends AggregateRoot<UserProps> {
name: props.name,
password: password,
isActive: true,
tokens: [],
createdAt: new Date(),
updatedAt: new Date(),
};
Expand All @@ -81,6 +87,16 @@ export class User extends AggregateRoot<UserProps> {
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;
}
Comment thread
daniloamsilva marked this conversation as resolved.

public static hydrate(props: UserProps): User {
return new User(props);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<UserCreatedEvent> {
constructor(private readonly queuesService: QueuesService) {}

async handle(event: UserCreatedEvent): Promise<void> {
await this.queuesService.execute({
userId: event.aggregateId,
queueType: QueueType.EMAIL_CONFIRMATION,
});
}
}
Comment thread
daniloamsilva marked this conversation as resolved.
66 changes: 66 additions & 0 deletions src/infrastructure/mailer/mailer.service.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
};

@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;
}
Comment thread
daniloamsilva marked this conversation as resolved.
}

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,
},
Comment thread
daniloamsilva marked this conversation as resolved.
});

transporter.use(
'compile',
hbs({
viewEngine: { defaultLayout: '' },
viewPath: 'src/infrastructure/mailer/templates',
extName: '.hbs',
}),
);

return transporter;
}
}
10 changes: 10 additions & 0 deletions src/infrastructure/mailer/templates/email-confirmation.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<p>Olá {{name}}</p>
<p>Clique neste link para verificar seu endereço de e-mail.</p>
<p><a
href='{{confirmationLink}}'
target='_blank'
rel='noopener noreferrer'
>{{confirmationLink}}</a></p>
Comment thread
daniloamsilva marked this conversation as resolved.
<p>Se você não solicitou a verificação deste endereço, ignore este e-mail.</p>
<p>Obrigado,</p>
<p>Equipe da {{from}}</p>
Original file line number Diff line number Diff line change
@@ -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();`);
};
26 changes: 3 additions & 23 deletions src/infrastructure/migrations/1734736313693_create-users-table.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;`);
};
Loading