diff --git a/apps/api/src/modules/auth/application/__tests__/update-profile.handler.spec.ts b/apps/api/src/modules/auth/application/__tests__/update-profile.handler.spec.ts index c9f1e6a..0137301 100644 --- a/apps/api/src/modules/auth/application/__tests__/update-profile.handler.spec.ts +++ b/apps/api/src/modules/auth/application/__tests__/update-profile.handler.spec.ts @@ -191,4 +191,85 @@ describe('UpdateProfileHandler', () => { expect(mockUserRepo.update).toHaveBeenCalledWith(user); expect(mockCache.invalidate).toHaveBeenCalled(); }); + + it('defers phone change via SMS OTP instead of updating directly', async () => { + const user = createTestUser(); + mockUserRepo.findById.mockResolvedValue(user); + mockUserRepo.findByPhone.mockResolvedValue(null); + mockUserRepo.update.mockResolvedValue(undefined); + + const command = new UpdateProfileCommand( + 'user-1', + undefined, + undefined, + undefined, + '0987654321', + ); + const result = await handler.execute(command); + + // Phone should NOT change yet — deferred pending OTP + expect(result.phoneNumber).toBe('+84912345678'); + expect(result.phoneChangePending).toBe(true); + + expect(mockRedis.set).toHaveBeenCalledWith( + 'auth:phone_change_otp:user-1', + expect.stringContaining('+84987654321'), + 600, + ); + expect(mockEventBus.publish).toHaveBeenCalledWith( + expect.objectContaining({ + eventName: 'user.phone_change_requested', + newPhone: '+84987654321', + }), + ); + }); + + it('throws ConflictException when new phone is already taken', async () => { + const user = createTestUser(); + const otherUser = createTestUser({ id: 'user-2' }); + mockUserRepo.findById.mockResolvedValue(user); + mockUserRepo.findByPhone.mockResolvedValue(otherUser); + + const command = new UpdateProfileCommand( + 'user-1', + undefined, + undefined, + undefined, + '0987654321', + ); + await expect(handler.execute(command)).rejects.toThrow('Số điện thoại đã được sử dụng'); + }); + + it('skips SMS OTP when phone is unchanged', async () => { + const user = createTestUser(); + mockUserRepo.findById.mockResolvedValue(user); + mockUserRepo.update.mockResolvedValue(undefined); + + const command = new UpdateProfileCommand( + 'user-1', + undefined, + undefined, + undefined, + '0912345678', + ); + const result = await handler.execute(command); + + expect(mockRedis.set).not.toHaveBeenCalled(); + expect(mockEventBus.publish).not.toHaveBeenCalled(); + expect(result.phoneChangePending).toBeUndefined(); + }); + + it('throws ValidationException for invalid phone format', async () => { + const user = createTestUser(); + mockUserRepo.findById.mockResolvedValue(user); + + const command = new UpdateProfileCommand( + 'user-1', + undefined, + undefined, + undefined, + 'not-a-phone', + ); + await expect(handler.execute(command)).rejects.toThrow('Số điện thoại'); + }); }); diff --git a/apps/api/src/modules/auth/application/__tests__/verify-phone-change.handler.spec.ts b/apps/api/src/modules/auth/application/__tests__/verify-phone-change.handler.spec.ts new file mode 100644 index 0000000..97b6590 --- /dev/null +++ b/apps/api/src/modules/auth/application/__tests__/verify-phone-change.handler.spec.ts @@ -0,0 +1,119 @@ +import { UserEntity } from '../../domain/entities/user.entity'; +import { type IUserRepository } from '../../domain/repositories/user.repository'; +import { type HashedPassword } from '../../domain/value-objects/hashed-password.vo'; +import { Phone } from '../../domain/value-objects/phone.vo'; +import { VerifyPhoneChangeCommand } from '../commands/verify-phone-change/verify-phone-change.command'; +import { VerifyPhoneChangeHandler } from '../commands/verify-phone-change/verify-phone-change.handler'; + +function createTestUser(overrides?: Partial<{ id: string; phone: string }>): UserEntity { + const phone = Phone.create(overrides?.phone ?? '0912345678').unwrap(); + const pw = { value: 'hashed' } as HashedPassword; + return new UserEntity(overrides?.id ?? 'user-1', { + email: null, + phone, + passwordHash: pw, + fullName: 'Nguyen Van A', + avatarUrl: null, + role: 'BUYER', + kycStatus: 'NONE', + kycData: null, + isActive: true, + totpSecret: null, + totpEnabled: false, + totpBackupCodes: [], + totpEnabledAt: null, + }); +} + +describe('VerifyPhoneChangeHandler', () => { + let handler: VerifyPhoneChangeHandler; + let mockUserRepo: { [K in keyof IUserRepository]: ReturnType }; + let mockRedis: { get: ReturnType; del: ReturnType; set: ReturnType }; + let mockCache: { invalidate: ReturnType }; + + beforeEach(() => { + mockUserRepo = { + findById: vi.fn(), + findByPhone: vi.fn(), + findByEmail: vi.fn(), + save: vi.fn(), + update: vi.fn(), + updateMfaSecret: vi.fn(), + updateMfaEnabled: vi.fn(), + updateMfaDisabled: vi.fn(), + updateBackupCodes: vi.fn(), + }; + mockRedis = { + get: vi.fn(), + del: vi.fn().mockResolvedValue(undefined), + set: vi.fn().mockResolvedValue(undefined), + }; + mockCache = { invalidate: vi.fn().mockResolvedValue(undefined) }; + + handler = new VerifyPhoneChangeHandler( + mockUserRepo as any, + mockRedis as any, + mockCache as any, + { error: vi.fn() } as any, + ); + }); + + it('verifies SMS OTP and updates phone', async () => { + const user = createTestUser(); + const payload = JSON.stringify({ newPhone: '+84987654321', code: '123456' }); + mockRedis.get.mockResolvedValue(payload); + mockUserRepo.findById.mockResolvedValue(user); + mockUserRepo.findByPhone.mockResolvedValue(null); + mockUserRepo.update.mockResolvedValue(undefined); + + const command = new VerifyPhoneChangeCommand('user-1', '123456'); + const result = await handler.execute(command); + + expect(result.phoneNumber).toBe('+84987654321'); + expect(result.id).toBe('user-1'); + expect(mockRedis.del).toHaveBeenCalledWith('auth:phone_change_otp:user-1'); + expect(mockUserRepo.update).toHaveBeenCalledWith(user); + expect(mockCache.invalidate).toHaveBeenCalledWith( + expect.stringContaining('user-1'), + ); + }); + + it('throws ValidationException when OTP has expired', async () => { + mockRedis.get.mockResolvedValue(null); + + const command = new VerifyPhoneChangeCommand('user-1', '123456'); + await expect(handler.execute(command)).rejects.toThrow('hết hạn'); + }); + + it('throws ValidationException when OTP code is wrong', async () => { + const payload = JSON.stringify({ newPhone: '+84987654321', code: '123456' }); + mockRedis.get.mockResolvedValue(payload); + + const command = new VerifyPhoneChangeCommand('user-1', '999999'); + await expect(handler.execute(command)).rejects.toThrow('không đúng'); + }); + + it('throws ConflictException when phone was taken since OTP was issued', async () => { + const user = createTestUser(); + const otherUser = createTestUser({ id: 'user-2', phone: '0987654321' }); + const payload = JSON.stringify({ newPhone: '+84987654321', code: '123456' }); + mockRedis.get.mockResolvedValue(payload); + mockUserRepo.findById.mockResolvedValue(user); + mockUserRepo.findByPhone.mockResolvedValue(otherUser); + + const command = new VerifyPhoneChangeCommand('user-1', '123456'); + await expect(handler.execute(command)).rejects.toThrow('Số điện thoại đã được sử dụng'); + + // OTP should be cleaned up on conflict + expect(mockRedis.del).toHaveBeenCalledWith('auth:phone_change_otp:user-1'); + }); + + it('throws NotFoundException when user does not exist', async () => { + const payload = JSON.stringify({ newPhone: '+84987654321', code: '123456' }); + mockRedis.get.mockResolvedValue(payload); + mockUserRepo.findById.mockResolvedValue(null); + + const command = new VerifyPhoneChangeCommand('user-1', '123456'); + await expect(handler.execute(command)).rejects.toThrow('Người dùng'); + }); +}); diff --git a/apps/api/src/modules/auth/application/commands/update-profile/update-profile.command.ts b/apps/api/src/modules/auth/application/commands/update-profile/update-profile.command.ts index 5e25d55..cd017b9 100644 --- a/apps/api/src/modules/auth/application/commands/update-profile/update-profile.command.ts +++ b/apps/api/src/modules/auth/application/commands/update-profile/update-profile.command.ts @@ -4,5 +4,6 @@ export class UpdateProfileCommand { public readonly fullName?: string, public readonly avatarUrl?: string, public readonly email?: string, + public readonly phoneNumber?: string, ) {} } diff --git a/apps/api/src/modules/auth/application/commands/update-profile/update-profile.handler.ts b/apps/api/src/modules/auth/application/commands/update-profile/update-profile.handler.ts index 33964a0..7a7136f 100644 --- a/apps/api/src/modules/auth/application/commands/update-profile/update-profile.handler.ts +++ b/apps/api/src/modules/auth/application/commands/update-profile/update-profile.handler.ts @@ -12,8 +12,10 @@ import { ValidationException, } from '@modules/shared'; import { EmailChangeRequestedEvent } from '../../../domain/events/email-change-requested.event'; +import { PhoneChangeRequestedEvent } from '../../../domain/events/phone-change-requested.event'; import { type IUserRepository, USER_REPOSITORY } from '../../../domain/repositories/user.repository'; import { Email } from '../../../domain/value-objects/email.vo'; +import { Phone } from '../../../domain/value-objects/phone.vo'; import { UpdateProfileCommand } from './update-profile.command'; /** TTL for email-change OTP codes stored in Redis (10 minutes). */ @@ -22,12 +24,20 @@ const EMAIL_CHANGE_OTP_TTL = 600; /** Redis key prefix for pending email-change OTP. */ export const EMAIL_CHANGE_OTP_PREFIX = 'auth:email_change_otp'; +/** TTL for phone-change OTP codes stored in Redis (10 minutes). */ +const PHONE_CHANGE_OTP_TTL = 600; + +/** Redis key prefix for pending phone-change OTP. */ +export const PHONE_CHANGE_OTP_PREFIX = 'auth:phone_change_otp'; + export interface UpdateProfileResultDto { id: string; fullName: string; avatarUrl: string | null; email: string | null; + phoneNumber: string; emailChangePending?: boolean; + phoneChangePending?: boolean; updatedAt: Date; } @@ -49,6 +59,7 @@ export class UpdateProfileHandler implements ICommandHandler { + constructor( + @Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository, + private readonly redis: RedisService, + private readonly cache: CacheService, + private readonly logger: LoggerService, + ) {} + + async execute(command: VerifyPhoneChangeCommand): Promise { + try { + const redisKey = `${PHONE_CHANGE_OTP_PREFIX}:${command.userId}`; + const raw = await this.redis.get(redisKey); + + if (!raw) { + throw new ValidationException( + 'Mã xác thực đã hết hạn hoặc không tồn tại. Vui lòng yêu cầu đổi số điện thoại lại.', + ); + } + + const { newPhone, code } = JSON.parse(raw) as { newPhone: string; code: string }; + + if (code !== command.code) { + throw new ValidationException('Mã xác thực không đúng'); + } + + const user = await this.userRepo.findById(command.userId); + if (!user) { + throw new NotFoundException('Người dùng', command.userId); + } + + // Re-check phone uniqueness (may have been taken since the request) + const existingUser = await this.userRepo.findByPhone(newPhone); + if (existingUser && existingUser.id !== command.userId) { + await this.redis.del(redisKey); + throw new ConflictException('Số điện thoại đã được sử dụng bởi tài khoản khác'); + } + + const phoneVo = Phone.create(newPhone).unwrap(); + user.updatePhone(phoneVo); + await this.userRepo.update(user); + + // Clean up OTP and invalidate profile cache + await this.redis.del(redisKey); + await this.cache.invalidate( + CacheService.buildKey(CachePrefix.USER_PROFILE, command.userId), + ); + + return { + id: user.id, + phoneNumber: phoneVo.value, + updatedAt: user.updatedAt, + }; + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to verify phone change: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException('Không thể xác thực đổi số điện thoại'); + } + } +} diff --git a/apps/api/src/modules/auth/auth.module.ts b/apps/api/src/modules/auth/auth.module.ts index a87a1aa..ebdd4e7 100644 --- a/apps/api/src/modules/auth/auth.module.ts +++ b/apps/api/src/modules/auth/auth.module.ts @@ -25,6 +25,7 @@ import { VerifyEmailChangeHandler } from './application/commands/verify-email-ch import { VerifyKycHandler } from './application/commands/verify-kyc/verify-kyc.handler'; 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 { 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'; @@ -55,6 +56,7 @@ const CommandHandlers = [ GenerateKycUploadUrlsHandler, UpdateProfileHandler, VerifyEmailChangeHandler, + VerifyPhoneChangeHandler, RequestUserDeletionHandler, CancelUserDeletionHandler, ForceDeleteUserHandler, diff --git a/apps/api/src/modules/auth/domain/entities/user.entity.ts b/apps/api/src/modules/auth/domain/entities/user.entity.ts index d21b3e8..521c42b 100644 --- a/apps/api/src/modules/auth/domain/entities/user.entity.ts +++ b/apps/api/src/modules/auth/domain/entities/user.entity.ts @@ -145,4 +145,9 @@ export class UserEntity extends AggregateRoot { if (email !== undefined) this._email = email; this.updatedAt = new Date(); } + + updatePhone(phone: Phone): void { + this._phone = phone; + this.updatedAt = new Date(); + } } diff --git a/apps/api/src/modules/auth/domain/events/index.ts b/apps/api/src/modules/auth/domain/events/index.ts index b4675b7..e8b816d 100644 --- a/apps/api/src/modules/auth/domain/events/index.ts +++ b/apps/api/src/modules/auth/domain/events/index.ts @@ -1,3 +1,4 @@ export { UserRegisteredEvent } from './user-registered.event'; export { AgentVerifiedEvent } from './agent-verified.event'; export { EmailChangeRequestedEvent } from './email-change-requested.event'; +export { PhoneChangeRequestedEvent } from './phone-change-requested.event'; diff --git a/apps/api/src/modules/auth/domain/events/phone-change-requested.event.ts b/apps/api/src/modules/auth/domain/events/phone-change-requested.event.ts new file mode 100644 index 0000000..4714aa2 --- /dev/null +++ b/apps/api/src/modules/auth/domain/events/phone-change-requested.event.ts @@ -0,0 +1,12 @@ +import { type DomainEvent } from '@modules/shared'; + +export class PhoneChangeRequestedEvent implements DomainEvent { + readonly eventName = 'user.phone_change_requested'; + readonly occurredAt = new Date(); + + constructor( + public readonly aggregateId: string, + public readonly newPhone: string, + public readonly otpCode: string, + ) {} +} diff --git a/apps/api/src/modules/auth/index.ts b/apps/api/src/modules/auth/index.ts index 111a7ee..67f58a1 100644 --- a/apps/api/src/modules/auth/index.ts +++ b/apps/api/src/modules/auth/index.ts @@ -12,4 +12,5 @@ export { UserDeactivatedEvent } from './domain/events/user-deactivated.event'; export { UserKycUpdatedEvent } from './domain/events/user-kyc-updated.event'; export { UserRegisteredEvent } from './domain/events/user-registered.event'; export { EmailChangeRequestedEvent } from './domain/events/email-change-requested.event'; +export { PhoneChangeRequestedEvent } from './domain/events/phone-change-requested.event'; export { USER_REPOSITORY, IUserRepository } from './domain/repositories/user.repository'; diff --git a/apps/api/src/modules/auth/presentation/controllers/auth.controller.ts b/apps/api/src/modules/auth/presentation/controllers/auth.controller.ts index da74385..d236516 100644 --- a/apps/api/src/modules/auth/presentation/controllers/auth.controller.ts +++ b/apps/api/src/modules/auth/presentation/controllers/auth.controller.ts @@ -16,9 +16,8 @@ import { EndpointRateLimit, EndpointRateLimitGuard, UnauthorizedException, - ValidationException, } from '@modules/shared'; -import { GenerateKycUploadUrlsCommand, type KycFileRequest } from '../../application/commands/generate-kyc-upload-urls/generate-kyc-upload-urls.command'; +import { GenerateKycUploadUrlsCommand } from '../../application/commands/generate-kyc-upload-urls/generate-kyc-upload-urls.command'; import { LoginUserCommand } from '../../application/commands/login-user/login-user.command'; import { type LoginResult } from '../../application/commands/login-user/login-user.handler'; import { RefreshTokenCommand } from '../../application/commands/refresh-token/refresh-token.command'; @@ -29,6 +28,8 @@ import { type UpdateProfileResultDto } from '../../application/commands/update-p import { VerifyEmailChangeCommand } from '../../application/commands/verify-email-change/verify-email-change.command'; import { type VerifyEmailChangeResultDto } from '../../application/commands/verify-email-change/verify-email-change.handler'; import { VerifyKycCommand } from '../../application/commands/verify-kyc/verify-kyc.command'; +import { VerifyPhoneChangeCommand } from '../../application/commands/verify-phone-change/verify-phone-change.command'; +import { type VerifyPhoneChangeResultDto } from '../../application/commands/verify-phone-change/verify-phone-change.handler'; import { type AgentDto } from '../../application/queries/get-agent-by-user-id/get-agent-by-user-id.handler'; import { GetAgentByUserIdQuery } from '../../application/queries/get-agent-by-user-id/get-agent-by-user-id.query'; import { type UserProfileDto } from '../../application/queries/get-profile/get-profile.handler'; @@ -37,12 +38,15 @@ import { type TokenService, type JwtPayload, type TokenPair } from '../../infras import { type LocalStrategyResult } from '../../infrastructure/strategies/local.strategy'; import { CurrentUser } from '../decorators/current-user.decorator'; import { Roles } from '../decorators/roles.decorator'; +import { type GenerateKycUploadUrlsDto } from '../dto/generate-kyc-upload-urls.dto'; import { LoginDto } from '../dto/login.dto'; import { type RefreshTokenDto } from '../dto/refresh-token.dto'; import { type RegisterDto } from '../dto/register.dto'; +import { type SubmitKycDto } from '../dto/submit-kyc.dto'; import { type UpdateProfileDto } from '../dto/update-profile.dto'; import { type VerifyEmailChangeDto } from '../dto/verify-email-change.dto'; import { type VerifyKycDto } from '../dto/verify-kyc.dto'; +import { type VerifyPhoneChangeDto } from '../dto/verify-phone-change.dto'; import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { LocalAuthGuard } from '../guards/local-auth.guard'; import { RolesGuard } from '../guards/roles.guard'; @@ -227,11 +231,29 @@ export class AuthController { @Body() dto: UpdateProfileDto, ): Promise<{ message: string; data: UpdateProfileResultDto }> { const result: UpdateProfileResultDto = await this.commandBus.execute( - new UpdateProfileCommand(user.sub, dto.fullName, dto.avatarUrl, dto.email), + new UpdateProfileCommand(user.sub, dto.fullName, dto.avatarUrl, dto.email, dto.phoneNumber), ); return { message: 'Cập nhật hồ sơ thành công', data: result }; } + @UseGuards(JwtAuthGuard) + @Post('profile/verify-phone') + @ApiBearerAuth('JWT') + @ApiOperation({ summary: 'Verify phone number change with SMS OTP code' }) + @ApiResponse({ status: 201, description: 'Phone number changed successfully' }) + @ApiResponse({ status: 400, description: 'Invalid or expired OTP code' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 409, description: 'Phone number already in use' }) + async verifyPhoneChange( + @CurrentUser() user: JwtPayload, + @Body() dto: VerifyPhoneChangeDto, + ): Promise<{ message: string; data: VerifyPhoneChangeResultDto }> { + const result: VerifyPhoneChangeResultDto = await this.commandBus.execute( + new VerifyPhoneChangeCommand(user.sub, dto.code), + ); + return { message: 'Số điện thoại đã được cập nhật thành công', data: result }; + } + @UseGuards(JwtAuthGuard) @Post('profile/verify-email') @ApiBearerAuth('JWT') @@ -268,7 +290,7 @@ export class AuthController { @ApiResponse({ status: 400, description: 'Validation error' }) @ApiResponse({ status: 401, description: 'Unauthorized' }) async generateKycUploadUrls( - @Body() body: { files: KycFileRequest[] }, + @Body() body: GenerateKycUploadUrlsDto, @CurrentUser() user: JwtPayload, ): Promise<{ field: string; uploadUrl: string; publicUrl: string; objectKey: string }[]> { return this.commandBus.execute( @@ -284,20 +306,9 @@ export class AuthController { @ApiResponse({ status: 400, description: 'Validation error' }) @ApiResponse({ status: 401, description: 'Unauthorized' }) async submitKyc( - @Body() - body: { - documentType: string; - documentNumber: string; - frontImageUrl: string; - backImageUrl?: string; - selfieUrl?: string; - }, + @Body() body: SubmitKycDto, @CurrentUser() user: JwtPayload, ): Promise<{ message: string }> { - if (!body.frontImageUrl) { - throw new ValidationException('Vui lòng tải ảnh mặt trước giấy tờ'); - } - return this.commandBus.execute( new SubmitKycCommand( user.sub, diff --git a/apps/api/src/modules/auth/presentation/dto/index.ts b/apps/api/src/modules/auth/presentation/dto/index.ts index f8c1906..76fa761 100644 --- a/apps/api/src/modules/auth/presentation/dto/index.ts +++ b/apps/api/src/modules/auth/presentation/dto/index.ts @@ -2,4 +2,5 @@ export { RegisterDto } from './register.dto'; export { LoginDto } from './login.dto'; export { RefreshTokenDto } from './refresh-token.dto'; export { VerifyKycDto } from './verify-kyc.dto'; +export { GenerateKycUploadUrlsDto, KycFileRequestDto } from './generate-kyc-upload-urls.dto'; export { VerifyMfaSetupDto, VerifyMfaChallengeDto, UseBackupCodeDto, DisableMfaDto } from './mfa.dto'; diff --git a/apps/api/src/modules/auth/presentation/dto/update-profile.dto.ts b/apps/api/src/modules/auth/presentation/dto/update-profile.dto.ts index c6e3b0a..e38a375 100644 --- a/apps/api/src/modules/auth/presentation/dto/update-profile.dto.ts +++ b/apps/api/src/modules/auth/presentation/dto/update-profile.dto.ts @@ -21,4 +21,13 @@ export class UpdateProfileDto { @IsOptional() @IsEmail({}, { message: 'Email không hợp lệ' }) email?: string; + + @ApiPropertyOptional({ + example: '0912345678', + description: 'Vietnamese phone number (will trigger SMS OTP re-verification)', + }) + @IsOptional() + @IsString() + @MinLength(9, { message: 'Số điện thoại không hợp lệ' }) + phoneNumber?: string; } diff --git a/apps/api/src/modules/auth/presentation/dto/verify-phone-change.dto.ts b/apps/api/src/modules/auth/presentation/dto/verify-phone-change.dto.ts new file mode 100644 index 0000000..35006bc --- /dev/null +++ b/apps/api/src/modules/auth/presentation/dto/verify-phone-change.dto.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, Length } from 'class-validator'; + +export class VerifyPhoneChangeDto { + @ApiProperty({ example: '123456', description: '6-digit OTP code sent via SMS' }) + @IsNotEmpty({ message: 'Mã xác thực không được để trống' }) + @IsString() + @Length(6, 6, { message: 'Mã xác thực phải gồm 6 chữ số' }) + code!: string; +} diff --git a/apps/api/src/modules/notifications/application/listeners/phone-change-requested.listener.ts b/apps/api/src/modules/notifications/application/listeners/phone-change-requested.listener.ts new file mode 100644 index 0000000..d33aced --- /dev/null +++ b/apps/api/src/modules/notifications/application/listeners/phone-change-requested.listener.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@nestjs/common'; +import { type CommandBus } from '@nestjs/cqrs'; +import { OnEvent } from '@nestjs/event-emitter'; +import { type PhoneChangeRequestedEvent } from '@modules/auth'; +import { type LoggerService } from '@modules/shared'; +import { SendNotificationCommand } from '../commands/send-notification/send-notification.command'; + +@Injectable() +export class PhoneChangeRequestedListener { + constructor( + private readonly commandBus: CommandBus, + private readonly logger: LoggerService, + ) {} + + @OnEvent('user.phone_change_requested', { async: true }) + async handle(event: PhoneChangeRequestedEvent): Promise { + this.logger.log( + `Handling phone change OTP for user ${event.aggregateId}`, + 'PhoneChangeRequestedListener', + ); + + await this.commandBus.execute( + new SendNotificationCommand( + event.aggregateId, + 'SMS', + 'user.phone_change_otp', + { otpCode: event.otpCode }, + event.newPhone, + ), + ); + } +} diff --git a/apps/api/src/modules/notifications/infrastructure/__tests__/template.service.spec.ts b/apps/api/src/modules/notifications/infrastructure/__tests__/template.service.spec.ts index 2550f9f..a23b9bc 100644 --- a/apps/api/src/modules/notifications/infrastructure/__tests__/template.service.spec.ts +++ b/apps/api/src/modules/notifications/infrastructure/__tests__/template.service.spec.ts @@ -81,10 +81,10 @@ describe('TemplateService', () => { expect(result.body).toContain('/listings/2'); }); - it('getTemplateKeys returns all 12 template keys', () => { + it('getTemplateKeys returns all 13 template keys', () => { const keys = service.getTemplateKeys(); - expect(keys).toHaveLength(12); + expect(keys).toHaveLength(13); expect(keys).toContain('user.registered'); expect(keys).toContain('agent.verified'); expect(keys).toContain('listing.approved'); @@ -97,5 +97,6 @@ describe('TemplateService', () => { expect(keys).toContain('saved_search_alert'); expect(keys).toContain('saved_search_digest'); expect(keys).toContain('user.email_change_otp'); + expect(keys).toContain('user.phone_change_otp'); }); }); diff --git a/apps/api/src/modules/notifications/infrastructure/services/template.service.ts b/apps/api/src/modules/notifications/infrastructure/services/template.service.ts index 3d9f7b3..4566864 100644 --- a/apps/api/src/modules/notifications/infrastructure/services/template.service.ts +++ b/apps/api/src/modules/notifications/infrastructure/services/template.service.ts @@ -86,6 +86,10 @@ const TEMPLATES: Record = {

Nếu bạn không yêu cầu, hãy bỏ qua email này.

Trân trọng,
Đội ngũ GoodGo

`, }, + 'user.phone_change_otp': { + subject: 'Xác nhận thay đổi số điện thoại — GoodGo', + body: `Mã xác nhận thay đổi số điện thoại GoodGo: {{otpCode}}. Mã có hiệu lực trong 10 phút. Nếu bạn không yêu cầu, hãy bỏ qua tin nhắn này.`, + }, 'saved_search_alert': { subject: 'Tin mới phù hợp tìm kiếm "{{searchName}}"', body: `

Xin chào {{userName}}!

diff --git a/apps/api/src/modules/notifications/notifications.module.ts b/apps/api/src/modules/notifications/notifications.module.ts index d483391..26f025b 100644 --- a/apps/api/src/modules/notifications/notifications.module.ts +++ b/apps/api/src/modules/notifications/notifications.module.ts @@ -11,6 +11,7 @@ import { ListingSoldListener } from './application/listeners/listing-sold.listen import { PaymentCompletedListener } from './application/listeners/payment-completed.listener'; import { PaymentFailedListener } from './application/listeners/payment-failed.listener'; import { PaymentRefundedListener } from './application/listeners/payment-refunded.listener'; +import { PhoneChangeRequestedListener } from './application/listeners/phone-change-requested.listener'; import { QuotaExceededListener } from './application/listeners/quota-exceeded.listener'; import { SubscriptionExpiredListener } from './application/listeners/subscription-expired.listener'; import { SubscriptionExpiringListener } from './application/listeners/subscription-expiring.listener'; @@ -48,6 +49,7 @@ const EventListeners = [ ListingSoldListener, UserKycUpdatedListener, EmailChangeRequestedListener, + PhoneChangeRequestedListener, ]; @Module({