From 6741592cbecc6202f74b249ef084f50e254b94f5 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Wed, 8 Apr 2026 01:46:20 +0700 Subject: [PATCH] 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 --- apps/api/src/app.module.ts | 4 + .../reindex-all/reindex-all.command.ts | 1 + .../reindex-all/reindex-all.handler.ts | 17 ++ .../sync-listing/sync-listing.command.ts | 3 + .../sync-listing/sync-listing.handler.ts | 12 + .../src/modules/search/application/index.ts | 8 + .../queries/geo-search/geo-search.handler.ts | 43 ++++ .../queries/geo-search/geo-search.query.ts | 14 ++ .../search-properties.handler.ts | 57 +++++ .../search-properties.query.ts | 17 ++ apps/api/src/modules/search/domain/index.ts | 2 + .../search/domain/repositories/index.ts | 1 + .../domain/repositories/search.repository.ts | 60 +++++ .../domain/value-objects/geo-filter.vo.ts | 60 +++++ .../search/domain/value-objects/index.ts | 2 + .../domain/value-objects/search-filter.vo.ts | 75 ++++++ apps/api/src/modules/search/index.ts | 1 + .../infrastructure/event-handlers/index.ts | 1 + .../listing-approved.handler.ts | 30 +++ .../modules/search/infrastructure/index.ts | 2 + .../search/infrastructure/services/index.ts | 3 + .../services/listing-indexer.service.ts | 220 ++++++++++++++++++ .../services/typesense-client.service.ts | 32 +++ .../services/typesense-search.repository.ts | 174 ++++++++++++++ .../search/presentation/controllers/index.ts | 1 + .../controllers/search.controller.ts | 72 ++++++ .../search/presentation/dto/geo-search.dto.ts | 76 ++++++ .../modules/search/presentation/dto/index.ts | 2 + .../presentation/dto/search-properties.dto.ts | 88 +++++++ .../src/modules/search/presentation/index.ts | 2 + apps/api/src/modules/search/search.module.ts | 63 +++++ 31 files changed, 1143 insertions(+) create mode 100644 apps/api/src/modules/search/application/commands/reindex-all/reindex-all.command.ts create mode 100644 apps/api/src/modules/search/application/commands/reindex-all/reindex-all.handler.ts create mode 100644 apps/api/src/modules/search/application/commands/sync-listing/sync-listing.command.ts create mode 100644 apps/api/src/modules/search/application/commands/sync-listing/sync-listing.handler.ts create mode 100644 apps/api/src/modules/search/application/index.ts create mode 100644 apps/api/src/modules/search/application/queries/geo-search/geo-search.handler.ts create mode 100644 apps/api/src/modules/search/application/queries/geo-search/geo-search.query.ts create mode 100644 apps/api/src/modules/search/application/queries/search-properties/search-properties.handler.ts create mode 100644 apps/api/src/modules/search/application/queries/search-properties/search-properties.query.ts create mode 100644 apps/api/src/modules/search/domain/index.ts create mode 100644 apps/api/src/modules/search/domain/repositories/index.ts create mode 100644 apps/api/src/modules/search/domain/repositories/search.repository.ts create mode 100644 apps/api/src/modules/search/domain/value-objects/geo-filter.vo.ts create mode 100644 apps/api/src/modules/search/domain/value-objects/index.ts create mode 100644 apps/api/src/modules/search/domain/value-objects/search-filter.vo.ts create mode 100644 apps/api/src/modules/search/index.ts create mode 100644 apps/api/src/modules/search/infrastructure/event-handlers/index.ts create mode 100644 apps/api/src/modules/search/infrastructure/event-handlers/listing-approved.handler.ts create mode 100644 apps/api/src/modules/search/infrastructure/index.ts create mode 100644 apps/api/src/modules/search/infrastructure/services/index.ts create mode 100644 apps/api/src/modules/search/infrastructure/services/listing-indexer.service.ts create mode 100644 apps/api/src/modules/search/infrastructure/services/typesense-client.service.ts create mode 100644 apps/api/src/modules/search/infrastructure/services/typesense-search.repository.ts create mode 100644 apps/api/src/modules/search/presentation/controllers/index.ts create mode 100644 apps/api/src/modules/search/presentation/controllers/search.controller.ts create mode 100644 apps/api/src/modules/search/presentation/dto/geo-search.dto.ts create mode 100644 apps/api/src/modules/search/presentation/dto/index.ts create mode 100644 apps/api/src/modules/search/presentation/dto/search-properties.dto.ts create mode 100644 apps/api/src/modules/search/presentation/index.ts create mode 100644 apps/api/src/modules/search/search.module.ts diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 8b667bb..2296f2f 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -1,5 +1,7 @@ import { SharedModule } from '@modules/shared'; import { AuthModule } from '@modules/auth'; +import { ListingsModule } from '@modules/listings'; +import { SearchModule } from '@modules/search'; import { NotificationsModule } from '@modules/notifications'; import { Module } from '@nestjs/common'; import { APP_GUARD } from '@nestjs/core'; @@ -13,6 +15,8 @@ import { AppController } from './app.controller'; CqrsModule.forRoot(), SharedModule, AuthModule, + ListingsModule, + SearchModule, NotificationsModule, // ── Rate Limiting ── diff --git a/apps/api/src/modules/search/application/commands/reindex-all/reindex-all.command.ts b/apps/api/src/modules/search/application/commands/reindex-all/reindex-all.command.ts new file mode 100644 index 0000000..6a1a37f --- /dev/null +++ b/apps/api/src/modules/search/application/commands/reindex-all/reindex-all.command.ts @@ -0,0 +1 @@ +export class ReindexAllCommand {} diff --git a/apps/api/src/modules/search/application/commands/reindex-all/reindex-all.handler.ts b/apps/api/src/modules/search/application/commands/reindex-all/reindex-all.handler.ts new file mode 100644 index 0000000..1d32f1c --- /dev/null +++ b/apps/api/src/modules/search/application/commands/reindex-all/reindex-all.handler.ts @@ -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 { + constructor(private readonly indexer: ListingIndexerService) {} + + async execute(): Promise { + return this.indexer.reindexAll(); + } +} diff --git a/apps/api/src/modules/search/application/commands/sync-listing/sync-listing.command.ts b/apps/api/src/modules/search/application/commands/sync-listing/sync-listing.command.ts new file mode 100644 index 0000000..7a8ed33 --- /dev/null +++ b/apps/api/src/modules/search/application/commands/sync-listing/sync-listing.command.ts @@ -0,0 +1,3 @@ +export class SyncListingCommand { + constructor(public readonly listingId: string) {} +} diff --git a/apps/api/src/modules/search/application/commands/sync-listing/sync-listing.handler.ts b/apps/api/src/modules/search/application/commands/sync-listing/sync-listing.handler.ts new file mode 100644 index 0000000..7e16618 --- /dev/null +++ b/apps/api/src/modules/search/application/commands/sync-listing/sync-listing.handler.ts @@ -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 { + constructor(private readonly indexer: ListingIndexerService) {} + + async execute(command: SyncListingCommand): Promise { + await this.indexer.indexListing(command.listingId); + } +} diff --git a/apps/api/src/modules/search/application/index.ts b/apps/api/src/modules/search/application/index.ts new file mode 100644 index 0000000..f0d824b --- /dev/null +++ b/apps/api/src/modules/search/application/index.ts @@ -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'; diff --git a/apps/api/src/modules/search/application/queries/geo-search/geo-search.handler.ts b/apps/api/src/modules/search/application/queries/geo-search/geo-search.handler.ts new file mode 100644 index 0000000..c1b310f --- /dev/null +++ b/apps/api/src/modules/search/application/queries/geo-search/geo-search.handler.ts @@ -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 { + constructor( + @Inject(SEARCH_REPOSITORY) private readonly searchRepo: ISearchRepository, + ) {} + + async execute(query: GeoSearchQuery): Promise { + 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), + }); + } +} diff --git a/apps/api/src/modules/search/application/queries/geo-search/geo-search.query.ts b/apps/api/src/modules/search/application/queries/geo-search/geo-search.query.ts new file mode 100644 index 0000000..8bffdda --- /dev/null +++ b/apps/api/src/modules/search/application/queries/geo-search/geo-search.query.ts @@ -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, + ) {} +} diff --git a/apps/api/src/modules/search/application/queries/search-properties/search-properties.handler.ts b/apps/api/src/modules/search/application/queries/search-properties/search-properties.handler.ts new file mode 100644 index 0000000..cfc9973 --- /dev/null +++ b/apps/api/src/modules/search/application/queries/search-properties/search-properties.handler.ts @@ -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 { + constructor( + @Inject(SEARCH_REPOSITORY) private readonly searchRepo: ISearchRepository, + ) {} + + async execute(query: SearchPropertiesQuery): Promise { + 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, + }); + } +} diff --git a/apps/api/src/modules/search/application/queries/search-properties/search-properties.query.ts b/apps/api/src/modules/search/application/queries/search-properties/search-properties.query.ts new file mode 100644 index 0000000..9e9c67c --- /dev/null +++ b/apps/api/src/modules/search/application/queries/search-properties/search-properties.query.ts @@ -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, + ) {} +} diff --git a/apps/api/src/modules/search/domain/index.ts b/apps/api/src/modules/search/domain/index.ts new file mode 100644 index 0000000..f03dba7 --- /dev/null +++ b/apps/api/src/modules/search/domain/index.ts @@ -0,0 +1,2 @@ +export * from './value-objects'; +export * from './repositories'; diff --git a/apps/api/src/modules/search/domain/repositories/index.ts b/apps/api/src/modules/search/domain/repositories/index.ts new file mode 100644 index 0000000..0e577c9 --- /dev/null +++ b/apps/api/src/modules/search/domain/repositories/index.ts @@ -0,0 +1 @@ +export { SEARCH_REPOSITORY, type ISearchRepository, type ListingDocument, type SearchResult, type SearchParams } from './search.repository'; diff --git a/apps/api/src/modules/search/domain/repositories/search.repository.ts b/apps/api/src/modules/search/domain/repositories/search.repository.ts new file mode 100644 index 0000000..eb1012a --- /dev/null +++ b/apps/api/src/modules/search/domain/repositories/search.repository.ts @@ -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; + indexDocuments(docs: ListingDocument[]): Promise; + removeDocument(id: string): Promise; + search(params: SearchParams): Promise; + ensureCollection(): Promise; + dropCollection(): Promise; +} diff --git a/apps/api/src/modules/search/domain/value-objects/geo-filter.vo.ts b/apps/api/src/modules/search/domain/value-objects/geo-filter.vo.ts new file mode 100644 index 0000000..f4dbb25 --- /dev/null +++ b/apps/api/src/modules/search/domain/value-objects/geo-filter.vo.ts @@ -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 { + 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); + } +} diff --git a/apps/api/src/modules/search/domain/value-objects/index.ts b/apps/api/src/modules/search/domain/value-objects/index.ts new file mode 100644 index 0000000..bce3bd6 --- /dev/null +++ b/apps/api/src/modules/search/domain/value-objects/index.ts @@ -0,0 +1,2 @@ +export { SearchFilter } from './search-filter.vo'; +export { GeoFilter } from './geo-filter.vo'; diff --git a/apps/api/src/modules/search/domain/value-objects/search-filter.vo.ts b/apps/api/src/modules/search/domain/value-objects/search-filter.vo.ts new file mode 100644 index 0000000..260972a --- /dev/null +++ b/apps/api/src/modules/search/domain/value-objects/search-filter.vo.ts @@ -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 { + 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); + } +} diff --git a/apps/api/src/modules/search/index.ts b/apps/api/src/modules/search/index.ts new file mode 100644 index 0000000..6cddc83 --- /dev/null +++ b/apps/api/src/modules/search/index.ts @@ -0,0 +1 @@ +export { SearchModule } from './search.module'; diff --git a/apps/api/src/modules/search/infrastructure/event-handlers/index.ts b/apps/api/src/modules/search/infrastructure/event-handlers/index.ts new file mode 100644 index 0000000..9fe08d4 --- /dev/null +++ b/apps/api/src/modules/search/infrastructure/event-handlers/index.ts @@ -0,0 +1 @@ +export { ListingApprovedEventHandler } from './listing-approved.handler'; diff --git a/apps/api/src/modules/search/infrastructure/event-handlers/listing-approved.handler.ts b/apps/api/src/modules/search/infrastructure/event-handlers/listing-approved.handler.ts new file mode 100644 index 0000000..4cd4f1a --- /dev/null +++ b/apps/api/src/modules/search/infrastructure/event-handlers/listing-approved.handler.ts @@ -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 { + 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 { + 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 { + this.logger.log(`Handling listing.deactivated for ${payload.listingId}`, 'ListingApprovedHandler'); + await this.indexer.removeListing(payload.listingId); + } +} diff --git a/apps/api/src/modules/search/infrastructure/index.ts b/apps/api/src/modules/search/infrastructure/index.ts new file mode 100644 index 0000000..8aab521 --- /dev/null +++ b/apps/api/src/modules/search/infrastructure/index.ts @@ -0,0 +1,2 @@ +export * from './services'; +export * from './event-handlers'; diff --git a/apps/api/src/modules/search/infrastructure/services/index.ts b/apps/api/src/modules/search/infrastructure/services/index.ts new file mode 100644 index 0000000..c9a087c --- /dev/null +++ b/apps/api/src/modules/search/infrastructure/services/index.ts @@ -0,0 +1,3 @@ +export { TypesenseClientService } from './typesense-client.service'; +export { TypesenseSearchRepository } from './typesense-search.repository'; +export { ListingIndexerService } from './listing-indexer.service'; diff --git a/apps/api/src/modules/search/infrastructure/services/listing-indexer.service.ts b/apps/api/src/modules/search/infrastructure/services/listing-indexer.service.ts new file mode 100644 index 0000000..71d10eb --- /dev/null +++ b/apps/api/src/modules/search/infrastructure/services/listing-indexer.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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[]) : [], + }; + } + +} diff --git a/apps/api/src/modules/search/infrastructure/services/typesense-client.service.ts b/apps/api/src/modules/search/infrastructure/services/typesense-client.service.ts new file mode 100644 index 0000000..a85060b --- /dev/null +++ b/apps/api/src/modules/search/infrastructure/services/typesense-client.service.ts @@ -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; + } +} diff --git a/apps/api/src/modules/search/infrastructure/services/typesense-search.repository.ts b/apps/api/src/modules/search/infrastructure/services/typesense-search.repository.ts new file mode 100644 index 0000000..9f1c590 --- /dev/null +++ b/apps/api/src/modules/search/infrastructure/services/typesense-search.repository.ts @@ -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 { + 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 { + 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 { + await this.client + .collections(COLLECTION_NAME) + .documents() + .upsert(doc as unknown as Record); + } + + async indexDocuments(docs: ListingDocument[]): Promise { + if (docs.length === 0) return; + + const results = await this.client + .collections(COLLECTION_NAME) + .documents() + .import(docs as unknown as Record[], { 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 { + 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 { + 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: '', + highlight_end_tag: '', + }; + + 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'; + } + } +} diff --git a/apps/api/src/modules/search/presentation/controllers/index.ts b/apps/api/src/modules/search/presentation/controllers/index.ts new file mode 100644 index 0000000..d44b466 --- /dev/null +++ b/apps/api/src/modules/search/presentation/controllers/index.ts @@ -0,0 +1 @@ +export { SearchController } from './search.controller'; diff --git a/apps/api/src/modules/search/presentation/controllers/search.controller.ts b/apps/api/src/modules/search/presentation/controllers/search.controller.ts new file mode 100644 index 0000000..96e4c22 --- /dev/null +++ b/apps/api/src/modules/search/presentation/controllers/search.controller.ts @@ -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 { + 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 { + 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 { + return this.commandBus.execute(new ReindexAllCommand()); + } +} diff --git a/apps/api/src/modules/search/presentation/dto/geo-search.dto.ts b/apps/api/src/modules/search/presentation/dto/geo-search.dto.ts new file mode 100644 index 0000000..74888a6 --- /dev/null +++ b/apps/api/src/modules/search/presentation/dto/geo-search.dto.ts @@ -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; +} diff --git a/apps/api/src/modules/search/presentation/dto/index.ts b/apps/api/src/modules/search/presentation/dto/index.ts new file mode 100644 index 0000000..3dd5af9 --- /dev/null +++ b/apps/api/src/modules/search/presentation/dto/index.ts @@ -0,0 +1,2 @@ +export { SearchPropertiesDto, SortByOption } from './search-properties.dto'; +export { GeoSearchDto, GeoSortByOption } from './geo-search.dto'; diff --git a/apps/api/src/modules/search/presentation/dto/search-properties.dto.ts b/apps/api/src/modules/search/presentation/dto/search-properties.dto.ts new file mode 100644 index 0000000..fe130ce --- /dev/null +++ b/apps/api/src/modules/search/presentation/dto/search-properties.dto.ts @@ -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; +} diff --git a/apps/api/src/modules/search/presentation/index.ts b/apps/api/src/modules/search/presentation/index.ts new file mode 100644 index 0000000..5f229e9 --- /dev/null +++ b/apps/api/src/modules/search/presentation/index.ts @@ -0,0 +1,2 @@ +export * from './controllers'; +export * from './dto'; diff --git a/apps/api/src/modules/search/search.module.ts b/apps/api/src/modules/search/search.module.ts new file mode 100644 index 0000000..57deafe --- /dev/null +++ b/apps/api/src/modules/search/search.module.ts @@ -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 { + 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', + ); + } + } +}