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

@@ -1,5 +1,7 @@
import { SharedModule } from '@modules/shared'; import { SharedModule } from '@modules/shared';
import { AuthModule } from '@modules/auth'; import { AuthModule } from '@modules/auth';
import { ListingsModule } from '@modules/listings';
import { SearchModule } from '@modules/search';
import { NotificationsModule } from '@modules/notifications'; import { NotificationsModule } from '@modules/notifications';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core'; import { APP_GUARD } from '@nestjs/core';
@@ -13,6 +15,8 @@ import { AppController } from './app.controller';
CqrsModule.forRoot(), CqrsModule.forRoot(),
SharedModule, SharedModule,
AuthModule, AuthModule,
ListingsModule,
SearchModule,
NotificationsModule, NotificationsModule,
// ── Rate Limiting ── // ── Rate Limiting ──

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,
) {}
}

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);
}
}

View File

@@ -0,0 +1 @@
export { SearchModule } from './search.module';

View File

@@ -0,0 +1 @@
export { ListingApprovedEventHandler } from './listing-approved.handler';

View File

@@ -0,0 +1,30 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { LoggerService } from '@modules/shared/infrastructure/logger.service';
import { ListingIndexerService } from '../services/listing-indexer.service';
@Injectable()
export class ListingApprovedEventHandler {
constructor(
private readonly indexer: ListingIndexerService,
private readonly logger: LoggerService,
) {}
@OnEvent('listing.approved')
async handle(payload: { listingId: string }): Promise<void> {
this.logger.log(`Handling listing.approved for ${payload.listingId}`, 'ListingApprovedHandler');
await this.indexer.indexListing(payload.listingId);
}
@OnEvent('listing.updated')
async handleUpdate(payload: { listingId: string }): Promise<void> {
this.logger.log(`Handling listing.updated for ${payload.listingId}`, 'ListingApprovedHandler');
await this.indexer.indexListing(payload.listingId);
}
@OnEvent('listing.deactivated')
async handleDeactivation(payload: { listingId: string }): Promise<void> {
this.logger.log(`Handling listing.deactivated for ${payload.listingId}`, 'ListingApprovedHandler');
await this.indexer.removeListing(payload.listingId);
}
}

View File

@@ -0,0 +1,2 @@
export * from './services';
export * from './event-handlers';

View File

@@ -0,0 +1,3 @@
export { TypesenseClientService } from './typesense-client.service';
export { TypesenseSearchRepository } from './typesense-search.repository';
export { ListingIndexerService } from './listing-indexer.service';

View File

