feat(search): implement Search module with Typesense full-text & geo search

- TypesenseClient service with configurable connection
- Collection schema for listings with facets, geo-point, and Vietnamese text
- ListingIndexer service with PostGIS coordinate extraction for geo search
- CQRS commands: SyncListing, ReindexAll (batch with pagination)
- CQRS queries: SearchProperties (filters, sorting), GeoSearch (radius)
- Event handlers for listing.approved/updated/deactivated auto-sync
- REST endpoints: GET /search, GET /search/geo, POST /search/reindex (admin)
- DTOs with class-validator validation and pagination

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-08 01:46:20 +07:00
parent 0b29fac35e
commit 6741592cbe
31 changed files with 1143 additions and 0 deletions

View File

@@ -0,0 +1 @@
export class ReindexAllCommand {}

View File

@@ -0,0 +1,17 @@
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { ReindexAllCommand } from './reindex-all.command';
import { ListingIndexerService } from '../../../infrastructure/services/listing-indexer.service';
export interface ReindexResult {
indexed: number;
total: number;
}
@CommandHandler(ReindexAllCommand)
export class ReindexAllHandler implements ICommandHandler<ReindexAllCommand> {
constructor(private readonly indexer: ListingIndexerService) {}
async execute(): Promise<ReindexResult> {
return this.indexer.reindexAll();
}
}

View File

@@ -0,0 +1,3 @@
export class SyncListingCommand {
constructor(public readonly listingId: string) {}
}

View File

@@ -0,0 +1,12 @@
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { SyncListingCommand } from './sync-listing.command';
import { ListingIndexerService } from '../../../infrastructure/services/listing-indexer.service';
@CommandHandler(SyncListingCommand)
export class SyncListingHandler implements ICommandHandler<SyncListingCommand> {
constructor(private readonly indexer: ListingIndexerService) {}
async execute(command: SyncListingCommand): Promise<void> {
await this.indexer.indexListing(command.listingId);
}
}

View File

@@ -0,0 +1,8 @@
export { SyncListingCommand } from './commands/sync-listing/sync-listing.command';
export { SyncListingHandler } from './commands/sync-listing/sync-listing.handler';
export { ReindexAllCommand } from './commands/reindex-all/reindex-all.command';
export { ReindexAllHandler, type ReindexResult } from './commands/reindex-all/reindex-all.handler';
export { SearchPropertiesQuery } from './queries/search-properties/search-properties.query';
export { SearchPropertiesHandler } from './queries/search-properties/search-properties.handler';
export { GeoSearchQuery } from './queries/geo-search/geo-search.query';
export { GeoSearchHandler } from './queries/geo-search/geo-search.handler';

View File

@@ -0,0 +1,43 @@
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import { GeoSearchQuery } from './geo-search.query';
import {
SEARCH_REPOSITORY,
type ISearchRepository,
type SearchResult,
} from '../../../domain/repositories/search.repository';
@QueryHandler(GeoSearchQuery)
export class GeoSearchHandler implements IQueryHandler<GeoSearchQuery> {
constructor(
@Inject(SEARCH_REPOSITORY) private readonly searchRepo: ISearchRepository,
) {}
async execute(query: GeoSearchQuery): Promise<SearchResult> {
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}`);
}
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),
});
}
}

View File

@@ -0,0 +1,14 @@
export class GeoSearchQuery {
constructor(
public readonly lat: number,
public readonly lng: number,
public readonly radiusKm: number,
public readonly propertyType?: string,
public readonly transactionType?: string,
public readonly priceMin?: number,
public readonly priceMax?: number,
public readonly sortBy?: string,
public readonly page?: number,
public readonly perPage?: number,
) {}
}

View File

@@ -0,0 +1,57 @@
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import { SearchPropertiesQuery } from './search-properties.query';
import {
SEARCH_REPOSITORY,
type ISearchRepository,
type SearchResult,
} from '../../../domain/repositories/search.repository';
@QueryHandler(SearchPropertiesQuery)
export class SearchPropertiesHandler implements IQueryHandler<SearchPropertiesQuery> {
constructor(
@Inject(SEARCH_REPOSITORY) private readonly searchRepo: ISearchRepository,
) {}
async execute(query: SearchPropertiesQuery): Promise<SearchResult> {
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}`);
}
return this.searchRepo.search({
query: query.query,
filterBy: filters.join(' && '),
sortBy: query.sortBy,
page: query.page,
perPage: query.perPage,
});
}
}

View File

@@ -0,0 +1,17 @@
export class SearchPropertiesQuery {
constructor(
public readonly query?: string,
public readonly propertyType?: string,
public readonly transactionType?: string,
public readonly priceMin?: number,
public readonly priceMax?: number,
public readonly areaMin?: number,
public readonly areaMax?: number,
public readonly bedrooms?: number,
public readonly district?: string,
public readonly city?: string,
public readonly sortBy?: string,
public readonly page?: number,
public readonly perPage?: number,
) {}
}