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:
2
apps/api/src/modules/search/domain/index.ts
Normal file
2
apps/api/src/modules/search/domain/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './value-objects';
|
||||
export * from './repositories';
|
||||
1
apps/api/src/modules/search/domain/repositories/index.ts
Normal file
1
apps/api/src/modules/search/domain/repositories/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { SEARCH_REPOSITORY, type ISearchRepository, type ListingDocument, type SearchResult, type SearchParams } from './search.repository';
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { SearchFilter } from './search-filter.vo';
|
||||
export { GeoFilter } from './geo-filter.vo';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user