@@ -0,0 +1,220 @@
import { Inject, Injectable } from '@nestjs/common';
import { PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { LoggerService } from '@modules/shared/infrastructure/logger.service';
import {
SEARCH_REPOSITORY,
type ISearchRepository,
type ListingDocument,
} from '../../domain/repositories/search.repository';
@Injectable()
export class ListingIndexerService {
constructor(
private readonly prisma: PrismaService,
@Inject(SEARCH_REPOSITORY) private readonly searchRepo: ISearchRepository,
private readonly logger: LoggerService,
) {}
async indexListing(listingId: string): Promise<void> {
const doc = await this.fetchListingDocumentById(listingId);
if (!doc || doc.status !== 'ACTIVE') {
this.logger.warn(`Listing ${listingId} not found or not active, skipping index`, 'ListingIndexer');
return;
}
await this.searchRepo.indexDocument(doc);
this.logger.log(`Indexed listing ${listingId}`, 'ListingIndexer');
}
async removeListing(listingId: string): Promise<void> {
await this.searchRepo.removeDocument(listingId);
this.logger.log(`Removed listing ${listingId} from index`, 'ListingIndexer');
}
async reindexAll(): Promise<{ indexed: number; total: number }> {
this.logger.log('Starting full reindex...', 'ListingIndexer');
await this.searchRepo.dropCollection();
await this.searchRepo.ensureCollection();
const batchSize = 100;
let offset = 0;
let indexed = 0;
while (true) {
const rows = await this.fetchListingsWithCoords(batchSize, offset);
if (rows.length === 0) break;
await this.searchRepo.indexDocuments(rows);
indexed += rows.length;
offset += batchSize;
this.logger.log(`Reindex progress: ${indexed} documents`, 'ListingIndexer');
}
this.logger.log(`Full reindex complete: ${indexed} documents`, 'ListingIndexer');
return { indexed, total: indexed };
}
private async fetchListingsWithCoords(
limit: number,
offset: number,
): Promise<ListingDocument[]> {
const rows = await this.prisma.$queryRaw<
Array<{
id: string;
propertyId: string;
transactionType: string;
priceVND: bigint;
pricePerM2: number | null;
agentId: string | null;
sellerId: string;
status: string;
viewCount: number;
saveCount: number;
publishedAt: Date | null;
title: string;
description: string;
propertyType: string;
areaM2: number;
bedrooms: number | null;
bathrooms: number | null;
floors: number | null;
direction: string | null;
address: string;
ward: string;
district: string;
city: string;
projectName: string | null;
amenities: unknown;
lat: number | null;
lng: number | null;
}>
>`
SELECT
l."id", l."propertyId", l."transactionType", l."priceVND", l."pricePerM2",
l."agentId", l."sellerId", l."status", l."viewCount", l."saveCount", l."publishedAt",
p."title", p."description", p."propertyType", p."areaM2",
p."bedrooms", p."bathrooms", p."floors", p."direction",
p."address", p."ward", p."district", p."city", p."projectName", p."amenities",
ST_Y(p."location"::geometry) AS lat,
ST_X(p."location"::geometry) AS lng
FROM "Listing" l
JOIN "Property" p ON l."propertyId" = p."id"
WHERE l."status" = 'ACTIVE'
ORDER BY l."publishedAt" DESC NULLS LAST
LIMIT ${limit} OFFSET ${offset}
`;
return rows.map((row) => ({
id: row.id,
listingId: row.id,
propertyId: row.propertyId,
title: row.title,
description: row.description,
propertyType: row.propertyType,
transactionType: row.transactionType,
priceVND: Number(row.priceVND),
pricePerM2: row.pricePerM2,
areaM2: row.areaM2,
bedrooms: row.bedrooms,
bathrooms: row.bathrooms,
floors: row.floors,
direction: row.direction,
address: row.address,
ward: row.ward,
district: row.district,
city: row.city,
location: [row.lat ?? 0, row.lng ?? 0] as [number, number],
agentId: row.agentId,
sellerId: row.sellerId,
status: row.status,
publishedAt: row.publishedAt ? Math.floor(row.publishedAt.getTime() / 1000) : 0,
viewCount: row.viewCount,
saveCount: row.saveCount,
projectName: row.projectName,
amenities: Array.isArray(row.amenities) ? (row.amenities as string[]) : [],
}));
}
async fetchListingDocumentById(listingId: string): Promise<ListingDocument | null> {
const rows = await this.prisma.$queryRaw<
Array<{
id: string;
propertyId: string;
transactionType: string;
priceVND: bigint;
pricePerM2: number | null;
agentId: string | null;
sellerId: string;
status: string;
viewCount: number;
saveCount: number;
publishedAt: Date | null;
title: string;
description: string;
propertyType: string;
areaM2: number;
bedrooms: number | null;
bathrooms: number | null;
floors: number | null;
direction: string | null;
address: string;
ward: string;
district: string;
city: string;
projectName: string | null;
amenities: unknown;
lat: number | null;
lng: number | null;
}>
>`
SELECT
l."id", l."propertyId", l."transactionType", l."priceVND", l."pricePerM2",
l."agentId", l."sellerId", l."status", l."viewCount", l."saveCount", l."publishedAt",
p."title", p."description", p."propertyType", p."areaM2",
p."bedrooms", p."bathrooms", p."floors", p."direction",
p."address", p."ward", p."district", p."city", p."projectName", p."amenities",
ST_Y(p."location"::geometry) AS lat,
ST_X(p."location"::geometry) AS lng
FROM "Listing" l
JOIN "Property" p ON l."propertyId" = p."id"
WHERE l."id" = ${listingId}
`;
if (rows.length === 0) return null;
const row = rows[0]!;
return {
id: row.id,
listingId: row.id,
propertyId: row.propertyId,
title: row.title,
description: row.description,
propertyType: row.propertyType,
transactionType: row.transactionType,
priceVND: Number(row.priceVND),
pricePerM2: row.pricePerM2,
areaM2: row.areaM2,
bedrooms: row.bedrooms,
bathrooms: row.bathrooms,
floors: row.floors,
direction: row.direction,
address: row.address,
ward: row.ward,
district: row.district,
city: row.city,
location: [row.lat ?? 0, row.lng ?? 0],
agentId: row.agentId,
sellerId: row.sellerId,
status: row.status,
publishedAt: row.publishedAt ? Math.floor(row.publishedAt.getTime() / 1000) : 0,
viewCount: row.viewCount,
saveCount: row.saveCount,
projectName: row.projectName,
amenities: Array.isArray(row.amenities) ? (row.amenities as string[]) : [],
};
}
}

View File

@@ -0,0 +1,32 @@
import { Injectable, type OnModuleInit } from '@nestjs/common';
import { LoggerService } from '@modules/shared/infrastructure/logger.service';
import { Client as TypesenseClient } from 'typesense';
@Injectable()
export class TypesenseClientService implements OnModuleInit {
private client!: TypesenseClient;
constructor(private readonly logger: LoggerService) {}
onModuleInit(): void {
this.client = new TypesenseClient({
nodes: [
{
host: process.env['TYPESENSE_HOST'] || 'localhost',
port: parseInt(process.env['TYPESENSE_PORT'] || '8108', 10),
protocol: process.env['TYPESENSE_PROTOCOL'] || 'http',
},
],
apiKey: process.env['TYPESENSE_API_KEY'] || 'ts_dev_key_change_me',
connectionTimeoutSeconds: 5,
retryIntervalSeconds: 0.1,
numRetries: 3,
});
this.logger.log('TypesenseClientService initialized', 'TypesenseClient');
}
getClient(): TypesenseClient {
return this.client;
}
}

View File

@@ -0,0 +1,174 @@
import { Injectable } from '@nestjs/common';
import { LoggerService } from '@modules/shared/infrastructure/logger.service';
import {
type ISearchRepository,
type ListingDocument,
type SearchParams,
type SearchResult,
} from '../../domain/repositories/search.repository';
import { TypesenseClientService } from './typesense-client.service';
import { Client as TypesenseClient } from 'typesense';
import type { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections';
const COLLECTION_NAME = 'listings';
const LISTING_SCHEMA: CollectionCreateSchema = {
name: COLLECTION_NAME,
fields: [
{ name: 'listingId', type: 'string', facet: false },
{ name: 'propertyId', type: 'string', facet: false },
{ name: 'title', type: 'string', facet: false },
{ name: 'description', type: 'string', facet: false },
{ name: 'propertyType', type: 'string', facet: true },
{ name: 'transactionType', type: 'string', facet: true },
{ name: 'priceVND', type: 'int64', facet: false },
{ name: 'pricePerM2', type: 'float', facet: false, optional: true },
{ name: 'areaM2', type: 'float', facet: false },
{ name: 'bedrooms', type: 'int32', facet: true, optional: true },
{ name: 'bathrooms', type: 'int32', facet: true, optional: true },
{ name: 'floors', type: 'int32', facet: false, optional: true },
{ name: 'direction', type: 'string', facet: true, optional: true },
{ name: 'address', type: 'string', facet: false },
{ name: 'ward', type: 'string', facet: true },
{ name: 'district', type: 'string', facet: true },
{ name: 'city', type: 'string', facet: true },
{ name: 'location', type: 'geopoint', facet: false },
{ name: 'agentId', type: 'string', facet: false, optional: true },
{ name: 'sellerId', type: 'string', facet: false },
{ name: 'status', type: 'string', facet: true },
{ name: 'publishedAt', type: 'int64', facet: false },
{ name: 'viewCount', type: 'int32', facet: false },
{ name: 'saveCount', type: 'int32', facet: false },
{ name: 'projectName', type: 'string', facet: true, optional: true },
{ name: 'amenities', type: 'string[]', facet: true, optional: true },
],
token_separators: ['-', '_'],
enable_nested_fields: false,
};
@Injectable()
export class TypesenseSearchRepository implements ISearchRepository {
private readonly client: TypesenseClient;
constructor(
private readonly typesenseClient: TypesenseClientService,
private readonly logger: LoggerService,
) {
this.client = this.typesenseClient.getClient();
}
async ensureCollection(): Promise<void> {
try {
await this.client.collections(COLLECTION_NAME).retrieve();
this.logger.log(`Collection "${COLLECTION_NAME}" already exists`, 'TypesenseSearch');
} catch {
await this.client.collections().create(LISTING_SCHEMA);
this.logger.log(`Collection "${COLLECTION_NAME}" created`, 'TypesenseSearch');
}
}
async dropCollection(): Promise<void> {
try {
await this.client.collections(COLLECTION_NAME).delete();
this.logger.log(`Collection "${COLLECTION_NAME}" dropped`, 'TypesenseSearch');
} catch {
this.logger.warn(`Collection "${COLLECTION_NAME}" not found to drop`, 'TypesenseSearch');
}
}
async indexDocument(doc: ListingDocument): Promise<void> {
await this.client
.collections(COLLECTION_NAME)
.documents()
.upsert(doc as unknown as Record<string, unknown>);
}
async indexDocuments(docs: ListingDocument[]): Promise<void> {
if (docs.length === 0) return;
const results = await this.client
.collections(COLLECTION_NAME)
.documents()
.import(docs as unknown as Record<string, unknown>[], { action: 'upsert' });
const failures = results.filter((r: { success: boolean }) => !r.success);
if (failures.length > 0) {
this.logger.warn(
`${failures.length}/${docs.length} documents failed to index`,
'TypesenseSearch',
);
}
}
async removeDocument(id: string): Promise<void> {
try {
await this.client.collections(COLLECTION_NAME).documents(id).delete();
} catch {
this.logger.warn(`Document ${id} not found for removal`, 'TypesenseSearch');
}
}
async search(params: SearchParams): Promise<SearchResult> {
const page = params.page ?? 1;
const perPage = params.perPage ?? 20;
let filterBy = params.filterBy || '';
if (params.geoPoint && params.geoRadiusKm) {
const geoFilter = `location:(${params.geoPoint.lat}, ${params.geoPoint.lng}, ${params.geoRadiusKm} km)`;
filterBy = filterBy ? `${filterBy} && ${geoFilter}` : geoFilter;
}
const searchParams = {
q: params.query || '*',
query_by: 'title,description,address,district,city,projectName',
query_by_weights: '5,3,2,2,1,2',
filter_by: filterBy,
sort_by: this.buildSortBy(params),
page,
per_page: perPage,
highlight_full_fields: 'title,description',
highlight_start_tag: '<mark>',
highlight_end_tag: '</mark>',
};
const result = await this.client
.collections(COLLECTION_NAME)
.documents()
.search(searchParams);
const hits = (result.hits ?? []).map(
(hit) => hit.document as unknown as ListingDocument,
);
const totalFound = (result.found as number) ?? 0;
return {
hits,
totalFound,
page,
perPage,
totalPages: Math.ceil(totalFound / perPage),
searchTimeMs: result.search_time_ms ?? 0,
};
}
private buildSortBy(params: SearchParams): string {
if (params.geoPoint) {
if (params.sortBy === 'distance' || (!params.sortBy && params.geoRadiusKm)) {
return `location(${params.geoPoint.lat}, ${params.geoPoint.lng}):asc`;
}
}
switch (params.sortBy) {
case 'price_asc':
return 'priceVND:asc';
case 'price_desc':
return 'priceVND:desc';
case 'date_desc':
return 'publishedAt:desc';
case 'relevance':
default:
return params.query && params.query !== '*' ? '_text_match:desc,publishedAt:desc' : 'publishedAt:desc';
}
}
}

View File

@@ -0,0 +1 @@
export { SearchController } from './search.controller';

View File

@@ -0,0 +1,72 @@
import {
Controller,
Get,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import { SearchPropertiesQuery } from '../../application/queries/search-properties/search-properties.query';
import { GeoSearchQuery } from '../../application/queries/geo-search/geo-search.query';
import { ReindexAllCommand } from '../../application/commands/reindex-all/reindex-all.command';
import { SearchPropertiesDto } from '../dto/search-properties.dto';
import { GeoSearchDto } from '../dto/geo-search.dto';
import { JwtAuthGuard } from '@modules/auth/presentation/guards/jwt-auth.guard';
import { RolesGuard } from '@modules/auth/presentation/guards/roles.guard';
import { Roles } from '@modules/auth/presentation/decorators/roles.decorator';
import { type SearchResult } from '../../domain/repositories/search.repository';
import { type ReindexResult } from '../../application/commands/reindex-all/reindex-all.handler';
@Controller('search')
export class SearchController {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
@Get()
async search(@Query() dto: SearchPropertiesDto): Promise<SearchResult> {
return this.queryBus.execute(
new SearchPropertiesQuery(
dto.q,
dto.propertyType,
dto.transactionType,
dto.priceMin,
dto.priceMax,
dto.areaMin,
dto.areaMax,
dto.bedrooms,
dto.district,
dto.city,
dto.sortBy,
dto.page,
dto.perPage,
),
);
}
@Get('geo')
async geoSearch(@Query() dto: GeoSearchDto): Promise<SearchResult> {
return this.queryBus.execute(
new GeoSearchQuery(
dto.lat,
dto.lng,
dto.radiusKm,
dto.propertyType,
dto.transactionType,
dto.priceMin,
dto.priceMax,
dto.sortBy,
dto.page,
dto.perPage,
),
);
}
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('ADMIN')
@Post('reindex')
async reindex(): Promise<ReindexResult> {
return this.commandBus.execute(new ReindexAllCommand());
}
}

View File

@@ -0,0 +1,76 @@
import {
IsOptional,
IsString,
IsNumber,
IsEnum,
IsInt,
Min,
Max,
} from 'class-validator';
import { Transform, Type } from 'class-transformer';
export enum GeoSortByOption {
DISTANCE = 'distance',
PRICE_ASC = 'price_asc',
PRICE_DESC = 'price_desc',
DATE_DESC = 'date_desc',
}
export class GeoSearchDto {
@Type(() => Number)
@IsNumber()
@Min(-90)
@Max(90)
lat!: number;
@Type(() => Number)
@IsNumber()
@Min(-180)
@Max(180)
lng!: number;
@Type(() => Number)
@IsNumber()
@Min(0.1)
@Max(100)
radiusKm!: number;
@IsOptional()
@IsString()
propertyType?: string;
@IsOptional()
@IsString()
transactionType?: string;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
priceMin?: number;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
priceMax?: number;
@IsOptional()
@IsEnum(GeoSortByOption)
sortBy?: GeoSortByOption;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Transform(({ value }) => value ?? 1)
page?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
@Transform(({ value }) => value ?? 20)
perPage?: number;
}

View File

@@ -0,0 +1,2 @@
export { SearchPropertiesDto, SortByOption } from './search-properties.dto';
export { GeoSearchDto, GeoSortByOption } from './geo-search.dto';

View File

@@ -0,0 +1,88 @@
import {
IsOptional,
IsString,
IsNumber,
IsEnum,
IsInt,
Min,
Max,
} from 'class-validator';
import { Transform, Type } from 'class-transformer';
export enum SortByOption {
PRICE_ASC = 'price_asc',
PRICE_DESC = 'price_desc',
DATE_DESC = 'date_desc',
RELEVANCE = 'relevance',
}
export class SearchPropertiesDto {
@IsOptional()
@IsString()
q?: string;
@IsOptional()
@IsString()
propertyType?: string;
@IsOptional()
@IsString()
transactionType?: string;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
priceMin?: number;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
priceMax?: number;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
areaMin?: number;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
areaMax?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
bedrooms?: number;
@IsOptional()
@IsString()
district?: string;
@IsOptional()
@IsString()
city?: string;
@IsOptional()
@IsEnum(SortByOption)
sortBy?: SortByOption;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Transform(({ value }) => value ?? 1)
page?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
@Transform(({ value }) => value ?? 20)
perPage?: number;
}

View File

@@ -0,0 +1,2 @@
export * from './controllers';
export * from './dto';

View File

@@ -0,0 +1,63 @@
import { Module, type OnModuleInit } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
// Domain
import { SEARCH_REPOSITORY } from './domain/repositories/search.repository';
// Infrastructure
import { TypesenseClientService } from './infrastructure/services/typesense-client.service';
import { TypesenseSearchRepository } from './infrastructure/services/typesense-search.repository';
import { ListingIndexerService } from './infrastructure/services/listing-indexer.service';
import { ListingApprovedEventHandler } from './infrastructure/event-handlers/listing-approved.handler';
// Application
import { SyncListingHandler } from './application/commands/sync-listing/sync-listing.handler';
import { ReindexAllHandler } from './application/commands/reindex-all/reindex-all.handler';
import { SearchPropertiesHandler } from './application/queries/search-properties/search-properties.handler';
import { GeoSearchHandler } from './application/queries/geo-search/geo-search.handler';
// Presentation
import { SearchController } from './presentation/controllers/search.controller';
import { LoggerService } from '@modules/shared/infrastructure/logger.service';
const CommandHandlers = [SyncListingHandler, ReindexAllHandler];
const QueryHandlers = [SearchPropertiesHandler, GeoSearchHandler];
@Module({
imports: [CqrsModule],
controllers: [SearchController],
providers: [
// Infrastructure
TypesenseClientService,
{ provide: SEARCH_REPOSITORY, useClass: TypesenseSearchRepository },
ListingIndexerService,
// Event handlers
ListingApprovedEventHandler,
// CQRS
...CommandHandlers,
...QueryHandlers,
],
exports: [ListingIndexerService, SEARCH_REPOSITORY],
})
export class SearchModule implements OnModuleInit {
constructor(
private readonly typesenseClient: TypesenseClientService,
private readonly searchRepo: TypesenseSearchRepository,
private readonly logger: LoggerService,
) {}
async onModuleInit(): Promise<void> {
try {
await this.searchRepo.ensureCollection();
this.logger.log('Search module initialized — Typesense collection ready', 'SearchModule');
} catch (err) {
this.logger.error(
`Failed to initialize Typesense collection: ${err instanceof Error ? err.message : String(err)}`,
'SearchModule',
);
}
}
}