feat(auth): add phoneNumber to profile update with SMS OTP re-verify

TEC-2722 — PATCH /api/v1/auth/profile now accepts phoneNumber alongside
fullName, avatarUrl, and email. Phone changes are deferred until the user
confirms the SMS OTP via POST /api/v1/auth/profile/verify-phone, mirroring
the existing email-change OTP flow.

- Add PhoneChangeRequestedEvent + user.phone_change_otp SMS template
- Add VerifyPhoneChangeHandler with Redis-backed 10-minute OTP
- Re-check phone uniqueness at verify time to catch races
- Extend unit tests for UpdateProfileHandler + add VerifyPhoneChangeHandler spec

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-18 00:17:12 +07:00
parent 78e46a024b
commit e18390ead9
19 changed files with 451 additions and 19 deletions

View File

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

View File

@@ -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');
});
});

View File

@@ -86,6 +86,10 @@ const TEMPLATES: Record<string, TemplateDefinition> = {
<p>Nếu bạn không yêu cầu, hãy bỏ qua email này.</p>
<p>Trân trọng,<br/>Đội ngũ GoodGo</p>`,
},
'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: `<h1>Xin chào {{userName}}!</h1>

View File

@@ -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({