Skip to content

Commit

Permalink
feat: add cache manager (#256)
Browse files Browse the repository at this point in the history
  • Loading branch information
lamngockhuong authored Sep 8, 2024
1 parent 6a3d1ba commit 1bea9e7
Show file tree
Hide file tree
Showing 21 changed files with 321 additions and 51 deletions.
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"@nestjs-modules/mailer": "^2.0.2",
"@nestjs/axios": "^3.0.3",
"@nestjs/bullmq": "^10.2.1",
"@nestjs/cache-manager": "^2.2.2",
"@nestjs/common": "^10.4.1",
"@nestjs/config": "^3.2.3",
"@nestjs/core": "^10.4.1",
Expand All @@ -48,6 +49,8 @@
"argon2": "^0.41.1",
"axios": "^1.7.7",
"bullmq": "^5.12.14",
"cache-manager": "^5.7.6",
"cache-manager-redis-yet": "^5.1.4",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"compression": "^1.7.4",
Expand Down
164 changes: 152 additions & 12 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions src/api/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { CurrentUser } from '@/decorators/current-user.decorator';
import { ApiAuth, ApiPublic } from '@/decorators/http.decorators';
import { Body, Controller, Post } from '@nestjs/common';
import { Body, Controller, Get, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { LoginReqDto } from './dto/login.req.dto';
Expand Down Expand Up @@ -70,7 +70,7 @@ export class AuthController {
}

@ApiPublic()
@Post('verify/email')
@Get('verify/email')
async verifyEmail() {
return 'verify-email';
}
Expand Down
7 changes: 7 additions & 0 deletions src/api/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getQueueToken } from '@nestjs/bullmq';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { Test, TestingModule } from '@nestjs/testing';
Expand Down Expand Up @@ -50,6 +51,12 @@ describe('AuthService', () => {
add: jest.fn(),
},
},
{
provide: CACHE_MANAGER,
useValue: {
set: jest.fn(),
},
},
],
}).compile();

