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

@@ -9,6 +9,7 @@
"start:prod": "node dist/main",
"lint": "eslint src/",
"test": "vitest run",
"test:integration": "vitest run --config vitest.integration.config.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
@@ -16,22 +17,40 @@
"@nestjs/core": "^11.0.0",
"@nestjs/cqrs": "^11.0.0",
"@nestjs/event-emitter": "^3.0.0",
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.0",
"@nestjs/throttler": "^6.5.0",
"@paralleldrive/cuid2": "^3.3.0",
"@prisma/client": "^6.0.0",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"helmet": "^8.1.0",
"ioredis": "^5.4.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"pino": "^9.0.0",
"pino-pretty": "^13.0.0",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.0"
"rxjs": "^7.8.0",
"sanitize-html": "^2.17.2"
},
"devDependencies": {
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.0",
"@types/bcrypt": "^6.0.0",
"@types/express": "^5.0.0",
"@types/node": "^22.0.0",
"typescript": "^5.7.0",
"@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.38",
"@types/sanitize-html": "^2.16.1",
"@types/supertest": "^7.2.0",
"prisma": "^6.0.0",
"supertest": "^7.2.2",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
}
}

View File

@@ -1,10 +1,42 @@
import { SharedModule } from '@modules/shared';
import { AuthModule } from '@modules/auth';
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { CqrsModule } from '@nestjs/cqrs';
import { ThrottlerModule } from '@nestjs/throttler';
import { ThrottlerBehindProxyGuard } from '@modules/shared/infrastructure/guards/throttler-behind-proxy.guard';
import { AppController } from './app.controller';
@Module({
imports: [CqrsModule.forRoot(), SharedModule],
imports: [
CqrsModule.forRoot(),
SharedModule,
AuthModule,
// ── Rate Limiting ──
// Default: 60 requests per 60 seconds per IP
// Override per-route with @Throttle() decorator
ThrottlerModule.forRoot({
throttlers: [
{
name: 'default',
ttl: 60_000,
limit: 60,
},
{
name: 'auth',
ttl: 60_000,
limit: 10,
},
],
}),
],
controllers: [AppController],
providers: [
{
provide: APP_GUARD,
useClass: ThrottlerBehindProxyGuard,
},
],
})
export class AppModule {}

View File

@@ -0,0 +1,197 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { Test, type TestingModule } from '@nestjs/testing';
import { type INestApplication, ValidationPipe } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import request from 'supertest';
import { AuthModule } from '../auth.module';
import { PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { SharedModule } from '@modules/shared/shared.module';
describe('Auth Controller (Integration)', () => {
let app: INestApplication;
let prisma: PrismaService;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [CqrsModule.forRoot(), SharedModule, AuthModule],
}).compile();
app = moduleFixture.createNestApplication();
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: { enableImplicitConversion: true },
}),
);
await app.init();
prisma = app.get(PrismaService);
});
afterAll(async () => {
// Clean up test data
await prisma.refreshToken.deleteMany({});
await prisma.user.deleteMany({
where: { phone: { in: ['+84912345678', '+84987654321'] } },
});
await app.close();
});
describe('POST /auth/register', () => {
it('should register a new user and return tokens', async () => {
const res = await request(app.getHttpServer())
.post('/auth/register')
.send({
phone: '0912345678',
password: 'StrongPass123',
fullName: 'Test User',
email: 'test@example.com',
})
.expect(201);
expect(res.body.accessToken).toBeDefined();
expect(res.body.refreshToken).toBeDefined();
expect(res.body.expiresIn).toBe(900);
});
it('should reject duplicate phone', async () => {
await request(app.getHttpServer())
.post('/auth/register')
.send({
phone: '0912345678',
password: 'StrongPass123',
fullName: 'Duplicate User',
})
.expect(409);
});
it('should reject invalid phone', async () => {
await request(app.getHttpServer())
.post('/auth/register')
.send({
phone: '12345',
password: 'StrongPass123',
fullName: 'Invalid Phone',
})
.expect(400);
});
it('should reject short password', async () => {
await request(app.getHttpServer())
.post('/auth/register')
.send({
phone: '0987654321',
password: 'short',
fullName: 'Short Pass',
})
.expect(400);
});
});
describe('POST /auth/login', () => {
it('should login with valid credentials', async () => {
const res = await request(app.getHttpServer())
.post('/auth/login')
.send({
phone: '0912345678',
password: 'StrongPass123',
})
.expect(201);
expect(res.body.accessToken).toBeDefined();
expect(res.body.refreshToken).toBeDefined();
});
it('should reject invalid password', async () => {
await request(app.getHttpServer())
.post('/auth/login')
.send({
phone: '0912345678',
password: 'WrongPassword',
})
.expect(401);
});
it('should reject non-existent phone', async () => {
await request(app.getHttpServer())
.post('/auth/login')
.send({
phone: '0999999999',
password: 'StrongPass123',
})
.expect(401);
});
});
describe('POST /auth/refresh', () => {
let refreshToken: string;
beforeAll(async () => {
const res = await request(app.getHttpServer())
.post('/auth/login')
.send({
phone: '0912345678',
password: 'StrongPass123',
});
refreshToken = res.body.refreshToken;
});
it('should rotate refresh token', async () => {
const res = await request(app.getHttpServer())
.post('/auth/refresh')
.send({ refreshToken })
.expect(201);
expect(res.body.accessToken).toBeDefined();
expect(res.body.refreshToken).toBeDefined();
expect(res.body.refreshToken).not.toBe(refreshToken);
});
it('should reject reused refresh token', async () => {
// The old token was already rotated — reuse should fail
await request(app.getHttpServer())
.post('/auth/refresh')
.send({ refreshToken })
.expect(401);
});
});
describe('GET /auth/profile', () => {
let accessToken: string;
beforeAll(async () => {
const res = await request(app.getHttpServer())
.post('/auth/login')
.send({
phone: '0912345678',
password: 'StrongPass123',
});
accessToken = res.body.accessToken;
});
it('should return user profile', async () => {
const res = await request(app.getHttpServer())
.get('/auth/profile')
.set('Authorization', `Bearer ${accessToken}`)
.expect(200);
expect(res.body.phone).toBe('+84912345678');
expect(res.body.fullName).toBe('Test User');
expect(res.body.role).toBe('BUYER');
expect(res.body.kycStatus).toBe('NONE');
});
it('should reject unauthenticated request', async () => {
await request(app.getHttpServer()).get('/auth/profile').expect(401);
});
it('should reject invalid token', async () => {
await request(app.getHttpServer())
.get('/auth/profile')
.set('Authorization', 'Bearer invalid-token')
.expect(401);
});
});
});

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) {}
}

