From 37ce51d657341a617584581547ff7f6efd447e8b Mon Sep 17 00:00:00 2001 From: Big-cedar Date: Thu, 26 Feb 2026 01:20:06 +0100 Subject: [PATCH] feat(search): implement unified search module with pg full-text search --- backend/src/app.module.ts | 2 + backend/src/search/dto/search-query.dto.ts | 30 ++++++ backend/src/search/dto/search-result.dto.ts | 12 +++ .../search/interfaces/searchable.interface.ts | 8 ++ backend/src/search/search.controller.ts | 40 ++++++++ backend/src/search/search.module.ts | 93 ++++++++++++++++++ backend/src/search/search.service.ts | 95 +++++++++++++++++++ 7 files changed, 280 insertions(+) create mode 100644 backend/src/search/dto/search-query.dto.ts create mode 100644 backend/src/search/dto/search-result.dto.ts create mode 100644 backend/src/search/interfaces/searchable.interface.ts create mode 100644 backend/src/search/search.controller.ts create mode 100644 backend/src/search/search.module.ts create mode 100644 backend/src/search/search.service.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 3d224a05..6eb9e0cb 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -17,6 +17,7 @@ import { ContactModule } from './contact/contact.module'; import { CategoriesModule } from './categories/categories.module'; import { ProductsModule } from './products/products.module'; import { AnalyticsModule } from './analytics/analytics.module'; +import { SearchModule } from './search/search.module'; @Module({ imports: [ @@ -88,6 +89,7 @@ import { AnalyticsModule } from './analytics/analytics.module'; DashboardModule, ProductsModule, AnalyticsModule, + SearchModule, ], controllers: [AppController], providers: [ diff --git a/backend/src/search/dto/search-query.dto.ts b/backend/src/search/dto/search-query.dto.ts new file mode 100644 index 00000000..76bcaaaf --- /dev/null +++ b/backend/src/search/dto/search-query.dto.ts @@ -0,0 +1,30 @@ +import { IsOptional, IsString, IsNumber, Min, IsArray } from 'class-validator'; +import { Type, Transform } from 'class-transformer'; + +export class SearchQueryDto { + @IsString() + q: string; + + @IsOptional() + @Transform(({ value }) => { + if (typeof value === 'string') { + return value.split(',').map((t) => t.trim()); + } + return value; + }) + @IsArray() + @IsString({ each: true }) + types?: string[]; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + page?: number = 1; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + limit?: number = 10; +} diff --git a/backend/src/search/dto/search-result.dto.ts b/backend/src/search/dto/search-result.dto.ts new file mode 100644 index 00000000..5f2b8845 --- /dev/null +++ b/backend/src/search/dto/search-result.dto.ts @@ -0,0 +1,12 @@ +export class SearchResultDto { + type: string; + id: string; + title: string; + description: string; + url: string; + score: number; + + constructor(partial: Partial) { + Object.assign(this, partial); + } +} diff --git a/backend/src/search/interfaces/searchable.interface.ts b/backend/src/search/interfaces/searchable.interface.ts new file mode 100644 index 00000000..7c1ddc76 --- /dev/null +++ b/backend/src/search/interfaces/searchable.interface.ts @@ -0,0 +1,8 @@ +import { SearchResultDto } from '../dto/search-result.dto'; + +export type SearchQueryFn = (query: string, limit: number) => Promise; + +export interface Searchable { + registerSearchable(type: string, queryFn: SearchQueryFn): void; + getSearchableTypes(): string[]; +} diff --git a/backend/src/search/search.controller.ts b/backend/src/search/search.controller.ts new file mode 100644 index 00000000..5cab609e --- /dev/null +++ b/backend/src/search/search.controller.ts @@ -0,0 +1,40 @@ +import { Controller, Get, Post, Query, Param, Body, BadRequestException } from '@nestjs/common'; +import { SearchService } from './search.service'; +import { SearchQueryDto } from './dto/search-query.dto'; +import { SearchResultDto } from './dto/search-result.dto'; + +@Controller('search') +export class SearchController { + constructor(private readonly searchService: SearchService) {} + + @Get() + async search(@Query() query: SearchQueryDto): Promise { + return this.searchService.search(query); + } + + @Get('types') + getTypes() { + return this.searchService.getSearchableTypes().map(type => ({ + type, + enabled: this.searchService.isTypeEnabled(type) + })); + } + + @Post('types/:type/enable') + enableType(@Param('type') type: string) { + const success = this.searchService.enableType(type); + if (!success) { + throw new BadRequestException(`Type '${type}' not found`); + } + return { message: `Search for type '${type}' enabled` }; + } + + @Post('types/:type/disable') + disableType(@Param('type') type: string) { + const success = this.searchService.disableType(type); + if (!success) { + throw new BadRequestException(`Type '${type}' not found`); + } + return { message: `Search for type '${type}' disabled` }; + } +} diff --git a/backend/src/search/search.module.ts b/backend/src/search/search.module.ts new file mode 100644 index 00000000..8d0fb9f1 --- /dev/null +++ b/backend/src/search/search.module.ts @@ -0,0 +1,93 @@ +import { Module, OnModuleInit, Logger } from '@nestjs/common'; +import { SearchController } from './search.controller'; +import { SearchService } from './search.service'; +import { DataSource } from 'typeorm'; +import { SearchResultDto } from './dto/search-result.dto'; + +@Module({ + controllers: [SearchController], + providers: [SearchService], + exports: [SearchService], +}) +export class SearchModule implements OnModuleInit { + private readonly logger = new Logger(SearchModule.name); + + constructor( + private readonly searchService: SearchService, + private readonly dataSource: DataSource, + ) {} + + onModuleInit() { + this.registerProductsSearch(); + this.registerUsersSearch(); + } + + private registerProductsSearch() { + this.searchService.registerSearchable('products', async (query, limit) => { + try { + const sql = ` + SELECT + id, + name as title, + ts_headline('english', coalesce(description, ''), plainto_tsquery('english', $1)) as description, + 'products' as type, + '/products/' || id as url, + ts_rank(to_tsvector('english', name || ' ' || coalesce(description, '')), plainto_tsquery('english', $1)) as score + FROM products + WHERE to_tsvector('english', name || ' ' || coalesce(description, '')) @@ plainto_tsquery('english', $1) + ORDER BY score DESC + LIMIT $2 + `; + + const results = await this.dataSource.query(sql, [query, limit]); + + return results.map(r => new SearchResultDto({ + type: r.type, + id: r.id, + title: r.title, + description: r.description, + url: r.url, + score: r.score + })); + } catch (error) { + this.logger.error(`Error searching products: ${error.message}`); + return []; + } + }); + } + + private registerUsersSearch() { + this.searchService.registerSearchable('users', async (query, limit) => { + try { + // Based on User entity: firstname, lastname, email + const sql = ` + SELECT + id, + firstname || ' ' || lastname as title, + email as description, + 'users' as type, + '/users/' || id as url, + ts_rank(to_tsvector('english', coalesce(firstname, '') || ' ' || coalesce(lastname, '') || ' ' || coalesce(email, '')), plainto_tsquery('english', $1)) as score + FROM users + WHERE to_tsvector('english', coalesce(firstname, '') || ' ' || coalesce(lastname, '') || ' ' || coalesce(email, '')) @@ plainto_tsquery('english', $1) + ORDER BY score DESC + LIMIT $2 + `; + + const results = await this.dataSource.query(sql, [query, limit]); + + return results.map(r => new SearchResultDto({ + type: r.type, + id: r.id, + title: r.title, + description: r.description, + url: r.url, + score: r.score + })); + } catch (error) { + this.logger.error(`Error searching users: ${error.message}`); + return []; + } + }); + } +} diff --git a/backend/src/search/search.service.ts b/backend/src/search/search.service.ts new file mode 100644 index 00000000..a14c8257 --- /dev/null +++ b/backend/src/search/search.service.ts @@ -0,0 +1,95 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { SearchQueryDto } from './dto/search-query.dto'; +import { SearchResultDto } from './dto/search-result.dto'; +import { SearchQueryFn } from './interfaces/searchable.interface'; + +@Injectable() +export class SearchService { + private readonly logger = new Logger(SearchService.name); + private searchables: Map = new Map(); + private disabledTypes: Set = new Set(); + + registerSearchable(type: string, queryFn: SearchQueryFn) { + this.logger.log(`Registering searchable type: ${type}`); + this.searchables.set(type, queryFn); + } + + getSearchableTypes(): string[] { + return Array.from(this.searchables.keys()); + } + + enableType(type: string) { + if (this.searchables.has(type)) { + this.disabledTypes.delete(type); + this.logger.log(`Enabled search for type: ${type}`); + return true; + } + return false; + } + + disableType(type: string) { + if (this.searchables.has(type)) { + this.disabledTypes.add(type); + this.logger.log(`Disabled search for type: ${type}`); + return true; + } + return false; + } + + isTypeEnabled(type: string): boolean { + return this.searchables.has(type) && !this.disabledTypes.has(type); + } + + async search(queryDto: SearchQueryDto): Promise { + const { q, types, page = 1, limit = 10 } = queryDto; + const query = q.trim(); + + if (!query) { + return []; + } + + // Determine which types to search + const availableTypes = Array.from(this.searchables.keys()); + let typesToSearch = availableTypes; + + if (types && types.length > 0) { + // Filter requested types to ensure they are registered + typesToSearch = types.filter(t => this.searchables.has(t)); + } + + // Filter out disabled types + typesToSearch = typesToSearch.filter(t => !this.disabledTypes.has(t)); + + if (typesToSearch.length === 0) { + return []; + } + + this.logger.log(`Searching for '${query}' in types: ${typesToSearch.join(', ')}`); + + // We fetch (page * limit) items from each provider to ensure we have enough candidates for merging + // This is a heuristic; for perfect pagination, we'd need a more complex strategy. + const fetchLimit = page * limit; + + const promises = typesToSearch.map(async (type) => { + const fn = this.searchables.get(type); + try { + return await fn(query, fetchLimit); + } catch (error) { + this.logger.error(`Error searching type ${type}: ${error.message}`); + return []; + } + }); + + const results = await Promise.all(promises); + const flatResults = results.flat(); + + // Sort by score descending + flatResults.sort((a, b) => b.score - a.score); + + // Apply pagination to the merged results + const startIndex = (page - 1) * limit; + const endIndex = startIndex + limit; + + return flatResults.slice(startIndex, endIndex); + } +}