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