fix(api): add error handling to 29 CQRS handlers in admin, inquiries, leads, reviews

Add standardized try-catch error handling pattern to all command and
query handlers in the four priority modules:
- admin (15 handlers): commands + queries, added LoggerService injection
- inquiries (4 handlers): commands + queries
- leads (5 handlers): commands + queries
- reviews (5 handlers): commands + queries

Each handler now:
- Wraps execute() in try-catch
- Re-throws DomainException subclasses (NotFoundException, etc.)
- Logs infrastructure errors via LoggerService
- Throws InternalServerErrorException for unexpected failures

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-11 19:35:21 +07:00
parent c0537ed535
commit 2da333a95b
29 changed files with 897 additions and 575 deletions

View File

@@ -1,9 +1,9 @@
import { Inject } from '@nestjs/common';
import { Inject, InternalServerErrorException } from '@nestjs/common';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
import { CommandHandler, EventBus, ICommandHandler } from '@nestjs/cqrs';
import { createId } from '@paralleldrive/cuid2';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
import { ConflictException, ValidationException, LoggerService } from '@modules/shared';
import { ConflictException, DomainException, ValidationException, LoggerService } from '@modules/shared';
import { ReviewEntity } from '../../../domain/entities/review.entity';
import { REVIEW_REPOSITORY, type IReviewRepository } from '../../../domain/repositories/review.repository';
import { Rating } from '../../../domain/value-objects/rating.vo';
@@ -26,54 +26,64 @@ export class CreateReviewHandler implements ICommandHandler<CreateReviewCommand>
) {}
async execute(command: CreateReviewCommand): Promise<CreateReviewResult> {
// Validate rating value object
const ratingResult = Rating.create(command.rating);
if (ratingResult.isErr) {
throw new ValidationException(ratingResult.unwrapErr());
try {
// Validate rating value object
const ratingResult = Rating.create(command.rating);
if (ratingResult.isErr) {
throw new ValidationException(ratingResult.unwrapErr());
}
const rating = ratingResult.unwrap();
// Prevent self-review
if (command.userId === command.targetId && command.targetType === 'user') {
throw new ValidationException('Không thể tự đánh giá bản thân');
}
// One review per user per target
const existing = await this.reviewRepo.findByUserAndTarget(
command.userId,
command.targetType,
command.targetId,
);
if (existing) {
throw new ConflictException('Bạn đã đánh giá mục tiêu này rồi');
}
const id = createId();
const review = ReviewEntity.createNew(
id,
command.userId,
command.targetType,
command.targetId,
rating,
command.comment,
);
await this.reviewRepo.save(review);
// Publish domain events
const events = review.clearDomainEvents();
for (const event of events) {
this.eventBus.publish(event);
}
this.logger.log(`Review ${id} created by user ${command.userId} for ${command.targetType}:${command.targetId}`, 'CreateReviewHandler');
return {
id,
rating: rating.value,
targetType: command.targetType,
targetId: command.targetId,
createdAt: review.createdAt.toISOString(),
};
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(
`Failed to create review: ${error instanceof Error ? error.message : String(error)}`,
error instanceof Error ? error.stack : undefined,
'CreateReviewHandler',
);
throw new InternalServerErrorException('Lỗi khi tạo đánh giá');
}
const rating = ratingResult.unwrap();
// Prevent self-review
if (command.userId === command.targetId && command.targetType === 'user') {
throw new ValidationException('Không thể tự đánh giá bản thân');
}
// One review per user per target
const existing = await this.reviewRepo.findByUserAndTarget(
command.userId,
command.targetType,
command.targetId,
);
if (existing) {
throw new ConflictException('Bạn đã đánh giá mục tiêu này rồi');
}
const id = createId();
const review = ReviewEntity.createNew(
id,
command.userId,
command.targetType,
command.targetId,
rating,
command.comment,
);
await this.reviewRepo.save(review);
// Publish domain events
const events = review.clearDomainEvents();
for (const event of events) {
this.eventBus.publish(event);
}
this.logger.log(`Review ${id} created by user ${command.userId} for ${command.targetType}:${command.targetId}`, 'CreateReviewHandler');
return {
id,
rating: rating.value,
targetType: command.targetType,
targetId: command.targetId,
createdAt: review.createdAt.toISOString(),
};
}
}

View File

