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,7 +1,8 @@
import { InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, type CommandBus, type ICommandHandler, type QueryBus } from '@nestjs/cqrs';
import { createId } from '@paralleldrive/cuid2';
import { type SavedSearch, type Prisma } from '@prisma/client';
import { ValidationException, type PrismaService, type LoggerService } from '@modules/shared';
import { DomainException, ValidationException, type PrismaService, type LoggerService } from '@modules/shared';
import { CheckQuotaQuery, type QuotaCheckResult, MeterUsageCommand } from '@modules/subscriptions';
import { CreateSavedSearchCommand } from './create-saved-search.command';
@@ -23,57 +24,67 @@ export class CreateSavedSearchHandler implements ICommandHandler<CreateSavedSear
) {}
async execute(command: CreateSavedSearchCommand): Promise<CreateSavedSearchResult> {
// Validate name
if (!command.name || command.name.trim().length === 0) {
throw new ValidationException('Tên tìm kiếm không được để trống');
}
if (command.name.trim().length > 100) {
throw new ValidationException('Tên tìm kiếm không được vượt quá 100 ký tự');
}
// Check quota
const quotaResult: QuotaCheckResult = await this.queryBus.execute(
new CheckQuotaQuery(command.userId, 'searches_saved'),
);
if (!quotaResult.allowed) {
throw new ValidationException(
`Bạn đã đạt giới hạn ${quotaResult.limit} tìm kiếm đã lưu. Vui lòng nâng cấp gói để tiếp tục.`,
);
}
const id = createId();
const savedSearch: SavedSearch = await this.prisma.savedSearch.create({
data: {
id,
userId: command.userId,
name: command.name.trim(),
filters: command.filters as Prisma.InputJsonValue,
alertEnabled: command.alertEnabled,
},
});
// Best-effort usage metering
try {
await this.commandBus.execute(
new MeterUsageCommand(command.userId, 'searches_saved', 1),
// Validate name
if (!command.name || command.name.trim().length === 0) {
throw new ValidationException('Tên tìm kiếm không được để trống');
}
if (command.name.trim().length > 100) {
throw new ValidationException('Tên tìm kiếm không được vượt quá 100 ký tự');
}
// Check quota
const quotaResult: QuotaCheckResult = await this.queryBus.execute(
new CheckQuotaQuery(command.userId, 'searches_saved'),
);
} catch (err) {
this.logger.warn(
`Usage metering failed for saved search: ${err instanceof Error ? err.message : String(err)}`,
'CreateSavedSearchHandler',
if (!quotaResult.allowed) {
throw new ValidationException(
`Bạn đã đạt giới hạn ${quotaResult.limit} tìm kiếm đã lưu. Vui lòng nâng cấp gói để tiếp tục.`,
);
}
const id = createId();
const savedSearch: SavedSearch = await this.prisma.savedSearch.create({
data: {
id,
userId: command.userId,
name: command.name.trim(),
filters: command.filters as Prisma.InputJsonValue,
alertEnabled: command.alertEnabled,
},
});
// Best-effort usage metering
try {
await this.commandBus.execute(
new MeterUsageCommand(command.userId, 'searches_saved', 1),
);
} catch (err) {
this.logger.warn(
`Usage metering failed for saved search: ${err instanceof Error ? err.message : String(err)}`,
'CreateSavedSearchHandler',
);
}
this.logger.log(`Saved search created: id=${id}, user=${command.userId}`, 'CreateSavedSearchHandler');
return {
id: savedSearch.id,
name: savedSearch.name,
filters: savedSearch.filters,
alertEnabled: savedSearch.alertEnabled,
createdAt: savedSearch.createdAt,
};
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(
`Failed to create saved search: ${error instanceof Error ? error.message : error}`,
error instanceof Error ? error.stack : undefined,
this.constructor.name,
);
throw new InternalServerErrorException('Không thể tạo tìm kiếm đã lưu');
}
this.logger.log(`Saved search created: id=${id}, user=${command.userId}`, 'CreateSavedSearchHandler');
return {
id: savedSearch.id,
name: savedSearch.name,
filters: savedSearch.filters,
alertEnabled: savedSearch.alertEnabled,
createdAt: savedSearch.createdAt,
};
}
}

View File

@@ -1,5 +1,6 @@
import { InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { ForbiddenException, NotFoundException, type PrismaService, type LoggerService } from '@modules/shared';
import { DomainException, ForbiddenException, NotFoundException, type PrismaService, type LoggerService } from '@modules/shared';
import { DeleteSavedSearchCommand } from './delete-saved-search.command';
@CommandHandler(DeleteSavedSearchCommand)
@@ -10,22 +11,32 @@ export class DeleteSavedSearchHandler implements ICommandHandler<DeleteSavedSear
) {}
async execute(command: DeleteSavedSearchCommand): Promise<{ deleted: boolean }> {
const savedSearch = await this.prisma.savedSearch.findUnique({
where: { id: command.id },
});
try {
const savedSearch = await this.prisma.savedSearch.findUnique({
where: { id: command.id },
});
if (!savedSearch) {
throw new NotFoundException('SavedSearch', command.id);
if (!savedSearch) {
throw new NotFoundException('SavedSearch', command.id);
}
if (savedSearch.userId !== command.userId) {
throw new ForbiddenException('Bạn không có quyền xóa tìm kiếm này');
}
await this.prisma.savedSearch.delete({ where: { id: command.id } });
this.logger.log(`Saved search deleted: id=${command.id}, user=${command.userId}`, 'DeleteSavedSearchHandler');
return { deleted: true };
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(
`Failed to delete saved search: ${error instanceof Error ? error.message : error}`,
error instanceof Error ? error.stack : undefined,
this.constructor.name,
);
throw new InternalServerErrorException('Không thể xóa tìm kiếm đã lưu');
}
if (savedSearch.userId !== command.userId) {
throw new ForbiddenException('Bạn không có quyền xóa tìm kiếm này');
}
await this.prisma.savedSearch.delete({ where: { id: command.id } });
this.logger.log(`Saved search deleted: id=${command.id}, user=${command.userId}`, 'DeleteSavedSearchHandler');
return { deleted: true };
}
}

View File

@@ -1,4 +1,6 @@
import { InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { DomainException, type LoggerService } from '@modules/shared';
import { type ListingIndexerService } from '../../../infrastructure/services/listing-indexer.service';
import { ReindexAllCommand } from './reindex-all.command';
@@ -9,9 +11,22 @@ export interface ReindexResult {
@CommandHandler(ReindexAllCommand)
export class ReindexAllHandler implements ICommandHandler<ReindexAllCommand> {
constructor(private readonly indexer: ListingIndexerService) {}
constructor(
private readonly indexer: ListingIndexerService,
private readonly logger: LoggerService,
) {}
async execute(): Promise<ReindexResult> {
return this.indexer.reindexAll();
try {
return this.indexer.reindexAll();
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(
`Failed to reindex all listings: ${error instanceof Error ? error.message : error}`,
error instanceof Error ? error.stack : undefined,
this.constructor.name,
);
throw new InternalServerErrorException('Không thể đánh chỉ mục lại toàn bộ danh sách');
}
}
}

View File

@@ -1,12 +1,27 @@
import { InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { DomainException, type LoggerService } from '@modules/shared';
import { type ListingIndexerService } from '../../../infrastructure/services/listing-indexer.service';
import { SyncListingCommand } from './sync-listing.command';
@CommandHandler(SyncListingCommand)
export class SyncListingHandler implements ICommandHandler<SyncListingCommand> {
constructor(private readonly indexer: ListingIndexerService) {}
constructor(
private readonly indexer: ListingIndexerService,
private readonly logger: LoggerService,
) {}
async execute(command: SyncListingCommand): Promise<void> {
await this.indexer.indexListing(command.listingId);
try {
await this.indexer.indexListing(command.listingId);
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(
`Failed to sync listing ${command.listingId}: ${error instanceof Error ? error.message : error}`,
error instanceof Error ? error.stack : undefined,
this.constructor.name,
);
throw new InternalServerErrorException('Không thể đồng bộ tin đăng vào chỉ mục tìm kiếm');
}
}
}

View File

@@ -1,6 +1,7 @@
import { InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { type Prisma } from '@prisma/client';
import { ForbiddenException, NotFoundException, ValidationException, type PrismaService, type LoggerService } from '@modules/shared';
import { DomainException, ForbiddenException, NotFoundException, ValidationException, type PrismaService, type LoggerService } from '@modules/shared';
import { UpdateSavedSearchCommand } from './update-saved-search.command';
export interface UpdateSavedSearchResult {
@@ -19,43 +20,53 @@ export class UpdateSavedSearchHandler implements ICommandHandler<UpdateSavedSear
) {}
async execute(command: UpdateSavedSearchCommand): Promise<UpdateSavedSearchResult> {
const savedSearch = await this.prisma.savedSearch.findUnique({
where: { id: command.id },
});
try {
const savedSearch = await this.prisma.savedSearch.findUnique({
where: { id: command.id },
});
if (!savedSearch) {
throw new NotFoundException('SavedSearch', command.id);
if (!savedSearch) {
throw new NotFoundException('SavedSearch', command.id);
}
if (savedSearch.userId !== command.userId) {
throw new ForbiddenException('Bạn không có quyền cập nhật tìm kiếm này');
}
if (command.name !== undefined && command.name.trim().length === 0) {
throw new ValidationException('Tên tìm kiếm không được để trống');
}
if (command.name !== undefined && command.name.trim().length > 100) {
throw new ValidationException('Tên tìm kiếm không được vượt quá 100 ký tự');
}
const updated = await this.prisma.savedSearch.update({
where: { id: command.id },
data: {
...(command.name !== undefined && { name: command.name.trim() }),
...(command.filters !== undefined && { filters: command.filters as Prisma.InputJsonValue }),
...(command.alertEnabled !== undefined && { alertEnabled: command.alertEnabled }),
},
});
this.logger.log(`Saved search updated: id=${command.id}, user=${command.userId}`, 'UpdateSavedSearchHandler');
return {
id: updated.id,
name: updated.name,
filters: updated.filters,
alertEnabled: updated.alertEnabled,
createdAt: updated.createdAt,
};
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(
`Failed to update saved search: ${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 tìm kiếm đã lưu');
}
if (savedSearch.userId !== command.userId) {
throw new ForbiddenException('Bạn không có quyền cập nhật tìm kiếm này');
}
if (command.name !== undefined && command.name.trim().length === 0) {
throw new ValidationException('Tên tìm kiếm không được để trống');
}
if (command.name !== undefined && command.name.trim().length > 100) {
throw new ValidationException('Tên tìm kiếm không được vượt quá 100 ký tự');
}
const updated = await this.prisma.savedSearch.update({
where: { id: command.id },
data: {
...(command.name !== undefined && { name: command.name.trim() }),
...(command.filters !== undefined && { filters: command.filters as Prisma.InputJsonValue }),
...(command.alertEnabled !== undefined && { alertEnabled: command.alertEnabled }),
},
});
this.logger.log(`Saved search updated: id=${command.id}, user=${command.userId}`, 'UpdateSavedSearchHandler');
return {
id: updated.id,
name: updated.name,
filters: updated.filters,
alertEnabled: updated.alertEnabled,
createdAt: updated.createdAt,
};
}
}

View File

@@ -1,6 +1,6 @@
import { Inject } from '@nestjs/common';
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared';
import { CacheService, CachePrefix, CacheTTL, DomainException, type LoggerService } from '@modules/shared';
import {
SEARCH_REPOSITORY,
type ISearchRepository,
@@ -13,52 +13,63 @@ export class GeoSearchHandler implements IQueryHandler<GeoSearchQuery> {
constructor(
@Inject(SEARCH_REPOSITORY) private readonly searchRepo: ISearchRepository,
private readonly cache: CacheService,
private readonly logger: LoggerService,
) {}
async execute(query: GeoSearchQuery): Promise<SearchResult> {
const cacheKey = CacheService.buildKey(
CachePrefix.GEO_SEARCH,
`${query.lat}_${query.lng}_${query.radiusKm}`,
query.propertyType,
query.transactionType,
query.priceMin,
query.priceMax,
query.sortBy,
query.page,
query.perPage,
);
try {
const cacheKey = CacheService.buildKey(
CachePrefix.GEO_SEARCH,
`${query.lat}_${query.lng}_${query.radiusKm}`,
query.propertyType,
query.transactionType,
query.priceMin,
query.priceMax,
query.sortBy,
query.page,
query.perPage,
);
return this.cache.getOrSet(
cacheKey,
async () => {
const filters: string[] = ['status:=ACTIVE'];
return this.cache.getOrSet(
cacheKey,
async () => {
const filters: string[] = ['status:=ACTIVE'];
if (query.propertyType) {
filters.push(`propertyType:=${query.propertyType}`);
}
if (query.transactionType) {
filters.push(`transactionType:=${query.transactionType}`);
}
if (query.priceMin !== undefined && query.priceMax !== undefined) {
filters.push(`priceVND:[${query.priceMin}..${query.priceMax}]`);
} else if (query.priceMin !== undefined) {
filters.push(`priceVND:>=${query.priceMin}`);
} else if (query.priceMax !== undefined) {
filters.push(`priceVND:<=${query.priceMax}`);
}
if (query.propertyType) {
filters.push(`propertyType:=${query.propertyType}`);
}
if (query.transactionType) {
filters.push(`transactionType:=${query.transactionType}`);
}
if (query.priceMin !== undefined && query.priceMax !== undefined) {
filters.push(`priceVND:[${query.priceMin}..${query.priceMax}]`);
} else if (query.priceMin !== undefined) {
filters.push(`priceVND:>=${query.priceMin}`);
} else if (query.priceMax !== undefined) {
filters.push(`priceVND:<=${query.priceMax}`);
}
return this.searchRepo.search({
query: '*',
filterBy: filters.join(' && '),
sortBy: query.sortBy,
page: query.page,
perPage: query.perPage,
geoPoint: { lat: query.lat, lng: query.lng },
geoRadiusKm: Math.min(query.radiusKm, 100),
});
},
CacheTTL.SEARCH_RESULTS,
'geo_search',
);
return this.searchRepo.search({
query: '*',
filterBy: filters.join(' && '),
sortBy: query.sortBy,
page: query.page,
perPage: query.perPage,
geoPoint: { lat: query.lat, lng: query.lng },
geoRadiusKm: Math.min(query.radiusKm, 100),
});
},
CacheTTL.SEARCH_RESULTS,
'geo_search',
);
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(
`Failed to execute geo search: ${error instanceof Error ? error.message : error}`,
error instanceof Error ? error.stack : undefined,
this.constructor.name,
);
throw new InternalServerErrorException('Không thể thực hiện tìm kiếm theo vị trí địa lý');
}
}
}

View File

@@ -1,5 +1,6 @@
import { InternalServerErrorException } from '@nestjs/common';
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { ForbiddenException, NotFoundException, type PrismaService } from '@modules/shared';
import { DomainException, ForbiddenException, NotFoundException, type LoggerService, type PrismaService } from '@modules/shared';
import { GetSavedSearchQuery } from './get-saved-search.query';
export interface SavedSearchDetail {
@@ -15,28 +16,39 @@ export interface SavedSearchDetail {
export class GetSavedSearchHandler implements IQueryHandler<GetSavedSearchQuery> {
constructor(
private readonly prisma: PrismaService,
private readonly logger: LoggerService,
) {}
async execute(query: GetSavedSearchQuery): Promise<SavedSearchDetail> {
const savedSearch = await this.prisma.savedSearch.findUnique({
where: { id: query.id },
});
try {
const savedSearch = await this.prisma.savedSearch.findUnique({
where: { id: query.id },
});
if (!savedSearch) {
throw new NotFoundException('SavedSearch', query.id);
if (!savedSearch) {
throw new NotFoundException('SavedSearch', query.id);
}
if (savedSearch.userId !== query.userId) {
throw new ForbiddenException('Bạn không có quyền xem tìm kiếm này');
}
return {
id: savedSearch.id,
name: savedSearch.name,
filters: savedSearch.filters,
alertEnabled: savedSearch.alertEnabled,
lastAlertAt: savedSearch.lastAlertAt,
createdAt: savedSearch.createdAt,
};
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(
`Failed to get saved search: ${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 tìm kiếm đã lưu');
}
if (savedSearch.userId !== query.userId) {
throw new ForbiddenException('Bạn không có quyền xem tìm kiếm này');
}
return {
id: savedSearch.id,
name: savedSearch.name,
filters: savedSearch.filters,
alertEnabled: savedSearch.alertEnabled,
lastAlertAt: savedSearch.lastAlertAt,
createdAt: savedSearch.createdAt,
};
}
}

View File

@@ -1,5 +1,6 @@
import { InternalServerErrorException } from '@nestjs/common';
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { type PrismaService } from '@modules/shared';
import { DomainException, type LoggerService, type PrismaService } from '@modules/shared';
import { GetSavedSearchesQuery } from './get-saved-searches.query';
export interface SavedSearchItem {
@@ -22,35 +23,46 @@ export interface SavedSearchListResult {
export class GetSavedSearchesHandler implements IQueryHandler<GetSavedSearchesQuery> {
constructor(
private readonly prisma: PrismaService,
private readonly logger: LoggerService,
) {}
async execute(query: GetSavedSearchesQuery): Promise<SavedSearchListResult> {
const skip = (query.page - 1) * query.limit;
try {
const skip = (query.page - 1) * query.limit;
const [data, total] = await Promise.all([
this.prisma.savedSearch.findMany({
where: { userId: query.userId },
orderBy: { createdAt: 'desc' },
skip,
take: query.limit,
}),
this.prisma.savedSearch.count({
where: { userId: query.userId },
}),
]);
const [data, total] = await Promise.all([
this.prisma.savedSearch.findMany({
where: { userId: query.userId },
orderBy: { createdAt: 'desc' },
skip,
take: query.limit,
}),
this.prisma.savedSearch.count({
where: { userId: query.userId },
}),
]);
return {
data: data.map((s) => ({
id: s.id,
name: s.name,
filters: s.filters,
alertEnabled: s.alertEnabled,
lastAlertAt: s.lastAlertAt,
createdAt: s.createdAt,
})),
total,
page: query.page,
limit: query.limit,
};
return {
data: data.map((s) => ({
id: s.id,
name: s.name,
filters: s.filters,
alertEnabled: s.alertEnabled,
lastAlertAt: s.lastAlertAt,
createdAt: s.createdAt,
})),
total,
page: query.page,
limit: query.limit,
};
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(
`Failed to get saved searches: ${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 tìm kiếm đã lưu');
}
}
}

View File

@@ -1,6 +1,6 @@
import { Inject } from '@nestjs/common';
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared';
import { CacheService, CachePrefix, CacheTTL, DomainException, type LoggerService } from '@modules/shared';
import {
SEARCH_REPOSITORY,
type ISearchRepository,
@@ -13,71 +13,82 @@ export class SearchPropertiesHandler implements IQueryHandler<SearchPropertiesQu
constructor(
@Inject(SEARCH_REPOSITORY) private readonly searchRepo: ISearchRepository,
private readonly cache: CacheService,
private readonly logger: LoggerService,
) {}
async execute(query: SearchPropertiesQuery): Promise<SearchResult> {
const filters: string[] = ['status:=ACTIVE'];
try {
const filters: string[] = ['status:=ACTIVE'];
if (query.propertyType) {
filters.push(`propertyType:=${query.propertyType}`);
}
if (query.transactionType) {
filters.push(`transactionType:=${query.transactionType}`);
}
if (query.priceMin !== undefined && query.priceMax !== undefined) {
filters.push(`priceVND:[${query.priceMin}..${query.priceMax}]`);
} else if (query.priceMin !== undefined) {
filters.push(`priceVND:>=${query.priceMin}`);
} else if (query.priceMax !== undefined) {
filters.push(`priceVND:<=${query.priceMax}`);
}
if (query.areaMin !== undefined && query.areaMax !== undefined) {
filters.push(`areaM2:[${query.areaMin}..${query.areaMax}]`);
} else if (query.areaMin !== undefined) {
filters.push(`areaM2:>=${query.areaMin}`);
} else if (query.areaMax !== undefined) {
filters.push(`areaM2:<=${query.areaMax}`);
}
if (query.bedrooms !== undefined) {
filters.push(`bedrooms:>=${query.bedrooms}`);
}
if (query.district) {
filters.push(`district:=${query.district}`);
}
if (query.city) {
filters.push(`city:=${query.city}`);
}
if (query.propertyType) {
filters.push(`propertyType:=${query.propertyType}`);
}
if (query.transactionType) {
filters.push(`transactionType:=${query.transactionType}`);
}
if (query.priceMin !== undefined && query.priceMax !== undefined) {
filters.push(`priceVND:[${query.priceMin}..${query.priceMax}]`);
} else if (query.priceMin !== undefined) {
filters.push(`priceVND:>=${query.priceMin}`);
} else if (query.priceMax !== undefined) {
filters.push(`priceVND:<=${query.priceMax}`);
}
if (query.areaMin !== undefined && query.areaMax !== undefined) {
filters.push(`areaM2:[${query.areaMin}..${query.areaMax}]`);
} else if (query.areaMin !== undefined) {
filters.push(`areaM2:>=${query.areaMin}`);
} else if (query.areaMax !== undefined) {
filters.push(`areaM2:<=${query.areaMax}`);
}
if (query.bedrooms !== undefined) {
filters.push(`bedrooms:>=${query.bedrooms}`);
}
if (query.district) {
filters.push(`district:=${query.district}`);
}
if (query.city) {
filters.push(`city:=${query.city}`);
}
const searchParams = {
query: query.query,
filterBy: filters.join(' && '),
sortBy: query.sortBy,
page: query.page,
perPage: query.perPage,
};
const searchParams = {
query: query.query,
filterBy: filters.join(' && '),
sortBy: query.sortBy,
page: query.page,
perPage: query.perPage,
};
const cacheKey = CacheService.buildKey(
CachePrefix.SEARCH,
query.query ?? '*',
query.propertyType,
query.transactionType,
query.district,
query.city,
query.page,
query.perPage,
query.priceMin,
query.priceMax,
query.areaMin,
query.areaMax,
query.bedrooms,
query.sortBy,
);
const cacheKey = CacheService.buildKey(
CachePrefix.SEARCH,
query.query ?? '*',
query.propertyType,
query.transactionType,
query.district,
query.city,
query.page,
query.perPage,
query.priceMin,
query.priceMax,
query.areaMin,
query.areaMax,
query.bedrooms,
query.sortBy,
);
return this.cache.getOrSet(
cacheKey,
() => this.searchRepo.search(searchParams),
CacheTTL.SEARCH_RESULTS,
'search',
);
return this.cache.getOrSet(
cacheKey,
() => this.searchRepo.search(searchParams),
CacheTTL.SEARCH_RESULTS,
'search',
);
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(
`Failed to search properties: ${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 bất động sản');
}
}
}