View File

@@ -0,0 +1,65 @@
import { Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
// Domain
import { USER_REPOSITORY } from './domain/repositories/user.repository';
import { REFRESH_TOKEN_REPOSITORY } from './domain/repositories/refresh-token.repository';
// Infrastructure
import { PrismaUserRepository } from './infrastructure/repositories/prisma-user.repository';
import { PrismaRefreshTokenRepository } from './infrastructure/repositories/prisma-refresh-token.repository';
import { JwtStrategy } from './infrastructure/strategies/jwt.strategy';
import { LocalStrategy } from './infrastructure/strategies/local.strategy';
import { TokenService } from './infrastructure/services/token.service';
// Application
import { RegisterUserHandler } from './application/commands/register-user/register-user.handler';
import { LoginUserHandler } from './application/commands/login-user/login-user.handler';
import { RefreshTokenHandler } from './application/commands/refresh-token/refresh-token.handler';
import { VerifyKycHandler } from './application/commands/verify-kyc/verify-kyc.handler';
import { GetProfileHandler } from './application/queries/get-profile/get-profile.handler';
import { GetAgentByUserIdHandler } from './application/queries/get-agent-by-user-id/get-agent-by-user-id.handler';
// Presentation
import { AuthController } from './presentation/controllers/auth.controller';
const CommandHandlers = [
RegisterUserHandler,
LoginUserHandler,
RefreshTokenHandler,
VerifyKycHandler,
];
const QueryHandlers = [GetProfileHandler, GetAgentByUserIdHandler];
@Module({
imports: [
CqrsModule,
PassportModule,
JwtModule.register({
secret: process.env['JWT_SECRET'] || 'goodgo-jwt-secret-change-in-production',
signOptions: { expiresIn: '15m' },
}),
],
controllers: [AuthController],
providers: [
// Repositories
{ provide: USER_REPOSITORY, useClass: PrismaUserRepository },
{ provide: REFRESH_TOKEN_REPOSITORY, useClass: PrismaRefreshTokenRepository },
// Strategies
JwtStrategy,
LocalStrategy,
// Services
TokenService,
// CQRS
...CommandHandlers,
...QueryHandlers,
],
exports: [TokenService, USER_REPOSITORY],
})
export class AuthModule {}

View File

@@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest';
import { Email } from '../value-objects/email.vo';
describe('Email Value Object', () => {
it('should create a valid email', () => {
const result = Email.create('Test@Example.com');
expect(result.isOk).toBe(true);
expect(result.unwrap().value).toBe('test@example.com');
});
it('should reject an invalid email', () => {
const result = Email.create('invalid-email');
expect(result.isErr).toBe(true);
expect(result.unwrapErr()).toBe('Email không hợp lệ');
});
it('should trim whitespace', () => {
const result = Email.create(' user@test.com ');
expect(result.isOk).toBe(true);
expect(result.unwrap().value).toBe('user@test.com');
});
it('should check equality', () => {
const a = Email.create('user@test.com').unwrap();
const b = Email.create('USER@TEST.COM').unwrap();
expect(a.equals(b)).toBe(true);
});
});

View File

@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest';
import { HashedPassword } from '../value-objects/hashed-password.vo';
describe('HashedPassword Value Object', () => {
it('should hash and verify a password', async () => {
const result = await HashedPassword.fromPlain('StrongPass123');
expect(result.isOk).toBe(true);
const hashed = result.unwrap();
expect(hashed.value).toMatch(/^\$2[ab]\$/);
const isValid = await hashed.compare('StrongPass123');
expect(isValid).toBe(true);
const isInvalid = await hashed.compare('WrongPassword');
expect(isInvalid).toBe(false);
});
it('should reject short passwords', async () => {
const result = await HashedPassword.fromPlain('short');
expect(result.isErr).toBe(true);
expect(result.unwrapErr()).toContain('ít nhất 8 ký tự');
});
it('should create from existing hash', () => {
const hash = '$2b$12$mockHashValue';
const hashed = HashedPassword.fromHash(hash);
expect(hashed.value).toBe(hash);
});
});

View File

@@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest';
import { Phone } from '../value-objects/phone.vo';
describe('Phone Value Object', () => {
it('should create from valid Vietnam phone (0 prefix)', () => {
const result = Phone.create('0912345678');
expect(result.isOk).toBe(true);
expect(result.unwrap().value).toBe('+84912345678');
});
it('should create from valid Vietnam phone (+84 prefix)', () => {
const result = Phone.create('+84912345678');
expect(result.isOk).toBe(true);
expect(result.unwrap().value).toBe('+84912345678');
});
it('should reject invalid phone', () => {
const result = Phone.create('12345');
expect(result.isErr).toBe(true);
expect(result.unwrapErr()).toBe('Số điện thoại không hợp lệ');
});
it('should check equality after normalization', () => {
const a = Phone.create('0912345678').unwrap();
const b = Phone.create('+84912345678').unwrap();
expect(a.equals(b)).toBe(true);
});
});

View File

@@ -0,0 +1,65 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { UserEntity } from '../entities/user.entity';
import { Phone } from '../value-objects/phone.vo';
import { HashedPassword } from '../value-objects/hashed-password.vo';
import { Email } from '../value-objects/email.vo';
import { UserRegisteredEvent } from '../events/user-registered.event';
describe('UserEntity', () => {
let phone: Phone;
let passwordHash: HashedPassword;
beforeEach(async () => {
phone = Phone.create('0912345678').unwrap();
passwordHash = (await HashedPassword.fromPlain('Password123')).unwrap();
});
it('should create a new user with domain event', () => {
const user = UserEntity.createNew('user-1', phone, 'Nguyễn Văn A', passwordHash);
expect(user.id).toBe('user-1');
expect(user.phone.value).toBe('+84912345678');
expect(user.fullName).toBe('Nguyễn Văn A');
expect(user.role).toBe('BUYER');
expect(user.kycStatus).toBe('NONE');
expect(user.isActive).toBe(true);
expect(user.email).toBeNull();
const events = user.domainEvents;
expect(events).toHaveLength(1);
expect(events[0]).toBeInstanceOf(UserRegisteredEvent);
expect((events[0] as UserRegisteredEvent).phone).toBe('+84912345678');
});
it('should create a user with email', () => {
const email = Email.create('test@example.com').unwrap();
const user = UserEntity.createNew('user-2', phone, 'Trần Thị B', passwordHash, email);
expect(user.email?.value).toBe('test@example.com');
});
it('should update KYC status', () => {
const user = UserEntity.createNew('user-3', phone, 'Lê Văn C', passwordHash);
user.updateKycStatus('PENDING', { idCard: '123456789' });
expect(user.kycStatus).toBe('PENDING');
expect(user.kycData).toEqual({ idCard: '123456789' });
});
it('should deactivate user', () => {
const user = UserEntity.createNew('user-4', phone, 'Phạm Thị D', passwordHash);
expect(user.isActive).toBe(true);
user.deactivate();
expect(user.isActive).toBe(false);
});
it('should clear domain events', () => {
const user = UserEntity.createNew('user-5', phone, 'Hoàng Văn E', passwordHash);
expect(user.domainEvents).toHaveLength(1);
const cleared = user.clearDomainEvents();
expect(cleared).toHaveLength(1);
expect(user.domainEvents).toHaveLength(0);
});
});

View File

@@ -0,0 +1 @@
export { UserEntity, type UserProps } from './user.entity';

View File

@@ -0,0 +1,88 @@
import { AggregateRoot } from '@modules/shared/domain/aggregate-root';
import { type UserRole, type KYCStatus } from '@prisma/client';
import { UserRegisteredEvent } from '../events/user-registered.event';
import { type Email } from '../value-objects/email.vo';
import { type Phone } from '../value-objects/phone.vo';
import { type HashedPassword } from '../value-objects/hashed-password.vo';
export interface UserProps {
email: Email | null;
phone: Phone;
passwordHash: HashedPassword | null;
fullName: string;
avatarUrl: string | null;
role: UserRole;
kycStatus: KYCStatus;
kycData: unknown;
isActive: boolean;
}
export class UserEntity extends AggregateRoot<string> {
private _email: Email | null;
private _phone: Phone;
private _passwordHash: HashedPassword | null;
private _fullName: string;
private _avatarUrl: string | null;
private _role: UserRole;
private _kycStatus: KYCStatus;
private _kycData: unknown;
private _isActive: boolean;
constructor(id: string, props: UserProps, createdAt?: Date, updatedAt?: Date) {
super(id, createdAt, updatedAt);
this._email = props.email;
this._phone = props.phone;
this._passwordHash = props.passwordHash;
this._fullName = props.fullName;
this._avatarUrl = props.avatarUrl;
this._role = props.role;
this._kycStatus = props.kycStatus;
this._kycData = props.kycData;
this._isActive = props.isActive;
}
get email(): Email | null { return this._email; }
get phone(): Phone { return this._phone; }
get passwordHash(): HashedPassword | null { return this._passwordHash; }
get fullName(): string { return this._fullName; }
get avatarUrl(): string | null { return this._avatarUrl; }
get role(): UserRole { return this._role; }
get kycStatus(): KYCStatus { return this._kycStatus; }
get kycData(): unknown { return this._kycData; }
get isActive(): boolean { return this._isActive; }
static createNew(
id: string,
phone: Phone,
fullName: string,
passwordHash: HashedPassword,
email?: Email,
role: UserRole = 'BUYER',
): UserEntity {
const user = new UserEntity(id, {
email: email ?? null,
phone,
passwordHash,
fullName,
avatarUrl: null,
role,
kycStatus: 'NONE',
kycData: null,
isActive: true,
});
user.addDomainEvent(new UserRegisteredEvent(id, phone.value, role));
return user;
}
updateKycStatus(status: KYCStatus, kycData?: unknown): void {
this._kycStatus = status;
if (kycData !== undefined) this._kycData = kycData;
this.updatedAt = new Date();
}
deactivate(): void {
this._isActive = false;
this.updatedAt = new Date();
}
}

View File

@@ -0,0 +1,11 @@
import { type DomainEvent } from '@modules/shared/domain/domain-event';
export class AgentVerifiedEvent implements DomainEvent {
readonly eventName = 'agent.verified';
readonly occurredAt = new Date();
constructor(
public readonly aggregateId: string,
public readonly userId: string,
) {}
}

View File

@@ -0,0 +1,2 @@
export { UserRegisteredEvent } from './user-registered.event';
export { AgentVerifiedEvent } from './agent-verified.event';

View File

@@ -0,0 +1,13 @@
import { type DomainEvent } from '@modules/shared/domain/domain-event';
import { type UserRole } from '@prisma/client';
export class UserRegisteredEvent implements DomainEvent {
readonly eventName = 'user.registered';
readonly occurredAt = new Date();
constructor(
public readonly aggregateId: string,
public readonly phone: string,
public readonly role: UserRole,
) {}
}

View File

@@ -0,0 +1,4 @@
export * from './entities';
export * from './value-objects';
export * from './events';
export * from './repositories';

View File

@@ -0,0 +1,6 @@
export { USER_REPOSITORY, type IUserRepository } from './user.repository';
export {
REFRESH_TOKEN_REPOSITORY,
type IRefreshTokenRepository,
type RefreshTokenRecord,
} from './refresh-token.repository';

View File

@@ -0,0 +1,19 @@
export const REFRESH_TOKEN_REPOSITORY = Symbol('REFRESH_TOKEN_REPOSITORY');
export interface RefreshTokenRecord {
id: string;
userId: string;
token: string;
family: string;
expiresAt: Date;
revokedAt: Date | null;
createdAt: Date;
}
export interface IRefreshTokenRepository {
create(record: Omit<RefreshTokenRecord, 'id' | 'createdAt'>): Promise<RefreshTokenRecord>;
findByToken(token: string): Promise<RefreshTokenRecord | null>;
revokeByFamily(family: string): Promise<void>;
revokeAllForUser(userId: string): Promise<void>;
deleteExpired(): Promise<number>;
}

View File

@@ -0,0 +1,11 @@
import { type UserEntity } from '../entities/user.entity';
export const USER_REPOSITORY = Symbol('USER_REPOSITORY');
export interface IUserRepository {
findById(id: string): Promise<UserEntity | null>;
findByPhone(phone: string): Promise<UserEntity | null>;
findByEmail(email: string): Promise<UserEntity | null>;
save(user: UserEntity): Promise<void>;
update(user: UserEntity): Promise<void>;
}

View File

@@ -0,0 +1,22 @@
import { ValueObject } from '@modules/shared/domain/value-object';
import { Result } from '@modules/shared/domain/result';
interface EmailProps {
value: string;
}
export class Email extends ValueObject<EmailProps> {
private static readonly EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
get value(): string {
return this.props.value;
}
static create(email: string): Result<Email, string> {
const normalized = email.trim().toLowerCase();
if (!this.EMAIL_REGEX.test(normalized)) {
return Result.err('Email không hợp lệ');
}
return Result.ok(new Email({ value: normalized }));
}
}

View File

@@ -0,0 +1,32 @@
import { ValueObject } from '@modules/shared/domain/value-object';
import { Result } from '@modules/shared/domain/result';
import * as bcrypt from 'bcrypt';
interface HashedPasswordProps {
value: string;
}
export class HashedPassword extends ValueObject<HashedPasswordProps> {
private static readonly SALT_ROUNDS = 12;
private static readonly MIN_LENGTH = 8;
get value(): string {
return this.props.value;
}
static async fromPlain(password: string): Promise<Result<HashedPassword, string>> {
if (password.length < this.MIN_LENGTH) {
return Result.err(`Mật khẩu phải có ít nhất ${this.MIN_LENGTH} ký tự`);
}
const hash = await bcrypt.hash(password, this.SALT_ROUNDS);
return Result.ok(new HashedPassword({ value: hash }));
}
static fromHash(hash: string): HashedPassword {
return new HashedPassword({ value: hash });
}
async compare(plainPassword: string): Promise<boolean> {
return bcrypt.compare(plainPassword, this.props.value);
}
}

View File

@@ -0,0 +1,3 @@
export { Email } from './email.vo';
export { Phone } from './phone.vo';
export { HashedPassword } from './hashed-password.vo';

View File

@@ -0,0 +1,24 @@
import { ValueObject } from '@modules/shared/domain/value-object';
import { Result } from '@modules/shared/domain/result';
import { isValidVietnamPhone, normalizeVietnamPhone } from '@modules/shared/utils/vietnam-phone.validator';
interface PhoneProps {
value: string;
}
export class Phone extends ValueObject<PhoneProps> {
get value(): string {
return this.props.value;
}
static create(phone: string): Result<Phone, string> {
if (!isValidVietnamPhone(phone)) {
return Result.err('Số điện thoại không hợp lệ');
}
const normalized = normalizeVietnamPhone(phone);
if (!normalized) {
return Result.err('Không thể chuẩn hóa số điện thoại');
}
return Result.ok(new Phone({ value: normalized }));
}
}

View File

@@ -0,0 +1,6 @@
export { AuthModule } from './auth.module';
export { JwtAuthGuard } from './presentation/guards/jwt-auth.guard';
export { RolesGuard } from './presentation/guards/roles.guard';
export { Roles } from './presentation/decorators/roles.decorator';
export { CurrentUser } from './presentation/decorators/current-user.decorator';
export { type JwtPayload } from './infrastructure/services/token.service';

View File

@@ -0,0 +1,3 @@
export * from './repositories';
export * from './strategies';
export * from './services';

View File

@@ -0,0 +1,2 @@
export { PrismaUserRepository } from './prisma-user.repository';
export { PrismaRefreshTokenRepository } from './prisma-refresh-token.repository';

View File

@@ -0,0 +1,50 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '@modules/shared/infrastructure/prisma.service';
import {
type IRefreshTokenRepository,
type RefreshTokenRecord,
} from '../../domain/repositories/refresh-token.repository';
@Injectable()
export class PrismaRefreshTokenRepository implements IRefreshTokenRepository {
constructor(private readonly prisma: PrismaService) {}
async create(
record: Omit<RefreshTokenRecord, 'id' | 'createdAt'>,
): Promise<RefreshTokenRecord> {
return this.prisma.refreshToken.create({
data: {
userId: record.userId,
token: record.token,
family: record.family,
expiresAt: record.expiresAt,
revokedAt: record.revokedAt,
},
});
}
async findByToken(token: string): Promise<RefreshTokenRecord | null> {
return this.prisma.refreshToken.findUnique({ where: { token } });
}
async revokeByFamily(family: string): Promise<void> {
await this.prisma.refreshToken.updateMany({
where: { family, revokedAt: null },
data: { revokedAt: new Date() },
});
}
async revokeAllForUser(userId: string): Promise<void> {
await this.prisma.refreshToken.updateMany({
where: { userId, revokedAt: null },
data: { revokedAt: new Date() },
});
}
async deleteExpired(): Promise<number> {
const result = await this.prisma.refreshToken.deleteMany({
where: { expiresAt: { lt: new Date() } },
});
return result.count;
}
}

View File

@@ -0,0 +1,84 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { type User as PrismaUser } from '@prisma/client';
import { type IUserRepository } from '../../domain/repositories/user.repository';
import { UserEntity, type UserProps } from '../../domain/entities/user.entity';
import { Email } from '../../domain/value-objects/email.vo';
import { Phone } from '../../domain/value-objects/phone.vo';
import { HashedPassword } from '../../domain/value-objects/hashed-password.vo';
@Injectable()
export class PrismaUserRepository implements IUserRepository {
constructor(private readonly prisma: PrismaService) {}
async findById(id: string): Promise<UserEntity | null> {
const user = await this.prisma.user.findUnique({ where: { id } });
return user ? this.toDomain(user) : null;
}
async findByPhone(phone: string): Promise<UserEntity | null> {
const user = await this.prisma.user.findUnique({ where: { phone } });
return user ? this.toDomain(user) : null;
}
async findByEmail(email: string): Promise<UserEntity | null> {
const user = await this.prisma.user.findUnique({ where: { email } });
return user ? this.toDomain(user) : null;
}
async save(entity: UserEntity): Promise<void> {
await this.prisma.user.create({
data: {
id: entity.id,
email: entity.email?.value ?? null,
phone: entity.phone.value,
passwordHash: entity.passwordHash?.value ?? null,
fullName: entity.fullName,
avatarUrl: entity.avatarUrl,
role: entity.role,
kycStatus: entity.kycStatus,
kycData: entity.kycData as any,
isActive: entity.isActive,
},
});
}
async update(entity: UserEntity): Promise<void> {
await this.prisma.user.update({
where: { id: entity.id },
data: {
email: entity.email?.value ?? null,
phone: entity.phone.value,
passwordHash: entity.passwordHash?.value ?? null,
fullName: entity.fullName,
avatarUrl: entity.avatarUrl,
role: entity.role,
kycStatus: entity.kycStatus,
kycData: entity.kycData as any,
isActive: entity.isActive,
},
});
}
private toDomain(raw: PrismaUser): UserEntity {
const phone = Phone.create(raw.phone).unwrap();
const email = raw.email ? Email.create(raw.email).unwrap() : null;
const passwordHash = raw.passwordHash
? HashedPassword.fromHash(raw.passwordHash)
: null;
const props: UserProps = {
email,
phone,
passwordHash,
fullName: raw.fullName,
avatarUrl: raw.avatarUrl,
role: raw.role,
kycStatus: raw.kycStatus,
kycData: raw.kycData,
isActive: raw.isActive,
};
return new UserEntity(raw.id, props, raw.createdAt, raw.updatedAt);
}
}

View File

@@ -0,0 +1,6 @@
export {
TokenService,
type JwtPayload,
type TokenPair,
type RotateResult,
} from './token.service';

View File

@@ -0,0 +1,127 @@
import { Inject, Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { randomBytes, createHash } from 'crypto';
import {
REFRESH_TOKEN_REPOSITORY,
type IRefreshTokenRepository,
} from '../../domain/repositories/refresh-token.repository';
export interface JwtPayload {
sub: string;
phone: string;
role: string;
}
export interface TokenPair {
accessToken: string;
refreshToken: string;
expiresIn: number;
}
export interface RotateResult {
userId: string;
refreshToken: string;
}
@Injectable()
export class TokenService {
private readonly REFRESH_TOKEN_EXPIRY_DAYS = 30;
constructor(
private readonly jwtService: JwtService,
@Inject(REFRESH_TOKEN_REPOSITORY)
private readonly refreshTokenRepo: IRefreshTokenRepository,
) {}
async generateTokenPair(payload: JwtPayload): Promise<TokenPair> {
const accessToken = this.jwtService.sign(payload);
const rawRefreshToken = randomBytes(64).toString('hex');
const hashedToken = this.hashToken(rawRefreshToken);
const family = randomBytes(16).toString('hex');
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + this.REFRESH_TOKEN_EXPIRY_DAYS);
await this.refreshTokenRepo.create({
userId: payload.sub,
token: hashedToken,
family,
expiresAt,
revokedAt: null,
});
return {
accessToken,
refreshToken: `${family}.${rawRefreshToken}`,
expiresIn: 900,
};
}
async rotateRefreshToken(refreshToken: string): Promise<RotateResult | null> {
const dotIndex = refreshToken.indexOf('.');
if (dotIndex === -1) return null;
const family = refreshToken.substring(0, dotIndex);
const rawToken = refreshToken.substring(dotIndex + 1);
if (!family || !rawToken) return null;
const hashedToken = this.hashToken(rawToken);
const existing = await this.refreshTokenRepo.findByToken(hashedToken);
if (!existing) {
// Possible token reuse attack — revoke entire family
await this.refreshTokenRepo.revokeByFamily(family);
return null;
}
if (existing.revokedAt || existing.expiresAt < new Date()) {
await this.refreshTokenRepo.revokeByFamily(existing.family);
return null;
}
// Revoke all tokens in this family
await this.refreshTokenRepo.revokeByFamily(existing.family);
// Create new token in a new family
const newRawToken = randomBytes(64).toString('hex');
const newHashedToken = this.hashToken(newRawToken);
const newFamily = randomBytes(16).toString('hex');
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + this.REFRESH_TOKEN_EXPIRY_DAYS);
await this.refreshTokenRepo.create({
userId: existing.userId,
token: newHashedToken,
family: newFamily,
expiresAt,
revokedAt: null,
});
return {
userId: existing.userId,
refreshToken: `${newFamily}.${newRawToken}`,
};
}
generateAccessToken(payload: JwtPayload): string {
return this.jwtService.sign(payload);
}
async revokeAllUserTokens(userId: string): Promise<void> {
await this.refreshTokenRepo.revokeAllForUser(userId);
}
verifyAccessToken(token: string): JwtPayload | null {
try {
return this.jwtService.verify<JwtPayload>(token);
} catch {
return null;
}
}
private hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
}

