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,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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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ý');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user