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 new file mode 100644 index 0000000..daf99ec --- /dev/null +++ b/apps/api/src/modules/auth/application/__tests__/update-profile.handler.spec.ts @@ -0,0 +1,180 @@ +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 { UpdateProfileCommand } from '../commands/update-profile/update-profile.command'; +import { UpdateProfileHandler } from '../commands/update-profile/update-profile.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('UpdateProfileHandler', () => { + let handler: UpdateProfileHandler; + let mockUserRepo: { [K in keyof IUserRepository]: 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(), + }; + mockCache = { invalidate: vi.fn().mockResolvedValue(undefined) }; + + handler = new UpdateProfileHandler( + mockUserRepo as any, + mockCache as any, + { error: vi.fn() } as any, + ); + }); + + it('updates fullName and invalidates cache', async () => { + const user = createTestUser(); + mockUserRepo.findById.mockResolvedValue(user); + mockUserRepo.update.mockResolvedValue(undefined); + + const command = new UpdateProfileCommand('user-1', 'Tran Van B'); + const result = await handler.execute(command); + + expect(mockUserRepo.findById).toHaveBeenCalledWith('user-1'); + expect(mockUserRepo.update).toHaveBeenCalledWith(user); + expect(result.fullName).toBe('Tran Van B'); + expect(result.id).toBe('user-1'); + expect(mockCache.invalidate).toHaveBeenCalledWith( + expect.stringContaining('user-1'), + ); + }); + + it('updates avatarUrl', async () => { + const user = createTestUser(); + mockUserRepo.findById.mockResolvedValue(user); + mockUserRepo.update.mockResolvedValue(undefined); + + const command = new UpdateProfileCommand('user-1', undefined, 'https://cdn.example.com/avatar.jpg'); + const result = await handler.execute(command); + + expect(result.avatarUrl).toBe('https://cdn.example.com/avatar.jpg'); + expect(mockUserRepo.update).toHaveBeenCalledWith(user); + }); + + it('updates email with uniqueness check', async () => { + const user = createTestUser(); + mockUserRepo.findById.mockResolvedValue(user); + mockUserRepo.findByEmail.mockResolvedValue(null); + mockUserRepo.update.mockResolvedValue(undefined); + + 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); + }); + + it('throws ConflictException when email is already taken', async () => { + const user = createTestUser(); + const otherUser = createTestUser({ id: 'user-2', email: 'taken@example.com' }); + mockUserRepo.findById.mockResolvedValue(user); + mockUserRepo.findByEmail.mockResolvedValue(otherUser); + + const command = new UpdateProfileCommand('user-1', undefined, undefined, 'taken@example.com'); + await expect(handler.execute(command)).rejects.toThrow('Email đã được sử dụng'); + }); + + it('skips email uniqueness check 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); + + 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(); + }); + + it('throws NotFoundException when user does not exist', async () => { + mockUserRepo.findById.mockResolvedValue(null); + + const command = new UpdateProfileCommand('non-existent', 'New Name'); + await expect(handler.execute(command)).rejects.toThrow('Người dùng'); + }); + + it('does not call update or invalidate when user is not found', async () => { + mockUserRepo.findById.mockResolvedValue(null); + + const command = new UpdateProfileCommand('non-existent', 'New Name'); + await expect(handler.execute(command)).rejects.toThrow(); + + expect(mockUserRepo.update).not.toHaveBeenCalled(); + expect(mockCache.invalidate).not.toHaveBeenCalled(); + }); + + it('throws ValidationException for invalid email format', async () => { + const user = createTestUser(); + mockUserRepo.findById.mockResolvedValue(user); + + const command = new UpdateProfileCommand('user-1', undefined, undefined, 'not-an-email'); + await expect(handler.execute(command)).rejects.toThrow('Email không hợp lệ'); + }); + + it('updates all fields at once', async () => { + const user = createTestUser(); + mockUserRepo.findById.mockResolvedValue(user); + mockUserRepo.findByEmail.mockResolvedValue(null); + mockUserRepo.update.mockResolvedValue(undefined); + + const command = new UpdateProfileCommand( + 'user-1', + 'Le Thi C', + 'https://cdn.example.com/new.jpg', + 'new@example.com', + ); + const result = await handler.execute(command); + + expect(result.fullName).toBe('Le Thi C'); + expect(result.avatarUrl).toBe('https://cdn.example.com/new.jpg'); + expect(result.email).toBe('new@example.com'); + expect(mockUserRepo.update).toHaveBeenCalledWith(user); + expect(mockCache.invalidate).toHaveBeenCalled(); + }); +}); 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 new file mode 100644 index 0000000..5e25d55 --- /dev/null +++ b/apps/api/src/modules/auth/application/commands/update-profile/update-profile.command.ts @@ -0,0 +1,8 @@ +export class UpdateProfileCommand { + constructor( + public readonly userId: string, + public readonly fullName?: string, + public readonly avatarUrl?: string, + public readonly email?: 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 new file mode 100644 index 0000000..9e644f8 --- /dev/null +++ b/apps/api/src/modules/auth/application/commands/update-profile/update-profile.handler.ts @@ -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 { + constructor( + @Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository, + private readonly cache: CacheService, + private readonly logger: LoggerService, + ) {} + + async execute(command: UpdateProfileCommand): Promise { + 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'); + } + } +} diff --git a/apps/api/src/modules/auth/application/index.ts b/apps/api/src/modules/auth/application/index.ts index acf2058..b4fbf0e 100644 --- a/apps/api/src/modules/auth/application/index.ts +++ b/apps/api/src/modules/auth/application/index.ts @@ -6,6 +6,8 @@ export { RefreshTokenCommand } from './commands/refresh-token/refresh-token.comm export { RefreshTokenHandler } from './commands/refresh-token/refresh-token.handler'; 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 { 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'; diff --git a/apps/api/src/modules/auth/auth.module.ts b/apps/api/src/modules/auth/auth.module.ts index 9abc5d2..8813b3c 100644 --- a/apps/api/src/modules/auth/auth.module.ts +++ b/apps/api/src/modules/auth/auth.module.ts @@ -2,6 +2,11 @@ import { Module } from '@nestjs/common'; import { CqrsModule } from '@nestjs/cqrs'; import { JwtModule } from '@nestjs/jwt'; import { PassportModule } from '@nestjs/passport'; +import { MulterModule } from '@nestjs/platform-express'; +import { + MEDIA_STORAGE_SERVICE, + MinioMediaStorageService, +} from '../listings/infrastructure/services/media-storage.service'; import { CancelUserDeletionHandler } from './application/commands/cancel-user-deletion/cancel-user-deletion.handler'; import { DisableMfaHandler } from './application/commands/disable-mfa/disable-mfa.handler'; import { ExportUserDataHandler } from './application/commands/export-user-data/export-user-data.handler'; @@ -12,6 +17,8 @@ import { RefreshTokenHandler } from './application/commands/refresh-token/refres import { RegisterUserHandler } from './application/commands/register-user/register-user.handler'; import { RequestUserDeletionHandler } from './application/commands/request-user-deletion/request-user-deletion.handler'; import { SetupMfaHandler } from './application/commands/setup-mfa/setup-mfa.handler'; +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 { VerifyKycHandler } from './application/commands/verify-kyc/verify-kyc.handler'; import { VerifyMfaChallengeHandler } from './application/commands/verify-mfa-challenge/verify-mfa-challenge.handler'; @@ -42,6 +49,8 @@ const CommandHandlers = [ LoginUserHandler, RefreshTokenHandler, VerifyKycHandler, + SubmitKycHandler, + UpdateProfileHandler, RequestUserDeletionHandler, CancelUserDeletionHandler, ForceDeleteUserHandler, @@ -61,6 +70,9 @@ const QueryHandlers = [GetProfileHandler, GetAgentByUserIdHandler, GetMfaStatusH imports: [ CqrsModule, PassportModule, + MulterModule.register({ + limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB — per-type limits enforced by FileValidationPipe + }), JwtModule.registerAsync({ useFactory: () => { const secret = process.env['JWT_SECRET']; @@ -81,6 +93,9 @@ const QueryHandlers = [GetProfileHandler, GetAgentByUserIdHandler, GetMfaStatusH { provide: REFRESH_TOKEN_REPOSITORY, useClass: PrismaRefreshTokenRepository }, { provide: MFA_CHALLENGE_REPOSITORY, useClass: PrismaMfaChallengeRepository }, + // Storage + { provide: MEDIA_STORAGE_SERVICE, useClass: MinioMediaStorageService }, + // Strategies JwtStrategy, LocalStrategy, 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 a01abad..7679361 100644 --- a/apps/api/src/modules/auth/domain/entities/user.entity.ts +++ b/apps/api/src/modules/auth/domain/entities/user.entity.ts @@ -138,4 +138,11 @@ export class UserEntity extends AggregateRoot { this._totpBackupCodes = this._totpBackupCodes.filter((_, i) => i !== index); this.updatedAt = new Date(); } + + updateProfile(fullName?: string, avatarUrl?: string | null, email?: Email | null): void { + if (fullName !== undefined) this._fullName = fullName; + if (avatarUrl !== undefined) this._avatarUrl = avatarUrl; + if (email !== undefined) this._email = email; + this.updatedAt = new Date(); + } } diff --git a/apps/api/src/modules/auth/presentation/__tests__/update-profile.dto.spec.ts b/apps/api/src/modules/auth/presentation/__tests__/update-profile.dto.spec.ts new file mode 100644 index 0000000..44e7670 --- /dev/null +++ b/apps/api/src/modules/auth/presentation/__tests__/update-profile.dto.spec.ts @@ -0,0 +1,77 @@ +import { validate } from 'class-validator'; +import { UpdateProfileDto } from '../../presentation/dto/update-profile.dto'; + +describe('UpdateProfileDto', () => { + it('accepts valid fullName only', async () => { + const dto = new UpdateProfileDto(); + dto.fullName = 'Nguyen Van A'; + + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it('accepts valid avatarUrl only', async () => { + const dto = new UpdateProfileDto(); + dto.avatarUrl = 'https://cdn.example.com/avatar.jpg'; + + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it('accepts valid email only', async () => { + const dto = new UpdateProfileDto(); + dto.email = 'user@example.com'; + + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it('accepts all fields together', async () => { + const dto = new UpdateProfileDto(); + dto.fullName = 'Tran Van B'; + dto.avatarUrl = 'https://cdn.example.com/avatar.jpg'; + dto.email = 'user@example.com'; + + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it('accepts empty dto (all optional)', async () => { + const dto = new UpdateProfileDto(); + + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it('rejects invalid email format', async () => { + const dto = new UpdateProfileDto(); + dto.email = 'not-an-email'; + + const errors = await validate(dto); + expect(errors.some(e => e.property === 'email')).toBe(true); + }); + + it('rejects invalid avatarUrl format', async () => { + const dto = new UpdateProfileDto(); + dto.avatarUrl = 'not-a-url'; + + const errors = await validate(dto); + expect(errors.some(e => e.property === 'avatarUrl')).toBe(true); + }); + + it('rejects empty fullName (minLength 1)', async () => { + const dto = new UpdateProfileDto(); + dto.fullName = ''; + + const errors = await validate(dto); + expect(errors.some(e => e.property === 'fullName')).toBe(true); + }); + + it('rejects non-string fullName', async () => { + const dto = new UpdateProfileDto(); + (dto as any).fullName = 123; + + const errors = await validate(dto); + expect(errors.some(e => e.property === 'fullName')).toBe(true); + }); +}); 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 d10870d..16481f6 100644 --- a/apps/api/src/modules/auth/presentation/controllers/auth.controller.ts +++ b/apps/api/src/modules/auth/presentation/controllers/auth.controller.ts @@ -6,30 +6,44 @@ import { Post, Req, Res, + UploadedFiles, UseGuards, + UseInterceptors, } from '@nestjs/common'; -import { CommandBus, QueryBus } from '@nestjs/cqrs'; -import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiBody } from '@nestjs/swagger'; +import { type CommandBus, type QueryBus } from '@nestjs/cqrs'; +import { FileFieldsInterceptor } from '@nestjs/platform-express'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiBody, ApiConsumes } from '@nestjs/swagger'; import { Throttle } from '@nestjs/throttler'; -import { Request, Response } from 'express'; -import { EndpointRateLimit, EndpointRateLimitGuard, UnauthorizedException } from '@modules/shared'; +import { type Request, type Response } from 'express'; +import { + EndpointRateLimit, + EndpointRateLimitGuard, + UnauthorizedException, + ValidationException, + FileValidationPipe, + type UploadedFile as ValidatedFile, +} from '@modules/shared'; import { LoginUserCommand } from '../../application/commands/login-user/login-user.command'; -import { LoginResult } from '../../application/commands/login-user/login-user.handler'; +import { type LoginResult } from '../../application/commands/login-user/login-user.handler'; import { RefreshTokenCommand } from '../../application/commands/refresh-token/refresh-token.command'; import { RegisterUserCommand } from '../../application/commands/register-user/register-user.command'; +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 { VerifyKycCommand } from '../../application/commands/verify-kyc/verify-kyc.command'; -import { AgentDto } from '../../application/queries/get-agent-by-user-id/get-agent-by-user-id.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 { UserProfileDto } from '../../application/queries/get-profile/get-profile.handler'; +import { type UserProfileDto } from '../../application/queries/get-profile/get-profile.handler'; import { GetProfileQuery } from '../../application/queries/get-profile/get-profile.query'; -import { TokenService, JwtPayload, TokenPair } from '../../infrastructure/services/token.service'; -import { LocalStrategyResult } from '../../infrastructure/strategies/local.strategy'; +import { type TokenService, type JwtPayload, type TokenPair } from '../../infrastructure/services/token.service'; +import { type LocalStrategyResult } from '../../infrastructure/strategies/local.strategy'; import { CurrentUser } from '../decorators/current-user.decorator'; import { Roles } from '../decorators/roles.decorator'; import { LoginDto } from '../dto/login.dto'; -import { RefreshTokenDto } from '../dto/refresh-token.dto'; -import { RegisterDto } from '../dto/register.dto'; -import { VerifyKycDto } from '../dto/verify-kyc.dto'; +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 { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { LocalAuthGuard } from '../guards/local-auth.guard'; import { RolesGuard } from '../guards/roles.guard'; @@ -201,6 +215,24 @@ export class AuthController { return this.queryBus.execute(new GetProfileQuery(user.sub)); } + @UseGuards(JwtAuthGuard) + @Patch('profile') + @ApiBearerAuth('JWT') + @ApiOperation({ summary: 'Update current user profile' }) + @ApiResponse({ status: 200, description: 'Profile updated successfully' }) + @ApiResponse({ status: 400, description: 'Validation error' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 409, description: 'Email already in use' }) + async updateProfile( + @CurrentUser() user: JwtPayload, + @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), + ); + return { message: 'Cập nhật hồ sơ thành công', data: result }; + } + @UseGuards(JwtAuthGuard) @Get('profile/agent') @ApiBearerAuth('JWT') @@ -211,6 +243,62 @@ export class AuthController { return this.queryBus.execute(new GetAgentByUserIdQuery(user.sub)); } + @UseGuards(JwtAuthGuard) + @UseInterceptors( + FileFieldsInterceptor([ + { name: 'frontImage', maxCount: 1 }, + { name: 'backImage', maxCount: 1 }, + { name: 'selfieImage', maxCount: 1 }, + ]), + ) + @Post('kyc/submit') + @ApiBearerAuth('JWT') + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'Submit KYC documents for verification' }) + @ApiResponse({ status: 201, description: 'KYC documents submitted successfully' }) + @ApiResponse({ status: 400, description: 'Validation error (missing files or invalid format)' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 413, description: 'File too large (max 10MB per image)' }) + async submitKyc( + @UploadedFiles() + files: { + frontImage?: ValidatedFile[]; + backImage?: ValidatedFile[]; + selfieImage?: ValidatedFile[]; + }, + @Body() body: { documentType: string; documentNumber: string }, + @CurrentUser() user: JwtPayload, + ): Promise<{ message: string }> { + const kycImagePipe = new FileValidationPipe({ + maxSizeBytes: 10 * 1024 * 1024, + allowedMimeTypes: ['image/jpeg', 'image/png', 'image/webp'], + }); + + const frontImage = files.frontImage?.[0]; + if (!frontImage) { + throw new ValidationException('Vui lòng tải ảnh mặt trước giấy tờ'); + } + kycImagePipe.transform(frontImage); + + if (files.backImage?.[0]) { + kycImagePipe.transform(files.backImage[0]); + } + if (files.selfieImage?.[0]) { + kycImagePipe.transform(files.selfieImage[0]); + } + + return this.commandBus.execute( + new SubmitKycCommand( + user.sub, + body.documentType, + body.documentNumber, + frontImage, + files.backImage?.[0], + files.selfieImage?.[0], + ), + ); + } + @UseGuards(JwtAuthGuard, RolesGuard) @Roles('ADMIN') @Patch('kyc') 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 new file mode 100644 index 0000000..c6e3b0a --- /dev/null +++ b/apps/api/src/modules/auth/presentation/dto/update-profile.dto.ts @@ -0,0 +1,24 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEmail, IsOptional, IsString, IsUrl, MinLength } from 'class-validator'; + +export class UpdateProfileDto { + @ApiPropertyOptional({ example: 'Nguyen Van A', description: 'Full name' }) + @IsOptional() + @IsString() + @MinLength(1) + fullName?: string; + + @ApiPropertyOptional({ + example: 'https://cdn.goodgo.vn/avatars/user-123.jpg', + description: 'Avatar URL', + }) + @IsOptional() + @IsString() + @IsUrl({}, { message: 'Avatar URL không hợp lệ' }) + avatarUrl?: string; + + @ApiPropertyOptional({ example: 'user@example.com', description: 'Email address' }) + @IsOptional() + @IsEmail({}, { message: 'Email không hợp lệ' }) + email?: string; +}