feat(auth): implement Auth module with register, login, JWT, guards, and CQRS

- Add RefreshToken and OAuthAccount models to Prisma schema
- Implement clean architecture: domain (entities, VOs, events, repo interfaces),
  infrastructure (Prisma repos, Passport strategies, token service),
  application (CQRS command/query handlers), presentation (controller, guards, DTOs)
- Endpoints: POST /auth/register, /auth/login, /auth/refresh, GET /auth/profile,
  GET /auth/profile/agent, PATCH /auth/kyc
- JWT access + refresh token rotation with family-based revocation
- Role-based guards (BUYER, SELLER, AGENT, ADMIN)
- 16 unit tests (value objects, entity) + integration test suite
- All 80 tests passing, clean TypeScript build

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-08 00:24:42 +07:00
parent c981bff771
commit 391c040100
63 changed files with 2194 additions and 33 deletions

View File

@@ -0,0 +1,7 @@
export class LoginUserCommand {
constructor(
public readonly userId: string,
public readonly phone: string,
public readonly role: string,
) {}
}

View File

@@ -0,0 +1,16 @@
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { LoginUserCommand } from './login-user.command';
import { TokenService, type TokenPair } from '../../../infrastructure/services/token.service';
@CommandHandler(LoginUserCommand)
export class LoginUserHandler implements ICommandHandler<LoginUserCommand> {
constructor(private readonly tokenService: TokenService) {}
async execute(command: LoginUserCommand): Promise<TokenPair> {
return this.tokenService.generateTokenPair({
sub: command.userId,
phone: command.phone,
role: command.role,
});
}
}

View File

@@ -0,0 +1,3 @@
export class RefreshTokenCommand {
constructor(public readonly refreshToken: string) {}
}

View File

@@ -0,0 +1,37 @@
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { UnauthorizedException, Inject } from '@nestjs/common';
import { RefreshTokenCommand } from './refresh-token.command';
import { TokenService, type TokenPair } from '../../../infrastructure/services/token.service';
import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository';
@CommandHandler(RefreshTokenCommand)
export class RefreshTokenHandler implements ICommandHandler<RefreshTokenCommand> {
constructor(
private readonly tokenService: TokenService,
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
) {}
async execute(command: RefreshTokenCommand): Promise<TokenPair> {
const rotated = await this.tokenService.rotateRefreshToken(command.refreshToken);
if (!rotated) {
throw new UnauthorizedException('Refresh token không hợp lệ hoặc đã hết hạn');
}
const user = await this.userRepo.findById(rotated.userId);
if (!user || !user.isActive) {
throw new UnauthorizedException('Tài khoản không tồn tại hoặc đã bị vô hiệu hóa');
}
const accessToken = this.tokenService.generateAccessToken({
sub: user.id,
phone: user.phone.value,
role: user.role,
});
return {
accessToken,
refreshToken: rotated.refreshToken,
expiresIn: 900,
};
}
}

View File

@@ -0,0 +1,8 @@
export class RegisterUserCommand {
constructor(
public readonly phone: string,
public readonly password: string,
public readonly fullName: string,
public readonly email?: string,
) {}
}

View File

@@ -0,0 +1,77 @@
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { ConflictException, BadRequestException } from '@nestjs/common';
import { Inject } from '@nestjs/common';
import { createId } from '@paralleldrive/cuid2';
import { RegisterUserCommand } from './register-user.command';
import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository';
import { UserEntity } from '../../../domain/entities/user.entity';
import { Phone } from '../../../domain/value-objects/phone.vo';
import { Email } from '../../../domain/value-objects/email.vo';
import { HashedPassword } from '../../../domain/value-objects/hashed-password.vo';
import { TokenService, type TokenPair } from '../../../infrastructure/services/token.service';
@CommandHandler(RegisterUserCommand)
export class RegisterUserHandler implements ICommandHandler<RegisterUserCommand> {
constructor(
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
private readonly tokenService: TokenService,
private readonly eventBus: EventBus,
) {}
async execute(command: RegisterUserCommand): Promise<TokenPair> {
// Validate phone
const phoneResult = Phone.create(command.phone);
if (phoneResult.isErr) {
throw new BadRequestException(phoneResult.unwrapErr());
}
const phone = phoneResult.unwrap();
// Check duplicate phone
const existingByPhone = await this.userRepo.findByPhone(phone.value);
if (existingByPhone) {
throw new ConflictException('Số điện thoại đã được đăng ký');
}
// Validate email if provided
let email: Email | undefined;
if (command.email) {
const emailResult = Email.create(command.email);
if (emailResult.isErr) {
throw new BadRequestException(emailResult.unwrapErr());
}
email = emailResult.unwrap();
const existingByEmail = await this.userRepo.findByEmail(email.value);
if (existingByEmail) {
throw new ConflictException('Email đã được đăng ký');
}
}
// Hash password
const passwordResult = await HashedPassword.fromPlain(command.password);
if (passwordResult.isErr) {
throw new BadRequestException(passwordResult.unwrapErr());
}
const passwordHash = passwordResult.unwrap();
// Create user entity
const userId = createId();
const user = UserEntity.createNew(userId, phone, command.fullName, passwordHash, email);
// Persist
await this.userRepo.save(user);
// Publish domain events
const events = user.clearDomainEvents();
for (const event of events) {
this.eventBus.publish(event);
}
// Generate tokens
return this.tokenService.generateTokenPair({
sub: user.id,
phone: user.phone.value,
role: user.role,
});
}
}

