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:
@@ -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 ──
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export class ReindexAllCommand {}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export class SyncListingCommand {
|
||||||
|
constructor(public readonly listingId: string) {}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
8
apps/api/src/modules/search/application/index.ts
Normal file
8
apps/api/src/modules/search/application/index.ts
Normal 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';
|
||||||
@@ -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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
1
apps/api/src/modules/search/index.ts
Normal file
1
apps/api/src/modules/search/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { SearchModule } from './search.module';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { ListingApprovedEventHandler } from './listing-approved.handler';
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
2
apps/api/src/modules/search/infrastructure/index.ts
Normal file
2
apps/api/src/modules/search/infrastructure/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './services';
|
||||||
|
export * from './event-handlers';
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { TypesenseClientService } from './typesense-client.service';
|
||||||
|
export { TypesenseSearchRepository } from './typesense-search.repository';
|
||||||
|
export { ListingIndexerService } from './listing-indexer.service';
|
||||||
@@ -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[]) : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { SearchController } from './search.controller';
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
2
apps/api/src/modules/search/presentation/dto/index.ts
Normal file
2
apps/api/src/modules/search/presentation/dto/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { SearchPropertiesDto, SortByOption } from './search-properties.dto';
|
||||||
|
export { GeoSearchDto, GeoSortByOption } from './geo-search.dto';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
2
apps/api/src/modules/search/presentation/index.ts
Normal file
2
apps/api/src/modules/search/presentation/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './controllers';
|
||||||
|
export * from './dto';
|
||||||
63
apps/api/src/modules/search/search.module.ts
Normal file
63
apps/api/src/modules/search/search.module.ts
Normal 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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user