View File

@@ -0,0 +1,2 @@
export { JwtStrategy } from './jwt.strategy';
export { LocalStrategy } from './local.strategy';

View File

@@ -0,0 +1,19 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { type JwtPayload } from '../services/token.service';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: process.env['JWT_SECRET'] || 'goodgo-jwt-secret-change-in-production',
});
}
validate(payload: JwtPayload): JwtPayload {
return { sub: payload.sub, phone: payload.phone, role: payload.role };
}
}

View File

@@ -0,0 +1,38 @@
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-local';
import { USER_REPOSITORY, type IUserRepository } from '../../domain/repositories/user.repository';
import { normalizeVietnamPhone } from '@modules/shared/utils/vietnam-phone.validator';
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(
@Inject(USER_REPOSITORY)
private readonly userRepo: IUserRepository,
) {
super({ usernameField: 'phone', passwordField: 'password' });
}
async validate(phone: string, password: string): Promise<{ id: string; phone: string; role: string }> {
const normalizedPhone = normalizeVietnamPhone(phone);
if (!normalizedPhone) {
throw new UnauthorizedException('Số điện thoại không hợp lệ');
}
const user = await this.userRepo.findByPhone(normalizedPhone);
if (!user || !user.passwordHash) {
throw new UnauthorizedException('Số điện thoại hoặc mật khẩu không đúng');
}
if (!user.isActive) {
throw new UnauthorizedException('Tài khoản đã bị vô hiệu hóa');
}
const isValid = await user.passwordHash.compare(password);
if (!isValid) {
throw new UnauthorizedException('Số điện thoại hoặc mật khẩu không đúng');
}
return { id: user.id, phone: user.phone.value, role: user.role };
}
}