View File

@@ -0,0 +1,9 @@
import { type KYCStatus } from '@prisma/client';
export class VerifyKycCommand {
constructor(
public readonly userId: string,
public readonly kycStatus: KYCStatus,
public readonly kycData?: Record<string, unknown>,
) {}
}

View File

@@ -0,0 +1,21 @@
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { Inject, NotFoundException } from '@nestjs/common';
import { VerifyKycCommand } from './verify-kyc.command';
import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository';
@CommandHandler(VerifyKycCommand)
export class VerifyKycHandler implements ICommandHandler<VerifyKycCommand> {
constructor(
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
) {}
async execute(command: VerifyKycCommand): Promise<void> {
const user = await this.userRepo.findById(command.userId);
if (!user) {
throw new NotFoundException('Người dùng không tồn tại');
}
user.updateKycStatus(command.kycStatus, command.kycData);
await this.userRepo.update(user);
}
}

View File

@@ -0,0 +1,12 @@
export { RegisterUserCommand } from './commands/register-user/register-user.command';
export { RegisterUserHandler } from './commands/register-user/register-user.handler';
export { LoginUserCommand } from './commands/login-user/login-user.command';
export { LoginUserHandler } from './commands/login-user/login-user.handler';
export { RefreshTokenCommand } from './commands/refresh-token/refresh-token.command';
export { RefreshTokenHandler } from './commands/refresh-token/refresh-token.handler';
export { VerifyKycCommand } from './commands/verify-kyc/verify-kyc.command';
export { VerifyKycHandler } from './commands/verify-kyc/verify-kyc.handler';
export { GetProfileQuery } from './queries/get-profile/get-profile.query';
export { GetProfileHandler, type UserProfileDto } from './queries/get-profile/get-profile.handler';
export { GetAgentByUserIdQuery } from './queries/get-agent-by-user-id/get-agent-by-user-id.query';
export { GetAgentByUserIdHandler, type AgentDto } from './queries/get-agent-by-user-id/get-agent-by-user-id.handler';

View File

@@ -0,0 +1,46 @@
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { Injectable } from '@nestjs/common';
import { PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { GetAgentByUserIdQuery } from './get-agent-by-user-id.query';
export interface AgentDto {
id: string;
userId: string;
licenseNumber: string | null;
agency: string | null;
qualityScore: number;
totalDeals: number;
responseTimeAvg: number | null;
bio: string | null;
serviceAreas: unknown;
isVerified: boolean;
createdAt: Date;
}
@Injectable()
@QueryHandler(GetAgentByUserIdQuery)
export class GetAgentByUserIdHandler implements IQueryHandler<GetAgentByUserIdQuery> {
constructor(private readonly prisma: PrismaService) {}
async execute(query: GetAgentByUserIdQuery): Promise<AgentDto | null> {
const agent = await this.prisma.agent.findUnique({
where: { userId: query.userId },
});
if (!agent) return null;
return {
id: agent.id,
userId: agent.userId,
licenseNumber: agent.licenseNumber,
agency: agent.agency,
qualityScore: agent.qualityScore,
totalDeals: agent.totalDeals,
responseTimeAvg: agent.responseTimeAvg,
bio: agent.bio,
serviceAreas: agent.serviceAreas,
isVerified: agent.isVerified,
createdAt: agent.createdAt,
};
}
}

View File

@@ -0,0 +1,3 @@
export class GetAgentByUserIdQuery {
constructor(public readonly userId: string) {}
}

View File

@@ -0,0 +1,42 @@
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { Inject, NotFoundException } from '@nestjs/common';
import { GetProfileQuery } from './get-profile.query';
import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository';
export interface UserProfileDto {
id: string;
email: string | null;
phone: string;
fullName: string;
avatarUrl: string | null;
role: string;
kycStatus: string;
isActive: boolean;
createdAt: Date;
}
@QueryHandler(GetProfileQuery)
export class GetProfileHandler implements IQueryHandler<GetProfileQuery> {
constructor(
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
) {}
async execute(query: GetProfileQuery): Promise<UserProfileDto> {
const user = await this.userRepo.findById(query.userId);
if (!user) {
throw new NotFoundException('Người dùng không tồn tại');
}
return {
id: user.id,
email: user.email?.value ?? null,
phone: user.phone.value,
fullName: user.fullName,
avatarUrl: user.avatarUrl,
role: user.role,
kycStatus: user.kycStatus,
isActive: user.isActive,
createdAt: user.createdAt,
};
}
}

View File

@@ -0,0 +1,3 @@
export class GetProfileQuery {
constructor(public readonly userId: string) {}
}