diff --git a/apps/api/src/modules/auth/application/commands/generate-kyc-upload-urls/generate-kyc-upload-urls.command.ts b/apps/api/src/modules/auth/application/commands/generate-kyc-upload-urls/generate-kyc-upload-urls.command.ts new file mode 100644 index 0000000..234e811 --- /dev/null +++ b/apps/api/src/modules/auth/application/commands/generate-kyc-upload-urls/generate-kyc-upload-urls.command.ts @@ -0,0 +1,12 @@ +export interface KycFileRequest { + field: 'frontImage' | 'backImage' | 'selfieImage'; + mimeType: string; + fileName: string; +} + +export class GenerateKycUploadUrlsCommand { + constructor( + public readonly userId: string, + public readonly files: KycFileRequest[], + ) {} +} diff --git a/apps/api/src/modules/auth/application/commands/generate-kyc-upload-urls/generate-kyc-upload-urls.handler.ts b/apps/api/src/modules/auth/application/commands/generate-kyc-upload-urls/generate-kyc-upload-urls.handler.ts new file mode 100644 index 0000000..81c3403 --- /dev/null +++ b/apps/api/src/modules/auth/application/commands/generate-kyc-upload-urls/generate-kyc-upload-urls.handler.ts @@ -0,0 +1,96 @@ +import { Inject } from '@nestjs/common'; +import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; +import { + NotFoundException, + ValidationException, + type LoggerService, +} from '@modules/shared'; +import { + MEDIA_STORAGE_SERVICE, + type IMediaStorageService, +} from '../../../../listings/infrastructure/services/media-storage.service'; +import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository'; +import { GenerateKycUploadUrlsCommand } from './generate-kyc-upload-urls.command'; + +const KYC_FOLDER = 'kyc'; +const ALLOWED_SUBMIT_STATUSES = new Set(['NONE', 'REJECTED']); +const ALLOWED_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']); +const PRESIGNED_URL_EXPIRY_SECONDS = 300; // 5 minutes + +export interface KycUploadUrlResult { + field: string; + uploadUrl: string; + publicUrl: string; + objectKey: string; +} + +@CommandHandler(GenerateKycUploadUrlsCommand) +export class GenerateKycUploadUrlsHandler + implements ICommandHandler +{ + constructor( + @Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository, + @Inject(MEDIA_STORAGE_SERVICE) private readonly mediaStorage: IMediaStorageService, + private readonly logger: LoggerService, + ) {} + + async execute(command: GenerateKycUploadUrlsCommand): Promise { + const user = await this.userRepo.findById(command.userId); + if (!user) { + throw new NotFoundException('Nguoi dung', command.userId); + } + + if (!ALLOWED_SUBMIT_STATUSES.has(user.kycStatus)) { + throw new ValidationException( + 'Ban da gui ho so KYC. Vui long cho ket qua xem xet.', + ); + } + + if (!command.files.length || command.files.length > 3) { + throw new ValidationException( + 'Vui long cung cap 1-3 file de tai len', + ); + } + + // Validate MIME types + for (const file of command.files) { + if (!ALLOWED_MIME_TYPES.has(file.mimeType)) { + throw new ValidationException( + `Dinh dang file khong duoc ho tro: ${file.mimeType}. Chi chap nhan JPEG, PNG, WebP.`, + ); + } + } + + const folder = `${KYC_FOLDER}/${command.userId}`; + + const results: KycUploadUrlResult[] = []; + for (const file of command.files) { + try { + const presigned = await this.mediaStorage.generatePresignedUpload( + folder, + file.fileName, + file.mimeType, + PRESIGNED_URL_EXPIRY_SECONDS, + ); + + results.push({ + field: file.field, + uploadUrl: presigned.uploadUrl, + publicUrl: presigned.publicUrl, + objectKey: presigned.objectKey, + }); + } catch (error) { + this.logger.error( + `Failed to generate presigned URL for KYC ${file.field}: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + 'GenerateKycUploadUrlsHandler', + ); + throw new ValidationException( + `Khong the tao URL tai len cho ${file.field}`, + ); + } + } + + return results; + } +} diff --git a/apps/api/src/modules/auth/application/commands/submit-kyc/submit-kyc.command.ts b/apps/api/src/modules/auth/application/commands/submit-kyc/submit-kyc.command.ts new file mode 100644 index 0000000..1b7cbbe --- /dev/null +++ b/apps/api/src/modules/auth/application/commands/submit-kyc/submit-kyc.command.ts @@ -0,0 +1,24 @@ +export interface KycFileData { + buffer: Buffer; + mimetype: string; + originalname: string; + size: number; +} + +export interface KycImageUrls { + frontImageUrl: string; + backImageUrl?: string; + selfieUrl?: string; +} + +export class SubmitKycCommand { + constructor( + public readonly userId: string, + public readonly documentType: string, + public readonly documentNumber: string, + public readonly frontImage?: KycFileData, + public readonly backImage?: KycFileData, + public readonly selfieImage?: KycFileData, + public readonly imageUrls?: KycImageUrls, + ) {} +} diff --git a/apps/api/src/modules/auth/application/commands/submit-kyc/submit-kyc.handler.ts b/apps/api/src/modules/auth/application/commands/submit-kyc/submit-kyc.handler.ts new file mode 100644 index 0000000..c6d6552 --- /dev/null +++ b/apps/api/src/modules/auth/application/commands/submit-kyc/submit-kyc.handler.ts @@ -0,0 +1,118 @@ +import { Inject, InternalServerErrorException } from '@nestjs/common'; +import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; +import { + DomainException, + type LoggerService, + NotFoundException, + ValidationException, + CacheService, + CachePrefix, +} from '@modules/shared'; +import { + MEDIA_STORAGE_SERVICE, + type IMediaStorageService, +} from '../../../../listings/infrastructure/services/media-storage.service'; +import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository'; +import { SubmitKycCommand, type KycFileData } from './submit-kyc.command'; + +const KYC_FOLDER = 'kyc'; +const ALLOWED_SUBMIT_STATUSES = new Set(['NONE', 'REJECTED']); + +@CommandHandler(SubmitKycCommand) +export class SubmitKycHandler implements ICommandHandler { + constructor( + @Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository, + @Inject(MEDIA_STORAGE_SERVICE) private readonly mediaStorage: IMediaStorageService, + private readonly cache: CacheService, + private readonly logger: LoggerService, + ) {} + + async execute(command: SubmitKycCommand): Promise<{ message: string }> { + try { + const user = await this.userRepo.findById(command.userId); + if (!user) { + throw new NotFoundException('Nguoi dung', command.userId); + } + + if (!ALLOWED_SUBMIT_STATUSES.has(user.kycStatus)) { + throw new ValidationException( + 'Ban da gui ho so KYC. Vui long cho ket qua xem xet.', + ); + } + + let frontImageUrl: string; + let backImageUrl: string | null = null; + let selfieUrl: string | null = null; + + if (command.imageUrls) { + // Presigned URL flow: images were already uploaded directly to MinIO + frontImageUrl = command.imageUrls.frontImageUrl; + backImageUrl = command.imageUrls.backImageUrl ?? null; + selfieUrl = command.imageUrls.selfieUrl ?? null; + } else if (command.frontImage) { + // Legacy file upload flow: upload buffers server-side + const folder = `${KYC_FOLDER}/${command.userId}`; + + frontImageUrl = await this.uploadFile(command.frontImage, folder, 'front'); + backImageUrl = command.backImage + ? await this.uploadFile(command.backImage, folder, 'back') + : null; + selfieUrl = command.selfieImage + ? await this.uploadFile(command.selfieImage, folder, 'selfie') + : null; + } else { + throw new ValidationException( + 'Vui long tai len anh mat truoc giay to hoac cung cap URL anh da tai len', + ); + } + + const kycData = { + idType: command.documentType, + idNumber: command.documentNumber, + frontImageUrl, + backImageUrl, + selfieUrl, + submittedAt: new Date().toISOString(), + }; + + user.updateKycStatus('PENDING', kycData); + await this.userRepo.update(user); + + await this.cache.invalidate( + CacheService.buildKey(CachePrefix.USER_PROFILE, command.userId), + ); + + return { message: 'Ho so KYC da duoc gui thanh cong' }; + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to submit KYC for user ${command.userId}: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException('Khong the gui ho so KYC'); + } + } + + private async uploadFile( + file: KycFileData, + folder: string, + label: string, + ): Promise { + try { + return await this.mediaStorage.upload( + file.buffer, + file.originalname, + file.mimetype, + folder, + ); + } catch (error) { + this.logger.error( + `KYC ${label} image upload failed: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + 'SubmitKycHandler', + ); + throw new ValidationException(`Tai anh ${label} that bai, vui long thu lai`); + } + } +} diff --git a/apps/api/src/modules/auth/auth.module.ts b/apps/api/src/modules/auth/auth.module.ts index 8813b3c..b1c6051 100644 --- a/apps/api/src/modules/auth/auth.module.ts +++ b/apps/api/src/modules/auth/auth.module.ts @@ -17,6 +17,7 @@ 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 { GenerateKycUploadUrlsHandler } from './application/commands/generate-kyc-upload-urls/generate-kyc-upload-urls.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'; @@ -50,6 +51,7 @@ const CommandHandlers = [ RefreshTokenHandler, VerifyKycHandler, SubmitKycHandler, + GenerateKycUploadUrlsHandler, UpdateProfileHandler, RequestUserDeletionHandler, CancelUserDeletionHandler, 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 16481f6..d68fad6 100644 --- a/apps/api/src/modules/auth/presentation/controllers/auth.controller.ts +++ b/apps/api/src/modules/auth/presentation/controllers/auth.controller.ts @@ -27,6 +27,8 @@ import { LoginUserCommand } from '../../application/commands/login-user/login-us 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 { GenerateKycUploadUrlsCommand, type KycFileRequest } from '../../application/commands/generate-kyc-upload-urls/generate-kyc-upload-urls.command'; +import { type KycUploadUrlResult } from '../../application/commands/generate-kyc-upload-urls/generate-kyc-upload-urls.handler'; 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'; @@ -244,57 +246,56 @@ export class AuthController { } @UseGuards(JwtAuthGuard) - @UseInterceptors( - FileFieldsInterceptor([ - { name: 'frontImage', maxCount: 1 }, - { name: 'backImage', maxCount: 1 }, - { name: 'selfieImage', maxCount: 1 }, - ]), - ) + @Post('kyc/upload-urls') + @ApiBearerAuth('JWT') + @ApiOperation({ summary: 'Generate presigned upload URLs for KYC images' }) + @ApiResponse({ status: 201, description: 'Presigned URLs generated' }) + @ApiResponse({ status: 400, description: 'Validation error' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async generateKycUploadUrls( + @Body() body: { files: KycFileRequest[] }, + @CurrentUser() user: JwtPayload, + ): Promise<{ field: string; uploadUrl: string; publicUrl: string; objectKey: string }[]> { + return this.commandBus.execute( + new GenerateKycUploadUrlsCommand(user.sub, body.files), + ); + } + + @UseGuards(JwtAuthGuard) @Post('kyc/submit') @ApiBearerAuth('JWT') - @ApiConsumes('multipart/form-data') - @ApiOperation({ summary: 'Submit KYC documents for verification' }) + @ApiOperation({ summary: 'Submit KYC documents with presigned image URLs' }) @ApiResponse({ status: 201, description: 'KYC documents submitted successfully' }) - @ApiResponse({ status: 400, description: 'Validation error (missing files or invalid format)' }) + @ApiResponse({ status: 400, description: 'Validation error' }) @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; + frontImageUrl: string; + backImageUrl?: string; + selfieUrl?: string; }, - @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) { + if (!body.frontImageUrl) { 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], + undefined, + undefined, + undefined, + { + frontImageUrl: body.frontImageUrl, + backImageUrl: body.backImageUrl, + selfieUrl: body.selfieUrl, + }, ), ); } diff --git a/apps/web/app/[locale]/(dashboard)/dashboard/kyc/page.tsx b/apps/web/app/[locale]/(dashboard)/dashboard/kyc/page.tsx index 3f5b409..9e1fad8 100644 --- a/apps/web/app/[locale]/(dashboard)/dashboard/kyc/page.tsx +++ b/apps/web/app/[locale]/(dashboard)/dashboard/kyc/page.tsx @@ -1,13 +1,12 @@ 'use client'; -import { useState } from 'react'; +import { useState, useCallback } from 'react'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Select } from '@/components/ui/select'; -import { apiClient } from '@/lib/api-client'; import { useAuthStore } from '@/lib/auth-store'; const KYC_STATUS_MAP: Record = { @@ -30,12 +29,67 @@ const KYC_STEPS = [ { step: 3, title: 'Xác nhận', description: 'Kiểm tra và gửi hồ sơ' }, ]; +const API_BASE_URL = + process.env['NEXT_PUBLIC_API_URL'] || 'http://localhost:3001/api/v1'; + +function getCsrfToken(): string | undefined { + const csrfMatch = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]*)/); + return csrfMatch?.[1] ? decodeURIComponent(csrfMatch[1]) : undefined; +} + +function buildHeaders(): Record { + const headers: Record = { 'Content-Type': 'application/json' }; + const csrfToken = getCsrfToken(); + if (csrfToken) headers['X-CSRF-Token'] = csrfToken; + return headers; +} + +interface PresignedUrlResult { + field: string; + uploadUrl: string; + publicUrl: string; + objectKey: string; +} + +function uploadFileWithProgress( + url: string, + file: File, + onProgress: (percent: number) => void, +): Promise { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('PUT', url); + xhr.setRequestHeader('Content-Type', file.type); + + xhr.upload.addEventListener('progress', (e) => { + if (e.lengthComputable) { + onProgress(Math.round((e.loaded / e.total) * 100)); + } + }); + + xhr.addEventListener('load', () => { + if (xhr.status >= 200 && xhr.status < 300) { + onProgress(100); + resolve(); + } else { + reject(new Error(`Upload thất bại (${xhr.status})`)); + } + }); + + xhr.addEventListener('error', () => reject(new Error('Lỗi kết nối khi tải ảnh'))); + xhr.addEventListener('abort', () => reject(new Error('Tải ảnh đã bị hủy'))); + + xhr.send(file); + }); +} + export default function KycPage() { const { user, fetchProfile } = useAuthStore(); const [currentStep, setCurrentStep] = useState(1); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(false); + const [uploadProgress, setUploadProgress] = useState>({}); const [documentType, setDocumentType] = useState('CCCD'); const [documentNumber, setDocumentNumber] = useState(''); @@ -47,6 +101,12 @@ export default function KycPage() { const kycInfo = KYC_STATUS_MAP[kycStatus] ?? { label: 'Chưa xác minh', variant: 'outline' as const, description: 'Bạn chưa gửi hồ sơ xác minh danh tính.' }; const canSubmit = kycStatus === 'NONE' || kycStatus === 'REJECTED'; + const totalProgress = useCallback(() => { + const fields = Object.values(uploadProgress); + if (fields.length === 0) return 0; + return Math.round(fields.reduce((sum, p) => sum + p, 0) / fields.length); + }, [uploadProgress]); + const handleSubmit = async () => { if (!documentNumber.trim()) { setError('Vui lòng nhập số giấy tờ'); @@ -59,14 +119,74 @@ export default function KycPage() { setSubmitting(true); setError(null); + setUploadProgress({}); + try { - await apiClient.patch('/auth/profile', { - kycData: { + // Step 1: Build the list of files to upload + const filesToUpload: { field: 'frontImage' | 'backImage' | 'selfieImage'; file: File }[] = [ + { field: 'frontImage', file: frontImage }, + ]; + if (backImage) filesToUpload.push({ field: 'backImage', file: backImage }); + if (selfieImage) filesToUpload.push({ field: 'selfieImage', file: selfieImage }); + + // Step 2: Request presigned URLs from the backend + const uploadUrlsRes = await fetch(`${API_BASE_URL}/auth/kyc/upload-urls`, { + method: 'POST', + credentials: 'include', + headers: buildHeaders(), + body: JSON.stringify({ + files: filesToUpload.map((f) => ({ + field: f.field, + mimeType: f.file.type, + fileName: f.file.name, + })), + }), + }); + + if (!uploadUrlsRes.ok) { + const errData = await uploadUrlsRes.json().catch(() => ({ message: uploadUrlsRes.statusText })); + throw new Error(errData.message || 'Không thể tạo URL tải lên'); + } + + const presignedUrls: PresignedUrlResult[] = await uploadUrlsRes.json(); + + // Step 3: Upload each file directly to MinIO via presigned URL + const urlMap = new Map(presignedUrls.map((p) => [p.field, p])); + + await Promise.all( + filesToUpload.map(({ field, file }) => { + const presigned = urlMap.get(field); + if (!presigned) throw new Error(`Không tìm thấy URL tải lên cho ${field}`); + + return uploadFileWithProgress(presigned.uploadUrl, file, (percent) => { + setUploadProgress((prev) => ({ ...prev, [field]: percent })); + }); + }), + ); + + // Step 4: Submit KYC with the uploaded image URLs + const frontUrl = urlMap.get('frontImage')!.publicUrl; + const backUrl = urlMap.get('backImage')?.publicUrl; + const selfieUrl = urlMap.get('selfieImage')?.publicUrl; + + const submitRes = await fetch(`${API_BASE_URL}/auth/kyc/submit`, { + method: 'POST', + credentials: 'include', + headers: buildHeaders(), + body: JSON.stringify({ documentType, documentNumber: documentNumber.trim(), - submittedAt: new Date().toISOString(), - }, + frontImageUrl: frontUrl, + backImageUrl: backUrl, + selfieUrl, + }), }); + + if (!submitRes.ok) { + const errData = await submitRes.json().catch(() => ({ message: submitRes.statusText })); + throw new Error(errData.message || 'Gửi hồ sơ thất bại'); + } + await fetchProfile(); setSuccess(true); } catch (e) { @@ -247,10 +367,26 @@ export default function KycPage() { )} + {/* Upload progress */} + {submitting && Object.keys(uploadProgress).length > 0 && ( +
+
+ Đang tải ảnh lên... + {totalProgress()}% +
+
+
+
+
+ )} + {/* Navigation buttons */}
{currentStep > 1 ? ( - ) : (