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:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
197
apps/api/src/modules/auth/__tests__/auth.integration.spec.ts
Normal file
197
apps/api/src/modules/auth/__tests__/auth.integration.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
export class LoginUserCommand {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly phone: string,
|
||||
public readonly role: string,
|
||||
) {}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export class RefreshTokenCommand {
|
||||
constructor(public readonly refreshToken: string) {}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export class RegisterUserCommand {
|
||||
constructor(
|
||||
public readonly phone: string,
|
||||
public readonly password: string,
|
||||
public readonly fullName: string,
|
||||
public readonly email?: string,
|
||||
) {}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
) {}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
12
apps/api/src/modules/auth/application/index.ts
Normal file
12
apps/api/src/modules/auth/application/index.ts
Normal 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';
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export class GetAgentByUserIdQuery {
|
||||
constructor(public readonly userId: string) {}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export class GetProfileQuery {
|
||||
constructor(public readonly userId: string) {}
|
||||
}
|
||||
65
apps/api/src/modules/auth/auth.module.ts
Normal file
65
apps/api/src/modules/auth/auth.module.ts
Normal 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 {}
|
||||
28
apps/api/src/modules/auth/domain/__tests__/email.vo.spec.ts
Normal file
28
apps/api/src/modules/auth/domain/__tests__/email.vo.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
28
apps/api/src/modules/auth/domain/__tests__/phone.vo.spec.ts
Normal file
28
apps/api/src/modules/auth/domain/__tests__/phone.vo.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
1
apps/api/src/modules/auth/domain/entities/index.ts
Normal file
1
apps/api/src/modules/auth/domain/entities/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { UserEntity, type UserProps } from './user.entity';
|
||||
88
apps/api/src/modules/auth/domain/entities/user.entity.ts
Normal file
88
apps/api/src/modules/auth/domain/entities/user.entity.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
2
apps/api/src/modules/auth/domain/events/index.ts
Normal file
2
apps/api/src/modules/auth/domain/events/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { UserRegisteredEvent } from './user-registered.event';
|
||||
export { AgentVerifiedEvent } from './agent-verified.event';
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
4
apps/api/src/modules/auth/domain/index.ts
Normal file
4
apps/api/src/modules/auth/domain/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './entities';
|
||||
export * from './value-objects';
|
||||
export * from './events';
|
||||
export * from './repositories';
|
||||
6
apps/api/src/modules/auth/domain/repositories/index.ts
Normal file
6
apps/api/src/modules/auth/domain/repositories/index.ts
Normal 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';
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
22
apps/api/src/modules/auth/domain/value-objects/email.vo.ts
Normal file
22
apps/api/src/modules/auth/domain/value-objects/email.vo.ts
Normal 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 }));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
3
apps/api/src/modules/auth/domain/value-objects/index.ts
Normal file
3
apps/api/src/modules/auth/domain/value-objects/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { Email } from './email.vo';
|
||||
export { Phone } from './phone.vo';
|
||||
export { HashedPassword } from './hashed-password.vo';
|
||||
24
apps/api/src/modules/auth/domain/value-objects/phone.vo.ts
Normal file
24
apps/api/src/modules/auth/domain/value-objects/phone.vo.ts
Normal 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 }));
|
||||
}
|
||||
}
|
||||
6
apps/api/src/modules/auth/index.ts
Normal file
6
apps/api/src/modules/auth/index.ts
Normal 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';
|
||||
3
apps/api/src/modules/auth/infrastructure/index.ts
Normal file
3
apps/api/src/modules/auth/infrastructure/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './repositories';
|
||||
export * from './strategies';
|
||||
export * from './services';
|
||||
@@ -0,0 +1,2 @@
|
||||
export { PrismaUserRepository } from './prisma-user.repository';
|
||||
export { PrismaRefreshTokenRepository } from './prisma-refresh-token.repository';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
TokenService,
|
||||
type JwtPayload,
|
||||
type TokenPair,
|
||||
type RotateResult,
|
||||
} from './token.service';
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { JwtStrategy } from './jwt.strategy';
|
||||
export { LocalStrategy } from './local.strategy';
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { AuthController } from './auth.controller';
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Roles, ROLES_KEY } from './roles.decorator';
|
||||
export { CurrentUser } from './current-user.decorator';
|
||||
@@ -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);
|
||||
4
apps/api/src/modules/auth/presentation/dto/index.ts
Normal file
4
apps/api/src/modules/auth/presentation/dto/index.ts
Normal 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';
|
||||
9
apps/api/src/modules/auth/presentation/dto/login.dto.ts
Normal file
9
apps/api/src/modules/auth/presentation/dto/login.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@IsString()
|
||||
phone!: string;
|
||||
|
||||
@IsString()
|
||||
password!: string;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class RefreshTokenDto {
|
||||
@IsString()
|
||||
refreshToken!: string;
|
||||
}
|
||||
18
apps/api/src/modules/auth/presentation/dto/register.dto.ts
Normal file
18
apps/api/src/modules/auth/presentation/dto/register.dto.ts
Normal 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;
|
||||
}
|
||||
11
apps/api/src/modules/auth/presentation/dto/verify-kyc.dto.ts
Normal file
11
apps/api/src/modules/auth/presentation/dto/verify-kyc.dto.ts
Normal 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>;
|
||||
}
|
||||
3
apps/api/src/modules/auth/presentation/guards/index.ts
Normal file
3
apps/api/src/modules/auth/presentation/guards/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { JwtAuthGuard } from './jwt-auth.guard';
|
||||
export { LocalAuthGuard } from './local-auth.guard';
|
||||
export { RolesGuard } from './roles.guard';
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class LocalAuthGuard extends AuthGuard('local') {}
|
||||
23
apps/api/src/modules/auth/presentation/guards/roles.guard.ts
Normal file
23
apps/api/src/modules/auth/presentation/guards/roles.guard.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
4
apps/api/src/modules/auth/presentation/index.ts
Normal file
4
apps/api/src/modules/auth/presentation/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './controllers';
|
||||
export * from './guards';
|
||||
export * from './decorators';
|
||||
export * from './dto';
|
||||
@@ -6,6 +6,7 @@ export default defineConfig({
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['src/**/*.spec.ts'],
|
||||
exclude: ['src/**/*.integration.spec.ts', 'node_modules'],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
16
apps/api/vitest.integration.config.ts
Normal file
16
apps/api/vitest.integration.config.ts
Normal 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
647
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user