feat(api): add async error handling to critical module handlers
Wrap async operations at application layer boundaries with proper try/catch, LoggerService logging, and domain exceptions: - UploadMediaHandler: mediaStorage.upload() error boundary - ExportUserDataHandler: Promise.all() error logging - ForceDeleteUserHandler: $transaction error logging - LoginUserHandler: token generation error boundary - RefreshTokenHandler: token rotation error boundary - CreatePaymentHandler: payment gateway call error boundary Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -41,23 +41,41 @@ export class ExportUserDataHandler implements ICommandHandler<ExportUserDataComm
|
|||||||
|
|
||||||
if (!user) throw new NotFoundException('User', command.userId);
|
if (!user) throw new NotFoundException('User', command.userId);
|
||||||
|
|
||||||
const [agent, listings, payments, subscription, reviews, inquiries, savedSearches, transactions] =
|
let agent: unknown | null;
|
||||||
await Promise.all([
|
let listings: unknown[];
|
||||||
this.prisma.agent.findUnique({ where: { userId: command.userId } }),
|
let payments: unknown[];
|
||||||
this.prisma.listing.findMany({
|
let subscription: unknown | null;
|
||||||
where: { sellerId: command.userId },
|
let reviews: unknown[];
|
||||||
include: { property: { select: { title: true, address: true, district: true, city: true } } },
|
let inquiries: unknown[];
|
||||||
}),
|
let savedSearches: unknown[];
|
||||||
this.prisma.payment.findMany({
|
let transactions: unknown[];
|
||||||
where: { userId: command.userId },
|
|
||||||
select: { id: true, provider: true, type: true, amountVND: true, status: true, createdAt: true },
|
try {
|
||||||
}),
|
[agent, listings, payments, subscription, reviews, inquiries, savedSearches, transactions] =
|
||||||
this.prisma.subscription.findFirst({ where: { userId: command.userId } }),
|
await Promise.all([
|
||||||
this.prisma.review.findMany({ where: { userId: command.userId } }),
|
this.prisma.agent.findUnique({ where: { userId: command.userId } }),
|
||||||
this.prisma.inquiry.findMany({ where: { userId: command.userId } }),
|
this.prisma.listing.findMany({
|
||||||
this.prisma.savedSearch.findMany({ where: { userId: command.userId } }),
|
where: { sellerId: command.userId },
|
||||||
this.prisma.transaction.findMany({ where: { buyerId: command.userId } }),
|
include: { property: { select: { title: true, address: true, district: true, city: true } } },
|
||||||
]);
|
}),
|
||||||
|
this.prisma.payment.findMany({
|
||||||
|
where: { userId: command.userId },
|
||||||
|
select: { id: true, provider: true, type: true, amountVND: true, status: true, createdAt: true },
|
||||||
|
}),
|
||||||
|
this.prisma.subscription.findFirst({ where: { userId: command.userId } }),
|
||||||
|
this.prisma.review.findMany({ where: { userId: command.userId } }),
|
||||||
|
this.prisma.inquiry.findMany({ where: { userId: command.userId } }),
|
||||||
|
this.prisma.savedSearch.findMany({ where: { userId: command.userId } }),
|
||||||
|
this.prisma.transaction.findMany({ where: { buyerId: command.userId } }),
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to export user data for ${command.userId}: ${error instanceof Error ? error.message : error}`,
|
||||||
|
error instanceof Error ? error.stack : undefined,
|
||||||
|
'ExportUserDataHandler',
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
this.logger.log(`User data exported for ${command.userId}`, 'ExportUserDataHandler');
|
this.logger.log(`User data exported for ${command.userId}`, 'ExportUserDataHandler');
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,16 @@ export class ForceDeleteUserHandler implements ICommandHandler<ForceDeleteUserCo
|
|||||||
if (!user) throw new NotFoundException('User', command.userId);
|
if (!user) throw new NotFoundException('User', command.userId);
|
||||||
if (user.deletedAt) return { message: 'Tài khoản đã bị xóa' };
|
if (user.deletedAt) return { message: 'Tài khoản đã bị xóa' };
|
||||||
|
|
||||||
await this.anonymizeAndDelete(command.userId);
|
try {
|
||||||
|
await this.anonymizeAndDelete(command.userId);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Force delete transaction failed for user ${command.userId}: ${error instanceof Error ? error.message : error}`,
|
||||||
|
error instanceof Error ? error.stack : undefined,
|
||||||
|
'ForceDeleteUserHandler',
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`User ${command.userId} force-deleted by admin ${command.adminId}: ${command.reason}`,
|
`User ${command.userId} force-deleted by admin ${command.adminId}: ${command.reason}`,
|
||||||
|
|||||||
@@ -1,16 +1,29 @@
|
|||||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||||
|
import { type LoggerService, UnauthorizedException } from '@modules/shared';
|
||||||
import { type TokenService, type TokenPair } from '../../../infrastructure/services/token.service';
|
import { type TokenService, type TokenPair } from '../../../infrastructure/services/token.service';
|
||||||
import { LoginUserCommand } from './login-user.command';
|
import { LoginUserCommand } from './login-user.command';
|
||||||
|
|
||||||
@CommandHandler(LoginUserCommand)
|
@CommandHandler(LoginUserCommand)
|
||||||
export class LoginUserHandler implements ICommandHandler<LoginUserCommand> {
|
export class LoginUserHandler implements ICommandHandler<LoginUserCommand> {
|
||||||
constructor(private readonly tokenService: TokenService) {}
|
constructor(
|
||||||
|
private readonly tokenService: TokenService,
|
||||||
|
private readonly logger: LoggerService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async execute(command: LoginUserCommand): Promise<TokenPair> {
|
async execute(command: LoginUserCommand): Promise<TokenPair> {
|
||||||
return this.tokenService.generateTokenPair({
|
try {
|
||||||
sub: command.userId,
|
return await this.tokenService.generateTokenPair({
|
||||||
phone: command.phone,
|
sub: command.userId,
|
||||||
role: command.role,
|
phone: command.phone,
|
||||||
});
|
role: command.role,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Token generation failed for user ${command.userId}: ${error instanceof Error ? error.message : error}`,
|
||||||
|
error instanceof Error ? error.stack : undefined,
|
||||||
|
'LoginUserHandler',
|
||||||
|
);
|
||||||
|
throw new UnauthorizedException('Không thể tạo phiên đăng nhập, vui lòng thử lại');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Inject } from '@nestjs/common';
|
import { Inject } from '@nestjs/common';
|
||||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||||
import { UnauthorizedException } from '@modules/shared';
|
import { type LoggerService, UnauthorizedException } from '@modules/shared';
|
||||||
import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository';
|
import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository';
|
||||||
import { type TokenService, type TokenPair } from '../../../infrastructure/services/token.service';
|
import { type TokenService, type TokenPair } from '../../../infrastructure/services/token.service';
|
||||||
import { RefreshTokenCommand } from './refresh-token.command';
|
import { RefreshTokenCommand } from './refresh-token.command';
|
||||||
@@ -10,10 +10,22 @@ export class RefreshTokenHandler implements ICommandHandler<RefreshTokenCommand>
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly tokenService: TokenService,
|
private readonly tokenService: TokenService,
|
||||||
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
|
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
|
||||||
|
private readonly logger: LoggerService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(command: RefreshTokenCommand): Promise<TokenPair> {
|
async execute(command: RefreshTokenCommand): Promise<TokenPair> {
|
||||||
const rotated = await this.tokenService.rotateRefreshToken(command.refreshToken);
|
let rotated: Awaited<ReturnType<TokenService['rotateRefreshToken']>>;
|
||||||
|
try {
|
||||||
|
rotated = await this.tokenService.rotateRefreshToken(command.refreshToken);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Token rotation failed: ${error instanceof Error ? error.message : error}`,
|
||||||
|
error instanceof Error ? error.stack : undefined,
|
||||||
|
'RefreshTokenHandler',
|
||||||
|
);
|
||||||
|
throw new UnauthorizedException('Không thể làm mới phiên đăng nhập');
|
||||||
|
}
|
||||||
|
|
||||||
if (!rotated) {
|
if (!rotated) {
|
||||||
throw new UnauthorizedException('Refresh token không hợp lệ hoặc đã hết hạn');
|
throw new UnauthorizedException('Refresh token không hợp lệ hoặc đã hết hạn');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Inject } from '@nestjs/common';
|
import { Inject } from '@nestjs/common';
|
||||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||||
import { createId } from '@paralleldrive/cuid2';
|
import { createId } from '@paralleldrive/cuid2';
|
||||||
import { NotFoundException, ValidationException } from '@modules/shared';
|
import { type LoggerService, NotFoundException, ValidationException } from '@modules/shared';
|
||||||
import { PropertyMediaEntity } from '../../../domain/entities/property-media.entity';
|
import { PropertyMediaEntity } from '../../../domain/entities/property-media.entity';
|
||||||
import { PROPERTY_REPOSITORY, type IPropertyRepository } from '../../../domain/repositories/property.repository';
|
import { PROPERTY_REPOSITORY, type IPropertyRepository } from '../../../domain/repositories/property.repository';
|
||||||
import { MEDIA_STORAGE_SERVICE, type IMediaStorageService } from '../../../infrastructure/services/media-storage.service';
|
import { MEDIA_STORAGE_SERVICE, type IMediaStorageService } from '../../../infrastructure/services/media-storage.service';
|
||||||
@@ -14,6 +14,7 @@ export class UploadMediaHandler implements ICommandHandler<UploadMediaCommand> {
|
|||||||
constructor(
|
constructor(
|
||||||
@Inject(PROPERTY_REPOSITORY) private readonly propertyRepo: IPropertyRepository,
|
@Inject(PROPERTY_REPOSITORY) private readonly propertyRepo: IPropertyRepository,
|
||||||
@Inject(MEDIA_STORAGE_SERVICE) private readonly mediaStorage: IMediaStorageService,
|
@Inject(MEDIA_STORAGE_SERVICE) private readonly mediaStorage: IMediaStorageService,
|
||||||
|
private readonly logger: LoggerService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(command: UploadMediaCommand): Promise<{ mediaId: string; url: string }> {
|
async execute(command: UploadMediaCommand): Promise<{ mediaId: string; url: string }> {
|
||||||
@@ -29,12 +30,22 @@ export class UploadMediaHandler implements ICommandHandler<UploadMediaCommand> {
|
|||||||
|
|
||||||
const mediaType = command.file.mimetype.startsWith('video/') ? 'video' as const : 'image' as const;
|
const mediaType = command.file.mimetype.startsWith('video/') ? 'video' as const : 'image' as const;
|
||||||
|
|
||||||
const url = await this.mediaStorage.upload(
|
let url: string;
|
||||||
command.file.buffer,
|
try {
|
||||||
command.file.originalname,
|
url = await this.mediaStorage.upload(
|
||||||
command.file.mimetype,
|
command.file.buffer,
|
||||||
`properties/${command.propertyId}`,
|
command.file.originalname,
|
||||||
);
|
command.file.mimetype,
|
||||||
|
`properties/${command.propertyId}`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Media upload failed for property ${command.propertyId}: ${error instanceof Error ? error.message : error}`,
|
||||||
|
error instanceof Error ? error.stack : undefined,
|
||||||
|
'UploadMediaHandler',
|
||||||
|
);
|
||||||
|
throw new ValidationException('Tải lên media thất bại, vui lòng thử lại');
|
||||||
|
}
|
||||||
|
|
||||||
const mediaId = createId();
|
const mediaId = createId();
|
||||||
const media = PropertyMediaEntity.createNew(
|
const media = PropertyMediaEntity.createNew(
|
||||||
|
|||||||
@@ -65,13 +65,24 @@ export class CreatePaymentHandler implements ICommandHandler<CreatePaymentComman
|
|||||||
|
|
||||||
// Get payment gateway and create URL
|
// Get payment gateway and create URL
|
||||||
const gateway = this.gatewayFactory.getGateway(command.provider);
|
const gateway = this.gatewayFactory.getGateway(command.provider);
|
||||||
const { paymentUrl, providerTxId } = await gateway.createPaymentUrl({
|
let paymentUrl: string;
|
||||||
orderId: paymentId,
|
let providerTxId: string;
|
||||||
amountVND: command.amountVND,
|
try {
|
||||||
description: command.description,
|
({ paymentUrl, providerTxId } = await gateway.createPaymentUrl({
|
||||||
returnUrl: command.returnUrl,
|
orderId: paymentId,
|
||||||
ipAddress: command.ipAddress,
|
amountVND: command.amountVND,
|
||||||
});
|
description: command.description,
|
||||||
|
returnUrl: command.returnUrl,
|
||||||
|
ipAddress: command.ipAddress,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Payment gateway ${command.provider} failed for order ${paymentId}: ${error instanceof Error ? error.message : error}`,
|
||||||
|
error instanceof Error ? error.stack : undefined,
|
||||||
|
'CreatePaymentHandler',
|
||||||
|
);
|
||||||
|
throw new ValidationException('Không thể tạo liên kết thanh toán, vui lòng thử lại');
|
||||||
|
}
|
||||||
|
|
||||||
// Mark processing and save
|
// Mark processing and save
|
||||||
payment.markProcessing(providerTxId);
|
payment.markProcessing(providerTxId);
|
||||||
|
|||||||
Reference in New Issue
Block a user