Expand Down
58 changes: 47 additions & 11 deletions src/api/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
import { IEmailJob, IVerifyEmailJob } from '@/common/interfaces/job.interface';
import { Branded } from '@/common/types/types';
import { AllConfigType } from '@/config/config.type';
import { SYSTEM_USER_ID } from '@/constants/app.constant';
import { ErrorCode } from '@/constants/error-code.constant';
import { JobName, QueueName } from '@/constants/job.constant';
import { ValidationException } from '@/exceptions/validation.exception';
import { verifyPassword } from '@/utils/password.util';
import { InjectQueue } from '@nestjs/bullmq';
import {
BadRequestException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import { randomStringGenerator } from '@nestjs/common/utils/random-string-generator.util';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { Queue } from 'bullmq';
import { Cache } from 'cache-manager';
import { plainToInstance } from 'class-transformer';
import crypto from 'crypto';
import ms from 'ms';
Expand Down Expand Up @@ -46,7 +47,9 @@ export class AuthService {
@InjectRepository(UserEntity)
private readonly userRepository: Repository<UserEntity>,
@InjectQueue(QueueName.EMAIL)
private readonly emailQueue: Queue,
private readonly emailQueue: Queue<IEmailJob, any, string>,
@Inject(CACHE_MANAGER)
private readonly cacheManager: Cache,
) {}

/**
Expand Down Expand Up @@ -94,14 +97,17 @@ export class AuthService {
}

async register(dto: RegisterReqDto): Promise<RegisterResDto> {
const existUser = await UserEntity.findOne({ where: { email: dto.email } });
// Check if the user already exists
const isExistUser = await UserEntity.exists({
where: { email: dto.email },
});

if (existUser) {
throw new BadRequestException('Account with this email already exists');
if (isExistUser) {
throw new ValidationException(ErrorCode.E003);
}

// Register user
const user = new UserEntity({
username: dto.email.split('@')[0],
email: dto.email,
password: dto.password,
createdBy: SYSTEM_USER_ID,
Expand All @@ -110,11 +116,25 @@ export class AuthService {

await user.save();

// Send email verification
const token = await this.createVerificationToken({ id: user.id });
const tokenExpiresIn = this.configService.getOrThrow(
'auth.confirmEmailExpires',
{
infer: true,
},
);
await this.cacheManager.set(
`auth:token:${user.id}:email-verification`,
token,
ms(tokenExpiresIn),
);
await this.emailQueue.add(
JobName.EMAIL_VERIFICATION,
{
email: dto.email,
},
token,
} as IVerifyEmailJob,
{ attempts: 3, backoff: { type: 'exponential', delay: 60000 } },
);

Expand Down Expand Up @@ -182,6 +202,22 @@ export class AuthService {
}
}

private async createVerificationToken(data: { id: string }): Promise<string> {
return await this.jwtService.signAsync(
{
id: data.id,
},
{
secret: this.configService.getOrThrow('auth.confirmEmailSecret', {
infer: true,
}),
expiresIn: this.configService.getOrThrow('auth.confirmEmailExpires', {
infer: true,
}),
},
);
}

private async createToken(data: {
id: string;
sessionId: string;
Expand Down
9 changes: 9 additions & 0 deletions src/api/post/entities/post.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
Column,
DeleteDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Relation,
Expand Down Expand Up @@ -32,6 +33,14 @@ export class PostEntity extends AbstractEntity {
@Column({ nullable: true })
content?: string;

@Column({ name: 'user_id' })
userId!: Uuid;

@JoinColumn({
name: 'user_id',
referencedColumnName: 'id',
foreignKeyConstraintName: 'FK_post_user_id',
})
@ManyToOne(() => UserEntity, (user) => user.posts)
user: Relation<UserEntity>;

Expand Down
7 changes: 5 additions & 2 deletions src/api/user/entities/user.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,15 @@ export class UserEntity extends AbstractEntity {
@PrimaryGeneratedColumn('uuid', { primaryKeyConstraintName: 'PK_user_id' })
id!: Uuid;

@Column()
@Column({
length: 50,
nullable: true,
})
@Index('UQ_user_username', {
where: '"deleted_at" IS NULL',
unique: true,
})
username!: string;
username: string;

@Column()
@Index('UQ_user_email', { where: '"deleted_at" IS NULL', unique: true })
Expand Down
7 changes: 3 additions & 4 deletions src/background/queues/email-queue/email-queue.service.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { IVerifyEmailJob } from '@/common/interfaces/job.interface';
import { MailService } from '@/mail/mail.service';
import { Injectable, Logger } from '@nestjs/common';

Expand All @@ -7,10 +8,8 @@ export class EmailQueueService {

constructor(private readonly mailService: MailService) {}

async sendEmailVerification(data: any): Promise<void> {
async sendEmailVerification(data: IVerifyEmailJob): Promise<void> {
this.logger.debug(`Sending email verification to ${data.email}`);
await this.mailService.sendEmailVerification(data.email, 'test'); // TODO: Update logic when sending email verification

return null;
await this.mailService.sendEmailVerification(data.email, data.token);
}
}
10 changes: 8 additions & 2 deletions src/background/queues/email-queue/email.processor.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { IEmailJob, IVerifyEmailJob } from '@/common/interfaces/job.interface';
import { JobName, QueueName } from '@/constants/job.constant';
import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
Expand All @@ -22,14 +23,19 @@ export class EmailProcessor extends WorkerHost {
constructor(private readonly emailQueueService: EmailQueueService) {
super();
}
async process(job: Job<any, any, string>, _token?: string): Promise<any> {
async process(
job: Job<IEmailJob, any, string>,
_token?: string,
): Promise<any> {
this.logger.debug(
`Processing job ${job.id} of type ${job.name} with data ${JSON.stringify(job.data)}...`,
);

switch (job.name) {
case JobName.EMAIL_VERIFICATION:
return await this.emailQueueService.sendEmailVerification(job.data);
return await this.emailQueueService.sendEmailVerification(
job.data as unknown as IVerifyEmailJob,
);
default:
throw new Error(`Unknown job name: ${job.name}`);
}
Expand Down
7 changes: 7 additions & 0 deletions src/common/interfaces/job.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export interface IEmailJob {
email: string;
}

export interface IVerifyEmailJob extends IEmailJob {
token: string;
}
1 change: 1 addition & 0 deletions src/constants/error-code.constant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ export enum ErrorCode {
// Error
E001 = 'user.error.username_or_email_exists',
E002 = 'user.error.not_found',
E003 = 'user.error.email_exists',
}
2 changes: 1 addition & 1 deletion src/database/migrations/1721488504685-create-user-table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export class CreateUserTable1720105653064 implements MigrationInterface {
await queryRunner.query(`
CREATE TABLE "user" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"username" character varying NOT NULL,
"username" character varying(50),
"email" character varying NOT NULL,
"password" character varying NOT NULL,
"bio" character varying NOT NULL DEFAULT '',
Expand Down
13 changes: 11 additions & 2 deletions src/database/migrations/1722352657866-create-post-table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,30 @@ export class CreatePostTable1722352657866 implements MigrationInterface {
await queryRunner.query(`
CREATE TABLE "post" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"slug" character varying NOT NULL,
"title" character varying NOT NULL,
"slug" character varying NOT NULL,
"description" character varying,
"content" character varying,
"user_id" uuid NOT NULL,
"deleted_at" TIMESTAMP WITH TIME ZONE,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"created_by" character varying NOT NULL,
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updated_by" character varying NOT NULL,
CONSTRAINT "PK_post_id" PRIMARY KEY ("id")
)
`);
`);

await queryRunner.query(`
ALTER TABLE "post"
ADD CONSTRAINT "FK_post_user_id" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION
`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "post" DROP CONSTRAINT "FK_post_user_id"
`);
await queryRunner.query(`
DROP TABLE "post"
`);
Expand Down
12 changes: 8 additions & 4 deletions src/database/seeds/1722382752268-post-seeder.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import { PostEntity } from '@/api/post/entities/post.entity';
import { UserEntity } from '@/api/user/entities/user.entity';
import { DataSource } from 'typeorm';
import { Seeder, SeederFactoryManager } from 'typeorm-extension';

export class PostSeeder1722382752268 implements Seeder {
track = false;

public async run(
_dataSource: DataSource,
dataSource: DataSource,
factoryManager: SeederFactoryManager,
): Promise<any> {
const postFactory = factoryManager.get(PostEntity);

await postFactory.saveMany(10);
const userRepository = dataSource.getRepository(UserEntity);
const adminUser = await userRepository.findOneBy({ username: 'admin' });
if (adminUser) {
const postFactory = factoryManager.get(PostEntity);
await postFactory.saveMany(10, { userId: adminUser.id });
}
}
}
1 change: 1 addition & 0 deletions src/generated/i18n.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export type I18nTranslations = {
};
"error": {
"username_or_email_exists": string;
"email_exists": string;
"not_found": string;
"invalid_password": string;
"invalid_token": string;
Expand Down
1 change: 1 addition & 0 deletions src/i18n/en/user.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
},
"error": {
"username_or_email_exists": "Username or email already exists",
"email_exists": "Account with this email already exists",
"not_found": "User not found",
"invalid_password": "Invalid password",
"invalid_token": "Invalid token"
Expand Down
7 changes: 7 additions & 0 deletions src/i18n/jp/user.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,12 @@
},
"validation": {
"is_empty": "は必須です"
},
"error": {
"username_or_email_exists": "ユーザー名またはメールアドレスは既に存在します",
"email_exists": "このメールアドレスを持つアカウントは既に存在します",
"not_found": "ユーザーが見つかりません",
"invalid_password": "無効なパスワード",
"invalid_token": "無効なトークン"
}
}
7 changes: 7 additions & 0 deletions src/i18n/vi/user.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,12 @@
},
"validation": {
"is_empty": "Trường này không được để trống"
},
"error": {
"username_or_email_exists": "Tên người dùng hoặc email đã tồn tại",
"email_exists": "Tài khoản với email này đã tồn tại",
"not_found": "Người dùng không tồn tại",
"invalid_password": "Mật khẩu không hợp lệ",
"invalid_token": "Token không hợp lệ"
}
}
10 changes: 8 additions & 2 deletions src/mail/mail.service.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import { AllConfigType } from '@/config/config.type';
import { MailerService } from '@nestjs-modules/mailer';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class MailService {
constructor(private readonly mailerService: MailerService) {}
constructor(
private readonly configService: ConfigService<AllConfigType>,
private readonly mailerService: MailerService,
) {}

async sendEmailVerification(email: string, token: string) {
const url = `example.com/auth/verify-email?token=${token}`;
// Please replace the URL with your own frontend URL
const url = `${this.configService.get('app.url', { infer: true })}/api/v1/auth/verify/email?token=${token}`;

await this.mailerService.sendMail({
to: email,
Expand Down
Loading

0 comments on commit 1bea9e7

Please sign in to comment.