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:
@@ -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';
|
||||
Reference in New Issue
Block a user