View File

@@ -0,0 +1,78 @@
import {
Body,
Controller,
Get,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import { RegisterUserCommand } from '../../application/commands/register-user/register-user.command';
import { LoginUserCommand } from '../../application/commands/login-user/login-user.command';
import { RefreshTokenCommand } from '../../application/commands/refresh-token/refresh-token.command';
import { VerifyKycCommand } from '../../application/commands/verify-kyc/verify-kyc.command';
import { GetProfileQuery } from '../../application/queries/get-profile/get-profile.query';
import { GetAgentByUserIdQuery } from '../../application/queries/get-agent-by-user-id/get-agent-by-user-id.query';
import { RegisterDto } from '../dto/register.dto';
import { RefreshTokenDto } from '../dto/refresh-token.dto';
import { VerifyKycDto } from '../dto/verify-kyc.dto';
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { LocalAuthGuard } from '../guards/local-auth.guard';
import { RolesGuard } from '../guards/roles.guard';
import { CurrentUser } from '../decorators/current-user.decorator';
import { Roles } from '../decorators/roles.decorator';
import { type JwtPayload, type TokenPair } from '../../infrastructure/services/token.service';
import { type UserProfileDto } from '../../application/queries/get-profile/get-profile.handler';
import { type AgentDto } from '../../application/queries/get-agent-by-user-id/get-agent-by-user-id.handler';
@Controller('auth')
export class AuthController {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
@Post('register')
async register(@Body() dto: RegisterDto): Promise<TokenPair> {
return this.commandBus.execute(
new RegisterUserCommand(dto.phone, dto.password, dto.fullName, dto.email),
);
}
@UseGuards(LocalAuthGuard)
@Post('login')
async login(@CurrentUser() user: { id: string; phone: string; role: string }): Promise<TokenPair> {
return this.commandBus.execute(
new LoginUserCommand(user.id, user.phone, user.role),
);
}
@Post('refresh')
async refresh(@Body() dto: RefreshTokenDto): Promise<TokenPair> {
return this.commandBus.execute(new RefreshTokenCommand(dto.refreshToken));
}
@UseGuards(JwtAuthGuard)
@Get('profile')
async getProfile(@CurrentUser() user: JwtPayload): Promise<UserProfileDto> {
return this.queryBus.execute(new GetProfileQuery(user.sub));
}
@UseGuards(JwtAuthGuard)
@Get('profile/agent')
async getAgentProfile(@CurrentUser() user: JwtPayload): Promise<AgentDto | null> {
return this.queryBus.execute(new GetAgentByUserIdQuery(user.sub));
}
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('ADMIN')
@Patch('kyc')
async verifyKyc(
@Body() dto: VerifyKycDto & { userId: string },
): Promise<{ message: string }> {
await this.commandBus.execute(
new VerifyKycCommand(dto.userId, dto.kycStatus, dto.kycData),
);
return { message: 'KYC status đã được cập nhật' };
}
}

View File

@@ -0,0 +1 @@
export { AuthController } from './auth.controller';

View File

@@ -0,0 +1,9 @@
import { createParamDecorator, type ExecutionContext } from '@nestjs/common';
import { type JwtPayload } from '../../infrastructure/services/token.service';
export const CurrentUser = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): JwtPayload => {
const request = ctx.switchToHttp().getRequest();
return request.user;
},
);

