fix(api): add error handling to remaining 51 CQRS handlers across 8 modules

Wraps every handler's execute() method in a try-catch block that:
- Re-throws DomainExceptions to preserve structured error responses
- Logs unexpected infrastructure errors with full context
- Throws InternalServerErrorException with Vietnamese user message

Modules updated:
- auth (11 handlers: register, refresh-token, verify-kyc, deletions, profile queries)
- listings (7 handlers: create, moderate, upload, status, search, queries)
- payments (5 handlers: create, callback, refund, status, transactions)
- subscriptions (7 handlers: create, cancel, upgrade, meter, quota, billing, plans)
- analytics (8 handlers: reports, events, market-index, district, heatmap, trends, valuation)
- search (9 handlers: saved-search CRUD, reindex, sync, geo-search, properties)
- notifications (1 handler: send-notification)
- agents (3 handlers: quality-score, dashboard, public-profile)

Combined with the previous commit (29 handlers in admin, inquiries, leads, reviews),
all 80+ CQRS handlers now have comprehensive error handling.

Verification:
- pnpm typecheck: 0 errors
- pnpm test: 1387 tests passed (228 files)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-11 20:04:42 +07:00
parent 7008230424
commit 18e50a9649
51 changed files with 1998 additions and 1499 deletions

View File

