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

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

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

View File

@@ -0,0 +1,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) {}
}