View File

@@ -0,0 +1,2 @@
export { Roles, ROLES_KEY } from './roles.decorator';
export { CurrentUser } from './current-user.decorator';

View File

@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
import { type UserRole } from '@prisma/client';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);

View File

@@ -0,0 +1,4 @@
export { RegisterDto } from './register.dto';
export { LoginDto } from './login.dto';
export { RefreshTokenDto } from './refresh-token.dto';
export { VerifyKycDto } from './verify-kyc.dto';

View File

@@ -0,0 +1,9 @@
import { IsString } from 'class-validator';
export class LoginDto {
@IsString()
phone!: string;
@IsString()
password!: string;
}

View File

@@ -0,0 +1,6 @@
import { IsString } from 'class-validator';
export class RefreshTokenDto {
@IsString()
refreshToken!: string;
}

View File

@@ -0,0 +1,18 @@
import { IsString, IsOptional, IsEmail, MinLength } from 'class-validator';
export class RegisterDto {
@IsString()
phone!: string;
@IsString()
@MinLength(8)
password!: string;
@IsString()
@MinLength(1)
fullName!: string;
@IsOptional()
@IsEmail()
email?: string;
}

View File

@@ -0,0 +1,11 @@
import { IsEnum, IsOptional, IsObject } from 'class-validator';
import { KYCStatus } from '@prisma/client';
export class VerifyKycDto {
@IsEnum(KYCStatus)
kycStatus!: KYCStatus;
@IsOptional()
@IsObject()
kycData?: Record<string, unknown>;
}

