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,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 }));
}
}