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 { 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 ──
|
||||
|
||||
@@ -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