feat(auth): add OTP verification for email changes on profile update
Email changes via PATCH /api/v1/auth/profile now require OTP verification instead of updating immediately. A 6-digit code is sent to the new email address and must be confirmed via POST /api/v1/auth/profile/verify-email within 10 minutes. Also fixes pre-existing web valuation test failures (formatPrice output format, removed comparables section, missing QueryClientProvider wrapper). Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -31,6 +31,8 @@ describe('UpdateProfileHandler', () => {
|
||||
let handler: UpdateProfileHandler;
|
||||
let mockUserRepo: { [K in keyof IUserRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockCache: { invalidate: ReturnType<typeof vi.fn> };
|
||||
let mockRedis: { set: ReturnType<typeof vi.fn>; get: ReturnType<typeof vi.fn>; del: ReturnType<typeof vi.fn> };
|
||||
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockUserRepo = {
|
||||
@@ -45,10 +47,18 @@ describe('UpdateProfileHandler', () => {
|
||||
updateBackupCodes: vi.fn(),
|
||||
};
|
||||
mockCache = { invalidate: vi.fn().mockResolvedValue(undefined) };
|
||||
mockRedis = {
|
||||
set: vi.fn().mockResolvedValue(undefined),
|
||||
get: vi.fn().mockResolvedValue(null),
|
||||
del: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
mockEventBus = { publish: vi.fn() };
|
||||
|
||||
handler = new UpdateProfileHandler(
|
||||
mockUserRepo as any,
|
||||
mockCache as any,
|
||||
mockRedis as any,
|
||||
mockEventBus as any,
|
||||
{ error: vi.fn() } as any,
|
||||
);
|
||||
});
|
||||
@@ -82,7 +92,7 @@ describe('UpdateProfileHandler', () => {
|
||||
expect(mockUserRepo.update).toHaveBeenCalledWith(user);
|
||||
});
|
||||
|
||||
it('updates email with uniqueness check', async () => {
|
||||
it('defers email change via OTP instead of updating directly', async () => {
|
||||
const user = createTestUser();
|
||||
mockUserRepo.findById.mockResolvedValue(user);
|
||||
mockUserRepo.findByEmail.mockResolvedValue(null);
|
||||
@@ -91,12 +101,27 @@ describe('UpdateProfileHandler', () => {
|
||||
const command = new UpdateProfileCommand('user-1', undefined, undefined, 'new@example.com');
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.email).toBe('new@example.com');
|
||||
expect(mockUserRepo.findByEmail).toHaveBeenCalledWith('new@example.com');
|
||||
expect(mockUserRepo.update).toHaveBeenCalledWith(user);
|
||||
// Email should NOT be updated yet — it is deferred pending OTP
|
||||
expect(result.email).toBeNull();
|
||||
expect(result.emailChangePending).toBe(true);
|
||||
|
||||
// OTP stored in Redis
|
||||
expect(mockRedis.set).toHaveBeenCalledWith(
|
||||
'auth:email_change_otp:user-1',
|
||||
expect.stringContaining('new@example.com'),
|
||||
600,
|
||||
);
|
||||
|
||||
// Event emitted for notification
|
||||
expect(mockEventBus.publish).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
eventName: 'user.email_change_requested',
|
||||
newEmail: 'new@example.com',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws ConflictException when email is already taken', async () => {
|
||||
it('throws ConflictException when new email is already taken', async () => {
|
||||
const user = createTestUser();
|
||||
const otherUser = createTestUser({ id: 'user-2', email: 'taken@example.com' });
|
||||
mockUserRepo.findById.mockResolvedValue(user);
|
||||
@@ -106,30 +131,17 @@ describe('UpdateProfileHandler', () => {
|
||||
await expect(handler.execute(command)).rejects.toThrow('Email đã được sử dụng');
|
||||
});
|
||||
|
||||
it('skips email uniqueness check when email is unchanged', async () => {
|
||||
it('skips OTP when email is unchanged', async () => {
|
||||
const user = createTestUser({ email: 'same@example.com' });
|
||||
mockUserRepo.findById.mockResolvedValue(user);
|
||||
mockUserRepo.update.mockResolvedValue(undefined);
|
||||
|
||||
const command = new UpdateProfileCommand('user-1', undefined, undefined, 'same@example.com');
|
||||
await handler.execute(command);
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(mockUserRepo.findByEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows same user to keep their own email', async () => {
|
||||
const user = createTestUser({ email: 'mine@example.com' });
|
||||
mockUserRepo.findById.mockResolvedValue(user);
|
||||
mockUserRepo.findByEmail.mockResolvedValue(user);
|
||||
mockUserRepo.update.mockResolvedValue(undefined);
|
||||
|
||||
// Email resolves to same user — should not conflict
|
||||
// This case is actually covered by the unchanged check above,
|
||||
// but keeping explicit for safety
|
||||
const command = new UpdateProfileCommand('user-1', undefined, undefined, 'mine@example.com');
|
||||
await handler.execute(command);
|
||||
|
||||
expect(mockUserRepo.update).toHaveBeenCalled();
|
||||
expect(mockRedis.set).not.toHaveBeenCalled();
|
||||
expect(mockEventBus.publish).not.toHaveBeenCalled();
|
||||
expect(result.emailChangePending).toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws NotFoundException when user does not exist', async () => {
|
||||
@@ -157,7 +169,7 @@ describe('UpdateProfileHandler', () => {
|
||||
await expect(handler.execute(command)).rejects.toThrow('Email không hợp lệ');
|
||||
});
|
||||
|
||||
it('updates all fields at once', async () => {
|
||||
it('updates fullName and avatarUrl while deferring email', async () => {
|
||||
const user = createTestUser();
|
||||
mockUserRepo.findById.mockResolvedValue(user);
|
||||
mockUserRepo.findByEmail.mockResolvedValue(null);
|
||||
@@ -173,7 +185,9 @@ describe('UpdateProfileHandler', () => {
|
||||
|
||||
expect(result.fullName).toBe('Le Thi C');
|
||||
expect(result.avatarUrl).toBe('https://cdn.example.com/new.jpg');
|
||||
expect(result.email).toBe('new@example.com');
|
||||
// Email deferred
|
||||
expect(result.email).toBeNull();
|
||||
expect(result.emailChangePending).toBe(true);
|
||||
expect(mockUserRepo.update).toHaveBeenCalledWith(user);
|
||||
expect(mockCache.invalidate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
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 { Email } from '../../domain/value-objects/email.vo';
|
||||
import { Phone } from '../../domain/value-objects/phone.vo';
|
||||
import { VerifyEmailChangeCommand } from '../commands/verify-email-change/verify-email-change.command';
|
||||
import { VerifyEmailChangeHandler } from '../commands/verify-email-change/verify-email-change.handler';
|
||||
|
||||
function createTestUser(overrides?: Partial<{ email: string; id: string }>): UserEntity {
|
||||
const phone = Phone.create('0912345678').unwrap();
|
||||
const pw = { value: 'hashed' } as HashedPassword;
|
||||
const email = overrides?.email ? Email.create(overrides.email).unwrap() : null;
|
||||
return new UserEntity(overrides?.id ?? 'user-1', {
|
||||
email,
|
||||
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('VerifyEmailChangeHandler', () => {
|
||||
let handler: VerifyEmailChangeHandler;
|
||||
let mockUserRepo: { [K in keyof IUserRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockRedis: { get: ReturnType<typeof vi.fn>; del: ReturnType<typeof vi.fn>; set: ReturnType<typeof vi.fn> };
|
||||
let mockCache: { invalidate: ReturnType<typeof vi.fn> };
|
||||
|
||||
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 VerifyEmailChangeHandler(
|
||||
mockUserRepo as any,
|
||||
mockRedis as any,
|
||||
mockCache as any,
|
||||
{ error: vi.fn() } as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('verifies OTP and updates email', async () => {
|
||||
const user = createTestUser();
|
||||
const payload = JSON.stringify({ newEmail: 'new@example.com', code: '123456' });
|
||||
mockRedis.get.mockResolvedValue(payload);
|
||||
mockUserRepo.findById.mockResolvedValue(user);
|
||||
mockUserRepo.findByEmail.mockResolvedValue(null);
|
||||
mockUserRepo.update.mockResolvedValue(undefined);
|
||||
|
||||
const command = new VerifyEmailChangeCommand('user-1', '123456');
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.email).toBe('new@example.com');
|
||||
expect(result.id).toBe('user-1');
|
||||
expect(mockRedis.del).toHaveBeenCalledWith('auth:email_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 VerifyEmailChangeCommand('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({ newEmail: 'new@example.com', code: '123456' });
|
||||
mockRedis.get.mockResolvedValue(payload);
|
||||
|
||||
const command = new VerifyEmailChangeCommand('user-1', '999999');
|
||||
await expect(handler.execute(command)).rejects.toThrow('không đúng');
|
||||
});
|
||||
|
||||
it('throws ConflictException when email was taken since OTP was issued', async () => {
|
||||
const user = createTestUser();
|
||||
const otherUser = createTestUser({ id: 'user-2', email: 'new@example.com' });
|
||||
const payload = JSON.stringify({ newEmail: 'new@example.com', code: '123456' });
|
||||
mockRedis.get.mockResolvedValue(payload);
|
||||
mockUserRepo.findById.mockResolvedValue(user);
|
||||
mockUserRepo.findByEmail.mockResolvedValue(otherUser);
|
||||
|
||||
const command = new VerifyEmailChangeCommand('user-1', '123456');
|
||||
await expect(handler.execute(command)).rejects.toThrow('Email đã được sử dụng');
|
||||
|
||||
// OTP should be cleaned up on conflict
|
||||
expect(mockRedis.del).toHaveBeenCalledWith('auth:email_change_otp:user-1');
|
||||
});
|
||||
|
||||
it('throws NotFoundException when user does not exist', async () => {
|
||||
const payload = JSON.stringify({ newEmail: 'new@example.com', code: '123456' });
|
||||
mockRedis.get.mockResolvedValue(payload);
|
||||
mockUserRepo.findById.mockResolvedValue(null);
|
||||
|
||||
const command = new VerifyEmailChangeCommand('user-1', '123456');
|
||||
await expect(handler.execute(command)).rejects.toThrow('Người dùng');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { CommandHandler, EventBus, ICommandHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
CachePrefix,
|
||||
CacheService,
|
||||
@@ -7,17 +7,27 @@ import {
|
||||
DomainException,
|
||||
LoggerService,
|
||||
NotFoundException,
|
||||
RedisService,
|
||||
ValidationException,
|
||||
} from '@modules/shared';
|
||||
import { randomInt } from 'crypto';
|
||||
import { Email } from '../../../domain/value-objects/email.vo';
|
||||
import { EmailChangeRequestedEvent } from '../../../domain/events/email-change-requested.event';
|
||||
import { IUserRepository, USER_REPOSITORY } from '../../../domain/repositories/user.repository';
|
||||
import { UpdateProfileCommand } from './update-profile.command';
|
||||
|
||||
/** TTL for email-change OTP codes stored in Redis (10 minutes). */
|
||||
const EMAIL_CHANGE_OTP_TTL = 600;
|
||||
|
||||
/** Redis key prefix for pending email-change OTP. */
|
||||
export const EMAIL_CHANGE_OTP_PREFIX = 'auth:email_change_otp';
|
||||
|
||||
export interface UpdateProfileResultDto {
|
||||
id: string;
|
||||
fullName: string;
|
||||
avatarUrl: string | null;
|
||||
email: string | null;
|
||||
emailChangePending?: boolean;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
@@ -26,6 +36,8 @@ export class UpdateProfileHandler implements ICommandHandler<UpdateProfileComman
|
||||
constructor(
|
||||
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
|
||||
private readonly cache: CacheService,
|
||||
private readonly redis: RedisService,
|
||||
private readonly eventBus: EventBus,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
@@ -36,8 +48,9 @@ export class UpdateProfileHandler implements ICommandHandler<UpdateProfileComman
|
||||
throw new NotFoundException('Người dùng', command.userId);
|
||||
}
|
||||
|
||||
// Validate and resolve email if provided
|
||||
let newEmail: Email | null | undefined;
|
||||
let emailChangePending = false;
|
||||
|
||||
// Validate and handle email change via OTP
|
||||
if (command.email !== undefined) {
|
||||
const emailResult = Email.create(command.email);
|
||||
if (emailResult.isErr) {
|
||||
@@ -52,11 +65,27 @@ export class UpdateProfileHandler implements ICommandHandler<UpdateProfileComman
|
||||
if (existingUser && existingUser.id !== command.userId) {
|
||||
throw new ConflictException('Email đã được sử dụng bởi tài khoản khác');
|
||||
}
|
||||
newEmail = email;
|
||||
|
||||
// Generate OTP and store pending change in Redis
|
||||
const otpCode = String(randomInt(100_000, 999_999));
|
||||
const payload = JSON.stringify({ newEmail: email.value, code: otpCode });
|
||||
await this.redis.set(
|
||||
`${EMAIL_CHANGE_OTP_PREFIX}:${command.userId}`,
|
||||
payload,
|
||||
EMAIL_CHANGE_OTP_TTL,
|
||||
);
|
||||
|
||||
// Emit event so notifications module can send the OTP email
|
||||
this.eventBus.publish(
|
||||
new EmailChangeRequestedEvent(command.userId, email.value, otpCode),
|
||||
);
|
||||
|
||||
emailChangePending = true;
|
||||
}
|
||||
}
|
||||
|
||||
user.updateProfile(command.fullName, command.avatarUrl, newEmail);
|
||||
// Apply non-email fields immediately
|
||||
user.updateProfile(command.fullName, command.avatarUrl, undefined);
|
||||
await this.userRepo.update(user);
|
||||
|
||||
await this.cache.invalidate(
|
||||
@@ -68,6 +97,7 @@ export class UpdateProfileHandler implements ICommandHandler<UpdateProfileComman
|
||||
fullName: user.fullName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
email: user.email?.value ?? null,
|
||||
...(emailChangePending ? { emailChangePending: true } : {}),
|
||||
updatedAt: user.updatedAt,
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export class VerifyEmailChangeCommand {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly code: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
CachePrefix,
|
||||
CacheService,
|
||||
ConflictException,
|
||||
DomainException,
|
||||
LoggerService,
|
||||
NotFoundException,
|
||||
RedisService,
|
||||
ValidationException,
|
||||
} from '@modules/shared';
|
||||
import { Email } from '../../../domain/value-objects/email.vo';
|
||||
import { IUserRepository, USER_REPOSITORY } from '../../../domain/repositories/user.repository';
|
||||
import { EMAIL_CHANGE_OTP_PREFIX } from '../update-profile/update-profile.handler';
|
||||
import { VerifyEmailChangeCommand } from './verify-email-change.command';
|
||||
|
||||
export interface VerifyEmailChangeResultDto {
|
||||
id: string;
|
||||
email: string;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
@CommandHandler(VerifyEmailChangeCommand)
|
||||
export class VerifyEmailChangeHandler implements ICommandHandler<VerifyEmailChangeCommand> {
|
||||
constructor(
|
||||
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
|
||||
private readonly redis: RedisService,
|
||||
private readonly cache: CacheService,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async execute(command: VerifyEmailChangeCommand): Promise<VerifyEmailChangeResultDto> {
|
||||
try {
|
||||
const redisKey = `${EMAIL_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 email lại.',
|
||||
);
|
||||
}
|
||||
|
||||
const { newEmail, code } = JSON.parse(raw) as { newEmail: 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 email uniqueness (may have been taken since the request)
|
||||
const existingUser = await this.userRepo.findByEmail(newEmail);
|
||||
if (existingUser && existingUser.id !== command.userId) {
|
||||
await this.redis.del(redisKey);
|
||||
throw new ConflictException('Email đã được sử dụng bởi tài khoản khác');
|
||||
}
|
||||
|
||||
const emailVo = Email.create(newEmail).unwrap();
|
||||
user.updateProfile(undefined, undefined, emailVo);
|
||||
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,
|
||||
email: emailVo.value,
|
||||
updatedAt: user.updatedAt,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DomainException) throw error;
|
||||
this.logger.error(
|
||||
`Failed to verify email 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 email');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ export { VerifyKycCommand } from './commands/verify-kyc/verify-kyc.command';
|
||||
export { VerifyKycHandler } from './commands/verify-kyc/verify-kyc.handler';
|
||||
export { UpdateProfileCommand } from './commands/update-profile/update-profile.command';
|
||||
export { UpdateProfileHandler, type UpdateProfileResultDto } from './commands/update-profile/update-profile.handler';
|
||||
export { VerifyEmailChangeCommand } from './commands/verify-email-change/verify-email-change.command';
|
||||
export { VerifyEmailChangeHandler, type VerifyEmailChangeResultDto } from './commands/verify-email-change/verify-email-change.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';
|
||||
|
||||
@@ -21,6 +21,7 @@ import { GenerateKycUploadUrlsHandler } from './application/commands/generate-ky
|
||||
import { SubmitKycHandler } from './application/commands/submit-kyc/submit-kyc.handler';
|
||||
import { UpdateProfileHandler } from './application/commands/update-profile/update-profile.handler';
|
||||
import { UseBackupCodeHandler } from './application/commands/use-backup-code/use-backup-code.handler';
|
||||
import { VerifyEmailChangeHandler } from './application/commands/verify-email-change/verify-email-change.handler';
|
||||
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';
|
||||
@@ -53,6 +54,7 @@ const CommandHandlers = [
|
||||
SubmitKycHandler,
|
||||
GenerateKycUploadUrlsHandler,
|
||||
UpdateProfileHandler,
|
||||
VerifyEmailChangeHandler,
|
||||
RequestUserDeletionHandler,
|
||||
CancelUserDeletionHandler,
|
||||
ForceDeleteUserHandler,
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { DomainEvent } from '@modules/shared';
|
||||
|
||||
export class EmailChangeRequestedEvent implements DomainEvent {
|
||||
readonly eventName = 'user.email_change_requested';
|
||||
readonly occurredAt = new Date();
|
||||
|
||||
constructor(
|
||||
public readonly aggregateId: string,
|
||||
public readonly newEmail: string,
|
||||
public readonly otpCode: string,
|
||||
) {}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export { UserRegisteredEvent } from './user-registered.event';
|
||||
export { AgentVerifiedEvent } from './agent-verified.event';
|
||||
export { EmailChangeRequestedEvent } from './email-change-requested.event';
|
||||
|
||||
@@ -11,4 +11,5 @@ export { AgentVerifiedEvent } from './domain/events/agent-verified.event';
|
||||
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 { USER_REPOSITORY, IUserRepository } from './domain/repositories/user.repository';
|
||||
|
||||
@@ -32,6 +32,8 @@ import { type KycUploadUrlResult } from '../../application/commands/generate-kyc
|
||||
import { SubmitKycCommand } from '../../application/commands/submit-kyc/submit-kyc.command';
|
||||
import { UpdateProfileCommand } from '../../application/commands/update-profile/update-profile.command';
|
||||
import { type UpdateProfileResultDto } from '../../application/commands/update-profile/update-profile.handler';
|
||||
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 { 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';
|
||||
@@ -46,6 +48,7 @@ import { type RefreshTokenDto } from '../dto/refresh-token.dto';
|
||||
import { type RegisterDto } from '../dto/register.dto';
|
||||
import { type VerifyKycDto } from '../dto/verify-kyc.dto';
|
||||
import { UpdateProfileDto } from '../dto/update-profile.dto';
|
||||
import { VerifyEmailChangeDto } from '../dto/verify-email-change.dto';
|
||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||
import { LocalAuthGuard } from '../guards/local-auth.guard';
|
||||
import { RolesGuard } from '../guards/roles.guard';
|
||||
@@ -235,6 +238,24 @@ export class AuthController {
|
||||
return { message: 'Cập nhật hồ sơ thành công', data: result };
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('profile/verify-email')
|
||||
@ApiBearerAuth('JWT')
|
||||
@ApiOperation({ summary: 'Verify email change with OTP code' })
|
||||
@ApiResponse({ status: 201, description: 'Email changed successfully' })
|
||||
@ApiResponse({ status: 400, description: 'Invalid or expired OTP code' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
@ApiResponse({ status: 409, description: 'Email already in use' })
|
||||
async verifyEmailChange(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Body() dto: VerifyEmailChangeDto,
|
||||
): Promise<{ message: string; data: VerifyEmailChangeResultDto }> {
|
||||
const result: VerifyEmailChangeResultDto = await this.commandBus.execute(
|
||||
new VerifyEmailChangeCommand(user.sub, dto.code),
|
||||
);
|
||||
return { message: 'Email đã được cập nhật thành công', data: result };
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('profile/agent')
|
||||
@ApiBearerAuth('JWT')
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString, Length } from 'class-validator';
|
||||
|
||||
export class VerifyEmailChangeDto {
|
||||
@ApiProperty({ example: '123456', description: '6-digit OTP code sent to new email' })
|
||||
@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;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CommandBus } from '@nestjs/cqrs';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { EmailChangeRequestedEvent } from '@modules/auth';
|
||||
import { LoggerService } from '@modules/shared';
|
||||
import { SendNotificationCommand } from '../commands/send-notification/send-notification.command';
|
||||
|
||||
@Injectable()
|
||||
export class EmailChangeRequestedListener {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
@OnEvent('user.email_change_requested', { async: true })
|
||||
async handle(event: EmailChangeRequestedEvent): Promise<void> {
|
||||
this.logger.log(
|
||||
`Handling email change OTP for user ${event.aggregateId}`,
|
||||
'EmailChangeRequestedListener',
|
||||
);
|
||||
|
||||
await this.commandBus.execute(
|
||||
new SendNotificationCommand(
|
||||
event.aggregateId,
|
||||
'EMAIL',
|
||||
'user.email_change_otp',
|
||||
{ otpCode: event.otpCode },
|
||||
event.newEmail,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -81,10 +81,10 @@ describe('TemplateService', () => {
|
||||
expect(result.body).toContain('/listings/2');
|
||||
});
|
||||
|
||||
it('getTemplateKeys returns all 11 template keys', () => {
|
||||
it('getTemplateKeys returns all 12 template keys', () => {
|
||||
const keys = service.getTemplateKeys();
|
||||
|
||||
expect(keys).toHaveLength(11);
|
||||
expect(keys).toHaveLength(12);
|
||||
expect(keys).toContain('user.registered');
|
||||
expect(keys).toContain('agent.verified');
|
||||
expect(keys).toContain('listing.approved');
|
||||
@@ -96,5 +96,6 @@ describe('TemplateService', () => {
|
||||
expect(keys).toContain('subscription.expiring');
|
||||
expect(keys).toContain('saved_search_alert');
|
||||
expect(keys).toContain('saved_search_digest');
|
||||
expect(keys).toContain('user.email_change_otp');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -75,6 +75,15 @@ const TEMPLATES: Record<string, TemplateDefinition> = {
|
||||
body: `<h1>Gói đăng ký đã bị huỷ</h1>
|
||||
<p>Gói <strong>{{planTier}}</strong> của bạn đã bị huỷ.</p>
|
||||
<p>Bạn có thể đăng ký lại bất cứ lúc nào để tiếp tục sử dụng đầy đủ tính năng.</p>
|
||||
<p>Trân trọng,<br/>Đội ngũ GoodGo</p>`,
|
||||
},
|
||||
'user.email_change_otp': {
|
||||
subject: 'Xác nhận thay đổi email — GoodGo',
|
||||
body: `<h1>Xác nhận thay đổi email</h1>
|
||||
<p>Bạn đã yêu cầu thay đổi email trên GoodGo. Sử dụng mã OTP sau để xác nhận:</p>
|
||||
<p style="font-size:24px;font-weight:bold;letter-spacing:4px;text-align:center;margin:24px 0;">{{otpCode}}</p>
|
||||
<p>Mã có hiệu lực trong 10 phút.</p>
|
||||
<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>`,
|
||||
},
|
||||
'saved_search_alert': {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CqrsModule } from '@nestjs/cqrs';
|
||||
import { AuthModule } from '@modules/auth';
|
||||
import { SendNotificationHandler } from './application/commands/send-notification/send-notification.handler';
|
||||
import { AgentVerifiedListener } from './application/listeners/agent-verified.listener';
|
||||
import { EmailChangeRequestedListener } from './application/listeners/email-change-requested.listener';
|
||||
import { InquiryReceivedListener } from './application/listeners/inquiry-received.listener';
|
||||
import { ListingApprovedListener } from './application/listeners/listing-approved.listener';
|
||||
import { ListingRejectedListener } from './application/listeners/listing-rejected.listener';
|
||||
@@ -21,8 +23,11 @@ import { PrismaNotificationPreferenceRepository } from './infrastructure/reposit
|
||||
import { PrismaNotificationRepository } from './infrastructure/repositories/prisma-notification.repository';
|
||||
import { EmailService } from './infrastructure/services/email.service';
|
||||
import { FcmService } from './infrastructure/services/fcm.service';
|
||||
import { StringeeSmsService } from './infrastructure/services/stringee-sms.service';
|
||||
import { TemplateService } from './infrastructure/services/template.service';
|
||||
import { ZaloOaService } from './infrastructure/services/zalo-oa.service';
|
||||
import { NotificationsController } from './presentation/controllers/notifications.controller';
|
||||
import { NotificationsGateway } from './presentation/gateways/notifications.gateway';
|
||||
|
||||
const CommandHandlers = [SendNotificationHandler];
|
||||
|
||||
@@ -41,10 +46,11 @@ const EventListeners = [
|
||||
InquiryReceivedListener,
|
||||
ListingSoldListener,
|
||||
UserKycUpdatedListener,
|
||||
EmailChangeRequestedListener,
|
||||
];
|
||||
|
||||
@Module({
|
||||
imports: [CqrsModule],
|
||||
imports: [CqrsModule, AuthModule],
|
||||
controllers: [NotificationsController],
|
||||
providers: [
|
||||
// Repositories
|
||||
@@ -54,14 +60,19 @@ const EventListeners = [
|
||||
// Services
|
||||
EmailService,
|
||||
FcmService,
|
||||
StringeeSmsService,
|
||||
ZaloOaService,
|
||||
TemplateService,
|
||||
|
||||
// WebSocket Gateway
|
||||
NotificationsGateway,
|
||||
|
||||
// CQRS
|
||||
...CommandHandlers,
|
||||
|
||||
// Event Listeners
|
||||
...EventListeners,
|
||||
],
|
||||
exports: [EmailService, FcmService, TemplateService],
|
||||
exports: [EmailService, FcmService, StringeeSmsService, ZaloOaService, TemplateService, NotificationsGateway],
|
||||
})
|
||||
export class NotificationsModule {}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ValuationForm } from '../valuation-form';
|
||||
|
||||
@@ -7,6 +8,11 @@ vi.mock('@hookform/resolvers/zod', () => ({
|
||||
zodResolver: () => vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock useProjectSearch hook used by ValuationForm
|
||||
vi.mock('@/lib/hooks/use-valuation', () => ({
|
||||
useProjectSearch: () => ({ data: [], isLoading: false }),
|
||||
}));
|
||||
|
||||
// Mock valuation validation
|
||||
vi.mock('@/lib/validations/valuation', () => ({
|
||||
valuationFormSchema: {},
|
||||
@@ -23,84 +29,93 @@ vi.mock('@/lib/validations/valuation', () => ({
|
||||
],
|
||||
}));
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||
};
|
||||
}
|
||||
|
||||
describe('ValuationForm', () => {
|
||||
it('renders form title', () => {
|
||||
render(<ValuationForm onSubmit={vi.fn()} />);
|
||||
render(<ValuationForm onSubmit={vi.fn()} />, { wrapper: createWrapper() });
|
||||
expect(screen.getByText('Định giá bất động sản')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders property type select', () => {
|
||||
render(<ValuationForm onSubmit={vi.fn()} />);
|
||||
render(<ValuationForm onSubmit={vi.fn()} />, { wrapper: createWrapper() });
|
||||
expect(screen.getByLabelText('Loại bất động sản *')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders city select', () => {
|
||||
render(<ValuationForm onSubmit={vi.fn()} />);
|
||||
render(<ValuationForm onSubmit={vi.fn()} />, { wrapper: createWrapper() });
|
||||
expect(screen.getByLabelText('Tỉnh/Thành phố *')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders district input', () => {
|
||||
render(<ValuationForm onSubmit={vi.fn()} />);
|
||||
render(<ValuationForm onSubmit={vi.fn()} />, { wrapper: createWrapper() });
|
||||
expect(screen.getByLabelText('Quận/Huyện *')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders area input', () => {
|
||||
render(<ValuationForm onSubmit={vi.fn()} />);
|
||||
render(<ValuationForm onSubmit={vi.fn()} />, { wrapper: createWrapper() });
|
||||
expect(screen.getByLabelText('Diện tích (m²) *')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders bedroom, bathroom, floors inputs', () => {
|
||||
render(<ValuationForm onSubmit={vi.fn()} />);
|
||||
render(<ValuationForm onSubmit={vi.fn()} />, { wrapper: createWrapper() });
|
||||
expect(screen.getByLabelText('Phòng ngủ')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Phòng tắm')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Số tầng')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders frontage and road width inputs', () => {
|
||||
render(<ValuationForm onSubmit={vi.fn()} />);
|
||||
render(<ValuationForm onSubmit={vi.fn()} />, { wrapper: createWrapper() });
|
||||
expect(screen.getByLabelText('Mặt tiền (m)')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Độ rộng đường (m)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders year built input', () => {
|
||||
render(<ValuationForm onSubmit={vi.fn()} />);
|
||||
render(<ValuationForm onSubmit={vi.fn()} />, { wrapper: createWrapper() });
|
||||
expect(screen.getByLabelText('Năm xây dựng')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders legal paper checkbox', () => {
|
||||
render(<ValuationForm onSubmit={vi.fn()} />);
|
||||
render(<ValuationForm onSubmit={vi.fn()} />, { wrapper: createWrapper() });
|
||||
expect(screen.getByLabelText('Có sổ đỏ/giấy tờ hợp pháp')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders submit button', () => {
|
||||
render(<ValuationForm onSubmit={vi.fn()} />);
|
||||
render(<ValuationForm onSubmit={vi.fn()} />, { wrapper: createWrapper() });
|
||||
expect(screen.getByText('Định giá ngay')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows loading text when isLoading', () => {
|
||||
render(<ValuationForm onSubmit={vi.fn()} isLoading={true} />);
|
||||
render(<ValuationForm onSubmit={vi.fn()} isLoading={true} />, { wrapper: createWrapper() });
|
||||
expect(screen.getByText('Đang định giá...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables submit button when loading', () => {
|
||||
render(<ValuationForm onSubmit={vi.fn()} isLoading={true} />);
|
||||
render(<ValuationForm onSubmit={vi.fn()} isLoading={true} />, { wrapper: createWrapper() });
|
||||
expect(screen.getByText('Đang định giá...')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('renders property type options', () => {
|
||||
render(<ValuationForm onSubmit={vi.fn()} />);
|
||||
render(<ValuationForm onSubmit={vi.fn()} />, { wrapper: createWrapper() });
|
||||
expect(screen.getByText('Căn hộ')).toBeInTheDocument();
|
||||
expect(screen.getByText('Nhà riêng')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders city options', () => {
|
||||
render(<ValuationForm onSubmit={vi.fn()} />);
|
||||
render(<ValuationForm onSubmit={vi.fn()} />, { wrapper: createWrapper() });
|
||||
expect(screen.getByText('Hồ Chí Minh')).toBeInTheDocument();
|
||||
expect(screen.getByText('Hà Nội')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders description text', () => {
|
||||
render(<ValuationForm onSubmit={vi.fn()} />);
|
||||
render(<ValuationForm onSubmit={vi.fn()} />, { wrapper: createWrapper() });
|
||||
expect(screen.getByText(/Nhập thông tin bất động sản để nhận ước tính giá từ AI/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -92,8 +92,8 @@ describe('ValuationHistory', () => {
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('5.00 tỷ')).toBeInTheDocument();
|
||||
expect(screen.getByText('8.50 tỷ')).toBeInTheDocument();
|
||||
expect(screen.getByText('5 tỷ')).toBeInTheDocument();
|
||||
expect(screen.getByText('8.5 tỷ')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onSelect when an item is clicked', async () => {
|
||||
|
||||
@@ -43,7 +43,7 @@ const mockResult: ValuationResult = {
|
||||
describe('ValuationResults', () => {
|
||||
it('renders estimated price', () => {
|
||||
render(<ValuationResults result={mockResult} />);
|
||||
expect(screen.getByText('5.00 tỷ VNĐ')).toBeInTheDocument();
|
||||
expect(screen.getByText('5 tỷ VNĐ')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders confidence percentage', () => {
|
||||
@@ -58,7 +58,8 @@ describe('ValuationResults', () => {
|
||||
|
||||
it('renders price range', () => {
|
||||
render(<ValuationResults result={mockResult} />);
|
||||
expect(screen.getByText(/4\.50 tỷ.*5\.50 tỷ/)).toBeInTheDocument();
|
||||
expect(screen.getByText('4.5 tỷ')).toBeInTheDocument();
|
||||
expect(screen.getByText('5.5 tỷ')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders price drivers section', () => {
|
||||
@@ -78,33 +79,10 @@ describe('ValuationResults', () => {
|
||||
expect(screen.getByText('-5.2%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders comparables section', () => {
|
||||
render(<ValuationResults result={mockResult} />);
|
||||
expect(screen.getByText('Bất động sản tương tự')).toBeInTheDocument();
|
||||
expect(screen.getByText('Căn hộ tương tự A')).toBeInTheDocument();
|
||||
expect(screen.getByText('Căn hộ tương tự B')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows comparable count', () => {
|
||||
render(<ValuationResults result={mockResult} />);
|
||||
expect(screen.getByText(/2 bất động sản/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows similarity percentage for comparables', () => {
|
||||
render(<ValuationResults result={mockResult} />);
|
||||
expect(screen.getByText('92% tương tự')).toBeInTheDocument();
|
||||
expect(screen.getByText('85% tương tự')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides drivers section when empty', () => {
|
||||
const noDrivers = { ...mockResult, priceDrivers: [] };
|
||||
render(<ValuationResults result={noDrivers} />);
|
||||
expect(screen.queryByText('Yếu tố ảnh hưởng giá')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides comparables section when empty', () => {
|
||||
const noComps = { ...mockResult, comparables: [] };
|
||||
render(<ValuationResults result={noComps} />);
|
||||
expect(screen.queryByText('Bất động sản tương tự')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user