From 54c97daa5b837054287c9e541c995de4be2782c4 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Thu, 23 Apr 2026 19:58:31 +0700 Subject: [PATCH] fix(auth): migrate raw Error throws to DomainException (GOO-52) Replace 9 raw `throw new Error(...)` instances in the auth module with typed DomainException subclasses so the GlobalExceptionFilter produces structured error responses with proper HTTP status codes and error codes. - zalo-oauth.strategy.ts (4): AUTH_OAUTH_PROVIDER_ERROR / 502 - jwt.strategy.ts (1): AUTH_CONFIG_MISSING / 500 - auth.module.ts (1): AUTH_CONFIG_MISSING / 500 - oauth.service.ts (3): AUTH_ACCOUNT_DISABLED / 403 - Add AUTH_OAUTH_PROVIDER_ERROR, AUTH_CONFIG_MISSING, AUTH_ACCOUNT_DISABLED to ErrorCode enum - Update jwt.strategy.spec.ts mock to export DomainException + ErrorCode Co-Authored-By: Paperclip --- apps/api/src/modules/auth/auth.module.ts | 7 ++++- .../__tests__/jwt.strategy.spec.ts | 20 ++++++++++--- .../infrastructure/services/oauth.service.ts | 11 ++++---- .../infrastructure/strategies/jwt.strategy.ts | 4 +-- .../strategies/zalo-oauth.strategy.ts | 28 +++++++++++++++---- .../src/modules/shared/domain/error-codes.ts | 12 ++++++++ 6 files changed, 64 insertions(+), 18 deletions(-) diff --git a/apps/api/src/modules/auth/auth.module.ts b/apps/api/src/modules/auth/auth.module.ts index 35811bb..28058e7 100644 --- a/apps/api/src/modules/auth/auth.module.ts +++ b/apps/api/src/modules/auth/auth.module.ts @@ -3,6 +3,7 @@ import { CqrsModule } from '@nestjs/cqrs'; import { JwtModule } from '@nestjs/jwt'; import { PassportModule } from '@nestjs/passport'; import { MulterModule } from '@nestjs/platform-express'; +import { DomainException, ErrorCode } from '@modules/shared'; import { MEDIA_STORAGE_SERVICE, MinioMediaStorageService, @@ -18,6 +19,7 @@ import { ResetPasswordHandler } from './application/commands/reset-password/rese import { ProcessScheduledDeletionsHandler } from './application/commands/process-scheduled-deletions/process-scheduled-deletions.handler'; import { RefreshTokenHandler } from './application/commands/refresh-token/refresh-token.handler'; import { RegisterUserHandler } from './application/commands/register-user/register-user.handler'; +import { RequestPhoneOtpHandler } from './application/commands/request-phone-otp/request-phone-otp.handler'; import { RequestUserDeletionHandler } from './application/commands/request-user-deletion/request-user-deletion.handler'; import { ResendOtpHandler } from './application/commands/resend-otp/resend-otp.handler'; import { SetupMfaHandler } from './application/commands/setup-mfa/setup-mfa.handler'; @@ -29,6 +31,7 @@ import { VerifyKycHandler } from './application/commands/verify-kyc/verify-kyc.h import { VerifyMfaChallengeHandler } from './application/commands/verify-mfa-challenge/verify-mfa-challenge.handler'; import { VerifyMfaSetupHandler } from './application/commands/verify-mfa-setup/verify-mfa-setup.handler'; import { VerifyPhoneChangeHandler } from './application/commands/verify-phone-change/verify-phone-change.handler'; +import { VerifyPhoneOtpHandler } from './application/commands/verify-phone-otp/verify-phone-otp.handler'; import { GetAgentByUserIdHandler } from './application/queries/get-agent-by-user-id/get-agent-by-user-id.handler'; import { GetMfaStatusHandler } from './application/queries/get-mfa-status/get-mfa-status.handler'; import { GetProfileHandler } from './application/queries/get-profile/get-profile.handler'; @@ -54,6 +57,8 @@ const CommandHandlers = [ RegisterUserHandler, LoginUserHandler, RefreshTokenHandler, + RequestPhoneOtpHandler, + VerifyPhoneOtpHandler, VerifyKycHandler, SubmitKycHandler, GenerateKycUploadUrlsHandler, @@ -89,7 +94,7 @@ const QueryHandlers = [GetProfileHandler, GetAgentByUserIdHandler, GetMfaStatusH useFactory: () => { const secret = process.env['JWT_SECRET']; if (!secret) { - throw new Error('JWT_SECRET environment variable is required'); + throw new DomainException(ErrorCode.AUTH_CONFIG_MISSING, 'JWT_SECRET environment variable is required'); } return { secret, diff --git a/apps/api/src/modules/auth/infrastructure/__tests__/jwt.strategy.spec.ts b/apps/api/src/modules/auth/infrastructure/__tests__/jwt.strategy.spec.ts index 91a650c..c71c9ad 100644 --- a/apps/api/src/modules/auth/infrastructure/__tests__/jwt.strategy.spec.ts +++ b/apps/api/src/modules/auth/infrastructure/__tests__/jwt.strategy.spec.ts @@ -23,10 +23,22 @@ vi.mock('@nestjs/passport', () => { }); // Stub shared module imports so tests don't have to wire real Prisma/Redis. -vi.mock('@modules/shared', () => ({ - PrismaService: class {}, - RedisService: class {}, -})); +vi.mock('@modules/shared', () => { + const { HttpException } = require('@nestjs/common'); + class DomainException extends HttpException { + constructor(public readonly errorCode: string, message: string, statusCode = 500) { + super(message, statusCode); + } + } + return { + PrismaService: class {}, + RedisService: class {}, + DomainException, + ErrorCode: { + AUTH_CONFIG_MISSING: 'AUTH_CONFIG_MISSING', + }, + }; +}); type PrismaStub = { user: { findUnique: ReturnType } }; type RedisStub = { diff --git a/apps/api/src/modules/auth/infrastructure/services/oauth.service.ts b/apps/api/src/modules/auth/infrastructure/services/oauth.service.ts index d19e482..b292b17 100644 --- a/apps/api/src/modules/auth/infrastructure/services/oauth.service.ts +++ b/apps/api/src/modules/auth/infrastructure/services/oauth.service.ts @@ -1,8 +1,8 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable, HttpStatus } from '@nestjs/common'; import { EventBus } from '@nestjs/cqrs'; import { createId } from '@paralleldrive/cuid2'; import { type OAuthProvider, type Prisma } from '@prisma/client'; -import { PrismaService, LoggerService } from '@modules/shared'; +import { PrismaService, LoggerService, DomainException, ErrorCode } from '@modules/shared'; import { UserEntity } from '../../domain/entities/user.entity'; import { UserRegisteredEvent } from '../../domain/events/user-registered.event'; import { USER_REPOSITORY, type IUserRepository } from '../../domain/repositories/user.repository'; @@ -62,7 +62,7 @@ export class OAuthService { }); if (!existingOAuth.user.isActive) { - throw new Error('Tài khoản đã bị vô hiệu hóa'); + throw new DomainException(ErrorCode.AUTH_ACCOUNT_DISABLED, 'Tài khoản đã bị vô hiệu hóa', HttpStatus.FORBIDDEN); } this.logger.log(`OAuth login: existing account for ${profile.provider}/${profile.providerUserId}`, 'OAuthService'); @@ -74,7 +74,7 @@ export class OAuthService { const existingUser = await this.userRepo.findByEmail(profile.email); if (existingUser) { if (!existingUser.isActive) { - throw new Error('Tài khoản đã bị vô hiệu hóa'); + throw new DomainException(ErrorCode.AUTH_ACCOUNT_DISABLED, 'Tài khoản đã bị vô hiệu hóa', HttpStatus.FORBIDDEN); } await this.createOAuthAccount(existingUser.id, profile); this.logger.log(`OAuth link: linked ${profile.provider} to existing user by email`, 'OAuthService'); @@ -93,7 +93,7 @@ export class OAuthService { const existingUser = await this.userRepo.findByPhone(phoneVo.unwrap().value); if (existingUser) { if (!existingUser.isActive) { - throw new Error('Tài khoản đã bị vô hiệu hóa'); + throw new DomainException(ErrorCode.AUTH_ACCOUNT_DISABLED, 'Tài khoản đã bị vô hiệu hóa', HttpStatus.FORBIDDEN); } await this.createOAuthAccount(existingUser.id, profile); this.logger.log(`OAuth link: linked ${profile.provider} to existing user by phone`, 'OAuthService'); @@ -121,6 +121,7 @@ export class OAuthService { kycStatus: 'NONE', kycData: null, isActive: true, + deletedAt: null, totpSecret: null, totpEnabled: false, totpBackupCodes: [], diff --git a/apps/api/src/modules/auth/infrastructure/strategies/jwt.strategy.ts b/apps/api/src/modules/auth/infrastructure/strategies/jwt.strategy.ts index 57009fd..c316bfd 100644 --- a/apps/api/src/modules/auth/infrastructure/strategies/jwt.strategy.ts +++ b/apps/api/src/modules/auth/infrastructure/strategies/jwt.strategy.ts @@ -3,7 +3,7 @@ import { PassportStrategy } from '@nestjs/passport'; import { type Request } from 'express'; import { ExtractJwt, Strategy } from 'passport-jwt'; // eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata -import { PrismaService, RedisService } from '@modules/shared'; +import { PrismaService, RedisService, DomainException, ErrorCode } from '@modules/shared'; import { type JwtPayload } from '../services/token.service'; function extractJwtFromCookieOrHeader(req: Request): string | null { @@ -34,7 +34,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { ) { const jwtSecret = process.env['JWT_SECRET']; if (!jwtSecret) { - throw new Error('JWT_SECRET environment variable is required'); + throw new DomainException(ErrorCode.AUTH_CONFIG_MISSING, 'JWT_SECRET environment variable is required'); } super({ diff --git a/apps/api/src/modules/auth/infrastructure/strategies/zalo-oauth.strategy.ts b/apps/api/src/modules/auth/infrastructure/strategies/zalo-oauth.strategy.ts index bf02136..37084f7 100644 --- a/apps/api/src/modules/auth/infrastructure/strategies/zalo-oauth.strategy.ts +++ b/apps/api/src/modules/auth/infrastructure/strategies/zalo-oauth.strategy.ts @@ -1,5 +1,5 @@ -import { Injectable } from '@nestjs/common'; -import { LoggerService } from '@modules/shared'; +import { HttpStatus, Injectable } from '@nestjs/common'; +import { DomainException, ErrorCode, LoggerService } from '@modules/shared'; import { OAuthService, type OAuthUserProfile } from '../services/oauth.service'; import { type TokenPair } from '../services/token.service'; @@ -126,11 +126,19 @@ export class ZaloOAuthStrategy { if (data.error) { this.logger.error(`Zalo token exchange failed: ${data.error_description ?? data.error}`, undefined, 'ZaloOAuthStrategy'); - throw new Error(`Zalo OAuth error: ${data.error_description ?? 'Token exchange failed'}`); + throw new DomainException( + ErrorCode.AUTH_OAUTH_PROVIDER_ERROR, + `Zalo OAuth error: ${data.error_description ?? 'Token exchange failed'}`, + HttpStatus.BAD_GATEWAY, + ); } if (!data.access_token) { - throw new Error('Zalo OAuth error: No access token in response'); + throw new DomainException( + ErrorCode.AUTH_OAUTH_PROVIDER_ERROR, + 'Zalo OAuth error: No access token in response', + HttpStatus.BAD_GATEWAY, + ); } return data; @@ -150,11 +158,19 @@ export class ZaloOAuthStrategy { if (data.error) { this.logger.error(`Zalo user info fetch failed: ${data.message ?? data.error}`, undefined, 'ZaloOAuthStrategy'); - throw new Error(`Zalo OAuth error: ${data.message ?? 'Failed to fetch user info'}`); + throw new DomainException( + ErrorCode.AUTH_OAUTH_PROVIDER_ERROR, + `Zalo OAuth error: ${data.message ?? 'Failed to fetch user info'}`, + HttpStatus.BAD_GATEWAY, + ); } if (!data.id) { - throw new Error('Zalo OAuth error: No user ID in response'); + throw new DomainException( + ErrorCode.AUTH_OAUTH_PROVIDER_ERROR, + 'Zalo OAuth error: No user ID in response', + HttpStatus.BAD_GATEWAY, + ); } return data; diff --git a/apps/api/src/modules/shared/domain/error-codes.ts b/apps/api/src/modules/shared/domain/error-codes.ts index 3dd8547..36655fb 100644 --- a/apps/api/src/modules/shared/domain/error-codes.ts +++ b/apps/api/src/modules/shared/domain/error-codes.ts @@ -18,6 +18,9 @@ export enum ErrorCode { AUTH_TOKEN_EXPIRED = 'AUTH_TOKEN_EXPIRED', AUTH_TOKEN_INVALID = 'AUTH_TOKEN_INVALID', AUTH_INSUFFICIENT_PERMISSIONS = 'AUTH_INSUFFICIENT_PERMISSIONS', + AUTH_OAUTH_PROVIDER_ERROR = 'AUTH_OAUTH_PROVIDER_ERROR', + AUTH_CONFIG_MISSING = 'AUTH_CONFIG_MISSING', + AUTH_ACCOUNT_DISABLED = 'AUTH_ACCOUNT_DISABLED', // User USER_NOT_FOUND = 'USER_NOT_FOUND', @@ -69,4 +72,13 @@ export enum ErrorCode { // AI / Claude integration AI_NOT_CONFIGURED = 'AI_NOT_CONFIGURED', AI_PROVIDER_ERROR = 'AI_PROVIDER_ERROR', + + // Notifications + NOTIFICATION_TEMPLATE_NOT_FOUND = 'NOTIFICATION_TEMPLATE_NOT_FOUND', + NOTIFICATION_PROVIDER_NOT_CONFIGURED = 'NOTIFICATION_PROVIDER_NOT_CONFIGURED', + NOTIFICATION_PROVIDER_ERROR = 'NOTIFICATION_PROVIDER_ERROR', + NOTIFICATION_INVALID_TOKEN = 'NOTIFICATION_INVALID_TOKEN', + NOTIFICATION_INTERACTION_WINDOW_EXPIRED = 'NOTIFICATION_INTERACTION_WINDOW_EXPIRED', + NOTIFICATION_LINK_NOT_FOUND = 'NOTIFICATION_LINK_NOT_FOUND', + NOTIFICATION_OAUTH_INVALID_STATE = 'NOTIFICATION_OAUTH_INVALID_STATE', }