@@ -1,8 +1,8 @@
import { Inject } from '@nestjs/common';
import { Inject, InternalServerErrorException } from '@nestjs/common';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
import { CommandHandler, EventBus, ICommandHandler } from '@nestjs/cqrs';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
import { ForbiddenException, NotFoundException, LoggerService } from '@modules/shared';
import { DomainException, ForbiddenException, NotFoundException, LoggerService } from '@modules/shared';
import { REVIEW_REPOSITORY, type IReviewRepository } from '../../../domain/repositories/review.repository';
import { DeleteReviewCommand } from './delete-review.command';
@@ -15,24 +15,34 @@ export class DeleteReviewHandler implements ICommandHandler<DeleteReviewCommand>
) {}
async execute(command: DeleteReviewCommand): Promise<void> {
const review = await this.reviewRepo.findById(command.reviewId);
if (!review) {
throw new NotFoundException('Review', command.reviewId);
try {
const review = await this.reviewRepo.findById(command.reviewId);
if (!review) {
throw new NotFoundException('Review', command.reviewId);
}
if (review.userId !== command.userId) {
throw new ForbiddenException('Bạn chỉ có thể xóa đánh giá của chính mình');
}
review.markDeleted();
await this.reviewRepo.delete(command.reviewId);
// Publish domain events
const events = review.clearDomainEvents();
for (const event of events) {
this.eventBus.publish(event);
}
this.logger.log(`Review ${command.reviewId} deleted by user ${command.userId}`, 'DeleteReviewHandler');
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(
`Failed to delete review: ${error instanceof Error ? error.message : String(error)}`,
error instanceof Error ? error.stack : undefined,
'DeleteReviewHandler',
);
throw new InternalServerErrorException('Lỗi khi xóa đánh giá');
}
if (review.userId !== command.userId) {
throw new ForbiddenException('Bạn chỉ có thể xóa đánh giá của chính mình');
}
review.markDeleted();
await this.reviewRepo.delete(command.reviewId);
// Publish domain events
const events = review.clearDomainEvents();
for (const event of events) {
this.eventBus.publish(event);
}
this.logger.log(`Review ${command.reviewId} deleted by user ${command.userId}`, 'DeleteReviewHandler');
}
}

View File

@@ -1,5 +1,6 @@
import { Inject } from '@nestjs/common';
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { DomainException, type LoggerService } from '@modules/shared';
import { type ReviewStatsData } from '../../../domain/repositories/review-read.dto';
import { REVIEW_REPOSITORY, type IReviewRepository } from '../../../domain/repositories/review.repository';
import { GetAverageRatingQuery } from './get-average-rating.query';
@@ -8,9 +9,20 @@ import { GetAverageRatingQuery } from './get-average-rating.query';
export class GetAverageRatingHandler implements IQueryHandler<GetAverageRatingQuery> {
constructor(
@Inject(REVIEW_REPOSITORY) private readonly reviewRepo: IReviewRepository,
private readonly logger: LoggerService,
) {}
async execute(query: GetAverageRatingQuery): Promise<ReviewStatsData> {
return this.reviewRepo.getStats(query.targetType, query.targetId);
try {
return await this.reviewRepo.getStats(query.targetType, query.targetId);
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(
`Failed to get average rating: ${error instanceof Error ? error.message : String(error)}`,
error instanceof Error ? error.stack : undefined,
'GetAverageRatingHandler',
);
throw new InternalServerErrorException('Lỗi khi lấy đánh giá trung bình');
}
}
}

View File

@@ -1,5 +1,6 @@
import { Inject } from '@nestjs/common';
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { DomainException, type LoggerService } from '@modules/shared';
import { type ReviewItemData } from '../../../domain/repositories/review-read.dto';
import { REVIEW_REPOSITORY, type IReviewRepository, type PaginatedResult } from '../../../domain/repositories/review.repository';
import { GetReviewsByTargetQuery } from './get-reviews-by-target.query';
@@ -8,14 +9,25 @@ import { GetReviewsByTargetQuery } from './get-reviews-by-target.query';
export class GetReviewsByTargetHandler implements IQueryHandler<GetReviewsByTargetQuery> {
constructor(
@Inject(REVIEW_REPOSITORY) private readonly reviewRepo: IReviewRepository,
private readonly logger: LoggerService,
) {}
async execute(query: GetReviewsByTargetQuery): Promise<PaginatedResult<ReviewItemData>> {
return this.reviewRepo.findByTarget(
query.targetType,
query.targetId,
query.page,
query.limit,
);
try {
return await this.reviewRepo.findByTarget(
query.targetType,
query.targetId,
query.page,
query.limit,
);
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(
`Failed to get reviews by target: ${error instanceof Error ? error.message : String(error)}`,
error instanceof Error ? error.stack : undefined,
'GetReviewsByTargetHandler',
);
throw new InternalServerErrorException('Lỗi khi lấy danh sách đánh giá');
}
}
}

View File

@@ -1,5 +1,6 @@
import { Inject } from '@nestjs/common';
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { DomainException, type LoggerService } from '@modules/shared';
import { type ReviewItemData } from '../../../domain/repositories/review-read.dto';
import { REVIEW_REPOSITORY, type IReviewRepository, type PaginatedResult } from '../../../domain/repositories/review.repository';
import { GetReviewsByUserQuery } from './get-reviews-by-user.query';
@@ -8,9 +9,20 @@ import { GetReviewsByUserQuery } from './get-reviews-by-user.query';
export class GetReviewsByUserHandler implements IQueryHandler<GetReviewsByUserQuery> {
constructor(
@Inject(REVIEW_REPOSITORY) private readonly reviewRepo: IReviewRepository,
private readonly logger: LoggerService,
) {}
async execute(query: GetReviewsByUserQuery): Promise<PaginatedResult<ReviewItemData>> {
return this.reviewRepo.findByUserId(query.userId, query.page, query.limit);
try {
return await this.reviewRepo.findByUserId(query.userId, query.page, query.limit);
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(
`Failed to get reviews by user: ${error instanceof Error ? error.message : String(error)}`,
error instanceof Error ? error.stack : undefined,
'GetReviewsByUserHandler',
);
throw new InternalServerErrorException('Lỗi khi lấy danh sách đánh giá');
}
}
}