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:
@@ -1,7 +1,7 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { createId } from '@paralleldrive/cuid2';
|
||||
import { ValidationException, type CacheService, CachePrefix, type LoggerService } from '@modules/shared';
|
||||
import { DomainException, ValidationException, type CacheService, CachePrefix, type LoggerService } from '@modules/shared';
|
||||
import { ListingEntity } from '../../../domain/entities/listing.entity';
|
||||
import { PropertyEntity } from '../../../domain/entities/property.entity';
|
||||
import { LISTING_REPOSITORY, type IListingRepository } from '../../../domain/repositories/listing.repository';
|
||||
@@ -51,126 +51,136 @@ export class CreateListingHandler implements ICommandHandler<CreateListingComman
|
||||
) {}
|
||||
|
||||
async execute(command: CreateListingCommand): Promise<CreateListingResult> {
|
||||
// Validate value objects
|
||||
const addressResult = Address.create(command.address, command.ward, command.district, command.city);
|
||||
if (addressResult.isErr) throw new ValidationException(addressResult.unwrapErr());
|
||||
|
||||
const geoPointResult = GeoPoint.create(command.latitude, command.longitude);
|
||||
if (geoPointResult.isErr) throw new ValidationException(geoPointResult.unwrapErr());
|
||||
|
||||
const priceResult = Price.create(command.priceVND);
|
||||
if (priceResult.isErr) throw new ValidationException(priceResult.unwrapErr());
|
||||
|
||||
const address = addressResult.unwrap();
|
||||
const geoPoint = geoPointResult.unwrap();
|
||||
const price = priceResult.unwrap();
|
||||
|
||||
// Create property
|
||||
const propertyId = createId();
|
||||
const property = PropertyEntity.createNew(propertyId, {
|
||||
propertyType: command.propertyType,
|
||||
title: command.title,
|
||||
description: command.description,
|
||||
address,
|
||||
location: geoPoint,
|
||||
areaM2: command.areaM2,
|
||||
usableAreaM2: command.usableAreaM2 ?? null,
|
||||
bedrooms: command.bedrooms ?? null,
|
||||
bathrooms: command.bathrooms ?? null,
|
||||
floors: command.floors ?? null,
|
||||
floor: command.floor ?? null,
|
||||
totalFloors: command.totalFloors ?? null,
|
||||
direction: command.direction ?? null,
|
||||
yearBuilt: command.yearBuilt ?? null,
|
||||
legalStatus: command.legalStatus ?? null,
|
||||
amenities: command.amenities ?? null,
|
||||
nearbyPOIs: command.nearbyPOIs ?? null,
|
||||
metroDistanceM: command.metroDistanceM ?? null,
|
||||
projectName: command.projectName ?? null,
|
||||
});
|
||||
|
||||
await this.propertyRepo.save(property);
|
||||
|
||||
// Create listing
|
||||
const listingId = createId();
|
||||
const listing = ListingEntity.createNew(
|
||||
listingId,
|
||||
propertyId,
|
||||
command.sellerId,
|
||||
command.transactionType,
|
||||
price,
|
||||
command.areaM2,
|
||||
command.agentId,
|
||||
command.rentPriceMonthly,
|
||||
command.commissionPct,
|
||||
);
|
||||
|
||||
await this.listingRepo.save(listing);
|
||||
|
||||
// Publish domain events
|
||||
const events = [...property.clearDomainEvents(), ...listing.clearDomainEvents()];
|
||||
for (const event of events) {
|
||||
this.eventBus.publish(event);
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
this.cache.invalidateByPrefix(CachePrefix.SEARCH),
|
||||
this.cache.invalidateByPrefix(CachePrefix.MARKET_DISTRICT),
|
||||
this.cache.invalidateByPrefix(CachePrefix.MARKET_REPORT),
|
||||
]);
|
||||
|
||||
// Duplicate detection — flag but never block creation
|
||||
let duplicateWarnings: DuplicateWarning[] = [];
|
||||
try {
|
||||
const candidates = await this.duplicateDetector.findDuplicates({
|
||||
excludePropertyId: propertyId,
|
||||
latitude: command.latitude,
|
||||
longitude: command.longitude,
|
||||
// Validate value objects
|
||||
const addressResult = Address.create(command.address, command.ward, command.district, command.city);
|
||||
if (addressResult.isErr) throw new ValidationException(addressResult.unwrapErr());
|
||||
|
||||
const geoPointResult = GeoPoint.create(command.latitude, command.longitude);
|
||||
if (geoPointResult.isErr) throw new ValidationException(geoPointResult.unwrapErr());
|
||||
|
||||
const priceResult = Price.create(command.priceVND);
|
||||
if (priceResult.isErr) throw new ValidationException(priceResult.unwrapErr());
|
||||
|
||||
const address = addressResult.unwrap();
|
||||
const geoPoint = geoPointResult.unwrap();
|
||||
const price = priceResult.unwrap();
|
||||
|
||||
// Create property
|
||||
const propertyId = createId();
|
||||
const property = PropertyEntity.createNew(propertyId, {
|
||||
propertyType: command.propertyType,
|
||||
title: command.title,
|
||||
propertyType: command.propertyType,
|
||||
});
|
||||
|
||||
duplicateWarnings = candidates.map((c) => ({
|
||||
listingId: c.listingId,
|
||||
propertyId: c.propertyId,
|
||||
title: c.title,
|
||||
address: c.address,
|
||||
district: c.district,
|
||||
distanceMeters: c.distanceMeters,
|
||||
titleSimilarity: c.titleSimilarity,
|
||||
}));
|
||||
} catch {
|
||||
this.logger.warn('Duplicate detection failed — listing created without warnings', 'CreateListingHandler');
|
||||
}
|
||||
|
||||
// Price validation — flag but never block creation
|
||||
let priceWarning: PriceWarning | undefined;
|
||||
try {
|
||||
const priceResult = await this.priceValidator.validate({
|
||||
priceVND: command.priceVND,
|
||||
description: command.description,
|
||||
address,
|
||||
location: geoPoint,
|
||||
areaM2: command.areaM2,
|
||||
propertyType: command.propertyType,
|
||||
district: command.district,
|
||||
usableAreaM2: command.usableAreaM2 ?? null,
|
||||
bedrooms: command.bedrooms ?? null,
|
||||
bathrooms: command.bathrooms ?? null,
|
||||
floors: command.floors ?? null,
|
||||
floor: command.floor ?? null,
|
||||
totalFloors: command.totalFloors ?? null,
|
||||
direction: command.direction ?? null,
|
||||
yearBuilt: command.yearBuilt ?? null,
|
||||
legalStatus: command.legalStatus ?? null,
|
||||
amenities: command.amenities ?? null,
|
||||
nearbyPOIs: command.nearbyPOIs ?? null,
|
||||
metroDistanceM: command.metroDistanceM ?? null,
|
||||
projectName: command.projectName ?? null,
|
||||
});
|
||||
|
||||
if (priceResult.isSuspicious) {
|
||||
priceWarning = {
|
||||
pricePerM2: priceResult.pricePerM2,
|
||||
minPricePerM2: priceResult.minPricePerM2,
|
||||
maxPricePerM2: priceResult.maxPricePerM2,
|
||||
reason: priceResult.reason!,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
this.logger.warn('Price validation failed — listing created without price warning', 'CreateListingHandler');
|
||||
}
|
||||
await this.propertyRepo.save(property);
|
||||
|
||||
return {
|
||||
listingId,
|
||||
propertyId,
|
||||
status: listing.status,
|
||||
duplicateWarnings,
|
||||
priceWarning,
|
||||
};
|
||||
// Create listing
|
||||
const listingId = createId();
|
||||
const listing = ListingEntity.createNew(
|
||||
listingId,
|
||||
propertyId,
|
||||
command.sellerId,
|
||||
command.transactionType,
|
||||
price,
|
||||
command.areaM2,
|
||||
command.agentId,
|
||||
command.rentPriceMonthly,
|
||||
command.commissionPct,
|
||||
);
|
||||
|
||||
await this.listingRepo.save(listing);
|
||||
|
||||
// Publish domain events
|
||||
const events = [...property.clearDomainEvents(), ...listing.clearDomainEvents()];
|
||||
for (const event of events) {
|
||||
this.eventBus.publish(event);
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
this.cache.invalidateByPrefix(CachePrefix.SEARCH),
|
||||
this.cache.invalidateByPrefix(CachePrefix.MARKET_DISTRICT),
|
||||
this.cache.invalidateByPrefix(CachePrefix.MARKET_REPORT),
|
||||
]);
|
||||
|
||||
// Duplicate detection — flag but never block creation
|
||||
let duplicateWarnings: DuplicateWarning[] = [];
|
||||
try {
|
||||
const candidates = await this.duplicateDetector.findDuplicates({
|
||||
excludePropertyId: propertyId,
|
||||
latitude: command.latitude,
|
||||
longitude: command.longitude,
|
||||
title: command.title,
|
||||
propertyType: command.propertyType,
|
||||
});
|
||||
|
||||
duplicateWarnings = candidates.map((c) => ({
|
||||
listingId: c.listingId,
|
||||
propertyId: c.propertyId,
|
||||
title: c.title,
|
||||
address: c.address,
|
||||
district: c.district,
|
||||
distanceMeters: c.distanceMeters,
|
||||
titleSimilarity: c.titleSimilarity,
|
||||
}));
|
||||
} catch {
|
||||
this.logger.warn('Duplicate detection failed — listing created without warnings', 'CreateListingHandler');
|
||||
}
|
||||
|
||||
// Price validation — flag but never block creation
|
||||
let priceWarning: PriceWarning | undefined;
|
||||
try {
|
||||
const priceResult = await this.priceValidator.validate({
|
||||
priceVND: command.priceVND,
|
||||
areaM2: command.areaM2,
|
||||
propertyType: command.propertyType,
|
||||
district: command.district,
|
||||
});
|
||||
|
||||
if (priceResult.isSuspicious) {
|
||||
priceWarning = {
|
||||
pricePerM2: priceResult.pricePerM2,
|
||||
minPricePerM2: priceResult.minPricePerM2,
|
||||
maxPricePerM2: priceResult.maxPricePerM2,
|
||||
reason: priceResult.reason!,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
this.logger.warn('Price validation failed — listing created without price warning', 'CreateListingHandler');
|
||||
}
|
||||
|
||||
return {
|
||||
listingId,
|
||||
propertyId,
|
||||
status: listing.status,
|
||||
duplicateWarnings,
|
||||
priceWarning,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DomainException) throw error;
|
||||
this.logger.error(
|
||||
`Failed to create listing: ${error instanceof Error ? error.message : error}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
this.constructor.name,
|
||||
);
|
||||
throw new InternalServerErrorException('Không thể tạo tin đăng bất động sản');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { NotFoundException, CacheService, CachePrefix } from '@modules/shared';
|
||||
import { DomainException, NotFoundException, CacheService, CachePrefix, type LoggerService } from '@modules/shared';
|
||||
import { LISTING_REPOSITORY, type IListingRepository } from '../../../domain/repositories/listing.repository';
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI needs runtime reference
|
||||
import { ModerationService } from '../../../domain/services/moderation.service';
|
||||
@@ -13,32 +13,43 @@ export class ModerateListingHandler implements ICommandHandler<ModerateListingCo
|
||||
private readonly eventBus: EventBus,
|
||||
private readonly cache: CacheService,
|
||||
private readonly moderationService: ModerationService,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async execute(command: ModerateListingCommand): Promise<{ status: string }> {
|
||||
const listing = await this.listingRepo.findById(command.listingId);
|
||||
if (!listing) {
|
||||
throw new NotFoundException('Listing', command.listingId);
|
||||
try {
|
||||
const listing = await this.listingRepo.findById(command.listingId);
|
||||
if (!listing) {
|
||||
throw new NotFoundException('Listing', command.listingId);
|
||||
}
|
||||
|
||||
this.moderationService.applyModeration(listing, {
|
||||
action: command.action,
|
||||
moderationScore: command.moderationScore,
|
||||
notes: command.notes,
|
||||
});
|
||||
|
||||
await this.listingRepo.update(listing);
|
||||
|
||||
const events = listing.clearDomainEvents();
|
||||
for (const event of events) {
|
||||
this.eventBus.publish(event);
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
this.cache.invalidate(CacheService.buildKey(CachePrefix.LISTING, command.listingId)),
|
||||
this.cache.invalidateByPrefix(CachePrefix.SEARCH),
|
||||
]);
|
||||
|
||||
return { status: listing.status };
|
||||
} catch (error) {
|
||||
if (error instanceof DomainException) throw error;
|
||||
this.logger.error(
|
||||
`Failed to moderate listing ${command.listingId}: ${error instanceof Error ? error.message : error}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
this.constructor.name,
|
||||
);
|
||||
throw new InternalServerErrorException('Không thể kiểm duyệt tin đăng');
|
||||
}
|
||||
|
||||
this.moderationService.applyModeration(listing, {
|
||||
action: command.action,
|
||||
moderationScore: command.moderationScore,
|
||||
notes: command.notes,
|
||||
});
|
||||
|
||||
await this.listingRepo.update(listing);
|
||||
|
||||
const events = listing.clearDomainEvents();
|
||||
for (const event of events) {
|
||||
this.eventBus.publish(event);
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
this.cache.invalidate(CacheService.buildKey(CachePrefix.LISTING, command.listingId)),
|
||||
this.cache.invalidateByPrefix(CachePrefix.SEARCH),
|
||||
]);
|
||||
|
||||
return { status: listing.status };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { NotFoundException, CacheService, CachePrefix } from '@modules/shared';
|
||||
import { DomainException, NotFoundException, CacheService, CachePrefix, type LoggerService } from '@modules/shared';
|
||||
import { LISTING_REPOSITORY, type IListingRepository } from '../../../domain/repositories/listing.repository';
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI needs runtime reference
|
||||
import { ModerationService } from '../../../domain/services/moderation.service';
|
||||
@@ -13,32 +13,43 @@ export class UpdateListingStatusHandler implements ICommandHandler<UpdateListing
|
||||
private readonly eventBus: EventBus,
|
||||
private readonly cache: CacheService,
|
||||
private readonly moderationService: ModerationService,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async execute(command: UpdateListingStatusCommand): Promise<{ status: string }> {
|
||||
const listing = await this.listingRepo.findById(command.listingId);
|
||||
if (!listing) {
|
||||
throw new NotFoundException('Listing', command.listingId);
|
||||
try {
|
||||
const listing = await this.listingRepo.findById(command.listingId);
|
||||
if (!listing) {
|
||||
throw new NotFoundException('Listing', command.listingId);
|
||||
}
|
||||
|
||||
this.moderationService.applyStatusTransition(
|
||||
listing,
|
||||
command.newStatus,
|
||||
command.moderationNotes,
|
||||
);
|
||||
|
||||
await this.listingRepo.update(listing);
|
||||
|
||||
const events = listing.clearDomainEvents();
|
||||
for (const event of events) {
|
||||
this.eventBus.publish(event);
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
this.cache.invalidate(CacheService.buildKey(CachePrefix.LISTING, command.listingId)),
|
||||
this.cache.invalidateByPrefix(CachePrefix.SEARCH),
|
||||
]);
|
||||
|
||||
return { status: listing.status };
|
||||
} catch (error) {
|
||||
if (error instanceof DomainException) throw error;
|
||||
this.logger.error(
|
||||
`Failed to update listing status ${command.listingId}: ${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 trạng thái tin đăng');
|
||||
}
|
||||
|
||||
this.moderationService.applyStatusTransition(
|
||||
listing,
|
||||
command.newStatus,
|
||||
command.moderationNotes,
|
||||
);
|
||||
|
||||
await this.listingRepo.update(listing);
|
||||
|
||||
const events = listing.clearDomainEvents();
|
||||
for (const event of events) {
|
||||
this.eventBus.publish(event);
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
this.cache.invalidate(CacheService.buildKey(CachePrefix.LISTING, command.listingId)),
|
||||
this.cache.invalidateByPrefix(CachePrefix.SEARCH),
|
||||
]);
|
||||
|
||||
return { status: listing.status };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { createId } from '@paralleldrive/cuid2';
|
||||
import { type LoggerService, NotFoundException, ValidationException } from '@modules/shared';
|
||||
import { DomainException, type LoggerService, NotFoundException, ValidationException } from '@modules/shared';
|
||||
import { PropertyMediaEntity } from '../../../domain/entities/property-media.entity';
|
||||
import { PROPERTY_REPOSITORY, type IPropertyRepository } from '../../../domain/repositories/property.repository';
|
||||
import { MEDIA_STORAGE_SERVICE, type IMediaStorageService } from '../../../infrastructure/services/media-storage.service';
|
||||
@@ -18,47 +18,57 @@ export class UploadMediaHandler implements ICommandHandler<UploadMediaCommand> {
|
||||
) {}
|
||||
|
||||
async execute(command: UploadMediaCommand): Promise<{ mediaId: string; url: string }> {
|
||||
const property = await this.propertyRepo.findById(command.propertyId);
|
||||
if (!property) {
|
||||
throw new NotFoundException('Property', command.propertyId);
|
||||
}
|
||||
|
||||
const mediaCount = await this.propertyRepo.countMediaByPropertyId(command.propertyId);
|
||||
if (mediaCount >= MAX_MEDIA_PER_PROPERTY) {
|
||||
throw new ValidationException(`Tối đa ${MAX_MEDIA_PER_PROPERTY} ảnh/video cho mỗi bất động sản`);
|
||||
}
|
||||
|
||||
const mediaType = command.file.mimetype.startsWith('video/') ? 'video' as const : 'image' as const;
|
||||
|
||||
let url: string;
|
||||
try {
|
||||
url = await this.mediaStorage.upload(
|
||||
command.file.buffer,
|
||||
command.file.originalname,
|
||||
command.file.mimetype,
|
||||
`properties/${command.propertyId}`,
|
||||
const property = await this.propertyRepo.findById(command.propertyId);
|
||||
if (!property) {
|
||||
throw new NotFoundException('Property', command.propertyId);
|
||||
}
|
||||
|
||||
const mediaCount = await this.propertyRepo.countMediaByPropertyId(command.propertyId);
|
||||
if (mediaCount >= MAX_MEDIA_PER_PROPERTY) {
|
||||
throw new ValidationException(`Tối đa ${MAX_MEDIA_PER_PROPERTY} ảnh/video cho mỗi bất động sản`);
|
||||
}
|
||||
|
||||
const mediaType = command.file.mimetype.startsWith('video/') ? 'video' as const : 'image' as const;
|
||||
|
||||
let url: string;
|
||||
try {
|
||||
url = await this.mediaStorage.upload(
|
||||
command.file.buffer,
|
||||
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 media = PropertyMediaEntity.createNew(
|
||||
mediaId,
|
||||
command.propertyId,
|
||||
url,
|
||||
mediaType,
|
||||
mediaCount, // next order index
|
||||
command.caption,
|
||||
);
|
||||
|
||||
await this.propertyRepo.addMedia(media);
|
||||
|
||||
return { mediaId, url };
|
||||
} catch (error) {
|
||||
if (error instanceof DomainException) throw error;
|
||||
this.logger.error(
|
||||
`Media upload failed for property ${command.propertyId}: ${error instanceof Error ? error.message : error}`,
|
||||
`Failed to upload media for property ${command.propertyId}: ${error instanceof Error ? error.message : error}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
'UploadMediaHandler',
|
||||
this.constructor.name,
|
||||
);
|
||||
throw new ValidationException('Tải lên media thất bại, vui lòng thử lại');
|
||||
throw new InternalServerErrorException('Không thể tải lên hình ảnh/video cho bất động sản');
|
||||
}
|
||||
|
||||
const mediaId = createId();
|
||||
const media = PropertyMediaEntity.createNew(
|
||||
mediaId,
|
||||
command.propertyId,
|
||||
url,
|
||||
mediaType,
|
||||
mediaCount, // next order index
|
||||
command.caption,
|
||||
);
|
||||
|
||||
await this.propertyRepo.addMedia(media);
|
||||
|
||||
return { mediaId, url };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||
import { NotFoundException, CacheService, CachePrefix, CacheTTL } from '@modules/shared';
|
||||
import { DomainException, NotFoundException, CacheService, CachePrefix, CacheTTL, type LoggerService } from '@modules/shared';
|
||||
import { type ListingDetailData } from '../../../domain/repositories/listing-read.dto';
|
||||
import { LISTING_REPOSITORY, type IListingRepository } from '../../../domain/repositories/listing.repository';
|
||||
import { GetListingQuery } from './get-listing.query';
|
||||
@@ -13,22 +13,33 @@ export class GetListingHandler implements IQueryHandler<GetListingQuery> {
|
||||
constructor(
|
||||
@Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository,
|
||||
private readonly cache: CacheService,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async execute(query: GetListingQuery): Promise<ListingDetailData> {
|
||||
const cacheKey = CacheService.buildKey(CachePrefix.LISTING, query.listingId);
|
||||
try {
|
||||
const cacheKey = CacheService.buildKey(CachePrefix.LISTING, query.listingId);
|
||||
|
||||
return this.cache.getOrSet(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const result = await this.listingRepo.findByIdWithProperty(query.listingId);
|
||||
if (!result) {
|
||||
throw new NotFoundException('Listing', query.listingId);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
CacheTTL.LISTING_DETAIL,
|
||||
'listing',
|
||||
);
|
||||
return this.cache.getOrSet(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const result = await this.listingRepo.findByIdWithProperty(query.listingId);
|
||||
if (!result) {
|
||||
throw new NotFoundException('Listing', query.listingId);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
CacheTTL.LISTING_DETAIL,
|
||||
'listing',
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof DomainException) throw error;
|
||||
this.logger.error(
|
||||
`Failed to get listing ${query.listingId}: ${error instanceof Error ? error.message : error}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
this.constructor.name,
|
||||
);
|
||||
throw new InternalServerErrorException('Không thể lấy thông tin tin đăng');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||
import { DomainException, type LoggerService } from '@modules/shared';
|
||||
import { type ListingSearchItem } from '../../../domain/repositories/listing-read.dto';
|
||||
import { LISTING_REPOSITORY, type IListingRepository, type PaginatedResult } from '../../../domain/repositories/listing.repository';
|
||||
import { GetPendingModerationQuery } from './get-pending-moderation.query';
|
||||
@@ -8,9 +9,20 @@ import { GetPendingModerationQuery } from './get-pending-moderation.query';
|
||||
export class GetPendingModerationHandler implements IQueryHandler<GetPendingModerationQuery> {
|
||||
constructor(
|
||||
@Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async execute(query: GetPendingModerationQuery): Promise<PaginatedResult<ListingSearchItem>> {
|
||||
return this.listingRepo.findByStatus('PENDING_REVIEW', query.page, query.limit);
|
||||
try {
|
||||
return this.listingRepo.findByStatus('PENDING_REVIEW', query.page, query.limit);
|
||||
} catch (error) {
|
||||
if (error instanceof DomainException) throw error;
|
||||
this.logger.error(
|
||||
`Failed to get pending moderation listings: ${error instanceof Error ? error.message : error}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
this.constructor.name,
|
||||
);
|
||||
throw new InternalServerErrorException('Không thể lấy danh sách tin đăng chờ kiểm duyệt');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { type ListingSearchItem } from '../../../domain/repositories/listing-read.dto';
|
||||
import { LISTING_REPOSITORY, type IListingRepository, type PaginatedResult } from '../../../domain/repositories/listing.repository';
|
||||
import { SearchListingsQuery } from './search-listings.query';
|
||||
@@ -10,44 +10,55 @@ export class SearchListingsHandler implements IQueryHandler<SearchListingsQuery>
|
||||
constructor(
|
||||
@Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository,
|
||||
private readonly cacheService: CacheService,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async execute(query: SearchListingsQuery): Promise<PaginatedResult<ListingSearchItem>> {
|
||||
const cacheKey = CacheService.buildKey(
|
||||
CachePrefix.SEARCH,
|
||||
query.status,
|
||||
query.transactionType,
|
||||
query.propertyType,
|
||||
query.city,
|
||||
query.district,
|
||||
query.minPrice?.toString(),
|
||||
query.maxPrice?.toString(),
|
||||
query.minArea?.toString(),
|
||||
query.maxArea?.toString(),
|
||||
query.bedrooms?.toString(),
|
||||
String(query.page),
|
||||
String(query.limit),
|
||||
);
|
||||
try {
|
||||
const cacheKey = CacheService.buildKey(
|
||||
CachePrefix.SEARCH,
|
||||
query.status,
|
||||
query.transactionType,
|
||||
query.propertyType,
|
||||
query.city,
|
||||
query.district,
|
||||
query.minPrice?.toString(),
|
||||
query.maxPrice?.toString(),
|
||||
query.minArea?.toString(),
|
||||
query.maxArea?.toString(),
|
||||
query.bedrooms?.toString(),
|
||||
String(query.page),
|
||||
String(query.limit),
|
||||
);
|
||||
|
||||
return this.cacheService.getOrSet(
|
||||
cacheKey,
|
||||
async () =>
|
||||
this.listingRepo.search({
|
||||
status: query.status,
|
||||
transactionType: query.transactionType,
|
||||
propertyType: query.propertyType,
|
||||
city: query.city,
|
||||
district: query.district,
|
||||
minPrice: query.minPrice,
|
||||
maxPrice: query.maxPrice,
|
||||
minArea: query.minArea,
|
||||
maxArea: query.maxArea,
|
||||
bedrooms: query.bedrooms,
|
||||
page: query.page,
|
||||
limit: query.limit,
|
||||
}),
|
||||
CacheTTL.SEARCH_RESULTS,
|
||||
'listing_search',
|
||||
);
|
||||
return this.cacheService.getOrSet(
|
||||
cacheKey,
|
||||
async () =>
|
||||
this.listingRepo.search({
|
||||
status: query.status,
|
||||
transactionType: query.transactionType,
|
||||
propertyType: query.propertyType,
|
||||
city: query.city,
|
||||
district: query.district,
|
||||
minPrice: query.minPrice,
|
||||
maxPrice: query.maxPrice,
|
||||
minArea: query.minArea,
|
||||
maxArea: query.maxArea,
|
||||
bedrooms: query.bedrooms,
|
||||
page: query.page,
|
||||
limit: query.limit,
|
||||
}),
|
||||
CacheTTL.SEARCH_RESULTS,
|
||||
'listing_search',
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof DomainException) throw error;
|
||||
this.logger.error(
|
||||
`Failed to search listings: ${error instanceof Error ? error.message : error}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
this.constructor.name,
|
||||
);
|
||||
throw new InternalServerErrorException('Không thể tìm kiếm tin đăng bất động sản');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user