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,2 @@
export * from './value-objects';
export * from './repositories';

View File

@@ -0,0 +1 @@
export { SEARCH_REPOSITORY, type ISearchRepository, type ListingDocument, type SearchResult, type SearchParams } from './search.repository';

View File

@@ -0,0 +1,60 @@
export const SEARCH_REPOSITORY = Symbol('SEARCH_REPOSITORY');
export interface ListingDocument {
id: string;
listingId: string;
propertyId: string;
title: string;
description: string;
propertyType: string;
transactionType: string;
priceVND: number;
pricePerM2: number | null;
areaM2: number;
bedrooms: number | null;
bathrooms: number | null;
floors: number | null;
direction: string | null;
address: string;
ward: string;
district: string;
city: string;
location: [number, number]; // [lat, lng]
agentId: string | null;
sellerId: string;
status: string;
publishedAt: number; // unix timestamp
viewCount: number;
saveCount: number;
projectName: string | null;
amenities: string[];
}
export interface SearchResult {
hits: ListingDocument[];
totalFound: number;
page: number;
perPage: number;
totalPages: number;
searchTimeMs: number;
}
export interface SearchParams {
query?: string;
filterBy?: string;
sortBy?: string;
page?: number;
perPage?: number;
geoDistanceField?: string;
geoPoint?: { lat: number; lng: number };
geoRadiusKm?: number;
}
export interface ISearchRepository {
indexDocument(doc: ListingDocument): Promise<void>;
indexDocuments(docs: ListingDocument[]): Promise<void>;
removeDocument(id: string): Promise<void>;
search(params: SearchParams): Promise<SearchResult>;
ensureCollection(): Promise<void>;
dropCollection(): Promise<void>;
}

View File

@@ -0,0 +1,60 @@
import { ValueObject } from '@modules/shared/domain/value-object';
interface GeoFilterProps {
lat: number;
lng: number;
radiusKm: number;
propertyType?: string;
transactionType?: string;
priceMin?: number;
priceMax?: number;
sortBy?: 'distance' | 'price_asc' | 'price_desc' | 'date_desc';
page?: number;
perPage?: number;
}
export class GeoFilter extends ValueObject<GeoFilterProps> {
get lat(): number {
return this.props.lat;
}
get lng(): number {
return this.props.lng;
}
get radiusKm(): number {
return Math.min(this.props.radiusKm, 100);
}
get propertyType(): string | undefined {
return this.props.propertyType;
}
get transactionType(): string | undefined {
return this.props.transactionType;
}
get priceMin(): number | undefined {
return this.props.priceMin;
}
get priceMax(): number | undefined {
return this.props.priceMax;
}
get sortBy(): string {
return this.props.sortBy ?? 'distance';
}
get page(): number {
return this.props.page ?? 1;
}
get perPage(): number {
return Math.min(this.props.perPage ?? 20, 100);
}
static create(props: GeoFilterProps): GeoFilter {
return new GeoFilter(props);
}
}

View File

@@ -0,0 +1,2 @@
export { SearchFilter } from './search-filter.vo';
export { GeoFilter } from './geo-filter.vo';

View File

@@ -0,0 +1,75 @@
import { ValueObject } from '@modules/shared/domain/value-object';
interface SearchFilterProps {
query?: string;
propertyType?: string;
transactionType?: string;
priceMin?: number;
priceMax?: number;
areaMin?: number;
areaMax?: number;
bedrooms?: number;
district?: string;
city?: string;
sortBy?: 'price_asc' | 'price_desc' | 'date_desc' | 'relevance' | 'distance';
page?: number;
perPage?: number;
}
export class SearchFilter extends ValueObject<SearchFilterProps> {
get query(): string | undefined {
return this.props.query;
}
get propertyType(): string | undefined {
return this.props.propertyType;
}
get transactionType(): string | undefined {
return this.props.transactionType;
}
get priceMin(): number | undefined {
return this.props.priceMin;
}
get priceMax(): number | undefined {
return this.props.priceMax;
}
get areaMin(): number | undefined {
return this.props.areaMin;
}
get areaMax(): number | undefined {
return this.props.areaMax;
}
get bedrooms(): number | undefined {
return this.props.bedrooms;
}
get district(): string | undefined {
return this.props.district;
}
get city(): string | undefined {
return this.props.city;
}
get sortBy(): string {
return this.props.sortBy ?? 'relevance';
}
get page(): number {
return this.props.page ?? 1;
}
get perPage(): number {
return Math.min(this.props.perPage ?? 20, 100);
}
static create(props: SearchFilterProps): SearchFilter {
return new SearchFilter(props);
}
}