@@ -1,5 +1,6 @@
import { Inject } from '@nestjs/common';
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { DomainException, type LoggerService } from '@modules/shared';
import {
MARKET_INDEX_REPOSITORY,
type IMarketIndexRepository,
@@ -18,20 +19,31 @@ export interface GenerateReportResult {
export class GenerateReportHandler implements ICommandHandler<GenerateReportCommand> {
constructor(
@Inject(MARKET_INDEX_REPOSITORY) private readonly marketIndexRepo: IMarketIndexRepository,
private readonly logger: LoggerService,
) {}
async execute(command: GenerateReportCommand): Promise<GenerateReportResult> {
const data = await this.marketIndexRepo.getMarketReport(
command.city,
command.period,
command.propertyType,
);
try {
const data = await this.marketIndexRepo.getMarketReport(
command.city,
command.period,
command.propertyType,
);
return {
city: command.city,
period: command.period,
data,
generatedAt: new Date().toISOString(),
};
return {
city: command.city,
period: command.period,
data,
generatedAt: new Date().toISOString(),
};
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(
`Failed to tạo báo cáo thị trường: ${error instanceof Error ? error.message : error}`,
error instanceof Error ? error.stack : undefined,
this.constructor.name,
);
throw new InternalServerErrorException('Không thể tạo báo cáo thị trường. Vui lòng thử lại sau.');
}
}
}

View File

@@ -1,5 +1,6 @@
import { InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { type LoggerService } from '@modules/shared';
import { DomainException, type LoggerService } from '@modules/shared';
import { TrackEventCommand } from './track-event.command';
export interface TrackEventResult {
@@ -12,14 +13,24 @@ export class TrackEventHandler implements ICommandHandler<TrackEventCommand> {
constructor(private readonly logger: LoggerService) {}
async execute(command: TrackEventCommand): Promise<TrackEventResult> {
this.logger.log(
`Analytics event: ${command.eventType} on ${command.entityType}:${command.entityId}`,
'TrackEventHandler',
);
try {
this.logger.log(
`Analytics event: ${command.eventType} on ${command.entityType}:${command.entityId}`,
'TrackEventHandler',
);
return {
tracked: true,
eventType: command.eventType,
};
return {
tracked: true,
eventType: command.eventType,
};
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(
`Failed to ghi nhận sự kiện phân tích: ${error instanceof Error ? error.message : error}`,
error instanceof Error ? error.stack : undefined,
this.constructor.name,
);
throw new InternalServerErrorException('Không thể ghi nhận sự kiện phân tích. Vui lòng thử lại sau.');
}
}
}

View File

@@ -1,6 +1,6 @@
import { Inject } from '@nestjs/common';
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { type CacheService, CachePrefix } from '@modules/shared';
import { DomainException, type CacheService, CachePrefix, type LoggerService } from '@modules/shared';
import { MarketIndexEntity } from '../../../domain/entities/market-index.entity';
import {
MARKET_INDEX_REPOSITORY,
@@ -18,18 +18,40 @@ export class UpdateMarketIndexHandler implements ICommandHandler<UpdateMarketInd
constructor(
@Inject(MARKET_INDEX_REPOSITORY) private readonly marketIndexRepo: IMarketIndexRepository,
private readonly cache: CacheService,
private readonly logger: LoggerService,
) {}
async execute(command: UpdateMarketIndexCommand): Promise<UpdateMarketIndexResult> {
const existing = await this.marketIndexRepo.findByKey(
command.district,
command.city,
command.propertyType,
command.period,
);
try {
const existing = await this.marketIndexRepo.findByKey(
command.district,
command.city,
command.propertyType,
command.period,
);
if (existing) {
existing.updateMetrics(
if (existing) {
existing.updateMetrics(
command.medianPrice,
command.avgPriceM2,
command.totalListings,
command.daysOnMarket,
command.inventoryLevel,
command.absorptionRate,
command.yoyChange,
);
await this.marketIndexRepo.update(existing);
await this.invalidateMarketCaches();
return { id: existing.id, created: false };
}
const id = crypto.randomUUID();
const entity = MarketIndexEntity.createNew(
id,
command.district,
command.city,
command.propertyType,
command.period,
command.medianPrice,
command.avgPriceM2,
command.totalListings,
@@ -38,32 +60,21 @@ export class UpdateMarketIndexHandler implements ICommandHandler<UpdateMarketInd
command.absorptionRate,
command.yoyChange,
);
await this.marketIndexRepo.update(existing);
await this.marketIndexRepo.save(entity);
await this.invalidateMarketCaches();
return { id: existing.id, created: false };
return { id, created: true };
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(
`Failed to cập nhật chỉ số thị trường: ${error instanceof Error ? error.message : error}`,
error instanceof Error ? error.stack : undefined,
this.constructor.name,
);
throw new InternalServerErrorException('Không thể cập nhật chỉ số thị trường. Vui lòng thử lại sau.');
}
const id = crypto.randomUUID();
const entity = MarketIndexEntity.createNew(
id,
command.district,
command.city,
command.propertyType,
command.period,
command.medianPrice,
command.avgPriceM2,
command.totalListings,
command.daysOnMarket,
command.inventoryLevel,
command.absorptionRate,
command.yoyChange,
);
await this.marketIndexRepo.save(entity);
await this.invalidateMarketCaches();
return { id, created: true };
}
private async invalidateMarketCaches(): Promise<void> {

View File

@@ -1,6 +1,6 @@
import { Inject } from '@nestjs/common';
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { type CacheService, CachePrefix, CacheTTL, Cacheable } from '@modules/shared';
import { DomainException, type CacheService, CachePrefix, CacheTTL, Cacheable, type LoggerService } from '@modules/shared';
import {
MARKET_INDEX_REPOSITORY,
type IMarketIndexRepository,
@@ -19,6 +19,7 @@ export class GetDistrictStatsHandler implements IQueryHandler<GetDistrictStatsQu
constructor(
@Inject(MARKET_INDEX_REPOSITORY) private readonly marketIndexRepo: IMarketIndexRepository,
private readonly cacheService: CacheService,
private readonly logger: LoggerService,
) {}
@Cacheable({
@@ -31,7 +32,17 @@ export class GetDistrictStatsHandler implements IQueryHandler<GetDistrictStatsQu
},
})
async execute(query: GetDistrictStatsQuery): Promise<DistrictStatsDto> {
const districts = await this.marketIndexRepo.getDistrictStats(query.city, query.period);
return { city: query.city, period: query.period, districts };
try {
const districts = await this.marketIndexRepo.getDistrictStats(query.city, query.period);
return { city: query.city, period: query.period, districts };
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(
`Failed to truy vấn thống kê quận/huyện: ${error instanceof Error ? error.message : error}`,
error instanceof Error ? error.stack : undefined,
this.constructor.name,
);
throw new InternalServerErrorException('Không thể truy vấn thống kê quận/huyện. Vui lòng thử lại sau.');
}
}
}

View File

@@ -1,6 +1,6 @@
import { Inject } from '@nestjs/common';
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared';
import { DomainException, CacheService, CachePrefix, CacheTTL, type LoggerService } from '@modules/shared';
import {
MARKET_INDEX_REPOSITORY,
type IMarketIndexRepository,
@@ -19,19 +19,30 @@ export class GetHeatmapHandler implements IQueryHandler<GetHeatmapQuery> {
constructor(
@Inject(MARKET_INDEX_REPOSITORY) private readonly marketIndexRepo: IMarketIndexRepository,
private readonly cache: CacheService,
private readonly logger: LoggerService,
) {}
async execute(query: GetHeatmapQuery): Promise<HeatmapDto> {
const cacheKey = CacheService.buildKey(CachePrefix.MARKET_HEATMAP, query.city, query.period);
try {
const cacheKey = CacheService.buildKey(CachePrefix.MARKET_HEATMAP, query.city, query.period);
return this.cache.getOrSet(
cacheKey,
async () => {
const dataPoints = await this.marketIndexRepo.getHeatmap(query.city, query.period);
return { city: query.city, period: query.period, dataPoints };
},
CacheTTL.HEATMAP,
'heatmap',
);
return this.cache.getOrSet(
cacheKey,
async () => {
const dataPoints = await this.marketIndexRepo.getHeatmap(query.city, query.period);
return { city: query.city, period: query.period, dataPoints };
},
CacheTTL.HEATMAP,
'heatmap',
);
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(
`Failed to truy vấn dữ liệu bản đồ nhiệt: ${error instanceof Error ? error.message : error}`,
error instanceof Error ? error.stack : undefined,
this.constructor.name,
);
throw new InternalServerErrorException('Không thể truy vấn dữ liệu bản đồ nhiệt. Vui lòng thử lại sau.');
}
}
}

View File

@@ -1,6 +1,6 @@
import { Inject } from '@nestjs/common';
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared';
import { DomainException, CacheService, CachePrefix, CacheTTL, type LoggerService } from '@modules/shared';
import {
MARKET_INDEX_REPOSITORY,
type IMarketIndexRepository,
@@ -19,23 +19,34 @@ export class GetMarketReportHandler implements IQueryHandler<GetMarketReportQuer
constructor(
@Inject(MARKET_INDEX_REPOSITORY) private readonly marketIndexRepo: IMarketIndexRepository,
private readonly cache: CacheService,
private readonly logger: LoggerService,
) {}
async execute(query: GetMarketReportQuery): Promise<MarketReportDto> {
const cacheKey = CacheService.buildKey(CachePrefix.MARKET_REPORT, query.city, query.period, query.propertyType);
try {
const cacheKey = CacheService.buildKey(CachePrefix.MARKET_REPORT, query.city, query.period, query.propertyType);
return this.cache.getOrSet(
cacheKey,
async () => {
const districts = await this.marketIndexRepo.getMarketReport(
query.city,
query.period,
query.propertyType,
);
return { city: query.city, period: query.period, districts };
},
CacheTTL.MARKET_REPORT,
'market_report',
);
return this.cache.getOrSet(
cacheKey,
async () => {
const districts = await this.marketIndexRepo.getMarketReport(
query.city,
query.period,
query.propertyType,
);
return { city: query.city, period: query.period, districts };
},
CacheTTL.MARKET_REPORT,
'market_report',
);
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(
`Failed to truy vấn báo cáo thị trường: ${error instanceof Error ? error.message : error}`,
error instanceof Error ? error.stack : undefined,
this.constructor.name,
);
throw new InternalServerErrorException('Không thể truy vấn báo cáo thị trường. Vui lòng thử lại sau.');
}
}
}

View File

@@ -1,6 +1,6 @@
import { Inject } from '@nestjs/common';
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared';
import { DomainException, CacheService, CachePrefix, CacheTTL, type LoggerService } from '@modules/shared';
import {
MARKET_INDEX_REPOSITORY,
type IMarketIndexRepository,
@@ -20,30 +20,41 @@ export class GetPriceTrendHandler implements IQueryHandler<GetPriceTrendQuery> {
constructor(
@Inject(MARKET_INDEX_REPOSITORY) private readonly marketIndexRepo: IMarketIndexRepository,
private readonly cache: CacheService,
private readonly logger: LoggerService,
) {}
async execute(query: GetPriceTrendQuery): Promise<PriceTrendDto> {
const cacheKey = CacheService.buildKey(
CachePrefix.MARKET_TREND,
query.district,
query.city,
query.propertyType,
query.periods?.join(','),
);
try {
const cacheKey = CacheService.buildKey(
CachePrefix.MARKET_TREND,
query.district,
query.city,
query.propertyType,
query.periods?.join(','),
);
return this.cache.getOrSet(
cacheKey,
async () => {
const trend = await this.marketIndexRepo.getPriceTrend(
query.district,
query.city,
query.propertyType,
query.periods,
);
return { district: query.district, city: query.city, propertyType: query.propertyType, trend };
},
CacheTTL.MARKET_DATA,
'price_trend',
);
return this.cache.getOrSet(
cacheKey,
async () => {
const trend = await this.marketIndexRepo.getPriceTrend(
query.district,
query.city,
query.propertyType,
query.periods,
);
return { district: query.district, city: query.city, propertyType: query.propertyType, trend };
},
CacheTTL.MARKET_DATA,
'price_trend',
);
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(
`Failed to truy vấn xu hướng giá: ${error instanceof Error ? error.message : error}`,
error instanceof Error ? error.stack : undefined,
this.constructor.name,
);
throw new InternalServerErrorException('Không thể truy vấn xu hướng giá. Vui lòng thử lại sau.');
}
}
}

View File

@@ -1,6 +1,6 @@
import { Inject } from '@nestjs/common';
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared';
import { DomainException, CacheService, CachePrefix, CacheTTL, type LoggerService } from '@modules/shared';
import {
AVM_SERVICE,
type IAVMService,
@@ -15,31 +15,42 @@ export class GetValuationHandler implements IQueryHandler<GetValuationQuery> {
constructor(
@Inject(AVM_SERVICE) private readonly avmService: IAVMService,
private readonly cache: CacheService,
private readonly logger: LoggerService,
) {}
async execute(query: GetValuationQuery): Promise<ValuationDto> {
const cacheKey = CacheService.buildKey(
CachePrefix.VALUATION,
query.propertyId ?? '',
query.latitude?.toString(),
query.longitude?.toString(),
query.areaM2?.toString(),
query.propertyType,
);
try {
const cacheKey = CacheService.buildKey(
CachePrefix.VALUATION,
query.propertyId ?? '',
query.latitude?.toString(),
query.longitude?.toString(),
query.areaM2?.toString(),
query.propertyType,
);
return this.cache.getOrSet(
cacheKey,
async () => {
return this.avmService.estimateValue({
propertyId: query.propertyId,
latitude: query.latitude,
longitude: query.longitude,
areaM2: query.areaM2,
propertyType: query.propertyType,
});
},
CacheTTL.MARKET_DATA,
'valuation',
);
return this.cache.getOrSet(
cacheKey,
async () => {
return this.avmService.estimateValue({
propertyId: query.propertyId,
latitude: query.latitude,
longitude: query.longitude,
areaM2: query.areaM2,
propertyType: query.propertyType,
});
},
CacheTTL.MARKET_DATA,
'valuation',
);
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(
`Failed to định giá bất động sản: ${error instanceof Error ? error.message : error}`,
error instanceof Error ? error.stack : undefined,
this.constructor.name,
);
throw new InternalServerErrorException('Không thể định giá bất động sản. Vui lòng thử lại sau.');
}
}
}