diff --git a/apps/api/package.json b/apps/api/package.json index 3ed121d..d22c361 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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" } } diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 05125bd..a44bff0 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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 {} diff --git a/apps/api/src/modules/auth/__tests__/auth.integration.spec.ts b/apps/api/src/modules/auth/__tests__/auth.integration.spec.ts new file mode 100644 index 0000000..e34d561 --- /dev/null +++ b/apps/api/src/modules/auth/__tests__/auth.integration.spec.ts @@ -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); + }); + }); +}); diff --git a/apps/api/src/modules/auth/application/commands/login-user/login-user.command.ts b/apps/api/src/modules/auth/application/commands/login-user/login-user.command.ts new file mode 100644 index 0000000..c04da4b --- /dev/null +++ b/apps/api/src/modules/auth/application/commands/login-user/login-user.command.ts @@ -0,0 +1,7 @@ +export class LoginUserCommand { + constructor( + public readonly userId: string, + public readonly phone: string, + public readonly role: string, + ) {} +} diff --git a/apps/api/src/modules/auth/application/commands/login-user/login-user.handler.ts b/apps/api/src/modules/auth/application/commands/login-user/login-user.handler.ts new file mode 100644 index 0000000..d3eacbd --- /dev/null +++ b/apps/api/src/modules/auth/application/commands/login-user/login-user.handler.ts @@ -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 { + constructor(private readonly tokenService: TokenService) {} + + async execute(command: LoginUserCommand): Promise { + return this.tokenService.generateTokenPair({ + sub: command.userId, + phone: command.phone, + role: command.role, + }); + } +} diff --git a/apps/api/src/modules/auth/application/commands/refresh-token/refresh-token.command.ts b/apps/api/src/modules/auth/application/commands/refresh-token/refresh-token.command.ts new file mode 100644 index 0000000..fe2bc55 --- /dev/null +++ b/apps/api/src/modules/auth/application/commands/refresh-token/refresh-token.command.ts @@ -0,0 +1,3 @@ +export class RefreshTokenCommand { + constructor(public readonly refreshToken: string) {} +} diff --git a/apps/api/src/modules/auth/application/commands/refresh-token/refresh-token.handler.ts b/apps/api/src/modules/auth/application/commands/refresh-token/refresh-token.handler.ts new file mode 100644 index 0000000..efc8370 --- /dev/null +++ b/apps/api/src/modules/auth/application/commands/refresh-token/refresh-token.handler.ts @@ -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 { + constructor( + private readonly tokenService: TokenService, + @Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository, + ) {} + + async execute(command: RefreshTokenCommand): Promise { + 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, + }; + } +} diff --git a/apps/api/src/modules/auth/application/commands/register-user/register-user.command.ts b/apps/api/src/modules/auth/application/commands/register-user/register-user.command.ts new file mode 100644 index 0000000..aa1e98a --- /dev/null +++ b/apps/api/src/modules/auth/application/commands/register-user/register-user.command.ts @@ -0,0 +1,8 @@ +export class RegisterUserCommand { + constructor( + public readonly phone: string, + public readonly password: string, + public readonly fullName: string, + public readonly email?: string, + ) {} +} diff --git a/apps/api/src/modules/auth/application/commands/register-user/register-user.handler.ts b/apps/api/src/modules/auth/application/commands/register-user/register-user.handler.ts new file mode 100644 index 0000000..17845f7 --- /dev/null +++ b/apps/api/src/modules/auth/application/commands/register-user/register-user.handler.ts @@ -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 { + constructor( + @Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository, + private readonly tokenService: TokenService, + private readonly eventBus: EventBus, + ) {} + + async execute(command: RegisterUserCommand): Promise { + // 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, + }); + } +} diff --git a/apps/api/src/modules/auth/application/commands/verify-kyc/verify-kyc.command.ts b/apps/api/src/modules/auth/application/commands/verify-kyc/verify-kyc.command.ts new file mode 100644 index 0000000..230d6f4 --- /dev/null +++ b/apps/api/src/modules/auth/application/commands/verify-kyc/verify-kyc.command.ts @@ -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, + ) {} +} diff --git a/apps/api/src/modules/auth/application/commands/verify-kyc/verify-kyc.handler.ts b/apps/api/src/modules/auth/application/commands/verify-kyc/verify-kyc.handler.ts new file mode 100644 index 0000000..976f647 --- /dev/null +++ b/apps/api/src/modules/auth/application/commands/verify-kyc/verify-kyc.handler.ts @@ -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 { + constructor( + @Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository, + ) {} + + async execute(command: VerifyKycCommand): Promise { + 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); + } +} diff --git a/apps/api/src/modules/auth/application/index.ts b/apps/api/src/modules/auth/application/index.ts new file mode 100644 index 0000000..05c9c5c --- /dev/null +++ b/apps/api/src/modules/auth/application/index.ts @@ -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'; diff --git a/apps/api/src/modules/auth/application/queries/get-agent-by-user-id/get-agent-by-user-id.handler.ts b/apps/api/src/modules/auth/application/queries/get-agent-by-user-id/get-agent-by-user-id.handler.ts new file mode 100644 index 0000000..0a621f5 --- /dev/null +++ b/apps/api/src/modules/auth/application/queries/get-agent-by-user-id/get-agent-by-user-id.handler.ts @@ -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 { + constructor(private readonly prisma: PrismaService) {} + + async execute(query: GetAgentByUserIdQuery): Promise { + 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, + }; + } +} diff --git a/apps/api/src/modules/auth/application/queries/get-agent-by-user-id/get-agent-by-user-id.query.ts b/apps/api/src/modules/auth/application/queries/get-agent-by-user-id/get-agent-by-user-id.query.ts new file mode 100644 index 0000000..416280e --- /dev/null +++ b/apps/api/src/modules/auth/application/queries/get-agent-by-user-id/get-agent-by-user-id.query.ts @@ -0,0 +1,3 @@ +export class GetAgentByUserIdQuery { + constructor(public readonly userId: string) {} +} diff --git a/apps/api/src/modules/auth/application/queries/get-profile/get-profile.handler.ts b/apps/api/src/modules/auth/application/queries/get-profile/get-profile.handler.ts new file mode 100644 index 0000000..e486600 --- /dev/null +++ b/apps/api/src/modules/auth/application/queries/get-profile/get-profile.handler.ts @@ -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 { + constructor( + @Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository, + ) {} + + async execute(query: GetProfileQuery): Promise { + 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, + }; + } +} diff --git a/apps/api/src/modules/auth/application/queries/get-profile/get-profile.query.ts b/apps/api/src/modules/auth/application/queries/get-profile/get-profile.query.ts new file mode 100644 index 0000000..a42933c --- /dev/null +++ b/apps/api/src/modules/auth/application/queries/get-profile/get-profile.query.ts @@ -0,0 +1,3 @@ +export class GetProfileQuery { + constructor(public readonly userId: string) {} +} diff --git a/apps/api/src/modules/auth/auth.module.ts b/apps/api/src/modules/auth/auth.module.ts new file mode 100644 index 0000000..ed45c0e --- /dev/null +++ b/apps/api/src/modules/auth/auth.module.ts @@ -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 {} diff --git a/apps/api/src/modules/auth/domain/__tests__/email.vo.spec.ts b/apps/api/src/modules/auth/domain/__tests__/email.vo.spec.ts new file mode 100644 index 0000000..d7b9664 --- /dev/null +++ b/apps/api/src/modules/auth/domain/__tests__/email.vo.spec.ts @@ -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); + }); +}); diff --git a/apps/api/src/modules/auth/domain/__tests__/hashed-password.vo.spec.ts b/apps/api/src/modules/auth/domain/__tests__/hashed-password.vo.spec.ts new file mode 100644 index 0000000..a4303ff --- /dev/null +++ b/apps/api/src/modules/auth/domain/__tests__/hashed-password.vo.spec.ts @@ -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); + }); +}); diff --git a/apps/api/src/modules/auth/domain/__tests__/phone.vo.spec.ts b/apps/api/src/modules/auth/domain/__tests__/phone.vo.spec.ts new file mode 100644 index 0000000..76f9710 --- /dev/null +++ b/apps/api/src/modules/auth/domain/__tests__/phone.vo.spec.ts @@ -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); + }); +}); diff --git a/apps/api/src/modules/auth/domain/__tests__/user.entity.spec.ts b/apps/api/src/modules/auth/domain/__tests__/user.entity.spec.ts new file mode 100644 index 0000000..7dd80f6 --- /dev/null +++ b/apps/api/src/modules/auth/domain/__tests__/user.entity.spec.ts @@ -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); + }); +}); diff --git a/apps/api/src/modules/auth/domain/entities/index.ts b/apps/api/src/modules/auth/domain/entities/index.ts new file mode 100644 index 0000000..012ec05 --- /dev/null +++ b/apps/api/src/modules/auth/domain/entities/index.ts @@ -0,0 +1 @@ +export { UserEntity, type UserProps } from './user.entity'; diff --git a/apps/api/src/modules/auth/domain/entities/user.entity.ts b/apps/api/src/modules/auth/domain/entities/user.entity.ts new file mode 100644 index 0000000..01d693e --- /dev/null +++ b/apps/api/src/modules/auth/domain/entities/user.entity.ts @@ -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 { + 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(); + } +} diff --git a/apps/api/src/modules/auth/domain/events/agent-verified.event.ts b/apps/api/src/modules/auth/domain/events/agent-verified.event.ts new file mode 100644 index 0000000..f27e00d --- /dev/null +++ b/apps/api/src/modules/auth/domain/events/agent-verified.event.ts @@ -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, + ) {} +} diff --git a/apps/api/src/modules/auth/domain/events/index.ts b/apps/api/src/modules/auth/domain/events/index.ts new file mode 100644 index 0000000..485a6eb --- /dev/null +++ b/apps/api/src/modules/auth/domain/events/index.ts @@ -0,0 +1,2 @@ +export { UserRegisteredEvent } from './user-registered.event'; +export { AgentVerifiedEvent } from './agent-verified.event'; diff --git a/apps/api/src/modules/auth/domain/events/user-registered.event.ts b/apps/api/src/modules/auth/domain/events/user-registered.event.ts new file mode 100644 index 0000000..5dc8f2d --- /dev/null +++ b/apps/api/src/modules/auth/domain/events/user-registered.event.ts @@ -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, + ) {} +} diff --git a/apps/api/src/modules/auth/domain/index.ts b/apps/api/src/modules/auth/domain/index.ts new file mode 100644 index 0000000..8726a22 --- /dev/null +++ b/apps/api/src/modules/auth/domain/index.ts @@ -0,0 +1,4 @@ +export * from './entities'; +export * from './value-objects'; +export * from './events'; +export * from './repositories'; diff --git a/apps/api/src/modules/auth/domain/repositories/index.ts b/apps/api/src/modules/auth/domain/repositories/index.ts new file mode 100644 index 0000000..5299f2e --- /dev/null +++ b/apps/api/src/modules/auth/domain/repositories/index.ts @@ -0,0 +1,6 @@ +export { USER_REPOSITORY, type IUserRepository } from './user.repository'; +export { + REFRESH_TOKEN_REPOSITORY, + type IRefreshTokenRepository, + type RefreshTokenRecord, +} from './refresh-token.repository'; diff --git a/apps/api/src/modules/auth/domain/repositories/refresh-token.repository.ts b/apps/api/src/modules/auth/domain/repositories/refresh-token.repository.ts new file mode 100644 index 0000000..6d5fd76 --- /dev/null +++ b/apps/api/src/modules/auth/domain/repositories/refresh-token.repository.ts @@ -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): Promise; + findByToken(token: string): Promise; + revokeByFamily(family: string): Promise; + revokeAllForUser(userId: string): Promise; + deleteExpired(): Promise; +} diff --git a/apps/api/src/modules/auth/domain/repositories/user.repository.ts b/apps/api/src/modules/auth/domain/repositories/user.repository.ts new file mode 100644 index 0000000..8be4f77 --- /dev/null +++ b/apps/api/src/modules/auth/domain/repositories/user.repository.ts @@ -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; + findByPhone(phone: string): Promise; + findByEmail(email: string): Promise; + save(user: UserEntity): Promise; + update(user: UserEntity): Promise; +} diff --git a/apps/api/src/modules/auth/domain/value-objects/email.vo.ts b/apps/api/src/modules/auth/domain/value-objects/email.vo.ts new file mode 100644 index 0000000..cb7224c --- /dev/null +++ b/apps/api/src/modules/auth/domain/value-objects/email.vo.ts @@ -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 { + private static readonly EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + + get value(): string { + return this.props.value; + } + + static create(email: string): Result { + 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 })); + } +} diff --git a/apps/api/src/modules/auth/domain/value-objects/hashed-password.vo.ts b/apps/api/src/modules/auth/domain/value-objects/hashed-password.vo.ts new file mode 100644 index 0000000..05172c6 --- /dev/null +++ b/apps/api/src/modules/auth/domain/value-objects/hashed-password.vo.ts @@ -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 { + 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> { + 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 { + return bcrypt.compare(plainPassword, this.props.value); + } +} diff --git a/apps/api/src/modules/auth/domain/value-objects/index.ts b/apps/api/src/modules/auth/domain/value-objects/index.ts new file mode 100644 index 0000000..dcb925d --- /dev/null +++ b/apps/api/src/modules/auth/domain/value-objects/index.ts @@ -0,0 +1,3 @@ +export { Email } from './email.vo'; +export { Phone } from './phone.vo'; +export { HashedPassword } from './hashed-password.vo'; diff --git a/apps/api/src/modules/auth/domain/value-objects/phone.vo.ts b/apps/api/src/modules/auth/domain/value-objects/phone.vo.ts new file mode 100644 index 0000000..cae9c73 --- /dev/null +++ b/apps/api/src/modules/auth/domain/value-objects/phone.vo.ts @@ -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 { + get value(): string { + return this.props.value; + } + + static create(phone: string): Result { + 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 })); + } +} diff --git a/apps/api/src/modules/auth/index.ts b/apps/api/src/modules/auth/index.ts new file mode 100644 index 0000000..d9b65ec --- /dev/null +++ b/apps/api/src/modules/auth/index.ts @@ -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'; diff --git a/apps/api/src/modules/auth/infrastructure/index.ts b/apps/api/src/modules/auth/infrastructure/index.ts new file mode 100644 index 0000000..350e047 --- /dev/null +++ b/apps/api/src/modules/auth/infrastructure/index.ts @@ -0,0 +1,3 @@ +export * from './repositories'; +export * from './strategies'; +export * from './services'; diff --git a/apps/api/src/modules/auth/infrastructure/repositories/index.ts b/apps/api/src/modules/auth/infrastructure/repositories/index.ts new file mode 100644 index 0000000..723d317 --- /dev/null +++ b/apps/api/src/modules/auth/infrastructure/repositories/index.ts @@ -0,0 +1,2 @@ +export { PrismaUserRepository } from './prisma-user.repository'; +export { PrismaRefreshTokenRepository } from './prisma-refresh-token.repository'; diff --git a/apps/api/src/modules/auth/infrastructure/repositories/prisma-refresh-token.repository.ts b/apps/api/src/modules/auth/infrastructure/repositories/prisma-refresh-token.repository.ts new file mode 100644 index 0000000..014358e --- /dev/null +++ b/apps/api/src/modules/auth/infrastructure/repositories/prisma-refresh-token.repository.ts @@ -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, + ): Promise { + 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 { + return this.prisma.refreshToken.findUnique({ where: { token } }); + } + + async revokeByFamily(family: string): Promise { + await this.prisma.refreshToken.updateMany({ + where: { family, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + } + + async revokeAllForUser(userId: string): Promise { + await this.prisma.refreshToken.updateMany({ + where: { userId, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + } + + async deleteExpired(): Promise { + const result = await this.prisma.refreshToken.deleteMany({ + where: { expiresAt: { lt: new Date() } }, + }); + return result.count; + } +} diff --git a/apps/api/src/modules/auth/infrastructure/repositories/prisma-user.repository.ts b/apps/api/src/modules/auth/infrastructure/repositories/prisma-user.repository.ts new file mode 100644 index 0000000..bf4d567 --- /dev/null +++ b/apps/api/src/modules/auth/infrastructure/repositories/prisma-user.repository.ts @@ -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 { + const user = await this.prisma.user.findUnique({ where: { id } }); + return user ? this.toDomain(user) : null; + } + + async findByPhone(phone: string): Promise { + const user = await this.prisma.user.findUnique({ where: { phone } }); + return user ? this.toDomain(user) : null; + } + + async findByEmail(email: string): Promise { + const user = await this.prisma.user.findUnique({ where: { email } }); + return user ? this.toDomain(user) : null; + } + + async save(entity: UserEntity): Promise { + 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 { + 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); + } +} diff --git a/apps/api/src/modules/auth/infrastructure/services/index.ts b/apps/api/src/modules/auth/infrastructure/services/index.ts new file mode 100644 index 0000000..adc5a8a --- /dev/null +++ b/apps/api/src/modules/auth/infrastructure/services/index.ts @@ -0,0 +1,6 @@ +export { + TokenService, + type JwtPayload, + type TokenPair, + type RotateResult, +} from './token.service'; diff --git a/apps/api/src/modules/auth/infrastructure/services/token.service.ts b/apps/api/src/modules/auth/infrastructure/services/token.service.ts new file mode 100644 index 0000000..7bcf343 --- /dev/null +++ b/apps/api/src/modules/auth/infrastructure/services/token.service.ts @@ -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 { + 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 { + 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 { + await this.refreshTokenRepo.revokeAllForUser(userId); + } + + verifyAccessToken(token: string): JwtPayload | null { + try { + return this.jwtService.verify(token); + } catch { + return null; + } + } + + private hashToken(token: string): string { + return createHash('sha256').update(token).digest('hex'); + } +} diff --git a/apps/api/src/modules/auth/infrastructure/strategies/index.ts b/apps/api/src/modules/auth/infrastructure/strategies/index.ts new file mode 100644 index 0000000..e8ab195 --- /dev/null +++ b/apps/api/src/modules/auth/infrastructure/strategies/index.ts @@ -0,0 +1,2 @@ +export { JwtStrategy } from './jwt.strategy'; +export { LocalStrategy } from './local.strategy'; diff --git a/apps/api/src/modules/auth/infrastructure/strategies/jwt.strategy.ts b/apps/api/src/modules/auth/infrastructure/strategies/jwt.strategy.ts new file mode 100644 index 0000000..ac3a810 --- /dev/null +++ b/apps/api/src/modules/auth/infrastructure/strategies/jwt.strategy.ts @@ -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 }; + } +} diff --git a/apps/api/src/modules/auth/infrastructure/strategies/local.strategy.ts b/apps/api/src/modules/auth/infrastructure/strategies/local.strategy.ts new file mode 100644 index 0000000..8beaff0 --- /dev/null +++ b/apps/api/src/modules/auth/infrastructure/strategies/local.strategy.ts @@ -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 }; + } +} diff --git a/apps/api/src/modules/auth/presentation/controllers/auth.controller.ts b/apps/api/src/modules/auth/presentation/controllers/auth.controller.ts new file mode 100644 index 0000000..fedfb79 --- /dev/null +++ b/apps/api/src/modules/auth/presentation/controllers/auth.controller.ts @@ -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 { + 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 { + return this.commandBus.execute( + new LoginUserCommand(user.id, user.phone, user.role), + ); + } + + @Post('refresh') + async refresh(@Body() dto: RefreshTokenDto): Promise { + return this.commandBus.execute(new RefreshTokenCommand(dto.refreshToken)); + } + + @UseGuards(JwtAuthGuard) + @Get('profile') + async getProfile(@CurrentUser() user: JwtPayload): Promise { + return this.queryBus.execute(new GetProfileQuery(user.sub)); + } + + @UseGuards(JwtAuthGuard) + @Get('profile/agent') + async getAgentProfile(@CurrentUser() user: JwtPayload): Promise { + 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' }; + } +} diff --git a/apps/api/src/modules/auth/presentation/controllers/index.ts b/apps/api/src/modules/auth/presentation/controllers/index.ts new file mode 100644 index 0000000..74c6815 --- /dev/null +++ b/apps/api/src/modules/auth/presentation/controllers/index.ts @@ -0,0 +1 @@ +export { AuthController } from './auth.controller'; diff --git a/apps/api/src/modules/auth/presentation/decorators/current-user.decorator.ts b/apps/api/src/modules/auth/presentation/decorators/current-user.decorator.ts new file mode 100644 index 0000000..a51fcd1 --- /dev/null +++ b/apps/api/src/modules/auth/presentation/decorators/current-user.decorator.ts @@ -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; + }, +); diff --git a/apps/api/src/modules/auth/presentation/decorators/index.ts b/apps/api/src/modules/auth/presentation/decorators/index.ts new file mode 100644 index 0000000..48d685f --- /dev/null +++ b/apps/api/src/modules/auth/presentation/decorators/index.ts @@ -0,0 +1,2 @@ +export { Roles, ROLES_KEY } from './roles.decorator'; +export { CurrentUser } from './current-user.decorator'; diff --git a/apps/api/src/modules/auth/presentation/decorators/roles.decorator.ts b/apps/api/src/modules/auth/presentation/decorators/roles.decorator.ts new file mode 100644 index 0000000..272c7c4 --- /dev/null +++ b/apps/api/src/modules/auth/presentation/decorators/roles.decorator.ts @@ -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); diff --git a/apps/api/src/modules/auth/presentation/dto/index.ts b/apps/api/src/modules/auth/presentation/dto/index.ts new file mode 100644 index 0000000..f7ff491 --- /dev/null +++ b/apps/api/src/modules/auth/presentation/dto/index.ts @@ -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'; diff --git a/apps/api/src/modules/auth/presentation/dto/login.dto.ts b/apps/api/src/modules/auth/presentation/dto/login.dto.ts new file mode 100644 index 0000000..b093c14 --- /dev/null +++ b/apps/api/src/modules/auth/presentation/dto/login.dto.ts @@ -0,0 +1,9 @@ +import { IsString } from 'class-validator'; + +export class LoginDto { + @IsString() + phone!: string; + + @IsString() + password!: string; +} diff --git a/apps/api/src/modules/auth/presentation/dto/refresh-token.dto.ts b/apps/api/src/modules/auth/presentation/dto/refresh-token.dto.ts new file mode 100644 index 0000000..752ff4f --- /dev/null +++ b/apps/api/src/modules/auth/presentation/dto/refresh-token.dto.ts @@ -0,0 +1,6 @@ +import { IsString } from 'class-validator'; + +export class RefreshTokenDto { + @IsString() + refreshToken!: string; +} diff --git a/apps/api/src/modules/auth/presentation/dto/register.dto.ts b/apps/api/src/modules/auth/presentation/dto/register.dto.ts new file mode 100644 index 0000000..cd46515 --- /dev/null +++ b/apps/api/src/modules/auth/presentation/dto/register.dto.ts @@ -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; +} diff --git a/apps/api/src/modules/auth/presentation/dto/verify-kyc.dto.ts b/apps/api/src/modules/auth/presentation/dto/verify-kyc.dto.ts new file mode 100644 index 0000000..b76b08d --- /dev/null +++ b/apps/api/src/modules/auth/presentation/dto/verify-kyc.dto.ts @@ -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; +} diff --git a/apps/api/src/modules/auth/presentation/guards/index.ts b/apps/api/src/modules/auth/presentation/guards/index.ts new file mode 100644 index 0000000..8be26b6 --- /dev/null +++ b/apps/api/src/modules/auth/presentation/guards/index.ts @@ -0,0 +1,3 @@ +export { JwtAuthGuard } from './jwt-auth.guard'; +export { LocalAuthGuard } from './local-auth.guard'; +export { RolesGuard } from './roles.guard'; diff --git a/apps/api/src/modules/auth/presentation/guards/jwt-auth.guard.ts b/apps/api/src/modules/auth/presentation/guards/jwt-auth.guard.ts new file mode 100644 index 0000000..2155290 --- /dev/null +++ b/apps/api/src/modules/auth/presentation/guards/jwt-auth.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') {} diff --git a/apps/api/src/modules/auth/presentation/guards/local-auth.guard.ts b/apps/api/src/modules/auth/presentation/guards/local-auth.guard.ts new file mode 100644 index 0000000..ccf962b --- /dev/null +++ b/apps/api/src/modules/auth/presentation/guards/local-auth.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class LocalAuthGuard extends AuthGuard('local') {} diff --git a/apps/api/src/modules/auth/presentation/guards/roles.guard.ts b/apps/api/src/modules/auth/presentation/guards/roles.guard.ts new file mode 100644 index 0000000..dcd36ba --- /dev/null +++ b/apps/api/src/modules/auth/presentation/guards/roles.guard.ts @@ -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(ROLES_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (!requiredRoles || requiredRoles.length === 0) { + return true; + } + + const { user } = context.switchToHttp().getRequest(); + return requiredRoles.includes(user?.role); + } +} diff --git a/apps/api/src/modules/auth/presentation/index.ts b/apps/api/src/modules/auth/presentation/index.ts new file mode 100644 index 0000000..3ccaf8f --- /dev/null +++ b/apps/api/src/modules/auth/presentation/index.ts @@ -0,0 +1,4 @@ +export * from './controllers'; +export * from './guards'; +export * from './decorators'; +export * from './dto'; diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts index 1620d5e..c5ddd16 100644 --- a/apps/api/vitest.config.ts +++ b/apps/api/vitest.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ globals: true, environment: 'node', include: ['src/**/*.spec.ts'], + exclude: ['src/**/*.integration.spec.ts', 'node_modules'], }, resolve: { alias: { diff --git a/apps/api/vitest.integration.config.ts b/apps/api/vitest.integration.config.ts new file mode 100644 index 0000000..a7a8987 --- /dev/null +++ b/apps/api/vitest.integration.config.ts @@ -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'), + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a7e004e..f98cbeb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,25 +59,58 @@ importers: dependencies: '@nestjs/common': specifier: ^11.0.0 - version: 11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': specifier: ^11.0.0 - version: 11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.18)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.18)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/cqrs': specifier: ^11.0.0 - version: 11.0.3(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.0.3(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/event-emitter': specifier: ^3.0.0 - version: 3.0.1(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18) + version: 3.0.1(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18) + '@nestjs/jwt': + specifier: ^11.0.2 + version: 11.0.2(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)) + '@nestjs/passport': + specifier: ^11.0.5 + version: 11.0.5(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) '@nestjs/platform-express': specifier: ^11.0.0 - version: 11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18) + version: 11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18) + '@nestjs/throttler': + specifier: ^6.5.0 + version: 6.5.0(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)(reflect-metadata@0.2.2) + '@paralleldrive/cuid2': + specifier: ^3.3.0 + version: 3.3.0 '@prisma/client': specifier: ^6.0.0 version: 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3) + bcrypt: + specifier: ^6.0.0 + version: 6.0.0 + class-transformer: + specifier: ^0.5.1 + version: 0.5.1 + class-validator: + specifier: ^0.15.1 + version: 0.15.1 + helmet: + specifier: ^8.1.0 + version: 8.1.0 ioredis: specifier: ^5.4.0 version: 5.10.1 + passport: + specifier: ^0.7.0 + version: 0.7.0 + passport-jwt: + specifier: ^4.0.1 + version: 4.0.1 + passport-local: + specifier: ^1.0.0 + version: 1.0.0 pino: specifier: ^9.0.0 version: 9.14.0 @@ -90,6 +123,9 @@ importers: rxjs: specifier: ^7.8.0 version: 7.8.2 + sanitize-html: + specifier: ^2.17.2 + version: 2.17.2 devDependencies: '@nestjs/cli': specifier: ^11.0.0 @@ -99,16 +135,34 @@ importers: version: 11.0.10(chokidar@4.0.3)(typescript@5.9.3) '@nestjs/testing': specifier: ^11.0.0 - version: 11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)(@nestjs/platform-express@11.1.18) + version: 11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)(@nestjs/platform-express@11.1.18) + '@types/bcrypt': + specifier: ^6.0.0 + version: 6.0.0 '@types/express': specifier: ^5.0.0 version: 5.0.6 '@types/node': specifier: ^22.0.0 version: 22.19.17 + '@types/passport-jwt': + specifier: ^4.0.1 + version: 4.0.1 + '@types/passport-local': + specifier: ^1.0.38 + version: 1.0.38 + '@types/sanitize-html': + specifier: ^2.16.1 + version: 2.16.1 + '@types/supertest': + specifier: ^7.2.0 + version: 7.2.0 prisma: specifier: ^6.0.0 version: 6.19.3(typescript@5.9.3) + supertest: + specifier: ^7.2.2 + version: 7.2.2 typescript: specifier: ^5.7.0 version: 5.9.3 @@ -635,6 +689,17 @@ packages: '@nestjs/common': ^10.0.0 || ^11.0.0 '@nestjs/core': ^10.0.0 || ^11.0.0 + '@nestjs/jwt@11.0.2': + resolution: {integrity: sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + + '@nestjs/passport@11.0.5': + resolution: {integrity: sha512-ulQX6mbjlws92PIM15Naes4F4p2JoxGnIJuUsdXQPT+Oo2sqQmENEZXM7eYuimocfHnKlcfZOuyzbA33LwUlOQ==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + passport: ^0.5.0 || ^0.6.0 || ^0.7.0 + '@nestjs/platform-express@11.1.18': resolution: {integrity: sha512-s6GdHMTa3qx0fJewR74Xa30ysPHfBEqxIwZ7BGSTLoAEQ1vTP24urNl+b6+s49NFLEIOyeNho5fN/9/I17QlOw==} peerDependencies: @@ -659,6 +724,13 @@ packages: '@nestjs/platform-express': optional: true + '@nestjs/throttler@6.5.0': + resolution: {integrity: sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==} + peerDependencies: + '@nestjs/common': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + '@nestjs/core': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + reflect-metadata: ^0.1.13 || ^0.2.0 + '@next/env@14.2.35': resolution: {integrity: sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ==} @@ -716,6 +788,14 @@ packages: cpu: [x64] os: [win32] + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@2.0.1': + resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} + engines: {node: '>= 20.19.0'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -736,6 +816,13 @@ packages: '@package-json/types@0.0.12': resolution: {integrity: sha512-uu43FGU34B5VM9mCNjXCwLaGHYjXdNincqKLaraaCW+7S2+SmiBg1Nv8bPnmschrIfZmfKNY9f3fC376MRrObw==} + '@paralleldrive/cuid2@2.3.1': + resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + + '@paralleldrive/cuid2@3.3.0': + resolution: {integrity: sha512-OqiFvSOF0dBSesELYY2CAMa4YINvlLpvKOz/rv6NeZEqiyttlHgv98Juwv4Ch+GrEV7IZ8jfI2VcEoYUjXXCjw==} + hasBin: true + '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} @@ -943,6 +1030,9 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/bcrypt@6.0.0': + resolution: {integrity: sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==} + '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} @@ -952,6 +1042,9 @@ packages: '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/cookiejar@2.1.5': + resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -976,9 +1069,30 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + + '@types/methods@1.1.4': + resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@22.19.17': resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} + '@types/passport-jwt@4.0.1': + resolution: {integrity: sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==} + + '@types/passport-local@1.0.38': + resolution: {integrity: sha512-nsrW4A963lYE7lNTv9cr5WmiUD1ibYJvWrpE13oxApFsRt77b0RdtZvKbCdNIY4v/QZ6TRQWaDDEwV1kCTmcXg==} + + '@types/passport-strategy@0.2.38': + resolution: {integrity: sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==} + + '@types/passport@1.0.17': + resolution: {integrity: sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==} + '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} @@ -996,12 +1110,24 @@ packages: '@types/react@18.3.28': resolution: {integrity: sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==} + '@types/sanitize-html@2.16.1': + resolution: {integrity: sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==} + '@types/send@1.2.1': resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} '@types/serve-static@2.2.0': resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + '@types/superagent@8.1.9': + resolution: {integrity: sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==} + + '@types/supertest@7.2.0': + resolution: {integrity: sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==} + + '@types/validator@13.15.10': + resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==} + '@typescript-eslint/eslint-plugin@8.58.0': resolution: {integrity: sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1346,10 +1472,16 @@ packages: array-timsort@1.0.3: resolution: {integrity: sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==} + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + atomic-sleep@1.0.0: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} @@ -1376,6 +1508,13 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bcrypt@6.0.0: + resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==} + engines: {node: '>= 18'} + + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -1403,6 +1542,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -1481,6 +1623,12 @@ packages: citty@0.2.2: resolution: {integrity: sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==} + class-transformer@0.5.1: + resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==} + + class-validator@0.15.1: + resolution: {integrity: sha512-LqoS80HBBSCVhz/3KloUly0ovokxpdOLR++Al3J3+dHXWt9sTKlKd4eYtoxhxyUjoe5+UcIM+5k9MIxyBWnRTw==} + cli-cursor@3.1.0: resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} engines: {node: '>=8'} @@ -1526,6 +1674,10 @@ packages: colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + commander@14.0.3: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} @@ -1545,6 +1697,9 @@ packages: resolution: {integrity: sha512-ObxuY6vnbWTN6Od72xfwN9DbzC7Y2vv8u1Soi9ahRKL37gb6y1qk6/dgjs+3JWuXJHWvsg3BXIwzd/rkmAwavg==} engines: {node: '>= 12.0.0'} + component-emitter@1.3.1: + resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -1575,6 +1730,9 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + cookiejar@2.1.4: + resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + cors@2.8.6: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} @@ -1633,6 +1791,10 @@ packages: defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + denque@2.1.0: resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} engines: {node: '>=0.10'} @@ -1649,12 +1811,28 @@ packages: destr@2.0.5: resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + dezalgo@1.0.4: + resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + dotenv@16.6.1: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} @@ -1663,6 +1841,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -1693,10 +1874,21 @@ packages: resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} engines: {node: '>=10.13.0'} + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + environment@1.1.0: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} + error-causes@3.0.2: + resolution: {integrity: sha512-i0B8zq1dHL6mM85FGoxaJnVtx6LD5nL2v0hlpGdntg5FOSyzQ46c9lmz5qx0xRS2+PWHGOHcYxGIBC5Le2dRMw==} + error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -1718,6 +1910,10 @@ packages: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + esbuild@0.27.7: resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} engines: {node: '>=18'} @@ -1934,6 +2130,14 @@ packages: typescript: '>3.6.0' webpack: ^5.11.0 + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + formidable@3.5.4: + resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} + engines: {node: '>=14.0.0'} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -2021,13 +2225,24 @@ packages: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + helmet@8.1.0: + resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==} + engines: {node: '>=18.0.0'} + help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + htmlparser2@10.1.0: + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -2125,6 +2340,10 @@ packages: resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==} engines: {node: '>=12'} + is-plain-object@5.0.0: + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} + engines: {node: '>=0.10.0'} + is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} @@ -2191,6 +2410,16 @@ packages: jsonfile@6.2.0: resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -2202,6 +2431,9 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + libphonenumber-js@1.12.41: + resolution: {integrity: sha512-lsmMmGXBxXIK/VMLEj0kL6MtUs1kBGj1nTCzi6zgQoG1DEwqwt2DQyHxcLykceIxAnfE3hya7NuIh6PpC6S3fA==} + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -2233,12 +2465,33 @@ packages: lodash.defaults@4.2.0: resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + lodash.isarguments@3.1.0: resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} @@ -2294,6 +2547,10 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -2314,6 +2571,11 @@ packages: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -2391,12 +2653,20 @@ packages: node-abort-controller@3.1.1: resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + node-addon-api@8.7.0: + resolution: {integrity: sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==} + engines: {node: ^18 || ^20 || >= 21} + node-emoji@1.11.0: resolution: {integrity: sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==} node-fetch-native@1.6.7: resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + node-releases@2.0.37: resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==} @@ -2467,10 +2737,28 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + parse-srcset@1.0.2: + resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + passport-jwt@4.0.1: + resolution: {integrity: sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==} + + passport-local@1.0.0: + resolution: {integrity: sha512-9wCE6qKznvf9mQYYbgJ3sVOHmCWoUNMVFoZzNoznmISbhnNNPhN9xfY3sLmScHMetEJeoY7CXwfhCe7argfQow==} + engines: {node: '>= 0.4.0'} + + passport-strategy@1.0.0: + resolution: {integrity: sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==} + engines: {node: '>= 0.4.0'} + + passport@0.7.0: + resolution: {integrity: sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==} + engines: {node: '>= 0.4.0'} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -2500,6 +2788,9 @@ packages: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} + pause@0.0.1: + resolution: {integrity: sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==} + perfect-debounce@1.0.0: resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} @@ -2767,6 +3058,9 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + sanitize-html@2.17.2: + resolution: {integrity: sha512-EnffJUl46VE9uvZ0XeWzObHLurClLlT12gsOk1cHyP2Ol1P0BnBnsXmShlBmWVJM+dKieQI68R0tsPY5m/B+Jg==} + scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} @@ -2949,6 +3243,14 @@ packages: engines: {node: '>=16 || 14 >=14.17'} hasBin: true + superagent@10.3.0: + resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} + engines: {node: '>=14.18.0'} + + supertest@7.2.2: + resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==} + engines: {node: '>=14.18.0'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -3133,6 +3435,14 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + validator@13.15.35: + resolution: {integrity: sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==} + engines: {node: '>= 0.10'} + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -3679,7 +3989,7 @@ snapshots: - uglify-js - webpack-cli - '@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: file-type: 21.3.4 iterare: 1.2.1 @@ -3688,12 +3998,15 @@ snapshots: rxjs: 7.8.2 tslib: 2.8.1 uid: 2.0.2 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.15.1 transitivePeerDependencies: - supports-color - '@nestjs/core@11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.18)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/core@11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.18)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: - '@nestjs/common': 11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nuxt/opencollective': 0.4.1 fast-safe-stringify: 2.1.1 iterare: 1.2.1 @@ -3703,25 +4016,36 @@ snapshots: tslib: 2.8.1 uid: 2.0.2 optionalDependencies: - '@nestjs/platform-express': 11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18) + '@nestjs/platform-express': 11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18) - '@nestjs/cqrs@11.0.3(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/cqrs@11.0.3(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: - '@nestjs/common': 11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.18)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.18)(reflect-metadata@0.2.2)(rxjs@7.8.2) reflect-metadata: 0.2.2 rxjs: 7.8.2 - '@nestjs/event-emitter@3.0.1(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)': + '@nestjs/event-emitter@3.0.1(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)': dependencies: - '@nestjs/common': 11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.18)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.18)(reflect-metadata@0.2.2)(rxjs@7.8.2) eventemitter2: 6.4.9 - '@nestjs/platform-express@11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)': + '@nestjs/jwt@11.0.2(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))': dependencies: - '@nestjs/common': 11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.18)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@types/jsonwebtoken': 9.0.10 + jsonwebtoken: 9.0.3 + + '@nestjs/passport@11.0.5(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)': + dependencies: + '@nestjs/common': 11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + passport: 0.7.0 + + '@nestjs/platform-express@11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)': + dependencies: + '@nestjs/common': 11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.18)(reflect-metadata@0.2.2)(rxjs@7.8.2) cors: 2.8.6 express: 5.2.1 multer: 2.1.1 @@ -3741,13 +4065,19 @@ snapshots: transitivePeerDependencies: - chokidar - '@nestjs/testing@11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)(@nestjs/platform-express@11.1.18)': + '@nestjs/testing@11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)(@nestjs/platform-express@11.1.18)': dependencies: - '@nestjs/common': 11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.18)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.18)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 optionalDependencies: - '@nestjs/platform-express': 11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18) + '@nestjs/platform-express': 11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18) + + '@nestjs/throttler@6.5.0(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)(reflect-metadata@0.2.2)': + dependencies: + '@nestjs/common': 11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.18)(reflect-metadata@0.2.2)(rxjs@7.8.2) + reflect-metadata: 0.2.2 '@next/env@14.2.35': {} @@ -3778,6 +4108,10 @@ snapshots: '@next/swc-win32-x64-msvc@14.2.33': optional: true + '@noble/hashes@1.8.0': {} + + '@noble/hashes@2.0.1': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -3796,6 +4130,16 @@ snapshots: '@package-json/types@0.0.12': {} + '@paralleldrive/cuid2@2.3.1': + dependencies: + '@noble/hashes': 1.8.0 + + '@paralleldrive/cuid2@3.3.0': + dependencies: + '@noble/hashes': 2.0.1 + bignumber.js: 9.3.1 + error-causes: 3.0.2 + '@pinojs/redact@0.4.0': {} '@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3)': @@ -3949,6 +4293,10 @@ snapshots: tslib: 2.8.1 optional: true + '@types/bcrypt@6.0.0': + dependencies: + '@types/node': 22.19.17 + '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 @@ -3963,6 +4311,8 @@ snapshots: dependencies: '@types/node': 22.19.17 + '@types/cookiejar@2.1.5': {} + '@types/deep-eql@4.0.2': {} '@types/eslint-scope@3.7.7': @@ -3994,10 +4344,39 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 22.19.17 + + '@types/methods@1.1.4': {} + + '@types/ms@2.1.0': {} + '@types/node@22.19.17': dependencies: undici-types: 6.21.0 + '@types/passport-jwt@4.0.1': + dependencies: + '@types/jsonwebtoken': 9.0.10 + '@types/passport-strategy': 0.2.38 + + '@types/passport-local@1.0.38': + dependencies: + '@types/express': 5.0.6 + '@types/passport': 1.0.17 + '@types/passport-strategy': 0.2.38 + + '@types/passport-strategy@0.2.38': + dependencies: + '@types/express': 5.0.6 + '@types/passport': 1.0.17 + + '@types/passport@1.0.17': + dependencies: + '@types/express': 5.0.6 + '@types/prop-types@15.7.15': {} '@types/qs@6.15.0': {} @@ -4013,6 +4392,10 @@ snapshots: '@types/prop-types': 15.7.15 csstype: 3.2.3 + '@types/sanitize-html@2.16.1': + dependencies: + htmlparser2: 10.1.0 + '@types/send@1.2.1': dependencies: '@types/node': 22.19.17 @@ -4022,6 +4405,20 @@ snapshots: '@types/http-errors': 2.0.5 '@types/node': 22.19.17 + '@types/superagent@8.1.9': + dependencies: + '@types/cookiejar': 2.1.5 + '@types/methods': 1.1.4 + '@types/node': 22.19.17 + form-data: 4.0.5 + + '@types/supertest@7.2.0': + dependencies: + '@types/methods': 1.1.4 + '@types/superagent': 8.1.9 + + '@types/validator@13.15.10': {} + '@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -4383,8 +4780,12 @@ snapshots: array-timsort@1.0.3: {} + asap@2.0.6: {} + assertion-error@2.0.1: {} + asynckit@0.4.0: {} + atomic-sleep@1.0.0: {} autoprefixer@10.4.27(postcss@8.5.8): @@ -4404,6 +4805,13 @@ snapshots: baseline-browser-mapping@2.10.16: {} + bcrypt@6.0.0: + dependencies: + node-addon-api: 8.7.0 + node-gyp-build: 4.8.4 + + bignumber.js@9.3.1: {} + binary-extensions@2.3.0: {} bl@4.1.0: @@ -4447,6 +4855,8 @@ snapshots: node-releases: 2.0.37 update-browserslist-db: 1.2.3(browserslist@4.28.2) + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} buffer@5.7.1: @@ -4534,6 +4944,14 @@ snapshots: citty@0.2.2: {} + class-transformer@0.5.1: {} + + class-validator@0.15.1: + dependencies: + '@types/validator': 13.15.10 + libphonenumber-js: 1.12.41 + validator: 13.15.35 + cli-cursor@3.1.0: dependencies: restore-cursor: 3.1.0 @@ -4571,6 +4989,10 @@ snapshots: colorette@2.0.20: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + commander@14.0.3: {} commander@2.20.3: {} @@ -4584,6 +5006,8 @@ snapshots: comment-parser@1.4.6: {} + component-emitter@1.3.1: {} + concat-map@0.0.1: {} concat-stream@2.0.0: @@ -4605,6 +5029,8 @@ snapshots: cookie@0.7.2: {} + cookiejar@2.1.4: {} + cors@2.8.6: dependencies: object-assign: 4.1.1 @@ -4649,6 +5075,8 @@ snapshots: defu@6.1.7: {} + delayed-stream@1.0.0: {} + denque@2.1.0: {} depd@2.0.0: {} @@ -4676,10 +5104,33 @@ snapshots: destr@2.0.5: {} + dezalgo@1.0.4: + dependencies: + asap: 2.0.6 + wrappy: 1.0.2 + didyoumean@1.2.2: {} dlv@1.1.3: {} + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + dotenv@16.6.1: {} dunder-proto@1.0.1: @@ -4688,6 +5139,10 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + ee-first@1.1.1: {} effect@3.21.0: @@ -4714,8 +5169,14 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.2 + entities@4.5.0: {} + + entities@7.0.1: {} + environment@1.1.0: {} + error-causes@3.0.2: {} + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -4732,6 +5193,13 @@ snapshots: dependencies: es-errors: 1.3.0 + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + esbuild@0.27.7: optionalDependencies: '@esbuild/aix-ppc64': 0.27.7 @@ -5028,6 +5496,20 @@ snapshots: typescript: 5.9.3 webpack: 5.105.4 + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + formidable@3.5.4: + dependencies: + '@paralleldrive/cuid2': 2.3.1 + dezalgo: 1.0.4 + once: 1.4.0 + forwarded@0.2.0: {} fraction.js@5.3.4: {} @@ -5112,12 +5594,25 @@ snapshots: has-symbols@1.1.0: {} + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + hasown@2.0.2: dependencies: function-bind: 1.1.2 + helmet@8.1.0: {} + help-me@5.0.0: {} + htmlparser2@10.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 7.0.1 + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -5204,6 +5699,8 @@ snapshots: is-path-inside@4.0.0: {} + is-plain-object@5.0.0: {} + is-promise@4.0.0: {} is-unicode-supported@0.1.0: {} @@ -5252,6 +5749,30 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.7.4 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -5263,6 +5784,8 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + libphonenumber-js@1.12.41: {} + lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} @@ -5295,10 +5818,24 @@ snapshots: lodash.defaults@4.2.0: {} + lodash.includes@4.3.0: {} + lodash.isarguments@3.1.0: {} + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + lodash.merge@4.6.2: {} + lodash.once@4.1.1: {} + lodash@4.18.1: {} log-symbols@4.1.0: @@ -5346,6 +5883,8 @@ snapshots: merge2@1.4.1: {} + methods@1.1.2: {} + micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -5363,6 +5902,8 @@ snapshots: dependencies: mime-db: 1.54.0 + mime@2.6.0: {} + mimic-fn@2.1.0: {} mimic-function@5.0.1: {} @@ -5433,12 +5974,16 @@ snapshots: node-abort-controller@3.1.1: {} + node-addon-api@8.7.0: {} + node-emoji@1.11.0: dependencies: lodash: 4.18.1 node-fetch-native@1.6.7: {} + node-gyp-build@4.8.4: {} + node-releases@2.0.37: {} normalize-path@3.0.0: {} @@ -5515,8 +6060,27 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parse-srcset@1.0.2: {} + parseurl@1.3.3: {} + passport-jwt@4.0.1: + dependencies: + jsonwebtoken: 9.0.3 + passport-strategy: 1.0.0 + + passport-local@1.0.0: + dependencies: + passport-strategy: 1.0.0 + + passport-strategy@1.0.0: {} + + passport@0.7.0: + dependencies: + passport-strategy: 1.0.0 + pause: 0.0.1 + utils-merge: 1.0.1 + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -5536,6 +6100,8 @@ snapshots: pathval@2.0.1: {} + pause@0.0.1: {} + perfect-debounce@1.0.0: {} picocolors@1.1.1: {} @@ -5828,6 +6394,15 @@ snapshots: safer-buffer@2.1.2: {} + sanitize-html@2.17.2: + dependencies: + deepmerge: 4.3.1 + escape-string-regexp: 4.0.0 + htmlparser2: 10.1.0 + is-plain-object: 5.0.0 + parse-srcset: 1.0.2 + postcss: 8.5.8 + scheduler@0.23.2: dependencies: loose-envify: 1.4.0 @@ -6017,6 +6592,28 @@ snapshots: tinyglobby: 0.2.15 ts-interface-checker: 0.1.13 + superagent@10.3.0: + dependencies: + component-emitter: 1.3.1 + cookiejar: 2.1.4 + debug: 4.4.3 + fast-safe-stringify: 2.1.1 + form-data: 4.0.5 + formidable: 3.5.4 + methods: 1.1.2 + mime: 2.6.0 + qs: 6.15.0 + transitivePeerDependencies: + - supports-color + + supertest@7.2.2: + dependencies: + cookie-signature: 1.2.2 + methods: 1.1.2 + superagent: 10.3.0 + transitivePeerDependencies: + - supports-color + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -6230,6 +6827,10 @@ snapshots: util-deprecate@1.0.2: {} + utils-merge@1.0.1: {} + + validator@13.15.35: {} + vary@1.1.2: {} vite-node@3.2.4(@types/node@22.19.17)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 938dca9..18a3188 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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