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:
@@ -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[],
|
||||
) {}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user