View File

@@ -0,0 +1,3 @@
export { JwtAuthGuard } from './jwt-auth.guard';
export { LocalAuthGuard } from './local-auth.guard';
export { RolesGuard } from './roles.guard';

View File

@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

View File

@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}

View File

@@ -0,0 +1,23 @@
import { Injectable, type CanActivate, type ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { type UserRole } from '@prisma/client';
import { ROLES_KEY } from '../decorators/roles.decorator';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles || requiredRoles.length === 0) {
return true;
}
const { user } = context.switchToHttp().getRequest();
return requiredRoles.includes(user?.role);
}
}

View File

@@ -0,0 +1,4 @@
export * from './controllers';
export * from './guards';
export * from './decorators';
export * from './dto';

View File

@@ -6,6 +6,7 @@ export default defineConfig({
globals: true,
environment: 'node',
include: ['src/**/*.spec.ts'],
exclude: ['src/**/*.integration.spec.ts', 'node_modules'],
},
resolve: {
alias: {

View File

@@ -0,0 +1,16 @@
import path from 'path';
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.integration.spec.ts'],
testTimeout: 30_000,
},
resolve: {
alias: {
'@modules': path.resolve(__dirname, 'src/modules'),
},
},
});

647
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -46,18 +46,57 @@ model User {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
agent Agent?
listings Listing[]
savedSearches SavedSearch[]
subscription Subscription?
payments Payment[]
reviews Review[]
inquiriesSent Inquiry[]
agent Agent?
listings Listing[]
savedSearches SavedSearch[]
subscription Subscription?
payments Payment[]
reviews Review[]
inquiriesSent Inquiry[]
refreshTokens RefreshToken[]
oauthAccounts OAuthAccount[]
@@index([phone])
@@index([role])
}
enum OAuthProvider {
GOOGLE
ZALO
}
model RefreshToken {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
token String @unique
family String
expiresAt DateTime
revokedAt DateTime?
createdAt DateTime @default(now())
@@index([userId])
@@index([family])
@@index([expiresAt])
}
model OAuthAccount {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
provider OAuthProvider
providerUserId String
accessToken String?
refreshToken String?
expiresAt DateTime?
profile Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([provider, providerUserId])
@@index([userId])
}
model Agent {
id String @id @default(cuid())
userId String @unique