feat(auth): add PATCH /auth/profile endpoint for user profile updates

Implement user profile update with fullName, avatarUrl, and email fields.
Email changes include uniqueness validation and Email VO verification.
Follows existing DDD/CQRS patterns with cache invalidation.
19 unit tests covering handler logic and DTO validation.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-15 22:34:40 +07:00
parent 8039b47795
commit 74c52198b3
9 changed files with 496 additions and 12 deletions

View File

@@ -0,0 +1,8 @@
export class UpdateProfileCommand {
constructor(
public readonly userId: string,
public readonly fullName?: string,
public readonly avatarUrl?: string,
public readonly email?: string,
) {}
}

View File

@@ -0,0 +1,83 @@
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import {
CachePrefix,
CacheService,
ConflictException,
DomainException,
LoggerService,
NotFoundException,
ValidationException,
} from '@modules/shared';
import { Email } from '../../../domain/value-objects/email.vo';
import { IUserRepository, USER_REPOSITORY } from '../../../domain/repositories/user.repository';
import { UpdateProfileCommand } from './update-profile.command';
export interface UpdateProfileResultDto {
id: string;
fullName: string;
avatarUrl: string | null;
email: string | null;
updatedAt: Date;
}
@CommandHandler(UpdateProfileCommand)
export class UpdateProfileHandler implements ICommandHandler<UpdateProfileCommand> {
constructor(
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
private readonly cache: CacheService,
private readonly logger: LoggerService,
) {}
async execute(command: UpdateProfileCommand): Promise<UpdateProfileResultDto> {
try {
const user = await this.userRepo.findById(command.userId);
if (!user) {
throw new NotFoundException('Người dùng', command.userId);
}
// Validate and resolve email if provided
let newEmail: Email | null | undefined;
if (command.email !== undefined) {
const emailResult = Email.create(command.email);
if (emailResult.isErr) {
throw new ValidationException(emailResult.unwrapErr());
}
const email = emailResult.unwrap();
// Check if email is actually changing
if (user.email?.value !== email.value) {
// Check uniqueness
const existingUser = await this.userRepo.findByEmail(email.value);
if (existingUser && existingUser.id !== command.userId) {
throw new ConflictException('Email đã được sử dụng bởi tài khoản khác');
}
newEmail = email;
}
}
user.updateProfile(command.fullName, command.avatarUrl, newEmail);
await this.userRepo.update(user);
await this.cache.invalidate(
CacheService.buildKey(CachePrefix.USER_PROFILE, command.userId),
);
return {
id: user.id,
fullName: user.fullName,
avatarUrl: user.avatarUrl,
email: user.email?.value ?? null,
updatedAt: user.updatedAt,
};
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(
`Failed to update profile: ${error instanceof Error ? error.message : error}`,
error instanceof Error ? error.stack : undefined,
this.constructor.name,
);
throw new InternalServerErrorException('Không thể cập nhật hồ sơ người dùng');
}
}
}