feat(auth): implement KYC upload with presigned URLs and multi-step form

Backend:
- GenerateKycUploadUrls command — presigned MinIO URLs (5-min expiry),
  MIME validation (JPEG/PNG/WebP), unique object keys per user
- SubmitKyc command — stores document type, number, and image URLs in
  kycData JSON field, updates kycStatus to PENDING
- POST /auth/kyc/upload-urls and POST /auth/kyc/submit endpoints

Frontend:
- 3-step KYC form: document info → image upload → review
- Direct client-to-MinIO upload via presigned URLs with progress tracking
- Status-aware UI (NONE/PENDING/VERIFIED/REJECTED)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-16 02:37:10 +07:00
parent 89aaa25bb6
commit 8f8e20f4c0
7 changed files with 431 additions and 42 deletions

View File

@@ -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[],
) {}
}

View File

@@ -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<GenerateKycUploadUrlsCommand>
{
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<KycUploadUrlResult[]> {
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;
}
}

View File

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

View File

@@ -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<SubmitKycCommand> {
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<string> {
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`);
}
}
}