Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -88,6 +89,7 @@ import { AnalyticsModule } from './analytics/analytics.module';
DashboardModule,
ProductsModule,
AnalyticsModule,
SearchModule,
],
controllers: [AppController],
providers: [
Expand Down
30 changes: 30 additions & 0 deletions backend/src/search/dto/search-query.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
12 changes: 12 additions & 0 deletions backend/src/search/dto/search-result.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export class SearchResultDto {
type: string;
id: string;
title: string;
description: string;
url: string;
score: number;

constructor(partial: Partial<SearchResultDto>) {
Object.assign(this, partial);
}
}
8 changes: 8 additions & 0 deletions backend/src/search/interfaces/searchable.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { SearchResultDto } from '../dto/search-result.dto';

export type SearchQueryFn = (query: string, limit: number) => Promise<SearchResultDto[]>;

export interface Searchable {
registerSearchable(type: string, queryFn: SearchQueryFn): void;
getSearchableTypes(): string[];
}
40 changes: 40 additions & 0 deletions backend/src/search/search.controller.ts
Original file line number Diff line number Diff line change
@@ -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<SearchResultDto[]> {
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` };
}
}
93 changes: 93 additions & 0 deletions backend/src/search/search.module.ts
Original file line number Diff line number Diff line change
@@ -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 [];
}
});
}
}
95 changes: 95 additions & 0 deletions backend/src/search/search.service.ts
Original file line number Diff line number Diff line change
@@ -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<string, SearchQueryFn> = new Map();
private disabledTypes: Set<string> = 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<SearchResultDto[]> {
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);
}
}